//! [`AppChannel`] gives per-concern subscription a single `watch` channel woke every //! consumer and deep-cloned the inbox on every event use std::collections::{HashMap, HashSet}; use std::sync::Arc; use freya::radio::RadioChannel; use oneclient_common::domain::ProviderId; use oneclient_core::relocate::{RelocationOutcome, RelocationPlan}; use oneclient_core::settings::LauncherSettings; use oneclient_events::LaunchStage; use crate::notifications::{InboxEntry, NotificationState, PendingPrompt}; /// Writing through a channel wakes only the components that subscribed to it #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] pub enum AppChannel { Launcher, Settings, Notifications, Game, AccountSwitcher, ControlCenter, MicrosoftLogin, Installs, StorageScan, Relocation, PendingLaunch, } impl RadioChannel for AppChannel {} #[derive(Default)] pub struct AppState { pub launcher: LauncherInit, pub settings: SettingsState, /// The engine itself not a snapshot both the pump and UI actions fold into it pub notifications: NotificationState, /// Held beside the engine which folds events into it by `&mut` so the engine /// stays a plain state machine with no channels of its own pub inbox: Vec, pub prompt: Option, pub center_open: bool, pub game: GameState, pub account_switcher_open: bool, pub control_center_open: bool, pub microsoft_login: Option, pub installs: InstallState, pub storage_scan: Option, pub relocation: RelocationState, pub pending_launch: Option, } /// A move of the data folder owns the whole window while it runs: the router /// swaps to [`crate::routes::Route::Relocating`] so nothing else can be touched /// until the copy settles #[derive(Clone, Debug, Default, PartialEq)] pub struct RelocationState { /// What is being moved where; cleared when the user leaves the move screen pub plan: Option, /// Bytes written so far and the total to write, reported by the copy itself pub copied: u64, pub total: u64, /// Set once the copy settles, which turns the screen into its result pub result: Option>, } /// In-flight installs so the button that started one stays disabled until it lands #[derive(Clone, Debug, Default, PartialEq)] pub struct InstallState { pending: HashSet<(i64, ProviderId, String)>, } impl InstallState { pub fn begin(&mut self, cluster_id: i64, provider: ProviderId, project_id: String) { self.pending.insert((cluster_id, provider, project_id)); } pub fn finish(&mut self, cluster_id: i64, provider: ProviderId, project_id: &str) { self.pending .remove(&(cluster_id, provider, project_id.to_string())); } #[must_use] pub fn is_installing(&self, cluster_id: i64, provider: ProviderId, project_id: &str) -> bool { self.pending .contains(&(cluster_id, provider, project_id.to_string())) } } #[derive(Clone, Debug, Default)] pub struct LauncherInit { pub ready: bool, pub fetching: bool, pub syncing_bundles: bool, pub error: Option, pub data_dir: String, pub snapshots: usize, pub needs_location: bool, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum AsyncStatus { #[default] Idle, Loading, Ready, Error, } #[derive(Clone, Debug, Default)] pub struct SettingsState { pub settings: LauncherSettings, pub status: AsyncStatus, pub saving: bool, pub error: Option, } /// Rendered inside the sign-in modal rather than as a toast the core reports it /// as ordinary progress and has no opinion about where it is shown #[derive(Clone, Debug, PartialEq, Eq)] pub struct LoginProgress { pub label: String, pub current: u64, pub total: u64, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct StorageScanProgress { pub label: String, pub current: u64, pub total: u64, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum LaunchBlock { Starting(i64), Running(i64), } impl LaunchBlock { #[must_use] pub fn cluster_id(self) -> i64 { match self { Self::Starting(id) | Self::Running(id) => id, } } } #[derive(Clone, Debug, Default, PartialEq)] pub struct GameState { pub stages: HashMap, pub error: Option, pub logs: HashMap>>>, /// Launches started from the UI but not yet answered by core which takes a few /// hundred ms every click in that window otherwise spawns its own game pending: HashSet, } impl GameState { #[must_use] pub fn stage(&self, cluster_id: i64) -> Option { self.stages.get(&cluster_id).copied() } /// Returns false if a launch is already in flight the re-entrancy guard pub fn begin_launch(&mut self, cluster_id: i64) -> bool { if self.block_for(cluster_id).is_some() { return false; } self.pending.insert(cluster_id); true } fn block_for(&self, cluster_id: i64) -> Option { if self.is_running(cluster_id) { Some(LaunchBlock::Running(cluster_id)) } else if self.is_active(cluster_id) || self.is_launch_pending(cluster_id) { Some(LaunchBlock::Starting(cluster_id)) } else { None } } #[must_use] pub fn launch_block(&self, cluster_id: i64, parallel: bool) -> Option { if let Some(block) = self.block_for(cluster_id) { return Some(block); } if parallel { return None; } self.stages .keys() .chain(self.pending.iter()) .copied() .filter(|id| *id != cluster_id) .find_map(|id| self.block_for(id)) } pub fn finish_launch(&mut self, cluster_id: i64) { self.pending.remove(&cluster_id); } #[must_use] pub fn is_launch_pending(&self, cluster_id: i64) -> bool { self.pending.contains(&cluster_id) } #[must_use] pub fn is_busy(&self, cluster_id: i64) -> bool { self.stage(cluster_id).is_some_and(LaunchStage::is_busy) } pub fn running_clusters(&self) -> impl Iterator + '_ { self.stages .iter() .filter(|(_, stage)| **stage == LaunchStage::Running) .map(|(id, _)| *id) } #[must_use] pub fn is_running(&self, cluster_id: i64) -> bool { self.stage(cluster_id) == Some(LaunchStage::Running) } #[must_use] pub fn is_active(&self, cluster_id: i64) -> bool { matches!(self.stage(cluster_id), Some(s) if s != LaunchStage::Exited) } #[must_use] pub fn logs_for(&self, cluster_id: i64) -> Arc>> { self.logs.get(&cluster_id).cloned().unwrap_or_default() } } #[cfg(test)] mod tests { use super::*; use crate::view::app::launch_button_state; #[test] fn a_second_click_is_refused_before_core_answers() { let mut game = GameState::default(); assert!(game.begin_launch(1)); assert!(!game.begin_launch(1)); assert!(game.begin_launch(2)); } #[test] fn a_running_cluster_cannot_be_launched_again() { let mut game = GameState::default(); game.stages.insert(1, LaunchStage::Running); assert!(!game.begin_launch(1)); } #[test] fn the_claim_is_released_when_the_launch_settles() { let mut game = GameState::default(); assert!(game.begin_launch(1)); game.finish_launch(1); assert!(game.begin_launch(1)); } #[test] fn a_shortcut_is_told_which_cluster_is_in_the_way() { let mut game = GameState::default(); game.stages.insert(1, LaunchStage::Running); assert_eq!(game.launch_block(1, false), Some(LaunchBlock::Running(1))); assert_eq!(game.launch_block(2, false), Some(LaunchBlock::Running(1))); assert_eq!(game.launch_block(2, true), None); } #[test] fn a_game_that_is_still_coming_up_still_blocks() { let mut game = GameState::default(); game.stages.insert(1, LaunchStage::Downloading); assert_eq!(game.launch_block(1, false), Some(LaunchBlock::Starting(1))); assert_eq!(game.launch_block(2, false), Some(LaunchBlock::Starting(1))); } #[test] fn a_claim_with_no_stage_yet_blocks_too() { let mut game = GameState::default(); game.begin_launch(1); assert_eq!(game.launch_block(1, false), Some(LaunchBlock::Starting(1))); assert_eq!(game.launch_block(2, false), Some(LaunchBlock::Starting(1))); } #[test] fn an_exited_game_is_out_of_the_way() { let mut game = GameState::default(); game.stages.insert(1, LaunchStage::Exited); assert_eq!(game.launch_block(2, false), None); assert_eq!(game.launch_block(1, false), None); } #[test] fn the_button_disables_on_the_claim_alone() { let mut game = GameState::default(); assert_eq!(launch_button_state(&game, 1, false), ("Launch", true)); game.begin_launch(1); assert_eq!(launch_button_state(&game, 1, false), ("Launching", false)); // Held past a failure which parks the stage at `Exited` game.stages.insert(1, LaunchStage::Exited); assert_eq!(launch_button_state(&game, 1, false), ("Launching", false)); game.finish_launch(1); assert_eq!(launch_button_state(&game, 1, false), ("Launch", true)); } }