Merge remote-tracking branch 'origin/main' into pr1067

This commit is contained in:
Kurt Himebauch committed 2026-10-25 19:22:34 -04:00
commit 1ee597e07f
538 files changed
+38465 -3751

No files matched your search

+1 -1
View File
@@ -23,7 +23,7 @@ permissions:
jobs:
validate:
name: Build APK
name: Validate source and build result
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
+312 -459
View File
@@ -16,26 +16,29 @@ on:
- 'LICENSE'
- '.gitignore'
workflow_dispatch:
inputs:
rebuild_preloads:
description: Rebuild preloads instead of reusing the approved binary snapshot
type: boolean
default: false
permissions:
contents: read
actions: read
pull-requests: write
concurrency:
group: build-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
PRELOAD_CACHE_VERSION: v6
jobs:
xbox:
name: Xbox ARM64 backend
source:
name: Pin source tree
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
runs-on: ubuntu-24.04-arm
timeout-minutes: 30
runs-on: ubuntu-24.04
timeout-minutes: 5
outputs:
base-sha: ${{ steps.merge.outputs.base-sha }}
base-sha: ${{ steps.merge.outputs.base-sha || steps.trusted.outputs.base-sha }}
tree-sha: ${{ steps.source.outputs.tree-sha }}
steps:
- uses: actions/checkout@v4
@@ -53,9 +56,46 @@ jobs:
git config user.name "ci"
git config user.email "ci@users.noreply.github.com"
git merge --no-ff --no-edit origin/main
- name: Record backend source tree
- name: Resolve trusted manifest base
id: trusted
if: github.event_name != 'pull_request'
run: |
set -euo pipefail
git fetch --no-tags origin main
echo "base-sha=$(git rev-parse origin/main)" >> "$GITHUB_OUTPUT"
- name: Record pinned source tree
id: source
run: echo "tree-sha=$(git rev-parse 'HEAD^{tree}')" >> "$GITHUB_OUTPUT"
xbox:
name: Xbox ARM64 backend
needs: source
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
runs-on: ubuntu-24.04-arm
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
persist-credentials: false
- name: Merge the pinned APK base
if: github.event_name == 'pull_request'
env:
BUILD_BASE_SHA: ${{ needs.source.outputs.base-sha }}
run: |
set -euo pipefail
git fetch --no-tags origin "$BUILD_BASE_SHA"
git config user.name "ci"
git config user.email "ci@users.noreply.github.com"
git merge --no-ff --no-edit FETCH_HEAD
- name: Verify pinned source tree
env:
SOURCE_TREE: ${{ needs.source.outputs.tree-sha }}
run: test "$(git rev-parse 'HEAD^{tree}')" = "$SOURCE_TREE"
- name: Plan component identities
id: plan
run: python3 tools/components/prepare.py plan
- uses: actions/cache@v4
id: xbox-cache
with:
@@ -63,14 +103,10 @@ jobs:
app/src/main/assets/linuxfs/usr/local/lib/droiddeck-xbox
app/build/xbox/xodus-droiddeck-source.tar.xz
app/build/xbox/runtime/*-source.tar.xz
key: xbox-arm64-${{ hashFiles('tools/xbox/**') }}
key: xbox-v2-${{ steps.plan.outputs.xbox }}
- name: Build Xbox backend
if: steps.xbox-cache.outputs.cache-hit != 'true'
run: |
rustup toolchain install 1.99.0 --profile minimal
sudo apt-get update -qq
sudo apt-get install -y build-essential pkg-config libssl-dev protobuf-compiler cmake xz-utils
bash tools/xbox/build.sh
run: python3 tools/components/prepare.py xbox
- uses: actions/upload-artifact@v4
with:
name: xbox-arm64
@@ -85,9 +121,158 @@ jobs:
if-no-files-found: error
retention-days: 14
components:
name: Prepare verified components
needs: source
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
runs-on: ubuntu-24.04
timeout-minutes: 40
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
persist-credentials: false
- name: Merge the pinned APK base
if: github.event_name == 'pull_request'
env:
BUILD_BASE_SHA: ${{ needs.source.outputs.base-sha }}
run: |
set -euo pipefail
git fetch --no-tags origin "$BUILD_BASE_SHA"
git config user.name "ci"
git config user.email "ci@users.noreply.github.com"
git merge --no-ff --no-edit FETCH_HEAD
- name: Verify pinned source tree
env:
SOURCE_TREE: ${{ needs.source.outputs.tree-sha }}
run: test "$(git rev-parse 'HEAD^{tree}')" = "$SOURCE_TREE"
- name: Resolve the pinned Android NDK
run: bash tools/ci/resolve-ndk.sh
- name: Plan component identities
id: plan
run: python3 tools/components/prepare.py plan
- name: Cache remaining Linux runtime assets
id: preloads
uses: actions/cache@v4
with:
path: |
app/src/main/assets/linuxfs
!app/src/main/assets/linuxfs/usr/local/lib/droiddeck-comet
!app/src/main/assets/linuxfs/usr/local/lib/droiddeck-xbox
!app/src/main/assets/linuxfs/i386/libblsession.so
!app/src/main/assets/linuxfs/i386/libfakeinput.so
!app/src/main/assets/linuxfs/i386/libfaultreport.so
!app/src/main/assets/linuxfs/libblaudit.so
!app/src/main/assets/linuxfs/libblfastpath.so
!app/src/main/assets/linuxfs/libblsession.so
!app/src/main/assets/linuxfs/libfakeinput.so
!app/src/main/assets/linuxfs/libssbs.so
!app/src/main/assets/linuxfs/usr/local/bin/droiddeck-clipboard
!app/src/main/assets/linuxfs/x86_64/libblsession.so
!app/src/main/assets/linuxfs/x86_64/libfakeinput.so
!app/src/main/assets/linuxfs/x86_64/libfaultreport.so
!app/src/main/assets/linuxfs/x86_64/libvulkan-thunk.so
!app/src/main/assets/linuxfs/x86_64/libwebkitinput.so
key: runtime-v9-${{ steps.plan.outputs.runtime }}
- name: Cache PulseAudio 13.0 source
id: pa-src
uses: actions/cache@v4
with:
path: pa13
key: pulseaudio-src-${{ steps.plan.outputs.pulseaudio-source }}
- name: Cache proot
id: proot
uses: actions/cache@v4
with:
path: |
app/src/main/jniLibs/arm64-v8a/libproot.so
app/src/main/jniLibs/arm64-v8a/libproot-loader.so
key: proot-v2-${{ steps.plan.outputs.proot }}
- name: Cache GOG Galaxy companion
id: comet
uses: actions/cache@v4
with:
path: app/src/main/assets/linuxfs/usr/local/lib/droiddeck-comet
key: comet-v2-${{ steps.plan.outputs.comet }}
- name: Restore verified preload snapshots
id: component-preloads
env:
GH_TOKEN: ${{ github.token }}
FORCE_PRELOAD_REBUILD: ${{ inputs.rebuild_preloads || 'false' }}
COMPONENT_TRUSTED_BASE: ${{ needs.source.outputs.base-sha }}
run: python3 tools/components/prepare.py preload-snapshot
- name: Prepare required Linux build tools once
env:
COMPONENTS_TO_BUILD: >-
${{ steps.preloads.outputs.cache-hit != 'true' && 'runtime' || '' }}
${{ steps.component-preloads.outputs.arm64 != 'true' && 'arm64-preloads' || '' }}
${{ steps.comet.outputs.cache-hit != 'true' && 'comet' || '' }}
run: python3 tools/components/prepare.py tools
- name: Build changed ARM64 preloads
if: steps.component-preloads.outputs.arm64 != 'true'
run: python3 tools/components/prepare.py arm64-preloads
- name: Prepare remaining Linux runtime assets
if: steps.preloads.outputs.cache-hit != 'true'
run: python3 tools/components/prepare.py runtime
- name: Fetch the Debian image the preloads build in
if: steps.component-preloads.outputs.x86 != 'true'
env:
GH_TOKEN: ${{ github.token }}
run: python3 tools/components/prepare.py debian-image
- name: Build the x86 preloads FEX guests load (glibc 2.31)
if: steps.component-preloads.outputs.x86 != 'true'
run: python3 tools/components/prepare.py x86-preloads
- name: Bundle the droiddeck-esync packs
run: python3 tools/components/prepare.py esync
- name: Fetch PulseAudio 13.0 source
if: steps.pa-src.outputs.cache-hit != 'true'
run: python3 tools/components/prepare.py pulseaudio-source
- name: Fetch DirectAudio (tools/directaudio/release.env)
run: python3 tools/components/prepare.py directaudio
- name: Build the AAudio sink into the audio bundle
run: python3 tools/components/prepare.py audio
- name: Build proot
if: steps.proot.outputs.cache-hit != 'true'
run: python3 tools/components/prepare.py proot
- name: Check the preloads
run: |
set -euo pipefail
bash tools/linuxfs/check-preloads.sh app/src/main/assets/linuxfs
- name: Build GOG Galaxy companion
if: steps.comet.outputs.cache-hit != 'true'
run: python3 tools/components/prepare.py comet
- name: Prepare graphics driver variants
run: python3 tools/quest/build-driver.py "$ANDROID_HOME/ndk/$NDK_VERSION"
- name: Record and pack prepared components
run: python3 tools/components/prepare.py pack
- uses: actions/upload-artifact@v4
with:
name: prepared-components
path: app/build/prepared-components.tar.gz
if-no-files-found: error
compression-level: 0
retention-days: 14
build:
needs: xbox
name: Compile and test APK
needs: [source, components, xbox]
name: Compile APK and Android tests
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
runs-on: ubuntu-24.04
timeout-minutes: 45
@@ -150,7 +335,7 @@ jobs:
- name: Merge onto main, or say what conflicts
if: github.event_name == 'pull_request'
env:
BUILD_BASE_SHA: ${{ needs.xbox.outputs.base-sha }}
BUILD_BASE_SHA: ${{ needs.source.outputs.base-sha }}
run: |
set -uo pipefail
git config user.name "ci"
@@ -208,7 +393,7 @@ jobs:
- name: Verify backend and APK source match
env:
XBOX_SOURCE_TREE: ${{ needs.xbox.outputs.tree-sha }}
XBOX_SOURCE_TREE: ${{ needs.source.outputs.tree-sha }}
run: test "$(git rev-parse 'HEAD^{tree}')" = "$XBOX_SOURCE_TREE"
- uses: actions/setup-java@v4
@@ -218,13 +403,11 @@ jobs:
- uses: gradle/actions/setup-gradle@v4
with:
cache-read-only: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository }}
cache-cleanup: on-success
- name: Resolve the runner's NDK
run: |
NDK="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"
VERSION=$(sed -n 's/^Pkg.Revision *= *//p' "$NDK/source.properties")
echo "NDK_VERSION=$VERSION" >> "$GITHUB_ENV"
- name: Resolve the pinned Android NDK
run: bash tools/ci/resolve-ndk.sh
- name: Set the APK build label
id: build-label
@@ -250,264 +433,6 @@ jobs:
echo "DROIDDECK_PR_NUMBER=${PR_NUMBER:-0}" >> "$GITHUB_ENV"
echo "Build: \`$label\`" >> "$GITHUB_STEP_SUMMARY"
- name: Cache the preloads
id: preloads
uses: actions/cache@v4
with:
path: app/src/main/assets/linuxfs
key: preloads-${{ env.PRELOAD_CACHE_VERSION }}-${{ hashFiles('app/src/main/cpp/fakeinput_steam.cpp', 'app/src/main/cpp/adrenotools/src/hook/kgsl.h', 'tools/quest/*', 'tools/linuxfs/preload/*.c', 'tools/linuxfs/build-x86-preloads.sh', 'tools/linuxfs/fex/*', 'tools/linuxfs/clipboard/*.c', 'tools/linuxfs/ssbs/*.c', 'tools/proot/fastpath/*.c', 'tools/linuxfs/overlay/usr/local/bin/*', 'tools/linuxfs/overlay/usr/bin/**', 'tools/linuxfs/desktop/*', 'tools/gamescope/release.env', 'tools/wlroots/release.env', 'tools/linuxfs/uruntime.env', 'tools/linuxfs/licenses/*', 'tools/mangoapp/*', 'tools/msitools/**', 'tools/comet/**', 'tools/xbox/**', 'tools/linuxfs/debian-image.env', 'tools/vr/**') }}
- name: Build libfakeinput.so and libblsession.so (glibc aarch64)
if: steps.preloads.outputs.cache-hit != 'true'
run: |
set -euo pipefail
sudo apt-get -o Dir::Etc::sourcelist=sources.list.d/ubuntu.sources \
-o Dir::Etc::sourceparts=- -o Acquire::Retries=3 update -qq
sudo apt-get install -y -qq gcc-aarch64-linux-gnu g++-aarch64-linux-gnu libvulkan1 mesa-vulkan-drivers >/dev/null
D=app/src/main/assets/linuxfs
mkdir -p "$D"
aarch64-linux-gnu-g++ -shared -fPIC -O2 -Wall -Wno-attributes -Wno-nonnull-compare \
-pthread -std=c++17 -static-libstdc++ -static-libgcc -Wl,--exclude-libs,ALL \
-o "$D/libfakeinput.so" app/src/main/cpp/fakeinput_steam.cpp -ldl
aarch64-linux-gnu-strip --strip-unneeded "$D/libfakeinput.so"
aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -pthread \
-o "$D/libblsession.so" tools/linuxfs/preload/*.c -ldl
aarch64-linux-gnu-strip --strip-unneeded "$D/libblsession.so"
aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -pthread \
-o "$D/libblfastpath.so" tools/proot/fastpath/fastpath.c -ldl
aarch64-linux-gnu-strip --strip-unneeded "$D/libblfastpath.so"
aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -nostdlib -ffreestanding -fno-stack-protector \
-fno-tree-loop-distribute-patterns -Wl,-z,defs -o "$D/libblaudit.so" tools/proot/fastpath/audit.c
aarch64-linux-gnu-strip --strip-unneeded "$D/libblaudit.so"
aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -pthread \
-o "$D/libssbs.so" tools/linuxfs/ssbs/ssbs_adapter.c -ldl
aarch64-linux-gnu-strip --strip-unneeded "$D/libssbs.so"
mkdir -p "$D/usr/local/bin"
aarch64-linux-gnu-gcc -O2 -Wall -Wextra -o "$D/usr/local/bin/droiddeck-clipboard" tools/linuxfs/clipboard/clipboard.c -ldl
aarch64-linux-gnu-strip --strip-unneeded "$D/usr/local/bin/droiddeck-clipboard"
VK_INCLUDE=$(ls -d "${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"/toolchains/llvm/prebuilt/*/sysroot/usr/include | head -1) \
tools/vr/build.sh --native "$D/usr/local/lib/droiddeck-vr"
for script in tools/linuxfs/overlay/usr/local/bin/droiddeck-* tools/linuxfs/overlay/usr/local/bin/steam-compatibility; do
install -Dm644 "$script" "$D/usr/local/bin/$(basename "$script")"
done
for f in tools/linuxfs/overlay/usr/bin/* tools/linuxfs/overlay/usr/bin/steamos-polkit-helpers/*; do
[ -f "$f" ] && install -Dm644 "$f" "$D/${f#tools/linuxfs/overlay/}"
done
if [ -f tools/gamescope/release.env ]; then
. tools/gamescope/release.env
curl -fsSL --retry 3 -o gamescope.tzst "https://github.com/${GAMESCOPE_REPO:-${GITHUB_REPOSITORY}}/releases/download/${GAMESCOPE_TAG}/gamescope.tzst"
echo "${GAMESCOPE_SHA256} gamescope.tzst" | sha256sum -c -
tar --use-compress-program=unzstd -xf gamescope.tzst -C "$D"
test -f "$D/usr/local/bin/gamescope"
rm gamescope.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 -
install -Dm644 uruntime "$D/usr/local/lib/droiddeck/uruntime"
install -Dm644 tools/linuxfs/licenses/uruntime-LICENSE "$D/usr/local/share/licenses/uruntime/LICENSE"
rm uruntime
M="$D/usr/local/lib/mangoapp"
mkdir -p "$M" mango-pkgs
grep -v '^#' tools/mangoapp/packages.txt | while read -r sum url; do
[ -n "$url" ] || continue
curl -fsSL --retry 3 -o mango-pkgs/pkg.tar.zst "$url"
echo "$sum mango-pkgs/pkg.tar.zst" | sha256sum -c -
tar --use-compress-program=unzstd -xf mango-pkgs/pkg.tar.zst -C mango-pkgs
rm mango-pkgs/pkg.tar.zst
done
install -m644 mango-pkgs/usr/bin/mangoapp "$M/mangoapp"
for so in libfmt.so.10 libspdlog.so.1.13 libglfw.so.3 libtraceevent.so.1; do
cp -L "mango-pkgs/usr/lib/$so" "$M/$so"
done
aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -Wl,-soname,libtracefs.so.1 \
-o "$M/libtracefs.so.1" tools/mangoapp/libtracefs-shim.c
aarch64-linux-gnu-strip --strip-unneeded "$M/libtracefs.so.1"
install -Dm644 tools/mangoapp/mangoapp "$D/usr/local/bin/mangoapp"
rm -rf mango-pkgs
ls -l "$M"
mkdir -p pw-pkgs
grep -v '^#' tools/pipewire/packages.txt | while read -r sum url; do
[ -n "$url" ] || continue
curl -fsSL --retry 3 -o pw-pkgs/pkg.tar.zst "$url"
echo "$sum pw-pkgs/pkg.tar.zst" | sha256sum -c -
tar --use-compress-program=unzstd -xf pw-pkgs/pkg.tar.zst -C pw-pkgs
rm pw-pkgs/pkg.tar.zst
done
tools/pipewire/assemble.sh pw-pkgs "$D/usr/local/lib/droiddeck-pipewire.tzst"
rm -rf pw-pkgs
ls -l "$D/usr/local/lib/droiddeck-pipewire.tzst"
T="$D/usr/local/lib/droiddeck-msitools"
mkdir -p "$T" msi-pkgs
grep -v '^#' tools/msitools/packages.txt | while read -r sum url; do
[ -n "$url" ] || continue
curl -fsSL --retry 3 -o msi-pkgs/pkg.tar.xz "$url"
echo "$sum msi-pkgs/pkg.tar.xz" | sha256sum -c -
tar -xJf msi-pkgs/pkg.tar.xz -C msi-pkgs
rm msi-pkgs/pkg.tar.xz
done
install -m644 msi-pkgs/usr/bin/msiinfo "$T/msiinfo"
install -m644 msi-pkgs/usr/bin/cabextract "$T/cabextract"
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 7zip-License 7zip-unRarLicense; do
install -Dm644 "tools/msitools/licenses/$f" "$D/usr/local/share/licenses/droiddeck-msitools/$f"
done
rm -rf msi-pkgs
ls -l "$T"
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/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"
- name: Fetch the Debian image the preloads build in
if: steps.preloads.outputs.cache-hit != 'true'
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
. tools/linuxfs/debian-image.env
image=public.ecr.aws/docker/library/debian:bullseye
if gh release download "$DEBIAN_IMAGE_TAG" -R "$GITHUB_REPOSITORY" -p "$DEBIAN_IMAGE_ASSET" -O /tmp/debian-image.tar.zst \
&& { [ -z "$DEBIAN_IMAGE_SHA256" ] || echo "$DEBIAN_IMAGE_SHA256 /tmp/debian-image.tar.zst" | sha256sum -c -; }; then
zstd -dc /tmp/debian-image.tar.zst | docker load
rm -f /tmp/debian-image.tar.zst
else
echo "::warning::the hosted Debian image could not be fetched; pulling from the registry instead"
for attempt in 1 2 3 4 5; do
docker pull "$image" && break
[ "$attempt" -lt 5 ] || { echo "::error::could not pull $image after $attempt attempts"; exit 1; }
echo "pull attempt $attempt failed (registry busy?); retrying in $((attempt * 30)) s"
sleep $((attempt * 30))
done
fi
docker image ls "$image"
- name: Build the x86 preloads FEX guests load (glibc 2.31)
if: steps.preloads.outputs.cache-hit != 'true'
run: |
set -euo pipefail
docker run --rm -v "$PWD:/src" -w /src public.ecr.aws/docker/library/debian:bullseye bash -c '
set -euo pipefail
install_compilers() {
apt-get -o Acquire::Check-Valid-Until=false update -qq &&
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends gcc g++ gcc-multilib g++-multilib binutils >/dev/null
}
printf "deb http://deb.debian.org/debian bullseye main\ndeb http://security.debian.org/debian-security bullseye-security main\n" > /etc/apt/sources.list
if ! install_compilers; then
printf "deb http://archive.debian.org/debian bullseye main\ndeb http://archive.debian.org/debian-security bullseye-security main\n" > /etc/apt/sources.list
install_compilers
fi
tools/linuxfs/build-x86-preloads.sh app/src/main/assets/linuxfs
'
sudo chown -R "$(id -u):$(id -g)" app/src/main/assets/linuxfs
ls -l app/src/main/assets/linuxfs/x86_64 app/src/main/assets/linuxfs/i386
- name: Bundle the droiddeck-esync packs
run: |
set -euo pipefail
if [ -f tools/droiddeck-esync/release.env ]; then
. tools/droiddeck-esync/release.env
release_url="https://github.com/${SYNC_BUNDLE_REPO}/releases/download/${SYNC_BUNDLE_TAG}"
curl -fsSL --retry 3 -o sync-bundle.tzst "$release_url/$SYNC_BUNDLE_ASSET"
echo "$SYNC_BUNDLE_SHA256 sync-bundle.tzst" | sha256sum -c -
mkdir -p app/src/main/assets/droiddeck-esync
tar --use-compress-program=unzstd -xf sync-bundle.tzst -C app/src/main/assets/droiddeck-esync
rm sync-bundle.tzst
curl -fsSL --retry 3 -o sync-index.json "$release_url/index.json"
revoked=$(python3 -c 'import json, sys; print(" ".join(p["id"] for p in json.load(open(sys.argv[1]))["packs"] if p.get("revoked") is True))' sync-index.json)
for id in $revoked; do
if [ -e "app/src/main/assets/droiddeck-esync/packs/$id.tzst" ]; then
echo "::warning::$SYNC_BUNDLE_ASSET carries revoked pack $id; it is left out of the APK (point tools/droiddeck-esync/release.env at a newer bundle)"
rm -f "app/src/main/assets/droiddeck-esync/packs/$id.tzst"
fi
done
rm sync-index.json
test -f app/src/main/assets/droiddeck-esync/index.json
test -f app/src/main/assets/droiddeck-esync/index.json.sig
ls -lR app/src/main/assets/droiddeck-esync
fi
- name: Cache PulseAudio 13.0 source
id: pa-src
uses: actions/cache@v4
with:
path: pa13
key: pulseaudio-src-13.0
- name: Fetch PulseAudio 13.0 source
if: steps.pa-src.outputs.cache-hit != 'true'
run: |
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
- 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
export NDK="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"
tools/aaudio-sink/build.sh "$PWD/pa13" "$PWD/sink-out"
rm -rf pa-bundle && mkdir -p pa-bundle
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-native-sink.so pa-bundle/modules/arm64/module-directaudio-native-sink.so
(cd pa-bundle && tar --use-compress-program='zstd -19' -cf ../pulseaudio.tzst .)
mkdir -p app/build/prepared-assets
mv pulseaudio.tzst app/build/prepared-assets/pulseaudio.tzst
tar --use-compress-program=unzstd -tf app/build/prepared-assets/pulseaudio.tzst | grep -c '\.so$'
rm -rf pa-bundle sink-out
- name: Cache proot
id: proot
uses: actions/cache@v4
with:
path: |
app/src/main/jniLibs/arm64-v8a/libproot.so
app/src/main/jniLibs/arm64-v8a/libproot-loader.so
key: proot-${{ env.NDK_VERSION }}-${{ hashFiles('tools/proot/**') }}
- name: Build proot
if: steps.proot.outputs.cache-hit != 'true'
run: |
set -euo pipefail
export NDK="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"
tools/proot/build.sh app/src/main/jniLibs/arm64-v8a
- name: Check the preloads
run: |
set -euo pipefail
bash tools/linuxfs/check-preloads.sh app/src/main/assets/linuxfs
- name: Check Quest tracking IPC
run: |
set -euo pipefail
NDK="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"
VK_INCLUDE=$(ls -d "$NDK"/toolchains/llvm/prebuilt/*/sysroot/usr/include | head -1)
mkdir -p build/vr/include
cp -R "$VK_INCLUDE/vulkan" build/vr/include/
if [ -d "$VK_INCLUDE/vk_video" ]; then cp -R "$VK_INCLUDE/vk_video" build/vr/include/; fi
g++ -std=c++17 -Wall -Wextra -Werror -pthread tools/vr/tests/test_xr_tracking.cpp -o /tmp/test-xr-tracking
/tmp/test-xr-tracking
g++ -std=c++17 -Wall -Wextra -Werror tools/vr/tests/test_xr_touch.cpp -o /tmp/test-xr-touch
/tmp/test-xr-touch
g++ -std=c++17 -Wall -Wextra -Werror tools/vr/tests/test_pad_controller_config.cpp -o /tmp/test-pad-controller-config
/tmp/test-pad-controller-config
g++ -std=c++17 -Wall -Wextra -Werror -pthread tools/vr/tests/test_xr_vsync.cpp -o /tmp/test-xr-vsync
/tmp/test-xr-vsync
g++ -std=c++17 -Wall -Wextra -Werror -pthread tools/vr/tests/test_xr_frames.cpp -o /tmp/test-xr-frames
/tmp/test-xr-frames
cc -Itools/vr/third_party/wayland -c tools/vr/src/wayland_client_stub.c -o /tmp/test-wayland-stub.o
cc -Itools/vr/third_party/wayland -c app/src/main/cpp/waylandcomp/generated/xdg-shell-protocol.c -o /tmp/test-xdg-shell.o
g++ -std=c++17 -Wall -Wextra -Werror -Wno-unused-parameter -Wno-missing-field-initializers -pthread -Ibuild/vr/include \
-Itools/vr/third_party/wayland -Iapp/src/main/cpp/waylandcomp/generated \
tools/vr/tests/test_presenter_shutdown.cpp tools/vr/src/presenter.cpp /tmp/test-wayland-stub.o /tmp/test-xdg-shell.o \
-ldl -o /tmp/test-presenter-shutdown
timeout 10 /tmp/test-presenter-shutdown
- name: Rust toolchain for the store engines (aarch64-linux-android)
id: rust
run: |
@@ -525,9 +450,10 @@ jobs:
~/.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') }}
key: stores-v2-${{ runner.os }}-${{ runner.arch }}-${{ env.NDK_VERSION }}-${{ steps.rust.outputs.rustc }}-${{ hashFiles('app/src/main/rust/stores/Cargo.lock') }}-${{ hashFiles('app/src/main/rust/stores/src/**', 'app/src/main/rust/stores/Cargo.toml', 'app/src/main/rust/stores/build.rs', 'app/src/main/rust/stores/.cargo/**', 'app/build.gradle') }}
restore-keys: |
stores-${{ steps.rust.outputs.rustc }}-
stores-v2-${{ runner.os }}-${{ runner.arch }}-${{ env.NDK_VERSION }}-${{ steps.rust.outputs.rustc }}-${{ hashFiles('app/src/main/rust/stores/Cargo.lock') }}-
stores-v2-${{ runner.os }}-${{ runner.arch }}-${{ env.NDK_VERSION }}-${{ steps.rust.outputs.rustc }}-
- name: cargo-ndk
run: |
@@ -545,18 +471,26 @@ jobs:
restore-keys: |
cxx-${{ env.NDK_VERSION }}-
- uses: actions/download-artifact@v4
with:
name: prepared-components
path: app/build
- name: Verify and unpack prepared components
env:
SOURCE_TREE: ${{ needs.source.outputs.tree-sha }}
run: python3 tools/components/prepare.py unpack --tree "$SOURCE_TREE"
- uses: actions/download-artifact@v4
with:
name: xbox-arm64
path: app/src/main/assets/linuxfs/usr/local/lib/droiddeck-xbox
- name: Build GOG Galaxy companion
if: steps.preloads.outputs.cache-hit != 'true'
run: |
rustup toolchain install 1.90.0 --profile minimal
rustup target add --toolchain 1.90.0 aarch64-unknown-linux-gnu
RUSTUP_TOOLCHAIN=1.90.0 bash tools/comet/build.sh
- name: Cache checksum-pinned pack inputs
uses: actions/cache@v4
with:
path: ~/.cache/droiddeck-pack-inputs
key: pack-inputs-v1-${{ hashFiles('tools/packs/build.sh', 'tools/packs/fetch-verified.sh', 'tools/packs/tracking/release.env') }}
restore-keys: pack-inputs-v1-
- name: Build the add-on packs
run: bash tools/packs/build.sh app/build/packs
@@ -567,7 +501,6 @@ jobs:
SGDB_API_KEY: ${{ secrets.SGDB_API_KEY }}
run: |
set -o pipefail
python3 tools/quest/build-driver.py "$ANDROID_HOME/ndk/$NDK_VERSION"
python3 tools/release/runtime_inputs.py record
./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
@@ -598,16 +531,11 @@ jobs:
path: app/src/main/rust/stores/Cargo.lock
if-no-files-found: ignore
- name: Install Python test dependencies
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq python3-yaml
- name: Steam import and game environment tests
run: python3 -m unittest discover -s tools/tests -p 'test_*.py'
- name: Unit tests
id: unit-tests
env:
DROIDDECK_BUILD_LABEL: ${{ steps.build-label.outputs.label }}
SGDB_API_KEY: ${{ secrets.SGDB_API_KEY }}
run: |
set -o pipefail
mkdir -p app/build
@@ -683,86 +611,84 @@ jobs:
TEST_DIAGNOSTICS_URL: ${{ steps.unit-test-diagnostics.outputs.artifact-url }}
APK_OUTCOME: ${{ steps.apk-upload.outcome }}
SAME_REPO: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
run: bash tools/release/report-build.sh
python-tests:
name: Python and native regression tests
needs: source
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
persist-credentials: false
- name: Merge the pinned APK base
if: github.event_name == 'pull_request'
env:
BUILD_BASE_SHA: ${{ needs.source.outputs.base-sha }}
run: |
set -uo pipefail
MARK="<!-- droiddeck-pr-build -->"
{
echo "$MARK"
echo "## Build of this pull request"
echo
[ -f /tmp/pr-merge.md ] && cat /tmp/pr-merge.md
echo
case "$BUILD_OUTCOME:$UNIT_TEST_OUTCOME:$APK_OUTCOME" in
success:success:success)
echo "### Build passed"
echo
echo "The \`droiddeck-apk\` artifact is [on this run]($RUN_URL), signed with the public test key."
echo
if [ "$SAME_REPO" = true ]; then
echo "A release-signed tester build that installs over Stable or Preview is being published now and will appear in DroidDeck's **Test builds** list. Label the PR \`no-test-build\` to skip that."
else
echo "**Maintainer:** add the \`publish-test\` label to publish a release-signed tester build that installs over Stable or Preview and appears in DroidDeck's **Test builds** list. The label is removed automatically after publishing."
fi
echo
echo "Fallback: [open **PR Release Publish**]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/workflows/pr-release-publish.yml) on \`main\` and enter PR \`$PR\`."
;;
success:success:*)
echo "### APK publication incomplete"
echo
echo "Compilation and unit tests passed, but APK publication did not complete. See [the run]($RUN_URL)."
;;
success:failure:*)
echo "### ERROR: Unit tests failed"
echo
echo "The APK compiled, but unit tests failed. No installable artifact was published."
echo
if [ -n "$TEST_DIAGNOSTICS_URL" ]; then
echo "The [unit-test-results]($TEST_DIAGNOSTICS_URL) artifact contains the test log and any generated reports."
else
echo "The test diagnostics upload did not complete. See [the test log]($RUN_URL)."
fi
;;
success:*)
echo "### Build incomplete"
echo
echo "The APK compiled, but unit tests did not complete successfully. See [the run]($RUN_URL)."
;;
failure:*)
echo "### ERROR: Build failed"
echo
echo "The compiler's errors ([full log]($RUN_URL)):"
echo
echo '```'
{ grep -E '^e: |^w: .*error|error:|CMake Error|fatal error|ninja: error|FAILED|What went wrong|Execution failed for task|Unresolved reference|Type mismatch|Unresolved' /tmp/build.log 2>/dev/null \
| sed -e 's#file:///home/runner/work/[^/]*/[^/]*/##' -e 's#/home/runner/work/[^/]*/[^/]*/##' \
| grep -v '^w: ' | awk '!seen[$0]++' | head -80; } || true
echo '```'
echo
echo "<details><summary>What went wrong (Gradle)</summary>"
echo
echo '```'
awk '/^\* What went wrong/{p=1} /^\* Try:/{p=0} p{print}' /tmp/build.log 2>/dev/null | head -60
echo '```'
echo "</details>"
;;
skipped:*|cancelled:*)
echo "### Not built"
echo
echo "The build did not run (see above, or [the run]($RUN_URL))."
;;
esac
echo
} > /tmp/pr-report.md
cat /tmp/pr-report.md >> "$GITHUB_STEP_SUMMARY"
ID=$(gh api "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" --paginate --jq ".[] | select(.body | startswith(\"$MARK\")) | .id" | head -1 || true)
if [ -n "$ID" ]; then
gh api -X PATCH "repos/${GITHUB_REPOSITORY}/issues/comments/${ID}" -F body=@/tmp/pr-report.md >/dev/null
else
gh api -X POST "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" -F body=@/tmp/pr-report.md >/dev/null
fi
set -euo pipefail
git fetch --no-tags origin "$BUILD_BASE_SHA"
git config user.name "ci"
git config user.email "ci@users.noreply.github.com"
git merge --no-ff --no-edit FETCH_HEAD
- name: Verify backend and test source match
env:
XBOX_SOURCE_TREE: ${{ needs.source.outputs.tree-sha }}
run: test "$(git rev-parse 'HEAD^{tree}')" = "$XBOX_SOURCE_TREE"
- name: Install Python test dependencies
timeout-minutes: 7
run: bash tools/ci/install-ubuntu-packages.sh python3-yaml gcc-aarch64-linux-gnu libc6-dev-arm64-cross qemu-user libvulkan-dev mesa-vulkan-drivers
- name: Resolve the pinned Android NDK
run: bash tools/ci/resolve-ndk.sh
- name: Check Quest tracking IPC
run: |
set -euo pipefail
NDK="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"
VK_INCLUDE=$(ls -d "$NDK"/toolchains/llvm/prebuilt/*/sysroot/usr/include | head -1)
mkdir -p build/vr/include
cp -R "$VK_INCLUDE/vulkan" build/vr/include/
if [ -d "$VK_INCLUDE/vk_video" ]; then cp -R "$VK_INCLUDE/vk_video" build/vr/include/; fi
g++ -std=c++17 -Wall -Wextra -Werror -pthread tools/vr/tests/test_xr_tracking.cpp -o /tmp/test-xr-tracking
/tmp/test-xr-tracking
g++ -std=c++17 -Wall -Wextra -Werror tools/vr/tests/test_xr_touch.cpp -o /tmp/test-xr-touch
/tmp/test-xr-touch
g++ -std=c++17 -Wall -Wextra -Werror tools/vr/tests/test_pad_controller_config.cpp -o /tmp/test-pad-controller-config
/tmp/test-pad-controller-config
g++ -std=c++17 -Wall -Wextra -Werror -pthread tools/vr/tests/test_xr_vsync.cpp -o /tmp/test-xr-vsync
/tmp/test-xr-vsync
g++ -std=c++17 -Wall -Wextra -Werror -pthread tools/vr/tests/test_xr_frames.cpp -o /tmp/test-xr-frames
/tmp/test-xr-frames
cc -Itools/vr/third_party/wayland -c tools/vr/src/wayland_client_stub.c -o /tmp/test-wayland-stub.o
cc -Itools/vr/third_party/wayland -c app/src/main/cpp/waylandcomp/generated/xdg-shell-protocol.c -o /tmp/test-xdg-shell.o
g++ -std=c++17 -Wall -Wextra -Werror -Wno-unused-parameter -Wno-missing-field-initializers -pthread -Ibuild/vr/include \
-Itools/vr/third_party/wayland -Iapp/src/main/cpp/waylandcomp/generated \
tools/vr/tests/test_presenter_shutdown.cpp tools/vr/src/presenter.cpp /tmp/test-wayland-stub.o /tmp/test-xdg-shell.o \
-ldl -o /tmp/test-presenter-shutdown
timeout 10 /tmp/test-presenter-shutdown
- name: Steam import and game environment tests
run: python3 -m unittest discover -s tools/tests -p 'test_*.py'
checks:
name: Compile and test APK
needs: [build, python-tests]
if: ${{ always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft) }}
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Require compilation and every test job
env:
BUILD_RESULT: ${{ needs.build.result }}
PYTHON_RESULT: ${{ needs.python-tests.result }}
run: test "$BUILD_RESULT" = success && test "$PYTHON_RESULT" = success
sign:
needs: build
needs: [build, checks]
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
permissions:
contents: read
@@ -774,7 +700,7 @@ jobs:
secrets: inherit
packs:
needs: build
needs: [build, checks]
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
permissions:
contents: read
@@ -794,6 +720,10 @@ jobs:
CI_REPO: Droid-Deck/DroidDeck-CI
KEEP: 10
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.sha }}
persist-credentials: false
- uses: actions/download-artifact@v4
with:
pattern: droiddeck-signed-*
@@ -811,72 +741,13 @@ jobs:
- name: Describe the change
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
pr=$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls" \
--jq '[.[] | select(.merged_at != null and .base.ref == "main")] | first // empty')
if [ -n "$pr" ]; then
number=$(jq -r .number <<<"$pr")
title=$(jq -r .title <<<"$pr")
merged=$(jq -r '.merged_at[:10]' <<<"$pr")
summary=$(jq -r '.body // ""' <<<"$pr" | tr -d '\r' | { grep -v '^🤖' || true; } |
awk '!p && /^#/ { next } NF { p = 1; print; next } p { exit }' | head -n 6 | cut -c1-400)
{
echo "**Merged to main** - [PR #$number]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/pull/$number): $title (merged $merged)"
if [ -n "$summary" ]; then
echo
echo "$summary"
fi
} > notes-head.md
else
subject=$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA" --jq '.commit.message | split("\n")[0]')
echo "**Pushed to main** without a PR - $subject" > notes-head.md
fi
cat notes-head.md
run: bash tools/release/describe-build.sh
- name: Publish to DroidDeck-CI
id: publish
env:
GH_TOKEN: ${{ steps.ci-token.outputs.token }}
run: |
set -euo pipefail
tag=main-${GITHUB_SHA::7}
cat signed/SHA256SUMS-*.txt > signed/SHA256SUMS.txt
rm signed/SHA256SUMS-*.txt
notes="$(cat notes-head.md)
Signed build of $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/commit/$GITHUB_SHA - in main, not yet in a DroidDeck release; for testing.
Commit: $GITHUB_SHA
VersionCode: $(sed -n 's/^ *versionCode \([0-9][0-9]*\)$/\1/p' app/build.gradle)
Each apk installs over a DroidDeck release and keeps its data. \`standard\` is the normal app; the others are the same app under another package name, installed beside it.
SHA-256:
\`\`\`
$(cat signed/SHA256SUMS.txt)
\`\`\`"
gh release delete "$tag" -R "$CI_REPO" --yes --cleanup-tag 2>/dev/null || true
gh release create "$tag" -R "$CI_REPO" --target main --prerelease \
--title "main ${GITHUB_SHA::7}" --notes "$notes" signed/*
gh release list -R "$CI_REPO" -L 200 --json tagName,publishedAt \
--jq '[.[] | select(.tagName | startswith("main-"))] | sort_by(.publishedAt) | reverse | .['"$KEEP"':][] | .tagName' |
while IFS= read -r old; do
if [ "$old" = "$tag" ]; then
continue
fi
if ! gh release delete "$old" -R "$CI_REPO" --yes --cleanup-tag 2>/dev/null; then
if gh release view "$old" -R "$CI_REPO" >/dev/null 2>&1; then
echo "::error::Could not delete old release $old"
exit 1
fi
fi
done
published=$(gh release view "$tag" -R "$CI_REPO" --json tagName,assets)
jq -e --arg tag "$tag" '.tagName == $tag and (.assets | length) > 0' <<< "$published" >/dev/null
url="$GITHUB_SERVER_URL/$CI_REPO/releases/tag/$tag"
apk=$(cd signed && ls DroidDeck-*.apk | awk 'length(a) == 0 || length($0) < length(a) { a = $0 } END { print a }')
echo "release=$url" >> "$GITHUB_OUTPUT"
echo "apk=$GITHUB_SERVER_URL/$CI_REPO/releases/download/$tag/$apk" >> "$GITHUB_OUTPUT"
echo "Published $url"
run: bash tools/release/publish-ci.sh
- name: Post the Preview build to Discord
env:
@@ -884,26 +755,8 @@ jobs:
GH_TOKEN: ${{ github.token }}
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
commit=$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA")
subject=$(jq -r '.commit.message | split("\n")[0]' <<<"$commit")
title=$(jq -r '.commit.message | split("\n") | map(select(length > 0)) | .[1] // ""' <<<"$commit")
pr=$(sed -n 's/^Merge pull request #\([0-9]*\).*/\1/p' <<<"$subject")
if [ -n "$pr" ]; then
what="[#$pr]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/pull/$pr): ${title:-$subject}"
else
what="[\`${GITHUB_SHA::7}\`]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/commit/$GITHUB_SHA): $subject"
fi
get=$(printf 'Download: [standard APK](<%s>) · [all variants](<%s>)' "$APK" "$RELEASE")
content=$(printf '**New signed Preview build** `main-%s`\n%s\nIn the app: **Updates → Preview**\n%s' "${GITHUB_SHA::7}" "$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: bash tools/release/announce-preview.sh
catalog:
needs: announce
+19 -1
View File
@@ -9,6 +9,20 @@ DroidDeck brings the SteamOS experience to Android: Valve's Steam client in Big
<p align="center"><a href="https://discord.gg/JRGAvawjsm"><img src="https://img.shields.io/badge/Discord-Join%20the%20community-5865F2?logo=discord&logoColor=white" alt="Join the DroidDeck Discord"></a></p>
<p align="center">
<a href="https://www.star-history.com/droid-deck/droiddeck"><img src="https://api.star-history.com/badge?repo=Droid-Deck/DroidDeck&type=rank&theme=dark" alt="Star History Rank" /></a>
</p>
<p align="center">
<a href="https://trendshift.io/repositories/267852?utm_source=trendshift-badge&amp;utm_medium=badge&amp;utm_campaign=badge-trendshift-267852" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/267852/daily?language=C" alt="Droid-Deck%2FDroidDeck | Trendshift" width="250" height="55"/></a>
<a href="https://trendshift.io/repositories/267852?utm_source=trendshift-badge&amp;utm_medium=badge&amp;utm_campaign=badge-trendshift-267852" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/267852/daily?language=Kotlin" alt="Droid-Deck%2FDroidDeck | Trendshift" width="250" height="55"/></a>
</p>
<p align="center">
<a href="https://trendshift.io/repositories/267852?utm_source=trendshift-badge&amp;utm_medium=badge&amp;utm_campaign=badge-trendshift-267852" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/267852/weekly?language=C" alt="Droid-Deck%2FDroidDeck | Trendshift" width="250" height="55"/></a>
<a href="https://trendshift.io/repositories/267852?utm_source=trendshift-badge&amp;utm_medium=badge&amp;utm_campaign=badge-trendshift-267852" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/267852/weekly?language=Kotlin" alt="Droid-Deck%2FDroidDeck | Trendshift" width="250" height="55"/></a>
</p>
<p align="center"><img src="docs/releases/media/0.2.0/launch-into-steam.gif" width="80%" alt="Tapping DroidDeck on the Android home screen and landing in Steam Big Picture"></p>
> Note: DroidDeck does not have a stand-alone website. Do not click on any download links from websites claiming to be the DroidDeck team.
@@ -47,7 +61,7 @@ Compatibility and performance vary by device; hardware validation is limited. De
## Credits and licence
GPL-3.0. Runtime, shim, input, and controller work build on WinNative and Bannerlator (maxjivi05). Epic Games and GOG support build on [Phobos’s](https://github.com/phobos665) adaptation of [Legendary](https://github.com/legendary-gl/legendary) and [gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl), respectively. The registry entries a GOG game's install script (`goggame-<id>.script`) sets are read the way [Heroic Games Launcher](https://github.com/Heroic-Games-Launcher/HeroicGamesLauncher)'s GOG setup (GPL-3.0) reads them. LSFG frame generation is from the work of Camille LaVey and the [Eden](https://eden-emu.dev) emulator project, following [lsfg-vk](https://github.com/PancakeTAS/lsfg-vk), ported to WinNative and DroidDeck by [@maxjivi05](https://github.com/maxjivi05); it needs your own copy of [Lossless Scaling](https://store.steampowered.com/app/993090/) and ships none of its shaders. x86 AppImages, and any whose own runtime cannot unpack them, are unpacked with [uruntime](https://github.com/VHSgunzo/uruntime) by VHSgunzo (MIT), shipped unmodified with its licence, which also starts the arm64 emulator AppImages whose own runtime cannot run its extractor on Android. The Steam client's audio goes through [@The412Banner](https://github.com/The412Banner)'s [DirectAudio](https://github.com/The412Banner/directaudio) PulseAudio sink (LGPL-2.1-or-later), built from `tools/directaudio/sink` with its request size kept to its ring so clients are not asked to queue 100 ms ahead. mmap ring-buffer controller input was developed by ribbit384 ([WinNative #345](https://github.com/WinNative-Emu/WinNative/pull/345)). Why the x86-64 Steam client refused the arm64 client's sign-in was checked by reading Steam's saved token with the `ConnectCache` format from [deadlock-api-ingest](https://github.com/deadlock-api/deadlock-api-ingest). x86 programs that link OpenSSL 3 under FEX use the copy in the [Steam Linux Runtime 4.0](https://gitlab.steamos.cloud/steamrt/steamrt) platform. Hooks written with `WriteProcessMemory` into code [FEX](https://github.com/FEX-Emu/FEX) has translated go through `/proc/<pid>/mem` again when the kernel refuses `process_vm_writev`, as [Wine](https://gitlab.winehq.org/wine/wine)'s `server/ptrace.c` wrote them before Wine 11. x86 Linux games under FEX get the same `/etc/asound.conf` that [pressure-vessel](https://gitlab.steamos.cloud/steamrt/steam-runtime-tools) writes into the Steam Linux Runtime, so ALSA plays through PulseAudio. Games render with [Mesa](https://gitlab.freedesktop.org/mesa/mesa) Turnip's own per-render-pass choice between tile memory and system memory, while the Steam client keeps `TU_DEBUG=sysmem`; [FEX](https://github.com/FEX-Emu/FEX)'s code disk cache was checked in its source and is already on through Proton. See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve.
GPL-3.0. Runtime, shim, input, and controller work build on WinNative and Bannerlator (maxjivi05). Epic Games and GOG support build on [Phobos’s](https://github.com/phobos665) adaptation of [Legendary](https://github.com/legendary-gl/legendary) and [gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl), respectively. Battle.net support is [@kayakyakr](https://github.com/kayakyakr)'s: it runs Blizzard's own Battle.net client, downloaded from Blizzard and not included, in Proton, and shows the key art from each game's [Battle.net Shop](https://shop.battle.net) page; without the Steam client its games see DroidDeck's pad as an Xbox 360 controller through `PROTON_SPOOF_STEAMINPUT_VIDPID` in [Proton](https://github.com/ValveSoftware/wine)'s `dlls/winebus.sys`. The registry entries a GOG game's install script (`goggame-<id>.script`) sets are read the way [Heroic Games Launcher](https://github.com/Heroic-Games-Launcher/HeroicGamesLauncher)'s GOG setup (GPL-3.0) reads them. LSFG frame generation is from the work of Camille LaVey and the [Eden](https://eden-emu.dev) emulator project, following [lsfg-vk](https://github.com/PancakeTAS/lsfg-vk), ported to WinNative and DroidDeck by [@maxjivi05](https://github.com/maxjivi05); it needs your own copy of [Lossless Scaling](https://store.steampowered.com/app/993090/) and ships none of its shaders. x86 AppImages, and any whose own runtime cannot unpack them, are unpacked with [uruntime](https://github.com/VHSgunzo/uruntime) by VHSgunzo (MIT), shipped unmodified with its licence, which also starts the arm64 emulator AppImages whose own runtime cannot run its extractor on Android. The Steam client's audio goes through [@The412Banner](https://github.com/The412Banner)'s [DirectAudio](https://github.com/The412Banner/directaudio) PulseAudio sink (LGPL-2.1-or-later), built from `tools/directaudio/sink` with its request size kept to its ring so clients are not asked to queue 100 ms ahead. mmap ring-buffer controller input was developed by ribbit384 ([WinNative #345](https://github.com/WinNative-Emu/WinNative/pull/345)). Why the x86-64 Steam client refused the arm64 client's sign-in was checked by reading Steam's saved token with the `ConnectCache` format from [deadlock-api-ingest](https://github.com/deadlock-api/deadlock-api-ingest). x86 programs that link OpenSSL 3 under FEX use the copy in the [Steam Linux Runtime 4.0](https://gitlab.steamos.cloud/steamrt/steamrt) platform. Hooks written with `WriteProcessMemory` into code [FEX](https://github.com/FEX-Emu/FEX) has translated go through `/proc/<pid>/mem` again when the kernel refuses `process_vm_writev`, as [Wine](https://gitlab.winehq.org/wine/wine)'s `server/ptrace.c` wrote them before Wine 11. x86 Linux games under FEX get the same `/etc/asound.conf` that [pressure-vessel](https://gitlab.steamos.cloud/steamrt/steam-runtime-tools) writes into the Steam Linux Runtime, so ALSA plays through PulseAudio. Games render with [Mesa](https://gitlab.freedesktop.org/mesa/mesa) Turnip's own per-render-pass choice between tile memory and system memory, while the Steam client keeps `TU_DEBUG=sysmem`; [FEX](https://github.com/FEX-Emu/FEX)'s code disk cache was checked in its source and is already on through Proton. The proot fast path picks the guest name of a host file bound under several names in the order [PRoot](https://github.com/proot-me/proot)'s `path/binding.c` (GPL-2.0-or-later) sorts its bindings, so links and `/proc/self/fd` read back as they do through proot. The session's [PipeWire](https://gitlab.freedesktop.org/pipewire/pipewire) (MIT) opens its sockets with the per-socket access settings documented in its `module-access.c`, and [WirePlumber](https://gitlab.freedesktop.org/pipewire/wireplumber) (MIT) keeps those clients at full permissions through its `access.rules`. Wine write watches use the userfaultfd support in [Proton](https://github.com/ValveSoftware/wine)'s `dlls/ntdll/unix/virtual.c` (LGPL-2.1-or-later): libblsession gives it the arm64 syscall number and answers its `PAGEMAP_SCAN` from `/proc/self/pagemap` where Android refuses the ioctl. Wine on arm64 sets a thread's full context by raising `SIGUSR2` from `syscall_dispatcher_return_slowpath` in [Wine](https://gitlab.winehq.org/wine/wine)'s `dlls/ntdll/unix/signal_arm64.c` (LGPL-2.1-or-later); libblsession runs that handler in place on a frame laid out as the [Linux](https://github.com/torvalds/linux) kernel's `arch/arm64/kernel/signal.c` builds it and returns through `rt_sigreturn`, so proot does not stop the thread for it. Wine's process list reads each process's memory counters from `/proc/<pid>/status` in `fill_vm_counters` ([Wine](https://gitlab.winehq.org/wine/wine) `dlls/ntdll/unix/process.c`, LGPL-2.1-or-later), so libblsession gives those reads the kernel's file instead of a copy with `TracerPid` cleared. droiddeck-ntsync is checked against the object, wait and error semantics documented for the [Linux](https://github.com/torvalds/linux) ntsync driverLine truncated
XR glasses support builds on WinNative's VITURE glasses work. VITURE protocol details were checked against [XRLinuxDriver](https://github.com/wheaney/XRLinuxDriver) by [@wheaney](https://github.com/wheaney), including the VITURE protocol header it ships, [ar-drivers-rs](https://github.com/badicsalex/ar-drivers-rs) by [@badicsalex](https://github.com/badicsalex) and the [go-macos/iokit VITURE package](https://pkg.go.dev/github.com/go-macos/iokit/viture); the optional VITURE SDK belongs to VITURE and is not included. RayNeo support adapts [@informalTechCode](https://github.com/informalTechCode)'s [RayNeo-Air-Series-3DoF-Scaffold](https://github.com/informalTechCode/RayNeo-Air-Series-3DoF-Scaffold) (Apache-2.0) and references [@verncat](https://github.com/verncat)'s [RayNeo-Air-3S-Pro-OpenVR](https://github.com/verncat/RayNeo-Air-3S-Pro-OpenVR) (MIT) and [Monado](https://gitlab.freedesktop.org/monado/monado) (BSL-1.0); see [docs/third-party/rayneo](docs/third-party/rayneo/README.md). XREAL 1S support references [XRLinuxDriver](https://github.com/wheaney/XRLinuxDriver) and [xrealOneDeviceKit](https://github.com/wheaney/xrealOneDeviceKit) by @wheaney, [xreal_one_driver](https://github.com/rohitsangwan01/xreal_one_driver) by [@rohitsangwan01](https://github.com/rohitsangwan01) (MIT) and [xr-tools](https://github.com/0xcaff/xr-tools) by [@0xcaff](https://github.com/0xcaff). Rokid support follows the Rokid protocol notes in [ar-drivers-rs](https://github.com/badicsalex/ar-drivers-rs) and @badicsalex's [Void Computing write-ups](https://voidcomputing.hu/blog/worse-better-prettier/), and [Monado](https://gitlab.freedesktop.org/monado/monado)'s Rokid driver. The REDMAGIC glasses-display findings draw on [@mekhontsev](https://github.com/mekhontsev)'s [MagicDesk investigation](https://github.com/mekhontsev/magicdesk/blob/main/docs/xr-resolution-fix.md) and [@Coding-BR](https://github.com/Coding-BR)'s [decompiled Nubia kernel](https://github.com/Coding-BR/android_kernel_nubia_sm8850_qwjujube). VITURE, RayNeo, XREAL, Rokid and REDMAGIC are trademarks of their owners; this project is not affiliated with them.
@@ -56,3 +70,7 @@ Joy-Con VR controllers port the arm model of Google's [Google VR SDK for Unity](
Camera hand tracking runs Google's [MediaPipe](https://github.com/google-ai-edge/mediapipe) Tasks Vision library with its Pose Landmarker (Lite) model, BlazePose GHUM 3D, and its Hand Landmarker model (all Apache-2.0), on frames from AndroidX [CameraX](https://developer.android.com/jetpack/androidx/releases/camera) (Apache-2.0); MediaPipe's usage reports are switched off. The design was checked against [@F8LEFT](https://github.com/F8LEFT)'s [JoyConVr](https://github.com/F8LEFT/JoyConVr) camera app (MIT), and the short Joy-Con movements follow the accelerometer mode of the [Google VR SDK for Unity](https://github.com/googlevr/gvr-unity-sdk)'s arm model, Eric Foxlin's zero-velocity updates and Frosio, Pedersini and Borghese's accelerometer autocalibration, and the camera and Joy-Con are joined by a Kalman filter (R. E. Kalman, 1960). Bare camera hands take their gestures after [ALVR](https://github.com/alvr-org/ALVR)'s `hand_gestures.rs` (MIT) and Meta Quest's menu pinch, with distances checked on photos from the [HaGRID](https://github.com/hukenovs/hagrid) gesture set (CC BY-SA 4.0, not included), fit the palm with Horn's quaternion method (1987) and smooth it with the 1€ filter (Casiez, Roussel and Vogel, 2012); the white glove model follows Valve's OpenVR render model reference and Index controller poses, is meshed with surface nets (Gibson, 1998; Mikola Lysenko) and blends and shades it with Inigo Quilez's smooth minimum and distance-field ambient occlusion, porting his polygon distance (MIT); see [docs/third-party/handtracking](docs/third-party/handtracking/README.md).
The Store installs and runs apps with [Flatpak](https://github.com/flatpak/flatpak) (LGPL-2.1). Its sandbox goes through DroidDeck's own bubblewrap shim, which follows how Flatpak hands bwrap its environment. Chromium and Electron apps from Flathub start their browser processes through [zypak](https://github.com/refi64/zypak) by [@refi64](https://github.com/refi64) (BSD-3-Clause); its source was read to see that it always spawns them through Flatpak's spawn portal, which is why DroidDeck starts them directly without Chromium's sandbox instead.
Steam's Browse buttons open a GTK 3 file chooser through PyGObject (LGPL-2.1-or-later). It runs under Steam's own `reaper` with Steam's app ID, which [gamescope](https://github.com/ValveSoftware/gamescope) (BSD-2-Clause) reads from the process tree (`GetAppIdFromReaper` in `src/Utils/Process.cpp`) to show it over Steam.
Black Ops III and Dark Souls Remastered start without [umu-protonfixes](https://github.com/Open-Wine-Components/umu-protonfixes) (BSD-2-Clause), whose fixes for them (`311210.py` sets ALSA audio, `570940.py` installs VC++ 2017) fail under ARM64 Proton.
+10 -3
View File
@@ -80,7 +80,7 @@ def sourceCommit = providers.environmentVariable('DROIDDECK_SOURCE_COMMIT').getO
def prNumber = (providers.environmentVariable('DROIDDECK_PR_NUMBER').getOrNull() ?: '0').with { it.isInteger() ? it : '0' }
def ciBuild = providers.environmentVariable('GITHUB_ACTIONS').getOrNull() == 'true'
def releaseSigner = rootProject.file('keystore/release-signer.sha256').text.trim()
def commitTime = (gitValue(['show', '-s', '--format=%ct', 'HEAD']) ?: '0').with { it.isLong() ? it : '0' }
def commitTime = (gitValue(['show', '-s', '--format=%ct', '--end-of-options', sourceCommit]) ?: '0').with { it.isLong() ? it : '0' }
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()] }
@@ -122,8 +122,8 @@ android {
applicationId "com.droiddeck.launcher"
minSdkVersion 26
targetSdkVersion 28
versionCode 14
versionName "0.5.0"
versionCode 15
versionName "0.6.0"
buildConfigField "String", "BUILD_LABEL", "\"${escapedBuildLabel}\""
buildConfigField "String", "SOURCE_COMMIT", "\"${sourceCommit}\""
buildConfigField "int", "PR_NUMBER", "${prNumber}"
@@ -220,6 +220,10 @@ android {
unitTests.returnDefaultValues = true
unitTests.includeAndroidResources = true
unitTests.all {
if (ciBuild) {
outputs.upToDateWhen { false }
outputs.doNotCacheIf('CI executes the test suite on every run') { true }
}
// Robolectric keeps a sandbox per SDK level the tests ask for; seven no longer fit in Gradle's default 512 MB.
maxHeapSize = '2g'
def logTarget = providers.systemProperty('robolectric.logging').orNull
@@ -258,6 +262,7 @@ def runtimeAssetsDir = layout.buildDirectory.dir('runtimeAssets')
def stageRuntimeAssets = tasks.register('stageRuntimeAssets', Sync) {
dependsOn(stageSessionScripts)
from(layout.projectDirectory.dir('src/main/assets')) { exclude 'pulseaudio.tzst' }
from(rootProject.file('tools/linuxfs/innoextract'))
from(layout.buildDirectory.dir('prepared-assets'))
into(runtimeAssetsDir)
}
@@ -333,6 +338,8 @@ dependencies {
implementation 'androidx.camera:camera-camera2:1.4.2'
implementation 'androidx.camera:camera-lifecycle:1.4.2'
implementation 'androidx.webkit:webkit:1.12.1'
// Google's WebRTC (BSD) for the native Xbox Cloud Gaming player (XboxNativePlayerActivity).
implementation 'io.github.webrtc-sdk:android:125.6422.07'
implementation("com.google.mediapipe:tasks-vision:${trackingPack.MEDIAPIPE_VERSION}") {
exclude group: 'com.google.auto.value', module: 'auto-value'
}
+35 -4
View File
@@ -2,6 +2,15 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<queries>
<intent>
<action android:name="android.content.pm.action.CONFIRM_INSTALL" />
</intent>
<intent>
<action android:name="android.content.pm.action.CONFIRM_PERMISSIONS" />
</intent>
</queries>
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
@@ -34,6 +43,7 @@
android:localeConfig="@xml/locales_config"
android:networkSecurityConfig="@xml/network_security_config"
android:allowBackup="false"
android:allowAudioPlaybackCapture="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:theme="@style/AppTheme"
@@ -61,7 +71,7 @@
</activity>
<activity
android:name=".MainActivity"
android:name=".FrontEndActivity"
android:exported="true"
android:screenOrientation="sensorLandscape"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|density|keyboard|keyboardHidden|navigation">
@@ -84,8 +94,8 @@
</activity>
<activity-alias
android:name=".LauncherDark"
android:targetActivity=".MainActivity"
android:name=".MainActivity"
android:targetActivity=".FrontEndActivity"
android:icon="@mipmap/ic_launcher"
android:exported="true">
<intent-filter>
@@ -97,7 +107,7 @@
<activity-alias
android:name=".LauncherLight"
android:targetActivity=".MainActivity"
android:targetActivity=".FrontEndActivity"
android:icon="@mipmap/ic_launcher_light"
android:enabled="false"
android:exported="true">
@@ -151,6 +161,27 @@
android:process=":xboxlogin" android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|density"
android:theme="@style/AppTheme" />
<activity android:name=".stores.xbox.XboxCloudPlayerActivity" android:exported="false"
android:screenOrientation="sensorLandscape" android:hardwareAccelerated="true"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|smallestScreenSize|density|navigation|uiMode"
android:theme="@style/AppTheme" />
<service android:name=".stores.xbox.XboxCloudTokenService" android:exported="false" />
<activity android:name=".stores.xbox.XboxNativePlayerActivity" android:exported="false"
android:process=":xcloudnative" android:screenOrientation="sensorLandscape" android:hardwareAccelerated="true"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|smallestScreenSize|density|navigation|uiMode"
android:theme="@style/AppTheme" />
<activity android:name=".stores.xbox.XboxCloudActivity" android:exported="false"
android:process=":xboxcloud" android:launchMode="singleTop" android:screenOrientation="sensorLandscape"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|smallestScreenSize|density|navigation|uiMode"
android:theme="@style/AppTheme" />
<activity android:name=".stores.xbox.XboxCloudSettingsActivity" android:exported="false"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|density"
android:theme="@style/AppTheme" />
<activity
android:name=".stores.StoreLoginActivity"
android:exported="false"
+18
View File
@@ -0,0 +1,18 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<style>
html, body { margin: 0; height: 100%; background: #000; overflow: hidden; }
#player { position: absolute; inset: 0; }
#status { position: absolute; left: 0; right: 0; bottom: 12%; text-align: center; color: #ddd;
font: 16px sans-serif; text-shadow: 0 0 4px #000; pointer-events: none; }
</style>
</head>
<body>
<div id="player"></div>
<div id="status"></div>
<script src="player.js"></script>
</body>
</html>
+187
View File
@@ -0,0 +1,187 @@
/* DroidDeck Xbox Cloud Gaming player. Includes Greenlight's player (c) UnknownSKL (Jim Kroon), MIT. */
(()=>{var _=class{_player;_dataChannel;constructor(e){this._player=e,this._dataChannel=this._player._peerConnection.createDataChannel(this.getChannelName(),this.getChannelConfig()),this._dataChannel.onopen=this.onOpen.bind(this),this._dataChannel.onmessage=this.onMessage.bind(this),this._dataChannel.onclosing=this.onClosing.bind(this),this._dataChannel.onclose=this.onClose.bind(this),this._dataChannel.onerror=this.onError.bind(this)}getChannelName(){return"channel"}getChannelConfig(){return{}}onOpen(e){console.log(this.getChannelName(),"Opening channel:",e)}onMessage(e){console.log(this.getChannelName(),"Message channel:",e)}onClosing(e){console.log(this.getChannelName(),"Closing channel:",e)}onClose(e){console.log(this.getChannelName(),"Closed channel:",e)}onError(e){console.log(this.getChannelName(),"Error channel:",e)}send(e){if(this._dataChannel.readyState!=="open"){console.log("["+this.getChannelName()+"] Tried to send data while channel is not open. readyState is:",this._dataChannel.readyState);return}return typeof e=="string"&&(e=new TextEncoder().encode(e)),this._dataChannel.send(e)}getPlayer(){return this._player}destroy(){}};var S=class extends _{_micPermissions="prompt";_micStream;constructor(e){super(e),navigator.permissions.query({name:"microphone"}).then(t=>{this._micPermissions=t.state,t.onchange=()=>{this._micPermissions=t.state}})}getChannelName(){return"chat"}getChannelConfig(){return{ordered:!0,protocol:"chatV1"}}destroy(){}};var v=class r{_player;_index;_physicalGamepadId=-1;_rumbleInterval;_isFocused=!0;_gamepadOverride={buttons:{}};_options={enable_keyboard:!1,enable_gamepad:!0,enable_vibration:!0,keyboard_mapping:{A:"Enter",B:"Backspace",X:"x",Y:"y",DPadUp:"ArrowUp",DPadDown:"ArrowDown",DPadLeft:"ArrowLeft",DPadRight:"ArrowRight",LeftShoulder:"[",RightShoulder:"]",LeftThumb:"l",RightThumb:"r",LeftTrigger:"-",RightTrigger:"=",Menu:"m",View:"v",Nexus:"n"},gamepad_mapping:{A:"0",B:"1",X:"2",Y:"3",DPadUp:"12",DPadDown:"13",DPadLeft:"14",DPadRight:"15",LeftShoulder:"4",RightShoulder:"5",LeftThumb:"10",RightThumb:"11",LeftTrigger:"6",RightTrigger:"7",Menu:"9",View:"8",Nexus:"16"},gamepad_axes_mapping:{LeftThumbXAxis:"0",LeftThumbYAxis:"1",RightThumbXAxis:"2",RightThumbYAxis:"3"},gamepad_deadzone:.08,gamepad_force_capture:!1};_listener={keyDown:this.onKeyDown.bind(this),keyUp:this.onKeyUp.bind(this),gamepadConnected:this.onGamepadConnected.bind(this),gamepadDisconnected:this.onGamepadDisconnected.bind(this),windowFocus:this.onWindowFocus.bind(this),windowBlur:this.onWindowBlur.bind(this)};constructor(e,t={}){this._index=e,this._options={...this._options,...t}}attach(e){this._player=e,this._player._channels.control.sendGamepadState(this._index,!0,this),this._options.enable_keyboard===!0&&(window.addEventListener("keydown",this._listener.keyDown),window.addEventListener("keyup",this._listener.keyUp)),this._options.enable_gamepad===!0&&(window.addEventListener("gamepadconnected",this._listener.gamepadConnected),window.addEventListener("gamepaddisconnected",this._listener.gamepadDisconnected)),this._options.enable_gamepad===!0&&this._options.gamepad_force_capture===!1&&(window.addEventListener("blur",this._listener.windowBlur),window.addEventListener("focus",this._listener.windowFocus)),this.detectActiveGamepad()}detach(){if(this._player===void 0){console.log("[Gamepad] Player is not attached. this._player is:",this._player);return}this._player._channels.control.sendGamepadState(this._index,!1),this._physicalGamepadId=-1,this._rumbleInterval!==void 0&&clearInterval(this._rumbleInterval),this._options.enable_keyboard===!0&&(window.removeEventListener("keydown",this._listener.keyDown),window.removeEventListener("keyup",this._listener.keyUp)),this._options.enable_gamepad===!0&&(window.removeEventListener("gamepadconnected",this._listener.gamepadConnected),window.removeEventListener("gamepaddisconnected",this._listener.gamepadDisconnected)),this._options.enable_gamepad===!0&&this._options.gamepad_force_capture===!1&&(window.removeEventListener("blur",this._listener.windowBlur),window.removeEventListener("focus",this._listener.windowFocus))}sendButtonState(e,t){if(this._player===void 0){console.log("[Gamepad] Player is not attached. this._player is:",this._player);return}if(!(e in this.getDefaultFamepadFrame())){console.log("[Gamepad] Invalid button:",e);return}let n=this.getDefaultFamepadFrame();n.GamepadIndex=this._index,this._gamepadOverride.buttons[e]=t}onKeyDown(e){if(this._isFocused!==!1)for(let t in this._options.keyboard_mapping)this._options.keyboard_mapping[t]===e.key&&(this._gamepadOverride.buttons[t]=1,e.preventDefault())}onKeyUp(e){if(this._isFocused!==!1)for(let t in this._options.keyboard_mapping)this._options.keyboard_mapping[t]===e.key&&(this._gamepadOverride.buttons[t]=0,e.preventDefault())}onWindowFocus(){this._isFocused=!0}onWindowBlur(){}getDefaultFamepadFrame(){return{GamepadIndex:0,Nexus:0,Menu:0,View:0,A:0,B:0,X:0,Y:0,DPadUp:0,DPadDown:0,DPadLeft:0,DPadRight:0,LeftShoulder:0,Line truncated
`+t.ContentText)?this.completeTransaction(e.id,{Result:0}):this.completeTransaction(e.id,{Result:1})}};var M=class extends _{_handshakeCompleted=!1;getChannelName(){return"message"}getChannelConfig(){return{protocol:"messageV1",ordered:!0}}onOpen(){console.log("[MessageChannel] Sending handshake...");let e=JSON.stringify({type:"Handshake",version:"messageV1",id:"be0bfc6d-1e83-4c8a-90ed-fa8601c5a179",cv:"0"});this.send(e)}onMessage(e){let t=JSON.parse(e.data);switch(t.type){case"HandshakeAck":console.log("[MessageChannel] Handshake completed"),this._handshakeCompleted=!0,this.getPlayer()._channels.control.sendAuthorization(),this.getPlayer()._channels.input.start(),this.sendConfig();break;case"TransactionStart":case"Message":new D(this,t);break;default:console.log("[MessageChannel] Unhandled message type:",t.type,t)}}sendConfig(){let e=[],t=[0,2,0],n=JSON.stringify(this.generateMessage("/streaming/systemUi/configuration",{version:t,systemUis:e}));this.send(n);let i=JSON.stringify(this.generateMessage("/streaming/properties/clientappinstallidchanged",{clientAppInstallId:"c97d7ee0-73b2-4239-bf1d-9d805a338429"}));this.send(i);let a=JSON.stringify(this.generateMessage("/streaming/characteristics/orientationchanged",{orientation:0}));this.send(a);let s=JSON.stringify(this.generateMessage("/streaming/characteristics/touchinputenabledchanged",{touchInputEnabled:!1}));this.send(s);let o=JSON.stringify(this.generateMessage("/streaming/characteristics/clientdevicecapabilities",{}));this.send(o);let d=JSON.stringify(this.generateMessage("/streaming/characteristics/dimensionschanged",{horizontal:1920,vertical:1080,preferredWidth:1920,preferredHeight:1080,safeAreaLeft:0,safeAreaTop:0,safeAreaRight:1920,safeAreaBottom:1080,supportsCustomResolution:!0}));this.send(d)}generateMessage(e,t){return{type:"Message",content:JSON.stringify(t),id:Q(),target:e,cv:""}}destroy(){}};var E=class{_address;_ipv4Address;_ipv4Port;constructor(e){this._address=e;let t=this._address.split(":"),n=t[6]+t[7],i=t[5],a=~parseInt(n.substring(0,2),16)&255,s=~parseInt(n.substring(2,4),16)&255,o=~parseInt(n.substring(4,6),16)&255,d=~parseInt(n.substring(6,8),16)&255,u=~parseInt(i.substring(0,4),16)&65535;this._ipv4Address=a+"."+s+"."+o+"."+d,this._ipv4Port=u}getIpv4Address(){return this._ipv4Address}getIpv4Port(){return this._ipv4Port}};var R=class{_peerConnection;_iceCandidates=[];constructor(e){this._peerConnection=e._peerConnection,this._peerConnection.addEventListener("icecandidate",t=>{t.candidate&&this._iceCandidates.push(t.candidate)})}getCandidates(){return this._iceCandidates}setRemoteCandidates(e){e.length===0&&console.warn("Error: No candidates received!");for(let t in e){if(e[t].candidate==="a=end-of-candidates")continue;let n=e[t].candidate.split(" ");if(n.length>4&&n[4].substr(0,4)==="2001"){let i=new E(n[4]);this._peerConnection.addIceCandidate({candidate:"a=candidate:10 1 UDP 1 "+i.getIpv4Address()+" 9002 typ host ",sdpMLineIndex:e[t].sdpMLineIndex,sdpMid:e[t].sdpMid}),this._peerConnection.addIceCandidate({candidate:"a=candidate:11 1 UDP 1 "+i.getIpv4Address()+" "+i.getIpv4Port()+" typ host ",sdpMLineIndex:e[t].sdpMLineIndex,sdpMid:e[t].sdpMid})}this._peerConnection.addIceCandidate({candidate:e[t].candidate,sdpMid:e[t].sdpMid,sdpMLineIndex:e[t].sdpMLineIndex})}}};var I=class{_player;_peerConnection;constructor(e){this._player=e,this._peerConnection=e._peerConnection}getAvailableCodecs(){let e=RTCRtpReceiver.getCapabilities("video")?.codecs;if(e===void 0)return[];let t=[];for(let n in e)t.includes(e[n].mimeType)||t.push(e[n].mimeType);return t}setLocalSDP(e){return console.log("[SDP] setLocalSDP() Setting local SDP:",e),e.sdp=e.sdp?.replace("useinbandfec=1","useinbandfec=1; stereo=1"),e}setRemoteSDP(e){return console.log("[SDP] setRemoteSDP() Setting remote SDP:",e),e}getDefaultCodecPreferences(){let e=RTCRtpReceiver.getCapabilities("video")?.codecs;if(e===void 0)return[];let t=[],n=[],i=[],a=[];for(let o in e)e[o].mimeType.includes("H264")?e[o].sdpFmtpLine?.includes("profile-level-id=4d")?t.push(e[o]):e[o].sdpFmtpLine?.includes("profile-level-id=42e")?n.push(e[o]):e[o].sdpFmtpLine?.includes("profile-level-id=420")&&i.push(e[o]):(e[o].mimeType.includes("ulpfec")||e[o].mimeType.includes("flexfec")||e[o].mimeType.includes("VP9")||e[o].mimeType.includes("VP8"))&&a.push(e[o]);return[...t,...n,...i,...a]}_setBitrate(e,t,n){let i=e.split(`
`),a=-1;for(let o=0;o<i.length;o++)if(i[o].indexOf("m="+t)===0){a=o;break}if(a===-1)return console.debug("Could not find the m line for",t),e;for(a++;i[a].indexOf("i=")===0||i[a].indexOf("c=")===0;)a++;if(i[a].indexOf("b")===0)return i[a]="b=AS:"+n,i.join(`
`);let s=i.slice(0,a);return s.push("b=AS:"+n),s=s.concat(i.slice(a,i.length)),s.join(`
`)}};var P=class{_player;_peerConnection;_videoCodec="";_audioCodec="";_videoWidth=0;_videoHeight=0;_videoFps=0;_rtt=0;_remoteHost="";_remotePort=0;_remoteIsLocal=!1;_remoteIsIpv6=!1;_activeRemoteCandidate="";constructor(e){this._player=e,this._peerConnection=e._peerConnection,setInterval(this.loop.bind(this),1e3)}loop(){this._peerConnection.getStats().then(e=>{e.forEach(t=>{t.type==="inbound-rtp"?this.readInboundRtp(t):t.type==="candidate-pair"&&t.packetsReceived!==void 0&&t.packetsReceived>0?this.readCandidatePair(t):t.type==="remote-candidate"&&this.readRemoteCandidate(t)})})}readInboundRtp(e){e.kind==="video"&&e.codecId&&e.packetsReceived!==void 0&&e.packetsReceived>0?(e.codecId.includes("profile-level-id=4d")?this._videoCodec="H264 (High)":e.codecId.includes("profile-level-id=42e")?this._videoCodec="H264 (Normal)":e.codecId.includes("profile-level-id=420")?this._videoCodec="H264 (Low)":this._videoCodec="Other ("+e.codecId+")",e.frameWidth&&(this._videoWidth=e.frameWidth),e.frameHeight&&(this._videoHeight=e.frameHeight),e.framesPerSecond&&(this._videoFps=e.framesPerSecond)):e.kind==="audio"&&e.codecId&&e.packetsReceived!==void 0&&e.packetsReceived>0&&(this._audioCodec=e.codecId)}readCandidatePair(e){this._activeRemoteCandidate=e.remoteCandidateId,this._rtt=e.currentRoundTripTime}readRemoteCandidate(e){e.id===this._activeRemoteCandidate&&(this.isPrivateIP(e.address)?this._remoteIsLocal=!0:this._remoteIsLocal=!1,this.isIpv6(e.address)?this._remoteIsIpv6=!0:this._remoteIsIpv6=!1,this._remoteHost=e.address,this._remotePort=e.port)}isPrivateIP(e){let t=e.split(".");return t[0]==="10"||t[0]==="172"&&parseInt(t[1],10)>=16&&parseInt(t[1],10)<=31||t[0]==="192"&&t[1]==="168"}isIpv6(e){return e.split(":").length>3}};var G=class{_player;_videoComponent;_overlays={debug:void 0};constructor(e,t){this._videoComponent=e,this._player=t}toggleDebug(){this._overlays.debug===void 0?this.createDebugOverlay():this.destroy()}createDebugOverlay(){this._overlays.debug=document.createElement("div"),this._overlays.debug.id="playerDebugOverlay",this._overlays.debug.style.position="absolute",this._overlays.debug.style.top="0",this._overlays.debug.style.left="0",this._overlays.debug.style.width=this._videoComponent.getElement()?.style.width||"100%",this._overlays.debug.style.height=this._videoComponent.getElement()?.style.height||"auto",this._overlays.debug.style.background="linear-gradient(0deg, rgba(0,0,0, 0) 0%, rgba(0,0,0, 0) 75%, rgba(0,0,0, 0.5) 100%)",this._overlays.debug.style.padding="10px",this._overlays.debug.style.pointerEvents="none",document.getElementById(this._player.getElementId())?.appendChild(this._overlays.debug),setTimeout(()=>{this.refreshDebugOverlay()},500),this.refreshDebugOverlay()}destroy(){this._overlays.debug?.remove(),this._overlays.debug=void 0}refreshDebugOverlay(){if(this._overlays.debug!==void 0){this._overlays.debug.innerHTML="",this._overlays.debug.appendChild(this.createLabel("Local Play",this._player.getStats()._remoteIsLocal?"Local":"Remote","ok")),this._overlays.debug.appendChild(this.createLabel("Resolution",this._player.getStats()._videoWidth+"x"+this._player.getStats()._videoHeight,"ok")),this._overlays.debug.appendChild(this.createLabel("Stream FPS",this._player.getStats()._videoFps.toString(),this._player.getStats()._videoFps>=58?"ok":"warning"));let e=this._videoComponent.getFrameStats?.();e&&(this._overlays.debug.appendChild(this.createLabel("Render FPS",e.renderingFps.toString(),e.renderingFps>=58?"ok":"warning")),this._overlays.debug.appendChild(this.createLabel("Render Delay",e.renderDelayMs.toString()+" ms",e.renderDelayMs<16?"ok":e.renderDelayMs<33?"warning":"error")),this._overlays.debug.appendChild(this.createLabel("Dropped Frames",e.droppedFrames.toString(),e.droppedFrames===0?"ok":e.droppedFrames<30?"warning":"error"))),this._overlays.debug.appendChild(this.createLabel("Connection",this._player.getStats()._remoteIsIpv6?"IPv6":"IPv4","ok")),this._overlays.debug.appendChild(this.createLabel("Video",this._player.getStats()._videoCodec,"ok"));let t=this._player.getStats()._rtt*1e3>75?"error":this._player.getStats()._rtt*1e3>40?"warning":"ok";this._overlays.debug.appendChild(this.createLabel("RTT",(this._player.getStats()._rtt*1e3).toString()+" ms",t)),setTimeout(()=>{this.refreshDebugOverlay()},500)}}createLabel(e,t="",n=""){let i={borderRadius:"5px",background:"linear-gradient(0deg, rgba(28,26,26,1) 0%, rgba(47,45,45,1) 100%)",padding:"10px",fontSize:"12px",marginTop:"3px",marginRight:"10px",marginBottom:"2px",textTransform:"uppercase",color:"white",display:"inline-block"},a=document.createElement("span"),s={error:"rgb(220, 53, 69)",warning:"rgb(255, 193, 7)",ok:"rgb(25, 135, 84)"};t!==""?a.innerHTML=`${e}: <span style="background: ${s[n]}; border-radius: 5px; padding: 4px; padding-left: 8px; padding-right: 8px;">${t}</span>`:a.innerText=e;for(let o in i)a.style[o]=i[o];return a}};var L=class{_player;_element;_overlay;constructor(e){this._player=e,this._overlay=new G(this,this._pLine truncated
in vec2 position;
out vec2 vUV;
void main() { vUV = position * 0.5 + 0.5; gl_Position = vec4(position, 0.0, 1.0); }`,j=`attribute vec2 position;
varying vec2 vUV;
void main() { vUV = position * 0.5 + 0.5; gl_Position = vec4(position, 0.0, 1.0); }`,Z=`#version 300 es
precision mediump float;
uniform sampler2D screenTexture;
uniform vec2 resolution;
uniform int filterId;
uniform float sharpenFactor;
uniform float brightness;
uniform float contrast;
uniform float gamma;
uniform float saturation;
uniform float deband;
out vec4 fragColor;
const float CAS_CONTRAST_PEAK = 0.8 * -3.0 + 8.0;
vec3 sharpen(vec2 coord, vec3 e) {
vec2 px = 1.0 / resolution;
vec3 b = texture(screenTexture, coord + px * vec2(0, 1)).rgb;
vec3 d = texture(screenTexture, coord + px * vec2(-1, 0)).rgb;
vec3 f = texture(screenTexture, coord + px * vec2(1, 0)).rgb;
vec3 h = texture(screenTexture, coord + px * vec2(0, -1)).rgb;
vec3 a = texture(screenTexture, coord + px * vec2(-1, 1)).rgb;
vec3 c = texture(screenTexture, coord + px * vec2(1, 1)).rgb;
vec3 g = texture(screenTexture, coord + px * vec2(-1, -1)).rgb;
vec3 i = texture(screenTexture, coord + px * vec2(1, -1)).rgb;
if (filterId == 1) {
vec3 blur = ((a + c + g + i) + (b + d + f + h) * 2.0 + e * 4.0) / 16.0;
return e + (e - blur) * sharpenFactor / 3.0;
}
vec3 minRgb = min(min(min(d, e), min(f, b)), h) + min(min(a, c), min(g, i));
vec3 maxRgb = max(max(max(d, e), max(f, b)), h) + max(max(a, c), max(g, i));
vec3 amplify = inversesqrt(clamp(min(minRgb, 2.0 - maxRgb) / maxRgb, 0.0, 1.0));
vec3 weight = -(1.0 / (amplify * CAS_CONTRAST_PEAK));
vec3 outColor = clamp(((b + d + f + h) * weight + e) / (4.0 * weight + 1.0), 0.0, 1.0);
return mix(e, outColor, sharpenFactor / 2.0);
}
float ign(vec2 p) { return fract(52.9829189 * fract(dot(p, vec2(0.06711056, 0.00583715)))); }
void main() {
vec2 uv = gl_FragCoord.xy / resolution;
vec3 color = texture(screenTexture, uv).rgb;
if (sharpenFactor > 0.0) color = sharpen(uv, color);
color = clamp(color + brightness, 0.0, 1.0);
color = (color - 0.5) * clamp(contrast + 1.0, 0.5, 2.0) + 0.5;
color = mix(vec3(dot(color, vec3(0.2126, 0.7152, 0.0722))), color, saturation);
color = pow(max(color, 0.0), vec3(1.0 / gamma));
if (deband > 0.0) color += vec3(((ign(gl_FragCoord.xy) + ign(gl_FragCoord.xy + vec2(11.0, 17.0))) - 1.0) * (deband / 255.0));
fragColor = vec4(color, 1.0);
}`,ee=`precision highp float;
#define FXAA_MIN_REDUCE (1.0 / 128.0)
#define FXAA_MUL_REDUCE (1.0 / 8.0)
#define MAX_SPAN 8.0
uniform sampler2D screenTexture;
uniform vec2 resolution;
const vec3 luma = vec3(0.299, 0.587, 0.114);
void main() {
vec2 invResolution = 1.0 / resolution;
vec3 rgbNW = texture2D(screenTexture, (gl_FragCoord.xy + vec2(-1.0, -1.0)) * invResolution).rgb;
vec3 rgbNE = texture2D(screenTexture, (gl_FragCoord.xy + vec2( 1.0, -1.0)) * invResolution).rgb;
vec3 rgbSW = texture2D(screenTexture, (gl_FragCoord.xy + vec2(-1.0, 1.0)) * invResolution).rgb;
vec3 rgbSE = texture2D(screenTexture, (gl_FragCoord.xy + vec2( 1.0, 1.0)) * invResolution).rgb;
vec3 rgbM = texture2D(screenTexture, gl_FragCoord.xy * invResolution).rgb;
float lumaNW = dot(rgbNW, luma);
float lumaNE = dot(rgbNE, luma);
float lumaSW = dot(rgbSW, luma);
float lumaSE = dot(rgbSE, luma);
float lumaM = dot(rgbM, luma);
float lumaMin = min(lumaM, min(min(lumaNW, lumaNE), min(lumaSW, lumaSE)));
float lumaMax = max(lumaM, max(max(lumaNW, lumaNE), max(lumaSW, lumaSE)));
vec2 dir;
dir.x = -((lumaNW + lumaNE) - (lumaSW + lumaSE));
dir.y = ((lumaNW + lumaSW) - (lumaNE + lumaSE));
float dirReduce = max((lumaNW + lumaNE + lumaSW + lumaSE) * 0.25 * FXAA_MUL_REDUCE, FXAA_MIN_REDUCE);
float minDirFactor = 1.0 / (min(abs(dir.x), abs(dir.y)) + dirReduce);
dir = clamp(dir * minDirFactor, vec2(-MAX_SPAN), vec2(MAX_SPAN)) * invResolution;
vec4 rgbA = 0.5 * (
texture2D(screenTexture, gl_FragCoord.xy * invResolution + dir * (1.0 / 3.0 - 0.5)) +
texture2D(screenTexture, gl_FragCoord.xy * invResolution + dir * (2.0 / 3.0 - 0.5)));
vec4 rgbB = rgbA * 0.5 + 0.25 * (
texture2D(screenTexture, gl_FragCoord.xy * invResolution + dir * -0.5) +
texture2D(screenTexture, gl_FragCoord.xy * invResolution + dir * 0.5));
float lumaB = dot(rgbB, vec4(luma, 0.0));
gl_FragColor = lumaB < lumaMin || lumaB > lumaMax ? rgbA : rgbB;
}`,te=`precision highp float;
uniform sampler2D screenTexture;
uniform vec2 resolution;
void main() {
vec2 uv = gl_FragCoord.xy / resolution;
float edgeThreshold = 0.2;
vec2 offset = vec2(1.0) / resolution;
vec3 colorCenter = texture2D(screenTexture, uv).rgb;
vec3 colorLeft = texture2D(screenTexture, uv - vec2(offset.x, 0.0)).rgb;
vec3 colorRight = texture2D(screenTexture, uv + vec2(offset.x, 0.0)).rgb;
vec3 colorUp = texture2D(screenTexture, uv - vec2(0.0, offset.y)).rgb;
vec3 colorDown = texture2D(screenTexture, uv + vec2(0.0, offset.y)).rgb;
float diffHorizontal = length(colorRight - colorLeft);
float diffVertical = length(colorUp - colorDown);
float edgeFactor = step(edgeThreshold, diffHorizontal + diffVertical);
vec3 outlineColor = mix(colorCenter, vec3(0.0), edgeFactor);
gl_FragColor = vec4(outlineColor, 1.0);
}`,ne=`precision highp float;
#define CA_AMOUNT 1.0025
#define SCANLINE_INTENSITY_X 0.125
#define SCANLINE_INTENSITY_Y 0.375
#define SCANLINE_SIZE 1024.0
uniform sampler2D screenTexture;
varying vec2 vUV;
void main() {
vec4 finalColor = texture2D(screenTexture, vUV);
finalColor.rgb = vec3(
texture2D(screenTexture, (vUV - 0.5) * CA_AMOUNT + 0.5).r,
finalColor.g,
texture2D(screenTexture, (vUV - 0.5) / CA_AMOUNT + 0.5).b
);
float scanlineX = abs(sin(vUV.x * SCANLINE_SIZE) * 0.5 * SCANLINE_INTENSITY_X);
float scanlineY = abs(sin(vUV.y * SCANLINE_SIZE) * 0.5 * SCANLINE_INTENSITY_Y);
gl_FragColor = vec4(mix(finalColor.rgb, vec3(0.0), scanlineX + scanlineY), finalColor.a);
}`,ie=`precision highp float;
#define PI 3.14159265
#define SCANLINE_INTENSITY 0.35
#define CHROMA_OFFSET 0.005
#define BLUR_RADIUS 0.002
#define WARP_AMOUNT 0.01
#define SCANLINE_DARKEN 0.5
uniform sampler2D screenTexture;
uniform int FrameCount;
uniform vec2 TextureSize;
uniform vec2 resolution;
varying vec2 vUV;
const mat3 yiq_mat = mat3(
0.299, 0.587, 0.114,
0.596, -0.275, -0.321,
0.212, -0.523, 0.311
);
const mat3 yiq2rgb_mat = mat3(
1.0, 0.956, 0.621,
1.0, -0.272, -0.647,
1.0, -1.106, 1.705
);
vec3 applyNTSC(vec2 uv) {
vec3 col = texture2D(screenTexture, uv).rgb;
vec3 yiq = col * yiq_mat;
float chromaPhase = PI * (mod(uv.y * TextureSize.y, 2.0) + float(FrameCount));
yiq.y *= cos(chromaPhase * 0.5);
yiq.z *= sin(chromaPhase * 0.5);
vec3 rgb = yiq * yiq2rgb_mat;
vec3 finalColor;
finalColor.r = texture2D(screenTexture, uv + vec2(CHROMA_OFFSET, 0.0)).r;
finalColor.g = texture2D(screenTexture, uv + vec2(0.0, BLUR_RADIUS)).g;
finalColor.b = texture2D(screenTexture, uv - vec2(CHROMA_OFFSET, 0.0)).b;
return finalColor;
}
vec3 applyScanlines(vec2 uv) {
vec3 col = texture2D(screenTexture, uv).rgb;
float scanline = abs(sin(uv.y * resolution.y * 2.0)) * SCANLINE_INTENSITY;
col *= 1.0 - (scanline * SCANLINE_DARKEN);
return col;
}
vec2 applyWarp(vec2 uv) {
uv = uv * 2.0 - 1.0;
float r = sqrt(uv.x * uv.x + uv.y * uv.y);
uv += uv * (r * r) * WARP_AMOUNT;
return uv * 0.5 + 0.5;
}
void main() {
vec2 warpedUV = applyWarp(vUV);
vec3 ntscColor = applyNTSC(warpedUV);
vec3 scanlineColor = applyScanlines(warpedUV);
vec3 finalColor = mix(ntscColor, scanlineColor, 0.7);
gl_FragColor = vec4(finalColor, 1.0);
}`,H=class{constructor(e,t){this.video=e;this.effects=t;this.canvas=document.createElement("canvas"),Object.assign(this.canvas.style,{position:"absolute",inset:"0",width:"100%",height:"100%",objectFit:"contain",zIndex:"1"}),e.parentElement?.appendChild(this.canvas);let n=this.canvas.getContext("webgl2",{alpha:!1,antialias:!1,depth:!1,desynchronized:!0,powerPreference:"high-performance"});if(n===null)throw new Error("WebGL2 unavailable");this.gl=n;let i=n.createBuffer();n.bindBuffer(n.ARRAY_BUFFER,i),n.bufferData(n.ARRAY_BUFFER,new Float32Array([-1,-1,3,-1,-1,3]),n.STATIC_DRAW),this.passes.set("base",this.compile(J,Z,["screenTexture","resolution","filterId","sharpenFactor","brightness","contrast","gamma","saturation","deband"])),this.videoTexture=this.texture(),this.schedule()}canvas;gl;passes=new Map;videoTexture;targets=[];width=0;height=0;frame=0;lastDraw=0;stopped=!1;idle=!1;pending=!1;update(e){this.effects=e}pause(){this.idle||this.stopped||(this.idle=!0,this.canvas.style.display="none")}resume(e){this.effects=e,!(!this.idle||this.stopped)&&(this.idle=!1,this.canvas.style.display="",this.schedule())}schedule(){this.pending||(this.pending=!0,this.video.requestVideoFrameCallback(this.onFrame))}compile(e,t,n){let i=this.gl,a=(o,d)=>{let u=i.createShader(o);if(i.shaderSource(u,d),i.compileShader(u),!i.getShaderParameter(u,i.COMPILE_STATUS))throw new Error(`shader: ${i.getShaderInfoLog(u)}`);return u},s=i.createProgram();if(i.attachShader(s,a(i.VERTEX_SHADER,e)),i.attachShader(s,a(i.FRAGMENT_SHADER,t)),i.bindAttribLocation(s,0,"position"),i.linkProgram(s),!i.getProgramParameter(s,i.LINK_STATUS))throw new Error(`program: ${i.getProgramInfoLog(s)}`);return{program:s,uniforms:new Map(n.map(o=>[o,i.getUniformLocation(s,o)]))}}extra(e){let t=this.passes.get(e);if(t===void 0){let n={fxaa:ee,toon:te,crt:ne,ntsc:ie}[e];t=this.compile(j,n,["screenTexture","resolution","TextureSize","FrameCount"]),this.passes.set(e,t)}return t}texture(){let e=this.gl,t=e.createTexture();return e.bindTexture(e.TEXTURE_2D,t),e.texParameteri(e.TEXTURE_2D,e.TEXTURE_MIN_FILTER,e.LINEAR),e.texParameteri(e.TEXTURE_2D,e.TEXTURE_MAG_FILTER,e.LINEAR),e.texParameteri(e.TEXTURE_2D,e.TEXTURE_WRAP_S,e.CLAMP_TO_EDGE),e.texParameteri(e.TEXTURE_2D,e.TEXTURE_WRAP_T,e.CLAMP_TO_EDGE),t}resize(e,t){let n=this.gl;this.width=e,this.height=t,this.canvas.width=e,this.canvas.height=t;for(let i of this.targets)n.deleteFramebuffer(i.framebuffer),n.deleteTexture(i.texture);this.targets=[0,1].map(()=>{let i=this.texture();n.texImage2D(n.TEXTURE_2D,0,n.RGBA,e,t,0,n.RGBA,n.UNSIGNED_BYTE,null);let a=n.createFramebuffer();return n.bindFramebuffer(n.FRAMEBUFFER,a),n.framebufferTexture2D(n.FRAMEBUFFER,n.COLOR_ATTACHMENT0,n.TEXTURE_2D,i,0),{framebuffer:a,texture:i}}),n.bindFramebuffer(n.FRAMEBUFFER,null)}onFrame=e=>{if(this.pending=!1,this.stopped||this.idle)return;this.schedule();let t=this.effects.maxFps;if(!(t>0&&e-this.lastDraw<1e3/t-2)){this.lastDraw=e;try{this.draw()}catch(n){window.DDCloud?.log(`effects: ${String(n).slice(0,200)}`),this.destroy()}}};draw(){let e=this.gl,t=this.effects,n=this.video.videoWidth,i=this.video.videoHeight;if(n===0||i===0)return;(n!==this.width||i!==this.height)&&this.resize(n,i),this.frame++,e.activeTexture(e.TEXTURE0),e.bindTexture(e.TEXTURE_2D,this.videoTexture),e.pixelStorei(e.UNPACK_FLIP_Y_WEBGL,!0),e.texImage2D(e.TEXTURE_2D,0,e.RGBA,e.RGBA,e.UNSIGNED_BYTE,this.video);let a=["fxaa","toon","crt","ntsc"].filter(d=>t[d]);e.viewport(0,0,n,i),e.enableVertexAttribArray(0),e.vertexAttribPointer(0,2,e.FLOAT,!1,0,0);let s=this.passes.get("base");e.bindFramebuffer(e.FRAMEBUFFER,a.length>0?this.targets[0].framebuffer:null),e.useProgram(s.program);let o=s.uniforms;e.uniform1i(o.get("screenTexture"),0),e.uniform2f(o.get("resolution"),n,i),e.uniform1i(o.get("filterId"),t.sharpenMode==="usm"?1:2),e.uniform1f(o.get("sharpenFactor"),t.sharpen),e.uniform1f(o.get("brightness"),t.brightness/100),e.uniform1f(o.get("contrast"),t.contrast/100),e.uniform1f(o.get("gamma"),Math.min(5,Math.max(.1,t.gamma))),e.uniform1f(o.get("saturation"),t.saturation/100),e.uniform1f(o.get("deband"),t.deband),e.drawArrays(e.TRIANGLES,0,3),a.forEach((d,u)=>{let h=this.extra(d),g=this.targets[u%2],p=u===a.length-1;e.bindFramebuffer(e.FRAMEBUFFER,p?null:this.targets[(u+1)%2].framebuffer),e.useProgram(h.program),e.bindTexture(e.TEXTURE_2D,g.texture);let c=h.uniforms;e.uniform1i(c.get("screenTexture")??null,0),e.uniform2f(c.get("resolution")??null,n,i),e.uniform2f(c.get("TextureSize")??null,n,i),e.uniform1i(c.get("FrameCount")??null,this.frame),e.drawArrays(e.TRIANGLES,0,3)})}destroy(){this.stopped=!0,this.canvas.remove(),this.gl.getExtension("WEBGL_lose_context")?.loseContext()}};var V=class{constructor(e){this.pc=e;this.element=document.createElement("div"),Object.assign(this.element.style,{position:"absolute",top:"8px",right:"8px",zIndex:"3",padding:"4px 8px",borderRadius:"6px",background:"rgba(0,0,0,0.55)",color:"#e8e8e8",font:"12px monospace",lineHeight:"16px",pointerEvent Line truncated
`:" \xB7 ")}destroy(){this.show(0,this.vertical,this.server,this.left),this.element.remove()}};function z(r,e){let t=e==="high"?"4d":e==="low"?"420":"42e",n=[];for(let d of r.matchAll(/a=fmtp:(\d+).*profile-level-id=([0-9a-f]{6})/g))d[2].startsWith(t)&&n.push(d[1]);if(n.length===0)return r;let i=r.split(`\r
`),a=i.findIndex(d=>d.startsWith("m=video"));if(a<0)return r;let s=i[a].trim().split(" "),o=s.slice(3).filter(d=>!n.includes(d));return i[a]=s.slice(0,3).concat(n,o).join(" "),i.join(`\r
`)}function K(r,e){let t=r.split(`\r
`),n=t.findIndex(a=>a.startsWith("m=video"));if(n<0)return r;let i=`b=AS:${e}`;for(let a=n+1;a<=t.length;a++){let s=t[a];if(s===void 0||s.startsWith("m=")){t.splice(a,0,i);break}if(!(s.startsWith("i=")||s.startsWith("c="))){if(s.startsWith("b=AS:")){t[a]=i;break}t.splice(a,0,i);break}}return t.join(`\r
`)}var m=JSON.parse(DDCloud.settings()),ae=RTCPeerConnection.prototype.setLocalDescription;RTCPeerConnection.prototype.setLocalDescription=function(r){return r?.sdp&&((m.codec==="high"||m.codec==="normal"||m.codec==="low")&&(r.sdp=z(r.sdp,m.codec)),m.bitrateMbps>0&&(r.sdp=K(r.sdp,m.bitrateMbps*1e3))),ae.call(this,r)};window.__ddRumble=m.rumble/100;window.addEventListener("error",r=>DDCloud.log(`error: ${r.message} @${r.lineno}`));window.addEventListener("unhandledrejection",r=>DDCloud.log(`rejection: ${String(r.reason).slice(0,200)}`));window.addEventListener("gamepadconnected",r=>DDCloud.log(`controller: ${r.gamepad.id.slice(0,60)} mapping=${r.gamepad.mapping||"none"} buttons=${r.gamepad.buttons.length} axes=${r.gamepad.axes.length}`));var W=new Map,re=1;window.__ddResolve=(r,e,t)=>{let n=W.get(r);W.delete(r),n&&n({status:e,body:t})};function C(r,e=""){return new Promise(t=>{let n=re++;W.set(n,t),DDCloud.call(n,r,typeof e=="string"?e:JSON.stringify(e))})}var B=r=>new Promise(e=>setTimeout(e,r)),se=r=>r.status>=200&&r.status<300;function x(r){let e=document.getElementById("status");e&&(e.textContent=r),DDCloud.status(r)}var T=class extends Error{constructor(t,n){super(t);this.stage=t;this.detail=n}};function k(r,e){if(!se(e))throw new T(r,`${e.status} ${e.body.slice(0,300)}`);return e}function oe(r,e){return new Promise(t=>{if(r.iceGatheringState==="complete")return t();let n=setTimeout(t,e);r.addEventListener("icegatheringstatechange",()=>{r.iceGatheringState==="complete"&&(clearTimeout(n),t())})})}async function de(){x("Starting the game on Xbox Cloud Gaming\u2026"),k("start",await C("start"));let r=!1,e=0;for(;;){let l=k("state",await C("state")),f=JSON.parse(l.body||"{}");if(f.state==="Provisioned")break;if(f.state==="Failed")throw new T("provision",JSON.stringify(f.errorDetails??{}).slice(0,300));f.state==="ReadyToConnect"&&!r?(r=!0,x("Signing in to the cloud console\u2026"),k("connect",await C("connect"))):f.state==="WaitingForResources"?x(`In the queue for a cloud console\u2026 ${Math.round(e/1e3)} s`):x(`Getting a cloud console ready\u2026 ${Math.round(e/1e3)} s`),await B(1e3),e+=1e3}x("Connecting\u2026");let t=new U("player");t.init();let n=t._peerConnection,i=t._channels.input,a=i.queueMetadataFrame.bind(i);i.queueMetadataFrame=l=>{if(m.preventDrops){let f=l.frameRenderedTimeMs-l.frameDecodedTimeMs;l.frameDecodedTimeMs=l.frameSubmittedTimeMs+10,l.frameRenderedTimeMs=l.frameDecodedTimeMs+f}a(l)};let s,o,d=()=>{if(!(o===void 0||s===void 0&&m.volume===100)){if(s===void 0){let l=new AudioContext;s=l.createGain(),l.createMediaStreamSource(o).connect(s).connect(l.destination);let f=document.querySelector("#player audio");f&&(f.muted=!0)}s.gain.value=m.volume/100}};n.addEventListener("track",l=>{l.track.kind==="audio"&&(o=l.streams[0],d())});let u=new V(n),h,g=()=>{let l=t.getVideoElement();if(l!==void 0){if(!X(m)){h?.pause();return}if(h===void 0)try{h=new H(l,m)}catch(f){DDCloud.log(`effects unavailable: ${String(f).slice(0,200)}`)}else h.resume(m)}};window.__ddApply=l=>{m=JSON.parse(l),window.__ddRumble=m.rumble/100,u.show(m.statsLevel,m.statsLayout==="vertical",m.server,m.statsCorner==="left"),d(),g()},n.addEventListener("connectionstatechange",()=>{n.connectionState==="connected"?(x(""),DDCloud.playing(),u.show(m.statsLevel,m.statsLayout==="vertical",m.server,m.statsCorner==="left"),setTimeout(g,500)):n.connectionState==="failed"?DDCloud.failed("webrtc","connection failed"):n.connectionState==="disconnected"&&x("Connection lost\u2026")});let p=await t.createOffer(),c=k("sdp",await C("sdp",p.sdp??"")),O=JSON.parse(JSON.parse(c.body).exchangeResponse);if(O.status&&O.status!=="success")throw new T("sdp-answer",JSON.stringify(O).slice(0,300));await t.setRemoteSDP(O.sdp),await oe(n,3e3);let Y=(await t.getICECandidates()).map(l=>JSON.stringify({candidate:l.candidate,sdpMid:l.sdpMid,sdpMLineIndex:l.sdpMLineIndex,usernameFragment:l.usernameFragment})),$=k("ice",await C("ice",Y));t.setRemoteIceCandidates(JSON.parse(JSON.parse($.body).exchangeResponse));let q=Date.now();for(;!t._channels.message._handshakeCompleted&&Date.now()-q<2e4;)await B(100);DDCloud.log(`handshake ${t._channels.message._handshakeCompleted?"done":"missing"} after ${Date.now()-q} ms`),await B(300);let b=new v(0,{enable_keyboard:!1});b.attach(t),DDCloud.log(`controllers at attach: ${Array.from(navigator.getGamepads()).filter(Boolean).length}`),window.__ddNexus=()=>{b.sendButtonState("Nexus",1),setTimeout(()=>b.sendButtonState("Nexus",0),200)},window.__ddButton=(l,f)=>b.sendButtonState(l,f),window.__ddInput=l=>{l?b.onWindowFocus():b.onWindowBlur()},setInterval(()=>{C("keepalive")},3e4),window.__ddStop=()=>{try{h?.destroy(),u.destroy(),b.detach(),t.destroy()}catch{}}}de().catch(r=>{r instanceof T?DDCloud.failed(r.stage,r.detail):DDCloud.failed("page",String(r).slice(0,300))});})();
@@ -8,6 +8,7 @@ add_library(droiddeckwayland SHARED
src/sc_layer.c
src/ahb_swapchain.c
src/waylandcomp_jni.c
src/gpu_clock_pin.c
src/droiddeck_ext.c
src/wl_dmabuf.c
src/wl_clipboard.c
@@ -94,4 +95,5 @@ target_link_libraries(droiddeckwayland
dl
log
android
nativewindow
)
+127 -4
View File
@@ -16,6 +16,7 @@
#include <sys/syscall.h>
#include <sys/sysmacros.h>
#include <android/log.h>
#include <android/hardware_buffer.h>
#include <wayland-server.h>
#include "xdg-shell-server-protocol.h"
@@ -263,6 +264,8 @@ static struct wl_event_source *g_fallback_timer;
static int g_fallback_armed;
volatile int g_fps_limit;
/* fps limit -1 (Performance mode without a limit): replaced buffers go back to the client at once instead of once per refresh. */
volatile int g_release_unpaced;
volatile int g_hide_shell;
@@ -370,7 +373,7 @@ static int on_release_timer(int fd, uint32_t mask, void *data) {
static void release_buffer(struct surface *s, struct wl_resource *buffer, int64_t since_ns) {
const int limit = g_fps_limit;
const int64_t interval = limit > 0 ? 1000000000LL / limit : g_refresh_ns;
if (interval <= 0 || g_release_timer_fd < 0) { wl_buffer_send_release(buffer); perf_note_release(since_ns); return; }
if (g_release_unpaced || interval <= 0 || g_release_timer_fd < 0) { wl_buffer_send_release(buffer); perf_note_release(since_ns); return; }
int64_t now = now_ns();
int64_t at = s->next_release_ns + interval;
int64_t latest = now + interval * (int64_t)(s->releases_pending + 1);
@@ -808,10 +811,14 @@ static uint32_t nominal_refresh_ns(void) {
}
static int64_t presented_ns(int64_t render_ns) {
if (!g_tick_vsync_ns || render_ns - g_last_vsync_ns > (int64_t)nominal_refresh_ns() ||
render_ns < g_tick_vsync_ns)
int64_t tick = g_tick_vsync_ns;
int64_t refresh = (int64_t)nominal_refresh_ns();
if (!tick || render_ns - g_last_vsync_ns > refresh)
return render_ns;
return g_tick_vsync_ns;
int64_t delta = render_ns - tick;
if (delta > refresh || -delta > refresh)
return render_ns;
return tick;
}
static void feedback_present_all(struct wl_list *list, int64_t t) {
@@ -1841,7 +1848,121 @@ static void hdr_count_copied(const struct draw_list *dl, const char *reason) {
}
}
/*
* Stream mode (the Xbox Cloud Gaming native player, in its own process): no Wayland clients, the scene is one
* picture handed over as AHardwareBuffers (ImageReader images of the decoded video). Each buffer is imported once
* and kept while the reader cycles through its few buffers; the newest frame is the scene, so the screen effects
* and frame generation apply to it as to a game.
*/
#define STREAM_SLOTS 6
static pthread_mutex_t g_stream_lock = PTHREAD_MUTEX_INITIALIZER;
static atomic_int g_stream_on;
static AHardwareBuffer *g_stream_pending;
static struct { AHardwareBuffer *ahb; struct vkp_image *img; uint64_t used; } g_stream_slots[STREAM_SLOTS];
static struct vkp_image *g_stream_img;
static uint64_t g_stream_seq;
static atomic_uint g_stream_frames;
static void stream_forget_all(void) {
vkp_wait_idle();
for (int i = 0; i < STREAM_SLOTS; i++) {
if (g_stream_slots[i].img) vkp_image_destroy(g_stream_slots[i].img);
if (g_stream_slots[i].ahb) AHardwareBuffer_release(g_stream_slots[i].ahb);
g_stream_slots[i].img = NULL; g_stream_slots[i].ahb = NULL; g_stream_slots[i].used = 0;
}
g_stream_img = NULL;
}
/* Compositor thread: the newest handed-over buffer becomes the scene picture. */
static void stream_take(void) {
pthread_mutex_lock(&g_stream_lock);
AHardwareBuffer *ahb = g_stream_pending;
g_stream_pending = NULL;
pthread_mutex_unlock(&g_stream_lock);
if (!ahb) return;
if (!atomic_load(&g_stream_on)) { AHardwareBuffer_release(ahb); return; }
int slot = -1, oldest = 0;
for (int i = 0; i < STREAM_SLOTS; i++) {
if (g_stream_slots[i].ahb == ahb) { slot = i; break; }
if (g_stream_slots[i].used < g_stream_slots[oldest].used) oldest = i;
}
if (slot >= 0) {
AHardwareBuffer_release(ahb); /* the slot keeps its own reference */
} else {
struct vkp_image *img = vkp_image_from_ahb(ahb);
if (!img) {
static int said;
if (!said) { said = 1; droiddeck_log("stream", "a decoded frame could not be imported: nothing to show"); }
AHardwareBuffer_release(ahb);
return;
}
droiddeck_log("stream", "decoded frame buffer imported (%dx%d)", vkp_image_width(img), vkp_image_height(img));
slot = oldest;
if (g_stream_slots[slot].img) {
vkp_wait_idle(); /* the GPU may still read the picture being replaced */
if (g_stream_img == g_stream_slots[slot].img) g_stream_img = NULL;
vkp_image_destroy(g_stream_slots[slot].img);
}
if (g_stream_slots[slot].ahb) AHardwareBuffer_release(g_stream_slots[slot].ahb);
g_stream_slots[slot].ahb = ahb;
g_stream_slots[slot].img = img;
}
g_stream_slots[slot].used = ++g_stream_seq;
g_stream_img = g_stream_slots[slot].img;
atomic_fetch_add(&g_stream_frames, 1);
schedule_render();
}
static void render_stream(void) {
g_dirty = 0;
struct vkp_image *img = g_stream_img;
if (!img) return;
int w = vkp_image_width(img), h = vkp_image_height(img);
if (w != g_scene_w || h != g_scene_h) {
droiddeck_log("stream", "picture %dx%d", w, h);
g_scene_w = w;
g_scene_h = h;
}
struct vkp_draw d = {.img = img, .sx = 0, .sy = 0, .sw = (float)w, .sh = (float)h, .dx = 0, .dy = 0, .dw = w, .dh = h, .blend = 0};
vkp_apply_window_request();
sc_layer_hide();
int r = vkp_render(w, h, &d, 1);
if (r == 0) {
g_stat_frames++;
static int first;
if (!first) { first = 1; droiddeck_log("stream", "first stream frame presented"); }
} else {
static int failed;
if (failed++ % 600 == 0) droiddeck_log("stream", "presenting a stream frame failed (%d, %d times)", r, failed);
}
}
void droiddeck_stream_set(int on) {
atomic_store(&g_stream_on, on ? 1 : 0);
if (g_input_pipe[1] < 0) return;
struct input_msg m = { 21, on ? 1 : 0, 0, 0, 0 };
ssize_t n = write(g_input_pipe[1], &m, sizeof(m));
(void)n;
}
/* Any thread: hands over one decoded frame (acquires its own reference). */
void droiddeck_stream_frame(AHardwareBuffer *ahb) {
if (!ahb || !atomic_load(&g_stream_on) || g_input_pipe[1] < 0) return;
AHardwareBuffer_acquire(ahb);
pthread_mutex_lock(&g_stream_lock);
AHardwareBuffer *dropped = g_stream_pending;
g_stream_pending = ahb;
pthread_mutex_unlock(&g_stream_lock);
if (dropped) { AHardwareBuffer_release(dropped); return; } /* a wake-up is already queued */
struct input_msg m = { 20, 0, 0, 0, 0 };
ssize_t n = write(g_input_pipe[1], &m, sizeof(m));
(void)n;
}
unsigned droiddeck_stream_frames(void) { return atomic_load(&g_stream_frames); }
static void render_scene(void) {
if (atomic_load(&g_stream_on)) { render_stream(); return; }
struct draw_list dl = {0};
struct surface *s;
int w, h;
@@ -3001,6 +3122,8 @@ static int on_input_readable(int fd, uint32_t mask, void *data) {
break;
case 10: break;
case 11: axis_event(m.p1, m.p2); break;
case 20: stream_take(); break;
case 21: if (!m.p1) stream_forget_all(); schedule_render(); break;
default: deliver_pointer(&m); break;
}
}
@@ -238,7 +238,7 @@ static int ensure_ring(uint32_t w, uint32_t h, VkFormat fmt, uint32_t n) {
}
g_ring_w = w; g_ring_h = h; g_ring_fmt = fmt;
LOGD("framegen: generation ring %ux%u x%u", w, h, n);
return 1;
return 2;
}
@@ -350,6 +350,7 @@ int vkp_framegen_run(VkCommandBuffer cmd, VkImage scene, VkImageView scene_view,
fge_configure((uint32_t)mult, (uint32_t)atomic_load(&g_target), flow, hz,
atomic_load(&g_model), atomic_load(&g_preset));
}
const int size_changed = g_built_w != (uint32_t)w || g_built_h != (uint32_t)h || g_built_fmt != fmt;
if (!fge_prepare((uint32_t)w, (uint32_t)h, fmt)) {
if (fmt != VK_FORMAT_R8G8B8A8_UNORM && fge_unavailable()) {
refuse_format(fmt, kind, w, h, "build its chain");
@@ -361,17 +362,25 @@ int vkp_framegen_run(VkCommandBuffer cmd, VkImage scene, VkImageView scene_view,
}
return 0;
}
if (g_built_w != (uint32_t)w || g_built_h != (uint32_t)h || g_built_fmt != fmt) {
if (size_changed) {
g_built_w = (uint32_t)w; g_built_h = (uint32_t)h; g_built_fmt = fmt;
g_generating_logged = 0;
}
uint32_t want = (uint32_t)(mult - 1);
if (!ensure_ring((uint32_t)w, (uint32_t)h, fmt, want)) {
const int ring = ensure_ring((uint32_t)w, (uint32_t)h, fmt, want);
if (!ring) {
if (fmt != VK_FORMAT_R8G8B8A8_UNORM) { refuse_format(fmt, kind, w, h, "make its generation ring"); return 0; }
FGLOG("no memory for the generation ring (%dx%d x%u); frame generation stays off", w, h, want);
mark_failed(kind);
return 0;
}
/* The ring and the engine's images are new on this frame. Dispatching into them now
* null-dereferenced Turnip (vkp_framegen_run, first Steam frame, 2560x1440 to the panel).
* Show this frame as it is; generation starts once the size has settled. */
if (size_changed || ring == 2) {
FGLOG("frame generation catches up at %dx%d; this frame is shown as it is", w, h);
return 0;
}
/* The pacer's source rate must be the game's, not this loop's: a loop slowed by its own
* generations would otherwise read as a slower game and justify even more generations. */
@@ -0,0 +1,170 @@
#include "gpu_clock_pin.h"
#include <android/log.h>
#include <errno.h>
#include <fcntl.h>
#include <pthread.h>
#include <stdatomic.h>
#include <stdint.h>
#include <string.h>
#include <sys/ioctl.h>
#include <sys/mman.h>
#include <unistd.h>
#include "../../adrenotools/src/hook/kgsl.h"
static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
static pthread_t worker;
static bool joinable;
static atomic_bool running;
struct pin {
int fd;
unsigned int context;
unsigned int memory;
unsigned int timestamp;
unsigned long gpuaddr;
uint32_t nop;
void *commands;
size_t size;
};
static struct pin pin = { .fd = -1 };
static void release_pin(void) {
if (pin.context) {
struct kgsl_drawctxt_destroy destroy = { .drawctxt_id = pin.context };
ioctl(pin.fd, IOCTL_KGSL_DRAWCTXT_DESTROY, &destroy);
}
if (pin.commands) munmap(pin.commands, pin.size);
if (pin.memory) {
struct kgsl_gpumem_free_id free_memory = { .id = pin.memory };
ioctl(pin.fd, IOCTL_KGSL_GPUMEM_FREE_ID, &free_memory);
}
if (pin.fd >= 0) close(pin.fd);
pin = (struct pin) { .fd = -1 };
}
static bool submit_pin(void) {
struct kgsl_command_object object = {
.gpuaddr = pin.gpuaddr,
.size = 2 * sizeof(uint32_t),
.flags = KGSL_CMDLIST_IB,
.id = pin.memory,
};
struct kgsl_gpu_command command = {
.flags = KGSL_CMDBATCH_PWR_CONSTRAINT,
.cmdlist = (uintptr_t)&object,
.cmdsize = sizeof(object),
.numcmds = 1,
.context_id = pin.context,
};
if (ioctl(pin.fd, IOCTL_KGSL_GPU_COMMAND, &command) < 0) return false;
pin.timestamp = command.timestamp;
return true;
}
static bool prepare_pin(void) {
pin.fd = open("/dev/kgsl-3d0", O_RDWR | O_CLOEXEC);
if (pin.fd < 0) return false;
struct kgsl_devinfo info = {0};
struct kgsl_device_getproperty device_info = {
.type = KGSL_PROP_DEVICE_INFO, .value = &info, .sizebytes = sizeof(info),
};
if (ioctl(pin.fd, IOCTL_KGSL_DEVICE_GETPROPERTY, &device_info) < 0) return false;
unsigned int core = info.chip_id >> 24;
if (!core) core = info.gpu_id / 100;
if (core < 3) { errno = ENODEV; return false; }
pin.nop = core >= 5 ? 0x70100001 : 0xc0001000;
struct kgsl_drawctxt_create context = {
.flags = KGSL_CONTEXT_NO_GMEM_ALLOC | KGSL_CONTEXT_PREAMBLE | KGSL_CONTEXT_PWR_CONSTRAINT,
};
if (ioctl(pin.fd, IOCTL_KGSL_DRAWCTXT_CREATE, &context) < 0) return false;
pin.context = context.drawctxt_id;
struct kgsl_device_constraint_pwrlevel level = { .level = KGSL_CONSTRAINT_PWR_MAX };
struct kgsl_device_constraint constraint = {
.type = KGSL_CONSTRAINT_PWRLEVEL,
.context_id = pin.context,
.data = &level,
.size = sizeof(level),
};
struct kgsl_device_getproperty property = {
.type = KGSL_PROP_PWR_CONSTRAINT,
.value = &constraint,
.sizebytes = sizeof(constraint),
};
if (ioctl(pin.fd, IOCTL_KGSL_SETPROPERTY, &property) < 0) return false;
struct kgsl_gpumem_alloc_id memory = { .size = 4096 };
if (ioctl(pin.fd, IOCTL_KGSL_GPUMEM_ALLOC_ID, &memory) < 0) return false;
pin.memory = memory.id;
pin.gpuaddr = memory.gpuaddr;
pin.size = memory.mmapsize;
void *commands = mmap(NULL, pin.size, PROT_READ | PROT_WRITE, MAP_SHARED, pin.fd,
(off_t)pin.memory << 12);
if (commands == MAP_FAILED) return false;
pin.commands = commands;
// A3xx/A4xx use type-3 CP_NOP; newer cores use type-7 with zero odd-parity bits.
uint32_t *words = commands;
words[0] = pin.nop;
words[1] = 0;
struct kgsl_gpumem_sync_cache sync = { .id = pin.memory, .op = KGSL_GPUMEM_CACHE_CLEAN };
if (ioctl(pin.fd, IOCTL_KGSL_GPUMEM_SYNC_CACHE, &sync) < 0) return false;
return submit_pin();
}
static void *hold_pin(void *unused) {
(void)unused;
while (atomic_load(&running)) {
struct kgsl_device_waittimestamp_ctxtid wait = {
.context_id = pin.context,
.timestamp = pin.timestamp,
.timeout = 100,
};
if (ioctl(pin.fd, IOCTL_KGSL_DEVICE_WAITTIMESTAMP_CTXTID, &wait) < 0) {
if (errno == EINTR || errno == ETIMEDOUT) continue;
break;
}
// Constraints expire after idle_timer (normally 80 ms); renew with a NOP, keeping devfreq alive.
usleep(25000);
if (atomic_load(&running) && !submit_pin()) break;
}
if (atomic_exchange(&running, false))
__android_log_print(ANDROID_LOG_WARN, "GpuClockPin", "constraint stopped: %s", strerror(errno));
release_pin();
return NULL;
}
bool gpu_clock_pin_set(bool on) {
pthread_mutex_lock(&lock);
if (joinable) {
if (on && atomic_load(&running)) {
pthread_mutex_unlock(&lock);
return true;
}
atomic_store(&running, false);
pthread_join(worker, NULL);
joinable = false;
}
bool ok = true;
if (on) {
ok = prepare_pin();
if (ok) {
atomic_store(&running, true);
int error = pthread_create(&worker, NULL, hold_pin, NULL);
if (error) {
atomic_store(&running, false);
errno = error;
ok = false;
} else {
joinable = true;
}
}
if (!ok) {
int error = errno;
release_pin();
errno = error;
}
}
pthread_mutex_unlock(&lock);
return ok;
}
@@ -0,0 +1,5 @@
#pragma once
#include <stdbool.h>
bool gpu_clock_pin_set(bool on);
+1 -1
View File
@@ -21,7 +21,7 @@
X(BindImageMemory) X(DestroyImage) X(FreeMemory) X(AcquireNextImageKHR) \
X(ResetCommandBuffer) X(BeginCommandBuffer) X(CmdPipelineBarrier) X(CmdBlitImage) \
X(EndCommandBuffer) X(ResetFences) X(QueueSubmit) X(QueuePresentKHR) X(WaitForFences) \
X(QueueWaitIdle) X(GetMemoryFdPropertiesKHR) \
X(QueueWaitIdle) X(GetMemoryFdPropertiesKHR) X(GetAndroidHardwareBufferPropertiesANDROID) \
X(GetImageSubresourceLayout) X(MapMemory) X(UnmapMemory) X(CmdClearColorImage) \
\
X(CreateShaderModule) X(DestroyShaderModule) X(CreatePipelineLayout) X(DestroyPipelineLayout) \
+76 -7
View File
@@ -19,6 +19,7 @@
#include <time.h>
#include <pthread.h>
#include <android/log.h>
#include <android/hardware_buffer.h>
#define TAG "DroidDeckWayland"
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__)
@@ -39,6 +40,7 @@ struct vkp_image {
};
static ANativeWindow *g_window;
static int g_ahb_import;
static char *g_driver_path, *g_library_name, *g_native_lib_dir;
static int g_dev_state;
static VkInstance g_inst;
@@ -459,17 +461,19 @@ static int dev_init(void) {
}
g_vk.GetPhysicalDeviceMemoryProperties(g_pd, &g_memprops);
const char *dev_exts[7] = {VK_KHR_SWAPCHAIN_EXTENSION_NAME, "VK_KHR_external_memory_fd",
"VK_EXT_external_memory_dma_buf", "VK_EXT_image_drm_format_modifier",
"VK_KHR_image_format_list", NULL, NULL};
uint32_t n_dev_exts = 5;
static const char *const import_exts[4] = {"VK_KHR_external_memory_fd", "VK_EXT_external_memory_dma_buf",
"VK_EXT_image_drm_format_modifier", "VK_KHR_image_format_list"};
const char *dev_exts[9] = {VK_KHR_SWAPCHAIN_EXTENSION_NAME};
uint32_t n_dev_exts = 1;
uint32_t ne = 0;
g_vk.EnumerateDeviceExtensionProperties(g_pd, NULL, &ne, NULL);
VkExtensionProperties *exts = calloc(ne ? ne : 1, sizeof(*exts));
g_vk.EnumerateDeviceExtensionProperties(g_pd, NULL, &ne, exts);
for (unsigned i = 0; i < 5; i++)
if (!has_ext(exts, ne, dev_exts[i]))
LOGE("present: driver MISSING %s (dmabuf import will fail)", dev_exts[i]);
/* Only what the driver has: the system driver lacks the dma-buf ones, which the cloud player never needs. */
for (unsigned i = 0; i < 4; i++) {
if (has_ext(exts, ne, import_exts[i])) dev_exts[n_dev_exts++] = import_exts[i];
else LOGE("present: driver MISSING %s (dmabuf import will fail)", import_exts[i]);
}
int want_hdr_md = 0;
if (droiddeck_color_requested()) {
want_hdr_md = has_ext(exts, ne, VK_EXT_HDR_METADATA_EXTENSION_NAME);
@@ -477,6 +481,13 @@ static int dev_init(void) {
}
const int want_sem_fd = has_ext(exts, ne, VK_KHR_EXTERNAL_SEMAPHORE_FD_EXTENSION_NAME);
if (want_sem_fd) dev_exts[n_dev_exts++] = VK_KHR_EXTERNAL_SEMAPHORE_FD_EXTENSION_NAME;
/* The cloud player's decoded frames arrive as AHardwareBuffers (vkp_image_from_ahb). */
g_ahb_import = has_ext(exts, ne, VK_ANDROID_EXTERNAL_MEMORY_ANDROID_HARDWARE_BUFFER_EXTENSION_NAME) &&
has_ext(exts, ne, VK_EXT_QUEUE_FAMILY_FOREIGN_EXTENSION_NAME);
if (g_ahb_import) {
dev_exts[n_dev_exts++] = VK_ANDROID_EXTERNAL_MEMORY_ANDROID_HARDWARE_BUFFER_EXTENSION_NAME;
dev_exts[n_dev_exts++] = VK_EXT_QUEUE_FAMILY_FOREIGN_EXTENSION_NAME;
}
free(exts);
float prio = 1.0f;
@@ -987,6 +998,60 @@ struct vkp_image *vkp_image_import_dmabuf(int fd, uint32_t drm_format, uint64_t
return img;
}
void vkp_wait_idle(void) {
if (g_dev && g_vk.DeviceWaitIdle) g_vk.DeviceWaitIdle(g_dev);
}
struct vkp_image *vkp_image_from_ahb(AHardwareBuffer *ahb) {
if (!ahb || dev_init() != 0) return NULL;
if (!g_ahb_import || !g_vk.GetAndroidHardwareBufferPropertiesANDROID) {
static int said;
if (!said) { said = 1; LOGE("ahb: the driver cannot import AHardwareBuffers (VK_ANDROID_external_memory_android_hardware_buffer)"); }
return NULL;
}
AHardwareBuffer_Desc desc;
AHardwareBuffer_describe(ahb, &desc);
VkAndroidHardwareBufferFormatPropertiesANDROID fp = {
.sType = VK_STRUCTURE_TYPE_ANDROID_HARDWARE_BUFFER_FORMAT_PROPERTIES_ANDROID};
VkAndroidHardwareBufferPropertiesANDROID props = {
.sType = VK_STRUCTURE_TYPE_ANDROID_HARDWARE_BUFFER_PROPERTIES_ANDROID, .pNext = &fp};
VkResult pr = g_vk.GetAndroidHardwareBufferPropertiesANDROID(g_dev, ahb, &props);
if (pr != VK_SUCCESS || fp.format == VK_FORMAT_UNDEFINED) {
LOGE("ahb: %ux%u format %u: properties %s, Vulkan format %d", desc.width, desc.height, desc.format,
vk_result_name(pr), (int)fp.format);
return NULL;
}
struct vkp_image *img = calloc(1, sizeof(*img));
if (!img) return NULL;
img->w = (int)desc.width; img->h = (int)desc.height; img->dmabuf = 1; img->fmt = fp.format; img->sampled = 1;
VkExternalMemoryImageCreateInfo ext = {
.sType = VK_STRUCTURE_TYPE_EXTERNAL_MEMORY_IMAGE_CREATE_INFO,
.handleTypes = VK_EXTERNAL_MEMORY_HANDLE_TYPE_ANDROID_HARDWARE_BUFFER_BIT_ANDROID};
VkImageCreateInfo ici = {
.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO, .pNext = &ext,
.imageType = VK_IMAGE_TYPE_2D, .format = fp.format, .extent = {desc.width, desc.height, 1},
.mipLevels = 1, .arrayLayers = 1, .samples = VK_SAMPLE_COUNT_1_BIT, .tiling = VK_IMAGE_TILING_OPTIMAL,
.usage = VK_IMAGE_USAGE_TRANSFER_SRC_BIT | VK_IMAGE_USAGE_SAMPLED_BIT,
.sharingMode = VK_SHARING_MODE_EXCLUSIVE, .initialLayout = VK_IMAGE_LAYOUT_UNDEFINED};
VkResult cr = g_vk.CreateImage(g_dev, &ici, NULL, &img->image);
if (cr != VK_SUCCESS) { LOGE("ahb: vkCreateImage %ux%u -> %s", desc.width, desc.height, vk_result_name(cr)); free(img); return NULL; }
int idx = -1;
for (int i = 0; i < 32; i++) if (props.memoryTypeBits & (1u << i)) { idx = i; break; }
VkImportAndroidHardwareBufferInfoANDROID imp = {
.sType = VK_STRUCTURE_TYPE_IMPORT_ANDROID_HARDWARE_BUFFER_INFO_ANDROID, .buffer = ahb};
VkMemoryDedicatedAllocateInfo ded = {.sType = VK_STRUCTURE_TYPE_MEMORY_DEDICATED_ALLOCATE_INFO,
.pNext = &imp, .image = img->image};
VkMemoryAllocateInfo mai = {.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO, .pNext = &ded,
.allocationSize = props.allocationSize, .memoryTypeIndex = (uint32_t)(idx < 0 ? 0 : idx)};
VkResult mr = idx < 0 ? VK_ERROR_FEATURE_NOT_PRESENT : g_vk.AllocateMemory(g_dev, &mai, NULL, &img->mem);
if (mr != VK_SUCCESS || g_vk.BindImageMemory(g_dev, img->image, img->mem, 0) != VK_SUCCESS) {
LOGE("ahb: importing %ux%u -> %s", desc.width, desc.height, vk_result_name(mr));
if (img->mem) g_vk.FreeMemory(g_dev, img->mem, NULL);
g_vk.DestroyImage(g_dev, img->image, NULL); free(img); return NULL;
}
return img;
}
static int shm_sampled(void) {
static int known;
if (!known) {
@@ -1626,6 +1691,10 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i
VkImageLayout result_layout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL;
if (fx) {
int mapped_w = (int)(scene_w * g_map.kx + 0.5f), mapped_h = (int)(scene_h * g_map.ky + 0.5f);
/* Frame generation samples every output pixel. Upscaling first made it interpolate
* the whole panel (3200x1440 on a 1440p phone) and rebuild on Steam's first frame.
* Grade at the game's size instead; the screen blit still scales to the panel. */
if (fg) { mapped_w = scene_w; mapped_h = scene_h; }
vkp_effects_set_formats(scene_fmt, scene_fmt);
result = vkp_effects_run(cmd, scene_img, scene_w, scene_h, mapped_w, mapped_h, &rw, &rh);
result_layout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL;
@@ -34,6 +34,9 @@ struct vkp_image *vkp_image_import_dmabuf(int fd, uint32_t drm_format, uint64_t
int vkp_image_is_dmabuf(const struct vkp_image *img);
struct vkp_image *vkp_image_create_shm(int w, int h);
struct AHardwareBuffer;
struct vkp_image *vkp_image_from_ahb(struct AHardwareBuffer *ahb);
void vkp_wait_idle(void);
void vkp_image_upload_shm(struct vkp_image *img, const void *data, int stride);
int vkp_image_width(const struct vkp_image *img);
@@ -9,6 +9,7 @@
#include <sys/resource.h>
#include <android/log.h>
#include <android/native_window_jni.h>
#include <android/hardware_buffer_jni.h>
#include "vk_present.h"
#include "droiddeck_ext.h"
#include "ahb_swapchain.h"
@@ -16,6 +17,7 @@
#include "effects_chain.h"
#include "cardboard.h"
#include "droiddeck_color.h"
#include "gpu_clock_pin.h"
extern int droiddeck_wayland_run(void);
extern void droiddeck_wayland_set_scale_mode(int mode, int alignment);
@@ -26,9 +28,13 @@ 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_stream_set(int on);
extern void droiddeck_stream_frame(AHardwareBuffer *ahb);
extern unsigned droiddeck_stream_frames(void);
extern void droiddeck_wayland_output_resized(void);
extern void droiddeck_wayland_set_output_refresh_rate(int mhz);
extern volatile int g_fps_limit;
extern volatile int g_release_unpaced;
extern volatile int g_hide_shell;
extern volatile int g_zero_copy;
extern volatile unsigned g_zero_copy_last;
@@ -569,6 +575,7 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetLookName(JNIEnv *
JNIEXPORT void JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetFpsLimit(JNIEnv *env, jclass clazz, jint fps) {
g_fps_limit = fps > 0 ? fps : 0;
g_release_unpaced = fps < 0;
__android_log_print(ANDROID_LOG_INFO, TAG, "fps limit %d", fps);
}
@@ -634,8 +641,14 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeTextInputDelete(JNIE
extern void adrenotools_set_turbo(bool turbo);
JNIEXPORT void JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetGpuTurbo(JNIEnv *env, jclass clazz, jboolean on) {
(void)env; (void)clazz;
adrenotools_set_turbo(on == JNI_TRUE);
(void)clazz;
if (!gpu_clock_pin_set(on == JNI_TRUE)) {
int saved_errno = errno;
jclass error = (*env)->FindClass(env, "java/lang/IllegalStateException");
if (error) (*env)->ThrowNew(env, error, strerror(saved_errno));
}
// Clear a legacy global pin when recovering its marker after an app upgrade or crash.
if (on != JNI_TRUE) adrenotools_set_turbo(false);
}
JNIEXPORT jint JNICALL
@@ -660,3 +673,21 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetCardboardSpatialP
(*env)->GetFloatArrayRegion(env, pos, 0, 3, p);
vkp_cardboard_set_spatial_pose(m, p, live ? 1 : 0);
}
/* The Xbox Cloud Gaming native player: its decoded frames are the whole scene (no Wayland clients). */
JNIEXPORT void JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeStreamMode(JNIEnv *env, jclass clazz, jboolean on) {
droiddeck_stream_set(on ? 1 : 0);
}
JNIEXPORT void JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeStreamFrame(JNIEnv *env, jclass clazz, jobject hardwareBuffer) {
if (!hardwareBuffer) return;
AHardwareBuffer *ahb = AHardwareBuffer_fromHardwareBuffer(env, hardwareBuffer);
if (ahb) droiddeck_stream_frame(ahb);
}
JNIEXPORT jint JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeStreamFrames(JNIEnv *env, jclass clazz) {
return (jint)droiddeck_stream_frames();
}
@@ -25,9 +25,19 @@ class App : Application() {
if (android.os.Build.VERSION.SDK_INT >= 28) android.webkit.WebView.setDataDirectorySuffix("xbox")
return
}
if (process == "$packageName:xboxcloud") {
if (android.os.Build.VERSION.SDK_INT >= 28) android.webkit.WebView.setDataDirectorySuffix("xboxcloud")
return
}
if (process == "$packageName:xcloudnative") {
// The native cloud player: the stores log and nothing that touches the Linux session.
com.droiddeck.launcher.stores.StoresState.init(this)
return
}
if (process == "$packageName:vr_recovery" || process == "$packageName:rgb_diagnostic") return
CrashHandler.install(this)
com.droiddeck.launcher.session.QuestExperimental.initialize(this)
com.droiddeck.launcher.session.SessionPrefs.applyNtsyncDefault(this)
com.droiddeck.launcher.session.SessionPrefs.settleGpuDriverMode(this)
com.droiddeck.launcher.update.AppUpdates.init(this)
com.droiddeck.launcher.core.DownloadSource.init(this)
@@ -43,6 +53,7 @@ class App : Application() {
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.amazon.AmazonBackend)
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.itch.ItchBackend)
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.xbox.XboxBackend)
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.battlenet.BattleNetBackend)
}
/** Fetches the packs an existing setup already relies on, so the first use after an update does not wait. */
@@ -55,6 +55,8 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
private set
private var runtimePending by mutableStateOf(false)
private var sessionRunning by mutableStateOf(false)
var tpUbwcMode: String by mutableStateOf(SessionPrefs.TP_UBWC_AUTO)
private set
val uiMode: String get() = when (mode) {
SessionPrefs.GPU_DRIVERS_AUTO -> GPU_MODE_AUTO
@@ -73,8 +75,16 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
mode = uiMode, display = displaySlot(), runtime = runtimeSlot(),
canRestart = !sessionRunning,
checkedAt = TurnipReleases.cached(activity)?.checkedAt ?: 0L,
tpUbwcMode = tpUbwcMode.takeIf { gpu.model == 740 },
tpUbwcRestartRequired = com.droiddeck.launcher.gpu.TpUbwcHint.restartRequired(activity),
)
fun chooseTpUbwcMode(mode: String) {
if (gpu.model != 740) return
SessionPrefs.setTpUbwcMode(activity, mode)
tpUbwcMode = mode
}
private fun slotSource(chosenDefault: Boolean, defaultLabel: Int): String = activity.getString(when {
uiMode == GPU_MODE_AUTO -> R.string.gpu_src_auto
uiMode == GPU_MODE_PAIR && pairRows.any { it.active } -> R.string.gpu_src_pair
@@ -124,6 +134,7 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
var androidSelected by mutableStateOf("")
fun refreshDrivers() {
tpUbwcMode = SessionPrefs.tpUbwcMode(activity)
val lm = LinuxVulkanDriverManager(activity)
fun origin(id: String) = if (TurnipReleases.isDownloaded(activity, id)) DriverRow.DOWNLOADED else DriverRow.IMPORTED
val bundles = DriverBundle.all(activity)
@@ -80,7 +80,7 @@ import com.droiddeck.launcher.session.GameStorage
import com.droiddeck.launcher.input.SecondScreenDisplay
import com.droiddeck.launcher.input.SecondScreenDisplays
class MainActivity : ComponentActivity() {
class FrontEndActivity : ComponentActivity() {
override fun attachBaseContext(newBase: android.content.Context) {
super.attachBaseContext(newBase)
com.droiddeck.launcher.core.AppLanguage.applyTo(this, newBase)
@@ -91,7 +91,7 @@ class MainActivity : ComponentActivity() {
private val components = ComponentsMenu(this, ui)
private val decky = DeckyMenu(this, ui)
private val updates = UpdatesMenu(this, ui)
private val protons = ProtonMenu(this, ui)
private val protons = ProtonMenu(this, ui) { !busy && flood == null && pkgStage == null && components.compBusy == null }
private var installed by mutableStateOf<String?>(null)
private var flood by mutableStateOf<PendingFlood?>(null)
@@ -120,6 +120,7 @@ class MainActivity : ComponentActivity() {
private var glThread by mutableStateOf(true)
private var noGlError by mutableStateOf(true)
private var returnAfterGame by mutableStateOf(true)
private var shaderPrecache by mutableStateOf<Boolean?>(null)
private var steamRepairQueued by mutableStateOf(false)
private var mangoapp by mutableStateOf(true)
private var steamController by mutableStateOf(SessionPrefs.CONTROLLER_DECK)
@@ -138,6 +139,7 @@ class MainActivity : ComponentActivity() {
private var pkgPercent by mutableIntStateOf(-1)
private var desktopInstalled by mutableStateOf(false)
private var desktopPresent by mutableStateOf(false)
private var battleNetInstalled by mutableStateOf(false)
private var offlineAccount by mutableStateOf<String?>(null)
private var offline by mutableStateOf(false)
private var showPerformance by mutableStateOf(false)
@@ -151,9 +153,13 @@ class MainActivity : ComponentActivity() {
private var syncFallback by mutableStateOf(true)
private var fsyncFirst by mutableStateOf(false)
private var gamescopeRealtime by mutableStateOf(false)
private var ramCapMb by mutableStateOf(0)
private var zramClient by mutableStateOf(true)
private var vramCapMb by mutableStateOf(0)
private var gpuClockPin by mutableStateOf(false)
private var prootNoSeccomp by mutableStateOf(false)
private var prootFastPath by mutableStateOf(true)
private var cursorComposite by mutableStateOf(false)
private var guestHostname by mutableStateOf(SessionPrefs.DEFAULT_GUEST_HOSTNAME)
private var phantomWarning by mutableStateOf<String?>(null)
private var phantomProcessStatus by mutableStateOf(PhantomProcessStatus.NOT_APPLICABLE)
@@ -161,16 +167,16 @@ class MainActivity : ComponentActivity() {
private var directAudio by mutableStateOf(false)
private var picturePosition by mutableStateOf("center")
private var stretch16x9 by mutableStateOf(false)
private var orientation by mutableStateOf(android.content.pm.ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE)
private var orientation by mutableStateOf(android.content.pm.ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE)
private var launcherFullscreen by mutableStateOf(true)
private var downloadSource by mutableStateOf(DownloadSource.MODE_AUTO)
private var downloadMirror by mutableStateOf("")
private var downloadPrefix by mutableStateOf("")
private var downloadProxy by mutableStateOf("")
private var animationsEnabled by mutableStateOf(true)
private var launcherIcon by mutableStateOf(LauncherIcon.DARK)
private var gameStoresEnabled by mutableStateOf(true)
private var gameStoresSpeedTier by mutableStateOf("fast")
private var storesOpenTab by mutableStateOf(SessionPrefs.STORES_OPEN_LIBRARY)
private var navRequest by mutableStateOf<String?>(null)
private var storesShowMature by mutableStateOf(false)
private var storesSort by mutableStateOf(SessionPrefs.STORES_SORTS.first())
@@ -386,14 +392,13 @@ class MainActivity : ComponentActivity() {
private var appScale by mutableStateOf(com.droiddeck.launcher.core.AppUiPrefs.DEFAULT_SCALE)
private var hdrOn by mutableStateOf(false)
private var fpsLimit by mutableStateOf(0)
private var performanceMode by mutableStateOf(false)
private var upscaler by mutableStateOf(0)
private var upscaleSharpness by mutableStateOf(75)
private var hdrReason by mutableStateOf<String?>(null)
private var touchMode by mutableStateOf(SessionPrefs.TOUCH_AUTO)
private var suspendPolicy by mutableStateOf(SessionPrefs.SUSPEND_MANUAL)
private var steamDownloadsInBackground by mutableStateOf(false)
private var zramCompression by mutableStateOf(false)
private var zramSupported by mutableStateOf<Boolean?>(null)
private var oscMode by mutableStateOf(SessionPrefs.OSC_AUTO)
private var backActionsInverted by mutableStateOf(false)
private var gameStorage by mutableStateOf("")
@@ -412,6 +417,10 @@ class MainActivity : ComponentActivity() {
private var logsEnabled by mutableStateOf(true)
private var vacDiagnostics by mutableStateOf(false)
private var agentCommands by mutableStateOf(false)
private var clearCachedApps by mutableStateOf(false)
private var adbPaired by mutableStateOf(false)
private var memoryHud by mutableStateOf(SessionPrefs.MEMORY_HUD_OFF)
private var backgroundAppLimit by mutableStateOf(com.droiddeck.launcher.core.BackgroundAppLimit.OFF)
private var showRoms by mutableStateOf(false)
private var homeAppSelected by mutableStateOf(false)
private var homeScreenEnabled by mutableStateOf(false)
@@ -460,7 +469,13 @@ class MainActivity : ComponentActivity() {
}
private fun launchFlooded(f: PendingFlood) {
if (busy || LinuxRuntimeInstaller.isBusy()) { flood = null; floodProgress = 0f; return }
if (busy || LinuxRuntimeInstaller.isBusy() || protons.protonBusyId != null ||
ProtonExtras.installInProgress || pkgStage != null) {
flood = null
floodProgress = 0f
com.droiddeck.launcher.ui.LaunchOrigin.flooding = null
return
}
val signal = com.droiddeck.launcher.ui.Themes.byId(theme).signal
super.startActivity(f.intent.putExtra(com.droiddeck.launcher.ui.EXTRA_FLOOD, signal.toArgb()))
overridePendingTransition(0, 0)
@@ -544,12 +559,12 @@ class MainActivity : ComponentActivity() {
}
startActivity(Intent(this, SessionActivity::class.java).setAction(SessionService.ACTION_RESUME)
.putExtra(SessionActivity.EXTRA_STEAMVR, steamVr)
.putExtra(SessionActivity.EXTRA_RETURN_HOME, returnHome)
.returnTo(returnHome)
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP))
return true
}
return startSession(Intent(this, SessionActivity::class.java)
.putExtra(SessionActivity.EXTRA_RETURN_HOME, returnHome)
.returnTo(returnHome)
.apply {
if (game.gameIdString == com.droiddeck.launcher.gpu.SteamVr.APP_ID) {
putExtra(SessionActivity.EXTRA_STEAMVR, true)
@@ -560,6 +575,12 @@ class MainActivity : ComponentActivity() {
}, steamSession = true)
}
private fun Intent.returnTo(returnHome: Boolean): Intent {
val task = taskId
return putExtra(SessionActivity.EXTRA_RETURN_HOME, returnHome)
.apply { if (returnHome) putExtra(SessionActivity.EXTRA_LINK_TASK, task) }
}
private fun launchWithoutSteam(game: Library.SteamGame, returnHome: Boolean): Boolean {
val app = applicationContext
Thread({
@@ -573,16 +594,18 @@ class MainActivity : ComponentActivity() {
}
com.droiddeck.launcher.session.NoSteamLaunch.remember(command)
val next = Intent(this, SessionActivity::class.java)
.putExtra(SessionActivity.EXTRA_RETURN_HOME, returnHome)
.returnTo(returnHome)
.putExtra(SessionService.EXTRA_MODE, SessionService.MODE_RUN)
.putExtra(SessionService.EXTRA_PROGRAM, command.program)
.putExtra(SessionService.EXTRA_PROGRAM_ARGS, command.args.toTypedArray())
.putExtra(SessionService.EXTRA_GAME_ID, com.droiddeck.launcher.session.NoSteamLaunch.scope(game))
when {
!SessionState.running -> startSessionOrWait(next)
SessionState.stopRequested -> android.widget.Toast.makeText(this, R.string.game_link_session_busy, android.widget.Toast.LENGTH_LONG).show()
command.program == com.droiddeck.launcher.session.NoSteamLaunch.BATTLENET && handToBattleNet(command.args, returnHome) { pendingSwitch = next } -> {}
SessionState.mode == SessionService.MODE_RUN && SessionState.program == command.program && SessionState.programArgs == command.args ->
startActivity(Intent(this, SessionActivity::class.java).setAction(SessionService.ACTION_RESUME)
.putExtra(SessionActivity.EXTRA_RETURN_HOME, returnHome)
.returnTo(returnHome)
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP))
else -> pendingSwitch = next
}
@@ -677,7 +700,8 @@ class MainActivity : ComponentActivity() {
}
private fun startSessionOrWait(next: Intent): Boolean {
val started = startSession(next, steamSession = next.hasExtra(SessionService.EXTRA_STEAM_URL))
val started = startSession(next, steamSession = next.hasExtra(SessionService.EXTRA_STEAM_URL) ||
next.getStringExtra(SessionService.EXTRA_PROGRAM) in setOf(com.droiddeck.launcher.session.NoSteamLaunch.PROTON_RUN, com.droiddeck.launcher.session.NoSteamLaunch.BATTLENET))
if (!started && (busy || LinuxRuntimeInstaller.isBusy())) android.widget.Toast.makeText(this, R.string.main_wait_install_game, android.widget.Toast.LENGTH_SHORT).show()
return started
}
@@ -699,7 +723,7 @@ class MainActivity : ComponentActivity() {
else {
switchPoll = null
switchingTo = null
android.widget.Toast.makeText(this@MainActivity, R.string.game_link_session_busy, android.widget.Toast.LENGTH_LONG).show()
android.widget.Toast.makeText(this@FrontEndActivity, R.string.game_link_session_busy, android.widget.Toast.LENGTH_LONG).show()
}
return
}
@@ -742,6 +766,7 @@ class MainActivity : ComponentActivity() {
gameSyncFolder = GameFileSync.folder(this)
displayManager = getSystemService(DISPLAY_SERVICE) as DisplayManager
refreshPhantomStatus()
if (!SessionState.running) com.droiddeck.launcher.core.BackgroundAppLimit.restoreAsync(this)
theme = SessionPrefs.theme(this)
frameGenLabel = getString(R.string.frame_gen_off)
appScale = com.droiddeck.launcher.core.AppUiPrefs.scale(this)
@@ -755,12 +780,13 @@ class MainActivity : ComponentActivity() {
downloadSource = DownloadSource.mode(this)
downloadMirror = DownloadSource.mirror(this)
downloadPrefix = DownloadSource.custom(this)
downloadProxy = DownloadSource.proxyHostPort(this)
DownloadSource.probe()
animationsEnabled = SessionPrefs.animationsEnabled(this)
LauncherIcon.repair(this, intent?.component)
launcherIcon = LauncherIcon.current(this)
gameStoresEnabled = SessionPrefs.gameStoresEnabled(this)
gameStoresSpeedTier = SessionPrefs.gameStoresSpeedTier(this)
storesOpenTab = SessionPrefs.storesOpenTab(this)
storesShowMature = SessionPrefs.storesShowMature(this)
storesSort = SessionPrefs.storesSort(this)
storesView = SessionPrefs.storesView(this)
@@ -790,6 +816,7 @@ class MainActivity : ComponentActivity() {
offlineAccount = offlineAccount, offline = offline,
frameGenLabel = frameGenLabel, romsDir = romsDir, logsEnabled = logsEnabled,
vacDiagnostics = vacDiagnostics, agentCommands = agentCommands,
clearCachedApps = clearCachedApps, adbPaired = adbPaired, memoryHud = memoryHud, backgroundAppLimit = backgroundAppLimit,
steamGames = steamGames, steamGamesX64 = steamGamesX64, emulators = emulatorList, running = runningLabel,
frameGen = FrameGen.mode(this),
lossless = lossless,
@@ -808,6 +835,7 @@ class MainActivity : ComponentActivity() {
packageStage = pkgStage,
packagePercent = pkgPercent,
sessionRunning = SessionState.running,
battleNetInstalled = battleNetInstalled,
backActionsInverted = backActionsInverted,
buildLabel = BuildConfig.BUILD_LABEL,
oscMode = oscMode,
@@ -820,11 +848,11 @@ class MainActivity : ComponentActivity() {
downloadSource = downloadSource,
downloadMirror = downloadMirror,
downloadPrefix = downloadPrefix,
downloadProxy = downloadProxy,
animationsEnabled = animationsEnabled,
launcherIcon = launcherIcon,
gameStoresEnabled = gameStoresEnabled,
gameStoresSpeedTier = gameStoresSpeedTier,
storesOpenTab = storesOpenTab,
navigate = navRequest,
addedGamesArt = addedGamesArt,
sgdbUserKey = sgdbUserKey,
@@ -896,6 +924,7 @@ class MainActivity : ComponentActivity() {
.putExtra(SessionService.EXTRA_PROGRAM, app.program)
.putExtra(SessionService.EXTRA_PROGRAM_ARGS, app.args.toTypedArray()))
},
onBattleNet = { openBattleNet() },
onFlatpakApp = { id, name ->
Library.flatpakNames[id] = name
startActivity(Intent(this, SessionActivity::class.java)
@@ -950,6 +979,12 @@ class MainActivity : ComponentActivity() {
com.droiddeck.launcher.agent.AgentAccess.setCommandsEnabled(this, !com.droiddeck.launcher.agent.AgentAccess.commandsEnabled(this))
agentCommands = com.droiddeck.launcher.agent.AgentAccess.commandsEnabled(this)
},
onClearCachedApps = { on -> SessionPrefs.setClearCachedApps(this, on); clearCachedApps = on },
onMemoryHud = { value -> SessionPrefs.setMemoryHud(this, value); memoryHud = value },
onBackgroundAppLimit = { limit ->
SessionPrefs.setBackgroundAppLimit(this, limit); backgroundAppLimit = limit
if (limit == com.droiddeck.launcher.core.BackgroundAppLimit.OFF) com.droiddeck.launcher.core.BackgroundAppLimit.restoreAsync(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()
@@ -999,7 +1034,6 @@ class MainActivity : ComponentActivity() {
applyLauncherFullscreen()
},
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 },
onStoresSort = { sort -> SessionPrefs.setStoresSort(this, sort); storesSort = sort },
@@ -1022,6 +1056,10 @@ class MainActivity : ComponentActivity() {
DownloadSource.setCustom(this, prefix)
downloadPrefix = DownloadSource.custom(this)
},
onDownloadProxy = { value ->
DownloadSource.setProxy(this, value)
downloadProxy = DownloadSource.proxyHostPort(this)
},
onHomeApp = { manageHomeApp() },
onHomeScreen = { on ->
HomeApp.setHomeScreenEnabled(this, on)
@@ -1037,7 +1075,7 @@ class MainActivity : ComponentActivity() {
onWirelessAdbPair = { host, port, code, complete ->
Thread({
val result = runCatching {
kotlinx.coroutines.runBlocking { WirelessAdbFix.pair(this@MainActivity, host, port, code) }
kotlinx.coroutines.runBlocking { WirelessAdbFix.pair(this@FrontEndActivity, host, port, code) }
}
val error = result.exceptionOrNull()?.let { it.localizedMessage ?: getString(R.string.main_wadb_pair_failed) }
ui.post { complete(error) }
@@ -1045,13 +1083,13 @@ class MainActivity : ComponentActivity() {
},
onFindWirelessAdbPort = { host, complete ->
Thread({
val port = WirelessAdbFix.findConnectPort(this@MainActivity, host)
val port = WirelessAdbFix.findConnectPort(this@FrontEndActivity, host)
ui.post { complete(port) }
}, "wireless-adb-discovery").start()
},
onWirelessAdbApply = { host, port, enabled, complete ->
Thread({
val result = runCatching { WirelessAdbFix.setChildProcessLimit(this@MainActivity, host, port, enabled) }
val result = runCatching { WirelessAdbFix.setChildProcessLimit(this@FrontEndActivity, host, port, enabled) }
val error = result.exceptionOrNull()?.let { it.localizedMessage ?: getString(R.string.main_wadb_command_failed) }
ui.post {
if (error == null) refreshPhantomStatus()
@@ -1061,7 +1099,7 @@ class MainActivity : ComponentActivity() {
},
onSetPhantomProcessLimit = { enabled, complete ->
Thread({
val result = runCatching { WirelessAdbFix.setUsingSavedPairing(this@MainActivity, enabled) }
val result = runCatching { WirelessAdbFix.setUsingSavedPairing(this@FrontEndActivity, enabled) }
val error = result.exceptionOrNull()?.let { it.localizedMessage ?: getString(R.string.main_wadb_unavailable) }
ui.post {
if (error == null) refreshPhantomStatus()
@@ -1117,7 +1155,7 @@ class MainActivity : ComponentActivity() {
showNonAdreno?.let { release ->
ConfirmDialog(
title = androidx.compose.ui.res.stringResource(R.string.main_non_adreno_title),
text = androidx.compose.ui.res.stringResource(R.string.main_non_adreno_text, com.droiddeck.launcher.core.DeviceSupport.gpuName(this@MainActivity), release.size / 1e6),
text = androidx.compose.ui.res.stringResource(R.string.main_non_adreno_text, com.droiddeck.launcher.core.DeviceSupport.gpuName(this@FrontEndActivity), release.size / 1e6),
confirm = androidx.compose.ui.res.stringResource(R.string.main_install_anyway),
onConfirm = { showNonAdreno = null; install(release) },
onDismiss = { showNonAdreno = null },
@@ -1263,6 +1301,23 @@ class MainActivity : ComponentActivity() {
}
}
override fun onUserInteraction() {
super.onUserInteraction()
com.droiddeck.launcher.ui.Ambient.poke()
}
override fun dispatchTouchEvent(event: android.view.MotionEvent): Boolean {
if (event.actionMasked == android.view.MotionEvent.ACTION_UP || event.actionMasked == android.view.MotionEvent.ACTION_CANCEL) {
com.droiddeck.launcher.ui.Ambient.poke()
}
return super.dispatchTouchEvent(event)
}
override fun dispatchGenericMotionEvent(event: android.view.MotionEvent): Boolean {
com.droiddeck.launcher.ui.Ambient.poke()
return super.dispatchGenericMotionEvent(event)
}
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)) {
@@ -1287,7 +1342,9 @@ class MainActivity : ComponentActivity() {
refreshWifiDiscovery()
com.droiddeck.launcher.stores.StoresState.refresh(this)
com.droiddeck.launcher.stores.StoresState.libraryListener = { refreshAddedGames(); refresh() }
com.droiddeck.launcher.stores.battlenet.BattleNetClient.opener = { args -> openBattleNet(args) }
com.droiddeck.launcher.ui.Motion.refresh(this)
com.droiddeck.launcher.ui.Ambient.poke()
com.droiddeck.launcher.ui.QuitFlood.take()?.let { c ->
returning = ReturningFlood(c, com.droiddeck.launcher.ui.LaunchOrigin.takeReturn())
}
@@ -1353,6 +1410,7 @@ class MainActivity : ComponentActivity() {
unregisterReceiver(mediaReceiver)
ui.removeCallbacks(mediaRefresh)
com.droiddeck.launcher.stores.StoresState.libraryListener = null
com.droiddeck.launcher.stores.battlenet.BattleNetClient.opener = null
displayManager.unregisterDisplayListener(secondScreenDisplayListener)
flood = null
returning = null
@@ -1510,6 +1568,7 @@ class MainActivity : ComponentActivity() {
onRestoreBundled = { TurnipDriver(this).restoreBundled(); drivers.refreshDrivers() },
onMode = { m -> drivers.chooseMode(m) },
onRestart = { drivers.restartApp() },
onTpUbwcMode = { drivers.chooseTpUbwcMode(it) },
),
protons = com.droiddeck.launcher.ui.ProtonsState(
rows = protons.protonRows, busyId = protons.protonBusyId, stage = protons.protonStage, percent = protons.protonPercent,
@@ -1545,6 +1604,50 @@ class MainActivity : ComponentActivity() {
desktopPresent = DesktopCatalog.desktopPresent(this)
}
private fun openBattleNet(args: List<String> = emptyList()) {
if (protons.protonBusyId != null || ProtonExtras.installInProgress || pkgStage != null) {
android.widget.Toast.makeText(this, R.string.main_wait_install_game, android.widget.Toast.LENGTH_SHORT).show()
return
}
val app = applicationContext
Thread({
val full = if (args.firstOrNull() == "--library") args else com.droiddeck.launcher.stores.battlenet.BattleNetClient.library(app) + args
ui.post {
if (isDestroyed) return@post
val next = Intent(this, SessionActivity::class.java)
.putExtra(SessionService.EXTRA_MODE, SessionService.MODE_RUN)
.putExtra(SessionService.EXTRA_PROGRAM, com.droiddeck.launcher.session.NoSteamLaunch.BATTLENET)
.putExtra(SessionService.EXTRA_PROGRAM_ARGS, full.toTypedArray())
when {
!SessionState.running -> startSessionOrWait(next)
SessionState.stopRequested -> android.widget.Toast.makeText(this, R.string.game_link_session_busy, android.widget.Toast.LENGTH_LONG).show()
handToBattleNet(full) { pendingSwitch = next } -> {}
else -> pendingSwitch = next
}
}
}, "battlenet-open").start()
}
private fun handToBattleNet(args: List<String>, returnHome: Boolean = false, otherwise: () -> Unit): Boolean {
if (SessionState.mode != SessionService.MODE_RUN || SessionState.program != com.droiddeck.launcher.session.NoSteamLaunch.BATTLENET) return false
val library = args.takeIf { it.firstOrNull() == "--library" }?.getOrNull(1)
val sessionLibrary = SessionState.programArgs.takeIf { it.firstOrNull() == "--library" }?.getOrNull(1)
if (library != null && sessionLibrary != null && library != sessionLibrary) return false
val request = if (library != null) args.drop(2) else args
val app = applicationContext
Thread({
val handed = request.isEmpty() || com.droiddeck.launcher.stores.battlenet.BattleNetClient.request(LinuxRuntime.sessionRoot(app), request)
ui.post {
if (isDestroyed) return@post
if (!handed) otherwise()
else startActivity(Intent(this, SessionActivity::class.java).setAction(SessionService.ACTION_RESUME)
.returnTo(returnHome)
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP))
}
}, "battlenet-request").start()
return true
}
private fun launchProgram(path: String) {
startActivity(Intent(this, SessionActivity::class.java)
.putExtra(SessionService.EXTRA_MODE, SessionService.MODE_RUN)
@@ -1610,14 +1713,12 @@ class MainActivity : ComponentActivity() {
ModeSettings(
mode = mode, resolution = resolution,
panelSize = com.droiddeck.launcher.session.SessionDisplay.panelSize(this),
hdr = hdrOn, hdrReason = hdrReason, fpsLimit = fpsLimit,
hdr = hdrOn, hdrReason = hdrReason, fpsLimit = fpsLimit, performanceMode = performanceMode,
upscaler = upscaler, upscaleSharpness = upscaleSharpness,
gpuDrivers = drivers.summary(),
touchMode = touchMode,
suspendPolicy = suspendPolicy,
steamDownloadsInBackground = steamDownloadsInBackground,
zramCompression = if (mode == SessionService.MODE_STEAM) zramCompression else null,
zramSupported = zramSupported,
pipSupported = com.droiddeck.launcher.session.SessionPipController.supported(this),
pipAutoEnter = pipAutoEnter,
unfoldedControls = if (mode == SessionService.MODE_STEAM &&
@@ -1637,6 +1738,7 @@ class MainActivity : ComponentActivity() {
syncBackend = if (mode == SessionService.MODE_STEAM) SessionPrefs.syncBackendOf(fastSync, fsyncFirst, syncFallback) else null,
steamClientArch = if (mode == SessionService.MODE_STEAM) steamClientArch else null,
returnAfterGame = if (mode == SessionService.MODE_STEAM) returnAfterGame else null,
shaderPrecache = if (mode == SessionService.MODE_STEAM) shaderPrecache else null,
steamRepairQueued = steamRepairQueued,
mangoapp = mangoapp,
steamController = if (mode == SessionService.MODE_STEAM) steamController else null,
@@ -1678,6 +1780,7 @@ class MainActivity : ComponentActivity() {
onHdr = { on -> SessionPrefs.setHdr(this, mode, on); hdrOn = on },
onGpuDrivers = { openComponents(focusContent = true, tab = com.droiddeck.launcher.ui.GPU_TAB) },
onFpsLimit = { fps -> SessionPrefs.setFpsLimit(this, mode, fps); fpsLimit = fps },
onPerformanceMode = { on -> SessionPrefs.setPerformanceMode(this, mode, on); performanceMode = on },
onUpscaler = { m -> SessionPrefs.setUpscaler(this, m); upscaler = m },
onUpscaleSharpness = { pct -> SessionPrefs.setUpscaleSharpness(this, pct); upscaleSharpness = pct },
onTouch = { t -> SessionPrefs.setTouchMode(this, t); touchMode = t },
@@ -1686,7 +1789,6 @@ class MainActivity : ComponentActivity() {
SessionPrefs.setSteamDownloadsInBackground(this, enabled)
steamDownloadsInBackground = enabled
},
onZramCompression = { on -> SessionPrefs.setZramCompression(this, on); zramCompression = on },
onPipAutoEnter = { on -> SessionPrefs.setPipAutoEnter(this, on); pipAutoEnter = on },
onUnfoldedControls = { on -> SessionPrefs.setUnfoldedControls(this, on); unfoldedControls = on },
onUnfoldedControlsWithController = { on ->
@@ -1724,9 +1826,15 @@ class MainActivity : ComponentActivity() {
syncFallback = SessionPrefs.syncFallback(this)
},
onSteamRepair = { steamRepairQueued = SteamRepair.queue(this) },
onSteamClientArch = { id -> SessionPrefs.setSteamClientArch(this, id); steamClientArch = id; refresh() },
onSteamClientArch = { id ->
SessionPrefs.setSteamClientArch(this, id)
steamClientArch = id
loadShaderPrecache(mode)
refresh()
},
onMangoapp = { on -> SessionPrefs.setMangoapp(this, on); mangoapp = on },
onReturnAfterGame = { on -> SessionPrefs.setReturnAfterGame(this, on); returnAfterGame = on },
onShaderPrecache = { on -> SessionPrefs.setShaderPrecache(this, on); shaderPrecache = on },
onSteamController = { id -> SessionPrefs.setSteamController(this, id); steamController = id },
onRunSteamAtStartup = { on ->
SessionPrefs.setRunSteamAtStartup(this, on)
@@ -1767,7 +1875,12 @@ class MainActivity : ComponentActivity() {
fsyncFirst = fsyncFirst,
gamescopeRealtime = gamescopeRealtime,
gpuClockPin = gpuClockPin,
prootNoSeccomp = prootNoSeccomp, prootFastPath = prootFastPath, guestHostname = guestHostname, phantomWarning = phantomWarning,
ramCapMb = ramCapMb, vramCapMb = vramCapMb, zramClient = zramClient,
deviceRamMb = (android.app.ActivityManager.MemoryInfo()
.also((getSystemService(ACTIVITY_SERVICE) as android.app.ActivityManager)::getMemoryInfo).totalMem / (1024 * 1024)).toInt(),
ramCapAvailable = com.droiddeck.launcher.core.ZramSupport.supported(),
prootNoSeccomp = prootNoSeccomp, prootFastPath = prootFastPath, cursorComposite = cursorComposite,
guestHostname = guestHostname, phantomWarning = phantomWarning,
onClientOverride = { on -> SessionPrefs.setClientCpusOverride(this, on); clientOverride = on },
onTuSysmem = { on -> SessionPrefs.setTuSysmem(this, on); tuSysmem = on },
onZinkLazy = { on -> SessionPrefs.setZinkLazy(this, on); zinkLazy = on },
@@ -1779,8 +1892,12 @@ class MainActivity : ComponentActivity() {
onFsyncFirst = { on -> SessionPrefs.setFsyncFirst(this, on); fsyncFirst = on },
onGamescopeRealtime = { on -> SessionPrefs.setGamescopeRealtime(this, on); gamescopeRealtime = on },
onGpuClockPin = { on -> SessionPrefs.setGpuClockPin(this, on); gpuClockPin = on },
onRamCapMb = { mb -> SessionPrefs.setRamCapMb(this, mb); ramCapMb = mb },
onZramClient = { on -> SessionPrefs.setZramClient(this, on); zramClient = on },
onVramCapMb = { mb -> SessionPrefs.setVramCapMb(this, mb); vramCapMb = mb },
onProotNoSeccomp = { on -> SessionPrefs.setProotNoSeccomp(this, on); prootNoSeccomp = on },
onProotFastPath = { on -> SessionPrefs.setProotFastPath(this, on); prootFastPath = on },
onCursorComposite = { on -> SessionPrefs.setCursorComposite(this, on); cursorComposite = on },
onGuestHostname = { name -> SessionPrefs.setGuestHostname(this, name) },
onClientCore = { core, on ->
clientCores = if (on) clientCores + core else clientCores - core
@@ -1881,6 +1998,19 @@ class MainActivity : ComponentActivity() {
private var pipAutoEnter by mutableStateOf(false)
private var unfoldedControls by mutableStateOf(false)
private var unfoldedControlsWithController by mutableStateOf(false)
private var shaderPrecacheGeneration = 0
private fun loadShaderPrecache(mode: String) {
val generation = ++shaderPrecacheGeneration
shaderPrecache = null
if (mode != SessionService.MODE_STEAM) return
Thread({
val precache = SessionPrefs.shaderPrecache(this)
ui.post {
if (!isDestroyed && generation == shaderPrecacheGeneration) shaderPrecache = precache
}
}, "shader-precache").start()
}
private fun loadModeSettings(mode: String) {
pipAutoEnter = SessionPrefs.pipAutoEnter(this)
@@ -1896,11 +2026,13 @@ class MainActivity : ComponentActivity() {
steamRepairQueued = SteamRepair.queued(this)
mangoapp = SessionPrefs.mangoapp(this)
returnAfterGame = SessionPrefs.returnAfterGame(this)
loadShaderPrecache(mode)
steamController = SessionPrefs.steamController(this)
runSteamAtStartup = SessionPrefs.runSteamAtStartup(this)
loadBootMovie()
hdrOn = SessionPrefs.hdr(this, mode)
fpsLimit = SessionPrefs.fpsLimit(this, mode)
performanceMode = SessionPrefs.performanceMode(this, mode)
upscaler = SessionPrefs.upscaler(this)
upscaleSharpness = SessionPrefs.upscaleSharpness(this)
hdrReason = com.droiddeck.launcher.wayland.HdrSupport.probe(this).reason
@@ -1918,9 +2050,6 @@ class MainActivity : ComponentActivity() {
storageDiagnostics = SessionPrefs.storageDiagnosticsEnabled(this)
settingsMode = mode
Thread({
val zram = com.droiddeck.launcher.core.ZramSupport.supported()
val zramOn = SessionPrefs.zramCompression(this)
ui.post { zramSupported = zram; zramCompression = zramOn }
drivers.refreshDrivers()
val storage = GameStorage.options(this)
val deckyInstalled = if (mode == SessionService.MODE_STEAM) DeckyManager.installed(this) else null
@@ -1962,9 +2091,13 @@ class MainActivity : ComponentActivity() {
syncFallback = SessionPrefs.syncFallback(this)
fsyncFirst = SessionPrefs.fsyncFirst(this)
gamescopeRealtime = SessionPrefs.gamescopeRealtime(this)
ramCapMb = SessionPrefs.ramCapMb(this)
zramClient = SessionPrefs.zramClient(this)
vramCapMb = SessionPrefs.vramCapMb(this)
gpuClockPin = SessionPrefs.gpuClockPin(this)
prootNoSeccomp = SessionPrefs.prootNoSeccomp(this)
prootFastPath = SessionPrefs.prootFastPath(this)
cursorComposite = SessionPrefs.cursorComposite(this)
guestHostname = SessionPrefs.guestHostname(this)
refreshPhantomStatus()
}
@@ -1981,6 +2114,7 @@ class MainActivity : ComponentActivity() {
addedGamesArt = SessionPrefs.addedGamesArt(this)
desktopInstalled = DesktopCatalog.desktopInstalled(this)
desktopPresent = DesktopCatalog.desktopPresent(this)
battleNetInstalled = com.droiddeck.launcher.runtime.BattleNet.installed(this)
offlineAccount = OfflineMode.account(this)
offline = OfflineMode.enabled(this)
installed = LinuxRuntimeInstaller.installedVersion(this)
@@ -1991,6 +2125,10 @@ class MainActivity : ComponentActivity() {
logsEnabled = SessionPrefs.logsEnabled(this)
vacDiagnostics = SessionPrefs.vacDiagnostics(this)
agentCommands = com.droiddeck.launcher.agent.AgentAccess.commandsEnabled(this)
clearCachedApps = SessionPrefs.clearCachedApps(this)
adbPaired = WirelessAdbFix.hasSavedPairing(this)
memoryHud = SessionPrefs.memoryHud(this)
backgroundAppLimit = SessionPrefs.backgroundAppLimit(this)
runningLabel = if (SessionState.running) when (SessionState.mode) {
SessionService.MODE_DESKTOP -> getString(R.string.rail_desktop)
SessionService.MODE_RUN -> Library.nameForProgram(SessionState.program)
@@ -2037,7 +2175,7 @@ class MainActivity : ComponentActivity() {
steamGamesX64 = scannedX64
steamGames = all.map { g ->
val newer = current[g.appId]
if (newer != null && g.library != Library.ADDED && steamArtRefreshes[g.appId] != artRefreshesAtStart[g.appId]) g.withArt(newer.art, newer.hero, newer.icon) else g
if (newer != null && g.library != Library.ADDED && steamArtRefreshes[g.appId] != artRefreshesAtStart[g.appId]) g.withArt(newer.art, newer.hero, newer.icon, newer.logo) else g
}
shortcutLibraryScanning = false
handleGameLink(all)
@@ -2088,6 +2226,7 @@ class MainActivity : ComponentActivity() {
private fun refreshPhantomStatus() {
phantomProcessStatus = PhantomProcessLimit.read(this)
adbPaired = WirelessAdbFix.hasSavedPairing(this)
phantomWarning = if (PhantomProcessLimit.blocksSteam(phantomProcessStatus)) {
getString(
R.string.phantom_warning, PhantomProcessLimit.title(this, phantomProcessStatus),
@@ -23,7 +23,7 @@ class HomeActivity : Activity() {
.setAction(SessionService.ACTION_HOME_GUIDE)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
} else {
Intent(this, MainActivity::class.java)
Intent(this, FrontEndActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
}
startActivity(destination)
@@ -8,7 +8,7 @@ object LauncherIcon {
const val DARK = "dark"
const val LIGHT = "light"
private val aliases = linkedMapOf(DARK to "com.droiddeck.launcher.LauncherDark", LIGHT to "com.droiddeck.launcher.LauncherLight")
private val aliases = linkedMapOf(DARK to "com.droiddeck.launcher.MainActivity", LIGHT to "com.droiddeck.launcher.LauncherLight")
val options: List<Pair<String, Int>> = listOf(
DARK to R.string.setup_launcher_icon_dark,
@@ -29,6 +29,12 @@ object LauncherIcon {
fun activity(context: Context): ComponentName = component(context, current(context))
fun repair(context: Context, launchedFrom: ComponentName?) {
if (!enabled(context, DARK) || !enabled(context, LIGHT) || launchedFrom == component(context, DARK)) return
context.packageManager.setComponentEnabledSetting(component(context, DARK),
PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP)
}
fun set(context: Context, style: String) {
if (style !in aliases || style == current(context)) return
val pm = context.packageManager
@@ -1,6 +1,8 @@
package com.droiddeck.launcher
import android.os.Handler
import android.util.Log
import androidx.annotation.VisibleForTesting
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
@@ -8,31 +10,48 @@ import androidx.compose.runtime.setValue
import com.droiddeck.launcher.session.ProtonExtras
import com.droiddeck.launcher.ui.ProtonRow
import com.droiddeck.launcher.session.SessionState
import com.droiddeck.launcher.session.SessionPhase
import com.droiddeck.launcher.frontend.AutoComponents
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.runtime.LinuxRuntimeInstaller
internal class ProtonMenu(private val activity: android.app.Activity, private val ui: android.os.Handler) {
internal class ProtonMenu(
private val activity: android.app.Activity,
private val ui: Handler,
private val idle: () -> Boolean = { true },
) {
var protonRows by mutableStateOf<List<ProtonRow>>(emptyList())
var protonBusyId by mutableStateOf<String?>(null)
var protonStage by mutableStateOf<String?>(null)
var protonPercent by mutableIntStateOf(-1)
private val checking = java.util.concurrent.atomic.AtomicBoolean(false)
@VisibleForTesting internal var installer: (ProtonExtras.Tool, (String, Int) -> Unit) -> String? =
{ tool, progress -> ProtonExtras.install(activity, tool, progress) }
fun installProton(id: String) {
fun installProton(id: String, automatic: Boolean = false) {
val tool = ProtonExtras.tools.firstOrNull { it.id == id } ?: return
if (protonBusyId != null || SessionState.running) return
if (protonBusyId != null || ProtonExtras.installInProgress || SessionState.running) return
ProtonExtras.unqueue(activity, tool)
protonBusyId = id
protonStage = activity.getString(R.string.store_starting)
protonPercent = -1
Thread({
val problem = ProtonExtras.install(activity, tool) { label, value ->
ui.post { protonStage = label; protonPercent = value }
}
val problem = runCatching {
installer(tool) { label, value ->
ui.post { protonStage = label; protonPercent = value }
}
}.getOrElse { it.message ?: it.javaClass.simpleName }
if (problem == null && !automatic && tool.id == ProtonExtras.OWN) ProtonExtras.setDeclined(activity, false)
ui.post {
protonBusyId = null
protonStage = null
protonPercent = -1
refreshProtons()
if (problem != null) android.widget.Toast.makeText(activity, problem, android.widget.Toast.LENGTH_LONG).show()
if (problem != null) {
if (automatic) Log.w("ProtonMenu", "auto-update ${tool.id}: $problem")
else android.widget.Toast.makeText(activity, problem, android.widget.Toast.LENGTH_LONG).show()
}
if (automatic) ensureAutoUpdates()
}
}, "install-proton-$id").start()
}
@@ -45,6 +64,7 @@ internal class ProtonMenu(private val activity: android.app.Activity, private va
protonPercent = -1
Thread({
if (older) ProtonExtras.removeOlder(activity, tool) else ProtonExtras.remove(activity, tool, dir)
if (tool.id == ProtonExtras.OWN && ProtonExtras.installedAll(activity, tool).isEmpty()) ProtonExtras.setDeclined(activity, true)
ui.post {
protonBusyId = null
protonStage = null
@@ -64,7 +84,38 @@ internal class ProtonMenu(private val activity: android.app.Activity, private va
if (!checking.compareAndSet(false, true)) return
Thread({
val asked = try { ProtonExtras.checkLatest(activity, force) } catch (_: Throwable) { false } finally { checking.set(false) }
if (asked) ui.post { refreshProtons() }
ui.post {
if (asked) refreshProtons()
ensureAutoUpdates()
}
}, "proton-updates").start()
}
internal fun ensureAutoUpdates(now: Long = System.currentTimeMillis()) {
if (activity.isFinishing || activity.isDestroyed || !idle() || protonBusyId != null ||
ProtonExtras.installInProgress || SessionState.running || LinuxRuntimeInstaller.isBusy() ||
SessionState.phase !in setOf(SessionPhase.IDLE, SessionPhase.FAILED) ||
!LinuxRuntime.isInstalled(activity) || !AutoComponents.online(activity) || AutoComponents.unfavourable(activity)) return
if (ProtonExtras.installedAll(activity, ProtonExtras.tools.first { it.id == ProtonExtras.OWN }).size > 1) {
protonBusyId = ProtonExtras.OWN
Thread({
ProtonExtras.tidy(activity)
val tidied = ProtonExtras.installedAll(activity, ProtonExtras.tools.first { it.id == ProtonExtras.OWN }).size <= 1
ui.post {
protonBusyId = null
refreshProtons()
if (tidied) ensureAutoUpdates()
}
}, "proton-tidy").start()
return
}
val attempts = activity.getSharedPreferences("proton-auto-updates", android.content.Context.MODE_PRIVATE)
val (tool, update) = ProtonExtras.tools.firstNotNullOfOrNull { tool ->
val update = ProtonExtras.update(activity, tool) ?: ProtonExtras.missing(activity, tool) ?: return@firstNotNullOfOrNull null
val previous = attempts.getLong("${tool.id}.at", 0L)
if (attempts.getString("${tool.id}.tag", null) != update || now - previous !in 0 until 15L * 60 * 1000) tool to update else null
} ?: return
attempts.edit().putString("${tool.id}.tag", update).putLong("${tool.id}.at", now).apply()
installProton(tool.id, automatic = true)
}
}
@@ -75,8 +75,10 @@ import com.droiddeck.launcher.input.OnScreenControls
import com.droiddeck.launcher.input.PadBridge
import com.droiddeck.launcher.input.PadMouse
import com.droiddeck.launcher.input.PadState
import com.droiddeck.launcher.input.ScreenKeys
import com.droiddeck.launcher.input.SteamControllerBle
import com.droiddeck.launcher.input.DirectTouchHold
import com.droiddeck.launcher.input.GestureTouchStreamGate
import com.droiddeck.launcher.input.PointerGestures
import com.droiddeck.launcher.input.SecondScreenDisplay
import com.droiddeck.launcher.input.SecondScreenDisplays
@@ -155,6 +157,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private lateinit var sessionOverlay: ComposeView
private lateinit var loading: LoadingState
private lateinit var hud: PerfHud
private lateinit var memoryHud: com.droiddeck.launcher.session.MemoryHud
private var padBridge: PadBridge? = null
private var joyConVr: com.droiddeck.launcher.input.JoyConVr? = null
private var handCamera: com.droiddeck.launcher.handtracking.HandCamera? = null
@@ -198,10 +201,20 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private var gestureConfig = com.droiddeck.launcher.input.TouchGestureConfig()
private var gestureGame: String? = null
private var gestureSettingsOpen by mutableStateOf(false)
private var screenKeyPickerOpen by mutableStateOf(false)
private var screenKeyBind: List<Int>? = null
private var screenKeyDir: Int? = null
private var screenKeyTextFocus = false
private var screenKeyEdit by mutableStateOf<com.droiddeck.launcher.input.ScreenKey?>(null)
private var screenLayoutsOpen by mutableStateOf(false)
private var screenKeysShown by mutableStateOf(true)
private var screenLayoutNames by mutableStateOf(listOf<String>())
private var screenKeys: ScreenKeys? = null
private var recognizedGesture by mutableStateOf<TouchscreenGestures.Gesture?>(null)
private var gesturePointerX = 0f
private var gesturePointerY = 0f
private var gesturePointerReady = false
private val gestureTouchStreamGate = GestureTouchStreamGate()
private val clearRecognizedGesture = Runnable { recognizedGesture = null }
private val gestureKeysDown = mutableSetOf<Int>()
private lateinit var scrollTouch: ScrollTouchGestures
@@ -270,6 +283,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
}
private val resumeKeysDown = mutableSetOf<Pair<Int, Int>>()
private val drawerShortcutKeysDown = mutableSetOf<Pair<Int, Int>>()
private var drawerOpen by mutableStateOf(false)
private var quitFrom by mutableStateOf<androidx.compose.ui.geometry.Rect?>(null)
@@ -280,6 +294,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private var drawerDefaultProton by mutableStateOf<String?>(null)
private val drawerComponentMenu by lazy { ComponentsMenu(this, uiHandler) }
private var drawerGameProfileId by mutableStateOf<Long?>(null)
private fun noSteamSession(): Boolean = SessionState.mode == SessionService.MODE_RUN &&
SessionState.program in setOf(com.droiddeck.launcher.session.NoSteamLaunch.PROTON_RUN, com.droiddeck.launcher.session.NoSteamLaunch.EPIC)
private val drawerGameInventory = GameProfileInventoryCache<Library.SteamGame>()
private var drawerGameInventoryRevision = 0L
private var drawerGameFexPresetSaving by mutableStateOf(false)
@@ -329,6 +346,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private var glassesSettings by mutableStateOf(Glasses.settings.value)
private var glassesScreen: GlassesScreen? = null
private var glassesOutputActive by mutableStateOf(false)
private var pausedFrame by mutableStateOf<com.droiddeck.launcher.ui.PausedFrame?>(null)
private var pausedFrameWanted = false
private var trackingRecovery by mutableStateOf<TrackingRecovery?>(null)
private var glasses3dForVr = false
private var ended = false
@@ -473,9 +492,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
com.droiddeck.launcher.gpu.SteamVrHealth.begin(this, SessionState.sessionId)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
if (!SessionState.running) {
SessionState.fpsLimit = SessionPrefs.fpsLimit(
this, SessionPrefs.prefMode(intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM),
)
val prefMode = SessionPrefs.prefMode(intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM)
SessionState.fpsLimit = SessionPrefs.fpsLimit(this, prefMode)
SessionState.performanceMode = SessionPrefs.performanceMode(this, prefMode)
}
applyOutputPacing()
if (!SessionState.running) SessionState.followScreen = followChoice()
@@ -532,6 +551,21 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
cursorVisible = false
}
onScreenControls = OnScreenControls(this, bridge, onKeyboard = ::togglePcKeyboard).also { root.addView(it) }
screenKeysShown = com.droiddeck.launcher.input.ControllerPrefs.screenKeysShown(this)
screenKeys = ScreenKeys(this) { code, down -> if (CompositorHost.isStarted) sendGuestKey(code, down) }.also { keys ->
keys.onTouchMiss = miss@{ event ->
if (routedTouchMode == SessionPrefs.TOUCH_GESTURES &&
!gestureTouchStreamGate.accepts(GestureTouchStreamGate.Source.SCREEN_KEYS, event)) return@miss
event.offsetLocation(keys.left - surfaceView.left.toFloat(), keys.top - surfaceView.top.toFloat())
onTouchEvent(event)
}
keys.onAdd = { screenKeyPickerOpen = true }
keys.onSelect = { codes -> screenKeyEdit = screenKeys?.stateOf(codes) }
keys.onEditingChanged = { if (!it) screenKeyEdit = null }
keys.steamStyle = { steamTouchControls?.style }
keys.visibility = if (screenKeysShown) View.VISIBLE else View.GONE
root.addView(keys)
}
keyboard = KeyboardHost(this).also { root.addView(it) }
touchpad = TouchpadGestures(PointerGestures.slop(this), pointerListener)
directTouchHold = DirectTouchHold(PointerGestures.slop(this), pointerListener) {
@@ -546,7 +580,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
// game surface and on-screen buttons, in either finger-down order.
root.isMotionEventSplittingEnabled = true
surfaceView.setOnTouchListener { _, event ->
routedTouchMode == SessionPrefs.TOUCH_GESTURES && onTouchEvent(event)
if (routedTouchMode != SessionPrefs.TOUCH_GESTURES) {
gestureTouchStreamGate.reset()
false
} else if (!gestureTouchStreamGate.accepts(GestureTouchStreamGate.Source.SURFACE, event)) {
false
} else onTouchEvent(event)
}
padMouse = PadMouse(padMouseListener)
bridge.setTouchRoute(object : PadBridge.TouchRoute {
@@ -570,17 +609,21 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
val needResume = !needRuntime && com.droiddeck.launcher.runtime.DesktopCatalog.interrupted(this).isNotEmpty()
val needDesktop = intent.getStringExtra(SessionService.EXTRA_MODE) == SessionService.MODE_DESKTOP &&
(repair || !com.droiddeck.launcher.runtime.DesktopCatalog.desktopInstalled(this))
val needProton = (intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM) == SessionService.MODE_STEAM &&
com.droiddeck.launcher.runtime.DesktopCatalog.protonSeedNeeded(this)
val program = intent.getStringExtra(SessionService.EXTRA_PROGRAM)
val protonUser = (intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM) == SessionService.MODE_STEAM ||
program == com.droiddeck.launcher.runtime.BattleNet.PROGRAM || program == com.droiddeck.launcher.session.NoSteamLaunch.PROTON_RUN
val needProton = protonUser && com.droiddeck.launcher.runtime.DesktopCatalog.protonSeedNeeded(this)
if (needRuntime || needResume || needDesktop || needProton) installThenStart(needRuntime, needDesktop, needProton, needResume)
}
hud = PerfHud(this)
memoryHud = com.droiddeck.launcher.session.MemoryHud(this)
hud.onPresentingWindowChanged = {
if (FrameGen.engine(this) != FrameGen.ENGINE_OFF) CompositorHost.rearmFrameGen { applyFrameGen() }
}
if (SessionState.running && SessionState.firstFrameSeen) {
loading.visible = false
hud.start()
memoryHud.start()
}
readPrefs()
@@ -675,7 +718,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize()) {
CursorOverlay({ cursorPos }, cursorVisible, resources.displayMetrics.density,
cursorImage, cursorHotX, cursorHotY, cursorImageScale)
if (hud.text.isNotEmpty() || hud.graph.size >= 2) HudText(hud.text, hud.graph, hudColor, hudOpacity)
val hudLines = listOf(hud.text, memoryHud.text).filter { it.isNotEmpty() }
if (hudLines.isNotEmpty() || hud.graph.size >= 2)
HudText(hudLines.joinToString("\n"), hud.graph, hudColor, hudOpacity)
val phonePanel = glassesOutputActive && !loading.visible && !drawerOpen && !pcKeyboardOpen
if (phonePanel) GlassesPhonePanel(glasses, steamVrStatus == SteamVr.Status.RUNNING, headTracking)
if (trackingRecovery != null && !drawerOpen && !pcKeyboardOpen && !loading.visible) {
@@ -731,6 +776,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
LaunchedEffect(drawerOpen, pcKeyboardOpen, achievementsOpen, gestureSettingsOpen, pipUi, SessionState.suspended) {
if (drawerOpen || pcKeyboardOpen || achievementsOpen || gestureSettingsOpen || pipUi || SessionState.suspended) {
cancelTouchGestures()
screenKeys?.stopEditing()
}
}
com.droiddeck.launcher.ui.GestureFeedback(recognizedGesture)
@@ -739,6 +785,84 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
onDismiss = { gestureSettingsOpen = false },
onSaved = { gestureSettingsOpen = false; refreshGestureConfig() },
)
if (screenKeyPickerOpen) {
val dir = screenKeyDir
com.droiddeck.launcher.ui.ScreenKeyPicker(
initial = if (dir != null) {
listOfNotNull((screenKeyEdit?.codes?.getOrNull(dir) ?: 0).takeIf { it != 0 })
} else {
screenKeyBind ?: emptyList()
},
onDismiss = { screenKeyPickerOpen = false; screenKeyBind = null; screenKeyDir = null },
onAdd = { codes ->
val bind = screenKeyBind
if (dir != null && bind != null) {
screenKeys?.setDirection(bind, dir, codes.lastOrNull() ?: 0)?.let { screenKeyEdit = it }
} else if (bind != null) {
screenKeys?.setCodes(bind, codes)?.let { screenKeyEdit = it }
} else {
screenKeys?.add(codes)
screenKeyEdit = screenKeys?.stateOf(codes)
}
screenKeyPickerOpen = false
screenKeyBind = null
screenKeyDir = null
},
)
}
screenKeyEdit?.let { key ->
com.droiddeck.launcher.ui.ScreenKeyEditor(
codes = key.codes, keyY = key.y,
size = key.size, opacity = key.opacity, shape = key.shape, toggle = key.toggle,
labels = key.labels,
text = key.text, hasImage = key.image.isNotEmpty(),
onResize = { screenKeys?.resize(key.codes, it); screenKeyEdit = screenKeys?.stateOf(key.codes) },
onOpacity = { screenKeys?.setOpacity(key.codes, it); screenKeyEdit = screenKeys?.stateOf(key.codes) },
onShape = { screenKeys?.setShape(key.codes, it); screenKeyEdit = screenKeys?.stateOf(key.codes) },
onToggle = { on -> screenKeys?.setToggle(key.codes, on); screenKeyEdit = screenKeys?.stateOf(key.codes) },
onLabels = { on -> screenKeys?.setLabels(key.codes, on); screenKeyEdit = screenKeys?.stateOf(key.codes) },
onDirection = { index -> screenKeyBind = key.codes; screenKeyDir = index; screenKeyPickerOpen = true },
onText = { screenKeys?.setText(key.codes, it); screenKeyEdit = screenKeys?.stateOf(key.codes) },
onImage = { screenKeys?.setImage(key.codes, it); screenKeyEdit = screenKeys?.stateOf(key.codes) },
onClearImage = { screenKeys?.setImage(key.codes, ""); screenKeyEdit = screenKeys?.stateOf(key.codes) },
onBind = { screenKeyBind = key.codes; screenKeyDir = null; screenKeyPickerOpen = true },
onTextFocus = { screenKeyTextFocus = true; keyboard?.hide() },
onTextBlur = { screenKeyTextFocus = false },
onRemove = { screenKeys?.remove(key.codes); screenKeyEdit = null },
onDismiss = { screenKeyEdit = null; screenKeyTextFocus = false; screenKeys?.deselect() },
)
}
if (screenLayoutsOpen) com.droiddeck.launcher.ui.ScreenLayoutPanel(
layouts = screenLayoutNames,
onSave = { name ->
screenKeys?.let { keys ->
com.droiddeck.launcher.input.ControllerPrefs.saveScreenLayout(this@SessionActivity, name, keys.export())
}
screenLayoutNames = layoutNames()
},
onLoad = { name ->
com.droiddeck.launcher.input.ControllerPrefs.screenLayouts(this@SessionActivity)[name]?.let { screenKeys?.load(it) }
screenLayoutsOpen = false
},
onDelete = { name ->
com.droiddeck.launcher.input.ControllerPrefs.deleteScreenLayout(this@SessionActivity, name)
screenLayoutNames = layoutNames()
},
onImport = { text ->
val width = (screenKeys?.width ?: 0).takeIf { it > 0 } ?: resources.displayMetrics.widthPixels
val keys = com.droiddeck.launcher.input.importScreenLayout(text, width, resources.displayMetrics.density)
if (keys != null) {
screenKeys?.load(com.droiddeck.launcher.input.ControllerPrefs.encodeScreenKeys(keys))
screenLayoutsOpen = false
}
},
exportText = {
val width = (screenKeys?.width ?: 0).takeIf { it > 0 } ?: resources.displayMetrics.widthPixels
val keys = com.droiddeck.launcher.input.ControllerPrefs.decodeScreenKeys(screenKeys?.export().orEmpty())
com.droiddeck.launcher.input.exportScreenLayout(keys, width, resources.displayMetrics.density)
},
onDismiss = { screenLayoutsOpen = false },
)
androidx.compose.runtime.LaunchedEffect(drawerOpen) {
if (drawerOpen) {
syncLossless()
@@ -785,6 +909,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
)
SessionDrawer(drawerOpen, drawerPage, drawerControllerActive, onPageChange = { drawerPage = it }, a = DrawerActions(
steam = SessionState.mode == SessionService.MODE_STEAM,
direct = noSteamSession(),
title = if (SessionState.mode == SessionService.MODE_RUN)
com.droiddeck.launcher.frontend.Library.nameForProgram(SessionState.program) ?: getString(R.string.session_title_game) else null,
isHomeApp = isHomeApp,
@@ -816,6 +941,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
lossless = lossless,
oscMode = oscMode, onScreenButtonsVisible = onScreenButtonsVisible, suspendPolicy = suspendPolicy,
steamDownloadsInBackground = steamDownloadsInBackground, touchMode = touchMode,
micAvailable = SessionState.micAvailable && !SessionState.suspended,
micEnabled = SessionState.micEnabled,
onMicEnabled = { SessionService.setMicEnabled(this@SessionActivity, it) },
touchAuto = getString(if (usingTouchpad()) R.string.session_touch_auto_touchpad else R.string.session_touch_auto_direct),
fexPreset = fexPreset,
secondScreenMode = secondScreenMode,
@@ -926,6 +1054,19 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
},
onTouch = { v -> SessionPrefs.setTouchMode(this@SessionActivity, v); readPrefs() },
onGestureSettings = { drawerOpen = false; gestureSettingsOpen = true },
onScreenKeys = { drawerOpen = false; screenKeys?.startEditing() },
screenKeysShown = screenKeysShown,
onScreenKeysShown = { shown ->
screenKeysShown = shown
com.droiddeck.launcher.input.ControllerPrefs.setScreenKeysShown(this@SessionActivity, shown)
screenKeys?.stopEditing()
screenKeys?.visibility = if (shown) View.VISIBLE else View.GONE
},
onScreenLayouts = {
drawerOpen = false
screenLayoutNames = layoutNames()
screenLayoutsOpen = true
},
onFexPreset = { v -> SessionPrefs.setFexPreset(this@SessionActivity, v); readPrefs() },
onSecondScreenMode = ::selectSecondScreenMode,
onSecondScreenDisplay = ::selectSecondScreenDisplay,
@@ -1013,9 +1154,20 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
SessionAchievements(inFront.game, inFront.title, onClose = ::closeAchievements)
}
com.droiddeck.launcher.ui.AchievementNotificationOverlay()
if (SessionState.suspended) SessionPausedOverlay(
// Auto and Native resume on their own as the session comes back; only a failed resume needs the button.
val autoResume = suspendPolicy == SessionPrefs.SUSPEND_AUTO || suspendPolicy == SessionPrefs.SUSPEND_NATIVE
SessionPausedOverlay(
suspended = SessionState.suspended && (!autoResume || SessionState.resumeFailed),
engaged = SessionState.resumeRequested || SessionState.resuming,
failures = SessionState.resumeFailures,
frame = pausedFrame,
title = pausedTitle(),
onResume = { SessionService.resume(this@SessionActivity) },
onFrameNeeded = ::requestPausedFrame,
onHidden = {
pausedFrame = null
pausedFrameWanted = false
},
)
if (leaving) com.droiddeck.launcher.ui.FloodSpread(
com.droiddeck.launcher.ui.LocalPalette.current.signal,
@@ -1225,6 +1377,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
val activeAppId = AgentGuest.activeSteamAppId(this, installedAppIds)
val selectedSteamAppId = runCatching { AgentGuest.selectedSteamAppId(this) }
AgentGuest.selectedOrActiveInstalledSteamAppId(selectedSteamAppId, activeAppId, installedAppIds)
?: SessionState.gameId?.toLongOrNull()
?: refresh.requestedAppId
} else {
refresh.requestedAppId
@@ -1471,7 +1624,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (nextTouchMode == SessionPrefs.TOUCH_OFF || !usingTouchpad()) cursorVisible = false
}
touchMode = nextTouchMode
routedTouchMode = effectiveTouchMode()
val nextRoutedTouchMode = effectiveTouchMode()
if (routedTouchMode != nextRoutedTouchMode) gestureTouchStreamGate.reset()
routedTouchMode = nextRoutedTouchMode
frameGen = FrameGen.mode(this)
fexPreset = SessionPrefs.fexPreset(this)
suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode)
@@ -1693,7 +1848,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
com.droiddeck.launcher.gpu.DriverSetupLog.write(this, "compositor.start driver=$driverId")
CompositorHost.startOrAttach(
holder.surface, runtimeDir.path,
this, holder.surface, runtimeDir.path,
driverId?.let { turnip.driverPath(it) }, driverId?.let { turnip.libraryName(it) },
applicationInfo.nativeLibraryDir, size.first, size.second, refreshHz(), outputFpsLimit(),
)
@@ -1711,6 +1866,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
intent.getStringExtra(SessionService.EXTRA_STEAM_UI),
intent.getStringExtra(SessionService.EXTRA_STEAM_URL),
intent.getStringArrayExtra(SessionService.EXTRA_PROGRAM_ARGS),
intent.getStringExtra(SessionService.EXTRA_GAME_ID),
sessionActivityDisplayId,
)
}
@@ -1827,7 +1983,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private fun mirrorRefreshLimit(): Int =
GlassesRendering.mirrorRefreshLimit(glassesSettings, glasses, glassesScreen != null)
private fun outputFpsLimit(): Int = GlassesRendering.fpsLimit(SessionState.fpsLimit, mirrorRefreshLimit())
private fun outputFpsLimit(): Int = SessionPrefs.compositorFpsLimit(
GlassesRendering.fpsLimit(SessionState.fpsLimit, mirrorRefreshLimit()), SessionState.performanceMode,
)
private fun applyOutputPacing() {
Log.i(TAG, "perf: " + PerfMode.apply(this, outputFpsLimit(), mirrorRefreshLimit()))
@@ -1880,7 +2038,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun run() {
if (!watching) return
if (::pip.isInitialized) pip.refresh()
if (SessionState.stopRequested && !SessionState.running && !installingRuntime) {
if (SessionState.stopRequested && !SessionState.running && !installingRuntime &&
SessionState.phase != SessionPhase.STOPPING) {
SessionState.stopRequested = false
SessionEvents.transition(SessionPhase.IDLE, "session.cancelled")
collectStartArtifacts("session start cancelled")
@@ -1913,6 +2072,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
SessionState.relaunch?.let { next ->
SessionState.relaunch = null
next.putExtra(EXTRA_RETURN_HOME, intent.getBooleanExtra(EXTRA_RETURN_HOME, false))
next.putExtra(EXTRA_LINK_TASK, intent.getIntExtra(EXTRA_LINK_TASK, -1))
setIntent(next)
recreate()
return@runOnUiThread
@@ -1944,7 +2104,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
repairOffered = hint?.repair == true
loading.showEnded(
hint?.text ?: stopped,
getString(R.string.session_ended_detail, status, SessionState.logFile?.path ?: getString(R.string.session_ended_no_log)),
getString(R.string.session_ended_detail, status,
SessionState.logFile?.path?.let { android.text.BidiFormatter.getInstance().unicodeWrap(it, android.text.TextDirectionHeuristics.LTR) }
?: getString(R.string.session_ended_no_log)),
)
focusEndedScreen()
}
@@ -2023,6 +2185,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return super.dispatchKeyEvent(event)
}
if (pipUi) return true
if (screenKeyTextFocus) return super.dispatchKeyEvent(event)
val pad = com.droiddeck.launcher.input.JoyCon.normalize(event)
if (pad !== event) return dispatchKeyEvent(pad)
val fromController = PadBridge.isControllerKeyEvent(event)
@@ -2044,6 +2207,20 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
return super.dispatchKeyEvent(event)
}
val drawerShortcutKey = event.deviceId to event.keyCode
if (fromController && (event.keyCode == KeyEvent.KEYCODE_BUTTON_SELECT || event.keyCode == KeyEvent.KEYCODE_BUTTON_START) &&
((drawerOpen && SessionState.mode == SessionService.MODE_STEAM) || drawerShortcutKey in drawerShortcutKeysDown)) {
if (event.action == KeyEvent.ACTION_DOWN) {
drawerShortcutKeysDown.add(drawerShortcutKey)
if (event.repeatCount == 0) {
releaseDrawerDirection()
if (event.keyCode == KeyEvent.KEYCODE_BUTTON_SELECT) sendSteamGuide() else triggerSteamQam()
}
} else if (event.action == KeyEvent.ACTION_UP) {
drawerShortcutKeysDown.remove(drawerShortcutKey)
}
return true
}
if (drawerOpen) {
if (fromController && (event.keyCode == KeyEvent.KEYCODE_BUTTON_L1 || event.keyCode == KeyEvent.KEYCODE_BUTTON_R1)) {
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
@@ -2210,9 +2387,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
super.dispatchKeyEvent(event)
}
private fun layoutNames(): List<String> =
com.droiddeck.launcher.input.ControllerPrefs.screenLayouts(this).keys.sorted()
private fun refreshGestureConfig() {
gestureConfig = com.droiddeck.launcher.input.GesturePrefs.read(this, gestureGame)
gestureTouchStreamGate.reset()
if (::touchscreenGestures.isInitialized) touchscreenGestures.configure(gestureConfig)
if (::directTouchHold.isInitialized) directTouchHold.configure(gestureConfig)
gesturePointerReady = false
recognizedGesture = null
}
@@ -2500,6 +2682,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private fun refreshControls() {
applyUnfoldedControls()
updateOnScreenControls()
screenKeys?.refreshAppearance()
}
private fun applyUnfoldedControls() {
@@ -2585,6 +2768,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (::scrollTouch.isInitialized) scrollTouch.cancel()
if (CompositorHost.isStarted) WaylandCompositor.nativeSendTouch(3, -1, 0, 0)
if (next == SessionPrefs.TOUCH_OFF) cursorVisible = false
gestureTouchStreamGate.reset()
routedTouchMode = next
}
@@ -2775,6 +2959,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
device.plugged = false
uiHandler.postDelayed({ updateOnScreenControls() }, 700)
}
view.onStyle = { screenKeys?.refreshAppearance() }
root.addView(view, root.indexOfChild(onScreenControls) + 1)
steamTouchControls = view
steamTouchAvailable = true
@@ -2805,6 +2990,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
controls.visibility = View.GONE
steamTouchControls?.let { it.releaseAll(); it.visibility = View.GONE }
com.droiddeck.launcher.input.SteamTouchDevice.current?.plugged = false
screenKeys?.followSteam = false
applyTouchRouting()
return
}
@@ -2833,6 +3019,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
onScreenButtonsVisible = show
val drawn = show && !keyboardCoversPad()
if (steam != null) {
screenKeys?.followSteam = drawn
if (controls.visibility == View.VISIBLE) { controls.releaseAll(); controls.visibility = View.GONE }
if (!drawn) steam.releaseAll()
steam.visibility = if (drawn) View.VISIBLE else View.GONE
@@ -2841,6 +3028,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
Log.i(TAG, "on-screen controls: Steam's touch controller " + if (drawn) "shown" else "hidden")
return
}
screenKeys?.followSteam = false
if (drawn == (controls.visibility == View.VISIBLE)) return
if (!drawn) controls.releaseAll()
controls.visibility = if (drawn) View.VISIBLE else View.GONE
@@ -2860,6 +3048,50 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (down || code !in gestureKeysDown) WaylandCompositor.nativeSendKey(code, if (down) 1 else 0)
}
// The frozen game cannot draw again, so the paused overlay shows a copy taken while the surface still holds its frame.
private fun capturePausedFrame() {
if (!SessionState.running || pipUi || isInPictureInPictureMode || glassesOutputActive) return
if (SessionState.suspended && pausedFrame != null) return
if (suspendPolicy == SessionPrefs.SUSPEND_NEVER || !::surfaceView.isInitialized) return
if (surfaceView.holder.surface?.isValid != true) return
val width = surfaceView.width
val height = surfaceView.height
if (width <= 0 || height <= 0) return
val scale = minOf(1f, PAUSED_FRAME_MAX_EDGE / maxOf(width, height).toFloat())
val bitmap = try {
android.graphics.Bitmap.createBitmap(
maxOf(1, (width * scale).toInt()), maxOf(1, (height * scale).toInt()), android.graphics.Bitmap.Config.ARGB_8888,
)
} catch (_: OutOfMemoryError) {
return
}
val surfaceAt = IntArray(2).also { surfaceView.getLocationInWindow(it) }
val overlayAt = IntArray(2).also { sessionOverlay.getLocationInWindow(it) }
val left = surfaceAt[0] - overlayAt[0]
val top = surfaceAt[1] - overlayAt[1]
val bounds = android.graphics.Rect(left, top, left + width, top + height)
try {
android.view.PixelCopy.request(surfaceView, bitmap, { result ->
if (result == android.view.PixelCopy.SUCCESS && !isDestroyed) {
pausedFrame = com.droiddeck.launcher.ui.PausedFrame(bitmap.asImageBitmap(), bounds)
pausedFrameWanted = false
} else {
bitmap.recycle()
}
}, uiHandler)
} catch (_: IllegalArgumentException) {
bitmap.recycle()
}
}
// Sessions that froze from picture-in-picture, or before onPause could copy a frame, have none; the
// compositor shows the frozen frame again once its output is back, so copy it from there.
private fun requestPausedFrame() {
if (pausedFrame != null) return
pausedFrameWanted = true
if (resumed && surfaceW > 0) capturePausedFrame()
}
private fun pipBounds(): android.graphics.Rect {
val rect = android.graphics.Rect()
if (!::surfaceView.isInitialized || !surfaceView.getGlobalVisibleRect(rect)) return rect
@@ -2882,6 +3114,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
pendingBackAction = null
releaseDrawerDirection()
onScreenControls?.releaseAll()
screenKeys?.releaseAll()
releasePads()
touchpad.cancel()
scrollTouch.cancel()
@@ -2970,6 +3203,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return
}
if (this.intent.getBooleanExtra(EXTRA_RETURN_HOME, false)) intent.putExtra(EXTRA_RETURN_HOME, true)
if (!intent.hasExtra(EXTRA_LINK_TASK)) intent.putExtra(EXTRA_LINK_TASK, this.intent.getIntExtra(EXTRA_LINK_TASK, -1))
setIntent(intent)
if (intent.getBooleanExtra(EXTRA_STEAMVR, false) && SessionState.mode == SessionService.MODE_STEAM) {
SessionState.steamVr = true
@@ -2990,8 +3224,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
applyFollowOrientation(resources.configuration)
if (runtimeRemovalBlocked) return
if (!SessionState.suspended) pausedFrame = null
com.droiddeck.launcher.ui.Motion.refresh(this)
refreshHomeApp()
if (::memoryHud.isInitialized && SessionState.running && SessionState.firstFrameSeen) memoryHud.refresh()
if (intent?.action == SessionService.ACTION_RESUME) {
intent.action = null
SessionService.resume(this)
@@ -3015,6 +3251,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun onPause() {
if (runtimeRemovalBlocked) { super.onPause(); return }
capturePausedFrame()
sessionClipboard.stop()
if (::touchscreenGestures.isInitialized) touchscreenGestures.cancel()
if (::touchpad.isInitialized) touchpad.cancel()
@@ -3253,6 +3490,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
surfaceView.visibility = if (glassesOutputActive) View.INVISIBLE else View.VISIBLE
Log.i(TAG, "compositor output attached: " +
if (glassesOutputActive) "glasses display ${glassesScreen?.display?.displayId}" else "phone")
if (pausedFrameWanted) uiHandler.postDelayed({
if (pausedFrameWanted && pausedFrame == null && resumed) capturePausedFrame()
}, PAUSED_FRAME_REDRAW_MS)
}
private fun restorePhoneOutput(screen: GlassesScreen) {
@@ -3433,8 +3673,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
SessionEvents.firstFrame()
if (SessionState.mode == SessionService.MODE_STEAM) SteamRepair.clientShown(this)
runOnUiThread {
if (isFinishing || isDestroyed) return@runOnUiThread
loading.visible = false
hud.start()
memoryHud.start()
}
}
@@ -3452,6 +3694,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
glassesScreen?.let { screen -> glassesScreen = null; runCatching { screen.dismiss() } }
headTracker?.stop()
if (::hud.isInitialized) hud.stop()
if (::memoryHud.isInitialized) memoryHud.stop()
joyConVr?.close()
joyConVr = null
handCamera?.stop()
@@ -3487,17 +3730,33 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
return
}
super.finish()
if (!closeAtOnce && intent.getBooleanExtra(EXTRA_RETURN_HOME, false)) {
com.droiddeck.launcher.ui.QuitFlood.take()
com.droiddeck.launcher.ui.LaunchOrigin.flooding = null
startActivity(Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
closeLinkTasks()
if (!isTaskRoot) moveTaskToBack(true)
super.finishAndRemoveTask()
} else super.finish()
if (quitFlooded) overridePendingTransition(0, 0)
else overridePendingTransition(R.anim.session_hold, R.anim.session_sink)
}
private fun closeLinkTasks() {
val linkTask = intent.getIntExtra(EXTRA_LINK_TASK, -1)
if (linkTask == -1) return
val tasks = runCatching { getSystemService(android.app.ActivityManager::class.java)?.appTasks }.getOrNull().orEmpty()
for (task in tasks) {
runCatching {
val info = task.taskInfo
@Suppress("DEPRECATION")
val id = if (android.os.Build.VERSION.SDK_INT >= 29) info.taskId else info.persistentId
val base = info.baseIntent
if (id == linkTask && base.component?.className == FrontEndActivity::class.java.name &&
com.droiddeck.launcher.frontend.GameLaunchIntent.accepts(base.action)) task.finishAndRemoveTask()
}
}
}
private fun completeLeave() {
if (isFinishing) return
val signal = com.droiddeck.launcher.ui.Themes.current(this).signal
@@ -3509,9 +3768,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
companion object {
const val EXTRA_RETURN_HOME = "returnHome"
const val EXTRA_LINK_TASK = "linkTask"
const val EXTRA_STEAMVR = "steamVr"
const val EXTRA_REPAIR_RUNTIME = "repairRuntime"
private const val LEAVE_TIMEOUT_MS = 1_500L
private const val PAUSED_FRAME_MAX_EDGE = 1920f
private const val PAUSED_FRAME_REDRAW_MS = 150L
private const val TAG = "SessionActivity"
private var startedScreens = 0
private const val FEX_MISSING = "droiddeck-fex: x86 Linux programs need"
@@ -29,6 +29,7 @@ internal class UpdatesMenu(private val activity: Activity, private val ui: Handl
private var pending: AppUpdates.Release? = null
private var lastCheck = 0L
private var request = 0
@Volatile private var session = -1
fun state() = UpdatesState(catalog, follow, checking, error, stage, percent, askPermission)
@@ -87,13 +88,14 @@ internal class UpdatesMenu(private val activity: Activity, private val ui: Handl
if (SessionState.running) { error = activity.getString(R.string.upd_stop_first); return }
if (!SelfInstaller.canInstall(activity)) { pending = r; askPermission = true; return }
error = null
session = -1
stage = activity.getString(R.string.upd_stage_downloading)
percent = 0
Thread({
val result = runCatching {
val file = SelfInstaller.download(activity, apk) { pct -> ui.post { percent = pct } }
ui.post { stage = activity.getString(R.string.upd_stage_installing); percent = -1 }
SelfInstaller.install(activity, file)
SelfInstaller.install(activity, file) { id -> session = id }
}
result.onFailure { e -> ui.post { stage = null; error = e.message ?: activity.getString(R.string.upd_failed) } }
}, "app-update-install").start()
@@ -101,13 +103,15 @@ internal class UpdatesMenu(private val activity: Activity, private val ui: Handl
private val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.getIntExtra(PackageInstaller.EXTRA_SESSION_ID, -1).let { it < 0 || it != session }) return
when (val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)) {
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
val confirm = if (Build.VERSION.SDK_INT >= 33) intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
else @Suppress("DEPRECATION") intent.getParcelableExtra(Intent.EXTRA_INTENT)
if (confirm == null) { stage = null; error = activity.getString(R.string.upd_not_offered); return }
val safe = confirm?.let { SelfInstaller.safeConfirmIntent(activity, it) }
if (safe == null) { stage = null; error = activity.getString(R.string.upd_not_offered); return }
stage = activity.getString(R.string.upd_stage_confirm)
activity.startActivity(confirm.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
if (runCatching { activity.startActivity(safe) }.isFailure) { stage = null; error = activity.getString(R.string.upd_not_offered) }
}
PackageInstaller.STATUS_SUCCESS -> stage = null
else -> {
@@ -3,9 +3,13 @@ package com.droiddeck.launcher.agent
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.runtime.ProotFastPath
import com.droiddeck.launcher.session.SessionPhase
import com.droiddeck.launcher.session.SessionState
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.security.MessageDigest
import java.nio.file.Files
import java.nio.file.LinkOption.NOFOLLOW_LINKS
import java.nio.file.StandardOpenOption.READ
@@ -55,6 +59,9 @@ object AgentEnv {
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 '-'")
if (name == "libblfastpath.so" && (SessionState.running || SessionState.guestPid > 0 || SessionState.phase == SessionPhase.STOPPING)) {
throw AgentException("SESSION_ACTIVE", "Stop the session before installing a fast-path library")
}
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")
@@ -67,6 +74,10 @@ object AgentEnv {
staged.delete()
throw AgentException("OVERRIDE_FAILED", "Could not stage the override")
}
if (name == "libblfastpath.so" && !arm64SharedLibrary(staged)) {
staged.delete()
throw AgentException("INVALID_LIBRARY", "The fast path must be a 64-bit little-endian ARM64 ELF shared library")
}
if (!staged.setExecutable(true, false) || !staged.renameTo(target)) {
staged.delete()
throw AgentException("OVERRIDE_FAILED", "Could not install $name in the rootfs")
@@ -76,6 +87,40 @@ object AgentEnv {
state.put("pendingOverrides", JSONArray(merge(strings(state.optJSONArray("pendingOverrides")), listOf(name))))
save(context, state)
state.put("installed", "$GUEST_BIN/$name")
if (name == "libblfastpath.so") {
val digest = MessageDigest.getInstance("SHA-256")
target.inputStream().use { input ->
val buffer = ByteArray(65536)
while (true) {
val count = input.read(buffer)
if (count < 0) break
digest.update(buffer, 0, count)
}
}
state.put("sha256", digest.digest().joinToString("") { "%02x".format(it) })
}
state
}
fun fastPathLibrary(context: Context): String = synchronized(lock) {
if (!AgentAccess.commandsAllowed(context)) return@synchronized ProotFastPath.LIBRARY
if ("libblfastpath.so" !in strings(load(context).optJSONArray("activeOverrides"))) return@synchronized ProotFastPath.LIBRARY
val file = File(bin(context), "libblfastpath.so")
if (!file.isFile || Files.isSymbolicLink(file.toPath())) return@synchronized ProotFastPath.LIBRARY
"$GUEST_BIN/libblfastpath.so"
}
private fun arm64SharedLibrary(file: File): Boolean = file.inputStream().use { input ->
val header = ByteArray(20)
var count = 0
while (count < header.size) {
val read = input.read(header, count, header.size - count)
if (read < 0) return@use false
count += read
}
header.take(7) == listOf<Byte>(0x7f, 0x45, 0x4c, 0x46, 2, 1, 1) &&
header[16] == 3.toByte() && header[17] == 0.toByte() &&
header[18] == 183.toByte() && header[19] == 0.toByte()
}
fun beginSession(context: Context): List<String> = synchronized(lock) {
@@ -86,13 +131,13 @@ object AgentEnv {
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)
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)}"
val withPath = if (overrides.none { it != "libblfastpath.so" }) lines else lines + "PATH=$GUEST_BIN:${pathOf(lines)}"
state.put("active", JSONArray(active)).put("pending", JSONArray())
state.put("activeOverrides", JSONArray(overrides)).put("pendingOverrides", JSONArray())
save(context, state)
if (withPath.isNotEmpty()) Log.i(TAG, "agent environment keys for this session: ${withPath.map { it.substringBefore('=') }}")
withPath
}
@@ -3,6 +3,7 @@ package com.droiddeck.launcher.audio;
import android.os.Process;
import android.util.Log;
import com.droiddeck.launcher.core.FileUtils;
import com.droiddeck.launcher.core.SessionPart;
import com.droiddeck.launcher.core.HostProcess;
@@ -17,10 +18,15 @@ public class DirectAudioRelayComponent extends SessionPart {
private File logFile;
private final File micFifoPath;
private volatile int pid = -1;
private final Object micLock = new Object();
private boolean micHot;
private final File micControl;
public java.util.function.Consumer<Boolean> onMicEnabled;
public DirectAudioRelayComponent(File socketPath, File micFifoPath) {
this.socketPath = socketPath;
this.micFifoPath = micFifoPath;
this.micControl = micFifoPath == null ? null : new File(micFifoPath.getParentFile(), "mic-enabled");
}
public void setLogFile(File file) {
@@ -30,6 +36,9 @@ public class DirectAudioRelayComponent extends SessionPart {
@Override
public void start() {
stop();
synchronized (micLock) {
micHot = false;
}
File binary = new File(app().getApplicationInfo().nativeLibraryDir, BINARY);
if (!binary.isFile()) {
Log.w(TAG, "helper missing at " + binary + "; DirectAudio will report itself unavailable");
@@ -58,8 +67,13 @@ public class DirectAudioRelayComponent extends SessionPart {
StringBuilder command = new StringBuilder(binary.getAbsolutePath());
command.append(" --socket ").append(socketPath.getAbsolutePath());
command.append(" --log");
if (micFifoPath != null) {
final boolean withMic = micFifoPath != null && setMicEnabled(true);
if (withMic) {
command.append(" --mic-fifo ").append(micFifoPath.getAbsolutePath());
command.append(" --mic-control ").append(micControl.getAbsolutePath());
} else if (micFifoPath != null) {
Log.w(TAG, "microphone control unavailable; starting with capture disabled");
command.append(" --mic-control /dev/null");
}
ArrayList<String> env = new ArrayList<>();
env.add("HOME=" + app().getFilesDir());
@@ -68,7 +82,7 @@ public class DirectAudioRelayComponent extends SessionPart {
try {
out = new java.io.PrintWriter(new java.io.FileWriter(logFile, true));
out.println("== DirectAudio relay helper starting"
+ (micFifoPath != null ? " with the microphone" : " (no microphone)"));
+ (withMic ? " with the microphone" : " (no microphone)"));
out.flush();
} catch (Exception e) {
Log.w(TAG, "could not open " + logFile, e);
@@ -79,10 +93,47 @@ public class DirectAudioRelayComponent extends SessionPart {
app().getFilesDir(), null,
line -> {
Log.i(TAG, line);
if (withMic) trackMic(line);
if (log != null) synchronized (log) { log.println(line); log.flush(); }
});
Log.i(TAG, "started pid=" + pid + " socket=" + socketPath
+ (micFifoPath != null ? " mic=" + micFifoPath : " (no microphone)"));
+ (withMic ? " mic=" + micFifoPath : " (no microphone)"));
}
public synchronized boolean setMicEnabled(boolean enabled) {
if (micControl == null) return false;
File next = new File(micControl.getPath() + ".new");
return FileUtils.writeString(next, enabled ? "1" : "0") && next.renameTo(micControl);
}
private void trackMic(String line) {
if (line.startsWith("DA-Relay: mic enabled=") && onMicEnabled != null) {
onMicEnabled.accept(line.equals("DA-Relay: mic enabled=1"));
}
boolean start = line.startsWith("DA-Relay: mic start ");
if (!start && !line.startsWith("DA-Relay: mic stop ")) return;
synchronized (micLock) {
micHot = start;
micLock.notifyAll();
}
}
// Android keeps the input stream's wake lock while a frozen helper still has it started.
public boolean awaitMicReleased(long timeoutMs) {
long deadline = System.currentTimeMillis() + timeoutMs;
synchronized (micLock) {
while (micHot) {
long left = deadline - System.currentTimeMillis();
if (left <= 0) return false;
try {
micLock.wait(left);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
}
return true;
}
}
@Override
@@ -20,6 +20,7 @@ import java.util.concurrent.TimeUnit;
public class PulseAudioComponent extends SessionPart {
private static final String TAG = "PulseAudio";
public static final String SOCKET_NAME = "PS0";
private static final String MIC_SOURCE = "DirectAudioMic";
private static final String BUNDLE_STAMP = "2026-10-08-pa13-directaudio-native";
@@ -97,9 +98,9 @@ public class PulseAudioComponent extends SessionPart {
config.add("set-default-sink DirectAudio");
config.add(".fail");
if (micFifoPath != null && !micFifoPath.isEmpty()) {
config.add("load-module module-pipe-source source_name=DirectAudioMic file=\""
config.add("load-module module-pipe-source source_name=" + MIC_SOURCE + " file=\""
+ micFifoPath + "\" format=s16le rate=48000 channels=1");
config.add("set-default-source DirectAudioMic");
config.add("set-default-source " + MIC_SOURCE);
}
FileUtils.writeString(new File(workingDir, "default.pa"), String.join("\n", config));
@@ -202,16 +203,24 @@ public class PulseAudioComponent extends SessionPart {
}
public boolean setSinkSuspended(boolean suspended) {
return suspend("sink", "@DEFAULT_SINK@", suspended);
}
public boolean setMicSuspended(boolean suspended) {
return micFifoPath == null || micFifoPath.isEmpty() || suspend("source", MIC_SOURCE, suspended);
}
private boolean suspend(String kind, String name, boolean suspended) {
File pactl = new File(workingDir, "pactl");
if (!pactl.isFile() || pid <= 1) {
Log.w(TAG, "cannot change sink state: PulseAudio control client or server unavailable");
Log.w(TAG, "cannot change " + kind + " state: PulseAudio control client or server unavailable");
return false;
}
java.lang.Process process = null;
try {
File modules = new File(workingDir, "modules/arm64");
ProcessBuilder builder = new ProcessBuilder(
pactl.getAbsolutePath(), "suspend-sink", "@DEFAULT_SINK@", Boolean.toString(suspended));
pactl.getAbsolutePath(), "suspend-" + kind, name, Boolean.toString(suspended));
builder.directory(workingDir);
builder.redirectErrorStream(true);
builder.redirectOutput(new File("/dev/null"));
@@ -222,18 +231,18 @@ public class PulseAudioComponent extends SessionPart {
process = builder.start();
if (!process.waitFor(5, TimeUnit.SECONDS)) {
process.destroyForcibly();
Log.w(TAG, "timed out changing sink state to suspended=" + suspended);
Log.w(TAG, "timed out changing " + kind + " state to suspended=" + suspended);
return false;
}
int status = process.exitValue();
if (status != 0) Log.w(TAG, "sink state change failed with exit " + status);
if (status != 0) Log.w(TAG, kind + " state change failed with exit " + status);
return status == 0;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
Log.w(TAG, "interrupted changing sink state", e);
Log.w(TAG, "interrupted changing " + kind + " state", e);
return false;
} catch (Exception e) {
Log.w(TAG, "could not change sink state", e);
Log.w(TAG, "could not change " + kind + " state", e);
return false;
} finally {
if (process != null && process.isAlive()) process.destroyForcibly();
@@ -0,0 +1,59 @@
package com.droiddeck.launcher.core
import android.content.Context
import android.util.Log
import java.util.concurrent.Executors
object BackgroundAppLimit {
private const val TAG = "BackgroundAppLimit"
const val OFF = -1
val CHOICES = listOf(OFF, 4, 1, 0)
private const val KEY = "max_cached_processes"
private const val STATE = "background-app-limit"
private const val APPLIED = "applied"
private const val PREVIOUS = "previous"
private val worker = Executors.newSingleThreadExecutor { Thread(it, "background-app-limit").apply { isDaemon = true } }
internal fun readCommand() = "device_config get activity_manager $KEY"
internal fun setCommand(limit: Int): String {
require(limit in 0..1024)
return "device_config put activity_manager $KEY $limit"
}
internal fun restoreCommand(previous: String?) =
if (previous == null) "device_config delete activity_manager $KEY" else "device_config put activity_manager $KEY $previous"
internal fun parse(output: String): String? = output.trim().takeIf { it.toIntOrNull() != null }
private fun state(context: Context) = context.applicationContext.getSharedPreferences(STATE, Context.MODE_PRIVATE)
fun applied(context: Context): Boolean = state(context).getBoolean(APPLIED, false)
fun applyAsync(context: Context, limit: Int) {
val app = context.applicationContext
worker.execute {
runCatching {
if (!applied(app)) {
val previous = parse(WirelessAdbFix.shell(app, readCommand()))
check(state(app).edit().putBoolean(APPLIED, true).putString(PREVIOUS, previous).commit()) { "previous value not saved" }
}
WirelessAdbFix.shell(app, setCommand(limit))
}.onSuccess { Log.i(TAG, "at most $limit cached app(s) while the session runs") }
.onFailure { Log.w(TAG, "limit not set: ${it.message}") }
}
}
fun restoreAsync(context: Context) {
val app = context.applicationContext
worker.execute {
if (!applied(app)) return@execute
val previous = state(app).getString(PREVIOUS, null)
runCatching { WirelessAdbFix.shell(app, restoreCommand(previous)) }
.onSuccess {
state(app).edit().putBoolean(APPLIED, false).remove(PREVIOUS).commit()
Log.i(TAG, "cached-app limit put back (${previous ?: "Android's own"})")
}
.onFailure { Log.w(TAG, "limit not put back yet: ${it.message}") }
}
}
}
@@ -1,27 +0,0 @@
package com.droiddeck.launcher.core
import com.droiddeck.launcher.gpu.GpuInfo
import com.droiddeck.launcher.gpu.SocNames
object CpuAllocationDefaults {
data class Allocation(val client: String, val game: String)
val device: Allocation? by lazy {
forDevice(SocNames.model(), GpuInfo.systemProperty("ro.board.platform"),
CpuCores.all.associateWith(CpuCores::maxGhz))
}
fun forDevice(model: String, platform: String, frequencies: Map<Int, Double?>): Allocation? {
val chip = model.trim().uppercase().substringBefore('-')
val gen2 = chip in setOf("SM8550", "QCS8550") ||
(chip.isEmpty() && platform.trim().equals("kalama", ignoreCase = true))
if (!gen2 || frequencies.size != 8 || frequencies.keys.any { it < 0 } ||
frequencies.values.any { it == null || !it.isFinite() || it <= 0.0 }) return null
val clusters = frequencies.entries.groupBy { it.value }.toSortedMap(compareBy { it ?: 0.0 }).values.toList()
if (clusters.map { it.size } != listOf(3, 4, 1)) return null
return Allocation(
client = CpuCores.format(clusters[2].map { it.key }.toSet()),
game = CpuCores.format(clusters[1].map { it.key }.toSet()),
)
}
}
@@ -152,13 +152,17 @@ object DeviceReport {
k("RAM total", FileUtils.sizeToString(mi.totalMem))
k("RAM available", FileUtils.sizeToString(mi.availMem))
}
runCatching {
val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) k("Thermal status", thermalStatusName(pm.currentThermalStatus))
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
k("Thermal headroom (10 s)", pm.getThermalHeadroom(10).let { if (it.isNaN()) "not reported" else String.format(Locale.US, "%.2f", it) })
val pm = runCatching { context.getSystemService(Context.POWER_SERVICE) as? PowerManager }.getOrNull()
if (pm != null) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
k("Thermal status", runCatching { thermalStatusName(pm.currentThermalStatus) }.getOrNull())
}
k("Power saver", pm.isPowerSaveMode)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
k("Thermal headroom (10 s)", runCatching {
pm.getThermalHeadroom(10).let { if (it.isNaN()) "not reported" else String.format(Locale.US, "%.2f", it) }
}.getOrNull())
}
k("Power saver", runCatching { pm.isPowerSaveMode }.getOrNull())
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val rate = runCatching { context.getSystemService(PerformanceHintManager::class.java)?.preferredUpdateRateNanos }.getOrNull()
@@ -200,7 +204,12 @@ object DeviceReport {
h("Drivers")
val gpu = com.droiddeck.launcher.gpu.GpuInfo.detect()
k("GPU", "${gpu.name} · ${gpu.family.label} · ${gpu.supportText}" + if (gpu.oneUi8Gen2) " · One UI 8 Gen 2" else "")
if (gpu.model == 740) k("TP UBWC hint", com.droiddeck.launcher.gpu.TpUbwcHint.forThisDevice() ?: "driver default")
if (gpu.model == 740) {
val hint = com.droiddeck.launcher.gpu.TpUbwcHint
k("TP UBWC hint", hint.forThisDevice(context) ?: "driver default")
k("TP UBWC mode", hint.activeMode ?: SessionPrefs.TP_UBWC_AUTO)
if (hint.restartRequired(context)) k("TP UBWC pending", "${SessionPrefs.tpUbwcMode(context)} (restart required)")
}
k("Driver mode", SessionPrefs.gpuDriverMode(context) +
(com.droiddeck.launcher.gpu.DriverPairs.recommendedKey(gpu, com.droiddeck.launcher.gpu.DriverPairs.from(
com.droiddeck.launcher.gpu.TurnipReleases.cached(context)), com.droiddeck.launcher.gpu.TurnipReleases.autoPicks(context),
@@ -247,6 +256,9 @@ object DeviceReport {
EsyncPacks.status(LinuxRuntime.rootDir(context)).let { k("droiddeck-esync packs", "${it.installed} installed, ${it.wanted} wanted") }
k("Linux x86 (FEX)", if (com.droiddeck.launcher.runtime.LinuxFex.ready(context)) "ready" else "not set up")
k("gamescope realtime", SessionPrefs.gamescopeRealtime(context))
k("zRAM: compress Steam during games", SessionPrefs.zramClient(context))
k("RAM cap", SessionPrefs.ramCapMb(context).let { if (it > 0) "$it MB" else "off" })
k("VRAM cap", SessionPrefs.vramCapMb(context).let { if (it > 0) "$it MB" else "driver default" })
k("proot without seccomp", SessionPrefs.prootNoSeccomp(context))
k("proot fast path", SessionPrefs.prootFastPath(context))
k("Guest host name", SessionPrefs.guestHostname(context))
@@ -3,6 +3,8 @@ package com.droiddeck.launcher.core
import android.content.Context
import android.content.SharedPreferences
import java.net.HttpURLConnection
import java.net.InetSocketAddress
import java.net.Proxy
import java.net.URL
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CountDownLatch
@@ -20,6 +22,7 @@ object DownloadSource {
private const val KEY_MODE = "mode"
private const val KEY_MIRROR = "mirror"
private const val KEY_CUSTOM = "custom"
private const val KEY_PROXY = "proxy"
private const val KEY_FASTEST = "fastest"
private const val PROBE = "https://raw.githubusercontent.com/Droid-Deck/DroidDeck/main/README.md"
@@ -175,6 +178,22 @@ object DownloadSource {
prefs(context)?.edit()?.putString(KEY_CUSTOM, normalizePrefix(prefix))?.apply()
}
fun proxyHostPort(context: Context): String = prefs(context)?.getString(KEY_PROXY, "") ?: ""
fun setProxy(context: Context, value: String) {
prefs(context)?.edit()?.putString(KEY_PROXY, normalizeProxy(value))?.apply()
}
/** An HTTP proxy every Downloader connection uses, or null when none is configured. */
fun httpProxy(): Proxy? = proxyOf(prefs()?.getString(KEY_PROXY, "") ?: "")
internal fun proxyOf(value: String): Proxy? {
val host = value.substringBefore(':')
val port = value.substringAfter(':', "").toIntOrNull() ?: return null
if (host.isEmpty()) return null
return Proxy(Proxy.Type.HTTP, InetSocketAddress(host, port))
}
fun reset(context: Context) {
lastGood = null
prefs(context)?.edit()?.clear()?.apply()
@@ -205,4 +224,11 @@ object DownloadSource {
if (trimmed.isEmpty() || trimmed.startsWith("http://")) return ""
return if (trimmed.startsWith("https://")) trimmed else "https://$trimmed"
}
internal fun normalizeProxy(value: String): String {
val trimmed = value.trim().removePrefix("http://").trimEnd('/')
val host = trimmed.substringBefore(':')
val port = trimmed.substringAfter(':', "").toIntOrNull() ?: 0
return if (host.isNotEmpty() && port in 1..65535) "$host:$port" else ""
}
}
@@ -11,6 +11,7 @@ import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.Proxy;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.List;
@@ -277,7 +278,10 @@ public final class Downloader {
}
private static HttpURLConnection open(String url, int readTimeoutMs) throws java.io.IOException {
HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
// An explicit Proxy routes the connection through the app setting instead of the system-wide proxy.
Proxy proxy = DownloadSource.INSTANCE.httpProxy();
HttpURLConnection connection = (HttpURLConnection) (proxy != null
? new URL(url).openConnection(proxy) : new URL(url).openConnection());
connection.setConnectTimeout(CONNECT_TIMEOUT_MS);
connection.setReadTimeout(readTimeoutMs);
connection.setInstanceFollowRedirects(true);
@@ -14,6 +14,8 @@ object GameEnvironmentOptions {
Option("MESA_SHADER_CACHE_MAX_SIZE", "1G", R.string.game_env_cache_size, Type.CHOICE, listOf("512M", "1G", "2G", "4G")),
Option("PROTON_LOG", "1", R.string.game_env_proton_log, Type.TOGGLE, listOf("0", "1")),
Option("PROTON_USE_WINED3D", "1", R.string.game_env_wined3d, Type.TOGGLE, listOf("0", "1")),
Option("PROTON_DXVK_D3D8", "1", R.string.game_env_dxvk_d3d8, Type.TOGGLE, listOf("0", "1")),
Option("WINE_D3D_CONFIG", "renderer=vulkan", R.string.game_env_wined3d_renderer, Type.CHOICE, listOf("renderer=vulkan", "renderer=gl")),
Option("PROTON_USE_XALIA", "0", R.string.game_env_xalia, Type.TOGGLE, listOf("0", "1")),
Option("PROTON_NO_NTSYNC", "1", R.string.game_env_no_ntsync, Type.TOGGLE, listOf("0", "1")),
Option("PROTON_NO_ESYNC", "1", R.string.game_env_no_esync, Type.TOGGLE, listOf("0", "1")),
@@ -32,6 +34,7 @@ object GameEnvironmentOptions {
Option("MESA_EXTENSION_MAX_YEAR", "", 0, Type.TEXT),
Option("MESA_GL_VERSION_OVERRIDE", "", 0, Type.TEXT),
Option("PULSE_LATENCY_MSEC", "", 0, Type.NUMBER),
Option("DROIDDECK_GAME_CORES", "", R.string.game_env_cores, Type.TEXT),
Option("DROIDDECK_ANTICHEAT_OFFLINE", "1", R.string.game_env_anticheat_offline, Type.TOGGLE, listOf("0", "1")),
Option("WINE_LARGE_ADDRESS_AWARE", "0", 0, Type.TOGGLE, listOf("0", "1")),
Option("WINEDLLOVERRIDES", "", 0, Type.TEXT),
@@ -8,7 +8,11 @@ object HostProcess {
private const val TAG = "HostProcess"
@JvmStatic
fun start(command: String, env: Array<String>?, dir: File?, onExit: Consumer<Int>?, onLine: Consumer<String>?): Int {
fun start(command: String, env: Array<String>?, dir: File?, onExit: Consumer<Int>?, onLine: Consumer<String>?): Int =
start(command, env, dir, onExit, onLine, null)
@JvmStatic
fun start(command: String, env: Array<String>?, dir: File?, onExit: Consumer<Int>?, onLine: Consumer<String>?, outputFile: File?): Int {
val argv = split(command)
if (argv.isEmpty()) return -1
return try {
@@ -19,7 +23,10 @@ object HostProcess {
if (eq > 0) builder.environment()[line.substring(0, eq)] = line.substring(eq + 1)
}
builder.redirectErrorStream(true)
if (onLine == null) builder.redirectOutput(File("/dev/null"))
if (onLine == null) {
if (outputFile == null) builder.redirectOutput(File("/dev/null"))
else builder.redirectOutput(ProcessBuilder.Redirect.appendTo(outputFile))
}
val process = builder.start()
val pid = pidOf(process)
Log.i(TAG, "started pid $pid: ${argv.first()} (${argv.size - 1} arguments)")
@@ -76,7 +76,7 @@ object LogRedactor {
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)" +
"(?i)(?<![A-Za-z0-9_])(account[_-]?name|account[_-]?id|user[_-]?name|epicusername)" +
"([\"']?\\s*[=:]\\s*|[\"']\\s+)" + ACCOUNT_VALUE
)
private val LOGIN_KV = Regex(
@@ -182,25 +182,47 @@ object WirelessAdbFix {
}
fun setUsingSavedPairing(context: Context, enabled: Boolean) {
withSavedPairing(context) { appContext, host, port -> setChildProcessLimit(appContext, host, port, enabled) }
}
fun hasSavedPairing(context: Context): Boolean = savedConnection(context.applicationContext) != null
fun clearCachedApps(context: Context) {
shell(context, "am kill-all")
}
fun shell(context: Context, command: String): String {
var output = ""
withSavedPairing(context) { appContext, host, port ->
loadOrCreateIdentity(appContext)
Kadb.create(host, port).use { adb ->
val result = adb.shell(command)
check(result.exitCode == 0) { result.allOutput.ifBlank { appContext.getString(R.string.adbfix_command_failed, result.exitCode) } }
output = result.output
}
saveConnection(appContext, host, port)
}
return output
}
private fun withSavedPairing(context: Context, action: (Context, String, Int) -> Unit) {
val appContext = context.applicationContext
val saved = savedConnection(appContext)
?: error(appContext.getString(R.string.adbfix_no_pairing))
val cachedPort = saved.port
if (cachedPort != null) {
val cachedResult = runCatching {
setChildProcessLimit(appContext, saved.host, cachedPort, enabled)
}
val cachedResult = runCatching { action(appContext, saved.host, cachedPort) }
if (cachedResult.isSuccess) return
val discoveredPort = findConnectPort(appContext, saved.host)
if (discoveredPort != null && discoveredPort != cachedPort) {
setChildProcessLimit(appContext, saved.host, discoveredPort, enabled)
action(appContext, saved.host, discoveredPort)
} else {
cachedResult.getOrThrow()
}
} else {
val discoveredPort = findConnectPort(appContext, saved.host)
?: error(appContext.getString(R.string.adbfix_enter_address))
setChildProcessLimit(appContext, saved.host, discoveredPort, enabled)
action(appContext, saved.host, discoveredPort)
}
}
}
@@ -14,7 +14,7 @@ import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import com.droiddeck.launcher.MainActivity
import com.droiddeck.launcher.FrontEndActivity
import com.droiddeck.launcher.R
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -185,7 +185,7 @@ class WirelessAdbPairingService : Service() {
enableVibration(false)
})
val open = PendingIntent.getActivity(this, 0,
Intent(this, MainActivity::class.java).setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT),
Intent(this, FrontEndActivity::class.java).setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT),
PendingIntent.FLAG_IMMUTABLE)
val cancel = PendingIntent.getService(this, 1,
Intent(this, WirelessAdbPairingService::class.java).setAction(ACTION_CANCEL), PendingIntent.FLAG_IMMUTABLE)
@@ -1,26 +1,10 @@
package com.droiddeck.launcher.core
import android.app.ActivityManager
import android.content.Context
import android.os.Build
import java.io.File
object ZramSupport {
private const val GIB = 1L shl 30
fun supported(): Boolean = kernelAtLeast(System.getProperty("os.version").orEmpty(), 5, 4) && swapTotalKb() > 0
fun recommended(context: Context): Boolean {
if (!supported()) return false
val memory = ActivityManager.MemoryInfo()
context.getSystemService(ActivityManager::class.java).getMemoryInfo(memory)
val advertised = if (Build.VERSION.SDK_INT >= 34) memory.advertisedMem else 0L
return belowTwelveGb(advertised, memory.totalMem)
}
fun belowTwelveGb(advertisedBytes: Long, totalBytes: Long): Boolean =
if (advertisedBytes > 0) advertisedBytes < 11 * GIB else totalBytes < 10 * GIB
fun kernelAtLeast(release: String, major: Int, minor: Int): Boolean {
val parts = Regex("^(\\d+)\\.(\\d+)").find(release)?.groupValues ?: return false
val ma = parts[1].toInt()
@@ -29,7 +29,7 @@ class FileManagerActivity : ComponentActivity() {
setContent {
DroidDeckTheme(appScale = com.droiddeck.launcher.core.AppUiPrefs.scale(this)) {
Surface(modifier = Modifier.fillMaxSize().systemBarsPadding(), color = MaterialTheme.colorScheme.background) {
FileManagerScreen(initialDir = initialDir)
FileManagerScreen(initialDir = initialDir, onExit = { finish() })
}
}
}
@@ -28,6 +28,8 @@ import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ExitToApp
import androidx.compose.material.icons.automirrored.filled.KeyboardReturn
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.ContentPaste
import androidx.compose.material.icons.filled.CreateNewFolder
@@ -68,6 +70,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -90,12 +93,20 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.session.SessionPrefs
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.util.Date
private enum class FileManagerNavigation { EXIT, RETURN_TO_START, BACK, UP }
private const val HISTORY_LIMIT = 50
internal fun canNavigateUp(currentDir: File, currentRoot: File): Boolean =
currentDir != currentRoot && currentDir.parentFile?.exists() == true
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FileManagerScreen(
@@ -105,6 +116,7 @@ fun FileManagerScreen(
initialDir: File? = null,
pickerTitle: String? = null,
onPick: ((File) -> Unit)? = null,
onExit: (() -> Unit)? = null,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
@@ -118,12 +130,14 @@ fun FileManagerScreen(
val pickPrefs = remember { context.getSharedPreferences("file_manager", android.content.Context.MODE_PRIVATE) }
val browsePrefs = pickPrefs
val rootDir = remember {
val rootDir = remember(initialDir) {
initialDir?.takeIf { it.isDirectory } ?: File("/storage/emulated/0")
}
val launchDir = remember(initialDir, pickMode) {
initialDir?.takeIf { !pickMode }
}
var currentDir by remember { mutableStateOf(rootDir) }
var currentRoot by remember {
val initialRoot = remember(rootDir) {
val abs = rootDir.absolutePath
val internal = "/storage/emulated/0"
val vol = when {
@@ -139,8 +153,9 @@ fun FileManagerScreen(
?: rootDir
}
}
mutableStateOf(vol)
vol
}
var currentRoot by remember { mutableStateOf(initialRoot) }
var entries by remember { mutableStateOf(listOf<File>()) }
var selectedEntry by remember { mutableStateOf<File?>(null) }
var showMenuFor by remember { mutableStateOf<File?>(null) }
@@ -151,8 +166,10 @@ fun FileManagerScreen(
var pendingConflict by remember { mutableStateOf<File?>(null) }
var conflictChoice by remember { mutableStateOf<ConflictChoice?>(null) }
var conflictApplyToAll by remember { mutableStateOf(false) }
var operationJob by remember { mutableStateOf<kotlinx.coroutines.Job?>(null) }
var cancelledJob by remember { mutableStateOf<kotlinx.coroutines.Job?>(null) }
var operationJob by remember { mutableStateOf<Job?>(null) }
var cancelledJob by remember { mutableStateOf<Job?>(null) }
var pendingNavigation by remember { mutableStateOf<FileManagerNavigation?>(null) }
var cancellingNavigation by remember { mutableStateOf(false) }
var pendingBulkDelete by remember { mutableStateOf<List<File>>(emptyList()) }
var searchQuery by remember { mutableStateOf("") }
val visibleEntries = if (searchQuery.isBlank()) entries
@@ -178,10 +195,18 @@ fun FileManagerScreen(
var showFavorites by remember { mutableStateOf(false) }
var favTick by remember { mutableIntStateOf(0) }
var listingJob by remember { mutableStateOf<kotlinx.coroutines.Job?>(null) }
var listingJob by remember { mutableStateOf<Job?>(null) }
val history = remember { mutableStateListOf<Pair<File, File>>() }
var shownRoot by remember { mutableStateOf(initialRoot) }
fun loadDirectory(dir: File, resetScroll: Boolean = true) {
fun loadDirectory(dir: File, resetScroll: Boolean = true, record: Boolean = true) {
if (cancellingNavigation) return
if (record && dir != currentDir) {
history.add(currentDir to shownRoot)
if (history.size > HISTORY_LIMIT) history.removeAt(0)
}
currentDir = dir
shownRoot = currentRoot
if (pickMode) pickPrefs.edit().putString("lastFilePickerDir", dir.absolutePath).apply()
listingJob?.cancel()
listingJob = scope.launch {
@@ -205,25 +230,102 @@ fun FileManagerScreen(
}
fun openDrive(dir: File) {
if (cancellingNavigation) return
currentRoot = dir
loadDirectory(dir)
}
fun returnToStart() {
if (cancellingNavigation) return
val dir = launchDir ?: return
if (!dir.isDirectory) {
Toast.makeText(context, R.string.fm_start_unavailable, Toast.LENGTH_SHORT).show()
return
}
showFavorites = false
showSearch = false
searchQuery = ""
currentRoot = initialRoot
loadDirectory(dir)
}
LaunchedEffect(Unit) { loadDirectory(rootDir) }
BackHandler(enabled = showFavorites || currentDir != currentRoot) {
if (showFavorites) {
showFavorites = false
return@BackHandler
val canGoUp = currentDir != currentRoot && currentDir.parentFile?.exists() == true
fun isOperationActive() = isOperationRunning || operationJob?.isCompleted == false || cancelledJob?.isCompleted == false
val operationActive = isOperationActive()
fun navigate(action: FileManagerNavigation) {
if (cancellingNavigation) return
when (action) {
FileManagerNavigation.EXIT -> onExit?.invoke()
FileManagerNavigation.RETURN_TO_START -> returnToStart()
FileManagerNavigation.BACK -> {
if (showFavorites) showFavorites = false
else {
while (history.isNotEmpty() && !history.last().first.isDirectory) history.removeAt(history.lastIndex)
val previous = history.removeLastOrNull()
if (previous != null) {
currentRoot = previous.second
loadDirectory(previous.first, record = false)
} else if (canNavigateUp(currentDir, currentRoot)) currentDir.parentFile?.let { loadDirectory(it, record = false) }
else onExit?.invoke()
}
}
FileManagerNavigation.UP -> {
if (showFavorites) showFavorites = false
else if (canNavigateUp(currentDir, currentRoot)) currentDir.parentFile?.let { loadDirectory(it) }
else onExit?.invoke()
}
}
val parent = currentDir.parentFile
if (parent != null && parent.exists()) loadDirectory(parent)
}
val canGoUp = currentDir != currentRoot && currentDir.parentFile?.exists() == true
fun goUp() {
val parent = currentDir.parentFile
if (currentDir != currentRoot && parent != null && parent.exists()) loadDirectory(parent)
fun requestNavigation(action: FileManagerNavigation) {
if (cancellingNavigation) return
if (isOperationActive()) pendingNavigation = action else navigate(action)
}
BackHandler(enabled = operationActive || cancellingNavigation || showFavorites || currentDir != currentRoot || history.isNotEmpty()) {
requestNavigation(FileManagerNavigation.BACK)
}
if (pendingNavigation != null) {
OutlinedAlertDialog(
onDismissRequest = { pendingNavigation = null },
title = { Text(stringResource(R.string.fm_cancel_operation_title)) },
text = { Text(stringResource(R.string.fm_cancel_operation_confirmation)) },
confirmButton = {
TextButton(onClick = {
val action = pendingNavigation ?: return@TextButton
pendingNavigation = null
val job = operationJob?.takeUnless(Job::isCompleted)
?: cancelledJob?.takeUnless(Job::isCompleted)
if (job == null) {
navigate(action)
} else {
cancellingNavigation = true
cancelledJob = job
job.cancel()
scope.launch {
job.join()
if (operationJob === job) operationJob = null
if (cancelledJob === job) cancelledJob = null
pendingConflict = null
isOperationRunning = false
operationDeterminate = false
cancellingNavigation = false
if (action != FileManagerNavigation.EXIT) loadDirectory(currentDir, resetScroll = false)
navigate(action)
}
}
}) { Text(stringResource(R.string.fm_accept)) }
},
dismissButton = {
TextButton(onClick = { pendingNavigation = null }) {
Text(stringResource(R.string.fm_decline))
}
},
)
}
fun uniqueDestination(dir: File, name: String): File {
@@ -241,11 +343,14 @@ fun FileManagerScreen(
}
fun performDelete(file: File) {
scope.launch {
if (isOperationActive()) return
operationJob = scope.launch {
isOperationRunning = true
operationLabel = context.getString(R.string.fm_op_deleting)
val ok = withContext(Dispatchers.IO) { FileOps.delete(file) }
val job = coroutineContext[Job]
val ok = withContext(Dispatchers.IO) { FileOps.delete(file) { job?.isActive == false } }
isOperationRunning = false
if (operationJob === job) operationJob = null
loadDirectory(currentDir, resetScroll = false)
if (!ok) Toast.makeText(context, R.string.fm_delete_failed, Toast.LENGTH_SHORT).show()
}
@@ -260,7 +365,7 @@ fun FileManagerScreen(
fun performPaste() {
val sources = clipboardFiles
if (sources.isEmpty() || isOperationRunning || operationJob?.isActive == true || cancelledJob?.isCompleted == false) return
if (sources.isEmpty() || isOperationActive()) return
val dstDir = currentDir
val cut = isCutOperation
@@ -269,7 +374,7 @@ fun FileManagerScreen(
operationDeterminate = true
operationLabel = context.getString(if (cut) R.string.fm_op_moving else R.string.fm_op_copying)
isOperationRunning = true
val job = coroutineContext[kotlinx.coroutines.Job]
val job = coroutineContext[Job]
var applyToAll: ConflictChoice? = null
var failed = 0
@@ -322,7 +427,7 @@ fun FileManagerScreen(
isOperationRunning = false
operationDeterminate = false
operationJob = null
if (operationJob === job) operationJob = null
clipboardFiles = emptyList()
isCutOperation = false
selectionMode = false
@@ -340,32 +445,38 @@ fun FileManagerScreen(
}
fun performRename(file: File, newName: String) {
if (isOperationActive()) return
val target = File(file.parentFile, newName)
if (target.exists()) {
Toast.makeText(context, context.getString(R.string.fm_already_exists, newName), Toast.LENGTH_SHORT).show()
return
}
scope.launch {
operationJob = scope.launch {
val job = coroutineContext[Job]
isOperationRunning = true
operationLabel = context.getString(R.string.fm_op_renaming)
val ok = withContext(Dispatchers.IO) { file.renameTo(target) }
isOperationRunning = false
if (operationJob === job) operationJob = null
loadDirectory(currentDir, resetScroll = false)
if (!ok) Toast.makeText(context, R.string.fm_rename_failed, Toast.LENGTH_SHORT).show()
}
}
fun createFolder(parent: File, name: String) {
if (isOperationActive()) return
val target = File(parent, name)
if (target.exists()) {
Toast.makeText(context, context.getString(R.string.fm_already_exists, name), Toast.LENGTH_SHORT).show()
return
}
scope.launch {
operationJob = scope.launch {
val job = coroutineContext[Job]
isOperationRunning = true
operationLabel = context.getString(R.string.fm_op_creating_folder)
val ok = withContext(Dispatchers.IO) { target.mkdirs() }
isOperationRunning = false
if (operationJob === job) operationJob = null
loadDirectory(currentDir, resetScroll = false)
if (!ok) Toast.makeText(context, R.string.fm_create_folder_failed, Toast.LENGTH_SHORT).show()
}
@@ -481,18 +592,20 @@ fun FileManagerScreen(
},
confirmButton = {
TextButton(onClick = {
if (isOperationActive()) return@TextButton
pendingBulkDelete = emptyList()
selectionMode = false
selectedPaths = emptySet()
operationJob = scope.launch {
isOperationRunning = true
val job = coroutineContext[Job]
var failed = 0
victims.forEachIndexed { i, f ->
operationLabel = context.getString(R.string.fm_op_deleting_batch, i + 1, victims.size, f.name)
if (!withContext(Dispatchers.IO) { FileOps.delete(f) }) failed++
if (!withContext(Dispatchers.IO) { FileOps.delete(f) { job?.isActive == false } }) failed++
}
isOperationRunning = false
operationJob = null
if (operationJob === job) operationJob = null
loadDirectory(currentDir, resetScroll = false)
if (failed > 0) {
Toast.makeText(context, context.resources.getQuantityString(R.plurals.fm_delete_some_failed, failed, failed), Toast.LENGTH_SHORT).show()
@@ -582,7 +695,7 @@ fun FileManagerScreen(
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(horizontal = 8.dp, vertical = 6.dp),
) {
IconButton(onClick = { goUp() }, enabled = canGoUp) {
IconButton(onClick = { requestNavigation(FileManagerNavigation.UP) }, enabled = canGoUp && !cancellingNavigation) {
Icon(Icons.Filled.ArrowBack, stringResource(R.string.fm_back), tint = MaterialTheme.colorScheme.primary)
}
@@ -672,6 +785,13 @@ fun FileManagerScreen(
)
}
if (launchDir != null) {
IconButton(onClick = { requestNavigation(FileManagerNavigation.RETURN_TO_START) }, enabled = !cancellingNavigation) {
Icon(Icons.AutoMirrored.Filled.KeyboardReturn, stringResource(R.string.fm_return_to_start), tint = MaterialTheme.colorScheme.primary)
}
if (LocalConfiguration.current.screenWidthDp >= 700) Spacer(Modifier.width(56.dp))
}
if (!showFavorites) {
if (!pickMode) {
OutlinedButton(
@@ -747,6 +867,11 @@ fun FileManagerScreen(
Icon(Icons.Filled.StarBorder, stringResource(R.string.fm_show_favorites), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
if (!pickMode && onExit != null) {
IconButton(onClick = { requestNavigation(FileManagerNavigation.EXIT) }, enabled = !cancellingNavigation) {
Icon(Icons.AutoMirrored.Filled.ExitToApp, stringResource(R.string.fm_exit), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
if (showSearch && !showFavorites) {
@@ -986,7 +1111,7 @@ fun FileManagerScreen(
contentPadding = PaddingValues(8.dp),
) {
if (canGoUp) item(key = "..", span = { GridItemSpan(maxLineSpan) }) {
ParentFolderRow(compact = compactRows, onTap = { goUp() })
ParentFolderRow(compact = compactRows, onTap = { requestNavigation(FileManagerNavigation.UP) })
}
items(shownEntries, key = { it.absolutePath }) { file ->
val isFav = remember(file.absolutePath, favTick) {
@@ -1052,7 +1177,7 @@ fun FileManagerScreen(
} else
LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
if (canGoUp) item(key = "..") {
ParentFolderRow(compact = compactRows, onTap = { goUp() })
ParentFolderRow(compact = compactRows, onTap = { requestNavigation(FileManagerNavigation.UP) })
}
if (entries.isEmpty()) {
item {
@@ -9,6 +9,7 @@ import java.io.FileOutputStream
import java.io.IOException
import java.nio.file.Files
import java.util.Locale
import java.util.concurrent.CancellationException
object FileOps {
private const val TAG = "FileOps"
@@ -24,24 +25,27 @@ object FileOps {
fun isSymlink(file: File): Boolean = try { Files.isSymbolicLink(file.toPath()) } catch (e: Exception) { false }
fun delete(target: File?): Boolean {
fun delete(target: File?, isCancelled: () -> Boolean = { false }): Boolean {
if (isCancelled()) throw CancellationException("Delete cancelled: $target")
if (target == null) return false
if (target.isDirectory && !isSymlink(target)) {
if (!clear(target)) return false
if (!clear(target, isCancelled)) return false
}
if (isCancelled()) throw CancellationException("Delete cancelled: $target")
return target.delete()
}
fun clear(dir: File): Boolean {
fun clear(dir: File, isCancelled: () -> Boolean = { false }): Boolean {
val children = dir.listFiles() ?: return true
var ok = true
for (child in children) if (!delete(child)) ok = false
for (child in children) if (!delete(child, isCancelled)) ok = false
return ok
}
fun totalSize(f: File?): Long {
fun totalSize(f: File?, isCancelled: () -> Boolean = { false }): Long {
if (isCancelled()) throw CancellationException("Size scan cancelled: $f")
if (f == null || !f.exists() || isSymlink(f)) return 0
if (f.isDirectory) return f.listFiles()?.sumOf { totalSize(it) } ?: 0
if (f.isDirectory) return f.listFiles()?.sumOf { totalSize(it, isCancelled) } ?: 0
return f.length()
}
@@ -53,12 +57,12 @@ object FileOps {
return true
}
if (!copyWithProgress(src, dst, progress, isCancelled)) return false
if (isCancelled()) return false
// Once the destination is committed, finish deleting the source.
return delete(src)
}
fun copyWithProgress(src: File, dst: File, progress: ProgressCallback?, isCancelled: () -> Boolean = { false }): Boolean {
val total = maxOf(totalSize(src), 1L)
val total = maxOf(totalSize(src, isCancelled), 1L)
return copyWithProgress(src, dst, longArrayOf(0), total, progress, isCancelled)
}
@@ -71,15 +75,23 @@ object FileOps {
return false
}
if (src.isDirectory) {
if (!dst.exists() && !dst.mkdirs()) return false
var allOk = true
src.list()?.forEach { name ->
if (!copyWithProgress(File(src, name), File(dst, name), done, total, progress, isCancelled)) {
Log.e(TAG, "Failed to copy: ${File(src, name)}")
allOk = false
val created = if (dst.exists()) false else {
if (!dst.mkdirs()) return false
true
}
try {
var allOk = true
src.list()?.forEach { name ->
if (!copyWithProgress(File(src, name), File(dst, name), done, total, progress, isCancelled)) {
Log.e(TAG, "Failed to copy: ${File(src, name)}")
allOk = false
}
}
return allOk
} catch (e: CancellationException) {
if (created) delete(dst)
throw e
}
return allOk
}
val parent = dst.parentFile
if (!src.exists() || (parent != null && !parent.exists() && !parent.mkdirs())) return false
@@ -118,6 +130,7 @@ object FileOps {
val size = input.size()
var position = 0L
while (position < size) {
if (isCancelled()) throw CancellationException("Copy cancelled: $src")
val transferred = input.transferTo(position, minOf(chunk, size - position), channel)
if (transferred <= 0) break
position += transferred
@@ -125,12 +138,19 @@ object FileOps {
progress?.onProgress(done[0], total)
}
if (position < size) throw IOException("Incomplete copy: $position/$size bytes")
if (size > 0 && isCancelled()) throw CancellationException("Copy cancelled: $src")
}
}
staging?.let { directory ->
val copied = File(directory, "file").toPath()
if (permissions != null && Files.getPosixFilePermissions(copied) != permissions) {
Files.setPosixFilePermissions(copied, permissions)
if (permissions != null) {
try {
if (Files.getPosixFilePermissions(copied) != permissions) {
Files.setPosixFilePermissions(copied, permissions)
}
} catch (e: Exception) {
Log.w(TAG, "Failed to restore destination permissions: $target", e)
}
}
if (isCancelled()) throw java.util.concurrent.CancellationException("Copy cancelled: $src")
Files.move(copied, target.toPath(), java.nio.file.StandardCopyOption.ATOMIC_MOVE)
@@ -50,6 +50,7 @@ class FilePickerActivity : ComponentActivity() {
setResult(Activity.RESULT_OK, Intent().putExtra(EXTRA_SELECTED_FILE, file.absolutePath))
finish()
},
onExit = { finish() },
)
}
}
@@ -29,6 +29,50 @@ object StorageRoots {
val seeds = LinkedHashSet<File>()
}
private val EXTERNAL_MOUNT_TYPES = setOf(
"vfat", "exfat", "texfat", "ntfs", "ntfs3", "fuseblk", "fuse", "sdcardfs",
)
internal data class MountPoint(val target: File, val type: String)
internal fun parseMountPoints(mounts: String): List<MountPoint> {
val out = ArrayList<MountPoint>()
for (line in mounts.lineSequence()) {
val parts = line.split(' ')
if (parts.size < 3) continue
out.add(MountPoint(File(parts[1].replace("\\040", " ")), parts[2].lowercase()))
}
return out.distinct()
}
internal fun isExternalMount(mount: MountPoint): Boolean {
val path = mount.target.path
val name = when {
path.startsWith("/storage/") -> path.removePrefix("/storage/")
path.startsWith("/mnt/media_rw/") -> path.removePrefix("/mnt/media_rw/")
path.startsWith("/mnt/") -> path.removePrefix("/mnt/")
else -> return false
}
if ('/' in name || name.isEmpty() || name == "emulated" || name == "self") return false
if (mount.type in EXTERNAL_MOUNT_TYPES) {
if (path.startsWith("/mnt/") && !path.startsWith("/mnt/media_rw/")) {
val lower = name.lowercase()
if (!lower.contains("usb") && !lower.contains("otg") &&
!lower.contains("udisk") && !lower.contains("disk")
) return false
}
return true
}
// vold-managed locations only hold removable volumes, whatever the fstype is.
return path.startsWith("/storage/") || path.startsWith("/mnt/media_rw/")
}
internal fun parseExternalMounts(mounts: String): List<File> =
parseMountPoints(mounts).filter(::isExternalMount).map { it.target }
fun externalMounts(): List<File> =
runCatching { parseExternalMounts(File("/proc/mounts").readText()) }.getOrDefault(emptyList())
fun list(context: Context): List<StorageRoot> {
val volumes = LinkedHashMap<String, Volume>()
@@ -44,21 +88,31 @@ object StorageRoots {
val storageManager = context.getSystemService(StorageManager::class.java)
storageManager?.storageVolumes.orEmpty()
.filter { isMounted(it.state) }
.forEach { volume ->
.filter { present(it.state) }
.forEachIndexed { index, volume ->
val uuid = volume.uuid?.takeIf { it.isNotBlank() }
val entry = volumeOf(if (volume.isPrimary) PRIMARY_KEY else uuid ?: return@forEach)
val dirName = volume.directory?.name?.takeIf { it.isNotBlank() }
// Unmounted volumes often report neither uuid nor directory; never drop the
// volume or a plugged-in drive stays invisible with no explanation.
val descSlug = volume.getDescription(context)?.trim()
?.takeIf { it.isNotBlank() && !it.equals("android", ignoreCase = true) }
?.replace(Regex("[^A-Za-z0-9._-]+"), "-")
val entry = volumeOf(if (volume.isPrimary) PRIMARY_KEY else uuid ?: dirName ?: descSlug ?: "volume-$index")
entry.removable = !volume.isPrimary && volume.isRemovable
if (entry.label == null) entry.label = labelFor(context, volume, uuid)
if (entry.label == null) entry.label = labelFor(context, volume, uuid ?: dirName ?: descSlug)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
volume.directory?.let { entry.candidates += it }
}
if (uuid != null) {
entry.candidates += File("/storage/$uuid")
entry.candidates += File("/mnt/media_rw/$uuid")
val name = uuid ?: dirName
if (name != null) {
entry.candidates += File("/storage/$name")
entry.candidates += File("/mnt/media_rw/$name")
// Only strict uuid matches own an app directory; a loose match would
// attach another volume's folder to this entry.
if (uuid != null) appDirs.filter { belongsTo(storageManager!!, it, volume) }
.forEach { entry.seeds += it }
}
appDirs.filter { belongsTo(storageManager!!, it, volume) }.forEach { entry.seeds += it }
}
File("/storage").listFiles().orEmpty().forEach { child ->
@@ -68,6 +122,13 @@ object StorageRoots {
File("/mnt/media_rw").listFiles().orEmpty().forEach { child ->
if (child.isDirectory) volumeOf(child.name).candidates += child
}
// USB OTG drives do not always appear as storage volumes or under /storage for
// third-party apps, but their kernel mounts are still visible in /proc/mounts.
for (mount in externalMounts()) {
val entry = volumeOf(mount.name)
if (entry.label == null) entry.label = mount.name
entry.candidates += mount
}
appDirs.forEach { dir ->
val root = volumeRootOf(dir) ?: return@forEach
@@ -141,15 +202,19 @@ object StorageRoots {
private fun labelFor(context: Context, volume: StorageVolume, uuid: String?): String {
if (volume.isPrimary) return context.getString(R.string.fm_drive_internal)
if (volume.isRemovable) return context.getString(R.string.fm_drive_sd)
val description = volume.getDescription(context)?.trim()
return description?.takeIf { it.isNotBlank() && !it.equals("android", ignoreCase = true) }
?: uuid
if (!description.isNullOrBlank() && !description.equals("android", ignoreCase = true)) return description
if (volume.isRemovable) return context.getString(R.string.fm_drive_sd)
return uuid
?: context.getString(R.string.fm_drive_storage)
}
private fun isEmulated(dir: File): Boolean = dir.absolutePath.startsWith(EMULATED_PREFIX)
private fun present(state: String?): Boolean =
isMounted(state) || state == Environment.MEDIA_UNMOUNTED || state == Environment.MEDIA_CHECKING ||
state == Environment.MEDIA_NOFS || state == Environment.MEDIA_UNMOUNTABLE
private fun isMounted(state: String?): Boolean =
state == Environment.MEDIA_MOUNTED || state == Environment.MEDIA_MOUNTED_READ_ONLY
@@ -4,7 +4,11 @@ import android.content.Context
import android.graphics.Bitmap
import android.util.Log
import com.droiddeck.launcher.files.PeIconExtractor
import com.droiddeck.launcher.runtime.BattleNet
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreArt
import com.droiddeck.launcher.stores.StoreGameSidecar
import org.json.JSONObject
import java.io.File
import java.net.HttpURLConnection
@@ -73,14 +77,18 @@ object AddedGameArt {
)
}
private fun storeArt(context: Context, game: AddedGames.Game): BattleNet.Art? =
if (game.source != Store.BATTLENET.id) null else game.storeId?.let(BattleNet::title)?.let { BattleNet.art(context, it) }
fun resolve(context: Context, game: AddedGames.Game): Art {
val own = local(game)
val cache = cacheDir(context, game)
val store = storeArt(context, game)
fun cached(name: String) = File(cache, name).takeIf { it.isFile }
return Art(
portrait = chosen(cache, Slot.COVER) ?: own.portrait ?: cached("p.jpg") ?: exeArt(context, game, cover = true),
header = chosen(cache, Slot.WIDE) ?: own.header ?: cached("header.jpg") ?: exeArt(context, game, cover = false),
hero = chosen(cache, Slot.BACKGROUND) ?: own.hero ?: cached("hero.jpg"),
portrait = chosen(cache, Slot.COVER) ?: own.portrait ?: store?.cover ?: cached("p.jpg") ?: exeArt(context, game, cover = true),
header = chosen(cache, Slot.WIDE) ?: own.header ?: store?.header ?: cached("header.jpg") ?: exeArt(context, game, cover = false),
hero = chosen(cache, Slot.BACKGROUND) ?: own.hero ?: store?.header ?: cached("hero.jpg"),
logo = chosen(cache, Slot.LOGO) ?: own.logo ?: cached("logo.png"),
icon = chosen(cache, Slot.ICON) ?: own.icon ?: cached("icon-sgdb.png") ?: exeIcon(context, game),
)
@@ -89,15 +97,16 @@ object AddedGameArt {
fun describe(context: Context, game: AddedGames.Game): List<SlotState> {
val own = local(game)
val cache = cacheDir(context, game)
val store = storeArt(context, game)
fun cached(name: String): Pair<File, String>? = File(cache, name).takeIf { it.isFile }?.let { f ->
f to (File(cache, "$name.src").takeIf { it.isFile }?.readText()?.trim()?.takeIf { it.isNotEmpty() } ?: STEAM)
}
return Slot.values().map { slot ->
chosen(cache, slot)?.let { return@map SlotState(slot, it, chosenSource(context, game, slot).first.ifEmpty { FILE }, true) }
val auto: Pair<File, String>? = when (slot) {
Slot.COVER -> own.portrait?.let { it to FOLDER } ?: cached("p.jpg") ?: exeArt(context, game, cover = true)?.let { it to EXE }
Slot.WIDE -> own.header?.let { it to FOLDER } ?: cached("header.jpg") ?: exeArt(context, game, cover = false)?.let { it to EXE }
Slot.BACKGROUND -> own.hero?.let { it to FOLDER } ?: cached("hero.jpg")
Slot.COVER -> own.portrait?.let { it to FOLDER } ?: store?.cover?.let { it to AUTO } ?: cached("p.jpg") ?: exeArt(context, game, cover = true)?.let { it to EXE }
Slot.WIDE -> own.header?.let { it to FOLDER } ?: store?.header?.let { it to AUTO } ?: cached("header.jpg") ?: exeArt(context, game, cover = false)?.let { it to EXE }
Slot.BACKGROUND -> own.hero?.let { it to FOLDER } ?: store?.header?.let { it to AUTO } ?: cached("hero.jpg")
Slot.LOGO -> own.logo?.let { it to FOLDER } ?: cached("logo.png")
Slot.ICON -> own.icon?.let { it to FOLDER } ?: File(cache, "icon-sgdb.png").takeIf { it.isFile }?.let { it to SGDB }
?: exeIcon(context, game)?.let { it to EXE }
@@ -228,46 +237,103 @@ object AddedGameArt {
return if (tmp.renameTo(png)) png else null
}
fun fetchMissing(context: Context, games: List<AddedGames.Game>): Boolean {
internal class Sources(
val storeLogo: (AddedGames.Game) -> Boolean,
val steamSearch: (String) -> Int?,
val steam: (String, File, Set<Slot>) -> Boolean,
val sgdbGame: (String, Int?, String) -> Int?,
val sgdb: (String, Int, File, Set<Slot>) -> Boolean,
)
private val network = Sources(
storeLogo = ::storeLogo,
steamSearch = { name -> search(name)?.toIntOrNull() },
steam = ::download,
sgdbGame = SteamGridDb::gameId,
sgdb = SteamGridDb::download,
)
private val EXTRAS = setOf(Slot.LOGO, Slot.ICON)
private val STEAM_SLOTS = setOf(Slot.COVER, Slot.WIDE, Slot.BACKGROUND, Slot.LOGO)
internal const val EXTRAS_TRIED = "extras-tried"
internal fun missing(context: Context, game: AddedGames.Game): Set<Slot> {
val own = local(game)
val cache = cacheDir(context, game)
val store = storeArt(context, game)
fun has(slot: Slot, mine: File?, cached: String) = chosen(cache, slot) != null || mine != null || File(cache, cached).isFile
return Slot.values().filterNot { slot ->
when (slot) {
Slot.COVER -> has(slot, own.portrait ?: store?.cover, "p.jpg")
Slot.WIDE -> has(slot, own.header ?: store?.header, "header.jpg")
Slot.BACKGROUND -> has(slot, own.hero ?: store?.header, "hero.jpg")
Slot.LOGO -> has(slot, own.logo, "logo.png")
Slot.ICON -> has(slot, own.icon, "icon-sgdb.png")
}
}.toSet()
}
private fun storeLogo(game: AddedGames.Game): Boolean {
val url = StoreGameSidecar.read(game.folder)?.logo ?: return false
return StoreArt.fetchLogo(game.folder, url)
}
fun fetchMissing(context: Context, games: List<AddedGames.Game>): Boolean =
fetchMissing(context, games, SteamGridDb.key(context), network)
internal fun fetchMissing(context: Context, games: List<AddedGames.Game>, sgdbKey: String, sources: Sources): Boolean {
if (!SessionPrefs.addedGamesArt(context)) return false
val sgdbKey = SteamGridDb.key(context)
val keyChangedAt = SteamGridDb.keyChangedAt(context)
var sgdbDown = false
var changed = false
for (game in games) {
if (local(game).portrait != null) continue
val start = missing(context, game)
val full = Slot.COVER in start
val allowed = if (full) Slot.values().toSet() else EXTRAS
var need = start intersect allowed
if (need.isEmpty() || (need == setOf(Slot.ICON) && sgdbKey.isEmpty())) continue
val cache = cacheDir(context, game)
if (File(cache, "p.jpg").isFile) continue
val tried = File(cache, EXTRAS_TRIED)
if (!full && tried.isFile && tried.lastModified() >= keyChangedAt && System.currentTimeMillis() - tried.lastModified() < RETRY_AFTER_MS) continue
cache.mkdirs()
val match = steamMatch(context, game) { name ->
runCatching { search(name) }.onFailure { Log.w(TAG, "${game.name}: store search failed (${it.message})") }.getOrNull()?.toIntOrNull()
if (Slot.LOGO in need && game.source != Library.ADDED && runCatching { sources.storeLogo(game) }.getOrDefault(false)) {
changed = true
need = need - Slot.LOGO
}
val appId = match.appId?.takeIf { match.certainty == SteamMatch.Certainty.FILES }
val id = match.appId?.toString()
if (id != null && download(id, cache)) changed = true
if (File(cache, "p.jpg").isFile || sgdbKey.isEmpty() || sgdbDown) continue
val stored: String? = try {
remembered(File(cache, "sgdb-id"), keyChangedAt) {
SteamGridDb.gameId(sgdbKey, appId ?: id?.toIntOrNull(), game.name)?.toString()
val match by lazy {
steamMatch(context, game) { name ->
runCatching { sources.steamSearch(name) }.onFailure { Log.w(TAG, "${game.name}: store search failed (${it.message})") }.getOrNull()
}
} catch (e: Exception) {
Log.w(TAG, "${game.name}: SteamGridDB lookup failed (${e.javaClass.simpleName}); not asking again this pass")
sgdbDown = true
null
}
val gridId = stored?.toIntOrNull() ?: continue
val grabbed = try {
SteamGridDb.download(sgdbKey, gridId, cache)
} catch (e: Exception) {
sgdbDown = true
false
val steamWant = need intersect STEAM_SLOTS
if (steamWant.isNotEmpty()) {
val id = match.appId?.toString()
if (id != null && sources.steam(id, cache, steamWant)) changed = true
need = missing(context, game) intersect allowed
}
if (grabbed) changed = true
var asked = true
if (need.isNotEmpty() && sgdbKey.isNotEmpty()) {
if (sgdbDown) asked = false else try {
val gridId = remembered(File(cache, "sgdb-id"), keyChangedAt) {
sources.sgdbGame(sgdbKey, match.appId, game.name)?.toString()
}?.toIntOrNull()
if (gridId != null && sources.sgdb(sgdbKey, gridId, cache, need)) changed = true
} catch (e: Exception) {
Log.w(TAG, "${game.name}: SteamGridDB failed (${e.javaClass.simpleName}); not asking again this pass")
sgdbDown = true
asked = false
}
}
if ((missing(context, game) intersect EXTRAS).isEmpty()) tried.delete() else if (asked) tried.writeText("")
}
return changed
}
internal fun steamMatch(context: Context, game: AddedGames.Game, search: (String) -> Int?): SteamMatch.Match {
if (game.source == Store.BATTLENET.id) {
val id = game.storeId?.let(BattleNet::title)?.steam
return SteamMatch.Match(id, if (id == null) SteamMatch.Certainty.NONE else SteamMatch.Certainty.FILES, Store.BATTLENET.id, System.currentTimeMillis())
}
val match = SteamMatch.resolve(context, game.folder.path, game.exe, game.name, legacy = File(cacheDir(context, game), "steam-appid"), search = search)
match.appId?.let { Log.i(TAG, "${game.name}: Steam app $it (${match.certainty.name.lowercase()})") }
return match
@@ -307,18 +373,20 @@ object AddedGameArt {
.filter { it.second > 0 && it.first.isNotEmpty() }
.maxByOrNull { it.second }?.first
private fun search(name: String): String? {
internal fun search(name: String): String? {
val term = name.replace(EDITION, " ").trim().ifEmpty { name }
val json = get(STORE_SEARCH + URLEncoder.encode(term, "UTF-8"))?.toString(Charsets.UTF_8) ?: return null
val items = JSONObject(json).optJSONArray("items") ?: return null
return pickStoreResult(name, (0 until items.length()).map { items.getJSONObject(it) }.map { it.optString("id") to it.optString("name") })
}
private fun download(id: String, cache: File): Boolean {
internal fun download(id: String, cache: File, slots: Set<Slot> = Slot.entries.toSet()): Boolean {
var got = false
for ((remote, localName) in listOf(
"library_600x900.jpg" to "p.jpg", "header.jpg" to "header.jpg", "library_hero.jpg" to "hero.jpg", "logo.png" to "logo.png",
for ((slot, remote, localName) in listOf(
Triple(Slot.COVER, "library_600x900.jpg", "p.jpg"), Triple(Slot.WIDE, "header.jpg", "header.jpg"),
Triple(Slot.BACKGROUND, "library_hero.jpg", "hero.jpg"), Triple(Slot.LOGO, "logo.png", "logo.png"),
)) {
if (slot !in slots) continue
val dst = File(cache, localName)
if (dst.isFile) continue
val bytes = get("$CDN$id/$remote") ?: steamAssetUrl(id, remote)?.let { get(it) } ?: continue
@@ -79,6 +79,18 @@ object AddedGames {
return null
}
fun hostPath(context: Context, guest: String): File? {
if (!guest.startsWith("/") || guest.split('/').any { it == ".." }) return null
val mapped = roots(context).map { it.host to it.guest } + StoreInstallRoot.externalRoots(context) +
listOfNotNull(GameStorage.effective(context)?.let { File(it.path) to LIBRARY }) +
(android.os.Environment.getExternalStorageDirectory() to "/root/Storage")
for ((host, root) in mapped.sortedByDescending { it.second.length }) {
if (guest == root) return host
if (guest.startsWith("$root/")) return File(host, guest.removePrefix("$root/"))
}
return File(LinuxRuntime.rootDir(context), guest.removePrefix("/"))
}
fun candidates(folder: File): List<File> = rank(folder).exes
fun rank(folder: File): GameExePicker.Ranking {
@@ -177,6 +189,9 @@ object AddedGames {
scanGame(context, folder, out, single = entry)
}
for (folder in StoreInstallRoot.gameFolders(context)) if (StoreInstallRoot.isFinished(folder)) scanGame(context, folder, out)
for (game in com.droiddeck.launcher.runtime.BattleNet.games(context)) {
if (game.folder.path !in removed) com.droiddeck.launcher.runtime.BattleNet.shortcut(context, game)?.let(out::add)
}
return out.distinctBy { it.folder.canonicalPath }
}
@@ -274,7 +289,7 @@ object AddedGames {
class FolderResult(val added: List<Game>, val already: Int)
fun addFolder(context: Context, dir: File, onGame: (Game) -> Unit = {}, onChecking: (String?) -> Unit = {}): FolderResult {
fun addFolder(context: Context, dir: File, onGame: (Game) -> Unit = {}, onChecking: (String?) -> Unit = {}, showHidden: Boolean = false): FolderResult {
val app = context.applicationContext
// Binds are mounted at session start, so a new one reaches the session from the next start.
if (guestPath(app, dir) == null) bindFolder(app, dir)
@@ -283,7 +298,7 @@ object AddedGames {
var already = 0
val fresh = ArrayList<String>()
val added = ArrayList<Game>()
for (sub in dir.listFiles { f -> f.isDirectory && !f.name.startsWith(".") }?.sortedBy { it.name.lowercase() }.orEmpty()) {
for (sub in dir.listFiles { f -> f.isDirectory && (showHidden || !f.name.startsWith(".")) }?.sortedBy { it.name.lowercase() }.orEmpty()) {
val path = canonical(sub)
if (listed.any { it == path || inside(path, it) || inside(it, path) }) { already++; continue }
onChecking(sub.name)
@@ -387,11 +402,17 @@ object AddedGames {
}
}
private class LoginUser(val id: String, val mostRecent: Boolean, val autoLogin: Boolean, val timestamp: Long)
internal fun accountConfig(root: File): File? {
val users = runCatching { File(root, "config/loginusers.vdf").readText() }.getOrDefault("")
val recent = Regex(""""(\d{5,})"\s*\{([^}]*)\}""").findAll(users)
.lastOrNull { Regex(""""MostRecent"\s*"1"""").containsMatchIn(it.groupValues[2]) }
val account = recent?.groupValues?.get(1)?.toLongOrNull()?.let { (it - 76561197960265728L).toString() }
val recent = Regex(""""(\d{5,})"\s*\{([^}]*)\}""").findAll(users).map {
val body = it.groupValues[2]
LoginUser(it.groupValues[1], Regex(""""MostRecent"\s*"1"""").containsMatchIn(body), Regex(""""AutoLogin"\s*"1"""").containsMatchIn(body),
Regex(""""timestamp"\s*"(\d+)"""").find(body)?.groupValues?.get(1)?.toLongOrNull() ?: 0L)
}.filter { it.mostRecent || it.autoLogin || it.timestamp > 0 }.toList().asReversed()
.maxWithOrNull(compareBy<LoginUser>({ it.mostRecent }, { it.autoLogin }, { it.timestamp }))
val account = recent?.id?.toLongOrNull()?.let { (it - 76561197960265728L).toString() }
?: File(root, "userdata").list()?.filter { it.isNotEmpty() && it.all(Char::isDigit) && it != "0" }?.singleOrNull()
return account?.let { File(root, "userdata/$it/config") }
}
@@ -257,14 +257,18 @@ object AutoComponents {
private const val FINDINGS = "wincomp-findings.json"
private const val DETECTORS = "d3"
internal fun signature(context: Context, game: Library.SteamGame): String {
val folder = game.gameFiles
val parts = listOfNotNull(
DETECTORS,
folder?.lastModified(),
folder?.let { SteamRedists.manifest(it, game.appId)?.lastModified() },
folder?.let { StoreGameSidecar.file(it).lastModified() },
folder?.let { File(it, "fuel.json").lastModified() },
folder?.listFiles { f -> f.name.lowercase().let { it.startsWith("installscript") && it.endsWith(".vdf") } }
?.maxOfOrNull { it.lastModified() },
SteamAppInfo.appinfoFile(context).lastModified(),
folder?.let { SteamMatch.get(context, it.path) }?.let { "${it.appId}/${it.certainty}" },
steamAppOf(context, game)?.let { SteamAppInfo.state(context, it) },
@@ -11,7 +11,7 @@ object GameRemoval {
enum class Kind { STORE, SHORTCUT, LAUNCHER }
fun kind(game: Library.SteamGame): Kind = when {
game.library == Library.EPIC_LAUNCHER -> Kind.LAUNCHER
game.library == Library.EPIC_LAUNCHER || game.library == Library.BATTLENET_LAUNCHER -> Kind.LAUNCHER
game.library == Library.ADDED && game.source == Library.ADDED -> Kind.SHORTCUT
else -> Kind.STORE
}
@@ -11,13 +11,13 @@ import android.graphics.drawable.Icon
import android.net.Uri
import android.widget.Toast
import com.droiddeck.launcher.LauncherIcon
import com.droiddeck.launcher.MainActivity
import com.droiddeck.launcher.FrontEndActivity
import com.droiddeck.launcher.R
import java.io.File
object GameShortcuts {
fun launchIntent(context: Context, gameId: String) = Intent(Intent.ACTION_VIEW,
Uri.parse(GameLaunchLink.uri(gameId)), context, MainActivity::class.java)
Uri.parse(GameLaunchLink.uri(gameId)), context, FrontEndActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
private fun info(context: Context, game: Library.SteamGame): ShortcutInfo {
@@ -16,16 +16,23 @@ object Library {
val icon: File? = null,
val source: String = SOURCE_STEAM,
val storeId: String? = null,
val logo: File? = null,
) {
fun withArt(art: File?, hero: File?, icon: File?) =
SteamGame(appId, name, art, library, gameId, hero, lastPlayed, gameFiles, protonPrefix, icon, source, storeId)
fun withArt(art: File?, hero: File?, icon: File?, logo: File? = this.logo) =
SteamGame(appId, name, art, library, gameId, hero, lastPlayed, gameFiles, protonPrefix, icon, source, storeId, logo)
val gameIdString: String get() = java.lang.Long.toUnsignedString(gameId)
val profileKey: String get() = if (library == ADDED) java.lang.Integer.toUnsignedString(appId) else appId.toString()
val profileKey: String get() = when {
source == com.droiddeck.launcher.stores.Store.BATTLENET.id -> com.droiddeck.launcher.runtime.BattleNet.APP_ID.toString()
library == ADDED -> java.lang.Integer.toUnsignedString(appId)
else -> appId.toString()
}
}
const val ADDED = "added"
const val EPIC_LAUNCHER = "epic-launcher"
const val EPIC_LAUNCHER_NAME = "Epic Games Launcher"
const val BATTLENET_LAUNCHER = "battlenet-launcher"
const val XBOX_CLOUD = "xbox-cloud"
const val SOURCE_STEAM = "steam"
class Rom(val name: String, val hostPath: File, val guestPath: String, val emulatorId: String, val art: File? = null)
@@ -45,12 +52,14 @@ object Library {
1054830,
1070560,
1113280,
1161040,
1245040,
1391110,
1420170,
1493710,
1580130,
1628350,
1826330,
1887720,
2180100,
228980,
@@ -68,6 +77,7 @@ object Library {
)
private val STEAM_CAPSULES = listOf("library_capsule.jpg", "library_600x900.jpg")
private val STEAM_HEROES = listOf("library_hero.jpg")
internal val STEAM_LOGOS = listOf("logo.png")
private val STEAM_ICON = Regex("[a-fA-F0-9]{40}\\.(jpg|png)")
private val NAME = Regex("^\\s*\"name\"\\s*\"([^\"]*)\"", RegexOption.MULTILINE)
private val STATE = Regex("^\\s*\"StateFlags\"\\s*\"(\\d+)\"", RegexOption.MULTILINE)
@@ -102,7 +112,10 @@ object Library {
.firstOrNull { it.isDirectory }
}
fun steamGames(context: Context, strictRead: Boolean = false, arch: String = SessionPrefs.activeSteamClientArch(context)): List<SteamGame> {
fun steamGames(
context: Context, strictRead: Boolean = false, arch: String = SessionPrefs.activeSteamClientArch(context),
withShared: Boolean = true,
): List<SteamGame> {
val root = SessionPrefs.steamRoot(context, arch)
val cache = File(root, "appcache/librarycache")
val libraries = steamLibraries(context, arch)
@@ -119,8 +132,9 @@ object Library {
val name = NAME.find(text)?.groupValues?.get(1)?.trim().orEmpty()
val flags = STATE.find(text)?.groupValues?.get(1)?.toIntOrNull() ?: 0
if (name.isEmpty() || flags and 4 == 0) return@forEach
val art = steamCacheImage(cache, appId, STEAM_CAPSULES)
val hero = steamCacheImage(cache, appId, STEAM_HEROES)
val images = SteamCacheImages(File(cache, appId.toString()))
val art = images.find(STEAM_CAPSULES)
val hero = images.find(STEAM_HEROES)
val lastPlayed = LAST_PLAYED.find(text)?.groupValues?.get(1)?.toLongOrNull() ?: 0L
val installDir = INSTALL_DIR.find(text)?.groupValues?.get(1)?.trim()
?.takeIf { it.isNotEmpty() && it != "." && it != ".." && '/' !in it && '\\' !in it }
@@ -129,14 +143,25 @@ object Library {
appId, name, art, label, hero = hero, lastPlayed = lastPlayed,
gameFiles = gameFiles,
protonPrefix = protonPrefix(context, appId.toLong(), library, roots),
icon = steamCacheImage(cache, appId, listOf("icon.jpg", "icon.png"))
?: File(cache, appId.toString()).listFiles()?.firstOrNull { it.isFile && STEAM_ICON.matches(it.name) },
icon = images.icon(),
logo = images.find(STEAM_LOGOS),
))
}
}
if (withShared) {
val other = if (arch == "x86_64") "arm64" else "x86_64"
val shared = sharedApps(context, arch) - out.keys
if (shared.isNotEmpty()) runCatching { steamGames(context, arch = other, withShared = false) }.getOrDefault(emptyList())
.filter { it.appId in shared }.forEach { out[it.appId] = it }
}
return out.values.toList()
}
internal fun sharedApps(context: Context, arch: String): Set<Int> {
val view = File(LinuxRuntime.rootDir(context), "root/.droiddeck-x64/shared/${if (arch == "x86_64") "arm64" else "x86-64"}/steamapps")
return view.list().orEmpty().mapNotNull { Regex("appmanifest_(\\d+)\\.acf").matchEntire(it)?.groupValues?.get(1)?.toIntOrNull() }.toSet()
}
fun steamLibraryArt(context: Context): List<Pair<Int, File>> {
val root = SessionPrefs.activeSteamRoot(context)
val cache = File(root, "appcache/librarycache")
@@ -184,9 +209,24 @@ object Library {
(runCatching { g.folder.canonicalPath }.getOrDefault(g.folder.absolutePath) !in claimed &&
AddedGames.guestPath(context, g.folder).let { it == null || it !in claimed })
}
return (steam + own.map { g -> addedGame(context, g, roots) }).distinctBy { it.gameId }
val played = shortcutsLastPlayed(SessionPrefs.steamRoot(context, arch))
return (steam + own.map { g -> addedGame(context, g, roots, played[g.appId] ?: 0L) }).distinctBy { it.gameId }
}
/** Steam's LastPlayTime for each non-Steam shortcut, by its unsigned shortcut app id. */
internal fun shortcutsLastPlayed(root: File): Map<Long, Long> = runCatching {
val config = AddedGames.accountConfig(root) ?: return emptyMap()
val bytes = File(config, "shortcuts.vdf").takeIf { it.isFile }?.readBytes() ?: return emptyMap()
val vdf = SteamAppInfo.readMap(java.nio.ByteBuffer.wrap(bytes).order(java.nio.ByteOrder.LITTLE_ENDIAN), null)
(vdf["shortcuts"] as? Map<*, *>)?.values?.filterIsInstance<Map<*, *>>()?.mapNotNull { s ->
fun int(key: String) = s.entries.firstOrNull { (it.key as? String).equals(key, ignoreCase = true) }
?.let { (it.value as? String)?.toLongOrNull() }?.and(0xFFFFFFFFL)
val id = int("appid") ?: return@mapNotNull null
val time = int("LastPlayTime")?.takeIf { it > 0L } ?: return@mapNotNull null
id to time
}?.toMap()
}.getOrNull().orEmpty()
fun claimedFolders(steam: List<SteamGame>): Set<String> = steam.filter(::fromSteamClient).flatMap { g ->
val dir = g.gameFiles ?: return@flatMap emptyList()
listOfNotNull(
@@ -195,24 +235,35 @@ object Library {
)
}.toSet()
fun addedGame(context: Context, g: AddedGames.Game, roots: List<File> = steamLibraries(context).map { it.first }): SteamGame =
fun addedGame(
context: Context, g: AddedGames.Game, roots: List<File> = steamLibraries(context).map { it.first },
lastPlayed: Long = 0L,
): SteamGame =
AddedGameArt.resolve(context, g).let { art ->
SteamGame(
g.steamAppId ?: g.appId.toInt(), g.name, art.portrait ?: art.header, ADDED, g.gameId,
hero = art.hero ?: art.header, gameFiles = g.folder,
protonPrefix = protonPrefix(context, g.steamAppId?.toLong() ?: g.appId, libraries = roots),
hero = art.hero ?: art.header, lastPlayed = lastPlayed, gameFiles = g.folder,
protonPrefix = if (g.source == com.droiddeck.launcher.stores.Store.BATTLENET.id) File(com.droiddeck.launcher.runtime.BattleNet.prefix(context), "pfx").takeIf { it.isDirectory }
else protonPrefix(context, g.steamAppId?.toLong() ?: g.appId, libraries = roots),
icon = art.icon?.takeIf { it.extension.lowercase() != "ico" },
source = g.source, storeId = g.storeId,
logo = art.logo,
)
}
internal fun steamCacheImage(cache: File, appId: Int, names: List<String>): File? {
val appDir = File(cache, appId.toString())
val dirs = listOf(appDir) + appDir.listFiles()
.orEmpty().filter { it.isDirectory }.sortedBy { it.name }
return names.asSequence()
internal fun steamCacheImage(cache: File, appId: Int, names: List<String>): File? =
SteamCacheImages(File(cache, appId.toString())).find(names)
internal class SteamCacheImages(appDir: File) {
private val entries = appDir.listFiles().orEmpty()
private val dirs = listOf(appDir) + entries.filter { it.isDirectory }.sortedBy { it.name }
fun find(names: List<String>): File? = names.asSequence()
.flatMap { name -> dirs.asSequence().map { File(it, name) } }
.firstOrNull { it.isFile && it.length() > 0L }
fun icon(): File? = find(listOf("icon.jpg", "icon.png"))
?: entries.firstOrNull { it.isFile && STEAM_ICON.matches(it.name) }
}
private class Spec(val id: String, val name: String, val system: String, val program: String, val folders: List<String>, val exts: Set<String>, val atPanel: Boolean = false)
@@ -27,6 +27,7 @@ object LibraryCache {
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 },
logo = o.file("logo"),
)
} to x64
}.getOrElse { e -> Log.w(TAG, "unreadable, ignored: ${e.message}"); Pair(emptyList(), false) }
@@ -44,7 +45,7 @@ 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).putFile("logo", g.logo)
.put("source", g.source).apply { g.storeId?.let { put("storeId", it) } },
)
val f = file(context)
@@ -22,10 +22,12 @@ object SteamGameArt {
val cover = chosenFile(context, game.appId, AddedGameArt.Slot.COVER)
val hero = chosenFile(context, game.appId, AddedGameArt.Slot.BACKGROUND)
val icon = chosenFile(context, game.appId, AddedGameArt.Slot.ICON)
if (cover == null && hero == null && icon == null) return game
val logo = chosenFile(context, game.appId, AddedGameArt.Slot.LOGO)
if (cover == null && hero == null && icon == null && logo == null) return game
return Library.SteamGame(
game.appId, game.name, cover ?: game.art, game.library, game.gameId,
hero ?: game.hero, game.lastPlayed, game.gameFiles, game.protonPrefix, icon ?: game.icon, game.source, game.storeId,
logo ?: game.logo,
)
}
@@ -5,6 +5,7 @@ import android.util.Log
import com.droiddeck.launcher.session.SessionPrefs
import org.json.JSONObject
import java.io.File
import java.util.UUID
/** Steam keeps a game's custom art in `userdata/<account>/config/grid`; this is where DroidDeck's choices and Steam's meet. */
object SteamGrid {
@@ -41,14 +42,42 @@ object SteamGrid {
private fun sigFile(context: Context, target: Target, slot: AddedGameArt.Slot) = File(target.artDir, sigName(context, slot.key))
private fun recorded(context: Context, target: Target, slot: AddedGameArt.Slot): String? =
sigFile(context, target, slot).takeIf { it.isFile }?.readText()
private const val GRID_ID = ".droiddeck-grid"
private const val UNKNOWN_GRID = "?"
private fun record(context: Context, target: Target, slot: AddedGameArt.Slot, value: String) {
target.artDir.mkdirs()
sigFile(context, target, slot).writeText(value)
/**
* A random id kept in the grid folder, so a record made against another account's folder, or one wiped with the
* runtime, reads as "not seen yet" instead of "removed in Steam". "?" never matches, so nothing is dropped.
*/
private fun gridId(grid: File): String = runCatching {
val f = File(grid, GRID_ID)
f.takeIf { it.isFile }?.readText()?.trim()?.takeIf { it.isNotEmpty() } ?: UUID.randomUUID().toString().also { id ->
grid.mkdirs()
val tmp = File(grid, "$GRID_ID.tmp")
tmp.writeText(id)
if (f.isFile) f.delete()
if (!tmp.renameTo(f)) error("grid id")
}
}.getOrDefault(UNKNOWN_GRID)
private fun readRecord(grid: File, file: File): String? {
val text = file.takeIf { it.isFile }?.readText() ?: return null
val cut = text.indexOf('\n')
val id = gridId(grid)
return if (cut > 0 && id != UNKNOWN_GRID && text.substring(0, cut) == id) text.substring(cut + 1) else null
}
private fun writeRecord(grid: File, target: Target, file: File, value: String) {
target.artDir.mkdirs()
file.writeText("${gridId(grid)}\n$value")
}
private fun recorded(context: Context, grid: File, target: Target, slot: AddedGameArt.Slot): String? =
readRecord(grid, sigFile(context, target, slot))
private fun record(context: Context, grid: File, target: Target, slot: AddedGameArt.Slot, value: String) =
writeRecord(grid, target, sigFile(context, target, slot), value)
private fun writtenByDroidDeck(grid: File): Set<String> = runCatching {
val manifest = JSONObject(File(grid, MANIFEST).readText())
manifest.keys().asSequence().filter { name -> File(grid, name).let { it.isFile && it.length() == manifest.optLong(name, -1L) } }.toSet()
@@ -62,7 +91,7 @@ object SteamGrid {
val grid = dir(context) ?: return true
if (file == null) {
if (files(grid, target.id, slot).map { it.delete() }.any { !it }) return false
record(context, target, slot, "")
record(context, grid, target, slot, "")
return true
}
val ext = file.extension.lowercase().let { if (it == "jpeg") "jpg" else it }
@@ -76,7 +105,7 @@ object SteamGrid {
return false
}
if (files(grid, target.id, slot).filter { it != dst }.map { it.delete() }.any { !it }) return false
record(context, target, slot, signature(placed))
record(context, grid, target, slot, signature(placed))
return true
}
@@ -90,13 +119,11 @@ object SteamGrid {
/** The logo position chosen here (or taken over from Steam) for a game; null when Steam's own default applies. */
fun position(target: Target): LogoPosition.Value? = positionIn(File(target.artDir, POSITION))
private fun positionRecorded(context: Context, target: Target): String? =
File(target.artDir, sigName(context, POSITION_SIG)).takeIf { it.isFile }?.readText()
private fun positionRecorded(context: Context, grid: File, target: Target): String? =
readRecord(grid, File(target.artDir, sigName(context, POSITION_SIG)))
private fun recordPosition(context: Context, target: Target, value: String) {
target.artDir.mkdirs()
File(target.artDir, sigName(context, POSITION_SIG)).writeText(value)
}
private fun recordPosition(context: Context, grid: File, target: Target, value: String) =
writeRecord(grid, target, File(target.artDir, sigName(context, POSITION_SIG)), value)
private fun storePosition(target: Target, value: LogoPosition.Value?): Boolean {
val file = File(target.artDir, POSITION)
@@ -119,7 +146,7 @@ object SteamGrid {
val next = if (value == null) existing?.let(LogoPosition::without) else LogoPosition.toJson(value, existing ?: JSONObject())
if (next == null) {
if (file.isFile && !file.delete()) return false
recordPosition(context, target, "")
recordPosition(context, grid, target, "")
return true
}
grid.mkdirs()
@@ -129,7 +156,7 @@ object SteamGrid {
tmp.delete()
return false
}
recordPosition(context, target, signature(file))
recordPosition(context, grid, target, signature(file))
return true
}
@@ -144,16 +171,16 @@ object SteamGrid {
if (hasUnknownPosition(file)) return
val theirs = positionIn(file)
val now = if (theirs != null) signature(file) else ""
val before = positionRecorded(context, target)
val before = positionRecorded(context, grid, target)
val own = position(target)
when {
before == null && own != null && theirs == null -> placePosition(context, target, own)
before == null || now != before -> {
// Recorded only once the local side agrees, so a failed take-over is tried again on the next scan.
if (theirs != null) {
if (own == theirs || storePosition(target, theirs)) recordPosition(context, target, now)
if (own == theirs || storePosition(target, theirs)) recordPosition(context, grid, target, now)
} else {
if (own == null || storePosition(target, null)) recordPosition(context, target, "")
if (own == null || storePosition(target, null)) recordPosition(context, grid, target, "")
}
}
own != null && own != theirs -> placePosition(context, target, own)
@@ -173,20 +200,20 @@ object SteamGrid {
val present = files(grid, target.id, slot)
val theirs = present.filter { it.name !in ours }.maxByOrNull { it.lastModified() }
val now = signature(theirs)
val before = recorded(context, target, slot)
val before = recorded(context, grid, target, slot)
when {
before == null && own != null && theirs == null -> write(context, target, slot, own)
before == null || now != before -> {
if (theirs != null) {
if (own != null && own.length() == theirs.length() && own.readBytes().contentEquals(theirs.readBytes())) record(context, target, slot, now)
if (own != null && own.length() == theirs.length() && own.readBytes().contentEquals(theirs.readBytes())) record(context, grid, target, slot, now)
else if (AddedGameArt.store(target.artDir, slot, AddedGameArt.Option(AddedGameArt.STEAM, theirs.path, theirs.path))) {
target.setSource(slot, AddedGameArt.STEAM + "|steam-client")
record(context, target, slot, now)
record(context, grid, target, slot, now)
changed = true
}
} else {
if (own != null && present.isEmpty()) { own.delete(); target.setSource(slot, ""); changed = true }
record(context, target, slot, "")
record(context, grid, target, slot, "")
}
}
own != null && theirs != null && now == before && own.length() != theirs.length() -> write(context, target, slot, own)
@@ -84,18 +84,23 @@ object SteamGridDb {
.filter { it.second > 0 && it.first > 0 }
.maxByOrNull { it.second }?.first
fun download(key: String, id: Int, cache: File): Boolean {
internal fun cacheName(slot: AddedGameArt.Slot): String =
when (slot) {
AddedGameArt.Slot.COVER -> "p.jpg"
AddedGameArt.Slot.WIDE -> "header.jpg"
AddedGameArt.Slot.BACKGROUND -> "hero.jpg"
AddedGameArt.Slot.LOGO -> "logo.png"
AddedGameArt.Slot.ICON -> "icon-sgdb.png"
}
fun download(key: String, id: Int, cache: File, slots: Set<AddedGameArt.Slot> = AddedGameArt.Slot.values().toSet()): Boolean {
var got = false
for ((path, local) in listOf(
"grids/game/$id?dimensions=600x900&$QUERY" to "p.jpg",
"grids/game/$id?dimensions=460x215,920x430&$QUERY" to "header.jpg",
"heroes/game/$id?$QUERY" to "hero.jpg",
"logos/game/$id?$QUERY" to "logo.png",
"icons/game/$id?$QUERY" to "icon-sgdb.png",
)) {
for (slot in AddedGameArt.Slot.values()) {
if (slot !in slots) continue
val local = cacheName(slot)
val dst = File(cache, local)
if (dst.isFile) continue
val items = json(key, "$API/$path")?.optJSONArray("data") ?: continue
val items = json(key, "$API/${path(id, slot)}")?.optJSONArray("data") ?: continue
val url = best(items, local.endsWith(".png")) ?: continue
val bytes = AddedGameArt.get(url) ?: continue
val tmp = File(cache, "$local.tmp")
@@ -15,6 +15,11 @@ object SteamRedists {
"228989" to ("vcredist2022_dll" to "VC++ 2022"),
"228990" to ("d3dx9" to "DirectX (June 2010)"),
)
/** Redists a game's own install script runs on Windows that its shared depots don't name. */
val SCRIPTED = mapOf(
72850 to listOf("xact" to "DirectX XACT audio"),
489830 to listOf("xact_x64" to "DirectX XACT audio (64-bit)"),
)
private val BLOCK = Regex("\"SharedDepots\"\\s*\\{([^}]*)\\}", RegexOption.IGNORE_CASE)
private val ENTRY = Regex("\"(\\d+)\"\\s+\"\\d+\"")
@@ -23,6 +28,17 @@ object SteamRedists {
return File(steamapps, "appmanifest_$appId.acf").takeIf { it.isFile }
}
/** Redists the game's own install script has Steam run on Windows, from its "Run Process" entries. */
fun installScript(gameDir: File?): List<Pair<String, String>> =
gameDir?.listFiles { f ->
f.isFile && f.length() < 1L shl 20 && f.name.lowercase().let { it.startsWith("installscript") && it.endsWith(".vdf") }
}.orEmpty().sortedBy { it.name }.flatMap { file ->
val root = runCatching { com.droiddeck.launcher.input.KeyValues.parse(file.readText()) }.getOrNull() ?: return@flatMap emptyList()
(root.child("InstallScript") ?: root).children("Run Process")
.flatMap { run -> run.entries.mapNotNull { (it.second as? com.droiddeck.launcher.input.KeyValues.Node)?.string("process 1") } }
.flatMap { path -> DependencyDetector.componentsFor(path).map { it to DependencyDetector.label(it) } }
}.distinctBy { it.first }
fun detect(gameDir: File?, appId: Int): List<DependencyDetector.Recommendation> {
val text = try {
manifest(gameDir, appId)?.takeIf { it.length() < 1L shl 20 }?.readText() ?: return emptyList()
@@ -3,12 +3,13 @@ package com.droiddeck.launcher.frontend
import android.content.Context
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreGameSidecar
import com.droiddeck.launcher.session.WinComponents
import org.json.JSONObject
import java.io.File
import java.util.Locale
object WinCompSources {
enum class Origin { STEAM, GOG, EPIC, AMAZON, STEAM_NAME, FOLDER }
enum class Origin { STEAM, GOG, EPIC, AMAZON, STEAM_NAME, FOLDER, XBOX }
data class Finding(val component: String?, val original: String, val origin: Origin, val auto: Boolean, val args: List<String> = emptyList())
@@ -49,6 +50,17 @@ object WinCompSources {
fun installer(path: String): List<String> = if (UE_PREREQ.containsMatchIn(path)) listOf("vcredist2022", "d3dx9") else DependencyDetector.componentsFor(path)
private val XBOX_KNOWN = Regex("""<KnownDependency\s+Name="([A-Za-z0-9_.-]{1,40})"""")
// VC14 also registers the ARM64 runtime: launchers running as ARM64EC look for it and stop when it is missing.
private val XBOX_COMPONENTS = mapOf("VC14" to listOf("vcredist2022", WinComponents.VCREDIST_ARM64))
/** Microsoft installs a PC Game Pass game's declared runtimes with it; VC14 is wanted by nearly all and is always added. */
fun xbox(folder: File?): List<Pair<String, String>> {
val config = folder?.listFiles().orEmpty().firstOrNull { it.isFile && it.name.equals("MicrosoftGame.config", ignoreCase = true) && it.length() < 1L shl 20 }
val declared = config?.let { file -> runCatching { XBOX_KNOWN.findAll(file.readText()).map { it.groupValues[1] }.toList() }.getOrNull() }.orEmpty()
return (listOf("VC14") + declared).flatMap { name -> XBOX_COMPONENTS[name].orEmpty().map { it to name } }.distinctBy { it.first }
}
fun steamDepots(ids: List<String>): List<Pair<String, String>> = ids.mapNotNull { SteamRedists.DEPOTS[it] }.distinctBy { it.first }
@@ -65,9 +77,10 @@ object WinCompSources {
fun steamList(context: Context, gameDir: File?, appId: Int, gameName: String, auto: Boolean): List<Finding> {
val local = SteamRedists.detect(gameDir, appId)
val pairs = if (local.isNotEmpty()) local.map { it.componentName to it.label }
else SteamAppInfo.info(context, appId)?.let { steamDepots(it.sharedDepots) }.orEmpty()
return pairs.map { (component, label) ->
val pairs = (if (local.isNotEmpty()) local.map { it.componentName to it.label }
else SteamAppInfo.info(context, appId)?.let { steamDepots(it.sharedDepots) }.orEmpty()) +
SteamRedists.installScript(gameDir) + SteamRedists.SCRIPTED[appId].orEmpty()
return pairs.distinctBy { it.first.removeSuffix("_dll") }.map { (component, label) ->
Finding(component, label, if (auto) Origin.STEAM else Origin.STEAM_NAME, auto, listOf(label, gameName, appId.toString()))
}
}
@@ -94,6 +107,7 @@ object WinCompSources {
parts.forEach { out += Finding(it, original, Origin.EPIC, true) }
}
}
store == Store.XBOX -> xbox(folder).forEach { (component, original) -> out += Finding(component, original, Origin.XBOX, true) }
store == Store.AMAZON -> folder?.let { amazonPostInstall(it) }?.forEach { command ->
val parts = installer(command)
val original = command.replace('\\', '/').substringAfterLast('/')
@@ -3,7 +3,7 @@ package com.droiddeck.launcher.glasses
import java.nio.ByteBuffer
import java.nio.ByteOrder
/** Rokid Air/Max share one USB identity; the product string distinguishes them. */
/** Rokid Air/Max share one USB identity; Max 2 has a separate product ID. */
internal class RokidModel private constructor(override val name: String, private val max: Boolean) : GlassesModel {
override val controllable = true
override val brightnessMax = BRIGHTNESS_STEPS
@@ -33,6 +33,7 @@ internal class RokidModel private constructor(override val name: String, private
companion object {
const val VENDOR_ID = 0x04d2
const val PRODUCT_ID = 0x162f
const val MAX2_PRODUCT_ID = 0x2002
const val BRIGHTNESS_STEPS = 10
// Vendor control requests, device recipient.
@@ -52,9 +53,9 @@ internal class RokidModel private constructor(override val name: String, private
// 0x04d2 is a shared vendor ID, so the Rokid product string is required too.
fun model(vid: Int, pid: Int, name: String?): GlassesModel? {
if (vid != VENDOR_ID || pid != PRODUCT_ID) return null
if (vid != VENDOR_ID || (pid != PRODUCT_ID && pid != MAX2_PRODUCT_ID)) return null
val product = name?.trim()?.takeIf { it.startsWith("Rokid", ignoreCase = true) } ?: return null
return RokidModel(product, product.contains("Max", ignoreCase = true))
return RokidModel(product, pid == MAX2_PRODUCT_ID || product.contains("Max", ignoreCase = true))
}
fun isDisplay(name: String) = name.contains("Rokid", ignoreCase = true)
@@ -50,8 +50,10 @@ object FrameGen {
else -> context.getString(R.string.frame_gen_off)
}
fun apply(context: Context, refreshHz: Float): String? {
val mode = mode(context)
fun apply(context: Context, refreshHz: Float): String? = apply(context, mode(context), refreshHz)
/** Arms [mode] in this process's compositor; the cloud player passes its own mode and frame-rate cap. */
fun apply(context: Context, mode: Mode, refreshHz: Float): String? {
when (mode.engine) {
ENGINE_LSFG -> {
val status = Lossless.sync(context)
@@ -1,6 +1,8 @@
package com.droiddeck.launcher.gpu
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.session.SessionPrefs
object TpUbwcHint {
const val ENV = "FD_DEV_FEATURES"
@@ -8,31 +10,43 @@ object TpUbwcHint {
@Volatile private var resolved = false
@Volatile private var cached: String? = null
@Volatile var activeMode: String? = null
private set
// On Adreno 740, TPL1_DBG_ECO_CNTL1.TP_UBWC_FLAG_HINT is shared by every driver on the GPU and must
// match the system driver: blob v6xx leaves it off, newer blobs set it. A mismatch corrupts Android UI.
// match the system driver. A mismatch corrupts Android UI.
// Samsung's One UI firmware sets it whatever blob version it reports.
internal fun features(model: Int, systemDriverVersion: String?, samsung: Boolean = false): String? {
internal fun features(model: Int, systemDriverVersion: String?, samsung: Boolean = false,
mode: String = SessionPrefs.TP_UBWC_AUTO): String? {
if (model != 740) return null
if (mode == SessionPrefs.TP_UBWC_ON) return "enable_tp_ubwc_flag_hint=1"
if (mode == SessionPrefs.TP_UBWC_OFF) return "enable_tp_ubwc_flag_hint=0"
if (samsung) return "enable_tp_ubwc_flag_hint=1"
val blob = systemDriverVersion?.split('.')?.getOrNull(1)?.toIntOrNull() ?: return null
return "enable_tp_ubwc_flag_hint=" + if (blob >= 700) "1" else "0"
val parts = systemDriverVersion?.split('.') ?: return null
val blob = parts.getOrNull(1)?.toIntOrNull() ?: return null
val patch = parts.getOrNull(2)?.toIntOrNull() ?: 0
val on = blob >= 700 || (blob == 676 && patch >= 76)
return "enable_tp_ubwc_flag_hint=" + if (on) "1" else "0"
}
@Synchronized
fun forThisDevice(): String? {
fun forThisDevice(context: Context): String? {
if (resolved) return cached
val mode = SessionPrefs.tpUbwcMode(context)
val gpu = GpuInfo.detect()
val model = gpu.model
val version = if (model == 740) VulkanInfo.query()["driver_version"] else null
cached = features(model, version, gpu.oneUi8Gen2)
cached = features(model, version, gpu.oneUi8Gen2, mode)
activeMode = mode
resolved = true
Log.i(TAG, "Adreno $model${if (gpu.oneUi8Gen2) " (One UI)" else ""}, system driver ${version ?: "n/a"}: ${cached ?: "driver default"}")
Log.i(TAG, "Adreno $model${if (gpu.oneUi8Gen2) " (One UI)" else ""}, system driver ${version ?: "n/a"}, mode $mode: ${cached ?: "driver default"}")
return cached
}
fun applyToProcess() {
val value = forThisDevice() ?: return
fun restartRequired(context: Context): Boolean = resolved && activeMode != SessionPrefs.tpUbwcMode(context)
fun applyToProcess(context: Context) {
val value = forThisDevice(context) ?: return
runCatching { android.system.Os.setenv(ENV, value, true) }
.onFailure { Log.w(TAG, "setenv $ENV failed: ${it.javaClass.simpleName}") }
}
@@ -157,6 +157,56 @@ object ControllerPrefs {
prefs(context).edit().remove("layout.${width}x$height").apply()
}
fun screenKeysShown(context: Context): Boolean = prefs(context).getBoolean("screenKeysShown", true)
fun setScreenKeysShown(context: Context, shown: Boolean) {
prefs(context).edit().putBoolean("screenKeysShown", shown).apply()
}
fun screenKeys(context: Context): List<ScreenKey> = decodeScreenKeys(prefs(context).getString("screenKeys", null).orEmpty())
fun setScreenKeys(context: Context, keys: List<ScreenKey>) {
prefs(context).edit().putString("screenKeys", encodeScreenKeys(keys)).apply()
}
fun encodeScreenKeys(keys: List<ScreenKey>): String = keys.joinToString(";") {
"${it.codes.joinToString("+")}:${it.x},${it.y},${it.size}:${escape(it.glyph)}:${escape(it.text)}:${escape(it.image)}:${it.opacity}:${it.shape}:${if (it.toggle) 1 else 0}:${if (it.labels) 1 else 0}"
}
fun decodeScreenKeys(raw: String): List<ScreenKey> = raw.split(';').mapNotNull { entry ->
val parts = entry.split(':')
if (parts.size < 2) return@mapNotNull null
val keys = parts[0].split('+').mapNotNull { it.toIntOrNull() }.takeIf { it.isNotEmpty() } ?: return@mapNotNull null
val nums = parts[1].split(',').mapNotNull { it.toFloatOrNull() }.takeIf { it.size == 3 } ?: return@mapNotNull null
ScreenKey(
keys, nums[0], nums[1], nums[2].toInt(),
parts.getOrNull(2)?.let(::unescape).orEmpty(),
parts.getOrNull(3)?.let(::unescape).orEmpty(),
parts.getOrNull(4)?.let(::unescape).orEmpty(),
parts.getOrNull(5)?.toFloatOrNull()?.coerceIn(0.2f, 1f) ?: 1f,
parts.getOrNull(6)?.toIntOrNull()?.coerceIn(SCREEN_KEY_PILL, SCREEN_KEY_STICK) ?: SCREEN_KEY_PILL,
parts.getOrNull(7) == "1",
parts.getOrNull(8) != "0",
)
}
fun screenLayouts(context: Context): Map<String, String> =
prefs(context).all.mapNotNull { (key, value) ->
if (!key.startsWith("screenLayout.") || value !is String) null else key.removePrefix("screenLayout.") to value
}.toMap()
fun saveScreenLayout(context: Context, name: String, raw: String) {
prefs(context).edit().putString("screenLayout.$name", raw).apply()
}
fun deleteScreenLayout(context: Context, name: String) {
prefs(context).edit().remove("screenLayout.$name").apply()
}
private fun escape(value: String) = value.replace("%", "%25").replace(";", "%3B").replace(":", "%3A").replace(",", "%2C")
private fun unescape(value: String) = value.replace("%2C", ",").replace("%3A", ":").replace("%3B", ";").replace("%25", "%")
fun resetAll(context: Context) {
prefs(context).edit().clear().putBoolean("rumble", true).apply()
}
@@ -15,6 +15,7 @@ class DirectTouchHold(
private var y = 0f
private var consumed = false
private var pressed = false
private var delayMs = 0L
private val release = Runnable {
if (pressed) listener.onButton(PointerGestures.BTN_RIGHT, false, x, y)
pressed = false
@@ -28,6 +29,11 @@ class DirectTouchHold(
handler.postDelayed(release, 50L)
}
fun configure(config: TouchGestureConfig) {
cancel()
delayMs = if (config.longPressEnabled) config.longPressDelay.toLong().coerceAtLeast(1L) else 0L
}
fun cancel() {
handler.removeCallbacks(hold)
handler.removeCallbacks(release)
@@ -40,7 +46,7 @@ class DirectTouchHold(
MotionEvent.ACTION_DOWN -> {
cancel()
x = event.x; y = event.y
handler.postDelayed(hold, 500L)
if (delayMs > 0L) handler.postDelayed(hold, delayMs)
}
MotionEvent.ACTION_MOVE -> if (event.pointerCount > 1 ||
abs(event.x - x) > slopPx || abs(event.y - y) > slopPx) handler.removeCallbacks(hold)
@@ -0,0 +1,21 @@
package com.droiddeck.launcher.input
import android.view.MotionEvent
/** Keeps split Android touch targets from restarting one shared gesture recognizer. */
internal class GestureTouchStreamGate {
enum class Source { SURFACE, SCREEN_KEYS }
private var owner: Source? = null
fun accepts(source: Source, event: MotionEvent): Boolean {
if (owner == null && event.actionMasked == MotionEvent.ACTION_DOWN) owner = source
val accepted = owner == source
if (accepted && (event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL)) {
owner = null
}
return accepted
}
fun reset() { owner = null }
}
@@ -1,5 +1,6 @@
package com.droiddeck.launcher.input;
import android.os.Build;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
@@ -308,10 +309,10 @@ public final class PadBridge {
case KeyEvent.KEYCODE_BUTTON_MODE:
case KeyEvent.KEYCODE_HOME: pad.press(PadState.GUIDE, pressed); break;
case KeyEvent.KEYCODE_BUTTON_Z:
if (isPrimary(event)) DeckControls.INSTANCE.setGrip(DeckControls.L4, pressed);
if (isPrimary(event)) DeckControls.INSTANCE.setGrip(isNovaController(event.getDevice()) ? DeckControls.R4 : DeckControls.L4, pressed);
break;
case KeyEvent.KEYCODE_BUTTON_C:
if (isPrimary(event)) DeckControls.INSTANCE.setGrip(DeckControls.R4, pressed);
if (isPrimary(event)) DeckControls.INSTANCE.setGrip(isNovaController(event.getDevice()) ? DeckControls.L4 : DeckControls.R4, pressed);
break;
case KeyEvent.KEYCODE_BUTTON_L2: pad.leftTrigger = pressed ? 1f : 0f; break;
case KeyEvent.KEYCODE_BUTTON_R2: pad.rightTrigger = pressed ? 1f : 0f; break;
@@ -573,6 +574,12 @@ public final class PadBridge {
return slot == 0 ? state : playerStates[slot];
}
private static boolean isNovaController(InputDevice device) {
return "Retroid Pocket Nova".equalsIgnoreCase(Build.MODEL)
&& device.getVendorId() == 0x2022
&& (device.getProductId() == 0x3001 || device.getProductId() == 0x3002);
}
private boolean isPrimary(KeyEvent event) {
int id = event.getDevice().getId();
int first = firstPlayerSlot();
@@ -0,0 +1,60 @@
package com.droiddeck.launcher.input
import android.content.Context
import androidx.annotation.StringRes
import com.droiddeck.launcher.R
data class PcKey(
val label: String,
val code: Int,
val weight: Float = 1f,
val shifted: String? = null,
@StringRes val labelRes: Int = 0,
)
internal fun k(label: String, code: Int, weight: Float = 1f, shifted: String? = null) = PcKey(label, code, weight, shifted)
internal fun k(@StringRes labelRes: Int, code: Int, weight: Float = 1f) = PcKey("", code, weight, null, labelRes)
val PC_KEY_ROWS: List<List<PcKey>> = listOf(
listOf(
k(R.string.pckb_key_esc, 1), k("F1", 59), k("F2", 60), k("F3", 61), k("F4", 62), k("F5", 63), k("F6", 64),
k("F7", 65), k("F8", 66), k("F9", 67), k("F10", 68), k("F11", 87), k("F12", 88),
k(R.string.pckb_key_ins, 110), k(R.string.pckb_key_del, 111), k(R.string.pckb_key_home, 102), k(R.string.pckb_key_end, 107), k(R.string.pckb_key_pgup, 104), k(R.string.pckb_key_pgdn, 109),
),
listOf(
k("`", 41, shifted = "~"), k("1", 2, shifted = "!"), k("2", 3, shifted = "@"), k("3", 4, shifted = "#"),
k("4", 5, shifted = "$"), k("5", 6, shifted = "%"), k("6", 7, shifted = "^"), k("7", 8, shifted = "&"),
k("8", 9, shifted = "*"), k("9", 10, shifted = "("), k("0", 11, shifted = ")"), k("-", 12, shifted = "_"),
k("=", 13, shifted = "+"), k(R.string.pckb_key_backspace, 14, 2f),
),
listOf(
k(R.string.pckb_key_tab, 15, 1.5f), k("q", 16), k("w", 17), k("e", 18), k("r", 19), k("t", 20), k("y", 21),
k("u", 22), k("i", 23), k("o", 24), k("p", 25), k("[", 26, shifted = "{"), k("]", 27, shifted = "}"),
k("\\", 43, 1.5f, shifted = "|"),
),
listOf(
k(R.string.pckb_key_caps, 58, 1.75f), k("a", 30), k("s", 31), k("d", 32), k("f", 33), k("g", 34), k("h", 35),
k("j", 36), k("k", 37), k("l", 38), k(";", 39, shifted = ":"), k("'", 40, shifted = "\""),
k(R.string.pckb_key_enter, 28, 2.25f),
),
listOf(
k(R.string.pckb_key_shift, 42, 2.25f), k("z", 44), k("x", 45), k("c", 46), k("v", 47), k("b", 48), k("n", 49),
k("m", 50), k(",", 51, shifted = "<"), k(".", 52, shifted = ">"), k("/", 53, shifted = "?"),
k(R.string.pckb_key_shift, 54, 1.75f), k("↑", 103),
),
listOf(
k(R.string.pckb_key_ctrl, 29, 1.5f), k(R.string.pckb_key_super, 125, 1.25f), k(R.string.pckb_key_alt, 56, 1.25f), k(R.string.pckb_key_space, 57, 6.25f),
k(R.string.pckb_key_altgr, 100, 1.25f), k(R.string.pckb_key_ctrl, 97, 1.25f), k("←", 105), k("↓", 108), k("→", 106),
),
)
val PC_MODIFIERS: Set<Int> = setOf(42, 54, 29, 97, 56, 100, 125)
val PC_KEYS: Map<Int, PcKey> = PC_KEY_ROWS.flatten().associateBy { it.code }
fun pcKeyLabel(context: Context, code: Int): String {
val key = PC_KEYS[code] ?: return code.toString()
val text = if (key.labelRes != 0) context.getString(key.labelRes) else key.label
return if (text.length == 1 && text[0].isLetter()) text.uppercase() else text
}
@@ -0,0 +1,63 @@
package com.droiddeck.launcher.input
import android.content.Context
fun screenKeyLabel(context: Context, codes: List<Int>): String =
codes.filter { it != 0 }.joinToString("+") { short(context, it) }
private fun short(context: Context, code: Int): String = when (code) {
28 -> "⏎"
14 -> "⌫"
15 -> "Tab"
57 -> "Space"
58 -> "Caps"
42, 54 -> "Shift"
29, 97 -> "Ctrl"
56 -> "Alt"
100 -> "AltGr"
125 -> "Super"
else -> pcKeyLabel(context, code)
}
const val SCREEN_KEY_PILL = 0
const val SCREEN_KEY_ROUND = 1
const val SCREEN_KEY_CIRCLE = 2
const val SCREEN_KEY_RECT = 3
const val SCREEN_KEY_TRIGGER = 4
const val SCREEN_KEY_DPAD = 5
const val SCREEN_KEY_STICK = 6
val SCREEN_KEY_DPAD_DIRS = listOf("↑", "→", "↓", "←")
val SCREEN_KEY_STICK_DIRS = listOf("↑", "↗", "→", "↘", "↓", "↙", "←", "↖")
fun screenKeyDirections(shape: Int): List<String> = when (shape) {
SCREEN_KEY_DPAD -> SCREEN_KEY_DPAD_DIRS
SCREEN_KEY_STICK -> SCREEN_KEY_STICK_DIRS
else -> emptyList()
}
val SCREEN_KEY_SHAPES =
listOf(
SCREEN_KEY_PILL, SCREEN_KEY_ROUND, SCREEN_KEY_CIRCLE, SCREEN_KEY_RECT,
SCREEN_KEY_TRIGGER, SCREEN_KEY_DPAD, SCREEN_KEY_STICK,
)
data class ScreenKey(
val codes: List<Int>,
val x: Float,
val y: Float,
val size: Int,
val glyph: String = "",
val text: String = "",
val image: String = "",
val opacity: Float = 1f,
val shape: Int = SCREEN_KEY_PILL,
val toggle: Boolean = false,
val labels: Boolean = true,
) {
fun shownLabel(context: Context): String = when {
text.isNotEmpty() -> text
glyph.isNotEmpty() -> glyph
else -> screenKeyLabel(context, codes)
}
}
@@ -0,0 +1,148 @@
package com.droiddeck.launcher.input
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Path
object ScreenKeyIcons {
fun has(code: Int): Boolean = code in SET
private val SET = setOf(28, 14, 15, 57, 42, 54, 29, 97, 125, 103, 108, 105, 106)
fun draw(canvas: Canvas, code: Int, cx: Float, cy: Float, box: Float, paint: Paint): Boolean {
if (!has(code)) return false
val h = box / 2f
val stroke = box * 0.085f
val path = Path()
paint.style = Paint.Style.STROKE
paint.strokeWidth = stroke
paint.strokeCap = Paint.Cap.ROUND
paint.strokeJoin = Paint.Join.ROUND
when (code) {
28 -> {
path.moveTo(cx - 0.34f * h, cy - 0.22f * h)
path.lineTo(cx + 0.30f * h, cy - 0.22f * h)
path.lineTo(cx + 0.30f * h, cy + 0.24f * h)
path.lineTo(cx - 0.04f * h, cy + 0.24f * h)
canvas.drawPath(path, paint)
path.reset()
path.moveTo(cx + 0.12f * h, cy + 0.06f * h)
path.lineTo(cx - 0.04f * h, cy + 0.24f * h)
path.lineTo(cx + 0.12f * h, cy + 0.42f * h)
canvas.drawPath(path, paint)
}
14 -> {
path.moveTo(cx - 0.72f * h, cy)
path.lineTo(cx - 0.10f * h, cy - 0.50f * h)
path.lineTo(cx + 0.74f * h, cy - 0.50f * h)
path.lineTo(cx + 0.74f * h, cy + 0.50f * h)
path.lineTo(cx - 0.10f * h, cy + 0.50f * h)
path.close()
canvas.drawPath(path, paint)
path.reset()
path.moveTo(cx + 0.10f * h, cy - 0.20f * h)
path.lineTo(cx + 0.44f * h, cy + 0.20f * h)
path.moveTo(cx + 0.44f * h, cy - 0.20f * h)
path.lineTo(cx + 0.10f * h, cy + 0.20f * h)
canvas.drawPath(path, paint)
}
15 -> {
path.moveTo(cx - 0.72f * h, cy)
path.lineTo(cx + 0.44f * h, cy)
path.moveTo(cx + 0.14f * h, cy - 0.30f * h)
path.lineTo(cx + 0.46f * h, cy)
path.lineTo(cx + 0.14f * h, cy + 0.30f * h)
canvas.drawPath(path, paint)
path.reset()
path.moveTo(cx + 0.70f * h, cy - 0.48f * h)
path.lineTo(cx + 0.70f * h, cy + 0.48f * h)
canvas.drawPath(path, paint)
}
57 -> {
path.moveTo(cx - 0.64f * h, cy - 0.22f * h)
path.lineTo(cx - 0.64f * h, cy + 0.20f * h)
path.lineTo(cx + 0.64f * h, cy + 0.20f * h)
path.lineTo(cx + 0.64f * h, cy - 0.22f * h)
canvas.drawPath(path, paint)
}
42, 54 -> {
paint.style = Paint.Style.FILL
path.moveTo(cx, cy - 0.55f * h)
path.lineTo(cx + 0.62f * h, cy + 0.02f * h)
path.lineTo(cx + 0.26f * h, cy + 0.02f * h)
path.lineTo(cx + 0.26f * h, cy + 0.32f * h)
path.lineTo(cx - 0.26f * h, cy + 0.32f * h)
path.lineTo(cx - 0.26f * h, cy + 0.02f * h)
path.lineTo(cx - 0.62f * h, cy + 0.02f * h)
path.close()
canvas.drawPath(path, paint)
path.reset()
path.moveTo(cx - 0.26f * h, cy + 0.44f * h)
path.lineTo(cx + 0.26f * h, cy + 0.44f * h)
path.lineTo(cx + 0.26f * h, cy + 0.58f * h)
path.lineTo(cx - 0.26f * h, cy + 0.58f * h)
path.close()
canvas.drawPath(path, paint)
}
29, 97 -> {
path.moveTo(cx - 0.58f * h, cy + 0.26f * h)
path.lineTo(cx, cy - 0.28f * h)
path.lineTo(cx + 0.58f * h, cy + 0.26f * h)
canvas.drawPath(path, paint)
}
125 -> {
paint.style = Paint.Style.FILL
val gap = 0.07f * h
val side = 0.34f * h
for (dxi in -1..1 step 2) for (dyi in -1..1 step 2) {
val left = cx + dxi * (gap + side / 2f) - side / 2f
val top = cy + dyi * (gap + side / 2f) - side / 2f
path.addRect(left, top, left + side, top + side, Path.Direction.CW)
}
canvas.drawPath(path, paint)
}
103, 108, 105, 106 -> {
paint.style = Paint.Style.FILL
val r = 0.52f * h
when (code) {
103 -> {
path.moveTo(cx, cy - r)
path.lineTo(cx + r * 0.80f, cy + r * 0.55f)
path.lineTo(cx - r * 0.80f, cy + r * 0.55f)
}
108 -> {
path.moveTo(cx, cy + r)
path.lineTo(cx + r * 0.80f, cy - r * 0.55f)
path.lineTo(cx - r * 0.80f, cy - r * 0.55f)
}
105 -> {
path.moveTo(cx - r, cy)
path.lineTo(cx + r * 0.55f, cy - r * 0.80f)
path.lineTo(cx + r * 0.55f, cy + r * 0.80f)
}
else -> {
path.moveTo(cx + r, cy)
path.lineTo(cx - r * 0.55f, cy - r * 0.80f)
path.lineTo(cx - r * 0.55f, cy + r * 0.80f)
}
}
path.close()
canvas.drawPath(path, paint)
}
}
paint.style = Paint.Style.FILL
paint.strokeCap = Paint.Cap.BUTT
paint.strokeJoin = Paint.Join.MITER
return true
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,140 @@
package com.droiddeck.launcher.input
import org.json.JSONArray
import org.json.JSONObject
import kotlin.math.roundToInt
private val ICP_NAMES: Map<Int, String> = mapOf(
103 to "KEY_UP", 106 to "KEY_RIGHT", 108 to "KEY_DOWN", 105 to "KEY_LEFT",
28 to "KEY_ENTER", 1 to "KEY_ESC", 14 to "KEY_BKSP", 111 to "KEY_DEL", 15 to "KEY_TAB",
57 to "KEY_SPACE", 29 to "KEY_CTRL_L", 97 to "KEY_CTRL_R", 110 to "KEY_INSERT",
42 to "KEY_SHIFT_L", 54 to "KEY_SHIFT_R", 56 to "KEY_ALT_L", 100 to "KEY_ALT_R",
102 to "KEY_HOME", 107 to "KEY_END", 99 to "KEY_PRTSCN", 104 to "KEY_PG_UP", 109 to "KEY_PG_DOWN",
58 to "KEY_CAPS_LOCK", 69 to "KEY_NUM_LOCK", 41 to "KEY_GRAVE", 12 to "KEY_MINUS",
11 to "KEY_0", 2 to "KEY_1", 3 to "KEY_2", 4 to "KEY_3", 5 to "KEY_4", 6 to "KEY_5",
7 to "KEY_6", 8 to "KEY_7", 9 to "KEY_8", 10 to "KEY_9",
30 to "KEY_A", 48 to "KEY_B", 46 to "KEY_C", 32 to "KEY_D", 18 to "KEY_E", 33 to "KEY_F",
34 to "KEY_G", 35 to "KEY_H", 23 to "KEY_I", 36 to "KEY_J", 37 to "KEY_K", 38 to "KEY_L",
50 to "KEY_M", 49 to "KEY_N", 24 to "KEY_O", 25 to "KEY_P", 16 to "KEY_Q", 19 to "KEY_R",
31 to "KEY_S", 20 to "KEY_T", 22 to "KEY_U", 47 to "KEY_V", 17 to "KEY_W", 45 to "KEY_X",
21 to "KEY_Y", 44 to "KEY_Z",
26 to "KEY_BRACKET_LEFT", 27 to "KEY_BRACKET_RIGHT", 43 to "KEY_BACKSLASH", 53 to "KEY_SLASH",
39 to "KEY_SEMICOLON", 51 to "KEY_COMMA", 52 to "KEY_PERIOD", 40 to "KEY_APOSTROPHE",
59 to "KEY_F1", 60 to "KEY_F2", 61 to "KEY_F3", 62 to "KEY_F4", 63 to "KEY_F5", 64 to "KEY_F6",
65 to "KEY_F7", 66 to "KEY_F8", 67 to "KEY_F9", 68 to "KEY_F10", 87 to "KEY_F11", 88 to "KEY_F12",
79 to "KEY_KP_1", 80 to "KEY_KP_2", 81 to "KEY_KP_3", 75 to "KEY_KP_4", 76 to "KEY_KP_5",
77 to "KEY_KP_6", 71 to "KEY_KP_7", 72 to "KEY_KP_8", 73 to "KEY_KP_9", 82 to "KEY_KP_0",
)
private val ICP_CODES: Map<String, Int> = ICP_NAMES.entries.associate { (code, name) -> name to code }
private fun icpShape(shape: Int): String = when (shape) {
SCREEN_KEY_CIRCLE -> "CIRCLE"
SCREEN_KEY_RECT -> "RECT"
SCREEN_KEY_ROUND -> "ROUND_RECT"
SCREEN_KEY_DPAD -> "ROUND_RECT"
SCREEN_KEY_STICK -> "ROUND_RECT"
SCREEN_KEY_TRIGGER -> "ROUND_RECT"
else -> "ROUND_RECT"
}
private fun screenKeyShape(shape: String): Int = when (shape) {
"CIRCLE" -> SCREEN_KEY_CIRCLE
"RECT" -> SCREEN_KEY_RECT
"SQUARE" -> SCREEN_KEY_ROUND
else -> SCREEN_KEY_PILL
}
fun exportScreenLayout(keys: List<ScreenKey>, widthPx: Int, density: Float): String {
val snap = widthPx / 100f
val elements = JSONArray()
keys.forEach { key ->
val binds = JSONArray()
val directional = key.shape == SCREEN_KEY_DPAD || key.shape == SCREEN_KEY_STICK
key.codes.forEach { code ->
val name = ICP_NAMES[code]
if (name != null) binds.put(name) else if (directional) binds.put("")
}
if (binds.length() == 0) return@forEach
val shape = icpShape(key.shape)
val sizePx = key.size * density
val scale = when {
key.shape == SCREEN_KEY_DPAD -> sizePx * 2.3f / (14f * snap)
key.shape == SCREEN_KEY_STICK -> sizePx * 2.3f / (14f * snap)
shape == "CIRCLE" -> sizePx / (6f * snap)
else -> sizePx / (4f * snap)
}
elements.put(
JSONObject()
.put(
"type",
when (key.shape) {
SCREEN_KEY_DPAD -> "D_PAD"
SCREEN_KEY_STICK -> "STICK"
else -> "BUTTON"
},
)
.put("shape", shape)
.put("customColor", 0)
.put("bindings", binds)
.put("scale", scale)
.put("x", key.x)
.put("y", key.y)
.put("toggleSwitch", key.toggle)
.put("swipeable", false)
.put("text", key.text)
.put("iconId", 0)
.apply { if (key.opacity < 1f) put("opacity", key.opacity) },
)
}
return JSONObject()
.put("id", 0)
.put("name", "DroidDeck")
.put("cursorSpeed", 1.5f)
.put("elements", elements)
.toString()
}
fun importScreenLayout(raw: String, widthPx: Int, density: Float): List<ScreenKey>? {
val root = runCatching { JSONObject(raw) }.getOrNull() ?: return null
val elements = root.optJSONArray("elements") ?: return null
val snap = widthPx / 100f
val keys = mutableListOf<ScreenKey>()
for (i in 0 until elements.length()) {
val element = elements.optJSONObject(i) ?: continue
val type = element.optString("type", "BUTTON")
if (type != "BUTTON" && type != "D_PAD" && type != "STICK") continue
val binds = element.optJSONArray("bindings") ?: continue
val raw = (0 until binds.length()).map { ICP_CODES[binds.optString(it)] ?: 0 }
val codes = if (type == "D_PAD" || type == "STICK") {
raw.dropLastWhile { it == 0 }.ifEmpty { listOf(0) }
} else {
raw.filter { it != 0 }
}
if (codes.isEmpty()) continue
val dpad = type == "D_PAD"
val stick = type == "STICK"
val shape = when {
dpad -> SCREEN_KEY_DPAD
stick -> SCREEN_KEY_STICK
else -> screenKeyShape(element.optString("shape"))
}
val scale = element.optDouble("scale", 1.0).toFloat().takeIf { it > 0f } ?: 1f
val sizePx = when {
dpad || stick -> 14f * snap * scale / 2.3f
shape == SCREEN_KEY_CIRCLE -> 6f * snap * scale
else -> 4f * snap * scale
}
keys += ScreenKey(
codes = codes,
x = element.optDouble("x", 0.5).toFloat().coerceIn(0f, 1f),
y = element.optDouble("y", 0.5).toFloat().coerceIn(0f, 1f),
size = (sizePx / density).roundToInt().coerceIn(ScreenKeys.MIN_SIZE, ScreenKeys.MAX_SIZE),
text = element.optString("text").take(24),
opacity = element.optDouble("opacity", 1.0).toFloat().coerceIn(0.2f, 1f),
shape = shape,
toggle = element.optBoolean("toggleSwitch"),
)
}
return keys
}
@@ -93,7 +93,10 @@ class SteamTouchControls(
private var opened = 0
private var elements: List<Element> = emptyList()
private var tint = Color.WHITE
private var alphaScale = 0.45f
private var alphaScale = 0.99f
var style: Pair<Int, Float> = Color.WHITE to 0.99f
private set
var onStyle: (() -> Unit)? = null
private class Visual(val ref: SteamTouchBindings.Ref?, val binding: SteamTouchBindings.Binding?)
private var visuals: Map<Int, List<Visual>> = emptyMap()
@@ -191,6 +194,8 @@ class SteamTouchControls(
private fun applyColor(color: FloatArray?) {
tint = if (color != null) Color.rgb((color[0] * 255).toInt(), (color[1] * 255).toInt(), (color[2] * 255).toInt()) else Color.WHITE
alphaScale = ((color?.get(3) ?: 0.45f) * 2.2f).coerceIn(0.15f, 1f)
style = tint to alphaScale
onStyle?.invoke()
}
private fun rebuildVisuals() {
@@ -0,0 +1,410 @@
package com.droiddeck.launcher.runtime
import android.app.ActivityManager
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.session.ComponentsManager
import com.droiddeck.launcher.session.GameEnvironmentStore
import java.io.File
object BattleNet {
private const val TAG = "BattleNet"
const val APP_ID = 2596113170L
const val FEX_REQUEST = "battlenet-fex.request"
const val FEX_RESULT = "battlenet-fex.result"
const val PROGRAM = "/usr/local/bin/droiddeck-battlenet"
class Title(val uid: String, val name: String, val code: String, val steam: Int? = null, val shop: String? = null)
internal val TITLES = listOf(
Title("wow", "World of Warcraft", "WoW", shop = "world-of-warcraft-midnight"),
Title("wow_classic", "World of Warcraft Classic", "WoWC", shop = "world-of-warcraft-burning-crusade-classic-anniversary-edition-upgrades"),
Title("wow_classic_era", "World of Warcraft Classic", "WoWC"),
Title("wow_classic_anniversary", "World of Warcraft Classic", "WoWC"),
Title("diablo3", "Diablo III", "D3", shop = "diablo-iii"),
Title("d3cn", "Diablo III", "D3CN", shop = "diablo-iii"),
Title("s2", "StarCraft II", "S2", shop = "starcraft-ii"),
Title("s1", "StarCraft", "S1", shop = "starcraft-remastered"),
Title("hs_beta", "Hearthstone", "WTCG", shop = "hearthstone"),
Title("heroes", "Heroes of the Storm", "Hero", shop = "heroes-of-the-storm"),
Title("prometheus", "Overwatch 2", "Pro", 2357570, shop = "overwatch"),
Title("w3", "Warcraft III: Reforged", "W3", shop = "warcraft-3-reforged"),
Title("viper", "Call of Duty: Black Ops 4", "VIPR", shop = "call-of-duty-black-ops-4"),
Title("odin", "Call of Duty: Modern Warfare", "ODIN", 2000950, shop = "call-of-duty-modern-warfare"),
Title("lazarus", "Call of Duty: Modern Warfare 2 Campaign Remastered", "LAZR", shop = "call-of-duty-modern-warfare-2-campaign-remastered"),
Title("zeus", "Call of Duty: Black Ops Cold War", "ZEUS", shop = "call-of-duty-black-ops-cold-war"),
Title("fore", "Call of Duty: Vanguard", "FORE", shop = "call-of-duty-vanguard"),
Title("auks", "Call of Duty: Modern Warfare II", "AUKS", shop = "call-of-duty-modern-warfare-ii"),
Title("pinta", "Call of Duty: Modern Warfare III", "PNTA", 2519060, shop = "call-of-duty-modern-warfare-iii"),
Title("wlby", "Crash Bandicoot 4: It's About Time", "WLBY", 1378990, shop = "crash-bandicoot-4"),
Title("osi", "Diablo II: Resurrected", "OSI"),
Title("rtro", "Blizzard Arcade Collection", "RTRO", shop = "blizzard-arcade-collection"),
Title("anbs", "Diablo Immortal", "ANBS", shop = "diablo-immortal"),
Title("fenris", "Diablo IV", "Fen", 2344520, shop = "diablo-iv"),
Title("fen", "Diablo IV", "Fen", 2344520, shop = "diablo-iv"),
Title("d1", "Diablo", "D1", shop = "diablo"),
Title("w1", "Warcraft: Orcs & Humans", "W1", shop = "warcraft-orcs-and-humans"),
Title("w2", "Warcraft II: Battle.net Edition", "W2", shop = "warcraft-2-battle-net-edition"),
Title("w1r", "Warcraft: Remastered", "W1R", shop = "warcraft-1-remastered"),
Title("w2r", "Warcraft II: Remastered", "W2R", shop = "warcraft-2-remastered"),
Title("gryphon", "Warcraft Rumble", "GRY", shop = "warcraft-rumble"),
Title("aris", "Doom: The Dark Ages", "ARIS", 3017860, shop = "doom-the-dark-ages"),
Title("scorpio", "Sea of Thieves", "SCOR", 1172620, shop = "sea-of-thieves"),
Title("arkansas", "The Outer Worlds 2", "ARK", 1449110, shop = "the-outer-worlds-2"),
Title("libra", "Tony Hawk's Pro Skater 3 + 4", "LBRA", 2545710),
Title("aqua", "Avowed", "AQUA", 2457220, shop = "avowed"),
)
val STORE: List<Title> get() = TITLES.distinctBy { it.code }.filter { it.uid != "d3cn" && it.code != "WoWC" }
fun notInstalled(title: Title, installedIds: Collection<String?>) =
installedIds.mapNotNull { id -> id?.let(::title)?.code }.none { it == title.code }
private const val COVER_RETRY_MS = 24 * 3600_000L
private fun coverDir(context: Context, title: Title) =
File(context.filesDir, "battlenet-art/${(TITLES.firstOrNull { it.code == title.code } ?: title).uid}")
fun cover(context: Context, title: Title): File? = art(context, title).cover
private val coverLock = Any()
fun fetchCovers(context: Context, titles: List<Title> = TITLES.distinctBy { it.code }.filter { it.uid != "d3cn" }): Boolean = synchronized(coverLock) {
val art = com.droiddeck.launcher.frontend.AddedGameArt
val key = com.droiddeck.launcher.frontend.SteamGridDb.key(context)
var got = false
for (title in titles) {
val dir = coverDir(context, title)
val tried = File(dir, ".tried")
val wide = File(dir, BLIZZARD)
val done = File(dir, "p.jpg").isFile && (title.shop == null || wide.isFile)
if (done || System.currentTimeMillis() - tried.lastModified() < COVER_RETRY_MS) continue
dir.mkdirs()
if (title.steam != null && runCatching { art.download(title.steam.toString(), dir) }.getOrDefault(false)) got = true
if (title.shop != null && !wide.isFile) {
val cover = File(dir, BLIZZARD_COVER).takeUnless { File(dir, "p.jpg").isFile }
if (runCatching { com.droiddeck.launcher.stores.battlenet.BlizzardArt.fetch(title.shop, wide, cover) }
.onFailure { Log.w(TAG, "Blizzard art for ${title.uid}: ${it.javaClass.simpleName}") }.getOrDefault(false)) got = true
}
if (!File(dir, "p.jpg").isFile && !File(dir, BLIZZARD_COVER).isFile && key.isNotEmpty()) {
val id = runCatching { com.droiddeck.launcher.frontend.SteamGridDb.gameId(key, title.steam, title.name) }.getOrNull()
if (id != null && runCatching { com.droiddeck.launcher.frontend.SteamGridDb.download(key, id, dir) }.getOrDefault(false)) got = true
}
runCatching { tried.writeText("") ; tried.setLastModified(System.currentTimeMillis()) }
}
got
}
private val LOCALES = setOf(
"enus", "engb", "dede", "eses", "esmx", "frfr", "itit", "kokr", "plpl", "ptbr", "ptpt", "ruru", "zhcn", "zhtw", "jajp", "thth", "trtr", "arsa",
)
private val NOT_GAMES = setOf("agent", "bna", "battle.net")
class Game(val uid: String, val name: String, val code: String, val folder: File) {
val title: Title get() = title(uid) ?: Title(uid, name, code)
}
class Art(val cover: File?, val header: File?)
private const val BLIZZARD = "blizzard.jpg"
private const val BLIZZARD_COVER = "p-blizzard.jpg"
fun art(context: Context, title: Title): Art {
val dir = coverDir(context, title)
fun first(vararg names: String) = names.map { File(dir, it) }.firstOrNull { it.isFile }
return Art(first("p.jpg", BLIZZARD_COVER), first(BLIZZARD, "header.jpg"))
}
fun prefix(context: Context) = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/steamapps/compatdata/$APP_ID")
fun client(context: Context) = File(prefix(context), "pfx/drive_c/Program Files (x86)/Battle.net/Battle.net.exe")
fun installed(context: Context) = File(prefix(context), "pfx/drive_c/Program Files (x86)/Battle.net/Battle.net Launcher.exe").isFile
fun profile(context: Context): Pair<String, String>? =
(APP_ID.toString() to context.getString(com.droiddeck.launcher.R.string.bnet_name)).takeIf { installed(context) }
fun icon(context: Context): File? {
val out = File(context.filesDir, "battlenet-art/launcher/icon.png")
if (out.isFile) return out
val exe = client(context).takeIf { it.isFile } ?: return null
val bitmap = runCatching { com.droiddeck.launcher.files.PeIconExtractor.extract(exe) }.getOrNull() ?: return null
return runCatching {
out.parentFile?.mkdirs()
val tmp = File(out.path + ".tmp")
tmp.outputStream().use { check(bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, it)) }
check(tmp.renameTo(out))
out
}.getOrNull()
}
fun launcherEntry(installed: Boolean, art: File? = null): com.droiddeck.launcher.frontend.Library.SteamGame? =
if (!installed) null
else com.droiddeck.launcher.frontend.Library.SteamGame(
appId = APP_ID.toInt(), name = com.droiddeck.launcher.stores.Store.BATTLENET.label, art = art,
library = com.droiddeck.launcher.frontend.Library.BATTLENET_LAUNCHER, gameId = APP_ID,
source = com.droiddeck.launcher.stores.Store.BATTLENET.id,
)
fun fexInUse(context: Context): String? = ComponentsManager.gameComponents(context, APP_ID.toString())["fex"]
internal fun title(uid: String): Title? {
val id = uid.lowercase()
TITLES.firstOrNull { it.uid == id }?.let { return it }
val base = id.substringBeforeLast('_', "")
return if (base.isNotEmpty() && id.substringAfterLast('_') in LOCALES) TITLES.firstOrNull { it.uid == base } else null
}
fun games(context: Context): List<Game> {
val pfx = File(prefix(context), "pfx")
val entries = runCatching { File(pfx, "system.reg").useLines { uninstallEntries(it) } }.getOrDefault(emptyList()) +
runCatching { productDbEntries(File(pfx, "drive_c/ProgramData/Battle.net/Agent/product.db").readBytes()) }.getOrDefault(emptyList())
val found = LinkedHashMap<String, Game>()
for ((uid, path) in entries) {
val title = title(uid)
if (title == null) {
if (uid.lowercase() !in NOT_GAMES) Log.i(TAG, "not listed: $uid (no launch code known)")
continue
}
if (title.code in found) continue
val folder = hostFolder(context, path)?.takeIf { it.isDirectory } ?: continue
found[title.code] = Game(title.uid, title.name, title.code, folder)
}
return found.values.toList()
}
internal fun hostFolder(context: Context, path: String): File? {
val drive = Regex("""^([CcZz]):[\\/](.*)$""").matchEntire(path.trim()) ?: return null
val relative = drive.groupValues[2].replace('\\', '/').trim('/')
if (relative.isEmpty() || relative.split('/').any { it == ".." }) return null
return if (drive.groupValues[1].equals("c", ignoreCase = true)) File(prefix(context), "pfx/drive_c/$relative")
else com.droiddeck.launcher.frontend.AddedGames.hostPath(context, "/$relative")
}
private val KEY = Regex("""^\[(.+)\](?:\s+\d+)?\s*$""")
private val UNINSTALL = Regex("""(?i)\\CurrentVersion\\Uninstall\\[^\\]+$""")
private val VALUE = Regex("""^"((?:[^"\\]|\\.)*)"=(?:str\(\d+\):)?"((?:[^"\\]|\\.)*)"\s*$""")
private val UID = Regex("""--uid=([A-Za-z0-9_.-]+)""")
internal fun uninstallEntries(lines: Sequence<String>): List<Pair<String, String>> {
val out = ArrayList<Pair<String, String>>()
var inside = false
var uid: String? = null
var location: String? = null
fun flush() {
val u = uid
val l = location
if (u != null && l != null) out.add(u to l)
uid = null
location = null
}
for (line in lines) {
if (line.startsWith("[")) {
flush()
inside = KEY.matchEntire(line)?.groupValues?.get(1)?.let { UNINSTALL.containsMatchIn(unescape(it)) } == true
continue
}
if (!inside) continue
val value = VALUE.matchEntire(line) ?: continue
val text = unescape(value.groupValues[2])
when (unescape(value.groupValues[1]).lowercase()) {
"uninstallstring" -> if (text.contains("battle.net", ignoreCase = true)) uid = UID.find(text)?.groupValues?.get(1)
"installlocation" -> location = text
}
}
flush()
return out
}
private fun unescape(s: String): String {
val out = StringBuilder(s.length)
var i = 0
while (i < s.length) {
val c = s[i++]
if (c != '\\' || i >= s.length) { out.append(c); continue }
when (val e = s[i++]) {
'n' -> out.append('\n')
'r' -> out.append('\r')
't' -> out.append('\t')
'0' -> out.append('\u0000')
'x' -> {
var end = i
while (end < s.length && end - i < 4 && s[end].lowercaseChar() in "0123456789abcdef") end++
if (end > i) { out.append(s.substring(i, end).toInt(16).toChar()); i = end } else out.append('x')
}
else -> out.append(e)
}
}
return out.toString()
}
internal fun productDbEntries(bytes: ByteArray): List<Pair<String, String>> {
val installs = runCatching { fields(bytes, 0, bytes.size) }.getOrDefault(emptyList()).filter { it.first == 1 }
return installs.mapNotNull { (_, start, end) ->
runCatching {
val install = fields(bytes, start, end)
val uid = install.firstOrNull { it.first == 1 }?.let { String(bytes, it.second, it.third - it.second) }
val path = install.firstOrNull { it.first == 3 }?.let { s -> fields(bytes, s.second, s.third).firstOrNull { it.first == 1 } }
?.let { String(bytes, it.second, it.third - it.second) }
val installed = install.firstOrNull { it.first == 4 }?.let { s -> fields(bytes, s.second, s.third).firstOrNull { it.first == 1 } }
?.let { s -> varints(bytes, s.second, s.third)[1] }
if (uid.isNullOrEmpty() || path.isNullOrEmpty() || installed == 0L) null else uid to path
}.getOrNull()
}
}
private fun fields(bytes: ByteArray, from: Int, to: Int): List<Triple<Int, Int, Int>> = walk(bytes, from, to) { _, _ -> }
private fun varints(bytes: ByteArray, from: Int, to: Int): Map<Int, Long> =
HashMap<Int, Long>().also { found -> walk(bytes, from, to) { field, value -> found.putIfAbsent(field, value) } }
private fun walk(bytes: ByteArray, from: Int, to: Int, varint: (Int, Long) -> Unit): List<Triple<Int, Int, Int>> {
val out = ArrayList<Triple<Int, Int, Int>>()
var i = from
fun next(): Long {
var value = 0L
var shift = 0
while (true) {
require(i < to && shift < 64) { "truncated" }
val b = bytes[i++].toInt() and 0xff
value = value or ((b and 0x7f).toLong() shl shift)
if (b < 0x80) return value
shift += 7
}
}
while (i < to) {
val key = next()
when ((key and 7).toInt()) {
0 -> varint((key ushr 3).toInt(), next())
1 -> { require(to - i >= 8) { "truncated" }; i += 8 }
2 -> {
val n = next()
require(n in 0..(to - i).toLong()) { "truncated" }
out.add(Triple((key ushr 3).toInt(), i, i + n.toInt()))
i += n.toInt()
}
5 -> { require(to - i >= 4) { "truncated" }; i += 4 }
else -> error("wire type ${key and 7}")
}
}
return out
}
fun shortcut(context: Context, game: Game): com.droiddeck.launcher.frontend.AddedGames.Game? {
val added = com.droiddeck.launcher.frontend.AddedGames
val client = client(context)
val guestExe = added.guestPath(context, client) ?: return null
val guestDir = added.guestPath(context, client.parentFile ?: return null) ?: return null
val guestPrefix = added.guestPath(context, prefix(context)) ?: return null
val crc = java.util.zip.CRC32().apply { update("\"$guestExe\"${game.name}".toByteArray()) }.value
val appId = com.droiddeck.launcher.session.SessionPrefs.addedGameAppId(context, game.folder.path, crc or 0x80000000L)
val exe = added.candidates(game.folder).firstOrNull() ?: client
return com.droiddeck.launcher.frontend.AddedGames.Game(
game.folder, game.name, exe, guestExe, guestDir, appId, (appId shl 32) or 0x02000000L, listOf(exe),
source = com.droiddeck.launcher.stores.Store.BATTLENET.id, storeId = game.uid,
launchOptions = "STEAM_COMPAT_DATA_PATH=$guestPrefix %command% --exec=\"launch ${game.code}\"",
)
}
fun remove(context: Context) {
val root = LinuxRuntime.rootDir(context)
com.droiddeck.launcher.core.FileUtils.delete(prefix(context))
File(root, "usr/local/share/applications/droiddeck-battlenet.desktop").delete()
com.droiddeck.launcher.core.FileUtils.delete(File(root, "root/.cache/droiddeck/battlenet"))
ComponentsManager.clearGameComponents(context, APP_ID.toString())
Log.i(TAG, "removed")
}
private val D2RFIX = Regex("""(?i)FEXCore-(\d+(?:\.\d+)*)-d2rfix(\d+)-linux\.wcp""")
private const val LISTING_MAX_AGE = 6 * 3600L
private val NEWEST = Comparator<Pair<List<Long>, Long>> { a, b ->
for (i in 0 until minOf(a.first.size, b.first.size)) if (a.first[i] != b.first[i]) return@Comparator a.first[i].compareTo(b.first[i])
if (a.first.size != b.first.size) a.first.size.compareTo(b.first.size) else a.second.compareTo(b.second)
}
internal fun d2rfix(file: String): Pair<List<Long>, Long>? {
val m = D2RFIX.matchEntire(file) ?: return null
val version = m.groupValues[1].split('.').map { it.toLongOrNull() ?: return null }
return version to (m.groupValues[2].toLongOrNull() ?: return null)
}
internal fun newestD2rFix(items: List<ComponentsManager.CatalogItem>): ComponentsManager.CatalogItem? =
items.filter { it.comp == "fex" }.mapNotNull { item -> d2rfix(item.file)?.let { item to it } }
.maxWithOrNull { a, b -> NEWEST.compare(a.second, b.second) }?.first
fun ensureFex(context: Context): String = ensureFex(context) { ComponentsManager.catalog(it, true).items }
@Synchronized
internal fun ensureFex(context: Context, refresh: (Context) -> List<ComponentsManager.CatalogItem>): String {
val item = newestD2rFix(listing(context, refresh)) ?: error("no d2rfix FEXCore build in the Nightlies listing")
val file = ComponentsManager.safeName(item.file)
if (ComponentsManager.packages(context).none { it.file == file }) {
Log.i(TAG, "downloading ${item.file}")
ComponentsManager.download(context, item) {}
}
val scope = APP_ID.toString()
if (ComponentsManager.gameComponents(context, scope)["fex"] != file) {
ComponentsManager.setGameComponent(context, scope, "fex", file)
Log.i(TAG, "FEX for the Battle.net prefix: $file")
}
return file
}
private fun listing(context: Context, refresh: (Context) -> List<ComponentsManager.CatalogItem>): List<ComponentsManager.CatalogItem> {
val cached = ComponentsManager.catalog(context, false)
val age = System.currentTimeMillis() / 1000 - cached.fetchedAt
if (age in 0 until LISTING_MAX_AGE && newestD2rFix(cached.items) != null) return cached.items
val fetched = runCatching { refresh(context) }
.onFailure { Log.w(TAG, "Nightlies listing: ${it.message}") }.getOrNull().orEmpty()
return if (newestD2rFix(fetched) != null) fetched else cached.items
}
internal fun videoMemory(totalMb: Long): Int = (totalMb / 5 / 512 * 512).coerceIn(1536, 3584).toInt()
internal fun videoMemoryOptions(megabytes: Int): String =
"dxgi.maxDeviceMemory = $megabytes; dxgi.maxSharedMemory = $megabytes; d3d9.maxAvailableMemory = $megabytes"
fun ensureVideoMemory(context: Context): Boolean {
val memory = ActivityManager.MemoryInfo()
context.getSystemService(ActivityManager::class.java)?.getMemoryInfo(memory) ?: return false
if (memory.totalMem <= 0) return false
val options = videoMemoryOptions(videoMemory(memory.totalMem / (1024 * 1024)))
val set = GameEnvironmentStore.setDefault(context, APP_ID.toString(), DXVK_CONFIG, options)
if (set) Log.i(TAG, "DXVK_CONFIG for the Battle.net profile: $options")
return set
}
private const val DXVK_CONFIG = "DXVK_CONFIG"
fun ensureNoSteam(context: Context): Boolean {
if (com.droiddeck.launcher.session.SessionPrefs.battleNetNoSteamSet(context)) return false
GameEnvironmentStore.setGameNoSteam(context, APP_ID.toString(), true)
com.droiddeck.launcher.session.SessionPrefs.setBattleNetNoSteamSet(context)
Log.i(TAG, "Start without Steam for the Battle.net profile")
return true
}
fun answerFexRequest(context: Context, dir: File) {
val app = context.applicationContext
Thread({
runCatching { ensureVideoMemory(app) }.onFailure { Log.w(TAG, "video memory cap for the Battle.net profile", it) }
runCatching { ensureNoSteam(app) }.onFailure { Log.w(TAG, "Start without Steam for the Battle.net profile", it) }
val answer = try {
"ok " + ensureFex(app)
} catch (e: Exception) {
Log.w(TAG, "FEX for the Battle.net prefix", e)
"error " + (e.message ?: e.javaClass.simpleName)
}
Log.i(TAG, "FEX request: $answer")
val result = File(dir, FEX_RESULT)
val partial = File(dir, "$FEX_RESULT.tmp")
runCatching { partial.writeText(answer.replace('\n', ' ') + "\n"); partial.renameTo(result) }
}, "battlenet-fex").start()
}
}
@@ -220,6 +220,7 @@ object BwrapSpawner {
val binds = ArrayList<Bind>()
val paths = libs.map { lib -> "$PRELOAD_DIR/${lib.substringAfterLast('/')}".also { binds.add(Bind(rootfs + lib, it)) } }
env["LD_PRELOAD"] = (paths + listOfNotNull(env["LD_PRELOAD"]?.takeIf { it.isNotBlank() })).joinToString(":")
File(rootfs, NET_LINK).takeIf { it.isFile }?.let { binds.add(Bind(it.path, "/$NET_LINK")) }
env["FAKE_EVDEV_DIR"]?.let { File(it).parent }?.let { dev ->
binds.addAll(bindsFor(session, rootfs, dev, dev))
}
@@ -227,6 +228,7 @@ object BwrapSpawner {
}
private const val PRELOAD_DIR = "/run/droiddeck-preload"
private const val NET_LINK = "etc/droiddeck-net"
private const val ZYPAK_SHIM = "#!/bin/sh\nprogram=\$1\nshift\nexec \"\$program\" --no-sandbox \"\$@\"\n"
@@ -2,16 +2,19 @@ package com.droiddeck.launcher.runtime
import android.content.Context
import android.os.Environment
import com.droiddeck.launcher.core.DownloadSource
import com.droiddeck.launcher.core.HostProcess
import com.droiddeck.launcher.session.OrphanReaper
import com.droiddeck.launcher.session.SessionFiles
import com.droiddeck.launcher.session.SessionPaths
import com.droiddeck.launcher.session.SessionPrefs
import java.io.File
import java.util.concurrent.atomic.AtomicBoolean
object GuestCommand {
fun run(context: Context, argv: List<String>, fakeRoot: Boolean = false, logName: String? = null,
linkDir: File? = null, extraBinds: List<String>? = null, fastPath: Boolean = false, onLine: (String) -> Unit): Int {
linkDir: File? = null, extraBinds: List<String>? = null, fastPath: Boolean = false, cancelled: AtomicBoolean? = null,
onLine: (String) -> Unit): Int {
val log = logName?.let {
try {
File(File(LinuxRuntime.logDir(context), SessionPaths.TOOLS_DIR).apply { mkdirs() }, "$it.log").printWriter()
@@ -19,14 +22,14 @@ object GuestCommand {
}
log?.println("== ${java.util.Date()} ${argv.joinToString(" ")}")
try {
return runLogged(context, argv, fakeRoot, linkDir, extraBinds, fastPath) { line -> log?.println(line); log?.flush(); onLine(line) }
return runLogged(context, argv, fakeRoot, linkDir, extraBinds, fastPath, cancelled) { line -> log?.println(line); log?.flush(); onLine(line) }
} finally {
log?.close()
}
}
private fun runLogged(context: Context, argv: List<String>, fakeRoot: Boolean, linkDir: File?, extraBinds: List<String>?,
fastPath: Boolean, onLine: (String) -> Unit): Int {
fastPath: Boolean, cancelled: AtomicBoolean?, onLine: (String) -> Unit): Int {
val root = LinuxRuntime.rootDir(context)
LinuxRuntime.writeAccounts(context)
SessionFiles.stage(context, root)
@@ -45,6 +48,15 @@ object GuestCommand {
"DROIDDECK_BWRAP_SOCKET=${BwrapSpawner.socket(context)}",
"XDG_DATA_DIRS=/root/.local/share/flatpak/exports/share:/usr/local/share:/usr/share",
)
// The Steam client installer and the package tools inside the guest fetch over plain curl,
// so the app proxy has to reach them through the environment proot starts them with.
DownloadSource.proxyHostPort(context).takeIf { it.isNotEmpty() }?.let { hostPort ->
cmd += listOf(
"http_proxy=http://$hostPort", "https_proxy=http://$hostPort",
"HTTP_PROXY=http://$hostPort", "HTTPS_PROXY=http://$hostPort",
"no_proxy=localhost,127.0.0.1,::1", "NO_PROXY=localhost,127.0.0.1,::1",
)
}
fastPathKey?.let { cmd += ProotFastPath.guestEnv(root, binds, it) }
cmd += argv
val builder = ProcessBuilder(cmd).directory(root).redirectErrorStream(true)
@@ -56,13 +68,32 @@ object GuestCommand {
fastPathKey?.let { ProotFastPath.hostEnv(it).let { (k, v) -> put(k, v) } }
LinuxRuntime.prootLibraryPath(context).takeIf { it.isNotEmpty() }?.let { put("LD_LIBRARY_PATH", it) }
}
if (cancelled?.get() == true) return 130
val process = builder.start()
val pid = HostProcess.pidOf(process)
OrphanReaper.keep(pid)
val watcher = cancelled?.let { flag ->
Thread({
try {
while (process.isAlive && !flag.get()) Thread.sleep(200)
if (flag.get()) {
// PRoot ignores SIGTERM; SIGQUIT makes it kill its tracees and exit cleanly.
if (pid > 0) {
runCatching { android.system.Os.kill(pid, android.system.OsConstants.SIGQUIT) }
} else {
process.destroy()
}
if (!process.waitFor(3, java.util.concurrent.TimeUnit.SECONDS)) process.destroyForcibly()
}
} catch (_: InterruptedException) {
}
}, "guest-cancel").apply { isDaemon = true; start() }
}
try {
process.inputStream.bufferedReader().useLines { lines -> lines.forEach(onLine) }
return process.waitFor()
} finally {
watcher?.interrupt()
OrphanReaper.release(pid)
}
}
@@ -301,15 +301,18 @@ class LinuxNetworkLinkComponent(
}
}
private fun seededMac(): String {
val id = Settings.Secure.getString(appContext.contentResolver, Settings.Secure.ANDROID_ID) ?: "bannerlator"
val bytes = java.security.MessageDigest.getInstance("SHA-1").digest(id.toByteArray())
val b0 = (bytes[0].toInt() and 0xfc) or 0x02
return listOf(b0, bytes[1].toInt(), bytes[2].toInt(), bytes[3].toInt(), bytes[4].toInt(), bytes[5].toInt())
.joinToString(":") { "%02x".format(it and 0xff) }
}
private fun seededMac(): String =
seededMac { Settings.Secure.getString(appContext.contentResolver, Settings.Secure.ANDROID_ID) }
companion object {
internal fun seededMac(readId: () -> String?): String {
val id = runCatching(readId).getOrNull() ?: "bannerlator"
val bytes = java.security.MessageDigest.getInstance("SHA-1").digest(id.toByteArray())
val b0 = (bytes[0].toInt() and 0xfc) or 0x02
return listOf(b0, bytes[1].toInt(), bytes[2].toInt(), bytes[3].toInt(), bytes[4].toInt(), bytes[5].toInt())
.joinToString(":") { "%02x".format(it and 0xff) }
}
private const val TAG = "LinuxNetworkLink"
private const val LINK_FILE = "etc/droiddeck-net"
private const val LINK_INDEX = 2
@@ -110,6 +110,11 @@ public final class LinuxRuntime {
File root = rootDir(context);
List<String> binds = binds(context, sessionRoot, runtimeDir, externalStorage, extraBinds);
if (sessionRoot != null) lastBinds = binds;
return commandWithBinds(context, root, binds, guestCommand);
}
public static List<String> commandWithBinds(Context context, File root, List<String> binds,
List<String> guestCommand) {
List<String> cmd = prootPrefix(context, root, "/root");
for (String spec : binds) bind(cmd, spec);
cmd.addAll(guestCommand);
@@ -299,27 +299,50 @@ public final class LinuxRuntimeInstaller {
discard(staging, null);
return false;
}
java.util.List<String> carryPaths = root.isDirectory()
? RuntimeFileTree.existingUserPaths(root, staging) : java.util.Collections.emptyList();
if (root.isDirectory() && !root.renameTo(old)) {
RuntimeFileTree.delete(staging, null);
return false;
}
File carryMarker = new File(staging, CARRY_MARKER);
java.util.List<String> encodedPaths = new java.util.ArrayList<>();
for (String rel : carryPaths) encodedPaths.add(java.net.URLEncoder.encode(rel, "UTF-8"));
if (!FileUtils.writeString(carryMarker, String.join("\n", encodedPaths))) {
if (old.isDirectory() && !old.renameTo(root))
Log.w(TAG, "could not restore the previous runtime; retained at " + old);
discard(staging, null);
return false;
}
java.util.List<String> carried;
try {
RuntimeFileTree.carryHome(old, staging);
carried = RuntimeFileTree.carryUserData(old, staging, false, carryPaths);
} catch (IOException e) {
Log.w(TAG, "could not carry root across the update; rolling back", e);
old.renameTo(root);
try { RuntimeFileTree.delete(staging, null); }
catch (IOException cleanup) { Log.w(TAG, "could not clear the failed staging tree", cleanup); }
Log.w(TAG, "could not carry user data across the update; rolling back", e);
if (old.isDirectory() && !old.renameTo(root))
Log.w(TAG, "could not restore the previous runtime; retained at " + old);
if (e.getSuppressed().length == 0) {
new File(staging, CARRY_MARKER).delete();
try { RuntimeFileTree.delete(staging, null); }
catch (IOException cleanup) { Log.w(TAG, "could not clear the failed staging tree", cleanup); }
} else {
Log.w(TAG, "incomplete rollback; retaining user data in " + staging);
}
return false;
}
if (!staging.renameTo(root)) {
File keptTo = new File(staging, USER_DATA);
if (keptTo.isDirectory() && old.isDirectory()) keptTo.renameTo(new File(old, USER_DATA));
if (old.isDirectory()) old.renameTo(root);
if (old.isDirectory()) {
try {
RuntimeFileTree.carryBack(staging, old, carried);
new File(staging, CARRY_MARKER).delete();
} catch (IOException e) { Log.w(TAG, "could not move all user data back after a failed swap", e); }
old.renameTo(root);
}
return false;
}
new File(root, CARRY_MARKER).delete();
discard(old, null);
return LinuxRuntime.isInstalled(context);
} catch (Exception e) {
@@ -330,17 +353,41 @@ public final class LinuxRuntimeInstaller {
}
}
static final String CARRY_MARKER = ".droiddeck-carry";
static void recoverInterruptedSwap(File root, File staging, File old) throws IOException {
if (!root.isDirectory() && old.isDirectory()) {
if (!old.renameTo(root)) throw new IOException("Cannot restore the previous runtime at " + old);
Log.w(TAG, "restored the previous runtime after an interrupted update");
}
File marker = new File(staging, CARRY_MARKER);
if (!RuntimeFileTree.exists(marker.toPath())) return;
File target = root.isDirectory() ? root : old;
String markerContent = FileUtils.readString(marker);
if (markerContent == null) throw new IOException("Cannot read runtime recovery marker: " + marker);
if (!markerContent.equals("1")) {
java.util.List<String> intended = new java.util.ArrayList<>();
for (String rel : markerContent.split("\\n"))
if (!rel.isEmpty()) intended.add(java.net.URLDecoder.decode(rel, "UTF-8"));
if (target.isDirectory()) RuntimeFileTree.recoverCarriedPaths(staging, target, intended);
if (staging.isDirectory() && RuntimeFileTree.hasPaths(staging, intended))
throw new IOException("User data still needs recovery from " + staging);
marker.delete();
return;
}
File stagedHome = new File(staging, USER_DATA);
File home = new File(target, USER_DATA);
if (target.isDirectory() && stagedHome.isDirectory() && (!home.exists() || isEmptyDir(home))) {
if (target.isDirectory() && stagedHome.isDirectory() && (!RuntimeFileTree.exists(home.toPath()) || isEmptyDir(home))) {
RuntimeFileTree.carryHome(staging, target);
Log.w(TAG, "recovered " + USER_DATA + " from an interrupted update");
}
if (!root.isDirectory() && old.isDirectory() && old.renameTo(root)) {
Log.w(TAG, "restored the previous runtime after an interrupted update");
if (target.isDirectory() && staging.isDirectory()) {
java.util.List<String> rest = RuntimeFileTree.carryUserData(staging, target, true);
if (!rest.isEmpty()) Log.w(TAG, "recovered " + rest + " from an interrupted update");
}
if (staging.isDirectory() && RuntimeFileTree.hasUserData(staging, target))
throw new IOException("User data still needs recovery from " + staging);
new File(staging, CARRY_MARKER).delete();
}
static final String TRASH = ".runtime-trash";
@@ -364,9 +411,10 @@ public final class LinuxRuntimeInstaller {
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;
private static boolean isEmptyDir(File dir) throws IOException {
try (java.nio.file.DirectoryStream<java.nio.file.Path> entries = java.nio.file.Files.newDirectoryStream(dir.toPath())) {
return !entries.iterator().hasNext();
}
}
public static Removal beginUninstall(Context context) {
@@ -74,6 +74,163 @@ public final class RuntimeFileTree {
throw new IOException("Cannot make directory removable: " + dir);
}
/**
* Paths in a runtime tree that belong to the user: the home folder, imported AppImages and script apps, and catalog
* AppImages with their menu entries and markers. Tar-kind catalog packages (the desktop) unpack over /usr and cannot
* be carried.
*/
static java.util.List<String> userPaths(File tree) throws IOException {
java.util.List<String> paths = new java.util.ArrayList<>(java.util.Arrays.asList("root", "opt/appimages/user", "opt/droiddeck-apps"));
for (String n : children(new File(tree, "usr/local/share/applications"))) {
if (n.endsWith(".desktop") && (n.startsWith("droiddeck-app-") || n.startsWith("droiddeck-appimage-")))
paths.add("usr/local/share/applications/" + n);
}
for (String n : children(new File(tree, "opt/appimages"))) {
if (!n.endsWith(".AppImage")) continue;
String id = n.substring(0, n.length() - ".AppImage".length());
java.util.Collections.addAll(paths, "opt/appimages/" + n,
"usr/share/applications/droiddeck-" + id + ".desktop", ".droiddeck-pkg-" + id);
}
for (String n : children(new File(tree, "usr/share/applications"))) {
if (!n.startsWith("droiddeck-") || !n.endsWith(".desktop")) continue;
String id = n.substring("droiddeck-".length(), n.length() - ".desktop".length());
if (!exists(new File(tree, "opt/appimages/" + id + ".AppImage").toPath()))
paths.add("usr/share/applications/" + n);
}
for (String n : children(tree)) {
if (!n.startsWith(".droiddeck-pkg-")) continue;
String id = n.substring(".droiddeck-pkg-".length());
boolean hasMenu = exists(new File(tree, "usr/share/applications/droiddeck-" + id + ".desktop").toPath());
if (hasMenu && !exists(new File(tree, "opt/appimages/" + id + ".AppImage").toPath()))
paths.add(n);
}
return paths;
}
static java.util.List<String> existingUserPaths(File from, File to) throws IOException {
java.util.List<String> paths = new java.util.ArrayList<>();
for (String rel : userPaths(from, to)) if (exists(safePath(from, rel).toPath())) paths.add(rel);
return paths;
}
private static java.util.List<String> children(File dir) throws IOException {
java.util.List<String> names = new java.util.ArrayList<>();
try (DirectoryStream<Path> entries = Files.newDirectoryStream(dir.toPath())) {
for (Path entry : entries) names.add(entry.getFileName().toString());
} catch (NoSuchFileException e) {
return names;
}
return names;
}
static boolean exists(Path path) throws IOException {
try { Files.readAttributes(path, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); return true; }
catch (NoSuchFileException e) { return false; }
}
private static java.util.Set<String> userPaths(File from, File to) throws IOException {
java.util.Set<String> paths = new java.util.LinkedHashSet<>(userPaths(from));
paths.addAll(userPaths(to));
return paths;
}
static boolean hasUserData(File from, File to) throws IOException {
for (String rel : userPaths(from, to)) if (exists(new File(from, rel).toPath())) return true;
return false;
}
/**
* Moves each user path that exists in {@code from} into {@code to}, replacing what {@code to} ships there. With
* {@code onlyMissing}, a path already present in {@code to} is left alone. On failure the paths already moved go back
* before the exception is thrown. Rollback failures are suppressed on that exception; callers must retain the source
* of any incomplete rollback.
* Returns the moved paths.
*/
static java.util.List<String> carryUserData(File from, File to, boolean onlyMissing) throws IOException {
return carryUserData(from, to, onlyMissing, new java.util.ArrayList<>(userPaths(from, to)));
}
static java.util.List<String> carryUserData(File from, File to, boolean onlyMissing, java.util.List<String> paths) throws IOException {
java.util.List<String> moved = new java.util.ArrayList<>();
try {
for (String rel : paths) {
File src = safePath(from, rel);
if (!exists(src.toPath())) continue;
File dst = safePath(to, rel);
if (onlyMissing && exists(dst.toPath())) continue;
move(from, to, rel);
moved.add(rel);
}
} catch (IOException e) {
for (int i = moved.size() - 1; i >= 0; i--) {
try { move(to, from, moved.get(i)); }
catch (IOException back) { e.addSuppressed(back); }
}
throw e;
}
return moved;
}
/** Puts the given paths back from {@code from} into {@code to}; failures are collected, not stopped on. */
static void carryBack(File from, File to, java.util.List<String> paths) throws IOException {
IOException failed = null;
for (int i = paths.size() - 1; i >= 0; i--) {
try { move(from, to, paths.get(i)); }
catch (IOException e) { if (failed == null) failed = e; else failed.addSuppressed(e); }
}
if (failed != null) throw failed;
}
private static void move(File from, File to, String rel) throws IOException {
File src = safePath(from, rel);
File dst = safePath(to, rel);
File parent = src.getParentFile();
if (parent != null) makeWritable(parent);
java.util.List<File> made = new java.util.ArrayList<>();
for (File d = dst.getParentFile(); d != null; d = d.getParentFile()) {
made.add(d);
if (d.equals(to)) break;
}
for (int i = made.size() - 1; i >= 0; i--) {
File d = made.get(i);
if (!d.isDirectory() && !d.mkdirs()) throw new IOException("Cannot create " + d);
makeWritable(d);
}
delete(dst, null);
if (!src.renameTo(dst)) throw new IOException("Cannot carry " + rel + " across the update");
}
static void recoverCarriedPaths(File staging, File target, java.util.List<String> paths) throws IOException {
for (String rel : paths) {
File src = safePath(staging, rel);
if (!exists(src.toPath())) continue;
File dst = safePath(target, rel);
if (!exists(dst.toPath())) move(staging, target, rel);
}
}
static boolean hasPaths(File tree, java.util.List<String> paths) throws IOException {
for (String rel : paths) if (exists(safePath(tree, rel).toPath())) return true;
return false;
}
private static File safePath(File tree, String rel) throws IOException {
Path relative = java.nio.file.Paths.get(rel);
if (relative.isAbsolute() || relative.getNameCount() == 0) throw new IOException("Invalid runtime path: " + rel);
for (Path part : relative) if (part.toString().equals("..")) throw new IOException("Invalid runtime path: " + rel);
Path current = tree.toPath();
for (int i = 0; i < relative.getNameCount() - 1; i++) {
current = current.resolve(relative.getName(i));
try {
BasicFileAttributes attrs = Files.readAttributes(current, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
if (!attrs.isDirectory()) throw new IOException("Runtime path has a non-directory parent: " + current);
} catch (NoSuchFileException ignored) {
break;
}
}
return tree.toPath().resolve(relative).toFile();
}
static void carryHome(File old, File staging) throws IOException {
File home = new File(old, "root");
if (!home.isDirectory()) return;
@@ -9,7 +9,7 @@ import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import com.droiddeck.launcher.MainActivity
import com.droiddeck.launcher.FrontEndActivity
import com.droiddeck.launcher.R
class RuntimeInstallService : Service() {
@@ -75,7 +75,7 @@ class RuntimeInstallService : Service() {
enableVibration(false)
})
val open = PendingIntent.getActivity(this, 0,
Intent(this, MainActivity::class.java).setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT),
Intent(this, FrontEndActivity::class.java).setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT),
PendingIntent.FLAG_IMMUTABLE)
return Notification.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_stat_session)
@@ -56,8 +56,9 @@ class BatteryComponent(val dir: File, private val vpowerDir: File) : SessionPart
val timeToEmptyS = if (discharging && chargeNowUah > 0 && abs(currentAvgUa) > 0) chargeNowUah * 3600L / abs(currentAvgUa) else 0L
val charging = status == BatteryManager.BATTERY_STATUS_CHARGING
val timeToFullS = if (charging && chargeFullUah > chargeNowUah && abs(currentAvgUa) > 0) (chargeFullUah - chargeNowUah) * 3600L / abs(currentAvgUa) else 0L
// Android can keep reporting charging at 100%; Linux reports Full there, and Steam shows 99% until it does.
val statusText = when (status) {
BatteryManager.BATTERY_STATUS_CHARGING -> "Charging"
BatteryManager.BATTERY_STATUS_CHARGING -> if (pct >= 100) "Full" else "Charging"
BatteryManager.BATTERY_STATUS_DISCHARGING -> "Discharging"
BatteryManager.BATTERY_STATUS_FULL -> "Full"
BatteryManager.BATTERY_STATUS_NOT_CHARGING -> "Not charging"
@@ -51,7 +51,7 @@ object BootMovie {
SessionPrefs.setBootMovieApplyNext(context, true)
BootMovieReport.note(context, "picked $id here; Steam names ${SessionFiles.startupMoviePath(BootMovieLibrary.steamConfig(context))}")
// Whatever Steam names right now is about to be replaced, so it is not a pick to adopt later.
SessionFiles.startupMoviePath(BootMovieLibrary.steamConfig(context))?.let { SessionPrefs.setBootMovieSteamSeen(context, it) }
SessionFiles.startupMovieLocalPath(BootMovieLibrary.steamConfig(context))?.let { SessionPrefs.setBootMovieSteamSeen(context, it) }
}
fun customName(context: Context): String? =
@@ -167,10 +167,12 @@ object BootMovie {
* that movie becomes the choice here. Steam already plays it, so nothing is staged over it. True when the choice changed.
*/
fun syncFromSteam(context: Context): Boolean {
val path = SessionFiles.startupMoviePath(BootMovieLibrary.steamConfig(context)) ?: return false
if (path == SessionPrefs.bootMovieSteamSeen(context)) return false
SessionPrefs.setBootMovieSteamSeen(context, path)
if (path == SessionFiles.BOOT_MOVIE_PATH) return false
val named = SessionFiles.startupMovieLocalPath(BootMovieLibrary.steamConfig(context)) ?: return false
val seen = SessionPrefs.bootMovieSteamSeen(context)
if (named == seen) return false
SessionPrefs.setBootMovieSteamSeen(context, named)
if (named == SessionFiles.BOOT_MOVIE_PATH || (named.isEmpty() && seen == null)) return false
val path = named.ifEmpty { "/movies/steam_os_startup.webm" }
val prefix = "/uioverrides/movies/"
// The list names a bought movie from the Steam folder (/config/communityitemscache/...), Steam's config from its config folder.
val id = if (path.startsWith(prefix)) BootMovieLibrary.idOf(path.removePrefix(prefix)) else {
@@ -79,8 +79,9 @@ object BootMovieLibrary {
return customMovieDirs(context).map { File(it, name) }.firstOrNull { it.isFile && it.length() > 0L }
}
private fun isMovie(file: File) = file.isFile && file.length() > 0L && file.name.endsWith(".webm", ignoreCase = true) &&
file.name !in STAGED && !file.name.startsWith(".")
private fun isMovie(file: File) = isWebm(file) && file.name !in STAGED
private fun isWebm(file: File) = file.isFile && file.length() > 0L && file.name.endsWith(".webm", ignoreCase = true) && !file.name.startsWith(".")
private fun readMeta(context: Context): JSONObject =
runCatching { JSONObject(metaFile(context).readText()) }.getOrDefault(JSONObject())
@@ -136,7 +137,7 @@ object BootMovieLibrary {
}
val bought = HashMap<String, String>()
for (root in steamRoots(context)) {
File(root, "steamui/movies").listFiles { f -> isMovie(f) && f.nameWithoutExtension.lowercase() in NATIVE }.orEmpty()
File(root, "steamui/movies").listFiles { f -> isWebm(f) && f.nameWithoutExtension.lowercase() in NATIVE }.orEmpty()
.sortedBy { it.name.lowercase() }.forEach { steam.putIfAbsent(STEAM_PREFIX + "/movies/" + it.name, it) }
val scan = pointShopMovies(root)
bought.putAll(scan.titles)
@@ -144,8 +145,8 @@ object BootMovieLibrary {
}
val seen = HashSet<String>()
for ((id, file) in steam) {
if (file.name in STAGED || !seen.add(file.canonicalPath)) continue
val native = file.nameWithoutExtension.lowercase() in NATIVE
if ((file.name in STAGED && !native) || !seen.add(file.canonicalPath)) continue
// Steam names a bought movie by an item number and a hash; the points shop's own title is shown when Steam has it.
val title = if (native) NATIVE[file.nameWithoutExtension.lowercase()] ?: file.nameWithoutExtension else bought[file.path] ?: boughtId(file)?.let { "#$it" } ?: file.nameWithoutExtension
out.add(Entry(id, title, if (native) SOURCE_STEAM else SOURCE_POINTSHOP, file, removable = false))
@@ -153,8 +154,11 @@ object BootMovieLibrary {
return out
}
/** The three startup movies Steam ships with; Steam's movies folder holds many more videos that are not startup movies. */
private val NATIVE = mapOf("deck_startup" to "Deck startup", "oled_startup" to "Oled startup", "startup_machine" to "Machine startup")
/** The four startup movies Steam ships with; Steam's movies folder holds many more videos that are not startup movies. */
private val NATIVE = mapOf(
"deck_startup" to "Deck startup", "oled_startup" to "Oled startup", "startup_machine" to "Machine startup",
"steam_os_startup" to "SteamOS startup",
)
private val skippedFolders = setOf("uioverrides", "steamapps", "librarycache", "httpcache", "logs", "compatdata", "shadercache")
@@ -721,13 +721,19 @@ object ComponentsManager {
class StoredOriginal(val protonId: String, val protonName: String?, val comp: String, val protonVersion: String, val size: Long, val kept: Boolean)
class Stored(val packages: List<StoredPackage>, val originals: List<StoredOriginal>)
private fun packagesInUse(state: JSONObject): Set<String> {
internal fun liveSelection(key: String, entry: JSONObject, proton: Proton?): Boolean =
proton != null && (key == "queued" || entry.optString("protonVersion") == proton.version)
private fun packagesInUse(state: JSONObject, installed: Map<String, Proton>): Set<String> {
val names = HashSet<String>()
for (key in listOf("active", "queued")) {
val byProton = state.optJSONObject(key) ?: continue
for (pid in byProton.keys()) {
val comps = byProton.optJSONObject(pid) ?: continue
for (comp in comps.keys()) comps.optJSONObject(comp)?.optString("file")?.takeIf { it.isNotEmpty() }?.let(names::add)
for (comp in comps.keys()) {
val entry = comps.optJSONObject(comp) ?: continue
if (liveSelection(key, entry, installed[pid])) entry.optString("file").takeIf { it.isNotEmpty() }?.let(names::add)
}
}
}
val profiles = state.optJSONObject("profiles")
@@ -747,8 +753,8 @@ object ComponentsManager {
fun stored(context: Context): Stored = synchronized(lock) {
val state = loadState(context)
val used = packagesInUse(state)
val installed = protons(context).associateBy { it.id }
val installed = protons(context).distinctBy { it.id }.associateBy { it.id }
val used = packagesInUse(state, installed)
val originals = originalsDir(context).listFiles()?.filter { it.isDirectory }?.flatMap { proton ->
proton.listFiles()?.filter { it.isDirectory }?.flatMap { version ->
COMPONENTS.mapNotNull { comp ->
@@ -774,27 +780,135 @@ object ComponentsManager {
true
}
fun deletePackage(context: Context, file: String): String = synchronized(lock) {
val name = safeName(file)
val state = loadState(context)
val queued = state.sub("queued")
for (pid in queued.keys()) {
val comps = queued.optJSONObject(pid) ?: continue
for (comp in comps.keys()) check(comps.optJSONObject(comp)?.optString("file") != name) { context.getString(R.string.cmgr_package_in_use, name, pid) }
}
val active = state.sub("active")
for (pid in active.keys()) {
val comps = active.optJSONObject(pid) ?: continue
for (comp in comps.keys()) check(comps.optJSONObject(comp)?.optString("file") != name) { context.getString(R.string.cmgr_package_in_use, name, pid) }
private fun checkPackageUnused(context: Context, state: JSONObject, name: String) {
val installed = protons(context).distinctBy { it.id }.associateBy { it.id }
for (key in listOf("queued", "active")) {
val byProton = state.sub(key)
for (pid in byProton.keys()) {
val comps = byProton.optJSONObject(pid) ?: continue
for (comp in comps.keys()) {
val entry = comps.optJSONObject(comp) ?: continue
check(entry.optString("file") != name || !liveSelection(key, entry, installed[pid])) {
context.getString(R.string.cmgr_package_in_use, name, installed[pid]?.name ?: pid)
}
}
}
}
val profiles = state.sub("profiles")
for (scope in profiles.keys()) {
val profile = profiles.optJSONObject(scope) ?: continue
check(COMPONENTS.none { profile.optString(it) == name }) { context.getString(R.string.cmgr_package_in_use, name, scope) }
}
}
fun carryOver(context: Context, fromDir: String, toDir: String) {
synchronized(lock) {
val target = protons(context).firstOrNull { it.id == safeName(toDir) } ?: return
val state = loadState(context)
val wanted = carried(state, safeName(fromDir)) { File(packagesDir(context), it).isFile }
val active = state.sub("active").optJSONObject(target.id)
val queued = state.sub("queued").sub(target.id)
var changed = false
wanted.forEach { (comp, entry) ->
if (active?.has(comp) != true && !queued.has(comp)) {
queued.put(comp, entry)
changed = true
}
}
if (queued.length() == 0) state.sub("queued").remove(target.id)
if (changed) saveState(context, state)
}
}
internal fun carried(state: JSONObject, from: String, present: (String) -> Boolean): Map<String, JSONObject> = buildMap {
state.optJSONObject("active")?.optJSONObject(from)?.let { active ->
active.keys().forEach { comp ->
val entry = active.optJSONObject(comp) ?: return@forEach
val file = entry.optString("file")
if (comp in COMPONENTS && file.endsWith(".wcp") && present(file)) {
put(comp, JSONObject().put("kind", "package").put("file", file).put("label", entry.optString("label")))
}
}
}
state.optJSONObject("queued")?.optJSONObject(from)?.let { queued ->
queued.keys().forEach { comp ->
val entry = queued.optJSONObject(comp) ?: return@forEach
if (comp in COMPONENTS && entry.optString("kind") == "package" && present(entry.optString("file"))) put(comp, entry)
}
}
}
fun forgetProton(context: Context, dirName: String) {
synchronized(lock) {
val id = safeName(dirName)
if (protons(context).any { it.id == id }) return
val state = loadState(context)
val selected = listOf("active", "queued").map { state.sub(it).remove(id) != null }.any { it }
if (selected && runCatching { saveState(context, state) }.onFailure { Log.w(TAG, "forget $id", it) }.isFailure) return
File(originalsDir(context), id).deleteRecursively()
}
}
private fun storePackage(context: Context, part: File, dest: File) {
try {
if (dest.isFile && runCatching { Hashes.sha256(dest) }.getOrNull()?.equals(Hashes.sha256(part), true) == true) return
if (dest.exists()) checkPackageUnused(context, loadState(context), dest.name)
val store = File(launchDir(context), "store").canonicalFile
val cacheKey = unpackedCacheKey(dest.name)
val unpacked = File(store, cacheKey).canonicalFile
check(unpacked.parentFile == store) { "Invalid unpacked component cache path for ${dest.name}" }
val complete = File(unpacked, ".complete")
if (complete.exists()) check(complete.delete()) { "Could not invalidate unpacked component ${dest.name}" }
if (!part.renameTo(dest)) {
dest.delete()
check(part.renameTo(dest)) { "Could not store ${dest.name}" }
}
if (!unpacked.deleteRecursively() && unpacked.exists()) {
check(!complete.exists()) { "Could not invalidate unpacked component ${dest.name}" }
Log.w(TAG, "Could not remove stale unpacked files for ${dest.name}; cache marker is invalidated")
}
} finally {
part.delete()
}
}
internal fun unpackedCacheKey(filename: String): String = safeName(filename.removeSuffix(".wcp")).also {
require(it != "." && it != "..") { "Invalid component cache name" }
}
fun deletePackage(context: Context, file: String): String = synchronized(lock) {
val name = safeName(file)
val state = loadState(context)
val installed = protons(context).distinctBy { it.id }.associateBy { it.id }
var changed = false
for (key in listOf("queued", "active")) {
val byProton = state.sub(key)
for (pid in byProton.keys().asSequence().toList()) {
val comps = byProton.optJSONObject(pid) ?: continue
for (comp in comps.keys().asSequence().toList()) {
val entry = comps.optJSONObject(comp) ?: continue
if (entry.optString("file") != name) continue
check(!liveSelection(key, entry, installed[pid])) { context.getString(R.string.cmgr_package_in_use, name, installed[pid]?.name ?: pid) }
comps.remove(comp)
changed = true
}
if (comps.length() == 0) byProton.remove(pid)
}
}
val f = File(packagesDir(context), name)
check(f.isFile) { context.getString(R.string.cmgr_package_not_stored, name) }
f.delete()
val profiles = state.sub("profiles")
for (scope in profiles.keys().asSequence().toList()) {
val profile = profiles.optJSONObject(scope) ?: continue
for (comp in COMPONENTS.filter { profile.optString(it) == name }) {
rememberProfiledComponent(state, comp)
profile.remove(comp)
changed = true
}
if (profile.length() == 0) profiles.remove(scope)
}
check(f.delete()) { name }
if (changed) saveState(context, state)
context.getString(R.string.cmgr_deleted, name)
}
@@ -811,7 +925,7 @@ object ComponentsManager {
fun importPackage(context: Context, source: File, name: String): Package = synchronized(lock) {
val info = packageInfo(context, source)
val dest = File(packagesDir(context), safeName(name).let { if (it.endsWith(".wcp")) it else "$it.wcp" })
source.copyTo(File(dest.parentFile, dest.name + ".part"), overwrite = true).renameTo(dest)
storePackage(context, source.copyTo(File(dest.parentFile, dest.name + ".part"), overwrite = true), dest)
info.copy(file = dest.name)
}
@@ -834,6 +948,7 @@ object ComponentsManager {
.put("url", a.optString("browser_download_url")).put("size", a.optLong("size")).put("digest", a.optString("digest")))
}
}
if (items.length() == 0 && cache.isFile) runCatching { return parseCatalog(JSONObject(cache.readText())) }
val out = JSONObject().put("items", items).put("fetchedAt", System.currentTimeMillis() / 1000)
val tmp = File(cache.parentFile, cache.name + ".tmp")
tmp.writeText(out.toString()); tmp.renameTo(cache)
@@ -963,7 +1078,7 @@ object ComponentsManager {
}
if (!Hashes.sha256(part).equals(item.digest.substringAfter(':'), true)) { part.delete(); error(context.getString(R.string.cmgr_checksum_mismatch)) }
val info = runCatching { packageInfo(context, part) }.getOrElse { part.delete(); throw it }
synchronized(lock) { part.renameTo(dest) }
synchronized(lock) { storePackage(context, part, dest) }
return info.copy(file = dest.name)
}
}
@@ -78,6 +78,14 @@ object GameEnvironmentStore {
return config
}
@Synchronized
fun setDefault(context: Context, scope: String, name: String, value: String): Boolean {
val config = read(context)
if (GameEnvironment.hasOverride(config, scope, name)) return false
save(context, GameEnvironment.withScopeValue(config, scope, name, value))
return true
}
@Synchronized
fun publish(context: Context, config: GameEnvironment.Config = read(context)) {
val resolved = config.copy(shared = GameEnvironment.defaults(SessionPrefs.fexPreset(context), SessionPrefs.forceSsbs(context)) + config.shared)
@@ -14,9 +14,12 @@ object GameStorage {
fun options(context: Context): List<Option> {
val sm = context.getSystemService(StorageManager::class.java)
val primary = context.getExternalFilesDir(null)?.absolutePath
val primaryStorage = Environment.getExternalStorageDirectory()?.absolutePath
return context.getExternalFilesDirs(null).orEmpty().filterNotNull().mapNotNull { dir ->
val removable = try { Environment.isExternalStorageRemovable(dir) } catch (e: Exception) { false }
if (!removable) return@mapNotNull null
// Non-primary app dirs are usable even when Android reports the volume as non-removable (USB SSDs often do).
if (primary != null && dir.absolutePath == primary) return@mapNotNull null
if (!primaryStorage.isNullOrBlank() && dir.absolutePath.startsWith("$primaryStorage/")) return@mapNotNull null
val volume = try { sm?.getStorageVolume(dir) } catch (e: Exception) { null }
val name = volume?.getDescription(context)?.takeIf { it.isNotBlank() && !it.equals("android", true) } ?: context.getString(R.string.gstore_sd_card)
Option(context.getString(R.string.gstore_volume_free, name, free(dir)), File(dir, "steam").absolutePath, name)
@@ -26,6 +26,7 @@ object GpuClockPin {
pinned = true
"gpu clock: held at its top"
} catch (t: Throwable) {
File(context.filesDir, MARKER).delete()
Log.w(TAG, "pin", t)
"gpu clock: pin failed"
}
@@ -1,5 +1,6 @@
package com.droiddeck.launcher.session
import android.os.SystemClock
import android.util.Log
import com.droiddeck.launcher.core.SessionPart
import com.droiddeck.launcher.runtime.LinuxRuntime
@@ -23,11 +24,16 @@ class GpuStatsComponent(val dir: File) : SessionPart() {
}
fun prepare(): Boolean {
if (!File(KGSL).exists() || MANGOHUD_FILES.none { File(KGSL, it).let { f -> f.exists() && !f.canRead() } }) return false
if (!File(KGSL).exists()) return false
val unreadable = MANGOHUD_FILES.any { File(KGSL, it).let { f -> f.exists() && !f.canRead() } }
if (!unreadable && !File(GPUBUSY).canRead()) return false
return try {
dir.mkdirs()
dir.listFiles()?.forEach { java.nio.file.Files.deleteIfExists(it.toPath()) }
feeds.clear()
PASSTHROUGH_FILES.forEach { name ->
runCatching { File(dir, name).writeText(File(KGSL, name).readText()) }
}
loadFeed()?.let { load ->
feeds.add(load)
feeds.add(Feed(File(dir, "perf_now"), load.source, load.read, "%-6s\n", percent = true))
@@ -37,7 +43,7 @@ class GpuStatsComponent(val dir: File) : SessionPart() {
feeds.add(Feed(File(dir, "cur_freq"), clock.source, { clock.read()?.times(1_000_000) }, "%-10d\n"))
}
pick("temp", tempSources(), "%6d\n") { milliCelsius(it) }?.let { feeds.add(it) }
feeds.forEach { feed -> feed.read()?.let { write(feed, it) } }
refresh()
Log.i(TAG, "hud: kgsl stats from the session's own: " + MANGOHUD_FILES.joinToString { name ->
"$name " + (feeds.firstOrNull { it.file.name == name }?.source ?: "none")
})
@@ -55,11 +61,20 @@ class GpuStatsComponent(val dir: File) : SessionPart() {
while (running) {
try { Thread.sleep(PERIOD_MS) } catch (e: InterruptedException) { break }
if (!running) break
for (feed in feeds) feed.read()?.let { write(feed, it) }
refresh()
}
}, "gpu-stats").apply { isDaemon = true; start() }
}
// kgsl's gpubusy can read 0 0 straight after a read, so feeds that share a reader share one read.
private fun refresh() {
val values = HashMap<() -> Long?, Long?>()
for (feed in feeds) {
if (feed.read !in values) values[feed.read] = feed.read()
values[feed.read]?.let { write(feed, it) }
}
}
override fun stop() {
running = false
thread?.interrupt()
@@ -67,16 +82,30 @@ class GpuStatsComponent(val dir: File) : SessionPart() {
}
private fun loadFeed(): Feed? {
pick("gpu_busy_percentage", LOAD_SOURCES, "%3d %%\n") { it.coerceIn(0, 100) }?.let { return it }
val gpubusy = "$KGSL/gpubusy"
val read = {
runCatching {
val parts = File(gpubusy).readText().trim().split(Regex("\\s+"))
val total = parts[1].toLong()
if (total > 0) (parts[0].toLong() * 100 / total).coerceIn(0, 100) else 0L
latchedBusyFeed()?.let { return it }
return pick("gpu_busy_percentage", LOAD_SOURCES, "%3d %%\n") { it.coerceIn(0, 100) }
}
// kgsl publishes busy/total once per ~1 s of GPU-on time and zeroes it on any read while the GPU is
// power-collapsed, so direct pollers mostly see 0; latch each published window instead.
private fun latchedBusyFeed(): Feed? {
var load = 0L
var windowAt = 0L
val read = read@{
val parts = runCatching {
File(GPUBUSY).readText().trim().split(Regex("\\s+")).map { it.toLong() }
}.getOrNull()
if (parts == null || parts.size < 2) return@read null
val now = SystemClock.elapsedRealtime()
if (parts[1] > 0) {
load = (parts[0] * 100 / parts[1]).coerceIn(0, 100)
windowAt = now
} else if (now - windowAt > LOAD_HOLD_MS) {
load = 0
}
load
}
return if (read() != null) Feed(File(dir, "gpu_busy_percentage"), gpubusy, read, "%3d %%\n") else null
return if (read() != null) Feed(File(dir, "gpu_busy_percentage"), GPUBUSY, read, "%3d %%\n") else null
}
private fun pick(name: String, sources: List<String>, format: String, unit: (Long) -> Long?): Feed? {
@@ -100,8 +129,11 @@ class GpuStatsComponent(val dir: File) : SessionPart() {
const val KGSL = "/sys/class/kgsl/kgsl-3d0"
const val MSM_LOAD = "/sys/kernel/debug/dri/0/perf_now"
const val MSM_CLOCK = "/sys/devices/platform/soc@0/3d00000.gpu/devfreq/3d00000.gpu/cur_freq"
private const val GPUBUSY = "$KGSL/gpubusy"
private const val PERIOD_MS = 500L
private const val LOAD_HOLD_MS = 5_000L
private val MANGOHUD_FILES = listOf("gpu_busy_percentage", "clock_mhz", "temp")
private val PASSTHROUGH_FILES = listOf("gpu_model", "gpu_chipid")
private const val QCOM_GPU = "/sys/kernel/gpu"
private val LOAD_SOURCES = listOf(
"$KGSL/gpu_busy_percentage", "$KGSL/devfreq/gpu_load", "$KGSL/gpuload", "$QCOM_GPU/gpu_busy",
@@ -87,11 +87,10 @@ class LoadingState(context: Context, private val steam: Boolean = true) {
if (line.startsWith("== STEP ")) {
stepAt = at
stepText = line.substringAfter("== STEP ").substringAfter(' ')
val m = INSTALL_COUNT.find(line)
if (m != null) {
clientDownload(line)?.let { (what, percent) ->
clientDownloadAt = at
clientDownload = m.groupValues[1]
clientPercent = m.groupValues[2].toInt() * 100 / maxOf(1, m.groupValues[3].toInt())
clientDownload = what
clientPercent = percent
}
} else {
val m = UPDATE_PROGRESS.find(line)
@@ -132,6 +131,19 @@ class LoadingState(context: Context, private val steam: Boolean = true) {
}
}
internal fun clientDownload(line: String): Pair<String, Int>? {
val count = INSTALL_COUNT.find(line) ?: return null
val mb = INSTALL_MEGABYTES.find(line.substring(count.range.last + 1))
val done = mb?.groupValues?.get(1)?.toLongOrNull()
val total = mb?.groupValues?.get(2)?.toLongOrNull()
if (done != null && total != null && total > 0) {
return "$done / $total MB" to (done * 100 / total).toInt().coerceIn(0, 100)
}
val part = count.groupValues[2].toIntOrNull() ?: return null
val all = count.groupValues[3].toIntOrNull() ?: return null
return count.groupValues[1] to (part.toLong() * 100 / maxOf(1, all)).coerceIn(0L, 100L).toInt()
}
internal fun readableScriptStep(scriptStep: String): Boolean {
val t = scriptStep.lowercase()
return t.startsWith("download") || t.startsWith("checking the linux") || t.startsWith("unpacking") || t.startsWith("installing")
@@ -140,5 +152,6 @@ class LoadingState(context: Context, private val steam: Boolean = true) {
private const val TAIL_BYTES = 48L * 1024
private val UPDATE_PROGRESS = Regex("""Downloading update \((\d+) of (\d+) KB\)""")
private val INSTALL_COUNT = Regex("""downloading Steam: (\S+) \((\d+)/(\d+)\)""")
private val INSTALL_MEGABYTES = Regex("""^, (\d+) of (\d+) MB""")
}
}
@@ -0,0 +1,175 @@
package com.droiddeck.launcher.session
import android.content.Context
import android.os.Handler
import android.os.Looper
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.droiddeck.launcher.R
import java.io.File
import java.util.Locale
internal object MemoryReadout {
data class Device(val totalKb: Long, val availableKb: Long, val swapTotalKb: Long, val swapFreeKb: Long) {
val usedKb get() = (totalKb - availableKb).coerceAtLeast(0)
val swapUsedKb get() = (swapTotalKb - swapFreeKb).coerceAtLeast(0)
}
data class Split(val gameKb: Long, val droiddeckKb: Long, val gpuKb: Long?)
fun parseMeminfo(text: String): Device? {
fun field(name: String) = Regex("""(?m)^$name:\s+(\d+) kB""").find(text)?.groupValues?.get(1)?.toLongOrNull()
val total = field("MemTotal") ?: return null
val available = field("MemAvailable") ?: return null
return Device(total, available, field("SwapTotal") ?: 0, field("SwapFree") ?: 0)
}
fun residentKb(status: String): Long {
fun field(name: String) = Regex("""(?m)^$name:\s+(\d+) kB""").find(status)?.groupValues?.get(1)?.toLongOrNull() ?: 0
return field("RssAnon") + field("RssShmem")
}
fun inWinePrefix(environ: ByteArray): Boolean {
var start = 0
val key = "WINEPREFIX=".toByteArray()
for (i in 0..environ.size) {
if (i == environ.size || environ[i] == 0.toByte()) {
if (i - start >= key.size && (key.indices).all { environ[start + it] == key[it] }) return true
start = i + 1
}
}
return false
}
fun androidKb(device: Device, split: Split): Long =
(device.usedKb - split.gameKb - split.droiddeckKb - (split.gpuKb ?: 0)).coerceAtLeast(0)
fun gb(kb: Long): String = String.format(Locale.US, "%.1f", kb / (1024.0 * 1024.0))
fun availableMb(): Long? = runCatching { parseMeminfo(File("/proc/meminfo").readText())?.availableKb?.div(1024) }.getOrNull()
fun device(): Device? = runCatching { parseMeminfo(File("/proc/meminfo").readText()) }.getOrNull()
fun split(root: Int = android.os.Process.myPid()): Split {
val parents = HashMap<Int, Int>()
File("/proc").listFiles()?.forEach { entry ->
val pid = entry.name.toIntOrNull() ?: return@forEach
parentOf(pid)?.let { parents[pid] = it }
}
val children = parents.entries.groupBy({ it.value }, { it.key })
var game = 0L
var droiddeck = 0L
val queue = ArrayDeque<Int>().apply { add(root) }
val seen = HashSet<Int>()
while (queue.isNotEmpty()) {
val pid = queue.removeFirst()
if (!seen.add(pid)) continue
val status = runCatching { File("/proc/$pid/status").readText() }.getOrNull()
if (status != null) {
val kb = residentKb(status)
val windows = pid != root && runCatching { inWinePrefix(File("/proc/$pid/environ").readBytes()) }.getOrDefault(false)
if (windows) game += kb else droiddeck += kb
}
children[pid]?.let(queue::addAll)
}
return Split(game, droiddeck, gpuKb())
}
private fun parentOf(pid: Int): Int? {
val stat = runCatching { File("/proc/$pid/stat").readText() }.getOrNull() ?: return null
val close = stat.lastIndexOf(')')
if (close < 0 || close + 2 >= stat.length) return null
return stat.substring(close + 2).split(' ').getOrNull(1)?.toIntOrNull()
}
private fun gpuKb(): Long? = runCatching {
File("/sys/class/kgsl/kgsl/page_alloc").readText().trim().split(Regex("\\s+"))[0].toLong() / 1024
}.getOrNull()
}
object MemoryLog {
private const val TAG = "Memory"
private const val PERIOD_MS = 10_000L
@Volatile private var thread: Thread? = null
internal fun line(device: MemoryReadout.Device, split: MemoryReadout.Split): String =
"used ${device.usedKb / 1024}/${device.totalKb / 1024} MB, available ${device.availableKb / 1024} MB, " +
"swap ${device.swapUsedKb / 1024}/${device.swapTotalKb / 1024} MB | game ${split.gameKb / 1024}, " +
"droiddeck ${split.droiddeckKb / 1024}, android ${MemoryReadout.androidKb(device, split) / 1024}, " +
"gpu ${split.gpuKb?.div(1024) ?: "?"} MB"
@Synchronized
fun start() {
if (thread != null) return
thread = Thread({
while (!Thread.currentThread().isInterrupted) {
runCatching { MemoryReadout.device()?.let { android.util.Log.i(TAG, line(it, MemoryReadout.split())) } }
try { Thread.sleep(PERIOD_MS) } catch (e: InterruptedException) { break }
}
}, "memory-log").apply { isDaemon = true; start() }
}
@Synchronized
fun stop() {
thread?.interrupt()
thread = null
}
}
class MemoryHud(context: Context) {
var text by mutableStateOf("")
private set
private val context = context.applicationContext
private val handler = Handler(Looper.getMainLooper())
@Volatile private var running = false
@Volatile private var generation = 0
private var thread: Thread? = null
fun refresh() {
if (SessionPrefs.memoryHud(context) == SessionPrefs.MEMORY_HUD_OFF) stop() else start()
}
fun start() {
if (running || SessionPrefs.memoryHud(context) == SessionPrefs.MEMORY_HUD_OFF) return
running = true
val token = ++generation
thread = Thread({
while (running && generation == token) {
val line = runCatching { sample() }.getOrDefault("")
handler.post { if (running && generation == token) text = line }
try { Thread.sleep(PERIOD_MS) } catch (e: InterruptedException) { break }
}
}, "memory-hud").apply { isDaemon = true; start() }
}
fun stop() {
running = false
generation++
thread?.interrupt()
thread = null
text = ""
}
private fun sample(): String {
val device = MemoryReadout.device() ?: return ""
val memory = context.getString(
R.string.memory_hud_simple,
MemoryReadout.gb(device.usedKb), MemoryReadout.gb(device.totalKb),
MemoryReadout.gb(device.swapUsedKb), MemoryReadout.gb(device.swapTotalKb),
)
if (SessionPrefs.memoryHud(context) != SessionPrefs.MEMORY_HUD_ADVANCED) return memory
val split = MemoryReadout.split()
val parts = context.getString(
R.string.memory_hud_split,
MemoryReadout.gb(split.gameKb), MemoryReadout.gb(split.droiddeckKb), MemoryReadout.gb(MemoryReadout.androidKb(device, split)),
)
val gpu = split.gpuKb?.let { context.getString(R.string.memory_hud_gpu, MemoryReadout.gb(it)) }
return listOfNotNull(memory, parts, gpu).joinToString("\n")
}
private companion object {
const val PERIOD_MS = 2000L
}
}
@@ -9,11 +9,13 @@ import com.droiddeck.launcher.stores.epic.EpicClient
object NoSteamLaunch {
const val PROTON_RUN = "/usr/local/bin/droiddeck-proton-run"
const val EPIC = "/usr/local/bin/droiddeck-epic"
const val BATTLENET = com.droiddeck.launcher.runtime.BattleNet.PROGRAM
class Command(val program: String, val args: List<String>, val name: String)
fun scope(game: Library.SteamGame): String? = when {
game.library == Library.EPIC_LAUNCHER -> EpicClient.shortcutAppId.toString()
game.library == Library.BATTLENET_LAUNCHER -> com.droiddeck.launcher.runtime.BattleNet.APP_ID.toString()
game.library == Library.ADDED || game.appId > 0 -> game.profileKey
else -> null
}?.takeIf { it.isNotEmpty() && GameEnvironment.validScope(it) }
@@ -30,6 +32,8 @@ object NoSteamLaunch {
fun command(game: Library.SteamGame, added: AddedGames.Game?): Command? = when {
game.library == Library.EPIC_LAUNCHER -> Command(EPIC, listOf("run"), game.name)
game.library == Library.ADDED && game.source == com.droiddeck.launcher.stores.Store.BATTLENET.id ->
game.storeId?.let(com.droiddeck.launcher.runtime.BattleNet::title)?.let { Command(BATTLENET, listOf("launch", it.code), game.name) }
game.library == Library.ADDED -> added?.let { a -> a.steamAppId?.let { Command(PROTON_RUN, listOf("--app", it.toString()), a.name) } ?: addedCommand(a) }
game.appId > 0 -> Command(PROTON_RUN, listOf("--app", game.appId.toString()), game.name)
else -> null
@@ -37,7 +41,9 @@ object NoSteamLaunch {
fun addedCommand(game: AddedGames.Game): Command = Command(
PROTON_RUN,
listOf("--app", (game.appId and 0xFFFFFFFFL).toString(), "--dir", game.guestDir, "--options", game.launchOptions, "--", game.guestExe),
listOf("--app", (game.appId and 0xFFFFFFFFL).toString(), "--dir", game.guestDir) +
(if (game.launchOptions.isEmpty()) emptyList() else listOf("--options", game.launchOptions)) +
listOf("--", game.guestExe),
game.name,
)
@@ -50,6 +56,7 @@ object NoSteamLaunch {
fun nameFor(program: String?, args: List<String>): String? = when (program) {
EPIC -> Library.EPIC_LAUNCHER_NAME
BATTLENET -> args.getOrNull(args.indexOf("launch") + 1)?.takeIf { args.contains("launch") }?.let { names[it] } ?: com.droiddeck.launcher.stores.Store.BATTLENET.label
PROTON_RUN -> args.getOrNull(args.indexOf("--app") + 1)?.takeIf { args.contains("--app") }?.let { names[it] }
else -> null
}
@@ -58,5 +65,6 @@ object NoSteamLaunch {
fun remember(command: Command) {
if (command.program == PROTON_RUN) command.args.getOrNull(command.args.indexOf("--app") + 1)?.let { names[it] = command.name }
if (command.program == BATTLENET) command.args.getOrNull(command.args.indexOf("launch") + 1)?.takeIf { command.args.contains("launch") }?.let { names[it] = command.name }
}
}
@@ -114,12 +114,19 @@ object ProtonDefault {
return if (SessionState.running && state.live && (tool == null || state.pending != tool.name)) Outcome.LIVE else Outcome.NEXT_START
}
internal fun movedGames(choices: Map<String, GameChoice?>, from: Set<String>, to: String): Map<String, GameChoice?>? {
if (choices.values.none { it != null && !it.valve && it.dir in from }) return null
return choices.mapValues { (_, choice) -> if (choice != null && !choice.valve && choice.dir in from) GameChoice(false, to) else choice }
}
@Synchronized
fun requestGame(context: Context, scope: String, proton: ComponentsManager.Proton?): Outcome {
require(GameEnvironment.validScope(scope))
fun moveGames(context: Context, from: Set<String>, to: String) {
val d = dir(context)
val choices = parseGames(read(File(d, GAMES))).toMutableMap()
choices[scope] = proton?.let { GameChoice(it.valve, it.dir.name) }
val moved = movedGames(parseGames(read(File(d, GAMES))), from, to) ?: return
writeGames(d, moved)
}
private fun writeGames(d: File, choices: Map<String, GameChoice?>) {
val games = JSONObject()
choices.forEach { (id, choice) ->
games.put(id, choice?.let { JSONObject().put("valve", it.valve).put("dir", it.dir) } ?: JSONObject.NULL)
@@ -134,6 +141,15 @@ object ProtonDefault {
Log.w(TAG, "could not write the per-game Proton request", e)
throw e
}
}
@Synchronized
fun requestGame(context: Context, scope: String, proton: ComponentsManager.Proton?): Outcome {
require(GameEnvironment.validScope(scope))
val d = dir(context)
val choices = parseGames(read(File(d, GAMES))).toMutableMap()
choices[scope] = proton?.let { GameChoice(it.valve, it.dir.name) }
writeGames(d, choices)
val state = state(context)
val tool = proton?.let { selected -> state.tools.firstOrNull { it.valve == selected.valve && it.dir == selected.dir.name } }
return if (SessionState.running && state.live && (proton == null || tool != null && state.pending != tool.name)) {
@@ -16,16 +16,20 @@ object ProtonExtras {
private const val TAG = "ProtonExtras"
private const val NEED_BYTES = 4L * 1024 * 1024 * 1024
private const val RELEASES = "https://api.github.com/repos/%s/releases?per_page=15"
const val CHECK_EVERY_MS = 6L * 60 * 60 * 1000
const val CHECK_EVERY_MS = 60L * 60 * 1000
@Volatile
var installInProgress = false
private set
@Volatile
private var replacing = false
class Tool(val id: String, val name: String, val prefix: String, val repo: String, val assetPattern: Regex)
internal data class Asset(val tag: String, val name: String, val url: String, val sha512: String?, val size: Long,
val sha256: String? = null)
const val OWN = "droiddeck"
val tools = listOf(
Tool("ge", "GE-Proton", "GE-Proton", "GloriousEggroll/proton-ge-custom", Regex("aarch64\\.tar\\.(gz|xz)$")),
Tool("cachyos", "proton-cachyos", "proton-cachyos", "CachyOS/proton-cachyos", Regex("arm64\\.tar\\.(gz|xz)$")),
@@ -78,11 +82,23 @@ object ProtonExtras {
fun update(context: Context, tool: Tool): String? = updateFor(installedTags(context, tool), cachedLatest(context, tool))
fun missing(context: Context, tool: Tool): String? =
if (tool.id == OWN && !declined(context) && installedAll(context, tool).isEmpty()) cachedLatest(context, tool) else null
private fun choices(context: Context) = context.getSharedPreferences("proton-auto-updates", Context.MODE_PRIVATE)
fun declined(context: Context): Boolean = choices(context).getBoolean("$OWN.declined", false)
fun setDeclined(context: Context, declined: Boolean) {
choices(context).edit().putBoolean("$OWN.declined", declined).apply()
}
fun cachedLatest(context: Context, tool: Tool): String? =
readJson(latestFile(context)).optJSONObject(tool.id)?.optString("tag")?.ifEmpty { null }
private val checkLock = Any()
private const val RETRY_MS = 15L * 60 * 1000
private const val USER_SETTINGS = "user_settings.py"
private var lastAttempt = 0L
fun checkLatest(context: Context, force: Boolean = false, now: Long = System.currentTimeMillis()): Boolean {
@@ -136,11 +152,51 @@ object ProtonExtras {
fun remove(context: Context, tool: Tool, dir: String? = null) {
val name = if (dir != null) dir.takeIf { it in installedAll(context, tool) } ?: return else installed(context, tool) ?: return
File(toolsDir(context), name).deleteRecursively()
if (File(toolsDir(context), name).deleteRecursively()) ComponentsManager.forgetProton(context, name)
}
fun removeOlder(context: Context, tool: Tool) {
installedAll(context, tool).dropLast(1).forEach { File(toolsDir(context), it).deleteRecursively() }
installedAll(context, tool).dropLast(1).forEach {
if (File(toolsDir(context), it).deleteRecursively()) ComponentsManager.forgetProton(context, it)
}
}
private fun replaceOlder(context: Context, tool: Tool) {
val all = installedAll(context, tool)
val newest = all.lastOrNull() ?: return
val older = all.dropLast(1)
if (older.isEmpty()) return
replacing = true
try {
for (old in older.asReversed()) {
if (SessionState.running) break
if (runCatching { ComponentsManager.carryOver(context, old, newest) }.onFailure { Log.w(TAG, "components of $old", it) }.isFailure) continue
if (runCatching { keepUserSettings(context, old, newest) }.onFailure { Log.w(TAG, "user settings of $old", it) }.isFailure) continue
if (runCatching { ProtonDefault.moveGames(context, setOf(old), newest) }.onFailure { Log.w(TAG, "per-game picks of $old", it) }.isFailure) continue
if (File(toolsDir(context), old).deleteRecursively()) ComponentsManager.forgetProton(context, old)
else Log.w(TAG, "could not remove $old")
}
} finally {
replacing = false
}
}
private fun keepUserSettings(context: Context, old: String, newest: String) {
val from = File(File(toolsDir(context), old), USER_SETTINGS)
val to = File(File(toolsDir(context), newest), USER_SETTINGS)
if (from.isFile && !to.exists()) from.copyTo(to)
}
fun tidy(context: Context) {
val tool = tools.firstOrNull { it.id == OWN } ?: return
if (installedAll(context, tool).size > 1) replaceOlder(context, tool)
}
fun awaitReplacement(timeoutMs: Long) {
val until = System.currentTimeMillis() + timeoutMs
while (replacing && System.currentTimeMillis() < until) {
try { Thread.sleep(50) } catch (_: InterruptedException) { Thread.currentThread().interrupt(); return }
}
}
@Synchronized
@@ -244,6 +300,7 @@ object ProtonExtras {
archive.delete()
unqueue(context, tool)
record(context, tool, placed, asset.tag)
if (tool.id == OWN) replaceOlder(context, tool)
if (EsyncPacks.enabled(context)) {
onProgress(context.getString(R.string.esync_fetching), -1)
try {
@@ -200,22 +200,86 @@ object SessionArtifacts {
internal fun recordedClient(dir: File): String? =
runCatching { File(dir, CLIENT_FILE).readText().trim() }.getOrNull()?.takeIf { it == "arm64" || it == "x86_64" }
private const val LOG_MARKS = "steam-log-marks.txt"
private const val MAX_STEAM_LOG = 8L * 1024 * 1024
private const val HEAD = 4096L
private class Mark(val size: Long, val head: String)
fun markSteamLogs(dir: File, logs: File) {
val lines = logs.listFiles { f -> f.isFile }?.mapNotNull { f ->
val size = f.length()
head(f, size)?.let { "$size\t$it\t${f.name}" }
} ?: return
runCatching { File(dir, LOG_MARKS).writeText(lines.joinToString("") { "$it\n" }) }
}
private fun head(f: File, length: Long): String? = runCatching {
val buf = ByteArray(minOf(length, HEAD).toInt())
f.inputStream().use { input ->
var off = 0
while (off < buf.size) {
val n = input.read(buf, off, buf.size - off)
if (n < 0) return@runCatching null
off += n
}
}
java.security.MessageDigest.getInstance("SHA-1").digest(buf).joinToString("") { "%02x".format(it) }
}.getOrNull()
private fun steamLogMarks(dir: File): List<Mark>? = runCatching {
File(dir, LOG_MARKS).readLines().mapNotNull { line ->
val parts = line.split('\t', limit = 3)
parts.getOrNull(0)?.toLongOrNull()?.let { size -> parts.getOrNull(1)?.let { Mark(size, it) } }
}
}.getOrNull()
private fun startOf(src: File, size: Long, marks: List<Mark>): Long {
val heads = HashMap<Long, String?>()
return marks.filter { it.size <= size }.sortedByDescending { it.size }
.firstOrNull { m -> heads.getOrPut(minOf(m.size, HEAD)) { head(src, m.size) } == m.head }?.size ?: 0L
}
private fun copySteamLogs(context: Context, dir: File, record: Record, steamArch: String? = null) {
val logs = File(steamArch?.let { SessionPrefs.steamRoot(context, it) } ?: SessionPrefs.activeSteamRoot(context), "logs")
if (!logs.isDirectory) return
val out = File(dir, "steam").apply { mkdirs() }
logs.listFiles { f -> f.isFile && f.length() < 8L * 1024 * 1024 }?.forEach { src ->
copySessionLogs(logs, dir, out).forEach { dst -> record.files[dst] = Scrubbed(dst.length(), dst.lastModified()) }
Log.i(TAG, "collected ${out.listFiles()?.size ?: 0} Steam log(s), scrubbed, into $out")
}
internal fun copySessionLogs(logs: File, dir: File, out: File): List<File> {
val marks = steamLogMarks(dir)
val copied = ArrayList<File>()
logs.listFiles { f -> f.isFile }?.forEach { src ->
val size = src.length()
val from = marks?.let { startOf(src, size, it) } ?: 0L
if (marks != null && from == size || size - from >= MAX_STEAM_LOG) return@forEach
try {
val dst = File(out, src.name)
dst.bufferedWriter().use { w ->
src.forEachLine { line -> w.write(LogRedactor.redact(line)); w.newLine() }
src.inputStream().buffered().use { input ->
var left = if (from > 0) from - 1 else 0L
while (left > 0) {
val skipped = input.skip(left)
if (skipped <= 0) break
left -= skipped
}
if (from > 0 && input.read() != '\n'.code) {
while (true) {
val c = input.read()
if (c < 0 || c == '\n'.code) break
}
}
dst.bufferedWriter().use { w ->
input.bufferedReader().forEachLine { line -> w.write(LogRedactor.redact(line)); w.newLine() }
}
}
record.files[dst] = Scrubbed(dst.length(), dst.lastModified())
copied.add(dst)
} catch (e: Exception) {
Log.w(TAG, "could not scrub ${src.name}", e)
}
}
Log.i(TAG, "collected ${out.listFiles()?.size ?: 0} Steam log(s), scrubbed, into $out")
return copied
}
@Synchronized
@@ -79,5 +79,5 @@ object SessionDisplay {
}
fun canStretch16x9(size: Pair<Int, Int>): Boolean =
(size.second * 16 + 4) / 9 / 2 * 2 > size.first
(size.second * 16 + 4) / 9 / 2 * 2 > size.first || (size.first * 9 + 8) / 16 / 2 * 2 > size.second
}
@@ -58,8 +58,10 @@ object SessionFiles {
"usr/local/bin/droiddeck-steam-install" to "usr/local/bin/droiddeck-steam-install",
"usr/local/bin/droiddeck-steam-ui-scale" to "usr/local/bin/droiddeck-steam-ui-scale",
"usr/local/bin/droiddeck-steam-language" to "usr/local/bin/droiddeck-steam-language",
"usr/local/bin/droiddeck-steam-shaders" to "usr/local/bin/droiddeck-steam-shaders",
"usr/local/bin/droiddeck-steam-desktop-ui" to "usr/local/bin/droiddeck-steam-desktop-ui",
"usr/local/bin/droiddeck-steam-library" to "usr/local/bin/droiddeck-steam-library",
"usr/local/bin/droiddeck-steam-share" to "usr/local/bin/droiddeck-steam-share",
"usr/local/bin/droiddeck-seed-redists" to "usr/local/bin/droiddeck-seed-redists",
"usr/local/bin/droiddeck-launcher-cores" to "usr/local/bin/droiddeck-launcher-cores",
"usr/local/bin/droiddeck-proton-extra" to "usr/local/bin/droiddeck-proton-extra",
@@ -72,12 +74,14 @@ object SessionFiles {
"usr/local/bin/droiddeck-steam-shortcuts" to "usr/local/bin/droiddeck-steam-shortcuts",
"usr/local/bin/droiddeck-xbox" to "usr/local/bin/droiddeck-xbox",
"usr/local/bin/droiddeck-store-launch" to "usr/local/bin/droiddeck-store-launch",
"usr/local/bin/droiddeck-battlenet" to "usr/local/bin/droiddeck-battlenet",
"usr/local/bin/droiddeck-epic" to "usr/local/bin/droiddeck-epic",
"usr/local/bin/droiddeck-epic-steam" to "usr/local/bin/droiddeck-epic-steam",
"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",
"usr/local/bin/droiddeck-steam-x64" to "usr/local/bin/droiddeck-steam-x64",
"usr/local/bin/droiddeck-steamrt-shim" to "usr/local/bin/droiddeck-steamrt-shim",
"usr/local/bin/droiddeck-x64-arm-proton" to "usr/local/bin/droiddeck-x64-arm-proton",
"usr/local/bin/droiddeck-x64-arm-webhelper" to "usr/local/bin/droiddeck-x64-arm-webhelper",
"usr/local/bin/droiddeck-x64-webhelper" to "usr/local/bin/droiddeck-x64-webhelper",
@@ -94,6 +98,15 @@ object SessionFiles {
"usr/local/bin/droiddeck-script-run" to "usr/local/bin/droiddeck-script-run",
"usr/local/bin/droiddeck-open" to "usr/local/bin/droiddeck-open",
"usr/local/bin/droiddeck-proton-run" to "usr/local/bin/droiddeck-proton-run",
"usr/local/bin/droiddeck-innoextract" to "usr/local/bin/droiddeck-innoextract",
"usr/local/lib/droiddeck-inno/innoextract" to "usr/local/lib/droiddeck-inno/innoextract",
"usr/local/lib/droiddeck-inno/libboost_container.so.1.92.0" to "usr/local/lib/droiddeck-inno/libboost_container.so.1.92.0",
"usr/local/lib/droiddeck-inno/libboost_filesystem.so.1.92.0" to "usr/local/lib/droiddeck-inno/libboost_filesystem.so.1.92.0",
"usr/local/lib/droiddeck-inno/libboost_iostreams.so.1.92.0" to "usr/local/lib/droiddeck-inno/libboost_iostreams.so.1.92.0",
"usr/local/lib/droiddeck-inno/libboost_program_options.so.1.92.0" to "usr/local/lib/droiddeck-inno/libboost_program_options.so.1.92.0",
"usr/local/lib/droiddeck-inno/libboost_random.so.1.92.0" to "usr/local/lib/droiddeck-inno/libboost_random.so.1.92.0",
"usr/local/share/licenses/droiddeck-inno/LICENSE.innoextract" to "usr/local/share/licenses/droiddeck-inno/LICENSE.innoextract",
"usr/local/share/licenses/droiddeck-inno/LICENSE.boost" to "usr/local/share/licenses/droiddeck-inno/LICENSE.boost",
"usr/bin/steamos-update" to "usr/bin/steamos-update",
"usr/bin/steamos-select-branch" to "usr/bin/steamos-select-branch",
"usr/bin/steamos-session-select" to "usr/bin/steamos-session-select",
@@ -138,6 +151,8 @@ object SessionFiles {
} + listOf("libvulkan-thunk.so", "libwebkitinput.so").map { "x86_64/$it" to "usr/local/lib/droiddeck-fex/x86_64/$it" }
val optional = (arrayOf(
"usr/local/bin/gamescope" to "usr/local/bin/gamescope",
// The gamescope pack carries its own WSI layer (patch 0117); it replaces the runtime's stock copy.
"usr/lib/libVkLayer_FROG_gamescope_wsi_aarch64.so" to "usr/lib/libVkLayer_FROG_gamescope_wsi_aarch64.so",
"usr/local/lib/droiddeck/uruntime" to "usr/local/lib/droiddeck/uruntime",
"usr/local/lib/droiddeck-pipewire.tzst" to "usr/local/lib/droiddeck-pipewire.tzst",
"usr/local/share/licenses/uruntime/LICENSE" to "usr/local/share/licenses/uruntime/LICENSE",
@@ -202,7 +217,11 @@ object SessionFiles {
if (!File(Environment.getExternalStorageDirectory(), NO_PAD_SWITCH).exists()) {
preload.append("/usr/local/lib/libfakeinput.so\n")
}
if (ProotFastPath.enabled(context)) preload.append(ProotFastPath.LIBRARY + "\n")
if (ProotFastPath.enabled(context)) {
val library = com.droiddeck.launcher.agent.AgentEnv.fastPathLibrary(context)
preload.append(library + "\n")
if (startsSession && library != ProotFastPath.LIBRARY) Log.i(TAG, "fast path preload: $library (debugging override)")
}
val etc = File(root, "etc").apply { mkdirs() }
val staged = File(etc, "ld.so.preload.staged")
if (!FileUtils.writeString(staged, preload.toString())
@@ -388,10 +407,12 @@ object SessionFiles {
private fun activeStartupMovie(text: String): VdfBlock? = customizationMovie(text) ?: olderMovie(text)
/** The movie Steam's config selects, as a path like `/uioverrides/movies/x.webm`; null when none is named. */
internal fun startupMoviePath(config: File): String? {
internal fun startupMoviePath(config: File): String? = startupMovieLocalPath(config)?.takeIf { it.isNotEmpty() }
internal fun startupMovieLocalPath(config: File): String? {
val text = runCatching { config.readText() }.getOrNull() ?: return null
val movie = activeStartupMovie(text) ?: return null
return scalar(text.substring(movie.keyStart, movie.close + 1), "LocalPath")?.takeIf { it.isNotEmpty() }
return scalar(text.substring(movie.keyStart, movie.close + 1), "LocalPath")
}
/** Both `StartupMovie` sections of Steam's config as written there, for the report; null when there is none. */
@@ -12,6 +12,7 @@ import androidx.compose.runtime.setValue
import androidx.core.content.FileProvider
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.LogRedactor
import com.droiddeck.launcher.runtime.BattleNet
import com.droiddeck.launcher.runtime.LinuxRuntime
import java.io.File
import java.util.zip.ZipEntry
@@ -49,7 +50,8 @@ object SessionLogShare {
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)
val battleNet = battleNetLogs(context)
val total = (files + live + stores + tools + battleNet.map { it.second }).sumOf { it.length() }.coerceAtLeast(1L)
var done = 0L
var shown = -1
fun advance(f: File) {
@@ -62,6 +64,7 @@ object SessionLogShare {
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) }
battleNet.forEach { (name, f) -> addEntry(z, "$BATTLENET_DIR/$name", f); advance(f) }
}
return zip
}
@@ -88,6 +91,27 @@ object SessionLogShare {
private fun shareDir(context: Context): File = File(context.cacheDir, "share")
private const val BATTLENET_DIR = "battlenet"
private fun battleNetLogs(context: Context): List<Pair<String, File>> {
val drive = File(BattleNet.prefix(context), "pfx/drive_c")
val data = File(drive, "ProgramData/Battle.net")
fun logsUnder(dir: File, prefix: String) = dir.listFiles { f -> f.isDirectory && f.name.startsWith(prefix) }.orEmpty().map { File(it, "Logs") }
return newest("droiddeck", listOf(File(LinuxRuntime.rootDir(context), "root/.cache/droiddeck/battlenet")), 2, setOf("battlenet.log", "spawned.log")) +
newest("client", listOf(File(drive, "users/steamuser/AppData/Local/Battle.net/Logs")), 6) +
newest("agent", listOf(File(data, "Agent/Logs")) + logsUnder(File(data, "Agent"), "Agent."), 10, byDir = true) +
newest("setup", logsUnder(File(data, "Setup"), ""), 3, byDir = true)
}
private fun newest(folder: String, dirs: List<File>, count: Int, names: Set<String>? = null, byDir: Boolean = false): List<Pair<String, File>> {
val since = System.currentTimeMillis() - RECENT_DAYS * 86_400_000L
return dirs.flatMap { dir ->
dir.listFiles { f -> f.isFile && f.name.endsWith(".log") && (names == null || f.name in names) && f.lastModified() >= since && f.length() <= STEAM_LOG_MAX_BYTES }.orEmpty().toList()
}.sortedByDescending { it.lastModified() }.take(count).map { f ->
(if (byDir) "$folder/${f.parentFile?.parentFile?.name}/${f.name}" else "$folder/${f.name}") to f
}
}
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()
@@ -4,10 +4,8 @@ import android.content.Context
import android.content.pm.ActivityInfo
import androidx.annotation.StringRes
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.CpuAllocationDefaults
import com.droiddeck.launcher.core.GameEnvironment
import com.droiddeck.launcher.core.TextureFiltering
import com.droiddeck.launcher.core.ZramSupport
import com.droiddeck.launcher.gpu.Cardboard
import com.droiddeck.launcher.gpu.ScreenEffects
import org.json.JSONObject
@@ -51,9 +49,9 @@ object SessionPrefs {
)
fun orientation(context: Context): Int =
prefs(context).getInt("orientation", ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE)
prefs(context).getInt("orientation", ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE)
.takeIf { value -> orientationOptions.any { it.first == value } }
?: ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
?: ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
fun setOrientation(context: Context, orientation: Int) {
require(orientationOptions.any { it.first == orientation })
@@ -93,13 +91,6 @@ object SessionPrefs {
*/
fun storesEpicMode(context: Context): String = prefs(context).getString("storesEpicMode", "adaptive") ?: "adaptive"
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()
}
fun storesShowMature(context: Context): Boolean = prefs(context).getBoolean("storesShowMature", false)
fun setStoresShowMature(context: Context, show: Boolean) {
@@ -137,9 +128,6 @@ object SessionPrefs {
val STORES_SORTS = listOf("lastPlayed", "recentlyInstalled", "titleAsc", "titleDesc", "sizeDesc", "sizeAsc", "releaseDateDesc")
val STORES_VIEWS = listOf("cards", "capsules", "list")
const val STORES_OPEN_LIBRARY = "library"
const val STORES_OPEN_STORE = "store"
fun storesInstallTarget(context: Context): String = prefs(context).getString("storesInstallTarget", "") ?: ""
fun setStoresInstallTarget(context: Context, path: String) {
@@ -153,6 +141,34 @@ object SessionPrefs {
prefs(context).edit().putString("storesInstallLanguage", code).apply()
}
const val DEFAULT_STORAGE_ASK = "ask"
const val DEFAULT_STORAGE_INTERNAL = "internal"
const val DEFAULT_STORAGE_EXTERNAL = "external"
private val DEFAULT_STORAGE_VALUES = setOf(DEFAULT_STORAGE_ASK, DEFAULT_STORAGE_INTERNAL, DEFAULT_STORAGE_EXTERNAL)
fun defaultStorage(context: Context): String =
prefs(context).getString("defaultStorage", DEFAULT_STORAGE_ASK)
?.takeIf { it in DEFAULT_STORAGE_VALUES } ?: DEFAULT_STORAGE_ASK
fun setDefaultStorage(context: Context, value: String) {
prefs(context).edit().putString("defaultStorage", value.takeIf { it in DEFAULT_STORAGE_VALUES } ?: DEFAULT_STORAGE_ASK).apply()
}
fun defaultStorageFolder(context: Context): String = prefs(context).getString("defaultStorageFolder", "") ?: ""
fun defaultStorageFolderLabel(context: Context): String = prefs(context).getString("defaultStorageFolderLabel", "") ?: ""
fun setDefaultStorageFolder(context: Context, path: String, label: String) {
prefs(context).edit()
.putString("defaultStorageFolder", path)
.putString("defaultStorageFolderLabel", label)
.apply()
}
fun clearDefaultStorageFolder(context: Context) {
prefs(context).edit().remove("defaultStorageFolder").remove("defaultStorageFolderLabel").apply()
}
fun gameStoresParallel(context: Context): Int = prefs(context).getInt("gameStoresParallel", 1).coerceIn(1, 3)
fun setGameStoresParallel(context: Context, count: Int) {
@@ -264,6 +280,19 @@ object SessionPrefs {
const val GPU_DRIVERS_MANUAL = "manual"
const val GPU_DRIVERS_MIX = "mix"
const val TP_UBWC_AUTO = "auto"
const val TP_UBWC_ON = "on"
const val TP_UBWC_OFF = "off"
fun tpUbwcMode(context: Context): String =
prefs(context).getString("tpUbwcMode", TP_UBWC_AUTO)
?.takeIf { it in setOf(TP_UBWC_AUTO, TP_UBWC_ON, TP_UBWC_OFF) } ?: TP_UBWC_AUTO
fun setTpUbwcMode(context: Context, mode: String) {
require(mode in setOf(TP_UBWC_AUTO, TP_UBWC_ON, TP_UBWC_OFF))
prefs(context).edit().putString("tpUbwcMode", mode).commit()
}
fun gpuDriverMode(context: Context): String =
prefs(context).getString("gpuDriverMode", GPU_DRIVERS_AUTO) ?: GPU_DRIVERS_AUTO
@@ -305,6 +334,34 @@ object SessionPrefs {
prefs(context).edit().putBoolean("returnAfterGame", on).apply()
}
private fun shaderPrecacheApplied(context: Context) =
java.io.File(com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(context), "root/.bl-shader-precache-applied")
/**
* Shader pre-caching as the launcher last set it, until a session reports it written into the
* client's config.vdf; after that the client's own toggle is the user's again.
*/
fun pendingShaderPrecache(context: Context): Boolean? {
val prefs = prefs(context)
if (!prefs.contains("shaderPrecache")) return null
val on = prefs.getBoolean("shaderPrecache", true)
val applied = shaderPrecacheApplied(context)
val written = runCatching { applied.readText().trim() }.getOrNull() ?: return on
applied.delete()
if (written != if (on) "1" else "0") return on
prefs.edit().remove("shaderPrecache").apply()
return null
}
fun setShaderPrecache(context: Context, on: Boolean) {
shaderPrecacheApplied(context).delete()
prefs(context).edit().putBoolean("shaderPrecache", on).apply()
}
fun shaderPrecache(context: Context): Boolean = pendingShaderPrecache(context)
?: runCatching { com.droiddeck.launcher.storage.SteamStorage.shaderPrecache(
java.io.File(steamRoot(context, steamClientArch(context)), "config/config.vdf").readText()) }.getOrDefault(true)
fun stretch16x9(context: Context): Boolean = prefs(context).getBoolean("stretch16x9", false)
fun setStretch16x9(context: Context, on: Boolean) {
@@ -342,30 +399,19 @@ object SessionPrefs {
prefs(context).edit().putBoolean("wifiDiscoveryAsked", true).apply()
}
internal data class CoreAllocation(val clientOverride: Boolean, val clientCpus: String, val gameCpus: String)
internal fun coreAllocation(context: Context, defaults: CpuAllocationDefaults.Allocation? = CpuAllocationDefaults.device): CoreAllocation {
val saved = prefs(context)
return CoreAllocation(
saved.getBoolean("clientCpusOverride", defaults != null && !saved.contains("clientCpus")),
saved.getString("clientCpus", defaults?.client.orEmpty()).orEmpty(),
saved.getString("gameCpus", defaults?.game.orEmpty()).orEmpty(),
)
}
fun clientCpusOverride(context: Context): Boolean = coreAllocation(context).clientOverride
fun clientCpusOverride(context: Context): Boolean = prefs(context).getBoolean("clientCpusOverride", false)
fun setClientCpusOverride(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("clientCpusOverride", on).apply()
}
fun clientCpus(context: Context): String = coreAllocation(context).clientCpus
fun clientCpus(context: Context): String = prefs(context).getString("clientCpus", "").orEmpty()
fun setClientCpus(context: Context, list: String) {
prefs(context).edit().putString("clientCpus", list).apply()
}
fun gameCpus(context: Context): String = coreAllocation(context).gameCpus
fun gameCpus(context: Context): String = prefs(context).getString("gameCpus", "").orEmpty()
fun setGameCpus(context: Context, list: String) {
prefs(context).edit().putString("gameCpus", list).apply()
@@ -377,7 +423,7 @@ object SessionPrefs {
prefs(context).edit().putBoolean("noXalia", on).apply()
}
fun fastSync(context: Context): Boolean = prefs(context).getBoolean("fastSync", false)
fun fastSync(context: Context): Boolean = prefs(context).getBoolean("fastSync", true)
fun setFastSync(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("fastSync", on).apply()
@@ -412,6 +458,17 @@ object SessionPrefs {
.apply()
}
fun applyNtsyncDefault(context: Context) {
val prefs = prefs(context)
if (prefs.getBoolean("ntsyncDefaultV1", false)) return
prefs.edit()
.putBoolean("fastSync", true)
.putBoolean("fsyncFirst", false)
.putBoolean("syncFallback", true)
.putBoolean("ntsyncDefaultV1", true)
.apply()
}
fun syncFallback(context: Context): Boolean = prefs(context).getBoolean("syncFallback", true)
fun setSyncFallback(context: Context, on: Boolean) {
@@ -424,6 +481,24 @@ object SessionPrefs {
prefs(context).edit().putBoolean("gamescopeRealtime", on).apply()
}
fun ramCapMb(context: Context): Int = prefs(context).getInt("ramCapMb", 0).coerceAtLeast(0)
fun setRamCapMb(context: Context, mb: Int) {
prefs(context).edit().putInt("ramCapMb", mb.coerceAtLeast(0)).apply()
}
fun zramClient(context: Context): Boolean = prefs(context).getBoolean("zramClient", true)
fun setZramClient(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("zramClient", on).apply()
}
fun vramCapMb(context: Context): Int = prefs(context).getInt("vramCapMb", 0).coerceAtLeast(0)
fun setVramCapMb(context: Context, mb: Int) {
prefs(context).edit().putInt("vramCapMb", mb.coerceAtLeast(0)).apply()
}
fun gpuClockPin(context: Context): Boolean = prefs(context).getBoolean("gpuClockPin", false)
fun setGpuClockPin(context: Context, on: Boolean) {
@@ -445,6 +520,13 @@ object SessionPrefs {
prefs(context).edit().putBoolean("prootFastPath", on).apply()
}
/** gamescope draws the cursor itself. Off by default: on a phone that plane costs frames. */
fun cursorComposite(context: Context): Boolean = prefs(context).getBoolean("cursorComposite", false)
fun setCursorComposite(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("cursorComposite", on).apply()
}
const val DEFAULT_GUEST_HOSTNAME = "DroidDeck"
@JvmStatic
@@ -518,6 +600,32 @@ object SessionPrefs {
prefs(context).edit().putBoolean("logs", on).apply()
}
fun battleNetNoSteamSet(context: Context): Boolean = prefs(context).getBoolean("battleNetNoSteamSet", false)
fun setBattleNetNoSteamSet(context: Context) { prefs(context).edit().putBoolean("battleNetNoSteamSet", true).apply() }
fun clearCachedApps(context: Context): Boolean = prefs(context).getBoolean("clearCachedApps", false)
fun setClearCachedApps(context: Context, on: Boolean) { prefs(context).edit().putBoolean("clearCachedApps", on).apply() }
fun backgroundAppLimit(context: Context): Int =
prefs(context).getInt("backgroundAppLimit", com.droiddeck.launcher.core.BackgroundAppLimit.OFF)
.takeIf { it in com.droiddeck.launcher.core.BackgroundAppLimit.CHOICES } ?: com.droiddeck.launcher.core.BackgroundAppLimit.OFF
fun setBackgroundAppLimit(context: Context, limit: Int) {
require(limit in com.droiddeck.launcher.core.BackgroundAppLimit.CHOICES)
prefs(context).edit().putInt("backgroundAppLimit", limit).apply()
}
const val MEMORY_HUD_OFF = "off"
const val MEMORY_HUD_SIMPLE = "simple"
const val MEMORY_HUD_ADVANCED = "advanced"
fun memoryHud(context: Context): String =
prefs(context).getString("memoryHud", MEMORY_HUD_OFF)
?.takeIf { it == MEMORY_HUD_SIMPLE || it == MEMORY_HUD_ADVANCED } ?: MEMORY_HUD_OFF
fun setMemoryHud(context: Context, value: String) {
require(value == MEMORY_HUD_OFF || value == MEMORY_HUD_SIMPLE || value == MEMORY_HUD_ADVANCED)
prefs(context).edit().putString("memoryHud", value).apply()
}
fun storageDiagnosticsEnabled(context: Context): Boolean =
prefs(context).getBoolean("storageDiagnostics", false)
@@ -765,6 +873,14 @@ object SessionPrefs {
it.getInt("gradientSecondary", com.droiddeck.launcher.ui.Themes.DEFAULT_SECONDARY)
}
/** How fast the Inner Gamer theme cycles its colours, 1 to 10. */
fun rgbSpeed(context: Context): Int =
prefs(context).getInt("innerGamerSpeed", com.droiddeck.launcher.ui.Themes.DEFAULT_RGB_SPEED).coerceIn(1, com.droiddeck.launcher.ui.Themes.RGB_SPEEDS)
fun setRgbSpeed(context: Context, speed: Int) {
prefs(context).edit().putInt("innerGamerSpeed", speed.coerceIn(1, com.droiddeck.launcher.ui.Themes.RGB_SPEEDS)).apply()
}
fun setGradientColors(context: Context, primary: Int, secondary: Int) {
prefs(context).edit().putInt("gradientPrimary", primary).putInt("gradientSecondary", secondary).apply()
}
@@ -787,7 +903,7 @@ object SessionPrefs {
prefs(context).edit().putBoolean("hdr.$mode", on).apply()
}
fun fpsLimit(context: Context, mode: String): Int = prefs(context).getInt("fpsLimit.$mode", 0)
fun fpsLimit(context: Context, mode: String): Int = prefs(context).getInt("fpsLimit.$mode", 0).coerceAtLeast(0)
fun setFpsLimit(context: Context, mode: String, fps: Int) {
prefs(context).edit().putInt("fpsLimit.$mode", fps.coerceAtLeast(0)).apply()
@@ -798,6 +914,22 @@ object SessionPrefs {
fun fpsLimitChoices(context: Context): List<Pair<Int, String>> =
fpsLimitChoices.map { (fps, label) -> fps to if (fps == 0) context.getString(R.string.frame_gen_off) else label }
/**
* Performance mode: without a frame limit, buffers go back to the game as soon as they are replaced
* instead of once per refresh, and gamescope's WSI layer and winex11 reuse their per-present window checks.
*/
fun performanceMode(context: Context, mode: String): Boolean = prefs(context).getBoolean("performanceMode.$mode", false)
fun setPerformanceMode(context: Context, mode: String, on: Boolean) {
prefs(context).edit().putBoolean("performanceMode.$mode", on).apply()
}
/** Compositor frame limit that releases buffers to the game unpaced. */
const val FPS_UNPACED = -1
/** The limit handed to the compositor: Performance mode without a limit is unpaced, a chosen limit still paces. */
fun compositorFpsLimit(limit: Int, performanceMode: Boolean): Int = if (performanceMode && limit <= 0) FPS_UNPACED else limit
val upscalerChoices = listOf(
0 to "Linear", 2 to "Nearest", 4 to "AMD FSR 1", 3 to "Snapdragon GSR",
8 to "Snapdragon GSR (quality)", 7 to "NVIDIA NIS", 6 to "Sharpen only",
@@ -1051,15 +1183,6 @@ object SessionPrefs {
}.apply()
}
fun zramCompression(context: Context): Boolean {
val prefs = prefs(context)
return if (prefs.contains("zramCompression")) prefs.getBoolean("zramCompression", false) else ZramSupport.recommended(context)
}
fun setZramCompression(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("zramCompression", on).apply()
}
fun gameStorage(context: Context): String = prefs(context).getString("gameStorage", "") ?: ""
@@ -71,6 +71,7 @@ class SessionService : Service() {
private val steamDownloadMonitor = SteamDownloadMonitor()
private var downloadMonitorTask: Runnable? = null
private var downloadActive = false
private var clientInstallActive = false
@Volatile private var steamLibraryMounts = emptyMap<String, File>()
private var suspendDownloadNow = false
private var suspendPolicy = SessionPrefs.SUSPEND_MANUAL
@@ -98,6 +99,8 @@ class SessionService : Service() {
private var suspendOperationPending = false
private var pipTask = false
private var suspendAttemptFailed = false
private var freezePending = false
private var freezeCancelled = false
private var pendingSteamGameId: String? = null
private var pendingSteamGameGeneration = -1
// Bumped to cancel a running game-exit watch (a new launch, or the session ending).
@@ -275,6 +278,16 @@ class SessionService : Service() {
updateSuspendPolicy()
return START_NOT_STICKY
}
ACTION_MIC_CHANGED -> {
if (SessionState.running && SessionState.micAvailable && !SessionState.suspended) {
val enabled = intent.getBooleanExtra("enabled", false)
val relay = components.firstNotNullOfOrNull { it as? DirectAudioRelayComponent }
if (relay?.setMicEnabled(enabled) != true) {
android.widget.Toast.makeText(this, R.string.drawer_mic_failed, android.widget.Toast.LENGTH_LONG).show()
}
}
return START_NOT_STICKY
}
ACTION_SUSPEND_POLICY_CHANGED -> {
if (!SessionState.running) return START_NOT_STICKY
suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode)
@@ -319,18 +332,27 @@ class SessionService : Service() {
suspendDownloadNow = false
steamDownloadMonitor.reset()
downloadActive = false
clientInstallActive = false
steamSleepToken = null
completeNativeSleep()
suspendOperationPending = false
freezePending = false
suspendAttemptFailed = false
suspendController = null
SessionState.micAvailable = false
SessionState.micEnabled = false
SessionState.suspended = false
SessionState.resumeRequested = false
SessionState.resuming = false
SessionState.resumeFailed = false
SessionState.pipActive = false
pipTask = false
SessionState.program = intent?.getStringExtra(EXTRA_PROGRAM)
SessionState.programArgs = intent?.getStringArrayExtra(EXTRA_PROGRAM_ARGS)?.toList().orEmpty()
SessionState.gameId = intent?.getStringExtra(EXTRA_GAME_ID)
SessionState.steamUi = intent?.getStringExtra(EXTRA_STEAM_UI)
SessionState.steamUrl = intent?.getStringExtra(EXTRA_STEAM_URL)
com.droiddeck.launcher.stores.battlenet.BattleNetClient.clearRequests(LinuxRuntime.sessionRoot(this))
synchronized(stopLock) {
SessionState.steamClientArch = null
SessionState.running = true
@@ -345,6 +367,9 @@ class SessionService : Service() {
SessionEvents.transition(SessionPhase.STARTING_GUEST, "service.started", mapOf("mode" to SessionState.mode))
com.droiddeck.launcher.wayland.CompositorHost.newSession()
val gen = ++sessionGen
SessionPrefs.backgroundAppLimit(this).takeIf { it != com.droiddeck.launcher.core.BackgroundAppLimit.OFF }
?.let { com.droiddeck.launcher.core.BackgroundAppLimit.applyAsync(this, it) }
if (SessionPrefs.clearCachedApps(this)) clearCachedApps()
cancelQueuedSteamGame(clearMailbox = false)
clearSteamGameMailbox()
if (SessionState.mode == MODE_STEAM) SessionState.steamUrl
@@ -352,6 +377,7 @@ class SessionService : Service() {
acquireLocks()
Thread({
OrphanReaper.reap("session starting")
ProtonExtras.awaitReplacement(120_000)
runSession(gen)
}, "session-start").start()
return START_NOT_STICKY
@@ -391,7 +417,7 @@ class SessionService : Service() {
private fun runSession(gen: Int) {
SessionTerminal.clear()
com.droiddeck.launcher.gpu.TpUbwcHint.forThisDevice()
com.droiddeck.launcher.gpu.TpUbwcHint.forThisDevice(this)
try {
LinuxRuntime.writeAccounts(this)
} catch (e: Exception) {
@@ -406,6 +432,9 @@ class SessionService : Service() {
killStragglers()
for (pack in listOf(AddonPack.PROOT, AddonPack.DIRECTAUDIO))
if (!pack.await(this, { gen == sessionGen && SessionState.running })) Log.w(TAG, "${pack.name} pack unavailable")
val agentEnvironment = runCatching { com.droiddeck.launcher.agent.AgentEnv.beginSession(this) }
.onFailure { Log.w(TAG, "agent environment skipped", it) }
.getOrDefault(emptyList())
SessionFiles.stage(this, root, startsSession = true)
// Earlier sessions' reports still read the Steam logs and VAC marker this session replaces.
SessionArtifacts.awaitCollections()
@@ -429,7 +458,7 @@ class SessionService : Service() {
.onSuccess { if (it != null) Log.i(TAG, it) }
.onFailure { Log.w(TAG, "forced components", it) }
}
addClientEnvironment(guest, steamHere)
addClientEnvironment(guest, steamHere, agentEnvironment)
if (steamHere) guest.add("BL_EPIC_APP_ID=" + com.droiddeck.launcher.stores.epic.EpicClient.shortcutAppId)
val fastPathAt = guest.size
@@ -450,6 +479,16 @@ class SessionService : Service() {
Log.i(TAG, "hdr: gamescope --hdr-enabled, DXVK_HDR=1")
}
guest.add("BL_FPS=" + SessionState.fpsLimit)
// gamescope's WSI layer and winex11 reuse their per-present window checks for this long; 0 checks on every present.
val windowCheckMs = if (SessionState.performanceMode) 100 else 0
guest.add("DROIDDECK_WSI_BYPASS_CHECK_MS=$windowCheckMs")
guest.add("WINE_OFFSCREEN_CHECK_MS=$windowCheckMs")
if (SessionPrefs.compositorFpsLimit(SessionState.fpsLimit, SessionState.performanceMode) == SessionPrefs.FPS_UNPACED) {
// gamescope turns buffers around slower than an unpaced game draws; more images keep it drawing.
guest.add("GAMESCOPE_WSI_MIN_IMAGE_COUNT=8")
guest.add("GAMESCOPE_WSI_ENSURE_MIN_IMAGE_COUNT=1")
}
Log.i(TAG, "performance mode " + (if (SessionState.performanceMode) "on" else "off") + ", frame limit " + SessionState.fpsLimit)
guest.add("BL_REFRESH=" + Math.round(SessionState.refreshHz))
guest.add("BL_LOG=" + sessionLog.path)
guest.add("BL_DEBUG_DIR=" + sessionDir.path)
@@ -539,6 +578,9 @@ class SessionService : Service() {
Log.i(TAG, "proot: seccomp acceleration off by request")
}
fastPathKey?.let { ProotFastPath.hostEnv(it).let { (k, v) -> hostEnv[k] = v } }
for (key in listOf("PROOT_PROFILE", "PROOT_PROFILE_SLOW_US")) {
guest.lastOrNull { it.startsWith("$key=") }?.substringAfter('=')?.let { hostEnv[key] = it }
}
val prootLibs = LinuxRuntime.prootLibraryPath(this)
if (prootLibs.isNotEmpty()) hostEnv["LD_LIBRARY_PATH"] = prootLibs
@@ -581,7 +623,7 @@ class SessionService : Service() {
val program = programStatus(sessionRoot)
Log.i(TAG, "session ended: $status" + if (program != null) " (program $program)" else "")
stopSession(program ?: status ?: -1)
}, null)
}, null, sessionLog.takeIf { "PROOT_PROFILE=1" in hostEnv.asArray() })
Log.i(TAG, "session pid $pid, log ${sessionLog.path}")
if (gen != sessionGen || !SessionState.running) {
Log.i(TAG, "session stopped while its guest was starting; taking it down")
@@ -612,13 +654,30 @@ class SessionService : Service() {
helperRoots = { components.mapNotNull { it.suspendPid().takeIf { pid -> pid > 1 } } },
suspendAudio = {
if (!pulse.setSinkSuspended(true)) Log.w(TAG, "could not suspend audio sink")
if (!pulse.setMicSuspended(true)) Log.w(TAG, "could not suspend microphone source")
val relay = components.firstNotNullOfOrNull { it as? DirectAudioRelayComponent }
if (relay != null && !relay.awaitMicReleased(MIC_RELEASE_TIMEOUT_MS)) {
Log.w(TAG, "microphone still open after ${MIC_RELEASE_TIMEOUT_MS} ms; suspending anyway")
}
},
resumeAudio = {
if (!pulse.setSinkSuspended(false)) Log.w(TAG, "could not resume audio sink")
if (!pulse.setMicSuspended(false)) Log.w(TAG, "could not resume microphone source")
},
zramControl = File(filesDir, "${LinuxRuntime.DIR}/tmp/.bl-zram"),
pagedOut = { processes, unacknowledged, residentKb, swapKb, swapBeforeKb, millis ->
Log.i(TAG, "paged out $processes processes ($unacknowledged unacknowledged) in $millis ms: " +
"resident ${residentKb / 1024} MB, zram ${swapBeforeKb / 1024} -> ${swapKb / 1024} MB")
mainHandler.post {
SessionEvents.record("session.paged_out", mapOf(
"processes" to processes, "unacknowledged" to unacknowledged, "residentMb" to residentKb / 1024,
"swapMb" to swapKb / 1024, "swapBeforeMb" to swapBeforeKb / 1024, "ms" to millis,
))
}
},
)
mainHandler.post { updateSuspendPolicy() }
updateDownloadMonitoring()
mainHandler.post { updateSuspendPolicy() }
}
}
@@ -633,10 +692,11 @@ class SessionService : Service() {
DeviceReport.write(this, File(sessionDir, "device.txt"), SessionState.mode)
NetworkReport.write(this, File(sessionDir, "network.txt"))
SessionLogCapture.start(File(sessionDir, "app.log"))
MemoryLog.start()
return sessionDir
}
private fun addClientEnvironment(guest: MutableList<String>, steamHere: Boolean) {
private fun addClientEnvironment(guest: MutableList<String>, steamHere: Boolean, agentEnvironment: List<String>) {
guest.add("/usr/bin/env")
guest.add("-i")
guest.add("DROIDDECK_BWRAP_SOCKET=" + com.droiddeck.launcher.runtime.BwrapSpawner.socket(this))
@@ -650,6 +710,7 @@ class SessionService : Service() {
com.droiddeck.launcher.core.AppLanguage.system(this).country,
))
guest.add("TZ=" + java.util.TimeZone.getDefault().id)
if (SessionState.mode == MODE_STEAM) SessionPrefs.pendingShaderPrecache(this)?.let { guest.add("BL_SHADER_PRECACHE=" + if (it) "1" else "0") }
guest.add("XDG_RUNTIME_DIR=" + LinuxRuntime.GUEST_RUNTIME_DIR)
guest.add("XDG_SESSION_TYPE=wayland")
guest.add("WAYLAND_DISPLAY=wayland-0")
@@ -672,7 +733,6 @@ class SessionService : Service() {
val tuOverride = tuDebugOverride()
val tuDriver = sysmemDriver(linuxDriverId)
(tuOverride ?: "sysmem".takeIf { tuDriver || SessionPrefs.tuSysmem(this) })?.let { guest.add("TU_DEBUG=$it") }
com.droiddeck.launcher.gpu.TpUbwcHint.forThisDevice()?.let { guest.add("${com.droiddeck.launcher.gpu.TpUbwcHint.ENV}=$it") }
if (SessionPrefs.zinkLazy(this)) {
guest.add("ZINK_DESCRIPTORS=lazy")
guest.add("ZINK_DEBUG=compact")
@@ -685,7 +745,10 @@ class SessionService : Service() {
SessionState.steamClientArch = if (steamHere) SessionPrefs.steamClientArch(this) else null
SessionState.steamClientArch?.let { arch ->
guest.add("BL_STEAM_CLIENT_ARCH=$arch")
SessionPaths.current()?.let { SessionArtifacts.recordClient(it, arch) }
SessionPaths.current()?.let {
SessionArtifacts.recordClient(it, arch)
SessionArtifacts.markSteamLogs(it, File(SessionPrefs.steamRoot(this, arch), "logs"))
}
if (arch == "x86_64") FexCatalog.cached(this)?.let { guest.addAll(FexCatalog.environment(it)) }
}
if (SessionState.mode == MODE_STEAM) {
@@ -696,12 +759,18 @@ class SessionService : Service() {
guest.add("BL_SYNC_FALLBACK=" + (if (SessionPrefs.syncFallback(this)) "1" else "0"))
guest.add("BL_FSYNC_FIRST=" + (if (SessionPrefs.fsyncFirst(this)) "1" else "0"))
guest.add("BL_GAMESCOPE_REALTIME=" + (if (SessionPrefs.gamescopeRealtime(this)) "1" else "0"))
if (SessionState.mode == MODE_STEAM && SessionPrefs.zramCompression(this) &&
com.droiddeck.launcher.core.ZramSupport.supported()) guest.add("BL_ZRAM=1")
if (SessionPrefs.cursorComposite(this)) guest.add("BL_CURSOR_COMPOSITE=1")
if (com.droiddeck.launcher.core.ZramSupport.supported()) {
guest.add("BL_ZRAM=1")
if (steamHere) SessionPrefs.ramCapMb(this).takeIf { it > 0 }?.let { guest.add("BL_RAM_CAP_MB=$it") }
if (!SessionPrefs.zramClient(this)) guest.add("BL_ZRAM_CLIENT=0")
}
SessionPrefs.vramCapMb(this).takeIf { it > 0 }?.let { guest.add("BL_VRAM_MB=$it") }
guest.add("BANNER_AUDIO_DIRECT_DECAY=0")
val extra = extraEnv() + com.droiddeck.launcher.agent.AgentEnv.beginSession(this)
val extra = extraEnv() + agentEnvironment
if (extra.none { it.startsWith("TU_DEBUG=") }) guest.add("$GAME_TU_DEBUG=" + (tuOverride ?: if (tuDriver) "sysmem" else ""))
extra.forEach { guest.add(it) }
com.droiddeck.launcher.gpu.TpUbwcHint.forThisDevice(this)?.let { guest.add("${com.droiddeck.launcher.gpu.TpUbwcHint.ENV}=$it") }
val clientPinned = steamHere && SessionPrefs.clientCpusOverride(this)
if (clientPinned) {
guest.add("BL_CLIENT_CPUS=" + CpuCores.listOrAll(SessionPrefs.clientCpus(this)))
@@ -711,7 +780,7 @@ class SessionService : Service() {
}
private fun noSteamGame(): Boolean =
SessionState.mode == MODE_RUN && SessionState.program in setOf(NoSteamLaunch.PROTON_RUN, NoSteamLaunch.EPIC)
SessionState.mode == MODE_RUN && SessionState.program in setOf(NoSteamLaunch.PROTON_RUN, NoSteamLaunch.EPIC, NoSteamLaunch.BATTLENET)
private fun startAudio(guest: MutableList<String>, sessionDir: File): PulseAudioComponent {
val wantsDirectAudio = (SessionState.mode == MODE_STEAM || SessionState.mode == MODE_DESKTOP || noSteamGame()) &&
@@ -732,6 +801,15 @@ class SessionService : Service() {
components.add(pulse)
if (wantsDirectAudio || wantsMic) {
val relay = DirectAudioRelayComponent(relaySocket, micFifo)
val gen = sessionGen
relay.onMicEnabled = java.util.function.Consumer { enabled ->
mainHandler.post {
if (gen == sessionGen && SessionState.running && wantsMic) {
SessionState.micAvailable = true
SessionState.micEnabled = enabled
}
}
}
relay.setLogFile(audioLog)
relay.attach(this)
components.add(relay)
@@ -1002,6 +1080,7 @@ class SessionService : Service() {
if (!stillSameProcess()) return
val tree = descendants(prootPid)
if (!stillSameProcess()) return
saveWineRegistries(tree)
android.os.Process.sendSignal(prootPid, 15)
if (!waitForExit(prootPid, GRACE_MS)) {
if (!stillSameProcess()) return
@@ -1018,6 +1097,31 @@ class SessionService : Service() {
if (killed > 0) Log.i(TAG, "swept $killed process(es) proot left behind")
}
private fun clearCachedApps() {
val app = applicationContext
Thread({
val before = MemoryReadout.availableMb()
val result = runCatching { com.droiddeck.launcher.core.WirelessAdbFix.clearCachedApps(app) }
val after = MemoryReadout.availableMb()
result.onSuccess { Log.i(TAG, "cached apps closed: available memory $before -> $after MB") }
.onFailure { Log.w(TAG, "cached apps not closed: ${it.message}") }
}, "clear-cached-apps").start()
}
private fun saveWineRegistries(tree: List<Pair<Int, Long>>) {
val servers = tree.filter { (pid, started) ->
readStat(pid)?.second == started && WineProcesses.isWineserver(
runCatching { File("/proc/$pid/cmdline").readBytes() }.getOrNull(),
runCatching { File("/proc/$pid/comm").readText() }.getOrNull(),
)
}
if (servers.isEmpty()) return
servers.forEach { (pid, _) -> android.os.Process.sendSignal(pid, 15) }
val deadline = System.currentTimeMillis() + WINE_SAVE_MS
val saved = servers.count { (pid, _) -> waitForExit(pid, (deadline - System.currentTimeMillis()).coerceAtLeast(0)) }
Log.i(TAG, "wineserver registry save: $saved of ${servers.size} exited after SIGTERM")
}
private fun descendants(root: Int): List<Pair<Int, Long>> {
val started = HashMap<Int, Long>()
val children = HashMap<Int, MutableList<Int>>()
@@ -1191,6 +1295,7 @@ class SessionService : Service() {
}
private fun resumeSession(panelVisible: Boolean = true) {
if (SessionState.suspended) SessionState.resumeRequested = true
if (panelVisible) activityVisible = true
screenOn = readScreenOn()
manualPauseRequested = false
@@ -1296,6 +1401,11 @@ class SessionService : Service() {
}
return
}
if (path == com.droiddeck.launcher.runtime.BattleNet.FEX_REQUEST) {
File(dir, path).delete()
com.droiddeck.launcher.runtime.BattleNet.answerFexRequest(this@SessionService, dir)
return
}
if (path != null && path.startsWith("desktop-import-") && path.endsWith(".request")) {
val request = File(dir, path)
val text = runCatching { request.readText() }.getOrNull() ?: return
@@ -1388,10 +1498,12 @@ class SessionService : Service() {
private fun updateSuspendPolicy() {
if (!SessionState.running) return
val hidden = !hasVisibleSurface
val downloadsBlockingSuspend = steamDownloadsInBackground && SessionState.mode == MODE_STEAM &&
suspendPolicy != SessionPrefs.SUSPEND_NEVER && hidden && downloadActive && !suspendDownloadNow
val downloadsBlockingSuspend = downloadsKeepAwake() && hidden && !suspendDownloadNow
if (suspendPolicy == SessionPrefs.SUSPEND_MANUAL && hidden && !downloadsBlockingSuspend) {
manualPauseRequested = true
} else if (!hidden && !SessionState.suspended) {
// Coming back before the freeze finished keeps the game running instead of pausing it a moment later.
manualPauseRequested = false
}
val controller = suspendController ?: return
if (downloadsBlockingSuspend) acquireLocks()
@@ -1413,6 +1525,11 @@ class SessionService : Service() {
SessionPrefs.SUSPEND_MANUAL -> manualPauseRequested && prepared && !downloadsBlockingSuspend
else -> false
}
if (freezePending && !shouldSuspend && !freezeCancelled) {
freezeCancelled = true
controller.cancelFreeze()
}
if (shouldSuspend && SessionState.suspended && !suspendOperationPending) SessionState.resumeRequested = false
if (suspendOperationPending || suspendAttemptFailed || shouldSuspend == SessionState.suspended) return
val gen = sessionGen
suspendOperationPending = true
@@ -1423,36 +1540,54 @@ class SessionService : Service() {
"nativeSleep" to (nativeSleepToken != null),
))
if (shouldSuspend) {
controller.freeze { success ->
freezePending = true
freezeCancelled = false
val freezeSteamToken = steamSleepToken
val freezeNativeToken = nativeSleepToken
controller.freeze(
pageOut = steamSleepToken != null || nativeSleepToken != null || !activityVisible || !screenOn,
) { success ->
mainHandler.post {
if (gen != sessionGen || !SessionState.running) return@post
suspendOperationPending = false
freezePending = false
if (success) {
SessionState.suspended = true
SessionState.resumeFailed = false
SessionEvents.transition(SessionPhase.SUSPENDED, "session.suspended")
releaseLocks()
refreshNotification()
} else {
completeNativeSleep()
completeSteamSleep()
suspendAttemptFailed = true
Log.w(TAG, "could not confirm that the session stopped")
if (nativeSleepToken == freezeNativeToken) completeNativeSleep()
if (steamSleepToken == freezeSteamToken) completeSteamSleep()
if (freezeCancelled) {
SessionEvents.record("session.suspend_cancelled")
} else {
suspendAttemptFailed = true
Log.w(TAG, "could not confirm that the session stopped")
}
}
updateSuspendPolicy()
}
}
} else {
acquireLocks()
SessionState.resuming = true
controller.resume { success ->
mainHandler.post {
if (gen != sessionGen || !SessionState.running) return@post
suspendOperationPending = false
SessionState.resuming = false
SessionState.resumeRequested = false
if (success) {
SessionState.suspended = false
SessionState.resumeFailed = false
SessionEvents.transition(SessionEvents.resumePhase(), "session.resumed")
refreshNotification()
} else {
suspendAttemptFailed = true
SessionState.resumeFailures++
SessionState.resumeFailed = true
Log.w(TAG, "could not confirm that the session resumed")
}
updateSuspendPolicy()
@@ -1542,6 +1677,9 @@ class SessionService : Service() {
return@post
}
SessionState.suspended = false
SessionState.resumeRequested = false
SessionState.resuming = false
SessionState.resumeFailed = false
SessionState.pipActive = false
pipTask = false
SessionState.guestPid = -1
@@ -1551,6 +1689,8 @@ class SessionService : Service() {
runCatching { com.droiddeck.launcher.agent.AgentEnv.endSession(this) }
.onFailure { Log.w(TAG, "clearing the agent's session environment", it) }
releaseLocks()
MemoryLog.stop()
com.droiddeck.launcher.core.BackgroundAppLimit.restoreAsync(applicationContext)
if (status == 0) {
SessionEvents.transition(SessionPhase.IDLE, "session.stopped", mapOf("status" to status))
} else {
@@ -1593,20 +1733,24 @@ class SessionService : Service() {
completeNativeSleep()
val (ended, endedArch) = synchronized(stopLock) {
if (!SessionState.running) return
SessionEvents.record("guest.exited", mapOf("status" to status))
SessionEvents.transition(SessionPhase.STOPPING, "session.stopping", mapOf("status" to status))
SessionState.running = false
SessionPaths.take() to SessionState.steamClientArch
}
SessionState.micAvailable = false
SessionState.micEnabled = false
xrActivityVisible = false
updateDownloadMonitoring()
val stoppedGen = sessionGen
val stoppedSession = SessionState.sessionId
val launcherEnded = SessionState.mode == MODE_STEAM || SessionState.mode == MODE_DESKTOP || SessionState.program == NoSteamLaunch.EPIC
val launcherEnded = SessionState.mode == MODE_STEAM || SessionState.mode == MODE_DESKTOP || SessionState.program == NoSteamLaunch.EPIC ||
SessionState.program == NoSteamLaunch.BATTLENET
SessionState.stopRequested = false
SessionEvents.record("guest.exited", mapOf("status" to status))
GpuClockPin.stop(this)
GameThreadHints.stop()
SessionEvents.transition(SessionPhase.STOPPING, "session.stopping", mapOf("status" to status))
suspendOperationPending = false
freezePending = false
launchWatcher?.stopWatching()
launchWatcher = null
syncWatcher?.stopWatching()
@@ -1661,6 +1805,9 @@ class SessionService : Service() {
private fun importLauncherGames() {
runCatching { com.droiddeck.launcher.stores.epic.EpicClientLibrary.importClientGames(this) }
.onFailure { Log.w(TAG, "client library scan failed: ${it.javaClass.simpleName}") }
runCatching { com.droiddeck.launcher.stores.battlenet.BattleNetClient.syncAccount(this) }
.onFailure { Log.w(TAG, "Battle.net sign-in check failed: ${it.javaClass.simpleName}") }
com.droiddeck.launcher.stores.StoresState.notifyLibraryChanged(this)
}
override fun onTaskRemoved(rootIntent: Intent?) {
@@ -1773,8 +1920,7 @@ class SessionService : Service() {
.setContentText(getString(
when {
SessionState.suspended -> R.string.session_notification_paused
downloadActive && steamDownloadsInBackground &&
suspendPolicy != SessionPrefs.SUSPEND_NEVER -> R.string.session_notification_downloads
downloadsKeepAwake() -> R.string.session_notification_downloads
else -> R.string.session_notification
}
))
@@ -1782,8 +1928,7 @@ class SessionService : Service() {
.apply {
if (SessionState.suspended) {
addAction(Notification.Action.Builder(null, getString(R.string.resume_session), resume).build())
} else if (downloadActive && steamDownloadsInBackground &&
suspendPolicy != SessionPrefs.SUSPEND_NEVER) {
} else if (downloadsKeepAwake()) {
val suspend = PendingIntent.getService(
this@SessionService, 3,
Intent(this@SessionService, SessionService::class.java).setAction(ACTION_SUSPEND_NOW),
@@ -1803,33 +1948,50 @@ class SessionService : Service() {
getSystemService(NotificationManager::class.java)?.notify(NOTIFICATION_ID, buildNotification())
}
private fun downloadsKeepAwake(): Boolean =
downloadActive && (steamDownloadsInBackground || clientInstallActive) &&
SessionState.mode == MODE_STEAM && suspendPolicy != SessionPrefs.SUSPEND_NEVER
private fun downloadWatchWanted(): Boolean =
SessionState.running && SessionState.mode == MODE_STEAM && suspendPolicy != SessionPrefs.SUSPEND_NEVER &&
(steamDownloadsInBackground || !SessionState.firstFrameSeen)
private fun updateDownloadMonitoring() {
val enabled = SessionState.running && SessionState.mode == MODE_STEAM &&
steamDownloadsInBackground && suspendPolicy != SessionPrefs.SUSPEND_NEVER
if (!enabled) {
if (!downloadWatchWanted()) {
val wasActive = downloadActive
downloadMonitorTask?.let(mainHandler::removeCallbacks)
downloadMonitorTask = null
steamDownloadMonitor.reset()
downloadActive = false
if (clientInstallActive) SessionEvents.record("steam.client_download", mapOf("active" to false))
clientInstallActive = false
if (wasActive) refreshNotification()
return
}
if (downloadMonitorTask != null) return
val poll = object : Runnable {
override fun run() {
if (downloadMonitorTask !== this || !SessionState.running ||
SessionState.mode != MODE_STEAM || !steamDownloadsInBackground ||
suspendPolicy == SessionPrefs.SUSPEND_NEVER) {
if (downloadMonitorTask !== this) return
if (!downloadWatchWanted()) {
downloadMonitorTask = null
updateDownloadMonitoring()
updateSuspendPolicy()
return
}
val root = LinuxRuntime.rootDir(this@SessionService)
val wasActive = downloadActive
downloadActive = steamDownloadMonitor.poll(
LinuxRuntime.rootDir(this@SessionService),
val wasInstalling = clientInstallActive
clientInstallActive = !SessionState.firstFrameSeen &&
(clientInstallActive || SteamDownloadMonitor.clientInstallPending(root))
downloadActive = clientInstallActive || steamDownloadsInBackground && steamDownloadMonitor.poll(
root,
receivedBytes = TrafficStats.getUidRxBytes(android.os.Process.myUid()),
client = SessionState.steamClientArch,
mounts = steamLibraryMounts,
)
if (wasInstalling != clientInstallActive) {
SessionEvents.record("steam.client_download", mapOf("active" to clientInstallActive))
}
if (wasActive != downloadActive) refreshNotification()
updateSuspendPolicy()
mainHandler.postDelayed(this, DOWNLOAD_POLL_MS)
@@ -1855,6 +2017,7 @@ class SessionService : Service() {
companion object {
private const val TAG = "SessionService"
private const val TRACER_NICE = -6
private const val MIC_RELEASE_TIMEOUT_MS = 5_000L
private const val TU_DEBUG_SWITCH = "Download/droiddeck-tu-debug"
private const val GAME_TU_DEBUG = "DROIDDECK_GAME_TU_DEBUG"
private const val LEGACY_ENV_SWITCH = "Download/droiddeck-env"
@@ -1873,6 +2036,7 @@ class SessionService : Service() {
const val ACTION_LAUNCH_GAME = "com.droiddeck.launcher.LAUNCH_STEAM_GAME"
const val ACTION_HOME_GUIDE = "com.droiddeck.launcher.HOME_GUIDE"
const val ACTION_AGENT_START = "com.droiddeck.launcher.AGENT_START"
private const val ACTION_MIC_CHANGED = "com.droiddeck.launcher.MIC_CHANGED"
private const val ACTION_SUSPEND_POLICY_CHANGED = "com.droiddeck.launcher.SUSPEND_POLICY_CHANGED"
private const val ACTION_ACTIVITY_VISIBLE = "com.droiddeck.launcher.ACTIVITY_VISIBLE"
private const val ACTION_ACTIVITY_HIDDEN = "com.droiddeck.launcher.ACTIVITY_HIDDEN"
@@ -1891,6 +2055,7 @@ class SessionService : Service() {
private val STRAGGLERS = listOf("droiddeck-session", "gamescope", "Xwayland", "steamrtarm64",
"steamwebhelper", "linuxfs/opt/android-host/proot", "/libproot.so", "pulseaudio/libpulseaudio.so")
private const val GRACE_MS = 1200L
private const val WINE_SAVE_MS = 3000L
private const val STEAM_PICKUP_MS = 1500L
private const val STEAM_EXIT_MS = 10_000L
private const val GAME_LAUNCH_RETRY_MS = 250L
@@ -1922,6 +2087,7 @@ class SessionService : Service() {
fun start(
context: Context, mode: String = MODE_STEAM, program: String? = null,
steamUi: String? = null, steamUrl: String? = null, programArgs: Array<String>? = null,
gameId: String? = null,
displayId: Int = Display.DEFAULT_DISPLAY,
) {
SessionState.activityDisplayId = displayId
@@ -1932,6 +2098,7 @@ class SessionService : Service() {
if (programArgs != null) intent.putExtra(EXTRA_PROGRAM_ARGS, programArgs)
if (steamUi != null) intent.putExtra(EXTRA_STEAM_UI, steamUi)
if (steamUrl != null) intent.putExtra(EXTRA_STEAM_URL, steamUrl)
if (gameId != null) intent.putExtra(EXTRA_GAME_ID, gameId)
if (Build.VERSION.SDK_INT >= 26) context.startForegroundService(intent)
else context.startService(intent)
}
@@ -1986,6 +2153,7 @@ class SessionService : Service() {
fun resume(context: Context) {
if (!SessionState.running) return
if (SessionState.suspended) SessionState.resumeRequested = true
context.startService(Intent(context, SessionService::class.java).setAction(ACTION_RESUME))
}
@@ -2002,6 +2170,11 @@ class SessionService : Service() {
}.getOrDefault(false)
}
fun setMicEnabled(context: Context, enabled: Boolean) {
context.startService(Intent(context, SessionService::class.java).setAction(ACTION_MIC_CHANGED)
.putExtra("enabled", enabled))
}
fun suspendPolicyChanged(context: Context) {
if (!SessionState.running) return
context.startService(Intent(context, SessionService::class.java).setAction(ACTION_SUSPEND_POLICY_CHANGED))
@@ -2,6 +2,7 @@ package com.droiddeck.launcher.session
import java.io.File
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
@@ -45,13 +46,21 @@ object SessionState {
var activityDisplayId = 0
@Volatile
var runtimeDriver: String? = null
var micAvailable by mutableStateOf(false)
var micEnabled by mutableStateOf(false)
var suspended by mutableStateOf(false)
var resumeRequested by mutableStateOf(false)
var resuming by mutableStateOf(false)
var resumeFailures by mutableIntStateOf(0)
var resumeFailed by mutableStateOf(false)
@Volatile var pipActive = false
@Volatile
var program: String? = null
@Volatile
var programArgs: List<String> = emptyList()
@Volatile
var gameId: String? = null
@Volatile
var steamUi: String? = null
@Volatile
var steamUrl: String? = null
@@ -97,6 +106,9 @@ object SessionState {
@Volatile
var fpsLimit: Int = 0
@Volatile
var performanceMode = false
@Volatile
var firstFrameSeen = false
@@ -18,6 +18,9 @@ internal class SessionSuspendController(
private val sleep: (Long) -> Unit = Thread::sleep,
private val nowMillis: () -> Long = { System.nanoTime() / 1_000_000 },
private val executor: ExecutorService = Executors.newSingleThreadExecutor(),
private val zramControl: File? = null,
private val pagedOut: (processes: Int, unacknowledged: Int, residentKb: Long, swapKb: Long, swapBeforeKb: Long, millis: Long) -> Unit =
{ _, _, _, _, _, _ -> },
) {
private enum class Role { SESSION_ROOT, SESSION_CHILD, HELPER_ROOT, HELPER_CHILD }
@@ -38,9 +41,11 @@ internal class SessionSuspendController(
private data class TaskIdentity(val process: ProcessRecord, val tid: Int, val started: Long)
@Volatile private var closing = false
@Volatile private var cancelled = false
private var frozen = emptyList<ProcessRecord>()
fun freeze(completion: (Boolean) -> Unit) {
fun freeze(pageOut: Boolean = false, completion: (Boolean) -> Unit) {
cancelled = false
submit({
val records = frozen.associateByTo(LinkedHashMap()) { it.pid to it.started }
var complete = false
@@ -59,17 +64,18 @@ internal class SessionSuspendController(
records[it.pid to it.started] = ProcessRecord(it.pid, it.started, Role.HELPER_ROOT)
}
}
if (pageOut) pageOut(records, root.pid, helpers)
val deadline = nowMillis() + FREEZE_TIMEOUT_MS
var previous: Map<TaskIdentity, Long>? = null
for (pass in 0 until FREEZE_MAX_PASSES) {
if (closing || nowMillis() >= deadline) break
if (closing || cancelled || nowMillis() >= deadline) break
try {
if (readProcess(root.pid)?.started != root.started) break
addSnapshot(records, root.pid, helpers)
if (readProcess(root.pid)?.started != root.started) break
stop(records.values.toList())
sleep(if (pass == 0) 70L else 35L)
if (closing || nowMillis() >= deadline) break
if (closing || cancelled || nowMillis() >= deadline) break
val current = stoppedSnapshot(records.values.toList(), root)
if (current != null && current == previous) {
complete = true
@@ -97,6 +103,11 @@ internal class SessionSuspendController(
}, { completion(false) })
}
// A freeze still queued or paging out stops before its next SIGSTOP pass and rolls back.
fun cancelFreeze() {
cancelled = true
}
fun resume(completion: (Boolean) -> Unit) {
submit({
val records = frozen
@@ -233,6 +244,65 @@ internal class SessionSuspendController(
return tasks.takeIf { stopped && it.isNotEmpty() }
}
// A stopped process cannot run libblsession's SIGURG handler, so the page-out has to finish
// before the stop. Android's low-memory killer otherwise finds the frozen game resident in RAM
// with most of zram still free.
private fun pageOut(records: MutableMap<Pair<Int, Long>, ProcessRecord>, root: Int, helpers: List<Int>) {
val control = zramControl?.takeIf { it.isFile } ?: return
try {
addSnapshot(records, root, helpers)
control.writeText("1")
} catch (_: IOException) {
return
}
val targets = records.values.filter { record ->
try {
File(procRoot, "${record.pid}/maps").useLines { lines -> lines.any { it.endsWith("/libblsession.so") } }
} catch (_: IOException) {
false
}
}
if (targets.isEmpty()) return
val acks = targets.associateWith { File(control.parentFile, "${control.name}.ack.${it.pid}") }
acks.values.forEach { it.delete() }
val start = nowMillis()
val before = memoryKb(targets)
for (record in targets) send(record, SIGURG)
// A program can replace libblsession's SIGURG handler (PyInstaller's bootloader, the Go runtime);
// such a process never creates its ack and is not waited for once the start window has passed.
val waiting = targets.toMutableList()
while (!closing && !cancelled && nowMillis() - start < PAGE_OUT_TIMEOUT_MS) {
val started = nowMillis() - start >= PAGE_OUT_START_MS
waiting.removeAll { val ack = acks.getValue(it); ack.length() > 0 || (started && !ack.exists()) || isGone(it) }
if (waiting.isEmpty()) break
sleep(PAGE_OUT_POLL_MS)
}
val unacknowledged = targets.count { acks.getValue(it).length() == 0L && !isGone(it) }
acks.values.forEach { it.delete() }
val after = memoryKb(targets)
pagedOut(targets.size, unacknowledged, after.first, after.second, before.second, nowMillis() - start)
}
private fun isGone(record: ProcessRecord): Boolean =
try { readProcess(record.pid)?.started != record.started } catch (_: Exception) { false }
private fun memoryKb(records: List<ProcessRecord>): Pair<Long, Long> {
var resident = 0L
var swap = 0L
for (record in records) {
try {
File(procRoot, "${record.pid}/status").useLines { lines ->
for (line in lines) {
val kb = line.substringAfter(':').trim().substringBefore(' ').toLongOrNull() ?: continue
if (line.startsWith("VmRSS:")) resident += kb else if (line.startsWith("VmSwap:")) swap += kb
}
}
} catch (_: IOException) {
}
}
return resident to swap
}
// PRoot traces every guest. A stop sent while it runs becomes a group stop that it restarts
// thread by thread; once it is stopped, each thread that takes a SIGSTOP stays parked in its
// signal-delivery stop. So the roots stop first and every guest thread gets its own SIGSTOP.
@@ -310,6 +380,10 @@ internal class SessionSuspendController(
private companion object {
const val SIGSTOP = 19
const val SIGCONT = 18
const val SIGURG = 23
const val PAGE_OUT_POLL_MS = 100L
const val PAGE_OUT_START_MS = 2_000L
const val PAGE_OUT_TIMEOUT_MS = 12_000L
const val RESTORE_CHECKS = 80
const val FREEZE_TIMEOUT_MS = 8_000L
const val FREEZE_MAX_PASSES = 256
@@ -18,9 +18,9 @@ object SettingsReset {
"gamepadLeft", "gamepadRight", "pipAutoEnter", "unfoldedControls", "unfoldedControlsWithController",
"stretch16x9", "picturePosition",
"gpuDriverMode", "androidDriver", "linuxDriver", "linuxDriver.steam", "linuxDriver.desktop",
"tuSysmem", "glThread", "noGlError", "zinkLazy", "mangoapp", "gpuClockPin", "gamescopeRealtime",
"tuSysmem", "glThread", "noGlError", "zinkLazy", "mangoapp", "gpuClockPin", "gamescopeRealtime", "ramCapMb", "vramCapMb", "zramClient",
"directAudio", "mic", "wifiDiscovery", "clientCpusOverride", "clientCpus", "gameCpus",
"noXalia", "fastSync", "fsyncFirst", "syncFallback", "prootNoSeccomp", "prootFastPath",
"noXalia", "fastSync", "fsyncFirst", "syncFallback", "prootNoSeccomp", "prootFastPath", "cursorComposite",
"guestHostname", "fexPreset", "forceSsbs", "logs", "storageDiagnostics",
"upscaler", "upscaleSharpness", "screenEffects", "textureAnisotropy", "textureLodBias",
"steamDownloadsInBackground", "steam_offline", "bootMovie", "bootMovieSound", "bootMovieVolume", "bootMovieSeconds",
@@ -29,7 +29,7 @@ object SettingsReset {
)
internal val SESSION_PREFIXES = listOf(
"resolutionCap.", "displayResolution.", "customRes.", "hdr.", "fpsLimit.", "suspendPolicy.",
"resolutionCap.", "displayResolution.", "customRes.", "hdr.", "fpsLimit.", "performanceMode.", "suspendPolicy.",
)
private val STEAM_TOUCH_KEYS = listOf("feedback", "autoFade", "gyro", "shake")
@@ -1,7 +1,7 @@
package com.droiddeck.launcher.session
import java.io.File
import java.io.RandomAccessFile
import java.io.FileInputStream
internal class SteamDownloadMonitor {
private data class ManifestState(
@@ -63,16 +63,17 @@ internal class SteamDownloadMonitor {
lastContentActivityAt = 0L
}
runCatching {
RandomAccessFile(log, "r").use { input ->
input.seek(contentLogOffset)
FileInputStream(log).use { input ->
input.channel.position(contentLogOffset)
val reader = input.bufferedReader(Charsets.ISO_8859_1)
while (true) {
val line = input.readLine() ?: break
val line = reader.readLine() ?: break
when {
"Client version:" in line -> {
activeContentUpdates.clear()
lastContentActivityAt = 0L
}
CONTENT_UPDATE.find(line)?.let { match ->
" update changed : " in line && CONTENT_UPDATE.find(line)?.let { match ->
val appId = match.groupValues[1]
val updateType = match.groupValues[2]
val state = match.groupValues[3]
@@ -85,14 +86,15 @@ internal class SteamDownloadMonitor {
}
true
} == true -> Unit
CURRENT_RATE.find(line)?.groupValues?.get(1)?.toDoubleOrNull()?.let { it > 0.0 } == true ->
"Current download rate: " in line &&
CURRENT_RATE.find(line)?.groupValues?.get(1)?.toDoubleOrNull()?.let { it > 0.0 } == true ->
lastContentActivityAt = now
"Downloading " in line && " chunks for depot " in line ->
lastContentActivityAt = now
}
if (activeContentUpdates.isNotEmpty()) lastContentActivityAt = now
}
contentLogOffset = input.filePointer
contentLogOffset = input.channel.position()
}
}
return activeContentUpdates.isNotEmpty() && lastContentActivityAt > 0L &&
@@ -138,13 +140,21 @@ internal class SteamDownloadMonitor {
private fun value(text: String, key: String): String? =
Regex("\"$key\"\\s+\"([^\"]*)\"").find(text)?.groupValues?.get(1)
private companion object {
const val MANIFEST_PROGRESS_GRACE_MS = 8_000L
const val CONTENT_ACTIVITY_GRACE_MS = 90_000L
const val MIN_RECEIVE_BYTES_PER_SEC = 64L * 1024L
val PATH = Regex("\"path\"\\s+\"([^\"]+)\"")
val STEAM_HOMES = listOf("root/.local/share/Steam", "root/.droiddeck-x64/.local/share/Steam")
val CONTENT_UPDATE = Regex("""AppID (\d+) (App|Workshop|Shader) update changed : (.*)""")
val CURRENT_RATE = Regex("""Current download rate: ([\d.]+) Mbps""")
companion object {
fun clientInstallPending(runtimeRoot: File): Boolean {
val home = File(runtimeRoot, "root")
val steam = File(home, ".local/share/Steam")
val stamped = File(steam, "package/droiddeck-installed").isFile ||
File(steam, "package/bannerlator-installed").isFile
return !(stamped && File(steam, "steamrtarm64/steam").isFile) || File(home, ".bl-steam-repair").exists()
}
private const val MANIFEST_PROGRESS_GRACE_MS = 8_000L
private const val CONTENT_ACTIVITY_GRACE_MS = 90_000L
private const val MIN_RECEIVE_BYTES_PER_SEC = 64L * 1024L
private val PATH = Regex("\"path\"\\s+\"([^\"]+)\"")
private val STEAM_HOMES = listOf("root/.local/share/Steam", "root/.droiddeck-x64/.local/share/Steam")
private val CONTENT_UPDATE = Regex("""AppID (\d+) (App|Workshop|Shader) update changed : (.*)""")
private val CURRENT_RATE = Regex("""Current download rate: ([\d.]+) Mbps""")
}
}
@@ -101,10 +101,28 @@ object WinComponents {
fun cached(context: Context): List<Component>? =
runCatching { parse(File(context.filesDir, CATALOG_CACHE).readText()) }.getOrNull()
const val VCREDIST_ARM64 = "vcredist2022_arm64"
/**
* The ARM64 Visual C++ 2015-2022 runtime as registry values only (the version a 14.44.35211 installer writes, in both
* registry views). Xbox games' launchers running as ARM64EC check for it; Wine's own CRT does the work. Same approach as
* MGamer404's Bannerlator #627. Used until the catalog carries a component of this name.
*/
internal fun builtInArm64Runtime(): Component {
fun value(view: String, name: String, type: String, data: String) = Step("set_register_key", JSONObject()
.put("action", "set_register_key").put("key", "HKLM\\Software\\${view}Microsoft\\VisualStudio\\14.0\\VC\\Runtimes\\arm64")
.put("value", name).put("type", type).put("data", data))
val steps = listOf("", "Wow6432Node\\").flatMap { view ->
listOf(value(view, "Installed", "REG_DWORD", "1"), value(view, "Major", "REG_DWORD", "14"), value(view, "Minor", "REG_DWORD", "44"),
value(view, "Bld", "REG_DWORD", "35211"), value(view, "Rbld", "REG_DWORD", "0"), value(view, "Version", "REG_SZ", "v14.44.35211.00"))
}
return Component(VCREDIST_ARM64, "Microsoft Visual C++ 2015-2022 ARM64 runtime 14.44.35211 (registry entries only)", "Microsoft", "ready", emptyList(), steps)
}
private fun parse(body: String): List<Component>? {
return try {
val arr = JSONObject(body).getJSONArray("components")
(0 until arr.length()).mapNotNull { i ->
val listed = (0 until arr.length()).mapNotNull { i ->
val o = arr.getJSONObject(i)
val name = o.optString("name")
if (!validId(name)) return@mapNotNull null
@@ -117,6 +135,7 @@ object WinComponents {
o.optString("snapshot"),
)
}
if (listed.any { it.name == VCREDIST_ARM64 }) listed else listed + builtInArm64Runtime()
} catch (e: Exception) {
Log.w(TAG, "catalog: $e"); null
}
Loaded 100 of 538 files, more files were not shown because too many files have changed in this diff. Show more