Skip to main content

game_manager_lib/sources/
battle_net.rs

1//! Source para importar jogos instalados via Battle.net (Blizzard/Activision)
2//!
3//! Detecta jogos instalados lendo `product.db`, o arquivo binário (protobuf) que o próprio
4//! Battle.net Agent usa para controlar o que está instalado. Enriquece com `aggregate.json`
5//! quando disponível (nome legível, caminho do executável, último jogado).
6//!
7//! **Por que duas fontes:**
8//! - `product.db` é o arquivo que o Battle.net Agent usa, então é a fonte mais confiável. Não tem nome legível do jogo.
9//! - `aggregate.json` não é documentado oficialmente, é ligado à integração com o app Xbox ("Play Anywhere") pós-aquisição da Microsoft.
10//! - Não há garantia de que `aggregate.json` exista, esteja completo, ou seja mantido no futuro. Por isso é usado para enriquecimento, e não fonte primária.
11//!
12//! **Observações:**
13//! - **Windows:** `C:\ProgramData\Battle.net\Agent\product.db` e `aggregate.json` no mesmo dir.
14//! - **Linux:** não suportado por ora (Battle.net não roda de forma confiável via Wine).
15
16use 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
25// === CONSTANTS ===
26
27const BATTLE_NET_AGENT_DIR_WINDOWS: &str = r"C:\ProgramData\Battle.net\Agent";
28
29/// Pseudo-produtos internos do próprio client Battle.net (o Agent e o Desktop App), que aparecem em `product.db` mas não são jogos.
30const BATTLE_NET_INTERNAL_PRODUCT_IDS: &[&str] = &["agent", "bna"];
31
32/// Catálogo estático de fallback ProductId -> Nome, extraído do Playnite (fonte aberta).
33/// Usado apenas quando `aggregate.json` não existe ou não cobre aquele produto — não é a fonte primária de nome.
34const BATTLE_NET_GAMES_CATALOG_JSON: &str = include_str!("../data/battle_net_games.json");
35
36// === STRUCTS ===
37
38#[derive(Debug, Deserialize)]
39#[serde(rename_all = "PascalCase")]
40struct CatalogEntry {
41    product_id: String,
42    name: String,
43}
44
45// === HELPERS ===
46
47static 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// === AGGREGATE.JSON (enriquecimento opcional) ===
61
62#[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
75// === PRODUCT.DB (protobuf, fonte primária) ===
76
77/// Uma entrada decodificada de `product.db`.
78struct ProductDbEntry {
79    #[allow(dead_code)]
80    internal_id: String,
81    product_id: String,
82    install_path: Option<String>,
83}
84
85/// Source responsável por importar jogos instalados via Battle.net
86#[derive(Default)]
87pub struct BattleNetSource {}
88
89impl BattleNetSource {
90    pub fn new() -> Self {
91        Self::default()
92    }
93
94    /// Resolve o diretório do Battle.net Agent (onde ficam `product.db` e `aggregate.json`).
95    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    /// Importa todos os jogos instalados detectados via `product.db`, enriquecidos com `aggregate.json` quando disponível.
108    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    /// Carrega `aggregate.json`, indexado por `product_id` em minúsculas.
183    ///
184    /// Ausência do arquivo (ou erro de parse) não é um erro fatal — só significa importar sem enriquecimento.
185    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
221// === PARSER MANUAL DE PROTOBUF PARA PRODUCT.DB ===
222//
223// Implementação mínima e defensiva: só o suficiente do wire format do protobuf para extrair os três campos.
224// Evita puxar uma dependência de protobuf (prost + build.rs) para um caso de uso tão pequeno.
225
226/// Decodifica todas as entradas de `product.db`.
227///
228/// - O arquivo é uma mensagem raiz com um campo 'repetido de número 1 (`InstalledProductInfo`).
229/// - Cada entrada é lida como qualquer campo length-delimited: tag + length (varint) + conteúdo.
230/// - Uma tag ou length ilegível interrompe o parse (perda de sincronia com o restante).
231/// - Entradas malformadas são puladas com log.
232fn 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        // Só o campo 1 (repeated InstalledProductInfo) interessa no nível raiz;
249        // Qualquer outro campo já foi consumido/pulado por `read_field_value` acima.
250        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
265/// Decodifica uma mensagem `InstalledProductInfo`:
266///
267/// - Campo 1 = InternalId (string)
268/// - Campo 2 = ProductId (string)
269/// - Campo 3 = Data { campo 1 = Path (string) }
270fn 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            _ => {} // campo desconhecido: valor já foi "consumido" por read_field_value
285        }
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
297/// Decodifica a submensagem `Data`: campo 1 = Path (string).
298fn 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
318/// Lê a tag de um campo protobuf (varint) e devolve (número_do_campo, wire_type, novo_pos).
319fn 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
326/// Lê o valor de um campo de acordo com seu wire type, avançando `pos` corretamente mesmo para campos que serão ignorados.
327/// Para wire_type 2 (length-delimited), devolve o slice do valor.
328fn read_field_value(data: &[u8], pos: usize, wire_type: u8) -> Option<(Option<&[u8]>, usize)> {
329    match wire_type {
330        0 => {
331            // varint
332            let (_, new_pos) = read_varint(data, pos)?;
333            Some((None, new_pos))
334        }
335        1 => {
336            // 64-bit fixo
337            let new_pos = pos.checked_add(8)?;
338            (new_pos <= data.len()).then_some((None, new_pos))
339        }
340        2 => {
341            // length-delimited: string, bytes ou submensagem
342            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            // 32-bit fixo
349            let new_pos = pos.checked_add(4)?;
350            (new_pos <= data.len()).then_some((None, new_pos))
351        }
352        _ => None, // wire type desconhecido: não sabemos avançar com segurança
353    }
354}
355
356/// Lê um varint (LEB128 base 128) a partir de `pos`. Devolve (valor, novo_pos).
357fn 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; // varint malformado (excede 64 bits)
371        }
372    }
373}