1use 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#[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#[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#[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#[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
127pub 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 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()); 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 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(), expires_at: refreshed.expires_in.map(|secs| now_unix() + secs as i64),
304 scope: stored.scope.clone(),
305 extra: stored.extra.clone(), };
307
308 save_oauth_token(&self.app_handle, AMAZON_PROVIDER_ID, &new_token)?;
309
310 Ok(new_token.access_token)
311 }
312
313 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 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(); 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("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
445fn 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#[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 }
565
566fn 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
573fn 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
591pub fn import_installed() -> Result<Vec<SourceGame>, AppError> {
593 let Some(db_path) = resolve_db_path() else {
594 return Ok(vec![]); };
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, 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
637pub 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), }
657 }
658}