fix(ui): disable the launch button on click, not until first message response

This commit is contained in:
LynithDev 2026-08-01 13:13:35 +02:00
parent 038dc94725
commit d61f6e6655
3 changed files with 143 additions and 23 deletions

View file

@ -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,

View file

@ -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));
}
}

View file

@ -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.