Files
The412Banner 4b17bc9a23 feat: rewrite the Epic and Amazon store modules (#802)
Both store modules are rewritten from a protocol-level spec: new Rust engines behind a small JNI surface (nativeCall/nativeInstall) and thin Kotlin facades, with the launcher's own user agent.

Epic: sign-in carries over, library, manifest download with byte-exact verification, assembly, uninstall, launch arguments, achievements.
Amazon: sign-in carries over, library, manifest download with per-file hash checks, path checks, executable detection from fuel.json, uninstall.
On-disk install layout is unchanged, so existing installs keep working.

Store game pages now take controller focus on their lead hero action (Install, Uninstall, Pause, Resume, Get, Buy), fall back to Back, and keep D-pad focus inside the page while it covers the library.
2026-10-14 07:20:06 -04:00

12 KiB
Raw Permalink Blame History

Amazon Games module: clean-room rewrite

The Amazon Games integration (the Rust amazon module in libdroiddeckstores.so and the Kotlin package com.droiddeck.launcher.stores.amazon) was rewritten so that it carries no code from the earlier implementation. This page records how that was done and every judgement call the new code makes.

Process

  1. Spec, written separately. A functional specification (six documents: summary and unknowns, service protocol, manifest format, install behaviour, on-disk compatibility, and the app-facing interface) was written from the old code by a separate pass. It describes behaviour and data formats only, not code structure.
  2. Implementation from the spec only. The implementer worked from those documents and from code that is not Amazon-specific:
    • read: the spec; the crate's Cargo.toml, Cargo.lock, lib.rs, stores_jni.rs, priority.rs, fetch_tuning.rs and the shared fetch_core.rs; the clean-room Epic module (Rust and Kotlin) as the structural template; JNI.md; the shared Kotlin store code (account store, install records, download queue, login activity, catalog and state); the signatures and call sites of StoreSizes' Amazon branch (to keep them compiling); build files and CI;
    • not read: anything under stores/amazon/ (Kotlin, Java, tests) or src/amazon* (Rust) of the old tree, other branches or checkouts, or any Amazon client source. The old files were deleted with git rm without being opened.
  3. Fresh code. Every file has its own structure, names and comments, with a plain description and SPDX-License-Identifier: GPL-3.0-or-later (the crate's licence). Where the spec left a fact open, the choice was made from general knowledge of Amazon's public services or from unauthenticated probes of the live endpoints (below), and is listed here.

Layout

Rust, app/src/main/rust/stores/src/amazon/:

File Role
net.rs blocking HTTP (reqwest + rustls), the three User-Agent identities, hex / percent / base64url helpers, URL scrubbing for logs
auth.rs per-attempt device serial, client id, PKCE verifier and challenge; sign-in URL; code from the return URL; registration, refresh, deregistration
distribution.rs the distribution service call shape (X-Amz-Target, x-amzn-token, Content-Encoding: amz-1.0), refusal detection, the download lookup
library.rs entitlement pages, field fallbacks, de-duplication, add-on filter, sort
cdn.rs path insertion before the signed query, file URLs, the manifest GET
codec.rs manifest framing (big-endian header length) and xz / LZMA-alone decompression (lzma-rs)
proto.rs a strict protobuf wire reader (and test writers)
manifest.rs packages → files, path normalisation and safety, duplicate handling
install/ mod.rs (the run), check.rs (completeness, stray .tmp, free space), writer.rs (streamed download, SHA-256, atomic placement), tests.rs (loopback end-to-end)
launch.rs fuel.json, exe scoring, the FuelPump environment
service.rs, ffi.rs named operations and the JNI boundary

Kotlin, app/src/main/java/com/droiddeck/launcher/stores/amazon/: AmazonBackend (the StoreBackend, installs the login flow), AmazonLogin (the WebView flow), AmazonCredentialStore (the account through StoreAccounts, single-flight refresh), AmazonPrefs, AmazonLibrary (sync, cache, posters, publishing), AmazonInstaller (the queue job), AmazonNative (JNI), and two thin wrappers kept for StoreSizes: AmazonApiClient (getGameDownload, getBytes, appendPath) and AmazonManifest (parse(bytes).totalInstallSize).

The JNI surface (nativeConfigure, nativeCall, nativeFetch, nativeInspect, nativeInstall, nativeCancel, nativeRelease) is documented in app/src/main/rust/stores/JNI.md. The old nativeStart symbol is gone. The Java xz library stays in app/build.gradle: commons-compress needs it for the .tar.xz Windows components, so only Amazon stopped using it.

Compatibility kept exactly

  • Credentials (stores/amazon/credentials.json through StoreAccounts): the same keys (access_token, refresh_token, device_serial, client_id, expires_at, display_name). A refresh merges access_token, device_serial, client_id, expires_at into the stored record and keeps every other key; a new sign-in writes the whole record. Registration and refresh keep the launcher's device constants, so existing refresh tokens keep working.
  • Preferences stores.amazon: library_cache (same element keys), library_synced_at, vcover_<id>, size_<id>, version_<id>; sign-out removes only the first two.
  • Install folders, the sidecar keys (extra.entitlementId, extra.sku, no cloud key), the launcher name .droiddeck-launch.bat, the .tmp naming and the two marker files.
  • The catalog item's extra (entitlementId, sku, version = "") so the shared size cache and the saved queue keep working.

User-Agents and what the probes showed

Unauthenticated probes of the live services (2026-10-14):

  • Distribution service (gaming.amazon.com): GetEntitlements, GetGameDownload and GetLiveVersionIds answered identically for AGSLauncher/1.0.0, DroidDeck/…, a curl UA, with and without the UserAgent header. A bad token gives HTTP 401 <NotAuthorizedException/>; no token gives HTTP 400 with com.amazon.coral.service#NotAuthorizedException. The probes cannot show whether the UA matters for an authorised call, so the launcher's User-Agent and UserAgent values are kept (Amazon's own client identifiers, one constant each in net.rs).
  • Identity API (api.amazon.com): register, token and deregister gave the same validation errors whatever the UA (Firefox, DroidDeck/…, none). The desktop Firefox string is kept for these calls, the same as the WebView: it is a generic browser label, not a client name, and existing refresh tokens are known to work with it.
  • Sign-in page (www.amazon.com/ap/signin): every UA got Amazon's bot check from the probe host, so nothing could be learned; the WebView keeps the desktop Firefox string (a sign-in page that misbehaves would lock every user out).
  • CDN: no URL is reachable without a token. Manifest and file requests now send DroidDeck/<versionName>, the only third-party label the old integration sent; this needs the device test below.

Refusal detection follows the probes: 401, 403, and 400 whose body names NotAuthorizedException mean "sign in again".

Judgement calls

Recommended fixes from the spec, adopted:

  1. Complete = size and SHA-256. One test for the space check and the download plan; a same-size corrupt file is repaired. Files without a SHA-256 (another algorithm) are judged by size. No cross-run "verified" cache: every present file is hashed on each run.
  2. A failed library page fails the sync and keeps the old cache; a refused token is reported with the shared "session expired: sign in again" line, also when a cache is shown.
  3. Single-flight refresh (AmazonCredentialStore); a forced refresh after a failed library call skips the refresh when another caller already replaced the token.
  4. Mark updating (beforeReplace → StoreInstalls.markUpdating) once, before the first file is put in place (new or replacing; the shared step only acts on an installed record).
  5. Manifest path safety: absolute paths, any : (drive letters and stream names; Windows file names cannot contain it), NUL, . / .. and empty segments, and DroidDeck's own root files (.droiddeck-store.json and its .tmp, .droiddeck-launch.bat, the two markers, compared ignoring case) fail the install naming the path. A manifest that lists both X and X.tmp is refused (the second would collide with the first's temporary file). The protobuf reader rejects truncation, over-long varints, groups and unknown wire types, field number 0, wrong wire types on the fields used, and a non-empty file without a hash or a SHA-256 that is not 32 bytes.
  6. Exe scoring on relative paths. The Unreal-folder rule is tested against "/" + relative path so a top-level Binaries/Win64/… still matches. The "first five letters" rule compares the title with the file name without .exe (the extension's letters would otherwise count). An empty title never matches the "path contains the title" rule. Ties: larger file, then the earlier path. Only regular files are candidates; symlinks are not followed; depth is capped at 16.
  7. CDN User-Agent is DroidDeck/<versionName> (see above).
  8. Empty files whose hash is absent or the SHA-256 of empty input are created locally; stray .tmp siblings of manifest files are removed at run start (other .tmp files are untouched).
  9. Markers .amazon_download_in_progress / .amazon_download_complete are still written.
  10. Duplicate paths: the later entry wins with a log warning; files sharing one hash are fetched once per path (no copy optimisation).

Other choices:

  • Install size is the sum over distinct paths (equal to the spec's "duplicates included" sum for every manifest without duplicate paths, which none have been seen to have); it is the progress total, the disk size and size_<id>.
  • Free space is measured in Rust (statvfs on the folder or its nearest existing parent), which is what the shared probe measures; unknown never blocks, a measured zero does.
  • Pre-download check is reported in the queue's Manifest stage ("Checking files…" with byte progress), like GOG and Epic: the Downloads UI orders stages Manifest → Download → Verify → Install, so a Verify stage before Download would read backwards.
  • Concurrency: one streamed GET per file through the shared fetch core in stream mode (the speed tier's window clamped to 1–128, per-host cap max(6, ceil(window / hosts)), 120 s to the response headers, the core's 30 s idle cap on the body, its five attempts with back-off and its stall and 429 handling). A piece at offset 0 restarts the file (a retry); an out-of-order piece, a body longer than the manifest size, a short body or a hash mismatch deletes the .tmp and is retried; a write error, a refused beforeReplace or a failed rename fails the run.
  • Decompression uses the lzma-rs crate (pure Rust; xz with any check type and LZMA-alone), with a 256 MiB cap on the decompressed manifest.
  • Live version lookup (GetLiveVersionIds) is not implemented; nothing used it.
  • DLC filter: add-ons are hidden only when they also name a parent product (as the spec's sync contract says), unless that would hide everything; the Rust call returns the filtered, sorted list plus the unfiltered count.
  • Sign-in code: the authorization code is read from the return URL's query (%XX decoded, + kept literally; codes are URL-safe). The WebView flow passes the URL to Rust and never logs it.
  • Randomness for the serial and verifier comes from /dev/urandom.
  • fuel.json is found by exact name, else a case-insensitive match at the folder root; a UTF-8 BOM is accepted. Main.Command is accepted after stripping leading / and ./, and must be a safe relative path naming a regular file.
  • Logs: the Rust side strips the query string and fragment of every URL in a line before it reaches onLog; the shared redaction runs after that as before.
  • No engine, no store: without the native library, sign-in, sync and install report "Amazon Games: the store engine is not available"; there is no Java fallback.

What needs device testing

  • An existing signed-in user stays signed in after the update: the library shows from cache at once, and a forced sync refreshes the token with the stored refresh token and lists the games.
  • A fresh sign-in through the WebView (registration, display name), and sign-out (deregistration fired, library cache cleared).
  • A library with many pages; add-ons hidden; posters.
  • Install, pause, resume, cancel (folder removed); repair of an installed game (nothing downloaded when intact; a corrupted file fetched again; "Resume install" offered when a repair is interrupted); an SD-card install; out-of-space refusal.
  • Every endpoint with the chosen User-Agents, the CDN with DroidDeck/… in particular.
  • Launch of an existing install through its existing Steam shortcut (same shortcut, same prefix) and of a fresh install (exe choice, fuel.json arguments, the five FuelPump variables in .droiddeck-launch.bat); uninstall.