Skip to main content

game_manager_lib/sources/
xbox.rs

1//! Source para importar jogos instalados via Microsoft Store / Xbox App (Gaming Services).
2//!
3//! Diferente do mecanismo UWP clássico (`Get-AppxPackage`), jogos "pesados" instalados
4//! via Xbox App ou Microsoft Store com suporte a Gaming Services ficam registrados em
5//! uma pasta própria por drive, localizada através de um arquivo marcador `.GamingRoot`
6//! na raiz do drive.
7//!
8//! **Formato do `.GamingRoot`** (confirmado empiricamente, não documentado oficialmente):
9//! - 4 bytes de assinatura: `RGBX`
10//! - 4 bytes: flag/contador (observado sempre `01 00 00 00`)
11//! - nome da pasta em UTF-16LE, terminado em `\0\0` (ex: "XboxGames")
12//!
13//! Cada jogo fica em `<drive>\<pasta>\<nome ou GUID>\content\MicrosoftGame.config`,
14//! um manifesto XML com nome de exibição, executável principal e `StoreId`.
15//!
16//! **Filtro de DLC/addons:** um manifesto sem nenhum `<Executable>` dentro de
17//! `<ExecutableList>` não é um jogo standalone — é conteúdo adicional vinculado a um
18//! jogo base via `<AllowedProducts>`/`<MainPackageDependency>`. Esses são descartados.
19//!
20//! **Limitação conhecida:** jogos que usam apenas o mecanismo UWP clássico sem passar
21//! por Gaming Services (raro para jogos "de peso"; mais comum em apps casuais como
22//! Microsoft Solitaire Collection) não são detectados por este scanner.
23
24use crate::errors::AppError;
25use crate::sources::providers::SourceGame;
26use serde::Deserialize;
27use std::fs;
28use std::path::{Path, PathBuf};
29
30// === STRUCTS: MicrosoftGame.config ===
31
32#[derive(Debug, Deserialize)]
33#[serde(rename = "Game")]
34struct GameManifest {
35    #[serde(rename = "Identity")]
36    identity: Identity,
37    #[serde(rename = "StoreId", default)]
38    store_id: Option<String>,
39    #[serde(rename = "ShellVisuals", default)]
40    shell_visuals: Option<ShellVisuals>,
41    #[serde(rename = "ExecutableList", default)]
42    executable_list: Option<ExecutableList>,
43}
44
45#[derive(Debug, Deserialize)]
46struct Identity {
47    #[serde(rename = "@Name")]
48    name: String,
49}
50
51#[derive(Debug, Deserialize)]
52struct ShellVisuals {
53    #[serde(rename = "@DefaultDisplayName", default)]
54    default_display_name: Option<String>,
55}
56
57#[derive(Debug, Deserialize, Default)]
58struct ExecutableList {
59    #[serde(rename = "Executable", default)]
60    executables: Vec<Executable>,
61}
62
63#[derive(Debug, Deserialize)]
64struct Executable {
65    #[serde(rename = "@Name")]
66    name: String,
67    #[serde(rename = "@OverrideDisplayName", default)]
68    override_display_name: Option<String>,
69}
70
71// === SCAN ===
72
73/// Importa jogos instalados detectados via Gaming Services em todos os drives do sistema.
74pub fn import_installed() -> Result<Vec<SourceGame>, AppError> {
75    let mut games = Vec::new();
76
77    for drive in candidate_drives() {
78        let Some(games_folder_name) = read_gaming_root(&drive.join(".GamingRoot")) else {
79            continue; // sem marcador nesse drive, nada a fazer
80        };
81
82        let games_root = drive.join(&games_folder_name);
83        if !games_root.is_dir() {
84            log::warn!(
85                "'.GamingRoot' aponta para '{games_folder_name}' mas a pasta não existe em {drive:?}"
86            );
87            continue;
88        }
89
90        let Ok(entries) = fs::read_dir(&games_root) else {
91            continue;
92        };
93
94        for entry in entries.flatten() {
95            let path = entry.path();
96            if !path.is_dir() {
97                continue;
98            }
99
100            match parse_game_folder(&path) {
101                Ok(Some(game)) => games.push(game),
102                Ok(None) => {} // DLC/addon, descartado silenciosamente
103                Err(err) => {
104                    log::warn!("Erro ao processar manifesto Xbox em {path:?}: {err}");
105                }
106            }
107        }
108    }
109
110    Ok(games)
111}
112
113/// Enumera drives existentes no sistema (A: até Z:).
114#[cfg(target_os = "windows")]
115fn candidate_drives() -> Vec<PathBuf> {
116    (b'A'..=b'Z')
117        .map(|b| PathBuf::from(format!("{}:\\", b as char)))
118        .filter(|p| p.exists())
119        .collect()
120}
121
122#[cfg(not(target_os = "windows"))]
123fn candidate_drives() -> Vec<PathBuf> {
124    Vec::new() // Gaming Services é exclusivo do Windows
125}
126
127/// Lê e decodifica o arquivo `.GamingRoot`, retornando o nome da pasta de jogos
128/// (ex: "XboxGames"). Retorna `None` se o arquivo não existir ou o formato não bater.
129fn read_gaming_root(path: &Path) -> Option<String> {
130    let bytes = fs::read(path).ok()?;
131
132    if bytes.len() < 8 || &bytes[0..4] != b"RGBX" {
133        return None;
134    }
135
136    let payload = &bytes[8..];
137    let mut units = Vec::with_capacity(payload.len() / 2);
138    let mut i = 0;
139    while i + 1 < payload.len() {
140        let unit = u16::from_le_bytes([payload[i], payload[i + 1]]);
141        if unit == 0 {
142            break;
143        }
144        units.push(unit);
145        i += 2;
146    }
147
148    String::from_utf16(&units).ok().filter(|s| !s.is_empty())
149}
150
151/// Lê e interpreta o manifesto de uma pasta de jogo. Retorna `None` se for DLC/addon
152/// (sem executável próprio), ou erro se o manifesto estiver ausente/corrompido.
153fn parse_game_folder(game_folder: &Path) -> Result<Option<SourceGame>, AppError> {
154    let config_path = game_folder.join("content").join("MicrosoftGame.config");
155    if !config_path.exists() {
156        return Ok(None); // pasta sem manifesto reconhecível; ignora
157    }
158
159    let content = fs::read_to_string(&config_path)?;
160    let manifest: GameManifest = quick_xml::de::from_str(&content)
161        .map_err(|e| AppError::ParseError(format!("Falha ao parsear {config_path:?}: {e}")))?;
162
163    let Some(executable_list) = &manifest.executable_list else {
164        return Ok(None); // sem <ExecutableList> — DLC/addon
165    };
166
167    let Some(executable) = executable_list.executables.first() else {
168        return Ok(None); // <ExecutableList> vazio — DLC/addon
169    };
170
171    let name = executable
172        .override_display_name
173        .clone()
174        .filter(|s| !s.trim().is_empty())
175        .or_else(|| {
176            manifest
177                .shell_visuals
178                .as_ref()
179                .and_then(|sv| sv.default_display_name.clone())
180                .filter(|s| !s.trim().is_empty())
181        })
182        .unwrap_or_else(|| manifest.identity.name.clone());
183
184    // Executable.Name pode conter subcaminho (ex: "launcher/idTechLauncher.exe");
185    // PathBuf::join lida com isso normalmente independente do separador usado.
186    let executable_path = game_folder
187        .join("content")
188        .join(&executable.name)
189        .to_string_lossy()
190        .to_string();
191
192    let platform_game_id = manifest
193        .store_id
194        .clone()
195        .unwrap_or_else(|| manifest.identity.name.clone());
196
197    Ok(Some(SourceGame {
198        platform: "Xbox".to_string(),
199        platform_game_id,
200        name: Some(name),
201        installed: true,
202        executable_path: Some(executable_path),
203        install_path: Some(game_folder.join("content").to_string_lossy().to_string()),
204        playtime_minutes: None,
205        last_played: None,
206    }))
207}