game_manager_lib/sources/
gog.rs1use 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
25pub 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
61impl 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 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
193async 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
235pub 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 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 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}