Skip to main content

game_manager_lib/sources/
gog.rs

1//! Source para importar a biblioteca de jogos possuídos na conta GOG.
2
3use crate::constants::{
4    GOG_AUTH_ENDPOINT, GOG_CLIENT_ID, GOG_CLIENT_SECRET, GOG_FILTERED_PRODUCTS_ENDPOINT,
5    GOG_REDIRECT_URI, GOG_TOKEN_ENDPOINT,
6};
7use crate::errors::AppError;
8use crate::sources::providers::{GameSource, OAuthGameSource, SourceGame};
9use crate::utils::http_client::HTTP_CLIENT;
10use crate::utils::oauth::config::{
11    build_authorize_url, exchange_code_for_token, OAuthProviderConfig, TokenAuthMethod,
12    TokenRequestMethod,
13};
14use crate::utils::oauth::core::{generate_state, AuthCallbackResult};
15use crate::utils::oauth::token_store::save_oauth_token;
16use crate::utils::text::is_likely_non_base_game;
17use async_trait::async_trait;
18use serde::Deserialize;
19use std::fs;
20use std::path::{Path, PathBuf};
21use std::sync::mpsc;
22use std::time::Duration;
23use tauri::{AppHandle, WebviewUrl, WebviewWindowBuilder};
24
25// === STRUCTS ===
26
27pub struct GogSource {
28    app_handle: AppHandle,
29    config: OAuthProviderConfig,
30}
31
32#[derive(Debug, Deserialize)]
33struct GogProduct {
34    id: i64,
35    title: String,
36}
37
38#[derive(Debug, Deserialize)]
39struct GogFilteredProductsResponse {
40    #[serde(rename = "totalPages")]
41    total_pages: u32,
42    products: Vec<GogProduct>,
43}
44
45#[derive(Deserialize)]
46struct GogPlayTask {
47    #[serde(rename = "type")]
48    task_type: String,
49    category: Option<String>,
50    path: Option<String>,
51    #[serde(rename = "isPrimary", default)]
52    is_primary: bool,
53}
54
55#[derive(Deserialize)]
56struct GogGameInfo {
57    #[serde(rename = "playTasks")]
58    play_tasks: Vec<GogPlayTask>,
59}
60
61// === IMPLEMENTAÇÕES ===
62
63impl GogSource {
64    pub fn new(app_handle: AppHandle) -> Self {
65        let config = OAuthProviderConfig {
66            provider_id: "gog",
67            client_id: GOG_CLIENT_ID.to_string(),
68            client_secret: Some(GOG_CLIENT_SECRET.to_string()),
69            authorize_endpoint: GOG_AUTH_ENDPOINT.to_string(),
70            token_endpoint: GOG_TOKEN_ENDPOINT.to_string(),
71            redirect_uri: GOG_REDIRECT_URI.to_string(),
72            scopes: vec![],
73            uses_pkce: false,
74            extra_params: vec![("layout".into(), "galaxy".into())],
75            token_request_method: TokenRequestMethod::Get,
76            token_auth_method: TokenAuthMethod::Body,
77        };
78
79        Self { app_handle, config }
80    }
81
82    pub async fn fetch_games_detailed(&self) -> Result<Vec<SourceGame>, AppError> {
83        let access_token = self.ensure_valid_token().await?;
84        let products = fetch_all_owned_products(&access_token).await?;
85
86        let games = products
87            .into_iter()
88            .filter(|p| !is_likely_non_base_game(&p.title))
89            .map(|p| SourceGame {
90                platform: "GOG".to_string(),
91                platform_game_id: p.id.to_string(),
92                name: Some(p.title),
93                installed: false,
94                executable_path: None,
95                install_path: None,
96                playtime_minutes: None,
97                last_played: None,
98            })
99            .collect();
100
101        Ok(games)
102    }
103}
104
105#[async_trait]
106impl GameSource for GogSource {
107    async fn fetch_games(&self) -> Result<Vec<SourceGame>, AppError> {
108        self.fetch_games_detailed().await
109    }
110}
111
112#[async_trait]
113impl OAuthGameSource for GogSource {
114    fn oauth_config(&self) -> &OAuthProviderConfig {
115        &self.config
116    }
117
118    fn app_handle(&self) -> &AppHandle {
119        &self.app_handle
120    }
121
122    /// GOG não aceita redirect_uri para localhost. O redirect registrado (`embed.gog.com/on_login_success?origin=client`) é interceptado numa
123    /// `WebviewWindow` própria antes de carregar, em vez de usar o servidor local (`wait_for_auth_code`) do fluxo default do trait.
124    async fn login(&self) -> Result<(), AppError> {
125        let config = self.oauth_config();
126        let expected_state = generate_state();
127        let auth_url = build_authorize_url(config, None, &expected_state)?;
128
129        let (tx, rx) = mpsc::channel::<Result<AuthCallbackResult, String>>();
130        let redirect_uri = config.redirect_uri.clone();
131
132        let window = WebviewWindowBuilder::new(
133            &self.app_handle,
134            "gog_oauth_login",
135            WebviewUrl::External(auth_url),
136        )
137        .title("Login GOG")
138        .inner_size(480.0, 720.0)
139        .on_navigation(move |url| {
140            let url_str = url.as_str();
141            if url_str.starts_with(&redirect_uri) {
142                let code = url
143                    .query_pairs()
144                    .find(|(k, _)| k == "code")
145                    .map(|(_, v)| v.to_string());
146                let state = url
147                    .query_pairs()
148                    .find(|(k, _)| k == "state")
149                    .map(|(_, v)| v.to_string());
150
151                let result = match code {
152                    Some(code) => Ok(AuthCallbackResult { code, state }),
153                    None => Err("Redirect alcançado, mas sem 'code' na URL".to_string()),
154                };
155                let _ = tx.send(result);
156                return false;
157            }
158            true
159        })
160        .build()
161        .map_err(|e| {
162            AppError::OAuthConfigError(format!("Falha ao abrir janela de login GOG: {e}"))
163        })?;
164
165        let callback = tokio::task::spawn_blocking(move || {
166            rx.recv_timeout(Duration::from_secs(
167                crate::constants::OAUTH_CALLBACK_TIMEOUT_SECS,
168            ))
169        })
170        .await
171        .map_err(|e| AppError::OAuthConfigError(format!("Task de callback falhou: {e}")))?
172        .map_err(|_| AppError::OAuthConfigError("Tempo limite de login excedido".to_string()))?
173        .map_err(AppError::OAuthConfigError)?;
174
175        let _ = window.close();
176
177        match &callback.state {
178            Some(state) if state == &expected_state => {}
179            _ => {
180                return Err(AppError::OAuthConfigError(
181                    "Parâmetro 'state' ausente ou não confere (possível CSRF)".into(),
182                ))
183            }
184        }
185
186        let token_response = exchange_code_for_token(config, &callback.code, None).await?;
187        save_oauth_token(&self.app_handle, config.provider_id, &token_response.into())?;
188
189        Ok(())
190    }
191}
192
193// === FUNÇÕES AUXILIARES ===
194
195/// Busca todos os jogos possuídos, já com nome, percorrendo a paginação de `/account/getFilteredProducts`.
196async fn fetch_all_owned_products(access_token: &str) -> Result<Vec<GogProduct>, AppError> {
197    let mut all_products = Vec::new();
198    let mut page = 1u32;
199
200    loop {
201        let response = HTTP_CLIENT
202            .get(GOG_FILTERED_PRODUCTS_ENDPOINT)
203            .bearer_auth(access_token)
204            .query(&[("mediaType", "1"), ("page", &page.to_string())])
205            .send()
206            .await?;
207
208        if !response.status().is_success() {
209            let status = response.status();
210            let body = response.text().await.unwrap_or_default();
211            return Err(AppError::NetworkError(format!(
212                "GOG getFilteredProducts retornou HTTP {status}: {body}"
213            )));
214        }
215
216        let parsed: GogFilteredProductsResponse = response
217            .json()
218            .await
219            .map_err(|e| AppError::ParseError(format!("Falha ao parsear produtos GOG: {e}")))?;
220
221        let total_pages = parsed.total_pages;
222        all_products.extend(parsed.products);
223
224        log::debug!("GOG getFilteredProducts: página {page}/{total_pages}");
225
226        if page >= total_pages {
227            break;
228        }
229        page += 1;
230    }
231
232    Ok(all_products)
233}
234
235/// Marca como instalados os jogos cuja pasta existe dentro do diretório `Games` configurado do GOG Galaxy.
236///
237/// **Heurística:** o GOG Galaxy cria uma subpasta por jogo com o nome do produto exatamente como aparece na loja.
238/// **Exemplo:** "Tomb Raider I-III Remastered"
239///
240/// **Observação:** Alguns jogos na loja têm sufixos extras no nome (ex: "Leap of Faith + Official Walkthrough"),
241/// enquanto a pasta instalada usa apenas o nome base do jogo. Por isso o match verifica se o nome da
242/// loja *começa com* o nome da pasta, e não por igualdade exata.
243pub fn detect_installed_games(games: &mut [SourceGame], gog_games_dir: &Path) {
244    let Ok(entries) = fs::read_dir(gog_games_dir) else {
245        log::warn!("Não foi possível ler diretório de jogos GOG: {gog_games_dir:?}");
246        return;
247    };
248
249    let installed_folders: Vec<(String, std::path::PathBuf)> = entries
250        .flatten()
251        .filter(|e| e.path().is_dir())
252        .filter_map(|e| {
253            let name = e.file_name().to_string_lossy().trim().to_lowercase();
254            if name.is_empty() {
255                return None;
256            }
257            Some((name, e.path()))
258        })
259        .collect();
260
261    for game in games.iter_mut() {
262        let Some(name) = &game.name else {
263            continue;
264        };
265        let normalized = name.trim().to_lowercase();
266
267        // Verifica se o nome da loja começa com o nome de alguma pasta instalada, garantindo
268        // que o caractere seguinte ao prefixo (se houver) não seja alfanumérico.
269        let matched = installed_folders.iter().find(|(folder, _)| {
270            if !normalized.starts_with(folder.as_str()) {
271                return false;
272            }
273            match normalized.as_bytes().get(folder.len()) {
274                None => true,
275                Some(&b) => !(b as char).is_alphanumeric(),
276            }
277        });
278
279        if let Some((_, path)) = matched {
280            game.installed = true;
281            game.install_path = Some(path.to_string_lossy().to_string());
282            game.executable_path = resolve_gog_executable(path, &game.platform_game_id)
283                .map(|p| p.to_string_lossy().to_string());
284        }
285    }
286}
287
288fn resolve_gog_executable(install_path: &Path, product_id: &str) -> Option<PathBuf> {
289    let info_path = install_path.join(format!("goggame-{product_id}.info"));
290    let content = fs::read_to_string(&info_path).ok()?;
291    let info: GogGameInfo = serde_json::from_str(&content).ok()?;
292
293    let is_game_file_task =
294        |t: &GogPlayTask| t.task_type == "FileTask" && t.category.as_deref() == Some("game");
295
296    // Preferência: task marcado como primário. Fallback: primeiro FileTask de categoria "game",
297    // já que alguns goggame-*.info não marcam isPrimary em nenhum task.
298    info.play_tasks
299        .iter()
300        .find(|t| is_game_file_task(t) && t.is_primary)
301        .or_else(|| info.play_tasks.iter().find(|t| is_game_file_task(t)))
302        .and_then(|t| t.path.as_ref())
303        .map(|rel| install_path.join(rel))
304}