use std::sync::Arc; use oneclient_core::notification::{Notification, NotificationLevel, NotificationService}; use oneclient_core::packages::PackageStore; use oneclient_core::settings::store::{ self, create_profile_from_global, create_settings_profile, delete_named_profile, save_global_profile, save_settings, update_named_profile, upsert_named_profile, }; use oneclient_core::{ClusterManager, LauncherState}; use tokio::sync::{mpsc, watch}; use crate::components::IconType; use crate::notifications::{ ClusterUpdateItem, ClusterUpdateSummary, InboxEntry, MESSAGE_TOAST_TTL, NotificationAction, NotificationActionKind, NotificationSpec, NotificationState, PendingPrompt, PendingPromptView, }; use super::commands::BridgeCommand; use super::snapshot::{AsyncStatus, BridgeSnapshot, LauncherInit}; use oneclient_core::notification::LaunchStage; pub struct CoreBridgeRuntime { pub snapshots_tx: watch::Sender, pub commands_rx: mpsc::UnboundedReceiver, pub commands_tx: mpsc::UnboundedSender, } impl CoreBridgeRuntime { pub async fn run(mut self) { let mut snapshots = BridgeSnapshot::default(); let mut notification_engine = NotificationState::default(); let mut inbox: Vec = Vec::new(); let mut notification_center_open = false; let mut pending_prompt: Option = None; let mut armed_toasts: std::collections::HashMap = std::collections::HashMap::new(); // Hovering any toast pauses every toast's timer, including ones that arrive while hovering. let mut toasts_paused = false; let mut game_flush_deadline: Option = None; const GAME_LOG_FLUSH: std::time::Duration = std::time::Duration::from_millis(120); let (notifier_tx, mut notifier_rx) = mpsc::unbounded_channel(); if let Err(err) = Self::initialize_launcher(&mut snapshots, notifier_tx).await { let message = err.to_string(); snapshots.launcher.error = Some(message.clone()); notification_engine.dispatch( &mut inbox, Notification::Message { title: "Launcher failed to start".into(), body: message, level: NotificationLevel::Error, }, ); sync_notifications( &mut snapshots, ¬ification_engine, &inbox, notification_center_open, &pending_prompt, ); publish(&self.snapshots_tx, &snapshots); tracing::error!("launcher init failed: {err:#}"); } else { sync_notifications( &mut snapshots, ¬ification_engine, &inbox, notification_center_open, &pending_prompt, ); publish(&self.snapshots_tx, &snapshots); if let Err(err) = self.commands_tx.send(BridgeCommand::SyncBundles) { tracing::error!("failed to queue startup bundle sync: {err}"); } } loop { let next_deadline = armed_toasts .values() .filter_map(ToastTimer::deadline) .min() .into_iter() .chain(game_flush_deadline) .min(); let timer = async { match next_deadline { Some(deadline) => tokio::time::sleep_until(deadline).await, None => std::future::pending::<()>().await, } }; tokio::select! { _ = timer => { let now = tokio::time::Instant::now(); let due: Vec = armed_toasts .iter() .filter(|(_, timer)| timer.deadline().is_some_and(|deadline| deadline <= now)) .map(|(id, _)| *id) .collect(); let toasts_expired = !due.is_empty(); for id in due { notification_engine.expire_toast(&inbox, id); armed_toasts.remove(&id); } if game_flush_deadline.is_some_and(|d| d <= now) { game_flush_deadline = None; } if toasts_expired { sync_notifications( &mut snapshots, ¬ification_engine, &inbox, notification_center_open, &pending_prompt, ); } publish(&self.snapshots_tx, &snapshots); if toasts_expired { reconcile_toasts(&snapshots, &inbox, &mut armed_toasts, toasts_paused); } } notification = notifier_rx.recv() => { let Some(first) = notification else { break }; let mut touched_engine = false; let mut immediate = false; let mut saw_game_log = false; let mut process = |notification, snapshots: &mut BridgeSnapshot, engine: &mut NotificationState, inbox: &mut Vec, pending: &mut Option| { match notification { Notification::InvalidateClusters => { bump_clusters_generation(snapshots); immediate = true; } Notification::InvalidateJava => { bump_java_generation(snapshots); immediate = true; } Notification::SyncComplete => { snapshots.launcher.fetching = false; bump_clusters_generation(snapshots); immediate = true; } Notification::GameStage { cluster_id, stage } => { apply_game_stage(snapshots, cluster_id, stage); immediate = true; } Notification::GameLog { cluster_id, line } => { apply_game_log(snapshots, cluster_id, line); saw_game_log = true; } Notification::GameFailed { cluster_id, message } => { apply_game_failed(snapshots, cluster_id, message); immediate = true; } Notification::MicrosoftLoginStatus(status) => { snapshots.microsoft_login = status; immediate = true; } other => { let (_timers, prompt) = engine.dispatch(inbox, other); if let Some(prompt) = prompt { *pending = Some(prompt); } touched_engine = true; } } }; process(first, &mut snapshots, &mut notification_engine, &mut inbox, &mut pending_prompt); let mut budget = 1024; while budget > 0 { match notifier_rx.try_recv() { Ok(next) => { process(next, &mut snapshots, &mut notification_engine, &mut inbox, &mut pending_prompt); budget -= 1; } Err(_) => break, } } if touched_engine { sync_notifications( &mut snapshots, ¬ification_engine, &inbox, notification_center_open, &pending_prompt, ); } if touched_engine || immediate { publish(&self.snapshots_tx, &snapshots); game_flush_deadline = None; if touched_engine { reconcile_toasts(&snapshots, &inbox, &mut armed_toasts, toasts_paused); } } else if saw_game_log && game_flush_deadline.is_none() { game_flush_deadline = Some(tokio::time::Instant::now() + GAME_LOG_FLUSH); } } command = self.commands_rx.recv() => { let Some(command) = command else { break }; match command { BridgeCommand::PauseToasts => { toasts_paused = true; for timer in armed_toasts.values_mut() { timer.pause(); } continue; } BridgeCommand::ResumeToasts => { toasts_paused = false; for timer in armed_toasts.values_mut() { timer.resume(); } continue; } other => { if let Err(err) = Self::dispatch( other, &mut snapshots, &mut notification_engine, &mut inbox, &mut notification_center_open, &mut pending_prompt, &self.commands_tx, ) .await { tracing::error!("command failed: {err:#}"); } } } sync_notifications( &mut snapshots, ¬ification_engine, &inbox, notification_center_open, &pending_prompt, ); publish(&self.snapshots_tx, &snapshots); reconcile_toasts(&snapshots, &inbox, &mut armed_toasts, toasts_paused); } } } } async fn initialize_launcher( snapshots: &mut BridgeSnapshot, notifier_tx: mpsc::UnboundedSender, ) -> anyhow::Result<()> { let notifier = NotificationService::new(notifier_tx); LauncherState::initialize(notifier).await?; oneclient_core::status::start(); oneclient_core::plus::start(); let data_dir = oneclient_core::paths::launcher_dir() .map(|p| p.display().to_string()) .unwrap_or_default(); snapshots.launcher = LauncherInit { ready: true, fetching: true, syncing_bundles: true, error: None, data_dir, }; refresh_settings(snapshots)?; bump_profiles_generation(snapshots); let auto_update = LauncherState::get()?.settings.read().auto_update; crate::updater::spawn_update_check(auto_update); Ok(()) } async fn dispatch( command: BridgeCommand, snapshots: &mut BridgeSnapshot, notification_engine: &mut NotificationState, inbox: &mut Vec, notification_center_open: &mut bool, pending_prompt: &mut Option, commands_tx: &mpsc::UnboundedSender, ) -> anyhow::Result<()> { match command { BridgeCommand::ReloadSettings => { snapshots.settings.status = AsyncStatus::Loading; snapshots.settings.error = None; let state = LauncherState::get()?; let loaded = store::load_settings(Some(&state.services.notifier)).await; let discord_enabled = loaded.discord_enabled; { let mut lock = state.settings.write(); *lock = loaded; } state.discord.set_enabled(discord_enabled); refresh_settings(snapshots)?; } BridgeCommand::SaveSettings => { let state = LauncherState::get()?; snapshots.settings.saving = true; snapshots.settings.error = None; let settings = state.settings.read().clone(); if let Err(err) = save_settings(&settings).await { snapshots.settings.error = Some(err.to_string()); } snapshots.settings.saving = false; snapshots.settings.status = AsyncStatus::Ready; } BridgeCommand::SetSettings { settings } => { mutate_settings(snapshots, |s| *s = settings)?; let state = LauncherState::get()?; let snapshot = state.settings.read().clone(); state.discord.set_enabled(snapshot.discord_enabled); if let Err(err) = save_settings(&snapshot).await { snapshots.settings.error = Some(err.to_string()); } } BridgeCommand::RecordSeenVersion { version } => { mutate_settings(snapshots, |settings| { if !settings.seen_versions.iter().any(|v| v == &version) { settings.seen_versions.push(version); } })?; } BridgeCommand::SetSeenChangelogVersion { version } => { let mut changed = false; mutate_settings(snapshots, |settings| { if settings.seen_changelog_version.as_deref() != Some(version.as_str()) { settings.seen_changelog_version = Some(version); changed = true; } })?; if changed { let state = LauncherState::get()?; let snapshot = state.settings.read().clone(); if let Err(err) = save_settings(&snapshot).await { snapshots.settings.error = Some(err.to_string()); } } } BridgeCommand::MarkOnboardingSeen => { mutate_settings(snapshots, |settings| { settings.seen_onboarding = true; })?; let state = LauncherState::get()?; let snapshot = state.settings.read().clone(); if let Err(err) = save_settings(&snapshot).await { snapshots.settings.error = Some(err.to_string()); } } BridgeCommand::AcceptTos { terms_version, privacy_version, } => { mutate_settings(snapshots, |settings| { settings.accepted_tos_version = terms_version; settings.accepted_privacy_version = privacy_version; })?; let state = LauncherState::get()?; let snapshot = state.settings.read().clone(); if let Err(err) = save_settings(&snapshot).await { snapshots.settings.error = Some(err.to_string()); } } BridgeCommand::SaveGlobalProfile { profile } => { let state = LauncherState::get()?; save_global_profile(&state.settings, profile).await?; refresh_settings(snapshots)?; bump_profiles_generation(snapshots); } BridgeCommand::UpdateGlobalProfile { update } => { let state = LauncherState::get()?; let mut global = state.settings.read().global_game_settings.clone(); update.apply(&mut global); save_global_profile(&state.settings, global).await?; refresh_settings(snapshots)?; bump_profiles_generation(snapshots); } BridgeCommand::CreateSettingsProfile { name } => { let state = LauncherState::get()?; let settings = state.settings.read().clone(); create_settings_profile(&state.services.db, &settings, &name).await?; bump_profiles_generation(snapshots); } BridgeCommand::CreateProfileFromGlobal { name, mem_max, force_fullscreen, } => { let state = LauncherState::get()?; let settings = state.settings.read().clone(); create_profile_from_global( &state.services.db, &settings, &name, mem_max, force_fullscreen, ) .await?; bump_profiles_generation(snapshots); } BridgeCommand::UpsertNamedProfile { profile } => { let state = LauncherState::get()?; upsert_named_profile(&state.services.db, &profile).await?; bump_profiles_generation(snapshots); } BridgeCommand::UpdateNamedProfile { name, update } => { let state = LauncherState::get()?; update_named_profile(&state.services.db, &name, update).await?; bump_profiles_generation(snapshots); } BridgeCommand::DeleteNamedProfile { name } => { let state = LauncherState::get()?; delete_named_profile(&state.services.db, &name).await?; bump_profiles_generation(snapshots); } BridgeCommand::UpdateClusterProfile { cluster_id, update } => { let state = LauncherState::get()?; ClusterManager::update_profile(&state, cluster_id, update).await?; bump_profiles_generation(snapshots); } BridgeCommand::CreateAndAssignClusterProfile { cluster_id, profile_name, } => { let state = LauncherState::get()?; ClusterManager::create_and_assign_profile(&state, cluster_id, &profile_name) .await?; bump_profiles_generation(snapshots); } BridgeCommand::SetClusterLoaderVersion { cluster_id, version, } => { let state = LauncherState::get()?; ClusterManager::update( &state, cluster_id, oneclient_core::ClusterUpdate::default().loader_version(version), ) .await?; let _ = ClusterManager::set_stage( &state, cluster_id, oneclient_core::ClusterStage::NotReady, ) .await; bump_clusters_generation(snapshots); } BridgeCommand::ImportLauncher { source, folder_name, target, } => { let state = LauncherState::get()?; tokio::spawn(async move { let notifier = state.services.notifier.clone(); match oneclient_core::import_migration_game_dir(source, &folder_name, target) .await { Ok(()) => { notifier.send_info( "Import complete", &format!("Copied your files from {}.", source.display_name()), ); notifier.invalidate_clusters(); } Err(err) => { notifier.send_error("Import failed", &err.to_string()); } } }); } BridgeCommand::InstallJavaRuntime { vendor, major } => { let state = LauncherState::get()?; tokio::spawn(async move { let notifier = state.services.notifier.clone(); match oneclient_core::java::JavaManager::install_runtime_from( &state.services, &vendor, major, ) .await { Ok(_) => { notifier.invalidate_java(); } Err(err) => { notifier.send_error("Java install failed", &err.to_string()); } } }); } BridgeCommand::AddCustomJavaRuntime { path } => { let state = LauncherState::get()?; tokio::spawn(async move { let notifier = state.services.notifier.clone(); match oneclient_core::java::JavaManager::add_custom_runtime( &state.services.db, path, ) .await { Ok(runtime) => { notifier.send_info( "Java added", &format!("Java {} ({})", runtime.major, runtime.vendor), ); notifier.invalidate_java(); } Err(err) => { notifier.send_error("Failed to add Java", &err.to_string()); } } }); } BridgeCommand::RemoveJavaRuntime { path } => { let state = LauncherState::get()?; oneclient_core::java::JavaManager::remove_runtime(&state.services.db, &path) .await?; bump_java_generation(snapshots); } BridgeCommand::ToggleNotificationCenter => { *notification_center_open = notification_engine.toggle_center(inbox, *notification_center_open); } BridgeCommand::CloseNotificationCenter => { *notification_center_open = false; } BridgeCommand::ToggleAccountSwitcher => { snapshots.account_switcher_open = !snapshots.account_switcher_open; } BridgeCommand::CloseAccountSwitcher => { snapshots.account_switcher_open = false; } BridgeCommand::ClearNotificationInbox => { inbox.clear(); notification_engine.clear_inbox(); } BridgeCommand::DismissToast(entry_id) => { notification_engine.dismiss_toast(inbox, entry_id); } BridgeCommand::PauseToasts | BridgeCommand::ResumeToasts => {} BridgeCommand::MarkNotificationRead(entry_id) => { notification_engine.mark_read(inbox, entry_id); } BridgeCommand::DismissNotification(entry_id) => { notification_engine.dismiss_notification(inbox, entry_id); } BridgeCommand::AnswerPrompt(choice) => { if let Some(prompt) = pending_prompt.take() && let Some(reply_tx) = prompt.reply_tx { let _ = reply_tx.send(choice); } } BridgeCommand::OpenClusterUpdate(summary) => { notification_engine.open_cluster_update(summary); *notification_center_open = false; } BridgeCommand::CloseClusterUpdate => { notification_engine.close_cluster_update(); } BridgeCommand::SendNotification { spec } => { notification_engine.push_custom(inbox, spec); } BridgeCommand::SendTestProgress { current, total } => { let state = LauncherState::get()?; let id = uuid::Uuid::from_u128(0x0CE0_0CE0_0CE0_0CE0_0CE0_0CE0_0CE0_0CE0); state .services .notifier .send_progress(&id, "Downloading assets", current, total); } BridgeCommand::LaunchCluster { cluster_id } => { let state = LauncherState::get()?; tokio::spawn(async move { let notifier = state.services.notifier.clone(); // Renews a lapsed token before launching let account = match oneclient_core::auth::default_account_for_launch().await { Ok(account) => account, Err(err) => { notifier.game_failed(cluster_id, format!("{err:#}")); return; } }; match account { Some(account) => { if let Err(err) = oneclient_core::launch_cluster(&state, cluster_id, &account, true) .await { notifier.game_failed(cluster_id, format!("{err:#}")); } } None => notifier.game_failed( cluster_id, "Add a Minecraft account before launching.".to_string(), ), } }); } BridgeCommand::KillCluster { cluster_id } => { let state = LauncherState::get()?; if !state.games.kill(cluster_id) { tracing::debug!(cluster_id, "kill requested but no tracked process"); } } BridgeCommand::DismissGameError => { snapshots.game.error = None; } BridgeCommand::ImportLocalFile { cluster_id, content_type, path, } => { let state = LauncherState::get()?; match oneclient_core::packages::PackageStore::import_local_file( &path, content_type, cluster_id, &state.services, ) .await { Ok(row) => { state .services .notifier .send_info("Imported", &format!("Added {}", row.file_name)); bump_clusters_generation(snapshots); } Err(err) => state .services .notifier .send_error("Import failed", &err.to_string()), } } BridgeCommand::InstallPackage { cluster_id, provider, project_id, version_id, } => { let state = LauncherState::get()?; tokio::spawn(async move { let notifier = state.services.notifier.clone(); match install_package(&state, provider, &project_id, &version_id, cluster_id) .await { Ok(name) => { notifier.send_info("Installed", &format!("Added {name}")); notifier.invalidate_clusters(); } Err(err) => notifier.send_error("Install failed", &err.to_string()), } }); } BridgeCommand::InstallBundle { cluster_id, bundle_name, skip_compatibility, } => { let state = LauncherState::get()?; tokio::spawn(async move { let notifier = state.services.notifier.clone(); match oneclient_core::install_bundle( cluster_id, &bundle_name, skip_compatibility, state.bundles.as_ref(), &state.services, ) .await { Ok(_) => { notifier.send_info("Installed", &format!("Added {bundle_name}")); notifier.invalidate_clusters(); } Err(err) => notifier.send_error("Install failed", &err.to_string()), } }); } BridgeCommand::ApplyBundleUpdates { cluster_id } => { let state = LauncherState::get()?; let result = oneclient_core::apply_bundle_updates( cluster_id, state.bundles.as_ref(), &state.services, ) .await?; bump_clusters_generation(snapshots); if let Some(spec) = cluster_update_notification(cluster_id, &result, &state.services).await { notification_engine.push_custom(inbox, spec); } } BridgeCommand::SyncBundles => { // Run the cluster-bundle download in the background so the main // app can load while it finishes. The launch buttons stay // disabled via `syncing_bundles` until BundleSyncComplete lands. snapshots.launcher.syncing_bundles = true; let commands_tx = commands_tx.clone(); tokio::spawn(async move { let state = match LauncherState::get() { Ok(state) => state, Err(err) => { tracing::error!("bundle sync: launcher not ready: {err:#}"); return; } }; if let Err(err) = state.bundles.sync(&state.services).await { tracing::error!("bundle catalog sync failed: {err:#}"); } if let Err(err) = oneclient_core::clusters::apply_remote_migrations(&state).await { tracing::error!("cluster migrations failed: {err:#}"); } if let Err(err) = oneclient_core::clusters::ensure_from_bundles(&state).await { tracing::error!("bundle cluster provisioning failed: {err:#}"); } // One shared grouped session for the whole batch, so every // cluster's downloads surface as a single "update in progress" // notification. `detach` hands that entry to the bridge, which // converts it to the finished "view changes" state in place. let session = oneclient_core::notification::GroupedProgressSession::start( &state.services.notifier, "Updating mods", ); let changed = oneclient_core::bundles::sync_all_cluster_bundles( state.bundles.as_ref(), &state.services, Some(&session), ) .await; let session_id = session.detach(); let _ = commands_tx .send(BridgeCommand::BundleSyncComplete { changed, session_id }); }); } BridgeCommand::BundleSyncComplete { changed, session_id, } => { snapshots.launcher.syncing_bundles = false; bump_clusters_generation(snapshots); let state = LauncherState::get()?; let spec = combined_cluster_update_spec(&changed, &state.services).await; notification_engine.finish_grouped_as_actions(inbox, session_id, spec); } } Ok(()) } } fn item_from_bundle_file(file: &oneclient_core::BundleFile) -> ClusterUpdateItem { match &file.kind { oneclient_core::BundleFileKind::Managed { provider, project_id, .. } => ClusterUpdateItem { provider: *provider, project_id: Some(project_id.clone()), fallback: file.display_name(), }, oneclient_core::BundleFileKind::External(_) => ClusterUpdateItem { provider: oneclient_core::packages::ProviderId::Local, project_id: None, fallback: file.display_name(), }, } } async fn cluster_update_summary( cluster_id: i64, result: &oneclient_core::ApplyBundleUpdatesResult, services: &oneclient_core::LauncherServices, ) -> Option { let updated: Vec = result .updates_applied .iter() .map(|u| item_from_bundle_file(&u.new_file)) .collect(); let added: Vec = result .additions_applied .iter() .map(|a| item_from_bundle_file(&a.new_file)) .collect(); let removed: Vec = result .removals_applied .iter() .map(|r| ClusterUpdateItem { provider: r.provider.unwrap_or(oneclient_core::packages::ProviderId::Local), project_id: r.project_id.clone(), fallback: r .display_name .clone() .unwrap_or_else(|| r.package_id.clone()), }) .collect(); if updated.is_empty() && added.is_empty() && removed.is_empty() { return None; } let cluster_name = PackageStore::get_cluster(cluster_id, services) .await .map(|c| c.name) .unwrap_or_else(|_| "Cluster".to_string()); Some(ClusterUpdateSummary { cluster_id, cluster_name, updated, added, removed, }) } async fn cluster_update_notification( cluster_id: i64, result: &oneclient_core::ApplyBundleUpdatesResult, services: &oneclient_core::LauncherServices, ) -> Option { 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 ); Some(NotificationSpec { title: "Cluster updated".to_string(), body, level: NotificationLevel::Info, icon: Some(IconType::DownloadCloud02), progress: None, actions: vec![NotificationAction { label: "View changes".to_string(), kind: NotificationActionKind::OpenClusterUpdate(vec![summary]), }], }) } /// Builds a single notification summarising a batch bundle sync. Every changed /// cluster rides along on one "View changes" action so the notification keeps /// exactly two buttons no matter how many clusters moved; the modal does the /// per-cluster breakdown. `None` when nothing changed. async fn combined_cluster_update_spec( changed: &[(i64, oneclient_core::ApplyBundleUpdatesResult)], services: &oneclient_core::LauncherServices, ) -> Option { let mut summaries = Vec::new(); let mut total_changes = 0usize; for (cluster_id, result) in changed { if let Some(summary) = cluster_update_summary(*cluster_id, result, services).await { total_changes += summary.total(); summaries.push(summary); } } if summaries.is_empty() { return None; } 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" } ); Some(NotificationSpec { title: "Mods updated".to_string(), body, level: NotificationLevel::Info, icon: Some(IconType::DownloadCloud02), progress: None, actions: vec![NotificationAction { label: "View changes".to_string(), kind: NotificationActionKind::OpenClusterUpdate(summaries), }], }) } fn publish(tx: &watch::Sender, snapshots: &BridgeSnapshot) { let _ = tx.send(snapshots.clone()); } fn sync_notifications( snapshots: &mut BridgeSnapshot, engine: &NotificationState, inbox: &[InboxEntry], center_open: bool, pending: &Option, ) { let pending_view = pending.as_ref().map(|prompt| PendingPromptView { title: prompt.title.clone(), question: prompt.question.clone(), kind: prompt.kind, }); snapshots.notifications = engine.snapshot(inbox, center_open, pending_view); } /// A toast's time-to-live, which hovering the toast pauses and un-hovering resumes. #[derive(Clone, Copy)] enum ToastTimer { Running(tokio::time::Instant), Paused(std::time::Duration), } impl ToastTimer { fn armed(paused: bool) -> Self { if paused { Self::Paused(MESSAGE_TOAST_TTL) } else { Self::Running(tokio::time::Instant::now() + MESSAGE_TOAST_TTL) } } /// The instant this toast expires, or `None` while paused. fn deadline(&self) -> Option { match self { Self::Running(deadline) => Some(*deadline), Self::Paused(_) => None, } } fn pause(&mut self) { if let Self::Running(deadline) = *self { *self = Self::Paused(deadline.saturating_duration_since(tokio::time::Instant::now())); } } fn resume(&mut self) { if let Self::Paused(remaining) = *self { *self = Self::Running(tokio::time::Instant::now() + remaining); } } } fn reconcile_toasts( snapshots: &BridgeSnapshot, inbox: &[InboxEntry], armed: &mut std::collections::HashMap, paused: bool, ) { let want: std::collections::HashSet = snapshots .notifications .active_toast_entry_ids .iter() .filter(|id| inbox.iter().any(|e| e.id == **id && !e.is_loading)) .copied() .collect(); for id in &want { armed.entry(*id).or_insert_with(|| ToastTimer::armed(paused)); } armed.retain(|id, _| want.contains(id)); } fn refresh_settings(snapshots: &mut BridgeSnapshot) -> anyhow::Result<()> { let state = LauncherState::get()?; snapshots.settings.settings = state.settings.read().clone(); snapshots.settings.status = AsyncStatus::Ready; snapshots.settings.error = None; Ok(()) } async fn install_package( state: &Arc, provider: oneclient_core::packages::ProviderId, project_id: &str, version_id: &str, cluster_id: i64, ) -> anyhow::Result { let provider_impl = state.services.packages.get(provider)?; let project = provider_impl .get_project(project_id, &state.services) .await?; let version = provider_impl .get_version(project_id, version_id, &state.services) .await?; let session = oneclient_core::notification::GroupedProgressSession::start( &state.services.notifier, format!("Installing {}", project.name), ); let size = version.primary_file().map(|f| f.size).unwrap_or(0); let child = session.child( project.name.clone(), size, oneclient_core::notification::TaskCategory::Packages, ); let result = PackageStore::install_to_cluster( provider, &project, &version, cluster_id, false, false, Some(&child), &state.services, ) .await; child.finish(); session.finish(); result?; Ok(project.name) } fn apply_game_stage(snapshots: &mut BridgeSnapshot, cluster_id: i64, stage: LaunchStage) { snapshots.game.stages.insert(cluster_id, stage); if stage == LaunchStage::Checking { snapshots.game.error = None; snapshots.game.logs.insert(cluster_id, Arc::new(Vec::new())); } } fn apply_game_failed(snapshots: &mut BridgeSnapshot, cluster_id: i64, message: String) { snapshots .game .stages .insert(cluster_id, LaunchStage::Exited); snapshots.game.error = Some(message); } fn apply_game_log(snapshots: &mut BridgeSnapshot, cluster_id: i64, line: String) { let buffer = snapshots.game.logs.entry(cluster_id).or_default(); Arc::make_mut(buffer).push(Arc::from(line)); } fn bump_profiles_generation(snapshots: &mut BridgeSnapshot) { snapshots.profiles.generation = snapshots.profiles.generation.wrapping_add(1); } fn bump_clusters_generation(snapshots: &mut BridgeSnapshot) { snapshots.clusters.generation = snapshots.clusters.generation.wrapping_add(1); } fn bump_java_generation(snapshots: &mut BridgeSnapshot) { snapshots.java.generation = snapshots.java.generation.wrapping_add(1); } fn mutate_settings( snapshots: &mut BridgeSnapshot, mutate: impl FnOnce(&mut oneclient_core::settings::LauncherSettings), ) -> anyhow::Result<()> { let state = LauncherState::get()?; { let mut lock = state.settings.write(); mutate(&mut lock); snapshots.settings.settings = lock.clone(); } snapshots.settings.status = AsyncStatus::Ready; Ok(()) }