game_manager_lib/sources/
battle_net.rs1use crate::errors::AppError;
17use crate::sources::providers::{GameSource, SourceGame};
18use async_trait::async_trait;
19use once_cell::sync::Lazy;
20use serde::Deserialize;
21use std::collections::HashMap;
22use std::fs;
23use std::path::{Path, PathBuf};
24
25const BATTLE_NET_AGENT_DIR_WINDOWS: &str = r"C:\ProgramData\Battle.net\Agent";
28
29const BATTLE_NET_INTERNAL_PRODUCT_IDS: &[&str] = &["agent", "bna"];
31
32const BATTLE_NET_GAMES_CATALOG_JSON: &str = include_str!("../data/battle_net_games.json");
35
36#[derive(Debug, Deserialize)]
39#[serde(rename_all = "PascalCase")]
40struct CatalogEntry {
41 product_id: String,
42 name: String,
43}
44
45static BATTLE_NET_CATALOG: Lazy<HashMap<String, String>> = Lazy::new(
48 || match serde_json::from_str::<Vec<CatalogEntry>>(BATTLE_NET_GAMES_CATALOG_JSON) {
49 Ok(entries) => entries
50 .into_iter()
51 .map(|e| (e.product_id.to_lowercase(), e.name))
52 .collect(),
53 Err(err) => {
54 log::warn!("Falha ao parsear catálogo estático battle_net_games.json: {err}");
55 HashMap::new()
56 }
57 },
58);
59
60#[derive(Debug, Deserialize)]
63struct AggregateFile {
64 installed: Vec<AggregateGame>,
65}
66
67#[derive(Debug, Deserialize)]
68struct AggregateGame {
69 name: Option<String>,
70 product_id: Option<String>,
71 icon_path: Option<String>,
72 last_played_timestamp: Option<i64>,
73}
74
75struct ProductDbEntry {
79 #[allow(dead_code)]
80 internal_id: String,
81 product_id: String,
82 install_path: Option<String>,
83}
84
85#[derive(Default)]
87pub struct BattleNetSource {}
88
89impl BattleNetSource {
90 pub fn new() -> Self {
91 Self::default()
92 }
93
94 fn resolve_agent_dir(&self) -> Option<PathBuf> {
96 #[cfg(target_os = "windows")]
97 {
98 let path = PathBuf::from(BATTLE_NET_AGENT_DIR_WINDOWS);
99 if path.exists() {
100 return Some(path);
101 }
102 }
103
104 None
105 }
106
107 pub async fn import_installed(&self) -> Result<Vec<SourceGame>, AppError> {
109 let Some(agent_dir) = self.resolve_agent_dir() else {
110 return Ok(vec![]);
111 };
112
113 let product_db_path = agent_dir.join("product.db");
114 if !product_db_path.exists() {
115 return Ok(vec![]);
116 }
117
118 let db_bytes = fs::read(&product_db_path)?;
119 let entries = parse_product_db(&db_bytes);
120 let aggregate_map = Self::load_aggregate_map(&agent_dir.join("aggregate.json"));
121
122 let mut games = Vec::with_capacity(entries.len());
123
124 for entry in entries {
125 if BATTLE_NET_INTERNAL_PRODUCT_IDS
126 .iter()
127 .any(|id| entry.product_id.eq_ignore_ascii_case(id))
128 {
129 continue;
130 }
131
132 let Some(install_path) = entry.install_path.as_ref() else {
133 log::warn!(
134 "product.db: entrada '{}' sem install_path, ignorando",
135 entry.product_id
136 );
137 continue;
138 };
139
140 if !Path::new(install_path).is_dir() {
141 log::warn!(
142 "product.db: pasta de instalação de '{}' não existe mais ({}), ignorando",
143 entry.product_id,
144 install_path
145 );
146 continue;
147 }
148
149 let enrichment = aggregate_map.get(&entry.product_id.to_lowercase());
150
151 let name = enrichment
152 .and_then(|g| g.name.clone())
153 .or_else(|| {
154 BATTLE_NET_CATALOG
155 .get(&entry.product_id.to_lowercase())
156 .cloned()
157 })
158 .unwrap_or_else(|| entry.product_id.clone());
159
160 let executable_path = enrichment.and_then(|g| g.icon_path.clone());
161
162 let last_played = enrichment
163 .and_then(|g| g.last_played_timestamp)
164 .filter(|&ms| ms > 0)
165 .map(|ms| ms / 1000);
166
167 games.push(SourceGame {
168 platform: "BattleNet".to_string(),
169 platform_game_id: entry.product_id,
170 name: Some(name),
171 installed: true,
172 executable_path,
173 install_path: Some(install_path.clone()),
174 playtime_minutes: None,
175 last_played,
176 });
177 }
178
179 Ok(games)
180 }
181
182 fn load_aggregate_map(path: &Path) -> HashMap<String, AggregateGame> {
186 let mut map = HashMap::new();
187
188 let content = match fs::read_to_string(path) {
189 Ok(content) => content,
190 Err(_) => {
191 log::info!("aggregate.json não encontrado, seguindo sem enriquecimento");
192 return map;
193 }
194 };
195
196 let parsed: AggregateFile = match serde_json::from_str(&content) {
197 Ok(parsed) => parsed,
198 Err(err) => {
199 log::warn!("Falha ao parsear aggregate.json: {err}");
200 return map;
201 }
202 };
203
204 for game in parsed.installed {
205 if let Some(product_id) = &game.product_id {
206 map.insert(product_id.to_lowercase(), game);
207 }
208 }
209
210 map
211 }
212}
213
214#[async_trait]
215impl GameSource for BattleNetSource {
216 async fn fetch_games(&self) -> Result<Vec<SourceGame>, AppError> {
217 self.import_installed().await
218 }
219}
220
221fn parse_product_db(data: &[u8]) -> Vec<ProductDbEntry> {
233 let mut entries = Vec::new();
234 let mut pos = 0;
235
236 while pos < data.len() {
237 let Some((field_number, wire_type, tag_end)) = read_tag(data, pos) else {
238 log::warn!("product.db: falha ao ler tag em pos {pos}, interrompendo parse");
239 break;
240 };
241 let Some((value, new_pos)) = read_field_value(data, tag_end, wire_type) else {
242 log::warn!(
243 "product.db: falha ao ler valor do campo {field_number} em pos {pos}, interrompendo parse"
244 );
245 break;
246 };
247
248 if field_number == 1 && wire_type == 2 {
251 if let Some(bytes) = value {
252 match parse_installed_product_info(bytes) {
253 Some(entry) => entries.push(entry),
254 None => log::warn!("product.db: entrada malformada ignorada em pos {pos}"),
255 }
256 }
257 }
258
259 pos = new_pos;
260 }
261
262 entries
263}
264
265fn parse_installed_product_info(data: &[u8]) -> Option<ProductDbEntry> {
271 let mut internal_id: Option<String> = None;
272 let mut product_id: Option<String> = None;
273 let mut install_path: Option<String> = None;
274
275 let mut pos = 0;
276 while pos < data.len() {
277 let (field_number, wire_type, tag_end) = read_tag(data, pos)?;
278 let (value, new_pos) = read_field_value(data, tag_end, wire_type)?;
279
280 match (field_number, value) {
281 (1, Some(bytes)) => internal_id = Some(String::from_utf8_lossy(bytes).into_owned()),
282 (2, Some(bytes)) => product_id = Some(String::from_utf8_lossy(bytes).into_owned()),
283 (3, Some(bytes)) => install_path = parse_install_data(bytes),
284 _ => {} }
286
287 pos = new_pos;
288 }
289
290 Some(ProductDbEntry {
291 internal_id: internal_id.unwrap_or_default(),
292 product_id: product_id?,
293 install_path,
294 })
295}
296
297fn parse_install_data(data: &[u8]) -> Option<String> {
299 let mut path = None;
300 let mut pos = 0;
301
302 while pos < data.len() {
303 let (field_number, wire_type, tag_end) = read_tag(data, pos)?;
304 let (value, new_pos) = read_field_value(data, tag_end, wire_type)?;
305
306 if field_number == 1 {
307 if let Some(bytes) = value {
308 path = Some(String::from_utf8_lossy(bytes).into_owned());
309 }
310 }
311
312 pos = new_pos;
313 }
314
315 path
316}
317
318fn read_tag(data: &[u8], pos: usize) -> Option<(u32, u8, usize)> {
320 let (tag, new_pos) = read_varint(data, pos)?;
321 let field_number = (tag >> 3) as u32;
322 let wire_type = (tag & 0x07) as u8;
323 Some((field_number, wire_type, new_pos))
324}
325
326fn read_field_value(data: &[u8], pos: usize, wire_type: u8) -> Option<(Option<&[u8]>, usize)> {
329 match wire_type {
330 0 => {
331 let (_, new_pos) = read_varint(data, pos)?;
333 Some((None, new_pos))
334 }
335 1 => {
336 let new_pos = pos.checked_add(8)?;
338 (new_pos <= data.len()).then_some((None, new_pos))
339 }
340 2 => {
341 let (len, new_pos) = read_varint(data, pos)?;
343 let len = len as usize;
344 let end = new_pos.checked_add(len)?;
345 (end <= data.len()).then_some((Some(&data[new_pos..end]), end))
346 }
347 5 => {
348 let new_pos = pos.checked_add(4)?;
350 (new_pos <= data.len()).then_some((None, new_pos))
351 }
352 _ => None, }
354}
355
356fn read_varint(data: &[u8], mut pos: usize) -> Option<(u64, usize)> {
358 let mut result: u64 = 0;
359 let mut shift = 0;
360
361 loop {
362 let byte = *data.get(pos)?;
363 pos += 1;
364 result |= ((byte & 0x7F) as u64) << shift;
365 if byte & 0x80 == 0 {
366 return Some((result, pos));
367 }
368 shift += 7;
369 if shift >= 64 {
370 return None; }
372 }
373}