From 06fa7739b34b1e156dd07708b68f1c33dacdcd9f Mon Sep 17 00:00:00 2001 From: Maciek <108873945+rozwader@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:44:04 +0200 Subject: [PATCH] feat: working modpacks from modrinth and curseforge as well as manually imported; couple ui fixes (#894) --- .../src/components/blocked_downloads_popup.rs | 528 +++++++++++++++ .../oneclient_app/src/components/file_drop.rs | 59 +- packages/oneclient_app/src/components/mod.rs | 6 +- .../src/components/modpack_confirm_popup.rs | 259 +++++++ .../src/components/navbar/app_navbar.rs | 8 +- .../src/components/recents_row.rs | 241 ++++++- .../src/components/scrollview.rs | 6 +- packages/oneclient_app/src/hooks/actions.rs | 10 + .../src/hooks/actions/modpacks.rs | 529 +++++++++++++++ packages/oneclient_app/src/hooks/mod.rs | 3 +- .../src/hooks/queries/folder_watch.rs | 2 +- .../oneclient_app/src/hooks/queries/mod.rs | 1 + .../src/hooks/queries/mutations.rs | 7 +- .../src/hooks/queries/packages.rs | 1 + .../oneclient_app/src/layout/app_shell.rs | 8 +- .../oneclient_app/src/layout/root_layout.rs | 10 +- packages/oneclient_app/src/notifications.rs | 122 +++- packages/oneclient_app/src/state.rs | 28 + packages/oneclient_app/src/utils.rs | 62 +- .../src/view/app/browser/index/cards.rs | 15 +- .../src/view/app/browser/index/mod.rs | 21 +- .../src/view/app/browser/package/mod.rs | 25 +- .../src/view/app/browser/package/sidebar.rs | 3 +- .../src/view/app/cluster/cluster_settings.rs | 13 + .../oneclient_app/src/view/app/cluster/mod.rs | 1 + .../src/view/app/cluster/modpack_settings.rs | 169 +++++ .../src/view/app/clusters/instance/copy.rs | 30 +- .../src/view/app/clusters/instance/create.rs | 86 ++- .../src/view/app/clusters/instance/data.rs | 8 +- .../src/view/app/clusters/instance/details.rs | 7 +- .../src/view/app/clusters/instance/edit.rs | 10 +- .../src/view/app/clusters/instance/model.rs | 12 + .../src/view/app/clusters/instance/steps.rs | 51 +- .../src/view/app/clusters/page.rs | 1 + packages/oneclient_cluster/src/cluster.rs | 7 + packages/oneclient_cluster/src/manager.rs | 38 +- packages/oneclient_cluster/src/naming.rs | 19 +- packages/oneclient_cluster/src/options.rs | 7 + .../oneclient_content/src/bundles/install.rs | 62 +- packages/oneclient_content/src/bundles/mod.rs | 6 +- .../src/bundles/overrides.rs | 239 ++++++- .../src/bundles/polymrpack.rs | 149 ++-- packages/oneclient_content/src/lib.rs | 1 + .../oneclient_content/src/modpacks/checker.rs | 307 +++++++++ .../src/modpacks/curseforge.rs | 461 +++++++++++++ .../oneclient_content/src/modpacks/install.rs | 637 ++++++++++++++++++ .../oneclient_content/src/modpacks/mod.rs | 217 ++++++ .../oneclient_content/src/modpacks/mrpack.rs | 325 +++++++++ .../oneclient_content/src/modpacks/update.rs | 212 ++++++ .../oneclient_content/src/packages/error.rs | 2 - .../oneclient_content/src/packages/mod.rs | 1 - .../src/packages/modpack/mod.rs | 17 - .../src/packages/modpack/mrpack.rs | 176 ----- .../src/packages/provider/curseforge.rs | 27 +- .../src/packages/store/mod.rs | 23 +- .../oneclient_core/examples/download_game.rs | 1 + packages/oneclient_core/src/clusters/mod.rs | 5 + .../oneclient_core/src/clusters/modpack.rs | 503 ++++++++++++++ packages/oneclient_core/src/game/launch.rs | 5 + packages/oneclient_db/src/dao/artifact.rs | 4 +- packages/oneclient_db/src/dao/cluster.rs | 9 + packages/polyio/src/archive.rs | 66 ++ 62 files changed, 5494 insertions(+), 374 deletions(-) create mode 100644 packages/oneclient_app/src/components/blocked_downloads_popup.rs create mode 100644 packages/oneclient_app/src/components/modpack_confirm_popup.rs create mode 100644 packages/oneclient_app/src/hooks/actions/modpacks.rs create mode 100644 packages/oneclient_app/src/view/app/cluster/modpack_settings.rs create mode 100644 packages/oneclient_content/src/modpacks/checker.rs create mode 100644 packages/oneclient_content/src/modpacks/curseforge.rs create mode 100644 packages/oneclient_content/src/modpacks/install.rs create mode 100644 packages/oneclient_content/src/modpacks/mod.rs create mode 100644 packages/oneclient_content/src/modpacks/mrpack.rs create mode 100644 packages/oneclient_content/src/modpacks/update.rs delete mode 100644 packages/oneclient_content/src/packages/modpack/mod.rs delete mode 100644 packages/oneclient_content/src/packages/modpack/mrpack.rs create mode 100644 packages/oneclient_core/src/clusters/modpack.rs diff --git a/packages/oneclient_app/src/components/blocked_downloads_popup.rs b/packages/oneclient_app/src/components/blocked_downloads_popup.rs new file mode 100644 index 00000000..3e5e34a1 --- /dev/null +++ b/packages/oneclient_app/src/components/blocked_downloads_popup.rs @@ -0,0 +1,528 @@ +use std::path::{Path, PathBuf}; + +use freya::prelude::*; +use freya::router::RouterContext; +use notify::{Event, EventKind}; +use oneclient_content::modpacks::BlockedFile; + +use crate::components::{Button, Icon, IconType, OverlayPopup}; +use crate::hooks::{use_dispatch, use_folder_watch, use_notifications_snapshot}; +use crate::notifications::BlockedDownloads; +use crate::routes::Route; +use crate::theme::colors; +use crate::ui::border_all_color; + +const CARD_BG: Color = Color::from_rgb(26, 34, 41); +const DIALOG_W: f32 = 560.; +const DIALOG_PAD: f32 = 22.; +const LIST_MAX_H: f32 = 300.; +const ROW_H: f32 = 52.; + +#[derive(PartialEq)] +pub struct BlockedDownloadsPopup; + +impl Component for BlockedDownloadsPopup { + fn render(&self) -> impl IntoElement { + let snapshot = use_notifications_snapshot(); + let dispatch = use_dispatch(); + let router = RouterContext::get(); + + use_side_effect_with_deps(&snapshot.open_cluster, move |requested| { + if requested.is_none() { + return; + } + if let Some(cluster_id) = dispatch.take_open_cluster() { + let _ = router.push(Route::ClusterOverview { cluster_id }); + } + }); + + match snapshot.blocked_downloads { + Some(blocked) => BlockedDownloadsDialog { blocked }.into_element(), + None => rect().into_element(), + } + } +} + +fn downloads_dir() -> Option { + directories::UserDirs::new().and_then(|dirs| dirs.download_dir().map(Path::to_path_buf)) +} + +fn touches_file(_root: &Path, event: &Event) -> bool { + matches!(event.kind, EventKind::Create(_) | EventKind::Modify(_)) +} + +type ExtraFolder = State>; + +fn bump(mut tick: State) { + let next = *tick.peek() + 1; + tick.set(next); +} + +#[derive(PartialEq)] +struct ExtraFolderWatch { + folder: PathBuf, + tick: State, + key: DiffKey, +} + +impl KeyExt for ExtraFolderWatch { + fn write_key(&mut self) -> &mut DiffKey { + &mut self.key + } +} + +impl Component for ExtraFolderWatch { + fn render(&self) -> impl IntoElement { + let tick = self.tick; + use_folder_watch(Some(self.folder.clone()), false, touches_file, move || { + bump(tick) + }); + rect() + } + + fn render_key(&self) -> DiffKey { + self.key.clone().or(self.default_key()) + } +} + +#[derive(PartialEq)] +struct BlockedDownloadsDialog { + blocked: BlockedDownloads, +} + +impl Component for BlockedDownloadsDialog { + fn render(&self) -> impl IntoElement { + let dispatch = use_dispatch(); + let downloads = use_hook(downloads_dir); + let tick = use_state(|| 0u64); + let extra: ExtraFolder = use_state(|| None); + + use_folder_watch(downloads.clone(), false, touches_file, move || bump(tick)); + + let cluster_id = self.blocked.cluster_id; + let chosen = extra + .read() + .as_ref() + .filter(|(owner, _)| *owner == cluster_id) + .map(|(_, folder)| folder.clone()); + + let deps = ( + *tick.read(), + cluster_id, + self.blocked.remaining(), + chosen.clone(), + ); + { + let dispatch = dispatch.clone(); + let downloads = downloads.clone(); + use_side_effect_with_deps(&deps, move |(_, cluster_id, files, chosen)| { + if files.is_empty() { + return; + } + let locations: Vec = + downloads.iter().chain(chosen.iter()).cloned().collect(); + if !locations.is_empty() { + dispatch.scan_blocked_downloads(*cluster_id, locations, files.clone()); + } + }); + } + + let close = dispatch.clone(); + OverlayPopup::new() + .on_close(move |_| close.close_blocked_downloads()) + .child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .child(dialog( + &self.blocked, + downloads.as_deref(), + chosen.as_deref(), + extra, + dispatch, + )), + ) + .maybe_child(chosen.map(|folder| { + let key = folder.to_string_lossy().into_owned(); + ExtraFolderWatch { + folder, + tick, + key: DiffKey::None, + } + .key(key) + .into_element() + })) + .into_element() + } +} + +fn folder_label(path: &Path) -> String { + path.file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.display().to_string()) +} + +fn watched_text(downloads: Option<&Path>, chosen: Option<&Path>) -> String { + match (downloads, chosen) { + (Some(_), Some(chosen)) => format!("Downloads and {}", chosen.display()), + (Some(downloads), None) => downloads.display().to_string(), + (None, Some(chosen)) => chosen.display().to_string(), + (None, None) => "Nothing yet. Add a folder or use Browse.".to_string(), + } +} + +fn pick_extra_folder(cluster_id: i64, start: Option, mut extra: ExtraFolder) { + spawn(async move { + let mut picker = rfd::AsyncFileDialog::new().set_title("Pick the folder you download into"); + if let Some(start) = start { + picker = picker.set_directory(start); + } + if let Some(handle) = picker.pick_folder().await { + extra.set(Some((cluster_id, handle.path().to_path_buf()))); + } + }); +} + +fn folder_row( + cluster_id: i64, + downloads: Option<&Path>, + chosen: Option<&Path>, + extra: ExtraFolder, +) -> impl IntoElement { + let start = chosen + .map(Path::to_path_buf) + .or_else(|| downloads.map(Path::to_path_buf)); + let has_extra = chosen.is_some(); + + rect() + .vertical() + .width(Size::fill()) + .spacing(6.) + .child( + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .padding(Gaps::new_symmetric(8., 12.)) + .spacing(10.) + .corner_radius(CornerRadius::new_all(10.)) + .background(colors::component_bg()) + .border(border_all_color(1., colors::component_border())) + .child( + Icon::new(IconType::FolderCheck) + .size(16.) + .color(colors::fg_secondary()), + ) + .child( + rect() + .vertical() + .width(Size::flex(1.)) + .spacing(2.) + .child( + label() + .text("Watching") + .font_size(11.) + .color(colors::fg_secondary()), + ) + .child( + label() + .text(watched_text(downloads, chosen)) + .font_size(12.5) + .max_lines(2) + .color(colors::fg_primary()), + ), + ) + .child( + Button::new() + .secondary() + .small() + .on_press(move |_| pick_extra_folder(cluster_id, start.clone(), extra)) + .text(if has_extra { + "Change folder" + } else { + "Add folder" + }), + ) + .maybe_child(has_extra.then(|| { + let mut extra = extra; + Button::new() + .ghost() + .small() + .on_press(move |_| extra.set(None)) + .text("Remove") + .into_element() + })), + ) + .child( + label() + .text( + "Your browser saves to its own download folder. To save somewhere else, \ + set it to ask where to save each file.", + ) + .font_size(11.) + .max_lines(2) + .color(colors::fg_secondary()), + ) +} + +fn dialog( + blocked: &BlockedDownloads, + downloads: Option<&Path>, + chosen: Option<&Path>, + extra: ExtraFolder, + dispatch: crate::Actions, +) -> impl IntoElement { + let count = blocked.files.len(); + let added = blocked.added.len().min(count); + let done = added == count; + let plural = crate::utils::plural(count as i64); + let where_to = match (downloads, chosen) { + (_, Some(chosen)) => format!( + "Download each one from its page and it will be picked up from Downloads or {} automatically.", + folder_label(chosen) + ), + (Some(_), None) => { + "Download each one from its page and it will be picked up from your Downloads folder automatically." + .to_string() + } + (None, None) => { + "Download each one from its page, then add the folder you saved them to or use Browse." + .to_string() + } + }; + let subtitle = if done { + format!( + "All {count} file{plural} are in {}. It is ready to play.", + blocked.cluster_name + ) + } else { + format!( + "CurseForge does not let launchers download {count} file{plural} that {} needs. {where_to}", + blocked.cluster_name + ) + }; + let title = if done { + "Manual downloads added".to_string() + } else if added > 0 { + format!("Some files need a manual download ({added} of {count} added)") + } else { + "Some files need a manual download".to_string() + }; + + let cluster_id = blocked.cluster_id; + let files = blocked.remaining(); + let browse = dispatch.clone(); + let close = dispatch.clone(); + let browse_start = chosen + .map(Path::to_path_buf) + .or_else(|| downloads.map(Path::to_path_buf)); + + rect() + .vertical() + .width(Size::px(DIALOG_W)) + .height(Size::Inner) + .max_width(Size::window_percent(95.)) + .max_height(Size::window_percent(85.)) + .overflow(Overflow::Clip) + .corner_radius(CornerRadius::new_all(16.)) + .background(CARD_BG) + .border(border_all_color(1., colors::component_border())) + .shadow(Shadow::from(( + 0., + 18., + 52., + 0., + Color::from_argb(150, 0, 0, 0), + ))) + .child( + rect() + .vertical() + .width(Size::fill()) + .height(Size::Inner) + .padding(Gaps::new_all(DIALOG_PAD)) + .spacing(14.) + .child( + rect() + .vertical() + .width(Size::fill()) + .spacing(3.) + .child( + label() + .text(title) + .font_size(17.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text(subtitle) + .font_size(12.5) + .max_lines(4) + .color(colors::fg_secondary()), + ), + ) + .maybe_child( + (!done) + .then(|| folder_row(cluster_id, downloads, chosen, extra).into_element()), + ) + .child(file_list(blocked)) + .child(if done { + let close = close.clone(); + rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::End) + .child( + Button::new() + .primary() + .on_press(move |_| close.close_blocked_downloads()) + .child(Icon::new(IconType::Check).size(15.)) + .text("Done"), + ) + .into_element() + } else { + footer(cluster_id, files, browse_start, browse, close).into_element() + }), + ) +} + +fn footer( + cluster_id: i64, + files: Vec, + browse_start: Option, + browse: crate::Actions, + close: crate::Actions, +) -> impl IntoElement { + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .main_align(Alignment::End) + .spacing(8.) + .child( + Button::new() + .ghost() + .on_press(move |_| close.close_blocked_downloads()) + .text("Skip for now"), + ) + .child( + Button::new() + .primary() + .on_press(move |_| { + let dispatch = browse.clone(); + let files = files.clone(); + let start = browse_start.clone(); + spawn(async move { + let mut picker = + rfd::AsyncFileDialog::new().set_title("Pick the downloaded files"); + if let Some(start) = start { + picker = picker.set_directory(start); + } + if let Some(handles) = picker.pick_files().await { + let picked = handles + .iter() + .map(|handle| handle.path().to_path_buf()) + .collect(); + dispatch.scan_blocked_downloads(cluster_id, picked, files); + } + }); + }) + .child(Icon::new(IconType::Folder).size(15.)) + .text("Browse"), + ) +} + +fn file_list(blocked: &BlockedDownloads) -> impl IntoElement { + ScrollView::new() + .width(Size::fill()) + .height(Size::Inner) + .max_height(Size::px(LIST_MAX_H)) + .child( + rect().vertical().width(Size::fill()).spacing(6.).children( + blocked + .files + .iter() + .map(|file| file_row(file, blocked.is_added(file)).into_element()), + ), + ) +} + +fn file_row(file: &BlockedFile, added: bool) -> impl IntoElement { + let page = file.page_url.clone(); + let (background, border) = if added { + (colors::success().with_a(28), colors::success().with_a(120)) + } else { + (colors::component_bg(), colors::component_border()) + }; + + let trailing = if added { + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(6.) + .padding(Gaps::new_symmetric(0., 4.)) + .child( + Icon::new(IconType::CheckCircle) + .size(14.) + .color(colors::success()), + ) + .child( + label() + .text("Added") + .font_size(12.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::success()), + ) + .into_element() + } else { + Button::new() + .secondary() + .small() + .enabled(page.is_some()) + .on_press(move |_| { + if let Some(url) = &page { + crate::platform::open_url(url); + } + }) + .child(Icon::new(IconType::LinkExternal01).size(13.)) + .text("Open page") + .into_element() + }; + + rect() + .key(file.sha1.clone()) + .horizontal() + .width(Size::fill()) + .height(Size::px(ROW_H)) + .content(Content::Flex) + .cross_align(Alignment::Center) + .padding(Gaps::new_symmetric(0., 12.)) + .spacing(10.) + .corner_radius(CornerRadius::new_all(10.)) + .background(background) + .border(border_all_color(1., border)) + .child( + rect() + .vertical() + .width(Size::flex(1.)) + .spacing(2.) + .child( + label() + .text(file.project_name.clone()) + .font_size(13.) + .font_weight(FontWeight::SEMI_BOLD) + .max_lines(1) + .color(colors::fg_primary()), + ) + .child( + label() + .text(file.file_name.clone()) + .font_size(11.) + .max_lines(1) + .color(colors::fg_secondary()), + ), + ) + .child(trailing) +} diff --git a/packages/oneclient_app/src/components/file_drop.rs b/packages/oneclient_app/src/components/file_drop.rs index d70aecbc..830698a5 100644 --- a/packages/oneclient_app/src/components/file_drop.rs +++ b/packages/oneclient_app/src/components/file_drop.rs @@ -3,20 +3,24 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; +use std::time::Duration; use freya::animation::{AnimNum, Ease, Function, OnCreation, use_animation}; use freya::prelude::*; use freya::router::use_route; +use oneclient_content::modpacks::detect_format; use oneclient_content::packages::ContentType; -use oneclient_core::clusters::Cluster; +use oneclient_core::clusters::{Cluster, ModpackSource}; use oneclient_db::models::ClusterId; use crate::Route; use crate::components::{Button, Dropdown, Icon, IconType, OverlayPopup, ScrollArea}; use crate::hooks::{ add_world_datapacks, settled_or_loading, spawn_world_task, try_cluster_worlds, - use_active_cluster_id, use_cluster_worlds, use_clusters, use_datapack_world, use_dispatch, + use_active_cluster_id, use_cluster_worlds, use_clusters, use_datapack_world, use_debounced, + use_dispatch, }; +use crate::launcher::off_ui; use crate::theme::colors; use crate::ui::border_all_color; @@ -90,6 +94,55 @@ fn infer_content_type( } } +const DROP_SETTLE: Duration = Duration::from_millis(300); + +pub fn accept_drop( + paths: &[PathBuf], + mut pending: State>, + mut modpacks: State>, +) { + for path in paths { + match extension(path).as_deref() { + Some("mrpack") => modpacks.write().push(path.clone()), + Some("zip") => { + let path = path.clone(); + spawn(async move { + let probe = path.clone(); + let is_modpack = + off_ui(async move { detect_format(&probe).await.is_ok() }).await; + if is_modpack { + modpacks.write().push(path); + } else { + pending.write().push(path); + } + }); + } + _ => pending.write().push(path.clone()), + } + } +} + +fn use_dropped_modpack_driver(pending: State>, modpacks: State>) { + let dispatch = use_dispatch(); + let settled = use_debounced( + (pending.read().is_empty(), modpacks.read().len()), + DROP_SETTLE, + ); + let settled = *settled.read(); + + use_side_effect_with_deps(&settled, move |&(prompt_closed, queued)| { + if !prompt_closed || queued == 0 || !pending.peek().is_empty() { + return; + } + + let mut modpacks = modpacks; + let dropped = std::mem::take(&mut *modpacks.write()); + for path in dropped { + dispatch.install_modpack(ModpackSource::File(path)); + } + }); +} + fn route_target(route: &Route) -> Option<(ClusterId, ContentType)> { match route { Route::ClusterMods { cluster_id } => Some((*cluster_id, ContentType::Mod)), @@ -105,10 +158,12 @@ fn route_target(route: &Route) -> Option<(ClusterId, ContentType)> { pub struct FileDropOverlay { pub hovering: State, pub pending: State>, + pub modpacks: State>, } impl Component for FileDropOverlay { fn render(&self) -> impl IntoElement { + use_dropped_modpack_driver(self.pending, self.modpacks); let files = self.pending.read().clone(); if !files.is_empty() { diff --git a/packages/oneclient_app/src/components/mod.rs b/packages/oneclient_app/src/components/mod.rs index 0b2dd81a..c50812a5 100644 --- a/packages/oneclient_app/src/components/mod.rs +++ b/packages/oneclient_app/src/components/mod.rs @@ -2,6 +2,7 @@ mod account_switcher; mod active_cluster_panel; mod asset_image; mod avatar; +mod blocked_downloads_popup; mod button; mod charts; mod checkbox; @@ -27,6 +28,7 @@ mod markdown; mod memory_field; mod microsoft_java_prompt; mod microsoft_login; +mod modpack_confirm_popup; mod navbar; mod notifications; mod optional_mods_popup; @@ -59,6 +61,7 @@ pub use account_switcher::AccountSwitcher; pub use active_cluster_panel::ActiveClusterPanel; pub use asset_image::AssetImage; pub use avatar::Avatar; +pub use blocked_downloads_popup::BlockedDownloadsPopup; pub use button::{Button, ButtonSize, ButtonVariant, link_button, open_folder_button}; pub use charts::{BarChart, PieChart, ValueUnit, slice_color}; pub use checkbox::checkbox_labeled; @@ -69,7 +72,7 @@ pub use date_range_picker::{DateRange, DateRangePicker}; pub use dropdown::Dropdown; pub use dynamic_art::{ART_PREVIEW_EDGE, DynamicArt}; pub use essential_confirm::EssentialConfirmOverlay; -pub use file_drop::FileDropOverlay; +pub use file_drop::{FileDropOverlay, accept_drop}; pub use filter_menu::{FilterMenu, FilterOption}; pub use flagged_install_popup::FlaggedInstallPopup; pub use generic_prompt::GenericPromptOverlay; @@ -85,6 +88,7 @@ pub use memory_field::memory_field; pub use microsoft_java_prompt::MicrosoftJavaPromptOverlay; pub(crate) use microsoft_login::login_dialog; pub use microsoft_login::use_microsoft_login; +pub use modpack_confirm_popup::ModpackConfirmPopup; pub(crate) use navbar::window_controls; pub use navbar::{AppNavbar, OnboardingNavbar}; pub use notifications::{ChevronToggle, NotificationCenter}; diff --git a/packages/oneclient_app/src/components/modpack_confirm_popup.rs b/packages/oneclient_app/src/components/modpack_confirm_popup.rs new file mode 100644 index 00000000..35371f91 --- /dev/null +++ b/packages/oneclient_app/src/components/modpack_confirm_popup.rs @@ -0,0 +1,259 @@ +use freya::prelude::*; +use oneclient_content::modpacks::ModpackSummary; + +use crate::components::{Button, Icon, IconType, OverlayPopup}; +use crate::hooks::{use_dispatch, use_notifications_snapshot}; +use crate::notifications::ModpackConfirm; +use crate::theme::colors; +use crate::ui::border_all_color; +use crate::utils::format_size; + +const CARD_BG: Color = Color::from_rgb(26, 34, 41); +const DIALOG_W: f32 = 500.; +const DIALOG_PAD: f32 = 22.; +const FACT_LABEL_W: f32 = 110.; + +#[derive(PartialEq)] +pub struct ModpackConfirmPopup; + +impl Component for ModpackConfirmPopup { + fn render(&self) -> impl IntoElement { + let snapshot = use_notifications_snapshot(); + let dispatch = use_dispatch(); + + let Some(confirm) = snapshot.modpack_confirm else { + return rect().into_element(); + }; + + let close = dispatch.clone(); + OverlayPopup::new() + .on_close(move |_| close.cancel_modpack()) + .child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .child(dialog(&confirm, dispatch)), + ) + .into_element() + } +} + +fn count_label(count: usize, one: &str, many: &str) -> String { + format!("{count} {}", if count == 1 { one } else { many }) +} + +fn content_line(summary: &ModpackSummary) -> String { + let parts: Vec = [ + (summary.mods, "mod", "mods"), + (summary.resource_packs, "resource pack", "resource packs"), + (summary.shaders, "shader", "shaders"), + (summary.other_files, "other file", "other files"), + ] + .into_iter() + .filter(|(count, _, _)| *count > 0) + .map(|(count, one, many)| count_label(count, one, many)) + .collect(); + + if parts.is_empty() { + "Configs and settings only".to_string() + } else { + parts.join(", ") + } +} + +fn notes(summary: &ModpackSummary) -> Vec<(String, Color)> { + let mut notes = Vec::new(); + if summary.optional > 0 { + notes.push(( + format!( + "{} optional. They are installed turned off, so you can switch them on in the Mods tab.", + count_label(summary.optional, "file is", "files are") + ), + colors::fg_secondary(), + )); + } + if summary.manual > 0 { + notes.push(( + format!( + "{} must be downloaded by hand from CurseForge. The launcher will show you which ones.", + count_label(summary.manual, "file", "files") + ), + colors::code_warn(), + )); + } + if summary.unresolved > 0 { + notes.push(( + format!( + "{} could not be found on Modrinth or CurseForge and will be skipped.", + count_label(summary.unresolved, "file", "files") + ), + colors::danger(), + )); + } + notes +} + +fn fact(name: &'static str, value: String) -> Element { + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .spacing(10.) + .child( + label() + .text(name) + .width(Size::px(FACT_LABEL_W)) + .font_size(12.5) + .color(colors::fg_secondary()), + ) + .child( + label() + .text(value) + .width(Size::flex(1.)) + .font_size(12.5) + .max_lines(2) + .color(colors::fg_primary()), + ) + .into_element() +} + +fn dialog(confirm: &ModpackConfirm, dispatch: crate::Actions) -> impl IntoElement { + let summary = &confirm.summary; + let version = if confirm.version.trim().is_empty() { + "Not stated by the pack".to_string() + } else { + confirm.version.clone() + }; + + let mut facts = vec![ + fact("Instance", confirm.instance_name.clone()), + fact("Version", version), + fact( + "Minecraft", + format!("{} · {}", confirm.mc_version, confirm.loader), + ), + fact("Content", content_line(summary)), + ]; + if summary.download_bytes > 0 { + facts.push(fact( + "Download", + format!( + "Up to {}, less for files the launcher already has", + format_size(summary.download_bytes) + ), + )); + } + + let cancel = dispatch.clone(); + let add = dispatch; + + rect() + .vertical() + .width(Size::px(DIALOG_W)) + .height(Size::Inner) + .max_width(Size::window_percent(95.)) + .max_height(Size::window_percent(85.)) + .overflow(Overflow::Clip) + .corner_radius(CornerRadius::new_all(16.)) + .background(CARD_BG) + .border(border_all_color(1., colors::component_border())) + .shadow(Shadow::from(( + 0., + 18., + 52., + 0., + Color::from_argb(150, 0, 0, 0), + ))) + .child( + rect() + .vertical() + .width(Size::fill()) + .height(Size::Inner) + .padding(Gaps::new_all(DIALOG_PAD)) + .spacing(16.) + .child( + rect() + .vertical() + .width(Size::fill()) + .spacing(3.) + .child( + label() + .text(format!("Add {}?", confirm.pack_name)) + .font_size(17.) + .font_weight(FontWeight::SEMI_BOLD) + .max_lines(2) + .color(colors::fg_primary()), + ) + .child( + label() + .text(format!( + "From {}. It is added as a new instance with its own folder.", + confirm.source + )) + .font_size(12.5) + .max_lines(2) + .color(colors::fg_secondary()), + ), + ) + .child( + rect() + .vertical() + .width(Size::fill()) + .spacing(8.) + .padding(Gaps::new_all(12.)) + .corner_radius(CornerRadius::new_all(10.)) + .background(colors::component_bg()) + .border(border_all_color(1., colors::component_border())) + .children(facts), + ) + .children(notes(summary).into_iter().map(|(text, color)| { + label() + .text(text) + .font_size(12.) + .max_lines(3) + .color(color) + .into_element() + })) + .child( + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .main_align(Alignment::End) + .spacing(8.) + .child( + Button::new() + .ghost() + .on_press(move |_| cancel.cancel_modpack()) + .text("Cancel"), + ) + .child( + Button::new() + .primary() + .on_press(move |_| add.confirm_modpack()) + .child(Icon::new(IconType::Plus).size(15.)) + .text("Add"), + ), + ), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_categories_are_left_out_of_the_content_line() { + let summary = ModpackSummary { + mods: 336, + shaders: 1, + ..ModpackSummary::default() + }; + assert_eq!(content_line(&summary), "336 mods, 1 shader"); + assert_eq!( + content_line(&ModpackSummary::default()), + "Configs and settings only" + ); + } +} diff --git a/packages/oneclient_app/src/components/navbar/app_navbar.rs b/packages/oneclient_app/src/components/navbar/app_navbar.rs index 4715b2d6..c30ddfca 100644 --- a/packages/oneclient_app/src/components/navbar/app_navbar.rs +++ b/packages/oneclient_app/src/components/navbar/app_navbar.rs @@ -12,7 +12,7 @@ use crate::{ use_clusters, use_current_account, use_dispatch, use_notifications_snapshot, }, theme, - utils::sort_clusters_for_home, + utils::default_cluster, }; const NAVBAR_INTRO_MS: u64 = 460; @@ -157,11 +157,7 @@ fn browse_target() -> Route { let active = *use_active_cluster_id().read(); let package_type = use_browser_type().read().clone(); - let cluster = active - .and_then(|id| clusters.iter().find(|cluster| cluster.id == id).cloned()) - .or_else(|| sort_clusters_for_home(clusters).into_iter().next()); - - match cluster { + match default_cluster(clusters, active) { Some(cluster) => Route::Browser { cluster_id: cluster.id, package_type: crate::view::app::browser::browsable_type(&package_type, &cluster), diff --git a/packages/oneclient_app/src/components/recents_row.rs b/packages/oneclient_app/src/components/recents_row.rs index 9475ca07..72f2465b 100644 --- a/packages/oneclient_app/src/components/recents_row.rs +++ b/packages/oneclient_app/src/components/recents_row.rs @@ -1,10 +1,17 @@ +use std::time::Duration; + use freya::animation::*; use freya::prelude::*; use freya::router::RouterContext; use oneclient_core::clusters::Cluster; -use crate::components::{ART_PREVIEW_EDGE, ContextMenu, DynamicArt, Icon, IconType}; +use freya::elements::image::{AspectRatio, ImageCover, ImageHandle, image}; + +use super::dynamic_art::use_art_bytes; +use super::local_image::decode; +use crate::components::{ART_PREVIEW_EDGE, ContextMenu, DynamicArt, Icon, IconType, LocalImage}; use crate::hooks::{settled_or_loading, use_active_cluster_id, use_clusters}; +use crate::motion::use_animations_enabled; use crate::routes::Route; use crate::theme; use crate::theme::colors; @@ -20,6 +27,18 @@ const CARD_MS: u64 = 460; const STAGGER_MS: u64 = 85; const CARD_RISE_PX: f32 = 48.0; const INTRO_ITEMS: usize = 6; +const MODPACK_ICON_PX: f32 = 72.0; +const MODPACK_ICON_EDGE: u32 = 192; +const MODPACK_BLUR: f32 = 4.0; +const MODPACK_TINT: Color = Color::from_argb(40, 21, 28, 34); +const MODPACK_BACKDROP: Color = Color::from_rgb(21, 28, 34); +const MODPACK_TITLE_INSET: f32 = 8.0; +const MODPACK_TITLE_LEFT: f32 = 10.5; +const MARQUEE_DELAY: Duration = Duration::from_millis(200); +const MARQUEE_HOLD: Duration = Duration::from_millis(700); +const MARQUEE_FRAME: Duration = Duration::from_millis(16); +const MARQUEE_SPEED_PX_PER_SEC: f32 = 60.0; +const MARQUEE_TRACK_PX: f32 = 4096.0; #[derive(PartialEq)] pub struct RecentsRow; @@ -153,6 +172,7 @@ impl PartialEq for ClusterCard { self.cluster.id == other.cluster.id && self.cluster.name == other.cluster.name && self.cluster.cover_path == other.cluster.cover_path + && self.cluster.linked_modpack_hash == other.cluster.linked_modpack_hash && self.cluster.mc_loader == other.cluster.mc_loader && self.index == other.index && self.items == other.items @@ -182,6 +202,11 @@ impl Component for ClusterCard { let cluster_id = self.cluster.id; let mod_tabs = !self.cluster.lacks_mod_loader(); + let modpack = self.cluster.linked_modpack_hash.is_some(); + let modpack_icon = self + .cluster + .modpack_icon_file() + .filter(|icon| icon.is_file()); let on_press = move |_| { *active_id.write() = Some(cluster_id); }; @@ -265,7 +290,13 @@ impl Component for ClusterCard { rect() .width(Size::fill()) .height(Size::fill()) - .child( + .child(if modpack { + BlurredArt { + art: DynamicArt::for_cluster(&self.cluster) + .max_edge(ART_PREVIEW_EDGE), + } + .into_element() + } else { rect() .width(Size::fill()) .height(Size::fill()) @@ -274,14 +305,29 @@ impl Component for ClusterCard { .child( DynamicArt::for_cluster(&self.cluster) .max_edge(ART_PREVIEW_EDGE), - ), - ) + ) + .into_element() + }) + .maybe_child(modpack_icon.map(|icon| { + rect() + .width(Size::fill()) + .height(Size::fill()) + .position(Position::new_absolute()) + .center() + .layer(Layer::Relative(5)) + .child(modpack_badge(icon)) + .into_element() + })) .child( rect() .width(Size::fill()) .height(Size::fill()) .position(Position::new_absolute()) - .padding(Gaps::new_symmetric(12., 24.)) + .padding(if modpack { + Gaps::new(12., MODPACK_TITLE_INSET, 12., MODPACK_TITLE_LEFT) + } else { + Gaps::new_symmetric(12., 24.) + }) .main_align(Alignment::End) .cross_align(Alignment::Start) .layer(Layer::Relative(4)) @@ -291,13 +337,15 @@ impl Component for ClusterCard { .stop((Color::from_argb(0, 25, 25, 25), 24.519)) .stop((Color::from_af32rgb(0.75, 17, 17, 21), 65.)), ) - .child( - label() - .text(title) - .font_size(32.) - .font_weight(FontWeight::SEMI_BOLD) - .color(colors::fg_primary()), - ), + .child(if modpack { + MarqueeTitle { + text: title, + hovered: *hovering.read(), + } + .into_element() + } else { + title_label(title).into_element() + }), ) .child( rect() @@ -321,6 +369,175 @@ impl Component for ClusterCard { } } +fn title_label(text: String) -> Label { + label() + .text(text) + .font_size(32.) + .font_weight(FontWeight::SEMI_BOLD) + .max_lines(1) + .color(colors::fg_primary()) +} + +#[derive(PartialEq)] +struct MarqueeTitle { + text: String, + hovered: bool, +} + +impl Component for MarqueeTitle { + fn render(&self) -> impl IntoElement { + let animations = use_animations_enabled(); + let mut box_width = use_state(|| 0f32); + let mut text_width = use_state(|| 0f32); + let offset = use_state(|| 0f32); + let run = use_state(|| 0u64); + + let overflow = (*text_width.read() - *box_width.read()).max(0.); + let deps = (self.hovered && animations, overflow); + use_side_effect_with_deps(&deps, move |&(hovered, overflow)| { + let mut run = run; + let mut offset = offset; + let current = *run.peek() + 1; + run.set(current); + + if !hovered || overflow < 1. { + offset.set(0.); + return; + } + + spawn(async move { + let alive = move || *run.peek() == current; + tokio::time::sleep(MARQUEE_DELAY).await; + if !alive() { + return; + } + if !slide(offset, 0., -overflow, &alive).await { + return; + } + tokio::time::sleep(MARQUEE_HOLD).await; + if !alive() { + return; + } + slide(offset, -overflow, 0., &alive).await; + }); + }); + + rect() + .width(Size::fill()) + .overflow(Overflow::Clip) + .on_sized(move |event: Event| { + let width = event.data().area.width(); + if (width - *box_width.peek()).abs() > 0.5 { + box_width.set(width); + } + }) + .child( + rect().width(Size::px(MARQUEE_TRACK_PX)).child( + rect() + .offset_x(*offset.read()) + .on_sized(move |event: Event| { + let width = event.data().area.width(); + if (width - *text_width.peek()).abs() > 0.5 { + text_width.set(width); + } + }) + .child(title_label(self.text.clone())), + ), + ) + } +} + +async fn slide(mut offset: State, from: f32, to: f32, alive: &impl Fn() -> bool) -> bool { + let distance = (to - from).abs(); + let total = Duration::from_secs_f32(distance / MARQUEE_SPEED_PX_PER_SEC); + let started = std::time::Instant::now(); + + loop { + if !alive() { + return false; + } + let progress = (started.elapsed().as_secs_f32() / total.as_secs_f32().max(0.001)).min(1.); + offset.set(from + (to - from) * progress); + if progress >= 1. { + return true; + } + tokio::time::sleep(MARQUEE_FRAME).await; + } +} + +#[derive(PartialEq)] +struct BlurredArt { + art: DynamicArt, +} + +impl Component for BlurredArt { + fn render(&self) -> impl IntoElement { + let (key, bytes) = use_art_bytes(&self.art); + let mut cache = use_state(|| None::<(String, ImageHandle)>); + + let cached = cache + .read() + .clone() + .filter(|(cached_key, _)| *cached_key == key) + .map(|(_, holder)| holder); + let holder = cached.or_else(|| { + let holder = decode(&bytes)?; + cache.set(Some((key.clone(), holder.clone()))); + Some(holder) + }); + + let backdrop = rect() + .width(Size::fill()) + .height(Size::fill()) + .position(Position::new_absolute()) + .overflow(Overflow::Clip) + .background(MODPACK_BACKDROP); + + let Some(holder) = holder else { + return backdrop.into_element(); + }; + + backdrop + .child( + rect() + .width(Size::fill()) + .height(Size::fill()) + .position(Position::new_absolute()) + .overflow(Overflow::Clip) + .layer(Layer::Relative(1)) + .child( + image(holder) + .width(Size::fill()) + .height(Size::fill()) + .aspect_ratio(AspectRatio::Max) + .image_cover(ImageCover::Center), + ), + ) + .child( + rect() + .width(Size::fill()) + .height(Size::fill()) + .position(Position::new_absolute()) + .overflow(Overflow::Clip) + .layer(Layer::Relative(3)) + .backdrop_blur(MODPACK_BLUR) + .background(MODPACK_TINT), + ) + .into_element() + } +} + +fn modpack_badge(icon: std::path::PathBuf) -> Element { + rect() + .width(Size::px(MODPACK_ICON_PX)) + .height(Size::px(MODPACK_ICON_PX)) + .min_width(Size::px(MODPACK_ICON_PX)) + .corner_radius(CornerRadius::new_all(14.)) + .overflow(Overflow::Clip) + .child(LocalImage::new(icon, MODPACK_ICON_EDGE, false)) + .into_element() +} + #[derive(PartialEq)] struct OtherVersionsTile { index: usize, diff --git a/packages/oneclient_app/src/components/scrollview.rs b/packages/oneclient_app/src/components/scrollview.rs index a3590163..520dbcea 100644 --- a/packages/oneclient_app/src/components/scrollview.rs +++ b/packages/oneclient_app/src/components/scrollview.rs @@ -39,7 +39,7 @@ pub(crate) fn scroll_pos_from_wheel(wheel: f32, inner: f32, viewport: f32, curre return 0; } if new_pos <= -(inner - viewport) && wheel < 0.0 { - return -(inner - viewport) as i32; + return -(inner - viewport).ceil() as i32; } new_pos as i32 } @@ -50,7 +50,7 @@ pub(crate) fn corrected_scroll(inner: f32, viewport: f32, pos: f32) -> f32 { } if (-pos + viewport) > inner { return if viewport < inner { - -(inner - viewport) + -(inner - viewport).ceil() } else { 0.0 }; @@ -708,7 +708,7 @@ impl ScrollArea { content_h.set(h); if stick_bottom { let vp = *viewport_h.read(); - let target = -((h - vp).max(0.)); + let target = -((h - vp).max(0.).ceil()); controller.scroll_to_y(target as i32); } } diff --git a/packages/oneclient_app/src/hooks/actions.rs b/packages/oneclient_app/src/hooks/actions.rs index 980e73fd..fc85f8a3 100644 --- a/packages/oneclient_app/src/hooks/actions.rs +++ b/packages/oneclient_app/src/hooks/actions.rs @@ -3,6 +3,9 @@ //! Always `spawn_forever` never `spawn` Freya's `spawn` cancels the task when //! the calling component unmounts this work is app-scoped not component-scoped +mod modpacks; + +pub use modpacks::modpack_job_running; mod release_migration; use std::collections::HashMap; @@ -989,6 +992,13 @@ impl Actions { } pub fn launch_cluster(&self, cluster_id: ClusterId) { + if modpack_job_running(cluster_id) { + self.notify("The modpack is still being set up") + .body("Wait for it to finish installing, then press Play again.") + .send(); + return; + } + let claimed = self .station .clone() diff --git a/packages/oneclient_app/src/hooks/actions/modpacks.rs b/packages/oneclient_app/src/hooks/actions/modpacks.rs new file mode 100644 index 00000000..ae52889d --- /dev/null +++ b/packages/oneclient_app/src/hooks/actions/modpacks.rs @@ -0,0 +1,529 @@ +use std::path::PathBuf; +use std::sync::{Mutex, PoisonError}; + +use freya::prelude::spawn_forever; +use oneclient_content::modpacks::{ + BlockedFile, ModpackInstallReport, find_blocked_downloads, import_blocked_files, +}; +use oneclient_core::clusters::{ + ModpackCluster, ModpackSource, PreparedModpack, create_modpack_instance, + install_modpack_instance, prepare_modpack, repair_modpack_cluster, update_modpack_cluster, +}; +use oneclient_db::models::ClusterId; +use oneclient_events::GroupedProgressSession; + +use super::Actions; +use crate::launcher::{self, off_ui}; +use crate::notifications::{BlockedDownloads, ModpackConfirm}; +use crate::state::AppChannel; + +const MAX_LISTED: usize = 3; + +enum JobSlot { + Idle, + Preparing, + AwaitingConfirm(Box), + Running(ClusterId), +} + +static JOB: Mutex = Mutex::new(JobSlot::Idle); + +fn with_slot(change: impl FnOnce(&mut JobSlot) -> R) -> R { + let mut slot = JOB.lock().unwrap_or_else(PoisonError::into_inner); + change(&mut slot) +} + +#[must_use] +pub fn modpack_job_running(cluster_id: ClusterId) -> bool { + with_slot(|slot| matches!(slot, JobSlot::Running(id) if *id == cluster_id)) +} + +fn take_prepared() -> Option { + with_slot(|slot| match std::mem::replace(slot, JobSlot::Preparing) { + JobSlot::AwaitingConfirm(prepared) => Some(*prepared), + other => { + *slot = other; + None + } + }) +} + +enum ModpackJob { + Install(Box), + Update { + cluster_id: ClusterId, + version_id: String, + }, + Repair { + cluster_id: ClusterId, + }, +} + +struct JobTexts { + progress: &'static str, + done: &'static str, + problems: &'static str, + failed: &'static str, +} + +impl ModpackJob { + fn texts(&self) -> JobTexts { + match self { + Self::Install(_) => JobTexts { + progress: "Installing modpack", + done: "Modpack installed", + problems: "Modpack installed with problems", + failed: "Modpack install failed", + }, + Self::Update { .. } => JobTexts { + progress: "Updating modpack", + done: "Modpack updated", + problems: "Modpack updated with problems", + failed: "Modpack update failed", + }, + Self::Repair { .. } => JobTexts { + progress: "Reinstalling modpack files", + done: "Modpack files reinstalled", + problems: "Some modpack files are still missing", + failed: "Could not reinstall the modpack files", + }, + } + } + + fn done_body(&self, cluster_name: &str) -> String { + match self { + Self::Install(_) => format!("{cluster_name} is ready to play."), + Self::Update { .. } => format!("{cluster_name} is on the new version."), + Self::Repair { .. } => format!("{cluster_name} has everything the pack lists."), + } + } +} + +fn confirm_view(prepared: &PreparedModpack) -> ModpackConfirm { + let manifest = &prepared.manifest; + let loader = match (&manifest.loader, &manifest.loader_version) { + (loader, _) if !loader.is_modded() => "Vanilla".to_string(), + (loader, Some(version)) => format!("{loader} {version}"), + (loader, None) => loader.to_string(), + }; + let source = match &prepared.source { + ModpackSource::Provider { provider, .. } => provider.to_string(), + ModpackSource::File(path) => path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| "a file".to_string()), + }; + + ModpackConfirm { + pack_name: if manifest.name.trim().is_empty() { + prepared.instance_name.clone() + } else { + manifest.name.clone() + }, + version: manifest.version.clone(), + instance_name: prepared.instance_name.clone(), + mc_version: manifest.mc_version.clone(), + loader, + source, + summary: manifest.summary(), + } +} + +impl Actions { + fn claim_modpack_job(&self) -> bool { + let claimed = with_slot(|slot| match slot { + JobSlot::Idle => { + *slot = JobSlot::Preparing; + true + } + _ => false, + }); + if !claimed { + self.notify("A modpack is already being installed") + .body("Wait for it to finish, then try again.") + .error() + .send(); + return false; + } + self.station + .clone() + .write_channel(AppChannel::Installs) + .installs + .modpack_busy = true; + true + } + + fn lock_modpack_cluster(&self, cluster_id: ClusterId) { + with_slot(|slot| *slot = JobSlot::Running(cluster_id)); + self.station + .clone() + .write_channel(AppChannel::Installs) + .installs + .modpack_cluster = Some(cluster_id); + } + + fn release_modpack_job(&self) { + with_slot(|slot| *slot = JobSlot::Idle); + let mut station = self.station; + let mut app = station.write_channel(AppChannel::Installs); + app.installs.modpack_busy = false; + app.installs.modpack_project = None; + app.installs.modpack_cluster = None; + } + + pub fn install_modpack(&self, source: ModpackSource) { + if !self.claim_modpack_job() { + return; + } + let from_browser = match &source { + ModpackSource::Provider { + provider, + project_id, + .. + } => { + self.station + .clone() + .write_channel(AppChannel::Installs) + .installs + .modpack_project = Some((*provider, project_id.clone())); + true + } + ModpackSource::File(_) => false, + }; + + let actions = self.clone(); + spawn_forever(async move { + let Ok(state) = launcher::state() else { + actions.release_modpack_job(); + return; + }; + + let prepared = off_ui({ + let state = state.clone(); + async move { + let session = + GroupedProgressSession::start(&state.services.events, "Reading modpack"); + let prepared = prepare_modpack(&state, &source).await; + session.finish(); + prepared + } + }) + .await; + + match prepared { + Ok(prepared) if from_browser => { + actions.run_modpack_job(ModpackJob::Install(Box::new(prepared))); + } + Ok(prepared) => { + let view = confirm_view(&prepared); + with_slot(|slot| *slot = JobSlot::AwaitingConfirm(Box::new(prepared))); + actions.with_engine(move |app| app.notifications.open_modpack_confirm(view)); + } + Err(err) => { + tracing::warn!(error = %err, "could not read the modpack"); + actions + .notify("Could not read the modpack") + .body(err.to_string()) + .error() + .send(); + actions.release_modpack_job(); + } + } + }); + } + + pub fn confirm_modpack(&self) { + self.with_engine(|app| app.notifications.close_modpack_confirm()); + match take_prepared() { + Some(prepared) => self.run_modpack_job(ModpackJob::Install(Box::new(prepared))), + None => self.release_modpack_job(), + } + } + + pub fn cancel_modpack(&self) { + self.with_engine(|app| app.notifications.close_modpack_confirm()); + self.release_modpack_job(); + } + + pub fn update_modpack(&self, cluster_id: ClusterId, version_id: String) { + if self.refuse_while_running(cluster_id) || !self.claim_modpack_job() { + return; + } + self.run_modpack_job(ModpackJob::Update { + cluster_id, + version_id, + }); + } + + pub fn repair_modpack(&self, cluster_id: ClusterId) { + if self.refuse_while_running(cluster_id) || !self.claim_modpack_job() { + return; + } + self.run_modpack_job(ModpackJob::Repair { cluster_id }); + } + + fn refuse_while_running(&self, cluster_id: ClusterId) -> bool { + let running = launcher::state().is_ok_and(|state| state.games.is_active(cluster_id)); + if running { + self.notify("Close the game first") + .body("The modpack's files cannot be changed while Minecraft is running.") + .error() + .send(); + } + running + } + + fn run_modpack_job(&self, job: ModpackJob) { + let actions = self.clone(); + spawn_forever(async move { + let Ok(state) = launcher::state() else { + actions.release_modpack_job(); + return; + }; + let events = state.services.events.clone(); + let texts = job.texts(); + + let cluster_id = match &job { + ModpackJob::Install(prepared) => { + let created = off_ui({ + let state = state.clone(); + let prepared = prepared.clone(); + async move { create_modpack_instance(&state, &prepared).await } + }) + .await; + match created { + Ok(cluster) => { + events.signal(oneclient_events::Signal::ClustersChanged); + cluster.id + } + Err(err) => { + tracing::warn!(error = %err, "{}", texts.failed); + events + .notify(texts.failed) + .body(err.to_string()) + .error() + .send(); + actions.release_modpack_job(); + return; + } + } + } + ModpackJob::Update { cluster_id, .. } | ModpackJob::Repair { cluster_id } => { + *cluster_id + } + }; + actions.lock_modpack_cluster(cluster_id); + + let (job, outcome) = off_ui({ + let state = state.clone(); + async move { + let session = + GroupedProgressSession::start(&state.services.events, job.texts().progress); + let outcome = match &job { + ModpackJob::Install(prepared) => { + install_modpack_instance(&state, cluster_id, prepared, Some(&session)) + .await + } + ModpackJob::Update { version_id, .. } => { + update_modpack_cluster(&state, cluster_id, version_id, Some(&session)) + .await + } + ModpackJob::Repair { .. } => { + repair_modpack_cluster(&state, cluster_id, Some(&session)).await + } + }; + session.finish(); + (job, outcome) + } + }) + .await; + + match outcome { + Ok(ModpackCluster { cluster, report }) => { + events.signal(oneclient_events::Signal::ClustersChanged); + crate::hooks::invalidate_cluster_queries().await; + notify_outcome(&actions, &job, &cluster.name, &report); + + let opens_page = matches!(job, ModpackJob::Install(_)); + if !report.blocked.is_empty() { + actions.open_blocked_downloads(BlockedDownloads { + cluster_id: cluster.id, + cluster_name: cluster.name.clone(), + files: report.blocked, + added: Default::default(), + open_when_done: opens_page, + }); + } else if opens_page && report.failed.is_empty() { + actions.open_cluster_page(cluster.id); + } + } + Err(err) => { + tracing::warn!(error = %err, "{}", texts.failed); + let body = match &job { + ModpackJob::Install(_) => { + let removed = off_ui({ + let state = state.clone(); + async move { state.clusters.delete(cluster_id, true).await } + }) + .await; + if let Err(cleanup) = &removed { + tracing::warn!(cluster_id, error = %cleanup, "could not remove the unfinished instance"); + } + events.signal(oneclient_events::Signal::ClustersChanged); + crate::hooks::invalidate_cluster_queries().await; + match removed { + Ok(()) => format!("{err} The unfinished instance was removed."), + Err(_) => format!( + "{err} Use Reinstall Missing Files in the instance's settings to finish it." + ), + } + } + _ => format!( + "{err} Use Reinstall Missing Files in the instance's settings to try again." + ), + }; + events.notify(texts.failed).body(body).error().send(); + } + } + + actions.release_modpack_job(); + }); + } + + pub fn open_cluster_page(&self, cluster_id: ClusterId) { + self.with_engine(move |state| state.notifications.request_open_cluster(cluster_id)); + } + + pub fn take_open_cluster(&self) -> Option { + let mut cluster_id = None; + self.with_engine(|state| cluster_id = state.notifications.take_open_cluster()); + cluster_id + } + + pub fn open_blocked_downloads(&self, blocked: BlockedDownloads) { + self.with_engine(move |state| state.notifications.open_blocked_downloads(blocked)); + } + + pub fn close_blocked_downloads(&self) { + let mut remaining = None; + self.with_engine(|state| remaining = state.notifications.take_blocked_downloads()); + + let Some(blocked) = remaining else { + return; + }; + let missing = blocked.remaining(); + if missing.is_empty() { + if blocked.open_when_done { + self.open_cluster_page(blocked.cluster_id); + } + return; + } + + self.notify("Some mods are still missing") + .body(format!( + "{} is missing {}. It may not start until they are added.", + blocked.cluster_name, + listed(missing.iter().map(|file| file.project_name.clone())), + )) + .error() + .send(); + } + + pub fn scan_blocked_downloads( + &self, + cluster_id: ClusterId, + locations: Vec, + files: Vec, + ) { + let actions = self.clone(); + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + + let imported = off_ui({ + let state = state.clone(); + async move { + let found = find_blocked_downloads(&locations, &files).await; + if found.is_empty() { + return Ok(Vec::new()); + } + import_blocked_files(cluster_id, &found, &state.services.content()) + .await + .map(|_| found.into_iter().map(|(_, file)| file).collect::>()) + } + }) + .await; + + match imported { + Ok(found) if found.is_empty() => {} + Ok(found) => { + let sha1s: Vec = found.iter().map(|file| file.sha1.clone()).collect(); + actions.with_engine(|app| { + app.notifications + .resolve_blocked_downloads(cluster_id, &sha1s); + }); + crate::hooks::invalidate_cluster_queries().await; + } + Err(err) => { + tracing::warn!(cluster_id, error = %err, "could not add a manual download"); + actions + .notify("Could not add a download") + .body(err.to_string()) + .error() + .send(); + } + } + }); + } +} + +fn notify_outcome( + actions: &Actions, + job: &ModpackJob, + cluster_name: &str, + report: &ModpackInstallReport, +) { + let texts = job.texts(); + if report.failed.is_empty() { + actions + .notify(texts.done) + .body(job.done_body(cluster_name)) + .send(); + return; + } + + actions + .notify(texts.problems) + .body(format!( + "{cluster_name} could not get {}.", + listed(report.failed.iter().cloned()) + )) + .error() + .send(); +} + +fn listed(names: impl Iterator) -> String { + let names: Vec = names.collect(); + let shown = names + .iter() + .take(MAX_LISTED) + .cloned() + .collect::>() + .join(", "); + match names.len().saturating_sub(MAX_LISTED) { + 0 => shown, + extra => format!("{shown} and {extra} more"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn long_lists_are_cut_short() { + let names = |n: usize| (1..=n).map(|i| format!("m{i}")); + assert_eq!(listed(names(2)), "m1, m2"); + assert_eq!(listed(names(3)), "m1, m2, m3"); + assert_eq!(listed(names(5)), "m1, m2, m3 and 2 more"); + } +} diff --git a/packages/oneclient_app/src/hooks/mod.rs b/packages/oneclient_app/src/hooks/mod.rs index 267138a0..3cd3bd7f 100644 --- a/packages/oneclient_app/src/hooks/mod.rs +++ b/packages/oneclient_app/src/hooks/mod.rs @@ -25,7 +25,8 @@ pub use active_cluster::{ use_provide_start_maximized, use_splash, use_start_maximized, }; -pub use actions::{Actions, NotificationBuilder, PumpSignal}; +pub use actions::{Actions, NotificationBuilder, PumpSignal, modpack_job_running}; +pub(crate) use queries::use_folder_watch; pub use queries::{ AddOfflineAccountKeys, AvailableBundlesQuery, BROWSE_PAGE_SIZE, BeginMicrosoftLoginMutation, CachedImageQuery, CancelMicrosoftLoginKeys, CancelMicrosoftLoginMutation, ClusterAction, diff --git a/packages/oneclient_app/src/hooks/queries/folder_watch.rs b/packages/oneclient_app/src/hooks/queries/folder_watch.rs index 0ab8d590..52ad1ae8 100644 --- a/packages/oneclient_app/src/hooks/queries/folder_watch.rs +++ b/packages/oneclient_app/src/hooks/queries/folder_watch.rs @@ -8,7 +8,7 @@ use tokio::sync::mpsc; const WATCH_QUIET: Duration = Duration::from_millis(400); -pub(super) fn use_folder_watch( +pub(crate) fn use_folder_watch( folder: Option, watch_children: bool, relevant: fn(&Path, &Event) -> bool, diff --git a/packages/oneclient_app/src/hooks/queries/mod.rs b/packages/oneclient_app/src/hooks/queries/mod.rs index a6962295..0be21054 100644 --- a/packages/oneclient_app/src/hooks/queries/mod.rs +++ b/packages/oneclient_app/src/hooks/queries/mod.rs @@ -62,6 +62,7 @@ pub use changelog::{ pub use cluster_content::{cluster_content_items, use_cluster_content}; pub use clusters::{use_cluster, use_clusters}; pub use disable_warnings::{DisableWarningsQuery, disable_warnings, use_disable_warnings}; +pub(crate) use folder_watch::use_folder_watch; pub use image::{CachedImageQuery, loaded_image, use_cached_image}; pub use java::{ invalidate_java_queries, java_runtimes, provider_versions, use_java_runtimes, diff --git a/packages/oneclient_app/src/hooks/queries/mutations.rs b/packages/oneclient_app/src/hooks/queries/mutations.rs index acc5fa30..57d58992 100644 --- a/packages/oneclient_app/src/hooks/queries/mutations.rs +++ b/packages/oneclient_app/src/hooks/queries/mutations.rs @@ -413,7 +413,12 @@ impl MutationCapability for ClusterMutation { }) } ClusterAction::DeleteInstance { cluster_id } => { - if state.games.is_active(*cluster_id) { + if crate::hooks::modpack_job_running(*cluster_id) { + Err(oneclient_content::ContentError::InvalidData { + reason: "Wait for the modpack to finish installing before deleting this instance." + .to_string(), + }) + } else if state.games.is_active(*cluster_id) { Err(oneclient_content::ContentError::InvalidData { reason: "Close the game before deleting this instance.".to_string(), }) diff --git a/packages/oneclient_app/src/hooks/queries/packages.rs b/packages/oneclient_app/src/hooks/queries/packages.rs index 37e22f96..6a505044 100644 --- a/packages/oneclient_app/src/hooks/queries/packages.rs +++ b/packages/oneclient_app/src/hooks/queries/packages.rs @@ -20,6 +20,7 @@ pub fn content_type_for_slug(slug: &str) -> ContentType { "shader" => ContentType::Shader, "texture" => ContentType::ResourcePack, "datapack" => ContentType::DataPack, + "modpack" => ContentType::Modpack, _ => ContentType::Mod, } } diff --git a/packages/oneclient_app/src/layout/app_shell.rs b/packages/oneclient_app/src/layout/app_shell.rs index da8d6f24..9ca0bddc 100644 --- a/packages/oneclient_app/src/layout/app_shell.rs +++ b/packages/oneclient_app/src/layout/app_shell.rs @@ -11,7 +11,7 @@ use crate::Route; use crate::components::Button; use crate::components::{ ART_PREVIEW_EDGE, AppNavbar, DynamicArt, FileDropOverlay, Icon, IconType, OverlayPopup, - ScrollArea, + ScrollArea, accept_drop, }; use crate::layout::AnimatedAppOutlet; use crate::theme; @@ -53,7 +53,8 @@ impl Component for AppShell { // `FileDrop` bubbles so anything a drop zone doesn't `stop_propagation()` lands here let mut drop_hovering = use_state(|| false); - let mut drop_pending = use_state(Vec::::new); + let drop_pending = use_state(Vec::::new); + let dropped_modpacks = use_state(Vec::::new); let game = use_game_snapshot(); @@ -81,7 +82,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); + accept_drop(&e.file_paths, drop_pending, dropped_modpacks); }) .child(AppNavbar) .child(AppHomeBackground) @@ -94,6 +95,7 @@ impl Component for AppShell { .child(FileDropOverlay { hovering: drop_hovering, pending: drop_pending, + modpacks: dropped_modpacks, }) .maybe_child( game.error diff --git a/packages/oneclient_app/src/layout/root_layout.rs b/packages/oneclient_app/src/layout/root_layout.rs index b8e7f0db..b5d8bf47 100644 --- a/packages/oneclient_app/src/layout/root_layout.rs +++ b/packages/oneclient_app/src/layout/root_layout.rs @@ -2,10 +2,10 @@ use freya::prelude::*; use freya::router::*; use crate::components::{ - AccountSwitcher, ClusterUpdatePopup, ControlCenter, FlaggedInstallPopup, GenericPromptOverlay, - JavaPromptOverlay, MicrosoftJavaPromptOverlay, NotificationCenter, OptionalModsPopup, - PackageUpdatePopup, ReleaseMigrationPopup, SplashCurtain, StatusBar, Toasts, TooltipHost, - UpdatePromptOverlay, use_provide_tooltips, + AccountSwitcher, BlockedDownloadsPopup, ClusterUpdatePopup, ControlCenter, FlaggedInstallPopup, + GenericPromptOverlay, JavaPromptOverlay, MicrosoftJavaPromptOverlay, ModpackConfirmPopup, + NotificationCenter, OptionalModsPopup, PackageUpdatePopup, ReleaseMigrationPopup, + SplashCurtain, StatusBar, Toasts, TooltipHost, UpdatePromptOverlay, use_provide_tooltips, }; #[cfg(not(target_os = "macos"))] use crate::hooks::use_start_maximized; @@ -52,6 +52,8 @@ impl Component for RootLayout { .child(GenericPromptOverlay) .child(ClusterUpdatePopup) .child(OptionalModsPopup) + .child(BlockedDownloadsPopup) + .child(ModpackConfirmPopup) .child(PackageUpdatePopup) .child(FlaggedInstallPopup) .child(ReleaseMigrationPopup) diff --git a/packages/oneclient_app/src/notifications.rs b/packages/oneclient_app/src/notifications.rs index 21330f41..17305df0 100644 --- a/packages/oneclient_app/src/notifications.rs +++ b/packages/oneclient_app/src/notifications.rs @@ -1,6 +1,7 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::time::{Duration, Instant}; +use oneclient_content::modpacks::{BlockedFile, ModpackSummary}; use oneclient_content::packages::ProviderId; use oneclient_core::BrowserPackageUpdate; use oneclient_db::models::{ClusterId, OptionalModStatus}; @@ -68,6 +69,40 @@ impl ClusterUpdateItem { } } +#[derive(Clone, Debug, PartialEq)] +pub struct ModpackConfirm { + pub pack_name: String, + pub version: String, + pub instance_name: String, + pub mc_version: String, + pub loader: String, + pub source: String, + pub summary: ModpackSummary, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct BlockedDownloads { + pub cluster_id: ClusterId, + pub cluster_name: String, + pub files: Vec, + pub added: HashSet, + pub open_when_done: bool, +} + +impl BlockedDownloads { + pub fn is_added(&self, file: &BlockedFile) -> bool { + self.added.contains(&file.sha1) + } + + pub fn remaining(&self) -> Vec { + self.files + .iter() + .filter(|file| !self.is_added(file)) + .cloned() + .collect() + } +} + #[derive(Clone, Debug, PartialEq)] pub struct OptionalModsGroup { pub cluster_id: ClusterId, @@ -217,6 +252,9 @@ pub struct NotificationSnapshot { pub cluster_update: Option>, pub optional_mods: Option>, pub package_updates: Option>, + pub blocked_downloads: Option, + pub open_cluster: Option, + pub modpack_confirm: Option, pub active_toast_entry_ids: Vec, } @@ -237,6 +275,9 @@ pub struct NotificationState { cluster_update: Option>, optional_mods: Option>, package_updates: Option>, + blocked_downloads: Option, + open_cluster: Option, + modpack_confirm: Option, /// Resumes the launch that opened the update modal /// Held here not by the launch task /// because every way the modal can end goes through this state @@ -350,6 +391,9 @@ impl NotificationState { cluster_update: self.cluster_update.clone(), optional_mods: self.optional_mods.clone(), package_updates: self.package_updates.clone(), + blocked_downloads: self.blocked_downloads.clone(), + open_cluster: self.open_cluster, + modpack_confirm: self.modpack_confirm.clone(), active_toast_entry_ids: self.active_toasts.iter().map(|t| t.entry_id).collect(), } } @@ -392,6 +436,40 @@ impl NotificationState { self.optional_mods_done = done; } + pub fn open_blocked_downloads(&mut self, blocked: BlockedDownloads) { + self.blocked_downloads = (!blocked.files.is_empty()).then_some(blocked); + } + + pub fn open_modpack_confirm(&mut self, confirm: ModpackConfirm) { + self.modpack_confirm = Some(confirm); + } + + pub fn close_modpack_confirm(&mut self) { + self.modpack_confirm = None; + } + + pub fn request_open_cluster(&mut self, cluster_id: ClusterId) { + self.open_cluster = Some(cluster_id); + } + + pub fn take_open_cluster(&mut self) -> Option { + self.open_cluster.take() + } + + pub fn take_blocked_downloads(&mut self) -> Option { + self.blocked_downloads.take() + } + + pub fn resolve_blocked_downloads(&mut self, cluster_id: ClusterId, sha1s: &[String]) { + if let Some(blocked) = self + .blocked_downloads + .as_mut() + .filter(|blocked| blocked.cluster_id == cluster_id) + { + blocked.added.extend(sha1s.iter().cloned()); + } + } + pub fn hide_optional_mods(&mut self) { self.optional_mods = None; } @@ -1113,6 +1191,48 @@ mod package_update_tests { assert!(state.package_updates.is_some()); } + fn blocked_file(sha1: &str) -> BlockedFile { + BlockedFile { + project_id: "1".into(), + version_id: "2".into(), + project_name: sha1.into(), + file_name: format!("{sha1}.jar"), + path: format!("mods/{sha1}.jar"), + sha1: sha1.into(), + size: 1, + content_type: oneclient_content::packages::ContentType::Mod, + page_url: None, + } + } + + #[test] + fn a_found_manual_download_stays_listed_as_added() { + let mut state = NotificationState::default(); + state.open_blocked_downloads(BlockedDownloads { + cluster_id: 7, + cluster_name: "Pack".into(), + files: vec![blocked_file("aa"), blocked_file("bb")], + added: HashSet::new(), + open_when_done: true, + }); + + state.resolve_blocked_downloads(8, &["aa".to_string()]); + state.resolve_blocked_downloads(7, &["aa".to_string()]); + + let blocked = state.take_blocked_downloads().unwrap(); + assert_eq!(blocked.files.len(), 2); + assert!(blocked.is_added(&blocked.files[0])); + assert!(!blocked.is_added(&blocked.files[1])); + assert_eq!( + blocked + .remaining() + .iter() + .map(|file| file.sha1.as_str()) + .collect::>(), + vec!["bb"] + ); + } + fn spec(title: &str) -> NotificationSpec { NotificationSpec { title: title.to_string(), diff --git a/packages/oneclient_app/src/state.rs b/packages/oneclient_app/src/state.rs index f2b639a3..b9e12321 100644 --- a/packages/oneclient_app/src/state.rs +++ b/packages/oneclient_app/src/state.rs @@ -121,6 +121,9 @@ pub struct RelocationState { pub struct InstallState { pending: HashSet<(i64, ProviderId, String)>, pub flagged: Option, + pub modpack_busy: bool, + pub modpack_project: Option<(ProviderId, String)>, + pub modpack_cluster: Option, } #[derive(Clone, Debug, PartialEq)] @@ -145,6 +148,31 @@ impl InstallState { .remove(&(cluster_id, provider, project_id.to_string())); } + #[must_use] + pub fn is_modpack_job(&self, cluster_id: i64) -> bool { + self.modpack_cluster == Some(cluster_id) + } + + #[must_use] + pub fn package_busy( + &self, + is_modpack: bool, + cluster_id: i64, + provider: ProviderId, + project_id: &str, + ) -> (bool, bool) { + if !is_modpack { + return (self.is_installing(cluster_id, provider, project_id), false); + } + let installing = + self.modpack_project + .as_ref() + .is_some_and(|(busy_provider, busy_project)| { + *busy_provider == provider && busy_project == project_id + }); + (installing, self.modpack_busy) + } + #[must_use] pub fn is_installing(&self, cluster_id: i64, provider: ProviderId, project_id: &str) -> bool { self.pending diff --git a/packages/oneclient_app/src/utils.rs b/packages/oneclient_app/src/utils.rs index 1b9ebbae..b4a9dbf2 100644 --- a/packages/oneclient_app/src/utils.rs +++ b/packages/oneclient_app/src/utils.rs @@ -334,13 +334,24 @@ pub fn bundle_display_name(archive: &BundleArchive) -> String { } } +pub fn default_cluster(clusters: Vec, active: Option) -> Option { + active + .and_then(|id| clusters.iter().find(|cluster| cluster.id == id).cloned()) + .or_else(|| sort_clusters_for_home(clusters).into_iter().next()) +} + pub fn sort_clusters_for_home(mut clusters: Vec) -> Vec { - clusters.sort_by(compare_last_played); + clusters.sort_by(compare_recent_activity); clusters } -fn compare_last_played(a: &Cluster, b: &Cluster) -> Ordering { - match (a.last_played, b.last_played) { +fn recent_activity(cluster: &Cluster) -> Option> { + let created = cluster.created_at.filter(|_| cluster.user_created); + cluster.last_played.max(created) +} + +fn compare_recent_activity(a: &Cluster, b: &Cluster) -> Ordering { + match (recent_activity(a), recent_activity(b)) { // Most recently played first (Some(a), Some(b)) => b.cmp(&a), (Some(_), None) => Ordering::Less, @@ -388,6 +399,51 @@ mod tests { } } + #[test] + fn a_newly_created_instance_comes_first_on_home() { + let now = chrono::Utc::now(); + let played = Cluster { + last_played: Some(now - chrono::Duration::hours(2)), + ..versioned(1, "1.21.1") + }; + let created = Cluster { + user_created: true, + created_at: Some(now), + ..versioned(2, "1.20.1") + }; + let provisioned = Cluster { + user_created: false, + created_at: Some(now), + ..versioned(3, "26.3") + }; + + let order: Vec = sort_clusters_for_home(vec![played, provisioned, created]) + .into_iter() + .map(|cluster| cluster.id) + .collect(); + assert_eq!(order, vec![2, 1, 3]); + } + + #[test] + fn playing_an_older_instance_moves_it_back_in_front() { + let now = chrono::Utc::now(); + let created = Cluster { + user_created: true, + created_at: Some(now - chrono::Duration::hours(1)), + ..versioned(1, "1.20.1") + }; + let played = Cluster { + last_played: Some(now), + ..versioned(2, "1.21.1") + }; + + let order: Vec = sort_clusters_for_home(vec![created, played]) + .into_iter() + .map(|cluster| cluster.id) + .collect(); + assert_eq!(order, vec![2, 1]); + } + fn line(major: u32, minor: Option) -> ReleaseLine { ReleaseLine { major, minor } } diff --git a/packages/oneclient_app/src/view/app/browser/index/cards.rs b/packages/oneclient_app/src/view/app/browser/index/cards.rs index bee8910f..b42ad035 100644 --- a/packages/oneclient_app/src/view/app/browser/index/cards.rs +++ b/packages/oneclient_app/src/view/app/browser/index/cards.rs @@ -5,6 +5,7 @@ use std::collections::HashMap; use freya::router::RouterContext; use oneclient_content::packages::types::ProjectSummary; use oneclient_content::packages::{ContentType, ProviderId}; +use oneclient_core::clusters::ModpackSource; use crate::components::{Button, Icon, IconType}; use crate::hooks::{ @@ -346,7 +347,8 @@ impl Component for InstallButton { let cluster = use_cluster(cluster_id); // The same narrowing the package page does so both agree on what "latest" installs - let (game_version, loader) = match (compat, &cluster) { + let is_modpack = self.content_type == ContentType::Modpack; + let (game_version, loader) = match (compat && !is_modpack, &cluster) { (true, Some(c)) => ( Some(c.mc_version.clone()), (self.content_type == ContentType::Mod).then_some(c.mc_loader), @@ -367,7 +369,8 @@ impl Component for InstallButton { let mut world_prompt = use_state(|| None::); // Nothing to start twice while an install is running or before versions arrive - let installing = use_installs_snapshot().is_installing(cluster_id, provider, &project_id); + let (installing, waiting) = + use_installs_snapshot().package_busy(is_modpack, cluster_id, provider, &project_id); if let Some(installed) = self.installed { let color = installed.color(); @@ -401,7 +404,7 @@ impl Component for InstallButton { .small() .height(Size::px(INSTALL_BUTTON_H)) .padding(Gaps::new_symmetric(0., 11.)) - .enabled(latest.is_some() && !installing) + .enabled(latest.is_some() && !installing && !waiting) .on_press({ let project_id = project_id.clone(); move |_| { @@ -410,6 +413,12 @@ impl Component for InstallButton { }; if is_datapack { world_prompt.set(Some(version_id)); + } else if is_modpack { + dispatch.install_modpack(ModpackSource::Provider { + provider, + project_id: project_id.clone(), + version_id, + }); } else { dispatch.install_package( cluster_id, diff --git a/packages/oneclient_app/src/view/app/browser/index/mod.rs b/packages/oneclient_app/src/view/app/browser/index/mod.rs index 1b80fec7..558d7235 100644 --- a/packages/oneclient_app/src/view/app/browser/index/mod.rs +++ b/packages/oneclient_app/src/view/app/browser/index/mod.rs @@ -67,11 +67,12 @@ const SORTS: [(SearchSort, &str); 4] = [ (SearchSort::Updated, "Updated"), ]; -const BROWSE_TYPES: [(&str, &str); 4] = [ +const BROWSE_TYPES: [(&str, &str); 5] = [ ("mod", "Mods"), ("texture", "Textures"), ("shader", "Shaders"), ("datapack", "Data packs"), + ("modpack", "Modpacks"), ]; const DATAPACK_SLUG: &str = "datapack"; @@ -163,7 +164,11 @@ impl Component for BrowserBody { let content_type = content_type_for_slug(&package_type); let store = use_browser_state_store(); - let state_key = format!("{cluster_id}:{package_type}"); + let state_key = if content_type == ContentType::Modpack { + package_type.clone() + } else { + format!("{cluster_id}:{package_type}") + }; let saved = store.peek().get(&state_key).cloned().unwrap_or_default(); let query = use_state(|| saved.query.clone()); @@ -200,7 +205,8 @@ impl Component for BrowserBody { let compat = *compatible_only.read(); let cats = selected_categories.read().clone(); - let (game_versions, loaders) = match (compat, &cluster) { + let targets_cluster = content_type != ContentType::Modpack; + let (game_versions, loaders) = match (compat && targets_cluster, &cluster) { (true, Some(c)) => { let loaders = if content_type == ContentType::Mod { vec![c.mc_loader] @@ -245,7 +251,7 @@ impl Component for BrowserBody { cluster_content_items(&use_cluster_content(cluster_id, content_type)), &bundles_with_status_items(&use_bundles_with_status(cluster_id)), ); - let installed = if content_type == ContentType::DataPack { + let installed = if matches!(content_type, ContentType::DataPack | ContentType::Modpack) { Default::default() } else { installed @@ -334,8 +340,11 @@ impl Component for BrowserBody { .spacing(18.) .child(header( &package_type, - cluster.as_ref().map(|c| c.name.clone()), - self.pick_cluster.then(|| ClusterPicker { + cluster + .as_ref() + .filter(|_| targets_cluster) + .map(|c| c.name.clone()), + (self.pick_cluster && targets_cluster).then(|| ClusterPicker { cluster_id, package_type: package_type.clone(), }), 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 c545ea34..48ed42cf 100644 --- a/packages/oneclient_app/src/view/app/browser/package/mod.rs +++ b/packages/oneclient_app/src/view/app/browser/package/mod.rs @@ -3,6 +3,7 @@ use std::collections::HashMap; use oneclient_content::packages::types::DependencyKind; use oneclient_content::packages::{ContentType, ProviderId}; +use oneclient_core::clusters::ModpackSource; use crate::components::{ScrollArea, use_shared_delete}; use crate::hooks::use_cluster; @@ -38,10 +39,20 @@ struct Installer { cluster_id: i64, provider: ProviderId, world_prompt: Option>>, + modpack: bool, } impl Installer { fn install(&self, project_id: String, version_id: String) { + if self.modpack { + self.dispatch.install_modpack(ModpackSource::Provider { + provider: self.provider, + project_id, + version_id, + }); + return; + } + match self.world_prompt { Some(mut prompt) => prompt.set(Some(version_id)), None => self.dispatch.install_package( @@ -143,11 +154,13 @@ impl Component for BrowserPackage { cluster_id, provider, world_prompt: is_datapack.then_some(world_prompt), + modpack: content_type == ContentType::Modpack, }; let cluster = use_cluster(cluster_id); let compat = *compatible_only.read(); - let (game_version, loader) = match (compat, &cluster) { + let narrows = content_type != ContentType::Modpack; + let (game_version, loader) = match (compat && narrows, &cluster) { (true, Some(c)) => ( Some(c.mc_version.clone()), (content_type == ContentType::Mod).then_some(c.mc_loader), @@ -173,7 +186,12 @@ impl Component for BrowserPackage { *versions_page.read(), ); - let installing = use_installs_snapshot().is_installing(cluster_id, provider, &project_id); + let (installing, waiting) = use_installs_snapshot().package_busy( + content_type == ContentType::Modpack, + cluster_id, + provider, + &project_id, + ); let installed = installed_map( cluster_content_items(&use_cluster_content(cluster_id, content_type)), @@ -234,7 +252,7 @@ impl Component for BrowserPackage { installer.clone(), on_remove, installed.clone(), - installing, + installing || waiting, ) .into_element(), (Some(_), _) => gallery_panel(gallery).into_element(), @@ -253,6 +271,7 @@ impl Component for BrowserPackage { confirm, installed, installing, + waiting, )) .child( rect() diff --git a/packages/oneclient_app/src/view/app/browser/package/sidebar.rs b/packages/oneclient_app/src/view/app/browser/package/sidebar.rs index 35cae9cc..72b4a706 100644 --- a/packages/oneclient_app/src/view/app/browser/package/sidebar.rs +++ b/packages/oneclient_app/src/view/app/browser/package/sidebar.rs @@ -14,6 +14,7 @@ pub(super) fn sidebar( confirm: State>, installed: Option, installing: bool, + waiting: bool, ) -> impl IntoElement { let Some(project) = project else { return rect() @@ -28,7 +29,7 @@ pub(super) fn sidebar( (Some(installed), Some(latest)) => installed.is_version(latest), _ => false, }; - let can_install = latest_version.is_some() && !have_latest && !installing; + let can_install = latest_version.is_some() && !have_latest && !installing && !waiting; rect() .vertical() 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 b88f5c64..75b7d6ca 100644 --- a/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs +++ b/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs @@ -24,6 +24,7 @@ use crate::view::app::settings::{section_header, settings_row, settings_row_disa use oneclient_core::clusters::rank_migration_sources; use super::cluster_not_found; +use super::modpack_settings::{ModpackRepairRow, ModpackUpdateRow}; use crate::hooks::use_cluster; #[derive(PartialEq)] @@ -78,6 +79,7 @@ impl Component for ClusterSettings { mc_version: cluster.mc_version.clone(), mc_loader: cluster.mc_loader, kind: cluster.kind, + modpack: cluster.linked_modpack_hash.is_some(), }, } .into_element() @@ -104,6 +106,16 @@ impl Component for ClusterSettings { Vec::new() }; + let modpack_section: Vec = if cluster.linked_modpack_hash.is_some() { + vec![ + section_header("MODPACK").into_element(), + ModpackUpdateRow { cluster_id }.into_element(), + ModpackRepairRow { cluster_id }.into_element(), + ] + } else { + Vec::new() + }; + cluster_content() .child( ScrollArea::new() @@ -176,6 +188,7 @@ impl Component for ClusterSettings { .into_element(), ) .append_children(migrate_row) + .append_children(modpack_section) .child(section_header("REPAIR")) .child(VerifyFilesRow { cluster_id }.into_element()), ) diff --git a/packages/oneclient_app/src/view/app/cluster/mod.rs b/packages/oneclient_app/src/view/app/cluster/mod.rs index 5cda715e..1cd50416 100644 --- a/packages/oneclient_app/src/view/app/cluster/mod.rs +++ b/packages/oneclient_app/src/view/app/cluster/mod.rs @@ -5,6 +5,7 @@ mod package_manager; mod cluster_settings; mod datapacks; mod folder_list; +mod modpack_settings; mod mods; mod process_logs; mod screenshots; diff --git a/packages/oneclient_app/src/view/app/cluster/modpack_settings.rs b/packages/oneclient_app/src/view/app/cluster/modpack_settings.rs new file mode 100644 index 00000000..2958f953 --- /dev/null +++ b/packages/oneclient_app/src/view/app/cluster/modpack_settings.rs @@ -0,0 +1,169 @@ +use freya::prelude::*; +use oneclient_content::modpacks::{ + ModpackRelease, ModpackUpdateStatus, check_modpack_update, cluster_modpack, +}; + +use crate::components::{Button, IconType}; +use crate::hooks::{use_dispatch, use_installs_snapshot}; +use crate::launcher::{self, off_ui}; +use crate::view::app::settings::settings_row; + +#[derive(Clone, PartialEq)] +enum UpdateCheck { + Idle, + Checking, + UpToDate, + Available { version_id: String, label: String }, + Unpublished, + Failed(String), +} + +fn load_release(cluster_id: i64, mut current: State>) { + spawn(async move { + let Ok(state) = launcher::state() else { return }; + let content = state.services.content(); + let found = off_ui(async move { cluster_modpack(cluster_id, &content).await }).await; + match found { + Ok(release) => current.set(release), + Err(err) => tracing::debug!(cluster_id, error = %err, "could not identify the modpack"), + } + }); +} + +fn run_check(cluster_id: i64, mut check: State) { + check.set(UpdateCheck::Checking); + spawn(async move { + let Ok(state) = launcher::state() else { + check.set(UpdateCheck::Idle); + return; + }; + let content = state.services.content(); + let found = off_ui(async move { check_modpack_update(cluster_id, &content).await }).await; + + check.set(match found { + Ok(ModpackUpdateStatus::Unpublished) => UpdateCheck::Unpublished, + Ok(ModpackUpdateStatus::UpToDate) => UpdateCheck::UpToDate, + Ok(ModpackUpdateStatus::Available(latest)) => UpdateCheck::Available { + version_id: latest.version_id, + label: latest.name, + }, + Err(err) => UpdateCheck::Failed(err.to_string()), + }); + }); +} + +fn update_description(current: Option<&ModpackRelease>, check: &UpdateCheck) -> String { + let installed = current.map(|release| format!("Installed: {}. ", release.name)); + let status = match check { + UpdateCheck::Idle => "Check Modrinth or CurseForge for a newer version of this pack.".to_string(), + UpdateCheck::Checking => "Checking for a newer version...".to_string(), + UpdateCheck::UpToDate => "This is the newest version for this Minecraft version.".to_string(), + UpdateCheck::Available { label, .. } => { + format!("{label} is available. Mods you removed stay removed and configs you edited are kept.") + } + UpdateCheck::Unpublished => { + "This pack is not published on Modrinth or CurseForge, so it cannot be updated from here." + .to_string() + } + UpdateCheck::Failed(err) => format!("Could not check for updates: {err}"), + }; + format!("{}{status}", installed.unwrap_or_default()) +} + +#[derive(PartialEq)] +pub struct ModpackUpdateRow { + pub cluster_id: i64, +} + +impl Component for ModpackUpdateRow { + fn render(&self) -> impl IntoElement { + let cluster_id = self.cluster_id; + let dispatch = use_dispatch(); + let check = use_state(|| UpdateCheck::Idle); + let current = use_state(|| None::); + let busy = use_installs_snapshot().is_modpack_job(cluster_id); + + use_hook(move || load_release(cluster_id, current)); + + let mut was_busy = use_state(|| busy); + if *was_busy.peek() != busy { + was_busy.set(busy); + if !busy { + let mut check = check; + check.set(UpdateCheck::Idle); + load_release(cluster_id, current); + } + } + + let state = check.read().clone(); + let description = update_description(current.read().as_ref(), &state); + + let button = match (&state, busy) { + (_, true) => Button::new() + .small() + .secondary() + .enabled(false) + .text("Working..."), + (UpdateCheck::Available { version_id, .. }, false) => { + let version_id = version_id.clone(); + Button::new() + .small() + .primary() + .on_press(move |_| dispatch.update_modpack(cluster_id, version_id.clone())) + .text("Update") + } + (UpdateCheck::Checking, false) => Button::new() + .small() + .secondary() + .enabled(false) + .text("Checking..."), + (UpdateCheck::Unpublished, false) => Button::new() + .small() + .secondary() + .enabled(false) + .text("Check for updates"), + (_, false) => Button::new() + .small() + .secondary() + .on_press(move |_| run_check(cluster_id, check)) + .text("Check for updates"), + }; + + settings_row( + IconType::DownloadCloud02, + "Modpack Updates", + description, + button, + ) + } +} + +#[derive(PartialEq)] +pub struct ModpackRepairRow { + pub cluster_id: i64, +} + +impl Component for ModpackRepairRow { + fn render(&self) -> impl IntoElement { + let cluster_id = self.cluster_id; + let dispatch = use_dispatch(); + let busy = use_installs_snapshot().is_modpack_job(cluster_id); + + let button = Button::new() + .small() + .secondary() + .enabled(!busy) + .maybe(!busy, |el| { + el.on_press(move |_| dispatch.repair_modpack(cluster_id)) + }) + .text(if busy { "Working..." } else { "Reinstall" }); + + settings_row( + IconType::RefreshCw01, + "Reinstall Missing Files", + "Download anything from the pack that is missing and reopen the manual downloads for \ + CurseForge files. Files you removed stay removed.", + button, + ) + } +} diff --git a/packages/oneclient_app/src/view/app/clusters/instance/copy.rs b/packages/oneclient_app/src/view/app/clusters/instance/copy.rs index a5aa7234..cec8a2da 100644 --- a/packages/oneclient_app/src/view/app/clusters/instance/copy.rs +++ b/packages/oneclient_app/src/view/app/clusters/instance/copy.rs @@ -1,13 +1,13 @@ use oneclient_cluster::naming::validate_instance_name; use super::data::Picks; -use super::model::{Step, TypeChoice, Wizard}; +use super::model::{ModpackOrigin, Step, TypeChoice, Wizard}; pub fn heading(picks: &Picks) -> (&'static str, String) { match picks.step { Step::Type => ( "Choose a type", - "Two ways to start. Packages can be added to either of them later.".to_string(), + "Three ways to start. Packages can be added to any of them later.".to_string(), ), Step::Loader => ( "Choose a mod loader", @@ -24,7 +24,7 @@ pub fn heading(picks: &Picks) -> (&'static str, String) { TypeChoice::OneClient => { "Only versions OneClient ships a build for are listed.".to_string() } - TypeChoice::Scratch => { + TypeChoice::Scratch | TypeChoice::Modpack => { "Every Minecraft version. Switch the release type to reach snapshots, betas and alphas." .to_string() } @@ -39,6 +39,11 @@ pub fn heading(picks: &Picks) -> (&'static str, String) { "Name the instance", "Everything here can be changed afterwards.".to_string(), ), + Step::Modpack => ( + "Add a modpack", + "The pack decides the version, the loader and the mods. The instance is named after it." + .to_string(), + ), } } @@ -50,6 +55,10 @@ pub fn footer_note(picks: &Picks) -> String { .to_string() } TypeChoice::Scratch => "Keeps its own game folder, worlds and packs.".to_string(), + TypeChoice::Modpack => { + "Keeps its own game folder, worlds and packs, set up the way the pack author made it." + .to_string() + } }, Step::Loader => match (&picks.versions.chosen, picks.loader.chosen) { (Some(version), None) => format!("Pick a loader with a build for {version}."), @@ -69,6 +78,14 @@ pub fn footer_note(picks: &Picks) -> String { } } Step::Customize => "The instance is created locally. Nothing is uploaded.".to_string(), + Step::Modpack => match picks.modpack_origin { + ModpackOrigin::Browse => { + "Installing a modpack from the browser creates its instance.".to_string() + } + ModpackOrigin::File => { + "Takes .mrpack files from Modrinth and .zip files from CurseForge.".to_string() + } + }, } } @@ -77,6 +94,7 @@ pub fn step_value(wizard: Wizard, picks: &Picks, step: Step) -> String { Step::Type => match picks.choice { TypeChoice::OneClient => "OneClient".to_string(), TypeChoice::Scratch => "From scratch".to_string(), + TypeChoice::Modpack => "Modpack".to_string(), }, Step::Loader => picks.loader_label(), Step::Version => picks @@ -92,6 +110,10 @@ pub fn step_value(wizard: Wizard, picks: &Picks, step: Step) -> String { format!("{taken} selected") } } + Step::Modpack => match picks.modpack_origin { + ModpackOrigin::Browse => "Browse".to_string(), + ModpackOrigin::File => "From a file".to_string(), + }, Step::Customize => match wizard.details.tags.read().len() { 0 => "Optional".to_string(), 1 => "1 tag".to_string(), @@ -102,7 +124,7 @@ pub fn step_value(wizard: Wizard, picks: &Picks, step: Step) -> String { pub fn is_ready(picks: &Picks) -> bool { match picks.step { - Step::Type | Step::Bundles => true, + Step::Type | Step::Bundles | Step::Modpack => true, Step::Loader => picks.loader.chosen.is_some(), Step::Version => { picks.versions.chosen.is_some() diff --git a/packages/oneclient_app/src/view/app/clusters/instance/create.rs b/packages/oneclient_app/src/view/app/clusters/instance/create.rs index 2bc67655..41858c5d 100644 --- a/packages/oneclient_app/src/view/app/clusters/instance/create.rs +++ b/packages/oneclient_app/src/view/app/clusters/instance/create.rs @@ -1,8 +1,9 @@ use std::collections::HashSet; use freya::prelude::*; +use freya::router::RouterContext; use oneclient_core::GameVersionKind; -use oneclient_core::clusters::ClusterKind; +use oneclient_core::clusters::{ClusterKind, ModpackSource}; use super::copy::{footer_note, heading, is_ready, step_value}; use super::data::{Picks, resolve}; @@ -11,7 +12,15 @@ use super::model::*; use super::rail::{Rail, RowState, rail, steps_card, version_art}; use super::shell::{Shell, shell}; use super::steps; -use crate::hooks::{ClusterAction, use_cluster_mutation}; +use crate::Route; +use crate::hooks::{ + ClusterAction, settled_or_loading, use_active_cluster_id, use_cluster_mutation, use_clusters, + use_dispatch, +}; +use crate::utils::default_cluster; + +const MODPACK_BROWSE_TYPE: &str = "modpack"; +const MODPACK_EXTENSIONS: [&str; 2] = ["mrpack", "zip"]; fn create_action(wizard: Wizard, picks: &Picks) -> Option { let mc_version = picks.versions.chosen.clone()?; @@ -51,6 +60,7 @@ fn wizard_rail(wizard: Wizard, picks: &Picks) -> Element { Some(version) => format!("{version} · {}", picks.loader_label()), None => picks.loader_label(), }, + TypeChoice::Modpack => "Modpack".to_string(), } } else { description @@ -77,8 +87,12 @@ fn wizard_rail(wizard: Wizard, picks: &Picks) -> Element { }) .collect(); - let art = version_art(picks.versions.chosen.as_deref(), picks.loader.chosen) - .picked_cover(wizard.details.cover.read().clone()); + let art = if picks.choice == TypeChoice::Modpack { + version_art(None, None) + } else { + version_art(picks.versions.chosen.as_deref(), picks.loader.chosen) + } + .picked_cover(wizard.details.cover.read().clone()); rail(Rail { art, @@ -118,8 +132,11 @@ impl Component for CreateInstanceModal { loader: use_state(|| LoaderChoice::Fabric), loader_version: use_state(|| None::), declined: use_state(|| None::>), + modpack_origin: use_state(|| ModpackOrigin::Browse), details: DetailsState::blank(), }; + let dispatch = use_dispatch(); + let browse_cluster = browse_cluster(); let picks = resolve(wizard); let action = create_action(wizard, &picks); @@ -127,6 +144,8 @@ impl Component for CreateInstanceModal { let first = picks.index == 0; let last = picks.step == Step::Customize; + let modpack_step = picks.step == Step::Modpack; + let origin = picks.modpack_origin; let index = picks.index; let mut step = wizard.step; let mut query = wizard.query; @@ -134,6 +153,7 @@ impl Component for CreateInstanceModal { let close_x = self.on_close.clone(); let close_cancel = self.on_close.clone(); let close_created = self.on_close.clone(); + let close_modpack = self.on_close.clone(); shell(Shell { rail: wizard_rail(wizard, &picks), @@ -144,7 +164,13 @@ impl Component for CreateInstanceModal { scrolls_itself: picks.step == Step::Version, note: footer_note(&picks), secondary_label: if first { "Cancel" } else { "Back" }.to_string(), - primary_label: if last { "Create instance" } else { "Next" }.to_string(), + primary_label: match (modpack_step, origin) { + (true, ModpackOrigin::Browse) => "Browse modpacks", + (true, ModpackOrigin::File) => "Choose file", + (false, _) if last => "Create instance", + (false, _) => "Next", + } + .to_string(), primary_enabled: is_ready(&picks), on_close: (move |()| close_x.call(())).into(), on_secondary: (move |()| { @@ -156,7 +182,14 @@ impl Component for CreateInstanceModal { }) .into(), on_primary: (move |()| { - if last { + if modpack_step { + start_modpack( + origin, + browse_cluster, + dispatch.clone(), + close_modpack.clone(), + ); + } else if last { if let Some(action) = action.clone() { mutation.mutate(action); close_created.call(()); @@ -170,3 +203,44 @@ impl Component for CreateInstanceModal { }) } } + +fn browse_cluster() -> i64 { + let clusters = settled_or_loading(&use_clusters()).unwrap_or_default(); + let active = *use_active_cluster_id().read(); + + default_cluster(clusters, active) + .map(|cluster| cluster.id) + .unwrap_or_default() +} + +fn start_modpack( + origin: ModpackOrigin, + cluster_id: i64, + dispatch: crate::Actions, + on_close: EventHandler<()>, +) { + match origin { + ModpackOrigin::Browse => { + on_close.call(()); + let _ = RouterContext::get().push(Route::Browser { + cluster_id, + package_type: MODPACK_BROWSE_TYPE.to_string(), + pick_cluster: true, + }); + } + ModpackOrigin::File => { + spawn(async move { + let Some(handle) = rfd::AsyncFileDialog::new() + .set_title("Choose a modpack") + .add_filter("Modpack", &MODPACK_EXTENSIONS) + .pick_file() + .await + else { + return; + }; + dispatch.install_modpack(ModpackSource::File(handle.path().to_path_buf())); + on_close.call(()); + }); + } + } +} diff --git a/packages/oneclient_app/src/view/app/clusters/instance/data.rs b/packages/oneclient_app/src/view/app/clusters/instance/data.rs index ae7fd44e..de74ce63 100644 --- a/packages/oneclient_app/src/view/app/clusters/instance/data.rs +++ b/packages/oneclient_app/src/view/app/clusters/instance/data.rs @@ -61,6 +61,7 @@ pub struct Picks { pub index: usize, pub step: Step, pub choice: TypeChoice, + pub modpack_origin: ModpackOrigin, pub kind: ClusterKind, pub name: String, pub suggested: String, @@ -264,7 +265,7 @@ fn kind_for(choice: TypeChoice, loader: Option) -> ClusterKind { match (choice, loader) { (TypeChoice::OneClient, _) => ClusterKind::OneClient, (TypeChoice::Scratch, Some(GameLoader::Vanilla) | None) => ClusterKind::Vanilla, - (TypeChoice::Scratch, Some(_)) => ClusterKind::Modded, + (TypeChoice::Scratch, Some(_)) | (TypeChoice::Modpack, _) => ClusterKind::Modded, } } @@ -337,7 +338,9 @@ pub fn resolve(w: Wizard) -> Picks { let available = settled_or_loading(&available_query); let loader = match choice { TypeChoice::OneClient => version.as_ref().and_then(|id| catalogue.loader_for(id)), - TypeChoice::Scratch => w.loader.read().resolve(available.as_deref().unwrap_or(&[])), + TypeChoice::Scratch | TypeChoice::Modpack => { + w.loader.read().resolve(available.as_deref().unwrap_or(&[])) + } }; let loader_versions = loader_versions(&use_loader_versions( @@ -371,6 +374,7 @@ pub fn resolve(w: Wizard) -> Picks { index, step, choice, + modpack_origin: *w.modpack_origin.read(), kind, name: w.details.effective_name(&suggested), suggested, diff --git a/packages/oneclient_app/src/view/app/clusters/instance/details.rs b/packages/oneclient_app/src/view/app/clusters/instance/details.rs index c300220a..713c9017 100644 --- a/packages/oneclient_app/src/view/app/clusters/instance/details.rs +++ b/packages/oneclient_app/src/view/app/clusters/instance/details.rs @@ -1,9 +1,7 @@ use std::path::PathBuf; use freya::prelude::*; -use oneclient_cluster::naming::{ - MAX_NAME_CHARS, MAX_TAG_CHARS, NameProblem, is_allowed_name_char, validate_tag, -}; +use oneclient_cluster::naming::{MAX_TAG_CHARS, NameProblem, is_allowed_name_char, validate_tag}; use crate::components::{ ART_PREVIEW_EDGE, Button, DynamicArt, GALLERY_COVER_EDGE, Icon, IconType, LocalImage, @@ -109,6 +107,7 @@ pub fn details_body( existing_cover: Option, version_art: DynamicArt, name_problem: Option, + name_limit: Option, ) -> Element { let mut touched = state.name_touched; let name = state.name; @@ -135,7 +134,7 @@ pub fn details_body( let next = validator.text(); let current = name.peek(); validator.set_valid( - within_limit(&next, ¤t, MAX_NAME_CHARS) + name_limit.is_none_or(|limit| within_limit(&next, ¤t, limit)) && no_new_forbidden(&next, ¤t), ); }), diff --git a/packages/oneclient_app/src/view/app/clusters/instance/edit.rs b/packages/oneclient_app/src/view/app/clusters/instance/edit.rs index b1f6dd47..176728a2 100644 --- a/packages/oneclient_app/src/view/app/clusters/instance/edit.rs +++ b/packages/oneclient_app/src/view/app/clusters/instance/edit.rs @@ -1,7 +1,7 @@ use std::path::PathBuf; use freya::prelude::*; -use oneclient_cluster::naming::validate_instance_name; +use oneclient_cluster::naming::{MAX_NAME_CHARS, validate_name}; use oneclient_common::domain::GameLoader; use oneclient_core::clusters::ClusterKind; @@ -20,6 +20,7 @@ pub struct InstanceFacts { pub mc_version: String, pub mc_loader: GameLoader, pub kind: ClusterKind, + pub modpack: bool, } #[derive(PartialEq)] @@ -37,6 +38,10 @@ impl EditInstanceModal { } } +fn name_limit(modpack: bool) -> Option { + (!modpack).then_some(MAX_NAME_CHARS) +} + fn kind_label(kind: ClusterKind) -> &'static str { match kind { ClusterKind::OneClient => "OneClient", @@ -63,7 +68,7 @@ impl Component for EditInstanceModal { let name_problem = if typed == self.facts.name { None } else { - validate_instance_name(&typed).err() + validate_name(&typed, name_limit(self.facts.modpack)).err() }; let ready = !typed.is_empty() && name_problem.is_none(); @@ -122,6 +127,7 @@ impl Component for EditInstanceModal { existing, version_art(Some(&self.facts.mc_version), Some(self.facts.mc_loader)), name_problem, + name_limit(self.facts.modpack), ), scrolls_itself: false, note: "Changes apply straight away.".to_string(), diff --git a/packages/oneclient_app/src/view/app/clusters/instance/model.rs b/packages/oneclient_app/src/view/app/clusters/instance/model.rs index dbbbdcd7..c908490b 100644 --- a/packages/oneclient_app/src/view/app/clusters/instance/model.rs +++ b/packages/oneclient_app/src/view/app/clusters/instance/model.rs @@ -27,6 +27,13 @@ pub fn kind_bit(kind: GameVersionKind) -> u8 { pub enum TypeChoice { OneClient, Scratch, + Modpack, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum ModpackOrigin { + Browse, + File, } #[derive(Clone, Copy, PartialEq, Eq)] @@ -110,6 +117,7 @@ pub enum Step { Version, Bundles, Customize, + Modpack, } impl Step { @@ -120,17 +128,20 @@ impl Step { Self::Version => "Version", Self::Bundles => "Bundles", Self::Customize => "Details", + Self::Modpack => "Source", } } } const ONECLIENT_STEPS: [Step; 4] = [Step::Type, Step::Version, Step::Bundles, Step::Customize]; const SCRATCH_STEPS: [Step; 4] = [Step::Type, Step::Version, Step::Loader, Step::Customize]; +const MODPACK_STEPS: [Step; 2] = [Step::Type, Step::Modpack]; pub fn step_order(choice: TypeChoice) -> &'static [Step] { match choice { TypeChoice::OneClient => &ONECLIENT_STEPS, TypeChoice::Scratch => &SCRATCH_STEPS, + TypeChoice::Modpack => &MODPACK_STEPS, } } @@ -189,5 +200,6 @@ pub struct Wizard { pub loader: State, pub loader_version: State>, pub declined: State>>, + pub modpack_origin: State, pub details: DetailsState, } diff --git a/packages/oneclient_app/src/view/app/clusters/instance/steps.rs b/packages/oneclient_app/src/view/app/clusters/instance/steps.rs index 6685548c..7aa0cf4f 100644 --- a/packages/oneclient_app/src/view/app/clusters/instance/steps.rs +++ b/packages/oneclient_app/src/view/app/clusters/instance/steps.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; use freya::prelude::*; -use oneclient_cluster::naming::validate_instance_name; +use oneclient_cluster::naming::{MAX_NAME_CHARS, validate_instance_name}; use oneclient_core::BundleArchive; use super::cards::{ @@ -11,7 +11,7 @@ use super::cards::{ }; use super::data::Picks; use super::details::details_body; -use super::model::{LoaderChoice, Step, TypeChoice, Wizard}; +use super::model::{LoaderChoice, ModpackOrigin, Step, TypeChoice, Wizard}; use super::model::{VERSION_KINDS, kind_bit}; use super::rail::version_art; use crate::components::{ @@ -27,6 +27,7 @@ pub fn body(wizard: Wizard, picks: &Picks) -> Element { Step::Loader => ("step-loader", loader_step(wizard, picks)), Step::Version => ("step-version", version_step(wizard, picks)), Step::Bundles => ("step-bundles", bundles_step(wizard, picks)), + Step::Modpack => ("step-modpack", modpack_step(wizard, picks)), Step::Customize => ( "step-details", details_body( @@ -35,6 +36,7 @@ pub fn body(wizard: Wizard, picks: &Picks) -> Element { None, version_art(picks.versions.chosen.as_deref(), picks.loader.chosen), validate_instance_name(&picks.name).err(), + Some(MAX_NAME_CHARS), ), ), }; @@ -69,6 +71,14 @@ fn type_step(mut wizard: Wizard, picks: &Picks) -> Element { "Pick a loader and a version and build the instance yourself. Packages can be added once it exists.", "Fabric, Forge, NeoForge, or no loader at all. Keeps its own game folder.", ), + ( + TypeChoice::Modpack, + IconType::DownloadCloud02, + "Modpack", + None, + "Install a ready-made pack from Modrinth or CurseForge, or one you already downloaded. Its version, loader, mods and configs come with it.", + "Modrinth .mrpack or CurseForge .zip. Keeps its own game folder.", + ), ]; rect() @@ -98,6 +108,43 @@ fn type_step(mut wizard: Wizard, picks: &Picks) -> Element { .into_element() } +fn modpack_step(wizard: Wizard, picks: &Picks) -> Element { + let cards = [ + ( + ModpackOrigin::Browse, + IconType::Globe01, + "Browse modpacks", + "Search Modrinth and CurseForge. Installing a pack from there creates its instance.", + "Opens the browser on the Modpacks tab.", + ), + ( + ModpackOrigin::File, + IconType::File02, + "Import a file", + "Use a modpack you already downloaded or got from someone else.", + "Modrinth .mrpack or CurseForge .zip.", + ), + ]; + + rect() + .vertical() + .width(Size::fill()) + .spacing(10.) + .children(cards.into_iter().map(|(origin, icon, title, blurb, meta)| { + let mut chosen = wizard.modpack_origin; + wide_card(WideCard { + icon, + title: title.to_string(), + badge: None, + blurb: blurb.to_string(), + meta: meta.to_string(), + selected: picks.modpack_origin == origin, + on_press: (move |()| chosen.set(origin)).into(), + }) + })) + .into_element() +} + fn select_loader(mut wizard: Wizard, choice: LoaderChoice) { wizard.loader.set(choice); wizard.loader_version.set(None); diff --git a/packages/oneclient_app/src/view/app/clusters/page.rs b/packages/oneclient_app/src/view/app/clusters/page.rs index d03b5c7e..bdca731f 100644 --- a/packages/oneclient_app/src/view/app/clusters/page.rs +++ b/packages/oneclient_app/src/view/app/clusters/page.rs @@ -539,6 +539,7 @@ fn filter_menu( fn search_input(query: State, width: Size) -> Element { TextInput::new(query) .width(width) + .height(Size::px(34.)) .placeholder("Search instances") .leading( Icon::new(IconType::SearchMd) diff --git a/packages/oneclient_cluster/src/cluster.rs b/packages/oneclient_cluster/src/cluster.rs index 3fbe1744..184378e0 100644 --- a/packages/oneclient_cluster/src/cluster.rs +++ b/packages/oneclient_cluster/src/cluster.rs @@ -33,6 +33,8 @@ pub async fn remove_mods_link(folder_name: &str) { } } +pub const MODPACK_ICON_FILE: &str = ".oneclient/modpack-icon.png"; + pub fn encode_tags(tags: &[String]) -> String { serde_json::to_string(tags).unwrap_or_else(|_| "[]".to_string()) } @@ -135,6 +137,11 @@ impl Cluster { content_type.is_global() && !self.is_isolated() } + pub fn modpack_icon_file(&self) -> Option { + self.linked_modpack_hash.as_ref()?; + Some(self.dir().ok()?.join(MODPACK_ICON_FILE)) + } + pub fn cover_file(&self) -> Option { let cover = self.cover_path.as_deref()?; let mut parts = std::path::Path::new(cover).components(); diff --git a/packages/oneclient_cluster/src/manager.rs b/packages/oneclient_cluster/src/manager.rs index e7c9f062..e63f1874 100644 --- a/packages/oneclient_cluster/src/manager.rs +++ b/packages/oneclient_cluster/src/manager.rs @@ -129,14 +129,15 @@ impl ClusterManager { options: CreateClusterOptions, ) -> ClusterResult { if options.user_created { - crate::naming::validate_instance_name(&options.name) + let limit = (!options.modpack).then_some(crate::naming::MAX_NAME_CHARS); + crate::naming::validate_name(&options.name, limit) .map_err(ClusterError::InvalidName)?; for tag in &options.tags { crate::naming::validate_tag(tag).map_err(ClusterError::InvalidName)?; } } - let folder_stem = Self::sanitize_name(&options.name); + let folder_stem = Self::sanitize_name(&cap_folder_stem(&options.name)); if folder_stem.is_empty() { return Err(ClusterError::EmptyName); } @@ -188,7 +189,11 @@ impl ClusterManager { if let Some(raw) = update.name.as_deref() && raw.trim() != existing.name { - crate::naming::validate_instance_name(raw).map_err(ClusterError::InvalidName)?; + let limit = existing + .linked_modpack_hash + .is_none() + .then_some(crate::naming::MAX_NAME_CHARS); + crate::naming::validate_name(raw, limit).map_err(ClusterError::InvalidName)?; } for tag in update.tags.iter().flatten() { if !existing.tags.contains(tag) { @@ -466,6 +471,12 @@ impl ClusterManager { Ok(()) } + #[tracing::instrument(level = "debug", skip(self))] + pub async fn mark_played(&self, cluster_id: ClusterId) -> ClusterResult<()> { + cluster_dao::touch_last_played(&self.db, cluster_id).await?; + Ok(()) + } + #[tracing::instrument(level = "debug", skip(self))] pub async fn add_playtime( &self, @@ -658,6 +669,13 @@ async fn ensure_profile_exists(pool: &oneclient_db::DbPool, name: &str) -> Clust } #[tracing::instrument(level = "debug")] +fn cap_folder_stem(name: &str) -> String { + name.trim() + .chars() + .take(crate::naming::MAX_FOLDER_CHARS) + .collect() +} + async fn resolve_unique_folder_name(name: &str) -> ClusterResult { let cluster_dir = oneclient_common::paths::clusters_dir()?; let mut folder_name = name.to_string(); @@ -695,7 +713,19 @@ async fn ensure_content_dirs(cluster_path: &std::path::Path) -> ClusterResult<() #[cfg(test)] mod tests { - use super::{COVER_MAX_EDGE, is_cover_file, shrink_cover}; + use super::{COVER_MAX_EDGE, ClusterManager, cap_folder_stem, is_cover_file, shrink_cover}; + + #[test] + fn a_long_modpack_name_gets_a_short_folder() { + let name = "All the Mods 10 - To the Sky Community Edition Extended"; + let folder = ClusterManager::sanitize_name(&cap_folder_stem(name)); + + assert_eq!(folder, "All the Mods 10 - To the Sky Community E"); + assert_eq!( + ClusterManager::sanitize_name(&cap_folder_stem("Short Pack")), + "Short Pack" + ); + } fn png(width: u32, height: u32) -> Vec { let mut out = Vec::new(); diff --git a/packages/oneclient_cluster/src/naming.rs b/packages/oneclient_cluster/src/naming.rs index d0104161..017ef277 100644 --- a/packages/oneclient_cluster/src/naming.rs +++ b/packages/oneclient_cluster/src/naming.rs @@ -2,6 +2,7 @@ use crate::manager::ClusterManager; pub const MAX_NAME_CHARS: usize = 20; pub const MAX_TAG_CHARS: usize = 20; +pub const MAX_FOLDER_CHARS: usize = 40; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum NameProblem { @@ -35,11 +36,19 @@ pub fn is_allowed_name_char(c: char) -> bool { } pub fn validate_instance_name(name: &str) -> Result<(), NameProblem> { + validate_name(name, Some(MAX_NAME_CHARS)) +} + +pub fn validate_modpack_instance_name(name: &str) -> Result<(), NameProblem> { + validate_name(name, None) +} + +pub fn validate_name(name: &str, max_chars: Option) -> Result<(), NameProblem> { let name = name.trim(); if name.is_empty() { return Err(NameProblem::Empty); } - if name.chars().count() > MAX_NAME_CHARS { + if max_chars.is_some_and(|max| name.chars().count() > max) { return Err(NameProblem::TooLong); } if !name.chars().all(is_allowed_name_char) { @@ -96,6 +105,14 @@ mod tests { Err(NameProblem::TooLong) ); assert_eq!(validate_instance_name("abcdefghijklmnopqrst"), Ok(())); + assert_eq!( + validate_modpack_instance_name("All the Mods 10 - To the Sky"), + Ok(()) + ); + assert_eq!( + validate_modpack_instance_name("my:world"), + Err(NameProblem::ForbiddenCharacters) + ); } #[test] diff --git a/packages/oneclient_cluster/src/options.rs b/packages/oneclient_cluster/src/options.rs index 820e6f1c..2aacd763 100644 --- a/packages/oneclient_cluster/src/options.rs +++ b/packages/oneclient_cluster/src/options.rs @@ -13,6 +13,7 @@ pub struct CreateClusterOptions { pub mem_max: Option, pub kind: ClusterKind, pub user_created: bool, + pub modpack: bool, pub description: Option, pub tags: Vec, } @@ -31,6 +32,7 @@ impl CreateClusterOptions { mem_max: None, kind: ClusterKind::OneClient, user_created: false, + modpack: false, description: None, tags: Vec::new(), } @@ -56,6 +58,11 @@ impl CreateClusterOptions { self } + pub fn modpack(mut self, modpack: bool) -> Self { + self.modpack = modpack; + self + } + pub fn description(mut self, description: impl Into) -> Self { self.description = Some(description.into()); self diff --git a/packages/oneclient_content/src/bundles/install.rs b/packages/oneclient_content/src/bundles/install.rs index ebbe0d81..42549abf 100644 --- a/packages/oneclient_content/src/bundles/install.rs +++ b/packages/oneclient_content/src/bundles/install.rs @@ -30,7 +30,7 @@ pub fn effective_enabled(file: &BundleFile, user_override: Option) } } -fn find_override( +pub(crate) fn find_override( overrides: &[oneclient_db::models::ClusterBundleOverrideRow], bundle_name: &str, package_id: &str, @@ -139,7 +139,7 @@ pub async fn install_package_from_bundle( } #[tracing::instrument(level = "debug", skip(ext, cluster, child, ctx), fields(file = %ext.name))] -async fn install_external( +pub(crate) async fn install_external( ext: &ExternalFile, cluster: &ClusterRow, skip_compatibility: bool, @@ -428,6 +428,36 @@ pub async fn install_enabled_bundle_files( "installing enabled bundle files" ); + let results = install_bundle_files( + to_install, + cluster_id, + &bundle_name, + skip_compatibility, + progress, + ctx, + ) + .await; + + for (file, result) in results { + match result { + Ok(hash) => installed.push(hash), + Err(err) => { + tracing::warn!(file = %file.display_name(), error = %err, "failed to install bundle file"); + } + } + } + + Ok(installed) +} + +pub(crate) async fn install_bundle_files( + to_install: Vec, + cluster_id: i64, + bundle_name: &str, + skip_compatibility: bool, + progress: Option<&GroupedProgressSession>, + ctx: &ContentCtx, +) -> Vec<(BundleFile, ContentResult)> { if let Some(p) = progress { let reserved_bytes: u64 = to_install.iter().map(|f| f.size.max(1)).sum(); p.expect( @@ -437,8 +467,7 @@ pub async fn install_enabled_bundle_files( ); } - let bundle_name = &bundle_name; - let results = futures_util::stream::iter(to_install.into_iter().map(|file| async move { + futures_util::stream::iter(to_install.into_iter().map(|file| async move { let child = progress.map(|p| { let c = p.child( format!("Mod {}", file.display_name()), @@ -463,32 +492,21 @@ pub async fn install_enabled_bundle_files( child.set_phase(TaskPhase::Installing); child.finish(); } - (file.display_name(), result) + (file, result) })) .buffer_unordered(BUNDLE_INSTALL_CONCURRENCY) .collect::>() - .await; - - for (name, result) in results { - match result { - Ok(hash) => installed.push(hash), - Err(err) => { - tracing::warn!(file = %name, error = %err, "failed to install bundle file"); - } - } - } - - Ok(installed) + .await } -struct PresentContent { +pub(crate) struct PresentContent { projects: std::collections::HashSet, hashes: std::collections::HashSet, tracked_ids: std::collections::HashSet, } impl PresentContent { - async fn load(cluster_id: i64, ctx: &ContentCtx) -> ContentResult { + pub(crate) async fn load(cluster_id: i64, ctx: &ContentCtx) -> ContentResult { let linked = PackageStore::list_linked_artifacts(cluster_id, ctx).await?; let tracked_ids = bundle_dao::list_bundle_tracked(&ctx.db, cluster_id) .await? @@ -506,7 +524,11 @@ impl PresentContent { }) } - fn contains(&self, file: &BundleFile) -> bool { + pub(crate) fn has_hash(&self, hash: &str) -> bool { + self.hashes.contains(hash) + } + + pub(crate) fn contains(&self, file: &BundleFile) -> bool { match &file.kind { BundleFileKind::Managed { project_id, .. } => self.projects.contains(project_id), BundleFileKind::External { file: ext, id, .. } => { diff --git a/packages/oneclient_content/src/bundles/mod.rs b/packages/oneclient_content/src/bundles/mod.rs index 4977d23b..26ffc209 100644 --- a/packages/oneclient_content/src/bundles/mod.rs +++ b/packages/oneclient_content/src/bundles/mod.rs @@ -1,10 +1,10 @@ mod error; -mod install; +pub(crate) mod install; mod manager; mod manifest; mod optional; -mod overrides; -mod polymrpack; +pub(crate) mod overrides; +pub(crate) mod polymrpack; mod types; mod updates; diff --git a/packages/oneclient_content/src/bundles/overrides.rs b/packages/oneclient_content/src/bundles/overrides.rs index 1a7dae13..a3fd2e16 100644 --- a/packages/oneclient_content/src/bundles/overrides.rs +++ b/packages/oneclient_content/src/bundles/overrides.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize}; use crate::error::ContentResult; use oneclient_events::EventBus; -use polyio::{sha1_bytes, sha1_file}; +use polyio::{ZipEntryCursor, sha1_bytes, sha1_file}; const ALWAYS_UPDATE_GLOBS: &[&str] = &["config/fabric_loader_dependencies.json"]; @@ -71,20 +71,98 @@ async fn sync_bundle_overrides_at( root: &Path, events: Option<&EventBus>, ) -> ContentResult { - let entries = - polyio::read_zip_file_entries(archive_path, |name| name.starts_with(OVERRIDES_PREFIX)) - .await?; + sync_layered_overrides( + archive_path, + bundle_name, + root, + &[OVERRIDES_PREFIX], + &|_| true, + events, + ) + .await +} + +pub(crate) struct OverrideLayers { + cursor: ZipEntryCursor, + entries: Vec<(String, String)>, +} + +impl OverrideLayers { + pub(crate) async fn open(archive_path: &Path, prefixes: &[&str]) -> ContentResult { + let layer_of = |name: &str| prefixes.iter().position(|prefix| name.starts_with(prefix)); + let cursor = ZipEntryCursor::open(archive_path, |name| layer_of(name).is_some()).await?; + + let mut winners: HashMap = HashMap::new(); + for name in cursor.names() { + let Some(layer) = layer_of(name) else { + continue; + }; + let rel = &name[prefixes[layer].len()..]; + if rel.is_empty() { + continue; + } + let replaces = winners + .get(rel) + .is_none_or(|(current, _)| layer >= *current); + if replaces { + winners.insert(rel.to_string(), (layer, name.clone())); + } + } + + let mut entries: Vec<(String, String)> = winners + .into_iter() + .map(|(rel, (_, name))| (rel, name)) + .collect(); + entries.sort(); + + Ok(Self { cursor, entries }) + } + + pub(crate) fn entries(&self) -> &[(String, String)] { + &self.entries + } + + pub(crate) async fn read(&mut self, entry: &str) -> Option> { + match self.cursor.read(entry).await { + Ok(bytes) => Some(bytes), + Err(err) => { + tracing::warn!(entry, error = %err, "skipping an override that could not be read"); + None + } + } + } +} + +#[tracing::instrument(level = "debug", skip(keep, events))] +pub(crate) async fn sync_layered_overrides( + archive_path: &Path, + bundle_name: &str, + root: &Path, + prefixes: &[&str], + keep: &(dyn Fn(&str) -> bool + Sync), + events: Option<&EventBus>, +) -> ContentResult { + let mut layers = OverrideLayers::open(archive_path, prefixes).await?; + let entries: Vec<(String, String)> = layers + .entries() + .iter() + .filter(|(rel, _)| keep(rel)) + .cloned() + .collect(); let mut lock = OverrideLock::load(root).await; let previous = lock.bundles.remove(bundle_name).unwrap_or_default(); let mut next: HashMap = HashMap::new(); let mut report = OverrideSyncReport::default(); - for (name, bytes) in entries { - let rel = name.trim_start_matches(OVERRIDES_PREFIX); - if rel.is_empty() { + for (rel, entry) in entries { + let rel = rel.as_str(); + let Some(bytes) = layers.read(&entry).await else { + if let Some(base) = previous.get(rel) { + next.insert(rel.to_string(), base.clone()); + } continue; - } + }; let new_sha1 = sha1_bytes(&bytes); let dest = root.join(polyio::sanitize_path(rel)); @@ -166,6 +244,42 @@ async fn sync_bundle_overrides_at( Ok(report) } +pub(crate) async fn sync_file_lock( + root: &Path, + key: &str, + written: HashMap, + listed: &std::collections::HashSet, +) -> Vec { + let mut lock = OverrideLock::load(root).await; + let previous = lock.bundles.remove(key).unwrap_or_default(); + let mut next = written; + let mut deleted = Vec::new(); + + for (rel, base_sha1) in previous { + if next.contains_key(&rel) { + continue; + } + if listed.contains(&rel) { + next.insert(rel, base_sha1); + continue; + } + + let dest = root.join(polyio::sanitize_path(&rel)); + if current_sha1(&dest).await.as_deref() == Some(base_sha1.as_str()) + && polyio::remove_file(&dest).await.is_ok() + { + deleted.push(rel); + } + } + + lock.bundles.insert(key.to_string(), next); + if let Err(err) = lock.save(root).await { + tracing::warn!(error = %err, "failed to persist the file lock"); + } + + deleted +} + async fn write_override(dest: &Path, bytes: &[u8], rel: &str) -> bool { if let Some(parent) = dest.parent() { polyio::create_dir_all(parent).await.ok(); @@ -316,6 +430,115 @@ mod tests { .unwrap() } + #[tokio::test] + async fn a_later_layer_wins_over_an_earlier_one() { + let root = polyio::testing::ScratchDir::new("layers"); + let zip = root.join("pack.mrpack"); + write_bundle( + &zip, + &[ + ("client-overrides/options.txt", b"client"), + ("overrides/options.txt", b"common"), + ("overrides/config/a.toml", b"alpha"), + ("server-overrides/server.properties", b"server"), + ], + ) + .await; + + let report = sync_layered_overrides( + &zip, + "test-pack", + root.path(), + &["overrides/", "client-overrides/"], + &|_| true, + None, + ) + .await + .unwrap(); + + assert_eq!(report.written.len(), 2); + assert_eq!(read(&root.join("options.txt")).await.unwrap(), b"client"); + assert_eq!(read(&root.join("config/a.toml")).await.unwrap(), b"alpha"); + assert!(read(&root.join("server.properties")).await.is_none()); + } + + #[tokio::test] + async fn entries_the_filter_rejects_are_left_alone() { + let root = polyio::testing::ScratchDir::new("keep-filter"); + let zip = root.join("pack.mrpack"); + write_bundle( + &zip, + &[ + ("overrides/mods/bundled.jar", b"jar"), + ("overrides/config/a.toml", b"alpha"), + ], + ) + .await; + + let report = sync_layered_overrides( + &zip, + "test-pack", + root.path(), + &["overrides/"], + &|rel| !rel.starts_with("mods/"), + None, + ) + .await + .unwrap(); + + assert_eq!(report.written, vec!["config/a.toml".to_string()]); + assert!(read(&root.join("mods/bundled.jar")).await.is_none()); + } + + #[tokio::test] + async fn a_file_dropped_from_the_lock_is_deleted_only_when_untouched() { + let root = polyio::testing::ScratchDir::new("file-lock"); + polyio::write(root.join("config/kept.json"), b"mine") + .await + .unwrap(); + polyio::write(root.join("config/gone.json"), b"pack") + .await + .unwrap(); + polyio::write(root.join("config/edited.json"), b"edited") + .await + .unwrap(); + + let first: HashMap = [ + ("config/kept.json", b"mine".as_slice()), + ("config/gone.json", b"pack".as_slice()), + ("config/edited.json", b"pack".as_slice()), + ] + .into_iter() + .map(|(rel, bytes)| (rel.to_string(), sha1_bytes(bytes))) + .collect(); + sync_file_lock(root.path(), "files", first, &Default::default()).await; + + let listed = ["config/kept.json".to_string()].into_iter().collect(); + let deleted = sync_file_lock(root.path(), "files", HashMap::new(), &listed).await; + + assert_eq!(deleted, vec!["config/gone.json".to_string()]); + assert!(read(&root.join("config/kept.json")).await.is_some()); + assert!(read(&root.join("config/edited.json")).await.is_some()); + } + + #[tokio::test] + async fn a_large_override_is_not_skipped() { + let root = polyio::testing::ScratchDir::new("large-override"); + let zip = root.join("pack.mrpack"); + let big = vec![7u8; 17 * 1024 * 1024]; + write_bundle(&zip, &[("overrides/resourcepacks/big.zip", big.as_slice())]).await; + + let report = sync(&zip, root.path()).await; + + assert_eq!(report.written, vec!["resourcepacks/big.zip".to_string()]); + assert_eq!( + read(&root.join("resourcepacks/big.zip")) + .await + .map(|bytes| bytes.len()), + Some(big.len()) + ); + } + #[tokio::test] async fn fresh_install_writes_all() { let root = polyio::testing::ScratchDir::new("fresh"); diff --git a/packages/oneclient_content/src/bundles/polymrpack.rs b/packages/oneclient_content/src/bundles/polymrpack.rs index ffd7117c..c51b2361 100644 --- a/packages/oneclient_content/src/bundles/polymrpack.rs +++ b/packages/oneclient_content/src/bundles/polymrpack.rs @@ -123,65 +123,65 @@ fn parse_manifest_bytes(bytes: &[u8]) -> ContentResult { }) } -fn parse_dependencies( +pub(crate) struct PackDependencies { + pub(crate) mc_version: Option, + pub(crate) loader: Option<(GameLoader, String)>, +} + +pub(crate) fn scan_dependencies( deps: &HashMap, -) -> ContentResult<(String, GameLoader, String)> { - let mut mc_version = None; - let mut loader = None; - let mut loader_version = None; + accept: impl Fn(GameLoader) -> bool, +) -> PackDependencies { + let mut scanned = PackDependencies { + mc_version: None, + loader: None, + }; for (key, value) in deps { let normalized = key.to_lowercase().replace(['_', '.', ' ', '-'], ""); if normalized == "minecraft" { - mc_version = Some(value.clone()); - } else if let Ok(parsed) = GameLoader::from_str(&normalized) { - loader = Some(parsed); - loader_version = Some(value.clone()); + scanned.mc_version = Some(value.clone()); + } else if let Ok(parsed) = GameLoader::from_str(&normalized) + && accept(parsed) + { + scanned.loader = Some((parsed, value.clone())); } } + scanned +} + +fn parse_dependencies( + deps: &HashMap, +) -> ContentResult<(String, GameLoader, String)> { + let scanned = scan_dependencies(deps, |_| true); + let (loader, loader_version) = scanned.loader.ok_or(BundleError::InvalidManifest)?; + Ok(( - mc_version.ok_or(BundleError::InvalidManifest)?, - loader.ok_or(BundleError::InvalidManifest)?, - loader_version.ok_or(BundleError::InvalidManifest)?, + scanned.mc_version.ok_or(BundleError::InvalidManifest)?, + loader, + loader_version, )) } fn parse_bundle_file(file: &PolyMrpackFile) -> Option { - if let Some(url) = file - .downloads - .iter() - .find(|url| url.starts_with(MODRINTH_CDN_PREFIX)) - { - let paths = url[MODRINTH_CDN_PREFIX.len()..] - .split('/') - .collect::>(); - if paths.len() >= 4 { - return Some(BundleFile { - enabled: file.enabled, - hidden: file.hidden, - path: file.path.clone(), - size: file.file_size, - file_type: file.file_type.unwrap_or_default(), - kind: BundleFileKind::Managed { - provider: ProviderId::Modrinth, - project_id: paths[0].to_string(), - version_id: paths[2].to_string(), - sha1: file.hashes.sha1.to_ascii_lowercase(), - }, - }); - } - tracing::error!("invalid modrinth file URL in bundle: '{url}'"); - return None; - } + let mut kind = mrpack_file_kind( + &file.path, + &file.downloads, + &file.hashes.sha1, + file.file_size, + )?; - let download_url = file.downloads.first().cloned()?; - let file_name = file - .path - .split('/') - .next_back() - .unwrap_or(&file.path) - .to_string(); + if let BundleFileKind::External { id, meta, .. } = &mut kind { + *id = non_blank(file.id.as_deref()).map(|id| { + if id.starts_with(EXTERNAL_ID_PREFIX) { + id + } else { + format!("{EXTERNAL_ID_PREFIX}{id}") + } + }); + *meta = file.overrides.as_ref().and_then(external_meta); + } Some(BundleFile { enabled: file.enabled, @@ -189,23 +189,50 @@ fn parse_bundle_file(file: &PolyMrpackFile) -> Option { path: file.path.clone(), size: file.file_size, file_type: file.file_type.unwrap_or_default(), - kind: BundleFileKind::External { - file: ExternalFile { - name: file_name, - url: download_url, - sha1: file.hashes.sha1.to_ascii_lowercase(), - size: file.file_size, - content_type: content_type_from_bundle_path(&file.path), - }, - id: non_blank(file.id.as_deref()).map(|id| { - if id.starts_with(EXTERNAL_ID_PREFIX) { - id - } else { - format!("{EXTERNAL_ID_PREFIX}{id}") - } - }), - meta: file.overrides.as_ref().and_then(external_meta), + kind, + }) +} + +pub(crate) fn mrpack_file_kind( + path: &str, + downloads: &[String], + sha1: &str, + size: u64, +) -> Option { + let sha1 = sha1.to_ascii_lowercase(); + + if let Some(url) = downloads + .iter() + .find(|url| url.starts_with(MODRINTH_CDN_PREFIX)) + { + let paths = url[MODRINTH_CDN_PREFIX.len()..] + .split('/') + .collect::>(); + if paths.len() >= 4 { + return Some(BundleFileKind::Managed { + provider: ProviderId::Modrinth, + project_id: paths[0].to_string(), + version_id: paths[2].to_string(), + sha1, + }); + } + tracing::error!("invalid modrinth file URL in bundle: '{url}'"); + return None; + } + + let download_url = downloads.first().cloned()?; + let file_name = path.split('/').next_back().unwrap_or(path).to_string(); + + Some(BundleFileKind::External { + file: ExternalFile { + name: file_name, + url: download_url, + sha1, + size, + content_type: content_type_from_bundle_path(path), }, + id: None, + meta: None, }) } diff --git a/packages/oneclient_content/src/lib.rs b/packages/oneclient_content/src/lib.rs index 5e9ca7a6..1df49b0d 100644 --- a/packages/oneclient_content/src/lib.rs +++ b/packages/oneclient_content/src/lib.rs @@ -1,5 +1,6 @@ #![recursion_limit = "256"] pub mod bundles; +pub mod modpacks; pub mod packages; mod ctx; diff --git a/packages/oneclient_content/src/modpacks/checker.rs b/packages/oneclient_content/src/modpacks/checker.rs new file mode 100644 index 00000000..fbfd42b5 --- /dev/null +++ b/packages/oneclient_content/src/modpacks/checker.rs @@ -0,0 +1,307 @@ +use std::collections::HashSet; +use std::path::Path; + +use serde::Deserialize; + +use super::curseforge::{FileRequest, resolve_requests}; +use super::{ + ModpackContents, ModpackManifest, file_name_of, file_sha1, tracked_content_type, tracked_folder, +}; +use crate::bundles::overrides::OverrideLayers; +use crate::ctx::ContentCtx; +use oneclient_common::domain::ContentType; + +const CHECKER_CONFIG: &str = "config/missing_mods_checker.json"; +const CURSEFORGE_HOST: &str = "curseforge.com"; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CheckerEntry { + #[serde(default)] + display_name: String, + #[serde(default)] + url: String, + #[serde(default)] + destination: String, +} + +pub(super) async fn add_checker_mods( + archive_path: &Path, + manifest: &mut ModpackManifest, + ctx: &ContentCtx, +) { + let Some(bytes) = checker_config(archive_path, &manifest.override_prefixes).await else { + return; + }; + + let requests = checker_requests(&bytes); + if requests.is_empty() { + return; + } + + match resolve_requests(&requests, ctx).await { + Ok(resolved) => { + tracing::info!( + listed = requests.len(), + "adding the curseforge mods the pack's missing mods checker asks for" + ); + let bundled = bundled_file_names(archive_path, &manifest.override_prefixes).await; + merge(manifest, resolved, &bundled); + } + Err(err) => { + tracing::warn!( + error = %err, + "could not resolve the missing mods checker list; the game will ask for them instead" + ); + } + } +} + +async fn checker_config(archive_path: &Path, prefixes: &[String]) -> Option> { + for prefix in prefixes.iter().rev() { + match super::zip_entry(archive_path, &format!("{prefix}{CHECKER_CONFIG}")).await { + Ok(Some(bytes)) => return Some(bytes), + Ok(None) => {} + Err(err) => { + tracing::debug!(error = %err, "could not read the missing mods checker config"); + return None; + } + } + } + None +} + +fn checker_requests(bytes: &[u8]) -> Vec { + let entries: Vec = match serde_json::from_slice(bytes) { + Ok(entries) => entries, + Err(err) => { + tracing::warn!(error = %err, "the missing mods checker config is not a list of mods"); + return Vec::new(); + } + }; + + let mut seen = HashSet::new(); + entries + .into_iter() + .filter_map(|entry| { + let file_id = curseforge_file_id(&entry.url)?; + seen.insert(file_id).then(|| FileRequest { + file_id, + project_id: None, + required: true, + label: Some(entry.display_name).filter(|name| !name.trim().is_empty()), + destination: destination(&entry.destination), + }) + }) + .collect() +} + +fn destination(folder: &str) -> Option { + tracked_folder(folder.trim_matches('/')) +} + +fn curseforge_file_id(url: &str) -> Option { + let parsed = url::Url::parse(url).ok()?; + let host = parsed.host_str()?; + if host != CURSEFORGE_HOST && !host.ends_with(&format!(".{CURSEFORGE_HOST}")) { + return None; + } + + let segments: Vec<&str> = parsed.path_segments()?.collect(); + segments + .windows(2) + .find(|pair| matches!(pair[0], "download" | "files")) + .and_then(|pair| pair[1].parse().ok()) +} + +fn merge( + manifest: &mut ModpackManifest, + resolved: ModpackContents, + bundled_names: &HashSet, +) { + let contents = &mut manifest.contents; + let mut ids = HashSet::new(); + let mut hashes = HashSet::new(); + let mut names: HashSet = bundled_names.clone(); + + let mut remember = |id: String, sha1: &str, path: &str| { + let name = file_name_of(path); + let known = ids.contains(&id) || hashes.contains(sha1) || names.contains(&name); + ids.insert(id); + hashes.insert(sha1.to_string()); + names.insert(name); + !known + }; + + for file in &contents.files { + remember(file.kind.package_id(), file_sha1(file), &file.path); + } + for file in &contents.blocked { + remember(file.project_id.clone(), &file.sha1, &file.file_name); + } + + for file in resolved.files { + if remember(file.kind.package_id(), file_sha1(&file), &file.path) { + contents.files.push(file); + } else { + tracing::debug!(file = %file.path, "the pack already has this checker mod"); + } + } + for file in resolved.blocked { + if remember(file.project_id.clone(), &file.sha1, &file.path) { + contents.blocked.push(file); + } else { + tracing::debug!(file = %file.path, "the pack already has this checker mod"); + } + } + for (sha1, file) in resolved.direct { + contents.direct.entry(sha1).or_insert(file); + } + contents.optional.extend(resolved.optional); + contents.loose.extend(resolved.loose); + contents.unresolved.extend(resolved.unresolved); +} + +async fn bundled_file_names(archive_path: &Path, prefixes: &[String]) -> HashSet { + let prefixes: Vec<&str> = prefixes.iter().map(String::as_str).collect(); + match OverrideLayers::open(archive_path, &prefixes).await { + Ok(layers) => layers + .entries() + .iter() + .filter(|(rel, _)| tracked_content_type(rel).is_some()) + .map(|(rel, _)| file_name_of(rel)) + .collect(), + Err(err) => { + tracing::debug!(error = %err, "could not list the pack's bundled files"); + HashSet::new() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn file_ids_come_from_curseforge_download_links() { + assert_eq!( + curseforge_file_id( + "https://www.curseforge.com/minecraft/mc-mods/balm-fabric/download/7420614" + ), + Some(7_420_614) + ); + assert_eq!( + curseforge_file_id("https://www.curseforge.com/minecraft/mc-mods/thing/files/5010620"), + Some(5_010_620) + ); + assert_eq!( + curseforge_file_id("https://example.com/minecraft/mc-mods/thing/download/5010620"), + None + ); + assert_eq!( + curseforge_file_id("https://www.curseforge.com/minecraft/mc-mods/thing"), + None + ); + } + + #[test] + fn the_checker_list_becomes_required_file_requests() { + let config = serde_json::json!([ + { + "displayName": "Balm (Fabric Edition)", + "pattern": "balm-fabric-1.20.1-7.3.38.jar", + "url": "https://www.curseforge.com/minecraft/mc-mods/balm-fabric/download/7420614", + "destination": "mods" + }, + { + "displayName": "Stay Clear", + "url": "https://www.curseforge.com/minecraft/texture-packs/stay-clear/download/6000001", + "destination": "resourcepacks" + }, + { + "displayName": "Duplicate", + "url": "https://www.curseforge.com/minecraft/mc-mods/balm-fabric/download/7420614" + }, + { + "displayName": "Elsewhere", + "url": "https://github.com/someone/thing/releases/download/1/thing.jar" + } + ]); + + let requests = checker_requests(&serde_json::to_vec(&config).unwrap()); + + assert_eq!(requests.len(), 2); + assert_eq!(requests[0].file_id, 7_420_614); + assert!(requests[0].required); + assert_eq!(requests[0].label.as_deref(), Some("Balm (Fabric Edition)")); + assert_eq!(requests[0].destination, Some(ContentType::Mod)); + assert_eq!(requests[1].destination, Some(ContentType::ResourcePack)); + } + + #[test] + fn a_checker_mod_the_pack_already_has_is_not_added_twice() { + use crate::bundles::{BundleFile, BundleFileKind, BundleFileType}; + use oneclient_common::domain::{GameLoader, ProviderId}; + + let managed = |project: &str, sha1: &str, path: &str| BundleFile { + enabled: true, + hidden: false, + path: path.into(), + size: 1, + file_type: BundleFileType::Normal, + kind: BundleFileKind::Managed { + provider: ProviderId::CurseForge, + project_id: project.into(), + version_id: "1".into(), + sha1: sha1.into(), + }, + }; + + let mut manifest = ModpackManifest { + name: "Pack".into(), + version: "1".into(), + summary: None, + mc_version: "1.20.1".into(), + loader: GameLoader::Fabric, + loader_version: None, + contents: ModpackContents { + files: vec![managed( + "modrinth-balm", + "aaaa", + "mods/balm-fabric-1.20.1-7.3.38.jar", + )], + ..Default::default() + }, + override_prefixes: vec!["overrides/".into()], + }; + + let resolved = ModpackContents { + files: vec![ + managed("111", "bbbb", "mods/balm-fabric-1.20.1-7.3.38.jar"), + managed("222", "aaaa", "mods/renamed.jar"), + managed("333", "cccc", "mods/bundled.jar"), + managed("444", "dddd", "mods/new.jar"), + ], + ..Default::default() + }; + let bundled: HashSet = ["bundled.jar".to_string()].into_iter().collect(); + + merge(&mut manifest, resolved, &bundled); + + let paths: Vec<&str> = manifest + .contents + .files + .iter() + .map(|file| file.path.as_str()) + .collect(); + assert_eq!( + paths, + vec!["mods/balm-fabric-1.20.1-7.3.38.jar", "mods/new.jar"] + ); + } + + #[test] + fn a_config_that_is_not_a_list_is_ignored() { + assert!(checker_requests(br#"{"mods": []}"#).is_empty()); + } +} diff --git a/packages/oneclient_content/src/modpacks/curseforge.rs b/packages/oneclient_content/src/modpacks/curseforge.rs new file mode 100644 index 00000000..22ccb965 --- /dev/null +++ b/packages/oneclient_content/src/modpacks/curseforge.rs @@ -0,0 +1,461 @@ +use std::collections::{HashMap, HashSet}; +use std::str::FromStr; + +use serde::Deserialize; + +use super::{BlockedFile, LooseFile, ModpackContents, ModpackManifest, is_safe_relative_path}; +use crate::bundles::{BundleFile, BundleFileKind, BundleFileType}; +use crate::ctx::ContentCtx; +use crate::error::ContentResult; +use crate::packages::PackageError; +use crate::packages::types::{ExternalFile, ProjectDetail, VersionDetail}; +use oneclient_common::domain::{ContentType, GameLoader, ProviderId}; + +pub(super) const MANIFEST_ENTRY: &str = "manifest.json"; + +const MODPACK_MANIFEST_TYPE: &str = "minecraftModpack"; +const LOOKUP_CHUNK: usize = 500; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CfManifest { + minecraft: CfMinecraft, + #[serde(default)] + manifest_type: String, + #[serde(default)] + name: String, + #[serde(default)] + version: String, + #[serde(default)] + files: Vec, + #[serde(default)] + overrides: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CfMinecraft { + version: String, + #[serde(default)] + mod_loaders: Vec, +} + +#[derive(Deserialize)] +struct CfModLoader { + id: String, + #[serde(default)] + primary: bool, +} + +#[derive(Deserialize)] +struct CfManifestFile { + #[serde(rename = "projectID")] + project_id: u32, + #[serde(rename = "fileID")] + file_id: u32, + #[serde(default = "required_by_default")] + required: bool, +} + +fn required_by_default() -> bool { + true +} + +pub(super) fn is_modpack_manifest(bytes: &[u8]) -> bool { + serde_json::from_slice::(bytes) + .is_ok_and(|manifest| manifest.manifest_type == MODPACK_MANIFEST_TYPE) +} + +pub(super) async fn resolve(bytes: &[u8], ctx: &ContentCtx) -> ContentResult { + let manifest: CfManifest = + serde_json::from_slice(bytes).map_err(|_| PackageError::UnsupportedModpackFormat)?; + if manifest.manifest_type != MODPACK_MANIFEST_TYPE { + return Err(PackageError::UnsupportedModpackFormat.into()); + } + + let (loader, loader_version) = parse_loader(&manifest.minecraft.mod_loaders)?; + + let requests: Vec = manifest + .files + .iter() + .map(|file| FileRequest { + file_id: file.file_id, + project_id: Some(file.project_id), + required: file.required, + label: None, + destination: None, + }) + .collect(); + let contents = resolve_requests(&requests, ctx).await?; + + let overrides = manifest + .overrides + .map(|folder| folder.trim_matches('/').to_string()) + .filter(|folder| !folder.is_empty() && is_safe_relative_path(folder)) + .unwrap_or_else(|| "overrides".to_string()); + + Ok(ModpackManifest { + name: manifest.name, + version: manifest.version, + summary: None, + mc_version: manifest.minecraft.version, + loader, + loader_version, + contents, + override_prefixes: vec![format!("{overrides}/")], + }) +} + +pub(super) struct FileRequest { + pub(super) file_id: u32, + pub(super) project_id: Option, + pub(super) required: bool, + pub(super) label: Option, + pub(super) destination: Option, +} + +pub(super) async fn resolve_requests( + requests: &[FileRequest], + ctx: &ContentCtx, +) -> ContentResult { + let provider = ctx.providers.get(ProviderId::CurseForge)?; + + let file_ids: Vec = requests + .iter() + .map(|request| request.file_id.to_string()) + .collect(); + let mut versions: HashMap = HashMap::new(); + for chunk in file_ids.chunks(LOOKUP_CHUNK) { + for version in provider.get_versions(chunk, ctx).await? { + versions.insert(version.version_id.clone(), version); + } + } + + let project_of = |request: &FileRequest| { + request.project_id.map(|id| id.to_string()).or_else(|| { + versions + .get(&request.file_id.to_string()) + .map(|version| version.project_id.clone()) + }) + }; + let project_ids: Vec = requests + .iter() + .filter_map(project_of) + .collect::>() + .into_iter() + .collect(); + let mut projects: HashMap = HashMap::new(); + for chunk in project_ids.chunks(LOOKUP_CHUNK) { + for project in provider.get_projects(chunk, ctx).await? { + projects.insert(project.id.clone(), project); + } + } + + let mut contents = ModpackContents::default(); + for request in requests { + contents.add( + request, + versions.get(&request.file_id.to_string()), + project_of(request).and_then(|id| projects.get(&id)), + ); + } + Ok(contents) +} + +impl ModpackContents { + fn add( + &mut self, + entry: &FileRequest, + version: Option<&VersionDetail>, + project: Option<&ProjectDetail>, + ) { + let label = || { + project + .map(|project| project.name.clone()) + .or_else(|| entry.label.clone()) + .unwrap_or_else(|| format!("CurseForge file {}", entry.file_id)) + }; + + let (Some(version), Some(file)) = (version, version.and_then(VersionDetail::primary_file)) + else { + tracing::warn!(file_id = entry.file_id, "curseforge file not found"); + self.unresolved.push(label()); + return; + }; + + let content_type = entry + .destination + .or_else(|| project.map(|project| project.content_type)) + .unwrap_or(ContentType::Mod); + let path = format!("{}/{}", content_type.folder_name(), file.file_name); + if !is_safe_relative_path(&path) || file.sha1.is_empty() { + tracing::warn!(path = %path, "curseforge file has an unusable name or no sha1"); + self.unresolved.push(label()); + return; + } + + let sha1 = file.sha1.to_ascii_lowercase(); + let project_id = version.project_id.clone(); + let version_id = entry.file_id.to_string(); + + if file.url.is_empty() { + self.blocked.push(BlockedFile { + project_id, + version_id: version_id.clone(), + project_name: label(), + file_name: file.file_name.clone(), + path, + sha1, + size: file.size, + content_type, + page_url: project.and_then(|project| download_page(project, &version_id)), + }); + return; + } + + match content_type { + ContentType::Mod | ContentType::ResourcePack | ContentType::Shader => { + self.direct.insert( + sha1.clone(), + ExternalFile { + name: file.file_name.clone(), + url: file.url.clone(), + sha1: sha1.clone(), + size: file.size, + content_type, + }, + ); + if !entry.required { + self.optional.insert(project_id.clone()); + } + self.files.push(BundleFile { + enabled: true, + hidden: false, + path, + size: file.size, + file_type: BundleFileType::Normal, + kind: BundleFileKind::Managed { + provider: ProviderId::CurseForge, + project_id, + version_id, + sha1, + }, + }); + } + _ => self.loose.push(LooseFile { + path, + url: file.url.clone(), + sha1, + size: file.size, + }), + } + } +} + +fn download_page(project: &ProjectDetail, file_id: &str) -> Option { + let website = project + .links + .iter() + .find(|(label, _)| label == "Website") + .map(|(_, url)| url.trim_end_matches('/').to_string())?; + Some(format!("{website}/download/{file_id}")) +} + +fn parse_loader(loaders: &[CfModLoader]) -> ContentResult<(GameLoader, Option)> { + let Some(chosen) = loaders + .iter() + .find(|loader| loader.primary) + .or_else(|| loaders.first()) + else { + return Ok((GameLoader::Vanilla, None)); + }; + + let (name, version) = chosen + .id + .split_once('-') + .ok_or(PackageError::UnsupportedModpackFormat)?; + let loader = GameLoader::from_str(name).map_err(|_| PackageError::UnsupportedModpackFormat)?; + Ok((loader, Some(version.to_string()))) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::packages::types::{PackageBody, VersionFile}; + use chrono::Utc; + + fn loader(id: &str, primary: bool) -> CfModLoader { + CfModLoader { + id: id.to_string(), + primary, + } + } + + #[test] + fn the_primary_loader_is_split_into_name_and_version() { + assert_eq!( + parse_loader(&[loader("fabric-0.15.3", false), loader("forge-47.2.0", true)]).unwrap(), + (GameLoader::Forge, Some("47.2.0".to_string())) + ); + assert_eq!( + parse_loader(&[loader("neoforge-21.1.72", true)]).unwrap(), + (GameLoader::NeoForge, Some("21.1.72".to_string())) + ); + assert_eq!(parse_loader(&[]).unwrap(), (GameLoader::Vanilla, None)); + assert!(parse_loader(&[loader("rift", true)]).is_err()); + } + + #[test] + fn only_minecraft_modpack_manifests_are_recognised() { + let pack = serde_json::json!({ + "minecraft": {"version": "1.20.1", "modLoaders": []}, + "manifestType": "minecraftModpack", + "files": [] + }); + let other = serde_json::json!({"name": "not a pack"}); + + assert!(is_modpack_manifest(&serde_json::to_vec(&pack).unwrap())); + assert!(!is_modpack_manifest(&serde_json::to_vec(&other).unwrap())); + } + + fn project(content_type: ContentType) -> ProjectDetail { + ProjectDetail { + id: "10".into(), + slug: "thing".into(), + provider: ProviderId::CurseForge, + content_type, + name: "Thing".into(), + summary: String::new(), + author: String::new(), + members: Vec::new(), + gallery: Vec::new(), + license: None, + links: vec![( + "Website".into(), + "https://www.curseforge.com/minecraft/mc-mods/thing/".into(), + )], + body: PackageBody::Raw(String::new()), + version_ids: Vec::new(), + game_versions: Vec::new(), + loaders: Vec::new(), + icon_url: None, + created: Utc::now(), + updated: Utc::now(), + downloads: 0, + } + } + + fn version(url: &str) -> VersionDetail { + VersionDetail { + version_id: "20".into(), + project_id: "10".into(), + name: "Thing 1.0".into(), + version_number: "thing-1.0.jar".into(), + changelog: None, + game_versions: Vec::new(), + loaders: Vec::new(), + published: Utc::now(), + downloads: 0, + files: vec![VersionFile { + sha1: "ABCD".into(), + url: url.into(), + file_name: "thing-1.0.jar".into(), + primary: true, + size: 5, + fingerprint: None, + }], + dependencies: Vec::new(), + } + } + + fn entry(required: bool) -> FileRequest { + FileRequest { + file_id: 20, + project_id: Some(10), + required, + label: None, + destination: None, + } + } + + #[test] + fn a_downloadable_mod_becomes_a_managed_file() { + let mut contents = ModpackContents::default(); + contents.add( + &entry(false), + Some(&version("https://edge.forgecdn.net/thing-1.0.jar")), + Some(&project(ContentType::Mod)), + ); + + assert_eq!(contents.files.len(), 1); + assert_eq!(contents.files[0].path, "mods/thing-1.0.jar"); + assert!(contents.files[0].enabled); + assert!(contents.optional.contains("10")); + assert_eq!( + contents.files[0].kind, + BundleFileKind::Managed { + provider: ProviderId::CurseForge, + project_id: "10".into(), + version_id: "20".into(), + sha1: "abcd".into(), + } + ); + } + + #[test] + fn a_file_without_a_download_url_is_blocked() { + let mut contents = ModpackContents::default(); + contents.add( + &entry(true), + Some(&version("")), + Some(&project(ContentType::ResourcePack)), + ); + + assert!(contents.files.is_empty()); + assert_eq!(contents.blocked.len(), 1); + assert_eq!(contents.blocked[0].path, "resourcepacks/thing-1.0.jar"); + assert_eq!( + contents.blocked[0].page_url.as_deref(), + Some("https://www.curseforge.com/minecraft/mc-mods/thing/download/20") + ); + } + + #[test] + fn a_requested_destination_beats_the_project_category() { + let mut contents = ModpackContents::default(); + let request = FileRequest { + destination: Some(ContentType::ResourcePack), + ..entry(true) + }; + contents.add( + &request, + Some(&version("https://edge.forgecdn.net/thing-1.0.jar")), + Some(&project(ContentType::Mod)), + ); + + assert_eq!(contents.files[0].path, "resourcepacks/thing-1.0.jar"); + } + + #[test] + fn a_missing_file_without_a_project_uses_its_label() { + let mut contents = ModpackContents::default(); + let request = FileRequest { + file_id: 20, + project_id: None, + required: true, + label: Some("Stay Clear".into()), + destination: None, + }; + contents.add(&request, None, None); + + assert_eq!(contents.unresolved, vec!["Stay Clear".to_string()]); + } + + #[test] + fn a_missing_file_is_reported_by_project_name() { + let mut contents = ModpackContents::default(); + contents.add(&entry(true), None, Some(&project(ContentType::Mod))); + + assert_eq!(contents.unresolved, vec!["Thing".to_string()]); + } +} diff --git a/packages/oneclient_content/src/modpacks/install.rs b/packages/oneclient_content/src/modpacks/install.rs new file mode 100644 index 00000000..c742b558 --- /dev/null +++ b/packages/oneclient_content/src/modpacks/install.rs @@ -0,0 +1,637 @@ +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; + +use oneclient_db::dao::artifact as artifact_dao; +use oneclient_db::dao::cluster_bundle as bundle_dao; +use oneclient_db::models::{ArtifactRow, ClusterBundleOverrideRow, ClusterRow, OverrideType}; +use oneclient_events::{GroupedProgressSession, TaskCategory, TaskPhase}; + +use futures_util::StreamExt; + +use super::{ + BlockedFile, LooseFile, MODPACK_BUNDLE_NAME, ModpackContents, ModpackManifest, + tracked_content_type, +}; +use crate::bundles::install::{ + PresentContent, effective_enabled, find_override, install_bundle_files, install_external, + remove_artifact_from_cluster, +}; +use crate::bundles::overrides::{OverrideLayers, sync_file_lock, sync_layered_overrides}; +use crate::bundles::{BundleFile, BundleFileKind}; +use crate::ctx::ContentCtx; +use crate::error::{ContentError, ContentResult}; +use crate::packages::PackageError; +use crate::packages::store::{self, PackageStore}; +use crate::packages::types::ExternalFile; +use oneclient_common::domain::{ContentType, ProviderId}; +use oneclient_common::paths; + +const LOOSE_LOCK_KEY: &str = "modpack:files"; +const LOOSE_CONCURRENCY: usize = 6; + +#[derive(Debug, Clone, Default)] +pub struct ModpackInstallReport { + pub installed: usize, + pub failed: Vec, + pub blocked: Vec, +} + +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn store_modpack_archive(path: &Path, ctx: &ContentCtx) -> ContentResult { + store::cache_local_file(path, ContentType::Modpack, ctx).await +} + +struct InstallContext<'a> { + manifest: &'a ModpackManifest, + cluster: &'a ClusterRow, + present: &'a PresentContent, + ctx: &'a ContentCtx, +} + +#[tracing::instrument(skip(manifest, progress, ctx), fields(pack = %manifest.name))] +pub async fn install_modpack( + archive_path: &Path, + manifest: &ModpackManifest, + cluster_id: i64, + progress: Option<&GroupedProgressSession>, + ctx: &ContentCtx, +) -> ContentResult { + let contents = &manifest.contents; + let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; + let root = paths::cluster_game_dir(&cluster.folder_name, cluster.is_isolated())?; + + let overrides = bundle_dao::list_overrides(&ctx.db, cluster_id).await?; + let present = PresentContent::load(cluster_id, ctx).await?; + let tracked: HashMap = + bundle_dao::list_bundle_tracked(&ctx.db, cluster_id) + .await? + .into_iter() + .filter(|row| row.bundle_name.as_deref() == Some(MODPACK_BUNDLE_NAME)) + .filter_map(|row| Some((row.package_id?, (row.hash, row.bundle_version_id?)))) + .collect(); + + let mut replaced: HashMap = HashMap::new(); + let to_install = contents + .files + .iter() + .filter(|file| { + let package_id = file.kind.package_id(); + let user_choice = find_override(&overrides, MODPACK_BUNDLE_NAME, &package_id); + if user_choice == Some(OverrideType::Removed) { + return false; + } + match tracked.get(&package_id) { + Some((_, version)) if *version == file.kind.bundle_version_id() => false, + Some((hash, _)) => { + replaced.insert(package_id, hash.clone()); + true + } + None => effective_enabled(file, user_choice) && !present.contains(file), + } + }) + .cloned() + .collect(); + + let mut report = ModpackInstallReport::default(); + let install = InstallContext { + manifest, + cluster: &cluster, + present: &present, + ctx, + }; + + let results = install_bundle_files( + to_install, + cluster_id, + MODPACK_BUNDLE_NAME, + true, + progress, + ctx, + ) + .await; + for (file, installed) in results { + let replaced = replaced.remove(&file.kind.package_id()); + match settle_file(&install, &file, installed, replaced).await { + Ok(()) => report.installed += 1, + Err(err) => { + let name = file.display_name(); + tracing::warn!(file = %name, error = %err, "failed to install modpack file"); + report.failed.push(name); + } + } + } + + let game_dir = root.as_path(); + let loose = futures_util::stream::iter(contents.loose.iter().cloned().map(|file| async move { + let result = install_loose_file(&file, game_dir, progress, ctx).await; + (file, result) + })) + .buffer_unordered(LOOSE_CONCURRENCY) + .collect::>() + .await; + let mut written = HashMap::new(); + for (file, result) in loose { + match result { + Ok(()) => { + written.insert(file.path.clone(), file.sha1.clone()); + } + Err(err) => { + tracing::warn!(path = %file.path, error = %err, "failed to install modpack file"); + report.failed.push(file.path.clone()); + } + } + } + let listed: HashSet = contents + .loose + .iter() + .map(|file| file.path.clone()) + .collect(); + sync_file_lock(&root, LOOSE_LOCK_KEY, written, &listed).await; + + let prefixes: Vec<&str> = manifest + .override_prefixes + .iter() + .map(String::as_str) + .collect(); + sync_layered_overrides( + archive_path, + MODPACK_BUNDLE_NAME, + &root, + &prefixes, + &|rel| tracked_content_type(rel).is_none(), + Some(&ctx.events), + ) + .await?; + + let bundled = + import_override_content(archive_path, &prefixes, &install, &overrides, &mut report).await?; + + remove_dropped_files(contents, &tracked, bundled, cluster_id, ctx).await; + + for file in &contents.blocked { + let suppressed = matches!( + find_override(&overrides, MODPACK_BUNDLE_NAME, &file.project_id), + Some(OverrideType::Removed | OverrideType::Disabled) + ); + if present.has_hash(&file.sha1) || suppressed { + continue; + } + + match cached_blocked_file(file, ctx).await { + Some(row) => match link_blocked_file(&cluster, file, &row, ctx).await { + Ok(()) => report.installed += 1, + Err(err) => { + tracing::warn!(file = %file.file_name, error = %err, "could not reuse a cached manual download"); + report.blocked.push(file.clone()); + } + }, + None => report.blocked.push(file.clone()), + } + } + + report.failed.extend(contents.unresolved.iter().cloned()); + + tracing::info!( + cluster_id, + installed = report.installed, + failed = report.failed.len(), + blocked = report.blocked.len(), + "modpack install finished" + ); + + Ok(report) +} + +async fn settle_file( + install: &InstallContext<'_>, + file: &BundleFile, + installed: ContentResult, + replaced: Option, +) -> ContentResult<()> { + let hash = match installed.and_then(|hash| verify_installed(file, hash)) { + Ok(hash) => hash, + Err(err) => { + unlink_mismatch(install, &err).await; + let Some(direct) = direct_download(file, install.manifest) else { + return Err(err); + }; + tracing::info!( + file = %file.display_name(), + error = %err, + "provider lookup failed, downloading the modpack's own link instead" + ); + install_direct(install, file, direct).await? + } + }; + + let cluster_id = install.cluster.id; + let ctx = install.ctx; + match replaced { + Some(old) if old != hash => { + let was_enabled = artifact_dao::get_cluster_artifact(&ctx.db, cluster_id, &old) + .await? + .is_none_or(|link| link.enabled != 0); + remove_artifact_from_cluster(cluster_id, &old, false, ctx).await?; + if !was_enabled { + PackageStore::set_artifact_enabled_to(cluster_id, &hash, false, ctx).await?; + } + } + Some(_) => {} + None if install + .manifest + .contents + .optional + .contains(&file.kind.package_id()) => + { + PackageStore::set_artifact_enabled_to(cluster_id, &hash, false, ctx).await?; + } + None => {} + } + Ok(()) +} + +fn verify_installed(file: &BundleFile, hash: String) -> ContentResult { + let expected = super::file_sha1(file); + if expected.is_empty() || expected == hash { + return Ok(hash); + } + + tracing::warn!( + file = %file.display_name(), + expected, + actual = %hash, + "the provider served a different file than the modpack lists" + ); + Err(PackageError::HashMismatch { + expected: expected.to_string(), + actual: hash, + } + .into()) +} + +async fn unlink_mismatch(install: &InstallContext<'_>, err: &ContentError) { + let ContentError::Package(PackageError::HashMismatch { actual, .. }) = err else { + return; + }; + if install.present.has_hash(actual) { + return; + } + if let Err(err) = + remove_artifact_from_cluster(install.cluster.id, actual, false, install.ctx).await + { + tracing::warn!(error = %err, "could not remove the mismatched file"); + } +} + +async fn remove_dropped_files( + contents: &ModpackContents, + tracked: &HashMap, + bundled: Vec, + cluster_id: i64, + ctx: &ContentCtx, +) { + if !contents.unresolved.is_empty() { + tracing::warn!( + cluster_id, + unresolved = contents.unresolved.len(), + "not removing dropped files because part of the modpack could not be resolved" + ); + return; + } + + let mut listed: HashSet = contents + .files + .iter() + .map(|file| file.kind.package_id()) + .collect(); + listed.extend(contents.blocked.iter().map(|file| file.project_id.clone())); + listed.extend(bundled); + + for (package_id, (hash, _)) in tracked { + if listed.contains(package_id) { + continue; + } + if let Err(err) = remove_artifact_from_cluster(cluster_id, hash, false, ctx).await { + tracing::warn!(package_id, error = %err, "could not remove a file the modpack dropped"); + } + } +} + +fn direct_download<'a>( + file: &BundleFile, + manifest: &'a ModpackManifest, +) -> Option<&'a ExternalFile> { + match &file.kind { + BundleFileKind::Managed { sha1, .. } => manifest.contents.direct.get(sha1), + BundleFileKind::External { .. } => None, + } +} + +async fn install_direct( + install: &InstallContext<'_>, + file: &BundleFile, + direct: &ExternalFile, +) -> ContentResult { + let ctx = install.ctx; + let hash = install_external(direct, install.cluster, true, None, ctx).await?; + bundle_dao::track_bundle_artifact( + &ctx.db, + install.cluster.id, + &hash, + MODPACK_BUNDLE_NAME, + &file.kind.bundle_version_id(), + &file.kind.package_id(), + ) + .await?; + Ok(hash) +} + +async fn import_override_content( + archive_path: &Path, + prefixes: &[&str], + install: &InstallContext<'_>, + overrides: &[ClusterBundleOverrideRow], + report: &mut ModpackInstallReport, +) -> ContentResult> { + let mut layers = OverrideLayers::open(archive_path, prefixes).await?; + let bundled: Vec<(String, String, ContentType)> = layers + .entries() + .iter() + .filter_map(|(rel, entry)| { + let content_type = tracked_content_type(rel)?; + Some((rel.clone(), entry.clone(), content_type)) + }) + .collect(); + let mut hashes = Vec::new(); + if bundled.is_empty() { + return Ok(hashes); + } + + let staging = polyio::tempdir().await?; + let mut files = Vec::new(); + + for (rel, entry, content_type) in bundled { + let Some(bytes) = layers.read(&entry).await else { + report.failed.push(rel); + continue; + }; + let hash = polyio::normalize_hash(&polyio::sha1_bytes(&bytes)); + hashes.push(hash.clone()); + let removed = matches!( + find_override(overrides, MODPACK_BUNDLE_NAME, &hash), + Some(OverrideType::Removed | OverrideType::Disabled) + ); + if removed || install.present.has_hash(&hash) { + continue; + } + + let Some(file_name) = rel.rsplit('/').next() else { + continue; + }; + let path = staging + .dir_path() + .join(content_type.folder_name()) + .join(file_name); + if let Some(parent) = path.parent() { + polyio::create_dir_all(parent).await?; + } + polyio::write(&path, bytes).await?; + files.push((path, content_type)); + } + + let ctx = install.ctx; + let imported = PackageStore::import_local_files(&files, install.cluster.id, ctx).await?; + + for row in &imported.imported { + bundle_dao::track_bundle_artifact( + &ctx.db, + install.cluster.id, + &row.hash, + MODPACK_BUNDLE_NAME, + &row.hash, + &row.hash, + ) + .await?; + report.installed += 1; + } + + for (path, err) in imported.failed { + let name = path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_default(); + tracing::warn!(file = %name, error = %err, "failed to import a file bundled in the modpack"); + report.failed.push(name); + } + + Ok(hashes) +} + +async fn install_loose_file( + file: &LooseFile, + root: &Path, + progress: Option<&GroupedProgressSession>, + ctx: &ContentCtx, +) -> ContentResult<()> { + let dest = root.join(polyio::sanitize_path(&file.path)); + let child = progress.map(|p| { + let child = p.child(file.path.clone(), file.size.max(1), TaskCategory::Packages); + child.set_phase(TaskPhase::Downloading); + child + }); + + let result = store::ensure_artifact_file(&file.sha1, &file.url, &dest, child.as_ref(), ctx) + .await + .map(|_| ()); + + if let Some(child) = child { + child.finish(); + } + result +} + +#[tracing::instrument(level = "debug", skip(blocked))] +pub async fn find_blocked_downloads( + locations: &[PathBuf], + blocked: &[BlockedFile], +) -> Vec<(PathBuf, BlockedFile)> { + let mut by_size: HashMap> = HashMap::new(); + for file in blocked { + by_size.entry(file.size).or_default().push(file); + } + + let mut found: Vec<(PathBuf, BlockedFile)> = Vec::new(); + for path in candidate_files(locations).await { + let Ok(meta) = polyio::stat(&path).await else { + continue; + }; + let Some(candidates) = by_size.get(&meta.len()) else { + continue; + }; + let Ok(sha1) = polyio::sha1_file(&path).await else { + continue; + }; + let sha1 = polyio::normalize_hash(&sha1); + + if let Some(file) = candidates.iter().find(|file| file.sha1 == sha1) + && !found.iter().any(|(_, known)| known.sha1 == sha1) + { + found.push((path, (*file).clone())); + } + } + + found +} + +async fn candidate_files(locations: &[PathBuf]) -> Vec { + let mut files = Vec::new(); + for location in locations { + let Ok(meta) = polyio::stat(location).await else { + continue; + }; + if meta.is_file() { + files.push(location.clone()); + continue; + } + + let Ok(mut entries) = polyio::read_dir(location).await else { + continue; + }; + while let Ok(Some(entry)) = entries.next_entry().await { + if entry.file_type().await.is_ok_and(|kind| kind.is_file()) { + files.push(entry.path()); + } + } + } + files +} + +#[tracing::instrument(level = "debug", skip(found, ctx), fields(files = found.len()))] +pub async fn import_blocked_files( + cluster_id: i64, + found: &[(PathBuf, BlockedFile)], + ctx: &ContentCtx, +) -> ContentResult> { + let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; + let mut imported = Vec::new(); + + for (path, file) in found { + let row = store::cache_local_file(path, file.content_type, ctx).await?; + if row.hash != file.sha1 { + return Err(PackageError::HashMismatch { + expected: file.sha1.clone(), + actual: row.hash, + } + .into()); + } + + link_blocked_file(&cluster, file, &row, ctx).await?; + imported.push(row.hash); + } + + Ok(imported) +} + +async fn link_blocked_file( + cluster: &ClusterRow, + file: &BlockedFile, + row: &ArtifactRow, + ctx: &ContentCtx, +) -> ContentResult<()> { + PackageStore::link_artifact(row, cluster, Some(&file.file_name), ctx).await?; + bundle_dao::track_bundle_artifact( + &ctx.db, + cluster.id, + &row.hash, + MODPACK_BUNDLE_NAME, + &file.version_id, + &file.project_id, + ) + .await?; + + if artifact_dao::get_release_by_hash(&ctx.db, &row.hash) + .await? + .is_some() + { + return Ok(()); + } + + match crate::packages::get_version_cached( + ctx, + ProviderId::CurseForge, + &file.project_id, + &file.version_id, + ) + .await + { + Ok(version) => { + if let Err(err) = + store::record_release(ProviderId::CurseForge, &version, &row.hash, ctx).await + { + tracing::debug!(file = %file.file_name, error = %err, "could not record the release"); + } + } + Err(err) => { + tracing::debug!(file = %file.file_name, error = %err, "could not describe the manual download"); + } + } + + Ok(()) +} + +async fn cached_blocked_file(file: &BlockedFile, ctx: &ContentCtx) -> Option { + let row = artifact_dao::get_artifact_by_hash(&ctx.db, &file.sha1) + .await + .ok() + .flatten()?; + let path = store::artifact_absolute_path(&row.path).ok()?; + polyio::try_exists(&path) + .await + .unwrap_or(false) + .then_some(row) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn blocked(sha1: &str, size: u64) -> BlockedFile { + BlockedFile { + project_id: "1".into(), + version_id: "2".into(), + project_name: "Thing".into(), + file_name: "thing.jar".into(), + path: "mods/thing.jar".into(), + sha1: sha1.into(), + size, + content_type: ContentType::Mod, + page_url: None, + } + } + + #[tokio::test] + async fn downloads_are_matched_by_hash_not_by_name() { + let dir = polyio::testing::ScratchDir::new("blocked-downloads"); + let wanted = b"the real jar"; + let renamed = dir.path().join("thing (1).jar"); + tokio::fs::write(&renamed, wanted).await.unwrap(); + tokio::fs::write(dir.path().join("thing.jar"), b"another file!") + .await + .unwrap(); + + let sha1 = polyio::sha1_bytes(wanted); + let found = find_blocked_downloads( + &[dir.path().to_path_buf()], + &[blocked(&sha1, wanted.len() as u64)], + ) + .await; + + assert_eq!(found.len(), 1); + assert_eq!(found[0].0, renamed); + + let picked = + find_blocked_downloads(&[renamed.clone()], &[blocked(&sha1, wanted.len() as u64)]) + .await; + assert_eq!(picked.len(), 1); + } +} diff --git a/packages/oneclient_content/src/modpacks/mod.rs b/packages/oneclient_content/src/modpacks/mod.rs new file mode 100644 index 00000000..9a162946 --- /dev/null +++ b/packages/oneclient_content/src/modpacks/mod.rs @@ -0,0 +1,217 @@ +mod checker; +mod curseforge; +mod install; +mod mrpack; +mod update; + +use std::collections::{HashMap, HashSet}; +use std::path::Path; + +use crate::bundles::BundleFile; +use crate::ctx::ContentCtx; +use crate::error::ContentResult; +use crate::packages::PackageError; +use crate::packages::types::ExternalFile; +use oneclient_common::domain::{ContentType, GameLoader}; + +pub use update::{ + ModpackRelease, ModpackUpdateStatus, check_modpack_update, cluster_modpack, identify_modpack, +}; + +pub use install::{ + ModpackInstallReport, find_blocked_downloads, import_blocked_files, install_modpack, + store_modpack_archive, +}; + +pub const MODPACK_BUNDLE_NAME: &str = "modpack"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ModpackFormat { + Modrinth, + CurseForge, +} + +#[derive(Debug, Clone)] +pub struct ModpackManifest { + pub name: String, + pub version: String, + pub summary: Option, + pub mc_version: String, + pub loader: GameLoader, + pub loader_version: Option, + pub contents: ModpackContents, + pub override_prefixes: Vec, +} + +#[derive(Debug, Clone, Default)] +pub struct ModpackContents { + pub files: Vec, + pub direct: HashMap, + pub optional: HashSet, + pub loose: Vec, + pub blocked: Vec, + pub unresolved: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ModpackSummary { + pub mods: usize, + pub resource_packs: usize, + pub shaders: usize, + pub other_files: usize, + pub optional: usize, + pub manual: usize, + pub unresolved: usize, + pub download_bytes: u64, +} + +impl ModpackManifest { + #[must_use] + pub fn summary(&self) -> ModpackSummary { + let contents = &self.contents; + let mut summary = ModpackSummary { + other_files: contents.loose.len(), + optional: contents.optional.len(), + manual: contents.blocked.len(), + unresolved: contents.unresolved.len(), + ..ModpackSummary::default() + }; + + for file in &contents.files { + match file.content_type() { + ContentType::ResourcePack => summary.resource_packs += 1, + ContentType::Shader => summary.shaders += 1, + _ => summary.mods += 1, + } + summary.download_bytes += file.size; + } + summary.download_bytes += contents.loose.iter().map(|file| file.size).sum::(); + summary + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LooseFile { + pub path: String, + pub url: String, + pub sha1: String, + pub size: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BlockedFile { + pub project_id: String, + pub version_id: String, + pub project_name: String, + pub file_name: String, + pub path: String, + pub sha1: String, + pub size: u64, + pub content_type: ContentType, + pub page_url: Option, +} + +#[tracing::instrument(level = "debug")] +pub async fn detect_format(path: &Path) -> ContentResult { + if zip_entry(path, mrpack::INDEX_ENTRY).await?.is_some() { + return Ok(ModpackFormat::Modrinth); + } + match zip_entry(path, curseforge::MANIFEST_ENTRY).await? { + Some(bytes) if curseforge::is_modpack_manifest(&bytes) => Ok(ModpackFormat::CurseForge), + _ => Err(PackageError::UnsupportedModpackFormat.into()), + } +} + +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn read_modpack(path: &Path, ctx: &ContentCtx) -> ContentResult { + if let Some(bytes) = zip_entry(path, mrpack::INDEX_ENTRY).await? { + let mut manifest = mrpack::parse(&bytes)?; + checker::add_checker_mods(path, &mut manifest, ctx).await; + return Ok(manifest); + } + + let bytes = zip_entry(path, curseforge::MANIFEST_ENTRY) + .await? + .ok_or(PackageError::UnsupportedModpackFormat)?; + curseforge::resolve(&bytes, ctx).await +} + +async fn zip_entry(path: &Path, entry: &str) -> ContentResult>> { + let file = tokio::fs::File::open(path).await?; + match polyio::try_read_zip_entry_bytes(tokio::io::BufReader::new(file), entry).await { + Ok(bytes) => Ok(Some(bytes)), + Err(polyio::IOError::FileNotFoundInZip { .. }) => Ok(None), + Err(err) => Err(err.into()), + } +} + +fn file_sha1(file: &BundleFile) -> &str { + match &file.kind { + crate::bundles::BundleFileKind::Managed { sha1, .. } => sha1, + crate::bundles::BundleFileKind::External { file, .. } => &file.sha1, + } +} + +fn file_name_of(path: &str) -> String { + path.rsplit('/').next().unwrap_or(path).to_lowercase() +} + +fn is_safe_relative_path(path: &str) -> bool { + if path.is_empty() || path.starts_with('/') || path.starts_with('\\') || path.contains(':') { + return false; + } + path.split(['/', '\\']) + .all(|segment| !segment.is_empty() && segment != "." && segment != "..") +} + +fn tracked_folder(folder: &str) -> Option { + match ContentType::from_folder_name(folder)? { + content_type @ (ContentType::Mod | ContentType::ResourcePack | ContentType::Shader) => { + Some(content_type) + } + _ => None, + } +} + +fn tracked_content_type(path: &str) -> Option { + let (folder, file) = path.split_once('/')?; + if file.is_empty() || file.contains('/') { + return None; + } + tracked_folder(folder) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn traversal_and_absolute_paths_are_rejected() { + assert!(is_safe_relative_path("mods/sodium.jar")); + assert!(is_safe_relative_path("config/sodium/options.json")); + assert!(!is_safe_relative_path("../mods/evil.jar")); + assert!(!is_safe_relative_path("mods/../../evil.jar")); + assert!(!is_safe_relative_path("/etc/passwd")); + assert!(!is_safe_relative_path("\\Windows\\evil.dll")); + assert!(!is_safe_relative_path("C:/Windows/evil.dll")); + assert!(!is_safe_relative_path("mods//evil.jar")); + assert!(!is_safe_relative_path("")); + } + + #[test] + fn only_top_level_content_files_are_tracked() { + assert_eq!(tracked_content_type("mods/a.jar"), Some(ContentType::Mod)); + assert_eq!( + tracked_content_type("resourcepacks/a.zip"), + Some(ContentType::ResourcePack) + ); + assert_eq!( + tracked_content_type("shaderpacks/a.zip"), + Some(ContentType::Shader) + ); + assert_eq!(tracked_content_type("mods/sub/a.jar"), None); + assert_eq!(tracked_content_type("config/a.json"), None); + assert_eq!(tracked_content_type("datapacks/a.zip"), None); + assert_eq!(tracked_content_type("a.jar"), None); + } +} diff --git a/packages/oneclient_content/src/modpacks/mrpack.rs b/packages/oneclient_content/src/modpacks/mrpack.rs new file mode 100644 index 00000000..a1c2d7f7 --- /dev/null +++ b/packages/oneclient_content/src/modpacks/mrpack.rs @@ -0,0 +1,325 @@ +use std::collections::HashMap; +use std::path::Path; + +use serde::Deserialize; + +use super::{ + LooseFile, ModpackContents, ModpackManifest, is_safe_relative_path, tracked_content_type, +}; +use crate::bundles::polymrpack::{mrpack_file_kind, scan_dependencies}; +use crate::bundles::{BundleFile, BundleFileKind, BundleFileType}; +use crate::error::ContentResult; +use crate::packages::PackageError; +use crate::packages::types::ExternalFile; +use oneclient_common::domain::GameLoader; + +pub(super) const INDEX_ENTRY: &str = "modrinth.index.json"; + +const OVERRIDES: &str = "overrides/"; +const CLIENT_OVERRIDES: &str = "client-overrides/"; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct MrpackManifest { + #[serde(default)] + name: String, + #[serde(default)] + version_id: String, + #[serde(default)] + summary: Option, + #[serde(default)] + files: Vec, + #[serde(default)] + dependencies: HashMap, +} + +#[derive(Deserialize)] +struct MrpackFileEntry { + path: String, + hashes: MrpackHashes, + #[serde(default)] + env: Option, + #[serde(default)] + downloads: Vec, + #[serde(rename = "fileSize", default)] + file_size: u64, +} + +#[derive(Deserialize)] +struct MrpackHashes { + sha1: String, +} + +#[derive(Deserialize)] +struct MrpackEnv { + #[serde(default)] + client: Option, +} + +impl MrpackFileEntry { + fn client_env(&self) -> Option<&str> { + self.env.as_ref().and_then(|env| env.client.as_deref()) + } + + fn client_side(&self) -> bool { + self.client_env() != Some("unsupported") + } + + fn optional(&self) -> bool { + self.client_env() == Some("optional") + } +} + +pub(super) fn parse(bytes: &[u8]) -> ContentResult { + let manifest: MrpackManifest = serde_json::from_slice(bytes)?; + let (mc_version, loader, loader_version) = parse_dependencies(&manifest.dependencies)?; + + let mut contents = ModpackContents::default(); + + for entry in manifest.files { + if !entry.client_side() { + continue; + } + + let path = entry.path.replace('\\', "/"); + if !is_safe_relative_path(&path) { + tracing::warn!(path = %entry.path, "skipping modpack file with an unsafe path"); + contents.unresolved.push(entry.path); + continue; + } + + let sha1 = entry.hashes.sha1.to_ascii_lowercase(); + + if let Some(content_type) = tracked_content_type(&path) + && let Some(kind) = mrpack_file_kind(&path, &entry.downloads, &sha1, entry.file_size) + { + if let (BundleFileKind::Managed { .. }, Some(url)) = (&kind, entry.downloads.first()) { + contents.direct.insert( + sha1.clone(), + ExternalFile { + name: file_name(&path), + url: url.clone(), + sha1: sha1.clone(), + size: entry.file_size, + content_type, + }, + ); + } + if entry.optional() { + contents.optional.insert(kind.package_id()); + } + contents.files.push(BundleFile { + enabled: true, + hidden: false, + path, + size: entry.file_size, + file_type: BundleFileType::Normal, + kind, + }); + continue; + } + + match entry.downloads.into_iter().next() { + Some(url) => contents.loose.push(LooseFile { + path, + url, + sha1, + size: entry.file_size, + }), + None => { + tracing::warn!(path = %path, "modpack file has no download"); + contents.unresolved.push(path); + } + } + } + + Ok(ModpackManifest { + name: manifest.name, + version: manifest.version_id, + summary: manifest + .summary + .filter(|summary| !summary.trim().is_empty()), + mc_version, + loader, + loader_version, + contents, + override_prefixes: vec![OVERRIDES.to_string(), CLIENT_OVERRIDES.to_string()], + }) +} + +fn file_name(path: &str) -> String { + Path::new(path) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(path) + .to_string() +} + +fn parse_dependencies( + deps: &HashMap, +) -> ContentResult<(String, GameLoader, Option)> { + let scanned = scan_dependencies(deps, GameLoader::is_modded); + let mc_version = scanned + .mc_version + .ok_or(PackageError::UnsupportedModpackFormat)?; + Ok(match scanned.loader { + Some((loader, version)) => (mc_version, loader, Some(version)), + None => (mc_version, GameLoader::Vanilla, None), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::bundles::BundleFileKind; + use oneclient_common::domain::ProviderId; + + fn index(files: serde_json::Value, dependencies: serde_json::Value) -> Vec { + serde_json::to_vec(&serde_json::json!({ + "formatVersion": 1, + "game": "minecraft", + "versionId": "1.2.0", + "name": "Example Pack", + "files": files, + "dependencies": dependencies, + })) + .unwrap() + } + + #[test] + fn a_plain_mrpack_parses_into_tracked_and_loose_files() { + let bytes = index( + serde_json::json!([ + { + "path": "mods/sodium.jar", + "hashes": {"sha1": "AAAA", "sha512": "x"}, + "downloads": ["https://cdn.modrinth.com/data/AANobbMI/versions/tFw0iWAk/sodium.jar"], + "fileSize": 10 + }, + { + "path": "mods/extra.jar", + "hashes": {"sha1": "bbbb"}, + "downloads": ["https://github.com/a/b/releases/download/1/extra.jar"], + "fileSize": 20 + }, + { + "path": "config/thing/defaults.json", + "hashes": {"sha1": "cccc"}, + "downloads": ["https://example.invalid/defaults.json"], + "fileSize": 30 + }, + { + "path": "mods/pick-me.jar", + "hashes": {"sha1": "ffff"}, + "env": {"client": "optional", "server": "optional"}, + "downloads": ["https://example.invalid/pick-me.jar"], + "fileSize": 60 + }, + { + "path": "mods/server-only.jar", + "hashes": {"sha1": "dddd"}, + "env": {"client": "unsupported", "server": "required"}, + "downloads": ["https://example.invalid/server-only.jar"], + "fileSize": 40 + }, + { + "path": "../escape.jar", + "hashes": {"sha1": "eeee"}, + "downloads": ["https://example.invalid/escape.jar"], + "fileSize": 50 + } + ]), + serde_json::json!({"minecraft": "1.21.1", "fabric-loader": "0.16.5"}), + ); + + let manifest = parse(&bytes).unwrap(); + + assert_eq!(manifest.name, "Example Pack"); + assert_eq!(manifest.version, "1.2.0"); + assert_eq!(manifest.mc_version, "1.21.1"); + assert_eq!(manifest.loader, GameLoader::Fabric); + assert_eq!(manifest.loader_version.as_deref(), Some("0.16.5")); + + assert_eq!(manifest.contents.files.len(), 3); + assert!(manifest.contents.optional.contains("ffff")); + assert_eq!(manifest.contents.optional.len(), 1); + assert_eq!( + manifest.contents.files[0].kind, + BundleFileKind::Managed { + provider: ProviderId::Modrinth, + project_id: "AANobbMI".into(), + version_id: "tFw0iWAk".into(), + sha1: "aaaa".into(), + } + ); + assert!(matches!( + manifest.contents.files[1].kind, + BundleFileKind::External { .. } + )); + assert_eq!( + manifest + .contents + .direct + .get("aaaa") + .map(|file| file.url.as_str()), + Some("https://cdn.modrinth.com/data/AANobbMI/versions/tFw0iWAk/sodium.jar") + ); + assert_eq!( + manifest + .contents + .direct + .get("aaaa") + .map(|file| file.name.as_str()), + Some("sodium.jar") + ); + assert!(!manifest.contents.direct.contains_key("bbbb")); + + assert_eq!(manifest.contents.loose.len(), 1); + assert_eq!( + manifest.contents.loose[0].path, + "config/thing/defaults.json" + ); + assert_eq!( + manifest.contents.unresolved, + vec!["../escape.jar".to_string()] + ); + assert_eq!( + manifest.override_prefixes, + vec!["overrides/".to_string(), "client-overrides/".to_string()] + ); + } + + #[test] + fn a_pack_without_a_loader_is_vanilla() { + let bytes = index( + serde_json::json!([]), + serde_json::json!({"minecraft": "1.21.1"}), + ); + + let manifest = parse(&bytes).unwrap(); + + assert_eq!(manifest.loader, GameLoader::Vanilla); + assert_eq!(manifest.loader_version, None); + } + + #[test] + fn neoforge_and_quilt_keys_are_recognised() { + for (key, loader) in [ + ("neoforge", GameLoader::NeoForge), + ("forge", GameLoader::Forge), + ("quilt-loader", GameLoader::Quilt), + ] { + let bytes = index( + serde_json::json!([]), + serde_json::json!({"minecraft": "1.21.1", key: "1.0"}), + ); + assert_eq!(parse(&bytes).unwrap().loader, loader, "{key}"); + } + } + + #[test] + fn a_pack_without_a_minecraft_version_is_rejected() { + let bytes = index(serde_json::json!([]), serde_json::json!({})); + assert!(parse(&bytes).is_err()); + } +} diff --git a/packages/oneclient_content/src/modpacks/update.rs b/packages/oneclient_content/src/modpacks/update.rs new file mode 100644 index 00000000..08dc5621 --- /dev/null +++ b/packages/oneclient_content/src/modpacks/update.rs @@ -0,0 +1,212 @@ +use oneclient_db::dao::artifact as artifact_dao; +use oneclient_db::models::{ClusterRow, ProviderReleaseRow}; + +use crate::ctx::ContentCtx; +use crate::error::ContentResult; +use crate::packages::store::{self, PackageStore}; +use crate::packages::types::{ReleaseType, VersionSummary}; +use oneclient_common::domain::{GameLoader, ProviderId}; + +const VERSIONS_LIMIT: usize = 500; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ModpackRelease { + pub provider: ProviderId, + pub project_id: String, + pub version_id: String, + pub name: String, + pub version: String, + pub published_at: Option, +} + +#[derive(Debug, Clone)] +pub enum ModpackUpdateStatus { + Unpublished, + UpToDate, + Available(VersionSummary), +} + +impl ModpackRelease { + fn from_row(row: ProviderReleaseRow) -> Option { + Some(Self { + provider: ProviderId::from_repr(u8::try_from(row.provider).ok()?)?, + project_id: row.project_id, + version_id: row.version_id, + name: row.display_name, + version: row.display_version, + published_at: row.published_at, + }) + } +} + +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn identify_modpack( + hash: &str, + ctx: &ContentCtx, +) -> ContentResult> { + if let Some(row) = artifact_dao::get_release_by_hash(&ctx.db, hash).await? { + return Ok(ModpackRelease::from_row(row)); + } + + let Some((provider, version)) = ctx.providers.lookup_version(hash, ctx).await? else { + return Ok(None); + }; + store::record_release(provider, &version, hash, ctx).await?; + + Ok(artifact_dao::get_release_by_hash(&ctx.db, hash) + .await? + .and_then(ModpackRelease::from_row)) +} + +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn cluster_modpack( + cluster_id: i64, + ctx: &ContentCtx, +) -> ContentResult> { + let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; + linked_release(&cluster, ctx).await +} + +async fn linked_release( + cluster: &ClusterRow, + ctx: &ContentCtx, +) -> ContentResult> { + match cluster.linked_modpack_hash.as_deref() { + Some(hash) => identify_modpack(hash, ctx).await, + None => Ok(None), + } +} + +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn check_modpack_update( + cluster_id: i64, + ctx: &ContentCtx, +) -> ContentResult { + let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; + let Some(current) = linked_release(&cluster, ctx).await? else { + return Ok(ModpackUpdateStatus::Unpublished); + }; + + let provider = ctx.providers.get(current.provider)?; + let versions = provider + .list_versions( + ¤t.project_id, + Some(&cluster.mc_version), + None, + 0, + VERSIONS_LIMIT, + ctx, + ) + .await? + .items; + + let loader = cluster_loader(cluster.mc_loader); + let versions = versions + .into_iter() + .filter(|version| { + version.loaders.is_empty() + || loader.is_none_or(|loader| version.loaders.contains(&loader)) + }) + .collect(); + + Ok(match newest(versions, ¤t) { + Some(latest) => ModpackUpdateStatus::Available(latest), + None => ModpackUpdateStatus::UpToDate, + }) +} + +fn cluster_loader(repr: i64) -> Option { + u8::try_from(repr).ok().and_then(GameLoader::from_repr) +} + +fn newest(versions: Vec, current: &ModpackRelease) -> Option { + let current_published = versions + .iter() + .find(|version| version.version_id == current.version_id) + .map(|version| version.published) + .or_else(|| { + current + .published_at + .as_deref() + .and_then(|at| chrono::DateTime::parse_from_rfc3339(at).ok()) + .map(|at| at.with_timezone(&chrono::Utc)) + }); + + let newer = versions.into_iter().filter(|version| { + version.version_id != current.version_id + && current_published.is_none_or(|published| version.published > published) + }); + + let (releases, others): (Vec<_>, Vec<_>) = + newer.partition(|version| matches!(version.release_type, ReleaseType::Release)); + + let latest = + |list: Vec| list.into_iter().max_by_key(|version| version.published); + latest(releases).or_else(|| latest(others)) +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::{TimeZone, Utc}; + + fn version(id: &str, day: u32, release_type: ReleaseType) -> VersionSummary { + VersionSummary { + version_id: id.into(), + project_id: "pack".into(), + name: id.into(), + version_number: id.into(), + published: Utc.with_ymd_and_hms(2026, 9, day, 0, 0, 0).unwrap(), + release_type, + game_versions: Vec::new(), + loaders: Vec::new(), + downloads: 0, + file_size: 0, + dependencies: Vec::new(), + } + } + + fn current(id: &str) -> ModpackRelease { + ModpackRelease { + provider: ProviderId::Modrinth, + project_id: "pack".into(), + version_id: id.into(), + name: "Pack".into(), + version: id.into(), + published_at: None, + } + } + + #[test] + fn the_newest_release_wins_over_a_newer_beta() { + let versions = vec![ + version("v1", 1, ReleaseType::Release), + version("v2", 5, ReleaseType::Release), + version("v3-beta", 9, ReleaseType::Beta), + ]; + + let latest = newest(versions, ¤t("v1")).unwrap(); + assert_eq!(latest.version_id, "v2"); + } + + #[test] + fn nothing_newer_means_no_update() { + let versions = vec![ + version("v1", 1, ReleaseType::Release), + version("v2", 5, ReleaseType::Release), + ]; + + assert!(newest(versions, ¤t("v2")).is_none()); + } + + #[test] + fn a_beta_is_offered_when_no_release_is_newer() { + let versions = vec![ + version("v2", 5, ReleaseType::Release), + version("v3-beta", 9, ReleaseType::Beta), + ]; + + let latest = newest(versions, ¤t("v2")).unwrap(); + assert_eq!(latest.version_id, "v3-beta"); + } +} diff --git a/packages/oneclient_content/src/packages/error.rs b/packages/oneclient_content/src/packages/error.rs index cbbabd27..4ac1cfda 100644 --- a/packages/oneclient_content/src/packages/error.rs +++ b/packages/oneclient_content/src/packages/error.rs @@ -11,8 +11,6 @@ pub enum PackageError { 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")] diff --git a/packages/oneclient_content/src/packages/mod.rs b/packages/oneclient_content/src/packages/mod.rs index d3ab2f7f..193d9629 100644 --- a/packages/oneclient_content/src/packages/mod.rs +++ b/packages/oneclient_content/src/packages/mod.rs @@ -4,7 +4,6 @@ pub mod dependencies; pub mod error; pub mod local_manifest; pub mod metadata_cache; -pub mod modpack; pub mod provider; pub mod release_migration; pub mod store; diff --git a/packages/oneclient_content/src/packages/modpack/mod.rs b/packages/oneclient_content/src/packages/modpack/mod.rs deleted file mode 100644 index 30753904..00000000 --- a/packages/oneclient_content/src/packages/modpack/mod.rs +++ /dev/null @@ -1,17 +0,0 @@ -mod mrpack; - -use std::path::Path; - -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, -) -> ContentResult<()> { - 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 deleted file mode 100644 index d07bfbc2..00000000 --- a/packages/oneclient_content/src/packages/modpack/mrpack.rs +++ /dev/null @@ -1,176 +0,0 @@ -use std::collections::HashMap; -use std::path::{Path, PathBuf}; - -use oneclient_db::models::ClusterRow; -use serde::Deserialize; -use uuid::Uuid; - -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 oneclient_common::domain::ContentType; - -pub struct MrpackInstaller; - -#[derive(Deserialize)] -#[allow(dead_code)] -struct MrpackManifest { - #[serde(default)] - files: Vec, - #[serde(default)] - dependencies: HashMap, -} - -#[derive(Deserialize)] -struct MrpackFileEntry { - path: String, - hashes: MrpackHashes, - #[serde(default)] - downloads: Vec, - #[serde(rename = "fileSize")] - file_size: u64, -} - -#[derive(Deserialize)] -struct MrpackHashes { - sha1: String, -} - -impl MrpackInstaller { - #[tracing::instrument(skip(ctx))] - pub async fn install_archive( - archive_path: PathBuf, - cluster_id: i64, - ctx: &ContentCtx, - ) -> ContentResult<()> { - tracing::info!("installing mrpack archive to cluster"); - let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; - let cluster_root = oneclient_common::paths::clusters_dir()?.join(&cluster.folder_name); - - let file = tokio::fs::File::open(&archive_path).await?; - let manifest_bytes = match polyio::try_read_zip_entry_bytes( - tokio::io::BufReader::new(file), - "modrinth.index.json", - ) - .await - { - Err(polyio::IOError::FileNotFoundInZip { .. }) => { - return Err(PackageError::UnsupportedModpackFormat.into()); - } - result => result?, - }; - - let manifest: MrpackManifest = serde_json::from_slice(&manifest_bytes)?; - - let mut failed = 0u64; - let total = manifest.files.len() as u64; - 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); - - let content_type = content_type_from_path(&entry.path); - let hash = entry.hashes.sha1.to_ascii_lowercase(); - let file_name = Path::new(&entry.path) - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or(&entry.path) - .to_string(); - - let path_str = entry.path.clone(); - - 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"); - } - } - - ctx.events - .progress(progress_id, "Installing Modpack Files", total, total); - - polyio::extract_zip_filtered( - &archive_path, - &cluster_root, - Some(|name: &str| { - name.strip_prefix("overrides/") - .is_some_and(|rest| !rest.is_empty()) - }), - Some(|name: &str| name.strip_prefix("overrides/").unwrap_or(name).to_string()), - ) - .await?; - - if failed > 0 { - tracing::error!(failed, total, "modpack install completed with failures"); - return Err(PackageError::PartialModpackInstall { failed, total }.into()); - } - - tracing::info!(total, "modpack install complete"); - Ok(()) - } -} - -#[tracing::instrument(level = "debug", skip(entry, cluster, ctx))] -async fn install_mrpack_file( - entry: MrpackFileEntry, - content_type: ContentType, - hash: String, - file_name: String, - cluster: &ClusterRow, - ctx: &ContentCtx, -) -> ContentResult<()> { - 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?; - return Ok(()); - } - } - - 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?; - PackageStore::link_artifact(&artifact, cluster, Some(&file_name), ctx).await?; - return Ok(()); - } - - let url = entry - .downloads - .first() - .cloned() - .ok_or(PackageError::NoPrimaryFile)?; - - let external = ExternalFile { - name: file_name.clone(), - url, - sha1: hash, - size: entry.file_size, - content_type, - }; - - let artifact = store::download_external(&external, false, None, ctx).await?; - PackageStore::link_artifact(&artifact, cluster, Some(&file_name), ctx).await?; - - Ok(()) -} - -fn content_type_from_path(path: &str) -> ContentType { - let top = path.split('/').next().unwrap_or(""); - ContentType::from_folder_name(top).unwrap_or(ContentType::Mod) -} - -#[tracing::instrument(level = "debug", skip(ctx))] -pub async fn install_mrpack_to_cluster( - archive_path: PathBuf, - cluster_id: i64, - ctx: &ContentCtx, -) -> ContentResult<()> { - MrpackInstaller::install_archive(archive_path, cluster_id, ctx).await -} diff --git a/packages/oneclient_content/src/packages/provider/curseforge.rs b/packages/oneclient_content/src/packages/provider/curseforge.rs index 1fa2fa0d..3cdf810b 100644 --- a/packages/oneclient_content/src/packages/provider/curseforge.rs +++ b/packages/oneclient_content/src/packages/provider/curseforge.rs @@ -552,7 +552,8 @@ struct CfFile { #[serde(default)] hashes: Vec, file_fingerprint: u32, - download_url: String, + #[serde(default)] + download_url: Option, file_length: u64, #[serde(default)] dependencies: Vec, @@ -737,7 +738,7 @@ impl From for VersionDetail { downloads: f.download_count, files: vec![VersionFile { sha1, - url: f.download_url, + url: f.download_url.unwrap_or_default(), file_name: f.file_name, primary: true, size: f.file_length, @@ -862,4 +863,26 @@ mod tests { assert_eq!(version.dependencies[2].kind, DependencyKind::Incompatible); assert_eq!(version.dependencies[3].kind, DependencyKind::Embedded); } + + #[test] + fn a_distribution_blocked_file_still_parses() { + let raw = serde_json::json!({ + "id": 1, + "modId": 2, + "displayName": "Blocked", + "fileName": "blocked.jar", + "releaseType": 1, + "fileDate": "2025-01-01T00:00:00Z", + "downloadCount": 10, + "fileFingerprint": 123, + "downloadUrl": null, + "fileLength": 100 + }); + + let version: VersionDetail = serde_json::from_value::(raw) + .expect("curseforge file") + .into(); + + assert_eq!(version.files[0].url, ""); + } } diff --git a/packages/oneclient_content/src/packages/store/mod.rs b/packages/oneclient_content/src/packages/store/mod.rs index acc61abb..ffdf9e34 100644 --- a/packages/oneclient_content/src/packages/store/mod.rs +++ b/packages/oneclient_content/src/packages/store/mod.rs @@ -458,6 +458,16 @@ async fn store_local_file( ) -> ContentResult { ensure_takes_mods(content_type, cluster)?; + let row = cache_local_file(path, content_type, ctx).await?; + PackageStore::link_artifact(&row, cluster, None, ctx).await?; + Ok(row) +} + +pub async fn cache_local_file( + path: &Path, + content_type: ContentType, + ctx: &ContentCtx, +) -> ContentResult { let file_name = path .file_name() .and_then(|name| name.to_str()) @@ -466,8 +476,9 @@ async fn store_local_file( let hash = normalize_hash(&sha1_file(path).await?); - if let Some(row) = artifact_dao::get_artifact_by_hash(&ctx.db, &hash).await? { - PackageStore::link_artifact(&row, cluster, None, ctx).await?; + if let Some(row) = artifact_dao::get_artifact_by_hash(&ctx.db, &hash).await? + && artifact_absolute_path(&row.path)?.exists() + { return Ok(row); } @@ -486,7 +497,7 @@ async fn store_local_file( let size = polyio::stat(&dest).await?.len(); let stored_path = relative_cache_path(&dest)?; - let row = artifact_dao::insert_artifact( + artifact_dao::insert_artifact( &ctx.db, &hash, content_type as i64, @@ -494,10 +505,8 @@ async fn store_local_file( &file_name, Some(size as i64), ) - .await?; - - PackageStore::link_artifact(&row, cluster, None, ctx).await?; - Ok(row) + .await + .map_err(Into::into) } /// A dropped file arrives as a file name and nothing else so it is worth diff --git a/packages/oneclient_core/examples/download_game.rs b/packages/oneclient_core/examples/download_game.rs index 5f964960..41b81c08 100644 --- a/packages/oneclient_core/examples/download_game.rs +++ b/packages/oneclient_core/examples/download_game.rs @@ -31,6 +31,7 @@ async fn main() -> LauncherResult<()> { mem_max: None, kind: oneclient_cluster::ClusterKind::OneClient, user_created: false, + modpack: false, description: None, tags: Vec::new(), }, diff --git a/packages/oneclient_core/src/clusters/mod.rs b/packages/oneclient_core/src/clusters/mod.rs index 3e8f42d2..c60cd63e 100644 --- a/packages/oneclient_core/src/clusters/mod.rs +++ b/packages/oneclient_core/src/clusters/mod.rs @@ -1,5 +1,6 @@ mod java_override; mod migrate; +mod modpack; pub(crate) mod prepare; mod provision; mod release_migration; @@ -7,6 +8,10 @@ mod unlink_legacy; pub use java_override::apply_bundle_java_override; pub use migrate::apply_remote_migrations; +pub use modpack::{ + ModpackCluster, ModpackSource, PreparedModpack, create_modpack_instance, + install_modpack_instance, prepare_modpack, repair_modpack_cluster, update_modpack_cluster, +}; pub use prepare::{ estimate_cluster_download, prepare_cluster, prepare_cluster_locked, required_java_major, }; diff --git a/packages/oneclient_core/src/clusters/modpack.rs b/packages/oneclient_core/src/clusters/modpack.rs new file mode 100644 index 00000000..3e06f874 --- /dev/null +++ b/packages/oneclient_core/src/clusters/modpack.rs @@ -0,0 +1,503 @@ +use std::path::PathBuf; +use std::sync::Arc; + +use oneclient_cluster::naming::{is_allowed_name_char, validate_modpack_instance_name}; +use oneclient_cluster::{Cluster, ClusterKind, ClusterUpdate, CreateClusterOptions}; +use oneclient_common::domain::{GameLoader, ProviderId}; +use oneclient_common::patch::Patch; +use oneclient_content::ContentError; +use oneclient_content::modpacks::{self, ModpackInstallReport, ModpackManifest, ModpackRelease}; +use oneclient_content::packages::store::artifact_absolute_path; +use oneclient_content::packages::{PackageStore, ProjectDetail}; +use oneclient_db::dao::artifact::get_artifact_by_hash; +use oneclient_events::GroupedProgressSession; + +use crate::LauncherResult; +use crate::images::DEFAULT_IMAGE_EDGE; +use crate::state::LauncherState; + +const FALLBACK_NAME: &str = "Modpack"; +const MODPACK_ICON_EDGE: u32 = 256; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ModpackSource { + File(PathBuf), + Provider { + provider: ProviderId, + project_id: String, + version_id: String, + }, +} + +#[derive(Debug, Clone)] +pub struct ModpackCluster { + pub cluster: Cluster, + pub report: ModpackInstallReport, +} + +#[derive(Debug, Clone)] +pub struct PreparedModpack { + pub source: ModpackSource, + pub artifact_hash: String, + pub archive_path: PathBuf, + pub manifest: ModpackManifest, + pub instance_name: String, +} + +#[tracing::instrument(skip(state))] +pub async fn prepare_modpack( + state: &Arc, + source: &ModpackSource, +) -> LauncherResult { + let content = state.services.content(); + + let artifact = match source { + ModpackSource::File(path) => modpacks::store_modpack_archive(path, &content).await?, + ModpackSource::Provider { + provider, + project_id, + version_id, + } => PackageStore::resolve_or_download(*provider, project_id, version_id, &content).await?, + }; + let archive_path = artifact_absolute_path(&artifact.path)?; + let manifest = modpacks::read_modpack(&archive_path, &content).await?; + + let taken: Vec = state + .clusters + .list() + .await? + .into_iter() + .map(|cluster| cluster.name) + .collect(); + + Ok(PreparedModpack { + source: source.clone(), + artifact_hash: artifact.hash, + archive_path, + instance_name: unique_name(&instance_name(&manifest.name), &taken), + manifest, + }) +} + +#[tracing::instrument(skip(state, prepared), fields(pack = %prepared.manifest.name))] +pub async fn create_modpack_instance( + state: &Arc, + prepared: &PreparedModpack, +) -> LauncherResult { + let manifest = &prepared.manifest; + let global = state.settings.read().global_game_settings.clone(); + let mut options = CreateClusterOptions::new( + prepared.instance_name.clone(), + manifest.mc_version.clone(), + manifest.loader, + ) + .kind(kind_for(manifest.loader)) + .user_created(true) + .modpack(true); + options.mc_loader_version = manifest.loader_version.clone(); + options.description = manifest.summary.clone(); + + let cluster = state.clusters.create(&global, options).await?; + let linked = state + .clusters + .update( + cluster.id, + ClusterUpdate { + linked_modpack_hash: Patch::Set(prepared.artifact_hash.clone()), + ..Default::default() + }, + ) + .await; + + match linked { + Ok(cluster) => Ok(cluster), + Err(err) => { + if let Err(cleanup) = state.clusters.delete(cluster.id, true).await { + tracing::warn!(cluster_id = cluster.id, error = %cleanup, "could not remove the unlinked modpack instance"); + } + Err(err.into()) + } + } +} + +#[tracing::instrument(skip(state, prepared, progress), fields(pack = %prepared.manifest.name))] +pub async fn install_modpack_instance( + state: &Arc, + cluster_id: i64, + prepared: &PreparedModpack, + progress: Option<&GroupedProgressSession>, +) -> LauncherResult { + let content = state.services.content(); + let report = modpacks::install_modpack( + &prepared.archive_path, + &prepared.manifest, + cluster_id, + progress, + &content, + ) + .await?; + + match modpacks::identify_modpack(&prepared.artifact_hash, &content).await { + Ok(Some(release)) => { + if let Err(err) = apply_project_details(state, cluster_id, &release).await { + tracing::debug!(cluster_id, error = %err, "could not read the modpack's project page"); + } + } + Ok(None) => {} + Err(err) => { + tracing::debug!(error = %err, "could not identify the modpack on any provider"); + } + } + + let cluster = state.clusters.get(cluster_id).await?; + Ok(ModpackCluster { cluster, report }) +} + +#[tracing::instrument(skip(state, progress))] +pub async fn update_modpack_cluster( + state: &Arc, + cluster_id: i64, + version_id: &str, + progress: Option<&GroupedProgressSession>, +) -> LauncherResult { + let content = state.services.content(); + let cluster = state.clusters.get(cluster_id).await?; + let current = modpacks::cluster_modpack(cluster_id, &content) + .await? + .ok_or_else(|| ContentError::InvalidData { + reason: "This instance's modpack is not published on Modrinth or CurseForge." + .to_string(), + })?; + + let artifact = PackageStore::resolve_or_download( + current.provider, + ¤t.project_id, + version_id, + &content, + ) + .await?; + let archive_path = artifact_absolute_path(&artifact.path)?; + let manifest = modpacks::read_modpack(&archive_path, &content).await?; + + if manifest.mc_version != cluster.mc_version || manifest.loader != cluster.mc_loader { + return Err(ContentError::InvalidData { + reason: format!( + "This version needs Minecraft {} with {}. Install it as a new instance instead.", + manifest.mc_version, manifest.loader + ), + } + .into()); + } + + let report = + modpacks::install_modpack(&archive_path, &manifest, cluster_id, progress, &content).await?; + + let loader_version = match &manifest.loader_version { + Some(version) if cluster.mc_loader_version.as_ref() != Some(version) => { + Patch::Set(version.clone()) + } + _ => Patch::Unchanged, + }; + let cluster = state + .clusters + .update( + cluster_id, + ClusterUpdate { + linked_modpack_hash: Patch::Set(artifact.hash.clone()), + mc_loader_version: loader_version, + ..Default::default() + }, + ) + .await?; + restore_missing_icon(state, &cluster).await; + + Ok(ModpackCluster { cluster, report }) +} + +#[tracing::instrument(skip(state, progress))] +pub async fn repair_modpack_cluster( + state: &Arc, + cluster_id: i64, + progress: Option<&GroupedProgressSession>, +) -> LauncherResult { + let content = state.services.content(); + let cluster = state.clusters.get(cluster_id).await?; + let hash = cluster + .linked_modpack_hash + .clone() + .ok_or_else(|| ContentError::InvalidData { + reason: "This instance was not installed from a modpack.".to_string(), + })?; + + let artifact = get_artifact_by_hash(&state.services.db, &hash) + .await? + .ok_or_else(|| ContentError::InvalidData { + reason: + "The modpack file for this instance is gone. Update the pack to fetch it again." + .to_string(), + })?; + let archive_path = artifact_absolute_path(&artifact.path)?; + let manifest = modpacks::read_modpack(&archive_path, &content).await?; + + let report = + modpacks::install_modpack(&archive_path, &manifest, cluster_id, progress, &content).await?; + restore_missing_icon(state, &cluster).await; + + Ok(ModpackCluster { cluster, report }) +} + +async fn fetch_project( + state: &Arc, + release: &ModpackRelease, +) -> LauncherResult { + let content = state.services.content(); + Ok(content + .providers + .get(release.provider)? + .get_project(&release.project_id, &content) + .await?) +} + +async fn apply_project_details( + state: &Arc, + cluster_id: i64, + release: &ModpackRelease, +) -> LauncherResult<()> { + let project = fetch_project(state, release).await?; + let cluster = state.clusters.get(cluster_id).await?; + + if let Err(err) = apply_project_description(state, &cluster, &project).await { + tracing::debug!(cluster_id, error = %err, "could not set the modpack description"); + } + if let Err(err) = save_project_icon(state, &cluster, &project).await { + tracing::debug!(cluster_id, error = %err, "no icon for the modpack"); + } + if let Err(err) = apply_project_cover(state, &cluster, &project).await { + tracing::debug!(cluster_id, error = %err, "no cover art for the modpack"); + } + Ok(()) +} + +async fn save_project_icon( + state: &Arc, + cluster: &Cluster, + project: &ProjectDetail, +) -> LauncherResult<()> { + let (Some(url), Some(dest)) = ( + project.icon_url.as_deref().filter(|url| !url.is_empty()), + cluster.modpack_icon_file(), + ) else { + return Ok(()); + }; + + let cached = state + .images + .ensure_on_disk(&state.services.requester, url, MODPACK_ICON_EDGE) + .await?; + if let Some(parent) = dest.parent() { + polyio::create_dir_all(parent).await?; + } + polyio::copy(&cached, &dest).await?; + Ok(()) +} + +async fn restore_missing_icon(state: &Arc, cluster: &Cluster) { + if cluster + .modpack_icon_file() + .is_none_or(|icon| icon.is_file()) + { + return; + } + + let content = state.services.content(); + let restored = async { + let Some(release) = modpacks::cluster_modpack(cluster.id, &content).await? else { + return Ok(()); + }; + let project = fetch_project(state, &release).await?; + save_project_icon(state, cluster, &project).await + }; + let result: LauncherResult<()> = restored.await; + if let Err(err) = result { + tracing::debug!(cluster_id = cluster.id, error = %err, "could not restore the modpack icon"); + } +} + +async fn apply_project_description( + state: &Arc, + cluster: &Cluster, + project: &ProjectDetail, +) -> LauncherResult<()> { + let Some(description) = project_description(cluster.description.as_deref(), project) else { + return Ok(()); + }; + + state + .clusters + .update( + cluster.id, + ClusterUpdate { + description: Patch::Set(description), + ..Default::default() + }, + ) + .await?; + Ok(()) +} + +fn project_description(current: Option<&str>, project: &ProjectDetail) -> Option { + if current.is_some_and(|current| !current.trim().is_empty()) { + return None; + } + let summary = project.summary.trim(); + (!summary.is_empty()).then(|| summary.to_string()) +} + +async fn apply_project_cover( + state: &Arc, + cluster: &Cluster, + project: &ProjectDetail, +) -> LauncherResult<()> { + let Some(url) = cover_url(project) else { + return Ok(()); + }; + + let cached = state + .images + .ensure_on_disk(&state.services.requester, &url, DEFAULT_IMAGE_EDGE) + .await?; + state + .clusters + .set_cover_from_file(cluster.id, &cached) + .await?; + Ok(()) +} + +fn cover_url(project: &ProjectDetail) -> Option { + project + .gallery + .first() + .map(|image| image.url.clone()) + .or_else(|| project.icon_url.clone()) + .filter(|url| !url.is_empty()) +} + +fn kind_for(loader: GameLoader) -> ClusterKind { + if loader.is_modded() { + ClusterKind::Modded + } else { + ClusterKind::Vanilla + } +} + +fn unique_name(base: &str, taken: &[String]) -> String { + let is_taken = |candidate: &str| { + taken + .iter() + .any(|name| name.trim().eq_ignore_ascii_case(candidate)) + }; + if !is_taken(base) { + return base.to_string(); + } + + (2..) + .map(|n| format!("{base} ({n})")) + .find(|candidate| !is_taken(candidate)) + .unwrap_or_else(|| base.to_string()) +} + +fn instance_name(pack_name: &str) -> String { + let name = pack_name + .chars() + .filter(|c| is_allowed_name_char(*c)) + .collect::() + .split_whitespace() + .collect::>() + .join(" "); + + if validate_modpack_instance_name(&name).is_ok() { + name + } else { + FALLBACK_NAME.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn project(summary: &str) -> ProjectDetail { + ProjectDetail { + id: "pack".into(), + slug: "pack".into(), + provider: ProviderId::Modrinth, + content_type: oneclient_content::packages::ContentType::Modpack, + name: "Pack".into(), + summary: summary.into(), + author: String::new(), + members: Vec::new(), + gallery: Vec::new(), + body: oneclient_content::packages::PackageBody::Raw(String::new()), + license: None, + links: Vec::new(), + version_ids: Vec::new(), + game_versions: Vec::new(), + loaders: Vec::new(), + icon_url: None, + created: chrono::Utc::now(), + updated: chrono::Utc::now(), + downloads: 0, + } + } + + #[test] + fn the_project_summary_only_fills_an_empty_description() { + let summary = " Adventure pack with 300+ mods. "; + + assert_eq!( + project_description(None, &project(summary)), + Some("Adventure pack with 300+ mods.".to_string()) + ); + assert_eq!( + project_description(Some(" "), &project(summary)), + Some("Adventure pack with 300+ mods.".to_string()) + ); + assert_eq!(project_description(Some("Mine"), &project(summary)), None); + assert_eq!(project_description(None, &project(" ")), None); + } + + #[test] + fn pack_names_are_cut_down_to_a_valid_instance_name() { + assert_eq!( + instance_name("Fabulously Optimized"), + "Fabulously Optimized" + ); + assert_eq!( + instance_name("All the Mods 10: To the Sky"), + "All the Mods 10 To the Sky" + ); + assert_eq!(instance_name("Better MC [FORGE]"), "Better MC FORGE"); + assert_eq!(instance_name("✨✨✨"), FALLBACK_NAME); + assert_eq!(instance_name(""), FALLBACK_NAME); + } + + #[test] + fn a_second_copy_of_a_pack_gets_a_numbered_name() { + let taken = vec!["Better MC FABRIC BMC".to_string()]; + assert_eq!( + unique_name("Better MC FABRIC BMC", &taken), + "Better MC FABRIC BMC (2)" + ); + + let taken = vec!["Fabulously".to_string(), "fabulously (2)".to_string()]; + assert_eq!(unique_name("Fabulously", &taken), "Fabulously (3)"); + assert_eq!(unique_name("Fresh", &taken), "Fresh"); + assert!(validate_modpack_instance_name(&unique_name("Fabulously", &taken)).is_ok()); + } + + #[test] + fn a_pack_without_a_loader_makes_a_vanilla_instance() { + assert_eq!(kind_for(GameLoader::Vanilla), ClusterKind::Vanilla); + assert_eq!(kind_for(GameLoader::Fabric), ClusterKind::Modded); + } +} diff --git a/packages/oneclient_core/src/game/launch.rs b/packages/oneclient_core/src/game/launch.rs index aaf3ab76..891d63b8 100644 --- a/packages/oneclient_core/src/game/launch.rs +++ b/packages/oneclient_core/src/game/launch.rs @@ -105,6 +105,11 @@ async fn start( state.games.set_dir(cluster_id, game_dir.clone()); + match state.clusters.mark_played(cluster_id).await { + Ok(()) => events.signal(oneclient_events::Signal::ClustersChanged), + Err(err) => tracing::debug!(cluster_id, error = %err, "could not record the launch time"), + } + let progress = GroupedProgressSession::start( &state.services.events, format!("Launching {}", existing.name), diff --git a/packages/oneclient_db/src/dao/artifact.rs b/packages/oneclient_db/src/dao/artifact.rs index 6e85d7da..30e28395 100644 --- a/packages/oneclient_db/src/dao/artifact.rs +++ b/packages/oneclient_db/src/dao/artifact.rs @@ -56,10 +56,11 @@ pub async fn insert_artifact( /// this layer does not touch the disk pub async fn delete_artifact_if_unused(pool: &SqlitePool, hash: &str) -> Result { let result = sqlx::query( - "DELETE FROM artifacts WHERE hash = ? AND NOT EXISTS (SELECT 1 FROM cluster_artifacts WHERE hash = ?)", + "DELETE FROM artifacts WHERE hash = ? AND NOT EXISTS (SELECT 1 FROM cluster_artifacts WHERE hash = ?) AND NOT EXISTS (SELECT 1 FROM clusters WHERE linked_modpack_hash = ?)", ) .bind(hash) .bind(hash) + .bind(hash) .execute(pool) .await?; @@ -133,6 +134,7 @@ pub async fn list_unused_artifacts(pool: &SqlitePool) -> Result SELECT hash, content_type, path, file_name, size_bytes FROM artifacts WHERE hash NOT IN (SELECT hash FROM cluster_artifacts) + AND hash NOT IN (SELECT linked_modpack_hash FROM clusters WHERE linked_modpack_hash IS NOT NULL) "#, ) .fetch_all(pool) diff --git a/packages/oneclient_db/src/dao/cluster.rs b/packages/oneclient_db/src/dao/cluster.rs index 3844d3ce..cae774d6 100644 --- a/packages/oneclient_db/src/dao/cluster.rs +++ b/packages/oneclient_db/src/dao/cluster.rs @@ -238,6 +238,15 @@ pub async fn set_stage(pool: &SqlitePool, id: i64, stage: i64) -> Result Result<(), sqlx::Error> { + sqlx::query("UPDATE clusters SET last_played = ? WHERE id = ?") + .bind(Utc::now().to_rfc3339()) + .bind(id) + .execute(pool) + .await?; + Ok(()) +} + pub async fn add_playtime( pool: &SqlitePool, id: i64, diff --git a/packages/polyio/src/archive.rs b/packages/polyio/src/archive.rs index f5f138ea..3e9f215a 100644 --- a/packages/polyio/src/archive.rs +++ b/packages/polyio/src/archive.rs @@ -246,6 +246,72 @@ pub async fn read_zip_file_entries( Ok(out) } +pub const MAX_STREAMED_ENTRY_BYTES: u64 = 1024 * 1024 * 1024; + +pub struct ZipEntryCursor { + reader: FileZipReader, + indices: std::collections::HashMap, +} + +impl ZipEntryCursor { + #[tracing::instrument(level = "debug", skip(zip_path, filter), fields(zip_path = %zip_path.as_ref().display()))] + pub async fn open( + zip_path: impl AsRef, + filter: impl Fn(&str) -> bool, + ) -> PolyIOResult { + let reader = open_zip_file(zip_path.as_ref()).await?; + + let mut indices = std::collections::HashMap::new(); + for (index, cdr) in reader.cdrs().iter().enumerate() { + let Some(name) = cdr.insecure_file_name.as_str() else { + continue; + }; + if name.ends_with('/') || !filter(name) { + continue; + } + indices.insert(name.to_string(), index); + } + + Ok(Self { reader, indices }) + } + + pub fn names(&self) -> impl Iterator { + self.indices.keys() + } + + pub async fn read(&mut self, name: &str) -> PolyIOResult> { + let index = *self + .indices + .get(name) + .ok_or_else(|| IOError::FileNotFoundInZip { + file_name: name.to_string(), + })?; + + let declared = self.reader.cdrs()[index].uncompressed_size()?; + if declared > MAX_STREAMED_ENTRY_BYTES { + return Err(IOError::IOError(std::io::Error::other(format!( + "'{name}' declares {declared} bytes, over the {MAX_STREAMED_ENTRY_BYTES} byte limit" + )))); + } + + let entry_reader = self.reader.file(index).await?; + let mut data = Vec::with_capacity(usize::try_from(declared).unwrap_or_default()); + futures_lite::AsyncReadExt::read_to_end( + &mut futures_lite::AsyncReadExt::take(entry_reader, MAX_STREAMED_ENTRY_BYTES + 1), + &mut data, + ) + .await?; + + if data.len() as u64 > MAX_STREAMED_ENTRY_BYTES { + return Err(IOError::IOError(std::io::Error::other(format!( + "'{name}' decompressed past the {MAX_STREAMED_ENTRY_BYTES} byte limit" + )))); + } + + Ok(data) + } +} + #[tracing::instrument( level = "debug", skip(zip_path),