feat: working modpacks from modrinth and curseforge as well as manually imported; couple ui fixes (#894)

This commit is contained in:
Maciek 2026-09-30 20:44:04 +02:00 • committed by GitHub
parent d630c27a88
commit 06fa7739b3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
62 changed files with 5494 additions and 374 deletions

View 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)
}

View file

@ -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() {

View file

@ -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};

View 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"
);
}
}

View file

@ -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),

View file

@ -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,

View file

@ -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);
}
}

View file

@ -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()

View 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");
}
}

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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(),
})

View file

@ -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,
}
}

View file

@ -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

View file

@ -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)

View file

@ -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(),

View file

@ -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

View file

@ -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 }
}

View file

@ -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,

View file

@ -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(),
}),

View file

@ -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()

View file

@ -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()

View file

@ -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()),
)

View file

@ -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;

View 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,
)
}
}

View file

@ -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()

View file

@ -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(());
});
}
}
}

View file

@ -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,

View file

@ -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, &current, MAX_NAME_CHARS)
name_limit.is_none_or(|limit| within_limit(&next, &current, limit))
&& no_new_forbidden(&next, &current),
);
}),

View file

@ -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(),

View file

@ -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,
}

View file

@ -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);

View file

@ -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)

View file

@ -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();

View file

@ -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();

View file

@ -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]

View file

@ -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

View file

@ -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, .. } => {

View file

@ -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;

View file

@ -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");

View file

@ -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,
})
}

View file

@ -1,5 +1,6 @@
#![recursion_limit = "256"]
pub mod bundles;
pub mod modpacks;
pub mod packages;
mod ctx;

View 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());
}
}

View 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()]);
}
}

View 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);
}
}

View 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);
}
}

View 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());
}
}

View 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(
&current.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, &current) {
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, &current("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, &current("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, &current("v2")).unwrap();
assert_eq!(latest.version_id, "v3-beta");
}
}

View file

@ -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")]

View file

@ -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;

View file

@ -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
}

View file

@ -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
}

View file

@ -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, "");
}
}

View file

@ -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

View file

@ -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(),
},

View file

@ -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,
};

View 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,
&current.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);
}
}

View file

@ -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),

View file

@ -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)

View file

@ -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,

View file

@ -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),