game_manager_lib/sources/
ubisoft.rs1use 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 #[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 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 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 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 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 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 let Some(game_id) = id else { return };
152 if game_id.is_empty() {
153 return;
154 }
155
156 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 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 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 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 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
287fn 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 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}