Skip to main content

game_manager_lib/sources/
ubisoft.rs

1//! Implementação do GameSource para a Ubisoft
2//!
3//! Fontes de dados utilizadas:
4//!
5//! - `%LOCALAPPDATA%\Ubisoft Game Launcher\settings.yaml`
6//!   Contém o caminho base onde os jogos estão instalados (`misc.game_installation_path`).
7//!
8//! - `%LOCALAPPDATA%\Ubisoft Game Launcher\cache\configuration\configurations`
9//!   Arquivo binário com entradas YAML embutidas que lista todos os jogos da conta,
10//!   incluindo nome, identificador e caminho relativo do executável.
11//!
12//! **Nota sobre jogos instalados:**
13//! Para determinar se um jogo está instalado, verifica se sua pasta existe dentro
14//! de `game_installation_path`. O nome da pasta é inferido a partir do `game_identifier`
15//! (que normalmente corresponde ao nome da pasta de instalação).
16//!
17//! **Linux (Wine):**
18//! No Linux, o diretório de dados é resolvido a partir do `wine_prefix` configurado.
19//! O caminho equivale a `<prefix>/drive_c/users/<USER>/AppData/Local/Ubisoft Game Launcher`.
20//! Se nenhum prefix for fornecido, a importação não estará disponível no Linux.
21
22use crate::errors::AppError;
23use crate::sources::providers::{GameSource, SourceGame};
24use crate::utils::text::{is_likely_non_base_game, strip_trademark_symbols};
25use async_trait::async_trait;
26use std::fs;
27use std::path::{Path, PathBuf};
28
29pub struct UbisoftSource {
30    pub include_library_cache: bool,
31    /// Wine prefix utilizado no Linux para localizar o diretório de dados do Ubisoft Game Launcher. Ignorado no Windows.
32    #[allow(dead_code)]
33    pub wine_prefix: Option<PathBuf>,
34}
35
36impl UbisoftSource {
37    pub fn new(include_library_cache: bool, wine_prefix: Option<PathBuf>) -> Self {
38        Self {
39            include_library_cache,
40            wine_prefix,
41        }
42    }
43
44    /// Resolve o diretório de dados do Ubisoft Game Launcher.
45    ///
46    /// - **Windows:** `%LOCALAPPDATA%\Ubisoft Game Launcher`
47    /// - **Linux:** `<wine_prefix>/drive_c/users/<USER>/AppData/Local/Ubisoft Game Launcher`
48    fn resolve_launcher_data_dir(&self) -> Option<PathBuf> {
49        #[cfg(target_os = "windows")]
50        {
51            if let Ok(local_app_data) = std::env::var("LOCALAPPDATA") {
52                let path = Path::new(&local_app_data).join("Ubisoft Game Launcher");
53                if path.exists() {
54                    return Some(path);
55                }
56            }
57        }
58
59        #[cfg(target_os = "linux")]
60        {
61            if let Some(prefix) = &self.wine_prefix {
62                let user = std::env::var("USER").ok()?;
63                let path = prefix
64                    .join("drive_c")
65                    .join("users")
66                    .join(&user)
67                    .join("AppData")
68                    .join("Local")
69                    .join("Ubisoft Game Launcher");
70                if path.exists() {
71                    return Some(path);
72                }
73            }
74        }
75
76        None
77    }
78
79    /// Lê o `settings.yaml` e retorna o caminho base de instalação dos jogos.
80    ///
81    /// Extrai `misc.game_installation_path`, que indica onde os jogos ficam instalados.
82    fn read_game_installation_path(data_dir: &Path) -> Option<PathBuf> {
83        let settings_path = data_dir.join("settings.yaml");
84        let content = fs::read_to_string(&settings_path).ok()?;
85
86        // O campo está sempre dentro do bloco `misc:`, como:
87        //   misc:
88        //     game_installation_path: E:/Ubisoft Game Launcher/games/
89        let mut in_misc = false;
90        for line in content.lines() {
91            if line.trim_end() == "misc:" {
92                in_misc = true;
93                continue;
94            }
95
96            if in_misc {
97                // Se encontra outro bloco de nível superior, sai do bloco misc
98                if !line.starts_with(' ') && !line.is_empty() {
99                    break;
100                }
101
102                if let Some(rest) = line.trim_start().strip_prefix("game_installation_path: ") {
103                    let raw = rest.trim().replace('/', std::path::MAIN_SEPARATOR_STR);
104                    let path = PathBuf::from(raw);
105                    if path.exists() {
106                        return Some(path);
107                    }
108                }
109            }
110        }
111
112        None
113    }
114
115    /// Lê o cache de biblioteca da Ubisoft.
116    ///
117    /// Retorna uma tupla `(Vec<SourceGame>, HashMap<game_identifier, exe_relativo>)`.
118    fn read_configuration_library(
119        data_dir: &Path,
120        games_base_path: Option<&Path>,
121    ) -> Vec<SourceGame> {
122        let config_path = data_dir
123            .join("cache")
124            .join("configuration")
125            .join("configurations");
126
127        if !config_path.exists() {
128            return Vec::new();
129        }
130
131        let content = match fs::read(&config_path) {
132            Ok(bytes) => String::from_utf8_lossy(&bytes).to_string(),
133            Err(_) => return Vec::new(),
134        };
135
136        let mut games = Vec::new();
137        let mut seen_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
138        let mut seen_names: std::collections::HashSet<String> = std::collections::HashSet::new();
139
140        let mut current_name: Option<String> = None;
141        let mut current_id: Option<String> = None;
142        let mut current_exe: Option<String> = None;
143
144        let flush = |name: Option<String>,
145                     id: Option<String>,
146                     exe: Option<String>,
147                     games: &mut Vec<SourceGame>,
148                     seen_ids: &mut std::collections::HashSet<String>,
149                     seen_names: &mut std::collections::HashSet<String>| {
150            // Regra 1: game_identifier obrigatório — descarta stubs binários sem ID
151            let Some(game_id) = id else { return };
152            if game_id.is_empty() {
153                return;
154            }
155
156            // Resolve nome de exibição: usa o nome do bloco quando válido, senão o game_identifier
157            let raw_name = name.unwrap_or_default();
158            let is_placeholder =
159                raw_name.is_empty() || raw_name == "GAMENAME" || raw_name.to_lowercase() == "l1";
160            let n = if is_placeholder {
161                game_id.clone()
162            } else {
163                raw_name
164            };
165
166            if n.len() <= 2 || is_likely_non_base_game(&n) {
167                return;
168            }
169
170            // Nome com " - " mas game_identifier sem " - " indica DLC, conteúdo extra ou são dados incorretos
171            if n.contains(" - ") && !game_id.contains(" - ") {
172                return;
173            }
174
175            let name_key = strip_trademark_symbols(&n).to_lowercase();
176
177            if seen_ids.contains(&game_id) || seen_names.contains(&name_key) {
178                return;
179            }
180
181            seen_ids.insert(game_id.clone());
182            seen_names.insert(name_key);
183
184            // Verifica se o jogo está instalado procurando sua pasta em game_installation_path.
185            let (installed, install_path) = if let Some(base) = games_base_path {
186                let candidate = base.join(&game_id);
187                if candidate.exists() {
188                    (true, Some(candidate.to_string_lossy().to_string()))
189                } else {
190                    (false, None)
191                }
192            } else {
193                (false, None)
194            };
195
196            // Constrói o caminho absoluto do executável combinando install_path + relative
197            let executable_path = install_path.as_ref().and_then(|dir| {
198                exe.as_ref()
199                    .map(|rel| PathBuf::from(dir).join(rel).to_string_lossy().to_string())
200            });
201
202            games.push(SourceGame {
203                platform: "Ubisoft".to_string(),
204                platform_game_id: game_id,
205                name: Some(strip_trademark_symbols(&n)),
206                installed,
207                executable_path,
208                install_path,
209                playtime_minutes: None,
210                last_played: None,
211            });
212        };
213
214        for raw_line in content.lines() {
215            let line = raw_line.trim_end_matches('\r');
216
217            if line == "version: 2.0" {
218                let name = current_name.take();
219                let id = current_id.take();
220                let exe = current_exe.take();
221                flush(name, id, exe, &mut games, &mut seen_ids, &mut seen_names);
222                continue;
223            }
224
225            if let Some(rest) = line.strip_prefix("  name: ") {
226                let name = parse_yaml_string_value(rest);
227                if name.is_empty() || name == "GAMENAME" {
228                    continue;
229                }
230                let prev_name = current_name.take();
231                let prev_id = current_id.take();
232                let prev_exe = current_exe.take();
233                flush(
234                    prev_name,
235                    prev_id,
236                    prev_exe,
237                    &mut games,
238                    &mut seen_ids,
239                    &mut seen_names,
240                );
241                current_name = Some(name);
242                current_id = None;
243                current_exe = None;
244                continue;
245            }
246
247            if let Some(rest) = line.strip_prefix("    game_identifier: ") {
248                let id = parse_yaml_string_value(rest);
249                if !id.is_empty() && current_id.is_none() {
250                    current_id = Some(id);
251                }
252                continue;
253            }
254
255            // Caminho relativo do executável, filtra pelo nome do arquivo para excluir utilitários.
256            if current_exe.is_none() {
257                let trimmed = line.trim_start();
258                if let Some(rest) = trimmed.strip_prefix("relative: ") {
259                    let rel = rest.trim().trim_end_matches('\r');
260                    if rel.to_lowercase().ends_with(".exe") {
261                        let filename = Path::new(rel)
262                            .file_name()
263                            .map(|f| f.to_string_lossy().to_lowercase())
264                            .unwrap_or_default();
265
266                        let is_auxiliary = ["updater", "editor", "launcher", "setup", "patcher"]
267                            .iter()
268                            .any(|kw| filename.contains(kw));
269
270                        if !is_auxiliary {
271                            current_exe = Some(rel.to_string());
272                        }
273                    }
274                }
275            }
276        }
277
278        let name = current_name.take();
279        let id = current_id.take();
280        let exe = current_exe.take();
281        flush(name, id, exe, &mut games, &mut seen_ids, &mut seen_names);
282
283        games
284    }
285}
286
287/// Remove aspas simples, duplas e espaços ao redor do valor de um campo YAML.
288fn parse_yaml_string_value(s: &str) -> String {
289    let trimmed = s.trim().trim_end_matches('\r').trim();
290    let unquoted = trimmed
291        .strip_prefix('"')
292        .and_then(|s| s.strip_suffix('"'))
293        .or_else(|| {
294            trimmed
295                .strip_prefix('\'')
296                .and_then(|s| s.strip_suffix('\''))
297        })
298        .unwrap_or(trimmed);
299    unquoted.trim().trim_end_matches('\r').trim().to_string()
300}
301
302#[async_trait]
303impl GameSource for UbisoftSource {
304    async fn fetch_games(&self) -> Result<Vec<SourceGame>, AppError> {
305        let data_dir = self.resolve_launcher_data_dir().ok_or_else(|| {
306            AppError::NotFound(
307                "Pasta de dados do Ubisoft Game Launcher não encontrada. \
308                Verifique se o Ubisoft Connect está instalado."
309                    .to_string(),
310            )
311        })?;
312
313        // Lê o caminho base de instalação dos jogos a partir do settings.yaml.
314        // Se não encontrado, importa os jogos sem install_path e executable_path.
315        let games_base_path = Self::read_game_installation_path(&data_dir);
316
317        if !self.include_library_cache {
318            return Ok(Vec::new());
319        }
320
321        let games = Self::read_configuration_library(&data_dir, games_base_path.as_deref());
322
323        Ok(games)
324    }
325}