2466 lines
82 KiB
Rust
2466 lines
82 KiB
Rust
//! Core operations go onto freya's UI-thread executor radio state is `!Send`
|
|
//!
|
|
//! Always `spawn_forever` never `spawn` Freya's `spawn` cancels the task when
|
|
//! the calling component unmounts this work is app-scoped not component-scoped
|
|
|
|
mod modpacks;
|
|
|
|
pub use modpacks::modpack_job_running;
|
|
mod release_migration;
|
|
|
|
use std::collections::HashMap;
|
|
use std::path::PathBuf;
|
|
use std::sync::{Arc, Mutex};
|
|
use std::time::{Duration, Instant};
|
|
|
|
use freya::prelude::spawn_forever;
|
|
use freya::radio::RadioStation;
|
|
use oneclient_cluster::profiles::list_named_profiles;
|
|
use oneclient_cluster::{
|
|
Cluster, ClusterStage, ClusterUpdate, GameSettingsProfile, PackageUpdateMode, ProfileUpdate,
|
|
};
|
|
use oneclient_common::domain::{ContentType, ProviderId};
|
|
use oneclient_content::packages::{LiveSync, LocalImportReport};
|
|
use oneclient_core::relocate::RelocationPlan;
|
|
use oneclient_core::settings::LauncherSettings;
|
|
use oneclient_core::settings::store::{save_global_profile, save_settings_and_apply};
|
|
use oneclient_db::models::ClusterId;
|
|
use oneclient_events::{Answer, Level};
|
|
use tokio::sync::mpsc;
|
|
|
|
use crate::components::IconType;
|
|
use crate::launcher::off_ui;
|
|
use crate::notifications::{
|
|
BundleChoices, ClusterUpdateSummary, NotificationAction, NotificationSpec, OptionalModRef,
|
|
OptionalModsGroup, OptionalModsOutcome, PackageUpdateGroup, PendingPrompt,
|
|
};
|
|
use crate::state::{AppChannel, AppState, AsyncStatus, FlaggedInstallPrompt, RelocationState};
|
|
use crate::{invalidate_java_queries, launcher};
|
|
|
|
/// Over-disabling is the cheaper mistake a dead button beats a second game
|
|
const LAUNCH_HOLD: Duration = Duration::from_secs(2);
|
|
|
|
/// Mirrors `oneclient_net`'s reachability probe not reqwest's per-request
|
|
/// timeouts which are minutes long This sits between Play and the game
|
|
const UPDATE_CHECK_BUDGET: Duration = Duration::from_secs(8);
|
|
const BUNDLE_CHECK_INTERVAL: Duration = Duration::from_secs(15 * 60);
|
|
const BUNDLE_SYNC_BUDGET: Duration = Duration::from_secs(30);
|
|
/// The downloads need one too the net stack alone retries for minutes and the
|
|
/// launch button is already claimed by then
|
|
const BUNDLE_APPLY_BUDGET: Duration = Duration::from_secs(60);
|
|
|
|
#[derive(Debug, Default)]
|
|
struct BundleCheckLog {
|
|
checked_at: HashMap<ClusterId, Instant>,
|
|
}
|
|
|
|
impl BundleCheckLog {
|
|
fn due(&self, cluster_id: ClusterId, now: Instant) -> bool {
|
|
self.checked_at
|
|
.get(&cluster_id)
|
|
.is_none_or(|last| now.duration_since(*last) >= BUNDLE_CHECK_INTERVAL)
|
|
}
|
|
|
|
fn record(&mut self, cluster_id: ClusterId, now: Instant) {
|
|
self.checked_at.insert(cluster_id, now);
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
enum LaunchUpdatePlan {
|
|
Nothing,
|
|
Apply(Vec<oneclient_core::BrowserPackageUpdate>),
|
|
Prompt(Vec<oneclient_core::BrowserPackageUpdate>),
|
|
}
|
|
|
|
/// Kept pure and separate from the work it describes so the three-way mode is
|
|
/// testable without a launcher database or provider
|
|
///
|
|
/// `pending` has already had declined versions filtered out by
|
|
/// `BrowserUpdateCheck::pending`
|
|
fn plan_launch_updates(
|
|
mode: PackageUpdateMode,
|
|
pending: Vec<oneclient_core::BrowserPackageUpdate>,
|
|
) -> LaunchUpdatePlan {
|
|
if pending.is_empty() {
|
|
return LaunchUpdatePlan::Nothing;
|
|
}
|
|
|
|
match mode {
|
|
// The cache was already written skipping only affects what is shown
|
|
PackageUpdateMode::Skip => LaunchUpdatePlan::Nothing,
|
|
PackageUpdateMode::Automatic => LaunchUpdatePlan::Apply(pending),
|
|
PackageUpdateMode::Prompt => LaunchUpdatePlan::Prompt(pending),
|
|
}
|
|
}
|
|
|
|
/// Names the clusters that pinned `java_path` to this runtime by hand
|
|
fn clusters_pinned_to_java(
|
|
profiles: &[GameSettingsProfile],
|
|
clusters: &[Cluster],
|
|
absolute_path: &str,
|
|
) -> Vec<String> {
|
|
profiles
|
|
.iter()
|
|
.filter(|profile| profile.java_path.as_deref() == Some(absolute_path))
|
|
.map(|profile| {
|
|
clusters
|
|
.iter()
|
|
.find(|cluster| {
|
|
cluster.setting_profile_name.as_deref() == Some(profile.name.as_str())
|
|
})
|
|
.map_or_else(|| profile.name.clone(), |cluster| cluster.name.clone())
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// The pump alone owns the toast timers so adding or removing a toast has to
|
|
/// tell it to re-arm hover-pause is a timer property not component state
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum PumpSignal {
|
|
Reconcile,
|
|
PauseToasts,
|
|
ResumeToasts,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct Actions {
|
|
station: RadioStation<AppState, AppChannel>,
|
|
pump: mpsc::UnboundedSender<PumpSignal>,
|
|
events: oneclient_events::EventBus,
|
|
bundle_checks: Arc<Mutex<BundleCheckLog>>,
|
|
}
|
|
|
|
impl Actions {
|
|
#[must_use]
|
|
pub fn station(&self) -> RadioStation<AppState, AppChannel> {
|
|
self.station
|
|
}
|
|
|
|
/// A channel handle, so a clone still feeds the pump set up at launch
|
|
#[must_use]
|
|
pub fn events(&self) -> oneclient_events::EventBus {
|
|
self.events.clone()
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn new(
|
|
station: RadioStation<AppState, AppChannel>,
|
|
pump: mpsc::UnboundedSender<PumpSignal>,
|
|
events: oneclient_events::EventBus,
|
|
) -> Self {
|
|
Self {
|
|
station,
|
|
pump,
|
|
events,
|
|
bundle_checks: Arc::new(Mutex::new(BundleCheckLog::default())),
|
|
}
|
|
}
|
|
|
|
fn nudge(&self, signal: PumpSignal) {
|
|
let _ = self.pump.send(signal);
|
|
}
|
|
|
|
fn bundle_check_due(&self, cluster_id: ClusterId) -> bool {
|
|
self.bundle_checks
|
|
.lock()
|
|
.unwrap()
|
|
.due(cluster_id, Instant::now())
|
|
}
|
|
|
|
fn record_bundle_checks(&self, cluster_ids: impl IntoIterator<Item = ClusterId>) {
|
|
let now = Instant::now();
|
|
let mut log = self.bundle_checks.lock().unwrap();
|
|
for cluster_id in cluster_ids {
|
|
log.record(cluster_id, now);
|
|
}
|
|
}
|
|
|
|
fn with_engine(&self, mutate: impl FnOnce(&mut AppState)) {
|
|
{
|
|
let mut guard = self
|
|
.station
|
|
.clone()
|
|
.write_channel(AppChannel::Notifications);
|
|
mutate(&mut guard);
|
|
}
|
|
self.nudge(PumpSignal::Reconcile);
|
|
}
|
|
|
|
fn set_settings_error(&self, error: Option<String>) {
|
|
self.station
|
|
.clone()
|
|
.write_channel(AppChannel::Settings)
|
|
.settings
|
|
.error = error;
|
|
}
|
|
|
|
pub fn reload_settings(&self) {
|
|
let station = self.station;
|
|
spawn_forever(async move {
|
|
let Ok(state) = launcher::state() else { return };
|
|
{
|
|
let mut guard = station.clone().write_channel(AppChannel::Settings);
|
|
guard.settings.status = AsyncStatus::Loading;
|
|
guard.settings.error = None;
|
|
}
|
|
|
|
let loaded =
|
|
oneclient_core::settings::store::load_settings(Some(&state.services.events)).await;
|
|
let discord_enabled = loaded.discord_enabled;
|
|
*state.settings.write() = loaded.clone();
|
|
state.discord.set_enabled(discord_enabled);
|
|
|
|
let mut guard = station.clone().write_channel(AppChannel::Settings);
|
|
guard.settings.settings = loaded;
|
|
guard.settings.status = AsyncStatus::Ready;
|
|
guard.settings.error = None;
|
|
});
|
|
}
|
|
|
|
pub fn save_settings(&self) {
|
|
let actions = self.clone();
|
|
spawn_forever(async move {
|
|
let Ok(state) = launcher::state() else { return };
|
|
{
|
|
let mut guard = actions.station.clone().write_channel(AppChannel::Settings);
|
|
guard.settings.saving = true;
|
|
guard.settings.error = None;
|
|
}
|
|
|
|
let settings = state.settings.read().clone();
|
|
let result = save_settings_and_apply(&state.services, &settings).await;
|
|
|
|
let mut guard = actions.station.clone().write_channel(AppChannel::Settings);
|
|
guard.settings.error = result.err().map(|err| err.to_string());
|
|
guard.settings.saving = false;
|
|
guard.settings.status = AsyncStatus::Ready;
|
|
});
|
|
}
|
|
|
|
fn mutate_settings(
|
|
&self,
|
|
mutate: impl FnOnce(&mut LauncherSettings),
|
|
) -> Option<LauncherSettings> {
|
|
let state = launcher::state().ok()?;
|
|
let updated = {
|
|
let mut lock = state.settings.write();
|
|
mutate(&mut lock);
|
|
lock.clone()
|
|
};
|
|
|
|
let mut guard = self.station.clone().write_channel(AppChannel::Settings);
|
|
guard.settings.settings = updated.clone();
|
|
guard.settings.status = AsyncStatus::Ready;
|
|
Some(updated)
|
|
}
|
|
|
|
fn persist(&self, settings: LauncherSettings) {
|
|
let actions = self.clone();
|
|
spawn_forever(async move {
|
|
let Ok(state) = launcher::state() else { return };
|
|
if let Err(err) = save_settings_and_apply(&state.services, &settings).await {
|
|
actions.set_settings_error(Some(err.to_string()));
|
|
}
|
|
});
|
|
}
|
|
|
|
pub fn refresh_settings_from_core(&self) {
|
|
self.mutate_settings(|_| {});
|
|
}
|
|
|
|
pub fn edit_settings(&self, edit: impl FnOnce(&mut LauncherSettings)) {
|
|
let Some(updated) = self.mutate_settings(edit) else {
|
|
return;
|
|
};
|
|
if let Ok(state) = launcher::state() {
|
|
state.discord.set_enabled(updated.discord_enabled);
|
|
}
|
|
self.persist(updated);
|
|
}
|
|
|
|
pub fn set_settings(&self, settings: LauncherSettings) {
|
|
let Some(updated) = self.mutate_settings(|s| {
|
|
let pending = std::mem::take(&mut s.pending_release_migrations);
|
|
*s = settings;
|
|
s.pending_release_migrations = pending;
|
|
}) else {
|
|
return;
|
|
};
|
|
if let Ok(state) = launcher::state() {
|
|
state.discord.set_enabled(updated.discord_enabled);
|
|
}
|
|
self.persist(updated);
|
|
}
|
|
|
|
/// Not persisted the next real save carries the seen versions along
|
|
pub fn record_seen_version(&self, version: impl Into<String>) {
|
|
let version = version.into();
|
|
self.mutate_settings(|settings| {
|
|
if !settings.seen_versions.iter().any(|v| v == &version) {
|
|
settings.seen_versions.push(version);
|
|
}
|
|
});
|
|
}
|
|
|
|
pub fn set_seen_changelog_version(&self, version: impl Into<String>) {
|
|
let version = version.into();
|
|
let mut changed = false;
|
|
let updated = self.mutate_settings(|settings| {
|
|
if settings.seen_changelog_version.as_deref() != Some(version.as_str()) {
|
|
settings.seen_changelog_version = Some(version);
|
|
changed = true;
|
|
}
|
|
});
|
|
|
|
if let (true, Some(updated)) = (changed, updated) {
|
|
self.persist(updated);
|
|
}
|
|
}
|
|
|
|
pub fn mark_onboarding_seen(&self) {
|
|
if let Some(updated) = self.mutate_settings(|settings| settings.seen_onboarding = true) {
|
|
self.persist(updated);
|
|
}
|
|
}
|
|
|
|
pub fn skip_microsoft_java(&self) {
|
|
if let Some(updated) = self.mutate_settings(|settings| settings.skip_microsoft_java = true)
|
|
{
|
|
self.persist(updated);
|
|
}
|
|
}
|
|
|
|
pub fn reset_onboarding(&self) {
|
|
if let Some(updated) = self.mutate_settings(|settings| {
|
|
settings.seen_onboarding = false;
|
|
settings.accepted_tos_version = 0;
|
|
settings.accepted_privacy_version = 0;
|
|
settings.seen_versions.clear();
|
|
}) {
|
|
self.persist(updated);
|
|
}
|
|
}
|
|
|
|
pub fn accept_tos(&self, terms_version: u32, privacy_version: u32) {
|
|
let mut was_declined = false;
|
|
|
|
let Some(updated) = self.mutate_settings(|settings| {
|
|
was_declined = settings.declined_tos;
|
|
settings.accepted_tos_version = terms_version;
|
|
settings.accepted_privacy_version = privacy_version;
|
|
settings.declined_tos = false;
|
|
}) else {
|
|
return;
|
|
};
|
|
|
|
self.persist(updated);
|
|
|
|
if was_declined {
|
|
self.notify("Restart to finish")
|
|
.body(
|
|
"Thanks. OneClient reconnects to Polyfrost services the next time you start \
|
|
it.",
|
|
)
|
|
.icon(IconType::RefreshCw01)
|
|
.send();
|
|
}
|
|
}
|
|
|
|
pub fn decline_tos(&self) {
|
|
oneclient_common::consent::decline();
|
|
|
|
if let Some(updated) = self.mutate_settings(|settings| {
|
|
settings.declined_tos = true;
|
|
settings.accepted_tos_version = 0;
|
|
settings.accepted_privacy_version = 0;
|
|
}) {
|
|
self.persist(updated);
|
|
}
|
|
}
|
|
|
|
pub fn save_global_profile(&self, profile: GameSettingsProfile) {
|
|
let actions = self.clone();
|
|
spawn_forever(async move {
|
|
let Ok(state) = launcher::state() else { return };
|
|
if let Err(err) = save_global_profile(&state.settings, profile).await {
|
|
tracing::error!("failed to save the global profile: {err:#}");
|
|
return;
|
|
}
|
|
actions.mutate_settings(|_| {});
|
|
super::invalidate_profile_queries().await;
|
|
});
|
|
}
|
|
|
|
pub fn update_global_profile(&self, update: ProfileUpdate) {
|
|
let actions = self.clone();
|
|
spawn_forever(async move {
|
|
let Ok(state) = launcher::state() else { return };
|
|
let mut global = state.settings.read().global_game_settings.clone();
|
|
update.apply(&mut global);
|
|
if let Err(err) = save_global_profile(&state.settings, global).await {
|
|
tracing::error!("failed to update the global profile: {err:#}");
|
|
return;
|
|
}
|
|
actions.mutate_settings(|_| {});
|
|
super::invalidate_profile_queries().await;
|
|
});
|
|
}
|
|
|
|
pub fn create_settings_profile(&self, name: impl Into<String>) {
|
|
let name = name.into();
|
|
spawn_forever(async move {
|
|
let Ok(state) = launcher::state() else { return };
|
|
let global = state.settings.read().global_game_settings.clone();
|
|
match oneclient_cluster::profiles::create_settings_profile(
|
|
&state.services.db,
|
|
&global,
|
|
&name,
|
|
)
|
|
.await
|
|
{
|
|
Ok(_) => super::invalidate_profile_queries().await,
|
|
Err(err) => tracing::error!("failed to create settings profile: {err:#}"),
|
|
}
|
|
});
|
|
}
|
|
|
|
pub fn create_profile_from_global(
|
|
&self,
|
|
name: impl Into<String>,
|
|
mem_max: Option<u32>,
|
|
force_fullscreen: Option<bool>,
|
|
) {
|
|
let name = name.into();
|
|
spawn_forever(async move {
|
|
let Ok(state) = launcher::state() else { return };
|
|
let global = state.settings.read().global_game_settings.clone();
|
|
match oneclient_cluster::profiles::create_profile_from_global(
|
|
&state.services.db,
|
|
&global,
|
|
&name,
|
|
mem_max,
|
|
force_fullscreen,
|
|
)
|
|
.await
|
|
{
|
|
Ok(_) => super::invalidate_profile_queries().await,
|
|
Err(err) => tracing::error!("failed to create profile: {err:#}"),
|
|
}
|
|
});
|
|
}
|
|
|
|
pub fn upsert_named_profile(&self, profile: GameSettingsProfile) {
|
|
spawn_forever(async move {
|
|
let Ok(state) = launcher::state() else { return };
|
|
match oneclient_cluster::profiles::upsert_named_profile(&state.services.db, &profile)
|
|
.await
|
|
{
|
|
Ok(_) => super::invalidate_profile_queries().await,
|
|
Err(err) => tracing::error!("failed to save profile: {err:#}"),
|
|
}
|
|
});
|
|
}
|
|
|
|
pub fn update_named_profile(&self, name: impl Into<String>, update: ProfileUpdate) {
|
|
let name = name.into();
|
|
spawn_forever(async move {
|
|
let Ok(state) = launcher::state() else { return };
|
|
match oneclient_cluster::profiles::update_named_profile(
|
|
&state.services.db,
|
|
&name,
|
|
update,
|
|
)
|
|
.await
|
|
{
|
|
Ok(_) => super::invalidate_profile_queries().await,
|
|
Err(err) => tracing::error!("failed to update profile: {err:#}"),
|
|
}
|
|
});
|
|
}
|
|
|
|
pub fn delete_named_profile(&self, name: impl Into<String>) {
|
|
let name = name.into();
|
|
spawn_forever(async move {
|
|
let Ok(state) = launcher::state() else { return };
|
|
match oneclient_cluster::profiles::delete_named_profile(&state.services.db, &name).await
|
|
{
|
|
Ok(()) => super::invalidate_profile_queries().await,
|
|
Err(err) => tracing::error!("failed to delete profile: {err:#}"),
|
|
}
|
|
});
|
|
}
|
|
|
|
pub fn update_cluster_profile(&self, cluster_id: ClusterId, update: ProfileUpdate) {
|
|
spawn_forever(async move {
|
|
let Ok(state) = launcher::state() else { return };
|
|
match state.clusters.update_profile(cluster_id, update).await {
|
|
Ok(_) => super::invalidate_profile_queries().await,
|
|
Err(err) => tracing::error!("failed to update cluster profile: {err:#}"),
|
|
}
|
|
});
|
|
}
|
|
|
|
pub fn create_and_assign_cluster_profile(
|
|
&self,
|
|
cluster_id: ClusterId,
|
|
profile_name: impl Into<String>,
|
|
) {
|
|
let profile_name = profile_name.into();
|
|
spawn_forever(async move {
|
|
let Ok(state) = launcher::state() else { return };
|
|
let global = state.settings.read().global_game_settings.clone();
|
|
match state
|
|
.clusters
|
|
.create_and_assign_profile(&global, cluster_id, &profile_name)
|
|
.await
|
|
{
|
|
Ok(_) => super::invalidate_profile_queries().await,
|
|
Err(err) => tracing::error!("failed to assign cluster profile: {err:#}"),
|
|
}
|
|
});
|
|
}
|
|
|
|
pub fn set_cluster_loader_version(&self, cluster_id: ClusterId, version: impl Into<String>) {
|
|
let version = version.into();
|
|
spawn_forever(async move {
|
|
let Ok(state) = launcher::state() else { return };
|
|
if let Err(err) = state
|
|
.clusters
|
|
.update(cluster_id, ClusterUpdate::default().loader_version(version))
|
|
.await
|
|
{
|
|
tracing::error!("failed to set loader version: {err:#}");
|
|
return;
|
|
}
|
|
// Best effort on failure the cluster just will not re-download
|
|
let _ = state
|
|
.clusters
|
|
.set_stage(cluster_id, ClusterStage::NotReady)
|
|
.await;
|
|
super::invalidate_cluster_queries().await;
|
|
});
|
|
}
|
|
|
|
pub fn import_launcher(
|
|
&self,
|
|
source: oneclient_core::MigrationSource,
|
|
folder_name: impl Into<String>,
|
|
target: oneclient_core::ImportTarget,
|
|
) {
|
|
let folder_name = folder_name.into();
|
|
spawn_forever(async move {
|
|
let Ok(state) = launcher::state() else { return };
|
|
let events = state.services.events.clone();
|
|
match off_ui({
|
|
let state = state.clone();
|
|
async move {
|
|
oneclient_core::import_migration_game_dir(&state, source, &folder_name, target)
|
|
.await
|
|
}
|
|
})
|
|
.await
|
|
{
|
|
Ok(()) => {
|
|
events
|
|
.notify("Import complete")
|
|
.body(format!("Copied your files from {}.", source.display_name()))
|
|
.send();
|
|
events.signal(oneclient_events::Signal::ClustersChanged);
|
|
}
|
|
Err(err) => events
|
|
.notify("Import failed")
|
|
.body(err.to_string())
|
|
.error()
|
|
.send(),
|
|
}
|
|
});
|
|
}
|
|
|
|
pub fn install_java_runtime(&self, vendor: oneclient_java::JavaVendor, major: u32) {
|
|
spawn_forever(async move {
|
|
let Ok(state) = launcher::state() else { return };
|
|
let events = state.services.events.clone();
|
|
match off_ui({
|
|
let state = state.clone();
|
|
async move { state.java.install_runtime_from(&vendor, major).await }
|
|
})
|
|
.await
|
|
{
|
|
Ok(_) => {
|
|
events.signal(oneclient_events::Signal::JavaChanged);
|
|
invalidate_java_queries().await
|
|
}
|
|
Err(err) => events
|
|
.notify("Java install failed")
|
|
.body(err.to_string())
|
|
.error()
|
|
.send(),
|
|
}
|
|
});
|
|
}
|
|
|
|
pub fn add_custom_java_runtime(&self, path: PathBuf) {
|
|
spawn_forever(async move {
|
|
let Ok(state) = launcher::state() else { return };
|
|
let events = state.services.events.clone();
|
|
match state.java.add_custom_runtime(path).await {
|
|
Ok(runtime) => {
|
|
events
|
|
.notify("Java added")
|
|
.body(format!("Java {} ({})", runtime.major, runtime.vendor))
|
|
.send();
|
|
events.signal(oneclient_events::Signal::JavaChanged);
|
|
}
|
|
Err(err) => events
|
|
.notify("Failed to add Java")
|
|
.body(err.to_string())
|
|
.error()
|
|
.send(),
|
|
}
|
|
});
|
|
}
|
|
|
|
pub fn remove_java_runtime(&self, path: impl Into<String>) {
|
|
let path = path.into();
|
|
spawn_forever(async move {
|
|
let Ok(state) = launcher::state() else { return };
|
|
let events = state.services.events.clone();
|
|
|
|
// Checks if the jdk is pinned to the cluster
|
|
match (
|
|
list_named_profiles(&state.services.db).await,
|
|
state.clusters.list().await,
|
|
) {
|
|
(Ok(profiles), Ok(clusters)) => {
|
|
let pinned = clusters_pinned_to_java(&profiles, &clusters, &path);
|
|
if !pinned.is_empty() {
|
|
events
|
|
.notify("Cannot remove this JDK")
|
|
.body(format!("It is used by cluster: {}", pinned.join(", ")))
|
|
.error()
|
|
.send();
|
|
return;
|
|
}
|
|
}
|
|
(Err(err), _) | (_, Err(err)) => {
|
|
tracing::error!("could not check whether the java runtime is in use: {err:#}");
|
|
}
|
|
}
|
|
|
|
match state.java.remove_runtime(&path).await {
|
|
Ok(()) => super::invalidate_java_queries().await,
|
|
Err(err) => tracing::error!("failed to remove java runtime: {err:#}"),
|
|
}
|
|
});
|
|
}
|
|
|
|
pub fn toggle_notification_center(&self) {
|
|
self.with_engine(|state| {
|
|
state.center_open = state
|
|
.notifications
|
|
.toggle_center(&mut state.inbox, state.center_open);
|
|
});
|
|
}
|
|
|
|
pub fn toggle_account_switcher(&self) {
|
|
let open = self.station.peek().account_switcher_open;
|
|
self.station
|
|
.clone()
|
|
.write_channel(AppChannel::AccountSwitcher)
|
|
.account_switcher_open = !open;
|
|
}
|
|
|
|
pub fn open_account_switcher(&self) {
|
|
self.station
|
|
.clone()
|
|
.write_channel(AppChannel::AccountSwitcher)
|
|
.account_switcher_open = true;
|
|
}
|
|
|
|
pub fn close_account_switcher(&self) {
|
|
self.station
|
|
.clone()
|
|
.write_channel(AppChannel::AccountSwitcher)
|
|
.account_switcher_open = false;
|
|
}
|
|
|
|
pub fn toggle_control_center(&self) {
|
|
let open = self.station.peek().control_center_open;
|
|
self.station
|
|
.clone()
|
|
.write_channel(AppChannel::ControlCenter)
|
|
.control_center_open = !open;
|
|
}
|
|
|
|
pub fn open_control_center(&self) {
|
|
self.station
|
|
.clone()
|
|
.write_channel(AppChannel::ControlCenter)
|
|
.control_center_open = true;
|
|
}
|
|
|
|
pub fn close_control_center(&self) {
|
|
self.station
|
|
.clone()
|
|
.write_channel(AppChannel::ControlCenter)
|
|
.control_center_open = false;
|
|
}
|
|
|
|
pub fn close_notification_center(&self) {
|
|
self.with_engine(|state| state.center_open = false);
|
|
}
|
|
|
|
pub fn clear_notification_inbox(&self) {
|
|
self.with_engine(|state| {
|
|
state.notifications.clear_inbox(&mut state.inbox);
|
|
});
|
|
}
|
|
|
|
pub fn dismiss_toast(&self, entry_id: u64) {
|
|
self.with_engine(|state| {
|
|
state
|
|
.notifications
|
|
.dismiss_toast(&mut state.inbox, entry_id)
|
|
});
|
|
}
|
|
|
|
pub fn mark_notification_read(&self, entry_id: u64) {
|
|
self.with_engine(|state| state.notifications.mark_read(&mut state.inbox, entry_id));
|
|
}
|
|
|
|
pub fn dismiss_notification(&self, entry_id: u64) {
|
|
self.with_engine(|state| {
|
|
state
|
|
.notifications
|
|
.dismiss_notification(&mut state.inbox, entry_id);
|
|
});
|
|
}
|
|
|
|
pub fn open_cluster_update(&self, summaries: Vec<ClusterUpdateSummary>) {
|
|
self.with_engine(|state| {
|
|
state.notifications.open_cluster_update(summaries);
|
|
state.center_open = false;
|
|
});
|
|
}
|
|
|
|
pub fn close_cluster_update(&self) {
|
|
self.with_engine(|state| state.notifications.close_cluster_update());
|
|
}
|
|
|
|
pub fn open_optional_mods(
|
|
&self,
|
|
groups: Vec<OptionalModsGroup>,
|
|
done: Option<tokio::sync::oneshot::Sender<OptionalModsOutcome>>,
|
|
) {
|
|
self.with_engine(move |state| state.notifications.open_optional_mods(groups, done));
|
|
}
|
|
|
|
pub fn close_bundle_choices(&self, chosen: Option<std::collections::HashSet<String>>) {
|
|
self.with_engine(move |state| state.notifications.finish_bundle_choices(chosen));
|
|
}
|
|
|
|
pub fn close_optional_mods(&self, outcome: OptionalModsOutcome) {
|
|
self.with_engine(move |state| state.notifications.finish_optional_mods(outcome));
|
|
}
|
|
|
|
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(OptionalModsOutcome::Launch);
|
|
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(OptionalModsOutcome::Launch);
|
|
return;
|
|
};
|
|
let events = state.services.events.clone();
|
|
let (installed, failed) = off_ui(async move {
|
|
let content = state.services.content();
|
|
let session = oneclient_events::GroupedProgressSession::start(
|
|
&state.services.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),
|
|
None,
|
|
)
|
|
.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();
|
|
(installed, failed)
|
|
})
|
|
.await;
|
|
|
|
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(OptionalModsOutcome::Launch);
|
|
});
|
|
}
|
|
|
|
pub fn proceed_package_updates(&self, chosen: Vec<oneclient_core::BrowserPackageUpdate>) {
|
|
self.with_engine(move |state| state.notifications.proceed_package_updates(chosen));
|
|
}
|
|
|
|
pub fn close_package_updates(&self) {
|
|
self.with_engine(|state| state.notifications.close_package_updates());
|
|
}
|
|
|
|
/// Hovering any toast pauses every toast including ones arriving while
|
|
/// hovering hence a pump signal rather than a state flag
|
|
pub fn pause_toasts(&self) {
|
|
self.nudge(PumpSignal::PauseToasts);
|
|
}
|
|
|
|
pub fn resume_toasts(&self) {
|
|
self.nudge(PumpSignal::ResumeToasts);
|
|
}
|
|
|
|
pub fn answer_prompt(&self, answer: Answer) {
|
|
self.reply_to_prompt(Some(answer));
|
|
}
|
|
|
|
pub fn dismiss_prompt(&self) {
|
|
self.reply_to_prompt(None);
|
|
}
|
|
|
|
fn reply_to_prompt(&self, answer: Option<Answer>) {
|
|
let taken = self
|
|
.station
|
|
.clone()
|
|
.write_channel(AppChannel::Notifications)
|
|
.prompt
|
|
.take();
|
|
|
|
if let Some(PendingPrompt {
|
|
reply_tx: Some(reply_tx),
|
|
..
|
|
}) = taken
|
|
{
|
|
let _ = reply_tx.send(answer);
|
|
}
|
|
}
|
|
|
|
pub fn notify(&self, title: impl Into<String>) -> NotificationBuilder {
|
|
NotificationBuilder {
|
|
actions: self.clone(),
|
|
spec: NotificationSpec {
|
|
title: title.into(),
|
|
body: String::new(),
|
|
level: Level::Info,
|
|
icon: None,
|
|
progress: None,
|
|
actions: Vec::new(),
|
|
toast_only: false,
|
|
},
|
|
}
|
|
}
|
|
|
|
fn push_notification(&self, spec: NotificationSpec) {
|
|
self.with_engine(|state| {
|
|
state.notifications.push_custom(&mut state.inbox, spec);
|
|
});
|
|
}
|
|
|
|
pub fn send_test_progress(&self, current: u64, total: u64) {
|
|
let Ok(state) = launcher::state() else { return };
|
|
let id = uuid::Uuid::from_u128(0x0CE0_0CE0_0CE0_0CE0_0CE0_0CE0_0CE0_0CE0);
|
|
state
|
|
.services
|
|
.events
|
|
.progress(id, "Downloading assets", current, total);
|
|
}
|
|
|
|
pub fn launch_cluster(&self, cluster_id: ClusterId) {
|
|
if modpack_job_running(cluster_id) {
|
|
self.notify("The modpack is still being set up")
|
|
.body("Wait for it to finish installing, then press Play again.")
|
|
.send();
|
|
return;
|
|
}
|
|
|
|
let claimed = self
|
|
.station
|
|
.clone()
|
|
.write_channel(AppChannel::Game)
|
|
.game
|
|
.begin_launch(cluster_id);
|
|
if !claimed {
|
|
return;
|
|
}
|
|
|
|
let station = self.station;
|
|
let actions = self.clone();
|
|
spawn_forever(async move {
|
|
let started = Instant::now();
|
|
launch(&actions, cluster_id).await;
|
|
|
|
// An instant failure would hand the button back inside the same
|
|
// click burst so hold it for the floor either way
|
|
if let Some(remaining) = LAUNCH_HOLD.checked_sub(started.elapsed()) {
|
|
tokio::time::sleep(remaining).await;
|
|
}
|
|
station
|
|
.clone()
|
|
.write_channel(AppChannel::Game)
|
|
.game
|
|
.finish_launch(cluster_id);
|
|
});
|
|
}
|
|
|
|
pub fn kill_cluster(&self, cluster_id: ClusterId) {
|
|
let Ok(state) = launcher::state() else { return };
|
|
if !state.games.kill(cluster_id) {
|
|
tracing::debug!(cluster_id, "kill requested but no tracked process");
|
|
}
|
|
}
|
|
|
|
pub fn dismiss_game_error(&self) {
|
|
self.station
|
|
.clone()
|
|
.write_channel(AppChannel::Game)
|
|
.game
|
|
.error = None;
|
|
}
|
|
|
|
pub fn import_local_file(
|
|
&self,
|
|
cluster_id: ClusterId,
|
|
content_type: ContentType,
|
|
path: PathBuf,
|
|
) {
|
|
self.import_local_files(cluster_id, vec![(path, content_type)]);
|
|
}
|
|
|
|
pub fn import_local_files(&self, cluster_id: ClusterId, files: Vec<(PathBuf, ContentType)>) {
|
|
if files.is_empty() {
|
|
return;
|
|
}
|
|
|
|
spawn_forever(async move {
|
|
let Ok(state) = launcher::state() else { return };
|
|
let events = state.services.events.clone();
|
|
let imported = off_ui({
|
|
let state = state.clone();
|
|
async move {
|
|
let report = oneclient_content::packages::PackageStore::import_local_files(
|
|
&files,
|
|
cluster_id,
|
|
&state.services.content(),
|
|
)
|
|
.await?;
|
|
|
|
let mut deferred = false;
|
|
for row in &report.imported {
|
|
let live = oneclient_content::packages::PackageStore::sync_live_content(
|
|
cluster_id,
|
|
row,
|
|
&state.services.content(),
|
|
)
|
|
.await
|
|
.unwrap_or(LiveSync::Skipped);
|
|
|
|
deferred |= live == LiveSync::Deferred;
|
|
}
|
|
Ok::<_, oneclient_content::ContentError>((report, deferred))
|
|
}
|
|
})
|
|
.await;
|
|
|
|
match imported {
|
|
Ok((report, deferred)) => {
|
|
notify_import(
|
|
&events,
|
|
&report,
|
|
deferred && state.games.is_active(cluster_id),
|
|
);
|
|
super::invalidate_cluster_queries().await;
|
|
}
|
|
Err(err) => events
|
|
.notify("Import failed")
|
|
.body(err.to_string())
|
|
.error()
|
|
.send(),
|
|
}
|
|
});
|
|
}
|
|
|
|
pub fn install_package(
|
|
&self,
|
|
cluster_id: ClusterId,
|
|
provider: ProviderId,
|
|
project_id: impl Into<String>,
|
|
version_id: impl Into<String>,
|
|
world: Option<String>,
|
|
) {
|
|
self.start_install(
|
|
cluster_id,
|
|
provider,
|
|
project_id.into(),
|
|
version_id.into(),
|
|
world,
|
|
false,
|
|
);
|
|
}
|
|
|
|
pub fn install_flagged_anyway(&self, prompt: FlaggedInstallPrompt) {
|
|
self.dismiss_flagged_install();
|
|
self.start_install(
|
|
prompt.cluster_id,
|
|
prompt.provider,
|
|
prompt.project_id,
|
|
prompt.version_id,
|
|
None,
|
|
true,
|
|
);
|
|
}
|
|
|
|
pub fn dismiss_flagged_install(&self) {
|
|
self.station
|
|
.clone()
|
|
.write_channel(AppChannel::Installs)
|
|
.installs
|
|
.flagged = None;
|
|
}
|
|
|
|
fn start_install(
|
|
&self,
|
|
cluster_id: ClusterId,
|
|
provider: ProviderId,
|
|
project_id: String,
|
|
version_id: String,
|
|
world: Option<String>,
|
|
allow_flagged: bool,
|
|
) {
|
|
let actions = self.clone();
|
|
|
|
self.station
|
|
.clone()
|
|
.write_channel(AppChannel::Installs)
|
|
.installs
|
|
.begin(cluster_id, provider, project_id.clone());
|
|
|
|
spawn_forever(async move {
|
|
let finish = |actions: &Actions| {
|
|
actions
|
|
.station
|
|
.clone()
|
|
.write_channel(AppChannel::Installs)
|
|
.installs
|
|
.finish(cluster_id, provider, &project_id);
|
|
};
|
|
|
|
let Ok(state) = launcher::state() else {
|
|
finish(&actions);
|
|
return;
|
|
};
|
|
|
|
let install = match off_ui({
|
|
let state = state.clone();
|
|
let project_id = project_id.clone();
|
|
let version_id = version_id.clone();
|
|
let world = world.clone();
|
|
async move {
|
|
crate::install::install_package(
|
|
&state,
|
|
provider,
|
|
&project_id,
|
|
&version_id,
|
|
cluster_id,
|
|
world,
|
|
allow_flagged,
|
|
)
|
|
.await
|
|
}
|
|
})
|
|
.await
|
|
{
|
|
Ok(install) => install,
|
|
Err(flagged) => {
|
|
let mut station = actions.station;
|
|
let mut app = station.write_channel(AppChannel::Installs);
|
|
app.installs.flagged = Some(FlaggedInstallPrompt {
|
|
cluster_id,
|
|
provider,
|
|
project_id: project_id.clone(),
|
|
version_id: version_id.clone(),
|
|
name: flagged.name,
|
|
mc_version: flagged.mc_version,
|
|
explanation: flagged.explanation,
|
|
alternatives: flagged.alternatives,
|
|
});
|
|
app.installs.finish(cluster_id, provider, &project_id);
|
|
return;
|
|
}
|
|
};
|
|
|
|
// Replaces the download's progress notification in place rather
|
|
// than arriving as a second one
|
|
let spec = match &install.result {
|
|
Ok(name) => NotificationSpec {
|
|
title: "Installed".to_string(),
|
|
body: crate::install::install_body(
|
|
name,
|
|
&install.dependencies,
|
|
&install.missing_dependencies,
|
|
install.live_deferred,
|
|
),
|
|
level: Level::Info,
|
|
icon: Some(IconType::Download01),
|
|
progress: None,
|
|
actions: Vec::new(),
|
|
toast_only: true,
|
|
},
|
|
Err(err) => NotificationSpec {
|
|
title: "Install failed".to_string(),
|
|
body: err.to_string(),
|
|
level: Level::Error,
|
|
icon: None,
|
|
progress: None,
|
|
actions: Vec::new(),
|
|
toast_only: false,
|
|
},
|
|
};
|
|
|
|
let session_id = install.session_id.unwrap_or_else(uuid::Uuid::nil);
|
|
actions.with_engine(|app| {
|
|
app.notifications
|
|
.finish_grouped_as_actions(&mut app.inbox, session_id, Some(spec));
|
|
});
|
|
|
|
if install.result.is_ok() {
|
|
// Before the refresh below so the version list never renders
|
|
// the moment where both copies read as active
|
|
if let Err(err) = oneclient_content::packages::reconcile_duplicate_activity(
|
|
cluster_id,
|
|
&state.services.content(),
|
|
)
|
|
.await
|
|
{
|
|
tracing::warn!(%err, "failed to resolve duplicate package versions");
|
|
}
|
|
|
|
super::invalidate_cluster_content_queries().await;
|
|
if let Some(world) = world {
|
|
super::invalidate_world_contents(cluster_id, world).await;
|
|
}
|
|
|
|
state
|
|
.services
|
|
.events
|
|
.signal(oneclient_events::Signal::ClustersChanged);
|
|
}
|
|
|
|
finish(&actions);
|
|
});
|
|
}
|
|
|
|
/// Shares the installs channel with [`Self::install_package`] so every row's
|
|
/// button reads as busy while one is being acted on
|
|
///
|
|
/// Recorded as a bundle override when a bundle owns the artifact otherwise
|
|
/// the next sync puts the version straight back
|
|
pub fn remove_package_version(
|
|
&self,
|
|
cluster_id: ClusterId,
|
|
provider: ProviderId,
|
|
project_id: impl Into<String>,
|
|
hash: impl Into<String>,
|
|
display_name: impl Into<String>,
|
|
) {
|
|
let (project_id, hash, display_name) =
|
|
(project_id.into(), hash.into(), display_name.into());
|
|
let actions = self.clone();
|
|
|
|
self.station
|
|
.clone()
|
|
.write_channel(AppChannel::Installs)
|
|
.installs
|
|
.begin(cluster_id, provider, project_id.clone());
|
|
|
|
spawn_forever(async move {
|
|
let finish = |actions: &Actions| {
|
|
actions
|
|
.station
|
|
.clone()
|
|
.write_channel(AppChannel::Installs)
|
|
.installs
|
|
.finish(cluster_id, provider, &project_id);
|
|
};
|
|
|
|
let Ok(state) = launcher::state() else {
|
|
finish(&actions);
|
|
return;
|
|
};
|
|
|
|
let events = state.services.events.clone();
|
|
match oneclient_core::delete_artifact(cluster_id, &hash, &state.services.content())
|
|
.await
|
|
{
|
|
Ok(()) => {
|
|
events.notify("Removed").body(display_name).send();
|
|
|
|
// The row's next state is read off the cluster's content
|
|
// so refresh that before the busy flag drops
|
|
super::invalidate_cluster_content_queries().await;
|
|
events.signal(oneclient_events::Signal::ClustersChanged);
|
|
}
|
|
Err(err) => events
|
|
.notify("Remove failed")
|
|
.body(err.to_string())
|
|
.error()
|
|
.send(),
|
|
}
|
|
|
|
finish(&actions);
|
|
});
|
|
}
|
|
|
|
pub fn install_bundle(
|
|
&self,
|
|
cluster_id: ClusterId,
|
|
bundle_name: impl Into<String>,
|
|
skip_compatibility: bool,
|
|
) {
|
|
let bundle_name = bundle_name.into();
|
|
spawn_forever(async move {
|
|
let Ok(state) = launcher::state() else { return };
|
|
let events = state.services.events.clone();
|
|
match off_ui({
|
|
let state = state.clone();
|
|
let bundle_name = bundle_name.clone();
|
|
async move {
|
|
oneclient_core::install_bundle(
|
|
cluster_id,
|
|
&bundle_name,
|
|
skip_compatibility,
|
|
state.bundles.as_ref(),
|
|
&state.services.content(),
|
|
)
|
|
.await
|
|
}
|
|
})
|
|
.await
|
|
{
|
|
Ok(_) => {
|
|
events
|
|
.notify("Installed")
|
|
.body(format!("Added {bundle_name}"))
|
|
.send();
|
|
events.signal(oneclient_events::Signal::ClustersChanged);
|
|
}
|
|
Err(err) => events
|
|
.notify("Install failed")
|
|
.body(err.to_string())
|
|
.error()
|
|
.send(),
|
|
}
|
|
});
|
|
}
|
|
|
|
pub fn apply_bundle_updates(&self, cluster_id: ClusterId) {
|
|
let actions = self.clone();
|
|
spawn_forever(async move {
|
|
let Ok(state) = launcher::state() else { return };
|
|
let result = match off_ui({
|
|
let state = state.clone();
|
|
async move {
|
|
oneclient_core::apply_bundle_updates(
|
|
cluster_id,
|
|
state.bundles.as_ref(),
|
|
&state.services.content(),
|
|
None,
|
|
)
|
|
.await
|
|
}
|
|
})
|
|
.await
|
|
{
|
|
Ok(result) => result,
|
|
Err(err) => {
|
|
state
|
|
.services
|
|
.events
|
|
.notify("Update failed")
|
|
.body(err.to_string())
|
|
.error()
|
|
.send();
|
|
return;
|
|
}
|
|
};
|
|
|
|
if result.settled() {
|
|
actions.record_bundle_checks([cluster_id]);
|
|
}
|
|
|
|
super::invalidate_cluster_queries().await;
|
|
if let Some(spec) =
|
|
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);
|
|
}
|
|
});
|
|
}
|
|
|
|
/// App-scoped on purpose: the copy has to outlive the settings page, which
|
|
/// unmounts as soon as the router swaps to the move screen
|
|
pub fn relocate(&self, plan: RelocationPlan) {
|
|
let state = match launcher::state() {
|
|
Ok(state) => state,
|
|
Err(err) => {
|
|
tracing::error!("move skipped, launcher not ready: {err:#}");
|
|
self.write_relocation(RelocationState {
|
|
plan: Some(plan),
|
|
result: Some(Err(format!("OneClient is not ready yet: {err}"))),
|
|
..RelocationState::default()
|
|
});
|
|
return;
|
|
}
|
|
};
|
|
|
|
self.write_relocation(RelocationState {
|
|
plan: Some(plan.clone()),
|
|
..RelocationState::default()
|
|
});
|
|
|
|
let mut station = self.station;
|
|
spawn_forever(async move {
|
|
let (progress, mut seen) = tokio::sync::watch::channel((0, 0));
|
|
let mut copy = std::pin::pin!(off_ui(async move {
|
|
oneclient_core::relocate::relocate(&state, &plan, |copied, total| {
|
|
let _ = progress.send((copied, total));
|
|
})
|
|
.await
|
|
}));
|
|
|
|
let mut show_progress = |(copied, total)| {
|
|
let mut guard = station.write_channel(AppChannel::Relocation);
|
|
guard.relocation.copied = copied;
|
|
guard.relocation.total = total;
|
|
};
|
|
let result = loop {
|
|
tokio::select! {
|
|
result = &mut copy => break result,
|
|
Ok(()) = seen.changed() => show_progress(*seen.borrow_and_update()),
|
|
}
|
|
};
|
|
show_progress(*seen.borrow());
|
|
|
|
if let Err(message) = &result {
|
|
tracing::error!("moving the data folder failed: {message}");
|
|
}
|
|
|
|
let moved = result.is_ok();
|
|
station
|
|
.write_channel(AppChannel::Relocation)
|
|
.relocation
|
|
.result = Some(result);
|
|
|
|
if moved {
|
|
super::invalidate_leftovers_queries().await;
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Leaves the move screen, which only the user can do and only once the
|
|
/// copy has settled
|
|
pub fn end_relocation(&self) {
|
|
self.write_relocation(RelocationState::default());
|
|
}
|
|
|
|
fn write_relocation(&self, relocation: RelocationState) {
|
|
self.station
|
|
.clone()
|
|
.write_channel(AppChannel::Relocation)
|
|
.relocation = relocation;
|
|
}
|
|
|
|
/// `syncing_bundles` gates every launch button so the readiness check must
|
|
/// happen before the flag is raised not inside the task
|
|
/// Lets the signed-out bar show at startup instead of on the first launch
|
|
pub fn check_sessions() {
|
|
let state = match launcher::state() {
|
|
Ok(state) => state,
|
|
Err(err) => {
|
|
tracing::error!("session check skipped, launcher not ready: {err:#}");
|
|
return;
|
|
}
|
|
};
|
|
|
|
spawn_forever(async move {
|
|
let signed_out = off_ui(async move { state.auth.check_expired_sessions().await }).await;
|
|
if signed_out {
|
|
super::invalidate_auth_queries(None).await;
|
|
}
|
|
});
|
|
}
|
|
|
|
pub fn sync_bundles(&self) {
|
|
let state = match launcher::state() {
|
|
Ok(state) => state,
|
|
Err(err) => {
|
|
tracing::error!("bundle sync skipped, launcher not ready: {err:#}");
|
|
return;
|
|
}
|
|
};
|
|
|
|
let actions = self.clone();
|
|
self.station
|
|
.clone()
|
|
.write_channel(AppChannel::Launcher)
|
|
.launcher
|
|
.syncing_bundles = true;
|
|
|
|
spawn_forever(async move {
|
|
off_ui(async move {
|
|
if let Err(err) = state.bundles.sync(&state.services.content()).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:#}");
|
|
}
|
|
})
|
|
.await;
|
|
|
|
actions
|
|
.station
|
|
.clone()
|
|
.write_channel(AppChannel::Launcher)
|
|
.launcher
|
|
.syncing_bundles = false;
|
|
super::invalidate_cluster_queries().await;
|
|
});
|
|
}
|
|
|
|
async fn resolve_bundle_updates_before_launch(
|
|
&self,
|
|
state: &Arc<oneclient_core::LauncherState>,
|
|
cluster_id: ClusterId,
|
|
) {
|
|
if !self.bundle_check_due(cluster_id) {
|
|
tracing::debug!(cluster_id, "bundle update check skipped, checked recently");
|
|
return;
|
|
}
|
|
|
|
let content = state.services.content();
|
|
|
|
if let Ok(false) = oneclient_core::cluster_has_bundle_content(cluster_id, &content).await {
|
|
tracing::debug!(cluster_id, "bundle update check skipped, no bundle content");
|
|
return;
|
|
}
|
|
|
|
self.station
|
|
.clone()
|
|
.write_channel(AppChannel::Launcher)
|
|
.launcher
|
|
.syncing_bundles = true;
|
|
|
|
let sync = {
|
|
let bundles = state.bundles.clone();
|
|
let content = content.clone();
|
|
off_ui(
|
|
async move { tokio::time::timeout(BUNDLE_SYNC_BUDGET, bundles.sync(&content)).await },
|
|
)
|
|
};
|
|
let synced = match sync.await {
|
|
Ok(Ok(_)) => true,
|
|
Ok(Err(err)) => {
|
|
tracing::warn!(
|
|
cluster_id,
|
|
error = %err,
|
|
"bundle catalog sync failed, launching against the cached catalog"
|
|
);
|
|
false
|
|
}
|
|
Err(_elapsed) => {
|
|
tracing::debug!(cluster_id, "bundle catalog sync exceeded its launch budget");
|
|
false
|
|
}
|
|
};
|
|
|
|
self.station
|
|
.clone()
|
|
.write_channel(AppChannel::Launcher)
|
|
.launcher
|
|
.syncing_bundles = false;
|
|
|
|
// Bundle content is forced, so the update mode never gates it and the
|
|
// modal never lists it
|
|
let deadline = Instant::now() + BUNDLE_APPLY_BUDGET;
|
|
let result = match off_ui({
|
|
let state = state.clone();
|
|
async move {
|
|
oneclient_core::apply_bundle_updates(
|
|
cluster_id,
|
|
state.bundles.as_ref(),
|
|
&state.services.content(),
|
|
Some(deadline),
|
|
)
|
|
.await
|
|
}
|
|
})
|
|
.await
|
|
{
|
|
Ok(result) => result,
|
|
Err(err) => {
|
|
tracing::warn!(
|
|
cluster_id,
|
|
error = %err,
|
|
"bundle update failed, launching anyway"
|
|
);
|
|
return;
|
|
}
|
|
};
|
|
|
|
if result.stopped_early {
|
|
tracing::warn!(
|
|
cluster_id,
|
|
"bundle updates ran out of their launch budget; the rest go at the next launch"
|
|
);
|
|
}
|
|
|
|
if synced && result.settled() {
|
|
self.record_bundle_checks([cluster_id]);
|
|
}
|
|
|
|
if result.updates_applied.is_empty()
|
|
&& result.additions_applied.is_empty()
|
|
&& result.removals_applied.is_empty()
|
|
{
|
|
return;
|
|
}
|
|
|
|
super::invalidate_cluster_queries().await;
|
|
}
|
|
|
|
/// The check runs and the cache is written whatever the mode says keeping
|
|
/// the package manager's "Update available" markers honest
|
|
///
|
|
/// Returns once the launch may proceed nothing is still downloading then
|
|
/// Never fails the launch every error logs and lets the game start
|
|
async fn resolve_package_updates_before_launch(
|
|
&self,
|
|
state: &Arc<oneclient_core::LauncherState>,
|
|
cluster_id: ClusterId,
|
|
) {
|
|
let content = state.services.content();
|
|
|
|
// Bounded a hanging network would otherwise hold the launch for
|
|
// reqwest's much longer timeouts
|
|
let check = match off_ui(async move {
|
|
tokio::time::timeout(
|
|
UPDATE_CHECK_BUDGET,
|
|
oneclient_core::refresh_browser_package_updates(cluster_id, &content),
|
|
)
|
|
.await
|
|
})
|
|
.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 pending = check.pending();
|
|
let mode = self.cluster_update_mode(state, cluster_id).await;
|
|
|
|
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
|
|
else {
|
|
return;
|
|
};
|
|
let chosen = self.prompt_package_updates(group).await;
|
|
if !chosen.is_empty() {
|
|
self.apply_updates_for_launch(state, &chosen).await;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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();
|
|
|
|
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()
|
|
}
|
|
|
|
/// One grouped progress notification so the pre-launch pause shows something
|
|
async fn apply_updates_for_launch(
|
|
&self,
|
|
state: &Arc<oneclient_core::LauncherState>,
|
|
updates: &[oneclient_core::BrowserPackageUpdate],
|
|
) {
|
|
let state = state.clone();
|
|
let updates = updates.to_vec();
|
|
let (applied, session_id) = off_ui(async move {
|
|
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,
|
|
// 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();
|
|
}
|
|
|
|
(applied, session.detach())
|
|
})
|
|
.await;
|
|
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(),
|
|
toast_only: true,
|
|
});
|
|
|
|
self.with_engine(|app| {
|
|
app.notifications
|
|
.finish_grouped_as_actions(&mut app.inbox, session_id, spec);
|
|
});
|
|
|
|
super::invalidate_cluster_queries().await;
|
|
|
|
if applied > 0 {
|
|
super::invalidate_cluster_queries().await;
|
|
}
|
|
}
|
|
|
|
async fn resolve_bundle_choices_before_launch(
|
|
&self,
|
|
state: &Arc<oneclient_core::LauncherState>,
|
|
cluster_id: ClusterId,
|
|
) {
|
|
let bundles = match off_ui({
|
|
let state = state.clone();
|
|
async move {
|
|
oneclient_content::bundles::pending_bundle_choices(
|
|
cluster_id,
|
|
state.bundles.as_ref(),
|
|
&state.services.content(),
|
|
)
|
|
.await
|
|
}
|
|
})
|
|
.await
|
|
{
|
|
Ok(bundles) if !bundles.is_empty() => bundles,
|
|
Ok(_) => return,
|
|
Err(err) => {
|
|
tracing::warn!(cluster_id, error = %err, "could not read the bundles to ask about, launching anyway");
|
|
return;
|
|
}
|
|
};
|
|
|
|
let choices_for: Vec<String> = bundles.iter().map(|a| a.manifest.name.clone()).collect();
|
|
let held = match oneclient_db::dao::cluster_bundle::list_bundle_tracked(
|
|
&state.services.db,
|
|
cluster_id,
|
|
)
|
|
.await
|
|
{
|
|
Ok(tracked) => tracked
|
|
.into_iter()
|
|
.filter_map(|row| row.bundle_name)
|
|
.collect(),
|
|
Err(err) => {
|
|
tracing::warn!(cluster_id, error = %err, "could not read what the cluster holds, launching without asking about bundles");
|
|
return;
|
|
}
|
|
};
|
|
let choices = BundleChoices {
|
|
held,
|
|
cluster_name: crate::install::cluster_display_name(cluster_id, &state.services).await,
|
|
bundles: bundles
|
|
.iter()
|
|
.map(|a| {
|
|
(
|
|
a.manifest.name.clone(),
|
|
crate::utils::bundle_display_name(a),
|
|
)
|
|
})
|
|
.collect(),
|
|
};
|
|
let (done, wait) = tokio::sync::oneshot::channel();
|
|
self.with_engine(move |state| {
|
|
state.notifications.open_bundle_choices(choices, done);
|
|
state.center_open = false;
|
|
});
|
|
let Ok(Some(chosen)) = wait.await else { return };
|
|
|
|
let choices: Vec<_> = choices_for
|
|
.iter()
|
|
.map(|name| (name.clone(), chosen.contains(name)))
|
|
.collect();
|
|
if let Err(err) = off_ui({
|
|
let state = state.clone();
|
|
async move {
|
|
oneclient_content::bundles::set_bundle_choices(
|
|
cluster_id,
|
|
&choices,
|
|
&state.services.content(),
|
|
)
|
|
.await
|
|
}
|
|
})
|
|
.await
|
|
{
|
|
tracing::warn!(cluster_id, error = %err, "could not record the bundle choices");
|
|
}
|
|
}
|
|
|
|
async fn resolve_optional_mods_before_launch(
|
|
&self,
|
|
state: &Arc<oneclient_core::LauncherState>,
|
|
cluster_id: ClusterId,
|
|
) -> OptionalModsOutcome {
|
|
let pending = match off_ui({
|
|
let state = state.clone();
|
|
async move {
|
|
oneclient_core::pending_optional_mods(
|
|
cluster_id,
|
|
state.bundles.as_ref(),
|
|
&state.services.content(),
|
|
)
|
|
.await
|
|
}
|
|
})
|
|
.await
|
|
{
|
|
Ok(pending) => pending,
|
|
Err(err) => {
|
|
tracing::warn!(
|
|
cluster_id,
|
|
error = %err,
|
|
"could not read queued optional mods, launching anyway"
|
|
);
|
|
return OptionalModsOutcome::Launch;
|
|
}
|
|
};
|
|
|
|
// `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 OptionalModsOutcome::Launch;
|
|
};
|
|
|
|
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;
|
|
});
|
|
|
|
wait.await.unwrap_or(OptionalModsOutcome::Launch)
|
|
}
|
|
|
|
async fn prompt_package_updates(
|
|
&self,
|
|
group: PackageUpdateGroup,
|
|
) -> Vec<oneclient_core::BrowserPackageUpdate> {
|
|
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;
|
|
});
|
|
|
|
wait.await.unwrap_or_default()
|
|
}
|
|
|
|
pub fn apply_package_update(&self, update: oneclient_core::BrowserPackageUpdate) {
|
|
let actions = self.clone();
|
|
spawn_forever(async move {
|
|
let Ok(state) = launcher::state() else { return };
|
|
|
|
let (result, session_id) = off_ui({
|
|
let update = update.clone();
|
|
async move {
|
|
let session = oneclient_events::GroupedProgressSession::start(
|
|
&state.services.events,
|
|
format!("Updating {}", update.display_name),
|
|
);
|
|
let child = session.child(
|
|
update.display_name.clone(),
|
|
1,
|
|
oneclient_events::TaskCategory::Packages,
|
|
);
|
|
|
|
let result = oneclient_core::apply_browser_package_update(
|
|
&update,
|
|
Some(&child),
|
|
&state.services.content(),
|
|
)
|
|
.await;
|
|
|
|
child.finish();
|
|
(result, session.detach())
|
|
}
|
|
})
|
|
.await;
|
|
|
|
let spec = match &result {
|
|
Ok(_) => NotificationSpec {
|
|
title: "Updated".to_string(),
|
|
body: format!(
|
|
"{} is now on {}",
|
|
update.display_name, update.latest_version_name
|
|
),
|
|
level: Level::Info,
|
|
icon: Some(IconType::DownloadCloud02),
|
|
progress: None,
|
|
actions: Vec::new(),
|
|
toast_only: true,
|
|
},
|
|
Err(err) => NotificationSpec {
|
|
title: "Update failed".to_string(),
|
|
body: err.to_string(),
|
|
level: Level::Error,
|
|
icon: None,
|
|
progress: None,
|
|
actions: Vec::new(),
|
|
toast_only: false,
|
|
},
|
|
};
|
|
|
|
actions.with_engine(|app| {
|
|
app.notifications
|
|
.finish_grouped_as_actions(&mut app.inbox, session_id, Some(spec));
|
|
});
|
|
|
|
// A failed update stays in the list so the user can retry
|
|
if result.is_ok() {
|
|
actions.with_engine(|app| {
|
|
app.notifications
|
|
.resolve_package_update(update.cluster_id, &update.hash);
|
|
});
|
|
super::invalidate_cluster_queries().await;
|
|
}
|
|
});
|
|
}
|
|
|
|
/// The package stays marked out of date only the modal stops asking and
|
|
/// only for this version
|
|
pub fn skip_package_update(&self, cluster_id: ClusterId, hash: impl Into<String>) {
|
|
let hash = hash.into();
|
|
let actions = self.clone();
|
|
spawn_forever(async move {
|
|
let Ok(state) = launcher::state() else { return };
|
|
if let Err(err) = oneclient_core::skip_browser_package_update(
|
|
cluster_id,
|
|
&hash,
|
|
&state.services.content(),
|
|
)
|
|
.await
|
|
{
|
|
tracing::warn!(error = %err, "failed to record a skipped package update");
|
|
}
|
|
|
|
actions.with_engine(|app| {
|
|
app.notifications.resolve_package_update(cluster_id, &hash);
|
|
});
|
|
super::invalidate_cluster_queries().await;
|
|
});
|
|
}
|
|
}
|
|
|
|
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();
|
|
|
|
events.game_stage(cluster_id, oneclient_events::LaunchStage::Checking);
|
|
|
|
let account = match state.auth.default_account_for_launch().await {
|
|
Ok(account) => account,
|
|
Err(err) => {
|
|
events.game_failed(cluster_id, format!("{err:#}"));
|
|
return;
|
|
}
|
|
};
|
|
|
|
let Some(account) = account else {
|
|
events.game_failed(
|
|
cluster_id,
|
|
"Add a Minecraft account before launching.".to_string(),
|
|
);
|
|
return;
|
|
};
|
|
|
|
// The renewal may have just marked the account signed out the accounts
|
|
// page and the status bar must pick that up while the game still starts
|
|
if account.needs_sign_in() {
|
|
super::invalidate_auth_queries(None).await;
|
|
}
|
|
|
|
crate::microsoft_java::offer_for_pinned_cluster(actions, cluster_id).await;
|
|
|
|
let content = state.services.content();
|
|
tokio::spawn(async move {
|
|
if let Err(err) = oneclient_content::packages::refresh_bad_mods(&content).await {
|
|
tracing::warn!(%err, "bad mods list refresh failed, keeping the last one");
|
|
}
|
|
});
|
|
|
|
// Before the game process never after Minecraft reads its mods once at
|
|
// startup
|
|
actions
|
|
.resolve_bundle_choices_before_launch(&state, cluster_id)
|
|
.await;
|
|
actions
|
|
.resolve_bundle_updates_before_launch(&state, cluster_id)
|
|
.await;
|
|
actions
|
|
.resolve_package_updates_before_launch(&state, cluster_id)
|
|
.await;
|
|
|
|
// After the updates so the player never faces two modals at once
|
|
if actions
|
|
.resolve_optional_mods_before_launch(&state, cluster_id)
|
|
.await
|
|
== OptionalModsOutcome::Cancel
|
|
{
|
|
events.game_stage(cluster_id, oneclient_events::LaunchStage::Exited);
|
|
return;
|
|
}
|
|
|
|
off_ui(async move {
|
|
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
|
|
if err.indicates_missing_files() {
|
|
repair_and_relaunch(&state, cluster_id, &account, err).await;
|
|
return;
|
|
}
|
|
|
|
events.game_failed(cluster_id, format!("{err:#}"));
|
|
}
|
|
})
|
|
.await;
|
|
}
|
|
|
|
/// Only ever one retry if the game still will not start after a full rehash and
|
|
/// repair the problem is not missing files
|
|
async fn repair_and_relaunch(
|
|
state: &std::sync::Arc<oneclient_core::LauncherState>,
|
|
cluster_id: ClusterId,
|
|
account: &oneclient_auth::MinecraftAccount,
|
|
original: oneclient_core::LauncherError,
|
|
) {
|
|
let events = state.services.events.clone();
|
|
|
|
tracing::warn!(
|
|
cluster_id,
|
|
"launch failed on a missing file; repairing: {original:#}"
|
|
);
|
|
events
|
|
.notify("Repairing installation")
|
|
.body("Some game files are missing. Checking and re-downloading them.")
|
|
.send();
|
|
|
|
let report = match oneclient_core::verify_cluster_files(state, cluster_id).await {
|
|
Ok(report) => report,
|
|
Err(repair_err) => {
|
|
tracing::error!(cluster_id, "repair failed: {repair_err:#}");
|
|
// Lead with the original failure the repair failure explains why
|
|
// it was not fixed
|
|
events.game_failed(
|
|
cluster_id,
|
|
format!("{original:#} (repair also failed: {repair_err:#})"),
|
|
);
|
|
return;
|
|
}
|
|
};
|
|
|
|
events
|
|
.notify("Repair complete")
|
|
.body(report.summary())
|
|
.send();
|
|
|
|
if let Err(err) = oneclient_core::launch_cluster(state, cluster_id, account, true).await {
|
|
tracing::error!(cluster_id, "launch failed again after repair: {err:#}");
|
|
events.game_failed(cluster_id, format!("{err:#}"));
|
|
}
|
|
}
|
|
|
|
fn notify_import(events: &oneclient_events::EventBus, report: &LocalImportReport, deferred: bool) {
|
|
let failed = report.failed.len();
|
|
|
|
let Some(mut body) = (match report.imported.as_slice() {
|
|
[] => None,
|
|
[only] => Some(format!("Added {}", only.file_name)),
|
|
rows => Some(format!("Added {} files", rows.len())),
|
|
}) else {
|
|
let reason = report.failed.first().map_or_else(
|
|
|| "Nothing could be read".to_string(),
|
|
|(_, err)| err.to_string(),
|
|
);
|
|
|
|
events.notify("Import failed").body(reason).error().send();
|
|
return;
|
|
};
|
|
|
|
if failed > 0 {
|
|
body.push_str(&format!(", {failed} could not be read"));
|
|
}
|
|
|
|
if deferred {
|
|
body.push_str(". Minecraft is running, so it will be there at the next launch");
|
|
}
|
|
|
|
if failed == 0 {
|
|
events.notify("Imported").body(body).send();
|
|
return;
|
|
}
|
|
|
|
events.notify("Imported").body(body).error().send();
|
|
}
|
|
|
|
#[must_use = "the notification is not raised until `.send()` is called"]
|
|
pub struct NotificationBuilder {
|
|
actions: Actions,
|
|
spec: NotificationSpec,
|
|
}
|
|
|
|
impl NotificationBuilder {
|
|
pub fn body(mut self, body: impl Into<String>) -> Self {
|
|
self.spec.body = body.into();
|
|
self
|
|
}
|
|
|
|
pub fn level(mut self, level: Level) -> Self {
|
|
self.spec.level = level;
|
|
self
|
|
}
|
|
|
|
pub fn info(self) -> Self {
|
|
self.level(Level::Info)
|
|
}
|
|
|
|
pub fn error(self) -> Self {
|
|
self.level(Level::Error)
|
|
}
|
|
|
|
pub fn icon(mut self, icon: IconType) -> Self {
|
|
self.spec.icon = Some(icon);
|
|
self
|
|
}
|
|
|
|
pub fn progress(mut self, current: u64, total: u64) -> Self {
|
|
self.spec.progress = Some((current, total));
|
|
self
|
|
}
|
|
|
|
pub fn action(mut self, action: NotificationAction) -> Self {
|
|
self.spec.actions.push(action);
|
|
self
|
|
}
|
|
|
|
pub fn actions(mut self, actions: impl IntoIterator<Item = NotificationAction>) -> Self {
|
|
self.spec.actions = actions.into_iter().collect();
|
|
self
|
|
}
|
|
|
|
/// Marks the notice as ephemeral: it shows as a toast but never sticks
|
|
/// around in the notification center
|
|
pub fn toast_only(mut self) -> Self {
|
|
self.spec.toast_only = true;
|
|
self
|
|
}
|
|
|
|
pub fn send(self) {
|
|
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 a_cluster_that_was_never_checked_is_due() {
|
|
assert!(BundleCheckLog::default().due(1, Instant::now()));
|
|
}
|
|
|
|
#[test]
|
|
fn a_relaunch_inside_the_window_reuses_the_last_check() {
|
|
let mut log = BundleCheckLog::default();
|
|
let now = Instant::now();
|
|
log.record(1, now);
|
|
|
|
assert!(!log.due(1, now));
|
|
assert!(!log.due(1, now + BUNDLE_CHECK_INTERVAL / 2));
|
|
}
|
|
|
|
#[test]
|
|
fn the_window_reopens_once_the_interval_has_passed() {
|
|
let mut log = BundleCheckLog::default();
|
|
let now = Instant::now();
|
|
log.record(1, now);
|
|
|
|
assert!(log.due(1, now + BUNDLE_CHECK_INTERVAL));
|
|
assert!(log.due(1, now + BUNDLE_CHECK_INTERVAL * 2));
|
|
}
|
|
|
|
#[test]
|
|
fn clusters_are_throttled_independently() {
|
|
let mut log = BundleCheckLog::default();
|
|
let now = Instant::now();
|
|
log.record(1, now);
|
|
|
|
assert!(!log.due(1, now));
|
|
assert!(log.due(2, now));
|
|
}
|
|
|
|
#[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",
|
|
);
|
|
}
|
|
}
|
|
|
|
const JDK_25: &str = r"C:\jdk-25\bin\javaw.exe";
|
|
|
|
fn profile(name: &str, java_path: Option<&str>) -> GameSettingsProfile {
|
|
GameSettingsProfile {
|
|
name: name.into(),
|
|
java_path: java_path.map(Into::into),
|
|
..GameSettingsProfile::default_global_profile()
|
|
}
|
|
}
|
|
|
|
fn cluster(id: i64, name: &str, profile_name: Option<&str>) -> Cluster {
|
|
Cluster {
|
|
id,
|
|
name: name.into(),
|
|
folder_name: name.into(),
|
|
setting_profile_name: profile_name.map(Into::into),
|
|
mc_version: "26.2".into(),
|
|
mc_loader: oneclient_common::domain::GameLoader::default(),
|
|
mc_loader_version: None,
|
|
stage: ClusterStage::default(),
|
|
created_at: None,
|
|
last_played: None,
|
|
overall_played: Duration::ZERO,
|
|
linked_modpack_hash: None,
|
|
kind: oneclient_db::models::ClusterKind::OneClient,
|
|
user_created: false,
|
|
description: None,
|
|
tags: Vec::new(),
|
|
cover_path: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_runtime_nobody_pinned_is_free_to_go() {
|
|
let profiles = vec![profile("26.2 Fabric", None)];
|
|
let clusters = vec![cluster(1, "26.2 Fabric", Some("26.2 Fabric"))];
|
|
|
|
assert!(clusters_pinned_to_java(&profiles, &clusters, JDK_25).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn a_pinned_runtime_is_reported_under_its_cluster_name() {
|
|
let profiles = vec![profile("26.2 Fabric", Some(JDK_25))];
|
|
let clusters = vec![cluster(1, "26.2 Fabric", Some("26.2 Fabric"))];
|
|
|
|
assert_eq!(
|
|
clusters_pinned_to_java(&profiles, &clusters, JDK_25),
|
|
vec!["26.2 Fabric".to_string()],
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn only_the_runtime_being_removed_counts() {
|
|
let profiles = vec![
|
|
profile("21 pinned", Some(r"C:\jdk-21\bin\javaw.exe")),
|
|
profile("25 pinned", Some(JDK_25)),
|
|
];
|
|
let clusters = vec![
|
|
cluster(1, "Old pack", Some("21 pinned")),
|
|
cluster(2, "New pack", Some("25 pinned")),
|
|
];
|
|
|
|
assert_eq!(
|
|
clusters_pinned_to_java(&profiles, &clusters, JDK_25),
|
|
vec!["New pack".to_string()],
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn every_cluster_holding_the_runtime_is_named() {
|
|
let profiles = vec![profile("a", Some(JDK_25)), profile("b", Some(JDK_25))];
|
|
let clusters = vec![
|
|
cluster(1, "Alpha", Some("a")),
|
|
cluster(2, "Beta", Some("b")),
|
|
];
|
|
|
|
assert_eq!(
|
|
clusters_pinned_to_java(&profiles, &clusters, JDK_25),
|
|
vec!["Alpha".to_string(), "Beta".to_string()],
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_profile_no_cluster_claims_falls_back_to_its_own_name() {
|
|
let profiles = vec![profile("orphaned", Some(JDK_25))];
|
|
|
|
assert_eq!(
|
|
clusters_pinned_to_java(&profiles, &[], JDK_25),
|
|
vec!["orphaned".to_string()],
|
|
);
|
|
}
|
|
}
|