feat: working modpacks from modrinth and curseforge as well as manually imported; couple ui fixes (#894)
This commit is contained in:
parent
d630c27a88
commit
06fa7739b3
62 changed files with 5494 additions and 374 deletions
528
packages/oneclient_app/src/components/blocked_downloads_popup.rs
Normal file
528
packages/oneclient_app/src/components/blocked_downloads_popup.rs
Normal file
|
|
@ -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<PathBuf> {
|
||||
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<Option<(i64, PathBuf)>>;
|
||||
|
||||
fn bump(mut tick: State<u64>) {
|
||||
let next = *tick.peek() + 1;
|
||||
tick.set(next);
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
struct ExtraFolderWatch {
|
||||
folder: PathBuf,
|
||||
tick: State<u64>,
|
||||
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<PathBuf> =
|
||||
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<PathBuf>, 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<BlockedFile>,
|
||||
browse_start: Option<PathBuf>,
|
||||
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)
|
||||
}
|
||||
|
|
@ -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<Vec<PathBuf>>,
|
||||
mut modpacks: State<Vec<PathBuf>>,
|
||||
) {
|
||||
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<Vec<PathBuf>>, modpacks: State<Vec<PathBuf>>) {
|
||||
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<bool>,
|
||||
pub pending: State<Vec<PathBuf>>,
|
||||
pub modpacks: State<Vec<PathBuf>>,
|
||||
}
|
||||
|
||||
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() {
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
259
packages/oneclient_app/src/components/modpack_confirm_popup.rs
Normal file
259
packages/oneclient_app/src/components/modpack_confirm_popup.rs
Normal file
|
|
@ -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<String> = [
|
||||
(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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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<SizedEventData>| {
|
||||
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<SizedEventData>| {
|
||||
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<f32>, 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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
529
packages/oneclient_app/src/hooks/actions/modpacks.rs
Normal file
529
packages/oneclient_app/src/hooks/actions/modpacks.rs
Normal file
|
|
@ -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<PreparedModpack>),
|
||||
Running(ClusterId),
|
||||
}
|
||||
|
||||
static JOB: Mutex<JobSlot> = Mutex::new(JobSlot::Idle);
|
||||
|
||||
fn with_slot<R>(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<PreparedModpack> {
|
||||
with_slot(|slot| match std::mem::replace(slot, JobSlot::Preparing) {
|
||||
JobSlot::AwaitingConfirm(prepared) => Some(*prepared),
|
||||
other => {
|
||||
*slot = other;
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
enum ModpackJob {
|
||||
Install(Box<PreparedModpack>),
|
||||
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<ClusterId> {
|
||||
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<PathBuf>,
|
||||
files: Vec<BlockedFile>,
|
||||
) {
|
||||
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::<Vec<_>>())
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
match imported {
|
||||
Ok(found) if found.is_empty() => {}
|
||||
Ok(found) => {
|
||||
let sha1s: Vec<String> = 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<Item = String>) -> String {
|
||||
let names: Vec<String> = names.collect();
|
||||
let shown = names
|
||||
.iter()
|
||||
.take(MAX_LISTED)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
.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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<PathBuf>,
|
||||
watch_children: bool,
|
||||
relevant: fn(&Path, &Event) -> bool,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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::<PathBuf>::new);
|
||||
let drop_pending = use_state(Vec::<PathBuf>::new);
|
||||
let dropped_modpacks = use_state(Vec::<PathBuf>::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<FileEventData>| {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<BlockedFile>,
|
||||
pub added: HashSet<String>,
|
||||
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<BlockedFile> {
|
||||
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<Vec<ClusterUpdateSummary>>,
|
||||
pub optional_mods: Option<Vec<OptionalModsGroup>>,
|
||||
pub package_updates: Option<Vec<PackageUpdateGroup>>,
|
||||
pub blocked_downloads: Option<BlockedDownloads>,
|
||||
pub open_cluster: Option<ClusterId>,
|
||||
pub modpack_confirm: Option<ModpackConfirm>,
|
||||
pub active_toast_entry_ids: Vec<u64>,
|
||||
}
|
||||
|
||||
|
|
@ -237,6 +275,9 @@ pub struct NotificationState {
|
|||
cluster_update: Option<Vec<ClusterUpdateSummary>>,
|
||||
optional_mods: Option<Vec<OptionalModsGroup>>,
|
||||
package_updates: Option<Vec<PackageUpdateGroup>>,
|
||||
blocked_downloads: Option<BlockedDownloads>,
|
||||
open_cluster: Option<ClusterId>,
|
||||
modpack_confirm: Option<ModpackConfirm>,
|
||||
/// 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<ClusterId> {
|
||||
self.open_cluster.take()
|
||||
}
|
||||
|
||||
pub fn take_blocked_downloads(&mut self) -> Option<BlockedDownloads> {
|
||||
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<_>>(),
|
||||
vec!["bb"]
|
||||
);
|
||||
}
|
||||
|
||||
fn spec(title: &str) -> NotificationSpec {
|
||||
NotificationSpec {
|
||||
title: title.to_string(),
|
||||
|
|
|
|||
|
|
@ -121,6 +121,9 @@ pub struct RelocationState {
|
|||
pub struct InstallState {
|
||||
pending: HashSet<(i64, ProviderId, String)>,
|
||||
pub flagged: Option<FlaggedInstallPrompt>,
|
||||
pub modpack_busy: bool,
|
||||
pub modpack_project: Option<(ProviderId, String)>,
|
||||
pub modpack_cluster: Option<i64>,
|
||||
}
|
||||
|
||||
#[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
|
||||
|
|
|
|||
|
|
@ -334,13 +334,24 @@ pub fn bundle_display_name(archive: &BundleArchive) -> String {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn default_cluster(clusters: Vec<Cluster>, active: Option<i64>) -> Option<Cluster> {
|
||||
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<Cluster>) -> Vec<Cluster> {
|
||||
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<chrono::DateTime<chrono::Utc>> {
|
||||
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<i64> = 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<i64> = 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<u32>) -> ReleaseLine {
|
||||
ReleaseLine { major, minor }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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::<String>);
|
||||
|
||||
// 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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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<State<Option<String>>>,
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ pub(super) fn sidebar(
|
|||
confirm: State<Option<String>>,
|
||||
installed: Option<Installed>,
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -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<Element> = 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()),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
169
packages/oneclient_app/src/view/app/cluster/modpack_settings.rs
Normal file
169
packages/oneclient_app/src/view/app/cluster/modpack_settings.rs
Normal file
|
|
@ -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<Option<ModpackRelease>>) {
|
||||
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<UpdateCheck>) {
|
||||
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::<ModpackRelease>);
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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<ClusterAction> {
|
||||
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::<String>),
|
||||
declined: use_state(|| None::<HashSet<String>>),
|
||||
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(());
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<GameLoader>) -> 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,
|
||||
|
|
|
|||
|
|
@ -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<PathBuf>,
|
||||
version_art: DynamicArt,
|
||||
name_problem: Option<NameProblem>,
|
||||
name_limit: Option<usize>,
|
||||
) -> 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),
|
||||
);
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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<usize> {
|
||||
(!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(),
|
||||
|
|
|
|||
|
|
@ -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<LoaderChoice>,
|
||||
pub loader_version: State<Option<String>>,
|
||||
pub declined: State<Option<HashSet<String>>>,
|
||||
pub modpack_origin: State<ModpackOrigin>,
|
||||
pub details: DetailsState,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -539,6 +539,7 @@ fn filter_menu(
|
|||
fn search_input(query: State<String>, width: Size) -> Element {
|
||||
TextInput::new(query)
|
||||
.width(width)
|
||||
.height(Size::px(34.))
|
||||
.placeholder("Search instances")
|
||||
.leading(
|
||||
Icon::new(IconType::SearchMd)
|
||||
|
|
|
|||
|
|
@ -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<PathBuf> {
|
||||
self.linked_modpack_hash.as_ref()?;
|
||||
Some(self.dir().ok()?.join(MODPACK_ICON_FILE))
|
||||
}
|
||||
|
||||
pub fn cover_file(&self) -> Option<PathBuf> {
|
||||
let cover = self.cover_path.as_deref()?;
|
||||
let mut parts = std::path::Path::new(cover).components();
|
||||
|
|
|
|||
|
|
@ -129,14 +129,15 @@ impl ClusterManager {
|
|||
options: CreateClusterOptions,
|
||||
) -> ClusterResult<Cluster> {
|
||||
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<String> {
|
||||
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<u8> {
|
||||
let mut out = Vec::new();
|
||||
|
|
|
|||
|
|
@ -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<usize>) -> 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]
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ pub struct CreateClusterOptions {
|
|||
pub mem_max: Option<u32>,
|
||||
pub kind: ClusterKind,
|
||||
pub user_created: bool,
|
||||
pub modpack: bool,
|
||||
pub description: Option<String>,
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
|
@ -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<String>) -> Self {
|
||||
self.description = Some(description.into());
|
||||
self
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ pub fn effective_enabled(file: &BundleFile, user_override: Option<OverrideType>)
|
|||
}
|
||||
}
|
||||
|
||||
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<BundleFile>,
|
||||
cluster_id: i64,
|
||||
bundle_name: &str,
|
||||
skip_compatibility: bool,
|
||||
progress: Option<&GroupedProgressSession>,
|
||||
ctx: &ContentCtx,
|
||||
) -> Vec<(BundleFile, ContentResult<String>)> {
|
||||
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::<Vec<_>>()
|
||||
.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<String>,
|
||||
hashes: std::collections::HashSet<String>,
|
||||
tracked_ids: std::collections::HashSet<String>,
|
||||
}
|
||||
|
||||
impl PresentContent {
|
||||
async fn load(cluster_id: i64, ctx: &ContentCtx) -> ContentResult<Self> {
|
||||
pub(crate) async fn load(cluster_id: i64, ctx: &ContentCtx) -> ContentResult<Self> {
|
||||
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, .. } => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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<OverrideSyncReport> {
|
||||
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<Self> {
|
||||
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<String, (usize, String)> = 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<Vec<u8>> {
|
||||
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<OverrideSyncReport> {
|
||||
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<String, String> = 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<String, String>,
|
||||
listed: &std::collections::HashSet<String>,
|
||||
) -> Vec<String> {
|
||||
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<String, String> = [
|
||||
("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");
|
||||
|
|
|
|||
|
|
@ -123,65 +123,65 @@ fn parse_manifest_bytes(bytes: &[u8]) -> ContentResult<BundleManifest> {
|
|||
})
|
||||
}
|
||||
|
||||
fn parse_dependencies(
|
||||
pub(crate) struct PackDependencies {
|
||||
pub(crate) mc_version: Option<String>,
|
||||
pub(crate) loader: Option<(GameLoader, String)>,
|
||||
}
|
||||
|
||||
pub(crate) fn scan_dependencies(
|
||||
deps: &HashMap<String, String>,
|
||||
) -> 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<String, String>,
|
||||
) -> 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<BundleFile> {
|
||||
if let Some(url) = file
|
||||
.downloads
|
||||
.iter()
|
||||
.find(|url| url.starts_with(MODRINTH_CDN_PREFIX))
|
||||
{
|
||||
let paths = url[MODRINTH_CDN_PREFIX.len()..]
|
||||
.split('/')
|
||||
.collect::<Vec<_>>();
|
||||
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<BundleFile> {
|
|||
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<BundleFileKind> {
|
||||
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::<Vec<_>>();
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
#![recursion_limit = "256"]
|
||||
pub mod bundles;
|
||||
pub mod modpacks;
|
||||
pub mod packages;
|
||||
|
||||
mod ctx;
|
||||
|
|
|
|||
307
packages/oneclient_content/src/modpacks/checker.rs
Normal file
307
packages/oneclient_content/src/modpacks/checker.rs
Normal file
|
|
@ -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<Vec<u8>> {
|
||||
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<FileRequest> {
|
||||
let entries: Vec<CheckerEntry> = 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<ContentType> {
|
||||
tracked_folder(folder.trim_matches('/'))
|
||||
}
|
||||
|
||||
fn curseforge_file_id(url: &str) -> Option<u32> {
|
||||
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<String>,
|
||||
) {
|
||||
let contents = &mut manifest.contents;
|
||||
let mut ids = HashSet::new();
|
||||
let mut hashes = HashSet::new();
|
||||
let mut names: HashSet<String> = 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<String> {
|
||||
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<String> = ["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());
|
||||
}
|
||||
}
|
||||
461
packages/oneclient_content/src/modpacks/curseforge.rs
Normal file
461
packages/oneclient_content/src/modpacks/curseforge.rs
Normal file
|
|
@ -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<CfManifestFile>,
|
||||
#[serde(default)]
|
||||
overrides: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CfMinecraft {
|
||||
version: String,
|
||||
#[serde(default)]
|
||||
mod_loaders: Vec<CfModLoader>,
|
||||
}
|
||||
|
||||
#[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::<CfManifest>(bytes)
|
||||
.is_ok_and(|manifest| manifest.manifest_type == MODPACK_MANIFEST_TYPE)
|
||||
}
|
||||
|
||||
pub(super) async fn resolve(bytes: &[u8], ctx: &ContentCtx) -> ContentResult<ModpackManifest> {
|
||||
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<FileRequest> = 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<u32>,
|
||||
pub(super) required: bool,
|
||||
pub(super) label: Option<String>,
|
||||
pub(super) destination: Option<ContentType>,
|
||||
}
|
||||
|
||||
pub(super) async fn resolve_requests(
|
||||
requests: &[FileRequest],
|
||||
ctx: &ContentCtx,
|
||||
) -> ContentResult<ModpackContents> {
|
||||
let provider = ctx.providers.get(ProviderId::CurseForge)?;
|
||||
|
||||
let file_ids: Vec<String> = requests
|
||||
.iter()
|
||||
.map(|request| request.file_id.to_string())
|
||||
.collect();
|
||||
let mut versions: HashMap<String, VersionDetail> = 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<String> = requests
|
||||
.iter()
|
||||
.filter_map(project_of)
|
||||
.collect::<HashSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
let mut projects: HashMap<String, ProjectDetail> = 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<String> {
|
||||
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<String>)> {
|
||||
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()]);
|
||||
}
|
||||
}
|
||||
637
packages/oneclient_content/src/modpacks/install.rs
Normal file
637
packages/oneclient_content/src/modpacks/install.rs
Normal file
|
|
@ -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<String>,
|
||||
pub blocked: Vec<BlockedFile>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "debug", skip(ctx))]
|
||||
pub async fn store_modpack_archive(path: &Path, ctx: &ContentCtx) -> ContentResult<ArtifactRow> {
|
||||
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<ModpackInstallReport> {
|
||||
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<String, (String, String)> =
|
||||
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<String, String> = 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::<Vec<_>>()
|
||||
.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<String> = 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<String>,
|
||||
replaced: Option<String>,
|
||||
) -> 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<String> {
|
||||
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<String, (String, String)>,
|
||||
bundled: Vec<String>,
|
||||
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<String> = 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<String> {
|
||||
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<Vec<String>> {
|
||||
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<u64, Vec<&BlockedFile>> = 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<PathBuf> {
|
||||
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<Vec<String>> {
|
||||
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<ArtifactRow> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
217
packages/oneclient_content/src/modpacks/mod.rs
Normal file
217
packages/oneclient_content/src/modpacks/mod.rs
Normal file
|
|
@ -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<String>,
|
||||
pub mc_version: String,
|
||||
pub loader: GameLoader,
|
||||
pub loader_version: Option<String>,
|
||||
pub contents: ModpackContents,
|
||||
pub override_prefixes: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ModpackContents {
|
||||
pub files: Vec<BundleFile>,
|
||||
pub direct: HashMap<String, ExternalFile>,
|
||||
pub optional: HashSet<String>,
|
||||
pub loose: Vec<LooseFile>,
|
||||
pub blocked: Vec<BlockedFile>,
|
||||
pub unresolved: Vec<String>,
|
||||
}
|
||||
|
||||
#[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::<u64>();
|
||||
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<String>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "debug")]
|
||||
pub async fn detect_format(path: &Path) -> ContentResult<ModpackFormat> {
|
||||
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<ModpackManifest> {
|
||||
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<Option<Vec<u8>>> {
|
||||
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<ContentType> {
|
||||
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<ContentType> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
325
packages/oneclient_content/src/modpacks/mrpack.rs
Normal file
325
packages/oneclient_content/src/modpacks/mrpack.rs
Normal file
|
|
@ -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<String>,
|
||||
#[serde(default)]
|
||||
files: Vec<MrpackFileEntry>,
|
||||
#[serde(default)]
|
||||
dependencies: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct MrpackFileEntry {
|
||||
path: String,
|
||||
hashes: MrpackHashes,
|
||||
#[serde(default)]
|
||||
env: Option<MrpackEnv>,
|
||||
#[serde(default)]
|
||||
downloads: Vec<String>,
|
||||
#[serde(rename = "fileSize", default)]
|
||||
file_size: u64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct MrpackHashes {
|
||||
sha1: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct MrpackEnv {
|
||||
#[serde(default)]
|
||||
client: Option<String>,
|
||||
}
|
||||
|
||||
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<ModpackManifest> {
|
||||
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<String, String>,
|
||||
) -> ContentResult<(String, GameLoader, Option<String>)> {
|
||||
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<u8> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
212
packages/oneclient_content/src/modpacks/update.rs
Normal file
212
packages/oneclient_content/src/modpacks/update.rs
Normal file
|
|
@ -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<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ModpackUpdateStatus {
|
||||
Unpublished,
|
||||
UpToDate,
|
||||
Available(VersionSummary),
|
||||
}
|
||||
|
||||
impl ModpackRelease {
|
||||
fn from_row(row: ProviderReleaseRow) -> Option<Self> {
|
||||
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<Option<ModpackRelease>> {
|
||||
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<Option<ModpackRelease>> {
|
||||
let cluster = PackageStore::get_cluster(cluster_id, ctx).await?;
|
||||
linked_release(&cluster, ctx).await
|
||||
}
|
||||
|
||||
async fn linked_release(
|
||||
cluster: &ClusterRow,
|
||||
ctx: &ContentCtx,
|
||||
) -> ContentResult<Option<ModpackRelease>> {
|
||||
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<ModpackUpdateStatus> {
|
||||
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<GameLoader> {
|
||||
u8::try_from(repr).ok().and_then(GameLoader::from_repr)
|
||||
}
|
||||
|
||||
fn newest(versions: Vec<VersionSummary>, current: &ModpackRelease) -> Option<VersionSummary> {
|
||||
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<VersionSummary>| 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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")]
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<Path>,
|
||||
cluster_id: i64,
|
||||
ctx: &ContentCtx,
|
||||
) -> ContentResult<()> {
|
||||
mrpack::install_mrpack_to_cluster(archive_path.as_ref().to_path_buf(), cluster_id, ctx).await
|
||||
}
|
||||
|
|
@ -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<MrpackFileEntry>,
|
||||
#[serde(default)]
|
||||
dependencies: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct MrpackFileEntry {
|
||||
path: String,
|
||||
hashes: MrpackHashes,
|
||||
#[serde(default)]
|
||||
downloads: Vec<String>,
|
||||
#[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
|
||||
}
|
||||
|
|
@ -552,7 +552,8 @@ struct CfFile {
|
|||
#[serde(default)]
|
||||
hashes: Vec<CfHash>,
|
||||
file_fingerprint: u32,
|
||||
download_url: String,
|
||||
#[serde(default)]
|
||||
download_url: Option<String>,
|
||||
file_length: u64,
|
||||
#[serde(default)]
|
||||
dependencies: Vec<CfDependency>,
|
||||
|
|
@ -737,7 +738,7 @@ impl From<CfFile> 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::<CfFile>(raw)
|
||||
.expect("curseforge file")
|
||||
.into();
|
||||
|
||||
assert_eq!(version.files[0].url, "");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -458,6 +458,16 @@ async fn store_local_file(
|
|||
) -> ContentResult<ArtifactRow> {
|
||||
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<ArtifactRow> {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
503
packages/oneclient_core/src/clusters/modpack.rs
Normal file
503
packages/oneclient_core/src/clusters/modpack.rs
Normal file
|
|
@ -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<LauncherState>,
|
||||
source: &ModpackSource,
|
||||
) -> LauncherResult<PreparedModpack> {
|
||||
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<String> = 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<LauncherState>,
|
||||
prepared: &PreparedModpack,
|
||||
) -> LauncherResult<Cluster> {
|
||||
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<LauncherState>,
|
||||
cluster_id: i64,
|
||||
prepared: &PreparedModpack,
|
||||
progress: Option<&GroupedProgressSession>,
|
||||
) -> LauncherResult<ModpackCluster> {
|
||||
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<LauncherState>,
|
||||
cluster_id: i64,
|
||||
version_id: &str,
|
||||
progress: Option<&GroupedProgressSession>,
|
||||
) -> LauncherResult<ModpackCluster> {
|
||||
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<LauncherState>,
|
||||
cluster_id: i64,
|
||||
progress: Option<&GroupedProgressSession>,
|
||||
) -> LauncherResult<ModpackCluster> {
|
||||
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<LauncherState>,
|
||||
release: &ModpackRelease,
|
||||
) -> LauncherResult<ProjectDetail> {
|
||||
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<LauncherState>,
|
||||
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<LauncherState>,
|
||||
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<LauncherState>, 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<LauncherState>,
|
||||
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<String> {
|
||||
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<LauncherState>,
|
||||
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<String> {
|
||||
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::<String>()
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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<bool, sqlx::Error> {
|
||||
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<Vec<ArtifactRow>
|
|||
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)
|
||||
|
|
|
|||
|
|
@ -238,6 +238,15 @@ pub async fn set_stage(pool: &SqlitePool, id: i64, stage: i64) -> Result<Cluster
|
|||
.await
|
||||
}
|
||||
|
||||
pub async fn touch_last_played(pool: &SqlitePool, id: i64) -> 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,
|
||||
|
|
|
|||
|
|
@ -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<String, usize>,
|
||||
}
|
||||
|
||||
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<std::path::Path>,
|
||||
filter: impl Fn(&str) -> bool,
|
||||
) -> PolyIOResult<Self> {
|
||||
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<Item = &String> {
|
||||
self.indices.keys()
|
||||
}
|
||||
|
||||
pub async fn read(&mut self, name: &str) -> PolyIOResult<Vec<u8>> {
|
||||
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),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue