mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 19:27:09 -04:00
Compare commits
43
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ef37b16c8 | ||
|
|
6e5932cb68 | ||
|
|
bed4c1040b | ||
|
|
3f45835ed6 | ||
|
|
29abf954ce | ||
|
|
38e3fa2a33 | ||
|
|
fa3037de68 | ||
|
|
9419f1efda | ||
|
|
d73c4e50c2 | ||
|
|
bcaeb27780 | ||
|
|
fc515aa0e4 | ||
|
|
f164af81e7 | ||
|
|
90af0b9c0c | ||
|
|
8e1a676f19 | ||
|
|
67cf1cdd9c | ||
|
|
571751f15c | ||
|
|
768ed0c505 | ||
|
|
9f94140bbf | ||
|
|
7c946db440 | ||
|
|
eaf26c8a20 | ||
|
|
0a96767657 | ||
|
|
32054e156e | ||
|
|
fd6d6b7c46 | ||
|
|
c101a451e3 | ||
|
|
a76ba69217 | ||
|
|
d06caf9838 | ||
|
|
134fa167fe | ||
|
|
da42a5d7a7 | ||
|
|
b6c1d3c09c | ||
|
|
a58b0ac3e0 | ||
|
|
e1700ba982 | ||
|
|
736ac7db74 | ||
|
|
6e2966be43 | ||
|
|
679948e792 | ||
|
|
e0fc8362b8 | ||
|
|
9d8ff9615f | ||
|
|
1304809240 | ||
|
|
343a516e1a | ||
|
|
7c08cb58ae | ||
|
|
7e81668de2 | ||
|
|
d231564f8e | ||
|
|
37e89c5b4d | ||
|
|
e070bd72e0 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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'
|
||||
|
||||
|
||||
@@ -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() }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -13,6 +13,7 @@ permissions:
|
||||
concurrency:
|
||||
group: update-app-catalog
|
||||
cancel-in-progress: false
|
||||
queue: max
|
||||
|
||||
jobs:
|
||||
catalog:
|
||||
|
||||
@@ -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/
|
||||
@@ -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
@@ -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'
|
||||
|
||||
@@ -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.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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>
|
||||
@@ -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]);
|
||||
@@ -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
Reference in new issue
Block a user