Compare commits

...
Author SHA1 Message Date
xXJsonDeruloXx 0ef37b16c8 Merge main into feat/stores-motion 2026-10-09 14:49:00 -04:00
The412Banner 6e5932cb68 Stores: seal the GOG, Epic and Amazon sign-in files with an Android Keystore key (#486)
The stores' sign-ins (files/stores/<store>/credentials.json: access and refresh
tokens, account id and name) were plain JSON. Other apps could not read them, but
every program in the Linux session runs as the app and could. They are now sealed
with AES-256-GCM under a non-exportable AndroidKeyStore key (StrongBox when the
device has one), so the session only ever sees ciphertext.

- Format: a versioned envelope {v, alg, iv, ct}, written atomically and readable
  only by the app. No new dependencies (javax.crypto and KeyStore).
- Migration: an existing plaintext file is sealed at the next start, checked by
  decrypting it, then the plaintext is removed; users stay signed in.
- No Keystore on the device: the file stays plain and the log says so once per
  start.
- A sealed file that can never be opened (tampered, key gone or invalidated,
  broken envelope) is removed and that store asks for sign-in again. A transient
  Keystore error keeps the file and retries.
2026-10-09 14:42:23 -04:00
xXJsonDeruloXx bed4c1040b perf(stores): keep chip motion responsive while libraries change 2026-10-09 14:29:34 -04:00
xXJsonDeruloXx 3f45835ed6 feat(stores): the store a pad's focus is on shows without A, and a press never lands on where it sent focus 2026-10-09 13:37:03 -04:00
xXJsonDeruloXx 29abf954ce fix(ui): the focus ring follows a control until it stops and meets one still rising into place 2026-10-09 13:37:03 -04:00
The412Banner 38e3fa2a33 Logs: every log in app-private storage, shared as one scrubbed zip (#484)
Session folders, tool and installer logs and the stores' log now all live under the
app's private files/logs instead of Download/DroidDeck, and every existing "Share
logs" action bundles them into one zip that goes through a final scrub.

- Where: LinuxRuntime.logDir() = files/logs; sessions keep their dated folders and
  30-session pruning, tools/ and stores/ beside them. The "Logs" setting keeps its
  meaning. Game save backups stay in Download/DroidDeck/Saves.
- Migration: at first start the old session folders, tools/ and loose logs move from
  Download/DroidDeck into files/logs (Saves untouched), and every moved folder is
  scrubbed under the current rules.
- Scrubbing: one rule set (SecretScrub) for the stores' log and the zip. The scrub
  marker carries the rules version, so a rules change re-scrubs every folder at the
  next start (in the background, a few at a time). Steam login account names and
  emails, account-name fields, OAuth codes, tokens in URLs and private LAN
  addresses are removed; Windows exception codes and server addresses are kept.
- Share: one zip of the current session folder plus the last 7 days of stores/ and
  tools/, written to cache/share and handed to the share sheet.
2026-10-09 13:28:03 -04:00
xXJsonDeruloXx fa3037de68 style(stores): the sign-in page is the store's name and Sign in 2026-10-09 13:25:51 -04:00
xXJsonDeruloXx 9419f1efda style(stores): drop the sign-in page's store eyebrow, which the title already names 2026-10-09 13:23:51 -04:00
xXJsonDeruloXx d73c4e50c2 feat(stores): sign-in pages in the Steam tab's style and an are-you-sure for sign out 2026-10-09 13:22:25 -04:00
xXJsonDeruloXx bcaeb27780 feat(stores): modes step out of the store chip, each store in its own colour, search in the header 2026-10-09 13:16:11 -04:00
xXJsonDeruloXx fc515aa0e4 fix(ui): a step-out's backdrop never takes the pad's focus 2026-10-09 13:16:11 -04:00
xXJsonDeruloXx f164af81e7 perf(stores): GOG remembers its non-games and the library cache loads off the main thread 2026-10-09 13:16:11 -04:00
xXJsonDeruloXx 90af0b9c0c fix(stores): a game page no longer leaves the pad's focus on a dead card and crashes the next press 2026-10-09 13:16:10 -04:00
The412Banner 8e1a676f19 Windows components: lay out installer .exe wrappers without running them (#480)
* Windows components: lay out installer .exe wrappers without running them

The components page could only take .msi packages apart; most catalog entries ship an
installer .exe, so VC++ runtimes, .NET Framework 4.0-4.8 and the .NET Core/5+ runtimes sat
under 'Need a Windows installer'. droiddeck-msi-install now opens those wrappers and lays
out the packages inside the same way:

- WiX bundles (Visual C++ 2012-2022, .NET Core/5+ desktop runtimes): the .wixburn section
  names the containers, each a cabinet found by its header (signing moves them); payloads
  get the names the bundle manifest lists, so each package finds its cab1.cab beside it.
  ARM64 packages are skipped, x86 and x64 both laid out.
- Self-extracting cabinets (Visual C++ 2005-2010): cabextract, external cabinet beside the
  package (vc_red.cab).
- Self-extracting 7-Zip archives (.NET Framework 4.0-4.8): 7-Zip, now bundled with the
  msitools (Arch Linux ARM 7zip 26.04: 7z + its 7z.so plugin, licences beside the others).
  The complete product (netfx_Full_*) wins over the 4.0 parts and the per-Windows copies.
- An .exe that is its own setup program (NSIS, InnoSetup) reports that it holds no package.
- --unpack opens an archive for the app: DirectX's redistributable for XACT's cabinet picks.

The app executes the remaining catalog steps offline: set_windows (HKCU\Software\Wine
Version), set_register_key, get_from_cab (through --unpack) and 'uninstall Wine Mono' (the
mscoree override beside it is what matters), and reads url/file_name the catalog nests under
'environment' on a few .NET entries. Setup-program installers stay in the waiting section
with a note that says what they are.

Verified on this branch's engine with msitools, cabextract and 7z: VC++ 2010 x64 (19 files,
61 values), VC++ 2022 x64 bundle (26 files, 66 values), .NET 4.0 (1053 files, 6056 values,
Framework64\v4.0.30319 with clr.dll and mscorlib.dll, NDP\v4 keys) and .NET 4.8 (1633
files, 522 GAC assemblies, 6495 values). Not yet run on a device.

* Windows components: the pressed row greys out and shows its own two-step progress bar

Pressing a switch now greys that row at once and runs a thin bar across it: the download
0..100, then the install 0..100, with a caption naming the file or the stage (a dependency's
name in front while that installs first). Turning a component off greys the row the same way
until the pick is saved. The page-wide bar at the bottom is gone; Done waits on the busy row.

droiddeck-msi-install reports how far along it is ('progress 37% placing files'): reading,
each cabinet, every 5% of files placed, the registry, done - and a wrapper maps each package
into its share, so a bundle's two packages run 10..55 and 55..100. The app parses the percent
and passes a typed Progress (component, stage, phase, percent) up to the editor.

* Windows components: the ones turned on sort to the top of the list

In 'All components' the picks for this game come first, in their usual alphabetical order
among themselves; turning one off returns it to its place. The row moves once the install has
finished and the pick is saved, not while its progress bar is running.

* Windows components: an 'Installed for this game' section under the recommendations

What is turned on for this game gets its own section between Recommended and All components,
alphabetical; the recommendations stay where they are (a recommended pick stays there). A
component in the section leaves All components, and goes back to its place when turned off.
LB/RB jump through the new section too.
2026-10-09 12:36:05 -04:00
Kurt Himebauch 67cf1cdd9c fix(stores): lay out the pane's rows in a column 2026-10-09 12:32:10 -04:00
Kurt Himebauch 571751f15c feat(stores): bring the launcher's motion to the stores pages 2026-10-09 12:17:35 -04:00
Kurt Himebauch 768ed0c505 feat(ui): let PageFlood carry a tile's art 2026-10-09 12:17:35 -04:00
Kurt Himebauch 9f94140bbf refactor(ui): share End session's step-out as StepOut 2026-10-09 12:17:35 -04:00
The412Banner 7c946db440 Stores: GOG, Epic Games and Amazon Games libraries, installs and cloud saves (#476)
A Stores section (rail, under Games; Setup › Stores gate) with each store's sign-in,
library, storefront shelves and a download queue. Each store has two tabs, Store and
Library, and Library filters to All or Installed; libraries open from a saved copy
and refresh in the background, with install sizes on every card. Games install to internal storage,
or a card chosen at Install time, and are registered in the Steam client as non-Steam
games, so they launch through Steam and droiddeck-proton from either the front end or
the client.

- Downloads: GOG, Epic and Amazon engines in Rust (app/src/main/rust/stores, built by
  cargo-ndk, nothing compiled in the tree). One adaptive fetch core with a latency
  brake, stalled-host handoff and every CDN a store offers. Per-stage progress,
  pause/resume, two-step cancel that deletes, failed downloads kept for retry, a
  foreground service with Wi-Fi and wake locks and notification controls.
- Games tab: store source chips (Steam, GOG, Epic, Amazon, Custom) and store art.
- Epic: per-launch exchange code from the app and from Steam's Play button,
  "Epic sign-in (EOS)", "Launch offline", and a game's web links opened in Android's
  browser for EOS consent. The EOS overlay crashed games and is left out.
- Cloud saves (GOG, Epic): pulled before launch, pushed after the game or the session
  ends, no upload before a first download, conflicts never overwritten, backups both
  ways.
- Logs: every store line redacted; the engine log goes to daily files.
2026-10-09 11:08:17 -04:00
Kurt Himebauch eaf26c8a20 feat: replace the LXQt desktop with SteamOS-style KDE Plasma (#473)
* feat: replace the LXQt desktop with SteamOS-style KDE Plasma

* feat: leaving the KDE desktop logs out through Plasma and returns to gaming mode

* feat: DroidDeck logo wallpaper as the KDE desktop default

* feat: build the KDE wallpaper from the DroidDeck mark artwork

* ci: keep the built desktop package as a short-lived artifact for device testing

* fix: the desktop loading screen no longer promises Steam on the desktop

* docs: the output resize comments name the desktop's KWin

* fix: the desktop package verifies every package's Arch Linux ARM signature and never replaces a published tag

* fix: programs sent to the GPU from the desktop drop its software rendering overrides

* fix: an interrupted desktop install no longer counts as installed

* feat: pin the desktop package to its DroidDeck-Components release instead of the catalog
2026-10-09 10:39:50 -04:00
Magarto 0a96767657 Desktop: keep the session when the big cores cannot be pinned (#394) 2026-10-09 07:13:57 -04:00
SG3xHERO 32054e156e Add a Follow screen resolution for foldables (#403) 2026-10-09 07:13:06 -04:00
Kurt Himebauch fd6d6b7c46 fix: proot fast path hands opendir of a directory symlink to proot (#454) 2026-10-09 07:11:09 -04:00
Kurt Himebauch c101a451e3 perf: fast path answers Steam's pre-2.33 stat calls and stays off without the patched proot (#466) 2026-10-09 03:14:29 +00:00
Kurt Himebauch a76ba69217 fix: runtime removal never strands an undeletable leftover (#463) 2026-10-08 21:39:00 -04:00
The412BannerandThe412Banner d06caf9838 Desktop: Firefox skips shared-memory seals again (Android 16 tab crash) (#460)
Desktop: Firefox skips shared-memory seals again (Android 16 tab crash, #280 line lost in #252)

Co-authored-by: The412Banner <[email protected]>
2026-10-08 19:45:44 -04:00
Kurt Himebauch 134fa167fe feat: install Flatpak apps from a .flatpak file (#446) 2026-10-08 14:13:32 -04:00
The412BannerandThe412Banner da42a5d7a7 DirectAudio from its own release: driver set per Proton interface (CachyOS fix), the Steam client's own DirectAudio sink, binaries out of tree (#439)
DirectAudio from its own release: driver set per Proton audio interface (CachyOS fix), the Steam client's own DirectAudio sink, binaries out of tree

Games
- Two driver sets ship from the pinned DirectAudio release (tools/directaudio/release.env =
  directaudio-linux-v1.1.0, sha256-checked by tools/directaudio/fetch.sh in CI and
  build_local.sh): linux-wine11 for Valve Proton 11 / Experimental / GE-Proton, and
  linux-wine11-systhread for Proton-CachyOS, whose Wine carries the system-thread mmdevapi
  interface. steam-compatibility picks by whether the Proton's own winepulse.so imports
  PsCreateSystemThread, never by the version string (both say wine-11.0; the wrong set is
  silence). No matching set: DirectAudio off, Proton's audio runs, logged.
- Proton-CachyOS's launcher pre-sets WINE_AUDIO_DRIVER=pulse,alsa and its mmdevapi reads that
  before the registry (Valve's and GE's do not read it): the wrapper exports
  WINE_AUDIO_DRIVER=directaudio,pulse as well as writing the registry. On CachyOS this also
  makes DirectAudio engage on a game's first launch.
- A wineserver from an earlier Steam verb can save its in-memory registry over the appended
  Audio value; the wrapper says so when one is live. Writing through it (wine reg add) and
  creating a missing prefix first (proton run wineboot) were tried on the device and withdrawn:
  a second Proton/Wine run inside Steam's launch sequence got launches aborted.
- Tests: both sets, no set, no prefix (34 pass).

Steam client audio
- DirectAudio = module-directaudio-native-sink, the game driver's AAudio engine inside the
  PulseAudio daemon, one step from Android (adaptive buffer, two-burst floor, primed ring with
  fades, reopen on route change, watchdog, limiter). Default. Shared output = the previous relay
  route, kept. New pref clientAudioRoute; the old boolean is not read. If the native sink cannot
  open its stream the daemon keeps running (.nofail) and ensureClientSink loads the plain AAudio
  sink instead, logged to audio.log. Audio bundle stamp bumped.

Out of tree
- app/src/main/assets/directaudio/ is generated and ignored; tools/directaudio-relay/ and the
  vendored module-directaudio-sink.c + da_relay_proto.h are gone; tools/aaudio-sink keeps only
  module-aaudio-sink (the fallback). The release carries the relay and sink field fixes by
  MaxsTechReview (#338), credited there and in its AUTHORS.

Device-proven on an AYANEO Pocket FIT: client sink 12 ms / 4 ms bursts, 0 underruns; FEZ on
cachyos-11.0-20261005-slr on DirectAudio (took pulse before); FEZ on Proton Experimental
unchanged; Curse of the Dead Gods on CachyOS from a cold prefix, DirectAudio on first launch.

Co-authored-by: The412Banner <[email protected]>
2026-10-08 12:35:00 -04:00
MaxsTechReview b6c1d3c09c Land taps and the pointer on what is drawn under them (#389)
The compositor takes touch and pointer positions over its whole output and
removes the letterbox bars itself, but the session sent them already
measured from the picture's edge, so the bars were taken off twice. Any
session whose output is a different shape from the panel (16:9 on a 16:10
tablet or a foldable, a fixed resolution on a wide phone) had taps and the
touchpad pointer pulled away from the centre, by up to a bar's height at
the edges: on a 2400x1504 panel showing 1280x720, a tap near the top
reached the first row and one near the bottom landed 32 rows low. Stretch
to panel hid it because that output has no bars.

Direct touch, the touchpad and mouse pointer, and the second-screen pointer
now send positions over the whole view, so the compositor's own mapping
puts them on the pixel under the finger.
2026-10-08 11:52:45 -04:00
Kurt Himebauch a58b0ac3e0 chore: bump version to 0.3.2 (12) (#437) 2026-10-08 10:49:33 -04:00
Kurt Himebauch e1700ba982 docs: point 0.3.2 full changelog at the 0.3.2 tag (#438) 2026-10-08 10:49:29 -04:00
Kurt Himebauch 736ac7db74 Draft 0.3.2 release notes (#417)
* docs: draft 0.3.2 release notes

* docs: update 0.3.2 release notes

* docs: update external storage install note

* docs: add keep downloads running demo gif to 0.3.2 notes

* fix: restore colours in keep downloads running gif

* docs: add native steam sleep demo gif to 0.3.2 notes

* fix: drop glitched frames from native steam sleep gif

* docs: add windows components screenshot to 0.3.2 notes

* docs: use newer windows components screenshot in 0.3.2 notes
2026-10-08 10:42:16 -04:00
MaxsTechReview 6e2966be43 fix: keep Steam's directory listings on FUSE libraries (#410)
* fix: keep Steam's directory listings on FUSE libraries

Before it creates, moves or deletes a content file the Steam client lists
the whole destination directory about ten times to match names
case-insensitively. On Android's FUSE storage every listing goes through
the media provider, so reserving space grew quadratically with the number
of files: 33 minutes for Haunted Paws Demo (3,938 files) in a Games folder.

The client's listings under steamapps/ on FUSE are now kept while the
directory's stat is unchanged, for at most five seconds, and patched by
the client's own creates, renames and removals. Other changes fail the
stat check and the directory is read again. BL_NO_DIR_CACHE=1 turns it off.

Haunted Paws Demo into the internal shared Games folder: reserving space
33m08s -> 25-52s, committing 6m15s -> 19-30s, install 39m50s -> 1m12s-1m51s.
Internal storage is unchanged (5s reserve).

* fix: drop stale directory listings on coarse-timestamp volumes

NTFS reports whole-second directory times and a fixed size, so a change
made outside the client's own calls could leave a cached listing stale.
Listings now carry an inotify watch and are dropped on any event the
client's own patched changes don't account for. A forked child stops
caching, since it shares the parent's queue.

Covers rewinddir, telldir and seekdir on cached listings, and directories
opened by the fast-path preload's opendir, which goes through fdopendir.
2026-10-08 08:50:00 -04:00
Kurt Himebauch 679948e792 Include PR descriptions and queue catalog updates (#421)
* Include a short PR description in build announcements

* Queue catalog refreshes without cancelling publishing runs
2026-10-08 00:46:34 -04:00
Kurt Himebauch e0fc8362b8 Fix PR build report lookup (#420)
Use standalone jq to filter paginated PR comments
2026-10-08 00:32:11 -04:00
Kurt Himebauch 9d8ff9615f Separate PR build announcements (#418)
* Separate PR build announcements and retire closed builds

* Isolate watcher test clocks and clean up the agent server
2026-10-08 00:23:55 -04:00
Tak-attackandxXJsonDeruloXx 1304809240 Improvements to the Japanese Translation (#314)
Update strings.xml

Improvements to the Japanese Translation
I have rewritten the translation to make it sound more natural in Japanese.

Also, since “Loader” in “Decky Loader” is a proper noun, I believe it does not need to be translated.

Co-authored-by: xXJsonDeruloXx <[email protected]>
2026-10-08 04:11:52 +00:00
kmnkv1andxXJsonDeruloXx 343a516e1a Update Russian translation (#324)
Translate the 103 missing strings (screen effects, scaling, Wine sync
backends, FEX for AppImages, Steam repair, device info) and fix existing
ones: inverted meaning in mode_suspend_note, content_add_roms and
wadb_connect_address, double space in the update/install buttons,
outdated AppImage/Proton hints, "1 приложений"-style counts, gender
agreement with substituted names, lowercase fragments that are joined
into sentences, and inconsistent terms (Setup, Developer options, Stop
session, Button mapping).

Co-authored-by: xXJsonDeruloXx <[email protected]>
2026-10-08 04:06:56 +00:00
0x142857andxXJsonDeruloXx 7c08cb58ae Share font defaults across runtime sessions (#252)
* Support CJK text with the device's own fonts

The Linux runtime's rootfs ships DejaVu alone, which has no CJK
glyphs, so Chinese, Japanese and Korean text in Steam Big Picture and
on the LXQt desktop draws as tofu boxes (issues #118, #206, #249).

Bind the device's own CJK fonts - Android's world-readable Noto CJK
.ttc, or DroidSansFallback / Source Han - into the guest under
/usr/share/fonts/droiddeck-cjk, so nothing is added to the ~790 MB
runtime image and the coverage follows the device. A new
droiddeck-fonts script, run by droiddeck-session before any program
that queries fontconfig, links the default rules, writes local.conf
with DejaVu first and the CJK families after it, and rebuilds the
fontconfig cache once per set of font files. The desktop's own
fontconfig block moves there, so every mode gets it.

* Fix CJK and Thai: bind the device font directory, not one file at a time

The first cut bound each device font with its own proot -b host:guest. A
per-file binding into a plain directory is not applied here: the guest sees a
zero-byte placeholder where the font should be (stat size=0, fc-scan empty),
so Chinese stayed tofu even though the app had "bound" the font. A directory
binding is what the rest of the runtime relies on, so bind /system/fonts as a
whole under /usr/share/fonts/droiddeck-device. That also brings Thai, Arabic,
Devanagari and the emoji fonts, which the runtime did not have either.

Verified on the AYN Thor: the session's fontconfig probe reports
sans=DejaVu Sans, zh=Noto Sans CJK SC, th=Noto Serif Thai, and Steam Big
Picture in Simplified Chinese renders without tofu (screenshot under
docs/development/evidence/).

---------

Co-authored-by: xXJsonDeruloXx <[email protected]>
2026-10-08 04:06:23 +00:00
7e81668de2 Polish Spanish translation (#234)
* i18n: complete Spanish

Spanish gets the strings added since it landed and the ones that are only a format or a brand name, so it has the same keys as English, in the same order. Each English term keeps one Spanish word across the file, and a few existing strings are reworded to read right with the values they are filled with.

* i18n: use compilación for build in Spanish

* i18n: translate new strings to Spanish

---------

Co-authored-by: Kurt Himebauch <[email protected]>
Co-authored-by: xXJsonDeruloXx <[email protected]>
2026-10-08 04:05:09 +00:00
Kurt Himebauch d231564f8e feat: agent bridge wayland and gamescope coverage (#406)
* feat: run a guest agent in every session for commands, focus and the Steam client's DevTools

* feat: give the agent bridge game, input, UI and experiment commands behind a Setup toggle

* feat: droiddeckctl commands for the new bridge, a scenario runner and contract tests

* feat: run cleanup steps after every scenario run so a failure cannot leak into the next

* feat: restart a session that died between scenario runs with start --reuse

* fix: give droiddeckctl stop its own, longer wait for the session's logs

* fix: harden debugging tools access and output
2026-10-08 03:50:58 +00:00
Kurt Himebauch 37e89c5b4d harden logging (#414) 2026-10-08 02:10:06 +00:00
The412Banner e070bd72e0 fix: read droiddeck-env from ps (#413) 2026-10-07 21:38:33 -04:00
317 changed files with 37902 additions and 4381 deletions

No files matched your search

-2
View File
@@ -6,7 +6,6 @@ on:
push:
paths:
- 'tools/aaudio-sink/**'
- 'tools/directaudio-relay/**'
- '.github/workflows/build-aaudio-sink.yml'
workflow_dispatch:
@@ -26,7 +25,6 @@ jobs:
set -euo pipefail
export NDK="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"
tools/aaudio-sink/build.sh "$PWD/pa13" "$PWD/out"
tools/directaudio-relay/build.sh "$PWD/out"
- uses: actions/upload-artifact@v4
with:
name: audio-sink-modules
+80
View File
@@ -0,0 +1,80 @@
name: Build desktop package (KDE Plasma)
# The desktop the app installs into its Linux runtime on first use: KDE Plasma as SteamOS's desktop
# mode has it, the closure of tools/desktop-kde/seeds.txt over Arch Linux ARM minus what the runtime
# already carries, plus tools/desktop-kde/overlay (tools/desktop-kde/build.sh), every package
# signature-checked. Published as a release in Droid-Deck/DroidDeck-Components with DroidDeck's
# other components; the apk pins it in tools/desktop-kde/release.env (tag, sha256, size). A
# published tag is never rebuilt: the packages are Arch Linux ARM's current ones, so a rebuild
# would not match the pinned sha256. A changed package gets a new tag (desktop-kde-r2, ...) and a
# release.env update.
on:
push:
paths:
- 'tools/desktop-kde/**'
- '.github/workflows/build-desktop-kde.yml'
workflow_dispatch:
inputs:
tag:
description: 'Release tag to publish as (e.g. desktop-kde-r1); empty = artifact only'
required: false
default: ''
permissions:
contents: write
jobs:
build:
runs-on: ubuntu-24.04
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- uses: actions/create-github-app-token@v2
id: components-token
if: github.event_name == 'workflow_dispatch' && inputs.tag != ''
with:
app-id: ${{ vars.DROIDDECK_CI_APP_ID }}
private-key: ${{ secrets.DROIDDECK_CI_APP_KEY }}
owner: Droid-Deck
repositories: DroidDeck-Components
- name: Refuse a published tag
if: github.event_name == 'workflow_dispatch' && inputs.tag != ''
env:
GH_TOKEN: ${{ steps.components-token.outputs.token }}
run: |
if gh release view '${{ inputs.tag }}' -R Droid-Deck/DroidDeck-Components >/dev/null 2>&1; then
echo "::error::${{ inputs.tag }} is already published, and published tags are never replaced: use a new tag"
exit 1
fi
- name: Build
run: |
set -euo pipefail
sudo apt-get update -qq && sudo apt-get install -y -qq zstd xz-utils gpgv >/dev/null
tools/desktop-kde/build.sh out
echo "size $(stat -c %s out/desktop-kde.tar.zst) sha256 $(cat out/desktop-kde.sha256)"
# The package itself too, briefly: enough to try a build on a device before publishing it.
- uses: actions/upload-artifact@v4
with:
name: desktop-kde
retention-days: 7
compression-level: 0
path: |
out/desktop-kde.tar.zst
out/desktop-kde.packages.txt
out/desktop-kde.sha256
- name: Publish
if: github.event_name == 'workflow_dispatch' && inputs.tag != ''
env:
GH_TOKEN: ${{ steps.components-token.outputs.token }}
GH_REPO: Droid-Deck/DroidDeck-Components
run: |
set -euo pipefail
TAG='${{ inputs.tag }}'
if gh release view "$TAG" >/dev/null 2>&1; then
echo "::error::$TAG is already published in DroidDeck-Components, and published tags are never replaced: use a new tag"
exit 1
fi
gh release create "$TAG" --latest=false --prerelease=false --title "$TAG" \
--notes "KDE Plasma desktop package for DroidDeck's Linux runtime ($GITHUB_REPOSITORY tools/desktop-kde, $GITHUB_SHA). size $(stat -c %s out/desktop-kde.tar.zst), sha256 $(cat out/desktop-kde.sha256). desktop-kde.packages.txt lists the signature-checked Arch Linux ARM packages it was built from." \
out/desktop-kde.tar.zst out/desktop-kde.sha256 out/desktop-kde.packages.txt
gh release edit "$TAG" --latest=false --prerelease=false
-49
View File
@@ -1,49 +0,0 @@
name: Build wlroots (patched)
# The desktop's wlroots (Arch Linux ARM's wlroots0.20, what the desktop package's labwc links)
# rebuilt with tools/wlroots/patches: a Vulkan DMA-BUF allocator for the Adreno node, which is not
# a DRM device, so labwc's vulkan and gles2 renderers can start. Built in an Arch Linux ARM
# container on an arm64 runner and published as a release asset the apk build fetches
# (tools/wlroots/release.env names the tag and sha). The hosted desktop package is never changed.
on:
push:
paths:
- 'tools/wlroots/**'
- '.github/workflows/build-wlroots.yml'
workflow_dispatch:
inputs:
tag:
description: 'Release tag to publish as (e.g. wlroots-0.20.2-p1); empty = artifact only'
required: false
default: ''
permissions:
contents: write
jobs:
build:
runs-on: ubuntu-24.04-arm
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- name: Build in Arch Linux ARM
run: |
set -euo pipefail
docker run --rm -v "$PWD:/work" -w /work menci/archlinuxarm:base-devel bash tools/wlroots/build-in-arch.sh
sudo chown -R "$(id -u):$(id -g)" .
- uses: actions/upload-artifact@v4
with:
name: wlroots-patched
path: |
wlroots.tzst
wlroots.tzst.sha256
- name: Publish
if: github.event_name == 'workflow_dispatch' && inputs.tag != ''
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
gh release create "${{ inputs.tag }}" --latest=false --prerelease=false --title "${{ inputs.tag }}" --notes "wlroots 0.20.2 with tools/wlroots/patches (Vulkan DMA-BUF allocator for non-DRM GPU nodes); staged by the apk at /usr/local/lib/droiddeck-wlroots for the desktop's GPU renderers. sha256 $(cut -d' ' -f1 wlroots.tzst.sha256)" wlroots.tzst wlroots.tzst.sha256 || gh release upload "${{ inputs.tag }}" wlroots.tzst wlroots.tzst.sha256 --clobber
# A component the apk fetches by tag, never something users install: it must not
# become the repo's Latest release or appear as a pre-release, also when re-uploaded.
gh release edit "${{ inputs.tag }}" --latest=false --prerelease=false
+91 -20
View File
@@ -216,14 +216,6 @@ jobs:
test -f "$D/usr/local/bin/gamescope"
rm gamescope.tzst
fi
if [ -f tools/wlroots/release.env ]; then
. tools/wlroots/release.env
gh release download "${WLROOTS_TAG}" -R "${GITHUB_REPOSITORY}" -p wlroots.tzst -O wlroots.tzst
echo "${WLROOTS_SHA256} wlroots.tzst" | sha256sum -c -
tar --use-compress-program=unzstd -xf wlroots.tzst -C "$D"
test -f "$D/usr/local/lib/droiddeck-wlroots/libwlroots-0.20.so"
rm wlroots.tzst
fi
. tools/linuxfs/uruntime.env
curl -fsSL --retry 3 -o uruntime "https://github.com/VHSgunzo/uruntime/releases/download/${URUNTIME_VERSION}/${URUNTIME_ASSET}"
echo "${URUNTIME_SHA256} uruntime" | sha256sum -c -
@@ -262,10 +254,13 @@ jobs:
done
install -m644 msi-pkgs/usr/bin/msiinfo "$T/msiinfo"
install -m644 msi-pkgs/usr/bin/cabextract "$T/cabextract"
# 7-Zip (the full build with its format plugin) opens installer .exe wrappers.
install -m644 msi-pkgs/usr/lib/7zip/7z "$T/7z"
install -m644 msi-pkgs/usr/lib/7zip/7z.so "$T/7z.so"
for so in libmsi-1.0.so.0 libgsf-1.so.114 libgcab-1.0.so.0; do
cp -L "msi-pkgs/usr/lib/$so" "$T/$so"
done
for f in NOTICE GPL-2 GPL-3 LGPL-2.1; do
for f in NOTICE GPL-2 GPL-3 LGPL-2.1 7zip-License 7zip-unRarLicense; do
install -Dm644 "tools/msitools/licenses/$f" "$D/usr/local/share/licenses/droiddeck-msitools/$f"
done
rm -rf msi-pkgs
@@ -273,9 +268,7 @@ jobs:
install -Dm644 tools/linuxfs/desktop/droiddeck-desktop "$D/usr/local/bin/droiddeck-desktop"
install -Dm644 tools/linuxfs/desktop/droiddeck-gpu "$D/usr/local/bin/droiddeck-gpu"
install -Dm644 tools/linuxfs/desktop/droiddeck-desktop-gpu "$D/usr/local/bin/droiddeck-desktop-gpu"
install -Dm644 tools/linuxfs/desktop/autostart "$D/etc/xdg/labwc/autostart"
install -Dm644 tools/linuxfs/desktop/rc.xml "$D/etc/xdg/labwc/rc.xml"
install -Dm644 tools/linuxfs/desktop/panel.conf "$D/etc/xdg/lxqt/panel.conf"
install -Dm644 tools/linuxfs/desktop/kwin_wayland_wrapper "$D/usr/local/bin/kwin_wayland_wrapper"
install -Dm644 tools/linuxfs/desktop/firefox-droiddeck.js "$D/usr/lib/firefox/defaults/pref/droiddeck.js"
ls -lR "$D"
@@ -331,6 +324,11 @@ jobs:
set -euo pipefail
curl -fsSL -o pa.tar.gz https://github.com/pulseaudio/pulseaudio/archive/refs/tags/v13.0.tar.gz
mkdir -p pa13 && tar -xzf pa.tar.gz -C pa13 --strip-components=1 && rm pa.tar.gz
# DirectAudio - the game driver (one set per Wine audio interface), the Android-side helper and
# the Steam client's sinks - comes from a pinned release of The412Banner/directaudio, checksummed,
# exactly like gamescope; nothing compiled for audio is kept in this repo.
- name: Fetch DirectAudio (tools/directaudio/release.env)
run: tools/directaudio/fetch.sh "$PWD" "$PWD/sink-out"
- name: Build the AAudio sink into the audio bundle
run: |
set -euo pipefail
@@ -340,17 +338,11 @@ jobs:
tar --use-compress-program=unzstd -xf app/src/main/assets/pulseaudio.tzst -C pa-bundle
test ! -f pa-bundle/modules/arm64/module-aaudio-sink.so
install -m755 sink-out/module-aaudio-sink.so pa-bundle/modules/arm64/module-aaudio-sink.so
install -m755 sink-out/module-directaudio-sink.so pa-bundle/modules/arm64/module-directaudio-sink.so
install -m755 sink-out/module-directaudio-native-sink.so pa-bundle/modules/arm64/module-directaudio-native-sink.so
(cd pa-bundle && tar --use-compress-program='zstd -19' -cf ../pulseaudio.tzst .)
mv pulseaudio.tzst app/src/main/assets/pulseaudio.tzst
tar --use-compress-program=unzstd -tf app/src/main/assets/pulseaudio.tzst | grep -c '\.so$'
rm -rf pa-bundle sink-out
- name: Build the DirectAudio relay
run: |
set -euo pipefail
export NDK="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"
tools/directaudio-relay/build.sh app/src/main/jniLibs/arm64-v8a
- name: Cache proot
id: proot
uses: actions/cache@v4
@@ -402,10 +394,43 @@ jobs:
test -f "$D/usr/local/bin/droiddeck-session"
test -f "$D/usr/local/bin/droiddeck-proton-extra"
test -f "$D/usr/local/bin/droiddeck-msi-install"
for f in msiinfo cabextract libmsi-1.0.so.0 libgsf-1.so.114 libgcab-1.0.so.0; do
for f in msiinfo cabextract 7z 7z.so libmsi-1.0.so.0 libgsf-1.so.114 libgcab-1.0.so.0; do
test -f "$D/usr/local/lib/droiddeck-msitools/$f" || { echo "::error::droiddeck-msitools/$f missing"; exit 1; }
done
# The store download engines (app/src/main/rust/stores) are Rust, built by Gradle's
# buildRustStores task through cargo-ndk. rustup and cargo are on the runner image; the Android
# target and cargo-ndk are added here. Cache = cargo registry + cargo-ndk + the crate's target
# tree, keyed on the rustc version and Cargo.lock, so a source-only change rebuilds this crate
# on top of the cached dependency objects, and a lockfile change still starts from the
# previous objects (restore-keys).
- name: Rust toolchain for the store engines (aarch64-linux-android)
id: rust
run: |
set -euo pipefail
rustup target add aarch64-linux-android
echo "rustc=$(rustc -V | awk '{print $2}')" >> "$GITHUB_OUTPUT"
rustc -V; cargo -V
- name: Cache cargo registry and the store engines' target
uses: actions/cache@v4
with:
path: |
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
~/.cargo/bin/cargo-ndk
app/src/main/rust/stores/target
key: stores-${{ steps.rust.outputs.rustc }}-${{ hashFiles('app/src/main/rust/stores/Cargo.lock') }}
restore-keys: |
stores-${{ steps.rust.outputs.rustc }}-
- name: cargo-ndk
run: |
set -euo pipefail
command -v cargo-ndk >/dev/null || cargo install cargo-ndk --locked
cargo ndk --version
- name: Cache the CMake build tree
uses: actions/cache@v4
with:
@@ -425,6 +450,52 @@ jobs:
./gradlew assembleRelease --console=plain -PndkVersion="$NDK_VERSION" 2>&1 | tee /tmp/build.log
python3 tools/release/check_session_assets.py app/build/outputs/apk/release/app-release.apk
- name: Check the store engines' library
# The exports Kotlin binds must be in the apk's libdroiddeckstores.so, or the stores fail on
# the device with an UnsatisfiedLinkError instead of here. The lockfile check is the
# reproducibility guard: cargo must not have had to re-resolve anything.
run: |
set -euo pipefail
NDK="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"
NM="$NDK/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-nm"
SO=app/build/rustJniLibs/arm64-v8a/libdroiddeckstores.so
test -s "$SO"
ls -la "$SO"
unzip -l app/build/outputs/apk/release/app-release.apk | grep -q 'lib/arm64-v8a/libdroiddeckstores.so' \
|| { echo "::error::libdroiddeckstores.so is not in the apk"; exit 1; }
for sym in \
Java_com_droiddeck_launcher_stores_StoresNative_nativeVersion \
Java_com_droiddeck_launcher_stores_gog_GogNative_nativeStart \
Java_com_droiddeck_launcher_stores_epic_EpicNative_nativeStart \
Java_com_droiddeck_launcher_stores_epic_EpicNative_nativeAssemble \
Java_com_droiddeck_launcher_stores_amazon_AmazonNative_nativeStart; do
"$NM" -D --defined-only "$SO" | grep -q " ${sym}\$" \
|| { echo "::error::libdroiddeckstores.so does not export $sym"; exit 1; }
done
git diff --stat -- app/src/main/rust/stores/Cargo.lock
git diff --exit-code -- app/src/main/rust/stores/Cargo.lock \
|| { echo "::error::Cargo.lock changed during the build; commit the regenerated lockfile (uploaded as stores-cargo-lock)"; exit 1; }
- name: Store engines host unit tests
# The crate's #[test] functions are pure host-side logic (manifest parsing, chunk plans,
# assembly from a cache, hashes), so a plain `cargo test` on the runner's own architecture
# runs them; the Android build above compiles none of them. Informational on this first
# run - the point is to learn whether they pass - and bounded so a hang can never hold
# the apk. Make it blocking once it is known green.
timeout-minutes: 10
continue-on-error: true
run: |
cd app/src/main/rust/stores
cargo test --locked 2>&1 | tail -60
- name: Upload the regenerated Cargo.lock
if: failure()
uses: actions/upload-artifact@v4
with:
name: stores-cargo-lock
path: app/src/main/rust/stores/Cargo.lock
if-no-files-found: ignore
- name: Steam import and game environment tests
run: python3 -m unittest discover -s tools/tests -p 'test_*.py'
+29
View File
@@ -13,13 +13,28 @@ permissions:
contents: read
pull-requests: read
concurrency:
group: pr-release-${{ github.event.pull_request.number }}
cancel-in-progress: false
queue: max
jobs:
cleanup:
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Check the current PR state
id: state
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
set -euo pipefail
state=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" --jq .state)
echo "closed=$([ "$state" = closed ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
# A token of the org's "DroidDeck CI Publisher" app, as build.yml's announce job uses.
- uses: actions/create-github-app-token@v2
if: ${{ steps.state.outputs.closed == 'true' }}
id: ci-token
with:
app-id: ${{ vars.DROIDDECK_CI_APP_ID }}
@@ -27,6 +42,7 @@ jobs:
owner: Droid-Deck
repositories: DroidDeck-CI
- name: Delete the test build
if: ${{ steps.state.outputs.closed == 'true' }}
env:
GH_TOKEN: ${{ steps.ci-token.outputs.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
@@ -42,6 +58,19 @@ jobs:
done
[ "$found" = 1 ] || echo "No test build for PR #$PR_NUMBER"
- uses: actions/checkout@v4
if: ${{ steps.state.outputs.closed == 'true' }}
with:
ref: main
persist-credentials: false
- name: Retire the Discord PR announcement
if: ${{ steps.state.outputs.closed == 'true' }}
env:
WEBHOOK: ${{ secrets.DISCORD_PR_BUILDS_WEBHOOK }}
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: python3 tools/release/pr_discord.py retire
catalog:
needs: cleanup
if: ${{ always() }}
+14 -21
View File
@@ -26,6 +26,7 @@ permissions:
concurrency:
group: pr-release-${{ github.event_name == 'pull_request_target' && github.event.pull_request.number || github.event_name == 'workflow_run' && github.event.workflow_run.pull_requests[0].number || inputs.pr_number }}
cancel-in-progress: false
queue: max
jobs:
authorize:
@@ -178,6 +179,10 @@ jobs:
issues: write
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
ref: main
persist-credentials: false
- uses: actions/download-artifact@v4
with:
pattern: droiddeck-pr-signed-*
@@ -307,39 +312,27 @@ jobs:
$NEXT
EOF
)
id=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" --paginate \
--jq ".[] | select(.body | startswith(\"$mark\")) | .id" | head -1 || true)
comment=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" --paginate --slurp | \
jq "[.[][] | select(.user.login == \"github-actions[bot]\") | select(.body | startswith(\"$mark\"))] | first // {}")
id=$(jq -r '.id // empty' <<<"$comment")
if [ -n "$id" ]; then
state=$(jq -r '.body | split("\n")[] | select(startswith("<!-- droiddeck-discord: ") and endswith(" -->"))' <<<"$comment")
if [ -n "$state" ]; then body=$(printf '%s\n%s' "$body" "$state"); fi
gh api -X PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$id" -f body="$body" >/dev/null
else
gh api -X POST "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" -f body="$body" >/dev/null
fi
# Asked for (the label, a manual run): always announced. Signed on its own after a push:
# only a PR's first test build is, and later ones update its release quietly.
- name: Post to Discord
if: ${{ always() && steps.publish.outcome == 'success' && (needs.authorize.outputs.automatic != 'true' || steps.publish.outputs.first == 'true') }}
- name: Update the Discord PR announcement
if: ${{ always() && steps.publish.outcome == 'success' }}
env:
WEBHOOK: ${{ secrets.DISCORD_BUILDS_WEBHOOK }}
WEBHOOK: ${{ secrets.DISCORD_PR_BUILDS_WEBHOOK }}
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ needs.authorize.outputs.pr_number }}
HEAD_SHA: ${{ needs.authorize.outputs.head_sha }}
RELEASE: ${{ steps.publish.outputs.release }}
APK: ${{ steps.publish.outputs.apk }}
run: |
set -euo pipefail
if [ -z "$WEBHOOK" ]; then
echo "::notice::No DISCORD_BUILDS_WEBHOOK secret; nothing posted"
exit 0
fi
short=${HEAD_SHA:0:7}
title=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" --jq .title)
what="[#$PR_NUMBER]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/pull/$PR_NUMBER): $title"
get=$(printf 'Download: [standard APK](<%s>) · [all variants](<%s>)' "$APK" "$RELEASE")
content=$(printf '**New signed PR test build** \`pr-%s\` · \`%s\`\n%s\n_Not merged — for testing._\n%s' "$PR_NUMBER" "$short" "$what" "$get")
jq -n --arg content "$content" '{content: $content, allowed_mentions: {parse: []}}' |
curl -fsS -H 'Content-Type: application/json' -d @- "$WEBHOOK" -o /dev/null
echo "Posted: $content"
run: python3 tools/release/pr_discord.py publish
catalog:
needs: publish
+1
View File
@@ -13,6 +13,7 @@ permissions:
concurrency:
group: update-app-catalog
cancel-in-progress: false
queue: max
jobs:
catalog:
+4
View File
@@ -15,6 +15,9 @@ app/src/main/jniLibs/arm64-v8a/libproot.so
app/src/main/jniLibs/arm64-v8a/.proot-inputs
app/src/main/jniLibs/arm64-v8a/libproot-loader.so
app/src/main/jniLibs/arm64-v8a/libdirectaudiorelay.so
# The store download engines (app/src/main/rust/stores) are cross-compiled by Gradle into
# app/build/rustJniLibs; cargo's own build tree stays next to the crate. Neither is source.
app/src/main/rust/stores/target/
app/src/main/assets/droiddeck-esync/
# Kotlin compiler session files (build-tool state, never source).
@@ -35,3 +38,4 @@ __pycache__/
# Local signing with the release key (tools/build_local.sh); holds a password.
.signing.env
app/src/main/assets/directaudio/
+3 -3
View File
@@ -15,13 +15,13 @@ DroidDeck brings the SteamOS experience to Android: Valve's Steam client in Big
## Requirements and install
Use Android 9 or newer on a supported Adreno device (730 or newer, or 8xx). Adreno 6xx is experimental: DirectX 11 uses DXVK 2 and may run, and DirectX 12 games can still crash. Mali, Xclipse, PowerVR, and Adreno 710 are unsupported. No root is required. Allow about 3 GB for the runtime and 1.1 GB more for the desktop and emulators. Install the APK from [Releases](https://github.com/Droid-Deck/DroidDeck/releases), install the Linux runtime, then press **Play** and sign in. Steam downloads on first launch. Install **Desktop & apps** to use the desktop and emulators. The **Store** installs Linux apps and games from Flathub (ARM64 builds) with Flatpak; with additional options to install Appimages and set up scripts.
Use Android 9 or newer on a supported Adreno device (730 or newer, or 8xx). Adreno 6xx is experimental: DirectX 11 uses DXVK 2 and may run, and DirectX 12 games can still crash. Mali, Xclipse, PowerVR, and Adreno 710 are unsupported. No root is required. Allow about 3 GB for the runtime and 1.1 GB more for the desktop and emulators. Install the APK from [Releases](https://github.com/Droid-Deck/DroidDeck/releases), install the Linux runtime, then press **Play** and sign in. Steam downloads on first launch. Install **Desktop & apps** to use the desktop and emulators. The **Store** installs Linux apps and games from Flathub (ARM64 builds) with Flatpak; with additional options to install Appimages and set up scripts. **Stores** (Setup › Stores › "Show Stores in the rail") signs in to GOG, Epic Games and Amazon Games, browses their catalogs and libraries, downloads games into the Games storage and adds each one to the Steam client as a non-Steam game, so it launches through Proton like everything else; the cog on its chip row sets which tab a store opens on and the download speed tier.
Before Steam launches, you must turn off **Restrict child processes** in Developer options. If this option is not available in developer settings (Android 12 and 13 devices), first launch of Steam will present a "Fix it for me" button, which will help automate the setup process.
## Community
Join the [DroidDeck Discord](https://discord.gg/JRGAvawjsm) for help, Preview builds, and device reports. Bug reports go in its **#bug-reports** forum; attach the session folder from `Download/DroidDeck/` so the logs come with it.
Join the [DroidDeck Discord](https://discord.gg/JRGAvawjsm) for help, Preview builds, and device reports. Bug reports go in its **#bug-reports** forum; attach the zip from **Share logs** (Setup, or the session drawer) so the logs come with it.
## Build
@@ -29,7 +29,7 @@ Run `tools/build_local.sh` with Docker, Java 17, the Android SDK/NDK, and `zstd`
## Limits
Compatibility and performance vary by device; hardware validation is limited. Desktop compositing uses software rendering. Firefox sandboxing is reduced under proot. See the session logs in `Download/DroidDeck/` when diagnosing problems.
Compatibility and performance vary by device; hardware validation is limited. Desktop compositing uses software rendering. Firefox sandboxing is reduced under proot. Logs are kept app-private (`files/logs/`, the last 30 sessions); **Share logs** packs the newest session, with the stores and tool logs, into one scrubbed zip.
## Credits and licence
+45 -2
View File
@@ -59,6 +59,12 @@ def ciBuild = providers.environmentVariable('GITHUB_ACTIONS').getOrNull() == 'tr
def releaseSigner = rootProject.file('keystore/release-signer.sha256').text.trim()
// When the source was committed, for "built 3 hours ago": fixed per commit, so it costs no rebuilds.
def commitTime = (gitValue(['show', '-s', '--format=%ct', 'HEAD']) ?: '0').with { it.isLong() ? it : '0' }
// The desktop package this apk installs (tools/desktop-kde/release.env): published once per tag in
// DroidDeck-Components, pinned here with its checksum like gamescope and the esync packs, so the
// desktop scripts in the apk always get the package they were made for.
def desktopKde = rootProject.file('tools/desktop-kde/release.env').readLines()
.findAll { it.contains('=') && !it.startsWith('#') }
.collectEntries { def kv = it.split('=', 2); [(kv[0].trim()): kv[1].trim()] }
// tools/release/variants.txt as "package=suffix;...", so the updater picks the apk of the package it runs as.
def variantSuffixes = rootProject.file('tools/release/variants.txt').readLines()
.findAll { it.trim() && !it.startsWith('#') }
@@ -79,8 +85,8 @@ android {
// file out of its own data directory, and this app's entire job is to exec a rootfs:
// proot, gamescope, the Steam client and everything it launches all live under files/.
targetSdkVersion 28
versionCode 11
versionName "0.3.1"
versionCode 12
versionName "0.3.2"
buildConfigField "String", "BUILD_LABEL", "\"${escapedBuildLabel}\""
buildConfigField "String", "SOURCE_COMMIT", "\"${sourceCommit}\""
buildConfigField "int", "PR_NUMBER", "${prNumber}"
@@ -88,6 +94,10 @@ android {
buildConfigField "String", "RELEASE_SIGNER", "\"${releaseSigner}\""
buildConfigField "long", "COMMIT_TIME", "${commitTime}L * 1000L"
buildConfigField "String", "VARIANT_SUFFIXES", "\"${variantSuffixes}\""
buildConfigField "String", "DESKTOP_KDE_TAG", "\"${desktopKde.DESKTOP_KDE_TAG}\""
buildConfigField "String", "DESKTOP_KDE_URL", "\"https://github.com/${desktopKde.DESKTOP_KDE_REPO}/releases/download/${desktopKde.DESKTOP_KDE_TAG}/desktop-kde.tar.zst\""
buildConfigField "String", "DESKTOP_KDE_SHA256", "\"${desktopKde.DESKTOP_KDE_SHA256}\""
buildConfigField "long", "DESKTOP_KDE_SIZE", "${desktopKde.DESKTOP_KDE_SIZE}L"
ndk {
abiFilters 'arm64-v8a'
@@ -188,6 +198,39 @@ def stageSessionScripts = tasks.register('stageSessionScripts', Copy) {
}
tasks.named('preBuild').configure { dependsOn(stageSessionScripts) }
// The GOG / Epic / Amazon download engines are Rust (app/src/main/rust/stores, ported from
// Bannerlator's bl-steam-client crate). cargo-ndk cross-compiles them for arm64-v8a into the build
// directory, which is one more jniLibs source below: nothing compiled is committed, the same rule
// the preloads, proot and the DirectAudio relay follow. cargo-ndk exports the NDK clang/llvm-ar as
// the C compiler for the one C-compiling crate (ring, under rustls) - the step Bannerlator's CI
// does by hand - and reads the NDK from ANDROID_NDK_HOME, which is set to the NDK this build pins.
// -PskipRust=true builds without the library, for a checkout with no Rust toolchain: the stores
// then report themselves unavailable instead of the build failing.
def rustCrateDir = layout.projectDirectory.dir('src/main/rust/stores')
def rustJniLibsDir = layout.buildDirectory.dir('rustJniLibs')
def skipRust = (project.findProperty('skipRust') ?: 'false').toString().toBoolean()
def buildRustStores = tasks.register('buildRustStores', Exec) {
description = 'Cross-compiles the store download engines (libdroiddeckstores.so) with cargo-ndk.'
onlyIf { !skipRust }
inputs.dir(rustCrateDir.dir('src'))
inputs.files(rustCrateDir.file('Cargo.toml'), rustCrateDir.file('Cargo.lock'))
outputs.dir(rustJniLibsDir)
workingDir rustCrateDir
// The configuration cache serialises the task, so everything it needs is resolved here, not
// in doFirst. ~/.cargo/bin is where rustup puts cargo and cargo-ndk; an IDE-launched Gradle
// does not always have it on PATH.
def cargoBin = "${System.getProperty('user.home')}/.cargo/bin"
environment 'PATH', "${cargoBin}${File.pathSeparator}${System.getenv('PATH')}"
environment 'ANDROID_NDK_HOME', android.ndkDirectory.absolutePath
environment 'CARGO_TERM_COLOR', 'never'
// --platform 26 = minSdkVersion: the engines link against the API 26 libc, like the C++ code.
commandLine 'cargo', 'ndk', '--target', 'arm64-v8a', '--platform', '26',
'--output-dir', rustJniLibsDir.get().asFile.absolutePath,
'build', '--release'
}
android.sourceSets.main.jniLibs.srcDir(rustJniLibsDir)
tasks.named('preBuild').configure { dependsOn(buildRustStores) }
dependencies {
// The runtime tarball is a zstd-compressed tar.
implementation 'com.github.luben:zstd-jni:1.5.2-3@aar'
+14
View File
@@ -8,6 +8,8 @@
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Store download progress and "installed" / "failed" notices (Android 13+). -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- The session keeps running while the app is not on screen: a foreground service holds the
process at perceptible priority, and a high-performance WiFi lock keeps a backgrounded
download from being throttled to nothing by the radio's power-save. -->
@@ -127,6 +129,13 @@
android:screenOrientation="sensorLandscape"
android:theme="@style/AppTheme" />
<!-- A store's own sign-in page (GOG, Epic Games, Amazon Games) in a WebView. -->
<activity
android:name=".stores.StoreLoginActivity"
android:exported="false"
android:screenOrientation="sensorLandscape"
android:theme="@style/AppTheme" />
<activity
android:name=".agent.AgentStartActivity"
android:exported="true"
@@ -163,6 +172,11 @@
<service
android:name=".core.WirelessAdbPairingService"
android:exported="false" />
<!-- Keeps the process alive while a store (GOG, Epic, Amazon) game downloads. -->
<service
android:name=".stores.download.StoreDownloadService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<!-- Hands a zipped session folder to the share sheet; nothing else is exposed. -->
<provider
android:name="androidx.core.content.FileProvider"
@@ -1,115 +0,0 @@
# DirectAudio for the Linux Steam client (relay build)
This is the driver for games that run under **Valve's ARM64 Proton inside a Linux
runtime on Android** - Bannerlator's linuxfs session with the native Steam client -
where the game process is a glibc process and cannot touch Android's audio
libraries itself.
Two pieces, both required:
| piece | what it is | where it runs |
|---|---|---|
| `aarch64-unix/winedirectaudio.so` + the two PE shells | the Wine `mmdevapi` driver, glibc build, **no AAudio inside** | in the game, inside the Linux rootfs |
| `directaudio-relay` | a small bionic program that owns the AAudio output and microphone streams | on the Android side, under the app's uid |
They meet over a unix socket. The driver connects, sends its launch config, and
gets back two shared-memory rings: one it fills with mixed game audio, one the
helper fills with microphone audio. The helper plays and records; the driver
keeps doing everything else it always did.
## Which Proton
Built inside `ValveSoftware/wine` at `proton_11.0`. The private `mmdevapi`
interface it implements is byte-identical across **Proton 11.0 (ARM64)**,
**Proton Experimental (ARM64)** and our own Wine-11 layers (checked 2026-09-19),
so this one build serves any of them. A future Proton on a Wine 12 base will
need a rebuild - the interface is not versioned, and a mismatch is silence, not
an error.
## Installing the driver next to Proton (not into it)
Steam verifies and updates its Proton depots, so files added under
`steamapps/common/Proton .../files/lib/wine/` will not survive. Put the driver
in a directory of its own and point Wine at it:
```
<somewhere>/directaudio/lib/wine/aarch64-unix/winedirectaudio.so
<somewhere>/directaudio/lib/wine/aarch64-windows/winedirectaudio.drv
<somewhere>/directaudio/lib/wine/i386-windows/winedirectaudio.drv
```
and set, for the game process:
```
WINEDLLPATH=<somewhere>/directaudio/lib/wine
```
Wine searches `WINEDLLPATH` entries with the per-arch subdirectories appended,
so both the PE shell and the unixlib are found there. In Bannerlator's runtime
the natural place to export it is the `bannerlator-proton` compatibility-tool
wrapper, which every Proton launch goes through.
## Selecting the driver
`mmdevapi` only tries the drivers named in the registry (or its built-in
`pulse,alsa,oss,coreaudio` list, which does not know about this one). Set, in the
game's prefix:
```
wine reg add "HKCU\Software\Wine\Drivers" /v Audio /d directaudio /f
```
Under Proton the prefix is `compatdata/<appid>/pfx`; run the command with that
prefix as `WINEPREFIX` using Proton's own `bin-arm64/wine`, or write the key from
the wrapper before it hands off to Proton.
## Running the helper
```
directaudio-relay --socket <path> [--log]
```
- Run it **under the app's uid**, the way the app already runs its PulseAudio
daemon. Android checks `RECORD_AUDIO` against the calling uid, so a helper
started any other way can play but cannot record.
- Start it **before** the game: the driver asks "is a helper there?" once, when
Wine picks an audio driver, and falls back to unavailable if not.
- The socket path must be reachable from inside the Linux rootfs. Bannerlator
binds the app's files directory and its runtime directory into the session at
their own paths, so any socket under either works unchanged on both sides.
- The helper serves any number of game processes; each gets its own streams.
It exits on `SIGTERM`; a game exiting tears down that game's streams only.
## Telling the driver where the helper is
```
BANNER_AUDIO_DIRECT_RELAY=<the same socket path>
```
Unset, the driver looks for `$XDG_RUNTIME_DIR/directaudio-relay`.
## Everything else is unchanged
All `BANNER_AUDIO_DIRECT_*` knobs mean what they mean in the in-process build:
`_PERF`, `_ADAPTIVE`, `_DECAY`, `_MS`, `_MAXMS`, `_BF`, `_MBF`, `_EXCLUSIVE`,
`_WATCHDOG`, `_STALL_MS`, `_DECAY_*`, `_LOG`, `_PERIOD_MS`, `_MINPERIOD_MS`, and
the live-config mailbox `_RUNTIME` (the helper watches the file; it must be a
path the helper can read on the Android side). **`BANNER_AUDIO_DIRECT_MIC=1`**
exposes the capture endpoint exactly as before: the helper opens the input
stream (`VOICE_COMMUNICATION` preset) when the game connects and makes it hot on
the game's first capture start.
One thing is different: there is one extra hand-off, the ring. The helper's
callback pulls a burst from it, so the driver keeps two bursts queued (about 8 ms
on a 192-frame device) on top of the AAudio buffer the presets control, and the
helper raises that if a callback ever finds the ring short. `get_latency` reports
the sum, so a game that sizes its own buffers from it still sees the truth.
## Proving it is running
- Helper side: `logcat -s DA-Relay:I` shows `hello from <exe>`, then `open: buffer
... ring target ...`, then `capture open` / `capture start` for the mic.
- Game side: the session log carries `DirectAudio: relay: connected - ...`.
- The game process's `/proc/<pid>/maps` shows `winedirectaudio.so` and **no**
`libaaudio.so` (it is in the helper's maps instead). The AudioFlinger track is
owned by the helper's pid.
@@ -1,4 +0,0 @@
directaudio linux-wine11 relay build
source 3548a4a730e8d01cb0cb0268d51a2fb8f23dcd33
wine ValveSoftware/wine@proton_11.0
unixlib.h sha256 fdbed263338d0719c4d12e02b045c23721de33219ec8878e4c2d7ebc9efcbe09
@@ -17,6 +17,7 @@ add_library(droiddeckwayland SHARED
src/wl_clipboard.c
src/wl_text_input.c
src/wl_toplevel_icon.c
src/wl_single_pixel.c
src/wl_color_mgmt.c
src/hdr_compose.c
src/blend_pass.c
@@ -31,6 +32,7 @@ add_library(droiddeckwayland SHARED
generated/wlr-data-control-unstable-v1-protocol.c
generated/text-input-unstable-v3-protocol.c
generated/xdg-toplevel-icon-v1-protocol.c
generated/single-pixel-buffer-v1-protocol.c
generated/banner-ahb-v1-protocol.c
src/framegen_bridge.c
src/framegen_jni.c
@@ -0,0 +1,61 @@
/* Generated by wayland-scanner 1.26.0 */
/*
* Copyright © 2022 Simon Ser
*
* Permission is hereby granted, free of charge, to any person obtaining a
* copy of this software and associated documentation files (the "Software"),
* to deal in the Software without restriction, including without limitation
* the rights to use, copy, modify, merge, publish, distribute, sublicense,
* and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice (including the next
* paragraph) shall be included in all copies or substantial portions of the
* Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
* THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
* DEALINGS IN THE SOFTWARE.
*/
#include <stdbool.h>
#include <stdlib.h>
#include <stdint.h>
#include "wayland-util.h"
#ifndef __has_attribute
# define __has_attribute(x) 0 /* Compatibility with non-clang compilers. */
#endif
#if (__has_attribute(visibility) || defined(__GNUC__) && __GNUC__ >= 4)
#define WL_PRIVATE __attribute__ ((visibility("hidden")))
#else
#define WL_PRIVATE
#endif
extern const struct wl_interface wl_buffer_interface;
static const struct wl_interface *single_pixel_buffer_v1_types[] = {
&wl_buffer_interface,
NULL,
NULL,
NULL,
NULL,
};
static const struct wl_message wp_single_pixel_buffer_manager_v1_requests[] = {
{ "destroy", "", single_pixel_buffer_v1_types + 0 },
{ "create_u32_rgba_buffer", "nuuuu", single_pixel_buffer_v1_types + 0 },
};
WL_PRIVATE const struct wl_interface wp_single_pixel_buffer_manager_v1_interface = {
"wp_single_pixel_buffer_manager_v1", 1,
2, wp_single_pixel_buffer_manager_v1_requests,
0, NULL,
};
@@ -0,0 +1,142 @@
/* Generated by wayland-scanner 1.26.0 */
#ifndef SINGLE_PIXEL_BUFFER_V1_SERVER_PROTOCOL_H
#define SINGLE_PIXEL_BUFFER_V1_SERVER_PROTOCOL_H
#include <stdint.h>
#include <stddef.h>
#include "wayland-server.h"
#ifdef __cplusplus
extern "C" {
#endif
struct wl_client;
struct wl_resource;
/**
* @page page_single_pixel_buffer_v1 The single_pixel_buffer_v1 protocol
* single pixel buffer factory
*
* @section page_desc_single_pixel_buffer_v1 Description
*
* This protocol extension allows clients to create single-pixel buffers.
*
* Compositors supporting this protocol extension should also support the
* viewporter protocol extension. Clients may use viewporter to scale a
* single-pixel buffer to a desired size.
*
* Warning! The protocol described in this file is currently in the testing
* phase. Backward compatible changes may be added together with the
* corresponding interface version bump. Backward incompatible changes can
* only be done by creating a new major version of the extension.
*
* @section page_ifaces_single_pixel_buffer_v1 Interfaces
* - @subpage page_iface_wp_single_pixel_buffer_manager_v1 - global factory for single-pixel buffers
* @section page_copyright_single_pixel_buffer_v1 Copyright
* <pre>
*
* Copyright © 2022 Simon Ser
*
* Permission is hereby granted, free of charge, to any person obtaining a
* copy of this software and associated documentation files (the "Software"),
* to deal in the Software without restriction, including without limitation
* the rights to use, copy, modify, merge, publish, distribute, sublicense,
* and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice (including the next
* paragraph) shall be included in all copies or substantial portions of the
* Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
* THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
* DEALINGS IN THE SOFTWARE.
* </pre>
*/
struct wl_buffer;
struct wp_single_pixel_buffer_manager_v1;
#ifndef WP_SINGLE_PIXEL_BUFFER_MANAGER_V1_INTERFACE
#define WP_SINGLE_PIXEL_BUFFER_MANAGER_V1_INTERFACE
/**
* @page page_iface_wp_single_pixel_buffer_manager_v1 wp_single_pixel_buffer_manager_v1
* @section page_iface_wp_single_pixel_buffer_manager_v1_desc Description
*
* The wp_single_pixel_buffer_manager_v1 interface is a factory for
* single-pixel buffers.
* @section page_iface_wp_single_pixel_buffer_manager_v1_api API
* See @ref iface_wp_single_pixel_buffer_manager_v1.
*/
/**
* @defgroup iface_wp_single_pixel_buffer_manager_v1 The wp_single_pixel_buffer_manager_v1 interface
*
* The wp_single_pixel_buffer_manager_v1 interface is a factory for
* single-pixel buffers.
*/
extern const struct wl_interface wp_single_pixel_buffer_manager_v1_interface;
#endif
/**
* @ingroup iface_wp_single_pixel_buffer_manager_v1
* @struct wp_single_pixel_buffer_manager_v1_interface
*/
struct wp_single_pixel_buffer_manager_v1_interface {
/**
* destroy the manager
*
* Destroy the wp_single_pixel_buffer_manager_v1 object.
*
* The child objects created via this interface are unaffected.
*/
void (*destroy)(struct wl_client *client,
struct wl_resource *resource);
/**
* create a 1×1 buffer from 32-bit RGBA values
*
* Create a single-pixel buffer from four 32-bit RGBA values.
*
* Unless specified in another protocol extension, the RGBA values
* use pre-multiplied alpha.
*
* The width and height of the buffer are 1.
*
* The r, g, b and a arguments valid range is from UINT32_MIN (0)
* to UINT32_MAX (0xffffffff).
*
* These arguments should be interpreted as a percentage, i.e. -
* UINT32_MIN = 0% of the given color component - UINT32_MAX = 100%
* of the given color component
* @param r value of the buffer's red channel
* @param g value of the buffer's green channel
* @param b value of the buffer's blue channel
* @param a value of the buffer's alpha channel
*/
void (*create_u32_rgba_buffer)(struct wl_client *client,
struct wl_resource *resource,
uint32_t id,
uint32_t r,
uint32_t g,
uint32_t b,
uint32_t a);
};
/**
* @ingroup iface_wp_single_pixel_buffer_manager_v1
*/
#define WP_SINGLE_PIXEL_BUFFER_MANAGER_V1_DESTROY_SINCE_VERSION 1
/**
* @ingroup iface_wp_single_pixel_buffer_manager_v1
*/
#define WP_SINGLE_PIXEL_BUFFER_MANAGER_V1_CREATE_U32_RGBA_BUFFER_SINCE_VERSION 1
#ifdef __cplusplus
}
#endif
#endif
@@ -0,0 +1,76 @@
<?xml version="1.0" encoding="UTF-8"?>
<protocol name="single_pixel_buffer_v1">
<copyright>
Copyright © 2022 Simon Ser
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice (including the next
paragraph) shall be included in all copies or substantial portions of the
Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
</copyright>
<description summary="single pixel buffer factory">
This protocol extension allows clients to create single-pixel buffers.
Compositors supporting this protocol extension should also support the
viewporter protocol extension. Clients may use viewporter to scale a
single-pixel buffer to a desired size.
Warning! The protocol described in this file is currently in the testing
phase. Backward compatible changes may be added together with the
corresponding interface version bump. Backward incompatible changes can
only be done by creating a new major version of the extension.
</description>
<interface name="wp_single_pixel_buffer_manager_v1" version="1">
<description summary="global factory for single-pixel buffers">
The wp_single_pixel_buffer_manager_v1 interface is a factory for
single-pixel buffers.
</description>
<request name="destroy" type="destructor">
<description summary="destroy the manager">
Destroy the wp_single_pixel_buffer_manager_v1 object.
The child objects created via this interface are unaffected.
</description>
</request>
<request name="create_u32_rgba_buffer">
<description summary="create a 1×1 buffer from 32-bit RGBA values">
Create a single-pixel buffer from four 32-bit RGBA values.
Unless specified in another protocol extension, the RGBA values use
pre-multiplied alpha.
The width and height of the buffer are 1.
The r, g, b and a arguments valid range is from UINT32_MIN (0)
to UINT32_MAX (0xffffffff).
These arguments should be interpreted as a percentage, i.e.
- UINT32_MIN = 0% of the given color component
- UINT32_MAX = 100% of the given color component
</description>
<arg name="id" type="new_id" interface="wl_buffer"/>
<arg name="r" type="uint" summary="value of the buffer's red channel"/>
<arg name="g" type="uint" summary="value of the buffer's green channel"/>
<arg name="b" type="uint" summary="value of the buffer's blue channel"/>
<arg name="a" type="uint" summary="value of the buffer's alpha channel"/>
</request>
</interface>
</protocol>
+61 -6
View File
@@ -745,6 +745,21 @@ static void take_shm(struct surface *s, struct wl_shm_buffer *shm, struct wl_res
if (ci && ci->asked_feedback) ci->shm_frames++; /* since it asked for GPU buffers */
}
/* A single-pixel buffer (wl_single_pixel.c) as a 1x1 image, which the surface's viewport stretches. */
static void take_pixel(struct surface *s, const uint8_t bgra[4], struct wl_resource *buffer) {
if (s->shm_img && (vkp_image_width(s->shm_img) != 1 || vkp_image_height(s->shm_img) != 1)) {
vkp_image_destroy(s->shm_img);
s->shm_img = NULL;
}
if (!s->shm_img) s->shm_img = vkp_image_create_shm(1, 1);
if (s->shm_img) vkp_image_upload_shm(s->shm_img, bgra, 4);
wl_buffer_send_release(buffer);
s->buf_w = 1;
s->buf_h = 1;
s->buf_alpha = bgra[3] != 0xff;
s->has_content = s->shm_img != NULL;
}
/* The window the app's performance HUD follows: the latest one at least as big as the last to
* start presenting GPU frames (take_dmabuf) (X11 binds the HUD to the _MESA_DRV window and counts X presents instead). JNI upcalls. */
static struct surface *g_hud_surface;
@@ -1084,6 +1099,7 @@ static void surface_commit(struct wl_client *c, struct wl_resource *r) {
struct wl_resource *buffer = s->pending_buffer;
struct dmabuf_buffer *db = get_dmabuf(buffer);
struct wl_shm_buffer *shm = buffer && !db ? wl_shm_buffer_get(buffer) : NULL;
uint8_t pixel[4];
/* The previous content is replaced before reaching the screen. */
feedback_discard_all(&s->feedback);
@@ -1113,6 +1129,9 @@ static void surface_commit(struct wl_client *c, struct wl_resource *r) {
} else if (shm) {
drop_dmabuf(s, 1);
take_shm(s, shm, buffer);
} else if (single_pixel_buffer_get(buffer, pixel)) {
drop_dmabuf(s, 1);
take_pixel(s, pixel, buffer);
} else {
drop_dmabuf(s, 1);
s->has_content = 0;
@@ -1661,15 +1680,24 @@ static void bind_desktop(struct wl_client *c, void *data, uint32_t ver, uint32_t
static void output_release(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); }
static const struct wl_output_interface output_impl = { .release = output_release };
static void bind_output(struct wl_client *c, void *data, uint32_t ver, uint32_t id) {
struct wl_resource *r = wl_resource_create(c, &wl_output_interface, ver, id);
if (!r) { wl_client_post_no_memory(c); return; }
wl_resource_set_implementation(r, &output_impl, NULL, NULL);
wl_output_send_geometry(r, 0, 0, 340, 190, WL_OUTPUT_SUBPIXEL_UNKNOWN,
"DroidDeck", "Wayland", WL_OUTPUT_TRANSFORM_NORMAL);
/* Every bound wl_output, so a resized output (a foldable opening) can send its new mode. */
static struct wl_list g_outputs;
static void output_resource_destroy(struct wl_resource *r) { wl_list_remove(wl_resource_get_link(r)); }
static void output_send_mode(struct wl_resource *r) {
wl_output_send_mode(r, WL_OUTPUT_MODE_CURRENT | WL_OUTPUT_MODE_PREFERRED,
g_output_w > 0 ? g_output_w : 1920, g_output_h > 0 ? g_output_h : 1080,
g_output_refresh_mhz > 0 ? g_output_refresh_mhz : 60000);
}
static void bind_output(struct wl_client *c, void *data, uint32_t ver, uint32_t id) {
struct wl_resource *r = wl_resource_create(c, &wl_output_interface, ver, id);
if (!r) { wl_client_post_no_memory(c); return; }
wl_resource_set_implementation(r, &output_impl, NULL, output_resource_destroy);
wl_list_insert(&g_outputs, wl_resource_get_link(r));
wl_output_send_geometry(r, 0, 0, 340, 190, WL_OUTPUT_SUBPIXEL_UNKNOWN,
"DroidDeck", "Wayland", WL_OUTPUT_TRANSFORM_NORMAL);
output_send_mode(r);
if (ver >= WL_OUTPUT_NAME_SINCE_VERSION) {
wl_output_send_name(r, "DroidDeck-1");
wl_output_send_description(r, "DroidDeck display");
@@ -3200,6 +3228,23 @@ static void key_event(uint32_t evdev, int pressed) {
wl_display_flush_clients(g_display);
}
/* The output changed size under a running session (a foldable opened or closed, with the
* session following the screen). Every client that has had its first configure is told the new
* size: gamescope's Wayland backend and the desktop's KWin both resize their output to it, and
* the next buffers they commit fill the new panel instead of being letterboxed onto it. */
static void output_resized(void) {
struct surface *s;
struct wl_resource *r;
wl_resource_for_each(r, &g_outputs) {
output_send_mode(r);
if (wl_resource_get_version(r) >= 2) wl_output_send_done(r);
}
wl_list_for_each(s, &g_surfaces, link)
if (s->xdg_toplevel && s->toplevel_committed) send_toplevel_configure(s);
droiddeck_log("display", "output resized to %dx%d", g_output_w, g_output_h);
wl_display_flush_clients(g_display);
}
/* wl event-loop callback: drain queued input events written by the Android UI thread. */
static int on_input_readable(int fd, uint32_t mask, void *data) {
struct input_msg m;
@@ -3212,6 +3257,7 @@ static int on_input_readable(int fd, uint32_t mask, void *data) {
case 5: on_vsync(((int64_t)m.p1 << 32) | (uint32_t)m.p2); break;
case 6: pointer_delta(m.p1 / 256.0, m.p2 / 256.0); break; /* relative motion, 1/256 px */
case 7: deliver_touch(&m); break;
case 8: output_resized(); break;
default: deliver_pointer(&m); break;
}
}
@@ -3242,6 +3288,14 @@ void droiddeck_wayland_send_key(int evdev, int state) {
(void)n;
}
/* Called from JNI after g_output_w/h changed: the compositor thread re-sends the size. */
void droiddeck_wayland_output_resized(void) {
if (g_input_pipe[1] < 0) return;
struct input_msg m = { 8, 0, 0, 0, 0 };
ssize_t n = write(g_input_pipe[1], &m, sizeof(m));
(void)n;
}
/* Called from JNI on every screen refresh (Choreographer). */
void droiddeck_wayland_vsync(int64_t frame_time_ns) {
if (g_input_pipe[1] < 0) return;
@@ -3439,6 +3493,7 @@ static struct wl_listener g_client_created = { .notify = on_client_created };
int droiddeck_wayland_run(void) {
wl_list_init(&g_surfaces);
wl_list_init(&g_toplevels);
wl_list_init(&g_outputs);
wl_list_init(&g_constraints);
wl_list_init(&g_relative_pointers);
open_session_log();
@@ -122,4 +122,5 @@ void droiddeck_ext_init(struct wl_display *display) {
clipboard_init(display);
text_input_init(display);
toplevel_icon_init(display);
single_pixel_init(display);
}
@@ -48,3 +48,6 @@ void text_input_host_commit(const char *utf8, size_t len);
void text_input_host_preedit(const char *utf8, size_t len, int cursor_begin, int cursor_end);
void text_input_host_delete(int before, int after);
void toplevel_icon_init(struct wl_display *display);
void single_pixel_init(struct wl_display *display);
/* A wp_single_pixel_buffer_manager_v1 buffer: 1 and its pixel (B, G, R, A, premultiplied), else 0. */
int single_pixel_buffer_get(struct wl_resource *buffer, uint8_t bgra[4]);
+16 -1
View File
@@ -160,6 +160,11 @@ static const char *vk_result_name(VkResult r);
static pthread_mutex_t g_req_lock = PTHREAD_MUTEX_INITIALIZER;
static ANativeWindow *g_req_window;
static int g_req_pending;
/* A detach was asked for since the compositor thread last looked. A rebind (null, then the same
* window) to rebuild the swapchain at a new size can land in full before the thread gets to it,
* and would otherwise read as the same window handed over again - nothing rebuilt, and the old
* size's buffers scaled onto the rotated or refolded panel. */
static int g_req_detached;
/* Scale mode + alignment (app values, see vk_present.h); written from any thread, read per frame. */
static volatile int g_mode = VKP_MODE_STRETCH, g_align = VKP_ALIGN_CENTER;
@@ -252,6 +257,7 @@ void vk_present_set_window(ANativeWindow *window) {
if (g_req_pending && g_req_window && g_req_window != window) superseded = g_req_window;
g_req_window = window;
g_req_pending = 1;
if (!window) g_req_detached = 1;
pthread_mutex_unlock(&g_req_lock);
if (superseded) ANativeWindow_release(superseded);
}
@@ -261,13 +267,20 @@ int vkp_apply_window_request(void) {
pthread_mutex_lock(&g_req_lock);
if (!g_req_pending) { pthread_mutex_unlock(&g_req_lock); return 0; }
w = g_req_window;
const int detached = g_req_detached;
g_req_window = NULL;
g_req_pending = 0;
g_req_detached = 0;
pthread_mutex_unlock(&g_req_lock);
if (w == g_window) {
/* The same window handed over again (ANativeWindow_fromSurface adds a reference each time). */
if (w) ANativeWindow_release(w);
return 0;
if (!w || !detached) return 0;
/* ... after a detach: a rebind for a resized surface. Rebuilt at its size on the next frame. */
sc_layer_window_gone();
destroy_swapchain();
droiddeck_log("gpu", "screen surface rebound: rebuilding the swapchain at its new size");
return 1;
}
sc_layer_window_gone(); /* the layer (if any) belongs to the old window */
destroy_swapchain(); /* recreated against the new window on the next frame */
@@ -1516,6 +1529,7 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i
const struct vkp_hdr_frame *hf, int *how) {
if (how) *how = 0;
vkp_apply_window_request();
check_surface_changed(); /* a rotation or a fold the rebind did not cover: rebuild at the new size */
if (g_dev_state == -2) return -1;
if (dev_init() != 0 || !g_window || scene_w <= 0 || scene_h <= 0) return -1;
@@ -2099,6 +2113,7 @@ static int pass_begin_impl(int scene_w, int scene_h, const struct vkp_draw *draw
const struct vkp_hdr_frame *hf, int *rw, int *rh) {
if (g_pass.active) vkp_pass_abort();
if (g_dev_state == -2 || dev_init() != 0 || !g_window || scene_w <= 0 || scene_h <= 0) return -1;
check_surface_changed(); /* as in render_impl: the effects pass maps onto the output's size */
if (!g_swapchain && swap_init() != 0) return -1; /* the mapping is in output pixels */
update_map(scene_w, scene_h);
if (!g_map.valid) return -1;
@@ -30,6 +30,7 @@ extern int droiddeck_cursor_serial(void);
extern void droiddeck_wayland_send_key(int evdev, int state);
extern void droiddeck_wayland_send_scene_input(int type, int a, int b);
extern void droiddeck_wayland_vsync(int64_t frame_time_ns);
extern void droiddeck_wayland_output_resized(void);
extern volatile int g_fps_limit;
extern volatile int g_hide_shell;
extern volatile int g_zero_copy;
@@ -401,6 +402,17 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetOutputSize(JNIEnv
g_output_h = h > 0 ? h : 0;
}
/* The output's size changed while the compositor runs (a foldable opening or closing with the
* session following the screen): store it and have the compositor thread tell the clients. */
JNIEXPORT void JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeResizeOutput(JNIEnv *env, jclass clazz, jint w, jint h) {
(void)env; (void)clazz;
if (w <= 0 || h <= 0) return;
g_output_w = w;
g_output_h = h;
droiddeck_wayland_output_resized();
}
/* Fullscreen mode + screen alignment (Container.FULLSCREEN_* / ALIGN_* values): how the scene is
* fitted onto the output. Any thread, any time; the next frame uses it. */
JNIEXPORT void JNICALL
@@ -0,0 +1,51 @@
/*
* wp_single_pixel_buffer_manager_v1 (version 1): a wl_buffer of one pixel of one colour, which a
* client stretches over a surface with wp_viewporter. KWin's Wayland backend - the desktop's KWin
* nested in this compositor - refuses to start without it. The surface takes the pixel as a 1x1
* image, the way it takes a wl_shm buffer (compositor.c, take_pixel).
*/
#include <stdlib.h>
#include "droiddeck_ext.h"
#include "single-pixel-buffer-v1-server-protocol.h"
struct single_pixel {
uint8_t bgra[4]; /* premultiplied, as wl_shm's ARGB8888 lies in memory */
};
static void buffer_destroy(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); }
static const struct wl_buffer_interface buffer_impl = { .destroy = buffer_destroy };
static void buffer_free(struct wl_resource *r) { free(wl_resource_get_user_data(r)); }
int single_pixel_buffer_get(struct wl_resource *buffer, uint8_t bgra[4]) {
if (!buffer || !wl_resource_instance_of(buffer, &wl_buffer_interface, &buffer_impl)) return 0;
struct single_pixel *p = wl_resource_get_user_data(buffer);
for (int i = 0; i < 4; i++) bgra[i] = p->bgra[i];
return 1;
}
static void mgr_destroy(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); }
static void mgr_create_buffer(struct wl_client *c, struct wl_resource *r, uint32_t id,
uint32_t red, uint32_t green, uint32_t blue, uint32_t alpha) {
struct single_pixel *p = calloc(1, sizeof(*p));
struct wl_resource *buffer = p ? wl_resource_create(c, &wl_buffer_interface, 1, id) : NULL;
if (!buffer) { free(p); wl_client_post_no_memory(c); return; }
/* The protocol's values span the whole u32 range; the top byte is the 8-bit channel. */
p->bgra[0] = blue >> 24;
p->bgra[1] = green >> 24;
p->bgra[2] = red >> 24;
p->bgra[3] = alpha >> 24;
wl_resource_set_implementation(buffer, &buffer_impl, p, buffer_free);
}
static const struct wp_single_pixel_buffer_manager_v1_interface mgr_impl = {
.destroy = mgr_destroy, .create_u32_rgba_buffer = mgr_create_buffer,
};
static void bind_manager(struct wl_client *c, void *data, uint32_t ver, uint32_t id) {
struct wl_resource *r = wl_resource_create(c, &wp_single_pixel_buffer_manager_v1_interface, ver, id);
if (!r) { wl_client_post_no_memory(c); return; }
wl_resource_set_implementation(r, &mgr_impl, NULL, NULL);
}
void single_pixel_init(struct wl_display *display) {
wl_global_create(display, &wp_single_pixel_buffer_manager_v1_interface, 1, NULL, bind_manager);
}
@@ -29,5 +29,10 @@ class App : Application() {
GpuClockPin.clearLeftover(this)
BwrapSpawner.start(this)
com.droiddeck.launcher.frontend.GameFileSync.start(this)
// The game stores the Stores section can talk to; the native engine loads only when one needs it.
com.droiddeck.launcher.stores.StoresState.init(this)
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.gog.GogBackend)
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.epic.EpicBackend)
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.amazon.AmazonBackend)
}
}
@@ -35,6 +35,7 @@ import com.droiddeck.launcher.runtime.LinuxRuntimeInstaller
import com.droiddeck.launcher.runtime.DeckyManager
import com.droiddeck.launcher.session.SessionService
import com.droiddeck.launcher.ui.PackageRow
import com.droiddeck.launcher.ui.exposeTestTags
import com.droiddeck.launcher.session.OfflineMode
import com.droiddeck.launcher.session.ProtonExtras
import com.droiddeck.launcher.session.ComponentsManager
@@ -159,11 +160,16 @@ class MainActivity : ComponentActivity() {
private var phantomProcessStatus by mutableStateOf(PhantomProcessStatus.NOT_APPLICABLE)
private var showPhantomGate by mutableStateOf(false)
private var directAudio by mutableStateOf(false)
private var clientDirectAudio by mutableStateOf(true)
private var stretch16x9 by mutableStateOf(false)
private var launcherFullscreen by mutableStateOf(true)
private var animationsEnabled by mutableStateOf(true)
private var storeEnabled by mutableStateOf(false)
private var gameStoresEnabled by mutableStateOf(false)
private var gameStoresSpeedTier by mutableStateOf("fast")
private var storesOpenTab by mutableStateOf(SessionPrefs.STORES_OPEN_LIBRARY)
/** A page a notification asked for ("stores:downloads", "app:<id>"), until the front end has gone there. */
private var navRequest by mutableStateOf<String?>(null)
private var storesShowMature by mutableStateOf(false)
private var mic by mutableStateOf(false)
private var wifiDiscovery by mutableStateOf(false)
private var wifiDiscoveryPermission by mutableStateOf(false)
@@ -343,7 +349,6 @@ class MainActivity : ComponentActivity() {
private var steamDownloadsInBackground by mutableStateOf(false)
private var oscMode by mutableStateOf(SessionPrefs.OSC_AUTO)
private var backActionsInverted by mutableStateOf(false)
private var renderer by mutableStateOf("vulkan")
private var gameStorage by mutableStateOf("")
private var storageDiagnostics by mutableStateOf(false)
private var storageOptions by mutableStateOf<List<GameStorage.Option>>(emptyList())
@@ -355,6 +360,7 @@ class MainActivity : ComponentActivity() {
private var emulatorList by mutableStateOf<List<Library.Emulator>>(emptyList())
private var runningLabel by mutableStateOf<String?>(null)
private var logsEnabled by mutableStateOf(true)
private var agentCommands by mutableStateOf(false)
private var showRoms by mutableStateOf(false)
private var homeAppSelected by mutableStateOf(false)
private var homeScreenEnabled by mutableStateOf(false)
@@ -436,6 +442,7 @@ class MainActivity : ComponentActivity() {
}
private fun readGameIntent(request: Intent) {
request.getStringExtra(com.droiddeck.launcher.stores.download.StoreDownloadService.EXTRA_NAV)?.let { navRequest = it; request.removeExtra(com.droiddeck.launcher.stores.download.StoreDownloadService.EXTRA_NAV) }
shortcutPicker = request.action == Intent.ACTION_CREATE_SHORTCUT
if (shortcutPicker) {
// The shortcut picker must only offer games from the scan started for this request.
@@ -522,11 +529,15 @@ class MainActivity : ComponentActivity() {
launcherFullscreen = SessionPrefs.launcherFullscreen(this)
animationsEnabled = SessionPrefs.animationsEnabled(this)
storeEnabled = SessionPrefs.storeEnabled(this)
gameStoresEnabled = SessionPrefs.gameStoresEnabled(this)
gameStoresSpeedTier = SessionPrefs.gameStoresSpeedTier(this)
storesOpenTab = SessionPrefs.storesOpenTab(this)
storesShowMature = SessionPrefs.storesShowMature(this)
applyLauncherFullscreen()
updates.start()
setContent {
DroidDeckTheme(theme, appScale = appScale) {
com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize()) {
com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize().exposeTestTags()) {
val sm = settingsMode
val page: (@Composable () -> Unit)? = when {
sm != null -> { { ModeSettingsHost(sm) } }
@@ -546,7 +557,7 @@ class MainActivity : ComponentActivity() {
busy = busy, stage = stage, percent = percent,
desktopInstalled = desktopInstalled,
offlineAccount = offlineAccount, offline = offline,
frameGenLabel = frameGenLabel, romsDir = romsDir, logsEnabled = logsEnabled,
frameGenLabel = frameGenLabel, romsDir = romsDir, logsEnabled = logsEnabled, agentCommands = agentCommands,
steamGames = steamGames, emulators = emulatorList, running = runningLabel,
frameGen = FrameGen.mode(this),
lossless = lossless,
@@ -575,6 +586,12 @@ class MainActivity : ComponentActivity() {
launcherFullscreen = launcherFullscreen,
animationsEnabled = animationsEnabled,
storeEnabled = storeEnabled,
gameStoresEnabled = gameStoresEnabled,
gameStoresSpeedTier = gameStoresSpeedTier,
storesOpenTab = storesOpenTab,
navigate = navRequest,
storesShowMature = storesShowMature,
storeDownloadsActive = com.droiddeck.launcher.stores.StoresState.activeDownloads,
),
FrontEndActions(
onPlay = { startSteamSession() },
@@ -585,7 +602,8 @@ class MainActivity : ComponentActivity() {
.putExtra(SessionService.EXTRA_MODE, SessionService.MODE_DESKTOP)
.putExtra(SessionService.EXTRA_STEAM_UI, "desktop"), steamSession = true)
},
onSteamGame = { g -> if (shortcutPicker) chooseGameShortcut(g) else launchGame(g) },
// A store game may need something done first (an Epic game its sign-in code).
onSteamGame = { g -> if (shortcutPicker) chooseGameShortcut(g) else com.droiddeck.launcher.stores.StoreLaunch.prepare(this, g) { launchGame(g) } },
onGameShortcut = { g -> com.droiddeck.launcher.frontend.GameShortcuts.pin(this, g) },
onExportGameFile = { g -> pickGameExport(g) },
onSyncGameFiles = { pickGameExport(null) },
@@ -658,6 +676,10 @@ class MainActivity : ComponentActivity() {
SessionPrefs.setLogsEnabled(this, !SessionPrefs.logsEnabled(this))
logsEnabled = SessionPrefs.logsEnabled(this)
},
onAgentCommands = {
com.droiddeck.launcher.agent.AgentAccess.setCommandsEnabled(this, !com.droiddeck.launcher.agent.AgentAccess.commandsEnabled(this))
agentCommands = com.droiddeck.launcher.agent.AgentAccess.commandsEnabled(this)
},
onShareLogs = {
SessionLogShare.prepare(this, { SessionLogShare.latest(this) }) { zip ->
if (zip == null) android.widget.Toast.makeText(this, R.string.main_no_logs_yet, android.widget.Toast.LENGTH_LONG).show()
@@ -697,6 +719,12 @@ class MainActivity : ComponentActivity() {
applyLauncherFullscreen()
},
onStoreEnabled = { on -> SessionPrefs.setStoreEnabled(this, on); storeEnabled = on },
onGameStoresEnabled = { on -> SessionPrefs.setGameStoresEnabled(this, on); gameStoresEnabled = on; if (on) com.droiddeck.launcher.stores.StoresState.refresh(this) },
onGameStoresSpeedTier = { tier -> SessionPrefs.setGameStoresSpeedTier(this, tier); gameStoresSpeedTier = tier },
onStoresOpenTab = { tab -> SessionPrefs.setStoresOpenTab(this, tab); storesOpenTab = tab },
onNavigated = { navRequest = null },
onStoresShowMature = { show -> SessionPrefs.setStoresShowMature(this, show); storesShowMature = show },
onLibraryChanged = { refreshAddedGames(); refresh() },
onAnimationsEnabled = { on ->
SessionPrefs.setAnimationsEnabled(this, on)
animationsEnabled = on
@@ -838,8 +866,12 @@ class MainActivity : ComponentActivity() {
SessionPrefs.setMicAsked(this)
}
if (wanted.isNotEmpty()) requestPermissions(wanted.toTypedArray(), 1)
// A store sign-in an earlier build left in plain text is sealed with the Keystore key.
Thread({ com.droiddeck.launcher.stores.StoreAccounts.encryptAll(this) }, "store-credentials").start()
// A session folder left without its ending - the process was killed - gets it now.
if (!SessionState.running) Thread({
com.droiddeck.launcher.session.LogMigration.run(this)
SessionLogShare.clear(this)
SessionArtifacts.finishAbandoned(this)
SessionArtifacts.scrubOlder(this)
SessionArtifacts.prune(this)
@@ -884,6 +916,7 @@ class MainActivity : ComponentActivity() {
/** On the Components page the pad's LB / RB step through GPU drivers, FEX, DXVK and VKD3D-Proton, wrapping around. */
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
if (event.keyCode == KeyEvent.KEYCODE_BUTTON_A) com.droiddeck.launcher.ui.HeldKeys.confirm = event.action == KeyEvent.ACTION_DOWN
if (showComponents && (event.keyCode == KeyEvent.KEYCODE_BUTTON_L1 || event.keyCode == KeyEvent.KEYCODE_BUTTON_R1)) {
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
val all = listOf(com.droiddeck.launcher.ui.GPU_TAB) + ComponentsManager.COMPONENTS
@@ -892,12 +925,24 @@ class MainActivity : ComponentActivity() {
}
return true
}
return super.dispatchKeyEvent(event)
// Compose throws when its focus tree has lost the focused node; one key is dropped
// instead of the app.
return try {
super.dispatchKeyEvent(event)
} catch (e: IllegalStateException) {
if (e.message?.contains("active focus target") != true) throw e
android.util.Log.w("MainActivity", "key ${event.keyCode} dropped: ${e.message}")
true
}
}
override fun onResume() {
super.onResume()
refreshWifiDiscovery()
// Back from a store's sign-in page: the Stores chips show the account at once. A store
// install finishing while this screen is up rebuilds the Games list through the listener.
if (gameStoresEnabled) com.droiddeck.launcher.stores.StoresState.refresh(this)
com.droiddeck.launcher.stores.StoresState.libraryListener = { refreshAddedGames(); refresh() }
com.droiddeck.launcher.ui.Motion.refresh(this)
// Back from a session stopped behind a flood: open on its blue, before the first frame.
com.droiddeck.launcher.ui.QuitFlood.take()?.let { c ->
@@ -955,6 +1000,8 @@ class MainActivity : ComponentActivity() {
unregisterReceiver(wifiLocationReceiver)
unregisterReceiver(mediaReceiver)
ui.removeCallbacks(mediaRefresh)
// An install finishing while this screen is away is picked up by onResume's refresh.
com.droiddeck.launcher.stores.StoresState.libraryListener = null
displayManager.unregisterDisplayListener(secondScreenDisplayListener)
// The session covers the page by now; coming back finds it as it was.
flood = null
@@ -1187,10 +1234,8 @@ class MainActivity : ComponentActivity() {
oscMode = if (mode == SessionService.MODE_STEAM) oscMode else null,
backActionsInverted = backActionsInverted,
directAudio = if (mode == SessionService.MODE_STEAM) directAudio else null,
clientDirectAudio = clientDirectAudio,
stretch16x9 = if (mode == SessionService.MODE_STEAM) stretch16x9 else null,
mic = if (mode == SessionService.MODE_STEAM) mic else null,
renderer = if (mode == SessionService.MODE_DESKTOP) renderer else null,
gameStorage = if (mode == SessionService.MODE_STEAM) gameStorage else null,
storageOptions = storageOptions,
storageDiagnostics = mode == SessionService.MODE_STEAM && storageDiagnostics,
@@ -1252,7 +1297,6 @@ class MainActivity : ComponentActivity() {
backActionsInverted = inverted
},
onDirectAudio = { on -> SessionPrefs.setDirectAudio(this, on); directAudio = on },
onClientDirectAudio = { on -> SessionPrefs.setClientDirectAudio(this, on); clientDirectAudio = on },
onStretch16x9 = { on -> SessionPrefs.setStretch16x9(this, on); stretch16x9 = on },
onMic = { on ->
SessionPrefs.setMicEnabled(this, on)
@@ -1263,7 +1307,6 @@ class MainActivity : ComponentActivity() {
requestPermissions(arrayOf(Manifest.permission.RECORD_AUDIO), 2)
}
},
onRenderer = { r -> SessionPrefs.setDesktopRenderer(this, r); renderer = r },
onGameStorage = { path, label -> setGameStorage(path, label) },
onPickGameStorageFolder = {
pickGameStorage.launch(InAppFilePicker.buildDirIntent(this, getString(R.string.main_pick_game_storage), gameStorage.ifEmpty { null }))
@@ -1417,11 +1460,9 @@ class MainActivity : ComponentActivity() {
oscMode = SessionPrefs.oscMode(this)
backActionsInverted = SessionPrefs.backActionsInverted(this)
directAudio = SessionPrefs.directAudio(this)
clientDirectAudio = SessionPrefs.clientDirectAudio(this)
stretch16x9 = SessionPrefs.stretch16x9(this)
mic = SessionPrefs.micEnabled(this)
refreshWifiDiscovery()
renderer = SessionPrefs.desktopRenderer(this)
gameStorage = SessionPrefs.gameStorage(this)
storageDiagnostics = SessionPrefs.storageDiagnosticsEnabled(this)
settingsMode = mode
@@ -1492,6 +1533,7 @@ class MainActivity : ComponentActivity() {
frameGenLabel = FrameGen.label(this)
romsDir = SessionPrefs.romsDir(this).takeIf { it.isNotEmpty() }
logsEnabled = SessionPrefs.logsEnabled(this)
agentCommands = com.droiddeck.launcher.agent.AgentAccess.commandsEnabled(this)
runningLabel = if (SessionState.running) when (SessionState.mode) {
SessionService.MODE_DESKTOP -> getString(R.string.rail_desktop)
SessionService.MODE_RUN -> Library.nameForProgram(SessionState.program)
@@ -45,6 +45,7 @@ import com.droiddeck.launcher.gpu.ScreenEffects
import com.droiddeck.launcher.gpu.Lossless
import com.droiddeck.launcher.gpu.TurnipDriver
import com.droiddeck.launcher.input.EvdevKeys
import com.droiddeck.launcher.input.InputSpace
import com.droiddeck.launcher.input.KeyboardHost
import com.droiddeck.launcher.input.ControllerPrefs
import com.droiddeck.launcher.input.SessionClipboard
@@ -335,6 +336,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
)
}
Log.i(TAG, "perf: " + PerfMode.apply(this, SessionState.fpsLimit))
if (!SessionState.running) SessionState.followScreen = followChoice()
applyFollowOrientation(resources.configuration)
goFullscreen()
// The device's volume keys change the stream the session plays on (the relay and
// PulseAudio are media playback); they are never forwarded to the guest.
@@ -362,6 +365,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
val bridge = PadBridge(File(LinuxRuntime.sessionRoot(this), "dev/input"))
padBridge = bridge
SessionState.padBridge = bridge
padMotion = com.droiddeck.launcher.input.PadMotion(this) {
@Suppress("DEPRECATION")
(if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay)?.rotation ?: android.view.Surface.ROTATION_0
@@ -741,9 +745,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
private fun loadingTitle(): String = when (loadingMode()) {
SessionService.MODE_DESKTOP ->
if (!SessionState.running && intent.getStringExtra(SessionService.EXTRA_STEAM_UI) != null) getString(R.string.session_starting_steam_desktop)
else getString(R.string.session_starting_desktop)
// Also when the desktop was asked for with Steam (Big Picture's Switch to Desktop): the client
// does not open on it.
SessionService.MODE_DESKTOP -> getString(R.string.session_starting_desktop)
SessionService.MODE_RUN -> {
val program = if (SessionState.running) SessionState.program else intent.getStringExtra(SessionService.EXTRA_PROGRAM)
com.droiddeck.launcher.frontend.Library.nameForProgram(program)?.let { getString(R.string.session_starting_named, it) }
@@ -925,8 +929,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
/** Null when the desktop package is in, else the loading screen's closing line. */
private fun installDesktop(): String? {
uiHandler.post { loading.percent = -1; loading.say(getString(DESKTOP_LINES.downloading), DESKTOP_LINES.topic, readable = true) }
val entry = com.droiddeck.launcher.runtime.DesktopCatalog.fetch()?.firstOrNull { it.id == "desktop" }
?: return getString(R.string.session_desktop_catalog_unreachable)
val entry = com.droiddeck.launcher.runtime.DesktopCatalog.desktopEntry(this)
val problem = com.droiddeck.launcher.runtime.DesktopCatalog.install(this, entry,
progressFor(DESKTOP_LINES, entry.size / 1_000_000))
return problem?.let { getString(R.string.session_desktop_install_failed, it) }
@@ -960,7 +963,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
// sized once when the session starts and cannot change. A foldable recreates the Surface
// on the other panel, and recomputing the size there told the compositor a 21:9 buffer
// was 16:9 - the picture came back squashed sideways. While a session runs, keep its size.
val size = if (SessionState.running) SessionState.outputSize else outputSize()
// Follow screen is the exception: there the compositor tells gamescope the new size, so the
// session takes the shape of the Surface it now has.
val previous = SessionState.outputSize
val live = if (SessionState.followScreen && !isInPictureInPictureMode)
com.droiddeck.launcher.session.SessionDisplay.followSize(holder.surfaceFrame.width(), holder.surfaceFrame.height())
else null
val size = live ?: if (SessionState.running) previous else outputSize()
SessionState.outputSize = size
if (!SessionState.running) SessionState.refreshHz = refreshHz()
// How far the panel enlarges the session, for texture sharpness "Auto" (TextureFiltering):
@@ -1019,6 +1028,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
driverId?.let { turnip.driverPath(it) }, driverId?.let { turnip.libraryName(it) },
applicationInfo.nativeLibraryDir, size.first, size.second, refreshHz(), SessionState.fpsLimit,
)
if (SessionState.running && size != previous) {
Log.i(TAG, "follow screen: ${previous.first}x${previous.second} -> ${size.first}x${size.second} on a new surface")
WaylandCompositor.nativeResizeOutput(size.first, size.second)
}
if (!SessionState.running) SessionEvents.record("compositor.started")
// The service owns everything below the compositor. It is started whenever no session is
// running - NOT only when the compositor was just started: the compositor lives for the
@@ -1047,9 +1060,51 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (resized) {
Log.i(TAG, "surface resized to ${width}x$height - rebinding the compositor")
CompositorHost.resize(holder.surface) { applyFrameGen() }
followWindow(width, height)
}
}
/**
* Follow screen: the session's display takes the window's new size (a Fold opening onto its
* inner panel, or closing onto the cover), so the picture fills the panel instead of being
* letterboxed onto it. Not in picture-in-picture: that window is a preview of the session,
* and resizing the guest to it would leave Steam laid out for a thumbnail.
*/
private fun followWindow(width: Int, height: Int) {
if (!SessionState.followScreen || isInPictureInPictureMode || !CompositorHost.isStarted) return
val size = com.droiddeck.launcher.session.SessionDisplay.followSize(width, height) ?: return
if (size == SessionState.outputSize) return
Log.i(TAG, "follow screen: ${SessionState.outputSize.first}x${SessionState.outputSize.second} -> ${size.first}x${size.second}")
SessionState.outputSize = size
val panel = panelBounds()
SessionState.upscaleRatio = maxOf(panel.width(), panel.height()).toFloat() / maxOf(size.first, size.second).coerceAtLeast(1)
WaylandCompositor.nativeResizeOutput(size.first, size.second)
}
/** Whether this session's resolution is Follow screen; decided once, when it starts. */
private fun followChoice(): Boolean {
val mode = SessionPrefs.prefMode(intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM)
return SessionPrefs.resolutionChoice(this, mode, com.droiddeck.launcher.session.SessionDisplay.panelSize(this)) ==
com.droiddeck.launcher.session.SessionDisplay.FOLLOW_SCREEN
}
/**
* Follow screen on a large panel (a Fold's inner screen) may turn with the device: a window
* locked to landscape on a squarer panel is letterboxed by the system itself, which no output
* size can undo. A phone-sized panel (the cover screen) stays landscape as before.
*/
private fun applyFollowOrientation(config: android.content.res.Configuration) {
if (!SessionState.followScreen) return
val wanted = if (config.smallestScreenWidthDp >= 600) android.content.pm.ActivityInfo.SCREEN_ORIENTATION_USER
else android.content.pm.ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
if (requestedOrientation != wanted) requestedOrientation = wanted
}
override fun onConfigurationChanged(newConfig: android.content.res.Configuration) {
super.onConfigurationChanged(newConfig)
applyFollowOrientation(newConfig)
}
override fun surfaceDestroyed(holder: SurfaceHolder) {
surfaceW = 0
surfaceH = 0
@@ -1485,18 +1540,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
private fun movePointer(x: Float, y: Float) {
val width = surfaceView.width.takeIf { it > 0 } ?: return
val height = surfaceView.height.takeIf { it > 0 } ?: return
val out = SessionState.outputSize
val scale = minOf(width / out.first.toFloat(), height / out.second.toFloat())
val drawnW = out.first * scale
val drawnH = out.second * scale
val left = (width - drawnW) / 2f
val top = (height - drawnH) / 2f
val px = ((x - left) / drawnW * 1920f).toInt().coerceIn(0, 1919)
val py = ((y - top) / drawnH * 1080f).toInt().coerceIn(0, 1079)
WaylandCompositor.nativeSendPointer(1, px, py)
showCursor(x.coerceIn(left, left + drawnW), y.coerceIn(top, top + drawnH))
val rect = drawnRect() ?: return
val cx = x.coerceIn(rect.left, rect.right)
val cy = y.coerceIn(rect.top, rect.bottom)
WaylandCompositor.nativeSendPointer(1, InputSpace.x(cx, surfaceView.width), InputSpace.y(cy, surfaceView.height))
showCursor(cx, cy)
}
/**
@@ -1643,10 +1691,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (width <= 0f || height <= 0f) return
val nx = (x / width).coerceIn(0f, 1f)
val ny = (y / height).coerceIn(0f, 1f)
WaylandCompositor.nativeSendPointer(1, (nx * 1919f).toInt(), (ny * 1079f).toInt())
drawnRect()?.let { rect ->
showCursor(rect.left + nx * rect.width(), rect.top + ny * rect.height())
}
val rect = drawnRect() ?: return
val px = rect.left + nx * rect.width()
val py = rect.top + ny * rect.height()
WaylandCompositor.nativeSendPointer(1, InputSpace.x(px, surfaceView.width), InputSpace.y(py, surfaceView.height))
showCursor(px, py)
}
private fun closeSecondScreen(reset: Boolean) {
@@ -1693,9 +1742,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
val rect = drawnRect() ?: return false
fun sendTouch(action: Int, index: Int) {
val x = ((event.getX(index) - rect.left) / rect.width()).coerceIn(0f, 1f)
val y = ((event.getY(index) - rect.top) / rect.height()).coerceIn(0f, 1f)
WaylandCompositor.nativeSendTouch(action, event.getPointerId(index), (x * 1919f).toInt(), (y * 1079f).toInt())
val x = event.getX(index).coerceIn(rect.left, rect.right)
val y = event.getY(index).coerceIn(rect.top, rect.bottom)
WaylandCompositor.nativeSendTouch(action, event.getPointerId(index),
InputSpace.x(x, surfaceView.width), InputSpace.y(y, surfaceView.height))
}
when (event.actionMasked) {
// A first finger starts a new gesture, and Android has ended every earlier one: whatever
@@ -1894,6 +1944,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun onPictureInPictureModeChanged(inPip: Boolean, newConfig: android.content.res.Configuration) {
super.onPictureInPictureModeChanged(inPip, newConfig)
if (::pip.isInitialized) pip.changed(inPip)
// The panel may have changed while the session was a thumbnail (folded or unfolded in PiP).
if (!inPip && surfaceW > 0) followWindow(surfaceW, surfaceH)
}
override fun startActivityForResult(intent: Intent, requestCode: Int, options: Bundle?) {
@@ -2021,10 +2073,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION)
// ALWAYS from Android 11: SHORT_EDGES left a black strip along a camera on the long edge,
// which is where a Fold's inner-screen camera sits with the device held in landscape.
if (Build.VERSION.SDK_INT >= 28) {
window.attributes = window.attributes.apply {
layoutInDisplayCutoutMode =
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
layoutInDisplayCutoutMode = if (Build.VERSION.SDK_INT >= 30)
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS
else WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
}
}
}
@@ -2052,6 +2107,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
closeSecondScreen(reset = false)
if (::hud.isInitialized) hud.stop()
padBridge?.stop()
if (SessionState.padBridge === padBridge) SessionState.padBridge = null
padMotion?.stop()
if (SessionState.deckPadListener === deckPadListener) SessionState.deckPadListener = null
if (SessionState.endListener === endListener) SessionState.endListener = null
@@ -0,0 +1,48 @@
package com.droiddeck.launcher.agent
import android.content.Context
/**
* Who may run code through the agent bridge. Reading state, starting and stopping sessions,
* launching and quitting games and sending input only need the shell (the provider's DUMP
* permission). Running commands in the guest, evaluating JavaScript in the Steam client, session
* environment, binary overrides and preference writes reach inside the app's sandbox - the guest
* holds the Steam login - which the shell of a stock device otherwise cannot. They require
* Setup > Session > Debugging tools to be on, in every build. The bridge itself cannot turn it on.
*/
object AgentAccess {
const val MAX_REQUEST_CHARS = 256 * 1024
private const val PREFS = "agent"
private const val KEY_COMMANDS = "commands"
/** The preferences file the bridge must never write: set-pref refuses it. */
const val PROTECTED_PREFS = PREFS
fun commandsAllowed(context: Context): Boolean =
prefs(context).getBoolean(KEY_COMMANDS, false)
/** The Setup toggle's own state. */
fun commandsEnabled(context: Context): Boolean = prefs(context).getBoolean(KEY_COMMANDS, false)
fun setCommandsEnabled(context: Context, enabled: Boolean) {
prefs(context).edit().putBoolean(KEY_COMMANDS, enabled).apply()
if (!enabled) runCatching { AgentEnv.revoke(context) }
.onFailure { android.util.Log.w("AgentAccess", "Could not finish clearing debugging experiments") }
}
fun requireCommands(context: Context) {
if (!commandsAllowed(context)) throw AgentException(
"AGENT_COMMANDS_DISABLED",
"Debugging tools are off: turn on Setup > Session > Debugging tools on the device",
)
}
fun requireStart(context: Context, request: org.json.JSONObject) {
if (request.optString("mode") == "run") requireCommands(context)
}
private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
}
/** A refusal with a stable code, answered as {"ok": false, "error": {code, message}}. */
class AgentException(val code: String, message: String) : RuntimeException(message)
@@ -1,5 +1,6 @@
package com.droiddeck.launcher.agent
import com.droiddeck.launcher.session.SessionPaths
import android.Manifest
import android.content.ContentProvider
import android.content.ContentValues
@@ -10,13 +11,14 @@ import android.net.Uri
import android.os.Binder
import android.os.Bundle
import android.os.Process
import android.util.Base64
import com.droiddeck.launcher.BuildConfig
import com.droiddeck.launcher.frontend.GameLaunchLink
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.runtime.LinuxRuntimeInstaller
import com.droiddeck.launcher.session.SessionArtifacts
import com.droiddeck.launcher.session.SessionEvents
import com.droiddeck.launcher.session.SessionPhase
import com.droiddeck.launcher.session.SessionPaths
import com.droiddeck.launcher.session.SessionService
import com.droiddeck.launcher.session.SessionState
import org.json.JSONArray
@@ -25,7 +27,14 @@ import java.io.File
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
/** A shell-only control surface, installed only by debug builds. */
/**
* The shell's control surface (docs/agent-control.md), in every build: the provider and its start
* Activity need android.permission.DUMP, held by the shell and privileged or explicitly granted callers. Commands that
* reach into the app's sandbox are further gated by [AgentAccess].
*
* A method's request is JSON, base64-encoded in the "request" extra; every answer is JSON in the
* "json" key of the returned Bundle, {"ok": true, ...} or {"ok": false, "error": {code, message}}.
*/
class AgentBridgeProvider : ContentProvider() {
override fun onCreate(): Boolean {
val appContext = context?.applicationContext ?: return false
@@ -48,22 +57,58 @@ class AgentBridgeProvider : ContentProvider() {
}
val response = try {
when (method) {
"state" -> state(context).put("ok", true)
"start" -> error("USE_DROIDDECKCTL", "Start sessions with tools/droiddeckctl so Android launches a visible Activity")
"stop" -> stop(context)
"resume" -> resume(context)
else -> error("UNKNOWN_COMMAND", "Unknown agent command '$method'")
val request = decode(extras?.getString(EXTRA_REQUEST))
// Binder identity is the shell's; everything below acts as the app.
val token = Binder.clearCallingIdentity()
try {
dispatch(context, method, request)
} finally {
Binder.restoreCallingIdentity(token)
}
} catch (e: AgentException) {
error(e.code, e.message ?: e.code)
} catch (e: Exception) {
error("COMMAND_FAILED", e.message ?: e.javaClass.simpleName)
}
return Bundle().apply { putString(RESULT_JSON, response.toString()) }
val json = response.toString().let {
if (it.length <= 384 * 1024) it else error("RESPONSE_TOO_LARGE", "Reduce the command output or select fewer fields").toString()
}
return Bundle().apply { putString(RESULT_JSON, json) }
}
private fun dispatch(context: Context, method: String, request: JSONObject): JSONObject = when (method) {
"state" -> state(context).put("ok", true)
"start" -> error("USE_DROIDDECKCTL", "Start sessions with tools/droiddeckctl so Android launches a visible Activity")
"stop" -> stop(context)
"resume" -> resume(context)
"launch" -> launch(context, request)
"focus" -> guest(context, JSONObject().put("kind", "focus"), 15_000)
"quit" -> guest(context, JSONObject().put("kind", "quit").put("appId", request.opt("appId"))
.put("timeout", request.optDouble("timeout", 15.0)), timeoutMs(request, 15.0, 30_000))
"input" -> AgentInput.handle(request)
"ui" -> AgentUi.handle(request)
"guest" -> {
AgentAccess.requireCommands(context)
guest(context, JSONObject(request.toString()).put("kind", "exec"), timeoutMs(request, 30.0, 10_000))
}
"cdp" -> {
AgentAccess.requireCommands(context)
guest(context, JSONObject(request.toString()).put("kind", "cdp"), timeoutMs(request, 15.0, 5_000))
}
"env" -> env(context, request)
"override" -> {
AgentAccess.requireCommands(context)
AgentEnv.override(context, request.optString("name")).put("ok", true).put("command", "override")
}
"prefs" -> prefs(context, request)
"access" -> JSONObject().put("ok", true).put("commands", AgentAccess.commandsAllowed(context))
.put("debugBuild", BuildConfig.DEBUG).put("inbox", AgentEnv.inbox(context).absolutePath)
else -> error("UNKNOWN_COMMAND", "Unknown agent command '$method'")
}
private fun state(context: Context): JSONObject {
val runtimeVersion = LinuxRuntimeInstaller.installedVersion(context)
val dir = SessionState.logDirectory ?: SessionState.logFile?.parentFile ?: latestSessionDirectory()
val dir = SessionState.logDirectory ?: SessionState.logFile?.parentFile ?: SessionPaths.sessionFolders(context).lastOrNull()
val session = JSONObject()
.put("id", SessionState.sessionId ?: dir?.name ?: JSONObject.NULL)
.put("phase", SessionState.phase.name)
@@ -91,22 +136,34 @@ class AgentBridgeProvider : ContentProvider() {
.put("status", SessionState.failureStatus ?: JSONObject.NULL)
session.put("failure", failure)
// gamescope's focus as last published by the guest agent, without the window lists
// (the focus command returns those, fresh).
val focus = AgentGuest.focus(context)?.let { snap ->
JSONObject()
.put("focusedApp", snap.opt("focusedApp") ?: JSONObject.NULL)
.put("focusedWindow", snap.opt("focusedWindow") ?: JSONObject.NULL)
.put("focusableApps", snap.optJSONArray("focusableApps") ?: JSONArray())
.put("baselayerAppIds", snap.optJSONArray("baselayerAppIds") ?: JSONArray())
.put("seq", snap.optInt("seq"))
.put("t", snap.optLong("t"))
}
session.put("focus", focus ?: JSONObject.NULL)
val hello = AgentGuest.hello(context)
return JSONObject()
.put("schema", 1)
.put("schema", SCHEMA)
.put("build", BuildConfig.BUILD_LABEL)
.put("appVersion", context.packageManager.getPackageInfo(context.packageName, 0).versionName)
.put("runtime", JSONObject()
.put("installed", LinuxRuntime.isInstalled(context))
.put("version", runtimeVersion ?: JSONObject.NULL))
.put("agent", JSONObject()
.put("guest", hello != null)
.put("guestFocus", hello?.optBoolean("focus") ?: false)
.put("commands", AgentAccess.commandsAllowed(context)))
.put("session", session)
}
private fun latestSessionDirectory(): File? {
val parent = LinuxRuntime.debugLogDir()
return parent.listFiles { file -> file.isDirectory && file.name.startsWith("session-") }
?.maxWithOrNull(compareBy<File> { it.lastModified() }.thenBy { it.name })
}
private fun stop(context: Context): JSONObject {
if (SessionState.running) {
SessionService.stop(context)
@@ -131,6 +188,67 @@ class AgentBridgeProvider : ContentProvider() {
return JSONObject().put("ok", true).put("command", "resume")
}
private fun launch(context: Context, request: JSONObject): JSONObject {
val appId = request.optString("appId")
if (!GameLaunchLink.validId(appId)) throw AgentException("INVALID_APP_ID", "appId must be a decimal Steam app id")
if (!SessionState.running || SessionState.mode != SessionService.MODE_STEAM) {
throw AgentException("NO_STEAM_SESSION", "Games launch inside a running Steam session")
}
if (!SessionService.launchGame(context, appId)) throw AgentException("LAUNCH_REJECTED", "The session did not accept the launch")
SessionEvents.record("agent.launch_requested", mapOf("appId" to appId))
return JSONObject().put("ok", true).put("command", "launch").put("appId", appId)
}
private fun env(context: Context, request: JSONObject): JSONObject {
AgentAccess.requireCommands(context)
val result = when (val op = request.optString("op", "list")) {
"list" -> AgentEnv.list(context)
"set" -> {
val lines = request.optJSONArray("lines") ?: JSONArray()
AgentEnv.set(context, List(lines.length()) { lines.optString(it) }, request.optBoolean("persistent"))
}
"clear" -> AgentEnv.clear(context, request.optString("scope", "next"))
else -> throw AgentException("INVALID_REQUEST", "env op '$op' is not list, set or clear")
}
return result.put("ok", true).put("command", "env")
}
private fun prefs(context: Context, request: JSONObject): JSONObject {
AgentAccess.requireCommands(context)
val file = request.optString("file")
val result = when (val op = request.optString("op", "get")) {
"get" -> AgentPrefs.get(context, file)
"set" -> AgentPrefs.set(context, file, request.optString("key"), request.optString("type", "string"),
if (request.isNull("value")) null else request.optString("value"))
else -> throw AgentException("INVALID_REQUEST", "prefs op '$op' is not get or set")
}
return result.put("ok", true).put("command", "prefs")
}
/** A guest agent request; its own answer already carries ok or error. */
private fun guest(context: Context, request: JSONObject, timeoutMs: Long): JSONObject =
AgentGuest.call(context, request, timeoutMs)
/** The guest's own timeout plus slack for the round trip. */
private fun timeoutMs(request: JSONObject, defaultSeconds: Double, slackMs: Long): Long {
val seconds = if (request.has("timeout")) request.opt("timeout")?.toString()?.toDoubleOrNull()
?: throw AgentException("INVALID_REQUEST", "timeout must be a number") else defaultSeconds
if (!seconds.isFinite() || seconds <= 0 || seconds > 600) {
throw AgentException("INVALID_REQUEST", "timeout must be a positive number no greater than 600 seconds")
}
return (seconds * 1000).toLong() + slackMs
}
private fun decode(encoded: String?): JSONObject {
if (encoded.isNullOrBlank()) return JSONObject()
if (encoded.length > AgentAccess.MAX_REQUEST_CHARS) throw AgentException("INVALID_REQUEST", "The encoded request exceeds 256 KiB")
return try {
JSONObject(String(Base64.decode(encoded, Base64.DEFAULT), Charsets.UTF_8))
} catch (e: Exception) {
throw AgentException("INVALID_REQUEST", "The request extra is not base64 JSON")
}
}
private fun error(code: String, message: String) = JSONObject()
.put("ok", false)
.put("error", JSONObject().put("code", code).put("message", message))
@@ -143,6 +261,9 @@ class AgentBridgeProvider : ContentProvider() {
companion object {
const val RESULT_JSON = "json"
const val EXTRA_REQUEST = "request"
/** 2 added agent and session.focus; everything in 1 is unchanged. */
const val SCHEMA = 2
private val recoveryComplete = CountDownLatch(1)
fun awaitRecovery() {
@@ -0,0 +1,222 @@
package com.droiddeck.launcher.agent
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.runtime.LinuxRuntime
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.nio.file.Files
import java.nio.file.LinkOption.NOFOLLOW_LINKS
import java.nio.file.StandardOpenOption.READ
/**
* Experiment switches an agent sets without touching Download/droiddeck-env or rebuilding the apk:
*
* - environment lines (KEY=VALUE) for the session, applied after the user's own droiddeck-env, so
* they win. "next" lines are taken by the next session that starts and dropped when it ends;
* "persistent" ones stay until cleared.
* - binary overrides: a file pushed to the app's external files (agent-inbox/<name>) is installed
* as /opt/droiddeck-agent/bin/<name> in the rootfs and put first on the next session's PATH -
* a gamescope build under test, say. Removed when that session ends.
*
* Kept in the app's own files (agent-env.json), never in a place the shell can write.
*/
object AgentEnv {
private const val TAG = "AgentEnv"
private const val FILE = "agent-env.json"
private const val GUEST_BIN = "/opt/droiddeck-agent/bin"
private const val BASE_PATH = "/usr/local/bin:/usr/bin:/bin"
private val NAME = Regex("[A-Za-z0-9._-]{1,64}")
private val KEY = Regex("[A-Za-z_][A-Za-z0-9_]*")
private val lock = Any()
fun inbox(context: Context): File = File(context.getExternalFilesDir(null), "agent-inbox")
fun list(context: Context): JSONObject = synchronized(lock) {
AgentAccess.requireCommands(context)
load(context)
}
/** Add [lines] for the next session ([persistent] = every session until cleared). */
fun set(context: Context, lines: List<String>, persistent: Boolean): JSONObject = synchronized(lock) {
AgentAccess.requireCommands(context)
if (lines.any { line -> KEY.matchEntire(line.substringBefore('=', "")) == null || !line.contains('=') || line.any { it == '\n' || it == '\r' || it == '\u0000' } }) {
throw AgentException("INVALID_ENV", "Environment entries must be single KEY=VALUE lines without NUL bytes")
}
val state = load(context)
val key = if (persistent) "persistent" else "pending"
val merged = merge(strings(state.optJSONArray(key)), lines)
state.put(key, JSONArray(merged))
save(context, state)
state
}
/** Drop lines: [scope] is "next", "persistent" or "all". */
fun clear(context: Context, scope: String): JSONObject = synchronized(lock) {
AgentAccess.requireCommands(context)
val state = load(context)
when (scope) {
"next" -> state.put("pending", JSONArray())
"persistent" -> state.put("persistent", JSONArray())
"all" -> { state.put("pending", JSONArray()); state.put("persistent", JSONArray()); state.put("pendingOverrides", JSONArray()) }
else -> throw AgentException("INVALID_SCOPE", "Scope must be next, persistent or all")
}
save(context, state)
state
}
/** Install agent-inbox/[name] as an override for the next session. */
fun override(context: Context, name: String): JSONObject = synchronized(lock) {
AgentAccess.requireCommands(context)
if (NAME.matchEntire(name) == null || name in setOf(".", "..")) throw AgentException("INVALID_NAME", "Override names are letters, digits, '.', '_' and '-'")
val source = File(inbox(context), name)
if (Files.isSymbolicLink(source.toPath())) throw AgentException("INVALID_NAME", "The override must be a regular file")
if (!source.isFile) throw AgentException("NO_SUCH_FILE", "Push the binary to ${source.path} first")
val target = File(bin(context), name)
target.parentFile?.mkdirs()
val staged = File.createTempFile("override-", ".tmp", target.parentFile)
try {
Files.newInputStream(source.toPath(), READ, NOFOLLOW_LINKS).use { input -> staged.outputStream().use { input.copyTo(it) } }
} catch (e: Exception) {
staged.delete()
throw AgentException("OVERRIDE_FAILED", "Could not stage the override")
}
if (!staged.setExecutable(true, false) || !staged.renameTo(target)) {
staged.delete()
throw AgentException("OVERRIDE_FAILED", "Could not install $name in the rootfs")
}
source.delete()
val state = load(context)
state.put("pendingOverrides", JSONArray(merge(strings(state.optJSONArray("pendingOverrides")), listOf(name))))
save(context, state)
state.put("installed", "$GUEST_BIN/$name")
}
/**
* At session start: the lines this session gets, after the user's droiddeck-env. Pending lines
* and overrides become this session's own, to be dropped by [endSession].
*/
fun beginSession(context: Context): List<String> = synchronized(lock) {
if (!AgentAccess.commandsAllowed(context)) {
revoke(context)
return@synchronized emptyList()
}
val state = load(context)
val active = strings(state.optJSONArray("pending"))
val overrides = strings(state.optJSONArray("pendingOverrides"))
state.put("active", JSONArray(active)).put("pending", JSONArray())
state.put("activeOverrides", JSONArray(overrides)).put("pendingOverrides", JSONArray())
save(context, state)
// Anything else there was left by a session that never reached endSession (the app died).
bin(context).listFiles()
?.filter { it.name !in overrides }?.forEach { it.delete() }
val lines = strings(state.optJSONArray("persistent")) + active
val withPath = if (overrides.isEmpty()) lines else lines + "PATH=$GUEST_BIN:${pathOf(lines)}"
if (withPath.isNotEmpty()) Log.i(TAG, "agent environment keys for this session: ${withPath.map { it.substringBefore('=') }}")
withPath
}
fun revoke(context: Context) = synchronized(lock) {
val state = load(context)
for (key in listOf("pending", "active", "persistent", "pendingOverrides", "activeOverrides")) state.put(key, JSONArray())
save(context, state)
bin(context).listFiles()?.forEach { it.delete() }
}
/** At session end: this session's lines and override binaries go. */
fun endSession(context: Context) = synchronized(lock) {
val state = load(context)
val overrides = strings(state.optJSONArray("activeOverrides"))
val pending = strings(state.optJSONArray("pendingOverrides"))
val bin = bin(context)
overrides.filter { it !in pending }.forEach { File(bin, it).delete() }
state.put("active", JSONArray()).put("activeOverrides", JSONArray())
save(context, state)
}
/** A PATH= line among [lines] (the user's or an agent's) is extended rather than replaced. */
private fun pathOf(lines: List<String>) =
lines.lastOrNull { it.startsWith("PATH=") }?.substringAfter('=')?.takeIf { it.isNotBlank() } ?: BASE_PATH
private fun bin(context: Context): File {
val root = LinuxRuntime.rootDir(context).canonicalFile.toPath()
val directory = root.resolve(GUEST_BIN.removePrefix("/"))
var path = directory
while (path != root) {
if (Files.isSymbolicLink(path)) throw AgentException("OVERRIDE_FAILED", "The override directory must not contain symbolic links")
path = path.parent
}
return directory.toFile()
}
private fun merge(existing: List<String>, added: List<String>): List<String> {
val keys = added.map { it.substringBefore('=') }.toSet()
return existing.filter { it.substringBefore('=') !in keys } + added
}
private fun strings(array: JSONArray?): List<String> =
if (array == null) emptyList() else List(array.length()) { array.optString(it) }.filter { it.isNotEmpty() }
private fun load(context: Context): JSONObject {
val file = File(context.filesDir, FILE)
val state = try { if (file.isFile) JSONObject(file.readText()) else JSONObject() } catch (e: Exception) { JSONObject() }
for (key in listOf("pending", "active", "persistent", "pendingOverrides", "activeOverrides")) {
if (!state.has(key)) state.put(key, JSONArray())
}
return state
}
private fun save(context: Context, state: JSONObject) {
val file = File(context.filesDir, FILE)
val staged = File(context.filesDir, "$FILE.tmp")
staged.writeText(state.toString())
if (!staged.renameTo(file)) {
staged.delete()
throw AgentException("ENV_WRITE_FAILED", "Could not save the debugging environment")
}
}
}
/** Typed writes to the app's SharedPreferences, for driving a settings matrix without the UI. */
object AgentPrefs {
fun set(context: Context, file: String, key: String, type: String, value: String?): JSONObject {
AgentAccess.requireCommands(context)
validateFile(file)
if (file == AgentAccess.PROTECTED_PREFS) throw AgentException("PROTECTED_PREFS", "The agent preferences are set on the device only")
if (file.isBlank() || key.isBlank()) throw AgentException("INVALID_PREF", "file and key are required")
val prefs = context.getSharedPreferences(file, Context.MODE_PRIVATE)
val previous = prefs.all[key]
val editor = prefs.edit()
try {
when (type) {
"remove" -> editor.remove(key)
"bool" -> editor.putBoolean(key, value?.toBooleanStrictOrNull() ?: throw NumberFormatException("not true/false"))
"int" -> editor.putInt(key, value!!.toInt())
"long" -> editor.putLong(key, value!!.toLong())
"float" -> editor.putFloat(key, value!!.toFloat())
"string" -> editor.putString(key, value ?: "")
else -> throw AgentException("INVALID_PREF", "type must be bool, int, long, float, string or remove")
}
} catch (e: RuntimeException) {
if (e is AgentException) throw e
throw AgentException("INVALID_PREF", "The value is not a $type")
}
if (!editor.commit()) throw AgentException("PREF_WRITE_FAILED", "Could not write $file/$key")
return JSONObject().put("file", file).put("key", key).put("type", type)
.put("previous", previous ?: JSONObject.NULL).put("value", prefs.all[key] ?: JSONObject.NULL)
}
fun get(context: Context, file: String): JSONObject {
AgentAccess.requireCommands(context)
validateFile(file)
val all = context.getSharedPreferences(file, Context.MODE_PRIVATE).all
return JSONObject().put("file", file).put("values", JSONObject(all.mapValues { it.value ?: JSONObject.NULL }))
}
private fun validateFile(file: String) {
if (!Regex("[A-Za-z0-9_-][A-Za-z0-9._-]{0,127}").matches(file)) {
throw AgentException("INVALID_PREF", "Preference files must be simple names")
}
}
}
@@ -0,0 +1,128 @@
package com.droiddeck.launcher.agent
import android.content.Context
import android.os.FileObserver
import android.util.Log
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.session.SessionEvents
import com.droiddeck.launcher.session.SessionState
import org.json.JSONObject
import java.io.File
import java.util.UUID
/**
* The app's side of the guest agent (tools/linuxfs/overlay/usr/local/bin/droiddeck-agent): requests
* go in as files under the session root's agent directory and answers come back the same way, so
* whatever runs does so inside the live session with its own environment.
*/
object AgentGuest {
private const val TAG = "AgentGuest"
private const val POLL_MS = 50L
private val lock = Any()
private var observer: FileObserver? = null
private var lastFocusKey: String? = null
fun dir(context: Context) = File(LinuxRuntime.sessionRoot(context), "agent")
/**
* Before each session: nothing of the last one's daemon, answers or focus may be read as
* current. Then watch for the new daemon's hello and focus snapshots, which go into the
* session's events.jsonl.
*/
fun reset(context: Context) {
synchronized(lock) {
observer?.stopWatching()
observer = null
lastFocusKey = null
val dir = dir(context)
dir.deleteRecursively()
dir.mkdirs()
@Suppress("DEPRECATION")
observer = object : FileObserver(dir.path, MOVED_TO or CLOSE_WRITE) {
override fun onEvent(event: Int, path: String?) {
when (path) {
"hello.json" -> readJson(File(dir, path))?.let {
SessionEvents.record("agent.ready", mapOf("pid" to it.optInt("pid"), "focus" to it.optBoolean("focus")))
}
"focus.json" -> readJson(File(dir, path))?.let(::recordFocus)
}
}
}.also { it.startWatching() }
}
}
/** At the end of a session: no more events for a folder that is being closed. */
fun stop() {
synchronized(lock) {
observer?.stopWatching()
observer = null
}
}
private fun recordFocus(snapshot: JSONObject) {
val fields = linkedMapOf<String, Any?>(
"focusedApp" to snapshot.opt("focusedApp"),
"focusableApps" to snapshot.optJSONArray("focusableApps")?.toString(),
"baselayerAppIds" to snapshot.optJSONArray("baselayerAppIds")?.toString(),
)
val key = fields.values.joinToString("|")
synchronized(lock) {
if (key == lastFocusKey) return
lastFocusKey = key
}
SessionEvents.record("focus.changed", fields + ("seq" to snapshot.optInt("seq")))
}
/** The daemon's hello, when it is running in the current session. */
fun hello(context: Context): JSONObject? {
if (!SessionState.running) return null
val hello = readJson(File(dir(context), "hello.json")) ?: return null
val pid = hello.optInt("pid", -1)
return hello.takeIf { pid > 1 && File("/proc/$pid").exists() }
}
/** The newest focus snapshot the daemon published, when a session is running. */
fun focus(context: Context): JSONObject? =
if (SessionState.running) readJson(File(dir(context), "focus.json")) else null
/** Send one request and wait for its answer. Throws [AgentException] with a stable code. */
fun call(context: Context, request: JSONObject, timeoutMs: Long): JSONObject {
if (request.optString("kind") in setOf("exec", "cdp")) AgentAccess.requireCommands(context)
if (!SessionState.running) throw AgentException("NO_ACTIVE_SESSION", "There is no running session")
hello(context) ?: throw AgentException("AGENT_UNAVAILABLE", "The guest agent is not running in this session (yet)")
val dir = dir(context)
val requests = File(dir, "req").apply { mkdirs() }
val answers = File(dir, "resp").apply { mkdirs() }
val id = UUID.randomUUID().toString()
val staged = File(requests, "$id.json.tmp")
val published = File(requests, "$id.json")
val answer = File(answers, "$id.json")
staged.writeText(request.toString())
if (!staged.renameTo(published)) {
staged.delete()
throw AgentException("AGENT_UNAVAILABLE", "Could not hand the request to the guest agent")
}
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
if (answer.isFile) {
if (answer.length() > 1024 * 1024) {
answer.delete()
throw AgentException("RESPONSE_TOO_LARGE", "The guest response exceeds 1 MiB")
}
val result = readJson(answer)
answer.delete()
return result ?: throw AgentException("INVALID_AGENT_RESPONSE", "The guest agent's answer was not JSON")
}
Thread.sleep(POLL_MS)
}
published.delete()
Log.w(TAG, "no answer to ${request.optString("kind")} in ${timeoutMs}ms")
throw AgentException("TIMEOUT", "The guest agent did not answer within ${timeoutMs / 1000}s")
}
private fun readJson(file: File): JSONObject? = try {
if (file.isFile && file.length() <= 1024 * 1024) JSONObject(file.readText()) else null
} catch (e: Exception) {
null
}
}
@@ -0,0 +1,221 @@
package com.droiddeck.launcher.agent
import com.droiddeck.launcher.input.PadState
import com.droiddeck.launcher.session.SessionState
import com.droiddeck.launcher.wayland.WaylandCompositor
import org.json.JSONObject
/**
* Input into the running session the way a player gives it: keys and touches through the
* compositor (what the session screen sends), and the virtual pad through the same PadBridge the
* on-screen controls drive, so games that ignore touch still see a controller.
*/
object AgentInput {
/** Out of the way of real fingers, which Android numbers from 0. */
private const val TOUCH_ID = 9
private const val TOUCH_SCALE_X = 1919f
private const val TOUCH_SCALE_Y = 1079f
fun handle(request: JSONObject): JSONObject {
if (!SessionState.running || !SessionState.firstFrameSeen) {
throw AgentException("SESSION_NOT_READY", "Input needs a running session that has drawn its first frame")
}
return when (val type = request.optString("type")) {
"key" -> key(request)
"text" -> text(request)
"tap" -> tap(request)
"swipe" -> swipe(request)
"pad" -> pad(request)
"stick" -> stick(request)
"trigger" -> trigger(request)
else -> throw AgentException("INVALID_INPUT", "Unknown input type '$type'")
}.put("ok", true).put("command", "input").put("type", request.optString("type"))
}
private fun key(request: JSONObject): JSONObject {
val name = request.optString("key")
val code = KEYS[name.uppercase()] ?: name.toIntOrNull()
?: throw AgentException("INVALID_INPUT", "Unknown key '$name' (a name such as ENTER or an evdev code)")
val modifiers = request.optJSONArray("modifiers")?.let { array ->
List(array.length()) { i ->
KEYS[array.optString(i).uppercase()] ?: throw AgentException("INVALID_INPUT", "Unknown modifier '${array.optString(i)}'")
}
}.orEmpty()
when (val action = request.optString("action", "tap")) {
"down" -> WaylandCompositor.nativeSendKey(code, 1)
"up" -> WaylandCompositor.nativeSendKey(code, 0)
"tap" -> {
modifiers.forEach { WaylandCompositor.nativeSendKey(it, 1) }
WaylandCompositor.nativeSendKey(code, 1)
hold(request, 40)
WaylandCompositor.nativeSendKey(code, 0)
modifiers.asReversed().forEach { WaylandCompositor.nativeSendKey(it, 0) }
}
else -> throw AgentException("INVALID_INPUT", "Key action '$action' is not tap, down or up")
}
return JSONObject().put("code", code)
}
/** Typed as key presses (US layout), which reaches X programs that have no text-input support. */
private fun text(request: JSONObject): JSONObject {
val text = request.optString("text")
if (request.optBoolean("commit")) {
WaylandCompositor.textInputCommit(text)
return JSONObject().put("length", text.length).put("via", "text-input")
}
val missing = text.filter { TYPED[it] == null }
if (missing.isNotEmpty()) throw AgentException("INVALID_INPUT", "Cannot type '${missing.toSet().joinToString("")}' as keys; use commit")
val delay = request.optLong("delayMs", 15)
for (ch in text) {
val (code, shift) = TYPED.getValue(ch)
if (shift) WaylandCompositor.nativeSendKey(KEYS.getValue("LEFTSHIFT"), 1)
WaylandCompositor.nativeSendKey(code, 1)
WaylandCompositor.nativeSendKey(code, 0)
if (shift) WaylandCompositor.nativeSendKey(KEYS.getValue("LEFTSHIFT"), 0)
Thread.sleep(delay)
}
return JSONObject().put("length", text.length).put("via", "keys")
}
private fun tap(request: JSONObject): JSONObject {
val (x, y) = point(request, "x", "y")
WaylandCompositor.nativeSendTouch(0, TOUCH_ID, x, y)
hold(request, 60)
WaylandCompositor.nativeSendTouch(2, TOUCH_ID, x, y)
return JSONObject().put("touch", org.json.JSONArray().put(x).put(y))
}
private fun swipe(request: JSONObject): JSONObject {
val (x1, y1) = point(request, "x", "y")
val (x2, y2) = point(request, "x2", "y2")
val duration = request.optLong("durationMs", 300).coerceIn(16, 10_000)
val steps = (duration / 16).toInt().coerceAtLeast(2)
WaylandCompositor.nativeSendTouch(0, TOUCH_ID, x1, y1)
for (i in 1..steps) {
Thread.sleep(duration / steps)
WaylandCompositor.nativeSendTouch(1, TOUCH_ID, x1 + (x2 - x1) * i / steps, y1 + (y2 - y1) * i / steps)
}
WaylandCompositor.nativeSendTouch(2, TOUCH_ID, x2, y2)
return JSONObject().put("from", org.json.JSONArray().put(x1).put(y1)).put("to", org.json.JSONArray().put(x2).put(y2))
}
/**
* A point in the compositor's touch space (0..1919 x 0..1079 whatever the session's size), from
* the session's output pixels (space "output", the default: what the guest draws at) or 0..1
* fractions (space "fraction").
*/
private fun point(request: JSONObject, xKey: String, yKey: String): Pair<Int, Int> {
if (!request.has(xKey) || !request.has(yKey)) throw AgentException("INVALID_INPUT", "$xKey and $yKey are required")
val x = request.getDouble(xKey)
val y = request.getDouble(yKey)
val (fx, fy) = when (val space = request.optString("space", "output")) {
"fraction" -> x to y
"output" -> {
val (w, h) = SessionState.outputSize
x / (w - 1).coerceAtLeast(1) to y / (h - 1).coerceAtLeast(1)
}
else -> throw AgentException("INVALID_INPUT", "space '$space' is not output or fraction")
}
return (fx.coerceIn(0.0, 1.0) * TOUCH_SCALE_X).toInt() to (fy.coerceIn(0.0, 1.0) * TOUCH_SCALE_Y).toInt()
}
private fun pad(request: JSONObject): JSONObject {
val bridge = SessionState.padBridge ?: throw AgentException("NO_PAD", "The session has no virtual pad")
val name = request.optString("button").lowercase()
if (name == "qam") {
bridge.triggerQam()
return JSONObject().put("button", name)
}
val button = BUTTONS[name]
if (button == null && name !in DPAD) throw AgentException("INVALID_INPUT", "Unknown pad button '$name'")
val apply = { s: PadState, down: Boolean ->
when (name) {
"up" -> s.up = down
"down" -> s.down = down
"left" -> s.left = down
"right" -> s.right = down
else -> s.press(button!!, down)
}
}
when (val action = request.optString("action", "tap")) {
"down" -> bridge.applyTouch { apply(it, true) }
"up" -> bridge.applyTouch { apply(it, false) }
"tap" -> {
bridge.applyTouch { apply(it, true) }
hold(request, 120)
bridge.applyTouch { apply(it, false) }
}
else -> throw AgentException("INVALID_INPUT", "Pad action '$action' is not tap, down or up")
}
return JSONObject().put("button", name)
}
private fun stick(request: JSONObject): JSONObject {
val bridge = SessionState.padBridge ?: throw AgentException("NO_PAD", "The session has no virtual pad")
val right = request.optString("stick", "left") == "right"
val x = request.optDouble("x", 0.0).toFloat().coerceIn(-1f, 1f)
val y = request.optDouble("y", 0.0).toFloat().coerceIn(-1f, 1f)
bridge.applyTouch { if (right) { it.rightX = x; it.rightY = y } else { it.leftX = x; it.leftY = y } }
hold(request, 300)
bridge.applyTouch { if (right) { it.rightX = 0f; it.rightY = 0f } else { it.leftX = 0f; it.leftY = 0f } }
return JSONObject().put("stick", if (right) "right" else "left")
}
private fun trigger(request: JSONObject): JSONObject {
val bridge = SessionState.padBridge ?: throw AgentException("NO_PAD", "The session has no virtual pad")
val right = request.optString("trigger", "right") == "right"
val value = request.optDouble("value", 1.0).toFloat().coerceIn(0f, 1f)
bridge.applyTouch { if (right) it.rightTrigger = value else it.leftTrigger = value }
hold(request, 200)
bridge.applyTouch { if (right) it.rightTrigger = 0f else it.leftTrigger = 0f }
return JSONObject().put("trigger", if (right) "right" else "left")
}
private fun hold(request: JSONObject, defaultMs: Long) =
Thread.sleep(request.optLong("holdMs", defaultMs).coerceIn(0, 10_000))
private val DPAD = setOf("up", "down", "left", "right")
private val BUTTONS = mapOf(
"a" to PadState.A, "b" to PadState.B, "x" to PadState.X, "y" to PadState.Y,
"lb" to PadState.LB, "rb" to PadState.RB, "select" to PadState.SELECT, "back" to PadState.SELECT,
"start" to PadState.START, "menu" to PadState.START, "l3" to PadState.L3, "r3" to PadState.R3,
"guide" to PadState.GUIDE, "steam" to PadState.GUIDE,
)
/** Linux evdev codes (input-event-codes.h) by name. */
private val KEYS: Map<String, Int> = buildMap {
"1234567890".forEachIndexed { i, c -> put(c.toString(), 2 + i) }
"QWERTYUIOP".forEachIndexed { i, c -> put(c.toString(), 16 + i) }
"ASDFGHJKL".forEachIndexed { i, c -> put(c.toString(), 30 + i) }
"ZXCVBNM".forEachIndexed { i, c -> put(c.toString(), 44 + i) }
(1..10).forEach { put("F$it", 58 + it) }
put("F11", 87); put("F12", 88)
putAll(mapOf(
"ESC" to 1, "MINUS" to 12, "EQUAL" to 13, "BACKSPACE" to 14, "TAB" to 15, "LEFTBRACE" to 26,
"RIGHTBRACE" to 27, "ENTER" to 28, "LEFTCTRL" to 29, "SEMICOLON" to 39, "APOSTROPHE" to 40,
"GRAVE" to 41, "LEFTSHIFT" to 42, "BACKSLASH" to 43, "COMMA" to 51, "DOT" to 52, "SLASH" to 53,
"RIGHTSHIFT" to 54, "LEFTALT" to 56, "SPACE" to 57, "CAPSLOCK" to 58, "RIGHTCTRL" to 97,
"RIGHTALT" to 100, "HOME" to 102, "UP" to 103, "PAGEUP" to 104, "LEFT" to 105, "RIGHT" to 106,
"END" to 107, "DOWN" to 108, "PAGEDOWN" to 109, "INSERT" to 110, "DELETE" to 111, "LEFTMETA" to 125,
"CTRL" to 29, "SHIFT" to 42, "ALT" to 56, "META" to 125, "SUPER" to 125, "RETURN" to 28, "ESCAPE" to 1,
))
}
/** US-layout characters as (evdev code, shift). */
private val TYPED: Map<Char, Pair<Int, Boolean>> = buildMap {
for (c in 'a'..'z') put(c, KEYS.getValue(c.uppercase()) to false)
for (c in 'A'..'Z') put(c, KEYS.getValue(c.toString()) to true)
for (c in '0'..'9') put(c, KEYS.getValue(c.toString()) to false)
"!@#$%^&*()".forEachIndexed { i, c -> put(c, KEYS.getValue(((i + 1) % 10).toString()) to true) }
putAll(mapOf(
' ' to (57 to false), '\n' to (28 to false), '\t' to (15 to false),
'-' to (12 to false), '_' to (12 to true), '=' to (13 to false), '+' to (13 to true),
'[' to (26 to false), '{' to (26 to true), ']' to (27 to false), '}' to (27 to true),
';' to (39 to false), ':' to (39 to true), '\'' to (40 to false), '"' to (40 to true),
'`' to (41 to false), '~' to (41 to true), '\\' to (43 to false), '|' to (43 to true),
',' to (51 to false), '<' to (51 to true), '.' to (52 to false), '>' to (52 to true),
'/' to (53 to false), '?' to (53 to true),
))
}
}
@@ -38,6 +38,10 @@ class AgentStartActivity : Activity() {
Log.e(TAG, "missing agent start request")
return null
}
if (encoded.length > AgentAccess.MAX_REQUEST_CHARS) {
Log.w(TAG, "agent start request is too large")
return null
}
return try {
JSONObject(String(Base64.decode(encoded, Base64.DEFAULT), Charsets.UTF_8))
} catch (e: Exception) {
@@ -47,6 +51,13 @@ class AgentStartActivity : Activity() {
}
private fun startSession(request: JSONObject) {
try {
AgentAccess.requireStart(this, request)
} catch (e: AgentException) {
Log.w(TAG, e.code)
finish()
return
}
val requestedMode = request.optString("mode")
val sessionMode = when (requestedMode) {
"steam" -> SessionService.MODE_STEAM
@@ -0,0 +1,157 @@
package com.droiddeck.launcher.agent
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.view.View
import android.view.ViewGroup
import android.view.inspector.WindowInspector
import androidx.compose.ui.platform.ViewRootForTest
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.semantics.SemanticsConfiguration
import androidx.compose.ui.semantics.SemanticsNode
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.semantics.getOrNull
import androidx.compose.ui.text.AnnotatedString
import org.json.JSONArray
import org.json.JSONObject
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
/**
* The app's own UI as an agent sees it: every Compose node in every window of the process (popups
* and menus included), read from Compose's semantics rather than through UI Automator, which on a
* dual-screen device dumps whichever window has focus - often not this app's. Nodes carry their
* test tag (ui/TestTags.kt), text, content description, role, state and on-screen bounds; click
* and set-text act on a node by tag or text, so a test needs no coordinates.
*/
object AgentUi {
private const val TIMEOUT_MS = 5_000L
/** Setup's Debugging tools toggle: the bridge never acts on its own gate (AgentAccess). */
private const val GATE_TAG = "setting-agent-commands"
fun handle(request: JSONObject): JSONObject = when (val op = request.optString("op", "dump")) {
"dump" -> onMain { dump(request.optBoolean("all")) }
"click" -> onMain { act(request) { node -> perform(node, SemanticsActions.OnClick) } }
"set-text" -> onMain {
val text = request.optString("text")
act(request) { node ->
node.config.getOrNull(SemanticsActions.SetText)?.action?.invoke(AnnotatedString(text))
?: throw AgentException("NOT_EDITABLE", "That node takes no text")
}
}
else -> throw AgentException("INVALID_REQUEST", "ui op '$op' is not dump, click or set-text")
}.put("ok", true).put("command", "ui")
private fun roots(): List<Pair<View, ViewRootForTest>> {
if (Build.VERSION.SDK_INT < 29) throw AgentException("UNSUPPORTED", "The UI dump needs Android 10 or later")
val found = mutableListOf<Pair<View, ViewRootForTest>>()
fun walk(view: View) {
if (view is ViewRootForTest) found += view to view
if (view is ViewGroup) for (i in 0 until view.childCount) walk(view.getChildAt(i))
}
WindowInspector.getGlobalWindowViews().filter { it.isAttachedToWindow && it.isShown }.forEach(::walk)
return found
}
private fun dump(all: Boolean): JSONObject {
val windows = JSONArray()
for ((view, root) in roots()) {
val nodes = JSONArray()
val location = IntArray(2).also { view.getLocationOnScreen(it) }
fun visit(node: SemanticsNode) {
val json = describe(node, location)
if (all || json.has("tag") || json.has("text") || json.has("description") || json.optBoolean("clickable")) nodes.put(json)
node.children.forEach(::visit)
}
visit(root.semanticsOwner.rootSemanticsNode)
windows.put(JSONObject()
.put("display", view.display?.displayId ?: JSONObject.NULL)
.put("window", view.rootView.javaClass.simpleName)
.put("nodes", nodes))
}
return JSONObject().put("windows", windows)
}
private fun describe(node: SemanticsNode, origin: IntArray): JSONObject {
val config = node.config
val json = JSONObject().put("id", node.id)
config.getOrNull(SemanticsProperties.TestTag)?.let { json.put("tag", it) }
val text = textFields(config)
text.keys().forEach { json.put(it, text.get(it)) }
config.getOrNull(SemanticsProperties.Role)?.let { json.put("role", it.toString()) }
config.getOrNull(SemanticsProperties.ToggleableState)?.let { json.put("toggle", it.name) }
config.getOrNull(SemanticsProperties.Selected)?.let { json.put("selected", it) }
config.getOrNull(SemanticsProperties.Focused)?.let { json.put("focused", it) }
if (config.contains(SemanticsProperties.Disabled)) json.put("disabled", true)
if (config.contains(SemanticsActions.OnClick)) json.put("clickable", true)
if (config.contains(SemanticsActions.SetText)) json.put("editable", true)
val b = node.boundsInWindow
json.put("bounds", JSONArray()
.put(origin[0] + b.left.toInt()).put(origin[1] + b.top.toInt())
.put(origin[0] + b.right.toInt()).put(origin[1] + b.bottom.toInt()))
return json
}
internal fun textFields(config: SemanticsConfiguration): JSONObject {
val password = config.contains(SemanticsProperties.Password)
val json = JSONObject()
for ((key, value) in listOf(
"text" to config.getOrNull(SemanticsProperties.Text)?.joinToString(" ") { it.text },
"editableText" to config.getOrNull(SemanticsProperties.EditableText)?.text,
"description" to config.getOrNull(SemanticsProperties.ContentDescription)?.joinToString(" "),
"state" to config.getOrNull(SemanticsProperties.StateDescription),
)) value?.let { json.put(key, if (password) "<redacted>" else it) }
return json
}
/**
* The node a request names by "tag" (exact) or "match" (text or description containing it,
* ignoring case); "index" picks among several, in tree order.
*/
private fun act(request: JSONObject, action: (SemanticsNode) -> Unit): JSONObject {
val tag = request.optString("tag").takeIf { it.isNotEmpty() }
val text = if (tag == null) request.optString("match").takeIf { it.isNotEmpty() } else null
if (tag == null && text == null) throw AgentException("INVALID_REQUEST", "Name the node by tag or match")
val matches = mutableListOf<Pair<SemanticsNode, IntArray>>()
for ((view, root) in roots()) {
val origin = IntArray(2).also { view.getLocationOnScreen(it) }
fun visit(node: SemanticsNode) {
val config = node.config
val hit = if (tag != null) config.getOrNull(SemanticsProperties.TestTag) == tag
else (config.getOrNull(SemanticsProperties.Text)?.joinToString(" ") { it.text }
?: config.getOrNull(SemanticsProperties.ContentDescription)?.joinToString(" ") ?: "")
.contains(text!!, ignoreCase = true)
if (hit) matches += node to origin
node.children.forEach(::visit)
}
visit(root.semanticsOwner.rootSemanticsNode)
}
val index = request.optInt("index", 0)
val (node, origin) = matches.getOrNull(index)
?: throw AgentException("NO_SUCH_NODE", "No node with ${if (tag != null) "tag '$tag'" else "text '$text'"} (${matches.size} found)")
if (generateSequence(node) { it.parent }.any { it.config.getOrNull(SemanticsProperties.TestTag) == GATE_TAG }) {
throw AgentException("PROTECTED_CONTROL", "Debugging tools are switched on the device only")
}
action(node)
return JSONObject().put("matches", matches.size).put("node", describe(node, origin))
}
private fun perform(node: SemanticsNode, key: androidx.compose.ui.semantics.SemanticsPropertyKey<androidx.compose.ui.semantics.AccessibilityAction<() -> Boolean>>) {
val action = node.config.getOrNull(key)?.action ?: throw AgentException("NOT_CLICKABLE", "That node has no click action")
action()
}
private fun <T> onMain(block: () -> T): T {
if (Looper.myLooper() == Looper.getMainLooper()) return block()
val result = AtomicReference<Result<T>>()
val done = CountDownLatch(1)
Handler(Looper.getMainLooper()).post {
result.set(runCatching(block))
done.countDown()
}
if (!done.await(TIMEOUT_MS, TimeUnit.MILLISECONDS)) throw AgentException("TIMEOUT", "The UI thread did not answer")
return result.get().getOrThrow()
}
}
@@ -28,7 +28,9 @@ public class PulseAudioComponent extends SessionPart {
/** Where the guest reaches the daemon; the session exports PULSE_SERVER=unix:<this>. */
public static final String SOCKET_NAME = "PS0";
/** Identifies the bundled pulseaudio.tzst; a change here re-unpacks it over what a device has. */
private static final String BUNDLE_STAMP = "2026-09-23-pa13-suspend-r5";
private static final String BUNDLE_STAMP = "2026-10-08-pa13-directaudio-native";
private final File workingDir;
/** Where the daemon's own output is kept for this session, or null for logcat only. */
@@ -40,8 +42,6 @@ public class PulseAudioComponent extends SessionPart {
* turns that one stream into a source the client can see, named DirectAudioMic.
*/
private final String micFifoPath;
/** The DirectAudio relay's socket when the client's output should go through it, else null. */
private String relaySocketPath;
private volatile int pid = -1;
public PulseAudioComponent(Context context) {
@@ -54,15 +54,6 @@ public class PulseAudioComponent extends SessionPart {
this.micFifoPath = micFifoPath;
}
/**
* Route the daemon's output through the DirectAudio relay at this socket instead of an AAudio
* stream of its own. The relay owns the stream outside proot, with its adaptive buffer; the
* daemon only fills a shared ring. Set before {@link #start()}; the relay may start later.
*/
public void setRelaySocket(String path) {
this.relaySocketPath = path;
}
/** CRC-32 of the bundled pulseaudio.tzst, or "?" if it cannot be read. About 600 KB; cheap. */
private String bundleChecksum() {
java.util.zip.CRC32 crc = new java.util.zip.CRC32();
@@ -106,6 +97,7 @@ public class PulseAudioComponent extends SessionPart {
String have = FileUtils.readString(stamp);
String want = BUNDLE_STAMP + "-" + bundleChecksum();
if (!new File(modulesDir, "arm64/module-aaudio-sink.so").isFile()
|| !new File(modulesDir, "arm64/module-directaudio-native-sink.so").isFile()
|| !new File(workingDir, "pactl").isFile()
|| have == null || !want.equals(have.trim())) {
Log.i(TAG, "unpacking pulseaudio.tzst (" + want + "; had " + have + ")");
@@ -139,13 +131,15 @@ public class PulseAudioComponent extends SessionPart {
// the sink to 0% and the session plays silence.
// The sink's name is what the client's Audio settings show as the output device, so it
// says which road the sound takes.
if (relaySocketPath != null && !relaySocketPath.isEmpty()) {
config.add("load-module module-directaudio-sink sink_name=DirectAudio socket=\"" + relaySocketPath + "\" performance_mode=1 adaptive=1 volume=1.0");
config.add("set-default-sink DirectAudio");
} else {
config.add("load-module module-aaudio-sink sink_name=AAudioSink performance_mode=1 adaptive=1 volume=1.0");
config.add("set-default-sink AAudioSink");
}
// The client's sound: DirectAudio's own engine, in this process, one step from Android.
// decay=0: shrinking the device buffer after a quiet spell probes below what works and that
// probe is a click; the client plays menus. Between .nofail and .fail a module that cannot
// open its stream does not take the daemon down; ensureClientSink() then loads the plain
// AAudio sink in its place.
config.add(".nofail");
config.add("load-module module-directaudio-native-sink sink_name=DirectAudio performance_mode=1 adaptive=1 decay=0 limiter=1 watchdog=1 volume=1.0");
config.add("set-default-sink DirectAudio");
config.add(".fail");
if (micFifoPath != null && !micFifoPath.isEmpty()) {
// The format is the helper's, fixed at s16le/48000/mono: it resamples when the device
// grants another input rate, so the daemon is never told a rate the bytes are not.
@@ -177,6 +171,74 @@ public class PulseAudioComponent extends SessionPart {
Log.i(TAG, line);
if (out != null) synchronized (out) { out.println(line); out.flush(); }
});
Thread guard = new Thread(() -> ensureClientSink(out), "pulse-sink-guard");
guard.setDaemon(true);
guard.start();
}
/**
* The fallback: once the daemon answers, if the DirectAudio sink is not
* there (the module failed to load or to open its stream), load the plain AAudio sink so the
* client is never left without an output. Says so in the audio log either way.
*/
private void ensureClientSink(java.io.PrintWriter out) {
long deadline = System.currentTimeMillis() + 15000L;
String sinks = null;
while (System.currentTimeMillis() < deadline && pid > 1) {
if (socket().exists() && (sinks = pactl("list", "short", "sinks")) != null) break;
try { Thread.sleep(250L); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; }
}
String line;
if (sinks == null) {
line = "== client sink: the daemon did not answer in 15 s; no fallback attempted";
} else if (sinks.contains("\tDirectAudio\t") || sinks.startsWith("DirectAudio\t") || sinks.contains("\tDirectAudio\n") || sinks.matches("(?s).*\\bDirectAudio\\b.*")) {
line = "== client sink: DirectAudio (module-directaudio-native-sink)";
} else {
boolean loaded = pactl("load-module", "module-aaudio-sink", "sink_name=AAudioSink", "performance_mode=1", "adaptive=1", "volume=1.0") != null
&& pactl("set-default-sink", "AAudioSink") != null;
line = loaded ? "== client sink: DirectAudio did not come up; FALLBACK to the plain AAudio sink (AAudioSink)"
: "== client sink: DirectAudio did not come up and the AAudio fallback failed too - no client sound";
Log.w(TAG, line);
}
Log.i(TAG, line);
if (out != null) synchronized (out) { out.println(line); out.flush(); }
}
/** Runs pactl against this daemon; its stdout, or null when it failed or timed out. */
private String pactl(String... args) {
File pactl = new File(workingDir, "pactl");
if (!pactl.isFile() || pid <= 1) return null;
java.lang.Process process = null;
try {
File modules = new File(workingDir, "modules/arm64");
ArrayList<String> cmd = new ArrayList<>();
cmd.add(pactl.getAbsolutePath());
java.util.Collections.addAll(cmd, args);
ProcessBuilder builder = new ProcessBuilder(cmd);
builder.directory(workingDir);
builder.redirectErrorStream(true);
builder.environment().put("LD_LIBRARY_PATH", "/system/lib64:" + modules + ":" + workingDir);
builder.environment().put("HOME", workingDir.getAbsolutePath());
builder.environment().put("TMPDIR", workingDir.getAbsolutePath());
builder.environment().put("PULSE_SERVER", "unix:" + socket().getAbsolutePath());
process = builder.start();
String output;
try (InputStream in = process.getInputStream()) {
output = new String(in.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8);
}
if (!process.waitFor(5, TimeUnit.SECONDS)) {
process.destroyForcibly();
return null;
}
return process.exitValue() == 0 ? output : null;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return null;
} catch (Exception e) {
return null;
} finally {
if (process != null && process.isAlive()) process.destroyForcibly();
}
}
@Override
@@ -53,7 +53,7 @@ object DeviceReport {
b.append("DroidDeck session report\n")
b.append("========================\n")
k("Written", SimpleDateFormat("yyyy-MM-dd HH:mm:ss zzz", Locale.US).format(Date()))
k("Session mode", when (mode) { SessionService.MODE_DESKTOP -> "desktop (labwc/LXQt)"; SessionService.MODE_RUN -> "a program under gamescope"; else -> "Steam client (gamescope)" })
k("Session mode", when (mode) { SessionService.MODE_DESKTOP -> "desktop (KDE Plasma)"; SessionService.MODE_RUN -> "a program under gamescope"; else -> "Steam client (gamescope)" })
h("App")
runCatching {
@@ -167,7 +167,6 @@ object DeviceReport {
k("Guest host name", SessionPrefs.guestHostname(context))
k("DirectAudio for games", SessionPrefs.directAudio(context))
k("Stretch 16:9 to panel", SessionPrefs.stretch16x9(context))
k("Client audio", if (SessionPrefs.clientDirectAudio(context)) "DirectAudio" else "classic")
k("Microphone", SessionPrefs.micEnabled(context))
k("On-screen controls", SessionPrefs.oscMode(context))
k("Touch mode", SessionPrefs.touchMode(context))
@@ -181,10 +180,16 @@ object DeviceReport {
h("Android process limits")
k("Phantom proc monitor", PhantomProcessLimit.reportValue(PhantomProcessLimit.read(context)))
h("Device switch files in Download")
for (name in listOf("droiddeck-env", "droiddeck-tu-debug", "droiddeck-driver",
h("Device switch files")
SessionService.envSwitchFile(context)?.takeIf { it.isFile }?.let { f ->
k("droiddeck-env", FileUtils.readString(f)?.trim()?.replace('\n', ' ')?.ifEmpty { "(present, empty)" } ?: "(present)")
}
if (File(Environment.getExternalStorageDirectory(), "Download/droiddeck-env").isFile) {
k("Download/droiddeck-env", "(ignored - it moved to Android/data/${context.packageName}/files)")
}
for (name in listOf("droiddeck-tu-debug", "droiddeck-driver",
"droiddeck-osc", "droiddeck-no-pad",
"droiddeck-no-hud", "droiddeck-wlr-renderer")) {
"droiddeck-no-hud")) {
val f = File(Environment.getExternalStorageDirectory(), "Download/$name")
if (f.isFile) k(name, FileUtils.readString(f)?.trim()?.replace('\n', ' ')?.ifEmpty { "(present, empty)" } ?: "(present)")
}
@@ -40,7 +40,7 @@ object HostProcess {
if (onLine == null) builder.redirectOutput(File("/dev/null"))
val process = builder.start()
val pid = pidOf(process)
Log.i(TAG, "started pid $pid: $command")
Log.i(TAG, "started pid $pid: ${argv.first()} (${argv.size - 1} arguments)")
if (onLine != null) {
Thread({
try {
@@ -59,7 +59,7 @@ object HostProcess {
}
pid
} catch (e: Exception) {
Log.e(TAG, "could not start: $command", e)
Log.e(TAG, "could not start: ${argv.first()}", e)
-1
}
}
@@ -60,15 +60,39 @@ object LogRedactor {
private val IPV4 = Regex("(?<![0-9.])(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})(?![0-9.])")
/** An IPv6 literal: "::" somewhere, or all eight groups (a clock's 10:03:05 is neither). */
private val IPV6 = Regex("(?<![0-9A-Za-z:])[0-9A-Fa-f]{0,4}(?::[0-9A-Fa-f]{0,4}){2,7}(?:%[A-Za-z0-9_.]+)?(?![0-9A-Za-z:])")
private val MAC = Regex("(?i)(?<![0-9a-z:-])[0-9a-f]{2}([:-])(?:[0-9a-f]{2}\\1){4}[0-9a-f]{2}(?![0-9a-z:-])")
private val MAC_FIELD = Regex(
"(?i)(\\bmac(?:[_ ]?address)?[\"']?\\s*[:=]\\s*[\"']?)[0-9a-f]{2}([:-])" +
"(?:[0-9a-f]{2}\\2){4}[0-9a-f]{2}(?![0-9a-z:-])"
)
private val DEVICE_IDENTIFIER = Regex(
"(?i)(\\b(?:android[_ ]?id|(?:device|controller)[_ ]?serial|serial(?:[_ ]?number)?|build\\.serial)" +
"[\"']?\\s*[:=]\\s*)(\"[^\"\\r\\n<>]+\"|'[^'\\r\\n<>]+'|[^\\s\"'<>&;,\\[\\]{}]+)"
)
/**
* The account a Steam UI login line names ("Login: OnLoginStateChange <account> 2 1 0 0"): a
* user who signs in with a plain account name, not an email, had it in every log. The single
* space then a non-space keeps "OnLoginStateChange 0 1 0 0" (no account yet) as it is.
*/
private val LOGIN_STATE = Regex("(OnLoginStateChange )(\\S+)")
private val LOGIN_STATE = Regex("(?i)(OnLoginStateChange:? )([^\\s<]\\S*)")
private val LOGIN_USERS = Regex("(OnLoginUsersChanged )(\\S.*)$")
/**
* An account name as a field: `AccountName`, `account_name`, `username` (`"AccountName"
* "someone"` in a VDF, `account_name=...`, `"username": "..."`) and `login` - the last only
* as `login=`, a quoted key or a VDF pair, so "Login: OnLoginStateChange ..." keeps its label.
* A quoted value is blanked whole; one already blanked is left as it is.
*/
private const val ACCOUNT_VALUE = "(\"[^\"<\\r\\n]+\"|'[^'<\\r\\n]+'|[^\\s\"'<>&;,}]+)"
private val ACCOUNT_KV = Regex(
"(?i)(?<![A-Za-z0-9_])(account[_-]?name|user[_-]?name|epicusername)" +
"([\"']?\\s*[=:]\\s*|[\"']\\s+)" + ACCOUNT_VALUE
)
private val LOGIN_KV = Regex(
"(?i)(?<![A-Za-z0-9_])(login)(\\s*=\\s*|[\"']\\s*[=:]\\s*|[\"']\\s+)" + ACCOUNT_VALUE
)
/** The device's Steam accounts and persona names as patterns (see [learnAccounts]). */
@Volatile
private var accounts: List<Regex> = emptyList()
@@ -196,18 +220,61 @@ object LogRedactor {
* only when that matches, so a folder scrubbed under older rules is scrubbed again on the way
* out.
*/
const val RULES_VERSION = 1
const val RULES_VERSION = 3
/**
* A private IPv4 address (10/8, 172.16/12, 192.168/16, link-local 169.254/16): the home
* network's layout, e.g. its router as the resolver. Blanked in a shared zip only; public
* server addresses stay. Not part of a longer dotted number (a version like 10.0.19041.1).
*/
private val LAN_IPV4 = Regex(
"(?<![\\d.])(?:10\\.\\d{1,3}|192\\.168|172\\.(?:1[6-9]|2\\d|3[01])|169\\.254)\\.\\d{1,3}\\.\\d{1,3}(?!\\.?\\d)"
)
/** [src]'s lines, scrubbed, to [out]. */
fun scrubTo(src: java.io.File, out: java.io.Writer) {
src.forEachLine { line -> out.write(redact(line)); out.write("\n") }
}
/**
* The last pass for a file going into a shared zip: [redact], then the rules the stores' log
* uses too ([SecretScrub]) - URLs without query, fragment and user:password and with any
* token-bearing path segment blanked, token values and Authorization / Cookie headers blanked
* - and private IPv4 addresses as `<lan-address>`. A line already clean comes out unchanged.
*/
fun redactForShare(line: String): String {
if (line.isEmpty()) return line
return try {
val out = SecretScrub.scrub(redact(line), SecretScrub.Urls.KEEP_PATH, "<redacted:token>")
LAN_IPV4.replace(out, "<lan-address>")
} catch (t: Throwable) {
"<redaction failed; line withheld>"
}
}
/** [src]'s lines through [redactForShare], to [out]: the pass every text file in a shared zip gets. */
fun scrubForShare(src: java.io.File, out: java.io.Writer) {
src.forEachLine { line -> out.write(redactForShare(line)); out.write("\n") }
}
/** `<redacted:account>`, inside [value]'s quotes when it had them. */
private fun quoted(value: String): String {
val q = value.first().takeIf { it == '"' || it == '\'' }?.toString().orEmpty()
return "$q<redacted:account>$q"
}
/** [line] with every credential shape replaced. Null- and exception-safe by construction. */
fun redact(line: String): String {
if (line.isEmpty()) return line
return try {
var out = line
out = MAC_FIELD.replace(out) { "${it.groupValues[1]}<redacted:mac>" }
out = MAC.replace(out, "<redacted:mac>")
out = DEVICE_IDENTIFIER.replace(out) {
val value = it.groupValues[2]
val quote = value.first().takeIf { char -> char == '\"' || char == '\'' }?.toString().orEmpty()
"${it.groupValues[1]}${quote}<redacted:serial>${quote}"
}
out = GUID.replace(out, "<redacted:guid>")
out = JWT_LABELLED.replace(out) { "${it.groupValues[1]}<redacted:jwt>" }
out = JWT_BASE64.replace(out, "<redacted:jwt>")
@@ -224,6 +291,8 @@ object LogRedactor {
for (r in own) out = r.replace(out, "<redacted:ip>")
out = LOGIN_STATE.replace(out) { "${it.groupValues[1]}<redacted:account>" }
out = LOGIN_USERS.replace(out) { "${it.groupValues[1]}<redacted:account>" }
out = ACCOUNT_KV.replace(out) { "${it.groupValues[1]}${it.groupValues[2]}${quoted(it.groupValues[3])}" }
out = LOGIN_KV.replace(out) { "${it.groupValues[1]}${it.groupValues[2]}${quoted(it.groupValues[3])}" }
for (r in accounts) out = r.replace(out, "<redacted:account>")
out = EMAIL.replace(out, "<redacted:email>")
out = RESIDUAL.replace(out) { "${it.groupValues[1]}=<redacted:token>" }
@@ -0,0 +1,72 @@
package com.droiddeck.launcher.core
/**
* The one set of rules that keeps URLs and tokens out of anything the app writes or shares: the
* stores' log ([com.droiddeck.launcher.stores.StoreLog.redactLine]) and the last pass over every
* text file in a shared log zip ([LogRedactor.redactForShare]).
*
* - Every URL loses its query, fragment and `user:password@`. [Urls.SHORT] then keeps only the host
* and a plain first path segment (the stores' lines, where a GOG secure link carries its token in
* the path); [Urls.KEEP_PATH] keeps the path but blanks any segment that carries a token.
* - Outside URLs, the values of token-like keys (`token`, `__token__`, `f_token`, `hdnts`, access /
* refresh / id tokens; exchange and authorization codes of four characters or more, and a bare
* `code=` where it is one: in a query string or on a line with OAuth keys such as `client_id`,
* `redirect_uri` or `state`, never an 8-hex exception code like Proton's `code=c0000005`) and of
* Authorization / Cookie headers are blanked with [mark].
*
* A line already scrubbed comes out unchanged.
*/
object SecretScrub {
enum class Urls { SHORT, KEEP_PATH }
private val URL = Regex("""[A-Za-z][A-Za-z0-9+.\-]*://[^\s"'<>]+""")
private val SAFE_SEGMENT = Regex("""[A-Za-z0-9._\-]{1,40}""")
private val TOKEN_SEGMENT = Regex("""(?i)(token|hdnts|hmac|signature|sig|exp)=""")
private val TOKEN = Regex("""(?i)(?<![A-Za-z0-9_])(__token__|f_token|hdnts|access_token|refresh_token|id_token|token)(["']?\s*[=:]\s*["']?)[^\s&;,"'<>}]+""")
/** Codes that are only ever OAuth grants. */
private val GRANT_CODE = Regex("""(?i)(?<![A-Za-z0-9_])(exchange_code|authorization_?code)(["']?\s*=\s*["']?)[^\s&;,"'<>}]{4,}""")
/** A bare `code=`: a grant only right after `?` / `&` (a query string) or beside OAuth keys ([OAUTH_CONTEXT]). */
private val CODE = Regex("""(?i)(?<![A-Za-z0-9_])(code)(["']?\s*=\s*["']?)([^\s&;,"'<>}]{4,})""")
private val OAUTH_CONTEXT = Regex("""(?i)(?<![A-Za-z0-9_])(client_id|client_secret|redirect_uri|state|grant_type|response_type|access_token|refresh_token|id_token)["']?\s*[=:]""")
/** A Windows exception or status code (`code=c0000005`, `code=0x80000003`): never a grant. */
private val STATUS_CODE = Regex("""(?i)(0x)?[0-9a-f]{8}""")
private val HEADER = Regex("""(?i)\b(authorization|cookie|set-cookie)(\s*[:=]\s*).+""")
fun scrub(text: String, urls: Urls, mark: String): String {
var out = URL.replace(text) { m -> if (urls == Urls.SHORT) short(m.value) else keepPath(m.value, mark) }
out = TOKEN.replace(out) { m -> m.groupValues[1] + m.groupValues[2] + mark }
out = GRANT_CODE.replace(out) { m -> m.groupValues[1] + m.groupValues[2] + mark }
val oauth = OAUTH_CONTEXT.containsMatchIn(text)
val src = out
out = CODE.replace(src) { m ->
val inQuery = m.range.first > 0 && src[m.range.first - 1] in "?&"
val grant = !STATUS_CODE.matches(m.groupValues[3]) && (oauth || inQuery)
if (grant) m.groupValues[1] + m.groupValues[2] + mark else m.value
}
out = HEADER.replace(out) { m -> m.groupValues[1] + m.groupValues[2] + mark }
return out
}
private fun split(url: String): Triple<String, String, String?> {
val schemeEnd = url.indexOf("://")
val rest = url.substring(schemeEnd + 3).substringBefore('#').substringBefore('?')
val slash = rest.indexOf('/')
val authority = (if (slash >= 0) rest.substring(0, slash) else rest).substringAfterLast('@')
return Triple(url.substring(0, schemeEnd), authority, if (slash >= 0) rest.substring(slash + 1) else null)
}
private fun short(url: String): String {
val (scheme, authority, path) = split(url)
if (path == null) return "$scheme://$authority"
val first = path.substringBefore('/')
if (first.isEmpty()) return "$scheme://$authority/"
val shown = if (SAFE_SEGMENT.matches(first)) first else "…"
return "$scheme://$authority/$shown" + if (path.length > first.length && shown != "…") "/…" else ""
}
private fun keepPath(url: String, mark: String): String {
val (scheme, authority, path) = split(url)
val clean = path?.split('/')?.joinToString("/") { seg -> if (TOKEN_SEGMENT.containsMatchIn(seg)) mark else seg }
return "$scheme://$authority" + (clean?.let { "/$it" } ?: "")
}
}
@@ -1020,7 +1020,8 @@ fun FileManagerScreen(
// The ROMs folder chosen on the main screen: what the session shows as /root/ROMs.
SessionPrefs.romsDir(context).takeIf { it.isNotEmpty() }?.let(::File)?.takeIf { it.isDirectory }
?.let { add(locItem(stringResource(R.string.fm_rail_roms), Icons.Filled.SportsEsports, it)) }
File("/storage/emulated/0/Download/DroidDeck").takeIf { it.isDirectory }?.let { add(locItem(stringResource(R.string.fm_rail_session_logs), Icons.Filled.Description, it)) }
// The app's own logs folder: private, which this file manager (the app itself) can read.
com.droiddeck.launcher.runtime.LinuxRuntime.logDir(context).takeIf { it.isDirectory }?.let { add(locItem(stringResource(R.string.fm_rail_session_logs), Icons.Filled.Description, it)) }
File("/storage/emulated/0/Pictures").takeIf { it.isDirectory }?.let { add(locItem(stringResource(R.string.fm_rail_pictures), Icons.Filled.Image, it)) }
}
val favItems = remember(favTick) { FavoritesStore.list(context).map(::File).filter { it.exists() } }
@@ -6,6 +6,8 @@ import com.droiddeck.launcher.runtime.LinuxRuntime
import org.json.JSONObject
import com.droiddeck.launcher.session.GameStorage
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.stores.StoreGameSidecar
import com.droiddeck.launcher.stores.StoreInstallRoot
import java.io.File
import java.util.zip.CRC32
@@ -16,13 +18,21 @@ object AddedGames {
private const val LEGACY_LIBRARY = "/mnt/bannerlator-sd"
class Game(
val folder: File, val name: String, val exe: File, val guestExe: String, val guestDir: String,
val folder: File, val name: String,
/** The game's own .exe: what the icon is read from and what the settings page lists. */
val exe: File,
/** The guest path the shortcut runs: [exe], or a store install's launcher .bat in front of it. */
val guestExe: String, val guestDir: String,
/** The client's 32-bit appid for this shortcut, as an unsigned value. */
val appId: Long,
/** What steam://rungameid/ takes for a shortcut. */
val gameId: Long,
val candidates: List<File>,
val steamAppId: Int? = null,
/** Where the game came from: a store's id, or [Library.ADDED] for a folder the user added. */
val source: String = Library.ADDED,
/** The store's own id for the game, from its sidecar; null for a plain added folder. */
val storeId: String? = null,
) {
fun folderName(): String = folder.name
}
@@ -66,9 +76,16 @@ object AddedGames {
SessionPrefs.romsDir(context).takeIf { it.isNotEmpty() }?.let { roms ->
if (path.startsWith("$roms/")) return "/root/ROMs/" + path.removePrefix("$roms/")
}
// A store root on a card has a bind of its own, before the library rule: the same folder
// keeps the same guest path (and appid) whether or not that card is also the Steam library.
StoreInstallRoot.externalGuestPath(context, host)?.let { return it }
GameStorage.effective(context)?.let { lib ->
if (path.startsWith("${lib.path}/")) return "$LIBRARY/" + path.removePrefix("${lib.path}/")
}
// Inside the runtime's own tree (store installs on internal storage live at
// root/Games/Stores): the tree is the session's / and needs no bind.
val runtime = LinuxRuntime.rootDir(context).absolutePath
if (path.startsWith("$runtime/")) return "/" + path.removePrefix("$runtime/")
return null
}
@@ -99,9 +116,14 @@ object AddedGames {
val steamInstalls = steamInstallDirs(dir)
for (folder in dir.listFiles { f -> f.isDirectory }?.sortedBy { it.name.lowercase() } ?: emptyList()) {
if (folder.name.lowercase() in steamInstalls || folder.name.equals("steamapps", ignoreCase = true)) continue
// The store install root sits inside the Games storage; its games are scanned below, per store.
if (folder.name == "Games" && StoreInstallRoot.roots(context).any { it.absolutePath == folder.absolutePath }) continue
scanGame(context, folder, out)
}
}
// Games the Stores section installed: one folder each under <root>/<Store>/, with a sidecar.
// An unfinished one (sidecar still `installing`, or none at all) is neither a game nor Custom.
for (folder in StoreInstallRoot.gameFolders(context)) if (StoreInstallRoot.isFinished(folder)) scanGame(context, folder, out)
return out.distinctBy { it.folder.canonicalPath }
}
@@ -125,13 +147,19 @@ object AddedGames {
private fun scanGame(context: Context, folder: File, out: MutableList<Game>) {
run {
// A store install carries a sidecar; every one of them is a shortcut.
val sidecar = StoreGameSidecar.read(folder)
val candidates = candidates(folder)
val picked = SessionPrefs.addedGameExe(context, folder.path)
val chosen = picked.takeIf { it.isNotEmpty() }?.let { File(it) }?.takeIf { it.isFile }
val found = chosen ?: candidates.firstOrNull() ?: return
val foundGuest = guestPath(context, found)
if (foundGuest == null) { Log.w(TAG, "${folder.name}: the session cannot see ${found.path}"); return }
val name = folder.name
// A store install says which exe it is; the folder's guess only when that file is gone.
val found = chosen ?: sidecar?.exeFile(folder)?.takeIf { it.isFile } ?: candidates.firstOrNull() ?: return
// The store's launcher .bat stands in front of the exe when the game needs arguments or
// environment; it is what the shortcut runs. Dropped when the user picked another exe.
val launch = if (chosen == null) sidecar?.launcherFile(folder)?.takeIf { it.isFile } ?: found else found
val foundGuest = guestPath(context, launch)
if (foundGuest == null) { Log.w(TAG, "${folder.name}: the session cannot see ${launch.path}"); return }
val name = sidecar?.title ?: folder.name
// Keyed by the pre-rename path so shortcut ids, and the prefixes and saves under them, stay put.
val crc = CRC32().apply { update(("\"${foundGuest.replaceFirst(Regex("^$LIBRARY/"), "$LEGACY_LIBRARY/")}\"" + name).toByteArray()) }.value
val appId = SessionPrefs.addedGameAppId(context, folder.path, crc or 0x80000000L)
@@ -143,10 +171,11 @@ object AddedGames {
?.takeIf { SessionPrefs.adoptAddedGameExe(context, folder.path, picked, it.path, rev) }
val exe = adopted ?: found
val guestExe = if (adopted != null) guestPath(context, adopted) ?: return else foundGuest
val guestDir = guestPath(context, exe.parentFile ?: folder) ?: return
val guestDir = guestPath(context, (if (adopted != null) adopted else launch).parentFile ?: folder) ?: return
val steamId = config?.let { steamRoute(it, appId) }
out.add(Game(folder, name, exe, guestExe, guestDir, appId,
steamId?.toLong() ?: ((appId shl 32) or 0x02000000L), candidates, steamId))
steamId?.toLong() ?: ((appId shl 32) or 0x02000000L), candidates, steamId,
source = sidecar?.store?.id ?: Library.ADDED, storeId = sidecar?.id))
}
}
@@ -24,12 +24,18 @@ object Library {
val hero: File? = null, val lastPlayed: Long = 0L,
val gameFiles: File? = null, val protonPrefix: File? = null,
val icon: File? = null,
/** Where the game came from: [SOURCE_STEAM], a store's id (gog, epic, amazon) or [ADDED] for a folder the user added. */
val source: String = SOURCE_STEAM,
/** The store's own id for a store install; null otherwise. */
val storeId: String? = null,
) {
/** Decimal form used by Steam links and Android shortcuts, including unsigned shortcut ids. */
val gameIdString: String get() = java.lang.Long.toUnsignedString(gameId)
}
/** The [SteamGame.library] of a game added to the library rather than installed by Steam. */
/** The [SteamGame.library] of a game added to the library rather than installed by Steam, and the [SteamGame.source] of a plain added folder. */
const val ADDED = "added"
/** The [SteamGame.source] of a title Steam itself installed. */
const val SOURCE_STEAM = "steam"
class Rom(val name: String, val hostPath: File, val guestPath: String, val emulatorId: String, val art: File? = null)
class Emulator(val id: String, val name: String, val system: String, val program: String, val installed: Boolean, val games: List<Rom>) {
@@ -155,6 +161,7 @@ object Library {
hero = art.hero ?: art.header, gameFiles = g.folder,
protonPrefix = protonPrefix(context, g.steamAppId?.toLong() ?: g.appId, libraries = roots),
icon = art.icon?.takeIf { it.extension.lowercase() != "ico" },
source = g.source, storeId = g.storeId,
)
}
}).distinctBy { it.gameId }
@@ -26,6 +26,8 @@ object LibraryCache {
library = o.getString("library"), gameId = o.getLong("gameId"), hero = o.file("hero"),
lastPlayed = o.optLong("lastPlayed"), gameFiles = o.file("gameFiles"), protonPrefix = o.file("protonPrefix"),
icon = o.file("icon"),
source = o.optString("source").ifEmpty { if (o.getString("library") == Library.ADDED) Library.ADDED else Library.SOURCE_STEAM },
storeId = o.optString("storeId").ifEmpty { null },
)
}
}.getOrElse { e -> Log.w(TAG, "unreadable, ignored: ${e.message}"); emptyList() }
@@ -36,7 +38,8 @@ object LibraryCache {
for (g in games) array.put(
JSONObject().put("appId", g.appId).put("name", g.name).put("library", g.library).put("gameId", g.gameId)
.put("lastPlayed", g.lastPlayed).putFile("art", g.art).putFile("hero", g.hero)
.putFile("gameFiles", g.gameFiles).putFile("protonPrefix", g.protonPrefix).putFile("icon", g.icon),
.putFile("gameFiles", g.gameFiles).putFile("protonPrefix", g.protonPrefix).putFile("icon", g.icon)
.put("source", g.source).apply { g.storeId?.let { put("storeId", it) } },
)
val f = file(context)
val tmp = File(f.path + ".tmp")
@@ -25,7 +25,7 @@ import java.util.zip.ZipInputStream;
/**
* Imported LINUX Vulkan drivers: glibc Turnip ICDs ({@code Turnip-<tag>[-variant]-Linux.zip} from
* Banners-Turnip) for the runtime - the gamescope session that runs Valve's native ARM64 Steam
* client, and the labwc desktop beside it. This is the driver that DRAWS there: the client's own UI
* client, and the KDE Plasma desktop beside it. This is the driver that DRAWS there: the client's own UI
* (OpenGL through the runtime's Zink), every game the client launches (D3D through Proton's
* DXVK/VKD3D) and everything on the desktop. Putting the frame on the screen stays the Android
* driver's job, in the app's compositor ({@link TurnipDriver}).
@@ -0,0 +1,13 @@
package com.droiddeck.launcher.input
object InputSpace {
const val WIDTH = 1920
const val HEIGHT = 1080
fun x(position: Float, viewWidth: Int): Int = scale(position, viewWidth, WIDTH)
fun y(position: Float, viewHeight: Int): Int = scale(position, viewHeight, HEIGHT)
private fun scale(position: Float, extent: Int, size: Int): Int =
if (extent <= 0) 0 else (position / extent * size).toInt().coerceIn(0, size - 1)
}
@@ -242,7 +242,7 @@ object AppImageManager {
private fun menuEntry(context: Context, id: String) =
// A system directory, not ~/.local/share/applications: the desktop copies game entries from
// here into the user's with a GPU launcher (droiddeck-desktop-gpu) while it runs on pixman.
// here into the user's with a GPU launcher (droiddeck-desktop-gpu), since it composites in software.
File(LinuxRuntime.rootDir(context), "usr/local/share/applications/droiddeck-appimage-$id.desktop")
/** The Linux desktop's menu entry: the desktop's own Exec rules (and droiddeck-gpu) apply to it. */
@@ -232,10 +232,10 @@ object BwrapSpawner {
val driver = File(lib, "libvulkan_freedreno.so")
if (!driver.isFile || !File("/dev/kgsl-3d0").exists()) return null
if (env.containsKey("VK_ICD_FILENAMES") || env.containsKey("VK_DRIVER_FILES")) return null
// A window on the desktop while labwc composites with pixman gets shared-memory buffers
// only; Zink cannot present there, so such an app keeps Flathub's own Mesa (llvmpipe).
// From the desktop, games go through droiddeck-gpu's gamescope and do get the GPU.
if (env["WLR_RENDERER"] == "pixman" && env["GAMESCOPE_WAYLAND_DISPLAY"] == null) return null
// A window on the desktop gets shared-memory buffers only (KWin composites in software);
// Zink cannot present there, so such an app keeps Flathub's own Mesa (llvmpipe). From the
// desktop, games go through droiddeck-gpu's gamescope and do get the GPU.
if (env["BL_DESKTOP"] == "1" && env["GAMESCOPE_WAYLAND_DISPLAY"] == null) return null
val binds = ArrayList<Bind>()
for (name in listOf("libvulkan_freedreno.so", "libdisplay-info.so.3", "libSPIRV-Tools.so", "libSPIRV-Tools-opt.so")) {
val f = File(lib, name)
@@ -3,6 +3,7 @@ package com.droiddeck.launcher.runtime
import com.droiddeck.launcher.core.Hashes
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.BuildConfig
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.Downloader
import com.droiddeck.launcher.core.FileUtils
@@ -74,10 +75,32 @@ object DesktopCatalog {
installed(context, PROTON_SEED_ID) == null &&
!File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/steamapps/appmanifest_4427310.acf").isFile
// labwc comes with the hosted desktop package itself (its launcher is staged by the app at
// every session, so it cannot tell whether the package is there); SessionFiles uses the same test.
fun desktopInstalled(context: Context): Boolean =
File(LinuxRuntime.rootDir(context), "usr/bin/labwc").isFile
/**
* The desktop: KDE Plasma (tools/desktop-kde). It replaced an LXQt/labwc package of id
* "desktop", which a runtime may still carry; that one does not count, so the first desktop
* after the update installs this one over it.
*/
const val DESKTOP_ID = "desktop-kde"
/**
* The desktop package this apk installs: pinned in tools/desktop-kde/release.env and published
* in DroidDeck-Components, not read from the catalog, so the desktop scripts staged by this
* apk always get the package they were made for.
*/
fun desktopEntry(context: Context) = Entry(
DESKTOP_ID, context.getString(R.string.content_linux_desktop), 1, BuildConfig.DESKTOP_KDE_TAG, "tar",
BuildConfig.DESKTOP_KDE_URL, BuildConfig.DESKTOP_KDE_SHA256, BuildConfig.DESKTOP_KDE_SIZE, "",
"", "",
)
// The desktop package installed to the end: KWin is there and the marker install() writes once
// the whole package is extracted. An install cut short (the app killed, storage full) leaves
// KWin without the marker, and the next desktop installs it again. The launcher is staged by
// the app at every session, so it cannot tell. SessionFiles uses the same test.
fun desktopInstalled(context: Context): Boolean = desktopInstalled(LinuxRuntime.rootDir(context))
fun desktopInstalled(root: File): Boolean =
File(root, "usr/bin/kwin_wayland").isFile && File(root, ".droiddeck-pkg-$DESKTOP_ID").isFile
/** Downloads, verifies and installs one package. Returns null on success, else a message. */
fun install(context: Context, entry: Entry, listener: LinuxRuntimeInstaller.ProgressListener?): String? {
@@ -111,7 +134,12 @@ object DesktopCatalog {
"Exec=env APPIMAGE_EXTRACT_AND_RUN=1 /opt/appimages/${entry.id}.AppImage\n" +
"Icon=${entry.icon}\nTerminal=false\nCategories=${entry.category};\n")
}
else -> if (!LinuxRuntimeInstaller.extract(context, download, root, listener)) return context.getString(R.string.deskpkg_extract_failed)
else -> {
// Not complete until written again at the end: a reinstall cut short must not
// leave the last install's marker saying it is.
marker(context, entry.id).delete()
if (!LinuxRuntimeInstaller.extract(context, download, root, listener)) return context.getString(R.string.deskpkg_extract_failed)
}
}
FileUtils.writeString(marker(context, entry.id), entry.version)
return null
@@ -87,15 +87,16 @@ object FlatpakManager {
?.maxByOrNull { it.first }?.second
}
/** Runs [argv] in the runtime, its output also in Download/DroidDeck/flatpak-<verb>.log. */
/** Runs [argv] in the runtime, its output also in files/logs/tools/flatpak-<verb>.log. */
private fun runGuest(context: Context, argv: List<String>, fakeRoot: Boolean, onLine: (String) -> Unit): Int {
val name = "flatpak-" + (argv.getOrNull(2)?.takeIf { argv.getOrNull(1) == HELPER } ?: "setup")
return GuestCommand.run(context, argv, fakeRoot, name, onLine = onLine)
}
private inline fun <T> exclusive(what: String, block: () -> T): T? {
/** [block]'s result, or [ifBusy]'s while another operation has the installation. */
private inline fun <T> exclusive(what: String, ifBusy: () -> T, block: () -> T): T {
synchronized(this) {
if (busy != null) return null
if (busy != null) return ifBusy()
busy = what
}
try { return block() } finally { busy = null }
@@ -104,7 +105,7 @@ object FlatpakManager {
/** Puts Flatpak into the runtime and adds Flathub. Null on success, else what went wrong. */
fun setup(context: Context, onProgress: (String, Int) -> Unit): String? {
if (!LinuxRuntime.isInstalled(context)) return context.getString(R.string.user_apps_runtime_required)
return exclusive("setup") {
return exclusive("setup", { context.getString(R.string.flatpakmgr_busy) }) {
var failure: String? = null
val status = runGuest(context, listOf("/bin/bash", SETUP), fakeRoot = true) { line ->
Log.i(TAG, "setup: $line")
@@ -119,16 +120,18 @@ object FlatpakManager {
!ready(context) -> context.getString(R.string.flatpakmgr_setup_unfinished)
else -> null
}
} ?: context.getString(R.string.flatpakmgr_busy)
}
}
/**
* One store operation - install, uninstall or update - with its progress as a stage line and
* an overall percentage across the steps Flatpak plans (a runtime, its extensions, the app).
*/
private fun transaction(context: Context, verb: String, id: String?, onProgress: (String, Int) -> Unit): String? {
private fun transaction(
context: Context, verb: String, id: String?, onProgress: (String, Int) -> Unit, onDone: (JSONObject) -> Unit = {},
): String? {
if (!ready(context)) return context.getString(R.string.flatpakmgr_not_ready)
return exclusive("$verb:${id ?: "all"}") {
return exclusive("$verb:${id ?: "all"}", { context.getString(R.string.flatpakmgr_busy) }) {
var error: String? = null
// Progress lines carry no ref; they belong to the step the last "op" line started.
var step = context.getString(R.string.flatpakmgr_starting)
@@ -141,10 +144,11 @@ object FlatpakManager {
"progress" -> onProgress(step, overall(o, o.optInt("percent")))
"error" -> { error = o.optString("message"); Log.w(TAG, "$verb $id: $error") }
"warning" -> Log.w(TAG, "$verb $id: ${o.optString("message")}")
"done" -> onDone(o)
}
}
error ?: if (status != 0) context.getString(R.string.flatpakmgr_exit_status, status) else null
} ?: context.getString(R.string.flatpakmgr_busy)
}
}
private fun stage(context: Context, o: JSONObject): String {
@@ -195,13 +199,20 @@ object FlatpakManager {
fun install(context: Context, id: String, onProgress: (String, Int) -> Unit) = transaction(context, "install", id, onProgress)
fun uninstall(context: Context, id: String, onProgress: (String, Int) -> Unit) = transaction(context, "uninstall", id, onProgress)
/** Installs the .flatpak bundle at [guestPath], its runtimes from Flathub: the app's ID, and what went wrong if anything. */
fun installBundle(context: Context, guestPath: String, onProgress: (String, Int) -> Unit): Pair<String?, String?> {
var id: String? = null
val problem = transaction(context, "install-bundle", guestPath, onProgress) { id = it.optString("id").ifEmpty { null } }
return id to problem
}
fun update(context: Context, id: String?, onProgress: (String, Int) -> Unit) = transaction(context, "update", id, onProgress)
/** Installed apps with an update on Flathub, or null when that could not be checked. */
fun updates(context: Context): Set<String>? {
if (!ready(context)) return emptySet()
var apps: Set<String>? = null
exclusive("updates") {
exclusive("updates", {}) {
runGuest(context, listOf("/usr/bin/python3", HELPER, "updates"), fakeRoot = false) { line ->
val o = runCatching { JSONObject(line) }.getOrNull() ?: return@runGuest
if (o.optString("e") == "updates") {
@@ -17,7 +17,7 @@ object GuestCommand {
/**
* Runs [argv] and hands each output line to [onLine]; returns the exit status. [fakeRoot] is
* for the package tools, which refuse any uid but 0. With [logName], everything the command
* says also goes to Download/DroidDeck/tools/<logName>.log beside the session logs, so a problem can
* says also goes to files/logs/tools/<logName>.log beside the session logs, so a problem can
* be handed over like a session's. With [linkDir], hard links (which Android denies apps)
* become symlinks to files proot keeps there (its link2symlink).
*/
@@ -25,7 +25,7 @@ object GuestCommand {
linkDir: File? = null, onLine: (String) -> Unit): Int {
val log = logName?.let {
try {
File(File(LinuxRuntime.debugLogDir(), SessionPaths.TOOLS_DIR).apply { mkdirs() }, "$it.log").printWriter()
File(File(LinuxRuntime.logDir(context), SessionPaths.TOOLS_DIR).apply { mkdirs() }, "$it.log").printWriter()
} catch (e: Exception) { null }
}
log?.println("== ${java.util.Date()} ${argv.joinToString(" ")}")
@@ -44,10 +44,20 @@ public final class LinuxRuntime {
public static final String GUEST_RUNTIME_DIR = "/run/droiddeck";
private static final String SYSTEM_FONTS = "/system/fonts";
private static final String GUEST_SYSTEM_FONTS = "/usr/local/share/fonts/android";
/** Where every Linux session's debug log lands: public, so a user can just hand the folder over. */
/** The folder under Downloads where builds before private logs wrote them; read only to move them. */
public static final String DEBUG_LOG_DIR = "DroidDeck";
public static File debugLogDir() {
/**
* Where every log lands: app-private, {@code files/logs}. Session folders, tools/ (one-off
* commands) and stores/ sit under it; the files directory is bound into every session at the
* same path, so the guest writes there too. Share logs is how a folder leaves the device.
*/
public static File logDir(Context context) {
return new File(context.getFilesDir(), "logs");
}
/** {@code Download/DroidDeck}, where earlier builds wrote logs. Only LogMigration reads it. */
public static File legacyLogDir() {
return new File(android.os.Environment.getExternalStoragePublicDirectory(
android.os.Environment.DIRECTORY_DOWNLOADS), DEBUG_LOG_DIR);
}
@@ -227,7 +227,8 @@ public final class LinuxRuntimeInstaller {
// Its quarantined home is not user data to carry into an update.
File pendingRemoval = removalDirectory(LinuxRuntime.rootDir(context));
if (pendingRemoval.exists() && listener != null) listener.onProgress(context.getString(R.string.rtinst_removing_leftovers), -1);
RuntimeFileTree.delete(pendingRemoval, null);
if (!discard(pendingRemoval, null)) throw new IOException("Cannot clear the previous runtime: " + pendingRemoval);
emptyTrash(LinuxRuntime.rootDir(context).getParentFile());
String downloading = context.getString(R.string.rtinst_downloading);
if (listener != null) listener.onProgress(Step.DOWNLOADING, downloading, 0);
// Downloader reports a 0..1 fraction, or -1 while the total size is unknown.
@@ -255,8 +256,7 @@ public final class LinuxRuntimeInstaller {
File staging = new File(root.getParentFile(), LinuxRuntime.DIR + ".new");
File old = new File(root.getParentFile(), LinuxRuntime.DIR + ".old");
recoverInterruptedSwap(root, staging, old);
RuntimeFileTree.delete(staging, null);
if (!staging.mkdirs()) return false;
if (!discard(staging, null) || !staging.mkdirs()) return false;
if (listener != null) listener.onProgress(context.getString(R.string.rtinst_extracting), -1);
if (!extract(context, archive, staging, listener)) {
RuntimeFileTree.delete(staging, null);
@@ -264,7 +264,10 @@ public final class LinuxRuntimeInstaller {
}
FileUtils.writeString(new File(staging, VERSION_FILE), release.version);
RuntimeFileTree.delete(old, null);
if (!discard(old, null)) {
discard(staging, null);
return false;
}
if (root.isDirectory() && !root.renameTo(old)) {
RuntimeFileTree.delete(staging, null);
return false;
@@ -292,7 +295,7 @@ public final class LinuxRuntimeInstaller {
if (old.isDirectory()) old.renameTo(root);
return false;
}
RuntimeFileTree.delete(old, null);
discard(old, null);
return LinuxRuntime.isInstalled(context);
} catch (Exception e) {
Log.e(TAG, "install", e);
@@ -315,6 +318,35 @@ public final class LinuxRuntimeInstaller {
}
}
/** Where entries the app cannot unlink are set aside, out of the way of the runtime's names. */
static final String TRASH = ".runtime-trash";
/**
* Gets {@code tree} out from under its name. What can be deleted is; if something inside
* cannot be (a file made by root or {@code su} carries another SELinux category, and the app
* may not unlink it), the remainder is moved aside into {@link #TRASH} instead of blocking
* every later install and removal on it. False only when even that move is refused.
*/
static boolean discard(File tree, java.util.function.LongConsumer progress) {
if (RuntimeFileTree.deleteWhatCan(tree, progress)) return true;
File trash = new File(tree.getParentFile(), TRASH);
trash.mkdirs();
File aside = new File(trash, tree.getName() + "-" + System.currentTimeMillis());
if (tree.renameTo(aside)) {
Log.w(TAG, "could not delete all of " + tree + "; set the rest aside in " + aside);
return true;
}
Log.w(TAG, "could not delete or set aside " + tree);
return !tree.exists();
}
/** Another try at what {@link #discard} set aside. Whatever still cannot go stays there. */
static void emptyTrash(File parent) {
File trash = new File(parent, TRASH);
if (trash.exists() && !RuntimeFileTree.deleteWhatCan(trash, null))
Log.i(TAG, "some runtime leftovers still cannot be deleted: " + trash);
}
private static boolean isEmptyDir(File dir) {
String[] names = dir.list();
return dir.isDirectory() && names != null && names.length == 0;
@@ -369,15 +401,21 @@ public final class LinuxRuntimeInstaller {
// Invalidate the installation before traversing it. A process killed halfway
// through leaves a named removal to resume, never a launchable partial runtime.
if (root.exists()) {
RuntimeFileTree.delete(pending, progress);
if (!root.renameTo(pending)) throw new IOException("Cannot prepare the runtime for removal");
if (!discard(pending, progress) || !root.renameTo(pending))
throw new IOException("Cannot prepare the runtime for removal");
} else if (!pending.isDirectory() && !pending.mkdirs()) {
throw new IOException("Cannot prepare the runtime for removal");
}
// Keep the quarantine until all leftovers are gone, so a failure stays retryable.
RuntimeFileTree.delete(new File(root.getParentFile(), root.getName() + ".new"), progress);
RuntimeFileTree.delete(new File(root.getParentFile(), root.getName() + ".old"), progress);
RuntimeFileTree.delete(pending, progress);
// An entry the app may not delete is set aside rather than failing the removal:
// a quarantine that can never clear would block reinstalling for good.
for (File leftover : new File[]{
new File(root.getParentFile(), root.getName() + ".new"),
new File(root.getParentFile(), root.getName() + ".old"),
pending}) {
if (!discard(leftover, progress)) throw new IOException("Cannot remove " + leftover);
}
emptyTrash(root.getParentFile());
job.ok = true;
report(text(app, R.string.rtinst_removed), 100);
return true;
@@ -24,8 +24,15 @@ object ProotFastPath {
/** MAX_BINDS in fastpath.c: past it the library turns itself off, so the session keeps it off. */
const val MAX_BINDS = 256
/**
* Only the app's own proot (libproot.so, built with patches/0014) lets the trampoline through.
* The runtime's fallback copy (LinuxRuntime.prootBinary) is Termux's, which traps those calls
* too and translates their host paths a second time: every lookup then fails inside the
* process and goes to proot anyway, paying for both.
*/
fun enabled(context: Context): Boolean =
SessionPrefs.prootFastPath(context) && !SessionPrefs.prootNoSeccomp(context)
SessionPrefs.prootFastPath(context) && !SessionPrefs.prootNoSeccomp(context) &&
LinuxRuntime.prootBinary(context).parent == context.applicationInfo.nativeLibraryDir
/** The key for this rootfs and these binds, or null when the library cannot be told them. */
fun key(root: File, binds: List<String>): String? {
@@ -2,6 +2,7 @@ package com.droiddeck.launcher.runtime;
import java.io.File;
import java.io.IOException;
import java.nio.file.AccessDeniedException;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.LinkOption;
@@ -9,11 +10,15 @@ import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.function.LongConsumer;
import java.util.function.Predicate;
/** Checked removal of an app-owned runtime tree, including read-only directories. */
final class RuntimeFileTree {
private RuntimeFileTree() {}
/** Unit tests stand in for an entry the app cannot unlink (a file another SELinux user made). */
static volatile Predicate<Path> refuseForTest;
static void delete(File root, LongConsumer progress) throws IOException {
delete(root.toPath(), progress, new long[]{0});
}
@@ -30,10 +35,48 @@ final class RuntimeFileTree {
for (Path child : children) delete(child, progress, removed);
}
}
Files.delete(path);
unlink(path);
if (progress != null) progress.accept(++removed[0]);
}
/**
* Removes what it can of {@code root} and carries on past entries it cannot, rather than
* stopping at the first. Returns true when nothing is left.
*/
static boolean deleteWhatCan(File root, LongConsumer progress) {
return deleteWhatCan(root.toPath(), progress, new long[]{0});
}
private static boolean deleteWhatCan(Path path, LongConsumer progress, long[] removed) {
BasicFileAttributes attrs;
try { attrs = Files.readAttributes(path, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); }
catch (NoSuchFileException e) { return true; }
catch (IOException e) { return false; }
boolean emptied = true;
if (attrs.isDirectory()) {
try {
makeWritable(path.toFile());
try (DirectoryStream<Path> children = Files.newDirectoryStream(path)) {
for (Path child : children) emptied &= deleteWhatCan(child, progress, removed);
}
} catch (IOException | RuntimeException e) {
emptied = false;
}
}
if (!emptied) return false;
try { unlink(path); }
catch (NoSuchFileException e) { return true; }
catch (IOException e) { return false; }
if (progress != null) progress.accept(++removed[0]);
return true;
}
private static void unlink(Path path) throws IOException {
Predicate<Path> refuse = refuseForTest;
if (refuse != null && refuse.test(path)) throw new AccessDeniedException(path.toString());
Files.delete(path);
}
private static void makeWritable(File dir) throws IOException {
if (!dir.setReadable(true, true) || !dir.setWritable(true, true) || !dir.setExecutable(true, true))
throw new IOException("Cannot make directory removable: " + dir);
@@ -69,6 +69,7 @@ object UserApps {
class AppImage(val file: File) : Source()
class GitHub(val repo: String) : Source()
class Flatpak(val id: String) : Source()
class FlatpakBundle(val file: File) : Source()
}
/** [icon]: a picked file's path or a suggested icon's URL. */
@@ -124,6 +125,7 @@ object UserApps {
is Source.AppImage -> AppImageManager.import(context, s.file, name, icon) { onProgress(it, -1) }
is Source.GitHub -> addFromGitHub(context, s.repo, name, icon, onProgress)
is Source.Flatpak -> addFlatpak(context, s.id, name, icon, onProgress)
is Source.FlatpakBundle -> addFlatpakBundle(context, s.file, name, icon, onProgress)
}
}
}
@@ -422,9 +424,36 @@ object UserApps {
private fun addFlatpak(context: Context, id: String, name: String?, icon: File?, onProgress: (String, Int) -> Unit): String? {
if (!FlatpakManager.ready(context)) FlatpakManager.setup(context, onProgress)?.let { return it }
FlatpakManager.install(context, id, onProgress)?.let { return it }
val custom = overrides(context, id)
if (name != null) FileUtils.writeString(File(custom.apply { mkdirs() }, "name"), name)
if (icon != null && !saveIcon(icon, File(custom, "icon.png"))) Log.w(TAG, "icon ${icon.path} could not be read")
customiseFlatpak(context, id, name, icon)
return null
}
private fun addFlatpakBundle(context: Context, file: File, name: String?, icon: File?, onProgress: (String, Int) -> Unit): String? {
if (!file.isFile) return context.getString(R.string.user_apps_file_gone)
if (!FlatpakManager.ready(context)) FlatpakManager.setup(context, onProgress)?.let { return it }
// Shared storage is bound into the runtime at its own path; a file anywhere else is copied in.
val shared = Environment.getExternalStorageDirectory().canonicalPath
val copy = File(LinuxRuntime.rootDir(context), "tmp/droiddeck-bundle.flatpak").takeIf { !file.canonicalPath.startsWith("$shared/") }
try {
if (copy != null) {
onProgress(context.getString(R.string.user_apps_copying, file.name), -1)
file.copyTo(copy.apply { parentFile?.mkdirs() }, overwrite = true)
}
val (id, problem) = FlatpakManager.installBundle(context, copy?.let { "/tmp/${it.name}" } ?: file.canonicalPath, onProgress)
problem?.let { return it }
if (id != null) customiseFlatpak(context, id, name, icon)
return null
} catch (e: Exception) {
Log.e(TAG, "bundle ${file.path}", e)
return e.message ?: context.getString(R.string.user_apps_failed)
} finally {
copy?.delete()
}
}
private fun customiseFlatpak(context: Context, id: String, name: String?, icon: File?) {
val custom = overrides(context, id)
if (name != null) FileUtils.writeString(File(custom.apply { mkdirs() }, "name"), name)
if (icon != null && !saveIcon(icon, File(custom.apply { mkdirs() }, "icon.png"))) Log.w(TAG, "icon ${icon.path} could not be read")
}
}
@@ -0,0 +1,73 @@
package com.droiddeck.launcher.session
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.runtime.LinuxRuntime
import java.io.File
/**
* Once, on the first start of a build with private logs: the session folders, tools/ and the loose
* tool logs that earlier builds left in `Download/DroidDeck/` move into `files/logs/`. Best effort:
* an item that cannot be moved stays where it is. Moved session folders lose their old scrub markers
* ([SessionArtifacts.unmarkMoved]), so [SessionArtifacts.scrubOlder] scrubs them under the current rules. Nothing else in that folder is touched - game
* save backups (Saves/) stay public - and after this pass the app never reads it again.
*/
object LogMigration {
private const val TAG = "LogMigration"
private const val MARKER = ".moved-from-downloads"
@Synchronized
fun run(context: Context) {
val target = LinuxRuntime.logDir(context).apply { mkdirs() }
val marker = File(target, MARKER)
if (marker.exists()) return
val legacy = LinuxRuntime.legacyLogDir()
val items = if (legacy.isDirectory) legacy.listFiles() else emptyArray()
// Unreadable (no storage access yet): try again next start rather than give up on them.
if (items == null) { Log.w(TAG, "$legacy cannot be listed; logs there stay until it can"); return }
var moved = 0
val movedFolders = ArrayList<File>()
for (f in items) {
val isLog = SessionPaths.isSessionFolder(f) ||
(f.isDirectory && f.name == SessionPaths.TOOLS_DIR) ||
(f.isFile && f.name.endsWith(".log"))
if (!isLog) continue
val dest = File(if (f.isFile) File(target, SessionPaths.TOOLS_DIR).apply { mkdirs() } else target, f.name)
if (dest.exists() && f.name != SessionPaths.TOOLS_DIR) continue
if (move(f, dest)) {
moved++
if (SessionPaths.isSessionFolder(dest)) movedFolders.add(dest)
} else Log.w(TAG, "could not move ${f.name}; left in place")
}
// Earlier builds scrubbed these under older rules (or not at all) and marked them done:
// without their markers, the pass over older folders that follows at the same start puts
// them through the current redactor.
SessionArtifacts.unmarkMoved(movedFolders)
try { marker.writeText("moved $moved item(s)\n") } catch (e: Exception) { Log.w(TAG, "could not mark the move: ${e.message}") }
if (moved > 0) Log.i(TAG, "moved $moved log item(s) from $legacy into $target")
}
/** A rename where it works (same volume), else a copy then the source's removal; false = left as it was. */
private fun move(src: File, dest: File): Boolean {
if (src.renameTo(dest)) return true
return try {
copy(src, dest)
FileUtils.delete(src)
true
} catch (e: Exception) {
Log.w(TAG, "copy of ${src.name} failed: ${e.message}")
false
}
}
private fun copy(src: File, dest: File) {
if (src.isDirectory) {
dest.mkdirs()
src.listFiles()?.forEach { copy(it, File(dest, it.name)) }
} else {
dest.parentFile?.mkdirs()
src.inputStream().use { input -> dest.outputStream().use { input.copyTo(it) } }
}
}
}
@@ -31,16 +31,20 @@ object SessionArtifacts {
/** Written last; a folder without it did not get its ending. */
const val COMPLETE_MARKER = ".complete"
/** Every file in the folder has been through the redactor, own addresses and accounts included (-2: accounts added). */
private const val SCRUBBED_MARKER = ".scrubbed-2"
/**
* Every file in the folder has been through the redactor under its current rules. Named after
* [LogRedactor.RULES_VERSION], so a folder scrubbed under older rules is scrubbed again.
*/
private val SCRUBBED_MARKER = ".scrubbed-r${LogRedactor.RULES_VERSION}"
private val OLD_SCRUBBED_MARKER = Regex("""\.scrubbed-(r?\d+)""")
/**
* Written by a session's own ending, and only when every file in the folder, subfolders
* included (whatever the session script copied into steam/ and droiddeck-esync/ as well as what
* the app wrote), went through the redactor without a failure. It lists each of those files
* with its size and modification time, under the redactor's rules version: a share copies a
* file as it is only when all three still match ([scrubbedFiles]), and scrubs anything else -
* a file added or changed since, a hidden one, or every file of a folder without it.
* with its size and modification time, under the redactor's rules version ([scrubbedFiles]).
* A share no longer relies on it: every text file goes through the share pass on the way into
* the zip regardless.
*/
const val SCRUBBED_TREE_MARKER = ".scrubbed-tree"
@@ -67,24 +71,48 @@ object SessionArtifacts {
}
/**
* Once, for session folders written before every file was scrubbed and before the device's own
* addresses were (network.txt listed them; the client's IPv6 check logs "external address"
* into steam/connection_log.txt): the whole folder, steam/ included, through the redactor.
* A marker records it. Runs at app start with [finishAbandoned].
* Every session folder not yet scrubbed under the redactor's current rules - written before
* every file was scrubbed, before the device's own addresses or the Steam account were, or
* moved in from Download/ ([LogMigration] strips their markers first, [unmarkMoved]): the
* whole folder, steam/ and every other subfolder included, through the redactor, and the
* current marker written in place of the old ones. Runs at app start with [finishAbandoned].
*
* A folder holds some 30 MB of Steam logs, about half a minute's work, so folders go through
* a few at a time; one the process does not get to keeps its old marker and is taken up at
* the next start.
*/
@Synchronized
fun scrubOlder(context: Context) {
val current = SessionPaths.current()
val dirs = LinuxRuntime.debugLogDir().listFiles { f ->
val dirs = LinuxRuntime.logDir(context).listFiles { f ->
SessionPaths.isSessionFolder(f) && f != current && !File(f, SCRUBBED_MARKER).exists()
} ?: return
}?.toList() ?: return
if (dirs.isEmpty()) return
LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context))
dirs.forEach { dir ->
Record(dir).let { r -> scrubFolder(dir, r); File(dir, "steam").takeIf { it.isDirectory }?.let { scrubFolder(it, r) } }
try { File(dir, SCRUBBED_MARKER).writeText("scrubbed ${now()}\n") } catch (e: Exception) {}
val workers = (Runtime.getRuntime().availableProcessors() / 2).coerceIn(1, 4)
val pool = java.util.concurrent.Executors.newFixedThreadPool(workers) { r ->
Thread({ android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_BACKGROUND); r.run() }, "scrub-logs")
}
val done = try {
pool.invokeAll(dirs.map { dir -> java.util.concurrent.Callable { scrubAndMark(dir) } })
.count { f -> try { f.get() } catch (e: Exception) { false } }
} finally {
pool.shutdown()
}
val failed = dirs.size - done
Log.i(TAG, "logs: scrubbed $done folders under r${LogRedactor.RULES_VERSION}" +
if (failed > 0) ", $failed not fully (again at the next start)" else "")
}
/**
* Folders [LogMigration] just moved in lose every scrub marker an earlier build left, so
* [scrubOlder], which runs right after it, puts them through the current rules whatever those
* markers claimed.
*/
fun unmarkMoved(dirs: List<File>) {
dirs.filter { SessionPaths.isSessionFolder(it) }.forEach { dir ->
dir.listFiles { f -> OLD_SCRUBBED_MARKER.matches(f.name) || f.name == SCRUBBED_TREE_MARKER }?.forEach { it.delete() }
}
Log.i(TAG, "scrubbed ${dirs.size} older session folder(s)")
}
/**
@@ -143,15 +171,17 @@ object SessionArtifacts {
* Records [record]'s folder as scrubbed - every file went through without a failure - or
* leaves it unmarked, so the next share and the pass over older folders scrub it again.
*/
private fun markScrubbed(record: Record) {
private fun markScrubbed(record: Record): Boolean {
val dir = record.dir
val tree = File(dir, SCRUBBED_TREE_MARKER)
// A marker from older rules vouches for nothing now, whether or not this pass succeeded.
dir.listFiles { f -> OLD_SCRUBBED_MARKER.matches(f.name) }?.forEach { it.delete() }
if (record.failed) {
tree.delete()
Log.w(TAG, "$dir is not fully scrubbed; it is scrubbed again when shared")
return
return false
}
try {
return try {
File(dir, SCRUBBED_MARKER).writeText("scrubbed ${now()}\n")
val lines = StringBuilder("rules ${LogRedactor.RULES_VERSION}\n")
record.files.forEach { (f, s) ->
@@ -159,8 +189,11 @@ object SessionArtifacts {
.append(f.relativeTo(dir).path).append('\n')
}
tree.writeText(lines.toString())
true
} catch (e: Exception) {
tree.delete()
File(dir, SCRUBBED_MARKER).delete()
false
}
}
@@ -181,9 +214,8 @@ object SessionArtifacts {
}.toSet()
}
/** The end-of-session scrub of [dir] on its own (steam/ already in place), for tests. */
@androidx.annotation.VisibleForTesting
internal fun scrubAndMark(dir: File) = markScrubbed(scrubTree(Record(dir)))
/** The end-of-session scrub of [dir] on its own (steam/ already in place); also the pass over older and moved folders. */
internal fun scrubAndMark(dir: File): Boolean = markScrubbed(scrubTree(Record(dir)))
/**
* [record]'s folder and every folder under it (steam/, droiddeck-esync/): the session script
@@ -223,7 +255,7 @@ object SessionArtifacts {
*/
@Synchronized
fun finishAbandoned(context: Context) {
val parent = LinuxRuntime.debugLogDir()
val parent = LinuxRuntime.logDir(context)
val abandoned = parent.listFiles { f ->
SessionPaths.isSessionFolder(f) && !File(f, COMPLETE_MARKER).exists()
}?.sortedWith(SessionPaths.chronological) ?: return
@@ -282,9 +314,7 @@ object SessionArtifacts {
val current = SessionPaths.current()
val gone = SessionPaths.sessionFolders(context).filter { it != current }
gone.forEach { com.droiddeck.launcher.core.FileUtils.delete(it) }
com.droiddeck.launcher.core.FileUtils.delete(File(LinuxRuntime.debugLogDir(), SessionPaths.TOOLS_DIR))
// Before tools/, those logs sat loose beside the session folders.
LinuxRuntime.debugLogDir().listFiles { f -> f.isFile && f.name.endsWith(".log") }?.forEach { it.delete() }
com.droiddeck.launcher.core.FileUtils.delete(File(LinuxRuntime.logDir(context), SessionPaths.TOOLS_DIR))
Log.i(TAG, "cleared ${gone.size} session folder(s)")
return gone.size
}
@@ -6,6 +6,11 @@ import android.view.WindowManager
/** The virtual display advertised to the guest, before it is fitted onto the Android panel. */
object SessionDisplay {
const val MATCH_SCREEN = "screen"
/** The window's size, kept in step while the session runs: a foldable opening or closing, a
* split screen or a freeform window resizes the guest's display instead of letterboxing it. */
const val FOLLOW_SCREEN = "follow"
/** Below this a side is a picture-in-picture or a sliver of split screen: not worth a resize. */
const val FOLLOW_MIN_SIDE = 480
/**
* Presets are heights, not sizes: "720p" is the panel's own shape at 720 lines, never narrower
@@ -25,7 +30,7 @@ object SessionDisplay {
(maxOf(panel.first, panel.second) and 1.inv()) to (minOf(panel.first, panel.second) and 1.inv())
fun resolveChoice(panel: Pair<Int, Int>, choice: String): Pair<Int, Int> {
if (choice == MATCH_SCREEN) return screenSize(panel)
if (choice == MATCH_SCREEN || choice == FOLLOW_SCREEN) return screenSize(panel)
presetHeight(choice)?.let { return resolve(panel, it, SessionPrefs.SHAPE_AUTO) }
// Fixed sizes (custom, or a preset saved before presets were heights) keep their exact size,
// including legacy ones past the custom dialog's limit.
@@ -34,13 +39,19 @@ object SessionDisplay {
else resolve(panel, 720, SessionPrefs.SHAPE_AUTO)
}
/** The height presets the panel is tall enough for, then the panel itself, with no duplicate sizes. */
/** The height presets the panel is tall enough for, then the panel itself, with no duplicate
* sizes, then Follow screen. */
fun resolutionOptions(panel: Pair<Int, Int>): List<String> {
val panelHeight = minOf(panel.first, panel.second)
return PRESET_HEIGHTS.filter { it <= panelHeight }.map { "${it}p" }
.filter { resolveChoice(panel, it) != screenSize(panel) } + MATCH_SCREEN
.filter { resolveChoice(panel, it) != screenSize(panel) } + MATCH_SCREEN + FOLLOW_SCREEN
}
/** A Follow screen session's display for a window of this size: its own shape, square or
* portrait included, with even sides; null when it is too small to follow (PiP). */
fun followSize(width: Int, height: Int): Pair<Int, Int>? =
if (minOf(width, height) < FOLLOW_MIN_SIDE) null else (width and 1.inv()) to (height and 1.inv())
fun panelSize(context: Context): Pair<Int, Int> {
val manager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
@Suppress("DEPRECATION")
@@ -5,6 +5,7 @@ import android.os.Environment
import android.util.Log
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.runtime.DesktopCatalog
import com.droiddeck.launcher.runtime.LegacyNames
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.runtime.ProotFastPath
@@ -16,6 +17,8 @@ object SessionFiles {
private const val NO_PAD_SWITCH = "Download/droiddeck-no-pad"
/** Where the DirectAudio driver lives inside the runtime; the wrappers get it as BL_DIRECTAUDIO. */
const val DIRECTAUDIO_DIR = "usr/local/lib/directaudio"
/** The driver sets staged under it: classic Wine 11 (Valve, GE) and the system-thread interface (Proton-CachyOS). */
val DIRECTAUDIO_SETS = arrayOf("linux-wine11", "linux-wine11-systhread")
/**
* The libraries and scripts the session runs, refreshed from the apk at every launch.
@@ -41,6 +44,8 @@ object SessionFiles {
"libblfastpath.so" to "usr/local/lib/libblfastpath.so",
"libssbs.so" to "usr/local/lib/libssbs.so",
"usr/local/bin/droiddeck-session" to "usr/local/bin/droiddeck-session",
"usr/local/bin/droiddeck-fonts" to "usr/local/bin/droiddeck-fonts",
"usr/local/bin/droiddeck-agent" to "usr/local/bin/droiddeck-agent",
"usr/local/bin/steam-compatibility" to "usr/local/bin/steam-compatibility",
"usr/local/bin/droiddeck-clipboard" to "usr/local/bin/droiddeck-clipboard",
"usr/local/bin/droiddeck-steam-install" to "usr/local/bin/droiddeck-steam-install",
@@ -56,6 +61,10 @@ object SessionFiles {
"usr/local/bin/droiddeck-desktop-bookmarks" to "usr/local/bin/droiddeck-desktop-bookmarks",
"usr/local/bin/droiddeck-steam-shim" to "usr/local/bin/droiddeck-steam-shim",
"usr/local/bin/droiddeck-steam-shortcuts" to "usr/local/bin/droiddeck-steam-shortcuts",
// Asks the app for an Epic game's sign-in code at launch, from the compat tool (StoreLaunchRequests).
"usr/local/bin/droiddeck-store-launch" to "usr/local/bin/droiddeck-store-launch",
// A game's web links to Android's browser (Wine's winebrowser starts it).
"usr/local/bin/droiddeck-open-url" to "usr/local/bin/droiddeck-open-url",
"usr/local/bin/droiddeck-steam-games" to "usr/local/bin/droiddeck-steam-games",
"usr/local/bin/droiddeck-pad-defaults" to "usr/local/bin/droiddeck-pad-defaults",
// Flatpak: the bwrap stand-in, the store's helper and setup, and the front end's launcher.
@@ -82,16 +91,15 @@ object SessionFiles {
"usr/bin/steamos-polkit-helpers/jupiter-biosupdate" to "usr/bin/steamos-polkit-helpers/jupiter-biosupdate",
"usr/bin/steamos-polkit-helpers/jupiter-dock-updater" to "usr/bin/steamos-polkit-helpers/jupiter-dock-updater",
)
// The desktop's launcher and labwc defaults, only where the desktop package is installed:
// The desktop's launchers, only where the desktop package is installed:
// staging them into a runtime without it would make the desktop look present when it is not.
val desktop = arrayOf(
"usr/local/bin/droiddeck-desktop" to "usr/local/bin/droiddeck-desktop",
// Games and emulators from the menu, full screen in a gamescope of their own.
"usr/local/bin/droiddeck-gpu" to "usr/local/bin/droiddeck-gpu",
"usr/local/bin/droiddeck-desktop-gpu" to "usr/local/bin/droiddeck-desktop-gpu",
"etc/xdg/labwc/autostart" to "etc/xdg/labwc/autostart",
"etc/xdg/labwc/rc.xml" to "etc/xdg/labwc/rc.xml",
"etc/xdg/lxqt/panel.conf" to "etc/xdg/lxqt/panel.conf",
// KWin at the app's surface size, for Plasma's session (it starts KWin through this name).
"usr/local/bin/kwin_wayland_wrapper" to "usr/local/bin/kwin_wayland_wrapper",
"usr/lib/firefox/defaults/pref/droiddeck.js" to "usr/lib/firefox/defaults/pref/droiddeck.js",
)
// The patched gamescope (tools/gamescope): the runtime's own version rebuilt with the ARM64
@@ -109,13 +117,9 @@ object SessionFiles {
"usr/local/lib/mangoapp/libtracefs.so.1",
).map { it to it }
// What the Windows components installer reads .msi packages with (tools/msitools).
val msitools = listOf("msiinfo", "cabextract", "libmsi-1.0.so.0", "libgsf-1.so.114", "libgcab-1.0.so.0")
val msitools = listOf("msiinfo", "cabextract", "7z", "7z.so", "libmsi-1.0.so.0", "libgsf-1.so.114", "libgcab-1.0.so.0")
.map { "usr/local/lib/droiddeck-msitools/$it" } +
listOf("NOTICE", "GPL-2", "GPL-3", "LGPL-2.1").map { "usr/local/share/licenses/droiddeck-msitools/$it" }
// The patched wlroots (tools/wlroots) the desktop loads for its vulkan / gles2 renderers.
val wlroots = if (File(root, "usr/bin/labwc").isFile) {
arrayOf("usr/local/lib/droiddeck-wlroots/libwlroots-0.20.so" to "usr/local/lib/droiddeck-wlroots/libwlroots-0.20.so")
} else emptyArray()
listOf("NOTICE", "GPL-2", "GPL-3", "LGPL-2.1", "7zip-License", "7zip-unRarLicense").map { "usr/local/share/licenses/droiddeck-msitools/$it" }
val fexPreloads = listOf("x86_64", "i386").flatMap { arch ->
listOf("libblsession.so", "libfakeinput.so").map { "$arch/$it" to "usr/local/lib/droiddeck-fex/$arch/$it" }
} + listOf("libfaultreport.so", "libthunkaudit.so", "libvulkan-thunk.so").map { "x86_64/$it" to "usr/local/lib/droiddeck-fex/x86_64/$it" }
@@ -123,11 +127,11 @@ object SessionFiles {
"usr/local/bin/gamescope" to "usr/local/bin/gamescope",
"usr/local/lib/droiddeck/uruntime" to "usr/local/lib/droiddeck/uruntime",
"usr/local/share/licenses/uruntime/LICENSE" to "usr/local/share/licenses/uruntime/LICENSE",
) + wlroots + mangoapp + msitools.map { it to it } + fexPreloads).filter { (asset, _) ->
) + mangoapp + msitools.map { it to it } + fexPreloads).filter { (asset, _) ->
val dir = asset.substringBeforeLast('/')
runCatching { context.assets.list("linuxfs/$dir")?.contains(asset.substringAfterLast('/')) == true }.getOrDefault(false)
}
val all = (if (File(root, "usr/bin/labwc").isFile) files + desktop else files) + optional
val all = (if (DesktopCatalog.desktopInstalled(root)) files + desktop else files) + optional
for ((asset, relative) in all) {
val target = File(root, relative)
val staged = File(target.parentFile, target.name + ".staged")
@@ -150,30 +154,37 @@ object SessionFiles {
ComponentsManager.migrateLaunchDir(context)
EsyncPacks.stageBundled(context, root)
// The DirectAudio driver for games under Proton: the glibc build of winedirectaudio, which
// the Proton wrappers add to WINEDLLPATH when the session asks for it (BL_DIRECTAUDIO).
// Staged like the scripts, so a driver fix reaches an installed runtime without re-hosting.
// the Proton wrappers add to WINEDLLPATH when the session asks for it (BL_DIRECTAUDIO). Two
// sets, one per Wine audio interface - the wrapper picks by what the Proton's own winepulse.so
// imports (see steam-compatibility), because both Wines call themselves 11.0 and the wrong set
// plays silence. Staged like the scripts, so a driver fix reaches an installed runtime without
// re-hosting. The sets come from the pinned DirectAudio release (tools/directaudio/release.env).
val directAudio = arrayOf(
"aarch64-unix/winedirectaudio.so",
"aarch64-windows/winedirectaudio.drv",
"i386-windows/winedirectaudio.drv",
"version.txt",
)
for (relative in directAudio) {
val target = File(root, "$DIRECTAUDIO_DIR/lib/wine/$relative")
for (set in DIRECTAUDIO_SETS) for (relative in directAudio) {
val target = File(root, "$DIRECTAUDIO_DIR/$set/lib/wine/$relative")
val staged = File(target.parentFile, target.name + ".staged")
var installed = false
try {
target.parentFile?.mkdirs()
context.assets.open("directaudio/linux-wine11/$relative").use { input ->
context.assets.open("directaudio/$set/$relative").use { input ->
staged.outputStream().use { output -> FileUtils.copy(input, output) }
}
installed = staged.setReadable(true, false) && staged.renameTo(target)
} catch (e: Exception) {
Log.w(TAG, "could not stage DirectAudio $relative", e)
Log.w(TAG, "could not stage DirectAudio $set/$relative", e)
} finally {
if (!installed) staged.delete()
}
if (!installed) Log.e(TAG, "DirectAudio $relative NOT staged")
if (!installed) Log.e(TAG, "DirectAudio $set/$relative NOT staged")
}
// A runtime from before the sets had names kept the driver at the directory's root; it would
// never be picked again, so it goes.
for (old in arrayOf("aarch64-unix", "aarch64-windows", "i386-windows")) File(root, "$DIRECTAUDIO_DIR/lib/wine/$old").deleteRecursively()
// What every process in the session preloads. LD_PRELOAD in the environment would not
// survive: the Steam client rebuilds it for each process it starts and appends its own
// overlay entry without a separator, which silently drops whatever was there.
@@ -350,25 +361,9 @@ object SessionFiles {
).joinToString(newline)
/**
* Where the session writes its log. Downloads is the point - a failed run is handed over as a
* folder rather than dug out of app-private storage - but the session script redirects its own
* output there with `exec`, and a redirection a non-interactive shell cannot open ends that
* shell. So a public directory is used only once it is proven writable; otherwise the app's
* own files directory, which is bound into the session anyway, stands in.
* Where the session writes its log: app-private `files/logs`, bound into the session at the same
* path, so the session script's own redirection always opens. A folder leaves the device only
* through Share logs, which scrubs every file again on the way into the zip.
*/
fun logDirectory(context: Context): File {
val public = LinuxRuntime.debugLogDir()
if (public.isDirectory || public.mkdirs()) {
val probe = File(public, ".writable")
try {
if (probe.createNewFile() || probe.isFile) {
probe.delete()
return public
}
} catch (ignored: Exception) {
}
}
Log.w(TAG, "$public is not writable (storage permission?); logging to files/logs")
return File(context.filesDir, "logs").apply { mkdirs() }
}
fun logDirectory(context: Context): File = LinuxRuntime.logDir(context).apply { mkdirs() }
}
@@ -18,9 +18,11 @@ import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
/**
* Zips the most recent session's log folder and hands it to Android's share sheet. Works where
* the folder could not be written to Downloads (no storage permission, or a device that refuses
* it) and landed in the app's private files instead, which a user cannot otherwise reach.
* Share logs: one zip, made when asked, of a session folder (the newest by default) with the last
* seven days of the stores' log and the one-off command logs beside it, handed to Android's share
* sheet. Logs live in app-private `files/logs`; this is how they leave the device. Every text file
* goes through [LogRedactor.redactForShare] on the way in, so even a file written before a rule
* existed is clean in the zip. The zip sits in `cache/share/` until the next share or app start.
*/
object SessionLogShare {
private val main = Handler(Looper.getMainLooper())
@@ -62,7 +64,7 @@ object SessionLogShare {
val files = folder.walkTopDown().filter { it.isFile }
.filterNot { it.parentFile?.name == "steam" && it.length() > STEAM_LOG_MAX_BYTES }.toList()
if (files.isEmpty()) return null
val out = File(context.cacheDir, "shared-logs").apply { deleteRecursively(); mkdirs() }
val out = shareDir(context).apply { deleteRecursively(); mkdirs() }
val zip = File(out, "DroidDeck-${folder.name}.zip")
// Scrubbed on the way into the zip: a session shared while it runs has not had its end-of-
// session pass yet, and the redactor changes nothing in a line that is already clean.
@@ -71,9 +73,11 @@ object SessionLogShare {
// since, goes in as it is; anything else (added or changed since, hidden, or any file of a
// session still running) is scrubbed again. Scrubbing tens of MB of the client's logs on
// every share kept the share sheet from appearing for ten seconds or more.
val scrubbed = SessionArtifacts.scrubbedFiles(folder)
val live = liveSteamLogs(context, folder)
val total = (files + live).sumOf { it.length() }.coerceAtLeast(1L)
val logs = LinuxRuntime.logDir(context)
val stores = recent(File(logs, STORES_DIR))
val tools = recent(File(logs, SessionPaths.TOOLS_DIR))
val total = (files + live + stores + tools).sumOf { it.length() }.coerceAtLeast(1L)
var done = 0L
var shown = -1
fun advance(f: File) {
@@ -82,17 +86,20 @@ object SessionLogShare {
if (percent != shown) { shown = percent; onProgress?.invoke(percent / 100f) }
}
ZipOutputStream(zip.outputStream().buffered()).use { z ->
files.forEach { f -> addEntry(z, folder.name + "/" + f.relativeTo(folder).path, f, f.relativeTo(folder).path in scrubbed); advance(f) }
live.forEach { f -> addEntry(z, folder.name + "/steam/" + f.name, f, false); advance(f) }
files.forEach { f -> addEntry(z, folder.name + "/" + f.relativeTo(folder).path, f); advance(f) }
live.forEach { f -> addEntry(z, folder.name + "/steam/" + f.name, f); advance(f) }
stores.forEach { f -> addEntry(z, "$STORES_DIR/" + f.name, f); advance(f) }
tools.forEach { f -> addEntry(z, SessionPaths.TOOLS_DIR + "/" + f.name, f); advance(f) }
}
return zip
}
private fun addEntry(z: ZipOutputStream, name: String, f: File, scrubbed: Boolean) {
private fun addEntry(z: ZipOutputStream, name: String, f: File) {
z.putNextEntry(ZipEntry(name))
if (!scrubbed && LogRedactor.isText(f)) {
// Every text file, every time: the folder's own scrub may predate a rule.
if (LogRedactor.isText(f)) {
val w = z.bufferedWriter()
LogRedactor.scrubTo(f, w)
LogRedactor.scrubForShare(f, w)
w.flush()
} else {
f.inputStream().use { it.copyTo(z) }
@@ -116,6 +123,25 @@ object SessionLogShare {
/** The client's content and bootstrap logs grow large over months and say nothing about a session. */
private const val STEAM_LOG_MAX_BYTES = 8L * 1024 * 1024
/** The stores' engine log, one file a day, under files/logs. */
const val STORES_DIR = "stores"
private const val RECENT_DAYS = 7
private fun shareDir(context: Context): File = File(context.cacheDir, "share")
/** The files in [dir] changed in the last [RECENT_DAYS] days. */
private fun recent(dir: File): List<File> {
val since = System.currentTimeMillis() - RECENT_DAYS * 86_400_000L
return dir.listFiles { f -> f.isFile && f.lastModified() >= since }?.sortedBy { it.name }.orEmpty()
}
/** Deletes the last share's zip; at app start, so a zip never outlives the share it was made for. */
fun clear(context: Context) {
shareDir(context).deleteRecursively()
// Where builds before this one put it.
File(context.cacheDir, "shared-logs").deleteRecursively()
}
fun shareIntent(context: Context, zip: File): Intent {
val uri = FileProvider.getUriForFile(context, context.packageName + ".logs", zip)
val send = Intent(Intent.ACTION_SEND)
@@ -8,19 +8,20 @@ import java.util.Date
import java.util.Locale
/**
* One folder per session under `Download/DroidDeck/`, holding everything that session recorded,
* One folder per session under app-private `files/logs/`, holding everything that session recorded,
* named for the day, that day's session number and what was run, so the one a report is about can
* be picked out without opening any:
*
* ```
* Download/DroidDeck/2026-09-30-03-steam/
* files/logs/2026-09-30-03-steam/
* device.txt what this device is, and every setting the session ran with
* session.log the guest session: proot, gamescope, the client's stdout
* wayland.log the app's compositor
* steam.log the Steam client's own log, scrubbed (Steam mode)
* steam/ the rest of the client's logs, scrubbed (Steam mode)
* desktop.log labwc, the panel and the programs on it (desktop mode)
* Download/DroidDeck/tools/ one-off commands: Flatpak installs, AppImage imports
* desktop.log KWin, Plasma and the programs on it (desktop mode)
* files/logs/tools/ one-off commands: Flatpak installs, AppImage imports
* files/logs/stores/ the stores' engine log, one file a day
* ```
*
* Folders from before this naming (`session-20260930-180642`) are still recognised, shared and
@@ -137,8 +138,7 @@ object SessionPaths {
/** Every session folder in the places logs are written, oldest first. */
fun sessionFolders(context: Context): List<File> =
listOf(com.droiddeck.launcher.runtime.LinuxRuntime.debugLogDir(), File(context.filesDir, "logs"))
.flatMap { parent -> parent.listFiles { f -> isSessionFolder(f) }?.toList() ?: emptyList() }
(com.droiddeck.launcher.runtime.LinuxRuntime.logDir(context).listFiles { f -> isSessionFolder(f) }?.toList() ?: emptyList())
.sortedWith(chronological)
internal fun slug(text: String): String =
@@ -53,6 +53,52 @@ object SessionPrefs {
prefs(context).edit().putBoolean("storeEnabled", on).apply()
}
/** The Stores section (GOG, Epic Games, Amazon Games): its rail item. Off by default. */
fun gameStoresEnabled(context: Context): Boolean = prefs(context).getBoolean("gameStoresEnabled", false)
fun setGameStoresEnabled(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("gameStoresEnabled", on).apply()
}
/** The store download engine's speed tier (stores/download/StoreDownloadTier ids); Fast unless chosen otherwise. */
fun gameStoresSpeedTier(context: Context): String = prefs(context).getString("gameStoresSpeedTier", "fast") ?: "fast"
fun setGameStoresSpeedTier(context: Context, tier: String) {
prefs(context).edit().putString("gameStoresSpeedTier", tier).apply()
}
/** Which tab a signed-in store opens on when its chip is picked: [STORES_OPEN_LIBRARY] or [STORES_OPEN_STORE]. */
fun storesOpenTab(context: Context): String =
prefs(context).getString("storesOpenTab", STORES_OPEN_LIBRARY)?.takeIf { it == STORES_OPEN_STORE } ?: STORES_OPEN_LIBRARY
fun setStoresOpenTab(context: Context, tab: String) {
prefs(context).edit().putString("storesOpenTab", if (tab == STORES_OPEN_STORE) STORES_OPEN_STORE else STORES_OPEN_LIBRARY).apply()
}
/** Mature titles (the stores' own 17+/18 ratings or adult tags) in the storefront's shelves and search; off by default. Owned games always show. */
fun storesShowMature(context: Context): Boolean = prefs(context).getBoolean("storesShowMature", false)
fun setStoresShowMature(context: Context, show: Boolean) {
prefs(context).edit().putBoolean("storesShowMature", show).apply()
}
const val STORES_OPEN_LIBRARY = "library"
const val STORES_OPEN_STORE = "store"
/** The install root picked last time the Stores asked where (a StoreInstallRoot target's path); only the dialog's default, never a silent choice. */
fun storesInstallTarget(context: Context): String = prefs(context).getString("storesInstallTarget", "") ?: ""
fun setStoresInstallTarget(context: Context, path: String) {
prefs(context).edit().putString("storesInstallTarget", path).apply()
}
/** How many store downloads run at once, 1..3; one by default (a single download already fills the link). */
fun gameStoresParallel(context: Context): Int = prefs(context).getInt("gameStoresParallel", 1).coerceIn(1, 3)
fun setGameStoresParallel(context: Context, count: Int) {
prefs(context).edit().putInt("gameStoresParallel", count.coerceIn(1, 3)).apply()
}
/**
* The session's performance HUD (the fps box). A Deck-mode Steam session with the performance
* overlay has Steam's own (mangoapp, from the QAM), so there the HUD is off unless turned on
@@ -160,15 +206,9 @@ object SessionPrefs {
prefs(context).edit().putStringSet("gpuAutoInstalled", ids.toSet()).apply()
}
/**
* The Steam client's own sound through the DirectAudio relay instead of the classic AAudio
* sink. On unless the user picked Classic.
*/
fun clientDirectAudio(context: Context): Boolean = prefs(context).getBoolean("clientDirectAudio", true)
fun setClientDirectAudio(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("clientDirectAudio", on).apply()
}
// The Steam client's own sound has one route now: DirectAudio's engine inside the PulseAudio
// daemon (PulseAudioComponent), one step from Android. The old "clientDirectAudio" boolean
// (which meant the relay route, one hop more) is no longer read.
fun stretch16x9(context: Context): Boolean = prefs(context).getBoolean("stretch16x9", false)
@@ -453,7 +493,7 @@ object SessionPrefs {
}
/**
* Whether a session writes its folder under Download/DroidDeck. Off, the same logs are kept in
* Whether a session keeps its folder under files/logs. Off, the same logs are kept in
* the app's cache for the session's lifetime (the scripts need somewhere to write) and thrown
* away at the end, so nothing accumulates in Downloads.
*/
@@ -493,7 +533,8 @@ object SessionPrefs {
fun resolutionChoice(context: Context, mode: String, panel: Pair<Int, Int>): String {
val saved = prefs(context)
saved.getString("displayResolution.$mode", null)?.let { value ->
if (value == SessionDisplay.MATCH_SCREEN || SessionDisplay.presetHeight(value) != null) return value
if (value == SessionDisplay.MATCH_SCREEN || value == SessionDisplay.FOLLOW_SCREEN ||
SessionDisplay.presetHeight(value) != null) return value
parseResolution(value)?.let { return "${it.first}x${it.second}" }
}
if (!resolutionChosen(context, mode) && !saved.contains("shape")) return SessionDisplay.DEFAULT_RESOLUTION
@@ -503,7 +544,8 @@ object SessionPrefs {
}
fun setResolutionChoice(context: Context, mode: String, choice: String) {
val value = if (choice == SessionDisplay.MATCH_SCREEN || SessionDisplay.presetHeight(choice) != null) choice else {
val value = if (choice == SessionDisplay.MATCH_SCREEN || choice == SessionDisplay.FOLLOW_SCREEN ||
SessionDisplay.presetHeight(choice) != null) choice else {
val size = requireNotNull(parseResolution(choice)) { "Invalid resolution" }
"${size.first}x${size.second}"
}
@@ -638,21 +680,6 @@ object SessionPrefs {
return Pair(w and 1.inv(), h and 1.inv())
}
/**
* What the desktop shell composites with: vulkan (the default) or gles2 on the GPU, through the
* app's patched wlroots (tools/wlroots) - the Adreno stand-in is not a DRM device and stock
* wlroots cannot allocate on it - or pixman in software. A GPU renderer a device cannot start
* falls back to pixman by itself (droiddeck-desktop). `Download/droiddeck-wlr-renderer` still
* overrides it.
*/
fun desktopRenderer(context: Context): String = prefs(context).getString("desktopRenderer", "vulkan") ?: "vulkan"
fun setDesktopRenderer(context: Context, renderer: String) {
prefs(context).edit().putString("desktopRenderer", renderer).apply()
// A choice made again is a retry: forget that a renderer failed to start here before.
java.io.File(com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(context), "root/.droiddeck-renderer-failed").delete()
}
/**
* HDR10 output for MODE_STEAM / MODE_DESKTOP. Off by default. Honoured only when the panel
* lists HDR10 (HdrSupport), and decided when the compositor starts, which is once per app
@@ -285,11 +285,15 @@ class SessionService : Service() {
}
private fun extraEnv(): List<String> {
val file = File(Environment.getExternalStorageDirectory(), ENV_SWITCH).takeIf { it.isFile } ?: return emptyList()
if (File(Environment.getExternalStorageDirectory(), LEGACY_ENV_SWITCH).isFile) {
Log.w(TAG, "ignoring $LEGACY_ENV_SWITCH: any app with storage access can write there; " +
"the file now lives at ${envSwitchFile(this)?.path ?: "(not available before Android 11)"}")
}
val file = envSwitchFile(this)?.takeIf { it.isFile } ?: return emptyList()
val lines = FileUtils.readString(file)?.lines().orEmpty()
.map { it.trim() }
.filter { it.isNotEmpty() && !it.startsWith("#") && it.contains('=') && !it.startsWith("=") }
if (lines.isNotEmpty()) Log.i(TAG, "extra environment from $ENV_SWITCH: $lines")
if (lines.isNotEmpty()) Log.i(TAG, "extra environment keys from ${file.path}: ${lines.map { it.substringBefore('=') }}")
return lines
}
@@ -320,6 +324,9 @@ class SessionService : Service() {
val runtimeDir = File(filesDir, ".wayland-rt").apply { mkdirs() }
killStragglers()
SessionFiles.stage(this, root)
com.droiddeck.launcher.agent.AgentGuest.reset(this)
// The compat tool asks for an Epic game's sign-in code at every launch, the client's Play button included.
com.droiddeck.launcher.stores.StoreLaunchRequests.start(this)
val sessionDir = openSessionFolder()
val sessionLog = File(sessionDir, "session.log")
@@ -347,6 +354,9 @@ class SessionService : Service() {
guest.add("BL_WIDTH=" + size.first)
guest.add("BL_HEIGHT=" + size.second)
// Follow screen: the output is resized under gamescope when a foldable opens or closes,
// and the session script asks gamescope to resize Steam's X screen along with it.
if (SessionState.followScreen) guest.add("BL_FOLLOW_OUTPUT=1")
if (SessionState.hdr) {
// The activity opened the compositor's HDR gate: gamescope offers HDR to its clients
// and DXVK takes the HDR10 swapchain when a game asks for one.
@@ -364,16 +374,6 @@ class SessionService : Service() {
SessionState.deckPad = false
deckBinds = emptyList()
if (controllersOn) addControllerEnvironment(guest, fakeInputDir, sessionDir)
// The desktop is wlroots (labwc). Stock wlroots allocates through gbm on a real DRM render
// node, and ours is a KGSL stand-in - labwc died at "unable to create allocator" - so the
// default is pixman (software, shm), where a Vulkan program cannot draw at all; those run
// through droiddeck-gpu instead. vulkan / gles2 use the app's patched wlroots and fall back
// to pixman by themselves. droiddeck-wlr-renderer in Downloads overrides the choice.
if (SessionState.mode == MODE_DESKTOP) {
val override = File(Environment.getExternalStorageDirectory(), "Download/droiddeck-wlr-renderer")
.takeIf { it.isFile }?.let { FileUtils.readString(it)?.trim() }
guest.add("BL_WLR_RENDERER=" + (override?.takeIf { it.isNotEmpty() } ?: SessionPrefs.desktopRenderer(this)))
}
// The user's own games, for the runtime's shortcuts writer to put in the client's library
// before the client starts (see frontend/AddedGames and droiddeck-steam-shortcuts).
if (steamHere) {
@@ -389,9 +389,6 @@ class SessionService : Service() {
// The second library's name, for droiddeck-steam-library; the bind itself is made below.
GameStorage.effective(this)?.let { guest.add("BL_LIBRARY_LABEL=" + it.label.replace('"', ' ')) }
if (SessionState.mode == MODE_STEAM && SessionState.steamUi == "desktop") guest.add("BL_STEAM_UI=desktop")
// The desktop asked for with Steam in it (the front end's "Steam Desktop UI", Big Picture's
// "Switch to Desktop"): the desktop's autostart opens the client's desktop UI there.
if (SessionState.mode == MODE_DESKTOP && SessionState.steamUi == "desktop") guest.add("BL_DESKTOP_STEAM=1")
val shellGuest = ArrayList(guest).apply {
add("SHELL=/bin/bash")
add("TERM=xterm-256color")
@@ -407,7 +404,7 @@ class SessionService : Service() {
}
// A program under gamescope: the script's run mode takes the path (an AppImage, a script
// or a binary inside the runtime). This is how an emulator gets the GPU - the desktop's
// labwc composites in software and offers no dma-buf, so a Vulkan swapchain cannot exist
// KWin composites in software and offers no dma-buf, so a Vulkan swapchain cannot exist
// there (RPCS3 died with VK_ERROR_SURFACE_LOST); gamescope's Xwayland is the path the
// Steam games already render through.
if (SessionState.mode == MODE_RUN) {
@@ -419,7 +416,7 @@ class SessionService : Service() {
}
guest.add(program)
guest.addAll(SessionState.programArgs)
Log.i(TAG, "run: $program ${SessionState.programArgs.joinToString(" ")} under gamescope")
Log.i(TAG, "run: $program (${SessionState.programArgs.size} arguments) under gamescope")
}
// Android has no /dev/shm; the cache stands in for it and, unlike the real thing, keeps
@@ -649,11 +646,14 @@ class SessionService : Service() {
// GAMESCOPE_FORCE_VULKAN_REALTIME); off unless the user turns it on.
guest.add("BL_GAMESCOPE_REALTIME=" + (if (SessionPrefs.gamescopeRealtime(this)) "1" else "0"))
guest.add("BANNER_AUDIO_DIRECT_DECAY=0")
// Anything else, for a device that cannot be reached with a debugger: Downloads/droiddeck-env
// holds KEY=VALUE lines that go into the session's environment as written, after ours, so a
// line here wins. Zink and Turnip tunables (ZINK_DESCRIPTORS=lazy, MESA_*), gamescope's,
// the client's - whatever the experiment needs, without a build per attempt.
// Anything else, for a device that cannot be reached with a debugger: droiddeck-env in the
// app's own external files (envSwitchFile) holds KEY=VALUE lines that go into the session's
// environment as written, after ours, so a line here wins. Zink and Turnip tunables
// (ZINK_DESCRIPTORS=lazy, MESA_*), gamescope's, the client's - whatever the experiment
// needs, without a build per attempt.
extraEnv().forEach { guest.add(it) }
// The agent bridge's experiment lines (AgentEnv), after the user's so an agent's win.
com.droiddeck.launcher.agent.AgentEnv.beginSession(this).forEach { guest.add(it) }
// Core masks, Bannerlator's two (cfca3912). The client's is sent whenever the override is
// on, even naming every core: it exists to undo the pin Steam applies to its own interface
// renderer, and the scheduler's default is exactly what that pin takes away. A game's is
@@ -686,16 +686,13 @@ class SessionService : Service() {
val audioLog = File(sessionDir, "audio.log")
val pulse = PulseAudioComponent(this, micFifo?.absolutePath)
// With DirectAudio on, the client's own sound goes through the relay too: the daemon
// fills the relay's ring and the relay, outside proot, drives the device.
// The client's own sound: the classic AAudio sink unless the user chose the relay.
val clientDirectAudio = SessionState.mode == MODE_STEAM && SessionPrefs.clientDirectAudio(this)
if (clientDirectAudio) pulse.setRelaySocket(relaySocket.absolutePath)
// The client's own sound: DirectAudio's engine inside the daemon, one step from Android (the
// daemon runs on the Android side). The relay below is for games and the microphone only.
pulse.setLogFile(audioLog)
pulse.attach(this)
guest.add("PULSE_SERVER=unix:" + pulse.socket().absolutePath)
components.add(pulse)
if (wantsDirectAudio || wantsMic || clientDirectAudio) {
if (wantsDirectAudio || wantsMic) {
// After the daemon in the list, so it can wait for the pipe the daemon makes.
val relay = DirectAudioRelayComponent(relaySocket, micFifo)
relay.setLogFile(audioLog)
@@ -709,7 +706,7 @@ class SessionService : Service() {
guest.add("BL_DIRECTAUDIO=/" + SessionFiles.DIRECTAUDIO_DIR)
guest.add("BANNER_AUDIO_DIRECT_RELAY=" + relaySocket.absolutePath)
}
Log.i(TAG, "audio: client " + (if (clientDirectAudio) "DirectAudio" else "classic AAudio sink") + (if (wantsDirectAudio) " + DirectAudio for games" else "")
Log.i(TAG, "audio: client DirectAudio (in-process sink)" + (if (wantsDirectAudio) " + DirectAudio for games" else "")
+ (if (wantsMic) " + microphone" else "") +
(if (SessionPrefs.micEnabled(this) && !wantsMic) " (microphone wanted but RECORD_AUDIO not granted)" else ""))
return pulse
@@ -889,6 +886,15 @@ class SessionService : Service() {
Log.w(TAG, "added games: ${root.host} is not a readable folder this session")
}
}
// Store games installed on a card: each card's Games root at its own fixed place, so the
// shortcuts point somewhere whether or not the card is also the Steam library.
for ((host, guest) in com.droiddeck.launcher.stores.StoreInstallRoot.externalRoots(this)) {
if (host.isDirectory && host.canRead()) {
File(LinuxRuntime.rootDir(this), guest.removePrefix("/")).mkdirs()
binds.add(host.path + ":" + guest)
Log.i(TAG, "store games: $host -> $guest")
}
}
// Folders of added scripts outside internal storage, where their links point.
binds.addAll(com.droiddeck.launcher.runtime.UserApps.binds(this))
val roms = SessionPrefs.romsDir(this).takeIf { it.isNotEmpty() }?.let { File(it) }
@@ -1399,6 +1405,13 @@ class SessionService : Service() {
SessionState.pipActive = false
pipTask = false
SessionState.guestPid = -1
com.droiddeck.launcher.agent.AgentGuest.stop()
com.droiddeck.launcher.stores.StoreLaunchRequests.stop()
// Cloud saves of a store game played this session, if the compat tool's exit request did
// not upload them (a session closed from the drawer never reaches it).
Thread({ com.droiddeck.launcher.stores.CloudSaves.uploadAllDirty(applicationContext, "session-end", running = false) }, "cloud-session-end").start()
runCatching { com.droiddeck.launcher.agent.AgentEnv.endSession(this) }
.onFailure { Log.w(TAG, "clearing the agent's session environment", it) }
releaseLocks()
if (status == 0) {
SessionEvents.transition(SessionPhase.IDLE, "session.stopped", mapOf("status" to status))
@@ -1700,8 +1713,25 @@ class SessionService : Service() {
private const val TRACER_NICE = -6
/** Downloads file whose contents become TU_DEBUG inside the session, e.g. "sysmem". */
private const val TU_DEBUG_SWITCH = "Download/droiddeck-tu-debug"
/** Downloads file of KEY=VALUE lines added to the session environment verbatim. */
private const val ENV_SWITCH = "Download/droiddeck-env"
/**
* Where droiddeck-env (KEY=VALUE lines added to the session environment verbatim) used to
* be. Ignored now: a line there reaches the Steam client's environment as written
* (LD_PRELOAD, VK_ICD_FILENAMES, PATH...), and any app with storage access can write to
* Download - code of its choosing inside our sandbox, next to the client's saved login.
*/
private const val LEGACY_ENV_SWITCH = "Download/droiddeck-env"
private const val ENV_SWITCH_NAME = "droiddeck-env"
/**
* The session's extra-environment file: Android/data/<package>/files/droiddeck-env. Only
* this app, adb and the user (through the app's own file manager) can write there - from
* Android 11 on. Before that other apps with storage access can reach Android/data too, so
* there is no such file at all.
*/
fun envSwitchFile(context: Context): File? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
context.getExternalFilesDir(null)?.let { File(it, ENV_SWITCH_NAME) }
else null
private const val CHANNEL_ID = "session"
private const val NOTIFICATION_ID = 1001
const val ACTION_STOP = "com.droiddeck.launcher.STOP_SESSION"
@@ -80,6 +80,9 @@ object SessionState {
/** Told (on the thread that changed it) whenever [deckPad] changes. */
@Volatile var deckPadListener: (() -> Unit)? = null
/** The session screen's virtual pad, for the agent bridge's pad input (AgentInput). */
@Volatile var padBridge: com.droiddeck.launcher.input.PadBridge? = null
/** What the second screen shows, and on which display: kept for the session, so a screen
* turned off (sleep, a closed lid) or an activity recreated brings it back as it was. */
@Volatile var secondScreenMode = com.droiddeck.launcher.input.SecondScreenMode.NONE
@@ -93,6 +96,10 @@ object SessionState {
@Volatile
var outputSize: Pair<Int, Int> = Pair(1920, 1080)
/** The session's resolution is "Follow screen": outputSize tracks the window while it runs. */
@Volatile
var followScreen = false
@Volatile
var refreshHz: Float = 60f
@@ -47,16 +47,35 @@ object WinComponents {
// at the same path, so the native file at that path is the one the registration loads.
private val FILE_STEPS = setOf("download_archive", "archive_extract", "copy_dll", "copy_file", "override_dll", "register_dll")
private val INSTALLER_STEPS = setOf("install_exe", "install_msi")
/**
* Steps done here without any installer running: a Windows version or registry value for the
* prefix (written by droiddeck-wincomponents with the rest), files picked out of a cabinet, and
* "uninstall Wine Mono", which the mscoree override beside it already does for the game.
*/
private val OFFLINE_STEPS = setOf("set_windows", "set_register_key", "get_from_cab", "uninstall")
/**
* Installers that are their own setup program (NSIS, InnoSetup, IExpress scripts): nothing
* inside them is a package to lay out, so they wait for a way to run them.
*/
private val RUNS_OWN_SETUP = setOf(
"K-Lite", "ffdshow", "lavfilters702", "lavfilters741", "quicktime72", "dirac", "webview2", "aairruntime",
"gfw", "ie8_kb2936068", "art2kmin", "art2k7min", "vcredist6sp6", "VulkanRT", "jet40", "mdac28", "oalinst",
"dotnet20", "dotnet20sp1", "dotnet35", "dotnet35sp1",
)
private const val MSI_INSTALL = "/usr/local/bin/droiddeck-msi-install"
/** Where a downloaded .msi waits for the installer, inside the runtime so it can read it. */
/** Where a downloaded package or installer waits for the installer, inside the runtime so it can read it. */
private const val MSI_CACHE = "$STORE/.packages"
/** Catalog entries Proton already provides newer copies of, which an install would only shadow. */
private val PROTON_PROVIDES = setOf("gecko")
private val PROTON_PROVIDES = setOf("gecko", "mono")
/** The keys the catalog nests under "environment" on a few .NET entries. */
private val NESTED_KEYS = setOf("url", "file_name", "file_checksum", "file_size")
fun validId(id: String): Boolean = ID.matches(id) && ".." !in id
class Step(val action: String, val json: JSONObject) {
fun str(key: String): String = json.optString(key, "")
fun str(key: String): String = json.optString(key, "").ifEmpty {
if (key in NESTED_KEYS) json.optJSONObject("environment")?.optString(key, "").orEmpty() else ""
}
}
class Component(
@@ -66,10 +85,27 @@ object WinComponents {
enum class Support { READY, NEEDS_INSTALLER, UNSUPPORTED }
/** A step that installs a Windows Installer package, which droiddeck-msi-install does here. */
private fun isMsi(step: Step): Boolean = step.action in INSTALLER_STEPS && step.str("url").startsWith("https://") &&
/** The two halves of an install the page shows one after the other, each 0..100. */
enum class Phase { DOWNLOAD, INSTALL }
/** Progress: the component being worked on, what it is doing, the phase, 0..100 or -1 when unknown. */
class Progress(val component: String, val stage: String, val phase: Phase, val percent: Int)
/** The percent a droiddeck-msi-install progress line carries ("progress 37% placing files"), or -1. */
private val PERCENT_LINE = Regex("""^(\d{1,3})% (.*)$""")
private fun engineLine(text: String): Pair<String, Int> =
PERCENT_LINE.find(text)?.let { it.groupValues[2] to it.groupValues[1].toInt().coerceIn(0, 100) } ?: (text to -1)
/**
* A step that installs a Windows Installer package, or an installer .exe that is a wrapper
* around packages (a WiX bundle, a self-extracting cabinet or 7-Zip archive), which
* droiddeck-msi-install lays out here without running it.
*/
private fun isPackage(step: Step): Boolean = step.action in INSTALLER_STEPS && step.str("url").startsWith("https://") &&
(step.action == "install_msi" || listOf(step.str("file_name"), step.str("url").substringBefore('?'))
.any { it.endsWith(".msi", ignoreCase = true) })
.any { it.endsWith(".msi", ignoreCase = true) || it.endsWith(".exe", ignoreCase = true) })
private fun protonProvides(name: String): Boolean = name in PROTON_PROVIDES || name.startsWith("mono-")
fun fetch(): List<Component>? {
val body = Downloader.downloadString(CATALOG_URL) ?: return null
@@ -94,13 +130,14 @@ object WinComponents {
/** Whether [c] can be installed here, its bundled components included. */
fun support(c: Component, all: Map<String, Component>, depth: Int = 0): Support {
if (c.status != "ready" || depth > 8 || c.name in PROTON_PROVIDES) return Support.UNSUPPORTED
if (c.status != "ready" || depth > 8 || protonProvides(c.name)) return Support.UNSUPPORTED
val installers = c.steps.filter { it.action in INSTALLER_STEPS }
fun offline(step: Step) = step.action == "delete_dlls" || step.action in OFFLINE_STEPS || step.action in FILE_STEPS && fileStepOk(step)
val own = when {
installers.isNotEmpty() && installers.all { isMsi(it) } &&
c.steps.all { it in installers || it.action == "delete_dlls" || it.action in FILE_STEPS && fileStepOk(it) } -> Support.READY
installers.isNotEmpty() && c.name !in RUNS_OWN_SETUP && installers.all { isPackage(it) } &&
c.steps.all { it in installers || offline(it) } -> Support.READY
installers.isNotEmpty() -> Support.NEEDS_INSTALLER
c.steps.all { it.action in FILE_STEPS && fileStepOk(it) } -> Support.READY
c.steps.all { offline(it) } -> Support.READY
else -> Support.UNSUPPORTED
}
if (own != Support.READY) return own
@@ -109,9 +146,10 @@ object WinComponents {
}
private fun fileStepOk(step: Step): Boolean = when (step.action) {
// An .exe or .cab archive (DirectX's redistributable) is opened by the runtime's tools.
"download_archive", "archive_extract" -> step.str("url").let { url ->
!url.startsWith("http") || url.startsWith("https://github.com/") &&
url.substringBefore('?').let { it.endsWith(".tar.xz") || it.endsWith(".zip") }
url.substringBefore('?').lowercase().let { it.endsWith(".tar.xz") || it.endsWith(".zip") || it.endsWith(".exe") || it.endsWith(".cab") }
}
"copy_dll", "copy_file" -> ".." !in step.str("dest")
else -> true
@@ -131,7 +169,7 @@ object WinComponents {
* Installs [c] and the components it bundles that are not installed yet. [onProgress] gets a
* line for the step and 0..100 (or -1). Returns null on success, else a message.
*/
fun install(context: Context, c: Component, all: Map<String, Component>, onProgress: (String, Int) -> Unit): String? {
fun install(context: Context, c: Component, all: Map<String, Component>, onProgress: (Progress) -> Unit): String? {
if (support(c, all) != Support.READY) return "${c.name} cannot be installed here yet"
val order = ArrayList<Component>()
fun visit(x: Component, depth: Int) {
@@ -146,7 +184,7 @@ object WinComponents {
val archives = HashMap<String, File>()
return try {
for (x in order) {
onProgress(x.name, -1)
onProgress(Progress(x.name, x.name, Phase.DOWNLOAD, -1))
installOne(context, x, work, archives, onProgress)?.let { return "${x.name}: $it" }
}
null
@@ -158,18 +196,27 @@ object WinComponents {
}
}
private fun installOne(context: Context, c: Component, work: File, archives: HashMap<String, File>, onProgress: (String, Int) -> Unit): String? {
private fun installOne(context: Context, c: Component, work: File, archives: HashMap<String, File>, onProgress: (Progress) -> Unit): String? {
val root = LinuxRuntime.rootDir(context)
if (!root.isDirectory) return "The Linux runtime is not installed"
val staging = File(root, "$STORE/.${c.name}.new").apply { FileUtils.delete(this); mkdirs() }
val overrides = ArrayList<String>()
var source: File? = null
var msi: JSONObject? = null
// The registry values the steps themselves set (a Windows version, a key), beside the packages'.
val registry = ArrayList<JSONObject>()
// Archives the runtime's tools opened (installer .exe, cabinets), by the name the catalog
// calls them, and the folders they were opened into; cleaned up at the end.
val runtimeArchives = HashMap<String, File>()
val runtimeDirs = ArrayList<File>()
val cabs = HashMap<String, File>()
val temp = File(root, "$MSI_CACHE/.t-${c.name}").apply { FileUtils.delete(this) }
try {
for (step in c.steps) when (step.action) {
// A component may have several packages (PowerShell's 32- and 64-bit): each adds to the
// same folder, the first one names the component.
"install_msi", "install_exe" -> {
val (result, problem) = installMsi(context, c, step, staging, onProgress)
val (result, problem) = installPackage(context, c, step, staging, onProgress)
if (result == null) return problem
msi = msi?.apply {
val notes = optJSONArray("notes") ?: JSONArray().also { put("notes", it) }
@@ -183,19 +230,84 @@ object WinComponents {
if (!url.startsWith("http")) continue
source = archives[url] ?: run {
val name = url.substringBefore('?').substringAfterLast('/')
val file = File(work, "${archives.size}-$name")
val ok = Downloader.downloadFile(url, file, false) { f -> onProgress("${c.name}: $name", if (f < 0) -1 else Math.round(f * 100f)) }
// A zip or tar.xz is opened here; an .exe or .cab by the runtime's tools, so it
// is downloaded where the runtime can read it and kept for get_from_cab steps.
val byRuntime = name.lowercase().let { it.endsWith(".exe") || it.endsWith(".cab") }
val file = if (byRuntime) File(root, "$MSI_CACHE/${archives.size}-$name").apply { parentFile?.mkdirs() }
else File(work, "${archives.size}-$name")
val ok = Downloader.downloadFile(url, file, false) { f -> onProgress(Progress(c.name, name, Phase.DOWNLOAD, if (f < 0) -1 else Math.round(f * 100f))) }
if (!ok) return "download failed: $name"
step.str("file_checksum").takeIf { it.length == 32 }?.let { md5 ->
if (!digest(file, "MD5").equals(md5, ignoreCase = true)) return "checksum mismatch: $name"
}
onProgress("${c.name}: unpacking $name", -1)
val dir = File(work, "${archives.size}-x").apply { mkdirs() }
if (!extract(file, dir)) return "could not unpack $name"
file.delete()
onProgress(Progress(c.name, "unpacking $name", Phase.INSTALL, -1))
val dir = if (byRuntime) {
runtimeArchives[step.str("file_name").ifEmpty { name }] = file
runtimeDirs += file
unpackInRuntime(context, c, file, "${archives.size}-x", onProgress)?.also { runtimeDirs += it }
?: return "could not unpack $name"
} else {
File(work, "${archives.size}-x").apply { mkdirs() }.also { if (!extract(file, it)) return "could not unpack $name" }
.also { file.delete() }
}
dir.also { archives[url] = it }
}
}
// Files picked out of a cabinet: one the catalog downloaded (DirectX's redistributable,
// a self-extracting cabinet), or cabinets an earlier pick put under temp/.
"get_from_cab" -> {
val pattern = step.str("source")
val containers = runtimeArchives[pattern]?.let { listOf(it) } ?: run {
val folder = File(temp, pattern.substringBeforeLast('/', "").replace('\\', '/'))
val rx = globRegex(pattern.substringAfterLast('/'))
folder.listFiles { f -> f.isFile && rx.matches(f.name) }?.sortedBy { it.name }.orEmpty()
}
if (containers.isEmpty()) return "nothing is called $pattern"
val dest = step.str("dest").replace('\\', '/')
for (container in containers) {
val unpacked = cabs.getOrPut(container.path) {
unpackInRuntime(context, c, container, "c${cabs.size}", onProgress)?.also { runtimeDirs += it }
?: return "could not open ${container.name}"
}
if (dest.startsWith("temp/", ignoreCase = true)) {
copyMatching(unpacked, step.str("file_name"), File(temp, dest.substring(5).trimEnd('/')), null)
} else {
val (arch, base) = when (dest.substringBefore('/').lowercase()) {
"win64", "system32" -> "win64" to "system32"
"win32", "syswow64" -> "win32" to "syswow64"
else -> null to "system32"
}
val sub = dest.substringAfter('/', "").trimEnd('/')
copyMatching(unpacked, step.str("file_name"), File(staging, if (sub.isEmpty()) base else "$base/$sub"), arch)
}
}
}
// The Windows version a game sees: HKCU\Software\Wine "Version", the last word wins.
"set_windows" -> step.str("version").takeIf { it.isNotEmpty() }?.let { version ->
registry.removeAll { it.optString("key") == "Software\\Wine" && it.optString("name") == "Version" }
registry += JSONObject().put("hive", "HKCU").put("key", "Software\\Wine").put("name", "Version").put("type", "sz").put("data", version)
}
"set_register_key" -> {
val full = step.str("key").replace("\\\\", "\\").trim('\\')
val hive = when (full.substringBefore('\\').uppercase()) {
"HKLM", "HKEY_LOCAL_MACHINE" -> "HKLM"
"HKCU", "HKEY_CURRENT_USER" -> "HKCU"
else -> return "a registry key outside HKLM and HKCU: $full"
}
val key = full.substringAfter('\\', "")
if (key.isEmpty()) return "a registry value without a key: $full"
val data = step.str("data")
val value = JSONObject().put("hive", hive).put("key", key).put("name", step.str("value"))
when (step.str("type").uppercase()) {
"REG_DWORD" -> value.put("type", "dword").put("data", data.trim().let { it.removePrefix("0x").toLongOrNull(if (it.startsWith("0x")) 16 else 10) ?: 0L })
"REG_EXPAND_SZ" -> value.put("type", "expand_sz").put("data", data)
else -> value.put("type", "sz").put("data", data)
}
registry += value
}
// "Uninstall Wine Mono" before a real .NET Framework: the mscoree override the catalog
// sets beside it is what takes Mono out of the game's way; nothing to remove here.
"uninstall" -> Unit
"copy_dll", "copy_file" -> {
val from = source ?: return "a copy step before any archive"
val dest = step.str("dest")
@@ -226,6 +338,17 @@ object WinComponents {
"register_dll" -> Unit
else -> return "unsupported step ${step.action}"
}
} finally {
runtimeDirs.forEach { FileUtils.delete(it) }
FileUtils.delete(temp)
}
if (registry.isNotEmpty()) {
// The packages' values are already in registry.json; the steps' own go after them.
val file = File(staging, "registry.json")
val values = runCatching { JSONObject(file.readText()).optJSONArray("values") }.getOrNull() ?: JSONArray()
registry.forEach { values.put(it) }
FileUtils.writeString(file, JSONObject().put("version", 1).put("values", values).toString(1))
}
val version = msi?.optString("version")?.takeIf { it.isNotEmpty() }
?: Regex("""\b(\d+\.\d+(\.\d+)*)\b""").find(c.description)?.value ?: "catalog"
val meta = JSONObject()
@@ -240,31 +363,64 @@ object WinComponents {
return null
}
/** The regex for a catalog file pattern: * matches anything, case does not matter. */
private fun globRegex(pattern: String) =
Regex("^" + pattern.split("*").joinToString(".*") { Regex.escape(it) } + "$", RegexOption.IGNORE_CASE)
/**
* Downloads the component's .msi into the runtime and has droiddeck-msi-install lay it out in
* [staging]. Returns the installer's result (product, version, counts, notes), or null and why.
* Everything the installer prints also goes to Download/DroidDeck/tools.
* Has the runtime's tools open [archive] (an installer .exe, a cabinet, a self-extracting
* one) into a folder of the package cache called [tag]; that folder, or null and a log line.
*/
private fun installMsi(context: Context, c: Component, step: Step, staging: File, onProgress: (String, Int) -> Unit): Pair<JSONObject?, String> {
private fun unpackInRuntime(context: Context, c: Component, archive: File, tag: String, onProgress: (Progress) -> Unit): File? {
val root = LinuxRuntime.rootDir(context)
val cache = File(root, MSI_CACHE)
val dir = File(cache, ".x-$tag").apply { FileUtils.delete(this); mkdirs() }
val inside = archive.relativeTo(root).path
var problem: String? = null
var ok = false
val status = GuestCommand.run(context, listOf(MSI_INSTALL, "--unpack", "/$inside", "/$MSI_CACHE/${dir.name}"),
logName = "wincomponents-${c.name}") { line ->
when {
line.startsWith("progress ") -> engineLine(line.removePrefix("progress ")).let { (text, _) -> onProgress(Progress(c.name, text, Phase.INSTALL, -1)) }
line.startsWith("result ") -> ok = true
line.startsWith("error ") -> problem = line.removePrefix("error ")
}
}
if (status != 0 || !ok) {
Log.w(TAG, "unpack ${archive.name}: ${problem ?: "status $status"}")
FileUtils.delete(dir)
return null
}
return dir
}
/**
* Downloads the component's package (.msi) or installer (.exe) into the runtime and has
* droiddeck-msi-install lay it out in [staging]. Returns the installer's result (product,
* version, counts, notes), or null and why. Everything the installer prints also goes to
* files/logs/tools.
*/
private fun installPackage(context: Context, c: Component, step: Step, staging: File, onProgress: (Progress) -> Unit): Pair<JSONObject?, String> {
val root = LinuxRuntime.rootDir(context)
val url = step.str("url")
val name = url.substringBefore('?').substringAfterLast('/')
val cache = File(root, MSI_CACHE).apply { mkdirs() }
val file = File(cache, "${c.name}.msi")
val ext = if (listOf(step.str("file_name"), name).any { it.endsWith(".exe", ignoreCase = true) }) "exe" else "msi"
val file = File(cache, "${c.name}-${c.steps.indexOf(step)}.$ext")
try {
if (!Downloader.downloadFile(url, file, false) { f -> onProgress("${c.name}: $name", if (f < 0) -1 else Math.round(f * 100f)) }) {
if (!Downloader.downloadFile(url, file, false) { f -> onProgress(Progress(c.name, name, Phase.DOWNLOAD, if (f < 0) -1 else Math.round(f * 100f))) }) {
return null to "download failed: $name"
}
step.str("file_checksum").takeIf { it.length == 32 }?.let { md5 ->
if (!digest(file, "MD5").equals(md5, ignoreCase = true)) return null to "checksum mismatch: $name"
}
onProgress("${c.name}: installing", -1)
onProgress(Progress(c.name, "installing", Phase.INSTALL, 0))
var result: JSONObject? = null
var problem: String? = null
val status = GuestCommand.run(context, listOf(MSI_INSTALL, "/$MSI_CACHE/${file.name}", "/$STORE/${staging.name}"),
logName = "wincomponents-${c.name}") { line ->
when {
line.startsWith("progress ") -> onProgress("${c.name}: ${line.removePrefix("progress ")}", -1)
line.startsWith("progress ") -> engineLine(line.removePrefix("progress ")).let { (text, pct) -> onProgress(Progress(c.name, text, Phase.INSTALL, pct)) }
line.startsWith("result ") -> result = runCatching { JSONObject(line.removePrefix("result ")) }.getOrNull()
line.startsWith("error ") -> problem = line.removePrefix("error ")
}
@@ -273,7 +429,7 @@ object WinComponents {
if (status != 0 || done == null) return null to (problem ?: "the installer stopped (status $status)")
return done to ""
} catch (e: Exception) {
Log.e(TAG, "msi ${c.name}", e)
Log.e(TAG, "package ${c.name}", e)
return null to (e.message ?: "the installer failed")
} finally {
file.delete()
@@ -0,0 +1,87 @@
package com.droiddeck.launcher.stores
import org.json.JSONObject
/**
* What a cloud sync may do, decided from three views of each save file: the local copy, the cloud
* copy, and the baseline - both as they were when this device last synced the game. Pure, so the
* rules are tested on their own:
*
* - No upload before a download has completed once on this device (no baseline): a first launch
* writes new profile files that must never replace the real saves in the cloud.
* - The first download (no baseline) lets the cloud win where the two differ - the local folder is
* backed up first.
* - A file changed on both sides since the baseline is a conflict: neither side is overwritten; the
* user picks Keep cloud or Keep local in Manage saves.
* - Otherwise the side that changed since the baseline wins; one that changed on neither is left.
*/
object CloudPlan {
/** One file as this device last saw it in sync: the local MD5, and the cloud's MD5 (when known) and time. */
data class Entry(val localMd5: String, val cloudMd5: String?, val cloudModified: Long)
class Local(val md5: String)
class Remote(val md5: String?, val modified: Long)
class Plan(val transfer: List<String>, val conflicts: List<String>, val same: List<String>, val refused: String? = null)
/** The cloud copy differs from what the baseline recorded of it. */
private fun cloudChanged(r: Remote, b: Entry?): Boolean {
if (b == null) return true
if (r.md5 != null && b.cloudMd5 != null) return !r.md5.equals(b.cloudMd5, ignoreCase = true)
// Times only (Epic's listing): the service's own clock, so a couple of seconds of slack.
return r.modified > 0 && b.cloudModified > 0 && kotlin.math.abs(r.modified - b.cloudModified) > 2000
}
private fun localChanged(l: Local, b: Entry?): Boolean = b == null || !l.md5.equals(b.localMd5, ignoreCase = true)
private fun same(l: Local, r: Remote): Boolean = r.md5 != null && r.md5.equals(l.md5, ignoreCase = true)
/** Cloud → device. [baseline] null = this device never synced the game. */
fun down(local: Map<String, Local>, remote: Map<String, Remote>, baseline: Map<String, Entry>?): Plan {
val transfer = ArrayList<String>(); val conflicts = ArrayList<String>(); val same = ArrayList<String>()
for ((name, r) in remote) {
val l = local[name]
val b = baseline?.get(name)
when {
l == null -> transfer += name
same(l, r) -> same += name
baseline == null -> transfer += name
cloudChanged(r, b) && localChanged(l, b) -> conflicts += name
cloudChanged(r, b) -> transfer += name
}
}
return Plan(transfer, conflicts, same)
}
/** Device → cloud. Refused outright without a baseline. */
fun up(local: Map<String, Local>, remote: Map<String, Remote>, baseline: Map<String, Entry>?): Plan {
if (baseline == null) return Plan(emptyList(), emptyList(), emptyList(), refused = "no-baseline")
val transfer = ArrayList<String>(); val conflicts = ArrayList<String>(); val same = ArrayList<String>()
for ((name, l) in local) {
val r = remote[name]
val b = baseline[name]
when {
r == null -> transfer += name
same(l, r) -> same += name
localChanged(l, b) && cloudChanged(r, b) -> conflicts += name
localChanged(l, b) -> transfer += name
}
}
return Plan(transfer, conflicts, same)
}
fun toJson(baseline: Map<String, Entry>): String = JSONObject().apply {
for ((k, e) in baseline) put(k, JSONObject().put("l", e.localMd5).put("c", e.cloudMd5 ?: "").put("t", e.cloudModified))
}.toString()
fun fromJson(text: String?): Map<String, Entry>? {
if (text == null) return null
return runCatching {
val o = JSONObject(text)
o.keys().asSequence().associateWith { k ->
val e = o.getJSONObject(k)
Entry(e.optString("l"), e.optString("c").ifEmpty { null }, e.optLong("t", -1L))
}
}.getOrNull()
}
}
@@ -0,0 +1,84 @@
/*
* Cloud-save folder resolution for GOG and Epic games, after Bannerlator's GogCloudSavePaths and
* EpicCloudSavePaths (device-proven there, August 2026): a store's save-location template expanded
* against the game's Proton prefix and its install folder.
*/
package com.droiddeck.launcher.stores
import java.io.File
/**
* Where a store keeps a game's saves on Windows, made concrete inside this game's Proton prefix.
*
* GOG gives a template per game in its public remote-config (`cloudStorage.locations[].location`):
* a leading `<?TOKEN?>` (or `%TOKEN%`) and a path, e.g. ELDERBORN's
* `<?APPLICATION_DATA_LOCAL_LOW?>/Hyperstrange/ELDERBORN`. Epic gives `CloudSaveFolder` in the
* game's catalog attributes: a leading `{Token}`, with `{EpicId}` / `{AppName}` inside, e.g.
* `{AppData}/Game/Saved/SaveGames`. Profile tokens map under the prefix's Wine user
* (`drive_c/users/steamuser`), the install tokens to the game's folder. The path is walked against
* what is on disk case-insensitively; a result outside its boundary is refused, never guessed.
*/
object CloudSavePaths {
/** The Wine user every prefix has (Proton's). */
fun profile(prefix: File): File = File(prefix, "drive_c/users/steamuser")
/** Expands a GOG location template; null for an unknown token or an escape. */
fun gog(template: String, prefix: File, install: File): File? {
val norm = template.replace('\\', '/').trim().trimStart('/')
val match = Regex("^(<\\?[^?>]+\\?>|%[^%]+%)").find(norm) ?: return null
val token = match.groupValues[1].trim('<', '>', '?', '%').uppercase()
val profile = profile(prefix)
val (base, boundary) = when (token) {
"INSTALL" -> install to install
"SAVED_GAMES" -> File(profile, "Saved Games") to profile
"DOCUMENTS" -> File(profile, "Documents") to profile
"APPLICATION_DATA_LOCAL", "LOCALAPPDATA" -> File(profile, "AppData/Local") to profile
"APPLICATION_DATA_LOCAL_LOW" -> File(profile, "AppData/LocalLow") to profile
"APPLICATION_DATA_ROAMING", "APPDATA" -> File(profile, "AppData/Roaming") to profile
"USERPROFILE" -> profile to profile
"PROGRAMDATA" -> File(prefix, "drive_c/ProgramData").let { it to it }
else -> return null
}
return walk(base, boundary, norm.substring(match.value.length), fuzzy = false)
}
/** Expands an Epic CloudSaveFolder; null for an unknown token or an escape. */
fun epic(template: String, prefix: File, install: File, accountId: String, appName: String): File? {
val norm = template.replace('\\', '/').trim().trimStart('/')
val match = Regex("^\\{([^}]+)\\}").find(norm) ?: return null
val profile = profile(prefix)
val (base, boundary) = when (match.groupValues[1].lowercase()) {
"installdir" -> install to install
"userprofile" -> profile to profile
"userdir" -> File(profile, "Documents") to profile
"usersavedgames" -> File(profile, "Saved Games") to profile
"appdata", "localappdata" -> File(profile, "AppData/Local") to profile
"roamingappdata" -> File(profile, "AppData/Roaming") to profile
else -> return null
}
val rest = norm.substring(match.value.length)
.replace("{epicid}", accountId, ignoreCase = true)
.replace("{appname}", appName, ignoreCase = true)
return walk(base, boundary, rest, fuzzy = true)
}
/**
* Each segment matched against the folder's entries case-insensitively (and, for Epic, by letters
* and digits alone when that names exactly one), else taken as written - a download creates it.
*/
private fun walk(base: File, boundary: File, rest: String, fuzzy: Boolean): File? {
var dir = base
for (seg in rest.trimStart('/').split('/').filter { it.isNotEmpty() && it != "." }) {
if (seg == "..") { dir = dir.parentFile ?: return null; continue }
val kids = dir.listFiles()
val existing = kids?.firstOrNull { it.name.equals(seg, ignoreCase = true) }
?: if (fuzzy) kids?.filter { squash(it.name) == squash(seg) && squash(seg).isNotEmpty() }?.singleOrNull() else null
dir = existing ?: File(dir, seg)
}
val bound = runCatching { boundary.canonicalPath }.getOrNull() ?: return null
val dest = runCatching { dir.canonicalPath }.getOrNull() ?: return null
return if (dest == bound || dest.startsWith(bound + File.separator)) dir else null
}
private fun squash(s: String) = s.lowercase().filter { it.isLetterOrDigit() }
}
@@ -0,0 +1,387 @@
package com.droiddeck.launcher.stores
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.frontend.AddedGames
import com.droiddeck.launcher.frontend.Library
import com.droiddeck.launcher.stores.epic.EpicApiClient
import com.droiddeck.launcher.stores.epic.EpicCredentialStore
import com.droiddeck.launcher.stores.epic.EpicPrefs
import com.droiddeck.launcher.stores.gog.GogAuth
import com.droiddeck.launcher.stores.gog.GogPrefs
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
import java.security.MessageDigest
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import org.json.JSONObject
/**
* GOG and Epic cloud saves for an installed store game: before a launch the cloud copy is brought
* down where it is newer, after the game exits what changed goes up - newest wins, file by file.
* Before a download overwrites anything, the folder is copied to the app's storage (the last
* [BACKUPS] per game). Both launch paths reach it: the compat tool's request from
* droiddeck-store-launch ([StoreLaunchRequests]) for every launch, and the Games tab's Manage saves.
*
* The save folder: GOG's remote-config location template (by the game's Galaxy client id) or
* Epic's CloudSaveFolder catalog attribute, cached per game, made concrete by [CloudSavePaths]
* inside the game's Proton prefix. A game whose store gives none has no cloud saves.
*/
object CloudSaves {
private const val TAG = "CloudSaves"
private const val BACKUPS = 3
private const val PARALLEL = 6
/** What a sync did, for the log and the Manage saves row. */
/**
* What a sync did, for the log and the Manage saves row. [result]: ok, skipped (nothing to do or
* not possible: [reason] says why), deferred (no prefix yet; it runs once Proton makes one),
* failed. [conflicts]: files changed on both sides, left alone.
*/
class Result(val result: String, val files: Int, val bytes: Long, val reason: String, val conflicts: Int = 0) {
val ok: Boolean get() = result != "failed"
val line: String get() = "result=$result files=$files bytes=$bytes reason=$reason" + if (conflicts > 0) " conflicts=$conflicts" else ""
}
/** A game's cloud state for the Manage saves row. */
class Status(val supported: Boolean, val folder: File?, val lastSync: Long, val reason: String, val conflicts: List<String> = emptyList())
private fun game(context: Context, store: Store, id: String): Pair<File, StoreGameSidecar>? =
StoreInstallRoot.gameFolders(context).firstNotNullOfOrNull { f ->
StoreGameSidecar.read(f)?.takeIf { it.store == store && it.id == id && it.isInstalled }?.let { f to it }
}
/** The game's prefix: compatdata/<its shortcut appid>/pfx, wherever that library is. */
fun prefix(context: Context, folder: File): File? {
val appId = AddedGames.scan(context).firstOrNull { it.folder.absolutePath == folder.absolutePath }?.appId ?: return null
return Library.protonPrefix(context, appId and 0xFFFFFFFFL)
}
private fun prefs(context: Context, store: Store) = if (store == Store.GOG) GogPrefs.get(context) else EpicPrefs.get(context)
fun lastSync(context: Context, store: Store, id: String): Long = prefs(context, store).getLong("cloud_sync_$id", 0L)
private fun markSynced(context: Context, store: Store, id: String) = prefs(context, store).edit().putLong("cloud_sync_$id", System.currentTimeMillis()).apply()
// ---- the save folder ------------------------------------------------------------------------
/** The store's template for this game: cached, else fetched; "" when the store gives none, null when it could not be asked. */
fun template(context: Context, store: Store, id: String, folder: File, sidecar: StoreGameSidecar): String? {
val p = prefs(context, store)
if (p.contains("cloud_template_$id")) return p.getString("cloud_template_$id", "")
val fetched = when (store) {
Store.GOG -> gogTemplate(context, id, folder)
Store.EPIC -> {
val token = EpicCredentialStore.getValidAccessToken(context) ?: return null
val ns = sidecar.extra["namespace"].orEmpty()
val item = sidecar.extra["catalogItemId"].orEmpty()
if (ns.isEmpty() || item.isEmpty()) "" else EpicApiClient.getCustomAttribute(token, ns, item, "CloudSaveFolder")
}
Store.AMAZON -> ""
} ?: return null
p.edit().putString("cloud_template_$id", fetched).apply()
Log.i(TAG, "cloud ${store.id} $id template ${if (fetched.isEmpty()) "none" else "found"}")
return fetched
}
private fun gogClientId(context: Context, id: String, folder: File): String? =
GogPrefs.get(context).getString("client_id_$id", null)?.ifEmpty { null }
?: runCatching { JSONObject(File(folder, "goggame-$id.info").readText()).optString("clientId").ifEmpty { null } }.getOrNull()
/** GOG's public remote-config: `content.Windows.cloudStorage.locations`, the one named "saves" first. */
private fun gogTemplate(context: Context, id: String, folder: File): String? {
val clientId = gogClientId(context, id, folder) ?: return ""
return try {
val c = URL("https://remote-config.gog.com/components/galaxy_client/clients/$clientId?component_version=2.0.45").openConnection() as HttpURLConnection
c.connectTimeout = 15_000; c.readTimeout = 15_000
c.setRequestProperty("User-Agent", "GOG Galaxy")
if (c.responseCode !in 200..299) { c.disconnect(); return null }
val cloud = JSONObject(c.inputStream.bufferedReader().readText()).optJSONObject("content")?.optJSONObject("Windows")?.optJSONObject("cloudStorage")
c.disconnect()
if (cloud?.optBoolean("enabled", false) != true) return ""
val locations = cloud.optJSONArray("locations") ?: return ""
val all = (0 until locations.length()).mapNotNull { locations.optJSONObject(it) }
(all.firstOrNull { it.optString("name").equals("saves", true) } ?: all.firstOrNull())?.optString("location").orEmpty()
} catch (e: Exception) {
Log.w(TAG, "gog remote-config: ${e.javaClass.simpleName}")
null
}
}
/** The concrete save folder in the prefix, or null with why. */
private fun saveFolder(context: Context, store: Store, id: String): Pair<File?, String> {
val (folder, sidecar) = game(context, store, id) ?: return null to "not-installed"
val template = template(context, store, id, folder, sidecar) ?: return null to "store-unreachable"
if (template.isEmpty()) return null to "no-cloud-saves"
val pfx = prefix(context, folder) ?: return null to "no-prefix"
val dir = when (store) {
Store.GOG -> CloudSavePaths.gog(template, pfx, folder)
Store.EPIC -> CloudSavePaths.epic(template, pfx, folder, EpicCredentialStore.load(context)?.accountId.orEmpty(), id)
Store.AMAZON -> null
}
return dir to (if (dir == null) "unresolved" else "ok")
}
fun status(context: Context, store: Store, id: String): Status {
val (dir, reason) = saveFolder(context, store, id)
return Status(reason != "no-cloud-saves" && reason != "not-installed", dir, lastSync(context, store, id), reason, conflicts(context, store, id))
}
// ---- transport ------------------------------------------------------------------------------
private fun transport(context: Context, store: Store, id: String, folder: File): CloudTransport? = when (store) {
Store.GOG -> {
val account = StoreAccounts.read(context, Store.GOG)
val userId = account?.optString("user_id").orEmpty()
val galaxy = GogAuth.validToken(context)
val clientId = gogClientId(context, id, folder)
if (userId.isEmpty() || galaxy == null || clientId == null) null
else GogCloud(userId, clientId, gogScopedToken(context, id, clientId, account?.optString("refresh_token").orEmpty()) ?: galaxy)
}
Store.EPIC -> {
val token = EpicCredentialStore.getValidAccessToken(context)
val account = EpicCredentialStore.load(context)?.accountId.orEmpty()
if (token == null || account.isEmpty()) null else EpicCloud(account, id, token)
}
Store.AMAZON -> null
}
/** GOG cloud storage wants a token issued to the game's own client: the Galaxy refresh token, exchanged with the game's id and secret. */
private fun gogScopedToken(context: Context, id: String, clientId: String, refresh: String): String? {
val secret = GogPrefs.get(context).getString("client_secret_$id", null)?.ifEmpty { null } ?: return null
if (refresh.isEmpty()) return null
return try {
val enc = { s: String -> java.net.URLEncoder.encode(s, "UTF-8") }
val c = URL("https://auth.gog.com/token?client_id=${enc(clientId)}&client_secret=${enc(secret)}&grant_type=refresh_token&refresh_token=${enc(refresh)}").openConnection() as HttpURLConnection
c.connectTimeout = 15_000; c.readTimeout = 15_000
c.setRequestProperty("User-Agent", "GOG Galaxy")
if (c.responseCode != 200) { c.disconnect(); return null }
JSONObject(c.inputStream.bufferedReader().readText()).optString("access_token").ifEmpty { null }.also { c.disconnect() }
} catch (e: Exception) { null }
}
// ---- sync -----------------------------------------------------------------------------------
private fun baseline(context: Context, store: Store, id: String): Map<String, CloudPlan.Entry>? =
CloudPlan.fromJson(prefs(context, store).getString("cloud_baseline_$id", null))
private fun saveBaseline(context: Context, store: Store, id: String, b: Map<String, CloudPlan.Entry>) =
prefs(context, store).edit().putString("cloud_baseline_$id", CloudPlan.toJson(b)).apply()
/** Files changed on both sides since the last sync, waiting for Keep cloud / Keep local. */
fun conflicts(context: Context, store: Store, id: String): List<String> =
prefs(context, store).getString("cloud_conflicts_$id", "").orEmpty().split('\n').filter { it.isNotEmpty() }
private fun saveConflicts(context: Context, store: Store, id: String, names: Collection<String>) =
prefs(context, store).edit().putString("cloud_conflicts_$id", names.joinToString("\n")).apply()
/** Cloud → device where the cloud copy changed (the first time: wherever it differs). [force]: conflicts too (Keep cloud). Blocking. */
fun download(context: Context, store: Store, id: String, force: Boolean = false): Result =
sync(context, store, id, "down") { dir, cloud -> downWith(context, store, id, dir, cloud, force) }
/** Device → cloud where the local copy changed, once a download has set the baseline. [force]: conflicts too (Keep local). Blocking. */
fun upload(context: Context, store: Store, id: String, force: Boolean = false): Result =
sync(context, store, id, "up") { dir, cloud -> upWith(context, store, id, dir, cloud, force) }
fun keepCloud(context: Context, store: Store, id: String): Result = download(context, store, id, force = true)
fun keepLocal(context: Context, store: Store, id: String): Result = upload(context, store, id, force = true)
/**
* The pre-launch download, or - when Proton has not made the game's prefix yet (a first launch)
* - a deferred one: answered at once, then run in the background as soon as the prefix's user
* folder appears (within [PREFIX_WAIT_MS]), so the launch is not held up for it.
*/
fun downloadOrDefer(context: Context, store: Store, id: String): Result {
val app = context.applicationContext
val g = game(app, store, id) ?: return done(store, id, "down", Result("skipped", 0, 0, "not-installed"))
if (!g.second.cloud) return done(store, id, "down", Result("skipped", 0, 0, "off"))
if (prefix(app, g.first)?.let { CloudSavePaths.profile(it).isDirectory } == true) return download(app, store, id)
Thread({
val end = System.currentTimeMillis() + PREFIX_WAIT_MS
while (System.currentTimeMillis() < end) {
if (prefix(app, g.first)?.let { CloudSavePaths.profile(it).isDirectory } == true) { download(app, store, id); return@Thread }
Thread.sleep(2000)
}
done(store, id, "down", Result("skipped", 0, 0, "no-prefix"))
}, "cloud-deferred").start()
return done(store, id, "down", Result("deferred", 0, 0, "no-prefix"))
}
private const val PREFIX_WAIT_MS = 60_000L
private fun sync(context: Context, store: Store, id: String, way: String, body: (File, CloudTransport) -> Result): Result {
val result = try {
val g = game(context, store, id)
when {
g == null -> Result("skipped", 0, 0, "not-installed")
!g.second.cloud -> Result("skipped", 0, 0, "off")
else -> {
val (dir, reason) = saveFolder(context, store, id)
val cloud = if (dir != null) transport(context, store, id, g.first) else null
when {
dir == null -> Result("skipped", 0, 0, reason)
cloud == null -> Result("skipped", 0, 0, "not-signed-in")
else -> body(dir, cloud).also { if (it.result == "ok") markSynced(context, store, id) }
}
}
}
} catch (e: NoCloudSaves) {
prefs(context, store).edit().putString("cloud_template_$id", "").apply()
Result("skipped", 0, 0, "no-cloud-saves")
} catch (e: Exception) {
Result("failed", 0, 0, e.javaClass.simpleName)
}
return done(store, id, way, result)
}
private fun done(store: Store, id: String, way: String, r: Result): Result {
Log.i(TAG, "cloud ${store.id} $id $way ${r.line}")
StoresState.logLine("cloud ${store.id} $id $way ${r.line}")
return r
}
private fun localFiles(dir: File): Map<String, File> =
if (!dir.isDirectory) emptyMap()
else dir.walkTopDown().filter { it.isFile }.associateBy { it.relativeTo(dir).path.replace(File.separatorChar, '/') }
internal fun md5(f: File): String = md5(f.readBytes())
internal fun md5(data: ByteArray): String = MessageDigest.getInstance("MD5").digest(data).joinToString("") { "%02x".format(it) }
/** The cloud listing, with MD5 and time filled in (one HEAD each where the listing lacks them, in parallel). */
private fun remote(cloud: CloudTransport, wanted: (String) -> Boolean): Map<String, CloudFile> {
val files = cloud.list()
val pool = Executors.newFixedThreadPool(PARALLEL, com.droiddeck.launcher.stores.download.DownloadQueue.workerFactory("cloud"))
for (f in files) if (wanted(f.name)) pool.execute { runCatching { cloud.details(f) } }
pool.shutdown(); pool.awaitTermination(2, TimeUnit.MINUTES)
return files.associateBy { it.name }
}
internal fun downWith(context: Context, store: Store, id: String, dir: File, cloud: CloudTransport, force: Boolean): Result {
val files = localFiles(dir)
val remote = remote(cloud) { it in files }
val local = files.mapValues { CloudPlan.Local(md5(it.value)) }
val base = baseline(context, store, id)
val plan = CloudPlan.down(local, remote.mapValues { CloudPlan.Remote(it.value.md5, it.value.modifiedMs) }, base)
val take = if (force) plan.transfer + plan.conflicts else plan.transfer
if (take.isNotEmpty() && files.isNotEmpty()) backup(context, store, id, dir, "local")
val next = HashMap(base.orEmpty())
var bytes = 0L
for (name in take) {
val f = remote[name] ?: continue
val (data, modified) = cloud.get(f)
val dest = File(dir, name)
if (!dest.canonicalPath.startsWith(dir.canonicalPath + File.separator)) continue
dest.parentFile?.mkdirs()
dest.writeBytes(data)
if (modified > 0) dest.setLastModified(modified)
bytes += data.size
next[name] = CloudPlan.Entry(md5(data), f.md5, if (modified > 0) modified else f.modifiedMs)
}
for (name in plan.same) remote[name]?.let { r -> next[name] = CloudPlan.Entry(local.getValue(name).md5, r.md5, r.modifiedMs) }
// A download that completed sets the baseline even when the cloud was empty: uploads may follow.
saveBaseline(context, store, id, next)
val left = if (force) emptyList() else plan.conflicts
saveConflicts(context, store, id, left)
return Result("ok", take.size, bytes, if (take.isEmpty()) "up-to-date" else "downloaded", left.size)
}
internal fun upWith(context: Context, store: Store, id: String, dir: File, cloud: CloudTransport, force: Boolean): Result {
val base = baseline(context, store, id) ?: return Result("skipped", 0, 0, "no-baseline")
val files = localFiles(dir)
if (files.isEmpty()) return Result("skipped", 0, 0, "nothing-local")
val remote = remote(cloud) { it in files }
val local = files.mapValues { CloudPlan.Local(md5(it.value)) }
val plan = CloudPlan.up(local, remote.mapValues { CloudPlan.Remote(it.value.md5, it.value.modifiedMs) }, base)
val send = if (force) plan.transfer + plan.conflicts else plan.transfer
val next = HashMap(base)
for (name in plan.same) remote[name]?.let { r -> next[name] = CloudPlan.Entry(local.getValue(name).md5, r.md5, r.modifiedMs) }
if (send.isNotEmpty()) {
// The cloud copies about to be replaced are kept first, as a download keeps the local ones.
backupCloud(context, store, id, cloud, send.mapNotNull { remote[it] })
val data = send.associateWith { files.getValue(it).readBytes() }
val stamped = cloud.put(data)
for ((name, t) in stamped) if (t > 0) files[name]?.setLastModified(t)
val after = remote(cloud) { it in data }
for (name in data.keys) {
val r = after[name]
next[name] = CloudPlan.Entry(md5(data.getValue(name)), r?.md5, r?.modifiedMs ?: stamped[name] ?: -1L)
}
}
saveBaseline(context, store, id, next)
val left = if (force) emptyList() else plan.conflicts
saveConflicts(context, store, id, left)
return Result("ok", send.size, send.sumOf { files.getValue(it).length() }, if (send.isEmpty()) "up-to-date" else "uploaded", left.size)
}
// ---- uploads that do not depend on one hook ---------------------------------------------------
/**
* A game launched with cloud saves is marked dirty in the app's storage when its pre-launch step
* runs. The upload happens on the first of: the compat tool's exit request (`exit`), the end of
* the session (`session-end`), or the app's next start for a mark a killed app left (`recovery`)
* - so a session closed from the drawer, a game Steam killed, or a crash still uploads. The mark
* goes only after an upload that went through, or one skipped on purpose with its reason logged.
*/
private fun marks(context: Context) = context.applicationContext.getSharedPreferences("stores.cloud", Context.MODE_PRIVATE)
fun markDirty(context: Context, store: Store, id: String) {
marks(context).edit().putLong("dirty:${store.id}:$id", System.currentTimeMillis()).apply()
}
fun dirty(context: Context): List<Pair<Store, String>> = marks(context).all.keys.mapNotNull { k ->
val parts = k.split(':', limit = 3)
if (parts.size == 3 && parts[0] == "dirty") Store.byId(parts[1])?.let { it to parts[2] } else null
}
private val markLock = Any()
/** Why a skip is final: retrying cannot change it, so the mark goes. */
private val FINAL_SKIPS = setOf("off", "no-cloud-saves", "no-baseline", "not-installed", "nothing-local")
/** Uploads [id] if it is marked dirty; null when it is not (another trigger got there first). */
fun uploadDirty(context: Context, store: Store, id: String, trigger: String): Result? = synchronized(markLock) {
val key = "dirty:${store.id}:$id"
if (!marks(context).contains(key)) return null
val r = sync(context, store, id, "up trigger=$trigger") { dir, cloud -> upWith(context, store, id, dir, cloud, force = false) }
if (r.result == "ok" || (r.result == "skipped" && r.reason in FINAL_SKIPS)) marks(context).edit().remove(key).apply()
r
}
/**
* Every dirty game, uploaded; [running] says a game session is up, in which case `recovery` waits
* - a marked game may be the one running.
*/
fun uploadAllDirty(context: Context, trigger: String, running: Boolean = com.droiddeck.launcher.session.SessionState.running) {
if (trigger == "recovery" && running) return
for ((store, id) in dirty(context)) uploadDirty(context, store, id, trigger)
}
private fun backupRoot(context: Context, store: Store, id: String) =
File(context.filesDir, "stores/cloud-backups/${store.id}-${id.replace(Regex("[^A-Za-z0-9._-]"), "_")}")
/** The local folder as it is, before a download changes it; the last [BACKUPS] of each kind are kept. */
private fun backup(context: Context, store: Store, id: String, dir: File, kind: String) {
val root = backupRoot(context, store, id)
runCatching { dir.copyRecursively(File(root, "$kind-${System.currentTimeMillis()}"), overwrite = true) }
prune(root, kind)
}
private fun backupCloud(context: Context, store: Store, id: String, cloud: CloudTransport, files: List<CloudFile>) {
if (files.isEmpty()) return
val root = backupRoot(context, store, id)
val target = File(root, "cloud-${System.currentTimeMillis()}")
for (f in files) {
val dest = File(target, f.name)
if (!dest.canonicalPath.startsWith(target.canonicalPath + File.separator)) continue
dest.parentFile?.mkdirs()
dest.writeBytes(cloud.get(f).first)
}
prune(root, "cloud")
}
private fun prune(root: File, kind: String) {
root.listFiles { f -> f.name.startsWith("$kind-") }?.sortedByDescending { it.name }?.drop(BACKUPS)?.forEach { it.deleteRecursively() }
}
}
@@ -0,0 +1,191 @@
/*
* The two stores' cloud-save services, after Bannerlator's GogCloudSaveManager and
* EpicCloudSaveManager (device-proven there, August 2026): GOG's cloudstorage (one object per
* file, Last-Modified and an MD5 ETag on a HEAD) and Epic's savesync data storage (a listing with
* lastModified and signed read links; signed write links asked for per upload).
*/
package com.droiddeck.launcher.stores
import java.io.ByteArrayOutputStream
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.TimeZone
import org.json.JSONArray
import org.json.JSONObject
/** One file in a game's cloud: its key (a path with '/'), when it was written (-1 unknown) and its MD5 when known. */
class CloudFile(val name: String, var modifiedMs: Long, var md5: String? = null, val readLink: String? = null)
/** A store's cloud for one game. Every call is blocking network. */
interface CloudTransport {
fun list(): List<CloudFile>
/** Fills in [CloudFile.modifiedMs] / [CloudFile.md5] where the listing left them out. */
fun details(file: CloudFile) {}
/** The file's bytes and its cloud time (-1 unknown). */
fun get(file: CloudFile): Pair<ByteArray, Long>
/** Writes [files] (key → bytes); the cloud times assigned, where the service says (-1 unknown). */
fun put(files: Map<String, ByteArray>): Map<String, Long>
}
private const val TIMEOUT = 30_000
private fun open(url: String, method: String, token: String?, ua: String): HttpURLConnection =
(URL(url).openConnection() as HttpURLConnection).apply {
requestMethod = method
connectTimeout = TIMEOUT
readTimeout = TIMEOUT
setRequestProperty("User-Agent", ua)
if (token != null) setRequestProperty("Authorization", "Bearer $token")
}
private fun HttpURLConnection.body(): ByteArray = inputStream.use { input ->
ByteArrayOutputStream().also { input.copyTo(it) }.toByteArray()
}
private fun httpDate(s: String?): Long = try {
SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US).apply { timeZone = TimeZone.getTimeZone("GMT") }.parse(s!!.trim())?.time ?: -1L
} catch (e: Exception) { -1L }
internal fun isoDate(s: String?): Long {
if (s == null || s.length < 19) return -1L
return try {
java.util.Calendar.getInstance(TimeZone.getTimeZone("UTC")).apply {
clear()
set(s.substring(0, 4).toInt(), s.substring(5, 7).toInt() - 1, s.substring(8, 10).toInt(),
s.substring(11, 13).toInt(), s.substring(14, 16).toInt(), s.substring(17, 19).toInt())
}.timeInMillis
} catch (e: Exception) { -1L }
}
/** Thrown when the store says the game has no cloud saves for this client. */
class NoCloudSaves : Exception("no cloud saves")
/** GOG cloudstorage: `/v1/<userId>/<clientId>[/<key>]`. */
class GogCloud(private val userId: String, private val clientId: String, private val token: String) : CloudTransport {
private val base = "https://cloudstorage.gog.com/v1/$userId/$clientId"
private val ua = "GOG Galaxy"
/** A path segment: '+' from a space is wrong in a path (it stores a second, '+'-named copy). */
private fun key(name: String) = URLEncoder.encode(name, "UTF-8").replace("+", "%20")
override fun list(): List<CloudFile> {
val c = open(base, "GET", token, ua)
val code = c.responseCode
if (code == 404) { c.disconnect(); return emptyList() }
if (code !in 200..299) {
val err = runCatching { c.errorStream?.readBytes()?.decodeToString().orEmpty() }.getOrDefault("")
c.disconnect()
if ("not_enabled_for_client" in err || "disabled" in err) throw NoCloudSaves()
throw java.io.IOException("GOG cloud list HTTP $code")
}
val text = c.body().decodeToString().trim()
c.disconnect()
if (text.isEmpty() || text == "[]") return emptyList()
// A plain newline-separated list of keys (checked on device); a JSON array is read too.
if (text.startsWith("[")) {
val a = JSONArray(text)
return (0 until a.length()).mapNotNull { a.optJSONObject(it) }.mapNotNull { o ->
val n = o.optString("name").ifEmpty { return@mapNotNull null }
val lm = o.optLong("last_modified", 0L)
CloudFile(n, if (lm > 1_000_000_000_000L) lm else if (lm > 0) lm * 1000 else -1L)
}
}
return text.lines().map { it.trim() }.filter { it.isNotEmpty() }.map { CloudFile(it, -1L) }
}
override fun details(file: CloudFile) {
if (file.modifiedMs > 0 && file.md5 != null) return
val c = open("$base/${key(file.name)}", "HEAD", token, ua)
if (c.responseCode in 200..299) {
httpDate(c.getHeaderField("Last-Modified")).takeIf { it > 0 }?.let { file.modifiedMs = it }
c.getHeaderField("ETag")?.replace("\"", "")?.trim()?.ifEmpty { null }?.let { file.md5 = it }
}
c.disconnect()
}
override fun get(file: CloudFile): Pair<ByteArray, Long> {
val c = open("$base/${key(file.name)}", "GET", token, ua)
if (c.responseCode !in 200..299) { val code = c.responseCode; c.disconnect(); throw java.io.IOException("GOG cloud get HTTP $code") }
val modified = httpDate(c.getHeaderField("Last-Modified"))
return c.body().also { c.disconnect() } to modified
}
override fun put(files: Map<String, ByteArray>): Map<String, Long> = files.mapValues { (name, data) ->
val c = open("$base/${key(name)}", "PUT", token, ua)
c.doOutput = true
c.setRequestProperty("Content-Type", "application/octet-stream")
c.setFixedLengthStreamingMode(data.size)
c.outputStream.use { it.write(data) }
val code = c.responseCode
c.disconnect()
if (code !in 200..299) throw java.io.IOException("GOG cloud put HTTP $code")
// The server stamps the object itself; that time, so the next sync sees the two as equal.
CloudFile(name, -1L).also { details(it) }.modifiedMs
}
}
/** Epic savesync: `/api/v1/access/egstore/savesync/<accountId>/<appName>/`. */
class EpicCloud(private val accountId: String, private val appName: String, private val token: String) : CloudTransport {
private val base = "https://datastorage-public-service-liveegs.live.use1a.on.epicgames.com/api/v1/access/egstore/savesync/$accountId/$appName/"
private val ua = "EpicGamesLauncher/15.17.1-22692490"
override fun list(): List<CloudFile> {
val c = open(base, "GET", token, ua)
val code = c.responseCode
if (code == 404) { c.disconnect(); return emptyList() }
if (code !in 200..299) { c.disconnect(); throw java.io.IOException("Epic cloud list HTTP $code") }
val root = JSONObject(c.body().decodeToString().ifEmpty { "{}" })
c.disconnect()
val files = root.optJSONObject("files") ?: return emptyList()
return files.keys().asSequence().mapNotNull { k ->
val e = files.optJSONObject(k) ?: return@mapNotNull null
CloudFile(strip(k), isoDate(e.optString("lastModified", null)), e.optString("hash", "").ifEmpty { null }, e.optString("readLink", "").ifEmpty { null })
}.toList()
}
/** The listing names a file `<...>/<appName>/<key>`; the key is what is under the save folder. */
private fun strip(key: String): String {
val marker = "/$appName/"
val i = key.indexOf(marker)
if (i >= 0) return key.substring(i + marker.length)
return key.removePrefix("$appName/")
}
override fun get(file: CloudFile): Pair<ByteArray, Long> {
val link = file.readLink ?: throw java.io.IOException("Epic cloud: no read link")
val c = open(link, "GET", null, ua)
if (c.responseCode !in 200..299) { val code = c.responseCode; c.disconnect(); throw java.io.IOException("Epic cloud get HTTP $code") }
return c.body().also { c.disconnect() } to file.modifiedMs
}
override fun put(files: Map<String, ByteArray>): Map<String, Long> {
if (files.isEmpty()) return emptyMap()
val c = open(base, "POST", token, ua)
c.doOutput = true
c.setRequestProperty("Content-Type", "application/json")
val req = JSONObject().put("files", JSONArray(files.keys.toList())).toString().toByteArray()
c.outputStream.use { it.write(req) }
if (c.responseCode !in 200..299) { val code = c.responseCode; c.disconnect(); throw java.io.IOException("Epic cloud write links HTTP $code") }
val links = JSONObject(c.body().decodeToString()).optJSONObject("files") ?: JSONObject()
c.disconnect()
val out = HashMap<String, Long>()
for (k in links.keys()) {
val link = links.optJSONObject(k)?.optString("writeLink", "")?.ifEmpty { null } ?: continue
val name = strip(k)
val data = files[name] ?: files[k] ?: continue
val p = open(link, "PUT", null, ua)
p.doOutput = true
p.setRequestProperty("Content-Type", "application/octet-stream")
p.setFixedLengthStreamingMode(data.size)
p.outputStream.use { it.write(data) }
val code = p.responseCode
p.disconnect()
if (code !in 200..299) throw java.io.IOException("Epic cloud put HTTP $code")
out[name] = -1L
}
return out
}
}
@@ -0,0 +1,105 @@
package com.droiddeck.launcher.stores
import android.os.Build
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import org.json.JSONObject
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
/**
* The stores' sign-in files at rest: AES-256-GCM under a key that lives in the AndroidKeyStore and
* never leaves it, as a small versioned envelope `{"v":1,"alg":"AES/GCM","iv":...,"ct":...}`
* (base64). The session's Linux side shares the app's files directory but not its Keystore, so
* what it can see of a sign-in is the envelope only.
*/
object CredentialCipher {
const val VERSION = 1
const val ALG = "AES/GCM"
private const val TRANSFORMATION = "AES/GCM/NoPadding"
private const val TAG_BITS = 128
/** Where the key comes from: the AndroidKeyStore on a device, a plain key in tests. */
fun interface KeyProvider {
/** The key, created on first use. Throws when no key can be had (no Keystore on this ROM). */
fun key(): SecretKey
/** The key that sealed earlier files: never a new one. Throws [KeyMissing] when there is none. */
fun existing(): SecretKey = key()
}
/** The Keystore answered and holds no key: nothing sealed before can open again. */
class KeyMissing : java.security.GeneralSecurityException("no key")
/**
* Whether [e], from [open], means the file can never open: it does not authenticate (tampered,
* or sealed under another key), the key is permanently invalidated or gone, or the file is not
* an envelope. Anything else - the Keystore busy or erroring, an I/O error - may pass.
*/
fun isPermanent(e: Throwable): Boolean = e is javax.crypto.AEADBadTagException ||
e is android.security.keystore.KeyPermanentlyInvalidatedException ||
e is KeyMissing || e is org.json.JSONException || e is IllegalArgumentException
/**
* An AES-256 key in the AndroidKeyStore: not exportable, encrypt/decrypt with GCM only, no user
* authentication (background downloads and launch-time code fetches use it with the screen
* off). StrongBox where the device has it, the TEE otherwise.
*/
object Keystore : KeyProvider {
private const val ALIAS = "droiddeck-store-credentials"
private const val PROVIDER = "AndroidKeyStore"
@Synchronized
override fun existing(): SecretKey {
val ks = KeyStore.getInstance(PROVIDER).apply { load(null) }
return ks.getKey(ALIAS, null) as? SecretKey ?: throw KeyMissing()
}
@Synchronized
override fun key(): SecretKey {
val ks = KeyStore.getInstance(PROVIDER).apply { load(null) }
(ks.getKey(ALIAS, null) as? SecretKey)?.let { return it }
if (Build.VERSION.SDK_INT >= 28) {
try { return generate(strongBox = true) } catch (e: Exception) { /* no StrongBox: the TEE */ }
}
return generate(strongBox = false)
}
private fun generate(strongBox: Boolean): SecretKey {
val spec = KeyGenParameterSpec.Builder(ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
.setKeySize(256)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setUserAuthenticationRequired(false)
.apply { if (strongBox && Build.VERSION.SDK_INT >= 28) setIsStrongBoxBacked(true) }
.build()
return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, PROVIDER).run { init(spec); generateKey() }
}
}
/** [plain] sealed into the envelope. The cipher picks the IV (the Keystore requires it). */
fun seal(plain: String, keys: KeyProvider): JSONObject {
val cipher = Cipher.getInstance(TRANSFORMATION).apply { init(Cipher.ENCRYPT_MODE, keys.key()) }
val ct = cipher.doFinal(plain.toByteArray(Charsets.UTF_8))
return JSONObject().put("v", VERSION).put("alg", ALG)
.put("iv", b64(cipher.iv)).put("ct", b64(ct))
}
/** The text inside [envelope]. Throws when it is not ours, of another version, or does not authenticate. */
fun open(envelope: JSONObject, keys: KeyProvider): String {
require(envelope.optInt("v", -1) == VERSION && envelope.optString("alg") == ALG) { "unknown envelope" }
val iv = Base64.decode(envelope.getString("iv"), Base64.NO_WRAP)
val ct = Base64.decode(envelope.getString("ct"), Base64.NO_WRAP)
val cipher = Cipher.getInstance(TRANSFORMATION).apply { init(Cipher.DECRYPT_MODE, keys.existing(), GCMParameterSpec(TAG_BITS, iv)) }
return String(cipher.doFinal(ct), Charsets.UTF_8)
}
/** Whether [json] is an envelope rather than plain credentials. */
fun isEnvelope(json: JSONObject): Boolean = json.has("v") && json.has("ct") && json.has("iv")
private fun b64(bytes: ByteArray) = Base64.encodeToString(bytes, Base64.NO_WRAP)
}
@@ -0,0 +1,55 @@
package com.droiddeck.launcher.stores
import android.net.Uri
import android.util.Log
import android.webkit.WebView
import com.droiddeck.launcher.stores.gog.GogAuth
/** The three stores' sign-in flows for [StoreLoginActivity]. */
internal object LoginFlows {
private const val TAG = "StoreLogin"
fun forStore(store: Store, state: String): StoreLoginActivity.Flow? = when (store) {
Store.GOG -> GogFlow(state)
Store.EPIC -> epicFlow()
Store.AMAZON -> amazonFlow()
}
/** Set by the Epic and Amazon backends when they are in the build; null keeps the chip's card honest. */
@Volatile var epicFlow: () -> StoreLoginActivity.Flow? = { null }
@Volatile var amazonFlow: () -> StoreLoginActivity.Flow? = { null }
/**
* GOG's implicit grant: the token arrives in the redirect URL's fragment. The CSRF state is
* checked only when GOG echoes one back - its social-login path returns none, and treating
* that as a mismatch threw away good tokens.
*/
private class GogFlow(private val state: String) : StoreLoginActivity.Flow {
override val startUrl: String get() = GogAuth.authUrl(state)
override val userAgent: String get() = GogAuth.GALAXY_UA
override fun isRedirect(url: String): Boolean = url.startsWith(GogAuth.REDIRECT_PREFIX)
override fun finish(activity: StoreLoginActivity, view: WebView, url: String): String? {
val fragment = Uri.parse(url).fragment ?: return activity.getString(com.droiddeck.launcher.R.string.stores_login_error_verification)
val frag = Uri.parse("x://x?$fragment")
val returned = frag.getQueryParameter("state")
if (returned != null && returned != state) {
Log.w(TAG, "gog: state mismatch; redirect rejected")
return activity.getString(com.droiddeck.launcher.R.string.stores_login_error_verification)
}
val access = frag.getQueryParameter("access_token")
if (access.isNullOrEmpty()) {
// The keys only, never the values: the fragment carries live tokens.
Log.w(TAG, "gog: redirect without access_token; keys=${frag.queryParameterNames}")
return activity.getString(com.droiddeck.launcher.R.string.stores_login_error_verification)
}
return try {
GogAuth.saveLogin(activity, access, frag.getQueryParameter("refresh_token"), frag.getQueryParameter("user_id"))
null
} catch (e: Exception) {
Log.w(TAG, "gog: saving the sign-in failed: ${e.javaClass.simpleName}")
activity.getString(com.droiddeck.launcher.R.string.stores_login_error_generic)
}
}
}
}
@@ -0,0 +1,46 @@
package com.droiddeck.launcher.stores
import java.io.File
import org.json.JSONObject
/**
* Game sizes as found, kept per game AND the version they were found for, in
* `filesDir/stores/<store>/sizes.json`: a size is never fetched twice for one version, and a
* new version (a library refresh brings it) simply misses and is looked up again. The file carries
* its format; one in another format is discarded, not read.
*/
class SizeCache(private val file: File) {
private val sizes = HashMap<String, Pair<String, Long>>()
init {
runCatching {
val o = JSONObject(file.readText())
if (o.optInt("format") == FORMAT) {
val s = o.getJSONObject("sizes")
for (k in s.keys()) s.optJSONObject(k)?.let { e -> sizes[k] = e.optString("v") to e.optLong("b") }
}
}
}
@Synchronized
fun get(id: String, version: String): Long? = sizes[id]?.takeIf { it.first == version && it.second > 0 }?.second
@Synchronized
fun put(id: String, version: String, bytes: Long) {
if (bytes <= 0) return
sizes[id] = version to bytes
runCatching {
file.parentFile?.mkdirs()
val s = JSONObject()
for ((k, v) in sizes) s.put(k, JSONObject().put("v", v.first).put("b", v.second))
val tmp = File(file.path + ".tmp")
tmp.writeText(JSONObject().put("format", FORMAT).put("sizes", s).toString())
tmp.renameTo(file)
}
}
companion object {
const val FORMAT = 1
fun forStore(filesDir: File, store: Store) = SizeCache(File(filesDir, "stores/${store.id}/sizes.json"))
}
}
@@ -0,0 +1,187 @@
package com.droiddeck.launcher.stores
import android.content.Context
import android.util.Base64
import android.util.Log
import com.droiddeck.launcher.frontend.AddedGames
import com.droiddeck.launcher.frontend.Library
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.session.SessionService
import com.droiddeck.launcher.session.SessionState
import org.json.JSONArray
import org.json.JSONObject
import java.io.ByteArrayOutputStream
import java.io.DataInputStream
import java.io.File
import java.io.InputStream
import java.io.OutputStream
import java.net.HttpURLConnection
import java.net.Socket
import java.net.URL
import java.security.SecureRandom
/**
* Adds and removes a store game's shortcut in a Steam client that is running right now, so it
* shows without waiting for the next client start. The runtime starts the client with its
* DevTools port open (droiddeck-session writes it to session/agent/cdp-port) and the client's own
* JavaScript API has `SteamClient.Apps.AddShortcut`; this speaks the small part of the DevTools
* protocol that one `Runtime.evaluate` needs, over a plain socket, nothing else.
*
* Best-effort throughout: no session, no port, no SharedJSContext, or an API that moved and the
* shortcut simply arrives with the shortcuts writer at the next start. The appid Steam derives
* (CRC of the quoted exe plus the name) is the one the writer computes, so the two never disagree.
*/
object SteamLiveShortcuts {
private const val TAG = "SteamLive"
private const val TAG_NAME = "droiddeck-app"
private const val COMPAT_TOOL = "droiddeck-proton-arm64"
/** Adds every store game in [games] the client does not list yet. Blocking; worker thread. */
fun sync(context: Context, games: List<AddedGames.Game>) {
val wanted = games.filter { it.source != Library.ADDED }
if (wanted.isEmpty() || !clientRunning()) return
val list = JSONArray()
for (g in wanted) list.put(JSONObject().put("name", g.name).put("exe", g.guestExe).put("dir", g.guestDir))
val js = """
(async () => {
const want = ${list};
const have = await SteamClient.Apps.GetAllShortcuts();
const strip = s => (s || "").replace(/^"|"$/g, "");
let added = 0;
for (const w of want) {
if (have.some(a => a.data && strip(a.data.strExePath) === w.exe && a.data.strAppName === w.name)) continue;
const id = await SteamClient.Apps.AddShortcut(w.name, w.exe, w.dir, "");
if (!id) continue;
try { SteamClient.Apps.AddUserTagToApps([id], ${JSONObject.quote(TAG_NAME)}); } catch (e) {}
try { SteamClient.Apps.SpecifyCompatTool(id, ${JSONObject.quote(COMPAT_TOOL)}); } catch (e) {}
added++;
}
return added;
})()
""".trimIndent()
val result = evaluate(context, js) ?: return
Log.i(TAG, "live shortcuts: $result added")
if (result != "0") StoresState.logLine("Steam: $result shortcut(s) added to the running client")
}
/** Removes the shortcut with [appId] from the running client. Blocking; worker thread. */
fun remove(context: Context, appId: Long) {
if (!clientRunning()) return
val result = evaluate(context, "(async () => { await SteamClient.Apps.RemoveShortcut(${appId and 0xFFFFFFFFL}); return 1; })()")
if (result != null) Log.i(TAG, "live shortcuts: removed $appId")
}
private fun clientRunning(): Boolean = SessionState.running && SessionState.mode == SessionService.MODE_STEAM && !SessionState.stopRequested
/** `Runtime.evaluate` of [expression] in the client's SharedJSContext; the value as text, or null. */
private fun evaluate(context: Context, expression: String): String? {
val portFile = File(LinuxRuntime.sessionRoot(context), "agent/cdp-port")
val port = runCatching { portFile.readText().trim().toInt() }.getOrNull() ?: return null
val targets = runCatching { JSONArray(get("http://127.0.0.1:$port/json")) }.getOrNull() ?: return null
var wsUrl: String? = null
for (i in 0 until targets.length()) {
val t = targets.optJSONObject(i) ?: continue
if (t.optString("title").contains("SharedJSContext") || t.optString("url").contains("SharedJSContext")) { wsUrl = t.optString("webSocketDebuggerUrl").ifEmpty { null }; break }
}
if (wsUrl == null) { Log.w(TAG, "no SharedJSContext target"); return null }
return try {
WebSocket(wsUrl, 8000).use { ws ->
ws.sendText(JSONObject().put("id", 1).put("method", "Runtime.evaluate").put("params", JSONObject()
.put("expression", expression).put("returnByValue", true).put("awaitPromise", true).put("userGesture", true)).toString())
val deadline = System.currentTimeMillis() + 15_000
while (System.currentTimeMillis() < deadline) {
val message = JSONObject(ws.receiveText() ?: return null)
if (message.optInt("id") != 1) continue
if (message.has("error")) { Log.w(TAG, "cdp: ${message.optJSONObject("error")}"); return null }
val result = message.optJSONObject("result") ?: return null
result.optJSONObject("exceptionDetails")?.let { Log.w(TAG, "js: ${it.optJSONObject("exception")?.optString("description") ?: it.optString("text")}"); return null }
return result.optJSONObject("result")?.opt("value")?.toString()
}
null
}
} catch (e: Exception) {
Log.w(TAG, "cdp: ${e.javaClass.simpleName}: ${e.message}")
null
}
}
private fun get(url: String): String {
val c = URL(url).openConnection() as HttpURLConnection
c.connectTimeout = 3000; c.readTimeout = 3000
try { return c.inputStream.bufferedReader().readText() } finally { c.disconnect() }
}
/** The least of RFC 6455 a localhost DevTools talk needs: upgrade, masked text out, text in. */
private class WebSocket(url: String, timeoutMs: Int) : AutoCloseable {
private val socket: Socket
private val out: OutputStream
private val input: DataInputStream
init {
val u = URL(url.replaceFirst("ws://", "http://"))
socket = Socket(u.host, if (u.port > 0) u.port else 80).apply { soTimeout = timeoutMs }
out = socket.getOutputStream()
input = DataInputStream(socket.getInputStream())
val key = ByteArray(16).also { SecureRandom().nextBytes(it) }
val path = u.path.ifEmpty { "/" } + (u.query?.let { "?$it" } ?: "")
out.write(("GET $path HTTP/1.1\r\nHost: ${u.host}:${u.port}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" +
"Sec-WebSocket-Key: ${Base64.encodeToString(key, Base64.NO_WRAP)}\r\nSec-WebSocket-Version: 13\r\n\r\n").toByteArray())
out.flush()
val status = readLine(input) ?: throw java.io.IOException("no upgrade response")
if (!status.contains(" 101 ")) throw java.io.IOException("upgrade refused: $status")
while (true) { val line = readLine(input) ?: break; if (line.isEmpty()) break }
}
private fun readLine(s: InputStream): String? {
val buf = ByteArrayOutputStream()
while (true) {
val b = s.read()
if (b < 0) return if (buf.size() == 0) null else buf.toString("UTF-8")
if (b == '\n'.code) return buf.toString("UTF-8").trimEnd('\r')
buf.write(b)
}
}
fun sendText(text: String) {
val payload = text.toByteArray()
val mask = ByteArray(4).also { SecureRandom().nextBytes(it) }
val frame = ByteArrayOutputStream()
frame.write(0x81)
when {
payload.size < 126 -> frame.write(0x80 or payload.size)
payload.size < 65536 -> { frame.write(0x80 or 126); frame.write(payload.size ushr 8); frame.write(payload.size and 0xFF) }
else -> { frame.write(0x80 or 127); for (i in 7 downTo 0) frame.write(((payload.size.toLong() ushr (8 * i)) and 0xFF).toInt()) }
}
frame.write(mask)
for (i in payload.indices) frame.write((payload[i].toInt() xor mask[i % 4].toInt()) and 0xFF)
out.write(frame.toByteArray()); out.flush()
}
/** The next text message (fragments joined); null on close. */
fun receiveText(): String? {
val message = ByteArrayOutputStream()
while (true) {
val b0 = input.read(); if (b0 < 0) return null
val b1 = input.read(); if (b1 < 0) return null
val fin = b0 and 0x80 != 0
val opcode = b0 and 0x0F
var len = (b1 and 0x7F).toLong()
if (len == 126L) len = input.readUnsignedShort().toLong()
else if (len == 127L) len = input.readLong()
val masked = b1 and 0x80 != 0
val mask = if (masked) ByteArray(4).also { input.readFully(it) } else null
if (len > 16L * 1024 * 1024) throw java.io.IOException("frame too large")
val data = ByteArray(len.toInt()).also { input.readFully(it) }
if (mask != null) for (i in data.indices) data[i] = (data[i].toInt() xor mask[i % 4].toInt()).toByte()
when (opcode) {
0x8 -> return null
0x9 -> { out.write(byteArrayOf(0x8A.toByte(), 0x80.toByte(), 0, 0, 0, 0)); out.flush() }
0x1, 0x0 -> { message.write(data); if (fin) return message.toString("UTF-8") }
else -> {}
}
}
}
override fun close() { runCatching { out.write(byteArrayOf(0x88.toByte(), 0x80.toByte(), 0, 0, 0, 0)); out.flush() }; runCatching { socket.close() } }
}
}
@@ -0,0 +1,15 @@
package com.droiddeck.launcher.stores
/**
* The three storefronts the Stores section talks to. [id] is what the sidecar, the prefs file
* names and the Games badge carry; it never changes once a game is installed under it.
*/
enum class Store(val id: String, val label: String, val shortLabel: String) {
GOG("gog", "GOG", "GOG"),
EPIC("epic", "Epic Games", "Epic"),
AMAZON("amazon", "Amazon Games", "Amazon");
companion object {
fun byId(id: String?): Store? = entries.firstOrNull { it.id == id }
}
}
@@ -0,0 +1,156 @@
package com.droiddeck.launcher.stores
import android.content.Context
import android.util.Log
import androidx.annotation.VisibleForTesting
import org.json.JSONObject
import java.io.File
/**
* Each store's sign-in, kept in the app's private files: `filesDir/stores/<store>/credentials.json`.
* Tokens never go to shared storage and never into a log; the file holds whatever the store's auth
* flow handed back (access and refresh tokens, expiry, the account's display name, and for Amazon
* the device registration), as one JSON object the store's own client reads and writes - sealed
* with [CredentialCipher] under a key in the AndroidKeyStore. Every read and write of a sign-in
* goes through here.
*
* - A plain file an earlier build wrote is sealed the first time it is read ([encryptAll] at app
* start), verified by opening it again, and the plain text is replaced.
* - Where the Keystore does not work (some ROMs), the file stays plain, the log says so once, and
* the next start tries again.
* - A sealed file that can never open again (the key is gone: data cleared, a backup restored on
* another device) is deleted and the store reads as signed out; one that fails for a reason that
* may pass is kept and tried again (see [read]).
*/
object StoreAccounts {
private const val TAG = "StoreAccounts"
private const val FILE = "credentials.json"
@VisibleForTesting
@Volatile
internal var keys: CredentialCipher.KeyProvider = CredentialCipher.Keystore
/** The Keystore failed once in this process: files stay plain until the next start tries again. */
@VisibleForTesting
@Volatile
internal var keystoreFailed = false
/** The store's private folder: credentials, caches, the engine's scratch. */
fun dir(context: Context, store: Store): File = File(File(context.filesDir, "stores"), store.id).apply { mkdirs() }
fun credentialsFile(context: Context, store: Store): File = File(dir(context, store), FILE)
/** Stores whose sealed sign-in could not be opened this time for a reason that may pass (see [read]). */
private val unavailable = java.util.concurrent.ConcurrentHashMap.newKeySet<Store>()
@Volatile
private var unavailableLogged = false
/**
* The stored credentials, or null when signed out or unreadable (never logged: the text is the
* secret).
*
* A sealed file is deleted - the store signs in again - only when it can never open: it does
* not authenticate (tampered, or another key: data cleared, restored on another device), the
* key is permanently invalidated or gone from the Keystore, or it is not an envelope at all. Any
* other failure (the Keystore busy or erroring, an I/O error) keeps the file, marks the store
* [isUnavailable] - signed in, its actions failing for now - and the next read tries again.
*/
@Synchronized
fun read(context: Context, store: Store): JSONObject? {
val f = credentialsFile(context, store)
if (!f.isFile) { unavailable.remove(store); return null }
val text = try { f.readText() } catch (e: Exception) { return markUnavailable(store, e) }
val json = try { JSONObject(text) } catch (e: Exception) { return unreadable(f, store, e) }
if (!CredentialCipher.isEnvelope(json)) {
unavailable.remove(store)
seal(f, json)?.let { Log.i(TAG, "stores: credentials encrypted ${store.id}") }
return json
}
return try {
JSONObject(CredentialCipher.open(json, keys)).also { unavailable.remove(store) }
} catch (e: Exception) {
if (CredentialCipher.isPermanent(e)) unreadable(f, store, e) else markUnavailable(store, e)
}
}
/** The store's sign-in is on disk but could not be opened this time (see [read]). */
fun isUnavailable(store: Store): Boolean = store in unavailable
private fun unreadable(f: File, store: Store, e: Exception): JSONObject? {
Log.w(TAG, "stores: credentials for ${store.id} cannot be opened (${e.javaClass.simpleName}); signed out")
f.delete()
unavailable.remove(store)
return null
}
private fun markUnavailable(store: Store, e: Exception): JSONObject? {
unavailable.add(store)
if (!unavailableLogged) {
unavailableLogged = true
Log.w(TAG, "stores: credentials unreadable this start (${e.javaClass.simpleName})")
}
return null
}
@Synchronized
fun write(context: Context, store: Store, json: JSONObject) {
unavailable.remove(store)
val f = credentialsFile(context, store)
if (seal(f, json) == null) put(f, json.toString())
}
@Synchronized
fun clear(context: Context, store: Store) {
unavailable.remove(store)
credentialsFile(context, store).delete()
File(credentialsFile(context, store).path + ".tmp").delete()
}
/** At app start: every plain sign-in an earlier build left is sealed now. */
fun encryptAll(context: Context) {
Store.entries.forEach { store ->
// An older build's temp file held the plain text too.
File(credentialsFile(context, store).path + ".tmp").delete()
read(context, store)
}
}
/**
* The account's display name when signed in (the store's own name for it, or a stand-in), else
* null. A sign-in that could not be opened this time ([isUnavailable]) still counts, under the
* store's name, so the sign-in card does not ask for what is still there.
*/
fun signedInAs(context: Context, store: Store): String? {
val json = read(context, store) ?: return if (isUnavailable(store)) store.label else null
if (json.optString("access_token", "").isEmpty()) return null
return json.optString("display_name", "").ifEmpty { json.optString("username", "") }.ifEmpty { store.label }
}
/**
* [json] sealed into [f], checked by opening what was written. Null - and [f] as it was - when
* the Keystore cannot seal or the check fails.
*/
private fun seal(f: File, json: JSONObject): File? {
if (keystoreFailed) return null
val text = json.toString()
val envelope = try {
CredentialCipher.seal(text, keys).also { check(CredentialCipher.open(it, keys) == text) { "check failed" } }
} catch (e: Exception) {
keystoreFailed = true
Log.w(TAG, "stores: keystore unavailable, credentials stay plain (${e.javaClass.simpleName})")
return null
}
put(f, envelope.toString())
return f
}
/** [text] into [f] through a temp file and a rename, readable and writable by the app alone. */
private fun put(f: File, text: String) {
val tmp = File(f.path + ".tmp")
tmp.writeText(text)
tmp.setReadable(false, false); tmp.setReadable(true, true)
tmp.setWritable(false, false); tmp.setWritable(true, true)
if (!tmp.renameTo(f)) { f.delete(); tmp.renameTo(f) }
}
}
@@ -0,0 +1,63 @@
package com.droiddeck.launcher.stores
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.Color
import android.util.Log
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
/**
* The store's art, placed in the game's folder under the names AddedGameArt already looks for -
* `cover.jpg` (the portrait capsule), `hero.jpg` and `header.jpg` (the wide art) - so a store game
* shows real art in the Games tab and the session's shortcuts writer copies it into the client's
* grid. Each image is decoded and re-encoded as an opaque JPEG over a dark ground: a store's PNG
* with an alpha fade would otherwise end in white, which is what the washed-out cards were.
*/
object StoreArt {
private const val TAG = "StoreArt"
/** Fetches what the sidecar names; a missing or unreadable image is skipped, never an error. Blocking. */
fun fetchInto(folder: File, sidecar: StoreGameSidecar) {
sidecar.cover?.let { place(it, File(folder, "cover.jpg")) }
val wide = sidecar.hero ?: return
if (place(wide, File(folder, "hero.jpg"))) {
val header = File(folder, "header.jpg")
if (!header.isFile) runCatching { File(folder, "hero.jpg").copyTo(header, overwrite = true) }
}
}
private fun place(url: String, dst: File): Boolean {
if (dst.isFile && dst.length() > 0) return true
val bytes = get(url) ?: return false
val bitmap = runCatching { BitmapFactory.decodeByteArray(bytes, 0, bytes.size) }.getOrNull() ?: return false
val opaque = Bitmap.createBitmap(bitmap.width, bitmap.height, Bitmap.Config.ARGB_8888)
Canvas(opaque).apply { drawColor(Color.rgb(0x12, 0x14, 0x17)); drawBitmap(bitmap, 0f, 0f, null) }
val tmp = File(dst.path + ".tmp")
return try {
tmp.outputStream().use { opaque.compress(Bitmap.CompressFormat.JPEG, 90, it) }
tmp.renameTo(dst)
} catch (e: Exception) {
Log.w(TAG, "${dst.name}: ${e.javaClass.simpleName}")
tmp.delete()
false
} finally {
bitmap.recycle(); opaque.recycle()
}
}
private fun get(url: String): ByteArray? {
var c: HttpURLConnection? = null
return try {
c = (URL(url).openConnection() as HttpURLConnection).apply { connectTimeout = 10_000; readTimeout = 20_000; setRequestProperty("User-Agent", StoreNet.BROWSER_UA) }
if (c.responseCode != 200) null else c.inputStream.use { it.readBytes() }
} catch (e: Exception) {
Log.w(TAG, "${StoreLog.redactUrl(url)}: ${e.javaClass.simpleName}")
null
} finally {
c?.disconnect()
}
}
}
@@ -0,0 +1,135 @@
package com.droiddeck.launcher.stores
/**
* One title as the Stores page draws it, whichever store produced it: a card on a shelf, a tile
* in a grid, the hero of a game page. Library games and storefront results both project into
* this, so one card composable serves every tab.
*/
data class CatalogItem(
val store: Store,
/** Store-native id: GOG product id, Epic catalog item id (offer id for storefront results), Amazon product id. */
val id: String,
val title: String,
/** Wide (16:9-ish) art for cards and heroes. */
val imageUrl: String?,
/** Tall (2:3) box art when the store publishes one; falls back to [imageUrl]. */
val tallImageUrl: String? = null,
/** Comma-joined genre / tag line. */
val tags: String = "",
val isFree: Boolean = false,
/** False when the endpoint gave no price at all: the price row then draws nothing. */
val hasPrice: Boolean = false,
/** Pre-formatted by the store ("$9.99"). */
val finalPrice: String = "",
val originalPrice: String = "",
val discountPercent: Int = 0,
/** The web product page: where "Get for free" and "View on <store>" land. */
val storeUrl: String = "",
val developer: String = "",
val releaseDate: String = "",
val description: String = "",
/** True for a title the signed-in account owns (a library entry). */
val owned: Boolean = false,
/** The install size in bytes when known, else 0. */
val sizeBytes: Long = 0L,
/** Per-store identifiers the install needs (Epic namespace / catalog id / app name, Amazon entitlement / sku). */
val extra: Map<String, String> = emptyMap(),
/** Rated or tagged for adults by the store's own data; hidden from the storefront unless Show mature content is on. */
val mature: Boolean = false,
) {
val isDiscounted: Boolean get() = discountPercent > 0 && originalPrice.isNotBlank()
/** Stable key across stores ("gog:1207658924"). */
val key: String get() = "${store.id}:$id"
}
/** A store's storefront shelves: what the Store tab shows above the account's own library. */
class StoreShelves(
val whatsNew: List<CatalogItem> = emptyList(),
val deals: List<CatalogItem> = emptyList(),
val free: List<CatalogItem> = emptyList(),
val trending: List<CatalogItem> = emptyList(),
) {
val isEmpty: Boolean get() = whatsNew.isEmpty() && deals.isEmpty() && free.isEmpty() && trending.isEmpty()
val all: List<CatalogItem> get() = (whatsNew + deals + free + trending).distinctBy { it.id }
}
/** A game a store installed, as found on disk through its sidecar. */
/** Store tags that mark adult content, by slug or name, lowercased. */
private val MATURE_TAGS = setOf("mature", "nsfw", "adult", "adult only", "adults only", "nudity", "sexual content", "sexual-content")
/** Whether a store's own tags mark a title as adult. */
fun matureByTags(tags: Collection<String>): Boolean = tags.any { it.trim().lowercase() in MATURE_TAGS }
/**
* Whether a store's own age ratings mark a title as adult: ESRB M (17) or AO (18), PEGI / USK / the
* store's own rating 18, or any rating system's 17+. Ratings are ages, as GOG gives them.
*/
fun matureByAge(ages: Collection<Int>): Boolean = ages.any { it >= 17 }
/**
* The Installed tab's cards for [store], from the installs on disk - the same source as its count:
* each joined to its library item by store id (by title when the ids differ), else a card made
* from the sidecar's own title and art, so an install shows even before the library has loaded.
*/
fun installedCards(store: Store, installed: List<InstalledStoreGame>, library: List<CatalogItem>): List<CatalogItem> {
val byId = library.associateBy { it.id }
val byTitle = library.associateBy { it.title.lowercase() }
return installed.filter { it.sidecar.store == store }.map { game ->
val s = game.sidecar
byId[s.id] ?: byTitle[s.title.lowercase()]
?: CatalogItem(store, s.id, s.title, imageUrl = s.hero ?: s.cover, tallImageUrl = s.cover ?: s.hero, owned = true)
}.distinctBy { it.id }.sortedBy { it.title.lowercase() }
}
/**
* A refreshed list with every unchanged item kept as the very object already shown, so a refresh
* that lands while the grid is on screen recomposes only the cards that changed - no reset, the
* scroll position and the pad's focus stay.
*/
fun mergeItems(old: List<CatalogItem>?, new: List<CatalogItem>): List<CatalogItem> {
if (old.isNullOrEmpty()) return new
val before = old.associateBy { it.key }
val merged = new.map { n -> before[n.key]?.takeIf { it == n } ?: n }
return if (merged.size == old.size && merged.indices.all { merged[it] === old[it] }) old else merged
}
class InstalledStoreGame(val sidecar: StoreGameSidecar, val folder: java.io.File) {
val key: String get() = "${sidecar.store.id}:${sidecar.id}"
}
private val TEMPLATE_KEY = Regex("product_(description|feature)_\\d+")
private val HTML_TAG = Regex("<[^>]+>")
private val ENTITY = Regex("&(#\\d+|#x[0-9a-fA-F]+|[a-zA-Z]+);")
private val NAMED_ENTITIES = mapOf("amp" to "&", "lt" to "<", "gt" to ">", "quot" to "\"", "apos" to "'", "nbsp" to " ", "ndash" to "–", "mdash" to "—", "hellip" to "…", "trade" to "™", "reg" to "®", "copy" to "©")
/**
* Store copy as plain text: GOG's account endpoints return template keys
* (`product_description_2015545325`) for some products instead of words, and every store's
* descriptions carry HTML. Those keys go, tags go, entities are decoded, whitespace is collapsed;
* "" when nothing readable is left, so a page shows nothing rather than a placeholder.
*/
fun cleanStoreText(raw: String?): String {
if (raw.isNullOrBlank()) return ""
var s = raw.replace(TEMPLATE_KEY, " ")
s = s.replace(Regex("(?i)<br\\s*/?>|</p>|</li>|</div>"), "\n").replace(HTML_TAG, " ")
s = ENTITY.replace(s) { m ->
val e = m.groupValues[1]
when {
e.startsWith("#x") -> e.substring(2).toIntOrNull(16)?.toChar()?.toString() ?: " "
e.startsWith("#") -> e.substring(1).toIntOrNull()?.toChar()?.toString() ?: " "
else -> NAMED_ENTITIES[e.lowercase()] ?: " "
}
}
return s.lines().map { it.replace(Regex("[ \\t\\u00A0]+"), " ").trim() }.filter { it.isNotEmpty() }.joinToString("\n")
}
/** Human sizes the way the Downloads page and the cards print them ("810.2 MB", "3.9 GB"). */
fun formatBytes(bytes: Long): String = when {
bytes >= 1_073_741_824L -> "%.1f GB".format(bytes / 1_073_741_824.0)
bytes >= 1_048_576L -> "%.1f MB".format(bytes / 1_048_576.0)
bytes >= 1024L -> "%.0f KB".format(bytes / 1024.0)
bytes >= 0L -> "$bytes B"
else -> ""
}
fun formatSpeed(bytesPerSec: Long): String = if (bytesPerSec <= 0L) "" else "${formatBytes(bytesPerSec)}/s"
@@ -0,0 +1,44 @@
package com.droiddeck.launcher.stores
import java.io.File
/**
* Which .exe in an install folder is the game. The store's own hint (a manifest's launch
* executable) wins when the file exists; else the candidates, helpers and installers left out,
* the one named most like the title first, then the shallowest. Relative to the folder, forward
* slashes; "" when the folder has none.
*/
object StoreExe {
private val SKIP = listOf("redist", "unins", "setup", "crash", "report", "helper", "dotnet", "vcredist", "directx", "eac", "easyanticheat", "battleye", "launcher", "updater", "config")
fun pick(folder: File, hint: String?, title: String): String {
val hinted = hint?.takeIf { it.isNotBlank() }?.replace('\\', '/')?.trimStart('/')?.let { File(folder, it) }
if (hinted != null && hinted.isFile) return relative(folder, hinted)
val candidates = ArrayList<File>()
collect(folder, candidates, 0)
if (candidates.isEmpty()) return ""
val key = title.lowercase().replace(Regex("[^a-z0-9]"), "")
val best = candidates.firstOrNull { f ->
val n = f.nameWithoutExtension.lowercase().replace(Regex("[^a-z0-9]"), "")
n.isNotEmpty() && (n == key || (key.length >= 4 && key.startsWith(n)) || (n.length >= 4 && n.startsWith(key)))
} ?: candidates.sortedWith(compareBy<File> { depth(folder, it) }.thenByDescending { it.length() }).first()
return relative(folder, best)
}
private fun collect(dir: File, out: MutableList<File>, depth: Int) {
if (depth > 4) return
val files = dir.listFiles() ?: return
for (f in files) {
if (f.isFile && f.name.endsWith(".exe", ignoreCase = true)) {
val lower = f.name.lowercase()
if (SKIP.none { lower.contains(it) }) out.add(f)
}
}
for (f in files) if (f.isDirectory && !f.name.startsWith(".")) collect(f, out, depth + 1)
}
private fun depth(root: File, f: File): Int = relative(root, f).count { it == '/' }
fun relative(root: File, f: File): String =
f.absolutePath.removePrefix(root.absolutePath).trimStart('/').replace('\\', '/')
}
@@ -0,0 +1,165 @@
package com.droiddeck.launcher.stores
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
/**
* What a store install leaves beside the game so the rest of the app can treat the folder like
* any added game: `.droiddeck-store.json` in the game's folder.
*
* [AddedGames.scanGame] reads it to pick the exe (instead of guessing from the folder), to name
* the shortcut after the store's title and to tag the game with its [store]. [launcher] is the
* small .bat the install writes when the game needs arguments or environment at launch (Epic's
* identity arguments, Amazon's FuelPump variables): a Steam shortcut can only carry a fixed Exe,
* so the launcher is what the shortcut runs and [exe] is what it starts. Every store install is a
* Steam shortcut; there is no switch for it (an `addToSteam` field in an older sidecar is ignored).
*
* Paths are relative to the game folder with forward slashes, so the folder can move between
* storage volumes and stay valid.
*
* An install writes the sidecar twice: at its start with [state] `installing` (store, id, title -
* no exe yet), and at its end with the exe, the launcher and `installed`. A folder whose sidecar
* still says `installing` is an unfinished install, never a game: the scan leaves it out and the
* Stores page offers to resume it. A sidecar without the field (an earlier build) is installed.
*/
class StoreGameSidecar(
val store: Store,
val id: String,
val title: String,
val exe: String,
val launcher: String? = null,
val args: List<String> = emptyList(),
val env: Map<String, String> = emptyMap(),
val installVersion: String = "",
val installedAt: Long = 0L,
val cover: String? = null,
val hero: String? = null,
/** Per-store identifiers the launcher needs again (Epic namespace / catalog id, Amazon entitlement). */
val extra: Map<String, String> = emptyMap(),
val state: String = STATE_INSTALLED,
/** An Epic game's launch choices (the Games tab's Epic card); both launch paths read them from here. */
val epic: EpicOptions = EpicOptions(),
/** GOG / Epic cloud saves for this game: down before a launch, up after it (the game card's switch). */
val cloud: Boolean = true,
) {
val isInstalled: Boolean get() = state == STATE_INSTALLED
fun exeFile(folder: File): File = File(folder, exe)
fun launcherFile(folder: File): File? = launcher?.takeIf { it.isNotEmpty() }?.let { File(folder, it) }
fun copy(
exe: String = this.exe, launcher: String? = this.launcher, args: List<String> = this.args,
env: Map<String, String> = this.env, installVersion: String = this.installVersion,
installedAt: Long = this.installedAt, cover: String? = this.cover, hero: String? = this.hero,
extra: Map<String, String> = this.extra, state: String = this.state, epic: EpicOptions = this.epic, cloud: Boolean = this.cloud,
) = StoreGameSidecar(store, id, title, exe, launcher, args, env, installVersion, installedAt, cover, hero, extra, state, epic, cloud)
fun toJson(): JSONObject = JSONObject().apply {
put("version", VERSION)
put("store", store.id)
put("id", id)
put("title", title)
if (exe.isNotEmpty()) put("exe", exe)
if (state != STATE_INSTALLED) put("state", state)
if (store == Store.EPIC) put("epic", JSONObject().put("eos", epic.eos).put("offline", epic.offline))
if (store != Store.AMAZON) put("cloud", cloud)
if (!launcher.isNullOrEmpty()) put("launcher", launcher)
if (args.isNotEmpty()) put("args", JSONArray(args))
if (env.isNotEmpty()) put("env", JSONObject(env as Map<*, *>))
if (installVersion.isNotEmpty()) put("installVersion", installVersion)
if (installedAt > 0L) put("installedAt", installedAt)
if (!cover.isNullOrEmpty()) put("cover", cover)
if (!hero.isNullOrEmpty()) put("hero", hero)
if (extra.isNotEmpty()) put("extra", JSONObject(extra as Map<*, *>))
}
fun write(folder: File) {
folder.mkdirs()
val file = File(folder, FILE_NAME)
val tmp = File(folder, "$FILE_NAME.tmp")
tmp.writeText(toJson().toString(2))
if (!tmp.renameTo(file)) { file.delete(); tmp.renameTo(file) }
}
companion object {
const val FILE_NAME = ".droiddeck-store.json"
const val VERSION = 1
const val STATE_INSTALLED = "installed"
const val STATE_INSTALLING = "installing"
fun file(folder: File): File = File(folder, FILE_NAME)
/**
* Changes an Epic game's launch choices on disk: reads the sidecar fresh (never a copy held
* by a screen), applies [change], writes it, and reads it back. The written sidecar, or null
* when there is none or the write did not take. Blocking.
*/
fun updateEpic(folder: File, change: (EpicOptions) -> EpicOptions): StoreGameSidecar? {
val current = read(folder) ?: return null
val next = current.copy(epic = change(current.epic))
next.write(folder)
return read(folder)?.takeIf { it.epic == next.epic }
}
/** As [updateEpic], for the cloud-saves switch. */
fun updateCloud(folder: File, on: Boolean): StoreGameSidecar? {
val current = read(folder) ?: return null
current.copy(cloud = on).write(folder)
return read(folder)?.takeIf { it.cloud == on }
}
/** The sidecar in [folder], or null when there is none or it cannot be read. */
fun read(folder: File): StoreGameSidecar? {
val f = file(folder)
if (!f.isFile) return null
return runCatching { parse(f.readText()) }.getOrNull()
}
/** Pure parse; null for anything that is not a usable sidecar (so a scan falls back to guessing). */
fun parse(text: String): StoreGameSidecar? {
val o = runCatching { JSONObject(text) }.getOrNull() ?: return null
val store = Store.byId(o.optString("store")) ?: return null
val id = o.optString("id", "")
val title = o.optString("title", "")
val exe = o.optString("exe", "").replace('\\', '/')
val state = o.optString("state", STATE_INSTALLED).ifEmpty { STATE_INSTALLED }
if (id.isEmpty() || title.isEmpty()) return null
// An unfinished install has no exe yet; a finished one must name one.
if (exe.isEmpty() && state == STATE_INSTALLED) return null
// A relative path only: a sidecar that points outside its folder is not trusted.
if (exe.isNotEmpty() && !relativeInside(exe)) return null
val launcher = o.optString("launcher", "").replace('\\', '/').takeIf { it.isNotEmpty() && relativeInside(it) }
val args = o.optJSONArray("args")?.let { a -> List(a.length()) { a.optString(it) }.filter { it.isNotEmpty() } } ?: emptyList()
val env = LinkedHashMap<String, String>()
o.optJSONObject("env")?.let { e -> e.keys().forEach { k -> env[k] = e.optString(k) } }
val extra = LinkedHashMap<String, String>()
o.optJSONObject("extra")?.let { e -> e.keys().forEach { k -> extra[k] = e.optString(k) } }
return StoreGameSidecar(
store, id, title, exe, launcher, args, env,
installVersion = o.optString("installVersion", ""),
installedAt = o.optLong("installedAt", 0L),
cover = o.optString("cover", "").ifEmpty { null },
hero = o.optString("hero", "").ifEmpty { null },
extra = extra,
state = state,
// An "overlay" (or "v") field from an earlier build is read past: there is no overlay.
epic = o.optJSONObject("epic")?.let { e -> EpicOptions(eos = e.optBoolean("eos", true), offline = e.optBoolean("offline", false)) } ?: EpicOptions(),
cloud = o.optBoolean("cloud", true),
)
}
/** Relative, no drive letter, no step up: an absolute or escaping path is refused, not trimmed into shape. */
private fun relativeInside(path: String): Boolean =
path.isNotEmpty() && !path.startsWith("/") && !path.contains(":") && path.split('/').none { it == ".." || it.isEmpty() }
}
}
/**
* An Epic game's launch choices. [eos]: pass a fresh exchange code (Epic sign-in). [offline]: start
* with the Epic identity arguments only, no code - for a game that will not start when sign-in fails.
*/
data class EpicOptions(val eos: Boolean = true, val offline: Boolean = false) {
/** Whether a launch asks for a code at all. */
val wantsCode: Boolean get() = eos && !offline
}
@@ -0,0 +1,158 @@
package com.droiddeck.launcher.stores
import android.content.Context
import android.os.StatFs
import android.os.storage.StorageManager
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.session.GameStorage
import java.io.File
import java.util.zip.CRC32
/**
* Where store games are installed: `<root>/<Store>/<title>`.
*
* The default root is the app's own internal storage - the runtime's tree, where the folder sits at
* /root/Games/Stores and needs no bind - whatever the session's Game storage setting says. An SD
* card the app may write (its app folder, `GameStorage.options`) is offered as a second root at
* Install time and never chosen silently. Every root is scanned, so a game stays in the library
* wherever it went. A card that is the Steam library is already bound (`/mnt/droiddeck-sd`); any
* other card's root is bound at a fixed place of its own (`/mnt/droiddeck-stores/<volume>`), so a
* shortcut's path - and with it its appid - stays the same from one session to the next.
*/
object StoreInstallRoot {
private const val INTERNAL = "root/Games/Stores"
private const val ON_VOLUME = "Games"
const val GUEST_EXTERNAL = "/mnt/droiddeck-stores"
/** One place a game can be installed: the folder, its name for the dialog, and whether it is a card. */
class Target(val root: File, val label: String, val removable: Boolean) {
val freeBytes: Long get() = try { StatFs((if (root.isDirectory) root else root.parentFile ?: root).path).availableBytes } catch (e: Exception) { 0L }
}
/** The internal root: always there, always the default. */
fun internalRoot(context: Context): File = File(LinuxRuntime.rootDir(context), INTERNAL)
/** The roots a game can go to right now: internal first, then every writable card. */
fun targets(context: Context): List<Target> {
val internal = Target(internalRoot(context), context.getString(com.droiddeck.launcher.R.string.games_internal), removable = false)
val cards = GameStorage.options(context).map { Target(File(it.path, ON_VOLUME), it.name, removable = true) }
return listOf(internal) + cards
}
/** The default root, when nothing asks: internal storage. */
fun installRoot(context: Context): File = internalRoot(context)
/** Every root that may hold store games: the internal one, every card's, and the Steam library's (older installs). */
fun roots(context: Context): List<File> {
val cards = GameStorage.options(context).map { File(it.path, ON_VOLUME) }
val library = GameStorage.effective(context)?.let { File(it.path, ON_VOLUME) }
return (listOf(internalRoot(context)) + cards + listOfNotNull(library)).distinctBy { it.absolutePath }
}
/**
* The card roots that need a bind of their own, with where the session sees them. A root under
* the Steam library is left out: the library's bind already covers it, and the games there keep
* the guest path (and appid) they were registered with.
*/
fun externalRoots(context: Context): List<Pair<File, String>> {
val library = GameStorage.effective(context)?.path
return roots(context)
.filter { !isInternal(context, it) && (library == null || !it.absolutePath.startsWith("$library/")) }
.map { it to guestFor(context, it) }
}
/** A card root's guest path, keyed on the volume (its UUID, else a hash of the path), so it is the same across sessions. */
private fun guestFor(context: Context, root: File): String {
val sm = context.getSystemService(StorageManager::class.java)
val uuid = runCatching { sm?.getStorageVolume(root)?.uuid }.getOrNull()?.takeIf { it.isNotBlank() }?.replace(Regex("[^A-Za-z0-9-]"), "")
val key = uuid ?: "%08x".format(CRC32().apply { update(root.absolutePath.toByteArray()) }.value)
return "$GUEST_EXTERNAL/$key"
}
fun isInternal(context: Context, root: File): Boolean = root.absolutePath == internalRoot(context).absolutePath
/** Where a host path under a card root appears inside the session, or null when it is not under one. */
fun externalGuestPath(context: Context, host: File): String? {
val path = host.absolutePath
for ((root, guest) in externalRoots(context)) {
val dir = root.absolutePath
if (path == dir) return guest
if (path.startsWith("$dir/")) return guest + "/" + path.removePrefix("$dir/")
}
return null
}
/** The target a game folder sits under, for the page and the Downloads row; internal when unknown. */
fun labelFor(context: Context, folder: File): String {
val path = folder.absolutePath
for (t in targets(context)) if (path.startsWith(t.root.absolutePath + "/")) return t.label
return context.getString(com.droiddeck.launcher.R.string.games_internal)
}
fun isRemovable(context: Context, folder: File): Boolean = !folder.absolutePath.startsWith(internalRoot(context).absolutePath + "/")
fun storeDir(root: File, store: Store): File = File(root, store.shortLabel)
/** The game folders under every root, by store: what AddedGames scans in addition to the user's folders. */
fun gameFolders(context: Context): List<File> = roots(context).flatMap { root ->
Store.entries.flatMap { store ->
storeDir(root, store).listFiles { f -> f.isDirectory }?.sortedBy { it.name.lowercase() } ?: emptyList()
}
}
/**
* The folder a game installs into under [root]. An existing folder wins over a fresh one, so a
* repair, an update or a resume lands on what is already there: one with this game's sidecar
* anywhere, else one under any root with this game's folder name and no finished sidecar (an
* install an earlier build left without one, or that stopped before writing it).
*/
fun folderFor(context: Context, store: Store, id: String, title: String, root: File = installRoot(context)): File =
existingFolder(context, store, id, title) ?: File(storeDir(root, store), folderName(title, id))
/** The folder an earlier install of this game left, finished or not; null when there is none. */
fun existingFolder(context: Context, store: Store, id: String, title: String): File? {
val all = roots(context).flatMap { r -> storeDir(r, store).listFiles { f -> f.isDirectory }?.toList().orEmpty() }
all.firstOrNull { folder -> StoreGameSidecar.read(folder)?.let { it.store == store && it.id == id } == true }?.let { return it }
val name = folderName(title, id)
return all.firstOrNull { it.name == name && StoreGameSidecar.read(it)?.isInstalled != true }
}
/** An unfinished install: [id] from its `installing` sidecar, null when it has none (matched by folder name then). */
class Unfinished(val store: Store, val id: String?, val folder: File)
/**
* A folder under a store root is a game only once its sidecar says installed. Without one it is
* an install that never finished (stopped, killed, or from a build that wrote the sidecar last):
* not a Custom game, and not listed in Steam.
*/
fun isFinished(folder: File): Boolean = StoreGameSidecar.read(folder)?.isInstalled == true
/** Every unfinished install under every root, for the Stores page's "Resume install". */
fun unfinished(context: Context): List<Unfinished> = roots(context).flatMap { root ->
Store.entries.flatMap { store ->
storeDir(root, store).listFiles { f -> f.isDirectory }.orEmpty().mapNotNull { folder ->
val sidecar = StoreGameSidecar.read(folder)
when {
sidecar == null -> Unfinished(store, null, folder)
!sidecar.isInstalled -> Unfinished(store, sidecar.id, folder)
else -> null
}
}
}
}
/** A scratch folder in the app's cache for an install's in-flight pieces, when the install itself is on a card. */
fun scratchDir(context: Context, store: Store, id: String): File =
File(File(File(context.cacheDir, "stores"), store.id), id.replace(Regex("[^A-Za-z0-9._-]"), "_"))
/**
* A folder name from a store title: letters, digits and a few safe marks, single spaces, at most
* 60 characters; the store id when nothing printable is left. The name is the shortcut's folder
* for ever, so it is chosen once at install and read back from the sidecar afterwards.
*/
fun folderName(title: String, id: String): String {
val cleaned = title.replace(Regex("[^A-Za-z0-9 ._'()\\-]"), " ").replace(Regex("\\s+"), " ").trim().trimEnd('.')
val cut = if (cleaned.length > 60) cleaned.take(60).trimEnd().trimEnd('.') else cleaned
return cut.ifEmpty { id.replace(Regex("[^A-Za-z0-9._-]"), "_").ifEmpty { "game" } }
}
}
@@ -0,0 +1,110 @@
package com.droiddeck.launcher.stores
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.frontend.AddedGames
import com.droiddeck.launcher.frontend.LibraryCache
import com.droiddeck.launcher.frontend.Library
import java.io.File
/**
* What happens around a store install so the rest of the app sees the game: the sidecar is
* written, the launcher .bat beside it when the game needs one, and Steam is told.
*
* Telling Steam is the same path an added folder takes: the game is one more folder the
* [AddedGames] scan finds, the scan's listing (`session/added-games.json`) is what the runtime's
* shortcuts writer reads at the client's next start, and here the listing is rewritten at once so
* a Steam client that restarts inside the running session already has it. When the client is up,
* [SteamLiveShortcuts] adds the shortcut over the client's DevTools port as well, so the game shows
* without a restart; that part is best-effort.
*/
object StoreInstalls {
private const val TAG = "StoreInstalls"
/**
* Marks [folder] as an install under way before anything is fetched: a sidecar in state
* `installing` with the store, id and title. Whatever stops the install from here on - a
* failure, a cancel, the process killed - leaves a folder the scan skips and the Stores page
* offers to resume, never a half-written Custom game. A finished install being repaired or
* updated keeps its finished sidecar, so the game stays in Steam meanwhile.
*/
fun begin(folder: File, store: Store, id: String, title: String, cover: String?, hero: String?) {
if (StoreGameSidecar.read(folder)?.isInstalled == true) return
StoreGameSidecar(store, id, title, exe = "", cover = cover, hero = hero, state = StoreGameSidecar.STATE_INSTALLING).write(folder)
}
/**
* Finishes an install: writes the launcher and the finished sidecar, then the art, then
* registers the game. Runs on the caller's worker thread. The launcher and sidecar writes
* throw, so a failure there fails the download with its message; the art and the Steam side
* are best-effort and only logged.
*/
fun complete(context: Context, folder: File, sidecar: StoreGameSidecar, onStep: ((Int, Int) -> Unit)? = null) {
val app = context.applicationContext
onStep?.invoke(0, STEPS)
// The user's launch choices survive an update or a repair.
val before = StoreGameSidecar.read(folder)
val finished = StoreLaunch.writeLauncher(folder, sidecar.copy(state = StoreGameSidecar.STATE_INSTALLED, epic = before?.epic ?: sidecar.epic, cloud = before?.cloud ?: sidecar.cloud))
finished.write(folder)
if (StoreGameSidecar.read(folder)?.isInstalled != true) throw java.io.IOException("the install record could not be written in ${folder.name}")
onStep?.invoke(1, STEPS)
// The art before the listing, so the listing already carries it for the client's grid.
try { StoreArt.fetchInto(folder, finished) } catch (e: Exception) { Log.w(TAG, "art for ${folder.name}: ${e.message}") }
onStep?.invoke(2, STEPS)
register(app)
onStep?.invoke(STEPS, STEPS)
}
/** The finishing steps [complete] reports: the record and launcher, the art, Steam. */
private const val STEPS = 3
/** Rewrites the session's listing from a fresh scan and, with a client running, adds live. */
fun register(context: Context) {
val app = context.applicationContext
try {
val games = AddedGames.scan(app)
AddedGames.writeListing(app, games)
LibraryCache.save(app, Library.launchableGames(app))
SteamLiveShortcuts.sync(app, games)
} catch (e: Exception) {
Log.w(TAG, "registration: ${e.message}")
}
}
/**
* Removes a store game: its folder with the sidecar, then the shortcut (the listing without
* it makes the writer drop the entry; the live client is asked too). Blocking.
*/
fun uninstall(context: Context, game: InstalledStoreGame) {
val app = context.applicationContext
val appId = AddedGames.scan(app).firstOrNull { it.folder.absolutePath == game.folder.absolutePath }?.appId
deleteTree(game.folder)
// The scratch cache an install on a card may have left in the app's cache.
deleteTree(StoreInstallRoot.scratchDir(app, game.sidecar.store, game.sidecar.id))
register(app)
if (appId != null) SteamLiveShortcuts.remove(app, appId)
StoresState.logLine("uninstalled \"${game.sidecar.title}\"")
}
/**
* What Cancel does: everything the install wrote goes - the game folder with its sidecar and
* the caches in [caches] - unless the folder holds a finished install being repaired or updated;
* then only the caches go and the working game stays. Off the caller's thread; the library
* follows when it is done, so the game reads Install again.
*/
fun discard(context: Context, folder: File, caches: List<File>) {
val app = context.applicationContext
Thread({
val keep = StoreGameSidecar.read(folder)?.isInstalled == true
caches.forEach { deleteTree(it) }
if (!keep) deleteTree(folder)
StoresState.logLine(if (keep) "removed the partial update of ${folder.name}" else "removed ${folder.name}")
StoresState.notifyLibraryChanged(app)
}, "store-cancel-clean").start()
}
fun deleteTree(dir: File) {
dir.listFiles()?.forEach { if (it.isDirectory) deleteTree(it) else it.delete() }
dir.delete()
}
}
@@ -0,0 +1,160 @@
package com.droiddeck.launcher.stores
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.frontend.Library
import java.io.File
/**
* How a store game is started inside the session. The shortcut Steam gets can only name an exe,
* so when a game needs arguments or environment the install writes a small `.droiddeck-launch.bat`
* beside it and the shortcut runs that: Proton's steam.exe shim hands a .bat to Wine's cmd, the
* script sets the variables, steps into the exe's folder and starts it, and waits for it as a
* batch file does, so Steam sees the game running.
*
* Epic's online sign-in is a fresh exchange code per launch (it expires in minutes), which no fixed
* command line can carry: [prepare] mints one right before a launch and leaves it in
* `.droiddeck-epic-code`; the script reads the file, deletes it and passes the AUTH arguments. With
* no file the game starts in its offline identity mode, which most EOS titles accept.
*/
object StoreLaunch {
private const val TAG = "StoreLaunch"
const val LAUNCHER = ".droiddeck-launch.bat"
const val EPIC_CODE = ".droiddeck-epic-code"
/**
* Writes the launcher for [sidecar] when it needs one (arguments, environment, or an Epic
* sign-in), removes a stale one otherwise, and returns the sidecar with [StoreGameSidecar.launcher] set.
*/
fun writeLauncher(folder: File, sidecar: StoreGameSidecar): StoreGameSidecar {
val needs = sidecar.args.isNotEmpty() || sidecar.env.isNotEmpty() || sidecar.store == Store.EPIC
val file = File(folder, LAUNCHER)
if (!needs) { file.delete(); return sidecar.copy(launcher = null) }
file.writeText(launcherText(sidecar))
return sidecar.copy(launcher = LAUNCHER)
}
/** The batch file's text; pure, so it can be checked without a folder. CRLF line ends, as cmd expects. */
fun launcherText(sidecar: StoreGameSidecar): String {
val exe = sidecar.exe.replace('/', '\\')
val dir = exe.substringBeforeLast('\\', "")
val lines = ArrayList<String>()
lines += "@echo off"
lines += "setlocal"
lines += "rem Written by DroidDeck for ${sidecar.store.label}: ${sidecar.title}. Steam's shortcut runs this file."
lines += "cd /d \"%~dp0$dir\""
for ((k, v) in sidecar.env) if (ENV_NAME.matches(k)) lines += "set \"$k=${v.replace("\"", "")}\""
val args = sidecar.args.joinToString(" ") { quoteArg(it) }
val run = "\"%~dp0$exe\"" + (if (args.isNotEmpty()) " $args" else "")
if (sidecar.store == Store.EPIC) {
// The one-shot code reaches the game on its command line only. cmd expands %DD_X% when it
// parses the last line, so the variable is cleared on that same line before the game
// starts: nothing of it is left in the environment the game inherits (and dumps).
lines += "set \"$CODE_VAR=\""
lines += "if exist \"%~dp0$EPIC_CODE\" set /p $CODE_VAR=<\"%~dp0$EPIC_CODE\""
lines += "if exist \"%~dp0$EPIC_CODE\" del \"%~dp0$EPIC_CODE\""
lines += "if defined $CODE_VAR goto signed"
lines += run
lines += "goto :eof"
lines += ":signed"
lines += "set \"$CODE_VAR=\" & $run -AUTH_LOGIN=unused -AUTH_PASSWORD=%$CODE_VAR% -AUTH_TYPE=exchangecode"
} else {
lines += run
}
return lines.joinToString("\r\n") + "\r\n"
}
private val ENV_NAME = Regex("[A-Za-z_][A-Za-z0-9_]*")
/** An argument on cmd's line: quoted when it has a space and is not already quoted; control characters dropped. */
fun quoteArg(arg: String): String {
val clean = arg.filter { it >= ' ' && it != '\u007f' }
return if (' ' in clean && !clean.contains('"')) "\"$clean\"" else clean
}
/**
* Everything a launch needs done first, then [onReady] on the main thread: for an Epic game a
* fresh exchange code written beside its launcher (a few seconds at most; the launch goes
* ahead without one when the store cannot be reached). Other stores need nothing.
*/
/** The launcher's variable for the one-shot code; a plain name, cleared before the game starts. */
private const val CODE_VAR = "DD_X"
/**
* Rewrites a game's launcher when its text is not what this build writes - an install from an
* earlier build keeps its old .bat otherwise. Called before every Epic launch.
*/
fun refreshLauncher(folder: File, sidecar: StoreGameSidecar) {
if (sidecar.launcher != LAUNCHER) return
val file = File(folder, LAUNCHER)
val text = launcherText(sidecar)
if (runCatching { file.readText() }.getOrNull() != text) {
file.writeText(text)
Log.i(TAG, "launcher rewritten for ${sidecar.id}")
}
}
fun prepare(context: Context, store: Store, id: String, onReady: () -> Unit) {
if (store != Store.EPIC) { onReady(); return }
val app = context.applicationContext
Thread({
epicCode(app, id)
StoresState.post(onReady)
}, "stores-launch-prep").start()
}
/** What [epicCode] did, for the guest's request and the log: whether a code was written, and why not. */
class CodeResult(val written: Boolean, val reason: String)
/**
* Mints an Epic exchange code for the installed game [id] and writes it as [EPIC_CODE] into the
* very folder its sidecar and launcher are in, where the launcher .bat reads and deletes it.
* Blocking (network). Logs `epic launch id=<id> code=yes|no reason=...` - never the code. Every
* launch path comes here: the Games tab and Stores before they launch, and the compat tool for
* any launch, the Steam client's own Play button included ([StoreLaunchRequests]).
*/
fun epicCode(context: Context, id: String): CodeResult {
val app = context.applicationContext
val result = try {
val folder = StoreInstallRoot.gameFolders(app).firstOrNull { f -> StoreGameSidecar.read(f)?.let { it.store == Store.EPIC && it.id == id } == true }
val sidecar = folder?.let { StoreGameSidecar.read(it) }
sidecar?.let { refreshLauncher(folder, it) }
val options = sidecar?.epic
if (folder == null) CodeResult(false, "not-installed")
else if (options != null && !options.wantsCode) {
File(folder, EPIC_CODE).delete()
CodeResult(false, if (options.offline) "offline" else "eos-off")
} else {
val file = File(folder, EPIC_CODE)
file.delete()
val support = StoresState.backend(Store.EPIC) as? EpicLaunchSupport
val code = support?.exchangeCode(app)
when {
support == null -> CodeResult(false, "no-backend")
code != null -> { file.writeText(code); CodeResult(true, "ok") }
StoreAccounts.signedInAs(app, Store.EPIC) == null -> CodeResult(false, "signed-out")
support.signInExpired(app) -> { StoresState.markSignInExpired(Store.EPIC); CodeResult(false, "sign-in-expired") }
else -> CodeResult(false, "exchange-failed")
}
}
} catch (e: Exception) {
CodeResult(false, "error-" + e.javaClass.simpleName)
}
Log.i(TAG, "epic launch id=$id code=${if (result.written) "yes" else "no"} reason=${result.reason}")
return result
}
fun prepare(context: Context, game: Library.SteamGame, onReady: () -> Unit) {
val store = Store.byId(game.source)
val id = game.storeId
if (store == null || id == null) { onReady(); return }
prepare(context, store, id, onReady)
}
}
/** What the Epic backend adds for launches: a short-lived exchange code for the signed-in account. */
interface EpicLaunchSupport {
fun exchangeCode(context: Context): String?
/** True when the stored sign-in can no longer be refreshed: the user has to sign in again. */
fun signInExpired(context: Context): Boolean
}
@@ -0,0 +1,108 @@
package com.droiddeck.launcher.stores
import android.content.Context
import android.os.FileObserver
import android.util.Log
import com.droiddeck.launcher.runtime.LinuxRuntime
import java.io.File
import org.json.JSONObject
/**
* The app's half of droiddeck-store-launch: the compat tool asks, before Proton starts a store
* game, for what the store needs at launch, through files under the session root's `stores/`
* (`req/<id>.json` in, `resp/<id>.json` out, both written tmp + rename). Today that is an Epic
* game's exchange code, which [StoreLaunch.epicCode] writes into the game's own folder; the answer
* says only whether it did and why not. Watched while a session runs.
*/
object StoreLaunchRequests {
private const val TAG = "StoreLaunchRequests"
private val lock = Any()
private var observer: FileObserver? = null
fun dir(context: Context) = File(LinuxRuntime.sessionRoot(context), "stores")
/** Before each session: nothing of the last one's requests; then watch for new ones. */
fun start(context: Context) {
val app = context.applicationContext
synchronized(lock) {
observer?.stopWatching()
val root = dir(app)
root.deleteRecursively()
val req = File(root, "req").apply { mkdirs() }
File(root, "resp").mkdirs()
@Suppress("DEPRECATION")
observer = object : FileObserver(req.path, MOVED_TO or CLOSE_WRITE) {
override fun onEvent(event: Int, path: String?) {
if (path == null || !path.endsWith(".json")) return
val file = File(req, path)
Thread({ answer(app, file) }, "store-launch-request").start()
}
}.also { it.startWatching() }
}
}
fun stop() {
synchronized(lock) {
observer?.stopWatching()
observer = null
}
}
private fun answer(app: Context, file: File) {
val request = try {
JSONObject(file.readText()).also { file.delete() }
} catch (e: Exception) {
return // withdrawn already, or not complete yet (the rename brings it again)
}
val reply = when (request.optString("op")) {
"epic-code" -> {
val id = request.optString("id")
if (id.isEmpty()) JSONObject().put("ok", false).put("reason", "no-id")
else {
val code = StoreLaunch.epicCode(app, id)
JSONObject().put("ok", true).put("code", code.written).put("reason", code.reason)
}
}
// Cloud saves: down before Proton starts (the wrapper waits a bounded time), up after the
// game exits (answered at once; the upload runs on without holding Steam).
"cloud-down" -> {
val store = Store.byId(request.optString("store"))
val id = request.optString("id")
if (store == null || id.isEmpty()) JSONObject().put("ok", false).put("reason", "no-game")
else {
// Marked before the game runs: whatever ends it, an upload follows (CloudSaves.uploadDirty).
CloudSaves.markDirty(app, store, id)
CloudSaves.downloadOrDefer(app, store, id).let { JSONObject().put("ok", it.ok).put("result", it.result).put("files", it.files).put("reason", it.reason) }
}
}
"cloud-up" -> {
val store = Store.byId(request.optString("store"))
val id = request.optString("id")
if (store != null && id.isNotEmpty()) Thread({ CloudSaves.uploadDirty(app, store, id, "exit") }, "cloud-up").start()
JSONObject().put("ok", true)
}
// A browser a game opens inside the session (an EOS device sign-in, a store page):
// http(s) only, handed to Android's browser.
"open-url" -> {
val url = request.optString("url")
if (!(url.startsWith("https://") || url.startsWith("http://"))) JSONObject().put("ok", false).put("reason", "not-http")
else try {
app.startActivity(android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(url)).addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK))
Log.i(TAG, "open-url: " + StoreLog.redactUrl(url))
JSONObject().put("ok", true)
} catch (e: Exception) {
JSONObject().put("ok", false).put("reason", e.javaClass.simpleName)
}
}
else -> JSONObject().put("ok", false).put("reason", "unknown-op")
}
try {
val resp = File(dir(app), "resp").apply { mkdirs() }
val tmp = File(resp, file.name + ".tmp")
tmp.writeText(reply.toString())
tmp.renameTo(File(resp, file.name))
} catch (e: Exception) {
Log.w(TAG, "could not answer ${file.name}: ${e.message}")
}
}
}
@@ -0,0 +1,79 @@
package com.droiddeck.launcher.stores
import android.content.Context
import android.util.Log
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.Executors
/**
* The stores' log on disk, app-private: `filesDir/logs/stores/stores-<yyyy-MM-dd>.log` (beside the
* session folders, where every log lives), one file a
* day, the last [KEEP_DAYS] kept, each capped near [MAX_BYTES] (the full one moves to `.1`). Lines
* arrive already redacted ([StoreLog.redactLine]). Written on one background thread, in order. The
* session's Share logs zip carries these files, so they can be attached to a report when asked.
*/
object StoreLogFiles {
private const val TAG = "StoreLogFiles"
private const val KEEP_DAYS = 7
private const val MAX_BYTES = 2L * 1024 * 1024
private val DAY = SimpleDateFormat("yyyy-MM-dd", Locale.US)
private val NAME = Regex("""stores-(\d{4}-\d{2}-\d{2})\.log(\.1)?""")
private val writer = Executors.newSingleThreadExecutor { r -> Thread(r, "store-log").apply { isDaemon = true } }
fun dir(context: Context): File = File(context.applicationContext.filesDir, "logs/stores")
private val pending = java.util.concurrent.ConcurrentLinkedQueue<String>()
private val flushQueued = java.util.concurrent.atomic.AtomicBoolean(false)
private val flusher = java.util.concurrent.Executors.newSingleThreadScheduledExecutor { r ->
Thread({ android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_BACKGROUND); r.run() }, "store-log-flush").apply { isDaemon = true }
}
/**
* Queues one stamped line for today's file. Any thread; nothing touches the disk here. Lines are
* written in batches, at most every half second, on [writer] in order.
*/
fun append(context: Context, line: String) {
val app = context.applicationContext
pending.add(line)
if (flushQueued.compareAndSet(false, true)) {
flusher.schedule({ flushQueued.set(false); writer.execute { flush(app) } }, 500, java.util.concurrent.TimeUnit.MILLISECONDS)
}
}
private fun flush(app: Context) {
if (pending.isEmpty()) return
val batch = StringBuilder()
while (true) { val l = pending.poll() ?: break; batch.append(l).append('\n') }
try {
val dir = dir(app).apply { mkdirs() }
val file = File(dir, "stores-${synchronized(DAY) { DAY.format(Date()) }}.log")
if (file.length() > MAX_BYTES) {
val old = File(dir, file.name + ".1")
old.delete()
file.renameTo(old)
}
file.appendText(batch.toString())
} catch (e: Exception) {
Log.w(TAG, "could not write the stores log: ${e.message}")
}
}
/** Drops the files older than [KEEP_DAYS] days. Called as the app starts. */
fun prune(context: Context) {
val app = context.applicationContext
writer.execute {
val cutoff = synchronized(DAY) { DAY.format(Date(System.currentTimeMillis() - (KEEP_DAYS - 1) * 86_400_000L)) }
dir(app).listFiles()?.forEach { f ->
val day = NAME.matchEntire(f.name)?.groupValues?.get(1) ?: return@forEach
if (day < cutoff) f.delete()
}
}
}
/** The files there are, oldest first, for a log share. */
fun files(context: Context): List<File> =
dir(context).listFiles { f -> f.isFile && NAME.matches(f.name) }?.sortedBy { it.name }.orEmpty()
}
@@ -0,0 +1,333 @@
package com.droiddeck.launcher.stores
import android.annotation.SuppressLint
import android.graphics.Color
import android.net.Uri
import android.net.http.SslError
import android.os.Bundle
import android.os.Message
import android.util.Log
import android.util.TypedValue
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.webkit.CookieManager
import android.webkit.SslErrorHandler
import android.webkit.WebChromeClient
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.Button
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.LinearLayout
import android.widget.ProgressBar
import android.widget.TextView
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedCallback
import com.droiddeck.launcher.R
import com.droiddeck.launcher.stores.gog.GogAuth
import java.util.concurrent.atomic.AtomicBoolean
/**
* A store's own sign-in page in a WebView, one activity for the three stores: GOG's OAuth page
* (the token comes back in the redirect's fragment), Epic's web login (an authorization code on
* a JSON page), Amazon's device sign-in (a PKCE code on the return URL). Plain views, as the
* proven GOG flow was: the page is rendered with the store's own client identity, and a social
* sign-in hop to Google / Facebook / Apple runs under a real browser identity in a popup, since
* those providers refuse an embedded browser.
*
* Nothing from the page is logged but redacted URLs and key names; the tokens go straight to
* StoreAccounts' private file and the activity finishes.
*/
class StoreLoginActivity : ComponentActivity() {
companion object {
private const val TAG = "StoreLogin"
const val EXTRA_STORE = "store"
private const val KEY_STATE = "oauth_state"
/**
* The colour the Stores page flooded to before opening this page, read once: the page opens
* on it, with no window animation, and its chrome rises in, as a session opens on Play's blue.
*/
@Volatile internal var floodColor: Int? = null
private const val CHROME_UA = "Mozilla/5.0 (Linux; Android 14; SM-S918B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.103 Mobile Safari/537.36"
fun generateState(): String {
val bytes = ByteArray(24)
java.security.SecureRandom().nextBytes(bytes)
return android.util.Base64.encodeToString(bytes, android.util.Base64.URL_SAFE or android.util.Base64.NO_WRAP or android.util.Base64.NO_PADDING)
}
}
/** What one store's flow decides: the page to open, the identity to open it with, and the redirect that ends it. */
internal interface Flow {
val startUrl: String
val userAgent: String
/** True when [url] is the flow's end; the activity then calls [finish]. */
fun isRedirect(url: String): Boolean
/** Runs off the main thread with the redirect; returns null on success, else a message to show. */
fun finish(activity: StoreLoginActivity, view: WebView, url: String): String?
/** Whether a JSON page at the redirect must be read out of the document instead of the URL (Epic). */
val readsPage: Boolean get() = false
}
private var store: Store? = null
private var flow: Flow? = null
private var webViewRef: WebView? = null
private var popupWebView: WebView? = null
private lateinit var contentHost: FrameLayout
private lateinit var popupHost: FrameLayout
private lateinit var progressBar: ProgressBar
private lateinit var errorPanel: LinearLayout
private lateinit var errorText: TextView
private lateinit var titleText: TextView
private var oauthState: String? = null
private val captured = AtomicBoolean(false)
/** Opened behind a flood: it opens and closes with no window animation. */
private var flooded = false
private fun dp(value: Int): Int = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, value.toFloat(), resources.displayMetrics).toInt()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
store = Store.byId(intent.getStringExtra(EXTRA_STORE))
oauthState = savedInstanceState?.getString(KEY_STATE) ?: generateState()
flow = store?.let { LoginFlows.forStore(it, oauthState!!) }
floodColor?.let { c ->
floodColor = null
flooded = true
window.setBackgroundDrawable(android.graphics.drawable.ColorDrawable(c))
@Suppress("DEPRECATION") overridePendingTransition(0, 0)
}
if (flow == null) { finish(); return }
val chrome = buildChrome()
setContentView(chrome)
if (flooded) {
chrome.alpha = 0f
chrome.translationY = dp(14).toFloat()
chrome.animate().alpha(1f).translationY(0f).setStartDelay(60).setDuration(320).start()
}
val webView = newWebView(flow!!.userAgent, isPopup = false)
webViewRef = webView
contentHost.addView(webView, 0, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
when {
popupWebView != null -> dismissPopup()
webViewRef?.canGoBack() == true -> webViewRef?.goBack()
else -> finish()
}
}
})
webView.loadUrl(flow!!.startUrl)
}
override fun finish() {
super.finish()
// Back to the Stores page, which is still covered in the flood and drains it from there.
@Suppress("DEPRECATION") if (flooded) overridePendingTransition(0, 0)
}
private fun buildChrome(): View {
val root = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL; setBackgroundColor(0xFF0A0B0D.toInt()) }
val bar = LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL; gravity = Gravity.CENTER_VERTICAL; setBackgroundColor(0xFF121417.toInt())
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(48))
}
titleText = TextView(this).apply {
text = getString(R.string.stores_login_title, store?.label ?: "")
setTextColor(0xFFF2F4F7.toInt()); textSize = 16f; setPadding(dp(16), 0, dp(8), 0)
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
}
val reload = ImageButton(this).apply {
setImageResource(android.R.drawable.ic_menu_rotate); setBackgroundColor(Color.TRANSPARENT)
contentDescription = getString(R.string.stores_login_reload); layoutParams = LinearLayout.LayoutParams(dp(48), dp(48))
setOnClickListener { reloadLogin() }
}
val close = ImageButton(this).apply {
setImageResource(android.R.drawable.ic_menu_close_clear_cancel); setBackgroundColor(Color.TRANSPARENT)
contentDescription = getString(R.string.stores_login_close); layoutParams = LinearLayout.LayoutParams(dp(48), dp(48))
setOnClickListener { finish() }
}
bar.addView(titleText); bar.addView(reload); bar.addView(close)
progressBar = ProgressBar(this, null, android.R.attr.progressBarStyleHorizontal).apply {
max = 100; isIndeterminate = false
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(3))
}
contentHost = FrameLayout(this).apply { setBackgroundColor(0xFF0A0B0D.toInt()); layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f) }
popupHost = FrameLayout(this).apply { setBackgroundColor(0xFF0A0B0D.toInt()); visibility = View.GONE }
contentHost.addView(popupHost, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
errorPanel = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL; gravity = Gravity.CENTER; setBackgroundColor(0xFF0A0B0D.toInt())
setPadding(dp(24), dp(24), dp(24), dp(24)); visibility = View.GONE
}
errorText = TextView(this).apply { setTextColor(0xFFF2F4F7.toInt()); textSize = 15f; gravity = Gravity.CENTER }
val retry = Button(this).apply { text = getString(R.string.stores_login_retry); setOnClickListener { reloadLogin() } }
errorPanel.addView(errorText)
errorPanel.addView(retry, LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { topMargin = dp(16) })
contentHost.addView(errorPanel, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
root.addView(bar); root.addView(progressBar); root.addView(contentHost)
return root
}
private fun showError(message: String) {
runOnUiThread { errorText.text = message; errorPanel.visibility = View.VISIBLE; errorPanel.bringToFront(); progressBar.visibility = View.GONE }
}
private fun clearError() { runOnUiThread { errorPanel.visibility = View.GONE } }
private fun reloadLogin() {
dismissPopup(); clearError()
captured.set(false)
val fresh = generateState()
oauthState = fresh
flow = store?.let { LoginFlows.forStore(it, fresh) } ?: return
webViewRef?.let { it.settings.userAgentString = flow!!.userAgent; it.loadUrl(flow!!.startUrl) }
}
@SuppressLint("SetJavaScriptEnabled")
private fun newWebView(userAgent: String, isPopup: Boolean): WebView = WebView(this).apply {
setBackgroundColor(0xFF0A0B0D.toInt())
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.userAgentString = userAgent
// The stores' social-login buttons are window.open() popups; without these the call is dropped silently.
settings.setSupportMultipleWindows(true)
settings.javaScriptCanOpenWindowsAutomatically = true
if (isPopup) CookieManager.getInstance().setAcceptThirdPartyCookies(this, true)
webViewClient = Client(isPopup)
webChromeClient = Chrome(isPopup)
}
private inner class Chrome(private val isPopup: Boolean) : WebChromeClient() {
override fun onProgressChanged(view: WebView?, newProgress: Int) {
progressBar.progress = newProgress
progressBar.visibility = if (newProgress in 1..99) View.VISIBLE else View.GONE
}
override fun onCreateWindow(view: WebView?, isDialog: Boolean, isUserGesture: Boolean, resultMsg: Message?): Boolean {
if (resultMsg == null) return false
dismissPopup()
val child = newWebView(CHROME_UA, isPopup = true)
popupWebView = child
popupHost.addView(child, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
popupHost.visibility = View.VISIBLE
popupHost.bringToFront()
titleText.text = getString(R.string.stores_login_provider_title)
(resultMsg.obj as WebView.WebViewTransport).webView = child
resultMsg.sendToTarget()
return true
}
override fun onCloseWindow(window: WebView?) { if (isPopup || window === popupWebView) dismissPopup() }
}
/** Deferred: destroying a WebView from inside one of its own callbacks crashes chromium. */
private fun dismissPopup() {
val child = popupWebView ?: return
popupWebView = null
popupHost.post { destroyPopupView(child) }
}
private fun destroyPopupView(child: WebView) {
child.stopLoading(); child.webChromeClient = null; child.loadUrl("about:blank")
popupHost.removeView(child); child.destroy()
if (popupWebView == null) {
popupHost.visibility = View.GONE
if (!isFinishing) titleText.text = getString(R.string.stores_login_title, store?.label ?: "")
}
}
private inner class Client(private val isPopup: Boolean) : WebViewClient() {
override fun onPageStarted(view: WebView?, url: String?, favicon: android.graphics.Bitmap?) {
Log.d(TAG, "page[popup=$isPopup]: ${StoreLog.redactUrl(url)}")
// Amazon's return URL lands as a page load rather than a navigation we can intercept.
if (url != null && view != null && flow?.isRedirect(url) == true && flow?.readsPage != true) complete(view, url)
}
override fun onPageFinished(view: WebView?, url: String?) {
// Epic's redirect is a JSON page: the code is in the document, read once it is loaded.
if (url != null && view != null && flow?.readsPage == true && flow?.isRedirect(url) == true) complete(view, url)
}
override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) {
if (request?.isForMainFrame == true) showError(getString(R.string.stores_login_error_network, error?.description ?: ""))
}
override fun onReceivedHttpError(view: WebView?, request: WebResourceRequest?, errorResponse: WebResourceResponse?) {
val status = errorResponse?.statusCode ?: 0
if (request?.isForMainFrame != true) return
if (GogAuth.isIdentityProviderHost(request.url?.host) && (status == 403 || status == 400)) showError(getString(R.string.stores_login_error_idp_blocked))
else showError(getString(R.string.stores_login_error_http, status))
}
override fun onReceivedSslError(view: WebView?, handler: SslErrorHandler?, error: SslError?) {
handler?.cancel()
showError(getString(R.string.stores_login_error_ssl))
}
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean = route(view, request.url)
@Suppress("DEPRECATION")
override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean = route(view, Uri.parse(url))
private fun route(view: WebView, uri: Uri): Boolean {
val f = flow ?: return false
val url = uri.toString()
if (f.isRedirect(url) && !f.readsPage) { complete(view, url); return true }
// A social sign-in hop that stays in the main view: the provider gets a browser identity,
// and the store gets its own identity back afterwards (a plain Chrome UA makes GOG's
// page serve a form that never renders in a WebView).
if (!isPopup && store == Store.GOG) {
if (GogAuth.isGogHost(uri.host) && view.settings.userAgentString != f.userAgent) { view.settings.userAgentString = f.userAgent; return false }
if (GogAuth.isIdentityProviderHost(uri.host) && view.settings.userAgentString != CHROME_UA) {
view.settings.userAgentString = CHROME_UA
CookieManager.getInstance().setAcceptThirdPartyCookies(view, true)
view.loadUrl(url)
return true
}
}
return false
}
}
private fun complete(view: WebView, url: String) {
if (!captured.compareAndSet(false, true)) return
val f = flow ?: return
dismissPopup()
runOnUiThread {
clearError()
titleText.text = getString(R.string.stores_login_finishing)
if (!f.readsPage) view.stopLoading()
}
if (f.readsPage) {
// The document's text, then the exchange off the main thread.
view.evaluateJavascript("(function(){ try { return document.body.innerText; } catch(e){ return ''; } })()") { json ->
Thread({ settle(f.finish(this, view, json ?: "")) }, "store-login").start()
}
} else Thread({ settle(f.finish(this, view, url)) }, "store-login").start()
}
private fun settle(problem: String?) {
runOnUiThread {
if (problem == null) { StoresState.refresh(this); finish(); return@runOnUiThread }
captured.set(false)
if (!isFinishing && !isDestroyed) android.widget.Toast.makeText(this, problem, android.widget.Toast.LENGTH_LONG).show()
reloadLogin()
}
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
oauthState?.let { outState.putString(KEY_STATE, it) }
}
override fun onDestroy() {
popupWebView?.let { popupWebView = null; destroyPopupView(it) }
webViewRef?.let { it.webChromeClient = null; (it.parent as? ViewGroup)?.removeView(it); it.destroy() }
webViewRef = null
super.onDestroy()
}
}
@@ -0,0 +1,136 @@
package com.droiddeck.launcher.stores
import android.util.Log
import java.io.BufferedReader
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.net.HttpURLConnection
import java.net.URL
/**
* The small HTTP the storefront catalogs and account calls need: a GET or a JSON POST with the
* right User-Agent, a bearer token when the call is signed in, and a body or null. Store URLs
* carry their authorization in the query string or the path, so a URL is only ever logged
* through [StoreLog.redactUrl]. Plain HttpURLConnection, like the rest of the app.
*/
internal object StoreNet {
private const val TAG = "StoreNet"
/** A desktop browser: GOG's catalog and Epic's store GraphQL answer it; Epic's edge refuses the bare Java UA. */
const val BROWSER_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36"
fun get(url: String, bearer: String? = null, userAgent: String = BROWSER_UA, timeoutMs: Int = 20_000, headers: Map<String, String> = emptyMap()): String? {
var conn: HttpURLConnection? = null
return try {
conn = (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = timeoutMs
readTimeout = timeoutMs
instanceFollowRedirects = true
setRequestProperty("User-Agent", userAgent)
setRequestProperty("Accept", "application/json, text/plain, */*")
if (bearer != null) setRequestProperty("Authorization", "Bearer $bearer")
headers.forEach { (k, v) -> setRequestProperty(k, v) }
}
val code = conn.responseCode
if (code !in 200..299) { Log.w(TAG, "GET $code ${StoreLog.redactUrl(url)}"); null } else readAll(conn)
} catch (e: Exception) {
Log.w(TAG, "GET failed ${StoreLog.redactUrl(url)}: ${e.javaClass.simpleName}")
null
} finally {
runCatching { conn?.disconnect() }
}
}
fun postJson(url: String, body: String, bearer: String? = null, userAgent: String = BROWSER_UA, timeoutMs: Int = 25_000): String? {
var conn: HttpURLConnection? = null
return try {
conn = (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = timeoutMs
readTimeout = timeoutMs
requestMethod = "POST"
doOutput = true
setRequestProperty("User-Agent", userAgent)
setRequestProperty("Content-Type", "application/json")
setRequestProperty("Accept", "application/json")
if (bearer != null) setRequestProperty("Authorization", "Bearer $bearer")
}
OutputStreamWriter(conn.outputStream, "UTF-8").use { it.write(body) }
val code = conn.responseCode
if (code !in 200..299) { Log.w(TAG, "POST $code ${StoreLog.redactUrl(url)}"); null } else readAll(conn)
} catch (e: Exception) {
Log.w(TAG, "POST failed ${StoreLog.redactUrl(url)}: ${e.javaClass.simpleName}")
null
} finally {
runCatching { conn?.disconnect() }
}
}
/**
* A 600x900 poster for [title] from SteamGridDB by name, or "" - the lookup the GOG library and
* the Amazon poster backfill use, since those stores publish no tall art of their own.
*/
fun sgdbPoster(title: String): String = try {
val enc = java.net.URLEncoder.encode(title, "UTF-8")
val search = get("https://www.steamgriddb.com/api/v2/search/autocomplete/$enc", bearer = SGDB_KEY)
val data = search?.let { org.json.JSONObject(it).optJSONArray("data") }
if (data == null || data.length() == 0) "" else {
val gameId = data.getJSONObject(0).getInt("id")
val grids = get("https://www.steamgriddb.com/api/v2/grids/game/$gameId?dimensions=600x900&mimes=image/jpeg,image/png&limit=1", bearer = SGDB_KEY)
?.let { org.json.JSONObject(it).optJSONArray("data") }
if (grids == null || grids.length() == 0) "" else grids.getJSONObject(0).optString("url", "")
}
} catch (_: Exception) { "" }
private const val SGDB_KEY = "cf89227f12c773bb1117b6b109ae1659"
private fun readAll(conn: HttpURLConnection): String {
val sb = StringBuilder()
BufferedReader(InputStreamReader(conn.inputStream, "UTF-8")).use { br ->
val buf = CharArray(8192)
var n: Int
while (br.read(buf).also { n = it } > 0) sb.append(buf, 0, n)
}
return sb.toString()
}
}
/**
* Keeps credentials out of the logs. A store's CDN, manifest and token URLs carry their
* authorization in the query string (signed, time-limited tokens, `client_secret`,
* `refresh_token`) or in the authority's userinfo; one such line in logcat or a shared log hands a
* reader a live download. Every store URL that is written anywhere goes through [redactUrl] first.
*/
object StoreLog {
/** `scheme://host/path` only: query, fragment and `user:pass@` dropped; "[url]" when it cannot be parsed. */
@JvmStatic
fun redactUrl(url: String?): String? {
if (url == null) return null
return try {
var out: String = url
val hash = out.indexOf('#')
if (hash >= 0) out = out.substring(0, hash)
val q = out.indexOf('?')
if (q >= 0) out = out.substring(0, q)
val scheme = out.indexOf("://")
if (scheme >= 0) {
val authStart = scheme + 3
val pathStart = out.indexOf('/', authStart)
val authority = if (pathStart >= 0) out.substring(authStart, pathStart) else out.substring(authStart)
val at = authority.lastIndexOf('@')
if (at >= 0) out = out.substring(0, authStart) + authority.substring(at + 1) + (if (pathStart >= 0) out.substring(pathStart) else "")
}
out.ifEmpty { "[url]" }
} catch (e: Exception) {
"[url]"
}
}
/**
* What any store line may say, for the stores' log and logcat alike: URLs cut to their host and a
* plain first path segment, token values and Authorization / Cookie headers blanked - the shared
* rules of [com.droiddeck.launcher.core.SecretScrub], which a shared log zip applies too.
*/
@JvmStatic
fun redactLine(text: String): String =
com.droiddeck.launcher.core.SecretScrub.scrub(text, com.droiddeck.launcher.core.SecretScrub.Urls.SHORT, "…")
}
@@ -0,0 +1,96 @@
package com.droiddeck.launcher.stores
import android.content.Context
import android.util.Log
import androidx.compose.runtime.mutableStateMapOf
import com.droiddeck.launcher.stores.amazon.AmazonApiClient
import com.droiddeck.launcher.stores.amazon.AmazonCredentialStore
import com.droiddeck.launcher.stores.amazon.AmazonManifest
import com.droiddeck.launcher.stores.amazon.AmazonPrefs
import com.droiddeck.launcher.stores.epic.EpicApiClient
import com.droiddeck.launcher.stores.epic.EpicCredentialStore
import com.droiddeck.launcher.stores.epic.EpicDownloadManager
import com.droiddeck.launcher.stores.epic.EpicInstallTags
import com.droiddeck.launcher.stores.epic.EpicPrefs
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
/**
* Install sizes for owned Epic and Amazon games, whose library answers carry none: Epic's from the
* game's build manifest (the files this device installs), Amazon's from its download manifest.
* Asked for a card as it comes on screen, three at a time in the background, once per game - the
* answer is kept in the store's prefs (`size_<id>`, where the library reads it too). Until it is
* known the card shows nothing.
*/
object StoreSizes {
private const val TAG = "StoreSizes"
/** Sizes found this run, by card key; the cards read them. */
val known = mutableStateMapOf<String, Long>()
private val asked = ConcurrentHashMap.newKeySet<String>()
private val pool = Executors.newFixedThreadPool(3, com.droiddeck.launcher.stores.download.DownloadQueue.workerFactory("sizes"))
fun size(item: CatalogItem): Long = known[item.key] ?: item.sizeBytes
private val caches = ConcurrentHashMap<Store, SizeCache>()
private fun cache(context: Context, store: Store) = caches.getOrPut(store) { SizeCache.forStore(context.applicationContext.filesDir, store) }
/** A card for [item] is on screen (or about to be): its size from the cache for this version, else looked up. */
fun request(context: Context, item: CatalogItem) {
if (!item.owned || item.sizeBytes > 0 || item.store == Store.GOG) return
if (known.containsKey(item.key) || !asked.add(item.key)) return
val app = context.applicationContext
val version = item.extra["version"].orEmpty()
pool.execute {
val cached = cache(app, item.store).get(item.id, version)
if (cached != null) { StoresState.post { known[item.key] = cached }; return@execute }
val bytes = runCatching { fetch(app, item) }.onFailure { Log.w(TAG, "${item.key}: ${it.javaClass.simpleName}") }.getOrNull() ?: 0L
if (bytes > 0) {
cache(app, item.store).put(item.id, version, bytes)
when (item.store) {
Store.EPIC -> EpicPrefs.get(app).edit().putLong("size_${item.id}", bytes).apply()
Store.AMAZON -> AmazonPrefs.get(app).edit().putLong("size_${item.id}", bytes).apply()
Store.GOG -> {}
}
StoresState.post { known[item.key] = bytes }
}
}
}
/** An installed game's size on disk, by card key; worked out once per install. */
val onDisk = mutableStateMapOf<String, Long>()
fun requestDisk(context: Context, key: String, game: InstalledStoreGame) {
if (onDisk.containsKey(key) || !asked.add("disk:$key")) return
val app = context.applicationContext
val version = "disk:${game.sidecar.installedAt}:${game.sidecar.installVersion}"
pool.execute {
val c = cache(app, game.sidecar.store)
val bytes = c.get("disk:${game.sidecar.id}", version)
?: game.folder.walkTopDown().filter { it.isFile && !it.name.startsWith(".droiddeck") }.sumOf { it.length() }.also { c.put("disk:${game.sidecar.id}", version, it) }
if (bytes > 0) StoresState.post { onDisk[key] = bytes }
}
}
private fun fetch(context: Context, item: CatalogItem): Long { return when (item.store) {
Store.EPIC -> {
val token = EpicCredentialStore.getValidAccessToken(context) ?: return 0L
val ns = item.extra["namespace"].orEmpty()
val catalogItemId = item.extra["catalogItemId"].orEmpty()
if (ns.isEmpty() || catalogItemId.isEmpty()) return 0L
val json = EpicApiClient.getManifestApiJson(token, ns, catalogItemId, item.id) ?: return 0L
val bytes = EpicDownloadManager.downloadManifest(json, EpicDownloadManager.parseCdnUrls(json)) ?: return 0L
val manifest = EpicDownloadManager.parseManifest(bytes) ?: return 0L
EpicDownloadManager.resolveInstallFiles(manifest, EpicInstallTags.tagsForDevice()).sumOf { it.fileSize() }
}
Store.AMAZON -> {
val token = AmazonCredentialStore.getValidAccessToken(context) ?: return 0L
val entitlement = item.extra["entitlementId"].orEmpty().ifEmpty { return 0L }
val spec = AmazonApiClient.getGameDownload(token, entitlement) ?: return 0L
val bytes = AmazonApiClient.getBytes(AmazonApiClient.appendPath(spec.downloadUrl, "manifest.proto"), token) ?: return 0L
AmazonManifest.parse(bytes).totalInstallSize
}
Store.GOG -> 0L
} }
}
@@ -0,0 +1,54 @@
package com.droiddeck.launcher.stores
import android.util.Log
/**
* The native download engine for the three stores: `libdroiddeckstores.so`, loaded the first time
* a store needs it, never at process start. The app has to run without it (a build without the
* Rust side, or an ABI the library was not built for), so [available] is a plain false then and
* Setup says "Stores engine not built"; the store managers fall back to their Java fetch loops.
*/
object StoresNative {
private const val TAG = "StoresNative"
private const val LIBRARY = "droiddeckstores"
@Volatile private var loaded: Boolean? = null
@Volatile private var versionText: String? = null
@Volatile private var failure: String? = null
/** True once the library is loaded and its version symbol binds; false when it cannot be. Cached. */
val available: Boolean get() = ensureLoaded()
/** The engine's own version string, or null when the library is not there. */
val version: String? get() { ensureLoaded(); return versionText }
/** Why the library did not load, for the Setup row; null when it did. */
val problem: String? get() { ensureLoaded(); return failure }
@Synchronized
fun ensureLoaded(): Boolean {
loaded?.let { return it }
val ok = try {
System.loadLibrary(LIBRARY)
versionText = nativeVersion()
Log.i(TAG, "engine ${versionText ?: "?"} loaded")
true
} catch (t: Throwable) {
// UnsatisfiedLinkError when the .so is missing or a symbol is not there; anything else is
// a surprise worth the same treatment: the Java paths carry on.
failure = t.javaClass.simpleName + (t.message?.let { ": $it" } ?: "")
Log.w(TAG, "engine unavailable: $failure")
false
}
loaded = ok
return ok
}
/** Resets the cached answer, so a probe after an update tries the library again. */
@Synchronized
fun forget() { loaded = null }
/** The crate's version string; only after [ensureLoaded] said yes. */
@JvmStatic
external fun nativeVersion(): String
}
@@ -0,0 +1,281 @@
package com.droiddeck.launcher.stores
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.droiddeck.launcher.stores.download.DownloadEntry
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* The Stores section's state for the life of the process (like the Flathub store's StoreState):
* who is signed in where, each store's library and shelves, what is installed on disk and what is
* being downloaded, so a download carries on - and its progress shows - while the user moves
* around the launcher or plays something meanwhile.
*
* Everything here is read on the main thread by Compose; the workers post their results back.
* The stores themselves plug in as [StoreBackend]s; a store with no backend in this build shows
* its sign-in card and nothing else.
*/
object StoresState {
private const val TAG = "Stores"
private val main = Handler(Looper.getMainLooper())
/** The signed-in account name per store; absent = signed out. */
val accounts = mutableStateMapOf<Store, String>()
/** Each store's owned games, once synced; absent until then. */
val library = mutableStateMapOf<Store, List<CatalogItem>>()
/** Each store's storefront shelves, once fetched. */
val shelves = mutableStateMapOf<Store, StoreShelves>()
/** What a store is doing for the user right now ("Syncing 12 games…"); absent when idle. */
val status = mutableStateMapOf<Store, String>()
/** Why the last sync or shelf fetch failed, per store; cleared by the next success. */
val problems = mutableStateMapOf<Store, String>()
/** Games the stores installed, as their sidecars say; rebuilt by [refresh]. */
var installed by mutableStateOf<List<InstalledStoreGame>>(emptyList())
private set
/**
* The download queue as the Downloads page lists it, newest first. Replaced only when a row is
* added, removed, reordered or changes state - not on progress - so whatever iterates it does
* not recompose on every byte. A row's progress is read through [download].
*/
var downloads by mutableStateOf<List<DownloadEntry>>(emptyList())
private set
/** Downloads queued, running or paused across the stores (the rail badge); changes only when the count does. */
var activeDownloads by mutableStateOf(0)
private set
private val rows = HashMap<String, androidx.compose.runtime.MutableState<DownloadEntry?>>()
/** One row's live entry: a composable reading it recomposes for that row's progress only. Main thread. */
fun download(key: String): DownloadEntry? = rows.getOrPut(key) { mutableStateOf(null) }.value
/** Main thread: each row's holder set (equal entries change nothing), the list and the count only when they change. */
/** The newest list as published, progress included; for the notification, which is not composed. */
@Volatile var latestDownloads: List<DownloadEntry> = emptyList()
private set
private var notifiedAt = 0L
internal fun publishDownloads(list: List<DownloadEntry>) {
latestDownloads = list
// The notification follows at most once a second.
val now = android.os.SystemClock.uptimeMillis()
if (now - notifiedAt >= 1000) { notifiedAt = now; com.droiddeck.launcher.stores.download.StoreDownloadService.tick() }
val keys = list.mapTo(HashSet()) { it.key }
for (e in list) rows.getOrPut(e.key) { mutableStateOf(null) }.value = e
for ((k, holder) in rows) if (k !in keys) holder.value = null
val shape = { l: List<DownloadEntry> -> l.map { it.key to it.state } }
if (shape(list) != shape(downloads)) downloads = list
activeDownloads = list.count { it.isActive }
}
/** The native engine's version once probed, "" when the probe said it is missing, null before. */
var engine by mutableStateOf<String?>(null)
private set
private val backends = HashMap<Store, StoreBackend>()
/** A store's implementation, registered once as the app starts; null when this build lacks it. */
fun backend(store: Store): StoreBackend? = backends[store]
fun register(backend: StoreBackend) { backends[backend.store] = backend }
/** Reads what is on disk for every store: sign-ins, installed games, the engine probe. Off the main thread. */
fun refresh(context: Context) {
val app = context.applicationContext
Thread({
val found = Store.entries.mapNotNull { store -> StoreAccounts.signedInAs(app, store)?.let { store to it } }
val onDisk = StoreInstallRoot.gameFolders(app).mapNotNull { folder ->
StoreGameSidecar.read(folder)?.takeIf { it.isInstalled }?.let { InstalledStoreGame(it, folder) }
}
val partial = StoreInstallRoot.unfinished(app)
val version = if (StoresNative.available) StoresNative.version ?: "" else ""
main.post {
accounts.clear()
found.forEach { (store, name) -> accounts[store] = name }
installed = onDisk
unfinished = partial
engine = version
}
}, "stores-refresh").start()
}
fun isSignedIn(store: Store): Boolean = accounts.containsKey(store)
/** A store action's error line when it found no usable sign-in: gone, or on disk but unreadable for now. */
fun notSignedInLine(store: Store): String =
if (StoreAccounts.isUnavailable(store)) "${store.shortLabel} sign-in could not be read: try again."
else "${store.shortLabel} session expired: sign in again."
/** Stores whose sign-in has run out (a launch could not get a code): their chip dims until the user signs in again. */
val expired = mutableStateMapOf<Store, Boolean>()
fun markSignInExpired(store: Store) = post { expired[store] = true }
/** Installs that stopped before they finished; their Install button reads "Resume install". */
var unfinished by mutableStateOf<List<StoreInstallRoot.Unfinished>>(emptyList())
internal set
fun isUnfinished(item: CatalogItem): Boolean = unfinished.any {
it.store == item.store && (it.id == item.id || (it.id == null && it.folder.name == StoreInstallRoot.folderName(item.title, item.id)))
}
/** Deletes an unfinished install's folder and caches, as Cancel would; drops its row if there is one. */
fun clearUnfinished(context: Context, item: CatalogItem) {
val app = context.applicationContext
val key = "${item.store.id}:${item.id}"
if (com.droiddeck.launcher.stores.download.DownloadQueue.entry(key) != null) { com.droiddeck.launcher.stores.download.DownloadQueue.clear(app, key); return }
val folder = unfinished.firstOrNull {
it.store == item.store && (it.id == item.id || (it.id == null && it.folder.name == StoreInstallRoot.folderName(item.title, item.id)))
}?.folder ?: return
StoreInstalls.discard(app, folder, listOf(java.io.File(folder, ".chunks"), java.io.File(folder, ".gog_chunks"), StoreInstallRoot.scratchDir(app, item.store, item.id)))
}
fun installedGame(store: Store, id: String): InstalledStoreGame? = installed.firstOrNull { it.sidecar.store == store && it.sidecar.id == id }
/** The user's library and the shelves for [store], from the cache first and the network when stale. */
fun open(context: Context, store: Store, force: Boolean = false) {
val backend = backends[store] ?: return
val app = context.applicationContext
if (!isSignedIn(store)) return
if (force || !library.containsKey(store)) backend.syncLibrary(app, force)
if (force || !shelves.containsKey(store)) backend.loadShelves(app, force)
}
/** A store not signed into: its public storefront only, for its sign-in page to show. */
fun preview(context: Context, store: Store) {
if (!shelves.containsKey(store)) backends[store]?.loadShelves(context.applicationContext, false)
}
fun signIn(context: Context, store: Store) {
expired.remove(store)
backends[store]?.signIn(context) ?: logLine("${store.label}: this build has no sign-in for it yet")
}
fun signOut(context: Context, store: Store) {
val app = context.applicationContext
Thread({
backends[store]?.signOut(app)
StoreAccounts.clear(app, store)
main.post {
accounts.remove(store); library.remove(store); status.remove(store); problems.remove(store)
logLine("${store.label}: signed out")
}
}, "stores-signout").start()
}
/** A notification asked for the Downloads chip; the Stores page takes it and clears it. */
var openDownloads by mutableStateOf(false)
/**
* Android 13+: notifications need permission. Asked once, the first time a download starts from
* a screen; refused, downloads still run, without their notification.
*/
private fun askForNotifications(context: Context) {
val activity = context as? android.app.Activity ?: return
if (android.os.Build.VERSION.SDK_INT < 33) return
if (activity.checkSelfPermission("android.permission.POST_NOTIFICATIONS") == android.content.pm.PackageManager.PERMISSION_GRANTED) return
val p = activity.getSharedPreferences("stores", Context.MODE_PRIVATE)
if (p.getBoolean("asked_notifications", false)) return
p.edit().putBoolean("asked_notifications", true).apply()
runCatching { activity.requestPermissions(arrayOf("android.permission.POST_NOTIFICATIONS"), 7102) }
}
/** An install waiting for the user to say where: the page shows the choice while this is set. */
var pendingInstall by mutableStateOf<CatalogItem?>(null)
/**
* The way a page starts an install: with a card in the device the user is asked where (the
* dialog's default is last time's pick, never a silent choice); with none it goes to internal
* storage at once.
*/
fun requestInstall(context: Context, item: CatalogItem) {
askForNotifications(context)
// A resume goes where the files already are; there is nothing to choose.
if (isUnfinished(item)) { install(context, item); return }
val targets = StoreInstallRoot.targets(context)
if (targets.size > 1) pendingInstall = item else install(context, item, targets.first().root)
}
/** Starts an install into [root] (the internal root when null). */
fun install(context: Context, item: CatalogItem, root: java.io.File? = null) {
val app = context.applicationContext
val target = root ?: StoreInstallRoot.installRoot(app)
backends[item.store]?.install(app, item, target) ?: logLine("${item.store.label}: this build cannot install yet")
}
/** Removes the game's folder and its shortcut, then [onDone] on the main thread. */
fun uninstall(context: Context, game: InstalledStoreGame, onDone: () -> Unit = {}) {
val app = context.applicationContext
Thread({
runCatching { backends[game.sidecar.store]?.uninstall(app, game) }
StoreInstalls.uninstall(app, game)
main.post(onDone)
}, "stores-uninstall").start()
}
/** Told on the main thread when an install or removal changed what is on disk; the launcher rebuilds its Games list. */
@Volatile var libraryListener: (() -> Unit)? = null
/** A game landed or went: the installed set is re-read and the launcher told. Any thread. */
fun notifyLibraryChanged(context: Context) {
refresh(context)
main.post { libraryListener?.invoke() }
}
/** Appends to the engine log with a clock, keeping the last 80 lines. Any thread. */
fun logLine(raw: String) {
// Every store line passes here, the engines' included: no token or full URL is kept.
val text = StoreLog.redactLine(raw)
Log.i(TAG, text)
val app = appContext ?: return
StoreLogFiles.append(app, "${synchronized(CLOCK) { CLOCK.format(Date()) }} $text")
}
private var appContext: Context? = null
/** Called once as the app starts: where the log file goes, and the old days pruned. */
fun init(context: Context) {
appContext = context.applicationContext
StoreLogFiles.prune(context)
// The EOS overlay an earlier build downloaded (656 MB) is gone with the feature.
val overlay = java.io.File(com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(context), "root/.local/share/droiddeck/epic-overlay")
if (overlay.exists()) Thread({ overlay.deleteRecursively(); Log.i(TAG, "removed the old EOS overlay download") }, "epic-overlay-cleanup").start()
// Cloud saves a killed app never uploaded; not while a session (and maybe that game) runs.
val app = context.applicationContext
Thread({ CloudSaves.uploadAllDirty(app, "recovery") }, "cloud-recovery").start()
}
internal fun post(block: () -> Unit) = main.post(block)
private val CLOCK = SimpleDateFormat("HH:mm:ss.SSS", Locale.US)
}
/** What one store contributes: sign-in, its library, its shelves, installs and removals. */
interface StoreBackend {
val store: Store
fun signIn(context: Context)
fun signOut(context: Context)
fun syncLibrary(context: Context, force: Boolean)
fun loadShelves(context: Context, force: Boolean)
/** Queues an install of [item] under [root] (a StoreInstallRoot target). */
fun install(context: Context, item: CatalogItem, root: java.io.File)
/** The store's own records of the install, before the folder goes (the queue row, cached ids). */
fun uninstall(context: Context, game: InstalledStoreGame)
}
@@ -0,0 +1,206 @@
package com.droiddeck.launcher.stores.amazon;
import android.util.Log;
import com.droiddeck.launcher.stores.StoreLog;
import org.json.JSONArray;
import org.json.JSONObject;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Amazon's distribution API, as the launcher calls it: every gaming call carries `X-Amz-Target`,
* `x-amzn-token`, the launcher's User-Agent and `Content-Encoding: amz-1.0`.
*/
public final class AmazonApiClient {
private static final String TAG = "AmazonApi";
private static final String ENTITLEMENTS_URL = "https://gaming.amazon.com/api/distribution/entitlements";
private static final String DISTRIBUTION_URL = "https://gaming.amazon.com/api/distribution/v2/public";
private static final String GAMING_USER_AGENT = "com.amazon.agslauncher.win/3.0.9202.1";
private static final String DOWNLOAD_USER_AGENT = "nile/0.1 Amazon";
private static final String KEY_ID = "d5dc8b8b-86c8-4fc4-ae93-18c0def5314d";
private AmazonApiClient() {}
/** The owned games, every page, one per productId. */
public static List<AmazonGame> getEntitlements(String accessToken, String deviceSerial) {
Map<String, AmazonGame> seen = new HashMap<>();
String nextToken = null;
String hardwareHash;
try {
hardwareHash = AmazonPkce.sha256Upper(deviceSerial);
} catch (Exception e) {
Log.e(TAG, "hardware hash failed", e);
return new ArrayList<>();
}
do {
try {
JSONObject body = new JSONObject();
body.put("Operation", "GetEntitlements");
body.put("clientId", "Sonic");
body.put("syncPoint", JSONObject.NULL);
body.put("nextToken", nextToken != null ? nextToken : JSONObject.NULL);
body.put("maxResults", 50);
body.put("productIdFilter", JSONObject.NULL);
body.put("keyId", KEY_ID);
body.put("hardwareHash", hardwareHash);
String resp = postGaming(ENTITLEMENTS_URL, "com.amazon.animusdistributionservice.entitlement.AnimusEntitlementsService.GetEntitlements", accessToken, body.toString());
if (resp == null) break;
JSONObject json = new JSONObject(resp);
JSONArray entitlements = json.optJSONArray("entitlements");
if (entitlements == null) break;
for (int i = 0; i < entitlements.length(); i++) {
AmazonGame game = parseEntitlement(entitlements.getJSONObject(i));
if (game != null && !game.productId.isEmpty()) seen.put(game.productId, game);
}
nextToken = json.optString("nextToken", null);
if (nextToken != null && nextToken.isEmpty()) nextToken = null;
} catch (Exception e) {
Log.e(TAG, "entitlements page failed: " + e.getClass().getSimpleName());
break;
}
} while (nextToken != null);
return new ArrayList<>(seen.values());
}
static AmazonGame parseEntitlement(JSONObject e) {
try {
JSONObject product = e.optJSONObject("product");
if (product == null) return null;
AmazonGame game = new AmazonGame();
game.entitlementId = e.optString("id", "");
game.productId = product.optString("id", "");
game.title = product.optString("title", "Unknown");
game.productSku = product.optString("sku", "");
String productType = product.optString("productType", "");
if (productType.isEmpty()) productType = product.optString("type", "");
String parentId = product.optString("parentProductId", "");
if (parentId.isEmpty()) parentId = product.optString("baseProductId", "");
if (parentId.isEmpty()) parentId = product.optString("parentId", "");
JSONObject detail = product.optJSONObject("productDetail");
if (detail != null) {
// The opaque images only: the square icon, and for the wide card the second
// background (a plain key art) before the first (often faded) or the crown image.
game.artUrl = detail.optString("iconUrl", "");
JSONObject details = detail.optJSONObject("details");
if (details != null) {
if (game.artUrl.isEmpty()) game.artUrl = details.optString("pgCrownImageUrl", "");
game.heroUrl = details.optString("backgroundUrl2", "");
if (game.heroUrl.isEmpty()) game.heroUrl = details.optString("backgroundUrl1", "");
if (game.heroUrl.isEmpty()) game.heroUrl = details.optString("pgCrownImageUrl", "");
game.developer = details.optString("developer", "");
game.publisher = details.optString("publisher", "");
if (productType.isEmpty()) productType = details.optString("productType", "");
if (parentId.isEmpty()) parentId = details.optString("parentProductId", "");
}
}
boolean typeIsDlc = !productType.isEmpty() && !productType.equalsIgnoreCase("GAME");
if (typeIsDlc || !parentId.isEmpty()) { game.isDLC = true; game.parentProductId = parentId; }
return game;
} catch (Exception ex) {
Log.e(TAG, "entitlement parse failed: " + ex.getClass().getSimpleName());
return null;
}
}
public static final class GameDownloadSpec {
public String downloadUrl;
public String versionId;
}
/** The download root and version for a game; takes the entitlement id, not the product id. */
public static GameDownloadSpec getGameDownload(String accessToken, String entitlementId) {
try {
JSONObject body = new JSONObject().put("entitlementId", entitlementId).put("Operation", "GetGameDownload");
String resp = postGaming(DISTRIBUTION_URL, "com.amazon.animusdistributionservice.external.AnimusDistributionService.GetGameDownload", accessToken, body.toString());
if (resp == null) return null;
JSONObject json = new JSONObject(resp);
GameDownloadSpec spec = new GameDownloadSpec();
spec.downloadUrl = json.optString("downloadUrl", "");
spec.versionId = json.optString("versionId", "");
return spec.downloadUrl.isEmpty() ? null : spec;
} catch (Exception e) {
Log.e(TAG, "GetGameDownload failed: " + e.getClass().getSimpleName());
return null;
}
}
/** The live version id of a product, for update checks. */
public static String getLiveVersionId(String accessToken, String productId) {
try {
JSONObject body = new JSONObject().put("adgProductIds", new JSONArray().put(productId)).put("Operation", "GetLiveVersionIds");
String resp = postGaming(DISTRIBUTION_URL, "com.amazon.animusdistributionservice.external.AnimusDistributionService.GetLiveVersionIds", accessToken, body.toString());
if (resp == null) return null;
JSONObject versions = new JSONObject(resp).optJSONObject("versionIds");
return versions == null ? null : versions.optString(productId, null);
} catch (Exception e) {
Log.e(TAG, "GetLiveVersionIds failed: " + e.getClass().getSimpleName());
return null;
}
}
/** `baseUrl/segment`, keeping a query string on the base where it belongs. */
public static String appendPath(String baseUrl, String segment) {
int q = baseUrl.indexOf('?');
if (q >= 0) return baseUrl.substring(0, q) + "/" + segment + baseUrl.substring(q);
return baseUrl + "/" + segment;
}
static String postGaming(String urlStr, String target, String accessToken, String body) {
try {
HttpURLConnection conn = (HttpURLConnection) new URL(urlStr).openConnection();
conn.setRequestMethod("POST");
conn.setConnectTimeout(30000);
conn.setReadTimeout(30000);
conn.setDoOutput(true);
conn.setRequestProperty("X-Amz-Target", target);
conn.setRequestProperty("x-amzn-token", accessToken);
conn.setRequestProperty("User-Agent", GAMING_USER_AGENT);
conn.setRequestProperty("Content-Type", "application/json");
conn.setRequestProperty("Content-Encoding", "amz-1.0");
try (OutputStream os = conn.getOutputStream()) { os.write(body.getBytes(StandardCharsets.UTF_8)); }
int code = conn.getResponseCode();
String resp = AmazonAuthClient.readStream(code < 400 ? conn.getInputStream() : conn.getErrorStream());
conn.disconnect();
if (code < 200 || code >= 300) { Log.e(TAG, "HTTP " + code + " from " + StoreLog.redactUrl(urlStr)); return null; }
return resp;
} catch (Exception e) {
Log.e(TAG, "POST failed: " + StoreLog.redactUrl(urlStr) + " (" + e.getClass().getSimpleName() + ")");
return null;
}
}
/** Raw bytes (the manifest). */
public static byte[] getBytes(String urlStr, String accessToken) {
try {
HttpURLConnection conn = (HttpURLConnection) new URL(urlStr).openConnection();
conn.setRequestMethod("GET");
conn.setConnectTimeout(30000);
conn.setReadTimeout(120000);
if (accessToken != null) conn.setRequestProperty("x-amzn-token", accessToken);
conn.setRequestProperty("User-Agent", DOWNLOAD_USER_AGENT);
int code = conn.getResponseCode();
if (code < 200 || code >= 300) { Log.e(TAG, "bytes HTTP " + code + " from " + StoreLog.redactUrl(urlStr)); conn.disconnect(); return null; }
java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
try (java.io.InputStream in = conn.getInputStream()) {
byte[] buf = new byte[131072];
int n;
while ((n = in.read(buf)) != -1) out.write(buf, 0, n);
}
conn.disconnect();
return out.toByteArray();
} catch (Exception e) {
Log.e(TAG, "bytes failed: " + StoreLog.redactUrl(urlStr) + " (" + e.getClass().getSimpleName() + ")");
return null;
}
}
}
@@ -0,0 +1,174 @@
package com.droiddeck.launcher.stores.amazon;
import android.util.Log;
import com.droiddeck.launcher.stores.StoreLog;
import org.json.JSONArray;
import org.json.JSONObject;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
/**
* Amazon's device-auth endpoints, as the Amazon Games launcher registers itself:
* POST api.amazon.com/auth/register (the PKCE code for bearer tokens, with a device registration)
* POST api.amazon.com/auth/token (access token renewal; the refresh token stays)
* POST api.amazon.com/auth/deregister (sign-out, best-effort)
*/
public final class AmazonAuthClient {
private static final String TAG = "AmazonAuth";
static final String REGISTER_URL = "https://api.amazon.com/auth/register";
static final String REFRESH_URL = "https://api.amazon.com/auth/token";
static final String DEREGISTER_URL = "https://api.amazon.com/auth/deregister";
public static final String USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0";
static final String APP_NAME = "AGSLauncher for Windows";
static final String APP_VERSION = "1.0.0";
static final String DEVICE_TYPE = "A2UMVHOX7UP4V7";
static final String OS_VERSION = "10.0.19044.0";
public static final class RegisterResult {
public String accessToken;
public String refreshToken;
public long expiresIn;
public String name;
}
private AmazonAuthClient() {}
/** Exchanges the PKCE authorization code for bearer tokens; null on failure. */
public static RegisterResult registerDevice(String authCode, String codeVerifier, String deviceSerial, String clientId) {
try {
JSONObject authData = new JSONObject();
authData.put("authorization_code", authCode);
authData.put("client_domain", "DeviceLegacy");
authData.put("client_id", clientId);
authData.put("code_algorithm", "SHA-256");
authData.put("code_verifier", codeVerifier);
authData.put("use_global_authentication", false);
JSONObject regData = new JSONObject();
regData.put("app_name", APP_NAME);
regData.put("app_version", APP_VERSION);
regData.put("device_model", "Windows");
regData.put("device_name", JSONObject.NULL);
regData.put("device_serial", deviceSerial);
regData.put("device_type", DEVICE_TYPE);
regData.put("domain", "Device");
regData.put("os_version", OS_VERSION);
JSONArray tokenTypes = new JSONArray().put("bearer").put("mac_dms");
JSONArray extensions = new JSONArray().put("customer_info").put("device_info");
JSONObject body = new JSONObject();
body.put("auth_data", authData);
body.put("registration_data", regData);
body.put("requested_extensions", extensions);
body.put("requested_token_type", tokenTypes);
body.put("user_context_map", new JSONObject());
String response = postJson(REGISTER_URL, body.toString(), null, null, null);
if (response == null) return null;
JSONObject success = new JSONObject(response).getJSONObject("response").getJSONObject("success");
JSONObject bearer = success.getJSONObject("tokens").getJSONObject("bearer");
RegisterResult result = new RegisterResult();
result.accessToken = bearer.getString("access_token");
result.refreshToken = bearer.getString("refresh_token");
result.expiresIn = bearer.optLong("expires_in", 3600L);
JSONObject customer = success.optJSONObject("extensions") != null ? success.getJSONObject("extensions").optJSONObject("customer_info") : null;
result.name = customer != null ? customer.optString("name", "") : "";
return result;
} catch (Exception e) {
Log.e(TAG, "registerDevice failed: " + e.getClass().getSimpleName());
return null;
}
}
/** A new access token; the refresh token is not returned and stays as it was. */
public static RegisterResult refreshAccessToken(String refreshToken) {
try {
JSONObject body = new JSONObject();
body.put("source_token", refreshToken);
body.put("source_token_type", "refresh_token");
body.put("requested_token_type", "access_token");
body.put("app_name", APP_NAME);
body.put("app_version", APP_VERSION);
String response = postJson(REFRESH_URL, body.toString(), null, "x-amzn-identity-auth-domain", "api.amazon.com");
if (response == null) return null;
JSONObject json = new JSONObject(response);
RegisterResult result = new RegisterResult();
result.accessToken = json.getString("access_token");
result.refreshToken = refreshToken;
result.expiresIn = json.optLong("expires_in", 3600L);
return result;
} catch (Exception e) {
Log.e(TAG, "refresh failed: " + e.getClass().getSimpleName());
return null;
}
}
public static void deregisterDevice(String accessToken) {
try {
JSONObject body = new JSONObject().put("requested_extensions", new JSONArray().put("device_info").put("customer_info"));
postJson(DEREGISTER_URL, body.toString(), accessToken, null, null);
} catch (Exception e) {
Log.w(TAG, "deregister failed (ignored): " + e.getClass().getSimpleName());
}
}
static String postJson(String urlStr, String body, String bearerToken, String extraKey, String extraVal) {
try {
HttpURLConnection conn = (HttpURLConnection) new URL(urlStr).openConnection();
conn.setRequestMethod("POST");
conn.setConnectTimeout(30000);
conn.setReadTimeout(30000);
conn.setDoOutput(true);
conn.setRequestProperty("User-Agent", USER_AGENT);
conn.setRequestProperty("Content-Type", "application/json");
conn.setRequestProperty("Accept", "application/json");
if (bearerToken != null) conn.setRequestProperty("Authorization", "Bearer " + bearerToken);
if (extraKey != null) conn.setRequestProperty(extraKey, extraVal);
try (OutputStream os = conn.getOutputStream()) { os.write(body.getBytes(StandardCharsets.UTF_8)); }
int code = conn.getResponseCode();
String resp = readStream(code < 400 ? conn.getInputStream() : conn.getErrorStream());
conn.disconnect();
if (code < 200 || code >= 300) { Log.e(TAG, "HTTP " + code + " from " + StoreLog.redactUrl(urlStr)); return null; }
return resp;
} catch (Exception e) {
Log.e(TAG, "POST failed: " + StoreLog.redactUrl(urlStr) + " (" + e.getClass().getSimpleName() + ")");
return null;
}
}
static String getRequest(String urlStr, String token, String tokenHeader, String extraKey, String extraVal) {
try {
HttpURLConnection conn = (HttpURLConnection) new URL(urlStr).openConnection();
conn.setRequestMethod("GET");
conn.setConnectTimeout(30000);
conn.setReadTimeout(120000);
if (token != null && tokenHeader != null) conn.setRequestProperty(tokenHeader, token);
if (extraKey != null) conn.setRequestProperty(extraKey, extraVal);
int code = conn.getResponseCode();
String resp = readStream(code < 400 ? conn.getInputStream() : conn.getErrorStream());
conn.disconnect();
if (code < 200 || code >= 300) { Log.e(TAG, "GET HTTP " + code + " from " + StoreLog.redactUrl(urlStr)); return null; }
return resp;
} catch (Exception e) {
Log.e(TAG, "GET failed: " + StoreLog.redactUrl(urlStr) + " (" + e.getClass().getSimpleName() + ")");
return null;
}
}
static String readStream(InputStream is) throws IOException {
if (is == null) return "";
StringBuilder sb = new StringBuilder();
try (BufferedReader br = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) {
String line;
while ((line = br.readLine()) != null) sb.append(line);
}
return sb.toString();
}
}
@@ -0,0 +1,167 @@
package com.droiddeck.launcher.stores.amazon
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.util.Log
import android.webkit.WebView
import com.droiddeck.launcher.stores.CatalogItem
import com.droiddeck.launcher.stores.InstalledStoreGame
import com.droiddeck.launcher.stores.LoginFlows
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreBackend
import com.droiddeck.launcher.stores.StoreExe
import com.droiddeck.launcher.stores.StoreGameSidecar
import com.droiddeck.launcher.stores.StoreInstallRoot
import com.droiddeck.launcher.stores.StoreInstalls
import com.droiddeck.launcher.stores.StoreLoginActivity
import com.droiddeck.launcher.stores.StoreShelves
import com.droiddeck.launcher.stores.StoresState
import com.droiddeck.launcher.stores.download.DownloadEntry
import com.droiddeck.launcher.stores.download.DownloadQueue
import com.droiddeck.launcher.stores.download.DownloadStage
import java.io.File
import java.util.concurrent.atomic.AtomicBoolean
/**
* Amazon Games in the Stores section. Amazon has no browsable catalog for third-party clients -
* Prime Gaming's claims live on gaming.amazon.com, signed in - so the Store tab's shelves are cut
* from the account's own library: owned-not-installed as Trending, installed as Your library.
*/
object AmazonBackend : StoreBackend {
private const val TAG = "AmazonBackend"
override val store: Store = Store.AMAZON
init { LoginFlows.amazonFlow = { LoginFlow() } }
override fun signIn(context: Context) {
context.startActivity(Intent(context, StoreLoginActivity::class.java).putExtra(StoreLoginActivity.EXTRA_STORE, store.id).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
override fun signOut(context: Context) {
AmazonCredentialStore.load(context)?.let { Thread({ AmazonAuthClient.deregisterDevice(it.accessToken) }, "amazon-deregister").start() }
AmazonPrefs.get(context).edit().remove("library_cache").remove("library_synced_at").apply()
}
override fun syncLibrary(context: Context, force: Boolean) {
val app = context.applicationContext
Thread({
// The cache off the main thread; with it on screen a background sync keeps quiet.
val cached = AmazonLibrary.cached(app)
val quiet = cached.isNotEmpty() && !force
StoresState.post {
if (cached.isNotEmpty()) publish(app, cached)
if (!quiet) StoresState.status[store] = "Fetching your library…"
}
val result = AmazonLibrary.sync(app, force) { line -> if (!quiet) StoresState.post { StoresState.status[store] = line } }
StoresState.post {
StoresState.status.remove(store)
when (result) {
is AmazonLibrary.SyncResult.Ok -> { publish(app, result.games); StoresState.problems.remove(store) }
is AmazonLibrary.SyncResult.Failed -> StoresState.problems[store] = result.message
AmazonLibrary.SyncResult.NotLoggedIn -> StoresState.problems[store] = StoresState.notSignedInLine(store)
AmazonLibrary.SyncResult.Throttled -> publish(app, AmazonLibrary.cached(app))
AmazonLibrary.SyncResult.Busy -> {}
}
}
}, "amazon-sync").start()
}
/** The library and, from it, the shelves. */
private fun publish(app: Context, games: List<AmazonGame>) {
val items = games.map { AmazonLibrary.toCatalogItem(app, it) }
StoresState.library[store] = com.droiddeck.launcher.stores.mergeItems(StoresState.library[store], items)
val installed = StoresState.installed.filter { it.sidecar.store == store }.map { it.sidecar.id }.toSet()
StoresState.shelves[store] = StoreShelves(trending = items.filter { it.id !in installed }, free = emptyList(), deals = emptyList(), whatsNew = emptyList())
}
override fun loadShelves(context: Context, force: Boolean) {
// Nothing public to fetch; the shelves follow the library sync.
if (!StoresState.library.containsKey(store)) syncLibrary(context, force)
}
override fun install(context: Context, item: CatalogItem, root: File) {
val app = context.applicationContext
val entitlementId = item.extra["entitlementId"] ?: ""
if (entitlementId.isEmpty()) { StoresState.logLine("Amazon: \"${item.title}\" has no entitlement to download with"); return }
val game = AmazonGame().apply { productId = item.id; this.entitlementId = entitlementId; title = item.title; productSku = item.extra["sku"] ?: ""; heroUrl = item.imageUrl ?: ""; artUrl = item.tallImageUrl ?: "" }
val folder = StoreInstallRoot.folderFor(app, Store.AMAZON, game.productId, game.title, root)
DownloadQueue.enqueue(app, DownloadEntry(store, item.id, item.title, cover = item.imageUrl, bytesTotal = item.sizeBytes, location = StoreInstallRoot.labelFor(app, folder))) { InstallJob(app, game, item, folder) }
}
override fun uninstall(context: Context, game: InstalledStoreGame) {}
private class InstallJob(val app: Context, val game: AmazonGame, val item: CatalogItem, val folder: File) : DownloadQueue.DownloadJob {
private val cancelled = AtomicBoolean(false)
override fun run(handle: DownloadQueue.JobHandle): String? {
handle.stage(DownloadStage.MANIFEST, "Checking sign-in…")
val token = AmazonCredentialStore.getValidAccessToken(app) ?: throw AmazonDownloadManager.InstallException("Not signed in to Amazon Games")
StoreInstalls.begin(folder, Store.AMAZON, game.productId, game.title, item.tallImageUrl ?: item.imageUrl, item.imageUrl)
var downloading = false
val result = AmazonDownloadManager.install(app, game, token, folder, cancelled, object : AmazonDownloadManager.Callback {
override fun onProgress(message: String, pct: Int) {
when {
message.startsWith("Finishing") -> handle.stage(DownloadStage.INSTALL, message)
message.startsWith("Downloading") && !message.startsWith("Downloading manifest") -> { if (!downloading) { downloading = true; handle.stage(DownloadStage.DOWNLOAD, message) } else handle.progress(-1, -1, message) }
else -> handle.stage(DownloadStage.MANIFEST, message)
}
}
override fun onBytes(done: Long, total: Long, speedBps: Long) { handle.progress(done, total, null, speedBps) }
override fun onLog(line: String) { handle.log(line) }
override fun onSizes(downloadBytes: Long, diskBytes: Long) { handle.diskSize(diskBytes) }
}) ?: return null
if (cancelled.get()) return null
handle.stage(DownloadStage.INSTALL, "Registering with Steam…")
AmazonLibrary.rememberSize(app, game.productId, result.bytes)
AmazonPrefs.get(app).edit().putString("version_${game.productId}", result.versionId).apply()
val spec = AmazonLaunchHelper.buildLaunchSpec(folder, game.title)
val exe = spec.exeRelative.ifEmpty { StoreExe.pick(folder, null, game.title) }
if (exe.isEmpty()) handle.log("amazon: no exe found in ${folder.name}; pick one in Steam settings › Added games")
val env = LinkedHashMap<String, String>()
for (kv in AmazonLaunchHelper.buildFuelEnv(game.entitlementId, game.productSku)) env[kv.substringBefore('=')] = kv.substringAfter('=')
val sidecar = StoreGameSidecar(
Store.AMAZON, game.productId, game.title, exe = exe.ifEmpty { "game.exe" }, args = spec.args, env = env,
installVersion = result.versionId ?: "", installedAt = System.currentTimeMillis(),
cover = item.tallImageUrl ?: item.imageUrl, hero = item.imageUrl,
extra = mapOf("entitlementId" to game.entitlementId, "sku" to game.productSku),
)
StoreInstalls.complete(app, folder, onStep = { done, steps -> handle.stageProgress(done.toLong(), steps.toLong()) }, sidecar = sidecar)
return folder.path
}
override fun cancel(deleteFiles: Boolean) {
cancelled.set(true)
if (deleteFiles) StoreInstalls.discard(app, folder, emptyList())
}
}
/** Amazon's device sign-in: a PKCE challenge on the sign-in page, the code on the return URL, a device registration for tokens. */
private class LoginFlow : StoreLoginActivity.Flow {
private val serial = AmazonPkce.generateDeviceSerial()
private val clientId = AmazonPkce.generateClientId(serial)
private val verifier = AmazonPkce.generateCodeVerifier()
override val startUrl: String = "https://www.amazon.com/ap/signin" +
"?openid.ns=http%3A%2F%2Fspecs.openid.net%2Fauth%2F2.0" +
"&openid.claimed_id=http%3A%2F%2Fspecs.openid.net%2Fauth%2F2.0%2Fidentifier_select" +
"&openid.identity=http%3A%2F%2Fspecs.openid.net%2Fauth%2F2.0%2Fidentifier_select" +
"&openid.mode=checkid_setup&openid.oa2.scope=device_auth_access" +
"&openid.ns.oa2=http%3A%2F%2Fwww.amazon.com%2Fap%2Fext%2Foauth%2F2&openid.oa2.response_type=code" +
"&openid.oa2.code_challenge_method=S256&openid.oa2.client_id=device%3A$clientId" +
"&language=en_US&marketPlaceId=ATVPDKIKX0DER&openid.return_to=https%3A%2F%2Fwww.amazon.com" +
"&openid.pape.max_auth_age=0&openid.ns.pape=http%3A%2F%2Fspecs.openid.net%2Fextensions%2Fpape%2F1.0" +
"&openid.assoc_handle=amzn_sonic_games_launcher&pageId=amzn_sonic_games_launcher" +
"&openid.oa2.code_challenge=${AmazonPkce.generateCodeChallenge(verifier)}"
override val userAgent: String get() = AmazonAuthClient.USER_AGENT
override fun isRedirect(url: String): Boolean = url.contains("openid.oa2.authorization_code=")
override fun finish(activity: StoreLoginActivity, view: WebView, url: String): String? {
val code = Uri.parse(url).getQueryParameter("openid.oa2.authorization_code")
if (code.isNullOrEmpty()) { Log.w(TAG, "login: return URL without a code"); return activity.getString(com.droiddeck.launcher.R.string.stores_login_error_verification) }
val result = AmazonAuthClient.registerDevice(code, verifier, serial, clientId) ?: return activity.getString(com.droiddeck.launcher.R.string.stores_login_error_generic)
AmazonCredentialStore.save(activity, result.accessToken, result.refreshToken, serial, clientId, System.currentTimeMillis() + result.expiresIn * 1000L, result.name)
Log.i(TAG, "signed in")
return null
}
}
}
@@ -0,0 +1,52 @@
package com.droiddeck.launcher.stores.amazon
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreAccounts
import org.json.JSONObject
/**
* The Amazon sign-in in StoreAccounts' private file: the bearer tokens, the device serial and
* client id the registration was made with (the entitlements call hashes the serial), the expiry.
*/
object AmazonCredentialStore {
private const val TAG = "AmazonAuth"
class Credentials(val accessToken: String, val refreshToken: String?, val deviceSerial: String, val clientId: String, val expiresAt: Long)
@JvmStatic
fun save(context: Context, accessToken: String, refreshToken: String?, deviceSerial: String, clientId: String, expiresAt: Long, displayName: String?) {
val json = StoreAccounts.read(context, Store.AMAZON) ?: JSONObject()
json.put("access_token", accessToken).put("device_serial", deviceSerial).put("client_id", clientId).put("expires_at", expiresAt)
if (!refreshToken.isNullOrEmpty()) json.put("refresh_token", refreshToken)
if (!displayName.isNullOrEmpty()) json.put("display_name", displayName)
StoreAccounts.write(context, Store.AMAZON, json)
}
@JvmStatic
fun load(context: Context): Credentials? {
val json = StoreAccounts.read(context, Store.AMAZON) ?: return null
val token = json.optString("access_token", "").ifEmpty { return null }
return Credentials(token, json.optString("refresh_token", "").ifEmpty { null }, json.optString("device_serial", ""), json.optString("client_id", ""), json.optLong("expires_at", 0L))
}
@JvmStatic
fun isLoggedIn(context: Context): Boolean = load(context) != null
/** A valid access token, refreshed within five minutes of expiry; null when signed out or the refresh failed. */
@JvmStatic
fun getValidAccessToken(context: Context): String? {
val creds = load(context) ?: return null
if (creds.expiresAt - System.currentTimeMillis() < 5L * 60L * 1000L && creds.refreshToken != null) {
val result = AmazonAuthClient.refreshAccessToken(creds.refreshToken)
if (result != null) {
save(context, result.accessToken, creds.refreshToken, creds.deviceSerial, creds.clientId, System.currentTimeMillis() + result.expiresIn * 1000L, null)
Log.i(TAG, "token refreshed")
return result.accessToken
}
Log.w(TAG, "token refresh failed; using the stored token")
}
return creds.accessToken
}
}
@@ -0,0 +1,288 @@
package com.droiddeck.launcher.stores.amazon;
import android.content.Context;
import android.util.Log;
import com.droiddeck.launcher.stores.StoreLog;
import com.droiddeck.launcher.stores.download.StoreDownloadTier;
import org.json.JSONArray;
import org.json.JSONObject;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
/**
* The Amazon Games install pipeline, ported from Bannerlator: GetGameDownload for the download
* root and version, `manifest.proto`, then every file fetched (the native engine when it is
* there, eight Java threads otherwise), SHA-256 checked, written as `.tmp` and renamed. A
* complete file on disk is skipped by size, so a rerun resumes. Blocking; the queue runs it.
*/
public final class AmazonDownloadManager {
private static final String TAG = "AmazonDownload";
private static final int MAX_PARALLEL = 8;
private static final int MAX_RETRIES = 3;
private static final long PROGRESS_INTERVAL = 512L * 1024L;
private static final String DOWNLOAD_USER_AGENT = "nile/0.1 Amazon";
private static final String IN_PROGRESS_MARKER = ".amazon_download_in_progress";
private static final String COMPLETE_MARKER = ".amazon_download_complete";
public interface Callback {
void onProgress(String message, int pct);
default void onBytes(long done, long total, long speedBps) {}
default void onLog(String line) {}
/** What this run fetches and what the game takes on disk. */
default void onSizes(long downloadBytes, long diskBytes) {}
}
public static final class Result {
public final String versionId;
public final long bytes;
Result(String versionId, long bytes) { this.versionId = versionId; this.bytes = bytes; }
}
public static final class InstallException extends Exception {
InstallException(String message) { super(message); }
}
private AmazonDownloadManager() {}
/** Installs the game into {@code installDir}; null when cancelled, an exception when it fails. */
public static Result install(Context ctx, AmazonGame game, String accessToken, File installDir, AtomicBoolean cancel, Callback cb) throws InstallException {
if (game.entitlementId == null || game.entitlementId.isEmpty()) throw new InstallException("This game has no entitlement to download with");
try {
installDir.mkdirs();
new File(installDir, IN_PROGRESS_MARKER).createNewFile();
cb.onProgress("Asking Amazon for the download…", 0);
AmazonApiClient.GameDownloadSpec spec = AmazonApiClient.getGameDownload(accessToken, game.entitlementId);
if (spec == null) throw new InstallException("Amazon gave no download for this game");
cb.onLog("amazon: version " + spec.versionId);
cb.onProgress("Downloading manifest…", 1);
byte[] manifestBytes = AmazonApiClient.getBytes(AmazonApiClient.appendPath(spec.downloadUrl, "manifest.proto"), accessToken);
if (manifestBytes == null) throw new InstallException("The manifest could not be downloaded");
AmazonManifest.ParsedManifest manifest = AmazonManifest.parse(manifestBytes);
cb.onLog("amazon: " + manifest.allFiles.size() + " files, " + fmt(manifest.totalInstallSize));
long usable = installDir.getUsableSpace();
if (manifest.totalInstallSize > 0 && usable > 0 && manifest.totalInstallSize > usable)
throw new InstallException("Not enough free space: need " + fmt(manifest.totalInstallSize) + ", only " + fmt(usable) + " free");
cb.onSizes(manifest.totalInstallSize, manifest.totalInstallSize);
cb.onBytes(0, manifest.totalInstallSize, 0);
if (cancel.get()) return null;
AtomicLong downloaded = new AtomicLong(0);
AtomicLong lastEmit = new AtomicLong(0);
AtomicLong lastSpeedMs = new AtomicLong(System.currentTimeMillis());
AtomicLong lastSpeedBytes = new AtomicLong(0);
AtomicLong speedBps = new AtomicLong(0);
List<AmazonManifest.ManifestFile> files = manifest.allFiles;
final long total = manifest.totalInstallSize;
if (AmazonNative.isAvailable()) {
if (!downloadAllNative(ctx, spec.downloadUrl, files, total, installDir, downloaded, lastEmit, lastSpeedMs, lastSpeedBytes, speedBps, cancel, cb)) {
if (cancel.get()) return null;
throw new InstallException("A file failed to download");
}
} else {
cb.onLog("amazon: engine=built-in (" + MAX_PARALLEL + " threads)");
ExecutorService pool = Executors.newFixedThreadPool(MAX_PARALLEL, com.droiddeck.launcher.stores.download.DownloadQueue.workerFactory("amazon-dl"));
List<Future<Boolean>> futures = new ArrayList<>();
for (AmazonManifest.ManifestFile file : files) {
final String dlUrl = spec.downloadUrl;
futures.add(pool.submit(() -> downloadFileWithRetry(file, dlUrl, installDir, downloaded, lastEmit, total, lastSpeedMs, lastSpeedBytes, speedBps, cb, cancel)));
}
pool.shutdown();
try {
for (Future<Boolean> f : futures) {
if (!f.get()) {
pool.shutdownNow();
if (cancel.get()) return null;
throw new InstallException("A file failed to download");
}
}
} catch (InstallException e) {
throw e;
} catch (Exception e) {
pool.shutdownNow();
throw new InstallException("download pool error: " + e.getClass().getSimpleName());
}
}
if (cancel.get()) return null;
cb.onProgress("Finishing…", 98);
new File(installDir, IN_PROGRESS_MARKER).delete();
new File(installDir, COMPLETE_MARKER).createNewFile();
return new Result(spec.versionId, total);
} catch (InstallException e) {
throw e;
} catch (Exception e) {
Log.w(TAG, "install failed", e);
throw new InstallException(e.getClass().getSimpleName() + (e.getMessage() != null ? ": " + e.getMessage() : ""));
}
}
/** One entry per manifest file with the exact URL the Java loop would open and the SHA-256 when it would check it. */
static String buildPlan(List<AmazonManifest.ManifestFile> files, String baseUrl) {
JSONArray arr = new JSONArray();
try {
for (AmazonManifest.ManifestFile file : files) {
String hashHex = file.hashHex();
arr.put(new JSONObject().put("relPath", file.unixPath()).put("url", AmazonApiClient.appendPath(baseUrl, "files/" + hashHex))
.put("size", file.size).put("sha256hex", (file.hashAlgorithm == 0 && file.hashBytes.length > 0) ? hashHex : ""));
}
} catch (Exception e) {
Log.e(TAG, "plan failed", e);
return "";
}
return arr.toString();
}
private static boolean downloadAllNative(Context ctx, String baseUrl, List<AmazonManifest.ManifestFile> files, long totalSize, File installDir,
AtomicLong totalDownloaded, AtomicLong lastEmit, AtomicLong lastSpeedMs, AtomicLong lastSpeedBytes, AtomicLong speedBps,
AtomicBoolean cancel, Callback cb) {
String plan = buildPlan(files, baseUrl);
if (plan.isEmpty()) return false;
StoreDownloadTier tier = StoreDownloadTier.Companion.current(ctx);
int maxWorkers = Math.max(1, Math.min(128, tier.getNetworkWindow()));
int processWorkers = Math.max(Math.max(1, Runtime.getRuntime().availableProcessors() / 2), tier.getProcessWorkers());
cb.onLog("amazon: engine=native workers=" + maxWorkers + " process_workers=" + processWorkers + " tier=" + tier.getId());
AmazonNative.Listener listener = new AmazonNative.Listener() {
@Override public void onProgress(long bytesDone, long bytesTotal, long filesDone, long filesTotal) {
totalDownloaded.set(bytesDone);
long emit = lastEmit.get();
if (bytesDone - emit >= PROGRESS_INTERVAL && lastEmit.compareAndSet(emit, bytesDone)) {
sampleSpeed(bytesDone, lastSpeedMs, lastSpeedBytes, speedBps);
cb.onProgress("Downloading (" + filesDone + "/" + filesTotal + " files)" + speedSuffix(speedBps.get()), (int) (bytesDone * 95L / Math.max(1, totalSize)));
cb.onBytes(bytesDone, totalSize, speedBps.get());
}
}
@Override public void onLog(String line) { cb.onLog(line); }
@Override public void onComplete(boolean success, String error, long bytesWritten) {
cb.onLog("amazon: complete success=" + success + " bytes=" + bytesWritten + (error.isEmpty() ? "" : " error=" + error));
}
};
AmazonNative.RunResult result = AmazonNative.runBlocking(plan, installDir.getAbsolutePath(), "", maxWorkers, processWorkers, cancel::get, listener);
if (result.cancelled) return false;
if (!result.success) { cb.onLog("amazon: engine failed: " + result.error); return false; }
return true;
}
private static boolean downloadFileWithRetry(AmazonManifest.ManifestFile file, String baseUrl, File installDir, AtomicLong totalDownloaded, AtomicLong lastEmit, long totalSize,
AtomicLong lastSpeedMs, AtomicLong lastSpeedBytes, AtomicLong speedBps, Callback cb, AtomicBoolean cancel) {
File destFile = new File(installDir, file.unixPath());
File tmpFile = new File(installDir, file.unixPath() + ".tmp");
if (destFile.exists() && destFile.length() == file.size) { totalDownloaded.addAndGet(file.size); return true; }
String fileUrl = AmazonApiClient.appendPath(baseUrl, "files/" + file.hashHex());
for (int attempt = 1; attempt <= MAX_RETRIES; attempt++) {
destFile.getParentFile().mkdirs();
try {
if (!downloadFile(fileUrl, tmpFile, totalDownloaded, lastEmit, totalSize, lastSpeedMs, lastSpeedBytes, speedBps, cb, cancel)) { tmpFile.delete(); return false; }
if (file.hashAlgorithm == 0 && file.hashBytes.length > 0 && !Arrays.equals(sha256(tmpFile), file.hashBytes)) {
cb.onLog("amazon: SHA-256 mismatch for " + file.unixPath());
tmpFile.delete();
if (attempt < MAX_RETRIES) { Thread.sleep(1000L << (attempt - 1)); continue; }
return false;
}
if (destFile.exists()) destFile.delete();
return tmpFile.renameTo(destFile);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
tmpFile.delete();
return false;
} catch (Exception e) {
cb.onLog("amazon: attempt " + attempt + " failed for " + file.unixPath() + ": " + e.getClass().getSimpleName());
tmpFile.delete();
if (attempt < MAX_RETRIES) { try { Thread.sleep(1000L << (attempt - 1)); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); return false; } }
}
}
return false;
}
/** Streams one file; false = cancelled. */
private static boolean downloadFile(String urlStr, File tmpFile, AtomicLong totalDownloaded, AtomicLong lastEmit, long totalSize,
AtomicLong lastSpeedMs, AtomicLong lastSpeedBytes, AtomicLong speedBps, Callback cb, AtomicBoolean cancel) throws IOException {
HttpURLConnection conn = (HttpURLConnection) new URL(urlStr).openConnection();
conn.setConnectTimeout(30000);
conn.setReadTimeout(120000);
conn.setRequestProperty("User-Agent", DOWNLOAD_USER_AGENT);
int code = conn.getResponseCode();
if (code < 200 || code >= 300) { conn.disconnect(); throw new IOException("HTTP " + code + " " + StoreLog.redactUrl(urlStr)); }
try (InputStream in = conn.getInputStream(); FileOutputStream out = new FileOutputStream(tmpFile)) {
byte[] buf = new byte[65536];
int n;
while ((n = in.read(buf)) >= 0) {
if (cancel.get()) { conn.disconnect(); return false; }
out.write(buf, 0, n);
long dl = totalDownloaded.addAndGet(n);
long emit = lastEmit.get();
if (dl - emit >= PROGRESS_INTERVAL && lastEmit.compareAndSet(emit, dl)) {
sampleSpeed(dl, lastSpeedMs, lastSpeedBytes, speedBps);
cb.onProgress("Downloading: " + tmpFile.getName().replace(".tmp", "") + speedSuffix(speedBps.get()), (int) (dl * 95L / Math.max(1, totalSize)));
cb.onBytes(dl, totalSize, speedBps.get());
}
}
} finally {
conn.disconnect();
}
return true;
}
/** The install size from the manifest alone, or -1. Blocking. */
public static long fetchInstallSizeBytes(String accessToken, String entitlementId) {
try {
AmazonApiClient.GameDownloadSpec spec = AmazonApiClient.getGameDownload(accessToken, entitlementId);
if (spec == null) return -1;
byte[] bytes = AmazonApiClient.getBytes(AmazonApiClient.appendPath(spec.downloadUrl, "manifest.proto"), accessToken);
if (bytes == null) return -1;
AmazonManifest.ParsedManifest m = AmazonManifest.parse(bytes);
return m.totalInstallSize > 0 ? m.totalInstallSize : -1;
} catch (Exception e) {
return -1;
}
}
public static boolean isInstalled(File installDir) { return new File(installDir, COMPLETE_MARKER).exists(); }
private static byte[] sha256(File file) throws Exception {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
try (FileInputStream fis = new FileInputStream(file)) {
byte[] buf = new byte[65536];
int n;
while ((n = fis.read(buf)) >= 0) digest.update(buf, 0, n);
}
return digest.digest();
}
private static void sampleSpeed(long done, AtomicLong lastSpeedMs, AtomicLong lastSpeedBytes, AtomicLong speedBps) {
long nowMs = System.currentTimeMillis(), prevMs = lastSpeedMs.get();
if (nowMs - prevMs >= 500 && lastSpeedMs.compareAndSet(prevMs, nowMs)) {
long prevB = lastSpeedBytes.getAndSet(done);
long dt = nowMs - prevMs;
if (dt > 0) speedBps.set((done - prevB) * 1000L / dt);
}
}
private static String speedSuffix(long bps) {
if (bps <= 0) return "";
return " " + (bps >= 1048576 ? String.format("%.1f MB/s", bps / 1048576.0) : (bps / 1024) + " KB/s");
}
private static String fmt(long bytes) {
if (bytes >= 1073741824L) return String.format("%.1f GB", bytes / 1073741824.0);
if (bytes >= 1048576L) return String.format("%.1f MB", bytes / 1048576.0);
return String.format("%.0f KB", bytes / 1024.0);
}
}
@@ -0,0 +1,28 @@
package com.droiddeck.launcher.stores.amazon;
/**
* One Amazon Games library entry. {@code productId} ("amzn1.adg.product.…") names the game,
* {@code entitlementId} (the top-level UUID of GetEntitlements) is what GetGameDownload takes,
* {@code productSku} goes to the game as AMAZON_GAMES_FUEL_PRODUCT_SKU.
*/
public class AmazonGame {
public String productId = "";
public String entitlementId = "";
public String title = "";
/** iconUrl: square art. */
public String artUrl = "";
/** backgroundUrl1: the wide background. */
public String heroUrl = "";
public String developer = "";
public String publisher = "";
public String productSku = "";
public String versionId = "";
public boolean isDLC = false;
public String parentProductId = "";
/** The id's last segment, for display when there is no title. */
public String shortId() {
int dot = productId.lastIndexOf('.');
return (dot >= 0 && dot < productId.length() - 1) ? productId.substring(dot + 1) : productId;
}
}
@@ -0,0 +1,140 @@
package com.droiddeck.launcher.stores.amazon;
import android.util.Log;
import org.json.JSONArray;
import org.json.JSONObject;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileReader;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Pattern;
/**
* How an installed Amazon game is started: its `fuel.json` names the exe, a working subfolder and
* arguments when the publisher shipped one; otherwise the exe is scored (Unreal shipping binaries
* first, the title's name, never crash handlers or installers). The FuelPump variables the Amazon
* Games Services SDK reads go into the game's environment.
*/
public final class AmazonLaunchHelper {
private static final String TAG = "AmazonLaunch";
public static final class LaunchSpec {
/** The exe relative to the install folder, forward slashes. */
public String exeRelative = "";
/** The arguments fuel.json names, in order. */
public List<String> args = new ArrayList<>();
}
private AmazonLaunchHelper() {}
public static LaunchSpec buildLaunchSpec(File installDir, String gameTitle) {
LaunchSpec spec = new LaunchSpec();
String fuelCommand = null;
File fuelFile = new File(installDir, "fuel.json");
if (fuelFile.exists()) {
try {
StringBuilder sb = new StringBuilder();
try (BufferedReader br = new BufferedReader(new FileReader(fuelFile))) {
String line;
while ((line = br.readLine()) != null) sb.append(line);
}
JSONObject main = new JSONObject(sb.toString()).optJSONObject("Main");
if (main != null) {
String cmd = main.optString("Command", "").trim();
if (!cmd.isEmpty()) fuelCommand = cmd;
JSONArray argsArr = main.optJSONArray("Args");
if (argsArr != null) for (int i = 0; i < argsArr.length(); i++) {
String a = argsArr.optString(i);
if (a != null && !a.isEmpty()) spec.args.add(a);
}
}
} catch (Exception e) {
Log.w(TAG, "fuel.json unreadable; choosing the exe by name");
}
}
if (fuelCommand != null && new File(installDir, fuelCommand.replace('\\', '/')).isFile()) {
spec.exeRelative = fuelCommand.replace('\\', '/');
} else {
File chosen = choosePrimaryExe(installDir, gameTitle);
if (chosen != null) spec.exeRelative = relativePath(installDir, chosen);
}
return spec;
}
/** The FuelPump variables, as KEY=VALUE, for the game's environment. */
public static String[] buildFuelEnv(String entitlementId, String productSku) {
return new String[]{
"FUEL_DIR=C:\\ProgramData\\Amazon Games Services\\Legacy",
"AMAZON_GAMES_SDK_PATH=C:\\ProgramData\\Amazon Games Services\\AmazonGamesSDK",
"AMAZON_GAMES_FUEL_ENTITLEMENT_ID=" + entitlementId,
"AMAZON_GAMES_FUEL_PRODUCT_SKU=" + productSku,
"AMAZON_GAMES_FUEL_DISPLAY_NAME=Player",
};
}
private static final Pattern UE_SHIPPING = Pattern.compile(".*-win(32|64)(-shipping)?\\.exe$", Pattern.CASE_INSENSITIVE);
private static final Pattern UE_BINARIES = Pattern.compile(".*/binaries/win(32|64)/.*\\.exe$", Pattern.CASE_INSENSITIVE);
private static final Pattern GENERIC_NAME = Pattern.compile("^[a-z]\\d{1,3}\\.exe$", Pattern.CASE_INSENSITIVE);
private static final String[] NEGATIVE_KEYWORDS = { "crash", "handler", "viewer", "compiler", "tool", "setup", "unins", "eac", "launcher", "steam" };
private static boolean isLikelyStub(File f) {
String n = f.getName().toLowerCase();
if (GENERIC_NAME.matcher(n).matches()) return true;
if (f.length() < 1_000_000L) return true;
for (String kw : NEGATIVE_KEYWORDS) if (n.contains(kw)) return true;
return false;
}
static int scoreExe(File f, String gameNameLower) {
int score = 50;
String path = f.getAbsolutePath().replace('\\', '/').toLowerCase();
if (UE_SHIPPING.matcher(path).matches()) score += 300;
if (UE_BINARIES.matcher(path).find()) score += 250;
String fn = f.getName().toLowerCase();
String cleanGame = gameNameLower.replaceAll("[^a-z]", "");
String cleanFile = fn.replaceAll("[^a-z]", "");
boolean nameMatch = path.contains(gameNameLower) || (cleanGame.length() >= 5 && cleanFile.length() >= 5 && cleanGame.substring(0, 5).equals(cleanFile.substring(0, 5)));
if (nameMatch) score += 100;
for (String kw : NEGATIVE_KEYWORDS) if (path.contains(kw)) { score -= 150; break; }
if (GENERIC_NAME.matcher(fn).matches()) score -= 200;
return score;
}
static File choosePrimaryExe(File installDir, String gameTitle) {
if (!installDir.isDirectory()) return null;
List<File> all = new ArrayList<>();
collectExe(installDir, all);
if (all.isEmpty()) return null;
List<File> pool = new ArrayList<>();
for (File f : all) if (!isLikelyStub(f)) pool.add(f);
if (pool.isEmpty()) pool = all;
String lowerTitle = gameTitle.toLowerCase();
File best = null;
int bestScore = Integer.MIN_VALUE;
for (File f : pool) {
int s = scoreExe(f, lowerTitle);
if (best == null || s > bestScore || (s == bestScore && f.length() > best.length())) { best = f; bestScore = s; }
}
return best;
}
private static void collectExe(File dir, List<File> out) {
File[] entries = dir.listFiles();
if (entries == null) return;
for (File f : entries) {
if (f.isDirectory()) collectExe(f, out);
else if (f.getName().toLowerCase().endsWith(".exe")) out.add(f);
}
}
private static String relativePath(File base, File absolute) {
String b = base.getAbsolutePath();
if (!b.endsWith("/")) b += "/";
String a = absolute.getAbsolutePath();
return a.startsWith(b) ? a.substring(b.length()) : absolute.getName();
}
}
@@ -0,0 +1,129 @@
package com.droiddeck.launcher.stores.amazon
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import com.droiddeck.launcher.stores.CatalogItem
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreNet
import com.droiddeck.launcher.stores.StoresState
import org.json.JSONArray
import org.json.JSONObject
import java.util.concurrent.Callable
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean
/**
* The signed-in account's Amazon Games library: the entitlements, DLC folded under their base
* game, cached so the tab paints at once, re-synced when forced or 15 minutes stale. Amazon
* publishes a square icon and a wide background only, so a 2:3 poster per title is backfilled
* from SteamGridDB (a few per pass). Ported from Bannerlator's AmazonLibraryRepo.
*/
object AmazonLibrary {
private const val TAG = "AmazonLibrary"
private const val CACHE_KEY = "library_cache"
private const val LAST_SYNC_KEY = "library_synced_at"
private const val THROTTLE_MS = 6L * 60L * 60L * 1000L
private const val POSTER_BUDGET = 40
private val syncing = AtomicBoolean(false)
sealed interface SyncResult {
class Ok(val games: List<AmazonGame>) : SyncResult
class Failed(val message: String) : SyncResult
object NotLoggedIn : SyncResult
object Busy : SyncResult
object Throttled : SyncResult
}
fun cached(context: Context): List<AmazonGame> {
val json = AmazonPrefs.get(context).getString(CACHE_KEY, null) ?: return emptyList()
return runCatching {
val arr = JSONArray(json)
val out = ArrayList<AmazonGame>(arr.length())
for (i in 0 until arr.length()) {
val j = arr.optJSONObject(i) ?: continue
val g = AmazonGame()
g.productId = j.optString("productId", ""); g.entitlementId = j.optString("entitlementId", ""); g.title = j.optString("title", "")
g.artUrl = j.optString("artUrl", ""); g.heroUrl = j.optString("heroUrl", ""); g.developer = j.optString("developer", "")
g.publisher = j.optString("publisher", ""); g.productSku = j.optString("productSku", ""); g.versionId = j.optString("versionId", "")
if (g.productId.isNotEmpty()) out.add(g)
}
out.distinctBy { it.productId }.sortedBy { it.title.lowercase() }
}.getOrDefault(emptyList())
}
fun poster(context: Context, productId: String): String? = AmazonPrefs.get(context).getString("vcover_$productId", null)?.ifBlank { null }
fun rememberSize(context: Context, productId: String, bytes: Long) {
if (bytes > 0) AmazonPrefs.get(context).edit().putLong("size_$productId", bytes).apply()
}
fun sync(context: Context, force: Boolean = false, onStatus: (String) -> Unit = {}): SyncResult {
if (!syncing.compareAndSet(false, true)) return SyncResult.Busy
try {
val app = context.applicationContext
val p = AmazonPrefs.get(app)
val cachedList = cached(app)
if (!force && cachedList.isNotEmpty() && System.currentTimeMillis() - p.getLong(LAST_SYNC_KEY, 0L) < THROTTLE_MS) {
backfillPosters(p, cachedList, onStatus)
return SyncResult.Throttled
}
onStatus("Checking sign-in…")
val creds = AmazonCredentialStore.load(app) ?: return SyncResult.NotLoggedIn
val token = AmazonCredentialStore.getValidAccessToken(app) ?: return SyncResult.Failed(StoresState.notSignedInLine(Store.AMAZON))
onStatus("Fetching game list…")
val all = AmazonApiClient.getEntitlements(token, creds.deviceSerial)
if (all.isNullOrEmpty()) return if (cachedList.isEmpty()) SyncResult.Failed("No games found in your Amazon library") else SyncResult.Ok(cachedList)
val games = all.filter { !(it.isDLC && it.parentProductId.isNotEmpty()) }.ifEmpty { all }
.sortedWith { a, b -> a.title.compareTo(b.title, ignoreCase = true) }
for (fresh in games) cachedList.firstOrNull { it.productId == fresh.productId }?.let { fresh.versionId = it.versionId }
saveCache(p, games)
p.edit().putLong(LAST_SYNC_KEY, System.currentTimeMillis()).apply()
backfillPosters(p, games, onStatus)
Log.i(TAG, "sync: entitlements=${all.size} games=${games.size}")
return SyncResult.Ok(games)
} catch (e: Exception) {
Log.w(TAG, "sync failed: ${e.message}")
return SyncResult.Failed(e.message ?: "Sync failed")
} finally {
syncing.set(false)
}
}
private fun backfillPosters(p: SharedPreferences, games: List<AmazonGame>, onStatus: (String) -> Unit) {
val missing = games.filter { it.productId.isNotEmpty() && !p.contains("vcover_${it.productId}") }.take(POSTER_BUDGET)
if (missing.isEmpty()) return
onStatus("Fetching cover art…")
val pool = Executors.newFixedThreadPool(4)
val futures = missing.map { g -> pool.submit(Callable { g.productId to StoreNet.sgdbPoster(g.title) }) }
pool.shutdown()
val ed = p.edit()
for (f in futures) {
val (pid, url) = runCatching { f.get() }.getOrNull() ?: continue
// A miss is recorded as "" so it is not asked again every pass.
ed.putString("vcover_$pid", url)
}
ed.apply()
}
private fun saveCache(p: SharedPreferences, games: List<AmazonGame>) {
runCatching {
val arr = JSONArray()
for (g in games) arr.put(JSONObject().put("productId", g.productId).put("entitlementId", g.entitlementId).put("title", g.title).put("artUrl", g.artUrl)
.put("heroUrl", g.heroUrl).put("developer", g.developer).put("publisher", g.publisher).put("productSku", g.productSku).put("versionId", g.versionId))
p.edit().putString(CACHE_KEY, arr.toString()).apply()
}
}
fun toCatalogItem(context: Context, g: AmazonGame): CatalogItem = CatalogItem(
store = Store.AMAZON, id = g.productId, title = g.title.ifBlank { g.shortId() },
imageUrl = g.heroUrl.ifBlank { null } ?: g.artUrl.ifBlank { null },
tallImageUrl = poster(context, g.productId) ?: g.artUrl.ifBlank { null } ?: g.heroUrl.ifBlank { null },
tags = listOfNotNull(g.developer.ifBlank { null }, g.publisher.ifBlank { null }.takeIf { it != g.developer }).joinToString(", "),
developer = g.developer, owned = true,
sizeBytes = AmazonPrefs.get(context).getLong("size_${g.productId}", 0L),
storeUrl = "https://gaming.amazon.com/home",
extra = mapOf("entitlementId" to g.entitlementId, "sku" to g.productSku, "version" to (g.versionId ?: "")),
)
}
@@ -0,0 +1,158 @@
package com.droiddeck.launcher.stores.amazon;
import org.tukaani.xz.LZMAInputStream;
import org.tukaani.xz.XZInputStream;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
/**
* Amazon's `manifest.proto`: a big-endian header length, a ManifestHeader protobuf naming the
* compression, then an LZMA (or XZ) body holding the Manifest protobuf - packages of files, each
* with a Windows path, a size and a SHA-256. Read with a tiny protobuf walker; the XZ library the
* app already carries does the decompression.
*/
public final class AmazonManifest {
public static final class ManifestFile {
public final String path;
public final long size;
/** 0 = SHA-256. */
public final int hashAlgorithm;
public final byte[] hashBytes;
ManifestFile(String path, long size, int hashAlgorithm, byte[] hashBytes) {
this.path = path; this.size = size; this.hashAlgorithm = hashAlgorithm; this.hashBytes = hashBytes;
}
public String unixPath() { return path.replace('\\', '/'); }
public String hashHex() {
StringBuilder sb = new StringBuilder();
for (byte b : hashBytes) sb.append(String.format("%02x", b & 0xFF));
return sb.toString();
}
}
public static final class ParsedManifest {
public final List<ManifestFile> allFiles;
public final long totalInstallSize;
ParsedManifest(List<ManifestFile> files) {
this.allFiles = files;
long total = 0;
for (ManifestFile f : files) total += f.size;
this.totalInstallSize = total;
}
}
private AmazonManifest() {}
public static ParsedManifest parse(byte[] data) throws IOException {
if (data == null || data.length < 4) throw new IOException("Manifest too short");
int headerSize = ByteBuffer.wrap(data, 0, 4).order(ByteOrder.BIG_ENDIAN).getInt();
if (headerSize < 0 || 4 + headerSize > data.length) throw new IOException("Invalid header size " + headerSize);
byte[] body = Arrays.copyOfRange(data, 4 + headerSize, data.length);
return parseManifest(decompress(body));
}
private static byte[] decompress(byte[] body) throws IOException {
if (body.length < 2) return body;
boolean isXz = (body[0] & 0xFF) == 0xFD && (body[1] & 0xFF) == 0x37;
try (InputStream in = isXz ? new XZInputStream(new ByteArrayInputStream(body), -1) : new LZMAInputStream(new ByteArrayInputStream(body), -1)) {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
byte[] buf = new byte[65536];
int n;
while ((n = in.read(buf)) >= 0) bos.write(buf, 0, n);
return bos.toByteArray();
}
}
private static ParsedManifest parseManifest(byte[] bytes) {
List<ManifestFile> files = new ArrayList<>();
ProtoReader r = new ProtoReader(bytes);
while (r.hasMore()) {
int tag = (int) r.readVarint();
if ((tag >>> 3) == 1 && (tag & 7) == 2) parsePackage(r.readBytes(), files); else r.skip(tag & 7);
}
return new ParsedManifest(files);
}
private static void parsePackage(byte[] bytes, List<ManifestFile> out) {
ProtoReader r = new ProtoReader(bytes);
while (r.hasMore()) {
int tag = (int) r.readVarint();
int field = tag >>> 3, wire = tag & 7;
if (field == 2 && wire == 2) { ManifestFile f = parseFile(r.readBytes()); if (f != null) out.add(f); }
else r.skip(wire);
}
}
private static ManifestFile parseFile(byte[] bytes) {
String path = "";
long size = 0L;
int hashAlgo = 0;
byte[] hashValue = new byte[0];
ProtoReader r = new ProtoReader(bytes);
while (r.hasMore()) {
int tag = (int) r.readVarint();
int field = tag >>> 3, wire = tag & 7;
if (field == 1 && wire == 2) path = r.readString();
else if (field == 3 && wire == 0) size = r.readVarint();
else if (field == 5 && wire == 2) {
ProtoReader hr = new ProtoReader(r.readBytes());
while (hr.hasMore()) {
int ht = (int) hr.readVarint();
int hf = ht >>> 3, hw = ht & 7;
if (hf == 1 && hw == 0) hashAlgo = (int) hr.readVarint();
else if (hf == 2 && hw == 2) hashValue = hr.readBytes();
else hr.skip(hw);
}
} else r.skip(wire);
}
if (path.isEmpty()) return null;
return new ManifestFile(path, size, hashAlgo, hashValue);
}
private static final class ProtoReader {
private final byte[] buf;
private int pos;
ProtoReader(byte[] buf) { this.buf = buf; }
boolean hasMore() { return pos < buf.length; }
long readVarint() {
long result = 0;
int shift = 0;
while (pos < buf.length) {
int b = buf[pos++] & 0xFF;
result |= (long) (b & 0x7F) << shift;
if ((b & 0x80) == 0) break;
shift += 7;
}
return result;
}
byte[] readBytes() {
int len = (int) readVarint();
if (len < 0 || pos + len > buf.length) return new byte[0];
byte[] out = Arrays.copyOfRange(buf, pos, pos + len);
pos += len;
return out;
}
String readString() { return new String(readBytes(), java.nio.charset.StandardCharsets.UTF_8); }
void skip(int wireType) {
switch (wireType) {
case 0: readVarint(); break;
case 1: pos += 8; break;
case 2: readBytes(); break;
case 5: pos += 4; break;
default: pos = buf.length; break;
}
}
}
}
Loaded 100 of 317 files, more files were not shown because too many files have changed in this diff. Show more