fix(ui): disable the launch button on click, not until first message response
This commit is contained in:
parent
038dc94725
commit
d61f6e6655
3 changed files with 143 additions and 23 deletions
|
|
@ -11,6 +11,7 @@
|
|||
//! app-scoped, not component-scoped.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use freya::prelude::spawn_forever;
|
||||
use freya::radio::RadioStation;
|
||||
|
|
@ -29,6 +30,12 @@ use crate::notifications::{
|
|||
};
|
||||
use crate::state::{AppChannel, AppState, AsyncStatus};
|
||||
|
||||
/// How long the launch button stays disabled after a click, at minimum.
|
||||
///
|
||||
/// Over-disabling is the cheaper mistake: a button that is dead for two seconds
|
||||
/// after a failed launch beats one that lets a second game start.
|
||||
const LAUNCH_HOLD: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Asks the event pump to do something it alone owns.
|
||||
///
|
||||
/// The pump holds the toast timers, so an action that adds or removes a toast
|
||||
|
|
@ -578,33 +585,40 @@ impl Actions {
|
|||
|
||||
// --- game --------------------------------------------------------------
|
||||
|
||||
/// Starts a launch, at most one per cluster.
|
||||
///
|
||||
/// The pending flag is raised synchronously, before the first `await`, so
|
||||
/// the button is already disabled by the time a second click of a
|
||||
/// double-click could be dispatched; waiting for core to report `Checking`
|
||||
/// left a few hundred ms where every click spawned its own game. The claim
|
||||
/// itself is the guard, so a click that bypasses the disabled attribute
|
||||
/// (keyboard activation, replayed events) is dropped here too.
|
||||
pub fn launch_cluster(&self, cluster_id: ClusterId) {
|
||||
let claimed = self
|
||||
.station
|
||||
.clone()
|
||||
.write_channel(AppChannel::Game)
|
||||
.game
|
||||
.begin_launch(cluster_id);
|
||||
if !claimed {
|
||||
return;
|
||||
}
|
||||
|
||||
let station = self.station;
|
||||
spawn_forever(async move {
|
||||
let Ok(state) = launcher::state() else { return };
|
||||
let events = state.services.events.clone();
|
||||
let started = Instant::now();
|
||||
launch(cluster_id).await;
|
||||
|
||||
// Renews a lapsed token before launching.
|
||||
let account = match state.auth.default_account_for_launch().await {
|
||||
Ok(account) => account,
|
||||
Err(err) => {
|
||||
events.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
|
||||
{
|
||||
events.game_failed(cluster_id, format!("{err:#}"));
|
||||
}
|
||||
}
|
||||
None => events.game_failed(
|
||||
cluster_id,
|
||||
"Add a Minecraft account before launching.".to_string(),
|
||||
),
|
||||
// A launch that fails instantly 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);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -870,6 +884,36 @@ impl Actions {
|
|||
}
|
||||
}
|
||||
|
||||
/// The launch itself. Failures are reported as game events, so the caller only
|
||||
/// has to know that the attempt is over.
|
||||
async fn launch(cluster_id: ClusterId) {
|
||||
let Ok(state) = launcher::state() else { return };
|
||||
let events = state.services.events.clone();
|
||||
|
||||
// Renews a lapsed token before launching.
|
||||
let account = match state.auth.default_account_for_launch().await {
|
||||
Ok(account) => account,
|
||||
Err(err) => {
|
||||
events.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
|
||||
{
|
||||
events.game_failed(cluster_id, format!("{err:#}"));
|
||||
}
|
||||
}
|
||||
None => events.game_failed(
|
||||
cluster_id,
|
||||
"Add a Minecraft account before launching.".to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use = "the notification is not raised until `.send()` is called"]
|
||||
pub struct NotificationBuilder {
|
||||
actions: Actions,
|
||||
|
|
|
|||
|
|
@ -130,6 +130,12 @@ pub struct GameState {
|
|||
pub stages: HashMap<i64, LaunchStage>,
|
||||
pub error: Option<String>,
|
||||
pub logs: HashMap<i64, Arc<Vec<Arc<str>>>>,
|
||||
/// Clusters whose launch was started from the UI but has not been answered
|
||||
/// by core yet.
|
||||
///
|
||||
/// Core takes a few hundred ms to report [`LaunchStage::Checking`], and
|
||||
/// every click that lands in that window used to spawn its own game.
|
||||
pending: HashSet<i64>,
|
||||
}
|
||||
|
||||
impl GameState {
|
||||
|
|
@ -138,6 +144,25 @@ impl GameState {
|
|||
self.stages.get(&cluster_id).copied()
|
||||
}
|
||||
|
||||
/// Claims the launch for this cluster, returning false if one is already in
|
||||
/// flight — the re-entrancy guard behind the button's disabled state.
|
||||
pub fn begin_launch(&mut self, cluster_id: i64) -> bool {
|
||||
if self.is_active(cluster_id) || self.is_launch_pending(cluster_id) {
|
||||
return false;
|
||||
}
|
||||
self.pending.insert(cluster_id);
|
||||
true
|
||||
}
|
||||
|
||||
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)
|
||||
|
|
@ -158,3 +183,51 @@ impl GameState {
|
|||
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 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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,9 @@ pub fn launch_button_state(
|
|||
Some(LaunchStage::Downloading) => ("Downloading", false),
|
||||
Some(LaunchStage::Launching) => ("Launching", false),
|
||||
Some(LaunchStage::Running) => ("Running", false),
|
||||
// The click's own flag, raised before core has said anything and held
|
||||
// for a beat after a failure so a burst of clicks starts one game.
|
||||
_ if game.is_launch_pending(cluster_id) => ("Launching", false),
|
||||
_ => ("Launch", true),
|
||||
};
|
||||
// Block launching while the startup bundle download is still running.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue