OneLauncher-Cracked/packages/oneclient_app/src/bridge/runtime.rs

1089 lines
42 KiB
Rust

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<BridgeSnapshot>,
pub commands_rx: mpsc::UnboundedReceiver<BridgeCommand>,
pub commands_tx: mpsc::UnboundedSender<BridgeCommand>,
}
impl CoreBridgeRuntime {
pub async fn run(mut self) {
let mut snapshots = BridgeSnapshot::default();
let mut notification_engine = NotificationState::default();
let mut inbox: Vec<InboxEntry> = Vec::new();
let mut notification_center_open = false;
let mut pending_prompt: Option<PendingPrompt> = None;
let mut armed_toasts: std::collections::HashMap<u64, ToastTimer> =
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<tokio::time::Instant> = 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,
&notification_engine,
&inbox,
notification_center_open,
&pending_prompt,
);
publish(&self.snapshots_tx, &snapshots);
tracing::error!("launcher init failed: {err:#}");
} else {
sync_notifications(
&mut snapshots,
&notification_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<u64> = 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,
&notification_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<InboxEntry>,
pending: &mut Option<PendingPrompt>| {
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,
&notification_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,
&notification_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<Notification>,
) -> 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<InboxEntry>,
notification_center_open: &mut bool,
pending_prompt: &mut Option<PendingPrompt>,
commands_tx: &mpsc::UnboundedSender<BridgeCommand>,
) -> 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<ClusterUpdateSummary> {
let updated: Vec<ClusterUpdateItem> = result
.updates_applied
.iter()
.map(|u| item_from_bundle_file(&u.new_file))
.collect();
let added: Vec<ClusterUpdateItem> = result
.additions_applied
.iter()
.map(|a| item_from_bundle_file(&a.new_file))
.collect();
let removed: Vec<ClusterUpdateItem> = 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<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
);
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<NotificationSpec> {
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<BridgeSnapshot>, snapshots: &BridgeSnapshot) {
let _ = tx.send(snapshots.clone());
}
fn sync_notifications(
snapshots: &mut BridgeSnapshot,
engine: &NotificationState,
inbox: &[InboxEntry],
center_open: bool,
pending: &Option<PendingPrompt>,
) {
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<tokio::time::Instant> {
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<u64, ToastTimer>,
paused: bool,
) {
let want: std::collections::HashSet<u64> = 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<LauncherState>,
provider: oneclient_core::packages::ProviderId,
project_id: &str,
version_id: &str,
cluster_id: i64,
) -> anyhow::Result<String> {
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(())
}