feat(ui): check browser package updates on game launch instead
This commit is contained in:
parent
5580093408
commit
6e25ea2bfd
6 changed files with 427 additions and 181 deletions
|
|
@ -482,9 +482,6 @@ fn run_action(dispatch: &crate::Actions, kind: &NotificationActionKind) {
|
|||
NotificationActionKind::OpenClusterUpdate(summaries) => {
|
||||
dispatch.open_cluster_update(summaries.clone());
|
||||
}
|
||||
NotificationActionKind::OpenPackageUpdates(groups) => {
|
||||
dispatch.open_package_updates(groups.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -406,9 +406,6 @@ fn run_action(dispatch: &crate::Actions, kind: &NotificationActionKind) {
|
|||
NotificationActionKind::OpenClusterUpdate(summaries) => {
|
||||
dispatch.open_cluster_update(summaries.clone());
|
||||
}
|
||||
NotificationActionKind::OpenPackageUpdates(groups) => {
|
||||
dispatch.open_package_updates(groups.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
//! app-scoped, not component-scoped.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use freya::prelude::spawn_forever;
|
||||
|
|
@ -38,6 +39,49 @@ use crate::state::{AppChannel, AppState, AsyncStatus};
|
|||
/// after a failed launch beats one that lets a second game start.
|
||||
const LAUNCH_HOLD: Duration = Duration::from_secs(2);
|
||||
|
||||
/// How long the pre-launch update check may take before the launch goes ahead
|
||||
/// without it.
|
||||
///
|
||||
/// Mirrors `oneclient_net`'s reachability probe rather than reqwest's own
|
||||
/// per-request timeouts, which are minutes long and sized for downloads. This
|
||||
/// one sits between the user pressing Play and the game starting.
|
||||
const UPDATE_CHECK_BUDGET: Duration = Duration::from_secs(8);
|
||||
|
||||
/// What a launch should do about the updates its check turned up.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum LaunchUpdatePlan {
|
||||
/// Launch straight away. The cache has still been written.
|
||||
Nothing,
|
||||
Apply(Vec<oneclient_core::BrowserPackageUpdate>),
|
||||
Prompt(Vec<oneclient_core::BrowserPackageUpdate>),
|
||||
}
|
||||
|
||||
/// Decides what a launch does with its pending updates.
|
||||
///
|
||||
/// Pure, and deliberately separate from the work it describes: the mode is the
|
||||
/// one piece of this flow with three-way behaviour worth pinning down in tests,
|
||||
/// and none of it needs a launcher, a database or a provider to be true.
|
||||
///
|
||||
/// `pending` has already had declined versions filtered out by
|
||||
/// `BrowserUpdateCheck::pending`, so nothing here re-asks a question the user
|
||||
/// has answered.
|
||||
fn plan_launch_updates(
|
||||
mode: PackageUpdateMode,
|
||||
pending: Vec<oneclient_core::BrowserPackageUpdate>,
|
||||
) -> LaunchUpdatePlan {
|
||||
if pending.is_empty() {
|
||||
return LaunchUpdatePlan::Nothing;
|
||||
}
|
||||
|
||||
match mode {
|
||||
// The check ran and the cache was written before this point; skipping
|
||||
// is only ever about what the user is shown.
|
||||
PackageUpdateMode::Skip => LaunchUpdatePlan::Nothing,
|
||||
PackageUpdateMode::Automatic => LaunchUpdatePlan::Apply(pending),
|
||||
PackageUpdateMode::Prompt => LaunchUpdatePlan::Prompt(pending),
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks the event pump to do something it alone owns.
|
||||
///
|
||||
/// The pump holds the toast timers, so an action that adds or removes a toast
|
||||
|
|
@ -520,13 +564,6 @@ impl Actions {
|
|||
self.with_engine(|state| state.notifications.close_cluster_update());
|
||||
}
|
||||
|
||||
pub fn open_package_updates(&self, groups: Vec<PackageUpdateGroup>) {
|
||||
self.with_engine(|state| {
|
||||
state.notifications.open_package_updates(groups);
|
||||
state.center_open = false;
|
||||
});
|
||||
}
|
||||
|
||||
pub fn close_package_updates(&self) {
|
||||
self.with_engine(|state| state.notifications.close_package_updates());
|
||||
}
|
||||
|
|
@ -598,14 +635,17 @@ impl Actions {
|
|||
|
||||
// --- game --------------------------------------------------------------
|
||||
|
||||
/// Starts a launch, at most one per cluster.
|
||||
/// The Play button. Starts a launch, at most one per cluster.
|
||||
///
|
||||
/// The pending flag is raised synchronously, before the first `await`, so
|
||||
/// the button is already disabled by the time a second click of a
|
||||
/// double-click could be dispatched; waiting for core to report `Checking`
|
||||
/// left a few hundred ms where every click spawned its own game. The claim
|
||||
/// itself is the guard, so a click that bypasses the disabled attribute
|
||||
/// (keyboard activation, replayed events) is dropped here too.
|
||||
/// (keyboard activation, replayed events) is dropped here too. It also
|
||||
/// covers the pre-launch work in [`launch`], which the core does not own —
|
||||
/// otherwise the update modal could sit open above a Play button still
|
||||
/// inviting a second press.
|
||||
pub fn launch_cluster(&self, cluster_id: ClusterId) {
|
||||
let claimed = self
|
||||
.station
|
||||
|
|
@ -618,9 +658,10 @@ impl Actions {
|
|||
}
|
||||
|
||||
let station = self.station;
|
||||
let actions = self.clone();
|
||||
spawn_forever(async move {
|
||||
let started = Instant::now();
|
||||
launch(cluster_id).await;
|
||||
launch(&actions, cluster_id).await;
|
||||
|
||||
// A launch that fails instantly would hand the button back inside
|
||||
// the same click burst, so hold it for the floor either way.
|
||||
|
|
@ -893,131 +934,180 @@ impl Actions {
|
|||
app.notifications
|
||||
.finish_grouped_as_actions(&mut app.inbox, session_id, spec);
|
||||
});
|
||||
|
||||
// Last, so browser packages are judged against a cluster the bundle
|
||||
// sync has finished settling: a package a bundle has just taken over
|
||||
// must not also be offered as a browser update.
|
||||
actions.check_package_updates();
|
||||
});
|
||||
}
|
||||
|
||||
// --- browser package updates -------------------------------------------
|
||||
|
||||
/// Checks every cluster's browser-installed packages for newer versions and
|
||||
/// acts on each cluster's own `browser_update_mode`.
|
||||
/// Brings the launching cluster's browser-installed packages up to date,
|
||||
/// or asks about them, before the game starts.
|
||||
///
|
||||
/// The check runs and the cache is written whatever the setting says — that
|
||||
/// is what keeps the "out of date" markers in the package manager honest for
|
||||
/// users who never want the modal. The setting only decides what happens
|
||||
/// next.
|
||||
/// The check runs and the cache is written whatever the mode says — that is
|
||||
/// what keeps the "Update available" markers in the package manager honest
|
||||
/// for users who never want the modal. The mode only decides what the user
|
||||
/// sees and whether anything is installed.
|
||||
///
|
||||
/// Offline is not a failure: the check leaves the previous cache in place,
|
||||
/// finds nothing new, and this returns without saying anything.
|
||||
pub fn check_package_updates(&self) {
|
||||
let actions = self.clone();
|
||||
spawn_forever(async move {
|
||||
let Ok(state) = launcher::state() else { return };
|
||||
let content = state.services.content();
|
||||
let global = state.settings.read().global_game_settings.clone();
|
||||
/// Returns once the launch may proceed. In `Prompt` mode that means once
|
||||
/// the user has answered every row or dismissed the modal; the applies
|
||||
/// themselves complete before their rows disappear, so nothing is still
|
||||
/// downloading into the cluster when the game starts.
|
||||
///
|
||||
/// Never fails the launch. An unreachable provider, a check that runs long,
|
||||
/// a failed install: all of them log and let the game start on what is
|
||||
/// already installed.
|
||||
async fn resolve_package_updates_before_launch(
|
||||
&self,
|
||||
state: &Arc<oneclient_core::LauncherState>,
|
||||
cluster_id: ClusterId,
|
||||
) {
|
||||
let content = state.services.content();
|
||||
|
||||
let clusters = match oneclient_db::dao::cluster::list_all(&state.services.db).await {
|
||||
Ok(clusters) => clusters,
|
||||
Err(err) => {
|
||||
tracing::error!("package update check skipped, cannot list clusters: {err:#}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
// Bounded because this sits between the user pressing Play and the game
|
||||
// starting. A network that hangs rather than refuses would otherwise
|
||||
// hold the launch for reqwest's own much longer timeouts, so the check
|
||||
// gets the same budget the reachability probe uses and the launch
|
||||
// continues on whatever is already installed.
|
||||
let check = match tokio::time::timeout(
|
||||
UPDATE_CHECK_BUDGET,
|
||||
oneclient_core::refresh_browser_package_updates(cluster_id, &content),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(check)) => check,
|
||||
Ok(Err(err)) => {
|
||||
tracing::warn!(
|
||||
cluster_id,
|
||||
error = %err,
|
||||
"browser package update check failed, launching anyway"
|
||||
);
|
||||
return;
|
||||
}
|
||||
Err(_elapsed) => {
|
||||
tracing::debug!(
|
||||
cluster_id,
|
||||
"browser package update check exceeded its launch budget"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut groups: Vec<PackageUpdateGroup> = Vec::new();
|
||||
let mut applied = 0usize;
|
||||
let pending = check.pending();
|
||||
let mode = self.cluster_update_mode(state, cluster_id).await;
|
||||
|
||||
for cluster in clusters {
|
||||
let check = match oneclient_core::refresh_browser_package_updates(
|
||||
cluster.id,
|
||||
&content,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(check) => check,
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
cluster_id = cluster.id,
|
||||
error = %err,
|
||||
"browser package update check failed"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let pending = check.pending();
|
||||
if pending.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mode = oneclient_cluster::profiles::resolve_cluster_profile(
|
||||
&state.services.db,
|
||||
&global,
|
||||
cluster.setting_profile_name.as_deref(),
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|profile| profile.browser_update_mode)
|
||||
.unwrap_or_default();
|
||||
|
||||
match mode {
|
||||
PackageUpdateMode::Skip => {}
|
||||
PackageUpdateMode::Automatic => {
|
||||
for update in &pending {
|
||||
match oneclient_core::apply_browser_package_update(
|
||||
update, None, &content,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => applied += 1,
|
||||
Err(err) => tracing::warn!(
|
||||
package = %update.display_name,
|
||||
error = %err,
|
||||
"automatic package update failed"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
PackageUpdateMode::Prompt => {
|
||||
if let Some(group) = crate::install::package_update_group(
|
||||
cluster.id,
|
||||
&pending,
|
||||
&state.services,
|
||||
)
|
||||
match plan_launch_updates(mode, pending) {
|
||||
LaunchUpdatePlan::Nothing => {}
|
||||
LaunchUpdatePlan::Apply(updates) => {
|
||||
self.apply_updates_for_launch(state, &updates).await;
|
||||
}
|
||||
LaunchUpdatePlan::Prompt(updates) => {
|
||||
let Some(group) =
|
||||
crate::install::package_update_group(cluster_id, &updates, &state.services)
|
||||
.await
|
||||
{
|
||||
groups.push(group);
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
return;
|
||||
};
|
||||
self.prompt_package_updates(group).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if applied > 0 {
|
||||
super::invalidate_cluster_queries().await;
|
||||
state
|
||||
.services
|
||||
.events
|
||||
.notify("Packages updated")
|
||||
.body(format!(
|
||||
"{applied} package{} updated automatically",
|
||||
if applied == 1 { "" } else { "s" }
|
||||
))
|
||||
.send();
|
||||
}
|
||||
/// The launching cluster's effective mode, with the usual profile-over-
|
||||
/// global inheritance. Anything unreadable falls back to the default rather
|
||||
/// than blocking the launch on a settings lookup.
|
||||
async fn cluster_update_mode(
|
||||
&self,
|
||||
state: &Arc<oneclient_core::LauncherState>,
|
||||
cluster_id: ClusterId,
|
||||
) -> PackageUpdateMode {
|
||||
let global = state.settings.read().global_game_settings.clone();
|
||||
|
||||
if !groups.is_empty() {
|
||||
// The notification carries the same list, so dismissing the
|
||||
// modal without answering leaves a way back to it.
|
||||
if let Some(spec) = crate::install::package_updates_notification(&groups) {
|
||||
actions.push_notification(spec);
|
||||
}
|
||||
actions.open_package_updates(groups);
|
||||
let Ok(cluster) = state.clusters.get(cluster_id).await else {
|
||||
return PackageUpdateMode::default();
|
||||
};
|
||||
|
||||
state
|
||||
.clusters
|
||||
.resolve_settings(&global, &cluster)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|profile| profile.browser_update_mode)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Installs every pending update, in one grouped progress notification so
|
||||
/// the pre-launch pause has something to show for itself.
|
||||
async fn apply_updates_for_launch(
|
||||
&self,
|
||||
state: &Arc<oneclient_core::LauncherState>,
|
||||
updates: &[oneclient_core::BrowserPackageUpdate],
|
||||
) {
|
||||
let content = state.services.content();
|
||||
let session = oneclient_events::GroupedProgressSession::start(
|
||||
&state.services.events,
|
||||
"Updating packages",
|
||||
);
|
||||
session.expect(
|
||||
oneclient_events::TaskCategory::Packages,
|
||||
updates.len() as u64,
|
||||
updates.len() as u64,
|
||||
);
|
||||
|
||||
let mut applied = 0usize;
|
||||
for update in updates {
|
||||
let child = session.child(
|
||||
update.display_name.clone(),
|
||||
1,
|
||||
oneclient_events::TaskCategory::Packages,
|
||||
);
|
||||
match oneclient_core::apply_browser_package_update(update, Some(&child), &content).await
|
||||
{
|
||||
Ok(_) => applied += 1,
|
||||
// One package failing is not a reason to hold the game back;
|
||||
// the cache row survives, so the next launch offers it again.
|
||||
Err(err) => tracing::warn!(
|
||||
package = %update.display_name,
|
||||
error = %err,
|
||||
"automatic package update failed"
|
||||
),
|
||||
}
|
||||
child.finish();
|
||||
}
|
||||
|
||||
let session_id = session.detach();
|
||||
let spec = (applied > 0).then(|| NotificationSpec {
|
||||
title: "Packages updated".to_string(),
|
||||
body: format!(
|
||||
"{applied} package{} updated before launch",
|
||||
if applied == 1 { "" } else { "s" }
|
||||
),
|
||||
level: Level::Info,
|
||||
icon: Some(IconType::DownloadCloud02),
|
||||
progress: None,
|
||||
actions: Vec::new(),
|
||||
});
|
||||
|
||||
self.with_engine(|app| {
|
||||
app.notifications
|
||||
.finish_grouped_as_actions(&mut app.inbox, session_id, spec);
|
||||
});
|
||||
|
||||
if applied > 0 {
|
||||
super::invalidate_cluster_queries().await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the modal and waits for the user to be done with it.
|
||||
async fn prompt_package_updates(&self, group: PackageUpdateGroup) {
|
||||
let (done, wait) = tokio::sync::oneshot::channel();
|
||||
|
||||
self.with_engine(move |state| {
|
||||
state.notifications.open_package_updates(vec![group], Some(done));
|
||||
state.center_open = false;
|
||||
});
|
||||
|
||||
// An error means the modal state was replaced or torn down, which is
|
||||
// equally a reason to stop waiting and get on with the launch.
|
||||
let _ = wait.await;
|
||||
}
|
||||
|
||||
/// Applies one update from the modal, then drops that row from it.
|
||||
|
|
@ -1113,10 +1203,16 @@ impl Actions {
|
|||
|
||||
/// The launch itself. Failures are reported as game events, so the caller only
|
||||
/// has to know that the attempt is over.
|
||||
async fn launch(cluster_id: ClusterId) {
|
||||
///
|
||||
/// Everything before [`oneclient_core::launch_cluster`] is pre-launch work the
|
||||
/// core does not own: renewing the account token, and bringing
|
||||
/// browser-installed packages up to date.
|
||||
async fn launch(actions: &Actions, cluster_id: ClusterId) {
|
||||
let Ok(state) = launcher::state() else { return };
|
||||
let events = state.services.events.clone();
|
||||
|
||||
events.game_stage(cluster_id, oneclient_events::LaunchStage::Checking);
|
||||
|
||||
// Renews a lapsed token before launching.
|
||||
let account = match state.auth.default_account_for_launch().await {
|
||||
Ok(account) => account,
|
||||
|
|
@ -1126,18 +1222,23 @@ async fn launch(cluster_id: ClusterId) {
|
|||
}
|
||||
};
|
||||
|
||||
match account {
|
||||
Some(account) => {
|
||||
if let Err(err) =
|
||||
oneclient_core::launch_cluster(&state, cluster_id, &account, true).await
|
||||
{
|
||||
events.game_failed(cluster_id, format!("{err:#}"));
|
||||
}
|
||||
}
|
||||
None => events.game_failed(
|
||||
let Some(account) = account else {
|
||||
events.game_failed(
|
||||
cluster_id,
|
||||
"Add a Minecraft account before launching.".to_string(),
|
||||
),
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
// Before the game process, never after: Minecraft reads its mods once at
|
||||
// startup, so an update applied to a live session would do nothing until
|
||||
// the next launch anyway.
|
||||
actions
|
||||
.resolve_package_updates_before_launch(&state, cluster_id)
|
||||
.await;
|
||||
|
||||
if let Err(err) = oneclient_core::launch_cluster(&state, cluster_id, &account, true).await {
|
||||
events.game_failed(cluster_id, format!("{err:#}"));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1190,3 +1291,59 @@ impl NotificationBuilder {
|
|||
self.actions.push_notification(self.spec);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use oneclient_common::domain::ProviderId;
|
||||
|
||||
fn update(hash: &str) -> oneclient_core::BrowserPackageUpdate {
|
||||
oneclient_core::BrowserPackageUpdate {
|
||||
cluster_id: 1,
|
||||
hash: hash.into(),
|
||||
provider: ProviderId::Modrinth,
|
||||
project_id: "sodium".into(),
|
||||
installed_version_id: "v1".into(),
|
||||
installed_version_name: "1.0".into(),
|
||||
latest_version_id: "v2".into(),
|
||||
latest_version_name: "2.0".into(),
|
||||
display_name: "Sodium".into(),
|
||||
skipped: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skip_mode_launches_without_asking_or_installing() {
|
||||
assert_eq!(
|
||||
plan_launch_updates(PackageUpdateMode::Skip, vec![update("a")]),
|
||||
LaunchUpdatePlan::Nothing,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_mode_installs_without_asking() {
|
||||
assert_eq!(
|
||||
plan_launch_updates(PackageUpdateMode::Automatic, vec![update("a")]),
|
||||
LaunchUpdatePlan::Apply(vec![update("a")]),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_mode_is_the_default_and_asks() {
|
||||
assert_eq!(
|
||||
plan_launch_updates(PackageUpdateMode::default(), vec![update("a")]),
|
||||
LaunchUpdatePlan::Prompt(vec![update("a")]),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_pending_never_opens_a_modal() {
|
||||
for mode in PackageUpdateMode::ALL.iter().copied() {
|
||||
assert_eq!(
|
||||
plan_launch_updates(mode, Vec::new()),
|
||||
LaunchUpdatePlan::Nothing,
|
||||
"{mode:?} must not hold a launch up over an empty list",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,8 +28,8 @@ pub async fn cluster_display_name(
|
|||
.unwrap_or_else(|_| "Cluster".to_string())
|
||||
}
|
||||
|
||||
/// Turns one cluster's pending browser updates into what the modal renders.
|
||||
/// `None` when the cluster has nothing to offer.
|
||||
/// Turns the launching cluster's pending browser updates into what the modal
|
||||
/// renders. `None` when there is nothing to offer.
|
||||
pub async fn package_update_group(
|
||||
cluster_id: i64,
|
||||
updates: &[oneclient_core::BrowserPackageUpdate],
|
||||
|
|
@ -46,41 +46,6 @@ pub async fn package_update_group(
|
|||
})
|
||||
}
|
||||
|
||||
/// One notification for a whole launch-time check, with a single action that
|
||||
/// reopens the modal. Mirrors [`combined_cluster_update_spec`]: however many
|
||||
/// clusters are stale, the user sees one card with one button.
|
||||
pub fn package_updates_notification(groups: &[PackageUpdateGroup]) -> Option<NotificationSpec> {
|
||||
if groups.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let total: usize = groups.iter().map(|group| group.packages.len()).sum();
|
||||
let body = match groups {
|
||||
[only] => format!(
|
||||
"{total} package{} in {} can be updated",
|
||||
if total == 1 { "" } else { "s" },
|
||||
only.cluster_name
|
||||
),
|
||||
_ => format!(
|
||||
"{total} package{} across {} clusters can be updated",
|
||||
if total == 1 { "" } else { "s" },
|
||||
groups.len()
|
||||
),
|
||||
};
|
||||
|
||||
Some(NotificationSpec {
|
||||
title: "Updates available".to_string(),
|
||||
body,
|
||||
level: Level::Info,
|
||||
icon: Some(IconType::DownloadCloud02),
|
||||
progress: None,
|
||||
actions: vec![NotificationAction {
|
||||
label: "Review".to_string(),
|
||||
kind: NotificationActionKind::OpenPackageUpdates(groups.to_vec()),
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
pub fn item_from_bundle_file(file: &oneclient_core::BundleFile) -> ClusterUpdateItem {
|
||||
match &file.kind {
|
||||
oneclient_core::BundleFileKind::Managed {
|
||||
|
|
|
|||
|
|
@ -27,13 +27,9 @@ pub enum NotificationActionKind {
|
|||
/// list so a batch sync stays a single "View changes" action instead of one
|
||||
/// button per cluster.
|
||||
OpenClusterUpdate(Vec<ClusterUpdateSummary>),
|
||||
/// Reopens the browser-package update modal for the clusters whose
|
||||
/// launch-time check found something. Same reasoning as above: one action,
|
||||
/// however many clusters are stale.
|
||||
OpenPackageUpdates(Vec<PackageUpdateGroup>),
|
||||
}
|
||||
|
||||
/// The stale browser packages of one cluster.
|
||||
/// The stale browser packages of the cluster being launched.
|
||||
///
|
||||
/// Carries the core updates whole rather than a display projection: the modal's
|
||||
/// Update button hands one straight back to the apply path, and a trimmed copy
|
||||
|
|
@ -203,6 +199,11 @@ pub struct NotificationState {
|
|||
pending_timers: Vec<ToastDismissTimer>,
|
||||
cluster_update: Option<Vec<ClusterUpdateSummary>>,
|
||||
package_updates: Option<Vec<PackageUpdateGroup>>,
|
||||
/// Resumes the launch that opened the update modal. Held here rather than
|
||||
/// by the launch task because every way the modal can end — the last row
|
||||
/// answered, "Not now", Escape, the scrim — goes through this state, and
|
||||
/// each of them has to let the game start.
|
||||
package_updates_done: Option<oneshot::Sender<()>>,
|
||||
}
|
||||
|
||||
/// Fixed display order for the category rows.
|
||||
|
|
@ -334,19 +335,37 @@ impl NotificationState {
|
|||
self.cluster_update = None;
|
||||
}
|
||||
|
||||
pub fn open_package_updates(&mut self, groups: Vec<PackageUpdateGroup>) {
|
||||
/// Opens the pre-launch update modal, with `done` as the launch's
|
||||
/// continuation.
|
||||
///
|
||||
/// Nothing to show is answered immediately rather than left pending: a
|
||||
/// launch must never be able to wait on a modal that was never drawn.
|
||||
pub fn open_package_updates(
|
||||
&mut self,
|
||||
groups: Vec<PackageUpdateGroup>,
|
||||
done: Option<oneshot::Sender<()>>,
|
||||
) {
|
||||
let groups: Vec<PackageUpdateGroup> = groups
|
||||
.into_iter()
|
||||
.filter(|group| !group.packages.is_empty())
|
||||
.collect();
|
||||
|
||||
if groups.is_empty() {
|
||||
if let Some(done) = done {
|
||||
let _ = done.send(());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// A second modal would strand the first launch, so hand the old
|
||||
// continuation back before taking the new one.
|
||||
self.finish_package_updates();
|
||||
self.package_updates = Some(groups);
|
||||
self.package_updates_done = done;
|
||||
}
|
||||
|
||||
pub fn close_package_updates(&mut self) {
|
||||
self.package_updates = None;
|
||||
self.finish_package_updates();
|
||||
}
|
||||
|
||||
/// Drops one package from the open modal, closing it once the user has
|
||||
|
|
@ -366,7 +385,19 @@ impl NotificationState {
|
|||
groups.retain(|group| !group.packages.is_empty());
|
||||
|
||||
if groups.is_empty() {
|
||||
self.package_updates = None;
|
||||
self.finish_package_updates();
|
||||
}
|
||||
}
|
||||
|
||||
/// Closes the modal and releases the launch waiting on it. Safe to call
|
||||
/// when nothing is open; that is what makes every exit path able to call it
|
||||
/// without first checking which one it is.
|
||||
fn finish_package_updates(&mut self) {
|
||||
self.package_updates = None;
|
||||
if let Some(done) = self.package_updates_done.take() {
|
||||
// The launch task is gone if this fails, which is not this layer's
|
||||
// problem: there is simply nothing left to resume.
|
||||
let _ = done.send(());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -911,3 +942,100 @@ fn progress_body(label: &str, current: u64, total: u64) -> String {
|
|||
let percent = ((current as f64 / total as f64) * 100.0).round() as u64;
|
||||
format!("{label} - {percent}%")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod package_update_tests {
|
||||
use super::*;
|
||||
use oneclient_common::domain::ProviderId;
|
||||
|
||||
fn update(hash: &str) -> BrowserPackageUpdate {
|
||||
BrowserPackageUpdate {
|
||||
cluster_id: 1,
|
||||
hash: hash.into(),
|
||||
provider: ProviderId::Modrinth,
|
||||
project_id: "sodium".into(),
|
||||
installed_version_id: "v1".into(),
|
||||
installed_version_name: "1.0".into(),
|
||||
latest_version_id: "v2".into(),
|
||||
latest_version_name: "2.0".into(),
|
||||
display_name: "Sodium".into(),
|
||||
skipped: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn group(hashes: &[&str]) -> PackageUpdateGroup {
|
||||
PackageUpdateGroup {
|
||||
cluster_id: 1,
|
||||
cluster_name: "Test".into(),
|
||||
packages: hashes.iter().map(|hash| update(hash)).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_launch_resumes_once_every_row_is_answered() {
|
||||
let mut state = NotificationState::default();
|
||||
let (done, mut wait) = oneshot::channel();
|
||||
state.open_package_updates(vec![group(&["a", "b"])], Some(done));
|
||||
|
||||
state.resolve_package_update(1, "a");
|
||||
assert!(
|
||||
wait.try_recv().is_err(),
|
||||
"a launch must wait while rows are still unanswered"
|
||||
);
|
||||
|
||||
state.resolve_package_update(1, "b");
|
||||
assert!(wait.try_recv().is_ok(), "the last answer releases the launch");
|
||||
assert!(state.package_updates.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dismissing_the_modal_still_releases_the_launch() {
|
||||
let mut state = NotificationState::default();
|
||||
let (done, mut wait) = oneshot::channel();
|
||||
state.open_package_updates(vec![group(&["a"])], Some(done));
|
||||
|
||||
state.close_package_updates();
|
||||
|
||||
assert!(
|
||||
wait.try_recv().is_ok(),
|
||||
"\"Not now\" declines the updates, not the launch"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_to_show_releases_the_launch_immediately() {
|
||||
let mut state = NotificationState::default();
|
||||
let (done, mut wait) = oneshot::channel();
|
||||
|
||||
state.open_package_updates(vec![group(&[])], Some(done));
|
||||
|
||||
assert!(wait.try_recv().is_ok(), "a modal that never opens cannot be answered");
|
||||
assert!(state.package_updates.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_second_modal_does_not_strand_the_first_launch() {
|
||||
let mut state = NotificationState::default();
|
||||
let (first, mut first_wait) = oneshot::channel();
|
||||
let (second, mut second_wait) = oneshot::channel();
|
||||
|
||||
state.open_package_updates(vec![group(&["a"])], Some(first));
|
||||
state.open_package_updates(vec![group(&["b"])], Some(second));
|
||||
|
||||
assert!(first_wait.try_recv().is_ok(), "the replaced launch is let go");
|
||||
assert!(second_wait.try_recv().is_err(), "the new one still waits");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolving_an_unknown_package_is_a_no_op() {
|
||||
let mut state = NotificationState::default();
|
||||
let (done, mut wait) = oneshot::channel();
|
||||
state.open_package_updates(vec![group(&["a"])], Some(done));
|
||||
|
||||
state.resolve_package_update(1, "not-here");
|
||||
state.resolve_package_update(99, "a");
|
||||
|
||||
assert!(wait.try_recv().is_err());
|
||||
assert!(state.package_updates.is_some());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -501,6 +501,7 @@ mod tests {
|
|||
bundles,
|
||||
&HashMap::new(),
|
||||
&PackageMetaMap::new(),
|
||||
&HashSet::new(),
|
||||
ContentType::Mod,
|
||||
)
|
||||
}
|
||||
|
|
@ -556,6 +557,7 @@ mod tests {
|
|||
manifest_default: true,
|
||||
installed: true,
|
||||
hash: None,
|
||||
update_available: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue