game_manager_lib/services/
playtime.rs1use std::collections::HashSet;
11use std::path::PathBuf;
12use std::sync::Mutex;
13use std::time::Duration;
14
15use sysinfo::{ProcessRefreshKind, ProcessesToUpdate, RefreshKind, System};
16use tauri::{AppHandle, Manager};
17
18use crate::database::AppState;
19use crate::models::Platform;
20use crate::utils::status_logic;
21
22const POLL_INTERVAL: Duration = Duration::from_secs(60);
25
26const MAX_WAIT_FOR_START: Duration = Duration::from_secs(10 * 60);
30
31#[derive(Default)]
33pub struct PlaytimeRegistry {
34 watching: Mutex<HashSet<String>>,
35}
36
37impl PlaytimeRegistry {
38 fn try_start(&self, game_id: &str) -> bool {
40 self.watching.lock().unwrap().insert(game_id.to_string())
41 }
42
43 fn finish(&self, game_id: &str) {
44 self.watching.lock().unwrap().remove(game_id);
45 }
46}
47
48pub fn has_official_playtime_source(platform: &Platform) -> bool {
51 matches!(
52 platform,
53 Platform::Steam | Platform::Itch | Platform::Indiegala
54 )
55}
56
57fn normalize_path_str(s: &str) -> String {
58 s.replace('\\', "/")
59}
60
61pub fn watch_game(
64 app: AppHandle,
65 game_id: String,
66 exe_path: PathBuf,
67 install_path: Option<PathBuf>,
68) {
69 let state = app.state::<AppState>();
70 if !state.playtime_registry.try_start(&game_id) {
71 tracing::warn!("[playtime] watch já em andamento para {game_id}, ignorando");
72 return;
73 }
74
75 let expected_filename = exe_path
76 .file_name()
77 .map(|f| f.to_string_lossy().to_lowercase());
78
79 let install_path_lower = install_path
80 .as_ref()
81 .map(|p| normalize_path_str(&p.to_string_lossy().to_lowercase()));
82
83 tracing::info!(
84 "[playtime] iniciando watch para {game_id} | expected_filename={expected_filename:?} | install_path_lower={install_path_lower:?}"
85 );
86
87 tauri::async_runtime::spawn(async move {
88 let mut sys = System::new_with_specifics(
89 RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()),
90 );
91
92 let mut found = false;
93 let mut waited = Duration::ZERO;
94
95 loop {
96 tokio::time::sleep(POLL_INTERVAL).await;
97 sys.refresh_processes(ProcessesToUpdate::All, true);
98
99 let mut running = false;
100
101 for p in sys.processes().values() {
102 let Some(exe) = p.exe() else { continue };
103 let exe_str = normalize_path_str(&exe.to_string_lossy().to_lowercase());
104
105 let candidate_filename =
106 exe.file_name().map(|f| f.to_string_lossy().to_lowercase());
107 let name_matches = candidate_filename == expected_filename;
108
109 let within_install_dir = match &install_path_lower {
110 Some(dir) => exe_str.starts_with(dir.as_str()),
111 None => true,
112 };
113
114 if name_matches && within_install_dir {
115 running = true;
116 break; }
118 }
119
120 if running {
121 found = true;
122 if let Err(e) = increment_playtime(&app, &game_id) {
123 tracing::warn!("[playtime] falha ao gravar incremento para {game_id}: {e}");
124 }
125 } else if found {
126 tracing::info!("[playtime] processo sumiu, encerrando sessão para {game_id}");
127 break;
128 } else {
129 waited += POLL_INTERVAL;
130 if waited >= MAX_WAIT_FOR_START {
131 tracing::warn!("[playtime] timeout esperando processo iniciar para {game_id}");
132 break;
133 }
134 }
135 }
136
137 app.state::<AppState>().playtime_registry.finish(&game_id);
138 });
139}
140
141fn increment_playtime(app: &AppHandle, game_id: &str) -> Result<(), rusqlite::Error> {
145 let state = app.state::<AppState>();
146 let conn = state.games_db.lock().unwrap();
147
148 let current: Option<i32> = conn.query_row(
149 "SELECT playtime FROM games WHERE id = ?1",
150 [game_id],
151 |row| row.get(0),
152 )?;
153 let new_playtime = current.unwrap_or(0) + 1;
154 let status = status_logic::calculate_status(new_playtime);
155 let source = crate::models::PlaytimeSource::Local.as_db_str();
156
157 conn.execute(
158 "UPDATE games SET playtime = ?1, status = ?2, playtime_source = ?3 WHERE id = ?4",
159 rusqlite::params![new_playtime, status, source, game_id],
160 )?;
161 Ok(())
162}