diff --git a/packages/oneclient_app/src/cli.rs b/packages/oneclient_app/src/cli.rs index d7a41ec3..5f0f39a4 100644 --- a/packages/oneclient_app/src/cli.rs +++ b/packages/oneclient_app/src/cli.rs @@ -47,8 +47,14 @@ mod tests { #[test] fn both_spellings_carry_the_folder() { - assert_eq!(parse(&["--launch", "fabric-1-20"]).launch.as_deref(), Some("fabric-1-20")); - assert_eq!(parse(&["--launch=fabric-1-20"]).launch.as_deref(), Some("fabric-1-20")); + assert_eq!( + parse(&["--launch", "fabric-1-20"]).launch.as_deref(), + Some("fabric-1-20") + ); + assert_eq!( + parse(&["--launch=fabric-1-20"]).launch.as_deref(), + Some("fabric-1-20") + ); } #[test] @@ -68,7 +74,12 @@ mod tests { #[test] fn unknown_flags_are_ignored_not_fatal() { - assert_eq!(parse(&["--verbose", "--launch", "x", "leftover"]).launch.as_deref(), Some("x")); + assert_eq!( + parse(&["--verbose", "--launch", "x", "leftover"]) + .launch + .as_deref(), + Some("x") + ); } #[test] diff --git a/packages/oneclient_app/src/components/account_switcher.rs b/packages/oneclient_app/src/components/account_switcher.rs index 0f18cc61..494fcdc6 100644 --- a/packages/oneclient_app/src/components/account_switcher.rs +++ b/packages/oneclient_app/src/components/account_switcher.rs @@ -6,7 +6,6 @@ use freya::{ use oneclient_auth::MinecraftAccount; use crate::{ - ui::{divider}, Route, components::{Avatar, Button, Icon, IconType, OverlayPopup}, hooks::{ @@ -14,6 +13,7 @@ use crate::{ use_accounts, use_current_account, use_dispatch, use_set_default_account, }, theme::colors, + ui::divider, }; #[derive(PartialEq)] diff --git a/packages/oneclient_app/src/components/active_cluster_panel.rs b/packages/oneclient_app/src/components/active_cluster_panel.rs index 9b81eb45..bc920463 100644 --- a/packages/oneclient_app/src/components/active_cluster_panel.rs +++ b/packages/oneclient_app/src/components/active_cluster_panel.rs @@ -5,7 +5,10 @@ use freya::router::RouterContext; use crate::components::{Button, Icon, IconType}; use oneclient_common::parse_mc_version; -use crate::hooks::{settled_or_loading, use_active_cluster_id, use_clusters, use_dispatch, use_game_snapshot, use_launcher, use_version_metadata}; +use crate::hooks::{ + settled_or_loading, use_active_cluster_id, use_clusters, use_dispatch, use_game_snapshot, + use_launcher, use_version_metadata, +}; use crate::routes::Route; use crate::theme::colors; use crate::utils::sort_clusters_for_home; diff --git a/packages/oneclient_app/src/components/button.rs b/packages/oneclient_app/src/components/button.rs index b4276b9a..479203bd 100644 --- a/packages/oneclient_app/src/components/button.rs +++ b/packages/oneclient_app/src/components/button.rs @@ -56,7 +56,7 @@ pub struct Button { tooltip: Option>, tooltip_placement: TooltipPlacement, on_press: Option>>, - overflow: Overflow, + overflow: Overflow, elements: Vec, key: DiffKey, @@ -118,7 +118,7 @@ impl Button { tooltip_placement: TooltipPlacement::default(), on_press: None, elements: Vec::new(), - overflow: Overflow::Clip, + overflow: Overflow::Clip, key: DiffKey::None, cursor_icon: CursorIcon::Pointer, } @@ -158,10 +158,10 @@ impl Button { self } - pub fn overflow(mut self, overflow: Overflow) -> Self { - self.overflow = overflow; - self - } + pub fn overflow(mut self, overflow: Overflow) -> Self { + self.overflow = overflow; + self + } pub fn font_size(mut self, font_size: impl Into) -> Self { self.text_style.font_size = Some(font_size.into().into()); diff --git a/packages/oneclient_app/src/components/charts.rs b/packages/oneclient_app/src/components/charts.rs index 7b6d89c1..cd0e918d 100644 --- a/packages/oneclient_app/src/components/charts.rs +++ b/packages/oneclient_app/src/components/charts.rs @@ -368,7 +368,7 @@ impl Component for BarChart { rect() .width(Size::fill()) .height(Size::px(h)) - .corner_radius(CornerRadius::new(4., 4., 0., 0.)) + .corner_radius(CornerRadius::new(4., 4., 0., 0.)) .background(color), ), ); diff --git a/packages/oneclient_app/src/components/cluster_update_popup.rs b/packages/oneclient_app/src/components/cluster_update_popup.rs index 24623a54..156a52f4 100644 --- a/packages/oneclient_app/src/components/cluster_update_popup.rs +++ b/packages/oneclient_app/src/components/cluster_update_popup.rs @@ -4,7 +4,9 @@ use freya::{prelude::*, router::RouterContext}; use oneclient_content::packages::{CachedPackageMeta, ProviderId}; use crate::components::{Button, Icon, IconType, OverlayPopup, ScrollArea, TabBar, TabItem}; -use crate::hooks::{package_meta_batch, use_dispatch, use_notifications_snapshot, use_package_meta_batch}; +use crate::hooks::{ + package_meta_batch, use_dispatch, use_notifications_snapshot, use_package_meta_batch, +}; use crate::notifications::{ClusterUpdateItem, ClusterUpdateSummary}; use crate::routes::Route; use crate::theme::colors; @@ -355,11 +357,7 @@ fn change_list( } /// Multi-cluster stand-in for the footer's "Open cluster" shortcut -fn cluster_header( - group: &ClusterGroup, - first: bool, - dispatch: crate::Actions, -) -> impl IntoElement { +fn cluster_header(group: &ClusterGroup, first: bool, dispatch: crate::Actions) -> impl IntoElement { let cluster_id = group.cluster_id; let count = group.names.len(); diff --git a/packages/oneclient_app/src/components/control_center.rs b/packages/oneclient_app/src/components/control_center.rs index 1fce04f4..8b2c430c 100644 --- a/packages/oneclient_app/src/components/control_center.rs +++ b/packages/oneclient_app/src/components/control_center.rs @@ -120,7 +120,12 @@ impl Component for AccountHeader { let unusable = query_error(¤t).is_some(); let account = try_default_account(¤t).or_else(|| { unusable - .then(|| try_accounts(&accounts_query).unwrap_or_default().into_iter().next()) + .then(|| { + try_accounts(&accounts_query) + .unwrap_or_default() + .into_iter() + .next() + }) .flatten() }); @@ -164,11 +169,7 @@ impl Component for AccountHeader { .cross_align(Alignment::Center) .spacing(10.) .padding(Gaps::new_all(4.)) - .child( - Avatar::new(uuid) - .width(Size::px(40.)) - .height(Size::px(40.)), - ) + .child(Avatar::new(uuid).width(Size::px(40.)).height(Size::px(40.))) .child( rect() .vertical() @@ -257,11 +258,7 @@ impl Component for NavRow { .on_pointer_enter(move |_| hovered.set(true)) .on_pointer_leave(move |_| hovered.set(false)) .on_press(go) - .child( - Icon::new(self.icon) - .size(20.) - .color(colors::fg_secondary()), - ) + .child(Icon::new(self.icon).size(20.).color(colors::fg_secondary())) .child( label() .text(self.row_label) @@ -619,9 +616,7 @@ impl Component for FooterAction { .a11y_role(AccessibilityRole::Button) .on_pointer_enter(move |_| hovered.set(true)) .on_pointer_leave(move |_| hovered.set(false)) - .map(handler, |el, handler| { - el.on_press(move |e| handler.call(e)) - }) + .map(handler, |el, handler| el.on_press(move |e| handler.call(e))) .child(Icon::new(self.icon).size(16.).color(self.color)) .child( label() diff --git a/packages/oneclient_app/src/components/date_range_picker.rs b/packages/oneclient_app/src/components/date_range_picker.rs index 56410f5c..753e6d2e 100644 --- a/packages/oneclient_app/src/components/date_range_picker.rs +++ b/packages/oneclient_app/src/components/date_range_picker.rs @@ -270,8 +270,7 @@ fn month_grid( outside: day.month0() != shown.month0(), enabled: (min..=max).contains(&day), in_range: selected.is_some_and(|(a, b)| (a..=b).contains(&day)), - edge: selected.is_some_and(|(a, b)| day == a || day == b) - || pending == Some(day), + edge: selected.is_some_and(|(a, b)| day == a || day == b) || pending == Some(day), pick: pick.clone(), }); } @@ -349,7 +348,11 @@ impl Component for DayCell { } } -fn footer(has_range: bool, pending: Option, mut clear: impl FnMut() + 'static) -> Element { +fn footer( + has_range: bool, + pending: Option, + mut clear: impl FnMut() + 'static, +) -> Element { let hint = match pending { Some(day) => format!("From {} — pick an end date", format_day(day)), None => "Click a start date, then an end date".to_string(), diff --git a/packages/oneclient_app/src/components/dynamic_art.rs b/packages/oneclient_app/src/components/dynamic_art.rs index 261814aa..11d108a7 100644 --- a/packages/oneclient_app/src/components/dynamic_art.rs +++ b/packages/oneclient_app/src/components/dynamic_art.rs @@ -1,9 +1,9 @@ use bytes::Bytes; use freya::prelude::*; -use oneclient_core::clusters::Cluster; -use oneclient_core::images::DEFAULT_IMAGE_EDGE; use oneclient_common::domain::GameLoader; use oneclient_common::{VersionKey, parse_mc_version}; +use oneclient_core::clusters::Cluster; +use oneclient_core::images::DEFAULT_IMAGE_EDGE; use crate::AppAssets; use crate::hooks::{loaded_image, use_cached_image, use_version_metadata}; diff --git a/packages/oneclient_app/src/components/file_drop.rs b/packages/oneclient_app/src/components/file_drop.rs index e10dc522..c80d4509 100644 --- a/packages/oneclient_app/src/components/file_drop.rs +++ b/packages/oneclient_app/src/components/file_drop.rs @@ -7,8 +7,8 @@ use std::path::{Path, PathBuf}; use freya::animation::{AnimNum, Ease, Function, OnCreation, use_animation}; use freya::prelude::*; use freya::router::use_route; -use oneclient_core::clusters::Cluster; use oneclient_content::packages::ContentType; +use oneclient_core::clusters::Cluster; use oneclient_db::models::ClusterId; use crate::Route; diff --git a/packages/oneclient_app/src/components/generic_prompt.rs b/packages/oneclient_app/src/components/generic_prompt.rs index 96b22e2b..8ac8f7ea 100644 --- a/packages/oneclient_app/src/components/generic_prompt.rs +++ b/packages/oneclient_app/src/components/generic_prompt.rs @@ -79,9 +79,7 @@ impl Component for GenericPromptOverlay { .pick_folder() .await { - dispatch.answer_prompt( - Answer::new(id).with_folder(handle.path()), - ); + dispatch.answer_prompt(Answer::new(id).with_folder(handle.path())); } }); } diff --git a/packages/oneclient_app/src/components/java_prompt.rs b/packages/oneclient_app/src/components/java_prompt.rs index 1a180caf..995e0edd 100644 --- a/packages/oneclient_app/src/components/java_prompt.rs +++ b/packages/oneclient_app/src/components/java_prompt.rs @@ -1,6 +1,6 @@ use freya::prelude::*; -use oneclient_java::{JAVA_CHOICE_DOWNLOAD, JAVA_CHOICE_FOLDER, JavaVendor}; use oneclient_events::Answer; +use oneclient_java::{JAVA_CHOICE_DOWNLOAD, JAVA_CHOICE_FOLDER, JavaVendor}; use crate::components::{Button, Icon, IconType, JavaInstallManager, OverlayPopup}; use crate::hooks::{use_dispatch, use_notifications_snapshot}; @@ -65,9 +65,8 @@ impl Component for JavaPromptOverlay { .pick_folder() .await { - dispatch.answer_prompt( - Answer::new(JAVA_CHOICE_FOLDER).with_folder(handle.path()), - ); + dispatch + .answer_prompt(Answer::new(JAVA_CHOICE_FOLDER).with_folder(handle.path())); } }); }; @@ -124,9 +123,7 @@ impl Component for JavaPromptOverlay { .child( Button::new() .secondary() - .on_press(move |_| { - cancel.dismiss_prompt() - }) + .on_press(move |_| cancel.dismiss_prompt()) .text("Cancel"), ) .child( diff --git a/packages/oneclient_app/src/components/markdown/image.rs b/packages/oneclient_app/src/components/markdown/image.rs index d1f1c0ae..1599ddd9 100644 --- a/packages/oneclient_app/src/components/markdown/image.rs +++ b/packages/oneclient_app/src/components/markdown/image.rs @@ -195,8 +195,7 @@ impl ElementExt for ScaledImageElement { let image = &self.image_handle.image; let natural = Size2D::new(image.width() as f32, image.height() as f32); - let available = - (*context.area_size - context.torin_node.margin.into()).max(Size2D::zero()); + let available = (*context.area_size - context.torin_node.margin.into()).max(Size2D::zero()); let ratio = (available.width / natural.width).min(1.); let ratio = if ratio.is_finite() && ratio > 0. { @@ -256,8 +255,8 @@ impl ElementExt for ScaledImageElement { #[cfg(test)] mod tests { - use freya::prelude::Bytes; use freya::engine::prelude::AlphaType; + use freya::prelude::Bytes; use freya_testing::prelude::*; use super::*; diff --git a/packages/oneclient_app/src/components/markdown/mod.rs b/packages/oneclient_app/src/components/markdown/mod.rs index b281dba7..482003ec 100644 --- a/packages/oneclient_app/src/components/markdown/mod.rs +++ b/packages/oneclient_app/src/components/markdown/mod.rs @@ -115,4 +115,3 @@ impl Component for Markdown { self.key.clone().or(self.default_key()) } } - diff --git a/packages/oneclient_app/src/components/markdown/parse.rs b/packages/oneclient_app/src/components/markdown/parse.rs index 13a87f99..099a9ef6 100644 --- a/packages/oneclient_app/src/components/markdown/parse.rs +++ b/packages/oneclient_app/src/components/markdown/parse.rs @@ -274,7 +274,10 @@ impl Walker { } else { std::mem::take(&mut self.image_alt) }; - self.place(Inline::Image { url, alt }, |url, alt| Block::Image { url, alt }); + self.place(Inline::Image { url, alt }, |url, alt| Block::Image { + url, + alt, + }); } TagEnd::Link => { self.in_link = false; @@ -488,18 +491,27 @@ mod tests { #[test] fn inline_html_keeps_the_text_it_wrapped() { - assert_eq!(paragraph_text(&blocks("a bold word")[0]), "a bold word"); + assert_eq!( + paragraph_text(&blocks("a bold word")[0]), + "a bold word" + ); } #[test] fn block_html_keeps_its_text() { - assert_eq!(paragraph_text(&blocks("
\nhello\n
")[0]).trim(), "hello"); + assert_eq!( + paragraph_text(&blocks("
\nhello\n
")[0]).trim(), + "hello" + ); } #[test] fn images_arrive_as_paragraph_content() { assert_eq!(paragraph_text(&blocks("![alt](a.png)")[0]), "[img:alt]"); - assert_eq!(paragraph_text(&blocks("see ![alt](a.png) here")[0]), "see [img:alt] here"); + assert_eq!( + paragraph_text(&blocks("see ![alt](a.png) here")[0]), + "see [img:alt] here" + ); } #[test] @@ -530,7 +542,8 @@ mod tests { #[test] fn tables_split_head_from_body() { - let Block::Table { headers, rows } = &blocks("| a | b |\n| - | - |\n| 1 | 2 |\n| 3 | 4 |")[0] + let Block::Table { headers, rows } = + &blocks("| a | b |\n| - | - |\n| 1 | 2 |\n| 3 | 4 |")[0] else { panic!("expected a table"); }; diff --git a/packages/oneclient_app/src/components/markdown/render.rs b/packages/oneclient_app/src/components/markdown/render.rs index bc968ce1..55928863 100644 --- a/packages/oneclient_app/src/components/markdown/render.rs +++ b/packages/oneclient_app/src/components/markdown/render.rs @@ -7,12 +7,15 @@ use crate::hooks::LinkConfirmState; pub fn render_block(block: &Block, key: usize, style: &MarkdownStyle) -> Element { match block { - Block::Heading { level, spans } => { - render_spans(spans, style.heading_size(*level), style.color, style.color_code) - .font_weight(FontWeight::BOLD) - .key(key) - .into() - } + Block::Heading { level, spans } => render_spans( + spans, + style.heading_size(*level), + style.color, + style.color_code, + ) + .font_weight(FontWeight::BOLD) + .key(key) + .into(), Block::Paragraph { content } => render_content(content, style.paragraph_size, style) .key(key) .into(), @@ -53,7 +56,9 @@ pub fn render_block(block: &Block, key: usize, style: &MarkdownStyle) -> Element .alignment(BorderAlignment::Inner), ) .background(style.background_blockquote) - .child(render_content(content, style.paragraph_size, style).font_slant(FontSlant::Italic)) + .child( + render_content(content, style.paragraph_size, style).font_slant(FontSlant::Italic), + ) .into(), Block::Rule => rect() .key(key) @@ -215,12 +220,7 @@ impl KeyExt for MarkdownLink { } impl MarkdownLink { - fn new( - url: String, - title: Option, - content: Vec, - style: MarkdownStyle, - ) -> Self { + fn new(url: String, title: Option, content: Vec, style: MarkdownStyle) -> Self { Self { url, title, @@ -242,18 +242,24 @@ impl Component for MarkdownLink { let mut text = paragraph().font_size(self.style.paragraph_size); for item in &self.content { text = match item { - Inline::Span(span) => { - text.span(styled_span(span, self.style.color_link, self.style.color_code)) - } + Inline::Span(span) => text.span(styled_span( + span, + self.style.color_link, + self.style.color_code, + )), Inline::Image { url, alt } => { text.child(MarkdownImage::new(url.clone(), alt.clone())) } - Inline::Link { content, .. } => content.iter().fold(text, |text, item| match item { - Inline::Span(span) => { - text.span(styled_span(span, self.style.color_link, self.style.color_code)) - } - _ => text, - }), + Inline::Link { content, .. } => { + content.iter().fold(text, |text, item| match item { + Inline::Span(span) => text.span(styled_span( + span, + self.style.color_link, + self.style.color_code, + )), + _ => text, + }) + } }; } diff --git a/packages/oneclient_app/src/components/microsoft_login.rs b/packages/oneclient_app/src/components/microsoft_login.rs index aabe3044..10042c03 100644 --- a/packages/oneclient_app/src/components/microsoft_login.rs +++ b/packages/oneclient_app/src/components/microsoft_login.rs @@ -1,9 +1,9 @@ +use crate::state::LoginProgress; use freya::prelude::*; use freya::query::{MutationCapability, MutationStateData, UseMutation}; use freya::text_edit::Clipboard; -use oneclient_core::LauncherError; use oneclient_auth::{AuthErrorGuidance, MicrosoftLoginSession}; -use crate::state::LoginProgress; +use oneclient_core::LauncherError; use crate::components::{Button, Icon, IconType, OverlayPopup}; use crate::hooks::{ @@ -225,10 +225,7 @@ pub(crate) fn login_dialog( .spacing(18.) .child(browser_dialog_body(auth_url)) .child(dialog_divider()) - .child(device_code_dialog_body( - user_code, - verification_uri, - )) + .child(device_code_dialog_body(user_code, verification_uri)) .child(status_row(status, error)) .maybe_child(guidance.map(guidance_block)), ), @@ -314,17 +311,13 @@ fn status_row(status: Option, error: Option) -> impl Into } fn guidance_block(guidance: AuthErrorGuidance) -> impl IntoElement { - let mut steps = rect() - .vertical() - .width(Size::fill()) - .spacing(6.) - .child( - label() - .text("What you can do:") - .font_size(12.) - .font_weight(FontWeight::SEMI_BOLD) - .color(colors::fg_primary()), - ); + let mut steps = rect().vertical().width(Size::fill()).spacing(6.).child( + label() + .text("What you can do:") + .font_size(12.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ); for (index, step) in guidance.steps_to_fix.into_iter().enumerate() { steps = steps.child( diff --git a/packages/oneclient_app/src/components/mod.rs b/packages/oneclient_app/src/components/mod.rs index 5dd33b9e..bdb89577 100644 --- a/packages/oneclient_app/src/components/mod.rs +++ b/packages/oneclient_app/src/components/mod.rs @@ -1,5 +1,4 @@ mod account_switcher; -mod control_center; mod active_cluster_panel; mod avatar; mod button; @@ -8,24 +7,25 @@ mod checkbox; mod cluster_landscape_art; mod cluster_update_popup; mod context_menu; +mod control_center; mod date_range_picker; -mod optional_mods_popup; mod dropdown; mod dynamic_art; mod file_drop; +mod generic_prompt; mod icons; mod java_install_manager; -mod generic_prompt; mod java_prompt; -mod microsoft_java_prompt; mod link_confirm; mod local_image; mod log_viewer; mod markdown; mod memory_field; +mod microsoft_java_prompt; mod microsoft_login; mod navbar; mod notifications; +mod optional_mods_popup; mod overlay_popup; mod package_row; mod package_update_popup; @@ -44,11 +44,10 @@ mod toasts; mod toggle; mod tooltip; mod update_prompt; -mod version_card; pub mod upload_mclogs; +mod version_card; pub use account_switcher::AccountSwitcher; -pub use control_center::ControlCenter; pub use active_cluster_panel::ActiveClusterPanel; pub use avatar::Avatar; pub use button::{Button, ButtonSize, ButtonVariant, link_button, open_folder_button}; @@ -57,29 +56,30 @@ pub use checkbox::checkbox_labeled; pub use cluster_landscape_art::ClusterLandscapeArt; pub use cluster_update_popup::ClusterUpdatePopup; pub use context_menu::ContextMenu; +pub use control_center::ControlCenter; pub use date_range_picker::{DateRange, DateRangePicker}; -pub use optional_mods_popup::OptionalModsPopup; pub use dropdown::Dropdown; pub use dynamic_art::{ART_PREVIEW_EDGE, DynamicArt}; pub use file_drop::FileDropOverlay; +pub use generic_prompt::GenericPromptOverlay; pub use icons::{Icon, IconTint, IconType}; pub use java_install_manager::JavaInstallManager; -pub use generic_prompt::GenericPromptOverlay; pub use java_prompt::JavaPromptOverlay; -pub use microsoft_java_prompt::MicrosoftJavaPromptOverlay; pub use link_confirm::ConfirmLinkOverlay; pub use local_image::LocalImage; -pub use markdown::{Markdown, MarkdownStyle}; pub use log_viewer::LogViewer; +pub use markdown::{Markdown, MarkdownStyle}; pub use memory_field::memory_field; -pub use microsoft_login::use_microsoft_login; +pub use microsoft_java_prompt::MicrosoftJavaPromptOverlay; pub(crate) use microsoft_login::login_dialog; +pub use microsoft_login::use_microsoft_login; pub(crate) use navbar::window_controls; pub use navbar::{AppNavbar, OnboardingNavbar}; pub use notifications::NotificationCenter; +pub use optional_mods_popup::OptionalModsPopup; pub use overlay_popup::{OVERLAY_BASE_LEVEL, OVERLAY_MAX_LEVEL, OverlayPopup, overlay_is_topmost}; -pub use package_row::{CardLayout, PackageEntry, PackageRow, provider_badge}; pub(crate) use package_row::{CARD_GRID_H, GRID_GAP, GRID_MIN_W, grid_card, package_icon}; +pub use package_row::{CardLayout, PackageEntry, PackageRow, provider_badge}; pub use package_update_popup::PackageUpdatePopup; pub use pagination::Pagination; pub use player_model::PlayerModel; diff --git a/packages/oneclient_app/src/components/navbar/app_navbar.rs b/packages/oneclient_app/src/components/navbar/app_navbar.rs index 304f967d..86e703ad 100644 --- a/packages/oneclient_app/src/components/navbar/app_navbar.rs +++ b/packages/oneclient_app/src/components/navbar/app_navbar.rs @@ -125,10 +125,10 @@ fn navbar_center(is_small: bool) -> impl IntoElement { }) .cross_align(Alignment::Center) .spacing(if is_small { - NAV_LINK_SPACING_PX - } else { - NAV_LINK_SPACING_PX / 2. - }) + NAV_LINK_SPACING_PX + } else { + NAV_LINK_SPACING_PX / 2. + }) .child(NavLink { active: route == Route::Home {}, target: NavTarget::Route(Route::Home {}), @@ -308,7 +308,7 @@ impl Component for NavbarRight { ) .child( super::navbar_button() - .overflow(Overflow::None) + .overflow(Overflow::None) .tooltip("Control Center") .padding(0.0) .on_press(open_control_center) @@ -323,11 +323,7 @@ fn avatar_with_gear(uuid: String) -> impl IntoElement { .width(Size::px(28.)) .height(Size::px(28.)) .center() - .child( - Avatar::new(uuid) - .width(Size::px(28.)) - .height(Size::px(28.)), - ) + .child(Avatar::new(uuid).width(Size::px(28.)).height(Size::px(28.))) .child( rect() .position(Position::new_absolute().bottom(-7.).right(-7.)) diff --git a/packages/oneclient_app/src/components/notifications.rs b/packages/oneclient_app/src/components/notifications.rs index b8d40b1d..6469ac41 100644 --- a/packages/oneclient_app/src/components/notifications.rs +++ b/packages/oneclient_app/src/components/notifications.rs @@ -5,12 +5,12 @@ use freya::{ use oneclient_events::Level; use crate::{ - ui::{divider, relative_time}, components::{Button, ButtonVariant, Icon, IconType, OverlayPopup, ScrollArea, progress_track}, hooks::{use_dispatch, use_notifications_snapshot}, notifications::{InboxEntry, NotificationActionKind}, theme::colors, transfer::TransferStats, + ui::{divider, relative_time}, utils::{format_durations, format_size}, }; @@ -212,7 +212,9 @@ fn tasks_section(entry: &InboxEntry, mut expanded: State) -> impl IntoElem let now = !*expanded.peek(); expanded.set(now); }) - .child(ChevronToggle { expanded: is_expanded }) + .child(ChevronToggle { + expanded: is_expanded, + }) .child( label() .text(if is_expanded { @@ -525,4 +527,3 @@ fn level_color(level: &Level) -> Color { Level::Error => colors::danger(), } } - diff --git a/packages/oneclient_app/src/components/package_update_popup.rs b/packages/oneclient_app/src/components/package_update_popup.rs index 89907681..8b82a30a 100644 --- a/packages/oneclient_app/src/components/package_update_popup.rs +++ b/packages/oneclient_app/src/components/package_update_popup.rs @@ -8,7 +8,9 @@ use oneclient_content::packages::{CachedPackageMeta, ProviderId}; use oneclient_core::{BrowserPackageUpdate, ProfileUpdate}; use oneclient_db::models::ClusterId; -use crate::components::{Button, Dropdown, Icon, IconType, OverlayPopup, ScrollArea, checkbox_labeled}; +use crate::components::{ + Button, Dropdown, Icon, IconType, OverlayPopup, ScrollArea, checkbox_labeled, +}; use crate::hooks::{ package_meta_batch, use_dispatch, use_notifications_snapshot, use_package_meta_batch, }; @@ -210,9 +212,7 @@ fn content( .main_align(Alignment::Start) .cross_align(Alignment::Center) .spacing(6.) - .child( - checkbox_labeled(dont_show_again, "Don't show again?") - ) + .child(checkbox_labeled(dont_show_again, "Don't show again?")), ) .child( rect() @@ -267,7 +267,7 @@ fn content( }) .child(Icon::new(IconType::DownloadCloud02).size(15.)) .text("Proceed"), - ) + ), ), ) } diff --git a/packages/oneclient_app/src/components/pagination.rs b/packages/oneclient_app/src/components/pagination.rs index 5e9f1211..d19669c6 100644 --- a/packages/oneclient_app/src/components/pagination.rs +++ b/packages/oneclient_app/src/components/pagination.rs @@ -52,26 +52,24 @@ impl Component for Pagination { let current = *page.read(); let last = total_pages.saturating_sub(1); - let icon_btn = move |target: usize, - available: bool, - icon: IconType, - tooltip: &'static str| { - let mut page = page; - let enabled = available && interactive; - Button::new() - .secondary() - .icon() - .enabled(enabled) - .tooltip(tooltip) - .tooltip_placement(TooltipPlacement::Top) - .on_press(move |_| page.set(target)) - .child(Icon::new(icon).size(14.).color(if enabled { - colors::fg_primary() - } else { - colors::fg_secondary().with_a(90) - })) - .into_element() - }; + let icon_btn = + move |target: usize, available: bool, icon: IconType, tooltip: &'static str| { + let mut page = page; + let enabled = available && interactive; + Button::new() + .secondary() + .icon() + .enabled(enabled) + .tooltip(tooltip) + .tooltip_placement(TooltipPlacement::Top) + .on_press(move |_| page.set(target)) + .child(Icon::new(icon).size(14.).color(if enabled { + colors::fg_primary() + } else { + colors::fg_secondary().with_a(90) + })) + .into_element() + }; let num_btn = move |target: usize| { let mut page = page; diff --git a/packages/oneclient_app/src/components/recents_row.rs b/packages/oneclient_app/src/components/recents_row.rs index 191b1e72..a5022bd2 100644 --- a/packages/oneclient_app/src/components/recents_row.rs +++ b/packages/oneclient_app/src/components/recents_row.rs @@ -35,10 +35,8 @@ impl Component for RecentsRow { let sorted: Vec = sort_clusters_for_home(clusters); - let slots = recent_card_slots_for_window( - root_size.read().width, - *scale_factor.read() as f32, - ); + let slots = + recent_card_slots_for_window(root_size.read().width, *scale_factor.read() as f32); // Budget off cluster count not slot count an offset-only change makes the layout // engine reuse a card's cached area freezing survivors at pre-resize widths @@ -255,7 +253,10 @@ impl Component for ClusterCard { .height(Size::fill()) .position(Position::new_absolute()) .layer(Layer::Relative(1)) - .child(DynamicArt::for_cluster(&self.cluster).max_edge(ART_PREVIEW_EDGE)), + .child( + DynamicArt::for_cluster(&self.cluster) + .max_edge(ART_PREVIEW_EDGE), + ), ) .child( rect() diff --git a/packages/oneclient_app/src/components/scrollview.rs b/packages/oneclient_app/src/components/scrollview.rs index cadeed8e..e2762600 100644 --- a/packages/oneclient_app/src/components/scrollview.rs +++ b/packages/oneclient_app/src/components/scrollview.rs @@ -11,11 +11,11 @@ pub(crate) fn normalize_wheel_delta(delta: f64, scale_factor: f64) -> f32 { return 0.0; } - const FREYA_LINE_SPEED: f64 = 53.0; - const FREYA_PIXEL_SPEED: f64 = 2.0; - const LINE_DELTA_TOLERANCE: f64 = 1e-6; - const LINE_SCROLL: f64 = 53.0; - const PIXEL_SCROLL: f64 = 1.0; + const FREYA_LINE_SPEED: f64 = 53.0; + const FREYA_PIXEL_SPEED: f64 = 2.0; + const LINE_DELTA_TOLERANCE: f64 = 1e-6; + const LINE_SCROLL: f64 = 53.0; + const PIXEL_SCROLL: f64 = 1.0; let lines = (delta / FREYA_LINE_SPEED).round(); if lines != 0.0 && (delta - lines * FREYA_LINE_SPEED).abs() <= LINE_DELTA_TOLERANCE { diff --git a/packages/oneclient_app/src/components/toasts.rs b/packages/oneclient_app/src/components/toasts.rs index 8a63f263..56babcee 100644 --- a/packages/oneclient_app/src/components/toasts.rs +++ b/packages/oneclient_app/src/components/toasts.rs @@ -10,12 +10,12 @@ use freya::{ use oneclient_events::Level; use crate::{ - ui::{divider}, components::{Button, ButtonSize, Icon, IconType, TooltipPlacement, progress_track}, hooks::{use_dispatch, use_notifications_snapshot}, motion::use_animations_enabled, notifications::{InboxEntry, NotificationActionKind}, theme::colors, + ui::divider, }; const TOAST_W: f32 = 300.; @@ -339,12 +339,7 @@ fn close_button(dispatch: crate::Actions, id: u64) -> impl IntoElement { ) } -fn message_card( - entry: &InboxEntry, - dispatch: crate::Actions, - id: u64, - ttl_pct: f32, -) -> Rect { +fn message_card(entry: &InboxEntry, dispatch: crate::Actions, id: u64, ttl_pct: f32) -> Rect { toast_shell(entry) .child( rect() diff --git a/packages/oneclient_app/src/components/tooltip.rs b/packages/oneclient_app/src/components/tooltip.rs index 029cd694..131735ec 100644 --- a/packages/oneclient_app/src/components/tooltip.rs +++ b/packages/oneclient_app/src/components/tooltip.rs @@ -287,11 +287,19 @@ fn place(anchor: AnchorRect, placement: TooltipPlacement, size: Size2D) -> (f32, let (x, y) = match placement { TooltipPlacement::Top => ( centered_x, - if fits_above || !fits_below { above } else { below }, + if fits_above || !fits_below { + above + } else { + below + }, ), TooltipPlacement::Bottom => ( centered_x, - if fits_below || !fits_above { below } else { above }, + if fits_below || !fits_above { + below + } else { + above + }, ), TooltipPlacement::Left => ( if fits_before || !fits_after { diff --git a/packages/oneclient_app/src/components/upload_mclogs.rs b/packages/oneclient_app/src/components/upload_mclogs.rs index 81ead70a..ac5d2ffa 100644 --- a/packages/oneclient_app/src/components/upload_mclogs.rs +++ b/packages/oneclient_app/src/components/upload_mclogs.rs @@ -14,10 +14,7 @@ pub struct UploadToMclogs { impl UploadToMclogs { pub fn new(has_log: bool, confirm: State>) -> Self { - Self { - has_log, - confirm - } + Self { has_log, confirm } } } diff --git a/packages/oneclient_app/src/constants.rs b/packages/oneclient_app/src/constants.rs index b4762806..0bfe6067 100644 --- a/packages/oneclient_app/src/constants.rs +++ b/packages/oneclient_app/src/constants.rs @@ -2,5 +2,6 @@ pub const WINDOW_APP_ID: &str = "oneclient_app"; pub const WINDOW_TITLE: &str = "OneClient"; pub const UPDATER_PUBKEY: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDFGODk3MkMyMjg0MjFDMDUKUldRRkhFSW93bktKSHpkWjNEMXNzaDVINVpCTU8xSnhuK2RnV0dTZ2FkcFJWbG1zUkhGYTNjaUkK"; -pub const UPDATER_ENDPOINT: &str = "https://github.com/Polyfrost/OneLauncher/releases/latest/download/latest.json"; -pub const RELEASES_URL: &str = "https://github.com/Polyfrost/OneLauncher/releases/latest"; \ No newline at end of file +pub const UPDATER_ENDPOINT: &str = + "https://github.com/Polyfrost/OneLauncher/releases/latest/download/latest.json"; +pub const RELEASES_URL: &str = "https://github.com/Polyfrost/OneLauncher/releases/latest"; diff --git a/packages/oneclient_app/src/events.rs b/packages/oneclient_app/src/events.rs index 56bd0328..d21b5c6e 100644 --- a/packages/oneclient_app/src/events.rs +++ b/packages/oneclient_app/src/events.rs @@ -183,7 +183,10 @@ impl EventPump { guard.game.stages.insert(cluster_id, stage); if stage == LaunchStage::Checking { guard.game.error = None; - guard.game.logs.insert(cluster_id, std::sync::Arc::new(Vec::new())); + guard + .game + .logs + .insert(cluster_id, std::sync::Arc::new(Vec::new())); } } for (cluster_id, line) in logs { @@ -264,7 +267,9 @@ fn reconcile( .collect(); for id in &want { - armed.entry(*id).or_insert_with(|| ToastTimer::armed(paused)); + armed + .entry(*id) + .or_insert_with(|| ToastTimer::armed(paused)); } armed.retain(|id, _| want.contains(id)); } @@ -358,10 +363,7 @@ pub async fn start_launcher( Ok(()) } -pub fn report_startup_failure( - station: &RadioStation, - err: &anyhow::Error, -) { +pub fn report_startup_failure(station: &RadioStation, err: &anyhow::Error) { let message = err.to_string(); tracing::error!("launcher init failed: {err:#}"); @@ -379,7 +381,9 @@ pub fn report_startup_failure( let mut guard = station.write_channel(AppChannel::Notifications); let AppState { - notifications, inbox, .. + notifications, + inbox, + .. } = &mut **guard; notifications.dispatch( inbox, diff --git a/packages/oneclient_app/src/file_content.rs b/packages/oneclient_app/src/file_content.rs index 16ee825f..bf115be7 100644 --- a/packages/oneclient_app/src/file_content.rs +++ b/packages/oneclient_app/src/file_content.rs @@ -104,7 +104,10 @@ pub fn bundle_slug(folder: &str) -> String { pub fn info_plist(name: &str, executable: &str, folder: &str, icon: Option<&str>) -> String { let display = xml_escape(&single_line(name)); let icon_entry = icon.map_or_else(String::new, |icon| { - format!("\tCFBundleIconFile\n\t{}\n", xml_escape(icon)) + format!( + "\tCFBundleIconFile\n\t{}\n", + xml_escape(icon) + ) }); format!( diff --git a/packages/oneclient_app/src/hooks/actions.rs b/packages/oneclient_app/src/hooks/actions.rs index 674a3f17..bdfcbad9 100644 --- a/packages/oneclient_app/src/hooks/actions.rs +++ b/packages/oneclient_app/src/hooks/actions.rs @@ -24,12 +24,12 @@ use oneclient_events::{Answer, Level}; use tokio::sync::mpsc; use crate::components::IconType; -use crate::{invalidate_java_queries, launcher}; use crate::notifications::{ ClusterUpdateSummary, NotificationAction, NotificationSpec, OptionalModRef, OptionalModsGroup, PackageUpdateGroup, PendingPrompt, }; use crate::state::{AppChannel, AppState, AsyncStatus, RelocationState}; +use crate::{invalidate_java_queries, launcher}; /// Over-disabling is the cheaper mistake a dead button beats a second game const LAUNCH_HOLD: Duration = Duration::from_secs(2); @@ -172,7 +172,10 @@ impl Actions { fn with_engine(&self, mutate: impl FnOnce(&mut AppState)) { { - let mut guard = self.station.clone().write_channel(AppChannel::Notifications); + let mut guard = self + .station + .clone() + .write_channel(AppChannel::Notifications); mutate(&mut guard); } self.nudge(PumpSignal::Reconcile); @@ -196,10 +199,8 @@ impl Actions { guard.settings.error = None; } - let loaded = oneclient_core::settings::store::load_settings(Some( - &state.services.events, - )) - .await; + let loaded = + oneclient_core::settings::store::load_settings(Some(&state.services.events)).await; let discord_enabled = loaded.discord_enabled; *state.settings.write() = loaded.clone(); state.discord.set_enabled(discord_enabled); @@ -231,7 +232,10 @@ impl Actions { }); } - fn mutate_settings(&self, mutate: impl FnOnce(&mut LauncherSettings)) -> Option { + fn mutate_settings( + &self, + mutate: impl FnOnce(&mut LauncherSettings), + ) -> Option { let state = launcher::state().ok()?; let updated = { let mut lock = state.settings.write(); @@ -312,7 +316,7 @@ impl Actions { self.persist(updated); } } - + pub fn reset_onboarding(&self) { if let Some(updated) = self.mutate_settings(|settings| { settings.seen_onboarding = false; @@ -561,14 +565,12 @@ impl Actions { Ok(_) => { events.signal(oneclient_events::Signal::JavaChanged); invalidate_java_queries().await - }, - Err(err) => { - events + } + Err(err) => events .notify("Java install failed") .body(err.to_string()) .error() - .send() - } + .send(), } }); } @@ -692,7 +694,11 @@ impl Actions { } pub fn dismiss_toast(&self, entry_id: u64) { - self.with_engine(|state| state.notifications.dismiss_toast(&mut state.inbox, entry_id)); + self.with_engine(|state| { + state + .notifications + .dismiss_toast(&mut state.inbox, entry_id) + }); } pub fn mark_notification_read(&self, entry_id: u64) { @@ -779,10 +785,8 @@ impl Actions { }; let content = state.services.content(); let events = state.services.events.clone(); - let session = oneclient_events::GroupedProgressSession::start( - &events, - "Adding optional mods", - ); + let session = + oneclient_events::GroupedProgressSession::start(&events, "Adding optional mods"); let opt_in = session.child( "Enabling mods", mods.len() as u64, @@ -887,9 +891,7 @@ impl Actions { } pub fn proceed_package_updates(&self, chosen: Vec) { - self.with_engine(move |state| { - state.notifications.proceed_package_updates(chosen) - }); + self.with_engine(move |state| state.notifications.proceed_package_updates(chosen)); } pub fn close_package_updates(&self) { @@ -1005,15 +1007,16 @@ impl Actions { .error = None; } - pub fn import_local_file(&self, cluster_id: ClusterId, content_type: ContentType, path: PathBuf) { + pub fn import_local_file( + &self, + cluster_id: ClusterId, + content_type: ContentType, + path: PathBuf, + ) { self.import_local_files(cluster_id, vec![(path, content_type)]); } - pub fn import_local_files( - &self, - cluster_id: ClusterId, - files: Vec<(PathBuf, ContentType)>, - ) { + pub fn import_local_files(&self, cluster_id: ClusterId, files: Vec<(PathBuf, ContentType)>) { if files.is_empty() { return; } @@ -1042,7 +1045,11 @@ impl Actions { deferred |= live == LiveSync::Deferred; } - notify_import(&events, &report, deferred && state.games.is_active(cluster_id)); + notify_import( + &events, + &report, + deferred && state.games.is_active(cluster_id), + ); super::invalidate_cluster_queries().await; } Err(err) => events @@ -1353,7 +1360,7 @@ impl Actions { .relocation = relocation; } - /// `syncing_bundles` gates every launch button so the readiness check must + /// `syncing_bundles` gates every launch button so the readiness check must /// happen before the flag is raised not inside the task pub fn sync_bundles(&self) { let state = match launcher::state() { @@ -1372,7 +1379,6 @@ impl Actions { .syncing_bundles = true; spawn_forever(async move { - if let Err(err) = state.bundles.sync(&state.services.content()).await { tracing::error!("bundle catalog sync failed: {err:#}"); } @@ -1416,26 +1422,22 @@ impl Actions { .launcher .syncing_bundles = true; - let synced = match tokio::time::timeout(BUNDLE_SYNC_BUDGET, state.bundles.sync(&content)) - .await - { - Ok(Ok(_)) => true, - Ok(Err(err)) => { - tracing::warn!( - cluster_id, - error = %err, - "bundle catalog sync failed, launching against the cached catalog" - ); - false - } - Err(_elapsed) => { - tracing::debug!( - cluster_id, - "bundle catalog sync exceeded its launch budget" - ); - false - } - }; + let synced = + match tokio::time::timeout(BUNDLE_SYNC_BUDGET, state.bundles.sync(&content)).await { + Ok(Ok(_)) => true, + Ok(Err(err)) => { + tracing::warn!( + cluster_id, + error = %err, + "bundle catalog sync failed, launching against the cached catalog" + ); + false + } + Err(_elapsed) => { + tracing::debug!(cluster_id, "bundle catalog sync exceeded its launch budget"); + false + } + }; self.station .clone() @@ -1664,7 +1666,9 @@ impl Actions { let (done, wait) = tokio::sync::oneshot::channel(); self.with_engine(move |state| { - state.notifications.open_optional_mods(vec![group], Some(done)); + state + .notifications + .open_optional_mods(vec![group], Some(done)); state.center_open = false; }); @@ -1678,7 +1682,9 @@ impl Actions { let (done, wait) = tokio::sync::oneshot::channel(); self.with_engine(move |state| { - state.notifications.open_package_updates(vec![group], Some(done)); + state + .notifications + .open_package_updates(vec![group], Some(done)); state.center_open = false; }); @@ -1825,10 +1831,10 @@ fn notify_import(events: &oneclient_events::EventBus, report: &LocalImportReport [only] => Some(format!("Added {}", only.file_name)), rows => Some(format!("Added {} files", rows.len())), }) else { - let reason = report - .failed - .first() - .map_or_else(|| "Nothing could be read".to_string(), |(_, err)| err.to_string()); + let reason = report.failed.first().map_or_else( + || "Nothing could be read".to_string(), + |(_, err)| err.to_string(), + ); events.notify("Import failed").body(reason).error().send(); return; @@ -2056,7 +2062,10 @@ mod tests { #[test] fn every_cluster_holding_the_runtime_is_named() { let profiles = vec![profile("a", Some(JDK_25)), profile("b", Some(JDK_25))]; - let clusters = vec![cluster(1, "Alpha", Some("a")), cluster(2, "Beta", Some("b"))]; + let clusters = vec![ + cluster(1, "Alpha", Some("a")), + cluster(2, "Beta", Some("b")), + ]; assert_eq!( clusters_pinned_to_java(&profiles, &clusters, JDK_25), diff --git a/packages/oneclient_app/src/hooks/mod.rs b/packages/oneclient_app/src/hooks/mod.rs index cf024931..b8e24060 100644 --- a/packages/oneclient_app/src/hooks/mod.rs +++ b/packages/oneclient_app/src/hooks/mod.rs @@ -1,6 +1,6 @@ +mod actions; mod active_cluster; mod debounce; -mod actions; mod overlay_claims; mod queries; mod shortcut_actions; @@ -27,43 +27,39 @@ pub use actions::{Actions, NotificationBuilder, PumpSignal}; pub use queries::{ AddOfflineAccountKeys, BROWSE_PAGE_SIZE, BeginMicrosoftLoginMutation, CachedImageQuery, CancelMicrosoftLoginKeys, CancelMicrosoftLoginMutation, ClusterAction, ClusterBundles, - ClusterLogsQuery, FinishMicrosoftLoginMutation, LogAction, LogContentQuery, MigrationQuery, - OnboardingBundlesQuery, RefreshAccountKeys, RemoveAccountKeys, ScreenshotAction, - SetDefaultAccountKeys, StorageAction, StorageActionMutation, StorageReportQuery, TermsQuery, - UploadLogKeys, UploadLogMutation, UseLogAction, - UseRefreshAccount, UseRemoveAccount, UseScreenshotAction, UseSetDefaultAccount, - UseStorageAction, 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, + ClusterLogsQuery, DiscardLeftoversKeys, DiscardLeftoversMutation, FinishMicrosoftLoginMutation, + LeftoversQuery, LogAction, LogContentQuery, MigrationQuery, OnboardingBundlesQuery, + RefreshAccountKeys, RemoveAccountKeys, ScreenshotAction, SetDefaultAccountKeys, StorageAction, + StorageActionMutation, StorageReportQuery, TermsQuery, UploadLogKeys, UploadLogMutation, + UseDiscardLeftovers, UseLogAction, UseRefreshAccount, UseRemoveAccount, UseScreenshotAction, + UseSetDefaultAccount, UseStorageAction, 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_content_queries, - invalidate_cluster_queries, invalidate_java_queries, + invalidate_cluster_queries, invalidate_java_queries, invalidate_leftovers_queries, invalidate_logs_queries, invalidate_profile_queries, invalidate_screenshots_queries, - invalidate_storage_queries, try_storage_report, use_storage_action, use_storage_report, - DiscardLeftoversKeys, DiscardLeftoversMutation, LeftoversQuery, - UseDiscardLeftovers, invalidate_leftovers_queries, mutation_ok, try_leftovers, - use_discard_leftovers, use_leftovers, - java_runtimes, latest_changelog_version, loaded_image, loader_versions, - login_code_already_handled, migration_detection, - mutation_error, mutation_is_pending, mutation_is_running, onboarding_bundles_items, package_meta_batch, - package_updates, pick_version_metadata, project_detail, provider_versions, query_error, - query_is_busy, stale_hashes, use_package_updates, - query_is_loading, reset_login_code_dedup, search_items, search_pending, search_total, - settled_or_loading, terms_document, terms_error, terms_is_loading, 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, + invalidate_storage_queries, java_runtimes, latest_changelog_version, loaded_image, + loader_versions, login_code_already_handled, migration_detection, mutation_error, + mutation_is_pending, mutation_is_running, mutation_ok, onboarding_bundles_items, + package_meta_batch, package_updates, pick_version_metadata, project_detail, provider_versions, + query_error, query_is_busy, query_is_loading, reset_login_code_dedup, search_items, + search_pending, search_total, settled_or_loading, stale_hashes, terms_document, terms_error, + terms_is_loading, try_account, try_accounts, try_cluster_analytics, try_cluster_logs, + try_cluster_screenshots, try_default_account, try_game_profile, try_global_analytics, + try_leftovers, try_log_content, try_storage_report, 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, 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_package_versions_when, - use_player_profile, + use_cancel_microsoft_login, use_changelog, use_cluster, 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_discard_leftovers, use_finish_microsoft_login, use_game_profile, + use_global_analytics, use_java_runtimes, use_leftovers, 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_updates, use_package_versions, use_package_versions_when, use_player_profile, use_player_skin, use_provider_versions, use_refresh_account, use_refresh_all_accounts, - use_remove_account, use_screenshot_action, use_screenshot_folder_watch, use_set_default_account, - use_terms, use_upload_log, + use_remove_account, use_screenshot_action, use_screenshot_folder_watch, + use_set_default_account, use_storage_action, use_storage_report, use_terms, use_upload_log, use_version_metadata, use_versions, version_list, versions_metadata, versions_total, }; @@ -117,7 +113,9 @@ pub fn use_account_switcher_open() -> bool { } pub fn use_control_center_open() -> bool { - use_radio(AppChannel::ControlCenter).read().control_center_open + use_radio(AppChannel::ControlCenter) + .read() + .control_center_open } pub fn use_game_snapshot() -> GameState { @@ -148,4 +146,3 @@ pub fn use_microsoft_login_status() -> Option { .microsoft_login .clone() } - diff --git a/packages/oneclient_app/src/hooks/overlay_claims.rs b/packages/oneclient_app/src/hooks/overlay_claims.rs index 3d226f91..3e7cd207 100644 --- a/packages/oneclient_app/src/hooks/overlay_claims.rs +++ b/packages/oneclient_app/src/hooks/overlay_claims.rs @@ -18,7 +18,10 @@ impl OverlayClaims { fn release(&self) { let held = self.0.get(); - debug_assert!(held > 0, "overlay claim released without a matching acquire"); + debug_assert!( + held > 0, + "overlay claim released without a matching acquire" + ); self.0.set(held.saturating_sub(1)); } } diff --git a/packages/oneclient_app/src/hooks/queries/auth.rs b/packages/oneclient_app/src/hooks/queries/auth.rs index fb5dccf6..d4739c0d 100644 --- a/packages/oneclient_app/src/hooks/queries/auth.rs +++ b/packages/oneclient_app/src/hooks/queries/auth.rs @@ -1,10 +1,11 @@ use std::sync::Mutex; use freya::query::{ - Mutation, MutationCapability, MutationStateData, QueriesStorage, Query, QueryCapability, UseMutation, UseQuery, use_mutation, use_query, + Mutation, MutationCapability, MutationStateData, QueriesStorage, Query, QueryCapability, + UseMutation, UseQuery, use_mutation, use_query, }; -use oneclient_core::LauncherError; use oneclient_auth::{AccountKind, MicrosoftLoginSession, MinecraftAccount}; +use oneclient_core::LauncherError; use uuid::Uuid; static HANDLED_LOGIN_CODE: Mutex> = Mutex::new(None); @@ -132,7 +133,10 @@ impl MutationCapability for BeginMicrosoftLoginMutation { type Keys = (); async fn run(&self, _keys: &Self::Keys) -> Result { - Ok(crate::launcher::state()?.auth.begin_microsoft_login().await?) + Ok(crate::launcher::state()? + .auth + .begin_microsoft_login() + .await?) } } @@ -145,7 +149,10 @@ impl MutationCapability for FinishMicrosoftLoginMutation { type Keys = MicrosoftLoginSession; async fn run(&self, flow: &Self::Keys) -> Result { - Ok(crate::launcher::state()?.auth.finish_microsoft_login(flow.clone()).await?) + Ok(crate::launcher::state()? + .auth + .finish_microsoft_login(flow.clone()) + .await?) } async fn on_settled(&self, _keys: &Self::Keys, result: &Result) { @@ -170,7 +177,10 @@ impl MutationCapability for CancelMicrosoftLoginMutation { type Keys = CancelMicrosoftLoginKeys; async fn run(&self, keys: &Self::Keys) -> Result { - crate::launcher::state()?.auth.cancel_microsoft_login(&keys.state_token).await; + crate::launcher::state()? + .auth + .cancel_microsoft_login(&keys.state_token) + .await; Ok(()) } } @@ -210,7 +220,10 @@ impl MutationCapability for AddOfflineAccountMutation { type Keys = AddOfflineAccountKeys; async fn run(&self, keys: &Self::Keys) -> Result { - Ok(crate::launcher::state()?.auth.add_offline_account(keys.username.clone()).await?) + Ok(crate::launcher::state()? + .auth + .add_offline_account(keys.username.clone()) + .await?) } async fn on_settled(&self, _keys: &Self::Keys, result: &Result) { @@ -234,7 +247,10 @@ impl MutationCapability for RemoveAccountMutation { type Keys = RemoveAccountKeys; async fn run(&self, keys: &Self::Keys) -> Result { - Ok(crate::launcher::state()?.auth.remove_account(keys.id).await?) + Ok(crate::launcher::state()? + .auth + .remove_account(keys.id) + .await?) } async fn on_settled(&self, keys: &Self::Keys, result: &Result) { @@ -258,7 +274,10 @@ impl MutationCapability for SetDefaultAccountMutation { type Keys = SetDefaultAccountKeys; async fn run(&self, keys: &Self::Keys) -> Result { - Ok(crate::launcher::state()?.auth.set_default_account(keys.id).await?) + Ok(crate::launcher::state()? + .auth + .set_default_account(keys.id) + .await?) } async fn on_settled(&self, keys: &Self::Keys, result: &Result) { @@ -282,7 +301,10 @@ impl MutationCapability for RefreshAccountMutation { type Keys = RefreshAccountKeys; async fn run(&self, keys: &Self::Keys) -> Result { - Ok(crate::launcher::state()?.auth.refresh_account(keys.id).await?) + Ok(crate::launcher::state()? + .auth + .refresh_account(keys.id) + .await?) } async fn on_settled(&self, keys: &Self::Keys, result: &Result) { @@ -301,7 +323,10 @@ impl MutationCapability for RefreshAllAccountsMutation { type Keys = (); async fn run(&self, _keys: &Self::Keys) -> Result { - Ok(crate::launcher::state()?.auth.refresh_all_accounts().await?) + Ok(crate::launcher::state()? + .auth + .refresh_all_accounts() + .await?) } async fn on_settled(&self, _keys: &Self::Keys, result: &Result) { diff --git a/packages/oneclient_app/src/hooks/queries/bundles.rs b/packages/oneclient_app/src/hooks/queries/bundles.rs index f24b88cf..7e22aa88 100644 --- a/packages/oneclient_app/src/hooks/queries/bundles.rs +++ b/packages/oneclient_app/src/hooks/queries/bundles.rs @@ -33,7 +33,11 @@ impl QueryCapability for OnboardingBundlesQuery { for cluster in clusters { let archives = state .bundles - .archives_for(&state.services.content(), &cluster.mc_version, cluster.mc_loader) + .archives_for( + &state.services.content(), + &cluster.mc_version, + cluster.mc_loader, + ) .await .unwrap_or_default(); out.push(ClusterBundles { cluster, archives }); @@ -70,14 +74,12 @@ impl QueryCapability for BundlesWithStatusQuery { async fn run(&self, keys: &Self::Keys) -> Result { let _ = keys; let state = crate::launcher::state()?; - Ok( - get_bundles_with_update_status( - self.cluster_id, - state.bundles.as_ref(), - &state.services.content(), - ) - .await?, + Ok(get_bundles_with_update_status( + self.cluster_id, + state.bundles.as_ref(), + &state.services.content(), ) + .await?) } } @@ -112,7 +114,8 @@ impl QueryCapability for BundleOverridesQuery { async fn run(&self, keys: &Self::Keys) -> Result { let _ = keys; let state = crate::launcher::state()?; - let rows = list_cluster_bundle_overrides(self.cluster_id, &state.services.content()).await?; + let rows = + list_cluster_bundle_overrides(self.cluster_id, &state.services.content()).await?; Ok(rows .into_iter() .map(|(bundle, pkg, ty)| ((bundle, pkg), ty)) diff --git a/packages/oneclient_app/src/hooks/queries/changelog.rs b/packages/oneclient_app/src/hooks/queries/changelog.rs index 55d29431..3f4d0eab 100644 --- a/packages/oneclient_app/src/hooks/queries/changelog.rs +++ b/packages/oneclient_app/src/hooks/queries/changelog.rs @@ -1,7 +1,5 @@ use freya::query::{Query, QueryCapability, UseQuery, use_query}; -use oneclient_core::{ - ChangelogGroup, LauncherError, fetch_changelog, parse_changelog, -}; +use oneclient_core::{ChangelogGroup, LauncherError, fetch_changelog, parse_changelog}; #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub struct ChangelogKeys { diff --git a/packages/oneclient_app/src/hooks/queries/cluster_content.rs b/packages/oneclient_app/src/hooks/queries/cluster_content.rs index 33f62445..196d57f5 100644 --- a/packages/oneclient_app/src/hooks/queries/cluster_content.rs +++ b/packages/oneclient_app/src/hooks/queries/cluster_content.rs @@ -22,7 +22,8 @@ impl QueryCapability for ClusterContentQuery { async fn run(&self, _keys: &Self::Keys) -> Result { let state = crate::launcher::state()?; - let all = PackageStore::list_linked_artifacts(self.cluster_id, &state.services.content()).await?; + let all = + PackageStore::list_linked_artifacts(self.cluster_id, &state.services.content()).await?; Ok(all .into_iter() .filter(|item| item.content_type == self.content_type) diff --git a/packages/oneclient_app/src/hooks/queries/image.rs b/packages/oneclient_app/src/hooks/queries/image.rs index 85d2e506..e35c94e2 100644 --- a/packages/oneclient_app/src/hooks/queries/image.rs +++ b/packages/oneclient_app/src/hooks/queries/image.rs @@ -37,7 +37,10 @@ impl QueryCapability for CachedImageQuery { /// Returns the url alongside the bytes callers key their image handle on it /// and a stale url with fresh bytes renders the wrong picture for a frame -pub fn loaded_image(url: Option<&str>, query: &UseQuery) -> Option<(String, Bytes)> { +pub fn loaded_image( + url: Option<&str>, + query: &UseQuery, +) -> Option<(String, Bytes)> { let url = url?; let bytes = super::state::settled_or_loading(query)?; Some((url.to_string(), bytes)) diff --git a/packages/oneclient_app/src/hooks/queries/java.rs b/packages/oneclient_app/src/hooks/queries/java.rs index 5c26f125..b65ec305 100644 --- a/packages/oneclient_app/src/hooks/queries/java.rs +++ b/packages/oneclient_app/src/hooks/queries/java.rs @@ -14,9 +14,7 @@ impl QueryCapability for ListJavaRuntimesQuery { async fn run(&self, _keys: &Self::Keys) -> Result { let state = crate::launcher::state().map_err(|e| e.to_string())?; - state.java.list_runtimes() - .await - .map_err(|e| e.to_string()) + state.java.list_runtimes().await.map_err(|e| e.to_string()) } } @@ -47,7 +45,9 @@ impl QueryCapability for ProviderVersionsQuery { async fn run(&self, keys: &Self::Keys) -> Result { let state = crate::launcher::state().map_err(|e| e.to_string())?; - state.java.available_versions(&keys.vendor) + state + .java + .available_versions(&keys.vendor) .await .map_err(|e| e.to_string()) } diff --git a/packages/oneclient_app/src/hooks/queries/logs.rs b/packages/oneclient_app/src/hooks/queries/logs.rs index 53d1a4b1..c1718ffe 100644 --- a/packages/oneclient_app/src/hooks/queries/logs.rs +++ b/packages/oneclient_app/src/hooks/queries/logs.rs @@ -1,8 +1,8 @@ use std::path::PathBuf; use freya::query::{ - Mutation, MutationCapability, QueriesStorage, Query, QueryCapability, - UseMutation, UseQuery, use_mutation, use_query, + Mutation, MutationCapability, QueriesStorage, Query, QueryCapability, UseMutation, UseQuery, + use_mutation, use_query, }; use oneclient_core::{ LauncherError, LogFileInfo, LogLevel, LogLine, MclogsUploadResponse, ReadOptions, diff --git a/packages/oneclient_app/src/hooks/queries/mod.rs b/packages/oneclient_app/src/hooks/queries/mod.rs index d6efb2f9..d49c26c8 100644 --- a/packages/oneclient_app/src/hooks/queries/mod.rs +++ b/packages/oneclient_app/src/hooks/queries/mod.rs @@ -40,11 +40,11 @@ pub use auth::{ CancelMicrosoftLoginMutation, FinishMicrosoftLoginMutation, RefreshAccountKeys, RemoveAccountKeys, SetDefaultAccountKeys, UseRefreshAccount, UseRemoveAccount, UseSetDefaultAccount, accounts_have_microsoft, login_code_already_handled, mutation_error, - mutation_is_pending, mutation_is_running, mutation_ok, 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, + mutation_is_pending, mutation_is_running, mutation_ok, 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, @@ -52,7 +52,8 @@ pub use bundles::{ use_onboarding_bundles, }; pub use changelog::{ - changelog_error, changelog_groups, changelog_is_loading, latest_changelog_version, use_changelog, + changelog_error, changelog_groups, changelog_is_loading, latest_changelog_version, + use_changelog, }; pub use cluster_content::{cluster_content_items, use_cluster_content}; pub use clusters::{use_cluster, use_clusters}; @@ -67,13 +68,6 @@ pub use logs::{ use_log_action, use_log_content, use_upload_log, }; pub use migration::{MigrationQuery, has_migration_data, migration_detection, use_migration}; -pub use storage::{ - DiscardLeftoversKeys, DiscardLeftoversMutation, LeftoversQuery, - StorageAction, StorageActionMutation, StorageReportQuery, UseDiscardLeftovers, - UseStorageAction, invalidate_leftovers_queries, invalidate_storage_queries, try_leftovers, - try_storage_report, use_discard_leftovers, use_leftovers, use_storage_action, - use_storage_report, -}; pub use mutations::{ ClusterAction, invalidate_cluster_content_queries, invalidate_cluster_queries, invalidate_profile_queries, use_cluster_mutation, @@ -83,8 +77,7 @@ pub use packages::{ BROWSE_PAGE_SIZE, VERSIONS_PAGE_SIZE, category_list, content_type_for_slug, package_meta_batch, project_detail, search_items, search_pending, search_total, use_package_categories, use_package_meta_batch, use_package_project, use_package_search, use_package_versions, - use_package_versions_when, - version_list, versions_total, + use_package_versions_when, version_list, versions_total, }; pub use player_profile::use_player_profile; pub use screenshots::{ @@ -97,6 +90,12 @@ pub use settings_profiles::{ }; pub use skin::use_player_skin; pub use state::{query_error, query_is_busy, query_is_loading, settled_or_loading}; +pub use storage::{ + DiscardLeftoversKeys, DiscardLeftoversMutation, LeftoversQuery, StorageAction, + StorageActionMutation, StorageReportQuery, UseDiscardLeftovers, UseStorageAction, + invalidate_leftovers_queries, invalidate_storage_queries, try_leftovers, try_storage_report, + use_discard_leftovers, use_leftovers, use_storage_action, use_storage_report, +}; pub use tos::{TermsQuery, terms_document, terms_error, terms_is_loading, use_terms}; pub use version_metadata::{pick_version_metadata, use_version_metadata}; pub use versions::{loader_versions, use_loader_versions, use_versions, versions_metadata}; diff --git a/packages/oneclient_app/src/hooks/queries/mutations.rs b/packages/oneclient_app/src/hooks/queries/mutations.rs index e6cdda32..5dbff147 100644 --- a/packages/oneclient_app/src/hooks/queries/mutations.rs +++ b/packages/oneclient_app/src/hooks/queries/mutations.rs @@ -23,12 +23,36 @@ async fn timed(step: &'static str, fut: impl std::future::Future) { pub async fn invalidate_cluster_queries() { let started = std::time::Instant::now(); - timed("cluster_content", QueriesStorage::::invalidate_all()).await; - timed("bundle_overrides", QueriesStorage::::invalidate_all()).await; - timed("bundles_with_status", QueriesStorage::::invalidate_all()).await; - timed("clusters", QueriesStorage::::invalidate_all()).await; - timed("bundle_updates", QueriesStorage::::invalidate_all()).await; - timed("package_updates", QueriesStorage::::invalidate_all()).await; + timed( + "cluster_content", + QueriesStorage::::invalidate_all(), + ) + .await; + timed( + "bundle_overrides", + QueriesStorage::::invalidate_all(), + ) + .await; + timed( + "bundles_with_status", + QueriesStorage::::invalidate_all(), + ) + .await; + timed( + "clusters", + QueriesStorage::::invalidate_all(), + ) + .await; + timed( + "bundle_updates", + QueriesStorage::::invalidate_all(), + ) + .await; + timed( + "package_updates", + QueriesStorage::::invalidate_all(), + ) + .await; tracing::debug!( target: "oneclient_app::perf", ms = started.elapsed().as_millis() as u64, @@ -47,10 +71,26 @@ pub async fn invalidate_cluster_content_queries() { /// The bundle queries do move a toggle writes a bundle override and both read /// those back async fn invalidate_enabled_flag_queries() { - timed("cluster_content", QueriesStorage::::invalidate_all()).await; - timed("bundle_overrides", QueriesStorage::::invalidate_all()).await; - timed("bundles_with_status", QueriesStorage::::invalidate_all()).await; - timed("bundle_updates", QueriesStorage::::invalidate_all()).await; + timed( + "cluster_content", + QueriesStorage::::invalidate_all(), + ) + .await; + timed( + "bundle_overrides", + QueriesStorage::::invalidate_all(), + ) + .await; + timed( + "bundles_with_status", + QueriesStorage::::invalidate_all(), + ) + .await; + timed( + "bundle_updates", + QueriesStorage::::invalidate_all(), + ) + .await; } pub async fn invalidate_profile_queries() { @@ -195,7 +235,13 @@ impl MutationCapability for ClusterMutation { if let Err(err) = result && let Ok(state) = crate::launcher::state() { - state.services.events.notify("Action failed").body(err).error().send(); + state + .services + .events + .notify("Action failed") + .body(err) + .error() + .send(); } if matches!(keys, ClusterAction::SetArtifactEnabled { .. }) { invalidate_enabled_flag_queries().await; diff --git a/packages/oneclient_app/src/hooks/queries/player_profile.rs b/packages/oneclient_app/src/hooks/queries/player_profile.rs index 742b5ad7..ec19930f 100644 --- a/packages/oneclient_app/src/hooks/queries/player_profile.rs +++ b/packages/oneclient_app/src/hooks/queries/player_profile.rs @@ -27,7 +27,10 @@ impl QueryCapability for FetchPlayerProfileQuery { let state = crate::launcher::state()?; let client = &state.services.requester; - Ok(minecraft::fetch_player_profile_view(client, &keys.uuid, access_token.as_deref()).await?) + Ok( + minecraft::fetch_player_profile_view(client, &keys.uuid, access_token.as_deref()) + .await?, + ) } // TODO Cache diff --git a/packages/oneclient_app/src/hooks/queries/screenshots.rs b/packages/oneclient_app/src/hooks/queries/screenshots.rs index c7d2fa1e..29625df6 100644 --- a/packages/oneclient_app/src/hooks/queries/screenshots.rs +++ b/packages/oneclient_app/src/hooks/queries/screenshots.rs @@ -6,8 +6,8 @@ use std::time::Duration; use bytes::Bytes; use freya::prelude::{spawn, use_hook}; use freya::query::{ - Mutation, MutationCapability, QueriesStorage, Query, QueryCapability, - UseMutation, UseQuery, use_mutation, use_query, + Mutation, MutationCapability, QueriesStorage, Query, QueryCapability, UseMutation, UseQuery, + use_mutation, use_query, }; use notify::{EventKind, RecursiveMode, Watcher}; use oneclient_core::{LauncherError, ScreenshotInfo}; @@ -119,15 +119,14 @@ async fn watch_folder( std::fs::create_dir_all(folder)?; let (tx, mut rx) = mpsc::unbounded_channel(); - let mut watcher = notify::recommended_watcher(move |event: notify::Result| { - match event { + let mut watcher = + notify::recommended_watcher(move |event: notify::Result| match event { Ok(event) if touches_image(&event) => { let _ = tx.send(()); } Ok(_) => {} Err(err) => tracing::debug!(error = %err, "screenshot watcher reported an error"), - } - })?; + })?; watcher.watch(folder, RecursiveMode::NonRecursive)?; let mut pending = false; diff --git a/packages/oneclient_app/src/hooks/queries/settings_profiles.rs b/packages/oneclient_app/src/hooks/queries/settings_profiles.rs index 329339e2..6977b829 100644 --- a/packages/oneclient_app/src/hooks/queries/settings_profiles.rs +++ b/packages/oneclient_app/src/hooks/queries/settings_profiles.rs @@ -1,8 +1,8 @@ use freya::query::{Query, QueryCapability, UseQuery, use_query}; -use oneclient_core::settings::GameSettingsProfile; use oneclient_cluster::profiles::{ get_profile_or_default, list_named_profiles, resolve_cluster_profile, }; +use oneclient_core::settings::GameSettingsProfile; use oneclient_db::models::ClusterId; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -110,11 +110,15 @@ impl QueryCapability for ClusterSettingsQuery { async fn run(&self, keys: &Self::Keys) -> Result { let cluster_id = keys.cluster_id; let state = crate::launcher::state().map_err(|e| e.to_string())?; - let cluster = state.clusters.get(cluster_id) + let cluster = state + .clusters + .get(cluster_id) .await .map_err(|e| e.to_string())?; let global = state.settings.read().global_game_settings.clone(); - state.clusters.resolve_settings(&global, &cluster) + state + .clusters + .resolve_settings(&global, &cluster) .await .map_err(|e| e.to_string()) } diff --git a/packages/oneclient_app/src/hooks/queries/version_metadata.rs b/packages/oneclient_app/src/hooks/queries/version_metadata.rs index fb90e3d9..e2f5646c 100644 --- a/packages/oneclient_app/src/hooks/queries/version_metadata.rs +++ b/packages/oneclient_app/src/hooks/queries/version_metadata.rs @@ -1,6 +1,6 @@ use oneclient_common::VersionKey; -use oneclient_core::VersionMetadata; use oneclient_common::domain::GameLoader; +use oneclient_core::VersionMetadata; use super::use_versions; diff --git a/packages/oneclient_app/src/hooks/queries/versions.rs b/packages/oneclient_app/src/hooks/queries/versions.rs index be744e63..d4ff81cf 100644 --- a/packages/oneclient_app/src/hooks/queries/versions.rs +++ b/packages/oneclient_app/src/hooks/queries/versions.rs @@ -15,12 +15,18 @@ impl QueryCapability for VersionsMetadataQuery { async fn run(&self, _keys: &Self::Keys) -> Result { let state = crate::launcher::state()?; - let metadata = state.versions.metadata(&state.services.requester.config().meta_url_base).await; + let metadata = state + .versions + .metadata(&state.services.requester.config().meta_url_base) + .await; if !metadata.is_empty() { return Ok(metadata); } state.versions.sync(&state.services).await?; - Ok(state.versions.metadata(&state.services.requester.config().meta_url_base).await) + Ok(state + .versions + .metadata(&state.services.requester.config().meta_url_base) + .await) } } diff --git a/packages/oneclient_app/src/ipc.rs b/packages/oneclient_app/src/ipc.rs index 015d8f39..b03b02b7 100644 --- a/packages/oneclient_app/src/ipc.rs +++ b/packages/oneclient_app/src/ipc.rs @@ -155,7 +155,10 @@ mod imp { } pub async fn bind() -> Result { - match ServerOptions::new().first_pipe_instance(true).create(ENDPOINT) { + match ServerOptions::new() + .first_pipe_instance(true) + .create(ENDPOINT) + { Ok(server) => Ok(Listener { server }), Err(err) if err.raw_os_error() == Some(ERROR_ACCESS_DENIED) => Err(BindError::Taken), Err(err) => Err(BindError::Io(err)), @@ -244,7 +247,10 @@ mod imp { let listener = UnixListener::bind(path)?; std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; - Ok(Listener { listener, path: path.to_path_buf() }) + Ok(Listener { + listener, + path: path.to_path_buf(), + }) } pub async fn send(request: &str) -> bool { @@ -284,7 +290,10 @@ mod tests { #[test] fn focus_survives_the_wire() { - assert_eq!(IpcCommand::decode(&IpcCommand::Focus.encode()), Some(IpcCommand::Focus)); + assert_eq!( + IpcCommand::decode(&IpcCommand::Focus.encode()), + Some(IpcCommand::Focus) + ); } #[test] @@ -313,7 +322,9 @@ mod tests { fn a_bare_start_asks_only_for_the_window() { assert_eq!(request_for(&Cli::default()), IpcCommand::Focus); assert_eq!( - request_for(&Cli { launch: Some("pack".into()) }), + request_for(&Cli { + launch: Some("pack".into()) + }), IpcCommand::Launch("pack".into()), ); } diff --git a/packages/oneclient_app/src/launcher.rs b/packages/oneclient_app/src/launcher.rs index a8c7db22..8b7bcb4e 100644 --- a/packages/oneclient_app/src/launcher.rs +++ b/packages/oneclient_app/src/launcher.rs @@ -18,8 +18,5 @@ pub fn install(state: Arc) -> Arc { /// Errors with [`LauncherError::NotInitialized`] before startup installs the handle pub fn state() -> LauncherResult> { - LAUNCHER - .get() - .cloned() - .ok_or(LauncherError::NotInitialized) + LAUNCHER.get().cloned().ok_or(LauncherError::NotInitialized) } diff --git a/packages/oneclient_app/src/layout/app_shell.rs b/packages/oneclient_app/src/layout/app_shell.rs index f1ed300f..b6fc9a76 100644 --- a/packages/oneclient_app/src/layout/app_shell.rs +++ b/packages/oneclient_app/src/layout/app_shell.rs @@ -77,7 +77,7 @@ impl Component for AppShell { .on_global_file_hover_cancelled(move |_| drop_hovering.set(false)) .on_file_drop(move |e: Event| { drop_hovering.set(false); - drop_pending.write().extend_from_slice(&e.file_paths); + drop_pending.write().extend_from_slice(&e.file_paths); }) .child(AppNavbar) .child(AppHomeBackground) diff --git a/packages/oneclient_app/src/layout/cluster_shell.rs b/packages/oneclient_app/src/layout/cluster_shell.rs index 8f619cff..85e09b49 100644 --- a/packages/oneclient_app/src/layout/cluster_shell.rs +++ b/packages/oneclient_app/src/layout/cluster_shell.rs @@ -4,7 +4,9 @@ use freya::router::*; use oneclient_common::parse_mc_version; use crate::components::{Button, Icon, IconType, TabBar, TabItem}; -use crate::hooks::{use_cluster, use_dispatch, use_game_snapshot, use_launcher, use_version_metadata}; +use crate::hooks::{ + use_cluster, use_dispatch, use_game_snapshot, use_launcher, use_version_metadata, +}; use crate::routes::Route; use crate::theme::colors; use crate::ui::entrance_motion_layer; @@ -107,7 +109,9 @@ impl Component for ClusterShell { // Queried unconditionally the shell can mount before the cluster list settles // and a conditional hook would change this component's hook count mid-life - let parsed = cluster.as_ref().and_then(|c| parse_mc_version(&c.mc_version)); + let parsed = cluster + .as_ref() + .and_then(|c| parse_mc_version(&c.mc_version)); let metadata = use_version_metadata( parsed.as_ref().map(|p| p.major), parsed.and_then(|p| p.key()), diff --git a/packages/oneclient_app/src/layout/pending_launch.rs b/packages/oneclient_app/src/layout/pending_launch.rs index db28ab66..aa9f6f05 100644 --- a/packages/oneclient_app/src/layout/pending_launch.rs +++ b/packages/oneclient_app/src/layout/pending_launch.rs @@ -117,7 +117,10 @@ fn report_already_open( (None, true) => format!("{} is open already.", cluster.name), (None, false) => format!("{} is on its way up.", cluster.name), (Some(other), true) => { - format!("{other} is open. Close it before launching {}.", cluster.name) + format!( + "{other} is open. Close it before launching {}.", + cluster.name + ) } (Some(other), false) => format!( "{other} is on its way up. Wait for it before launching {}.", diff --git a/packages/oneclient_app/src/layout/root_layout.rs b/packages/oneclient_app/src/layout/root_layout.rs index 8f481f69..e1dffcd8 100644 --- a/packages/oneclient_app/src/layout/root_layout.rs +++ b/packages/oneclient_app/src/layout/root_layout.rs @@ -2,13 +2,13 @@ use freya::prelude::*; use freya::router::*; use crate::components::{ - AccountSwitcher, ClusterUpdatePopup, ControlCenter, GenericPromptOverlay, JavaPromptOverlay, OptionalModsPopup, MicrosoftJavaPromptOverlay, - NotificationCenter, PackageUpdatePopup, SplashCurtain, StatusBar, Toasts, TooltipHost, - UpdatePromptOverlay, use_provide_tooltips, + AccountSwitcher, ClusterUpdatePopup, ControlCenter, GenericPromptOverlay, JavaPromptOverlay, + MicrosoftJavaPromptOverlay, NotificationCenter, OptionalModsPopup, PackageUpdatePopup, + SplashCurtain, StatusBar, Toasts, TooltipHost, UpdatePromptOverlay, use_provide_tooltips, }; -use crate::hooks::{SplashState, use_provide_overlay_claims, use_provide_splash}; #[cfg(not(target_os = "macos"))] use crate::hooks::use_start_maximized; +use crate::hooks::{SplashState, use_provide_overlay_claims, use_provide_splash}; use crate::layout::{HomeArtPrefetch, PendingLaunchDriver}; use crate::motion::AnimationClockDriver; use crate::routes::Route; @@ -26,45 +26,12 @@ impl Component for RootLayout { use_provide_overlay_claims(); use_provide_tooltips(); - // macOS rounds the window natively so Freya must not round on top of it - #[cfg(target_os = "macos")] - let corner = 0.; - - // Elsewhere the window is borderless and squared when maximized - // No reactive maximized signal exists so root_size changes stand in as the trigger - #[cfg(not(target_os = "macos"))] - let corner = { - let root_size = Platform::get().root_size; - // Seeded because the query below only answers after the window is visible - // which would round the corners of the first frames of a maximized launch - let start_maximized = use_start_maximized(); - let mut maximized = use_state(move || start_maximized); - let size = *root_size.read(); - let dep = (size.width as i32, size.height as i32); - use_side_effect_with_deps(&dep, move |_| { - spawn(async move { - let is_max = Platform::get() - .post_callback(|id, ctx| { - ctx.windows.get(&id).map(|w| w.window().is_maximized()) - }) - .await; - if let Ok(Some(is_max)) = is_max { - if *maximized.peek() != is_max { - maximized.set(is_max); - } - } - }); - }); - if *maximized.read() { 0. } else { 12. } - }; - rect() .width(Size::fill()) .height(Size::fill()) .background(colors::page()) .color(colors::fg_primary()) .font_family(theme::DEFAULT_FONT) - //.corner_radius(CornerRadius::new_all(corner)) .overflow(Overflow::Clip) .child( rect() diff --git a/packages/oneclient_app/src/layout/settings_shell.rs b/packages/oneclient_app/src/layout/settings_shell.rs index 31b2ed33..a1263fe7 100644 --- a/packages/oneclient_app/src/layout/settings_shell.rs +++ b/packages/oneclient_app/src/layout/settings_shell.rs @@ -68,8 +68,16 @@ const SEARCH_INDEX: &[SearchItem] = &[ title: "Launcher Folder", description: "Open the launcher data directory, or move it to another drive.", keywords: &[ - "data dir", "directory", "folder", "open", "move", "change", "relocate", "location", - "drive", "disk", + "data dir", + "directory", + "folder", + "open", + "move", + "change", + "relocate", + "location", + "drive", + "disk", ], route: Route::SettingsLauncher {}, }, @@ -457,11 +465,7 @@ impl Component for SettingsShell { } } -fn content_header( - title: String, - search: State, - mut width: State, -) -> impl IntoElement { +fn content_header(title: String, search: State, mut width: State) -> impl IntoElement { let search_width = search_width_for(*width.read()); rect() @@ -770,9 +774,7 @@ impl Component for SidebarInfo { .width(Size::fill()) .spacing(4.) .font_size(12.) - .padding(Gaps::new( - 15.,0.,0.,0. - )) + .padding(Gaps::new(15., 0., 0., 0.)) .color(colors::fg_secondary()) .children( items diff --git a/packages/oneclient_app/src/lib.rs b/packages/oneclient_app/src/lib.rs index 7db65b8e..d84c8b44 100644 --- a/packages/oneclient_app/src/lib.rs +++ b/packages/oneclient_app/src/lib.rs @@ -6,14 +6,12 @@ mod assets; pub mod cli; mod components; -pub mod hooks; pub mod events; pub(crate) mod file_content; +pub mod hooks; mod install; pub mod ipc; mod launcher; -pub mod state; -mod transfer; mod layout; pub mod microsoft_java; mod motion; @@ -23,7 +21,9 @@ pub mod protocol; pub mod recovery; mod routes; pub mod shortcut; +pub mod state; pub mod theme; +mod transfer; mod ui; pub mod updater; pub(crate) mod utils; @@ -32,8 +32,8 @@ mod view; pub mod constants; pub use assets::AppAssets; -pub use events::EventPump; -pub use state::{AppChannel, AppState}; pub use components::ConfirmLinkOverlay; +pub use events::EventPump; pub use hooks::*; pub use routes::{Route, router}; +pub use state::{AppChannel, AppState}; diff --git a/packages/oneclient_app/src/main.rs b/packages/oneclient_app/src/main.rs index 4fcdc615..c1a6ecf5 100644 --- a/packages/oneclient_app/src/main.rs +++ b/packages/oneclient_app/src/main.rs @@ -10,8 +10,8 @@ use oneclient_app::ipc::{self, Claim}; use oneclient_app::state::{AppChannel, AppState, LauncherInit}; use oneclient_app::{ Actions, ConfirmLinkOverlay, EventPump, LinkConfirmState, StartMaximizedState, cli, constants, - events, platform, router, theme, use_provide_actions, use_provide_link_confirm, - use_provide_start_maximized, microsoft_java, + events, microsoft_java, platform, router, theme, use_provide_actions, use_provide_link_confirm, + use_provide_start_maximized, }; use std::cell::Cell; use tokio::runtime::Builder; @@ -196,39 +196,40 @@ fn main() { boot_launch: Cell::new(cli.launch), ipc: Cell::new(ipc), }) - .with_title(constants::WINDOW_TITLE) - .with_app_id(constants::WINDOW_APP_ID) - .with_icon(LaunchConfig::window_icon(include_bytes!( - "../icons/128x128.png" - ))) - .with_size(1200., 800.) - .with_min_size(800., 600.) - .with_transparency(false) - .with_background(Color::TRANSPARENT) - // A half-copied data folder is unrecoverable, so the window refuses to - // close while one is being moved; the move screen says as much - .with_on_close(|_, _| { - if oneclient_core::relocate::in_progress() { - tracing::warn!("close request ignored, the data folder is still being moved"); - CloseDecision::KeepOpen - } else { - CloseDecision::Close - } - }); + .with_title(constants::WINDOW_TITLE) + .with_app_id(constants::WINDOW_APP_ID) + .with_icon(LaunchConfig::window_icon(include_bytes!( + "../icons/128x128.png" + ))) + .with_size(1200., 800.) + .with_min_size(800., 600.) + .with_transparency(false) + .with_background(Color::TRANSPARENT) + // A half-copied data folder is unrecoverable, so the window refuses to + // close while one is being moved; the move screen says as much + .with_on_close(|_, _| { + if oneclient_core::relocate::in_progress() { + tracing::warn!("close request ignored, the data folder is still being moved"); + CloseDecision::KeepOpen + } else { + CloseDecision::Close + } + }); #[cfg(target_os = "macos")] - let window_config = window_config - .with_decorations(true) - .with_window_attributes(move |attrs, _| { - use freya::winit::platform::macos::WindowAttributesExtMacOS; - attrs - .with_title_hidden(true) - .with_titlebar_transparent(true) - .with_titlebar_buttons_hidden(true) - .with_fullsize_content_view(true) - .with_has_shadow(true) - .with_maximized(start_maximized) - }); + let window_config = + window_config + .with_decorations(true) + .with_window_attributes(move |attrs, _| { + use freya::winit::platform::macos::WindowAttributesExtMacOS; + attrs + .with_title_hidden(true) + .with_titlebar_transparent(true) + .with_titlebar_buttons_hidden(true) + .with_fullsize_content_view(true) + .with_has_shadow(true) + .with_maximized(start_maximized) + }); #[cfg(not(target_os = "macos"))] let window_config = window_config diff --git a/packages/oneclient_app/src/microsoft_java.rs b/packages/oneclient_app/src/microsoft_java.rs index 18451889..1545ac8c 100644 --- a/packages/oneclient_app/src/microsoft_java.rs +++ b/packages/oneclient_app/src/microsoft_java.rs @@ -2,13 +2,13 @@ use std::collections::BTreeSet; use std::sync::{Arc, Mutex}; use freya::prelude::spawn_forever; -use tokio::sync::mpsc::UnboundedSender; use oneclient_common::Patch; use oneclient_core::settings::store::save_settings_and_apply; use oneclient_core::{LauncherState, ProfileUpdate}; use oneclient_db::models::ClusterId; use oneclient_events::{Choice, Prompt, Signal}; use oneclient_java::{JavaRuntime, JavaVendor}; +use tokio::sync::mpsc::UnboundedSender; use crate::hooks::Actions; use crate::launcher; @@ -59,7 +59,10 @@ async fn auto_install(refresh: UnboundedSender<()>) { return; } Err(err) => { - tracing::warn!(cluster_id, "could not read the cluster's Java version: {err:#}"); + tracing::warn!( + cluster_id, + "could not read the cluster's Java version: {err:#}" + ); return; } }; @@ -113,7 +116,10 @@ pub async fn offer_for_pinned_cluster(actions: &Actions, cluster_id: ClusterId) return; } Err(err) => { - tracing::warn!(cluster_id, "could not read the cluster's Java version: {err:#}"); + tracing::warn!( + cluster_id, + "could not read the cluster's Java version: {err:#}" + ); return; } }; diff --git a/packages/oneclient_app/src/notifications.rs b/packages/oneclient_app/src/notifications.rs index bcd34267..dd9cd48a 100644 --- a/packages/oneclient_app/src/notifications.rs +++ b/packages/oneclient_app/src/notifications.rs @@ -1,12 +1,12 @@ use std::collections::HashMap; use std::time::{Duration, Instant}; -use oneclient_events::{ - Answer, Choice, Event, GroupedProgressEvent, Level, Notification, ProgressEvent, TaskCategory, -}; use oneclient_content::packages::ProviderId; use oneclient_core::BrowserPackageUpdate; use oneclient_db::models::{ClusterId, OptionalModStatus}; +use oneclient_events::{ + Answer, Choice, Event, GroupedProgressEvent, Level, Notification, ProgressEvent, TaskCategory, +}; use tokio::sync::oneshot; use uuid::Uuid; @@ -80,9 +80,11 @@ impl OptionalModsGroup { } pub fn offers(&self) -> impl Iterator + '_ { - self.mods - .iter() - .filter_map(move |item| item.offer.clone().map(|reference| (self.cluster_id, reference))) + self.mods.iter().filter_map(move |item| { + item.offer + .clone() + .map(|reference| (self.cluster_id, reference)) + }) } } @@ -274,8 +276,11 @@ impl GroupedTasks { let done_count = self.done_count.get(&cat).copied().unwrap_or(0); let reserved_units = self.reserved_units.get(&cat).copied().unwrap_or(0); - let live: Vec<&ChildRec> = - self.children.values().filter(|c| c.category == cat).collect(); + let live: Vec<&ChildRec> = self + .children + .values() + .filter(|c| c.category == cat) + .collect(); let live_current: u64 = live.iter().map(|c| c.current.min(c.total)).sum(); let live_total: u64 = live.iter().map(|c| c.total).sum(); @@ -471,7 +476,7 @@ impl NotificationState { ); self.push_ephemeral_toast(entry_id, MESSAGE_TOAST_TTL); } - + Event::Progress(ProgressEvent::Update { id, .. }) if id == oneclient_auth::MICROSOFT_LOGIN_PROGRESS => {} Event::Progress(ProgressEvent::Update { id, .. }) @@ -741,8 +746,7 @@ impl NotificationState { self.update_inbox_entry(inbox, entry_id, title, body, None, false); self.ensure_progress_toast(entry_id); } else { - let entry_id = - self.push_inbox(inbox, title, body, Level::Info, None, false); + let entry_id = self.push_inbox(inbox, title, body, Level::Info, None, false); self.push_ephemeral_toast(entry_id, MESSAGE_TOAST_TTL); } } @@ -1017,7 +1021,10 @@ mod package_update_tests { ); state.resolve_package_update(1, "b"); - assert!(wait.try_recv().is_ok(), "the last answer releases the launch"); + assert!( + wait.try_recv().is_ok(), + "the last answer releases the launch" + ); assert!(state.package_updates.is_none()); } @@ -1042,7 +1049,10 @@ mod package_update_tests { state.open_package_updates(vec![group(&[])], Some(done)); - assert!(wait.try_recv().is_ok(), "a modal that never opens cannot be answered"); + assert!( + wait.try_recv().is_ok(), + "a modal that never opens cannot be answered" + ); assert!(state.package_updates.is_none()); } @@ -1055,7 +1065,10 @@ mod package_update_tests { state.open_package_updates(vec![group(&["a"])], Some(first)); state.open_package_updates(vec![group(&["b"])], Some(second)); - assert!(first_wait.try_recv().is_ok(), "the replaced launch is let go"); + assert!( + first_wait.try_recv().is_ok(), + "the replaced launch is let go" + ); assert!(second_wait.try_recv().is_err(), "the new one still waits"); } diff --git a/packages/oneclient_app/src/protocol/mod.rs b/packages/oneclient_app/src/protocol/mod.rs index a95d0698..59415824 100644 --- a/packages/oneclient_app/src/protocol/mod.rs +++ b/packages/oneclient_app/src/protocol/mod.rs @@ -72,7 +72,10 @@ mod tests { #[test] fn a_folder_survives_the_round_trip() { let folder = "26.1.2 Fabric"; - assert_eq!(parse_launch_url(&launch_url(folder)).as_deref(), Some(folder)); + assert_eq!( + parse_launch_url(&launch_url(folder)).as_deref(), + Some(folder) + ); } #[test] diff --git a/packages/oneclient_app/src/protocol/registry.rs b/packages/oneclient_app/src/protocol/registry.rs index a5920116..299d17e5 100644 --- a/packages/oneclient_app/src/protocol/registry.rs +++ b/packages/oneclient_app/src/protocol/registry.rs @@ -44,8 +44,7 @@ pub fn write_string(subkey: &str, name: Option<&str>, value: &str) -> Result<()> let bytes = unsafe { std::slice::from_raw_parts(wide.as_ptr().cast::(), wide.len() * 2) }; let name = name.map(HSTRING::from); - let status = - unsafe { RegSetValueExW(key, name_ptr(name.as_ref()), None, REG_SZ, Some(bytes)) }; + let status = unsafe { RegSetValueExW(key, name_ptr(name.as_ref()), None, REG_SZ, Some(bytes)) }; close(key); check(status, "writing the registry value") diff --git a/packages/oneclient_app/src/recovery.rs b/packages/oneclient_app/src/recovery.rs index a5fc246c..04b4c844 100644 --- a/packages/oneclient_app/src/recovery.rs +++ b/packages/oneclient_app/src/recovery.rs @@ -49,7 +49,9 @@ fn retry(actions: &Actions) { let events = actions.events(); { - let mut guard = station.clone().write_channel(crate::state::AppChannel::Launcher); + let mut guard = station + .clone() + .write_channel(crate::state::AppChannel::Launcher); guard.launcher.error = None; guard.launcher.snapshots = snapshots().len(); } diff --git a/packages/oneclient_app/src/shortcut/macos.rs b/packages/oneclient_app/src/shortcut/macos.rs index c022126b..c1774c0b 100644 --- a/packages/oneclient_app/src/shortcut/macos.rs +++ b/packages/oneclient_app/src/shortcut/macos.rs @@ -79,7 +79,10 @@ mod tests { #[test] fn a_bare_binary_has_no_bundle() { - assert_eq!(bundle_root(&PathBuf::from("/usr/local/bin/oneclient_app")), None); + assert_eq!( + bundle_root(&PathBuf::from("/usr/local/bin/oneclient_app")), + None + ); assert_eq!( bundle_root(&PathBuf::from("/tmp/target/debug/oneclient_app")), None, diff --git a/packages/oneclient_app/src/shortcut/mod.rs b/packages/oneclient_app/src/shortcut/mod.rs index cbbb1b0e..4ddc0d13 100644 --- a/packages/oneclient_app/src/shortcut/mod.rs +++ b/packages/oneclient_app/src/shortcut/mod.rs @@ -38,7 +38,11 @@ pub fn create(request: &ShortcutRequest) -> Result { std::fs::create_dir_all(&request.dir) .with_context(|| format!("couldn't open {}", request.dir.display()))?; - let path = unique_path(&request.dir, &file_stem(&request.cluster_name), imp::EXTENSION)?; + let path = unique_path( + &request.dir, + &file_stem(&request.cluster_name), + imp::EXTENSION, + )?; imp::write(request, &exe, &path) .with_context(|| format!("couldn't write {}", path.display()))?; diff --git a/packages/oneclient_app/src/updater.rs b/packages/oneclient_app/src/updater.rs index f46d288a..20691f2f 100644 --- a/packages/oneclient_app/src/updater.rs +++ b/packages/oneclient_app/src/updater.rs @@ -106,13 +106,15 @@ async fn run_check(auto_install: bool, events: EventBus) -> anyhow::Result<()> { } fn can_self_update() -> bool { - if cfg!(debug_assertions) { - return false; - } + if cfg!(debug_assertions) { + return false; + } - if std::env::var_os("ONECLIENT_DISABLE_AUTOUPDATE").is_some_and(|val| val.eq_ignore_ascii_case("1")) { - return false; - } + if std::env::var_os("ONECLIENT_DISABLE_AUTOUPDATE") + .is_some_and(|val| val.eq_ignore_ascii_case("1")) + { + return false; + } #[cfg(target_os = "linux")] { diff --git a/packages/oneclient_app/src/utils.rs b/packages/oneclient_app/src/utils.rs index 15ac17cb..2a2c9f51 100644 --- a/packages/oneclient_app/src/utils.rs +++ b/packages/oneclient_app/src/utils.rs @@ -2,9 +2,9 @@ use std::cmp::Ordering; use std::collections::BTreeMap; use chrono::{Datelike, NaiveDate}; -use oneclient_core::clusters::Cluster; use oneclient_common::domain::GameLoader; use oneclient_common::{ParsedMcVersion, VersionKey, format_mc_version, parse_mc_version}; +use oneclient_core::clusters::Cluster; use oneclient_common::MEMORY_HEADROOM_GB; pub use oneclient_common::total_ram_mb; @@ -207,7 +207,7 @@ pub fn format_res((w, h): (u32, u32)) -> String { format!("{w}×{h}") } -/// Prevents user from choosing his max amount of ram preset (e.g Someone has 16GB of RAM, +/// Prevents user from choosing his max amount of ram preset (e.g Someone has 16GB of RAM, /// so the max preset is 16GB - 2GB = 14GB) const MEMORY_PRESETS_GB: [u32; 11] = [2, 3, 4, 6, 8, 12, 16, 24, 32, 48, 64]; diff --git a/packages/oneclient_app/src/view/app/analytics/charts.rs b/packages/oneclient_app/src/view/app/analytics/charts.rs index 8acaae64..e81b92a3 100644 --- a/packages/oneclient_app/src/view/app/analytics/charts.rs +++ b/packages/oneclient_app/src/view/app/analytics/charts.rs @@ -82,9 +82,7 @@ impl Component for PlaytimeChart { "Playtime by day", format!("Every {day} you have played, added up"), BarChart::new(self.per_weekday.clone(), weekday_labels()) - .readout_labels( - WEEKDAY_FULL.iter().map(|d| format!("All {d}s")).collect(), - ) + .readout_labels(WEEKDAY_FULL.iter().map(|d| format!("All {d}s")).collect()) .highlight(self.peak_weekday) .unit(ValueUnit::Duration) .gap(6.) @@ -241,15 +239,15 @@ impl Component for DailyChart { .child(DateRangePicker::new(custom, (first_day, last_day)).height(30.)) .into_element(); - chart_card( - "Daily playtime", - subtitle, - Some(nav), - BarChart::new(values, labels) - .unit(ValueUnit::Duration) - .gap(3.) - .into_element(), - ) + chart_card( + "Daily playtime", + subtitle, + Some(nav), + BarChart::new(values, labels) + .unit(ValueUnit::Duration) + .gap(3.) + .into_element(), + ) } } @@ -293,10 +291,8 @@ fn monthly_series(daily: &[DayPlaytime]) -> Vec<(NaiveDate, i64)> { *totals.entry(month).or_insert(0) += day.secs; } - let (Some(first), Some(last)) = ( - totals.keys().min().copied(), - totals.keys().max().copied(), - ) else { + let (Some(first), Some(last)) = (totals.keys().min().copied(), totals.keys().max().copied()) + else { return Vec::new(); }; diff --git a/packages/oneclient_app/src/view/app/analytics/tiles.rs b/packages/oneclient_app/src/view/app/analytics/tiles.rs index 97541f48..00300039 100644 --- a/packages/oneclient_app/src/view/app/analytics/tiles.rs +++ b/packages/oneclient_app/src/view/app/analytics/tiles.rs @@ -278,4 +278,3 @@ fn persona_icon(persona: Persona) -> IconType { Persona::Sprinter => IconType::Play, } } - diff --git a/packages/oneclient_app/src/view/app/browser/mod.rs b/packages/oneclient_app/src/view/app/browser/mod.rs index 5d6f4510..7188dd2a 100644 --- a/packages/oneclient_app/src/view/app/browser/mod.rs +++ b/packages/oneclient_app/src/view/app/browser/mod.rs @@ -175,13 +175,7 @@ pub(crate) fn activity_badge(active: bool) -> impl IntoElement { .padding(Gaps::new_symmetric(2., 8.)) .corner_radius(CornerRadius::new_all(999.)) .background(color.with_a(38)) - .child( - label() - .text(text) - .font_size(11.) - .max_lines(1) - .color(color), - ) + .child(label().text(text).font_size(11.).max_lines(1).color(color)) } fn badge( @@ -444,7 +438,10 @@ mod tests { ); let sodium = entry(&map, "sodium"); - assert!(sodium.is_version("v1"), "the older version is still in there"); + assert!( + sodium.is_version("v1"), + "the older version is still in there" + ); assert!(sodium.is_version("v2")); assert_eq!( sodium.find_version("v2").and_then(|v| v.hash.clone()), @@ -464,7 +461,11 @@ mod tests { ); let sodium = entry(&map, "sodium"); - assert_eq!(sodium.source, InstallSource::Bundled, "the bundle owns the project"); + assert_eq!( + sodium.source, + InstallSource::Bundled, + "the bundle owns the project" + ); assert_eq!( sodium.find_version("v1").map(|v| v.source), Some(InstallSource::Bundled) 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 62212e47..31770866 100644 --- a/packages/oneclient_app/src/view/app/browser/package/mod.rs +++ b/packages/oneclient_app/src/view/app/browser/package/mod.rs @@ -2,6 +2,7 @@ use freya::prelude::*; use oneclient_content::packages::{ContentType, ProviderId}; use crate::components::ScrollArea; +use crate::hooks::use_cluster; use crate::hooks::{ bundles_with_status_items, cluster_content_items, content_type_for_slug, project_detail, use_browser_compat, use_bundles_with_status, use_cluster_content, use_dispatch, @@ -10,7 +11,6 @@ use crate::hooks::{ }; use crate::theme::colors; use crate::ui::border_all_color; -use crate::hooks::use_cluster; use super::{ Installed, InstalledVersion, PackageBanner, Thumbnail, activity_badge, installed_badge, 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 7ae602be..2aa9b5aa 100644 --- a/packages/oneclient_app/src/view/app/browser/package/panels.rs +++ b/packages/oneclient_app/src/view/app/browser/package/panels.rs @@ -5,7 +5,9 @@ use oneclient_content::packages::markdown::normalize_markdown; use oneclient_content::packages::types::{PackageBody, ProjectDetail, ReleaseType, VersionSummary}; use crate::Actions; -use crate::components::{Button, Icon, IconType, Markdown, MarkdownStyle, Segment, SegmentedControl}; +use crate::components::{ + Button, Icon, IconType, Markdown, MarkdownStyle, Segment, SegmentedControl, +}; use crate::hooks::VERSIONS_PAGE_SIZE; use crate::theme::colors; use crate::ui::border_all_color; @@ -283,14 +285,7 @@ fn version_row( .map(|installed| activity_badge(installed.enabled).into_element()), ) .child(version_button( - installed, - v.name, - provider, - project_id, - version_id, - cluster_id, - dispatch, - installing, + installed, v.name, provider, project_id, version_id, cluster_id, dispatch, installing, )) } @@ -323,7 +318,11 @@ fn version_button( }; let Some(hash) = installed.hash else { - return Button::new().secondary().small().enabled(false).text("Install"); + return Button::new() + .secondary() + .small() + .enabled(false) + .text("Install"); }; Button::new() 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 78fe3fe3..e570b62e 100644 --- a/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs +++ b/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs @@ -1,10 +1,8 @@ use freya::prelude::*; use oneclient_common::Patch; -use oneclient_java::JavaRuntime; use oneclient_common::domain::GameLoader; -use oneclient_core::settings::{ - GameSettingsProfile, PackageUpdateMode, ProfileUpdate, Resolution, -}; +use oneclient_core::settings::{GameSettingsProfile, PackageUpdateMode, ProfileUpdate, Resolution}; +use oneclient_java::JavaRuntime; use crate::components::{ Button, Dropdown, Icon, IconType, ScrollArea, TextInput, memory_field, toggle, @@ -309,7 +307,11 @@ impl Component for VerifyFilesRow { .secondary() .enabled(!running) .maybe(!running, |el| el.on_press(on_press)) - .text(if running { "Verifying..." } else { "Verify Files" }); + .text(if running { + "Verifying..." + } else { + "Verify Files" + }); settings_row( IconType::ClipboardCheck, diff --git a/packages/oneclient_app/src/view/app/cluster/logs.rs b/packages/oneclient_app/src/view/app/cluster/logs.rs index b21594d7..e276306f 100644 --- a/packages/oneclient_app/src/view/app/cluster/logs.rs +++ b/packages/oneclient_app/src/view/app/cluster/logs.rs @@ -336,9 +336,7 @@ pub fn viewer_header( ) .into_element(), ) - .child( - UploadToMclogs::new(has_log, confirm) - ); + .child(UploadToMclogs::new(has_log, confirm)); rect() .vertical() diff --git a/packages/oneclient_app/src/view/app/cluster/mods.rs b/packages/oneclient_app/src/view/app/cluster/mods.rs index 09ab2da0..e5afc692 100644 --- a/packages/oneclient_app/src/view/app/cluster/mods.rs +++ b/packages/oneclient_app/src/view/app/cluster/mods.rs @@ -7,10 +7,10 @@ use crate::hooks::{ }; use crate::layout::cluster_content; +use super::cluster_not_found; use super::package_manager::{ PackageManager, bundle_categories, bundle_packages, use_content_meta, }; -use super::cluster_not_found; use crate::hooks::use_cluster; #[derive(PartialEq)] diff --git a/packages/oneclient_app/src/view/app/cluster/overview.rs b/packages/oneclient_app/src/view/app/cluster/overview.rs index 160616e9..655ea2aa 100644 --- a/packages/oneclient_app/src/view/app/cluster/overview.rs +++ b/packages/oneclient_app/src/view/app/cluster/overview.rs @@ -4,7 +4,7 @@ use crate::components::ScrollArea; use crate::hooks::{try_cluster_analytics, use_cluster_analytics}; use crate::layout::cluster_content; use crate::theme::colors; -use crate::ui::{centered_note}; +use crate::ui::centered_note; use crate::view::app::{analytics_body, analytics_placeholder}; use super::cluster_not_found; diff --git a/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs b/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs index 6119a953..d48db615 100644 --- a/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs +++ b/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs @@ -74,10 +74,7 @@ pub fn use_content_meta( } } - let local = use_package_meta_batch( - ProviderId::Local, - local_project_ids(content, content_type), - ); + let local = use_package_meta_batch(ProviderId::Local, local_project_ids(content, content_type)); for (hash, meta) in package_meta_batch(&local) { out.insert((ProviderId::Local, hash), meta); } @@ -257,9 +254,7 @@ fn make_row( hash: installed_info.map(|i| i.hash.clone()), update_available, hidden, - seen_status: installed_info - .map(|i| i.seen_status) - .unwrap_or_default(), + seen_status: installed_info.map(|i| i.seen_status).unwrap_or_default(), } } @@ -405,7 +400,10 @@ impl Component for PackageManager { .await { Ok(cleared) if cleared > 0 => { - tracing::debug!(cleared, "retired package badges after the list was viewed"); + tracing::debug!( + cleared, + "retired package badges after the list was viewed" + ); } Ok(_) => {} Err(err) => tracing::warn!(%err, "failed to retire package badges"), 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 d028cd41..31c1ee92 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 @@ -170,19 +170,11 @@ pub(super) fn toolbar_bar( } else { Size::flex(1.0) }) - .height(if stacked { - Size::fill() - } else { - Size::auto() - }) + .height(if stacked { Size::fill() } else { Size::auto() }) .child( TabBar::new() .width(Size::auto()) - .height(if stacked { - Size::fill() - } else { - Size::auto() - }) + .height(if stacked { Size::fill() } else { Size::auto() }) .spacing(20.) .font_size(12.) .tabs(tab_items), diff --git a/packages/oneclient_app/src/view/app/cluster/process_logs.rs b/packages/oneclient_app/src/view/app/cluster/process_logs.rs index a6325538..4f8ca2cf 100644 --- a/packages/oneclient_app/src/view/app/cluster/process_logs.rs +++ b/packages/oneclient_app/src/view/app/cluster/process_logs.rs @@ -70,7 +70,11 @@ impl Component for ProcessLogs { } } -fn filter_lines(lines: &Arc>>, search: &str, level: LevelFilter) -> Arc>> { +fn filter_lines( + lines: &Arc>>, + search: &str, + level: LevelFilter, +) -> Arc>> { let needle = (!search.trim().is_empty()).then(|| search.to_lowercase()); let wanted = level.to_level(); diff --git a/packages/oneclient_app/src/view/app/cluster/screenshots.rs b/packages/oneclient_app/src/view/app/cluster/screenshots.rs index a7a8905a..3edb0f4e 100644 --- a/packages/oneclient_app/src/view/app/cluster/screenshots.rs +++ b/packages/oneclient_app/src/view/app/cluster/screenshots.rs @@ -9,7 +9,10 @@ use crate::components::{ Button, Icon, IconType, LocalImage, OverlayPopup, ScreenshotViewer, ScrollArea, Segment, SegmentedControl, open_folder_button, screenshot_context_menu, }; -use crate::hooks::{ScreenshotAction, query_is_loading, try_cluster_screenshots, use_cluster_screenshots, use_dispatch, use_screenshot_action, use_screenshot_folder_watch, use_view_state}; +use crate::hooks::{ + ScreenshotAction, query_is_loading, try_cluster_screenshots, use_cluster_screenshots, + use_dispatch, use_screenshot_action, use_screenshot_folder_watch, use_view_state, +}; use crate::layout::cluster_content; use crate::theme::colors; use crate::ui::{border_all_color, flow_grid, fmt_date, grid_columns_for_width}; @@ -65,8 +68,7 @@ impl Component for ClusterScreenshots { ); let content: Element = if shots.is_empty() { - empty_state(query_is_loading(&query)) - .into_element() + empty_state(query_is_loading(&query)).into_element() } else { let mut items: Vec = Vec::new(); for (idx, info) in shots.iter().enumerate() { diff --git a/packages/oneclient_app/src/view/app/cluster/shaders.rs b/packages/oneclient_app/src/view/app/cluster/shaders.rs index ab75017e..cea5ad8c 100644 --- a/packages/oneclient_app/src/view/app/cluster/shaders.rs +++ b/packages/oneclient_app/src/view/app/cluster/shaders.rs @@ -7,10 +7,10 @@ use crate::hooks::{ }; use crate::layout::cluster_content; +use super::cluster_not_found; use super::package_manager::{ PackageManager, bundle_categories, bundle_packages, use_content_meta, }; -use super::cluster_not_found; use crate::hooks::use_cluster; #[derive(PartialEq)] diff --git a/packages/oneclient_app/src/view/app/cluster/textures.rs b/packages/oneclient_app/src/view/app/cluster/textures.rs index ccf90c38..bfdf038a 100644 --- a/packages/oneclient_app/src/view/app/cluster/textures.rs +++ b/packages/oneclient_app/src/view/app/cluster/textures.rs @@ -7,10 +7,10 @@ use crate::hooks::{ }; use crate::layout::cluster_content; +use super::cluster_not_found; use super::package_manager::{ PackageManager, bundle_categories, bundle_packages, use_content_meta, }; -use super::cluster_not_found; use crate::hooks::use_cluster; #[derive(PartialEq)] diff --git a/packages/oneclient_app/src/view/app/clusters/page.rs b/packages/oneclient_app/src/view/app/clusters/page.rs index f1306920..3ff0f53c 100644 --- a/packages/oneclient_app/src/view/app/clusters/page.rs +++ b/packages/oneclient_app/src/view/app/clusters/page.rs @@ -1,13 +1,16 @@ use freya::prelude::*; use freya::router::RouterContext; use oneclient_common::VersionKey; -use oneclient_core::clusters::Cluster; use oneclient_common::domain::GameLoader; +use oneclient_core::clusters::Cluster; use crate::components::{ Button, ClusterLandscapeArt, Dropdown, Icon, IconType, ScrollArea, VersionCard, }; -use crate::hooks::{settled_or_loading, use_active_cluster_id, use_clusters, use_dispatch, use_game_snapshot, use_launcher, use_version_metadata}; +use crate::hooks::{ + settled_or_loading, use_active_cluster_id, use_clusters, use_dispatch, use_game_snapshot, + use_launcher, use_version_metadata, +}; use crate::routes::Route; use crate::theme::colors; use crate::ui::border_all_color; @@ -294,7 +297,12 @@ impl Component for DetailSidebar { .color(colors::fg_secondary()), ), ) - .children(version_rows(self.line, &keys, version_value, self.selected_version)) + .children(version_rows( + self.line, + &keys, + version_value, + self.selected_version, + )) .children(loader_rows(&loaders, loader_value, self.selected_loader)), ) .child( diff --git a/packages/oneclient_app/src/view/app/debug.rs b/packages/oneclient_app/src/view/app/debug.rs index 0c1c13e4..b93a18c6 100644 --- a/packages/oneclient_app/src/view/app/debug.rs +++ b/packages/oneclient_app/src/view/app/debug.rs @@ -25,7 +25,7 @@ pub struct Debug; impl Component for Debug { fn render(&self) -> impl IntoElement { - let dispatch = use_dispatch(); + let dispatch = use_dispatch(); let log_debug_info = use_state(|| false); let show_dev_stuff = use_state(|| false); let seen_onboarding = use_state(|| true); @@ -103,12 +103,15 @@ impl Component for Debug { .child(divider()) .child(section( "Other", - vec![action_row(&dispatch, vec![ - ("Open Dev Tools", IconType::CodeSnippet02), - ("Open Onboarding", IconType::Rocket02), - ("Open Launcher Data", IconType::Folder), - ("Log Running Processes", IconType::Terminal), - ])], + vec![action_row( + &dispatch, + vec![ + ("Open Dev Tools", IconType::CodeSnippet02), + ("Open Onboarding", IconType::Rocket02), + ("Open Launcher Data", IconType::Folder), + ("Log Running Processes", IconType::Terminal), + ], + )], )), ) } @@ -432,52 +435,50 @@ const CLUSTER_UPDATE_PRESETS: [(&str, IconType, ClusterUpdatePreset); 7] = [ &[], ), preset_summary(2, "Skyblock", &[], &[], &["OptiFine", "Skytils"], &[]), - preset_summary(3, "Vanilla+", &[], &["Sodium", "Iris", "FerriteCore"], &[], &[]), + preset_summary( + 3, + "Vanilla+", + &[], + &["Sodium", "Iris", "FerriteCore"], + &[], + &[], + ), ] }), ("2 clusters · removals only", IconType::Trash01, || { vec![ preset_summary(1, "PolyBlock", &[], &[], &["OptiFine"], &[]), - preset_summary(2, "Skyblock", &[], &[], &["Skytils", "NotEnoughUpdates"], &[]), + preset_summary( + 2, + "Skyblock", + &[], + &[], + &["Skytils", "NotEnoughUpdates"], + &[], + ), ] }), ("6 clusters · long names", IconType::Database01, || { (1..=6) - .map(|i| { - ClusterUpdateSummary { - cluster_id: i, - cluster_name: format!( - "Cluster {i} with a deliberately overlong name that has to truncate" - ), - updated: cluster_update_items(&[ - format!("Sodium 0.{i} → 0.{}", i + 1), - format!("Iris 1.{i} → 1.{}", i + 1), - ]), - added: cluster_update_items(&[format!("Lithium {i}")]), - removed: Vec::new(), - optional: Vec::new(), - } + .map(|i| ClusterUpdateSummary { + cluster_id: i, + cluster_name: format!( + "Cluster {i} with a deliberately overlong name that has to truncate" + ), + updated: cluster_update_items(&[ + format!("Sodium 0.{i} → 0.{}", i + 1), + format!("Iris 1.{i} → 1.{}", i + 1), + ]), + added: cluster_update_items(&[format!("Lithium {i}")]), + removed: Vec::new(), + optional: Vec::new(), }) .collect() }), ("2 clusters · offers only", IconType::Plus, || { vec![ - preset_summary( - 1, - "PolyBlock", - &[], - &[], - &[], - &["Lithium", "FerriteCore"], - ), - preset_summary( - 2, - "Skyblock", - &[], - &["Skytils"], - &[], - &["Skytils"], - ), + preset_summary(1, "PolyBlock", &[], &[], &[], &["Lithium", "FerriteCore"]), + preset_summary(2, "Skyblock", &[], &["Skytils"], &[], &["Skytils"]), ] }), ]; @@ -519,7 +520,7 @@ fn preset_summary( updated: cluster_update_items(updated), added: cluster_update_items(added), removed: cluster_update_items(removed), - optional: cluster_update_items(optional) + optional: cluster_update_items(optional), } } @@ -1036,9 +1037,11 @@ fn run_damage(dispatch: &crate::Actions, kind: DamageKind, cluster_id: i64) { let dispatch = dispatch.clone(); spawn(async move { let result = match kind { - DamageKind::Assets(count, damage) => oneclient_core::simulate::damage_assets(count, damage) - .await - .map(|report| (report, damage.verb())), + DamageKind::Assets(count, damage) => { + oneclient_core::simulate::damage_assets(count, damage) + .await + .map(|report| (report, damage.verb())) + } DamageKind::Libraries(count, damage) => { oneclient_core::simulate::damage_libraries(count, damage) .await @@ -1300,9 +1303,9 @@ fn action_row(dispatch: &Actions, buttons: Vec<(&'static str, IconType)>) -> Ele .text(text); if text == "Open Onboarding" { - let dispatch = dispatch.clone(); - button = button.on_press(move |_| { - dispatch.reset_onboarding(); + let dispatch = dispatch.clone(); + button = button.on_press(move |_| { + dispatch.reset_onboarding(); let _ = RouterContext::get().replace(Route::OnboardingWelcome {}); }); } diff --git a/packages/oneclient_app/src/view/app/settings/accounts.rs b/packages/oneclient_app/src/view/app/settings/accounts.rs index 57b23773..5f75a4f6 100644 --- a/packages/oneclient_app/src/view/app/settings/accounts.rs +++ b/packages/oneclient_app/src/view/app/settings/accounts.rs @@ -555,10 +555,10 @@ impl Component for AccountRow { .enabled(false) .tooltip("Edit") .child( - Icon::new(IconType::Pencil01) - .size(18.) - .color(colors::fg_secondary()), - ), + Icon::new(IconType::Pencil01) + .size(18.) + .color(colors::fg_secondary()), + ), ) .child( Button::new() diff --git a/packages/oneclient_app/src/view/app/settings/changelog.rs b/packages/oneclient_app/src/view/app/settings/changelog.rs index 3c78dfa3..110d71d5 100644 --- a/packages/oneclient_app/src/view/app/settings/changelog.rs +++ b/packages/oneclient_app/src/view/app/settings/changelog.rs @@ -137,7 +137,11 @@ impl Component for ReleaseCard { .font_weight(FontWeight::SEMI_BOLD) .color(colors::fg_primary()), ) - .child(Icon::new(IconType::ChevronDown).size(18.).rotate(chevron_deg)), + .child( + Icon::new(IconType::ChevronDown) + .size(18.) + .rotate(chevron_deg), + ), ) .maybe_child(is_open.then(|| { rect() diff --git a/packages/oneclient_app/src/view/app/settings/java.rs b/packages/oneclient_app/src/view/app/settings/java.rs index 84089146..8c5b70e5 100644 --- a/packages/oneclient_app/src/view/app/settings/java.rs +++ b/packages/oneclient_app/src/view/app/settings/java.rs @@ -6,7 +6,6 @@ use oneclient_java::{JavaRuntime, JavaVendor, is_launcher_managed}; use super::settings_page; use crate::components::{Button, Icon, IconType, JavaInstallManager, OverlayPopup, ScrollArea}; use crate::hooks::{Actions, java_runtimes, use_dispatch, use_java_runtimes}; -use crate::invalidate_java_queries; use crate::theme::colors; use crate::ui::border_all_color; use crate::view::app::settings::section_header; @@ -23,19 +22,6 @@ impl Component for SettingsJava { let mut show_manager = use_state(|| false); let pending_remove = use_state(|| None::); - fn invalidate_runtimes(dispatch: Actions) { - spawn(async move { - invalidate_java_queries().await; - dispatch - .notify("Java runtimes refreshed") - .body("The installed runtime list is up to date") - .info() - .send(); - }); - } - - let refresh_dispatch = dispatch.clone(); - let mut shell = settings_page() .child(section_header("ADD RUNTIME")) .child(AddRow { show_manager }.into_element()) @@ -56,7 +42,11 @@ impl Component for SettingsJava { let pending = pending_remove.read().clone(); if let Some(target) = pending { - shell = shell.child(confirm_remove_modal(removing_dispatch, pending_remove, target)) + shell = shell.child(confirm_remove_modal( + removing_dispatch, + pending_remove, + target, + )) } shell.into_element() @@ -141,7 +131,7 @@ fn runtimes_table( RuntimeRow { runtime, last: idx + 1 == count, - pending_remove + pending_remove, } .into_element(), ); @@ -191,7 +181,7 @@ fn table_header() -> impl IntoElement { struct RuntimeRow { runtime: JavaRuntime, last: bool, - pending_remove: State> + pending_remove: State>, } impl Component for RuntimeRow { @@ -303,7 +293,7 @@ fn confirm_remove_modal( Button::new().primary().text("Remove from list") }; - OverlayPopup::new() + OverlayPopup::new() .on_close(move |()| pending.set(None)) .child( rect() @@ -359,7 +349,7 @@ fn confirm_remove_modal( pending.set(None); })), ), - ) + ), ) .into_element() } diff --git a/packages/oneclient_app/src/view/app/settings/launcher.rs b/packages/oneclient_app/src/view/app/settings/launcher.rs index 55580965..11d0bb1f 100644 --- a/packages/oneclient_app/src/view/app/settings/launcher.rs +++ b/packages/oneclient_app/src/view/app/settings/launcher.rs @@ -125,7 +125,11 @@ impl Component for DataFolder { .small() .disabled(checking_now) .on_press(move |_| browse(pending, error, checking)) - .text(if checking_now { "Checking…" } else { "Change…" }); + .text(if checking_now { + "Checking…" + } else { + "Change…" + }); let mut buttons = Vec::new(); diff --git a/packages/oneclient_app/src/view/app/settings/minecraft.rs b/packages/oneclient_app/src/view/app/settings/minecraft.rs index a1f0e559..22101ffa 100644 --- a/packages/oneclient_app/src/view/app/settings/minecraft.rs +++ b/packages/oneclient_app/src/view/app/settings/minecraft.rs @@ -1,15 +1,15 @@ use freya::prelude::*; use oneclient_common::Patch; -use oneclient_core::settings::{PackageUpdateMode, ProfileUpdate, Resolution}; #[cfg(any(target_os = "linux", windows))] use oneclient_core::settings::SettingsOsExtra; +use oneclient_core::settings::{PackageUpdateMode, ProfileUpdate, Resolution}; use super::settings_page; +#[cfg(any(target_os = "linux", windows))] +use crate::components::toggle_controlled; use crate::components::{ Dropdown, Icon, IconType, TextInput, memory_field, toggle, validate_number, }; -#[cfg(any(target_os = "linux", windows))] -use crate::components::toggle_controlled; use crate::hooks::{use_dispatch, use_settings_snapshot}; use crate::theme::colors; use crate::view::app::settings::{section_header, settings_row}; @@ -219,10 +219,7 @@ fn build_update( } /// Dispatched separately from [`build_update`] which debounces keystrokes a dropdown has no intermediate states -fn update_mode_field( - selected: PackageUpdateMode, - dispatch: crate::Actions, -) -> impl IntoElement { +fn update_mode_field(selected: PackageUpdateMode, dispatch: crate::Actions) -> impl IntoElement { let options: Vec = PackageUpdateMode::ALL .iter() .map(|mode| mode.label().to_string()) @@ -276,4 +273,3 @@ fn resolution_field(width: State, height: State) -> impl IntoEle ) .into_element() } - diff --git a/packages/oneclient_app/src/view/app/settings/mod.rs b/packages/oneclient_app/src/view/app/settings/mod.rs index 44173202..5f41b7b1 100644 --- a/packages/oneclient_app/src/view/app/settings/mod.rs +++ b/packages/oneclient_app/src/view/app/settings/mod.rs @@ -14,9 +14,9 @@ use freya::prelude::*; pub use accounts::SettingsAccounts; pub use apis::SettingsApis; +pub use appearance::SettingsAppearance; pub use changelog::SettingsChangelog; pub use developer::SettingsDeveloper; -pub use appearance::SettingsAppearance; pub use java::SettingsJava; pub use language::SettingsLanguage; pub use launcher::SettingsLauncher; diff --git a/packages/oneclient_app/src/view/app/settings/storage.rs b/packages/oneclient_app/src/view/app/settings/storage.rs index 28b9f99f..9c3d35e3 100644 --- a/packages/oneclient_app/src/view/app/settings/storage.rs +++ b/packages/oneclient_app/src/view/app/settings/storage.rs @@ -322,10 +322,7 @@ fn unused_cache_description(entry: &ReclaimableEntry) -> String { ) } -fn legacy_content_description( - content: &ReclaimableEntry, - natives: &ReclaimableEntry, -) -> String { +fn legacy_content_description(content: &ReclaimableEntry, natives: &ReclaimableEntry) -> String { if content.is_empty() && natives.is_empty() { return "Nothing here - no old cluster content and no unused natives.".to_string(); } diff --git a/packages/oneclient_app/src/view/app/stats.rs b/packages/oneclient_app/src/view/app/stats.rs index 744c0fd3..49340a53 100644 --- a/packages/oneclient_app/src/view/app/stats.rs +++ b/packages/oneclient_app/src/view/app/stats.rs @@ -3,7 +3,7 @@ use freya::prelude::*; use crate::components::ScrollArea; use crate::hooks::{try_global_analytics, use_global_analytics}; use crate::theme::colors; -use crate::ui::{centered_note}; +use crate::ui::centered_note; use crate::view::app::{analytics_body, analytics_placeholder}; #[derive(PartialEq)] diff --git a/packages/oneclient_app/src/view/console.rs b/packages/oneclient_app/src/view/console.rs index 64563ac5..eae4b184 100644 --- a/packages/oneclient_app/src/view/console.rs +++ b/packages/oneclient_app/src/view/console.rs @@ -85,7 +85,11 @@ fn raise_to_debug() { } fn restore_directives() { - let Some(previous) = PREVIOUS_DIRECTIVES.lock().expect("console directives").take() else { + let Some(previous) = PREVIOUS_DIRECTIVES + .lock() + .expect("console directives") + .take() + else { return; }; diff --git a/packages/oneclient_app/src/view/onboarding/bundles/mod.rs b/packages/oneclient_app/src/view/onboarding/bundles/mod.rs index c6619d61..647d13d3 100644 --- a/packages/oneclient_app/src/view/onboarding/bundles/mod.rs +++ b/packages/oneclient_app/src/view/onboarding/bundles/mod.rs @@ -1,12 +1,15 @@ use std::collections::{HashMap, HashSet}; use freya::prelude::*; -use oneclient_core::clusters::Cluster; use oneclient_content::packages::ProviderId; +use oneclient_core::clusters::Cluster; use oneclient_core::{BundleArchive, BundleFile, BundleFileKind}; use crate::components::ScrollArea; -use crate::hooks::{ClusterBundles, onboarding_bundles_items, package_meta_batch, query_error, query_is_loading, use_onboarding_bundles, use_onboarding_selection, use_package_meta_batch}; +use crate::hooks::{ + ClusterBundles, onboarding_bundles_items, package_meta_batch, query_error, query_is_loading, + use_onboarding_bundles, use_onboarding_selection, use_package_meta_batch, +}; type MetaMap = HashMap; use crate::routes::Route; @@ -341,55 +344,50 @@ fn opt_in_card( let yes_ids = all_ids.clone(); let no_bundle = bundle.clone(); - let mut card = rect() - .vertical() - .width(Size::fill()) - .spacing(10.) - .child( - label() - .text(format!("Do you want to install {name} mods?")) - .font_size(15.) - .font_weight(FontWeight::SEMI_BOLD) - .color(colors::fg_primary()), - ); + let mut card = rect().vertical().width(Size::fill()).spacing(10.).child( + label() + .text(format!("Do you want to install {name} mods?")) + .font_size(15.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ); - card = card - .child( - rect() - .horizontal() - .width(Size::fill()) - .spacing(8.) - .content(Content::Flex) - .child( - rect() - .width(Size::flex(1.0)) - .height(Size::px(ANSWER_H)) - .child(choice_row_sized( - "Yes", - &format!("{} mods", yes_bundle.mod_count()), - wanted, - Size::fill(), - move |()| { - if wanted { - return; - } - set_bundle_versions(selected, user_touched, &yes_bundle, &yes_ids) - }, - )), - ) - .child( - rect() - .width(Size::flex(1.0)) - .height(Size::px(ANSWER_H)) - .child(choice_row_sized( - "No", - "", - !wanted, - Size::fill(), - move |()| set_bundle_versions(selected, user_touched, &no_bundle, &[]), - )), - ), - ); + card = card.child( + rect() + .horizontal() + .width(Size::fill()) + .spacing(8.) + .content(Content::Flex) + .child( + rect() + .width(Size::flex(1.0)) + .height(Size::px(ANSWER_H)) + .child(choice_row_sized( + "Yes", + &format!("{} mods", yes_bundle.mod_count()), + wanted, + Size::fill(), + move |()| { + if wanted { + return; + } + set_bundle_versions(selected, user_touched, &yes_bundle, &yes_ids) + }, + )), + ) + .child( + rect() + .width(Size::flex(1.0)) + .height(Size::px(ANSWER_H)) + .child(choice_row_sized( + "No", + "", + !wanted, + Size::fill(), + move |()| set_bundle_versions(selected, user_touched, &no_bundle, &[]), + )), + ), + ); if wanted && name.eq_ignore_ascii_case(FPS_WARNING_BUNDLE) { card = card.child(fps_warning_banner()); diff --git a/packages/oneclient_app/src/view/onboarding/downloading/mod.rs b/packages/oneclient_app/src/view/onboarding/downloading/mod.rs index 349bf007..9cb51f3c 100644 --- a/packages/oneclient_app/src/view/onboarding/downloading/mod.rs +++ b/packages/oneclient_app/src/view/onboarding/downloading/mod.rs @@ -4,14 +4,14 @@ use std::time::{Duration, Instant}; use freya::animation::*; use freya::prelude::*; use freya::router::RouterContext; -use oneclient_events::{ - EventBus, GroupedProgressEvent, GroupedProgressSession, ProgressEvent, TaskCategory, -}; use oneclient_core::{ BundleArchive, BundleFile, Cluster, ImportTarget, MigrationSource, SentryExclusion, VersionMetadata, }; use oneclient_db::models::OverrideType; +use oneclient_events::{ + EventBus, GroupedProgressEvent, GroupedProgressSession, ProgressEvent, TaskCategory, +}; use tokio::sync::mpsc; use uuid::Uuid; @@ -502,12 +502,7 @@ fn build_plans( let kept = kept_package_ids(cb.cluster.id, &cb.archives, selected); let mut overrides = Vec::new(); for archive in &cb.archives { - overrides.extend(archive_overrides( - cb.cluster.id, - archive, - selected, - &kept, - )); + overrides.extend(archive_overrides(cb.cluster.id, archive, selected, &kept)); } ClusterPlan { cluster_id: cb.cluster.id, @@ -693,7 +688,9 @@ fn run_install_batch(plans: Vec, predownload: bool, handles: Instal apply_grouped(&mut local, event); } while let Ok(notification) = notif_rx.try_recv() { - if let oneclient_events::Event::Progress(ProgressEvent::Grouped(event)) = notification { + if let oneclient_events::Event::Progress(ProgressEvent::Grouped(event)) = + notification + { apply_grouped(&mut local, event); } } @@ -799,15 +796,18 @@ async fn install_one( "Saving your package choices...".to_string(), )); } - oneclient_core::set_bundle_package_overrides(plan.cluster_id, &plan.overrides, &state.services.content()) - .await?; + oneclient_core::set_bundle_package_overrides( + plan.cluster_id, + &plan.overrides, + &state.services.content(), + ) + .await?; if !predownload { return Ok(()); } - let session = - GroupedProgressSession::start(events, format!("Downloading {}", plan.mc_version)); + let session = GroupedProgressSession::start(events, format!("Downloading {}", plan.mc_version)); let _ = ui_tx.send(InstallUiEvent::Activity(format!( "Installing mods & content for {}...", diff --git a/packages/oneclient_app/src/view/onboarding/downloading/progress.rs b/packages/oneclient_app/src/view/onboarding/downloading/progress.rs index f9246e4f..21d6f3b1 100644 --- a/packages/oneclient_app/src/view/onboarding/downloading/progress.rs +++ b/packages/oneclient_app/src/view/onboarding/downloading/progress.rs @@ -251,10 +251,7 @@ fn build_stats(view: StatsView) -> Option { } else { // Both readings come off the same smoothed rate so they always agree match transfer.and_then(|t| t.eta_secs) { - Some(remaining) => parts.push(format!( - "~{} left", - format_durations(remaining as i64) - )), + Some(remaining) => parts.push(format!("~{} left", format_durations(remaining as i64))), None => parts.push("~- left".to_string()), } diff --git a/packages/oneclient_app/src/view/onboarding/downloading/tips.rs b/packages/oneclient_app/src/view/onboarding/downloading/tips.rs index 1759a38c..5e2e9112 100644 --- a/packages/oneclient_app/src/view/onboarding/downloading/tips.rs +++ b/packages/oneclient_app/src/view/onboarding/downloading/tips.rs @@ -24,8 +24,7 @@ const ONBOARDING_TIPS_BACKUP: &[&str] = &[ "You can sleep through a thunderstorm, even during the day", ]; -const FUNFACTS_URL: &str = - "https://data-v2.polyfrost.org/oneclient/funfacts.txt"; +const FUNFACTS_URL: &str = "https://data-v2.polyfrost.org/oneclient/funfacts.txt"; fn normalize_line_breaks(value: &str) -> String { value.replace("/n", "\n").replace("\\n", "\n") diff --git a/packages/oneclient_app/src/view/onboarding/test_support.rs b/packages/oneclient_app/src/view/onboarding/test_support.rs index 3ce0fc03..87f25aa8 100644 --- a/packages/oneclient_app/src/view/onboarding/test_support.rs +++ b/packages/oneclient_app/src/view/onboarding/test_support.rs @@ -1,6 +1,6 @@ -use oneclient_core::clusters::{Cluster, ClusterStage}; use oneclient_common::domain::{ContentType, GameLoader}; use oneclient_content::packages::types::ExternalFile; +use oneclient_core::clusters::{Cluster, ClusterStage}; use oneclient_core::{Bundle, BundleArchive, BundleFile, BundleFileKind, BundleManifest}; use std::path::PathBuf; diff --git a/packages/oneclient_app/src/view/relocating.rs b/packages/oneclient_app/src/view/relocating.rs index fbd35566..889e84ef 100644 --- a/packages/oneclient_app/src/view/relocating.rs +++ b/packages/oneclient_app/src/view/relocating.rs @@ -46,7 +46,11 @@ fn copying(plan: &RelocationPlan, copied: u64, total: u64) -> Element { let total = if total > 0 { total } else { plan.bytes }.max(1); screen() - .child(heading(IconType::FolderDownload, "Moving your data folder", colors::fg_primary())) + .child(heading( + IconType::FolderDownload, + "Moving your data folder", + colors::fg_primary(), + )) .child(path_block("From", &plan.from)) .child(path_block("To", &plan.to)) .child(progress_track( @@ -90,7 +94,11 @@ fn moved(outcome: &RelocationOutcome, actions: Actions) -> Element { } screen() - .child(heading(IconType::FolderCheck, "Game data moved", colors::brand())) + .child(heading( + IconType::FolderCheck, + "Game data moved", + colors::brand(), + )) .child(note(body, colors::brand())) .child( label() @@ -103,26 +111,28 @@ fn moved(outcome: &RelocationOutcome, actions: Actions) -> Element { .color(colors::fg_secondary()), ) .child( - buttons() - .child(back_button(actions)) - .child( - Button::new() - .primary() - .on_press(|_| { - let platform = Platform::get(); - Platform::get().with_window(Platform::window_id(), move |window| { - platform.close_window(window.id()); - }); - }) - .text("Quit OneClient"), - ), + buttons().child(back_button(actions)).child( + Button::new() + .primary() + .on_press(|_| { + let platform = Platform::get(); + Platform::get().with_window(Platform::window_id(), move |window| { + platform.close_window(window.id()); + }); + }) + .text("Quit OneClient"), + ), ) .into_element() } fn failed(message: String, actions: Actions) -> Element { screen() - .child(heading(IconType::AlertTriangle, "The move didn't finish", colors::danger())) + .child(heading( + IconType::AlertTriangle, + "The move didn't finish", + colors::danger(), + )) .child(note(message, colors::danger())) .child( label() diff --git a/packages/oneclient_app/src/view/startup.rs b/packages/oneclient_app/src/view/startup.rs index 79077dbd..79d6648b 100644 --- a/packages/oneclient_app/src/view/startup.rs +++ b/packages/oneclient_app/src/view/startup.rs @@ -3,12 +3,12 @@ use freya::{prelude::*, router::*}; use crate::AppAssets; use crate::Route; -use crate::hooks::{ - terms_document, terms_is_loading, use_launcher, use_notifications_snapshot, use_settings_snapshot, - use_splash, use_terms, -}; -use crate::hooks::use_dispatch; use crate::components::{Button, progress_track}; +use crate::hooks::use_dispatch; +use crate::hooks::{ + terms_document, terms_is_loading, use_launcher, use_notifications_snapshot, + use_settings_snapshot, use_splash, use_terms, +}; use crate::theme::colors; /// Startup is a sequence of boolean gates, not measurable work, so the bar steps @@ -227,7 +227,9 @@ impl Component for Startup { if is_error { content = content .child(rect().height(Size::px(6.))) - .child(RecoveryActions { snapshots: launcher.snapshots }) + .child(RecoveryActions { + snapshots: launcher.snapshots, + }) .child( label() .text(format!( diff --git a/packages/oneclient_auth/src/diagnostics.rs b/packages/oneclient_auth/src/diagnostics.rs index af0a1d91..f3605a59 100644 --- a/packages/oneclient_auth/src/diagnostics.rs +++ b/packages/oneclient_auth/src/diagnostics.rs @@ -4,7 +4,7 @@ use oneclient_net::{NetworkFailure, classify_network_failure}; use reqwest::StatusCode; -use super::error::{friendly_xbox_error, MinecraftAuthError, MinecraftAuthStep}; +use super::error::{MinecraftAuthError, MinecraftAuthStep, friendly_xbox_error}; #[derive(Debug, Clone, serde::Serialize)] pub struct AuthErrorGuidance { @@ -298,7 +298,9 @@ fn xerr(code: u64) -> MinecraftAuthError { MinecraftAuthError::XboxError { step: MinecraftAuthStep::XstsAuthorize, error_code: code, - message: friendly_xbox_error(code).unwrap_or("Xbox rejected sign-in.").to_string(), + message: friendly_xbox_error(code) + .unwrap_or("Xbox rejected sign-in.") + .to_string(), redirect: None, } } diff --git a/packages/oneclient_auth/src/error.rs b/packages/oneclient_auth/src/error.rs index 2a7242c6..a3014884 100644 --- a/packages/oneclient_auth/src/error.rs +++ b/packages/oneclient_auth/src/error.rs @@ -35,7 +35,10 @@ pub enum MinecraftAuthError { }, // Rendered via `error_chain` reqwest's `Display` names only the URL and the // cause we need (certificate handshake DNS refused) is under it - #[error("failed to request using HTTP during MSA step {step:?}: {}", oneclient_net::error_chain(source))] + #[error( + "failed to request using HTTP during MSA step {step:?}: {}", + oneclient_net::error_chain(source) + )] RequestError { step: MinecraftAuthStep, #[source] @@ -79,12 +82,24 @@ pub enum MinecraftAuthError { pub fn friendly_xbox_error(code: u64) -> Option<&'static str> { Some(match code { 2_148_916_227 => "This account has been banned or suspended from Xbox.", - 2_148_916_229 => "This account is a child account that must be added to a Family group by an adult before signing in.", - 2_148_916_233 => "This Microsoft account does not have an Xbox profile yet. Create one at xbox.com, then try again.", - 2_148_916_234 => "This account has not accepted the Xbox Terms of Service. Sign in at xbox.com to accept them first.", - 2_148_916_235 => "Xbox Live is not available in your country or region, so sign-in is blocked.", - 2_148_916_236 | 2_148_916_237 => "This account requires adult verification (South Korea) before it can sign in.", - 2_148_916_238 => "This is a child account. An adult must add it to a Microsoft Family group before it can sign in.", + 2_148_916_229 => { + "This account is a child account that must be added to a Family group by an adult before signing in." + } + 2_148_916_233 => { + "This Microsoft account does not have an Xbox profile yet. Create one at xbox.com, then try again." + } + 2_148_916_234 => { + "This account has not accepted the Xbox Terms of Service. Sign in at xbox.com to accept them first." + } + 2_148_916_235 => { + "Xbox Live is not available in your country or region, so sign-in is blocked." + } + 2_148_916_236 | 2_148_916_237 => { + "This account requires adult verification (South Korea) before it can sign in." + } + 2_148_916_238 => { + "This is a child account. An adult must add it to a Microsoft Family group before it can sign in." + } _ => return None, }) } diff --git a/packages/oneclient_auth/src/lib.rs b/packages/oneclient_auth/src/lib.rs index ce401a6b..920dc164 100644 --- a/packages/oneclient_auth/src/lib.rs +++ b/packages/oneclient_auth/src/lib.rs @@ -11,7 +11,7 @@ mod service; mod store; pub use data::{ - AccountKind, BrowserLogin, DeviceCodeLogin, MicrosoftLoginSession, MinecraftAccount, + AccountKind, BrowserLogin, DeviceCodeLogin, MicrosoftLoginSession, MinecraftAccount, }; pub use diagnostics::{AuthErrorGuidance, AuthErrorSample, diagnose_auth_error, preview_samples}; pub use error::{AuthError, AuthResult, MinecraftAuthError, MinecraftAuthStep}; diff --git a/packages/oneclient_auth/src/msa.rs b/packages/oneclient_auth/src/msa.rs index b485f71b..b94a721e 100644 --- a/packages/oneclient_auth/src/msa.rs +++ b/packages/oneclient_auth/src/msa.rs @@ -1,13 +1,12 @@ - use std::future::Future; use std::time::Duration; -use base64::prelude::BASE64_URL_SAFE_NO_PAD; use base64::Engine; +use base64::prelude::BASE64_URL_SAFE_NO_PAD; use chrono::{DateTime, Utc}; use reqwest::Client; -use serde::de::Error as DeError; use serde::Deserialize; +use serde::de::Error as DeError; use serde_json::json; use sha2::{Digest, Sha256}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -16,20 +15,17 @@ use url::Url; use uuid::Uuid; use super::data::{AccountKind, BrowserLogin, DeviceCodeLogin, MinecraftAccount}; -use super::error::{friendly_xbox_error, MinecraftAuthError, MinecraftAuthStep}; +use super::error::{MinecraftAuthError, MinecraftAuthStep, friendly_xbox_error}; use oneclient_common::constants::{MICROSOFT_CLIENT_ID, MINECRAFT_SCOPES}; -const DEVICE_CODE_URL: &str = - "https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode"; +const DEVICE_CODE_URL: &str = "https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode"; -const AUTHORIZE_URL: &str = - "https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize"; +const AUTHORIZE_URL: &str = "https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize"; const TOKEN_URL: &str = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token"; const AUTH_STEPS: u64 = 5; - #[tracing::instrument(level = "debug", skip_all)] pub async fn begin_device_login(client: &Client) -> Result { request_device_code(client).await @@ -446,9 +442,7 @@ async fn poll_device_token( "expired_token" => return Err(MinecraftAuthError::DeviceAuthorizationExpired), other => { return Err(MinecraftAuthError::DeviceAuthorizationFailed { - error: err - .error_description - .unwrap_or_else(|| other.to_string()), + error: err.error_description.unwrap_or_else(|| other.to_string()), }); } } @@ -488,7 +482,8 @@ async fn refresh_msa_token( source, })?; - parse_json_response(res, MinecraftAuthStep::RefreshToken).await + parse_json_response(res, MinecraftAuthStep::RefreshToken) + .await .map(MsaToken::from_response) } @@ -790,7 +785,10 @@ async fn parse_json_response Deserialize<'de>>( serde_json::from_str(&text).map_err(|source| { if !status.is_success() { - MinecraftAuthError::ServiceError { step, status_code: status } + MinecraftAuthError::ServiceError { + step, + status_code: status, + } } else { MinecraftAuthError::DeserializeError { step, diff --git a/packages/oneclient_auth/src/offline.rs b/packages/oneclient_auth/src/offline.rs index d5d0e962..fe292200 100644 --- a/packages/oneclient_auth/src/offline.rs +++ b/packages/oneclient_auth/src/offline.rs @@ -2,8 +2,8 @@ use chrono::{Duration, Utc}; use md5::{Digest, Md5}; use uuid::Uuid; -use super::error::AuthError; use super::data::{AccountKind, MinecraftAccount}; +use super::error::AuthError; use crate::error::AuthResult; pub fn offline_uuid(username: &str) -> Uuid { @@ -23,8 +23,7 @@ pub fn validate_offline_username(username: &str) -> AuthResult<()> { if !(3..=16).contains(&len) { return Err(AuthError::InvalidOfflineUsername { reason: "username must be 3-16 characters".into(), - } - ); + }); } if !username @@ -33,8 +32,7 @@ pub fn validate_offline_username(username: &str) -> AuthResult<()> { { return Err(AuthError::InvalidOfflineUsername { reason: "username may only contain letters, digits, and underscores".into(), - } - ); + }); } Ok(()) diff --git a/packages/oneclient_auth/src/service.rs b/packages/oneclient_auth/src/service.rs index 136571a7..e6502821 100644 --- a/packages/oneclient_auth/src/service.rs +++ b/packages/oneclient_auth/src/service.rs @@ -15,411 +15,418 @@ use crate::store::{self, CredentialsStore}; /// Stable id for the Microsoft login's progress so a front-end can recognise /// it among everything else on the bus pub const MICROSOFT_LOGIN_PROGRESS: Uuid = - Uuid::from_u128(0x4D53_4141_5554_4800_0000_0000_0000_0001); + Uuid::from_u128(0x4D53_4141_5554_4800_0000_0000_0000_0001); /// `finish_microsoft_login` takes the listener and runs with it while the /// token stays here so a cancel from the UI has something to flip struct PendingLogin { - /// `None` once a flow owns the listener or once a cancel freed the port - /// before any flow got that far - browser: Option, - cancel: CancellationToken, + /// `None` once a flow owns the listener or once a cancel freed the port + /// before any flow got that far + browser: Option, + cancel: CancellationToken, } pub struct AuthService { - store: Mutex, - /// Keyed by CSRF state token - pending_logins: Mutex>, - /// Serialises token renewal per account Microsoft rotates the refresh token - /// on every use so concurrent renewals would sign the account out - refresh_guards: StdMutex>>>, - net: RequestClient, - events: EventBus, + store: Mutex, + /// Keyed by CSRF state token + pending_logins: Mutex>, + /// Serialises token renewal per account Microsoft rotates the refresh token + /// on every use so concurrent renewals would sign the account out + refresh_guards: StdMutex>>>, + net: RequestClient, + events: EventBus, } impl AuthService { - /// A missing or unreadable `auth.json` yields an empty store rather than - /// failing so a corrupt file means "sign in again" not "will not start" - pub async fn load(net: RequestClient, events: EventBus) -> AuthResult { - Ok(Self::with_store(CredentialsStore::load().await?, net, events)) - } + /// A missing or unreadable `auth.json` yields an empty store rather than + /// failing so a corrupt file means "sign in again" not "will not start" + pub async fn load(net: RequestClient, events: EventBus) -> AuthResult { + Ok(Self::with_store( + CredentialsStore::load().await?, + net, + events, + )) + } - #[must_use] - pub fn with_store(store: CredentialsStore, net: RequestClient, events: EventBus) -> Self { - Self { - store: Mutex::new(store), - pending_logins: Mutex::new(HashMap::new()), - refresh_guards: StdMutex::new(HashMap::new()), - net, - events, - } - } + #[must_use] + pub fn with_store(store: CredentialsStore, net: RequestClient, events: EventBus) -> Self { + Self { + store: Mutex::new(store), + pending_logins: Mutex::new(HashMap::new()), + refresh_guards: StdMutex::new(HashMap::new()), + net, + events, + } + } - #[tracing::instrument(skip_all)] - pub async fn begin_microsoft_login(&self) -> AuthResult { - tracing::info!("beginning Microsoft login"); - let client = self.net.http(); + #[tracing::instrument(skip_all)] + pub async fn begin_microsoft_login(&self) -> AuthResult { + tracing::info!("beginning Microsoft login"); + let client = self.net.http(); - let (browser, pending) = msa::begin_browser_login().await?; - let device = msa::begin_device_login(client).await?; + let (browser, pending) = msa::begin_browser_login().await?; + let device = msa::begin_device_login(client).await?; - let mut logins = self.pending_logins.lock().await; - // A login cancelled before its flow ever started has nobody left to remove - // it so the next login sweeps it up - logins.retain(|_, login| !login.cancel.is_cancelled()); - logins.insert( - browser.state.clone(), - PendingLogin { - browser: Some(pending), - cancel: CancellationToken::new(), - }, - ); + let mut logins = self.pending_logins.lock().await; + // A login cancelled before its flow ever started has nobody left to remove + // it so the next login sweeps it up + logins.retain(|_, login| !login.cancel.is_cancelled()); + logins.insert( + browser.state.clone(), + PendingLogin { + browser: Some(pending), + cancel: CancellationToken::new(), + }, + ); - Ok(MicrosoftLoginSession { browser, device }) - } + Ok(MicrosoftLoginSession { browser, device }) + } - /// Only one caller can ever claim a login - async fn claim_pending_login( - &self, - state_token: &str, - ) -> AuthResult<(PendingBrowserLogin, CancellationToken)> { - let mut logins = self.pending_logins.lock().await; + /// Only one caller can ever claim a login + async fn claim_pending_login( + &self, + state_token: &str, + ) -> AuthResult<(PendingBrowserLogin, CancellationToken)> { + let mut logins = self.pending_logins.lock().await; - // Cancelled between `begin` and here the listener is already gone and - // there is nothing to run - if logins - .get(state_token) - .is_some_and(|login| login.cancel.is_cancelled()) - { - logins.remove(state_token); - return Err(AuthError::LoginCancelled); - } + // Cancelled between `begin` and here the listener is already gone and + // there is nothing to run + if logins + .get(state_token) + .is_some_and(|login| login.cancel.is_cancelled()) + { + logins.remove(state_token); + return Err(AuthError::LoginCancelled); + } - let login = logins.get_mut(state_token).ok_or(AuthError::Minecraft( - MinecraftAuthError::BrowserLoginNotFound, - ))?; - let cancel = login.cancel.clone(); - let browser = login.browser.take().ok_or(AuthError::Minecraft( - MinecraftAuthError::BrowserLoginNotFound, - ))?; + let login = logins.get_mut(state_token).ok_or(AuthError::Minecraft( + MinecraftAuthError::BrowserLoginNotFound, + ))?; + let cancel = login.cancel.clone(); + let browser = login.browser.take().ok_or(AuthError::Minecraft( + MinecraftAuthError::BrowserLoginNotFound, + ))?; - Ok((browser, cancel)) - } + Ok((browser, cancel)) + } - #[tracing::instrument(skip_all)] - pub async fn finish_microsoft_login( - &self, - session: MicrosoftLoginSession, - ) -> AuthResult { - tracing::info!("finishing Microsoft login"); + #[tracing::instrument(skip_all)] + pub async fn finish_microsoft_login( + &self, + session: MicrosoftLoginSession, + ) -> AuthResult { + tracing::info!("finishing Microsoft login"); - let (pending, cancel) = self.claim_pending_login(&session.browser.state).await?; + let (pending, cancel) = self.claim_pending_login(&session.browser.state).await?; - let events = self.events.clone(); - let flow = msa::finish_dual_login( - self.net.http(), - pending, - &session.device, - |label, current, total| { - events.progress(MICROSOFT_LOGIN_PROGRESS, label, current, total); - }, - ); + let events = self.events.clone(); + let flow = msa::finish_dual_login( + self.net.http(), + pending, + &session.device, + |label, current, total| { + events.progress(MICROSOFT_LOGIN_PROGRESS, label, current, total); + }, + ); - // Losing the race drops `flow` and with it the device-code poll the - // loopback listener and whichever request was mid-flight - let result = tokio::select! { - biased; - () = cancel.cancelled() => Err(AuthError::LoginCancelled), - res = flow => res.map_err(AuthError::from), - }; + // Losing the race drops `flow` and with it the device-code poll the + // loopback listener and whichever request was mid-flight + let result = tokio::select! { + biased; + () = cancel.cancelled() => Err(AuthError::LoginCancelled), + res = flow => res.map_err(AuthError::from), + }; - // Whoever runs the flow owns the cleanup a cancel only flips the token - self.pending_logins.lock().await.remove(&session.browser.state); + // Whoever runs the flow owns the cleanup a cancel only flips the token + self.pending_logins + .lock() + .await + .remove(&session.browser.state); - // Drives the card to 100% so the front-end can clear it a cancelled flow - // needs this as much as a successful one - self.events - .progress(MICROSOFT_LOGIN_PROGRESS, "Signed in", 1, 1); + // Drives the card to 100% so the front-end can clear it a cancelled flow + // needs this as much as a successful one + self.events + .progress(MICROSOFT_LOGIN_PROGRESS, "Signed in", 1, 1); - // Re-read rather than trusting `result` the last handshake step can - // complete in the same breath as a cancel - if cancel.is_cancelled() { - tracing::info!("Microsoft login cancelled by the user"); - return Err(AuthError::LoginCancelled); - } + // Re-read rather than trusting `result` the last handshake step can + // complete in the same breath as a cancel + if cancel.is_cancelled() { + tracing::info!("Microsoft login cancelled by the user"); + return Err(AuthError::LoginCancelled); + } - if let Err(err) = &result { - let mut chain = String::new(); - let mut source = std::error::Error::source(err); - while let Some(cause) = source { - chain.push_str(&format!("\n caused by: {cause}")); - source = cause.source(); - } - tracing::warn!("Microsoft login failed: {err}{chain}"); - } + if let Err(err) = &result { + let mut chain = String::new(); + let mut source = std::error::Error::source(err); + while let Some(cause) = source { + chain.push_str(&format!("\n caused by: {cause}")); + source = cause.source(); + } + tracing::warn!("Microsoft login failed: {err}{chain}"); + } - let account = result?; - tracing::info!(username = %account.username, "Microsoft login succeeded"); - self.store - .lock() - .await - .commit_account(account, &self.events) - .await - } + let account = result?; + tracing::info!(username = %account.username, "Microsoft login succeeded"); + self.store + .lock() + .await + .commit_account(account, &self.events) + .await + } - /// The entry is deliberately left in the map `finish_microsoft_login` may - /// not have claimed it yet and must find the cancelled token not a hole - #[tracing::instrument(level = "debug", skip_all)] - pub async fn cancel_microsoft_login(&self, state_token: &str) { - let mut logins = self.pending_logins.lock().await; - let Some(login) = logins.get_mut(state_token) else { - return; - }; + /// The entry is deliberately left in the map `finish_microsoft_login` may + /// not have claimed it yet and must find the cancelled token not a hole + #[tracing::instrument(level = "debug", skip_all)] + pub async fn cancel_microsoft_login(&self, state_token: &str) { + let mut logins = self.pending_logins.lock().await; + let Some(login) = logins.get_mut(state_token) else { + return; + }; - login.cancel.cancel(); - // Frees the port now if no flow ever took the listener once one has the - // flow drops it as it unwinds - login.browser = None; - } + login.cancel.cancel(); + // Frees the port now if no flow ever took the listener once one has the + // flow drops it as it unwinds + login.browser = None; + } - #[tracing::instrument(skip(self), fields(username = %username))] - pub async fn add_offline_account(&self, username: String) -> AuthResult { - self.store - .lock() - .await - .add_offline_account_and_save(username) - .await - } + #[tracing::instrument(skip(self), fields(username = %username))] + pub async fn add_offline_account(&self, username: String) -> AuthResult { + self.store + .lock() + .await + .add_offline_account_and_save(username) + .await + } - pub async fn list_accounts(&self) -> Vec { - self.store.lock().await.list_accounts() - } + pub async fn list_accounts(&self) -> Vec { + self.store.lock().await.list_accounts() + } - pub async fn get_account(&self, id: Uuid) -> Option { - self.store.lock().await.get_account(id).cloned() - } + pub async fn get_account(&self, id: Uuid) -> Option { + self.store.lock().await.get_account(id).cloned() + } - #[tracing::instrument(level = "debug", skip_all)] - pub async fn default_account(&self) -> AuthResult> { - self.store.lock().await.default_account().await - } + #[tracing::instrument(level = "debug", skip_all)] + pub async fn default_account(&self) -> AuthResult> { + self.store.lock().await.default_account().await + } - #[tracing::instrument(level = "debug", skip(self), fields(?id))] - pub async fn set_default_account(&self, id: Option) -> AuthResult<()> { - self.store.lock().await.set_default_user(id).await - } + #[tracing::instrument(level = "debug", skip(self), fields(?id))] + pub async fn set_default_account(&self, id: Option) -> AuthResult<()> { + self.store.lock().await.set_default_user(id).await + } - #[tracing::instrument(skip(self), fields(%id))] - pub async fn remove_account(&self, id: Uuid) -> AuthResult<()> { - self.store.lock().await.remove_account(id).await?; - Ok(()) - } + #[tracing::instrument(skip(self), fields(%id))] + pub async fn remove_account(&self, id: Uuid) -> AuthResult<()> { + self.store.lock().await.remove_account(id).await?; + Ok(()) + } - pub async fn has_microsoft_account(&self) -> bool { - self.store.lock().await.has_microsoft_account() - } + pub async fn has_microsoft_account(&self) -> bool { + self.store.lock().await.has_microsoft_account() + } - fn refresh_guard(&self, id: Uuid) -> Arc> { - Arc::clone( - self.refresh_guards - .lock() - .expect("refresh guard registry poisoned") - .entry(id) - .or_default(), - ) - } + fn refresh_guard(&self, id: Uuid) -> Arc> { + Arc::clone( + self.refresh_guards + .lock() + .expect("refresh guard registry poisoned") + .entry(id) + .or_default(), + ) + } - async fn account_snapshot(&self, id: Uuid) -> AuthResult { - self.store - .lock() - .await - .get_account(id) - .cloned() - .ok_or(AuthError::AccountNotFound(id)) - } + async fn account_snapshot(&self, id: Uuid) -> AuthResult { + self.store + .lock() + .await + .get_account(id) + .cloned() + .ok_or(AuthError::AccountNotFound(id)) + } - /// The store lock is never held across the Microsoft handshake only around - /// the reads and the final write - #[tracing::instrument(level = "debug", skip(self), fields(%id))] - async fn renew_token(&self, id: Uuid, force: bool) -> AuthResult { - let existing = self.account_snapshot(id).await?; - if !existing.is_microsoft() || (!force && !existing.is_expired()) { - return Ok(existing); - } + /// The store lock is never held across the Microsoft handshake only around + /// the reads and the final write + #[tracing::instrument(level = "debug", skip(self), fields(%id))] + async fn renew_token(&self, id: Uuid, force: bool) -> AuthResult { + let existing = self.account_snapshot(id).await?; + if !existing.is_microsoft() || (!force && !existing.is_expired()) { + return Ok(existing); + } - let guard = self.refresh_guard(id); - let _serialised = guard.lock().await; + let guard = self.refresh_guard(id); + let _serialised = guard.lock().await; - // Re-read under the guard whoever held it may have just refreshed - let existing = self.account_snapshot(id).await?; - if !existing.is_microsoft() || (!force && !existing.is_expired()) { - return Ok(existing); - } + // Re-read under the guard whoever held it may have just refreshed + let existing = self.account_snapshot(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(self.net.http(), &existing).await { - Ok(refreshed) => { - self.store - .lock() - .await - .commit_refreshed_account(refreshed.clone()) - .await?; - Ok(refreshed) - } - Err(err) => { - let err = 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::info!(username = %existing.username, "renewing Microsoft access token"); + match msa::refresh_microsoft_account(self.net.http(), &existing).await { + Ok(refreshed) => { + self.store + .lock() + .await + .commit_refreshed_account(refreshed.clone()) + .await?; + Ok(refreshed) + } + Err(err) => { + let err = 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(self), fields(%id))] - pub async fn refresh_account(&self, id: Uuid) -> AuthResult { - self.renew_token(id, true).await - } + #[tracing::instrument(level = "debug", skip(self), fields(%id))] + pub async fn refresh_account(&self, id: Uuid) -> AuthResult { + self.renew_token(id, true).await + } - #[tracing::instrument(level = "debug", skip_all)] - pub async fn refresh_all_accounts(&self) -> AuthResult> { - let ids: Vec = self.store.lock().await.users.keys().copied().collect(); - let mut refreshed = Vec::with_capacity(ids.len()); + #[tracing::instrument(level = "debug", skip_all)] + pub async fn refresh_all_accounts(&self) -> AuthResult> { + let ids: Vec = self.store.lock().await.users.keys().copied().collect(); + let mut refreshed = Vec::with_capacity(ids.len()); - for id in ids { - refreshed.push(self.renew_token(id, true).await?); - } + for id in ids { + refreshed.push(self.renew_token(id, true).await?); + } - Ok(refreshed) - } + Ok(refreshed) + } - #[tracing::instrument(level = "debug", skip(self), fields(%id))] - pub async fn account_for_launch(&self, id: Uuid) -> AuthResult { - let account = self.renew_token(id, false).await?; + #[tracing::instrument(level = "debug", skip(self), fields(%id))] + pub async fn account_for_launch(&self, id: Uuid) -> AuthResult { + let account = self.renew_token(id, false).await?; - if account.is_offline() && !self.has_microsoft_account().await { - return Err(AuthError::OfflineRequiresMicrosoft); - } + if account.is_offline() && !self.has_microsoft_account().await { + return Err(AuthError::OfflineRequiresMicrosoft); + } - Ok(account) - } + Ok(account) + } - #[tracing::instrument(level = "debug", skip_all)] - pub async fn default_account_for_launch(&self) -> AuthResult> { - let Some(id) = self.store.lock().await.resolve_default_id().await? else { - return Ok(None); - }; - Ok(Some(self.account_for_launch(id).await?)) - } + #[tracing::instrument(level = "debug", skip_all)] + pub async fn default_account_for_launch(&self) -> AuthResult> { + let Some(id) = self.store.lock().await.resolve_default_id().await? else { + return Ok(None); + }; + Ok(Some(self.account_for_launch(id).await?)) + } } #[cfg(test)] mod tests { - use oneclient_net::NetConfig; + use oneclient_net::NetConfig; - use super::*; - use crate::data::DeviceCodeLogin; + use super::*; + use crate::data::DeviceCodeLogin; - fn service(events: EventBus) -> AuthService { - let net = RequestClient::new(NetConfig::default()).expect("net client"); - AuthService::with_store(CredentialsStore::default(), net, events) - } + fn service(events: EventBus) -> AuthService { + let net = RequestClient::new(NetConfig::default()).expect("net client"); + AuthService::with_store(CredentialsStore::default(), net, events) + } - /// Registers a login the way `begin_microsoft_login` does minus the device - /// code request which is the only half that needs the network - async fn register_login(service: &AuthService) -> MicrosoftLoginSession { - let (browser, pending) = msa::begin_browser_login().await.expect("loopback bind"); + /// Registers a login the way `begin_microsoft_login` does minus the device + /// code request which is the only half that needs the network + async fn register_login(service: &AuthService) -> MicrosoftLoginSession { + let (browser, pending) = msa::begin_browser_login().await.expect("loopback bind"); - service.pending_logins.lock().await.insert( - browser.state.clone(), - PendingLogin { - browser: Some(pending), - cancel: CancellationToken::new(), - }, - ); + service.pending_logins.lock().await.insert( + browser.state.clone(), + PendingLogin { + browser: Some(pending), + cancel: CancellationToken::new(), + }, + ); - MicrosoftLoginSession { - browser, - device: DeviceCodeLogin { - user_code: "ABCD-EFGH".to_string(), - device_code: "device-code".to_string(), - verification_uri: "https://microsoft.com/link".to_string(), - expires_in: 900, - interval: 5, - message: String::new(), - }, - } - } + MicrosoftLoginSession { + browser, + device: DeviceCodeLogin { + user_code: "ABCD-EFGH".to_string(), + device_code: "device-code".to_string(), + verification_uri: "https://microsoft.com/link".to_string(), + expires_in: 900, + interval: 5, + message: String::new(), + }, + } + } - #[tokio::test] - async fn a_cancel_that_beats_the_flow_is_still_a_cancel() { - // A quick cancel can land before the flow claims the login - let (events, _rx) = EventBus::channel(); - let service = service(events); - let session = register_login(&service).await; + #[tokio::test] + async fn a_cancel_that_beats_the_flow_is_still_a_cancel() { + // A quick cancel can land before the flow claims the login + let (events, _rx) = EventBus::channel(); + let service = service(events); + let session = register_login(&service).await; - service.cancel_microsoft_login(session.dedupe_key()).await; - let err = service.finish_microsoft_login(session).await.unwrap_err(); + service.cancel_microsoft_login(session.dedupe_key()).await; + let err = service.finish_microsoft_login(session).await.unwrap_err(); - assert!(matches!(err, AuthError::LoginCancelled), "{err}"); - assert!(service.pending_logins.lock().await.is_empty()); - assert!(service.store.lock().await.list_accounts().is_empty()); - } + assert!(matches!(err, AuthError::LoginCancelled), "{err}"); + assert!(service.pending_logins.lock().await.is_empty()); + assert!(service.store.lock().await.list_accounts().is_empty()); + } - #[tokio::test] - async fn a_cancel_frees_the_loopback_port_it_was_holding() { - let (events, _rx) = EventBus::channel(); - let service = service(events); - let session = register_login(&service).await; + #[tokio::test] + async fn a_cancel_frees_the_loopback_port_it_was_holding() { + let (events, _rx) = EventBus::channel(); + let service = service(events); + let session = register_login(&service).await; - service.cancel_microsoft_login(session.dedupe_key()).await; + service.cancel_microsoft_login(session.dedupe_key()).await; - let logins = service.pending_logins.lock().await; - let login = logins - .get(session.dedupe_key()) - .expect("the entry is what a flow starting late reads the cancel from"); - assert!(login.cancel.is_cancelled()); - assert!( - login.browser.is_none(), - "the listener should be dropped rather than left bound" - ); - } + let logins = service.pending_logins.lock().await; + let login = logins + .get(session.dedupe_key()) + .expect("the entry is what a flow starting late reads the cancel from"); + assert!(login.cancel.is_cancelled()); + assert!( + login.browser.is_none(), + "the listener should be dropped rather than left bound" + ); + } - #[tokio::test] - async fn a_cancel_reaches_a_flow_that_has_already_claimed_the_login() { - // Once claimed the entry is the only channel a cancel has to the flow - let (events, _rx) = EventBus::channel(); - let service = service(events); - let session = register_login(&service).await; + #[tokio::test] + async fn a_cancel_reaches_a_flow_that_has_already_claimed_the_login() { + // Once claimed the entry is the only channel a cancel has to the flow + let (events, _rx) = EventBus::channel(); + let service = service(events); + let session = register_login(&service).await; - let (_listener, cancel) = service - .claim_pending_login(session.dedupe_key()) - .await - .expect("claim"); - service.cancel_microsoft_login(session.dedupe_key()).await; + let (_listener, cancel) = service + .claim_pending_login(session.dedupe_key()) + .await + .expect("claim"); + service.cancel_microsoft_login(session.dedupe_key()).await; - assert!(cancel.is_cancelled()); - } + assert!(cancel.is_cancelled()); + } - #[tokio::test] - async fn a_login_can_only_be_claimed_once() { - let (events, _rx) = EventBus::channel(); - let service = service(events); - let session = register_login(&service).await; + #[tokio::test] + async fn a_login_can_only_be_claimed_once() { + let (events, _rx) = EventBus::channel(); + let service = service(events); + let session = register_login(&service).await; - service - .claim_pending_login(session.dedupe_key()) - .await - .expect("claim"); - let claimed_again = service.claim_pending_login(session.dedupe_key()).await; + service + .claim_pending_login(session.dedupe_key()) + .await + .expect("claim"); + let claimed_again = service.claim_pending_login(session.dedupe_key()).await; - assert!(matches!( - claimed_again, - Err(AuthError::Minecraft( - MinecraftAuthError::BrowserLoginNotFound - )) - )); - } + assert!(matches!( + claimed_again, + Err(AuthError::Minecraft( + MinecraftAuthError::BrowserLoginNotFound + )) + )); + } } diff --git a/packages/oneclient_auth/src/store.rs b/packages/oneclient_auth/src/store.rs index b9d45990..c6951a16 100644 --- a/packages/oneclient_auth/src/store.rs +++ b/packages/oneclient_auth/src/store.rs @@ -111,10 +111,7 @@ impl CredentialsStore { } #[tracing::instrument(level = "debug", skip_all, fields(%account.id))] - pub async fn commit_refreshed_account( - &mut self, - account: MinecraftAccount, - ) -> AuthResult<()> { + pub async fn commit_refreshed_account(&mut self, account: MinecraftAccount) -> AuthResult<()> { self.users.insert(account.id, account); self.save().await?; tracing::debug!("stored refreshed Microsoft account"); diff --git a/packages/oneclient_cluster/src/cluster.rs b/packages/oneclient_cluster/src/cluster.rs index 60a89fa7..777f37d8 100644 --- a/packages/oneclient_cluster/src/cluster.rs +++ b/packages/oneclient_cluster/src/cluster.rs @@ -6,9 +6,9 @@ use oneclient_db::models::ClusterId; use oneclient_db::models::ClusterRow; use serde::{Deserialize, Serialize}; +use crate::error::ClusterResult; use oneclient_common::domain::GameLoader; use oneclient_common::paths; -use crate::error::ClusterResult; use crate::error::ClusterError; use crate::stage::ClusterStage; @@ -17,36 +17,36 @@ pub use oneclient_common::paths::DEDICATED_MARKER; // takes a cluster out of the shared `mods` folder pub async fn remove_mods_link(folder_name: &str) { - let Ok(link) = paths::shared_mods_link(folder_name) else { - return; - }; + let Ok(link) = paths::shared_mods_link(folder_name) else { + return; + }; - match polyio::symlink_metadata(&link).await { - Ok(meta) if meta.file_type().is_symlink() => { - if let Err(err) = polyio::remove_symlink_dir(&link).await { - tracing::warn!(folder = folder_name, error = %err, "failed to clear cluster mods link"); - } - } + match polyio::symlink_metadata(&link).await { + Ok(meta) if meta.file_type().is_symlink() => { + if let Err(err) = polyio::remove_symlink_dir(&link).await { + tracing::warn!(folder = folder_name, error = %err, "failed to clear cluster mods link"); + } + } - _ => {} - } + _ => {} + } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Cluster { - pub id: ClusterId, - pub name: String, - pub folder_name: String, - pub setting_profile_name: Option, - pub mc_version: String, - pub mc_loader: GameLoader, - pub mc_loader_version: Option, - pub stage: ClusterStage, - pub created_at: Option>, - pub last_played: Option>, - #[serde(with = "serde_duration_secs")] - pub overall_played: Duration, - pub linked_modpack_hash: Option, + pub id: ClusterId, + pub name: String, + pub folder_name: String, + pub setting_profile_name: Option, + pub mc_version: String, + pub mc_loader: GameLoader, + pub mc_loader_version: Option, + pub stage: ClusterStage, + pub created_at: Option>, + pub last_played: Option>, + #[serde(with = "serde_duration_secs")] + pub overall_played: Duration, + pub linked_modpack_hash: Option, } impl PartialEq for Cluster { @@ -56,75 +56,79 @@ impl PartialEq for Cluster { } mod serde_duration_secs { - use serde::{Deserialize, Deserializer, Serialize, Serializer}; - use std::time::Duration; + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + use std::time::Duration; - pub fn serialize(duration: &Duration, serializer: S) -> Result { - duration.as_secs().serialize(serializer) - } + pub fn serialize(duration: &Duration, serializer: S) -> Result { + duration.as_secs().serialize(serializer) + } - pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result { - Ok(Duration::from_secs(u64::deserialize(deserializer)?)) - } + pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result { + Ok(Duration::from_secs(u64::deserialize(deserializer)?)) + } } impl Cluster { - pub fn try_from_row(row: ClusterRow) -> Result { - let mc_loader = GameLoader::from_repr(row.mc_loader as u8) - .ok_or(ClusterError::InvalidLoader(row.mc_loader))?; - let stage = ClusterStage::from_repr(row.stage) - .ok_or(ClusterError::InvalidStage(row.stage))?; + pub fn try_from_row(row: ClusterRow) -> Result { + let mc_loader = GameLoader::from_repr(row.mc_loader as u8) + .ok_or(ClusterError::InvalidLoader(row.mc_loader))?; + let stage = + ClusterStage::from_repr(row.stage).ok_or(ClusterError::InvalidStage(row.stage))?; - Ok(Self { - id: row.id, - name: row.name, - folder_name: row.folder_name, - setting_profile_name: row.setting_profile_name, - mc_version: row.mc_version, - mc_loader, - mc_loader_version: row.mc_loader_version, - stage, - created_at: parse_timestamp(row.created_at), - last_played: parse_timestamp(row.last_played), - overall_played: Duration::from_secs(row.overall_played.unwrap_or(0).max(0) as u64), - linked_modpack_hash: row.linked_modpack_hash, - }) - } + Ok(Self { + id: row.id, + name: row.name, + folder_name: row.folder_name, + setting_profile_name: row.setting_profile_name, + mc_version: row.mc_version, + mc_loader, + mc_loader_version: row.mc_loader_version, + stage, + created_at: parse_timestamp(row.created_at), + last_played: parse_timestamp(row.last_played), + overall_played: Duration::from_secs(row.overall_played.unwrap_or(0).max(0) as u64), + linked_modpack_hash: row.linked_modpack_hash, + }) + } - pub fn dir(&self) -> ClusterResult { - Ok(paths::cluster_dir(&self.folder_name)?) - } + pub fn dir(&self) -> ClusterResult { + Ok(paths::cluster_dir(&self.folder_name)?) + } - pub fn dedicated_marker(&self) -> ClusterResult { - Ok(self.dir()?.join(DEDICATED_MARKER)) - } + pub fn dedicated_marker(&self) -> ClusterResult { + Ok(self.dir()?.join(DEDICATED_MARKER)) + } - pub fn uses_dedicated_dir(&self) -> bool { - paths::cluster_uses_dedicated_dir(&self.folder_name) - } + pub fn uses_dedicated_dir(&self) -> bool { + paths::cluster_uses_dedicated_dir(&self.folder_name) + } - pub fn game_dir(&self) -> ClusterResult { - Ok(paths::cluster_game_dir(&self.folder_name)?) - } + pub fn game_dir(&self) -> ClusterResult { + Ok(paths::cluster_game_dir(&self.folder_name)?) + } - pub fn as_link_target(&self) -> ClusterLinkTarget<'_> { - ClusterLinkTarget { - id: self.id, - folder_name: &self.folder_name, - mc_version: &self.mc_version, - mc_loader: self.mc_loader, - } - } + pub fn as_link_target(&self) -> ClusterLinkTarget<'_> { + ClusterLinkTarget { + id: self.id, + folder_name: &self.folder_name, + mc_version: &self.mc_version, + mc_loader: self.mc_loader, + } + } } #[derive(Debug, Clone)] pub struct ClusterLinkTarget<'a> { - pub id: ClusterId, - pub folder_name: &'a str, - pub mc_version: &'a str, - pub mc_loader: GameLoader, + pub id: ClusterId, + pub folder_name: &'a str, + pub mc_version: &'a str, + pub mc_loader: GameLoader, } fn parse_timestamp(value: Option) -> Option> { - value.and_then(|text| DateTime::parse_from_rfc3339(&text).ok().map(|dt| dt.with_timezone(&Utc))) + value.and_then(|text| { + DateTime::parse_from_rfc3339(&text) + .ok() + .map(|dt| dt.with_timezone(&Utc)) + }) } diff --git a/packages/oneclient_cluster/src/error.rs b/packages/oneclient_cluster/src/error.rs index 1f9cf0f1..0fda9c82 100644 --- a/packages/oneclient_cluster/src/error.rs +++ b/packages/oneclient_cluster/src/error.rs @@ -7,69 +7,69 @@ pub type ClusterResult = Result; #[derive(Debug, Error)] pub enum ClusterError { - #[error(transparent)] - Io(#[from] polyio::IOError), + #[error(transparent)] + Io(#[from] polyio::IOError), - #[error(transparent)] - StdIo(#[from] std::io::Error), + #[error(transparent)] + StdIo(#[from] std::io::Error), - #[error(transparent)] - Db(#[from] oneclient_db::DbError), + #[error(transparent)] + Db(#[from] oneclient_db::DbError), - #[error("Database execution failed: {0}")] - Sql(#[from] sqlx::Error), + #[error("Database execution failed: {0}")] + Sql(#[from] sqlx::Error), - #[error(transparent)] - Paths(#[from] oneclient_common::PathsError), + #[error(transparent)] + Paths(#[from] oneclient_common::PathsError), - #[error("Unable to parse JSON: {0}")] - Json(#[from] serde_json::Error), + #[error("Unable to parse JSON: {0}")] + Json(#[from] serde_json::Error), - #[error("invalid settings profile: {reason}")] - InvalidProfile { reason: String }, + #[error("invalid settings profile: {reason}")] + InvalidProfile { reason: String }, - #[error("invalid minecraft version: {0}")] - InvalidVersion(String), + #[error("invalid minecraft version: {0}")] + InvalidVersion(String), - #[error(transparent)] - Logs(#[from] crate::logs::LogsError), + #[error(transparent)] + Logs(#[from] crate::logs::LogsError), - #[error(transparent)] - Screenshots(#[from] crate::screenshots::ScreenshotsError), + #[error(transparent)] + Screenshots(#[from] crate::screenshots::ScreenshotsError), - #[error(transparent)] - Request(#[from] oneclient_net::RequestError), + #[error(transparent)] + Request(#[from] oneclient_net::RequestError), - #[error("cluster {0} not found")] - NotFound(i64), + #[error("cluster {0} not found")] + NotFound(i64), - #[error("cluster folder '{0}' not found on disk")] - FolderNotFound(String), + #[error("cluster folder '{0}' not found on disk")] + FolderNotFound(String), - #[error("cluster is in {0} stage")] - Busy(ClusterStage), + #[error("cluster is in {0} stage")] + Busy(ClusterStage), #[error("cluster is missing a required Java version")] MissingJavaVersion, - #[error("loader {0} is not compatible with {1}")] - MismatchedLoader(GameLoader, GameLoader), + #[error("loader {0} is not compatible with {1}")] + MismatchedLoader(GameLoader, GameLoader), - #[error("setting profile '{0}' not found")] - ProfileNotFound(String), + #[error("setting profile '{0}' not found")] + ProfileNotFound(String), - #[error("cluster has no settings profile assigned")] - NoProfile, + #[error("cluster has no settings profile assigned")] + NoProfile, - #[error("cluster {0} is already running")] - AlreadyRunning(i64), + #[error("cluster {0} is already running")] + AlreadyRunning(i64), - #[error("cluster name is empty after sanitization")] - EmptyName, + #[error("cluster name is empty after sanitization")] + EmptyName, - #[error("unknown loader id {0} in database")] - InvalidLoader(i64), + #[error("unknown loader id {0} in database")] + InvalidLoader(i64), - #[error("unknown stage id {0} in database")] - InvalidStage(i64), + #[error("unknown stage id {0} in database")] + InvalidStage(i64), } diff --git a/packages/oneclient_cluster/src/logs/manage.rs b/packages/oneclient_cluster/src/logs/manage.rs index 3f15250b..c2ad8558 100644 --- a/packages/oneclient_cluster/src/logs/manage.rs +++ b/packages/oneclient_cluster/src/logs/manage.rs @@ -2,8 +2,8 @@ use std::path::{Path, PathBuf}; use chrono::{DateTime, Utc}; -use crate::error::ClusterResult; use crate::cluster::Cluster; +use crate::error::ClusterResult; use oneclient_common::paths; use super::parse::parse_level; diff --git a/packages/oneclient_cluster/src/logs/mclogs.rs b/packages/oneclient_cluster/src/logs/mclogs.rs index baa51ca1..1dc22673 100644 --- a/packages/oneclient_cluster/src/logs/mclogs.rs +++ b/packages/oneclient_cluster/src/logs/mclogs.rs @@ -2,8 +2,8 @@ use std::path::Path; use serde::Deserialize; -use oneclient_net::RequestError; use crate::error::ClusterResult; +use oneclient_net::RequestError; use super::manage::{ensure_allowed, read_file_string}; use super::{LogsError, MclogsUploadResponse}; @@ -67,7 +67,10 @@ pub async fn upload_log_at( return Err(LogsError::Upload(reason).into()); } - tracing::info!(url = parsed.url.as_deref().unwrap_or(""), "uploaded log to mclo.gs"); + tracing::info!( + url = parsed.url.as_deref().unwrap_or(""), + "uploaded log to mclo.gs" + ); Ok(MclogsUploadResponse { id: parsed.id.unwrap_or_default(), diff --git a/packages/oneclient_cluster/src/logs/mod.rs b/packages/oneclient_cluster/src/logs/mod.rs index 2d4789f0..c4483dbb 100644 --- a/packages/oneclient_cluster/src/logs/mod.rs +++ b/packages/oneclient_cluster/src/logs/mod.rs @@ -110,7 +110,7 @@ mod tests { ); assert_eq!(parse_level("[ERROR] boom"), Some(LogLevel::Error)); assert_eq!(parse_level("[main/FATAL]"), Some(LogLevel::Fatal)); - + assert_eq!(parse_level("\tat java.base/java.lang.Thread.run"), None); assert_eq!(parse_level("[12:34:56] just a timestamp"), None); } diff --git a/packages/oneclient_cluster/src/manager.rs b/packages/oneclient_cluster/src/manager.rs index bc3b719a..86f28e24 100644 --- a/packages/oneclient_cluster/src/manager.rs +++ b/packages/oneclient_cluster/src/manager.rs @@ -1,14 +1,12 @@ use oneclient_db::dao::{cluster as cluster_dao, setting_profile as profile_dao}; use oneclient_db::models::{ClusterId, ClusterPatch, NewCluster}; -use oneclient_common::domain::ContentType; -use crate::profiles::{ - create_profile_from_global, resolve_cluster_profile, update_named_profile, -}; -use oneclient_common::patch::Patch; +use crate::error::ClusterResult; use crate::profile::GameSettingsProfile; use crate::profiles::ProfileUpdate; -use crate::error::ClusterResult; +use crate::profiles::{create_profile_from_global, resolve_cluster_profile, update_named_profile}; +use oneclient_common::domain::ContentType; +use oneclient_common::patch::Patch; use oneclient_db::DbPool; use tokio::sync::Mutex; @@ -19,351 +17,341 @@ use crate::options::{ClusterUpdate, CreateClusterOptions}; use crate::stage::ClusterStage; pub struct ClusterManager { - db: DbPool, - /// Serialises creation so two concurrent creates cannot resolve to the same - /// folder name and race each other onto disk - provisioning: Mutex<()>, + db: DbPool, + /// Serialises creation so two concurrent creates cannot resolve to the same + /// folder name and race each other onto disk + provisioning: Mutex<()>, } impl ClusterManager { - #[must_use] - pub fn new(db: DbPool) -> Self { - Self { - db, - provisioning: Mutex::new(()), - } - } + #[must_use] + pub fn new(db: DbPool) -> Self { + Self { + db, + provisioning: Mutex::new(()), + } + } - /// Orphan-folder recovery must hold this across its whole scan or a - /// concurrent create's folder is adopted into a duplicate row - pub async fn provisioning_guard(&self) -> tokio::sync::MutexGuard<'_, ()> { - self.provisioning.lock().await - } + /// Orphan-folder recovery must hold this across its whole scan or a + /// concurrent create's folder is adopted into a duplicate row + pub async fn provisioning_guard(&self) -> tokio::sync::MutexGuard<'_, ()> { + self.provisioning.lock().await + } - pub fn sanitize_name(name: &str) -> String { - let mut name = name.to_string(); - name.retain(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | ' ' | '.' | '(' | ')')); - name.trim().to_string() - } + pub fn sanitize_name(name: &str) -> String { + let mut name = name.to_string(); + name.retain(|c| { + c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | ' ' | '.' | '(' | ')') + }); + name.trim().to_string() + } - #[tracing::instrument(level = "debug", skip(self))] - pub async fn get(&self, cluster_id: ClusterId) -> ClusterResult { - let row = cluster_dao::get_by_id(&self.db, cluster_id) - .await? - .ok_or(ClusterError::NotFound(cluster_id))?; - Cluster::try_from_row(row) - } + #[tracing::instrument(level = "debug", skip(self))] + pub async fn get(&self, cluster_id: ClusterId) -> ClusterResult { + let row = cluster_dao::get_by_id(&self.db, cluster_id) + .await? + .ok_or(ClusterError::NotFound(cluster_id))?; + Cluster::try_from_row(row) + } - #[tracing::instrument(level = "debug", skip(self))] - pub async fn find_by_folder_name(&self, folder_name: &str) -> ClusterResult> { - cluster_dao::get_by_folder_name(&self.db, folder_name) - .await? - .map(Cluster::try_from_row) - .transpose() - } + #[tracing::instrument(level = "debug", skip(self))] + pub async fn find_by_folder_name(&self, folder_name: &str) -> ClusterResult> { + cluster_dao::get_by_folder_name(&self.db, folder_name) + .await? + .map(Cluster::try_from_row) + .transpose() + } - #[tracing::instrument(level = "debug", skip(self))] - pub async fn list(&self) -> ClusterResult> { - let rows = cluster_dao::list_all(&self.db).await?; - rows.into_iter() - .map(Cluster::try_from_row) - .collect::, _>>()} + #[tracing::instrument(level = "debug", skip(self))] + pub async fn list(&self) -> ClusterResult> { + let rows = cluster_dao::list_all(&self.db).await?; + rows.into_iter() + .map(Cluster::try_from_row) + .collect::, _>>() + } - #[tracing::instrument(skip(self))] - pub async fn create( - &self, - global: &GameSettingsProfile, - options: CreateClusterOptions, - ) -> ClusterResult { - let _guard = self.provisioning.lock().await; - self.create_core(global, options).await - } + #[tracing::instrument(skip(self))] + pub async fn create( + &self, + global: &GameSettingsProfile, + options: CreateClusterOptions, + ) -> ClusterResult { + let _guard = self.provisioning.lock().await; + self.create_core(global, options).await + } - /// Returns `None` if a cluster for this version/loader already exists - #[tracing::instrument(level = "debug", skip(self))] - pub async fn create_provisioned( - &self, - global: &GameSettingsProfile, - options: CreateClusterOptions, - ) -> ClusterResult> { - let _guard = self.provisioning.lock().await; - if cluster_dao::find_by_version_loader( - &self.db, - &options.mc_version, - options.mc_loader as i64, - ) - .await? - .is_some() - { - return Ok(None); - } - self.create_core(global, options).await.map(Some) - } + /// Returns `None` if a cluster for this version/loader already exists + #[tracing::instrument(level = "debug", skip(self))] + pub async fn create_provisioned( + &self, + global: &GameSettingsProfile, + options: CreateClusterOptions, + ) -> ClusterResult> { + let _guard = self.provisioning.lock().await; + if cluster_dao::find_by_version_loader( + &self.db, + &options.mc_version, + options.mc_loader as i64, + ) + .await? + .is_some() + { + return Ok(None); + } + self.create_core(global, options).await.map(Some) + } - /// Callers MUST hold `self.provisioning` this does not lock - #[tracing::instrument(level = "debug", skip(self, global))] - async fn create_core( - &self, - global: &GameSettingsProfile, - options: CreateClusterOptions, - ) -> ClusterResult { - let name = Self::sanitize_name(&options.name); - if name.is_empty() { - return Err(ClusterError::EmptyName); - } + /// Callers MUST hold `self.provisioning` this does not lock + #[tracing::instrument(level = "debug", skip(self, global))] + async fn create_core( + &self, + global: &GameSettingsProfile, + options: CreateClusterOptions, + ) -> ClusterResult { + let name = Self::sanitize_name(&options.name); + if name.is_empty() { + return Err(ClusterError::EmptyName); + } - let folder_name = resolve_unique_folder_name(&name).await?; - let cluster_path = oneclient_common::paths::clusters_dir()?.join(&folder_name); + let folder_name = resolve_unique_folder_name(&name).await?; + let cluster_path = oneclient_common::paths::clusters_dir()?.join(&folder_name); - match create_inner(&self.db, global, &options, &name, &folder_name, &cluster_path).await { - 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) - } - } - } + match create_inner( + &self.db, + global, + &options, + &name, + &folder_name, + &cluster_path, + ) + .await + { + 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(self))] - pub async fn update( - &self, - cluster_id: ClusterId, - update: ClusterUpdate, - ) -> ClusterResult { - let _ = self.get(cluster_id).await?; + #[tracing::instrument(level = "debug", skip(self))] + pub async fn update( + &self, + cluster_id: ClusterId, + update: ClusterUpdate, + ) -> ClusterResult { + let _ = self.get(cluster_id).await?; - if let Patch::Set(ref profile_name) = update.setting_profile_name { - ensure_profile_exists(&self.db, profile_name).await?; - } + if let Patch::Set(ref profile_name) = update.setting_profile_name { + ensure_profile_exists(&self.db, profile_name).await?; + } - let name = update.name.as_deref().map(Self::sanitize_name); - if name.as_deref().is_some_and(str::is_empty) { - return Err(ClusterError::EmptyName); - } + let name = update.name.as_deref().map(Self::sanitize_name); + if name.as_deref().is_some_and(str::is_empty) { + return Err(ClusterError::EmptyName); + } - let patch = ClusterPatch { - name, - setting_profile_name: update.setting_profile_name.into_db_patch(), - mc_loader_version: update.mc_loader_version.into_db_patch(), - linked_modpack_hash: update.linked_modpack_hash.into_db_patch(), - }; + let patch = ClusterPatch { + name, + setting_profile_name: update.setting_profile_name.into_db_patch(), + mc_loader_version: update.mc_loader_version.into_db_patch(), + linked_modpack_hash: update.linked_modpack_hash.into_db_patch(), + }; - let row = cluster_dao::update(&self.db, cluster_id, &patch).await?; - Cluster::try_from_row(row) - } + let row = cluster_dao::update(&self.db, cluster_id, &patch).await?; + Cluster::try_from_row(row) + } - #[tracing::instrument(skip(self))] - pub async fn delete( - &self, - cluster_id: ClusterId, - remove_files: bool, - ) -> ClusterResult<()> { - let cluster = self.get(cluster_id).await?; + #[tracing::instrument(skip(self))] + pub async fn delete(&self, cluster_id: ClusterId, remove_files: bool) -> ClusterResult<()> { + let cluster = self.get(cluster_id).await?; - if !cluster_dao::delete_by_id(&self.db, cluster_id).await? { - return Err(ClusterError::NotFound(cluster_id)); - } + if !cluster_dao::delete_by_id(&self.db, cluster_id).await? { + return Err(ClusterError::NotFound(cluster_id)); + } - remove_mods_link(&cluster.folder_name).await; + remove_mods_link(&cluster.folder_name).await; - if remove_files { - let path = cluster.dir()?; - if path.exists() { - polyio::remove_dir_all(&path).await?; - } - } + if remove_files { + let path = cluster.dir()?; + if path.exists() { + polyio::remove_dir_all(&path).await?; + } + } - tracing::info!(cluster_id, remove_files, "deleted cluster"); - Ok(()) - } + tracing::info!(cluster_id, remove_files, "deleted cluster"); + Ok(()) + } - #[tracing::instrument(level = "debug", skip(self))] - pub async fn set_stage( - &self, - cluster_id: ClusterId, - stage: ClusterStage, - ) -> ClusterResult { - let row = cluster_dao::set_stage(&self.db, cluster_id, stage as i64).await?; - Cluster::try_from_row(row) - } + #[tracing::instrument(level = "debug", skip(self))] + pub async fn set_stage( + &self, + cluster_id: ClusterId, + stage: ClusterStage, + ) -> ClusterResult { + let row = cluster_dao::set_stage(&self.db, cluster_id, stage as i64).await?; + Cluster::try_from_row(row) + } - #[tracing::instrument(level = "debug", skip(self))] - pub async fn uses_dedicated_dir(&self, cluster_id: ClusterId) -> ClusterResult { - Ok(self.get(cluster_id).await?.uses_dedicated_dir()) - } + #[tracing::instrument(level = "debug", skip(self))] + pub async fn uses_dedicated_dir(&self, cluster_id: ClusterId) -> ClusterResult { + Ok(self.get(cluster_id).await?.uses_dedicated_dir()) + } - #[tracing::instrument(level = "debug", skip(self))] - /// `is_running` is passed in because process liveness belongs to the game - /// lifecycle not to cluster records - pub async fn set_dedicated_dir( - &self, - cluster_id: ClusterId, - dedicated: bool, - is_running: bool, - ) -> ClusterResult<()> { - if is_running { - return Err(ClusterError::AlreadyRunning(cluster_id)); - } + #[tracing::instrument(level = "debug", skip(self))] + /// `is_running` is passed in because process liveness belongs to the game + /// lifecycle not to cluster records + pub async fn set_dedicated_dir( + &self, + cluster_id: ClusterId, + dedicated: bool, + is_running: bool, + ) -> ClusterResult<()> { + if is_running { + return Err(ClusterError::AlreadyRunning(cluster_id)); + } - let cluster = self.get(cluster_id).await?; - let marker = cluster.dedicated_marker()?; - if dedicated { - polyio::create_dir_all(cluster.dir()?).await?; - polyio::write(&marker, b"").await.ok(); - } else if marker.exists() { - polyio::remove_file(&marker).await.ok(); - } - Ok(()) - } + let cluster = self.get(cluster_id).await?; + let marker = cluster.dedicated_marker()?; + if dedicated { + polyio::create_dir_all(cluster.dir()?).await?; + polyio::write(&marker, b"").await.ok(); + } else if marker.exists() { + polyio::remove_file(&marker).await.ok(); + } + Ok(()) + } - #[tracing::instrument(level = "debug", skip(self))] - pub async fn add_playtime( - &self, - cluster_id: ClusterId, - duration: std::time::Duration, - ) -> ClusterResult { - let seconds = i64::try_from(duration.as_secs()).unwrap_or(i64::MAX); - let row = cluster_dao::add_playtime(&self.db, cluster_id, seconds).await?; + #[tracing::instrument(level = "debug", skip(self))] + pub async fn add_playtime( + &self, + cluster_id: ClusterId, + duration: std::time::Duration, + ) -> ClusterResult { + let seconds = i64::try_from(duration.as_secs()).unwrap_or(i64::MAX); + let row = cluster_dao::add_playtime(&self.db, cluster_id, seconds).await?; - Cluster::try_from_row(row) - } + Cluster::try_from_row(row) + } - #[tracing::instrument(level = "debug", skip(self, global, cluster), fields(cluster_id = cluster.id))] - pub async fn resolve_settings( - &self, - global: &GameSettingsProfile, - cluster: &Cluster, - ) -> ClusterResult { - resolve_cluster_profile( - &self.db, - global, - cluster.setting_profile_name.as_deref(), - ) - .await - } + #[tracing::instrument(level = "debug", skip(self, global, cluster), fields(cluster_id = cluster.id))] + pub async fn resolve_settings( + &self, + global: &GameSettingsProfile, + cluster: &Cluster, + ) -> ClusterResult { + resolve_cluster_profile(&self.db, global, cluster.setting_profile_name.as_deref()).await + } - #[tracing::instrument(level = "debug", skip(self, update))] - pub async fn update_profile( - &self, - cluster_id: ClusterId, - update: ProfileUpdate, - ) -> ClusterResult { - let cluster = self.get(cluster_id).await?; - let profile_name = cluster - .setting_profile_name - .ok_or(ClusterError::NoProfile)?; + #[tracing::instrument(level = "debug", skip(self, update))] + pub async fn update_profile( + &self, + cluster_id: ClusterId, + update: ProfileUpdate, + ) -> ClusterResult { + let cluster = self.get(cluster_id).await?; + let profile_name = cluster + .setting_profile_name + .ok_or(ClusterError::NoProfile)?; - update_named_profile(&self.db, &profile_name, update).await - } + update_named_profile(&self.db, &profile_name, update).await + } - #[tracing::instrument(level = "debug", skip(self))] - pub async fn create_and_assign_profile( - &self, - global: &GameSettingsProfile, - cluster_id: ClusterId, - profile_name: &str, - ) -> ClusterResult { - let profile = create_profile_from_global( - &self.db, - global, - profile_name, - None, - None, - ) - .await?; + #[tracing::instrument(level = "debug", skip(self))] + pub async fn create_and_assign_profile( + &self, + global: &GameSettingsProfile, + cluster_id: ClusterId, + profile_name: &str, + ) -> ClusterResult { + let profile = + create_profile_from_global(&self.db, global, profile_name, None, None).await?; - self.update( - cluster_id, - ClusterUpdate::default().setting_profile(&profile.name), - ) - .await?; + self.update( + cluster_id, + ClusterUpdate::default().setting_profile(&profile.name), + ) + .await?; - Ok(profile) - } + Ok(profile) + } } #[tracing::instrument(level = "debug", skip(db, global, options))] async fn create_inner( - db: &DbPool, - global: &GameSettingsProfile, - options: &CreateClusterOptions, - name: &str, - folder_name: &str, - cluster_path: &std::path::Path, + db: &DbPool, + global: &GameSettingsProfile, + options: &CreateClusterOptions, + name: &str, + folder_name: &str, + cluster_path: &std::path::Path, ) -> ClusterResult { - polyio::create_dir_all(cluster_path).await?; - ensure_content_dirs(cluster_path).await?; + polyio::create_dir_all(cluster_path).await?; + ensure_content_dirs(cluster_path).await?; - let profile = create_profile_from_global( - db, - global, - name, - options.mem_max, - None, - ) - .await?; + let profile = create_profile_from_global(db, global, name, options.mem_max, None).await?; - let row = cluster_dao::insert( - db, - &NewCluster { - name, - folder_name, - mc_version: &options.mc_version, - mc_loader: options.mc_loader as i64, - mc_loader_version: options.mc_loader_version.as_deref(), - setting_profile_name: Some(&profile.name), - stage: ClusterStage::NotReady as i64, - }, - ) - .await?; + let row = cluster_dao::insert( + db, + &NewCluster { + name, + folder_name, + mc_version: &options.mc_version, + mc_loader: options.mc_loader as i64, + mc_loader_version: options.mc_loader_version.as_deref(), + setting_profile_name: Some(&profile.name), + stage: ClusterStage::NotReady as i64, + }, + ) + .await?; - Cluster::try_from_row(row) + Cluster::try_from_row(row) } #[tracing::instrument(level = "debug", skip(pool))] async fn ensure_profile_exists(pool: &oneclient_db::DbPool, name: &str) -> ClusterResult<()> { - if profile_dao::get_by_name(pool, name).await?.is_none() { - return Err(ClusterError::ProfileNotFound(name.to_string())); - } - Ok(()) + if profile_dao::get_by_name(pool, name).await?.is_none() { + return Err(ClusterError::ProfileNotFound(name.to_string())); + } + Ok(()) } #[tracing::instrument(level = "debug")] async fn resolve_unique_folder_name(name: &str) -> ClusterResult { - let cluster_dir = oneclient_common::paths::clusters_dir()?; - let mut folder_name = name.to_string(); - let mut path = cluster_dir.join(&folder_name); + let cluster_dir = oneclient_common::paths::clusters_dir()?; + let mut folder_name = name.to_string(); + let mut path = cluster_dir.join(&folder_name); - if path.exists() { - let mut which = 1; - loop { - let candidate = format!("{folder_name} ({which})"); - path = cluster_dir.join(&candidate); - if !path.exists() { - folder_name = candidate; - break; - } - which += 1; - } - } + if path.exists() { + let mut which = 1; + loop { + let candidate = format!("{folder_name} ({which})"); + path = cluster_dir.join(&candidate); + if !path.exists() { + folder_name = candidate; + break; + } + which += 1; + } + } - Ok(folder_name) + Ok(folder_name) } #[tracing::instrument(level = "debug")] async fn ensure_content_dirs(cluster_path: &std::path::Path) -> ClusterResult<()> { - for content_type in [ - ContentType::Mod, - ContentType::ResourcePack, - ContentType::Shader, - ContentType::DataPack, - ContentType::World, - ] { - polyio::create_dir_all(cluster_path.join(content_type.folder_name())).await?; - } - Ok(()) + for content_type in [ + ContentType::Mod, + ContentType::ResourcePack, + ContentType::Shader, + ContentType::DataPack, + ContentType::World, + ] { + polyio::create_dir_all(cluster_path.join(content_type.folder_name())).await?; + } + Ok(()) } diff --git a/packages/oneclient_cluster/src/options.rs b/packages/oneclient_cluster/src/options.rs index f7bfd697..3b9ced50 100644 --- a/packages/oneclient_cluster/src/options.rs +++ b/packages/oneclient_cluster/src/options.rs @@ -5,51 +5,55 @@ use oneclient_common::patch::Patch; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CreateClusterOptions { - pub name: String, - pub mc_version: String, - pub mc_loader: GameLoader, - pub mc_loader_version: Option, - pub mem_max: Option, + pub name: String, + pub mc_version: String, + pub mc_loader: GameLoader, + pub mc_loader_version: Option, + pub mem_max: Option, } impl CreateClusterOptions { - pub fn new(name: impl Into, mc_version: impl Into, mc_loader: GameLoader) -> Self { - Self { - name: name.into(), - mc_version: mc_version.into(), - mc_loader, - mc_loader_version: None, - mem_max: None, - } - } + pub fn new( + name: impl Into, + mc_version: impl Into, + mc_loader: GameLoader, + ) -> Self { + Self { + name: name.into(), + mc_version: mc_version.into(), + mc_loader, + mc_loader_version: None, + mem_max: None, + } + } - pub fn mem_max(mut self, megabytes: u32) -> Self { - self.mem_max = Some(megabytes); - self - } + pub fn mem_max(mut self, megabytes: u32) -> Self { + self.mem_max = Some(megabytes); + self + } - pub fn loader_version(mut self, version: impl Into) -> Self { - self.mc_loader_version = Some(version.into()); - self - } + pub fn loader_version(mut self, version: impl Into) -> Self { + self.mc_loader_version = Some(version.into()); + self + } } #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct ClusterUpdate { - pub name: Option, - pub setting_profile_name: Patch, - pub mc_loader_version: Patch, - pub linked_modpack_hash: Patch, + pub name: Option, + pub setting_profile_name: Patch, + pub mc_loader_version: Patch, + pub linked_modpack_hash: Patch, } impl ClusterUpdate { - pub fn setting_profile(mut self, name: impl Into) -> Self { - self.setting_profile_name = Patch::Set(name.into()); - self - } + pub fn setting_profile(mut self, name: impl Into) -> Self { + self.setting_profile_name = Patch::Set(name.into()); + self + } - pub fn loader_version(mut self, version: impl Into) -> Self { - self.mc_loader_version = Patch::Set(version.into()); - self - } + pub fn loader_version(mut self, version: impl Into) -> Self { + self.mc_loader_version = Patch::Set(version.into()); + self + } } diff --git a/packages/oneclient_cluster/src/profile.rs b/packages/oneclient_cluster/src/profile.rs index 14c3b8c6..cc0a7bc9 100644 --- a/packages/oneclient_cluster/src/profile.rs +++ b/packages/oneclient_cluster/src/profile.rs @@ -6,19 +6,19 @@ pub const GLOBAL_PROFILE_NAME: &str = "Global"; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct GameSettingsProfile { - pub name: String, - pub java_path: Option, - pub resolution: Option, - pub force_fullscreen: Option, - pub mem_max: Option, - pub launch_args: Option, - pub launch_env: Option, - pub hook_pre: Option, - pub hook_wrapper: Option, - pub hook_post: Option, - pub os_extra: Option, - /// Never applies to bundle content - pub browser_update_mode: Option, + pub name: String, + pub java_path: Option, + pub resolution: Option, + pub force_fullscreen: Option, + pub mem_max: Option, + pub launch_args: Option, + pub launch_env: Option, + pub hook_pre: Option, + pub hook_wrapper: Option, + pub hook_post: Option, + pub os_extra: Option, + /// Never applies to bundle content + pub browser_update_mode: Option, } // `Resolution` lives in oneclient_common the launch-argument builder needs it @@ -29,162 +29,162 @@ pub use oneclient_common::domain::PackageUpdateMode; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(default)] pub struct SettingsOsExtra { - #[cfg(target_os = "linux")] - pub enable_gamemode: Option, - #[cfg(any(target_os = "linux", windows))] - pub use_discrete_gpu: Option, - #[serde(flatten)] - pub unknown: serde_json::Map, + #[cfg(target_os = "linux")] + pub enable_gamemode: Option, + #[cfg(any(target_os = "linux", windows))] + pub use_discrete_gpu: Option, + #[serde(flatten)] + pub unknown: serde_json::Map, } impl Default for SettingsOsExtra { - fn default() -> Self { - Self { - #[cfg(target_os = "linux")] - enable_gamemode: Some(true), - #[cfg(any(target_os = "linux", windows))] - use_discrete_gpu: Some(true), - unknown: serde_json::Map::new(), - } - } + fn default() -> Self { + Self { + #[cfg(target_os = "linux")] + enable_gamemode: Some(true), + #[cfg(any(target_os = "linux", windows))] + use_discrete_gpu: Some(true), + unknown: serde_json::Map::new(), + } + } } impl GameSettingsProfile { - pub fn default_global_profile() -> Self { - Self { - name: GLOBAL_PROFILE_NAME.into(), - java_path: None, - resolution: None, - force_fullscreen: Some(false), - mem_max: Some(oneclient_common::default_mem_max()), - launch_args: None, - launch_env: None, - hook_pre: None, - hook_wrapper: None, - hook_post: None, - os_extra: Some(SettingsOsExtra::default()), - browser_update_mode: Some(PackageUpdateMode::default()), - } - } + pub fn default_global_profile() -> Self { + Self { + name: GLOBAL_PROFILE_NAME.into(), + java_path: None, + resolution: None, + force_fullscreen: Some(false), + mem_max: Some(oneclient_common::default_mem_max()), + launch_args: None, + launch_env: None, + hook_pre: None, + hook_wrapper: None, + hook_post: None, + os_extra: Some(SettingsOsExtra::default()), + browser_update_mode: Some(PackageUpdateMode::default()), + } + } - pub fn use_discrete_gpu(&self) -> bool { - cfg_select! { - any(target_os = "linux", target_os = "windows") => self - .os_extra - .as_ref() - .and_then(|extra| extra.use_discrete_gpu) - .unwrap_or(false), - _ => false - } - } + pub fn use_discrete_gpu(&self) -> bool { + cfg_select! { + any(target_os = "linux", target_os = "windows") => self + .os_extra + .as_ref() + .and_then(|extra| extra.use_discrete_gpu) + .unwrap_or(false), + _ => false + } + } - pub fn is_global(&self) -> bool { - self.name == GLOBAL_PROFILE_NAME - } + pub fn is_global(&self) -> bool { + self.name == GLOBAL_PROFILE_NAME + } - pub fn merge_global(&mut self, global: &Self) { - if self.java_path.is_none() { - self.java_path = global.java_path.clone(); - } - if self.resolution.is_none() { - self.resolution = global.resolution; - } - if self.force_fullscreen.is_none() { - self.force_fullscreen = global.force_fullscreen; - } - if self.mem_max.is_none() { - self.mem_max = global.mem_max; - } - if self.launch_args.is_none() { - self.launch_args = global.launch_args.clone(); - } - if self.launch_env.is_none() { - self.launch_env = global.launch_env.clone(); - } - if self.hook_pre.is_none() { - self.hook_pre = global.hook_pre.clone(); - } - if self.hook_wrapper.is_none() { - self.hook_wrapper = global.hook_wrapper.clone(); - } - if self.hook_post.is_none() { - self.hook_post = global.hook_post.clone(); - } - if self.os_extra.is_none() { - self.os_extra = global.os_extra.clone(); - } - if self.browser_update_mode.is_none() { - self.browser_update_mode = global.browser_update_mode; - } - } + pub fn merge_global(&mut self, global: &Self) { + if self.java_path.is_none() { + self.java_path = global.java_path.clone(); + } + if self.resolution.is_none() { + self.resolution = global.resolution; + } + if self.force_fullscreen.is_none() { + self.force_fullscreen = global.force_fullscreen; + } + if self.mem_max.is_none() { + self.mem_max = global.mem_max; + } + if self.launch_args.is_none() { + self.launch_args = global.launch_args.clone(); + } + if self.launch_env.is_none() { + self.launch_env = global.launch_env.clone(); + } + if self.hook_pre.is_none() { + self.hook_pre = global.hook_pre.clone(); + } + if self.hook_wrapper.is_none() { + self.hook_wrapper = global.hook_wrapper.clone(); + } + if self.hook_post.is_none() { + self.hook_post = global.hook_post.clone(); + } + if self.os_extra.is_none() { + self.os_extra = global.os_extra.clone(); + } + if self.browser_update_mode.is_none() { + self.browser_update_mode = global.browser_update_mode; + } + } - pub fn from_row(row: SettingProfileRow) -> crate::ClusterResult { - Ok(Self { - name: row.name, - java_path: row.java_path, - resolution: row - .resolution - .map(|json| serde_json::from_str(&json)) - .transpose()?, - force_fullscreen: row.force_fullscreen.map(|v| v != 0), - mem_max: row.mem_max.map(|v| v as u32), - launch_args: row.launch_args, - launch_env: row.launch_env, - hook_pre: row.hook_pre, - hook_wrapper: row.hook_wrapper, - hook_post: row.hook_post, - os_extra: row - .os_extra - .map(|json| serde_json::from_str(&json)) - .transpose()?, - // Unrecognised values inherit rather than error so a profile from a - // newer build cannot make the cluster unloadable - browser_update_mode: row - .browser_update_mode - .as_deref() - .and_then(PackageUpdateMode::parse), - }) - } + pub fn from_row(row: SettingProfileRow) -> crate::ClusterResult { + Ok(Self { + name: row.name, + java_path: row.java_path, + resolution: row + .resolution + .map(|json| serde_json::from_str(&json)) + .transpose()?, + force_fullscreen: row.force_fullscreen.map(|v| v != 0), + mem_max: row.mem_max.map(|v| v as u32), + launch_args: row.launch_args, + launch_env: row.launch_env, + hook_pre: row.hook_pre, + hook_wrapper: row.hook_wrapper, + hook_post: row.hook_post, + os_extra: row + .os_extra + .map(|json| serde_json::from_str(&json)) + .transpose()?, + // Unrecognised values inherit rather than error so a profile from a + // newer build cannot make the cluster unloadable + browser_update_mode: row + .browser_update_mode + .as_deref() + .and_then(PackageUpdateMode::parse), + }) + } - pub fn into_row(&self) -> crate::ClusterResult { - Ok(SettingProfileRow { - name: self.name.clone(), - java_path: self.java_path.clone(), - resolution: self - .resolution - .map(|res| serde_json::to_string(&res)) - .transpose()?, - force_fullscreen: self.force_fullscreen.map(i64::from), - mem_max: self.mem_max.map(i64::from), - launch_args: self.launch_args.clone(), - launch_env: self.launch_env.clone(), - hook_pre: self.hook_pre.clone(), - hook_wrapper: self.hook_wrapper.clone(), - hook_post: self.hook_post.clone(), - os_extra: self - .os_extra - .as_ref() - .map(serde_json::to_string) - .transpose()?, - browser_update_mode: self - .browser_update_mode - .map(|mode| mode.as_str().to_string()), - }) - } + pub fn into_row(&self) -> crate::ClusterResult { + Ok(SettingProfileRow { + name: self.name.clone(), + java_path: self.java_path.clone(), + resolution: self + .resolution + .map(|res| serde_json::to_string(&res)) + .transpose()?, + force_fullscreen: self.force_fullscreen.map(i64::from), + mem_max: self.mem_max.map(i64::from), + launch_args: self.launch_args.clone(), + launch_env: self.launch_env.clone(), + hook_pre: self.hook_pre.clone(), + hook_wrapper: self.hook_wrapper.clone(), + hook_post: self.hook_post.clone(), + os_extra: self + .os_extra + .as_ref() + .map(serde_json::to_string) + .transpose()?, + browser_update_mode: self + .browser_update_mode + .map(|mode| mode.as_str().to_string()), + }) + } } #[cfg(test)] mod tests { - use super::*; + use super::*; - #[test] - fn os_extra_keeps_the_keys_this_build_has_no_field_for() { - let stored = r#"{"enable_gamemode":true,"use_discrete_gpu":true,"some_other_os_flag":7}"#; + #[test] + fn os_extra_keeps_the_keys_this_build_has_no_field_for() { + let stored = r#"{"enable_gamemode":true,"use_discrete_gpu":true,"some_other_os_flag":7}"#; - let extra: SettingsOsExtra = serde_json::from_str(stored).unwrap(); - let written = serde_json::to_value(&extra).unwrap(); - let original: serde_json::Value = serde_json::from_str(stored).unwrap(); + let extra: SettingsOsExtra = serde_json::from_str(stored).unwrap(); + let written = serde_json::to_value(&extra).unwrap(); + let original: serde_json::Value = serde_json::from_str(stored).unwrap(); - assert_eq!(written, original); - } + assert_eq!(written, original); + } } diff --git a/packages/oneclient_cluster/src/profiles.rs b/packages/oneclient_cluster/src/profiles.rs index ce442b66..45b010b4 100644 --- a/packages/oneclient_cluster/src/profiles.rs +++ b/packages/oneclient_cluster/src/profiles.rs @@ -162,4 +162,3 @@ pub async fn update_named_profile( upsert_named_profile(pool, &profile).await } - diff --git a/packages/oneclient_cluster/src/screenshots/mod.rs b/packages/oneclient_cluster/src/screenshots/mod.rs index 027184ae..c2a2fcf4 100644 --- a/packages/oneclient_cluster/src/screenshots/mod.rs +++ b/packages/oneclient_cluster/src/screenshots/mod.rs @@ -5,8 +5,8 @@ use chrono::{DateTime, Utc}; use thiserror::Error; use crate::cluster::Cluster; -use oneclient_common::paths; use crate::error::ClusterResult; +use oneclient_common::paths; #[derive(Debug, Error)] pub enum ScreenshotsError { @@ -33,8 +33,11 @@ fn is_image(name: &str) -> bool { } fn ensure_in_clusters(path: &Path) -> ClusterResult { - polyio::ensure_under(path, [paths::clusters_dir()?, paths::shared_minecraft_dir()?])? - .ok_or_else(|| ScreenshotsError::InvalidPath(path.display().to_string()).into()) + polyio::ensure_under( + path, + [paths::clusters_dir()?, paths::shared_minecraft_dir()?], + )? + .ok_or_else(|| ScreenshotsError::InvalidPath(path.display().to_string()).into()) } #[tracing::instrument(level = "debug", skip(cluster), fields(cluster_id = cluster.id))] @@ -105,7 +108,9 @@ fn thumbnail(raw: &[u8], max_edge: u32) -> Option { let mut out = Vec::new(); let mut cursor = std::io::Cursor::new(&mut out); - resized.write_to(&mut cursor, image::ImageFormat::Png).ok()?; + resized + .write_to(&mut cursor, image::ImageFormat::Png) + .ok()?; Some(Bytes::from(out)) } diff --git a/packages/oneclient_common/src/consent.rs b/packages/oneclient_common/src/consent.rs index 4e3ac14e..68699bb1 100644 --- a/packages/oneclient_common/src/consent.rs +++ b/packages/oneclient_common/src/consent.rs @@ -5,123 +5,123 @@ const GATED_DOMAINS: &[&str] = &["polyfrost.org", "sentry.io"]; /// Hosts that are allowed even if someone didn't accept TOS const ALLOWED_HOSTS: &[&str] = &[ - "data-v2.polyfrost.org", - "meta.polyfrost.org", - "status.polyfrost.org", + "data-v2.polyfrost.org", + "meta.polyfrost.org", + "status.polyfrost.org", ]; static DECLINED: AtomicBool = AtomicBool::new(false); pub fn init(declined: bool) { - DECLINED.store(declined, Ordering::SeqCst); + DECLINED.store(declined, Ordering::SeqCst); } pub fn decline() { - DECLINED.store(true, Ordering::SeqCst); + DECLINED.store(true, Ordering::SeqCst); } #[must_use] pub fn declined() -> bool { - DECLINED.load(Ordering::Relaxed) + DECLINED.load(Ordering::Relaxed) } #[must_use] pub fn blocks_url(url: &str) -> bool { - declined() && is_gated_url(url) + declined() && is_gated_url(url) } #[must_use] pub fn is_gated_url(url: &str) -> bool { - url::Url::parse(url) - .ok() - .and_then(|parsed| parsed.host_str().map(is_gated_host)) - .unwrap_or(true) + url::Url::parse(url) + .ok() + .and_then(|parsed| parsed.host_str().map(is_gated_host)) + .unwrap_or(true) } #[must_use] pub fn is_gated_host(host: &str) -> bool { - let host = host.trim_end_matches('.').to_ascii_lowercase(); + let host = host.trim_end_matches('.').to_ascii_lowercase(); - if ALLOWED_HOSTS.contains(&host.as_str()) { - return false; - } + if ALLOWED_HOSTS.contains(&host.as_str()) { + return false; + } - GATED_DOMAINS - .iter() - .any(|domain| host == *domain || host.ends_with(&format!(".{domain}"))) + GATED_DOMAINS + .iter() + .any(|domain| host == *domain || host.ends_with(&format!(".{domain}"))) } #[cfg(test)] mod tests { - use super::*; + use super::*; - #[test] - fn gates_polyfrost_services_and_the_crash_reporter() { - for host in [ - "polyfrost.org", - "plus.polyfrost.org", - "PLUS.POLYFROST.ORG", - "plus.polyfrost.org.", - "o4511714343124992.ingest.us.sentry.io", - ] { - assert!(is_gated_host(host), "expected {host} to be gated"); - } - } + #[test] + fn gates_polyfrost_services_and_the_crash_reporter() { + for host in [ + "polyfrost.org", + "plus.polyfrost.org", + "PLUS.POLYFROST.ORG", + "plus.polyfrost.org.", + "o4511714343124992.ingest.us.sentry.io", + ] { + assert!(is_gated_host(host), "expected {host} to be gated"); + } + } - #[test] - fn leaves_everything_else_alone() { - for host in [ - "api.modrinth.com", - "api.curseforge.com", - "sessionserver.mojang.com", - "api.minecraftservices.com", - "www.gstatic.com", - "github.com", - "notpolyfrost.org", - "polyfrost.org.attacker.com", - "evil-sentry.io.attacker.com", - ] { - assert!(!is_gated_host(host), "unexpected gate on {host}"); - } - } + #[test] + fn leaves_everything_else_alone() { + for host in [ + "api.modrinth.com", + "api.curseforge.com", + "sessionserver.mojang.com", + "api.minecraftservices.com", + "www.gstatic.com", + "github.com", + "notpolyfrost.org", + "polyfrost.org.attacker.com", + "evil-sentry.io.attacker.com", + ] { + assert!(!is_gated_host(host), "unexpected gate on {host}"); + } + } - #[test] - fn lets_the_exempt_hosts_through() { - for url in [ - "https://data-v2.polyfrost.org/oneclient/bundles/metadata.json", - "https://data-v2.polyfrost.org/oneclient/versions/art/Tricky_Trials.jpg", - "https://data-v2.polyfrost.org/oneclient/tos.json", - "https://meta.polyfrost.org/fabric/v2/manifest.json", - "https://status.polyfrost.org/index.json", - "https://META.POLYFROST.ORG./minecraft/v1/manifest.json", - ] { - assert!(!is_gated_url(url), "unexpected gate on {url}"); - } - } + #[test] + fn lets_the_exempt_hosts_through() { + for url in [ + "https://data-v2.polyfrost.org/oneclient/bundles/metadata.json", + "https://data-v2.polyfrost.org/oneclient/versions/art/Tricky_Trials.jpg", + "https://data-v2.polyfrost.org/oneclient/tos.json", + "https://meta.polyfrost.org/fabric/v2/manifest.json", + "https://status.polyfrost.org/index.json", + "https://META.POLYFROST.ORG./minecraft/v1/manifest.json", + ] { + assert!(!is_gated_url(url), "unexpected gate on {url}"); + } + } - #[test] - fn the_exemption_is_the_exact_host_and_nothing_near_it() { - for host in [ - "plus.polyfrost.org", - "evil.data-v2.polyfrost.org", - "meta.polyfrost.org.evil.polyfrost.org", - "status.polyfrost.org.evil.polyfrost.org", - ] { - assert!(is_gated_host(host), "expected {host} to stay gated"); - } - } + #[test] + fn the_exemption_is_the_exact_host_and_nothing_near_it() { + for host in [ + "plus.polyfrost.org", + "evil.data-v2.polyfrost.org", + "meta.polyfrost.org.evil.polyfrost.org", + "status.polyfrost.org.evil.polyfrost.org", + ] { + assert!(is_gated_host(host), "expected {host} to stay gated"); + } + } - #[test] - fn gates_urls_by_their_host() { - assert!(is_gated_url("https://plus.polyfrost.org/account/login")); - assert!(is_gated_url("wss://plus.polyfrost.org/websocket")); - assert!(!is_gated_url("https://api.modrinth.com/v2/search")); - } + #[test] + fn gates_urls_by_their_host() { + assert!(is_gated_url("https://plus.polyfrost.org/account/login")); + assert!(is_gated_url("wss://plus.polyfrost.org/websocket")); + assert!(!is_gated_url("https://api.modrinth.com/v2/search")); + } - #[test] - fn unparseable_urls_fail_closed() { - assert!(is_gated_url("not a url")); - assert!(is_gated_url("")); - assert!(is_gated_url("file:///etc/hosts")); - } + #[test] + fn unparseable_urls_fail_closed() { + assert!(is_gated_url("not a url")); + assert!(is_gated_url("")); + assert!(is_gated_url("file:///etc/hosts")); + } } diff --git a/packages/oneclient_common/src/constants.rs b/packages/oneclient_common/src/constants.rs index ab28ed17..ed35f8c8 100644 --- a/packages/oneclient_common/src/constants.rs +++ b/packages/oneclient_common/src/constants.rs @@ -1,4 +1,3 @@ - pub const DUMMY_REPLACE_NEWLINE: &str = "\n"; pub const MICROSOFT_CLIENT_ID: &str = "9419b7ee-1448-4d1b-b52a-550d8f36ab56"; @@ -13,15 +12,17 @@ pub const CURSEFORGE_GAME_ID: u32 = 432; pub const METADATA_API_URL: &str = "https://meta.polyfrost.org"; pub const MCLOGS_API_URL: &str = "https://api.mclo.gs/1"; pub const SKYCLIENT_BASE_URL: &str = - "https://raw.githubusercontent.com/SkyblockClient/SkyblockClient-REPO/refs/heads/main/v1"; + "https://raw.githubusercontent.com/SkyblockClient/SkyblockClient-REPO/refs/heads/main/v1"; pub const META_URL_BASE: &str = "https://data-v2.polyfrost.org"; pub const TOS_URL: &str = "https://polyfrost.org/legal/terms"; pub const PRIVACY_URL: &str = "https://polyfrost.org/legal/privacy"; pub const PLUS_BACKEND_URL: &str = "https://plus.polyfrost.org"; pub const SENTRY_DSN: &str = match option_env!("ONECLIENT_SENTRY_DSN") { - Some(dsn) => dsn, - None => "https://e7dff7e07427e1a28b9212cfcc8ddc1e@o4511714343124992.ingest.us.sentry.io/4511714354135040", + Some(dsn) => dsn, + None => { + "https://e7dff7e07427e1a28b9212cfcc8ddc1e@o4511714343124992.ingest.us.sentry.io/4511714354135040" + } }; pub const TARGET_OS: &str = cfg_select! { @@ -39,9 +40,9 @@ pub const NATIVE_ARCH: &str = cfg_select! { pub const JAVA_BIN: &str = if cfg!(windows) { "javaw.exe" } else { "java" }; pub const ARCH_WIDTH: &str = if cfg!(target_pointer_width = "64") { - "64" + "64" } else { - "32" + "32" }; pub const LINE_ENDING: &str = if cfg!(windows) { "\r\n" } else { "\n" }; diff --git a/packages/oneclient_common/src/domain.rs b/packages/oneclient_common/src/domain.rs index 652e906f..62375524 100644 --- a/packages/oneclient_common/src/domain.rs +++ b/packages/oneclient_common/src/domain.rs @@ -139,17 +139,7 @@ pub enum HashAlgorithm { /// Applies only to browser-installed packages bundles have their own update flow /// The check always runs regardless of variant so "out of date" markers stay populated #[derive( - Debug, - Clone, - Copy, - PartialEq, - Eq, - Hash, - Serialize, - Deserialize, - Default, - EnumIter, - StrumDisplay, + Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default, EnumIter, StrumDisplay, )] #[serde(rename_all = "snake_case")] pub enum PackageUpdateMode { diff --git a/packages/oneclient_common/src/error.rs b/packages/oneclient_common/src/error.rs index 86b3b938..ef1fbcc8 100644 --- a/packages/oneclient_common/src/error.rs +++ b/packages/oneclient_common/src/error.rs @@ -3,6 +3,6 @@ pub type PathsResult = Result; /// Kept separate from the launcher-wide error so this crate stays a leaf #[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] pub enum PathsError { - #[error("Could not resolve launcher data directory")] - DataDirUnavailable, + #[error("Could not resolve launcher data directory")] + DataDirUnavailable, } diff --git a/packages/oneclient_common/src/lib.rs b/packages/oneclient_common/src/lib.rs index 27376f67..3e251f95 100644 --- a/packages/oneclient_common/src/lib.rs +++ b/packages/oneclient_common/src/lib.rs @@ -6,8 +6,8 @@ pub mod constants; pub mod domain; pub mod memory; pub mod os_ext; -pub mod paths; pub mod patch; +pub mod paths; pub mod process; pub mod search; pub mod version; diff --git a/packages/oneclient_common/src/patch.rs b/packages/oneclient_common/src/patch.rs index 38058801..5108fe38 100644 --- a/packages/oneclient_common/src/patch.rs +++ b/packages/oneclient_common/src/patch.rs @@ -3,55 +3,53 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "lowercase")] pub enum Patch { - #[default] - Unchanged, - Clear, - Set(T), + #[default] + Unchanged, + Clear, + Set(T), } impl Patch { - pub fn is_unchanged(&self) -> bool { - matches!(self, Self::Unchanged) - } + pub fn is_unchanged(&self) -> bool { + matches!(self, Self::Unchanged) + } - pub fn apply_to_option(&self, slot: &mut Option) - where - T: Clone, - { - match self { - Self::Unchanged => {} - Self::Clear => *slot = None, - Self::Set(value) => *slot = Some(value.clone()), - } - } + pub fn apply_to_option(&self, slot: &mut Option) + where + T: Clone, + { + match self { + Self::Unchanged => {} + Self::Clear => *slot = None, + Self::Set(value) => *slot = Some(value.clone()), + } + } - pub fn into_db_patch(self) -> Option> { - match self { - Self::Unchanged => None, - Self::Clear => Some(None), - Self::Set(value) => Some(Some(value)), - } - } + pub fn into_db_patch(self) -> Option> { + match self { + Self::Unchanged => None, + Self::Clear => Some(None), + Self::Set(value) => Some(Some(value)), + } + } } impl Patch { - pub fn apply_to_command_option(&self, slot: &mut Option) - { - match self { - Self::Unchanged => {} - Self::Clear => *slot = None, - Self::Set(value) => *slot = Some(value.trim().to_owned()), - } - } + pub fn apply_to_command_option(&self, slot: &mut Option) { + match self { + Self::Unchanged => {} + Self::Clear => *slot = None, + Self::Set(value) => *slot = Some(value.trim().to_owned()), + } + } } impl Patch<&str> { - pub fn apply_to_command_option(&self, slot: &mut Option) - { - match self { - Self::Unchanged => {} - Self::Clear => *slot = None, - Self::Set(value) => *slot = Some(value.trim().to_owned()), - } - } + pub fn apply_to_command_option(&self, slot: &mut Option) { + match self { + Self::Unchanged => {} + Self::Clear => *slot = None, + Self::Set(value) => *slot = Some(value.trim().to_owned()), + } + } } diff --git a/packages/oneclient_common/src/paths.rs b/packages/oneclient_common/src/paths.rs index 20ce4fda..893b0b8b 100644 --- a/packages/oneclient_common/src/paths.rs +++ b/packages/oneclient_common/src/paths.rs @@ -21,254 +21,259 @@ static CONFIG_DIR_OVERRIDE: OnceLock = OnceLock::new(); static DATA_DIR_OVERRIDE: OnceLock = OnceLock::new(); pub fn set_launcher_dir(dir: PathBuf) { - let _ = CONFIG_DIR_OVERRIDE.set(dir.clone()); - let _ = DATA_DIR_OVERRIDE.set(dir); + let _ = CONFIG_DIR_OVERRIDE.set(dir.clone()); + let _ = DATA_DIR_OVERRIDE.set(dir); } pub fn set_data_dir(dir: PathBuf) { - let _ = DATA_DIR_OVERRIDE.set(dir); + let _ = DATA_DIR_OVERRIDE.set(dir); } fn organization_dir() -> PathsResult { - BaseDirs::new() - .map(|dirs| dirs.data_dir().join(ORGANIZATION)) - .ok_or(PathsError::DataDirUnavailable) + BaseDirs::new() + .map(|dirs| dirs.data_dir().join(ORGANIZATION)) + .ok_or(PathsError::DataDirUnavailable) } fn legacy_dir() -> Option { - ProjectDirs::from(QUALIFIER, ORGANIZATION, APPLICATION) - .map(|dirs| dirs.data_local_dir().to_path_buf()) + ProjectDirs::from(QUALIFIER, ORGANIZATION, APPLICATION) + .map(|dirs| dirs.data_local_dir().to_path_buf()) } fn resolve_default_dir() -> PathsResult { - if let Some(legacy) = legacy_dir() - && legacy.join(SETTINGS_FILE).is_file() - { - return Ok(legacy); - } + if let Some(legacy) = legacy_dir() + && legacy.join(SETTINGS_FILE).is_file() + { + return Ok(legacy); + } - Ok(organization_dir()?.join(APPLICATION)) + Ok(organization_dir()?.join(APPLICATION)) } fn default_dir() -> PathsResult<&'static Path> { - if let Some(dir) = DEFAULT_DIR.get() { - return Ok(dir); - } + if let Some(dir) = DEFAULT_DIR.get() { + return Ok(dir); + } - let _ = DEFAULT_DIR.set(resolve_default_dir()?); - DEFAULT_DIR - .get() - .map(PathBuf::as_path) - .ok_or(PathsError::DataDirUnavailable) + let _ = DEFAULT_DIR.set(resolve_default_dir()?); + DEFAULT_DIR + .get() + .map(PathBuf::as_path) + .ok_or(PathsError::DataDirUnavailable) } pub fn config_dir() -> PathsResult<&'static Path> { - if let Some(dir) = CONFIG_DIR_OVERRIDE.get() { - return Ok(dir); - } + if let Some(dir) = CONFIG_DIR_OVERRIDE.get() { + return Ok(dir); + } - default_dir() + default_dir() } pub fn data_dir() -> PathsResult<&'static Path> { - if let Some(dir) = DATA_DIR_OVERRIDE.get() { - return Ok(dir); - } + if let Some(dir) = DATA_DIR_OVERRIDE.get() { + return Ok(dir); + } - config_dir() + config_dir() } #[must_use] pub fn picker_start_dir() -> Option { - organization_dir() - .ok() - .filter(|dir| dir.is_dir()) - .or_else(|| data_dir().ok().map(Path::to_path_buf)) + organization_dir() + .ok() + .filter(|dir| dir.is_dir()) + .or_else(|| data_dir().ok().map(Path::to_path_buf)) } pub fn database_file() -> PathsResult { - Ok(data_dir()?.join("user_data.db")) + Ok(data_dir()?.join("user_data.db")) } pub fn settings_file() -> PathsResult { - Ok(config_dir()?.join(SETTINGS_FILE)) + Ok(config_dir()?.join(SETTINGS_FILE)) } pub fn damaged_settings_file() -> PathsResult { - Ok(config_dir()?.join("settings.json.corrupt")) + Ok(config_dir()?.join("settings.json.corrupt")) } pub fn auth_file() -> PathsResult { - Ok(config_dir()?.join("auth.json")) + Ok(config_dir()?.join("auth.json")) } pub fn logs_dir() -> PathsResult { - Ok(config_dir()?.join("logs")) + Ok(config_dir()?.join("logs")) } pub fn java_dir() -> PathsResult { - Ok(data_dir()?.join("metadata").join("java")) + Ok(data_dir()?.join("metadata").join("java")) } pub fn clusters_dir() -> PathsResult { - Ok(data_dir()?.join("clusters")) + Ok(data_dir()?.join("clusters")) } pub fn shared_minecraft_dir() -> PathsResult { - Ok(data_dir()?.join(".minecraft")) + Ok(data_dir()?.join(".minecraft")) } /// Presence marks a cluster folder as its own game dir instead of the shared `.minecraft` pub const DEDICATED_MARKER: &str = ".dedicated_directory"; pub fn cluster_dir(folder_name: &str) -> PathsResult { - Ok(clusters_dir()?.join(folder_name)) + Ok(clusters_dir()?.join(folder_name)) } pub fn cluster_uses_dedicated_dir(folder_name: &str) -> bool { - cluster_dir(folder_name).is_ok_and(|dir| dir.join(DEDICATED_MARKER).exists()) + cluster_dir(folder_name).is_ok_and(|dir| dir.join(DEDICATED_MARKER).exists()) } /// Lives here not on `Cluster` so the content layer can resolve it from a bare /// `ClusterRow` without duplicating the marker-file rule pub fn cluster_game_dir(folder_name: &str) -> PathsResult { - if cluster_uses_dedicated_dir(folder_name) { - cluster_dir(folder_name) - } else { - shared_minecraft_dir() - } + if cluster_uses_dedicated_dir(folder_name) { + cluster_dir(folder_name) + } else { + shared_minecraft_dir() + } } pub fn cluster_mods_dir(folder_name: &str) -> PathsResult { - Ok(cluster_dir(folder_name)?.join(ContentType::Mod.folder_name())) + Ok(cluster_dir(folder_name)?.join(ContentType::Mod.folder_name())) } pub fn shared_mods_dir() -> PathsResult { - Ok(shared_minecraft_dir()?.join(ContentType::Mod.folder_name())) + Ok(shared_minecraft_dir()?.join(ContentType::Mod.folder_name())) } pub fn global_content_dir(content_type: ContentType) -> PathsResult { - Ok(shared_minecraft_dir()?.join(content_type.folder_name())) + Ok(shared_minecraft_dir()?.join(content_type.folder_name())) } pub fn shared_mods_link(folder_name: &str) -> PathsResult { - Ok(shared_mods_dir()?.join(folder_name)) + Ok(shared_mods_dir()?.join(folder_name)) } pub fn packages_cache_dir() -> PathsResult { - Ok(data_dir()?.join("metadata").join("packages")) + Ok(data_dir()?.join("metadata").join("packages")) } pub fn caches_dir() -> PathsResult { - Ok(data_dir()?.join("metadata").join("caches")) + Ok(data_dir()?.join("metadata").join("caches")) } pub fn bundles_dir() -> PathsResult { - Ok(caches_dir()?.join("bundles")) + Ok(caches_dir()?.join("bundles")) } pub fn images_cache_dir() -> PathsResult { - Ok(caches_dir()?.join("images")) + Ok(caches_dir()?.join("images")) } pub const LOCAL_IMAGE_SCHEME: &str = "local:"; pub fn local_icons_dir() -> PathsResult { - Ok(images_cache_dir()?.join("local")) + Ok(images_cache_dir()?.join("local")) } pub fn local_image_path(reference: &str) -> Option { - let name = reference.strip_prefix(LOCAL_IMAGE_SCHEME)?; + let name = reference.strip_prefix(LOCAL_IMAGE_SCHEME)?; - if name.is_empty() || name.contains(['/', '\\']) || name.contains("..") { - return None; - } + if name.is_empty() || name.contains(['/', '\\']) || name.contains("..") { + return None; + } - Some(local_icons_dir().ok()?.join(name)) + Some(local_icons_dir().ok()?.join(name)) } pub fn profiles_cache_dir() -> PathsResult { - Ok(caches_dir()?.join("profiles")) + Ok(caches_dir()?.join("profiles")) } pub fn versions_dir() -> PathsResult { - Ok(data_dir()?.join("metadata").join("versions")) + Ok(data_dir()?.join("metadata").join("versions")) } pub fn libraries_dir() -> PathsResult { - Ok(data_dir()?.join("metadata").join("libraries")) + Ok(data_dir()?.join("metadata").join("libraries")) } pub fn natives_dir() -> PathsResult { - Ok(data_dir()?.join("metadata").join("natives")) + Ok(data_dir()?.join("metadata").join("natives")) } pub fn assets_dir() -> PathsResult { - Ok(data_dir()?.join("metadata").join("assets")) + Ok(data_dir()?.join("metadata").join("assets")) } pub fn assets_index_dir() -> PathsResult { - Ok(assets_dir()?.join("indexes")) + Ok(assets_dir()?.join("indexes")) } pub fn assets_object_dir() -> PathsResult { - Ok(assets_dir()?.join("objects")) + Ok(assets_dir()?.join("objects")) } pub fn legacy_assets_dir() -> PathsResult { - Ok(data_dir()?.join("metadata").join("resources")) + Ok(data_dir()?.join("metadata").join("resources")) } pub fn package_version_dir( - content_type: ContentType, - provider: ProviderId, - project_id: &str, - version_id: &str, + content_type: ContentType, + provider: ProviderId, + project_id: &str, + version_id: &str, ) -> PathsResult { - Ok(packages_cache_dir()? - .join(content_type.folder_name()) - .join(provider.dir_name()) - .join(project_id) - .join(version_id)) + Ok(packages_cache_dir()? + .join(content_type.folder_name()) + .join(provider.dir_name()) + .join(project_id) + .join(version_id)) } #[cfg(test)] mod tests { - use super::*; - use std::ffi::OsStr; + use super::*; + use std::ffi::OsStr; - #[test] - fn the_launcher_sits_one_level_under_the_organization_folder() { - let organization = organization_dir().expect("a home directory"); - assert_eq!(organization.file_name(), Some(OsStr::new(ORGANIZATION))); + #[test] + fn the_launcher_sits_one_level_under_the_organization_folder() { + let organization = organization_dir().expect("a home directory"); + assert_eq!(organization.file_name(), Some(OsStr::new(ORGANIZATION))); - let app = organization.join(APPLICATION); - let tail: Vec<&OsStr> = app.iter().rev().take(2).collect(); + let app = organization.join(APPLICATION); + let tail: Vec<&OsStr> = app.iter().rev().take(2).collect(); - assert_eq!( - tail, - vec![OsStr::new(APPLICATION), OsStr::new(ORGANIZATION)], - "every Polyfrost product shares one folder and takes a single name inside it" - ); - } + assert_eq!( + tail, + vec![OsStr::new(APPLICATION), OsStr::new(ORGANIZATION)], + "every Polyfrost product shares one folder and takes a single name inside it" + ); + } - #[cfg(windows)] - #[test] - fn a_windows_install_roams_with_the_user() { - let organization = organization_dir().expect("a home directory"); + #[cfg(windows)] + #[test] + fn a_windows_install_roams_with_the_user() { + let organization = organization_dir().expect("a home directory"); - assert!( - organization.iter().any(|part| part == OsStr::new("Roaming")), - "settings and saves follow the user between machines; Local would strand them" - ); - } + assert!( + organization + .iter() + .any(|part| part == OsStr::new("Roaming")), + "settings and saves follow the user between machines; Local would strand them" + ); + } - #[test] - fn a_debug_build_never_shares_a_folder_with_a_release_one() { - assert_eq!(APPLICATION, if cfg!(debug_assertions) { - "OneClient-dev" - } else { - "OneClient" - }); - } + #[test] + fn a_debug_build_never_shares_a_folder_with_a_release_one() { + assert_eq!( + APPLICATION, + if cfg!(debug_assertions) { + "OneClient-dev" + } else { + "OneClient" + } + ); + } } diff --git a/packages/oneclient_common/src/process.rs b/packages/oneclient_common/src/process.rs index 6ccc5661..c5aa4d06 100644 --- a/packages/oneclient_common/src/process.rs +++ b/packages/oneclient_common/src/process.rs @@ -1,12 +1,12 @@ pub fn no_window(command: &mut std::process::Command) { - #[cfg(windows)] - { - use std::os::windows::process::CommandExt; + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; - const CREATE_NO_WINDOW: u32 = 0x0800_0000; - command.creation_flags(CREATE_NO_WINDOW); - } + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + command.creation_flags(CREATE_NO_WINDOW); + } - #[cfg(not(windows))] - let _ = command; + #[cfg(not(windows))] + let _ = command; } diff --git a/packages/oneclient_common/src/version.rs b/packages/oneclient_common/src/version.rs index 00928961..291f70b6 100644 --- a/packages/oneclient_common/src/version.rs +++ b/packages/oneclient_common/src/version.rs @@ -1,4 +1,3 @@ - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ParsedMcVersion { pub major: u32, diff --git a/packages/oneclient_content/src/bundles/install.rs b/packages/oneclient_content/src/bundles/install.rs index 496fe527..8b645808 100644 --- a/packages/oneclient_content/src/bundles/install.rs +++ b/packages/oneclient_content/src/bundles/install.rs @@ -6,17 +6,17 @@ use oneclient_db::dao::cluster_bundle as bundle_dao; use oneclient_db::models::ClusterRow; use oneclient_db::models::OverrideType; -use crate::error::ContentError; -use crate::error::ContentResult; use crate::bundles::error::BundleError; use crate::bundles::manager::BundlesManager; use crate::bundles::overrides; use crate::bundles::types::{BundleArchive, BundleFile, BundleFileKind}; -use oneclient_events::{GroupedProgressChild, GroupedProgressSession, TaskCategory, TaskPhase}; -use oneclient_common::domain::{ContentType, GameLoader}; +use crate::ctx::ContentCtx; +use crate::error::ContentError; +use crate::error::ContentResult; use crate::packages::store::{LiveSync, PackageStore, evict_if_unused, try_unlink_materialized}; use crate::packages::types::ExternalFile; -use crate::ctx::ContentCtx; +use oneclient_common::domain::{ContentType, GameLoader}; +use oneclient_events::{GroupedProgressChild, GroupedProgressSession, TaskCategory, TaskPhase}; fn is_base62(s: &str) -> bool { !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric()) @@ -100,15 +100,11 @@ pub async fn install_package_from_bundle( .await; let version = if is_base62(version_id) { - crate::packages::get_version_cached(ctx, *provider, project_id, version_id) - .await? - } else if let Ok(Some((_, version))) = - ctx.providers.lookup_version(sha1, ctx).await - { + crate::packages::get_version_cached(ctx, *provider, project_id, version_id).await? + } else if let Ok(Some((_, version))) = ctx.providers.lookup_version(sha1, ctx).await { version } else { - crate::packages::get_version_cached(ctx, *provider, project_id, version_id) - .await? + crate::packages::get_version_cached(ctx, *provider, project_id, version_id).await? }; let (artifact, _) = PackageStore::install_to_cluster( @@ -378,7 +374,11 @@ pub async fn install_enabled_bundle_files( if let Some(p) = progress { let reserved_bytes: u64 = to_install.iter().map(|f| f.size.max(1)).sum(); - p.expect(TaskCategory::Packages, to_install.len() as u64, reserved_bytes); + p.expect( + TaskCategory::Packages, + to_install.len() as u64, + reserved_bytes, + ); } let bundle_name = &bundle_name; @@ -640,11 +640,7 @@ pub async fn set_artifact_enabled_to( } // which clusters have to record what the user just did -async fn override_scope( - cluster_id: i64, - hash: &str, - ctx: &ContentCtx, -) -> ContentResult> { +async fn override_scope(cluster_id: i64, hash: &str, ctx: &ContentCtx) -> ContentResult> { let global = artifact_dao::get_artifact_by_hash(&ctx.db, hash) .await? .and_then(|artifact| ContentType::from_repr(artifact.content_type as u8)) @@ -688,9 +684,10 @@ pub async fn on_user_enable_artifact( ) -> ContentResult<()> { for id in override_scope(cluster_id, hash, ctx).await? { if let Some(tracked) = bundle_dao::get_bundle_tracked(&ctx.db, id, hash).await? - && let Some(package_id) = tracked.package_id { - clear_suppressing_overrides(id, &package_id, ctx).await?; - } + && let Some(package_id) = tracked.package_id + { + clear_suppressing_overrides(id, &package_id, ctx).await?; + } } Ok(()) @@ -703,8 +700,7 @@ async fn handle_user_artifact_action( ctx: &ContentCtx, override_type: OverrideType, ) -> ContentResult<()> { - let Some(tracked) = bundle_dao::get_bundle_tracked(&ctx.db, cluster_id, hash).await? - else { + let Some(tracked) = bundle_dao::get_bundle_tracked(&ctx.db, cluster_id, hash).await? else { return Ok(()); }; @@ -759,37 +755,32 @@ pub async fn remove_artifact_from_cluster( // The package actually lives in the cache // `evict_if_unused` drops it only once no other cluster still needs it - if !deferred - && let Err(err) = evict_if_unused(hash, ctx).await - { + if !deferred && let Err(err) = evict_if_unused(hash, ctx).await { tracing::warn!(hash, error = %err, "failed to evict unused artifact from the cache"); } if let Some(tracked) = bundle_data && let (Some(bundle_name), Some(package_id)) = (tracked.bundle_name.clone(), tracked.package_id.clone()) - { - if record_override { - bundle_dao::save_override( - &ctx.db, - cluster_id, - &bundle_name, - &package_id, - OverrideType::Removed, - ) - .await?; - } else { - let replacement_exists = bundle_dao::has_bundle_mapping_for_package( - &ctx.db, - cluster_id, - &package_id, - ) - .await?; - if !replacement_exists { - clear_suppressing_overrides(cluster_id, &package_id, ctx).await?; - } + { + if record_override { + bundle_dao::save_override( + &ctx.db, + cluster_id, + &bundle_name, + &package_id, + OverrideType::Removed, + ) + .await?; + } else { + let replacement_exists = + bundle_dao::has_bundle_mapping_for_package(&ctx.db, cluster_id, &package_id) + .await?; + if !replacement_exists { + clear_suppressing_overrides(cluster_id, &package_id, ctx).await?; } } + } Ok(()) } @@ -824,7 +815,10 @@ mod tests { #[test] fn suppressing_overrides_win_over_enabled_manifest() { - assert!(!effective_enabled(&file(true), Some(OverrideType::Disabled))); + assert!(!effective_enabled( + &file(true), + Some(OverrideType::Disabled) + )); assert!(!effective_enabled(&file(true), Some(OverrideType::Removed))); } diff --git a/packages/oneclient_content/src/bundles/manager.rs b/packages/oneclient_content/src/bundles/manager.rs index 2159057a..7b2e1153 100644 --- a/packages/oneclient_content/src/bundles/manager.rs +++ b/packages/oneclient_content/src/bundles/manager.rs @@ -9,14 +9,14 @@ use tokio::sync::RwLock; use crate::bundles::error::BundleError; use crate::bundles::manifest::{BundleManifest as RemoteBundleManifest, RemoteBundleRef}; -use crate::bundles::types::BundleArchive; use crate::bundles::polymrpack; -use oneclient_net::RequestError; -use oneclient_common::domain::GameLoader; -use oneclient_common::paths; -use oneclient_net::{EtagPolicy, fetch_cached}; +use crate::bundles::types::BundleArchive; use crate::ctx::ContentCtx; use crate::error::{ContentError, ContentResult}; +use oneclient_common::domain::GameLoader; +use oneclient_common::paths; +use oneclient_net::RequestError; +use oneclient_net::{EtagPolicy, fetch_cached}; #[derive(Debug, Clone)] pub struct Bundle { @@ -179,8 +179,7 @@ impl BundlesManager { loader: GameLoader, ) -> ContentResult> { let rows = - bundle_dao::list_visible_for_version_loader(&ctx.db, mc_version, loader as i64) - .await?; + bundle_dao::list_visible_for_version_loader(&ctx.db, mc_version, loader as i64).await?; rows.into_iter() .map(Bundle::try_from_row) @@ -245,17 +244,14 @@ impl BundlesManager { /// bundles that never arrived /// `sync` commits it once all land #[tracing::instrument(level = "debug", skip(ctx))] - async fn fetch_manifest( - ctx: &ContentCtx, - ) -> ContentResult> { + async fn fetch_manifest(ctx: &ContentCtx) -> ContentResult> { let manifest_path = paths::bundles_dir()?.join("metadata.json"); let url = format!( "{}/oneclient/bundles/metadata.json", ctx.net.config().meta_url_base ); - let Some(fetched) = - fetch_cached(&ctx.net, &url, &manifest_path, EtagPolicy::Defer).await? + let Some(fetched) = fetch_cached(&ctx.net, &url, &manifest_path, EtagPolicy::Defer).await? else { return Ok(None); }; @@ -280,7 +276,6 @@ struct FetchedManifest { etag: Option, } - #[tracing::instrument(level = "debug", skip(ctx))] async fn download_bundle_if_needed( ctx: &ContentCtx, @@ -308,7 +303,6 @@ async fn download_bundle_if_needed( .map_err(map_request_error) } - fn map_request_error(err: RequestError) -> ContentError { match err { RequestError::DeserializeError { diff --git a/packages/oneclient_content/src/bundles/mod.rs b/packages/oneclient_content/src/bundles/mod.rs index 9fdfc4de..86f1f759 100644 --- a/packages/oneclient_content/src/bundles/mod.rs +++ b/packages/oneclient_content/src/bundles/mod.rs @@ -11,26 +11,23 @@ mod updates; pub use error::BundleError; pub use install::{ effective_enabled, enabled_bundle_bytes, extract_bundle_overrides_for_cluster, - heal_bundle_activity, install_bundle, - install_cluster_bundles, install_enabled_bundle_files, install_package_from_bundle, - list_cluster_bundle_overrides, - on_user_disable_artifact, on_user_enable_artifact, on_user_remove_artifact, - remove_artifact_from_cluster, set_artifact_enabled_to, set_bundle_package_enabled, - set_bundle_package_opt_in, + heal_bundle_activity, install_bundle, install_cluster_bundles, install_enabled_bundle_files, + install_package_from_bundle, list_cluster_bundle_overrides, on_user_disable_artifact, + on_user_enable_artifact, on_user_remove_artifact, remove_artifact_from_cluster, + set_artifact_enabled_to, set_bundle_package_enabled, set_bundle_package_opt_in, set_bundle_package_override, set_bundle_package_overrides, }; pub use manager::{Bundle, BundlesManager}; +pub use manifest::BundleManifest as RemoteBundleManifest; pub use optional::{ PendingOptionalMod, pending_optional_mods, resolve_optional_mods, skip_optional_mods, }; -pub use manifest::BundleManifest as RemoteBundleManifest; pub use types::{ - ApplyBundleUpdatesResult, BundleArchive, BundleFile, BundleFileKind, BundleOptionalPackage, - BundleManifest, BundlePackageAddition, BundlePackageRemoval, BundlePackageUpdate, + ApplyBundleUpdatesResult, BundleArchive, BundleFile, BundleFileKind, BundleManifest, + BundleOptionalPackage, BundlePackageAddition, BundlePackageRemoval, BundlePackageUpdate, BundleUpdateCheckResult, BundleWithUpdateStatus, FileUpdateStatus, }; pub use updates::{ apply_bundle_updates, apply_bundle_updates_with, check_bundle_updates, - cluster_has_bundle_content, - get_bundles_with_update_status, + cluster_has_bundle_content, get_bundles_with_update_status, }; diff --git a/packages/oneclient_content/src/bundles/optional.rs b/packages/oneclient_content/src/bundles/optional.rs index 79d3e6f8..9328b8c0 100644 --- a/packages/oneclient_content/src/bundles/optional.rs +++ b/packages/oneclient_content/src/bundles/optional.rs @@ -1,12 +1,12 @@ -use oneclient_db::dao::cluster_bundle as bundle_dao; -use oneclient_db::dao::cluster_optional_mod as optional_dao; -use oneclient_db::models::{OptionalModStatus, OverrideType}; use crate::bundles::manager::BundlesManager; use crate::bundles::types::{BundleArchive, BundleFile}; use crate::ctx::ContentCtx; use crate::error::ContentResult; use crate::packages::store::PackageStore; use oneclient_common::domain::GameLoader; +use oneclient_db::dao::cluster_bundle as bundle_dao; +use oneclient_db::dao::cluster_optional_mod as optional_dao; +use oneclient_db::models::{OptionalModStatus, OverrideType}; #[derive(Debug, Clone)] pub struct PendingOptionalMod { diff --git a/packages/oneclient_content/src/bundles/overrides.rs b/packages/oneclient_content/src/bundles/overrides.rs index 9afd2980..1a7dae13 100644 --- a/packages/oneclient_content/src/bundles/overrides.rs +++ b/packages/oneclient_content/src/bundles/overrides.rs @@ -5,8 +5,8 @@ use oneclient_db::models::ClusterRow; use serde::{Deserialize, Serialize}; use crate::error::ContentResult; -use polyio::{sha1_bytes, sha1_file}; use oneclient_events::EventBus; +use polyio::{sha1_bytes, sha1_file}; const ALWAYS_UPDATE_GLOBS: &[&str] = &["config/fabric_loader_dependencies.json"]; @@ -187,11 +187,7 @@ async fn current_sha1(path: &Path) -> Option { } } -fn notify_conflicts( - events: Option<&EventBus>, - bundle_name: &str, - conflicts: &[String], -) { +fn notify_conflicts(events: Option<&EventBus>, bundle_name: &str, conflicts: &[String]) { let Some(events) = events else { return; }; @@ -482,9 +478,7 @@ mod tests { async fn bootstrap_pre_existing_edit_is_never_overwritten() { let root = polyio::testing::ScratchDir::new("bootstrap"); - polyio::create_dir_all(root.join("config")) - .await - .unwrap(); + polyio::create_dir_all(root.join("config")).await.unwrap(); polyio::write(root.join("config/a.toml"), b"pre-existing") .await .unwrap(); diff --git a/packages/oneclient_content/src/bundles/polymrpack.rs b/packages/oneclient_content/src/bundles/polymrpack.rs index fe3d460e..f5245983 100644 --- a/packages/oneclient_content/src/bundles/polymrpack.rs +++ b/packages/oneclient_content/src/bundles/polymrpack.rs @@ -4,14 +4,14 @@ use std::str::FromStr; use serde::Deserialize; -use crate::error::ContentResult; use crate::bundles::error::BundleError; use crate::bundles::types::{ BundleFile, BundleFileKind, BundleManifest, content_type_from_bundle_path, }; +use crate::error::ContentResult; +use crate::packages::types::ExternalFile; use oneclient_common::constants::MODRINTH_CDN_PREFIX; use oneclient_common::domain::{GameLoader, ProviderId}; -use crate::packages::types::ExternalFile; #[derive(Debug, Clone, PartialEq, Eq)] pub struct PolyMrpackMeta { diff --git a/packages/oneclient_content/src/bundles/types.rs b/packages/oneclient_content/src/bundles/types.rs index 41c207e2..42d2e4e3 100644 --- a/packages/oneclient_content/src/bundles/types.rs +++ b/packages/oneclient_content/src/bundles/types.rs @@ -1,8 +1,8 @@ use serde::{Deserialize, Serialize}; use crate::bundles::Bundle; -use oneclient_common::domain::{ContentType, GameLoader, ProviderId}; use crate::packages::types::ExternalFile; +use oneclient_common::domain::{ContentType, GameLoader, ProviderId}; #[derive(Debug, Clone)] pub struct BundleArchive { diff --git a/packages/oneclient_content/src/bundles/updates.rs b/packages/oneclient_content/src/bundles/updates.rs index 9144b5d7..6cfd50e7 100644 --- a/packages/oneclient_content/src/bundles/updates.rs +++ b/packages/oneclient_content/src/bundles/updates.rs @@ -4,14 +4,13 @@ use std::sync::{Arc, Mutex, OnceLock}; use std::time::Instant; use oneclient_db::dao::artifact as artifact_dao; -use oneclient_db::dao::cluster as cluster_dao; use oneclient_db::dao::bundle as bundle_catalog_dao; +use oneclient_db::dao::cluster as cluster_dao; use oneclient_db::dao::cluster_bundle as bundle_dao; use oneclient_db::dao::cluster_optional_mod as optional_dao; use oneclient_db::models::ClusterPatch; use oneclient_db::models::{ - BundleTrackedArtifactRow, ClusterBundleOverrideRow, OptionalModStatus, OverrideType, - SeenStatus, + BundleTrackedArtifactRow, ClusterBundleOverrideRow, OptionalModStatus, OverrideType, SeenStatus, }; use tokio::sync::Mutex as AsyncMutex; @@ -29,11 +28,11 @@ use crate::bundles::types::{ BundlePackageAddition, BundlePackageRemoval, BundlePackageUpdate, BundleUpdateCheckResult, BundleWithUpdateStatus, FileUpdateStatus, external_bundle_key, managed_bundle_key, }; -use oneclient_common::domain::{GameLoader, ProviderId}; -use crate::packages::store::PackageStore; -use crate::packages::types::LinkedArtifactInfo; use crate::ctx::ContentCtx; use crate::error::{ContentError, ContentResult}; +use crate::packages::store::PackageStore; +use crate::packages::types::LinkedArtifactInfo; +use oneclient_common::domain::{GameLoader, ProviderId}; static CLUSTER_UPDATE_LOCKS: OnceLock>>>> = OnceLock::new(); @@ -421,11 +420,10 @@ pub async fn apply_bundle_updates( ctx: &ContentCtx, deadline: Option, ) -> ContentResult { - let session = oneclient_events::GroupedProgressSession::start( - &ctx.events, - "Updating bundle content", - ); - let result = apply_bundle_updates_with(cluster_id, bundles, ctx, Some(&session), deadline).await; + let session = + oneclient_events::GroupedProgressSession::start(&ctx.events, "Updating bundle content"); + let result = + apply_bundle_updates_with(cluster_id, bundles, ctx, Some(&session), deadline).await; session.finish(); result } @@ -493,7 +491,12 @@ pub async fn apply_bundle_updates_with( .updates_available .iter() .map(|u| u.new_file.size.max(1)) - .chain(check.additions_available.iter().map(|a| a.new_file.size.max(1))) + .chain( + check + .additions_available + .iter() + .map(|a| a.new_file.size.max(1)), + ) .sum(); s.expect(oneclient_events::TaskCategory::Packages, count, bytes); } @@ -608,10 +611,7 @@ pub async fn apply_bundle_updates_with( { let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; let loader = GameLoader::from_repr(cluster.mc_loader as u8).unwrap_or(GameLoader::Fabric); - if let Ok(archives) = bundles - .archives_for(ctx, &cluster.mc_version, loader) - .await - { + if let Ok(archives) = bundles.archives_for(ctx, &cluster.mc_version, loader).await { for archive in archives { if let Err(err) = overrides::sync_bundle_overrides( &archive.bundle.path, @@ -732,7 +732,8 @@ async fn reconcile_update( set_artifact_enabled_to(update.cluster_id, hash, enabled, ctx).await?; if hash != update.installed_hash { - artifact_dao::set_seen_status(&ctx.db, update.cluster_id, hash, SeenStatus::Updated).await?; + artifact_dao::set_seen_status(&ctx.db, update.cluster_id, hash, SeenStatus::Updated) + .await?; remove_artifact_from_cluster(update.cluster_id, &update.installed_hash, false, ctx).await?; } diff --git a/packages/oneclient_content/src/ctx.rs b/packages/oneclient_content/src/ctx.rs index f3bf88d4..1045ec72 100644 --- a/packages/oneclient_content/src/ctx.rs +++ b/packages/oneclient_content/src/ctx.rs @@ -9,25 +9,25 @@ use crate::packages::provider::PackageProviderRegistry; /// four subsystems #[derive(Clone)] pub struct ContentCtx { - pub db: DbPool, - pub net: RequestClient, - pub events: EventBus, - pub providers: PackageProviderRegistry, + pub db: DbPool, + pub net: RequestClient, + pub events: EventBus, + pub providers: PackageProviderRegistry, } impl ContentCtx { - #[must_use] - pub fn new( - db: DbPool, - net: RequestClient, - events: EventBus, - providers: PackageProviderRegistry, - ) -> Self { - Self { - db, - net, - events, - providers, - } - } + #[must_use] + pub fn new( + db: DbPool, + net: RequestClient, + events: EventBus, + providers: PackageProviderRegistry, + ) -> Self { + Self { + db, + net, + events, + providers, + } + } } diff --git a/packages/oneclient_content/src/error.rs b/packages/oneclient_content/src/error.rs index a696c2a7..481a41e1 100644 --- a/packages/oneclient_content/src/error.rs +++ b/packages/oneclient_content/src/error.rs @@ -5,62 +5,62 @@ pub type ContentResult = Result; #[derive(Debug, thiserror::Error)] pub enum ContentError { - #[error(transparent)] - Package(#[from] PackageError), + #[error(transparent)] + Package(#[from] PackageError), - #[error(transparent)] - Bundle(#[from] BundleError), + #[error(transparent)] + Bundle(#[from] BundleError), - #[error(transparent)] - Request(#[from] oneclient_net::RequestError), + #[error(transparent)] + Request(#[from] oneclient_net::RequestError), - #[error(transparent)] - Db(#[from] oneclient_db::DbError), + #[error(transparent)] + Db(#[from] oneclient_db::DbError), - #[error("Database execution failed: {0}")] - Sql(#[from] sqlx::Error), + #[error("Database execution failed: {0}")] + Sql(#[from] sqlx::Error), - #[error(transparent)] - Io(#[from] polyio::IOError), + #[error(transparent)] + Io(#[from] polyio::IOError), - #[error(transparent)] - StdIo(#[from] std::io::Error), + #[error(transparent)] + StdIo(#[from] std::io::Error), - #[error(transparent)] - Paths(#[from] oneclient_common::PathsError), + #[error(transparent)] + Paths(#[from] oneclient_common::PathsError), - #[error(transparent)] - Events(#[from] oneclient_events::EventError), + #[error(transparent)] + Events(#[from] oneclient_events::EventError), - #[error("Unable to parse JSON: {0}")] - Json(#[from] serde_json::Error), + #[error("Unable to parse JSON: {0}")] + Json(#[from] serde_json::Error), - #[error("Invalid URL: {0}")] - Url(#[from] url::ParseError), + #[error("Invalid URL: {0}")] + Url(#[from] url::ParseError), - #[error("invalid content data: {reason}")] - InvalidData { reason: String }, + #[error("invalid content data: {reason}")] + InvalidData { reason: String }, } impl ContentError { - #[must_use] - pub fn is_transient(&self) -> bool { - match self { - Self::Request(source) => source.is_transient(), - Self::Io(polyio::IOError::IOError(source)) - | Self::Io(polyio::IOError::PathIOError { source, .. }) - | Self::StdIo(source) => { - use std::io::ErrorKind; - matches!( - source.kind(), - ErrorKind::ConnectionRefused - | ErrorKind::ConnectionReset - | ErrorKind::ConnectionAborted - | ErrorKind::NotConnected - | ErrorKind::TimedOut - ) - } - _ => false, - } - } + #[must_use] + pub fn is_transient(&self) -> bool { + match self { + Self::Request(source) => source.is_transient(), + Self::Io(polyio::IOError::IOError(source)) + | Self::Io(polyio::IOError::PathIOError { source, .. }) + | Self::StdIo(source) => { + use std::io::ErrorKind; + matches!( + source.kind(), + ErrorKind::ConnectionRefused + | ErrorKind::ConnectionReset + | ErrorKind::ConnectionAborted + | ErrorKind::NotConnected + | ErrorKind::TimedOut + ) + } + _ => false, + } + } } diff --git a/packages/oneclient_content/src/packages/activity.rs b/packages/oneclient_content/src/packages/activity.rs index 24bcbbc4..1a7f2e41 100644 --- a/packages/oneclient_content/src/packages/activity.rs +++ b/packages/oneclient_content/src/packages/activity.rs @@ -12,8 +12,8 @@ use oneclient_db::dao::cluster_bundle as bundle_dao; use crate::ctx::ContentCtx; use crate::error::ContentResult; -use crate::packages::dependencies::base_game_version; use crate::packages::FileIdentity; +use crate::packages::dependencies::base_game_version; use crate::packages::store::{PackageStore, record_release}; use crate::packages::types::LinkedArtifactInfo; @@ -71,9 +71,7 @@ fn duplicates_to_disable(linked: &[LinkedArtifactInfo]) -> Vec { /// Local files and a bundle's external files have no project to group on so /// they are left out /// two of those are two packages not two copies -fn group_duplicates( - linked: &[LinkedArtifactInfo], -) -> Vec<((ProviderId, String), Vec)> { +fn group_duplicates(linked: &[LinkedArtifactInfo]) -> Vec<((ProviderId, String), Vec)> { let mut by_project: HashMap<(ProviderId, String), Vec> = HashMap::new(); for info in linked { @@ -130,7 +128,9 @@ pub async fn disable_foreign_game_versions( let candidates: Vec<&LinkedArtifactInfo> = linked .iter() .filter(|info| { - info.enabled && info.content_type == ContentType::Mod && !from_bundle.contains(&info.hash) + info.enabled + && info.content_type == ContentType::Mod + && !from_bundle.contains(&info.hash) }) .collect(); @@ -173,7 +173,12 @@ async fn refresh_stated_game_versions( let mut stale = Vec::new(); for info in candidates { - match migration_dao::is_applied(&ctx.db, &foreign_repair_id(cluster_id, mc_version, &info.hash)).await { + match migration_dao::is_applied( + &ctx.db, + &foreign_repair_id(cluster_id, mc_version, &info.hash), + ) + .await + { Ok(true) => continue, Ok(false) => {} Err(err) => { @@ -187,7 +192,9 @@ async fn refresh_stated_game_versions( stale.push(FileIdentity::from_sha1(&info.hash)); } Ok(_) => {} - Err(err) => tracing::debug!(hash = %info.hash, %err, "could not read the stated game versions"), + Err(err) => { + tracing::debug!(hash = %info.hash, %err, "could not read the stated game versions") + } } } @@ -316,7 +323,11 @@ mod tests { ("middle", false, Some("2026-03-01T00:00:00Z")), ])); - assert_eq!(picked.as_deref(), Some("new"), "being enabled does not win it"); + assert_eq!( + picked.as_deref(), + Some("new"), + "being enabled does not win it" + ); } #[test] @@ -333,7 +344,10 @@ mod tests { fn an_undated_group_still_picks_one() { let picked = newest(&copies(&[("a", false, None), ("b", false, None)])); - assert!(picked.is_some(), "a group with no dates must not go unresolved"); + assert!( + picked.is_some(), + "a group with no dates must not go unresolved" + ); } #[test] @@ -345,9 +359,18 @@ mod tests { ) }; - assert!(!foreign(&[]), "a jar no provider ever described is left alone"); - assert!(!foreign(&["[]"]), "an empty list is the provider saying nothing"); - assert!(!foreign(&["[\"1.21.11"]), "a list that will not parse says nothing"); + assert!( + !foreign(&[]), + "a jar no provider ever described is left alone" + ); + assert!( + !foreign(&["[]"]), + "an empty list is the provider saying nothing" + ); + assert!( + !foreign(&["[\"1.21.11"]), + "a list that will not parse says nothing" + ); assert!(!foreign(&["[\"26.1.2\"]"])); assert!( !foreign(&["[\"1.21.11\",\"26.1.2\"]"]), @@ -440,7 +463,12 @@ mod tests { fn the_user_disabling_the_newest_copy_is_left_standing() { let disable = duplicates_to_disable(&[ info(Some("sodium"), "chosen", true, Some("2026-01-01T00:00:00Z")), - info(Some("sodium"), "newest", false, Some("2026-06-01T00:00:00Z")), + info( + Some("sodium"), + "newest", + false, + Some("2026-06-01T00:00:00Z"), + ), ]); assert!( diff --git a/packages/oneclient_content/src/packages/dependencies.rs b/packages/oneclient_content/src/packages/dependencies.rs index 1a9af701..24ad2716 100644 --- a/packages/oneclient_content/src/packages/dependencies.rs +++ b/packages/oneclient_content/src/packages/dependencies.rs @@ -8,7 +8,7 @@ use crate::error::ContentResult; use crate::packages::provider::PackageProvider; use crate::packages::store::PackageStore; use crate::packages::types::{ - DependencyKind, ProjectDetail, ReleaseType, VersionDependency, VersionDetail, VersionSummary, + DependencyKind, ProjectDetail, ReleaseType, VersionDependency, VersionDetail, VersionSummary, }; /// Caps the transitive walk so a provider serving a cycle can't spin forever @@ -18,357 +18,419 @@ const VERSION_WINDOW: usize = 50; #[derive(Debug, Clone)] pub struct ResolvedDependency { - pub project: ProjectDetail, - pub version: VersionDetail, + pub project: ProjectDetail, + pub version: VersionDetail, } #[derive(Debug, Clone, Default)] pub struct DependencyResolution { - /// Ordered breadth-first from the root - /// already-present ones are filtered out - pub install: Vec, - pub unresolved: Vec, + /// Ordered breadth-first from the root + /// already-present ones are filtered out + pub install: Vec, + pub unresolved: Vec, } impl DependencyResolution { - pub fn is_empty(&self) -> bool { - self.install.is_empty() && self.unresolved.is_empty() - } + pub fn is_empty(&self) -> bool { + self.install.is_empty() && self.unresolved.is_empty() + } } #[tracing::instrument(level = "debug", skip(root, ctx), fields(project_id = %root.project_id, version_id = %root.version_id))] pub async fn resolve_required( - provider_id: ProviderId, - root: &VersionDetail, - cluster_id: i64, - ctx: &ContentCtx, + provider_id: ProviderId, + root: &VersionDetail, + cluster_id: i64, + ctx: &ContentCtx, ) -> ContentResult { - let mut resolution = DependencyResolution::default(); - if !root.dependencies.iter().any(is_required) { - return Ok(resolution); - } + let mut resolution = DependencyResolution::default(); + if !root.dependencies.iter().any(is_required) { + return Ok(resolution); + } - let provider = ctx.providers.get(provider_id)?; - let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; + let provider = ctx.providers.get(provider_id)?; + let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; - // Seeded with installed projects and the root against refetches and self-referencing graphs - let mut seen = installed_project_ids(provider_id, cluster_id, ctx).await?; - seen.insert(root.project_id.clone()); + // Seeded with installed projects and the root against refetches and self-referencing graphs + let mut seen = installed_project_ids(provider_id, cluster_id, ctx).await?; + seen.insert(root.project_id.clone()); - let mut queue: VecDeque<(VersionDependency, usize)> = root - .dependencies - .iter() - .filter(|dep| is_required(dep)) - .map(|dep| (dep.clone(), 1)) - .collect(); + let mut queue: VecDeque<(VersionDependency, usize)> = root + .dependencies + .iter() + .filter(|dep| is_required(dep)) + .map(|dep| (dep.clone(), 1)) + .collect(); - while let Some((dep, depth)) = queue.pop_front() { - if let Some(project_id) = &dep.project_id - && seen.contains(project_id) - { - continue; - } + while let Some((dep, depth)) = queue.pop_front() { + if let Some(project_id) = &dep.project_id + && seen.contains(project_id) + { + continue; + } - let resolved = match resolve_one(provider, &dep, &cluster, ctx).await { - Ok(Some(resolved)) => resolved, - Ok(None) => { - tracing::warn!(?dep, "no compatible version for dependency"); - resolution.unresolved.push(dependency_label(&dep)); - continue; - } - Err(err) => { - tracing::warn!(?dep, %err, "failed to resolve dependency"); - resolution.unresolved.push(dependency_label(&dep)); - continue; - } - }; + let resolved = match resolve_one(provider, &dep, &cluster, ctx).await { + Ok(Some(resolved)) => resolved, + Ok(None) => { + tracing::warn!(?dep, "no compatible version for dependency"); + resolution.unresolved.push(dependency_label(&dep)); + continue; + } + Err(err) => { + tracing::warn!(?dep, %err, "failed to resolve dependency"); + resolution.unresolved.push(dependency_label(&dep)); + continue; + } + }; - // A version-pinned dependency only reveals its project once fetched so dedupe again here - if !seen.insert(resolved.version.project_id.clone()) { - continue; - } + // A version-pinned dependency only reveals its project once fetched so dedupe again here + if !seen.insert(resolved.version.project_id.clone()) { + continue; + } - if depth < MAX_DEPTH { - for next in resolved.version.dependencies.iter().filter(|d| is_required(d)) { - queue.push_back((next.clone(), depth + 1)); - } - } + if depth < MAX_DEPTH { + for next in resolved + .version + .dependencies + .iter() + .filter(|d| is_required(d)) + { + queue.push_back((next.clone(), depth + 1)); + } + } - resolution.install.push(resolved); - } + resolution.install.push(resolved); + } - Ok(resolution) + Ok(resolution) } fn is_required(dep: &VersionDependency) -> bool { - dep.kind == DependencyKind::Required + dep.kind == DependencyKind::Required } fn dependency_label(dep: &VersionDependency) -> String { - dep.project_id - .clone() - .or_else(|| dep.version_id.clone()) - .unwrap_or_else(|| "unknown".to_string()) + dep.project_id + .clone() + .or_else(|| dep.version_id.clone()) + .unwrap_or_else(|| "unknown".to_string()) } async fn resolve_one( - provider: &dyn PackageProvider, - dep: &VersionDependency, - cluster: &ClusterRow, - ctx: &ContentCtx, + provider: &dyn PackageProvider, + dep: &VersionDependency, + cluster: &ClusterRow, + ctx: &ContentCtx, ) -> ContentResult> { - // A pinned version wins even when newer exists - // providers ignore the project id once the version id is known - let version = match &dep.version_id { - Some(version_id) => { - provider - .get_version( - dep.project_id.as_deref().unwrap_or_default(), - version_id, - ctx, - ) - .await? - } - None => { - let Some(project_id) = dep.project_id.as_deref() else { - return Ok(None); - }; - let Some(pick) = pick_version(provider, project_id, cluster, ctx).await? else { - return Ok(None); - }; - provider.get_version(project_id, &pick.version_id, ctx).await? - } - }; + // A pinned version wins even when newer exists + // providers ignore the project id once the version id is known + let version = match &dep.version_id { + Some(version_id) => { + provider + .get_version( + dep.project_id.as_deref().unwrap_or_default(), + version_id, + ctx, + ) + .await? + } + None => { + let Some(project_id) = dep.project_id.as_deref() else { + return Ok(None); + }; + let Some(pick) = pick_version(provider, project_id, cluster, ctx).await? else { + return Ok(None); + }; + provider + .get_version(project_id, &pick.version_id, ctx) + .await? + } + }; - let project = provider.get_project(&version.project_id, ctx).await?; - Ok(Some(ResolvedDependency { project, version })) + let project = provider.get_project(&version.project_id, ctx).await?; + Ok(Some(ResolvedDependency { project, version })) } /// Falls back to the newest prerelease when no release fits the cluster pub(crate) async fn pick_version( - provider: &dyn PackageProvider, - project_id: &str, - cluster: &ClusterRow, - ctx: &ContentCtx, + provider: &dyn PackageProvider, + project_id: &str, + cluster: &ClusterRow, + ctx: &ContentCtx, ) -> ContentResult> { - let loader = GameLoader::from_repr(cluster.mc_loader as u8).unwrap_or(GameLoader::Vanilla); - let loader_filter = loader.is_modded().then_some(loader); + let loader = GameLoader::from_repr(cluster.mc_loader as u8).unwrap_or(GameLoader::Vanilla); + let loader_filter = loader.is_modded().then_some(loader); - let mut candidates = provider - .list_versions( - project_id, - Some(&cluster.mc_version), - loader_filter, - 0, - VERSION_WINDOW, - ctx, - ) - .await? - .items; + let mut candidates = provider + .list_versions( + project_id, + Some(&cluster.mc_version), + loader_filter, + 0, + VERSION_WINDOW, + ctx, + ) + .await? + .items; - // Resource packs shaders and datapacks declare no loader so a loader-filtered query is empty - if candidates.is_empty() && loader_filter.is_some() { - candidates = provider - .list_versions(project_id, Some(&cluster.mc_version), None, 0, VERSION_WINDOW, ctx) - .await? - .items; - } + // Resource packs shaders and datapacks declare no loader so a loader-filtered query is empty + if candidates.is_empty() && loader_filter.is_some() { + candidates = provider + .list_versions( + project_id, + Some(&cluster.mc_version), + None, + 0, + VERSION_WINDOW, + ctx, + ) + .await? + .items; + } - Ok(choose_version(candidates, cluster, loader)) + Ok(choose_version(candidates, cluster, loader)) } fn choose_version( - mut candidates: Vec, - cluster: &ClusterRow, - loader: GameLoader, + mut candidates: Vec, + cluster: &ClusterRow, + loader: GameLoader, ) -> Option { - candidates.retain(|version| fits_cluster(version, cluster, loader)); - // Neither provider promises an order so sort rather than trust the page - candidates.sort_by_key(|version| std::cmp::Reverse(version.published)); + candidates.retain(|version| fits_cluster(version, cluster, loader)); + // Neither provider promises an order so sort rather than trust the page + candidates.sort_by_key(|version| std::cmp::Reverse(version.published)); - candidates - .iter() - .find(|version| matches!(version.release_type, ReleaseType::Release)) - .or_else(|| candidates.first()) - .cloned() + candidates + .iter() + .find(|version| matches!(version.release_type, ReleaseType::Release)) + .or_else(|| candidates.first()) + .cloned() } #[must_use] pub(crate) fn base_game_version(version: &str) -> &str { - version - .split_once('-') - .filter(|(base, _)| base.contains('.')) - .map_or(version, |(base, _)| base) + version + .split_once('-') + .filter(|(base, _)| base.contains('.')) + .map_or(version, |(base, _)| base) } #[must_use] pub(crate) fn supports_game_version(game_versions: &[String], mc_version: &str) -> bool { - let wanted = base_game_version(mc_version); - game_versions.is_empty() || game_versions.iter().any(|v| base_game_version(v) == wanted) + let wanted = base_game_version(mc_version); + game_versions.is_empty() || game_versions.iter().any(|v| base_game_version(v) == wanted) } fn fits_cluster(version: &VersionSummary, cluster: &ClusterRow, loader: GameLoader) -> bool { - if !supports_game_version(&version.game_versions, &cluster.mc_version) { - return false; - } + if !supports_game_version(&version.game_versions, &cluster.mc_version) { + return false; + } - version.loaders.is_empty() || version.loaders.iter().any(|l| loader.compatible_with(*l)) + version.loaders.is_empty() || version.loaders.iter().any(|l| loader.compatible_with(*l)) } async fn installed_project_ids( - provider_id: ProviderId, - cluster_id: i64, - ctx: &ContentCtx, + provider_id: ProviderId, + cluster_id: i64, + ctx: &ContentCtx, ) -> ContentResult> { - Ok(PackageStore::list_linked_artifacts(cluster_id, ctx) - .await? - .into_iter() - .filter(|linked| linked.provider == Some(provider_id)) - .filter_map(|linked| linked.project_id) - .collect()) + Ok(PackageStore::list_linked_artifacts(cluster_id, ctx) + .await? + .into_iter() + .filter(|linked| linked.provider == Some(provider_id)) + .filter_map(|linked| linked.project_id) + .collect()) } /// False for modpacks they bring their own file list pub fn resolves_dependencies(content_type: ContentType) -> bool { - !matches!(content_type, ContentType::Modpack) + !matches!(content_type, ContentType::Modpack) } #[cfg(test)] mod tests { - use chrono::{TimeZone, Utc}; + use chrono::{TimeZone, Utc}; - use super::*; + use super::*; - #[test] - fn a_version_is_offered_only_for_the_game_version_it_names() { - let offered = |stated: &[&str], mc_version: &str| { - supports_game_version( - &stated.iter().map(|v| (*v).to_string()).collect::>(), - mc_version, - ) - }; + #[test] + fn a_version_is_offered_only_for_the_game_version_it_names() { + let offered = |stated: &[&str], mc_version: &str| { + supports_game_version( + &stated.iter().map(|v| (*v).to_string()).collect::>(), + mc_version, + ) + }; - assert!(offered(&[], "26.3"), "an undescribed version is offered"); - assert!(offered(&["26.3"], "26.3")); - assert!( - offered(&["26.3-rc-1"], "26.3"), - "a pre-release tag names the same game version" - ); - assert!(offered(&["26.3"], "26.3-snapshot-10")); - assert!( - !offered(&["26.1"], "26.1.2"), - "strict where the launch-time check is lenient: #830 stopped 26.1 \ + assert!(offered(&[], "26.3"), "an undescribed version is offered"); + assert!(offered(&["26.3"], "26.3")); + assert!( + offered(&["26.3-rc-1"], "26.3"), + "a pre-release tag names the same game version" + ); + assert!(offered(&["26.3"], "26.3-snapshot-10")); + assert!( + !offered(&["26.1"], "26.1.2"), + "strict where the launch-time check is lenient: #830 stopped 26.1 \ builds being offered to a 26.1.2 cluster" - ); - assert!(!offered(&["1.21.11"], "26.1.2")); - assert!( - !offered(&["rd-132211"], "rd-160052"), - "pre-classic names the build after the dash, so there is no tag to strip" - ); - } + ); + assert!(!offered(&["1.21.11"], "26.1.2")); + assert!( + !offered(&["rd-132211"], "rd-160052"), + "pre-classic names the build after the dash, so there is no tag to strip" + ); + } - fn summary( - version_id: &str, - release_type: ReleaseType, - day: u32, - loaders: Vec, - game_versions: Vec<&str>, - ) -> VersionSummary { - VersionSummary { - version_id: version_id.into(), - project_id: "p".into(), - name: version_id.into(), - version_number: version_id.into(), - published: Utc.with_ymd_and_hms(2025, 1, day, 0, 0, 0).unwrap(), - release_type, - game_versions: game_versions.into_iter().map(Into::into).collect(), - loaders, - downloads: 0, - file_size: 0, - } - } + fn summary( + version_id: &str, + release_type: ReleaseType, + day: u32, + loaders: Vec, + game_versions: Vec<&str>, + ) -> VersionSummary { + VersionSummary { + version_id: version_id.into(), + project_id: "p".into(), + name: version_id.into(), + version_number: version_id.into(), + published: Utc.with_ymd_and_hms(2025, 1, day, 0, 0, 0).unwrap(), + release_type, + game_versions: game_versions.into_iter().map(Into::into).collect(), + loaders, + downloads: 0, + file_size: 0, + } + } - fn cluster(loader: GameLoader, mc_version: &str) -> ClusterRow { - ClusterRow { - id: 1, - name: "c".into(), - folder_name: "c".into(), - setting_profile_name: None, - mc_version: mc_version.into(), - mc_loader: loader as i64, - stage: 0, - mc_loader_version: None, - created_at: None, - last_played: None, - overall_played: None, - linked_modpack_hash: None, - } - } + fn cluster(loader: GameLoader, mc_version: &str) -> ClusterRow { + ClusterRow { + id: 1, + name: "c".into(), + folder_name: "c".into(), + setting_profile_name: None, + mc_version: mc_version.into(), + mc_loader: loader as i64, + stage: 0, + mc_loader_version: None, + created_at: None, + last_played: None, + overall_played: None, + linked_modpack_hash: None, + } + } - #[test] - fn prefers_the_newest_release_over_a_newer_prerelease() { - let cluster = cluster(GameLoader::Fabric, "1.21.4"); - let candidates = vec![ - summary("old", ReleaseType::Release, 1, vec![GameLoader::Fabric], vec!["1.21.4"]), - summary("beta", ReleaseType::Beta, 3, vec![GameLoader::Fabric], vec!["1.21.4"]), - summary("new", ReleaseType::Release, 2, vec![GameLoader::Fabric], vec!["1.21.4"]), - ]; + #[test] + fn prefers_the_newest_release_over_a_newer_prerelease() { + let cluster = cluster(GameLoader::Fabric, "1.21.4"); + let candidates = vec![ + summary( + "old", + ReleaseType::Release, + 1, + vec![GameLoader::Fabric], + vec!["1.21.4"], + ), + summary( + "beta", + ReleaseType::Beta, + 3, + vec![GameLoader::Fabric], + vec!["1.21.4"], + ), + summary( + "new", + ReleaseType::Release, + 2, + vec![GameLoader::Fabric], + vec!["1.21.4"], + ), + ]; - let pick = choose_version(candidates, &cluster, GameLoader::Fabric); - assert_eq!(pick.unwrap().version_id, "new"); - } + let pick = choose_version(candidates, &cluster, GameLoader::Fabric); + assert_eq!(pick.unwrap().version_id, "new"); + } - #[test] - fn falls_back_to_a_prerelease_when_no_release_fits() { - let cluster = cluster(GameLoader::Fabric, "1.21.4"); - let candidates = vec![ - summary("alpha", ReleaseType::Alpha, 1, vec![GameLoader::Fabric], vec!["1.21.4"]), - summary("beta", ReleaseType::Beta, 2, vec![GameLoader::Fabric], vec!["1.21.4"]), - ]; + #[test] + fn falls_back_to_a_prerelease_when_no_release_fits() { + let cluster = cluster(GameLoader::Fabric, "1.21.4"); + let candidates = vec![ + summary( + "alpha", + ReleaseType::Alpha, + 1, + vec![GameLoader::Fabric], + vec!["1.21.4"], + ), + summary( + "beta", + ReleaseType::Beta, + 2, + vec![GameLoader::Fabric], + vec!["1.21.4"], + ), + ]; - let pick = choose_version(candidates, &cluster, GameLoader::Fabric); - assert_eq!(pick.unwrap().version_id, "beta"); - } + let pick = choose_version(candidates, &cluster, GameLoader::Fabric); + assert_eq!(pick.unwrap().version_id, "beta"); + } - #[test] - fn drops_versions_the_store_would_reject() { - let cluster = cluster(GameLoader::Fabric, "1.21.4"); - let candidates = vec![ - summary("forge", ReleaseType::Release, 3, vec![GameLoader::Forge], vec!["1.21.4"]), - summary("old-mc", ReleaseType::Release, 2, vec![GameLoader::Fabric], vec!["1.20.1"]), - summary("fits", ReleaseType::Release, 1, vec![GameLoader::Fabric], vec!["1.21.4"]), - ]; + #[test] + fn drops_versions_the_store_would_reject() { + let cluster = cluster(GameLoader::Fabric, "1.21.4"); + let candidates = vec![ + summary( + "forge", + ReleaseType::Release, + 3, + vec![GameLoader::Forge], + vec!["1.21.4"], + ), + summary( + "old-mc", + ReleaseType::Release, + 2, + vec![GameLoader::Fabric], + vec!["1.20.1"], + ), + summary( + "fits", + ReleaseType::Release, + 1, + vec![GameLoader::Fabric], + vec!["1.21.4"], + ), + ]; - let pick = choose_version(candidates, &cluster, GameLoader::Fabric); - assert_eq!(pick.unwrap().version_id, "fits"); - } + let pick = choose_version(candidates, &cluster, GameLoader::Fabric); + assert_eq!(pick.unwrap().version_id, "fits"); + } - #[test] - fn keeps_loaderless_content_for_a_modded_cluster() { - let cluster = cluster(GameLoader::Fabric, "1.21.4"); - let candidates = vec![summary( - "pack", - ReleaseType::Release, - 1, - Vec::new(), - vec!["1.21.4"], - )]; + #[test] + fn keeps_loaderless_content_for_a_modded_cluster() { + let cluster = cluster(GameLoader::Fabric, "1.21.4"); + let candidates = vec![summary( + "pack", + ReleaseType::Release, + 1, + Vec::new(), + vec!["1.21.4"], + )]; - let pick = choose_version(candidates, &cluster, GameLoader::Fabric); - assert_eq!(pick.unwrap().version_id, "pack"); - } + let pick = choose_version(candidates, &cluster, GameLoader::Fabric); + assert_eq!(pick.unwrap().version_id, "pack"); + } - #[test] - fn no_compatible_version_resolves_to_nothing() { - let cluster = cluster(GameLoader::Fabric, "1.21.4"); - let candidates = vec![summary( - "forge", - ReleaseType::Release, - 1, - vec![GameLoader::Forge], - vec!["1.21.4"], - )]; + #[test] + fn no_compatible_version_resolves_to_nothing() { + let cluster = cluster(GameLoader::Fabric, "1.21.4"); + let candidates = vec![summary( + "forge", + ReleaseType::Release, + 1, + vec![GameLoader::Forge], + vec!["1.21.4"], + )]; - assert!(choose_version(candidates, &cluster, GameLoader::Fabric).is_none()); - } + assert!(choose_version(candidates, &cluster, GameLoader::Fabric).is_none()); + } } diff --git a/packages/oneclient_content/src/packages/error.rs b/packages/oneclient_content/src/packages/error.rs index b1120825..cbbabd27 100644 --- a/packages/oneclient_content/src/packages/error.rs +++ b/packages/oneclient_content/src/packages/error.rs @@ -1,38 +1,38 @@ use thiserror::Error; -use oneclient_common::domain::ProviderId; use super::types::PackageBody; +use oneclient_common::domain::ProviderId; #[derive(Debug, Error)] pub enum PackageError { - #[error("no primary download file on version")] - NoPrimaryFile, - #[error("package is not a modpack")] - NotModpack, - #[error("unsupported modpack format")] - UnsupportedModpackFormat, - #[error("modpack install had {failed} of {total} file failures")] - PartialModpackInstall { failed: u64, total: u64 }, - #[error("missing API key for provider {0:?}")] - MissingApiKey(ProviderId), - #[error("unsupported package body type")] - UnsupportedBody(PackageBody), - #[error("package is not compatible with cluster Minecraft version")] - IncompatibleMcVersion, - #[error("package is not compatible with cluster loader")] - IncompatibleLoader, - #[error("cluster {0} not found")] - ClusterNotFound(i64), - #[error("artifact file missing at {0}")] - ArtifactMissing(String), - #[error("hash mismatch: expected {expected}, got {actual}")] - HashMismatch { expected: String, actual: String }, - #[error("provider {0:?} does not support this operation")] - UnsupportedProvider(ProviderId), - #[error("package provider {0:?} is not registered")] - ProviderNotRegistered(ProviderId), - #[error("invalid local file: {0}")] - InvalidLocalFile(String), + #[error("no primary download file on version")] + NoPrimaryFile, + #[error("package is not a modpack")] + NotModpack, + #[error("unsupported modpack format")] + UnsupportedModpackFormat, + #[error("modpack install had {failed} of {total} file failures")] + PartialModpackInstall { failed: u64, total: u64 }, + #[error("missing API key for provider {0:?}")] + MissingApiKey(ProviderId), + #[error("unsupported package body type")] + UnsupportedBody(PackageBody), + #[error("package is not compatible with cluster Minecraft version")] + IncompatibleMcVersion, + #[error("package is not compatible with cluster loader")] + IncompatibleLoader, + #[error("cluster {0} not found")] + ClusterNotFound(i64), + #[error("artifact file missing at {0}")] + ArtifactMissing(String), + #[error("hash mismatch: expected {expected}, got {actual}")] + HashMismatch { expected: String, actual: String }, + #[error("provider {0:?} does not support this operation")] + UnsupportedProvider(ProviderId), + #[error("package provider {0:?} is not registered")] + ProviderNotRegistered(ProviderId), + #[error("invalid local file: {0}")] + InvalidLocalFile(String), } pub type PackageResult = Result; diff --git a/packages/oneclient_content/src/packages/file_identity.rs b/packages/oneclient_content/src/packages/file_identity.rs index a6002012..cdf50cec 100644 --- a/packages/oneclient_content/src/packages/file_identity.rs +++ b/packages/oneclient_content/src/packages/file_identity.rs @@ -1,6 +1,5 @@ use std::path::Path; - use crate::error::ContentResult; use polyio::{normalize_hash, sha1_bytes}; diff --git a/packages/oneclient_content/src/packages/local_manifest.rs b/packages/oneclient_content/src/packages/local_manifest.rs index 619b72a2..d4046a9b 100644 --- a/packages/oneclient_content/src/packages/local_manifest.rs +++ b/packages/oneclient_content/src/packages/local_manifest.rs @@ -16,333 +16,336 @@ const MANIFESTS: [&str; 5] = [FABRIC, QUILT, NEOFORGE, FORGE, LEGACY_FORGE]; #[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct JarManifest { - pub name: Option, - pub description: Option, - pub authors: Vec, - pub icon_entry: Option, + pub name: Option, + pub description: Option, + pub authors: Vec, + pub icon_entry: Option, } impl JarManifest { - #[must_use] - pub fn is_empty(&self) -> bool { - self.name.is_none() - && self.description.is_none() - && self.authors.is_empty() - && self.icon_entry.is_none() - } + #[must_use] + pub fn is_empty(&self) -> bool { + self.name.is_none() + && self.description.is_none() + && self.authors.is_empty() + && self.icon_entry.is_none() + } - #[must_use] - pub fn author_line(&self) -> String { - self.authors.join(", ") - } + #[must_use] + pub fn author_line(&self) -> String { + self.authors.join(", ") + } - fn fill_from(&mut self, other: Self) { - if self.name.is_none() { - self.name = other.name; - } - if self.description.is_none() { - self.description = other.description; - } - if self.authors.is_empty() { - self.authors = other.authors; - } - if self.icon_entry.is_none() { - self.icon_entry = other.icon_entry; - } - } + fn fill_from(&mut self, other: Self) { + if self.name.is_none() { + self.name = other.name; + } + if self.description.is_none() { + self.description = other.description; + } + if self.authors.is_empty() { + self.authors = other.authors; + } + if self.icon_entry.is_none() { + self.icon_entry = other.icon_entry; + } + } } #[tracing::instrument(level = "debug", fields(jar = %jar.display()))] pub async fn read_jar_manifest(jar: &Path) -> JarManifest { - let entries = match polyio::read_zip_file_entries(jar, |name| MANIFESTS.contains(&name)).await { - Ok(entries) => entries, - Err(err) => { - tracing::debug!("could not read {}: {err}", jar.display()); - return JarManifest::default(); - } - }; + let entries = match polyio::read_zip_file_entries(jar, |name| MANIFESTS.contains(&name)).await { + Ok(entries) => entries, + Err(err) => { + tracing::debug!("could not read {}: {err}", jar.display()); + return JarManifest::default(); + } + }; - let mut out = JarManifest::default(); - for wanted in MANIFESTS { - let Some((_, bytes)) = entries.iter().find(|(name, _)| name == wanted) else { - continue; - }; - let Ok(text) = std::str::from_utf8(bytes) else { - continue; - }; + let mut out = JarManifest::default(); + for wanted in MANIFESTS { + let Some((_, bytes)) = entries.iter().find(|(name, _)| name == wanted) else { + continue; + }; + let Ok(text) = std::str::from_utf8(bytes) else { + continue; + }; - let parsed = match wanted { - FABRIC => parse_fabric(text), - QUILT => parse_quilt(text), - NEOFORGE | FORGE => parse_forge(text), - _ => parse_legacy_forge(text), - }; + let parsed = match wanted { + FABRIC => parse_fabric(text), + QUILT => parse_quilt(text), + NEOFORGE | FORGE => parse_forge(text), + _ => parse_legacy_forge(text), + }; - match parsed { - Some(parsed) => out.fill_from(parsed), - None => tracing::debug!("{wanted} in {} did not parse", jar.display()), - } - } + match parsed { + Some(parsed) => out.fill_from(parsed), + None => tracing::debug!("{wanted} in {} did not parse", jar.display()), + } + } - out + out } #[tracing::instrument(level = "debug", fields(jar = %jar.display()))] pub async fn read_jar_icon(jar: &Path, entry: &str) -> Option> { - let entry = entry.to_string(); - let found = polyio::read_zip_file_entries(jar, |name| name == entry) - .await - .inspect_err(|err| tracing::debug!("no icon in {}: {err}", jar.display())) - .ok()?; + let entry = entry.to_string(); + let found = polyio::read_zip_file_entries(jar, |name| name == entry) + .await + .inspect_err(|err| tracing::debug!("no icon in {}: {err}", jar.display())) + .ok()?; - found.into_iter().next().map(|(_, bytes)| bytes) + found.into_iter().next().map(|(_, bytes)| bytes) } #[must_use] pub fn parse_fabric(text: &str) -> Option { - #[derive(Deserialize)] - struct FabricMod { - name: Option, - description: Option, - #[serde(default)] - authors: Vec, - icon: Option, - } + #[derive(Deserialize)] + struct FabricMod { + name: Option, + description: Option, + #[serde(default)] + authors: Vec, + icon: Option, + } - let parsed: FabricMod = serde_json::from_str(text).ok()?; + let parsed: FabricMod = serde_json::from_str(text).ok()?; - Some(JarManifest { - name: clean(parsed.name), - description: clean_description(parsed.description), - authors: people(parsed.authors), - icon_entry: parsed.icon.and_then(Icon::path).and_then(entry_path), - }) + Some(JarManifest { + name: clean(parsed.name), + description: clean_description(parsed.description), + authors: people(parsed.authors), + icon_entry: parsed.icon.and_then(Icon::path).and_then(entry_path), + }) } #[must_use] pub fn parse_quilt(text: &str) -> Option { - #[derive(Deserialize)] - struct QuiltMod { - quilt_loader: QuiltLoader, - } + #[derive(Deserialize)] + struct QuiltMod { + quilt_loader: QuiltLoader, + } - #[derive(Deserialize)] - struct QuiltLoader { - metadata: Option, - } + #[derive(Deserialize)] + struct QuiltLoader { + metadata: Option, + } - #[derive(Deserialize)] - struct QuiltMetadata { - name: Option, - description: Option, - #[serde(default)] - contributors: BTreeMap, - icon: Option, - } + #[derive(Deserialize)] + struct QuiltMetadata { + name: Option, + description: Option, + #[serde(default)] + contributors: BTreeMap, + icon: Option, + } - let parsed: QuiltMod = serde_json::from_str(text).ok()?; - let metadata = parsed.quilt_loader.metadata?; + let parsed: QuiltMod = serde_json::from_str(text).ok()?; + let metadata = parsed.quilt_loader.metadata?; - Some(JarManifest { - name: clean(metadata.name), - description: clean_description(metadata.description), - authors: metadata - .contributors - .into_keys() - .filter_map(|name| clean(Some(name))) - .collect(), - icon_entry: metadata.icon.and_then(Icon::path).and_then(entry_path), - }) + Some(JarManifest { + name: clean(metadata.name), + description: clean_description(metadata.description), + authors: metadata + .contributors + .into_keys() + .filter_map(|name| clean(Some(name))) + .collect(), + icon_entry: metadata.icon.and_then(Icon::path).and_then(entry_path), + }) } #[must_use] pub fn parse_forge(text: &str) -> Option { - #[derive(Deserialize)] - struct ForgeManifest { - #[serde(default)] - mods: Vec, - /// Some older jars put it above the mod list rather than inside it - #[serde(rename = "logoFile")] - logo_file: Option, - } + #[derive(Deserialize)] + struct ForgeManifest { + #[serde(default)] + mods: Vec, + /// Some older jars put it above the mod list rather than inside it + #[serde(rename = "logoFile")] + logo_file: Option, + } - #[derive(Deserialize)] - struct ForgeMod { - #[serde(rename = "displayName")] - display_name: Option, - description: Option, - authors: Option, - credits: Option, - #[serde(rename = "logoFile")] - logo_file: Option, - } + #[derive(Deserialize)] + struct ForgeMod { + #[serde(rename = "displayName")] + display_name: Option, + description: Option, + authors: Option, + credits: Option, + #[serde(rename = "logoFile")] + logo_file: Option, + } - let parsed: ForgeManifest = toml::from_str(text).ok()?; - let first = parsed.mods.into_iter().next()?; + let parsed: ForgeManifest = toml::from_str(text).ok()?; + let first = parsed.mods.into_iter().next()?; - Some(JarManifest { - name: clean(first.display_name), - description: clean_description(first.description), - authors: first - .authors - .or(first.credits) - .map(StringOrList::into_people) - .unwrap_or_default(), - icon_entry: first.logo_file.or(parsed.logo_file).and_then(entry_path), - }) + Some(JarManifest { + name: clean(first.display_name), + description: clean_description(first.description), + authors: first + .authors + .or(first.credits) + .map(StringOrList::into_people) + .unwrap_or_default(), + icon_entry: first.logo_file.or(parsed.logo_file).and_then(entry_path), + }) } #[must_use] pub fn parse_legacy_forge(text: &str) -> Option { - #[derive(Deserialize)] - #[serde(untagged)] - enum McModInfo { - Bare(Vec), - Wrapped { - #[serde(rename = "modList")] - mod_list: Vec, - }, - } + #[derive(Deserialize)] + #[serde(untagged)] + enum McModInfo { + Bare(Vec), + Wrapped { + #[serde(rename = "modList")] + mod_list: Vec, + }, + } - #[derive(Deserialize)] - struct LegacyMod { - name: Option, - description: Option, - #[serde(rename = "authorList", default)] - author_list: Vec, - credits: Option, - #[serde(rename = "logoFile")] - logo_file: Option, - } + #[derive(Deserialize)] + struct LegacyMod { + name: Option, + description: Option, + #[serde(rename = "authorList", default)] + author_list: Vec, + credits: Option, + #[serde(rename = "logoFile")] + logo_file: Option, + } - let mods = match serde_json::from_str::(text).ok()? { - McModInfo::Bare(mods) | McModInfo::Wrapped { mod_list: mods } => mods, - }; - let first = mods.into_iter().next()?; + let mods = match serde_json::from_str::(text).ok()? { + McModInfo::Bare(mods) | McModInfo::Wrapped { mod_list: mods } => mods, + }; + let first = mods.into_iter().next()?; - let authors: Vec = first - .author_list - .into_iter() - .filter_map(|name| clean(Some(name))) - .collect(); + let authors: Vec = first + .author_list + .into_iter() + .filter_map(|name| clean(Some(name))) + .collect(); - Some(JarManifest { - name: clean(first.name), - description: clean_description(first.description), - authors: if authors.is_empty() { - clean(first.credits).into_iter().collect() - } else { - authors - }, - icon_entry: first.logo_file.and_then(entry_path), - }) + Some(JarManifest { + name: clean(first.name), + description: clean_description(first.description), + authors: if authors.is_empty() { + clean(first.credits).into_iter().collect() + } else { + authors + }, + icon_entry: first.logo_file.and_then(entry_path), + }) } #[derive(Deserialize)] #[serde(untagged)] enum Icon { - Path(String), - Sizes(BTreeMap), + Path(String), + Sizes(BTreeMap), } impl Icon { - fn path(self) -> Option { - match self { - Self::Path(path) => Some(path), - Self::Sizes(sizes) => sizes - .into_iter() - .max_by_key(|(size, _)| size.parse::().unwrap_or(0)) - .map(|(_, path)| path), - } - } + fn path(self) -> Option { + match self { + Self::Path(path) => Some(path), + Self::Sizes(sizes) => sizes + .into_iter() + .max_by_key(|(size, _)| size.parse::().unwrap_or(0)) + .map(|(_, path)| path), + } + } } fn people(list: Vec) -> Vec { - list.into_iter() - .filter_map(|person| match person { - serde_json::Value::String(name) => clean(Some(name)), - serde_json::Value::Object(mut fields) => match fields.remove("name") { - Some(serde_json::Value::String(name)) => clean(Some(name)), - _ => None, - }, - _ => None, - }) - .collect() + list.into_iter() + .filter_map(|person| match person { + serde_json::Value::String(name) => clean(Some(name)), + serde_json::Value::Object(mut fields) => match fields.remove("name") { + Some(serde_json::Value::String(name)) => clean(Some(name)), + _ => None, + }, + _ => None, + }) + .collect() } #[derive(Deserialize)] #[serde(untagged)] enum StringOrList { - One(String), - Many(Vec), + One(String), + Many(Vec), } impl StringOrList { - fn into_people(self) -> Vec { - match self { - Self::One(text) => clean(Some(text)).into_iter().collect(), - Self::Many(list) => list.into_iter().filter_map(|name| clean(Some(name))).collect(), - } - } + fn into_people(self) -> Vec { + match self { + Self::One(text) => clean(Some(text)).into_iter().collect(), + Self::Many(list) => list + .into_iter() + .filter_map(|name| clean(Some(name))) + .collect(), + } + } } fn clean(value: Option) -> Option { - let value = value?.trim().to_string(); - (!value.is_empty()).then_some(value) + let value = value?.trim().to_string(); + (!value.is_empty()).then_some(value) } fn clean_description(value: Option) -> Option { - let raw = clean(value)?; + let raw = clean(value)?; - // Collapsing whitespace only shortens so a window this wide still holds - // words to spare and a megabyte long blurb is never copied whole - let end = raw - .char_indices() - .nth(MAX_DESCRIPTION_WINDOW) - .map_or(raw.len(), |(idx, _)| idx); + // Collapsing whitespace only shortens so a window this wide still holds + // words to spare and a megabyte long blurb is never copied whole + let end = raw + .char_indices() + .nth(MAX_DESCRIPTION_WINDOW) + .map_or(raw.len(), |(idx, _)| idx); - let mut text = raw[..end].split_whitespace().collect::>().join(" "); - if text.is_empty() { - return None; - } + let mut text = raw[..end].split_whitespace().collect::>().join(" "); + if text.is_empty() { + return None; + } - if text.chars().count() > MAX_DESCRIPTION { - let cut = text - .char_indices() - .nth(MAX_DESCRIPTION) - .map_or(text.len(), |(idx, _)| idx); - text.truncate(cut); - text.push('…'); - } else if end < raw.len() { - text.push('…'); - } + if text.chars().count() > MAX_DESCRIPTION { + let cut = text + .char_indices() + .nth(MAX_DESCRIPTION) + .map_or(text.len(), |(idx, _)| idx); + text.truncate(cut); + text.push('…'); + } else if end < raw.len() { + text.push('…'); + } - Some(text) + Some(text) } fn entry_path(raw: String) -> Option { - let path = raw - .trim() - .replace('\\', "/") - .trim_start_matches('/') - .trim_start_matches("./") - .to_string(); + let path = raw + .trim() + .replace('\\', "/") + .trim_start_matches('/') + .trim_start_matches("./") + .to_string(); - if path.is_empty() || path.split('/').any(|part| part == "..") { - return None; - } + if path.is_empty() || path.split('/').any(|part| part == "..") { + return None; + } - Some(path) + Some(path) } #[cfg(test)] mod tests { - use super::*; + use super::*; - #[test] - fn fabric_reads_every_field() { - let parsed = parse_fabric( - r#"{ + #[test] + fn fabric_reads_every_field() { + let parsed = parse_fabric( + r#"{ "schemaVersion": 1, "id": "sodium", "name": "Sodium", @@ -350,41 +353,41 @@ mod tests { "authors": ["JellySquid", {"name": "Someone", "contact": {}}], "icon": "assets/sodium/icon.png" }"#, - ) - .expect("valid fabric manifest"); + ) + .expect("valid fabric manifest"); - assert_eq!(parsed.name.as_deref(), Some("Sodium")); - assert_eq!( - parsed.description.as_deref(), - Some("A modern rendering engine"), - "hard wrapping collapses so the card lays out its own lines" - ); - assert_eq!(parsed.author_line(), "JellySquid, Someone"); - assert_eq!(parsed.icon_entry.as_deref(), Some("assets/sodium/icon.png")); - } + assert_eq!(parsed.name.as_deref(), Some("Sodium")); + assert_eq!( + parsed.description.as_deref(), + Some("A modern rendering engine"), + "hard wrapping collapses so the card lays out its own lines" + ); + assert_eq!(parsed.author_line(), "JellySquid, Someone"); + assert_eq!(parsed.icon_entry.as_deref(), Some("assets/sodium/icon.png")); + } - #[test] - fn fabric_icon_map_takes_the_largest() { - let parsed = parse_fabric( - r#"{"id": "x", "icon": {"32": "small.png", "128": "big.png", "64": "mid.png"}}"#, - ) - .expect("valid fabric manifest"); + #[test] + fn fabric_icon_map_takes_the_largest() { + let parsed = parse_fabric( + r#"{"id": "x", "icon": {"32": "small.png", "128": "big.png", "64": "mid.png"}}"#, + ) + .expect("valid fabric manifest"); - assert_eq!(parsed.icon_entry.as_deref(), Some("big.png")); - } + assert_eq!(parsed.icon_entry.as_deref(), Some("big.png")); + } - #[test] - fn fabric_survives_an_author_shaped_oddly() { - let parsed = parse_fabric(r#"{"id": "x", "name": "X", "authors": [["nested"], "Real"]}"#) - .expect("one odd author must not sink the manifest"); + #[test] + fn fabric_survives_an_author_shaped_oddly() { + let parsed = parse_fabric(r#"{"id": "x", "name": "X", "authors": [["nested"], "Real"]}"#) + .expect("one odd author must not sink the manifest"); - assert_eq!(parsed.author_line(), "Real"); - } + assert_eq!(parsed.author_line(), "Real"); + } - #[test] - fn quilt_reads_contributor_names() { - let parsed = parse_quilt( - r#"{ + #[test] + fn quilt_reads_contributor_names() { + let parsed = parse_quilt( + r#"{ "schema_version": 1, "quilt_loader": { "id": "example", @@ -396,18 +399,21 @@ mod tests { } } }"#, - ) - .expect("valid quilt manifest"); + ) + .expect("valid quilt manifest"); - assert_eq!(parsed.name.as_deref(), Some("Example")); - assert_eq!(parsed.author_line(), "Alice, Bob"); - assert_eq!(parsed.icon_entry.as_deref(), Some("assets/example/icon.png")); - } + assert_eq!(parsed.name.as_deref(), Some("Example")); + assert_eq!(parsed.author_line(), "Alice, Bob"); + assert_eq!( + parsed.icon_entry.as_deref(), + Some("assets/example/icon.png") + ); + } - #[test] - fn forge_reads_the_first_mod_entry() { - let parsed = parse_forge( - r#" + #[test] + fn forge_reads_the_first_mod_entry() { + let parsed = parse_forge( + r#" modLoader="javafml" loaderVersion="[47,)" license="MIT" @@ -427,19 +433,22 @@ spans lines modId="forge" mandatory=true "#, - ) - .expect("valid forge manifest"); + ) + .expect("valid forge manifest"); - assert_eq!(parsed.name.as_deref(), Some("Example Mod")); - assert_eq!(parsed.description.as_deref(), Some("A mod that spans lines")); - assert_eq!(parsed.author_line(), "Someone"); - assert_eq!(parsed.icon_entry.as_deref(), Some("examplemod.png")); - } + assert_eq!(parsed.name.as_deref(), Some("Example Mod")); + assert_eq!( + parsed.description.as_deref(), + Some("A mod that spans lines") + ); + assert_eq!(parsed.author_line(), "Someone"); + assert_eq!(parsed.icon_entry.as_deref(), Some("examplemod.png")); + } - #[test] - fn forge_falls_back_to_credits_and_a_root_logo() { - let parsed = parse_forge( - r#" + #[test] + fn forge_falls_back_to_credits_and_a_root_logo() { + let parsed = parse_forge( + r#" logoFile="logo.png" [[mods]] @@ -447,82 +456,82 @@ modId="x" displayName="X" credits="Thanks to everyone" "#, - ) - .expect("valid forge manifest"); + ) + .expect("valid forge manifest"); - assert_eq!(parsed.author_line(), "Thanks to everyone"); - assert_eq!(parsed.icon_entry.as_deref(), Some("logo.png")); - } + assert_eq!(parsed.author_line(), "Thanks to everyone"); + assert_eq!(parsed.icon_entry.as_deref(), Some("logo.png")); + } - #[test] - fn legacy_forge_reads_both_shapes() { - let bare = parse_legacy_forge( + #[test] + fn legacy_forge_reads_both_shapes() { + let bare = parse_legacy_forge( r#"[{"modid":"x","name":"X","description":"Old","authorList":["A","B"],"logoFile":"/logo.png"}]"#, ) .expect("valid mcmod.info"); - assert_eq!(bare.name.as_deref(), Some("X")); - assert_eq!(bare.author_line(), "A, B"); - assert_eq!( - bare.icon_entry.as_deref(), - Some("logo.png"), - "the leading slash is jar relative not absolute" - ); + assert_eq!(bare.name.as_deref(), Some("X")); + assert_eq!(bare.author_line(), "A, B"); + assert_eq!( + bare.icon_entry.as_deref(), + Some("logo.png"), + "the leading slash is jar relative not absolute" + ); - let wrapped = parse_legacy_forge( - r#"{"modListVersion":2,"modList":[{"modid":"y","name":"Y","authorList":["C"]}]}"#, - ) - .expect("valid v2 mcmod.info"); + let wrapped = parse_legacy_forge( + r#"{"modListVersion":2,"modList":[{"modid":"y","name":"Y","authorList":["C"]}]}"#, + ) + .expect("valid v2 mcmod.info"); - assert_eq!(wrapped.name.as_deref(), Some("Y")); - } + assert_eq!(wrapped.name.as_deref(), Some("Y")); + } - #[test] - fn a_second_manifest_only_fills_gaps() { - let mut fabric = JarManifest { - name: Some("Fabric Name".into()), - ..JarManifest::default() - }; - fabric.fill_from(JarManifest { - name: Some("Forge Name".into()), - description: Some("From forge".into()), - authors: vec!["Someone".into()], - icon_entry: Some("logo.png".into()), - }); + #[test] + fn a_second_manifest_only_fills_gaps() { + let mut fabric = JarManifest { + name: Some("Fabric Name".into()), + ..JarManifest::default() + }; + fabric.fill_from(JarManifest { + name: Some("Forge Name".into()), + description: Some("From forge".into()), + authors: vec!["Someone".into()], + icon_entry: Some("logo.png".into()), + }); - assert_eq!(fabric.name.as_deref(), Some("Fabric Name")); - assert_eq!(fabric.description.as_deref(), Some("From forge")); - assert_eq!(fabric.author_line(), "Someone"); - assert_eq!(fabric.icon_entry.as_deref(), Some("logo.png")); - } + assert_eq!(fabric.name.as_deref(), Some("Fabric Name")); + assert_eq!(fabric.description.as_deref(), Some("From forge")); + assert_eq!(fabric.author_line(), "Someone"); + assert_eq!(fabric.icon_entry.as_deref(), Some("logo.png")); + } - #[test] - fn traversing_icon_paths_are_refused() { - assert_eq!(entry_path("../../etc/passwd".into()), None); - assert_eq!(entry_path(" ".into()), None); - assert_eq!(entry_path("./icon.png".into()).as_deref(), Some("icon.png")); - } + #[test] + fn traversing_icon_paths_are_refused() { + assert_eq!(entry_path("../../etc/passwd".into()), None); + assert_eq!(entry_path(" ".into()), None); + assert_eq!(entry_path("./icon.png".into()).as_deref(), Some("icon.png")); + } - #[test] - fn long_descriptions_are_cut_on_a_char_boundary() { - let long = "ż".repeat(MAX_DESCRIPTION + 50); - let cut = clean_description(Some(long)).expect("non empty"); + #[test] + fn long_descriptions_are_cut_on_a_char_boundary() { + let long = "ż".repeat(MAX_DESCRIPTION + 50); + let cut = clean_description(Some(long)).expect("non empty"); - assert_eq!( - cut.chars().count(), - MAX_DESCRIPTION + 1, - "the ellipsis is the extra one" - ); - assert!(cut.ends_with('…')); - } + assert_eq!( + cut.chars().count(), + MAX_DESCRIPTION + 1, + "the ellipsis is the extra one" + ); + assert!(cut.ends_with('…')); + } - #[test] - fn garbage_is_not_a_manifest() { - assert!(parse_fabric("not json").is_none()); - assert!(parse_forge("[[mods").is_none()); - assert!( - parse_forge("modLoader=\"javafml\"").is_none(), - "no mod entry to describe" - ); - } + #[test] + fn garbage_is_not_a_manifest() { + assert!(parse_fabric("not json").is_none()); + assert!(parse_forge("[[mods").is_none()); + assert!( + parse_forge("modLoader=\"javafml\"").is_none(), + "no mod entry to describe" + ); + } } diff --git a/packages/oneclient_content/src/packages/markdown.rs b/packages/oneclient_content/src/packages/markdown.rs index 2d5d6b0e..190cb4b5 100644 --- a/packages/oneclient_content/src/packages/markdown.rs +++ b/packages/oneclient_content/src/packages/markdown.rs @@ -179,7 +179,10 @@ mod tests { assert!(out.contains("# Mod"), "{out}"); assert!(out.contains("### Tagline"), "{out}"); - assert!(out.contains("[![Badge](badge.svg)](https://example.com)"), "{out}"); + assert!( + out.contains("[![Badge](badge.svg)](https://example.com)"), + "{out}" + ); assert!(!out.contains("x"), "x"); - assert_eq!(replace_break_tags("a
b"), format!("a{BREAK}b")); + assert_eq!( + replace_break_tags("a
b"), + format!("a{BREAK}b") + ); } } diff --git a/packages/oneclient_content/src/packages/metadata_cache.rs b/packages/oneclient_content/src/packages/metadata_cache.rs index 89a39537..7480ef90 100644 --- a/packages/oneclient_content/src/packages/metadata_cache.rs +++ b/packages/oneclient_content/src/packages/metadata_cache.rs @@ -1,4 +1,3 @@ - use std::collections::HashMap; use chrono::Utc; @@ -7,233 +6,235 @@ use serde::{Deserialize, Serialize}; use oneclient_db::dao::artifact as artifact_dao; use oneclient_db::dao::package_metadata as meta_dao; -use crate::packages::ProviderId; -use oneclient_common::domain::{ContentType, GameLoader}; -use crate::packages::store::artifact_absolute_path; -use crate::packages::types::{PackageBody, ProjectDetail, VersionDetail, VersionFile}; use crate::ctx::ContentCtx; use crate::error::ContentResult; +use crate::packages::ProviderId; +use crate::packages::store::artifact_absolute_path; +use crate::packages::types::{PackageBody, ProjectDetail, VersionDetail, VersionFile}; +use oneclient_common::domain::{ContentType, GameLoader}; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CachedPackageMeta { - pub provider: ProviderId, - pub project_id: String, - pub name: String, - pub summary: String, - pub author: String, - pub icon_url: Option, + pub provider: ProviderId, + pub project_id: String, + pub name: String, + pub summary: String, + pub author: String, + pub icon_url: Option, } #[tracing::instrument(level = "debug", skip(ctx, project_ids))] pub async fn read_cached_package_meta( - ctx: &ContentCtx, - provider: ProviderId, - project_ids: &[String], + ctx: &ContentCtx, + provider: ProviderId, + project_ids: &[String], ) -> HashMap { - if project_ids.is_empty() { - return HashMap::new(); - } + if project_ids.is_empty() { + return HashMap::new(); + } - let rows = meta_dao::get_package_metadata_batch(&ctx.db, provider as i64, project_ids) - .await - .unwrap_or_default(); + let rows = meta_dao::get_package_metadata_batch(&ctx.db, provider as i64, project_ids) + .await + .unwrap_or_default(); - rows.into_iter() - .map(|row| { - ( - row.project_id.clone(), - CachedPackageMeta { - provider, - project_id: row.project_id, - name: row.name, - summary: row.summary, - author: row.author, - icon_url: row.icon_url, - }, - ) - }) - .collect() + rows.into_iter() + .map(|row| { + ( + row.project_id.clone(), + CachedPackageMeta { + provider, + project_id: row.project_id, + name: row.name, + summary: row.summary, + author: row.author, + icon_url: row.icon_url, + }, + ) + }) + .collect() } #[tracing::instrument(level = "debug", skip(ctx))] pub async fn cached_project_detail( - ctx: &ContentCtx, - provider: ProviderId, - project_id: &str, - content_type: ContentType, + ctx: &ContentCtx, + provider: ProviderId, + project_id: &str, + content_type: ContentType, ) -> ProjectDetail { - let cached = read_cached_package_meta(ctx, provider, std::slice::from_ref(&project_id.to_string())) - .await; - let meta = cached.get(project_id); + let cached = + read_cached_package_meta(ctx, provider, std::slice::from_ref(&project_id.to_string())) + .await; + let meta = cached.get(project_id); - ProjectDetail { - id: project_id.to_string(), - slug: String::new(), - provider, - content_type, - name: meta - .map(|m| m.name.clone()) - .filter(|n| !n.is_empty()) - .unwrap_or_else(|| project_id.to_string()), - summary: meta.map(|m| m.summary.clone()).unwrap_or_default(), - author: meta.map(|m| m.author.clone()).unwrap_or_default(), - members: Vec::new(), - gallery: Vec::new(), - body: PackageBody::Raw(String::new()), - license: None, - links: Vec::new(), - version_ids: Vec::new(), - game_versions: Vec::new(), - loaders: Vec::new(), - icon_url: meta.and_then(|m| m.icon_url.clone()), - created: Utc::now(), - updated: Utc::now(), - downloads: 0, - } + ProjectDetail { + id: project_id.to_string(), + slug: String::new(), + provider, + content_type, + name: meta + .map(|m| m.name.clone()) + .filter(|n| !n.is_empty()) + .unwrap_or_else(|| project_id.to_string()), + summary: meta.map(|m| m.summary.clone()).unwrap_or_default(), + author: meta.map(|m| m.author.clone()).unwrap_or_default(), + members: Vec::new(), + gallery: Vec::new(), + body: PackageBody::Raw(String::new()), + license: None, + links: Vec::new(), + version_ids: Vec::new(), + game_versions: Vec::new(), + loaders: Vec::new(), + icon_url: meta.and_then(|m| m.icon_url.clone()), + created: Utc::now(), + updated: Utc::now(), + downloads: 0, + } } #[tracing::instrument(level = "debug", skip(ctx))] pub async fn get_version_cached( - ctx: &ContentCtx, - provider: ProviderId, - project_id: &str, - version_id: &str, + ctx: &ContentCtx, + provider: ProviderId, + project_id: &str, + version_id: &str, ) -> ContentResult { - if let Some(cached) = cached_version_detail(ctx, provider, project_id, version_id).await { - return Ok(cached); - } + if let Some(cached) = cached_version_detail(ctx, provider, project_id, version_id).await { + return Ok(cached); + } - ctx - .providers - .get(provider)? - .get_version(project_id, version_id, ctx) - .await + ctx.providers + .get(provider)? + .get_version(project_id, version_id, ctx) + .await } #[tracing::instrument(level = "debug", skip(ctx))] async fn cached_version_detail( - ctx: &ContentCtx, - provider: ProviderId, - project_id: &str, - version_id: &str, + ctx: &ContentCtx, + provider: ProviderId, + project_id: &str, + version_id: &str, ) -> Option { - let release = - artifact_dao::get_provider_release(&ctx.db, provider as i64, project_id, version_id) - .await - .ok() - .flatten()?; - let artifact = artifact_dao::get_artifact_by_hash(&ctx.db, &release.hash) - .await - .ok() - .flatten()?; + let release = + artifact_dao::get_provider_release(&ctx.db, provider as i64, project_id, version_id) + .await + .ok() + .flatten()?; + let artifact = artifact_dao::get_artifact_by_hash(&ctx.db, &release.hash) + .await + .ok() + .flatten()?; - let path = artifact_absolute_path(&artifact.path).ok()?; - if !path.exists() { - return None; - } + let path = artifact_absolute_path(&artifact.path).ok()?; + if !path.exists() { + return None; + } - let game_versions: Vec = serde_json::from_str(&release.mc_versions).unwrap_or_default(); - let loaders: Vec = serde_json::from_str(&release.mc_loaders).unwrap_or_default(); - let published = release - .published_at - .as_deref() - .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()) - .map(|dt| dt.with_timezone(&Utc)) - .unwrap_or_else(Utc::now); + let game_versions: Vec = serde_json::from_str(&release.mc_versions).unwrap_or_default(); + let loaders: Vec = serde_json::from_str(&release.mc_loaders).unwrap_or_default(); + let published = release + .published_at + .as_deref() + .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()) + .map(|dt| dt.with_timezone(&Utc)) + .unwrap_or_else(Utc::now); - Some(VersionDetail { - version_id: version_id.to_string(), - project_id: project_id.to_string(), - name: release.display_name, - version_number: release.display_version, - changelog: None, - game_versions, - loaders, - published, - downloads: 0, - files: vec![VersionFile { - sha1: release.hash, - url: String::new(), - file_name: artifact.file_name, - primary: true, - size: artifact.size_bytes.unwrap_or(0) as u64, - fingerprint: None, - }], - // Not persisted with the release row callers needing the graph go to the provider - dependencies: Vec::new(), - }) + Some(VersionDetail { + version_id: version_id.to_string(), + project_id: project_id.to_string(), + name: release.display_name, + version_number: release.display_version, + changelog: None, + game_versions, + loaders, + published, + downloads: 0, + files: vec![VersionFile { + sha1: release.hash, + url: String::new(), + file_name: artifact.file_name, + primary: true, + size: artifact.size_bytes.unwrap_or(0) as u64, + fingerprint: None, + }], + // Not persisted with the release row callers needing the graph go to the provider + dependencies: Vec::new(), + }) } #[tracing::instrument(level = "debug", skip(ctx, project_ids))] pub async fn fetch_package_meta( - ctx: &ContentCtx, - provider: ProviderId, - project_ids: &[String], + ctx: &ContentCtx, + provider: ProviderId, + project_ids: &[String], ) -> ContentResult> { - if project_ids.is_empty() { - return Ok(HashMap::new()); - } + if project_ids.is_empty() { + return Ok(HashMap::new()); + } - let provider_repr = provider as i64; - let mut out: HashMap = HashMap::new(); + let provider_repr = provider as i64; + let mut out: HashMap = HashMap::new(); - for row in meta_dao::get_package_metadata_batch(&ctx.db, provider_repr, project_ids).await? - { - out.insert( - row.project_id.clone(), - CachedPackageMeta { - provider, - project_id: row.project_id, - name: row.name, - summary: row.summary, - author: row.author, - icon_url: row.icon_url, - }, - ); - } + for row in meta_dao::get_package_metadata_batch(&ctx.db, provider_repr, project_ids).await? { + out.insert( + row.project_id.clone(), + CachedPackageMeta { + provider, + project_id: row.project_id, + name: row.name, + summary: row.summary, + author: row.author, + icon_url: row.icon_url, + }, + ); + } - if provider == ProviderId::Local { - return Ok(out); - } + if provider == ProviderId::Local { + return Ok(out); + } - let missing: Vec = project_ids - .iter() - .filter(|id| out.get(*id).is_none_or(|m| m.author.is_empty())) - .cloned() - .collect(); - if missing.is_empty() { - return Ok(out); - } + let missing: Vec = project_ids + .iter() + .filter(|id| out.get(*id).is_none_or(|m| m.author.is_empty())) + .cloned() + .collect(); + if missing.is_empty() { + return Ok(out); + } - tracing::debug!(count = missing.len(), "fetching missing package metadata from provider"); + tracing::debug!( + count = missing.len(), + "fetching missing package metadata from provider" + ); - let details = ctx - .providers - .get(provider)? - .get_projects(&missing, ctx) - .await?; + let details = ctx + .providers + .get(provider)? + .get_projects(&missing, ctx) + .await?; - for detail in details { - let meta = CachedPackageMeta { - provider, - project_id: detail.id, - name: detail.name, - summary: detail.summary, - author: detail.author, - icon_url: detail.icon_url, - }; - meta_dao::upsert_package_metadata( - &ctx.db, - provider_repr, - &meta.project_id, - &meta.name, - &meta.summary, - &meta.author, - meta.icon_url.as_deref(), - ) - .await?; - out.insert(meta.project_id.clone(), meta); - } + for detail in details { + let meta = CachedPackageMeta { + provider, + project_id: detail.id, + name: detail.name, + summary: detail.summary, + author: detail.author, + icon_url: detail.icon_url, + }; + meta_dao::upsert_package_metadata( + &ctx.db, + provider_repr, + &meta.project_id, + &meta.name, + &meta.summary, + &meta.author, + meta.icon_url.as_deref(), + ) + .await?; + out.insert(meta.project_id.clone(), meta); + } - Ok(out) + Ok(out) } diff --git a/packages/oneclient_content/src/packages/mod.rs b/packages/oneclient_content/src/packages/mod.rs index b996a0a5..12ab95fa 100644 --- a/packages/oneclient_content/src/packages/mod.rs +++ b/packages/oneclient_content/src/packages/mod.rs @@ -13,23 +13,23 @@ mod file_identity; pub mod markdown; // Re-exported so `oneclient_core::packages::ContentType` keeps working -pub use oneclient_common::domain::{ContentType, GameLoader, HashAlgorithm, ProviderId}; -pub use metadata_cache::{ - CachedPackageMeta, cached_project_detail, fetch_package_meta, get_version_cached, - read_cached_package_meta, -}; pub use activity::{disable_foreign_game_versions, reconcile_duplicate_activity}; pub use dependencies::{ DependencyResolution, ResolvedDependency, resolve_required, resolves_dependencies, }; -pub use file_identity::{curseforge_fingerprint, FileIdentity}; -pub use local_manifest::{JarManifest, read_jar_icon, read_jar_manifest}; pub use error::{PackageError, PackageResult}; +pub use file_identity::{FileIdentity, curseforge_fingerprint}; +pub use local_manifest::{JarManifest, read_jar_icon, read_jar_manifest}; +pub use metadata_cache::{ + CachedPackageMeta, cached_project_detail, fetch_package_meta, get_version_cached, + read_cached_package_meta, +}; +pub use oneclient_common::domain::{ContentType, GameLoader, HashAlgorithm, ProviderId}; pub use provider::{PackageProvider, PackageProviderRegistry}; pub use store::{LiveSync, LocalImportReport, PackageStore}; pub use types::*; pub use updates::{ BrowserPackageUpdate, BrowserUpdateCheck, apply_browser_package_update, - cached_browser_package_updates, check_browser_package_updates, - refresh_browser_package_updates, skip_browser_package_update, + cached_browser_package_updates, check_browser_package_updates, refresh_browser_package_updates, + skip_browser_package_update, }; diff --git a/packages/oneclient_content/src/packages/modpack/mod.rs b/packages/oneclient_content/src/packages/modpack/mod.rs index 83a65a9f..30753904 100644 --- a/packages/oneclient_content/src/packages/modpack/mod.rs +++ b/packages/oneclient_content/src/packages/modpack/mod.rs @@ -2,17 +2,16 @@ mod mrpack; use std::path::Path; - -pub use mrpack::{install_mrpack_to_cluster, MrpackInstaller}; +pub use mrpack::{MrpackInstaller, install_mrpack_to_cluster}; use crate::ctx::ContentCtx; use crate::error::ContentResult; #[tracing::instrument(level = "debug", skip(archive_path, ctx))] pub async fn install_modpack_archive( - archive_path: impl AsRef, - cluster_id: i64, - ctx: &ContentCtx, + archive_path: impl AsRef, + cluster_id: i64, + ctx: &ContentCtx, ) -> ContentResult<()> { - mrpack::install_mrpack_to_cluster(archive_path.as_ref().to_path_buf(), cluster_id, ctx).await + mrpack::install_mrpack_to_cluster(archive_path.as_ref().to_path_buf(), cluster_id, ctx).await } diff --git a/packages/oneclient_content/src/packages/modpack/mrpack.rs b/packages/oneclient_content/src/packages/modpack/mrpack.rs index 16e32fc7..2b71889a 100644 --- a/packages/oneclient_content/src/packages/modpack/mrpack.rs +++ b/packages/oneclient_content/src/packages/modpack/mrpack.rs @@ -7,12 +7,12 @@ use oneclient_db::models::ClusterRow; use serde::Deserialize; use uuid::Uuid; -use oneclient_common::domain::ContentType; +use crate::ctx::ContentCtx; +use crate::error::ContentResult; use crate::packages::error::PackageError; use crate::packages::store::{self, PackageStore}; use crate::packages::types::ExternalFile; -use crate::ctx::ContentCtx; -use crate::error::ContentResult; +use oneclient_common::domain::ContentType; pub struct MrpackInstaller; @@ -75,12 +75,8 @@ impl MrpackInstaller { let progress_id = Uuid::new_v4(); for (index, entry) in manifest.files.into_iter().enumerate() { - ctx.events.progress( - progress_id, - "Installing Modpack Files", - index as u64, - total, - ); + ctx.events + .progress(progress_id, "Installing Modpack Files", index as u64, total); let content_type = content_type_from_path(&entry.path); let hash = entry.hashes.sha1.to_ascii_lowercase(); @@ -92,15 +88,8 @@ impl MrpackInstaller { let path_str = entry.path.clone(); - if let Err(err) = install_mrpack_file( - entry, - content_type, - hash, - file_name, - &cluster, - ctx, - ) - .await + if let Err(err) = + install_mrpack_file(entry, content_type, hash, file_name, &cluster, ctx).await { failed += 1; tracing::warn!(path = %path_str, error = %err, "modpack file install failed"); @@ -151,9 +140,7 @@ async fn install_mrpack_file( cluster: &ClusterRow, ctx: &ContentCtx, ) -> ContentResult<()> { - if let Some(row) = - oneclient_db::dao::artifact::get_artifact_by_hash(&ctx.db, &hash).await? - { + if let Some(row) = oneclient_db::dao::artifact::get_artifact_by_hash(&ctx.db, &hash).await? { let path = store::artifact_absolute_path(&row.path)?; if path.exists() { PackageStore::link_artifact(&row, cluster, Some(&file_name), ctx).await?; @@ -161,22 +148,12 @@ async fn install_mrpack_file( } } - if let Some((provider_id, version)) = - ctx.providers.lookup_version(&hash, ctx).await? - { + if let Some((provider_id, version)) = ctx.providers.lookup_version(&hash, ctx).await? { let provider = ctx.providers.get(provider_id)?; - let project = provider - .get_project(&version.project_id, ctx) - .await?; - let artifact = PackageStore::download_and_cache( - provider_id, - &project, - &version, - false, - None, - ctx, - ) - .await?; + let project = provider.get_project(&version.project_id, ctx).await?; + let artifact = + PackageStore::download_and_cache(provider_id, &project, &version, false, None, ctx) + .await?; PackageStore::link_artifact(&artifact, cluster, Some(&file_name), ctx).await?; return Ok(()); } diff --git a/packages/oneclient_content/src/packages/provider/curseforge.rs b/packages/oneclient_content/src/packages/provider/curseforge.rs index f2e439bf..c999f412 100644 --- a/packages/oneclient_content/src/packages/provider/curseforge.rs +++ b/packages/oneclient_content/src/packages/provider/curseforge.rs @@ -7,16 +7,16 @@ use url::Url; use super::PackageProvider; use super::http::fetch_json; +use crate::ctx::ContentCtx; use crate::error::ContentResult; -use oneclient_common::constants::{CURSEFORGE_API_URL, CURSEFORGE_GAME_ID}; -use oneclient_common::domain::{ContentType, GameLoader, ProviderId}; use crate::packages::file_identity::FileIdentity; use crate::packages::types::{ DependencyKind, GalleryImage, PackageBody, Page, ProjectDetail, ProjectMember, ProjectSummary, ReleaseType, SearchFilters, VersionDependency, VersionDetail, VersionFile, VersionLookup, VersionSummary, }; -use crate::ctx::ContentCtx; +use oneclient_common::constants::{CURSEFORGE_API_URL, CURSEFORGE_GAME_ID}; +use oneclient_common::domain::{ContentType, GameLoader, ProviderId}; pub struct CurseForgeProvider; @@ -75,13 +75,8 @@ impl PackageProvider for CurseForgeProvider { } } - let response: CfPaged> = fetch_json( - &ctx.net, - Method::GET, - url.as_str(), - None, - ) - .await?; + let response: CfPaged> = + fetch_json(&ctx.net, Method::GET, url.as_str(), None).await?; Ok(Page { offset: response.pagination.index, @@ -130,13 +125,8 @@ impl PackageProvider for CurseForgeProvider { ) -> ContentResult> { let mod_ids: Vec = project_ids.iter().filter_map(|s| s.parse().ok()).collect(); let body = serde_json::json!({ "modIds": mod_ids }); - let response: CfData> = fetch_json( - &ctx.net, - Method::POST, - &api_url("/mods"), - Some(body), - ) - .await?; + let response: CfData> = + fetch_json(&ctx.net, Method::POST, &api_url("/mods"), Some(body)).await?; Ok(response.data.into_iter().map(CfMod::into_detail).collect()) } @@ -160,13 +150,8 @@ impl PackageProvider for CurseForgeProvider { params.append_pair("modLoaderType", &t.to_string()); } } - let response: CfPaged> = fetch_json( - &ctx.net, - Method::GET, - url.as_str(), - None, - ) - .await?; + let response: CfPaged> = + fetch_json(&ctx.net, Method::GET, url.as_str(), None).await?; let total = response.data.len(); let items = response @@ -200,13 +185,8 @@ impl PackageProvider for CurseForgeProvider { url.query_pairs_mut() .append_pair("gameId", &CURSEFORGE_GAME_ID.to_string()) .append_pair("classId", &cf_class_id(content_type).to_string()); - let response: CfData> = fetch_json( - &ctx.net, - Method::GET, - url.as_str(), - None, - ) - .await?; + let response: CfData> = + fetch_json(&ctx.net, Method::GET, url.as_str(), None).await?; Ok(response.data.into_iter().map(|c| c.name).collect()) } @@ -235,13 +215,8 @@ impl PackageProvider for CurseForgeProvider { ) -> ContentResult> { let file_ids: Vec = version_ids.iter().filter_map(|s| s.parse().ok()).collect(); let body = serde_json::json!({ "fileIds": file_ids }); - let response: CfData> = fetch_json( - &ctx.net, - Method::POST, - &api_url("/mods/files"), - Some(body), - ) - .await?; + let response: CfData> = + fetch_json(&ctx.net, Method::POST, &api_url("/mods/files"), Some(body)).await?; Ok(response.data.into_iter().map(Into::into).collect()) } @@ -315,11 +290,7 @@ struct CfPaged { } #[tracing::instrument(level = "debug", skip(detail, ctx))] -async fn fetch_body( - project_id: &str, - detail: &ProjectDetail, - ctx: &ContentCtx, -) -> PackageBody { +async fn fetch_body(project_id: &str, detail: &ProjectDetail, ctx: &ContentCtx) -> PackageBody { let fallback = || { detail .links @@ -358,7 +329,10 @@ fn html_to_markdown(html: &str) -> Option { fn absolutize_links(markdown: &str) -> String { markdown - .replace("](/linkout?remoteUrl=", "](https://www.curseforge.com/linkout?remoteUrl=") + .replace( + "](/linkout?remoteUrl=", + "](https://www.curseforge.com/linkout?remoteUrl=", + ) .replace("](/", "](https://www.curseforge.com/") } @@ -578,11 +552,7 @@ impl CfMod { }) .collect(), license: None, - links: self - .links - .as_ref() - .map(cf_links) - .unwrap_or_default(), + links: self.links.as_ref().map(cf_links).unwrap_or_default(), body: PackageBody::Raw(String::new()), version_ids: Vec::new(), game_versions: Vec::new(), @@ -727,7 +697,6 @@ fn cf_loader_type(loader: GameLoader) -> Option { } } - #[cfg(test)] mod tests { use super::*; diff --git a/packages/oneclient_content/src/packages/provider/http.rs b/packages/oneclient_content/src/packages/provider/http.rs index ed17abbb..5b67bc78 100644 --- a/packages/oneclient_content/src/packages/provider/http.rs +++ b/packages/oneclient_content/src/packages/provider/http.rs @@ -1,9 +1,9 @@ use reqwest::Method; use serde::de::DeserializeOwned; -use oneclient_net::{RequestClient, RequestError, ResponseNotifyOptions, ResponseOptions}; -use oneclient_events::GroupedProgressChild; use crate::ctx::ContentCtx; +use oneclient_events::GroupedProgressChild; +use oneclient_net::{RequestClient, RequestError, ResponseNotifyOptions, ResponseOptions}; #[tracing::instrument(level = "debug", skip(client, url, body))] pub async fn fetch_json( diff --git a/packages/oneclient_content/src/packages/provider/mod.rs b/packages/oneclient_content/src/packages/provider/mod.rs index 725e5bdd..48367712 100644 --- a/packages/oneclient_content/src/packages/provider/mod.rs +++ b/packages/oneclient_content/src/packages/provider/mod.rs @@ -7,14 +7,14 @@ pub use curseforge::CurseForgeProvider; pub use modrinth::ModrinthProvider; pub use registry::PackageProviderRegistry; -use oneclient_common::domain::ProviderId; -use crate::packages::file_identity::FileIdentity; use super::types::{ Page, ProjectDetail, ProjectSummary, SearchFilters, VersionDetail, VersionLookup, VersionSummary, }; -use crate::error::ContentResult; use crate::ctx::ContentCtx; +use crate::error::ContentResult; +use crate::packages::file_identity::FileIdentity; +use oneclient_common::domain::ProviderId; #[async_trait::async_trait] pub trait PackageProvider: Send + Sync { @@ -26,11 +26,8 @@ pub trait PackageProvider: Send + Sync { ctx: &ContentCtx, ) -> ContentResult>; - async fn get_project( - &self, - project_id: &str, - ctx: &ContentCtx, - ) -> ContentResult; + async fn get_project(&self, project_id: &str, ctx: &ContentCtx) + -> ContentResult; /// Populates [`ProjectDetail::body`] /// Costs an extra request on providers that serve descriptions separately diff --git a/packages/oneclient_content/src/packages/provider/modrinth.rs b/packages/oneclient_content/src/packages/provider/modrinth.rs index f3c95495..477ce6f5 100644 --- a/packages/oneclient_content/src/packages/provider/modrinth.rs +++ b/packages/oneclient_content/src/packages/provider/modrinth.rs @@ -10,17 +10,17 @@ use serde::de::DeserializeOwned; use super::PackageProvider; use super::http::fetch_json_with_headers; +use crate::ctx::ContentCtx; use crate::error::ContentResult; -use oneclient_common::constants::MODRINTH_API_URL; -use oneclient_net::{RequestClient, RequestError}; -use oneclient_common::domain::{ContentType, GameLoader, ProviderId}; use crate::packages::file_identity::FileIdentity; use crate::packages::types::{ DependencyKind, GalleryImage, PackageBody, Page, ProjectDetail, ProjectMember, ProjectSummary, ReleaseType, SearchFilters, VersionDependency, VersionDetail, VersionFile, VersionLookup, VersionSummary, }; -use crate::ctx::ContentCtx; +use oneclient_common::constants::MODRINTH_API_URL; +use oneclient_common::domain::{ContentType, GameLoader, ProviderId}; +use oneclient_net::{RequestClient, RequestError}; pub struct ModrinthProvider; @@ -34,7 +34,14 @@ async fn fetch_json( url: impl reqwest::IntoUrl, body: Option, ) -> Result { - fetch_json_with_headers(client, method, url, body, &client.config().modrinth_headers()).await + fetch_json_with_headers( + client, + method, + url, + body, + &client.config().modrinth_headers(), + ) + .await } #[async_trait::async_trait] @@ -64,7 +71,10 @@ impl PackageProvider for ModrinthProvider { let mut groups: Vec> = Vec::new(); if let Some(content_type) = filters.content_type { - groups.push(vec![format!("project_type:{}", content_type.modrinth_type())]); + groups.push(vec![format!( + "project_type:{}", + content_type.modrinth_type() + )]); } if let Some(versions) = &filters.game_versions { let group: Vec = versions.iter().map(|v| format!("versions:{v}")).collect(); @@ -87,7 +97,10 @@ impl PackageProvider for ModrinthProvider { } } if !groups.is_empty() { - params.append_pair("facets", &serde_json::to_string(&groups).unwrap_or_default()); + params.append_pair( + "facets", + &serde_json::to_string(&groups).unwrap_or_default(), + ); } if let Some(sort) = filters.sort { @@ -121,8 +134,7 @@ impl PackageProvider for ModrinthProvider { date_modified: DateTime, } - let response: Response = - fetch_json(&ctx.net, Method::GET, url.as_str(), None).await?; + let response: Response = fetch_json(&ctx.net, Method::GET, url.as_str(), None).await?; Ok(Page { offset: response.offset, @@ -162,8 +174,7 @@ impl PackageProvider for ModrinthProvider { ) -> ContentResult { let project_url = v2(&format!("/project/{project_id}")); let members_url = v2(&format!("/project/{project_id}/members")); - let project_fut = - fetch_json::(&ctx.net, Method::GET, &project_url, None); + let project_fut = fetch_json::(&ctx.net, Method::GET, &project_url, None); let members_fut = fetch_json::>(&ctx.net, Method::GET, &members_url, None); let (raw, members) = tokio::join!(project_fut, members_fut); @@ -186,8 +197,7 @@ impl PackageProvider for ModrinthProvider { name: String, project_type: String, } - let tags: Vec = - fetch_json(&ctx.net, Method::GET, &v2("/tag/category"), None).await?; + let tags: Vec = fetch_json(&ctx.net, Method::GET, &v2("/tag/category"), None).await?; let want = content_type.modrinth_type(); Ok(tags .into_iter() @@ -345,13 +355,8 @@ impl PackageProvider for ModrinthProvider { "algorithm": "sha1" }); - let fetched: HashMap = fetch_json( - &ctx.net, - Method::POST, - &v2("/version_files"), - Some(body), - ) - .await?; + let fetched: HashMap = + fetch_json(&ctx.net, Method::POST, &v2("/version_files"), Some(body)).await?; let mut out = HashMap::new(); for identity in identities { @@ -459,7 +464,10 @@ impl ModrinthMember { } fn apply_modrinth_members(detail: &mut ProjectDetail, members: Vec) { - detail.members = members.into_iter().map(ModrinthMember::into_member).collect(); + detail.members = members + .into_iter() + .map(ModrinthMember::into_member) + .collect(); if detail.author.is_empty() { detail.author = detail .members @@ -518,10 +526,7 @@ impl ModrinthProject { .gallery .into_iter() .map(|g| GalleryImage { - url: g - .raw_url - .filter(|url| !url.is_empty()) - .unwrap_or(g.url), + url: g.raw_url.filter(|url| !url.is_empty()).unwrap_or(g.url), title: g.title.filter(|t| !t.is_empty()), }) .collect(), @@ -685,7 +690,6 @@ fn parse_release_type(s: &str) -> ReleaseType { } } - #[cfg(test)] mod tests { use super::*; diff --git a/packages/oneclient_content/src/packages/provider/registry.rs b/packages/oneclient_content/src/packages/provider/registry.rs index f7b2cfa7..058568aa 100644 --- a/packages/oneclient_content/src/packages/provider/registry.rs +++ b/packages/oneclient_content/src/packages/provider/registry.rs @@ -1,15 +1,14 @@ use std::collections::HashMap; use std::sync::Arc; - use super::{CurseForgeProvider, ModrinthProvider, PackageProvider}; +use crate::ctx::ContentCtx; use crate::error::ContentResult; -use oneclient_common::domain::ProviderId; use crate::packages::error::PackageError; use crate::packages::file_identity::FileIdentity; use crate::packages::store::artifact_absolute_path; use crate::packages::types::{ProviderVersionLookup, VersionDetail}; -use crate::ctx::ContentCtx; +use oneclient_common::domain::ProviderId; #[derive(Clone)] pub struct PackageProviderRegistry { diff --git a/packages/oneclient_content/src/packages/store/download.rs b/packages/oneclient_content/src/packages/store/download.rs index 6c12290a..8c61ebac 100644 --- a/packages/oneclient_content/src/packages/store/download.rs +++ b/packages/oneclient_content/src/packages/store/download.rs @@ -2,12 +2,12 @@ use oneclient_db::dao::artifact as artifact_dao; use oneclient_db::models::ArtifactRow; use super::paths::{artifact_absolute_path, cache_file_path, relative_cache_path}; +use crate::ctx::ContentCtx; use crate::error::ContentResult; -use polyio::{normalize_hash, sha1_file}; +use crate::packages::types::{ExternalFile, VersionDetail, VersionFile}; use oneclient_common::domain::{ContentType, ProviderId}; use oneclient_events::GroupedProgressChild; -use crate::packages::types::{ExternalFile, VersionDetail, VersionFile}; -use crate::ctx::ContentCtx; +use polyio::{normalize_hash, sha1_file}; #[tracing::instrument(level = "debug", skip(child, ctx))] pub async fn ensure_artifact_file( @@ -31,7 +31,9 @@ pub async fn ensure_artifact_file( dest, Some(&expected), 0, - child.cloned().map(oneclient_net::ResponseNotifyOptions::grouped), + child + .cloned() + .map(oneclient_net::ResponseNotifyOptions::grouped), ) .await?; diff --git a/packages/oneclient_content/src/packages/store/gc.rs b/packages/oneclient_content/src/packages/store/gc.rs index 5a7b78a6..f3b73a76 100644 --- a/packages/oneclient_content/src/packages/store/gc.rs +++ b/packages/oneclient_content/src/packages/store/gc.rs @@ -15,264 +15,270 @@ const RECENT_GRACE: Duration = Duration::from_secs(60 * 60); #[derive(Debug, Default, Clone, Copy)] pub struct GcReport { - pub artifacts_removed: usize, - pub files_removed: usize, - pub bytes_freed: u64, + pub artifacts_removed: usize, + pub files_removed: usize, + pub bytes_freed: u64, } #[tracing::instrument(level = "debug", skip(ctx))] pub async fn evict_if_unused(hash: &str, ctx: &ContentCtx) -> ContentResult { - // Read the row first once it is deleted there is no way back to the file - let row = artifact_dao::get_artifact_by_hash(&ctx.db, hash).await?; + // Read the row first once it is deleted there is no way back to the file + let row = artifact_dao::get_artifact_by_hash(&ctx.db, hash).await?; - if !artifact_dao::delete_artifact_if_unused(&ctx.db, hash).await? { - return Ok(false); - } + if !artifact_dao::delete_artifact_if_unused(&ctx.db, hash).await? { + return Ok(false); + } - if let Some(row) = row - && let Ok(path) = artifact_absolute_path(&row.path) - { - remove_cached_file(&path).await; - } + if let Some(row) = row + && let Ok(path) = artifact_absolute_path(&row.path) + { + remove_cached_file(&path).await; + } - Ok(true) + Ok(true) } #[tracing::instrument(level = "debug", skip(ctx))] pub async fn collect_unused_artifacts(ctx: &ContentCtx) -> ContentResult { - let mut report = GcReport::default(); + let mut report = GcReport::default(); - for row in artifact_dao::list_unused_artifacts(&ctx.db).await? { - let path = artifact_absolute_path(&row.path).ok(); + for row in artifact_dao::list_unused_artifacts(&ctx.db).await? { + let path = artifact_absolute_path(&row.path).ok(); - // An install inserts the row then links it - // in between it looks unused - if let Some(path) = &path - && is_recent(path).await - { - continue; - } + // An install inserts the row then links it + // in between it looks unused + if let Some(path) = &path + && is_recent(path).await + { + continue; + } - if !artifact_dao::delete_artifact_if_unused(&ctx.db, &row.hash).await? { - continue; - } - report.artifacts_removed += 1; + if !artifact_dao::delete_artifact_if_unused(&ctx.db, &row.hash).await? { + continue; + } + report.artifacts_removed += 1; - if let Some(path) = path { - report.bytes_freed += file_len(&path).await; - remove_cached_file(&path).await; - } - } + if let Some(path) = path { + report.bytes_freed += file_len(&path).await; + remove_cached_file(&path).await; + } + } - if report.artifacts_removed > 0 { - tracing::info!( - artifacts = report.artifacts_removed, - mb_freed = report.bytes_freed / 1_000_000, - "reclaimed unused packages" - ); - } + if report.artifacts_removed > 0 { + tracing::info!( + artifacts = report.artifacts_removed, + mb_freed = report.bytes_freed / 1_000_000, + "reclaimed unused packages" + ); + } - Ok(report) + Ok(report) } pub async fn find_unreferenced_files(ctx: &ContentCtx) -> ContentResult> { - let cache_root = paths::packages_cache_dir()?; - if !polyio::try_exists(&cache_root).await.unwrap_or(false) { - return Ok(Vec::new()); - } + let cache_root = paths::packages_cache_dir()?; + if !polyio::try_exists(&cache_root).await.unwrap_or(false) { + return Ok(Vec::new()); + } - let known: HashSet = artifact_dao::list_artifact_paths(&ctx.db) - .await? - .into_iter() - .collect(); + let known: HashSet = artifact_dao::list_artifact_paths(&ctx.db) + .await? + .into_iter() + .collect(); - // An empty artifacts table means a lost index not a cache full of garbage - // the other reading would offer to wipe every package the user has - if known.is_empty() { - tracing::debug!("no artifacts indexed; treating the package cache as fully referenced"); - return Ok(Vec::new()); - } + // An empty artifacts table means a lost index not a cache full of garbage + // the other reading would offer to wipe every package the user has + if known.is_empty() { + tracing::debug!("no artifacts indexed; treating the package cache as fully referenced"); + return Ok(Vec::new()); + } - let mut found = Vec::new(); - for file in walk_files(&cache_root).await { - let Ok(relative) = relative_cache_path(&file) else { - continue; - }; - if known.contains(&relative) || is_recent(&file).await { - continue; - } + let mut found = Vec::new(); + for file in walk_files(&cache_root).await { + let Ok(relative) = relative_cache_path(&file) else { + continue; + }; + if known.contains(&relative) || is_recent(&file).await { + continue; + } - let size = file_len(&file).await; - found.push((file, size)); - } + let size = file_len(&file).await; + found.push((file, size)); + } - Ok(found) + Ok(found) } /// Reasons from the filesystem not the database so it can misread an install whose database was reset #[tracing::instrument(level = "debug", skip(ctx))] pub async fn remove_unreferenced_files(ctx: &ContentCtx) -> ContentResult { - let cache_root = paths::packages_cache_dir()?; - let mut report = GcReport::default(); + let cache_root = paths::packages_cache_dir()?; + let mut report = GcReport::default(); - for (file, size) in find_unreferenced_files(ctx).await? { - if polyio::remove_file(&file).await.is_ok() { - report.files_removed += 1; - report.bytes_freed += size; - prune_empty_parents(&file, &cache_root).await; - } - } + for (file, size) in find_unreferenced_files(ctx).await? { + if polyio::remove_file(&file).await.is_ok() { + report.files_removed += 1; + report.bytes_freed += size; + prune_empty_parents(&file, &cache_root).await; + } + } - if report.files_removed > 0 { - tracing::info!( - files = report.files_removed, - mb_freed = report.bytes_freed / 1_000_000, - "removed unreferenced cache files" - ); - } + if report.files_removed > 0 { + tracing::info!( + files = report.files_removed, + mb_freed = report.bytes_freed / 1_000_000, + "removed unreferenced cache files" + ); + } - Ok(report) + Ok(report) } async fn remove_cached_file(path: &Path) { - if polyio::remove_file(path).await.is_err() { - return; - } + if polyio::remove_file(path).await.is_err() { + return; + } - if let Ok(root) = paths::packages_cache_dir() { - prune_empty_parents(path, &root).await; - } + if let Ok(root) = paths::packages_cache_dir() { + prune_empty_parents(path, &root).await; + } } async fn prune_empty_parents(file: &Path, root: &Path) { - let mut dir = file.parent().map(Path::to_path_buf); + let mut dir = file.parent().map(Path::to_path_buf); - while let Some(current) = dir { - if current == root || !current.starts_with(root) { - return; - } + while let Some(current) = dir { + if current == root || !current.starts_with(root) { + return; + } - // `remove_dir` refuses a non-empty directory which is exactly the stop condition - if polyio::remove_dir(¤t).await.is_err() { - return; - } + // `remove_dir` refuses a non-empty directory which is exactly the stop condition + if polyio::remove_dir(¤t).await.is_err() { + return; + } - dir = current.parent().map(Path::to_path_buf); - } + dir = current.parent().map(Path::to_path_buf); + } } async fn is_recent(path: &Path) -> bool { - let Ok(meta) = polyio::stat(path).await else { - // Cannot tell how old it is so leave it be - return true; - }; + let Ok(meta) = polyio::stat(path).await else { + // Cannot tell how old it is so leave it be + return true; + }; - meta.modified() - .ok() - .and_then(|modified| SystemTime::now().duration_since(modified).ok()) - .is_none_or(|age| age < RECENT_GRACE) + meta.modified() + .ok() + .and_then(|modified| SystemTime::now().duration_since(modified).ok()) + .is_none_or(|age| age < RECENT_GRACE) } async fn file_len(path: &Path) -> u64 { - polyio::stat(path).await.map(|m| m.len()).unwrap_or(0) + polyio::stat(path).await.map(|m| m.len()).unwrap_or(0) } async fn walk_files(root: &Path) -> Vec { - let mut files = Vec::new(); - let mut stack = vec![root.to_path_buf()]; + let mut files = Vec::new(); + let mut stack = vec![root.to_path_buf()]; - while let Some(dir) = stack.pop() { - let Ok(mut entries) = polyio::read_dir(&dir).await else { - continue; - }; + while let Some(dir) = stack.pop() { + let Ok(mut entries) = polyio::read_dir(&dir).await else { + continue; + }; - while let Ok(Some(entry)) = entries.next_entry().await { - let Ok(file_type) = entry.file_type().await else { - continue; - }; + while let Ok(Some(entry)) = entries.next_entry().await { + let Ok(file_type) = entry.file_type().await else { + continue; + }; - if file_type.is_dir() { - stack.push(entry.path()); - } else if file_type.is_file() { - files.push(entry.path()); - } - } - } + if file_type.is_dir() { + stack.push(entry.path()); + } else if file_type.is_file() { + files.push(entry.path()); + } + } + } - files + files } #[cfg(test)] mod tests { - use super::*; + use super::*; - #[tokio::test] - async fn empty_parents_go_but_the_root_stays() { - let root = polyio::testing::ScratchDir::new("gc_prune"); - let cache = root.join("packages"); - let nested = cache.join("mod").join("modrinth").join("proj").join("v1"); - polyio::create_dir_all(&nested).await.unwrap(); + #[tokio::test] + async fn empty_parents_go_but_the_root_stays() { + let root = polyio::testing::ScratchDir::new("gc_prune"); + let cache = root.join("packages"); + let nested = cache.join("mod").join("modrinth").join("proj").join("v1"); + polyio::create_dir_all(&nested).await.unwrap(); - let file = nested.join("a.jar"); - polyio::write(&file, b"jar".as_slice()).await.unwrap(); - polyio::remove_file(&file).await.unwrap(); + let file = nested.join("a.jar"); + polyio::write(&file, b"jar".as_slice()).await.unwrap(); + polyio::remove_file(&file).await.unwrap(); - prune_empty_parents(&file, &cache).await; + prune_empty_parents(&file, &cache).await; - assert!(!cache.join("mod").exists(), "empty chain should be gone"); - assert!(cache.exists(), "the cache root itself always stays"); + assert!(!cache.join("mod").exists(), "empty chain should be gone"); + assert!(cache.exists(), "the cache root itself always stays"); - std::fs::remove_dir_all(root.path()).ok(); - } + std::fs::remove_dir_all(root.path()).ok(); + } - #[tokio::test] - async fn a_sibling_stops_the_prune() { - let root = polyio::testing::ScratchDir::new("gc_prune_sibling"); - let cache = root.join("packages"); - let version = cache.join("mod").join("modrinth").join("proj").join("v1"); - polyio::create_dir_all(&version).await.unwrap(); + #[tokio::test] + async fn a_sibling_stops_the_prune() { + let root = polyio::testing::ScratchDir::new("gc_prune_sibling"); + let cache = root.join("packages"); + let version = cache.join("mod").join("modrinth").join("proj").join("v1"); + polyio::create_dir_all(&version).await.unwrap(); - let gone = version.join("a.jar"); - polyio::write(&gone, b"jar".as_slice()).await.unwrap(); - polyio::write(version.join("b.jar"), b"jar".as_slice()) - .await - .unwrap(); - polyio::remove_file(&gone).await.unwrap(); + let gone = version.join("a.jar"); + polyio::write(&gone, b"jar".as_slice()).await.unwrap(); + polyio::write(version.join("b.jar"), b"jar".as_slice()) + .await + .unwrap(); + polyio::remove_file(&gone).await.unwrap(); - prune_empty_parents(&gone, &cache).await; + prune_empty_parents(&gone, &cache).await; - assert!(version.exists(), "a directory with another file survives"); + assert!(version.exists(), "a directory with another file survives"); - std::fs::remove_dir_all(root.path()).ok(); - } + std::fs::remove_dir_all(root.path()).ok(); + } - #[tokio::test] - async fn fresh_files_are_spared() { - let root = polyio::testing::ScratchDir::new("gc_recent"); - polyio::create_dir_all(root.path()).await.unwrap(); + #[tokio::test] + async fn fresh_files_are_spared() { + let root = polyio::testing::ScratchDir::new("gc_recent"); + polyio::create_dir_all(root.path()).await.unwrap(); - let file = root.join("downloading.jar"); - polyio::write(&file, b"partial".as_slice()).await.unwrap(); + let file = root.join("downloading.jar"); + polyio::write(&file, b"partial".as_slice()).await.unwrap(); - assert!(is_recent(&file).await, "a just-written file is not garbage"); + assert!(is_recent(&file).await, "a just-written file is not garbage"); - std::fs::remove_dir_all(root.path()).ok(); - } + std::fs::remove_dir_all(root.path()).ok(); + } - #[tokio::test] - async fn walk_finds_files_at_every_depth() { - let root = polyio::testing::ScratchDir::new("gc_walk"); - polyio::create_dir_all(root.join("a").join("b")).await.unwrap(); - polyio::write(root.join("top.jar"), b"1".as_slice()).await.unwrap(); - polyio::write(root.join("a").join("mid.jar"), b"2".as_slice()).await.unwrap(); - polyio::write(root.join("a").join("b").join("deep.jar"), b"3".as_slice()) - .await - .unwrap(); + #[tokio::test] + async fn walk_finds_files_at_every_depth() { + let root = polyio::testing::ScratchDir::new("gc_walk"); + polyio::create_dir_all(root.join("a").join("b")) + .await + .unwrap(); + polyio::write(root.join("top.jar"), b"1".as_slice()) + .await + .unwrap(); + polyio::write(root.join("a").join("mid.jar"), b"2".as_slice()) + .await + .unwrap(); + polyio::write(root.join("a").join("b").join("deep.jar"), b"3".as_slice()) + .await + .unwrap(); - let found = walk_files(root.path()).await; + let found = walk_files(root.path()).await; - assert_eq!(found.len(), 3, "found: {found:?}"); + assert_eq!(found.len(), 3, "found: {found:?}"); - std::fs::remove_dir_all(root.path()).ok(); - } + std::fs::remove_dir_all(root.path()).ok(); + } } diff --git a/packages/oneclient_content/src/packages/store/link.rs b/packages/oneclient_content/src/packages/store/link.rs index d1f1e432..9336c2cd 100644 --- a/packages/oneclient_content/src/packages/store/link.rs +++ b/packages/oneclient_content/src/packages/store/link.rs @@ -3,9 +3,9 @@ use std::path::{Path, PathBuf}; use oneclient_db::models::{ArtifactRow, ClusterRow}; +use crate::error::ContentResult; use oneclient_common::domain::ContentType; use oneclient_common::paths; -use crate::error::ContentResult; use super::manifest; use super::paths::artifact_absolute_path; @@ -15,407 +15,409 @@ const STAGING_SUFFIX: &str = ".oneclient-tmp"; /// What a live add actually did, so a caller can say so instead of promising #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LiveSync { - /// The file is in the running game's folder now - Applied, - /// It could not be put there; it arrives at the next launch instead - Deferred, - /// Nothing to do: this content type never reloads, or no session owns the folder - Skipped, + /// The file is in the running game's folder now + Applied, + /// It could not be put there; it arrives at the next launch instead + Deferred, + /// Nothing to do: this content type never reloads, or no session owns the folder + Skipped, } /// so a rerun that died mid-way leaves atleast one stale file, that next rerun clears anyways fn staging_path(dest: &Path) -> PathBuf { - let mut name = OsString::from("."); - name.push(dest.file_name().unwrap_or_else(|| "entry".as_ref())); - name.push(STAGING_SUFFIX); + let mut name = OsString::from("."); + name.push(dest.file_name().unwrap_or_else(|| "entry".as_ref())); + name.push(STAGING_SUFFIX); - dest.with_file_name(name) + dest.with_file_name(name) } /// A launcher killed between the copy and the rename leaves one of these behind /// and every other pass over these folders skips dotfiles #[tracing::instrument(level = "debug")] pub async fn sweep_staging_files(dir: &Path) { - let Ok(mut entries) = polyio::read_dir(dir).await else { - return; - }; + let Ok(mut entries) = polyio::read_dir(dir).await else { + return; + }; - while let Ok(Some(entry)) = entries.next_entry().await { - let path = entry.path(); - let Some(name) = path.file_name().and_then(|name| name.to_str()) else { - continue; - }; + while let Ok(Some(entry)) = entries.next_entry().await { + let path = entry.path(); + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; - if !name.starts_with('.') || !name.ends_with(STAGING_SUFFIX) { - continue; - } + if !name.starts_with('.') || !name.ends_with(STAGING_SUFFIX) { + continue; + } - if let Err(err) = remove_entry(&path).await { - tracing::debug!(file = name, error = %err, "failed to clear a stale staging file"); - } - } + if let Err(err) = remove_entry(&path).await { + tracing::debug!(file = name, error = %err, "failed to clear a stale staging file"); + } + } } /// written to a staging name and renamed into place so the destination is never missing even for a moment #[tracing::instrument(level = "debug")] pub async fn link_or_copy(src: &Path, dest: &Path) -> ContentResult<()> { - if let Some(parent) = dest.parent() { - polyio::create_dir_all(parent).await?; - } + if let Some(parent) = dest.parent() { + polyio::create_dir_all(parent).await?; + } - let staging = staging_path(dest); - remove_entry(&staging).await?; + let staging = staging_path(dest); + remove_entry(&staging).await?; - if polyio::symlink_file(src, &staging).await.is_err() { - polyio::copy(src, &staging).await?; - } + if polyio::symlink_file(src, &staging).await.is_err() { + polyio::copy(src, &staging).await?; + } - if let Err(err) = polyio::rename(&staging, dest).await { - remove_entry(&staging).await.ok(); - return Err(err.into()); - } + if let Err(err) = polyio::rename(&staging, dest).await { + remove_entry(&staging).await.ok(); + return Err(err.into()); + } - Ok(()) + Ok(()) } pub async fn remove_entry(path: &Path) -> ContentResult<()> { - if polyio::symlink_metadata(path).await.is_err() { - return Ok(()); - } + if polyio::symlink_metadata(path).await.is_err() { + return Ok(()); + } - polyio::remove_file(path).await?; - Ok(()) + polyio::remove_file(path).await?; + Ok(()) } async fn materialized_root( - cluster: &ClusterRow, - content_type: ContentType, + cluster: &ClusterRow, + content_type: ContentType, ) -> Option<(PathBuf, &'static str)> { - if content_type.is_global() { - return paths::shared_minecraft_dir() - .ok() - .map(|dir| (dir, manifest::GLOBAL_MANIFEST_NAME)); - } + if content_type.is_global() { + return paths::shared_minecraft_dir() + .ok() + .map(|dir| (dir, manifest::GLOBAL_MANIFEST_NAME)); + } - if content_type == ContentType::Mod - && let Ok(dir) = paths::cluster_dir(&cluster.folder_name) - && manifest::mods_live_in_cluster(&dir).await - { - return Some((dir, manifest::MODS_MANIFEST_NAME)); - } + if content_type == ContentType::Mod + && let Ok(dir) = paths::cluster_dir(&cluster.folder_name) + && manifest::mods_live_in_cluster(&dir).await + { + return Some((dir, manifest::MODS_MANIFEST_NAME)); + } - paths::cluster_game_dir(&cluster.folder_name) - .ok() - .map(|dir| (dir, manifest::MANIFEST_NAME)) + paths::cluster_game_dir(&cluster.folder_name) + .ok() + .map(|dir| (dir, manifest::MANIFEST_NAME)) } async fn session_owns(cluster: &ClusterRow) -> bool { - let Ok(game_dir) = paths::cluster_game_dir(&cluster.folder_name) else { - return false; - }; + let Ok(game_dir) = paths::cluster_game_dir(&cluster.folder_name) else { + return false; + }; - manifest::load(&game_dir, manifest::MANIFEST_NAME) - .await - .is_some_and(|session| session.cluster_id == cluster.id) + manifest::load(&game_dir, manifest::MANIFEST_NAME) + .await + .is_some_and(|session| session.cluster_id == cluster.id) } #[tracing::instrument(level = "debug", skip(cluster), fields(cluster_id = cluster.id))] pub async fn try_unlink_materialized( - cluster: &ClusterRow, - content_type: ContentType, - file_name: &str, + cluster: &ClusterRow, + content_type: ContentType, + file_name: &str, ) -> LiveSync { - if !content_type.reloads_in_game() && session_owns(cluster).await { - tracing::debug!( - file = file_name, - "file will be moved at next launch due to an active session" - ); - return LiveSync::Deferred; - } + if !content_type.reloads_in_game() && session_owns(cluster).await { + tracing::debug!( + file = file_name, + "file will be moved at next launch due to an active session" + ); + return LiveSync::Deferred; + } - let Some((root, manifest_name)) = materialized_root(cluster, content_type).await else { - return LiveSync::Deferred; - }; + let Some((root, manifest_name)) = materialized_root(cluster, content_type).await else { + return LiveSync::Deferred; + }; - let _guard = manifest::lock().await; + let _guard = manifest::lock().await; - let Some(mut loaded) = manifest::load(&root, manifest_name).await else { - return LiveSync::Skipped; - }; + let Some(mut loaded) = manifest::load(&root, manifest_name).await else { + return LiveSync::Skipped; + }; - let relative = manifest::entry_path(content_type.folder_name(), file_name); - let ours = if content_type.is_global() { - loaded.contains(&relative) - } else { - loaded.owns(cluster.id, &relative) - }; + let relative = manifest::entry_path(content_type.folder_name(), file_name); + let ours = if content_type.is_global() { + loaded.contains(&relative) + } else { + loaded.owns(cluster.id, &relative) + }; - if !ours { - return LiveSync::Skipped; - } + if !ours { + return LiveSync::Skipped; + } - let path = root.join(&relative); - if let Err(err) = remove_entry(&path).await { - tracing::debug!( - file = file_name, - error = %err, - "could not drop package from the game folder now; it goes at the next launch" - ); - return LiveSync::Deferred; - } + let path = root.join(&relative); + if let Err(err) = remove_entry(&path).await { + tracing::debug!( + file = file_name, + error = %err, + "could not drop package from the game folder now; it goes at the next launch" + ); + return LiveSync::Deferred; + } - loaded.entries.retain(|entry| entry.path != relative); - manifest::save(&root, manifest_name, &loaded).await; + loaded.entries.retain(|entry| entry.path != relative); + manifest::save(&root, manifest_name, &loaded).await; - LiveSync::Applied + LiveSync::Applied } #[tracing::instrument(level = "debug", skip(cluster, artifact), fields(cluster_id = cluster.id, hash = %artifact.hash))] pub async fn try_link_materialized( - cluster: &ClusterRow, - artifact: &ArtifactRow, - file_name: &str, + cluster: &ClusterRow, + artifact: &ArtifactRow, + file_name: &str, ) -> LiveSync { - let Some(content_type) = ContentType::from_repr(artifact.content_type as u8) else { - return LiveSync::Skipped; - }; + let Some(content_type) = ContentType::from_repr(artifact.content_type as u8) else { + return LiveSync::Skipped; + }; - if !content_type.reloads_in_game() { - return LiveSync::Skipped; - } + if !content_type.reloads_in_game() { + return LiveSync::Skipped; + } - let Some((root, manifest_name)) = materialized_root(cluster, content_type).await else { - return LiveSync::Deferred; - }; + let Some((root, manifest_name)) = materialized_root(cluster, content_type).await else { + return LiveSync::Deferred; + }; - let Ok(src) = artifact_absolute_path(&artifact.path) else { - return LiveSync::Deferred; - }; + let Ok(src) = artifact_absolute_path(&artifact.path) else { + return LiveSync::Deferred; + }; - if !polyio::try_exists(&src).await.unwrap_or(false) { - tracing::warn!(hash = %artifact.hash, "cached artifact missing; leaving it to the next launch"); - return LiveSync::Deferred; - } + if !polyio::try_exists(&src).await.unwrap_or(false) { + tracing::warn!(hash = %artifact.hash, "cached artifact missing; leaving it to the next launch"); + return LiveSync::Deferred; + } - let _guard = manifest::lock().await; + let _guard = manifest::lock().await; - // No manifest means nothing is playing out of this folder right now - let Some(mut loaded) = manifest::load(&root, manifest_name).await else { - return LiveSync::Skipped; - }; + // No manifest means nothing is playing out of this folder right now + let Some(mut loaded) = manifest::load(&root, manifest_name).await else { + return LiveSync::Skipped; + }; - if !content_type.is_global() && loaded.cluster_id != cluster.id { - return LiveSync::Deferred; - } + if !content_type.is_global() && loaded.cluster_id != cluster.id { + return LiveSync::Deferred; + } - let relative = manifest::entry_path(content_type.folder_name(), file_name); - let dest = root.join(&relative); + let relative = manifest::entry_path(content_type.folder_name(), file_name); + let dest = root.join(&relative); - let ours = if content_type.is_global() { - loaded.contains(&relative) - } else { - loaded.owns(cluster.id, &relative) - }; + let ours = if content_type.is_global() { + loaded.contains(&relative) + } else { + loaded.owns(cluster.id, &relative) + }; - // Same rule as the unlink path: never write over a file we did not place - if !ours && polyio::symlink_metadata(&dest).await.is_ok() { - tracing::debug!( - file = file_name, - "a file we do not own already sits in the game folder; leaving it to the next launch" - ); - return LiveSync::Deferred; - } + // Same rule as the unlink path: never write over a file we did not place + if !ours && polyio::symlink_metadata(&dest).await.is_ok() { + tracing::debug!( + file = file_name, + "a file we do not own already sits in the game folder; leaving it to the next launch" + ); + return LiveSync::Deferred; + } - if let Err(err) = link_or_copy(&src, &dest).await { - tracing::debug!( - file = file_name, - error = %err, - "could not add the pack to the running game; it goes in at the next launch" - ); - return LiveSync::Deferred; - } + if let Err(err) = link_or_copy(&src, &dest).await { + tracing::debug!( + file = file_name, + error = %err, + "could not add the pack to the running game; it goes in at the next launch" + ); + return LiveSync::Deferred; + } - loaded.entries.retain(|entry| entry.path != relative); - loaded.entries.push(manifest::ManifestEntry { - path: relative, - hash: artifact.hash.clone(), - }); - manifest::save(&root, manifest_name, &loaded).await; + loaded.entries.retain(|entry| entry.path != relative); + loaded.entries.push(manifest::ManifestEntry { + path: relative, + hash: artifact.hash.clone(), + }); + manifest::save(&root, manifest_name, &loaded).await; - LiveSync::Applied + LiveSync::Applied } #[cfg(test)] mod tests { - use super::*; + use super::*; - fn cluster() -> ClusterRow { - ClusterRow { - id: 1, - name: "Test".into(), - folder_name: "test".into(), - setting_profile_name: None, - mc_version: "1.21.1".into(), - mc_loader: 0, - stage: 0, - mc_loader_version: None, - created_at: None, - last_played: None, - overall_played: None, - linked_modpack_hash: None, - } - } + fn cluster() -> ClusterRow { + ClusterRow { + id: 1, + name: "Test".into(), + folder_name: "test".into(), + setting_profile_name: None, + mc_version: "1.21.1".into(), + mc_loader: 0, + stage: 0, + mc_loader_version: None, + created_at: None, + last_played: None, + overall_played: None, + linked_modpack_hash: None, + } + } - fn artifact(content_type: ContentType) -> ArtifactRow { - ArtifactRow { - hash: "abc".into(), - content_type: content_type as i64, - path: "packages/whatever".into(), - file_name: "thing".into(), - size_bytes: None, - } - } + fn artifact(content_type: ContentType) -> ArtifactRow { + ArtifactRow { + hash: "abc".into(), + content_type: content_type as i64, + path: "packages/whatever".into(), + file_name: "thing".into(), + size_bytes: None, + } + } - #[tokio::test] - async fn a_mod_is_never_added_to_a_running_game() { - assert_eq!( - try_link_materialized(&cluster(), &artifact(ContentType::Mod), "sodium.jar").await, - LiveSync::Skipped - ); - assert_eq!( - try_link_materialized(&cluster(), &artifact(ContentType::World), "world.zip").await, - LiveSync::Skipped - ); - } + #[tokio::test] + async fn a_mod_is_never_added_to_a_running_game() { + assert_eq!( + try_link_materialized(&cluster(), &artifact(ContentType::Mod), "sodium.jar").await, + LiveSync::Skipped + ); + assert_eq!( + try_link_materialized(&cluster(), &artifact(ContentType::World), "world.zip").await, + LiveSync::Skipped + ); + } - #[tokio::test] - async fn a_stale_staging_file_is_swept() { - let root = polyio::testing::ScratchDir::new("sweep_staging"); - let dir = root.path(); - polyio::create_dir_all(dir).await.unwrap(); + #[tokio::test] + async fn a_stale_staging_file_is_swept() { + let root = polyio::testing::ScratchDir::new("sweep_staging"); + let dir = root.path(); + polyio::create_dir_all(dir).await.unwrap(); - let orphan = staging_path(&dir.join("pack.zip")); - polyio::write(&orphan, b"half a copy".as_slice()).await.unwrap(); - polyio::write(dir.join("keep.zip"), b"pack".as_slice()) - .await - .unwrap(); + let orphan = staging_path(&dir.join("pack.zip")); + polyio::write(&orphan, b"half a copy".as_slice()) + .await + .unwrap(); + polyio::write(dir.join("keep.zip"), b"pack".as_slice()) + .await + .unwrap(); - sweep_staging_files(dir).await; + sweep_staging_files(dir).await; - assert!(polyio::symlink_metadata(&orphan).await.is_err()); - assert!(polyio::symlink_metadata(dir.join("keep.zip")).await.is_ok()); + assert!(polyio::symlink_metadata(&orphan).await.is_err()); + assert!(polyio::symlink_metadata(dir.join("keep.zip")).await.is_ok()); - std::fs::remove_dir_all(root.path()).ok(); - } + std::fs::remove_dir_all(root.path()).ok(); + } - #[cfg(not(windows))] - #[tokio::test] - async fn remove_entry_clears_a_dangling_link() { - let root = polyio::testing::ScratchDir::new("dangling_link"); - let dir = root.path(); - polyio::create_dir_all(dir).await.unwrap(); + #[cfg(not(windows))] + #[tokio::test] + async fn remove_entry_clears_a_dangling_link() { + let root = polyio::testing::ScratchDir::new("dangling_link"); + let dir = root.path(); + polyio::create_dir_all(dir).await.unwrap(); - let target = dir.join("target.jar"); - let link = dir.join("link.jar"); - polyio::write(&target, b"jar".as_slice()).await.unwrap(); - polyio::symlink_file(&target, &link).await.unwrap(); - polyio::remove_file(&target).await.unwrap(); + let target = dir.join("target.jar"); + let link = dir.join("link.jar"); + polyio::write(&target, b"jar".as_slice()).await.unwrap(); + polyio::symlink_file(&target, &link).await.unwrap(); + polyio::remove_file(&target).await.unwrap(); - assert!(!link.exists(), "the link resolves to nothing"); - assert!( - polyio::symlink_metadata(&link).await.is_ok(), - "but the link itself is still there" - ); + assert!(!link.exists(), "the link resolves to nothing"); + assert!( + polyio::symlink_metadata(&link).await.is_ok(), + "but the link itself is still there" + ); - remove_entry(&link).await.unwrap(); - assert!(polyio::symlink_metadata(&link).await.is_err()); + remove_entry(&link).await.unwrap(); + assert!(polyio::symlink_metadata(&link).await.is_err()); - std::fs::remove_dir_all(root.path()).ok(); - } + std::fs::remove_dir_all(root.path()).ok(); + } - #[cfg(windows)] - #[tokio::test] - async fn remove_entry_clears_a_hard_link() { - let root = polyio::testing::ScratchDir::new("hard_link"); - let dir = root.path(); - polyio::create_dir_all(dir).await.unwrap(); + #[cfg(windows)] + #[tokio::test] + async fn remove_entry_clears_a_hard_link() { + let root = polyio::testing::ScratchDir::new("hard_link"); + let dir = root.path(); + polyio::create_dir_all(dir).await.unwrap(); - let target = dir.join("target.jar"); - let link = dir.join("link.jar"); - polyio::write(&target, b"jar".as_slice()).await.unwrap(); - polyio::symlink_file(&target, &link).await.unwrap(); - polyio::remove_file(&target).await.unwrap(); + let target = dir.join("target.jar"); + let link = dir.join("link.jar"); + polyio::write(&target, b"jar".as_slice()).await.unwrap(); + polyio::symlink_file(&target, &link).await.unwrap(); + polyio::remove_file(&target).await.unwrap(); - assert!(link.exists(), "the link is still holding the file"); + assert!(link.exists(), "the link is still holding the file"); - remove_entry(&link).await.unwrap(); - assert!(polyio::symlink_metadata(&link).await.is_err()); + remove_entry(&link).await.unwrap(); + assert!(polyio::symlink_metadata(&link).await.is_err()); - std::fs::remove_dir_all(root.path()).ok(); - } + std::fs::remove_dir_all(root.path()).ok(); + } - #[tokio::test] - async fn replacing_a_pack_leaves_only_the_pack() { - let root = polyio::testing::ScratchDir::new("atomic_replace"); - let dir = root.path(); - polyio::create_dir_all(dir).await.unwrap(); + #[tokio::test] + async fn replacing_a_pack_leaves_only_the_pack() { + let root = polyio::testing::ScratchDir::new("atomic_replace"); + let dir = root.path(); + polyio::create_dir_all(dir).await.unwrap(); - let old = dir.join("old.zip"); - let new = dir.join("new.zip"); - polyio::write(&old, b"old".as_slice()).await.unwrap(); - polyio::write(&new, b"new".as_slice()).await.unwrap(); + let old = dir.join("old.zip"); + let new = dir.join("new.zip"); + polyio::write(&old, b"old".as_slice()).await.unwrap(); + polyio::write(&new, b"new".as_slice()).await.unwrap(); - let packs = dir.join("resourcepacks"); - let dest = packs.join("pack.zip"); + let packs = dir.join("resourcepacks"); + let dest = packs.join("pack.zip"); - link_or_copy(&old, &dest).await.unwrap(); - link_or_copy(&new, &dest).await.unwrap(); + link_or_copy(&old, &dest).await.unwrap(); + link_or_copy(&new, &dest).await.unwrap(); - assert_eq!(polyio::read_to_string(&dest).await.unwrap(), "new"); + assert_eq!(polyio::read_to_string(&dest).await.unwrap(), "new"); - let mut names = Vec::new(); - let mut entries = polyio::read_dir(&packs).await.unwrap(); - while let Ok(Some(entry)) = entries.next_entry().await { - names.push(entry.file_name().to_string_lossy().into_owned()); - } + let mut names = Vec::new(); + let mut entries = polyio::read_dir(&packs).await.unwrap(); + while let Ok(Some(entry)) = entries.next_entry().await { + names.push(entry.file_name().to_string_lossy().into_owned()); + } - assert_eq!(names, vec!["pack.zip".to_string()]); + assert_eq!(names, vec!["pack.zip".to_string()]); - std::fs::remove_dir_all(root.path()).ok(); - } + std::fs::remove_dir_all(root.path()).ok(); + } - /// A crash between the write and the rename must not wedge the next attempt - #[tokio::test] - async fn a_stale_staging_file_is_cleared() { - let root = polyio::testing::ScratchDir::new("stale_staging"); - let dir = root.path(); - polyio::create_dir_all(dir).await.unwrap(); + /// A crash between the write and the rename must not wedge the next attempt + #[tokio::test] + async fn a_stale_staging_file_is_cleared() { + let root = polyio::testing::ScratchDir::new("stale_staging"); + let dir = root.path(); + polyio::create_dir_all(dir).await.unwrap(); - let src = dir.join("src.zip"); - let dest = dir.join("pack.zip"); - polyio::write(&src, b"real".as_slice()).await.unwrap(); - polyio::write(staging_path(&dest), b"junk".as_slice()) - .await - .unwrap(); + let src = dir.join("src.zip"); + let dest = dir.join("pack.zip"); + polyio::write(&src, b"real".as_slice()).await.unwrap(); + polyio::write(staging_path(&dest), b"junk".as_slice()) + .await + .unwrap(); - link_or_copy(&src, &dest).await.unwrap(); + link_or_copy(&src, &dest).await.unwrap(); - assert_eq!(polyio::read_to_string(&dest).await.unwrap(), "real"); - assert!( - polyio::symlink_metadata(staging_path(&dest)).await.is_err(), - "the staging file is consumed by the rename" - ); + assert_eq!(polyio::read_to_string(&dest).await.unwrap(), "real"); + assert!( + polyio::symlink_metadata(staging_path(&dest)).await.is_err(), + "the staging file is consumed by the rename" + ); - std::fs::remove_dir_all(root.path()).ok(); - } + std::fs::remove_dir_all(root.path()).ok(); + } - #[tokio::test] - async fn remove_entry_is_fine_with_nothing_there() { - let root = polyio::testing::ScratchDir::new("remove_missing"); - polyio::create_dir_all(root.path()).await.unwrap(); + #[tokio::test] + async fn remove_entry_is_fine_with_nothing_there() { + let root = polyio::testing::ScratchDir::new("remove_missing"); + polyio::create_dir_all(root.path()).await.unwrap(); - remove_entry(&root.join("never_existed.jar")).await.unwrap(); + remove_entry(&root.join("never_existed.jar")).await.unwrap(); - std::fs::remove_dir_all(root.path()).ok(); - } + std::fs::remove_dir_all(root.path()).ok(); + } } diff --git a/packages/oneclient_content/src/packages/store/manifest.rs b/packages/oneclient_content/src/packages/store/manifest.rs index ec7bbe5e..6970bbaa 100644 --- a/packages/oneclient_content/src/packages/store/manifest.rs +++ b/packages/oneclient_content/src/packages/store/manifest.rs @@ -11,182 +11,186 @@ const MANIFEST_VERSION: u32 = 1; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ManifestEntry { - pub path: String, - pub hash: String, + pub path: String, + pub hash: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MaterializedManifest { - pub version: u32, - pub cluster_id: i64, - pub entries: Vec, + pub version: u32, + pub cluster_id: i64, + pub entries: Vec, } impl MaterializedManifest { - #[must_use] - pub fn new(cluster_id: i64, entries: Vec) -> Self { - Self { - version: MANIFEST_VERSION, - cluster_id, - entries, - } - } + #[must_use] + pub fn new(cluster_id: i64, entries: Vec) -> Self { + Self { + version: MANIFEST_VERSION, + cluster_id, + entries, + } + } - #[must_use] - pub fn paths(&self) -> HashSet<&str> { - self.entries.iter().map(|e| e.path.as_str()).collect() - } + #[must_use] + pub fn paths(&self) -> HashSet<&str> { + self.entries.iter().map(|e| e.path.as_str()).collect() + } - #[must_use] - pub fn contains(&self, relative_path: &str) -> bool { - self.entries.iter().any(|e| e.path == relative_path) - } + #[must_use] + pub fn contains(&self, relative_path: &str) -> bool { + self.entries.iter().any(|e| e.path == relative_path) + } - /// The cluster check is what makes deleting out of the shared game directory safe - #[must_use] - pub fn owns(&self, cluster_id: i64, relative_path: &str) -> bool { - self.cluster_id == cluster_id && self.contains(relative_path) - } + /// The cluster check is what makes deleting out of the shared game directory safe + #[must_use] + pub fn owns(&self, cluster_id: i64, relative_path: &str) -> bool { + self.cluster_id == cluster_id && self.contains(relative_path) + } } #[must_use] pub fn manifest_path(dir: &Path, name: &str) -> PathBuf { - dir.join(name) + dir.join(name) } /// An unparseable manifest reads as `None` not an error /// stashing links as user content is recoverable refusing to launch is not pub async fn load(dir: &Path, name: &str) -> Option { - let raw = polyio::read_to_string(manifest_path(dir, name)).await.ok()?; + let raw = polyio::read_to_string(manifest_path(dir, name)) + .await + .ok()?; - match serde_json::from_str::(&raw) { - Ok(manifest) if manifest.version == MANIFEST_VERSION => Some(manifest), - Ok(manifest) => { - tracing::warn!( - version = manifest.version, - "ignoring materialized manifest written by a different launcher version" - ); - None - } - Err(err) => { - tracing::warn!(error = %err, "materialized manifest is unreadable; ignoring"); - None - } - } + match serde_json::from_str::(&raw) { + Ok(manifest) if manifest.version == MANIFEST_VERSION => Some(manifest), + Ok(manifest) => { + tracing::warn!( + version = manifest.version, + "ignoring materialized manifest written by a different launcher version" + ); + None + } + Err(err) => { + tracing::warn!(error = %err, "materialized manifest is unreadable; ignoring"); + None + } + } } pub async fn save(dir: &Path, name: &str, manifest: &MaterializedManifest) { - let body = match serde_json::to_vec_pretty(manifest) { - Ok(body) => body, - Err(err) => { - tracing::warn!(error = %err, "failed to encode materialized manifest"); - return; - } - }; + let body = match serde_json::to_vec_pretty(manifest) { + Ok(body) => body, + Err(err) => { + tracing::warn!(error = %err, "failed to encode materialized manifest"); + return; + } + }; - if let Err(err) = polyio::write(manifest_path(dir, name), body).await { - tracing::warn!(error = %err, "failed to write materialized manifest"); - } + if let Err(err) = polyio::write(manifest_path(dir, name), body).await { + tracing::warn!(error = %err, "failed to write materialized manifest"); + } } pub async fn clear(dir: &Path, name: &str) { - polyio::remove_file(manifest_path(dir, name)).await.ok(); + polyio::remove_file(manifest_path(dir, name)).await.ok(); } static MANIFEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); pub async fn lock() -> tokio::sync::MutexGuard<'static, ()> { - MANIFEST_LOCK.lock().await + MANIFEST_LOCK.lock().await } #[must_use] pub fn entry_path(content_folder: &str, file_name: &str) -> String { - format!("{content_folder}/{file_name}") + format!("{content_folder}/{file_name}") } // whether this cluster keeps its mods in its own folder rather than in the game directory pub async fn mods_live_in_cluster(cluster_dir: &Path) -> bool { - load(cluster_dir, MODS_MANIFEST_NAME).await.is_some() + load(cluster_dir, MODS_MANIFEST_NAME).await.is_some() } #[cfg(test)] mod tests { - use super::*; + use super::*; - fn manifest() -> MaterializedManifest { - MaterializedManifest::new( - 7, - vec![ManifestEntry { - path: "mods/sodium.jar".into(), - hash: "abc".into(), - }], - ) - } + fn manifest() -> MaterializedManifest { + MaterializedManifest::new( + 7, + vec![ManifestEntry { + path: "mods/sodium.jar".into(), + hash: "abc".into(), + }], + ) + } - #[test] - fn ownership_is_scoped_to_the_cluster_that_wrote_it() { - let manifest = manifest(); + #[test] + fn ownership_is_scoped_to_the_cluster_that_wrote_it() { + let manifest = manifest(); - assert!(manifest.owns(7, "mods/sodium.jar")); - assert!(!manifest.owns(8, "mods/sodium.jar")); - assert!(!manifest.owns(7, "mods/iris.jar")); - } + assert!(manifest.owns(7, "mods/sodium.jar")); + assert!(!manifest.owns(8, "mods/sodium.jar")); + assert!(!manifest.owns(7, "mods/iris.jar")); + } - #[tokio::test] - async fn round_trips_through_the_game_dir() { - let root = polyio::testing::ScratchDir::new("manifest_round_trip"); - let dir = root.path(); - polyio::create_dir_all(dir).await.unwrap(); + #[tokio::test] + async fn round_trips_through_the_game_dir() { + let root = polyio::testing::ScratchDir::new("manifest_round_trip"); + let dir = root.path(); + polyio::create_dir_all(dir).await.unwrap(); - assert!(load(dir, MANIFEST_NAME).await.is_none(), "no manifest yet"); + assert!(load(dir, MANIFEST_NAME).await.is_none(), "no manifest yet"); - save(dir, MANIFEST_NAME, &manifest()).await; - let loaded = load(dir, MANIFEST_NAME).await.expect("manifest should load"); - assert_eq!(loaded.cluster_id, 7); - assert!(loaded.contains("mods/sodium.jar")); + save(dir, MANIFEST_NAME, &manifest()).await; + let loaded = load(dir, MANIFEST_NAME) + .await + .expect("manifest should load"); + assert_eq!(loaded.cluster_id, 7); + assert!(loaded.contains("mods/sodium.jar")); - clear(dir, MANIFEST_NAME).await; - assert!( - load(dir, MANIFEST_NAME).await.is_none(), - "cleared manifest should be gone" - ); + clear(dir, MANIFEST_NAME).await; + assert!( + load(dir, MANIFEST_NAME).await.is_none(), + "cleared manifest should be gone" + ); - std::fs::remove_dir_all(root.path()).ok(); - } + std::fs::remove_dir_all(root.path()).ok(); + } - #[tokio::test] - async fn the_two_manifests_do_not_collide() { - let root = polyio::testing::ScratchDir::new("manifest_two_names"); - let dir = root.path(); - polyio::create_dir_all(dir).await.unwrap(); + #[tokio::test] + async fn the_two_manifests_do_not_collide() { + let root = polyio::testing::ScratchDir::new("manifest_two_names"); + let dir = root.path(); + polyio::create_dir_all(dir).await.unwrap(); - save(dir, MODS_MANIFEST_NAME, &manifest()).await; + save(dir, MODS_MANIFEST_NAME, &manifest()).await; - assert!( - load(dir, MODS_MANIFEST_NAME).await.is_some(), - "the mods manifest is there" - ); - assert!( - load(dir, MANIFEST_NAME).await.is_none(), - "and it is not mistaken for the game-dir one" - ); + assert!( + load(dir, MODS_MANIFEST_NAME).await.is_some(), + "the mods manifest is there" + ); + assert!( + load(dir, MANIFEST_NAME).await.is_none(), + "and it is not mistaken for the game-dir one" + ); - clear(dir, MODS_MANIFEST_NAME).await; + clear(dir, MODS_MANIFEST_NAME).await; - std::fs::remove_dir_all(root.path()).ok(); - } + std::fs::remove_dir_all(root.path()).ok(); + } - #[tokio::test] - async fn garbage_manifest_reads_as_absent() { - let root = polyio::testing::ScratchDir::new("manifest_garbage"); - let dir = root.path(); - polyio::create_dir_all(dir).await.unwrap(); - polyio::write(manifest_path(dir, MANIFEST_NAME), b"not json".as_slice()) - .await - .unwrap(); + #[tokio::test] + async fn garbage_manifest_reads_as_absent() { + let root = polyio::testing::ScratchDir::new("manifest_garbage"); + let dir = root.path(); + polyio::create_dir_all(dir).await.unwrap(); + polyio::write(manifest_path(dir, MANIFEST_NAME), b"not json".as_slice()) + .await + .unwrap(); - assert!(load(dir, MANIFEST_NAME).await.is_none()); + assert!(load(dir, MANIFEST_NAME).await.is_none()); - std::fs::remove_dir_all(root.path()).ok(); - } + std::fs::remove_dir_all(root.path()).ok(); + } } diff --git a/packages/oneclient_content/src/packages/store/mod.rs b/packages/oneclient_content/src/packages/store/mod.rs index 35f58a1f..0b4a6a06 100644 --- a/packages/oneclient_content/src/packages/store/mod.rs +++ b/packages/oneclient_content/src/packages/store/mod.rs @@ -23,15 +23,17 @@ use oneclient_db::models::{ArtifactRow, ClusterRow, SeenStatus}; use oneclient_common::domain::{ContentType, GameLoader, ProviderId}; // `paths` alone is this module's own cache-path helpers -use oneclient_common::paths as common_paths; use super::error::PackageError; use super::file_identity::FileIdentity; +use super::types::{ + CachedArtifact, LinkedArtifactInfo, ProjectDetail, ProviderReleaseInfo, VersionDetail, +}; use super::{local_manifest, metadata_cache}; -use super::types::{CachedArtifact, ProjectDetail, ProviderReleaseInfo, VersionDetail, LinkedArtifactInfo}; -use polyio::{normalize_hash, sha1_file}; -use oneclient_events::GroupedProgressChild; use crate::ctx::ContentCtx; use crate::error::{ContentError, ContentResult}; +use oneclient_common::paths as common_paths; +use oneclient_events::GroupedProgressChild; +use polyio::{normalize_hash, sha1_file}; use std::path::{Path, PathBuf}; pub struct PackageStore; @@ -46,10 +48,7 @@ pub struct LocalImportReport { impl PackageStore { #[tracing::instrument(level = "debug", skip(ctx))] - pub async fn get_cluster( - cluster_id: i64, - ctx: &ContentCtx, - ) -> ContentResult { + pub async fn get_cluster(cluster_id: i64, ctx: &ContentCtx) -> ContentResult { cluster_dao::get_by_id(&ctx.db, cluster_id) .await? .ok_or(PackageError::ClusterNotFound(cluster_id).into()) @@ -91,7 +90,7 @@ impl PackageStore { .await } - #[allow(clippy::too_many_arguments)] + #[allow(clippy::too_many_arguments)] #[tracing::instrument(skip(project, version, child, ctx), fields(project_id = %project.id, version_id = %version.version_id))] pub async fn install_to_cluster( provider_id: ProviderId, @@ -110,15 +109,9 @@ impl PackageStore { ensure_compatible(project, version, &cluster)?; } - let artifact = Self::download_and_cache( - provider_id, - project, - version, - force_download, - child, - ctx, - ) - .await?; + let artifact = + Self::download_and_cache(provider_id, project, version, force_download, child, ctx) + .await?; let enabled = Self::link_artifact(&artifact, &cluster, None, ctx).await?; @@ -181,11 +174,7 @@ impl PackageStore { /// `packages` must not depend on `bundles` and its override reconciliation /// does not apply here #[tracing::instrument(level = "debug", skip(ctx))] - async fn unlink_superseded( - cluster_id: i64, - hash: &str, - ctx: &ContentCtx, - ) -> ContentResult<()> { + async fn unlink_superseded(cluster_id: i64, hash: &str, ctx: &ContentCtx) -> ContentResult<()> { let cluster = Self::get_cluster(cluster_id, ctx).await?; let content_type = artifact_dao::get_artifact_by_hash(&ctx.db, hash) .await? @@ -340,9 +329,11 @@ impl PackageStore { .await? .ok_or(PackageError::ArtifactMissing(hash.to_string()))?; - let content_type = ContentType::from_repr(artifact.content_type as u8) - .ok_or_else(|| ContentError::InvalidData { - reason: format!("unknown content type {}", artifact.content_type), + let content_type = + ContentType::from_repr(artifact.content_type as u8).ok_or_else(|| { + ContentError::InvalidData { + reason: format!("unknown content type {}", artifact.content_type), + } })?; let enabled = target.unwrap_or(link.enabled == 0); @@ -437,9 +428,7 @@ impl PackageStore { ) -> ContentResult { let provider = ctx.providers.get(provider_id)?; - let version = provider - .get_version(project_id, version_id, ctx) - .await?; + let version = provider.get_version(project_id, version_id, ctx).await?; let project = provider.get_project(project_id, ctx).await?; @@ -657,10 +646,7 @@ async fn store_local_icon(hash: &str, jar: &std::path::Path, entry: &str) -> Opt } #[tracing::instrument(level = "debug", skip(row, ctx), fields(hash = %row.hash))] -async fn row_to_cached( - row: ArtifactRow, - ctx: &ContentCtx, -) -> ContentResult { +async fn row_to_cached(row: ArtifactRow, ctx: &ContentCtx) -> ContentResult { let path = artifact_absolute_path(&row.path)?; let release = artifact_dao::get_release_by_hash(&ctx.db, &row.hash) .await? diff --git a/packages/oneclient_content/src/packages/types.rs b/packages/oneclient_content/src/packages/types.rs index d1cc044b..a0d647f7 100644 --- a/packages/oneclient_content/src/packages/types.rs +++ b/packages/oneclient_content/src/packages/types.rs @@ -11,219 +11,224 @@ pub const DEFAULT_PAGE_SIZE: usize = 24; #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct SearchFilters { - pub query: Option, - pub offset: Option, - pub limit: Option, - pub content_type: Option, - pub game_versions: Option>, - pub loaders: Option>, - pub categories: Option>, - pub sort: Option, + pub query: Option, + pub offset: Option, + pub limit: Option, + pub content_type: Option, + pub game_versions: Option>, + pub loaders: Option>, + pub categories: Option>, + pub sort: Option, } impl SearchFilters { - /// Providers rank the raw string so " sodium " and "sodium" score differently - #[must_use] - pub fn normalized_query(&self) -> String { - self.query.as_deref().map(normalize_query).unwrap_or_default() - } + /// Providers rank the raw string so " sodium " and "sodium" score differently + #[must_use] + pub fn normalized_query(&self) -> String { + self.query + .as_deref() + .map(normalize_query) + .unwrap_or_default() + } } #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum SearchSort { - #[default] - Relevance, - Downloads, - Newest, - Updated, + #[default] + Relevance, + Downloads, + Newest, + Updated, } impl SearchSort { - pub fn modrinth_index(self) -> &'static str { - match self { - Self::Relevance => "relevance", - Self::Downloads => "downloads", - Self::Newest => "newest", - Self::Updated => "updated", - } - } + pub fn modrinth_index(self) -> &'static str { + match self { + Self::Relevance => "relevance", + Self::Downloads => "downloads", + Self::Newest => "newest", + Self::Updated => "updated", + } + } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Page { - pub offset: usize, - pub limit: usize, - pub total: usize, - pub items: Vec, + pub offset: usize, + pub limit: usize, + pub total: usize, + pub items: Vec, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ProjectSummary { - pub id: String, - pub slug: String, - pub provider: ProviderId, - pub content_type: ContentType, - pub name: String, - pub summary: String, - pub author: String, - pub icon_url: Option, - pub downloads: u64, - pub created: DateTime, - pub updated: DateTime, - pub loaders: Vec, - pub game_versions: Vec, + pub id: String, + pub slug: String, + pub provider: ProviderId, + pub content_type: ContentType, + pub name: String, + pub summary: String, + pub author: String, + pub icon_url: Option, + pub downloads: u64, + pub created: DateTime, + pub updated: DateTime, + pub loaders: Vec, + pub game_versions: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub enum PackageBody { - Url(String), - Raw(String), + Url(String), + Raw(String), } impl PackageBody { - pub fn raw(markdown: impl AsRef) -> Self { - Self::Raw(crate::packages::markdown::normalize_markdown(markdown.as_ref())) - } + pub fn raw(markdown: impl AsRef) -> Self { + Self::Raw(crate::packages::markdown::normalize_markdown( + markdown.as_ref(), + )) + } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProjectMember { - pub name: String, - pub role: String, - pub url: Option, - pub avatar_url: Option, + pub name: String, + pub role: String, + pub url: Option, + pub avatar_url: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct GalleryImage { - pub url: String, - pub title: Option, + pub url: String, + pub title: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProjectDetail { - pub id: String, - pub slug: String, - pub provider: ProviderId, - pub content_type: ContentType, - pub name: String, - pub summary: String, - pub author: String, - pub members: Vec, - pub gallery: Vec, - pub body: PackageBody, - pub license: Option, - pub links: Vec<(String, String)>, - pub version_ids: Vec, - pub game_versions: Vec, - pub loaders: Vec, - pub icon_url: Option, - pub created: DateTime, - pub updated: DateTime, - pub downloads: u64, + pub id: String, + pub slug: String, + pub provider: ProviderId, + pub content_type: ContentType, + pub name: String, + pub summary: String, + pub author: String, + pub members: Vec, + pub gallery: Vec, + pub body: PackageBody, + pub license: Option, + pub links: Vec<(String, String)>, + pub version_ids: Vec, + pub game_versions: Vec, + pub loaders: Vec, + pub icon_url: Option, + pub created: DateTime, + pub updated: DateTime, + pub downloads: u64, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct VersionSummary { - pub version_id: String, - pub project_id: String, - pub name: String, - pub version_number: String, - pub published: DateTime, - pub release_type: ReleaseType, - pub game_versions: Vec, - pub loaders: Vec, - pub downloads: u64, - pub file_size: u64, + pub version_id: String, + pub project_id: String, + pub name: String, + pub version_number: String, + pub published: DateTime, + pub release_type: ReleaseType, + pub game_versions: Vec, + pub loaders: Vec, + pub downloads: u64, + pub file_size: u64, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum ReleaseType { - #[default] - Release, - Beta, - Alpha, + #[default] + Release, + Beta, + Alpha, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct VersionDetail { - pub version_id: String, - pub project_id: String, - pub name: String, - pub version_number: String, - pub changelog: Option, - pub game_versions: Vec, - pub loaders: Vec, - pub published: DateTime, - pub downloads: u64, - pub files: Vec, - /// Only [`DependencyKind::Required`] entries are pulled in automatically - #[serde(default)] - pub dependencies: Vec, + pub version_id: String, + pub project_id: String, + pub name: String, + pub version_number: String, + pub changelog: Option, + pub game_versions: Vec, + pub loaders: Vec, + pub published: DateTime, + pub downloads: u64, + pub files: Vec, + /// Only [`DependencyKind::Required`] entries are pulled in automatically + #[serde(default)] + pub dependencies: Vec, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum DependencyKind { - Required, - Optional, - Incompatible, - Embedded, + Required, + Optional, + Incompatible, + Embedded, } /// At least one id is set Modrinth may pin a version CurseForge only names the project #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct VersionDependency { - pub project_id: Option, - pub version_id: Option, - pub kind: DependencyKind, + pub project_id: Option, + pub version_id: Option, + pub kind: DependencyKind, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct VersionFile { - pub sha1: String, - pub url: String, - pub file_name: String, - pub primary: bool, - pub size: u64, - pub fingerprint: Option, + pub sha1: String, + pub url: String, + pub file_name: String, + pub primary: bool, + pub size: u64, + pub fingerprint: Option, } impl VersionDetail { - pub fn primary_file(&self) -> Option<&VersionFile> { - self.files.iter().find(|f| f.primary).or(self.files.first()) - } + pub fn primary_file(&self) -> Option<&VersionFile> { + self.files.iter().find(|f| f.primary).or(self.files.first()) + } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ExternalFile { - pub name: String, - pub url: String, - pub sha1: String, - pub size: u64, - pub content_type: ContentType, + pub name: String, + pub url: String, + pub sha1: String, + pub size: u64, + pub content_type: ContentType, } #[derive(Debug, Clone)] pub struct CachedArtifact { - pub hash: String, - pub content_type: ContentType, - pub path: std::path::PathBuf, - pub file_name: String, - pub size_bytes: Option, - pub release: Option, + pub hash: String, + pub content_type: ContentType, + pub path: std::path::PathBuf, + pub file_name: String, + pub size_bytes: Option, + pub release: Option, } #[derive(Debug, Clone)] pub struct ProviderReleaseInfo { - pub provider: ProviderId, - pub project_id: String, - pub version_id: String, - pub display_name: String, - pub display_version: String, - pub mc_versions: Vec, - pub loaders: Vec, + pub provider: ProviderId, + pub project_id: String, + pub version_id: String, + pub display_name: String, + pub display_version: String, + pub mc_versions: Vec, + pub loaders: Vec, } pub type VersionLookup = HashMap; @@ -231,18 +236,18 @@ pub type ProviderVersionLookup = HashMap; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LinkedArtifactInfo { - pub hash: String, - pub cluster_file_name: String, - pub enabled: bool, - pub content_type: ContentType, - pub file_name: String, - pub project_id: Option, - pub version_id: Option, - pub display_name: Option, - pub display_version: Option, - pub provider: Option, - /// Provider publish time RFC 3339 - pub published_at: Option, - /// Drives the new/updated badge in the package manager - pub seen_status: SeenStatus, + pub hash: String, + pub cluster_file_name: String, + pub enabled: bool, + pub content_type: ContentType, + pub file_name: String, + pub project_id: Option, + pub version_id: Option, + pub display_name: Option, + pub display_version: Option, + pub provider: Option, + /// Provider publish time RFC 3339 + pub published_at: Option, + /// Drives the new/updated badge in the package manager + pub seen_status: SeenStatus, } diff --git a/packages/oneclient_content/src/packages/updates.rs b/packages/oneclient_content/src/packages/updates.rs index 771913f1..e86ec3f3 100644 --- a/packages/oneclient_content/src/packages/updates.rs +++ b/packages/oneclient_content/src/packages/updates.rs @@ -29,281 +29,287 @@ const UPDATE_CHECK_CONCURRENCY: usize = 6; #[derive(Debug, Clone, PartialEq, Eq)] pub struct BrowserPackageUpdate { - pub cluster_id: i64, - /// The artifact currently linked into the cluster - pub hash: String, - pub provider: ProviderId, - pub project_id: String, - pub installed_version_id: String, - pub installed_version_name: String, - pub latest_version_id: String, - pub latest_version_name: String, - /// Local fallback for a UI with no meta cache entry - pub display_name: String, - /// The user has already declined this exact newer version - pub skipped: bool, + pub cluster_id: i64, + /// The artifact currently linked into the cluster + pub hash: String, + pub provider: ProviderId, + pub project_id: String, + pub installed_version_id: String, + pub installed_version_name: String, + pub latest_version_id: String, + pub latest_version_name: String, + /// Local fallback for a UI with no meta cache entry + pub display_name: String, + /// The user has already declined this exact newer version + pub skipped: bool, } impl BrowserPackageUpdate { - fn from_row(row: BrowserPackageUpdateRow) -> Self { - Self { - cluster_id: row.cluster_id, - hash: row.hash, - provider: ProviderId::from_repr(row.provider as u8).unwrap_or(ProviderId::Modrinth), - project_id: row.project_id, - installed_version_id: row.installed_version_id, - installed_version_name: row.installed_version_name, - latest_version_id: row.latest_version_id, - latest_version_name: row.latest_version_name, - display_name: row.display_name, - skipped: row.skipped != 0, - } - } + fn from_row(row: BrowserPackageUpdateRow) -> Self { + Self { + cluster_id: row.cluster_id, + hash: row.hash, + provider: ProviderId::from_repr(row.provider as u8).unwrap_or(ProviderId::Modrinth), + project_id: row.project_id, + installed_version_id: row.installed_version_id, + installed_version_name: row.installed_version_name, + latest_version_id: row.latest_version_id, + latest_version_name: row.latest_version_name, + display_name: row.display_name, + skipped: row.skipped != 0, + } + } } #[derive(Debug, Clone, Default)] pub struct BrowserUpdateCheck { - pub cluster_id: i64, - pub updates: Vec, - /// Unreachable this run - /// their cached rows are preserved not cleared - pub unchecked: Vec, + pub cluster_id: i64, + pub updates: Vec, + /// Unreachable this run + /// their cached rows are preserved not cleared + pub unchecked: Vec, } impl BrowserUpdateCheck { - #[must_use] - pub fn pending(&self) -> Vec { - self.updates.iter().filter(|u| !u.skipped).cloned().collect() - } + #[must_use] + pub fn pending(&self) -> Vec { + self.updates + .iter() + .filter(|u| !u.skipped) + .cloned() + .collect() + } } struct Candidate { - hash: String, - provider: ProviderId, - project_id: String, - version_id: String, - display_name: String, - display_version: String, - published_at: Option>, + hash: String, + provider: ProviderId, + project_id: String, + version_id: String, + display_name: String, + display_version: String, + published_at: Option>, } /// A missing bundle tracking row is the marker for "the user added this themselves" fn browser_installed( - linked: &[LinkedArtifactInfo], - bundle_hashes: &HashSet, - bundle_projects: &HashSet, + linked: &[LinkedArtifactInfo], + bundle_hashes: &HashSet, + bundle_projects: &HashSet, ) -> Vec { - linked - .iter() - .filter(|info| info.enabled) - .filter(|info| !bundle_hashes.contains(&info.hash)) - .filter(|info| { - info.project_id - .as_ref() - .is_none_or(|id| !bundle_projects.contains(id)) - }) - .filter_map(|info| { - let provider = info.provider?; - if provider == ProviderId::Local { - return None; - } - Some(Candidate { - hash: info.hash.clone(), - provider, - project_id: info.project_id.clone()?, - version_id: info.version_id.clone()?, - display_name: info - .display_name - .clone() - .unwrap_or_else(|| info.file_name.clone()), - display_version: info.display_version.clone().unwrap_or_default(), - published_at: info.published_at.as_deref().and_then(parse_published), - }) - }) - .collect() + linked + .iter() + .filter(|info| info.enabled) + .filter(|info| !bundle_hashes.contains(&info.hash)) + .filter(|info| { + info.project_id + .as_ref() + .is_none_or(|id| !bundle_projects.contains(id)) + }) + .filter_map(|info| { + let provider = info.provider?; + if provider == ProviderId::Local { + return None; + } + Some(Candidate { + hash: info.hash.clone(), + provider, + project_id: info.project_id.clone()?, + version_id: info.version_id.clone()?, + display_name: info + .display_name + .clone() + .unwrap_or_else(|| info.file_name.clone()), + display_version: info.display_version.clone().unwrap_or_default(), + published_at: info.published_at.as_deref().and_then(parse_published), + }) + }) + .collect() } /// An unreachable project lands in [`BrowserUpdateCheck::unchecked`] /// only an unreadable cluster errors #[tracing::instrument(level = "debug", skip(ctx))] pub async fn check_browser_package_updates( - cluster_id: i64, - ctx: &ContentCtx, + cluster_id: i64, + ctx: &ContentCtx, ) -> ContentResult { - let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; - let linked = PackageStore::list_linked_artifacts(cluster_id, ctx).await?; + let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; + let linked = PackageStore::list_linked_artifacts(cluster_id, ctx).await?; - let tracked = bundle_dao::list_bundle_tracked(&ctx.db, cluster_id).await?; - let bundle_projects: HashSet = - tracked.iter().filter_map(|row| row.package_id.clone()).collect(); - let bundle_hashes: HashSet = tracked.into_iter().map(|row| row.hash).collect(); + let tracked = bundle_dao::list_bundle_tracked(&ctx.db, cluster_id).await?; + let bundle_projects: HashSet = tracked + .iter() + .filter_map(|row| row.package_id.clone()) + .collect(); + let bundle_hashes: HashSet = tracked.into_iter().map(|row| row.hash).collect(); - let candidates = browser_installed(&linked, &bundle_hashes, &bundle_projects); - if candidates.is_empty() { - return Ok(BrowserUpdateCheck { - cluster_id, - ..Default::default() - }); - } + let candidates = browser_installed(&linked, &bundle_hashes, &bundle_projects); + if candidates.is_empty() { + return Ok(BrowserUpdateCheck { + cluster_id, + ..Default::default() + }); + } - let skipped: HashSet = update_dao::list_for_cluster(&ctx.db, cluster_id) - .await? - .into_iter() - .filter(|row| row.skipped != 0) - .map(|row| format!("{}:{}", row.hash, row.latest_version_id)) - .collect(); + let skipped: HashSet = update_dao::list_for_cluster(&ctx.db, cluster_id) + .await? + .into_iter() + .filter(|row| row.skipped != 0) + .map(|row| format!("{}:{}", row.hash, row.latest_version_id)) + .collect(); - let results = futures_util::stream::iter(candidates.into_iter().map(|candidate| { - let cluster = &cluster; - async move { - let outcome = latest_for(&candidate, cluster, ctx).await; - (candidate, outcome) - } - })) - .buffer_unordered(UPDATE_CHECK_CONCURRENCY) - .collect::>() - .await; + let results = futures_util::stream::iter(candidates.into_iter().map(|candidate| { + let cluster = &cluster; + async move { + let outcome = latest_for(&candidate, cluster, ctx).await; + (candidate, outcome) + } + })) + .buffer_unordered(UPDATE_CHECK_CONCURRENCY) + .collect::>() + .await; - let mut check = BrowserUpdateCheck { - cluster_id, - ..Default::default() - }; + let mut check = BrowserUpdateCheck { + cluster_id, + ..Default::default() + }; - for (candidate, outcome) in results { - match outcome { - Ok(Some((latest_version_id, latest_version_name))) => { - let key = format!("{}:{latest_version_id}", candidate.hash); - check.updates.push(BrowserPackageUpdate { - cluster_id, - hash: candidate.hash, - provider: candidate.provider, - project_id: candidate.project_id, - installed_version_id: candidate.version_id, - installed_version_name: candidate.display_version, - latest_version_id, - latest_version_name, - display_name: candidate.display_name, - skipped: skipped.contains(&key), - }); - } - Ok(None) => {} - Err(err) => { - // Offline is the common case and says nothing about the launcher - if err.is_transient() { - tracing::debug!( - project_id = %candidate.project_id, - error = %err, - "update check skipped, provider unreachable" - ); - } else { - tracing::warn!( - project_id = %candidate.project_id, - error = %err, - "update check failed for package" - ); - } - check.unchecked.push(candidate.hash); - } - } - } + for (candidate, outcome) in results { + match outcome { + Ok(Some((latest_version_id, latest_version_name))) => { + let key = format!("{}:{latest_version_id}", candidate.hash); + check.updates.push(BrowserPackageUpdate { + cluster_id, + hash: candidate.hash, + provider: candidate.provider, + project_id: candidate.project_id, + installed_version_id: candidate.version_id, + installed_version_name: candidate.display_version, + latest_version_id, + latest_version_name, + display_name: candidate.display_name, + skipped: skipped.contains(&key), + }); + } + Ok(None) => {} + Err(err) => { + // Offline is the common case and says nothing about the launcher + if err.is_transient() { + tracing::debug!( + project_id = %candidate.project_id, + error = %err, + "update check skipped, provider unreachable" + ); + } else { + tracing::warn!( + project_id = %candidate.project_id, + error = %err, + "update check failed for package" + ); + } + check.unchecked.push(candidate.hash); + } + } + } - Ok(check) + Ok(check) } fn parse_published(raw: &str) -> Option> { - DateTime::parse_from_rfc3339(raw) - .ok() - .map(|at| at.with_timezone(&Utc)) + DateTime::parse_from_rfc3339(raw) + .ok() + .map(|at| at.with_timezone(&Utc)) } fn is_upgrade(installed_published: Option>, latest_published: DateTime) -> bool { - installed_published.is_none_or(|installed| latest_published > installed) + installed_published.is_none_or(|installed| latest_published > installed) } async fn latest_for( - candidate: &Candidate, - cluster: &ClusterRow, - ctx: &ContentCtx, + candidate: &Candidate, + cluster: &ClusterRow, + ctx: &ContentCtx, ) -> ContentResult> { - let provider = ctx.providers.get(candidate.provider)?; - let Some(latest) = pick_version(provider, &candidate.project_id, cluster, ctx).await? else { - return Ok(None); - }; + let provider = ctx.providers.get(candidate.provider)?; + let Some(latest) = pick_version(provider, &candidate.project_id, cluster, ctx).await? else { + return Ok(None); + }; - if latest.version_id == candidate.version_id { - return Ok(None); - } + if latest.version_id == candidate.version_id { + return Ok(None); + } - if !is_upgrade(candidate.published_at, latest.published) { - tracing::debug!( - project_id = %candidate.project_id, - installed = %candidate.version_id, - offered = %latest.version_id, - "update not offered, the installed version is not older than the pick" - ); - return Ok(None); - } + if !is_upgrade(candidate.published_at, latest.published) { + tracing::debug!( + project_id = %candidate.project_id, + installed = %candidate.version_id, + offered = %latest.version_id, + "update not offered, the installed version is not older than the pick" + ); + return Ok(None); + } - let name = if latest.version_number.is_empty() { - latest.name - } else { - latest.version_number - }; + let name = if latest.version_number.is_empty() { + latest.name + } else { + latest.version_number + }; - Ok(Some((latest.version_id, name))) + Ok(Some((latest.version_id, name))) } /// Unreported rows are dropped except unreachable ones which keep their last /// known state #[tracing::instrument(level = "debug", skip(ctx))] pub async fn refresh_browser_package_updates( - cluster_id: i64, - ctx: &ContentCtx, + cluster_id: i64, + ctx: &ContentCtx, ) -> ContentResult { - let check = check_browser_package_updates(cluster_id, ctx).await?; + let check = check_browser_package_updates(cluster_id, ctx).await?; - let mut keep: Vec = check.unchecked.clone(); - let mut stored = Vec::with_capacity(check.updates.len()); + let mut keep: Vec = check.unchecked.clone(); + let mut stored = Vec::with_capacity(check.updates.len()); - for update in &check.updates { - let row = update_dao::upsert( - &ctx.db, - cluster_id, - &update.hash, - update.provider as i64, - &update.project_id, - &update.installed_version_id, - &update.installed_version_name, - &update.latest_version_id, - &update.latest_version_name, - &update.display_name, - ) - .await?; - keep.push(row.hash.clone()); - // The row is the authority on `skipped` for this exact version - stored.push(BrowserPackageUpdate::from_row(row)); - } + for update in &check.updates { + let row = update_dao::upsert( + &ctx.db, + cluster_id, + &update.hash, + update.provider as i64, + &update.project_id, + &update.installed_version_id, + &update.installed_version_name, + &update.latest_version_id, + &update.latest_version_name, + &update.display_name, + ) + .await?; + keep.push(row.hash.clone()); + // The row is the authority on `skipped` for this exact version + stored.push(BrowserPackageUpdate::from_row(row)); + } - update_dao::retain_hashes(&ctx.db, cluster_id, &keep).await?; + update_dao::retain_hashes(&ctx.db, cluster_id, &keep).await?; - Ok(BrowserUpdateCheck { - cluster_id, - updates: stored, - unchecked: check.unchecked, - }) + Ok(BrowserUpdateCheck { + cluster_id, + updates: stored, + unchecked: check.unchecked, + }) } /// Never touches the network #[tracing::instrument(level = "debug", skip(ctx))] pub async fn cached_browser_package_updates( - cluster_id: i64, - ctx: &ContentCtx, + cluster_id: i64, + ctx: &ContentCtx, ) -> ContentResult> { - Ok(update_dao::list_for_cluster(&ctx.db, cluster_id) - .await? - .into_iter() - .map(BrowserPackageUpdate::from_row) - .collect()) + Ok(update_dao::list_for_cluster(&ctx.db, cluster_id) + .await? + .into_iter() + .map(BrowserPackageUpdate::from_row) + .collect()) } /// The row stays and the package still reads as out of date @@ -311,305 +317,309 @@ pub async fn cached_browser_package_updates( /// appears #[tracing::instrument(level = "debug", skip(ctx))] pub async fn skip_browser_package_update( - cluster_id: i64, - hash: &str, - ctx: &ContentCtx, + cluster_id: i64, + hash: &str, + ctx: &ContentCtx, ) -> ContentResult<()> { - update_dao::set_skipped(&ctx.db, cluster_id, hash, true).await?; - Ok(()) + update_dao::set_skipped(&ctx.db, cluster_id, hash, true).await?; + Ok(()) } /// Download first unlink second so a failed download leaves the cluster as it was #[tracing::instrument(level = "debug", skip(update, child, ctx), fields(cluster_id = update.cluster_id, project_id = %update.project_id))] pub async fn apply_browser_package_update( - update: &BrowserPackageUpdate, - child: Option<&GroupedProgressChild>, - ctx: &ContentCtx, + update: &BrowserPackageUpdate, + child: Option<&GroupedProgressChild>, + ctx: &ContentCtx, ) -> ContentResult { - // Never touch anything a bundle owns however this was called - if bundle_dao::get_bundle_tracked(&ctx.db, update.cluster_id, &update.hash) - .await? - .is_some() - { - return Err(ContentError::InvalidData { - reason: format!( - "{} is provided by a bundle and is not updated by the browser flow", - update.display_name - ), - }); - } + // Never touch anything a bundle owns however this was called + if bundle_dao::get_bundle_tracked(&ctx.db, update.cluster_id, &update.hash) + .await? + .is_some() + { + return Err(ContentError::InvalidData { + reason: format!( + "{} is provided by a bundle and is not updated by the browser flow", + update.display_name + ), + }); + } - let provider = ctx.providers.get(update.provider)?; - let project = provider.get_project(&update.project_id, ctx).await?; - let version = provider - .get_version(&update.project_id, &update.latest_version_id, ctx) - .await?; + let provider = ctx.providers.get(update.provider)?; + let project = provider.get_project(&update.project_id, ctx).await?; + let version = provider + .get_version(&update.project_id, &update.latest_version_id, ctx) + .await?; - let enabled_before = artifact_dao::get_cluster_artifact(&ctx.db, update.cluster_id, &update.hash) - .await? - .map(|link| link.enabled != 0); + let enabled_before = + artifact_dao::get_cluster_artifact(&ctx.db, update.cluster_id, &update.hash) + .await? + .map(|link| link.enabled != 0); - install_new_dependencies(update, &project, &version, ctx).await; + install_new_dependencies(update, &project, &version, ctx).await; - // Compatibility is not re-checked - // a provider disagreeing at install time would strand the user on a build - // they cannot move off - let (installed, _) = PackageStore::install_to_cluster( - update.provider, - &project, - &version, - update.cluster_id, - true, - false, - child, - ctx, - ) - .await?; + // Compatibility is not re-checked + // a provider disagreeing at install time would strand the user on a build + // they cannot move off + let (installed, _) = PackageStore::install_to_cluster( + update.provider, + &project, + &version, + update.cluster_id, + true, + false, + child, + ctx, + ) + .await?; - // Provider handed back the already-linked file - // leave the link untouched - if installed.hash == update.hash { - update_dao::delete(&ctx.db, update.cluster_id, &update.hash).await?; - return Ok(installed.hash); - } + // Provider handed back the already-linked file + // leave the link untouched + if installed.hash == update.hash { + update_dao::delete(&ctx.db, update.cluster_id, &update.hash).await?; + return Ok(installed.hash); + } - // A package switched off stays off - // the fresh link starts enabled so this is a toggle rather than a set - if enabled_before == Some(false) { - PackageStore::set_artifact_enabled(update.cluster_id, &installed.hash, ctx).await?; - } + // A package switched off stays off + // the fresh link starts enabled so this is a toggle rather than a set + if enabled_before == Some(false) { + PackageStore::set_artifact_enabled(update.cluster_id, &installed.hash, ctx).await?; + } - artifact_dao::set_seen_status( - &ctx.db, - update.cluster_id, - &installed.hash, - SeenStatus::Updated, - ) - .await?; + artifact_dao::set_seen_status( + &ctx.db, + update.cluster_id, + &installed.hash, + SeenStatus::Updated, + ) + .await?; - update_dao::delete(&ctx.db, update.cluster_id, &update.hash).await?; + update_dao::delete(&ctx.db, update.cluster_id, &update.hash).await?; - Ok(installed.hash) + Ok(installed.hash) } #[tracing::instrument(level = "debug", skip(update, project, version, ctx), fields(cluster_id = update.cluster_id))] async fn install_new_dependencies( - update: &BrowserPackageUpdate, - project: &crate::packages::types::ProjectDetail, - version: &crate::packages::types::VersionDetail, - ctx: &ContentCtx, + update: &BrowserPackageUpdate, + project: &crate::packages::types::ProjectDetail, + version: &crate::packages::types::VersionDetail, + ctx: &ContentCtx, ) { - if !resolves_dependencies(project.content_type) { - return; - } + if !resolves_dependencies(project.content_type) { + return; + } - let resolution = match resolve_required(update.provider, version, update.cluster_id, ctx).await { - Ok(resolution) => resolution, - Err(err) => { - tracing::warn!(%err, "dependency resolution failed, updating the package alone"); - return; - } - }; + let resolution = match resolve_required(update.provider, version, update.cluster_id, ctx).await + { + Ok(resolution) => resolution, + Err(err) => { + tracing::warn!(%err, "dependency resolution failed, updating the package alone"); + return; + } + }; - for missing in &resolution.unresolved { - tracing::warn!(dependency = %missing, "no compatible version for a new dependency"); - } + for missing in &resolution.unresolved { + tracing::warn!(dependency = %missing, "no compatible version for a new dependency"); + } - for dependency in &resolution.install { - match PackageStore::install_to_cluster( - update.provider, - &dependency.project, - &dependency.version, - update.cluster_id, - true, - false, - None, - ctx, - ) - .await - { - Ok((artifact, _)) => { - tracing::info!( - dependency = %dependency.project.name, - "installed a dependency the newer version needs" - ); - if let Err(err) = artifact_dao::set_seen_status( - &ctx.db, - update.cluster_id, - &artifact.hash, - SeenStatus::New, - ) - .await - { - tracing::debug!(%err, "could not mark a new dependency as new"); - } - } - Err(err) => tracing::warn!( - dependency = %dependency.project.name, - %err, - "failed to install a dependency the newer version needs" - ), - } - } + for dependency in &resolution.install { + match PackageStore::install_to_cluster( + update.provider, + &dependency.project, + &dependency.version, + update.cluster_id, + true, + false, + None, + ctx, + ) + .await + { + Ok((artifact, _)) => { + tracing::info!( + dependency = %dependency.project.name, + "installed a dependency the newer version needs" + ); + if let Err(err) = artifact_dao::set_seen_status( + &ctx.db, + update.cluster_id, + &artifact.hash, + SeenStatus::New, + ) + .await + { + tracing::debug!(%err, "could not mark a new dependency as new"); + } + } + Err(err) => tracing::warn!( + dependency = %dependency.project.name, + %err, + "failed to install a dependency the newer version needs" + ), + } + } } #[cfg(test)] mod tests { - use super::*; - use oneclient_common::domain::ContentType; + use super::*; + use oneclient_common::domain::ContentType; - fn at(raw: &str) -> DateTime { - parse_published(raw).expect("test dates are rfc 3339") - } + fn at(raw: &str) -> DateTime { + parse_published(raw).expect("test dates are rfc 3339") + } - fn linked( - hash: &str, - provider: Option, - project_id: Option<&str>, - version_id: Option<&str>, - ) -> LinkedArtifactInfo { - LinkedArtifactInfo { - hash: hash.into(), - cluster_file_name: format!("{hash}.jar"), - enabled: true, - content_type: ContentType::Mod, - file_name: format!("{hash}.jar"), - project_id: project_id.map(Into::into), - version_id: version_id.map(Into::into), - display_name: None, - display_version: None, - provider, - published_at: None, - seen_status: SeenStatus::Seen, - } - } + fn linked( + hash: &str, + provider: Option, + project_id: Option<&str>, + version_id: Option<&str>, + ) -> LinkedArtifactInfo { + LinkedArtifactInfo { + hash: hash.into(), + cluster_file_name: format!("{hash}.jar"), + enabled: true, + content_type: ContentType::Mod, + file_name: format!("{hash}.jar"), + project_id: project_id.map(Into::into), + version_id: version_id.map(Into::into), + display_name: None, + display_version: None, + provider, + published_at: None, + seen_status: SeenStatus::Seen, + } + } - #[test] - fn bundle_provided_packages_are_never_candidates() { - let linked = vec![linked( - "a", - Some(ProviderId::Modrinth), - Some("sodium"), - Some("v1"), - )]; - let bundles: HashSet = ["a".to_string()].into_iter().collect(); + #[test] + fn bundle_provided_packages_are_never_candidates() { + let linked = vec![linked( + "a", + Some(ProviderId::Modrinth), + Some("sodium"), + Some("v1"), + )]; + let bundles: HashSet = ["a".to_string()].into_iter().collect(); - assert!( - browser_installed(&linked, &bundles, &HashSet::new()).is_empty(), - "a bundle-tracked artifact must stay out of the browser update flow" - ); - } + assert!( + browser_installed(&linked, &bundles, &HashSet::new()).is_empty(), + "a bundle-tracked artifact must stay out of the browser update flow" + ); + } - #[test] - fn browser_installed_remote_packages_are_candidates() { - let linked = vec![linked( - "a", - Some(ProviderId::Modrinth), - Some("sodium"), - Some("v1"), - )]; + #[test] + fn browser_installed_remote_packages_are_candidates() { + let linked = vec![linked( + "a", + Some(ProviderId::Modrinth), + Some("sodium"), + Some("v1"), + )]; - let found = browser_installed(&linked, &HashSet::new(), &HashSet::new()); - assert_eq!(found.len(), 1); - assert_eq!(found[0].project_id, "sodium"); - assert_eq!(found[0].version_id, "v1"); - } + let found = browser_installed(&linked, &HashSet::new(), &HashSet::new()); + assert_eq!(found.len(), 1); + assert_eq!(found[0].project_id, "sodium"); + assert_eq!(found[0].version_id, "v1"); + } - #[test] - fn local_imports_are_not_candidates() { - let linked = vec![ - linked("a", Some(ProviderId::Local), Some("x"), Some("v1")), - linked("b", None, None, None), - ]; + #[test] + fn local_imports_are_not_candidates() { + let linked = vec![ + linked("a", Some(ProviderId::Local), Some("x"), Some("v1")), + linked("b", None, None, None), + ]; - assert!( - browser_installed(&linked, &HashSet::new(), &HashSet::new()).is_empty(), - "a file with no provider release has no version to compare" - ); - } + assert!( + browser_installed(&linked, &HashSet::new(), &HashSet::new()).is_empty(), + "a file with no provider release has no version to compare" + ); + } - #[test] - fn a_remote_package_with_no_recorded_version_is_not_a_candidate() { - let linked = vec![linked("a", Some(ProviderId::Modrinth), Some("sodium"), None)]; + #[test] + fn a_remote_package_with_no_recorded_version_is_not_a_candidate() { + let linked = vec![linked( + "a", + Some(ProviderId::Modrinth), + Some("sodium"), + None, + )]; - assert!( - browser_installed(&linked, &HashSet::new(), &HashSet::new()).is_empty(), - "without an installed version id there is nothing to compare against" - ); - } + assert!( + browser_installed(&linked, &HashSet::new(), &HashSet::new()).is_empty(), + "without an installed version id there is nothing to compare against" + ); + } - #[test] - fn pending_hides_versions_the_user_declined() { - let make = |hash: &str, skipped: bool| BrowserPackageUpdate { - cluster_id: 1, - hash: hash.into(), - provider: ProviderId::Modrinth, - project_id: "sodium".into(), - installed_version_id: "v1".into(), - installed_version_name: "1.0".into(), - latest_version_id: "v2".into(), - latest_version_name: "2.0".into(), - display_name: "Sodium".into(), - skipped, - }; + #[test] + fn pending_hides_versions_the_user_declined() { + let make = |hash: &str, skipped: bool| BrowserPackageUpdate { + cluster_id: 1, + hash: hash.into(), + provider: ProviderId::Modrinth, + project_id: "sodium".into(), + installed_version_id: "v1".into(), + installed_version_name: "1.0".into(), + latest_version_id: "v2".into(), + latest_version_name: "2.0".into(), + display_name: "Sodium".into(), + skipped, + }; - let check = BrowserUpdateCheck { - cluster_id: 1, - updates: vec![make("a", true), make("b", false)], - unchecked: Vec::new(), - }; + let check = BrowserUpdateCheck { + cluster_id: 1, + updates: vec![make("a", true), make("b", false)], + unchecked: Vec::new(), + }; - let pending = check.pending(); - assert_eq!(pending.len(), 1); - assert_eq!(pending[0].hash, "b"); - } + let pending = check.pending(); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].hash, "b"); + } - #[test] - fn a_newer_pick_is_an_upgrade() { - assert!(is_upgrade( - Some(at("2026-01-01T00:00:00Z")), - at("2026-02-01T00:00:00Z") - )); - } + #[test] + fn a_newer_pick_is_an_upgrade() { + assert!(is_upgrade( + Some(at("2026-01-01T00:00:00Z")), + at("2026-02-01T00:00:00Z") + )); + } - #[test] - fn a_pick_older_than_the_installed_build_is_not_an_upgrade() { - assert!( - !is_upgrade( - Some(at("2026-02-01T00:00:00Z")), - at("2026-01-01T00:00:00Z") - ), - "a hand-installed build newer than the provider pick must not be offered a downgrade" - ); - } + #[test] + fn a_pick_older_than_the_installed_build_is_not_an_upgrade() { + assert!( + !is_upgrade(Some(at("2026-02-01T00:00:00Z")), at("2026-01-01T00:00:00Z")), + "a hand-installed build newer than the provider pick must not be offered a downgrade" + ); + } - #[test] - fn a_pick_published_at_the_same_moment_is_not_an_upgrade() { - assert!(!is_upgrade( - Some(at("2026-02-01T00:00:00Z")), - at("2026-02-01T00:00:00Z") - )); - } + #[test] + fn a_pick_published_at_the_same_moment_is_not_an_upgrade() { + assert!(!is_upgrade( + Some(at("2026-02-01T00:00:00Z")), + at("2026-02-01T00:00:00Z") + )); + } - #[test] - fn an_unknown_installed_date_still_offers_the_update() { - assert!(is_upgrade(None, at("2026-02-01T00:00:00Z"))); - } + #[test] + fn an_unknown_installed_date_still_offers_the_update() { + assert!(is_upgrade(None, at("2026-02-01T00:00:00Z"))); + } - #[test] - fn candidates_carry_the_installed_publish_date() { - let mut info = linked("a", Some(ProviderId::Modrinth), Some("sodium"), Some("v1")); - info.published_at = Some("2026-02-01T00:00:00Z".into()); + #[test] + fn candidates_carry_the_installed_publish_date() { + let mut info = linked("a", Some(ProviderId::Modrinth), Some("sodium"), Some("v1")); + info.published_at = Some("2026-02-01T00:00:00Z".into()); - let found = browser_installed(&[info], &HashSet::new(), &HashSet::new()); - assert_eq!(found[0].published_at, Some(at("2026-02-01T00:00:00Z"))); - } - #[test] - fn an_unparseable_publish_date_leaves_the_candidate_undated() { - let mut info = linked("a", Some(ProviderId::Modrinth), Some("sodium"), Some("v1")); - info.published_at = Some("not a date".into()); + let found = browser_installed(&[info], &HashSet::new(), &HashSet::new()); + assert_eq!(found[0].published_at, Some(at("2026-02-01T00:00:00Z"))); + } + #[test] + fn an_unparseable_publish_date_leaves_the_candidate_undated() { + let mut info = linked("a", Some(ProviderId::Modrinth), Some("sodium"), Some("v1")); + info.published_at = Some("not a date".into()); - let found = browser_installed(&[info], &HashSet::new(), &HashSet::new()); - assert_eq!(found[0].published_at, None); - } + let found = browser_installed(&[info], &HashSet::new(), &HashSet::new()); + assert_eq!(found[0].published_at, None); + } } diff --git a/packages/oneclient_content/tests/file_identity.rs b/packages/oneclient_content/tests/file_identity.rs index 6c8aadd0..0e9bfb08 100644 --- a/packages/oneclient_content/tests/file_identity.rs +++ b/packages/oneclient_content/tests/file_identity.rs @@ -1,39 +1,39 @@ -use oneclient_content::packages::{curseforge_fingerprint, FileIdentity}; +use oneclient_content::packages::{FileIdentity, curseforge_fingerprint}; #[test] fn curseforge_fingerprint_ignores_padding_whitespace() { - let a = curseforge_fingerprint(b"hello"); - let b = curseforge_fingerprint(b"\t\n\r hello \n\t\r"); - assert_eq!(a, b); + let a = curseforge_fingerprint(b"hello"); + let b = curseforge_fingerprint(b"\t\n\r hello \n\t\r"); + assert_eq!(a, b); } #[test] fn curseforge_fingerprint_empty_is_stable() { - assert_eq!(curseforge_fingerprint(&[]), curseforge_fingerprint(&[])); + assert_eq!(curseforge_fingerprint(&[]), curseforge_fingerprint(&[])); } #[test] fn curseforge_fingerprint_differs_for_content() { - let a = curseforge_fingerprint(b"hello"); - let b = curseforge_fingerprint(b"world"); - assert_ne!(a, b); + let a = curseforge_fingerprint(b"hello"); + let b = curseforge_fingerprint(b"world"); + assert_ne!(a, b); } #[test] fn file_identity_from_bytes_sets_sha1_and_cf_fingerprint() { - let id = FileIdentity::from_bytes(b"test jar content"); - assert_eq!(id.sha1.len(), 40); - assert!(id.cf_fingerprint.is_some()); + let id = FileIdentity::from_bytes(b"test jar content"); + assert_eq!(id.sha1.len(), 40); + assert!(id.cf_fingerprint.is_some()); } #[test] fn file_identity_from_sha1_has_no_cf_fingerprint() { - let id = FileIdentity::from_sha1("a".repeat(40)); - assert!(id.cf_fingerprint.is_none()); + let id = FileIdentity::from_sha1("a".repeat(40)); + assert!(id.cf_fingerprint.is_none()); } #[test] fn file_identity_with_fingerprint_roundtrip() { - let id = FileIdentity::from_sha1("abc").with_curseforge_fingerprint(12345); - assert_eq!(id.cf_fingerprint, Some(12345)); + let id = FileIdentity::from_sha1("abc").with_curseforge_fingerprint(12345); + assert_eq!(id.cf_fingerprint, Some(12345)); } diff --git a/packages/oneclient_core/examples/auth_msa.rs b/packages/oneclient_core/examples/auth_msa.rs index d7915f6b..c2093e4f 100644 --- a/packages/oneclient_core/examples/auth_msa.rs +++ b/packages/oneclient_core/examples/auth_msa.rs @@ -1,7 +1,6 @@ - use std::process::Command; -use oneclient_core::{dev, logger, LauncherResult}; +use oneclient_core::{LauncherResult, dev, logger}; #[tokio::main] async fn main() -> LauncherResult<()> { @@ -25,7 +24,10 @@ async fn main() -> LauncherResult<()> { "Signed in as {} ({})\n token expires: {}", account.username, account.id, account.expires ); - println!("Account saved to {}", oneclient_common::paths::auth_file()?.display()); + println!( + "Account saved to {}", + oneclient_common::paths::auth_file()?.display() + ); Ok(()) } @@ -42,7 +44,9 @@ fn try_open_browser(url: &str) { match result { Ok(_) => println!("Opened verification page in your default browser.\n"), Err(err) => { - println!("Could not open browser automatically ({err}). Open the URL above manually.\n"); + println!( + "Could not open browser automatically ({err}). Open the URL above manually.\n" + ); } } } diff --git a/packages/oneclient_core/examples/bundles_sync.rs b/packages/oneclient_core/examples/bundles_sync.rs index eccb554d..bf9e3b58 100644 --- a/packages/oneclient_core/examples/bundles_sync.rs +++ b/packages/oneclient_core/examples/bundles_sync.rs @@ -1,6 +1,5 @@ - -use oneclient_core::dev; use oneclient_common::domain::GameLoader; +use oneclient_core::dev; #[tokio::main] async fn main() -> Result<(), Box> { diff --git a/packages/oneclient_core/examples/cluster_manage.rs b/packages/oneclient_core/examples/cluster_manage.rs index 53790711..ba287d81 100644 --- a/packages/oneclient_core/examples/cluster_manage.rs +++ b/packages/oneclient_core/examples/cluster_manage.rs @@ -1,42 +1,47 @@ - +use oneclient_cluster::profiles::list_named_profiles; +use oneclient_common::domain::GameLoader; use oneclient_core::clusters::CreateClusterOptions; use oneclient_core::dev; -use oneclient_common::domain::GameLoader; -use oneclient_cluster::profiles::list_named_profiles; #[tokio::main] async fn main() -> Result<(), Box> { - tracing_subscriber::fmt::init(); + tracing_subscriber::fmt::init(); - let state = dev::ephemeral_state().await?; - let global = state.settings.read().global_game_settings.clone(); + let state = dev::ephemeral_state().await?; + let global = state.settings.read().global_game_settings.clone(); - let cluster = state.clusters.create( - &global, - CreateClusterOptions::new("Example 1.21.1 Fabric", "1.21.1", GameLoader::Fabric).mem_max(3072), - ) - .await?; + let cluster = state + .clusters + .create( + &global, + CreateClusterOptions::new("Example 1.21.1 Fabric", "1.21.1", GameLoader::Fabric) + .mem_max(3072), + ) + .await?; - println!("Created cluster #{} at folder '{}'", cluster.id, cluster.folder_name); - println!(" setting profile: {:?}", cluster.setting_profile_name); - println!(" created at: {:?}", cluster.created_at); + println!( + "Created cluster #{} at folder '{}'", + cluster.id, cluster.folder_name + ); + println!(" setting profile: {:?}", cluster.setting_profile_name); + println!(" created at: {:?}", cluster.created_at); - let profile = state.clusters.resolve_settings(&global, &cluster).await?; - println!( - " resolved mem_max: {:?}, force_fullscreen: {:?}", - profile.mem_max, profile.force_fullscreen - ); + let profile = state.clusters.resolve_settings(&global, &cluster).await?; + println!( + " resolved mem_max: {:?}, force_fullscreen: {:?}", + profile.mem_max, profile.force_fullscreen + ); - for c in state.clusters.list().await? { - println!(" - {} ({}, {})", c.name, c.mc_version, c.mc_loader); - } + for c in state.clusters.list().await? { + println!(" - {} ({}, {})", c.name, c.mc_version, c.mc_loader); + } - for p in list_named_profiles(&state.services.db).await? { - println!("Profile {} (mem_max: {:?})", p.name, p.mem_max); - } + for p in list_named_profiles(&state.services.db).await? { + println!("Profile {} (mem_max: {:?})", p.name, p.mem_max); + } - state.clusters.delete(cluster.id, true).await?; - println!("Deleted cluster #{}", cluster.id); + state.clusters.delete(cluster.id, true).await?; + println!("Deleted cluster #{}", cluster.id); - Ok(()) + Ok(()) } diff --git a/packages/oneclient_core/examples/download_game.rs b/packages/oneclient_core/examples/download_game.rs index 045ec845..aceeee8b 100644 --- a/packages/oneclient_core/examples/download_game.rs +++ b/packages/oneclient_core/examples/download_game.rs @@ -1,8 +1,8 @@ #![recursion_limit = "256"] -use oneclient_core::clusters::CreateClusterOptions; -use oneclient_core::dev; use oneclient_common::domain::GameLoader; use oneclient_core::LauncherResult; +use oneclient_core::clusters::CreateClusterOptions; +use oneclient_core::dev; #[tokio::main] async fn main() -> LauncherResult<()> { @@ -19,24 +19,29 @@ async fn main() -> LauncherResult<()> { let global = state.settings.read().global_game_settings.clone(); - let cluster = state.clusters.create( - &global, - CreateClusterOptions { - name: format!("download-{mc_version}"), - mc_version: mc_version.to_string(), - mc_loader: loader, - mc_loader_version: loader_version.map(str::to_string), - mem_max: None, - }, - ) - .await?; + let cluster = state + .clusters + .create( + &global, + CreateClusterOptions { + name: format!("download-{mc_version}"), + mc_version: mc_version.to_string(), + mc_loader: loader, + mc_loader_version: loader_version.map(str::to_string), + mem_max: None, + }, + ) + .await?; println!( "Preparing cluster {} ({} {:?})...", cluster.name, cluster.mc_version, cluster.mc_loader ); - let ready = oneclient_core::clusters::prepare_cluster_locked(&state, cluster.id, false, true, true, None).await?; + let ready = oneclient_core::clusters::prepare_cluster_locked( + &state, cluster.id, false, true, true, None, + ) + .await?; println!("Cluster ready at {}", ready.dir()?.display()); Ok(()) diff --git a/packages/oneclient_core/examples/launch_game.rs b/packages/oneclient_core/examples/launch_game.rs index 3da7091f..2c435b24 100644 --- a/packages/oneclient_core/examples/launch_game.rs +++ b/packages/oneclient_core/examples/launch_game.rs @@ -1,9 +1,9 @@ #![recursion_limit = "256"] +use oneclient_common::domain::GameLoader; use oneclient_core::LauncherResult; use oneclient_core::clusters::CreateClusterOptions; use oneclient_core::dev; -use oneclient_common::domain::GameLoader; #[tokio::main] async fn main() -> LauncherResult<()> { @@ -17,16 +17,24 @@ async fn main() -> LauncherResult<()> { .and_then(|s| s.parse().ok()) .unwrap_or(GameLoader::Vanilla); - let account = state.auth.add_offline_account("Example".to_string()).await?; - println!("Using offline account {} ({})", account.username, account.id); + let account = state + .auth + .add_offline_account("Example".to_string()) + .await?; + println!( + "Using offline account {} ({})", + account.username, account.id + ); let global = state.settings.read().global_game_settings.clone(); - let cluster = state.clusters.create( - &global, - CreateClusterOptions::new(format!("launch-{mc_version}"), mc_version, loader), - ) - .await?; + let cluster = state + .clusters + .create( + &global, + CreateClusterOptions::new(format!("launch-{mc_version}"), mc_version, loader), + ) + .await?; println!("Launching {} ({mc_version} {loader:?})...", cluster.name); let game = oneclient_core::launch_cluster(&state, cluster.id, &account, true).await?; diff --git a/packages/oneclient_core/examples/notifications.rs b/packages/oneclient_core/examples/notifications.rs index e7c6da98..432cbaf9 100644 --- a/packages/oneclient_core/examples/notifications.rs +++ b/packages/oneclient_core/examples/notifications.rs @@ -1,5 +1,4 @@ - -use oneclient_events::{GroupedProgressSession, EventBus}; +use oneclient_events::{EventBus, GroupedProgressSession}; use tokio::sync::mpsc; use uuid::Uuid; @@ -9,7 +8,11 @@ async fn main() { let events = EventBus::new(tx); events.notify("Welcome").body("Launcher started").send(); - events.notify("Heads up").body("Example error notification").error().send(); + events + .notify("Heads up") + .body("Example error notification") + .error() + .send(); let download = Uuid::new_v4(); for current in (0..=100).step_by(25) { diff --git a/packages/oneclient_core/examples/package_project.rs b/packages/oneclient_core/examples/package_project.rs index eb4f9662..857e4e34 100644 --- a/packages/oneclient_core/examples/package_project.rs +++ b/packages/oneclient_core/examples/package_project.rs @@ -1,72 +1,59 @@ - use std::env; use oneclient_common::domain::ProviderId; -use oneclient_core::{dev, logger, LauncherResult}; +use oneclient_core::{LauncherResult, dev, logger}; #[tokio::main] async fn main() -> LauncherResult<()> { - logger::init_debug()?; + logger::init_debug()?; - let mut args = env::args().skip(1); - let provider_name = args.next().unwrap_or_else(|| "modrinth".into()); - let project_ref = args.next().unwrap_or_else(|| "sodium".into()); - let mc_version = args.next(); + let mut args = env::args().skip(1); + let provider_name = args.next().unwrap_or_else(|| "modrinth".into()); + let project_ref = args.next().unwrap_or_else(|| "sodium".into()); + let mc_version = args.next(); - let provider_id = parse_provider(&provider_name)?; - let env = dev::ephemeral_services().await?.content(); - let provider = env.providers.get(provider_id)?; + let provider_id = parse_provider(&provider_name)?; + let env = dev::ephemeral_services().await?.content(); + let provider = env.providers.get(provider_id)?; - let project = provider.get_project(&project_ref, &env).await?; + let project = provider.get_project(&project_ref, &env).await?; - println!( - "{:?} project: {} ({})", - project.provider, project.name, project.id - ); - println!(" {}\n", project.summary); + println!( + "{:?} project: {} ({})", + project.provider, project.name, project.id + ); + println!(" {}\n", project.summary); - let versions = provider - .list_versions( - &project.id, - mc_version.as_deref(), - None, - 0, - 5, - &env, - ) - .await?; + let versions = provider + .list_versions(&project.id, mc_version.as_deref(), None, 0, 5, &env) + .await?; - println!("Versions (up to {}):", versions.items.len()); - for v in &versions.items { - println!( - " {} - {} ({})", - v.name, v.version_number, v.version_id - ); - } + println!("Versions (up to {}):", versions.items.len()); + for v in &versions.items { + println!(" {} - {} ({})", v.name, v.version_number, v.version_id); + } - if let Some(version) = versions.items.first() { - let detail = provider - .get_version(&project.id, &version.version_id, &env) - .await?; - if let Some(file) = detail.primary_file() { - println!( - "\nPrimary file: {} sha1={} cf_fp={:?}", - file.file_name, file.sha1, file.fingerprint - ); - } - } + if let Some(version) = versions.items.first() { + let detail = provider + .get_version(&project.id, &version.version_id, &env) + .await?; + if let Some(file) = detail.primary_file() { + println!( + "\nPrimary file: {} sha1={} cf_fp={:?}", + file.file_name, file.sha1, file.fingerprint + ); + } + } - Ok(()) + Ok(()) } fn parse_provider(name: &str) -> LauncherResult { - match name.to_lowercase().as_str() { - "modrinth" | "mr" => Ok(ProviderId::Modrinth), - "curseforge" | "cf" => Ok(ProviderId::CurseForge), - other => Err(oneclient_core::LauncherError::InvalidSettingsProfile { - reason: format!( - "unknown provider {other:?}; use modrinth or curseforge" - ), - }), - } + match name.to_lowercase().as_str() { + "modrinth" | "mr" => Ok(ProviderId::Modrinth), + "curseforge" | "cf" => Ok(ProviderId::CurseForge), + other => Err(oneclient_core::LauncherError::InvalidSettingsProfile { + reason: format!("unknown provider {other:?}; use modrinth or curseforge"), + }), + } } diff --git a/packages/oneclient_core/examples/package_search.rs b/packages/oneclient_core/examples/package_search.rs index 7a326a06..ba89859c 100644 --- a/packages/oneclient_core/examples/package_search.rs +++ b/packages/oneclient_core/examples/package_search.rs @@ -1,4 +1,3 @@ - use std::env; use oneclient_common::domain::{ContentType, ProviderId}; diff --git a/packages/oneclient_core/src/changelog.rs b/packages/oneclient_core/src/changelog.rs index ee0667d4..9d55a9de 100644 --- a/packages/oneclient_core/src/changelog.rs +++ b/packages/oneclient_core/src/changelog.rs @@ -1,9 +1,7 @@ - - -use oneclient_common::paths; -use oneclient_net::{EtagPolicy, fetch_cached}; -use oneclient_net::RequestClient; use crate::{LauncherError, LauncherResult}; +use oneclient_common::paths; +use oneclient_net::RequestClient; +use oneclient_net::{EtagPolicy, fetch_cached}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct ChangelogGroup { @@ -34,10 +32,7 @@ pub fn parse_changelog(data: &str) -> Vec { #[tracing::instrument(level = "debug", skip(net))] pub async fn fetch_changelog(net: &RequestClient) -> LauncherResult { - let url = format!( - "{}/oneclient/CHANGE_LOG.md", - net.config().meta_url_base - ); + let url = format!("{}/oneclient/CHANGE_LOG.md", net.config().meta_url_base); let cache_path = paths::caches_dir()?.join("CHANGE_LOG.md"); let fetched = fetch_cached(net, &url, &cache_path, EtagPolicy::CommitNow) diff --git a/packages/oneclient_core/src/clusters/migrate.rs b/packages/oneclient_core/src/clusters/migrate.rs index 3c344f6b..fd9a07f3 100644 --- a/packages/oneclient_core/src/clusters/migrate.rs +++ b/packages/oneclient_core/src/clusters/migrate.rs @@ -5,9 +5,9 @@ use oneclient_db::dao::{applied_migration as migration_dao, cluster as cluster_d use oneclient_db::models::ClusterRow; use crate::LauncherResult; -use oneclient_common::domain::GameLoader; use crate::state::LauncherState; use crate::versions::RemoteMigration; +use oneclient_common::domain::GameLoader; #[tracing::instrument(skip(state))] pub async fn apply_remote_migrations(state: &LauncherState) -> LauncherResult { diff --git a/packages/oneclient_core/src/clusters/prepare.rs b/packages/oneclient_core/src/clusters/prepare.rs index d4e9ed51..697819bc 100644 --- a/packages/oneclient_core/src/clusters/prepare.rs +++ b/packages/oneclient_core/src/clusters/prepare.rs @@ -5,18 +5,18 @@ use interfrost::api::minecraft::{DownloadType, VersionInfo}; use interfrost::api::modded::SidedDataEntry; use tokio::process::Command; -use oneclient_cluster::Cluster; -use oneclient_cluster::ClusterError; -use oneclient_cluster::ClusterStage; use crate::game::{ self, download_minecraft, download_version_info, get_loader_version, resolve_minecraft_version, }; -use oneclient_java::JavaRuntime; -use oneclient_mc::MetadataStore; -use oneclient_events::GroupedProgressSession; -use oneclient_common::paths; use crate::state::{LauncherServices, LauncherState}; use crate::{GameError, LauncherResult}; +use oneclient_cluster::Cluster; +use oneclient_cluster::ClusterError; +use oneclient_cluster::ClusterStage; +use oneclient_common::paths; +use oneclient_events::GroupedProgressSession; +use oneclient_java::JavaRuntime; +use oneclient_mc::MetadataStore; #[tracing::instrument(skip(state, shared_progress))] pub async fn prepare_cluster_locked( @@ -61,7 +61,10 @@ pub async fn prepare_cluster( ); if !continuing { - state.clusters.set_stage(cluster_id, ClusterStage::Downloading).await?; + state + .clusters + .set_stage(cluster_id, ClusterStage::Downloading) + .await?; } let owned = shared_progress.is_none().then(|| { @@ -99,12 +102,18 @@ pub async fn prepare_cluster( tracing::error!(cluster_id, error = %err, "cluster preparation failed"); } if !continuing { - let _ = state.clusters.set_stage(cluster_id, ClusterStage::NotReady).await; + let _ = state + .clusters + .set_stage(cluster_id, ClusterStage::NotReady) + .await; } return Err(err); } - let cluster = state.clusters.set_stage(cluster_id, ClusterStage::Ready).await?; + let cluster = state + .clusters + .set_stage(cluster_id, ClusterStage::Ready) + .await?; tracing::debug!(cluster_id, "cluster stage set to Ready"); Ok(cluster) } @@ -174,8 +183,14 @@ pub async fn required_java_major( cluster.mc_loader_version.as_deref(), ) .await?; - download_version_info(&state.services.mc(), None, &version, loader_version.as_ref(), false) - .await? + download_version_info( + &state.services.mc(), + None, + &version, + loader_version.as_ref(), + false, + ) + .await? }; Ok(info.java_version.map(|java| java.major_version)) @@ -202,23 +217,32 @@ pub async fn estimate_cluster_download( cluster.mc_loader_version.as_deref(), ) .await?; - download_version_info(&state.services.mc(), None, &version, loader_version.as_ref(), false).await? + download_version_info( + &state.services.mc(), + None, + &version, + loader_version.as_ref(), + false, + ) + .await? }; let mut total = game_download_bytes(&state.services, &info).await; if let Some(java) = &info.java_version { - let installed = state.java.list_runtimes() - .await - .unwrap_or_default(); + let installed = state.java.list_runtimes().await.unwrap_or_default(); if !installed.iter().any(|rt| rt.major == java.major_version) { total += JRE_ESTIMATE_BYTES; } } - total += oneclient_content::bundles::enabled_bundle_bytes(cluster_id, bundles, &state.services.content()) - .await - .unwrap_or(0); + total += oneclient_content::bundles::enabled_bundle_bytes( + cluster_id, + bundles, + &state.services.content(), + ) + .await + .unwrap_or(0); Ok(total) } @@ -265,14 +289,21 @@ async fn install_cluster( .map(|v| v.major_version) .ok_or(ClusterError::MissingJavaVersion)?; - let java = if let Some(runtime) = - state.java.runtime_for_profile(profile.java_path.as_deref()).await? + let java = if let Some(runtime) = state + .java + .runtime_for_profile(profile.java_path.as_deref()) + .await? { runtime } else { state .java - .prepare(java_major, search_for_java, auto_install_java, Some(progress)) + .prepare( + java_major, + search_for_java, + auto_install_java, + Some(progress), + ) .await? }; diff --git a/packages/oneclient_core/src/clusters/provision.rs b/packages/oneclient_core/src/clusters/provision.rs index 767f5f1a..a4fde317 100644 --- a/packages/oneclient_core/src/clusters/provision.rs +++ b/packages/oneclient_core/src/clusters/provision.rs @@ -2,10 +2,10 @@ use std::str::FromStr; use oneclient_db::dao::{bundle as bundle_dao, cluster as cluster_dao}; -use oneclient_common::domain::GameLoader; -use crate::state::LauncherState; -use oneclient_common::version::format_mc_version; use crate::LauncherResult; +use crate::state::LauncherState; +use oneclient_common::domain::GameLoader; +use oneclient_common::version::format_mc_version; use oneclient_cluster::Cluster; use oneclient_cluster::CreateClusterOptions; @@ -45,7 +45,7 @@ pub async fn ensure_from_bundles(state: &LauncherState) -> LauncherResult { tracing::info!( @@ -73,7 +73,10 @@ pub async fn ensure_from_bundles(state: &LauncherState) -> LauncherResult LauncherResult> { - let metadata = state.versions.metadata(&state.services.requester.config().meta_url_base).await; + let metadata = state + .versions + .metadata(&state.services.requester.config().meta_url_base) + .await; let mut created = Vec::new(); for entry in metadata { @@ -110,7 +113,7 @@ pub async fn ensure_from_versions(state: &LauncherState) -> LauncherResult { tracing::info!( diff --git a/packages/oneclient_core/src/clusters/unlink_legacy.rs b/packages/oneclient_core/src/clusters/unlink_legacy.rs index 49d94c71..aadc2ed7 100644 --- a/packages/oneclient_core/src/clusters/unlink_legacy.rs +++ b/packages/oneclient_core/src/clusters/unlink_legacy.rs @@ -5,195 +5,194 @@ use oneclient_db::dao::artifact as artifact_dao; use crate::LauncherResult; use crate::state::LauncherState; use oneclient_common::domain::ContentType; -use oneclient_content::packages::store::manifest::{ - self, ManifestEntry, MaterializedManifest, -}; use oneclient_content::packages::store::artifact_absolute_path; +use oneclient_content::packages::store::manifest::{self, ManifestEntry, MaterializedManifest}; const SWEPT_TYPES: [ContentType; 2] = [ContentType::Mod, ContentType::DataPack]; const SWEPT_TYPES_REDIRECTED: [ContentType; 1] = [ContentType::DataPack]; async fn swept_types(cluster_root: &Path) -> &'static [ContentType] { - if manifest::mods_live_in_cluster(cluster_root).await { - &SWEPT_TYPES_REDIRECTED - } else { - &SWEPT_TYPES - } + if manifest::mods_live_in_cluster(cluster_root).await { + &SWEPT_TYPES_REDIRECTED + } else { + &SWEPT_TYPES + } } #[derive(Debug, Default, Clone, Copy)] pub struct SweepReport { - pub removed: usize, - pub adopted: usize, - pub skipped: usize, + pub removed: usize, + pub adopted: usize, + pub skipped: usize, } #[tracing::instrument(skip(state))] pub async fn unlink_legacy_cluster_content(state: &LauncherState) -> LauncherResult { - let mut report = SweepReport::default(); + let mut report = SweepReport::default(); - for cluster in state.clusters.list().await? { - let dedicated = cluster.uses_dedicated_dir(); - let Ok(cluster_root) = cluster.dir() else { - continue; - }; + for cluster in state.clusters.list().await? { + let dedicated = cluster.uses_dedicated_dir(); + let Ok(cluster_root) = cluster.dir() else { + continue; + }; - let swept = swept_types(&cluster_root).await; + let swept = swept_types(&cluster_root).await; - if dedicated { - adopt_dedicated(state, &cluster, &cluster_root, swept, &mut report).await; - } else { - sweep_shared(state, &cluster_root, swept, &mut report).await; - } - } + if dedicated { + adopt_dedicated(state, &cluster, &cluster_root, swept, &mut report).await; + } else { + sweep_shared(state, &cluster_root, swept, &mut report).await; + } + } - if report.removed > 0 || report.adopted > 0 { - tracing::info!( - removed = report.removed, - adopted = report.adopted, - skipped = report.skipped, - "cleaned up legacy cluster content links" - ); - } + if report.removed > 0 || report.adopted > 0 { + tracing::info!( + removed = report.removed, + adopted = report.adopted, + skipped = report.skipped, + "cleaned up legacy cluster content links" + ); + } - Ok(report) + Ok(report) } async fn sweep_shared( - state: &LauncherState, - cluster_root: &Path, - swept: &[ContentType], - report: &mut SweepReport, + state: &LauncherState, + cluster_root: &Path, + swept: &[ContentType], + report: &mut SweepReport, ) { - for content_type in swept { - let dir = cluster_root.join(content_type.folder_name()); - let Ok(mut entries) = polyio::read_dir(&dir).await else { - continue; - }; + for content_type in swept { + let dir = cluster_root.join(content_type.folder_name()); + let Ok(mut entries) = polyio::read_dir(&dir).await else { + continue; + }; - while let Ok(Some(entry)) = entries.next_entry().await { - let path = entry.path(); - let Ok(file_type) = entry.file_type().await else { - continue; - }; + while let Ok(Some(entry)) = entries.next_entry().await { + let path = entry.path(); + let Ok(file_type) = entry.file_type().await else { + continue; + }; - // A symlink here is unambiguously ours nothing else writes links here - if file_type.is_symlink() { - polyio::remove_file(&path).await.ok(); - report.removed += 1; - continue; - } + // A symlink here is unambiguously ours nothing else writes links here + if file_type.is_symlink() { + polyio::remove_file(&path).await.ok(); + report.removed += 1; + continue; + } - if !file_type.is_file() { - continue; - } + if !file_type.is_file() { + continue; + } - match cached_artifact_for(state, &path).await { - Some(CacheMatch::Elsewhere) => { - if polyio::remove_file(&path).await.is_ok() { - report.removed += 1; - } - } - // Cache row points at this very file (older recovery pass stored it - // in place) deleting it would destroy the only copy - Some(CacheMatch::IsTheArtifact) => { - tracing::debug!( - path = %path.display(), - "leaving cluster file in place; it is the artifact's only copy" - ); - report.skipped += 1; - } - None => report.skipped += 1, - } - } - } + match cached_artifact_for(state, &path).await { + Some(CacheMatch::Elsewhere) => { + if polyio::remove_file(&path).await.is_ok() { + report.removed += 1; + } + } + // Cache row points at this very file (older recovery pass stored it + // in place) deleting it would destroy the only copy + Some(CacheMatch::IsTheArtifact) => { + tracing::debug!( + path = %path.display(), + "leaving cluster file in place; it is the artifact's only copy" + ); + report.skipped += 1; + } + None => report.skipped += 1, + } + } + } } /// Writes a manifest so later launches can tell adopted content from user files async fn adopt_dedicated( - state: &LauncherState, - cluster: &crate::clusters::Cluster, - cluster_root: &Path, - swept: &[ContentType], - report: &mut SweepReport, + state: &LauncherState, + cluster: &crate::clusters::Cluster, + cluster_root: &Path, + swept: &[ContentType], + report: &mut SweepReport, ) { - let _manifest = manifest::lock().await; - - if manifest::load(cluster_root, manifest::MANIFEST_NAME) - .await - .is_some() - { - return; - } + let _manifest = manifest::lock().await; - let linked = match oneclient_content::packages::PackageStore::list_linked_artifacts( - cluster.id, - &state.services.content(), - ) - .await - { - Ok(linked) => linked, - Err(err) => { - tracing::warn!(cluster_id = cluster.id, error = %err, "cannot adopt dedicated content"); - return; - } - }; + if manifest::load(cluster_root, manifest::MANIFEST_NAME) + .await + .is_some() + { + return; + } - let mut entries = Vec::new(); - for link in linked { - if !link.enabled || !swept.contains(&link.content_type) { - continue; - } + let linked = match oneclient_content::packages::PackageStore::list_linked_artifacts( + cluster.id, + &state.services.content(), + ) + .await + { + Ok(linked) => linked, + Err(err) => { + tracing::warn!(cluster_id = cluster.id, error = %err, "cannot adopt dedicated content"); + return; + } + }; - let relative = - manifest::entry_path(link.content_type.folder_name(), &link.cluster_file_name); - if polyio::symlink_metadata(cluster_root.join(&relative)) - .await - .is_err() - { - continue; - } + let mut entries = Vec::new(); + for link in linked { + if !link.enabled || !swept.contains(&link.content_type) { + continue; + } - entries.push(ManifestEntry { - path: relative, - hash: link.hash, - }); - } + let relative = + manifest::entry_path(link.content_type.folder_name(), &link.cluster_file_name); + if polyio::symlink_metadata(cluster_root.join(&relative)) + .await + .is_err() + { + continue; + } - report.adopted += entries.len(); - manifest::save( - cluster_root, - manifest::MANIFEST_NAME, - &MaterializedManifest::new(cluster.id, entries), - ) - .await; + entries.push(ManifestEntry { + path: relative, + hash: link.hash, + }); + } + + report.adopted += entries.len(); + manifest::save( + cluster_root, + manifest::MANIFEST_NAME, + &MaterializedManifest::new(cluster.id, entries), + ) + .await; } enum CacheMatch { - /// Cache holds this content at its own path the cluster copy is a link - Elsewhere, - /// The artifact row points at this exact file - IsTheArtifact, + /// Cache holds this content at its own path the cluster copy is a link + Elsewhere, + /// The artifact row points at this exact file + IsTheArtifact, } async fn cached_artifact_for(state: &LauncherState, path: &Path) -> Option { - let hash = polyio::sha1_file(path).await.ok()?; - let row = artifact_dao::get_artifact_by_hash(&state.services.db, &polyio::normalize_hash(&hash)) - .await - .ok() - .flatten()?; + let hash = polyio::sha1_file(path).await.ok()?; + let row = + artifact_dao::get_artifact_by_hash(&state.services.db, &polyio::normalize_hash(&hash)) + .await + .ok() + .flatten()?; - let canonical = artifact_absolute_path(&row.path).ok()?; - if same_file(&canonical, path) { - Some(CacheMatch::IsTheArtifact) - } else { - Some(CacheMatch::Elsewhere) - } + let canonical = artifact_absolute_path(&row.path).ok()?; + if same_file(&canonical, path) { + Some(CacheMatch::IsTheArtifact) + } else { + Some(CacheMatch::Elsewhere) + } } fn same_file(a: &Path, b: &Path) -> bool { - match (polyio::canonicalize(a), polyio::canonicalize(b)) { - (Ok(a), Ok(b)) => a == b, - _ => a == b, - } + match (polyio::canonicalize(a), polyio::canonicalize(b)) { + (Ok(a), Ok(b)) => a == b, + _ => a == b, + } } diff --git a/packages/oneclient_core/src/dev.rs b/packages/oneclient_core/src/dev.rs index 7001dc75..4d2b3f44 100644 --- a/packages/oneclient_core/src/dev.rs +++ b/packages/oneclient_core/src/dev.rs @@ -6,20 +6,18 @@ use tokio::sync::mpsc; use uuid::Uuid; use oneclient_content::packages::provider::PackageProviderRegistry; -use oneclient_net::RequestClient; use oneclient_events::{ - Event, EventBus, GameEvent, GroupedProgressEvent, Level, Notification, ProgressEvent, + Event, EventBus, GameEvent, GroupedProgressEvent, Level, Notification, ProgressEvent, }; +use oneclient_net::RequestClient; -use crate::{ - LauncherResult, LauncherServices, LauncherState, -}; +use crate::{LauncherResult, LauncherServices, LauncherState}; struct GroupedSessionUi { - title: String, - parent: ProgressBar, - children: HashMap, - child_bytes: HashMap, + title: String, + parent: ProgressBar, + children: HashMap, + child_bytes: HashMap, } fn spawn_notification_handler(mut rx: oneclient_events::EventReceiver) { @@ -55,7 +53,12 @@ fn spawn_notification_handler(mut rx: oneclient_events::EventReceiver) { Level::Error => tracing::error!(%title, %body), }); } - Event::Progress(ProgressEvent::Update { id, label, current, total }) => { + Event::Progress(ProgressEvent::Update { + id, + label, + current, + total, + }) => { if current >= total { if let Some(pb) = progress_bars.remove(&id) { pb.finish_with_message(format!("{label} Done!")); @@ -160,10 +163,9 @@ fn spawn_notification_handler(mut rx: oneclient_events::EventReceiver) { } GroupedProgressEvent::End { session_id } => { if let Some(session) = grouped_sessions.remove(&session_id) { - session.parent.finish_with_message(format!( - "{} - complete", - session.title - )); + session + .parent + .finish_with_message(format!("{} - complete", session.title)); } } }, @@ -174,7 +176,10 @@ fn spawn_notification_handler(mut rx: oneclient_events::EventReceiver) { Event::Game(GameEvent::Log { line, .. }) => { mp.suspend(|| tracing::info!("[game] {line}")); } - Event::Game(GameEvent::Failed { cluster_id, message }) => { + Event::Game(GameEvent::Failed { + cluster_id, + message, + }) => { mp.suspend(|| tracing::error!(cluster_id, "launch failed: {message}")); } // No TTY dialog here answer `None` so the waiting task fails @@ -216,104 +221,104 @@ pub async fn initialize() -> LauncherResult> { } async fn ephemeral_root() -> LauncherResult { - let path = std::env::current_dir() - .map_err(crate::LauncherError::StdIoError)? - .join("target") - .join(format!("ephemeral-{}", Uuid::new_v4())); + let path = std::env::current_dir() + .map_err(crate::LauncherError::StdIoError)? + .join("target") + .join(format!("ephemeral-{}", Uuid::new_v4())); + + polyio::create_dir_all(&path).await?; - polyio::create_dir_all(&path).await?; - Ok(path) } pub async fn ephemeral_state() -> LauncherResult> { - let root = ephemeral_root().await?; - oneclient_common::paths::set_launcher_dir(root.clone()); + let root = ephemeral_root().await?; + oneclient_common::paths::set_launcher_dir(root.clone()); - let (tx, rx) = mpsc::unbounded_channel(); - spawn_notification_handler(rx); + let (tx, rx) = mpsc::unbounded_channel(); + spawn_notification_handler(rx); - let db = oneclient_db::connect(root.join("example.db")).await?; - let settings = crate::settings::store::load_settings(None).await; + let db = oneclient_db::connect(root.join("example.db")).await?; + let settings = crate::settings::store::load_settings(None).await; - let services = LauncherServices { - events: EventBus::new(tx), - requester: RequestClient::new(oneclient_net::NetConfig::default())?, - db, - packages: PackageProviderRegistry::new(), - }; - let auth = Arc::new(oneclient_auth::AuthService::with_store( - Default::default(), - services.requester.clone(), - services.events.clone(), - )); + let services = LauncherServices { + events: EventBus::new(tx), + requester: RequestClient::new(oneclient_net::NetConfig::default())?, + db, + packages: PackageProviderRegistry::new(), + }; + let auth = Arc::new(oneclient_auth::AuthService::with_store( + Default::default(), + services.requester.clone(), + services.events.clone(), + )); - let clusters = crate::clusters::ClusterManager::new(services.db.clone()); + let clusters = crate::clusters::ClusterManager::new(services.db.clone()); - let java = oneclient_java::JavaService::new( - Arc::new(crate::java_store::SqlJavaStore::new(services.db.clone())), - services.requester.clone(), - services.events.clone(), - ); + let java = oneclient_java::JavaService::new( + Arc::new(crate::java_store::SqlJavaStore::new(services.db.clone())), + services.requester.clone(), + services.events.clone(), + ); - Ok(Arc::new(LauncherState { - services, - auth, - java, - clusters, - settings: parking_lot::RwLock::new(settings), - metadata: tokio::sync::Mutex::new(oneclient_mc::MetadataStore::new()), - bundles: Arc::new(oneclient_content::bundles::BundlesManager::new()), - versions: Arc::new(crate::versions::VersionsManager::new()), - images: crate::images::ImageCacheStore::new(), - games: crate::game::GameProcessManager::new(), - discord: oneclient_discord::DiscordRpc::spawn(false), - })) + Ok(Arc::new(LauncherState { + services, + auth, + java, + clusters, + settings: parking_lot::RwLock::new(settings), + metadata: tokio::sync::Mutex::new(oneclient_mc::MetadataStore::new()), + bundles: Arc::new(oneclient_content::bundles::BundlesManager::new()), + versions: Arc::new(crate::versions::VersionsManager::new()), + images: crate::images::ImageCacheStore::new(), + games: crate::game::GameProcessManager::new(), + discord: oneclient_discord::DiscordRpc::spawn(false), + })) } pub async fn ephemeral_services() -> LauncherResult { - let root = ephemeral_root().await?; - oneclient_common::paths::set_launcher_dir(root.clone()); - let db = oneclient_db::connect(root.join("example.db")).await?; - let (tx, rx) = mpsc::unbounded_channel(); - spawn_notification_handler(rx); + let root = ephemeral_root().await?; + oneclient_common::paths::set_launcher_dir(root.clone()); + let db = oneclient_db::connect(root.join("example.db")).await?; + let (tx, rx) = mpsc::unbounded_channel(); + spawn_notification_handler(rx); - Ok(LauncherServices { - events: EventBus::new(tx), - requester: RequestClient::new(oneclient_net::NetConfig::default())?, - db, - packages: PackageProviderRegistry::new(), - }) + Ok(LauncherServices { + events: EventBus::new(tx), + requester: RequestClient::new(oneclient_net::NetConfig::default())?, + db, + packages: PackageProviderRegistry::new(), + }) } pub async fn seed_bundle_archive( - state: &LauncherState, - manifest: oneclient_content::bundles::BundleManifest, + state: &LauncherState, + manifest: oneclient_content::bundles::BundleManifest, ) -> LauncherResult<()> { - let disk_path = format!("bundles/{}.mrpack", manifest.name); - let loader = manifest.loader as i64; + let disk_path = format!("bundles/{}.mrpack", manifest.name); + let loader = manifest.loader as i64; - oneclient_db::dao::bundle::upsert_bundle( - &state.services.db, - oneclient_db::models::NewBundle { - remote_path: &disk_path, - mc_version: &manifest.mc_version, - mc_loader: loader, - file_name: &format!("{}.mrpack", manifest.name), - name: Some(&manifest.name), - version_id: Some(&manifest.version_id), - category: Some(&manifest.category), - loader_version: Some(&manifest.loader_version), - disk_path: &disk_path, - hidden: false, - etag: None, - synced_at: None, - }, - ) - .await?; + oneclient_db::dao::bundle::upsert_bundle( + &state.services.db, + oneclient_db::models::NewBundle { + remote_path: &disk_path, + mc_version: &manifest.mc_version, + mc_loader: loader, + file_name: &format!("{}.mrpack", manifest.name), + name: Some(&manifest.name), + version_id: Some(&manifest.version_id), + category: Some(&manifest.category), + loader_version: Some(&manifest.loader_version), + disk_path: &disk_path, + hidden: false, + etag: None, + synced_at: None, + }, + ) + .await?; - let path = oneclient_common::paths::data_dir()?.join(&disk_path); - state.bundles.cache_archive_manifest(path, manifest).await; + let path = oneclient_common::paths::data_dir()?.join(&disk_path); + state.bundles.cache_archive_manifest(path, manifest).await; - Ok(()) + Ok(()) } diff --git a/packages/oneclient_core/src/error.rs b/packages/oneclient_core/src/error.rs index e36d89f3..32b61259 100644 --- a/packages/oneclient_core/src/error.rs +++ b/packages/oneclient_core/src/error.rs @@ -103,7 +103,7 @@ impl LauncherError { } /// Whether the install is missing pieces and so is worth repairing - #[must_use] + #[must_use] pub fn indicates_missing_files(&self) -> bool { let mut source: Option<&(dyn std::error::Error + 'static)> = Some(self); @@ -181,8 +181,9 @@ impl SentryExclusion for reqwest::Error { impl SentryExclusion for polyio::IOError { fn is_sentry_excluded(&self) -> bool { match self { - polyio::IOError::IOError(source) - | polyio::IOError::PathIOError { source, .. } => source.is_sentry_excluded(), + polyio::IOError::IOError(source) | polyio::IOError::PathIOError { source, .. } => { + source.is_sentry_excluded() + } _ => false, } } @@ -205,8 +206,7 @@ mod tests { #[test] fn a_missing_asset_directory_asks_for_a_repair() { - let path = - "/Users/someone/Library/Application Support/org.Polyfrost.OneClient-dev/metadata/assets"; + let path = "/Users/someone/Library/Application Support/org.Polyfrost.OneClient-dev/metadata/assets"; let err = not_found(path); assert!(err.indicates_missing_files(), "{err}"); @@ -225,12 +225,8 @@ mod tests { #[test] fn dismissing_a_dialog_is_a_cancellation_not_a_failure() { - assert!( - LauncherError::JavaError(oneclient_java::JavaError::Cancelled).is_cancelled() - ); - assert!( - LauncherError::AuthError(oneclient_auth::AuthError::LoginCancelled).is_cancelled() - ); + assert!(LauncherError::JavaError(oneclient_java::JavaError::Cancelled).is_cancelled()); + assert!(LauncherError::AuthError(oneclient_auth::AuthError::LoginCancelled).is_cancelled()); } /// The whole point of the split anything that is not a dismissal keeps @@ -271,8 +267,6 @@ mod tests { } assert!(!LauncherError::NotInitialized.indicates_missing_files()); - assert!( - !LauncherError::Minecraft("bad manifest".into()).indicates_missing_files() - ); + assert!(!LauncherError::Minecraft("bad manifest".into()).indicates_missing_files()); } } diff --git a/packages/oneclient_core/src/game/analytics.rs b/packages/oneclient_core/src/game/analytics.rs index fd4fdf2a..8acca949 100644 --- a/packages/oneclient_core/src/game/analytics.rs +++ b/packages/oneclient_core/src/game/analytics.rs @@ -28,628 +28,640 @@ const MAX_PERSONAS: usize = 6; #[derive(Debug, Clone, PartialEq)] pub struct DayPlaytime { - pub date: String, - pub secs: i64, + pub date: String, + pub secs: i64, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Persona { - Veteran, - Marathoner, - Regular, - Loyalist, - Explorer, - NightOwl, - EarlyBird, - WeekendWarrior, - Gamer, - Sprinter, + Veteran, + Marathoner, + Regular, + Loyalist, + Explorer, + NightOwl, + EarlyBird, + WeekendWarrior, + Gamer, + Sprinter, } impl Persona { - pub fn title(self) -> &'static str { - match self { - Persona::Veteran => "Veteran", - Persona::Marathoner => "Marathoner", - Persona::Regular => "Regular", - Persona::Loyalist => "Loyalist", - Persona::Explorer => "Explorer", - Persona::NightOwl => "Night Owl", - Persona::EarlyBird => "Early Bird", - Persona::WeekendWarrior => "Weekend Warrior", - Persona::Gamer => "Gamer", - Persona::Sprinter => "Sprinter", - } - } + pub fn title(self) -> &'static str { + match self { + Persona::Veteran => "Veteran", + Persona::Marathoner => "Marathoner", + Persona::Regular => "Regular", + Persona::Loyalist => "Loyalist", + Persona::Explorer => "Explorer", + Persona::NightOwl => "Night Owl", + Persona::EarlyBird => "Early Bird", + Persona::WeekendWarrior => "Weekend Warrior", + Persona::Gamer => "Gamer", + Persona::Sprinter => "Sprinter", + } + } - pub fn description(self) -> &'static str { - match self { - Persona::Veteran => "500h+ played", - Persona::Marathoner => "An 8h+ session", - Persona::Regular => "A week straight", - Persona::Loyalist => "Mostly one server", - Persona::Explorer => "10+ servers joined", - Persona::NightOwl => "Mostly after dark", - Persona::EarlyBird => "Mostly before 9am", - Persona::WeekendWarrior => "Mostly weekends", - Persona::Gamer => "5h+ per day played", - Persona::Sprinter => "Short and frequent", - } - } + pub fn description(self) -> &'static str { + match self { + Persona::Veteran => "500h+ played", + Persona::Marathoner => "An 8h+ session", + Persona::Regular => "A week straight", + Persona::Loyalist => "Mostly one server", + Persona::Explorer => "10+ servers joined", + Persona::NightOwl => "Mostly after dark", + Persona::EarlyBird => "Mostly before 9am", + Persona::WeekendWarrior => "Mostly weekends", + Persona::Gamer => "5h+ per day played", + Persona::Sprinter => "Short and frequent", + } + } } fn personas_for(stats: &PlaytimeStats, servers: &[ServerStat]) -> Vec { - if stats.total_secs <= 0 { - return Vec::new(); - } + if stats.total_secs <= 0 { + return Vec::new(); + } - let total = stats.total_secs as f64; - let share = |hours: &[usize]| -> f64 { - hours.iter().map(|&h| stats.per_hour[h]).sum::() as f64 / total - }; - let weekend = WEEKEND_DAYS - .iter() - .map(|&d| stats.per_weekday[d]) - .sum::() as f64 - / total; + let total = stats.total_secs as f64; + let share = |hours: &[usize]| -> f64 { + hours.iter().map(|&h| stats.per_hour[h]).sum::() as f64 / total + }; + let weekend = WEEKEND_DAYS + .iter() + .map(|&d| stats.per_weekday[d]) + .sum::() as f64 + / total; - let server_secs: i64 = servers.iter().map(|s| s.total_secs).sum(); - let top_server_share = servers - .iter() - .map(|s| s.total_secs) - .max() - .filter(|_| server_secs > 0) - .map_or(0.0, |top| top as f64 / server_secs as f64); + let server_secs: i64 = servers.iter().map(|s| s.total_secs).sum(); + let top_server_share = servers + .iter() + .map(|s| s.total_secs) + .max() + .filter(|_| server_secs > 0) + .map_or(0.0, |top| top as f64 / server_secs as f64); - let mut lengths: Vec = stats.session_secs.clone(); - lengths.sort_unstable(); - let median = lengths.get(lengths.len() / 2).copied().unwrap_or(0); + let mut lengths: Vec = stats.session_secs.clone(); + lengths.sort_unstable(); + let median = lengths.get(lengths.len() / 2).copied().unwrap_or(0); - let earned = [ - (Persona::Veteran, stats.total_secs >= VETERAN_SECS), - ( - Persona::Marathoner, - stats.longest_session_secs >= MARATHON_SECS, - ), - (Persona::Regular, stats.longest_streak >= REGULAR_STREAK), - ( - Persona::Loyalist, - servers.len() > 1 && top_server_share >= LOYALIST_SHARE, - ), - (Persona::Explorer, servers.len() >= EXPLORER_SERVERS), - (Persona::NightOwl, share(&NIGHT_HOURS) >= NIGHT_OWL_SHARE), - ( - Persona::EarlyBird, - share(&MORNING_HOURS) >= EARLY_BIRD_SHARE, - ), - (Persona::WeekendWarrior, weekend >= WEEKEND_SHARE), - ( - Persona::Gamer, - stats.avg_secs_per_active_day > GAMER_SECS_PER_DAY, - ), - ( - Persona::Sprinter, - stats.session_count >= SPRINTER_SESSIONS && median < SPRINTER_SECS, - ), - ]; + let earned = [ + (Persona::Veteran, stats.total_secs >= VETERAN_SECS), + ( + Persona::Marathoner, + stats.longest_session_secs >= MARATHON_SECS, + ), + (Persona::Regular, stats.longest_streak >= REGULAR_STREAK), + ( + Persona::Loyalist, + servers.len() > 1 && top_server_share >= LOYALIST_SHARE, + ), + (Persona::Explorer, servers.len() >= EXPLORER_SERVERS), + (Persona::NightOwl, share(&NIGHT_HOURS) >= NIGHT_OWL_SHARE), + ( + Persona::EarlyBird, + share(&MORNING_HOURS) >= EARLY_BIRD_SHARE, + ), + (Persona::WeekendWarrior, weekend >= WEEKEND_SHARE), + ( + Persona::Gamer, + stats.avg_secs_per_active_day > GAMER_SECS_PER_DAY, + ), + ( + Persona::Sprinter, + stats.session_count >= SPRINTER_SESSIONS && median < SPRINTER_SECS, + ), + ]; - earned - .into_iter() - .filter_map(|(persona, earned)| earned.then_some(persona)) - .take(MAX_PERSONAS) - .collect() + earned + .into_iter() + .filter_map(|(persona, earned)| earned.then_some(persona)) + .take(MAX_PERSONAS) + .collect() } pub const WEEKDAY_LABELS: [&str; 7] = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; #[derive(Debug, Clone, PartialEq)] pub struct PlaytimeStats { - pub total_secs: i64, - pub session_count: usize, - pub per_weekday: [i64; 7], - pub per_hour: [i64; 24], - pub daily: Vec, - pub session_secs: Vec, - pub longest_session_secs: i64, - pub active_days: usize, - pub current_streak: usize, - pub longest_streak: usize, - pub avg_secs_per_active_day: f64, - pub peak_hour: Option, - pub peak_weekday: Option, - pub night_share: f64, + pub total_secs: i64, + pub session_count: usize, + pub per_weekday: [i64; 7], + pub per_hour: [i64; 24], + pub daily: Vec, + pub session_secs: Vec, + pub longest_session_secs: i64, + pub active_days: usize, + pub current_streak: usize, + pub longest_streak: usize, + pub avg_secs_per_active_day: f64, + pub peak_hour: Option, + pub peak_weekday: Option, + pub night_share: f64, } impl PlaytimeStats { - pub fn from_spans(spans: &[SessionSpan]) -> Self { - Self::from_spans_on(spans, Local::now().date_naive()) - } + pub fn from_spans(spans: &[SessionSpan]) -> Self { + Self::from_spans_on(spans, Local::now().date_naive()) + } - fn from_spans_on(spans: &[SessionSpan], today: NaiveDate) -> Self { - let mut per_weekday = [0i64; 7]; - let mut per_hour = [0i64; 24]; - let mut total_secs = 0i64; - let mut night_secs = 0i64; - let mut session_count = 0usize; - let mut by_day: BTreeMap = BTreeMap::new(); - let mut session_secs: Vec = Vec::new(); + fn from_spans_on(spans: &[SessionSpan], today: NaiveDate) -> Self { + let mut per_weekday = [0i64; 7]; + let mut per_hour = [0i64; 24]; + let mut total_secs = 0i64; + let mut night_secs = 0i64; + let mut session_count = 0usize; + let mut by_day: BTreeMap = BTreeMap::new(); + let mut session_secs: Vec = Vec::new(); - for span in spans { - let Some(ended) = span.ended_at.as_deref() else { - continue; - }; - let (Ok(start), Ok(end)) = ( - DateTime::parse_from_rfc3339(&span.started_at), - DateTime::parse_from_rfc3339(ended), - ) else { - continue; - }; + for span in spans { + let Some(ended) = span.ended_at.as_deref() else { + continue; + }; + let (Ok(start), Ok(end)) = ( + DateTime::parse_from_rfc3339(&span.started_at), + DateTime::parse_from_rfc3339(ended), + ) else { + continue; + }; - let secs = (end - start).num_seconds(); - if secs <= 0 { - continue; - } + let secs = (end - start).num_seconds(); + if secs <= 0 { + continue; + } - total_secs += secs; - session_count += 1; - session_secs.push(secs); + total_secs += secs; + session_count += 1; + session_secs.push(secs); - for_each_local_hour( - start.with_timezone(&Utc), - end.with_timezone(&Utc), - |at, slice| { - let hour = at.hour() as usize; - per_hour[hour] += slice; - per_weekday[at.weekday().num_days_from_monday() as usize] += slice; - if NIGHT_HOURS.contains(&hour) { - night_secs += slice; - } - *by_day.entry(at.date_naive()).or_insert(0) += slice; - }, - ); - } + for_each_local_hour( + start.with_timezone(&Utc), + end.with_timezone(&Utc), + |at, slice| { + let hour = at.hour() as usize; + per_hour[hour] += slice; + per_weekday[at.weekday().num_days_from_monday() as usize] += slice; + if NIGHT_HOURS.contains(&hour) { + night_secs += slice; + } + *by_day.entry(at.date_naive()).or_insert(0) += slice; + }, + ); + } - let longest_session_secs = session_secs.iter().copied().max().unwrap_or(0); - let dates: Vec = by_day.keys().copied().collect(); - let (current_streak, longest_streak) = streaks(&dates, today); - let daily: Vec = by_day - .iter() - .map(|(date, secs)| DayPlaytime { - date: date.format("%Y-%m-%d").to_string(), - secs: *secs, - }) - .collect(); + let longest_session_secs = session_secs.iter().copied().max().unwrap_or(0); + let dates: Vec = by_day.keys().copied().collect(); + let (current_streak, longest_streak) = streaks(&dates, today); + let daily: Vec = by_day + .iter() + .map(|(date, secs)| DayPlaytime { + date: date.format("%Y-%m-%d").to_string(), + secs: *secs, + }) + .collect(); - let active_days = daily.len(); - let avg_secs_per_active_day = if active_days > 0 { - total_secs as f64 / active_days as f64 - } else { - 0.0 - }; - let night_share = if total_secs > 0 { - night_secs as f64 / total_secs as f64 - } else { - 0.0 - }; + let active_days = daily.len(); + let avg_secs_per_active_day = if active_days > 0 { + total_secs as f64 / active_days as f64 + } else { + 0.0 + }; + let night_share = if total_secs > 0 { + night_secs as f64 / total_secs as f64 + } else { + 0.0 + }; - let peak_hour = argmax(&per_hour); - let peak_weekday = argmax(&per_weekday); + let peak_hour = argmax(&per_hour); + let peak_weekday = argmax(&per_weekday); - Self { - total_secs, - session_count, - per_weekday, - per_hour, - daily, - session_secs, - longest_session_secs, - active_days, - current_streak, - longest_streak, - avg_secs_per_active_day, - peak_hour, - peak_weekday, - night_share, - } - } + Self { + total_secs, + session_count, + per_weekday, + per_hour, + daily, + session_secs, + longest_session_secs, + active_days, + current_streak, + longest_streak, + avg_secs_per_active_day, + peak_hour, + peak_weekday, + night_share, + } + } } fn for_each_local_hour( - start: DateTime, - end: DateTime, - mut f: impl FnMut(DateTime, i64), + start: DateTime, + end: DateTime, + mut f: impl FnMut(DateTime, i64), ) { - let mut cur = start; - while cur < end { - let local = cur.with_timezone(&Local); - let boundary = next_local_hour(local); - let next = if boundary > cur { - boundary.min(end) - } else { - (cur + Duration::hours(1)).min(end) - }; - f(local, (next - cur).num_seconds()); - cur = next; - } + let mut cur = start; + while cur < end { + let local = cur.with_timezone(&Local); + let boundary = next_local_hour(local); + let next = if boundary > cur { + boundary.min(end) + } else { + (cur + Duration::hours(1)).min(end) + }; + f(local, (next - cur).num_seconds()); + cur = next; + } } fn next_local_hour(local: DateTime) -> DateTime { - let naive = local.naive_local(); - let top = match naive.date().and_hms_opt(naive.hour(), 0, 0) { - Some(top) => top + Duration::hours(1), - None => return (local + Duration::hours(1)).with_timezone(&Utc), - }; + let naive = local.naive_local(); + let top = match naive.date().and_hms_opt(naive.hour(), 0, 0) { + Some(top) => top + Duration::hours(1), + None => return (local + Duration::hours(1)).with_timezone(&Utc), + }; - Local - .from_local_datetime(&top) - .earliest() - .map(|at| at.with_timezone(&Utc)) - .unwrap_or_else(|| (local + Duration::hours(1)).with_timezone(&Utc)) + Local + .from_local_datetime(&top) + .earliest() + .map(|at| at.with_timezone(&Utc)) + .unwrap_or_else(|| (local + Duration::hours(1)).with_timezone(&Utc)) } fn streaks(days: &[NaiveDate], today: NaiveDate) -> (usize, usize) { - let mut longest = 0usize; - let mut run = 0usize; - let mut prev: Option = None; + let mut longest = 0usize; + let mut run = 0usize; + let mut prev: Option = None; - for &day in days { - run = match prev { - Some(p) if p == day => run, - Some(p) if p.succ_opt() == Some(day) => run + 1, - _ => 1, - }; - longest = longest.max(run); - prev = Some(day); - } + for &day in days { + run = match prev { + Some(p) if p == day => run, + Some(p) if p.succ_opt() == Some(day) => run + 1, + _ => 1, + }; + longest = longest.max(run); + prev = Some(day); + } - let current = match days.last() { - Some(last) if *last == today || Some(*last) == today.pred_opt() => run, - _ => 0, - }; + let current = match days.last() { + Some(last) if *last == today || Some(*last) == today.pred_opt() => run, + _ => 0, + }; - (current, longest) + (current, longest) } fn argmax(buckets: &[i64]) -> Option { - buckets - .iter() - .enumerate() - .filter(|&(_, &v)| v > 0) - .max_by_key(|&(_, &v)| v) - .map(|(i, _)| i) + buckets + .iter() + .enumerate() + .filter(|&(_, &v)| v > 0) + .max_by_key(|&(_, &v)| v) + .map(|(i, _)| i) } #[derive(Debug, Clone, PartialEq, Eq)] pub struct ServerStat { - pub address: String, - pub port: Option, - pub joins: i64, - pub total_secs: i64, - pub last_played: Option, - pub is_ip: bool, + pub address: String, + pub port: Option, + pub joins: i64, + pub total_secs: i64, + pub last_played: Option, + pub is_ip: bool, } fn address_is_ip(address: &str) -> bool { - address.parse::().is_ok() + address.parse::().is_ok() } pub fn aggregate_servers(rows: &[GameSessionServerRow]) -> Vec { - struct Acc { - port: Option, - joins: i64, - total_secs: i64, - last_played: Option, - } + struct Acc { + port: Option, + joins: i64, + total_secs: i64, + last_played: Option, + } - let mut map: HashMap = HashMap::new(); - for row in rows { - let acc = map.entry(row.address.clone()).or_insert(Acc { - port: row.port, - joins: 0, - total_secs: 0, - last_played: None, - }); - acc.joins += 1; + let mut map: HashMap = HashMap::new(); + for row in rows { + let acc = map.entry(row.address.clone()).or_insert(Acc { + port: row.port, + joins: 0, + total_secs: 0, + last_played: None, + }); + acc.joins += 1; - if let Some(ended) = row.disconnected_at.as_deref() - && let (Ok(start), Ok(end)) = ( - DateTime::parse_from_rfc3339(&row.joined_at), - DateTime::parse_from_rfc3339(ended), - ) { - let secs = (end - start).num_seconds(); - if secs > 0 { - acc.total_secs += secs; - } - } + if let Some(ended) = row.disconnected_at.as_deref() + && let (Ok(start), Ok(end)) = ( + DateTime::parse_from_rfc3339(&row.joined_at), + DateTime::parse_from_rfc3339(ended), + ) + { + let secs = (end - start).num_seconds(); + if secs > 0 { + acc.total_secs += secs; + } + } - if acc - .last_played - .as_deref() - .is_none_or(|prev| row.joined_at.as_str() > prev) - { - acc.last_played = Some(row.joined_at.clone()); - acc.port = row.port; - } - } + if acc + .last_played + .as_deref() + .is_none_or(|prev| row.joined_at.as_str() > prev) + { + acc.last_played = Some(row.joined_at.clone()); + acc.port = row.port; + } + } - let mut servers: Vec = map - .into_iter() - .map(|(address, acc)| ServerStat { - is_ip: address_is_ip(&address), - address, - port: acc.port, - joins: acc.joins, - total_secs: acc.total_secs, - last_played: acc.last_played, - }) - .collect(); + let mut servers: Vec = map + .into_iter() + .map(|(address, acc)| ServerStat { + is_ip: address_is_ip(&address), + address, + port: acc.port, + joins: acc.joins, + total_secs: acc.total_secs, + last_played: acc.last_played, + }) + .collect(); - servers.sort_by(|a, b| { - b.total_secs - .cmp(&a.total_secs) - .then(b.joins.cmp(&a.joins)) - .then(a.address.cmp(&b.address)) - }); - servers + servers.sort_by(|a, b| { + b.total_secs + .cmp(&a.total_secs) + .then(b.joins.cmp(&a.joins)) + .then(a.address.cmp(&b.address)) + }); + servers } #[derive(Debug, Clone, PartialEq)] pub struct Analytics { - pub playtime: PlaytimeStats, - pub servers: Vec, - pub personas: Vec, + pub playtime: PlaytimeStats, + pub servers: Vec, + pub personas: Vec, } impl Analytics { - pub fn new(playtime: PlaytimeStats, servers: Vec) -> Self { - let personas = personas_for(&playtime, &servers); - Self { - playtime, - servers, - personas, - } - } + pub fn new(playtime: PlaytimeStats, servers: Vec) -> Self { + let personas = personas_for(&playtime, &servers); + Self { + playtime, + servers, + personas, + } + } } #[tracing::instrument(level = "debug", skip(db))] pub async fn global_analytics(db: &oneclient_db::DbPool) -> LauncherResult { - let spans = session_dao::all_session_spans(db).await?; - let servers = session_dao::all_session_servers(db).await?; - Ok(Analytics::new( - PlaytimeStats::from_spans(&spans), - aggregate_servers(&servers), - )) + let spans = session_dao::all_session_spans(db).await?; + let servers = session_dao::all_session_servers(db).await?; + Ok(Analytics::new( + PlaytimeStats::from_spans(&spans), + aggregate_servers(&servers), + )) } #[tracing::instrument(level = "debug", skip(db))] -pub async fn cluster_analytics(db: &oneclient_db::DbPool, cluster_id: i64) -> LauncherResult { - let spans = session_dao::session_spans_for_cluster(db, cluster_id).await?; - let servers = session_dao::session_servers_for_cluster(db, cluster_id).await?; - Ok(Analytics::new( - PlaytimeStats::from_spans(&spans), - aggregate_servers(&servers), - )) +pub async fn cluster_analytics( + db: &oneclient_db::DbPool, + cluster_id: i64, +) -> LauncherResult { + let spans = session_dao::session_spans_for_cluster(db, cluster_id).await?; + let servers = session_dao::session_servers_for_cluster(db, cluster_id).await?; + Ok(Analytics::new( + PlaytimeStats::from_spans(&spans), + aggregate_servers(&servers), + )) } #[cfg(test)] mod tests { - use super::*; + use super::*; - fn span(start: &str, end: &str) -> SessionSpan { - SessionSpan { - started_at: start.to_string(), - ended_at: Some(end.to_string()), - } - } + fn span(start: &str, end: &str) -> SessionSpan { + SessionSpan { + started_at: start.to_string(), + ended_at: Some(end.to_string()), + } + } - #[test] - fn empty_has_no_personas() { - let stats = PlaytimeStats::from_spans(&[]); - assert_eq!(stats.total_secs, 0); - assert!(personas_for(&stats, &[]).is_empty()); - assert_eq!(stats.peak_hour, None); - } + #[test] + fn empty_has_no_personas() { + let stats = PlaytimeStats::from_spans(&[]); + assert_eq!(stats.total_secs, 0); + assert!(personas_for(&stats, &[]).is_empty()); + assert_eq!(stats.peak_hour, None); + } - #[test] - fn skips_open_and_negative_spans() { - let spans = vec![ - SessionSpan { - started_at: "2026-01-01T10:00:00+00:00".into(), - ended_at: None, - }, - span("2026-01-01T10:00:00+00:00", "2026-01-01T09:00:00+00:00"), - ]; - let stats = PlaytimeStats::from_spans(&spans); - assert_eq!(stats.session_count, 0); - assert_eq!(stats.total_secs, 0); - } + #[test] + fn skips_open_and_negative_spans() { + let spans = vec![ + SessionSpan { + started_at: "2026-01-01T10:00:00+00:00".into(), + ended_at: None, + }, + span("2026-01-01T10:00:00+00:00", "2026-01-01T09:00:00+00:00"), + ]; + let stats = PlaytimeStats::from_spans(&spans); + assert_eq!(stats.session_count, 0); + assert_eq!(stats.total_secs, 0); + } - fn server(address: &str, joined: &str, disconnected: Option<&str>) -> GameSessionServerRow { - GameSessionServerRow { - session_started_at: "s".into(), - address: address.into(), - port: Some(25565), - joined_at: joined.into(), - disconnected_at: disconnected.map(str::to_string), - } - } + fn server(address: &str, joined: &str, disconnected: Option<&str>) -> GameSessionServerRow { + GameSessionServerRow { + session_started_at: "s".into(), + address: address.into(), + port: Some(25565), + joined_at: joined.into(), + disconnected_at: disconnected.map(str::to_string), + } + } - #[test] - fn ip_addresses_are_flagged_domains_are_not() { - assert!(address_is_ip("192.168.1.10")); - assert!(address_is_ip("::1")); - assert!(!address_is_ip("mc.hypixel.net")); - assert!(!address_is_ip("play.example.com")); - } + #[test] + fn ip_addresses_are_flagged_domains_are_not() { + assert!(address_is_ip("192.168.1.10")); + assert!(address_is_ip("::1")); + assert!(!address_is_ip("mc.hypixel.net")); + assert!(!address_is_ip("play.example.com")); + } - #[test] - fn aggregate_servers_sums_time_and_counts_joins() { - let rows = vec![ - server( - "mc.hypixel.net", - "2026-01-01T10:00:00+00:00", - Some("2026-01-01T11:00:00+00:00"), - ), - server("mc.hypixel.net", "2026-01-02T10:00:00+00:00", None), - server( - "192.168.1.10", - "2026-01-01T12:00:00+00:00", - Some("2026-01-01T12:30:00+00:00"), - ), - ]; + #[test] + fn aggregate_servers_sums_time_and_counts_joins() { + let rows = vec![ + server( + "mc.hypixel.net", + "2026-01-01T10:00:00+00:00", + Some("2026-01-01T11:00:00+00:00"), + ), + server("mc.hypixel.net", "2026-01-02T10:00:00+00:00", None), + server( + "192.168.1.10", + "2026-01-01T12:00:00+00:00", + Some("2026-01-01T12:30:00+00:00"), + ), + ]; - let servers = aggregate_servers(&rows); - assert_eq!(servers.len(), 2); - assert_eq!(servers[0].address, "mc.hypixel.net"); - assert_eq!(servers[0].joins, 2); - assert_eq!(servers[0].total_secs, 3600); - assert!(!servers[0].is_ip); - assert_eq!(servers[1].address, "192.168.1.10"); - assert_eq!(servers[1].joins, 1); - assert_eq!(servers[1].total_secs, 1800); - assert!(servers[1].is_ip); - } + let servers = aggregate_servers(&rows); + assert_eq!(servers.len(), 2); + assert_eq!(servers[0].address, "mc.hypixel.net"); + assert_eq!(servers[0].joins, 2); + assert_eq!(servers[0].total_secs, 3600); + assert!(!servers[0].is_ip); + assert_eq!(servers[1].address, "192.168.1.10"); + assert_eq!(servers[1].joins, 1); + assert_eq!(servers[1].total_secs, 1800); + assert!(servers[1].is_ip); + } - fn day(s: &str) -> NaiveDate { - NaiveDate::parse_from_str(s, "%Y-%m-%d").expect("a date") - } + fn day(s: &str) -> NaiveDate { + NaiveDate::parse_from_str(s, "%Y-%m-%d").expect("a date") + } - fn local_span(start: &str, end: &str) -> SessionSpan { - let at = |s: &str| { - Local - .from_local_datetime( - &chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S").expect("a time"), - ) - .earliest() - .expect("an unambiguous local time") - .to_rfc3339() - }; - SessionSpan { - started_at: at(start), - ended_at: Some(at(end)), - } - } + fn local_span(start: &str, end: &str) -> SessionSpan { + let at = |s: &str| { + Local + .from_local_datetime( + &chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S").expect("a time"), + ) + .earliest() + .expect("an unambiguous local time") + .to_rfc3339() + }; + SessionSpan { + started_at: at(start), + ended_at: Some(at(end)), + } + } - #[test] - fn an_overnight_session_is_split_across_both_days() { - let spans = vec![local_span("2026-07-16 22:00:00", "2026-07-17 04:00:00")]; - let stats = PlaytimeStats::from_spans_on(&spans, day("2026-07-17")); + #[test] + fn an_overnight_session_is_split_across_both_days() { + let spans = vec![local_span("2026-07-16 22:00:00", "2026-07-17 04:00:00")]; + let stats = PlaytimeStats::from_spans_on(&spans, day("2026-07-17")); - assert_eq!(stats.total_secs, 6 * 3600); - assert_eq!(stats.session_count, 1, "still one session"); - assert_eq!(stats.daily.len(), 2); - assert_eq!(stats.daily[0].date, "2026-07-16"); - assert_eq!(stats.daily[0].secs, 2 * 3600); - assert_eq!(stats.daily[1].date, "2026-07-17"); - assert_eq!(stats.daily[1].secs, 4 * 3600); - } + assert_eq!(stats.total_secs, 6 * 3600); + assert_eq!(stats.session_count, 1, "still one session"); + assert_eq!(stats.daily.len(), 2); + assert_eq!(stats.daily[0].date, "2026-07-16"); + assert_eq!(stats.daily[0].secs, 2 * 3600); + assert_eq!(stats.daily[1].date, "2026-07-17"); + assert_eq!(stats.daily[1].secs, 4 * 3600); + } - #[test] - fn a_multi_day_session_books_a_full_day_per_day() { - let spans = vec![local_span("2026-07-16 12:00:00", "2026-07-19 12:00:00")]; - let stats = PlaytimeStats::from_spans_on(&spans, day("2026-07-19")); + #[test] + fn a_multi_day_session_books_a_full_day_per_day() { + let spans = vec![local_span("2026-07-16 12:00:00", "2026-07-19 12:00:00")]; + let stats = PlaytimeStats::from_spans_on(&spans, day("2026-07-19")); - assert_eq!(stats.daily.len(), 4, "three nights means four days touched"); - assert_eq!(stats.daily[1].secs, 24 * 3600); - assert_eq!(stats.daily[2].secs, 24 * 3600); - assert_eq!( - stats.daily.iter().map(|d| d.secs).sum::(), - stats.total_secs - ); - assert!(stats.per_hour.iter().all(|&h| h > 0)); - } + assert_eq!(stats.daily.len(), 4, "three nights means four days touched"); + assert_eq!(stats.daily[1].secs, 24 * 3600); + assert_eq!(stats.daily[2].secs, 24 * 3600); + assert_eq!( + stats.daily.iter().map(|d| d.secs).sum::(), + stats.total_secs + ); + assert!(stats.per_hour.iter().all(|&h| h > 0)); + } - #[test] - fn weekday_totals_follow_the_hours_played_not_the_start() { - let spans = vec![local_span("2026-07-17 23:00:00", "2026-07-18 09:00:00")]; - let stats = PlaytimeStats::from_spans_on(&spans, day("2026-07-18")); + #[test] + fn weekday_totals_follow_the_hours_played_not_the_start() { + let spans = vec![local_span("2026-07-17 23:00:00", "2026-07-18 09:00:00")]; + let stats = PlaytimeStats::from_spans_on(&spans, day("2026-07-18")); - assert_eq!(stats.per_weekday[4], 3600, "Friday keeps only its one hour"); - assert_eq!(stats.per_weekday[5], 9 * 3600); - assert_eq!(stats.peak_weekday, Some(5)); - } + assert_eq!(stats.per_weekday[4], 3600, "Friday keeps only its one hour"); + assert_eq!(stats.per_weekday[5], 9 * 3600); + assert_eq!(stats.peak_weekday, Some(5)); + } - #[test] - fn a_streak_survives_an_empty_today_but_not_an_empty_yesterday() { - let days = [day("2026-07-15"), day("2026-07-16"), day("2026-07-17")]; + #[test] + fn a_streak_survives_an_empty_today_but_not_an_empty_yesterday() { + let days = [day("2026-07-15"), day("2026-07-16"), day("2026-07-17")]; - assert_eq!(streaks(&days, day("2026-07-17")), (3, 3)); - assert_eq!(streaks(&days, day("2026-07-18")), (3, 3), "today is not over"); - assert_eq!(streaks(&days, day("2026-07-19")), (0, 3), "yesterday was missed"); - } + assert_eq!(streaks(&days, day("2026-07-17")), (3, 3)); + assert_eq!( + streaks(&days, day("2026-07-18")), + (3, 3), + "today is not over" + ); + assert_eq!( + streaks(&days, day("2026-07-19")), + (0, 3), + "yesterday was missed" + ); + } - #[test] - fn a_gap_ends_the_run_but_keeps_the_record() { - let days = [ - day("2026-07-01"), - day("2026-07-02"), - day("2026-07-03"), - day("2026-07-10"), - ]; - assert_eq!(streaks(&days, day("2026-07-10")), (1, 3)); - assert_eq!(streaks(&[], day("2026-07-10")), (0, 0)); - } + #[test] + fn a_gap_ends_the_run_but_keeps_the_record() { + let days = [ + day("2026-07-01"), + day("2026-07-02"), + day("2026-07-03"), + day("2026-07-10"), + ]; + assert_eq!(streaks(&days, day("2026-07-10")), (1, 3)); + assert_eq!(streaks(&[], day("2026-07-10")), (0, 0)); + } - #[test] - fn gamer_needs_five_hour_average() { - let spans = vec![local_span("2026-01-01 12:00:00", "2026-01-01 18:00:00")]; - let stats = PlaytimeStats::from_spans_on(&spans, day("2026-01-01")); - assert_eq!(stats.active_days, 1); - assert!(personas_for(&stats, &[]).contains(&Persona::Gamer)); - } + #[test] + fn gamer_needs_five_hour_average() { + let spans = vec![local_span("2026-01-01 12:00:00", "2026-01-01 18:00:00")]; + let stats = PlaytimeStats::from_spans_on(&spans, day("2026-01-01")); + assert_eq!(stats.active_days, 1); + assert!(personas_for(&stats, &[]).contains(&Persona::Gamer)); + } - fn stat_server(address: &str, total_secs: i64) -> ServerStat { - ServerStat { - address: address.into(), - port: None, - joins: 1, - total_secs, - last_played: None, - is_ip: false, - } - } + fn stat_server(address: &str, total_secs: i64) -> ServerStat { + ServerStat { + address: address.into(), + port: None, + joins: 1, + total_secs, + last_played: None, + is_ip: false, + } + } - #[test] - fn loyalty_needs_somewhere_else_to_have_been() { - let spans = vec![local_span("2026-01-01 12:00:00", "2026-01-01 18:00:00")]; - let stats = PlaytimeStats::from_spans_on(&spans, day("2026-01-01")); + #[test] + fn loyalty_needs_somewhere_else_to_have_been() { + let spans = vec![local_span("2026-01-01 12:00:00", "2026-01-01 18:00:00")]; + let stats = PlaytimeStats::from_spans_on(&spans, day("2026-01-01")); - let only_one = [stat_server("a.example.com", 3600)]; - assert!( - !personas_for(&stats, &only_one).contains(&Persona::Loyalist), - "a single server is loyalty to nothing" - ); + let only_one = [stat_server("a.example.com", 3600)]; + assert!( + !personas_for(&stats, &only_one).contains(&Persona::Loyalist), + "a single server is loyalty to nothing" + ); - let favourite = [ - stat_server("a.example.com", 9000), - stat_server("b.example.com", 1000), - ]; - assert!(personas_for(&stats, &favourite).contains(&Persona::Loyalist)); - } + let favourite = [ + stat_server("a.example.com", 9000), + stat_server("b.example.com", 1000), + ]; + assert!(personas_for(&stats, &favourite).contains(&Persona::Loyalist)); + } - #[test] - fn an_overnight_session_still_reads_as_a_night_owl() { - let spans = vec![local_span("2026-01-01 22:00:00", "2026-01-02 06:00:00")]; - let stats = PlaytimeStats::from_spans_on(&spans, day("2026-01-02")); + #[test] + fn an_overnight_session_still_reads_as_a_night_owl() { + let spans = vec![local_span("2026-01-01 22:00:00", "2026-01-02 06:00:00")]; + let stats = PlaytimeStats::from_spans_on(&spans, day("2026-01-02")); - assert!(personas_for(&stats, &[]).contains(&Persona::NightOwl)); - assert!(personas_for(&stats, &[]).contains(&Persona::Marathoner)); - } + assert!(personas_for(&stats, &[]).contains(&Persona::NightOwl)); + assert!(personas_for(&stats, &[]).contains(&Persona::Marathoner)); + } - #[test] - fn no_more_personas_than_fit() { - let mut spans = Vec::new(); - for day_of in 1..=20 { - spans.push(local_span( - &format!("2026-01-{day_of:02} 22:00:00"), - &format!("2026-01-{:02} 08:00:00", day_of + 1), - )); - } - let stats = PlaytimeStats::from_spans_on(&spans, day("2026-01-21")); - let servers: Vec = (0..12) - .map(|i| stat_server(&format!("s{i}.example.com"), 3600)) - .collect(); + #[test] + fn no_more_personas_than_fit() { + let mut spans = Vec::new(); + for day_of in 1..=20 { + spans.push(local_span( + &format!("2026-01-{day_of:02} 22:00:00"), + &format!("2026-01-{:02} 08:00:00", day_of + 1), + )); + } + let stats = PlaytimeStats::from_spans_on(&spans, day("2026-01-21")); + let servers: Vec = (0..12) + .map(|i| stat_server(&format!("s{i}.example.com"), 3600)) + .collect(); - assert_eq!(personas_for(&stats, &servers).len(), MAX_PERSONAS); - } + assert_eq!(personas_for(&stats, &servers).len(), MAX_PERSONAS); + } } diff --git a/packages/oneclient_core/src/game/diagnosis.rs b/packages/oneclient_core/src/game/diagnosis.rs index c58e7625..87dad72e 100644 --- a/packages/oneclient_core/src/game/diagnosis.rs +++ b/packages/oneclient_core/src/game/diagnosis.rs @@ -50,9 +50,7 @@ pub fn diagnose(line: &str) -> Option { return None; } - Some(CrashDiagnosis::CorruptArchive { - file: jar_in(line), - }) + Some(CrashDiagnosis::CorruptArchive { file: jar_in(line) }) } /// Opportunistic some JVMs omit the path entirely and a missing name still @@ -97,7 +95,10 @@ impl CrashWatch { && let Ok(mut found) = self.found.lock() && found.is_none() { - tracing::warn!(?diagnosis, "recognised a repairable crash cause in the game log"); + tracing::warn!( + ?diagnosis, + "recognised a repairable crash cause in the game log" + ); *found = Some(diagnosis); } } @@ -141,7 +142,8 @@ mod tests { #[test] fn a_windows_path_is_reduced_to_its_file_name() { - let line = r"Error: Invalid or corrupt jarfile C:\Users\someone\metadata\libraries\asm-9.7.jar"; + let line = + r"Error: Invalid or corrupt jarfile C:\Users\someone\metadata\libraries\asm-9.7.jar"; assert_eq!( diagnose(line), @@ -194,6 +196,9 @@ mod tests { watch.observe("java.util.zip.ZipException: error in opening zip file"); assert!(watch.take().is_some()); - assert!(watch.take().is_none(), "a diagnosis must not be reported twice"); + assert!( + watch.take().is_none(), + "a diagnosis must not be reported twice" + ); } } diff --git a/packages/oneclient_core/src/game/error.rs b/packages/oneclient_core/src/game/error.rs index 5057273a..82c8bd6a 100644 --- a/packages/oneclient_core/src/game/error.rs +++ b/packages/oneclient_core/src/game/error.rs @@ -47,9 +47,7 @@ pub enum GameError { impl GameError { pub(crate) fn wrapper_spawn(program: &str, err: &std::io::Error) -> Self { let reason = match err.kind() { - std::io::ErrorKind::NotFound => { - "command not found".to_string() - } + std::io::ErrorKind::NotFound => "command not found".to_string(), std::io::ErrorKind::PermissionDenied => { "insufficient permissions or file is not an executable".to_string() } diff --git a/packages/oneclient_core/src/game/fabric.rs b/packages/oneclient_core/src/game/fabric.rs index 611de7ca..a6433deb 100644 --- a/packages/oneclient_core/src/game/fabric.rs +++ b/packages/oneclient_core/src/game/fabric.rs @@ -107,7 +107,11 @@ mod tests { assert!(mods_folder_argument(GameLoader::Fabric, Some("0.16.5"), "-Xmx4G", dir).is_some()); - assert!(!uses_cluster_mods_folder(GameLoader::Fabric, Some("0.16.5"), mine)); + assert!(!uses_cluster_mods_folder( + GameLoader::Fabric, + Some("0.16.5"), + mine + )); assert!(mods_folder_argument(GameLoader::Fabric, Some("0.16.5"), mine, dir).is_none()); } } diff --git a/packages/oneclient_core/src/game/gpu/linux.rs b/packages/oneclient_core/src/game/gpu/linux.rs index 7734a33c..b2b89c2c 100644 --- a/packages/oneclient_core/src/game/gpu/linux.rs +++ b/packages/oneclient_core/src/game/gpu/linux.rs @@ -421,24 +421,46 @@ mod tests { ), ( "the same laptop on nouveau gets DRI_PRIME and no ICD filter", - vec![igpu(), Gpu { driver: Driver::Nouveau, ..dgpu() }], + vec![ + igpu(), + Gpu { + driver: Driver::Nouveau, + ..dgpu() + }, + ], vec![("DRI_PRIME", "pci-0000_01_00_0".to_string())], ), ( "an nvidia card without a render node is still a target", - vec![igpu(), Gpu { render_node: None, ..dgpu() }], + vec![ + igpu(), + Gpu { + render_node: None, + ..dgpu() + }, + ], nvidia_offload(), ), ( "an unbound card is never the target", - vec![igpu(), Gpu { driver: Driver::Other, render_node: None, ..dgpu() }], + vec![ + igpu(), + Gpu { + driver: Driver::Other, + render_node: None, + ..dgpu() + }, + ], vec![], ), ( "a bmc display adapter is skipped for the real card behind it", vec![ igpu(), - Gpu { render_node: None, ..gpu("0000:03:00.0", Driver::Other, Some(false)) }, + Gpu { + render_node: None, + ..gpu("0000:03:00.0", Driver::Other, Some(false)) + }, gpu("0000:c1:00.0", Driver::AmdGpu, Some(false)), ], dri_prime("pci-0000_c1_00_0", amd), @@ -447,7 +469,10 @@ mod tests { "the discrete card wins over a renderable card at a lower address", vec![ igpu(), - Gpu { discrete: false, ..gpu("0000:03:00.0", Driver::AmdGpu, Some(false)) }, + Gpu { + discrete: false, + ..gpu("0000:03:00.0", Driver::AmdGpu, Some(false)) + }, gpu("0000:c1:00.0", Driver::AmdGpu, Some(false)), ], dri_prime("pci-0000_c1_00_0", amd), @@ -486,7 +511,11 @@ mod tests { #[test] fn pci_tags_match_the_shape_mesa_builds() { assert_eq!(pci_tag("0000:01:00.0").as_deref(), Some("pci-0000_01_00_0")); - assert_eq!(pci_tag("10000:af:1f.7").as_deref(), None, "domain is 4 wide"); + assert_eq!( + pci_tag("10000:af:1f.7").as_deref(), + None, + "domain is 4 wide" + ); assert_eq!(pci_tag("0000:1:00.0"), None, "bus is 2 wide"); assert_eq!(pci_tag("0000:01:00"), None, "no function"); assert_eq!(pci_tag("0000:0g:00.0"), None, "not hex"); @@ -510,8 +539,16 @@ mod tests { #[test] fn the_cards_we_can_place_without_asking_the_driver() { - assert!(probe_discrete(&gpu("0000:01:00.0", Driver::Nvidia, Some(false)))); - assert!(!probe_discrete(&gpu(INTEL_INTEGRATED, Driver::I915, Some(true)))); + assert!(probe_discrete(&gpu( + "0000:01:00.0", + Driver::Nvidia, + Some(false) + ))); + assert!(!probe_discrete(&gpu( + INTEL_INTEGRATED, + Driver::I915, + Some(true) + ))); assert!( probe_discrete(&gpu("0000:03:00.0", Driver::I915, Some(false))), "an arc board does not sit at the integrated address" diff --git a/packages/oneclient_core/src/game/heal.rs b/packages/oneclient_core/src/game/heal.rs index 71571762..9199ed14 100644 --- a/packages/oneclient_core/src/game/heal.rs +++ b/packages/oneclient_core/src/game/heal.rs @@ -140,7 +140,11 @@ fn worth_probing(path: &Path) -> bool { !path .extension() .and_then(|ext| ext.to_str()) - .is_some_and(|ext| NEVER_PROBED.iter().any(|skip| ext.eq_ignore_ascii_case(skip))) + .is_some_and(|ext| { + NEVER_PROBED + .iter() + .any(|skip| ext.eq_ignore_ascii_case(skip)) + }) } fn is_zeroed(path: &Path) -> bool { diff --git a/packages/oneclient_core/src/game/launch.rs b/packages/oneclient_core/src/game/launch.rs index e58868b0..0e634114 100644 --- a/packages/oneclient_core/src/game/launch.rs +++ b/packages/oneclient_core/src/game/launch.rs @@ -9,21 +9,21 @@ use tokio::io::AsyncReadExt; use tokio::process::Command; use crate::ClusterStage; -use oneclient_auth::MinecraftAccount; +use crate::LauncherResult; use crate::clusters::Cluster; -use oneclient_discord::Presence; +use crate::game::GameError; use crate::game::session::SessionRecorder; use crate::game::tail::spawn_log_tail; -use crate::game::GameError; -use oneclient_mc::{ - self as arguments, download_minecraft, download_version_info, get_loader_version, - game_files_missing, resolve_minecraft_version, -}; -use oneclient_events::{GroupedProgressSession, LaunchStage}; use crate::settings::GameSettingsProfile; use crate::state::LauncherState; -use crate::LauncherResult; +use oneclient_auth::MinecraftAccount; use oneclient_common::paths; +use oneclient_discord::Presence; +use oneclient_events::{GroupedProgressSession, LaunchStage}; +use oneclient_mc::{ + self as arguments, download_minecraft, download_version_info, game_files_missing, + get_loader_version, resolve_minecraft_version, +}; pub fn is_running(state: &LauncherState, cluster_id: i64) -> bool { state.games.is_running(cluster_id) @@ -46,7 +46,10 @@ pub async fn launch_cluster( let parallel = state.settings.read().allow_parallel_running_clusters; if !parallel && state.games.is_active(cluster_id) { - tracing::warn!(cluster_id, "cluster already launching or running; refusing launch"); + tracing::warn!( + cluster_id, + "cluster already launching or running; refusing launch" + ); return Err(GameError::AlreadyRunning(cluster_id).into()); } @@ -197,8 +200,10 @@ async fn start( "resolved launch metadata" ); - let java = if let Some(runtime) = - state.java.runtime_for_profile(profile.java_path.as_deref()).await? + let java = if let Some(runtime) = state + .java + .runtime_for_profile(profile.java_path.as_deref()) + .await? { runtime } else { @@ -208,13 +213,19 @@ async fn start( .map(|v| v.major_version) .ok_or(GameError::MissingJavaVersion)?; - state.java.prepare(major, search_for_java, false, None).await? + state + .java + .prepare(major, search_for_java, false, None) + .await? }; match game_files_missing(&version_info, &java.os_arch, updated) { Ok(true) => { tracing::info!(cluster_id, "missing game files; repairing"); - let _ = state.clusters.set_stage(cluster_id, ClusterStage::Repairing).await; + let _ = state + .clusters + .set_stage(cluster_id, ClusterStage::Repairing) + .await; stage(LaunchStage::Downloading); if let Err(err) = download_minecraft( &state.services.mc(), @@ -230,7 +241,10 @@ async fn start( stage(LaunchStage::Exited); return Err(err.into()); } - let _ = state.clusters.set_stage(cluster_id, ClusterStage::Ready).await; + let _ = state + .clusters + .set_stage(cluster_id, ClusterStage::Ready) + .await; } Ok(false) => {} Err(err @ oneclient_mc::McError::NoNativesForPlatform { .. }) => { @@ -255,11 +269,8 @@ async fn start( let custom_args = profile.launch_args.clone().unwrap_or_default(); let loader_version_id = loader_version.as_ref().map(|lv| lv.id.as_str()); - let mods_in_cluster = crate::game::uses_cluster_mods_folder( - cluster.mc_loader, - loader_version_id, - &custom_args, - ); + let mods_in_cluster = + crate::game::uses_cluster_mods_folder(cluster.mc_loader, loader_version_id, &custom_args); if let Err(err) = crate::game::materialize_content(&state.services, &cluster, &cwd, mods_in_cluster).await @@ -296,7 +307,9 @@ async fn start( &libraries, &classpaths, &version_name, - profile.mem_max.unwrap_or_else(oneclient_common::default_mem_max), + profile + .mem_max + .unwrap_or_else(oneclient_common::default_mem_max), profile.launch_args.clone().unwrap_or_default(), &java.os_arch, java.major, @@ -350,13 +363,13 @@ async fn start( let (mut command, wrapper) = base_command(&profile, &java.absolute_path); - if profile.use_discrete_gpu() { + if profile.use_discrete_gpu() { crate::game::gpu::prefer_discrete(&mut command, &java.absolute_path).await; } - apply_env(&mut command, &profile); + apply_env(&mut command, &profile); - command + command .args(jvm_args) .arg(&version_info.main_class) .args(mc_args) @@ -407,7 +420,9 @@ async fn start( let recorder = SessionRecorder::start( state, cluster_id, - profile.mem_max.unwrap_or_else(oneclient_common::default_mem_max), + profile + .mem_max + .unwrap_or_else(oneclient_common::default_mem_max), &java, ) .await; @@ -599,15 +614,23 @@ pub(crate) async fn finalize_session( Exit::Observed { success: true, .. } => state .services .events - .notify("Game closed").body(format!("{name} exited")).send(), + .notify("Game closed") + .body(format!("{name} exited")) + .send(), Exit::Observed { display, .. } => state .services .events - .notify("Game crashed").body(format!("{name} exited with {display}")).error().send(), + .notify("Game crashed") + .body(format!("{name} exited with {display}")) + .error() + .send(), Exit::Failed(err) => state .services .events - .notify("Game error").body(format!("{name}: {err}")).error().send(), + .notify("Game error") + .body(format!("{name}: {err}")) + .error() + .send(), // Nothing was watching so there is no crash to report Exit::Inferred => {} } diff --git a/packages/oneclient_core/src/game/log_replay.rs b/packages/oneclient_core/src/game/log_replay.rs index 46a04d2f..2eb86497 100644 --- a/packages/oneclient_core/src/game/log_replay.rs +++ b/packages/oneclient_core/src/game/log_replay.rs @@ -13,273 +13,276 @@ const ROLLOVER_SLACK_SECS: i64 = 12 * 60 * 60; /// Absent any of these the span is closed by the next join or the session end /// so a missing marker costs accuracy never correctness const LEAVE_MARKERS: &[&str] = &[ - "Stopping!", - "Stopping worker threads", - "Connection lost", - "Lost connection to server", - "Client disconnected with reason", - "Disconnected from server", + "Stopping!", + "Stopping worker threads", + "Connection lost", + "Lost connection to server", + "Client disconnected with reason", + "Disconnected from server", ]; /// Clean-shutdown marker its timestamp is the truest exit time available const STOP_MARKER: &str = "Stopping!"; pub(crate) fn parse_log_time(line: &str) -> Option { - let stamp = line.strip_prefix('[')?.split(']').next()?.trim(); + let stamp = line.strip_prefix('[')?.split(']').next()?.trim(); - // `%.f` also matches an empty fraction covering both `[12:29:00]` and - // `[12:29:00.123]` - if let Ok(time) = NaiveTime::parse_from_str(stamp, "%H:%M:%S%.f") { - return Some(time); - } + // `%.f` also matches an empty fraction covering both `[12:29:00]` and + // `[12:29:00.123]` + if let Ok(time) = NaiveTime::parse_from_str(stamp, "%H:%M:%S%.f") { + return Some(time); + } - // Some pack-shipped log4j configs prepend the date - for fmt in ["%Y-%m-%d %H:%M:%S%.f", "%d.%m.%Y %H:%M:%S%.f"] { - if let Ok(dt) = NaiveDateTime::parse_from_str(stamp, fmt) { - return Some(dt.time()); - } - } + // Some pack-shipped log4j configs prepend the date + for fmt in ["%Y-%m-%d %H:%M:%S%.f", "%d.%m.%Y %H:%M:%S%.f"] { + if let Ok(dt) = NaiveDateTime::parse_from_str(stamp, fmt) { + return Some(dt.time()); + } + } - None + None } pub(crate) fn is_leave_marker(line: &str) -> bool { - LEAVE_MARKERS.iter().any(|marker| line.contains(marker)) + LEAVE_MARKERS.iter().any(|marker| line.contains(marker)) } fn local_to_utc(date: NaiveDate, time: NaiveTime) -> DateTime { - let naive = NaiveDateTime::new(date, time); - match Local.from_local_datetime(&naive) { - LocalResult::Single(dt) => dt.with_timezone(&Utc), - // DST end the time happens twice the first fits a forward-walking log - LocalResult::Ambiguous(first, _) => first.with_timezone(&Utc), - // DST start this time never existed so step over the gap rather than - // reading it as UTC which would be off by the zone's whole offset - LocalResult::None => Local - .from_local_datetime(&(naive + chrono::Duration::hours(1))) - .earliest() - .map(|dt| dt.with_timezone(&Utc)) - .unwrap_or_else(|| Utc.from_utc_datetime(&naive)), - } + let naive = NaiveDateTime::new(date, time); + match Local.from_local_datetime(&naive) { + LocalResult::Single(dt) => dt.with_timezone(&Utc), + // DST end the time happens twice the first fits a forward-walking log + LocalResult::Ambiguous(first, _) => first.with_timezone(&Utc), + // DST start this time never existed so step over the gap rather than + // reading it as UTC which would be off by the zone's whole offset + LocalResult::None => Local + .from_local_datetime(&(naive + chrono::Duration::hours(1))) + .earliest() + .map(|dt| dt.with_timezone(&Utc)) + .unwrap_or_else(|| Utc.from_utc_datetime(&naive)), + } } /// Turns bare times into absolute instants advancing the date on wrap pub(crate) struct LogClock { - date: NaiveDate, - /// High-water mark not the previous line threads interleave so a line - /// may legitimately sit behind the one before it - peak: NaiveTime, + date: NaiveDate, + /// High-water mark not the previous line threads interleave so a line + /// may legitimately sit behind the one before it + peak: NaiveTime, } impl LogClock { - pub(crate) fn new(anchor: DateTime) -> Self { - let local = anchor.with_timezone(&Local).naive_local(); - Self { - date: local.date(), - peak: local.time(), - } - } + pub(crate) fn new(anchor: DateTime) -> Self { + let local = anchor.with_timezone(&Local).naive_local(); + Self { + date: local.date(), + peak: local.time(), + } + } - pub(crate) fn absolute(&mut self, time: NaiveTime) -> DateTime { - let behind = self.peak.signed_duration_since(time).num_seconds(); - if behind > ROLLOVER_SLACK_SECS { - self.date = self.date.succ_opt().unwrap_or(self.date); - self.peak = time; - } else if time > self.peak { - self.peak = time; - } - local_to_utc(self.date, time) - } + pub(crate) fn absolute(&mut self, time: NaiveTime) -> DateTime { + let behind = self.peak.signed_duration_since(time).num_seconds(); + if behind > ROLLOVER_SLACK_SECS { + self.date = self.date.succ_opt().unwrap_or(self.date); + self.peak = time; + } else if time > self.peak { + self.peak = time; + } + local_to_utc(self.date, time) + } } #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct ServerSpan { - pub host: String, - pub port: Option, - pub joined_at: DateTime, - pub disconnected_at: Option>, + pub host: String, + pub port: Option, + pub joined_at: DateTime, + pub disconnected_at: Option>, } #[derive(Debug, Default, Clone)] pub(crate) struct LogReplay { - /// Timestamp of the last line carrying one a lower bound on the exit - pub last_activity: Option>, - pub stopped_at: Option>, - pub servers: Vec, + /// Timestamp of the last line carrying one a lower bound on the exit + pub last_activity: Option>, + pub stopped_at: Option>, + pub servers: Vec, } pub(crate) fn replay(content: &str, started_at: DateTime) -> LogReplay { - let mut clock = LogClock::new(started_at); - let mut out = LogReplay::default(); - // Timestamps appear only on the first line of a multi-line entry so carry - // the last one forward - let mut now = started_at; + let mut clock = LogClock::new(started_at); + let mut out = LogReplay::default(); + // Timestamps appear only on the first line of a multi-line entry so carry + // the last one forward + let mut now = started_at; - for line in content.lines() { - if let Some(time) = parse_log_time(line) { - now = clock.absolute(time); - out.last_activity = Some(now); - } + for line in content.lines() { + if let Some(time) = parse_log_time(line) { + now = clock.absolute(time); + out.last_activity = Some(now); + } - if let Some(ServerJoin { host, port }) = parse_server_join(line) { - close_open_span(&mut out, now); - out.servers.push(ServerSpan { - host, - port, - joined_at: now, - disconnected_at: None, - }); - continue; - } + if let Some(ServerJoin { host, port }) = parse_server_join(line) { + close_open_span(&mut out, now); + out.servers.push(ServerSpan { + host, + port, + joined_at: now, + disconnected_at: None, + }); + continue; + } - if is_leave_marker(line) { - close_open_span(&mut out, now); - } + if is_leave_marker(line) { + close_open_span(&mut out, now); + } - if line.contains(STOP_MARKER) { - out.stopped_at = Some(now); - } - } + if line.contains(STOP_MARKER) { + out.stopped_at = Some(now); + } + } - out + out } fn close_open_span(replay: &mut LogReplay, at: DateTime) { - if let Some(open) = replay - .servers - .iter_mut() - .rev() - .find(|span| span.disconnected_at.is_none()) - { - open.disconnected_at = Some(at.max(open.joined_at)); - } + if let Some(open) = replay + .servers + .iter_mut() + .rev() + .find(|span| span.disconnected_at.is_none()) + { + open.disconnected_at = Some(at.max(open.joined_at)); + } } #[cfg(test)] mod tests { - use super::*; - use chrono::Duration; + use super::*; + use chrono::Duration; - fn anchor() -> DateTime { - Local - .with_ymd_and_hms(2026, 7, 16, 12, 0, 0) - .unwrap() - .with_timezone(&Utc) - } + fn anchor() -> DateTime { + Local + .with_ymd_and_hms(2026, 7, 16, 12, 0, 0) + .unwrap() + .with_timezone(&Utc) + } - fn local_at(h: u32, m: u32, s: u32) -> DateTime { - Local - .with_ymd_and_hms(2026, 7, 16, h, m, s) - .unwrap() - .with_timezone(&Utc) - } + fn local_at(h: u32, m: u32, s: u32) -> DateTime { + Local + .with_ymd_and_hms(2026, 7, 16, h, m, s) + .unwrap() + .with_timezone(&Utc) + } - #[test] - fn parses_plain_and_fractional_times() { - assert_eq!( - parse_log_time("[12:29:00] [Client thread/INFO]: Setting user: Lynith"), - NaiveTime::from_hms_opt(12, 29, 0) - ); - assert_eq!( - parse_log_time("[12:29:00.123] [Render thread/INFO]: hi"), - NaiveTime::from_hms_milli_opt(12, 29, 0, 123) - ); - } + #[test] + fn parses_plain_and_fractional_times() { + assert_eq!( + parse_log_time("[12:29:00] [Client thread/INFO]: Setting user: Lynith"), + NaiveTime::from_hms_opt(12, 29, 0) + ); + assert_eq!( + parse_log_time("[12:29:00.123] [Render thread/INFO]: hi"), + NaiveTime::from_hms_milli_opt(12, 29, 0, 123) + ); + } - #[test] - fn parses_dated_stamp() { - assert_eq!( - parse_log_time("[2026-07-16 12:29:00] [Render thread/INFO]: hi"), - NaiveTime::from_hms_opt(12, 29, 0) - ); - } + #[test] + fn parses_dated_stamp() { + assert_eq!( + parse_log_time("[2026-07-16 12:29:00] [Render thread/INFO]: hi"), + NaiveTime::from_hms_opt(12, 29, 0) + ); + } - #[test] - fn ignores_untimestamped_lines() { - assert!(parse_log_time("\tat net.minecraft.Foo.bar(Foo.java:12)").is_none()); - assert!(parse_log_time("[Render thread/INFO]: no time here").is_none()); - } + #[test] + fn ignores_untimestamped_lines() { + assert!(parse_log_time("\tat net.minecraft.Foo.bar(Foo.java:12)").is_none()); + assert!(parse_log_time("[Render thread/INFO]: no time here").is_none()); + } - #[test] - fn clock_keeps_same_day_while_moving_forward() { - let mut clock = LogClock::new(anchor()); - let at = clock.absolute(NaiveTime::from_hms_opt(12, 30, 0).unwrap()); - assert_eq!(at, local_at(12, 30, 0)); - } + #[test] + fn clock_keeps_same_day_while_moving_forward() { + let mut clock = LogClock::new(anchor()); + let at = clock.absolute(NaiveTime::from_hms_opt(12, 30, 0).unwrap()); + assert_eq!(at, local_at(12, 30, 0)); + } - #[test] - fn clock_rolls_over_midnight() { - let mut clock = LogClock::new(anchor()); - clock.absolute(NaiveTime::from_hms_opt(23, 59, 0).unwrap()); - let at = clock.absolute(NaiveTime::from_hms_opt(0, 1, 0).unwrap()); - assert_eq!(at, local_at(12, 0, 0) + Duration::hours(12) + Duration::minutes(1)); - } + #[test] + fn clock_rolls_over_midnight() { + let mut clock = LogClock::new(anchor()); + clock.absolute(NaiveTime::from_hms_opt(23, 59, 0).unwrap()); + let at = clock.absolute(NaiveTime::from_hms_opt(0, 1, 0).unwrap()); + assert_eq!( + at, + local_at(12, 0, 0) + Duration::hours(12) + Duration::minutes(1) + ); + } - #[test] - fn clock_tolerates_out_of_order_threads() { - let mut clock = LogClock::new(anchor()); - clock.absolute(NaiveTime::from_hms_opt(12, 29, 8).unwrap()); - // A second thread's line lands a second late this must not add a day - let at = clock.absolute(NaiveTime::from_hms_opt(12, 29, 7).unwrap()); - assert_eq!(at, local_at(12, 29, 7)); - } + #[test] + fn clock_tolerates_out_of_order_threads() { + let mut clock = LogClock::new(anchor()); + clock.absolute(NaiveTime::from_hms_opt(12, 29, 8).unwrap()); + // A second thread's line lands a second late this must not add a day + let at = clock.absolute(NaiveTime::from_hms_opt(12, 29, 7).unwrap()); + assert_eq!(at, local_at(12, 29, 7)); + } - #[test] - fn replays_server_span_closed_by_quit() { - let log = "\ + #[test] + fn replays_server_span_closed_by_quit() { + let log = "\ [12:29:00] [Client thread/INFO]: Setting user: Lynith [12:30:00] [Render thread/INFO]: Connecting to mc.hypixel.net, 25565 [12:45:00] [Client thread/INFO]: Stopping! "; - let out = replay(log, anchor()); - assert_eq!(out.servers.len(), 1); - let span = &out.servers[0]; - assert_eq!(span.host, "mc.hypixel.net"); - assert_eq!(span.port, Some(25565)); - assert_eq!(span.joined_at, local_at(12, 30, 0)); - assert_eq!(span.disconnected_at, Some(local_at(12, 45, 0))); - assert_eq!(out.stopped_at, Some(local_at(12, 45, 0))); - assert_eq!(out.last_activity, Some(local_at(12, 45, 0))); - } + let out = replay(log, anchor()); + assert_eq!(out.servers.len(), 1); + let span = &out.servers[0]; + assert_eq!(span.host, "mc.hypixel.net"); + assert_eq!(span.port, Some(25565)); + assert_eq!(span.joined_at, local_at(12, 30, 0)); + assert_eq!(span.disconnected_at, Some(local_at(12, 45, 0))); + assert_eq!(out.stopped_at, Some(local_at(12, 45, 0))); + assert_eq!(out.last_activity, Some(local_at(12, 45, 0))); + } - #[test] - fn consecutive_joins_close_previous_span() { - let log = "\ + #[test] + fn consecutive_joins_close_previous_span() { + let log = "\ [12:30:00] [Render thread/INFO]: Connecting to a.example.com, 25565 [12:40:00] [Render thread/INFO]: Connecting to b.example.com, 25566 "; - let out = replay(log, anchor()); - assert_eq!(out.servers.len(), 2); - assert_eq!(out.servers[0].disconnected_at, Some(local_at(12, 40, 0))); - assert_eq!(out.servers[1].disconnected_at, None); - } + let out = replay(log, anchor()); + assert_eq!(out.servers.len(), 2); + assert_eq!(out.servers[0].disconnected_at, Some(local_at(12, 40, 0))); + assert_eq!(out.servers[1].disconnected_at, None); + } - #[test] - fn span_left_open_when_log_just_stops() { - let log = "[12:30:00] [Render thread/INFO]: Connecting to a.example.com, 25565\n"; - let out = replay(log, anchor()); - assert_eq!(out.servers[0].disconnected_at, None); - assert_eq!(out.stopped_at, None); - assert_eq!(out.last_activity, Some(local_at(12, 30, 0))); - } + #[test] + fn span_left_open_when_log_just_stops() { + let log = "[12:30:00] [Render thread/INFO]: Connecting to a.example.com, 25565\n"; + let out = replay(log, anchor()); + assert_eq!(out.servers[0].disconnected_at, None); + assert_eq!(out.stopped_at, None); + assert_eq!(out.last_activity, Some(local_at(12, 30, 0))); + } - #[test] - fn crash_without_stop_marker_reports_last_activity() { - let log = "\ + #[test] + fn crash_without_stop_marker_reports_last_activity() { + let log = "\ [12:30:00] [Render thread/INFO]: Connecting to a.example.com, 25565 [12:31:00] [Render thread/ERROR]: Encountered an unexpected exception \tat net.minecraft.Foo.bar(Foo.java:12) "; - let out = replay(log, anchor()); - assert_eq!(out.stopped_at, None); - assert_eq!(out.last_activity, Some(local_at(12, 31, 0))); - } + let out = replay(log, anchor()); + assert_eq!(out.stopped_at, None); + assert_eq!(out.last_activity, Some(local_at(12, 31, 0))); + } - #[test] - fn empty_log_yields_nothing() { - let out = replay("", anchor()); - assert!(out.servers.is_empty()); - assert!(out.last_activity.is_none()); - assert!(out.stopped_at.is_none()); - } + #[test] + fn empty_log_yields_nothing() { + let out = replay("", anchor()); + assert!(out.servers.is_empty()); + assert!(out.last_activity.is_none()); + assert!(out.stopped_at.is_none()); + } } diff --git a/packages/oneclient_core/src/game/mod.rs b/packages/oneclient_core/src/game/mod.rs index d9c59cdb..dd4c1cc2 100644 --- a/packages/oneclient_core/src/game/mod.rs +++ b/packages/oneclient_core/src/game/mod.rs @@ -24,21 +24,20 @@ pub mod diagnosis; pub use diagnosis::{CrashDiagnosis, diagnose}; pub use error::GameError; +pub use fabric::{mods_folder_argument, uses_cluster_mods_folder}; pub use heal::clear_zeroed_files; pub use launch::{LaunchedGame, is_running, launch_cluster, offer_repair}; +pub use oneclient_mc::{ + DownloadPlan, NativesReport, PlannedLibrary, check_natives, confirm_incomplete_install, + download_minecraft, download_version_info, game_files_missing, get_game_versions, + get_loader_version, get_loader_versions, get_loaders_for_version, is_version_updated, + libraries_missing, natives_missing, plan_downloads, resolve_minecraft_version, validate_rules, + verify_game_files, +}; pub use process::{ GameProcess, GameProcessManager, is_process_alive, kill_process, process_start_time, }; pub use reattach::recover_sessions; -pub use oneclient_mc::{ - DownloadPlan, NativesReport, PlannedLibrary, check_natives, confirm_incomplete_install, - download_minecraft, download_version_info, get_game_versions, - get_loader_version, get_loader_versions, get_loaders_for_version, is_version_updated, - game_files_missing, libraries_missing, natives_missing, plan_downloads, resolve_minecraft_version, - validate_rules, - verify_game_files, -}; -pub use fabric::{mods_folder_argument, uses_cluster_mods_folder}; pub use shared_dir::{ dematerialize_content, import_manual_content, link_cluster_logs, materialize_content, unlink_cluster_logs, write_allowed_symlinks, diff --git a/packages/oneclient_core/src/game/process.rs b/packages/oneclient_core/src/game/process.rs index 0d4938bc..5077f29a 100644 --- a/packages/oneclient_core/src/game/process.rs +++ b/packages/oneclient_core/src/game/process.rs @@ -1,4 +1,3 @@ - use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Mutex; @@ -10,46 +9,46 @@ use tokio::sync::oneshot; use oneclient_events::LaunchStage; fn probe(pid: u32) -> Option<(System, Pid)> { - let pid = Pid::from_u32(pid); - let mut sys = System::new(); - sys.refresh_processes(ProcessesToUpdate::Some(&[pid]), true); - sys.process(pid)?; - Some((sys, pid)) + let pid = Pid::from_u32(pid); + let mut sys = System::new(); + sys.refresh_processes(ProcessesToUpdate::Some(&[pid]), true); + sys.process(pid)?; + Some((sys, pid)) } /// Unix seconds Pids get recycled so this is what pins process identity pub fn process_start_time(pid: u32) -> Option { - let (sys, pid) = probe(pid)?; - Some(sys.process(pid)?.start_time()) + let (sys, pid) = probe(pid)?; + Some(sys.process(pid)?.start_time()) } /// A matching pid whose start time differs is a recycled pid belonging to /// someone else never ours pub fn is_process_alive(pid: u32, started_at: Option) -> bool { - let Some(actual) = process_start_time(pid) else { - return false; - }; - match started_at { - Some(expected) => actual == expected, - // Pre-migration sessions have no recorded start time assuming running - // is the safer error as the exit time is recovered on a later start - None => true, - } + let Some(actual) = process_start_time(pid) else { + return false; + }; + match started_at { + Some(expected) => actual == expected, + // Pre-migration sessions have no recorded start time assuming running + // is the safer error as the exit time is recovered on a later start + None => true, + } } /// For games re-adopted after a launcher restart where no `Child` handle /// survives to kill through pub fn kill_process(pid: u32) -> bool { - let Some((sys, pid)) = probe(pid) else { - return false; - }; - let Some(process) = sys.process(pid) else { - return false; - }; - // Prefer a graceful terminate so the game can save before shutting down - process - .kill_with(Signal::Term) - .unwrap_or_else(|| process.kill()) + let Some((sys, pid)) = probe(pid) else { + return false; + }; + let Some(process) = sys.process(pid) else { + return false; + }; + // Prefer a graceful terminate so the game can save before shutting down + process + .kill_with(Signal::Term) + .unwrap_or_else(|| process.kill()) } #[derive(Debug, Clone)] @@ -138,7 +137,11 @@ impl GameProcessManager { } pub fn pid(&self, cluster_id: i64) -> Option { - self.inner.lock().unwrap().get(&cluster_id).and_then(|p| p.pid) + self.inner + .lock() + .unwrap() + .get(&cluster_id) + .and_then(|p| p.pid) } pub fn is_running(&self, cluster_id: i64) -> bool { diff --git a/packages/oneclient_core/src/game/reattach.rs b/packages/oneclient_core/src/game/reattach.rs index 54843134..8a4c59a0 100644 --- a/packages/oneclient_core/src/game/reattach.rs +++ b/packages/oneclient_core/src/game/reattach.rs @@ -10,14 +10,14 @@ use oneclient_db::dao::game_session as session_dao; use oneclient_db::models::UnfinishedSession; use crate::clusters::Cluster; -use oneclient_discord::Presence; use crate::game::launch::{Exit, SessionEnd, finalize_session}; use crate::game::log_replay::{self, ServerSpan}; use crate::game::process::{is_process_alive, kill_process}; use crate::game::session::SessionRecorder; use crate::game::tail::spawn_log_tail; -use oneclient_events::LaunchStage; use crate::state::LauncherState; +use oneclient_discord::Presence; +use oneclient_events::LaunchStage; /// Only a fallback for a process we don't own so a slow poll is plenty const LIVENESS_POLL: Duration = Duration::from_secs(3); @@ -25,380 +25,402 @@ const LIVENESS_POLL: Duration = Duration::from_secs(3); /// Call once at startup before anything can launch a game #[tracing::instrument(skip(state), level = "debug")] pub async fn recover_sessions(state: &Arc) { - let sessions = match session_dao::unfinished_sessions(&state.services.db).await { - Ok(sessions) => sessions, - Err(err) => { - tracing::warn!(error = %err, "failed to list unfinished game sessions"); - return; - } - }; + let sessions = match session_dao::unfinished_sessions(&state.services.db).await { + Ok(sessions) => sessions, + Err(err) => { + tracing::warn!(error = %err, "failed to list unfinished game sessions"); + return; + } + }; - if sessions.is_empty() { - return; - } + if sessions.is_empty() { + return; + } - tracing::info!(count = sessions.len(), "recovering unfinished game sessions"); + tracing::info!( + count = sessions.len(), + "recovering unfinished game sessions" + ); - let mut live = Vec::new(); - let mut dead = Vec::new(); - for session in sessions { - match classify(state, session).await { - Some((cluster, session, started_at, Some(pid))) => { - live.push((cluster, session, started_at, pid)); - } - Some((cluster, session, started_at, None)) => { - dead.push((cluster, session, started_at)); - } - None => {} - } - } + let mut live = Vec::new(); + let mut dead = Vec::new(); + for session in sessions { + match classify(state, session).await { + Some((cluster, session, started_at, Some(pid))) => { + live.push((cluster, session, started_at, pid)); + } + Some((cluster, session, started_at, None)) => { + dead.push((cluster, session, started_at)); + } + None => {} + } + } - // Live games first reconciling an exited session tears down a shared game - // dir and that check reads the live registrations - for (cluster, session, started_at, pid) in live { - readopt(state, cluster, session, started_at, pid).await; - } + // Live games first reconciling an exited session tears down a shared game + // dir and that check reads the live registrations + for (cluster, session, started_at, pid) in live { + readopt(state, cluster, session, started_at, pid).await; + } - for (cluster, session, started_at) in dead { - reconcile(state, cluster, session, started_at).await; - } + for (cluster, session, started_at) in dead { + reconcile(state, cluster, session, started_at).await; + } } /// `None` means the session was closed out here and needs no further work async fn classify( - state: &Arc, - session: UnfinishedSession, + state: &Arc, + session: UnfinishedSession, ) -> Option<(Cluster, UnfinishedSession, DateTime, Option)> { - let cluster_id = session.cluster_id; + let cluster_id = session.cluster_id; - if state.games.is_active(cluster_id) { - return None; - } + if state.games.is_active(cluster_id) { + return None; + } - let started_at = match DateTime::parse_from_rfc3339(&session.started_at) { - Ok(at) => at.with_timezone(&Utc), - Err(err) => { - tracing::warn!(cluster_id, session = %session.started_at, error = %err, "unparseable session start; closing it"); - close_untraceable(state, &session).await; - return None; - } - }; + let started_at = match DateTime::parse_from_rfc3339(&session.started_at) { + Ok(at) => at.with_timezone(&Utc), + Err(err) => { + tracing::warn!(cluster_id, session = %session.started_at, error = %err, "unparseable session start; closing it"); + close_untraceable(state, &session).await; + return None; + } + }; - // No cluster means no log and no playtime worth attributing close the row - // so it stops being reconsidered forever - let Ok(cluster) = state.clusters.get(cluster_id).await else { - tracing::warn!(cluster_id, "cluster missing for unfinished session; closing it"); - close_untraceable(state, &session).await; - return None; - }; + // No cluster means no log and no playtime worth attributing close the row + // so it stops being reconsidered forever + let Ok(cluster) = state.clusters.get(cluster_id).await else { + tracing::warn!( + cluster_id, + "cluster missing for unfinished session; closing it" + ); + close_untraceable(state, &session).await; + return None; + }; - let alive = match (session.pid, session.pid_started_at) { - (Some(pid), Some(pid_started_at)) => u32::try_from(pid) - .ok() - .filter(|pid| is_process_alive(*pid, Some(pid_started_at as u64))), - (Some(pid), None) => { - tracing::debug!(cluster_id, pid, "session has no process start time; not re-adopting"); - None - } - (None, _) => None, - }; + let alive = match (session.pid, session.pid_started_at) { + (Some(pid), Some(pid_started_at)) => u32::try_from(pid) + .ok() + .filter(|pid| is_process_alive(*pid, Some(pid_started_at as u64))), + (Some(pid), None) => { + tracing::debug!( + cluster_id, + pid, + "session has no process start time; not re-adopting" + ); + None + } + (None, _) => None, + }; - Some((cluster, session, started_at, alive)) + Some((cluster, session, started_at, alive)) } /// Ends at the start time which books no playtime rather than inventing some async fn close_untraceable(state: &Arc, session: &UnfinishedSession) { - if let Err(err) = session_dao::finish_session_at( - &state.services.db, - &session.started_at, - &session.started_at, - None, - ) - .await - { - tracing::warn!(error = %err, "failed to close untraceable session"); - } + if let Err(err) = session_dao::finish_session_at( + &state.services.db, + &session.started_at, + &session.started_at, + None, + ) + .await + { + tracing::warn!(error = %err, "failed to close untraceable session"); + } } #[tracing::instrument(skip(state, cluster, session), fields(cluster_id = cluster.id, pid), level = "debug")] async fn readopt( - state: &Arc, - cluster: Cluster, - session: UnfinishedSession, - started_at: DateTime, - pid: u32, + state: &Arc, + cluster: Cluster, + session: UnfinishedSession, + started_at: DateTime, + pid: u32, ) { - let cluster_id = cluster.id; - let Ok(cwd) = cluster.game_dir() else { - return; - }; + let cluster_id = cluster.id; + let Ok(cwd) = cluster.game_dir() else { + return; + }; - tracing::info!(cluster_id, pid, "re-adopting running game"); + tracing::info!(cluster_id, pid, "re-adopting running game"); - let open_server = open_server_of(state, &session.started_at).await; - let recorder = SessionRecorder::resume( - state.services.db.clone(), - session.started_at.clone(), - open_server, - ); + let open_server = open_server_of(state, &session.started_at).await; + let recorder = SessionRecorder::resume( + state.services.db.clone(), + session.started_at.clone(), + open_server, + ); - state.games.set_stage(cluster_id, LaunchStage::Running); - state.games.set_pid(cluster_id, Some(pid)); - state.games.set_dir(cluster_id, cwd.clone()); - state.services.events.game_stage(cluster_id, LaunchStage::Running); - state.discord.set_presence(Presence::Playing { - cluster: cluster.name.clone(), - mc_version: cluster.mc_version.clone(), - }); + state.games.set_stage(cluster_id, LaunchStage::Running); + state.games.set_pid(cluster_id, Some(pid)); + state.games.set_dir(cluster_id, cwd.clone()); + state + .services + .events + .game_stage(cluster_id, LaunchStage::Running); + state.discord.set_presence(Presence::Playing { + cluster: cluster.name.clone(), + mc_version: cluster.mc_version.clone(), + }); - let Ok(log_path) = oneclient_cluster::logs::cluster_output_log(&cluster) else { - return; - }; - // A re-adopted game still writes its log so crashes stay readable even - // though the exit code went with the launcher that spawned it - let crash_watch = crate::game::diagnosis::CrashWatch::new(); - let tail = spawn_log_tail( - cluster_id, - log_path, - state.services.events.clone(), - Some(recorder.clone()), - crash_watch.clone(), - ); + let Ok(log_path) = oneclient_cluster::logs::cluster_output_log(&cluster) else { + return; + }; + // A re-adopted game still writes its log so crashes stay readable even + // though the exit code went with the launcher that spawned it + let crash_watch = crate::game::diagnosis::CrashWatch::new(); + let tail = spawn_log_tail( + cluster_id, + log_path, + state.services.events.clone(), + Some(recorder.clone()), + crash_watch.clone(), + ); - let (kill_tx, kill_rx) = tokio::sync::oneshot::channel::<()>(); - state.games.register_kill(cluster_id, kill_tx); + let (kill_tx, kill_rx) = tokio::sync::oneshot::channel::<()>(); + state.games.register_kill(cluster_id, kill_tx); - // Resolved up front so the watcher task doesn't reach back into the state - let global = state.settings.read().global_game_settings.clone(); - let post_hook = state.clusters.resolve_settings(&global, &cluster) - .await - .ok() - .and_then(|profile| profile.hook_post); - let dedicated = cluster.uses_dedicated_dir(); - let pid_started_at = session.pid_started_at.map(|t| t as u64); - let state = Arc::clone(state); + // Resolved up front so the watcher task doesn't reach back into the state + let global = state.settings.read().global_game_settings.clone(); + let post_hook = state + .clusters + .resolve_settings(&global, &cluster) + .await + .ok() + .and_then(|profile| profile.hook_post); + let dedicated = cluster.uses_dedicated_dir(); + let pid_started_at = session.pid_started_at.map(|t| t as u64); + let state = Arc::clone(state); - tokio::spawn(async move { - tokio::select! { - () = wait_for_exit(pid, pid_started_at) => {} - _ = kill_rx => { - kill_process(pid); - wait_for_exit(pid, pid_started_at).await; - } - } + tokio::spawn(async move { + tokio::select! { + () = wait_for_exit(pid, pid_started_at) => {} + _ = kill_rx => { + kill_process(pid); + wait_for_exit(pid, pid_started_at).await; + } + } - tail.stop().await; + tail.stop().await; - finalize_session( - &state, - &cluster, - &cwd, - dedicated, - post_hook.as_deref(), - Some(recorder), - SessionEnd { - started_at, - ended_at: Utc::now(), - outcome: Exit::Inferred, - // With parallel clusters a newer game may already own this - // cluster's slot only tear down if it is still ours - owns_slot: state.games.pid(cluster_id) == Some(pid), - diagnosis: crash_watch.take(), - }, - ) - .await; - }); + finalize_session( + &state, + &cluster, + &cwd, + dedicated, + post_hook.as_deref(), + Some(recorder), + SessionEnd { + started_at, + ended_at: Utc::now(), + outcome: Exit::Inferred, + // With parallel clusters a newer game may already own this + // cluster's slot only tear down if it is still ours + owns_slot: state.games.pid(cluster_id) == Some(pid), + diagnosis: crash_watch.take(), + }, + ) + .await; + }); } async fn wait_for_exit(pid: u32, pid_started_at: Option) { - while is_process_alive(pid, pid_started_at) { - tokio::time::sleep(LIVENESS_POLL).await; - } + while is_process_alive(pid, pid_started_at) { + tokio::time::sleep(LIVENESS_POLL).await; + } } #[tracing::instrument(skip(state, cluster, session), fields(cluster_id = cluster.id), level = "debug")] async fn reconcile( - state: &Arc, - cluster: Cluster, - session: UnfinishedSession, - started_at: DateTime, + state: &Arc, + cluster: Cluster, + session: UnfinishedSession, + started_at: DateTime, ) { - let cluster_id = cluster.id; - let log = read_session_log(&cluster).await; + let cluster_id = cluster.id; + let log = read_session_log(&cluster).await; - let replay = log - .as_ref() - .map(|log| log_replay::replay(&log.content, started_at)) - .unwrap_or_default(); + let replay = log + .as_ref() + .map(|log| log_replay::replay(&log.content, started_at)) + .unwrap_or_default(); - let now = Utc::now().max(started_at); + let now = Utc::now().max(started_at); - let plausible = |at: DateTime| (started_at..=now).contains(&at).then_some(at); + let plausible = |at: DateTime| (started_at..=now).contains(&at).then_some(at); - // Prefer the log's own timestamps over mtime mtime only helps when nothing - // in the log carries a time - let ended_at = replay - .stopped_at - .and_then(plausible) - .or_else(|| replay.last_activity.and_then(plausible)) - .or_else(|| log.as_ref().and_then(|log| log.modified).and_then(plausible)) - .unwrap_or(started_at); + // Prefer the log's own timestamps over mtime mtime only helps when nothing + // in the log carries a time + let ended_at = replay + .stopped_at + .and_then(plausible) + .or_else(|| replay.last_activity.and_then(plausible)) + .or_else(|| { + log.as_ref() + .and_then(|log| log.modified) + .and_then(plausible) + }) + .unwrap_or(started_at); - tracing::info!( - cluster_id, - session = %session.started_at, - %ended_at, - clean_exit = replay.stopped_at.is_some(), - servers = replay.servers.len(), - "recovered session that outlived the launcher" - ); + tracing::info!( + cluster_id, + session = %session.started_at, + %ended_at, + clean_exit = replay.stopped_at.is_some(), + servers = replay.servers.len(), + "recovered session that outlived the launcher" + ); - let open_server = apply_spans(state, &session.started_at, &replay.servers).await; - let recorder = SessionRecorder::resume( - state.services.db.clone(), - session.started_at.clone(), - open_server, - ); + let open_server = apply_spans(state, &session.started_at, &replay.servers).await; + let recorder = SessionRecorder::resume( + state.services.db.clone(), + session.started_at.clone(), + open_server, + ); - let Ok(cwd) = cluster.game_dir() else { - recorder.finish_at(&ended_at.to_rfc3339(), None).await; - return; - }; + let Ok(cwd) = cluster.game_dir() else { + recorder.finish_at(&ended_at.to_rfc3339(), None).await; + return; + }; - // A cluster re-adopted moments ago may share this directory clearing it - // mid-session would pull that game apart underneath it - let shared_dir_busy = state.games.dir_in_use_by(&cwd, cluster_id).is_some(); - if shared_dir_busy { - tracing::debug!(cluster_id, "shared game dir still in use; skipping exit cleanup"); - } + // A cluster re-adopted moments ago may share this directory clearing it + // mid-session would pull that game apart underneath it + let shared_dir_busy = state.games.dir_in_use_by(&cwd, cluster_id).is_some(); + if shared_dir_busy { + tracing::debug!( + cluster_id, + "shared game dir still in use; skipping exit cleanup" + ); + } - finalize_session( - state, - &cluster, - &cwd, - cluster.uses_dedicated_dir() || shared_dir_busy, - // Post hook skipped on purpose the exit already happened possibly days - // ago so firing it during startup would surprise the user - None, - Some(recorder), - SessionEnd { - started_at, - ended_at, - outcome: Exit::Inferred, - // The cluster may have a newer session playing right now so only - // claim the slot if nothing else holds it - owns_slot: !state.games.is_active(cluster_id), - // Nothing watched this log while it ran so no crash was recognised - diagnosis: None, - }, - ) - .await; + finalize_session( + state, + &cluster, + &cwd, + cluster.uses_dedicated_dir() || shared_dir_busy, + // Post hook skipped on purpose the exit already happened possibly days + // ago so firing it during startup would surprise the user + None, + Some(recorder), + SessionEnd { + started_at, + ended_at, + outcome: Exit::Inferred, + // The cluster may have a newer session playing right now so only + // claim the slot if nothing else holds it + owns_slot: !state.games.is_active(cluster_id), + // Nothing watched this log while it ran so no crash was recognised + diagnosis: None, + }, + ) + .await; } /// Returns the `joined_at` of a span still left open for the caller to close async fn apply_spans( - state: &Arc, - session_id: &str, - spans: &[ServerSpan], + state: &Arc, + session_id: &str, + spans: &[ServerSpan], ) -> Option { - let db = &state.services.db; - let existing = session_dao::list_session_servers(db, session_id) - .await - .unwrap_or_default(); + let db = &state.services.db; + let existing = session_dao::list_session_servers(db, session_id) + .await + .unwrap_or_default(); - // Fewer replayed spans than recorded means the log was rotated or truncated - // so the live rows are the better record keep them - if spans.len() < existing.len() { - tracing::debug!( - session = %session_id, - replayed = spans.len(), - existing = existing.len(), - "log replay is thinner than recorded spans; keeping recorded ones" - ); - return existing - .into_iter() - .find(|row| row.disconnected_at.is_none()) - .map(|row| row.joined_at); - } + // Fewer replayed spans than recorded means the log was rotated or truncated + // so the live rows are the better record keep them + if spans.len() < existing.len() { + tracing::debug!( + session = %session_id, + replayed = spans.len(), + existing = existing.len(), + "log replay is thinner than recorded spans; keeping recorded ones" + ); + return existing + .into_iter() + .find(|row| row.disconnected_at.is_none()) + .map(|row| row.joined_at); + } - if let Err(err) = session_dao::delete_session_servers(db, session_id).await { - tracing::warn!(session = %session_id, error = %err, "failed to clear server spans for replay"); - return None; - } + if let Err(err) = session_dao::delete_session_servers(db, session_id).await { + tracing::warn!(session = %session_id, error = %err, "failed to clear server spans for replay"); + return None; + } - let mut open = None; - for span in spans { - let joined_at = span.joined_at.to_rfc3339(); - if let Err(err) = session_dao::insert_server_join_at( - db, - session_id, - &span.host, - span.port.map(i64::from), - &joined_at, - ) - .await - { - tracing::warn!(session = %session_id, error = %err, "failed to replay server join"); - continue; - } + let mut open = None; + for span in spans { + let joined_at = span.joined_at.to_rfc3339(); + if let Err(err) = session_dao::insert_server_join_at( + db, + session_id, + &span.host, + span.port.map(i64::from), + &joined_at, + ) + .await + { + tracing::warn!(session = %session_id, error = %err, "failed to replay server join"); + continue; + } - match span.disconnected_at { - Some(at) => { - if let Err(err) = - session_dao::finish_server_at(db, &joined_at, &at.to_rfc3339()).await - { - tracing::warn!(session = %session_id, error = %err, "failed to replay server leave"); - } - } - None => open = Some(joined_at), - } - } + match span.disconnected_at { + Some(at) => { + if let Err(err) = + session_dao::finish_server_at(db, &joined_at, &at.to_rfc3339()).await + { + tracing::warn!(session = %session_id, error = %err, "failed to replay server leave"); + } + } + None => open = Some(joined_at), + } + } - open + open } async fn open_server_of(state: &Arc, session_id: &str) -> Option { - session_dao::list_session_servers(&state.services.db, session_id) - .await - .unwrap_or_default() - .into_iter() - .find(|row| row.disconnected_at.is_none()) - .map(|row| row.joined_at) + session_dao::list_session_servers(&state.services.db, session_id) + .await + .unwrap_or_default() + .into_iter() + .find(|row| row.disconnected_at.is_none()) + .map(|row| row.joined_at) } struct SessionLog { - content: String, - modified: Option>, + content: String, + modified: Option>, } /// `cluster-output.log` is preferred it is truncated at every launch so it /// belongs to one session whereas `latest.log` may already have rotated async fn read_session_log(cluster: &Cluster) -> Option { - let mut candidates: Vec = Vec::new(); - if let Ok(path) = oneclient_cluster::logs::cluster_output_log(cluster) { - candidates.push(path); - } - if let Ok(dir) = cluster.dir() { - candidates.push(dir.join("logs").join("latest.log")); - } + let mut candidates: Vec = Vec::new(); + if let Ok(path) = oneclient_cluster::logs::cluster_output_log(cluster) { + candidates.push(path); + } + if let Ok(dir) = cluster.dir() { + candidates.push(dir.join("logs").join("latest.log")); + } - for path in candidates { - match read_log(&path).await { - Some(log) if !log.content.trim().is_empty() => return Some(log), - _ => continue, - } - } + for path in candidates { + match read_log(&path).await { + Some(log) if !log.content.trim().is_empty() => return Some(log), + _ => continue, + } + } - None + None } async fn read_log(path: &Path) -> Option { - let content = polyio::read_to_string(path).await.ok()?; - let modified = polyio::stat(path) - .await - .ok() - .and_then(|meta| meta.modified().ok()) - .map(DateTime::::from); + let content = polyio::read_to_string(path).await.ok()?; + let modified = polyio::stat(path) + .await + .ok() + .and_then(|meta| meta.modified().ok()) + .map(DateTime::::from); - Some(SessionLog { content, modified }) + Some(SessionLog { content, modified }) } diff --git a/packages/oneclient_core/src/game/session.rs b/packages/oneclient_core/src/game/session.rs index 57c0d27b..795552ac 100644 --- a/packages/oneclient_core/src/game/session.rs +++ b/packages/oneclient_core/src/game/session.rs @@ -5,235 +5,240 @@ use oneclient_db::dao::game_session as session_dao; use oneclient_db::models::NewGameSession; use tokio::sync::Mutex; -use oneclient_java::JavaRuntime; -use oneclient_content::packages::{ContentType, PackageStore}; use crate::state::LauncherState; +use oneclient_content::packages::{ContentType, PackageStore}; +use oneclient_java::JavaRuntime; #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct ServerJoin { - pub host: String, - pub port: Option, + pub host: String, + pub port: Option, } /// The session row's `started_at` which is its primary key pub(crate) type SessionId = String; pub(crate) fn parse_server_join(line: &str) -> Option { - const MARKER: &str = "Connecting to "; - let idx = line.find(MARKER)?; - let rest = line[idx + MARKER.len()..].trim(); - if rest.is_empty() { - return None; - } + const MARKER: &str = "Connecting to "; + let idx = line.find(MARKER)?; + let rest = line[idx + MARKER.len()..].trim(); + if rest.is_empty() { + return None; + } - let (host, port) = match rest.split_once(", ") { - Some((host, port)) => { - let port = port.split_whitespace().next().and_then(|p| p.parse::().ok()); - (host.trim(), port) - } - None => (rest, None), - }; + let (host, port) = match rest.split_once(", ") { + Some((host, port)) => { + let port = port + .split_whitespace() + .next() + .and_then(|p| p.parse::().ok()); + (host.trim(), port) + } + None => (rest, None), + }; - let host = host.trim(); - if host.is_empty() || host.chars().any(char::is_whitespace) { - return None; - } + let host = host.trim(); + if host.is_empty() || host.chars().any(char::is_whitespace) { + return None; + } - Some(ServerJoin { - host: host.to_string(), - port, - }) + Some(ServerJoin { + host: host.to_string(), + port, + }) } #[derive(Clone)] pub(crate) struct SessionRecorder { - session_started_at: String, - db: DbPool, - open_server: Arc>>, + session_started_at: String, + db: DbPool, + open_server: Arc>>, } 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, - ram_allocated_mb: u32, - java: &JavaRuntime, - ) -> Option { - let mods_enabled = count_enabled_mods(state, cluster_id).await; + #[tracing::instrument( + skip(state, java), + fields(cluster_id, ram_allocated_mb), + level = "debug" + )] + pub(crate) async fn start( + state: &Arc, + cluster_id: i64, + ram_allocated_mb: u32, + java: &JavaRuntime, + ) -> Option { + let mods_enabled = count_enabled_mods(state, cluster_id).await; - let java_vendor = java.vendor.to_string(); - let started_at = chrono::Utc::now().to_rfc3339(); - let new = NewGameSession { - cluster_id, - started_at: &started_at, - ram_allocated_mb: i64::from(ram_allocated_mb), - mods_enabled: mods_enabled as i64, - java_vendor: Some(java_vendor.as_str()), - java_version: Some(java.version.as_str()), - }; + let java_vendor = java.vendor.to_string(); + let started_at = chrono::Utc::now().to_rfc3339(); + let new = NewGameSession { + cluster_id, + started_at: &started_at, + ram_allocated_mb: i64::from(ram_allocated_mb), + mods_enabled: mods_enabled as i64, + java_vendor: Some(java_vendor.as_str()), + java_version: Some(java.version.as_str()), + }; - let session = match session_dao::insert_session(&state.services.db, &new).await { - Ok(row) => row, - Err(err) => { - tracing::warn!(cluster_id, error = %err, "failed to record game session"); - return None; - } - }; + let session = match session_dao::insert_session(&state.services.db, &new).await { + Ok(row) => row, + Err(err) => { + tracing::warn!(cluster_id, error = %err, "failed to record game session"); + return None; + } + }; - Some(Self { - session_started_at: session.started_at, - db: state.services.db.clone(), - open_server: Arc::new(Mutex::new(None)), - }) - } + Some(Self { + session_started_at: session.started_at, + db: state.services.db.clone(), + open_server: Arc::new(Mutex::new(None)), + }) + } - /// Re-attaches to a row left open by a previous launcher run so a game that - /// outlived the launcher keeps recording into the same session - pub(crate) fn resume(db: DbPool, session_started_at: SessionId, open_server: Option) -> Self { - Self { - session_started_at, - db, - open_server: Arc::new(Mutex::new(open_server)), - } - } + /// Re-attaches to a row left open by a previous launcher run so a game that + /// outlived the launcher keeps recording into the same session + pub(crate) fn resume( + db: DbPool, + session_started_at: SessionId, + open_server: Option, + ) -> Self { + Self { + session_started_at, + db, + open_server: Arc::new(Mutex::new(open_server)), + } + } - /// Playtime is measured against this so it agrees with the session span - /// analytics reads back out of the row - pub(crate) fn started_at(&self) -> Option> { - chrono::DateTime::parse_from_rfc3339(&self.session_started_at) - .ok() - .map(|at| at.with_timezone(&chrono::Utc)) - } + /// Playtime is measured against this so it agrees with the session span + /// analytics reads back out of the row + pub(crate) fn started_at(&self) -> Option> { + chrono::DateTime::parse_from_rfc3339(&self.session_started_at) + .ok() + .map(|at| at.with_timezone(&chrono::Utc)) + } - /// Without this a launcher restart cannot tell a live game from one that - /// exited unobserved - #[tracing::instrument(skip(self), level = "debug")] - pub(crate) async fn record_process(&self, pid: u32, pid_started_at: Option) { - if let Err(err) = session_dao::set_session_process( - &self.db, - &self.session_started_at, - Some(i64::from(pid)), - pid_started_at.map(|t| t as i64), - ) - .await - { - tracing::warn!(pid, error = %err, "failed to record session process"); - } - } + /// Without this a launcher restart cannot tell a live game from one that + /// exited unobserved + #[tracing::instrument(skip(self), level = "debug")] + pub(crate) async fn record_process(&self, pid: u32, pid_started_at: Option) { + if let Err(err) = session_dao::set_session_process( + &self.db, + &self.session_started_at, + Some(i64::from(pid)), + pid_started_at.map(|t| t as i64), + ) + .await + { + tracing::warn!(pid, error = %err, "failed to record session process"); + } + } - pub(crate) async fn observe(&self, line: &str) { - if let Some(join) = parse_server_join(line) { - self.open(join).await; - } else if super::log_replay::is_leave_marker(line) { - self.close_open().await; - } - } + pub(crate) async fn observe(&self, line: &str) { + if let Some(join) = parse_server_join(line) { + self.open(join).await; + } else if super::log_replay::is_leave_marker(line) { + self.close_open().await; + } + } - async fn open(&self, join: ServerJoin) { - let mut open = self.open_server.lock().await; - if let Some(prev) = open.take() - && let Err(err) = session_dao::finish_server(&self.db, &prev).await - { - tracing::warn!(error = %err, "failed to close previous server row"); - } + async fn open(&self, join: ServerJoin) { + let mut open = self.open_server.lock().await; + if let Some(prev) = open.take() + && let Err(err) = session_dao::finish_server(&self.db, &prev).await + { + tracing::warn!(error = %err, "failed to close previous server row"); + } - let port = join.port.map(i64::from); - match session_dao::insert_server_join( - &self.db, - &self.session_started_at, - &join.host, - port, - ) - .await - { - Ok(joined_at) => *open = Some(joined_at), - Err(err) => tracing::warn!(error = %err, "failed to record server join"), - } - } + let port = join.port.map(i64::from); + match session_dao::insert_server_join(&self.db, &self.session_started_at, &join.host, port) + .await + { + Ok(joined_at) => *open = Some(joined_at), + Err(err) => tracing::warn!(error = %err, "failed to record server join"), + } + } - async fn close_open(&self) { - if let Some(prev) = self.open_server.lock().await.take() - && let Err(err) = session_dao::finish_server(&self.db, &prev).await - { - tracing::warn!(error = %err, "failed to close server row"); - } - } + async fn close_open(&self) { + if let Some(prev) = self.open_server.lock().await.take() + && let Err(err) = session_dao::finish_server(&self.db, &prev).await + { + tracing::warn!(error = %err, "failed to close server row"); + } + } - /// The time is explicit because an exit is not always observed as it happens - /// one recovered from a log ended in the past - #[tracing::instrument(skip(self), fields(exit_code), level = "debug")] - pub(crate) async fn finish_at(self, ended_at: &str, exit_code: Option) { - if let Some(open) = self.open_server.lock().await.take() - && let Err(err) = session_dao::finish_server_at(&self.db, &open, ended_at).await - { - tracing::warn!(error = %err, "failed to close open server on exit"); - } + /// The time is explicit because an exit is not always observed as it happens + /// one recovered from a log ended in the past + #[tracing::instrument(skip(self), fields(exit_code), level = "debug")] + pub(crate) async fn finish_at(self, ended_at: &str, exit_code: Option) { + if let Some(open) = self.open_server.lock().await.take() + && let Err(err) = session_dao::finish_server_at(&self.db, &open, ended_at).await + { + tracing::warn!(error = %err, "failed to close open server on exit"); + } - if let Err(err) = - session_dao::finish_session_at(&self.db, &self.session_started_at, ended_at, exit_code) - .await - { - tracing::warn!(session = %self.session_started_at, error = %err, "failed to finish game session"); - } - } + if let Err(err) = + session_dao::finish_session_at(&self.db, &self.session_started_at, ended_at, exit_code) + .await + { + tracing::warn!(session = %self.session_started_at, error = %err, "failed to finish game session"); + } + } } #[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.content()).await { - Ok(linked) => linked - .into_iter() - .filter(|a| a.enabled && a.content_type == ContentType::Mod) - .count(), - Err(err) => { - tracing::warn!(cluster_id, error = %err, "failed to count mods for session"); - 0 - } - } + match PackageStore::list_linked_artifacts(cluster_id, &state.services.content()).await { + Ok(linked) => linked + .into_iter() + .filter(|a| a.enabled && a.content_type == ContentType::Mod) + .count(), + Err(err) => { + tracing::warn!(cluster_id, error = %err, "failed to count mods for session"); + 0 + } + } } #[cfg(test)] mod tests { - use super::*; + use super::*; - #[test] - fn parses_host_and_port() { - let join = - parse_server_join("[12:00:00] [Render thread/INFO]: Connecting to mc.hypixel.net, 25565") - .expect("should parse"); - assert_eq!(join.host, "mc.hypixel.net"); - assert_eq!(join.port, Some(25565)); - } + #[test] + fn parses_host_and_port() { + let join = parse_server_join( + "[12:00:00] [Render thread/INFO]: Connecting to mc.hypixel.net, 25565", + ) + .expect("should parse"); + assert_eq!(join.host, "mc.hypixel.net"); + assert_eq!(join.port, Some(25565)); + } - #[test] - fn parses_ip_and_port() { - let join = parse_server_join("[Render thread/INFO]: Connecting to 192.168.1.10, 25577") - .expect("should parse"); - assert_eq!(join.host, "192.168.1.10"); - assert_eq!(join.port, Some(25577)); - } + #[test] + fn parses_ip_and_port() { + let join = parse_server_join("[Render thread/INFO]: Connecting to 192.168.1.10, 25577") + .expect("should parse"); + assert_eq!(join.host, "192.168.1.10"); + assert_eq!(join.port, Some(25577)); + } - #[test] - fn parses_host_without_port() { - let join = parse_server_join("Connecting to play.example.com").expect("should parse"); - assert_eq!(join.host, "play.example.com"); - assert_eq!(join.port, None); - } + #[test] + fn parses_host_without_port() { + let join = parse_server_join("Connecting to play.example.com").expect("should parse"); + assert_eq!(join.host, "play.example.com"); + assert_eq!(join.port, None); + } - #[test] - fn ignores_unrelated_lines() { - assert!(parse_server_join("[Render thread/INFO]: Loading world").is_none()); - assert!(parse_server_join("Connecting to ").is_none()); - } + #[test] + fn ignores_unrelated_lines() { + assert!(parse_server_join("[Render thread/INFO]: Loading world").is_none()); + assert!(parse_server_join("Connecting to ").is_none()); + } - #[test] - fn rejects_addresses_with_spaces() { - assert!( - parse_server_join( - "[INFO]: Connecting to voice chat server: 'vc.example.com:24454'" - ) - .is_none() - ); - } + #[test] + fn rejects_addresses_with_spaces() { + assert!( + parse_server_join("[INFO]: Connecting to voice chat server: 'vc.example.com:24454'") + .is_none() + ); + } } diff --git a/packages/oneclient_core/src/game/shared_dir.rs b/packages/oneclient_core/src/game/shared_dir.rs index 66178f09..a800bca6 100644 --- a/packages/oneclient_core/src/game/shared_dir.rs +++ b/packages/oneclient_core/src/game/shared_dir.rs @@ -8,17 +8,15 @@ use oneclient_db::dao::cluster as cluster_dao; use crate::LauncherResult; use crate::clusters::Cluster; +use crate::state::LauncherServices; use oneclient_cluster::remove_mods_link; use oneclient_common::domain::{ContentType, ProviderId}; use oneclient_common::paths; -use oneclient_content::packages::store::manifest::{ - self, ManifestEntry, MaterializedManifest, -}; +use oneclient_content::packages::PackageStore; +use oneclient_content::packages::store::manifest::{self, ManifestEntry, MaterializedManifest}; use oneclient_content::packages::store::{ artifact_absolute_path, link_or_copy, remove_entry, sweep_staging_files, }; -use oneclient_content::packages::PackageStore; -use crate::state::LauncherServices; const REDIRECTED_DIRS: [&str; 2] = ["logs", "crash-reports"]; @@ -98,7 +96,10 @@ pub async fn materialize_content( let into = cluster_dir.join(ContentType::Mod.folder_name()); let ours = ours_in_folder(ContentType::Mod, &linked, previous.as_ref()); - tracing::info!(cluster_id = cluster.id, "moving mods out of the shared game directory"); + tracing::info!( + cluster_id = cluster.id, + "moving mods out of the shared game directory" + ); stash_content_files(&from, &into, ContentType::Mod, &ours).await; } @@ -120,17 +121,19 @@ pub async fn materialize_content( } if mods_in_cluster { - clear_unlinked_mods_once(services, cluster, &cluster_dir.join(ContentType::Mod.folder_name())) - .await; + clear_unlinked_mods_once( + services, + cluster, + &cluster_dir.join(ContentType::Mod.folder_name()), + ) + .await; } import_manual_content_with(services, cluster, game_dir, mods_in_cluster, true).await; - if let Err(err) = oneclient_content::packages::reconcile_duplicate_activity( - cluster.id, - &services.content(), - ) - .await + if let Err(err) = + oneclient_content::packages::reconcile_duplicate_activity(cluster.id, &services.content()) + .await { // Not worth blocking a launch the duplicates were already there tracing::warn!(cluster_id = cluster.id, %err, "failed to resolve duplicate package versions"); @@ -147,11 +150,21 @@ pub async fn materialize_content( Ok(disabled) if !disabled.is_empty() => { let names = removal_summary(&disabled); let body = if disabled.len() == 1 { - format!("{names} is built for a different Minecraft version, so it has been switched off in {}.", cluster.name) + format!( + "{names} is built for a different Minecraft version, so it has been switched off in {}.", + cluster.name + ) } else { - format!("{names} are built for a different Minecraft version, so they have been switched off in {}.", cluster.name) + format!( + "{names} are built for a different Minecraft version, so they have been switched off in {}.", + cluster.name + ) }; - services.events.notify("Incompatible mods switched off").body(body).send(); + services + .events + .notify("Incompatible mods switched off") + .body(body) + .send(); } Ok(_) => {} Err(err) => { @@ -187,7 +200,11 @@ pub async fn materialize_content( // it themselves so it cannot be taken any earlier let _manifest = manifest::lock().await; - let mods_root = if mods_in_cluster { &cluster_dir } else { game_dir }; + let mods_root = if mods_in_cluster { + &cluster_dir + } else { + game_dir + }; for content_type in SWAP_TYPES { sweep_staging_files(&mods_root.join(content_type.folder_name())).await; } @@ -268,7 +285,8 @@ async fn desired_global( continue; } - let Some(artifact) = artifact_dao::get_artifact_by_hash(&services.db, &row.hash).await? + let Some(artifact) = + artifact_dao::get_artifact_by_hash(&services.db, &row.hash).await? else { continue; }; @@ -676,7 +694,10 @@ async fn cached_file( .filter(|artifact| artifact.hash == hash)?; let path = artifact_absolute_path(&restored.path).ok()?; - polyio::try_exists(&path).await.unwrap_or(false).then_some(path) + polyio::try_exists(&path) + .await + .unwrap_or(false) + .then_some(path) } async fn link_desired(root: &Path, desired: &[Desired]) -> Vec { @@ -966,15 +987,14 @@ async fn import_manual_content_with( for content_type in GLOBAL_TYPES { let dir = global_root.join(content_type.folder_name()); - let known = match artifact_dao::list_global_artifacts(&services.db, content_type as i64) - .await - { - Ok(rows) => rows.into_iter().map(|row| row.file_name).collect(), - Err(err) => { - tracing::warn!(error = %err, "cannot list global content; skipping its import"); - continue; - } - }; + let known = + match artifact_dao::list_global_artifacts(&services.db, content_type as i64).await { + Ok(rows) => rows.into_iter().map(|row| row.file_name).collect(), + Err(err) => { + tracing::warn!(error = %err, "cannot list global content; skipping its import"); + continue; + } + }; import_from_dir( services, @@ -1075,7 +1095,10 @@ async fn discard_adopted_original( match artifact_absolute_path(&row.path) { Ok(cached) if polyio::try_exists(&cached).await.unwrap_or(false) => {} _ => { - tracing::warn!(file = name, "adopted content is not in the cache; leaving the original"); + tracing::warn!( + file = name, + "adopted content is not in the cache; leaving the original" + ); return; } } @@ -1610,7 +1633,11 @@ mod tests { assert_eq!(lines.len(), 2); assert!(lines[0].starts_with("[prefix]/home/alex/")); assert!(lines[1].starts_with("[prefix]/var/home/alex/")); - assert!(lines.iter().all(|line| line.ends_with(std::path::MAIN_SEPARATOR))); + assert!( + lines + .iter() + .all(|line| line.ends_with(std::path::MAIN_SEPARATOR)) + ); } #[test] @@ -1709,13 +1736,17 @@ mod tests { assert!(!shared.join("bsl.zip").exists(), "managed pack left behind"); assert!(!shared.join("bsl.zip.txt").exists(), "sidecar left behind"); assert_eq!( - polyio::read_to_string(stash.join("bsl.zip.txt")).await.unwrap(), + polyio::read_to_string(stash.join("bsl.zip.txt")) + .await + .unwrap(), "BLOOM=off" ); restore_stashed(&stash, &shared, ContentType::Shader, &ours).await; assert_eq!( - polyio::read_to_string(shared.join("bsl.zip.txt")).await.unwrap(), + polyio::read_to_string(shared.join("bsl.zip.txt")) + .await + .unwrap(), "BLOOM=off" ); @@ -1723,7 +1754,9 @@ mod tests { .await .unwrap(); assert_eq!( - polyio::read_to_string(shared.join("bsl.zip.txt")).await.unwrap(), + polyio::read_to_string(shared.join("bsl.zip.txt")) + .await + .unwrap(), "BLOOM=on" ); @@ -1735,10 +1768,15 @@ mod tests { let root = polyio::testing::ScratchDir::new("unpacked"); let shared = root.join("shared").join("shaderpacks"); let stash = root.join("cluster").join("shaderpacks"); - polyio::create_dir_all(shared.join("Loose/shaders")).await.unwrap(); - polyio::write(shared.join("Loose/shaders/final.fsh"), b"void main".as_slice()) + polyio::create_dir_all(shared.join("Loose/shaders")) .await .unwrap(); + polyio::write( + shared.join("Loose/shaders/final.fsh"), + b"void main".as_slice(), + ) + .await + .unwrap(); stash_content_files(&shared, &stash, ContentType::Shader, &HashSet::new()).await; assert!(!shared.join("Loose").exists()); @@ -1809,7 +1847,12 @@ mod tests { let jar = game_dir.join("mods").join("theirs.jar"); polyio::write(&jar, b"jar".as_slice()).await.unwrap(); - prune_previous(game_dir, Some(&manifest_of(1, &["mods/theirs.jar"])), &HashSet::new()).await; + prune_previous( + game_dir, + Some(&manifest_of(1, &["mods/theirs.jar"])), + &HashSet::new(), + ) + .await; assert!(polyio::symlink_metadata(&jar).await.is_err()); @@ -1827,9 +1870,17 @@ mod tests { let mine = game_dir.join("mods").join("handmade.jar"); polyio::write(&mine, b"jar".as_slice()).await.unwrap(); - prune_previous(game_dir, Some(&manifest_of(1, &["mods/ours.jar"])), &HashSet::new()).await; + prune_previous( + game_dir, + Some(&manifest_of(1, &["mods/ours.jar"])), + &HashSet::new(), + ) + .await; - assert!(mine.exists(), "a file we never materialized is not ours to delete"); + assert!( + mine.exists(), + "a file we never materialized is not ours to delete" + ); std::fs::remove_dir_all(root.path()).ok(); } @@ -1863,7 +1914,10 @@ mod tests { stash.join("removed.jar").exists(), "and it must not be silently deleted either" ); - assert!(shared.join("options.txt").exists(), "sidecars still restore"); + assert!( + shared.join("options.txt").exists(), + "sidecars still restore" + ); std::fs::remove_dir_all(root.path()).ok(); } @@ -1884,7 +1938,9 @@ mod tests { polyio::write(stash.join("leftover.jar"), b"jar".as_slice()) .await .unwrap(); - polyio::create_dir_all(stash.join("unpacked")).await.unwrap(); + polyio::create_dir_all(stash.join("unpacked")) + .await + .unwrap(); polyio::write(stash.join("unpacked").join("inner.txt"), b"x".as_slice()) .await .unwrap(); @@ -1989,7 +2045,9 @@ mod tests { polyio::create_dir_all(root.path()).await.unwrap(); for file in present { - polyio::write(root.join(file), b"jar".as_slice()).await.unwrap(); + polyio::write(root.join(file), b"jar".as_slice()) + .await + .unwrap(); } root @@ -2038,7 +2096,11 @@ mod tests { let manifest = mods_manifest(&["a.jar", "b.jar"]); let missing = Path::new("definitely-not-a-directory-ю"); - assert!(hand_removed_content(missing, ContentType::Mod, Some(&manifest)).await.is_empty()); + assert!( + hand_removed_content(missing, ContentType::Mod, Some(&manifest)) + .await + .is_empty() + ); } #[tokio::test] @@ -2047,7 +2109,9 @@ mod tests { let manifest = mods_manifest(&["a.jar", "b.jar", "c.jar", "d.jar"]); assert!( - hand_removed_content(dir.path(), ContentType::Mod, Some(&manifest)).await.is_empty(), + hand_removed_content(dir.path(), ContentType::Mod, Some(&manifest)) + .await + .is_empty(), "an empty folder where everything was is not four deliberate deletions" ); @@ -2059,7 +2123,12 @@ mod tests { let dir = mods_scratch("small_clear", &[]).await; let manifest = mods_manifest(&["a.jar", "b.jar"]); - assert_eq!(hand_removed_content(dir.path(), ContentType::Mod, Some(&manifest)).await.len(), 2); + assert_eq!( + hand_removed_content(dir.path(), ContentType::Mod, Some(&manifest)) + .await + .len(), + 2 + ); std::fs::remove_dir_all(dir.path()).ok(); } @@ -2068,7 +2137,11 @@ mod tests { async fn a_first_launch_concludes_nothing() { let dir = mods_scratch("no_manifest", &[]).await; - assert!(hand_removed_content(dir.path(), ContentType::Mod, None).await.is_empty()); + assert!( + hand_removed_content(dir.path(), ContentType::Mod, None) + .await + .is_empty() + ); std::fs::remove_dir_all(dir.path()).ok(); } diff --git a/packages/oneclient_core/src/game/tail.rs b/packages/oneclient_core/src/game/tail.rs index 5e5394f5..e63493fb 100644 --- a/packages/oneclient_core/src/game/tail.rs +++ b/packages/oneclient_core/src/game/tail.rs @@ -12,270 +12,292 @@ use oneclient_events::EventBus; const POLL_INTERVAL: Duration = Duration::from_millis(200); pub(crate) struct LogTail { - stop: Arc, - handle: tokio::task::JoinHandle<()>, + stop: Arc, + handle: tokio::task::JoinHandle<()>, } impl LogTail { - /// Stops only after one last pass the lines explaining why the game - /// exited are written moments before it does - pub(crate) async fn stop(self) { - self.stop.store(true, Ordering::Relaxed); - let _ = self.handle.await; - } + /// Stops only after one last pass the lines explaining why the game + /// exited are written moments before it does + pub(crate) async fn stop(self) { + self.stop.store(true, Ordering::Relaxed); + let _ = self.handle.await; + } } pub(crate) fn spawn_log_tail( - cluster_id: i64, - path: PathBuf, - events: EventBus, - recorder: Option, - crash_watch: CrashWatch, + cluster_id: i64, + path: PathBuf, + events: EventBus, + recorder: Option, + crash_watch: CrashWatch, ) -> LogTail { - let stop = Arc::new(AtomicBool::new(false)); - let flag = Arc::clone(&stop); + let stop = Arc::new(AtomicBool::new(false)); + let flag = Arc::clone(&stop); - let handle = tokio::spawn(async move { - if let Err(err) = tail(cluster_id, &path, &events, recorder.as_ref(), &crash_watch, &flag).await { - tracing::warn!(cluster_id, path = %path.display(), error = %err, "game log tail stopped"); - } - }); + let handle = tokio::spawn(async move { + if let Err(err) = tail( + cluster_id, + &path, + &events, + recorder.as_ref(), + &crash_watch, + &flag, + ) + .await + { + tracing::warn!(cluster_id, path = %path.display(), error = %err, "game log tail stopped"); + } + }); - LogTail { stop, handle } + LogTail { stop, handle } } async fn tail( - cluster_id: i64, - path: &PathBuf, - events: &EventBus, - recorder: Option<&SessionRecorder>, - crash_watch: &CrashWatch, - stop: &AtomicBool, + cluster_id: i64, + path: &PathBuf, + events: &EventBus, + recorder: Option<&SessionRecorder>, + crash_watch: &CrashWatch, + stop: &AtomicBool, ) -> std::io::Result<()> { - // The game may not have created the file yet on a re-adopt so wait for it - // rather than giving up - let file = loop { - match tokio::fs::File::open(path).await { - Ok(file) => break file, - Err(_) if !stop.load(Ordering::Relaxed) => { - tokio::time::sleep(POLL_INTERVAL).await; - } - Err(err) => return Err(err), - } - }; + // The game may not have created the file yet on a re-adopt so wait for it + // rather than giving up + let file = loop { + match tokio::fs::File::open(path).await { + Ok(file) => break file, + Err(_) if !stop.load(Ordering::Relaxed) => { + tokio::time::sleep(POLL_INTERVAL).await; + } + Err(err) => return Err(err), + } + }; - let mut reader = BufReader::new(file); - let mut line = String::new(); - let mut offset = 0u64; + let mut reader = BufReader::new(file); + let mut line = String::new(); + let mut offset = 0u64; - loop { - line.clear(); - let read = reader.read_line(&mut line).await?; + loop { + line.clear(); + let read = reader.read_line(&mut line).await?; - if read == 0 { - // The read above returned EOF on a drained file so the final pass - // has already run by the time the flag is seen here - if stop.load(Ordering::Relaxed) { - return Ok(()); - } - // A relaunch truncates the log if the file shrank our offset is - // past the end and we would never read again - let len = polyio::stat(path).await.map(|m| m.len()).unwrap_or(offset); - if len < offset { - reader.seek(std::io::SeekFrom::Start(0)).await?; - offset = 0; - } - tokio::time::sleep(POLL_INTERVAL).await; - continue; - } + if read == 0 { + // The read above returned EOF on a drained file so the final pass + // has already run by the time the flag is seen here + if stop.load(Ordering::Relaxed) { + return Ok(()); + } + // A relaunch truncates the log if the file shrank our offset is + // past the end and we would never read again + let len = polyio::stat(path).await.map(|m| m.len()).unwrap_or(offset); + if len < offset { + reader.seek(std::io::SeekFrom::Start(0)).await?; + offset = 0; + } + tokio::time::sleep(POLL_INTERVAL).await; + continue; + } - offset += read as u64; + offset += read as u64; - // A partial line means the game is mid-write wait for the newline - // rather than emitting half a line and re-emitting the rest later - if !line.ends_with('\n') { - // Unless the game is gone a process killed mid-write leaves a - // newline that never arrives hanging the caller of `stop()` - if stop.load(Ordering::Relaxed) { - emit(cluster_id, &line, events, recorder, crash_watch).await; - return Ok(()); - } - reader.seek(std::io::SeekFrom::Start(offset - read as u64)).await?; - offset -= read as u64; - tokio::time::sleep(POLL_INTERVAL).await; - continue; - } + // A partial line means the game is mid-write wait for the newline + // rather than emitting half a line and re-emitting the rest later + if !line.ends_with('\n') { + // Unless the game is gone a process killed mid-write leaves a + // newline that never arrives hanging the caller of `stop()` + if stop.load(Ordering::Relaxed) { + emit(cluster_id, &line, events, recorder, crash_watch).await; + return Ok(()); + } + reader + .seek(std::io::SeekFrom::Start(offset - read as u64)) + .await?; + offset -= read as u64; + tokio::time::sleep(POLL_INTERVAL).await; + continue; + } - emit(cluster_id, &line, events, recorder, crash_watch).await; - } + emit(cluster_id, &line, events, recorder, crash_watch).await; + } } async fn emit( - cluster_id: i64, - line: &str, - events: &EventBus, - recorder: Option<&SessionRecorder>, - crash_watch: &CrashWatch, + cluster_id: i64, + line: &str, + events: &EventBus, + recorder: Option<&SessionRecorder>, + crash_watch: &CrashWatch, ) { - // Blank lines are kept so the console reads the way the log does - let text = line.trim_end_matches(['\n', '\r']).to_string(); - if let Some(recorder) = recorder { - recorder.observe(&text).await; - } - // Read on the way past rather than re-opening the log afterwards a crash - // can take the log with it - crash_watch.observe(&text); - events.game_log(cluster_id, text); + // Blank lines are kept so the console reads the way the log does + let text = line.trim_end_matches(['\n', '\r']).to_string(); + if let Some(recorder) = recorder { + recorder.observe(&text).await; + } + // Read on the way past rather than re-opening the log afterwards a crash + // can take the log with it + crash_watch.observe(&text); + events.game_log(cluster_id, text); } #[cfg(test)] mod tests { - use super::*; - use tokio::io::AsyncWriteExt; - use tokio::sync::mpsc; + use super::*; + use tokio::io::AsyncWriteExt; + use tokio::sync::mpsc; - struct Harness { - dir: async_tempfile::TempDir, - rx: oneclient_events::EventReceiver, - events: EventBus, - } + struct Harness { + dir: async_tempfile::TempDir, + rx: oneclient_events::EventReceiver, + events: EventBus, + } - impl Harness { - async fn new() -> Self { - let (tx, rx) = mpsc::unbounded_channel(); - let dir = polyio::tempdir().await.expect("temp dir"); - Self { - dir, - rx, - events: EventBus::new(tx), - } - } + impl Harness { + async fn new() -> Self { + let (tx, rx) = mpsc::unbounded_channel(); + let dir = polyio::tempdir().await.expect("temp dir"); + Self { + dir, + rx, + events: EventBus::new(tx), + } + } - fn path(&self) -> PathBuf { - self.dir.dir_path().join("cluster-output.log") - } + fn path(&self) -> PathBuf { + self.dir.dir_path().join("cluster-output.log") + } - fn drain(&mut self) -> Vec { - let mut out = Vec::new(); - while let Ok(oneclient_events::Event::Game(oneclient_events::GameEvent::Log { line, .. })) = - self.rx.try_recv() - { - out.push(line); - } - out - } - } + fn drain(&mut self) -> Vec { + let mut out = Vec::new(); + while let Ok(oneclient_events::Event::Game(oneclient_events::GameEvent::Log { + line, + .. + })) = self.rx.try_recv() + { + out.push(line); + } + out + } + } - async fn settle() { - tokio::time::sleep(POLL_INTERVAL * 4).await; - } + async fn settle() { + tokio::time::sleep(POLL_INTERVAL * 4).await; + } - #[tokio::test] - async fn follows_lines_appended_after_start() { - let mut h = Harness::new().await; - let path = h.path(); - polyio::write(&path, "[12:00:00] first\n").await.unwrap(); + #[tokio::test] + async fn follows_lines_appended_after_start() { + let mut h = Harness::new().await; + let path = h.path(); + polyio::write(&path, "[12:00:00] first\n").await.unwrap(); - let tail = spawn_log_tail(7, path.clone(), h.events.clone(), None, CrashWatch::new()); - settle().await; + let tail = spawn_log_tail(7, path.clone(), h.events.clone(), None, CrashWatch::new()); + settle().await; - let mut file = tokio::fs::OpenOptions::new() - .append(true) - .open(&path) - .await - .unwrap(); - file.write_all(b"[12:00:01] second\n").await.unwrap(); - file.flush().await.unwrap(); - settle().await; - tail.stop().await; + let mut file = tokio::fs::OpenOptions::new() + .append(true) + .open(&path) + .await + .unwrap(); + file.write_all(b"[12:00:01] second\n").await.unwrap(); + file.flush().await.unwrap(); + settle().await; + tail.stop().await; - assert_eq!(h.drain(), vec!["[12:00:00] first", "[12:00:01] second"]); - } + assert_eq!(h.drain(), vec!["[12:00:00] first", "[12:00:01] second"]); + } - #[tokio::test] - async fn waits_for_a_file_that_does_not_exist_yet() { - let mut h = Harness::new().await; - let path = h.path(); + #[tokio::test] + async fn waits_for_a_file_that_does_not_exist_yet() { + let mut h = Harness::new().await; + let path = h.path(); - let tail = spawn_log_tail(7, path.clone(), h.events.clone(), None, CrashWatch::new()); - settle().await; - polyio::write(&path, "[12:00:00] late\n").await.unwrap(); - settle().await; - tail.stop().await; + let tail = spawn_log_tail(7, path.clone(), h.events.clone(), None, CrashWatch::new()); + settle().await; + polyio::write(&path, "[12:00:00] late\n").await.unwrap(); + settle().await; + tail.stop().await; - assert_eq!(h.drain(), vec!["[12:00:00] late"]); - } + assert_eq!(h.drain(), vec!["[12:00:00] late"]); + } - #[tokio::test] - async fn holds_back_a_partial_line_until_its_newline_lands() { - let mut h = Harness::new().await; - let path = h.path(); - polyio::write(&path, "[12:00:00] half").await.unwrap(); + #[tokio::test] + async fn holds_back_a_partial_line_until_its_newline_lands() { + let mut h = Harness::new().await; + let path = h.path(); + polyio::write(&path, "[12:00:00] half").await.unwrap(); - let tail = spawn_log_tail(7, path.clone(), h.events.clone(), None, CrashWatch::new()); - settle().await; - // A line mid-write must not be emitted or it would arrive twice - assert!(h.drain().is_empty()); + let tail = spawn_log_tail(7, path.clone(), h.events.clone(), None, CrashWatch::new()); + settle().await; + // A line mid-write must not be emitted or it would arrive twice + assert!(h.drain().is_empty()); - let mut file = tokio::fs::OpenOptions::new() - .append(true) - .open(&path) - .await - .unwrap(); - file.write_all(b" and half\n").await.unwrap(); - file.flush().await.unwrap(); - settle().await; - tail.stop().await; + let mut file = tokio::fs::OpenOptions::new() + .append(true) + .open(&path) + .await + .unwrap(); + file.write_all(b" and half\n").await.unwrap(); + file.flush().await.unwrap(); + settle().await; + tail.stop().await; - assert_eq!(h.drain(), vec!["[12:00:00] half and half"]); - } + assert_eq!(h.drain(), vec!["[12:00:00] half and half"]); + } - #[tokio::test] - async fn recovers_when_the_log_is_truncated() { - let mut h = Harness::new().await; - let path = h.path(); - polyio::write(&path, "[12:00:00] old session line\n").await.unwrap(); + #[tokio::test] + async fn recovers_when_the_log_is_truncated() { + let mut h = Harness::new().await; + let path = h.path(); + polyio::write(&path, "[12:00:00] old session line\n") + .await + .unwrap(); - let tail = spawn_log_tail(7, path.clone(), h.events.clone(), None, CrashWatch::new()); - settle().await; - assert_eq!(h.drain(), vec!["[12:00:00] old session line"]); + let tail = spawn_log_tail(7, path.clone(), h.events.clone(), None, CrashWatch::new()); + settle().await; + assert_eq!(h.drain(), vec!["[12:00:00] old session line"]); - // A relaunch truncates the log the tail would otherwise sit forever at - // an offset past the new end - polyio::write(&path, "[13:00:00] new\n").await.unwrap(); - settle().await; - tail.stop().await; + // A relaunch truncates the log the tail would otherwise sit forever at + // an offset past the new end + polyio::write(&path, "[13:00:00] new\n").await.unwrap(); + settle().await; + tail.stop().await; - assert_eq!(h.drain(), vec!["[13:00:00] new"]); - } + assert_eq!(h.drain(), vec!["[13:00:00] new"]); + } - #[tokio::test] - async fn stops_even_when_the_last_line_has_no_newline() { - let mut h = Harness::new().await; - let path = h.path(); - // A process killed mid-write leaves a newline that never arrives - polyio::write(&path, "[12:00:00] cut off mid-writ").await.unwrap(); + #[tokio::test] + async fn stops_even_when_the_last_line_has_no_newline() { + let mut h = Harness::new().await; + let path = h.path(); + // A process killed mid-write leaves a newline that never arrives + polyio::write(&path, "[12:00:00] cut off mid-writ") + .await + .unwrap(); - let tail = spawn_log_tail(7, path.clone(), h.events.clone(), None, CrashWatch::new()); - settle().await; + let tail = spawn_log_tail(7, path.clone(), h.events.clone(), None, CrashWatch::new()); + settle().await; - // Waiting for that newline would hang the exit path forever - let stopped = tokio::time::timeout(Duration::from_secs(5), tail.stop()).await; - assert!(stopped.is_ok(), "tail.stop() hung on an unterminated final line"); - assert_eq!(h.drain(), vec!["[12:00:00] cut off mid-writ"]); - } + // Waiting for that newline would hang the exit path forever + let stopped = tokio::time::timeout(Duration::from_secs(5), tail.stop()).await; + assert!( + stopped.is_ok(), + "tail.stop() hung on an unterminated final line" + ); + assert_eq!(h.drain(), vec!["[12:00:00] cut off mid-writ"]); + } - #[tokio::test] - async fn emits_the_final_lines_written_just_before_exit() { - let mut h = Harness::new().await; - let path = h.path(); - polyio::write(&path, "[12:00:00] Stopping!\n").await.unwrap(); + #[tokio::test] + async fn emits_the_final_lines_written_just_before_exit() { + let mut h = Harness::new().await; + let path = h.path(); + polyio::write(&path, "[12:00:00] Stopping!\n") + .await + .unwrap(); - // Stopping immediately as the exit path does must still flush the - // reason the game exited - let tail = spawn_log_tail(7, path.clone(), h.events.clone(), None, CrashWatch::new()); - tail.stop().await; + // Stopping immediately as the exit path does must still flush the + // reason the game exited + let tail = spawn_log_tail(7, path.clone(), h.events.clone(), None, CrashWatch::new()); + tail.stop().await; - assert_eq!(h.drain(), vec!["[12:00:00] Stopping!"]); - } + assert_eq!(h.drain(), vec!["[12:00:00] Stopping!"]); + } } diff --git a/packages/oneclient_core/src/images/mod.rs b/packages/oneclient_core/src/images/mod.rs index 71d5370b..6070240a 100644 --- a/packages/oneclient_core/src/images/mod.rs +++ b/packages/oneclient_core/src/images/mod.rs @@ -5,11 +5,11 @@ use bytes::Bytes; use reqwest::Method; use tokio::sync::Mutex; -use polyio::sha1_bytes; -use oneclient_net::RequestError; +use crate::{LauncherError, LauncherResult}; use oneclient_common::paths; use oneclient_net::RequestClient; -use crate::{LauncherError, LauncherResult}; +use oneclient_net::RequestError; +use polyio::sha1_bytes; pub const DEFAULT_IMAGE_EDGE: u32 = 1200; @@ -172,7 +172,9 @@ fn downscale(bytes: &[u8], max_edge: u32) -> Option { let mut out = Vec::new(); let mut cursor = std::io::Cursor::new(&mut out); if resized.color().has_alpha() { - resized.write_to(&mut cursor, image::ImageFormat::Png).ok()?; + resized + .write_to(&mut cursor, image::ImageFormat::Png) + .ok()?; } else { image::codecs::jpeg::JpegEncoder::new_with_quality(&mut cursor, 85) .encode_image(&resized) diff --git a/packages/oneclient_core/src/java_store.rs b/packages/oneclient_core/src/java_store.rs index eebcb6a9..31f2f0c5 100644 --- a/packages/oneclient_core/src/java_store.rs +++ b/packages/oneclient_core/src/java_store.rs @@ -7,73 +7,73 @@ use oneclient_db::{DbPool, dao, models::JavaVersionRow}; use oneclient_java::{JavaRuntime, JavaStore, JavaVendor, StoreError, StoreResult}; pub struct SqlJavaStore { - pool: DbPool, + pool: DbPool, } impl SqlJavaStore { - #[must_use] - pub fn new(pool: DbPool) -> Self { - Self { pool } - } + #[must_use] + pub fn new(pool: DbPool) -> Self { + Self { pool } + } } fn row_to_runtime(row: JavaVersionRow) -> JavaRuntime { - JavaRuntime { - absolute_path: row.absolute_path, - major: row.major as u32, - version: row.version, - vendor: JavaVendor::from_str(&row.vendor).unwrap_or(JavaVendor::Other(row.vendor)), - os_arch: row.os_arch, - is_jdk: row.is_jdk, - probe_version: row.probe_version.try_into().unwrap_or(0), - } + JavaRuntime { + absolute_path: row.absolute_path, + major: row.major as u32, + version: row.version, + vendor: JavaVendor::from_str(&row.vendor).unwrap_or(JavaVendor::Other(row.vendor)), + os_arch: row.os_arch, + is_jdk: row.is_jdk, + probe_version: row.probe_version.try_into().unwrap_or(0), + } } #[async_trait::async_trait] impl JavaStore for SqlJavaStore { - async fn upsert(&self, runtime: &JavaRuntime) -> StoreResult { - let row = dao::java::insert( - &self.pool, - &runtime.absolute_path, - runtime.major, - &runtime.version, - &runtime.vendor.to_string(), - &runtime.os_arch, - runtime.is_jdk, - runtime.probe_version, - ) - .await - .map_err(StoreError::new)?; + async fn upsert(&self, runtime: &JavaRuntime) -> StoreResult { + let row = dao::java::insert( + &self.pool, + &runtime.absolute_path, + runtime.major, + &runtime.version, + &runtime.vendor.to_string(), + &runtime.os_arch, + runtime.is_jdk, + runtime.probe_version, + ) + .await + .map_err(StoreError::new)?; - Ok(row_to_runtime(row)) - } + Ok(row_to_runtime(row)) + } - async fn get_by_path(&self, absolute_path: &str) -> StoreResult> { - Ok(dao::java::get_by_path(&self.pool, absolute_path) - .await - .map_err(StoreError::new)? - .map(row_to_runtime)) - } + async fn get_by_path(&self, absolute_path: &str) -> StoreResult> { + Ok(dao::java::get_by_path(&self.pool, absolute_path) + .await + .map_err(StoreError::new)? + .map(row_to_runtime)) + } - async fn latest_by_major(&self, major: u32) -> StoreResult> { - Ok(dao::java::get_latest_by_major(&self.pool, major) - .await - .map_err(StoreError::new)? - .map(row_to_runtime)) - } + async fn latest_by_major(&self, major: u32) -> StoreResult> { + Ok(dao::java::get_latest_by_major(&self.pool, major) + .await + .map_err(StoreError::new)? + .map(row_to_runtime)) + } - async fn delete_by_path(&self, absolute_path: &str) -> StoreResult<()> { - dao::java::delete_by_path(&self.pool, absolute_path) - .await - .map_err(StoreError::new) - } + async fn delete_by_path(&self, absolute_path: &str) -> StoreResult<()> { + dao::java::delete_by_path(&self.pool, absolute_path) + .await + .map_err(StoreError::new) + } - async fn list(&self) -> StoreResult> { - Ok(dao::java::list_all(&self.pool) - .await - .map_err(StoreError::new)? - .into_iter() - .map(row_to_runtime) - .collect()) - } + async fn list(&self) -> StoreResult> { + Ok(dao::java::list_all(&self.pool) + .await + .map_err(StoreError::new)? + .into_iter() + .map(row_to_runtime) + .collect()) + } } diff --git a/packages/oneclient_core/src/lib.rs b/packages/oneclient_core/src/lib.rs index a364cbc7..4d1b3818 100644 --- a/packages/oneclient_core/src/lib.rs +++ b/packages/oneclient_core/src/lib.rs @@ -15,30 +15,14 @@ pub mod recovery; pub mod relocate; pub mod reporting; pub mod settings; +pub mod simulate; +mod state; pub mod storage; pub mod tos; -mod state; -pub mod simulate; pub mod verify; pub mod versions; -pub use oneclient_content::bundles::{ - apply_bundle_updates, apply_bundle_updates_with, check_bundle_updates, - cluster_has_bundle_content, effective_enabled, - install_bundle, - install_cluster_bundles, - install_package_from_bundle, list_cluster_bundle_overrides, - pending_optional_mods, resolve_optional_mods, skip_optional_mods, PendingOptionalMod, - set_bundle_package_enabled, set_bundle_package_opt_in, set_bundle_package_override, - set_bundle_package_overrides, - ApplyBundleUpdatesResult, Bundle, BundleArchive, BundleError, BundleFile, - BundleFileKind, BundleManifest, BundleOptionalPackage, BundlesManager, BundleUpdateCheckResult, - BundleWithUpdateStatus, FileUpdateStatus, get_bundles_with_update_status, - remove_artifact_from_cluster, set_artifact_enabled_to, -}; -pub use changelog::{fetch_changelog, parse_changelog, ChangelogGroup}; -pub use tos::{fetch_terms, TermsDocument}; -pub use oneclient_discord::{DiscordRpc, Presence}; +pub use changelog::{ChangelogGroup, fetch_changelog, parse_changelog}; pub use clusters::{ Cluster, ClusterError, ClusterManager, ClusterStage, ClusterUpdate, CreateClusterOptions, ensure_from_bundles, ensure_from_versions, estimate_cluster_download, required_java_major, @@ -46,30 +30,43 @@ pub use clusters::{ pub use error::{LauncherError, LauncherResult, SentryExclusion}; pub use game::{GameError, LaunchedGame, get_loader_versions, launch_cluster}; pub use images::ImageCacheStore; +pub use migration::{ + ImportTarget, MigrationDetection, MigrationSource, SourceInstance, detect as detect_migration, + import_game_dir as import_migration_game_dir, +}; pub use oneclient_cluster::logs::{ LogFileInfo, LogKind, LogLevel, LogLine, LogsError, MclogsUploadResponse, ReadOptions, delete_log_at, list_cluster_logs, read_log_at, upload_log_at, }; -pub use oneclient_mc::{McError as MetadataError, MetadataStore}; -pub use migration::{ - detect as detect_migration, import_game_dir as import_migration_game_dir, ImportTarget, - MigrationDetection, MigrationSource, SourceInstance, -}; pub use oneclient_cluster::screenshots::{ ScreenshotInfo, ScreenshotsError, delete_screenshot, list_cluster_screenshots, load_screenshot, }; +pub use oneclient_content::bundles::{ + ApplyBundleUpdatesResult, Bundle, BundleArchive, BundleError, BundleFile, BundleFileKind, + BundleManifest, BundleOptionalPackage, BundleUpdateCheckResult, BundleWithUpdateStatus, + BundlesManager, FileUpdateStatus, PendingOptionalMod, apply_bundle_updates, + apply_bundle_updates_with, check_bundle_updates, cluster_has_bundle_content, effective_enabled, + get_bundles_with_update_status, install_bundle, install_cluster_bundles, + install_package_from_bundle, list_cluster_bundle_overrides, pending_optional_mods, + remove_artifact_from_cluster, resolve_optional_mods, set_artifact_enabled_to, + set_bundle_package_enabled, set_bundle_package_opt_in, set_bundle_package_override, + set_bundle_package_overrides, skip_optional_mods, +}; pub use oneclient_content::packages::LinkedArtifactInfo; -pub use oneclient_db::models::SeenStatus; pub use oneclient_content::packages::updates::{ BrowserPackageUpdate, BrowserUpdateCheck, apply_browser_package_update, - cached_browser_package_updates, check_browser_package_updates, - refresh_browser_package_updates, skip_browser_package_update, + cached_browser_package_updates, check_browser_package_updates, refresh_browser_package_updates, + skip_browser_package_update, }; +pub use oneclient_db::models::SeenStatus; +pub use oneclient_discord::{DiscordRpc, Presence}; +pub use oneclient_mc::{McError as MetadataError, MetadataStore}; pub use settings::ProfileUpdate; -pub use verify::{ClusterVerifyReport, verify_cluster_files}; pub use state::LauncherServices; pub use state::LauncherState; pub use state::run_startup_tasks; +pub use tos::{TermsDocument, fetch_terms}; +pub use verify::{ClusterVerifyReport, verify_cluster_files}; pub use versions::{ RemoteMigration, VersionMetadata, VersionsManager, VersionsManifest, resolve_migration_chain, }; diff --git a/packages/oneclient_core/src/logger.rs b/packages/oneclient_core/src/logger.rs index 59900805..66c5c50c 100644 --- a/packages/oneclient_core/src/logger.rs +++ b/packages/oneclient_core/src/logger.rs @@ -89,10 +89,7 @@ fn sentry_event_filter(metadata: &tracing::Metadata<'_>) -> sentry_tracing::Even } /// `None` when the target is one of ours, meaning the default filter decides -fn dependency_filter( - level: tracing::Level, - target: &str, -) -> Option { +fn dependency_filter(level: tracing::Level, target: &str) -> Option { if is_app_target(target) { return None; } @@ -217,7 +214,10 @@ pub fn init_filtered(filter: impl FnOnce() -> String) -> LauncherResult<()> { let logs_dir = oneclient_common::paths::logs_dir()?; std::fs::create_dir_all(&logs_dir)?; - let log_path = logs_dir.join(format!("{}.log", chrono::Local::now().to_rfc3339().replace(':', "-"))); + let log_path = logs_dir.join(format!( + "{}.log", + chrono::Local::now().to_rfc3339().replace(':', "-") + )); let file = std::fs::OpenOptions::new() .create(true) diff --git a/packages/oneclient_core/src/migration/mod.rs b/packages/oneclient_core/src/migration/mod.rs index f8d1f770..72f3673b 100644 --- a/packages/oneclient_core/src/migration/mod.rs +++ b/packages/oneclient_core/src/migration/mod.rs @@ -44,7 +44,7 @@ pub struct SourceInstance { pub instance_id: i64, pub folder_name: String, pub mc_version: String, - /// Set for "migrated" clusters (e.g. 26.1 fabric -> 26.1.2 fabric) + /// Set for "migrated" clusters (e.g. 26.1 fabric -> 26.1.2 fabric) pub target_mc_version: Option, pub mc_loader: GameLoader, /// Empty when the source has no category concept @@ -62,7 +62,9 @@ pub struct MigrationDetection { impl SourceInstance { #[must_use] pub fn import_version(&self) -> &str { - self.target_mc_version.as_deref().unwrap_or(&self.mc_version) + self.target_mc_version + .as_deref() + .unwrap_or(&self.mc_version) } } diff --git a/packages/oneclient_core/src/migration/vanilla.rs b/packages/oneclient_core/src/migration/vanilla.rs index 68804855..fcb8fc72 100644 --- a/packages/oneclient_core/src/migration/vanilla.rs +++ b/packages/oneclient_core/src/migration/vanilla.rs @@ -210,7 +210,9 @@ mod tests { polyio::write(path, b"x".to_vec()).await.unwrap(); } - polyio::copy_dir(src, dst, IMPORT_EXCLUDE_TOP).await.unwrap(); + polyio::copy_dir(src, dst, IMPORT_EXCLUDE_TOP) + .await + .unwrap(); for kept in [ "options.txt", diff --git a/packages/oneclient_core/src/recovery.rs b/packages/oneclient_core/src/recovery.rs index 9c712a26..da048d0a 100644 --- a/packages/oneclient_core/src/recovery.rs +++ b/packages/oneclient_core/src/recovery.rs @@ -1,424 +1,434 @@ use std::path::{Path, PathBuf}; +use oneclient_db::DbPool; use oneclient_db::dao::{ - artifact as artifact_dao, cluster as cluster_dao, cluster_bundle as cluster_bundle_dao, + artifact as artifact_dao, cluster as cluster_dao, cluster_bundle as cluster_bundle_dao, }; use oneclient_db::models::NewCluster; -use oneclient_db::DbPool; use strum::IntoEnumIterator; +use crate::LauncherResult; use crate::clusters::ClusterStage; -use oneclient_content::packages::PackageStore; -use polyio::sha1_file; +use crate::state::LauncherState; +use oneclient_cluster::profiles::create_profile_from_global; use oneclient_common::domain::{ContentType, GameLoader, ProviderId}; use oneclient_common::paths; -use oneclient_cluster::profiles::create_profile_from_global; -use crate::state::LauncherState; use oneclient_common::version::parse_mc_version; -use crate::LauncherResult; +use oneclient_content::packages::PackageStore; +use polyio::sha1_file; const FILE_CONTENT_TYPES: [ContentType; 4] = [ - ContentType::Mod, - ContentType::ResourcePack, - ContentType::Shader, - ContentType::DataPack, + ContentType::Mod, + ContentType::ResourcePack, + ContentType::Shader, + ContentType::DataPack, ]; #[derive(Debug, Default, Clone, Copy)] pub struct RecoveryReport { - pub adopted_clusters: usize, - pub indexed_artifacts: usize, - pub relinked_files: usize, + pub adopted_clusters: usize, + pub indexed_artifacts: usize, + pub relinked_files: usize, } impl RecoveryReport { - pub fn did_recover(&self) -> bool { - self.adopted_clusters > 0 - } + pub fn did_recover(&self) -> bool { + self.adopted_clusters > 0 + } } #[tracing::instrument(skip(state))] pub async fn reconstruct_from_disk(state: &LauncherState) -> LauncherResult { - let mut report = RecoveryReport::default(); + let mut report = RecoveryReport::default(); - // Held across the whole scan+adopt so a concurrent cluster create (folder - // written before its DB row commits) isn't misclassified as an orphan - let _guard = state.clusters.provisioning_guard().await; + // Held across the whole scan+adopt so a concurrent cluster create (folder + // written before its DB row commits) isn't misclassified as an orphan + let _guard = state.clusters.provisioning_guard().await; - let clusters_dir = paths::clusters_dir()?; - let orphans = orphan_cluster_folders(&state.services.db, &clusters_dir).await?; - if orphans.is_empty() { - return Ok(report); - } + let clusters_dir = paths::clusters_dir()?; + let orphans = orphan_cluster_folders(&state.services.db, &clusters_dir).await?; + if orphans.is_empty() { + return Ok(report); + } - tracing::info!( - orphan_folders = orphans.len(), - "user_data.db appears reset; reconstructing from disk" - ); + tracing::info!( + orphan_folders = orphans.len(), + "user_data.db appears reset; reconstructing from disk" + ); - if let Err(err) = state.java.rescan().await { - tracing::warn!("java rescan during recovery failed: {err:#}"); - } + if let Err(err) = state.java.rescan().await { + tracing::warn!("java rescan during recovery failed: {err:#}"); + } - report.indexed_artifacts = rebuild_artifact_cache(&state.services.db).await?; + report.indexed_artifacts = rebuild_artifact_cache(&state.services.db).await?; - for folder in orphans { - match adopt_cluster(state, &folder, &mut report).await { - Ok(()) => {} - Err(err) => { - tracing::warn!(folder = %folder, "failed to adopt cluster folder: {err:#}"); - } - } - } + for folder in orphans { + match adopt_cluster(state, &folder, &mut report).await { + Ok(()) => {} + Err(err) => { + tracing::warn!(folder = %folder, "failed to adopt cluster folder: {err:#}"); + } + } + } - tracing::info!( - adopted = report.adopted_clusters, - artifacts = report.indexed_artifacts, - files = report.relinked_files, - "database reconstruction complete" - ); + tracing::info!( + adopted = report.adopted_clusters, + artifacts = report.indexed_artifacts, + files = report.relinked_files, + "database reconstruction complete" + ); - Ok(report) + Ok(report) } #[tracing::instrument(level = "debug", skip(pool))] -async fn orphan_cluster_folders( - pool: &DbPool, - clusters_dir: &Path, -) -> LauncherResult> { - let mut orphans = Vec::new(); - for name in list_dir_names(clusters_dir).await? { - if cluster_dao::get_by_folder_name(pool, &name).await?.is_none() { - orphans.push(name); - } - } - Ok(orphans) +async fn orphan_cluster_folders(pool: &DbPool, clusters_dir: &Path) -> LauncherResult> { + let mut orphans = Vec::new(); + for name in list_dir_names(clusters_dir).await? { + if cluster_dao::get_by_folder_name(pool, &name) + .await? + .is_none() + { + orphans.push(name); + } + } + Ok(orphans) } #[tracing::instrument(level = "debug", skip(pool))] async fn rebuild_artifact_cache(pool: &DbPool) -> LauncherResult { - let cache_root = paths::packages_cache_dir()?; - if !cache_root.exists() { - return Ok(0); - } + let cache_root = paths::packages_cache_dir()?; + if !cache_root.exists() { + return Ok(0); + } - let launcher_dir = paths::data_dir()?; - let mut indexed = 0; + let launcher_dir = paths::data_dir()?; + let mut indexed = 0; - for content_name in list_dir_names(&cache_root).await? { - let Some(content_type) = ContentType::from_folder_name(&content_name) else { - continue; - }; - let content_dir = cache_root.join(&content_name); + for content_name in list_dir_names(&cache_root).await? { + let Some(content_type) = ContentType::from_folder_name(&content_name) else { + continue; + }; + let content_dir = cache_root.join(&content_name); - for provider_name in list_dir_names(&content_dir).await? { - let Some(provider) = provider_from_dir_name(&provider_name) else { - continue; - }; - let provider_dir = content_dir.join(&provider_name); + for provider_name in list_dir_names(&content_dir).await? { + let Some(provider) = provider_from_dir_name(&provider_name) else { + continue; + }; + let provider_dir = content_dir.join(&provider_name); - for project_id in list_dir_names(&provider_dir).await? { - let project_dir = provider_dir.join(&project_id); + for project_id in list_dir_names(&provider_dir).await? { + let project_dir = provider_dir.join(&project_id); - for version_id in list_dir_names(&project_dir).await? { - let version_dir = project_dir.join(&version_id); + for version_id in list_dir_names(&project_dir).await? { + let version_dir = project_dir.join(&version_id); - for file in list_files(&version_dir).await? { - if let Err(err) = index_cache_file( - pool, - launcher_dir, - content_type, - provider, - &project_id, - &version_id, - &file, - ) - .await - { - tracing::warn!(path = %file.display(), "failed to index cached file: {err:#}"); - continue; - } - indexed += 1; - } - } - } - } - } + for file in list_files(&version_dir).await? { + if let Err(err) = index_cache_file( + pool, + launcher_dir, + content_type, + provider, + &project_id, + &version_id, + &file, + ) + .await + { + tracing::warn!(path = %file.display(), "failed to index cached file: {err:#}"); + continue; + } + indexed += 1; + } + } + } + } + } - Ok(indexed) + Ok(indexed) } #[allow(clippy::too_many_arguments)] #[tracing::instrument(level = "debug", skip(pool, launcher_dir, file))] async fn index_cache_file( - pool: &DbPool, - launcher_dir: &Path, - content_type: ContentType, - provider: ProviderId, - project_id: &str, - version_id: &str, - file: &Path, + pool: &DbPool, + launcher_dir: &Path, + content_type: ContentType, + provider: ProviderId, + project_id: &str, + version_id: &str, + file: &Path, ) -> LauncherResult<()> { - let hash = sha1_file(file).await?; - let stored_path = relative_launcher_path(launcher_dir, file); - let file_name = file - .file_name() - .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_default(); - let size = polyio::stat(file).await.ok().map(|m| m.len() as i64); + let hash = sha1_file(file).await?; + let stored_path = relative_launcher_path(launcher_dir, file); + let file_name = file + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + let size = polyio::stat(file).await.ok().map(|m| m.len() as i64); - artifact_dao::insert_artifact( - pool, - &hash, - content_type as i64, - &stored_path, - &file_name, - size, - ) - .await?; + artifact_dao::insert_artifact( + pool, + &hash, + content_type as i64, + &stored_path, + &file_name, + size, + ) + .await?; - if matches!(provider, ProviderId::Modrinth | ProviderId::CurseForge) { - artifact_dao::upsert_provider_release( - pool, - provider as i64, - project_id, - version_id, - &hash, - &file_name, - version_id, - None, - "[]", - "[]", - ) - .await?; - } + if matches!(provider, ProviderId::Modrinth | ProviderId::CurseForge) { + artifact_dao::upsert_provider_release( + pool, + provider as i64, + project_id, + version_id, + &hash, + &file_name, + version_id, + None, + "[]", + "[]", + ) + .await?; + } - Ok(()) + Ok(()) } #[tracing::instrument(level = "debug", skip(state, report))] async fn adopt_cluster( - state: &LauncherState, - folder_name: &str, - report: &mut RecoveryReport, + state: &LauncherState, + folder_name: &str, + report: &mut RecoveryReport, ) -> LauncherResult<()> { - let (name, mc_version, loader) = parse_folder_identity(folder_name); + let (name, mc_version, loader) = parse_folder_identity(folder_name); - let global = state.settings.read().global_game_settings.clone(); - let profile = create_profile_from_global(&state.services.db, &global, &name, None, None).await?; + let global = state.settings.read().global_game_settings.clone(); + let profile = + create_profile_from_global(&state.services.db, &global, &name, None, None).await?; - let row = cluster_dao::insert( - &state.services.db, - &NewCluster { - name: &name, - folder_name, - mc_version: &mc_version, - mc_loader: loader as i64, - mc_loader_version: None, - setting_profile_name: Some(&profile.name), - stage: ClusterStage::NotReady as i64, - }, - ) - .await?; + let row = cluster_dao::insert( + &state.services.db, + &NewCluster { + name: &name, + folder_name, + mc_version: &mc_version, + mc_loader: loader as i64, + mc_loader_version: None, + setting_profile_name: Some(&profile.name), + stage: ClusterStage::NotReady as i64, + }, + ) + .await?; - report.adopted_clusters += 1; - tracing::info!( - cluster_id = row.id, - folder = folder_name, - mc_version = %mc_version, - loader = %loader, - "adopted cluster folder from disk" - ); + report.adopted_clusters += 1; + tracing::info!( + cluster_id = row.id, + folder = folder_name, + mc_version = %mc_version, + loader = %loader, + "adopted cluster folder from disk" + ); - relink_cluster_files(state, row.id, folder_name, report).await?; - Ok(()) + relink_cluster_files(state, row.id, folder_name, report).await?; + Ok(()) } /// Files are taken into the artifact cache not indexed in place the cache is /// the only place content is materialized from so in-place rows break on tidy-up #[tracing::instrument(level = "debug", skip(state, report))] async fn relink_cluster_files( - state: &LauncherState, - cluster_id: i64, - folder_name: &str, - report: &mut RecoveryReport, + state: &LauncherState, + cluster_id: i64, + folder_name: &str, + report: &mut RecoveryReport, ) -> LauncherResult<()> { - let pool = &state.services.db; - let content = state.services.content(); - let cluster_root = paths::cluster_dir(folder_name)?; + let pool = &state.services.db; + let content = state.services.content(); + let cluster_root = paths::cluster_dir(folder_name)?; - for content_type in FILE_CONTENT_TYPES { - let dir = cluster_root.join(content_type.folder_name()); + for content_type in FILE_CONTENT_TYPES { + let dir = cluster_root.join(content_type.folder_name()); - if polyio::symlink_metadata(&dir) - .await - .is_ok_and(|meta| meta.file_type().is_symlink()) - { - continue; - } + if polyio::symlink_metadata(&dir) + .await + .is_ok_and(|meta| meta.file_type().is_symlink()) + { + continue; + } - let files = match list_files(&dir).await { - Ok(files) => files, - Err(_) => continue, - }; + let files = match list_files(&dir).await { + Ok(files) => files, + Err(_) => continue, + }; - for file in files { - let file_name = file - .file_name() - .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_default(); - let enabled = !file_name.ends_with(".disabled"); - let base_name = file_name.trim_end_matches(".disabled").to_string(); + for file in files { + let file_name = file + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + let enabled = !file_name.ends_with(".disabled"); + let base_name = file_name.trim_end_matches(".disabled").to_string(); - let row = match PackageStore::import_local_file( - &file, - content_type, - cluster_id, - &content, - ) - .await - { - Ok(row) => row, - Err(err) => { - tracing::warn!(path = %file.display(), "failed to adopt cluster file: {err:#}"); - continue; - } - }; + let row = match PackageStore::import_local_file( + &file, + content_type, + cluster_id, + &content, + ) + .await + { + Ok(row) => row, + Err(err) => { + tracing::warn!(path = %file.display(), "failed to adopt cluster file: {err:#}"); + continue; + } + }; - // `import_local_file` links under the artifact's own name which is - // the `.disabled` spelling for a file the user had switched off - if !enabled { - artifact_dao::update_cluster_artifact(pool, cluster_id, &row.hash, &base_name, 0) - .await?; - } + // `import_local_file` links under the artifact's own name which is + // the `.disabled` spelling for a file the user had switched off + if !enabled { + artifact_dao::update_cluster_artifact(pool, cluster_id, &row.hash, &base_name, 0) + .await?; + } - report.relinked_files += 1; - } - } + report.relinked_files += 1; + } + } - Ok(()) + Ok(()) } #[tracing::instrument(skip(state))] pub async fn restore_bundle_tracking(state: &LauncherState) -> LauncherResult<()> { - let clusters = state.clusters.list().await?; + let clusters = state.clusters.list().await?; - for cluster in clusters { - let archives = match state - .bundles - .archives_for(&state.services.content(), &cluster.mc_version, cluster.mc_loader) - .await - { - Ok(archives) => archives, - Err(err) => { - tracing::warn!( - cluster_id = cluster.id, - "failed to load bundle archives during recovery: {err:#}" - ); - continue; - } - }; + for cluster in clusters { + let archives = match state + .bundles + .archives_for( + &state.services.content(), + &cluster.mc_version, + cluster.mc_loader, + ) + .await + { + Ok(archives) => archives, + Err(err) => { + tracing::warn!( + cluster_id = cluster.id, + "failed to load bundle archives during recovery: {err:#}" + ); + continue; + } + }; - for archive in archives { - let bundle_name = &archive.manifest.name; - for file in &archive.manifest.files { - use oneclient_content::bundles::BundleFileKind; - let BundleFileKind::Managed { - provider, - project_id, - version_id, - .. - } = &file.kind - else { - continue; - }; + for archive in archives { + let bundle_name = &archive.manifest.name; + for file in &archive.manifest.files { + use oneclient_content::bundles::BundleFileKind; + let BundleFileKind::Managed { + provider, + project_id, + version_id, + .. + } = &file.kind + else { + continue; + }; - let Some(release) = artifact_dao::get_provider_release( - &state.services.db, - *provider as i64, - project_id, - version_id, - ) - .await? - else { - continue; - }; + let Some(release) = artifact_dao::get_provider_release( + &state.services.db, + *provider as i64, + project_id, + version_id, + ) + .await? + else { + continue; + }; - if artifact_dao::is_cluster_linked(&state.services.db, cluster.id, &release.hash) - .await? - { - cluster_bundle_dao::track_bundle_artifact( - &state.services.db, - cluster.id, - &release.hash, - bundle_name, - version_id, - project_id, - ) - .await?; - } - } - } - } + if artifact_dao::is_cluster_linked(&state.services.db, cluster.id, &release.hash) + .await? + { + cluster_bundle_dao::track_bundle_artifact( + &state.services.db, + cluster.id, + &release.hash, + bundle_name, + version_id, + project_id, + ) + .await?; + } + } + } + } - Ok(()) + Ok(()) } fn parse_folder_identity(folder_name: &str) -> (String, String, GameLoader) { - let tokens: Vec<&str> = folder_name.split_whitespace().collect(); + let tokens: Vec<&str> = folder_name.split_whitespace().collect(); - for take in [2usize, 1] { - if tokens.len() > take { - let split = tokens.len() - take; - let loader_str = tokens[split..].join(" "); - if let Ok(loader) = loader_str.parse::() { - let version = tokens[..split].join(" "); - if parse_mc_version(&version).is_some() { - return (folder_name.to_string(), version, loader); - } - } - } - } + for take in [2usize, 1] { + if tokens.len() > take { + let split = tokens.len() - take; + let loader_str = tokens[split..].join(" "); + if let Ok(loader) = loader_str.parse::() { + let version = tokens[..split].join(" "); + if parse_mc_version(&version).is_some() { + return (folder_name.to_string(), version, loader); + } + } + } + } - let mc_version = parse_mc_version(folder_name) - .map(|_| folder_name.to_string()) - .unwrap_or_else(|| "unknown".to_string()); - (folder_name.to_string(), mc_version, GameLoader::Vanilla) + let mc_version = parse_mc_version(folder_name) + .map(|_| folder_name.to_string()) + .unwrap_or_else(|| "unknown".to_string()); + (folder_name.to_string(), mc_version, GameLoader::Vanilla) } fn provider_from_dir_name(name: &str) -> Option { - ProviderId::iter().find(|p| p.dir_name() == name) + ProviderId::iter().find(|p| p.dir_name() == name) } fn relative_launcher_path(launcher_dir: &Path, abs: &Path) -> String { - abs.strip_prefix(launcher_dir) - .unwrap_or(abs) - .to_string_lossy() - .replace('\\', "/") + abs.strip_prefix(launcher_dir) + .unwrap_or(abs) + .to_string_lossy() + .replace('\\', "/") } async fn list_dir_names(dir: &Path) -> LauncherResult> { - let mut names = Vec::new(); - let Ok(mut entries) = polyio::read_dir(dir).await else { - return Ok(names); - }; - while let Some(entry) = entries.next_entry().await? { - if entry.file_type().await.map(|t| t.is_dir()).unwrap_or(false) { - names.push(entry.file_name().to_string_lossy().into_owned()); - } - } - Ok(names) + let mut names = Vec::new(); + let Ok(mut entries) = polyio::read_dir(dir).await else { + return Ok(names); + }; + while let Some(entry) = entries.next_entry().await? { + if entry.file_type().await.map(|t| t.is_dir()).unwrap_or(false) { + names.push(entry.file_name().to_string_lossy().into_owned()); + } + } + Ok(names) } async fn list_files(dir: &Path) -> LauncherResult> { - let mut files = Vec::new(); - let Ok(mut entries) = polyio::read_dir(dir).await else { - return Ok(files); - }; - while let Some(entry) = entries.next_entry().await? { - if entry.file_type().await.map(|t| t.is_file()).unwrap_or(false) { - files.push(entry.path()); - } - } - Ok(files) + let mut files = Vec::new(); + let Ok(mut entries) = polyio::read_dir(dir).await else { + return Ok(files); + }; + while let Some(entry) = entries.next_entry().await? { + if entry + .file_type() + .await + .map(|t| t.is_file()) + .unwrap_or(false) + { + files.push(entry.path()); + } + } + Ok(files) } diff --git a/packages/oneclient_core/src/relocate.rs b/packages/oneclient_core/src/relocate.rs index ccf8bfdf..de323224 100644 --- a/packages/oneclient_core/src/relocate.rs +++ b/packages/oneclient_core/src/relocate.rs @@ -19,22 +19,22 @@ static IN_PROGRESS: AtomicBool = AtomicBool::new(false); #[must_use] pub fn in_progress() -> bool { - IN_PROGRESS.load(Ordering::SeqCst) + IN_PROGRESS.load(Ordering::SeqCst) } struct InProgress; impl InProgress { - fn begin() -> Self { - IN_PROGRESS.store(true, Ordering::SeqCst); - Self - } + fn begin() -> Self { + IN_PROGRESS.store(true, Ordering::SeqCst); + Self + } } impl Drop for InProgress { - fn drop(&mut self) { - IN_PROGRESS.store(false, Ordering::SeqCst); - } + fn drop(&mut self) { + IN_PROGRESS.store(false, Ordering::SeqCst); + } } const HEADROOM: f64 = 1.05; @@ -43,779 +43,793 @@ const REPORT_EVERY: u64 = 32 * 1024 * 1024; #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct RelocationPlan { - pub from: PathBuf, - pub to: PathBuf, - pub bytes: u64, - pub available: Option, - pub warning: Option, + pub from: PathBuf, + pub to: PathBuf, + pub bytes: u64, + pub available: Option, + pub warning: Option, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct RelocationOutcome { - pub to: PathBuf, - pub bytes: u64, - pub skipped_links: usize, + pub to: PathBuf, + pub bytes: u64, + pub skipped_links: usize, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct Leftovers { - pub path: PathBuf, - pub bytes: u64, + pub path: PathBuf, + pub bytes: u64, } pub async fn plan(state: &LauncherState, picked: &Path) -> Result { - let from = ready_to_move(state)?; - let checked = data_dir::check(picked).await?; + let from = ready_to_move(state)?; + let checked = data_dir::check(picked).await?; - weigh(state, from, checked, &[]).await + weigh(state, from, checked, &[]).await } pub async fn plan_default(state: &LauncherState) -> Result { - let from = ready_to_move(state)?; - let home = data_dir::default_path()?; + let from = ready_to_move(state)?; + let home = data_dir::default_path()?; - if home == from { - return Err("Your game data is already in the default folder.".to_string()); - } + if home == from { + return Err("Your game data is already in the default folder.".to_string()); + } - let keep = config_owned_names(&home); - let checked = data_dir::check_exact(&home).await?; + let keep = config_owned_names(&home); + let checked = data_dir::check_exact(&home).await?; - weigh(state, from, checked, &keep).await + weigh(state, from, checked, &keep).await } fn ready_to_move(state: &LauncherState) -> Result { - let from = current_dir()?; + let from = current_dir()?; - if let Some(reason) = busy_reason(state) { - return Err(reason); - } + if let Some(reason) = busy_reason(state) { + return Err(reason); + } - if let Some(reason) = restart_pending(state, &from) { - return Err(reason); - } + if let Some(reason) = restart_pending(state, &from) { + return Err(reason); + } - Ok(from) + Ok(from) } async fn weigh( - state: &LauncherState, - from: PathBuf, - checked: data_dir::DataDirCheck, - settled: &[OsString], + state: &LauncherState, + from: PathBuf, + checked: data_dir::DataDirCheck, + settled: &[OsString], ) -> Result { - let to = checked.path; + let to = checked.path; - if to == from { - return Err("Your game data is already there.".to_string()); - } + if to == from { + return Err("Your game data is already there.".to_string()); + } - if to.starts_with(&from) || from.starts_with(&to) { - return Err(format!( - "Pick a folder outside {} (one cannot hold the other).", - from.display() - )); - } + if to.starts_with(&from) || from.starts_with(&to) { + return Err(format!( + "Pick a folder outside {} (one cannot hold the other).", + from.display() + )); + } - if incomplete(&to) { - return Err(format!( - "{} holds a move that was interrupted. Delete that folder and try again.", - to.display() - )); - } + if incomplete(&to) { + return Err(format!( + "{} holds a move that was interrupted. Delete that folder and try again.", + to.display() + )); + } - if let Some(old) = leftovers(state).await { - return Err(format!( - "Clear the {} still sitting in {} first. OneClient keeps track of one old folder at a time.", - format_bytes(old.bytes), - old.path.display() - )); - } + if let Some(old) = leftovers(state).await { + return Err(format!( + "Clear the {} still sitting in {} first. OneClient keeps track of one old folder at a time.", + format_bytes(old.bytes), + old.path.display() + )); + } - if !data_dir::looks_empty(&to, settled).await { - return Err(format!("{} is not empty. Pick an empty folder.", to.display())); - } + if !data_dir::looks_empty(&to, settled).await { + return Err(format!( + "{} is not empty. Pick an empty folder.", + to.display() + )); + } - let bytes = collect(&from, &skipped_names(&from)).await?.total; - let available = data_dir::available_space(&to); + let bytes = collect(&from, &skipped_names(&from)).await?.total; + let available = data_dir::available_space(&to); - if let Some(available) = available - && (available as f64) < bytes as f64 * HEADROOM - { - return Err(format!( - "{} needs {} and only {} is free there.", - to.display(), - format_bytes(bytes), - format_bytes(available) - )); - } + if let Some(available) = available + && (available as f64) < bytes as f64 * HEADROOM + { + return Err(format!( + "{} needs {} and only {} is free there.", + to.display(), + format_bytes(bytes), + format_bytes(available) + )); + } - Ok(RelocationPlan { - from, - to, - bytes, - available, - warning: checked.warning, - }) + Ok(RelocationPlan { + from, + to, + bytes, + available, + warning: checked.warning, + }) } /// `on_copied` is handed the bytes written and the total to write, throttled to /// one call per [`REPORT_EVERY`] bytes plus a final one when the tree lands #[tracing::instrument(skip(state, plan, on_copied), fields(from = %plan.from.display(), to = %plan.to.display()))] pub async fn relocate( - state: &LauncherState, - plan: &RelocationPlan, - mut on_copied: impl FnMut(u64, u64), + state: &LauncherState, + plan: &RelocationPlan, + mut on_copied: impl FnMut(u64, u64), ) -> Result { - if let Some(reason) = busy_reason(state) { - return Err(reason); - } + if let Some(reason) = busy_reason(state) { + return Err(reason); + } - if plan.from != current_dir()? { - return Err("The data folder changed since this move was planned.".to_string()); - } + if plan.from != current_dir()? { + return Err("The data folder changed since this move was planned.".to_string()); + } - if let Some(reason) = restart_pending(state, &plan.from) { - return Err(reason); - } + if let Some(reason) = restart_pending(state, &plan.from) { + return Err(reason); + } - let _in_progress = InProgress::begin(); + let _in_progress = InProgress::begin(); - drop_materialized_content(state).await; + drop_materialized_content(state).await; - let skip = skipped_names(&plan.from); - let found = collect(&plan.from, &skip).await?; + let skip = skipped_names(&plan.from); + let found = collect(&plan.from, &skip).await?; - polyio::create_dir_all(&plan.to) - .await - .map_err(|err| format!("Couldn't create {}: {err}", plan.to.display()))?; + polyio::create_dir_all(&plan.to) + .await + .map_err(|err| format!("Couldn't create {}: {err}", plan.to.display()))?; - let marker = plan.to.join(IN_PROGRESS_MARKER); - polyio::write(&marker, b"".as_slice()) - .await - .map_err(|err| format!("Couldn't write to {}: {err}", plan.to.display()))?; + let marker = plan.to.join(IN_PROGRESS_MARKER); + polyio::write(&marker, b"".as_slice()) + .await + .map_err(|err| format!("Couldn't write to {}: {err}", plan.to.display()))?; - let copied = match write_everything(state, plan, &found, &mut on_copied).await { - Ok(copied) => copied, - Err(err) => { - abandon(&plan.to).await; - return Err(err); - } - }; + let copied = match write_everything(state, plan, &found, &mut on_copied).await { + Ok(copied) => copied, + Err(err) => { + abandon(&plan.to).await; + return Err(err); + } + }; - polyio::remove_file(&marker) - .await - .map_err(|err| format!("Couldn't finish the move at {}: {err}", plan.to.display()))?; + polyio::remove_file(&marker) + .await + .map_err(|err| format!("Couldn't finish the move at {}: {err}", plan.to.display()))?; - if let Err(err) = commit(state, &plan.from, &plan.to).await { - discard_unclaimed(&plan.to).await; - return Err(err); - } + if let Err(err) = commit(state, &plan.from, &plan.to).await { + discard_unclaimed(&plan.to).await; + return Err(err); + } - tracing::info!( - bytes = copied, - path = %plan.to.display(), - "the game data moved; a restart picks up the new folder" - ); + tracing::info!( + bytes = copied, + path = %plan.to.display(), + "the game data moved; a restart picks up the new folder" + ); - Ok(RelocationOutcome { - to: plan.to.clone(), - bytes: copied, - skipped_links: found.links.len(), - }) + Ok(RelocationOutcome { + to: plan.to.clone(), + bytes: copied, + skipped_links: found.links.len(), + }) } pub async fn leftovers(state: &LauncherState) -> Option { - let path = state.settings.read().previous_data_dir.clone()?; + let path = state.settings.read().previous_data_dir.clone()?; - if current_dir().is_ok_and(|current| current == path) { - return None; - } + if current_dir().is_ok_and(|current| current == path) { + return None; + } - if !polyio::try_exists(&path).await.unwrap_or(false) { - return None; - } + if !polyio::try_exists(&path).await.unwrap_or(false) { + return None; + } - Some(Leftovers { - bytes: dir_size(&path).await, - path, - }) + Some(Leftovers { + bytes: dir_size(&path).await, + path, + }) } #[tracing::instrument(skip(state))] pub async fn discard_leftovers(state: &LauncherState) -> Result { - let Some(old) = state.settings.read().previous_data_dir.clone() else { - return Ok(0); - }; + let Some(old) = state.settings.read().previous_data_dir.clone() else { + return Ok(0); + }; - let current = current_dir()?; - if old == current { - return Err("That is the folder you are using now.".to_string()); - } + let current = current_dir()?; + if old == current { + return Err("That is the folder you are using now.".to_string()); + } - let freed = dir_size(&old).await; - let keep = config_owned_names(&old); + let freed = dir_size(&old).await; + let keep = config_owned_names(&old); - if keep.is_empty() { - polyio::remove_dir_all(&old) - .await - .map_err(|err| format!("Couldn't remove {}: {err}", old.display()))?; - } else { - remove_children_except(&old, &keep).await?; - } + if keep.is_empty() { + polyio::remove_dir_all(&old) + .await + .map_err(|err| format!("Couldn't remove {}: {err}", old.display()))?; + } else { + remove_children_except(&old, &keep).await?; + } - { - let mut settings = state.settings.write(); - settings.previous_data_dir = None; - } + { + let mut settings = state.settings.write(); + settings.previous_data_dir = None; + } - let snapshot = state.settings.read().clone(); - save_settings(&snapshot) - .await - .map_err(|err| format!("Couldn't save your settings: {err}"))?; + let snapshot = state.settings.read().clone(); + save_settings(&snapshot) + .await + .map_err(|err| format!("Couldn't save your settings: {err}"))?; - Ok(freed) + Ok(freed) } #[must_use] pub fn incomplete(dir: &Path) -> bool { - dir.join(IN_PROGRESS_MARKER).exists() + dir.join(IN_PROGRESS_MARKER).exists() } fn current_dir() -> Result { - paths::data_dir() - .map(Path::to_path_buf) - .map_err(|err| format!("Couldn't work out where your game data lives: {err}")) + paths::data_dir() + .map(Path::to_path_buf) + .map_err(|err| format!("Couldn't work out where your game data lives: {err}")) } pub fn restart_pending(state: &LauncherState, current: &Path) -> Option { - let settled = state.settings.read().data_dir.clone()?; + let settled = state.settings.read().data_dir.clone()?; - (settled != current).then(|| { - format!( - "A move to {} is already waiting. Restart OneClient to finish it.", - settled.display() - ) - }) + (settled != current).then(|| { + format!( + "A move to {} is already waiting. Restart OneClient to finish it.", + settled.display() + ) + }) } fn busy_reason(state: &LauncherState) -> Option { - let active = state.games.active_ids().len(); - if active == 0 { - return None; - } + let active = state.games.active_ids().len(); + if active == 0 { + return None; + } - Some(format!( - "Close Minecraft first. {active} instance{} still open.", - if active == 1 { " is" } else { "s are" } - )) + Some(format!( + "Close Minecraft first. {active} instance{} still open.", + if active == 1 { " is" } else { "s are" } + )) } fn skipped_names(from: &Path) -> Vec { - let mut names = database_names(); - names.extend(config_owned_names(from)); - names + let mut names = database_names(); + names.extend(config_owned_names(from)); + names } fn database_names() -> Vec { - let Ok(db) = paths::database_file() else { - return Vec::new(); - }; - let Some(name) = db.file_name().and_then(|name| name.to_str()) else { - return Vec::new(); - }; + let Ok(db) = paths::database_file() else { + return Vec::new(); + }; + let Some(name) = db.file_name().and_then(|name| name.to_str()) else { + return Vec::new(); + }; - ["", "-wal", "-shm", "-journal"] - .iter() - .map(|suffix| OsString::from(format!("{name}{suffix}"))) - .collect() + ["", "-wal", "-shm", "-journal"] + .iter() + .map(|suffix| OsString::from(format!("{name}{suffix}"))) + .collect() } fn config_owned_names(dir: &Path) -> Vec { - if paths::config_dir().is_ok_and(|config| config == dir) { - [ - paths::settings_file(), - paths::damaged_settings_file(), - paths::auth_file(), - paths::logs_dir(), - ] - .into_iter() - .flatten() - .filter_map(|path| path.file_name().map(std::ffi::OsStr::to_os_string)) - .collect() - } else { - Vec::new() - } + if paths::config_dir().is_ok_and(|config| config == dir) { + [ + paths::settings_file(), + paths::damaged_settings_file(), + paths::auth_file(), + paths::logs_dir(), + ] + .into_iter() + .flatten() + .filter_map(|path| path.file_name().map(std::ffi::OsStr::to_os_string)) + .collect() + } else { + Vec::new() + } } async fn drop_materialized_content(state: &LauncherState) { - let mut dirs = Vec::new(); + let mut dirs = Vec::new(); - if let Ok(shared) = paths::shared_minecraft_dir() { - dirs.push((shared.clone(), manifest::MANIFEST_NAME)); - dirs.push((shared, manifest::GLOBAL_MANIFEST_NAME)); - } + if let Ok(shared) = paths::shared_minecraft_dir() { + dirs.push((shared.clone(), manifest::MANIFEST_NAME)); + dirs.push((shared, manifest::GLOBAL_MANIFEST_NAME)); + } - match state.clusters.list().await { - Ok(clusters) => { - for cluster in &clusters { - if let Ok(dir) = cluster.dir() { - dirs.push((dir, manifest::MODS_MANIFEST_NAME)); - } + match state.clusters.list().await { + Ok(clusters) => { + for cluster in &clusters { + if let Ok(dir) = cluster.dir() { + dirs.push((dir, manifest::MODS_MANIFEST_NAME)); + } - if cluster.uses_dedicated_dir() - && let Ok(dir) = cluster.game_dir() - { - dirs.push((dir, manifest::MANIFEST_NAME)); - } - } - } - Err(err) => tracing::warn!(%err, "could not list clusters; only clearing the shared folder"), - } + if cluster.uses_dedicated_dir() + && let Ok(dir) = cluster.game_dir() + { + dirs.push((dir, manifest::MANIFEST_NAME)); + } + } + } + Err(err) => { + tracing::warn!(%err, "could not list clusters; only clearing the shared folder") + } + } - for (dir, manifest_name) in dirs { - let Some(loaded) = manifest::load(&dir, manifest_name).await else { - continue; - }; + for (dir, manifest_name) in dirs { + let Some(loaded) = manifest::load(&dir, manifest_name).await else { + continue; + }; - for entry in &loaded.entries { - let mut path = dir.clone(); - path.extend(entry.path.split('/')); + for entry in &loaded.entries { + let mut path = dir.clone(); + path.extend(entry.path.split('/')); - if let Err(err) = remove_entry(&path).await { - tracing::warn!(path = %path.display(), %err, "could not clear linked package"); - } - } + if let Err(err) = remove_entry(&path).await { + tracing::warn!(path = %path.display(), %err, "could not clear linked package"); + } + } - manifest::clear(&dir, manifest_name).await; - } + manifest::clear(&dir, manifest_name).await; + } - if let Ok(shared) = paths::shared_minecraft_dir() { - crate::game::unlink_cluster_logs(&shared).await; - } + if let Ok(shared) = paths::shared_minecraft_dir() { + crate::game::unlink_cluster_logs(&shared).await; + } } #[derive(Debug, Default)] struct Found { - dirs: Vec, - files: Vec<(PathBuf, u64)>, - links: Vec, - total: u64, + dirs: Vec, + files: Vec<(PathBuf, u64)>, + links: Vec, + total: u64, } async fn collect(root: &Path, skip_top: &[OsString]) -> Result { - let mut found = Found::default(); - let mut stack = vec![(root.to_path_buf(), true)]; + let mut found = Found::default(); + let mut stack = vec![(root.to_path_buf(), true)]; - while let Some((dir, is_top)) = stack.pop() { - let mut entries = match polyio::read_dir(&dir).await { - Ok(entries) => entries, - Err(err) => { - return Err(format!("Couldn't read {}: {err}", dir.display())); - } - }; + while let Some((dir, is_top)) = stack.pop() { + let mut entries = match polyio::read_dir(&dir).await { + Ok(entries) => entries, + Err(err) => { + return Err(format!("Couldn't read {}: {err}", dir.display())); + } + }; - while let Ok(Some(entry)) = entries.next_entry().await { - let path = entry.path(); - let name = entry.file_name(); + while let Ok(Some(entry)) = entries.next_entry().await { + let path = entry.path(); + let name = entry.file_name(); - if is_top && skip_top.contains(&name) { - continue; - } + if is_top && skip_top.contains(&name) { + continue; + } - let Ok(file_type) = entry.file_type().await else { - continue; - }; + let Ok(file_type) = entry.file_type().await else { + continue; + }; - let Ok(relative) = path.strip_prefix(root).map(Path::to_path_buf) else { - continue; - }; + let Ok(relative) = path.strip_prefix(root).map(Path::to_path_buf) else { + continue; + }; - if file_type.is_symlink() { - found.links.push(relative); - continue; - } + if file_type.is_symlink() { + found.links.push(relative); + continue; + } - if file_type.is_dir() { - found.dirs.push(relative); - stack.push((path, false)); - } else if file_type.is_file() { - if let Ok(meta) = entry.metadata().await { - found.total += meta.len(); - found.files.push((relative, meta.len())); - } - } else { - tracing::debug!( - path = %relative.display(), - "leaving a socket or pipe behind" - ); - } - } - } + if file_type.is_dir() { + found.dirs.push(relative); + stack.push((path, false)); + } else if file_type.is_file() { + if let Ok(meta) = entry.metadata().await { + found.total += meta.len(); + found.files.push((relative, meta.len())); + } + } else { + tracing::debug!( + path = %relative.display(), + "leaving a socket or pipe behind" + ); + } + } + } - Ok(found) + Ok(found) } async fn copy_tree( - from: &Path, - to: &Path, - found: &Found, - on_copied: &mut impl FnMut(u64, u64), + from: &Path, + to: &Path, + found: &Found, + on_copied: &mut impl FnMut(u64, u64), ) -> Result { - for relative in &found.dirs { - let dir = to.join(relative); - polyio::create_dir_all(&dir) - .await - .map_err(|err| format!("Couldn't create {}: {err}", dir.display()))?; - } + for relative in &found.dirs { + let dir = to.join(relative); + polyio::create_dir_all(&dir) + .await + .map_err(|err| format!("Couldn't create {}: {err}", dir.display()))?; + } - for link in &found.links { - tracing::warn!(path = %link.display(), "leaving a link behind; it points outside our tree"); - } + for link in &found.links { + tracing::warn!(path = %link.display(), "leaving a link behind; it points outside our tree"); + } - let mut copied = 0u64; - let mut reported = 0u64; + let mut copied = 0u64; + let mut reported = 0u64; - for (relative, size) in &found.files { - let src = from.join(relative); - let dest = to.join(relative); + for (relative, size) in &found.files { + let src = from.join(relative); + let dest = to.join(relative); - if let Some(parent) = dest.parent() { - polyio::create_dir_all(parent).await.ok(); - } + if let Some(parent) = dest.parent() { + polyio::create_dir_all(parent).await.ok(); + } - let written = polyio::copy(&src, &dest) - .await - .map_err(|err| format!("Couldn't copy {}: {err}", relative.display()))?; + let written = polyio::copy(&src, &dest) + .await + .map_err(|err| format!("Couldn't copy {}: {err}", relative.display()))?; - if written != *size { - return Err(format!( - "{} arrived short. Nothing was removed from the old folder.", - relative.display() - )); - } + if written != *size { + return Err(format!( + "{} arrived short. Nothing was removed from the old folder.", + relative.display() + )); + } - copied += written; + copied += written; - if copied - reported >= REPORT_EVERY { - reported = copied; - on_copied(copied, found.total); - } - } + if copied - reported >= REPORT_EVERY { + reported = copied; + on_copied(copied, found.total); + } + } - on_copied(copied, found.total.max(copied)); - Ok(copied) + on_copied(copied, found.total.max(copied)); + Ok(copied) } async fn write_everything( - state: &LauncherState, - plan: &RelocationPlan, - found: &Found, - on_copied: &mut impl FnMut(u64, u64), + state: &LauncherState, + plan: &RelocationPlan, + found: &Found, + on_copied: &mut impl FnMut(u64, u64), ) -> Result { - let copied = copy_tree(&plan.from, &plan.to, found, on_copied).await?; + let copied = copy_tree(&plan.from, &plan.to, found, on_copied).await?; - if copied != found.total { - return Err(format!( - "Only {} of {} arrived at {}.", - format_bytes(copied), - format_bytes(found.total), - plan.to.display() - )); - } + if copied != found.total { + return Err(format!( + "Only {} of {} arrived at {}.", + format_bytes(copied), + format_bytes(found.total), + plan.to.display() + )); + } - let database = snapshot_database(&state.services.db, &plan.to).await?; + let database = snapshot_database(&state.services.db, &plan.to).await?; - let rewritten = rewrite_java_paths(&database, &plan.from, &plan.to).await?; - if rewritten > 0 { - tracing::info!(rows = rewritten, "repointed Java runtimes at the new folder"); - } + let rewritten = rewrite_java_paths(&database, &plan.from, &plan.to).await?; + if rewritten > 0 { + tracing::info!( + rows = rewritten, + "repointed Java runtimes at the new folder" + ); + } - Ok(copied) + Ok(copied) } async fn snapshot_database(pool: &DbPool, to: &Path) -> Result { - let Ok(name) = paths::database_file() else { - return Err("Couldn't work out where the database lives.".to_string()); - }; - let Some(name) = name.file_name() else { - return Err("Couldn't work out what the database is called.".to_string()); - }; + let Ok(name) = paths::database_file() else { + return Err("Couldn't work out where the database lives.".to_string()); + }; + let Some(name) = name.file_name() else { + return Err("Couldn't work out what the database is called.".to_string()); + }; - let target = to.join(name); + let target = to.join(name); - sqlx::query("VACUUM INTO ?") - .bind(target.to_string_lossy().as_ref()) - .execute(pool) - .await - .map_err(|err| format!("Couldn't copy the database to {}: {err}", target.display()))?; + sqlx::query("VACUUM INTO ?") + .bind(target.to_string_lossy().as_ref()) + .execute(pool) + .await + .map_err(|err| format!("Couldn't copy the database to {}: {err}", target.display()))?; - Ok(target) + Ok(target) } async fn rewrite_java_paths(database: &Path, from: &Path, to: &Path) -> Result { - let old = java_root(from); - let new = java_root(to); + let old = java_root(from); + let new = java_root(to); - let options = sqlx::sqlite::SqliteConnectOptions::new() - .filename(database) - .create_if_missing(false) - .foreign_keys(false); + let options = sqlx::sqlite::SqliteConnectOptions::new() + .filename(database) + .create_if_missing(false) + .foreign_keys(false); - let mut conn = ::connect_with(&options) - .await - .map_err(|err| format!("Couldn't open the copied database: {err}"))?; + let mut conn = ::connect_with(&options) + .await + .map_err(|err| format!("Couldn't open the copied database: {err}"))?; - let result = swap_java_prefix(&mut conn, &old, &new).await; + let result = swap_java_prefix(&mut conn, &old, &new).await; - if let Err(err) = sqlx::Connection::close(conn).await { - tracing::warn!(%err, "failed to close the copied database cleanly"); - } + if let Err(err) = sqlx::Connection::close(conn).await { + tracing::warn!(%err, "failed to close the copied database cleanly"); + } - result + result } fn java_root(dir: &Path) -> String { - dir.join("metadata") - .join("java") - .to_string_lossy() - .into_owned() + dir.join("metadata") + .join("java") + .to_string_lossy() + .into_owned() } async fn swap_java_prefix( - conn: &mut sqlx::SqliteConnection, - old: &str, - new: &str, + conn: &mut sqlx::SqliteConnection, + old: &str, + new: &str, ) -> Result { - let len = i64::try_from(old.chars().count()).unwrap_or(i64::MAX); + let len = i64::try_from(old.chars().count()).unwrap_or(i64::MAX); - let mut tx = conn - .begin() - .await - .map_err(|err| format!("Couldn't start a database transaction: {err}"))?; + let mut tx = conn + .begin() + .await + .map_err(|err| format!("Couldn't start a database transaction: {err}"))?; - let profiles = sqlx::query( - "UPDATE setting_profiles + let profiles = sqlx::query( + "UPDATE setting_profiles SET java_path = ? || substr(java_path, ?) WHERE java_path IS NOT NULL AND substr(java_path, 1, ?) = ?", - ) - .bind(new) - .bind(len + 1) - .bind(len) - .bind(old) - .execute(&mut *tx) - .await - .map_err(|err| format!("Couldn't update your Java settings: {err}"))?; + ) + .bind(new) + .bind(len + 1) + .bind(len) + .bind(old) + .execute(&mut *tx) + .await + .map_err(|err| format!("Couldn't update your Java settings: {err}"))?; - let runtimes = sqlx::query( - "UPDATE java_versions + let runtimes = sqlx::query( + "UPDATE java_versions SET absolute_path = ? || substr(absolute_path, ?) WHERE substr(absolute_path, 1, ?) = ?", - ) - .bind(new) - .bind(len + 1) - .bind(len) - .bind(old) - .execute(&mut *tx) - .await - .map_err(|err| format!("Couldn't update your Java runtimes: {err}"))?; + ) + .bind(new) + .bind(len + 1) + .bind(len) + .bind(old) + .execute(&mut *tx) + .await + .map_err(|err| format!("Couldn't update your Java runtimes: {err}"))?; - let orphans: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM setting_profiles p + let orphans: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM setting_profiles p WHERE p.java_path IS NOT NULL AND NOT EXISTS (SELECT 1 FROM java_versions j WHERE j.absolute_path = p.java_path)", - ) - .fetch_one(&mut *tx) - .await - .map_err(|err| format!("Couldn't check your Java settings: {err}"))?; + ) + .fetch_one(&mut *tx) + .await + .map_err(|err| format!("Couldn't check your Java settings: {err}"))?; - if orphans > 0 { - tx.rollback().await.ok(); - return Err(format!( - "{orphans} of your profiles would lose their Java runtime. Nothing was changed." - )); - } + if orphans > 0 { + tx.rollback().await.ok(); + return Err(format!( + "{orphans} of your profiles would lose their Java runtime. Nothing was changed." + )); + } - tx.commit() - .await - .map_err(|err| format!("Couldn't save the database changes: {err}"))?; + tx.commit() + .await + .map_err(|err| format!("Couldn't save the database changes: {err}"))?; - Ok(profiles.rows_affected() + runtimes.rows_affected()) + Ok(profiles.rows_affected() + runtimes.rows_affected()) } async fn commit(state: &LauncherState, from: &Path, to: &Path) -> Result<(), String> { - { - let mut settings = state.settings.write(); - settings.data_dir = Some(to.to_path_buf()); - settings.previous_data_dir = Some(from.to_path_buf()); - } + { + let mut settings = state.settings.write(); + settings.data_dir = Some(to.to_path_buf()); + settings.previous_data_dir = Some(from.to_path_buf()); + } - let snapshot = state.settings.read().clone(); + let snapshot = state.settings.read().clone(); - save_settings(&snapshot).await.map_err(|err| { - format!("Everything was copied but your settings could not be saved: {err}") - }) + save_settings(&snapshot) + .await + .map_err(|err| format!("Everything was copied but your settings could not be saved: {err}")) } async fn abandon(to: &Path) { - if !incomplete(to) { - tracing::error!( - path = %to.display(), - "leaving the destination alone; it is not carrying our marker" - ); - return; - } + if !incomplete(to) { + tracing::error!( + path = %to.display(), + "leaving the destination alone; it is not carrying our marker" + ); + return; + } - discard_unclaimed(to).await; + discard_unclaimed(to).await; } async fn discard_unclaimed(to: &Path) { - match polyio::remove_dir_all(to).await { - Ok(()) => tracing::info!(path = %to.display(), "cleared the abandoned folder"), - Err(err) => tracing::error!( - path = %to.display(), - %err, - "could not clear the abandoned folder; delete it before trying again" - ), - } + match polyio::remove_dir_all(to).await { + Ok(()) => tracing::info!(path = %to.display(), "cleared the abandoned folder"), + Err(err) => tracing::error!( + path = %to.display(), + %err, + "could not clear the abandoned folder; delete it before trying again" + ), + } } async fn remove_children_except(dir: &Path, keep: &[OsString]) -> Result<(), String> { - let mut entries = polyio::read_dir(dir) - .await - .map_err(|err| format!("Couldn't read {}: {err}", dir.display()))?; + let mut entries = polyio::read_dir(dir) + .await + .map_err(|err| format!("Couldn't read {}: {err}", dir.display()))?; - while let Ok(Some(entry)) = entries.next_entry().await { - let name = entry.file_name(); - if keep.contains(&name) { - continue; - } + while let Ok(Some(entry)) = entries.next_entry().await { + let name = entry.file_name(); + if keep.contains(&name) { + continue; + } - let path = entry.path(); - let Ok(file_type) = entry.file_type().await else { - continue; - }; + let path = entry.path(); + let Ok(file_type) = entry.file_type().await else { + continue; + }; - let removed = if file_type.is_dir() { - polyio::remove_dir_all(&path).await - } else { - polyio::remove_file(&path).await - }; + let removed = if file_type.is_dir() { + polyio::remove_dir_all(&path).await + } else { + polyio::remove_file(&path).await + }; - if let Err(err) = removed { - tracing::warn!(path = %path.display(), %err, "could not remove leftover"); - } - } + if let Err(err) = removed { + tracing::warn!(path = %path.display(), %err, "could not remove leftover"); + } + } - Ok(()) + Ok(()) } #[cfg(test)] mod tests { - use super::*; + use super::*; - #[test] - fn a_managed_runtime_sits_under_the_prefix_the_swap_looks_for() { - let root = PathBuf::from("D:").join("OneClient"); - let prefix = java_root(&root); + #[test] + fn a_managed_runtime_sits_under_the_prefix_the_swap_looks_for() { + let root = PathBuf::from("D:").join("OneClient"); + let prefix = java_root(&root); - let runtime = paths::java_dir() - .map(|dir| dir.join("21").join("bin").join("java")) - .expect("java dir resolves"); + let runtime = paths::java_dir() + .map(|dir| dir.join("21").join("bin").join("java")) + .expect("java dir resolves"); - let managed = root.join("metadata").join("java").join("21"); - assert!(managed.to_string_lossy().starts_with(&prefix)); - assert!(runtime.to_string_lossy().contains("java")); - } + let managed = root.join("metadata").join("java").join("21"); + assert!(managed.to_string_lossy().starts_with(&prefix)); + assert!(runtime.to_string_lossy().contains("java")); + } - #[test] - fn the_write_ahead_sidecars_are_never_copied_by_hand() { - let names = database_names(); + #[test] + fn the_write_ahead_sidecars_are_never_copied_by_hand() { + let names = database_names(); - assert!( - names.iter().any(|name| name == "user_data.db-wal"), - "a copy that took the database but not its log would arrive stale" - ); - assert!(names.iter().any(|name| name == "user_data.db-shm")); - } + assert!( + names.iter().any(|name| name == "user_data.db-wal"), + "a copy that took the database but not its log would arrive stale" + ); + assert!(names.iter().any(|name| name == "user_data.db-shm")); + } - #[test] - fn a_folder_that_is_not_the_config_folder_gives_nothing_up() { - let names = config_owned_names(Path::new("D:").join("OneClient").as_path()); + #[test] + fn a_folder_that_is_not_the_config_folder_gives_nothing_up() { + let names = config_owned_names(Path::new("D:").join("OneClient").as_path()); - assert!( - names.is_empty(), - "settings only stay behind when the source folder is the one holding them" - ); - } + assert!( + names.is_empty(), + "settings only stay behind when the source folder is the one holding them" + ); + } - #[tokio::test] - async fn the_walk_skips_what_it_is_told_to_and_counts_the_rest() { - let root = polyio::testing::ScratchDir::new("relocate_collect"); - let dir = root.path(); - polyio::create_dir_all(dir.join("metadata")).await.unwrap(); + #[tokio::test] + async fn the_walk_skips_what_it_is_told_to_and_counts_the_rest() { + let root = polyio::testing::ScratchDir::new("relocate_collect"); + let dir = root.path(); + polyio::create_dir_all(dir.join("metadata")).await.unwrap(); - polyio::write(dir.join("user_data.db"), b"skipped".as_slice()) - .await - .unwrap(); - polyio::write(dir.join("metadata").join("kept.bin"), b"1234".as_slice()) - .await - .unwrap(); + polyio::write(dir.join("user_data.db"), b"skipped".as_slice()) + .await + .unwrap(); + polyio::write(dir.join("metadata").join("kept.bin"), b"1234".as_slice()) + .await + .unwrap(); - let found = collect(dir, &[OsString::from("user_data.db")]).await.unwrap(); + let found = collect(dir, &[OsString::from("user_data.db")]) + .await + .unwrap(); - assert_eq!(found.total, 4, "only the file we did not skip counts"); - assert_eq!(found.files.len(), 1); - assert_eq!(found.dirs.len(), 1, "the folder itself still has to be made"); + assert_eq!(found.total, 4, "only the file we did not skip counts"); + assert_eq!(found.files.len(), 1); + assert_eq!( + found.dirs.len(), + 1, + "the folder itself still has to be made" + ); - std::fs::remove_dir_all(dir).ok(); - } + std::fs::remove_dir_all(dir).ok(); + } - #[cfg(unix)] - #[tokio::test] - async fn a_socket_is_walked_past_rather_than_queued_for_copying() { - let root = polyio::testing::ScratchDir::new("relocate_socket"); - let dir = root.path(); + #[cfg(unix)] + #[tokio::test] + async fn a_socket_is_walked_past_rather_than_queued_for_copying() { + let root = polyio::testing::ScratchDir::new("relocate_socket"); + let dir = root.path(); - let socket = std::os::unix::net::UnixListener::bind(dir.join("ipc.sock")).unwrap(); - polyio::write(dir.join("kept.bin"), vec![0u8; 100]) - .await - .unwrap(); + let socket = std::os::unix::net::UnixListener::bind(dir.join("ipc.sock")).unwrap(); + polyio::write(dir.join("kept.bin"), vec![0u8; 100]) + .await + .unwrap(); - let found = collect(dir, &[]).await.unwrap(); + let found = collect(dir, &[]).await.unwrap(); - assert_eq!( - found.files.len(), - 1, - "a socket cannot be copied, so it must never reach the copy list" - ); - assert_eq!(found.total, 100, "nor count towards what has to fit"); + assert_eq!( + found.files.len(), + 1, + "a socket cannot be copied, so it must never reach the copy list" + ); + assert_eq!(found.total, 100, "nor count towards what has to fit"); - drop(socket); - std::fs::remove_dir_all(dir).ok(); - } + drop(socket); + std::fs::remove_dir_all(dir).ok(); + } - #[tokio::test] - async fn a_folder_kept_out_of_the_copy_is_kept_out_of_the_total_too() { - let root = polyio::testing::ScratchDir::new("relocate_skip_dir"); - let dir = root.path(); - polyio::create_dir_all(dir.join("logs")).await.unwrap(); + #[tokio::test] + async fn a_folder_kept_out_of_the_copy_is_kept_out_of_the_total_too() { + let root = polyio::testing::ScratchDir::new("relocate_skip_dir"); + let dir = root.path(); + polyio::create_dir_all(dir.join("logs")).await.unwrap(); - polyio::write(dir.join("logs").join("latest.log"), vec![0u8; 900]) - .await - .unwrap(); - polyio::write(dir.join("kept.bin"), vec![0u8; 100]) - .await - .unwrap(); + polyio::write(dir.join("logs").join("latest.log"), vec![0u8; 900]) + .await + .unwrap(); + polyio::write(dir.join("kept.bin"), vec![0u8; 100]) + .await + .unwrap(); - let found = collect(dir, &[OsString::from("logs")]).await.unwrap(); + let found = collect(dir, &[OsString::from("logs")]).await.unwrap(); - assert_eq!(found.total, 100, "the skipped folder is not counted"); - assert!( - found.dirs.is_empty(), - "nor is it recreated at the destination" - ); + assert_eq!(found.total, 100, "the skipped folder is not counted"); + assert!( + found.dirs.is_empty(), + "nor is it recreated at the destination" + ); - std::fs::remove_dir_all(dir).ok(); - } + std::fs::remove_dir_all(dir).ok(); + } } diff --git a/packages/oneclient_core/src/reporting.rs b/packages/oneclient_core/src/reporting.rs index 01a15bf6..d20ebbc5 100644 --- a/packages/oneclient_core/src/reporting.rs +++ b/packages/oneclient_core/src/reporting.rs @@ -91,4 +91,4 @@ fn install_panic_flush() { client.flush(Some(PANIC_FLUSH_TIMEOUT)); } })); -} \ No newline at end of file +} diff --git a/packages/oneclient_core/src/settings/data_dir.rs b/packages/oneclient_core/src/settings/data_dir.rs index 6e4842ed..97a8e5a3 100644 --- a/packages/oneclient_core/src/settings/data_dir.rs +++ b/packages/oneclient_core/src/settings/data_dir.rs @@ -12,258 +12,260 @@ const LOW_SPACE_BYTES: u64 = 5 * 1000 * 1000 * 1000; const PROBE_NAME: &str = ".oneclient_write_test"; const OS_CLUTTER: &[&str] = &[ - ".DS_Store", - ".localized", - ".Spotlight-V100", - ".Trashes", - ".fseventsd", - "desktop.ini", - "Thumbs.db", - "$RECYCLE.BIN", - "System Volume Information", + ".DS_Store", + ".localized", + ".Spotlight-V100", + ".Trashes", + ".fseventsd", + "desktop.ini", + "Thumbs.db", + "$RECYCLE.BIN", + "System Volume Information", ]; #[derive(Debug, Clone, PartialEq, Eq)] pub struct DataDirCheck { - pub path: PathBuf, - pub warning: Option, + pub path: PathBuf, + pub warning: Option, } pub async fn looks_empty(dir: &Path, ignored: &[OsString]) -> bool { - let Ok(mut entries) = polyio::read_dir(dir).await else { - return true; - }; + let Ok(mut entries) = polyio::read_dir(dir).await else { + return true; + }; - while let Ok(Some(entry)) = entries.next_entry().await { - let name = entry.file_name(); + while let Ok(Some(entry)) = entries.next_entry().await { + let name = entry.file_name(); - if ignored.contains(&name) { - continue; - } + if ignored.contains(&name) { + continue; + } - let Some(name) = name.to_str() else { - return false; - }; + let Some(name) = name.to_str() else { + return false; + }; - if !OS_CLUTTER.iter().any(|junk| junk.eq_ignore_ascii_case(name)) { - return false; - } - } + if !OS_CLUTTER + .iter() + .any(|junk| junk.eq_ignore_ascii_case(name)) + { + return false; + } + } - true + true } pub async fn resolve(picked: &Path) -> PathBuf { - if picked.file_name().is_some_and(|name| name == FOLDER_NAME) - || looks_empty(picked, &[]).await - { - picked.to_path_buf() - } else { - picked.join(FOLDER_NAME) - } + if picked.file_name().is_some_and(|name| name == FOLDER_NAME) || looks_empty(picked, &[]).await + { + picked.to_path_buf() + } else { + picked.join(FOLDER_NAME) + } } pub fn default_path() -> Result { - paths::config_dir() - .map(Path::to_path_buf) - .map_err(|err| format!("Couldn't work out where OneClient keeps its settings: {err}")) + paths::config_dir() + .map(Path::to_path_buf) + .map_err(|err| format!("Couldn't work out where OneClient keeps its settings: {err}")) } #[must_use] pub fn is_default(dir: &Path) -> bool { - default_path().is_ok_and(|home| home == dir) + default_path().is_ok_and(|home| home == dir) } pub async fn check(picked: &Path) -> Result { - check_exact(&resolve(picked).await).await + check_exact(&resolve(picked).await).await } pub async fn check_exact(path: &Path) -> Result { - let path = path.to_path_buf(); + let path = path.to_path_buf(); - let existed = polyio::try_exists(&path).await.unwrap_or(false); - polyio::create_dir_all(&path) - .await - .map_err(|err| format!("Couldn't create {}: {err}", path.display()))?; + let existed = polyio::try_exists(&path).await.unwrap_or(false); + polyio::create_dir_all(&path) + .await + .map_err(|err| format!("Couldn't create {}: {err}", path.display()))?; - let probe = path.join(PROBE_NAME); - let writable = polyio::write(&probe, b"".as_slice()).await; - polyio::remove_file(&probe).await.ok(); + let probe = path.join(PROBE_NAME); + let writable = polyio::write(&probe, b"".as_slice()).await; + polyio::remove_file(&probe).await.ok(); - if let Err(err) = writable { - if !existed { - polyio::remove_dir(&path).await.ok(); - } - return Err(format!("Can't write to {}: {err}", path.display())); - } + if let Err(err) = writable { + if !existed { + polyio::remove_dir(&path).await.ok(); + } + return Err(format!("Can't write to {}: {err}", path.display())); + } - if !existed { - polyio::remove_dir(&path).await.ok(); - } + if !existed { + polyio::remove_dir(&path).await.ok(); + } - Ok(DataDirCheck { - warning: low_space_warning(&path), - path, - }) + Ok(DataDirCheck { + warning: low_space_warning(&path), + path, + }) } pub async fn apply(picked: Option) -> Result<(), String> { - let mut settings = LauncherSettings::default(); - let mut chosen = None; + let mut settings = LauncherSettings::default(); + let mut chosen = None; - if let Some(picked) = picked { - let checked = check(&picked).await?; + if let Some(picked) = picked { + let checked = check(&picked).await?; - polyio::create_dir_all(&checked.path) - .await - .map_err(|err| format!("Couldn't create {}: {err}", checked.path.display()))?; + polyio::create_dir_all(&checked.path) + .await + .map_err(|err| format!("Couldn't create {}: {err}", checked.path.display()))?; - settings.data_dir = Some(checked.path.clone()); - chosen = Some(checked.path); - } + settings.data_dir = Some(checked.path.clone()); + chosen = Some(checked.path); + } - save_settings(&settings) - .await - .map_err(|err| format!("Couldn't save your settings: {err}"))?; + save_settings(&settings) + .await + .map_err(|err| format!("Couldn't save your settings: {err}"))?; - if let Some(chosen) = chosen { - paths::set_data_dir(chosen); - } + if let Some(chosen) = chosen { + paths::set_data_dir(chosen); + } - Ok(()) + Ok(()) } fn low_space_warning(path: &Path) -> Option { - let available = available_space(path)?; - if available >= LOW_SPACE_BYTES { - return None; - } + let available = available_space(path)?; + if available >= LOW_SPACE_BYTES { + return None; + } - Some(format!( - "Only {} free here. Minecraft, its libraries and a Java runtime need a few GB before you \ + Some(format!( + "Only {} free here. Minecraft, its libraries and a Java runtime need a few GB before you \ install anything.", - crate::storage::format_bytes(available) - )) + crate::storage::format_bytes(available) + )) } pub(crate) fn available_space(path: &Path) -> Option { - let target = polyio::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); + let target = polyio::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); - sysinfo::Disks::new_with_refreshed_list() - .iter() - .filter(|disk| target.starts_with(disk.mount_point())) - .max_by_key(|disk| disk.mount_point().as_os_str().len()) - .map(sysinfo::Disk::available_space) + sysinfo::Disks::new_with_refreshed_list() + .iter() + .filter(|disk| target.starts_with(disk.mount_point())) + .max_by_key(|disk| disk.mount_point().as_os_str().len()) + .map(sysinfo::Disk::available_space) } #[cfg(test)] mod tests { - use super::*; + use super::*; - #[tokio::test] - async fn a_folder_with_things_in_it_gets_a_folder_of_its_own() { - let root = polyio::testing::ScratchDir::new("data_dir_busy"); - let dir = root.path(); - polyio::create_dir_all(dir).await.unwrap(); - polyio::write(dir.join("holiday.jpg"), b"mine".as_slice()) - .await - .unwrap(); + #[tokio::test] + async fn a_folder_with_things_in_it_gets_a_folder_of_its_own() { + let root = polyio::testing::ScratchDir::new("data_dir_busy"); + let dir = root.path(); + polyio::create_dir_all(dir).await.unwrap(); + polyio::write(dir.join("holiday.jpg"), b"mine".as_slice()) + .await + .unwrap(); - assert_eq!(resolve(dir).await, dir.join(FOLDER_NAME)); + assert_eq!(resolve(dir).await, dir.join(FOLDER_NAME)); - std::fs::remove_dir_all(dir).ok(); - } + std::fs::remove_dir_all(dir).ok(); + } - #[tokio::test] - async fn an_empty_folder_is_used_as_it_stands() { - let root = polyio::testing::ScratchDir::new("data_dir_empty"); - let dir = root.path(); - polyio::create_dir_all(dir).await.unwrap(); + #[tokio::test] + async fn an_empty_folder_is_used_as_it_stands() { + let root = polyio::testing::ScratchDir::new("data_dir_empty"); + let dir = root.path(); + polyio::create_dir_all(dir).await.unwrap(); - assert_eq!( - resolve(dir).await, - dir.to_path_buf(), - "a folder made for this should not get another one inside it" - ); + assert_eq!( + resolve(dir).await, + dir.to_path_buf(), + "a folder made for this should not get another one inside it" + ); - std::fs::remove_dir_all(dir).ok(); - } + std::fs::remove_dir_all(dir).ok(); + } - #[tokio::test] - async fn a_folder_the_finder_has_been_in_still_counts_as_empty() { - let root = polyio::testing::ScratchDir::new("data_dir_clutter"); - let dir = root.path(); - polyio::create_dir_all(dir).await.unwrap(); - polyio::write(dir.join(".DS_Store"), b"junk".as_slice()) - .await - .unwrap(); + #[tokio::test] + async fn a_folder_the_finder_has_been_in_still_counts_as_empty() { + let root = polyio::testing::ScratchDir::new("data_dir_clutter"); + let dir = root.path(); + polyio::create_dir_all(dir).await.unwrap(); + polyio::write(dir.join(".DS_Store"), b"junk".as_slice()) + .await + .unwrap(); - assert_eq!( - resolve(dir).await, - dir.to_path_buf(), - "the folder looks empty to the person who picked it" - ); + assert_eq!( + resolve(dir).await, + dir.to_path_buf(), + "the folder looks empty to the person who picked it" + ); - std::fs::remove_dir_all(dir).ok(); - } + std::fs::remove_dir_all(dir).ok(); + } - #[tokio::test] - async fn resolving_twice_does_not_nest() { - let root = polyio::testing::ScratchDir::new("data_dir_nest"); - let dir = root.path(); - polyio::create_dir_all(dir).await.unwrap(); - polyio::write(dir.join("holiday.jpg"), b"mine".as_slice()) - .await - .unwrap(); + #[tokio::test] + async fn resolving_twice_does_not_nest() { + let root = polyio::testing::ScratchDir::new("data_dir_nest"); + let dir = root.path(); + polyio::create_dir_all(dir).await.unwrap(); + polyio::write(dir.join("holiday.jpg"), b"mine".as_slice()) + .await + .unwrap(); - let once = resolve(dir).await; - assert_eq!(resolve(&once).await, once); + let once = resolve(dir).await; + assert_eq!(resolve(&once).await, once); - std::fs::remove_dir_all(dir).ok(); - } + std::fs::remove_dir_all(dir).ok(); + } - #[tokio::test] - async fn the_settings_that_stayed_behind_do_not_make_the_default_folder_look_busy() { - let root = polyio::testing::ScratchDir::new("data_dir_settled"); - let dir = root.path(); - polyio::create_dir_all(dir).await.unwrap(); - polyio::write(dir.join("settings.json"), b"{}".as_slice()) - .await - .unwrap(); + #[tokio::test] + async fn the_settings_that_stayed_behind_do_not_make_the_default_folder_look_busy() { + let root = polyio::testing::ScratchDir::new("data_dir_settled"); + let dir = root.path(); + polyio::create_dir_all(dir).await.unwrap(); + polyio::write(dir.join("settings.json"), b"{}".as_slice()) + .await + .unwrap(); - let settled = [OsString::from("settings.json")]; + let settled = [OsString::from("settings.json")]; - assert!( - looks_empty(dir, &settled).await, - "moving back in has to be allowed over the settings that never left" - ); + assert!( + looks_empty(dir, &settled).await, + "moving back in has to be allowed over the settings that never left" + ); - polyio::create_dir_all(dir.join("clusters")).await.unwrap(); + polyio::create_dir_all(dir.join("clusters")).await.unwrap(); - assert!( - !looks_empty(dir, &settled).await, - "game data already sitting there is another matter" - ); + assert!( + !looks_empty(dir, &settled).await, + "game data already sitting there is another matter" + ); - std::fs::remove_dir_all(dir).ok(); - } + std::fs::remove_dir_all(dir).ok(); + } - #[tokio::test] - async fn a_writable_folder_passes_and_is_not_left_behind() { - let root = polyio::testing::ScratchDir::new("data_dir_check"); - polyio::create_dir_all(root.path()).await.unwrap(); + #[tokio::test] + async fn a_writable_folder_passes_and_is_not_left_behind() { + let root = polyio::testing::ScratchDir::new("data_dir_check"); + polyio::create_dir_all(root.path()).await.unwrap(); - polyio::write(root.join("holiday.jpg"), b"mine".as_slice()) - .await - .unwrap(); + polyio::write(root.join("holiday.jpg"), b"mine".as_slice()) + .await + .unwrap(); - let checked = check(root.path()).await.unwrap(); - assert_eq!(checked.path, root.join(FOLDER_NAME)); - assert!( - !checked.path.exists(), - "a folder the user has not confirmed yet is litter" - ); + let checked = check(root.path()).await.unwrap(); + assert_eq!(checked.path, root.join(FOLDER_NAME)); + assert!( + !checked.path.exists(), + "a folder the user has not confirmed yet is litter" + ); - std::fs::remove_dir_all(root.path()).ok(); - } + std::fs::remove_dir_all(root.path()).ok(); + } } diff --git a/packages/oneclient_core/src/settings/launcher.rs b/packages/oneclient_core/src/settings/launcher.rs index 81db7eac..0bc4ae69 100644 --- a/packages/oneclient_core/src/settings/launcher.rs +++ b/packages/oneclient_core/src/settings/launcher.rs @@ -8,91 +8,91 @@ use oneclient_cluster::GameSettingsProfile; #[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default)] #[serde(rename_all = "snake_case")] pub enum ViewLayout { - #[default] - Grid, - List, + #[default] + Grid, + List, } #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Default)] #[serde(default)] pub struct ViewState { - pub layout: ViewLayout, - #[serde(skip_serializing_if = "Option::is_none")] - pub sort: Option, + pub layout: ViewLayout, + #[serde(skip_serializing_if = "Option::is_none")] + pub sort: Option, } #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(default)] pub struct LauncherSettings { - pub settings_version: u32, - pub log_debug: bool, - pub auto_update: bool, - pub crash_reporting: bool, - pub enable_gamemode: bool, - pub discord_enabled: bool, - pub max_concurrent_requests: usize, - pub global_game_settings: GameSettingsProfile, - pub allow_parallel_running_clusters: bool, - pub dynamic_background_enabled: bool, - pub start_maximized: bool, - pub animations_enabled: bool, - pub view_states: BTreeMap, - pub seen_onboarding: bool, - pub skip_microsoft_java: bool, - pub microsoft_java_migrated: bool, - pub accepted_tos_version: u32, - pub accepted_privacy_version: u32, - pub declined_tos: bool, - pub seen_versions: Vec, - pub seen_changelog_version: Option, - pub modrinth_api_key: Option, - pub curseforge_api_key: Option, - pub custom_api_endpoint: Option, - pub custom_meta_url_base: Option, - pub data_dir: Option, - pub previous_data_dir: Option, + pub settings_version: u32, + pub log_debug: bool, + pub auto_update: bool, + pub crash_reporting: bool, + pub enable_gamemode: bool, + pub discord_enabled: bool, + pub max_concurrent_requests: usize, + pub global_game_settings: GameSettingsProfile, + pub allow_parallel_running_clusters: bool, + pub dynamic_background_enabled: bool, + pub start_maximized: bool, + pub animations_enabled: bool, + pub view_states: BTreeMap, + pub seen_onboarding: bool, + pub skip_microsoft_java: bool, + pub microsoft_java_migrated: bool, + pub accepted_tos_version: u32, + pub accepted_privacy_version: u32, + pub declined_tos: bool, + pub seen_versions: Vec, + pub seen_changelog_version: Option, + pub modrinth_api_key: Option, + pub curseforge_api_key: Option, + pub custom_api_endpoint: Option, + pub custom_meta_url_base: Option, + pub data_dir: Option, + pub previous_data_dir: Option, } impl LauncherSettings { - pub fn view_state(&self, key: &str) -> ViewState { - self.view_states.get(key).cloned().unwrap_or_default() - } + pub fn view_state(&self, key: &str) -> ViewState { + self.view_states.get(key).cloned().unwrap_or_default() + } - pub fn set_view_state(&mut self, key: impl Into, state: ViewState) { - self.view_states.insert(key.into(), state); - } + pub fn set_view_state(&mut self, key: impl Into, state: ViewState) { + self.view_states.insert(key.into(), state); + } } impl Default for LauncherSettings { - fn default() -> Self { - Self { - settings_version: 1, - log_debug: false, - auto_update: true, - crash_reporting: true, - discord_enabled: true, - enable_gamemode: false, - max_concurrent_requests: 25, - global_game_settings: GameSettingsProfile::default_global_profile(), - allow_parallel_running_clusters: false, - dynamic_background_enabled: true, - start_maximized: false, - animations_enabled: true, - view_states: BTreeMap::new(), - seen_onboarding: false, - skip_microsoft_java: false, - microsoft_java_migrated: false, - accepted_tos_version: 0, - accepted_privacy_version: 0, - declined_tos: false, - seen_versions: Vec::new(), - seen_changelog_version: None, - modrinth_api_key: None, - curseforge_api_key: None, - custom_api_endpoint: None, - custom_meta_url_base: None, - data_dir: None, - previous_data_dir: None, - } - } + fn default() -> Self { + Self { + settings_version: 1, + log_debug: false, + auto_update: true, + crash_reporting: true, + discord_enabled: true, + enable_gamemode: false, + max_concurrent_requests: 25, + global_game_settings: GameSettingsProfile::default_global_profile(), + allow_parallel_running_clusters: false, + dynamic_background_enabled: true, + start_maximized: false, + animations_enabled: true, + view_states: BTreeMap::new(), + seen_onboarding: false, + skip_microsoft_java: false, + microsoft_java_migrated: false, + accepted_tos_version: 0, + accepted_privacy_version: 0, + declined_tos: false, + seen_versions: Vec::new(), + seen_changelog_version: None, + modrinth_api_key: None, + curseforge_api_key: None, + custom_api_endpoint: None, + custom_meta_url_base: None, + data_dir: None, + previous_data_dir: None, + } + } } diff --git a/packages/oneclient_core/src/settings/mod.rs b/packages/oneclient_core/src/settings/mod.rs index 2c54d3af..d4ac67d5 100644 --- a/packages/oneclient_core/src/settings/mod.rs +++ b/packages/oneclient_core/src/settings/mod.rs @@ -4,7 +4,7 @@ pub mod data_dir; pub use launcher::{LauncherSettings, ViewLayout, ViewState}; pub use oneclient_cluster::{ - GameSettingsProfile, PackageUpdateMode, ProfileUpdate, SettingsOsExtra, + GameSettingsProfile, PackageUpdateMode, ProfileUpdate, SettingsOsExtra, }; pub use oneclient_common::Resolution; @@ -14,11 +14,10 @@ pub mod store; /// client never reaches back into global settings (and thus into its own owner) #[must_use] pub fn net_config(settings: &LauncherSettings) -> oneclient_net::NetConfig { - oneclient_net::NetConfig::default().with_overrides( - settings.curseforge_api_key.as_deref(), - settings.modrinth_api_key.as_deref(), - settings.custom_api_endpoint.as_deref(), - settings.custom_meta_url_base.as_deref(), - ) + oneclient_net::NetConfig::default().with_overrides( + settings.curseforge_api_key.as_deref(), + settings.modrinth_api_key.as_deref(), + settings.custom_api_endpoint.as_deref(), + settings.custom_meta_url_base.as_deref(), + ) } - diff --git a/packages/oneclient_core/src/settings/store.rs b/packages/oneclient_core/src/settings/store.rs index e7e28a88..9d642b62 100644 --- a/packages/oneclient_core/src/settings/store.rs +++ b/packages/oneclient_core/src/settings/store.rs @@ -1,8 +1,8 @@ use parking_lot::RwLock; -use oneclient_events::EventBus; -use oneclient_common::paths; use crate::{LauncherError, LauncherResult}; +use oneclient_common::paths; +use oneclient_events::EventBus; use super::launcher::LauncherSettings; use oneclient_cluster::GameSettingsProfile; @@ -27,7 +27,11 @@ pub async fn load_settings(notify: Option<&EventBus>) -> LauncherSettings { tracing::warn!("failed to read settings file: {err}"); if let Some(notify) = notify { - notify.notify("Settings").body("Failed to load settings").error().send(); + notify + .notify("Settings") + .body("Failed to load settings") + .error() + .send(); } recover_after_failed_load().await diff --git a/packages/oneclient_core/src/simulate.rs b/packages/oneclient_core/src/simulate.rs index 63c3f6d2..1c241ace 100644 --- a/packages/oneclient_core/src/simulate.rs +++ b/packages/oneclient_core/src/simulate.rs @@ -1,12 +1,12 @@ use std::path::{Path, PathBuf}; +use oneclient_common::paths; use oneclient_content::packages::PackageStore; use oneclient_content::packages::store::artifact_absolute_path; -use oneclient_common::paths; use oneclient_db::dao::artifact as artifact_dao; -use crate::state::LauncherState; use crate::LauncherResult; +use crate::state::LauncherState; #[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct SimulationReport { @@ -281,10 +281,8 @@ mod tests { use super::*; fn scratch(tag: &str) -> PathBuf { - let dir = std::env::temp_dir().join(format!( - "oneclient-simulate-{tag}-{}", - uuid::Uuid::new_v4() - )); + let dir = + std::env::temp_dir().join(format!("oneclient-simulate-{tag}-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&dir).unwrap(); dir } @@ -351,6 +349,9 @@ mod tests { assert_eq!(report.affected, 4); assert_eq!(report.samples.len(), 3); - assert_eq!(report.summary("Corrupted"), "Corrupted 4 file(s): aaaa and 3 more"); + assert_eq!( + report.summary("Corrupted"), + "Corrupted 4 file(s): aaaa and 3 more" + ); } } diff --git a/packages/oneclient_core/src/state.rs b/packages/oneclient_core/src/state.rs index e0a9c594..12bff6fb 100644 --- a/packages/oneclient_core/src/state.rs +++ b/packages/oneclient_core/src/state.rs @@ -3,179 +3,184 @@ use std::sync::Arc; use oneclient_db::DbPool; use tokio::sync::Mutex; -use oneclient_auth::AuthService; -use crate::clusters::ClusterManager; -use oneclient_content::bundles::BundlesManager; -use oneclient_discord::DiscordRpc; -use oneclient_net::RequestClient; -use crate::images::ImageCacheStore; -use oneclient_java::JavaService; -use oneclient_events::EventBus; -use oneclient_mc::MetadataStore; -use oneclient_content::packages::provider::PackageProviderRegistry; -use oneclient_common::paths; -use crate::settings::{store, LauncherSettings}; -use crate::versions::VersionsManager; use crate::LauncherResult; +use crate::clusters::ClusterManager; +use crate::images::ImageCacheStore; +use crate::settings::{LauncherSettings, store}; +use crate::versions::VersionsManager; +use oneclient_auth::AuthService; +use oneclient_common::paths; +use oneclient_content::bundles::BundlesManager; +use oneclient_content::packages::provider::PackageProviderRegistry; +use oneclient_discord::DiscordRpc; +use oneclient_events::EventBus; +use oneclient_java::JavaService; +use oneclient_mc::MetadataStore; +use oneclient_net::RequestClient; #[derive(Clone)] pub struct LauncherServices { - pub events: EventBus, - pub requester: RequestClient, - pub db: DbPool, - pub packages: PackageProviderRegistry, + pub events: EventBus, + pub requester: RequestClient, + pub db: DbPool, + pub packages: PackageProviderRegistry, } pub struct LauncherState { - pub services: LauncherServices, - pub settings: parking_lot::RwLock, - pub auth: Arc, - pub java: JavaService, - pub clusters: ClusterManager, - pub metadata: Mutex, - pub bundles: Arc, - pub versions: Arc, - pub images: ImageCacheStore, - pub games: crate::game::GameProcessManager, - pub discord: DiscordRpc, + pub services: LauncherServices, + pub settings: parking_lot::RwLock, + pub auth: Arc, + pub java: JavaService, + pub clusters: ClusterManager, + pub metadata: Mutex, + pub bundles: Arc, + pub versions: Arc, + pub images: ImageCacheStore, + pub games: crate::game::GameProcessManager, + pub discord: DiscordRpc, } impl LauncherServices { - #[must_use] - pub fn content(&self) -> oneclient_content::ContentCtx { - oneclient_content::ContentCtx::new( - self.db.clone(), - self.requester.clone(), - self.events.clone(), - self.packages.clone(), - ) - } + #[must_use] + pub fn content(&self) -> oneclient_content::ContentCtx { + oneclient_content::ContentCtx::new( + self.db.clone(), + self.requester.clone(), + self.events.clone(), + self.packages.clone(), + ) + } - /// Both fields are refcounted handles so building this per call is cheap - #[must_use] - pub fn mc(&self) -> oneclient_mc::McCtx { - oneclient_mc::McCtx::new(self.requester.clone(), self.events.clone()) - } + /// Both fields are refcounted handles so building this per call is cheap + #[must_use] + pub fn mc(&self) -> oneclient_mc::McCtx { + oneclient_mc::McCtx::new(self.requester.clone(), self.events.clone()) + } } impl LauncherState { - /// No side effects beyond opening the database and reading settings the - /// background startup work is [`run_startup_tasks`] which the caller runs + /// No side effects beyond opening the database and reading settings the + /// background startup work is [`run_startup_tasks`] which the caller runs #[tracing::instrument(skip(events))] - pub async fn new(events: EventBus) -> LauncherResult> { + pub async fn new(events: EventBus) -> LauncherResult> { let services = LauncherServices { - events, - db: oneclient_db::connect(paths::database_file()?).await?, - // Defaults because loading settings needs the event bus already inside - // `services` Real config is pushed in below before any request - requester: RequestClient::new(oneclient_net::NetConfig::default())?, - packages: PackageProviderRegistry::new(), - }; + events, + db: oneclient_db::connect(paths::database_file()?).await?, + // Defaults because loading settings needs the event bus already inside + // `services` Real config is pushed in below before any request + requester: RequestClient::new(oneclient_net::NetConfig::default())?, + packages: PackageProviderRegistry::new(), + }; let settings = store::load_settings(Some(&services.events)).await; - oneclient_common::consent::init(settings.declined_tos); - services - .requester - .set_config(crate::settings::net_config(&settings)); - let auth = Arc::new( - AuthService::load(services.requester.clone(), services.events.clone()).await?, - ); + oneclient_common::consent::init(settings.declined_tos); + services + .requester + .set_config(crate::settings::net_config(&settings)); + let auth = + Arc::new(AuthService::load(services.requester.clone(), services.events.clone()).await?); let java = JavaService::new( - std::sync::Arc::new(crate::java_store::SqlJavaStore::new(services.db.clone())), - services.requester.clone(), - services.events.clone(), - ); + std::sync::Arc::new(crate::java_store::SqlJavaStore::new(services.db.clone())), + services.requester.clone(), + services.events.clone(), + ); let discord = DiscordRpc::spawn(settings.discord_enabled); - let clusters = ClusterManager::new(services.db.clone()); + let clusters = ClusterManager::new(services.db.clone()); - let state = Arc::new(Self { - services, - settings: parking_lot::RwLock::new(settings), - auth, - java, - clusters, - metadata: Mutex::new(MetadataStore::new()), - bundles: Arc::new(BundlesManager::new()), - versions: Arc::new(VersionsManager::from_cache().await), - images: ImageCacheStore::new(), - games: crate::game::GameProcessManager::new(), - discord, - }); + let state = Arc::new(Self { + services, + settings: parking_lot::RwLock::new(settings), + auth, + java, + clusters, + metadata: Mutex::new(MetadataStore::new()), + bundles: Arc::new(BundlesManager::new()), + versions: Arc::new(VersionsManager::from_cache().await), + images: ImageCacheStore::new(), + games: crate::game::GameProcessManager::new(), + discord, + }); - Ok(state) - } + Ok(state) + } } /// Java archives are the only downloads big enough for a leaked scratch file to /// matter and they land flat in the java dir so this needs no recursion async fn sweep_java_scratch_files() { - let dir = match paths::java_dir() { - Ok(dir) => dir, - Err(err) => { - tracing::warn!("could not resolve the java dir to sweep: {err:#}"); - return; - } - }; + let dir = match paths::java_dir() { + Ok(dir) => dir, + Err(err) => { + tracing::warn!("could not resolve the java dir to sweep: {err:#}"); + return; + } + }; - if let Err(err) = polyio::sweep_temp_files(&dir).await { - tracing::warn!("java scratch file sweep failed: {err:#}"); - } + if let Err(err) = polyio::sweep_temp_files(&dir).await { + tracing::warn!("java scratch file sweep failed: {err:#}"); + } } pub fn run_startup_tasks(state: &Arc) { - let background = Arc::clone(state); - tokio::spawn(async move { - sweep_java_scratch_files().await; + let background = Arc::clone(state); + tokio::spawn(async move { + sweep_java_scratch_files().await; - let recovery = match crate::recovery::reconstruct_from_disk(&background).await { - Ok(report) => report, - Err(err) => { - tracing::error!("disk recovery failed: {err:#}"); - crate::recovery::RecoveryReport::default() - } - }; + let recovery = match crate::recovery::reconstruct_from_disk(&background).await { + Ok(report) => report, + Err(err) => { + tracing::error!("disk recovery failed: {err:#}"); + crate::recovery::RecoveryReport::default() + } + }; - crate::game::recover_sessions(&background).await; + crate::game::recover_sessions(&background).await; - let content = background.services.content(); - let (versions_res, bundles_res) = tokio::join!( - background.versions.sync(&background.services), - background.bundles.sync(&content), - ); - if let Err(err) = versions_res { - tracing::error!("versions manifest sync failed: {err:#}"); - } - if let Err(err) = bundles_res { - tracing::error!("bundle catalog sync failed: {err:#}"); - } + let content = background.services.content(); + let (versions_res, bundles_res) = tokio::join!( + background.versions.sync(&background.services), + background.bundles.sync(&content), + ); + if let Err(err) = versions_res { + tracing::error!("versions manifest sync failed: {err:#}"); + } + if let Err(err) = bundles_res { + tracing::error!("bundle catalog sync failed: {err:#}"); + } - if recovery.did_recover() - && let Err(err) = crate::recovery::restore_bundle_tracking(&background).await - { - tracing::warn!("bundle tracking restore failed: {err:#}"); - } + if recovery.did_recover() + && let Err(err) = crate::recovery::restore_bundle_tracking(&background).await + { + tracing::warn!("bundle tracking restore failed: {err:#}"); + } - if let Err(err) = crate::clusters::apply_remote_migrations(&background).await { - tracing::error!("cluster migrations failed: {err:#}"); - } + if let Err(err) = crate::clusters::apply_remote_migrations(&background).await { + tracing::error!("cluster migrations failed: {err:#}"); + } - if let Err(err) = crate::clusters::ensure_from_versions(&background).await { - tracing::error!("versions cluster provisioning failed: {err:#}"); - } else { - background.services.events.signal(oneclient_events::Signal::ClustersChanged); - } + if let Err(err) = crate::clusters::ensure_from_versions(&background).await { + tracing::error!("versions cluster provisioning failed: {err:#}"); + } else { + background + .services + .events + .signal(oneclient_events::Signal::ClustersChanged); + } - // Must run last judging an artifact unused before recovery bundle - // tracking and provisioning have restored their rows would evict - // content still in use Only the row-driven half runs unattended - if let Err(err) = - oneclient_content::packages::store::collect_unused_artifacts(&content).await - { - tracing::warn!("package cache cleanup failed: {err:#}"); - } + // Must run last judging an artifact unused before recovery bundle + // tracking and provisioning have restored their rows would evict + // content still in use Only the row-driven half runs unattended + if let Err(err) = + oneclient_content::packages::store::collect_unused_artifacts(&content).await + { + tracing::warn!("package cache cleanup failed: {err:#}"); + } - background.services.events.signal(oneclient_events::Signal::SyncComplete); - }); + background + .services + .events + .signal(oneclient_events::Signal::SyncComplete); + }); } diff --git a/packages/oneclient_core/src/storage.rs b/packages/oneclient_core/src/storage.rs index e149dbde..24b2e038 100644 --- a/packages/oneclient_core/src/storage.rs +++ b/packages/oneclient_core/src/storage.rs @@ -10,16 +10,13 @@ use crate::state::LauncherState; use oneclient_common::domain::ContentType; use oneclient_common::paths; use oneclient_content::packages::store::manifest; -use oneclient_content::packages::store::{ - find_unreferenced_files, remove_unreferenced_files, -}; +use oneclient_content::packages::store::{find_unreferenced_files, remove_unreferenced_files}; use oneclient_events::EventBus; const LEGACY_TYPES: [ContentType; 2] = [ContentType::Mod, ContentType::DataPack]; // stable id for the storage scan's progress -pub const STORAGE_SCAN_PROGRESS: Uuid = - Uuid::from_u128(0x5354_4F52_4147_4500_0000_0000_0000_0001); +pub const STORAGE_SCAN_PROGRESS: Uuid = Uuid::from_u128(0x5354_4F52_4147_4500_0000_0000_0000_0001); #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StorageEntry { @@ -575,7 +572,9 @@ mod tests { let dir = root.path(); polyio::create_dir_all(dir.join("nested")).await.unwrap(); - polyio::write(dir.join("a.jar"), vec![0u8; 1000]).await.unwrap(); + polyio::write(dir.join("a.jar"), vec![0u8; 1000]) + .await + .unwrap(); polyio::write(dir.join("nested").join("b.jar"), vec![0u8; 500]) .await .unwrap(); @@ -603,7 +602,9 @@ mod tests { polyio::create_dir_all(&store).await.unwrap(); polyio::create_dir_all(&cluster).await.unwrap(); - polyio::write(store.join("mod.jar"), vec![0u8; 1000]).await.unwrap(); + polyio::write(store.join("mod.jar"), vec![0u8; 1000]) + .await + .unwrap(); polyio::symlink_file(store.join("mod.jar"), cluster.join("mod.jar")) .await .unwrap(); diff --git a/packages/oneclient_core/src/tos.rs b/packages/oneclient_core/src/tos.rs index 23676900..f58e6ba7 100644 --- a/packages/oneclient_core/src/tos.rs +++ b/packages/oneclient_core/src/tos.rs @@ -1,11 +1,10 @@ - use serde::{Deserialize, Serialize}; +use crate::{LauncherError, LauncherResult}; use oneclient_common::constants; use oneclient_common::paths; -use oneclient_net::{EtagPolicy, fetch_cached}; use oneclient_net::RequestClient; -use crate::{LauncherError, LauncherResult}; +use oneclient_net::{EtagPolicy, fetch_cached}; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TermsDocument { @@ -51,10 +50,7 @@ impl TermsDocument { #[tracing::instrument(level = "debug", skip(net))] pub async fn fetch_terms(net: &RequestClient) -> LauncherResult { - let url = format!( - "{}/oneclient/tos.json", - net.config().meta_url_base - ); + let url = format!("{}/oneclient/tos.json", net.config().meta_url_base); let cache_path = paths::caches_dir()?.join("TERMS.json"); let fetched = fetch_cached(net, &url, &cache_path, EtagPolicy::CommitNow) @@ -66,7 +62,6 @@ pub async fn fetch_terms(net: &RequestClient) -> LauncherResult { Ok(fetched.json()?) } - #[cfg(test)] mod tests { use super::*; diff --git a/packages/oneclient_core/src/verify.rs b/packages/oneclient_core/src/verify.rs index 97774788..0f9fc53b 100644 --- a/packages/oneclient_core/src/verify.rs +++ b/packages/oneclient_core/src/verify.rs @@ -1,18 +1,18 @@ use std::sync::Arc; +use oneclient_common::paths; use oneclient_content::packages::PackageStore; use oneclient_content::packages::store::artifact_absolute_path; use oneclient_db::dao::artifact as artifact_dao; use oneclient_events::{GroupedProgressSession, TaskCategory, TaskPhase}; -use oneclient_common::paths; use polyio::{normalize_hash, sha1_file}; +use crate::LauncherResult; use crate::clusters::prepare::prepare_cluster_locked; use crate::game::{ download_version_info, get_loader_version, resolve_minecraft_version, verify_game_files, }; use crate::state::LauncherState; -use crate::LauncherResult; #[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct ClusterVerifyReport { @@ -215,8 +215,8 @@ async fn verify_cluster_content( for (index, link) in linked.iter().enumerate() { child.set_progress(index as u64, Some(linked.len() as u64)); - let Some(artifact) = artifact_dao::get_artifact_by_hash(&state.services.db, &link.hash) - .await? + let Some(artifact) = + artifact_dao::get_artifact_by_hash(&state.services.db, &link.hash).await? else { continue; }; @@ -284,7 +284,9 @@ async fn repair_content( let outcome = async { let provider = content.providers.get(provider_id)?; - let version = provider.get_version(project_id, version_id, &content).await?; + let version = provider + .get_version(project_id, version_id, &content) + .await?; // The cluster is pinned to one exact file by hash another file from the // same version would silently change what is installed diff --git a/packages/oneclient_core/src/versions/manager.rs b/packages/oneclient_core/src/versions/manager.rs index 48754a74..d1ab08be 100644 --- a/packages/oneclient_core/src/versions/manager.rs +++ b/packages/oneclient_core/src/versions/manager.rs @@ -1,11 +1,10 @@ - use tokio::sync::RwLock; -use oneclient_common::paths; -use oneclient_net::{EtagPolicy, fetch_cached}; +use crate::LauncherResult; use crate::state::LauncherServices; use crate::versions::manifest::{RemoteMigration, VersionMetadata, VersionsManifest}; -use crate::LauncherResult; +use oneclient_common::paths; +use oneclient_net::{EtagPolicy, fetch_cached}; pub struct VersionsManager { manifest: RwLock, @@ -72,8 +71,13 @@ impl VersionsManager { services.requester.config().meta_url_base ); - let Some(fetched) = - fetch_cached(&services.requester, &url, &manifest_path, EtagPolicy::CommitNow).await? + let Some(fetched) = fetch_cached( + &services.requester, + &url, + &manifest_path, + EtagPolicy::CommitNow, + ) + .await? else { return Ok(None); }; @@ -87,5 +91,3 @@ impl Default for VersionsManager { Self::new() } } - - diff --git a/packages/oneclient_core/src/versions/manifest.rs b/packages/oneclient_core/src/versions/manifest.rs index 913f91eb..33ed2006 100644 --- a/packages/oneclient_core/src/versions/manifest.rs +++ b/packages/oneclient_core/src/versions/manifest.rs @@ -161,7 +161,8 @@ impl VersionsManifest { patch_version: entry.patch_version, loader: entry.loader.clone(), name: entry.name.clone().unwrap_or_else(|| cluster_name.clone()), - art_url: art_url(&entry.art, meta_url_base).or_else(|| art_url(&cluster.art, meta_url_base)), + art_url: art_url(&entry.art, meta_url_base) + .or_else(|| art_url(&cluster.art, meta_url_base)), long_description: entry .long_description .clone() diff --git a/packages/oneclient_core/tests/bundle_updates.rs b/packages/oneclient_core/tests/bundle_updates.rs index 312e130c..efb88385 100644 --- a/packages/oneclient_core/tests/bundle_updates.rs +++ b/packages/oneclient_core/tests/bundle_updates.rs @@ -1,7 +1,9 @@ -use oneclient_content::bundles::{BundleFile, BundleFileKind, BundleManifest, check_bundle_updates}; -use oneclient_core::clusters::CreateClusterOptions; use oneclient_common::domain::{ContentType, GameLoader, ProviderId}; +use oneclient_content::bundles::{ + BundleFile, BundleFileKind, BundleManifest, check_bundle_updates, +}; use oneclient_core::LauncherState; +use oneclient_core::clusters::CreateClusterOptions; use oneclient_db::dao::{artifact as artifact_dao, cluster_bundle as bundle_dao}; use oneclient_db::models::OverrideType; @@ -57,12 +59,14 @@ fn manifest(files: Vec) -> BundleManifest { async fn cluster_with_tracked_mod(state: &LauncherState) -> i64 { let global = state.settings.read().global_game_settings.clone(); - let cluster = state.clusters.create( - &global, - CreateClusterOptions::new("Bundle Cluster", MC_VERSION, GameLoader::Fabric), - ) - .await - .unwrap(); + let cluster = state + .clusters + .create( + &global, + CreateClusterOptions::new("Bundle Cluster", MC_VERSION, GameLoader::Fabric), + ) + .await + .unwrap(); artifact_dao::insert_artifact( &state.services.db, @@ -116,9 +120,13 @@ async fn mod_still_in_manifest_is_not_removed() { .unwrap(); let cluster_id = cluster_with_tracked_mod(&state).await; - let check = check_bundle_updates(cluster_id, state.bundles.as_ref(), &state.services.content()) - .await - .unwrap(); + let check = check_bundle_updates( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + .unwrap(); assert!( check.removals_available.is_empty(), @@ -136,9 +144,13 @@ async fn mod_dropped_from_manifest_is_removed() { .unwrap(); let cluster_id = cluster_with_tracked_mod(&state).await; - let check = check_bundle_updates(cluster_id, state.bundles.as_ref(), &state.services.content()) - .await - .unwrap(); + let check = check_bundle_updates( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + .unwrap(); assert_eq!( check.removals_available.len(), @@ -169,9 +181,13 @@ async fn disabled_mod_dropped_from_manifest_is_still_removed() { .await .unwrap(); - let check = check_bundle_updates(cluster_id, state.bundles.as_ref(), &state.services.content()) - .await - .unwrap(); + let check = check_bundle_updates( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + .unwrap(); assert_eq!( check.removals_available.len(), @@ -201,9 +217,13 @@ async fn user_disabled_mod_is_not_treated_as_a_removal() { .await .unwrap(); - let check = check_bundle_updates(cluster_id, state.bundles.as_ref(), &state.services.content()) - .await - .unwrap(); + let check = check_bundle_updates( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + .unwrap(); assert!( check.removals_available.is_empty(), @@ -223,9 +243,13 @@ async fn live_bundle_takes_on_new_catalog_files() { .unwrap(); let cluster_id = cluster_with_tracked_mod(&state).await; - let check = check_bundle_updates(cluster_id, state.bundles.as_ref(), &state.services.content()) - .await - .unwrap(); + let check = check_bundle_updates( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + .unwrap(); assert_eq!( check.additions_available.len(), @@ -265,9 +289,13 @@ async fn emptied_bundle_does_not_take_on_new_catalog_files() { .await .unwrap(); - let check = check_bundle_updates(cluster_id, state.bundles.as_ref(), &state.services.content()) - .await - .unwrap(); + let check = check_bundle_updates( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + .unwrap(); assert!( check.additions_available.is_empty(), @@ -300,9 +328,13 @@ async fn removed_bundle_content_does_not_take_on_new_catalog_files() { .await .unwrap(); - let check = check_bundle_updates(cluster_id, state.bundles.as_ref(), &state.services.content()) - .await - .unwrap(); + let check = check_bundle_updates( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + .unwrap(); assert!( check.additions_available.is_empty(), @@ -348,9 +380,13 @@ async fn opting_a_single_file_in_keeps_the_bundle_live() { .await .unwrap(); - let check = check_bundle_updates(cluster_id, state.bundles.as_ref(), &state.services.content()) - .await - .unwrap(); + let check = check_bundle_updates( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + .unwrap(); assert_eq!( check.additions_available.len(), @@ -387,9 +423,13 @@ async fn delisted_bundle_content_is_removed() { seed_delisted_bundle(&state).await; let cluster_id = cluster_with_tracked_mod(&state).await; - let check = check_bundle_updates(cluster_id, state.bundles.as_ref(), &state.services.content()) - .await - .unwrap(); + let check = check_bundle_updates( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + .unwrap(); assert_eq!( check.removals_available.len(), @@ -410,9 +450,13 @@ async fn delisted_bundle_content_another_bundle_still_ships_is_kept() { .unwrap(); let cluster_id = cluster_with_tracked_mod(&state).await; - let check = check_bundle_updates(cluster_id, state.bundles.as_ref(), &state.services.content()) - .await - .unwrap(); + let check = check_bundle_updates( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + .unwrap(); assert!( check.removals_available.is_empty(), @@ -426,9 +470,13 @@ async fn tracked_bundle_that_never_synced_is_not_removed() { let state = oneclient_core::dev::ephemeral_state().await.unwrap(); let cluster_id = cluster_with_tracked_mod(&state).await; - let check = check_bundle_updates(cluster_id, state.bundles.as_ref(), &state.services.content()) - .await - .unwrap(); + let check = check_bundle_updates( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + .unwrap(); assert!( check.removals_available.is_empty(), diff --git a/packages/oneclient_core/tests/clusters.rs b/packages/oneclient_core/tests/clusters.rs index 13f6946a..0dab74c5 100644 --- a/packages/oneclient_core/tests/clusters.rs +++ b/packages/oneclient_core/tests/clusters.rs @@ -1,123 +1,148 @@ use std::time::Duration; -use oneclient_core::clusters::{ClusterStage, CreateClusterOptions}; +use oneclient_cluster::profiles::create_settings_profile; use oneclient_common::domain::GameLoader; use oneclient_common::patch::Patch; -use oneclient_cluster::profiles::create_settings_profile; use oneclient_core::ProfileUpdate; +use oneclient_core::clusters::{ClusterStage, CreateClusterOptions}; #[tokio::test] async fn cluster_lifecycle_with_settings_profile() { - let state = oneclient_core::dev::ephemeral_state().await.unwrap(); - let settings = state.settings.read().clone(); + let state = oneclient_core::dev::ephemeral_state().await.unwrap(); + let settings = state.settings.read().clone(); - let global = state.settings.read().global_game_settings.clone(); + let global = state.settings.read().global_game_settings.clone(); - // Deliberately not a value `default_mem_max()` can return so the assertions - // distinguish "the cluster's own profile was used" from "fell back to global - // and matched" - let cluster = state.clusters.create( - &global, - CreateClusterOptions::new("Test Cluster", "1.21.1", GameLoader::Fabric).mem_max(5120), - ) - .await - .unwrap(); + // Deliberately not a value `default_mem_max()` can return so the assertions + // distinguish "the cluster's own profile was used" from "fell back to global + // and matched" + let cluster = state + .clusters + .create( + &global, + CreateClusterOptions::new("Test Cluster", "1.21.1", GameLoader::Fabric).mem_max(5120), + ) + .await + .unwrap(); - assert_eq!(cluster.name, "Test Cluster"); - assert_eq!(cluster.mc_version, "1.21.1"); - assert_eq!(cluster.stage, ClusterStage::NotReady); - assert!(cluster.setting_profile_name.is_some()); - assert!(cluster.created_at.is_some()); + assert_eq!(cluster.name, "Test Cluster"); + assert_eq!(cluster.mc_version, "1.21.1"); + assert_eq!(cluster.stage, ClusterStage::NotReady); + assert!(cluster.setting_profile_name.is_some()); + assert!(cluster.created_at.is_some()); - let resolved = state.clusters.resolve_settings(&global, &cluster) - .await - .unwrap(); - assert_eq!(resolved.mem_max, Some(5120)); + let resolved = state + .clusters + .resolve_settings(&global, &cluster) + .await + .unwrap(); + assert_eq!(resolved.mem_max, Some(5120)); - state.clusters.update_profile(cluster.id, - ProfileUpdate { - mem_max: Patch::Set(6144), - ..Default::default() - }, - ) - .await - .unwrap(); + state + .clusters + .update_profile( + cluster.id, + ProfileUpdate { + mem_max: Patch::Set(6144), + ..Default::default() + }, + ) + .await + .unwrap(); - let updated = state.clusters.resolve_settings(&global, &cluster) - .await - .unwrap(); - assert_eq!(updated.mem_max, Some(6144)); + let updated = state + .clusters + .resolve_settings(&global, &cluster) + .await + .unwrap(); + assert_eq!(updated.mem_max, Some(6144)); - let shared = create_settings_profile(&state.services.db, &settings.global_game_settings, "Shared Profile") - .await - .unwrap(); + let shared = create_settings_profile( + &state.services.db, + &settings.global_game_settings, + "Shared Profile", + ) + .await + .unwrap(); - state.clusters.update(cluster.id, - oneclient_core::ClusterUpdate::default().setting_profile(shared.name.clone()), - ) - .await - .unwrap(); + state + .clusters + .update( + cluster.id, + oneclient_core::ClusterUpdate::default().setting_profile(shared.name.clone()), + ) + .await + .unwrap(); - let reassigned = state.clusters.get(cluster.id).await.unwrap(); - assert_eq!( - reassigned.setting_profile_name.as_deref(), - Some("Shared Profile") - ); + let reassigned = state.clusters.get(cluster.id).await.unwrap(); + assert_eq!( + reassigned.setting_profile_name.as_deref(), + Some("Shared Profile") + ); - let after_play = state.clusters.add_playtime(cluster.id, Duration::from_secs(120)) - .await - .unwrap(); - assert_eq!(after_play.overall_played, Duration::from_secs(120)); - assert!(after_play.last_played.is_some()); + let after_play = state + .clusters + .add_playtime(cluster.id, Duration::from_secs(120)) + .await + .unwrap(); + assert_eq!(after_play.overall_played, Duration::from_secs(120)); + assert!(after_play.last_played.is_some()); - state.clusters.delete(cluster.id, true).await.unwrap(); - assert!(state.clusters.get(cluster.id).await.is_err()); + state.clusters.delete(cluster.id, true).await.unwrap(); + assert!(state.clusters.get(cluster.id).await.is_err()); } #[tokio::test] async fn cleared_profile_fields_inherit_from_global() { - let state = oneclient_core::dev::ephemeral_state().await.unwrap(); + let state = oneclient_core::dev::ephemeral_state().await.unwrap(); - { - let mut settings = state.settings.write(); - settings.global_game_settings.mem_max = Some(8192); - } + { + let mut settings = state.settings.write(); + settings.global_game_settings.mem_max = Some(8192); + } - // Read the baseline *after* the write above so the new cluster inherits it - let global = state.settings.read().global_game_settings.clone(); - let cluster = state.clusters.create( - &global, - CreateClusterOptions::new("Inherit Test", "1.21.1", GameLoader::Vanilla).mem_max(2048), - ) - .await - .unwrap(); + // Read the baseline *after* the write above so the new cluster inherits it + let global = state.settings.read().global_game_settings.clone(); + let cluster = state + .clusters + .create( + &global, + CreateClusterOptions::new("Inherit Test", "1.21.1", GameLoader::Vanilla).mem_max(2048), + ) + .await + .unwrap(); - let profile_name = cluster.setting_profile_name.clone().unwrap(); + let profile_name = cluster.setting_profile_name.clone().unwrap(); - state.clusters.update_profile(cluster.id, - ProfileUpdate { - mem_max: Patch::Clear, - force_fullscreen: Patch::Clear, - ..Default::default() - }, - ) - .await - .unwrap(); + state + .clusters + .update_profile( + cluster.id, + ProfileUpdate { + mem_max: Patch::Clear, + force_fullscreen: Patch::Clear, + ..Default::default() + }, + ) + .await + .unwrap(); let global = state.settings.read().global_game_settings.clone(); - let stored = oneclient_cluster::profiles::get_profile_or_default( - &state.services.db, - &global, - Some(&profile_name), - ) - .await - .unwrap(); - assert_eq!(stored.mem_max, None); - assert_eq!(stored.force_fullscreen, None); + let stored = oneclient_cluster::profiles::get_profile_or_default( + &state.services.db, + &global, + Some(&profile_name), + ) + .await + .unwrap(); + assert_eq!(stored.mem_max, None); + assert_eq!(stored.force_fullscreen, None); - let resolved = state.clusters.resolve_settings(&global, &cluster) - .await - .unwrap(); - assert_eq!(resolved.mem_max, Some(8192)); - assert_eq!(resolved.force_fullscreen, Some(false)); + let resolved = state + .clusters + .resolve_settings(&global, &cluster) + .await + .unwrap(); + assert_eq!(resolved.mem_max, Some(8192)); + assert_eq!(resolved.force_fullscreen, Some(false)); } diff --git a/packages/oneclient_core/tests/patch.rs b/packages/oneclient_core/tests/patch.rs index af44c1ef..e5ba07a6 100644 --- a/packages/oneclient_core/tests/patch.rs +++ b/packages/oneclient_core/tests/patch.rs @@ -2,32 +2,32 @@ use oneclient_common::patch::Patch; #[test] fn patch_apply_to_option() { - let mut slot = Some(42u32); + let mut slot = Some(42u32); - Patch::Unchanged.apply_to_option(&mut slot); - assert_eq!(slot, Some(42)); + Patch::Unchanged.apply_to_option(&mut slot); + assert_eq!(slot, Some(42)); - Patch::Set(99).apply_to_option(&mut slot); - assert_eq!(slot, Some(99)); + Patch::Set(99).apply_to_option(&mut slot); + assert_eq!(slot, Some(99)); - Patch::::Clear.apply_to_option(&mut slot); - assert_eq!(slot, None); + Patch::::Clear.apply_to_option(&mut slot); + assert_eq!(slot, None); } #[test] fn patch_command_empty_string_clears() { - let mut slot: Option = Some("wrapper".into()); + let mut slot: Option = Some("wrapper".into()); - Patch::Set(" ".to_owned()).apply_to_command_option(&mut slot); - assert_eq!(slot, Some("".to_owned())); + Patch::Set(" ".to_owned()).apply_to_command_option(&mut slot); + assert_eq!(slot, Some("".to_owned())); } #[test] fn patch_into_db_patch() { - assert_eq!(Patch::::Unchanged.into_db_patch(), None); - assert_eq!(Patch::::Clear.into_db_patch(), Some(None)); - assert_eq!( - Patch::Set("x".to_string()).into_db_patch(), - Some(Some("x".to_string())) - ); + assert_eq!(Patch::::Unchanged.into_db_patch(), None); + assert_eq!(Patch::::Clear.into_db_patch(), Some(None)); + assert_eq!( + Patch::Set("x".to_string()).into_db_patch(), + Some(Some("x".to_string())) + ); } diff --git a/packages/oneclient_core/tests/providers.rs b/packages/oneclient_core/tests/providers.rs index 73f3f2e8..b5d98484 100644 --- a/packages/oneclient_core/tests/providers.rs +++ b/packages/oneclient_core/tests/providers.rs @@ -1,7 +1,6 @@ - -use oneclient_core::dev; use oneclient_common::domain::{ContentType, ProviderId}; use oneclient_content::packages::types::{PackageBody, SearchFilters}; +use oneclient_core::dev; #[test] fn registry_get_unknown_provider_errors() { @@ -112,7 +111,7 @@ async fn modrinth_lookup_version_by_sha1() { let found = env.providers.lookup_version(&sha1, &env).await.unwrap(); assert!(found.is_some()); - + let (provider_id, _) = found.unwrap(); assert_eq!(provider_id, ProviderId::Modrinth); } diff --git a/packages/oneclient_db/src/backup.rs b/packages/oneclient_db/src/backup.rs index 426deef6..15703703 100644 --- a/packages/oneclient_db/src/backup.rs +++ b/packages/oneclient_db/src/backup.rs @@ -57,9 +57,9 @@ pub fn list(database_path: &Path) -> Vec { .and_then(|name| name.to_str()) .is_some_and(|name| { name.ends_with(".db") - && name.strip_prefix(&prefix).is_some_and(|rest| { - rest.starts_with(|c: char| c.is_ascii_digit()) - }) + && name + .strip_prefix(&prefix) + .is_some_and(|rest| rest.starts_with(|c: char| c.is_ascii_digit())) }) }) .collect(); @@ -206,7 +206,10 @@ mod tests { .fetch_optional(&kept) .await .expect("probe"); - assert!(after.is_some(), "the replaced database must still be intact"); + assert!( + after.is_some(), + "the replaced database must still be intact" + ); } #[tokio::test] diff --git a/packages/oneclient_db/src/console.rs b/packages/oneclient_db/src/console.rs index 678b86d7..d9c6ecdf 100644 --- a/packages/oneclient_db/src/console.rs +++ b/packages/oneclient_db/src/console.rs @@ -163,6 +163,10 @@ mod tests { #[tokio::test] async fn invalid_sql_errors() { let pool = pool().await; - assert!(run_console_query(&pool, "SELECT * FROM nope").await.is_err()); + assert!( + run_console_query(&pool, "SELECT * FROM nope") + .await + .is_err() + ); } } diff --git a/packages/oneclient_db/src/dao/applied_migration.rs b/packages/oneclient_db/src/dao/applied_migration.rs index 27a205f1..5d55241e 100644 --- a/packages/oneclient_db/src/dao/applied_migration.rs +++ b/packages/oneclient_db/src/dao/applied_migration.rs @@ -2,23 +2,23 @@ use chrono::Utc; use sqlx::SqlitePool; pub async fn is_applied(pool: &SqlitePool, id: &str) -> Result { - let row = sqlx::query!("SELECT id FROM applied_migrations WHERE id = ?", id) - .fetch_optional(pool) - .await?; + let row = sqlx::query!("SELECT id FROM applied_migrations WHERE id = ?", id) + .fetch_optional(pool) + .await?; - Ok(row.is_some()) + Ok(row.is_some()) } pub async fn mark_applied(pool: &SqlitePool, id: &str) -> Result<(), sqlx::Error> { - let applied_at = Utc::now().to_rfc3339(); + let applied_at = Utc::now().to_rfc3339(); - sqlx::query!( - "INSERT OR IGNORE INTO applied_migrations (id, applied_at) VALUES (?, ?)", - id, - applied_at - ) - .execute(pool) - .await?; + sqlx::query!( + "INSERT OR IGNORE INTO applied_migrations (id, applied_at) VALUES (?, ?)", + id, + applied_at + ) + .execute(pool) + .await?; - Ok(()) + Ok(()) } diff --git a/packages/oneclient_db/src/dao/artifact.rs b/packages/oneclient_db/src/dao/artifact.rs index 86b8c802..ad8eba7b 100644 --- a/packages/oneclient_db/src/dao/artifact.rs +++ b/packages/oneclient_db/src/dao/artifact.rs @@ -1,37 +1,37 @@ use sqlx::SqlitePool; use crate::models::{ - ArtifactRow, ClusterArtifactRow, LinkedArtifactRow, ProviderReleaseRow, SeenStatus, + ArtifactRow, ClusterArtifactRow, LinkedArtifactRow, ProviderReleaseRow, SeenStatus, }; pub async fn get_artifact_by_hash( - pool: &SqlitePool, - hash: &str, + pool: &SqlitePool, + hash: &str, ) -> Result, sqlx::Error> { - sqlx::query_as!( - ArtifactRow, - r#" + sqlx::query_as!( + ArtifactRow, + r#" SELECT hash, content_type, path, file_name, size_bytes FROM artifacts WHERE hash = ? "#, - hash - ) - .fetch_optional(pool) - .await + hash + ) + .fetch_optional(pool) + .await } pub async fn insert_artifact( - pool: &SqlitePool, - hash: &str, - content_type: i64, - path: &str, - file_name: &str, - size_bytes: Option, + pool: &SqlitePool, + hash: &str, + content_type: i64, + path: &str, + file_name: &str, + size_bytes: Option, ) -> Result { - sqlx::query_as!( - ArtifactRow, - r#" + sqlx::query_as!( + ArtifactRow, + r#" INSERT INTO artifacts (hash, content_type, path, file_name, size_bytes) VALUES (?, ?, ?, ?, ?) ON CONFLICT(hash) DO UPDATE SET @@ -41,35 +41,33 @@ pub async fn insert_artifact( size_bytes = COALESCE(excluded.size_bytes, artifacts.size_bytes) RETURNING hash, content_type, path, file_name, size_bytes "#, - hash, - content_type, - path, - file_name, - size_bytes - ) - .fetch_one(pool) - .await + hash, + content_type, + path, + file_name, + size_bytes + ) + .fetch_one(pool) + .await } /// `provider_releases` cascades the cached file is the caller's to delete /// this layer does not touch the disk pub async fn delete_artifact_if_unused(pool: &SqlitePool, hash: &str) -> Result { - let linked: (i64,) = sqlx::query_as( - "SELECT COUNT(*) FROM cluster_artifacts WHERE hash = ?", - ) - .bind(hash) - .fetch_one(pool) - .await?; + let linked: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM cluster_artifacts WHERE hash = ?") + .bind(hash) + .fetch_one(pool) + .await?; - if linked.0 > 0 { - return Ok(false); - } + if linked.0 > 0 { + return Ok(false); + } - sqlx::query!("DELETE FROM artifacts WHERE hash = ?", hash) - .execute(pool) - .await?; + sqlx::query!("DELETE FROM artifacts WHERE hash = ?", hash) + .execute(pool) + .await?; - Ok(true) + Ok(true) } use crate::models::GlobalArtifactRow; @@ -86,11 +84,11 @@ use crate::models::GlobalArtifactRow; /// Written with the runtime-checked builder rather than `query!` so it needs no /// entry in the offline cache pub async fn list_global_artifacts( - pool: &SqlitePool, - content_type: i64, + pool: &SqlitePool, + content_type: i64, ) -> Result, sqlx::Error> { - sqlx::query_as::<_, GlobalArtifactRow>( - r#" + sqlx::query_as::<_, GlobalArtifactRow>( + r#" SELECT ca.hash AS hash, MIN(ca.cluster_file_name) AS file_name, @@ -100,10 +98,10 @@ pub async fn list_global_artifacts( WHERE a.content_type = ? GROUP BY ca.hash "#, - ) - .bind(content_type) - .fetch_all(pool) - .await + ) + .bind(content_type) + .fetch_all(pool) + .await } /// Sets the flag on every cluster that has this artifact @@ -112,55 +110,55 @@ pub async fn list_global_artifacts( /// whichever cluster the user happened to be looking at would leave the rest /// disagreeing with what is on disk pub async fn set_enabled_for_hash( - pool: &SqlitePool, - hash: &str, - enabled: i64, + pool: &SqlitePool, + hash: &str, + enabled: i64, ) -> Result { - let result = sqlx::query("UPDATE cluster_artifacts SET enabled = ? WHERE hash = ?") - .bind(enabled) - .bind(hash) - .execute(pool) - .await?; + let result = sqlx::query("UPDATE cluster_artifacts SET enabled = ? WHERE hash = ?") + .bind(enabled) + .bind(hash) + .execute(pool) + .await?; - Ok(result.rows_affected()) + Ok(result.rows_affected()) } pub async fn list_unused_artifacts(pool: &SqlitePool) -> Result, sqlx::Error> { - sqlx::query_as::<_, ArtifactRow>( - r#" + sqlx::query_as::<_, ArtifactRow>( + r#" SELECT hash, content_type, path, file_name, size_bytes FROM artifacts WHERE hash NOT IN (SELECT hash FROM cluster_artifacts) "#, - ) - .fetch_all(pool) - .await + ) + .fetch_all(pool) + .await } pub async fn list_artifact_paths(pool: &SqlitePool) -> Result, sqlx::Error> { - let rows: Vec<(String,)> = sqlx::query_as("SELECT path FROM artifacts") - .fetch_all(pool) - .await?; + let rows: Vec<(String,)> = sqlx::query_as("SELECT path FROM artifacts") + .fetch_all(pool) + .await?; - Ok(rows.into_iter().map(|(path,)| path).collect()) + Ok(rows.into_iter().map(|(path,)| path).collect()) } #[allow(clippy::too_many_arguments)] pub async fn upsert_provider_release( - pool: &SqlitePool, - provider_id: i64, - project_id: &str, - version_id: &str, - hash: &str, - display_name: &str, - display_version: &str, - published_at: Option<&str>, - mc_versions: &str, - mc_loaders: &str, + pool: &SqlitePool, + provider_id: i64, + project_id: &str, + version_id: &str, + hash: &str, + display_name: &str, + display_version: &str, + published_at: Option<&str>, + mc_versions: &str, + mc_loaders: &str, ) -> Result { - sqlx::query_as!( - ProviderReleaseRow, - r#" + sqlx::query_as!( + ProviderReleaseRow, + r#" INSERT INTO provider_releases ( provider, project_id, version_id, hash, display_name, display_version, published_at, mc_versions, mc_loaders @@ -177,50 +175,50 @@ pub async fn upsert_provider_release( provider as "provider!: i64", project_id, version_id, hash, display_name, display_version, published_at, mc_versions, mc_loaders "#, - provider_id, - project_id, - version_id, - hash, - display_name, - display_version, - published_at, - mc_versions, - mc_loaders - ) - .fetch_one(pool) - .await + provider_id, + project_id, + version_id, + hash, + display_name, + display_version, + published_at, + mc_versions, + mc_loaders + ) + .fetch_one(pool) + .await } pub async fn get_provider_release( - pool: &SqlitePool, - provider_id: i64, - project_id: &str, - version_id: &str, + pool: &SqlitePool, + provider_id: i64, + project_id: &str, + version_id: &str, ) -> Result, sqlx::Error> { - sqlx::query_as!( - ProviderReleaseRow, - r#" + sqlx::query_as!( + ProviderReleaseRow, + r#" SELECT provider as "provider!: i64", project_id, version_id, hash, display_name, display_version, published_at, mc_versions, mc_loaders FROM provider_releases WHERE provider = ? AND project_id = ? AND version_id = ? "#, - provider_id, - project_id, - version_id - ) - .fetch_optional(pool) - .await + provider_id, + project_id, + version_id + ) + .fetch_optional(pool) + .await } pub async fn get_release_by_hash( - pool: &SqlitePool, - hash: &str, + pool: &SqlitePool, + hash: &str, ) -> Result, sqlx::Error> { - sqlx::query_as!( - ProviderReleaseRow, - r#" + sqlx::query_as!( + ProviderReleaseRow, + r#" SELECT provider as "provider!: i64", project_id, version_id, hash, display_name, display_version, published_at, mc_versions, mc_loaders @@ -228,60 +226,60 @@ pub async fn get_release_by_hash( WHERE hash = ? LIMIT 1 "#, - hash - ) - .fetch_optional(pool) - .await + hash + ) + .fetch_optional(pool) + .await } pub async fn list_release_game_versions( - pool: &SqlitePool, - hash: &str, + pool: &SqlitePool, + hash: &str, ) -> Result, sqlx::Error> { - let rows: Vec<(String,)> = - sqlx::query_as("SELECT mc_versions FROM provider_releases WHERE hash = ?") - .bind(hash) - .fetch_all(pool) - .await?; + let rows: Vec<(String,)> = + sqlx::query_as("SELECT mc_versions FROM provider_releases WHERE hash = ?") + .bind(hash) + .fetch_all(pool) + .await?; - Ok(rows.into_iter().map(|(versions,)| versions).collect()) + Ok(rows.into_iter().map(|(versions,)| versions).collect()) } pub async fn link_cluster_artifact( - pool: &SqlitePool, - cluster_id: i64, - hash: &str, - cluster_file_name: &str, + pool: &SqlitePool, + cluster_id: i64, + hash: &str, + cluster_file_name: &str, ) -> Result { - sqlx::query_as!( - ClusterArtifactRow, - r#" + sqlx::query_as!( + ClusterArtifactRow, + r#" INSERT INTO cluster_artifacts (cluster_id, hash, cluster_file_name, enabled) VALUES (?, ?, ?, 1) ON CONFLICT(cluster_id, hash) DO UPDATE SET cluster_file_name = excluded.cluster_file_name RETURNING cluster_id, hash, cluster_file_name, enabled, seen_status "#, - cluster_id, - hash, - cluster_file_name - ) - .fetch_one(pool) - .await + cluster_id, + hash, + cluster_file_name + ) + .fetch_one(pool) + .await } /// `DISTINCT` is required `provider_releases` is keyed by version so one /// artifact joins several rows and would otherwise be reported repeatedly pub async fn list_cluster_artifacts_for_project( - pool: &SqlitePool, - cluster_id: i64, - provider: i64, - project_id: &str, - exclude_hash: &str, + pool: &SqlitePool, + cluster_id: i64, + provider: i64, + project_id: &str, + exclude_hash: &str, ) -> Result, sqlx::Error> { - sqlx::query_as!( - ClusterArtifactRow, - r#" + sqlx::query_as!( + ClusterArtifactRow, + r#" SELECT DISTINCT ca.cluster_id, ca.hash, ca.cluster_file_name, ca.enabled, ca.seen_status FROM cluster_artifacts ca JOIN provider_releases pr ON pr.hash = ca.hash @@ -290,57 +288,56 @@ pub async fn list_cluster_artifacts_for_project( AND pr.project_id = ? AND ca.hash <> ? "#, - cluster_id, - provider, - project_id, - exclude_hash - ) - .fetch_all(pool) - .await + cluster_id, + provider, + project_id, + exclude_hash + ) + .fetch_all(pool) + .await } pub async fn is_cluster_linked( - pool: &SqlitePool, - cluster_id: i64, - hash: &str, + pool: &SqlitePool, + cluster_id: i64, + hash: &str, ) -> Result { - let row: Option<(i64,)> = sqlx::query_as( - "SELECT 1 FROM cluster_artifacts WHERE cluster_id = ? AND hash = ? LIMIT 1", - ) - .bind(cluster_id) - .bind(hash) - .fetch_optional(pool) - .await?; + let row: Option<(i64,)> = + sqlx::query_as("SELECT 1 FROM cluster_artifacts WHERE cluster_id = ? AND hash = ? LIMIT 1") + .bind(cluster_id) + .bind(hash) + .fetch_optional(pool) + .await?; - Ok(row.is_some()) + Ok(row.is_some()) } pub async fn unlink_cluster_artifact( - pool: &SqlitePool, - cluster_id: i64, - hash: &str, + pool: &SqlitePool, + cluster_id: i64, + hash: &str, ) -> Result<(), sqlx::Error> { - sqlx::query!( - "DELETE FROM cluster_artifacts WHERE cluster_id = ? AND hash = ?", - cluster_id, - hash - ) - .execute(pool) - .await?; + sqlx::query!( + "DELETE FROM cluster_artifacts WHERE cluster_id = ? AND hash = ?", + cluster_id, + hash + ) + .execute(pool) + .await?; - Ok(()) + Ok(()) } /// One artifact can carry several `provider_releases` rows so the release /// columns are taken from a single row picked by a total order rather than /// grouped which lets SQLite mix columns from different rows pub async fn list_cluster_artifacts_detailed( - pool: &SqlitePool, - cluster_id: i64, + pool: &SqlitePool, + cluster_id: i64, ) -> Result, sqlx::Error> { - sqlx::query_as!( - LinkedArtifactRow, - r#" + sqlx::query_as!( + LinkedArtifactRow, + r#" SELECT ca.hash AS "hash!: String", ca.cluster_file_name AS "cluster_file_name!: String", @@ -365,211 +362,232 @@ pub async fn list_cluster_artifacts_detailed( ) WHERE ca.cluster_id = ? "#, - cluster_id - ) - .fetch_all(pool) - .await + cluster_id + ) + .fetch_all(pool) + .await } pub async fn get_cluster_artifact( - pool: &SqlitePool, - cluster_id: i64, - hash: &str, + pool: &SqlitePool, + cluster_id: i64, + hash: &str, ) -> Result, sqlx::Error> { - sqlx::query_as!( - ClusterArtifactRow, - r#" + sqlx::query_as!( + ClusterArtifactRow, + r#" SELECT cluster_id, hash, cluster_file_name, enabled, seen_status FROM cluster_artifacts WHERE cluster_id = ? AND hash = ? "#, - cluster_id, - hash - ) - .fetch_optional(pool) - .await + cluster_id, + hash + ) + .fetch_optional(pool) + .await } pub async fn update_cluster_artifact( - pool: &SqlitePool, - cluster_id: i64, - hash: &str, - cluster_file_name: &str, - enabled: i64, + pool: &SqlitePool, + cluster_id: i64, + hash: &str, + cluster_file_name: &str, + enabled: i64, ) -> Result { - sqlx::query_as!( - ClusterArtifactRow, - r#" + sqlx::query_as!( + ClusterArtifactRow, + r#" UPDATE cluster_artifacts SET cluster_file_name = ?, enabled = ? WHERE cluster_id = ? AND hash = ? RETURNING cluster_id, hash, cluster_file_name, enabled, seen_status "#, - cluster_file_name, - enabled, - cluster_id, - hash - ) - .fetch_one(pool) - .await + cluster_file_name, + enabled, + cluster_id, + hash + ) + .fetch_one(pool) + .await } pub async fn set_seen_status( - pool: &SqlitePool, - cluster_id: i64, - hash: &str, - status: SeenStatus, + pool: &SqlitePool, + cluster_id: i64, + hash: &str, + status: SeenStatus, ) -> Result<(), sqlx::Error> { - let status = status.as_i64(); + let status = status.as_i64(); - let result = sqlx::query!( - r#" + let result = sqlx::query!( + r#" UPDATE cluster_artifacts SET seen_status = ? WHERE cluster_id = ? AND hash = ? "#, - status, - cluster_id, - hash - ) - .execute(pool) - .await?; + status, + cluster_id, + hash + ) + .execute(pool) + .await?; - if result.rows_affected() == 0 { - tracing::warn!(cluster_id, hash, "no cluster artifact matched the seen status update"); - } + if result.rows_affected() == 0 { + tracing::warn!( + cluster_id, + hash, + "no cluster artifact matched the seen status update" + ); + } - Ok(()) + Ok(()) } pub async fn mark_all_seen(pool: &SqlitePool) -> Result { - let seen = SeenStatus::Seen.as_i64(); + let seen = SeenStatus::Seen.as_i64(); - let result = sqlx::query!( - "UPDATE cluster_artifacts SET seen_status = ? WHERE seen_status <> ?", - seen, - seen - ) - .execute(pool) - .await?; + let result = sqlx::query!( + "UPDATE cluster_artifacts SET seen_status = ? WHERE seen_status <> ?", + seen, + seen + ) + .execute(pool) + .await?; - Ok(result.rows_affected()) + Ok(result.rows_affected()) } #[cfg(test)] mod tests { - use super::*; - use crate::dao::cluster as cluster_dao; - use crate::models::NewCluster; + use super::*; + use crate::dao::cluster as cluster_dao; + use crate::models::NewCluster; - async fn pool() -> SqlitePool { - let pool = SqlitePool::connect("sqlite::memory:") - .await - .expect("in-memory sqlite"); - sqlx::migrate!().run(&pool).await.expect("migrations run"); - pool - } + async fn pool() -> SqlitePool { + let pool = SqlitePool::connect("sqlite::memory:") + .await + .expect("in-memory sqlite"); + sqlx::migrate!().run(&pool).await.expect("migrations run"); + pool + } - async fn seed_cluster(pool: &SqlitePool) -> i64 { - cluster_dao::insert( - pool, - &NewCluster { - name: "26.1 fabric", - folder_name: "26.1 fabric", - mc_version: "26.1", - mc_loader: 1, - mc_loader_version: None, - setting_profile_name: None, - stage: 0, - }, - ) - .await - .expect("insert cluster") - .id - } + async fn seed_cluster(pool: &SqlitePool) -> i64 { + cluster_dao::insert( + pool, + &NewCluster { + name: "26.1 fabric", + folder_name: "26.1 fabric", + mc_version: "26.1", + mc_loader: 1, + mc_loader_version: None, + setting_profile_name: None, + stage: 0, + }, + ) + .await + .expect("insert cluster") + .id + } - #[tokio::test] - async fn detailed_listing_matches_the_lookups_it_replaced() { - let pool = pool().await; - let cluster_id = seed_cluster(&pool).await; + #[tokio::test] + async fn detailed_listing_matches_the_lookups_it_replaced() { + let pool = pool().await; + let cluster_id = seed_cluster(&pool).await; - insert_artifact(&pool, "hash_managed", 0, "a/managed.jar", "managed.jar", Some(1)) - .await - .expect("insert managed artifact"); - insert_artifact(&pool, "hash_local", 0, "a/local.jar", "local.jar", None) - .await - .expect("insert local artifact"); + insert_artifact( + &pool, + "hash_managed", + 0, + "a/managed.jar", + "managed.jar", + Some(1), + ) + .await + .expect("insert managed artifact"); + insert_artifact(&pool, "hash_local", 0, "a/local.jar", "local.jar", None) + .await + .expect("insert local artifact"); - for version in ["v1", "v2"] { - upsert_provider_release( - &pool, - 1, - "sodium", - version, - "hash_managed", - "Sodium", - version, - None, - "[]", - "[]", - ) - .await - .expect("insert release"); - } + for version in ["v1", "v2"] { + upsert_provider_release( + &pool, + 1, + "sodium", + version, + "hash_managed", + "Sodium", + version, + None, + "[]", + "[]", + ) + .await + .expect("insert release"); + } - link_cluster_artifact(&pool, cluster_id, "hash_managed", "managed.jar") - .await - .expect("link managed"); - link_cluster_artifact(&pool, cluster_id, "hash_local", "local.jar") - .await - .expect("link local"); - update_cluster_artifact(&pool, cluster_id, "hash_local", "local.jar", 0) - .await - .expect("disable local"); + link_cluster_artifact(&pool, cluster_id, "hash_managed", "managed.jar") + .await + .expect("link managed"); + link_cluster_artifact(&pool, cluster_id, "hash_local", "local.jar") + .await + .expect("link local"); + update_cluster_artifact(&pool, cluster_id, "hash_local", "local.jar", 0) + .await + .expect("disable local"); - let rows = list_cluster_artifacts_detailed(&pool, cluster_id) - .await - .expect("detailed listing"); + let rows = list_cluster_artifacts_detailed(&pool, cluster_id) + .await + .expect("detailed listing"); - assert_eq!(rows.len(), 2, "one row per link, not per provider release"); + assert_eq!(rows.len(), 2, "one row per link, not per provider release"); - let managed = rows.iter().find(|r| r.hash == "hash_managed").expect("managed row"); - assert_eq!(managed.file_name, "managed.jar"); - assert_eq!(managed.enabled, 1); - assert_eq!(managed.project_id.as_deref(), Some("sodium")); - assert_eq!(managed.provider, Some(1)); - assert_eq!(managed.version_id.as_deref(), Some("v1")); - assert_eq!( - managed.display_version.as_deref(), - Some("v1"), - "every release column comes from the same release row" - ); + let managed = rows + .iter() + .find(|r| r.hash == "hash_managed") + .expect("managed row"); + assert_eq!(managed.file_name, "managed.jar"); + assert_eq!(managed.enabled, 1); + assert_eq!(managed.project_id.as_deref(), Some("sodium")); + assert_eq!(managed.provider, Some(1)); + assert_eq!(managed.version_id.as_deref(), Some("v1")); + assert_eq!( + managed.display_version.as_deref(), + Some("v1"), + "every release column comes from the same release row" + ); - let local = rows.iter().find(|r| r.hash == "hash_local").expect("local row"); - assert_eq!(local.enabled, 0); - assert!(local.project_id.is_none(), "a local file has no provider columns"); + let local = rows + .iter() + .find(|r| r.hash == "hash_local") + .expect("local row"); + assert_eq!(local.enabled, 0); + assert!( + local.project_id.is_none(), + "a local file has no provider columns" + ); - let mut conn = pool.acquire().await.expect("connection"); - sqlx::query("PRAGMA foreign_keys = OFF") - .execute(&mut *conn) - .await - .expect("disable foreign keys"); - sqlx::query!("DELETE FROM artifacts WHERE hash = ?", "hash_local") - .execute(&mut *conn) - .await - .expect("drop artifact row"); - let orphans: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM cluster_artifacts WHERE hash = ?") - .bind("hash_local") - .fetch_one(&mut *conn) - .await - .expect("count orphan link"); - assert_eq!(orphans, 1, "the link must outlive its artifact row"); - drop(conn); + let mut conn = pool.acquire().await.expect("connection"); + sqlx::query("PRAGMA foreign_keys = OFF") + .execute(&mut *conn) + .await + .expect("disable foreign keys"); + sqlx::query!("DELETE FROM artifacts WHERE hash = ?", "hash_local") + .execute(&mut *conn) + .await + .expect("drop artifact row"); + let orphans: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM cluster_artifacts WHERE hash = ?") + .bind("hash_local") + .fetch_one(&mut *conn) + .await + .expect("count orphan link"); + assert_eq!(orphans, 1, "the link must outlive its artifact row"); + drop(conn); - let rows = list_cluster_artifacts_detailed(&pool, cluster_id) - .await - .expect("detailed listing after delete"); - assert_eq!(rows.len(), 1); - assert_eq!(rows[0].hash, "hash_managed"); - } + let rows = list_cluster_artifacts_detailed(&pool, cluster_id) + .await + .expect("detailed listing after delete"); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].hash, "hash_managed"); + } } diff --git a/packages/oneclient_db/src/dao/browser_package_update.rs b/packages/oneclient_db/src/dao/browser_package_update.rs index e8341b7b..919f8fad 100644 --- a/packages/oneclient_db/src/dao/browser_package_update.rs +++ b/packages/oneclient_db/src/dao/browser_package_update.rs @@ -4,12 +4,12 @@ use sqlx::{QueryBuilder, Sqlite, SqlitePool}; use crate::models::BrowserPackageUpdateRow; pub async fn list_for_cluster( - pool: &SqlitePool, - cluster_id: i64, + pool: &SqlitePool, + cluster_id: i64, ) -> Result, sqlx::Error> { - sqlx::query_as!( - BrowserPackageUpdateRow, - r#" + sqlx::query_as!( + BrowserPackageUpdateRow, + r#" SELECT cluster_id, hash, provider as "provider!: i64", project_id, installed_version_id, installed_version_name, @@ -18,18 +18,16 @@ pub async fn list_for_cluster( FROM browser_package_updates WHERE cluster_id = ? "#, - cluster_id - ) - .fetch_all(pool) - .await + cluster_id + ) + .fetch_all(pool) + .await } -pub async fn list_all( - pool: &SqlitePool, -) -> Result, sqlx::Error> { - sqlx::query_as!( - BrowserPackageUpdateRow, - r#" +pub async fn list_all(pool: &SqlitePool) -> Result, sqlx::Error> { + sqlx::query_as!( + BrowserPackageUpdateRow, + r#" SELECT cluster_id, hash, provider as "provider!: i64", project_id, installed_version_id, installed_version_name, @@ -37,31 +35,31 @@ pub async fn list_all( display_name, checked_at, skipped FROM browser_package_updates "#, - ) - .fetch_all(pool) - .await + ) + .fetch_all(pool) + .await } /// Re-upserting the same `latest_version_id` keeps `skipped` a different one /// resets it since it is a new question for the user #[allow(clippy::too_many_arguments)] pub async fn upsert( - pool: &SqlitePool, - cluster_id: i64, - hash: &str, - provider: i64, - project_id: &str, - installed_version_id: &str, - installed_version_name: &str, - latest_version_id: &str, - latest_version_name: &str, - display_name: &str, + pool: &SqlitePool, + cluster_id: i64, + hash: &str, + provider: i64, + project_id: &str, + installed_version_id: &str, + installed_version_name: &str, + latest_version_id: &str, + latest_version_name: &str, + display_name: &str, ) -> Result { - let checked_at = Utc::now().to_rfc3339(); + let checked_at = Utc::now().to_rfc3339(); - sqlx::query_as!( - BrowserPackageUpdateRow, - r#" + sqlx::query_as!( + BrowserPackageUpdateRow, + r#" INSERT INTO browser_package_updates ( cluster_id, hash, provider, project_id, installed_version_id, installed_version_name, @@ -89,158 +87,160 @@ pub async fn upsert( latest_version_id, latest_version_name, display_name, checked_at, skipped "#, - cluster_id, - hash, - provider, - project_id, - installed_version_id, - installed_version_name, - latest_version_id, - latest_version_name, - display_name, - checked_at - ) - .fetch_one(pool) - .await + cluster_id, + hash, + provider, + project_id, + installed_version_id, + installed_version_name, + latest_version_id, + latest_version_name, + display_name, + checked_at + ) + .fetch_one(pool) + .await } pub async fn set_skipped( - pool: &SqlitePool, - cluster_id: i64, - hash: &str, - skipped: bool, + pool: &SqlitePool, + cluster_id: i64, + hash: &str, + skipped: bool, ) -> Result<(), sqlx::Error> { - let skipped = i64::from(skipped); - sqlx::query!( - r#" + let skipped = i64::from(skipped); + sqlx::query!( + r#" UPDATE browser_package_updates SET skipped = ? WHERE cluster_id = ? AND hash = ? "#, - skipped, - cluster_id, - hash - ) - .execute(pool) - .await?; + skipped, + cluster_id, + hash + ) + .execute(pool) + .await?; - Ok(()) + Ok(()) } pub async fn delete(pool: &SqlitePool, cluster_id: i64, hash: &str) -> Result<(), sqlx::Error> { - sqlx::query!( - "DELETE FROM browser_package_updates WHERE cluster_id = ? AND hash = ?", - cluster_id, - hash - ) - .execute(pool) - .await?; + sqlx::query!( + "DELETE FROM browser_package_updates WHERE cluster_id = ? AND hash = ?", + cluster_id, + hash + ) + .execute(pool) + .await?; - Ok(()) + Ok(()) } /// Drops rows for hashes the latest check did not report an empty list clears /// the cluster pub async fn retain_hashes( - pool: &SqlitePool, - cluster_id: i64, - hashes: &[String], + pool: &SqlitePool, + cluster_id: i64, + hashes: &[String], ) -> Result<(), sqlx::Error> { - if hashes.is_empty() { - sqlx::query!( - "DELETE FROM browser_package_updates WHERE cluster_id = ?", - cluster_id - ) - .execute(pool) - .await?; - return Ok(()); - } + if hashes.is_empty() { + sqlx::query!( + "DELETE FROM browser_package_updates WHERE cluster_id = ?", + cluster_id + ) + .execute(pool) + .await?; + return Ok(()); + } - let mut builder: QueryBuilder = - QueryBuilder::new("DELETE FROM browser_package_updates WHERE cluster_id = "); - builder.push_bind(cluster_id); - builder.push(" AND hash NOT IN ("); - let mut separated = builder.separated(", "); - for hash in hashes { - separated.push_bind(hash); - } - separated.push_unseparated(")"); + let mut builder: QueryBuilder = + QueryBuilder::new("DELETE FROM browser_package_updates WHERE cluster_id = "); + builder.push_bind(cluster_id); + builder.push(" AND hash NOT IN ("); + let mut separated = builder.separated(", "); + for hash in hashes { + separated.push_bind(hash); + } + separated.push_unseparated(")"); - builder.build().execute(pool).await?; + builder.build().execute(pool).await?; - Ok(()) + Ok(()) } #[cfg(test)] mod tests { - use super::*; + use super::*; - async fn pool() -> SqlitePool { - let pool = SqlitePool::connect("sqlite::memory:") - .await - .expect("in-memory sqlite"); - sqlx::migrate!().run(&pool).await.expect("migrations run"); - // Runtime query not the macro the offline cache is library-only - sqlx::query( - r#" + async fn pool() -> SqlitePool { + let pool = SqlitePool::connect("sqlite::memory:") + .await + .expect("in-memory sqlite"); + sqlx::migrate!().run(&pool).await.expect("migrations run"); + // Runtime query not the macro the offline cache is library-only + sqlx::query( + r#" INSERT INTO clusters (id, name, folder_name, mc_version) VALUES (1, 'test', 'test', '1.21.4') "#, - ) - .execute(&pool) - .await - .expect("cluster row"); - pool - } + ) + .execute(&pool) + .await + .expect("cluster row"); + pool + } - async fn record(pool: &SqlitePool, hash: &str, latest: &str) -> BrowserPackageUpdateRow { - upsert(pool, 1, hash, 0, "proj", "v1", "1.0.0", latest, "2.0.0", "Mod") - .await - .expect("upsert") - } + async fn record(pool: &SqlitePool, hash: &str, latest: &str) -> BrowserPackageUpdateRow { + upsert( + pool, 1, hash, 0, "proj", "v1", "1.0.0", latest, "2.0.0", "Mod", + ) + .await + .expect("upsert") + } - #[tokio::test] - async fn skip_survives_a_recheck_of_the_same_version() { - let pool = pool().await; - record(&pool, "abc", "v2").await; - set_skipped(&pool, 1, "abc", true).await.expect("skip"); + #[tokio::test] + async fn skip_survives_a_recheck_of_the_same_version() { + let pool = pool().await; + record(&pool, "abc", "v2").await; + set_skipped(&pool, 1, "abc", true).await.expect("skip"); - let row = record(&pool, "abc", "v2").await; - assert_eq!(row.skipped, 1, "re-running the check must not re-ask"); - } + let row = record(&pool, "abc", "v2").await; + assert_eq!(row.skipped, 1, "re-running the check must not re-ask"); + } - #[tokio::test] - async fn a_newer_version_asks_again() { - let pool = pool().await; - record(&pool, "abc", "v2").await; - set_skipped(&pool, 1, "abc", true).await.expect("skip"); + #[tokio::test] + async fn a_newer_version_asks_again() { + let pool = pool().await; + record(&pool, "abc", "v2").await; + set_skipped(&pool, 1, "abc", true).await.expect("skip"); - let row = record(&pool, "abc", "v3").await; - assert_eq!(row.skipped, 0, "a different version is a new question"); - } + let row = record(&pool, "abc", "v3").await; + assert_eq!(row.skipped, 0, "a different version is a new question"); + } - #[tokio::test] - async fn retain_drops_hashes_the_check_no_longer_reports() { - let pool = pool().await; - record(&pool, "abc", "v2").await; - record(&pool, "def", "v2").await; + #[tokio::test] + async fn retain_drops_hashes_the_check_no_longer_reports() { + let pool = pool().await; + record(&pool, "abc", "v2").await; + record(&pool, "def", "v2").await; - retain_hashes(&pool, 1, &["abc".to_string()]) - .await - .expect("retain"); + retain_hashes(&pool, 1, &["abc".to_string()]) + .await + .expect("retain"); - let rows = list_for_cluster(&pool, 1).await.expect("list"); - assert_eq!(rows.len(), 1); - assert_eq!(rows[0].hash, "abc"); - } + let rows = list_for_cluster(&pool, 1).await.expect("list"); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].hash, "abc"); + } - #[tokio::test] - async fn retain_with_nothing_clears_the_cluster() { - let pool = pool().await; - record(&pool, "abc", "v2").await; + #[tokio::test] + async fn retain_with_nothing_clears_the_cluster() { + let pool = pool().await; + record(&pool, "abc", "v2").await; - retain_hashes(&pool, 1, &[]).await.expect("retain"); + retain_hashes(&pool, 1, &[]).await.expect("retain"); - assert!(list_for_cluster(&pool, 1).await.expect("list").is_empty()); - } + assert!(list_for_cluster(&pool, 1).await.expect("list").is_empty()); + } } diff --git a/packages/oneclient_db/src/dao/cluster.rs b/packages/oneclient_db/src/dao/cluster.rs index 48d770e0..b2f84eba 100644 --- a/packages/oneclient_db/src/dao/cluster.rs +++ b/packages/oneclient_db/src/dao/cluster.rs @@ -4,63 +4,63 @@ use sqlx::SqlitePool; use crate::models::{ClusterPatch, ClusterRow, NewCluster}; pub async fn get_by_id(pool: &SqlitePool, id: i64) -> Result, sqlx::Error> { - sqlx::query_as!( - ClusterRow, - r#" + sqlx::query_as!( + ClusterRow, + r#" SELECT id, name, folder_name, setting_profile_name, mc_version, mc_loader, stage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash FROM clusters WHERE id = ? "#, - id - ) - .fetch_optional(pool) - .await + id + ) + .fetch_optional(pool) + .await } pub async fn get_by_folder_name( - pool: &SqlitePool, - folder_name: &str, + pool: &SqlitePool, + folder_name: &str, ) -> Result, sqlx::Error> { - sqlx::query_as!( - ClusterRow, - r#" + sqlx::query_as!( + ClusterRow, + r#" SELECT id, name, folder_name, setting_profile_name, mc_version, mc_loader, stage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash FROM clusters WHERE folder_name = ? "#, - folder_name - ) - .fetch_optional(pool) - .await + folder_name + ) + .fetch_optional(pool) + .await } pub async fn list_all(pool: &SqlitePool) -> Result, sqlx::Error> { - sqlx::query_as!( - ClusterRow, - r#" + sqlx::query_as!( + ClusterRow, + r#" SELECT id, name, folder_name, setting_profile_name, mc_version, mc_loader, stage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash FROM clusters ORDER BY last_played IS NULL, last_played DESC, name ASC "# - ) - .fetch_all(pool) - .await + ) + .fetch_all(pool) + .await } pub async fn find_by_version_loader( - pool: &SqlitePool, - mc_version: &str, - mc_loader: i64, + pool: &SqlitePool, + mc_version: &str, + mc_loader: i64, ) -> Result, sqlx::Error> { - sqlx::query_as!( - ClusterRow, - r#" + sqlx::query_as!( + ClusterRow, + r#" SELECT id, name, folder_name, setting_profile_name, mc_version, mc_loader, stage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash @@ -68,19 +68,19 @@ pub async fn find_by_version_loader( WHERE mc_version = ? AND mc_loader = ? LIMIT 1 "#, - mc_version, - mc_loader - ) - .fetch_optional(pool) - .await + mc_version, + mc_loader + ) + .fetch_optional(pool) + .await } pub async fn insert(pool: &SqlitePool, new: &NewCluster<'_>) -> Result { - let created_at = Utc::now().to_rfc3339(); + let created_at = Utc::now().to_rfc3339(); - sqlx::query_as!( - ClusterRow, - r#" + sqlx::query_as!( + ClusterRow, + r#" INSERT INTO clusters ( name, folder_name, mc_version, mc_loader, mc_loader_version, setting_profile_name, stage, created_at @@ -90,45 +90,43 @@ pub async fn insert(pool: &SqlitePool, new: &NewCluster<'_>) -> Result Result { - let existing = get_by_id(pool, id) - .await? - .ok_or(sqlx::Error::RowNotFound)?; + let existing = get_by_id(pool, id).await?.ok_or(sqlx::Error::RowNotFound)?; - let name = patch.name.as_deref().unwrap_or(&existing.name); - let setting_profile_name = patch - .setting_profile_name - .clone() - .unwrap_or(existing.setting_profile_name); - let mc_loader_version = patch - .mc_loader_version - .clone() - .unwrap_or(existing.mc_loader_version); - let linked_modpack_hash = patch - .linked_modpack_hash - .clone() - .unwrap_or(existing.linked_modpack_hash); + let name = patch.name.as_deref().unwrap_or(&existing.name); + let setting_profile_name = patch + .setting_profile_name + .clone() + .unwrap_or(existing.setting_profile_name); + let mc_loader_version = patch + .mc_loader_version + .clone() + .unwrap_or(existing.mc_loader_version); + let linked_modpack_hash = patch + .linked_modpack_hash + .clone() + .unwrap_or(existing.linked_modpack_hash); - sqlx::query_as!( - ClusterRow, - r#" + sqlx::query_as!( + ClusterRow, + r#" UPDATE clusters SET name = ?, setting_profile_name = ?, @@ -139,32 +137,30 @@ pub async fn update( id, name, folder_name, setting_profile_name, mc_version, mc_loader, stage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash "#, - name, - setting_profile_name, - mc_loader_version, - linked_modpack_hash, - id - ) - .fetch_one(pool) - .await + name, + setting_profile_name, + mc_loader_version, + linked_modpack_hash, + id + ) + .fetch_one(pool) + .await } pub async fn migrate_version( - pool: &SqlitePool, - id: i64, - mc_version: &str, - name: Option<&str>, - folder_name: &str, + pool: &SqlitePool, + id: i64, + mc_version: &str, + name: Option<&str>, + folder_name: &str, ) -> Result { - let existing = get_by_id(pool, id) - .await? - .ok_or(sqlx::Error::RowNotFound)?; + let existing = get_by_id(pool, id).await?.ok_or(sqlx::Error::RowNotFound)?; - let name = name.unwrap_or(&existing.name); + let name = name.unwrap_or(&existing.name); - sqlx::query_as!( - ClusterRow, - r#" + sqlx::query_as!( + ClusterRow, + r#" UPDATE clusters SET mc_version = ?, name = ?, @@ -174,19 +170,19 @@ pub async fn migrate_version( id, name, folder_name, setting_profile_name, mc_version, mc_loader, stage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash "#, - mc_version, - name, - folder_name, - id - ) - .fetch_one(pool) - .await + mc_version, + name, + folder_name, + id + ) + .fetch_one(pool) + .await } pub async fn set_stage(pool: &SqlitePool, id: i64, stage: i64) -> Result { - sqlx::query_as!( - ClusterRow, - r#" + sqlx::query_as!( + ClusterRow, + r#" UPDATE clusters SET stage = ? WHERE id = ? @@ -194,23 +190,23 @@ pub async fn set_stage(pool: &SqlitePool, id: i64, stage: i64) -> Result Result { - let now = Utc::now().to_rfc3339(); + let now = Utc::now().to_rfc3339(); - sqlx::query_as!( - ClusterRow, - r#" + sqlx::query_as!( + ClusterRow, + r#" UPDATE clusters SET overall_played = COALESCE(overall_played, 0) + ?, last_played = ? @@ -219,117 +215,137 @@ pub async fn add_playtime( id, name, folder_name, setting_profile_name, mc_version, mc_loader, stage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash "#, - seconds, - now, - id - ) - .fetch_one(pool) - .await + seconds, + now, + id + ) + .fetch_one(pool) + .await } pub async fn delete_by_id(pool: &SqlitePool, id: i64) -> Result { - let result = sqlx::query!("DELETE FROM clusters WHERE id = ?", id) - .execute(pool) - .await?; + let result = sqlx::query!("DELETE FROM clusters WHERE id = ?", id) + .execute(pool) + .await?; - Ok(result.rows_affected() > 0) + Ok(result.rows_affected() > 0) } #[cfg(test)] mod tests { - use super::*; - use crate::dao::applied_migration; + use super::*; + use crate::dao::applied_migration; - async fn pool() -> SqlitePool { - let pool = SqlitePool::connect("sqlite::memory:") - .await - .expect("in-memory sqlite"); - sqlx::migrate!().run(&pool).await.expect("migrations run"); - pool - } + async fn pool() -> SqlitePool { + let pool = SqlitePool::connect("sqlite::memory:") + .await + .expect("in-memory sqlite"); + sqlx::migrate!().run(&pool).await.expect("migrations run"); + pool + } - async fn seed(pool: &SqlitePool, mc_version: &str, folder: &str) -> ClusterRow { - insert( - pool, - &NewCluster { - name: &format!("{mc_version} fabric"), - folder_name: folder, - mc_version, - mc_loader: 1, - mc_loader_version: None, - setting_profile_name: None, - stage: 0, - }, - ) - .await - .expect("insert cluster") - } + async fn seed(pool: &SqlitePool, mc_version: &str, folder: &str) -> ClusterRow { + insert( + pool, + &NewCluster { + name: &format!("{mc_version} fabric"), + folder_name: folder, + mc_version, + mc_loader: 1, + mc_loader_version: None, + setting_profile_name: None, + stage: 0, + }, + ) + .await + .expect("insert cluster") + } - #[tokio::test] - async fn migrate_version_rewrites_identity_and_keeps_history() { - let pool = pool().await; - let cluster = seed(&pool, "26.1", "26.1 fabric").await; - add_playtime(&pool, cluster.id, 3600) - .await - .expect("record playtime"); + #[tokio::test] + async fn migrate_version_rewrites_identity_and_keeps_history() { + let pool = pool().await; + let cluster = seed(&pool, "26.1", "26.1 fabric").await; + add_playtime(&pool, cluster.id, 3600) + .await + .expect("record playtime"); - let migrated = migrate_version( - &pool, - cluster.id, - "26.1.2", - Some("26.1.2 fabric"), - "26.1.2 fabric", - ) - .await - .expect("migrate"); + let migrated = migrate_version( + &pool, + cluster.id, + "26.1.2", + Some("26.1.2 fabric"), + "26.1.2 fabric", + ) + .await + .expect("migrate"); - assert_eq!(migrated.id, cluster.id, "must move the row, not replace it"); - assert_eq!(migrated.mc_version, "26.1.2"); - assert_eq!(migrated.folder_name, "26.1.2 fabric"); - assert_eq!(migrated.name, "26.1.2 fabric"); - assert_eq!(migrated.overall_played, Some(3600)); - assert_eq!(migrated.created_at, cluster.created_at); + assert_eq!(migrated.id, cluster.id, "must move the row, not replace it"); + assert_eq!(migrated.mc_version, "26.1.2"); + assert_eq!(migrated.folder_name, "26.1.2 fabric"); + assert_eq!(migrated.name, "26.1.2 fabric"); + assert_eq!(migrated.overall_played, Some(3600)); + assert_eq!(migrated.created_at, cluster.created_at); - assert!( - find_by_version_loader(&pool, "26.1", 1) - .await - .unwrap() - .is_none() - ); - assert_eq!( - find_by_version_loader(&pool, "26.1.2", 1) - .await - .unwrap() - .unwrap() - .id, - cluster.id - ); - } + assert!( + find_by_version_loader(&pool, "26.1", 1) + .await + .unwrap() + .is_none() + ); + assert_eq!( + find_by_version_loader(&pool, "26.1.2", 1) + .await + .unwrap() + .unwrap() + .id, + cluster.id + ); + } - #[tokio::test] - async fn migrate_version_can_leave_name_and_folder_untouched() { - let pool = pool().await; - let cluster = seed(&pool, "26.1", "my cool pack").await; + #[tokio::test] + async fn migrate_version_can_leave_name_and_folder_untouched() { + let pool = pool().await; + let cluster = seed(&pool, "26.1", "my cool pack").await; - let migrated = migrate_version(&pool, cluster.id, "26.1.2", None, "my cool pack") - .await - .expect("migrate"); + let migrated = migrate_version(&pool, cluster.id, "26.1.2", None, "my cool pack") + .await + .expect("migrate"); - assert_eq!(migrated.mc_version, "26.1.2"); - assert_eq!(migrated.name, "26.1 fabric", "custom name must survive"); - assert_eq!(migrated.folder_name, "my cool pack"); - } + assert_eq!(migrated.mc_version, "26.1.2"); + assert_eq!(migrated.name, "26.1 fabric", "custom name must survive"); + assert_eq!(migrated.folder_name, "my cool pack"); + } - #[tokio::test] - async fn applied_migrations_ledger_is_idempotent() { - let pool = pool().await; + #[tokio::test] + async fn applied_migrations_ledger_is_idempotent() { + let pool = pool().await; - assert!(!applied_migration::is_applied(&pool, "rule-a").await.unwrap()); - applied_migration::mark_applied(&pool, "rule-a").await.unwrap(); - assert!(applied_migration::is_applied(&pool, "rule-a").await.unwrap()); + assert!( + !applied_migration::is_applied(&pool, "rule-a") + .await + .unwrap() + ); + applied_migration::mark_applied(&pool, "rule-a") + .await + .unwrap(); + assert!( + applied_migration::is_applied(&pool, "rule-a") + .await + .unwrap() + ); - applied_migration::mark_applied(&pool, "rule-a").await.unwrap(); - assert!(applied_migration::is_applied(&pool, "rule-a").await.unwrap()); - assert!(!applied_migration::is_applied(&pool, "rule-b").await.unwrap()); - } + applied_migration::mark_applied(&pool, "rule-a") + .await + .unwrap(); + assert!( + applied_migration::is_applied(&pool, "rule-a") + .await + .unwrap() + ); + assert!( + !applied_migration::is_applied(&pool, "rule-b") + .await + .unwrap() + ); + } } diff --git a/packages/oneclient_db/src/dao/game_session.rs b/packages/oneclient_db/src/dao/game_session.rs index 55557b20..4c179d75 100644 --- a/packages/oneclient_db/src/dao/game_session.rs +++ b/packages/oneclient_db/src/dao/game_session.rs @@ -2,17 +2,17 @@ use chrono::Utc; use sqlx::SqlitePool; use crate::models::{ - GameSessionRow, GameSessionServerRow, NewGameSession, ServerJoinCount, SessionSpan, - UnfinishedSession, + GameSessionRow, GameSessionServerRow, NewGameSession, ServerJoinCount, SessionSpan, + UnfinishedSession, }; pub async fn insert_session( - pool: &SqlitePool, - new: &NewGameSession<'_>, + pool: &SqlitePool, + new: &NewGameSession<'_>, ) -> Result { - sqlx::query_as!( - GameSessionRow, - r#" + sqlx::query_as!( + GameSessionRow, + r#" INSERT INTO game_sessions ( cluster_id, started_at, ram_allocated_mb, mods_enabled, java_vendor, java_version ) @@ -21,143 +21,150 @@ pub async fn insert_session( cluster_id, started_at, ended_at, exit_code, ram_allocated_mb, mods_enabled, java_vendor, java_version "#, - new.cluster_id, - new.started_at, - new.ram_allocated_mb, - new.mods_enabled, - new.java_vendor, - new.java_version - ) - .fetch_one(pool) - .await + new.cluster_id, + new.started_at, + new.ram_allocated_mb, + new.mods_enabled, + new.java_vendor, + new.java_version + ) + .fetch_one(pool) + .await } pub async fn finish_session( - pool: &SqlitePool, - started_at: &str, - exit_code: Option, + pool: &SqlitePool, + started_at: &str, + exit_code: Option, ) -> Result<(), sqlx::Error> { - finish_session_at(pool, started_at, &Utc::now().to_rfc3339(), exit_code).await + finish_session_at(pool, started_at, &Utc::now().to_rfc3339(), exit_code).await } /// For exit times inferred from the game's log rather than observed live pub async fn finish_session_at( - pool: &SqlitePool, - started_at: &str, - ended_at: &str, - exit_code: Option, + pool: &SqlitePool, + started_at: &str, + ended_at: &str, + exit_code: Option, ) -> Result<(), sqlx::Error> { - sqlx::query!( - r#" + sqlx::query!( + r#" UPDATE game_sessions SET ended_at = ?, exit_code = ? WHERE started_at = ? AND ended_at IS NULL "#, - ended_at, - exit_code, - started_at - ) - .execute(pool) - .await?; - Ok(()) + ended_at, + exit_code, + started_at + ) + .execute(pool) + .await?; + Ok(()) } pub async fn set_session_process( - pool: &SqlitePool, - started_at: &str, - pid: Option, - pid_started_at: Option, + pool: &SqlitePool, + started_at: &str, + pid: Option, + pid_started_at: Option, ) -> Result<(), sqlx::Error> { - sqlx::query!( - r#" + sqlx::query!( + r#" UPDATE game_sessions SET pid = ?, pid_started_at = ? WHERE started_at = ? "#, - pid, - pid_started_at, - started_at - ) - .execute(pool) - .await?; - Ok(()) + pid, + pid_started_at, + started_at + ) + .execute(pool) + .await?; + Ok(()) } pub async fn unfinished_sessions(pool: &SqlitePool) -> Result, sqlx::Error> { - sqlx::query_as!( - UnfinishedSession, - r#" + sqlx::query_as!( + UnfinishedSession, + r#" SELECT cluster_id, started_at, pid, pid_started_at FROM game_sessions WHERE ended_at IS NULL ORDER BY started_at ASC "# - ) - .fetch_all(pool) - .await + ) + .fetch_all(pool) + .await } pub async fn insert_server_join( - pool: &SqlitePool, - session_started_at: &str, - address: &str, - port: Option, + pool: &SqlitePool, + session_started_at: &str, + address: &str, + port: Option, ) -> Result { - insert_server_join_at(pool, session_started_at, address, port, &Utc::now().to_rfc3339()).await + insert_server_join_at( + pool, + session_started_at, + address, + port, + &Utc::now().to_rfc3339(), + ) + .await } pub async fn insert_server_join_at( - pool: &SqlitePool, - session_started_at: &str, - address: &str, - port: Option, - joined_at: &str, + pool: &SqlitePool, + session_started_at: &str, + address: &str, + port: Option, + joined_at: &str, ) -> Result { - sqlx::query!( - r#" + sqlx::query!( + r#" INSERT INTO game_session_servers (session_started_at, address, port, joined_at) VALUES (?, ?, ?, ?) "#, - session_started_at, - address, - port, - joined_at - ) - .execute(pool) - .await?; - Ok(joined_at.to_string()) + session_started_at, + address, + port, + joined_at + ) + .execute(pool) + .await?; + Ok(joined_at.to_string()) } pub async fn finish_server(pool: &SqlitePool, joined_at: &str) -> Result<(), sqlx::Error> { - finish_server_at(pool, joined_at, &Utc::now().to_rfc3339()).await + finish_server_at(pool, joined_at, &Utc::now().to_rfc3339()).await } pub async fn finish_server_at( - pool: &SqlitePool, - joined_at: &str, - disconnected_at: &str, + pool: &SqlitePool, + joined_at: &str, + disconnected_at: &str, ) -> Result<(), sqlx::Error> { - sqlx::query!( - r#" + sqlx::query!( + r#" UPDATE game_session_servers SET disconnected_at = ? WHERE joined_at = ? AND disconnected_at IS NULL "#, - disconnected_at, - joined_at - ) - .execute(pool) - .await?; - Ok(()) + disconnected_at, + joined_at + ) + .execute(pool) + .await?; + Ok(()) } pub async fn list_sessions_for_cluster( - pool: &SqlitePool, - cluster_id: i64, + pool: &SqlitePool, + cluster_id: i64, ) -> Result, sqlx::Error> { - sqlx::query_as!( - GameSessionRow, - r#" + sqlx::query_as!( + GameSessionRow, + r#" SELECT cluster_id, started_at, ended_at, exit_code, ram_allocated_mb, mods_enabled, java_vendor, java_version @@ -165,54 +172,54 @@ pub async fn list_sessions_for_cluster( WHERE cluster_id = ? ORDER BY started_at DESC "#, - cluster_id - ) - .fetch_all(pool) - .await + cluster_id + ) + .fetch_all(pool) + .await } pub async fn list_session_servers( - pool: &SqlitePool, - session_started_at: &str, + pool: &SqlitePool, + session_started_at: &str, ) -> Result, sqlx::Error> { - sqlx::query_as!( - GameSessionServerRow, - r#" + sqlx::query_as!( + GameSessionServerRow, + r#" SELECT session_started_at, address, port, joined_at, disconnected_at FROM game_session_servers WHERE session_started_at = ? ORDER BY joined_at ASC "#, - session_started_at - ) - .fetch_all(pool) - .await + session_started_at + ) + .fetch_all(pool) + .await } /// Lets a log replay rewrite a session's server spans from scratch pub async fn delete_session_servers( - pool: &SqlitePool, - session_started_at: &str, + pool: &SqlitePool, + session_started_at: &str, ) -> Result<(), sqlx::Error> { - sqlx::query!( - r#" + sqlx::query!( + r#" DELETE FROM game_session_servers WHERE session_started_at = ? "#, - session_started_at - ) - .execute(pool) - .await?; - Ok(()) + session_started_at + ) + .execute(pool) + .await?; + Ok(()) } pub async fn server_join_counts( - pool: &SqlitePool, - cluster_id: i64, + pool: &SqlitePool, + cluster_id: i64, ) -> Result, sqlx::Error> { - sqlx::query_as!( - ServerJoinCount, - r#" + sqlx::query_as!( + ServerJoinCount, + r#" SELECT s.address AS "address!", COUNT(*) AS "joins!: i64" FROM game_session_servers s JOIN game_sessions g ON g.started_at = s.session_started_at @@ -220,74 +227,74 @@ pub async fn server_join_counts( GROUP BY s.address ORDER BY COUNT(*) DESC "#, - cluster_id - ) - .fetch_all(pool) - .await + cluster_id + ) + .fetch_all(pool) + .await } pub async fn all_session_spans(pool: &SqlitePool) -> Result, sqlx::Error> { - sqlx::query_as!( - SessionSpan, - r#" + sqlx::query_as!( + SessionSpan, + r#" SELECT started_at, ended_at FROM game_sessions WHERE ended_at IS NOT NULL ORDER BY started_at ASC "# - ) - .fetch_all(pool) - .await + ) + .fetch_all(pool) + .await } pub async fn session_spans_for_cluster( - pool: &SqlitePool, - cluster_id: i64, + pool: &SqlitePool, + cluster_id: i64, ) -> Result, sqlx::Error> { - sqlx::query_as!( - SessionSpan, - r#" + sqlx::query_as!( + SessionSpan, + r#" SELECT started_at, ended_at FROM game_sessions WHERE cluster_id = ? AND ended_at IS NOT NULL ORDER BY started_at ASC "#, - cluster_id - ) - .fetch_all(pool) - .await + cluster_id + ) + .fetch_all(pool) + .await } pub async fn all_session_servers( - pool: &SqlitePool, + pool: &SqlitePool, ) -> Result, sqlx::Error> { - sqlx::query_as!( - GameSessionServerRow, - r#" + sqlx::query_as!( + GameSessionServerRow, + r#" SELECT session_started_at, address, port, joined_at, disconnected_at FROM game_session_servers ORDER BY joined_at ASC "# - ) - .fetch_all(pool) - .await + ) + .fetch_all(pool) + .await } pub async fn session_servers_for_cluster( - pool: &SqlitePool, - cluster_id: i64, + pool: &SqlitePool, + cluster_id: i64, ) -> Result, sqlx::Error> { - sqlx::query_as!( - GameSessionServerRow, - r#" + sqlx::query_as!( + GameSessionServerRow, + r#" SELECT s.session_started_at, s.address, s.port, s.joined_at, s.disconnected_at FROM game_session_servers s JOIN game_sessions g ON g.started_at = s.session_started_at WHERE g.cluster_id = ? ORDER BY s.joined_at ASC "#, - cluster_id - ) - .fetch_all(pool) - .await + cluster_id + ) + .fetch_all(pool) + .await } diff --git a/packages/oneclient_db/src/dao/package_metadata.rs b/packages/oneclient_db/src/dao/package_metadata.rs index 3fb45b22..34210171 100644 --- a/packages/oneclient_db/src/dao/package_metadata.rs +++ b/packages/oneclient_db/src/dao/package_metadata.rs @@ -3,43 +3,43 @@ use sqlx::{QueryBuilder, Sqlite, SqlitePool}; use crate::models::PackageMetadataRow; pub async fn get_package_metadata_batch( - pool: &SqlitePool, - provider: i64, - project_ids: &[String], + pool: &SqlitePool, + provider: i64, + project_ids: &[String], ) -> Result, sqlx::Error> { - if project_ids.is_empty() { - return Ok(Vec::new()); - } + if project_ids.is_empty() { + return Ok(Vec::new()); + } - let mut builder: QueryBuilder = QueryBuilder::new( - "SELECT provider, project_id, name, summary, author, icon_url \ + let mut builder: QueryBuilder = QueryBuilder::new( + "SELECT provider, project_id, name, summary, author, icon_url \ FROM package_metadata WHERE provider = ", - ); - builder.push_bind(provider); - builder.push(" AND project_id IN ("); - let mut separated = builder.separated(", "); - for id in project_ids { - separated.push_bind(id); - } - separated.push_unseparated(")"); + ); + builder.push_bind(provider); + builder.push(" AND project_id IN ("); + let mut separated = builder.separated(", "); + for id in project_ids { + separated.push_bind(id); + } + separated.push_unseparated(")"); - builder - .build_query_as::() - .fetch_all(pool) - .await + builder + .build_query_as::() + .fetch_all(pool) + .await } pub async fn upsert_package_metadata( - pool: &SqlitePool, - provider: i64, - project_id: &str, - name: &str, - summary: &str, - author: &str, - icon_url: Option<&str>, + pool: &SqlitePool, + provider: i64, + project_id: &str, + name: &str, + summary: &str, + author: &str, + icon_url: Option<&str>, ) -> Result<(), sqlx::Error> { - sqlx::query!( - r#" + sqlx::query!( + r#" INSERT INTO package_metadata (provider, project_id, name, summary, author, icon_url, updated_at) VALUES (?, ?, ?, ?, ?, ?, datetime('now')) ON CONFLICT(provider, project_id) DO UPDATE SET @@ -49,15 +49,15 @@ pub async fn upsert_package_metadata( icon_url = excluded.icon_url, updated_at = excluded.updated_at "#, - provider, - project_id, - name, - summary, - author, - icon_url - ) - .execute(pool) - .await?; + provider, + project_id, + name, + summary, + author, + icon_url + ) + .execute(pool) + .await?; - Ok(()) + Ok(()) } diff --git a/packages/oneclient_db/src/error.rs b/packages/oneclient_db/src/error.rs index 8c66cae2..19278210 100644 --- a/packages/oneclient_db/src/error.rs +++ b/packages/oneclient_db/src/error.rs @@ -2,20 +2,20 @@ use thiserror::Error; #[derive(Debug, Error)] pub enum DbError { - #[error(transparent)] - Io(#[from] std::io::Error), + #[error(transparent)] + Io(#[from] std::io::Error), - #[error(transparent)] - Sqlx(#[from] sqlx::Error), + #[error(transparent)] + Sqlx(#[from] sqlx::Error), - #[error(transparent)] - Migrate(#[from] sqlx::migrate::MigrateError), + #[error(transparent)] + Migrate(#[from] sqlx::migrate::MigrateError), - #[error("record not found")] - NotFound, + #[error("record not found")] + NotFound, - #[error("invalid value for {field}: {value}")] - InvalidValue { field: String, value: String }, + #[error("invalid value for {field}: {value}")] + InvalidValue { field: String, value: String }, } pub(crate) type DbResult = Result; diff --git a/packages/oneclient_db/src/lib.rs b/packages/oneclient_db/src/lib.rs index b56b5a58..ff5e44ab 100644 --- a/packages/oneclient_db/src/lib.rs +++ b/packages/oneclient_db/src/lib.rs @@ -8,4 +8,4 @@ pub mod dao; pub mod models; pub use error::DbError; -pub use pool::{connect, DbPool}; +pub use pool::{DbPool, connect}; diff --git a/packages/oneclient_db/src/migrate.rs b/packages/oneclient_db/src/migrate.rs index abc6f213..8bb88a43 100644 --- a/packages/oneclient_db/src/migrate.rs +++ b/packages/oneclient_db/src/migrate.rs @@ -69,12 +69,13 @@ mod tests { async fn migrates_a_fresh_database() { let (_dir, _path, pool) = migrated().await; - let column: Option = - sqlx::query_scalar("SELECT 1 FROM pragma_table_info('setting_profiles') WHERE name = ?") - .bind("browser_update_mode") - .fetch_optional(&pool) - .await - .expect("probe"); + let column: Option = sqlx::query_scalar( + "SELECT 1 FROM pragma_table_info('setting_profiles') WHERE name = ?", + ) + .bind("browser_update_mode") + .fetch_optional(&pool) + .await + .expect("probe"); assert!(column.is_some()); } @@ -99,7 +100,9 @@ mod tests { .await .expect("record a migration from the future"); - run(&pool, &path).await.expect("a downgrade must still start"); + run(&pool, &path) + .await + .expect("a downgrade must still start"); } #[tokio::test] diff --git a/packages/oneclient_db/src/models/artifact.rs b/packages/oneclient_db/src/models/artifact.rs index ebcfdd25..a48297a5 100644 --- a/packages/oneclient_db/src/models/artifact.rs +++ b/packages/oneclient_db/src/models/artifact.rs @@ -3,85 +3,85 @@ use sqlx::FromRow; #[derive(Debug, Clone, FromRow)] pub struct ArtifactRow { - pub hash: String, - pub content_type: i64, - pub path: String, - pub file_name: String, - pub size_bytes: Option, + pub hash: String, + pub content_type: i64, + pub path: String, + pub file_name: String, + pub size_bytes: Option, } #[derive(Debug, Clone, FromRow)] pub struct ProviderReleaseRow { - pub provider: i64, - pub project_id: String, - pub version_id: String, - pub hash: String, - pub display_name: String, - pub display_version: String, - pub published_at: Option, - pub mc_versions: String, - pub mc_loaders: String, + pub provider: i64, + pub project_id: String, + pub version_id: String, + pub hash: String, + pub display_name: String, + pub display_version: String, + pub published_at: Option, + pub mc_versions: String, + pub mc_loaders: String, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] #[repr(i64)] pub enum SeenStatus { - New = 0, - Updated = 1, - #[default] - Seen = 2, + New = 0, + Updated = 1, + #[default] + Seen = 2, } impl SeenStatus { - pub fn as_i64(self) -> i64 { - self as i64 - } + pub fn as_i64(self) -> i64 { + self as i64 + } - pub fn from_repr(value: i64) -> Option { - match value { - 0 => Some(Self::New), - 1 => Some(Self::Updated), - 2 => Some(Self::Seen), - _ => None, - } - } + pub fn from_repr(value: i64) -> Option { + match value { + 0 => Some(Self::New), + 1 => Some(Self::Updated), + 2 => Some(Self::Seen), + _ => None, + } + } } #[derive(Debug, Clone, FromRow)] pub struct ClusterArtifactRow { - pub cluster_id: i64, - pub hash: String, - pub cluster_file_name: String, - pub enabled: i64, - pub seen_status: i64, + pub cluster_id: i64, + pub hash: String, + pub cluster_file_name: String, + pub enabled: i64, + pub seen_status: i64, } impl ClusterArtifactRow { - pub fn status(&self) -> SeenStatus { - SeenStatus::from_repr(self.seen_status).unwrap_or_default() - } + pub fn status(&self) -> SeenStatus { + SeenStatus::from_repr(self.seen_status).unwrap_or_default() + } } #[derive(Debug, Clone, FromRow)] pub struct LinkedArtifactRow { - pub hash: String, - pub cluster_file_name: String, - pub enabled: i64, - pub seen_status: i64, - pub content_type: i64, - pub file_name: String, - pub provider: Option, - pub project_id: Option, - pub version_id: Option, - pub display_name: Option, - pub display_version: Option, - pub published_at: Option, + pub hash: String, + pub cluster_file_name: String, + pub enabled: i64, + pub seen_status: i64, + pub content_type: i64, + pub file_name: String, + pub provider: Option, + pub project_id: Option, + pub version_id: Option, + pub display_name: Option, + pub display_version: Option, + pub published_at: Option, } // used for resourcepacks and shaders #[derive(Debug, Clone, FromRow)] pub struct GlobalArtifactRow { - pub hash: String, - pub file_name: String, - pub enabled: i64, + pub hash: String, + pub file_name: String, + pub enabled: i64, } diff --git a/packages/oneclient_db/src/models/browser_package_update.rs b/packages/oneclient_db/src/models/browser_package_update.rs index 24ca147f..7fb36a50 100644 --- a/packages/oneclient_db/src/models/browser_package_update.rs +++ b/packages/oneclient_db/src/models/browser_package_update.rs @@ -4,15 +4,15 @@ use sqlx::FromRow; /// be attached to a file the cluster no longer has #[derive(Debug, Clone, FromRow)] pub struct BrowserPackageUpdateRow { - pub cluster_id: i64, - pub hash: String, - pub provider: i64, - pub project_id: String, - pub installed_version_id: String, - pub installed_version_name: String, - pub latest_version_id: String, - pub latest_version_name: String, - pub display_name: String, - pub checked_at: String, - pub skipped: i64, + pub cluster_id: i64, + pub hash: String, + pub provider: i64, + pub project_id: String, + pub installed_version_id: String, + pub installed_version_name: String, + pub latest_version_id: String, + pub latest_version_name: String, + pub display_name: String, + pub checked_at: String, + pub skipped: i64, } diff --git a/packages/oneclient_db/src/models/cluster.rs b/packages/oneclient_db/src/models/cluster.rs index 32b1b077..0a7d07c8 100644 --- a/packages/oneclient_db/src/models/cluster.rs +++ b/packages/oneclient_db/src/models/cluster.rs @@ -4,34 +4,34 @@ pub type ClusterId = i64; #[derive(Debug, Clone, FromRow)] pub struct ClusterRow { - pub id: ClusterId, - pub name: String, - pub folder_name: String, - pub setting_profile_name: Option, - pub mc_version: String, - pub mc_loader: i64, - pub stage: i64, - pub mc_loader_version: Option, - pub created_at: Option, - pub last_played: Option, - pub overall_played: Option, - pub linked_modpack_hash: Option, + pub id: ClusterId, + pub name: String, + pub folder_name: String, + pub setting_profile_name: Option, + pub mc_version: String, + pub mc_loader: i64, + pub stage: i64, + pub mc_loader_version: Option, + pub created_at: Option, + pub last_played: Option, + pub overall_played: Option, + pub linked_modpack_hash: Option, } pub struct NewCluster<'a> { - pub name: &'a str, - pub folder_name: &'a str, - pub mc_version: &'a str, - pub mc_loader: i64, - pub mc_loader_version: Option<&'a str>, - pub setting_profile_name: Option<&'a str>, - pub stage: i64, + pub name: &'a str, + pub folder_name: &'a str, + pub mc_version: &'a str, + pub mc_loader: i64, + pub mc_loader_version: Option<&'a str>, + pub setting_profile_name: Option<&'a str>, + pub stage: i64, } #[derive(Debug, Clone, Default)] pub struct ClusterPatch { - pub name: Option, - pub setting_profile_name: Option>, - pub mc_loader_version: Option>, - pub linked_modpack_hash: Option>, + pub name: Option, + pub setting_profile_name: Option>, + pub mc_loader_version: Option>, + pub linked_modpack_hash: Option>, } diff --git a/packages/oneclient_db/src/models/game_session.rs b/packages/oneclient_db/src/models/game_session.rs index 04a19ab3..26beed9b 100644 --- a/packages/oneclient_db/src/models/game_session.rs +++ b/packages/oneclient_db/src/models/game_session.rs @@ -4,50 +4,50 @@ pub type GameSessionId = String; #[derive(Debug, Clone, FromRow)] pub struct GameSessionRow { - pub cluster_id: i64, - pub started_at: String, - pub ended_at: Option, - pub exit_code: Option, - pub ram_allocated_mb: i64, - pub mods_enabled: i64, - pub java_vendor: Option, - pub java_version: Option, + pub cluster_id: i64, + pub started_at: String, + pub ended_at: Option, + pub exit_code: Option, + pub ram_allocated_mb: i64, + pub mods_enabled: i64, + pub java_vendor: Option, + pub java_version: Option, } pub struct NewGameSession<'a> { - pub cluster_id: i64, - pub started_at: &'a str, - pub ram_allocated_mb: i64, - pub mods_enabled: i64, - pub java_vendor: Option<&'a str>, - pub java_version: Option<&'a str>, + pub cluster_id: i64, + pub started_at: &'a str, + pub ram_allocated_mb: i64, + pub mods_enabled: i64, + pub java_vendor: Option<&'a str>, + pub java_version: Option<&'a str>, } #[derive(Debug, Clone, FromRow)] pub struct UnfinishedSession { - pub cluster_id: i64, - pub started_at: String, - pub pid: Option, - pub pid_started_at: Option, + pub cluster_id: i64, + pub started_at: String, + pub pid: Option, + pub pid_started_at: Option, } #[derive(Debug, Clone, FromRow)] pub struct GameSessionServerRow { - pub session_started_at: String, - pub address: String, - pub port: Option, - pub joined_at: String, - pub disconnected_at: Option, + pub session_started_at: String, + pub address: String, + pub port: Option, + pub joined_at: String, + pub disconnected_at: Option, } #[derive(Debug, Clone, FromRow)] pub struct ServerJoinCount { - pub address: String, - pub joins: i64, + pub address: String, + pub joins: i64, } #[derive(Debug, Clone, FromRow)] pub struct SessionSpan { - pub started_at: String, - pub ended_at: Option, + pub started_at: String, + pub ended_at: Option, } diff --git a/packages/oneclient_db/src/models/java.rs b/packages/oneclient_db/src/models/java.rs index 54c11188..db77db85 100644 --- a/packages/oneclient_db/src/models/java.rs +++ b/packages/oneclient_db/src/models/java.rs @@ -2,11 +2,11 @@ use sqlx::FromRow; #[derive(Debug, Clone, FromRow)] pub struct JavaVersionRow { - pub absolute_path: String, - pub major: i64, - pub version: String, - pub vendor: String, - pub os_arch: String, - pub is_jdk: bool, - pub probe_version: i64, + pub absolute_path: String, + pub major: i64, + pub version: String, + pub vendor: String, + pub os_arch: String, + pub is_jdk: bool, + pub probe_version: i64, } diff --git a/packages/oneclient_db/src/models/mod.rs b/packages/oneclient_db/src/models/mod.rs index 9e83ac64..9c0921f3 100644 --- a/packages/oneclient_db/src/models/mod.rs +++ b/packages/oneclient_db/src/models/mod.rs @@ -10,19 +10,18 @@ mod package_metadata; mod setting_profile; pub use artifact::{ - ArtifactRow, ClusterArtifactRow, LinkedArtifactRow, ProviderReleaseRow, SeenStatus, GlobalArtifactRow + ArtifactRow, ClusterArtifactRow, GlobalArtifactRow, LinkedArtifactRow, ProviderReleaseRow, + SeenStatus, }; pub use browser_package_update::BrowserPackageUpdateRow; -pub use package_metadata::PackageMetadataRow; pub use bundle::{BundleRow, NewBundle}; pub use cluster::{ClusterId, ClusterPatch, ClusterRow, NewCluster}; -pub use cluster_bundle::{ - BundleTrackedArtifactRow, ClusterBundleOverrideRow, OverrideType, -}; +pub use cluster_bundle::{BundleTrackedArtifactRow, ClusterBundleOverrideRow, OverrideType}; pub use cluster_optional_mod::{ClusterOptionalModRow, OptionalModStatus}; pub use game_session::{ GameSessionId, GameSessionRow, GameSessionServerRow, NewGameSession, ServerJoinCount, SessionSpan, UnfinishedSession, }; pub use java::JavaVersionRow; +pub use package_metadata::PackageMetadataRow; pub use setting_profile::SettingProfileRow; diff --git a/packages/oneclient_db/src/models/package_metadata.rs b/packages/oneclient_db/src/models/package_metadata.rs index a3e3b462..fa922345 100644 --- a/packages/oneclient_db/src/models/package_metadata.rs +++ b/packages/oneclient_db/src/models/package_metadata.rs @@ -2,10 +2,10 @@ use sqlx::FromRow; #[derive(Debug, Clone, FromRow)] pub struct PackageMetadataRow { - pub provider: i64, - pub project_id: String, - pub name: String, - pub summary: String, - pub author: String, - pub icon_url: Option, + pub provider: i64, + pub project_id: String, + pub name: String, + pub summary: String, + pub author: String, + pub icon_url: Option, } diff --git a/packages/oneclient_db/src/models/setting_profile.rs b/packages/oneclient_db/src/models/setting_profile.rs index f236bb04..66e2d213 100644 --- a/packages/oneclient_db/src/models/setting_profile.rs +++ b/packages/oneclient_db/src/models/setting_profile.rs @@ -2,17 +2,17 @@ use sqlx::FromRow; #[derive(Debug, Clone, FromRow)] pub struct SettingProfileRow { - pub name: String, - pub java_path: Option, - pub resolution: Option, - pub force_fullscreen: Option, - pub mem_max: Option, - pub launch_args: Option, - pub launch_env: Option, - pub hook_pre: Option, - pub hook_wrapper: Option, - pub hook_post: Option, - pub os_extra: Option, - /// `PackageUpdateMode::as_str` NULL inherits the global setting - pub browser_update_mode: Option, + pub name: String, + pub java_path: Option, + pub resolution: Option, + pub force_fullscreen: Option, + pub mem_max: Option, + pub launch_args: Option, + pub launch_env: Option, + pub hook_pre: Option, + pub hook_wrapper: Option, + pub hook_post: Option, + pub os_extra: Option, + /// `PackageUpdateMode::as_str` NULL inherits the global setting + pub browser_update_mode: Option, } diff --git a/packages/oneclient_discord/src/lib.rs b/packages/oneclient_discord/src/lib.rs index 1ed17a42..f8b41280 100644 --- a/packages/oneclient_discord/src/lib.rs +++ b/packages/oneclient_discord/src/lib.rs @@ -14,213 +14,213 @@ const LARGE_IMAGE: &str = "onelauncher_logo_512"; #[derive(Debug, Clone, PartialEq, Eq, Default)] pub enum Presence { - #[default] - Idle, - Playing { - cluster: String, - mc_version: String, - }, + #[default] + Idle, + Playing { + cluster: String, + mc_version: String, + }, } impl Presence { - fn details(&self) -> &'static str { - match self { - Self::Idle => "Idle", - Self::Playing { .. } => "Playing Minecraft", - } - } + fn details(&self) -> &'static str { + match self { + Self::Idle => "Idle", + Self::Playing { .. } => "Playing Minecraft", + } + } - fn state(&self) -> String { - match self { - Self::Idle => "In the launcher".to_owned(), - Self::Playing { - cluster, - mc_version, - } => format!("{cluster} ({mc_version})"), - } - } + fn state(&self) -> String { + match self { + Self::Idle => "In the launcher".to_owned(), + Self::Playing { + cluster, + mc_version, + } => format!("{cluster} ({mc_version})"), + } + } } enum Command { - SetPresence(Presence), - SetEnabled(bool), - Shutdown, + SetPresence(Presence), + SetEnabled(bool), + Shutdown, } #[derive(Clone)] pub struct DiscordRpc { - tx: Sender, + tx: Sender, } impl DiscordRpc { - pub fn spawn(enabled: bool) -> Self { - let (tx, rx) = mpsc::channel(); + pub fn spawn(enabled: bool) -> Self { + let (tx, rx) = mpsc::channel(); - std::thread::Builder::new() - .name("discord-rpc".to_owned()) - .spawn(move || Worker::new(enabled).run(&rx)) - .expect("failed to spawn the Discord RPC thread"); + std::thread::Builder::new() + .name("discord-rpc".to_owned()) + .spawn(move || Worker::new(enabled).run(&rx)) + .expect("failed to spawn the Discord RPC thread"); - Self { tx } - } + Self { tx } + } - pub fn set_presence(&self, presence: Presence) { - self.send(Command::SetPresence(presence)); - } + pub fn set_presence(&self, presence: Presence) { + self.send(Command::SetPresence(presence)); + } - pub fn set_enabled(&self, enabled: bool) { - self.send(Command::SetEnabled(enabled)); - } + pub fn set_enabled(&self, enabled: bool) { + self.send(Command::SetEnabled(enabled)); + } - pub fn shutdown(&self) { - self.send(Command::Shutdown); - } + pub fn shutdown(&self) { + self.send(Command::Shutdown); + } - fn send(&self, command: Command) { - if self.tx.send(command).is_err() { - tracing::debug!("Discord RPC worker is gone; dropping command"); - } - } + fn send(&self, command: Command) { + if self.tx.send(command).is_err() { + tracing::debug!("Discord RPC worker is gone; dropping command"); + } + } } struct Worker { - client: DiscordIpcClient, - enabled: bool, - connected: bool, - ever_connected: bool, - presence: Presence, - presence_since: i64, - next_attempt: Instant, + client: DiscordIpcClient, + enabled: bool, + connected: bool, + ever_connected: bool, + presence: Presence, + presence_since: i64, + next_attempt: Instant, } impl Worker { - fn new(enabled: bool) -> Self { - Self { - client: DiscordIpcClient::new(DISCORD_CLIENT_ID), - enabled, - connected: false, - ever_connected: false, - presence: Presence::Idle, - presence_since: chrono::Utc::now().timestamp(), - next_attempt: Instant::now(), - } - } + fn new(enabled: bool) -> Self { + Self { + client: DiscordIpcClient::new(DISCORD_CLIENT_ID), + enabled, + connected: false, + ever_connected: false, + presence: Presence::Idle, + presence_since: chrono::Utc::now().timestamp(), + next_attempt: Instant::now(), + } + } - fn run(mut self, rx: &mpsc::Receiver) { - self.publish(); + fn run(mut self, rx: &mpsc::Receiver) { + self.publish(); - loop { - match rx.recv_timeout(self.poll_interval()) { - Ok(Command::SetPresence(presence)) => self.set_presence(presence), - Ok(Command::SetEnabled(enabled)) => self.set_enabled(enabled), - Ok(Command::Shutdown) | Err(RecvTimeoutError::Disconnected) => break, - Err(RecvTimeoutError::Timeout) => self.publish(), - } - } + loop { + match rx.recv_timeout(self.poll_interval()) { + Ok(Command::SetPresence(presence)) => self.set_presence(presence), + Ok(Command::SetEnabled(enabled)) => self.set_enabled(enabled), + Ok(Command::Shutdown) | Err(RecvTimeoutError::Disconnected) => break, + Err(RecvTimeoutError::Timeout) => self.publish(), + } + } - self.disconnect(); - } + self.disconnect(); + } - fn poll_interval(&self) -> Duration { - if !self.enabled || self.connected { - return IDLE_POLL; - } + fn poll_interval(&self) -> Duration { + if !self.enabled || self.connected { + return IDLE_POLL; + } - self.next_attempt - .saturating_duration_since(Instant::now()) - .max(Duration::from_millis(100)) - } + self.next_attempt + .saturating_duration_since(Instant::now()) + .max(Duration::from_millis(100)) + } - fn set_presence(&mut self, presence: Presence) { - if self.presence == presence { - return; - } + fn set_presence(&mut self, presence: Presence) { + if self.presence == presence { + return; + } - self.presence = presence; - self.presence_since = chrono::Utc::now().timestamp(); - self.publish(); - } + self.presence = presence; + self.presence_since = chrono::Utc::now().timestamp(); + self.publish(); + } - fn set_enabled(&mut self, enabled: bool) { - if self.enabled == enabled { - return; - } + fn set_enabled(&mut self, enabled: bool) { + if self.enabled == enabled { + return; + } - self.enabled = enabled; + self.enabled = enabled; - if enabled { - self.next_attempt = Instant::now(); - self.publish(); - } else { - self.disconnect(); - } - } + if enabled { + self.next_attempt = Instant::now(); + self.publish(); + } else { + self.disconnect(); + } + } - fn publish(&mut self) { - if !self.enabled || !self.ensure_connected() { - return; - } + fn publish(&mut self) { + if !self.enabled || !self.ensure_connected() { + return; + } - let state = self.presence.state(); - let activity = Activity::new() - .details(self.presence.details()) - .state(&state) - .timestamps(Timestamps::new().start(self.presence_since)) - .assets( - Assets::new() - .large_image(LARGE_IMAGE) - .large_text("OneClient"), - ) - .buttons(vec![Button::new("Website", "https://polyfrost.org/")]); + let state = self.presence.state(); + let activity = Activity::new() + .details(self.presence.details()) + .state(&state) + .timestamps(Timestamps::new().start(self.presence_since)) + .assets( + Assets::new() + .large_image(LARGE_IMAGE) + .large_text("OneClient"), + ) + .buttons(vec![Button::new("Website", "https://polyfrost.org/")]); - if let Err(err) = self.client.set_activity(activity) { - tracing::warn!("Discord RPC set_activity failed: {err}"); - self.drop_connection(); - } - } + if let Err(err) = self.client.set_activity(activity) { + tracing::warn!("Discord RPC set_activity failed: {err}"); + self.drop_connection(); + } + } - fn ensure_connected(&mut self) -> bool { - if self.connected { - return true; - } + fn ensure_connected(&mut self) -> bool { + if self.connected { + return true; + } - if Instant::now() < self.next_attempt { - return false; - } - self.next_attempt = Instant::now() + RECONNECT_BACKOFF; + if Instant::now() < self.next_attempt { + return false; + } + self.next_attempt = Instant::now() + RECONNECT_BACKOFF; - let result = if self.ever_connected { - self.client.reconnect() - } else { - self.client.connect() - }; + let result = if self.ever_connected { + self.client.reconnect() + } else { + self.client.connect() + }; - match result { - Ok(()) => { - tracing::info!("Discord RPC connected"); - self.connected = true; - self.ever_connected = true; - true - } - Err(err) => { - tracing::debug!("Discord RPC connect failed (is Discord running?): {err}"); - false - } - } - } + match result { + Ok(()) => { + tracing::info!("Discord RPC connected"); + self.connected = true; + self.ever_connected = true; + true + } + Err(err) => { + tracing::debug!("Discord RPC connect failed (is Discord running?): {err}"); + false + } + } + } - fn drop_connection(&mut self) { - self.connected = false; - self.next_attempt = Instant::now() + RECONNECT_BACKOFF; - } + fn drop_connection(&mut self) { + self.connected = false; + self.next_attempt = Instant::now() + RECONNECT_BACKOFF; + } - fn disconnect(&mut self) { - if !self.connected { - return; - } + fn disconnect(&mut self) { + if !self.connected { + return; + } - let _ = self.client.clear_activity(); - let _ = self.client.close(); - self.connected = false; - } + let _ = self.client.clear_activity(); + let _ = self.client.close(); + self.connected = false; + } } diff --git a/packages/oneclient_events/src/bus.rs b/packages/oneclient_events/src/bus.rs index 15645bb1..54be020e 100644 --- a/packages/oneclient_events/src/bus.rs +++ b/packages/oneclient_events/src/bus.rs @@ -2,295 +2,300 @@ use tokio::sync::{mpsc, oneshot}; use uuid::Uuid; use crate::error::{EventError, EventResult}; -use crate::event::{Event, GameEvent, LaunchStage, Level, Message, Notification, ProgressEvent, Signal}; +use crate::event::{ + Event, GameEvent, LaunchStage, Level, Message, Notification, ProgressEvent, Signal, +}; use crate::prompt::{Answer, Chosen, Prompt, PromptRequest}; /// Cheap to clone every clone feeds the same channel /// Hand clones to subsystems rather than wrapping this in an `Arc` #[derive(Clone, Debug)] pub struct EventBus { - tx: mpsc::UnboundedSender, + tx: mpsc::UnboundedSender, } pub type EventReceiver = mpsc::UnboundedReceiver; impl EventBus { - #[must_use] - pub fn new(tx: mpsc::UnboundedSender) -> Self { - Self { tx } - } + #[must_use] + pub fn new(tx: mpsc::UnboundedSender) -> Self { + Self { tx } + } - #[must_use] - pub fn channel() -> (Self, EventReceiver) { - let (tx, rx) = mpsc::unbounded_channel(); - (Self::new(tx), rx) - } + #[must_use] + pub fn channel() -> (Self, EventReceiver) { + let (tx, rx) = mpsc::unbounded_channel(); + (Self::new(tx), rx) + } - /// A closed bus is logged rather than returned use [`EventBus::ask`] when - /// you need to know whether the other end is alive - pub fn emit(&self, event: impl Into) { - if let Err(err) = self.tx.send(event.into()) { - tracing::debug!("dropping event, bus is closed: {err}"); - } - } + /// A closed bus is logged rather than returned use [`EventBus::ask`] when + /// you need to know whether the other end is alive + pub fn emit(&self, event: impl Into) { + if let Err(err) = self.tx.send(event.into()) { + tracing::debug!("dropping event, bus is closed: {err}"); + } + } - #[must_use] - pub fn is_open(&self) -> bool { - !self.tx.is_closed() - } + #[must_use] + pub fn is_open(&self) -> bool { + !self.tx.is_closed() + } - /// ```ignore - /// events.notify("Install failed").body(err.to_string()).error().send(); - /// ``` - pub fn notify(&self, title: impl Into) -> NotificationBuilder<'_> { - NotificationBuilder { - bus: self, - message: Message { - title: title.into(), - body: String::new(), - level: Level::Info, - }, - } - } + /// ```ignore + /// events.notify("Install failed").body(err.to_string()).error().send(); + /// ``` + pub fn notify(&self, title: impl Into) -> NotificationBuilder<'_> { + NotificationBuilder { + bus: self, + message: Message { + title: title.into(), + body: String::new(), + level: Level::Info, + }, + } + } - /// Repeated calls with the same `id` replace one another - pub fn progress(&self, id: Uuid, label: impl Into, current: u64, total: u64) { - self.emit(ProgressEvent::Update { - id, - label: label.into(), - current, - total, - }); - } + /// Repeated calls with the same `id` replace one another + pub fn progress(&self, id: Uuid, label: impl Into, current: u64, total: u64) { + self.emit(ProgressEvent::Update { + id, + label: label.into(), + current, + total, + }); + } - pub fn finish_progress(&self, id: Uuid, title: impl Into, body: impl Into) { - self.emit(ProgressEvent::Complete { - id, - title: title.into(), - body: body.into(), - }); - } + pub fn finish_progress(&self, id: Uuid, title: impl Into, body: impl Into) { + self.emit(ProgressEvent::Complete { + id, + title: title.into(), + body: body.into(), + }); + } - pub fn game_stage(&self, cluster_id: i64, stage: LaunchStage) { - self.emit(GameEvent::Stage { cluster_id, stage }); - } + pub fn game_stage(&self, cluster_id: i64, stage: LaunchStage) { + self.emit(GameEvent::Stage { cluster_id, stage }); + } - pub fn game_log(&self, cluster_id: i64, line: impl Into) { - self.emit(GameEvent::Log { - cluster_id, - line: line.into(), - }); - } + pub fn game_log(&self, cluster_id: i64, line: impl Into) { + self.emit(GameEvent::Log { + cluster_id, + line: line.into(), + }); + } - pub fn game_failed(&self, cluster_id: i64, message: impl Into) { - self.emit(GameEvent::Failed { - cluster_id, - message: message.into(), - }); - } + pub fn game_failed(&self, cluster_id: i64, message: impl Into) { + self.emit(GameEvent::Failed { + cluster_id, + message: message.into(), + }); + } - pub fn signal(&self, signal: Signal) { - self.emit(signal); - } + pub fn signal(&self, signal: Signal) { + self.emit(signal); + } - /// Returns `Ok(None)` when the prompt was dismissed - #[tracing::instrument(level = "debug", skip_all, fields(title = %prompt.title))] - pub async fn ask(&self, prompt: Prompt) -> EventResult>> { - let Prompt { - title, - body, - options, - dismiss, - } = prompt; + /// Returns `Ok(None)` when the prompt was dismissed + #[tracing::instrument(level = "debug", skip_all, fields(title = %prompt.title))] + pub async fn ask(&self, prompt: Prompt) -> EventResult>> { + let Prompt { + title, + body, + options, + dismiss, + } = prompt; - let (choices, values): (Vec<_>, Vec<_>) = options.into_iter().unzip(); - let (reply, reply_rx) = oneshot::channel(); + let (choices, values): (Vec<_>, Vec<_>) = options.into_iter().unzip(); + let (reply, reply_rx) = oneshot::channel(); - self.tx - .send(Event::Notification(Notification::Prompt(PromptRequest { - title, - body, - choices: choices.clone(), - dismiss, - reply, - }))) - .map_err(|_| EventError::BusClosed)?; + self.tx + .send(Event::Notification(Notification::Prompt(PromptRequest { + title, + body, + choices: choices.clone(), + dismiss, + reply, + }))) + .map_err(|_| EventError::BusClosed)?; - // A dropped sender means the UI went away without answering - let Some(answer) = reply_rx.await.map_err(|_| EventError::BusClosed)? else { - return Ok(None); - }; + // A dropped sender means the UI went away without answering + let Some(answer) = reply_rx.await.map_err(|_| EventError::BusClosed)? else { + return Ok(None); + }; - let Answer { id, input } = answer; - let index = choices - .iter() - .position(|choice| choice.id == id) - .ok_or(EventError::UnknownChoice(id))?; + let Answer { id, input } = answer; + let index = choices + .iter() + .position(|choice| choice.id == id) + .ok_or(EventError::UnknownChoice(id))?; - let value = values - .into_iter() - .nth(index) - .ok_or(EventError::UnknownChoice(id))?; + let value = values + .into_iter() + .nth(index) + .ok_or(EventError::UnknownChoice(id))?; - Ok(Some(Chosen { value, input })) - } + Ok(Some(Chosen { value, input })) + } } #[must_use = "the notification is not emitted until `.send()` is called"] pub struct NotificationBuilder<'a> { - bus: &'a EventBus, - message: Message, + bus: &'a EventBus, + message: Message, } impl NotificationBuilder<'_> { - pub fn body(mut self, body: impl Into) -> Self { - self.message.body = body.into(); - self - } + pub fn body(mut self, body: impl Into) -> Self { + self.message.body = body.into(); + self + } - pub fn level(mut self, level: Level) -> Self { - self.message.level = level; - self - } + pub fn level(mut self, level: Level) -> Self { + self.message.level = level; + self + } - pub fn info(self) -> Self { - self.level(Level::Info) - } + pub fn info(self) -> Self { + self.level(Level::Info) + } - pub fn error(self) -> Self { - self.level(Level::Error) - } + pub fn error(self) -> Self { + self.level(Level::Error) + } - pub fn send(self) { - self.bus.emit(self.message); - } + pub fn send(self) { + self.bus.emit(self.message); + } } #[cfg(test)] mod tests { - use super::*; - use crate::prompt::{Choice, InputValue}; + use super::*; + use crate::prompt::{Choice, InputValue}; - #[derive(Debug, PartialEq, Eq)] - enum Answered { - Download, - Folder, - } + #[derive(Debug, PartialEq, Eq)] + enum Answered { + Download, + Folder, + } - fn prompt() -> Prompt { - Prompt::new("Java required", "No Java 21 runtime was found.") - .option( - Choice::primary("download", "Download").picks_selection("java-vendor"), - Answered::Download, - ) - .option( - Choice::new("folder", "Choose folder").picks_folder("Pick a folder"), - Answered::Folder, - ) - .dismiss("Cancel") - } + fn prompt() -> Prompt { + Prompt::new("Java required", "No Java 21 runtime was found.") + .option( + Choice::primary("download", "Download").picks_selection("java-vendor"), + Answered::Download, + ) + .option( + Choice::new("folder", "Choose folder").picks_folder("Pick a folder"), + Answered::Folder, + ) + .dismiss("Cancel") + } - #[tokio::test] - async fn ask_maps_the_answer_back_to_the_callers_value() { - let (bus, mut rx) = EventBus::channel(); + #[tokio::test] + async fn ask_maps_the_answer_back_to_the_callers_value() { + let (bus, mut rx) = EventBus::channel(); - let task = tokio::spawn(async move { bus.ask(prompt()).await }); + let task = tokio::spawn(async move { bus.ask(prompt()).await }); - let Some(Event::Notification(Notification::Prompt(request))) = rx.recv().await else { - panic!("expected a prompt"); - }; - assert_eq!(request.choices.len(), 2); - assert_eq!(request.dismiss.as_deref(), Some("Cancel")); - request - .reply - .send(Some(Answer::new("folder").with_folder("/opt/jdk21"))) - .unwrap(); + let Some(Event::Notification(Notification::Prompt(request))) = rx.recv().await else { + panic!("expected a prompt"); + }; + assert_eq!(request.choices.len(), 2); + assert_eq!(request.dismiss.as_deref(), Some("Cancel")); + request + .reply + .send(Some(Answer::new("folder").with_folder("/opt/jdk21"))) + .unwrap(); - let chosen = task.await.unwrap().unwrap().unwrap(); - assert_eq!(chosen.value, Answered::Folder); - assert_eq!(chosen.folder(), Some(std::path::Path::new("/opt/jdk21"))); - } + let chosen = task.await.unwrap().unwrap().unwrap(); + assert_eq!(chosen.value, Answered::Folder); + assert_eq!(chosen.folder(), Some(std::path::Path::new("/opt/jdk21"))); + } - #[tokio::test] - async fn selection_input_round_trips() { - let (bus, mut rx) = EventBus::channel(); - let task = tokio::spawn(async move { bus.ask(prompt()).await }); + #[tokio::test] + async fn selection_input_round_trips() { + let (bus, mut rx) = EventBus::channel(); + let task = tokio::spawn(async move { bus.ask(prompt()).await }); - let Some(Event::Notification(Notification::Prompt(request))) = rx.recv().await else { - panic!("expected a prompt"); - }; - request - .reply - .send(Some(Answer::new("download").with_selection("adoptium"))) - .unwrap(); + let Some(Event::Notification(Notification::Prompt(request))) = rx.recv().await else { + panic!("expected a prompt"); + }; + request + .reply + .send(Some(Answer::new("download").with_selection("adoptium"))) + .unwrap(); - let chosen = task.await.unwrap().unwrap().unwrap(); - assert_eq!(chosen.value, Answered::Download); - assert_eq!(chosen.selection(), Some("adoptium")); - assert_eq!( - chosen.input, - Some(InputValue::Selection("adoptium".to_string())) - ); - } + let chosen = task.await.unwrap().unwrap().unwrap(); + assert_eq!(chosen.value, Answered::Download); + assert_eq!(chosen.selection(), Some("adoptium")); + assert_eq!( + chosen.input, + Some(InputValue::Selection("adoptium".to_string())) + ); + } - #[tokio::test] - async fn dismissing_yields_none() { - let (bus, mut rx) = EventBus::channel(); - let task = tokio::spawn(async move { bus.ask(prompt()).await }); + #[tokio::test] + async fn dismissing_yields_none() { + let (bus, mut rx) = EventBus::channel(); + let task = tokio::spawn(async move { bus.ask(prompt()).await }); - let Some(Event::Notification(Notification::Prompt(request))) = rx.recv().await else { - panic!("expected a prompt"); - }; - request.reply.send(None).unwrap(); + let Some(Event::Notification(Notification::Prompt(request))) = rx.recv().await else { + panic!("expected a prompt"); + }; + request.reply.send(None).unwrap(); - assert!(task.await.unwrap().unwrap().is_none()); - } + assert!(task.await.unwrap().unwrap().is_none()); + } - #[tokio::test] - async fn unknown_choice_is_an_error_not_a_dismissal() { - let (bus, mut rx) = EventBus::channel(); - let task = tokio::spawn(async move { bus.ask(prompt()).await }); + #[tokio::test] + async fn unknown_choice_is_an_error_not_a_dismissal() { + let (bus, mut rx) = EventBus::channel(); + let task = tokio::spawn(async move { bus.ask(prompt()).await }); - let Some(Event::Notification(Notification::Prompt(request))) = rx.recv().await else { - panic!("expected a prompt"); - }; - request.reply.send(Some(Answer::new("nonsense"))).unwrap(); + let Some(Event::Notification(Notification::Prompt(request))) = rx.recv().await else { + panic!("expected a prompt"); + }; + request.reply.send(Some(Answer::new("nonsense"))).unwrap(); - assert_eq!( - task.await.unwrap().unwrap_err(), - EventError::UnknownChoice("nonsense") - ); - } + assert_eq!( + task.await.unwrap().unwrap_err(), + EventError::UnknownChoice("nonsense") + ); + } - #[tokio::test] - async fn dropping_the_receiver_unblocks_the_asker() { - let (bus, rx) = EventBus::channel(); - let task = tokio::spawn(async move { bus.ask(prompt()).await }); - drop(rx); + #[tokio::test] + async fn dropping_the_receiver_unblocks_the_asker() { + let (bus, rx) = EventBus::channel(); + let task = tokio::spawn(async move { bus.ask(prompt()).await }); + drop(rx); - assert_eq!(task.await.unwrap().unwrap_err(), EventError::BusClosed); - } + assert_eq!(task.await.unwrap().unwrap_err(), EventError::BusClosed); + } - #[test] - fn emitting_on_a_closed_bus_does_not_panic() { - let (bus, rx) = EventBus::channel(); - drop(rx); + #[test] + fn emitting_on_a_closed_bus_does_not_panic() { + let (bus, rx) = EventBus::channel(); + drop(rx); - assert!(!bus.is_open()); - bus.notify("gone").body("nobody is listening").error().send(); - bus.game_log(1, "still fine"); - } + assert!(!bus.is_open()); + bus.notify("gone") + .body("nobody is listening") + .error() + .send(); + bus.game_log(1, "still fine"); + } - #[tokio::test] - async fn notification_builder_defaults_to_info_with_an_empty_body() { - let (bus, mut rx) = EventBus::channel(); - bus.notify("Done").send(); + #[tokio::test] + async fn notification_builder_defaults_to_info_with_an_empty_body() { + let (bus, mut rx) = EventBus::channel(); + bus.notify("Done").send(); - let Some(Event::Notification(Notification::Message(message))) = rx.recv().await else { - panic!("expected a message"); - }; - assert_eq!(message.title, "Done"); - assert_eq!(message.body, ""); - assert_eq!(message.level, Level::Info); - } + let Some(Event::Notification(Notification::Message(message))) = rx.recv().await else { + panic!("expected a message"); + }; + assert_eq!(message.title, "Done"); + assert_eq!(message.body, ""); + assert_eq!(message.level, Level::Info); + } } diff --git a/packages/oneclient_events/src/error.rs b/packages/oneclient_events/src/error.rs index d36fc4ec..048199c8 100644 --- a/packages/oneclient_events/src/error.rs +++ b/packages/oneclient_events/src/error.rs @@ -2,11 +2,11 @@ pub type EventResult = Result; #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub enum EventError { - #[error("the event bus is closed")] - BusClosed, + #[error("the event bus is closed")] + BusClosed, - /// A wrong answer rather than a missing one so it must not be read as - /// "dismissed" - #[error("prompt answered with an unknown choice '{0}'")] - UnknownChoice(&'static str), + /// A wrong answer rather than a missing one so it must not be read as + /// "dismissed" + #[error("prompt answered with an unknown choice '{0}'")] + UnknownChoice(&'static str), } diff --git a/packages/oneclient_events/src/event.rs b/packages/oneclient_events/src/event.rs index 6c87722d..d118a802 100644 --- a/packages/oneclient_events/src/event.rs +++ b/packages/oneclient_events/src/event.rs @@ -9,123 +9,123 @@ use crate::prompt::PromptRequest; /// front-end's call #[derive(Debug)] pub enum Event { - Notification(Notification), - Progress(ProgressEvent), - Game(GameEvent), - /// State changed elsewhere whoever caches it should refetch - Signal(Signal), + Notification(Notification), + Progress(ProgressEvent), + Game(GameEvent), + /// State changed elsewhere whoever caches it should refetch + Signal(Signal), } #[derive(Debug)] pub enum Notification { - Message(Message), - Prompt(PromptRequest), + Message(Message), + Prompt(PromptRequest), } impl From for Event { - fn from(value: Notification) -> Self { - Self::Notification(value) - } + fn from(value: Notification) -> Self { + Self::Notification(value) + } } impl From for Event { - fn from(value: Message) -> Self { - Self::Notification(Notification::Message(value)) - } + fn from(value: Message) -> Self { + Self::Notification(Notification::Message(value)) + } } impl From for Event { - fn from(value: PromptRequest) -> Self { - Self::Notification(Notification::Prompt(value)) - } + fn from(value: PromptRequest) -> Self { + Self::Notification(Notification::Prompt(value)) + } } impl From for Event { - fn from(value: ProgressEvent) -> Self { - Self::Progress(value) - } + fn from(value: ProgressEvent) -> Self { + Self::Progress(value) + } } impl From for Event { - fn from(value: GameEvent) -> Self { - Self::Game(value) - } + fn from(value: GameEvent) -> Self { + Self::Game(value) + } } impl From for Event { - fn from(value: Signal) -> Self { - Self::Signal(value) - } + fn from(value: Signal) -> Self { + Self::Signal(value) + } } impl From for Event { - fn from(value: GroupedProgressEvent) -> Self { - Self::Progress(ProgressEvent::Grouped(value)) - } + fn from(value: GroupedProgressEvent) -> Self { + Self::Progress(ProgressEvent::Grouped(value)) + } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct Message { - pub title: String, - pub body: String, - pub level: Level, + pub title: String, + pub body: String, + pub level: Level, } #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] pub enum Level { - #[default] - Info, - Error, + #[default] + Info, + Error, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum ProgressEvent { - /// Keyed by `id` so repeated updates replace one another rather than stack - Update { - id: Uuid, - label: String, - current: u64, - total: u64, - }, + /// Keyed by `id` so repeated updates replace one another rather than stack + Update { + id: Uuid, + label: String, + current: u64, + total: u64, + }, - /// Turns the in-flight [`ProgressEvent::Update`] with this `id` into a - /// finished message in place rather than emitting a second card - Complete { - id: Uuid, - title: String, - body: String, - }, + /// Turns the in-flight [`ProgressEvent::Update`] with this `id` into a + /// finished message in place rather than emitting a second card + Complete { + id: Uuid, + title: String, + body: String, + }, - Grouped(GroupedProgressEvent), + Grouped(GroupedProgressEvent), } #[derive(Debug, Clone, PartialEq, Eq)] pub enum GameEvent { - Stage { cluster_id: i64, stage: LaunchStage }, - Log { cluster_id: i64, line: String }, - Failed { cluster_id: i64, message: String }, + Stage { cluster_id: i64, stage: LaunchStage }, + Log { cluster_id: i64, line: String }, + Failed { cluster_id: i64, message: String }, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LaunchStage { - Checking, - Downloading, - Launching, - Running, - Exited, + Checking, + Downloading, + Launching, + Running, + Exited, } impl LaunchStage { - #[must_use] - pub fn is_busy(self) -> bool { - matches!(self, Self::Checking | Self::Downloading | Self::Launching) - } + #[must_use] + pub fn is_busy(self) -> bool { + matches!(self, Self::Checking | Self::Downloading | Self::Launching) + } } #[derive(Debug, Clone, PartialEq, Eq)] pub enum Signal { - ClustersChanged, - JavaChanged, - /// Initial background sync finished - SyncComplete, + ClustersChanged, + JavaChanged, + /// Initial background sync finished + SyncComplete, } diff --git a/packages/oneclient_events/src/lib.rs b/packages/oneclient_events/src/lib.rs index d6c15444..43d79479 100644 --- a/packages/oneclient_events/src/lib.rs +++ b/packages/oneclient_events/src/lib.rs @@ -13,9 +13,11 @@ pub mod prompt; pub use bus::{EventBus, EventReceiver, NotificationBuilder}; pub use error::{EventError, EventResult}; pub use event::{ - Event, GameEvent, LaunchStage, Level, Message, Notification, ProgressEvent, Signal, + Event, GameEvent, LaunchStage, Level, Message, Notification, ProgressEvent, Signal, }; pub use progress::{ - GroupedProgressChild, GroupedProgressEvent, GroupedProgressSession, TaskCategory, TaskPhase, + GroupedProgressChild, GroupedProgressEvent, GroupedProgressSession, TaskCategory, TaskPhase, +}; +pub use prompt::{ + Answer, Choice, ChoiceInput, ChoiceStyle, Chosen, InputValue, Prompt, PromptRequest, }; -pub use prompt::{Answer, Choice, ChoiceInput, ChoiceStyle, Chosen, InputValue, Prompt, PromptRequest}; diff --git a/packages/oneclient_events/src/progress.rs b/packages/oneclient_events/src/progress.rs index d7663c9d..f810dd20 100644 --- a/packages/oneclient_events/src/progress.rs +++ b/packages/oneclient_events/src/progress.rs @@ -172,14 +172,12 @@ impl GroupedProgressSession { if count == 0 { return; } - self.inner - .events - .emit(GroupedProgressEvent::Expect { - session_id: self.inner.session_id, - category, - count, - total, - }); + self.inner.events.emit(GroupedProgressEvent::Expect { + session_id: self.inner.session_id, + category, + count, + total, + }); } pub fn child( @@ -192,15 +190,13 @@ impl GroupedProgressSession { let label = label.into(); let total = total.max(1); - self.inner - .events - .emit(GroupedProgressEvent::AddChild { - session_id: self.inner.session_id, - child_id, - label: label.clone(), - total, - category, - }); + self.inner.events.emit(GroupedProgressEvent::AddChild { + session_id: self.inner.session_id, + child_id, + label: label.clone(), + total, + category, + }); GroupedProgressChild { inner: Arc::new(ChildInner { @@ -258,13 +254,11 @@ impl GroupedProgressChild { { return; } - self.inner - .events - .emit(GroupedProgressEvent::SetChildPhase { - session_id: self.inner.session_id, - child_id: self.inner.child_id, - phase, - }); + self.inner.events.emit(GroupedProgressEvent::SetChildPhase { + session_id: self.inner.session_id, + child_id: self.inner.child_id, + phase, + }); } pub fn set_progress(&self, current: u64, total: Option) { @@ -283,14 +277,12 @@ impl GroupedProgressChild { if total > stored { self.inner.total.store(total, Ordering::Relaxed); } - self.inner - .events - .emit(GroupedProgressEvent::UpdateChild { - session_id: self.inner.session_id, - child_id: self.inner.child_id, - current, - total, - }); + self.inner.events.emit(GroupedProgressEvent::UpdateChild { + session_id: self.inner.session_id, + child_id: self.inner.child_id, + current, + total, + }); } pub fn finish(&self) { @@ -302,12 +294,10 @@ impl GroupedProgressChild { return; } - self.inner - .events - .emit(GroupedProgressEvent::FinishChild { - session_id: self.inner.session_id, - child_id: self.inner.child_id, - }); + self.inner.events.emit(GroupedProgressEvent::FinishChild { + session_id: self.inner.session_id, + child_id: self.inner.child_id, + }); } } @@ -359,10 +349,9 @@ mod tests { child.set_progress(50, Some(100)); let events = drain(&mut rx); assert!( - events.iter().any(|e| matches!( - e, - GroupedProgressEvent::UpdateChild { current: 50, .. } - )), + events + .iter() + .any(|e| matches!(e, GroupedProgressEvent::UpdateChild { current: 50, .. })), "progress must still be reported after a clone is dropped: {events:?}" ); @@ -382,7 +371,13 @@ mod tests { let _ = drain(&mut rx); child.finish(); - assert_eq!(drain(&mut rx).iter().filter(|e| matches!(e, GroupedProgressEvent::FinishChild { .. })).count(), 1); + assert_eq!( + drain(&mut rx) + .iter() + .filter(|e| matches!(e, GroupedProgressEvent::FinishChild { .. })) + .count(), + 1 + ); drop(child); assert!(!has_finish(&drain(&mut rx)), "drop must not finish twice"); diff --git a/packages/oneclient_events/src/prompt.rs b/packages/oneclient_events/src/prompt.rs index 2f54e859..4491b14f 100644 --- a/packages/oneclient_events/src/prompt.rs +++ b/packages/oneclient_events/src/prompt.rs @@ -10,75 +10,79 @@ use tokio::sync::oneshot; /// Only a hint the event layer has no business knowing what a button looks like #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum ChoiceStyle { - Primary, - #[default] - Secondary, - Danger, + Primary, + #[default] + Secondary, + Danger, } /// Something the UI must collect before the choice can be answered #[derive(Debug, Clone, PartialEq, Eq)] pub enum ChoiceInput { - Folder { title: String }, - /// `hint` names the kind of thing being picked so the front-end can route to - /// the right picker its meaning belongs to whoever built the prompt - Selection { hint: String }, + Folder { + title: String, + }, + /// `hint` names the kind of thing being picked so the front-end can route to + /// the right picker its meaning belongs to whoever built the prompt + Selection { + hint: String, + }, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum InputValue { - Folder(PathBuf), - Selection(String), + Folder(PathBuf), + Selection(String), } #[derive(Debug, Clone, PartialEq, Eq)] pub struct Choice { - /// Stable identifier matched when the answer comes back - pub id: &'static str, - pub label: String, - pub style: ChoiceStyle, - pub input: Option, + /// Stable identifier matched when the answer comes back + pub id: &'static str, + pub label: String, + pub style: ChoiceStyle, + pub input: Option, } impl Choice { - pub fn new(id: &'static str, label: impl Into) -> Self { - Self { - id, - label: label.into(), - style: ChoiceStyle::Secondary, - input: None, - } - } + pub fn new(id: &'static str, label: impl Into) -> Self { + Self { + id, + label: label.into(), + style: ChoiceStyle::Secondary, + input: None, + } + } - #[must_use] - pub fn primary(id: &'static str, label: impl Into) -> Self { - Self::new(id, label).style(ChoiceStyle::Primary) - } + #[must_use] + pub fn primary(id: &'static str, label: impl Into) -> Self { + Self::new(id, label).style(ChoiceStyle::Primary) + } - #[must_use] - pub fn danger(id: &'static str, label: impl Into) -> Self { - Self::new(id, label).style(ChoiceStyle::Danger) - } + #[must_use] + pub fn danger(id: &'static str, label: impl Into) -> Self { + Self::new(id, label).style(ChoiceStyle::Danger) + } - #[must_use] - pub fn style(mut self, style: ChoiceStyle) -> Self { - self.style = style; - self - } + #[must_use] + pub fn style(mut self, style: ChoiceStyle) -> Self { + self.style = style; + self + } - #[must_use] - pub fn picks_folder(mut self, title: impl Into) -> Self { - self.input = Some(ChoiceInput::Folder { - title: title.into(), - }); - self - } + #[must_use] + pub fn picks_folder(mut self, title: impl Into) -> Self { + self.input = Some(ChoiceInput::Folder { + title: title.into(), + }); + self + } - #[must_use] - pub fn picks_selection(mut self, hint: impl Into) -> Self { - self.input = Some(ChoiceInput::Selection { hint: hint.into() }); - self - } + #[must_use] + pub fn picks_selection(mut self, hint: impl Into) -> Self { + self.input = Some(ChoiceInput::Selection { hint: hint.into() }); + self + } } /// ```ignore @@ -99,88 +103,88 @@ impl Choice { /// ``` #[must_use = "a prompt is not shown until it is passed to `EventBus::ask`"] pub struct Prompt { - pub(crate) title: String, - pub(crate) body: String, - pub(crate) options: Vec<(Choice, T)>, - pub(crate) dismiss: Option, + pub(crate) title: String, + pub(crate) body: String, + pub(crate) options: Vec<(Choice, T)>, + pub(crate) dismiss: Option, } impl Prompt { - pub fn new(title: impl Into, body: impl Into) -> Self { - Self { - title: title.into(), - body: body.into(), - options: Vec::new(), - dismiss: None, - } - } + pub fn new(title: impl Into, body: impl Into) -> Self { + Self { + title: title.into(), + body: body.into(), + options: Vec::new(), + dismiss: None, + } + } - pub fn option(mut self, choice: Choice, value: T) -> Self { - self.options.push((choice, value)); - self - } + pub fn option(mut self, choice: Choice, value: T) -> Self { + self.options.push((choice, value)); + self + } - /// A prompt without this can still be dismissed by closing it so callers - /// must always handle `None` - pub fn dismiss(mut self, label: impl Into) -> Self { - self.dismiss = Some(label.into()); - self - } + /// A prompt without this can still be dismissed by closing it so callers + /// must always handle `None` + pub fn dismiss(mut self, label: impl Into) -> Self { + self.dismiss = Some(label.into()); + self + } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct Chosen { - pub value: T, - pub input: Option, + pub value: T, + pub input: Option, } impl Chosen { - pub fn folder(&self) -> Option<&Path> { - match &self.input { - Some(InputValue::Folder(path)) => Some(path), - _ => None, - } - } + pub fn folder(&self) -> Option<&Path> { + match &self.input { + Some(InputValue::Folder(path)) => Some(path), + _ => None, + } + } - pub fn selection(&self) -> Option<&str> { - match &self.input { - Some(InputValue::Selection(value)) => Some(value), - _ => None, - } - } + pub fn selection(&self) -> Option<&str> { + match &self.input { + Some(InputValue::Selection(value)) => Some(value), + _ => None, + } + } } /// The untyped prompt as it crosses the bus `T` never leaves the caller's crate #[derive(Debug)] pub struct PromptRequest { - pub title: String, - pub body: String, - pub choices: Vec, - pub dismiss: Option, - pub reply: oneshot::Sender>, + pub title: String, + pub body: String, + pub choices: Vec, + pub dismiss: Option, + pub reply: oneshot::Sender>, } /// `None` on the wire means dismissed #[derive(Debug, Clone, PartialEq, Eq)] pub struct Answer { - pub id: &'static str, - pub input: Option, + pub id: &'static str, + pub input: Option, } impl Answer { - pub fn new(id: &'static str) -> Self { - Self { id, input: None } - } + pub fn new(id: &'static str) -> Self { + Self { id, input: None } + } - #[must_use] - pub fn with_folder(mut self, path: impl Into) -> Self { - self.input = Some(InputValue::Folder(path.into())); - self - } + #[must_use] + pub fn with_folder(mut self, path: impl Into) -> Self { + self.input = Some(InputValue::Folder(path.into())); + self + } - #[must_use] - pub fn with_selection(mut self, value: impl Into) -> Self { - self.input = Some(InputValue::Selection(value.into())); - self - } + #[must_use] + pub fn with_selection(mut self, value: impl Into) -> Self { + self.input = Some(InputValue::Selection(value.into())); + self + } } diff --git a/packages/oneclient_java/src/checker.rs b/packages/oneclient_java/src/checker.rs index 56b4773c..c2f07ddc 100644 --- a/packages/oneclient_java/src/checker.rs +++ b/packages/oneclient_java/src/checker.rs @@ -40,7 +40,9 @@ pub async fn check_java_runtime(absolute_path: String) -> JavaResult JavaResult JavaResult JavaResult) -> bool { - if !probe_flag(info, "java.awt.link") { - tracing::warn!("java.awt.link returned false. ignoring") - } + if !probe_flag(info, "java.awt.link") { + tracing::warn!("java.awt.link returned false. ignoring") + } - probe_flag(info, "java.awt") - && probe_flag(info, "java.awt.natives") + probe_flag(info, "java.awt") && probe_flag(info, "java.awt.natives") } fn probe_flag(info: &HashMap, key: &str) -> bool { diff --git a/packages/oneclient_java/src/data.rs b/packages/oneclient_java/src/data.rs index 236703be..c4618e60 100644 --- a/packages/oneclient_java/src/data.rs +++ b/packages/oneclient_java/src/data.rs @@ -7,74 +7,73 @@ use crate::vendors::JavaVendor; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct JavaRuntime { - pub absolute_path: String, - pub major: u32, - pub version: String, - pub vendor: JavaVendor, - pub os_arch: String, - pub is_jdk: bool, - pub probe_version: u32, + pub absolute_path: String, + pub major: u32, + pub version: String, + pub vendor: JavaVendor, + pub os_arch: String, + pub is_jdk: bool, + pub probe_version: u32, } - #[derive(Debug, Clone, PartialEq, Eq)] pub struct JavaPackage { - pub download_url: String, - pub name: String, - pub java_version: Vec, - pub vendor: JavaVendor, - pub archive: PackageArchive, - /// Algorithm varies per vendor - /// `None` when vendor metadata omitted or malformed it - /// installs unverified rather than failing - pub checksum: Option, - /// Bytes when the vendor publishes it - pub size: Option, + pub download_url: String, + pub name: String, + pub java_version: Vec, + pub vendor: JavaVendor, + pub archive: PackageArchive, + /// Algorithm varies per vendor + /// `None` when vendor metadata omitted or malformed it + /// installs unverified rather than failing + pub checksum: Option, + /// Bytes when the vendor publishes it + pub size: Option, } impl JavaPackage { - /// Drops malformed vendor checksums which would otherwise make every - /// install fail as a corrupt download - #[must_use] - pub fn with_checksum(mut self, checksum: Option) -> Self { - self.checksum = checksum.filter(|sum| { - let ok = sum.is_well_formed(); - if !ok { - tracing::warn!( - algorithm = sum.algorithm.name(), - hex = %sum.hex, - "vendor published a malformed checksum; installing unverified" - ); - } - ok - }); - self - } + /// Drops malformed vendor checksums which would otherwise make every + /// install fail as a corrupt download + #[must_use] + pub fn with_checksum(mut self, checksum: Option) -> Self { + self.checksum = checksum.filter(|sum| { + let ok = sum.is_well_formed(); + if !ok { + tracing::warn!( + algorithm = sum.algorithm.name(), + hex = %sum.hex, + "vendor published a malformed checksum; installing unverified" + ); + } + ok + }); + self + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PackageArchive { - Zip, - TarGz, + Zip, + TarGz, } impl PackageArchive { - pub fn from_filename(name: &str) -> Self { - if name.ends_with(".tar.gz") || name.ends_with(".tgz") { - Self::TarGz - } else { - Self::Zip - } - } + pub fn from_filename(name: &str) -> Self { + if name.ends_with(".tar.gz") || name.ends_with(".tgz") { + Self::TarGz + } else { + Self::Zip + } + } } pub fn java_executable_relative_path() -> PathBuf { - #[cfg(target_os = "macos")] - { - PathBuf::from("Contents/Home/bin").join(oneclient_common::constants::JAVA_BIN) - } - #[cfg(not(target_os = "macos"))] - { - PathBuf::from("bin").join(oneclient_common::constants::JAVA_BIN) - } + #[cfg(target_os = "macos")] + { + PathBuf::from("Contents/Home/bin").join(oneclient_common::constants::JAVA_BIN) + } + #[cfg(not(target_os = "macos"))] + { + PathBuf::from("bin").join(oneclient_common::constants::JAVA_BIN) + } } diff --git a/packages/oneclient_java/src/error.rs b/packages/oneclient_java/src/error.rs index a26affa6..ecd4ec0e 100644 --- a/packages/oneclient_java/src/error.rs +++ b/packages/oneclient_java/src/error.rs @@ -4,116 +4,116 @@ pub type JavaResult = Result; #[derive(Debug, Error)] pub enum JavaError { - #[error(transparent)] - Store(#[from] crate::store::StoreError), + #[error(transparent)] + Store(#[from] crate::store::StoreError), - #[error(transparent)] - Request(#[from] oneclient_net::RequestError), + #[error(transparent)] + Request(#[from] oneclient_net::RequestError), - #[error(transparent)] - Events(#[from] oneclient_events::EventError), + #[error(transparent)] + Events(#[from] oneclient_events::EventError), - #[error(transparent)] - Paths(#[from] oneclient_common::PathsError), + #[error(transparent)] + Paths(#[from] oneclient_common::PathsError), - #[error("Invalid URL: {0}")] - Url(#[from] url::ParseError), + #[error("Invalid URL: {0}")] + Url(#[from] url::ParseError), - #[error("Error spawning Java runtime from path '{path}': {source}")] - RuntimeCheckError { - #[source] - source: std::io::Error, - path: String, - }, + #[error("Error spawning Java runtime from path '{path}': {source}")] + RuntimeCheckError { + #[source] + source: std::io::Error, + path: String, + }, - #[error("IO Error: {0}")] - PolyIOError(#[from] polyio::IOError), + #[error("IO Error: {0}")] + PolyIOError(#[from] polyio::IOError), - #[error("no java package found for major version {major}")] - PackageNotFound { major: u32 }, + #[error("no java package found for major version {major}")] + PackageNotFound { major: u32 }, - #[error("no java installations available")] - MissingJava, + #[error("no java installations available")] + MissingJava, - #[error("java setup was cancelled")] - Cancelled, + #[error("java setup was cancelled")] + Cancelled, - #[error("selected path is not a valid java installation: {path}")] - InvalidJavaPath { path: String }, + #[error("selected path is not a valid java installation: {path}")] + InvalidJavaPath { path: String }, - #[error( - "java installation at '{path}' has no usable java.awt (the java.desktop module or its native libraries are missing)" - )] - MissingAwtSupport { path: String }, + #[error( + "java installation at '{path}' has no usable java.awt (the java.desktop module or its native libraries are missing)" + )] + MissingAwtSupport { path: String }, - #[error("selected java is version {found}, but version {expected} is required")] - VersionMismatch { expected: u32, found: u32 }, + #[error("selected java is version {found}, but version {expected} is required")] + VersionMismatch { expected: u32, found: u32 }, - #[error("failed to parse java version '{version}'")] - ParseVersion { version: String }, + #[error("failed to parse java version '{version}'")] + ParseVersion { version: String }, - #[error("failed to extract archive '{archive}': {source}")] - ArchiveExtract { - archive: String, - source: std::io::Error, - }, + #[error("failed to extract archive '{archive}': {source}")] + ArchiveExtract { + archive: String, + source: std::io::Error, + }, - #[error("failed to extract archive '{archive}'")] - ArchiveExtractFailed { archive: String }, + #[error("failed to extract archive '{archive}'")] + ArchiveExtractFailed { archive: String }, } impl JavaError { - #[must_use] - pub fn is_invalid_installation(&self) -> bool { - matches!( - self, - JavaError::InvalidJavaPath { .. } - | JavaError::MissingAwtSupport { .. } - | JavaError::ParseVersion { .. } - ) - } + #[must_use] + pub fn is_invalid_installation(&self) -> bool { + matches!( + self, + JavaError::InvalidJavaPath { .. } + | JavaError::MissingAwtSupport { .. } + | JavaError::ParseVersion { .. } + ) + } - /// Whether this is an expected outcome or the user's environment rather - /// than a launcher bug - /// Callers decide what to do with that (e.g. suppress error reporting) - #[must_use] - pub fn is_transient(&self) -> bool { - match self { - JavaError::PackageNotFound { .. } | JavaError::Cancelled => true, - JavaError::RuntimeCheckError { source, .. } - | JavaError::ArchiveExtract { source, .. } => is_transient_io(source), - JavaError::PolyIOError(polyio::IOError::IOError(source)) - | JavaError::PolyIOError(polyio::IOError::PathIOError { source, .. }) => { - is_transient_io(source) - } - JavaError::Request(source) => source.is_transient(), - _ => false, - } - } + /// Whether this is an expected outcome or the user's environment rather + /// than a launcher bug + /// Callers decide what to do with that (e.g. suppress error reporting) + #[must_use] + pub fn is_transient(&self) -> bool { + match self { + JavaError::PackageNotFound { .. } | JavaError::Cancelled => true, + JavaError::RuntimeCheckError { source, .. } + | JavaError::ArchiveExtract { source, .. } => is_transient_io(source), + JavaError::PolyIOError(polyio::IOError::IOError(source)) + | JavaError::PolyIOError(polyio::IOError::PathIOError { source, .. }) => { + is_transient_io(source) + } + JavaError::Request(source) => source.is_transient(), + _ => false, + } + } } fn is_transient_io(error: &std::io::Error) -> bool { - use std::io::ErrorKind; + use std::io::ErrorKind; - if let Some(code) = error.raw_os_error() { - #[cfg(unix)] - if code == 28 { - // ENOSPC - return true; - } - #[cfg(windows)] - if code == 112 || code == 39 { - return true; - } - let _ = code; - } + if let Some(code) = error.raw_os_error() { + #[cfg(unix)] + if code == 28 { + // ENOSPC + return true; + } + #[cfg(windows)] + if code == 112 || code == 39 { + return true; + } + let _ = code; + } - matches!( - error.kind(), - ErrorKind::ConnectionRefused - | ErrorKind::ConnectionReset - | ErrorKind::ConnectionAborted - | ErrorKind::NotConnected - | ErrorKind::TimedOut - ) -} \ No newline at end of file + matches!( + error.kind(), + ErrorKind::ConnectionRefused + | ErrorKind::ConnectionReset + | ErrorKind::ConnectionAborted + | ErrorKind::NotConnected + | ErrorKind::TimedOut + ) +} diff --git a/packages/oneclient_java/src/install.rs b/packages/oneclient_java/src/install.rs index dba03be9..5adf9a7e 100644 --- a/packages/oneclient_java/src/install.rs +++ b/packages/oneclient_java/src/install.rs @@ -1,103 +1,100 @@ use std::path::{Path, PathBuf}; -use oneclient_net::ResponseNotifyOptions; -use crate::data::{ - java_executable_relative_path, JavaPackage, PackageArchive, -}; -use oneclient_events::{GroupedProgressSession, TaskCategory, TaskPhase}; +use crate::data::{JavaPackage, PackageArchive, java_executable_relative_path}; +use crate::error::JavaResult; use oneclient_common::paths; use oneclient_events::EventBus; +use oneclient_events::{GroupedProgressSession, TaskCategory, TaskPhase}; use oneclient_net::RequestClient; -use crate::error::JavaResult; +use oneclient_net::ResponseNotifyOptions; #[tracing::instrument(level = "debug", skip(net, events, progress))] pub async fn install_package( - package: &JavaPackage, - net: &RequestClient, - events: &EventBus, - progress: Option<&GroupedProgressSession>, + package: &JavaPackage, + net: &RequestClient, + events: &EventBus, + progress: Option<&GroupedProgressSession>, ) -> JavaResult { - let java_dir = paths::java_dir()?; - polyio::create_dir_all(&java_dir).await?; + let java_dir = paths::java_dir()?; + polyio::create_dir_all(&java_dir).await?; - let archive_path = java_dir.join(polyio::sanitize_path(&package.name)); + let archive_path = java_dir.join(polyio::sanitize_path(&package.name)); - let major = package.java_version.first().copied().unwrap_or(0); + let major = package.java_version.first().copied().unwrap_or(0); - // 0 means unknown the real total comes from Content-Length once headers land - let expected_size = package.size.unwrap_or(0); - let child = progress.map(|session| { - session.child( - format!("{} {}", package.vendor, major), - expected_size, - TaskCategory::Java, - ) - }); + // 0 means unknown the real total comes from Content-Length once headers land + let expected_size = package.size.unwrap_or(0); + let child = progress.map(|session| { + session.child( + format!("{} {}", package.vendor, major), + expected_size, + TaskCategory::Java, + ) + }); - let notify = match &child { - Some(child) => ResponseNotifyOptions::grouped(child.clone()), - None => ResponseNotifyOptions::standalone(format!( - "Installing {} {}", - package.vendor, major - )) - .done_label(format!("Installed {} {}", package.vendor, major)), - }; + let notify = match &child { + Some(child) => ResponseNotifyOptions::grouped(child.clone()), + None => { + ResponseNotifyOptions::standalone(format!("Installing {} {}", package.vendor, major)) + .done_label(format!("Installed {} {}", package.vendor, major)) + } + }; - if package.checksum.is_none() { - tracing::warn!( - vendor = %package.vendor, - major, - "vendor published no usable checksum; installing runtime unverified" - ); - } + if package.checksum.is_none() { + tracing::warn!( + vendor = %package.vendor, + major, + "vendor published no usable checksum; installing runtime unverified" + ); + } - oneclient_net::download_verified( - net, - events, - &package.download_url, - &archive_path, - package.checksum.as_ref(), - expected_size, - Some(notify), - ) - .await?; + oneclient_net::download_verified( + net, + events, + &package.download_url, + &archive_path, + package.checksum.as_ref(), + expected_size, + Some(notify), + ) + .await?; - let extract_root = java_dir.join(stem_without_archive(&package.name)); + let extract_root = java_dir.join(stem_without_archive(&package.name)); - if let Some(child) = &child { - child.set_phase(TaskPhase::Extracting); - } - //events.notify("Extracting package...").body(format!("{} {}", package.vendor, major)).send(); + if let Some(child) = &child { + child.set_phase(TaskPhase::Extracting); + } + //events.notify("Extracting package...").body(format!("{} {}", package.vendor, major)).send(); - match package.archive { - PackageArchive::Zip => polyio::extract_zip(&archive_path, &extract_root).await?, - PackageArchive::TarGz => polyio::extract_tar_gz(&archive_path, &extract_root).await?, - } + match package.archive { + PackageArchive::Zip => polyio::extract_zip(&archive_path, &extract_root).await?, + PackageArchive::TarGz => polyio::extract_tar_gz(&archive_path, &extract_root).await?, + } - // Walks the extract root and stats candidates, so it stays off the caller's - // executor for the same reason the extraction above does - let executable = { - let extract_root = extract_root.clone(); - let package = package.clone(); - tokio::task::spawn_blocking(move || resolve_installed_executable(&extract_root, &package)) - .await - .map_err(|err| polyio::IOError::from(std::io::Error::other(err)))? - }; + // Walks the extract root and stats candidates, so it stays off the caller's + // executor for the same reason the extraction above does + let executable = { + let extract_root = extract_root.clone(); + let package = package.clone(); + tokio::task::spawn_blocking(move || resolve_installed_executable(&extract_root, &package)) + .await + .map_err(|err| polyio::IOError::from(std::io::Error::other(err)))? + }; - #[cfg(unix)] - { - let _ = tokio::process::Command::new("chmod") - .arg("755") - .arg(&executable) - .output() - .await; - } + #[cfg(unix)] + { + let _ = tokio::process::Command::new("chmod") + .arg("755") + .arg(&executable) + .output() + .await; + } - let _ = polyio::remove_file(&archive_path).await; + let _ = polyio::remove_file(&archive_path).await; - tracing::info!(vendor = %package.vendor, major, "installed Java runtime"); + tracing::info!(vendor = %package.vendor, major, "installed Java runtime"); - Ok(executable) + Ok(executable) } /// The directory this crate extracted for `executable`, or `None` when the @@ -105,53 +102,53 @@ pub async fn install_package( /// user added themselves points #[tracing::instrument(level = "debug")] fn managed_install_root(executable: &Path) -> JavaResult> { - let java_dir = paths::java_dir()?; + let java_dir = paths::java_dir()?; - let Ok(root) = polyio::canonicalize(&java_dir) else { - return Ok(None); - }; + let Ok(root) = polyio::canonicalize(&java_dir) else { + return Ok(None); + }; - // Refuses to resolve once the executable is gone which is the safe answer - // there is no way left to prove what the path pointed at - let canon = match polyio::ensure_under(executable, [&root]) { - Ok(Some(canon)) => canon, - Ok(None) => return Ok(None), - Err(err) => { - tracing::debug!("could not resolve the java runtime path: {err}"); - return Ok(None); - } - }; + // Refuses to resolve once the executable is gone which is the safe answer + // there is no way left to prove what the path pointed at + let canon = match polyio::ensure_under(executable, [&root]) { + Ok(Some(canon)) => canon, + Ok(None) => return Ok(None), + Err(err) => { + tracing::debug!("could not resolve the java runtime path: {err}"); + return Ok(None); + } + }; - let Ok(relative) = canon.strip_prefix(&root) else { - return Ok(None); - }; + let Ok(relative) = canon.strip_prefix(&root) else { + return Ok(None); + }; - match relative.components().next() { - Some(std::path::Component::Normal(name)) => Ok(Some(root.join(name))), - _ => Ok(None), - } + match relative.components().next() { + Some(std::path::Component::Normal(name)) => Ok(Some(root.join(name))), + _ => Ok(None), + } } /// Whether [`remove_installed_package`] would take this runtime's files with /// it so the UI can say up front what removing it costs #[must_use] pub fn is_launcher_managed(executable: &Path) -> bool { - managed_install_root(executable).is_ok_and(|root| root.is_some()) + managed_install_root(executable).is_ok_and(|root| root.is_some()) } /// Only ever touches OneClient's own java dir a runtime the user added from /// their own folder is left on disk untouched #[tracing::instrument(level = "debug")] pub async fn remove_installed_package(executable: &Path) -> JavaResult { - let Some(install_root) = managed_install_root(executable)? else { - tracing::debug!("java runtime is not launcher-managed leaving its files alone"); - return Ok(false); - }; + let Some(install_root) = managed_install_root(executable)? else { + tracing::debug!("java runtime is not launcher-managed leaving its files alone"); + return Ok(false); + }; - polyio::remove_dir_all(&install_root).await?; + polyio::remove_dir_all(&install_root).await?; - tracing::info!(path = %install_root.display(), "removed installed Java runtime files"); - Ok(true) + tracing::info!(path = %install_root.display(), "removed installed Java runtime files"); + Ok(true) } #[tracing::instrument(level = "debug")] @@ -187,143 +184,145 @@ fn resolve_installed_executable(extract_root: &Path, package: &JavaPackage) -> P if !base_path.join("Contents").join("Home").exists() && let Ok(entries) = std::fs::read_dir(&base_path) - && let Some(dir) = entries.flatten().find(|entry| { - let file_name = entry.file_name(); - let name = file_name.to_string_lossy(); - (name.ends_with(".jre") || name.ends_with(".jdk") || name.contains("zulu")) - && entry.path().join("Contents").join("Home").exists() - }) { - base_path = dir.path(); - } + && let Some(dir) = entries.flatten().find(|entry| { + let file_name = entry.file_name(); + let name = file_name.to_string_lossy(); + (name.ends_with(".jre") || name.ends_with(".jdk") || name.contains("zulu")) + && entry.path().join("Contents").join("Home").exists() + }) + { + base_path = dir.path(); + } if package.vendor == JavaVendor::Zulu - && let Some(major) = package.java_version.first() { - let zulu_bundle = base_path.join(format!("zulu-{major}.jre")); - if zulu_bundle.join("Contents").join("Home").exists() { - base_path = zulu_bundle; - } + && let Some(major) = package.java_version.first() + { + let zulu_bundle = base_path.join(format!("zulu-{major}.jre")); + if zulu_bundle.join("Contents").join("Home").exists() { + base_path = zulu_bundle; } + } base_path.join(java_executable_relative_path()) } } fn stem_without_archive(name: &str) -> String { - let path = std::path::Path::new(name); - let stem = path - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or(name); + let path = std::path::Path::new(name); + let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or(name); - if stem.ends_with(".tar") { - std::path::Path::new(stem) - .file_stem() - .and_then(|s| s.to_str()) - .map(String::from) - .unwrap_or_else(|| stem.to_string()) - } else { - stem.to_string() - } + if stem.ends_with(".tar") { + std::path::Path::new(stem) + .file_stem() + .and_then(|s| s.to_str()) + .map(String::from) + .unwrap_or_else(|| stem.to_string()) + } else { + stem.to_string() + } } #[cfg(test)] mod tests { - use super::*; - use std::sync::atomic::{AtomicU32, Ordering}; + use super::*; + use std::sync::atomic::{AtomicU32, Ordering}; - /// Keeps the whole suite inside a temp tree the override is a `OnceLock` so - /// the first caller wins and every case works under one java dir - fn java_dir() -> PathBuf { - static ONCE: std::sync::Once = std::sync::Once::new(); - ONCE.call_once(|| { - paths::set_launcher_dir( - std::env::temp_dir().join(format!("oneclient-java-test-{}", std::process::id())), - ); - }); + /// Keeps the whole suite inside a temp tree the override is a `OnceLock` so + /// the first caller wins and every case works under one java dir + fn java_dir() -> PathBuf { + static ONCE: std::sync::Once = std::sync::Once::new(); + ONCE.call_once(|| { + paths::set_launcher_dir( + std::env::temp_dir().join(format!("oneclient-java-test-{}", std::process::id())), + ); + }); - let dir = paths::java_dir().unwrap(); - std::fs::create_dir_all(&dir).unwrap(); - dir - } + let dir = paths::java_dir().unwrap(); + std::fs::create_dir_all(&dir).unwrap(); + dir + } - fn install_runtime(root: &Path) -> (PathBuf, PathBuf) { - static N: AtomicU32 = AtomicU32::new(0); - let install = root.join(format!("zulu21-{}", N.fetch_add(1, Ordering::Relaxed))); - let bin = install.join("zulu21.0.12").join("bin"); - std::fs::create_dir_all(&bin).unwrap(); + fn install_runtime(root: &Path) -> (PathBuf, PathBuf) { + static N: AtomicU32 = AtomicU32::new(0); + let install = root.join(format!("zulu21-{}", N.fetch_add(1, Ordering::Relaxed))); + let bin = install.join("zulu21.0.12").join("bin"); + std::fs::create_dir_all(&bin).unwrap(); - let executable = bin.join("java"); - std::fs::write(&executable, b"").unwrap(); - (install, executable) - } + let executable = bin.join("java"); + std::fs::write(&executable, b"").unwrap(); + (install, executable) + } - #[test] - fn resolves_the_extracted_directory_not_the_executable_parent() { - let java_dir = java_dir(); - let (install, executable) = install_runtime(&java_dir); + #[test] + fn resolves_the_extracted_directory_not_the_executable_parent() { + let java_dir = java_dir(); + let (install, executable) = install_runtime(&java_dir); - let resolved = managed_install_root(&executable).unwrap().unwrap(); + let resolved = managed_install_root(&executable).unwrap().unwrap(); - assert_eq!(resolved, polyio::canonicalize(&install).unwrap()); - } + assert_eq!(resolved, polyio::canonicalize(&install).unwrap()); + } - #[tokio::test] - async fn removing_a_managed_runtime_deletes_its_files() { - let java_dir = java_dir(); - let (install, executable) = install_runtime(&java_dir); + #[tokio::test] + async fn removing_a_managed_runtime_deletes_its_files() { + let java_dir = java_dir(); + let (install, executable) = install_runtime(&java_dir); - assert!(remove_installed_package(&executable).await.unwrap()); + assert!(remove_installed_package(&executable).await.unwrap()); - assert!(!install.exists()); - assert!(java_dir.exists(), "the java dir itself must survive"); - } + assert!(!install.exists()); + assert!(java_dir.exists(), "the java dir itself must survive"); + } - #[tokio::test] - async fn a_runtime_outside_the_java_dir_keeps_its_files() { - let java_dir = java_dir(); - let elsewhere = java_dir.parent().unwrap().join("user-picked-jdk"); - let (_, executable) = install_runtime(&elsewhere); + #[tokio::test] + async fn a_runtime_outside_the_java_dir_keeps_its_files() { + let java_dir = java_dir(); + let elsewhere = java_dir.parent().unwrap().join("user-picked-jdk"); + let (_, executable) = install_runtime(&elsewhere); - assert_eq!(managed_install_root(&executable).unwrap(), None); - assert!(!remove_installed_package(&executable).await.unwrap()); - assert!(executable.exists(), "a folder the user added is not ours to delete"); + assert_eq!(managed_install_root(&executable).unwrap(), None); + assert!(!remove_installed_package(&executable).await.unwrap()); + assert!( + executable.exists(), + "a folder the user added is not ours to delete" + ); - std::fs::remove_dir_all(&elsewhere).unwrap(); - } + std::fs::remove_dir_all(&elsewhere).unwrap(); + } - #[test] - fn a_traversal_back_out_of_the_java_dir_is_not_managed() { - let java_dir = java_dir(); - let elsewhere = java_dir.parent().unwrap().join("traversal-jdk"); - let (_, executable) = install_runtime(&elsewhere); + #[test] + fn a_traversal_back_out_of_the_java_dir_is_not_managed() { + let java_dir = java_dir(); + let elsewhere = java_dir.parent().unwrap().join("traversal-jdk"); + let (_, executable) = install_runtime(&elsewhere); - let sneaky = java_dir - .join("..") - .join("traversal-jdk") - .join(executable.strip_prefix(&elsewhere).unwrap()); + let sneaky = java_dir + .join("..") + .join("traversal-jdk") + .join(executable.strip_prefix(&elsewhere).unwrap()); - assert_eq!(managed_install_root(&sneaky).unwrap(), None); + assert_eq!(managed_install_root(&sneaky).unwrap(), None); - std::fs::remove_dir_all(&elsewhere).unwrap(); - } + std::fs::remove_dir_all(&elsewhere).unwrap(); + } - #[test] - fn the_managed_flag_matches_what_removal_would_actually_delete() { - let java_dir = java_dir(); - let (_, ours) = install_runtime(&java_dir); - let elsewhere = java_dir.parent().unwrap().join("flagged-jdk"); - let (_, theirs) = install_runtime(&elsewhere); + #[test] + fn the_managed_flag_matches_what_removal_would_actually_delete() { + let java_dir = java_dir(); + let (_, ours) = install_runtime(&java_dir); + let elsewhere = java_dir.parent().unwrap().join("flagged-jdk"); + let (_, theirs) = install_runtime(&elsewhere); - assert!(is_launcher_managed(&ours)); - assert!(!is_launcher_managed(&theirs)); + assert!(is_launcher_managed(&ours)); + assert!(!is_launcher_managed(&theirs)); - std::fs::remove_dir_all(&elsewhere).unwrap(); - } + std::fs::remove_dir_all(&elsewhere).unwrap(); + } - #[test] - fn the_java_dir_itself_is_never_managed() { - let java_dir = java_dir(); + #[test] + fn the_java_dir_itself_is_never_managed() { + let java_dir = java_dir(); - assert_eq!(managed_install_root(&java_dir).unwrap(), None); - } + assert_eq!(managed_install_root(&java_dir).unwrap(), None); + } } diff --git a/packages/oneclient_java/src/lib.rs b/packages/oneclient_java/src/lib.rs index 26b3cbbf..f64cc1b8 100644 --- a/packages/oneclient_java/src/lib.rs +++ b/packages/oneclient_java/src/lib.rs @@ -20,8 +20,8 @@ pub use install::{install_package, is_launcher_managed}; pub use locate::{LocatedJava, best_for_major, locate_java}; pub use platform::{HostArch, HostOs, HostTarget, prefer_dedicated_gpu}; pub use service::{ - AvailableJava, INSTALLABLE_MAJORS, JAVA_CHOICE_DOWNLOAD, JAVA_CHOICE_FOLDER, JAVA_VENDOR_HINT, - JavaService, + AvailableJava, INSTALLABLE_MAJORS, JAVA_CHOICE_DOWNLOAD, JAVA_CHOICE_FOLDER, JAVA_VENDOR_HINT, + JavaService, }; pub use store::{JavaStore, MemoryJavaStore, StoreError, StoreResult}; pub use vendors::JavaVendor; diff --git a/packages/oneclient_java/src/locate.rs b/packages/oneclient_java/src/locate.rs index 2194b490..88341500 100644 --- a/packages/oneclient_java/src/locate.rs +++ b/packages/oneclient_java/src/locate.rs @@ -1,73 +1,72 @@ use std::cmp::Ordering; use std::path::PathBuf; - -use oneclient_common::constants::JAVA_BIN; use crate::checker::{JavaCheckInfo, check_java_runtime}; use crate::data::java_executable_relative_path; use crate::error::JavaResult; use crate::service::parse_major_version; +use oneclient_common::constants::JAVA_BIN; pub type LocatedJava = (PathBuf, JavaCheckInfo); /// Best first #[tracing::instrument(level = "debug")] pub async fn locate_java() -> JavaResult> { - let paths = internal_locate_java(); - let mut valid: Vec = Vec::new(); + let paths = internal_locate_java(); + let mut valid: Vec = Vec::new(); - for path in paths { - if valid.iter().any(|(existing, _)| existing == &path) { - continue; - } + for path in paths { + if valid.iter().any(|(existing, _)| existing == &path) { + continue; + } - let Ok(info) = check_java_runtime(path.display().to_string()).await else { - tracing::warn!("java installation at '{}' is not valid", path.display()); - continue; - }; - valid.push((path, info)); - } + let Ok(info) = check_java_runtime(path.display().to_string()).await else { + tracing::warn!("java installation at '{}' is not valid", path.display()); + continue; + }; + valid.push((path, info)); + } - valid.sort_by(|(_, a), (_, b)| compare_candidates(a, b)); + valid.sort_by(|(_, a), (_, b)| compare_candidates(a, b)); - tracing::debug!(count = valid.len(), "located Java runtimes"); + tracing::debug!(count = valid.len(), "located Java runtimes"); - Ok(valid) + Ok(valid) } /// `major` is a filter not a ranking input /// a newer JDK never stands in for the requested major #[must_use] pub fn best_for_major(candidates: &[LocatedJava], major: u32) -> Option<&LocatedJava> { - candidates - .iter() - .filter(|(_, info)| parse_major_version(&info.version).is_ok_and(|found| found == major)) - .min_by(|(_, a), (_, b)| compare_candidates(a, b)) + candidates + .iter() + .filter(|(_, info)| parse_major_version(&info.version).is_ok_and(|found| found == major)) + .min_by(|(_, a), (_, b)| compare_candidates(a, b)) } /// Best first a JDK beats a JRE outright then the newer version wins fn compare_candidates(a: &JavaCheckInfo, b: &JavaCheckInfo) -> Ordering { - b.is_jdk - .cmp(&a.is_jdk) - .then_with(|| version_key(b).cmp(&version_key(a))) + b.is_jdk + .cmp(&a.is_jdk) + .then_with(|| version_key(b).cmp(&version_key(a))) } /// Numeric so `21.0.10` sorts above `21.0.9` fn version_key(info: &JavaCheckInfo) -> (u32, Vec) { - let major = parse_major_version(&info.version).unwrap_or(0); - let parts = info - .version - .split(|c: char| !c.is_ascii_digit()) - .filter_map(|part| part.parse::().ok()) - .collect(); + let major = parse_major_version(&info.version).unwrap_or(0); + let parts = info + .version + .split(|c: char| !c.is_ascii_digit()) + .filter_map(|part| part.parse::().ok()) + .collect(); - (major, parts) + (major, parts) } #[cfg(target_os = "windows")] #[tracing::instrument(level = "debug")] fn internal_locate_java() -> Vec { - let mut java_homes = Vec::new(); + let mut java_homes = Vec::new(); let windows_paths = { let mut paths = vec![ @@ -89,42 +88,42 @@ fn internal_locate_java() -> Vec { paths }; - for base_path in windows_paths { - if let Ok(entries) = std::fs::read_dir(base_path) { - for entry in entries.flatten() { - if entry.file_type().is_ok_and(|ft| ft.is_dir()) { - let java_exe = entry.path().join(java_executable_relative_path()); - if java_exe.exists() { - java_homes.push(java_exe); - } - } - } - } - } + for base_path in windows_paths { + if let Ok(entries) = std::fs::read_dir(base_path) { + for entry in entries.flatten() { + if entry.file_type().is_ok_and(|ft| ft.is_dir()) { + let java_exe = entry.path().join(java_executable_relative_path()); + if java_exe.exists() { + java_homes.push(java_exe); + } + } + } + } + } - if let Ok(java_home) = std::env::var("JAVA_HOME") { - let java_exe = PathBuf::from(java_home).join(java_executable_relative_path()); - if java_exe.exists() { - java_homes.push(java_exe); - } - } + if let Ok(java_home) = std::env::var("JAVA_HOME") { + let java_exe = PathBuf::from(java_home).join(java_executable_relative_path()); + if java_exe.exists() { + java_homes.push(java_exe); + } + } - if let Ok(path) = std::env::var("PATH") { - for path_entry in path.split(';') { - let java_exe = PathBuf::from(path_entry).join(JAVA_BIN); - if java_exe.exists() && !java_homes.contains(&java_exe) { - java_homes.push(java_exe); - } - } - } + if let Ok(path) = std::env::var("PATH") { + for path_entry in path.split(';') { + let java_exe = PathBuf::from(path_entry).join(JAVA_BIN); + if java_exe.exists() && !java_homes.contains(&java_exe) { + java_homes.push(java_exe); + } + } + } - java_homes + java_homes } #[cfg(target_os = "macos")] #[tracing::instrument(level = "debug")] fn internal_locate_java() -> Vec { - let mut found = Vec::new(); + let mut found = Vec::new(); let macos_paths = { let mut paths = vec![ @@ -138,31 +137,35 @@ fn internal_locate_java() -> Vec { PathBuf::from("/usr/local/opt"), ]; if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) { - paths.push(home.join("Library").join("Java").join("JavaVirtualMachines")); + paths.push( + home.join("Library") + .join("Java") + .join("JavaVirtualMachines"), + ); paths.push(home.join(".jdks")); paths.push(home.join(".sdkman").join("candidates").join("java")); } paths }; - for path in macos_paths { - if let Ok(children) = std::fs::read_dir(path) { - for child in children.flatten() { - let java_exe = child.path().join(java_executable_relative_path()); - if java_exe.exists() { - found.push(java_exe); - } - } - } - } + for path in macos_paths { + if let Ok(children) = std::fs::read_dir(path) { + for child in children.flatten() { + let java_exe = child.path().join(java_executable_relative_path()); + if java_exe.exists() { + found.push(java_exe); + } + } + } + } - find_java_in_path(found) + find_java_in_path(found) } #[cfg(target_os = "linux")] #[tracing::instrument(level = "debug")] fn internal_locate_java() -> Vec { - let mut found = Vec::new(); + let mut found = Vec::new(); let linux_paths = { let mut paths = vec![ @@ -182,104 +185,104 @@ fn internal_locate_java() -> Vec { paths }; - for path in linux_paths { - if let Ok(children) = std::fs::read_dir(path) { - for child in children.flatten() { - let java_exe = child.path().join(java_executable_relative_path()); - if java_exe.exists() { - found.push(java_exe); - } - } - } - } + for path in linux_paths { + if let Ok(children) = std::fs::read_dir(path) { + for child in children.flatten() { + let java_exe = child.path().join(java_executable_relative_path()); + if java_exe.exists() { + found.push(java_exe); + } + } + } + } - find_java_in_path(found) + find_java_in_path(found) } #[cfg(unix)] fn find_java_in_path(mut found: Vec) -> Vec { - if let Ok(path) = std::env::var("PATH") { - for path_entry in path.split(':') { - let java = PathBuf::from(path_entry).join(JAVA_BIN); - if java.exists() && java.is_file() && !found.contains(&java) { - found.push(java); - } - } - } - found + if let Ok(path) = std::env::var("PATH") { + for path_entry in path.split(':') { + let java = PathBuf::from(path_entry).join(JAVA_BIN); + if java.exists() && java.is_file() && !found.contains(&java) { + found.push(java); + } + } + } + found } #[cfg(test)] mod tests { - use super::*; + use super::*; - fn candidate(path: &str, version: &str, is_jdk: bool) -> LocatedJava { - ( - PathBuf::from(path), - JavaCheckInfo { - version: version.to_string(), - vendor: String::from("Test"), - os_arch: String::from("x64"), - is_jdk, - }, - ) - } + fn candidate(path: &str, version: &str, is_jdk: bool) -> LocatedJava { + ( + PathBuf::from(path), + JavaCheckInfo { + version: version.to_string(), + vendor: String::from("Test"), + os_arch: String::from("x64"), + is_jdk, + }, + ) + } - fn ranked(mut candidates: Vec) -> Vec { - candidates.sort_by(|(_, a), (_, b)| compare_candidates(a, b)); - candidates - .into_iter() - .map(|(path, _)| path.display().to_string()) - .collect() - } + fn ranked(mut candidates: Vec) -> Vec { + candidates.sort_by(|(_, a), (_, b)| compare_candidates(a, b)); + candidates + .into_iter() + .map(|(path, _)| path.display().to_string()) + .collect() + } - #[test] - fn a_kit_outranks_a_runtime_even_when_it_is_older() { - let ranking = ranked(vec![ - candidate("/jre-21", "21.0.9", false), - candidate("/jdk-21", "21.0.1", true), - ]); + #[test] + fn a_kit_outranks_a_runtime_even_when_it_is_older() { + let ranking = ranked(vec![ + candidate("/jre-21", "21.0.9", false), + candidate("/jdk-21", "21.0.1", true), + ]); - assert_eq!(ranking, vec!["/jdk-21", "/jre-21"]); - } + assert_eq!(ranking, vec!["/jdk-21", "/jre-21"]); + } - #[test] - fn patch_levels_are_compared_as_numbers() { - let ranking = ranked(vec![ - candidate("/jdk-21.0.9", "21.0.9", true), - candidate("/jdk-21.0.10", "21.0.10", true), - ]); + #[test] + fn patch_levels_are_compared_as_numbers() { + let ranking = ranked(vec![ + candidate("/jdk-21.0.9", "21.0.9", true), + candidate("/jdk-21.0.10", "21.0.10", true), + ]); - assert_eq!(ranking, vec!["/jdk-21.0.10", "/jdk-21.0.9"]); - } + assert_eq!(ranking, vec!["/jdk-21.0.10", "/jdk-21.0.9"]); + } - #[test] - fn the_requested_major_still_wins_over_a_newer_kit() { - let candidates = vec![ - candidate("/jdk-21", "21.0.3", true), - candidate("/jre-8", "1.8.0_412", false), - ]; + #[test] + fn the_requested_major_still_wins_over_a_newer_kit() { + let candidates = vec![ + candidate("/jdk-21", "21.0.3", true), + candidate("/jre-8", "1.8.0_412", false), + ]; - let (path, _) = best_for_major(&candidates, 8).expect("a java 8 candidate"); - assert_eq!(path, &PathBuf::from("/jre-8")); - } + let (path, _) = best_for_major(&candidates, 8).expect("a java 8 candidate"); + assert_eq!(path, &PathBuf::from("/jre-8")); + } - #[test] - fn the_kit_wins_among_candidates_of_the_requested_major() { - let candidates = vec![ - candidate("/jre-17-newer", "17.0.11", false), - candidate("/jdk-17", "17.0.2", true), - candidate("/jdk-21", "21.0.3", true), - ]; + #[test] + fn the_kit_wins_among_candidates_of_the_requested_major() { + let candidates = vec![ + candidate("/jre-17-newer", "17.0.11", false), + candidate("/jdk-17", "17.0.2", true), + candidate("/jdk-21", "21.0.3", true), + ]; - let (path, _) = best_for_major(&candidates, 17).expect("a java 17 candidate"); - assert_eq!(path, &PathBuf::from("/jdk-17")); - } + let (path, _) = best_for_major(&candidates, 17).expect("a java 17 candidate"); + assert_eq!(path, &PathBuf::from("/jdk-17")); + } - #[test] - fn a_major_nobody_satisfies_has_no_best() { - let candidates = vec![candidate("/jdk-21", "21.0.3", true)]; + #[test] + fn a_major_nobody_satisfies_has_no_best() { + let candidates = vec![candidate("/jdk-21", "21.0.3", true)]; - assert!(best_for_major(&candidates, 8).is_none()); - } + assert!(best_for_major(&candidates, 8).is_none()); + } } diff --git a/packages/oneclient_java/src/platform.rs b/packages/oneclient_java/src/platform.rs index 8e23395c..3b13b1f7 100644 --- a/packages/oneclient_java/src/platform.rs +++ b/packages/oneclient_java/src/platform.rs @@ -5,75 +5,75 @@ use crate::data::PackageArchive; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum HostArch { - X86, - X86_64, - Arm, - Aarch64, + X86, + X86_64, + Arm, + Aarch64, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum HostOs { - Windows, - MacOs, - /// Several vendors publish musl builds under a separate name - Linux { - musl: bool, - }, + Windows, + MacOs, + /// Several vendors publish musl builds under a separate name + Linux { + musl: bool, + }, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct HostTarget { - pub arch: HostArch, - pub os: HostOs, + pub arch: HostArch, + pub os: HostOs, } impl HostTarget { - pub const CURRENT: Self = Self { - arch: cfg_select! { - target_arch = "x86" => HostArch::X86, - target_arch = "x86_64" => HostArch::X86_64, - target_arch = "arm" => HostArch::Arm, - target_arch = "aarch64" => HostArch::Aarch64, - }, - os: cfg_select! { - target_os = "windows" => HostOs::Windows, - target_os = "macos" => HostOs::MacOs, - target_os = "linux" => cfg_select! { - target_env = "musl" => HostOs::Linux { musl: true }, - _ => HostOs::Linux { musl: false }, - }, - }, - }; + pub const CURRENT: Self = Self { + arch: cfg_select! { + target_arch = "x86" => HostArch::X86, + target_arch = "x86_64" => HostArch::X86_64, + target_arch = "arm" => HostArch::Arm, + target_arch = "aarch64" => HostArch::Aarch64, + }, + os: cfg_select! { + target_os = "windows" => HostOs::Windows, + target_os = "macos" => HostOs::MacOs, + target_os = "linux" => cfg_select! { + target_env = "musl" => HostOs::Linux { musl: true }, + _ => HostOs::Linux { musl: false }, + }, + }, + }; - /// Liberica takes architecture and bitness as separate query parameters - #[must_use] - pub const fn bitness(self) -> &'static str { - match self.arch { - HostArch::X86 | HostArch::Arm => "32", - HostArch::X86_64 | HostArch::Aarch64 => "64", - } - } + /// Liberica takes architecture and bitness as separate query parameters + #[must_use] + pub const fn bitness(self) -> &'static str { + match self.arch { + HostArch::X86 | HostArch::Arm => "32", + HostArch::X86_64 | HostArch::Aarch64 => "64", + } + } - #[must_use] - pub const fn archive(self) -> PackageArchive { - match self.os { - HostOs::Windows => PackageArchive::Zip, - _ => PackageArchive::TarGz, - } - } + #[must_use] + pub const fn archive(self) -> PackageArchive { + match self.os { + HostOs::Windows => PackageArchive::Zip, + _ => PackageArchive::TarGz, + } + } - #[must_use] - pub const fn archive_ext(self) -> &'static str { - match self.os { - HostOs::Windows => "zip", - _ => "tar.gz", - } - } + #[must_use] + pub const fn archive_ext(self) -> &'static str { + match self.os { + HostOs::Windows => "zip", + _ => "tar.gz", + } + } - #[must_use] - pub const fn is_musl(self) -> bool { - matches!(self.os, HostOs::Linux { musl: true }) - } + #[must_use] + pub const fn is_musl(self) -> bool { + matches!(self.os, HostOs::Linux { musl: true }) + } } #[cfg(windows)] @@ -86,118 +86,122 @@ const GPU_PREFERENCE_EXECUTABLES: [&str; 2] = ["javaw.exe", "java.exe"]; /// Absolute so a `reg.exe` earlier in the search order cannot run in its place #[cfg(windows)] fn reg_command() -> tokio::process::Command { - const NO_WINDOW: u32 = 0x0800_0000; + const NO_WINDOW: u32 = 0x0800_0000; - let system_root = std::env::var_os("SystemRoot").unwrap_or_else(|| r"C:\Windows".into()); - let exe = std::path::Path::new(&system_root) - .join("System32") - .join("reg.exe"); + let system_root = std::env::var_os("SystemRoot").unwrap_or_else(|| r"C:\Windows".into()); + let exe = std::path::Path::new(&system_root) + .join("System32") + .join("reg.exe"); - let mut command = tokio::process::Command::new(exe); - command.creation_flags(NO_WINDOW); - command + let mut command = tokio::process::Command::new(exe); + command.creation_flags(NO_WINDOW); + command } #[cfg(windows)] fn registered_value<'a>(query: &'a str, name: &str) -> Option<&'a str> { - query.lines().find_map(|line| { - let (value_name, data) = line.split_once("REG_SZ")?; - if value_name.trim().eq_ignore_ascii_case(name) { - Some(data.trim()) - } else { - None - } - }) + query.lines().find_map(|line| { + let (value_name, data) = line.split_once("REG_SZ")?; + if value_name.trim().eq_ignore_ascii_case(name) { + Some(data.trim()) + } else { + None + } + }) } /// Windows writes `GpuPreference=0;` ("let Windows decide") for any app added /// through Settings, so only 1 or 2 is a choice worth leaving alone #[cfg(windows)] fn has_explicit_preference(data: &str) -> bool { - data.split(';') - .find_map(|part| part.trim().strip_prefix("GpuPreference=")) - .is_some_and(|preference| matches!(preference.trim(), "1" | "2")) + data.split(';') + .find_map(|part| part.trim().strip_prefix("GpuPreference=")) + .is_some_and(|preference| matches!(preference.trim(), "1" | "2")) } #[cfg(windows)] pub async fn prefer_dedicated_gpu(java_path: &std::path::Path) { - let Some(dir) = java_path.parent() else { return }; + let Some(dir) = java_path.parent() else { + return; + }; - let mut targets = Vec::new(); - for exe in GPU_PREFERENCE_EXECUTABLES { - let path = dir.join(exe); - if polyio::try_exists(&path).await.unwrap_or(false) { - targets.push(path); - } - } + let mut targets = Vec::new(); + for exe in GPU_PREFERENCE_EXECUTABLES { + let path = dir.join(exe); + if polyio::try_exists(&path).await.unwrap_or(false) { + targets.push(path); + } + } - if targets.is_empty() { - return; - } + if targets.is_empty() { + return; + } - // One query for the whole key instead of one per executable - let query = match reg_command() - .args(["query", GPU_PREFERENCES_KEY]) - .output() - .await - { - Ok(out) => String::from_utf8_lossy(&out.stdout).into_owned(), - Err(err) => { - tracing::warn!(%err, "could not run reg.exe"); - return; - } - }; + // One query for the whole key instead of one per executable + let query = match reg_command() + .args(["query", GPU_PREFERENCES_KEY]) + .output() + .await + { + Ok(out) => String::from_utf8_lossy(&out.stdout).into_owned(), + Err(err) => { + tracing::warn!(%err, "could not run reg.exe"); + return; + } + }; - for path in targets { - if registered_value(&query, &path.to_string_lossy()).is_some_and(has_explicit_preference) { - continue; - } + for path in targets { + if registered_value(&query, &path.to_string_lossy()).is_some_and(has_explicit_preference) { + continue; + } - let added = reg_command() - .args(["add", GPU_PREFERENCES_KEY, "/v"]) - .arg(&path) - .args(["/t", "REG_SZ", "/d", "GpuPreference=2;", "/f"]) - .output() - .await; + let added = reg_command() + .args(["add", GPU_PREFERENCES_KEY, "/v"]) + .arg(&path) + .args(["/t", "REG_SZ", "/d", "GpuPreference=2;", "/f"]) + .output() + .await; - match added { - Ok(out) if out.status.success() => { - tracing::info!(path = %path.display(), "registered JVM for high-performance GPU"); - } - Ok(out) => tracing::warn!( - path = %path.display(), - stderr = %String::from_utf8_lossy(&out.stderr).trim(), - "could not set GPU preference" - ), - Err(err) => tracing::warn!(%err, "could not run reg.exe"), - } - } + match added { + Ok(out) if out.status.success() => { + tracing::info!(path = %path.display(), "registered JVM for high-performance GPU"); + } + Ok(out) => tracing::warn!( + path = %path.display(), + stderr = %String::from_utf8_lossy(&out.stderr).trim(), + "could not set GPU preference" + ), + Err(err) => tracing::warn!(%err, "could not run reg.exe"), + } + } } /// Runtimes install into version-stamped folders so without this the key keeps /// a dead entry for every upgrade #[cfg(windows)] pub async fn forget_dedicated_gpu(java_path: &std::path::Path) { - let Some(dir) = java_path.parent() else { return }; + let Some(dir) = java_path.parent() else { + return; + }; - for exe in GPU_PREFERENCE_EXECUTABLES { - let path = dir.join(exe); - let removed = reg_command() - .args(["delete", GPU_PREFERENCES_KEY, "/v"]) - .arg(&path) - .arg("/f") - .output() - .await; + for exe in GPU_PREFERENCE_EXECUTABLES { + let path = dir.join(exe); + let removed = reg_command() + .args(["delete", GPU_PREFERENCES_KEY, "/v"]) + .arg(&path) + .arg("/f") + .output() + .await; - match removed { - Ok(out) if out.status.success() => { - tracing::info!(path = %path.display(), "removed GPU preference"); - } - // A value that was never written is reported as an error - Ok(_) => {} - Err(err) => tracing::warn!(%err, "could not run reg.exe"), - } - } + match removed { + Ok(out) if out.status.success() => { + tracing::info!(path = %path.display(), "removed GPU preference"); + } + // A value that was never written is reported as an error + Ok(_) => {} + Err(err) => tracing::warn!(%err, "could not run reg.exe"), + } + } } #[cfg(not(windows))] @@ -208,33 +212,33 @@ pub async fn forget_dedicated_gpu(_java_path: &std::path::Path) {} #[cfg(test)] mod tests { - use super::*; + use super::*; - #[test] - fn bitness_matches_the_architecture() { - assert_eq!(HostTarget::CURRENT.bitness(), { - if cfg!(target_pointer_width = "64") { - "64" - } else { - "32" - } - }); - } + #[test] + fn bitness_matches_the_architecture() { + assert_eq!(HostTarget::CURRENT.bitness(), { + if cfg!(target_pointer_width = "64") { + "64" + } else { + "32" + } + }); + } - #[test] - fn windows_ships_zips_and_everything_else_tarballs() { - let expected = if cfg!(windows) { - PackageArchive::Zip - } else { - PackageArchive::TarGz - }; - assert_eq!(HostTarget::CURRENT.archive(), expected); - } + #[test] + fn windows_ships_zips_and_everything_else_tarballs() { + let expected = if cfg!(windows) { + PackageArchive::Zip + } else { + PackageArchive::TarGz + }; + assert_eq!(HostTarget::CURRENT.archive(), expected); + } - #[test] - fn musl_is_only_ever_reported_on_linux() { - if HostTarget::CURRENT.is_musl() { - assert!(matches!(HostTarget::CURRENT.os, HostOs::Linux { .. })); - } - } + #[test] + fn musl_is_only_ever_reported_on_linux() { + if HostTarget::CURRENT.is_musl() { + assert!(matches!(HostTarget::CURRENT.os, HostOs::Linux { .. })); + } + } } diff --git a/packages/oneclient_java/src/resolve.rs b/packages/oneclient_java/src/resolve.rs index a5e671eb..0c4b85b5 100644 --- a/packages/oneclient_java/src/resolve.rs +++ b/packages/oneclient_java/src/resolve.rs @@ -1,67 +1,63 @@ use std::path::{Path, PathBuf}; - -use oneclient_common::constants::JAVA_BIN; use crate::data::java_executable_relative_path; use crate::error::{JavaError, JavaResult}; +use oneclient_common::constants::JAVA_BIN; #[tracing::instrument(level = "debug", skip_all)] pub fn resolve_java_executable(selection: impl AsRef) -> JavaResult { - let selection = selection.as_ref(); + let selection = selection.as_ref(); - if selection.is_file() { - return validate_executable(selection); - } + if selection.is_file() { + return validate_executable(selection); + } - let relative = java_executable_relative_path(); - let candidates = [ - selection.join(&relative), - selection.join(JAVA_BIN), - #[cfg(target_os = "macos")] - selection - .join("Contents") - .join("Home") - .join(&relative), - ]; + let relative = java_executable_relative_path(); + let candidates = [ + selection.join(&relative), + selection.join(JAVA_BIN), + #[cfg(target_os = "macos")] + selection.join("Contents").join("Home").join(&relative), + ]; - for candidate in candidates { - if candidate.is_file() { - return validate_executable(&candidate); - } - } + for candidate in candidates { + if candidate.is_file() { + return validate_executable(&candidate); + } + } - if let Ok(entries) = std::fs::read_dir(selection) { - for entry in entries.flatten() { - let path = entry.path(); - if !path.is_dir() { - continue; - } + if let Ok(entries) = std::fs::read_dir(selection) { + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } - let nested = path.join(&relative); - if nested.is_file() { - return validate_executable(&nested); - } - } - } + let nested = path.join(&relative); + if nested.is_file() { + return validate_executable(&nested); + } + } + } - tracing::warn!(path = %selection.display(), "no Java executable found in selection"); + tracing::warn!(path = %selection.display(), "no Java executable found in selection"); - Err(JavaError::InvalidJavaPath { - path: selection.display().to_string(), - }) + Err(JavaError::InvalidJavaPath { + path: selection.display().to_string(), + }) } fn validate_executable(path: &Path) -> JavaResult { - let file_name = path - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or_default(); + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or_default(); - if file_name != JAVA_BIN && file_name != "java.exe" && file_name != "javaw.exe" { - return Err(JavaError::InvalidJavaPath { - path: path.display().to_string(), - }); - } + if file_name != JAVA_BIN && file_name != "java.exe" && file_name != "javaw.exe" { + return Err(JavaError::InvalidJavaPath { + path: path.display().to_string(), + }); + } - Ok(path.to_path_buf()) + Ok(path.to_path_buf()) } diff --git a/packages/oneclient_java/src/service.rs b/packages/oneclient_java/src/service.rs index 381d9f00..24b854ef 100644 --- a/packages/oneclient_java/src/service.rs +++ b/packages/oneclient_java/src/service.rs @@ -27,290 +27,291 @@ pub const PROBE_VERSION: u32 = 1; const _: () = assert!(PROBE_VERSION > 0); enum JavaPromptAnswer { - Download, - PickFolder, + Download, + PickFolder, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct AvailableJava { - pub major: u32, - pub package: JavaPackage, + pub major: u32, + pub package: JavaPackage, } pub struct JavaService { - store: Arc, - net: RequestClient, - events: EventBus, + store: Arc, + net: RequestClient, + events: EventBus, } impl JavaService { - #[must_use] - pub fn new(store: Arc, net: RequestClient, events: EventBus) -> Self { - Self { store, net, events } - } + #[must_use] + pub fn new(store: Arc, net: RequestClient, events: EventBus) -> Self { + Self { store, net, events } + } - #[must_use] - pub fn net(&self) -> &RequestClient { - &self.net - } + #[must_use] + pub fn net(&self) -> &RequestClient { + &self.net + } - #[must_use] - pub fn events(&self) -> &EventBus { - &self.events - } + #[must_use] + pub fn events(&self) -> &EventBus { + &self.events + } - #[tracing::instrument(level = "debug", skip(self))] - pub async fn list_runtimes(&self) -> JavaResult> { - Ok(self.store.list().await?) - } + #[tracing::instrument(level = "debug", skip(self))] + pub async fn list_runtimes(&self) -> JavaResult> { + Ok(self.store.list().await?) + } - /// Checks if file wasn't deleted manually - #[tracing::instrument(level = "debug", skip(self))] - pub async fn has_vendor_runtime( - &self, - vendor: &JavaVendor, - major: Option, - ) -> JavaResult { - Ok(self.list_runtimes().await?.iter().any(|runtime| { - &runtime.vendor == vendor - && major.is_none_or(|major| runtime.major == major) - && Path::new(&runtime.absolute_path).is_file() - })) - } + /// Checks if file wasn't deleted manually + #[tracing::instrument(level = "debug", skip(self))] + pub async fn has_vendor_runtime( + &self, + vendor: &JavaVendor, + major: Option, + ) -> JavaResult { + Ok(self.list_runtimes().await?.iter().any(|runtime| { + &runtime.vendor == vendor + && major.is_none_or(|major| runtime.major == major) + && Path::new(&runtime.absolute_path).is_file() + })) + } - #[tracing::instrument(level = "debug", skip(self))] - pub async fn runtime_for_profile( - &self, - java_path: Option<&str>, - ) -> JavaResult> { - let Some(path) = java_path else { - return Ok(None); - }; - let Some(runtime) = self.store.get_by_path(path).await? else { - return Ok(None); - }; - self.revalidate(runtime).await - } + #[tracing::instrument(level = "debug", skip(self))] + pub async fn runtime_for_profile( + &self, + java_path: Option<&str>, + ) -> JavaResult> { + let Some(path) = java_path else { + return Ok(None); + }; + let Some(runtime) = self.store.get_by_path(path).await? else { + return Ok(None); + }; + self.revalidate(runtime).await + } - #[tracing::instrument(level = "debug", skip(self))] - async fn revalidate(&self, runtime: JavaRuntime) -> JavaResult> { - if runtime.probe_version == PROBE_VERSION { - if Path::new(&runtime.absolute_path).is_file() { - return Ok(Some(runtime)); - } + #[tracing::instrument(level = "debug", skip(self))] + async fn revalidate(&self, runtime: JavaRuntime) -> JavaResult> { + if runtime.probe_version == PROBE_VERSION { + if Path::new(&runtime.absolute_path).is_file() { + return Ok(Some(runtime)); + } - tracing::warn!( - path = %runtime.absolute_path, - "forgetting recorded Java runtime: the executable is gone" - ); - self.store.delete_by_path(&runtime.absolute_path).await?; - return Ok(None); - } + tracing::warn!( + path = %runtime.absolute_path, + "forgetting recorded Java runtime: the executable is gone" + ); + self.store.delete_by_path(&runtime.absolute_path).await?; + return Ok(None); + } - match checker::check_java_runtime(runtime.absolute_path.clone()).await { - Ok(info) => Ok(Some( - self.persist(Path::new(&runtime.absolute_path), &info).await?, - )), - Err(err) if err.is_invalid_installation() => { - tracing::warn!( - path = %runtime.absolute_path, - "forgetting recorded Java runtime: {err}" - ); - self.store.delete_by_path(&runtime.absolute_path).await?; - Ok(None) - } - Err(err) => { - tracing::warn!( - path = %runtime.absolute_path, - "could not re-probe recorded Java runtime, trusting the record: {err}" - ); - Ok(Some(runtime)) - } - } - } + match checker::check_java_runtime(runtime.absolute_path.clone()).await { + Ok(info) => Ok(Some( + self.persist(Path::new(&runtime.absolute_path), &info) + .await?, + )), + Err(err) if err.is_invalid_installation() => { + tracing::warn!( + path = %runtime.absolute_path, + "forgetting recorded Java runtime: {err}" + ); + self.store.delete_by_path(&runtime.absolute_path).await?; + Ok(None) + } + Err(err) => { + tracing::warn!( + path = %runtime.absolute_path, + "could not re-probe recorded Java runtime, trusting the record: {err}" + ); + Ok(Some(runtime)) + } + } + } - #[tracing::instrument(level = "debug", skip(self))] - pub async fn available_versions(&self, vendor: &JavaVendor) -> JavaResult> { - let Some(provider) = provider_for_vendor(vendor) else { - return Ok(Vec::new()); - }; + #[tracing::instrument(level = "debug", skip(self))] + pub async fn available_versions(&self, vendor: &JavaVendor) -> JavaResult> { + let Some(provider) = provider_for_vendor(vendor) else { + return Ok(Vec::new()); + }; - let packages = provider.list_packages(None, &self.net).await?; + let packages = provider.list_packages(None, &self.net).await?; - 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 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(); + let mut available: Vec = by_major + .into_iter() + .map(|(major, package)| AvailableJava { major, package }) + .collect(); - available.sort_by_key(|entry| std::cmp::Reverse(entry.major)); - Ok(available) - } + available.sort_by_key(|entry| std::cmp::Reverse(entry.major)); + Ok(available) + } - /// `None` when the vendor publishes nothing for this major on this host - #[tracing::instrument(level = "debug", skip(self))] - pub async fn latest_package( - &self, - vendor: &JavaVendor, - major: u32, - ) -> JavaResult> { - let Some(provider) = provider_for_vendor(vendor) else { - return Ok(None); - }; + /// `None` when the vendor publishes nothing for this major on this host + #[tracing::instrument(level = "debug", skip(self))] + pub async fn latest_package( + &self, + vendor: &JavaVendor, + major: u32, + ) -> JavaResult> { + let Some(provider) = provider_for_vendor(vendor) else { + return Ok(None); + }; - provider.latest_package_by_major(major, &self.net).await - } + provider.latest_package_by_major(major, &self.net).await + } - #[tracing::instrument(level = "debug", skip(self))] - pub async fn rescan(&self) -> JavaResult<()> { - self.register_located(&crate::locate::locate_java().await?) - .await - } + #[tracing::instrument(level = "debug", skip(self))] + pub async fn rescan(&self) -> JavaResult<()> { + self.register_located(&crate::locate::locate_java().await?) + .await + } - #[tracing::instrument(skip(self))] - pub async fn add_custom_runtime(&self, folder: PathBuf) -> JavaResult { - let executable = resolve_java_executable(&folder)?; - self.register_checked(&executable, None).await - } + #[tracing::instrument(skip(self))] + pub async fn add_custom_runtime(&self, folder: PathBuf) -> JavaResult { + let executable = resolve_java_executable(&folder)?; + self.register_checked(&executable, None).await + } - #[tracing::instrument(skip(self))] - pub async fn remove_runtime(&self, absolute_path: &str) -> JavaResult<()> { - self.store.delete_by_path(absolute_path).await?; + #[tracing::instrument(skip(self))] + pub async fn remove_runtime(&self, absolute_path: &str) -> JavaResult<()> { + self.store.delete_by_path(absolute_path).await?; - crate::platform::forget_dedicated_gpu(Path::new(absolute_path)).await; + crate::platform::forget_dedicated_gpu(Path::new(absolute_path)).await; - let removed_files = - match crate::install::remove_installed_package(Path::new(absolute_path)).await { - Ok(removed) => removed, - Err(err) => { - tracing::warn!("could not remove the installed Java files: {err:#}"); - false - } - }; + let removed_files = + match crate::install::remove_installed_package(Path::new(absolute_path)).await { + Ok(removed) => removed, + Err(err) => { + tracing::warn!("could not remove the installed Java files: {err:#}"); + false + } + }; - tracing::info!(removed_files, "removed Java runtime"); - Ok(()) - } + tracing::info!(removed_files, "removed Java runtime"); + Ok(()) + } - /// A recorded JRE is kept only as a fallback so a system scan gets its - /// chance to turn up a JDK of the same major first - #[tracing::instrument(level = "debug", skip(self, progress))] - pub async fn prepare( - &self, - major: u32, - search_system: bool, - auto_install: bool, - progress: Option<&GroupedProgressSession>, - ) -> JavaResult { - let recorded = self.best_recorded_for_major(major).await?; + /// A recorded JRE is kept only as a fallback so a system scan gets its + /// chance to turn up a JDK of the same major first + #[tracing::instrument(level = "debug", skip(self, progress))] + pub async fn prepare( + &self, + major: u32, + search_system: bool, + auto_install: bool, + progress: Option<&GroupedProgressSession>, + ) -> JavaResult { + let recorded = self.best_recorded_for_major(major).await?; - if let Some(runtime) = &recorded - && runtime.is_jdk - { - return Ok(runtime.clone()); - } + if let Some(runtime) = &recorded + && runtime.is_jdk + { + return Ok(runtime.clone()); + } - if search_system { - let located = crate::locate::locate_java().await?; - self.register_located(&located).await?; + if search_system { + let located = crate::locate::locate_java().await?; + self.register_located(&located).await?; - // A located runtime only displaces a recorded one by being a JDK - if let Some((path, info)) = crate::locate::best_for_major(&located, major) - && (info.is_jdk || recorded.is_none()) - { - return self.persist(path, info).await; - } - } + // A located runtime only displaces a recorded one by being a JDK + if let Some((path, info)) = crate::locate::best_for_major(&located, major) + && (info.is_jdk || recorded.is_none()) + { + return self.persist(path, info).await; + } + } - if let Some(runtime) = recorded { - return Ok(runtime); - } + if let Some(runtime) = recorded { + return Ok(runtime); + } - if auto_install { - return self.download_and_register(major, progress).await; - } + if auto_install { + return self.download_and_register(major, progress).await; + } - self.prompt_and_install(major, progress).await - } + self.prompt_and_install(major, progress).await + } - #[tracing::instrument(level = "debug", skip(self))] - async fn best_recorded_for_major(&self, major: u32) -> JavaResult> { - let preferred = vendors::default_vendor(); + #[tracing::instrument(level = "debug", skip(self))] + async fn best_recorded_for_major(&self, major: u32) -> JavaResult> { + let preferred = vendors::default_vendor(); - let mut candidates: Vec = self - .list_runtimes() - .await? - .into_iter() - .filter(|runtime| runtime.major == major) - .collect(); + let mut candidates: Vec = self + .list_runtimes() + .await? + .into_iter() + .filter(|runtime| runtime.major == major) + .collect(); - candidates.sort_by(|a, b| pick_order(b, &preferred).cmp(&pick_order(a, &preferred))); + candidates.sort_by_key(|a| std::cmp::Reverse(pick_order(a, &preferred))); - for candidate in candidates { - if let Some(valid) = self.revalidate(candidate).await? { - return Ok(Some(valid)); - } - } + for candidate in candidates { + if let Some(valid) = self.revalidate(candidate).await? { + return Ok(Some(valid)); + } + } - Ok(None) - } + Ok(None) + } - #[tracing::instrument(skip(self, progress))] - pub async fn install_runtime( - &self, - major: u32, - progress: Option<&GroupedProgressSession>, - ) -> JavaResult { - self.download_and_register(major, progress).await - } + #[tracing::instrument(skip(self, progress))] + pub async fn install_runtime( + &self, + major: u32, + progress: Option<&GroupedProgressSession>, + ) -> JavaResult { + self.download_and_register(major, progress).await + } - #[tracing::instrument(skip(self))] - pub async fn install_runtime_from( - &self, - vendor: &JavaVendor, - major: u32, - ) -> JavaResult { - self.install_vendor_runtime(vendor, major, None).await - } + #[tracing::instrument(skip(self))] + pub async fn install_runtime_from( + &self, + vendor: &JavaVendor, + major: u32, + ) -> JavaResult { + self.install_vendor_runtime(vendor, major, None).await + } - #[tracing::instrument(level = "debug", skip(self, progress))] - async fn install_vendor_runtime( - &self, - vendor: &JavaVendor, - major: u32, - progress: Option<&GroupedProgressSession>, - ) -> JavaResult { - let provider = provider_for_vendor(vendor).ok_or(JavaError::PackageNotFound { major })?; - let package = provider - .latest_package_by_major(major, &self.net) - .await? - .ok_or(JavaError::PackageNotFound { major })?; + #[tracing::instrument(level = "debug", skip(self, progress))] + async fn install_vendor_runtime( + &self, + vendor: &JavaVendor, + major: u32, + progress: Option<&GroupedProgressSession>, + ) -> JavaResult { + let provider = provider_for_vendor(vendor).ok_or(JavaError::PackageNotFound { major })?; + let package = provider + .latest_package_by_major(major, &self.net) + .await? + .ok_or(JavaError::PackageNotFound { major })?; - let owned = progress.is_none().then(|| { - GroupedProgressSession::start(&self.events, format!("Installing Java {major}")) - }); - let session = progress.or(owned.as_ref()).expect("session present"); + let owned = progress.is_none().then(|| { + GroupedProgressSession::start(&self.events, format!("Installing Java {major}")) + }); + let session = progress.or(owned.as_ref()).expect("session present"); - let executable = provider - .install_package(&package, &self.net, &self.events, Some(session)) - .await?; + let executable = provider + .install_package(&package, &self.net, &self.events, Some(session)) + .await?; - self.register_checked(&executable, Some(major)).await - } + self.register_checked(&executable, Some(major)).await + } - async fn prompt_and_install( - &self, - major: u32, - progress: Option<&GroupedProgressSession>, - ) -> JavaResult { - let prompt = Prompt::new( + async fn prompt_and_install( + &self, + major: u32, + progress: Option<&GroupedProgressSession>, + ) -> JavaResult { + let prompt = Prompt::new( "Java required", format!( "No Java {major} runtime was found. Download it automatically, choose an existing installation folder, or cancel?" @@ -327,249 +328,249 @@ impl JavaService { ) .dismiss("Cancel"); - let Some(chosen) = self.events.ask(prompt).await? else { - return Err(JavaError::Cancelled); - }; + let Some(chosen) = self.events.ask(prompt).await? else { + return Err(JavaError::Cancelled); + }; - match chosen.value { - JavaPromptAnswer::Download => match chosen.selection() { - Some(vendor) => { - let vendor = JavaVendor::from_str(vendor) - .unwrap_or_else(|_| JavaVendor::Other(vendor.to_string())); - self.install_vendor_runtime(&vendor, major, progress).await - } - None => self.download_and_register(major, progress).await, - }, - JavaPromptAnswer::PickFolder => match chosen.folder() { - Some(folder) => { - let executable = resolve_java_executable(folder)?; - self.register_checked(&executable, Some(major)).await - } - None => Err(JavaError::Cancelled), - }, - } - } + match chosen.value { + JavaPromptAnswer::Download => match chosen.selection() { + Some(vendor) => { + let vendor = JavaVendor::from_str(vendor) + .unwrap_or_else(|_| JavaVendor::Other(vendor.to_string())); + self.install_vendor_runtime(&vendor, major, progress).await + } + None => self.download_and_register(major, progress).await, + }, + JavaPromptAnswer::PickFolder => match chosen.folder() { + Some(folder) => { + let executable = resolve_java_executable(folder)?; + self.register_checked(&executable, Some(major)).await + } + None => Err(JavaError::Cancelled), + }, + } + } - #[tracing::instrument(level = "debug", skip(self, progress))] - async fn download_and_register( - &self, - major: u32, - progress: Option<&GroupedProgressSession>, - ) -> JavaResult { - for provider in vendors::runtime_providers() { - let vendor = provider.vendor(); + #[tracing::instrument(level = "debug", skip(self, progress))] + async fn download_and_register( + &self, + major: u32, + progress: Option<&GroupedProgressSession>, + ) -> JavaResult { + for provider in vendors::runtime_providers() { + let vendor = provider.vendor(); - let packages = provider.list_packages(Some(major), &self.net).await; - let package = match &packages { - 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"); - continue; - } - }, - Err(err) => { - tracing::warn!(?vendor, major, "failed to query packages: {err}"); - continue; - } - }; + let packages = provider.list_packages(Some(major), &self.net).await; + let package = match &packages { + 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"); + continue; + } + }, + Err(err) => { + tracing::warn!(?vendor, major, "failed to query packages: {err}"); + continue; + } + }; - tracing::info!(?vendor, major, "downloading Java runtime"); - match provider - .install_package(package, &self.net, &self.events, progress) - .await - { - Ok(executable) => return self.register_checked(&executable, Some(major)).await, - Err(err) => tracing::warn!(?vendor, major, "install failed: {err}"), - } - } + tracing::info!(?vendor, major, "downloading Java runtime"); + match provider + .install_package(package, &self.net, &self.events, progress) + .await + { + Ok(executable) => return self.register_checked(&executable, Some(major)).await, + Err(err) => tracing::warn!(?vendor, major, "install failed: {err}"), + } + } - Err(JavaError::PackageNotFound { major }) - } + Err(JavaError::PackageNotFound { major }) + } - #[tracing::instrument(level = "debug", skip(self))] - async fn register_checked( - &self, - executable: &Path, - expected_major: Option, - ) -> JavaResult { - let info = checker::check_java_runtime(executable.display().to_string()).await?; - let major = parse_major_version(&info.version)?; + #[tracing::instrument(level = "debug", skip(self))] + async fn register_checked( + &self, + executable: &Path, + expected_major: Option, + ) -> JavaResult { + let info = checker::check_java_runtime(executable.display().to_string()).await?; + let major = parse_major_version(&info.version)?; - if let Some(expected) = expected_major - && major != expected - { - return Err(JavaError::VersionMismatch { - expected, - found: major, - }); - } + if let Some(expected) = expected_major + && major != expected + { + return Err(JavaError::VersionMismatch { + expected, + found: major, + }); + } - self.persist(executable, &info).await - } + self.persist(executable, &info).await + } - /// Skips anything that fails to probe a broken JDK must not abort the scan - #[tracing::instrument(level = "debug", skip_all)] - async fn register_located(&self, located: &[(PathBuf, JavaCheckInfo)]) -> JavaResult<()> { - for (path, info) in located { - if let Err(err) = self.persist(path, info).await { - tracing::warn!( - path = %path.display(), - version = %info.version, - "skipping located Java runtime: {err}" - ); - } - } - Ok(()) - } + /// Skips anything that fails to probe a broken JDK must not abort the scan + #[tracing::instrument(level = "debug", skip_all)] + async fn register_located(&self, located: &[(PathBuf, JavaCheckInfo)]) -> JavaResult<()> { + for (path, info) in located { + if let Err(err) = self.persist(path, info).await { + tracing::warn!( + path = %path.display(), + version = %info.version, + "skipping located Java runtime: {err}" + ); + } + } + Ok(()) + } - async fn persist(&self, executable: &Path, info: &JavaCheckInfo) -> JavaResult { - let runtime = JavaRuntime { - absolute_path: executable.to_string_lossy().into_owned(), - major: parse_major_version(&info.version)?, - version: info.version.clone(), - vendor: JavaVendor::from_str(&info.vendor) - .unwrap_or_else(|_| JavaVendor::Other(info.vendor.clone())), - os_arch: info.os_arch.clone(), - is_jdk: info.is_jdk, - probe_version: PROBE_VERSION, - }; + async fn persist(&self, executable: &Path, info: &JavaCheckInfo) -> JavaResult { + let runtime = JavaRuntime { + absolute_path: executable.to_string_lossy().into_owned(), + major: parse_major_version(&info.version)?, + version: info.version.clone(), + vendor: JavaVendor::from_str(&info.vendor) + .unwrap_or_else(|_| JavaVendor::Other(info.vendor.clone())), + os_arch: info.os_arch.clone(), + is_jdk: info.is_jdk, + probe_version: PROBE_VERSION, + }; - Ok(self.store.upsert(&runtime).await?) - } + Ok(self.store.upsert(&runtime).await?) + } } /// Ranked highest-first the default vendor beats every other, a kit beats a /// runtime of the same vendor, and only then does the newest build win fn pick_order(runtime: &JavaRuntime, preferred: &JavaVendor) -> (bool, bool, Vec) { - ( - &runtime.vendor == preferred, - runtime.is_jdk, - version_key(&runtime.version), - ) + ( + &runtime.vendor == preferred, + runtime.is_jdk, + version_key(&runtime.version), + ) } /// `21.0.9` sorts above `21.0.12` as text which is backwards so the components /// are compared as numbers Legacy `1.8.0_412` keeps its build as the last one fn version_key(version: &str) -> Vec { - version - .split(|c: char| !c.is_ascii_digit()) - .filter_map(|part| part.parse().ok()) - .collect() + version + .split(|c: char| !c.is_ascii_digit()) + .filter_map(|part| part.parse().ok()) + .collect() } fn provider_for_vendor(vendor: &JavaVendor) -> Option> { - vendors::runtime_providers() - .into_iter() - .find(|provider| &provider.vendor() == vendor) + vendors::runtime_providers() + .into_iter() + .find(|provider| &provider.vendor() == vendor) } /// Java 8 and earlier report `1.8.0_412` where the major is the *second* /// component 9 and later report `21.0.3` where it is the first pub(crate) fn parse_major_version(version: &str) -> Result { - if let Some(rest) = version.strip_prefix("1.") { - let digit = rest.chars().next().ok_or_else(|| JavaError::ParseVersion { - version: version.to_string(), - })?; - return digit.to_digit(10).ok_or_else(|| JavaError::ParseVersion { - version: version.to_string(), - }); - } + if let Some(rest) = version.strip_prefix("1.") { + let digit = rest.chars().next().ok_or_else(|| JavaError::ParseVersion { + version: version.to_string(), + })?; + return digit.to_digit(10).ok_or_else(|| JavaError::ParseVersion { + version: version.to_string(), + }); + } - let head = version.split('.').next().unwrap_or(version); - head.parse::().map_err(|_| JavaError::ParseVersion { - version: version.to_string(), - }) + let head = version.split('.').next().unwrap_or(version); + head.parse::().map_err(|_| JavaError::ParseVersion { + version: version.to_string(), + }) } #[cfg(test)] mod tests { - use super::*; + use super::*; - #[test] - fn legacy_versions_take_the_second_component() { - assert_eq!(parse_major_version("1.8.0_412").unwrap(), 8); - assert_eq!(parse_major_version("1.7.0").unwrap(), 7); - } + #[test] + fn legacy_versions_take_the_second_component() { + assert_eq!(parse_major_version("1.8.0_412").unwrap(), 8); + assert_eq!(parse_major_version("1.7.0").unwrap(), 7); + } - #[test] - fn modern_versions_take_the_first() { - assert_eq!(parse_major_version("21.0.3").unwrap(), 21); - assert_eq!(parse_major_version("17").unwrap(), 17); - } + #[test] + fn modern_versions_take_the_first() { + assert_eq!(parse_major_version("21.0.3").unwrap(), 21); + assert_eq!(parse_major_version("17").unwrap(), 17); + } - #[test] - fn garbage_is_rejected_rather_than_defaulted() { - assert!(parse_major_version("not-a-version").is_err()); - assert!(parse_major_version("1.x").is_err()); - } + #[test] + fn garbage_is_rejected_rather_than_defaulted() { + assert!(parse_major_version("not-a-version").is_err()); + assert!(parse_major_version("1.x").is_err()); + } - fn runtime(vendor: JavaVendor, version: &str, is_jdk: bool) -> JavaRuntime { - JavaRuntime { - absolute_path: format!("/java/{vendor}-{version}/bin/java"), - major: parse_major_version(version).unwrap(), - version: version.to_string(), - vendor, - os_arch: "x64".to_string(), - is_jdk, - probe_version: PROBE_VERSION, - } - } + fn runtime(vendor: JavaVendor, version: &str, is_jdk: bool) -> JavaRuntime { + JavaRuntime { + absolute_path: format!("/java/{vendor}-{version}/bin/java"), + major: parse_major_version(version).unwrap(), + version: version.to_string(), + vendor, + os_arch: "x64".to_string(), + is_jdk, + probe_version: PROBE_VERSION, + } + } - /// Highest first the same way `best_recorded_for_major` sorts - fn best(mut candidates: Vec, preferred: &JavaVendor) -> JavaRuntime { - candidates.sort_by(|a, b| pick_order(b, preferred).cmp(&pick_order(a, preferred))); - candidates.remove(0) - } + /// Highest first the same way `best_recorded_for_major` sorts + fn best(mut candidates: Vec, preferred: &JavaVendor) -> JavaRuntime { + candidates.sort_by_key(|a| std::cmp::Reverse(pick_order(a, preferred))); + candidates.remove(0) + } - #[test] - fn patch_numbers_are_compared_as_numbers_not_as_text() { - assert!(version_key("21.0.12") > version_key("21.0.9")); - assert_eq!(version_key("1.8.0_412"), vec![1, 8, 0, 412]); - } + #[test] + fn patch_numbers_are_compared_as_numbers_not_as_text() { + assert!(version_key("21.0.12") > version_key("21.0.9")); + assert_eq!(version_key("1.8.0_412"), vec![1, 8, 0, 412]); + } - #[test] - fn the_default_vendor_wins_even_against_a_newer_build() { - let picked = best( - vec![ - runtime(JavaVendor::Zulu, "21.0.99", true), - runtime(JavaVendor::Microsoft, "21.0.1", true), - ], - &JavaVendor::Microsoft, - ); + #[test] + fn the_default_vendor_wins_even_against_a_newer_build() { + let picked = best( + vec![ + runtime(JavaVendor::Zulu, "21.0.99", true), + runtime(JavaVendor::Microsoft, "21.0.1", true), + ], + &JavaVendor::Microsoft, + ); - assert_eq!(picked.vendor, JavaVendor::Microsoft); - } + assert_eq!(picked.vendor, JavaVendor::Microsoft); + } - #[test] - fn a_kit_beats_a_runtime_of_the_same_vendor() { - let picked = best( - vec![ - runtime(JavaVendor::Microsoft, "21.0.5", false), - runtime(JavaVendor::Microsoft, "21.0.2", true), - ], - &JavaVendor::Microsoft, - ); + #[test] + fn a_kit_beats_a_runtime_of_the_same_vendor() { + let picked = best( + vec![ + runtime(JavaVendor::Microsoft, "21.0.5", false), + runtime(JavaVendor::Microsoft, "21.0.2", true), + ], + &JavaVendor::Microsoft, + ); - assert!(picked.is_jdk); - } + assert!(picked.is_jdk); + } - /// Nothing from the preferred vendor means the ranking falls through to the - /// newest build rather than to whatever the store happened to return - #[test] - fn without_the_default_vendor_the_newest_build_wins() { - let picked = best( - vec![ - runtime(JavaVendor::Zulu, "21.0.9", true), - runtime(JavaVendor::Adoptium, "21.0.12", true), - ], - &JavaVendor::Microsoft, - ); + /// Nothing from the preferred vendor means the ranking falls through to the + /// newest build rather than to whatever the store happened to return + #[test] + fn without_the_default_vendor_the_newest_build_wins() { + let picked = best( + vec![ + runtime(JavaVendor::Zulu, "21.0.9", true), + runtime(JavaVendor::Adoptium, "21.0.12", true), + ], + &JavaVendor::Microsoft, + ); - assert_eq!(picked.version, "21.0.12"); - } + assert_eq!(picked.version, "21.0.12"); + } } diff --git a/packages/oneclient_java/src/store.rs b/packages/oneclient_java/src/store.rs index ef582ce6..abaae97e 100644 --- a/packages/oneclient_java/src/store.rs +++ b/packages/oneclient_java/src/store.rs @@ -11,74 +11,78 @@ use crate::data::JavaRuntime; pub struct StoreError(#[source] pub Box); impl StoreError { - pub fn new(source: impl std::error::Error + Send + Sync + 'static) -> Self { - Self(Box::new(source)) - } + pub fn new(source: impl std::error::Error + Send + Sync + 'static) -> Self { + Self(Box::new(source)) + } } pub type StoreResult = Result; #[async_trait::async_trait] pub trait JavaStore: Send + Sync { - async fn upsert(&self, runtime: &JavaRuntime) -> StoreResult; + async fn upsert(&self, runtime: &JavaRuntime) -> StoreResult; - async fn get_by_path(&self, absolute_path: &str) -> StoreResult>; + async fn get_by_path(&self, absolute_path: &str) -> StoreResult>; - async fn latest_by_major(&self, major: u32) -> StoreResult>; + async fn latest_by_major(&self, major: u32) -> StoreResult>; - async fn delete_by_path(&self, absolute_path: &str) -> StoreResult<()>; + async fn delete_by_path(&self, absolute_path: &str) -> StoreResult<()>; - async fn list(&self) -> StoreResult>; + async fn list(&self) -> StoreResult>; } #[derive(Debug, Default)] pub struct MemoryJavaStore { - runtimes: std::sync::Mutex>, + runtimes: std::sync::Mutex>, } impl MemoryJavaStore { - #[must_use] - pub fn new() -> Arc { - Arc::new(Self::default()) - } + #[must_use] + pub fn new() -> Arc { + Arc::new(Self::default()) + } } #[async_trait::async_trait] impl JavaStore for MemoryJavaStore { - async fn upsert(&self, runtime: &JavaRuntime) -> StoreResult { - let mut runtimes = self.runtimes.lock().expect("java store poisoned"); - runtimes.retain(|existing| existing.absolute_path != runtime.absolute_path); - runtimes.push(runtime.clone()); - Ok(runtime.clone()) - } + async fn upsert(&self, runtime: &JavaRuntime) -> StoreResult { + let mut runtimes = self.runtimes.lock().expect("java store poisoned"); + runtimes.retain(|existing| existing.absolute_path != runtime.absolute_path); + runtimes.push(runtime.clone()); + Ok(runtime.clone()) + } - async fn get_by_path(&self, absolute_path: &str) -> StoreResult> { - let runtimes = self.runtimes.lock().expect("java store poisoned"); - Ok(runtimes - .iter() - .find(|runtime| runtime.absolute_path == absolute_path) - .cloned()) - } + async fn get_by_path(&self, absolute_path: &str) -> StoreResult> { + let runtimes = self.runtimes.lock().expect("java store poisoned"); + Ok(runtimes + .iter() + .find(|runtime| runtime.absolute_path == absolute_path) + .cloned()) + } - async fn latest_by_major(&self, major: u32) -> StoreResult> { - let runtimes = self.runtimes.lock().expect("java store poisoned"); - Ok(runtimes - .iter() - .filter(|runtime| runtime.major == major) - .max_by(|a, b| a.version.cmp(&b.version)) - .cloned()) - } + async fn latest_by_major(&self, major: u32) -> StoreResult> { + let runtimes = self.runtimes.lock().expect("java store poisoned"); + Ok(runtimes + .iter() + .filter(|runtime| runtime.major == major) + .max_by(|a, b| a.version.cmp(&b.version)) + .cloned()) + } - async fn delete_by_path(&self, absolute_path: &str) -> StoreResult<()> { - let mut runtimes = self.runtimes.lock().expect("java store poisoned"); - runtimes.retain(|runtime| runtime.absolute_path != absolute_path); - Ok(()) - } + async fn delete_by_path(&self, absolute_path: &str) -> StoreResult<()> { + let mut runtimes = self.runtimes.lock().expect("java store poisoned"); + runtimes.retain(|runtime| runtime.absolute_path != absolute_path); + Ok(()) + } - async fn list(&self) -> StoreResult> { - let runtimes = self.runtimes.lock().expect("java store poisoned"); - let mut out = runtimes.clone(); - out.sort_by(|a, b| b.major.cmp(&a.major).then_with(|| b.version.cmp(&a.version))); - Ok(out) - } + async fn list(&self) -> StoreResult> { + let runtimes = self.runtimes.lock().expect("java store poisoned"); + let mut out = runtimes.clone(); + out.sort_by(|a, b| { + b.major + .cmp(&a.major) + .then_with(|| b.version.cmp(&a.version)) + }); + Ok(out) + } } diff --git a/packages/oneclient_java/src/vendors/corretto.rs b/packages/oneclient_java/src/vendors/corretto.rs index 92b7801b..b62a23d8 100644 --- a/packages/oneclient_java/src/vendors/corretto.rs +++ b/packages/oneclient_java/src/vendors/corretto.rs @@ -59,13 +59,10 @@ impl JavaRuntimeProvider for CorrettoRuntimeProvider { // Prefer the versioned URL the `latest` redirect resolves at // download time and can move away from this index's checksum - let download_url = entry - .get("resource") - .and_then(Value::as_str) - .map_or_else( - || latest_url(this_major, CORRETTO_EXT.0), - |resource| format!("https://corretto.aws{resource}"), - ); + let download_url = entry.get("resource").and_then(Value::as_str).map_or_else( + || latest_url(this_major, CORRETTO_EXT.0), + |resource| format!("https://corretto.aws{resource}"), + ); let checksum = entry .get("checksum_sha256") diff --git a/packages/oneclient_java/src/vendors/microsoft.rs b/packages/oneclient_java/src/vendors/microsoft.rs index a66a5ba0..9746b9ff 100644 --- a/packages/oneclient_java/src/vendors/microsoft.rs +++ b/packages/oneclient_java/src/vendors/microsoft.rs @@ -37,9 +37,7 @@ impl JavaRuntimeProvider for MicrosoftRuntimeProvider { net: &RequestClient, ) -> JavaResult> { let Some(arch) = MICROSOFT_ARCH else { - tracing::debug!( - "Microsoft publishes no build for this architecture; listing nothing" - ); + tracing::debug!("Microsoft publishes no build for this architecture; listing nothing"); return Ok(Vec::new()); }; @@ -157,9 +155,7 @@ fn parse_checksum_file(body: &str, major: u32) -> Option { /// Splitting the whole filename the way the other vendors split their version /// strings would fold the `64` of `x64` into the version fn version_from_filename(filename: &str) -> Option> { - let (version, _) = filename - .strip_prefix("microsoft-jdk-")? - .split_once('-')?; + let (version, _) = filename.strip_prefix("microsoft-jdk-")?.split_once('-')?; let parts: Vec = version .split('.') @@ -216,8 +212,7 @@ const MICROSOFT_EXT: (&str, PackageArchive) = ( mod tests { use super::*; - const LINE: &str = - "bf27a5d6298c736af8daf5b8c883098e83291446e5766118d8a5ea6a2617195d microsoft-jdk-21.0.12-windows-x64.zip"; + const LINE: &str = "bf27a5d6298c736af8daf5b8c883098e83291446e5766118d8a5ea6a2617195d microsoft-jdk-21.0.12-windows-x64.zip"; #[test] fn only_kits_are_ever_requested() { diff --git a/packages/oneclient_java/src/vendors/mod.rs b/packages/oneclient_java/src/vendors/mod.rs index 8a83cfff..c3cc2c8e 100644 --- a/packages/oneclient_java/src/vendors/mod.rs +++ b/packages/oneclient_java/src/vendors/mod.rs @@ -1,6 +1,5 @@ use std::{path::PathBuf, str::FromStr}; - use oneclient_events::{EventBus, GroupedProgressSession}; use oneclient_net::RequestClient; @@ -22,7 +21,7 @@ pub use zulu::ZuluRuntimeProvider; pub fn runtime_providers() -> Vec> { vec![ - Box::new(MicrosoftRuntimeProvider), + Box::new(MicrosoftRuntimeProvider), Box::new(ZuluRuntimeProvider), Box::new(AdoptiumRuntimeProvider), Box::new(CorrettoRuntimeProvider), @@ -41,13 +40,13 @@ pub fn default_vendor() -> JavaVendor { #[async_trait::async_trait] pub trait JavaRuntimeProvider: Send + Sync { - fn vendor(&self) -> JavaVendor; + fn vendor(&self) -> JavaVendor; - async fn list_packages( - &self, - major: Option, - net: &RequestClient, - ) -> JavaResult>; + async fn list_packages( + &self, + major: Option, + net: &RequestClient, + ) -> JavaResult>; #[tracing::instrument(level = "debug", skip(self, net))] async fn latest_package_by_major( @@ -56,31 +55,33 @@ pub trait JavaRuntimeProvider: Send + Sync { net: &RequestClient, ) -> JavaResult> { let packages = self.list_packages(Some(major), net).await?; - Ok(packages.into_iter().find(|p| p.java_version.first() == Some(&major))) + 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, - net: &RequestClient, - events: &EventBus, - progress: Option<&GroupedProgressSession>, - ) -> JavaResult { + #[tracing::instrument(level = "debug", skip_all)] + async fn install_package( + &self, + package: &JavaPackage, + net: &RequestClient, + events: &EventBus, + progress: Option<&GroupedProgressSession>, + ) -> JavaResult { crate::install::install_package(package, net, events, progress).await } } #[derive(Debug, Clone, Serialize, PartialEq, Eq, Hash)] pub enum JavaVendor { - Zulu, - Corretto, - Oracle, - Microsoft, - Adoptium, - Liberica, - OpenJDK, - Other(String), + Zulu, + Corretto, + Oracle, + Microsoft, + Adoptium, + Liberica, + OpenJDK, + Other(String), } impl std::fmt::Display for JavaVendor { @@ -99,37 +100,39 @@ impl std::fmt::Display for JavaVendor { } impl FromStr for JavaVendor { - type Err = (); + type Err = (); - fn from_str(s: &str) -> Result { - let v = s.to_lowercase(); + fn from_str(s: &str) -> Result { + let v = s.to_lowercase(); - 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") { - Self::OpenJDK - } else if v.contains("oracle") { - Self::Oracle - } else if v.contains("amazon") || v.contains("corretto") { - Self::Corretto - } else if v.contains("azul") || v.contains("zulu") { - Self::Zulu - } else { - Self::Other(s.to_string()) - }) - } + 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") { + Self::OpenJDK + } else if v.contains("oracle") { + Self::Oracle + } else if v.contains("amazon") || v.contains("corretto") { + Self::Corretto + } else if v.contains("azul") || v.contains("zulu") { + Self::Zulu + } else { + Self::Other(s.to_string()) + }, + ) + } } impl<'de> Deserialize<'de> for JavaVendor { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let s = String::deserialize(deserializer)?; - Ok(Self::from_str(&s).unwrap()) - } + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + Ok(Self::from_str(&s).unwrap()) + } } diff --git a/packages/oneclient_java/src/vendors/zulu.rs b/packages/oneclient_java/src/vendors/zulu.rs index 60bf753a..6eab2afe 100644 --- a/packages/oneclient_java/src/vendors/zulu.rs +++ b/packages/oneclient_java/src/vendors/zulu.rs @@ -14,32 +14,38 @@ pub struct ZuluRuntimeProvider; #[derive(Debug, Deserialize)] struct ZuluPackage { - download_url: String, - name: String, - java_version: Vec, - /// Only present because `include_fields` asks for it - #[serde(default)] - sha256_hash: Option, - #[serde(default)] - size: Option, + download_url: String, + name: String, + java_version: Vec, + /// Only present because `include_fields` asks for it + #[serde(default)] + sha256_hash: Option, + #[serde(default)] + size: Option, } #[async_trait::async_trait] impl JavaRuntimeProvider for ZuluRuntimeProvider { - fn vendor(&self) -> JavaVendor { - JavaVendor::Zulu - } + fn vendor(&self) -> JavaVendor { + JavaVendor::Zulu + } - #[tracing::instrument(level = "debug", skip(self, net))] - async fn list_packages(&self, major: Option, net: &RequestClient) -> JavaResult> { + #[tracing::instrument(level = "debug", skip(self, net))] + async fn list_packages( + &self, + major: Option, + net: &RequestClient, + ) -> JavaResult> { let url = zulu_url(major)?; - let packages = net.send_as::>(Request::new(Method::GET, url)).await?; + let packages = net + .send_as::>(Request::new(Method::GET, url)) + .await?; let packages: Vec = packages.into_iter().map(map_zulu_package).collect(); tracing::debug!(count = packages.len(), "listed Zulu packages"); Ok(packages) - } + } } fn map_zulu_package(pkg: ZuluPackage) -> JavaPackage { @@ -113,4 +119,4 @@ mod tests { .is_some_and(|q| q.contains("java_package_type=jre")) ); } -} \ No newline at end of file +} diff --git a/packages/oneclient_java/tests/vendor_checksums.rs b/packages/oneclient_java/tests/vendor_checksums.rs index 0f33700b..6746c23f 100644 --- a/packages/oneclient_java/tests/vendor_checksums.rs +++ b/packages/oneclient_java/tests/vendor_checksums.rs @@ -88,8 +88,7 @@ async fn liberica_publishes_sha1_and_the_rest_publish_sha256() { }; assert_eq!( - checksum.algorithm, - expected, + checksum.algorithm, expected, "{vendor} changed the algorithm it publishes" ); } diff --git a/packages/oneclient_macro/src/lib.rs b/packages/oneclient_macro/src/lib.rs index 15a3e973..efae3076 100644 --- a/packages/oneclient_macro/src/lib.rs +++ b/packages/oneclient_macro/src/lib.rs @@ -9,4 +9,4 @@ pub fn derive_icon_named(input: proc_macro::TokenStream) -> proc_macro::TokenStr freya::expand_icon_named(input) .unwrap_or_else(|err| err.to_compile_error()) .into() -} \ No newline at end of file +} diff --git a/packages/oneclient_mc/src/arguments.rs b/packages/oneclient_mc/src/arguments.rs index 4b22f5bb..c4a53ac5 100644 --- a/packages/oneclient_mc/src/arguments.rs +++ b/packages/oneclient_mc/src/arguments.rs @@ -5,12 +5,12 @@ use interfrost::api::minecraft::{Argument, ArgumentValue, Library, VersionType}; use interfrost::api::modded::SidedDataEntry; use interfrost::utils::get_path_from_artifact; +use crate::error::McError; +use crate::error::McResult; +use crate::rules::validate_rules; +use oneclient_common::Resolution; use oneclient_common::constants::{self, DUMMY_REPLACE_NEWLINE}; use oneclient_common::paths; -use crate::rules::validate_rules; -use crate::error::McError; -use oneclient_common::Resolution; -use crate::error::McResult; #[allow(clippy::too_many_arguments)] pub fn java_arguments( @@ -338,7 +338,10 @@ pub fn parse_minecraft_argument( .replace("${auth_xuid}", "0") .replace("${auth_uuid}", &uuid.simple().to_string()) .replace("${uuid}", &uuid.simple().to_string()) - .replace("${clientid}", oneclient_common::constants::MICROSOFT_CLIENT_ID) + .replace( + "${clientid}", + oneclient_common::constants::MICROSOFT_CLIENT_ID, + ) .replace("${user_properties}", "{}") .replace("${user_type}", "msa") .replace("${version_name}", version) @@ -349,7 +352,9 @@ pub fn parse_minecraft_argument( ) .replace( "${assets_root}", - &polyio::canonicalize(assets_directory)?.display().to_string(), + &polyio::canonicalize(assets_directory)? + .display() + .to_string(), ) .replace("${game_assets}", &legacy_assets_path()?) .replace("${version_type}", version_type.as_str()) @@ -433,11 +438,7 @@ pub fn classpaths( .map(|(_, name)| get_library(libraries_path, name, false)) .collect::, _>>()?; - classpaths.insert( - polyio::canonicalize(client_path)? - .display() - .to_string(), - ); + classpaths.insert(polyio::canonicalize(client_path)?.display().to_string()); tracing::debug!(entries = classpaths.len(), "classpath resolved"); @@ -475,15 +476,11 @@ pub fn get_classpath_library>( Ok(classpaths.join(constants::CLASSPATH_SEPARATOR)) } -pub fn get_library( - libraries_path: &Path, - library: &str, - error_exist: bool, -) -> McResult { +pub fn get_library(libraries_path: &Path, library: &str, error_exist: bool) -> McResult { let mut path = libraries_path.to_path_buf(); - path.push(get_path_from_artifact(library).map_err(|_| { - McError::LibraryPath(library.to_string()) - })?); + path.push( + get_path_from_artifact(library).map_err(|_| McError::LibraryPath(library.to_string()))?, + ); if !path.exists() && error_exist { return Ok(path.display().to_string()); @@ -772,11 +769,7 @@ mod tests { fn a_modern_runtime_gets_the_full_set() { assert_eq!( flags(25, "aarch64", 16384), - vec![ - "-Xms512M", - "-XX:+UseZGC", - "-XX:+UseCompactObjectHeaders" - ] + vec!["-Xms512M", "-XX:+UseZGC", "-XX:+UseCompactObjectHeaders"] ); } @@ -795,11 +788,7 @@ mod tests { fn a_tiny_profile_never_starts_above_its_ceiling() { assert_eq!( flags(21, "amd64", 256), - vec![ - "-Xms256M", - "-XX:+UseG1GC", - "-XX:+ParallelRefProcEnabled", - ], + vec!["-Xms256M", "-XX:+UseG1GC", "-XX:+ParallelRefProcEnabled",], "a start size above the maximum is refused by the JVM outright" ); } @@ -825,8 +814,10 @@ mod tests { let dir = std::env::temp_dir().join("oneclient-classpath-test"); let client = dir.join("client.jar"); - for lib in ["net/fabricmc/fabric-loader/0.19.5/fabric-loader-0.19.5.jar", - "org/lwjgl/lwjgl/lwjgl/2.9.4+legacyfabric.15/lwjgl-2.9.4+legacyfabric.15.jar"] { + for lib in [ + "net/fabricmc/fabric-loader/0.19.5/fabric-loader-0.19.5.jar", + "org/lwjgl/lwjgl/lwjgl/2.9.4+legacyfabric.15/lwjgl-2.9.4+legacyfabric.15.jar", + ] { let path = dir.join(lib); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write(&path, b"").unwrap(); diff --git a/packages/oneclient_mc/src/error.rs b/packages/oneclient_mc/src/error.rs index 5c860db4..57cd78f1 100644 --- a/packages/oneclient_mc/src/error.rs +++ b/packages/oneclient_mc/src/error.rs @@ -5,85 +5,85 @@ pub type McResult = Result; #[derive(Debug, Error)] pub enum McError { - #[error("no client download exists for version {0}")] - NoClientDownload(String), + #[error("no client download exists for version {0}")] + NoClientDownload(String), - #[error("failed to resolve library artifact path for {0}")] - LibraryPath(String), + #[error("failed to resolve library artifact path for {0}")] + LibraryPath(String), - #[error("invalid game version {0}")] - InvalidVersion(String), + #[error("invalid game version {0}")] + InvalidVersion(String), - #[error("failed to fetch metadata")] - FetchError, + #[error("failed to fetch metadata")] + FetchError, - #[error("loader {0} does not use a modded manifest")] - NotModdedManifest(GameLoader), + #[error("loader {0} does not use a modded manifest")] + NotModdedManifest(GameLoader), - #[error("loader {0} does not use a vanilla manifest")] - NotVanillaManifest(GameLoader), + #[error("loader {0} does not use a vanilla manifest")] + NotVanillaManifest(GameLoader), - #[error("no matching loader found")] - NoMatchingLoader, + #[error("no matching loader found")] + NoMatchingLoader, - #[error("requested loader version '{requested}' was not found")] - RequestedLoaderVersionNotFound { requested: String }, + #[error("requested loader version '{requested}' was not found")] + RequestedLoaderVersionNotFound { requested: String }, - #[error("no matching version found")] - NoMatchingVersion, + #[error("no matching version found")] + NoMatchingVersion, - #[error( - "{version} ships no native libraries for {platform}; this platform is not supported \ + #[error( + "{version} ships no native libraries for {platform}; this platform is not supported \ by this version" - )] - NoNativesForPlatform { version: String, platform: String }, + )] + NoNativesForPlatform { version: String, platform: String }, - #[error("cancelled: {failed} file(s) could not be downloaded")] - IncompleteInstallCancelled { failed: usize }, + #[error("cancelled: {failed} file(s) could not be downloaded")] + IncompleteInstallCancelled { failed: usize }, - /// A Mojang response was well-formed JSON but not what the API documents - #[error("minecraft: {0}")] - Minecraft(String), + /// A Mojang response was well-formed JSON but not what the API documents + #[error("minecraft: {0}")] + Minecraft(String), - #[error("failed to parse metadata: {0}")] - ParseError(#[from] serde_json::Error), + #[error("failed to parse metadata: {0}")] + ParseError(#[from] serde_json::Error), - #[error(transparent)] - Request(#[from] oneclient_net::RequestError), + #[error(transparent)] + Request(#[from] oneclient_net::RequestError), - #[error(transparent)] - Io(#[from] polyio::IOError), + #[error(transparent)] + Io(#[from] polyio::IOError), - #[error(transparent)] - StdIo(#[from] std::io::Error), + #[error(transparent)] + StdIo(#[from] std::io::Error), - #[error(transparent)] - Paths(#[from] oneclient_common::PathsError), + #[error(transparent)] + Paths(#[from] oneclient_common::PathsError), - #[error("Invalid URL: {0}")] - Url(#[from] url::ParseError), + #[error("Invalid URL: {0}")] + Url(#[from] url::ParseError), } impl McError { - /// Environment fault rather than a launcher bug drives Sentry policy upstream - #[must_use] - pub fn is_transient(&self) -> bool { - match self { - Self::Request(source) => source.is_transient(), - Self::Io(polyio::IOError::IOError(source)) - | Self::Io(polyio::IOError::PathIOError { source, .. }) - | Self::StdIo(source) => { - use std::io::ErrorKind; - matches!( - source.kind(), - ErrorKind::ConnectionRefused - | ErrorKind::ConnectionReset - | ErrorKind::ConnectionAborted - | ErrorKind::NotConnected - | ErrorKind::TimedOut - ) - } - _ => false, - } - } + /// Environment fault rather than a launcher bug drives Sentry policy upstream + #[must_use] + pub fn is_transient(&self) -> bool { + match self { + Self::Request(source) => source.is_transient(), + Self::Io(polyio::IOError::IOError(source)) + | Self::Io(polyio::IOError::PathIOError { source, .. }) + | Self::StdIo(source) => { + use std::io::ErrorKind; + matches!( + source.kind(), + ErrorKind::ConnectionRefused + | ErrorKind::ConnectionReset + | ErrorKind::ConnectionAborted + | ErrorKind::NotConnected + | ErrorKind::TimedOut + ) + } + _ => false, + } + } } diff --git a/packages/oneclient_mc/src/install.rs b/packages/oneclient_mc/src/install.rs index 6f3bf560..30beeae6 100644 --- a/packages/oneclient_mc/src/install.rs +++ b/packages/oneclient_mc/src/install.rs @@ -10,13 +10,13 @@ use reqwest::Method; use crate::download::{download_to_path, fetch_bytes_verified}; use crate::rules::validate_rules; -use crate::manifest::MetadataStore; -use oneclient_common::os_ext::OsExt; -use oneclient_common::domain::GameLoader; -use oneclient_events::{Choice, GroupedProgressSession, Prompt, TaskCategory, TaskPhase}; -use oneclient_common::paths; use crate::McCtx; use crate::error::{McError, McResult}; +use crate::manifest::MetadataStore; +use oneclient_common::domain::GameLoader; +use oneclient_common::os_ext::OsExt; +use oneclient_common::paths; +use oneclient_events::{Choice, GroupedProgressSession, Prompt, TaskCategory, TaskPhase}; /// Asset objects are tiny (median ~10 KiB) and latency-bound so throughput /// scales with how many are in flight not with bandwidth @@ -315,10 +315,7 @@ async fn inspect_jar(path: &Path, java_arch: &str) -> JarVerdict { } }; - let natives: Vec<&String> = names - .iter() - .filter(|name| is_native_file(name)) - .collect(); + let natives: Vec<&String> = names.iter().filter(|name| is_native_file(name)).collect(); if natives.is_empty() { return JarVerdict::WrongArch; @@ -503,7 +500,9 @@ pub async fn verify_game_files( } match polyio::sha1_file_sync(path) { - Ok(actual) if polyio::normalize_hash(&actual) == polyio::normalize_hash(expected) => { + Ok(actual) + if polyio::normalize_hash(&actual) == polyio::normalize_hash(expected) => + { report.checked += 1; } Ok(actual) => { @@ -601,27 +600,20 @@ pub async fn download_minecraft( plan.libraries.len() as u64, plan.library_bytes, ); - progress.expect(TaskCategory::Client, u64::from(plan.client), plan.client_bytes); + progress.expect( + TaskCategory::Client, + u64::from(plan.client), + plan.client_bytes, + ); let DownloadPlan { assets, libraries, .. } = plan; let (failed_assets, _client, failed_libraries) = tokio::try_join!( - download_assets( - ctx, - progress, - uses_legacy_assets(&version.assets), - assets, - ), + download_assets(ctx, progress, uses_legacy_assets(&version.assets), assets,), download_client(ctx, progress, version, force), - download_libraries( - ctx, - progress, - version.id.clone(), - libraries, - java_arch, - ), + download_libraries(ctx, progress, version.id.clone(), libraries, java_arch,), )?; confirm_incomplete_install(ctx, failed_assets, failed_libraries).await?; @@ -751,17 +743,18 @@ pub async fn download_version_info( 1, TaskCategory::Metadata, |child| { - let requester = requester.clone(); - async move { - child.set_progress(0, Some(1)); - let result = requester - .send_json(Method::GET, version_url, None, &[]) - .await - .map_err(McError::from)?; - child.set_progress(1, Some(1)); - Ok::(result) - } - }) + let requester = requester.clone(); + async move { + child.set_progress(0, Some(1)); + let result = requester + .send_json(Method::GET, version_url, None, &[]) + .await + .map_err(McError::from)?; + child.set_progress(1, Some(1)); + Ok::(result) + } + }, + ) .await? } None => requester @@ -781,19 +774,18 @@ pub async fn download_version_info( 1, TaskCategory::Metadata, |child| { - let requester = requester.clone(); - async move { - child.set_progress(0, Some(1)); - let result = requester - .send_json(Method::GET, loader_url, None, &[]) - .await - .map_err(McError::from)?; - child.set_progress(1, Some(1)); - Ok::( - result, - ) - } - }) + let requester = requester.clone(); + async move { + child.set_progress(0, Some(1)); + let result = requester + .send_json(Method::GET, loader_url, None, &[]) + .await + .map_err(McError::from)?; + child.set_progress(1, Some(1)); + Ok::(result) + } + }, + ) .await? } None => requester @@ -1396,10 +1388,9 @@ pub async fn get_loader_version( return Ok(None); } - let resolve_from_manifest = - |manifest: &interfrost::api::modded::Manifest| { - resolve_loader_from_manifest(manifest, mc_version, loader_version) - }; + let resolve_from_manifest = |manifest: &interfrost::api::modded::Manifest| { + resolve_loader_from_manifest(manifest, mc_version, loader_version) + }; let mut manifest = metadata.get_modded_or_fetch(ctx, loader).await?; let (mut saw_matching, mut resolved) = resolve_from_manifest(manifest); @@ -1486,10 +1477,8 @@ mod tests { use super::*; fn scratch(tag: &str) -> PathBuf { - let dir = std::env::temp_dir().join(format!( - "oneclient-install-{tag}-{}", - uuid::Uuid::new_v4() - )); + let dir = + std::env::temp_dir().join(format!("oneclient-install-{tag}-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&dir).unwrap(); dir } @@ -1646,7 +1635,10 @@ mod tests { /// The bus is drained by the test so `ask` has somebody to talk to no /// request is ever sent through the client - fn test_ctx() -> (McCtx, tokio::sync::mpsc::UnboundedReceiver) { + fn test_ctx() -> ( + McCtx, + tokio::sync::mpsc::UnboundedReceiver, + ) { let (events, rx) = oneclient_events::EventBus::channel(); let net = oneclient_net::RequestClient::new(oneclient_net::NetConfig::default()) .expect("a client"); @@ -1673,9 +1665,9 @@ mod tests { let asking = tokio::spawn(async move { confirm_incomplete_install(&ctx, 3, 0).await }); - let Some(oneclient_events::Event::Notification( - oneclient_events::Notification::Prompt(request), - )) = rx.recv().await + let Some(oneclient_events::Event::Notification(oneclient_events::Notification::Prompt( + request, + ))) = rx.recv().await else { panic!("expected a prompt"); }; @@ -1694,17 +1686,13 @@ mod tests { let asking = tokio::spawn(async move { confirm_incomplete_install(&ctx, 0, 2).await }); - let Some(oneclient_events::Event::Notification( - oneclient_events::Notification::Prompt(request), - )) = rx.recv().await + let Some(oneclient_events::Event::Notification(oneclient_events::Notification::Prompt( + request, + ))) = rx.recv().await else { panic!("expected a prompt"); }; - assert!( - request.body.contains("fail to start"), - "{}", - request.body - ); + assert!(request.body.contains("fail to start"), "{}", request.body); request.reply.send(None).unwrap(); let err = asking.await.unwrap().expect_err("dismissal must cancel"); diff --git a/packages/oneclient_mc/src/lib.rs b/packages/oneclient_mc/src/lib.rs index 69d04563..25ac2f1e 100644 --- a/packages/oneclient_mc/src/lib.rs +++ b/packages/oneclient_mc/src/lib.rs @@ -6,15 +6,15 @@ use oneclient_net::RequestClient; #[derive(Clone)] pub struct McCtx { - pub net: RequestClient, - pub events: EventBus, + pub net: RequestClient, + pub events: EventBus, } impl McCtx { - #[must_use] - pub fn new(net: RequestClient, events: EventBus) -> Self { - Self { net, events } - } + #[must_use] + pub fn new(net: RequestClient, events: EventBus) -> Self { + Self { net, events } + } } mod arguments; @@ -31,7 +31,7 @@ pub use error::{McError, McResult}; pub use install::*; pub use manifest::MetadataStore; pub use profile::{ - MojangCape, MojangFullPlayerProfile, MojangPlayerProfile, MojangSkin, PlayerProfileView, - SkinVariant, fetch_logged_in_profile, fetch_player_profile, fetch_player_profile_view, + MojangCape, MojangFullPlayerProfile, MojangPlayerProfile, MojangSkin, PlayerProfileView, + SkinVariant, fetch_logged_in_profile, fetch_player_profile, fetch_player_profile_view, }; pub use rules::validate_rules; diff --git a/packages/oneclient_mc/src/manifest.rs b/packages/oneclient_mc/src/manifest.rs index 5a8a1009..648c2357 100644 --- a/packages/oneclient_mc/src/manifest.rs +++ b/packages/oneclient_mc/src/manifest.rs @@ -7,11 +7,10 @@ use reqwest::Method; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; - -use oneclient_common::domain::GameLoader; -use oneclient_common::paths; use crate::McCtx; use crate::error::{McError, McResult}; +use oneclient_common::domain::GameLoader; +use oneclient_common::paths; #[derive(Debug, Default)] pub struct MetadataStore { @@ -42,10 +41,7 @@ impl MetadataStore { } #[tracing::instrument(level = "debug", skip(self, ctx))] - pub async fn get_vanilla_or_fetch( - &mut self, - ctx: &McCtx, - ) -> McResult<&VanillaManifest> { + pub async fn get_vanilla_or_fetch(&mut self, ctx: &McCtx) -> McResult<&VanillaManifest> { if !self.initialized() { self.initialize(ctx).await?; } @@ -139,10 +135,11 @@ impl MetadataStore { let mut changed: u8 = 0; if self.inner.minecraft.is_none() - && let Ok(data) = fetch_vanilla_manifest(ctx).await { - self.inner.minecraft = Some(data); - changed += 1; - } + && let Ok(data) = fetch_vanilla_manifest(ctx).await + { + self.inner.minecraft = Some(data); + changed += 1; + } macro_rules! check_modded { ($var:ident) => { @@ -154,11 +151,7 @@ impl MetadataStore { changed += 1; } Err(err) => { - tracing::error!( - "failed to fetch manifest for {}: {}", - loader, - err - ); + tracing::error!("failed to fetch manifest for {}: {}", loader, err); } } } @@ -253,8 +246,7 @@ async fn fetch_vanilla_manifest(ctx: &McCtx) -> McResult { let url = interfrost::api::minecraft::VERSION_MANIFEST_URL .parse() .map_err(McError::Url)?; - ctx - .net + ctx.net .send_json(Method::GET, url, None, &[]) .await .map_err(McError::from) @@ -264,18 +256,12 @@ async fn fetch_vanilla_manifest(ctx: &McCtx) -> McResult { } #[tracing::instrument(level = "debug", skip_all)] -async fn fetch_modded_manifest( - ctx: &McCtx, - loader: GameLoader, -) -> McResult { +async fn fetch_modded_manifest(ctx: &McCtx, loader: GameLoader) -> McResult { fetch_manifest(ctx, loader).await } #[tracing::instrument(level = "debug", skip_all)] -async fn fetch_manifest( - ctx: &McCtx, - loader: GameLoader, -) -> McResult { +async fn fetch_manifest(ctx: &McCtx, loader: GameLoader) -> McResult { let url = format!( "{}/{}/v{}/manifest.json", ctx.net.config().metadata_api_url, @@ -284,8 +270,7 @@ async fn fetch_manifest( ); let parsed = url.parse().map_err(McError::Url)?; - ctx - .net + ctx.net .send_json(Method::GET, parsed, None, &[]) .await .map_err(McError::from) diff --git a/packages/oneclient_mc/src/profile.rs b/packages/oneclient_mc/src/profile.rs index 96e1361d..4e69b320 100644 --- a/packages/oneclient_mc/src/profile.rs +++ b/packages/oneclient_mc/src/profile.rs @@ -3,8 +3,8 @@ use chrono::{DateTime, TimeDelta, Utc}; use reqwest::Method; use serde::{Deserialize, Serialize}; -use oneclient_net::RequestClient; use crate::error::{McError, McResult}; +use oneclient_net::RequestClient; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct MojangPlayerProfile { @@ -68,8 +68,7 @@ impl From for PlayerProfileView { Self { uuid: profile.id, username: profile.username, - is_slim: active_skin - .is_some_and(|skin| skin.variant == SkinVariant::Slim), + is_slim: active_skin.is_some_and(|skin| skin.variant == SkinVariant::Slim), skin_url: active_skin.map(|skin| skin.url.clone()), cape_url: active_cape.map(|cape| cape.url.clone()), skins: profile.skins, @@ -120,7 +119,8 @@ fn normalize_texture_url(url: &str) -> String { } fn normalize_uuid(uuid: &str) -> &str { - uuid.trim().trim_matches(|c: char| c == '-' || c.is_whitespace()) + uuid.trim() + .trim_matches(|c: char| c == '-' || c.is_whitespace()) } fn auth_headers(access_token: &str) -> [(&'static str, String); 1] { @@ -187,9 +187,7 @@ pub async fn fetch_player_profile( let decoded = base64::prelude::BASE64_STANDARD .decode(texture_property.value.as_bytes()) - .map_err(|_| { - McError::Minecraft("failed to decode profile texture property".into()) - })?; + .map_err(|_| McError::Minecraft("failed to decode profile texture property".into()))?; let decoded = serde_json::from_slice::(&decoded) .map_err(McError::ParseError)? @@ -204,14 +202,8 @@ pub async fn fetch_player_profile( uuid: response.id, username: response.name, is_slim, - skin_url: decoded - .skin - .as_ref() - .map(|s| normalize_texture_url(&s.url)), - cape_url: decoded - .cape - .as_ref() - .map(|c| normalize_texture_url(&c.url)), + skin_url: decoded.skin.as_ref().map(|s| normalize_texture_url(&s.url)), + cape_url: decoded.cape.as_ref().map(|c| normalize_texture_url(&c.url)), }) } @@ -317,7 +309,11 @@ pub async fn fetch_player_profile_view( fn profile_cache_path(uuid: &str) -> Option { let key = polyio::sha1_bytes(normalize_uuid(uuid).as_bytes()); - Some(oneclient_common::paths::profiles_cache_dir().ok()?.join(format!("{key}.json"))) + Some( + oneclient_common::paths::profiles_cache_dir() + .ok()? + .join(format!("{key}.json")), + ) } async fn write_cached_profile(uuid: &str, view: &PlayerProfileView) { @@ -359,7 +355,8 @@ mod tests { use super::*; fn temp_launcher_dir() { - let dir = std::env::temp_dir().join(format!("oneclient-profile-test-{}", uuid::Uuid::new_v4())); + let dir = + std::env::temp_dir().join(format!("oneclient-profile-test-{}", uuid::Uuid::new_v4())); oneclient_common::paths::set_launcher_dir(dir); } @@ -412,7 +409,9 @@ mod tests { let view = sample_view("Legacy"); let path = profile_cache_path(&uuid).unwrap(); - polyio::create_dir_all(path.parent().unwrap()).await.unwrap(); + polyio::create_dir_all(path.parent().unwrap()) + .await + .unwrap(); polyio::write(&path, &serde_json::to_vec(&view).unwrap()) .await .unwrap(); @@ -422,6 +421,9 @@ mod tests { .expect("legacy entry should still parse"); assert_eq!(cached.view, view); - assert!(!cached.is_fresh(), "legacy entry should be due for revalidation"); + assert!( + !cached.is_fresh(), + "legacy entry should be due for revalidation" + ); } } diff --git a/packages/oneclient_net/src/cache.rs b/packages/oneclient_net/src/cache.rs index 593df3f7..964744fe 100644 --- a/packages/oneclient_net/src/cache.rs +++ b/packages/oneclient_net/src/cache.rs @@ -7,132 +7,132 @@ use crate::service::RequestClient; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum EtagPolicy { - CommitNow, - /// Hands the ETag back on [`Fetched::etag`] - /// Use when work downstream of the body can still fail so an interrupted run - /// retries instead of getting 304 - Defer, + CommitNow, + /// Hands the ETag back on [`Fetched::etag`] + /// Use when work downstream of the body can still fail so an interrupted run + /// retries instead of getting 304 + Defer, } #[derive(Debug, Clone)] pub struct Fetched { - pub bytes: Vec, - /// `false` when the server answered 304 or the request failed and the - /// cached copy was served instead - pub changed: bool, - /// Present only for a fresh body under [`EtagPolicy::Defer`] - pub etag: Option, + pub bytes: Vec, + /// `false` when the server answered 304 or the request failed and the + /// cached copy was served instead + pub changed: bool, + /// Present only for a fresh body under [`EtagPolicy::Defer`] + pub etag: Option, } impl Fetched { - pub fn json(&self) -> Result { - serde_json::from_slice(&self.bytes).map_err(|source| RequestError::DeserializeError { - source, - type_name: std::any::type_name::().to_string(), - url: String::new(), - status: 200, - snippet: crate::error::body_snippet(&self.bytes), - }) - } + pub fn json(&self) -> Result { + serde_json::from_slice(&self.bytes).map_err(|source| RequestError::DeserializeError { + source, + type_name: std::any::type_name::().to_string(), + url: String::new(), + status: 200, + snippet: crate::error::body_snippet(&self.bytes), + }) + } - /// Lossy a bad byte becomes a replacement character rather than an error - #[must_use] - pub fn text(&self) -> String { - String::from_utf8_lossy(&self.bytes).into_owned() - } + /// Lossy a bad byte becomes a replacement character rather than an error + #[must_use] + pub fn text(&self) -> String { + String::from_utf8_lossy(&self.bytes).into_owned() + } } fn etag_path(cache_path: &Path) -> PathBuf { - let mut path = cache_path.as_os_str().to_os_string(); - path.push(".etag"); - PathBuf::from(path) + let mut path = cache_path.as_os_str().to_os_string(); + path.push(".etag"); + PathBuf::from(path) } async fn read_sidecar_etag(cache_path: &Path) -> Option { - polyio::read(etag_path(cache_path)) - .await - .ok() - .and_then(|bytes| String::from_utf8(bytes).ok()) - .filter(|etag| !etag.is_empty()) + polyio::read(etag_path(cache_path)) + .await + .ok() + .and_then(|bytes| String::from_utf8(bytes).ok()) + .filter(|etag| !etag.is_empty()) } /// Callers using [`EtagPolicy::Defer`] call this once the work depending on the /// body has succeeded pub async fn commit_etag(cache_path: &Path, etag: &str) { - if let Err(err) = polyio::write_atomic(etag_path(cache_path), etag.as_bytes()).await { - tracing::warn!("failed to write etag sidecar: {err}"); - } + if let Err(err) = polyio::write_atomic(etag_path(cache_path), etag.as_bytes()).await { + tracing::warn!("failed to write etag sidecar: {err}"); + } } /// Returns `Ok(None)` only when the request failed *and* there is no cached /// copy #[tracing::instrument(level = "debug", skip(client), fields(%url))] pub async fn fetch_cached( - client: &RequestClient, - url: &str, - cache_path: &Path, - policy: EtagPolicy, + client: &RequestClient, + url: &str, + cache_path: &Path, + policy: EtagPolicy, ) -> Result, RequestError> { - let stored_etag = read_sidecar_etag(cache_path).await; - let mut request = reqwest::Request::new(Method::GET, url.parse()?); + let stored_etag = read_sidecar_etag(cache_path).await; + let mut request = reqwest::Request::new(Method::GET, url.parse()?); - if let Some(etag) = &stored_etag - && let Ok(value) = header::HeaderValue::from_str(etag) - { - request.headers_mut().insert(header::IF_NONE_MATCH, value); - } + if let Some(etag) = &stored_etag + && let Ok(value) = header::HeaderValue::from_str(etag) + { + request.headers_mut().insert(header::IF_NONE_MATCH, value); + } - let cached = || async { - match polyio::read(cache_path).await { - Ok(bytes) => Ok(Some(Fetched { - bytes, - changed: false, - etag: None, - })), - Err(err) => { - tracing::debug!("no usable cache at {}: {err}", cache_path.display()); - Ok(None) - } - } - }; + let cached = || async { + match polyio::read(cache_path).await { + Ok(bytes) => Ok(Some(Fetched { + bytes, + changed: false, + etag: None, + })), + Err(err) => { + tracing::debug!("no usable cache at {}: {err}", cache_path.display()); + Ok(None) + } + } + }; - match client.send(request).await { - Ok(res) if res.status() == StatusCode::NOT_MODIFIED => { - tracing::debug!("cache hit (304)"); - cached().await - } - Ok(res) if res.status().is_success() => { - let server_etag = res - .headers() - .get(header::ETAG) - .and_then(|value| value.to_str().ok()) - .map(str::to_string); + match client.send(request).await { + Ok(res) if res.status() == StatusCode::NOT_MODIFIED => { + tracing::debug!("cache hit (304)"); + cached().await + } + Ok(res) if res.status().is_success() => { + let server_etag = res + .headers() + .get(header::ETAG) + .and_then(|value| value.to_str().ok()) + .map(str::to_string); - let bytes = res.bytes().await?.to_vec(); - polyio::write_atomic(cache_path, &bytes).await?; + let bytes = res.bytes().await?.to_vec(); + polyio::write_atomic(cache_path, &bytes).await?; - let etag = match (&server_etag, policy) { - (Some(etag), EtagPolicy::CommitNow) => { - commit_etag(cache_path, etag).await; - None - } - (_, EtagPolicy::Defer) => server_etag, - (None, EtagPolicy::CommitNow) => None, - }; + let etag = match (&server_etag, policy) { + (Some(etag), EtagPolicy::CommitNow) => { + commit_etag(cache_path, etag).await; + None + } + (_, EtagPolicy::Defer) => server_etag, + (None, EtagPolicy::CommitNow) => None, + }; - Ok(Some(Fetched { - bytes, - changed: true, - etag, - })) - } - Ok(res) => { - tracing::warn!(status = %res.status(), "unexpected response; using cache if present"); - cached().await - } - Err(err) => { - tracing::debug!("fetch failed, using cache if present: {err}"); - cached().await - } - } + Ok(Some(Fetched { + bytes, + changed: true, + etag, + })) + } + Ok(res) => { + tracing::warn!(status = %res.status(), "unexpected response; using cache if present"); + cached().await + } + Err(err) => { + tracing::debug!("fetch failed, using cache if present: {err}"); + cached().await + } + } } diff --git a/packages/oneclient_net/src/config.rs b/packages/oneclient_net/src/config.rs index 6cd1b94e..e2d9d7a4 100644 --- a/packages/oneclient_net/src/config.rs +++ b/packages/oneclient_net/src/config.rs @@ -2,96 +2,104 @@ use oneclient_common::constants; #[derive(Clone, Debug, PartialEq, Eq)] pub struct NetConfig { - pub curseforge_api_key: String, - pub modrinth_api_key: Option, - pub metadata_api_url: String, - /// Never has a trailing slash - pub meta_url_base: String, + pub curseforge_api_key: String, + pub modrinth_api_key: Option, + pub metadata_api_url: String, + /// Never has a trailing slash + pub meta_url_base: String, } impl Default for NetConfig { - fn default() -> Self { - Self { - curseforge_api_key: constants::CURSEFORGE_API_KEY.to_string(), - modrinth_api_key: None, - metadata_api_url: constants::METADATA_API_URL.to_string(), - meta_url_base: constants::META_URL_BASE.to_string(), - } - } + fn default() -> Self { + Self { + curseforge_api_key: constants::CURSEFORGE_API_KEY.to_string(), + modrinth_api_key: None, + metadata_api_url: constants::METADATA_API_URL.to_string(), + meta_url_base: constants::META_URL_BASE.to_string(), + } + } } impl NetConfig { - /// Blank is treated as absent so clearing a field in the settings UI - /// restores the default rather than sending an empty key - #[must_use] - pub fn with_overrides( - mut self, - curseforge_api_key: Option<&str>, - modrinth_api_key: Option<&str>, - metadata_api_url: Option<&str>, - meta_url_base: Option<&str>, - ) -> Self { - if let Some(value) = non_empty(curseforge_api_key) { - self.curseforge_api_key = value; - } - self.modrinth_api_key = non_empty(modrinth_api_key); - if let Some(value) = non_empty(metadata_api_url) { - self.metadata_api_url = value; - } - if let Some(value) = non_empty(meta_url_base) { - self.meta_url_base = value; - } - self.meta_url_base = self.meta_url_base.trim_end_matches('/').to_string(); - self - } + /// Blank is treated as absent so clearing a field in the settings UI + /// restores the default rather than sending an empty key + #[must_use] + pub fn with_overrides( + mut self, + curseforge_api_key: Option<&str>, + modrinth_api_key: Option<&str>, + metadata_api_url: Option<&str>, + meta_url_base: Option<&str>, + ) -> Self { + if let Some(value) = non_empty(curseforge_api_key) { + self.curseforge_api_key = value; + } + self.modrinth_api_key = non_empty(modrinth_api_key); + if let Some(value) = non_empty(metadata_api_url) { + self.metadata_api_url = value; + } + if let Some(value) = non_empty(meta_url_base) { + self.meta_url_base = value; + } + self.meta_url_base = self.meta_url_base.trim_end_matches('/').to_string(); + self + } - #[must_use] - pub fn modrinth_headers(&self) -> Vec<(String, String)> { - match &self.modrinth_api_key { - Some(token) => vec![("Authorization".to_string(), token.clone())], - None => Vec::new(), - } - } + #[must_use] + pub fn modrinth_headers(&self) -> Vec<(String, String)> { + match &self.modrinth_api_key { + Some(token) => vec![("Authorization".to_string(), token.clone())], + None => Vec::new(), + } + } } fn non_empty(value: Option<&str>) -> Option { - value - .map(str::trim) - .filter(|v| !v.is_empty()) - .map(str::to_string) + value + .map(str::trim) + .filter(|v| !v.is_empty()) + .map(str::to_string) } #[cfg(test)] mod tests { - use super::*; + use super::*; - #[test] - fn blank_overrides_fall_back_to_defaults() { - let config = NetConfig::default().with_overrides(Some(" "), Some(""), None, None); + #[test] + fn blank_overrides_fall_back_to_defaults() { + let config = NetConfig::default().with_overrides(Some(" "), Some(""), None, None); - assert_eq!(config.curseforge_api_key, constants::CURSEFORGE_API_KEY); - assert_eq!(config.modrinth_api_key, None); - assert_eq!(config.metadata_api_url, constants::METADATA_API_URL); - } + assert_eq!(config.curseforge_api_key, constants::CURSEFORGE_API_KEY); + assert_eq!(config.modrinth_api_key, None); + assert_eq!(config.metadata_api_url, constants::METADATA_API_URL); + } - #[test] - fn meta_url_base_never_keeps_a_trailing_slash() { - let config = NetConfig::default() - .with_overrides(None, None, None, Some("https://example.test/base/")); + #[test] + fn meta_url_base_never_keeps_a_trailing_slash() { + let config = NetConfig::default().with_overrides( + None, + None, + None, + Some("https://example.test/base/"), + ); - assert_eq!(config.meta_url_base, "https://example.test/base"); - } + assert_eq!(config.meta_url_base, "https://example.test/base"); + } - #[test] - fn overrides_replace_defaults() { - let config = - NetConfig::default().with_overrides(Some("key"), Some("token"), Some("https://a"), None); + #[test] + fn overrides_replace_defaults() { + let config = NetConfig::default().with_overrides( + Some("key"), + Some("token"), + Some("https://a"), + None, + ); - assert_eq!(config.curseforge_api_key, "key"); - assert_eq!( - config.modrinth_headers(), - vec![("Authorization".to_string(), "token".to_string())] - ); - assert_eq!(config.metadata_api_url, "https://a"); - } + assert_eq!(config.curseforge_api_key, "key"); + assert_eq!( + config.modrinth_headers(), + vec![("Authorization".to_string(), "token".to_string())] + ); + assert_eq!(config.metadata_api_url, "https://a"); + } } diff --git a/packages/oneclient_net/src/download.rs b/packages/oneclient_net/src/download.rs index 014b0e05..25b7b831 100644 --- a/packages/oneclient_net/src/download.rs +++ b/packages/oneclient_net/src/download.rs @@ -30,206 +30,206 @@ fn retry_delay(attempt: u32) -> std::time::Duration { /// Returns `false` when the file is missing or unreadable pub async fn matches_on_disk(path: &Path, expected_sha1: &str) -> bool { - if !path.is_file() { - return false; - } + if !path.is_file() { + return false; + } - match polyio::sha1_file(path).await { - Ok(actual) => normalize_hash(&actual) == normalize_hash(expected_sha1), - Err(err) => { - tracing::debug!("could not hash {}: {err}", path.display()); - false - } - } + match polyio::sha1_file(path).await { + Ok(actual) => normalize_hash(&actual) == normalize_hash(expected_sha1), + Err(err) => { + tracing::debug!("could not hash {}: {err}", path.display()); + false + } + } } /// `dest` is only created once the hash matches so a failed attempt leaves /// nothing behind for a later `exists()` check to trip over #[tracing::instrument(level = "debug", skip(client, events, notify), fields(%url))] pub async fn download_verified( - client: &RequestClient, - events: &EventBus, - url: &str, - dest: &Path, - expected: Option<&Checksum>, - expected_size: u64, - notify: Option, + client: &RequestClient, + events: &EventBus, + url: &str, + dest: &Path, + expected: Option<&Checksum>, + expected_size: u64, + notify: Option, ) -> Result<(), RequestError> { - // Callers fanning out over thousands of files pre-create the tree so this - // stat avoids a blocking-pool round trip per file - if let Some(parent) = dest.parent() - && !parent.is_dir() - { - polyio::create_dir_all(parent).await?; - } + // Callers fanning out over thousands of files pre-create the tree so this + // stat avoids a blocking-pool round trip per file + if let Some(parent) = dest.parent() + && !parent.is_dir() + { + polyio::create_dir_all(parent).await?; + } - // Needs a fixed id up front or each retry opens a new UI entry - let notify = notify.map(ResponseNotifyOptions::pinned); + // Needs a fixed id up front or each retry opens a new UI entry + let notify = notify.map(ResponseNotifyOptions::pinned); - let mut attempt = 1; - loop { - match download_attempt( - client, - events, - url, - dest, - expected, - expected_size, - notify.as_ref(), - ) - .await - { - Ok(()) => break, - Err(err) if attempt < MAX_DOWNLOAD_ATTEMPTS && worth_retrying(&err) => { - tracing::warn!( - attempt, - %url, - "download failed, retrying: {err}" - ); - tokio::time::sleep(retry_delay(attempt)).await; - attempt += 1; - } - Err(err) => return Err(err), - } - } + let mut attempt = 1; + loop { + match download_attempt( + client, + events, + url, + dest, + expected, + expected_size, + notify.as_ref(), + ) + .await + { + Ok(()) => break, + Err(err) if attempt < MAX_DOWNLOAD_ATTEMPTS && worth_retrying(&err) => { + tracing::warn!( + attempt, + %url, + "download failed, retrying: {err}" + ); + tokio::time::sleep(retry_delay(attempt)).await; + attempt += 1; + } + Err(err) => return Err(err), + } + } - if let Some(child) = notify.as_ref().and_then(ResponseNotifyOptions::child) { - child.finish(); - } + if let Some(child) = notify.as_ref().and_then(ResponseNotifyOptions::child) { + child.finish(); + } - Ok(()) + Ok(()) } /// The progress child is shared across attempts and reports absolute byte /// counts so a restarted attempt rewinds its bar rather than double-counting #[allow(clippy::too_many_arguments)] async fn download_attempt( - client: &RequestClient, - events: &EventBus, - url: &str, - dest: &Path, - expected: Option<&Checksum>, - expected_size: u64, - notify: Option<&ResponseNotifyOptions>, + client: &RequestClient, + events: &EventBus, + url: &str, + dest: &Path, + expected: Option<&Checksum>, + expected_size: u64, + notify: Option<&ResponseNotifyOptions>, ) -> Result<(), RequestError> { - let child = notify.and_then(ResponseNotifyOptions::child); - if let Some(child) = child { - child.set_phase(TaskPhase::Downloading); - } + let child = notify.and_then(ResponseNotifyOptions::child); + if let Some(child) = child { + child.set_phase(TaskPhase::Downloading); + } - let request = reqwest::Request::new(Method::GET, url.parse()?); - let response = client.send(request).await?; + let request = reqwest::Request::new(Method::GET, url.parse()?); + let response = client.send(request).await?; - // Without this an error body is written to disk under the requested file's name - let status = response.status(); - if !status.is_success() { - let bytes = response.bytes().await?; - return Err(RequestError::HttpStatus { - status: status.as_u16(), - url: url.to_string(), - snippet: crate::error::body_snippet(&bytes), - }); - } + // Without this an error body is written to disk under the requested file's name + let status = response.status(); + if !status.is_success() { + let bytes = response.bytes().await?; + return Err(RequestError::HttpStatus { + status: status.as_u16(), + url: url.to_string(), + snippet: crate::error::body_snippet(&bytes), + }); + } - // Prefer the manifest size Content-Length is absent for compressed or chunked bodies - let declared = if expected_size > 0 { - Some(expected_size) - } else { - response.content_length() - }; - let total = declared.unwrap_or(0).max(1); + // Prefer the manifest size Content-Length is absent for compressed or chunked bodies + let declared = if expected_size > 0 { + Some(expected_size) + } else { + response.content_length() + }; + let total = declared.unwrap_or(0).max(1); - let options = ResponseOptions { - notify: notify.cloned(), - }; - let stream = response.stream(options, events).await?; + let options = ResponseOptions { + notify: notify.cloned(), + }; + let stream = response.stream(options, events).await?; - let mut hasher = expected.map(|expected| ChecksumStream::new(expected.algorithm)); - let mut received = 0u64; - { - let stream = futures_lite::StreamExt::map(stream, |item| { - if let Ok(chunk) = &item { - received += chunk.len() as u64; - if let Some(hasher) = hasher.as_mut() { - hasher.update(chunk); - } - } - item - }); - let stream = std::pin::pin!(stream); - // Writes to a scratch sibling and renames so `dest` is never truncated - polyio::write_stream(dest, stream, Some(total)).await?; - } + let mut hasher = expected.map(|expected| ChecksumStream::new(expected.algorithm)); + let mut received = 0u64; + { + let stream = futures_lite::StreamExt::map(stream, |item| { + if let Ok(chunk) = &item { + received += chunk.len() as u64; + if let Some(hasher) = hasher.as_mut() { + hasher.update(chunk); + } + } + item + }); + let stream = std::pin::pin!(stream); + // Writes to a scratch sibling and renames so `dest` is never truncated + polyio::write_stream(dest, stream, Some(total)).await?; + } - if let (Some(expected), Some(hasher)) = (expected, hasher) { - if let Some(child) = child { - child.set_phase(TaskPhase::Verifying); - } + if let (Some(expected), Some(hasher)) = (expected, hasher) { + if let Some(child) = child { + child.set_phase(TaskPhase::Verifying); + } - let actual = hasher.finish(); - if !expected.matches(&actual) { - // Remove it rather than leave it for the next `exists()` check - let _ = polyio::remove_file(dest).await; - return Err(RequestError::HashMismatch { - source_desc: dest.display().to_string(), - expected: format!("{} {}", expected.algorithm.name(), expected.hex), - actual, - }); - } - } else if let Some(declared) = declared - && received != declared - { - // With no hash the byte count is the only evidence the transfer finished - let _ = polyio::remove_file(dest).await; - return Err(RequestError::IncompleteBody { - source_desc: dest.display().to_string(), - expected: declared, - actual: received, - }); - } + let actual = hasher.finish(); + if !expected.matches(&actual) { + // Remove it rather than leave it for the next `exists()` check + let _ = polyio::remove_file(dest).await; + return Err(RequestError::HashMismatch { + source_desc: dest.display().to_string(), + expected: format!("{} {}", expected.algorithm.name(), expected.hex), + actual, + }); + } + } else if let Some(declared) = declared + && received != declared + { + // With no hash the byte count is the only evidence the transfer finished + let _ = polyio::remove_file(dest).await; + return Err(RequestError::IncompleteBody { + source_desc: dest.display().to_string(), + expected: declared, + actual: received, + }); + } - Ok(()) + Ok(()) } #[tracing::instrument(level = "debug", skip(client, events, progress), fields(%url))] pub async fn fetch_verified( - client: &RequestClient, - events: &EventBus, - url: &str, - expected_sha1: &str, - progress: Option, + client: &RequestClient, + events: &EventBus, + url: &str, + expected_sha1: &str, + progress: Option, ) -> Result, RequestError> { - let request = reqwest::Request::new(Method::GET, url.parse()?); - let response = client.send(request).await?; + let request = reqwest::Request::new(Method::GET, url.parse()?); + let response = client.send(request).await?; - let options = ResponseOptions { - notify: progress.clone().map(ResponseNotifyOptions::grouped), - }; - let stream = response.stream(options, events).await?; + let options = ResponseOptions { + notify: progress.clone().map(ResponseNotifyOptions::grouped), + }; + let stream = response.stream(options, events).await?; - use futures_util::StreamExt; - let mut stream = std::pin::pin!(stream); - let mut bytes = Vec::new(); - while let Some(chunk) = stream.next().await { - bytes.extend_from_slice(&chunk?); - } + use futures_util::StreamExt; + let mut stream = std::pin::pin!(stream); + let mut bytes = Vec::new(); + while let Some(chunk) = stream.next().await { + bytes.extend_from_slice(&chunk?); + } - if let Some(child) = &progress { - child.set_phase(TaskPhase::Verifying); - } + if let Some(child) = &progress { + child.set_phase(TaskPhase::Verifying); + } - let actual = polyio::sha1_bytes(&bytes); - if normalize_hash(&actual) != normalize_hash(expected_sha1) { - return Err(RequestError::HashMismatch { - source_desc: url.to_string(), - expected: expected_sha1.to_string(), - actual, - }); - } + let actual = polyio::sha1_bytes(&bytes); + if normalize_hash(&actual) != normalize_hash(expected_sha1) { + return Err(RequestError::HashMismatch { + source_desc: url.to_string(), + expected: expected_sha1.to_string(), + actual, + }); + } - if let Some(child) = progress { - child.finish(); - } + if let Some(child) = progress { + child.finish(); + } - Ok(bytes) + Ok(bytes) } diff --git a/packages/oneclient_net/src/error.rs b/packages/oneclient_net/src/error.rs index 93938f32..be8b224d 100644 --- a/packages/oneclient_net/src/error.rs +++ b/packages/oneclient_net/src/error.rs @@ -82,7 +82,7 @@ pub enum RequestError { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum NetworkFailure { Certificate, - Handshake, + Handshake, Dns, Generic, } diff --git a/packages/oneclient_net/src/response.rs b/packages/oneclient_net/src/response.rs index 35cbbd1c..f2e4e69d 100644 --- a/packages/oneclient_net/src/response.rs +++ b/packages/oneclient_net/src/response.rs @@ -2,7 +2,7 @@ use reqwest::Response; use uuid::Uuid; use crate::error::RequestError; -use oneclient_events::{GroupedProgressChild, EventBus}; +use oneclient_events::{EventBus, GroupedProgressChild}; const PROGRESS_INTERVAL: std::time::Duration = std::time::Duration::from_millis(50); @@ -85,22 +85,20 @@ impl ResponseExt for Response { let total = self.content_length().unwrap_or(0).max(1); let mut current = 0u64; - let grouped_child = options - .notify - .as_ref() - .and_then(|n| n.child.clone()); + let grouped_child = options.notify.as_ref().and_then(|n| n.child.clone()); let standalone_label = options .notify .as_ref() .and_then(|n| n.standalone_label.clone()); - let done_label = options - .notify - .as_ref() - .and_then(|n| n.done_label.clone()); + 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)); + 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)); @@ -112,34 +110,32 @@ impl ResponseExt for Response { // One event per chunk would bury the UI in tens of thousands of updates // so sample the last one always emits so the bar lands on complete let mut last_emit: Option = None; - let stream = futures_lite::StreamExt::map(self.bytes_stream(), move |item| { - match item { - Ok(chunk) => { - current += chunk.len() as u64; + let stream = futures_lite::StreamExt::map(self.bytes_stream(), move |item| match item { + Ok(chunk) => { + current += chunk.len() as u64; - let now = std::time::Instant::now(); - let due = current >= total - || last_emit.is_none_or(|last| now.duration_since(last) >= PROGRESS_INTERVAL); + let now = std::time::Instant::now(); + let due = current >= total + || last_emit.is_none_or(|last| now.duration_since(last) >= PROGRESS_INTERVAL); - if due { - last_emit = Some(now); - 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 - }; - events.progress(*id, label, current, total); - } + if due { + last_emit = Some(now); + 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 + }; + events.progress(*id, label, current, total); } - - Ok(chunk) } - Err(err) => Err(RequestError::from(err)), + + Ok(chunk) } + Err(err) => Err(RequestError::from(err)), }); Ok(stream) diff --git a/packages/oneclient_net/src/service.rs b/packages/oneclient_net/src/service.rs index 9868561c..145700e2 100644 --- a/packages/oneclient_net/src/service.rs +++ b/packages/oneclient_net/src/service.rs @@ -243,7 +243,10 @@ impl RequestClient { } #[tracing::instrument(level = "debug", skip_all)] - pub async fn send_as(&self, request: impl Into) -> Result { + pub async fn send_as( + &self, + request: impl Into, + ) -> Result { let res = self.send(request).await?; let status = res.status(); let url = res.url().to_string(); @@ -344,8 +347,10 @@ mod tests { #[test] fn does_not_override_explicit_key() { let mut req = request("https://api.curseforge.com/v1/mods"); - req.headers_mut() - .insert("x-api-key", reqwest::header::HeaderValue::from_static("mine")); + req.headers_mut().insert( + "x-api-key", + reqwest::header::HeaderValue::from_static("mine"), + ); apply_curseforge_auth(&mut req, oneclient_common::constants::CURSEFORGE_API_KEY).unwrap(); assert_eq!(req.headers()["x-api-key"], "mine"); } diff --git a/packages/oneclient_net/src/status.rs b/packages/oneclient_net/src/status.rs index 07d55a19..e1c8006d 100644 --- a/packages/oneclient_net/src/status.rs +++ b/packages/oneclient_net/src/status.rs @@ -1,5 +1,5 @@ -use std::sync::{OnceLock, RwLock}; use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{OnceLock, RwLock}; use std::time::Duration; use tokio::sync::{Notify, watch}; @@ -114,12 +114,7 @@ pub fn start(client: RequestClient) { } async fn reachable(client: &reqwest::Client, url: &str) -> bool { - client - .get(url) - .timeout(PROBE_TIMEOUT) - .send() - .await - .is_ok() + client.get(url).timeout(PROBE_TIMEOUT).send().await.is_ok() } pub async fn check_service_status(requester: &RequestClient) -> ServiceStatus { diff --git a/packages/oneclient_polyplus/src/lib.rs b/packages/oneclient_polyplus/src/lib.rs index d1d522ce..955b67c0 100644 --- a/packages/oneclient_polyplus/src/lib.rs +++ b/packages/oneclient_polyplus/src/lib.rs @@ -234,10 +234,7 @@ where Ok(()) } -async fn login( - client: &reqwest::Client, - account: &MinecraftAccount, -) -> Result { +async fn login(client: &reqwest::Client, account: &MinecraftAccount) -> Result { if plus_blocked() { return Err(PlusError::ConsentRequired); } @@ -355,9 +352,12 @@ mod tests { let pumped = tokio::time::timeout( Duration::from_secs(10), - pump(&mut websocket, Duration::from_millis(50), Uuid::nil(), || async { - false - }), + pump( + &mut websocket, + Duration::from_millis(50), + Uuid::nil(), + || async { false }, + ), ) .await .expect("pump should return once the server closes the socket"); diff --git a/packages/polyio/src/archive.rs b/packages/polyio/src/archive.rs index fd716cca..10362c5e 100644 --- a/packages/polyio/src/archive.rs +++ b/packages/polyio/src/archive.rs @@ -11,55 +11,55 @@ use crate::{IOError, PolyIOResult}; #[tracing::instrument(skip(data, f), level = "debug")] pub async fn read_zip_entries_bytes(data: Vec, mut f: F) -> PolyIOResult<()> where - F: AsyncFnMut( - usize, - &StoredZipEntry, - &mut async_zip::base::read::ZipEntryReader< - '_, - futures_lite::io::Cursor<&[u8]>, - WithoutEntry, - >, - ) -> PolyIOResult<()>, + F: AsyncFnMut( + usize, + &StoredZipEntry, + &mut async_zip::base::read::ZipEntryReader< + '_, + futures_lite::io::Cursor<&[u8]>, + WithoutEntry, + >, + ) -> PolyIOResult<()>, { - let reader = async_zip::base::read::mem::ZipFileReader::new(data).await?; + let reader = async_zip::base::read::mem::ZipFileReader::new(data).await?; - let entries = reader.file().entries(); - for index in 0..entries.len() { - let entry = entries.get(index).expect("expected more zip entries"); - let mut entry_reader = reader - .reader_without_entry(index) - .await - .unwrap_or_else(|_| panic!("expected zip entry at index '{index}'")); + let entries = reader.file().entries(); + for index in 0..entries.len() { + let entry = entries.get(index).expect("expected more zip entries"); + let mut entry_reader = reader + .reader_without_entry(index) + .await + .unwrap_or_else(|_| panic!("expected zip entry at index '{index}'")); - f(index, entry, &mut entry_reader).await?; - } + f(index, entry, &mut entry_reader).await?; + } - Ok(()) + Ok(()) } #[tracing::instrument(skip(data), level = "debug")] pub fn stream_zip_entries_bytes( - data: Vec, + data: Vec, ) -> impl Stream< - Item = Result< - ( - usize, - StoredZipEntry, - Arc, - ), - IOError, - >, + Item = Result< + ( + usize, + StoredZipEntry, + Arc, + ), + IOError, + >, > { - try_stream! { - let reader = Arc::new(async_zip::base::read::mem::ZipFileReader::new(data).await?); - let entries = reader.file().entries(); + try_stream! { + let reader = Arc::new(async_zip::base::read::mem::ZipFileReader::new(data).await?); + let entries = reader.file().entries(); - for index in 0..entries.len() { - let entry = entries.get(index).expect("expected more zip entries"); + for index in 0..entries.len() { + let entry = entries.get(index).expect("expected more zip entries"); - yield (index, entry.clone(), reader.clone()); - } - } + yield (index, entry.clone(), reader.clone()); + } + } } #[tracing::instrument( @@ -70,10 +70,10 @@ pub fn stream_zip_entries_bytes( ) )] pub async fn unzip_bytes( - data: Vec, - dest_path: impl AsRef, + data: Vec, + dest_path: impl AsRef, ) -> PolyIOResult<()> { - unzip_bytes_filtered(data, None:: bool>, dest_path).await + unzip_bytes_filtered(data, None:: bool>, dest_path).await } #[tracing::instrument( @@ -84,43 +84,43 @@ pub async fn unzip_bytes( ) )] pub async fn unzip_bytes_filtered( - data: Vec, - filter_entries: Option bool + Send + Sync>, - dest_path: impl AsRef, + data: Vec, + filter_entries: Option bool + Send + Sync>, + dest_path: impl AsRef, ) -> PolyIOResult<()> { - let stream = stream_zip_entries_bytes(data); - pin_mut!(stream); + let stream = stream_zip_entries_bytes(data); + pin_mut!(stream); - while let Some((index, entry, reader)) = stream.try_next().await? { - let file_name = entry.filename().as_str()?; + while let Some((index, entry, reader)) = stream.try_next().await? { + let file_name = entry.filename().as_str()?; - if let Some(filter) = &filter_entries - && !filter(file_name) - { - continue; - } + if let Some(filter) = &filter_entries + && !filter(file_name) + { + continue; + } - let path = dest_path.as_ref().join(crate::sanitize_path(file_name)); + let path = dest_path.as_ref().join(crate::sanitize_path(file_name)); - let is_dir = entry.dir()?; + let is_dir = entry.dir()?; - if is_dir { - crate::create_dir_all(path).await?; - } else { - if let Some(parent) = path.parent() { - crate::create_dir_all(parent).await?; - } + if is_dir { + crate::create_dir_all(path).await?; + } else { + if let Some(parent) = path.parent() { + crate::create_dir_all(parent).await?; + } - let entry_reader = reader.reader_without_entry(index).await?; + let entry_reader = reader.reader_without_entry(index).await?; - let file = tokio::fs::File::create(&path).await?; - let writer = tokio::io::BufWriter::new(file); + let file = tokio::fs::File::create(&path).await?; + let writer = tokio::io::BufWriter::new(file); - futures_lite::io::copy(entry_reader, &mut writer.compat_write()).await?; - } - } + futures_lite::io::copy(entry_reader, &mut writer.compat_write()).await?; + } + } - Ok(()) + Ok(()) } #[tracing::instrument( @@ -132,22 +132,22 @@ pub async fn unzip_bytes_filtered( ) )] pub async fn extract_zip( - zip_path: impl AsRef, - dest_path: impl AsRef, + zip_path: impl AsRef, + dest_path: impl AsRef, ) -> PolyIOResult<()> { - let zip_path = zip_path.as_ref().to_path_buf(); - let dest_path = dest_path.as_ref().to_path_buf(); + let zip_path = zip_path.as_ref().to_path_buf(); + let dest_path = dest_path.as_ref().to_path_buf(); - off_caller_thread(move || async move { - extract_zip_filtered( - zip_path, - dest_path, - None:: bool>, - None:: String>, - ) - .await - }) - .await + off_caller_thread(move || async move { + extract_zip_filtered( + zip_path, + dest_path, + None:: bool>, + None:: String>, + ) + .await + }) + .await } /// Inflating an archive is CPU work sitting between short async writes, and @@ -155,14 +155,14 @@ pub async fn extract_zip( /// run inline stalls rendering for as long as it takes async fn off_caller_thread(work: F) -> PolyIOResult<()> where - F: FnOnce() -> Fut + Send + 'static, - Fut: std::future::Future>, + F: FnOnce() -> Fut + Send + 'static, + Fut: std::future::Future>, { - let handle = tokio::runtime::Handle::current(); + let handle = tokio::runtime::Handle::current(); - tokio::task::spawn_blocking(move || handle.block_on(work())) - .await - .map_err(std::io::Error::other)? + tokio::task::spawn_blocking(move || handle.block_on(work())) + .await + .map_err(std::io::Error::other)? } #[tracing::instrument( @@ -174,52 +174,52 @@ where ) )] pub async fn extract_zip_filtered( - zip_path: impl AsRef, - dest_path: impl AsRef, - filter_entries: Option bool + Send + Sync>, - modify_entry_name: Option String>, + zip_path: impl AsRef, + dest_path: impl AsRef, + filter_entries: Option bool + Send + Sync>, + modify_entry_name: Option String>, ) -> PolyIOResult<()> { - let zip_path = zip_path.as_ref(); - let dest_path = dest_path.as_ref(); + let zip_path = zip_path.as_ref(); + let dest_path = dest_path.as_ref(); - let reader = async_zip::tokio::read::fs::ZipFileReader::new(zip_path).await?; - let entries = reader.file().entries(); + let reader = async_zip::tokio::read::fs::ZipFileReader::new(zip_path).await?; + let entries = reader.file().entries(); - for index in 0..entries.len() { - let entry = entries.get(index).expect("expected more zip entries"); + for index in 0..entries.len() { + let entry = entries.get(index).expect("expected more zip entries"); - if let Some(filter) = &filter_entries - && !filter(entry) - { - continue; - } + if let Some(filter) = &filter_entries + && !filter(entry) + { + continue; + } - let old_name = entry.filename().as_str()?; - let name: String = modify_entry_name - .as_ref() - .map_or_else(|| old_name.to_string(), |modify| modify(old_name)); + let old_name = entry.filename().as_str()?; + let name: String = modify_entry_name + .as_ref() + .map_or_else(|| old_name.to_string(), |modify| modify(old_name)); - let name = crate::sanitize_path(name); + let name = crate::sanitize_path(name); - let path = dest_path.join(name); - let is_dir = entry.dir()?; + let path = dest_path.join(name); + let is_dir = entry.dir()?; - if is_dir { - crate::create_dir_all(&path).await?; - } else { - if let Some(parent) = path.parent() { - crate::create_dir_all(parent).await?; - } + if is_dir { + crate::create_dir_all(&path).await?; + } else { + if let Some(parent) = path.parent() { + crate::create_dir_all(parent).await?; + } - let file = tokio::fs::File::create(&path).await?; - let writer = tokio::io::BufWriter::new(file); - let entry_reader = reader.reader_without_entry(index).await?; + let file = tokio::fs::File::create(&path).await?; + let writer = tokio::io::BufWriter::new(file); + let entry_reader = reader.reader_without_entry(index).await?; - futures_lite::io::copy(entry_reader, &mut writer.compat_write()).await?; - } - } + futures_lite::io::copy(entry_reader, &mut writer.compat_write()).await?; + } + } - Ok(()) + Ok(()) } /// Ceiling on a single entry read by [`read_zip_file_entries`] mod manifests, @@ -236,54 +236,54 @@ pub const MAX_ZIP_ENTRY_BYTES: u64 = 16 * 1024 * 1024; fields(zip_path = %zip_path.as_ref().display()) )] pub async fn read_zip_file_entries( - zip_path: impl AsRef, - filter: impl Fn(&str) -> bool, + 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 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(); + 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; - } + if entry.dir().unwrap_or(false) || !filter(&name) { + continue; + } - if entry.uncompressed_size() > MAX_ZIP_ENTRY_BYTES { - tracing::warn!( - "skipping '{name}' in {}: declares {} bytes over the {MAX_ZIP_ENTRY_BYTES} byte entry cap", - zip_path.display(), - entry.uncompressed_size() - ); - continue; - } + if entry.uncompressed_size() > MAX_ZIP_ENTRY_BYTES { + tracing::warn!( + "skipping '{name}' in {}: declares {} bytes over the {MAX_ZIP_ENTRY_BYTES} byte entry cap", + zip_path.display(), + entry.uncompressed_size() + ); + continue; + } - let entry_reader = reader.reader_without_entry(index).await?; - let mut data = Vec::new(); - futures_lite::AsyncReadExt::read_to_end( - &mut futures_lite::AsyncReadExt::take(entry_reader, MAX_ZIP_ENTRY_BYTES + 1), - &mut data, - ) - .await?; + let entry_reader = reader.reader_without_entry(index).await?; + let mut data = Vec::new(); + futures_lite::AsyncReadExt::read_to_end( + &mut futures_lite::AsyncReadExt::take(entry_reader, MAX_ZIP_ENTRY_BYTES + 1), + &mut data, + ) + .await?; - if data.len() as u64 > MAX_ZIP_ENTRY_BYTES { - tracing::warn!( - "skipping '{name}' in {}: decompressed past the {MAX_ZIP_ENTRY_BYTES} byte entry cap", - zip_path.display() - ); - continue; - } + if data.len() as u64 > MAX_ZIP_ENTRY_BYTES { + tracing::warn!( + "skipping '{name}' in {}: decompressed past the {MAX_ZIP_ENTRY_BYTES} byte entry cap", + zip_path.display() + ); + continue; + } - out.push((name, data)); - } + out.push((name, data)); + } - Ok(out) + Ok(out) } #[tracing::instrument( @@ -291,49 +291,44 @@ pub async fn read_zip_file_entries( skip(zip_path), fields(zip_path = %zip_path.as_ref().display()) )] -pub async fn zip_entry_names( - zip_path: impl AsRef, -) -> PolyIOResult> { - let reader = async_zip::tokio::read::fs::ZipFileReader::new(zip_path.as_ref()).await?; +pub async fn zip_entry_names(zip_path: impl AsRef) -> PolyIOResult> { + let reader = async_zip::tokio::read::fs::ZipFileReader::new(zip_path.as_ref()).await?; - Ok(reader - .file() - .entries() - .iter() - .filter_map(|entry| entry.filename().as_str().ok().map(ToString::to_string)) - .collect()) + Ok(reader + .file() + .entries() + .iter() + .filter_map(|entry| entry.filename().as_str().ok().map(ToString::to_string)) + .collect()) } /// Returns a zip file entry's bytes without reading the entire file into memory -#[tracing::instrument( - level = "debug", - skip(reader) -)] +#[tracing::instrument(level = "debug", skip(reader))] pub async fn try_read_zip_entry_bytes(reader: R, file_name: &str) -> PolyIOResult> where - R: tokio::io::AsyncRead + tokio::io::AsyncBufRead + tokio::io::AsyncSeek + Unpin, + R: tokio::io::AsyncRead + tokio::io::AsyncBufRead + tokio::io::AsyncSeek + Unpin, { - use tokio_util::compat::TokioAsyncReadCompatExt; - let compat = reader.compat(); + use tokio_util::compat::TokioAsyncReadCompatExt; + let compat = reader.compat(); - let mut zip_reader = async_zip::base::read::seek::ZipFileReader::new(compat).await?; + let mut zip_reader = async_zip::base::read::seek::ZipFileReader::new(compat).await?; - let index = zip_reader - .file() - .entries() - .iter() - .position(|entry| entry.filename().as_str().is_ok_and(|n| n == file_name)) - .ok_or_else(|| IOError::FileNotFoundInZip { - file_name: file_name.to_string() + let index = zip_reader + .file() + .entries() + .iter() + .position(|entry| entry.filename().as_str().is_ok_and(|n| n == file_name)) + .ok_or_else(|| IOError::FileNotFoundInZip { + file_name: file_name.to_string(), })?; - let mut entry_reader = zip_reader.reader_without_entry(index).await?; + let mut entry_reader = zip_reader.reader_without_entry(index).await?; - let mut data: Vec = Vec::new(); + let mut data: Vec = Vec::new(); - futures_lite::AsyncReadExt::read_to_end(&mut entry_reader, &mut data).await?; + futures_lite::AsyncReadExt::read_to_end(&mut entry_reader, &mut data).await?; - Ok(data) + Ok(data) } #[tracing::instrument( @@ -344,21 +339,24 @@ where dest = %dest.as_ref().display(), ) )] -pub async fn extract_tar_gz(archive: impl AsRef, dest: impl AsRef) -> PolyIOResult<()> { - let archive = archive.as_ref().to_path_buf(); - let dest = dest.as_ref().to_path_buf(); +pub async fn extract_tar_gz( + archive: impl AsRef, + dest: impl AsRef, +) -> PolyIOResult<()> { + let archive = archive.as_ref().to_path_buf(); + let dest = dest.as_ref().to_path_buf(); - off_caller_thread(move || async move { - crate::create_dir_all(&dest).await?; + off_caller_thread(move || async move { + crate::create_dir_all(&dest).await?; - 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 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?; + let mut tar_archive = tokio_tar::Archive::new(gzip_decoder); + tar_archive.unpack(dest).await?; - Ok(()) - }) - .await -} \ No newline at end of file + Ok(()) + }) + .await +} diff --git a/packages/polyio/src/error.rs b/packages/polyio/src/error.rs index 9ba72aa3..f77f1d5d 100644 --- a/packages/polyio/src/error.rs +++ b/packages/polyio/src/error.rs @@ -4,52 +4,39 @@ pub type PolyIOResult = Result; #[derive(Debug, thiserror::Error)] pub enum IOError { - #[error("Invalid absolute path '{path}'")] - InvalidAbsolutePath { - path: PathBuf, + #[error("Invalid absolute path '{path}'")] + InvalidAbsolutePath { path: PathBuf }, + + #[error("Couldn't find file '{file_name}' in zip")] + FileNotFoundInZip { file_name: String }, + + #[error("An error occurred whilst accessing path '{path}': {source}")] + PathIOError { + #[source] + source: std::io::Error, + path: String, }, - #[error("Couldn't find file '{file_name}' in zip")] - FileNotFoundInZip { - file_name: String, - }, + #[error(transparent)] + IOError(#[from] std::io::Error), - #[error("An error occurred whilst accessing path '{path}': {source}")] - PathIOError { - #[source] - source: std::io::Error, - path: String, - }, - - #[error(transparent)] - IOError( - #[from] - std::io::Error, - ), - - #[error("Failed to parse JSON for file '{file}': {source}")] - JsonFileParseError { - #[source] - source: serde_json::Error, - file: PathBuf + #[error("Failed to parse JSON for file '{file}': {source}")] + JsonFileParseError { + #[source] + source: serde_json::Error, + file: PathBuf, }, #[error("Failed to convert to JSON for file '{file}': {source}")] JsonFileWrite { #[source] source: serde_json::Error, - file: PathBuf + file: PathBuf, }, - #[error(transparent)] - AsyncZipError( - #[from] - async_zip::error::ZipError, - ), + #[error(transparent)] + AsyncZipError(#[from] async_zip::error::ZipError), - #[error("Temporary file error: {0}")] - TempFileError( - #[from] - async_tempfile::Error, - ), -} \ No newline at end of file + #[error("Temporary file error: {0}")] + TempFileError(#[from] async_tempfile::Error), +} diff --git a/packages/polyio/src/file.rs b/packages/polyio/src/file.rs index ec5834b8..1b8660fd 100644 --- a/packages/polyio/src/file.rs +++ b/packages/polyio/src/file.rs @@ -1,6 +1,9 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::time::Duration; -use std::{fs::Metadata, path::{Path, PathBuf}}; +use std::{ + fs::Metadata, + path::{Path, PathBuf}, +}; use async_tempfile::{TempDir, TempFile}; use serde::{Serialize, de::DeserializeOwned}; @@ -16,10 +19,10 @@ const DEFAULT_WRITE_BUFFER: usize = 64 * 1024; /// Sizing the buffer to the file is a memory-footprint choice not a throughput /// one a fixed 256 KiB costs ~8 MiB across 32 concurrent small-asset downloads fn write_buffer_size(size_hint: Option) -> usize { - match size_hint { - Some(0) | None => DEFAULT_WRITE_BUFFER, - Some(size) => (size.min(MAX_WRITE_BUFFER as u64) as usize).max(MIN_WRITE_BUFFER), - } + match size_hint { + Some(0) | None => DEFAULT_WRITE_BUFFER, + Some(size) => (size.min(MAX_WRITE_BUFFER as u64) as usize).max(MIN_WRITE_BUFFER), + } } #[tracing::instrument( @@ -28,14 +31,14 @@ fn write_buffer_size(size_hint: Option) -> usize { fields(path = %path.as_ref().display()) )] pub async fn read_dir(path: impl AsRef) -> PolyIOResult { - let path = path.as_ref(); + let path = path.as_ref(); - tokio::fs::read_dir(path) - .await - .map_err(|e| IOError::PathIOError { - source: e, - path: path.to_string_lossy().to_string(), - }) + tokio::fs::read_dir(path) + .await + .map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + }) } #[tracing::instrument( @@ -44,17 +47,17 @@ pub async fn read_dir(path: impl AsRef) -> PolyIOResult) -> PolyIOResult<()> { - let path = path.as_ref(); - if path.exists() { - return Ok(()); - } + let path = path.as_ref(); + if path.exists() { + return Ok(()); + } - tokio::fs::create_dir(path) - .await - .map_err(|e| IOError::PathIOError { - source: e, - path: path.to_string_lossy().to_string(), - }) + tokio::fs::create_dir(path) + .await + .map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + }) } #[tracing::instrument( @@ -63,13 +66,13 @@ pub async fn create_dir(path: impl AsRef) -> PolyIOResult<()> { fields(path = %path.as_ref().display()) )] pub async fn create_dir_all(path: impl AsRef) -> PolyIOResult<()> { - let path = path.as_ref(); - tokio::fs::create_dir_all(path) - .await - .map_err(|e| IOError::PathIOError { - source: e, - path: path.to_string_lossy().to_string(), - }) + let path = path.as_ref(); + tokio::fs::create_dir_all(path) + .await + .map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + }) } #[tracing::instrument( @@ -78,14 +81,14 @@ pub async fn create_dir_all(path: impl AsRef) -> PolyIOResult<( fields(path = %path.as_ref().display()) )] pub async fn remove_dir_all(path: impl AsRef) -> PolyIOResult<()> { - let path = path.as_ref(); + let path = path.as_ref(); - tokio::fs::remove_dir_all(path) - .await - .map_err(|e| IOError::PathIOError { - source: e, - path: path.to_string_lossy().to_string(), - }) + tokio::fs::remove_dir_all(path) + .await + .map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + }) } /// Fails if the directory is non-empty making "remove if empty" atomic with no @@ -96,14 +99,14 @@ pub async fn remove_dir_all(path: impl AsRef) -> PolyIOResult<( fields(path = %path.as_ref().display()) )] pub async fn remove_dir(path: impl AsRef) -> PolyIOResult<()> { - let path = path.as_ref(); + let path = path.as_ref(); - tokio::fs::remove_dir(path) - .await - .map_err(|e| IOError::PathIOError { - source: e, - path: path.to_string_lossy().to_string(), - }) + tokio::fs::remove_dir(path) + .await + .map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + }) } #[tracing::instrument( @@ -114,10 +117,11 @@ pub async fn remove_dir(path: impl AsRef) -> PolyIOResult<()> { pub async fn try_exists(path: impl AsRef) -> PolyIOResult { let path = path.as_ref(); - tokio::fs::try_exists(path).await + tokio::fs::try_exists(path) + .await .map_err(|e| IOError::PathIOError { source: e, - path: path.to_string_lossy().to_string() + path: path.to_string_lossy().to_string(), }) } @@ -127,15 +131,15 @@ pub async fn try_exists(path: impl AsRef) -> PolyIOResult fields(path = %path.as_ref().display()) )] pub async fn read_gz_to_string(path: impl AsRef) -> PolyIOResult { - let mut f = tokio::fs::File::open(path).await?; - let mut buf = vec![]; - tokio::io::AsyncReadExt::read_to_end(&mut f, &mut buf).await?; + let mut f = tokio::fs::File::open(path).await?; + let mut buf = vec![]; + tokio::io::AsyncReadExt::read_to_end(&mut f, &mut buf).await?; - let mut decoder = async_compression::tokio::bufread::GzipDecoder::new(buf.as_slice()); - let mut dst = String::new(); - tokio::io::AsyncReadExt::read_to_string(&mut decoder, &mut dst).await?; + let mut decoder = async_compression::tokio::bufread::GzipDecoder::new(buf.as_slice()); + let mut dst = String::new(); + tokio::io::AsyncReadExt::read_to_string(&mut decoder, &mut dst).await?; - Ok(dst) + Ok(dst) } #[tracing::instrument( @@ -144,14 +148,14 @@ pub async fn read_gz_to_string(path: impl AsRef) -> PolyIOResul fields(path = %path.as_ref().display()) )] pub async fn read_to_string(path: impl AsRef) -> PolyIOResult { - let path = path.as_ref(); + let path = path.as_ref(); - tokio::fs::read_to_string(path) - .await - .map_err(|e| IOError::PathIOError { - source: e, - path: path.to_string_lossy().to_string(), - }) + tokio::fs::read_to_string(path) + .await + .map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + }) } #[tracing::instrument( @@ -160,14 +164,14 @@ pub async fn read_to_string(path: impl AsRef) -> PolyIOResult) -> PolyIOResult> { - let path = path.as_ref(); + let path = path.as_ref(); - tokio::fs::read(path) - .await - .map_err(|e| IOError::PathIOError { - source: e, - path: path.to_string_lossy().to_string(), - }) + tokio::fs::read(path) + .await + .map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + }) } #[tracing::instrument( @@ -175,14 +179,11 @@ pub async fn read(path: impl AsRef) -> PolyIOResult> { skip(path), fields(path = %path.as_ref().display()) )] -pub async fn read_json( - path: impl AsRef, -) -> PolyIOResult { - serde_json::from_slice(&read(&path).await?) - .map_err(|err| IOError::JsonFileParseError { - source: err, - file: path.as_ref().to_path_buf() - }) +pub async fn read_json(path: impl AsRef) -> PolyIOResult { + serde_json::from_slice(&read(&path).await?).map_err(|err| IOError::JsonFileParseError { + source: err, + file: path.as_ref().to_path_buf(), + }) } #[tracing::instrument( @@ -190,18 +191,15 @@ pub async fn read_json( skip(path, data), fields(path = %path.as_ref().display()) )] -pub async fn write( - path: impl AsRef, - data: impl AsRef<[u8]>, -) -> PolyIOResult<()> { - let path = path.as_ref(); +pub async fn write(path: impl AsRef, data: impl AsRef<[u8]>) -> PolyIOResult<()> { + let path = path.as_ref(); - tokio::fs::write(path, data) - .await - .map_err(|e| IOError::PathIOError { - source: e, - path: path.to_string_lossy().to_string(), - }) + tokio::fs::write(path, data) + .await + .map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + }) } #[tracing::instrument( @@ -215,18 +213,20 @@ where F: for<'a> FnOnce(&'a mut tokio::io::BufWriter) -> Fut, Fut: std::future::Future>, { - let path = path.as_ref(); - let file = tokio::fs::File::create(path).await.map_err(IOError::from)?; - let mut writer = tokio::io::BufWriter::new(file); + let path = path.as_ref(); + let file = tokio::fs::File::create(path).await.map_err(IOError::from)?; + let mut writer = tokio::io::BufWriter::new(file); - let write_result = f(&mut writer).await; + let write_result = f(&mut writer).await; - let flush_result = tokio::io::AsyncWriteExt::flush(&mut writer).await.map_err(IOError::from); + let flush_result = tokio::io::AsyncWriteExt::flush(&mut writer) + .await + .map_err(IOError::from); write_result?; flush_result?; - Ok(()) + Ok(()) } /// Streams into a scratch sibling renamed over `path` on success so `path` @@ -289,15 +289,17 @@ where fields(path = %path.as_ref().display()) )] pub async fn write_json( - path: impl AsRef, - data: T, + path: impl AsRef, + data: T, ) -> PolyIOResult<()> { - write(&path, serde_json::to_vec(&data) - .map_err(|err| IOError::JsonFileParseError { + write( + &path, + serde_json::to_vec(&data).map_err(|err| IOError::JsonFileParseError { source: err, - file: path.as_ref().to_path_buf() - })? - ).await + file: path.as_ref().to_path_buf(), + })?, + ) + .await } /// Keeps two concurrent atomic writes to the same path off the same scratch file @@ -306,33 +308,33 @@ static ATOMIC_WRITE_COUNTER: AtomicU64 = AtomicU64::new(0); /// Sibling rather than temp dir `rename` across mount points fails with /// `EXDEV` and `/tmp` is often a separate tmpfs on Linux fn temp_sibling(path: &Path) -> PathBuf { - let n = ATOMIC_WRITE_COUNTER.fetch_add(1, Ordering::Relaxed); - let stem = path - .file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_else(|| "tmp".to_string()); + let n = ATOMIC_WRITE_COUNTER.fetch_add(1, Ordering::Relaxed); + let stem = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "tmp".to_string()); - let name = format!(".{stem}.{}.{n}.tmp", std::process::id()); - match path.parent() { - Some(parent) => parent.join(name), - None => PathBuf::from(name), - } + let name = format!(".{stem}.{}.{n}.tmp", std::process::id()); + match path.parent() { + Some(parent) => parent.join(name), + None => PathBuf::from(name), + } } // Time for a file to be flagged as abandoned const STALE_TEMP_AGE: Duration = Duration::from_secs(15 * 60); fn temp_sibling_pid(name: &str) -> Option { - let inner = name.strip_prefix('.')?.strip_suffix(".tmp")?; - let (head, counter) = inner.rsplit_once('.')?; - let (stem, pid) = head.rsplit_once('.')?; + let inner = name.strip_prefix('.')?.strip_suffix(".tmp")?; + let (head, counter) = inner.rsplit_once('.')?; + let (stem, pid) = head.rsplit_once('.')?; - counter.parse::().ok()?; - if stem.is_empty() { - return None; - } + counter.parse::().ok()?; + if stem.is_empty() { + return None; + } - pid.parse().ok() + pid.parse().ok() } /// Returns the number of bytes reclaimed non-recursive and never an error for a @@ -343,68 +345,68 @@ fn temp_sibling_pid(name: &str) -> Option { fields(dir = %dir.as_ref().display()) )] pub async fn sweep_temp_files(dir: impl AsRef) -> PolyIOResult { - let dir = dir.as_ref(); - let path_err = |source| IOError::PathIOError { - source, - path: dir.to_string_lossy().to_string(), - }; + let dir = dir.as_ref(); + let path_err = |source| IOError::PathIOError { + source, + path: dir.to_string_lossy().to_string(), + }; - let mut entries = match tokio::fs::read_dir(dir).await { - Ok(entries) => entries, - // Nothing has ever been written here so there is nothing to sweep - Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(0), - Err(err) => return Err(path_err(err)), - }; + let mut entries = match tokio::fs::read_dir(dir).await { + Ok(entries) => entries, + // Nothing has ever been written here so there is nothing to sweep + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(0), + Err(err) => return Err(path_err(err)), + }; - let current_pid = std::process::id(); - let mut files = 0usize; - let mut bytes = 0u64; + let current_pid = std::process::id(); + let mut files = 0usize; + let mut bytes = 0u64; - while let Some(entry) = entries.next_entry().await.map_err(path_err)? { - let name = entry.file_name(); - let Some(pid) = name.to_str().and_then(temp_sibling_pid) else { - continue; - }; + while let Some(entry) = entries.next_entry().await.map_err(path_err)? { + let name = entry.file_name(); + let Some(pid) = name.to_str().and_then(temp_sibling_pid) else { + continue; + }; - if pid == current_pid { - continue; - } + if pid == current_pid { + continue; + } - let Ok(metadata) = entry.metadata().await else { - continue; - }; - if !metadata.is_file() { - continue; - } + let Ok(metadata) = entry.metadata().await else { + continue; + }; + if !metadata.is_file() { + continue; + } - let stale = metadata - .modified() - .ok() - .and_then(|modified| modified.elapsed().ok()) - .is_some_and(|age| age >= STALE_TEMP_AGE); - if !stale { - continue; - } + let stale = metadata + .modified() + .ok() + .and_then(|modified| modified.elapsed().ok()) + .is_some_and(|age| age >= STALE_TEMP_AGE); + if !stale { + continue; + } - let size = metadata.len(); - match tokio::fs::remove_file(entry.path()).await { - Ok(()) => { - files += 1; - bytes += size; - } - // Another process may have just renamed it out from under us - Err(err) => tracing::debug!( - path = %entry.path().display(), - "could not remove stale scratch file: {err}" - ), - } - } + let size = metadata.len(); + match tokio::fs::remove_file(entry.path()).await { + Ok(()) => { + files += 1; + bytes += size; + } + // Another process may have just renamed it out from under us + Err(err) => tracing::debug!( + path = %entry.path().display(), + "could not remove stale scratch file: {err}" + ), + } + } - if files > 0 { - tracing::info!(files, bytes, dir = %dir.display(), "removed stale scratch files"); - } + if files > 0 { + tracing::info!(files, bytes, dir = %dir.display(), "removed stale scratch files"); + } - Ok(bytes) + Ok(bytes) } #[tracing::instrument( @@ -412,45 +414,42 @@ pub async fn sweep_temp_files(dir: impl AsRef) -> PolyIOResult { skip(path, data), fields(path = %path.as_ref().display()) )] -pub async fn write_atomic( - path: impl AsRef, - data: impl AsRef<[u8]>, -) -> PolyIOResult<()> { - let path = path.as_ref(); +pub async fn write_atomic(path: impl AsRef, data: impl AsRef<[u8]>) -> PolyIOResult<()> { + let path = path.as_ref(); - if let Some(parent) = path.parent() - && !parent.as_os_str().is_empty() - { - create_dir_all(parent).await?; - } + if let Some(parent) = path.parent() + && !parent.as_os_str().is_empty() + { + create_dir_all(parent).await?; + } - let tmp = temp_sibling(path); - let ctx = |e: std::io::Error| IOError::PathIOError { - source: e, - path: tmp.to_string_lossy().to_string(), - }; + let tmp = temp_sibling(path); + let ctx = |e: std::io::Error| IOError::PathIOError { + source: e, + path: tmp.to_string_lossy().to_string(), + }; - let write = async { - let mut file = tokio::fs::File::create(&tmp).await.map_err(ctx)?; - tokio::io::AsyncWriteExt::write_all(&mut file, data.as_ref()) - .await - .map_err(ctx)?; - file.sync_all().await.map_err(ctx)?; - Ok::<_, IOError>(()) - } - .await; + let write = async { + let mut file = tokio::fs::File::create(&tmp).await.map_err(ctx)?; + tokio::io::AsyncWriteExt::write_all(&mut file, data.as_ref()) + .await + .map_err(ctx)?; + file.sync_all().await.map_err(ctx)?; + Ok::<_, IOError>(()) + } + .await; - if let Err(err) = write { - let _ = tokio::fs::remove_file(&tmp).await; - return Err(err); - } + if let Err(err) = write { + let _ = tokio::fs::remove_file(&tmp).await; + return Err(err); + } - if let Err(err) = rename(&tmp, path).await { - let _ = tokio::fs::remove_file(&tmp).await; - return Err(err); - } + if let Err(err) = rename(&tmp, path).await { + let _ = tokio::fs::remove_file(&tmp).await; + return Err(err); + } - Ok(()) + Ok(()) } #[tracing::instrument( @@ -458,16 +457,13 @@ pub async fn write_atomic( skip(path, data), fields(path = %path.as_ref().display()) )] -pub async fn write_json_atomic( - path: impl AsRef, - data: T, -) -> PolyIOResult<()> { - let bytes = serde_json::to_vec(&data).map_err(|err| IOError::JsonFileWrite { - source: err, - file: path.as_ref().to_path_buf(), - })?; +pub async fn write_json_atomic(path: impl AsRef, data: T) -> PolyIOResult<()> { + let bytes = serde_json::to_vec(&data).map_err(|err| IOError::JsonFileWrite { + source: err, + file: path.as_ref().to_path_buf(), + })?; - write_atomic(path, bytes).await + write_atomic(path, bytes).await } #[tracing::instrument( @@ -476,69 +472,69 @@ pub async fn write_json_atomic( fields(path = %path.as_ref().display()) )] pub fn ensure_under>( - path: impl AsRef, - roots: impl IntoIterator, + path: impl AsRef, + roots: impl IntoIterator, ) -> PolyIOResult> { - let canon = crate::canonicalize(path)?; + let canon = crate::canonicalize(path)?; - for root in roots { - if let Ok(root) = crate::canonicalize(root) - && canon.starts_with(&root) - { - return Ok(Some(canon)); - } - } + for root in roots { + if let Ok(root) = crate::canonicalize(root) + && canon.starts_with(&root) + { + return Ok(Some(canon)); + } + } - Ok(None) + Ok(None) } #[tracing::instrument(level = "debug", skip(exclude_top))] pub async fn copy_dir(src: &Path, dst: &Path, exclude_top: &[&str]) -> PolyIOResult<()> { - let mut stack: Vec<(PathBuf, PathBuf, bool)> = - vec![(src.to_path_buf(), dst.to_path_buf(), true)]; + let mut stack: Vec<(PathBuf, PathBuf, bool)> = + vec![(src.to_path_buf(), dst.to_path_buf(), true)]; - while let Some((cur_src, cur_dst, is_top)) = stack.pop() { - let mut entries = read_dir(&cur_src).await?; - while let Some(entry) = entries.next_entry().await? { - let name = entry.file_name(); + while let Some((cur_src, cur_dst, is_top)) = stack.pop() { + let mut entries = read_dir(&cur_src).await?; + while let Some(entry) = entries.next_entry().await? { + let name = entry.file_name(); - if is_top - && let Some(name_str) = name.to_str() - && exclude_top.iter().any(|e| e.eq_ignore_ascii_case(name_str)) - { - continue; - } + if is_top + && let Some(name_str) = name.to_str() + && exclude_top.iter().any(|e| e.eq_ignore_ascii_case(name_str)) + { + continue; + } - let child_src = entry.path(); - let child_dst = cur_dst.join(&name); - let file_type = entry.file_type().await?; + let child_src = entry.path(); + let child_dst = cur_dst.join(&name); + let file_type = entry.file_type().await?; - if file_type.is_dir() { - create_dir_all(&child_dst).await?; + if file_type.is_dir() { + create_dir_all(&child_dst).await?; - stack.push((child_src, child_dst, false)); - } else if file_type.is_file() { - if let Some(parent) = child_dst.parent() { - create_dir_all(parent).await?; - } + stack.push((child_src, child_dst, false)); + } else if file_type.is_file() { + if let Some(parent) = child_dst.parent() { + create_dir_all(parent).await?; + } - copy(&child_src, &child_dst).await?; - } - } - } + copy(&child_src, &child_dst).await?; + } + } + } - Ok(()) + Ok(()) } pub async fn dir_has_content(dir: &Path) -> bool { - if !dir.is_dir() { - return false; - } + if !dir.is_dir() { + return false; + } - match read_dir(dir).await { - Ok(mut entries) => matches!(entries.next_entry().await, Ok(Some(_))), - Err(_) => false, - } + match read_dir(dir).await { + Ok(mut entries) => matches!(entries.next_entry().await, Ok(Some(_))), + Err(_) => false, + } } /// Replaces `to` if it already exists @@ -551,18 +547,18 @@ pub async fn dir_has_content(dir: &Path) -> bool { ) )] pub async fn rename( - from: impl AsRef, - to: impl AsRef, + from: impl AsRef, + to: impl AsRef, ) -> PolyIOResult<()> { - let from = from.as_ref(); - let to = to.as_ref(); + let from = from.as_ref(); + let to = to.as_ref(); - tokio::fs::rename(from, to) - .await - .map_err(|e| IOError::PathIOError { - source: e, - path: from.to_string_lossy().to_string(), - }) + tokio::fs::rename(from, to) + .await + .map_err(|e| IOError::PathIOError { + source: e, + path: from.to_string_lossy().to_string(), + }) } /// Also copies permission bits and overwrites `to` @@ -575,18 +571,18 @@ pub async fn rename( ) )] pub async fn copy( - from: impl AsRef, - to: impl AsRef, + from: impl AsRef, + to: impl AsRef, ) -> PolyIOResult { - let from = from.as_ref(); - let to = to.as_ref(); + let from = from.as_ref(); + let to = to.as_ref(); - tokio::fs::copy(from, to) - .await - .map_err(|e| IOError::PathIOError { - source: e, - path: from.to_string_lossy().to_string(), - }) + tokio::fs::copy(from, to) + .await + .map_err(|e| IOError::PathIOError { + source: e, + path: from.to_string_lossy().to_string(), + }) } #[tracing::instrument( @@ -595,13 +591,13 @@ pub async fn copy( fields(path = %path.as_ref().display()) )] pub async fn remove_file(path: impl AsRef) -> PolyIOResult<()> { - let path = path.as_ref(); - tokio::fs::remove_file(path) - .await - .map_err(|e| IOError::PathIOError { - source: e, - path: path.to_string_lossy().to_string(), - }) + let path = path.as_ref(); + tokio::fs::remove_file(path) + .await + .map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + }) } /// Unlike [`stat`] returns metadata about the link itself not its target @@ -611,17 +607,17 @@ pub async fn remove_file(path: impl AsRef) -> PolyIOResult<()> fields(path = %path.as_ref().display()) )] pub async fn symlink_metadata(path: impl AsRef) -> PolyIOResult { - let path = path.as_ref(); - tokio::fs::symlink_metadata(path) - .await - .map_err(|e| IOError::PathIOError { - source: e, - path: path.to_string_lossy().to_string(), - }) + let path = path.as_ref(); + tokio::fs::symlink_metadata(path) + .await + .map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + }) } fn resolved_target(original: &std::path::Path) -> PathBuf { - crate::canonicalize(original).unwrap_or_else(|_| original.to_path_buf()) + crate::canonicalize(original).unwrap_or_else(|_| original.to_path_buf()) } /// Symlink on Unix hard link on Windows so both paths must be on one volume @@ -634,22 +630,22 @@ fn resolved_target(original: &std::path::Path) -> PathBuf { ) )] pub async fn symlink_file( - original: impl AsRef, - link: impl AsRef, + original: impl AsRef, + link: impl AsRef, ) -> PolyIOResult<()> { - let original = resolved_target(original.as_ref()); - let original = original.as_path(); - let link = link.as_ref(); + let original = resolved_target(original.as_ref()); + let original = original.as_path(); + let link = link.as_ref(); - #[cfg(windows)] - let res = tokio::fs::hard_link(original, link).await; - #[cfg(not(windows))] - let res = tokio::fs::symlink(original, link).await; + #[cfg(windows)] + let res = tokio::fs::hard_link(original, link).await; + #[cfg(not(windows))] + let res = tokio::fs::symlink(original, link).await; - res.map_err(|e| IOError::PathIOError { - source: e, - path: link.to_string_lossy().to_string(), - }) + res.map_err(|e| IOError::PathIOError { + source: e, + path: link.to_string_lossy().to_string(), + }) } // what a file is on disk rather than what it is called @@ -660,47 +656,47 @@ pub async fn symlink_file( fields(path = %path.as_ref().display()) )] pub async fn file_id(path: impl AsRef) -> PolyIOResult<(u64, u64)> { - use std::os::windows::io::AsRawHandle; + use std::os::windows::io::AsRawHandle; - use windows_sys::Win32::Storage::FileSystem::{ - BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle, - }; + use windows_sys::Win32::Storage::FileSystem::{ + BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle, + }; - let path = path.as_ref().to_path_buf(); - let display = path.to_string_lossy().to_string(); + let path = path.as_ref().to_path_buf(); + let display = path.to_string_lossy().to_string(); - tokio::task::spawn_blocking(move || { - let file = std::fs::File::open(&path)?; - let mut info = BY_HANDLE_FILE_INFORMATION { - dwFileAttributes: 0, - ftCreationTime: unsafe { std::mem::zeroed() }, - ftLastAccessTime: unsafe { std::mem::zeroed() }, - ftLastWriteTime: unsafe { std::mem::zeroed() }, - dwVolumeSerialNumber: 0, - nFileSizeHigh: 0, - nFileSizeLow: 0, - nNumberOfLinks: 0, - nFileIndexHigh: 0, - nFileIndexLow: 0, - }; + tokio::task::spawn_blocking(move || { + let file = std::fs::File::open(&path)?; + let mut info = BY_HANDLE_FILE_INFORMATION { + dwFileAttributes: 0, + ftCreationTime: unsafe { std::mem::zeroed() }, + ftLastAccessTime: unsafe { std::mem::zeroed() }, + ftLastWriteTime: unsafe { std::mem::zeroed() }, + dwVolumeSerialNumber: 0, + nFileSizeHigh: 0, + nFileSizeLow: 0, + nNumberOfLinks: 0, + nFileIndexHigh: 0, + nFileIndexLow: 0, + }; - let filled = unsafe { - GetFileInformationByHandle(file.as_raw_handle().cast(), std::ptr::from_mut(&mut info)) - }; + let filled = unsafe { + GetFileInformationByHandle(file.as_raw_handle().cast(), std::ptr::from_mut(&mut info)) + }; - if filled == 0 { - return Err(std::io::Error::last_os_error()); - } + if filled == 0 { + return Err(std::io::Error::last_os_error()); + } - let index = (u64::from(info.nFileIndexHigh) << 32) | u64::from(info.nFileIndexLow); - Ok((u64::from(info.dwVolumeSerialNumber), index)) - }) - .await - .map_err(std::io::Error::other)? - .map_err(|e| IOError::PathIOError { - source: e, - path: display, - }) + let index = (u64::from(info.nFileIndexHigh) << 32) | u64::from(info.nFileIndexLow); + Ok((u64::from(info.dwVolumeSerialNumber), index)) + }) + .await + .map_err(std::io::Error::other)? + .map_err(|e| IOError::PathIOError { + source: e, + path: display, + }) } #[cfg(not(windows))] @@ -710,10 +706,10 @@ pub async fn file_id(path: impl AsRef) -> PolyIOResult<(u64, u6 fields(path = %path.as_ref().display()) )] pub async fn file_id(path: impl AsRef) -> PolyIOResult<(u64, u64)> { - use std::os::unix::fs::MetadataExt; + use std::os::unix::fs::MetadataExt; - let meta = stat(path).await?; - Ok((meta.dev(), meta.ino())) + let meta = stat(path).await?; + Ok((meta.dev(), meta.ino())) } #[tracing::instrument( @@ -725,31 +721,31 @@ pub async fn file_id(path: impl AsRef) -> PolyIOResult<(u64, u6 ) )] pub async fn symlink_dir( - original: impl AsRef, - link: impl AsRef, + original: impl AsRef, + link: impl AsRef, ) -> PolyIOResult<()> { - let original = resolved_target(original.as_ref()); - let original = original.as_path(); - let link = link.as_ref(); + let original = resolved_target(original.as_ref()); + let original = original.as_path(); + let link = link.as_ref(); - #[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 || junction::create(&original, &link)) - .await - .map_err(std::io::Error::other)? - .map_err(|e| IOError::PathIOError { source: e, path }); - } + #[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 || junction::create(&original, &link)) + .await + .map_err(std::io::Error::other)? + .map_err(|e| IOError::PathIOError { source: e, path }); + } - #[cfg(not(windows))] - tokio::fs::symlink(original, link) - .await - .map_err(|e| IOError::PathIOError { - source: e, - path: link.to_string_lossy().to_string(), - }) + #[cfg(not(windows))] + tokio::fs::symlink(original, link) + .await + .map_err(|e| IOError::PathIOError { + source: e, + path: link.to_string_lossy().to_string(), + }) } #[tracing::instrument( @@ -758,26 +754,26 @@ pub async fn symlink_dir( fields(path = %path.as_ref().display()) )] pub async fn read_link(path: impl AsRef) -> PolyIOResult { - let path = path.as_ref(); + let path = path.as_ref(); - let target = tokio::fs::read_link(path) - .await - .map_err(|e| IOError::PathIOError { - source: e, - path: path.to_string_lossy().to_string(), - })?; + let target = tokio::fs::read_link(path) + .await + .map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + })?; - #[cfg(windows)] - { - let text = target.to_string_lossy().into_owned(); - for prefix in [r"\??\", r"\\?\"] { - if let Some(rest) = text.strip_prefix(prefix) { - return Ok(std::path::PathBuf::from(rest)); - } - } - } + #[cfg(windows)] + { + let text = target.to_string_lossy().into_owned(); + for prefix in [r"\??\", r"\\?\"] { + if let Some(rest) = text.strip_prefix(prefix) { + return Ok(std::path::PathBuf::from(rest)); + } + } + } - Ok(target) + Ok(target) } #[tracing::instrument( @@ -786,28 +782,28 @@ pub async fn read_link(path: impl AsRef) -> PolyIOResult) -> PolyIOResult<()> { - let path = path.as_ref(); + let path = path.as_ref(); - #[cfg(windows)] - let res = tokio::fs::remove_dir(path).await; + #[cfg(windows)] + let res = tokio::fs::remove_dir(path).await; - #[cfg(not(windows))] - let res = tokio::fs::remove_file(path).await; + #[cfg(not(windows))] + let res = tokio::fs::remove_file(path).await; - res.map_err(|e| IOError::PathIOError { - source: e, - path: path.to_string_lossy().to_string(), - }) + res.map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + }) } #[tracing::instrument(level = "debug")] pub async fn tempdir() -> PolyIOResult { - Ok(TempDir::new().await?) + Ok(TempDir::new().await?) } #[tracing::instrument(level = "debug")] pub async fn tempfile() -> PolyIOResult { - Ok(TempFile::new().await?) + Ok(TempFile::new().await?) } /// Sanitises every component and normalises separators to `/` @@ -817,12 +813,12 @@ pub async fn tempfile() -> PolyIOResult { fields(path = %path.as_ref().display()) )] pub fn sanitize_path(path: impl AsRef) -> PathBuf { - path.as_ref() - .to_string_lossy() - .replace('\\', "/") - .split('/') - .map(sanitize_filename::sanitize) - .collect() + path.as_ref() + .to_string_lossy() + .replace('\\', "/") + .split('/') + .map(sanitize_filename::sanitize) + .collect() } #[tracing::instrument( @@ -831,312 +827,327 @@ pub fn sanitize_path(path: impl AsRef) -> PathBuf { fields(path = %path.as_ref().display()) )] pub async fn stat(path: impl AsRef) -> PolyIOResult { - tokio::fs::metadata(path).await.map_err(IOError::from) + tokio::fs::metadata(path).await.map_err(IOError::from) } #[cfg(test)] mod tests { - use super::*; - - #[test] - fn write_buffer_fits_the_file() { - assert_eq!(write_buffer_size(Some(10_217)), MIN_WRITE_BUFFER); - assert_eq!(write_buffer_size(Some(1)), MIN_WRITE_BUFFER); - assert_eq!(write_buffer_size(Some(64 * 1024)), 64 * 1024); - assert_eq!(write_buffer_size(Some(25_000_000)), MAX_WRITE_BUFFER); - assert_eq!(write_buffer_size(Some(u64::MAX)), MAX_WRITE_BUFFER); - } - - #[test] - fn write_buffer_falls_back_without_a_length() { - assert_eq!(write_buffer_size(None), DEFAULT_WRITE_BUFFER); - assert_eq!(write_buffer_size(Some(0)), DEFAULT_WRITE_BUFFER); - } - - #[cfg(unix)] - #[tokio::test] - async fn a_link_target_is_stored_resolved() { - let root = scratch("resolved-target"); - let real = root.join("var").join("home"); - std::fs::create_dir_all(&real).unwrap(); - std::os::unix::fs::symlink(&real, root.join("home")).unwrap(); - - let pack = real.join("pack.zip"); - std::fs::write(&pack, b"pack").unwrap(); - - let through_link = root.join("home").join("pack.zip"); - let link = root.join("resourcepacks.zip"); - - symlink_file(&through_link, &link).await.unwrap(); - - assert_eq!( - read_link(&link).await.unwrap(), - crate::canonicalize(&pack).unwrap(), - "the stored target must be the resolved path, not the spelling we were given" - ); - - std::fs::remove_dir_all(&root).ok(); - } - - #[cfg(unix)] - #[tokio::test] - async fn a_missing_target_is_stored_as_given() { - let root = scratch("unresolved-target"); - let missing = root.join("not-here.zip"); - let link = root.join("link.zip"); - - symlink_file(&missing, &link).await.unwrap(); - - assert_eq!(read_link(&link).await.unwrap(), missing); - - std::fs::remove_dir_all(&root).ok(); - } - - fn scratch(tag: &str) -> PathBuf { - static N: AtomicU64 = AtomicU64::new(0); - let dir = std::env::temp_dir().join(format!( - "polyio-{tag}-{}-{}", - std::process::id(), - N.fetch_add(1, Ordering::Relaxed) - )); - std::fs::create_dir_all(&dir).unwrap(); - dir - } - - #[tokio::test] - async fn write_atomic_creates_missing_parents() { - let dir = scratch("atomic-parents"); - let target = dir.join("a").join("b").join("settings.json"); - - write_atomic(&target, b"{}").await.unwrap(); - - assert_eq!(std::fs::read(&target).unwrap(), b"{}"); - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[tokio::test] - async fn write_atomic_replaces_and_leaves_no_scratch_files() { - let dir = scratch("atomic-replace"); - let target = dir.join("settings.json"); - - write_atomic(&target, b"old").await.unwrap(); - write_atomic(&target, b"new-and-longer").await.unwrap(); - - assert_eq!(std::fs::read(&target).unwrap(), b"new-and-longer"); - - let leftovers: Vec<_> = std::fs::read_dir(&dir) - .unwrap() - .flatten() - .map(|e| e.file_name().to_string_lossy().to_string()) - .filter(|n| n != "settings.json") - .collect(); - assert!(leftovers.is_empty(), "left scratch files behind: {leftovers:?}"); - - std::fs::remove_dir_all(&dir).unwrap(); - } - - /// Ages a file past [`STALE_TEMP_AGE`] so the sweep treats it as abandoned - fn backdate(path: &Path) { - let stale = std::time::SystemTime::now() - (STALE_TEMP_AGE + Duration::from_secs(60)); - let file = std::fs::File::options().write(true).open(path).unwrap(); - file.set_times(std::fs::FileTimes::new().set_modified(stale)) - .unwrap(); - } - - #[test] - fn temp_sibling_names_round_trip() { - let name = temp_sibling(Path::new("/java/zulu21.zip")); - let name = name.file_name().unwrap().to_str().unwrap(); - - assert_eq!(temp_sibling_pid(name), Some(std::process::id())); - - // Files polyio did not write - assert_eq!(temp_sibling_pid("zulu21.zip"), None); - assert_eq!(temp_sibling_pid(".vimrc.tmp"), None); - assert_eq!(temp_sibling_pid(".cache.notapid.7.tmp"), None); - assert_eq!(temp_sibling_pid(".cache.4242.notacounter.tmp"), None); - } - - #[tokio::test] - async fn sweep_removes_only_abandoned_scratch_files() { - let dir = scratch("sweep"); - - let abandoned = dir.join(format!(".zulu21.zip.{}.0.tmp", std::process::id() + 1)); - std::fs::write(&abandoned, b"half a runtime").unwrap(); - backdate(&abandoned); - - // Same shape but young enough to still have a writer behind it - let in_flight = dir.join(format!(".zulu17.zip.{}.0.tmp", std::process::id() + 2)); - std::fs::write(&in_flight, b"downloading").unwrap(); - - // Ours, however old the launcher has been up - let ours = temp_sibling(&dir.join("zulu8.zip")); - std::fs::write(&ours, b"mine").unwrap(); - backdate(&ours); - - // Not a scratch file at all - let keep = dir.join("zulu21.zip"); - std::fs::write(&keep, b"a real archive").unwrap(); - backdate(&keep); - - let freed = sweep_temp_files(&dir).await.unwrap(); - - assert_eq!(freed, "half a runtime".len() as u64); - assert!(!abandoned.exists()); - assert!(in_flight.exists()); - assert!(ours.exists()); - assert!(keep.exists()); - - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[tokio::test] - async fn sweep_ignores_a_missing_directory() { - let dir = scratch("sweep-missing"); - std::fs::remove_dir_all(&dir).unwrap(); - - assert_eq!(sweep_temp_files(&dir).await.unwrap(), 0); - } - - fn failing_stream( - chunks: Vec<&'static [u8]>, - ) -> impl futures_lite::Stream> + Unpin + Send { - let items = chunks - .into_iter() - .map(|c| Ok(bytes::Bytes::from_static(c))) - .chain(std::iter::once(Err(IOError::IOError( - std::io::Error::from(std::io::ErrorKind::ConnectionReset), - )))); - - Box::pin(futures_lite::stream::iter(items)) - } - - #[tokio::test] - async fn write_stream_publishes_only_a_complete_file() { - let dir = scratch("stream-ok"); - let target = dir.join("object.bin"); - - let chunks = vec![ - Ok(bytes::Bytes::from_static(b"hello ")), - Ok(bytes::Bytes::from_static(b"world")), - ]; - let stream = Box::pin(futures_lite::stream::iter(chunks)); - - write_stream::<_, IOError>(&target, stream, Some(11)) - .await - .unwrap(); - - assert_eq!(std::fs::read(&target).unwrap(), b"hello world"); - - let leftovers: Vec<_> = std::fs::read_dir(&dir) - .unwrap() - .flatten() - .map(|e| e.file_name().to_string_lossy().to_string()) - .filter(|n| n != "object.bin") - .collect(); - assert!(leftovers.is_empty(), "left scratch files behind: {leftovers:?}"); - - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[tokio::test] - async fn write_stream_leaves_nothing_when_the_stream_dies() { - let dir = scratch("stream-drop"); - let target = dir.join("object.bin"); - - write_stream::<_, IOError>(&target, failing_stream(vec![b"partial"]), Some(64)) - .await - .expect_err("a dropped stream must fail"); - - assert!(!target.exists(), "left a truncated file at the destination"); - - let leftovers: Vec<_> = std::fs::read_dir(&dir) - .unwrap() - .flatten() - .map(|e| e.file_name().to_string_lossy().to_string()) - .collect(); - assert!(leftovers.is_empty(), "left scratch files behind: {leftovers:?}"); - - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[tokio::test] - async fn write_stream_keeps_the_old_file_when_a_rewrite_fails() { - let dir = scratch("stream-keep"); - let target = dir.join("object.bin"); - std::fs::write(&target, b"known-good").unwrap(); - - write_stream::<_, IOError>(&target, failing_stream(vec![b"junk"]), Some(64)) - .await - .expect_err("a dropped stream must fail"); - - assert_eq!(std::fs::read(&target).unwrap(), b"known-good"); - - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[test] - fn ensure_under_accepts_a_path_inside_a_root() { - let dir = scratch("under-inside"); - let nested = dir.join("clusters").join("logs"); - std::fs::create_dir_all(&nested).unwrap(); - let file = nested.join("latest.log"); - std::fs::write(&file, b"").unwrap(); - - let resolved = ensure_under(&file, [&dir]).unwrap(); - assert!(resolved.is_some()); - - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[test] - fn ensure_under_rejects_a_traversal_out_of_every_root() { - let dir = scratch("under-escape"); - let root = dir.join("clusters"); - let outside = dir.join("secrets"); - std::fs::create_dir_all(&root).unwrap(); - std::fs::create_dir_all(&outside).unwrap(); - let file = outside.join("auth.json"); - std::fs::write(&file, b"").unwrap(); - - let sneaky = root.join("..").join("secrets").join("auth.json"); - assert_eq!(ensure_under(&sneaky, [&root]).unwrap(), None); - - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[test] - fn ensure_under_skips_roots_that_do_not_exist() { - let dir = scratch("under-missing-root"); - let file = dir.join("a.log"); - std::fs::write(&file, b"").unwrap(); - - let missing = dir.join("not-created-yet"); - let resolved = ensure_under(&file, [&missing, &dir]).unwrap(); - assert!(resolved.is_some(), "a missing root must not shadow a real one"); - - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[tokio::test] - async fn copy_dir_excludes_only_at_the_top_level() { - let dir = scratch("copy-dir"); - let src = dir.join("src"); - let dst = dir.join("dst"); - std::fs::create_dir_all(src.join("mods")).unwrap(); - std::fs::create_dir_all(src.join("keep").join("mods")).unwrap(); - std::fs::write(src.join("mods").join("top.jar"), b"top").unwrap(); - std::fs::write(src.join("keep").join("mods").join("nested.jar"), b"nested").unwrap(); - std::fs::write(src.join("options.txt"), b"opts").unwrap(); - - copy_dir(&src, &dst, &["mods"]).await.unwrap(); - - assert!(!dst.join("mods").exists(), "top-level `mods` should be excluded"); - assert!(dst.join("options.txt").exists()); - assert!( - dst.join("keep").join("mods").join("nested.jar").exists(), - "exclusion must not apply below the top level" - ); - - std::fs::remove_dir_all(&dir).unwrap(); - } + use super::*; + + #[test] + fn write_buffer_fits_the_file() { + assert_eq!(write_buffer_size(Some(10_217)), MIN_WRITE_BUFFER); + assert_eq!(write_buffer_size(Some(1)), MIN_WRITE_BUFFER); + assert_eq!(write_buffer_size(Some(64 * 1024)), 64 * 1024); + assert_eq!(write_buffer_size(Some(25_000_000)), MAX_WRITE_BUFFER); + assert_eq!(write_buffer_size(Some(u64::MAX)), MAX_WRITE_BUFFER); + } + + #[test] + fn write_buffer_falls_back_without_a_length() { + assert_eq!(write_buffer_size(None), DEFAULT_WRITE_BUFFER); + assert_eq!(write_buffer_size(Some(0)), DEFAULT_WRITE_BUFFER); + } + + #[cfg(unix)] + #[tokio::test] + async fn a_link_target_is_stored_resolved() { + let root = scratch("resolved-target"); + let real = root.join("var").join("home"); + std::fs::create_dir_all(&real).unwrap(); + std::os::unix::fs::symlink(&real, root.join("home")).unwrap(); + + let pack = real.join("pack.zip"); + std::fs::write(&pack, b"pack").unwrap(); + + let through_link = root.join("home").join("pack.zip"); + let link = root.join("resourcepacks.zip"); + + symlink_file(&through_link, &link).await.unwrap(); + + assert_eq!( + read_link(&link).await.unwrap(), + crate::canonicalize(&pack).unwrap(), + "the stored target must be the resolved path, not the spelling we were given" + ); + + std::fs::remove_dir_all(&root).ok(); + } + + #[cfg(unix)] + #[tokio::test] + async fn a_missing_target_is_stored_as_given() { + let root = scratch("unresolved-target"); + let missing = root.join("not-here.zip"); + let link = root.join("link.zip"); + + symlink_file(&missing, &link).await.unwrap(); + + assert_eq!(read_link(&link).await.unwrap(), missing); + + std::fs::remove_dir_all(&root).ok(); + } + + fn scratch(tag: &str) -> PathBuf { + static N: AtomicU64 = AtomicU64::new(0); + let dir = std::env::temp_dir().join(format!( + "polyio-{tag}-{}-{}", + std::process::id(), + N.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[tokio::test] + async fn write_atomic_creates_missing_parents() { + let dir = scratch("atomic-parents"); + let target = dir.join("a").join("b").join("settings.json"); + + write_atomic(&target, b"{}").await.unwrap(); + + assert_eq!(std::fs::read(&target).unwrap(), b"{}"); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[tokio::test] + async fn write_atomic_replaces_and_leaves_no_scratch_files() { + let dir = scratch("atomic-replace"); + let target = dir.join("settings.json"); + + write_atomic(&target, b"old").await.unwrap(); + write_atomic(&target, b"new-and-longer").await.unwrap(); + + assert_eq!(std::fs::read(&target).unwrap(), b"new-and-longer"); + + let leftovers: Vec<_> = std::fs::read_dir(&dir) + .unwrap() + .flatten() + .map(|e| e.file_name().to_string_lossy().to_string()) + .filter(|n| n != "settings.json") + .collect(); + assert!( + leftovers.is_empty(), + "left scratch files behind: {leftovers:?}" + ); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + /// Ages a file past [`STALE_TEMP_AGE`] so the sweep treats it as abandoned + fn backdate(path: &Path) { + let stale = std::time::SystemTime::now() - (STALE_TEMP_AGE + Duration::from_secs(60)); + let file = std::fs::File::options().write(true).open(path).unwrap(); + file.set_times(std::fs::FileTimes::new().set_modified(stale)) + .unwrap(); + } + + #[test] + fn temp_sibling_names_round_trip() { + let name = temp_sibling(Path::new("/java/zulu21.zip")); + let name = name.file_name().unwrap().to_str().unwrap(); + + assert_eq!(temp_sibling_pid(name), Some(std::process::id())); + + // Files polyio did not write + assert_eq!(temp_sibling_pid("zulu21.zip"), None); + assert_eq!(temp_sibling_pid(".vimrc.tmp"), None); + assert_eq!(temp_sibling_pid(".cache.notapid.7.tmp"), None); + assert_eq!(temp_sibling_pid(".cache.4242.notacounter.tmp"), None); + } + + #[tokio::test] + async fn sweep_removes_only_abandoned_scratch_files() { + let dir = scratch("sweep"); + + let abandoned = dir.join(format!(".zulu21.zip.{}.0.tmp", std::process::id() + 1)); + std::fs::write(&abandoned, b"half a runtime").unwrap(); + backdate(&abandoned); + + // Same shape but young enough to still have a writer behind it + let in_flight = dir.join(format!(".zulu17.zip.{}.0.tmp", std::process::id() + 2)); + std::fs::write(&in_flight, b"downloading").unwrap(); + + // Ours, however old the launcher has been up + let ours = temp_sibling(&dir.join("zulu8.zip")); + std::fs::write(&ours, b"mine").unwrap(); + backdate(&ours); + + // Not a scratch file at all + let keep = dir.join("zulu21.zip"); + std::fs::write(&keep, b"a real archive").unwrap(); + backdate(&keep); + + let freed = sweep_temp_files(&dir).await.unwrap(); + + assert_eq!(freed, "half a runtime".len() as u64); + assert!(!abandoned.exists()); + assert!(in_flight.exists()); + assert!(ours.exists()); + assert!(keep.exists()); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[tokio::test] + async fn sweep_ignores_a_missing_directory() { + let dir = scratch("sweep-missing"); + std::fs::remove_dir_all(&dir).unwrap(); + + assert_eq!(sweep_temp_files(&dir).await.unwrap(), 0); + } + + fn failing_stream( + chunks: Vec<&'static [u8]>, + ) -> impl futures_lite::Stream> + Unpin + Send { + let items = chunks + .into_iter() + .map(|c| Ok(bytes::Bytes::from_static(c))) + .chain(std::iter::once(Err(IOError::IOError( + std::io::Error::from(std::io::ErrorKind::ConnectionReset), + )))); + + Box::pin(futures_lite::stream::iter(items)) + } + + #[tokio::test] + async fn write_stream_publishes_only_a_complete_file() { + let dir = scratch("stream-ok"); + let target = dir.join("object.bin"); + + let chunks = vec![ + Ok(bytes::Bytes::from_static(b"hello ")), + Ok(bytes::Bytes::from_static(b"world")), + ]; + let stream = Box::pin(futures_lite::stream::iter(chunks)); + + write_stream::<_, IOError>(&target, stream, Some(11)) + .await + .unwrap(); + + assert_eq!(std::fs::read(&target).unwrap(), b"hello world"); + + let leftovers: Vec<_> = std::fs::read_dir(&dir) + .unwrap() + .flatten() + .map(|e| e.file_name().to_string_lossy().to_string()) + .filter(|n| n != "object.bin") + .collect(); + assert!( + leftovers.is_empty(), + "left scratch files behind: {leftovers:?}" + ); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[tokio::test] + async fn write_stream_leaves_nothing_when_the_stream_dies() { + let dir = scratch("stream-drop"); + let target = dir.join("object.bin"); + + write_stream::<_, IOError>(&target, failing_stream(vec![b"partial"]), Some(64)) + .await + .expect_err("a dropped stream must fail"); + + assert!(!target.exists(), "left a truncated file at the destination"); + + let leftovers: Vec<_> = std::fs::read_dir(&dir) + .unwrap() + .flatten() + .map(|e| e.file_name().to_string_lossy().to_string()) + .collect(); + assert!( + leftovers.is_empty(), + "left scratch files behind: {leftovers:?}" + ); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[tokio::test] + async fn write_stream_keeps_the_old_file_when_a_rewrite_fails() { + let dir = scratch("stream-keep"); + let target = dir.join("object.bin"); + std::fs::write(&target, b"known-good").unwrap(); + + write_stream::<_, IOError>(&target, failing_stream(vec![b"junk"]), Some(64)) + .await + .expect_err("a dropped stream must fail"); + + assert_eq!(std::fs::read(&target).unwrap(), b"known-good"); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn ensure_under_accepts_a_path_inside_a_root() { + let dir = scratch("under-inside"); + let nested = dir.join("clusters").join("logs"); + std::fs::create_dir_all(&nested).unwrap(); + let file = nested.join("latest.log"); + std::fs::write(&file, b"").unwrap(); + + let resolved = ensure_under(&file, [&dir]).unwrap(); + assert!(resolved.is_some()); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn ensure_under_rejects_a_traversal_out_of_every_root() { + let dir = scratch("under-escape"); + let root = dir.join("clusters"); + let outside = dir.join("secrets"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + let file = outside.join("auth.json"); + std::fs::write(&file, b"").unwrap(); + + let sneaky = root.join("..").join("secrets").join("auth.json"); + assert_eq!(ensure_under(&sneaky, [&root]).unwrap(), None); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn ensure_under_skips_roots_that_do_not_exist() { + let dir = scratch("under-missing-root"); + let file = dir.join("a.log"); + std::fs::write(&file, b"").unwrap(); + + let missing = dir.join("not-created-yet"); + let resolved = ensure_under(&file, [&missing, &dir]).unwrap(); + assert!( + resolved.is_some(), + "a missing root must not shadow a real one" + ); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[tokio::test] + async fn copy_dir_excludes_only_at_the_top_level() { + let dir = scratch("copy-dir"); + let src = dir.join("src"); + let dst = dir.join("dst"); + std::fs::create_dir_all(src.join("mods")).unwrap(); + std::fs::create_dir_all(src.join("keep").join("mods")).unwrap(); + std::fs::write(src.join("mods").join("top.jar"), b"top").unwrap(); + std::fs::write(src.join("keep").join("mods").join("nested.jar"), b"nested").unwrap(); + std::fs::write(src.join("options.txt"), b"opts").unwrap(); + + copy_dir(&src, &dst, &["mods"]).await.unwrap(); + + assert!( + !dst.join("mods").exists(), + "top-level `mods` should be excluded" + ); + assert!(dst.join("options.txt").exists()); + assert!( + dst.join("keep").join("mods").join("nested.jar").exists(), + "exclusion must not apply below the top level" + ); + + std::fs::remove_dir_all(&dir).unwrap(); + } } diff --git a/packages/polyio/src/hash.rs b/packages/polyio/src/hash.rs index 3f50f8c3..e596d554 100644 --- a/packages/polyio/src/hash.rs +++ b/packages/polyio/src/hash.rs @@ -12,246 +12,241 @@ const MAX_HASH_BUFFER: usize = 256 * 1024; pub struct Sha1Stream(sha1::Sha1); impl Sha1Stream { - #[must_use] - pub fn new() -> Self { - Self(sha1::Sha1::new()) - } + #[must_use] + pub fn new() -> Self { + Self(sha1::Sha1::new()) + } - pub fn update(&mut self, data: &[u8]) { - Digest::update(&mut self.0, data); - } + pub fn update(&mut self, data: &[u8]) { + Digest::update(&mut self.0, data); + } - #[must_use] - pub fn finish(self) -> String { - to_hex(&self.0.finalize()) - } + #[must_use] + pub fn finish(self) -> String { + to_hex(&self.0.finalize()) + } } impl Default for Sha1Stream { - fn default() -> Self { - Self::new() - } + fn default() -> Self { + Self::new() + } } /// Runs on the blocking pool one dispatch for the whole file keeping the /// digest off the async workers driving concurrent downloads pub async fn sha1_file(path: impl AsRef) -> PolyIOResult { - let path = path.as_ref().to_path_buf(); - tokio::task::spawn_blocking(move || sha1_file_sync(&path)) - .await - .map_err(std::io::Error::other)? + let path = path.as_ref().to_path_buf(); + tokio::task::spawn_blocking(move || sha1_file_sync(&path)) + .await + .map_err(std::io::Error::other)? } pub fn sha1_file_sync(path: &Path) -> PolyIOResult { - let mut file = std::fs::File::open(path).map_err(|e| IOError::PathIOError { - source: e, - path: path.to_string_lossy().to_string(), - })?; - let mut hasher = Sha1Stream::new(); + let mut file = std::fs::File::open(path).map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + })?; + let mut hasher = Sha1Stream::new(); - let size = file - .metadata() - .map(|meta| meta.len()) - .unwrap_or(MAX_HASH_BUFFER as u64); - let capacity = (size.max(1).min(MAX_HASH_BUFFER as u64)) as usize; - let mut buffer = vec![0u8; capacity]; + let size = file + .metadata() + .map(|meta| meta.len()) + .unwrap_or(MAX_HASH_BUFFER as u64); + let capacity = (size.max(1).min(MAX_HASH_BUFFER as u64)) as usize; + let mut buffer = vec![0u8; capacity]; - loop { - let n = file.read(&mut buffer).map_err(|e| IOError::PathIOError { - source: e, - path: path.to_string_lossy().to_string(), - })?; - if n == 0 { - break; - } - hasher.update(&buffer[..n]); - } + loop { + let n = file.read(&mut buffer).map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + })?; + if n == 0 { + break; + } + hasher.update(&buffer[..n]); + } - Ok(hasher.finish()) + Ok(hasher.finish()) } #[must_use] pub fn sha1_bytes(data: &[u8]) -> String { - let mut hasher = Sha1Stream::new(); - hasher.update(data); - hasher.finish() + let mut hasher = Sha1Stream::new(); + hasher.update(data); + hasher.finish() } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum ChecksumAlgorithm { - Sha1, - Sha256, + Sha1, + Sha256, } impl ChecksumAlgorithm { - /// Hex length which is how a bare manifest string with no algorithm field - /// gets identified - #[must_use] - pub const fn hex_len(self) -> usize { - match self { - Self::Sha1 => 40, - Self::Sha256 => 64, - } - } + /// Hex length which is how a bare manifest string with no algorithm field + /// gets identified + #[must_use] + pub const fn hex_len(self) -> usize { + match self { + Self::Sha1 => 40, + Self::Sha256 => 64, + } + } - #[must_use] - pub const fn name(self) -> &'static str { - match self { - Self::Sha1 => "SHA-1", - Self::Sha256 => "SHA-256", - } - } + #[must_use] + pub const fn name(self) -> &'static str { + match self { + Self::Sha1 => "SHA-1", + Self::Sha256 => "SHA-256", + } + } } #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct Checksum { - pub algorithm: ChecksumAlgorithm, - /// Always normalized (trimmed lowercase) - pub hex: String, + pub algorithm: ChecksumAlgorithm, + /// Always normalized (trimmed lowercase) + pub hex: String, } impl Checksum { - #[must_use] - pub fn new(algorithm: ChecksumAlgorithm, hex: impl AsRef) -> Self { - Self { - algorithm, - hex: normalize_hash(hex.as_ref()), - } - } + #[must_use] + pub fn new(algorithm: ChecksumAlgorithm, hex: impl AsRef) -> Self { + Self { + algorithm, + hex: normalize_hash(hex.as_ref()), + } + } - #[must_use] - pub fn sha1(hex: impl AsRef) -> Self { - Self::new(ChecksumAlgorithm::Sha1, hex) - } + #[must_use] + pub fn sha1(hex: impl AsRef) -> Self { + Self::new(ChecksumAlgorithm::Sha1, hex) + } - #[must_use] - pub fn sha256(hex: impl AsRef) -> Self { - Self::new(ChecksumAlgorithm::Sha256, hex) - } + #[must_use] + pub fn sha256(hex: impl AsRef) -> Self { + Self::new(ChecksumAlgorithm::Sha256, hex) + } - /// Whether the hash is the right shape for its algorithm - /// Callers drop malformed checksums and download unverified - #[must_use] - pub fn is_well_formed(&self) -> bool { - self.hex.len() == self.algorithm.hex_len() - && self.hex.bytes().all(|b| b.is_ascii_hexdigit()) - } + /// Whether the hash is the right shape for its algorithm + /// Callers drop malformed checksums and download unverified + #[must_use] + pub fn is_well_formed(&self) -> bool { + self.hex.len() == self.algorithm.hex_len() + && self.hex.bytes().all(|b| b.is_ascii_hexdigit()) + } - #[must_use] - pub fn matches(&self, actual: &str) -> bool { - self.hex == normalize_hash(actual) - } + #[must_use] + pub fn matches(&self, actual: &str) -> bool { + self.hex == normalize_hash(actual) + } } pub enum ChecksumStream { - Sha1(sha1::Sha1), - Sha256(sha2::Sha256), + Sha1(sha1::Sha1), + Sha256(sha2::Sha256), } impl ChecksumStream { - #[must_use] - pub fn new(algorithm: ChecksumAlgorithm) -> Self { - match algorithm { - ChecksumAlgorithm::Sha1 => Self::Sha1(sha1::Sha1::new()), - ChecksumAlgorithm::Sha256 => Self::Sha256(sha2::Sha256::new()), - } - } + #[must_use] + pub fn new(algorithm: ChecksumAlgorithm) -> Self { + match algorithm { + ChecksumAlgorithm::Sha1 => Self::Sha1(sha1::Sha1::new()), + ChecksumAlgorithm::Sha256 => Self::Sha256(sha2::Sha256::new()), + } + } - pub fn update(&mut self, data: &[u8]) { - match self { - Self::Sha1(hasher) => Digest::update(hasher, data), - Self::Sha256(hasher) => Digest::update(hasher, data), - } - } + pub fn update(&mut self, data: &[u8]) { + match self { + Self::Sha1(hasher) => Digest::update(hasher, data), + Self::Sha256(hasher) => Digest::update(hasher, data), + } + } - #[must_use] - pub fn finish(self) -> String { - match self { - Self::Sha1(hasher) => to_hex(&hasher.finalize()), - Self::Sha256(hasher) => to_hex(&hasher.finalize()), - } - } + #[must_use] + pub fn finish(self) -> String { + match self { + Self::Sha1(hasher) => to_hex(&hasher.finalize()), + Self::Sha256(hasher) => to_hex(&hasher.finalize()), + } + } } #[must_use] pub fn normalize_hash(hash: &str) -> String { - hash.trim().to_ascii_lowercase() + hash.trim().to_ascii_lowercase() } #[must_use] pub fn to_hex(data: &[u8]) -> String { - data.iter().fold(String::new(), |mut out, b| { - let _ = write!(out, "{b:02x}"); - out - }) + data.iter().fold(String::new(), |mut out, b| { + let _ = write!(out, "{b:02x}"); + out + }) } #[cfg(test)] mod tests { - use super::*; + use super::*; - #[test] - fn sha1_bytes_matches_known_vector() { - assert_eq!( - sha1_bytes(b"abc"), - "a9993e364706816aba3e25717850c26c9cd0d89d" - ); - assert_eq!( - sha1_bytes(b""), - "da39a3ee5e6b4b0d3255bfef95601890afd80709" - ); - } + #[test] + fn sha1_bytes_matches_known_vector() { + assert_eq!( + sha1_bytes(b"abc"), + "a9993e364706816aba3e25717850c26c9cd0d89d" + ); + assert_eq!(sha1_bytes(b""), "da39a3ee5e6b4b0d3255bfef95601890afd80709"); + } - #[test] - fn stream_and_oneshot_agree() { - let mut stream = Sha1Stream::new(); - stream.update(b"ab"); - stream.update(b"c"); - assert_eq!(stream.finish(), sha1_bytes(b"abc")); - } + #[test] + fn stream_and_oneshot_agree() { + let mut stream = Sha1Stream::new(); + stream.update(b"ab"); + stream.update(b"c"); + assert_eq!(stream.finish(), sha1_bytes(b"abc")); + } - #[test] - fn normalize_hash_trims_and_lowercases() { - assert_eq!(normalize_hash(" A9B2 "), "a9b2"); - } + #[test] + fn normalize_hash_trims_and_lowercases() { + assert_eq!(normalize_hash(" A9B2 "), "a9b2"); + } - #[test] - fn checksum_streams_match_their_one_shot_digests() { - let mut sha1 = ChecksumStream::new(ChecksumAlgorithm::Sha1); - sha1.update(b"ab"); - sha1.update(b"c"); - assert_eq!(sha1.finish(), "a9993e364706816aba3e25717850c26c9cd0d89d"); + #[test] + fn checksum_streams_match_their_one_shot_digests() { + let mut sha1 = ChecksumStream::new(ChecksumAlgorithm::Sha1); + sha1.update(b"ab"); + sha1.update(b"c"); + assert_eq!(sha1.finish(), "a9993e364706816aba3e25717850c26c9cd0d89d"); - let mut sha256 = ChecksumStream::new(ChecksumAlgorithm::Sha256); - sha256.update(b"ab"); - sha256.update(b"c"); - assert_eq!( - sha256.finish(), - "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" - ); - } + let mut sha256 = ChecksumStream::new(ChecksumAlgorithm::Sha256); + sha256.update(b"ab"); + sha256.update(b"c"); + assert_eq!( + sha256.finish(), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + } - #[test] - fn checksum_comparison_ignores_case_and_padding() { - let expected = Checksum::sha1(" A9993E364706816ABA3E25717850C26C9CD0D89D "); - assert!(expected.matches("a9993e364706816aba3e25717850c26c9cd0d89d")); - assert!(expected.matches("A9993E364706816ABA3E25717850C26C9CD0D89D")); - assert!(!expected.matches("da39a3ee5e6b4b0d3255bfef95601890afd80709")); - } + #[test] + fn checksum_comparison_ignores_case_and_padding() { + let expected = Checksum::sha1(" A9993E364706816ABA3E25717850C26C9CD0D89D "); + assert!(expected.matches("a9993e364706816aba3e25717850c26c9cd0d89d")); + assert!(expected.matches("A9993E364706816ABA3E25717850C26C9CD0D89D")); + assert!(!expected.matches("da39a3ee5e6b4b0d3255bfef95601890afd80709")); + } - #[test] - fn a_hash_of_the_wrong_shape_is_not_well_formed() { - assert!(!Checksum::sha256("").is_well_formed()); - assert!(!Checksum::sha256("n/a").is_well_formed()); - assert!(!Checksum::sha256("a9993e364706816aba3e25717850c26c9cd0d89d").is_well_formed()); - assert!(!Checksum::sha1("zzz3e364706816aba3e25717850c26c9cd0d89d!").is_well_formed()); + #[test] + fn a_hash_of_the_wrong_shape_is_not_well_formed() { + assert!(!Checksum::sha256("").is_well_formed()); + assert!(!Checksum::sha256("n/a").is_well_formed()); + assert!(!Checksum::sha256("a9993e364706816aba3e25717850c26c9cd0d89d").is_well_formed()); + assert!(!Checksum::sha1("zzz3e364706816aba3e25717850c26c9cd0d89d!").is_well_formed()); - assert!(Checksum::sha1("a9993e364706816aba3e25717850c26c9cd0d89d").is_well_formed()); - assert!( - Checksum::sha256( - "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" - ) - .is_well_formed() - ); - } + assert!(Checksum::sha1("a9993e364706816aba3e25717850c26c9cd0d89d").is_well_formed()); + assert!( + Checksum::sha256("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad") + .is_well_formed() + ); + } } diff --git a/packages/polyio/src/lib.rs b/packages/polyio/src/lib.rs index f693d8f9..00b33596 100644 --- a/packages/polyio/src/lib.rs +++ b/packages/polyio/src/lib.rs @@ -1,15 +1,15 @@ -mod error; -mod system; -mod file; mod archive; +mod error; +mod file; mod hash; +mod system; #[cfg(feature = "testing")] pub mod testing; -pub(crate) use error::PolyIOResult; -pub use error::IOError; -pub use file::*; -pub use system::*; pub use archive::*; +pub use error::IOError; +pub(crate) use error::PolyIOResult; +pub use file::*; pub use hash::*; +pub use system::*; diff --git a/packages/polyio/src/system.rs b/packages/polyio/src/system.rs index 0d1566f8..61fc8fbd 100644 --- a/packages/polyio/src/system.rs +++ b/packages/polyio/src/system.rs @@ -3,16 +3,15 @@ use std::path::PathBuf; use crate::{IOError, PolyIOResult}; pub fn env_path(name: &str) -> Option { - std::env::var_os(name).map(PathBuf::from) + std::env::var_os(name).map(PathBuf::from) } /// Like [`std::fs::canonicalize`] but on Windows emits the most compatible /// path form instead of UNC pub fn canonicalize(path: impl AsRef) -> PolyIOResult { - let path = path.as_ref(); - dunce::canonicalize(path).map_err(|e| IOError::PathIOError { - source: e, - path: path.to_string_lossy().to_string(), - }) + let path = path.as_ref(); + dunce::canonicalize(path).map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + }) } - diff --git a/packages/polyio/src/testing.rs b/packages/polyio/src/testing.rs index 6ac19567..ab7aee91 100644 --- a/packages/polyio/src/testing.rs +++ b/packages/polyio/src/testing.rs @@ -6,40 +6,39 @@ use std::sync::atomic::{AtomicU64, Ordering}; pub struct ScratchDir(PathBuf); impl ScratchDir { - /// `tag` only makes a leaked directory identifiable if the destructor is - /// skipped - #[must_use] - pub fn new(tag: &str) -> Self { - static COUNTER: AtomicU64 = AtomicU64::new(0); - let n = COUNTER.fetch_add(1, Ordering::Relaxed); + /// `tag` only makes a leaked directory identifiable if the destructor is + /// skipped + #[must_use] + pub fn new(tag: &str) -> Self { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); - let path = std::env::temp_dir() - .join(format!("oneclient-{tag}-{}-{n}", std::process::id())); - std::fs::create_dir_all(&path) - .unwrap_or_else(|e| panic!("failed to create scratch dir {}: {e}", path.display())); + let path = std::env::temp_dir().join(format!("oneclient-{tag}-{}-{n}", std::process::id())); + std::fs::create_dir_all(&path) + .unwrap_or_else(|e| panic!("failed to create scratch dir {}: {e}", path.display())); - Self(path) - } + Self(path) + } - #[must_use] - pub fn path(&self) -> &Path { - &self.0 - } + #[must_use] + pub fn path(&self) -> &Path { + &self.0 + } - #[must_use] - pub fn join(&self, name: impl AsRef) -> PathBuf { - self.0.join(name) - } + #[must_use] + pub fn join(&self, name: impl AsRef) -> PathBuf { + self.0.join(name) + } } impl AsRef for ScratchDir { - fn as_ref(&self) -> &Path { - &self.0 - } + fn as_ref(&self) -> &Path { + &self.0 + } } impl Drop for ScratchDir { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.0); - } + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } }