Skip to main content

game_manager_lib/sources/
amazon.rs

1//! Source para importar a biblioteca da Amazon Games e Scan local de instalados via Amazon Games App.
2//!
3//! Diferente de GOG/Epic, a Amazon não usa OAuth2 padrão — usa um esquema de "registro de
4//! dispositivo" (o mesmo mecanismo usado por apps Kindle/Alexa), baseado em OpenID 2.0 para
5//! o login e um endpoint próprio (`/auth/register`) para trocar o código por tokens.
6//!
7//! Para detectar jogos instalados lê o SQLite mantido pelo próprio launcher em
8//! `%LOCALAPPDATA%\Amazon Games\Data\Games\Sql\GameInstallInfo.sqlite`.
9
10use crate::constants::{
11    AMAZON_API, AMAZON_APP_NAME, AMAZON_APP_VERSION, AMAZON_ASSOC_HANDLE, AMAZON_DEVICE_TYPE,
12    AMAZON_ENTITLEMENTS_KEY_ID, AMAZON_GAMING_DISTRIBUTION_ENTITLEMENTS, AMAZON_MARKETPLACE_ID,
13    AMAZON_REDIRECT_PREFIX, OAUTH_CALLBACK_TIMEOUT_SECS,
14};
15use crate::errors::AppError;
16use crate::sources::providers::{GameSource, SourceGame};
17use crate::utils::http_client::HTTP_CLIENT;
18use crate::utils::oauth::config::{now_unix, OAuthToken};
19use crate::utils::oauth::core::PkceChallenge;
20use crate::utils::oauth::token_store::{delete_oauth_token, load_oauth_token, save_oauth_token};
21use async_trait::async_trait;
22use serde::Deserialize;
23use sha2::{Digest, Sha256};
24use std::collections::HashMap;
25use std::fs;
26use std::path::{Path, PathBuf};
27use std::sync::mpsc;
28use std::time::Duration;
29use tauri::{AppHandle, WebviewUrl, WebviewWindowBuilder};
30use url::Url;
31
32const AMAZON_PROVIDER_ID: &str = "amazon";
33
34// === STRUCTS: resposta de register_device ===
35
36#[derive(Debug, Deserialize)]
37struct RegisterDeviceResponse {
38    response: RegisterDeviceResponseInner,
39}
40
41#[derive(Debug, Deserialize)]
42struct RegisterDeviceResponseInner {
43    success: RegisterDeviceSuccess,
44}
45
46#[derive(Debug, Deserialize)]
47struct RegisterDeviceSuccess {
48    tokens: RegisterDeviceTokens,
49    extensions: RegisterDeviceExtensions,
50}
51
52#[derive(Debug, Deserialize)]
53struct RegisterDeviceTokens {
54    bearer: RegisterDeviceBearerToken,
55}
56
57#[derive(Debug, Deserialize)]
58struct RegisterDeviceBearerToken {
59    access_token: String,
60    refresh_token: Option<String>,
61    #[serde(default, deserialize_with = "deserialize_expires_in")]
62    expires_in: Option<u64>,
63}
64
65#[derive(Debug, Deserialize)]
66struct RegisterDeviceExtensions {
67    customer_info: RegisterDeviceCustomerInfo,
68    device_info: RegisterDeviceDeviceInfo,
69}
70
71#[derive(Debug, Deserialize)]
72struct RegisterDeviceCustomerInfo {
73    #[serde(default)]
74    given_name: Option<String>,
75}
76
77#[derive(Debug, Deserialize)]
78struct RegisterDeviceDeviceInfo {
79    #[serde(default)]
80    device_serial_number: Option<String>,
81}
82
83// === STRUCTS: refresh ===
84
85#[derive(Debug, Deserialize)]
86struct AmazonRefreshResponse {
87    access_token: String,
88    #[serde(default, deserialize_with = "deserialize_expires_in")]
89    expires_in: Option<u64>,
90}
91
92// === STRUCTS: biblioteca (entitlements) ===
93
94#[derive(Debug, Deserialize)]
95struct AmazonEntitlementsResponse {
96    entitlements: Vec<AmazonEntitlement>,
97    #[serde(rename = "nextToken", default)]
98    next_token: Option<String>,
99}
100
101#[derive(Debug, Deserialize)]
102struct AmazonEntitlement {
103    product: AmazonProduct,
104}
105
106#[derive(Debug, Deserialize)]
107struct AmazonProduct {
108    id: String,
109    #[serde(default)]
110    title: Option<String>,
111}
112
113// === STRUCTS: fuel.json ===
114
115#[derive(Deserialize)]
116struct FuelMain {
117    #[serde(rename = "Command")]
118    command: String,
119}
120
121#[derive(Deserialize)]
122struct FuelManifest {
123    #[serde(rename = "Main")]
124    main: FuelMain,
125}
126
127// === SOURCE ===
128
129pub struct AmazonSource {
130    app_handle: AppHandle,
131}
132
133impl AmazonSource {
134    pub fn new(app_handle: AppHandle) -> Self {
135        Self { app_handle }
136    }
137
138    pub fn is_authenticated(&self) -> Result<bool, AppError> {
139        Ok(load_oauth_token(&self.app_handle, AMAZON_PROVIDER_ID)?.is_some())
140    }
141
142    /// Fluxo de login: gera identidade de dispositivo local (PKCE + serial arbitrário),
143    /// abre a tela de login da Amazon, intercepta o redirect final e troca o código
144    /// obtido por tokens via `register_device`.
145    ///
146    /// **Observação:** o esquema OpenID 2.0 usado aqui não tem parâmetro `state` —
147    /// diferente de GOG/Epic, não há validação de CSRF nesse retorno porque a própria
148    /// Amazon não devolve nada equivalente nesse fluxo.
149    pub async fn login(&self) -> Result<(), AppError> {
150        let pkce = PkceChallenge::generate();
151        let serial = generate_device_serial();
152        let client_id_hex = generate_client_id(&serial);
153
154        let auth_url = build_amazon_auth_url(&client_id_hex, &pkce.challenge)?;
155
156        let (tx, rx) = mpsc::channel::<Result<String, String>>();
157
158        let window = WebviewWindowBuilder::new(
159            &self.app_handle,
160            "amazon_oauth_login",
161            WebviewUrl::External(auth_url),
162        )
163        .title("Login Amazon Games")
164        .inner_size(480.0, 720.0)
165        .on_navigation(move |url| {
166            let url_str = url.as_str();
167            if url_str.starts_with(AMAZON_REDIRECT_PREFIX) {
168                let code = url
169                    .query_pairs()
170                    .find(|(k, _)| k == "openid.oa2.authorization_code")
171                    .map(|(_, v)| v.to_string());
172                let result = code.ok_or_else(|| {
173                    "Redirect da Amazon alcançado, mas sem 'openid.oa2.authorization_code' na URL"
174                        .to_string()
175                });
176                let _ = tx.send(result);
177                return false;
178            }
179            true
180        })
181        .build()
182        .map_err(|e| {
183            AppError::OAuthConfigError(format!("Falha ao abrir janela de login Amazon: {e}"))
184        })?;
185
186        let code = tokio::task::spawn_blocking(move || {
187            rx.recv_timeout(Duration::from_secs(OAUTH_CALLBACK_TIMEOUT_SECS))
188        })
189        .await
190        .map_err(|e| AppError::OAuthConfigError(format!("Task de callback falhou: {e}")))?
191        .map_err(|_| AppError::OAuthConfigError("Tempo limite de login excedido".to_string()))?
192        .map_err(AppError::OAuthConfigError)?;
193
194        let _ = window.close();
195
196        self.register_device(&code, &client_id_hex, &pkce.verifier, &serial)
197            .await
198    }
199
200    async fn register_device(
201        &self,
202        code: &str,
203        client_id: &str,
204        verifier: &str,
205        serial: &str,
206    ) -> Result<(), AppError> {
207        let body = serde_json::json!({
208            "auth_data": {
209                "authorization_code": code,
210                "client_domain": "DeviceLegacy",
211                "client_id": client_id,
212                "code_algorithm": "SHA-256",
213                "code_verifier": verifier,
214                "use_global_authentication": false,
215            },
216            "registration_data": {
217                "app_name": AMAZON_APP_NAME,
218                "app_version": AMAZON_APP_VERSION,
219                "device_model": "Windows",
220                "device_name": null,
221                "device_serial": serial,
222                "device_type": AMAZON_DEVICE_TYPE,
223                "domain": "Device",
224                "os_version": "10.0.19044.0",
225            },
226            "requested_extensions": ["customer_info", "device_info"],
227            "requested_token_type": ["bearer", "mac_dms"],
228            "user_context_map": {},
229        });
230
231        let response = HTTP_CLIENT
232            .post(format!("{AMAZON_API}/auth/register"))
233            .header("User-Agent", "AGSLauncher/1.0.0")
234            .json(&body)
235            .send()
236            .await?;
237
238        let status = response.status();
239        let resp_body = response.text().await.unwrap_or_default();
240
241        if !status.is_success() {
242            return Err(AppError::OAuthTokenExchangeError(format!(
243                "Amazon register_device retornou HTTP {status}: {resp_body}"
244            )));
245        }
246
247        let parsed: RegisterDeviceResponse = serde_json::from_str(&resp_body).map_err(|e| {
248            AppError::OAuthTokenExchangeError(format!(
249                "Resposta inesperada do register_device Amazon: {e}"
250            ))
251        })?;
252
253        let success = parsed.response.success;
254        let bearer = success.tokens.bearer;
255
256        let mut extra = HashMap::new();
257        let device_serial = success
258            .extensions
259            .device_info
260            .device_serial_number
261            .unwrap_or_else(|| serial.to_string()); // fallback pro serial gerado localmente
262        extra.insert("device_serial".to_string(), device_serial);
263
264        if let Some(name) = success.extensions.customer_info.given_name {
265            extra.insert("given_name".to_string(), name);
266        }
267
268        let token = OAuthToken {
269            access_token: bearer.access_token,
270            refresh_token: bearer.refresh_token,
271            expires_at: bearer.expires_in.map(|secs| now_unix() + secs as i64),
272            scope: None,
273            extra,
274        };
275
276        save_oauth_token(&self.app_handle, AMAZON_PROVIDER_ID, &token)?;
277
278        Ok(())
279    }
280
281    /// Retorna um access_token válido, renovando via refresh_token se expirado.
282    /// Fluxo de refresh custom (não usa o `refresh_access_token` genérico) porque
283    /// o corpo esperado pela Amazon não segue o formato OAuth2 padrão.
284    pub async fn ensure_valid_token(&self) -> Result<String, AppError> {
285        let stored = load_oauth_token(&self.app_handle, AMAZON_PROVIDER_ID)?
286            .ok_or_else(|| AppError::OAuthTokenNotFound(AMAZON_PROVIDER_ID.to_string()))?;
287
288        if !stored.is_expired() {
289            return Ok(stored.access_token);
290        }
291
292        let refresh_token = stored.refresh_token.clone().ok_or_else(|| {
293            AppError::OAuthTokenNotFound(
294                "amazon: token expirado e sem refresh_token, é necessário novo login".to_string(),
295            )
296        })?;
297
298        let refreshed = refresh_access_token_amazon(&refresh_token).await?;
299
300        let new_token = OAuthToken {
301            access_token: refreshed.access_token,
302            refresh_token: stored.refresh_token.clone(), // Amazon não reemite no refresh
303            expires_at: refreshed.expires_in.map(|secs| now_unix() + secs as i64),
304            scope: stored.scope.clone(),
305            extra: stored.extra.clone(), // preserva device_serial
306        };
307
308        save_oauth_token(&self.app_handle, AMAZON_PROVIDER_ID, &new_token)?;
309
310        Ok(new_token.access_token)
311    }
312
313    /// Desconecta a conta: tenta desregistrar o dispositivo no servidor da Amazon,
314    /// mas remove o token local mesmo se a chamada falhar (token já expirado, rede fora do ar).
315    pub async fn logout(&self) -> Result<(), AppError> {
316        let Some(stored) = load_oauth_token(&self.app_handle, AMAZON_PROVIDER_ID)? else {
317            return delete_oauth_token(&self.app_handle, AMAZON_PROVIDER_ID);
318        };
319
320        let response = HTTP_CLIENT
321            .post(format!("{AMAZON_API}/auth/deregister"))
322            .header("Authorization", format!("bearer {}", stored.access_token))
323            .header("User-Agent", "AGSLauncher/1.0.0")
324            .json(&serde_json::json!({
325                "request_metadata": {
326                    "app_name": AMAZON_APP_NAME,
327                    "app_version": AMAZON_APP_VERSION,
328                }
329            }))
330            .send()
331            .await;
332
333        if let Ok(resp) = &response {
334            if !resp.status().is_success() {
335                log::warn!(
336                    "Amazon deregister retornou status não-OK; removendo token local mesmo assim"
337                );
338            }
339        }
340
341        delete_oauth_token(&self.app_handle, AMAZON_PROVIDER_ID)
342    }
343
344    /// Importa a biblioteca completa de entitlements possuídos na conta Amazon.
345    pub async fn fetch_library_detailed(&self) -> Result<Vec<SourceGame>, AppError> {
346        let access_token = self.ensure_valid_token().await?;
347
348        let stored = load_oauth_token(&self.app_handle, AMAZON_PROVIDER_ID)?
349            .ok_or_else(|| AppError::OAuthTokenNotFound(AMAZON_PROVIDER_ID.to_string()))?;
350        let serial = stored.extra.get("device_serial").ok_or_else(|| {
351            AppError::OAuthConfigError(
352                "Amazon: device_serial ausente no token salvo — faça login novamente".to_string(),
353            )
354        })?;
355
356        let hardware_hash = {
357            let mut hasher = Sha256::new();
358            hasher.update(serial.as_bytes());
359            hex_upper(&hasher.finalize())
360        };
361
362        let mut products: HashMap<String, String> = HashMap::new(); // id -> title
363        let mut next_token: Option<String> = None;
364
365        loop {
366            let body = serde_json::json!({
367                "Operation": "GetEntitlements",
368                "clientId": "Sonic",
369                "syncPoint": null,
370                "nextToken": next_token,
371                "maxResults": 50,
372                "productIdFilter": null,
373                "keyId": AMAZON_ENTITLEMENTS_KEY_ID,
374                "hardwareHash": hardware_hash,
375            });
376
377            let response = HTTP_CLIENT
378                .post(AMAZON_GAMING_DISTRIBUTION_ENTITLEMENTS)
379                .header(
380                    "X-Amz-Target",
381                    "com.amazon.animusdistributionservice.entitlement.AnimusEntitlementsService.GetEntitlements",
382                )
383                .header("x-amzn-token", &access_token)
384                // Header não-padrão "UserAgent" (sem hífen) — replicado fielmente do cliente de referência, distinto do header HTTP padrão "User-Agent".
385                .header("UserAgent", "com.amazon.agslauncher.win/3.0.9202.1")
386                .header("Content-Type", "application/json")
387                .header("Content-Encoding", "amz-1.0")
388                .json(&body)
389                .send()
390                .await?;
391
392            let status = response.status();
393            let resp_body = response.text().await.unwrap_or_default();
394
395            if !status.is_success() {
396                return Err(AppError::NetworkError(format!(
397                    "Amazon entitlements retornou HTTP {status}: {resp_body}"
398                )));
399            }
400
401            let parsed: AmazonEntitlementsResponse =
402                serde_json::from_str(&resp_body).map_err(|e| {
403                    AppError::ParseError(format!(
404                        "Falha ao parsear biblioteca Amazon: {e} — corpo: {resp_body}"
405                    ))
406                })?;
407
408            for entitlement in parsed.entitlements {
409                let id = entitlement.product.id;
410                let title = entitlement.product.title.unwrap_or_else(|| id.clone());
411                products.entry(id).or_insert(title);
412            }
413
414            match parsed.next_token {
415                Some(t) if !t.is_empty() => next_token = Some(t),
416                _ => break,
417            }
418        }
419
420        let games = products
421            .into_iter()
422            .map(|(id, title)| SourceGame {
423                platform: "Amazon".to_string(),
424                platform_game_id: id,
425                name: Some(title),
426                installed: false,
427                executable_path: None,
428                install_path: None,
429                playtime_minutes: None,
430                last_played: None,
431            })
432            .collect();
433
434        Ok(games)
435    }
436}
437
438#[async_trait]
439impl GameSource for AmazonSource {
440    async fn fetch_games(&self) -> Result<Vec<SourceGame>, AppError> {
441        self.fetch_library_detailed().await
442    }
443}
444
445// === FUNÇÕES AUXILIARES ===
446
447/// Aceita `expires_in` tanto como número quanto como string (a Amazon retorna como string em `register_device`).
448fn deserialize_expires_in<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
449where
450    D: serde::Deserializer<'de>,
451{
452    #[derive(Deserialize)]
453    #[serde(untagged)]
454    enum StringOrNumber {
455        String(String),
456        Number(u64),
457    }
458
459    let opt = Option::<StringOrNumber>::deserialize(deserializer)?;
460    Ok(match opt {
461        Some(StringOrNumber::String(s)) => s.parse::<u64>().ok(),
462        Some(StringOrNumber::Number(n)) => Some(n),
463        None => None,
464    })
465}
466
467async fn refresh_access_token_amazon(
468    refresh_token: &str,
469) -> Result<AmazonRefreshResponse, AppError> {
470    let body = serde_json::json!({
471        "source_token": refresh_token,
472        "source_token_type": "refresh_token",
473        "requested_token_type": "access_token",
474        "app_name": AMAZON_APP_NAME,
475        "app_version": AMAZON_APP_VERSION,
476    });
477
478    let response = HTTP_CLIENT
479        .post(format!("{AMAZON_API}/auth/token"))
480        .header("User-Agent", "AGSLauncher/1.0.0")
481        .json(&body)
482        .send()
483        .await?;
484
485    let status = response.status();
486    let resp_body = response.text().await.unwrap_or_default();
487
488    if !status.is_success() {
489        return Err(AppError::OAuthTokenExchangeError(format!(
490            "Amazon refresh retornou HTTP {status}: {resp_body}"
491        )));
492    }
493
494    serde_json::from_str(&resp_body).map_err(|e| {
495        AppError::OAuthTokenExchangeError(format!("Resposta inesperada do refresh Amazon: {e}"))
496    })
497}
498
499fn build_amazon_auth_url(client_id_hex: &str, challenge: &str) -> Result<Url, AppError> {
500    let mut url = Url::parse("https://www.amazon.com/ap/signin")
501        .map_err(|e| AppError::OAuthConfigError(format!("URL de login Amazon inválida: {e}")))?;
502
503    url.query_pairs_mut()
504        .append_pair("openid.ns", "http://specs.openid.net/auth/2.0")
505        .append_pair(
506            "openid.claimed_id",
507            "http://specs.openid.net/auth/2.0/identifier_select",
508        )
509        .append_pair(
510            "openid.identity",
511            "http://specs.openid.net/auth/2.0/identifier_select",
512        )
513        .append_pair("openid.mode", "checkid_setup")
514        .append_pair("openid.oa2.scope", "device_auth_access")
515        .append_pair("openid.ns.oa2", "http://www.amazon.com/ap/ext/oauth/2")
516        .append_pair("openid.oa2.response_type", "code")
517        .append_pair("openid.oa2.code_challenge_method", "S256")
518        .append_pair("openid.oa2.client_id", &format!("device:{client_id_hex}"))
519        .append_pair("language", "en_US")
520        .append_pair("marketPlaceId", AMAZON_MARKETPLACE_ID)
521        .append_pair("openid.return_to", "https://www.amazon.com")
522        .append_pair("openid.pape.max_auth_age", "0")
523        .append_pair("openid.assoc_handle", AMAZON_ASSOC_HANDLE)
524        .append_pair("pageId", AMAZON_ASSOC_HANDLE)
525        .append_pair("openid.oa2.code_challenge", challenge);
526
527    Ok(url)
528}
529
530fn generate_device_serial() -> String {
531    uuid::Uuid::new_v4().simple().to_string().to_uppercase()
532}
533
534fn generate_client_id(serial: &str) -> String {
535    let serial_ex = format!("{serial}#{AMAZON_DEVICE_TYPE}");
536    serial_ex
537        .as_bytes()
538        .iter()
539        .map(|b| format!("{b:02x}"))
540        .collect()
541}
542
543fn hex_upper(bytes: &[u8]) -> String {
544    bytes.iter().map(|b| format!("{b:02X}")).collect()
545}
546
547// === JOGOS INSTALADOS ===
548
549#[cfg(target_os = "windows")]
550fn resolve_db_path() -> Option<PathBuf> {
551    let local_appdata = std::env::var("LOCALAPPDATA").ok()?;
552    let path = PathBuf::from(local_appdata)
553        .join("Amazon Games")
554        .join("Data")
555        .join("Games")
556        .join("Sql")
557        .join("GameInstallInfo.sqlite");
558    path.exists().then_some(path)
559}
560
561#[cfg(not(target_os = "windows"))]
562fn resolve_db_path() -> Option<PathBuf> {
563    None // Amazon Games App é Windows-only
564}
565
566/// Copia o DB pra um arquivo temporário antes de abrir — evita conflito de lock com o Amazon Games App, caso esteja rodando ao mesmo tempo.
567fn copy_to_temp(source: &PathBuf) -> Result<PathBuf, AppError> {
568    let temp_path = std::env::temp_dir().join("playlite_amazon_gameinstallinfo.sqlite");
569    fs::copy(source, &temp_path)?;
570    Ok(temp_path)
571}
572
573/// Resolve o executável de um jogo Amazon Games a partir do `fuel.json` salvo na pasta de instalação.
574///
575/// **Observação:** formato não documentado oficialmente pela Amazon — inferido a partir do próprio
576/// arquivo e de integrações da comunidade (Nile, Playnite). Se o arquivo não existir, vier malformado,
577/// ou `Command` estiver vazio, retorna `None` sem erro — quem chama cai pro fallback (launcher).
578fn resolve_amazon_executable(install_dir: &Path) -> Option<PathBuf> {
579    let fuel_path = install_dir.join("fuel.json");
580    let content = fs::read_to_string(&fuel_path).ok()?;
581    let manifest: FuelManifest = serde_json::from_str(&content).ok()?;
582
583    let command = manifest.main.command.trim();
584    if command.is_empty() {
585        return None;
586    }
587
588    Some(install_dir.join(command))
589}
590
591/// Importa jogos instalados detectados no banco local do Amazon Games App.
592pub fn import_installed() -> Result<Vec<SourceGame>, AppError> {
593    let Some(db_path) = resolve_db_path() else {
594        return Ok(vec![]); // Amazon Games App não instalado, ou não é Windows
595    };
596
597    let temp_path = copy_to_temp(&db_path)?;
598    let conn = rusqlite::Connection::open_with_flags(
599        &temp_path,
600        rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
601    )?;
602
603    let mut stmt = conn.prepare(
604        "SELECT Id, InstallDirectory, Installed, ProductTitle FROM DbSet WHERE Installed = 1",
605    )?;
606
607    let games = stmt
608        .query_map([], |row| {
609            let id: String = row.get(0)?;
610            let install_dir: Option<String> = row.get(1)?;
611            let title: Option<String> = row.get(3)?;
612
613            let executable_path = install_dir
614                .as_ref()
615                .and_then(|dir| resolve_amazon_executable(Path::new(dir)))
616                .map(|p| p.to_string_lossy().to_string());
617
618            Ok(SourceGame {
619                platform: "Amazon".to_string(),
620                platform_game_id: id,
621                name: title,
622                installed: true,
623                executable_path, // resolvido via fuel.json quando disponível
624                install_path: install_dir,
625                playtime_minutes: None,
626                last_played: None,
627            })
628        })?
629        .filter_map(|r| r.ok())
630        .collect();
631
632    let _ = fs::remove_file(&temp_path);
633
634    Ok(games)
635}
636
637/// Cruza a biblioteca completa (OAuth) com os jogos detectados localmente.
638/// Diferente da Epic, o merge é por `platform_game_id` exato (mesmo formato de ID em ambas as fontes),
639/// não por nome — mais confiável, sem falso-positivo/negativo por variação de grafia entre local e API.
640pub fn merge_local_install_status(
641    library_games: &mut Vec<SourceGame>,
642    local_games: Vec<SourceGame>,
643) {
644    for local in local_games {
645        let matched = library_games
646            .iter_mut()
647            .find(|g| g.platform_game_id == local.platform_game_id);
648
649        match matched {
650            Some(g) => {
651                g.installed = true;
652                g.install_path = local.install_path;
653                g.executable_path = local.executable_path;
654            }
655            None => library_games.push(local), // instalado mas ausente da lib (raro; mantém mesmo assim)
656        }
657    }
658}