573 lines
18 KiB
Rust
573 lines
18 KiB
Rust
mod index;
|
|
mod package;
|
|
|
|
pub use index::Browser;
|
|
pub(crate) use index::{browsable_type, encode_package_id};
|
|
|
|
mod world_prompt;
|
|
use world_prompt::WorldInstallPrompt;
|
|
|
|
/// Projects shipping both a mod and a data pack tag the mod files with a loader
|
|
fn preferred_version(
|
|
versions: &[oneclient_content::packages::types::VersionSummary],
|
|
content_type: oneclient_content::packages::ContentType,
|
|
) -> Option<&oneclient_content::packages::types::VersionSummary> {
|
|
if content_type == oneclient_content::packages::ContentType::DataPack {
|
|
versions
|
|
.iter()
|
|
.find(|v| v.loaders.is_empty())
|
|
.or_else(|| versions.first())
|
|
} else {
|
|
versions.first()
|
|
}
|
|
}
|
|
pub use package::BrowserPackage;
|
|
|
|
use std::collections::HashMap;
|
|
|
|
use freya::prelude::*;
|
|
use oneclient_content::packages::ProviderId;
|
|
use oneclient_core::{BundleFileKind, BundleWithUpdateStatus, LinkedArtifactInfo};
|
|
|
|
use crate::components::{Icon, IconType};
|
|
use crate::hooks::{loaded_image, use_cached_image};
|
|
use crate::theme::colors;
|
|
use crate::ui::{ImageFallbackExt, border_all_color};
|
|
|
|
const BANNER_BG: Color = Color::from_rgb(21, 28, 34);
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
pub(crate) enum InstallSource {
|
|
Manual,
|
|
Bundled,
|
|
}
|
|
|
|
impl InstallSource {
|
|
fn label(self) -> &'static str {
|
|
match self {
|
|
Self::Manual => "Installed",
|
|
Self::Bundled => "Bundled",
|
|
}
|
|
}
|
|
|
|
/// Bundled reads blue rather than green the cluster's bundle put it there not the user
|
|
fn color(self) -> Color {
|
|
match self {
|
|
Self::Manual => colors::success(),
|
|
Self::Bundled => colors::code_info(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, PartialEq)]
|
|
pub(crate) struct InstalledVersion {
|
|
pub version_id: String,
|
|
/// The artifact to remove
|
|
/// `None` when a bundle names this version but nothing is linked
|
|
pub hash: Option<String>,
|
|
/// Only meaningful alongside a `hash` a bundle pin with nothing linked is neither on nor off
|
|
pub enabled: bool,
|
|
pub source: InstallSource,
|
|
}
|
|
|
|
/// Holds every version found rather than an arbitrary winner a cluster can end up with more than one
|
|
#[derive(Clone, PartialEq)]
|
|
pub(crate) struct Installed {
|
|
/// A bundle claiming the project wins over a hand-installed copy
|
|
pub source: InstallSource,
|
|
pub versions: Vec<InstalledVersion>,
|
|
}
|
|
|
|
impl Installed {
|
|
pub fn is_version(&self, version_id: &str) -> bool {
|
|
self.find_version(version_id).is_some()
|
|
}
|
|
|
|
pub fn find_version(&self, version_id: &str) -> Option<&InstalledVersion> {
|
|
self.versions.iter().find(|v| v.version_id == version_id)
|
|
}
|
|
|
|
/// Counts linked artifacts only a bundle pin the cluster never downloaded is not a second copy
|
|
pub fn is_duplicated(&self) -> bool {
|
|
self.versions.iter().filter(|v| v.hash.is_some()).count() > 1
|
|
}
|
|
}
|
|
|
|
/// Local files and a bundle's external files are left out they have no project id to match against
|
|
pub(crate) fn installed_map(
|
|
content: Vec<LinkedArtifactInfo>,
|
|
bundles: &[BundleWithUpdateStatus],
|
|
) -> HashMap<(ProviderId, String), Installed> {
|
|
let mut map: HashMap<(ProviderId, String), Installed> = HashMap::new();
|
|
|
|
for item in content {
|
|
let (Some(provider), Some(project_id)) = (item.provider, item.project_id) else {
|
|
continue;
|
|
};
|
|
|
|
// Created even without a recorded version the package is in the cluster it just cannot be tied to a version row
|
|
let installed = map.entry((provider, project_id)).or_insert(Installed {
|
|
source: InstallSource::Manual,
|
|
versions: Vec::new(),
|
|
});
|
|
|
|
if let Some(version_id) = item.version_id {
|
|
installed.versions.push(InstalledVersion {
|
|
version_id,
|
|
hash: Some(item.hash),
|
|
enabled: item.enabled,
|
|
source: InstallSource::Manual,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Bundle membership wins a bundle's files would otherwise read as hand-installed
|
|
// The manifest pin is only a fallback for a missing linked version
|
|
for bundle in bundles {
|
|
for (file, _status) in &bundle.files {
|
|
if let BundleFileKind::Managed {
|
|
provider,
|
|
project_id,
|
|
version_id,
|
|
..
|
|
} = &file.kind
|
|
{
|
|
match map.get_mut(&(*provider, project_id.clone())) {
|
|
Some(installed) => {
|
|
installed.source = InstallSource::Bundled;
|
|
if let Some(version) = installed
|
|
.versions
|
|
.iter_mut()
|
|
.find(|v| &v.version_id == version_id)
|
|
{
|
|
version.source = InstallSource::Bundled;
|
|
}
|
|
}
|
|
None => {
|
|
map.insert(
|
|
(*provider, project_id.clone()),
|
|
Installed {
|
|
source: InstallSource::Bundled,
|
|
versions: vec![InstalledVersion {
|
|
version_id: version_id.clone(),
|
|
hash: None,
|
|
enabled: false,
|
|
source: InstallSource::Bundled,
|
|
}],
|
|
},
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
map
|
|
}
|
|
|
|
pub(crate) fn installed_badge(installed: InstallSource, font_size: f32) -> impl IntoElement {
|
|
badge(installed, font_size, installed.color().with_a(38), None)
|
|
}
|
|
|
|
/// Which of several installed versions the game actually loads
|
|
pub(crate) fn activity_badge(active: bool) -> impl IntoElement {
|
|
let (text, color) = if active {
|
|
("Active", colors::brand())
|
|
} else {
|
|
("Inactive", colors::fg_secondary())
|
|
};
|
|
|
|
// No icon three pills in a row all wearing a tick reads as decoration
|
|
rect()
|
|
.horizontal()
|
|
.cross_align(Alignment::Center)
|
|
.padding(Gaps::new_symmetric(2., 8.))
|
|
.corner_radius(CornerRadius::new_all(999.))
|
|
.background(color.with_a(38))
|
|
.child(label().text(text).font_size(11.).max_lines(1).color(color))
|
|
}
|
|
|
|
fn badge(
|
|
installed: InstallSource,
|
|
font_size: f32,
|
|
background: Color,
|
|
border: Option<Color>,
|
|
) -> impl IntoElement {
|
|
let color = installed.color();
|
|
|
|
rect()
|
|
.horizontal()
|
|
.cross_align(Alignment::Center)
|
|
.spacing(4.)
|
|
.padding(Gaps::new_symmetric(2., 8.))
|
|
.corner_radius(CornerRadius::new_all(999.))
|
|
.background(background)
|
|
.map(border, |el, border| el.border(border_all_color(1., border)))
|
|
.child(
|
|
Icon::new(IconType::CheckCircle)
|
|
.size(font_size)
|
|
.color(color),
|
|
)
|
|
.child(
|
|
label()
|
|
.text(installed.label())
|
|
.font_size(font_size)
|
|
.max_lines(1)
|
|
.color(color),
|
|
)
|
|
}
|
|
|
|
#[derive(PartialEq)]
|
|
pub(crate) struct Thumbnail {
|
|
icon_url: Option<String>,
|
|
size: f32,
|
|
radius: f32,
|
|
key: DiffKey,
|
|
}
|
|
|
|
impl Thumbnail {
|
|
pub fn new(icon_url: Option<String>, size: f32) -> Self {
|
|
Self {
|
|
icon_url,
|
|
size,
|
|
radius: 10.,
|
|
key: DiffKey::None,
|
|
}
|
|
}
|
|
|
|
pub fn radius(mut self, radius: f32) -> Self {
|
|
self.radius = radius;
|
|
self
|
|
}
|
|
}
|
|
|
|
impl KeyExt for Thumbnail {
|
|
fn write_key(&mut self) -> &mut DiffKey {
|
|
&mut self.key
|
|
}
|
|
}
|
|
|
|
impl Component for Thumbnail {
|
|
fn render(&self) -> impl IntoElement {
|
|
let size = self.size;
|
|
let radius = self.radius;
|
|
let query = use_cached_image(self.icon_url.clone(), 256);
|
|
let loaded = loaded_image(self.icon_url.as_deref(), &query);
|
|
|
|
let placeholder = thumbnail_placeholder(size, radius, 0.4);
|
|
|
|
match loaded {
|
|
Some((url, bytes)) => ImageViewer::new((url, bytes))
|
|
.width(Size::px(size))
|
|
.height(Size::px(size))
|
|
.aspect_ratio(AspectRatio::Min)
|
|
.corner_radius(CornerRadius::new_all(radius))
|
|
.fallback(placeholder)
|
|
.into_element(),
|
|
None => placeholder,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn thumbnail_placeholder(size: f32, radius: f32, icon_ratio: f32) -> Element {
|
|
rect()
|
|
.center()
|
|
.width(Size::px(size))
|
|
.height(Size::px(size))
|
|
.corner_radius(CornerRadius::new_all(radius))
|
|
.background(colors::component_bg())
|
|
.child(
|
|
Icon::new(IconType::DotsGrid)
|
|
.size(size * icon_ratio)
|
|
.color(colors::fg_secondary()),
|
|
)
|
|
.into_element()
|
|
}
|
|
|
|
#[derive(PartialEq)]
|
|
pub(crate) struct PackageBanner {
|
|
icon_url: Option<String>,
|
|
height: f32,
|
|
backdrop_only: bool,
|
|
key: DiffKey,
|
|
}
|
|
|
|
impl PackageBanner {
|
|
pub fn new(icon_url: Option<String>, height: f32) -> Self {
|
|
Self {
|
|
icon_url,
|
|
height,
|
|
backdrop_only: false,
|
|
key: DiffKey::None,
|
|
}
|
|
}
|
|
|
|
/// Drops the centred icon so a caller can place its own artwork over the blur
|
|
pub fn backdrop_only(mut self) -> Self {
|
|
self.backdrop_only = true;
|
|
self
|
|
}
|
|
}
|
|
|
|
impl KeyExt for PackageBanner {
|
|
fn write_key(&mut self) -> &mut DiffKey {
|
|
&mut self.key
|
|
}
|
|
}
|
|
|
|
impl Component for PackageBanner {
|
|
fn render(&self) -> impl IntoElement {
|
|
let h = self.height;
|
|
let icon = h * 0.62;
|
|
let query = use_cached_image(self.icon_url.clone(), 512);
|
|
let loaded = loaded_image(self.icon_url.as_deref(), &query);
|
|
|
|
let banner = rect()
|
|
.width(Size::fill())
|
|
.height(Size::px(h))
|
|
.center()
|
|
.overflow(Overflow::Clip)
|
|
.background(BANNER_BG);
|
|
|
|
let icon_placeholder = thumbnail_placeholder(icon, 10., 0.45);
|
|
let backdrop_only = self.backdrop_only;
|
|
|
|
match loaded {
|
|
Some((url, bytes)) => banner
|
|
.child(
|
|
rect()
|
|
.position(Position::new_absolute().top(0.).left(0.))
|
|
.width(Size::fill())
|
|
.height(Size::fill())
|
|
.overflow(Overflow::Clip)
|
|
.child(
|
|
ImageViewer::new((url.clone(), bytes.clone()))
|
|
.width(Size::fill())
|
|
.height(Size::fill())
|
|
.aspect_ratio(AspectRatio::Max)
|
|
.image_cover(ImageCover::Center)
|
|
.fallback(rect().width(Size::fill()).height(Size::fill())),
|
|
)
|
|
.layer(Layer::Relative(1)),
|
|
)
|
|
.child(
|
|
rect()
|
|
.position(Position::new_absolute().top(0.).left(0.))
|
|
.width(Size::fill())
|
|
.height(Size::fill())
|
|
.backdrop_blur(12.)
|
|
.background(BANNER_BG.with_a(120))
|
|
.overflow(Overflow::Clip)
|
|
.layer(Layer::Relative(3)),
|
|
)
|
|
.maybe_child((!backdrop_only).then(|| {
|
|
rect()
|
|
.width(Size::px(icon))
|
|
.height(Size::px(icon))
|
|
.child(
|
|
ImageViewer::new((url, bytes))
|
|
.width(Size::px(icon))
|
|
.height(Size::px(icon))
|
|
.aspect_ratio(AspectRatio::Min)
|
|
.corner_radius(CornerRadius::new_all(10.))
|
|
.fallback(icon_placeholder.clone()),
|
|
)
|
|
.layer(Layer::Relative(5))
|
|
.into_element()
|
|
})),
|
|
None if backdrop_only => banner,
|
|
None => banner.child(icon_placeholder),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::view::onboarding::test_support::archive;
|
|
use oneclient_content::packages::ContentType;
|
|
use oneclient_core::{BundleFile, FileUpdateStatus};
|
|
|
|
fn linked(project_id: &str, version_id: Option<&str>, hash: &str) -> LinkedArtifactInfo {
|
|
disabled_linked(project_id, version_id, hash, true)
|
|
}
|
|
|
|
fn disabled_linked(
|
|
project_id: &str,
|
|
version_id: Option<&str>,
|
|
hash: &str,
|
|
enabled: bool,
|
|
) -> LinkedArtifactInfo {
|
|
LinkedArtifactInfo {
|
|
hash: hash.to_string(),
|
|
cluster_file_name: format!("{hash}.jar"),
|
|
enabled,
|
|
content_type: ContentType::Mod,
|
|
file_name: format!("{hash}.jar"),
|
|
project_id: Some(project_id.to_string()),
|
|
version_id: version_id.map(Into::into),
|
|
display_name: None,
|
|
display_version: None,
|
|
provider: Some(ProviderId::Modrinth),
|
|
published_at: None,
|
|
seen_status: oneclient_core::SeenStatus::Seen,
|
|
}
|
|
}
|
|
|
|
fn managed(project_id: &str, version_id: &str) -> BundleFile {
|
|
BundleFile {
|
|
enabled: true,
|
|
hidden: false,
|
|
path: format!("mods/{project_id}.jar"),
|
|
size: 1,
|
|
file_type: oneclient_core::BundleFileType::Normal,
|
|
kind: BundleFileKind::Managed {
|
|
provider: ProviderId::Modrinth,
|
|
project_id: project_id.to_string(),
|
|
version_id: version_id.to_string(),
|
|
sha1: format!("sha-{version_id}"),
|
|
},
|
|
}
|
|
}
|
|
|
|
fn bundles(files: Vec<BundleFile>) -> Vec<BundleWithUpdateStatus> {
|
|
vec![BundleWithUpdateStatus {
|
|
files: files
|
|
.iter()
|
|
.cloned()
|
|
.map(|f| (f, FileUpdateStatus::UpToDate))
|
|
.collect(),
|
|
archive: archive("performance", true, files),
|
|
has_updates: false,
|
|
}]
|
|
}
|
|
|
|
fn entry(map: &HashMap<(ProviderId, String), Installed>, project: &str) -> Installed {
|
|
map.get(&(ProviderId::Modrinth, project.to_string()))
|
|
.expect("project missing from the map")
|
|
.clone()
|
|
}
|
|
|
|
#[test]
|
|
fn every_installed_version_of_one_project_is_listed() {
|
|
let map = installed_map(
|
|
vec![
|
|
linked("sodium", Some("v1"), "hash-1"),
|
|
linked("sodium", Some("v2"), "hash-2"),
|
|
],
|
|
&[],
|
|
);
|
|
|
|
let sodium = entry(&map, "sodium");
|
|
assert!(
|
|
sodium.is_version("v1"),
|
|
"the older version is still in there"
|
|
);
|
|
assert!(sodium.is_version("v2"));
|
|
assert_eq!(
|
|
sodium.find_version("v2").and_then(|v| v.hash.clone()),
|
|
Some("hash-2".to_string()),
|
|
"each version carries the artifact the remove button needs"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_bundle_marks_only_the_version_it_ships() {
|
|
let map = installed_map(
|
|
vec![
|
|
linked("sodium", Some("v1"), "hash-1"),
|
|
linked("sodium", Some("v2"), "hash-2"),
|
|
],
|
|
&bundles(vec![managed("sodium", "v1")]),
|
|
);
|
|
|
|
let sodium = entry(&map, "sodium");
|
|
assert_eq!(
|
|
sodium.source,
|
|
InstallSource::Bundled,
|
|
"the bundle owns the project"
|
|
);
|
|
assert_eq!(
|
|
sodium.find_version("v1").map(|v| v.source),
|
|
Some(InstallSource::Bundled)
|
|
);
|
|
assert_eq!(
|
|
sodium.find_version("v2").map(|v| v.source),
|
|
Some(InstallSource::Manual),
|
|
"a version the user added themselves does not become the bundle's"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_unlinked_bundle_pin_is_the_fallback() {
|
|
let map = installed_map(Vec::new(), &bundles(vec![managed("sodium", "v1")]));
|
|
|
|
let sodium = entry(&map, "sodium");
|
|
assert_eq!(sodium.source, InstallSource::Bundled);
|
|
assert_eq!(
|
|
sodium.find_version("v1").map(|v| v.hash.clone()),
|
|
Some(None),
|
|
"nothing is linked, so there is no artifact to remove"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_bundle_pin_is_not_listed_beside_a_linked_version() {
|
|
let map = installed_map(
|
|
vec![linked("sodium", Some("v2"), "hash-2")],
|
|
&bundles(vec![managed("sodium", "v1")]),
|
|
);
|
|
|
|
let sodium = entry(&map, "sodium");
|
|
assert!(
|
|
!sodium.is_version("v1"),
|
|
"a pin the cluster never downloaded is not installed"
|
|
);
|
|
assert_eq!(sodium.versions.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn a_single_copy_is_not_a_duplicate() {
|
|
let map = installed_map(vec![linked("sodium", Some("v1"), "hash-1")], &[]);
|
|
|
|
assert!(!entry(&map, "sodium").is_duplicated());
|
|
}
|
|
|
|
#[test]
|
|
fn an_unlinked_bundle_pin_is_not_a_second_copy() {
|
|
let map = installed_map(Vec::new(), &bundles(vec![managed("sodium", "v1")]));
|
|
|
|
assert!(
|
|
!entry(&map, "sodium").is_duplicated(),
|
|
"one pin and nothing linked is not two of anything"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn each_copy_reports_whether_the_game_loads_it() {
|
|
let map = installed_map(
|
|
vec![
|
|
disabled_linked("sodium", Some("v1"), "hash-1", false),
|
|
linked("sodium", Some("v2"), "hash-2"),
|
|
],
|
|
&[],
|
|
);
|
|
|
|
let sodium = entry(&map, "sodium");
|
|
assert!(sodium.is_duplicated());
|
|
assert_eq!(sodium.find_version("v1").map(|v| v.enabled), Some(false));
|
|
assert_eq!(sodium.find_version("v2").map(|v| v.enabled), Some(true));
|
|
}
|
|
|
|
#[test]
|
|
fn an_artifact_with_no_recorded_version_still_marks_the_project() {
|
|
let map = installed_map(vec![linked("sodium", None, "hash-1")], &[]);
|
|
|
|
let sodium = entry(&map, "sodium");
|
|
assert!(
|
|
sodium.versions.is_empty(),
|
|
"there is no version to tie to a row in the list"
|
|
);
|
|
assert_eq!(sodium.source, InstallSource::Manual);
|
|
}
|
|
}
|