feat: added an opt-in flow for bundle mods shipped disabled (#737)
This commit is contained in:
parent
90fb354857
commit
2cc280bbb0
24 changed files with 1468 additions and 70 deletions
12
.sqlx/query-3b87531a1231c467072ec4d1f9afd3c93114777031250c7f9e74f6b9eef4ab9f.json
generated
Normal file
12
.sqlx/query-3b87531a1231c467072ec4d1f9afd3c93114777031250c7f9e74f6b9eef4ab9f.json
generated
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n INSERT INTO cluster_optional_mods\n (cluster_id, bundle_name, package_id, bundle_version_id, seen_status)\n VALUES (?, ?, ?, ?, ?)\n ON CONFLICT(cluster_id, package_id) DO UPDATE SET\n bundle_name = excluded.bundle_name,\n bundle_version_id = excluded.bundle_version_id\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 5
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "3b87531a1231c467072ec4d1f9afd3c93114777031250c7f9e74f6b9eef4ab9f"
|
||||
}
|
||||
98
.sqlx/query-5b3dd7940a9bd6f0067fac7b5f21e83f8d9eeceda6b1c53c5ce5b735a8fe2a71.json
generated
Normal file
98
.sqlx/query-5b3dd7940a9bd6f0067fac7b5f21e83f8d9eeceda6b1c53c5ce5b735a8fe2a71.json
generated
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT id, cluster_id, bundle_name, package_id, bundle_version_id,\n seen_status, queued_at\n FROM cluster_optional_mods\n WHERE cluster_id = ?\n ORDER BY queued_at, id\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "cluster_optional_mods",
|
||||
"name": "id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "cluster_id",
|
||||
"ordinal": 1,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "cluster_optional_mods",
|
||||
"name": "cluster_id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "bundle_name",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "cluster_optional_mods",
|
||||
"name": "bundle_name"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "package_id",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "cluster_optional_mods",
|
||||
"name": "package_id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "bundle_version_id",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "cluster_optional_mods",
|
||||
"name": "bundle_version_id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "seen_status",
|
||||
"ordinal": 5,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "cluster_optional_mods",
|
||||
"name": "seen_status"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "queued_at",
|
||||
"ordinal": 6,
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "cluster_optional_mods",
|
||||
"name": "queued_at"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "5b3dd7940a9bd6f0067fac7b5f21e83f8d9eeceda6b1c53c5ce5b735a8fe2a71"
|
||||
}
|
||||
12
.sqlx/query-e03d85ee97b3a632d5946019865d9efb5d665e521519d05b16a4b66b066987ca.json
generated
Normal file
12
.sqlx/query-e03d85ee97b3a632d5946019865d9efb5d665e521519d05b16a4b66b066987ca.json
generated
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n UPDATE cluster_optional_mods SET seen_status = ?\n WHERE cluster_id = ? AND package_id = ?\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "e03d85ee97b3a632d5946019865d9efb5d665e521519d05b16a4b66b066987ca"
|
||||
}
|
||||
12
.sqlx/query-e2ee6fbe534dd47ce658d547c2bfc3a4e86dfce86da3a4ffe35d25d1fa412d13.json
generated
Normal file
12
.sqlx/query-e2ee6fbe534dd47ce658d547c2bfc3a4e86dfce86da3a4ffe35d25d1fa412d13.json
generated
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n DELETE FROM cluster_optional_mods\n WHERE cluster_id = ? AND package_id = ?\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "e2ee6fbe534dd47ce658d547c2bfc3a4e86dfce86da3a4ffe35d25d1fa412d13"
|
||||
}
|
||||
|
|
@ -21,16 +21,23 @@ enum UpdateTab {
|
|||
Updates,
|
||||
Additions,
|
||||
Removals,
|
||||
Optional,
|
||||
}
|
||||
|
||||
impl UpdateTab {
|
||||
const ALL: [UpdateTab; 3] = [UpdateTab::Updates, UpdateTab::Additions, UpdateTab::Removals];
|
||||
const ALL: [UpdateTab; 4] = [
|
||||
UpdateTab::Updates,
|
||||
UpdateTab::Additions,
|
||||
UpdateTab::Removals,
|
||||
UpdateTab::Optional,
|
||||
];
|
||||
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
UpdateTab::Updates => "Updated",
|
||||
UpdateTab::Additions => "Added",
|
||||
UpdateTab::Removals => "Removed",
|
||||
UpdateTab::Optional => "Optional",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -39,6 +46,7 @@ impl UpdateTab {
|
|||
UpdateTab::Updates => "Nothing was updated.",
|
||||
UpdateTab::Additions => "Nothing was added.",
|
||||
UpdateTab::Removals => "Nothing was removed.",
|
||||
UpdateTab::Optional => "Nothing optional was offered.",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -47,6 +55,7 @@ impl UpdateTab {
|
|||
UpdateTab::Updates => IconType::RefreshCw01,
|
||||
UpdateTab::Additions => IconType::Plus,
|
||||
UpdateTab::Removals => IconType::Trash01,
|
||||
UpdateTab::Optional => IconType::Plus,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -55,6 +64,7 @@ impl UpdateTab {
|
|||
UpdateTab::Updates => colors::brand(),
|
||||
UpdateTab::Additions => colors::success(),
|
||||
UpdateTab::Removals => colors::danger(),
|
||||
UpdateTab::Optional => colors::brand(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -63,6 +73,7 @@ impl UpdateTab {
|
|||
UpdateTab::Updates => &summary.updated,
|
||||
UpdateTab::Additions => &summary.added,
|
||||
UpdateTab::Removals => &summary.removed,
|
||||
UpdateTab::Optional => &summary.optional,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -88,7 +99,13 @@ impl Component for ClusterUpdatePopup {
|
|||
let all_items: Vec<&ClusterUpdateItem> = summaries
|
||||
.iter()
|
||||
.flatten()
|
||||
.flat_map(|s| s.updated.iter().chain(&s.added).chain(&s.removed))
|
||||
.flat_map(|s| {
|
||||
s.updated
|
||||
.iter()
|
||||
.chain(&s.added)
|
||||
.chain(&s.removed)
|
||||
.chain(&s.optional)
|
||||
})
|
||||
.collect();
|
||||
let mut meta = MetaMap::new();
|
||||
for provider in ProviderId::REMOTE_PROVIDERS.iter().copied() {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ mod checkbox;
|
|||
mod cluster_landscape_art;
|
||||
mod cluster_update_popup;
|
||||
mod context_menu;
|
||||
mod optional_mods_popup;
|
||||
mod dropdown;
|
||||
mod dynamic_art;
|
||||
mod file_drop;
|
||||
|
|
@ -51,6 +52,7 @@ pub use checkbox::checkbox_labeled;
|
|||
pub use cluster_landscape_art::ClusterLandscapeArt;
|
||||
pub use cluster_update_popup::ClusterUpdatePopup;
|
||||
pub use context_menu::ContextMenu;
|
||||
pub use optional_mods_popup::OptionalModsPopup;
|
||||
pub use dropdown::Dropdown;
|
||||
pub use dynamic_art::{ART_PREVIEW_EDGE, DynamicArt};
|
||||
pub use file_drop::FileDropOverlay;
|
||||
|
|
@ -70,6 +72,7 @@ pub use navbar::{AppNavbar, OnboardingNavbar};
|
|||
pub use notifications::NotificationCenter;
|
||||
pub use overlay_popup::{OVERLAY_BASE_LEVEL, OVERLAY_MAX_LEVEL, OverlayPopup, overlay_is_topmost};
|
||||
pub use package_row::{CardLayout, PackageEntry, PackageRow, provider_badge};
|
||||
pub(crate) use package_row::{CARD_GRID_H, GRID_GAP, GRID_MIN_W, grid_card, package_icon};
|
||||
pub use package_update_popup::PackageUpdatePopup;
|
||||
pub use pagination::Pagination;
|
||||
pub use player_model::PlayerModel;
|
||||
|
|
|
|||
383
packages/oneclient_app/src/components/optional_mods_popup.rs
Normal file
383
packages/oneclient_app/src/components/optional_mods_popup.rs
Normal file
|
|
@ -0,0 +1,383 @@
|
|||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use freya::prelude::*;
|
||||
use oneclient_content::packages::{CachedPackageMeta, ProviderId};
|
||||
use oneclient_db::models::SeenStatus;
|
||||
|
||||
use crate::components::{
|
||||
Button, CARD_GRID_H, GRID_GAP, GRID_MIN_W, Icon, IconType, OverlayPopup, PackageEntry,
|
||||
grid_card, package_icon,
|
||||
};
|
||||
use crate::hooks::{
|
||||
package_meta_batch, use_cached_image, use_dispatch, use_notifications_snapshot,
|
||||
use_package_meta_batch,
|
||||
};
|
||||
use crate::notifications::{ClusterUpdateItem, OptionalModRef, OptionalModsGroup};
|
||||
use crate::theme::colors;
|
||||
use crate::ui::border_all_color;
|
||||
|
||||
const CARD_BG: Color = Color::from_rgb(26, 34, 41);
|
||||
const DIALOG_W: f32 = 720.;
|
||||
const DIALOG_PAD: f32 = 22.;
|
||||
const LIST_MAX_H: f32 = 306.;
|
||||
const ICON_SIZE: f32 = 52.;
|
||||
|
||||
type MetaMap = HashMap<(ProviderId, String), CachedPackageMeta>;
|
||||
|
||||
type Picked = HashSet<(i64, String)>;
|
||||
|
||||
#[derive(PartialEq)]
|
||||
pub struct OptionalModsPopup;
|
||||
|
||||
impl Component for OptionalModsPopup {
|
||||
fn render(&self) -> impl IntoElement {
|
||||
let snapshot = use_notifications_snapshot();
|
||||
let dispatch = use_dispatch();
|
||||
let picked = use_state(Picked::new);
|
||||
|
||||
let groups = snapshot.optional_mods.clone();
|
||||
|
||||
let mut meta = MetaMap::new();
|
||||
for provider in ProviderId::REMOTE_PROVIDERS.iter().copied() {
|
||||
let ids: Vec<String> = groups
|
||||
.iter()
|
||||
.flatten()
|
||||
.flat_map(|group| &group.mods)
|
||||
.filter(|item| item.provider == provider)
|
||||
.filter_map(|item| item.project_id.clone())
|
||||
.collect();
|
||||
let query = use_package_meta_batch(provider, ids);
|
||||
for (project_id, cached) in package_meta_batch(&query) {
|
||||
meta.insert((provider, project_id), cached);
|
||||
}
|
||||
}
|
||||
|
||||
let Some(groups) = groups.filter(|groups| !groups.is_empty()) else {
|
||||
return rect().into_element();
|
||||
};
|
||||
|
||||
let close = dispatch.clone();
|
||||
let dismissed: Vec<(i64, OptionalModRef)> =
|
||||
groups.iter().flat_map(|group| group.offers()).collect();
|
||||
|
||||
OverlayPopup::new()
|
||||
.on_close(move |_| close.decline_optional_mods(dismissed.clone()))
|
||||
.child(
|
||||
rect()
|
||||
.width(Size::window_percent(100.))
|
||||
.height(Size::window_percent(100.))
|
||||
.center()
|
||||
.child(dialog(&groups, &meta, picked, dispatch)),
|
||||
)
|
||||
.into_element()
|
||||
}
|
||||
}
|
||||
|
||||
fn package_key(item: &ClusterUpdateItem) -> String {
|
||||
item.offer
|
||||
.as_ref()
|
||||
.map(|(_, package_id)| package_id.clone())
|
||||
.unwrap_or_else(|| item.fallback.clone())
|
||||
}
|
||||
|
||||
fn entry_from_item(
|
||||
item: &ClusterUpdateItem,
|
||||
meta: &MetaMap,
|
||||
package_id: String,
|
||||
selected: bool,
|
||||
) -> PackageEntry {
|
||||
let cached = item
|
||||
.project_id
|
||||
.as_ref()
|
||||
.and_then(|project_id| meta.get(&(item.provider, project_id.clone())));
|
||||
|
||||
PackageEntry {
|
||||
package_id,
|
||||
bundle_name: item.offer.as_ref().map(|(bundle, _)| bundle.clone()),
|
||||
provider: item.provider,
|
||||
name: cached
|
||||
.map(|cached| cached.name.clone())
|
||||
.filter(|name| !name.is_empty())
|
||||
.unwrap_or_else(|| item.fallback.clone()),
|
||||
file_name: item.fallback.clone(),
|
||||
author: cached
|
||||
.map(|cached| cached.author.clone())
|
||||
.unwrap_or_default(),
|
||||
description: cached
|
||||
.map(|cached| cached.summary.clone())
|
||||
.unwrap_or_default(),
|
||||
icon_url: cached.and_then(|cached| cached.icon_url.clone()),
|
||||
size: 0,
|
||||
categories: Vec::new(),
|
||||
enabled: selected,
|
||||
installed: false,
|
||||
hash: None,
|
||||
manifest_default: false,
|
||||
hidden: false,
|
||||
update_available: false,
|
||||
// Offered, not installed, so there is no recency badge to show
|
||||
seen_status: SeenStatus::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn dialog(
|
||||
groups: &[OptionalModsGroup],
|
||||
meta: &MetaMap,
|
||||
picked: State<Picked>,
|
||||
dispatch: crate::Actions,
|
||||
) -> impl IntoElement {
|
||||
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(content(groups, meta, picked, dispatch))
|
||||
}
|
||||
|
||||
fn content(
|
||||
groups: &[OptionalModsGroup],
|
||||
meta: &MetaMap,
|
||||
picked: State<Picked>,
|
||||
dispatch: crate::Actions,
|
||||
) -> impl IntoElement {
|
||||
let total: usize = groups.iter().map(|group| group.mods.len()).sum();
|
||||
let offers: Vec<(i64, OptionalModRef)> =
|
||||
groups.iter().flat_map(|group| group.offers()).collect();
|
||||
|
||||
let chosen = picked.read().clone();
|
||||
let selected =
|
||||
|cluster_id: i64, package_id: &str| chosen.contains(&(cluster_id, package_id.to_string()));
|
||||
let (enable, skip): (Vec<_>, Vec<_>) = offers
|
||||
.iter()
|
||||
.cloned()
|
||||
.partition(|(cluster_id, (_, package_id))| selected(*cluster_id, package_id));
|
||||
|
||||
let plural = if total == 1 { "" } else { "s" };
|
||||
let subtitle = match groups {
|
||||
[only] => format!(
|
||||
"{} includes {total} optional mod{plural} that {} off by default. Pick the {} to turn on.",
|
||||
only.cluster_name,
|
||||
if total == 1 { "is" } else { "are" },
|
||||
if total == 1 { "one" } else { "ones" }
|
||||
),
|
||||
_ => format!(
|
||||
"Your bundles include {total} optional mod{plural} across {} clusters that are off by default. Pick the ones to turn on.",
|
||||
groups.len()
|
||||
),
|
||||
};
|
||||
|
||||
let cancel = dispatch.clone();
|
||||
let declined = offers.clone();
|
||||
|
||||
let enable_text = if enable.is_empty() {
|
||||
"Enable".to_string()
|
||||
} else {
|
||||
format!("Enable {}", enable.len())
|
||||
};
|
||||
|
||||
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("Optional mods available")
|
||||
.font_size(17.)
|
||||
.font_weight(FontWeight::SEMI_BOLD)
|
||||
.color(colors::fg_primary()),
|
||||
)
|
||||
.child(
|
||||
label()
|
||||
.text(subtitle)
|
||||
.font_size(12.5)
|
||||
.max_lines(3)
|
||||
.color(colors::fg_secondary()),
|
||||
),
|
||||
)
|
||||
.child(mod_list(groups, meta, picked))
|
||||
.child(
|
||||
rect()
|
||||
.horizontal()
|
||||
.width(Size::fill())
|
||||
.cross_align(Alignment::Center)
|
||||
.main_align(Alignment::End)
|
||||
.spacing(8.)
|
||||
.child(
|
||||
Button::new()
|
||||
.ghost()
|
||||
.on_press(move |_| cancel.decline_optional_mods(declined.clone()))
|
||||
.text("Cancel"),
|
||||
)
|
||||
.maybe_child((!offers.is_empty()).then(|| {
|
||||
let apply = dispatch.clone();
|
||||
Button::new()
|
||||
.primary()
|
||||
.disabled(enable.is_empty())
|
||||
.on_press(move |_| {
|
||||
apply.record_skipped_optional_mods(skip.clone());
|
||||
apply.enable_optional_mods(enable.clone());
|
||||
})
|
||||
.child(Icon::new(IconType::Check).size(15.))
|
||||
.text(enable_text.clone())
|
||||
.into_element()
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
fn grid_columns() -> usize {
|
||||
let content_w = DIALOG_W - DIALOG_PAD * 2.;
|
||||
(((content_w + GRID_GAP) / (GRID_MIN_W + GRID_GAP)).floor() as usize).clamp(1, 3)
|
||||
}
|
||||
|
||||
fn mod_list(
|
||||
groups: &[OptionalModsGroup],
|
||||
meta: &MetaMap,
|
||||
picked: State<Picked>,
|
||||
) -> impl IntoElement {
|
||||
let show_headers = groups.len() > 1;
|
||||
let cols = grid_columns();
|
||||
|
||||
let mut list = rect().vertical().width(Size::fill()).spacing(GRID_GAP);
|
||||
|
||||
for (group_index, group) in groups.iter().enumerate() {
|
||||
if show_headers {
|
||||
list = list.child(cluster_header(&group.cluster_name, group_index == 0));
|
||||
}
|
||||
|
||||
for (row_index, row_items) in group.mods.chunks(cols).enumerate() {
|
||||
list = list.child(grid_row(
|
||||
group.cluster_id,
|
||||
row_items,
|
||||
meta,
|
||||
picked,
|
||||
cols,
|
||||
format!("{group_index}-{row_index}"),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
ScrollView::new()
|
||||
.width(Size::fill())
|
||||
.height(Size::Inner)
|
||||
.max_height(Size::px(LIST_MAX_H))
|
||||
.child(list)
|
||||
}
|
||||
|
||||
fn grid_row(
|
||||
cluster_id: i64,
|
||||
items: &[ClusterUpdateItem],
|
||||
meta: &MetaMap,
|
||||
picked: State<Picked>,
|
||||
cols: usize,
|
||||
key: String,
|
||||
) -> impl IntoElement {
|
||||
let mut row = rect()
|
||||
.key(key)
|
||||
.horizontal()
|
||||
.width(Size::fill())
|
||||
.height(Size::px(CARD_GRID_H))
|
||||
.spacing(GRID_GAP)
|
||||
.content(Content::Flex);
|
||||
|
||||
for slot in 0..cols {
|
||||
let cell = rect().width(Size::flex(1.0)).height(Size::px(CARD_GRID_H));
|
||||
row = row.child(match items.get(slot) {
|
||||
Some(item) => {
|
||||
let package_id = package_key(item);
|
||||
cell.key(package_id.clone())
|
||||
.child(mod_card(cluster_id, item, meta, picked, package_id))
|
||||
}
|
||||
None => cell,
|
||||
});
|
||||
}
|
||||
|
||||
row
|
||||
}
|
||||
|
||||
fn mod_card(
|
||||
cluster_id: i64,
|
||||
item: &ClusterUpdateItem,
|
||||
meta: &MetaMap,
|
||||
mut picked: State<Picked>,
|
||||
package_id: String,
|
||||
) -> impl IntoElement {
|
||||
let key = (cluster_id, package_id.clone());
|
||||
let selected = picked.read().contains(&key);
|
||||
|
||||
let on_toggle: EventHandler<()> = (move |()| {
|
||||
let mut next = picked.read().clone();
|
||||
if !next.remove(&key) {
|
||||
next.insert(key.clone());
|
||||
}
|
||||
picked.set(next);
|
||||
})
|
||||
.into();
|
||||
|
||||
OptionalModCard {
|
||||
entry: entry_from_item(item, meta, package_id, selected),
|
||||
cluster_id,
|
||||
on_toggle,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
struct OptionalModCard {
|
||||
entry: PackageEntry,
|
||||
cluster_id: i64,
|
||||
on_toggle: EventHandler<()>,
|
||||
}
|
||||
|
||||
impl Component for OptionalModCard {
|
||||
fn render(&self) -> impl IntoElement {
|
||||
let icon_query = use_cached_image(self.entry.icon_url.clone(), 256);
|
||||
let icon = package_icon(&self.entry, &icon_query, ICON_SIZE);
|
||||
|
||||
grid_card(
|
||||
&self.entry,
|
||||
"mod",
|
||||
self.cluster_id,
|
||||
icon,
|
||||
self.on_toggle.clone(),
|
||||
false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn cluster_header(name: &str, first: bool) -> impl IntoElement {
|
||||
rect()
|
||||
.horizontal()
|
||||
.width(Size::fill())
|
||||
.cross_align(Alignment::Center)
|
||||
.padding(Gaps::new_symmetric(6., 8.))
|
||||
.margin(Gaps::new(if first { 0. } else { 8. }, 0., 3., 0.))
|
||||
.corner_radius(CornerRadius::new_all(8.))
|
||||
.background(colors::component_bg())
|
||||
.child(
|
||||
label()
|
||||
.text(name.to_string())
|
||||
.font_size(12.)
|
||||
.font_weight(FontWeight::SEMI_BOLD)
|
||||
.max_lines(1)
|
||||
.width(Size::fill())
|
||||
.color(colors::fg_primary()),
|
||||
)
|
||||
}
|
||||
|
|
@ -13,6 +13,9 @@ use crate::utils::format_size;
|
|||
pub(crate) const CARD_BG: Color = Color::from_rgb(26, 34, 41);
|
||||
pub(crate) const CARD_NAME: Color = Color::from_rgb(213, 219, 255);
|
||||
pub(crate) const CARD_H: f32 = 84.;
|
||||
pub(crate) const CARD_GRID_H: f32 = 148.;
|
||||
pub(crate) const GRID_GAP: f32 = 10.;
|
||||
pub(crate) const GRID_MIN_W: f32 = 260.;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub enum CardLayout {
|
||||
|
|
@ -122,11 +125,7 @@ impl Component for PackageRow {
|
|||
CardLayout::Grid => 52.,
|
||||
};
|
||||
let icon_query = use_cached_image(item.icon_url.clone(), 256);
|
||||
let icon = if item.is_remote() {
|
||||
remote_icon(&item.icon_url, &icon_query, icon_size)
|
||||
} else {
|
||||
local_icon(icon_size)
|
||||
};
|
||||
let icon = package_icon(&item, &icon_query, icon_size);
|
||||
|
||||
let on_toggle: EventHandler<()> = {
|
||||
let hash = item.hash.clone();
|
||||
|
|
@ -177,7 +176,7 @@ impl Component for PackageRow {
|
|||
remove_hover,
|
||||
)
|
||||
}
|
||||
CardLayout::Grid => grid_card(&item, package_type, cluster_id, icon, on_toggle),
|
||||
CardLayout::Grid => grid_card(&item, package_type, cluster_id, icon, on_toggle, true),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -210,12 +209,13 @@ fn list_card(
|
|||
.into_element()
|
||||
}
|
||||
|
||||
fn grid_card(
|
||||
pub(crate) fn grid_card(
|
||||
item: &PackageEntry,
|
||||
package_type: &'static str,
|
||||
cluster_id: i64,
|
||||
icon: impl IntoElement,
|
||||
on_toggle: EventHandler<()>,
|
||||
navigable: bool, // if true, it takes the user to the mod page
|
||||
) -> Element {
|
||||
let remote = item.is_remote();
|
||||
let enabled = item.enabled;
|
||||
|
|
@ -231,7 +231,7 @@ fn grid_card(
|
|||
(CARD_BG, colors::component_border(), 140)
|
||||
};
|
||||
|
||||
let badge = if remote {
|
||||
let badge = if remote && navigable {
|
||||
let provider = item.provider;
|
||||
let package_id = item.package_id.clone();
|
||||
let package_type_owned = package_type.to_string();
|
||||
|
|
@ -247,6 +247,8 @@ fn grid_card(
|
|||
})
|
||||
.child(provider_badge(item.provider))
|
||||
.into_element()
|
||||
} else if remote {
|
||||
provider_badge(item.provider)
|
||||
} else {
|
||||
local_badge()
|
||||
};
|
||||
|
|
@ -413,6 +415,18 @@ fn package_info(
|
|||
.into_element()
|
||||
}
|
||||
|
||||
pub(crate) fn package_icon(
|
||||
item: &PackageEntry,
|
||||
icon_query: &freya::query::UseQuery<crate::hooks::CachedImageQuery>,
|
||||
size: f32,
|
||||
) -> Element {
|
||||
if item.is_remote() {
|
||||
remote_icon(&item.icon_url, icon_query, size)
|
||||
} else {
|
||||
local_icon(size)
|
||||
}
|
||||
}
|
||||
|
||||
fn remote_icon(
|
||||
icon_url: &Option<String>,
|
||||
icon_query: &freya::query::UseQuery<crate::hooks::CachedImageQuery>,
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@ use tokio::sync::mpsc;
|
|||
use crate::components::IconType;
|
||||
use crate::{invalidate_java_queries, launcher};
|
||||
use crate::notifications::{
|
||||
ClusterUpdateSummary, NotificationAction, NotificationSpec, PackageUpdateGroup, PendingPrompt,
|
||||
ClusterUpdateSummary, NotificationAction, NotificationSpec, OptionalModRef, OptionalModsGroup,
|
||||
PackageUpdateGroup, PendingPrompt,
|
||||
};
|
||||
use crate::state::{AppChannel, AppState, AsyncStatus, RelocationState};
|
||||
|
||||
|
|
@ -587,6 +588,173 @@ impl Actions {
|
|||
self.with_engine(|state| state.notifications.close_cluster_update());
|
||||
}
|
||||
|
||||
pub fn open_optional_mods(
|
||||
&self,
|
||||
groups: Vec<OptionalModsGroup>,
|
||||
done: Option<tokio::sync::oneshot::Sender<()>>,
|
||||
) {
|
||||
self.with_engine(move |state| state.notifications.open_optional_mods(groups, done));
|
||||
}
|
||||
|
||||
pub fn close_optional_mods(&self) {
|
||||
self.with_engine(|state| state.notifications.finish_optional_mods());
|
||||
}
|
||||
|
||||
pub fn decline_optional_mods(&self, mods: Vec<(ClusterId, OptionalModRef)>) {
|
||||
self.close_optional_mods();
|
||||
self.record_skipped_optional_mods(mods);
|
||||
}
|
||||
|
||||
pub fn record_skipped_optional_mods(&self, mods: Vec<(ClusterId, OptionalModRef)>) {
|
||||
if mods.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
spawn_forever(async move {
|
||||
let Ok(state) = launcher::state() else { return };
|
||||
let content = state.services.content();
|
||||
|
||||
for (cluster_id, package_ids) in group_by_cluster(&mods) {
|
||||
if let Err(err) =
|
||||
oneclient_core::skip_optional_mods(cluster_id, &package_ids, &content).await
|
||||
{
|
||||
tracing::warn!(
|
||||
cluster_id,
|
||||
error = %err,
|
||||
"failed to record skipped optional mods, they will come back as new"
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Holds the launch until the mods are actually on disk Minecraft reads
|
||||
/// `mods/` once at startup, so finishing after the process starts is useless
|
||||
pub fn enable_optional_mods(&self, mods: Vec<(ClusterId, OptionalModRef)>) {
|
||||
if mods.is_empty() {
|
||||
self.close_optional_mods();
|
||||
return;
|
||||
}
|
||||
|
||||
// Off screen straight away, but the launch keeps waiting until the
|
||||
// install below is done, so the pause needs a progress notification of
|
||||
// its own the same way `apply_updates_for_launch` gives one
|
||||
self.with_engine(|state| state.notifications.hide_optional_mods());
|
||||
let actions = self.clone();
|
||||
|
||||
spawn_forever(async move {
|
||||
let Ok(state) = launcher::state() else {
|
||||
actions.close_optional_mods();
|
||||
return;
|
||||
};
|
||||
let content = state.services.content();
|
||||
let events = state.services.events.clone();
|
||||
let session = oneclient_events::GroupedProgressSession::start(
|
||||
&events,
|
||||
"Adding optional mods",
|
||||
);
|
||||
let opt_in = session.child(
|
||||
"Enabling mods",
|
||||
mods.len() as u64,
|
||||
oneclient_events::TaskCategory::Packages,
|
||||
);
|
||||
opt_in.set_phase(oneclient_events::TaskPhase::Installing);
|
||||
|
||||
let mut clusters: Vec<ClusterId> = Vec::new();
|
||||
let mut enabled: Vec<(ClusterId, OptionalModRef)> = Vec::new();
|
||||
let mut failed = 0usize;
|
||||
for (cluster_id, (bundle_name, package_id)) in &mods {
|
||||
match oneclient_core::set_bundle_package_enabled(
|
||||
*cluster_id,
|
||||
bundle_name,
|
||||
package_id,
|
||||
true,
|
||||
false,
|
||||
&content,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
if !clusters.contains(cluster_id) {
|
||||
clusters.push(*cluster_id);
|
||||
}
|
||||
enabled.push((*cluster_id, (bundle_name.clone(), package_id.clone())));
|
||||
}
|
||||
Err(err) => {
|
||||
failed += 1;
|
||||
tracing::warn!(
|
||||
cluster_id,
|
||||
%bundle_name,
|
||||
%package_id,
|
||||
error = %err,
|
||||
"failed to opt in to an optional mod"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
opt_in.finish();
|
||||
|
||||
let mut installed = 0usize;
|
||||
let mut applied: Vec<ClusterId> = Vec::new();
|
||||
for cluster_id in &clusters {
|
||||
match oneclient_core::apply_bundle_updates_with(
|
||||
*cluster_id,
|
||||
state.bundles.as_ref(),
|
||||
&content,
|
||||
Some(&session),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
installed += result.additions_applied.len();
|
||||
failed += result.additions_failed.len();
|
||||
applied.push(*cluster_id);
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(cluster_id, error = %err, "failed to install optional mods");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An offer only counts as answered once its mod really was enabled
|
||||
// and installed otherwise the click is lost and never comes back
|
||||
enabled.retain(|(cluster_id, _)| applied.contains(cluster_id));
|
||||
for (cluster_id, package_ids) in group_by_cluster(&enabled) {
|
||||
if let Err(err) =
|
||||
oneclient_core::resolve_optional_mods(cluster_id, &package_ids, &content).await
|
||||
{
|
||||
tracing::warn!(cluster_id, error = %err, "failed to clear answered offers");
|
||||
}
|
||||
}
|
||||
|
||||
session.finish();
|
||||
super::invalidate_cluster_queries().await;
|
||||
|
||||
if installed > 0 {
|
||||
events
|
||||
.notify("Mods added")
|
||||
.body(format!(
|
||||
"{installed} mod{} added",
|
||||
if installed == 1 { "" } else { "s" }
|
||||
))
|
||||
.send();
|
||||
}
|
||||
if failed > 0 {
|
||||
events
|
||||
.notify("Some mods were not added")
|
||||
.body(format!(
|
||||
"{failed} mod{} could not be installed",
|
||||
if failed == 1 { "" } else { "s" }
|
||||
))
|
||||
.error()
|
||||
.send();
|
||||
}
|
||||
|
||||
actions.close_optional_mods();
|
||||
});
|
||||
}
|
||||
|
||||
pub fn close_package_updates(&self) {
|
||||
self.with_engine(|state| state.notifications.close_package_updates());
|
||||
}
|
||||
|
|
@ -970,6 +1138,8 @@ impl Actions {
|
|||
crate::install::cluster_update_notification(cluster_id, &result, &state.services)
|
||||
.await
|
||||
{
|
||||
// The offers are queued by the sync itself and raised at launch
|
||||
// this path only reports what changed
|
||||
actions.push_notification(spec);
|
||||
}
|
||||
});
|
||||
|
|
@ -1237,6 +1407,46 @@ impl Actions {
|
|||
}
|
||||
}
|
||||
|
||||
async fn resolve_optional_mods_before_launch(
|
||||
&self,
|
||||
state: &Arc<oneclient_core::LauncherState>,
|
||||
cluster_id: ClusterId,
|
||||
) {
|
||||
let pending = match oneclient_core::pending_optional_mods(
|
||||
cluster_id,
|
||||
state.bundles.as_ref(),
|
||||
&state.services.content(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(pending) => pending,
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
cluster_id,
|
||||
error = %err,
|
||||
"could not read queued optional mods, launching anyway"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// `pending_optional_mods` already drops everything the player turned
|
||||
// down, so an empty list here means there is nothing left to ask
|
||||
let Some(group) =
|
||||
crate::install::pending_optional_group(cluster_id, &pending, &state.services).await
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let (done, wait) = tokio::sync::oneshot::channel();
|
||||
self.with_engine(move |state| {
|
||||
state.notifications.open_optional_mods(vec![group], Some(done));
|
||||
state.center_open = false;
|
||||
});
|
||||
|
||||
let _ = wait.await;
|
||||
}
|
||||
|
||||
async fn prompt_package_updates(&self, group: PackageUpdateGroup) {
|
||||
let (done, wait) = tokio::sync::oneshot::channel();
|
||||
|
||||
|
|
@ -1245,7 +1455,6 @@ impl Actions {
|
|||
state.center_open = false;
|
||||
});
|
||||
|
||||
// A replaced or torn-down modal is equally a reason to stop waiting
|
||||
let _ = wait.await;
|
||||
}
|
||||
|
||||
|
|
@ -1338,8 +1547,19 @@ impl Actions {
|
|||
}
|
||||
}
|
||||
|
||||
/// Failures are reported as game events so the caller only has to know the
|
||||
/// attempt is over Everything before `launch_cluster` is pre-launch work
|
||||
fn group_by_cluster(mods: &[(ClusterId, OptionalModRef)]) -> Vec<(ClusterId, Vec<String>)> {
|
||||
let mut grouped: Vec<(ClusterId, Vec<String>)> = Vec::new();
|
||||
|
||||
for (cluster_id, (_bundle_name, package_id)) in mods {
|
||||
match grouped.iter_mut().find(|(id, _)| id == cluster_id) {
|
||||
Some((_, package_ids)) => package_ids.push(package_id.clone()),
|
||||
None => grouped.push((*cluster_id, vec![package_id.clone()])),
|
||||
}
|
||||
}
|
||||
|
||||
grouped
|
||||
}
|
||||
|
||||
async fn launch(actions: &Actions, cluster_id: ClusterId) {
|
||||
let Ok(state) = launcher::state() else { return };
|
||||
let events = state.services.events.clone();
|
||||
|
|
@ -1368,6 +1588,11 @@ async fn launch(actions: &Actions, cluster_id: ClusterId) {
|
|||
.resolve_package_updates_before_launch(&state, cluster_id)
|
||||
.await;
|
||||
|
||||
// After the updates so the player never faces two modals at once
|
||||
actions
|
||||
.resolve_optional_mods_before_launch(&state, cluster_id)
|
||||
.await;
|
||||
|
||||
if let Err(err) = oneclient_core::launch_cluster(&state, cluster_id, &account, true).await {
|
||||
// A missing file is the one failure the launcher can fix itself and a
|
||||
// path inside our metadata folder gives the user nothing to act on
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ use oneclient_events::Level;
|
|||
use crate::components::IconType;
|
||||
use crate::notifications::{
|
||||
ClusterUpdateItem, ClusterUpdateSummary, NotificationAction, NotificationActionKind,
|
||||
NotificationSpec, PackageUpdateGroup,
|
||||
NotificationSpec, OptionalModsGroup, PackageUpdateGroup,
|
||||
};
|
||||
|
||||
/// Falls back to a placeholder a missing row must not lose the whole message
|
||||
|
|
@ -48,15 +48,26 @@ pub fn item_from_bundle_file(file: &oneclient_core::BundleFile) -> ClusterUpdate
|
|||
provider: *provider,
|
||||
project_id: Some(project_id.clone()),
|
||||
fallback: file.display_name(),
|
||||
offer: None,
|
||||
status: None,
|
||||
},
|
||||
oneclient_core::BundleFileKind::External(_) => ClusterUpdateItem {
|
||||
provider: oneclient_content::packages::ProviderId::Local,
|
||||
project_id: None,
|
||||
fallback: file.display_name(),
|
||||
offer: None,
|
||||
status: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn item_from_optional(package: &oneclient_core::BundleOptionalPackage) -> ClusterUpdateItem {
|
||||
ClusterUpdateItem {
|
||||
offer: Some((package.bundle_name.clone(), package.package_id.clone())),
|
||||
..item_from_bundle_file(&package.file)
|
||||
}
|
||||
}
|
||||
|
||||
async fn cluster_update_summary(
|
||||
cluster_id: i64,
|
||||
result: &oneclient_core::ApplyBundleUpdatesResult,
|
||||
|
|
@ -84,10 +95,17 @@ async fn cluster_update_summary(
|
|||
.display_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| r.package_id.clone()),
|
||||
offer: None,
|
||||
status: None,
|
||||
})
|
||||
.collect();
|
||||
let optional: Vec<ClusterUpdateItem> = result
|
||||
.optional_available
|
||||
.iter()
|
||||
.map(item_from_optional)
|
||||
.collect();
|
||||
|
||||
if updated.is_empty() && added.is_empty() && removed.is_empty() {
|
||||
if updated.is_empty() && added.is_empty() && removed.is_empty() && optional.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
|
|
@ -103,6 +121,7 @@ async fn cluster_update_summary(
|
|||
updated,
|
||||
added,
|
||||
removed,
|
||||
optional,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -113,14 +132,31 @@ pub async fn cluster_update_notification(
|
|||
) -> Option<NotificationSpec> {
|
||||
let summary = cluster_update_summary(cluster_id, result, services).await?;
|
||||
let total = summary.total();
|
||||
let body = format!(
|
||||
"{total} package{} changed in {}",
|
||||
if total == 1 { "" } else { "s" },
|
||||
summary.cluster_name
|
||||
);
|
||||
// A bundle can offer an opt-in mod without changing anything else
|
||||
// "0 packages changed" would be both wrong and alarming
|
||||
let (title, body) = if total == 0 {
|
||||
let offers = summary.optional.len();
|
||||
(
|
||||
"Optional mods available",
|
||||
format!(
|
||||
"{offers} optional mod{} in {}",
|
||||
if offers == 1 { "" } else { "s" },
|
||||
summary.cluster_name
|
||||
),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"Cluster updated",
|
||||
format!(
|
||||
"{total} package{} changed in {}",
|
||||
if total == 1 { "" } else { "s" },
|
||||
summary.cluster_name
|
||||
),
|
||||
)
|
||||
};
|
||||
|
||||
Some(NotificationSpec {
|
||||
title: "Cluster updated".to_string(),
|
||||
title: title.to_string(),
|
||||
body,
|
||||
level: Level::Info,
|
||||
icon: Some(IconType::DownloadCloud02),
|
||||
|
|
@ -152,14 +188,29 @@ pub async fn combined_cluster_update_spec(
|
|||
}
|
||||
|
||||
let cluster_count = summaries.len();
|
||||
let body = format!(
|
||||
"{total_changes} package{} updated across {cluster_count} cluster{}",
|
||||
if total_changes == 1 { "" } else { "s" },
|
||||
if cluster_count == 1 { "" } else { "s" }
|
||||
);
|
||||
let (title, body) = if total_changes == 0 {
|
||||
let offers: usize = summaries.iter().map(|s| s.optional.len()).sum();
|
||||
(
|
||||
"Optional mods available",
|
||||
format!(
|
||||
"{offers} optional mod{} across {cluster_count} cluster{}",
|
||||
if offers == 1 { "" } else { "s" },
|
||||
if cluster_count == 1 { "" } else { "s" }
|
||||
),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"Mods updated",
|
||||
format!(
|
||||
"{total_changes} package{} updated across {cluster_count} cluster{}",
|
||||
if total_changes == 1 { "" } else { "s" },
|
||||
if cluster_count == 1 { "" } else { "s" }
|
||||
),
|
||||
)
|
||||
};
|
||||
|
||||
Some(NotificationSpec {
|
||||
title: "Mods updated".to_string(),
|
||||
title: title.to_string(),
|
||||
body,
|
||||
level: Level::Info,
|
||||
icon: Some(IconType::DownloadCloud02),
|
||||
|
|
@ -171,14 +222,35 @@ pub async fn combined_cluster_update_spec(
|
|||
})
|
||||
}
|
||||
|
||||
/// The session is detached not finished so the caller can reuse its notification
|
||||
/// as the "Installed" / "Install failed" result
|
||||
/// `session_id` is `None` if no download ran
|
||||
pub async fn pending_optional_group(
|
||||
cluster_id: i64,
|
||||
pending: &[oneclient_core::PendingOptionalMod],
|
||||
services: &oneclient_core::LauncherServices,
|
||||
) -> Option<OptionalModsGroup> {
|
||||
if pending.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mods: Vec<ClusterUpdateItem> = pending
|
||||
.iter()
|
||||
.map(|entry| ClusterUpdateItem {
|
||||
offer: Some((entry.bundle_name.clone(), entry.package_id.clone())),
|
||||
status: Some(entry.status),
|
||||
..item_from_bundle_file(&entry.file)
|
||||
})
|
||||
.collect();
|
||||
|
||||
Some(OptionalModsGroup {
|
||||
cluster_id,
|
||||
cluster_name: cluster_display_name(cluster_id, services).await,
|
||||
mods,
|
||||
})
|
||||
}
|
||||
|
||||
pub struct PackageInstall {
|
||||
pub session_id: Option<uuid::Uuid>,
|
||||
pub result: anyhow::Result<String>,
|
||||
pub dependencies: Vec<String>,
|
||||
/// Unresolved or failed dependencies the package still installs
|
||||
pub missing_dependencies: Vec<String>,
|
||||
/// The game is open but could not take the package, so the toast must not
|
||||
/// claim it is there now
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ use freya::prelude::*;
|
|||
use freya::router::*;
|
||||
|
||||
use crate::components::{
|
||||
AccountSwitcher, ClusterUpdatePopup, GenericPromptOverlay, JavaPromptOverlay,
|
||||
AccountSwitcher, ClusterUpdatePopup, GenericPromptOverlay, JavaPromptOverlay, OptionalModsPopup,
|
||||
NotificationCenter, PackageUpdatePopup, SplashCurtain, StatusBar, Toasts,
|
||||
UpdatePromptOverlay,
|
||||
};
|
||||
|
|
@ -74,6 +74,7 @@ impl Component for RootLayout {
|
|||
// Must stay last it renders whatever the overlays above did not claim
|
||||
.child(GenericPromptOverlay)
|
||||
.child(ClusterUpdatePopup)
|
||||
.child(OptionalModsPopup)
|
||||
.child(PackageUpdatePopup)
|
||||
.child(StatusBar)
|
||||
.child(SplashCurtain)
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use oneclient_events::{
|
|||
};
|
||||
use oneclient_content::packages::ProviderId;
|
||||
use oneclient_core::BrowserPackageUpdate;
|
||||
use oneclient_db::models::ClusterId;
|
||||
use oneclient_db::models::{ClusterId, OptionalModStatus};
|
||||
use tokio::sync::oneshot;
|
||||
use uuid::Uuid;
|
||||
|
||||
|
|
@ -36,12 +36,16 @@ pub struct PackageUpdateGroup {
|
|||
pub packages: Vec<BrowserPackageUpdate>,
|
||||
}
|
||||
|
||||
pub type OptionalModRef = (String, String);
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ClusterUpdateItem {
|
||||
pub provider: ProviderId,
|
||||
pub project_id: Option<String>,
|
||||
/// Used when the meta cache has no entry (file name / package id)
|
||||
pub fallback: String,
|
||||
pub offer: Option<OptionalModRef>,
|
||||
pub status: Option<OptionalModStatus>,
|
||||
}
|
||||
|
||||
impl ClusterUpdateItem {
|
||||
|
|
@ -50,16 +54,45 @@ impl ClusterUpdateItem {
|
|||
provider: ProviderId::Local,
|
||||
project_id: None,
|
||||
fallback: name.into(),
|
||||
offer: None,
|
||||
status: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct OptionalModsGroup {
|
||||
pub cluster_id: ClusterId,
|
||||
pub cluster_name: String,
|
||||
pub mods: Vec<ClusterUpdateItem>,
|
||||
}
|
||||
|
||||
impl OptionalModsGroup {
|
||||
pub fn from_summary(summary: &ClusterUpdateSummary) -> Option<Self> {
|
||||
if summary.optional.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
cluster_id: summary.cluster_id,
|
||||
cluster_name: summary.cluster_name.clone(),
|
||||
mods: summary.optional.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn offers(&self) -> impl Iterator<Item = (ClusterId, OptionalModRef)> + '_ {
|
||||
self.mods
|
||||
.iter()
|
||||
.filter_map(move |item| item.offer.clone().map(|reference| (self.cluster_id, reference)))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ClusterUpdateSummary {
|
||||
pub cluster_id: ClusterId,
|
||||
pub cluster_name: String,
|
||||
pub updated: Vec<ClusterUpdateItem>,
|
||||
pub added: Vec<ClusterUpdateItem>,
|
||||
pub optional: Vec<ClusterUpdateItem>,
|
||||
pub removed: Vec<ClusterUpdateItem>,
|
||||
}
|
||||
|
||||
|
|
@ -167,6 +200,7 @@ pub struct NotificationSnapshot {
|
|||
pub center_open: bool,
|
||||
pub pending_prompt: Option<PendingPromptView>,
|
||||
pub cluster_update: Option<Vec<ClusterUpdateSummary>>,
|
||||
pub optional_mods: Option<Vec<OptionalModsGroup>>,
|
||||
pub package_updates: Option<Vec<PackageUpdateGroup>>,
|
||||
pub active_toast_entry_ids: Vec<u64>,
|
||||
}
|
||||
|
|
@ -186,11 +220,13 @@ pub struct NotificationState {
|
|||
grouped_tasks: HashMap<Uuid, GroupedTasks>,
|
||||
pending_timers: Vec<ToastDismissTimer>,
|
||||
cluster_update: Option<Vec<ClusterUpdateSummary>>,
|
||||
optional_mods: Option<Vec<OptionalModsGroup>>,
|
||||
package_updates: Option<Vec<PackageUpdateGroup>>,
|
||||
/// 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
|
||||
package_updates_done: Option<oneshot::Sender<()>>,
|
||||
optional_mods_done: Option<oneshot::Sender<()>>,
|
||||
}
|
||||
|
||||
const CATEGORY_ORDER: [TaskCategory; 7] = [
|
||||
|
|
@ -294,6 +330,7 @@ impl NotificationState {
|
|||
center_open,
|
||||
pending_prompt,
|
||||
cluster_update: self.cluster_update.clone(),
|
||||
optional_mods: self.optional_mods.clone(),
|
||||
package_updates: self.package_updates.clone(),
|
||||
active_toast_entry_ids: self.active_toasts.iter().map(|t| t.entry_id).collect(),
|
||||
}
|
||||
|
|
@ -315,8 +352,39 @@ impl NotificationState {
|
|||
self.cluster_update = None;
|
||||
}
|
||||
|
||||
/// `done` is the launch's continuation it fires immediately when there is nothing
|
||||
/// to show so a launch never waits on a modal that was never drawn
|
||||
pub fn open_optional_mods(
|
||||
&mut self,
|
||||
groups: Vec<OptionalModsGroup>,
|
||||
done: Option<oneshot::Sender<()>>,
|
||||
) {
|
||||
let groups: Vec<OptionalModsGroup> = groups
|
||||
.into_iter()
|
||||
.filter(|group| !group.mods.is_empty())
|
||||
.collect();
|
||||
|
||||
if groups.is_empty() {
|
||||
if let Some(done) = done {
|
||||
let _ = done.send(());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
self.finish_optional_mods();
|
||||
self.optional_mods = Some(groups);
|
||||
self.optional_mods_done = done;
|
||||
}
|
||||
|
||||
pub fn hide_optional_mods(&mut self) {
|
||||
self.optional_mods = None;
|
||||
}
|
||||
|
||||
pub fn finish_optional_mods(&mut self) {
|
||||
self.optional_mods = None;
|
||||
if let Some(done) = self.optional_mods_done.take() {
|
||||
let _ = done.send(());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open_package_updates(
|
||||
&mut self,
|
||||
groups: Vec<PackageUpdateGroup>,
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use oneclient_content::packages::{CachedPackageMeta, ContentType, ProviderId};
|
|||
use oneclient_core::{BundleFileKind, BundleWithUpdateStatus, LinkedArtifactInfo};
|
||||
use oneclient_db::models::OverrideType;
|
||||
|
||||
use crate::components::{CardLayout, PackageEntry};
|
||||
use crate::components::{CARD_GRID_H, CardLayout, GRID_GAP, GRID_MIN_W, PackageEntry};
|
||||
use crate::hooks::{package_meta_batch, use_game_snapshot, use_package_meta_batch, use_view_state};
|
||||
|
||||
mod views;
|
||||
|
|
@ -14,9 +14,6 @@ use views::{ContentBox, ContentKind, EnabledFilter, HiddenFilter, SortMode, tool
|
|||
|
||||
const CARD_H: f32 = 84.;
|
||||
const CARD_SPACING: f32 = 8.;
|
||||
const CARD_GRID_H: f32 = 148.;
|
||||
const GRID_GAP: f32 = 10.;
|
||||
const GRID_MIN_W: f32 = 260.;
|
||||
const LAZY_OVERSCAN: i64 = 2;
|
||||
|
||||
pub type PackageMetaMap = HashMap<(ProviderId, String), CachedPackageMeta>;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
use freya::prelude::*;
|
||||
use freya::router::RouterContext;
|
||||
use oneclient_db::console::{ConsoleQueryResult, run_console_query};
|
||||
use oneclient_db::models::OptionalModStatus;
|
||||
|
||||
use oneclient_auth::preview_samples;
|
||||
use oneclient_core::simulate::Damage;
|
||||
|
|
@ -11,6 +12,7 @@ use crate::components::{Button, Dropdown, Icon, IconType, TextInput, login_dialo
|
|||
use crate::hooks::use_dispatch;
|
||||
use crate::notifications::{
|
||||
ClusterUpdateItem, ClusterUpdateSummary, NotificationAction, NotificationActionKind,
|
||||
OptionalModsGroup,
|
||||
};
|
||||
use crate::routes::Route;
|
||||
use crate::theme::colors;
|
||||
|
|
@ -211,6 +213,7 @@ impl Component for ClusterUpdateSimulator {
|
|||
let updated = use_state(|| "Sodium 0.5 → 0.6, Iris 1.7 → 1.8".to_string());
|
||||
let added = use_state(|| "Lithium".to_string());
|
||||
let removed = use_state(|| "OptiFine".to_string());
|
||||
let optional = use_state(|| "Lithium".to_string());
|
||||
|
||||
let simulate = dispatch.clone();
|
||||
|
||||
|
|
@ -220,7 +223,7 @@ impl Component for ClusterUpdateSimulator {
|
|||
.spacing(10.)
|
||||
.child(
|
||||
label()
|
||||
.text("Builds the same notification the bundle sync sends: one \"View changes\" action carrying every changed cluster, opened into the changes modal.")
|
||||
.text("Builds the same notification the bundle sync sends: one \"View changes\" action carrying every changed cluster, opened into the changes modal. Anything listed under Optional also raises the \"Optional mods available\" prompt, exactly as the real sync does. Optional mods are not installed, so they are independent of Added rather than a subset of it.")
|
||||
.font_size(13.)
|
||||
.color(colors::fg_secondary()),
|
||||
)
|
||||
|
|
@ -259,6 +262,11 @@ impl Component for ClusterUpdateSimulator {
|
|||
rect().width(Size::flex(1.0)).child(
|
||||
TextInput::new(removed).placeholder("Removed (comma separated)"),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
rect().width(Size::flex(1.0)).child(
|
||||
TextInput::new(optional).placeholder("Optional (comma separated)"),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
|
|
@ -273,6 +281,7 @@ impl Component for ClusterUpdateSimulator {
|
|||
updated: cluster_update_items(&split_csv(&updated.read())),
|
||||
added: cluster_update_items(&split_csv(&added.read())),
|
||||
removed: cluster_update_items(&split_csv(&removed.read())),
|
||||
optional: cluster_update_items(&split_csv(&optional.read()))
|
||||
};
|
||||
send_cluster_update(&simulate, vec![summary]);
|
||||
}),
|
||||
|
|
@ -291,7 +300,7 @@ impl Component for ClusterUpdateSimulator {
|
|||
type ClusterUpdatePreset = fn() -> Vec<ClusterUpdateSummary>;
|
||||
|
||||
/// Each preset pins one variation plural copy empty category tab single- vs multi-cluster footer grouping header overflow
|
||||
const CLUSTER_UPDATE_PRESETS: [(&str, IconType, ClusterUpdatePreset); 6] = [
|
||||
const CLUSTER_UPDATE_PRESETS: [(&str, IconType, ClusterUpdatePreset); 7] = [
|
||||
(
|
||||
"1 cluster · all categories",
|
||||
IconType::DownloadCloud02,
|
||||
|
|
@ -302,6 +311,7 @@ const CLUSTER_UPDATE_PRESETS: [(&str, IconType, ClusterUpdatePreset); 6] = [
|
|||
&["Sodium 0.5 → 0.6", "Iris 1.7 → 1.8", "Patcher 1.8 → 1.9"],
|
||||
&["Lithium", "FerriteCore"],
|
||||
&["OptiFine"],
|
||||
&["Distant Horizons"],
|
||||
)]
|
||||
},
|
||||
),
|
||||
|
|
@ -317,10 +327,11 @@ const CLUSTER_UPDATE_PRESETS: [(&str, IconType, ClusterUpdatePreset); 6] = [
|
|||
],
|
||||
&[],
|
||||
&[],
|
||||
&[],
|
||||
)]
|
||||
}),
|
||||
("1 cluster · single change", IconType::Plus, || {
|
||||
vec![preset_summary(1, "PolyBlock", &[], &["Lithium"], &[])]
|
||||
vec![preset_summary(1, "PolyBlock", &[], &["Lithium"], &[], &[])]
|
||||
}),
|
||||
("3 clusters · mixed", IconType::DotsGrid, || {
|
||||
vec![
|
||||
|
|
@ -330,15 +341,16 @@ const CLUSTER_UPDATE_PRESETS: [(&str, IconType, ClusterUpdatePreset); 6] = [
|
|||
&["Sodium 0.5 → 0.6", "Iris 1.7 → 1.8"],
|
||||
&["Lithium"],
|
||||
&[],
|
||||
&[],
|
||||
),
|
||||
preset_summary(2, "Skyblock", &[], &[], &["OptiFine", "Skytils"]),
|
||||
preset_summary(3, "Vanilla+", &[], &["Sodium", "Iris", "FerriteCore"], &[]),
|
||||
preset_summary(2, "Skyblock", &[], &[], &["OptiFine", "Skytils"], &[]),
|
||||
preset_summary(3, "Vanilla+", &[], &["Sodium", "Iris", "FerriteCore"], &[], &[]),
|
||||
]
|
||||
}),
|
||||
("2 clusters · removals only", IconType::Trash01, || {
|
||||
vec![
|
||||
preset_summary(1, "PolyBlock", &[], &[], &["OptiFine"]),
|
||||
preset_summary(2, "Skyblock", &[], &[], &["Skytils", "NotEnoughUpdates"]),
|
||||
preset_summary(1, "PolyBlock", &[], &[], &["OptiFine"], &[]),
|
||||
preset_summary(2, "Skyblock", &[], &[], &["Skytils", "NotEnoughUpdates"], &[]),
|
||||
]
|
||||
}),
|
||||
("6 clusters · long names", IconType::Database01, || {
|
||||
|
|
@ -355,10 +367,31 @@ const CLUSTER_UPDATE_PRESETS: [(&str, IconType, ClusterUpdatePreset); 6] = [
|
|||
]),
|
||||
added: cluster_update_items(&[format!("Lithium {i}")]),
|
||||
removed: Vec::new(),
|
||||
optional: Vec::new(),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}),
|
||||
("2 clusters · offers only", IconType::Plus, || {
|
||||
vec![
|
||||
preset_summary(
|
||||
1,
|
||||
"PolyBlock",
|
||||
&[],
|
||||
&[],
|
||||
&[],
|
||||
&["Lithium", "FerriteCore"],
|
||||
),
|
||||
preset_summary(
|
||||
2,
|
||||
"Skyblock",
|
||||
&[],
|
||||
&["Skytils"],
|
||||
&[],
|
||||
&["Skytils"],
|
||||
),
|
||||
]
|
||||
}),
|
||||
];
|
||||
|
||||
fn cluster_update_preset_rows(dispatch: &crate::Actions) -> Vec<Element> {
|
||||
|
|
@ -390,6 +423,7 @@ fn preset_summary(
|
|||
updated: &[&str],
|
||||
added: &[&str],
|
||||
removed: &[&str],
|
||||
optional: &[&str],
|
||||
) -> ClusterUpdateSummary {
|
||||
ClusterUpdateSummary {
|
||||
cluster_id,
|
||||
|
|
@ -397,17 +431,26 @@ fn preset_summary(
|
|||
updated: cluster_update_items(updated),
|
||||
added: cluster_update_items(added),
|
||||
removed: cluster_update_items(removed),
|
||||
optional: cluster_update_items(optional)
|
||||
}
|
||||
}
|
||||
|
||||
fn cluster_update_items(names: &[impl AsRef<str>]) -> Vec<ClusterUpdateItem> {
|
||||
names
|
||||
.iter()
|
||||
.map(|name| ClusterUpdateItem::from_name(name.as_ref()))
|
||||
.enumerate()
|
||||
.map(|(index, name)| ClusterUpdateItem {
|
||||
offer: Some(("simulated-bundle".to_string(), name.as_ref().to_string())),
|
||||
status: Some(if index % 2 == 0 {
|
||||
OptionalModStatus::New
|
||||
} else {
|
||||
OptionalModStatus::Skipped
|
||||
}),
|
||||
..ClusterUpdateItem::from_name(name.as_ref())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Mirrors the copy the bridge builds so the simulated notification matches the real one
|
||||
fn send_cluster_update(dispatch: &crate::Actions, summaries: Vec<ClusterUpdateSummary>) {
|
||||
if summaries.is_empty() {
|
||||
return;
|
||||
|
|
@ -429,6 +472,11 @@ fn send_cluster_update(dispatch: &crate::Actions, summaries: Vec<ClusterUpdateSu
|
|||
),
|
||||
};
|
||||
|
||||
let optional: Vec<OptionalModsGroup> = summaries
|
||||
.iter()
|
||||
.filter_map(OptionalModsGroup::from_summary)
|
||||
.collect();
|
||||
|
||||
dispatch
|
||||
.notify(title)
|
||||
.body(body)
|
||||
|
|
@ -438,6 +486,9 @@ fn send_cluster_update(dispatch: &crate::Actions, summaries: Vec<ClusterUpdateSu
|
|||
kind: NotificationActionKind::OpenClusterUpdate(summaries),
|
||||
})
|
||||
.send();
|
||||
|
||||
// No continuation the real prompt is raised by a launch, which waits on one
|
||||
dispatch.open_optional_mods(optional, None);
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ mod error;
|
|||
mod install;
|
||||
mod manager;
|
||||
mod manifest;
|
||||
mod optional;
|
||||
mod overrides;
|
||||
mod polymrpack;
|
||||
mod runtime;
|
||||
|
|
@ -22,13 +23,17 @@ pub use install::{
|
|||
set_bundle_package_override, set_bundle_package_overrides,
|
||||
};
|
||||
pub use manager::{Bundle, BundlesManager};
|
||||
pub use optional::{
|
||||
PendingOptionalMod, pending_optional_mods, resolve_optional_mods, skip_optional_mods,
|
||||
};
|
||||
pub use manifest::BundleManifest as RemoteBundleManifest;
|
||||
pub use runtime::{is_bundle_syncing, sync_all_cluster_bundles};
|
||||
pub use types::{
|
||||
ApplyBundleUpdatesResult, BundleArchive, BundleFile, BundleFileKind, BundleManifest,
|
||||
BundlePackageAddition, BundlePackageRemoval, BundlePackageUpdate, BundleUpdateCheckResult,
|
||||
BundleWithUpdateStatus, FileUpdateStatus,
|
||||
ApplyBundleUpdatesResult, BundleArchive, BundleFile, BundleFileKind, BundleOptionalPackage,
|
||||
BundleManifest, BundlePackageAddition, BundlePackageRemoval, BundlePackageUpdate,
|
||||
BundleUpdateCheckResult, BundleWithUpdateStatus, FileUpdateStatus,
|
||||
};
|
||||
pub use updates::{
|
||||
apply_bundle_updates, check_bundle_updates, get_bundles_with_update_status,
|
||||
apply_bundle_updates, apply_bundle_updates_with, check_bundle_updates,
|
||||
get_bundles_with_update_status,
|
||||
};
|
||||
|
|
|
|||
121
packages/oneclient_content/src/bundles/optional.rs
Normal file
121
packages/oneclient_content/src/bundles/optional.rs
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
use oneclient_db::dao::cluster_bundle as bundle_dao;
|
||||
use oneclient_db::dao::cluster_optional_mod as optional_dao;
|
||||
use oneclient_db::models::{OptionalModStatus, OverrideType};
|
||||
use crate::bundles::manager::BundlesManager;
|
||||
use crate::bundles::types::{BundleArchive, BundleFile};
|
||||
use crate::ctx::ContentCtx;
|
||||
use crate::error::ContentResult;
|
||||
use crate::packages::store::PackageStore;
|
||||
use oneclient_common::domain::GameLoader;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PendingOptionalMod {
|
||||
pub cluster_id: i64,
|
||||
pub bundle_name: String,
|
||||
pub package_id: String,
|
||||
pub status: OptionalModStatus,
|
||||
pub file: BundleFile,
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "debug", skip(bundles, ctx))]
|
||||
pub async fn pending_optional_mods(
|
||||
cluster_id: i64,
|
||||
bundles: &BundlesManager,
|
||||
ctx: &ContentCtx,
|
||||
) -> ContentResult<Vec<PendingOptionalMod>> {
|
||||
let rows: Vec<_> = optional_dao::list_pending(&ctx.db, cluster_id)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|row| row.status() == OptionalModStatus::New)
|
||||
.collect();
|
||||
if rows.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let cluster = PackageStore::get_cluster(cluster_id, ctx).await?;
|
||||
let loader = GameLoader::from_repr(cluster.mc_loader as u8).unwrap_or(GameLoader::Fabric);
|
||||
let archives = bundles
|
||||
.archives_for(ctx, &cluster.mc_version, loader)
|
||||
.await?;
|
||||
let overrides = bundle_dao::list_overrides(&ctx.db, cluster_id).await?;
|
||||
|
||||
let mut pending = Vec::new();
|
||||
for row in rows {
|
||||
let Some((bundle_name, file)) = find_shipped(&archives, &row.bundle_name, &row.package_id)
|
||||
else {
|
||||
tracing::debug!(
|
||||
cluster_id,
|
||||
package_id = %row.package_id,
|
||||
"queued offer is no longer shipped by any bundle"
|
||||
);
|
||||
continue;
|
||||
};
|
||||
|
||||
// The queue row can outlive the offer the user may have opted in from
|
||||
// the package manager since it was written
|
||||
let user_override = overrides
|
||||
.iter()
|
||||
.find(|o| o.bundle_name == bundle_name && o.package_id == row.package_id)
|
||||
.and_then(|o| OverrideType::parse(&o.override_type));
|
||||
if crate::bundles::effective_enabled(&file, user_override) {
|
||||
tracing::debug!(
|
||||
cluster_id,
|
||||
package_id = %row.package_id,
|
||||
"queued offer is already enabled for this cluster"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let status = row.status();
|
||||
pending.push(PendingOptionalMod {
|
||||
cluster_id,
|
||||
bundle_name,
|
||||
package_id: row.package_id,
|
||||
status,
|
||||
file,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(pending)
|
||||
}
|
||||
|
||||
fn find_shipped(
|
||||
archives: &[BundleArchive],
|
||||
preferred_bundle: &str,
|
||||
package_id: &str,
|
||||
) -> Option<(String, BundleFile)> {
|
||||
let in_archive = |archive: &BundleArchive| {
|
||||
archive
|
||||
.manifest
|
||||
.files
|
||||
.iter()
|
||||
.find(|file| file.kind.package_id() == package_id)
|
||||
.map(|file| (archive.manifest.name.clone(), file.clone()))
|
||||
};
|
||||
|
||||
archives
|
||||
.iter()
|
||||
.find(|archive| archive.manifest.name == preferred_bundle)
|
||||
.and_then(in_archive)
|
||||
.or_else(|| archives.iter().find_map(in_archive))
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "debug", skip(ctx))]
|
||||
pub async fn skip_optional_mods(
|
||||
cluster_id: i64,
|
||||
package_ids: &[String],
|
||||
ctx: &ContentCtx,
|
||||
) -> ContentResult<()> {
|
||||
optional_dao::mark_skipped(&ctx.db, cluster_id, package_ids).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "debug", skip(ctx))]
|
||||
pub async fn resolve_optional_mods(
|
||||
cluster_id: i64,
|
||||
package_ids: &[String],
|
||||
ctx: &ContentCtx,
|
||||
) -> ContentResult<()> {
|
||||
optional_dao::resolve(&ctx.db, cluster_id, package_ids).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -130,6 +130,15 @@ pub struct BundleUpdateCheckResult {
|
|||
pub updates_available: Vec<BundlePackageUpdate>,
|
||||
pub removals_available: Vec<BundlePackageRemoval>,
|
||||
pub additions_available: Vec<BundlePackageAddition>,
|
||||
pub optional_available: Vec<BundleOptionalPackage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BundleOptionalPackage {
|
||||
pub cluster_id: i64,
|
||||
pub bundle_name: String,
|
||||
pub package_id: String,
|
||||
pub file: BundleFile,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
|
|
@ -137,6 +146,7 @@ pub struct ApplyBundleUpdatesResult {
|
|||
pub updates_applied: Vec<BundlePackageUpdate>,
|
||||
pub removals_applied: Vec<BundlePackageRemoval>,
|
||||
pub additions_applied: Vec<BundlePackageAddition>,
|
||||
pub optional_available: Vec<BundleOptionalPackage>,
|
||||
pub updates_failed: Vec<String>,
|
||||
pub removals_failed: Vec<String>,
|
||||
pub additions_failed: Vec<String>,
|
||||
|
|
|
|||
|
|
@ -6,9 +6,11 @@ use oneclient_db::dao::artifact as artifact_dao;
|
|||
use oneclient_db::dao::cluster as cluster_dao;
|
||||
use oneclient_db::dao::bundle as bundle_catalog_dao;
|
||||
use oneclient_db::dao::cluster_bundle as bundle_dao;
|
||||
use oneclient_db::dao::cluster_optional_mod as optional_dao;
|
||||
use oneclient_db::models::ClusterPatch;
|
||||
use oneclient_db::models::{
|
||||
BundleTrackedArtifactRow, ClusterBundleOverrideRow, OverrideType, SeenStatus,
|
||||
BundleTrackedArtifactRow, ClusterBundleOverrideRow, OptionalModStatus, OverrideType,
|
||||
SeenStatus,
|
||||
};
|
||||
use tokio::sync::Mutex as AsyncMutex;
|
||||
|
||||
|
|
@ -22,9 +24,9 @@ use crate::bundles::install::{
|
|||
use crate::bundles::manager::BundlesManager;
|
||||
use crate::bundles::overrides;
|
||||
use crate::bundles::types::{
|
||||
ApplyBundleUpdatesResult, BundleArchive, BundleFileKind, BundlePackageAddition,
|
||||
BundlePackageRemoval, BundlePackageUpdate, BundleUpdateCheckResult, BundleWithUpdateStatus,
|
||||
FileUpdateStatus, external_bundle_key, managed_bundle_key,
|
||||
ApplyBundleUpdatesResult, BundleArchive, BundleFileKind, BundleOptionalPackage,
|
||||
BundlePackageAddition, BundlePackageRemoval, BundlePackageUpdate, BundleUpdateCheckResult,
|
||||
BundleWithUpdateStatus, FileUpdateStatus, external_bundle_key, managed_bundle_key,
|
||||
};
|
||||
use oneclient_common::domain::{GameLoader, ProviderId};
|
||||
use crate::packages::store::PackageStore;
|
||||
|
|
@ -323,6 +325,7 @@ async fn check_bundle_updates_inner(
|
|||
.await?;
|
||||
|
||||
let mut additions_available = Vec::new();
|
||||
let mut optional_available: Vec<(String, BundleOptionalPackage)> = Vec::new();
|
||||
let mut planned_addition_keys = all_installed_managed_keys.clone();
|
||||
for hash in all_installed_external_hashes {
|
||||
planned_addition_keys.insert(external_bundle_key(&hash));
|
||||
|
|
@ -338,9 +341,6 @@ async fn check_bundle_updates_inner(
|
|||
let user_override = overrides_map
|
||||
.get(&(archive.manifest.name.clone(), file_id.clone()))
|
||||
.copied();
|
||||
if !crate::bundles::effective_enabled(file, user_override) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let file_key = match &file.kind {
|
||||
BundleFileKind::Managed {
|
||||
|
|
@ -354,6 +354,28 @@ async fn check_bundle_updates_inner(
|
|||
continue;
|
||||
}
|
||||
|
||||
if !crate::bundles::effective_enabled(file, user_override) {
|
||||
if user_override.is_none()
|
||||
&& !file.hidden
|
||||
&& !optional_available.iter().any(|(key, _)| *key == file_key)
|
||||
{
|
||||
optional_available.push((
|
||||
file_key,
|
||||
BundleOptionalPackage {
|
||||
cluster_id,
|
||||
bundle_name: archive.manifest.name.clone(),
|
||||
package_id: file_id,
|
||||
file: file.clone(),
|
||||
},
|
||||
));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// A bundle shipping the file enabled outranks one shipping it
|
||||
// disabled whichever order they happen to be iterated in
|
||||
optional_available.retain(|(key, _)| *key != file_key);
|
||||
|
||||
additions_available.push(BundlePackageAddition {
|
||||
cluster_id,
|
||||
bundle_name: archive.manifest.name.clone(),
|
||||
|
|
@ -368,6 +390,10 @@ async fn check_bundle_updates_inner(
|
|||
updates_available,
|
||||
removals_available,
|
||||
additions_available,
|
||||
optional_available: optional_available
|
||||
.into_iter()
|
||||
.map(|(_, offer)| offer)
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -532,6 +558,11 @@ pub async fn apply_bundle_updates_with(
|
|||
}
|
||||
}
|
||||
|
||||
let applied_something =
|
||||
!result.updates_applied.is_empty() || !result.additions_applied.is_empty();
|
||||
result.optional_available =
|
||||
settle_optional_offers(cluster_id, check.optional_available, applied_something, ctx).await;
|
||||
|
||||
{
|
||||
let cluster = PackageStore::get_cluster(cluster_id, ctx).await?;
|
||||
let loader = GameLoader::from_repr(cluster.mc_loader as u8).unwrap_or(GameLoader::Fabric);
|
||||
|
|
@ -567,9 +598,85 @@ pub async fn apply_bundle_updates_with(
|
|||
Ok(result)
|
||||
}
|
||||
|
||||
/// `enabled` is set not flipped and overrides are read across all bundles
|
||||
/// packages get re-resolved between bundles so a per-bundle lookup misses
|
||||
/// objections filed while the file lived elsewhere
|
||||
#[tracing::instrument(level = "debug", skip_all, fields(cluster_id, offers = offers.len()))]
|
||||
async fn settle_optional_offers(
|
||||
cluster_id: i64,
|
||||
offers: Vec<BundleOptionalPackage>,
|
||||
applied_something: bool,
|
||||
ctx: &ContentCtx,
|
||||
) -> Vec<BundleOptionalPackage> {
|
||||
let queued = match optional_dao::list_pending(&ctx.db, cluster_id).await {
|
||||
Ok(rows) => rows,
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
cluster_id,
|
||||
error = %err,
|
||||
"could not read the optional mod queue, leaving it untouched"
|
||||
);
|
||||
return offers;
|
||||
}
|
||||
};
|
||||
|
||||
let has_new_offer = offers
|
||||
.iter()
|
||||
.any(|offer| !queued.iter().any(|row| row.package_id == offer.package_id));
|
||||
|
||||
if applied_something || has_new_offer {
|
||||
queue_optional_offers(cluster_id, &offers, ctx).await;
|
||||
}
|
||||
|
||||
// Everything here was already queued, so it has been reported once
|
||||
// already reporting it again would re-notify on every app start
|
||||
if !has_new_offer {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let skipped: HashSet<&str> = queued
|
||||
.iter()
|
||||
.filter(|row| row.status() == OptionalModStatus::Skipped)
|
||||
.map(|row| row.package_id.as_str())
|
||||
.collect();
|
||||
|
||||
offers
|
||||
.into_iter()
|
||||
.filter(|offer| !skipped.contains(offer.package_id.as_str()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "debug", skip_all, fields(cluster_id, offers = offers.len()))]
|
||||
async fn queue_optional_offers(
|
||||
cluster_id: i64,
|
||||
offers: &[BundleOptionalPackage],
|
||||
ctx: &ContentCtx,
|
||||
) {
|
||||
for offer in offers {
|
||||
if let Err(err) = optional_dao::queue(
|
||||
&ctx.db,
|
||||
cluster_id,
|
||||
&offer.bundle_name,
|
||||
&offer.package_id,
|
||||
&offer.file.kind.bundle_version_id(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
cluster_id,
|
||||
package_id = %offer.package_id,
|
||||
error = %err,
|
||||
"failed to queue an optional mod offer"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let offered: Vec<String> = offers
|
||||
.iter()
|
||||
.map(|offer| offer.package_id.clone())
|
||||
.collect();
|
||||
if let Err(err) = optional_dao::retain(&ctx.db, cluster_id, &offered).await {
|
||||
tracing::warn!(cluster_id, error = %err, "failed to prune stale optional mod offers");
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "debug", skip_all, fields(cluster_id = update.cluster_id, bundle = %update.bundle_name, new_version = %update.new_version_id))]
|
||||
async fn reconcile_update(
|
||||
update: &BundlePackageUpdate,
|
||||
|
|
@ -582,13 +689,12 @@ async fn reconcile_update(
|
|||
let enabled = !disable_was_deliberate(update.new_file.hidden, suppression);
|
||||
set_artifact_enabled_to(update.cluster_id, hash, enabled, ctx).await?;
|
||||
|
||||
if hash == update.installed_hash {
|
||||
return Ok(());
|
||||
if hash != update.installed_hash {
|
||||
artifact_dao::set_seen_status(&ctx.db, update.cluster_id, hash, SeenStatus::Updated).await?;
|
||||
remove_artifact_from_cluster(update.cluster_id, &update.installed_hash, false, ctx).await?;
|
||||
}
|
||||
|
||||
artifact_dao::set_seen_status(&ctx.db, update.cluster_id, hash, SeenStatus::Updated).await?;
|
||||
|
||||
remove_artifact_from_cluster(update.cluster_id, &update.installed_hash, false, ctx).await
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "debug", skip_all, fields(cluster_id = addition.cluster_id, bundle = %addition.bundle_name))]
|
||||
|
|
@ -767,6 +873,7 @@ pub async fn apply_bundle_updates_for_all_clusters(
|
|||
if !result.updates_applied.is_empty()
|
||||
|| !result.additions_applied.is_empty()
|
||||
|| !result.removals_applied.is_empty()
|
||||
|| !result.optional_available.is_empty()
|
||||
{
|
||||
changed.push((cluster.id, result));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,13 +23,15 @@ pub mod verify;
|
|||
pub mod versions;
|
||||
|
||||
pub use oneclient_content::bundles::{
|
||||
apply_bundle_updates, check_bundle_updates, effective_enabled, install_bundle,
|
||||
apply_bundle_updates, apply_bundle_updates_with, check_bundle_updates, effective_enabled,
|
||||
install_bundle,
|
||||
install_cluster_bundles,
|
||||
install_package_from_bundle, is_bundle_syncing, list_cluster_bundle_overrides,
|
||||
pending_optional_mods, resolve_optional_mods, skip_optional_mods, PendingOptionalMod,
|
||||
set_bundle_package_enabled, set_bundle_package_opt_in, set_bundle_package_override,
|
||||
set_bundle_package_overrides,
|
||||
ApplyBundleUpdatesResult, Bundle, BundleArchive, BundleError, BundleFile,
|
||||
BundleFileKind, BundleManifest, BundlesManager, BundleUpdateCheckResult,
|
||||
BundleFileKind, BundleManifest, BundleOptionalPackage, BundlesManager, BundleUpdateCheckResult,
|
||||
BundleWithUpdateStatus, FileUpdateStatus, get_bundles_with_update_status,
|
||||
remove_artifact_from_cluster, toggle_artifact_enabled,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
CREATE TABLE cluster_optional_mods (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
cluster_id INTEGER NOT NULL,
|
||||
bundle_name TEXT NOT NULL,
|
||||
package_id TEXT NOT NULL,
|
||||
bundle_version_id TEXT NOT NULL,
|
||||
seen_status INTEGER NOT NULL,
|
||||
queued_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (cluster_id) REFERENCES clusters (id) ON DELETE CASCADE,
|
||||
UNIQUE (cluster_id, package_id)
|
||||
);
|
||||
|
||||
CREATE INDEX cluster_optional_mods_cluster_id_idx ON cluster_optional_mods (cluster_id);
|
||||
133
packages/oneclient_db/src/dao/cluster_optional_mod.rs
Normal file
133
packages/oneclient_db/src/dao/cluster_optional_mod.rs
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::models::{ClusterOptionalModRow, OptionalModStatus};
|
||||
|
||||
pub async fn queue(
|
||||
pool: &SqlitePool,
|
||||
cluster_id: i64,
|
||||
bundle_name: &str,
|
||||
package_id: &str,
|
||||
bundle_version_id: &str,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
let new = OptionalModStatus::New.as_i64();
|
||||
|
||||
sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO cluster_optional_mods
|
||||
(cluster_id, bundle_name, package_id, bundle_version_id, seen_status)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(cluster_id, package_id) DO UPDATE SET
|
||||
bundle_name = excluded.bundle_name,
|
||||
bundle_version_id = excluded.bundle_version_id
|
||||
"#,
|
||||
cluster_id,
|
||||
bundle_name,
|
||||
package_id,
|
||||
bundle_version_id,
|
||||
new
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn list_pending(
|
||||
pool: &SqlitePool,
|
||||
cluster_id: i64,
|
||||
) -> Result<Vec<ClusterOptionalModRow>, sqlx::Error> {
|
||||
sqlx::query_as!(
|
||||
ClusterOptionalModRow,
|
||||
r#"
|
||||
SELECT id, cluster_id, bundle_name, package_id, bundle_version_id,
|
||||
seen_status, queued_at
|
||||
FROM cluster_optional_mods
|
||||
WHERE cluster_id = ?
|
||||
ORDER BY queued_at, id
|
||||
"#,
|
||||
cluster_id
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
// if a mod is marked as skipped, it will never show in a modal again (we mark it as seen by a user, so he doesn't have to see it again)
|
||||
pub async fn mark_skipped(
|
||||
pool: &SqlitePool,
|
||||
cluster_id: i64,
|
||||
package_ids: &[String],
|
||||
) -> Result<(), sqlx::Error> {
|
||||
if package_ids.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let seen = OptionalModStatus::Skipped.as_i64();
|
||||
let mut tx = pool.begin().await?;
|
||||
let mut missing = 0usize;
|
||||
for package_id in package_ids {
|
||||
let affected = sqlx::query!(
|
||||
r#"
|
||||
UPDATE cluster_optional_mods SET seen_status = ?
|
||||
WHERE cluster_id = ? AND package_id = ?
|
||||
"#,
|
||||
seen,
|
||||
cluster_id,
|
||||
package_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?
|
||||
.rows_affected();
|
||||
|
||||
if affected == 0 {
|
||||
missing += 1;
|
||||
}
|
||||
}
|
||||
tx.commit().await?;
|
||||
|
||||
// Without a row to mark the skip is lost and the offer returns as `New`
|
||||
if missing > 0 {
|
||||
return Err(sqlx::Error::RowNotFound);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn resolve(
|
||||
pool: &SqlitePool,
|
||||
cluster_id: i64,
|
||||
package_ids: &[String],
|
||||
) -> Result<(), sqlx::Error> {
|
||||
if package_ids.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut tx = pool.begin().await?;
|
||||
for package_id in package_ids {
|
||||
sqlx::query!(
|
||||
r#"
|
||||
DELETE FROM cluster_optional_mods
|
||||
WHERE cluster_id = ? AND package_id = ?
|
||||
"#,
|
||||
cluster_id,
|
||||
package_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
tx.commit().await
|
||||
}
|
||||
|
||||
pub async fn retain(
|
||||
pool: &SqlitePool,
|
||||
cluster_id: i64,
|
||||
keep: &[String],
|
||||
) -> Result<(), sqlx::Error> {
|
||||
let stale: Vec<String> = list_pending(pool, cluster_id)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|row| row.status() != OptionalModStatus::Skipped)
|
||||
.map(|row| row.package_id)
|
||||
.filter(|package_id| !keep.contains(package_id))
|
||||
.collect();
|
||||
|
||||
resolve(pool, cluster_id, &stale).await
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ pub mod browser_package_update;
|
|||
pub mod bundle;
|
||||
pub mod cluster;
|
||||
pub mod cluster_bundle;
|
||||
pub mod cluster_optional_mod;
|
||||
pub mod game_session;
|
||||
pub mod java;
|
||||
pub mod package_metadata;
|
||||
|
|
|
|||
39
packages/oneclient_db/src/models/cluster_optional_mod.rs
Normal file
39
packages/oneclient_db/src/models/cluster_optional_mod.rs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
use sqlx::FromRow;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[repr(i64)]
|
||||
pub enum OptionalModStatus {
|
||||
New = 0,
|
||||
Skipped = 1,
|
||||
}
|
||||
|
||||
impl OptionalModStatus {
|
||||
pub fn as_i64(self) -> i64 {
|
||||
self as i64
|
||||
}
|
||||
|
||||
pub fn from_repr(value: i64) -> Option<Self> {
|
||||
match value {
|
||||
0 => Some(Self::New),
|
||||
1 => Some(Self::Skipped),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, FromRow)]
|
||||
pub struct ClusterOptionalModRow {
|
||||
pub id: i64,
|
||||
pub cluster_id: i64,
|
||||
pub bundle_name: String,
|
||||
pub package_id: String,
|
||||
pub bundle_version_id: String,
|
||||
pub seen_status: i64,
|
||||
pub queued_at: String,
|
||||
}
|
||||
|
||||
impl ClusterOptionalModRow {
|
||||
pub fn status(&self) -> OptionalModStatus {
|
||||
OptionalModStatus::from_repr(self.seen_status).unwrap_or(OptionalModStatus::New)
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ mod browser_package_update;
|
|||
mod bundle;
|
||||
mod cluster;
|
||||
mod cluster_bundle;
|
||||
mod cluster_optional_mod;
|
||||
mod game_session;
|
||||
mod java;
|
||||
mod package_metadata;
|
||||
|
|
@ -16,6 +17,7 @@ pub use cluster::{ClusterId, ClusterPatch, ClusterRow, NewCluster};
|
|||
pub use cluster_bundle::{
|
||||
BundleTrackedArtifactRow, ClusterBundleOverrideRow, OverrideType,
|
||||
};
|
||||
pub use cluster_optional_mod::{ClusterOptionalModRow, OptionalModStatus};
|
||||
pub use game_session::{
|
||||
GameSessionId, GameSessionRow, GameSessionServerRow, NewGameSession, ServerJoinCount,
|
||||
SessionSpan, UnfinishedSession,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue