From e4376c2b15127641706c727805577d42aac6243f Mon Sep 17 00:00:00 2001 From: "Calum H." Date: Fri, 26 Jun 2026 16:50:03 +0100 Subject: [PATCH] fix: clamp negative playtime (#6519) * fix: clamp negative playtime * fix: remove triggers use instant --- apps/app-frontend/src/App.vue | 11 ++-- ...0249bdc4023c0b3d0c683318079ce93c9dd56.json | 12 ++++ ...8587c035eb94098831c690739393870d4e814.json | 12 ---- ...c9c425cfac0520785d4e25d687c2d9ea82088.json | 12 ---- ...c2f984ce07a6d9498f19c24c6e2c031122fc5.json | 12 ++++ ...60000_clamp-negative-instance-playtime.sql | 12 ++++ .../adapters/sqlite/instance_rows.rs | 18 ++++-- .../instances/commands/launch_context.rs | 39 +++++++++++-- .../src/state/instances/model/instance.rs | 12 ++++ .../app-lib/src/state/legacy_converter.rs | 10 +++- packages/app-lib/src/state/process.rs | 55 ++++++++++--------- 11 files changed, 135 insertions(+), 70 deletions(-) create mode 100644 packages/app-lib/.sqlx/query-3af5ce6fdc84ce12da06b92950d0249bdc4023c0b3d0c683318079ce93c9dd56.json delete mode 100644 packages/app-lib/.sqlx/query-44f82d6bbf66f685e20bee5c9c58587c035eb94098831c690739393870d4e814.json delete mode 100644 packages/app-lib/.sqlx/query-8c0ef949dc78aa5d58b8fcd3e04c9c425cfac0520785d4e25d687c2d9ea82088.json create mode 100644 packages/app-lib/.sqlx/query-d0bdfcba97a212ad80f56c1884ec2f984ce07a6d9498f19c24c6e2c031122fc5.json create mode 100644 packages/app-lib/migrations/20260626160000_clamp-negative-instance-playtime.sql diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index 83e2312c44..1f39661978 100644 --- a/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue @@ -1341,12 +1341,11 @@ async function processPendingSurveys() { const creds = await getCreds().catch(handleError) const userId = creds?.user_id - const instances = await list().catch(handleError) - const isActivePlayer = - instances.findIndex( - (instance) => - isWithinLastTwoWeeks(instance.last_played) && !isWithinLastTwoWeeks(instance.created), - ) >= 0 + const instances = (await list().catch(handleError)) ?? [] + const isActivePlayer = instances.some( + (instance) => + isWithinLastTwoWeeks(instance.last_played) && !isWithinLastTwoWeeks(instance.created), + ) let surveys = [] try { diff --git a/packages/app-lib/.sqlx/query-3af5ce6fdc84ce12da06b92950d0249bdc4023c0b3d0c683318079ce93c9dd56.json b/packages/app-lib/.sqlx/query-3af5ce6fdc84ce12da06b92950d0249bdc4023c0b3d0c683318079ce93c9dd56.json new file mode 100644 index 0000000000..105a1c239c --- /dev/null +++ b/packages/app-lib/.sqlx/query-3af5ce6fdc84ce12da06b92950d0249bdc4023c0b3d0c683318079ce93c9dd56.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tUPDATE instances\n\t\tSET\n\t\t\tsubmitted_time_played = CASE\n\t\t\t\tWHEN submitted_time_played < 0 THEN ?\n\t\t\t\tWHEN submitted_time_played > ? THEN ?\n\t\t\t\tELSE submitted_time_played + ?\n\t\t\tEND,\n\t\t\trecent_time_played = 0,\n\t\t\tmodified = ?\n\t\tWHERE id = ?\n\t\t", + "describe": { + "columns": [], + "parameters": { + "Right": 6 + }, + "nullable": [] + }, + "hash": "3af5ce6fdc84ce12da06b92950d0249bdc4023c0b3d0c683318079ce93c9dd56" +} diff --git a/packages/app-lib/.sqlx/query-44f82d6bbf66f685e20bee5c9c58587c035eb94098831c690739393870d4e814.json b/packages/app-lib/.sqlx/query-44f82d6bbf66f685e20bee5c9c58587c035eb94098831c690739393870d4e814.json deleted file mode 100644 index f52de035a3..0000000000 --- a/packages/app-lib/.sqlx/query-44f82d6bbf66f685e20bee5c9c58587c035eb94098831c690739393870d4e814.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n\t\tUPDATE instances\n\t\tSET\n\t\t\tsubmitted_time_played = submitted_time_played + ?,\n\t\t\trecent_time_played = 0,\n\t\t\tmodified = ?\n\t\tWHERE id = ?\n\t\t", - "describe": { - "columns": [], - "parameters": { - "Right": 3 - }, - "nullable": [] - }, - "hash": "44f82d6bbf66f685e20bee5c9c58587c035eb94098831c690739393870d4e814" -} diff --git a/packages/app-lib/.sqlx/query-8c0ef949dc78aa5d58b8fcd3e04c9c425cfac0520785d4e25d687c2d9ea82088.json b/packages/app-lib/.sqlx/query-8c0ef949dc78aa5d58b8fcd3e04c9c425cfac0520785d4e25d687c2d9ea82088.json deleted file mode 100644 index 64eb7c2150..0000000000 --- a/packages/app-lib/.sqlx/query-8c0ef949dc78aa5d58b8fcd3e04c9c425cfac0520785d4e25d687c2d9ea82088.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n\t\tUPDATE instances\n\t\tSET recent_time_played = recent_time_played + ?, modified = ?\n\t\tWHERE id = ?\n\t\t", - "describe": { - "columns": [], - "parameters": { - "Right": 3 - }, - "nullable": [] - }, - "hash": "8c0ef949dc78aa5d58b8fcd3e04c9c425cfac0520785d4e25d687c2d9ea82088" -} diff --git a/packages/app-lib/.sqlx/query-d0bdfcba97a212ad80f56c1884ec2f984ce07a6d9498f19c24c6e2c031122fc5.json b/packages/app-lib/.sqlx/query-d0bdfcba97a212ad80f56c1884ec2f984ce07a6d9498f19c24c6e2c031122fc5.json new file mode 100644 index 0000000000..9e1357f74c --- /dev/null +++ b/packages/app-lib/.sqlx/query-d0bdfcba97a212ad80f56c1884ec2f984ce07a6d9498f19c24c6e2c031122fc5.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tUPDATE instances\n\t\tSET\n\t\t\trecent_time_played = CASE\n\t\t\t\tWHEN recent_time_played < 0 THEN ?\n\t\t\t\tWHEN recent_time_played > ? THEN ?\n\t\t\t\tELSE recent_time_played + ?\n\t\t\tEND,\n\t\t\tmodified = ?\n\t\tWHERE id = ?\n\t\t", + "describe": { + "columns": [], + "parameters": { + "Right": 6 + }, + "nullable": [] + }, + "hash": "d0bdfcba97a212ad80f56c1884ec2f984ce07a6d9498f19c24c6e2c031122fc5" +} diff --git a/packages/app-lib/migrations/20260626160000_clamp-negative-instance-playtime.sql b/packages/app-lib/migrations/20260626160000_clamp-negative-instance-playtime.sql new file mode 100644 index 0000000000..d5a624f251 --- /dev/null +++ b/packages/app-lib/migrations/20260626160000_clamp-negative-instance-playtime.sql @@ -0,0 +1,12 @@ +UPDATE instances +SET + submitted_time_played = CASE + WHEN submitted_time_played < 0 THEN 0 + ELSE submitted_time_played + END, + recent_time_played = CASE + WHEN recent_time_played < 0 THEN 0 + ELSE recent_time_played + END +WHERE submitted_time_played < 0 + OR recent_time_played < 0; diff --git a/packages/app-lib/src/state/instances/adapters/sqlite/instance_rows.rs b/packages/app-lib/src/state/instances/adapters/sqlite/instance_rows.rs index 0ed985183a..5a4a0e6141 100644 --- a/packages/app-lib/src/state/instances/adapters/sqlite/instance_rows.rs +++ b/packages/app-lib/src/state/instances/adapters/sqlite/instance_rows.rs @@ -3,7 +3,7 @@ use crate::state::instances::{ ContentSet, ContentSetStatus, ContentSourceKind, Instance, InstanceLaunchContext, InstanceLaunchOverrides, - InstanceLaunchOverridesData, InstanceLink, + InstanceLaunchOverridesData, InstanceLink, playtime_to_storage, }; use crate::state::{ InstanceInstallStage, LauncherFeatureVersion, ModLoader, ReleaseChannel, @@ -831,8 +831,12 @@ pub(crate) async fn insert_instance( let created = instance.created.timestamp(); let modified = instance.modified.timestamp(); let last_played = instance.last_played.map(|value| value.timestamp()); - let submitted_time_played = instance.submitted_time_played as i64; - let recent_time_played = instance.recent_time_played as i64; + let submitted_time_played = playtime_to_storage( + instance.submitted_time_played, + "submitted_time_played", + )?; + let recent_time_played = + playtime_to_storage(instance.recent_time_played, "recent_time_played")?; sqlx::query!( " @@ -887,8 +891,12 @@ pub(crate) async fn update_instance( let icon_path = instance.icon_path.as_deref(); let modified = instance.modified.timestamp(); let last_played = instance.last_played.map(|value| value.timestamp()); - let submitted_time_played = instance.submitted_time_played as i64; - let recent_time_played = instance.recent_time_played as i64; + let submitted_time_played = playtime_to_storage( + instance.submitted_time_played, + "submitted_time_played", + )?; + let recent_time_played = + playtime_to_storage(instance.recent_time_played, "recent_time_played")?; sqlx::query!( " diff --git a/packages/app-lib/src/state/instances/commands/launch_context.rs b/packages/app-lib/src/state/instances/commands/launch_context.rs index d37b070c9e..6b600a97d9 100644 --- a/packages/app-lib/src/state/instances/commands/launch_context.rs +++ b/packages/app-lib/src/state/instances/commands/launch_context.rs @@ -1,6 +1,6 @@ use crate::state::InstanceInstallStage; use crate::state::instances::{ - InstanceLaunchContext, adapters::sqlite::instance_rows, + InstanceLaunchContext, adapters::sqlite::instance_rows, playtime_to_storage, }; use chrono::{DateTime, Utc}; use sqlx::SqlitePool; @@ -120,16 +120,31 @@ pub(crate) async fn add_instance_recent_playtime( seconds: u64, pool: &SqlitePool, ) -> crate::Result<()> { - let seconds = seconds as i64; + if seconds == 0 { + return Ok(()); + } + + let seconds = playtime_to_storage(seconds, "recent_time_played")?; + let max_playtime = i64::MAX; + let max_playtime_before_increment = max_playtime - seconds; let modified = Utc::now().timestamp(); sqlx::query!( " UPDATE instances - SET recent_time_played = recent_time_played + ?, modified = ? + SET + recent_time_played = CASE + WHEN recent_time_played < 0 THEN ? + WHEN recent_time_played > ? THEN ? + ELSE recent_time_played + ? + END, + modified = ? WHERE id = ? ", seconds, + max_playtime_before_increment, + max_playtime, + seconds, modified, instance_id, ) @@ -144,19 +159,33 @@ pub(crate) async fn mark_instance_playtime_submitted( recent_time_played: u64, pool: &SqlitePool, ) -> crate::Result<()> { - let recent_time_played = recent_time_played as i64; + if recent_time_played == 0 { + return Ok(()); + } + + let recent_time_played = + playtime_to_storage(recent_time_played, "recent_time_played")?; + let max_playtime = i64::MAX; + let max_playtime_before_increment = max_playtime - recent_time_played; let modified = Utc::now().timestamp(); sqlx::query!( " UPDATE instances SET - submitted_time_played = submitted_time_played + ?, + submitted_time_played = CASE + WHEN submitted_time_played < 0 THEN ? + WHEN submitted_time_played > ? THEN ? + ELSE submitted_time_played + ? + END, recent_time_played = 0, modified = ? WHERE id = ? ", recent_time_played, + max_playtime_before_increment, + max_playtime, + recent_time_played, modified, instance_id, ) diff --git a/packages/app-lib/src/state/instances/model/instance.rs b/packages/app-lib/src/state/instances/model/instance.rs index 4bfda092f6..506d0c3516 100644 --- a/packages/app-lib/src/state/instances/model/instance.rs +++ b/packages/app-lib/src/state/instances/model/instance.rs @@ -20,3 +20,15 @@ pub struct Instance { pub submitted_time_played: u64, pub recent_time_played: u64, } + +pub(crate) fn playtime_to_storage( + value: u64, + column: &str, +) -> crate::Result { + i64::try_from(value).map_err(|_| { + crate::ErrorKind::InputError(format!( + "Expected {column} to fit in SQLite INTEGER" + )) + .into() + }) +} diff --git a/packages/app-lib/src/state/legacy_converter.rs b/packages/app-lib/src/state/legacy_converter.rs index ba9db4aace..b602a09b3a 100644 --- a/packages/app-lib/src/state/legacy_converter.rs +++ b/packages/app-lib/src/state/legacy_converter.rs @@ -3,7 +3,7 @@ use crate::jre::check_jre; use crate::prelude::ModLoader; use crate::state; use crate::state::instances::{ - InstanceLaunchOverrides, InstanceLaunchOverridesData, + InstanceLaunchOverrides, InstanceLaunchOverridesData, playtime_to_storage, }; use crate::state::{ CacheValue, CachedEntry, CachedFile, CachedFileHash, CachedFileUpdate, @@ -443,8 +443,12 @@ where let created = input.created.timestamp(); let modified = input.modified.timestamp(); let last_played = input.last_played.map(|value| value.timestamp()); - let submitted_time_played = input.submitted_time_played as i64; - let recent_time_played = input.recent_time_played as i64; + let submitted_time_played = playtime_to_storage( + input.submitted_time_played, + "submitted_time_played", + )?; + let recent_time_played = + playtime_to_storage(input.recent_time_played, "recent_time_played")?; sqlx::query!( " diff --git a/packages/app-lib/src/state/process.rs b/packages/app-lib/src/state/process.rs index cc2aff0224..669dac581f 100644 --- a/packages/app-lib/src/state/process.rs +++ b/packages/app-lib/src/state/process.rs @@ -17,6 +17,7 @@ use std::io::Write; use std::path::{Path, PathBuf}; use std::process::ExitStatus; use std::sync::LazyLock; +use std::time::Instant; #[cfg(feature = "tauri")] use tauri::Emitter; use tempfile::TempDir; @@ -752,46 +753,46 @@ impl Process { uuid: Uuid, ) -> crate::Result<()> { async fn update_playtime( - last_updated_playtime: &mut DateTime, + last_updated_playtime: &mut Instant, instance_id: &str, force_update: bool, ) { - let diff = Utc::now() - .signed_duration_since(*last_updated_playtime) - .num_seconds(); - if diff >= 60 || force_update { - let state = match crate::State::get().await { - Ok(state) => state, - Err(e) => { - tracing::warn!( - "Failed to get state for playtime update on instance {}: {}", - instance_id, - e - ); - return; - } - }; - if let Err(e) = - crate::state::instances::commands::add_instance_recent_playtime( - instance_id, - diff as u64, - &state.pool, - ) - .await - { + let elapsed = last_updated_playtime.elapsed().as_secs(); + if elapsed == 0 || (!force_update && elapsed < 60) { + return; + } + + let state = match crate::State::get().await { + Ok(state) => state, + Err(e) => { tracing::warn!( - "Failed to update playtime for instance {}: {}", + "Failed to get state for playtime update on instance {}: {}", instance_id, e ); + return; } - *last_updated_playtime = Utc::now(); + }; + if let Err(e) = + crate::state::instances::commands::add_instance_recent_playtime( + instance_id, + elapsed, + &state.pool, + ) + .await + { + tracing::warn!( + "Failed to update playtime for instance {}: {}", + instance_id, + e + ); } + *last_updated_playtime = Instant::now(); } // Wait on current Minecraft Child let mc_exit_status; - let mut last_updated_playtime = Utc::now(); + let mut last_updated_playtime = Instant::now(); let state = crate::State::get().await?; loop {