diff --git a/src-tauri/src/error/process_error.rs b/src-tauri/src/error/process_error.rs index fa4ddb5..9ce6ad8 100644 --- a/src-tauri/src/error/process_error.rs +++ b/src-tauri/src/error/process_error.rs @@ -11,7 +11,8 @@ pub enum ProcessError { IOError(Error), FormatError(String), // String errors supremacy InvalidPlatform, - OpenerError(tauri_plugin_opener::Error) + OpenerError(tauri_plugin_opener::Error), + PlaytimeError(String), } impl Display for ProcessError { @@ -25,6 +26,7 @@ impl Display for ProcessError { ProcessError::InvalidPlatform => "This game cannot be played on the current platform", ProcessError::FormatError(e) => &format!("Failed to format template: {e}"), ProcessError::OpenerError(error) => &format!("Failed to open directory: {error}"), + ProcessError::PlaytimeError(error) => &format!("Playtime tracking error: {error}"), }; write!(f, "{s}") } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 90caf5c..3baa476 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -11,6 +11,7 @@ mod games; mod client; mod download_manager; mod error; +mod playtime; mod process; mod remote; @@ -46,6 +47,12 @@ use games::commands::{ use games::downloads::commands::download_game; use games::library::{Game, update_game_configuration}; use log::{LevelFilter, debug, info, warn}; +use playtime::manager::PlaytimeManager; +use playtime::commands::{ + start_playtime_tracking, end_playtime_tracking, fetch_game_playtime, + fetch_all_playtime_stats, is_playtime_session_active, get_active_playtime_sessions, + cleanup_orphaned_playtime_sessions +}; use log4rs::Config; use log4rs::append::console::ConsoleAppender; use log4rs::append::file::FileAppender; @@ -127,7 +134,11 @@ pub struct AppState<'a> { #[serde(skip_serializing)] process_manager: Arc>>, #[serde(skip_serializing)] + playtime_manager: Arc>, + #[serde(skip_serializing)] compat_info: Option, + #[serde(skip_serializing)] + app_handle: AppHandle, } async fn setup(handle: AppHandle) -> AppState<'static> { @@ -164,6 +175,7 @@ async fn setup(handle: AppHandle) -> AppState<'static> { let games = HashMap::new(); let download_manager = Arc::new(DownloadManagerBuilder::build(handle.clone())); let process_manager = Arc::new(Mutex::new(ProcessManager::new(handle.clone()))); + let playtime_manager = Arc::new(Mutex::new(PlaytimeManager::new(handle.clone()))); let compat_info = create_new_compat_info(); debug!("checking if database is set up"); @@ -178,7 +190,9 @@ async fn setup(handle: AppHandle) -> AppState<'static> { games, download_manager, process_manager, + playtime_manager, compat_info, + app_handle: handle.clone(), }; } @@ -237,13 +251,20 @@ async fn setup(handle: AppHandle) -> AppState<'static> { warn!("failed to sync autostart state: {e}"); } + // Clean up any orphaned playtime sessions + if let Err(e) = playtime_manager.lock().unwrap().cleanup_orphaned_sessions() { + warn!("failed to cleanup orphaned playtime sessions: {e}"); + } + AppState { status: app_status, user, games, download_manager, process_manager, + playtime_manager, compat_info, + app_handle: handle.clone(), } } @@ -334,7 +355,15 @@ pub fn run() { kill_game, toggle_autostart, get_autostart_enabled, - open_process_logs + open_process_logs, + // Playtime tracking + start_playtime_tracking, + end_playtime_tracking, + fetch_game_playtime, + fetch_all_playtime_stats, + is_playtime_session_active, + get_active_playtime_sessions, + cleanup_orphaned_playtime_sessions ]) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_dialog::init()) diff --git a/src-tauri/src/playtime/commands.rs b/src-tauri/src/playtime/commands.rs new file mode 100644 index 0000000..2636c64 --- /dev/null +++ b/src-tauri/src/playtime/commands.rs @@ -0,0 +1,95 @@ +use std::collections::HashMap; +use tauri::State; +use std::sync::Mutex; + +use crate::AppState; +use super::manager::PlaytimeStats; +use super::events::{push_playtime_update, push_session_start, push_session_end}; + +#[tauri::command] +pub fn start_playtime_tracking( + game_id: String, + state: State<'_, Mutex>>, +) -> Result<(), String> { + let state_lock = state.lock().unwrap(); + let playtime_manager_lock = state_lock.playtime_manager.lock().unwrap(); + + match playtime_manager_lock.start_session(game_id.clone()) { + Ok(()) => { + push_session_start(&state_lock.app_handle, &game_id); + Ok(()) + } + Err(e) => Err(e.to_string()) + } +} + +#[tauri::command] +pub fn end_playtime_tracking( + game_id: String, + state: State<'_, Mutex>>, +) -> Result { + let state_lock = state.lock().unwrap(); + let playtime_manager_lock = state_lock.playtime_manager.lock().unwrap(); + + match playtime_manager_lock.end_session(game_id.clone()) { + Ok(stats) => { + push_session_end(&state_lock.app_handle, &game_id, &stats); + push_playtime_update(&state_lock.app_handle, &game_id, stats.clone(), false); + Ok(stats) + } + Err(e) => Err(e.to_string()) + } +} + +#[tauri::command] +pub fn fetch_game_playtime( + game_id: String, + state: State<'_, Mutex>>, +) -> Result, String> { + let state_lock = state.lock().unwrap(); + let playtime_manager_lock = state_lock.playtime_manager.lock().unwrap(); + + Ok(playtime_manager_lock.get_game_stats(&game_id)) +} + +#[tauri::command] +pub fn fetch_all_playtime_stats( + state: State<'_, Mutex>>, +) -> Result, String> { + let state_lock = state.lock().unwrap(); + let playtime_manager_lock = state_lock.playtime_manager.lock().unwrap(); + + Ok(playtime_manager_lock.get_all_stats()) +} + +#[tauri::command] +pub fn is_playtime_session_active( + game_id: String, + state: State<'_, Mutex>>, +) -> Result { + let state_lock = state.lock().unwrap(); + let playtime_manager_lock = state_lock.playtime_manager.lock().unwrap(); + + Ok(playtime_manager_lock.is_session_active(&game_id)) +} + +#[tauri::command] +pub fn get_active_playtime_sessions( + state: State<'_, Mutex>>, +) -> Result, String> { + let state_lock = state.lock().unwrap(); + let playtime_manager_lock = state_lock.playtime_manager.lock().unwrap(); + + Ok(playtime_manager_lock.get_active_sessions()) +} + +#[tauri::command] +pub fn cleanup_orphaned_playtime_sessions( + state: State<'_, Mutex>>, +) -> Result<(), String> { + let state_lock = state.lock().unwrap(); + let playtime_manager_lock = state_lock.playtime_manager.lock().unwrap(); + + playtime_manager_lock.cleanup_orphaned_sessions() + .map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/playtime/events.rs b/src-tauri/src/playtime/events.rs new file mode 100644 index 0000000..16c8279 --- /dev/null +++ b/src-tauri/src/playtime/events.rs @@ -0,0 +1,81 @@ +use serde::Serialize; +use tauri::{AppHandle, Emitter}; +use log::warn; + +use super::manager::PlaytimeStats; + +#[derive(Serialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct PlaytimeUpdateEvent { + pub game_id: String, + pub stats: PlaytimeStats, + pub is_active: bool, +} + +#[derive(Serialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct PlaytimeSessionStartEvent { + pub game_id: String, + pub start_time: std::time::SystemTime, +} + +#[derive(Serialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct PlaytimeSessionEndEvent { + pub game_id: String, + pub session_duration_seconds: u64, + pub total_playtime_seconds: u64, + pub session_count: u32, +} + +/// Push a playtime update event to the frontend +pub fn push_playtime_update(app_handle: &AppHandle, game_id: &str, stats: PlaytimeStats, is_active: bool) { + let event = PlaytimeUpdateEvent { + game_id: game_id.to_string(), + stats, + is_active, + }; + + if let Err(e) = app_handle.emit(&format!("playtime_update/{}", game_id), &event) { + warn!("Failed to emit playtime update event for {}: {}", game_id, e); + } + + // Also emit a general playtime update event for global listeners + if let Err(e) = app_handle.emit("playtime_update", &event) { + warn!("Failed to emit general playtime update event: {}", e); + } +} + +/// Push a session start event to the frontend +pub fn push_session_start(app_handle: &AppHandle, game_id: &str) { + let event = PlaytimeSessionStartEvent { + game_id: game_id.to_string(), + start_time: std::time::SystemTime::now(), + }; + + if let Err(e) = app_handle.emit(&format!("playtime_session_start/{}", game_id), &event) { + warn!("Failed to emit session start event for {}: {}", game_id, e); + } + + if let Err(e) = app_handle.emit("playtime_session_start", &event) { + warn!("Failed to emit general session start event: {}", e); + } +} + +/// Push a session end event to the frontend +pub fn push_session_end(app_handle: &AppHandle, game_id: &str, stats: &PlaytimeStats) { + let event = PlaytimeSessionEndEvent { + game_id: game_id.to_string(), + session_duration_seconds: stats.current_session_duration.unwrap_or(0), + total_playtime_seconds: stats.total_playtime_seconds, + session_count: stats.session_count, + }; + + if let Err(e) = app_handle.emit(&format!("playtime_session_end/{}", game_id), &event) { + warn!("Failed to emit session end event for {}: {}", game_id, e); + } + + if let Err(e) = app_handle.emit("playtime_session_end", &event) { + warn!("Failed to emit general session end event: {}", e); + } +} diff --git a/src-tauri/src/playtime/manager.rs b/src-tauri/src/playtime/manager.rs new file mode 100644 index 0000000..2faf08a --- /dev/null +++ b/src-tauri/src/playtime/manager.rs @@ -0,0 +1,254 @@ +use std::collections::HashMap; +use std::time::SystemTime; +use std::fmt; + +use log::{debug, warn}; +use serde::{Deserialize, Serialize}; +use tauri::AppHandle; + +use crate::database::db::{borrow_db_checked, borrow_db_mut_checked}; +use crate::database::models::data::{GamePlaytimeStats, PlaytimeSession}; +use crate::error::process_error::ProcessError; + +#[derive(Debug)] +pub enum PlaytimeError { + DatabaseError(String), + SessionNotFound(String), + SessionAlreadyActive(String), + InvalidGameId(String), +} + +impl fmt::Display for PlaytimeError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + PlaytimeError::DatabaseError(msg) => write!(f, "Database error: {}", msg), + PlaytimeError::SessionNotFound(game_id) => write!(f, "Session not found for game: {}", game_id), + PlaytimeError::SessionAlreadyActive(game_id) => write!(f, "Session already active for game: {}", game_id), + PlaytimeError::InvalidGameId(game_id) => write!(f, "Invalid game ID: {}", game_id), + } + } +} + +impl std::error::Error for PlaytimeError {} + +impl From for ProcessError { + fn from(error: PlaytimeError) -> Self { + ProcessError::PlaytimeError(error.to_string()) + } +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct PlaytimeStats { + pub game_id: String, + pub total_playtime_seconds: u64, + pub session_count: u32, + pub first_played: SystemTime, + pub last_played: SystemTime, + pub average_session_length: u64, + pub current_session_duration: Option, +} + +impl From for PlaytimeStats { + fn from(stats: GamePlaytimeStats) -> Self { + Self { + game_id: stats.game_id, + total_playtime_seconds: stats.total_playtime_seconds, + session_count: stats.session_count, + first_played: stats.first_played, + last_played: stats.last_played, + average_session_length: stats.average_session_length(), + current_session_duration: None, + } + } +} + +pub struct PlaytimeManager { + app_handle: AppHandle, +} + +impl PlaytimeManager { + pub fn new(app_handle: AppHandle) -> Self { + Self { app_handle } + } + + /// Start tracking playtime for a game + pub fn start_session(&self, game_id: String) -> Result<(), PlaytimeError> { + debug!("Starting playtime session for game: {}", game_id); + + let mut db_handle = borrow_db_mut_checked(); + + // Check if session is already active + if db_handle.playtime_data.active_sessions.contains_key(&game_id) { + warn!("Session already active for game: {}", game_id); + return Err(PlaytimeError::SessionAlreadyActive(game_id)); + } + + // Create new session + let session = PlaytimeSession::new(game_id.clone()); + db_handle.playtime_data.active_sessions.insert(game_id.clone(), session); + + debug!("Started playtime tracking for game: {}", game_id); + Ok(()) + } + + /// End tracking playtime for a game and update stats + pub fn end_session(&self, game_id: String) -> Result { + debug!("Ending playtime session for game: {}", game_id); + + let mut db_handle = borrow_db_mut_checked(); + + // Get active session + let session = db_handle.playtime_data.active_sessions.remove(&game_id) + .ok_or_else(|| PlaytimeError::SessionNotFound(game_id.clone()))?; + + let session_duration = session.duration().as_secs(); + debug!("Session duration for {}: {} seconds", game_id, session_duration); + + // Update or create game stats + let stats = db_handle.playtime_data.game_sessions + .entry(game_id.clone()) + .or_insert_with(|| GamePlaytimeStats::new(game_id.clone())); + + // Update stats + stats.total_playtime_seconds += session_duration; + stats.session_count += 1; + stats.last_played = SystemTime::now(); + + // If this is the first session, update first_played + if stats.session_count == 1 { + stats.first_played = session.start_time; + } + + let result_stats = PlaytimeStats { + game_id: stats.game_id.clone(), + total_playtime_seconds: stats.total_playtime_seconds, + session_count: stats.session_count, + first_played: stats.first_played, + last_played: stats.last_played, + average_session_length: stats.average_session_length(), + current_session_duration: Some(session_duration), + }; + + debug!("Updated playtime stats for {}: {} total seconds, {} sessions", + game_id, stats.total_playtime_seconds, stats.session_count); + + Ok(result_stats) + } + + /// Get playtime stats for a specific game + pub fn get_game_stats(&self, game_id: &str) -> Option { + let db_handle = borrow_db_checked(); + + if let Some(stats) = db_handle.playtime_data.game_sessions.get(game_id) { + let mut playtime_stats: PlaytimeStats = stats.clone().into(); + + // If there's an active session, include current session duration + if let Some(session) = db_handle.playtime_data.active_sessions.get(game_id) { + playtime_stats.current_session_duration = Some(session.duration().as_secs()); + } + + Some(playtime_stats) + } else { + None + } + } + + /// Get playtime stats for all games + pub fn get_all_stats(&self) -> HashMap { + let db_handle = borrow_db_checked(); + let mut result = HashMap::new(); + + for (game_id, stats) in &db_handle.playtime_data.game_sessions { + let mut playtime_stats: PlaytimeStats = stats.clone().into(); + + // If there's an active session, include current session duration + if let Some(session) = db_handle.playtime_data.active_sessions.get(game_id) { + playtime_stats.current_session_duration = Some(session.duration().as_secs()); + } + + result.insert(game_id.clone(), playtime_stats); + } + + result + } + + /// Check if a game has an active session + pub fn is_session_active(&self, game_id: &str) -> bool { + let db_handle = borrow_db_checked(); + db_handle.playtime_data.active_sessions.contains_key(game_id) + } + + /// Get active sessions (for debugging/monitoring) + pub fn get_active_sessions(&self) -> Vec { + let db_handle = borrow_db_checked(); + db_handle.playtime_data.active_sessions.keys().cloned().collect() + } + + /// Clean up any orphaned sessions (called on startup) + pub fn cleanup_orphaned_sessions(&self) -> Result<(), PlaytimeError> { + debug!("Cleaning up orphaned playtime sessions"); + + let mut db_handle = borrow_db_mut_checked(); + let orphaned_sessions: Vec = db_handle.playtime_data.active_sessions.keys().cloned().collect(); + + for game_id in orphaned_sessions { + warn!("Found orphaned session for game: {}, ending it", game_id); + + if let Some(session) = db_handle.playtime_data.active_sessions.remove(&game_id) { + let session_duration = session.duration().as_secs(); + + // Only count sessions that lasted more than 5 seconds to avoid counting crashes + if session_duration > 5 { + let stats = db_handle.playtime_data.game_sessions + .entry(game_id.clone()) + .or_insert_with(|| GamePlaytimeStats::new(game_id.clone())); + + stats.total_playtime_seconds += session_duration; + stats.session_count += 1; + stats.last_played = SystemTime::now(); + + if stats.session_count == 1 { + stats.first_played = session.start_time; + } + + debug!("Recovered orphaned session for {}: {} seconds", game_id, session_duration); + } else { + debug!("Discarded short orphaned session for {}: {} seconds", game_id, session_duration); + } + } + } + + Ok(()) + } + + // Future server-side methods (ready for migration) + + /// Start session with server sync (placeholder for future implementation) + #[allow(dead_code)] + pub async fn sync_session_start(&self, game_id: String) -> Result<(), PlaytimeError> { + // For now, just call local method + self.start_session(game_id)?; + + // Future: Send to server + // let response = self.api_client.post("/api/v1/playtime/start") + // .json(&StartSessionRequest { game_id }) + // .send().await?; + + Ok(()) + } + + /// End session with server sync (placeholder for future implementation) + #[allow(dead_code)] + pub async fn sync_session_end(&self, game_id: String) -> Result { + // For now, just call local method + let stats = self.end_session(game_id)?; + + // Future: Send to server + // let response = self.api_client.post("/api/v1/playtime/end") + // .json(&EndSessionRequest { game_id, duration: stats.current_session_duration }) + // .send().await?; + + Ok(stats) + } +} diff --git a/src-tauri/src/playtime/mod.rs b/src-tauri/src/playtime/mod.rs index f1f7f7e..4c2b022 100644 --- a/src-tauri/src/playtime/mod.rs +++ b/src-tauri/src/playtime/mod.rs @@ -1,7 +1,3 @@ pub mod commands; pub mod events; pub mod manager; - -pub use commands::*; -pub use events::*; -pub use manager::*; diff --git a/src-tauri/src/process/commands.rs b/src-tauri/src/process/commands.rs index 13b89fa..7d5e438 100644 --- a/src-tauri/src/process/commands.rs +++ b/src-tauri/src/process/commands.rs @@ -16,14 +16,28 @@ pub fn launch_game( // download_type: DownloadType::Game, //}; - match process_manager_lock.launch_process(id, &state_lock) { - Ok(()) => {} - Err(e) => return Err(e), + match process_manager_lock.launch_process(id.clone(), &state_lock) { + Ok(()) => { + // Start playtime tracking after successful launch + drop(process_manager_lock); + + let playtime_manager_lock = state_lock.playtime_manager.lock().unwrap(); + if let Err(e) = playtime_manager_lock.start_session(id.clone()) { + log::warn!("Failed to start playtime tracking for {}: {}", id, e); + } else { + log::debug!("Started playtime tracking for game: {}", id); + crate::playtime::events::push_session_start(&state_lock.app_handle, &id); + } + drop(playtime_manager_lock); + } + Err(e) => { + drop(process_manager_lock); + drop(state_lock); + return Err(e); + } } - drop(process_manager_lock); drop(state_lock); - Ok(()) } @@ -33,6 +47,18 @@ pub fn kill_game( state: tauri::State<'_, Mutex>, ) -> Result<(), ProcessError> { let state_lock = state.lock().unwrap(); + let mut process_manager_lock = state_lock.process_manager.lock().unwrap(); + + // End playtime tracking before killing the game + drop(process_manager_lock); + let playtime_manager_lock = state_lock.playtime_manager.lock().unwrap(); + if let Ok(stats) = playtime_manager_lock.end_session(game_id.clone()) { + log::debug!("Ended playtime tracking for game: {} (manual kill)", game_id); + crate::playtime::events::push_session_end(&state_lock.app_handle, &game_id, &stats); + crate::playtime::events::push_playtime_update(&state_lock.app_handle, &game_id, stats, false); + } + drop(playtime_manager_lock); + let mut process_manager_lock = state_lock.process_manager.lock().unwrap(); process_manager_lock .kill_game(game_id) diff --git a/src-tauri/src/process/process_manager.rs b/src-tauri/src/process/process_manager.rs index 901671f..e1da6dc 100644 --- a/src-tauri/src/process/process_manager.rs +++ b/src-tauri/src/process/process_manager.rs @@ -29,6 +29,7 @@ use crate::{ }, error::process_error::ProcessError, games::{library::push_game_update, state::GameStatusManager}, + playtime::events::{push_session_end, push_playtime_update}, process::{ format::DropFormatArgs, process_handlers::{AsahiMuvmLauncher, NativeGameLauncher, UMULauncher}, @@ -394,6 +395,15 @@ impl ProcessManager<'_> { let app_state = wait_thread_apphandle.state::>(); let app_state_handle = app_state.lock().unwrap(); + // End playtime tracking before processing finish + let playtime_manager_lock = app_state_handle.playtime_manager.lock().unwrap(); + if let Ok(stats) = playtime_manager_lock.end_session(wait_thread_game_id.id.clone()) { + debug!("Ended playtime tracking for game: {} (process finished)", wait_thread_game_id.id); + push_session_end(&app_state_handle.app_handle, &wait_thread_game_id.id, &stats); + push_playtime_update(&app_state_handle.app_handle, &wait_thread_game_id.id, stats, false); + } + drop(playtime_manager_lock); + let mut process_manager_handle = app_state_handle.process_manager.lock().unwrap(); process_manager_handle.on_process_finish(wait_thread_game_id.id, result);