diff --git a/.github/workflows/authorship.yml b/.github/workflows/authorship.yml index 97c6637a8..ebbcae644 100644 --- a/.github/workflows/authorship.yml +++ b/.github/workflows/authorship.yml @@ -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: diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index aacad45f0..146e7fe3b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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="" - { - 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 "
What went wrong (Gradle)" - echo - echo '```' - awk '/^\* What went wrong/{p=1} /^\* Try:/{p=0} p{print}' /tmp/build.log 2>/dev/null | head -60 - echo '```' - echo "
" - ;; - 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 diff --git a/README.md b/README.md index 97043973e..76a02242b 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,20 @@ DroidDeck brings the SteamOS experience to Android: Valve's Steam client in Big

Join the DroidDeck Discord

+

+ Star History Rank +

+ +

+ Droid-Deck%2FDroidDeck | Trendshift + Droid-Deck%2FDroidDeck | Trendshift +

+ +

+ Droid-Deck%2FDroidDeck | Trendshift + Droid-Deck%2FDroidDeck | Trendshift +

+

Tapping DroidDeck on the Android home screen and landing in Steam Big Picture

> 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-.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//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-.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//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//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 driver in `Documentation/userspace-api/ntsync.rst` (GPL-2.0). Xbox games load the Windows modules of [@domsvoboda](https://github.com/domsvoboda)'s [xodus-nexus](https://github.com/domsvoboda/xodus-nexus) runtime (LGPL-2.1-or-later), including its `wintypes.dll`, whose `ApiInformation` answer Minecraft for Windows needs to start; how Minecraft installs GameInput under Wine was checked against [@Wyze3306](https://github.com/Wyze3306)'s [BedrockOnLinux](https://github.com/Wyze3306/BedrockOnLinux) (MIT). See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve. 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. diff --git a/app/build.gradle b/app/build.gradle index c889af588..6fdfe9c4b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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' } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 543994093..a1c126962 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,15 @@ + + + + + + + + + @@ -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 @@ @@ -84,8 +94,8 @@ @@ -97,7 +107,7 @@ @@ -151,6 +161,27 @@ android:process=":xboxlogin" android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|density" android:theme="@style/AppTheme" /> + + + + + + + + + + + + + + + + + +
+
+ + + diff --git a/app/src/main/assets/xcloud/player.js b/app/src/main/assets/xcloud/player.js new file mode 100644 index 000000000..3d3762149 --- /dev/null +++ b/app/src/main/assets/xcloud/player.js @@ -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,RightShoulder:0,LeftThumb:0,RightThumb:0,LeftThumbXAxis:0,LeftThumbYAxis:0,RightThumbXAxis:0,RightThumbYAxis:0,LeftTrigger:0,RightTrigger:0}}detectActiveGamepad(){if(this._physicalGamepadId<0)for(let e in navigator.getGamepads()){let t=navigator.getGamepads()[e]?.index;if(t===void 0)continue;let n=!1;for(let i in this._player?._channels.control.getGamepadHandlers())if(this._player?._channels.control.getGamepadHandlers()[i]instanceof r&&(this._player?._channels.control.getGamepadHandlers()[i])._physicalGamepadId===navigator.getGamepads()[e]?.index){console.log("[Gamepad] Active gamepad already detected:",t,(this._player?._channels.control.getGamepadHandlers()[i])._physicalGamepadId),n=!0;continue}n===!1&&(this._physicalGamepadId=t,console.log("[Gamepad] Active gamepad detected:",this._physicalGamepadId))}else console.log("[Gamepad] Physical gamepad already detected:",this._physicalGamepadId)}onGamepadConnected(e){if(this._physicalGamepadId<0){for(let t in this._player?._channels.control.getGamepadHandlers())if(this._player?._channels.control.getGamepadHandlers()[t]instanceof r&&(this._player?._channels.control.getGamepadHandlers()[t])._physicalGamepadId===e.gamepad.index)return;this._physicalGamepadId=e.gamepad.index}}onGamepadDisconnected(e){e.gamepad.index===this._physicalGamepadId&&(this._physicalGamepadId=-1)}hasOverrideButtons(){return Object.keys(this._gamepadOverride.buttons).length>0}getGamepadState(){let e=this.getGamepad(this._physicalGamepadId);if((this._physicalGamepadId<0||e===void 0||this._isFocused===!1)&&this.hasOverrideButtons()===!1)return;let t=this.getDefaultFamepadFrame();if(t.GamepadIndex=this._index,e!==void 0){for(let i in this._options.gamepad_mapping)t[i]=e.buttons[this._options.gamepad_mapping[i]]?.value??0;for(let i in this._options.gamepad_axes_mapping)t[i]=this.normaliseAxis(e.axes[this._options.gamepad_axes_mapping[i]]??0)}for(let i in this._gamepadOverride.buttons)t[i]=this._gamepadOverride.buttons[i];let n=[];for(let i in this._gamepadOverride.buttons)this._gamepadOverride.buttons[i]===0&&n.push(i);for(let i of n)delete this._gamepadOverride.buttons[i];return t.View>0&&t.Menu>0&&(t.View=0,t.Menu=0,t.Nexus=1),t}getGamepad(e=-1){let t=navigator.getGamepads()[e];if(t!==null)return t}getPhysicalGamepadId(){return this._physicalGamepadId}handleVibration(e){if(this._physicalGamepadId<0){console.log("[Gamepad] Received a vibration report but no physical gamepad is connected");return}let t=this.getGamepad(this._physicalGamepadId);if(t===void 0){console.log("[Gamepad] Received a vibration report but no physical gamepad is connected");return}if(this._options.enable_vibration===!1)return;let n={startDelay:0,duration:e.durationMs,weakMagnitude:e.rightMotorPercent*(window.__ddRumble??1),strongMagnitude:e.leftMotorPercent*(window.__ddRumble??1),leftTrigger:e.leftTriggerMotorPercent,rightTrigger:e.rightTriggerMotorPercent};if(t.vibrationActuator?.type==="dual-rumble"){let i=e.rightMotorPercent<.6?(.6-e.rightMotorPercent)/2:0,a=(e.leftTriggerMotorPercent+e.rightTriggerMotorPercent)/4,s=Math.min(i,a);if(n.weakMagnitude=Math.min(1,(e.rightMotorPercent+s)*(window.__ddRumble??1)),n.leftTrigger=0,n.rightTrigger=0,t.vibrationActuator?.playEffect("dual-rumble",n),clearInterval(this._rumbleInterval),e.repeat>0){let o=e.repeat;this._rumbleInterval=setInterval(()=>{o<=0&&clearInterval(this._rumbleInterval),t.vibrationActuator!==void 0&&t.vibrationActuator?.playEffect("dual-rumble",n),o--},e.delayMs+e.durationMs)}}else console.log("[Gamepad] Unknown vibration type:",t.vibrationActuator.type)}normaliseAxis(e){return Math.abs(e)0&&(this._reportType|=1,s=s+this._calculateMetadataSize(e),this._metadataFrames=e),t.length>0&&(this._reportType|=2,s=s+this._calculateGamepadSize(t),this._gamepadFrames=t),n.length>0&&(this._reportType|=4,s=s+this._calculatePointerSize(n),this._pointerFrames=n),i.length>0&&(this._reportType|=32,s=s+this._calculateMouseSize(i),this._mouseFrames=i),a.length>0&&(this._reportType|=64,s=s+this._calculateKeyboardSize(a),this._keyboardFrames=a),this._totalSize=s}_calculateMetadataSize(e){return 1+7*4*e.length}_calculateGamepadSize(e){return 1+23*e.length}_calculatePointerSize(e){let t=1;for(let n in e)t=t+1+e[n].events.length*20;return t}_calculateMouseSize(e){return 1+18*e.length}_calculateKeyboardSize(e){return 1+3*e.length}_writeMetadataData(e,t,n){e.setUint8(t,n.length),t++,n.length>=30&&console.warn("metadataQueue is bigger then 30. This might impact reliability!");for(let i=0;i=30&&console.warn("gamepadQueue is bigger then 30. This might impact reliability!");for(let i=0;i0&&(s|=2),a.Menu>0&&(s|=4),a.View>0&&(s|=8),a.A>0&&(s|=16),a.B>0&&(s|=32),a.X>0&&(s|=64),a.Y>0&&(s|=128),a.DPadUp>0&&(s|=256),a.DPadDown>0&&(s|=512),a.DPadLeft>0&&(s|=1024),a.DPadRight>0&&(s|=2048),a.LeftShoulder>0&&(s|=4096),a.RightShoulder>0&&(s|=8192),a.LeftThumb>0&&(s|=16384),a.RightThumb>0&&(s|=32768),e.setUint16(t,s,!0),e.setInt16(t+2,this._normalizeAxisValue(a.LeftThumbXAxis),!0),e.setInt16(t+4,this._normalizeAxisValue(-a.LeftThumbYAxis),!0),e.setInt16(t+6,this._normalizeAxisValue(a.RightThumbXAxis),!0),e.setInt16(t+8,this._normalizeAxisValue(-a.RightThumbYAxis),!0),e.setUint16(t+10,this._normalizeTriggerValue(a.LeftTrigger),!0),e.setUint16(t+12,this._normalizeTriggerValue(a.RightTrigger),!0),e.setUint32(t+14,1,!0),e.setUint32(t+18,1,!1),t+=22}return t}_writePointerData(e,t,n){e.setUint8(t,1),t++,n.length>=2&&console.warn("pointerQueue is bigger then 1. Only one event will be sent.");let i=n[0];if(i!==void 0){e.setUint8(t,i.events.length),t++;let a=this._serverVideoWidth,s=this._serverVideoHeight;for(let o=0;o=30&&console.warn("mouseQueue is bigger then 30. This might impact reliability!");for(let i=0;i=30&&console.warn("keyboardQueue is bigger then 30. This might impact reliability!");for(let i=0;i0&&(n=this._writeMetadataData(t,n,this._metadataFrames)),this._gamepadFrames.length>0&&(n=this._writeGamepadData(t,n,this._gamepadFrames)),this._pointerFrames.length>0&&(n=this._writePointerData(t,n,this._pointerFrames)),this._mouseFrames.length>0&&(n=this._writeMouseData(t,n,this._mouseFrames)),this._keyboardFrames.length>0&&(n=this._writeKeyboardData(t,n,this._keyboardFrames)),this._reportType===8&&(t.setUint8(n,this._maxTouchpoints),n++),n!==n)throw new Error("Packet length mismatch. Something is wrong!");return t}_normalizeTriggerValue(e){return e<=0||Number.isNaN(e)?0:e>=1?65535:e*65535|0}_normalizeAxisValue(e){let i=Math.round(e*32767);return i>32767?32767:i<-32767?-32767:i}};var F=class{_player;_inputSequenceNum=0;_metadataQueue=[];_gamepadQueue=[];_pointerQueue=[];_mouseQueue=[];_keyboardQueue=[];_gamepadHeartbeatTimers=new Map;_lastGamepadState=new Map;_axisChangeThreshold=.08;constructor(e){this._player=e}_hasQueuedData(){return this._metadataQueue.length>0||this._gamepadQueue.length>0||this._pointerQueue.length>0||this._mouseQueue.length>0||this._keyboardQueue.length>0}_upsertGamepadFrame(e){for(let t=0;tthis._axisChangeThreshold||o>.15)return!0}for(let a of i)if(Math.abs(e[a]-t[a])>.01)return!0;return!1}_isHeartbeatDue(e){let t=this._gamepadHeartbeatTimers.get(e)??0;return performance.now()-t>33}queueMetadataFrame(e){this._metadataQueue.push(e),this.checkQueueAndSend()}queueGamepadFrame(e){let t=this._lastGamepadState.get(e.GamepadIndex),n=this._hasSignificantAxisChange(e,t),i=this._isHeartbeatDue(e.GamepadIndex);(n||i)&&(this._upsertGamepadFrame(e),this._lastGamepadState.set(e.GamepadIndex,e),this._gamepadHeartbeatTimers.set(e.GamepadIndex,performance.now()),this.checkQueueAndSend())}queueGamepadFrames(e,t=!1){for(let n of e)this._upsertGamepadFrame(n);this.checkQueueAndSend(t)}queueMouseFrame(e){this._mouseQueue.push(e),this.checkQueueAndSend()}queueKeyboardFrame(e){this._keyboardQueue.push(e),this.checkQueueAndSend()}queuePointerFrame(e){this._pointerQueue.length>0?this._pointerQueue[0].events=this._pointerQueue[0].events.concat(e.events):this._pointerQueue.push(e),this.checkQueueAndSend()}checkQueueAndSend(e=!1){if(e===!0){this._hasQueuedData()&&this.sendQueue();return}if(this._gamepadQueue.length>0){this.sendQueue();return}if(this._mouseQueue.length>0||this._keyboardQueue.length>0||this._pointerQueue.length>0){this.sendQueue();return}this._metadataQueue.length>5&&this.sendQueue()}sendQueue(){let e=new y(this.getSequenceNum());this._pointerQueue.length>0&&e.setStreamConfig({width:this._player._channels.input.getServerVideoWidth(),height:this._player._channels.input.getServerVideoHeight()}),e.setData(this._metadataQueue,this._gamepadQueue,this._pointerQueue,this._mouseQueue,this._keyboardQueue),this._player._channels.input.send(e.toBuffer()),this.clearQueues()}clearQueues(){this._metadataQueue=[],this._gamepadQueue=[],this._pointerQueue=[],this._mouseQueue=[],this._keyboardQueue=[]}getSequenceNum(){return this._inputSequenceNum++,this._inputSequenceNum}};var A=class extends _{_serverVideoWidth=0;_serverVideoHeight=0;_inputQueue=new F(this.getPlayer());getChannelName(){return"input"}getChannelConfig(){return{ordered:!0,protocol:"1.0"}}getServerVideoWidth(){return this._serverVideoWidth}getServerVideoHeight(){return this._serverVideoHeight}start(){let e=new y(0);e.setMetadata(navigator.maxTouchPoints>1?navigator.maxTouchPoints:1),this.send(e.toBuffer()),console.log("[InputChannel] Sent metadata:",e,e.toBuffer()),setTimeout(this.gamepadStateLoop.bind(this),16)}onMessage(e){let t=new DataView(e.data),n=t.getUint8(0);if(console.log("[InputChannel] received:",t),n===128){console.log("Received a vibration report"),t.getUint8(2);let i=t.getUint8(3),a=this.getPlayer()._channels.control.getGamepadHandler(i);if(a!==void 0){let s=t.getUint8(4)/100,o=t.getUint8(5)/100,d=t.getUint8(6)/100,u=t.getUint8(7)/100,h=t.getUint16(8,!0),g=t.getUint16(10,!0),p=t.getUint8(12),c={gamepadIndex:i,leftMotorPercent:s,rightMotorPercent:o,leftTriggerMotorPercent:d,rightTriggerMotorPercent:u,durationMs:h,delayMs:g,repeat:p};a.handleVibration(c)}else console.log("[InputChannel] Received a vibration report but no gamepad handler is available")}else n===16&&(console.log("[InputChannel] Received server video dimensions:",t.getUint32(2,!0),t.getUint32(6,!0)),this._serverVideoHeight=t.getUint32(2,!0),this._serverVideoWidth=t.getUint32(6,!0))}gamepadStateLoop(){let e=this.getPlayer()._channels.control.getGamepadHandlers(),t=[];for(let n in e)if(e[n]instanceof v){let i=e[n].getGamepadState();i!==void 0&&t.push(i)}t.length>0&&this.queueGamepadFrames(t),setTimeout(this.gamepadStateLoop.bind(this),16)}queueMetadataFrame(e){return this._inputQueue.queueMetadataFrame(e)}queueGamepadFrames(e,t=!1){this._inputQueue.queueGamepadFrames(e,t)}queueMouseFrame(e){this._inputQueue.queueMouseFrame(e)}queueKeyboardFrame(e){this._inputQueue.queueKeyboardFrame(e)}queuePointerFrame(e){this._inputQueue.queuePointerFrame(e)}destroy(){}};function Q(){return crypto.randomUUID()}var D=class{_channel;_data;constructor(e,t){this._channel=e,this._data=t,this.handle(this._data)}handle(e){switch(console.log("[MessageHandler] Handling:",e),e.target){case"/streaming/sessionLifetimeManagement/serverInitiatedDisconnect":this.completeTransaction(e.id,""),this._channel.getPlayer().destroy();break;case"/streaming/properties/titleinfo":console.log("Title update:",e);break;case"/streaming/touchcontrols/showlayoutv2":break;case"/streaming/systemUi/messages/ShowMessageDialog":this.handleMessage(e);break;default:console.log("[MessageChannel] Unhandled transaction:",e)}}completeTransaction(e,t){let n=JSON.stringify({type:"TransactionComplete",content:JSON.stringify(t),id:e,cv:""});this._channel.send(n)}cancelTransaction(e,t){let n=JSON.stringify({type:"ReceiverCancel",content:JSON.stringify(t),id:e,cv:""});this._channel.send(n)}handleMessage(e){let t=JSON.parse(e.content);console.log(t),confirm(t.TitleText+` + +`+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{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}: ${t}`: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._player)}create(e){let t=document.createElement("video");t.srcObject=e,t.autoplay=!0,t.muted=!0,t.playsInline=!0,t.style.width="100%",t.style.height="100%",t.style.objectFit="contain",t.style.backgroundColor="black",t.style.touchAction="none",t.poster="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";let n=document.getElementById(this._player.getElementId());if(n===null)return;this._element=t,n.appendChild(this._element),getComputedStyle(n).position==="static"===!0&&(n.style.position="relative"),this._element.requestVideoFrameCallback(this.processVideoMetadata.bind(this))}processVideoMetadata(e,t){this._element!==void 0&&(this._element.requestVideoFrameCallback(this.processVideoMetadata.bind(this)),this._player._channels.input.queueMetadataFrame({serverDataKey:t.rtpTimestamp,firstFramePacketArrivalTimeMs:t.receiveTime,frameSubmittedTimeMs:t.receiveTime,frameDecodedTimeMs:t.expectedDisplayTime,frameRenderedTimeMs:t.expectedDisplayTime}))}getElement(){return this._element}toggleDebugOverlay(){this._overlay.toggleDebug()}destroy(){let t=document.getElementById(this._player.getElementId())?.querySelector("video");this._overlay!==void 0&&this._overlay.destroy(),t&&t.remove()}};var N=class{_player;_element;constructor(e){this._player=e}create(e){let t=document.createElement("audio");t.srcObject=e,t.autoplay=!0,t.muted=!1,this._element=t;let n=document.getElementById(this._player.getElementId());n!==null&&n.appendChild(this._element)}getElement(){return this._element}destroy(){let t=document.getElementById(this._player.getElementId())?.querySelector("audio");t&&t.remove()}};var U=class{_peerConnection=new RTCPeerConnection({});_channels={chat:new S(this),control:new w(this),input:new A(this),message:new M(this)};_elementId;_isDestoyed=!1;_iceHelper=new R(this);_sdpHelper=new I(this);_statsHelper=new P(this);_videoComponent;_audioComponent;constructor(e){console.log("xCloudPlayer constructing..."),this._elementId=e,this._peerConnection.addTransceiver("audio",{direction:"sendrecv"}),this._peerConnection.addTransceiver("video",{direction:"recvonly"}).setCodecPreferences(this._sdpHelper.getDefaultCodecPreferences()),this._peerConnection.ontrack=n=>{n.track.kind==="video"?(this._videoComponent=new L(this),this._videoComponent.create(n.streams[0])):n.track.kind==="audio"?(this._audioComponent=new N(this),this._audioComponent.create(n.streams[0])):console.log("[xPlayer] Detected an unknown stream type: ",n.track.kind)},console.log("xCloudPlayer constructed.")}init(){console.log("xCloudPlayer init called.")}getElementId(){return this._elementId}async createOffer(){let e=await this._peerConnection.createOffer({offerToReceiveAudio:!0,offerToReceiveVideo:!0}),t=this._sdpHelper.setLocalSDP(e);return this._peerConnection.setLocalDescription(t),t}async setRemoteSDP(e){let t=this._sdpHelper.setRemoteSDP(e);return await this._peerConnection.setRemoteDescription({type:"answer",sdp:t})}async getICECandidates(){return this._iceHelper.getCandidates()}async setRemoteIceCandidates(e){this._iceHelper.setRemoteCandidates(e)}getVideoElement(){if(this._videoComponent)return this._videoComponent.getElement()}getAudioElement(){if(this._audioComponent)return this._audioComponent.getElement()}toggleDebugOverlay(){this._videoComponent&&this._videoComponent.toggleDebugOverlay()}getStats(){return this._statsHelper}destroy(){if(this._isDestoyed===!1){this._peerConnection.close(),this._peerConnection.onconnectionstatechange&&this._peerConnection.onconnectionstatechange(new Event("connectionstatechanged"));for(let e in this._channels)this._channels[e].destroy();this._videoComponent&&this._videoComponent.destroy(),this._audioComponent&&this._audioComponent.destroy(),this._isDestoyed=!0}else console.log("Cannot destroy because the player is already destroyed.")}};function X(r){return r.brightness!==0||r.contrast!==0||Math.abs(r.gamma-1)>.001||r.saturation!==100||r.sharpen>0||r.deband>0||r.fxaa||r.toon||r.crt||r.ntsc||r.maxFps>0}var J=`#version 300 es +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",pointerEvents:"none",display:"none",whiteSpace:"pre",textAlign:"right"}),document.body.appendChild(this.element)}element;timer;level=0;vertical=!1;left=!1;server="";last={bytes:0,time:0,decode:0,frames:0,lost:0,received:0};show(e,t,n,i=!1){this.level=e,this.vertical=t,this.server=n,this.left=i,Object.assign(this.element.style,i?{left:"8px",right:"",textAlign:"left"}:{left:"",right:"8px",textAlign:"right"}),this.element.style.display=e>0?"block":"none",e>0&&this.timer===void 0&&(this.timer=setInterval(()=>this.update(),1e3),this.update()),e===0&&this.timer!==void 0&&(clearInterval(this.timer),this.timer=void 0)}async update(){let e=await this.pc.getStats(),t,n,i=new Map;if(e.forEach(c=>{c.type==="inbound-rtp"&&c.kind==="video"?t=c:c.type==="candidate-pair"&&c.state==="succeeded"&&c.nominated?n=c:c.type==="codec"&&i.set(c.id,c)}),t===void 0)return;let a=this.last.time>0?(t.timestamp-this.last.time)/1e3:0,s=a>0?(t.bytesReceived-this.last.bytes)*8/a/1e6:0,o=(t.framesDecoded??0)-this.last.frames,d=o>0?((t.totalDecodeTime??0)-this.last.decode)*1e3/o:0,u=(t.packetsLost??0)-this.last.lost,h=(t.packetsReceived??0)-this.last.received,g=u+h>0?u*100/(u+h):0;this.last={bytes:t.bytesReceived,time:t.timestamp,decode:t.totalDecodeTime??0,frames:t.framesDecoded??0,lost:t.packetsLost??0,received:t.packetsReceived??0};let p=["web",`${Math.round(t.framesPerSecond??0)} fps`];if(this.level>=2&&(n?.currentRoundTripTime!==void 0&&p.push(`ping ${Math.round(n.currentRoundTripTime*1e3)} ms`),p.push(`${t.frameWidth??"?"}x${t.frameHeight??"?"}`)),this.level>=3&&p.push(`${s.toFixed(1)} Mb/s`,`decode ${d.toFixed(1)} ms`,`loss ${g.toFixed(1)}%`),this.level>=4){let c=i.get(t.codecId);c&&p.push(String(c.mimeType??"").replace("video/","")),p.push(`jitter ${Math.round((t.jitter??0)*1e3)} ms`,`dropped ${t.framesDropped??0}`),this.server&&p.push(this.server)}this.element.textContent=p.join(this.vertical?` +`:" \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))});})(); diff --git a/app/src/main/cpp/waylandcomp/CMakeLists.txt b/app/src/main/cpp/waylandcomp/CMakeLists.txt index 33585818b..c2bfc8643 100644 --- a/app/src/main/cpp/waylandcomp/CMakeLists.txt +++ b/app/src/main/cpp/waylandcomp/CMakeLists.txt @@ -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 ) diff --git a/app/src/main/cpp/waylandcomp/src/compositor.c b/app/src/main/cpp/waylandcomp/src/compositor.c index 95a8bf83a..0718623cf 100644 --- a/app/src/main/cpp/waylandcomp/src/compositor.c +++ b/app/src/main/cpp/waylandcomp/src/compositor.c @@ -16,6 +16,7 @@ #include #include #include +#include #include #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; } } diff --git a/app/src/main/cpp/waylandcomp/src/framegen_bridge.c b/app/src/main/cpp/waylandcomp/src/framegen_bridge.c index 94bb581a0..9de2824d7 100644 --- a/app/src/main/cpp/waylandcomp/src/framegen_bridge.c +++ b/app/src/main/cpp/waylandcomp/src/framegen_bridge.c @@ -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. */ diff --git a/app/src/main/cpp/waylandcomp/src/gpu_clock_pin.c b/app/src/main/cpp/waylandcomp/src/gpu_clock_pin.c new file mode 100644 index 000000000..0f8e1f61b --- /dev/null +++ b/app/src/main/cpp/waylandcomp/src/gpu_clock_pin.c @@ -0,0 +1,170 @@ +#include "gpu_clock_pin.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#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; +} diff --git a/app/src/main/cpp/waylandcomp/src/gpu_clock_pin.h b/app/src/main/cpp/waylandcomp/src/gpu_clock_pin.h new file mode 100644 index 000000000..5fe17d102 --- /dev/null +++ b/app/src/main/cpp/waylandcomp/src/gpu_clock_pin.h @@ -0,0 +1,5 @@ +#pragma once + +#include + +bool gpu_clock_pin_set(bool on); diff --git a/app/src/main/cpp/waylandcomp/src/vk_loader.h b/app/src/main/cpp/waylandcomp/src/vk_loader.h index e10950d0d..54cb88881 100644 --- a/app/src/main/cpp/waylandcomp/src/vk_loader.h +++ b/app/src/main/cpp/waylandcomp/src/vk_loader.h @@ -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) \ diff --git a/app/src/main/cpp/waylandcomp/src/vk_present.c b/app/src/main/cpp/waylandcomp/src/vk_present.c index 96d0b415d..c917afbba 100644 --- a/app/src/main/cpp/waylandcomp/src/vk_present.c +++ b/app/src/main/cpp/waylandcomp/src/vk_present.c @@ -19,6 +19,7 @@ #include #include #include +#include #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; diff --git a/app/src/main/cpp/waylandcomp/src/vk_present.h b/app/src/main/cpp/waylandcomp/src/vk_present.h index 48d32ab4d..7271f468b 100644 --- a/app/src/main/cpp/waylandcomp/src/vk_present.h +++ b/app/src/main/cpp/waylandcomp/src/vk_present.h @@ -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); diff --git a/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c b/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c index 6a407088b..e6bb03027 100644 --- a/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c +++ b/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c @@ -9,6 +9,7 @@ #include #include #include +#include #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(); +} diff --git a/app/src/main/java/com/droiddeck/launcher/App.kt b/app/src/main/java/com/droiddeck/launcher/App.kt index 3fc438377..324941bd4 100644 --- a/app/src/main/java/com/droiddeck/launcher/App.kt +++ b/app/src/main/java/com/droiddeck/launcher/App.kt @@ -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. */ diff --git a/app/src/main/java/com/droiddeck/launcher/DriverMenus.kt b/app/src/main/java/com/droiddeck/launcher/DriverMenus.kt index 7ccbba7f5..344aa7452 100644 --- a/app/src/main/java/com/droiddeck/launcher/DriverMenus.kt +++ b/app/src/main/java/com/droiddeck/launcher/DriverMenus.kt @@ -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) diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt similarity index 92% rename from app/src/main/java/com/droiddeck/launcher/MainActivity.kt rename to app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt index abc1eee8f..18244ae66 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt @@ -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(null) private var flood by mutableStateOf(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(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(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(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(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(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(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 = 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, 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), diff --git a/app/src/main/java/com/droiddeck/launcher/HomeActivity.kt b/app/src/main/java/com/droiddeck/launcher/HomeActivity.kt index 54526834b..71304e883 100644 --- a/app/src/main/java/com/droiddeck/launcher/HomeActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/HomeActivity.kt @@ -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) diff --git a/app/src/main/java/com/droiddeck/launcher/LauncherIcon.kt b/app/src/main/java/com/droiddeck/launcher/LauncherIcon.kt index 9183df76e..89c33523a 100644 --- a/app/src/main/java/com/droiddeck/launcher/LauncherIcon.kt +++ b/app/src/main/java/com/droiddeck/launcher/LauncherIcon.kt @@ -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> = 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 diff --git a/app/src/main/java/com/droiddeck/launcher/ProtonMenu.kt b/app/src/main/java/com/droiddeck/launcher/ProtonMenu.kt index 5bd651cf5..1d870a6bc 100644 --- a/app/src/main/java/com/droiddeck/launcher/ProtonMenu.kt +++ b/app/src/main/java/com/droiddeck/launcher/ProtonMenu.kt @@ -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>(emptyList()) var protonBusyId by mutableStateOf(null) var protonStage by mutableStateOf(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) + } } diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 6de07a00d..5d8ad8e39 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -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? = null + private var screenKeyDir: Int? = null + private var screenKeyTextFocus = false + private var screenKeyEdit by mutableStateOf(null) + private var screenLayoutsOpen by mutableStateOf(false) + private var screenKeysShown by mutableStateOf(true) + private var screenLayoutNames by mutableStateOf(listOf()) + private var screenKeys: ScreenKeys? = null private var recognizedGesture by mutableStateOf(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() private lateinit var scrollTouch: ScrollTouchGestures @@ -270,6 +283,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } } private val resumeKeysDown = mutableSetOf>() + private val drawerShortcutKeysDown = mutableSetOf>() private var drawerOpen by mutableStateOf(false) private var quitFrom by mutableStateOf(null) @@ -280,6 +294,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private var drawerDefaultProton by mutableStateOf(null) private val drawerComponentMenu by lazy { ComponentsMenu(this, uiHandler) } private var drawerGameProfileId by mutableStateOf(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() 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(null) + private var pausedFrameWanted = false private var trackingRecovery by mutableStateOf(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 = + 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" diff --git a/app/src/main/java/com/droiddeck/launcher/UpdatesMenu.kt b/app/src/main/java/com/droiddeck/launcher/UpdatesMenu.kt index 8459bc7d9..5eb9ff450 100644 --- a/app/src/main/java/com/droiddeck/launcher/UpdatesMenu.kt +++ b/app/src/main/java/com/droiddeck/launcher/UpdatesMenu.kt @@ -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 -> { diff --git a/app/src/main/java/com/droiddeck/launcher/agent/AgentEnv.kt b/app/src/main/java/com/droiddeck/launcher/agent/AgentEnv.kt index 5f7f09262..0842f6b5a 100644 --- a/app/src/main/java/com/droiddeck/launcher/agent/AgentEnv.kt +++ b/app/src/main/java/com/droiddeck/launcher/agent/AgentEnv.kt @@ -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(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 = 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 } diff --git a/app/src/main/java/com/droiddeck/launcher/audio/DirectAudioRelayComponent.java b/app/src/main/java/com/droiddeck/launcher/audio/DirectAudioRelayComponent.java index 15ffd7bdb..f8f1b2a3e 100644 --- a/app/src/main/java/com/droiddeck/launcher/audio/DirectAudioRelayComponent.java +++ b/app/src/main/java/com/droiddeck/launcher/audio/DirectAudioRelayComponent.java @@ -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 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 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 diff --git a/app/src/main/java/com/droiddeck/launcher/audio/PulseAudioComponent.java b/app/src/main/java/com/droiddeck/launcher/audio/PulseAudioComponent.java index 849455b7a..3b73b6a3c 100644 --- a/app/src/main/java/com/droiddeck/launcher/audio/PulseAudioComponent.java +++ b/app/src/main/java/com/droiddeck/launcher/audio/PulseAudioComponent.java @@ -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(); diff --git a/app/src/main/java/com/droiddeck/launcher/core/BackgroundAppLimit.kt b/app/src/main/java/com/droiddeck/launcher/core/BackgroundAppLimit.kt new file mode 100644 index 000000000..02447f360 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/core/BackgroundAppLimit.kt @@ -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}") } + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/core/CpuAllocationDefaults.kt b/app/src/main/java/com/droiddeck/launcher/core/CpuAllocationDefaults.kt deleted file mode 100644 index adaa106a0..000000000 --- a/app/src/main/java/com/droiddeck/launcher/core/CpuAllocationDefaults.kt +++ /dev/null @@ -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): 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()), - ) - } -} diff --git a/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt b/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt index 903851a5a..9d6226166 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt @@ -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)) diff --git a/app/src/main/java/com/droiddeck/launcher/core/DownloadSource.kt b/app/src/main/java/com/droiddeck/launcher/core/DownloadSource.kt index 870e8ed42..b11e3113a 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/DownloadSource.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/DownloadSource.kt @@ -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 "" + } } diff --git a/app/src/main/java/com/droiddeck/launcher/core/Downloader.java b/app/src/main/java/com/droiddeck/launcher/core/Downloader.java index 183076188..98e41ab6f 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/Downloader.java +++ b/app/src/main/java/com/droiddeck/launcher/core/Downloader.java @@ -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); diff --git a/app/src/main/java/com/droiddeck/launcher/core/GameEnvironmentOptions.kt b/app/src/main/java/com/droiddeck/launcher/core/GameEnvironmentOptions.kt index 3ad45c1fa..bcf3a0448 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/GameEnvironmentOptions.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/GameEnvironmentOptions.kt @@ -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), diff --git a/app/src/main/java/com/droiddeck/launcher/core/HostProcess.kt b/app/src/main/java/com/droiddeck/launcher/core/HostProcess.kt index 4a8b12934..075719246 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/HostProcess.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/HostProcess.kt @@ -8,7 +8,11 @@ object HostProcess { private const val TAG = "HostProcess" @JvmStatic - fun start(command: String, env: Array?, dir: File?, onExit: Consumer?, onLine: Consumer?): Int { + fun start(command: String, env: Array?, dir: File?, onExit: Consumer?, onLine: Consumer?): Int = + start(command, env, dir, onExit, onLine, null) + + @JvmStatic + fun start(command: String, env: Array?, dir: File?, onExit: Consumer?, onLine: Consumer?, 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)") diff --git a/app/src/main/java/com/droiddeck/launcher/core/LogRedactor.kt b/app/src/main/java/com/droiddeck/launcher/core/LogRedactor.kt index 491101dba..20073c4ca 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/LogRedactor.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/LogRedactor.kt @@ -76,7 +76,7 @@ object LogRedactor { private const val ACCOUNT_VALUE = "(\"[^\"<\\r\\n]+\"|'[^'<\\r\\n]+'|[^\\s\"'<>&;,}]+)" private val ACCOUNT_KV = Regex( - "(?i)(? 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) } } } diff --git a/app/src/main/java/com/droiddeck/launcher/core/WirelessAdbPairingService.kt b/app/src/main/java/com/droiddeck/launcher/core/WirelessAdbPairingService.kt index f8806c7c0..c924f8532 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/WirelessAdbPairingService.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/WirelessAdbPairingService.kt @@ -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) diff --git a/app/src/main/java/com/droiddeck/launcher/core/ZramSupport.kt b/app/src/main/java/com/droiddeck/launcher/core/ZramSupport.kt index 51c3b2494..3befaac38 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/ZramSupport.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/ZramSupport.kt @@ -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() diff --git a/app/src/main/java/com/droiddeck/launcher/files/FileManagerActivity.kt b/app/src/main/java/com/droiddeck/launcher/files/FileManagerActivity.kt index 27cb9dbce..1425a78e7 100644 --- a/app/src/main/java/com/droiddeck/launcher/files/FileManagerActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/files/FileManagerActivity.kt @@ -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() }) } } } diff --git a/app/src/main/java/com/droiddeck/launcher/files/FileManagerScreen.kt b/app/src/main/java/com/droiddeck/launcher/files/FileManagerScreen.kt index 0843057f7..8d60241c8 100644 --- a/app/src/main/java/com/droiddeck/launcher/files/FileManagerScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/files/FileManagerScreen.kt @@ -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()) } var selectedEntry by remember { mutableStateOf(null) } var showMenuFor by remember { mutableStateOf(null) } @@ -151,8 +166,10 @@ fun FileManagerScreen( var pendingConflict by remember { mutableStateOf(null) } var conflictChoice by remember { mutableStateOf(null) } var conflictApplyToAll by remember { mutableStateOf(false) } - var operationJob by remember { mutableStateOf(null) } - var cancelledJob by remember { mutableStateOf(null) } + var operationJob by remember { mutableStateOf(null) } + var cancelledJob by remember { mutableStateOf(null) } + var pendingNavigation by remember { mutableStateOf(null) } + var cancellingNavigation by remember { mutableStateOf(false) } var pendingBulkDelete by remember { mutableStateOf>(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(null) } + var listingJob by remember { mutableStateOf(null) } + val history = remember { mutableStateListOf>() } + 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 { diff --git a/app/src/main/java/com/droiddeck/launcher/files/FileOps.kt b/app/src/main/java/com/droiddeck/launcher/files/FileOps.kt index 29df5ff6d..05d467ef5 100644 --- a/app/src/main/java/com/droiddeck/launcher/files/FileOps.kt +++ b/app/src/main/java/com/droiddeck/launcher/files/FileOps.kt @@ -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) diff --git a/app/src/main/java/com/droiddeck/launcher/files/FilePickerActivity.kt b/app/src/main/java/com/droiddeck/launcher/files/FilePickerActivity.kt index 6fd89fa99..498ec504e 100644 --- a/app/src/main/java/com/droiddeck/launcher/files/FilePickerActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/files/FilePickerActivity.kt @@ -50,6 +50,7 @@ class FilePickerActivity : ComponentActivity() { setResult(Activity.RESULT_OK, Intent().putExtra(EXTRA_SELECTED_FILE, file.absolutePath)) finish() }, + onExit = { finish() }, ) } } diff --git a/app/src/main/java/com/droiddeck/launcher/files/StorageRoots.kt b/app/src/main/java/com/droiddeck/launcher/files/StorageRoots.kt index 99e940157..273917b00 100644 --- a/app/src/main/java/com/droiddeck/launcher/files/StorageRoots.kt +++ b/app/src/main/java/com/droiddeck/launcher/files/StorageRoots.kt @@ -29,6 +29,50 @@ object StorageRoots { val seeds = LinkedHashSet() } + 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 { + val out = ArrayList() + 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 = + parseMountPoints(mounts).filter(::isExternalMount).map { it.target } + + fun externalMounts(): List = + runCatching { parseExternalMounts(File("/proc/mounts").readText()) }.getOrDefault(emptyList()) + fun list(context: Context): List { val volumes = LinkedHashMap() @@ -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 diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/AddedGameArt.kt b/app/src/main/java/com/droiddeck/launcher/frontend/AddedGameArt.kt index 62d04d247..28eb1bf72 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/AddedGameArt.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/AddedGameArt.kt @@ -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 { val own = local(game) val cache = cacheDir(context, game) + val store = storeArt(context, game) fun cached(name: String): Pair? = 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? = 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): Boolean { + internal class Sources( + val storeLogo: (AddedGames.Game) -> Boolean, + val steamSearch: (String) -> Int?, + val steam: (String, File, Set) -> Boolean, + val sgdbGame: (String, Int?, String) -> Int?, + val sgdb: (String, Int, File, Set) -> 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 { + 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): Boolean = + fetchMissing(context, games, SteamGridDb.key(context), network) + + internal fun fetchMissing(context: Context, games: List, 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.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 diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt b/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt index 5de835f6e..aa1c6eef4 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt @@ -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 = 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, 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() val added = ArrayList() - 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({ 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") } } diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/AutoComponents.kt b/app/src/main/java/com/droiddeck/launcher/frontend/AutoComponents.kt index fc3274427..650c717d8 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/AutoComponents.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/AutoComponents.kt @@ -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) }, diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/GameRemoval.kt b/app/src/main/java/com/droiddeck/launcher/frontend/GameRemoval.kt index ab997f027..388448e18 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/GameRemoval.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/GameRemoval.kt @@ -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 } diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/GameShortcuts.kt b/app/src/main/java/com/droiddeck/launcher/frontend/GameShortcuts.kt index 80cdd93c1..45b245269 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/GameShortcuts.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/GameShortcuts.kt @@ -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 { diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt b/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt index 1242d9add..9fb776447 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt @@ -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 { + fun steamGames( + context: Context, strictRead: Boolean = false, arch: String = SessionPrefs.activeSteamClientArch(context), + withShared: Boolean = true, + ): List { 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 { + 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> { 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 = 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>()?.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): Set = 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 = steamLibraries(context).map { it.first }): SteamGame = + fun addedGame( + context: Context, g: AddedGames.Game, roots: List = 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): 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): 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): 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, val exts: Set, val atPanel: Boolean = false) diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/LibraryCache.kt b/app/src/main/java/com/droiddeck/launcher/frontend/LibraryCache.kt index 3d03b2307..cd30e400c 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/LibraryCache.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/LibraryCache.kt @@ -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) diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/SteamGameArt.kt b/app/src/main/java/com/droiddeck/launcher/frontend/SteamGameArt.kt index cb4f49f0f..25a3cb8fb 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/SteamGameArt.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/SteamGameArt.kt @@ -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, ) } diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/SteamGrid.kt b/app/src/main/java/com/droiddeck/launcher/frontend/SteamGrid.kt index 47b679031..88526f1bf 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/SteamGrid.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/SteamGrid.kt @@ -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//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 = 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) diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/SteamGridDb.kt b/app/src/main/java/com/droiddeck/launcher/frontend/SteamGridDb.kt index dec6c24cf..2eaa89410 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/SteamGridDb.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/SteamGridDb.kt @@ -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.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") diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/SteamRedists.kt b/app/src/main/java/com/droiddeck/launcher/frontend/SteamRedists.kt index f41b8265f..e022ebc8e 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/SteamRedists.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/SteamRedists.kt @@ -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> = + 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 { val text = try { manifest(gameDir, appId)?.takeIf { it.length() < 1L shl 20 }?.readText() ?: return emptyList() diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/WinCompSources.kt b/app/src/main/java/com/droiddeck/launcher/frontend/WinCompSources.kt index 7562cc1ee..8e91102f3 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/WinCompSources.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/WinCompSources.kt @@ -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 = emptyList()) @@ -49,6 +50,17 @@ object WinCompSources { fun installer(path: String): List = if (UE_PREREQ.containsMatchIn(path)) listOf("vcredist2022", "d3dx9") else DependencyDetector.componentsFor(path) + private val XBOX_KNOWN = Regex("""> { + 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): List> = 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 { 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('/') diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/RokidModel.kt b/app/src/main/java/com/droiddeck/launcher/glasses/RokidModel.kt index a1ad6e83a..35227206b 100644 --- a/app/src/main/java/com/droiddeck/launcher/glasses/RokidModel.kt +++ b/app/src/main/java/com/droiddeck/launcher/glasses/RokidModel.kt @@ -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) diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/FrameGen.kt b/app/src/main/java/com/droiddeck/launcher/gpu/FrameGen.kt index 4282ecf6f..3d493c606 100644 --- a/app/src/main/java/com/droiddeck/launcher/gpu/FrameGen.kt +++ b/app/src/main/java/com/droiddeck/launcher/gpu/FrameGen.kt @@ -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) diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/TpUbwcHint.kt b/app/src/main/java/com/droiddeck/launcher/gpu/TpUbwcHint.kt index 1ccee78e2..893d569a6 100644 --- a/app/src/main/java/com/droiddeck/launcher/gpu/TpUbwcHint.kt +++ b/app/src/main/java/com/droiddeck/launcher/gpu/TpUbwcHint.kt @@ -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}") } } diff --git a/app/src/main/java/com/droiddeck/launcher/input/ControllerPrefs.kt b/app/src/main/java/com/droiddeck/launcher/input/ControllerPrefs.kt index 9b08fd8ad..c784bc3bf 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/ControllerPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/ControllerPrefs.kt @@ -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 = decodeScreenKeys(prefs(context).getString("screenKeys", null).orEmpty()) + + fun setScreenKeys(context: Context, keys: List) { + prefs(context).edit().putString("screenKeys", encodeScreenKeys(keys)).apply() + } + + fun encodeScreenKeys(keys: List): 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 = 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 = + 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() } diff --git a/app/src/main/java/com/droiddeck/launcher/input/DirectTouchHold.kt b/app/src/main/java/com/droiddeck/launcher/input/DirectTouchHold.kt index 318941c71..8bf852fdb 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/DirectTouchHold.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/DirectTouchHold.kt @@ -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) diff --git a/app/src/main/java/com/droiddeck/launcher/input/GestureTouchStreamGate.kt b/app/src/main/java/com/droiddeck/launcher/input/GestureTouchStreamGate.kt new file mode 100644 index 000000000..e4ad4f655 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/input/GestureTouchStreamGate.kt @@ -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 } +} diff --git a/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java b/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java index cfb0978cb..0811cab05 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java +++ b/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java @@ -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(); diff --git a/app/src/main/java/com/droiddeck/launcher/input/PcKeys.kt b/app/src/main/java/com/droiddeck/launcher/input/PcKeys.kt new file mode 100644 index 000000000..f3ab1d4c6 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/input/PcKeys.kt @@ -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> = 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 = setOf(42, 54, 29, 97, 56, 100, 125) + +val PC_KEYS: Map = 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 +} diff --git a/app/src/main/java/com/droiddeck/launcher/input/ScreenKey.kt b/app/src/main/java/com/droiddeck/launcher/input/ScreenKey.kt new file mode 100644 index 000000000..2967a06f6 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/input/ScreenKey.kt @@ -0,0 +1,63 @@ +package com.droiddeck.launcher.input + +import android.content.Context + +fun screenKeyLabel(context: Context, codes: List): 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 = 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, + 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) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/input/ScreenKeyIcons.kt b/app/src/main/java/com/droiddeck/launcher/input/ScreenKeyIcons.kt new file mode 100644 index 000000000..b1ea677b9 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/input/ScreenKeyIcons.kt @@ -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 + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/input/ScreenKeys.kt b/app/src/main/java/com/droiddeck/launcher/input/ScreenKeys.kt new file mode 100644 index 000000000..4cc87ef0b --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/input/ScreenKeys.kt @@ -0,0 +1,1122 @@ +package com.droiddeck.launcher.input + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.PointF +import android.graphics.Rect +import android.graphics.RectF +import android.graphics.Typeface +import android.os.Build +import android.view.MotionEvent +import android.view.View +import android.view.WindowInsets +import java.io.File +import kotlin.math.abs +import kotlin.math.hypot +import kotlin.math.round + +class ScreenKeys(context: Context, private val sendKey: (Int, Boolean) -> Unit) : View(context) { + private class Entry(val codes: List) { + var glyph = "" + var text = "" + var image = "" + var bitmap: Bitmap? = null + var bitmapFor = "" + var x = 0.5f + var y = 0.5f + var size = DEFAULT_SIZE + var opacity = 1f + var shape = SCREEN_KEY_PILL + var toggle = false + var labels = true + var armed = -1 + var cx = 0f + var cy = 0f + var halfW = 0f + var halfH = 0f + } + + private val entries = mutableListOf() + private val down = HashMap() + private val latched = mutableListOf() + private val dpadDown = HashMap() + private val stickEntries = HashMap() + private val stickCodes = HashMap>() + private val held = HashMap() + private var forwardedPointerBits = 0 + private var addX = -1f + private var addY = -1f + private var pressX = 0f + private var pressY = 0f + private var guideX = -1f + private var guideY = -1f + private val addPress = Runnable { addX = pressX; addY = pressY; onAdd?.invoke() } + private var editing = false + private var selected: Entry? = null + private var dragX = 0f + private var dragY = 0f + private var dragging = false + private var moved = false + private var toolbarDrag = false + private var safe = Rect() + private var tint = ControllerPrefs.STEAM_BLUE + + init { + ControllerPrefs.screenKeys(context).forEach { key -> entries += entryOf(key) } + } + + var followSteam = false + set(value) { + if (field == value) return + field = value + invalidate() + } + + var steamStyle: () -> Pair? = { null } + + var onAdd: (() -> Unit)? = null + var onSelect: ((List) -> Unit)? = null + var onEditingChanged: ((Boolean) -> Unit)? = null + var onTouchMiss: ((MotionEvent) -> Unit)? = null + + private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG) + private val strokePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE } + private val ringPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE } + private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + textAlign = Paint.Align.CENTER + typeface = Typeface.DEFAULT_BOLD + } + private val gridPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE } + private val rect = RectF() + private val bodyPathRect = RectF() + private val bodyPath = android.graphics.Path() + private val clipPath = android.graphics.Path() + private val imageRect = RectF() + private val bitmapRect = RectF() + + private val capsuleRadius = dp(24f) + private val capsuleGap = dp(62f) + private val toolbar = PointF() + private val addCenter = PointF() + private val doneCenter = PointF() + + fun startEditing() { + if (editing) return + editing = true + releaseAll() + selected = null + placeToolbar() + invalidate() + onEditingChanged?.invoke(true) + } + + fun stopEditing() { + if (!editing) return + releaseAll() + editing = false + selected = null + save() + invalidate() + onEditingChanged?.invoke(false) + } + + fun add(codes: List) { + if (codes.isEmpty()) return + entries.firstOrNull { it.codes == codes }?.let { + selected = it + invalidate() + return + } + val entry = Entry(codes) + if (addX >= 0f && width > 0) { + entry.x = (addX / width).coerceIn(0.05f, 0.95f) + entry.y = (addY / height).coerceIn(0.05f, 0.95f) + } else { + entry.x = (0.30f + 0.08f * (entries.size % 5)).coerceAtMost(0.70f) + entry.y = 0.62f + } + addX = -1f + addY = -1f + entries += entry + if (width > 0) measure(entry) + selected = entry + save() + invalidate() + } + + fun remove(codes: List) { + val entry = entries.firstOrNull { it.codes == codes } ?: return + entries.remove(entry) + if (selected === entry) selected = null + save() + invalidate() + } + + fun setCodes(from: List, to: List): ScreenKey? { + if (to.isEmpty()) return null + val index = entries.indexOfFirst { it.codes == from } + if (index < 0) return null + val old = entries[index] + if (to != from && entries.any { it !== old && it.codes == to }) return null + val fresh = Entry(to).also { + it.glyph = old.glyph + it.text = old.text + it.image = old.image + it.bitmap = old.bitmap + it.bitmapFor = old.bitmapFor + it.x = old.x + it.y = old.y + it.size = old.size + it.opacity = old.opacity + it.shape = old.shape + it.toggle = old.toggle + it.labels = old.labels + } + entries[index] = fresh + measure(fresh) + if (selected === old) selected = fresh + save() + invalidate() + return stateOf(fresh.codes) + } + + fun setText(codes: List, text: String) { + val entry = entries.firstOrNull { it.codes == codes } ?: return + entry.text = text + measure(entry) + save() + invalidate() + } + + fun setImage(codes: List, image: String) { + val entry = entries.firstOrNull { it.codes == codes } ?: return + val previous = entry.image + entry.image = image + entry.bitmap = null + entry.bitmapFor = "" + if (previous.isNotEmpty() && previous != image) File(File(context.filesDir, IMAGE_DIR), previous).delete() + measure(entry) + save() + invalidate() + } + + fun resize(codes: List, size: Int) { + val entry = entries.firstOrNull { it.codes == codes } ?: return + entry.size = size.coerceIn(MIN_SIZE, MAX_SIZE) + measure(entry) + save() + invalidate() + } + + fun setOpacity(codes: List, opacity: Float) { + val entry = entries.firstOrNull { it.codes == codes } ?: return + entry.opacity = opacity.coerceIn(0.2f, 1f) + save() + invalidate() + } + + fun setShape(codes: List, shape: Int) { + val entry = entries.firstOrNull { it.codes == codes } ?: return + entry.shape = shape.coerceIn(SCREEN_KEY_PILL, SCREEN_KEY_STICK) + measure(entry) + save() + invalidate() + } + + fun setToggle(codes: List, toggle: Boolean) { + val entry = entries.firstOrNull { it.codes == codes } ?: return + entry.toggle = toggle + if (!toggle && latched.remove(entry)) release(entry) + save() + invalidate() + } + + fun setLabels(codes: List, labels: Boolean) { + val entry = entries.firstOrNull { it.codes == codes } ?: return + entry.labels = labels + save() + invalidate() + } + + fun setDirection(codes: List, index: Int, code: Int): ScreenKey? { + val entry = entries.firstOrNull { it.codes == codes } ?: entries.firstOrNull { it.codes == trimmed(codes) } ?: return null + val next = entry.codes.toMutableList() + while (next.size <= index) next += 0 + next[index] = code + val nextCodes = trimmed(next) + if (entries.any { it !== entry && it.codes == nextCodes }) return null + val updated = Entry(nextCodes).also { + it.glyph = entry.glyph + it.text = entry.text + it.image = entry.image + it.bitmap = entry.bitmap + it.bitmapFor = entry.bitmapFor + it.x = entry.x + it.y = entry.y + it.size = entry.size + it.opacity = entry.opacity + it.shape = entry.shape + it.toggle = entry.toggle + it.labels = entry.labels + } + entries[entries.indexOf(entry)] = updated + measure(updated) + if (selected === entry) selected = updated + save() + invalidate() + return stateOf(updated.codes) + } + + private fun trimmed(codes: List): List = codes.dropLastWhile { it == 0 }.ifEmpty { listOf(0) } + + fun stateOf(codes: List): ScreenKey? = entries.firstOrNull { it.codes == codes }?.let { + ScreenKey(it.codes, it.x, it.y, it.size, it.glyph, it.text, it.image, it.opacity, it.shape, it.toggle, it.labels) + } + + fun deselect() { + if (selected == null) return + selected = null + invalidate() + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + refreshAppearance() + } + + fun refreshAppearance() { + tint = ControllerPrefs.read(context).tint + invalidate() + } + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + entries.forEach { measure(it) } + placeToolbar() + } + + override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets { + val cutout = if (Build.VERSION.SDK_INT >= 28) insets.displayCutout else null + val fresh = Rect( + cutout?.safeInsetLeft ?: 0, cutout?.safeInsetTop ?: 0, + cutout?.safeInsetRight ?: 0, cutout?.safeInsetBottom ?: 0, + ) + if (fresh != safe) { + safe = fresh + placeToolbar() + invalidate() + } + return super.onApplyWindowInsets(insets) + } + + override fun onDetachedFromWindow() { + releaseAll() + super.onDetachedFromWindow() + } + + override fun onVisibilityChanged(changedView: View, visibility: Int) { + super.onVisibilityChanged(changedView, visibility) + if (visibility != VISIBLE) releaseAll() + } + + override fun setVisibility(visibility: Int) { + super.setVisibility(visibility) + if (visibility != VISIBLE) releaseAll() + } + + fun releaseAll() { + dpadDown.values.forEach { releaseKey(it) } + dpadDown.clear() + entries.forEach { it.armed = -1 } + stickCodes.values.flatten().forEach { if (it != 0) releaseKey(it) } + stickCodes.clear() + stickEntries.clear() + down.values.forEach { release(it) } + down.clear() + latched.toList().forEach { release(it) } + latched.clear() + invalidate() + } + + private fun snap(value: Float) = if (width > 0) round(value / (width / 100f)) * (width / 100f) else value + + private fun placeToolbar() { + if (width == 0 || height == 0) return + if (toolbar.x == 0f && toolbar.y == 0f) { + toolbar.set(width - safe.right - capsuleGap - capsuleRadius, height - safe.bottom - capsuleRadius - dp(20f)) + } + addCenter.set(toolbar.x - capsuleGap / 2f, toolbar.y) + doneCenter.set(toolbar.x + capsuleGap / 2f, toolbar.y) + } + + private fun entryOf(key: ScreenKey): Entry = Entry(key.codes).also { + it.glyph = key.glyph + it.text = key.text + it.image = key.image + it.x = key.x + it.y = key.y + it.size = key.size + it.opacity = key.opacity + it.shape = key.shape + it.toggle = key.toggle + it.labels = key.labels + } + + private fun label(entry: Entry) = when { + entry.text.isNotEmpty() -> entry.text + entry.glyph.isNotEmpty() -> entry.glyph + else -> screenKeyLabel(context, entry.codes) + } + + private fun bitmapFor(entry: Entry): Bitmap? { + if (entry.image.isEmpty()) return null + if (entry.bitmapFor == entry.image) return entry.bitmap + val file = File(File(context.filesDir, IMAGE_DIR), entry.image) + val bitmap = if (file.isFile) BitmapFactory.decodeFile(file.absolutePath) else null + entry.bitmap = bitmap + entry.bitmapFor = entry.image + return bitmap + } + + private fun measure(entry: Entry) { + val h = dp(entry.size.toFloat()) + entry.halfH = h / 2f + entry.halfW = when { + entry.shape == SCREEN_KEY_CIRCLE -> entry.halfH + entry.shape == SCREEN_KEY_TRIGGER -> entry.halfH * 1.7f + entry.shape == SCREEN_KEY_DPAD || entry.shape == SCREEN_KEY_STICK -> entry.halfH * 1.15f + entry.image.isNotEmpty() -> entry.halfH * 1.15f + hasIcon(entry) -> entry.halfH * 1.15f + else -> { + val chars = label(entry).length + entry.halfH * (0.55f * chars + 1.05f).coerceIn(1.7f, 4.6f) + } + } + entry.cx = entry.x * width + entry.cy = entry.y * height + } + + private fun hasIcon(entry: Entry): Boolean = + entry.text.isEmpty() && entry.glyph.isEmpty() && entry.codes.size == 1 && ScreenKeyIcons.has(entry.codes[0]) + + private fun unit() = minOf(width / 1280f, height / 720f) + + private fun accent(): Pair { + val style = if (followSteam) steamStyle() else null + return (style?.first ?: tint) to (style?.second ?: 1f) + } + + private fun tinted(color: Int, alpha: Float): Int { + val scale = accent().second + return Color.argb( + (alpha * scale * 255f).toInt().coerceIn(0, 255), + Color.red(color), Color.green(color), Color.blue(color), + ) + } + + private fun triggerPath(entry: Entry, path: android.graphics.Path, inset: Float = 0f) { + val lean = (entry.halfH - inset) * 0.6f + val left = entry.cx - entry.halfW + inset + val right = entry.cx + entry.halfW - inset + val top = entry.cy - entry.halfH + inset + val bottom = entry.cy + entry.halfH - inset + path.reset() + path.moveTo(left + lean, top) + path.lineTo(right, top) + path.lineTo(right - lean, bottom) + path.lineTo(left, bottom) + path.close() + } + + private fun body(canvas: Canvas, entry: Entry, radius: Float, paint: Paint, inset: Float = 0f) { + if (entry.shape != SCREEN_KEY_TRIGGER) { + canvas.drawRoundRect(rect, radius, radius, paint) + return + } + triggerPath(entry, bodyPath, inset) + canvas.drawPath(bodyPath, paint) + } + + private fun drawDpad(canvas: Canvas, entry: Entry) { + val color = accent().first + val alpha = entry.opacity + val r = minOf(entry.halfW, entry.halfH) + val t = r * 0.42f + val hairline = maxOf(dp(1.2f), entry.halfH * 0.032f) + val engaged = entry.armed >= 0 + crossPath(entry, bodyPath, r, t) + fillPaint.style = Paint.Style.FILL + fillPaint.color = tinted(color, if (entry.armed >= 0) 0.26f * alpha else 0.10f * alpha) + canvas.drawPath(bodyPath, fillPaint) + if (entry.armed >= 0) { + armRect(entry, bodyPathRect, entry.armed, r, t) + fillPaint.color = tinted(color, 0.55f * alpha) + canvas.drawRoundRect(bodyPathRect, t * 0.6f, t * 0.6f, fillPaint) + } + strokePaint.strokeJoin = Paint.Join.ROUND + strokePaint.strokeWidth = if (engaged) hairline * 1.6f else hairline + strokePaint.color = tinted(color, (if (editing) 0.90f else 0.80f) * alpha) + canvas.drawPath(bodyPath, strokePaint) + textPaint.setShadowLayer(0f, 0f, 0f, 0) + fillPaint.color = tinted(color, if (editing) 0.90f else 0.80f) + DPAD_DIRS.forEachIndexed { index, dir -> + val on = entry.armed == index + val code = entry.codes.getOrNull(index) ?: 0 + val name = if (code == 0) "" else screenKeyLabel(context, listOf(code)) + if (entry.labels && name.isNotEmpty()) { + textPaint.color = if (on) Color.WHITE else tinted(color, alpha) + textPaint.textSize = dp(48f) + val fit = 48f * (t * 1.9f) / textPaint.measureText(name).coerceAtLeast(1f) + textPaint.textSize = minOf(fit, t * 1.7f).coerceIn(dp(8f), dp(60f)) + val tx = entry.cx + dir.first * (r - t * 0.6f) + val ty = entry.cy + dir.second * (r - t * 0.6f) + canvas.drawText(name, tx, ty - (textPaint.ascent() + textPaint.descent()) / 2f, textPaint) + } else { + fillPaint.color = if (on) Color.WHITE else tinted(color, if (editing) 0.90f else 0.80f) + arrow(canvas, fillPaint, entry.cx, entry.cy, r - t * 0.55f, dir.first, dir.second, t * 0.8f) + } + } + if (editing && selected === entry) { + ringPaint.strokeWidth = hairline * 0.8f + ringPaint.color = tinted(color, 0.9f) + ringPaint.style = Paint.Style.STROKE + canvas.drawCircle(entry.cx, entry.cy, r + hairline * 3f, ringPaint) + } + } + + private fun crossPath(entry: Entry, path: android.graphics.Path, r: Float, t: Float) { + val cx = entry.cx + val cy = entry.cy + path.reset() + path.moveTo(cx - t, cy - r) + path.lineTo(cx + t, cy - r) + path.lineTo(cx + t, cy - t) + path.lineTo(cx + r, cy - t) + path.lineTo(cx + r, cy + t) + path.lineTo(cx + t, cy + t) + path.lineTo(cx + t, cy + r) + path.lineTo(cx - t, cy + r) + path.lineTo(cx - t, cy + t) + path.lineTo(cx - r, cy + t) + path.lineTo(cx - r, cy - t) + path.lineTo(cx - t, cy - t) + path.close() + } + + private fun armRect(entry: Entry, rect: RectF, index: Int, r: Float, t: Float) { + when (index) { + 0 -> rect.set(entry.cx - t, entry.cy - r, entry.cx + t, entry.cy - t) + 1 -> rect.set(entry.cx + t, entry.cy - t, entry.cx + r, entry.cy + t) + 2 -> rect.set(entry.cx - t, entry.cy + t, entry.cx + t, entry.cy + r) + else -> rect.set(entry.cx - r, entry.cy - t, entry.cx - t, entry.cy + t) + } + } + + private fun arrow(canvas: Canvas, paint: Paint, cx: Float, cy: Float, dist: Float, dx: Float, dy: Float, size: Float) { + val tipX = cx + dx * dist + val tipY = cy + dy * dist + val baseX = cx + dx * (dist - size) + val baseY = cy + dy * (dist - size) + val nx = -dy * size * 0.62f + val ny = dx * size * 0.62f + bodyPath.reset() + bodyPath.moveTo(tipX, tipY) + bodyPath.lineTo(baseX + nx, baseY + ny) + bodyPath.lineTo(baseX - nx, baseY - ny) + bodyPath.close() + canvas.drawPath(bodyPath, paint) + } + + private fun armOf(entry: Entry, x: Float, y: Float): Int { + val dx = x - entry.cx + val dy = y - entry.cy + return if (abs(dx) > abs(dy)) (if (dx > 0f) 1 else 3) else (if (dy > 0f) 2 else 0) + } + + private fun drawStick(canvas: Canvas, entry: Entry) { + val color = accent().first + val alpha = entry.opacity + val engaged = entry.armed >= 0 + val radius = minOf(entry.halfW, entry.halfH) + val hairline = maxOf(dp(1.2f), entry.halfH * 0.032f) + fillPaint.style = Paint.Style.FILL + fillPaint.color = tinted(color, 0.14f * alpha) + canvas.drawCircle(entry.cx, entry.cy, radius - hairline, fillPaint) + strokePaint.strokeWidth = if (engaged) hairline * 1.6f else hairline + strokePaint.color = tinted(color, (if (editing) 0.90f else 0.80f) * alpha) + canvas.drawCircle(entry.cx, entry.cy, radius - hairline / 2f, strokePaint) + val dir = if (engaged) STICK_DIRS[entry.armed.coerceIn(0, 7)] else 0f to 0f + val knob = radius * 0.36f + fillPaint.color = tinted(color, if (engaged) 0.55f * alpha else 0.24f * alpha) + canvas.drawCircle(entry.cx + dir.first * radius * 0.40f, entry.cy + dir.second * radius * 0.40f, knob, fillPaint) + strokePaint.strokeWidth = hairline + strokePaint.color = tinted(color, (if (editing) 0.90f else 0.80f) * alpha) + canvas.drawCircle(entry.cx + dir.first * radius * 0.40f, entry.cy + dir.second * radius * 0.40f, knob, strokePaint) + textPaint.setShadowLayer(0f, 0f, 0f, 0) + STICK_DIRS.forEachIndexed { index, unit -> + val on = entry.armed == index + val code = entry.codes.getOrNull(index) ?: 0 + val name = if (code == 0) "" else screenKeyLabel(context, listOf(code)) + if (entry.labels && name.isNotEmpty()) { + textPaint.color = if (on) Color.WHITE else tinted(color, 0.85f * alpha) + textPaint.textSize = dp(48f) + val fit = 48f * (radius * 0.34f) / textPaint.measureText(name).coerceAtLeast(1f) + textPaint.textSize = minOf(fit, radius * 0.22f).coerceAtLeast(dp(6f)) + val tx = entry.cx + unit.first * radius * 0.66f + val ty = entry.cy + unit.second * radius * 0.66f + canvas.drawText(name, tx, ty - (textPaint.ascent() + textPaint.descent()) / 2f, textPaint) + } else { + fillPaint.color = if (on) Color.WHITE else tinted(color, if (editing) 0.88f else 0.72f) + arrow(canvas, fillPaint, entry.cx, entry.cy, radius * 0.88f, unit.first, unit.second, radius * 0.16f) + } + } + if (editing && selected === entry) { + ringPaint.strokeWidth = hairline * 0.8f + ringPaint.color = tinted(color, 0.9f) + ringPaint.style = Paint.Style.STROKE + canvas.drawCircle(entry.cx, entry.cy, radius + hairline * 3f, ringPaint) + } + } + + private fun directionCodes(entry: Entry, index: Int): List { + if (index < 0) return emptyList() + val code = entry.codes.getOrNull(index) ?: 0 + if (code != 0) return listOf(code) + if (entry.shape != SCREEN_KEY_STICK || index % 2 == 0) return emptyList() + return listOfNotNull( + entry.codes.getOrNull((index + 7) % 8)?.takeIf { it != 0 }, + entry.codes.getOrNull((index + 1) % 8)?.takeIf { it != 0 }, + ) + } + + private fun stickSector(entry: Entry, x: Float, y: Float): Int { + val dx = x - entry.cx + val dy = y - entry.cy + if (hypot(dx, dy) < minOf(entry.halfW, entry.halfH) * 0.24f) return -1 + val degrees = Math.toDegrees(kotlin.math.atan2(dy, dx).toDouble()).toFloat() + val index = (round(degrees / 45f).toInt() + 2) % 8 + return (index + 8) % 8 + } + + private fun radiusOf(entry: Entry): Float = when (entry.shape) { + SCREEN_KEY_ROUND -> entry.halfH * 0.30f + SCREEN_KEY_RECT -> entry.halfH * 0.12f + SCREEN_KEY_TRIGGER -> entry.halfH * 0.45f + else -> entry.halfH + } + + private fun entryAt(x: Float, y: Float): Entry? = entries.lastOrNull { entry -> + x >= entry.cx - entry.halfW && x <= entry.cx + entry.halfW && y >= entry.cy - entry.halfH && y <= entry.cy + entry.halfH + } + + private fun inCircle(center: PointF, x: Float, y: Float, radius: Float) = hypot(x - center.x, y - center.y) <= radius + + private fun inToolbar(x: Float, y: Float): Boolean = + editing && x >= toolbar.x - capsuleGap / 2f - capsuleRadius && x <= toolbar.x + capsuleGap / 2f + capsuleRadius && + y >= toolbar.y - capsuleRadius && y <= toolbar.y + capsuleRadius + + override fun onDraw(canvas: Canvas) { + if (editing) drawGrid(canvas) + entries.forEach { draw(canvas, it) } + if (editing) { + drawGuides(canvas) + drawToolbar(canvas) + } + } + + private fun drawGuides(canvas: Canvas) { + if (guideX < 0f && guideY < 0f) return + gridPaint.strokeWidth = dp(1f) + gridPaint.color = tinted(accent().first, 0.9f) + if (guideX >= 0f) canvas.drawLine(guideX, 0f, guideX, height.toFloat(), gridPaint) + if (guideY >= 0f) canvas.drawLine(0f, guideY, width.toFloat(), guideY, gridPaint) + } + + private fun drawGrid(canvas: Canvas) { + if (width == 0 || height == 0) return + val step = width / 100f + gridPaint.strokeWidth = dp(1f) + gridPaint.color = Color.argb(38, 255, 255, 255) + var x = step + while (x < width) { + canvas.drawLine(x, 0f, x, height.toFloat(), gridPaint) + x += step + } + var y = step + while (y < height) { + canvas.drawLine(0f, y, width.toFloat(), y, gridPaint) + y += step + } + gridPaint.color = Color.argb(96, 255, 255, 255) + gridPaint.strokeWidth = dp(1f) + canvas.drawLine(width / 2f, 0f, width / 2f, height.toFloat(), gridPaint) + canvas.drawLine(0f, height / 2f, width.toFloat(), height / 2f, gridPaint) + } + + private fun draw(canvas: Canvas, entry: Entry) { + if (entry.shape == SCREEN_KEY_DPAD) { + drawDpad(canvas, entry) + return + } + if (entry.shape == SCREEN_KEY_STICK) { + drawStick(canvas, entry) + return + } + val style = if (followSteam) steamStyle() else null + val engaged = down.values.any { it === entry } || latched.contains(entry) + val bitmap = bitmapFor(entry) + val icon = bitmap == null && hasIcon(entry) + val frameless = entry.text.isBlank() && (bitmap != null || icon) + val alpha = entry.opacity + val h2 = entry.halfH * 2f + val radius = radiusOf(entry) + val selectedNow = editing && selected === entry + val hairline = maxOf(dp(1.2f), h2 * 0.032f) + rect.set(entry.cx - entry.halfW, entry.cy - entry.halfH, entry.cx + entry.halfW, entry.cy + entry.halfH) + val inkColor: Int + if (style != null) { + val inkAlpha = (255f * style.second * alpha).toInt().coerceIn(30, 255) + inkColor = Color.argb(inkAlpha, Color.red(style.first), Color.green(style.first), Color.blue(style.first)) + fillPaint.style = Paint.Style.FILL + if (engaged) { + fillPaint.color = Color.argb((inkAlpha * 0.85f).toInt(), 230, 233, 236) + body(canvas, entry, radius, fillPaint) + } + if (frameless) { + if (engaged) { + fillPaint.color = Color.argb((inkAlpha * 0.45f).toInt(), 230, 233, 236) + body(canvas, entry, radius, fillPaint) + } + } else { + strokePaint.color = if (selectedNow) Color.rgb(26, 159, 255) else inkColor + strokePaint.strokeWidth = if (engaged) hairline * 1.6f else hairline + body(canvas, entry, radius, strokePaint) + } + } else { + fillPaint.style = Paint.Style.FILL + if (!frameless) { + fillPaint.color = tinted(BODY, 0.60f * alpha) + canvas.drawRoundRect(rect, radius, radius, fillPaint) + } + if (engaged) { + fillPaint.color = tinted(accent().first, if (frameless) 0.32f * alpha else 0.42f * alpha) + canvas.drawRoundRect(rect, radius, radius, fillPaint) + } + if (!frameless) { + strokePaint.strokeWidth = if (engaged) hairline * 1.6f else hairline + strokePaint.color = tinted( + accent().first, + (if (engaged) 0.96f else if (editing) 0.80f else 0.68f) * alpha, + ) + body(canvas, entry, radius, strokePaint) + } + inkColor = if (engaged) Color.WHITE else tinted(accent().first, alpha) + } + if (editing && selected === entry) { + ringPaint.strokeWidth = hairline * 0.8f + ringPaint.color = tinted(accent().first, 0.9f) + val gap = hairline * 2.4f + rect.set( + entry.cx - entry.halfW - gap, entry.cy - entry.halfH - gap, + entry.cx + entry.halfW + gap, entry.cy + entry.halfH + gap, + ) + if (entry.shape == SCREEN_KEY_TRIGGER) { + body(canvas, entry, radius + gap, ringPaint, gap) + } else { + canvas.drawRoundRect(rect, radius + gap, radius + gap, ringPaint) + } + rect.set(entry.cx - entry.halfW, entry.cy - entry.halfH, entry.cx + entry.halfW, entry.cy + entry.halfH) + } + + if (bitmap != null) { + imageRect.set(rect) + canvas.save() + clipPath.reset() + when (entry.shape) { + SCREEN_KEY_CIRCLE -> clipPath.addCircle( + entry.cx, entry.cy, minOf(entry.halfW, entry.halfH), android.graphics.Path.Direction.CW, + ) + SCREEN_KEY_TRIGGER -> triggerPath(entry, clipPath, 0f) + else -> clipPath.addRoundRect(rect, radius, radius, android.graphics.Path.Direction.CW) + } + canvas.clipPath(clipPath) + val scale = maxOf(imageRect.width() / bitmap.width, imageRect.height() / bitmap.height) + val w = bitmap.width * scale + val h = bitmap.height * scale + bitmapRect.set( + imageRect.centerX() - w / 2f, imageRect.centerY() - h / 2f, + imageRect.centerX() + w / 2f, imageRect.centerY() + h / 2f, + ) + canvas.drawBitmap(bitmap, null, bitmapRect, fillPaint) + canvas.restore() + } + + val name = label(entry) + textPaint.color = inkColor + if (bitmap != null && name.isEmpty()) return + var iconDrawn = false + if (icon) { + val box = minOf(entry.halfW, entry.halfH) * 1.35f + iconDrawn = ScreenKeyIcons.draw(canvas, entry.codes[0], entry.cx, entry.cy, box, textPaint) + textPaint.color = inkColor + } + if (iconDrawn || name.isEmpty()) return + val overImage = bitmap != null + if (overImage) textPaint.color = Color.WHITE + textPaint.setShadowLayer(if (overImage) dp(3f) else 0f, 0f, 0f, Color.argb(200, 0, 0, 0)) + textPaint.textSize = dp(48f) + val available = rect.width() - hairline * 4f + val fit = 48f * available / textPaint.measureText(name).coerceAtLeast(1f) + textPaint.textSize = minOf(fit, entry.halfH * 1.15f).coerceIn(dp(8f), dp(120f)) + canvas.drawText(name, entry.cx, entry.cy - (textPaint.ascent() + textPaint.descent()) / 2f, textPaint) + textPaint.clearShadowLayer() + } + + private fun drawToolbar(canvas: Canvas) { + val (color, _) = accent() + val capsule = RectF( + toolbar.x - capsuleGap / 2f - capsuleRadius, toolbar.y - capsuleRadius, + toolbar.x + capsuleGap / 2f + capsuleRadius, toolbar.y + capsuleRadius, + ) + fillPaint.style = Paint.Style.FILL + fillPaint.color = tinted(color, 0.30f) + canvas.drawRoundRect(capsule, capsuleRadius, capsuleRadius, fillPaint) + strokePaint.strokeWidth = dp(1.5f) + strokePaint.color = tinted(color, 0.85f) + canvas.drawRoundRect(capsule, capsuleRadius, capsuleRadius, strokePaint) + + textPaint.color = Color.WHITE + textPaint.setShadowLayer(0f, 0f, 0f, 0) + textPaint.textSize = capsuleRadius * 1.15f + val plus = "+" + canvas.drawText(plus, addCenter.x, addCenter.y - (textPaint.ascent() + textPaint.descent()) / 2f, textPaint) + val path = android.graphics.Path() + val check = capsuleRadius * 0.42f + path.moveTo(doneCenter.x - check, doneCenter.y) + path.lineTo(doneCenter.x - check * 0.25f, doneCenter.y + check * 0.7f) + path.lineTo(doneCenter.x + check, doneCenter.y - check * 0.7f) + ringPaint.strokeWidth = dp(3f) + ringPaint.strokeCap = Paint.Cap.ROUND + ringPaint.color = Color.WHITE + canvas.drawPath(path, ringPaint) + ringPaint.strokeCap = Paint.Cap.BUTT + } + + override fun onTouchEvent(event: MotionEvent): Boolean = if (editing) editTouch(event) else playTouch(event) + + private fun playTouch(event: MotionEvent): Boolean { + when (event.actionMasked) { + MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> { + val index = event.actionIndex + val entry = entryAt(event.getX(index), event.getY(index)) + if (entry == null) { + if (event.actionMasked == MotionEvent.ACTION_DOWN) return false + forwardedPointerBits = forwardedPointerBits or (1 shl event.getPointerId(index)) + forwardPointers(event, forwardedPointerBits) + return true + } + if (forwardedPointerBits != 0) forwardPointers(event, forwardedPointerBits) + if (entry.shape == SCREEN_KEY_DPAD) { + val arm = armOf(entry, event.getX(index), event.getY(index)) + val code = entry.codes.getOrNull(arm) ?: 0 + if (code == 0) return true + val pid = event.getPointerId(index) + dpadDown.remove(pid)?.let { releaseKey(it) } + dpadDown[pid] = code + entry.armed = arm + pressKey(code) + invalidate() + return true + } + if (entry.shape == SCREEN_KEY_STICK) { + val sector = stickSector(entry, event.getX(index), event.getY(index)) + val pid = event.getPointerId(index) + stickCodes.remove(pid)?.forEach { if (it != 0) releaseKey(it) } + val codes = directionCodes(entry, sector) + stickEntries[pid] = entry + stickCodes[pid] = codes + entry.armed = if (codes.isEmpty()) -1 else sector + codes.forEach { pressKey(it) } + invalidate() + return true + } + if (entry.toggle) { + if (latched.remove(entry)) release(entry) else { + latched += entry + entry.codes.forEach { pressKey(it) } + } + invalidate() + return true + } + if (down.values.any { it === entry }) return true + down[event.getPointerId(index)] = entry + entry.codes.forEach { pressKey(it) } + invalidate() + return true + } + + MotionEvent.ACTION_MOVE -> { + if (forwardedPointerBits != 0) forwardPointers(event, forwardedPointerBits) + if (stickEntries.isEmpty()) return true + repeat(event.pointerCount) { index -> + val pid = event.getPointerId(index) + val entry = stickEntries[pid] ?: return@repeat + val sector = stickSector(entry, event.getX(index), event.getY(index)) + val codes = directionCodes(entry, sector) + val previous = stickCodes[pid].orEmpty() + if (codes != previous) { + previous.filterNot { it in codes }.forEach { releaseKey(it) } + codes.filterNot { it in previous }.forEach { pressKey(it) } + stickCodes[pid] = codes + entry.armed = if (codes.isEmpty()) -1 else sector + invalidate() + } + } + return true + } + + MotionEvent.ACTION_UP, MotionEvent.ACTION_POINTER_UP -> { + val pid = event.getPointerId(event.actionIndex) + if (forwardedPointerBits and (1 shl pid) != 0) { + forwardPointers(event, forwardedPointerBits) + forwardedPointerBits = forwardedPointerBits and (1 shl pid).inv() + if (stickCodes.containsKey(pid) || dpadDown.containsKey(pid)) return true + down.remove(pid)?.let(::release) + return true + } + if (forwardedPointerBits != 0) forwardPointers(event, forwardedPointerBits) + if (stickCodes.containsKey(pid)) { + stickCodes.remove(pid)?.forEach { if (it != 0) releaseKey(it) } + stickEntries.remove(pid)?.armed = -1 + invalidate() + return true + } + val armedCode = dpadDown.remove(pid) + if (armedCode != null) { + releaseKey(armedCode) + entries.firstOrNull { it.armed >= 0 && it.codes.contains(armedCode) }?.armed = -1 + invalidate() + return true + } + val entry = down.remove(pid) + if (entry != null) release(entry) + return true + } + + MotionEvent.ACTION_CANCEL -> { + if (forwardedPointerBits != 0) forwardPointers(event, forwardedPointerBits) + forwardedPointerBits = 0 + stickCodes.values.flatten().forEach { if (it != 0) releaseKey(it) } + stickCodes.clear() + stickEntries.clear() + entries.forEach { it.armed = -1 } + dpadDown.values.forEach { releaseKey(it) } + dpadDown.clear() + entries.forEach { it.armed = -1 } + down.values.forEach { release(it) } + down.clear() + return true + } + } + return true + } + + private fun forwardPointers(event: MotionEvent, pointerIdBits: Int) { + val indices = (0 until event.pointerCount).filter { index -> + pointerIdBits and (1 shl event.getPointerId(index)) != 0 + } + if (indices.isEmpty()) return + val actionIndex = indices.indexOf(event.actionIndex) + val action = when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> if (actionIndex >= 0) MotionEvent.ACTION_DOWN else MotionEvent.ACTION_MOVE + MotionEvent.ACTION_POINTER_DOWN -> if (actionIndex >= 0) { + if (indices.size == 1) MotionEvent.ACTION_DOWN + else MotionEvent.ACTION_POINTER_DOWN or (actionIndex shl MotionEvent.ACTION_POINTER_INDEX_SHIFT) + } else MotionEvent.ACTION_MOVE + MotionEvent.ACTION_UP -> if (actionIndex >= 0) MotionEvent.ACTION_UP else MotionEvent.ACTION_MOVE + MotionEvent.ACTION_POINTER_UP -> if (actionIndex >= 0) { + if (indices.size == 1) MotionEvent.ACTION_UP + else MotionEvent.ACTION_POINTER_UP or (actionIndex shl MotionEvent.ACTION_POINTER_INDEX_SHIFT) + } else MotionEvent.ACTION_MOVE + else -> event.action + } + val properties = Array(indices.size) { MotionEvent.PointerProperties() } + val coordinates = Array(indices.size) { MotionEvent.PointerCoords() } + indices.forEachIndexed { target, source -> + event.getPointerProperties(source, properties[target]) + event.getPointerCoords(source, coordinates[target]) + } + val split = MotionEvent.obtain( + event.downTime, event.eventTime, action, indices.size, properties, coordinates, + event.metaState, event.buttonState, event.xPrecision, event.yPrecision, + event.deviceId, event.edgeFlags, event.source, event.flags, + ) + try { + onTouchMiss?.invoke(split) + } finally { + split.recycle() + } + } + + private fun editTouch(event: MotionEvent): Boolean { + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + val x = event.x + val y = event.y + if (inCircle(addCenter, x, y, capsuleRadius)) { + onAdd?.invoke() + return true + } + if (inCircle(doneCenter, x, y, capsuleRadius)) { + stopEditing() + return true + } + if (inToolbar(x, y)) { + toolbarDrag = true + dragX = toolbar.x - x + dragY = toolbar.y - y + return true + } + val entry = entryAt(x, y) + if (entry == null) { + pressX = x + pressY = y + postDelayed(addPress, 520) + selected = null + invalidate() + return true + } + selected = entry + dragging = true + moved = false + dragX = entry.cx - x + dragY = entry.cy - y + invalidate() + return true + } + + MotionEvent.ACTION_MOVE -> { + if (toolbarDrag) { + val maxX = width - safe.right - capsuleGap / 2f - capsuleRadius + val maxY = height - safe.bottom - capsuleRadius + toolbar.x = (event.x + dragX).coerceIn(safe.left + capsuleGap / 2f + capsuleRadius, maxOf(safe.left + capsuleGap / 2f + capsuleRadius, maxX)) + toolbar.y = (event.y + dragY).coerceIn(safe.top + capsuleRadius, maxOf(safe.top + capsuleRadius, maxY)) + addCenter.set(toolbar.x - capsuleGap / 2f, toolbar.y) + doneCenter.set(toolbar.x + capsuleGap / 2f, toolbar.y) + invalidate() + return true + } + val entry = selected + if (entry == null) { + if (hypot(event.x - pressX, event.y - pressY) > dp(10f)) removeCallbacks(addPress) + return true + } + if (!dragging) return true + if (hypot(event.x - (entry.cx - dragX), event.y - (entry.cy - dragY)) > dp(10f)) removeCallbacks(addPress) + var nextX = snap((event.x + dragX).coerceIn(entry.halfW + safe.left, width - entry.halfW - safe.right)) + var nextY = snap((event.y + dragY).coerceIn(entry.halfH + safe.top, height - entry.halfH - safe.bottom)) + guideX = -1f + guideY = -1f + entries.forEach { other -> + if (other === entry) return@forEach + if (abs(nextX - other.cx) < dp(8f)) { nextX = other.cx; guideX = other.cx } + if (abs(nextY - other.cy) < dp(8f)) { nextY = other.cy; guideY = other.cy } + } + if (abs(nextX - width / 2f) < dp(8f)) { nextX = width / 2f; guideX = nextX } + if (abs(nextY - height / 2f) < dp(8f)) { nextY = height / 2f; guideY = nextY } + if (hypot(nextX - entry.cx, nextY - entry.cy) > dp(4f)) moved = true + entry.cx = nextX + entry.cy = nextY + entry.x = entry.cx / width + entry.y = entry.cy / height + invalidate() + return true + } + + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + removeCallbacks(addPress) + guideX = -1f + guideY = -1f + if (toolbarDrag) { + toolbarDrag = false + return true + } + val entry = selected + if (entry != null && dragging) { + dragging = false + if (moved) { + save() + invalidate() + } else { + onSelect?.invoke(entry.codes) + } + } + return true + } + } + return true + } + + private fun pressKey(code: Int) { + val owners = held[code] ?: 0 + held[code] = owners + 1 + if (owners == 0) sendKey(code, true) + } + + private fun releaseKey(code: Int) { + val owners = held[code] ?: return + if (owners > 1) { + held[code] = owners - 1 + return + } + held.remove(code) + sendKey(code, false) + } + + private fun release(entry: Entry) { + entry.codes.asReversed().forEach { releaseKey(it) } + invalidate() + } + + fun export(): String = ControllerPrefs.encodeScreenKeys( + entries.map { ScreenKey(it.codes, it.x, it.y, it.size, it.glyph, it.text, it.image, it.opacity, it.shape, it.toggle, it.labels) }, + ) + + fun load(raw: String) { + releaseAll() + entries.clear() + ControllerPrefs.decodeScreenKeys(raw).forEach { entries += entryOf(it) } + entries.forEach { measure(it) } + selected = null + save() + invalidate() + } + + private fun save() { + ControllerPrefs.setScreenKeys( + context, + entries.map { ScreenKey(it.codes, it.x, it.y, it.size, it.glyph, it.text, it.image, it.opacity, it.shape, it.toggle, it.labels) }, + ) + } + + private fun dp(value: Float) = value * resources.displayMetrics.density + + companion object { + private val DPAD_DIRS = listOf( + 0f to -1f, 1f to 0f, 0f to 1f, -1f to 0f, + ) + private val STICK_DIRS = listOf( + 0f to -1f, 0.7071f to -0.7071f, 1f to 0f, 0.7071f to 0.7071f, + 0f to 1f, -0.7071f to 0.7071f, -1f to 0f, -0.7071f to -0.7071f, + ) + const val DEFAULT_SIZE = 44 + const val MIN_SIZE = 24 + const val MAX_SIZE = 140 + const val IMAGE_DIR = "screenkeys" + private const val BODY = 0xFF14181F.toInt() + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/input/ScreenLayoutFile.kt b/app/src/main/java/com/droiddeck/launcher/input/ScreenLayoutFile.kt new file mode 100644 index 000000000..fc2594071 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/input/ScreenLayoutFile.kt @@ -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 = 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 = 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, 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? { + val root = runCatching { JSONObject(raw) }.getOrNull() ?: return null + val elements = root.optJSONArray("elements") ?: return null + val snap = widthPx / 100f + val keys = mutableListOf() + 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 +} diff --git a/app/src/main/java/com/droiddeck/launcher/input/SteamTouchControls.kt b/app/src/main/java/com/droiddeck/launcher/input/SteamTouchControls.kt index 1c6921a89..ca5157372 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/SteamTouchControls.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/SteamTouchControls.kt @@ -93,7 +93,10 @@ class SteamTouchControls( private var opened = 0 private var elements: List = emptyList() private var tint = Color.WHITE - private var alphaScale = 0.45f + private var alphaScale = 0.99f + var style: Pair = 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> = 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() { diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/BattleNet.kt b/app/src/main/java/com/droiddeck/launcher/runtime/BattleNet.kt new file mode 100644 index 000000000..5cd766a7b --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/runtime/BattleNet.kt @@ -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 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() + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/BwrapSpawner.kt b/app/src/main/java/com/droiddeck/launcher/runtime/BwrapSpawner.kt index 482564678..81fabd7f7 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/BwrapSpawner.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/BwrapSpawner.kt @@ -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" diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt b/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt index 8b4f14e5a..5a72e9f22 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt @@ -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) } } diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxNetworkLinkComponent.kt b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxNetworkLinkComponent.kt index ac87bd0aa..45f07471b 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxNetworkLinkComponent.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxNetworkLinkComponent.kt @@ -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 diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java index 11be5bf9b..6d17f2d75 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java +++ b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java @@ -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); diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntimeInstaller.java b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntimeInstaller.java index d6f6b0076..cd8c6c44d 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntimeInstaller.java +++ b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntimeInstaller.java @@ -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) { diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeFileTree.java b/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeFileTree.java index 5fb94de05..42e7f5b11 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeFileTree.java +++ b/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeFileTree.java @@ -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; diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeInstallService.kt b/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeInstallService.kt index d2cee0435..aa53092fe 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeInstallService.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeInstallService.kt @@ -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) diff --git a/app/src/main/java/com/droiddeck/launcher/session/BatteryComponent.kt b/app/src/main/java/com/droiddeck/launcher/session/BatteryComponent.kt index 252e06a9e..517fa6c74 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/BatteryComponent.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/BatteryComponent.kt @@ -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" diff --git a/app/src/main/java/com/droiddeck/launcher/session/BootMovie.kt b/app/src/main/java/com/droiddeck/launcher/session/BootMovie.kt index 8267edd57..953196900 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/BootMovie.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/BootMovie.kt @@ -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 { diff --git a/app/src/main/java/com/droiddeck/launcher/session/BootMovieLibrary.kt b/app/src/main/java/com/droiddeck/launcher/session/BootMovieLibrary.kt index 7c125e819..0dd7a0fad 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/BootMovieLibrary.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/BootMovieLibrary.kt @@ -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") diff --git a/app/src/main/java/com/droiddeck/launcher/session/ComponentsManager.kt b/app/src/main/java/com/droiddeck/launcher/session/ComponentsManager.kt index d1aae9d72..f10ab1244 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/ComponentsManager.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/ComponentsManager.kt @@ -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) } } diff --git a/app/src/main/java/com/droiddeck/launcher/session/GameEnvironmentStore.kt b/app/src/main/java/com/droiddeck/launcher/session/GameEnvironmentStore.kt index 351fe304c..4d1ae8de2 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/GameEnvironmentStore.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/GameEnvironmentStore.kt @@ -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) diff --git a/app/src/main/java/com/droiddeck/launcher/session/GameStorage.kt b/app/src/main/java/com/droiddeck/launcher/session/GameStorage.kt index 90feec16e..447fb254c 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/GameStorage.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/GameStorage.kt @@ -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) diff --git a/app/src/main/java/com/droiddeck/launcher/session/GpuClockPin.kt b/app/src/main/java/com/droiddeck/launcher/session/GpuClockPin.kt index 7855c4959..40135f08d 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/GpuClockPin.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/GpuClockPin.kt @@ -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" } diff --git a/app/src/main/java/com/droiddeck/launcher/session/GpuStatsComponent.kt b/app/src/main/java/com/droiddeck/launcher/session/GpuStatsComponent.kt index fb6efe09f..006350916 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/GpuStatsComponent.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/GpuStatsComponent.kt @@ -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", diff --git a/app/src/main/java/com/droiddeck/launcher/session/LoadingState.kt b/app/src/main/java/com/droiddeck/launcher/session/LoadingState.kt index a23f23beb..f69983f0d 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/LoadingState.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/LoadingState.kt @@ -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""") } } diff --git a/app/src/main/java/com/droiddeck/launcher/session/MemoryReadout.kt b/app/src/main/java/com/droiddeck/launcher/session/MemoryReadout.kt new file mode 100644 index 000000000..c5fcf8a73 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/MemoryReadout.kt @@ -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 + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/session/NoSteamLaunch.kt b/app/src/main/java/com/droiddeck/launcher/session/NoSteamLaunch.kt index 6787d92d2..b8dab5de1 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/NoSteamLaunch.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/NoSteamLaunch.kt @@ -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 } } } diff --git a/app/src/main/java/com/droiddeck/launcher/session/ProtonDefault.kt b/app/src/main/java/com/droiddeck/launcher/session/ProtonDefault.kt index ff3214cf7..2c7110a85 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/ProtonDefault.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/ProtonDefault.kt @@ -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)) { diff --git a/app/src/main/java/com/droiddeck/launcher/session/ProtonExtras.kt b/app/src/main/java/com/droiddeck/launcher/session/ProtonExtras.kt index 42c0deeb6..962291d2c 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/ProtonExtras.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/ProtonExtras.kt @@ -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 { diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionArtifacts.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionArtifacts.kt index 9ec21c209..c356bf0d0 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionArtifacts.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionArtifacts.kt @@ -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 diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionDisplay.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionDisplay.kt index c0bba6093..39ed2c43e 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionDisplay.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionDisplay.kt @@ -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 } diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt index 0dab068d8..0e10fca29 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt @@ -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. */ diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionLogShare.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionLogShare.kt index b44c5c626..ded53162c 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionLogShare.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionLogShare.kt @@ -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() diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt index 07a3c9f9e..ec502a279 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -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", "") ?: "" diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt index 55e1adb44..1df7ea31b 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -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)) diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt index cebc33caa..5db2211a1 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt @@ -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 diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionSuspendController.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionSuspendController.kt index d694d6786..5d0e32f99 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionSuspendController.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionSuspendController.kt @@ -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 diff --git a/app/src/main/java/com/droiddeck/launcher/session/SettingsReset.kt b/app/src/main/java/com/droiddeck/launcher/session/SettingsReset.kt index 0c430c1c5..c933229a8 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SettingsReset.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SettingsReset.kt @@ -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") diff --git a/app/src/main/java/com/droiddeck/launcher/session/SteamDownloadMonitor.kt b/app/src/main/java/com/droiddeck/launcher/session/SteamDownloadMonitor.kt index 37f7c1fb9..ff3602c1b 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SteamDownloadMonitor.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SteamDownloadMonitor.kt @@ -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""") } } diff --git a/app/src/main/java/com/droiddeck/launcher/session/WinComponents.kt b/app/src/main/java/com/droiddeck/launcher/session/WinComponents.kt index 2c5a1aa0d..4b355caa4 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/WinComponents.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/WinComponents.kt @@ -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 } diff --git a/app/src/main/java/com/droiddeck/launcher/session/WineProcesses.kt b/app/src/main/java/com/droiddeck/launcher/session/WineProcesses.kt new file mode 100644 index 000000000..d80099bdd --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/WineProcesses.kt @@ -0,0 +1,13 @@ +package com.droiddeck.launcher.session + +internal object WineProcesses { + private const val WINESERVER = "wineserver" + + fun isWineserver(cmdline: ByteArray?, comm: String?): Boolean { + val program = cmdline?.let { bytes -> + val end = bytes.indexOf(0.toByte()).let { if (it < 0) bytes.size else it } + String(bytes, 0, end, Charsets.UTF_8).substringAfterLast('/') + } + return program == WINESERVER || comm?.trim() == WINESERVER + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/storage/SteamStorage.kt b/app/src/main/java/com/droiddeck/launcher/storage/SteamStorage.kt index e707fd59f..9697addd6 100644 --- a/app/src/main/java/com/droiddeck/launcher/storage/SteamStorage.kt +++ b/app/src/main/java/com/droiddeck/launcher/storage/SteamStorage.kt @@ -28,6 +28,8 @@ object SteamStorage { private val LAST_PLAYED = Regex("^\\s*\"LastPlayed\"\\s*\"(\\d+)\"", RegexOption.MULTILINE) private val MAPPING = Regex("\"(\\d+)\"\\s*\\{\\s*\"name\"\\s*\"([^\"]*)\"") private val COMPAT_NAME = Regex("\"compat_tools\"\\s*\\{\\s*\"([^\"]+)\"") + private val VDF_TOKEN = Regex("\"((?:\\\\.|[^\"\\\\])*)\"|[{}]|//[^\n]*") + private val SHADER_CACHE_OFF = listOf("InstallConfigStore", "Software", "Valve", "Steam", "ShaderCacheManager", "DisableShaderCache") data class Manifest( val file: File, val appId: Int, val name: String, val installDir: String?, @@ -118,6 +120,28 @@ object SteamStorage { return MAPPING.findAll(configVdf.substring(open, end)).associate { it.groupValues[1] to it.groupValues[2] } } + fun shaderPrecache(configVdf: String): Boolean = vdfValue(configVdf, SHADER_CACHE_OFF) != "1" + + private fun vdfValue(text: String, path: List<String>): String? { + val blocks = ArrayList<String>() + var key: String? = null + for (token in VDF_TOKEN.findAll(text)) { + val name = key + when { + token.value.startsWith("//") -> {} + token.value == "{" -> { blocks.add(name.orEmpty()); key = null } + token.value == "}" -> { blocks.removeLastOrNull(); key = null } + name == null -> key = token.groupValues[1] + else -> { + val at = blocks + name + if (at.size == path.size && at.zip(path).all { (a, b) -> a.equals(b, ignoreCase = true) }) return token.groupValues[1] + key = null + } + } + } + return null + } + fun compatToolName(compatibilityToolVdf: String): String? = COMPAT_NAME.find(compatibilityToolVdf)?.groupValues?.get(1) private val ARM_IN_NAME = Regex("arm64|aarch64") diff --git a/app/src/main/java/com/droiddeck/launcher/stores/CloudSavePaths.kt b/app/src/main/java/com/droiddeck/launcher/stores/CloudSavePaths.kt index 194448f0c..9dfc6b665 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/CloudSavePaths.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/CloudSavePaths.kt @@ -3,8 +3,28 @@ package com.droiddeck.launcher.stores import java.io.File object CloudSavePaths { + const val GOG_DEFAULT_NAMESPACE = "__default" + fun profile(prefix: File): File = File(prefix, "drive_c/users/steamuser") + fun gogGalaxySdkTemplate(clientId: String) = + "%LocalAppData%/GOG.com/Galaxy/Applications/$clientId/Storage/Shared/Files" + + class GogCloudLocation(val name: String, val template: String) + + /** + * cloudStorage.enabled with an empty locations list is Galaxy SDK IStorage, whose objects the + * API names under a location called `__default`. + */ + fun gogSaveFromRemote(enabled: Boolean, locations: List<Pair<String, String>>, clientId: String): GogCloudLocation? { + if (!enabled) return null + val named = locations.filter { it.second.isNotEmpty() } + val pick = named.firstOrNull { it.first.equals("saves", true) } ?: named.firstOrNull() + if (pick != null) return GogCloudLocation(pick.first, pick.second) + if (clientId.isEmpty()) return null + return GogCloudLocation(GOG_DEFAULT_NAMESPACE, gogGalaxySdkTemplate(clientId)) + } + fun gog(template: String, prefix: File, install: File): File? { val norm = template.replace('\\', '/').trim().trimStart('/') val match = Regex("^(<\\?[^?>]+\\?>|%[^%]+%)").find(norm) ?: return null diff --git a/app/src/main/java/com/droiddeck/launcher/stores/CloudSaves.kt b/app/src/main/java/com/droiddeck/launcher/stores/CloudSaves.kt index 669a13eee..659ae7005 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/CloudSaves.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/CloudSaves.kt @@ -1,6 +1,7 @@ package com.droiddeck.launcher.stores import android.content.Context +import android.content.SharedPreferences import android.util.Log import com.droiddeck.launcher.frontend.AddedGames import com.droiddeck.launcher.frontend.Library @@ -22,6 +23,7 @@ object CloudSaves { private const val TAG = "CloudSaves" private const val BACKUPS = 3 private const val PARALLEL = 6 + private const val CLOUD_LAYOUT = 2 class Result(val result: String, val files: Int, val bytes: Long, val reason: String, val conflicts: Int = 0) { val ok: Boolean get() = result != "failed" @@ -47,20 +49,54 @@ object CloudSaves { private fun markSynced(context: Context, store: Store, id: String) = prefs(context, store).edit().putLong("cloud_sync_$id", System.currentTimeMillis()).apply() + private const val TEMPLATE_NONE = "none" + internal const val TEMPLATE_NONE_TTL_MS = 24 * 60 * 60 * 1000L + + internal fun noneTemplateIsCurrent(storedAtMs: Long, nowMs: Long) = + storedAtMs > 0L && nowMs - storedAtMs < TEMPLATE_NONE_TTL_MS + + private fun rememberNoCloud(p: SharedPreferences, id: String) { + p.edit().putString("cloud_template_$id", TEMPLATE_NONE) + .putLong("cloud_template_at_$id", System.currentTimeMillis()).apply() + } + fun template(context: Context, store: Store, id: String, folder: File, sidecar: StoreGameSidecar): String? { val p = prefs(context, store) - if (p.contains("cloud_template_$id")) return p.getString("cloud_template_$id", "") + var retryNone = false + if (p.contains("cloud_template_$id")) { + val cached = p.getString("cloud_template_$id", "") ?: "" + if (cached == TEMPLATE_NONE) { + if (noneTemplateIsCurrent(p.getLong("cloud_template_at_$id", 0L), System.currentTimeMillis())) return "" + retryNone = true + } else if (cached.isNotEmpty()) return cached + } + var gogLocation = "" val fetched = when (store) { - Store.GOG -> gogTemplate(context, id, folder) + Store.GOG -> { + val config = gogConfig(context, id, folder) + if (config == null) { + if (retryNone) { rememberNoCloud(p, id); return "" } + return null + } + gogLocation = config.name + config.location + } Store.EPIC -> { val token = EpicCredentialStore.getValidAccessToken(context) ?: return null val ns = sidecar.extra["namespace"].orEmpty() val item = sidecar.extra["catalogItemId"].orEmpty() if (ns.isEmpty() || item.isEmpty()) "" else EpicApiClient.getCustomAttribute(token, ns, item, "CloudSaveFolder") } - Store.AMAZON, Store.ITCH, Store.XBOX -> "" + Store.AMAZON, Store.ITCH, Store.XBOX, Store.BATTLENET -> "" } ?: return null - p.edit().putString("cloud_template_$id", fetched).apply() + p.edit() + .putString("cloud_template_$id", fetched.ifEmpty { TEMPLATE_NONE }) + .apply { + if (fetched.isEmpty()) putLong("cloud_template_at_$id", System.currentTimeMillis()) + else remove("cloud_template_at_$id") + if (store == Store.GOG && gogLocation.isNotEmpty()) putString("cloud_location_$id", gogLocation) + } + .apply() Log.i(TAG, "cloud ${store.id} $id template ${if (fetched.isEmpty()) "none" else "found"}") return fetched } @@ -69,8 +105,10 @@ object CloudSaves { GogPrefs.get(context).getString("client_id_$id", null)?.ifEmpty { null } ?: runCatching { JSONObject(File(folder, "goggame-$id.info").readText()).optString("clientId").ifEmpty { null } }.getOrNull() - private fun gogTemplate(context: Context, id: String, folder: File): String? { - val clientId = gogClientId(context, id, folder) ?: return "" + private class GogConfig(val name: String, val location: String) + + private fun gogConfig(context: Context, id: String, folder: File): GogConfig? { + val clientId = gogClientId(context, id, folder) ?: return GogConfig("", "") return try { val c = URL("https://remote-config.gog.com/components/galaxy_client/clients/$clientId?component_version=2.0.45").openConnection() as HttpURLConnection c.connectTimeout = 15_000; c.readTimeout = 15_000 @@ -78,16 +116,34 @@ object CloudSaves { if (c.responseCode !in 200..299) { c.disconnect(); return null } val cloud = JSONObject(c.inputStream.bufferedReader().readText()).optJSONObject("content")?.optJSONObject("Windows")?.optJSONObject("cloudStorage") c.disconnect() - if (cloud?.optBoolean("enabled", false) != true) return "" - val locations = cloud.optJSONArray("locations") ?: return "" - val all = (0 until locations.length()).mapNotNull { locations.optJSONObject(it) } - (all.firstOrNull { it.optString("name").equals("saves", true) } ?: all.firstOrNull())?.optString("location").orEmpty() + val locations = cloud?.optJSONArray("locations") + val picked = CloudSavePaths.gogSaveFromRemote( + enabled = cloud?.optBoolean("enabled", false) == true, + locations = (0 until (locations?.length() ?: 0)).mapNotNull { i -> + locations!!.optJSONObject(i)?.let { it.optString("name") to it.optString("location") } + }, + clientId = clientId, + ) + if (picked == null) GogConfig("", "") else GogConfig(picked.name, picked.template) } catch (e: Exception) { Log.w(TAG, "gog remote-config: ${e.javaClass.simpleName}") null } } + /** + * The selected cloud location's name: GOG prefixes every cloud file with it, and the local folder + * is the location itself. Older builds had no name cached; fetch it once for those. + */ + private fun gogLocationName(context: Context, id: String, folder: File): String? { + val p = GogPrefs.get(context) + p.getString("cloud_location_$id", null)?.ifEmpty { null }?.let { return it } + if (p.getString("cloud_template_$id", null)?.let { it.isEmpty() || it == TEMPLATE_NONE } == true) return null + val config = gogConfig(context, id, folder) ?: return null + if (config.name.isNotEmpty()) p.edit().putString("cloud_location_$id", config.name).apply() + return config.name.ifEmpty { null } + } + private fun saveFolder(context: Context, store: Store, id: String): Pair<File?, String> { val (folder, sidecar) = game(context, store, id) ?: return null to "not-installed" val template = template(context, store, id, folder, sidecar) ?: return null to "store-unreachable" @@ -96,7 +152,7 @@ object CloudSaves { val dir = when (store) { Store.GOG -> CloudSavePaths.gog(template, pfx, folder) Store.EPIC -> CloudSavePaths.epic(template, pfx, folder, EpicCredentialStore.load(context)?.accountId.orEmpty(), id) - Store.AMAZON, Store.ITCH, Store.XBOX -> null + Store.AMAZON, Store.ITCH, Store.XBOX, Store.BATTLENET -> null } return dir to (if (dir == null) "unresolved" else "ok") } @@ -114,14 +170,14 @@ object CloudSaves { val galaxy = GogAuth.validToken(context) val clientId = gogClientId(context, id, folder) if (userId.isEmpty() || galaxy == null || clientId == null) null - else GogCloud(userId, clientId, gogScopedToken(context, id, clientId) ?: galaxy) + else GogCloud(userId, clientId, gogScopedToken(context, id, clientId) ?: galaxy, gogLocationName(context, id, folder)) } Store.EPIC -> { val token = EpicCredentialStore.getValidAccessToken(context) val account = EpicCredentialStore.load(context)?.accountId.orEmpty() if (token == null || account.isEmpty()) null else EpicCloud(account, id, token) } - Store.AMAZON, Store.ITCH, Store.XBOX -> null + Store.AMAZON, Store.ITCH, Store.XBOX, Store.BATTLENET -> null } private fun gogScopedToken(context: Context, id: String, clientId: String): String? { @@ -136,6 +192,83 @@ object CloudSaves { private fun saveBaseline(context: Context, store: Store, id: String, b: Map<String, CloudPlan.Entry>) = prefs(context, store).edit().putString("cloud_baseline_$id", CloudPlan.toJson(b)).apply() + internal class Migration(val baseline: Map<String, CloudPlan.Entry>, val moved: Int, val cleaned: Int, val failed: Int) + + /** + * Moves only files whose `"<location>/<path>"` key proves the old mapping wrote them; anything + * the game itself created under that name is left where it is. Identical duplicates are removed + * only when they prove the same. The name must be one path segment: it comes from remote config. + */ + internal fun migrateNested(dir: File, name: String, base: Map<String, CloudPlan.Entry>): Migration? { + if (name.isEmpty() || name == "." || name == ".." || name.contains('/') || name.contains('\\')) return null + val nested = File(dir, name) + if (!nested.isDirectory) return null + val root = runCatching { dir.canonicalFile }.getOrNull() ?: return null + if (runCatching { nested.canonicalFile }.getOrNull()?.parentFile != root) return null + val prefix = "$name/" + val next = HashMap(base) + var moved = 0 + var cleaned = 0 + var failed = 0 + for (src in nested.walkTopDown().filter { it.isFile }.toList()) { + if (runCatching { java.nio.file.Files.isSymbolicLink(src.toPath()) }.getOrDefault(true)) continue + val srcCanon = runCatching { src.canonicalFile }.getOrNull() ?: continue + if (!srcCanon.path.startsWith(root.path + File.separator)) continue + val rel = src.relativeTo(nested).path.replace(File.separatorChar, '/') + val key = prefix + rel + val entry = base[key] ?: continue + val dest = File(dir, rel) + val destCanon = runCatching { dest.canonicalFile }.getOrNull() ?: continue + if (!destCanon.path.startsWith(root.path + File.separator)) continue + if (dest.isFile && runCatching { md5(dest) == md5(src) }.getOrDefault(false)) { + if (!src.delete()) { failed++; continue } + cleaned++ + next[rel] = entry + next.remove(key) + continue + } + if (dest.exists()) continue + val copied = runCatching { + dest.parentFile?.mkdirs() + val tmp = File.createTempFile(dest.name + ".", ".migrate", dest.parentFile) + try { + src.copyTo(tmp, overwrite = true) + if (md5(tmp) != md5(src)) false + else { + java.nio.file.Files.move(tmp.toPath(), dest.toPath(), java.nio.file.StandardCopyOption.ATOMIC_MOVE) + true + } + } finally { + if (tmp.exists()) tmp.delete() + } + }.getOrDefault(false) + if (!copied) { failed++; continue } + if (!src.delete()) { failed++; continue } + moved++ + next[rel] = entry + next.remove(key) + } + if (nested.walkTopDown().none { it.isFile }) nested.deleteRecursively() + return Migration(next, moved, cleaned, failed) + } + + private fun migrateGogNested(context: Context, id: String, folder: File, dir: File) { + val p = GogPrefs.get(context) + // Run once per game: after this the flat layout is authoritative, even if the game itself + // keeps a folder named like the cloud location. + if (p.getInt("cloud_layout_$id", 0) >= CLOUD_LAYOUT) return + val name = gogLocationName(context, id, folder) ?: return + val base = baseline(context, Store.GOG, id).orEmpty() + val result = migrateNested(dir, name, base) + if (result != null) { + if (result.baseline != base) saveBaseline(context, Store.GOG, id, result.baseline) + if (result.moved + result.cleaned > 0) { + StoresState.logLine("cloud gog $id migrate result=ok moved=${result.moved} cleaned=${result.cleaned} from=$name") + } + } + if (result == null || result.failed == 0) p.edit().putInt("cloud_layout_$id", CLOUD_LAYOUT).apply() + } + fun conflicts(context: Context, store: Store, id: String): List<String> = prefs(context, store).getString("cloud_conflicts_$id", "").orEmpty().split('\n').filter { it.isNotEmpty() } @@ -180,6 +313,7 @@ object CloudSaves { !g.second.cloud -> Result("skipped", 0, 0, "off") else -> { val (dir, reason) = saveFolder(context, store, id) + if (dir != null && store == Store.GOG) migrateGogNested(context, id, g.first, dir) val cloud = if (dir != null) transport(context, store, id, g.first) else null when { dir == null -> Result("skipped", 0, 0, reason) @@ -189,7 +323,7 @@ object CloudSaves { } } } catch (e: NoCloudSaves) { - prefs(context, store).edit().putString("cloud_template_$id", "").apply() + rememberNoCloud(prefs(context, store), id) Result("skipped", 0, 0, "no-cloud-saves") } catch (e: Exception) { Result("failed", 0, 0, e.javaClass.simpleName) diff --git a/app/src/main/java/com/droiddeck/launcher/stores/CloudTransport.kt b/app/src/main/java/com/droiddeck/launcher/stores/CloudTransport.kt index 33c34196f..9c32bd4c1 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/CloudTransport.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/CloudTransport.kt @@ -7,6 +7,8 @@ import java.net.URLEncoder import java.text.SimpleDateFormat import java.util.Locale import java.util.TimeZone +import java.util.zip.CRC32 +import java.util.zip.Deflater import org.json.JSONArray import org.json.JSONObject @@ -38,25 +40,101 @@ private fun httpDate(s: String?): Long = try { SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US).apply { timeZone = TimeZone.getTimeZone("GMT") }.parse(s!!.trim())?.time ?: -1L } catch (e: Exception) { -1L } +internal fun isGzip(data: ByteArray) = + data.size >= 10 && data[0] == 0x1f.toByte() && data[1] == 0x8b.toByte() && data[2] == 0x08.toByte() + +internal fun gunzip(data: ByteArray): ByteArray? = try { + java.util.zip.GZIPInputStream(data.inputStream()).use { it.readBytes() } +} catch (e: Exception) { null } + +/** Same gzip GOG uses on PUT: deflate level 6, mtime 0, OS Unix. */ +internal fun gzipGogPut(data: ByteArray): ByteArray { + val deflater = Deflater(6, true) + val raw = ByteArrayOutputStream() + try { + deflater.setInput(data) + deflater.finish() + val buf = ByteArray(512) + while (!deflater.finished()) { + val n = deflater.deflate(buf) + if (n > 0) raw.write(buf, 0, n) + } + } finally { + deflater.end() + } + val crc = CRC32().also { it.update(data) } + return ByteArrayOutputStream(18 + raw.size()).apply { + write(byteArrayOf(0x1f, 0x8b.toByte(), 8, 0, 0, 0, 0, 0, 0, 3)) + write(raw.toByteArray()) + writeIntLe(crc.value.toInt()) + writeIntLe(data.size) + }.toByteArray() +} + +private fun ByteArrayOutputStream.writeIntLe(v: Int) { + write(v and 0xff) + write(v ushr 8 and 0xff) + write(v ushr 16 and 0xff) + write(v ushr 24 and 0xff) +} + +internal fun gogDecodeCloudBody(data: ByteArray, contentEncoding: String?): ByteArray { + val encodings = contentEncoding.orEmpty().split(',').map { it.trim().lowercase() } + if ("gzip" in encodings || "x-gzip" in encodings) { + if (!isGzip(data)) return data + return gunzip(data) ?: throw java.io.IOException("Invalid GOG cloud gzip body") + } + if (!isGzip(data)) return data + val inflated = gunzip(data) ?: return data + return if (gzipGogPut(inflated).contentEquals(data)) inflated else data +} + internal fun isoDate(s: String?): Long { - if (s == null || s.length < 19) return -1L - return try { - java.util.Calendar.getInstance(TimeZone.getTimeZone("UTC")).apply { - clear() - set(s.substring(0, 4).toInt(), s.substring(5, 7).toInt() - 1, s.substring(8, 10).toInt(), - s.substring(11, 13).toInt(), s.substring(14, 16).toInt(), s.substring(17, 19).toInt()) - }.timeInMillis - } catch (e: Exception) { -1L } + if (s == null) return -1L + return runCatching { java.time.OffsetDateTime.parse(s).toInstant().toEpochMilli() } + .recoverCatching { java.time.LocalDateTime.parse(s).toInstant(java.time.ZoneOffset.UTC).toEpochMilli() } + .getOrDefault(-1L) } class NoCloudSaves : Exception("no cloud saves") -class GogCloud(private val userId: String, private val clientId: String, private val token: String) : CloudTransport { +/** GOG cloud names are `<location name>/<path under that location>`; the name is not part of the folder. */ +internal fun splitGogName(name: String): Pair<String?, String> { + val slash = name.indexOf('/') + return if (slash <= 0 || slash == name.length - 1) null to name + else name.substring(0, slash) to name.substring(slash + 1) +} + +/** Maps cloud names to paths under the selected location's folder; the chosen location name is returned. */ +internal fun gogRelativeNames(files: List<CloudFile>, location: String?): Pair<String?, List<CloudFile>> { + val locations = files.mapNotNull { splitGogName(it.name).first }.distinct() + // Only infer a location when every name is prefixed; a mixed listing keeps its old shape. + val allPrefixed = files.isNotEmpty() && files.all { splitGogName(it.name).first != null } + val chosen = when { + location != null && (location in locations || allPrefixed) -> location + location == null && allPrefixed -> locations.singleOrNull() + else -> null + } ?: return null to files + return chosen to files.mapNotNull { file -> + val (dir, rel) = splitGogName(file.name) + if (dir == chosen) CloudFile(rel, file.modifiedMs, file.md5, file.readLink) else null + } +} + +class GogCloud( + private val userId: String, + private val clientId: String, + private val token: String, + private val location: String? = null, +) : CloudTransport { private val base = "https://cloudstorage.gog.com/v1/$userId/$clientId" private val ua = "GOG Galaxy" + private var prefix: String? = location?.let { "$it/" } private fun key(name: String) = URLEncoder.encode(name, "UTF-8").replace("+", "%20") + private fun cloudKey(name: String) = key((prefix ?: "") + name) + override fun list(): List<CloudFile> { val c = open(base, "GET", token, ua) val code = c.responseCode @@ -70,20 +148,38 @@ class GogCloud(private val userId: String, private val clientId: String, private val text = c.body().decodeToString().trim() c.disconnect() if (text.isEmpty() || text == "[]") return emptyList() - if (text.startsWith("[")) { + val raw = if (text.startsWith("[")) { val a = JSONArray(text) - return (0 until a.length()).mapNotNull { a.optJSONObject(it) }.mapNotNull { o -> + (0 until a.length()).mapNotNull { a.optJSONObject(it) }.mapNotNull { o -> val n = o.optString("name").ifEmpty { return@mapNotNull null } val lm = o.optLong("last_modified", 0L) - CloudFile(n, if (lm > 1_000_000_000_000L) lm else if (lm > 0) lm * 1000 else -1L) + val iso = isoDate(o.optString("last_modified").takeIf { it.isNotEmpty() }) + CloudFile(n, when { + lm > 1_000_000_000_000L -> lm + lm > 0 -> lm * 1000 + iso > 0 -> iso + else -> -1L + }) } + } else { + text.lines().map { it.trim() }.filter { it.isNotEmpty() }.map { CloudFile(it, -1L) } } - return text.lines().map { it.trim() }.filter { it.isNotEmpty() }.map { CloudFile(it, -1L) } + return relative(raw) + } + + /** + * The API names every file `<location>/<path>`. The location's folder is the sync root, so the + * first segment is dropped. Older builds kept it, writing saves one folder too deep. + */ + private fun relative(raw: List<CloudFile>): List<CloudFile> { + val (chosen, files) = gogRelativeNames(raw, location) + prefix = chosen?.let { "$it/" } + return files } override fun details(file: CloudFile) { if (file.modifiedMs > 0 && file.md5 != null) return - val c = open("$base/${key(file.name)}", "HEAD", token, ua) + val c = open("$base/${cloudKey(file.name)}", "HEAD", token, ua) if (c.responseCode in 200..299) { httpDate(c.getHeaderField("Last-Modified")).takeIf { it > 0 }?.let { file.modifiedMs = it } c.getHeaderField("ETag")?.replace("\"", "")?.trim()?.ifEmpty { null }?.let { file.md5 = it } @@ -92,14 +188,18 @@ class GogCloud(private val userId: String, private val clientId: String, private } override fun get(file: CloudFile): Pair<ByteArray, Long> { - val c = open("$base/${key(file.name)}", "GET", token, ua) + val c = open("$base/${cloudKey(file.name)}", "GET", token, ua) + c.setRequestProperty("Accept-Encoding", "identity") if (c.responseCode !in 200..299) { val code = c.responseCode; c.disconnect(); throw java.io.IOException("GOG cloud get HTTP $code") } val modified = httpDate(c.getHeaderField("Last-Modified")) - return c.body().also { c.disconnect() } to modified + val encoding = c.getHeaderField("Content-Encoding") + val data = c.body() + c.disconnect() + return gogDecodeCloudBody(data, encoding) to modified } override fun put(files: Map<String, ByteArray>): Map<String, Long> = files.mapValues { (name, data) -> - val c = open("$base/${key(name)}", "PUT", token, ua) + val c = open("$base/${cloudKey(name)}", "PUT", token, ua) c.doOutput = true c.setRequestProperty("Content-Type", "application/octet-stream") c.setFixedLengthStreamingMode(data.size) diff --git a/app/src/main/java/com/droiddeck/launcher/stores/CredentialCipher.kt b/app/src/main/java/com/droiddeck/launcher/stores/CredentialCipher.kt index aa0748423..01c7eebcf 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/CredentialCipher.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/CredentialCipher.kt @@ -1,6 +1,5 @@ package com.droiddeck.launcher.stores -import android.os.Build import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties import android.util.Base64 @@ -13,6 +12,8 @@ import javax.crypto.spec.GCMParameterSpec object CredentialCipher { const val VERSION = 1 + /** Sealed with the hardware-keystore (TEE) key; [VERSION] envelopes keep opening with the older, possibly StrongBox, key. */ + const val VERSION_TEE = 2 const val ALG = "AES/GCM" private const val TRANSFORMATION = "AES/GCM/NoPadding" private const val TAG_BITS = 128 @@ -21,6 +22,11 @@ object CredentialCipher { fun key(): SecretKey fun existing(): SecretKey = key() + + /** Key for [VERSION] envelopes written before [VERSION_TEE]. */ + fun legacy(): SecretKey = existing() + + val version: Int get() = VERSION } class KeyMissing : java.security.GeneralSecurityException("no key") @@ -29,33 +35,33 @@ object CredentialCipher { e is android.security.keystore.KeyPermanentlyInvalidatedException || e is KeyMissing || e is org.json.JSONException || e is IllegalArgumentException + // StrongBox took over 10 s per Xbox credential write on a tester's phone, past the backend's + // 10 s reply timeout, so new writes use a TEE key. The old alias stays readable. object Keystore : KeyProvider { - private const val ALIAS = "droiddeck-store-credentials" + private const val LEGACY_ALIAS = "droiddeck-store-credentials" + private const val ALIAS = "droiddeck-store-credentials-tee" private const val PROVIDER = "AndroidKeyStore" - @Synchronized - override fun existing(): SecretKey { - val ks = KeyStore.getInstance(PROVIDER).apply { load(null) } - return ks.getKey(ALIAS, null) as? SecretKey ?: throw KeyMissing() - } + override val version: Int get() = VERSION_TEE @Synchronized - override fun key(): SecretKey { - val ks = KeyStore.getInstance(PROVIDER).apply { load(null) } - (ks.getKey(ALIAS, null) as? SecretKey)?.let { return it } - if (Build.VERSION.SDK_INT >= 28) { - try { return generate(strongBox = true) } catch (e: Exception) { } - } - return generate(strongBox = false) - } + override fun existing(): SecretKey = stored(ALIAS) ?: throw KeyMissing() - private fun generate(strongBox: Boolean): SecretKey { + @Synchronized + override fun legacy(): SecretKey = stored(LEGACY_ALIAS) ?: throw KeyMissing() + + @Synchronized + override fun key(): SecretKey = stored(ALIAS) ?: generate() + + private fun stored(alias: String): SecretKey? = + KeyStore.getInstance(PROVIDER).apply { load(null) }.getKey(alias, null) as? SecretKey + + private fun generate(): SecretKey { val spec = KeyGenParameterSpec.Builder(ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT) .setKeySize(256) .setBlockModes(KeyProperties.BLOCK_MODE_GCM) .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) .setUserAuthenticationRequired(false) - .apply { if (strongBox && Build.VERSION.SDK_INT >= 28) setIsStrongBoxBacked(true) } .build() return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, PROVIDER).run { init(spec); generateKey() } } @@ -64,15 +70,16 @@ object CredentialCipher { fun seal(plain: String, keys: KeyProvider): JSONObject { val cipher = Cipher.getInstance(TRANSFORMATION).apply { init(Cipher.ENCRYPT_MODE, keys.key()) } val ct = cipher.doFinal(plain.toByteArray(Charsets.UTF_8)) - return JSONObject().put("v", VERSION).put("alg", ALG) + return JSONObject().put("v", keys.version).put("alg", ALG) .put("iv", b64(cipher.iv)).put("ct", b64(ct)) } fun open(envelope: JSONObject, keys: KeyProvider): String { - require(envelope.optInt("v", -1) == VERSION && envelope.optString("alg") == ALG) { "unknown envelope" } + val version = envelope.optInt("v", -1) + require((version == VERSION || version == VERSION_TEE) && envelope.optString("alg") == ALG) { "unknown envelope" } val iv = Base64.decode(envelope.getString("iv"), Base64.NO_WRAP) val ct = Base64.decode(envelope.getString("ct"), Base64.NO_WRAP) - val cipher = Cipher.getInstance(TRANSFORMATION).apply { init(Cipher.DECRYPT_MODE, keys.existing(), GCMParameterSpec(TAG_BITS, iv)) } + val cipher = Cipher.getInstance(TRANSFORMATION).apply { init(Cipher.DECRYPT_MODE, if (version == VERSION) keys.legacy() else keys.existing(), GCMParameterSpec(TAG_BITS, iv)) } return String(cipher.doFinal(ct), Charsets.UTF_8) } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/LoginFlows.kt b/app/src/main/java/com/droiddeck/launcher/stores/LoginFlows.kt index bf9d5f6dc..79ca0f849 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/LoginFlows.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/LoginFlows.kt @@ -8,19 +8,18 @@ import com.droiddeck.launcher.stores.gog.GogAuth internal object LoginFlows { private const val TAG = "StoreLogin" - fun forStore(store: Store, state: String): StoreLoginActivity.Flow? = when (store) { - Store.GOG -> GogFlow(state) + fun forStore(store: Store, state: String, browserUserAgent: String): StoreLoginActivity.Flow? = when (store) { + Store.GOG -> GogFlow(state, browserUserAgent) Store.EPIC -> epicFlow() Store.AMAZON -> amazonFlow() - Store.ITCH, Store.XBOX -> null + Store.ITCH, Store.XBOX, Store.BATTLENET -> null } @Volatile var epicFlow: () -> StoreLoginActivity.Flow? = { null } @Volatile var amazonFlow: () -> StoreLoginActivity.Flow? = { null } - private class GogFlow(private val state: String) : StoreLoginActivity.Flow { + private class GogFlow(private val state: String, override val userAgent: String) : StoreLoginActivity.Flow { override val startUrl: String get() = GogAuth.authUrl(state) - override val userAgent: String get() = GogAuth.GALAXY_UA override fun isRedirect(url: String): Boolean = url.startsWith(GogAuth.REDIRECT_PREFIX) override fun forgetSignIn(done: () -> Unit) { diff --git a/app/src/main/java/com/droiddeck/launcher/stores/SteamLiveShortcuts.kt b/app/src/main/java/com/droiddeck/launcher/stores/SteamLiveShortcuts.kt index 345f4da32..0cc03b702 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/SteamLiveShortcuts.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/SteamLiveShortcuts.kt @@ -23,14 +23,14 @@ object SteamLiveShortcuts { val wanted = games.filter(include) if (wanted.isEmpty() || !clientRunning()) return val list = JSONArray() - for (g in wanted) list.put(JSONObject().put("name", g.name).put("exe", g.guestExe).put("dir", g.guestDir).put("appid", g.appId and 0xFFFFFFFFL)) + for (g in wanted) list.put(JSONObject().put("name", g.name).put("exe", g.guestExe).put("dir", g.guestDir).put("appid", g.appId and 0xFFFFFFFFL).put("launch", g.launchOptions)) val js = """ (async () => { const want = ${list}; let added = 0; for (const w of want) { if (await ${KNOWN}(w.appid, w.name, w.exe)) continue; - if (await ${ADD}(w.name, w.exe, w.dir, true)) added++; + if (await ${ADD}(w.name, w.exe, w.dir, true, ${JSONObject.quote(compatTool(context))}, w.launch)) added++; } return added; })() @@ -46,7 +46,7 @@ object SteamLiveShortcuts { (async () => { const known = await ${KNOWN}(${appId?.and(0xFFFFFFFFL) ?: 0}, ${JSONObject.quote(name)}, ${JSONObject.quote(exe)}); if (known) return known; - return await ${ADD}(${JSONObject.quote(name)}, ${JSONObject.quote(exe)}, ${JSONObject.quote(dir)}, $compat); + return await ${ADD}(${JSONObject.quote(name)}, ${JSONObject.quote(exe)}, ${JSONObject.quote(dir)}, $compat, ${JSONObject.quote(compatTool(context))}); })() """.trimIndent() val id = evaluate(context, js)?.toLongOrNull()?.and(0xFFFFFFFFL)?.takeIf { it != 0L } ?: return null @@ -161,12 +161,15 @@ object SteamLiveShortcuts { return 0; })""" - private val ADD = """(async (name, exe, dir, compat) => { - const id = await SteamClient.Apps.AddShortcut(name, exe, dir, ""); + private fun compatTool(context: Context): String = + com.droiddeck.launcher.session.ProtonDefault.state(context).default ?: COMPAT_TOOL + + private val ADD = """(async (name, exe, dir, compat, tool, launch) => { + const id = await SteamClient.Apps.AddShortcut(name, exe, dir, launch || ""); if (!id) return 0; try { SteamClient.Apps.SetShortcutName(id, name); } catch (e) {} try { SteamClient.Apps.AddUserTagToApps([id], ${JSONObject.quote(TAG_NAME)}); } catch (e) {} - if (compat) { try { SteamClient.Apps.SpecifyCompatTool(id, ${JSONObject.quote(COMPAT_TOOL)}); } catch (e) {} } + if (compat) { try { SteamClient.Apps.SpecifyCompatTool(id, tool); } catch (e) {} } for (let i = 0; i < 40; i++) { try { if (globalThis.appStore.GetAppOverviewByAppID(id)) break; } catch (e) {} await new Promise(r => setTimeout(r, 250)); diff --git a/app/src/main/java/com/droiddeck/launcher/stores/Store.kt b/app/src/main/java/com/droiddeck/launcher/stores/Store.kt index b5e0d1fbf..54476b6e1 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/Store.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/Store.kt @@ -5,7 +5,8 @@ enum class Store(val id: String, val label: String, val shortLabel: String) { EPIC("epic", "Epic Games", "Epic"), AMAZON("amazon", "Amazon Games", "Amazon"), XBOX("xbox", "Xbox", "Xbox"), - ITCH("itch", "itch.io", "itch"); + ITCH("itch", "itch.io", "itch"), + BATTLENET("battlenet", "Battle.net", "Battle.net"); companion object { fun byId(id: String?): Store? = entries.firstOrNull { it.id == id } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreAccounts.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreAccounts.kt index 27bf133f1..f69380f7c 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreAccounts.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreAccounts.kt @@ -90,10 +90,25 @@ object StoreAccounts { fun signedInAs(context: Context, store: Store): String? { val json = read(context, store) ?: return if (isUnavailable(store)) store.label else null if (store == Store.XBOX) return json.optString("username").takeIf { it.isNotBlank() } + if (store == Store.BATTLENET) return json.optString("display_name").takeIf { it.isNotBlank() } if (json.optString("access_token", "").isEmpty()) return null return json.optString("display_name", "").ifEmpty { json.optString("username", "") }.ifEmpty { store.label } } + /** The name the Stores page shows. Xbox shows the gamertag, else the sign-in email with its name part masked. */ + fun displayName(context: Context, store: Store): String? { + if (store != Store.XBOX) return signedInAs(context, store) + val json = read(context, store) ?: return if (isUnavailable(store)) store.label else null + val signedIn = json.optString("username").takeIf { it.isNotBlank() } ?: return null + json.optString("gamertag").takeIf { it.isNotBlank() }?.let { return it } + return maskEmail(signedIn) + } + + internal fun maskEmail(name: String): String { + val at = name.indexOf('@') + return if (at <= 0) name.take(1) + "•••" else name.take(1) + "•••" + name.substring(at) + } + private fun seal(f: File, json: JSONObject, retry: Boolean = false): File? { if (keystoreFailed && !retry) return null val text = json.toString() diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreArt.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreArt.kt index 4b2b0b803..14415fd9c 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreArt.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreArt.kt @@ -14,13 +14,45 @@ object StoreArt { fun fetchInto(folder: File, sidecar: StoreGameSidecar) { sidecar.cover?.let { place(it, File(folder, "cover.jpg")) } - val wide = sidecar.hero ?: return - if (place(wide, File(folder, "hero.jpg"))) { - val header = File(folder, "header.jpg") - if (!header.isFile) runCatching { File(folder, "hero.jpg").copyTo(header, overwrite = true) } + sidecar.hero?.let { wide -> + if (place(wide, File(folder, "hero.jpg"))) { + val header = File(folder, "header.jpg") + if (!header.isFile) runCatching { File(folder, "hero.jpg").copyTo(header, overwrite = true) } + } + } + sidecar.logo?.let { fetchLogo(folder, it) } + } + + fun fetchLogo(folder: File, url: String): Boolean { + val dst = File(folder, LOGO) + if (dst.isFile && dst.length() > 0) return true + return writeLogo(get(url) ?: return false, dst) + } + + /** A logo is drawn over the hero, so it stays a PNG with its transparency instead of going through [place]. */ + internal fun writeLogo(bytes: ByteArray, dst: File): Boolean { + if (dst.isFile && dst.length() > 0) return true + val bitmap = runCatching { BitmapFactory.decodeByteArray(bytes, 0, bytes.size) }.getOrNull() ?: return false + val tmp = File(dst.path + ".tmp") + return try { + if (bitmap.width <= 0 || bitmap.height <= 0) return false + if (isPng(bytes)) tmp.writeBytes(bytes) else tmp.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } + tmp.renameTo(dst) + } catch (e: Exception) { + Log.w(TAG, "${dst.name}: ${e.javaClass.simpleName}") + false + } finally { + tmp.delete() + bitmap.recycle() } } + private val PNG_MAGIC = byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A) + + private fun isPng(bytes: ByteArray): Boolean = bytes.size > PNG_MAGIC.size && PNG_MAGIC.indices.all { bytes[it] == PNG_MAGIC[it] } + + const val LOGO = "logo.png" + private fun place(url: String, dst: File): Boolean { if (dst.isFile && dst.length() > 0) return true val bytes = get(url) ?: return false diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreCatalog.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreCatalog.kt index debaf4ad0..967e56ca7 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreCatalog.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreCatalog.kt @@ -23,6 +23,7 @@ data class CatalogItem( val mature: Boolean = false, val subscription: Boolean = false, val includedWith: String = "", + val logoUrl: String? = null, ) { val accessible: Boolean get() = owned || subscription /** Licensed now, or a supported package the account's subscription can install. */ @@ -60,33 +61,38 @@ fun installedCards(store: Store, installed: List<InstalledStoreGame>, library: L }.distinctBy { it.id }.sortedBy { it.title.lowercase() } } -private fun releaseDateValue(item: CatalogItem): Long = - item.releaseDate.take(10).takeIf { it.length == 10 }?.let { raw -> - runCatching { java.time.LocalDate.parse(raw).toEpochDay() }.getOrDefault(0L) +private fun releaseDateValue(raw: String): Long = + raw.take(10).takeIf { it.length == 10 }?.let { day -> + runCatching { java.time.LocalDate.parse(day).toEpochDay() }.getOrDefault(0L) } ?: 0L -fun sortCatalogItems( - items: List<CatalogItem>, +fun <T> sortGames( + items: List<T>, sort: String, - lastPlayed: (CatalogItem) -> Long = { 0L }, - installedAt: (CatalogItem) -> Long = { 0L }, - size: (CatalogItem) -> Long = { it.sizeBytes }, -): List<CatalogItem> = when (sort) { - "lastPlayed" -> items.sortedWith(compareByDescending(lastPlayed).thenBy { it.title.lowercase() }) - "recentlyInstalled" -> items.sortedWith(compareByDescending(installedAt).thenBy { it.title.lowercase() }) - "titleAsc" -> items.sortedBy { it.title.lowercase() } - "titleDesc" -> items.sortedByDescending { it.title.lowercase() } - "sizeDesc" -> items.sortedWith(compareByDescending(size).thenBy { it.title.lowercase() }) - "sizeAsc" -> items.sortedWith(compareBy<CatalogItem> { size(it).takeIf { b -> b > 0 } ?: Long.MAX_VALUE }.thenBy { it.title.lowercase() }) - "releaseDateDesc" -> items.sortedWith(compareByDescending(::releaseDateValue).thenBy { it.title.lowercase() }) - else -> items + title: (T) -> String, + releaseDate: (T) -> String, + lastPlayed: (T) -> Long = { 0L }, + installedAt: (T) -> Long = { 0L }, + size: (T) -> Long = { 0L }, +): List<T> { + val byTitle = compareBy<T> { title(it).lowercase() } + return when (sort) { + "lastPlayed" -> items.sortedWith(compareByDescending(lastPlayed).then(byTitle)) + "recentlyInstalled" -> items.sortedWith(compareByDescending(installedAt).then(byTitle)) + "titleAsc" -> items.sortedWith(byTitle) + "titleDesc" -> items.sortedWith(byTitle.reversed()) + "sizeDesc" -> items.sortedWith(compareByDescending(size).then(byTitle)) + "sizeAsc" -> items.sortedWith(compareBy<T> { size(it).takeIf { b -> b > 0 } ?: Long.MAX_VALUE }.then(byTitle)) + "releaseDateDesc" -> items.sortedWith(compareByDescending<T> { releaseDateValue(releaseDate(it)) }.then(byTitle)) + else -> items + } } -internal fun retainCatalogOrder(items: List<CatalogItem>, previous: List<CatalogItem>): List<CatalogItem> { +internal fun <T> retainOrder(items: List<T>, previous: List<T>, key: (T) -> String): List<T> { if (items.size != previous.size) return items - val byKey = items.associateBy { it.key } - if (previous.any { it.key !in byKey }) return items - return previous.map { byKey.getValue(it.key) } + val byKey = items.associateBy(key) + if (previous.any { key(it) !in byKey }) return items + return previous.map { byKey.getValue(key(it)) } } /** A detail page opens for search results too; the library wins when it already knows the product. */ diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreGameSidecar.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreGameSidecar.kt index 25850f747..aa42aa75e 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreGameSidecar.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreGameSidecar.kt @@ -22,6 +22,7 @@ class StoreGameSidecar( val epic: EpicOptions = EpicOptions(), val cloud: Boolean = true, val achievements: Boolean = true, + val logo: String? = null, ) { val isInstalled: Boolean get() = state == STATE_INSTALLED @@ -33,7 +34,8 @@ class StoreGameSidecar( env: Map<String, String> = this.env, installVersion: String = this.installVersion, installedAt: Long = this.installedAt, cover: String? = this.cover, hero: String? = this.hero, extra: Map<String, String> = this.extra, state: String = this.state, epic: EpicOptions = this.epic, cloud: Boolean = this.cloud, achievements: Boolean = this.achievements, - ) = StoreGameSidecar(store, id, title, exe, launcher, args, env, installVersion, installedAt, cover, hero, extra, state, epic, cloud, achievements) + logo: String? = this.logo, + ) = StoreGameSidecar(store, id, title, exe, launcher, args, env, installVersion, installedAt, cover, hero, extra, state, epic, cloud, achievements, logo) fun toJson(): JSONObject = JSONObject().apply { put("version", VERSION) @@ -52,6 +54,7 @@ class StoreGameSidecar( if (installedAt > 0L) put("installedAt", installedAt) if (!cover.isNullOrEmpty()) put("cover", cover) if (!hero.isNullOrEmpty()) put("hero", hero) + if (!logo.isNullOrEmpty()) put("logo", logo) if (extra.isNotEmpty()) put("extra", JSONObject(extra as Map<*, *>)) } @@ -129,6 +132,7 @@ class StoreGameSidecar( epic = o.optJSONObject("epic")?.let { e -> EpicOptions(eos = e.optBoolean("eos", true), offline = e.optBoolean("offline", false)) } ?: EpicOptions(), cloud = o.optBoolean("cloud", true), achievements = o.optBoolean("achievements", true), + logo = o.optString("logo", "").ifEmpty { null }, ) } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreInstallRoot.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreInstallRoot.kt index 611b7f3db..c4faac54e 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreInstallRoot.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreInstallRoot.kt @@ -5,6 +5,7 @@ import android.os.StatFs import android.os.storage.StorageManager import com.droiddeck.launcher.runtime.LinuxRuntime import com.droiddeck.launcher.session.GameStorage +import com.droiddeck.launcher.session.SessionPrefs import java.io.File import java.util.zip.CRC32 @@ -76,34 +77,118 @@ object StoreInstallRoot { fun targets(context: Context): List<Target> { val internal = Target(internalRoot(context), context.getString(com.droiddeck.launcher.R.string.games_internal), removable = false) + val custom = customTarget(context) val cards = GameStorage.options(context).map { Target(File(it.path, ON_VOLUME), it.name, removable = true) } val library = GameStorage.effective(context)?.let { lib -> val root = File(lib.path, ON_VOLUME) if (cards.any { it.root.absolutePath == root.absolutePath }) return@let null - val volume = runCatching { context.getSystemService(StorageManager::class.java)?.getStorageVolume(File(lib.path)) }.getOrNull() - val name = File(lib.path).name.ifBlank { lib.label } - val where = volume?.takeUnless { it.isPrimary }?.getDescription(context) - val label = if (where.isNullOrBlank()) name else context.getString(com.droiddeck.launcher.R.string.gstore_folder_on_volume, name, where) - Target(root, label, removable = true, folder = true) + Target(root, folderLabel(context, File(lib.path)), removable = true, folder = true) } - return listOf(internal) + cards + listOfNotNull(library) + return (listOf(internal) + listOfNotNull(custom) + cards + listOfNotNull(library)) + .distinctBy { it.root.absolutePath } + } + + fun defaultStorageFolder(context: Context): File? = + SessionPrefs.defaultStorageFolder(context).takeIf { it.isNotBlank() }?.let(::File) + + fun customTarget(context: Context): Target? { + val folder = defaultStorageFolder(context) ?: return null + // Writes are probed when the folder is chosen; File.canWrite() is unreliable on FUSE volumes. + if (!folder.isDirectory) return null + val label = SessionPrefs.defaultStorageFolderLabel(context).ifBlank { folderLabel(context, folder) } + return Target(File(folder, ON_VOLUME), label, removable = true, folder = true) + } + + fun detectedExternalTarget(context: Context): Target? { + val custom = defaultStorageFolder(context)?.let { File(it, ON_VOLUME) }?.absolutePath + return externalTarget(targets(context).filter { it.root.absolutePath != custom }, internalRoot(context)) } fun installRoot(context: Context): File = internalRoot(context) + fun externalTarget(context: Context): Target? = + if (SessionPrefs.defaultStorageFolder(context).isNotBlank()) customTarget(context) + else detectedExternalTarget(context) + + internal fun externalTarget(targets: List<Target>, internalRoot: File): Target? = + targets.firstOrNull { it.folder && it.root.absolutePath != internalRoot.absolutePath } + ?: targets.firstOrNull { it.removable && it.root.absolutePath != internalRoot.absolutePath } + + fun defaultTarget(context: Context): Target? = when (SessionPrefs.defaultStorage(context)) { + SessionPrefs.DEFAULT_STORAGE_INTERNAL -> Target(internalRoot(context), context.getString(com.droiddeck.launcher.R.string.games_internal), removable = false) + SessionPrefs.DEFAULT_STORAGE_EXTERNAL -> externalTarget(context) + else -> null + } + + fun libraryRoot(context: Context): File? = GameStorage.effective(context)?.let { File(it.path, ON_VOLUME) } + fun roots(context: Context): List<File> { val cards = GameStorage.options(context).map { File(it.path, ON_VOLUME) } + val custom = defaultStorageFolder(context)?.takeIf { it.isDirectory }?.let { File(it, ON_VOLUME) } val library = GameStorage.effective(context)?.let { File(it.path, ON_VOLUME) } - return (listOf(internalRoot(context)) + cards + listOfNotNull(library)).distinctBy { it.absolutePath } + return (listOf(internalRoot(context)) + cards + listOfNotNull(custom) + listOfNotNull(library)) + .distinctBy { it.absolutePath } } fun externalRoots(context: Context): List<Pair<File, String>> { val library = GameStorage.effective(context)?.path + val used = mutableSetOf<String>() return roots(context) .filter { !isInternal(context, it) && (library == null || !it.absolutePath.startsWith("$library/")) } - .map { it to guestFor(context, it) } + .map { root -> + val base = guestFor(context, root) + val guest = if (used.add(base)) base else { + val suffix = "%08x".format(CRC32().apply { update(root.absolutePath.toByteArray()) }.value) + "$base-$suffix".also { used.add(it) } + } + root to guest + } } + fun folderLabel(context: Context, folder: File): String { + val name = folder.name.ifBlank { context.getString(com.droiddeck.launcher.R.string.gstore_folder) } + val volume = runCatching { + context.getSystemService(StorageManager::class.java)?.getStorageVolume(folder)?.getDescription(context) + }.getOrNull() + return if (volume.isNullOrBlank() || volume.equals("android", true) || volume.equals(name, true)) name + else context.getString(com.droiddeck.launcher.R.string.gstore_folder_on_volume, name, volume) + } + + fun prepareDefaultFolder(context: Context, folder: File): String? { + if (!folder.isDirectory) return context.getString(com.droiddeck.launcher.R.string.gstore_cannot_create, folder.path) + val root = File(folder, ON_VOLUME) + if (!root.isDirectory && !root.mkdirs()) return context.getString(com.droiddeck.launcher.R.string.gstore_cannot_create, folder.path) + return try { + File.createTempFile(".droiddeck-write-probe", null, root).delete() + null + } catch (e: Exception) { + context.getString(com.droiddeck.launcher.R.string.gstore_cannot_write_detail, root.path, e.message) + } + } + + /** Xodus has its own PRoot mount table and cannot see the selected Steam library's SD bind. */ + fun externalRootsForXodus(context: Context): List<Pair<File, String>> { + val roots = externalRoots(context).toMutableList() + val selectedLibrary = GameStorage.effective(context)?.let { File(it.path, ON_VOLUME) } + ?.takeUnless { isInternal(context, it) } + if (selectedLibrary != null && roots.none { it.first.absolutePath == selectedLibrary.absolutePath }) { + roots += selectedLibrary to guestFor(context, selectedLibrary) + } + return roots + } + + fun externalBindSpecs(context: Context): List<String> = externalBindSpecs(externalRoots(context), LinuxRuntime.rootDir(context)) + + fun externalBindSpecsForXodus(context: Context): List<String> = + externalBindSpecs(externalRootsForXodus(context), LinuxRuntime.rootDir(context)) + + internal fun externalBindSpecs(roots: List<Pair<File, String>>, guestRoot: File): List<String> = + roots.mapNotNull { (host, guest) -> + if (!host.isDirectory || !host.canRead()) return@mapNotNull null + val target = File(guestRoot, guest.removePrefix("/")) + if (target.isDirectory || target.mkdirs()) "${host.path}:$guest" else null + } + private fun guestFor(context: Context, root: File): String { val sm = context.getSystemService(StorageManager::class.java) val uuid = runCatching { sm?.getStorageVolume(root)?.uuid }.getOrNull()?.takeIf { it.isNotBlank() }?.replace(Regex("[^A-Za-z0-9-]"), "") @@ -114,11 +199,21 @@ object StoreInstallRoot { fun isInternal(context: Context, root: File): Boolean = root.absolutePath == internalRoot(context).absolutePath fun externalGuestPath(context: Context, host: File): String? { + return externalGuestPath(host, externalRoots(context)) + } + + fun guestPath(context: Context, host: File): String = externalGuestPath(context, host) ?: host.path + + fun guestPathForXodus(context: Context, host: File): String = + externalGuestPath(host, externalRootsForXodus(context)) ?: host.path + + internal fun externalGuestPath(host: File, roots: List<Pair<File, String>>): String? { val path = host.absolutePath - for ((root, guest) in externalRoots(context)) { - val dir = root.absolutePath + for ((root, guest) in roots.sortedByDescending { it.first.absolutePath.length }) { + val dir = root.absolutePath.trimEnd(File.separatorChar) if (path == dir) return guest - if (path.startsWith("$dir/")) return guest + "/" + path.removePrefix("$dir/") + val prefix = dir + File.separator + if (path.startsWith(prefix)) return guest + "/" + path.removePrefix(prefix).replace(File.separatorChar, '/') } return null } @@ -154,7 +249,7 @@ object StoreInstallRoot { fun isFinished(folder: File): Boolean = StoreGameSidecar.read(folder)?.isInstalled == true fun unfinished(context: Context): List<Unfinished> = roots(context).flatMap { root -> - Store.entries.flatMap { store -> + Store.entries.filter { it != Store.BATTLENET }.flatMap { store -> storeDir(root, store).listFiles { f -> f.isDirectory }.orEmpty().mapNotNull { folder -> val sidecar = StoreGameSidecar.read(folder) when { diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreInstalls.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreInstalls.kt index 9b3f7136d..9b73f8cc8 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreInstalls.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreInstalls.kt @@ -40,7 +40,7 @@ object StoreInstalls { val app = context.applicationContext onStep?.invoke(0, STEPS) val before = StoreGameSidecar.read(folder) - val finished = StoreLaunch.writeLauncher(folder, sidecar.copy(state = StoreGameSidecar.STATE_INSTALLED, epic = before?.epic ?: sidecar.epic, cloud = before?.cloud ?: sidecar.cloud, achievements = before?.achievements ?: sidecar.achievements)) + val finished = StoreLaunch.writeLauncher(folder, sidecar.copy(state = StoreGameSidecar.STATE_INSTALLED, epic = before?.epic ?: sidecar.epic, cloud = before?.cloud ?: sidecar.cloud, achievements = before?.achievements ?: sidecar.achievements, logo = sidecar.logo ?: before?.logo)) finished.write(folder) if (StoreGameSidecar.read(folder)?.isInstalled != true) throw java.io.IOException("the install record could not be written in ${folder.name}") onStep?.invoke(1, STEPS) @@ -70,7 +70,7 @@ object StoreInstalls { fun uninstall(context: Context, game: InstalledStoreGame, progress: (Float) -> Unit = {}, removed: () -> Unit = {}) { val app = context.applicationContext val appId = SessionPrefs.addedGameStoredAppId(app, game.folder.path) - val files = game.folder.walkBottomUp().toList() + val files = game.folder.walkBottomUp().onEnter { !Files.isSymbolicLink(it.toPath()) }.toList() val step = (files.size / 100).coerceAtLeast(1) files.forEachIndexed { i, f -> f.delete() @@ -98,6 +98,10 @@ object StoreInstalls { } fun deleteTree(dir: File) { + if (Files.isSymbolicLink(dir.toPath())) { + dir.delete() + return + } dir.listFiles()?.forEach { if (it.isDirectory) deleteTree(it) else it.delete() } dir.delete() } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreLogFiles.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreLogFiles.kt index 0af659012..bd94cfc3b 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreLogFiles.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreLogFiles.kt @@ -24,6 +24,11 @@ object StoreLogFiles { Thread({ android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_BACKGROUND); r.run() }, "store-log-flush").apply { isDaemon = true } } + private val CLOCK = SimpleDateFormat("HH:mm:ss.SSS", Locale.US) + + /** Appends [text] with a time stamp. Works in any of the app's processes. */ + fun stamped(context: Context, text: String) = append(context, "${synchronized(CLOCK) { CLOCK.format(Date()) }} $text") + fun append(context: Context, line: String) { val app = context.applicationContext pending.add(line) diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreLoginActivity.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreLoginActivity.kt index 1b5c6ccdd..9a4f97c87 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreLoginActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreLoginActivity.kt @@ -130,7 +130,7 @@ class StoreLoginActivity : ComponentActivity() { private fun newFlow(state: String): Flow? { val url = clientUrl if (url != null) return com.droiddeck.launcher.stores.epic.EpicClient.loginFlow(url, chromeUa) - return store?.let { LoginFlows.forStore(it, state) } + return store?.let { LoginFlows.forStore(it, state, chromeUa) } } override fun finish() { diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreSizes.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreSizes.kt index 454e3d23b..290676c9e 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreSizes.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreSizes.kt @@ -55,7 +55,7 @@ object StoreSizes { Store.EPIC -> EpicPrefs.get(app).edit().putLong("size_${item.id}", it).apply() Store.AMAZON -> AmazonPrefs.get(app).edit().putLong("size_${item.id}", it).apply() Store.ITCH -> {} - Store.GOG, Store.XBOX -> {} + Store.GOG, Store.XBOX, Store.BATTLENET -> {} } } }, pool).exceptionally { error -> @@ -111,7 +111,7 @@ object StoreSizes { val bytes = AmazonApiClient.getBytes(AmazonApiClient.appendPath(spec.downloadUrl, "manifest.proto"), token) ?: return 0L AmazonManifest.parse(bytes).totalInstallSize } - Store.GOG, Store.XBOX -> 0L + Store.GOG, Store.XBOX, Store.BATTLENET -> 0L Store.ITCH -> com.droiddeck.launcher.stores.itch.ItchLibrary.size(context, item) } } } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt index e780733d9..e15f19dd4 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt @@ -9,14 +9,13 @@ import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import com.droiddeck.launcher.session.SessionPrefs import com.droiddeck.launcher.stores.download.DownloadEntry -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale object StoresState { private const val TAG = "Stores" private const val SEARCH_DELAY_MS = 400L + private const val LIBRARY_RETRY_MS = 60_000L private val main = Handler(Looper.getMainLooper()) val accounts = mutableStateMapOf<Store, String>() @@ -79,16 +78,17 @@ object StoresState { val app = context.applicationContext com.droiddeck.launcher.stores.download.SavedQueue.restore(app) background("stores-refresh") { - val found = Store.entries.mapNotNull { store -> StoreAccounts.signedInAs(app, store)?.let { store to it } } + runCatching { com.droiddeck.launcher.stores.battlenet.BattleNetClient.syncAccount(app) } + val found = Store.entries.mapNotNull { store -> StoreAccounts.displayName(app, store)?.let { store to it } } val onDisk = StoreInstallRoot.gameFolders(app).mapNotNull { folder -> StoreGameSidecar.read(folder)?.takeIf { it.isInstalled }?.let { InstalledStoreGame(it, folder) } - } + } + runCatching { com.droiddeck.launcher.stores.battlenet.BattleNetBackend.installedGames(app) }.getOrDefault(emptyList()) val partial = StoreInstallRoot.unfinished(app) val version = if (StoresNative.available) StoresNative.version ?: "" else "" main.post { androidx.compose.runtime.snapshots.Snapshot.withMutableSnapshot { val restored = found.toMap() - accounts.keys.toList().filter { it !in restored }.forEach { accounts.remove(it) } + accounts.keys.toList().filter { it !in restored }.forEach { accounts.remove(it); libraryAsked.remove(it) } accounts.putAll(restored) installed = onDisk unfinished = partial @@ -129,7 +129,9 @@ object StoresState { fun installedGame(store: Store, id: String): InstalledStoreGame? = installed.firstOrNull { it.sidecar.store == store && it.sidecar.id == id } fun detailItem(store: Store, id: String): CatalogItem? = detailCatalogItem( - store, id, library[store].orEmpty(), found[store].orEmpty(), shelves[store], + store, id, library[store].orEmpty(), + found[store].orEmpty() + if (store == Store.ITCH) com.droiddeck.launcher.stores.itch.ItchBrowseState.items else emptyList(), + shelves[store], ) fun open(context: Context, store: Store, force: Boolean = false) { @@ -140,6 +142,18 @@ object StoresState { if (force || !shelves.containsKey(store)) backend.loadShelves(app, force) } + private val libraryAsked = java.util.concurrent.ConcurrentHashMap<Store, Long>() + + /** Once per store, again after a minute when the last sync never produced a library (offline, expired sign-in). */ + fun loadLibrary(context: Context, store: Store) { + if (!isSignedIn(store) || library.containsKey(store)) return + val now = android.os.SystemClock.elapsedRealtime() + val asked = libraryAsked[store] + if (asked != null && now - asked < LIBRARY_RETRY_MS) return + libraryAsked[store] = now + backends[store]?.syncLibrary(context.applicationContext, false) + } + fun preview(context: Context, store: Store) { if (!shelves.containsKey(store)) backends[store]?.loadShelves(context.applicationContext, false) } @@ -166,6 +180,7 @@ object StoresState { fun signIn(context: Context, store: Store) { expired.remove(store) + libraryAsked.remove(store) backends[store]?.signIn(context) ?: logLine("${store.label}: this build has no sign-in for it yet") } @@ -178,13 +193,15 @@ object StoresState { else if (store != Store.XBOX || backend == null) StoreAccounts.clear(app, store) main.post { if (store == Store.XBOX && StoreAccounts.signedInAs(app, store) != null) return@post - accounts.remove(store); library.remove(store); status.remove(store); problems.remove(store) + accounts.remove(store); library.remove(store); status.remove(store); problems.remove(store); libraryAsked.remove(store) logLine("${store.label}: signed out") } } } var openDownloads by mutableStateOf(false) + /** A "store:id" key another page asks the Stores page to open. */ + var openGameRequest by mutableStateOf<String?>(null) private fun askForNotifications(context: Context) { val activity = context as? android.app.Activity ?: return @@ -197,18 +214,33 @@ object StoresState { } var pendingInstall by mutableStateOf<CatalogItem?>(null) + var itchPicking by mutableStateOf<CatalogItem?>(null) fun requestInstall(context: Context, item: CatalogItem) { askForNotifications(context) + if (item.store == Store.ITCH && item.extra["uploadId"].isNullOrEmpty()) { itchPicking = item; return } if (isUnfinished(item)) { install(context, item); return } val targets = StoreInstallRoot.targets(context) - if (targets.size > 1 || StoreLanguage.selectable(item.store)) pendingInstall = item - else install(context, item, targets.first().root) + val preferred = StoreInstallRoot.defaultTarget(context) + val wantedExternal = SessionPrefs.defaultStorage(context) == SessionPrefs.DEFAULT_STORAGE_EXTERNAL + if (preferred != null) SessionPrefs.setStoresInstallTarget(context, preferred.root.absolutePath) + when { + preferred != null && !StoreLanguage.selectable(item.store) -> { + install(context, item, preferred.root) + } + targets.size > 1 || wantedExternal || StoreLanguage.selectable(item.store) -> pendingInstall = item + else -> install(context, item, targets.first().root) + } } fun install(context: Context, item: CatalogItem, root: java.io.File? = null, language: String? = null) { val app = context.applicationContext - val folder = StoreInstallRoot.folderFor(app, item.store, item.id, item.title, root ?: StoreInstallRoot.installRoot(app)) + val targetRoot = root ?: StoreInstallRoot.defaultTarget(app)?.root ?: StoreInstallRoot.installRoot(app) + if (item.store == Store.BATTLENET) { + backends[item.store]?.install(app, item, targetRoot) + return + } + val folder = StoreInstallRoot.folderFor(app, item.store, item.id, item.title, targetRoot) val target = folder.parentFile!!.parentFile!! val code = language?.takeIf { it.isNotBlank() } ?: StoreLanguage.preferredCode(app) val tagged = if (StoreLanguage.selectable(item.store)) item.copy(extra = item.extra + (StoreLanguage.EXTRA to code)) else item @@ -241,6 +273,10 @@ object StoresState { uninstalling[key] = 0f background("stores-uninstall", onFailure = { main.post { uninstalling.remove(key); onDone() } }) { runCatching { backends[game.sidecar.store]?.uninstall(app, game) } + if (game.sidecar.store == Store.BATTLENET) { + main.post { uninstalling.remove(key); onDone() } + return@background + } StoreInstalls.uninstall(app, game, progress = { f -> main.post { if (key in uninstalling) uninstalling[key] = f } }, removed = { main.post { installed = installed.filterNot { it.folder.absolutePath == game.folder.absolutePath } @@ -262,7 +298,7 @@ object StoresState { val text = StoreLog.redactLine(raw) Log.i(TAG, text) val app = appContext ?: return - StoreLogFiles.append(app, "${synchronized(CLOCK) { CLOCK.format(Date()) }} $text") + StoreLogFiles.stamped(app, text) } private var appContext: Context? = null @@ -295,8 +331,6 @@ object StoresState { } }, name).start() } - - private val CLOCK = SimpleDateFormat("HH:mm:ss.SSS", Locale.US) } interface StoreBackend { diff --git a/app/src/main/java/com/droiddeck/launcher/stores/amazon/AmazonInstaller.kt b/app/src/main/java/com/droiddeck/launcher/stores/amazon/AmazonInstaller.kt index 7eff5fbbe..401f621ed 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/amazon/AmazonInstaller.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/amazon/AmazonInstaller.kt @@ -124,6 +124,7 @@ internal class AmazonInstaller( installVersion = versionId, installedAt = System.currentTimeMillis(), cover = cover, hero = item.imageUrl, extra = mapOf("entitlementId" to entitlementId, "sku" to sku), + logo = item.logoUrl, ) StoreInstalls.complete(app, folder, sidecar, onStep = { done, steps -> handle.stageProgress(done.toLong(), steps.toLong()) }) return folder.path diff --git a/app/src/main/java/com/droiddeck/launcher/stores/amazon/AmazonLibrary.kt b/app/src/main/java/com/droiddeck/launcher/stores/amazon/AmazonLibrary.kt index 841fe9217..41f049979 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/amazon/AmazonLibrary.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/amazon/AmazonLibrary.kt @@ -24,6 +24,7 @@ data class AmazonGame( val publisher: String = "", val productSku: String = "", val versionId: String = "", + val logoUrl: String = "", ) object AmazonLibrary { @@ -42,7 +43,8 @@ object AmazonLibrary { JSONObject() .put("productId", g.productId).put("entitlementId", g.entitlementId).put("title", g.title) .put("artUrl", g.artUrl).put("heroUrl", g.heroUrl).put("developer", g.developer) - .put("publisher", g.publisher).put("productSku", g.productSku).put("versionId", g.versionId), + .put("publisher", g.publisher).put("productSku", g.productSku).put("versionId", g.versionId) + .apply { if (g.logoUrl.isNotEmpty()) put("logoUrl", g.logoUrl) }, ) }.toString() @@ -60,6 +62,7 @@ object AmazonLibrary { publisher = o.optString("publisher", ""), productSku = o.optString("productSku", ""), versionId = o.optString("versionId", ""), + logoUrl = o.optString("logoUrl", ""), ) } return tidy(games) @@ -94,6 +97,7 @@ object AmazonLibrary { sizeBytes = prefs.getLong("size_${game.productId}", 0L).takeIf { it > 0 } ?: 0L, storeUrl = STORE_URL, extra = mapOf("entitlementId" to game.entitlementId, "sku" to game.productSku, "version" to ""), + logoUrl = game.logoUrl.ifEmpty { null }, ) } @@ -106,7 +110,7 @@ object AmazonLibrary { } } - private fun gameFrom(o: JSONObject, before: AmazonGame?): AmazonGame = AmazonGame( + internal fun gameFrom(o: JSONObject, before: AmazonGame?): AmazonGame = AmazonGame( productId = o.optString("productId"), entitlementId = o.optString("entitlementId"), title = o.optString("title"), @@ -116,6 +120,7 @@ object AmazonLibrary { publisher = o.optString("publisher"), productSku = o.optString("sku"), versionId = before?.versionId.orEmpty(), + logoUrl = o.optString("logo").ifEmpty { before?.logoUrl.orEmpty() }, ) private fun backfillPosters(app: Context, games: List<AmazonGame>) { diff --git a/app/src/main/java/com/droiddeck/launcher/stores/battlenet/BattleNetStore.kt b/app/src/main/java/com/droiddeck/launcher/stores/battlenet/BattleNetStore.kt new file mode 100644 index 000000000..945c75c7a --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/battlenet/BattleNetStore.kt @@ -0,0 +1,294 @@ +package com.droiddeck.launcher.stores.battlenet + +import android.content.Context +import android.net.Uri +import android.util.Log +import com.droiddeck.launcher.R +import com.droiddeck.launcher.frontend.AddedGames +import com.droiddeck.launcher.runtime.BattleNet +import com.droiddeck.launcher.runtime.LinuxRuntime +import com.droiddeck.launcher.stores.CatalogItem +import com.droiddeck.launcher.stores.InstalledStoreGame +import com.droiddeck.launcher.stores.Store +import com.droiddeck.launcher.stores.StoreAccounts +import com.droiddeck.launcher.stores.StoreBackend +import com.droiddeck.launcher.stores.StoreGameSidecar +import com.droiddeck.launcher.stores.StoreInstallRoot +import com.droiddeck.launcher.stores.StoreShelves +import com.droiddeck.launcher.stores.StoresState +import com.droiddeck.launcher.stores.download.DownloadEntry +import com.droiddeck.launcher.stores.download.DownloadQueue +import com.droiddeck.launcher.stores.download.DownloadStage +import org.json.JSONObject +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.net.HttpURLConnection +import java.net.URL +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong + +object BattleNetClient { + private const val TAG = "BattleNetClient" + const val DOWNLOAD_ID = "battlenet-client" + const val DOWNLOAD_KEY = "battlenet:$DOWNLOAD_ID" + const val REQUEST = "battlenet-request" + internal const val INSTALLER_URL = "https://downloader.battle.net/download/getInstallerForGame?os=win&gameProgram=BATTLENET_APP&version=Live" + private const val SETUP = "root/.cache/droiddeck/battlenet/Battle.net-Setup.exe" + private const val CONFIG = "pfx/drive_c/users/steamuser/AppData/Roaming/Battle.net/Battle.net.config" + internal const val MIN_SETUP = 1L shl 20 + private val work = Executors.newSingleThreadExecutor { r -> Thread(r, "battlenet-client") } + + private val sequence = AtomicLong() + + @Volatile var opener: ((List<String>) -> Unit)? = null + + fun setup(context: Context): File = File(LinuxRuntime.rootDir(context), SETUP) + + internal fun setupReady(file: File): Boolean = runCatching { + file.length() >= MIN_SETUP && file.inputStream().use { input -> + val head = ByteArray(2) + input.read(head) == 2 && head[0] == 'M'.code.toByte() && head[1] == 'Z'.code.toByte() + } + }.getOrDefault(false) + + internal fun account(config: String?): String? { + val client = runCatching { JSONObject(config ?: return null).optJSONObject("Client") }.getOrNull() ?: return null + if (client.optString("AutoLogin") != "true") return null + return client.optString("SavedAccountNames").split(',').map { it.trim() }.firstOrNull { it.isNotEmpty() } + } + + private fun config(context: Context) = File(BattleNet.prefix(context), CONFIG) + + fun signedInAs(context: Context): String? = + if (!BattleNet.installed(context)) null + else account(runCatching { config(context).readText() }.getOrNull())?.let(StoreAccounts::maskEmail) + + fun syncAccount(context: Context): Boolean { + val name = signedInAs(context) + if (name == StoreAccounts.signedInAs(context, Store.BATTLENET)) return false + if (name == null) StoreAccounts.clear(context, Store.BATTLENET) + else StoreAccounts.write(context, Store.BATTLENET, JSONObject().put("display_name", name)) + Log.i(TAG, if (name == null) "Battle.net is signed out" else "Battle.net is signed in") + return true + } + + fun refresh(context: Context) { + val app = context.applicationContext + work.execute { + if (runCatching { syncAccount(app) }.getOrDefault(false)) StoresState.notifyLibraryChanged(app) + } + } + + fun signIn(context: Context) { + val app = context.applicationContext + work.execute { + if (BattleNet.installed(app) || setupReady(setup(app))) { + open(emptyList()) + return@execute + } + StoresState.post { + if (DownloadQueue.isActive(DOWNLOAD_KEY)) return@post + val entry = DownloadEntry( + Store.BATTLENET, DOWNLOAD_ID, app.getString(R.string.bnet_name), + location = StoreInstallRoot.labelFor(app, setup(app)), + ) + DownloadQueue.enqueue(app, entry) { SetupDownload(app) } + StoresState.openDownloads = true + } + } + } + + fun signOut(context: Context) { + val file = config(context) + runCatching { + val json = JSONObject(file.readText()) + json.optJSONObject("Client")?.put("AutoLogin", "false") ?: return@runCatching + val staged = File(file.path + ".droiddeck") + staged.writeText(json.toString(4)) + check(staged.renameTo(file)) + Log.i(TAG, "Battle.net will ask for a sign-in at its next start") + }.onFailure { Log.w(TAG, "Battle.net's sign-in kept: ${it.javaClass.simpleName}") } + } + + fun request(sessionRoot: File, args: List<String>): Boolean = runCatching { + require(args.none { '\n' in it }) + val now = System.nanoTime() + val name = "$REQUEST.%020d".format(java.util.Locale.ROOT, sequence.updateAndGet { maxOf(it + 1, now) }) + val staged = File(sessionRoot, "$name.tmp") + staged.writeText(args.joinToString("\n") + "\n") + check(staged.renameTo(File(sessionRoot, name))) + Log.i(TAG, "handed ${args.firstOrNull()} to the open Battle.net") + }.onFailure { Log.w(TAG, "Battle.net request not left: ${it.javaClass.simpleName}") }.isSuccess + + fun clearRequests(sessionRoot: File) { + sessionRoot.listFiles { file -> file.name == REQUEST || file.name.startsWith("$REQUEST.") }?.forEach { it.delete() } + } + + fun open(args: List<String>) { + StoresState.post { opener?.invoke(args) } + } + + fun library(context: Context, root: File = preferred(context)): List<String> = + AddedGames.guestPath(context, StoreInstallRoot.storeDir(root, Store.BATTLENET))?.let { listOf("--library", it) }.orEmpty() + + internal fun preferred(context: Context): File { + val second = StoreInstallRoot.libraryRoot(context)?.absolutePath + return StoreInstallRoot.targets(context).firstOrNull { it.root.absolutePath == second && (it.root.isDirectory || it.root.mkdirs()) }?.root + ?: StoreInstallRoot.internalRoot(context) + } + + fun installGame(context: Context, uid: String, root: File) { + val app = context.applicationContext + work.execute { open(library(app, root) + listOf("install-game", uid)) } + } + + internal fun downloaded(context: Context) { + Log.i(TAG, "Battle.net's installer downloaded from Blizzard") + open(emptyList()) + StoresState.post { StoresState.notifyLibraryChanged(context) } + } +} + +object BattleNetBackend : StoreBackend { + override val store: Store = Store.BATTLENET + + override fun signIn(context: Context) = BattleNetClient.signIn(context) + + override fun signOut(context: Context) { + BattleNetClient.signOut(context) + StoresState.post { StoresState.shelves.remove(store); StoresState.found.remove(store) } + } + + override fun syncLibrary(context: Context, force: Boolean) { + val app = context.applicationContext + Thread({ + val items = runCatching { installed(app).map { item(app, it.title, owned = true) } }.getOrDefault(emptyList()) + StoresState.post { StoresState.library[store] = items } + }, "battlenet-library").start() + } + + override fun loadShelves(context: Context, force: Boolean) { + val app = context.applicationContext + Thread({ + val known = shelves(app) + StoresState.post { StoresState.shelves[store] = known } + if (runCatching { BattleNet.fetchCovers(app) }.getOrDefault(false)) { + val covered = shelves(app) + val items = runCatching { installed(app).map { item(app, it.title, owned = true) } }.getOrDefault(emptyList()) + StoresState.post { + StoresState.shelves[store] = covered + StoresState.library[store] = items + StoresState.notifyLibraryChanged(app) + } + } + }, "battlenet-shelves").start() + } + + override fun search(context: Context, query: String): List<CatalogItem> = + BattleNet.STORE.filter { it.name.contains(query, ignoreCase = true) }.map { item(context, it) } + + override fun install(context: Context, item: CatalogItem, root: File) = BattleNetClient.installGame(context, item.id, root) + + override fun uninstall(context: Context, game: InstalledStoreGame) = BattleNetClient.open(emptyList()) + + fun installed(context: Context): List<BattleNet.Game> = if (BattleNet.installed(context)) BattleNet.games(context) else emptyList() + + fun installedGames(context: Context): List<InstalledStoreGame> = installed(context).map { game -> + val art = BattleNet.art(context, game.title) + val cover = art.cover?.let { Uri.fromFile(it).toString() } + InstalledStoreGame(StoreGameSidecar(store, game.title.uid, game.name, "", cover = cover, hero = art.header?.let { Uri.fromFile(it).toString() } ?: cover), game.folder) + } + + private fun shelves(context: Context): StoreShelves { + val items = BattleNet.STORE.map { item(context, it) } + return StoreShelves(trending = items, art = items.mapNotNull { it.tallImageUrl }) + } + + private fun item(context: Context, title: BattleNet.Title, owned: Boolean = false): CatalogItem { + val art = BattleNet.art(context, title) + return CatalogItem( + store, title.uid, title.name, + imageUrl = (art.header ?: art.cover)?.let { Uri.fromFile(it).toString() }, + tallImageUrl = art.cover?.let { Uri.fromFile(it).toString() }, + owned = owned, + ) + } +} + +internal class SetupDownload(private val app: Context) : DownloadQueue.DownloadJob { + private val cancelled = AtomicBoolean(false) + @Volatile private var connection: HttpURLConnection? = null + + override fun run(handle: DownloadQueue.JobHandle): String? { + handle.stage(DownloadStage.DOWNLOAD) + if (!LinuxRuntime.isInstalled(app)) throw IllegalStateException(app.getString(R.string.epic_client_no_runtime)) + val file = BattleNetClient.setup(app) + val partial = File(file.path + ".part") + file.parentFile?.mkdirs() + var attempt = 0 + while (!cancelled.get()) { + try { + fetch(partial, handle) + if (cancelled.get()) break + if (!BattleNetClient.setupReady(partial)) throw IOException("not a Windows program") + if (!partial.renameTo(file)) throw IOException("could not keep the installer") + BattleNetClient.downloaded(app) + return file.absolutePath + } catch (e: IOException) { + partial.delete() + if (cancelled.get()) break + attempt++ + handle.log("Battle.net installer attempt $attempt: ${e.message ?: e.javaClass.simpleName}") + if (attempt >= ATTEMPTS) throw IllegalStateException(app.getString(R.string.epic_client_download_failed, e.message ?: e.javaClass.simpleName)) + val until = System.currentTimeMillis() + (2_000L shl (attempt - 1)) + while (System.currentTimeMillis() < until && !cancelled.get()) Thread.sleep(100) + } + } + partial.delete() + return null + } + + private fun fetch(partial: File, handle: DownloadQueue.JobHandle) { + val conn = (URL(BattleNetClient.INSTALLER_URL).openConnection() as HttpURLConnection).apply { + connectTimeout = 20_000 + readTimeout = 30_000 + instanceFollowRedirects = true + setRequestProperty("User-Agent", "DroidDeck") + } + connection = conn + try { + if (conn.responseCode != 200) throw IOException("HTTP ${conn.responseCode}") + if (conn.url.protocol != "https") throw IOException("not served over HTTPS") + val total = conn.contentLengthLong + var done = 0L + conn.inputStream.use { input -> + FileOutputStream(partial).use { out -> + val buf = ByteArray(64 * 1024) + while (!cancelled.get()) { + val n = input.read(buf) + if (n < 0) break + out.write(buf, 0, n) + done += n + handle.progress(done, total) + } + out.fd.sync() + } + } + if (!cancelled.get() && total > 0 && done != total) throw IOException("the download ended early") + } finally { + connection = null + runCatching { conn.disconnect() } + } + } + + override fun cancel(deleteFiles: Boolean) { + cancelled.set(true) + runCatching { connection?.disconnect() } + } + + private companion object { + const val ATTEMPTS = 4 + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/battlenet/BlizzardArt.kt b/app/src/main/java/com/droiddeck/launcher/stores/battlenet/BlizzardArt.kt new file mode 100644 index 000000000..3fc972ab2 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/battlenet/BlizzardArt.kt @@ -0,0 +1,93 @@ +package com.droiddeck.launcher.stores.battlenet + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import java.io.File +import java.io.IOException +import java.net.CookieManager +import java.net.CookiePolicy +import java.net.HttpURLConnection +import java.net.URL + +internal object BlizzardArt { + private const val SHOP = "https://us.shop.battle.net/en-us/product/" + private const val MAX_PAGE = 16 shl 20 + private const val MAX_IMAGE = 12 shl 20 + private const val WIDE = 1280 + private val OG = Regex("""<meta property="og:image" content="(https://[^"]+)"""") + private val THUMB = Regex("""\\?"imageUrl\\?":\\?"(//catalog\.blzstatic\.com/[^"\\]+)""") + private val HOSTS = listOf(".blzstatic.com", ".blz-contentstack.com", "blz-contentstack-images.akamaized.net") + + internal fun imageUrl(page: String): String? { + val og = OG.find(page)?.groupValues?.get(1)?.takeUnless { "youtube" in it || "maxresdefault" in it } + val thumbs = THUMB.findAll(page).map { it.groupValues[1] }.filter { "_bundle" !in it }.map { "https:$it" } + return (sequenceOf(og) + thumbs).filterNotNull().firstOrNull(::allowed) + } + + private fun allowed(url: String): Boolean { + val host = runCatching { URL(url).host }.getOrNull() ?: return false + return HOSTS.any { h -> host == h.trimStart('.') || host.endsWith(h) } + } + + fun fetch(slug: String, wide: File, cover: File?): Boolean { + val page = get(URL(SHOP + slug), MAX_PAGE, true)?.toString(Charsets.UTF_8) ?: return false + val url = imageUrl(page) ?: return false + val bytes = get(URL(url.substringBefore('?') + "?imwidth=$WIDE"), MAX_IMAGE, false) ?: return false + val art = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return false + try { + val scaled = if (art.width > WIDE) Bitmap.createScaledBitmap(art, WIDE, art.height * WIDE / art.width, true) else art + save(scaled, wide) + if (cover != null) { + val w = minOf(scaled.width, scaled.height * 2 / 3) + val crop = Bitmap.createBitmap(scaled, (scaled.width - w) / 2, 0, w, w * 3 / 2) + save(Bitmap.createScaledBitmap(crop, 600, 900, true), cover) + } + return true + } finally { + art.recycle() + } + } + + private fun save(bitmap: Bitmap, file: File) { + val tmp = File(file.path + ".tmp") + tmp.outputStream().use { if (!bitmap.compress(Bitmap.CompressFormat.JPEG, 90, it)) throw IOException("not saved") } + if (!tmp.renameTo(file)) throw IOException("not kept") + } + + private fun get(start: URL, limit: Int, follow: Boolean): ByteArray? { + val cookies = CookieManager(null, CookiePolicy.ACCEPT_ALL) + var url = start + repeat(8) { + if (url.protocol != "https") return null + val conn = (url.openConnection() as HttpURLConnection).apply { + instanceFollowRedirects = false + connectTimeout = 15_000 + readTimeout = 20_000 + setRequestProperty("User-Agent", "DroidDeck") + cookies.get(url.toURI(), emptyMap()).forEach { (k, v) -> if (v.isNotEmpty()) setRequestProperty(k, v.joinToString("; ")) } + } + try { + val code = conn.responseCode + cookies.put(url.toURI(), conn.headerFields) + when { + code == 200 -> return conn.inputStream.use { input -> + val out = java.io.ByteArrayOutputStream() + val buf = ByteArray(64 * 1024) + while (true) { + val n = input.read(buf) + if (n < 0) break + out.write(buf, 0, n) + if (out.size() > limit) return null + } + out.toByteArray() + } + follow && code in 301..308 -> url = URL(url, conn.getHeaderField("Location") ?: return null) + else -> return null + } + } finally { + conn.disconnect() + } + } + return null + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/download/SavedQueue.kt b/app/src/main/java/com/droiddeck/launcher/stores/download/SavedQueue.kt index 5a5eae4fa..e9a0acbcf 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/download/SavedQueue.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/download/SavedQueue.kt @@ -77,8 +77,9 @@ object SavedQueue { private fun itemJson(i: CatalogItem) = JSONObject().apply { put("store", i.store.id); put("id", i.id); put("title", i.title) put("image", i.imageUrl ?: ""); put("tall", i.tallImageUrl ?: "") + i.logoUrl?.let { put("logo", it) } put("tags", i.tags); put("developer", i.developer); put("description", i.description) - put("size", i.sizeBytes); put("owned", i.owned); put("subscription", i.subscription) + put("size", i.sizeBytes); put("owned", i.owned); put("subscription", i.subscription); put("free", i.isFree) put("extra", JSONObject(i.extra)) } @@ -89,8 +90,9 @@ object SavedQueue { id = o.getString("id"), title = o.getString("title"), imageUrl = o.optString("image").ifEmpty { null }, tallImageUrl = o.optString("tall").ifEmpty { null }, tags = o.optString("tags"), developer = o.optString("developer"), description = o.optString("description"), - owned = o.optBoolean("owned", true), subscription = o.optBoolean("subscription"), sizeBytes = o.optLong("size"), + owned = o.optBoolean("owned", true), subscription = o.optBoolean("subscription"), isFree = o.optBoolean("free"), sizeBytes = o.optLong("size"), extra = extra?.keys()?.asSequence()?.associateWith { extra.getString(it) } ?: emptyMap(), + logoUrl = o.optString("logo").ifEmpty { null }, ) } } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/download/StoreDownloadService.kt b/app/src/main/java/com/droiddeck/launcher/stores/download/StoreDownloadService.kt index a32bceeb6..1f80eecf5 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/download/StoreDownloadService.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/download/StoreDownloadService.kt @@ -15,7 +15,7 @@ import android.os.IBinder import android.os.Looper import android.os.PowerManager import android.util.Log -import com.droiddeck.launcher.MainActivity +import com.droiddeck.launcher.FrontEndActivity import com.droiddeck.launcher.R import com.droiddeck.launcher.core.DeviceSupport import com.droiddeck.launcher.stores.StoresState @@ -189,7 +189,7 @@ class StoreDownloadService : Service() { } private fun openPage(context: Context, nav: String, code: Int): PendingIntent = - PendingIntent.getActivity(context, code, Intent(context, MainActivity::class.java).putExtra(EXTRA_NAV, nav) + PendingIntent.getActivity(context, code, Intent(context, FrontEndActivity::class.java).putExtra(EXTRA_NAV, nav) .addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) private fun openDownloads(context: Context, code: Int) = openPage(context, "stores:downloads", code) diff --git a/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicBackend.kt b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicBackend.kt index 316cabb65..ce5afa91b 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicBackend.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicBackend.kt @@ -47,6 +47,8 @@ object EpicBackend : StoreBackend, EpicLaunchSupport { if (!quiet) StoresState.status[store] = app.getString(R.string.stores_sync_library) } val result = EpicLibrary.sync(app, force) { line -> if (!quiet) StoresState.post { StoresState.status[store] = line } } + // Throttled means the cache is fresh, possibly written by another sync after this one read it. + val fresh = if (result == EpicLibrary.SyncResult.Throttled) EpicLibrary.cached(app).map { EpicLibrary.toCatalogItem(app, it) } else cached StoresState.post { StoresState.status.remove(store) when (result) { @@ -56,7 +58,7 @@ object EpicBackend : StoreBackend, EpicLaunchSupport { } is EpicLibrary.SyncResult.Failed -> StoresState.problems[store] = result.message EpicLibrary.SyncResult.NotLoggedIn -> StoresState.problems[store] = StoresState.notSignedInLine(store) - EpicLibrary.SyncResult.Throttled -> if (!StoresState.library.containsKey(store)) StoresState.library[store] = cached + EpicLibrary.SyncResult.Throttled -> if (fresh.isNotEmpty()) StoresState.library[store] = mergeItems(StoresState.library[store], fresh) EpicLibrary.SyncResult.Busy -> {} } } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicClient.kt b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicClient.kt index 7f0e86b14..454d94826 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicClient.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicClient.kt @@ -227,12 +227,6 @@ object EpicClient { ) } - fun withLauncher(games: List<Library.SteamGame>, launcher: Library.SteamGame?): List<Library.SteamGame> { - if (launcher == null) return games - val at = games.indexOfFirst { it.source == Store.EPIC.id }.takeIf { it >= 0 } ?: games.size - return games.subList(0, at) + launcher + games.subList(at, games.size) - } - private fun steamSession(): Boolean = SessionState.running && !SessionState.stopRequested && SessionState.mode == SessionService.MODE_STEAM @@ -298,10 +292,24 @@ object EpicClient { AddedGames.guestPath(app, folder)?.let { t.root.absolutePath to (folder to it) } }.toMap() if (dirs.isEmpty()) return null - val chosen = dirs[SessionPrefs.storesInstallTarget(app)] ?: dirs[StoreInstallRoot.internalRoot(app).absolutePath] ?: dirs.values.first() + val preferred = StoreInstallRoot.defaultTarget(app)?.root?.absolutePath + val chosen = preferred?.let { dirs[it] } + ?: dirs[SessionPrefs.storesInstallTarget(app)] + ?: dirs[StoreInstallRoot.internalRoot(app).absolutePath] + ?: dirs.values.first() val file = File(dataDir(app), SETTINGS) val settings = runCatching { JSONObject(file.readText()) }.getOrNull() ?: JSONObject() val current = settings.optString("defaultInstallDir").trimEnd('/') + val externalMissing = SessionPrefs.defaultStorage(app) == SessionPrefs.DEFAULT_STORAGE_EXTERNAL && preferred == null + if (externalMissing && current.isNotEmpty() && + (current.startsWith("/mnt/droiddeck-") || current.startsWith("/root/Storage/"))) { + Log.w(TAG, "the configured external storage is disconnected; keeping $current") + return current + } + if (externalMissing && current.isEmpty()) { + Log.w(TAG, "the configured external storage is disconnected; not choosing an install folder") + return null + } if (current.isNotEmpty() && current != STOCK_INSTALL_DIR && dirs.values.none { it.second == current } && !(current.endsWith("/${Store.EPIC.shortLabel}") && (current.startsWith("/mnt/droiddeck-") || current.startsWith("/root/Storage/")))) return current if (!chosen.first.isDirectory && !chosen.first.mkdirs()) return null if (current == chosen.second) return current diff --git a/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicClientLibrary.kt b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicClientLibrary.kt index 8521a90f5..e5ea933d8 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicClientLibrary.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicClientLibrary.kt @@ -251,7 +251,7 @@ internal object EpicClientLibrary { SessionPrefs.addedGameStoredAppId(app, oldFolder.path)?.let { SteamLiveShortcuts.remove(app, it) } changed = true } - val next = StoreGameSidecar( + val next = withLaunchArgs(StoreGameSidecar( Store.EPIC, appName, item.optString("DisplayName").ifEmpty { appName }, exe, installVersion = item.optString("AppVersionString"), installedAt = existing?.installedAt?.takeIf { it > 0 } ?: folder.lastModified(), cover = existing?.cover, hero = existing?.hero, @@ -260,7 +260,7 @@ internal object EpicClientLibrary { epic = existing?.epic ?: com.droiddeck.launcher.stores.EpicOptions(), cloud = existing?.cloud ?: true, launcher = StoreLaunch.LAUNCHER, - ) + ), commandArgs(item.optString("LaunchCommand"))) val sidecarChanged = existing?.toJson()?.toString(2) != next.toJson().toString(2) val launcher = File(folder, StoreLaunch.LAUNCHER) if (sidecarChanged || runCatching { launcher.readText() }.getOrNull() != StoreLaunch.launcherText(next)) { @@ -328,6 +328,7 @@ internal object EpicClientLibrary { .put("CatalogItemId", record.optString("itemId")) .put("InstallLocation", dir) .put("LaunchExecutable", record.optString("launchExecutable")) + .put("LaunchCommand", record.optString("launchCommand")) .put("AppVersionString", record.optString("version")) .put("SidecarDeploymentId", record.optJSONObject("sidecar")?.optString("deploymentId").orEmpty()) .put("DisplayName", title?.takeIf(String::isNotBlank) ?: dir.substringAfterLast('/')) @@ -358,7 +359,11 @@ internal object EpicClientLibrary { write(File(manifests, "$guid.item"), item(install, tags, ownership == true).toString(2)) runCatching { egstore(install, guid) { token } }.onFailure { Log.w(TAG, "${install.appName}: .egstore not written (${it.javaClass.simpleName})") } after.put(install.appName, JSONObject().put("guid", guid).put("signature", install.signature).apply { if (ownership != null) put("ownership", ownership) }) - StoreGameSidecar.read(install.folder)?.let { StoreLaunch.refreshLauncher(install.folder, it) } + StoreGameSidecar.read(install.folder)?.let { sidecar -> + val next = runCatching { manifestArgs(install, guid) }.getOrNull()?.let { withLaunchArgs(sidecar, it) } ?: sidecar + if (next !== sidecar) next.write(install.folder) + StoreLaunch.refreshLauncher(install.folder, next) + } } for (name in before.keys().asSequence().toList()) { if (after.has(name)) continue @@ -376,6 +381,33 @@ internal object EpicClientLibrary { return changed } + private const val LAUNCH_ARGS = "launchArgs" + + private fun manifestArgs(install: Install, guid: String): List<String>? { + val manifest = File(install.folder, ".egstore/$guid.manifest").takeIf { it.isFile } ?: return null + val launch = EpicNative.inspect(manifest.readBytes(), JSONObject().put("op", "launch")) ?: return null + val build = launch.optString("buildVersion") + if (install.version.isNotEmpty() && build != install.version) return null + val args = launch.optJSONArray("args") ?: return null + return List(args.length()) { args.optString(it) }.filter { it.isNotEmpty() } + } + + private fun commandArgs(command: String): List<String> { + if (command.isBlank()) return emptyList() + val args = EpicNative.call("launch.split", JSONObject().put("command", command))?.optJSONArray("args") ?: return emptyList() + return List(args.length()) { args.optString(it) }.filter { it.isNotEmpty() } + } + + internal fun withLaunchArgs(sidecar: StoreGameSidecar, args: List<String>): StoreGameSidecar { + val previous = sidecar.extra[LAUNCH_ARGS]?.split('\n')?.filter { it.isNotEmpty() }.orEmpty() + val kept = sidecar.args.filterNot { it in previous } + val added = args.filterNot { it in kept } + val next = kept + added + val extra = LinkedHashMap(sidecar.extra).apply { if (added.isEmpty()) remove(LAUNCH_ARGS) else put(LAUNCH_ARGS, added.joinToString("\n")) } + if (next == sidecar.args && extra == sidecar.extra) return sidecar + return sidecar.copy(args = next, extra = extra) + } + private fun ownershipToken(token: String, install: Install): Boolean? = runCatching { EpicApiClient.getCustomAttribute(token, install.namespace, install.catalogItemId, "OwnershipToken") } .getOrNull()?.trim()?.lowercase()?.let { it == "true" } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicInstaller.kt b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicInstaller.kt index 8d40d002b..3be966b96 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicInstaller.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicInstaller.kt @@ -133,7 +133,7 @@ internal class EpicInstaller( val sidecar = StoreGameSidecar( Store.EPIC, item.id, item.title, exe = picked.ifEmpty { "game.exe" }, args = args, installVersion = result.optString("buildVersion"), installedAt = System.currentTimeMillis(), - cover = cover, hero = item.imageUrl, extra = extra, + cover = cover, hero = item.imageUrl, extra = extra, logo = item.logoUrl, ) StoreInstalls.complete(app, folder, sidecar, onStep = { done, steps -> handle.stageProgress(done.toLong(), steps.toLong()) }) val disk = result.optLong("diskBytes", 0L) diff --git a/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicLibrary.kt b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicLibrary.kt index be665881b..32a485bfb 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicLibrary.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicLibrary.kt @@ -25,6 +25,7 @@ data class EpicGame( val releaseDate: String = "", val publisher: String = "", val isDlc: Boolean = false, + val artLogo: String = "", ) object EpicLibrary { @@ -50,7 +51,8 @@ object EpicLibrary { .put("title", g.title).put("artCover", g.artCover).put("artSquare", g.artSquare) .put("developer", g.developer).put("description", g.description).put("version", g.version) .put("installSize", g.installSize).put("canRunOffline", g.canRunOffline) - .put("releaseDate", g.releaseDate).put("publisher", g.publisher), + .put("releaseDate", g.releaseDate).put("publisher", g.publisher) + .apply { if (g.artLogo.isNotEmpty()) put("artLogo", g.artLogo) }, ) }.toString() @@ -73,6 +75,7 @@ object EpicLibrary { canRunOffline = o.optBoolean("canRunOffline", true), releaseDate = o.optString("releaseDate", ""), publisher = o.optString("publisher", ""), + artLogo = o.optString("artLogo", ""), ) } return tidy(games) @@ -106,6 +109,7 @@ object EpicLibrary { sizeBytes = prefs.getLong("size_${game.appName}", 0L).takeIf { it > 0 } ?: game.installSize, storeUrl = "https://store.epicgames.com/en-US/browse?q=" + URLEncoder.encode(title, "UTF-8"), extra = mapOf("namespace" to game.namespace, "catalogItemId" to game.catalogItemId, "version" to game.version), + logoUrl = game.artLogo.ifEmpty { null }, ) } @@ -128,6 +132,7 @@ object EpicLibrary { releaseDate = pick("releaseDate", before?.releaseDate.orEmpty()), publisher = pick("publisher", before?.publisher.orEmpty()), isDlc = d.optBoolean("dlc", false), + artLogo = pick("logo", before?.artLogo.orEmpty()), ) } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogBackend.kt b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogBackend.kt index 1ba78d795..bf62da5b5 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogBackend.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogBackend.kt @@ -11,10 +11,12 @@ import com.droiddeck.launcher.stores.StoreInstallRoot import com.droiddeck.launcher.stores.StoreInstalls import com.droiddeck.launcher.stores.StoreLanguage import com.droiddeck.launcher.stores.StoreLoginActivity +import com.droiddeck.launcher.stores.StoreNet import com.droiddeck.launcher.stores.StoresState import com.droiddeck.launcher.stores.download.DownloadEntry import com.droiddeck.launcher.stores.download.DownloadQueue import com.droiddeck.launcher.stores.download.DownloadStage +import org.json.JSONObject import java.io.File import java.util.concurrent.atomic.AtomicBoolean @@ -85,6 +87,13 @@ object GogBackend : StoreBackend { override fun uninstall(context: Context, game: InstalledStoreGame) {} + private fun fetchLogo(id: String): String? = + runCatching { StoreNet.get("https://api.gog.com/v2/games/$id?locale=en-US")?.let { logoOf(JSONObject(it)) } }.getOrNull() + + /** `images.logo2x` from the products API is an opaque banner, not a logo; the v2 `logo` link is a transparent PNG. */ + internal fun logoOf(v2: JSONObject): String? = + GogStoreCatalog.absolutize(v2.optJSONObject("_links")?.optJSONObject("logo")?.optString("href")).takeIf { it.startsWith("https://") } + private class InstallJob(val app: Context, val game: GogGame, val item: CatalogItem, val folder: File) : DownloadQueue.DownloadJob { private val cancelled = AtomicBoolean(false) @@ -93,24 +102,31 @@ object GogBackend : StoreBackend { StoreInstalls.begin(folder, Store.GOG, game.gameId, game.title, item.tallImageUrl ?: item.imageUrl, item.imageUrl) var downloading = false val language = item.extra[StoreLanguage.EXTRA] ?: StoreLanguage.preferredCode(app) - val result = GogDownloadManager.install(app, game, folder, object : GogDownloadManager.Callback { - override fun onProgress(message: String, pct: Int) { - when { - message.startsWith("Finishing") -> handle.stage(DownloadStage.INSTALL, message) - message.startsWith("Downloading") || message.startsWith("Verified") || message.startsWith("Resuming") -> { - if (!downloading) { downloading = true; handle.stage(DownloadStage.DOWNLOAD, message) } else handle.progress(-1, -1, message) + val result = try { + GogDownloadManager.install(app, game, folder, object : GogDownloadManager.Callback { + override fun onProgress(message: String, pct: Int) { + when { + message.startsWith("Finishing") || message.startsWith("Extracting") -> handle.stage(DownloadStage.INSTALL, message) + message.startsWith("Downloading") || message.startsWith("Verified") || message.startsWith("Resuming") -> { + if (!downloading) { downloading = true; handle.stage(DownloadStage.DOWNLOAD, message) } else handle.progress(-1, -1, message) + } + else -> handle.stage(DownloadStage.MANIFEST, message) } - else -> handle.stage(DownloadStage.MANIFEST, message) } + override fun onBytes(done: Long, total: Long, speedBps: Long) { handle.progress(done, total, null, speedBps) } + override fun onLog(line: String) { handle.log(line) } + override fun onSizes(downloadBytes: Long, diskBytes: Long) { handle.diskSize(diskBytes) } + override fun onStage(stage: String, done: Long, total: Long, items: Int, itemsTotal: Int) { + handle.stage(when (stage) { "check" -> DownloadStage.MANIFEST; "verify" -> DownloadStage.VERIFY; else -> DownloadStage.INSTALL }) + handle.stageProgress(done, total, items, itemsTotal, bytes = stage == "install") + } + }, cancelled, language) + } catch (t: Throwable) { + if (t is GogDownloadManager.InstallerException && !cancelled.get()) { + StoreInstalls.discard(app, folder, listOf(File(folder, ".gog_chunks"))) } - override fun onBytes(done: Long, total: Long, speedBps: Long) { handle.progress(done, total, null, speedBps) } - override fun onLog(line: String) { handle.log(line) } - override fun onSizes(downloadBytes: Long, diskBytes: Long) { handle.diskSize(diskBytes) } - override fun onStage(stage: String, done: Long, total: Long, items: Int, itemsTotal: Int) { - handle.stage(when (stage) { "check" -> DownloadStage.MANIFEST; "verify" -> DownloadStage.VERIFY; else -> DownloadStage.INSTALL }) - handle.stageProgress(done, total, items, itemsTotal, bytes = stage == "install") - } - }, cancelled, language) ?: return null + throw t + } ?: return null if (cancelled.get()) return null handle.stage(DownloadStage.INSTALL, app.getString(com.droiddeck.launcher.R.string.stores_registering)) val installedLanguage = result.language?.takeIf { it.isNotEmpty() } ?: language @@ -118,6 +134,7 @@ object GogBackend : StoreBackend { Store.GOG, game.gameId, game.title, exe = result.exeRelative.ifEmpty { "" }, installVersion = result.buildId, installedAt = System.currentTimeMillis(), cover = item.tallImageUrl ?: item.imageUrl, hero = item.imageUrl, + logo = item.logoUrl ?: fetchLogo(game.gameId), extra = mapOf(com.droiddeck.launcher.frontend.WinCompSources.GOG_DEPENDENCIES to result.dependencies.joinToString(",")) + (if (result.dependenciesRead) mapOf(com.droiddeck.launcher.frontend.WinCompSources.LIST_READ to "1") else emptyMap()) + mapOf(StoreLanguage.EXTRA to installedLanguage), diff --git a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogDownloadManager.java b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogDownloadManager.java index 05b182df7..dae63e5a2 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogDownloadManager.java +++ b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogDownloadManager.java @@ -3,6 +3,8 @@ package com.droiddeck.launcher.stores.gog; import android.content.Context; import android.util.Log; +import com.droiddeck.launcher.frontend.AddedGames; +import com.droiddeck.launcher.runtime.GuestCommand; import com.droiddeck.launcher.stores.StoreLanguage; import com.droiddeck.launcher.stores.StoreLog; import com.droiddeck.launcher.stores.download.StoreDownloadTier; @@ -21,7 +23,9 @@ import java.io.InputStream; import java.io.InputStreamReader; import java.net.HttpURLConnection; import java.net.URL; +import java.nio.file.Files; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.concurrent.Callable; @@ -63,10 +67,15 @@ public final class GogDownloadManager { Result(String exeRelative, String buildId, long bytes) { this.exeRelative = exeRelative; this.buildId = buildId; this.bytes = bytes; } } - public static final class InstallException extends Exception { + public static class InstallException extends Exception { InstallException(String message) { super(message); } } + /** A failure of the offline-installer fallback; its partial files can be discarded. */ + public static final class InstallerException extends InstallException { + InstallerException(String message) { super(message); } + } + private GogDownloadManager() {} public static Result install(Context ctx, GogGame game, File installPath, Callback cb, AtomicBoolean cancelled) throws InstallException { @@ -424,41 +433,149 @@ public final class GogDownloadManager { String productJson = httpGet("https://api.gog.com/products/" + game.gameId + "?expand=downloads", token); if (productJson == null) throw new InstallException("No downloadable builds for this game"); JSONObject downloads = new JSONObject(productJson).optJSONObject("downloads"); - JSONArray installers = downloads == null ? null : downloads.optJSONArray("installers"); - if (installers == null || installers.length() == 0) throw new InstallException("No downloadable builds for this game"); - String manualUrl = null, fileName = null; - for (int i = 0; i < installers.length(); i++) { - JSONObject inst = installers.getJSONObject(i); - if (!"windows".equals(inst.optString("os"))) continue; - JSONArray files = inst.optJSONArray("files"); - if (files != null && files.length() > 0) { - JSONObject f = files.getJSONObject(0); - manualUrl = f.optString("downlink", null); - if (manualUrl == null) manualUrl = f.optString("manualUrl", null); - fileName = f.optString("filename", null); - } - if (manualUrl == null) manualUrl = inst.optString("manualUrl", null); - if (fileName == null || fileName.isEmpty()) fileName = "setup_" + game.gameId + ".exe"; - break; - } - if (manualUrl == null) throw new InstallException("No Windows installer for this game"); - String downloadUrl = resolveRedirect(manualUrl, token); - if (downloadUrl == null) throw new InstallException("The installer link could not be resolved"); + List<InstallerPart> parts = installerParts(downloads, game.gameId); + if (parts.isEmpty()) throw new InstallException("No downloadable builds for this game"); installPath.mkdirs(); - File outFile = new File(installPath, fileName); - cb.onProgress("Downloading installer: " + fileName, 15); - long size = downloadWithProgress(downloadUrl, outFile, cb, cancelled); + long planned = 0; + boolean sized = true; + for (InstallerPart part : parts) { planned += part.size; if (part.size <= 0) sized = false; } + if (sized) { + long usable = com.droiddeck.launcher.stores.StoreInstallRoot.availableBytes(installPath); + if (com.droiddeck.launcher.stores.StoreInstallRoot.insufficientSpace(planned, usable)) { + throw new InstallException("Not enough free space: need " + formatBytes(planned) + ", only " + formatBytes(usable) + " free"); + } + } + long total = 0; + for (InstallerPart part : parts) { + if (cancelled.get()) return null; + String downloadUrl = resolveRedirect(part.url, token); + if (downloadUrl == null) throw new InstallException("The installer link could not be resolved: " + part.filename); + File outFile; + try { + outFile = installerDestination(installPath, part.filename); + } catch (IOException e) { + throw new InstallException("The installer part is not inside the game folder: " + part.filename); + } + cb.onProgress("Downloading installer: " + part.filename, 15 + (int) ((total / (float) Math.max(1, planned)) * 80)); + long size = downloadWithProgress(downloadUrl, outFile, cb, cancelled, total, sized ? planned : 0); + if (cancelled.get()) return null; + if (size <= 0) throw new InstallException("The installer download failed: " + part.filename); + total += size; + } if (cancelled.get()) return null; - if (size <= 0) throw new InstallException("The installer download failed"); - cb.onLog("gog: installer downloaded; the game installs itself on first launch"); - return new Result(fileName, "", size); - } catch (InstallException e) { + cb.onLog("gog: installer downloaded (" + parts.size() + " file(s)); extracting natively"); + extractInstaller(ctx, installPath, parts.get(0).filename, cb, cancelled); + if (cancelled.get()) return null; + for (InstallerPart part : parts) new File(installPath, part.filename).delete(); + deleteDir(new File(installPath, "tmp")); + deleteDir(new File(installPath, "commonappdata")); + String exe = pickExe(installPath, null, game.title); + if (exe.isEmpty()) throw new InstallException("The installer was extracted but no game executable was found"); + cb.onLog("gog: installer extracted natively; game exe " + exe); + return new Result(exe, "", total); + } catch (InstallerException e) { throw e; + } catch (InstallException e) { + throw new InstallerException(e.getMessage()); } catch (Exception e) { - throw new InstallException("Installer download error: " + e.getClass().getSimpleName()); + throw new InstallerException("Installer download error: " + e.getClass().getSimpleName()); } } + /** + * GOG's Windows installer is a 32-bit Inno Setup stub. On ARM64 it runs through + * Proton/FEX, where its path strings are corrupted before CreateFileW (observed on + * Wine 11 + FEX: "Path not found"). Extracting the same files with innoextract in + * the Linux guest avoids that path entirely. + */ + private static void extractInstaller(Context ctx, File installPath, String setupName, Callback cb, AtomicBoolean cancelled) throws InstallException { + String guestDir = AddedGames.INSTANCE.guestPath(ctx, installPath); + if (guestDir == null || guestDir.isEmpty()) { + throw new InstallException("The game folder is not visible to the Linux session"); + } + String guestSetup = guestDir + "/" + setupName; + cb.onProgress("Extracting installer…", 95); + int rc; + try { + rc = GuestCommand.INSTANCE.run(ctx, Arrays.asList( + "/usr/local/bin/droiddeck-innoextract", "--gog", "--silent", + "-d", guestDir, guestSetup), + false, "gog-innoextract", null, null, false, cancelled, + line -> { cb.onLog("gog innoextract: " + line); return kotlin.Unit.INSTANCE; }); + } catch (Exception e) { + throw new InstallException("The installer could not be extracted: " + e.getClass().getSimpleName()); + } + if (cancelled.get()) return; + if (rc != 0) throw new InstallException("The installer could not be extracted (innoextract exit " + rc + ")"); + } + + static final class InstallerPart { + final String url; + final String filename; + final long size; + InstallerPart(String url, String filename, long size) { this.url = url; this.filename = filename; this.size = size; } + } + + /** + * Every file of the first usable Windows installer. GOG lists a setup stub and its data parts as + * separate files, often without a filename; the parts are named after the stub so the installer + * finds them when launched (setup_<id>.exe, setup_<id>-1.bin, ...). + */ + static List<InstallerPart> installerParts(JSONObject downloads, String gameId) { + List<InstallerPart> out = new ArrayList<>(); + if (downloads == null) return out; + JSONArray installers = downloads.optJSONArray("installers"); + if (installers == null) return out; + for (int i = 0; i < installers.length(); i++) { + JSONObject inst = installers.optJSONObject(i); + if (inst == null || !"windows".equals(inst.optString("os"))) continue; + JSONArray files = inst.optJSONArray("files"); + String base = null; + if (files != null) { + for (int j = 0; j < files.length(); j++) { + JSONObject f = files.optJSONObject(j); + if (f == null) continue; + String url = f.optString("downlink", ""); + if (url.isEmpty()) url = f.optString("manualUrl", ""); + if (url.isEmpty()) continue; + String name = partName(f.optString("filename", "")); + if (name.isEmpty()) { + if (base == null) base = "setup_" + safeName(gameId); + name = out.isEmpty() ? base + ".exe" : base + "-" + out.size() + ".bin"; + } else if (base == null) { + base = name.toLowerCase(java.util.Locale.US).endsWith(".exe") ? name.substring(0, name.length() - 4) : name; + } + out.add(new InstallerPart(url, name, f.optLong("size", 0))); + } + } + if (out.isEmpty()) { + String url = inst.optString("manualUrl", ""); + if (url.isEmpty()) url = inst.optString("downlink", ""); + if (!url.isEmpty()) out.add(new InstallerPart(url, "setup_" + safeName(gameId) + ".exe", inst.optLong("total_size", 0))); + } + if (!out.isEmpty()) return out; + } + return out; + } + + static File installerDestination(File installPath, String filename) throws IOException { + File root = installPath.getCanonicalFile(); + File candidate = new File(root, filename); + if (Files.isSymbolicLink(candidate.toPath())) throw new IOException("installer part is a link"); + File out = candidate.getCanonicalFile(); + if (out.equals(root) || !out.toPath().startsWith(root.toPath())) throw new IOException("installer part leaves the game folder"); + return out; + } + + private static String partName(String raw) { + return raw == null ? "" : new File(raw.replace('\\', '/')).getName(); + } + + private static String safeName(String raw) { + String cleaned = raw == null ? "" : raw.replaceAll("[^A-Za-z0-9._-]", "_"); + return cleaned.isEmpty() ? "game" : cleaned; + } + private static String resolveRedirect(String url, String token) { try { if (url.startsWith("/")) url = "https://www.gog.com" + url; @@ -491,7 +608,7 @@ public final class GogDownloadManager { return null; } - private static long downloadWithProgress(String url, File out, Callback cb, AtomicBoolean cancelled) { + private static long downloadWithProgress(String url, File out, Callback cb, AtomicBoolean cancelled, long doneBefore, long planned) { try { HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); conn.setConnectTimeout(TIMEOUT); conn.setReadTimeout(60_000); @@ -507,7 +624,11 @@ public final class GogDownloadManager { downloaded += n; windowBytes += n; long elapsed = System.currentTimeMillis() - windowStart; if (elapsed >= 500) { speed = windowBytes * 1000L / elapsed; windowStart = System.currentTimeMillis(); windowBytes = 0; } - if (total > 0) { + if (planned > 0) { + long shown = doneBefore + downloaded; + cb.onProgress("Downloading: " + out.getName() + speedSuffix(speed), 15 + (int) ((shown / (float) planned) * 80)); + cb.onBytes(shown, planned, speed); + } else if (total > 0) { cb.onProgress("Downloading: " + out.getName() + speedSuffix(speed), 15 + (int) ((downloaded / (float) total) * 80)); cb.onBytes(downloaded, total, speed); } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchApi.kt b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchApi.kt index 92635630d..496691fee 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchApi.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchApi.kt @@ -20,7 +20,7 @@ internal object ItchApi { fun ownedKeys(key: String, page: Int): JSONObject? = get("/profile/owned-keys?page=$page&per_page=500", key) fun uploads(key: String, gameId: String, downloadKeyId: String): JSONArray? = - get("/games/$gameId/uploads?download_key_id=$downloadKeyId", key)?.optJSONArray("uploads") + get("/games/$gameId/uploads" + keyQuery(downloadKeyId), key)?.optJSONArray("uploads") fun search(key: String, query: String, page: Int): JSONArray? { val q = java.net.URLEncoder.encode(query, "UTF-8") @@ -28,7 +28,9 @@ internal object ItchApi { } fun downloadUrl(uploadId: String, downloadKeyId: String): String = - "$BASE/uploads/$uploadId/download?download_key_id=$downloadKeyId" + "$BASE/uploads/$uploadId/download" + keyQuery(downloadKeyId) + + private fun keyQuery(downloadKeyId: String): String = if (downloadKeyId.isBlank()) "" else "?download_key_id=$downloadKeyId" fun pageUrl(slug: String): String = "$WEB/$slug" } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchBackend.kt b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchBackend.kt index b1bf834c1..66ca72602 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchBackend.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchBackend.kt @@ -55,7 +55,7 @@ object ItchBackend : StoreBackend { override fun install(context: Context, item: CatalogItem, root: File) { val app = context.applicationContext - if (item.extra["downloadKeyId"].isNullOrEmpty()) { + if (item.extra["downloadKeyId"].isNullOrEmpty() && !item.isFree) { StoresState.logLine("itch.io: \"${item.title}\" is not in the library") return } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchBrowse.kt b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchBrowse.kt new file mode 100644 index 000000000..d430d1143 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchBrowse.kt @@ -0,0 +1,176 @@ +package com.droiddeck.launcher.stores.itch + +import android.content.Context +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.droiddeck.launcher.stores.CatalogItem +import com.droiddeck.launcher.stores.StoresState +import org.json.JSONArray +import java.io.File +import java.security.MessageDigest + +internal data class BrowseQuery( + val sort: String = ItchBrowse.SORT_POPULAR, + val price: String = ItchBrowse.PRICE_ANY, + val platforms: Set<String> = emptySet(), + val tags: List<String> = emptyList(), +) { + val isDefault: Boolean get() = this == BrowseQuery() +} + +internal class BrowsePage(val items: List<CatalogItem>, val code: Int, val cached: Boolean) + +internal object ItchBrowse { + const val SORT_POPULAR = "popular" + const val SORT_NEW_POPULAR = "new-and-popular" + const val SORT_TOP_SELLERS = "top-sellers" + const val SORT_TOP_RATED = "top-rated" + const val SORT_NEWEST = "newest" + val sorts = listOf(SORT_POPULAR, SORT_NEW_POPULAR, SORT_TOP_SELLERS, SORT_TOP_RATED, SORT_NEWEST) + + const val PRICE_ANY = "any" + const val PRICE_FREE = "free" + const val PRICE_SALE = "on-sale" + const val PRICE_PAID = "store" + const val PRICE_UNDER_5 = "5-dollars-or-less" + const val PRICE_UNDER_15 = "15-dollars-or-less" + val prices = listOf(PRICE_ANY, PRICE_FREE, PRICE_SALE, PRICE_PAID, PRICE_UNDER_5, PRICE_UNDER_15) + + val platformNames = listOf("windows", "osx", "linux", "android", "ios", "web", "mobile-web") + + private const val BASE = "https://itch.io/games" + private const val FRESH_MS = 3 * 60 * 60 * 1000L + private const val GAP_MS = 1_200L + private const val RETRY_MS = 8_000L + private const val FOLDER = "itch-browse" + + @Volatile private var lastRequestAt = 0L + private val lock = Any() + + private fun sortSegment(sort: String): String? = sort.takeIf { it != SORT_POPULAR && it in sorts } + + private fun priceSegment(price: String): String? = price.takeIf { it != PRICE_ANY && it in prices } + + fun path(query: BrowseQuery): String { + val segments = listOfNotNull(sortSegment(query.sort), priceSegment(query.price)) + + platformNames.filter { it in query.platforms }.map { "platform-$it" } + + query.tags.map { "tag-" + tagSlug(it) } + return if (segments.isEmpty()) BASE else BASE + "/" + segments.joinToString("/") + } + + fun url(query: BrowseQuery, page: Int): String = path(query) + ".xml" + if (page > 1) "?page=$page" else "" + + fun tagSlug(tag: String): String = tag.trim().lowercase().replace(Regex("[^a-z0-9]+"), "-").trim('-') + + fun page(context: Context, query: BrowseQuery, page: Int, force: Boolean = false): BrowsePage { + val app = context.applicationContext + val url = url(query, page) + if (!force) read(app, url)?.let { return BrowsePage(it, 200, true) } + val (status, body) = fetch(url) + val items = if (status in 200..299 && body != null) ItchShelves.parse(body) else emptyList() + StoresState.logLine("itch.io browse: $url answered $status with ${items.size} games") + if (items.isNotEmpty()) write(app, url, items) + return BrowsePage(items, status, false) + } + + private fun fetch(url: String): Pair<Int, String?> { + synchronized(lock) { + pause() + var answer = ItchShelves.open(url) + if (answer.first == 429) { + StoresState.logLine("itch.io browse: 429, waiting before one more try") + Thread.sleep(RETRY_MS) + pause() + answer = ItchShelves.open(url) + } + lastRequestAt = System.currentTimeMillis() + return answer + } + } + + private fun pause() { + val gap = GAP_MS - (System.currentTimeMillis() - lastRequestAt) + if (gap > 0) Thread.sleep(gap) + } + + private fun file(context: Context, url: String): File { + val digest = MessageDigest.getInstance("SHA-1").digest(url.toByteArray()).joinToString("") { "%02x".format(it) } + return File(File(context.cacheDir, FOLDER), "$digest.json") + } + + private fun read(context: Context, url: String): List<CatalogItem>? { + val file = file(context, url) + if (!file.isFile || System.currentTimeMillis() - file.lastModified() > FRESH_MS) return null + return runCatching { ItchCatalog.list(JSONArray(file.readText())) }.getOrNull()?.takeIf { it.isNotEmpty() } + } + + private fun write(context: Context, url: String, items: List<CatalogItem>) { + val file = file(context, url) + runCatching { + file.parentFile?.mkdirs() + file.writeText(ItchCatalog.array(items).toString()) + } + } +} + +internal object ItchBrowseState { + var query by mutableStateOf(BrowseQuery()) + private set + var items by mutableStateOf<List<CatalogItem>>(emptyList()) + private set + var loading by mutableStateOf(false) + private set + var ended by mutableStateOf(false) + private set + var failed by mutableStateOf(false) + private set + + private var next = 1 + private var generation = 0 + + val active: Boolean get() = !query.isDefault + + fun choose(context: Context, wanted: BrowseQuery) { + if (wanted == query) return + generation++ + query = wanted + items = emptyList() + ended = false + failed = false + loading = false + next = 1 + if (active) more(context) + } + + fun more(context: Context, retry: Boolean = false) { + if (loading || ended || !active || (failed && !retry)) return + loading = true + failed = false + val app = context.applicationContext + val mine = generation + val asked = query + val page = next + Thread({ + val answer = runCatching { ItchBrowse.page(app, asked, page) }.getOrNull() + StoresState.post { + if (mine != generation) return@post + loading = false + if (answer == null || answer.code !in 200..299) { + failed = true + return@post + } + if (answer.items.isEmpty()) { + ended = true + return@post + } + val known = items.mapTo(HashSet()) { it.id } + val fresh = answer.items.filter { known.add(it.id) } + if (fresh.isEmpty()) ended = true else { + items = items + fresh + next = page + 1 + } + } + }, "itch-browse").start() + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchGame.kt b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchGame.kt new file mode 100644 index 000000000..39b69ea3f --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchGame.kt @@ -0,0 +1,44 @@ +package com.droiddeck.launcher.stores.itch + +import com.droiddeck.launcher.stores.CatalogItem +import com.droiddeck.launcher.stores.StoreNet +import org.json.JSONArray + +internal class ItchBuild( + val uploadId: String, + val filename: String, + val size: Long, + val updatedAt: String, + val platforms: Set<String>, + val demo: Boolean, +) { + val installable: Boolean get() = "windows" in platforms +} + +internal object ItchGame { + private val PATH_AFTER = Regex("""name="itch:path"[^>]*content="games/(\d+)"""") + private val PATH_BEFORE = Regex("""content="games/(\d+)"[^>]*name="itch:path"""") + + fun idFromPage(html: String): String? = + PATH_AFTER.find(html)?.groupValues?.get(1) ?: PATH_BEFORE.find(html)?.groupValues?.get(1) + + fun resolveId(item: CatalogItem): String? { + item.extra["gameId"]?.takeIf { it.isNotBlank() }?.let { return it } + if (item.storeUrl.isBlank()) return null + val html = StoreNet.get(item.storeUrl) ?: return null + return idFromPage(html) + } + + fun builds(uploads: JSONArray?): List<ItchBuild> { + if (uploads == null) return emptyList() + return (0 until uploads.length()).mapNotNull { uploads.optJSONObject(it) } + .filter { it.optString("storage") != "external" } + .map { + ItchBuild( + it.optString("id"), ItchUploads.filename(it), ItchUploads.size(it), + it.optString("updated_at", it.optString("updatedAt")), ItchUploads.platforms(it), it.optBoolean("demo"), + ) + } + .sortedWith(compareByDescending<ItchBuild> { it.installable }.thenByDescending { it.updatedAt }) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchInstaller.kt b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchInstaller.kt index 96ec0ae35..5037de863 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchInstaller.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchInstaller.kt @@ -38,11 +38,14 @@ internal class ItchInstaller( val key = ItchAuth.key(app) if (key.isBlank()) throw IllegalStateException(app.getString(R.string.stores_sync_sign_in)) val downloadKeyId = item.extra["downloadKeyId"].orEmpty() - if (downloadKeyId.isBlank()) throw IllegalStateException(app.getString(R.string.stores_itch_not_owned)) - val gameId = item.extra["gameId"].orEmpty().ifBlank { item.id.substringAfterLast('/') } + if (downloadKeyId.isBlank() && !item.isFree) throw IllegalStateException(app.getString(R.string.stores_itch_not_owned)) + val gameId = ItchGame.resolveId(item) ?: throw IllegalStateException(app.getString(R.string.stores_itch_no_game)) + val resuming = StoreGameSidecar.read(folder)?.let { it.state == StoreGameSidecar.STATE_INSTALLING && it.id == item.id } == true StoreInstalls.begin(folder, Store.ITCH, item.id, item.title, item.imageUrl, item.imageUrl) handle.stage(DownloadStage.MANIFEST) - val upload = ItchUploads.windows(ItchApi.uploads(key, gameId, downloadKeyId)) + val uploads = ItchApi.uploads(key, gameId, downloadKeyId) + val chosen = item.extra["uploadId"].orEmpty() + val upload = (if (chosen.isBlank()) ItchUploads.windows(uploads) else ItchUploads.byId(uploads, chosen)) ?: throw IllegalStateException(app.getString(R.string.stores_itch_no_upload)) val uploadId = upload.optString("id") val filename = ItchUploads.filename(upload) @@ -55,7 +58,7 @@ internal class ItchInstaller( download(key, downloadKeyId, uploadId, upload.optString("updated_at", upload.optString("updatedAt")), archive, size, handle) if (cancelled.get()) return null handle.stage(DownloadStage.INSTALL) - install(archive, filename, handle) + install(archive, filename, handle, resuming) } catch (t: Throwable) { if (!cancelled.get()) StoreInstalls.discard(app, folder, listOf(scratch)) throw t @@ -147,8 +150,16 @@ internal class ItchInstaller( } } - private fun install(archive: File, filename: String, handle: DownloadQueue.JobHandle): String? { + private fun clearPartialInstall() { + folder.listFiles()?.forEach { + if (it.name == StoreGameSidecar.FILE_NAME) return@forEach + if (it.isDirectory) StoreInstalls.deleteTree(it) else it.delete() + } + } + + private fun install(archive: File, filename: String, handle: DownloadQueue.JobHandle, resuming: Boolean): String? { folder.mkdirs() + if (resuming) clearPartialInstall() when (val kind = sniff(archive, filename)) { "zip" -> unzip(archive, handle) "7z" -> sevenZip(archive, handle) diff --git a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchShelves.kt b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchShelves.kt index 0fd783ed6..acf332431 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchShelves.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchShelves.kt @@ -108,7 +108,7 @@ internal object ItchShelves { return shelves.takeUnless { it.isEmpty } } - private fun open(url: String): Pair<Int, String?> { + internal fun open(url: String): Pair<Int, String?> { var conn: HttpURLConnection? = null return try { conn = (URL(url).openConnection() as HttpURLConnection).apply { diff --git a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchUploads.kt b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchUploads.kt index 773ad22c3..7e3ed94b4 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchUploads.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchUploads.kt @@ -30,6 +30,28 @@ internal object ItchUploads { return names.none { it in PLATFORM_TRAITS } } + fun platforms(upload: JSONObject): Set<String> { + val found = LinkedHashSet<String>() + upload.optJSONObject("platforms")?.let { flags -> + for (name in listOf("windows", "linux", "osx", "android")) if (flags.optBoolean(name)) found.add(name) + } + upload.optJSONArray("traits")?.let { traits -> + for (i in 0 until traits.length()) when (traits.optString(i)) { + "p_windows" -> found.add("windows") + "p_linux" -> found.add("linux") + "p_osx" -> found.add("osx") + "p_android" -> found.add("android") + } + } + if (upload.optString("type") == "html") found.add("web") + return if (found.isEmpty()) setOf("windows") else found + } + + fun byId(uploads: JSONArray?, id: String): JSONObject? { + if (uploads == null || id.isBlank()) return null + return (0 until uploads.length()).mapNotNull { uploads.optJSONObject(it) }.firstOrNull { it.optString("id") == id } + } + fun size(upload: JSONObject): Long = upload.optLong("size", upload.optLong("size_bytes")) fun filename(upload: JSONObject): String = upload.optString("filename").substringAfterLast('/') diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxBackend.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxBackend.kt index 24d7ff622..d678a2f41 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxBackend.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxBackend.kt @@ -26,6 +26,8 @@ object XboxBackend : StoreBackend { access = emptyMap() } XboxLibraryCache.clear(context) + XboxCloudSession.clear() + XboxCloudActivity.clearWebSignIn(context) XboxRuntime.stop() } StoresState.post { packageChoice = null; StoresState.shelves.remove(store); StoresState.found.remove(store) } @@ -49,17 +51,21 @@ object XboxBackend : StoreBackend { if (current == generation) { if (cached != null) StoresState.library[store] = mergeItems(StoresState.library[store], cached.games) StoresState.problems.remove(store) - if (cached == null || force) StoresState.status[store] = "Syncing Xbox library…" + if (cached == null || force) StoresState.status[store] = app.getString(com.droiddeck.launcher.R.string.stores_sync_library) else StoresState.status.remove(store) } } - if (cached != null && !cached.needsRefresh(force)) return@Thread + if (cached != null && !cached.needsRefresh(force)) { XboxGamertag.ensure(app); return@Thread } val response = Xodus.run(app, listOf("library", "--json", "--market", market)) val licenses = XboxCatalog.access(response, account, market, report = { StoresState.logLine("Xbox: $it") }) val products = XboxCatalog.rawProducts(licenses.keys.toList(), market) - val games = products.mapNotNull { XboxCatalog.product(it, licenses) }.sortedBy { it.title.lowercase() } + val cloud = runCatching { XboxCatalog.cloudCatalog(market) } + .onFailure { StoresState.logLine("Xbox: cloud lists unavailable (${it.javaClass.simpleName})") }.getOrNull() + val games = mergeEditions(products.mapNotNull { XboxCatalog.product(it, licenses, cloud) }).sortedBy { it.title.lowercase() } StoresState.logLine("Xbox: library catalog requested=${licenses.size} returned=${products.size} supported=${games.size} " + - "filtered=${products.size - games.size} backend-skipped=${response.optInt("skippedEntitlements")}") + "filtered=${products.size - games.size} backend-skipped=${response.optInt("skippedEntitlements")} " + + "pc=${games.count { it.xboxPc }} cloud=${games.count { it.xboxCloud }} cloud-only=${games.count { !it.xboxPc }} " + + "cloud-lists=${cloud?.gamePass?.size ?: -1}/${cloud?.all?.size ?: -1}") synchronized(this) { if (current != generation || StoreAccounts.signedInAs(app, store) != account) return@Thread access = licenses @@ -73,6 +79,7 @@ object XboxBackend : StoreBackend { StoresState.problems.remove(store) } } + if (current == generation) XboxGamertag.ensure(app) } catch (e: Exception) { StoresState.post { if (current == generation) { diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCatalog.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCatalog.kt index 100371b06..d4a16c409 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCatalog.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCatalog.kt @@ -10,7 +10,7 @@ import java.time.Instant internal object XboxCatalog { private val productId = Regex("[A-Z0-9]{12}") private val plans = setOf("CFQ7TTC0KGQ8", "CFQ7TTC0KHS0", "CFQ7TTC0P85B", "CFQ7TTC0K5DJ", "CFQ7TTC0K6L8", "CFQ7TTC10QFD") - /** Plans that include PC games; the retired console-only plan must not offer a Windows install. */ + /** Plans whose store offers advertise PC games. Library entitlements under any active plan count, as the backend resolves a PC package for them. */ private val pcPlans = mapOf( "CFQ7TTC0KGQ8" to "PC Game Pass", "CFQ7TTC0KHS0" to "Game Pass Ultimate", @@ -61,7 +61,7 @@ internal object XboxCatalog { val parent = row.optString("satisfiedByProductId").takeUnless { it.isBlank() || it == "null" } if (parent == null) return if (id in plans) null else "owned" if (parent in blocked) return null - if (parent in plans) return if (parent in pcPlans && activePlan(parent)) "gamepass" else null + if (parent in plans) return if (activePlan(parent)) "gamepass" else null return kind(parent, visited + id) } val result = rows.keys.mapNotNull { id -> kind(id)?.let { id to it } }.toMap() @@ -98,7 +98,7 @@ internal object XboxCatalog { return products(objects(body.optJSONArray("productsList")).map { it.optString("productId") }.take(24), access) } - internal fun product(json: JSONObject, access: Map<String, String>): CatalogItem? { + internal fun product(json: JSONObject, access: Map<String, String>, cloud: XboxCloudCatalog? = null): CatalogItem? { val id = json.optString("ProductId").uppercase() if (!productId.matches(id) || json.optString("ProductType") != "Game") return null val localized = objects(json.optJSONArray("LocalizedProperties")).firstOrNull() ?: return null @@ -107,7 +107,9 @@ internal object XboxCatalog { objects(it.optJSONObject("Sku")?.optJSONObject("Properties")?.optJSONArray("Packages")) } val pc = packages.filter { pkg -> objects(pkg.optJSONArray("PlatformDependencies")).any { it.optString("PlatformName") == "Windows.Desktop" } } - if (pc.none { it.optString("PackageFormat").equals("MSIXVC", true) }) return null + val installable = pc.any { it.optString("PackageFormat").equals("MSIXVC", true) } + val streams = cloud?.streams(id, access[id].orEmpty()) == true + if (!installable && !streams) return null val images = objects(localized.optJSONArray("Images")) fun image(vararg kinds: String): String? = kinds.firstNotNullOfOrNull { purpose -> images.firstOrNull { it.optString("ImagePurpose") == purpose }?.optString("Uri")?.let { raw -> @@ -127,13 +129,57 @@ internal object XboxCatalog { storeUrl = "https://www.xbox.com/games/store/$id", publisher = localized.optString("PublisherName"), developer = localized.optString("DeveloperName"), description = localized.optString("ProductDescription"), - sizeBytes = pc.maxOfOrNull { it.optLong("MaxDownloadSizeInBytes") } ?: 0L, - extra = mapOf("market" to Xodus.market(), "access" to kind.orEmpty()), + sizeBytes = if (installable) pc.maxOfOrNull { it.optLong("MaxDownloadSizeInBytes") } ?: 0L else 0L, + extra = mapOf("market" to Xodus.market(), "access" to kind.orEmpty(), "pc" to flag(installable), "cloud" to flag(streams)), ) } + private const val CLOUD_GAME_PASS = "29a81209-df6f-41fd-a528-2ae6b91f719c" + private const val CLOUD_ALL = "ce573635-7c18-4d0c-9d68-90b932393470" + + /** Microsoft's public cloud lists (catalog.gamepass.com "sigls"; ids as Better xCloud by redphx, MIT, uses them). */ + fun cloudCatalog(market: String = Xodus.market()): XboxCloudCatalog = + XboxCloudCatalog(cloudList(CLOUD_GAME_PASS, market), cloudList(CLOUD_ALL, market)) + + private fun cloudList(id: String, market: String): Set<String> { + val body = JSONArray(StoreNet.get("https://catalog.gamepass.com/sigls/v2?id=$id&language=en-us&market=$market") ?: error("Xbox cloud list unavailable")) + return (0 until body.length()).mapNotNull { body.optJSONObject(it)?.optString("id")?.uppercase()?.takeIf(productId::matches) }.toSet() + } + + fun cloudUrl(id: String, title: String, cloudId: String = id): String = + "https://www.xbox.com/play/launch/${title.lowercase().replace(Regex("[^a-z0-9]+"), "-").trim('-').ifEmpty { "game" }}/$cloudId" + + internal fun flag(value: Boolean) = if (value) "1" else "0" + fun objects(array: JSONArray?): List<JSONObject> = if (array == null) emptyList() else (0 until array.length()).mapNotNull { array.optJSONObject(it) } private fun get(url: String): JSONObject = JSONObject(StoreNet.get(url) ?: error("Microsoft Store could not be reached. Try again.")) } + +internal data class XboxCloudCatalog(val gamePass: Set<String>, val all: Set<String>) { + fun streams(id: String, access: String): Boolean = when (access) { + "gamepass" -> id in gamePass + "owned" -> id in all || id in gamePass + else -> false + } +} + +val CatalogItem.xboxPc: Boolean get() = extra["pc"] != "0" +val CatalogItem.xboxCloud: Boolean get() = extra["cloud"] == "1" +val CatalogItem.xboxCloudId: String get() = extra["cloudId"]?.takeIf { it.isNotEmpty() } ?: id + +/** Microsoft lists console and PC editions as separate products; a cloud-only one with a PC game's title folds into that card. */ +internal fun mergeEditions(games: List<CatalogItem>): List<CatalogItem> { + fun key(title: String) = title.lowercase().replace(Regex("[^a-z0-9]+"), "") + val cloudOnly = games.filter { !it.xboxPc && it.xboxCloud }.groupBy { key(it.title) } + val folded = HashSet<String>() + val merged = games.map { game -> + if (!game.xboxPc || game.xboxCloud) game + else cloudOnly[key(game.title)]?.firstOrNull()?.let { console -> + folded.add(console.id) + game.copy(extra = game.extra + mapOf("cloud" to "1", "cloudId" to console.id)) + } ?: game + } + return merged.filter { it.id !in folded } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudActivity.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudActivity.kt new file mode 100644 index 000000000..1fa7bdfe5 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudActivity.kt @@ -0,0 +1,189 @@ +package com.droiddeck.launcher.stores.xbox + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.graphics.Color +import android.os.Bundle +import android.util.Log +import android.view.InputDevice +import android.view.KeyEvent +import android.view.View +import android.view.WindowManager +import android.webkit.ConsoleMessage +import android.webkit.CookieManager +import android.webkit.PermissionRequest +import android.webkit.WebChromeClient +import android.webkit.WebResourceRequest +import android.webkit.WebSettings +import android.webkit.WebView +import android.webkit.WebViewClient +import android.widget.FrameLayout +import androidx.activity.ComponentActivity +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import androidx.webkit.WebSettingsCompat +import androidx.webkit.WebViewCompat +import androidx.webkit.WebViewFeature + +/** + * Microsoft's web player (xbox.com/play) in its own process and WebView data directory: the store sign-in + * clears its own web data after each attempt, so the player keeps a separate Microsoft session. Signing out of + * the store clears it ([clearWebSignIn]). + */ +class XboxCloudActivity : ComponentActivity() { + private var web: WebView? = null + private var custom: View? = null + private var customCallback: WebChromeClient.CustomViewCallback? = null + private lateinit var root: FrameLayout + + @SuppressLint("SetJavaScriptEnabled") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + hideBars() + root = FrameLayout(this).apply { setBackgroundColor(Color.BLACK) } + setContentView(root) + Log.i(TAG, "WebView ${WebViewCompat.getCurrentWebViewPackage(this)?.versionName}") + + val view = WebView(this) + web = view + view.settings.apply { + javaScriptEnabled = true + domStorageEnabled = true + databaseEnabled = true + mediaPlaybackRequiresUserGesture = false + allowFileAccess = false + allowContentAccess = false + mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW + useWideViewPort = true + loadWithOverviewMode = true + userAgentString = chromeUserAgent(userAgentString) + } + // No "X-Requested-With: <package>" header: it marks the page as embedded in an app. + if (WebViewFeature.isFeatureSupported(WebViewFeature.REQUESTED_WITH_HEADER_ALLOW_LIST)) + WebSettingsCompat.setRequestedWithHeaderOriginAllowList(view.settings, emptySet()) + Log.i(TAG, "UA ${view.settings.userAgentString}") + CookieManager.getInstance().setAcceptCookie(true) + CookieManager.getInstance().setAcceptThirdPartyCookies(view, true) + view.webViewClient = object : WebViewClient() { + override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean = + request.url.scheme != "https" + override fun onPageFinished(view: WebView, url: String) { Log.i(TAG, "page ${url.substringBefore('?')}") } + } + view.webChromeClient = object : WebChromeClient() { + override fun onConsoleMessage(message: ConsoleMessage): Boolean { + Log.d(TAG, "console ${message.messageLevel()}: ${message.message().take(300)}") + return true + } + override fun onPermissionRequest(request: PermissionRequest) { + Log.i(TAG, "permission request ${request.resources.joinToString()} denied") + request.deny() + } + override fun onShowCustomView(view: View, callback: CustomViewCallback) { + custom?.let { root.removeView(it) } + custom = view; customCallback = callback + root.addView(view, FrameLayout.LayoutParams(-1, -1)) + hideBars() + } + override fun onHideCustomView() { + custom?.let { root.removeView(it) } + custom = null; customCallback = null + } + } + root.addView(view, FrameLayout.LayoutParams(-1, -1)) + if (savedInstanceState != null) view.restoreState(savedInstanceState) + else view.loadUrl(startUrl(intent)) + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + web?.loadUrl(startUrl(intent)) + } + + private fun startUrl(intent: Intent): String = + intent.getStringExtra("url")?.takeIf { url -> runCatching { java.net.URI(url) }.getOrNull()?.let { + it.scheme == "https" && it.host == "www.xbox.com" && it.userInfo == null && it.port == -1 } == true } ?: START_URL + + // Without the WebView markers the page treats this as Chrome for Android. + private fun chromeUserAgent(default: String): String = + default.replace("; wv)", ")").replace(Regex(" Version/\\d+(\\.\\d+)*"), "") + + private fun hideBars() { + WindowCompat.setDecorFitsSystemWindows(window, false) + WindowInsetsControllerCompat(window, window.decorView).apply { + hide(WindowInsetsCompat.Type.systemBars()) + systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + } + } + + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + if (hasFocus) hideBars() + } + + // A controller's unused B arrives as BACK; ask before leaving so a stray press never ends a stream. + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + if (event.keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_UP) { + val fromController = event.source and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD || + event.source and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK + when { + custom != null -> customCallback?.onCustomViewHidden() + fromController -> confirmLeave() + web?.canGoBack() == true -> web?.goBack() + else -> finish() + } + return true + } + if (event.keyCode == KeyEvent.KEYCODE_BACK) return true + return super.dispatchKeyEvent(event) + } + + private var leaving: android.app.AlertDialog? = null + + private fun confirmLeave() { + if (leaving?.isShowing == true) return + leaving = android.app.AlertDialog.Builder(this, android.R.style.Theme_DeviceDefault_Dialog_Alert) + .setTitle("Leave Xbox Cloud Gaming?") + .setMessage("The stream ends when you leave.") + .setPositiveButton("Leave") { _, _ -> finish() } + .setNegativeButton("Stay", null) + .show() + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + web?.saveState(outState) + } + + override fun onPause() { super.onPause(); CookieManager.getInstance().flush() } + + override fun onDestroy() { + web?.let { root.removeView(it); it.stopLoading(); it.destroy() } + web = null + super.onDestroy() + } + + companion object { + private const val TAG = "XboxCloud" + const val START_URL = "https://www.xbox.com/play" + + fun intent(context: Context, url: String? = null): Intent = Intent(context, XboxCloudActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) + .apply { if (url != null) putExtra("url", url) } + + const val PROCESS_SUFFIX = ":xboxcloud" + const val WEBVIEW_SUFFIX = "xboxcloud" + + fun clearWebSignIn(context: Context) { + val am = context.getSystemService(Context.ACTIVITY_SERVICE) as android.app.ActivityManager + am.runningAppProcesses.orEmpty().filter { it.processName.endsWith(PROCESS_SUFFIX) } + .forEach { android.os.Process.killProcess(it.pid) } + val suffix = WEBVIEW_SUFFIX + listOf(java.io.File(context.applicationInfo.dataDir, "app_webview_$suffix"), java.io.File(context.cacheDir.parentFile, "cache/webview_$suffix")) + .forEach { runCatching { it.deleteRecursively() } } + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudGames.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudGames.kt new file mode 100644 index 000000000..10b034eaa --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudGames.kt @@ -0,0 +1,135 @@ +package com.droiddeck.launcher.stores.xbox + +import android.content.Context +import android.content.Intent +import androidx.compose.runtime.mutableStateListOf +import com.droiddeck.launcher.frontend.Library +import com.droiddeck.launcher.session.SessionState +import com.droiddeck.launcher.stores.CatalogItem +import com.droiddeck.launcher.stores.Store +import com.droiddeck.launcher.stores.StoresState +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.net.HttpURLConnection +import java.net.URL + +/** Xbox games added to the Games tab to stream before (or without) installing them. */ +object XboxCloudGames { + class Entry(val id: String, val title: String, val poster: String?, val hero: String?) + + private const val PREFS = "xbox_cloud_games" + private val idPattern = Regex("[A-Z0-9]{12}") + val entries = mutableStateListOf<Entry>() + @Volatile private var loaded = false + + fun load(context: Context) { + if (loaded) return + loaded = true + val list = decode(context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString("games", null)) + StoresState.post { entries.clear(); entries.addAll(list) } + } + + fun isAdded(id: String) = entries.any { it.id == id } + + fun add(context: Context, item: CatalogItem) { + val app = context.applicationContext + if (isAdded(item.id)) return + entries.add(Entry(item.id, item.title, item.tallImageUrl, item.imageUrl)) + save(app) + Thread({ + fetch(item.tallImageUrl, File(artDir(app, item.id), "poster")) + fetch(item.imageUrl, File(artDir(app, item.id), "hero")) + StoresState.post { StoresState.notifyLibraryChanged(app) } + }, "xbox-cloud-art").start() + } + + fun remove(context: Context, id: String) { + val app = context.applicationContext + entries.removeAll { it.id == id } + save(app) + artDir(app, id).deleteRecursively() + } + + private fun save(app: Context) { + app.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putString("games", encode(entries.toList())).apply() + } + + internal fun encode(list: List<Entry>): String = JSONArray().apply { + list.forEach { put(JSONObject().put("id", it.id).put("title", it.title).put("poster", it.poster.orEmpty()).put("hero", it.hero.orEmpty())) } + }.toString() + + internal fun decode(text: String?): List<Entry> = runCatching { + val array = JSONArray(text ?: return emptyList()) + (0 until minOf(array.length(), 2000)).mapNotNull { array.optJSONObject(it) }.mapNotNull { o -> + val id = o.optString("id") + val title = o.optString("title") + if (!idPattern.matches(id) || title.isBlank()) null + else Entry(id, title.take(200), o.optString("poster").takeIf { it.isNotEmpty() }, o.optString("hero").takeIf { it.isNotEmpty() }) + }.distinctBy { it.id } + }.getOrDefault(emptyList()) + + /** Games-tab rows for the added games that are not installed (an installed copy has its own row). */ + fun rows(context: Context, installedIds: Set<String>): List<Library.SteamGame> = entries.filter { it.id !in installedIds }.map { e -> + val dir = artDir(context, e.id) + val appId = rowId(e.id) + Library.SteamGame(appId = appId, name = e.title, art = File(dir, "poster").takeIf { it.isFile }, library = Library.XBOX_CLOUD, + hero = File(dir, "hero").takeIf { it.isFile }, source = Store.XBOX.id, storeId = e.id) + } + + internal fun rowId(id: String): Int = ("xbox-cloud:$id").hashCode() or Int.MIN_VALUE + + private fun artDir(context: Context, id: String) = File(context.filesDir, "xbox-cloud-art/$id") + + private fun fetch(url: String?, target: File) { + if (url == null) return + runCatching { + val parsed = URL(url) + require(parsed.protocol == "https" && parsed.host in setOf("store-images.s-microsoft.com", "store-images.microsoft.com")) + val connection = parsed.openConnection() as HttpURLConnection + try { + connection.connectTimeout = 15_000; connection.readTimeout = 30_000 + check(connection.responseCode == 200) + val bytes = connection.inputStream.use { it.readBytes() } + require(bytes.size in 1..12_000_000) + target.parentFile?.mkdirs() + File(target.path + ".tmp").apply { writeBytes(bytes); check(renameTo(target)) } + } finally { connection.disconnect() } + } + } + + private val prefetching = java.util.concurrent.atomic.AtomicBoolean(false) + + /** + * Fetches the streaming tokens in the background while a cloud game is on screen (about 20 s), so Stream starts at + * once; both players take them from this process (the native one through XboxCloudTokenService). + */ + fun prefetch(context: Context) { + val app = context.applicationContext + if (!prefetching.compareAndSet(false, true)) return + Thread({ + try { + val account = com.droiddeck.launcher.stores.StoreAccounts.signedInAs(app, Store.XBOX) ?: return@Thread + runCatching { XboxCloudSession.get(app, account) } + .onFailure { StoresState.logLine("Xbox: cloud tokens not fetched ahead: ${it.message?.take(160)}") } + } finally { prefetching.set(false) } + }, "xcloud-prefetch").start() + } + + /** Streaming runs outside the Linux session; the session owns the screen, audio and controllers while it runs. */ + fun stream(context: Context, id: String, title: String) { + if (SessionState.running) { + android.widget.Toast.makeText(context, "Close the Steam session to stream Xbox games.", android.widget.Toast.LENGTH_LONG).show() + return + } + context.startActivity(XboxCloudPlayerActivity.playerIntent(context, id, title).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + } + + /** Whether the signed-in library says this game streams (cached library; reads disk). */ + fun streams(context: Context, id: String): Boolean = runCatching { + StoresState.library[Store.XBOX]?.firstOrNull { it.id == id }?.let { return it.xboxCloud } + val app = context.applicationContext + val account = com.droiddeck.launcher.stores.StoreAccounts.signedInAs(app, Store.XBOX) ?: return false + XboxLibraryCache.read(app, account, Xodus.market())?.games?.firstOrNull { it.id == id }?.xboxCloud == true + }.getOrDefault(false) +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudPlayerActivity.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudPlayerActivity.kt new file mode 100644 index 000000000..9b8d11c0d --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudPlayerActivity.kt @@ -0,0 +1,399 @@ +package com.droiddeck.launcher.stores.xbox + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.graphics.Color +import android.os.Bundle +import android.view.Gravity +import android.view.KeyEvent +import android.view.WindowManager +import android.webkit.JavascriptInterface +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebView +import android.webkit.WebViewClient +import android.widget.Button +import android.widget.FrameLayout +import android.widget.LinearLayout +import android.widget.TextView +import androidx.activity.ComponentActivity +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import androidx.webkit.WebViewAssetLoader +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.unit.dp +import com.droiddeck.launcher.stores.Store +import com.droiddeck.launcher.stores.StoreAccounts +import com.droiddeck.launcher.stores.StoresState +import org.json.JSONObject +import java.util.concurrent.Executors + +/** Runs the WebRTC client page (tools/xcloud-player); signalling and tokens stay on this side of the bridge. */ +class XboxCloudPlayerActivity : ComponentActivity() { + private lateinit var root: FrameLayout + private var web: WebView? = null + private var notice: LinearLayout? = null + private val worker = Executors.newSingleThreadExecutor() + private val signalling = Executors.newCachedThreadPool() + @Volatile private var session: XboxCloudSession.Session? = null + @Volatile private var sessionPath: String? = null + @Volatile private var titleId: String? = null + @Volatile private var closed = false + @Volatile private var webUrl: String? = null + @Volatile private var settings = XboxCloudSettings() + @Volatile private var sessionSignals: XboxCloudSignalling? = null + /** The settings this stream started with (stream-start settings changed since then need a restart). */ + @Volatile private var startedWith: XboxCloudSettings? = null + private var menu: androidx.compose.ui.platform.ComposeView? = null + + @SuppressLint("SetJavaScriptEnabled") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + hideBars() + watchBars() + root = FrameLayout(this).apply { setBackgroundColor(Color.BLACK) } + setContentView(root) + val requested = intent.getStringExtra("product").orEmpty() + showNotice("Signing in to Xbox Cloud Gaming…", withButtons = false) + worker.execute { + val result = runCatching { + val account = StoreAccounts.signedInAs(applicationContext, Store.XBOX) ?: error("Sign in to the Xbox store first.") + val cached = XboxLibraryCache.read(applicationContext, account, Xodus.market())?.games?.firstOrNull { it.id == requested } + val product = cached?.extra?.get("cloudId")?.takeIf { it.isNotEmpty() } ?: requested + webUrl = XboxCatalog.cloudUrl(requested, cached?.title ?: intent.getStringExtra("title").orEmpty(), product) + val active = XboxCloudSession.get(applicationContext, account) + val title = XboxCloudSession.titles(active)[product] ?: error("This game can't be streamed with this account here.") + settings = XboxCloudSettings.load(applicationContext) + startedWith = settings + session = active.forStream(settings.region, settings.osName); titleId = title + diag("cloud player: $product -> title $title, region ${java.net.URI(session!!.baseUri).host}, transfer=${active.userToken != null}") + } + runOnUiThread { + if (closed) return@runOnUiThread + result.onSuccess { openPlayer() }.onFailure { + diag("cloud player not started: ${it.javaClass.simpleName}: ${it.message?.take(160)}") + showNotice(it.message ?: "Xbox Cloud Gaming could not start.", withButtons = true) + } + } + } + } + + @SuppressLint("SetJavaScriptEnabled") + private fun openPlayer() { + val loader = WebViewAssetLoader.Builder().addPathHandler("/assets/", WebViewAssetLoader.AssetsPathHandler(this)).build() + val view = WebView(this) + web = view + view.settings.apply { + javaScriptEnabled = true + mediaPlaybackRequiresUserGesture = false + allowFileAccess = false + allowContentAccess = false + } + view.setBackgroundColor(Color.BLACK) + // The Gamepad API only sees controllers while the WebView has input focus. + view.isFocusable = true + view.isFocusableInTouchMode = true + view.addJavascriptInterface(Bridge(), "DDCloud") + view.webViewClient = object : WebViewClient() { + override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse? = + loader.shouldInterceptRequest(request.url) + override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean = true + } + view.webChromeClient = object : android.webkit.WebChromeClient() { + override fun onConsoleMessage(message: android.webkit.ConsoleMessage): Boolean { + android.util.Log.d("XboxCloudPlayer", "console ${message.messageLevel()}: ${message.message().take(300)}") + return true + } + } + root.addView(view, 0, FrameLayout.LayoutParams(-1, -1)) + notice?.let { root.removeView(it) } + notice = null + view.requestFocus() + view.loadUrl("https://appassets.androidplatform.net/assets/xcloud/player.html") + } + + private inner class Bridge { + @JavascriptInterface + fun call(id: Int, op: String, arg: String) { + signalling.execute { + val reply = runCatching { handle(op, arg) }.getOrElse { XboxCloudSession.Reply(0, it.javaClass.simpleName) } + if (reply.status !in 200..299) diag("cloud player $op: ${reply.status} ${reply.body.take(300)}") + else if (op != "state" && op != "keepalive") diag("cloud player $op: ${reply.status}") + runOnUiThread { web?.evaluateJavascript("window.__ddResolve($id, ${reply.status}, ${JSONObject.quote(reply.body)})", null) } + } + } + + @JavascriptInterface + fun status(text: String) { android.util.Log.i("XboxCloudPlayer", "status: ${text.take(120)}") } + + @JavascriptInterface + fun playing() { + diag("cloud player streaming") + runOnUiThread { web?.requestFocus() } + } + + @JavascriptInterface + fun settings(): String = pageSettings(this@XboxCloudPlayerActivity.settings) + + @JavascriptInterface + fun log(text: String) { diag("cloud player page: ${text.take(240)}") } + + @JavascriptInterface + fun failed(stage: String, detail: String) { + diag("cloud player failed at ${stage.take(40)}: ${detail.take(300)}") + runOnUiThread { if (!closed) showNotice("The stream could not start ($stage).", withButtons = true) } + } + } + + private fun handle(op: String, arg: String): XboxCloudSession.Reply { + val active = session ?: error("no session") + val signals = sessionSignals ?: XboxCloudSignalling(active, titleId ?: error("no title"), settings).also { sessionSignals = it } + return signals.op(op, arg).also { sessionPath = signals.path } + } + + private fun showNotice(text: String, withButtons: Boolean) { + notice?.let { root.removeView(it) } + val box = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.CENTER + setBackgroundColor(if (withButtons) Color.argb(230, 0, 0, 0) else Color.TRANSPARENT) + } + box.addView(TextView(this).apply { setTextColor(Color.WHITE); textSize = 18f; gravity = Gravity.CENTER; this.text = text; setPadding(32, 16, 32, 16) }) + if (withButtons) { + val row = LinearLayout(this).apply { orientation = LinearLayout.HORIZONTAL; gravity = Gravity.CENTER } + (webUrl ?: intent.getStringExtra("webUrl"))?.let { url -> + row.addView(Button(this).apply { this.text = "Open in Microsoft's player"; setOnClickListener { startActivity(XboxCloudActivity.intent(this@XboxCloudPlayerActivity, url)); finish() } }) + } + row.addView(Button(this).apply { this.text = "Close"; setOnClickListener { finish() } }) + box.addView(row) + } + notice = box + root.addView(box, FrameLayout.LayoutParams(-1, -1)) + } + + private val rehideBars = Runnable { if (window.decorView.hasWindowFocus()) hideBars() } + + private fun watchBars() { + androidx.core.view.ViewCompat.setOnApplyWindowInsetsListener(window.decorView) { v, insets -> + if (insets.isVisible(WindowInsetsCompat.Type.navigationBars()) || insets.isVisible(WindowInsetsCompat.Type.statusBars())) { + v.removeCallbacks(rehideBars) + v.postDelayed(rehideBars, 1500) + } + androidx.core.view.ViewCompat.onApplyWindowInsets(v, insets) + } + } + + override fun onResume() { + super.onResume() + hideBars() + } + + private fun hideBars() { + WindowCompat.setDecorFitsSystemWindows(window, false) + WindowInsetsControllerCompat(window, window.decorView).apply { + hide(WindowInsetsCompat.Type.systemBars()) + systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + } + } + + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + if (hasFocus) hideBars() + } + + private var guideDown = false + private var guideDownAt = 0L + private var lastGuideUp = 0L + private var pendingNexus: Runnable? = null + private var swallowGuideUp = false + + private fun isGuideKey(event: KeyEvent): Boolean = event.keyCode == KeyEvent.KEYCODE_BUTTON_MODE + + /** One press is the stream's Xbox button; two quick presses or a hold open the player menu. */ + private fun guideKey(event: KeyEvent): Boolean { + if (!isGuideKey(event)) return false + if (event.action == KeyEvent.ACTION_DOWN) { + if (event.repeatCount == 0) { + guideDown = true; guideDownAt = event.eventTime + val pending = pendingNexus + if (pending != null && event.eventTime - lastGuideUp <= DOUBLE_PRESS_MS) { + root.removeCallbacks(pending); pendingNexus = null; guideDown = false + swallowGuideUp = true + openMenu() + } + } else if (guideDown && event.eventTime - guideDownAt >= 600) { + guideDown = false + swallowGuideUp = true + openMenu() + } + } else if (event.action == KeyEvent.ACTION_UP && guideDown) { + guideDown = false + lastGuideUp = event.eventTime + val tap = Runnable { pendingNexus = null; pressNexus(1); root.postDelayed({ pressNexus(0) }, 150) } + pendingNexus = tap + root.postDelayed(tap, DOUBLE_PRESS_MS) + } + return true + } + + private fun pressNexus(value: Int) { + web?.evaluateJavascript("window.__ddButton && window.__ddButton('Nexus', $value)", null) + } + + // Back opens the player menu so a stray press never ends a stream. + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + if (menu != null) { + if (isGuideKey(event)) { + if (event.action == KeyEvent.ACTION_UP) { if (swallowGuideUp) swallowGuideUp = false else closeMenu() } + return true + } + if (event.keyCode == KeyEvent.KEYCODE_BACK) { + if (event.action == KeyEvent.ACTION_UP) { if (menuPage.isNotEmpty()) menuPage = "" else closeMenu() } + return true + } + return super.dispatchKeyEvent(event) + } + if (notice == null && guideKey(event)) return true + if (event.keyCode == KeyEvent.KEYCODE_BACK) { + if (event.action == KeyEvent.ACTION_UP) { if (notice == null) openMenu() else finish() } + return true + } + // Reporting controller buttons handled stops Android turning an unused one into a fallback key. + val handled = super.dispatchKeyEvent(event) + val fromController = event.source and android.view.InputDevice.SOURCE_GAMEPAD == android.view.InputDevice.SOURCE_GAMEPAD || + event.source and android.view.InputDevice.SOURCE_JOYSTICK == android.view.InputDevice.SOURCE_JOYSTICK + return handled || (notice == null && web != null && fromController) + } + + private var menuPage by androidx.compose.runtime.mutableStateOf("") + + private fun openMenu() { + if (menu != null || web == null) return + web?.evaluateJavascript("window.__ddInput && window.__ddInput(false)", null) + menuPage = "" + val regions = session?.regionList?.map { it.name }.orEmpty() + val view = androidx.compose.ui.platform.ComposeView(this) + view.setContent { + com.droiddeck.launcher.ui.DroidDeckTheme { + var current by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(settings) } + val entries = androidx.compose.runtime.remember { List(6) { androidx.compose.ui.focus.FocusRequester() } } + androidx.compose.runtime.LaunchedEffect(menuPage) { + if (menuPage.isEmpty()) runCatching { entries[lastMainEntry.coerceIn(0, 5)].requestFocus() } + } + val cs = androidx.compose.material3.MaterialTheme.colorScheme + androidx.compose.foundation.layout.Box(androidx.compose.ui.Modifier.fillMaxSize()) { + androidx.compose.foundation.layout.Column( + androidx.compose.ui.Modifier.fillMaxHeight().width(400.dp).background(cs.surface.copy(alpha = 0.95f)) + .verticalScroll(androidx.compose.foundation.rememberScrollState()).padding(vertical = 12.dp), + ) { + androidx.compose.material3.Text(intent.getStringExtra("title").orEmpty().ifEmpty { "Xbox Cloud Gaming" }, + style = androidx.compose.material3.MaterialTheme.typography.titleMedium, color = cs.onSurface, + modifier = androidx.compose.ui.Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) + if (menuPage.isEmpty()) { + MenuEntry("Resume", "Back to the game", entries[0], 0) { closeMenu() } + MenuEntry("Xbox menu", "The game's Xbox menu (also: Guide once, or View + Menu)", entries[1], 1) { + closeMenu(); web?.evaluateJavascript("window.__ddNexus && window.__ddNexus()", null) + } + MenuEntry("Screen effects", "Sharpen, colour, deband, FXAA, CRT, NTSC, Toon, fps cap", entries[2], 2) { menuPage = "picture" } + MenuEntry("Stream quality", "Resolution, region, language, codec, bitrate (next stream)", entries[3], 3) { menuPage = "stream" } + MenuEntry("Performance overlay and sound", "Overlay level and layout, volume, controller rumble", entries[4], 4) { menuPage = "overlay" } + MenuEntry("Leave", "End the stream", entries[5], 5) { finish() } + androidx.compose.material3.Text("Guide twice opens this menu; Back or Guide closes it.", + style = androidx.compose.material3.MaterialTheme.typography.bodySmall, color = cs.onSurfaceVariant, + modifier = androidx.compose.ui.Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) + } else { + MenuEntry("‹ Back", null) { menuPage = "" } + if (menuPage == "stream") MenuEntry("Restart the stream with these settings", + "Ends this cloud session and starts a new one; the game restarts, unsaved progress is lost.") { restartStream() } + val page = menuPage + XboxCloudSettingsPanel(current, regions, live = true, section = page, restoreRow = lastRow[page] ?: 0, + onRowFocused = { lastRow[page] = it }, startedWith = startedWith) { changed -> + current = changed + settings = changed + XboxCloudSettings.save(this@XboxCloudPlayerActivity, changed) + web?.evaluateJavascript("window.__ddApply && window.__ddApply(${JSONObject.quote(pageSettings(changed))})", null) + } + } + } + } + } + } + menu = view + root.addView(view, FrameLayout.LayoutParams(-1, -1)) + view.requestFocus() + } + + private fun pageSettings(s: XboxCloudSettings): String = JSONObject(s.toJson()) + .put("server", session?.let { active -> active.regionList.firstOrNull { it.baseUri == active.baseUri }?.name } ?: "").toString() + + private var lastMainEntry = 0 + private val lastRow = mutableMapOf<String, Int>() + + @androidx.compose.runtime.Composable + private fun MenuEntry(title: String, note: String?, focus: androidx.compose.ui.focus.FocusRequester? = null, index: Int = -1, onClick: () -> Unit) = + XboxMenuEntry(title, note, focus, { if (index >= 0) lastMainEntry = index }, onClick) + + private fun restartStream() { + val active = session; val path = sessionPath + sessionPath = null + closeMenu() + showNotice("Restarting the stream…", withButtons = false) + Thread({ + if (active != null && path != null) runCatching { XboxCloudSession.request(active, "DELETE", "/$path") } + .onSuccess { diag("cloud player restart: session closed ${it.status}") } + runOnUiThread { if (!isFinishing) { startActivity(playerIntent(this, intent.getStringExtra("product").orEmpty(), intent.getStringExtra("title").orEmpty())); finish() } } + }, "xcloud-restart").start() + } + + private fun closeMenu() { + menu?.let { root.removeView(it) } + menu = null + web?.evaluateJavascript("window.__ddInput && window.__ddInput(true)", null) + web?.requestFocus() + } + + override fun onDestroy() { + closed = true + web?.let { it.evaluateJavascript("window.__ddStop && window.__ddStop()", null); root.removeView(it); it.destroy() } + web = null + val active = session; val path = sessionPath + if (active != null && path != null) Thread({ + runCatching { XboxCloudSession.request(active, "DELETE", "/$path") } + .onSuccess { diag("cloud player session closed: ${it.status}") } + }, "xcloud-stop").start() + worker.shutdownNow(); signalling.shutdownNow() + super.onDestroy() + } + + private fun diag(text: String) = StoresState.logLine("Xbox: $text") + + companion object { + private const val DOUBLE_PRESS_MS = 350L + + fun intent(context: Context, productId: String, title: String): Intent = Intent(context, XboxCloudPlayerActivity::class.java) + .putExtra("product", productId.uppercase()).putExtra("title", title) + + /** The player the settings pick: the native engine (frame generation) where it can run, else the web player. */ + fun playerIntent(context: Context, productId: String, title: String): Intent { + val native = XboxNativePlayerActivity.supported() && XboxCloudSettings.load(context).playerEngine == "native" + return if (!native) intent(context, productId, title) else Intent(context, XboxNativePlayerActivity::class.java) + .putExtra("product", productId.uppercase()).putExtra("title", title) + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudSession.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudSession.kt new file mode 100644 index 000000000..ca2120278 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudSession.kt @@ -0,0 +1,120 @@ +package com.droiddeck.launcher.stores.xbox + +import android.content.Context +import org.json.JSONArray +import org.json.JSONObject +import java.net.HttpURLConnection +import java.net.URL + +/** Streaming-service tokens stay in memory only. Request shapes follow Greenlight / xal-node by unknownskl (MIT). */ +internal object XboxCloudSession { + class Region(val name: String, val baseUri: String, val isDefault: Boolean) + + class Session(val account: String, val token: String, val offering: String, val baseUri: String, val regionList: List<Region>, + val userToken: String?, internal val expiresAt: Long, + val osName: String = "windows") { + val fresh get() = System.currentTimeMillis() < expiresAt - 5 * 60_000L + val regions get() = regionList.size + + fun forStream(regionName: String, os: String): Session = Session(account, token, offering, + regionList.firstOrNull { it.name == regionName }?.baseUri ?: baseUri, regionList, userToken, expiresAt, os) + } + + @Volatile private var cached: Session? = null + + /** One fetch at a time (about 20 s: two ticket exchanges); a second caller waits and gets the same tokens. */ + @Synchronized + fun get(app: Context, account: String): Session { + cached?.takeIf { it.account == account && it.fresh }?.let { return it } + val json = Xodus.run(app, listOf("cloud-token"), timeout = 60) + require(json.getInt("schema") == 1 && json.getString("account") == account) + val array = json.getJSONArray("regions") + val regions = (0 until array.length()).map { array.getJSONObject(it) } + .map { Region(it.getString("name"), it.getString("baseUri"), it.optBoolean("isDefault")) } + val default = regions.firstOrNull { it.isDefault } ?: regions.firstOrNull() ?: error("No Xbox Cloud Gaming region") + val seconds = json.optLong("durationInSeconds").coerceIn(0, 24 * 3600) + return Session(account, json.getString("gsToken"), json.getString("offering"), default.baseUri, regions, + json.optString("userToken").takeIf { !json.isNull("userToken") && it.isNotEmpty() }, System.currentTimeMillis() + seconds * 1000).also { cached = it } + } + + fun clear() { cached = null; titleIds = null } + + /** + * For the native player's own process: the main process's tokens (it keeps them, and fetches them ahead while a + * cloud game's page is open), handed over in memory through XboxCloudTokenService; null if that is not reachable. + */ + fun fromMainProcess(app: Context, account: String, timeoutSeconds: Long = 90): Session? { + cached?.takeIf { it.account == account && it.fresh }?.let { return it } + val reply = XboxCloudTokenService.request(app, account, timeoutSeconds) ?: return null + reply.getString("error")?.let { error(it) } + return fromBundle(reply).takeIf { it.account == account && it.fresh }?.also { cached = it } + } + + internal fun toBundle(s: Session) = android.os.Bundle().apply { + putString("account", s.account); putString("token", s.token); putString("offering", s.offering) + putString("baseUri", s.baseUri); putString("userToken", s.userToken); putLong("expiresAt", s.expiresAt) + putStringArrayList("regionNames", ArrayList(s.regionList.map { it.name })) + putStringArrayList("regionUris", ArrayList(s.regionList.map { it.baseUri })) + putBooleanArray("regionDefaults", s.regionList.map { it.isDefault }.toBooleanArray()) + } + + internal fun fromBundle(b: android.os.Bundle): Session { + val names = b.getStringArrayList("regionNames").orEmpty() + val uris = b.getStringArrayList("regionUris").orEmpty() + val defaults = b.getBooleanArray("regionDefaults") ?: BooleanArray(0) + val regions = names.indices.map { Region(names[it], uris.getOrElse(it) { "" }, defaults.getOrElse(it) { false }) } + return Session(b.getString("account").orEmpty(), b.getString("token").orEmpty(), b.getString("offering").orEmpty(), + b.getString("baseUri").orEmpty(), regions, b.getString("userToken"), b.getLong("expiresAt")) + } + + @Volatile private var titleIds: Pair<String, Map<String, String>>? = null + + fun titles(session: Session): Map<String, String> { + titleIds?.takeIf { it.first == session.account }?.let { return it.second } + val reply = request(session, "GET", "/v2/titles") + check(reply.status == 200) { "Xbox Cloud Gaming answered ${reply.status}" } + val results = JSONObject(reply.body).optJSONArray("results") ?: JSONArray() + val map = (0 until results.length()).mapNotNull { index -> + val row = results.optJSONObject(index) ?: return@mapNotNull null + val product = row.optJSONObject("details")?.optString("productId")?.uppercase().orEmpty() + val title = row.optString("titleId") + if (product.matches(Regex("[A-Z0-9]{12}")) && title.isNotBlank() && title.length <= 64) product to title else null + }.toMap() + titleIds = session.account to map + return map + } + + class Reply(val status: Int, val body: String) + + fun request(session: Session, method: String, path: String, body: String? = null): Reply { + val connection = URL(session.baseUri + path).openConnection() as HttpURLConnection + try { + connection.connectTimeout = 15_000; connection.readTimeout = 30_000 + connection.requestMethod = method + connection.setRequestProperty("Authorization", "Bearer ${session.token}") + connection.setRequestProperty("Accept", "application/json") + connection.setRequestProperty("Content-Type", "application/json") + connection.setRequestProperty("X-Gssv-Client", "XboxComBrowser") + connection.setRequestProperty("X-MS-Device-Info", deviceInfo(session.osName)) + if (body != null) { + connection.doOutput = true + connection.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) } + } + val status = connection.responseCode + val stream = if (status in 200..299) connection.inputStream else connection.errorStream + val text = stream?.use { it.readBytes() }?.also { require(it.size <= 16_000_000) } ?: ByteArray(0) + return Reply(status, String(text, Charsets.UTF_8)) + } finally { connection.disconnect() } + } + + private fun deviceInfo(os: String) = JSONObject() + .put("appInfo", JSONObject().put("env", JSONObject().put("clientAppId", "www.xbox.com").put("clientAppType", "browser") + .put("clientAppVersion", "21.1.98").put("clientSdkVersion", "8.5.3").put("httpEnvironment", "prod").put("sdkInstallId", ""))) + .put("dev", JSONObject() + .put("hw", JSONObject().put("make", "Microsoft").put("model", "unknown").put("sdktype", "web")) + .put("os", JSONObject().put("name", os).put("ver", "22631.2715").put("platform", "desktop")) + .put("displayInfo", JSONObject().put("dimensions", JSONObject().put("widthInPixels", 1920).put("heightInPixels", 1080)) + .put("pixelDensity", JSONObject().put("dpiX", 2).put("dpiY", 2))) + .put("browser", JSONObject().put("browserName", "chrome").put("browserVersion", "119.0"))) + .toString() +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudSettings.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudSettings.kt new file mode 100644 index 000000000..7b8806a36 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudSettings.kt @@ -0,0 +1,108 @@ +package com.droiddeck.launcher.stores.xbox + +import android.content.Context +import org.json.JSONObject + +data class XboxCloudSettings( + val resolution: String = "1080p", // 720p | 1080p | 1080p_hq + val region: String = "", // "" = Microsoft's choice + val language: String = "", // "" = the device's language + val codec: String = "default", // default | high | normal | low + val bitrateMbps: Int = 0, // 0 = no cap + val preventDrops: Boolean = false, + val statsLevel: Int = 0, // 0 off, 1 fps, 2 basic, 3 detailed, 4 full + val statsLayout: String = "horizontal", + val statsCorner: String = "right", // right | left + val volume: Int = 100, // 100..300 % + val rumble: Int = 100, // 0..100 % + val maxFps: Int = 0, + val brightness: Int = 0, + val contrast: Int = 0, + val gamma: Float = 1f, + val saturation: Int = 100, + val sharpen: Int = 0, + val sharpenMode: String = "cas", // cas | usm + val deband: Int = 0, + val fxaa: Boolean = false, + val toon: Boolean = false, + val crt: Boolean = false, + val ntsc: Boolean = false, + val playerEngine: String = "web", // web | native (WebRTC library + DroidDeck's compositor, frame generation) + val frameGen: String = "ddfg", // native engine: off | ddfg (2x) | lsfg | lsfg3 | lsfg4 (needs Lossless.dll) + val fpsLimit: Int = 0, // native engine target: 0 = the screen's rate, else 30/45/60/120/144/165 + val fpsCustomOn: Boolean = false, // use fpsCustom instead of the presets + val fpsCustom: Int = 60, // 10..165 + val instantInput: Boolean = true, // native: send controller changes at once (else every 16 ms) + val smallBuffer: Boolean = false, // native: WebRTC shows frames as soon as decoded (no playout delay) + val fastDecoder: Boolean = true, // native: the hardware decoder's low-latency mode + val zeroCopy: Boolean = false, // native, frame generation off: decode straight onto the screen +) { + /** The native engine's frame-rate target (0 = the screen's own rate). */ + val fpsTarget get() = if (fpsCustomOn) fpsCustom else fpsLimit + + /** The service picks the stream resolution from the reported OS (Better xCloud's mapping). */ + val osName get() = when (resolution) { "720p" -> "android"; "1080p_hq" -> "tizen"; else -> "windows" } + + fun toJson(): String = JSONObject() + .put("resolution", resolution).put("region", region).put("language", language).put("codec", codec) + .put("bitrateMbps", bitrateMbps).put("preventDrops", preventDrops).put("statsLevel", statsLevel).put("statsLayout", statsLayout).put("statsCorner", statsCorner) + .put("volume", volume).put("rumble", rumble).put("maxFps", maxFps).put("brightness", brightness).put("contrast", contrast) + .put("gamma", gamma.toDouble()).put("saturation", saturation).put("sharpen", sharpen).put("sharpenMode", sharpenMode) + .put("deband", deband).put("fxaa", fxaa).put("toon", toon).put("crt", crt).put("ntsc", ntsc) + .put("playerEngine", playerEngine).put("frameGen", frameGen) + .put("fpsLimit", fpsLimit).put("fpsCustomOn", fpsCustomOn).put("fpsCustom", fpsCustom) + .put("instantInput", instantInput).put("smallBuffer", smallBuffer).put("fastDecoder", fastDecoder).put("zeroCopy", zeroCopy) + .toString() + + fun withoutEffects() = copy(brightness = 0, contrast = 0, gamma = 1f, saturation = 100, sharpen = 0, sharpenMode = "cas", + deband = 0, fxaa = false, toon = false, crt = false, ntsc = false) + + companion object { + private const val PREFS = "xbox_cloud" + private const val KEY = "settings" + + fun load(context: Context): XboxCloudSettings = + decode(context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY, null)) + + internal fun decode(text: String?): XboxCloudSettings = runCatching { + val json = JSONObject(text ?: return XboxCloudSettings()) + val d = XboxCloudSettings() + XboxCloudSettings( + resolution = json.optString("resolution", d.resolution).takeIf { it in setOf("720p", "1080p", "1080p_hq") } ?: d.resolution, + region = json.optString("region", d.region).take(64), language = json.optString("language", d.language).take(16), + codec = json.optString("codec", d.codec).takeIf { it in setOf("default", "high", "normal", "low") } ?: d.codec, + bitrateMbps = json.optInt("bitrateMbps", d.bitrateMbps).coerceIn(0, 100), + preventDrops = json.optBoolean("preventDrops", d.preventDrops), + statsLevel = json.optInt("statsLevel", d.statsLevel).coerceIn(0, 4), + statsLayout = json.optString("statsLayout", d.statsLayout).takeIf { it == "vertical" } ?: "horizontal", + statsCorner = json.optString("statsCorner", d.statsCorner).takeIf { it == "left" } ?: "right", + volume = json.optInt("volume", d.volume).coerceIn(100, 300), rumble = json.optInt("rumble", d.rumble).coerceIn(0, 100), + maxFps = json.optInt("maxFps", d.maxFps).coerceIn(0, 120), + brightness = json.optInt("brightness", d.brightness).coerceIn(-50, 50), contrast = json.optInt("contrast", d.contrast).coerceIn(-100, 100), + gamma = json.optDouble("gamma", 1.0).toFloat().coerceIn(0.5f, 3f), saturation = json.optInt("saturation", d.saturation).coerceIn(0, 200), + sharpen = json.optInt("sharpen", d.sharpen).coerceIn(0, 10), + sharpenMode = json.optString("sharpenMode", d.sharpenMode).takeIf { it == "usm" } ?: "cas", + deband = json.optInt("deband", d.deband).coerceIn(0, 8), + fxaa = json.optBoolean("fxaa"), toon = json.optBoolean("toon"), crt = json.optBoolean("crt"), ntsc = json.optBoolean("ntsc"), + playerEngine = json.optString("playerEngine", d.playerEngine).takeIf { it == "native" } ?: "web", + frameGen = json.optString("frameGen", d.frameGen).let { if (it == "winfg") "ddfg" else it } + .takeIf { it in setOf("off", "ddfg", "lsfg", "lsfg3", "lsfg4") } ?: d.frameGen, + fpsLimit = json.optInt("fpsLimit", d.fpsLimit).takeIf { it in setOf(0, 30, 45, 60, 120, 144, 165) } ?: 0, + fpsCustomOn = json.optBoolean("fpsCustomOn", d.fpsCustomOn), + fpsCustom = json.optInt("fpsCustom", d.fpsCustom).coerceIn(10, 165), + instantInput = json.optBoolean("instantInput", d.instantInput), smallBuffer = json.optBoolean("smallBuffer", d.smallBuffer), + fastDecoder = json.optBoolean("fastDecoder", d.fastDecoder), zeroCopy = json.optBoolean("zeroCopy", d.zeroCopy), + ) + }.getOrDefault(XboxCloudSettings()) + + fun save(context: Context, settings: XboxCloudSettings) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putString(KEY, settings.toJson()).apply() + } + + val LANGUAGES = listOf("" to "Device language", "en-US" to "English (US)", "en-GB" to "English (UK)", "de-DE" to "Deutsch", + "es-ES" to "Español (España)", "es-MX" to "Español (México)", "fr-FR" to "Français", "it-IT" to "Italiano", + "ja-JP" to "日本語", "ko-KR" to "한국어", "nl-NL" to "Nederlands", "pl-PL" to "Polski", "pt-BR" to "Português (Brasil)", + "pt-PT" to "Português (Portugal)", "ru-RU" to "Русский", "sv-SE" to "Svenska", "tr-TR" to "Türkçe", "zh-CN" to "中文 (简体)", + "zh-TW" to "中文 (繁體)") + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudSettingsActivity.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudSettingsActivity.kt new file mode 100644 index 000000000..bfe1e3908 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudSettingsActivity.kt @@ -0,0 +1,60 @@ +package com.droiddeck.launcher.stores.xbox + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.droiddeck.launcher.stores.Store +import com.droiddeck.launcher.stores.StoreAccounts +import com.droiddeck.launcher.ui.DroidDeckTheme +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class XboxCloudSettingsActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + DroidDeckTheme { + var settings by remember { mutableStateOf(XboxCloudSettings.load(this)) } + var regions by remember { mutableStateOf(emptyList<String>()) } + LaunchedEffect(Unit) { + regions = withContext(Dispatchers.IO) { + runCatching { + val account = StoreAccounts.signedInAs(applicationContext, Store.XBOX) ?: return@runCatching emptyList() + XboxCloudSession.get(applicationContext, account).regionList.map { it.name } + }.getOrDefault(emptyList()) + } + } + Column(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background).verticalScroll(rememberScrollState())) { + Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) { + OutlinedButton(onClick = { finish() }) { Text("Back") } + Text("Xbox Cloud Gaming", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onBackground, modifier = Modifier.padding(horizontal = 16.dp)) + } + XboxCloudSettingsPanel(settings, regions, live = false) { changed -> + settings = changed + XboxCloudSettings.save(this@XboxCloudSettingsActivity, changed) + } + } + } + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudSettingsPanel.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudSettingsPanel.kt new file mode 100644 index 000000000..0a09fd789 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudSettingsPanel.kt @@ -0,0 +1,313 @@ +package com.droiddeck.launcher.stores.xbox + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.foundation.border +import androidx.compose.foundation.background +import androidx.compose.foundation.focusable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp + +/** Shared by the Xbox cloud settings page and the in-player menu; [live] changes reach the running stream. */ +@Composable +fun XboxCloudSettingsPanel( + settings: XboxCloudSettings, + regions: List<String>, + live: Boolean, + /** "stream", "picture" or "overlay" shows that section only; null shows all. */ + section: String? = null, + /** The row to select when the panel opens (the last one used), or null for no initial selection. */ + restoreRow: Int? = null, + onRowFocused: (Int) -> Unit = {}, + /** The settings the running stream started with; rows that differ say they need a restart (null: no stream). */ + startedWith: XboxCloudSettings? = null, + onChange: (XboxCloudSettings) -> Unit, +) { + val focus = remember(section) { PanelFocus() } + focus.next = 0 + focus.onFocused = onRowFocused + CompositionLocalProvider(LocalPanelFocus provides focus) { + PanelContent(settings, regions, live, section, startedWith, onChange) + } + LaunchedEffect(section) { + if (restoreRow != null) runCatching { focus.requester(restoreRow.coerceIn(0, (focus.count - 1).coerceAtLeast(0))).requestFocus() } + } +} + +/** Gives each settings row its place in the page, so the last-used row can be selected again. */ +private class PanelFocus { + var next = 0 + var count = 0 + var onFocused: (Int) -> Unit = {} + private val requesters = mutableMapOf<Int, FocusRequester>() + fun requester(index: Int) = requesters.getOrPut(index) { FocusRequester() } + fun take(): Int = next++.also { count = maxOf(count, next) } +} + +private val LocalPanelFocus = compositionLocalOf<PanelFocus?> { null } + +@Composable +private fun PanelContent( + settings: XboxCloudSettings, + regions: List<String>, + live: Boolean, + section: String?, + startedWith: XboxCloudSettings?, + onChange: (XboxCloudSettings) -> Unit, +) { + // Stream-start settings changed during a stream wait for a restart; back at the starting value, no note. + fun pending(differs: (XboxCloudSettings) -> Boolean) = startedWith != null && differs(startedWith) + + if (section == null || section == "stream") Section("Stream", if (live) "Applies to the next stream." else "Applies when a stream starts.") { + if (XboxNativePlayerActivity.supported()) { + Choice("Player engine", listOf("web" to "Web", "native" to "Native (frame gen)"), settings.playerEngine, + pending { it.playerEngine != settings.playerEngine }) { onChange(settings.copy(playerEngine = it)) } + val ctx = androidx.compose.ui.platform.LocalContext.current + val lsfg = androidx.compose.runtime.remember { XboxNativeVideo.lsfgReady(ctx) } + Choice("Frame generation", XboxNativeVideo.modes(lsfg).map { it to XboxNativeVideo.modeName(it) }, settings.frameGen) { + onChange(settings.copy(frameGen = it)) + } + Text("Native engine (experimental): Google's WebRTC and DroidDeck's own compositor, so DroidDeck frame generation " + + "(DDFG) or LSFG can add frames. LSFG uses the Lossless.dll DroidDeck already uses for games. Screen effects " + + "apply to the web engine.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 8.dp, bottom = 6.dp)) + } + // The service picks the size from the reported device profile (the PC profile can send 1440p). + Choice("Resolution", listOf("720p" to "720p (phone)", "1080p" to "Best (PC)", "1080p_hq" to "1080p HQ (TV)"), + settings.resolution, pending { it.resolution != settings.resolution }) { onChange(settings.copy(resolution = it)) } + Choice("Server region", listOf("" to "Automatic") + regions.map { it to it }, settings.region, + pending { it.region != settings.region }) { onChange(settings.copy(region = it)) } + Choice("Game language", XboxCloudSettings.LANGUAGES, settings.language, + pending { it.language != settings.language }) { onChange(settings.copy(language = it)) } + Choice("Video codec quality", listOf("default" to "Automatic", "high" to "High", "normal" to "Normal", "low" to "Low"), settings.codec, + pending { it.codec != settings.codec }) { onChange(settings.copy(codec = it)) } + Choice("Bitrate limit", listOf(0 to "No limit") + listOf(5, 8, 10, 12, 15, 20, 25, 30, 40, 50).map { it to "$it Mb/s" }, settings.bitrateMbps, + pending { it.bitrateMbps != settings.bitrateMbps }) { onChange(settings.copy(bitrateMbps = it)) } + Toggle("Stop quality drops", "Keeps the resolution up when this device decodes slowly.", settings.preventDrops) { onChange(settings.copy(preventDrops = it)) } + } + + if (section == null || section == "picture") Section("Picture", "Shader effects on the stream.") { + Step("Brightness", "${settings.brightness}", { onChange(settings.copy(brightness = (settings.brightness - 5).coerceAtLeast(-50))) }, + { onChange(settings.copy(brightness = (settings.brightness + 5).coerceAtMost(50))) }) + Step("Contrast", "${settings.contrast}", { onChange(settings.copy(contrast = (settings.contrast - 10).coerceAtLeast(-100))) }, + { onChange(settings.copy(contrast = (settings.contrast + 10).coerceAtMost(100))) }) + Step("Gamma", String.format("%.1f", settings.gamma), { onChange(settings.copy(gamma = ((settings.gamma * 10).toInt() - 1).coerceAtLeast(5) / 10f)) }, + { onChange(settings.copy(gamma = ((settings.gamma * 10).toInt() + 1).coerceAtMost(30) / 10f)) }) + Step("Saturation", "${settings.saturation}%", { onChange(settings.copy(saturation = (settings.saturation - 10).coerceAtLeast(0))) }, + { onChange(settings.copy(saturation = (settings.saturation + 10).coerceAtMost(200))) }) + Step("Sharpen", if (settings.sharpen == 0) "Off" else "${settings.sharpen}", { onChange(settings.copy(sharpen = (settings.sharpen - 1).coerceAtLeast(0))) }, + { onChange(settings.copy(sharpen = (settings.sharpen + 1).coerceAtMost(10))) }) + Choice("Sharpen type", listOf("cas" to "Contrast-adaptive", "usm" to "Unsharp mask"), settings.sharpenMode) { onChange(settings.copy(sharpenMode = it)) } + Step("Deband", if (settings.deband == 0) "Off" else "${settings.deband}", { onChange(settings.copy(deband = (settings.deband - 1).coerceAtLeast(0))) }, + { onChange(settings.copy(deband = (settings.deband + 1).coerceAtMost(8))) }) + Toggle("FXAA", "Smooths jagged edges.", settings.fxaa) { onChange(settings.copy(fxaa = it)) } + Toggle("CRT", "Scanlines and colour fringes.", settings.crt) { onChange(settings.copy(crt = it)) } + Toggle("NTSC", "Old TV signal look.", settings.ntsc) { onChange(settings.copy(ntsc = it)) } + Toggle("Toon", "Cartoon outlines.", settings.toon) { onChange(settings.copy(toon = it)) } + Choice("Frame-rate cap", listOf(0 to "Off", 30 to "30 fps", 45 to "45 fps", 60 to "60 fps"), settings.maxFps) { onChange(settings.copy(maxFps = it)) } + Row(Modifier.fillMaxWidth().padding(top = 6.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = { onChange(settings.withoutEffects()) }) { Text("Reset picture") } + } + Text("Effects cost a little GPU time; with none set the stream is shown directly.", style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 4.dp)) + } + + if ((section == null && XboxNativePlayerActivity.supported()) || section == "framerate") Section("Frame rate (native engine)", + "With frame generation this is the rate shown; without it, only targets below the stream's 60 fps change anything.") { + Choice("Target", listOf(0 to "Screen's rate", 30 to "30 fps", 45 to "45 fps", 60 to "60 fps", 120 to "120 fps", 144 to "144 fps", 165 to "165 fps"), + settings.fpsLimit) { onChange(settings.copy(fpsLimit = it, fpsCustomOn = false)) } + Toggle("Custom frame rate", "Set any rate from 10 to 165 fps with the slider.", settings.fpsCustomOn) { onChange(settings.copy(fpsCustomOn = it)) } + if (settings.fpsCustomOn) SliderRow("Custom", settings.fpsCustom, 10, 165) { onChange(settings.copy(fpsCustom = it)) } + } + + if ((section == null && XboxNativePlayerActivity.supported()) || section == "latency") Section("Low latency (native engine)", null) { + Toggle("Instant controller input", "Sends each press the moment it happens instead of every 16 ms.", settings.instantInput) { + onChange(settings.copy(instantInput = it)) + } + Toggle("Small network buffer", "Shows frames as soon as they are decoded. Less delay; may stutter on weak Wi-Fi.", + settings.smallBuffer, pending { it.smallBuffer != settings.smallBuffer }) { onChange(settings.copy(smallBuffer = it)) } + Toggle("Fast decoder", "The video chip's low-latency mode.", settings.fastDecoder, + pending { it.fastDecoder != settings.fastDecoder }) { onChange(settings.copy(fastDecoder = it)) } + Toggle("Zero-copy picture", "Frames go straight from the video chip to the screen: the lowest delay. Used while frame generation is off and no target below 60 fps is set; switches live.", + settings.zeroCopy) { onChange(settings.copy(zeroCopy = it)) } + } + + // "overlay-native": the native player's page (its sound has no volume boost). + if (section == null || section == "overlay" || section == "overlay-native") Section("Overlay and sound", null) { + Choice("Performance overlay", listOf(0 to "Off", 1 to "FPS only", 2 to "Basic", 3 to "Detailed", 4 to "Full"), settings.statsLevel) { + onChange(settings.copy(statsLevel = it)) + } + Choice("Overlay layout", listOf("horizontal" to "Horizontal", "vertical" to "Vertical"), settings.statsLayout) { onChange(settings.copy(statsLayout = it)) } + Choice("Overlay position", listOf("right" to "Top right", "left" to "Top left"), settings.statsCorner) { onChange(settings.copy(statsCorner = it)) } + Text("Basic: fps, ping, resolution. Detailed: + bitrate, decode time, packet loss. Full: + codec, jitter, " + + "dropped frames, server.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + if (section != "overlay-native") Step("Volume", "${settings.volume}%", { onChange(settings.copy(volume = (settings.volume - 25).coerceAtLeast(100))) }, + { onChange(settings.copy(volume = (settings.volume + 25).coerceAtMost(300))) }) + Step("Controller rumble", if (settings.rumble == 0) "Off" else "${settings.rumble}%", { onChange(settings.copy(rumble = (settings.rumble - 25).coerceAtLeast(0))) }, + { onChange(settings.copy(rumble = (settings.rumble + 25).coerceAtMost(100))) }) + } +} + +@Composable +private fun Section(title: String, note: String?, content: @Composable ColumnScope.() -> Unit) { + Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) { + Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary) + if (note != null) Text(note, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.height(4.dp)) + content() + } +} + +/** + * One settings row, driven by a controller: it takes focus as a whole (highlighted), Left / Right change its + * value, A / Enter selects; the − / + buttons and switches inside stay for touch only. + */ +@Composable +private fun SettingRow( + onLeft: (() -> Unit)?, + onRight: (() -> Unit)?, + onSelect: (() -> Unit)?, + content: @Composable RowScope.() -> Unit, +) { + val panel = LocalPanelFocus.current + val index = panel?.take() ?: -1 + var focused by remember { mutableStateOf(false) } + val currentSelect by rememberUpdatedState(onSelect) + val cs = MaterialTheme.colorScheme + val shape = RoundedCornerShape(10.dp) + Row( + Modifier.fillMaxWidth().padding(vertical = 1.dp).clip(shape) + .background(if (focused) cs.primary.copy(alpha = 0.16f) else Color.Transparent) + .border(2.dp, if (focused) cs.primary else Color.Transparent, shape) + .let { if (panel != null && index >= 0) it.focusRequester(panel.requester(index)) else it } + .onFocusChanged { state -> focused = state.isFocused; if (state.isFocused && index >= 0) panel?.onFocused?.invoke(index) } + .onKeyEvent { event -> + if (event.type != KeyEventType.KeyDown) return@onKeyEvent false + val action = when (event.key) { + Key.DirectionLeft -> onLeft + Key.DirectionRight -> onRight + Key.Enter, Key.NumPadEnter, Key.DirectionCenter, Key.ButtonA -> onSelect + else -> null + } + if (action != null) { action(); true } else false + } + .focusable() + // A tap selects too, without a second focus stop (a clickable would be one). + .pointerInput(Unit) { detectTapGestures { currentSelect?.invoke() } } + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + content = content, + ) +} + +private val noFocus = Modifier.focusProperties { canFocus = false } + +@Composable +private fun Step(label: String, value: String, onMinus: () -> Unit, onPlus: () -> Unit) { + SettingRow(onLeft = onMinus, onRight = onPlus, onSelect = onPlus) { + Text(label, Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface) + OutlinedButton(onClick = onMinus, modifier = noFocus.size(40.dp), contentPadding = PaddingValues(0.dp)) { Text("−") } + Text(value, Modifier.widthIn(min = 112.dp, max = 168.dp).padding(horizontal = 4.dp), textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, maxLines = 1, overflow = TextOverflow.Ellipsis) + OutlinedButton(onClick = onPlus, modifier = noFocus.size(40.dp), contentPadding = PaddingValues(0.dp)) { Text("+") } + } +} + +/** A value slider (touch) that a controller moves with Left / Right in steps of 1 (hold to repeat). */ +@Composable +private fun SliderRow(label: String, value: Int, min: Int, max: Int, onValue: (Int) -> Unit) { + SettingRow(onLeft = { onValue((value - 1).coerceAtLeast(min)) }, onRight = { onValue((value + 1).coerceAtMost(max)) }, onSelect = null) { + Text(label, Modifier.width(72.dp), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface) + androidx.compose.material3.Slider(value = value.toFloat(), onValueChange = { onValue(it.toInt().coerceIn(min, max)) }, + valueRange = min.toFloat()..max.toFloat(), modifier = noFocus.weight(1f)) + Text("$value fps", Modifier.width(72.dp), textAlign = TextAlign.End, style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface) + } +} + +@Composable +private fun <T> Choice(label: String, options: List<Pair<T, String>>, selected: T, needsRestart: Boolean = false, onSelect: (T) -> Unit) { + val index = options.indexOfFirst { it.first == selected }.coerceAtLeast(0) + Step(label, options.getOrNull(index)?.second ?: "", + { if (options.isNotEmpty()) onSelect(options[(index - 1 + options.size) % options.size].first) }, + { if (options.isNotEmpty()) onSelect(options[(index + 1) % options.size].first) }) + if (needsRestart) Text("Takes effect when you restart the stream.", style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.tertiary, modifier = Modifier.padding(start = 8.dp, bottom = 4.dp)) +} + +@Composable +private fun Toggle(label: String, note: String, checked: Boolean, needsRestart: Boolean = false, onToggle: (Boolean) -> Unit) { + SettingRow(onLeft = { onToggle(false) }, onRight = { onToggle(true) }, onSelect = { onToggle(!checked) }) { + Column(Modifier.weight(1f)) { + Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface) + Text(note, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Spacer(Modifier.width(8.dp)) + Switch(checked = checked, onCheckedChange = onToggle, modifier = noFocus) + } + if (needsRestart) Text("Takes effect when you restart the stream.", style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.tertiary, modifier = Modifier.padding(start = 8.dp, bottom = 4.dp)) +} + +/** One entry of the cloud players' menu list (highlighted when selected; A or a tap opens it). */ +@Composable +internal fun XboxMenuEntry(title: String, note: String?, focus: FocusRequester? = null, onFocused: () -> Unit = {}, onClick: () -> Unit) { + val cs = MaterialTheme.colorScheme + var focused by remember { mutableStateOf(false) } + val shape = RoundedCornerShape(10.dp) + androidx.compose.material3.Surface( + onClick = onClick, color = if (focused) cs.primary.copy(alpha = 0.2f) else cs.surfaceContainerHigh, shape = shape, + border = if (focused) androidx.compose.foundation.BorderStroke(2.dp, cs.primary) else null, + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 3.dp) + .let { if (focus != null) it.focusRequester(focus) else it } + .onFocusChanged { state -> focused = state.isFocused; if (state.isFocused) onFocused() } + .onKeyEvent { event -> if (event.type == KeyEventType.KeyDown && event.key == Key.ButtonA) { onClick(); true } else false }, + ) { + Column(Modifier.padding(horizontal = 14.dp, vertical = 10.dp)) { + Text(title, style = MaterialTheme.typography.titleSmall, color = cs.onSurface) + if (note != null) Text(note, style = MaterialTheme.typography.bodySmall, color = cs.onSurfaceVariant) + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudSignalling.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudSignalling.kt new file mode 100644 index 000000000..a5e7587af --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudSignalling.kt @@ -0,0 +1,74 @@ +package com.droiddeck.launcher.stores.xbox + +import org.json.JSONArray +import org.json.JSONObject + +/** Request shapes follow Microsoft's web player as Greenlight (MIT) implements them. */ +internal class XboxCloudSignalling( + private val session: XboxCloudSession.Session, + private val titleId: String, + private val settings: XboxCloudSettings, +) { + @Volatile var path: String? = null + private set + + fun op(op: String, arg: String = ""): XboxCloudSession.Reply = when (op) { + "start" -> start() + "state" -> XboxCloudSession.request(session, "GET", "/$path/state") + "connect" -> connect() + "sdp" -> sdp(arg) + "ice" -> ice(arg) + "keepalive" -> XboxCloudSession.request(session, "POST", "/$path/keepalive", "{}") + else -> XboxCloudSession.Reply(0, "unknown request") + } + + fun start(): XboxCloudSession.Reply { + val body = JSONObject().put("clientSessionId", "").put("titleId", titleId).put("systemUpdateGroup", "") + .put("settings", JSONObject().put("nanoVersion", "V3;WebrtcTransport.dll").put("enableOptionalDataCollection", false) + .put("enableTextToSpeech", false).put("highContrast", 0).put("locale", settings.language.ifEmpty { java.util.Locale.getDefault().toLanguageTag() }) + .put("useIceConnection", false).put("timezoneOffsetMinutes", java.util.TimeZone.getDefault().getOffset(System.currentTimeMillis()) / 60_000) + .put("sdkType", "web").put("osName", settings.osName)) + .put("serverId", "").put("fallbackRegionNames", JSONArray()) + return XboxCloudSession.request(session, "POST", "/v5/sessions/cloud/play", body.toString()).also { reply -> + if (reply.status in 200..299) path = JSONObject(reply.body).getString("sessionPath").trim('/') + } + } + + fun connect(): XboxCloudSession.Reply { + val token = session.userToken ?: return XboxCloudSession.Reply(0, "no cloud transfer token for this account") + return XboxCloudSession.request(session, "POST", "/$path/connect", JSONObject().put("userToken", token).toString()) + } + + fun sdp(offer: String): XboxCloudSession.Reply { + val body = JSONObject().put("messageType", "offer").put("sdp", offer).put("requestId", "1").put("configuration", JSONObject() + .put("chatConfiguration", JSONObject().put("bytesPerSample", 2).put("expectedClipDurationMs", 20) + .put("format", JSONObject().put("codec", "opus").put("container", "webm")).put("numChannels", 1).put("sampleFrequencyHz", 24000)) + .put("chat", version(1, 1)).put("control", version(1, 3)).put("input", version(1, 9)).put("message", version(1, 1)) + .put("reliableinput", version(9, 9)).put("unreliableinput", version(9, 9))) + val posted = XboxCloudSession.request(session, "POST", "/$path/sdp", body.toString()) + return if (posted.status !in 200..299) posted else poll("/$path/sdp") + } + + fun ice(candidates: String): XboxCloudSession.Reply { + val posted = XboxCloudSession.request(session, "POST", "/$path/ice", JSONObject().put("candidates", JSONArray(candidates)).toString()) + return if (posted.status !in 200..299) posted else poll("/$path/ice") + } + + fun stop(): XboxCloudSession.Reply? { + val current = path ?: return null + path = null + return runCatching { XboxCloudSession.request(session, "DELETE", "/$current") }.getOrNull() + } + + private fun version(min: Int, max: Int) = JSONObject().put("minVersion", min).put("maxVersion", max) + + /** The exchange answers 204 until the console has replied. */ + private fun poll(path: String): XboxCloudSession.Reply { + repeat(60) { + val reply = XboxCloudSession.request(session, "GET", path) + if (reply.status != 204) return reply + Thread.sleep(500) + } + return XboxCloudSession.Reply(0, "no answer from the cloud console") + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudTokenService.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudTokenService.kt new file mode 100644 index 000000000..03df4c19d --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudTokenService.kt @@ -0,0 +1,74 @@ +package com.droiddeck.launcher.stores.xbox + +import android.app.Service +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.Bundle +import android.os.Handler +import android.os.HandlerThread +import android.os.IBinder +import android.os.Looper +import android.os.Message +import android.os.Messenger +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +/** + * Hands the main process's Xbox Cloud Gaming tokens to the native player's process (:xcloudnative) over Binder, so + * they stay in memory (not exported, same app only) and a stream does not wait for a second ~20 s token fetch. + */ +class XboxCloudTokenService : Service() { + private val worker = Executors.newSingleThreadExecutor() + private val messenger = Messenger(Handler(Looper.getMainLooper()) { message -> + val replyTo = message.replyTo ?: return@Handler true + val account = message.data?.getString("account").orEmpty() + worker.execute { + val reply = Message.obtain(null, REPLY) + reply.data = runCatching { XboxCloudSession.toBundle(XboxCloudSession.get(applicationContext, account)) } + .getOrElse { Bundle().apply { putString("error", it.message ?: it.javaClass.simpleName) } } + runCatching { replyTo.send(reply) } + } + true + }) + + override fun onBind(intent: Intent?): IBinder = messenger.binder + + override fun onDestroy() { worker.shutdown(); super.onDestroy() } + + companion object { + private const val REQUEST = 1 + private const val REPLY = 2 + + /** Blocks until the main process answers (its tokens, or "error"), or returns null when it cannot be reached. */ + fun request(context: Context, account: String, timeoutSeconds: Long): Bundle? { + val app = context.applicationContext + val done = CountDownLatch(1) + var answer: Bundle? = null + val thread = HandlerThread("xcloud-token").apply { start() } + val replies = Messenger(Handler(thread.looper) { message -> + if (message.what == REPLY) { answer = message.data; done.countDown() } + true + }) + val connection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, binder: IBinder?) { + val ask = Message.obtain(null, REQUEST).apply { data = Bundle().apply { putString("account", account) }; replyTo = replies } + if (binder == null || runCatching { Messenger(binder).send(ask) }.isFailure) done.countDown() + } + override fun onServiceDisconnected(name: ComponentName?) { done.countDown() } + } + val bound = runCatching { app.bindService(Intent(app, XboxCloudTokenService::class.java), connection, Context.BIND_AUTO_CREATE) } + .getOrDefault(false) + try { + if (!bound) return null + done.await(timeoutSeconds, TimeUnit.SECONDS) + return answer + } finally { + if (bound) runCatching { app.unbindService(connection) } + thread.quitSafely() + } + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCredentials.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCredentials.kt index 40cd28225..f969c20eb 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCredentials.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCredentials.kt @@ -14,6 +14,11 @@ internal class XboxCredentials(private val app: Context, expectedGeneration: Str val name = "droiddeck-xbox-${UUID.randomUUID()}" private val generation = lifecycle(app, expectedGeneration) { it } private val server = LocalServerSocket(name) + // Some devices refuse the abstract socket from inside PRoot; the backend then falls back to this keyed loopback port. + private val loopback = java.net.ServerSocket(0, 8, java.net.InetAddress.getByAddress(byteArrayOf(127, 0, 0, 1))) + val port: Int get() = loopback.localPort + val key: String = ByteArray(32).also { java.security.SecureRandom().nextBytes(it) }.joinToString("") { "%02x".format(it) } + @Volatile private var loopbackUsed = false @Volatile private var closed = false private val bound = java.util.concurrent.CountDownLatch(1) private var backend: Process? = null @@ -29,10 +34,16 @@ internal class XboxCredentials(private val app: Context, expectedGeneration: Str } private fun rejected(reason: String): Boolean { - android.util.Log.w("XboxCredentials", "Rejected credential peer: $reason") + note("Rejected credential peer: $reason") return false } + /** Also written to the stores log: sign-in runs in its own process, where only that file reaches a shared log. */ + private fun note(text: String) { + android.util.Log.w("XboxCredentials", text) + com.droiddeck.launcher.stores.StoreLogFiles.stamped(app, "Xbox credentials: $text") + } + private fun authorized(pid: Int): Boolean { if (!bound.await(5, java.util.concurrent.TimeUnit.SECONDS) || closed) return rejected("unbound") val process = backend ?: return rejected("missing-child") @@ -73,33 +84,122 @@ internal class XboxCredentials(private val app: Context, expectedGeneration: Str } } } - init { - Thread({ - while (!closed) { - val socket = runCatching { server.accept() }.getOrNull() ?: break - socket.use { - it.soTimeout = 10_000 - val reply = runCatching { - val peer = it.peerCredentials - check(peer.uid == android.os.Process.myUid() && authorized(peer.pid)) - val request = JSONObject(readLine(it.inputStream.buffered(), 2_097_152)) - storage(app) { - check(!closed && epoch(app) == generation && authorized(peer.pid)) - answer(request) - } - }.getOrElse { - android.util.Log.w("XboxCredentials", "Credential request rejected: ${it.javaClass.simpleName}") - JSONObject().put("ok", false) - } - runCatching { it.outputStream.write((reply.toString() + "\n").toByteArray()) } - } + /** Each connection gets its own thread; an accept error is logged and retried instead of silently ending the loop. */ + private fun <S> listen(route: String, accept: () -> S, serve: (S) -> Unit) = Thread({ + var failures = 0 + while (!closed) { + val socket = try { accept() } catch (e: Exception) { + if (closed) break + failures++ + note("Credential $route accept failed: ${e.javaClass.simpleName}") + if (failures >= 20) { note("Credential $route listener stopped after repeated accept failures"); break } + Thread.sleep(100) + continue } - }, "xbox-credentials").start() + failures = 0 + Thread({ serve(socket) }, "xbox-credentials-$route-request").start() + } + }, "xbox-credentials-$route").start() + + /** Logs requests that take 2 s or more, and the stage a request is still in after 8 s (the backend waits 30 s). */ + private fun tracked(route: String, block: (step: (String) -> Unit) -> JSONObject): JSONObject { + val started = android.os.SystemClock.elapsedRealtime() + val stage = java.util.concurrent.atomic.AtomicReference("auth") + val done = java.util.concurrent.CountDownLatch(1) + val stages = java.util.Collections.synchronizedList(mutableListOf<String>()) + var mark = started + val step = { next: String -> + val now = android.os.SystemClock.elapsedRealtime() + stages += "${stage.getAndSet(next)} ${now - mark} ms" + mark = now + } + Thread({ + if (!done.await(8, java.util.concurrent.TimeUnit.SECONDS)) note("Credential $route request still in ${stage.get()} after 8 s") + }, "xbox-credentials-watch").apply { isDaemon = true; start() } + try { + return block(step) + } finally { + done.countDown() + step("done") + val total = android.os.SystemClock.elapsedRealtime() - started + if (total >= 2000) note("Credential $route request took $total ms (${stages.joinToString(", ")})") + } } - private fun answer(request: JSONObject): JSONObject { + init { + listen("socket", { server.accept() }) { socket -> + socket.use { + it.soTimeout = 10_000 + val reply = tracked("socket") { step -> + runCatching { + val peer = it.peerCredentials + check(peer.uid == android.os.Process.myUid() && authorized(peer.pid)) + step("read") + val request = JSONObject(readLine(it.inputStream.buffered(), 2_097_152)) + step("lock") + storage(app) { + step("answer") + check(!closed && epoch(app) == generation && authorized(peer.pid)) + answer(request, step) + } + }.getOrElse { + note("Credential request rejected: ${it.javaClass.simpleName}") + JSONObject().put("ok", false) + } + } + runCatching { it.outputStream.write((reply.toString() + "\n").toByteArray()) } + .onFailure { failure -> note("Credential reply not delivered: ${failure.javaClass.simpleName}") } + } + } + listen("loopback", { loopback.accept() }) { socket -> + socket.use { + it.soTimeout = 10_000 + val reply = tracked("loopback") { step -> + runCatching { + val input = it.getInputStream().buffered() + check(it.inetAddress.isLoopbackAddress) + if (!java.security.MessageDigest.isEqual(readLine(input, 128).toByteArray(), key.toByteArray())) { + rejected("loopback-key"); error("loopback key") + } + if (!loopbackOwner(it.port)) { rejected("loopback-owner"); error("loopback owner") } + check(bound.await(5, java.util.concurrent.TimeUnit.SECONDS) && backend?.isAlive == true) + if (!loopbackUsed) { loopbackUsed = true; note("Backend reached the app over the loopback fallback") } + step("read") + val request = JSONObject(readLine(input, 2_097_152)) + step("lock") + storage(app) { + step("answer") + check(!closed && epoch(app) == generation) + answer(request, step) + } + }.getOrElse { failure -> + note("Credential request rejected: ${failure.javaClass.simpleName}") + JSONObject().put("ok", false) + } + } + runCatching { it.getOutputStream().write((reply.toString() + "\n").toByteArray()) } + .onFailure { failure -> note("Credential reply not delivered: ${failure.javaClass.simpleName}") } + } + } + } + + /** The connecting socket must belong to this app's user; an unreadable table (newer Android) leaves the key as the check. */ + private fun loopbackOwner(peerPort: Int): Boolean { + val uid = android.os.Process.myUid() + val rows = listOf("/proc/net/tcp", "/proc/net/tcp6").mapNotNull { runCatching { java.io.File(it).readLines().drop(1) }.getOrNull() }.flatten() + val owners = rows.mapNotNull { row -> + val fields = row.trim().split(Regex("\\s+")) + val local = fields.getOrNull(1)?.substringAfterLast(':')?.toIntOrNull(16) + val remote = fields.getOrNull(2)?.substringAfterLast(':')?.toIntOrNull(16) + if (local == peerPort && remote == loopback.localPort) fields.getOrNull(7)?.toIntOrNull() else null + } + return owners.isEmpty() || owners.all { it == uid } + } + + private fun answer(request: JSONObject, step: (String) -> Unit = {}): JSONObject { val key = request.getString("key") require(key.isNotEmpty() && key.length <= 512) + step("record-read") val record = StoreAccounts.read(app, Store.XBOX) check(!StoreAccounts.isUnavailable(Store.XBOX)) val json = record ?: JSONObject() @@ -112,15 +212,16 @@ internal class XboxCredentials(private val app: Context, expectedGeneration: Str require(bytes.length() <= 262_144) for (i in 0 until bytes.length()) require(bytes.getInt(i) in 0..255) slots.put(key, bytes) + step("record-save") StoreAccounts.write(app, Store.XBOX, json) } - "remove" -> { slots.remove(key); StoreAccounts.write(app, Store.XBOX, json) } + "remove" -> { slots.remove(key); step("record-save"); StoreAccounts.write(app, Store.XBOX, json) } else -> error("Invalid credential operation") } return result } - override fun close() { closed = true; bound.countDown(); runCatching { server.close() } } + override fun close() { closed = true; bound.countDown(); runCatching { server.close() }; runCatching { loopback.close() } } companion object { val lock = Any() diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxGamertag.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxGamertag.kt new file mode 100644 index 000000000..b065e8baf --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxGamertag.kt @@ -0,0 +1,51 @@ +package com.droiddeck.launcher.stores.xbox + +import android.content.Context +import com.droiddeck.launcher.stores.Store +import com.droiddeck.launcher.stores.StoreAccounts +import com.droiddeck.launcher.stores.StoresState +/** Keeps the account's Xbox gamertag in its record so the Stores page can show it instead of the sign-in email. */ +internal object XboxGamertag { + /** `xodus-cli profile` allows itself 45 s, and starting it through PRoot adds to that. */ + private const val TIMEOUT_SECONDS = 75L + private val triedGeneration = java.util.concurrent.atomic.AtomicReference<String?>(null) + + internal fun valid(value: String): Boolean = value.isNotEmpty() && value.length <= 64 && value.none { it.isISOControl() } + + /** Reads the gamertag with `xodus-cli profile` and stores it; false when Xbox didn't return one. */ + fun refresh(app: Context, credentialGeneration: String? = null): Boolean = try { + val gamertag = Xodus.run(app, listOf("profile", "--json"), timeout = TIMEOUT_SECONDS, credentialGeneration = credentialGeneration) + .optString("gamertag").trim() + require(valid(gamertag)) + XboxCredentials.lifecycle(app, credentialGeneration) { + val record = StoreAccounts.read(app, Store.XBOX) ?: error("Xbox credentials missing") + record.put("gamertag", gamertag) + StoreAccounts.write(app, Store.XBOX, record) + } + StoresState.logLine("Xbox: gamertag read") + true + } catch (e: Exception) { + StoresState.logLine("Xbox: gamertag unavailable (${XboxLoginActivity.failure(e)})") + false + } + + /** Fetches a missing gamertag once per signed-in account, on its own thread so it never holds up a library sync. */ + fun ensure(app: Context) { + Thread({ + runCatching { + val generation = XboxCredentials.lifecycle(app) { it } + if (!StoreAccounts.read(app, Store.XBOX)?.optString("gamertag").isNullOrBlank()) return@runCatching + if (!shouldTryRefresh(generation)) return@runCatching + if (refresh(app, generation)) StoresState.refresh(app) + }.onFailure { StoresState.logLine("Xbox: gamertag check failed (${it.javaClass.simpleName})") } + }, "xbox-gamertag").start() + } + + internal fun shouldTryRefresh(generation: String): Boolean { + while (true) { + val previous = triedGeneration.get() + if (previous == generation) return false + if (triedGeneration.compareAndSet(previous, generation)) return true + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxInstaller.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxInstaller.kt index 44e9ceff3..3d02e4db5 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxInstaller.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxInstaller.kt @@ -39,11 +39,17 @@ internal class XboxInstaller(private val app: Context, private val item: Catalog var sequence = -1L try { val source = if (content) selected else item.id + val guestFolder = StoreInstallRoot.guestPathForXodus(app, folder) val selection = if (content) emptyList() else listOf("--selected-content-id", selected) - Xodus.start(app, listOf("streaming", source, folder.path, "--market", market, + Xodus.start(app, listOf("streaming", source, guestFolder, "--market", market, "--progress-json", "--control-stdin") + selection).use { process -> child = process - val diagnostics = Xodus.diagnostics(process) + val rejected = AtomicBoolean(false) + val rejectedLine = Regex(Xodus.SIGN_IN_REJECTED) + val diagnostics = Xodus.diagnostics(process) { line -> + StoresState.logLine("Xbox: $line") + if (line.matches(rejectedLine)) rejected.set(true) + } if (cancelled.get()) process.process.destroy() process.process.inputStream.bufferedReader().useLines { lines -> lines.forEach { line -> if (line.startsWith("NEXUS_PROGRESS ")) { @@ -62,6 +68,7 @@ internal class XboxInstaller(private val app: Context, private val item: Catalog check(process.process.waitFor(15, TimeUnit.SECONDS)) { "Xbox installer did not exit." } diagnostics.join(2000) if (cancelled.get()) return null + check(!rejected.get()) { app.getString(com.droiddeck.launcher.R.string.stores_xbox_sign_in_again) } check(process.process.exitValue() == 0 && complete) { "Xbox download did not complete. Resume to retry with fresh licenses." } } if (content) { diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLibraryCache.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLibraryCache.kt index bf9e7ee70..5de1abcac 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLibraryCache.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLibraryCache.kt @@ -23,18 +23,19 @@ internal object XboxLibraryCache { fun clear(app: Context) { prefs(app).edit().remove("library").commit() } internal fun encode(account: String, market: String, games: List<CatalogItem>, now: Long = System.currentTimeMillis()): String = - JSONObject().put("schema", 1).put("account", account).put("market", market).put("savedAt", now) + JSONObject().put("schema", 2).put("account", account).put("market", market).put("savedAt", now) .put("games", JSONArray().apply { for (game in games) put(JSONObject().put("id", game.id).put("title", game.title) .put("image", game.imageUrl).put("poster", game.tallImageUrl).put("description", game.description) .put("developer", game.developer).put("publisher", game.publisher).put("size", game.sizeBytes) - .put("access", if (game.subscription) "gamepass" else if (game.owned) "owned" else "none")) + .put("access", if (game.subscription) "gamepass" else if (game.owned) "owned" else "none") + .put("pc", game.xboxPc).put("cloud", game.xboxCloud).put("cloudId", game.extra["cloudId"].orEmpty())) }).toString() internal fun decode(text: String?, account: String, market: String, now: Long = System.currentTimeMillis()): Snapshot? = runCatching { require(text != null && text.length <= 8_000_000) val json = JSONObject(text) - require(json.getInt("schema") == 1 && json.getString("account") == account && json.getString("market") == market) + require(json.getInt("schema") == 2 && json.getString("account") == account && json.getString("market") == market) val savedAt = json.getLong("savedAt") require(savedAt > 0 && savedAt <= now + 60_000) val rows = json.getJSONArray("games") @@ -44,12 +45,14 @@ internal object XboxLibraryCache { val id = row.getString("id") val title = row.getString("title") val access = row.getString("access") + val cloudId = row.optString("cloudId").takeIf { it.matches(Regex("[A-Z0-9]{12}")) }.orEmpty() require(id.matches(Regex("[A-Z0-9]{12}")) && title.isNotBlank() && access in setOf("owned", "gamepass")) CatalogItem(Store.XBOX, id, title, row.optString("image").takeIf { it.isNotEmpty() }, tallImageUrl = row.optString("poster").takeIf { it.isNotEmpty() }, description = row.optString("description"), developer = row.optString("developer"), publisher = row.optString("publisher"), sizeBytes = row.optLong("size"), owned = access == "owned", subscription = access == "gamepass", - storeUrl = "https://www.xbox.com/games/store/$id", extra = mapOf("market" to market, "access" to access)) + storeUrl = "https://www.xbox.com/games/store/$id", extra = mapOf("market" to market, "access" to access, + "pc" to XboxCatalog.flag(row.getBoolean("pc")), "cloud" to XboxCatalog.flag(row.getBoolean("cloud")), "cloudId" to cloudId)) } require(games.map { it.id }.distinct().size == games.size) Snapshot(games, savedAt) diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLibraryFilter.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLibraryFilter.kt new file mode 100644 index 000000000..3e3e001d7 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLibraryFilter.kt @@ -0,0 +1,41 @@ +package com.droiddeck.launcher.stores.xbox + +import android.content.Context +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.droiddeck.launcher.stores.CatalogItem + +object XboxLibraryFilter { + const val ALL = "all" + const val OWNED = "owned" + const val GAME_PASS = "gamepass" + const val CLOUD = "cloud" + val ALL_FILTERS = listOf(ALL, OWNED, GAME_PASS, CLOUD) + + var current by mutableStateOf(ALL) + private set + + fun load(context: Context) { + current = context.getSharedPreferences("xbox", Context.MODE_PRIVATE).getString("libraryFilter", ALL)?.takeIf { it in ALL_FILTERS } ?: ALL + } + + fun set(context: Context, filter: String) { + current = filter + context.getSharedPreferences("xbox", Context.MODE_PRIVATE).edit().putString("libraryFilter", filter).apply() + } + + fun keeps(filter: String, item: CatalogItem): Boolean = when (filter) { + OWNED -> item.owned + GAME_PASS -> item.subscription + CLOUD -> item.xboxCloud + else -> true + } + + fun label(filter: String): String = when (filter) { + OWNED -> "Owned" + GAME_PASS -> "Game Pass" + CLOUD -> "Cloud" + else -> "All" + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLoginActivity.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLoginActivity.kt index 01e19158f..10fc5ea71 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLoginActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLoginActivity.kt @@ -4,6 +4,7 @@ import android.annotation.SuppressLint import android.graphics.Color import android.net.Uri import android.os.Bundle +import android.util.Log import android.webkit.CookieManager import android.webkit.WebResourceRequest import android.webkit.WebView @@ -14,8 +15,11 @@ import android.widget.TextView import androidx.activity.ComponentActivity import androidx.webkit.WebViewCompat import androidx.webkit.WebViewFeature +import com.droiddeck.launcher.core.AddonPack import com.droiddeck.launcher.stores.Store import com.droiddeck.launcher.stores.StoreAccounts +import com.droiddeck.launcher.stores.StoreLogFiles +import com.droiddeck.launcher.stores.StoreLog import org.json.JSONObject import java.util.concurrent.TimeUnit @@ -26,6 +30,8 @@ class XboxLoginActivity : ComponentActivity() { @Volatile private var child: Xodus.Child? = null @Volatile private var session = 0L @Volatile private var closed = false + @Volatile private var step = "starting" + private val codes = java.util.concurrent.CopyOnWriteArrayList<String>() @SuppressLint("SetJavaScriptEnabled") override fun onCreate(savedInstanceState: Bundle?) { @@ -48,11 +54,30 @@ class XboxLoginActivity : ComponentActivity() { Thread({ if (closed) return@Thread try { + // A record without a username is left from a sign-in that never finished; starting clean + // also avoids reading it back with an older, slow Keystore key. + progress("preparing the sign-in", "Preparing Microsoft sign-in…") + val leftover = StoreAccounts.read(applicationContext, Store.XBOX) + if (leftover != null && leftover.optString("username").isBlank()) { + XboxCredentials.invalidate(applicationContext) + logStep("discarded credentials from an unfinished sign-in") + } val accountGeneration = XboxCredentials.lifecycle(applicationContext) { it } + progress("downloading the Xbox components", "Downloading Xbox components…") + check(AddonPack.XBOX.await(applicationContext, { !closed }) { state -> + if (state is AddonPack.State.Downloading) show("Downloading Xbox components… ${(state.progress * 100).toInt()}%") + }) { "Couldn't download the Xbox components. Check your connection and try again." } + if (closed) return@Thread + progress("starting the Xbox helper", "Starting the Xbox helper…") Xodus.start(applicationContext, listOf("--external-webview-stdio", "login"), credentialGeneration = accountGeneration).use { process -> child = process if (closed) return@use - Xodus.diagnostics(process) + // StoresState.logLine writes no file in this process, so the reason would miss the shared log. + Xodus.diagnostics(process) { line -> + logStep(line) + line.removePrefix("NEXUS_CREDENTIAL_ERROR_V1 ").takeIf { it != line && it !in codes && codes.size < 6 }?.let(codes::add) + } + progress("waiting for the Xbox helper", "Waiting for the Xbox helper…") val timer = Thread({ runCatching { if (!process.process.waitFor(10, TimeUnit.MINUTES)) process.process.destroy() } }, "xbox-login-timeout").apply { start() } @@ -68,16 +93,19 @@ class XboxLoginActivity : ComponentActivity() { check(!ready && frame.getInt("version") == 1) ready = true process.send(JSONObject().put("type", "hello").put("version", 1)) + progress("waiting for Microsoft's sign-in page", "Opening Microsoft sign-in…") } "open" -> { val next = frame.getLong("session") check(ready && next > latest && next <= 32 && trusted(frame.getString("url"))) latest = next; session = next + step = "loading Microsoft's sign-in page" runOnUiThread { if (!closed) open(frame) } } "close" -> { check(frame.getLong("session") == session) session = 0L + step = "completing the sign-in" runOnUiThread { disposeView(); status.text = "Completing Microsoft sign-in…" } } "context" -> { @@ -95,26 +123,53 @@ class XboxLoginActivity : ComponentActivity() { else -> error("Unsupported Xbox sign-in response") } } - check(process.process.waitFor(5, TimeUnit.SECONDS) && process.process.exitValue() == 0 && ready && latest > 0 && session == 0L) + if (closed) return@use + check(process.process.waitFor(5, TimeUnit.SECONDS)) { "the Xbox helper did not exit" } + val exit = process.process.exitValue() + check(exit == 0) { "the Xbox helper stopped with exit code $exit" } + check(ready) { "the Xbox helper never became ready" } + check(latest > 0) { "Microsoft's sign-in page was never requested" } + check(session == 0L) { "the sign-in page was closed before Microsoft finished" } timer.interrupt() } child = null if (closed) return@Thread + step = "saving the account" val account = Xodus.run(applicationContext, listOf("account", "--json"), credentialGeneration = accountGeneration) - check(account.optBoolean("stored")) + check(account.optBoolean("stored")) { "Xbox did not save the account" } XboxCredentials.lifecycle(this, accountGeneration) { val record = StoreAccounts.read(this, Store.XBOX) ?: error("Xbox credentials missing") record.put("username", account.getString("username")) StoreAccounts.write(this, Store.XBOX, record) } + logStep("signed in") runOnUiThread { finish() } } catch (e: Exception) { child = null - if (!closed) runOnUiThread { disposeView(); status.text = "Microsoft sign-in did not complete. Close this window and try again." } + if (closed) return@Thread + val failed = step + val reason = failure(e) + logStep("failed while $failed: $reason") + runOnUiThread { + disposeView() + val details = if (codes.isEmpty()) "" else "\nDetails: ${codes.joinToString(", ")}" + status.text = "Microsoft sign-in did not complete.\nFailed while $failed: $reason$details\nClose this window and try again." + } } }, "xbox-login").start() } + private fun show(text: String) = runOnUiThread { if (!closed && view == null) status.text = text } + + private fun progress(name: String, text: String) { step = name; show(text) } + + /** Written to the stores log, which the shared log zip carries; the main process's log helpers don't run in this process. */ + private fun logStep(line: String) { + val text = StoreLog.redactLine("Xbox sign-in: $line") + Log.i(TAG, text) + StoreLogFiles.stamped(applicationContext, text) + } + @SuppressLint("SetJavaScriptEnabled") private fun open(frame: JSONObject) { disposeView() @@ -172,6 +227,15 @@ class XboxLoginActivity : ComponentActivity() { } companion object { + private const val TAG = "XboxLogin" + + /** Our own check messages are safe to show; anything else is named by its type, never its text, which may hold sign-in data. */ + internal fun failure(e: Exception): String { + val message = e.message.orEmpty() + return if (e is IllegalStateException && message.isNotBlank() && message != "Check failed." && message.length <= 160) message + else e.javaClass.simpleName.ifBlank { "error" } + } + private val hosts = setOf("login.live.com", "account.live.com", "login.microsoftonline.com", "login.microsoft.com", "account.microsoft.com", "sisu.xboxlive.com") private val origins = hosts.map { "https://$it" }.toSet() private val allowedHeaders = setOf("cxh-capabilities", "cxh-correlationid", "cxh-msabinaryversion", "cxh-identityclientbinaryversion", "cxh-osversioninfo", "cxh-platform", "cxh-protocol", "cxh-source", "hostapp") diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxNativePad.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxNativePad.kt new file mode 100644 index 000000000..661023d54 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxNativePad.kt @@ -0,0 +1,80 @@ +package com.droiddeck.launcher.stores.xbox + +import android.view.InputDevice +import android.view.KeyEvent +import android.view.MotionEvent + +/** + * Controller state for the native cloud player, from Android key and motion events (no Gamepad API). Button + * bits follow the xCloud input report (Greenlight's packet.ts): Nexus 2, Menu 4, View 8, A 16, B 32, X 64, + * Y 128, D-pad 256..2048, shoulders 4096 / 8192, stick clicks 16384 / 32768. + */ +internal class XboxNativePad { + data class State(val buttons: Int, val lx: Float, val ly: Float, val rx: Float, val ry: Float, val lt: Float, val rt: Float) { + /** Greenlight's rule: any button change, a stick moved past 0.08 or held past 0.15, a trigger past 0.01. */ + fun differsFrom(o: State): Boolean = buttons != o.buttons || + listOf(lx to o.lx, ly to o.ly, rx to o.rx, ry to o.ry).any { (a, b) -> Math.abs(a - b) > 0.08f || Math.abs(a) > 0.15f } || + Math.abs(lt - o.lt) > 0.01f || Math.abs(rt - o.rt) > 0.01f + } + + @Volatile private var buttons = 0 + @Volatile private var hatButtons = 0 + @Volatile private var lx = 0f; @Volatile private var ly = 0f + @Volatile private var rx = 0f; @Volatile private var ry = 0f + @Volatile private var lt = 0f; @Volatile private var rt = 0f + /** Called after any change (instant controller input). */ + @Volatile var onChanged: (() -> Unit)? = null + + /** Buttons pressed on behalf of the app (the menu's Xbox button), OR-ed in. */ + @Volatile var overrideButtons = 0 + + fun snapshot() = State(buttons or hatButtons or overrideButtons, lx, ly, rx, ry, lt, rt) + + /** True when the event was a controller button this pad tracks. */ + fun onKey(event: KeyEvent): Boolean { + val bit = when (event.keyCode) { + KeyEvent.KEYCODE_BUTTON_A -> 16 + KeyEvent.KEYCODE_BUTTON_B -> 32 + KeyEvent.KEYCODE_BUTTON_X -> 64 + KeyEvent.KEYCODE_BUTTON_Y -> 128 + KeyEvent.KEYCODE_DPAD_UP -> 256 + KeyEvent.KEYCODE_DPAD_DOWN -> 512 + KeyEvent.KEYCODE_DPAD_LEFT -> 1024 + KeyEvent.KEYCODE_DPAD_RIGHT -> 2048 + KeyEvent.KEYCODE_BUTTON_L1 -> 4096 + KeyEvent.KEYCODE_BUTTON_R1 -> 8192 + KeyEvent.KEYCODE_BUTTON_THUMBL -> 16384 + KeyEvent.KEYCODE_BUTTON_THUMBR -> 32768 + KeyEvent.KEYCODE_BUTTON_START -> 4 + KeyEvent.KEYCODE_BUTTON_SELECT -> 8 + // Digital triggers on pads without analog axes. + KeyEvent.KEYCODE_BUTTON_L2 -> { lt = if (event.action == KeyEvent.ACTION_DOWN) 1f else 0f; onChanged?.invoke(); return true } + KeyEvent.KEYCODE_BUTTON_R2 -> { rt = if (event.action == KeyEvent.ACTION_DOWN) 1f else 0f; onChanged?.invoke(); return true } + else -> return false + } + buttons = if (event.action == KeyEvent.ACTION_DOWN) buttons or bit else buttons and bit.inv() + onChanged?.invoke() + return true + } + + /** True when the event came from a joystick (sticks, triggers, hat). */ + fun onMotion(event: MotionEvent): Boolean { + if (event.source and InputDevice.SOURCE_JOYSTICK != InputDevice.SOURCE_JOYSTICK || event.action != MotionEvent.ACTION_MOVE) return false + val device = event.device + fun axis(code: Int): Float { + val value = event.getAxisValue(code) + val flat = device?.getMotionRange(code, event.source)?.flat ?: 0f + return if (Math.abs(value) > maxOf(flat, 0.08f)) value else 0f + } + lx = axis(MotionEvent.AXIS_X); ly = axis(MotionEvent.AXIS_Y) + rx = axis(MotionEvent.AXIS_Z); ry = axis(MotionEvent.AXIS_RZ) + lt = maxOf(axis(MotionEvent.AXIS_LTRIGGER), axis(MotionEvent.AXIS_BRAKE)) + rt = maxOf(axis(MotionEvent.AXIS_RTRIGGER), axis(MotionEvent.AXIS_GAS)) + val hx = event.getAxisValue(MotionEvent.AXIS_HAT_X); val hy = event.getAxisValue(MotionEvent.AXIS_HAT_Y) + hatButtons = (if (hy < -0.5f) 256 else 0) or (if (hy > 0.5f) 512 else 0) or (if (hx < -0.5f) 1024 else 0) or (if (hx > 0.5f) 2048 else 0) + onChanged?.invoke() + return true + } + + fun reset() { buttons = 0; hatButtons = 0; overrideButtons = 0; lx = 0f; ly = 0f; rx = 0f; ry = 0f; lt = 0f; rt = 0f } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxNativePlayerActivity.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxNativePlayerActivity.kt new file mode 100644 index 000000000..97fd03a3d --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxNativePlayerActivity.kt @@ -0,0 +1,852 @@ +package com.droiddeck.launcher.stores.xbox + +import android.content.Context +import android.content.Intent +import android.graphics.Color +import android.os.Build +import android.os.Bundle +import android.os.VibrationEffect +import android.view.Gravity +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.SurfaceHolder +import android.view.SurfaceView +import android.view.WindowManager +import android.widget.FrameLayout +import android.widget.TextView +import androidx.activity.ComponentActivity +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.unit.dp +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import org.json.JSONArray +import org.json.JSONObject +import org.webrtc.DataChannel +import org.webrtc.DefaultVideoEncoderFactory +import org.webrtc.EglBase +import org.webrtc.IceCandidate +import org.webrtc.MediaConstraints +import org.webrtc.MediaStream +import org.webrtc.MediaStreamTrack +import org.webrtc.PeerConnection +import org.webrtc.PeerConnectionFactory +import org.webrtc.RtpReceiver +import org.webrtc.RtpTransceiver +import org.webrtc.SdpObserver +import org.webrtc.SessionDescription +import org.webrtc.VideoTrack +import org.webrtc.audio.JavaAudioDeviceModule +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +/** + * Native Xbox Cloud Gaming player (opt-in, Android 10+): Google's WebRTC library instead of the web page, the + * xCloud channels ported from Greenlight (XboxNativeProtocol), controller from Android input events, and the + * picture through DroidDeck's own compositor (XboxNativeVideo) so DDFG or LSFG can generate frames. Runs in + * its own process (:xcloudnative): that compositor instance is never the Linux session's. + * Sign-in and session requests are the web player's (XboxCloudSession, XboxCloudSignalling). + */ +class XboxNativePlayerActivity : ComponentActivity() { + private lateinit var root: FrameLayout + private lateinit var surfaceView: SurfaceView + private lateinit var status: TextView + private lateinit var overlay: TextView + private val worker = Executors.newSingleThreadExecutor() + private val ticker = Executors.newSingleThreadScheduledExecutor() + private val pad = XboxNativePad() + private var eglBase: EglBase? = null + private var factory: PeerConnectionFactory? = null + private var pc: PeerConnection? = null + private var protocol: XboxNativeProtocol? = null + private var video: XboxNativeVideo? = null + @Volatile private var signalling: XboxCloudSignalling? = null + @Volatile private var settings = XboxCloudSettings() + @Volatile private var closed = false + /** The stream reached the screen at least once (a later failure is a lost connection, not a failed start). */ + @Volatile private var connectedOnce = false + /** The session was ended because the player stayed in the background (Android freezes the app soon after). */ + @Volatile private var endedInBackground = false + private var backgroundEnd: Runnable? = null + private var notice: androidx.compose.ui.platform.ComposeView? = null + @Volatile private var regionNames = emptyList<String>() + @Volatile private var serverName: String? = null + private val candidates = mutableListOf<IceCandidate>() + private val gathered = CountDownLatch(1) + private var menu: androidx.compose.ui.platform.ComposeView? = null + private var menuPage by androidx.compose.runtime.mutableStateOf("") + private var lastMainEntry = 0 + private val lastRow = mutableMapOf<String, Int>() + /** The settings this stream started with (stream-start settings changed since then need a restart). */ + private var startedWith: XboxCloudSettings? = null + /** + * The picture's route right now: true = zero-copy (decoder straight onto the screen), false = WebRTC's texture + * through the Vulkan compositor. Zero-copy whenever it is chosen and frame generation is off; switched live. + */ + @Volatile private var zeroCopy = false + /** The SurfaceView's surface while it exists (UI thread). */ + private var screen: android.view.Surface? = null + private var screenW = 0 + private var screenH = 0 + @Volatile private var zeroCopyFrames = 0L + @Volatile private var decoders: org.webrtc.DroidDeckLowLatencyDecoderFactory? = null + private var lastZeroCopyFrames = 0L + private var lastSubmitted = 0L + /** The release of the Guide press that opened the menu must not close it again. */ + private var swallowGuideUp = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + hideBars() + watchBars() + root = FrameLayout(this).apply { setBackgroundColor(Color.BLACK) } + surfaceView = SurfaceView(this) + root.addView(surfaceView, FrameLayout.LayoutParams(-1, -1)) + status = TextView(this).apply { setTextColor(Color.WHITE); textSize = 16f; gravity = Gravity.CENTER } + root.addView(status, FrameLayout.LayoutParams(-1, -2, Gravity.BOTTOM).apply { bottomMargin = 120 }) + overlay = TextView(this).apply { + setTextColor(Color.WHITE); textSize = 12f; typeface = android.graphics.Typeface.MONOSPACE + setBackgroundColor(Color.argb(140, 0, 0, 0)); setPadding(12, 6, 12, 6); visibility = android.view.View.GONE + } + root.addView(overlay, FrameLayout.LayoutParams(-2, -2, Gravity.TOP or Gravity.END).apply { topMargin = 16; rightMargin = 16 }) + setContentView(root) + settings = XboxCloudSettings.load(this) + startedWith = settings + applyDisplayRate(settings.fpsTarget) + applyOverlay() + + val egl = EglBase.create().also { eglBase = it } + val nativeVideo = XboxNativeVideo(this, egl) { log(it) }.also { video = it } + surfaceView.holder.addCallback(object : SurfaceHolder.Callback { + override fun surfaceCreated(holder: SurfaceHolder) {} + override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { + screen = holder.surface; screenW = width; screenH = height + // Zero-copy: the decoder owns this surface (back on it after a stand-in); the Vulkan compositor must not connect to it. + if (zeroCopy) { decoders?.setZeroCopySurface(holder.surface); return } + @Suppress("DEPRECATION") + nativeVideo.setSurface(holder.surface, width, height, windowManager.defaultDisplay.refreshRate) + } + override fun surfaceDestroyed(holder: SurfaceHolder) { + screen = null + if (zeroCopy) decoders?.setZeroCopySurface(null) else nativeVideo.setSurface(null, 0, 0, 60f) + } + }) + show("Signing in to Xbox Cloud Gaming…") + worker.execute { runCatching { run() }.onFailure { fail("start", it.message ?: it.javaClass.simpleName) } } + } + + private fun log(text: String) = com.droiddeck.launcher.stores.StoresState.logLine("Xbox: native player: ${text.take(240)}") + private fun show(text: String) = runOnUiThread { status.text = text; status.visibility = if (text.isEmpty()) android.view.View.GONE else android.view.View.VISIBLE } + + private fun fail(stage: String, detail: String) { + log("failed at $stage: $detail") + if (closed) return + if (connectedOnce) showNotice("The connection to the cloud console was lost", + "The stream stopped reaching this device ($detail). Restart the stream to play again: the game starts over, " + + "unsaved progress is lost.", webPlayer = false) + else showNotice("The native stream could not start ($stage)", + "$detail\n\nYou can try again, or use the web player instead (Stores › Xbox › Cloud settings › Player engine).", + webPlayer = true) + } + + /** One popup at a time, in the app's dialog style; A or Enter picks the selected button, Back closes the player. */ + private fun showNotice(title: String, text: String, webPlayer: Boolean) = runOnUiThread { + if (notice != null || isFinishing || isDestroyed) return@runOnUiThread + closeMenu() + show("") + // The stream is over: stale numbers must not stay on screen. + overlayOn = false + overlay.visibility = android.view.View.GONE + val view = androidx.compose.ui.platform.ComposeView(this) + view.setContent { + com.droiddeck.launcher.ui.DroidDeckTheme { + val first = androidx.compose.runtime.remember { androidx.compose.ui.focus.FocusRequester() } + androidx.compose.runtime.LaunchedEffect(Unit) { androidx.compose.runtime.withFrameNanos { }; runCatching { first.requestFocus() } } + @androidx.compose.runtime.Composable + fun NoticeButton(label: String, focus: androidx.compose.ui.focus.FocusRequester?, onClick: () -> Unit) = + androidx.compose.material3.TextButton(onClick = onClick, + modifier = androidx.compose.ui.Modifier.let { if (focus != null) it.focusRequester(focus) else it } + .onKeyEvent { e -> if (e.type == KeyEventType.KeyDown && e.key == Key.ButtonA) { onClick(); true } else false }) { + androidx.compose.material3.Text(label) + } + androidx.compose.material3.AlertDialog( + onDismissRequest = { finish() }, + properties = androidx.compose.ui.window.DialogProperties(dismissOnClickOutside = false), + title = { androidx.compose.material3.Text(title) }, + text = { androidx.compose.material3.Text(text) }, + confirmButton = { + androidx.compose.foundation.layout.Row { + NoticeButton(if (connectedOnce) "Restart the stream" else "Try again", first) { restartStream() } + if (webPlayer) NoticeButton("Open the web player", null) { + startActivity(Intent(this@XboxNativePlayerActivity, XboxCloudPlayerActivity::class.java).putExtras(intent)); finish() + } + } + }, + dismissButton = { NoticeButton("Close", null) { finish() } }, + ) + } + } + notice = view + root.addView(view, FrameLayout.LayoutParams(-1, -1)) + } + + // ---- background ---- + + override fun onStop() { + super.onStop() + if (closed || isChangingConfigurations || signalling == null || endedInBackground) return + // Android freezes a background app about a minute after it leaves the screen; the connection then dies + // without a goodbye. End the session cleanly before that and offer a restart on return. + log("in the background") + val end = Runnable { backgroundEnd = null; endInBackground() } + backgroundEnd = end + root.postDelayed(end, BACKGROUND_GRACE_MS) + } + + override fun onStart() { + super.onStart() + backgroundEnd?.let { root.removeCallbacks(it); log("back before the stream was ended") } + backgroundEnd = null + if (endedInBackground && notice == null) showNotice("The stream ended while DroidDeck was in the background", + "Cloud sessions are closed after ${BACKGROUND_GRACE_MS / 1000} s away, before Android pauses the app. Restart the " + + "stream to play again: the game starts over, unsaved progress is lost.", webPlayer = false) + } + + private fun endInBackground() { + if (closed || endedInBackground) return + endedInBackground = true + closed = true + log("ending the session: ${BACKGROUND_GRACE_MS / 1000} s in the background") + val signals = signalling + signalling = null + ticker.shutdownNow() + runOnUiThread { overlayOn = false; overlay.visibility = android.view.View.GONE } + Thread({ signals?.stop()?.let { log("session closed: ${it.status}") } }, "xcloud-native-stop").start() + runCatching { protocol?.close() } + runCatching { pc?.close() } + } + + // ---- session ---- + + private fun run() { + val account = com.droiddeck.launcher.stores.StoreAccounts.signedInAs(applicationContext, com.droiddeck.launcher.stores.Store.XBOX) + ?: error("Sign in to the Xbox store first.") + val requested = intent.getStringExtra("product").orEmpty() + val cached = XboxLibraryCache.read(applicationContext, account, Xodus.market())?.games?.firstOrNull { it.id == requested } + val product = cached?.extra?.get("cloudId")?.takeIf { it.isNotEmpty() } ?: requested + // The main process's tokens when it has them (no second ~20 s fetch); else this process fetches its own. + val shared = runCatching { XboxCloudSession.fromMainProcess(applicationContext, account) } + .onFailure { log("tokens from the main process: ${it.message?.take(160)}") }.getOrNull() + log(if (shared != null) "tokens from the main process" else "tokens fetched here") + val active = (shared ?: XboxCloudSession.get(applicationContext, account)).forStream(settings.region, settings.osName) + regionNames = active.regionList.map { it.name } + serverName = active.regionList.firstOrNull { it.baseUri == active.baseUri }?.name + val title = XboxCloudSession.titles(active)[product] ?: error("This game can't be streamed with this account here.") + val signals = XboxCloudSignalling(active, title, settings).also { signalling = it } + log("$product -> $title, region ${java.net.URI(active.baseUri).host}, frame gen ${settings.frameGen}") + + show("Starting the game on Xbox Cloud Gaming…") + expect("start", signals.start()) + var connected = false + var waited = 0 + while (!closed) { + val state = JSONObject(expect("state", signals.op("state")).body.ifEmpty { "{}" }) + when (state.optString("state")) { + "Provisioned" -> break + "Failed" -> error("provision: ${state.optJSONObject("errorDetails")}") + "ReadyToConnect" -> if (!connected) { connected = true; show("Signing in to the cloud console…"); expect("connect", signals.connect()) } + "WaitingForResources" -> show("In the queue for a cloud console… ${waited / 1000} s") + else -> show("Getting a cloud console ready… ${waited / 1000} s") + } + Thread.sleep(1000); waited += 1000 + } + if (closed) return + + show("Connecting…") + // Small network buffer: no playout delay, frames go to the decoder and screen as soon as they are complete. + val trials = if (settings.smallBuffer) "WebRTC-ForcePlayoutDelay/min_ms:0,max_ms:0/WebRTC-ZeroPlayoutDelay/min_pacing:0ms,max_decode_queue_size:8/" else "" + PeerConnectionFactory.initialize(PeerConnectionFactory.InitializationOptions.builder(applicationContext) + .setFieldTrials(trials).createInitializationOptions()) + val egl = eglBase!! + val zeroSurface = if (zeroCopyWanted(settings)) { + repeat(40) { if (!surfaceView.holder.surface.isValid) Thread.sleep(50) } + surfaceView.holder.surface.takeIf { it.isValid } + } else null + if (zeroSurface != null) { + // The compositor may already hold the screen; it must let go before the decoder takes it. + val ready = CountDownLatch(1) + runOnUiThread { zeroCopy = true; video?.setSurface(null, 0, 0, 60f); ready.countDown() } + ready.await(2, TimeUnit.SECONDS) + } + log("latency: instant input ${settings.instantInput}, small buffer ${settings.smallBuffer}, fast decoder ${settings.fastDecoder}, " + + "zero-copy ${zeroSurface != null}") + val f = PeerConnectionFactory.builder() + .setAudioDeviceModule(JavaAudioDeviceModule.builder(applicationContext).createAudioDeviceModule()) + .setVideoDecoderFactory(org.webrtc.DroidDeckLowLatencyDecoderFactory(egl.eglBaseContext, settings.fastDecoder, zeroSurface) { zeroCopyFrames++ } + .also { decoders = it }) + .setVideoEncoderFactory(DefaultVideoEncoderFactory(egl.eglBaseContext, true, true)) + .createPeerConnectionFactory().also { factory = it } + val config = PeerConnection.RTCConfiguration(emptyList()).apply { sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN } + val peer = f.createPeerConnection(config, observer) ?: error("WebRTC unavailable") + pc = peer + peer.addTransceiver(MediaStreamTrack.MediaType.MEDIA_TYPE_AUDIO, RtpTransceiver.RtpTransceiverInit(RtpTransceiver.RtpTransceiverDirection.SEND_RECV)) + peer.addTransceiver(MediaStreamTrack.MediaType.MEDIA_TYPE_VIDEO, RtpTransceiver.RtpTransceiverInit(RtpTransceiver.RtpTransceiverDirection.RECV_ONLY)) + protocol = XboxNativeProtocol(peer, pad, { log(it) }, ::rumble) { runOnUiThread { finish() } } + pad.onChanged = { if (settings.instantInput) protocol?.inputChanged() } + + val offer = await<SessionDescription> { done -> peer.createOffer(sdpObserver(done), MediaConstraints()) } + val local = SessionDescription(offer.type, tweakOffer(offer.description)) + await<Unit> { done -> peer.setLocalDescription(sdpObserver(done), local) } + val sdpReply = expect("sdp", signals.sdp(local.description)) + val answer = JSONObject(JSONObject(sdpReply.body).getString("exchangeResponse")) + if (answer.has("status") && answer.optString("status") != "success") error("sdp answer: $answer") + await<Unit> { done -> peer.setRemoteDescription(sdpObserver(done), SessionDescription(SessionDescription.Type.ANSWER, answer.getString("sdp"))) } + + gathered.await(3, TimeUnit.SECONDS) + val local2 = synchronized(candidates) { candidates.toList() }.map { c -> + JSONObject().put("candidate", c.sdp).put("sdpMid", c.sdpMid).put("sdpMLineIndex", c.sdpMLineIndex).put("usernameFragment", JSONObject.NULL).toString() + } + val iceReply = expect("ice", signals.ice(JSONArray(local2).toString())) + val remote = JSONArray(JSONObject(iceReply.body).getString("exchangeResponse")) + for (i in 0 until remote.length()) addRemoteCandidate(peer, remote.getJSONObject(i)) + log("signalling done: ${local2.size} local / ${remote.length()} remote candidates") + ticker.scheduleWithFixedDelay({ runCatching { signals.op("keepalive") } }, 30, 30, TimeUnit.SECONDS) + ticker.scheduleWithFixedDelay({ runCatching { updateOverlay() } }, 1, 1, TimeUnit.SECONDS) + } + + private fun expect(stage: String, reply: XboxCloudSession.Reply): XboxCloudSession.Reply { + if (reply.status !in 200..299) error("$stage: ${reply.status} ${reply.body.take(200)}") + return reply + } + + /** Opus stereo (Greenlight), H.264 profile order (Main first unless the user picked one) and the bitrate cap. */ + private fun tweakOffer(sdp: String): String { + var out = sdp.replace("useinbandfec=1", "useinbandfec=1; stereo=1") + val prefix = when (settings.codec) { "low" -> "420"; "normal" -> "42e"; else -> "4d" } + val ids = Regex("a=fmtp:(\\d+).*profile-level-id=([0-9a-f]{6})").findAll(out).filter { it.groupValues[2].startsWith(prefix) }.map { it.groupValues[1] }.toList() + if (ids.isNotEmpty()) out = out.split("\r\n").joinToString("\r\n") { line -> + if (!line.startsWith("m=video")) line else { + val parts = line.trim().split(" ") + (parts.take(3) + ids + parts.drop(3).filter { it !in ids }).joinToString(" ") + } + } + if (settings.bitrateMbps > 0) { + val lines = out.split("\r\n").toMutableList() + val start = lines.indexOfFirst { it.startsWith("m=video") } + if (start >= 0) { + var i = start + 1 + while (i < lines.size && (lines[i].startsWith("i=") || lines[i].startsWith("c="))) i++ + if (i < lines.size && lines[i].startsWith("b=AS:")) lines[i] = "b=AS:${settings.bitrateMbps * 1000}" else lines.add(i, "b=AS:${settings.bitrateMbps * 1000}") + out = lines.joinToString("\r\n") + } + } + return out + } + + /** The server's candidates, plus the IPv4 behind a Teredo address (Greenlight's ice.ts / teredo.ts). */ + private fun addRemoteCandidate(peer: PeerConnection, c: JSONObject) { + val line = c.optString("candidate").removePrefix("a=") + if (line.isEmpty() || line == "end-of-candidates") return + val mid = c.optString("sdpMid", "0"); val index = c.optString("sdpMLineIndex", "0").toIntOrNull() ?: 0 + val address = line.split(" ").getOrNull(4).orEmpty() + if (address.startsWith("2001")) runCatching { + val p = address.split(":") + val hex = p[6].padStart(4, '0') + p[7].padStart(4, '0') + val ip = (0 until 4).joinToString(".") { k -> ((hex.substring(k * 2, k * 2 + 2).toInt(16)).inv() and 0xFF).toString() } + val port = p[5].toInt(16).inv() and 0xFFFF + peer.addIceCandidate(IceCandidate(mid, index, "candidate:10 1 UDP 1 $ip 9002 typ host ")) + peer.addIceCandidate(IceCandidate(mid, index, "candidate:11 1 UDP 1 $ip $port typ host ")) + } + peer.addIceCandidate(IceCandidate(mid, index, line)) + } + + private val observer = object : PeerConnection.Observer { + override fun onSignalingChange(state: PeerConnection.SignalingState) {} + override fun onIceConnectionChange(state: PeerConnection.IceConnectionState) { log("ice $state") } + override fun onIceConnectionReceivingChange(receiving: Boolean) {} + override fun onIceGatheringChange(state: PeerConnection.IceGatheringState) { if (state == PeerConnection.IceGatheringState.COMPLETE) gathered.countDown() } + override fun onIceCandidate(candidate: IceCandidate) { synchronized(candidates) { candidates.add(candidate) } } + override fun onIceCandidatesRemoved(removed: Array<out IceCandidate>) {} + override fun onAddStream(stream: MediaStream) {} + override fun onRemoveStream(stream: MediaStream) {} + override fun onDataChannel(channel: DataChannel) {} + override fun onRenegotiationNeeded() {} + override fun onAddTrack(receiver: RtpReceiver, streams: Array<out MediaStream>) { attach(receiver) } + override fun onTrack(transceiver: RtpTransceiver) { attach(transceiver.receiver) } + override fun onConnectionChange(state: PeerConnection.PeerConnectionState) { + log("connection $state") + when (state) { + PeerConnection.PeerConnectionState.CONNECTED -> { + connectedOnce = true + show("") + video?.setFpsTarget(settings.fpsTarget) + video?.setFrameGeneration(effectiveFrameGen()) + } + PeerConnection.PeerConnectionState.FAILED -> fail("webrtc", "connection failed") + PeerConnection.PeerConnectionState.DISCONNECTED -> show("Connection lost…") + else -> {} + } + } + } + + private var videoAttached = false + + private fun attach(receiver: RtpReceiver) = synchronized(this) { + val track = receiver.track() + // Always attached: in zero-copy WebRTC delivers no frames, and after a live switch back they flow again. + if (track is VideoTrack && !videoAttached) { videoAttached = true; video?.let { track.addSink(it) }; log("video track") } + } + + /** LSFG needs the Lossless.dll and its shader cache built; otherwise DDFG. */ + private fun effectiveFrameGen(): String { + if (!XboxNativeVideo.isLsfg(settings.frameGen)) return settings.frameGen + val status = com.droiddeck.launcher.gpu.Lossless.sync(applicationContext) + if (status == 0) return settings.frameGen + log("LSFG unavailable (status $status), using DDFG") + return XboxNativeVideo.FG_DDFG + } + + private fun <T> await(start: (done: (Result<T>) -> Unit) -> Unit): T { + val latch = CountDownLatch(1) + var result: Result<T>? = null + start { r -> result = r; latch.countDown() } + check(latch.await(15, TimeUnit.SECONDS)) { "WebRTC timed out" } + return result!!.getOrThrow() + } + + @Suppress("UNCHECKED_CAST") + private fun <T> sdpObserver(done: (Result<T>) -> Unit) = object : SdpObserver { + override fun onCreateSuccess(description: SessionDescription) = done(Result.success(description as T)) + override fun onSetSuccess() = done(Result.success(Unit as T)) + override fun onCreateFailure(error: String) = done(Result.failure(IllegalStateException(error))) + override fun onSetFailure(error: String) = done(Result.failure(IllegalStateException(error))) + } + + // ---- input, rumble, overlay ---- + + private var guideDown = false + private var guideDownAt = 0L + private var lastGuideUp = 0L + private var pendingNexus: Runnable? = null + private fun isGuide(event: KeyEvent) = event.keyCode == KeyEvent.KEYCODE_BUTTON_MODE + + private fun pressNexus() { + pad.overrideButtons = pad.overrideButtons or 2 + root.postDelayed({ pad.overrideButtons = pad.overrideButtons and 2.inv() }, 200) + } + + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + if (menu != null) { + // Guide closes the menu; Back goes up one page, then closes it. + if (isGuide(event)) { if (event.action == KeyEvent.ACTION_UP) { if (swallowGuideUp) swallowGuideUp = false else closeMenu() }; return true } + if (event.keyCode == KeyEvent.KEYCODE_BACK) { + if (event.action == KeyEvent.ACTION_UP) { if (menuPage.isNotEmpty()) menuPage = "" else closeMenu() } + return true + } + return super.dispatchKeyEvent(event) + } + // Guide / the Input Controls menu button: once = the Xbox button, twice or held = the player menu. + if (isGuide(event)) { + if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) { + val pending = pendingNexus + if (pending != null && event.eventTime - lastGuideUp <= 350) { root.removeCallbacks(pending); pendingNexus = null; swallowGuideUp = true; openMenu() } + else { guideDown = true; guideDownAt = event.eventTime } + } else if (event.action == KeyEvent.ACTION_DOWN && guideDown && event.eventTime - guideDownAt >= 600) { + guideDown = false; swallowGuideUp = true; openMenu() + } else if (event.action == KeyEvent.ACTION_UP && guideDown) { + guideDown = false; lastGuideUp = event.eventTime + val tap = Runnable { pendingNexus = null; pressNexus() } + pendingNexus = tap; root.postDelayed(tap, 350) + } + return true + } + if (event.keyCode == KeyEvent.KEYCODE_BACK) { if (event.action == KeyEvent.ACTION_UP) openMenu(); return true } + if (pad.onKey(event)) return true + return super.dispatchKeyEvent(event) + } + + // With the menu open, sticks and the hat go to Android unhandled, which turns them into D-pad keys for the menu. + override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean = + (menu == null && pad.onMotion(event)) || super.dispatchGenericMotionEvent(event) + + private fun rumble(strong: Float, weak: Float, durationMs: Int) { + val scale = settings.rumble / 100f + val amplitude = (maxOf(strong, weak) * scale * 255).toInt().coerceIn(0, 255) + if (amplitude == 0 || durationMs <= 0) return + val vibrator = (if (Build.VERSION.SDK_INT >= 31) getSystemService(android.os.VibratorManager::class.java)?.defaultVibrator + else @Suppress("DEPRECATION") getSystemService(android.os.Vibrator::class.java)) ?: return + runCatching { vibrator.vibrate(VibrationEffect.createOneShot(durationMs.coerceAtMost(1000).toLong(), amplitude)) } + } + + /** The overlay is remembered with the other cloud settings (on = overlay level above 0). */ + @Volatile private var overlayOn = false + + private var lastJitterDelay = 0.0 + private var lastJitterCount = 0.0 + private var lastDecodeTime = 0.0 + private var lastDecoded = 0.0 + private var lastBytes = 0.0 + private var lastStatsMs = 0.0 + private var lastLost = 0.0 + private var lastReceived = 0.0 + + /** + * The overlay, at the web player's levels and layout (settings statsLevel / statsLayout): 1 fps and frame + * generation base -> shown; 2 + resolution, ping; 3 + bitrate, jitter buffer, decode time, packet loss; 4 + frame + * generation's added delay, the estimated picture delay (one-way network + buffer + decode + frame generation + + * one screen refresh), codec and server. + */ + private fun updateOverlay() { + val level = settings.statsLevel + if (!overlayOn || level <= 0) return + val peer = pc ?: return + peer.getStats { report -> + var size = ""; var fps = 0; var codec = "" + var rttMs = -1.0; var bufferMs = 0.0; var decodeMs = 0.0; var mbps = 0.0; var loss = 0.0 + val codecs = HashMap<String, String>() + report.statsMap.values.forEach { s -> if (s.type == "codec") codecs[s.id] = (s.members["mimeType"] as? String).orEmpty().removePrefix("video/") } + report.statsMap.values.forEach { s -> + val m = s.members + if (s.type == "inbound-rtp" && m["kind"] == "video") { + size = "${m["frameWidth"]}x${m["frameHeight"]}" + fps = (m["framesPerSecond"] as? Number)?.toInt() ?: 0 + codec = codecs[m["codecId"] as? String].orEmpty() + val jd = (m["jitterBufferDelay"] as? Number)?.toDouble() ?: 0.0 + val jc = (m["jitterBufferEmittedCount"] as? Number)?.toDouble() ?: 0.0 + if (jc > lastJitterCount) bufferMs = (jd - lastJitterDelay) * 1000 / (jc - lastJitterCount) + lastJitterDelay = jd; lastJitterCount = jc + val dt = (m["totalDecodeTime"] as? Number)?.toDouble() ?: 0.0 + val df = (m["framesDecoded"] as? Number)?.toDouble() ?: 0.0 + if (df > lastDecoded) decodeMs = (dt - lastDecodeTime) * 1000 / (df - lastDecoded) + lastDecodeTime = dt; lastDecoded = df + val bytes = (m["bytesReceived"] as? Number)?.toDouble() ?: 0.0 + val now = s.timestampUs / 1000.0 + if (lastStatsMs > 0 && now > lastStatsMs) mbps = (bytes - lastBytes) * 8 / ((now - lastStatsMs) * 1000) + lastBytes = bytes; lastStatsMs = now + val lost = (m["packetsLost"] as? Number)?.toDouble() ?: 0.0 + val received = (m["packetsReceived"] as? Number)?.toDouble() ?: 0.0 + val dl = lost - lastLost; val dr = received - lastReceived + if (dl + dr > 0) loss = dl * 100 / (dl + dr) + lastLost = lost; lastReceived = received + } else if (s.type == "candidate-pair" && m["state"] == "succeeded" && m["nominated"] == true) { + (m["currentRoundTripTime"] as? Number)?.toDouble()?.let { rttMs = it * 1000 } + } + } + // Zero-copy: WebRTC never sees a decoded frame, so size and decode time come from the decoder itself. + if (zeroCopy) decoders?.let { d -> + d.videoSize()?.let { size = it } + val ms = d.takeDecodeMs() + if (ms >= 0) decodeMs = ms + } + val fg = video?.stats() + val fgOn = fg != null && settings.frameGen != XboxNativeVideo.FG_OFF && fg[2] > 1f + val fgWaitMs = if (fgOn) 1000.0 / fg!![2] else 0.0 + val fgGpuMs = if (fgOn && fg!![5] > 0f) fg[5].toDouble() else 0.0 + @Suppress("DEPRECATION") val refreshMs = 1000.0 / windowManager.defaultDisplay.refreshRate + val total = (if (rttMs >= 0) rttMs / 2 else 0.0) + bufferMs + decodeMs + fgWaitMs + fgGpuMs + refreshMs + // What reaches the screen: the decoder's own count in zero-copy, the presented rate with frame generation, + // else the frames handed to the compositor (a frame-rate target drops some of WebRTC's decoded frames). + val submitted = video?.frames ?: 0L + val handed = (submitted - lastSubmitted).toInt().also { lastSubmitted = submitted } + val shownFps = when { + zeroCopy -> (zeroCopyFrames - lastZeroCopyFrames).toInt().also { lastZeroCopyFrames = zeroCopyFrames } + fgOn -> fg!![3].toInt() + else -> handed + } + val items = buildList { + add("native · " + if (settings.frameGen == XboxNativeVideo.FG_OFF) "no frame gen" else XboxNativeVideo.modeName(settings.frameGen)) + add("$shownFps fps" + if (zeroCopy) " zero-copy" else "") + if (fgOn) add("${settings.frameGen} ${fg!![2].toInt()} → ${fg[3].toInt()}") + if (level >= 2) { add(size); add(if (rttMs >= 0) "ping ${rttMs.toInt()} ms" else "ping –") } + if (level >= 3) { + add("${"%.1f".format(mbps)} Mb/s"); add("buffer ${"%.1f".format(bufferMs)} ms") + add("decode ${"%.1f".format(decodeMs)} ms"); add("loss ${"%.1f".format(loss)}%") + } + if (level >= 4) { + if (fgOn) add("frame gen +${"%.1f".format(fgWaitMs + fgGpuMs)} ms (gpu ${"%.1f".format(fgGpuMs)})") + add("picture delay ≈ ${total.toInt()} ms (est.)") + if (codec.isNotEmpty()) add(codec) + serverName?.let { add(it) } + } + } + val text = items.joinToString(if (settings.statsLayout == "vertical") "\n" else " · ") + runOnUiThread { overlay.text = text } + } + } + + // ---- menu ---- + + /** The player menu (as the web player's): Resume, Xbox menu, Frame generation, Stream quality, overlay, Leave. */ + private fun openMenu() { + if (menu != null) return + pad.reset() + menuPage = "" + val regions = regionNames + val lsfg = XboxNativeVideo.lsfgReady(this) + val view = androidx.compose.ui.platform.ComposeView(this) + view.setContent { + com.droiddeck.launcher.ui.DroidDeckTheme { + var current by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(settings) } + val entries = androidx.compose.runtime.remember { List(7) { androidx.compose.ui.focus.FocusRequester() } } + val choices = androidx.compose.runtime.remember { List(5) { androidx.compose.ui.focus.FocusRequester() } } + androidx.compose.runtime.LaunchedEffect(menuPage) { + // Wait a frame so the entries are attached before one of them takes the selection. + androidx.compose.runtime.withFrameNanos { } + runCatching { + if (menuPage.isEmpty()) entries[lastMainEntry.coerceIn(0, 6)].requestFocus() + else if (menuPage == "framegen") choices[FG_MODES.indexOf(current.frameGen).coerceIn(0, if (lsfg) 4 else 1)].requestFocus() + } + } + val cs = androidx.compose.material3.MaterialTheme.colorScheme + // A full-size box first: the root of a ComposeView gets fixed full-screen constraints, so the panel's + // width only holds inside it (as the web player's menu does). + androidx.compose.foundation.layout.Box(androidx.compose.ui.Modifier.fillMaxSize()) { + androidx.compose.foundation.layout.Column( + androidx.compose.ui.Modifier.fillMaxHeight().width(400.dp).background(cs.surface.copy(alpha = 0.95f)) + .verticalScroll(androidx.compose.foundation.rememberScrollState()).padding(vertical = 12.dp), + ) { + androidx.compose.material3.Text("${intent.getStringExtra("title").orEmpty()} · native", + style = androidx.compose.material3.MaterialTheme.typography.titleMedium, color = cs.onSurface, + modifier = androidx.compose.ui.Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) + fun fgName(mode: String) = when (mode) { + XboxNativeVideo.FG_DDFG -> "DDFG 2×"; XboxNativeVideo.FG_LSFG -> "LSFG 2×" + XboxNativeVideo.FG_LSFG3 -> "LSFG 3×"; XboxNativeVideo.FG_LSFG4 -> "LSFG 4×"; else -> "Off" + } + fun fpsName(s: XboxCloudSettings) = if (s.fpsTarget == 0) "the screen's rate" else "${s.fpsTarget} fps" + when (menuPage) { + "" -> { + XboxMenuEntry("Resume", "Back to the game", entries[0], { lastMainEntry = 0 }) { closeMenu() } + XboxMenuEntry("Xbox menu", "The game's Xbox menu (also: Guide once)", entries[1], { lastMainEntry = 1 }) { closeMenu(); pressNexus() } + XboxMenuEntry("Frame generation & low latency", "Now: ${fgName(current.frameGen)}" + if (zeroCopy) " · zero-copy" else "", + entries[2], { lastMainEntry = 2 }) { menuPage = "framegen" } + XboxMenuEntry("Frame rate", "Target: ${fpsName(current)}", entries[3], { lastMainEntry = 3 }) { menuPage = "framerate" } + XboxMenuEntry("Stream quality", "Engine, resolution, region, codec, bitrate (next stream)", entries[4], { lastMainEntry = 4 }) { menuPage = "stream" } + XboxMenuEntry("Performance overlay and rumble", if (current.statsLevel == 0) "Overlay off" + else "Overlay: " + listOf("", "FPS only", "Basic", "Detailed", "Full")[current.statsLevel] + ", " + current.statsLayout, + entries[5], { lastMainEntry = 5 }) { menuPage = "overlay-native" } + XboxMenuEntry("Leave", "End the stream", entries[6], { lastMainEntry = 6 }) { finish() } + androidx.compose.material3.Text("Guide twice opens this menu; Back or Guide closes it.", + style = androidx.compose.material3.MaterialTheme.typography.bodySmall, color = cs.onSurfaceVariant, + modifier = androidx.compose.ui.Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) + } + "framegen" -> { + XboxMenuEntry("‹ Back", null) { menuPage = "" } + val modes = if (lsfg) FG_MODES else FG_MODES.take(2) + modes.forEachIndexed { i, mode -> + XboxMenuEntry((if (current.frameGen == mode) "✓ " else "") + fgName(mode), when (mode) { + XboxNativeVideo.FG_DDFG -> "DroidDeck's own frame generation (DDFG); doubles the shown frame rate" + XboxNativeVideo.FG_LSFG, XboxNativeVideo.FG_LSFG3, XboxNativeVideo.FG_LSFG4 -> + "Lossless Scaling frame generation (the Lossless.dll DroidDeck uses for games); the frame-rate target caps the output" + else -> "Show the stream's own frames only" + }, choices[i]) { current = current.copy(frameGen = mode); setFrameGen(mode) } + } + if (!lsfg) androidx.compose.material3.Text("LSFG appears here once DroidDeck has the Lossless.dll (frame generation settings).", + style = androidx.compose.material3.MaterialTheme.typography.bodySmall, color = cs.onSurfaceVariant, + modifier = androidx.compose.ui.Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) + if (current.zeroCopy) androidx.compose.material3.Text( + "Zero-copy is used while frame generation is Off; picking a frame generation mode switches to the compositor live.", + style = androidx.compose.material3.MaterialTheme.typography.bodySmall, color = cs.onSurfaceVariant, + modifier = androidx.compose.ui.Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) + XboxCloudSettingsPanel(current, regionNames, live = true, section = "latency", restoreRow = null, + startedWith = startedWith) { changed -> + val before = current + current = changed + applyLive(before, changed) + } + XboxMenuEntry("Restart the stream with these settings", + "Ends this cloud session and starts a new one; the game restarts, unsaved progress is lost.") { restartStream() } + } + else -> { + XboxMenuEntry("‹ Back", null) { menuPage = "" } + if (menuPage == "stream") XboxMenuEntry("Restart the stream with these settings", + "Ends this cloud session and starts a new one; the game restarts, unsaved progress is lost.") { restartStream() } + val page = menuPage + XboxCloudSettingsPanel(current, regions, live = true, section = page, restoreRow = lastRow[page] ?: 0, + onRowFocused = { lastRow[page] = it }, startedWith = startedWith) { changed -> + val before = current + current = changed + applyLive(before, changed) + } + } + } + } + } + } + } + menu = view + root.addView(view, FrameLayout.LayoutParams(-1, -1)) + view.requestFocus() + } + + /** + * Applies a settings change from any menu page at once: frame generation, the frame-rate target, zero-copy, instant + * input and the overlay. Resolution, region, language, codec, bitrate, the network buffer, the decoder mode and the + * engine belong to the cloud session and need a restart (the page says so). + */ + private fun applyLive(before: XboxCloudSettings, changed: XboxCloudSettings) { + settings = changed + XboxCloudSettings.save(this, changed) + if (changed.frameGen != before.frameGen) setFrameGen(changed.frameGen) + else if (changed.zeroCopy != before.zeroCopy) applyRoute() + if (changed.fpsTarget != before.fpsTarget) { applyDisplayRate(changed.fpsTarget); video?.setFpsTarget(changed.fpsTarget); applyRoute() } + applyOverlay() + } + + /** + * Zero-copy draws every decoded frame straight onto the screen, so it is used only when nothing has to touch the + * frames: frame generation off and no frame-rate target below the stream's 60 fps (the compositor drops frames for that). + */ + private fun zeroCopyWanted(s: XboxCloudSettings) = + s.zeroCopy && s.frameGen == XboxNativeVideo.FG_OFF && (s.fpsTarget == 0 || s.fpsTarget >= 60) + + private fun applyOverlay() { + overlayOn = settings.statsLevel > 0 + overlay.visibility = if (overlayOn) android.view.View.VISIBLE else android.view.View.GONE + overlay.textSize = 12f + // Top left keeps clear of the clock Xbox draws top right while its guide is open. + val left = settings.statsCorner == "left" + overlay.layoutParams = (overlay.layoutParams as FrameLayout.LayoutParams).apply { + gravity = Gravity.TOP or (if (left) Gravity.START else Gravity.END) + leftMargin = if (left) 16 else 0; rightMargin = if (left) 0 else 16 + } + overlay.gravity = if (left) Gravity.START else Gravity.END + } + + /** Stream-start settings need a new cloud session: end this one, then open the player again. */ + private fun restartStream() { + val signals = signalling + closed = true + closeMenu() + notice?.let { root.removeView(it) } + notice = null + show("Restarting the stream…") + Thread({ + signals?.stop()?.let { log("restart: session closed ${it.status}") } + runOnUiThread { if (!isFinishing) { startActivity(XboxCloudPlayerActivity.playerIntent(this, intent.getStringExtra("product").orEmpty(), intent.getStringExtra("title").orEmpty())); finish() } } + }, "xcloud-native-restart").start() + } + + private fun closeMenu() { + menu?.let { root.removeView(it) } + menu = null + pad.reset() + } + + private fun setFrameGen(mode: String) { + settings = settings.copy(frameGen = mode) + XboxCloudSettings.save(this, settings) + applyRoute() + worker.execute { video?.setFrameGeneration(effectiveFrameGen()) } + } + + /** + * Live route switch (UI thread): zero-copy when chosen and frame generation is off, else the compositor. + * The screen surface takes one producer at a time, so the old one lets go before the new one connects. + */ + private fun applyRoute() { + val d = decoders ?: return + val want = zeroCopyWanted(settings) + if (want == zeroCopy) return + val target = screen + if (want) { + if (target == null) return + video?.setSurface(null, 0, 0, 60f) + if (d.useScreen(target)) { lastZeroCopyFrames = zeroCopyFrames; zeroCopy = true; log("route: zero-copy") } + else { attachCompositor(target); log("route: zero-copy refused by the decoder, compositor kept") } + } else { + if (!d.useScreen(null)) { log("route: decoder could not leave the screen, zero-copy kept"); return } + zeroCopy = false + target?.let { attachCompositor(it) } + log("route: compositor") + } + } + + private fun attachCompositor(target: android.view.Surface) { + @Suppress("DEPRECATION") + video?.setSurface(target, screenW, screenH, windowManager.defaultDisplay.refreshRate) + } + + /** + * Ask Android for the display mode whose refresh rate fits the target (at least the target when possible, + * else the fastest; the fastest for "the screen's rate"), so generated frames are not held back at 60 Hz. + */ + private fun applyDisplayRate(target: Int) { + @Suppress("DEPRECATION") val display = windowManager.defaultDisplay + val current = display.mode + val modes = display.supportedModes.filter { it.physicalWidth == current.physicalWidth && it.physicalHeight == current.physicalHeight } + val pick = (if (target > 0) modes.filter { it.refreshRate >= target - 0.5f }.minByOrNull { it.refreshRate } else null) + ?: modes.maxByOrNull { it.refreshRate } ?: return + window.attributes = window.attributes.apply { preferredDisplayModeId = pick.modeId } + log("display mode ${pick.refreshRate.toInt()} Hz for target ${if (target > 0) target else "screen"}") + } + + private val rehideBars = Runnable { if (window.decorView.hasWindowFocus()) hideBars() } + + /** + * Bars that come back while the player has focus (return from recents or another app, a swipe) are hidden + * again after a moment; focus alone missed the return from the background (10-24). + */ + private fun watchBars() { + androidx.core.view.ViewCompat.setOnApplyWindowInsetsListener(window.decorView) { v, insets -> + if (insets.isVisible(WindowInsetsCompat.Type.navigationBars()) || insets.isVisible(WindowInsetsCompat.Type.statusBars())) { + v.removeCallbacks(rehideBars) + v.postDelayed(rehideBars, 1500) + } + androidx.core.view.ViewCompat.onApplyWindowInsets(v, insets) + } + } + + override fun onResume() { + super.onResume() + hideBars() + } + + private fun hideBars() { + WindowCompat.setDecorFitsSystemWindows(window, false) + WindowInsetsControllerCompat(window, window.decorView).apply { + hide(WindowInsetsCompat.Type.systemBars()) + systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + } + } + + override fun onWindowFocusChanged(hasFocus: Boolean) { super.onWindowFocusChanged(hasFocus); if (hasFocus) hideBars() } + + override fun onDestroy() { + closed = true + ticker.shutdownNow() + val signals = signalling + Thread({ signals?.stop()?.let { log("session closed: ${it.status}") } }, "xcloud-native-stop").start() + runCatching { protocol?.close() } + runCatching { pc?.close(); pc?.dispose() } + runCatching { video?.release() } + runCatching { factory?.dispose() } + runCatching { decoders?.release() } + runCatching { eglBase?.release() } + worker.shutdownNow() + super.onDestroy() + } + + companion object { + /** How long a stream may stay in the background (Android froze the app ~70 s after it left the screen, 10-24). */ + private const val BACKGROUND_GRACE_MS = 45_000L + private val FG_MODES = listOf(XboxNativeVideo.FG_OFF, XboxNativeVideo.FG_DDFG, XboxNativeVideo.FG_LSFG, XboxNativeVideo.FG_LSFG3, XboxNativeVideo.FG_LSFG4) + + /** True when the native engine can run here (ImageReader GPU buffers need Android 10). */ + fun supported(): Boolean = Build.VERSION.SDK_INT >= 29 + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxNativeProtocol.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxNativeProtocol.kt new file mode 100644 index 000000000..348079f52 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxNativeProtocol.kt @@ -0,0 +1,186 @@ +package com.droiddeck.launcher.stores.xbox + +import org.json.JSONObject +import org.webrtc.DataChannel +import org.webrtc.PeerConnection +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.charset.StandardCharsets +import java.util.UUID +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.TimeUnit + +/** + * The xCloud data-channel protocol for the native player, ported from Greenlight's player (UnknownSKL, MIT; + * tools/xcloud-player/greenlight/lib/channel): chat / control / input / message channels, the message + * handshake, authorization and gamepad announcements, and the binary input reports. The controller state + * comes from Android input events ([pad]); rumble reports come back through [onRumble]. + */ +internal class XboxNativeProtocol( + pc: PeerConnection, + private val pad: XboxNativePad, + private val log: (String) -> Unit, + private val onRumble: (strong: Float, weak: Float, durationMs: Int) -> Unit, + private val onServerDisconnect: () -> Unit, +) { + private val chat = pc.createDataChannel("chat", init("chatV1")) + private val control = pc.createDataChannel("control", init("controlV1")) + private val input = pc.createDataChannel("input", init("1.0")) + private val message = pc.createDataChannel("message", init("messageV1")) + private val timer = Executors.newSingleThreadScheduledExecutor { r -> Thread(r, "xcloud-input") } + private var loop: ScheduledFuture<*>? = null + private var sequence = 0 + private var lastSent: XboxNativePad.State? = null + private var lastSentAt = 0L + @Volatile var handshakeDone = false + private set + + init { + message.registerObserver(ChannelObserver(message, ::onMessageOpen, ::onMessageData)) + control.registerObserver(ChannelObserver(control, {}, {})) + input.registerObserver(ChannelObserver(input, {}, ::onInputData)) + chat.registerObserver(ChannelObserver(chat, {}, {})) + } + + private fun init(protocol: String) = DataChannel.Init().apply { ordered = true; this.protocol = protocol } + + private inner class ChannelObserver( + private val channel: DataChannel, + private val onOpen: () -> Unit, + private val onData: (DataChannel.Buffer) -> Unit, + ) : DataChannel.Observer { + private var opened = false + override fun onBufferedAmountChange(previous: Long) {} + override fun onStateChange() { + if (!opened && channel.state() == DataChannel.State.OPEN) { opened = true; runCatching(onOpen).onFailure { log("channel open failed: ${it.message}") } } + } + override fun onMessage(buffer: DataChannel.Buffer) { runCatching { onData(buffer) }.onFailure { log("channel message failed: ${it.message}") } } + } + + private fun send(channel: DataChannel, text: String) { + if (channel.state() != DataChannel.State.OPEN) return + channel.send(DataChannel.Buffer(ByteBuffer.wrap(text.toByteArray(StandardCharsets.UTF_8)), false)) + } + + private fun send(channel: DataChannel, bytes: ByteBuffer) { + if (channel.state() != DataChannel.State.OPEN) return + channel.send(DataChannel.Buffer(bytes, true)) + } + + private fun onMessageOpen() { + log("message channel open, handshake") + send(message, JSONObject().put("type", "Handshake").put("version", "messageV1") + .put("id", "be0bfc6d-1e83-4c8a-90ed-fa8601c5a179").put("cv", "0").toString()) + } + + private fun onMessageData(buffer: DataChannel.Buffer) { + val bytes = ByteArray(buffer.data.remaining()).also { buffer.data.get(it) } + val data = JSONObject(String(bytes, StandardCharsets.UTF_8)) + when (data.optString("type")) { + "HandshakeAck" -> { + handshakeDone = true + log("handshake done") + // Greenlight's order: authorization (announces controller 0 and removes it), input start, config. + send(control, JSONObject().put("message", "authorizationRequest").put("accessKey", "4BDB3609-C1F1-4195-9B37-FEFF45DA8B8E").toString()) + gamepadChanged(0, true); gamepadChanged(0, false) + startInput() + sendConfig() + // Our controller, after the authorization's add/remove. + gamepadChanged(0, true) + send(control, JSONObject().put("message", "videoKeyframeRequested").put("ifrRequested", true).toString()) + } + "TransactionStart", "Message" -> handleTransaction(data) + else -> {} + } + } + + private fun handleTransaction(data: JSONObject) { + when (data.optString("target")) { + "/streaming/sessionLifetimeManagement/serverInitiatedDisconnect" -> { + complete(data.optString("id"), "") + onServerDisconnect() + } + // The game's own dialogs: accept (Greenlight shows a confirm; the native player has no page). + "/streaming/systemUi/messages/ShowMessageDialog" -> complete(data.optString("id"), JSONObject().put("Result", 0)) + else -> {} + } + } + + private fun complete(id: String, content: Any) { + send(message, JSONObject().put("type", "TransactionComplete").put("content", content.toString().let { if (content is String) JSONObject.quote(it) else it }) + .put("id", id).put("cv", "").toString()) + } + + private fun gamepadChanged(index: Int, added: Boolean) { + send(control, JSONObject().put("message", "gamepadChanged").put("gamepadIndex", index).put("wasAdded", added).toString()) + } + + private fun sendConfig() { + fun msg(path: String, content: JSONObject) = JSONObject().put("type", "Message").put("content", content.toString()) + .put("id", UUID.randomUUID().toString()).put("target", path).put("cv", "").toString() + send(message, msg("/streaming/systemUi/configuration", JSONObject().put("version", org.json.JSONArray(listOf(0, 2, 0))).put("systemUis", org.json.JSONArray()))) + send(message, msg("/streaming/properties/clientappinstallidchanged", JSONObject().put("clientAppInstallId", "c97d7ee0-73b2-4239-bf1d-9d805a338429"))) + send(message, msg("/streaming/characteristics/orientationchanged", JSONObject().put("orientation", 0))) + send(message, msg("/streaming/characteristics/touchinputenabledchanged", JSONObject().put("touchInputEnabled", false))) + send(message, msg("/streaming/characteristics/clientdevicecapabilities", JSONObject())) + send(message, msg("/streaming/characteristics/dimensionschanged", JSONObject().put("horizontal", 1920).put("vertical", 1080) + .put("preferredWidth", 1920).put("preferredHeight", 1080).put("safeAreaLeft", 0).put("safeAreaTop", 0) + .put("safeAreaRight", 1920).put("safeAreaBottom", 1080).put("supportsCustomResolution", true))) + } + + private fun header(type: Int, size: Int): ByteBuffer = ByteBuffer.allocateDirect(size).order(ByteOrder.LITTLE_ENDIAN).apply { + putShort(type.toShort()); putInt(sequence++); putDouble(System.nanoTime() / 1e6) + } + + private fun startInput() { + // Client metadata: max touch points. + send(input, header(8, 15).apply { put(1.toByte()); flip() }) + loop = timer.scheduleWithFixedDelay({ runCatching { tick() } }, 16, 16, TimeUnit.MILLISECONDS) + } + + /** Instant controller input: send a report now if the state changed (the 16 ms loop stays as the heartbeat). */ + fun inputChanged() { + // At most one extra report every 4 ms (stick motion arrives far faster than that). + if (handshakeDone && loop != null && System.nanoTime() - lastSentAt > 4_000_000L) timer.execute { runCatching { tick() } } + } + + /** Greenlight's queue: a gamepad frame when the state changed (sticks past a small threshold) or every 33 ms. */ + @Synchronized + private fun tick() { + val state = pad.snapshot() + val now = System.nanoTime() + val changed = lastSent?.let { state.differsFrom(it) } ?: true + if (!changed && now - lastSentAt < 33_000_000L) return + lastSent = state; lastSentAt = now + val packet = header(2, 14 + 1 + 23).apply { + put(1.toByte()) // one frame + put(0.toByte()) // gamepad index + putShort(state.buttons.toShort()) + putShort(axis(state.lx)); putShort(axis(-state.ly)); putShort(axis(state.rx)); putShort(axis(-state.ry)) + putShort(trigger(state.lt)); putShort(trigger(state.rt)) + putInt(1) // physical physicality + order(ByteOrder.BIG_ENDIAN); putInt(1); order(ByteOrder.LITTLE_ENDIAN) // virtual physicality (Greenlight writes it big-endian) + flip() + } + send(input, packet) + } + + private fun axis(value: Float): Short = (value * 32767).toInt().coerceIn(-32767, 32767).toShort() + private fun trigger(value: Float): Short = if (value <= 0f) 0 else if (value >= 1f) 65535.toShort() else (value * 65535).toInt().toShort() + + private fun onInputData(buffer: DataChannel.Buffer) { + val data = buffer.data.order(ByteOrder.LITTLE_ENDIAN) + if (data.remaining() < 13 || (data.get(0).toInt() and 0xFF) != 128) return // vibration report + val strong = (data.get(4).toInt() and 0xFF) / 100f + val weak = (data.get(5).toInt() and 0xFF) / 100f + val duration = data.getShort(8).toInt() and 0xFFFF + onRumble(strong, weak, duration) + } + + fun close() { + loop?.cancel(false) + timer.shutdownNow() + listOf(chat, control, input, message).forEach { runCatching { it.unregisterObserver(); it.close(); it.dispose() } } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxNativeVideo.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxNativeVideo.kt new file mode 100644 index 000000000..dc3657dbe --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxNativeVideo.kt @@ -0,0 +1,183 @@ +package com.droiddeck.launcher.stores.xbox + +import android.content.Context +import android.graphics.PixelFormat +import android.hardware.HardwareBuffer +import android.media.Image +import android.media.ImageReader +import android.os.Handler +import android.os.HandlerThread +import android.view.Surface +import com.droiddeck.launcher.gpu.FrameGen +import com.droiddeck.launcher.gpu.Lossless +import com.droiddeck.launcher.wayland.CompositorHost +import com.droiddeck.launcher.wayland.WaylandCompositor +import org.webrtc.EglBase +import org.webrtc.EglRenderer +import org.webrtc.GlRectDrawer +import org.webrtc.VideoFrame +import org.webrtc.VideoSink +import java.io.File +import java.util.ArrayDeque + +/** + * The native player's picture: WebRTC's hardware-decoded frames are drawn (GL, WebRTC's EglRenderer) into an + * ImageReader whose images are GPU-shareable HardwareBuffers, and each one goes to DroidDeck's own compositor in + * stream mode (WaylandCompositor.nativeStreamFrame) as the whole scene, where DroidDeck's frame generation (DDFG, + * or LSFG with the Lossless.dll) multiplies it. The player runs in its own process (:xcloudnative), so this + * compositor is never the one a Linux session uses. Android 10+ (ImageReader usage flags). + */ +internal class XboxNativeVideo(private val context: Context, eglBase: EglBase, private val log: (String) -> Unit) : VideoSink { + companion object { + const val FG_OFF = "off" + const val FG_DDFG = "ddfg" + const val FG_LSFG = "lsfg" + const val FG_LSFG3 = "lsfg3" + const val FG_LSFG4 = "lsfg4" + fun isLsfg(mode: String) = mode == FG_LSFG || mode == FG_LSFG3 || mode == FG_LSFG4 + fun multiplier(mode: String) = when (mode) { FG_LSFG3 -> 3; FG_LSFG4 -> 4; FG_OFF -> 0; else -> 2 } + fun modes(lsfg: Boolean) = if (lsfg) listOf(FG_OFF, FG_DDFG, FG_LSFG, FG_LSFG3, FG_LSFG4) else listOf(FG_OFF, FG_DDFG) + fun modeName(mode: String) = when (mode) { + FG_DDFG -> "DDFG 2×"; FG_LSFG -> "LSFG 2×"; FG_LSFG3 -> "LSFG 3×"; FG_LSFG4 -> "LSFG 4×"; else -> "Off" + } + /** LSFG can run: the Lossless.dll was imported (or found in Steam) and its shader cache built. */ + fun lsfgReady(context: Context) = Lossless.state(context).ready + private const val SCALE_FIT = 1 + private const val ALIGN_CENTER = 0 + } + + private val lock = Any() + private val egl = EglRenderer("xcloud-native").apply { init(eglBase.eglBaseContext, EglBase.CONFIG_RGBA, GlRectDrawer()) } + private val readerThread = HandlerThread("xcloud-frames").apply { start() } + private val readerHandler = Handler(readerThread.looper) + private var reader: ImageReader? = null + private var readerW = 0 + private var readerH = 0 + /** Images the compositor may still read: the oldest goes back to the reader once two newer ones arrived. */ + private val held = ArrayDeque<Image>() + private var surface: Surface? = null + private var attached = false + @Volatile private var frameGen = FG_OFF + @Volatile private var refreshHz = 60f + /** The frame-rate target (0 = the screen's rate): the frame-gen pacer's cap, or a limit on stream frames without it. */ + @Volatile private var fpsTarget = 0 + private var nextDueNs = 0L + @Volatile var frames = 0L + private set + + /** The screen: the compositor presents to it (null detaches, e.g. for zero-copy or when the surface goes). */ + fun setSurface(target: Surface?, width: Int, height: Int, displayRefreshHz: Float) = synchronized(lock) { + refreshHz = displayRefreshHz + if (target == null) { + if (attached) letGo() + attached = false; surface = null + return@synchronized + } + surface = target + val runtime = File(context.cacheDir, "xcloud-wayland").apply { mkdirs() } + // System Vulkan driver; this process's compositor shows only the stream. + val started = CompositorHost.startOrAttach(context, target, runtime.path, null, null, context.applicationInfo.nativeLibraryDir, + width, height, displayRefreshHz, 0) + if (started) { + WaylandCompositor.nativeSetScaleMode(SCALE_FIT, ALIGN_CENTER) + log("compositor started on ${width}x$height at ${displayRefreshHz.toInt()} Hz") + } else { + WaylandCompositor.nativeResizeOutput(width, height) + } + WaylandCompositor.nativeStreamMode(true) + attached = true + applyFrameGen() + } + + /** + * Gives the screen surface up and waits until the compositor has really disconnected from it: the window request + * is applied on the compositor thread at its next tick, and the decoder (zero-copy) can only connect after that. + */ + private fun letGo() { + WaylandCompositor.nativeSetFrameGenArmed(false, 0, 0) + CompositorHost.detach(surface) + WaylandCompositor.nativeVsync(System.nanoTime()) + if (!WaylandCompositor.nativeWaitSurface(1000)) log("compositor did not let go of the screen within 1 s") + } + + /** Frame-rate target, 0 = the screen's rate. Live. */ + fun setFpsTarget(fps: Int) { + fpsTarget = fps + synchronized(lock) { if (attached) applyFrameGen() } + } + + /** off, ddfg (2x) or lsfg / lsfg3 / lsfg4 (needs the Lossless.dll). Live. */ + fun setFrameGeneration(mode: String) { + frameGen = mode + synchronized(lock) { if (attached) applyFrameGen() } + } + + override fun onFrame(frame: VideoFrame) { + val w = frame.rotatedWidth; val h = frame.rotatedHeight + synchronized(lock) { if (w != readerW || h != readerH) startReader(w, h) } + egl.onFrame(frame) + } + + private fun startReader(w: Int, h: Int) { + val old = reader + if (old != null) egl.releaseEglSurface { } + held.forEach { runCatching { it.close() } }; held.clear() + old?.close() + readerW = w; readerH = h + val next = ImageReader.newInstance(w, h, PixelFormat.RGBA_8888, 4, + HardwareBuffer.USAGE_GPU_SAMPLED_IMAGE or HardwareBuffer.USAGE_GPU_COLOR_OUTPUT) + next.setOnImageAvailableListener({ r -> onImage(r) }, readerHandler) + reader = next + egl.createEglSurface(next.surface) + log("native video ${w}x$h") + } + + private fun onImage(r: ImageReader) { + val image = runCatching { r.acquireLatestImage() }.getOrNull() ?: return + synchronized(lock) { + if (r !== reader) { image.close(); return } + val buffer = image.hardwareBuffer + // Without frame generation a target below the stream's rate drops stream frames evenly: each frame has a + // due time one interval after the last one's, so 30 of 60 frames (or 45: three of four) reach the screen. + val now = System.nanoTime() + val target = fpsTarget + val skip = frameGen == FG_OFF && target in 1 until 60 && run { + val interval = 1_000_000_000L / target + if (nextDueNs == 0L || now - nextDueNs > interval) nextDueNs = now + if (now + 4_000_000L < nextDueNs) true else { nextDueNs += interval; false } + } + if (attached && buffer != null && !skip) { WaylandCompositor.nativeStreamFrame(buffer); frames++ } + buffer?.close() + held.addLast(image) + while (held.size > 2) held.removeFirst().close() + } + } + + private fun applyFrameGen() { + // The pacer caps generated output at this rate: the user's target, else the screen's. + val cap = if (fpsTarget > 0) fpsTarget.toFloat() else refreshHz + val mode = when { + frameGen == FG_DDFG -> FrameGen.Mode(FrameGen.ENGINE_DDFG, 2) + isLsfg(frameGen) -> FrameGen.Mode(FrameGen.ENGINE_LSFG, multiplier(frameGen)) + else -> FrameGen.Mode.OFF + } + val problem = FrameGen.apply(context, mode, cap) + log("native frame generation: $frameGen, target ${if (fpsTarget > 0) fpsTarget else "screen"} fps" + (problem?.let { " ($it)" } ?: "")) + } + + /** {planned, planned, sourceFps, presentedFps, thermal, -1}, or null while nothing is shown. */ + fun stats(): FloatArray? = synchronized(lock) { if (attached) WaylandCompositor.nativeFrameGenStats() else null } + + fun frameGenProblem(): Int = synchronized(lock) { if (attached) WaylandCompositor.nativeFrameGenProblem() else -1 } + + fun release() { + egl.release() + synchronized(lock) { + if (attached) { WaylandCompositor.nativeStreamMode(false); letGo() } + attached = false; surface = null + held.forEach { runCatching { it.close() } }; held.clear() + reader?.close(); reader = null + } + readerThread.quitSafely() + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxRuntime.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxRuntime.kt index 28d6c12d9..d1ba4a52e 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxRuntime.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxRuntime.kt @@ -46,7 +46,7 @@ internal object XboxRuntime { val market = game.second.extra["market"]?.takeIf { it.matches(Regex("[A-Z]{2}")) } ?: Xodus.market() val directory = views.create(app.filesDir) val process = try { - Xodus.start(app, listOf("run", game.first.path, "droiddeck:prepare", "--exe", game.second.exe, + Xodus.start(app, listOf("run", StoreInstallRoot.guestPathForXodus(app, game.first), "droiddeck:prepare", "--exe", game.second.exe, "--materialize-chain", "--market", market), launchDirectory = directory, credentialGeneration = credentials) } catch (e: Exception) { XboxLaunchViews.remove(directory); throw e } if (!launches.attach(token, process)) { process.close(); error("Xbox launch was cancelled") } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/Xodus.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/Xodus.kt index 6e7c30df8..5d6160c44 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/xbox/Xodus.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/Xodus.kt @@ -5,7 +5,9 @@ import android.os.Environment import com.droiddeck.launcher.core.AddonPack import com.droiddeck.launcher.runtime.LinuxNetworkLinkComponent import com.droiddeck.launcher.runtime.LinuxRuntime +import com.droiddeck.launcher.runtime.ProotFastPath import com.droiddeck.launcher.session.SessionPrefs +import com.droiddeck.launcher.stores.StoreInstallRoot import org.json.JSONObject import java.io.Closeable import java.io.File @@ -79,17 +81,34 @@ internal object Xodus { networkLink.publish() networkLink.start() val runtime = (runtimeDirectory ?: File(app.filesDir, "xbox-runtime")).apply { mkdirs() } - val command = LinuxRuntime.command(app, null, null, Environment.getExternalStorageDirectory(), listOf( + val root = LinuxRuntime.rootDir(app) + val external = Environment.getExternalStorageDirectory() + val extraBinds = StoreInstallRoot.externalBindSpecsForXodus(app) + val binds = LinuxRuntime.binds(app, null, null, external, extraBinds) + val fastPathPreloaded = runCatching { + File(root, "etc/ld.so.preload").useLines { lines -> lines.any { it.trim() == ProotFastPath.LIBRARY } } + }.getOrDefault(false) + val fastPathKey = if (ProotFastPath.enabled(app) && fastPathPreloaded && + File(root, ProotFastPath.LIBRARY.removePrefix("/")).isFile) ProotFastPath.key(root, binds) else null + val guestCommand = mutableListOf( "/usr/bin/env", "HOME=/root", "XODUS_LOG=off", "RUST_LOG=off", "XDG_RUNTIME_DIR=${runtime.path}", "DROIDDECK_XBOX_CREDENTIAL_SOCKET=${credentials.name}", binary, - ) + args) + ) + fastPathKey?.let { key -> + guestCommand.addAll(guestCommand.lastIndex, ProotFastPath.guestEnv(root, binds, key)) + } + guestCommand.addAll(args) + val command = LinuxRuntime.commandWithBinds(app, root, binds, guestCommand) val builder = ProcessBuilder(command).directory(LinuxRuntime.rootDir(app)) if (launchDirectory != null) builder.environment()["TMPDIR"] = launchDirectory.path + builder.environment()["DROIDDECK_XBOX_CREDENTIAL_PORT"] = credentials.port.toString() + builder.environment()["DROIDDECK_XBOX_CREDENTIAL_KEY"] = credentials.key builder.environment().apply { remove("LD_PRELOAD"); remove("LD_AUDIT"); remove("LD_LIBRARY_PATH") put("PROOT_LOADER", LinuxRuntime.prootLoader(app).path) put("PROOT_TMP_DIR", LinuxRuntime.prootTempDir(app).path) if (SessionPrefs.prootNoSeccomp(app)) put("PROOT_NO_SECCOMP", "1") + fastPathKey?.let { ProotFastPath.hostEnv(it).let { (key, value) -> put(key, value) } } LinuxRuntime.prootLibraryPath(app).takeIf { it.isNotEmpty() }?.let { put("LD_LIBRARY_PATH", it) } } val digest = java.security.MessageDigest.getInstance("SHA-256") @@ -133,11 +152,14 @@ internal object Xodus { } } - fun diagnostics(child: Child): Thread = Thread({ + const val SIGN_IN_REJECTED = "Microsoft content licensing rejected sign-in \\(HTTP 401\\)\\. Sign in again in Settings\\." + + /** Passes the backend's known stderr diagnostics to [report]; other stderr text never leaves the process. */ + fun diagnostics(child: Child, report: (String) -> Unit = { com.droiddeck.launcher.stores.StoresState.logLine("Xbox: $it") }): Thread = Thread({ runCatching { child.process.errorStream.bufferedReader().useLines { lines -> lines.forEach { line -> - if (line.matches(Regex("NEXUS_CREDENTIAL_ERROR_V1 [a-z-]{1,48}|NEXUS_LIBRARY_DIAGNOSTIC_V1 [a-z-]{1,48} [0-9]{1,3} [0-9]{1,6}|NEXUS_SESSION_DIAGNOSTIC_V1 [a-z-]{1,64}|Account library unavailable \\([a-z-]{1,64}\\); credentials and installed games are unchanged\\.|Account library timed out; installed games are unchanged\\."))) - com.droiddeck.launcher.stores.StoresState.logLine("Xbox: $line") + if (line.matches(Regex("NEXUS_CREDENTIAL_ERROR_V1 [a-z-]{1,48}|NEXUS_LIBRARY_DIAGNOSTIC_V1 [a-z-]{1,48} [0-9]{1,3} [0-9]{1,6}|NEXUS_SESSION_DIAGNOSTIC_V1 [a-z-]{1,64}|Account library unavailable \\([a-z-]{1,64}\\); credentials and installed games are unchanged\\.|Account library timed out; installed games are unchanged\\.|Xbox profile is temporarily unavailable \\([a-z-]{1,48}\\); stored account is unchanged\\.|Xbox profile request timed out; stored account is unchanged\\.|NEXUS_CONTENT_LICENSE_DIAGNOSTIC_V1 [a-z-]{1,48} [0-9]{3}|$SIGN_IN_REJECTED"))) + report(line) } } } }, "xbox-diagnostics").apply { start() } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/AddAppDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/AddAppDialog.kt index 5e63d7b0f..b3b88f0aa 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/AddAppDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/AddAppDialog.kt @@ -308,6 +308,7 @@ internal fun AppDialog( width: Dp? = null, fitContent: Boolean = false, back: (() -> Unit)? = null, scrollable: Boolean = true, + scrollState: androidx.compose.foundation.ScrollState = rememberScrollState(), content: @Composable ColumnScope.() -> Unit, ) { val pal = LocalPalette.current @@ -345,7 +346,7 @@ internal fun AppDialog( .blockTouches() .controllerBack(back ?: close) .then(modifier) - .then(if (scrollable) Modifier.verticalScroll(rememberScrollState()) else Modifier) + .then(if (scrollable) Modifier.verticalScroll(scrollState) else Modifier) .padding(horizontal = 18.dp, vertical = 16.dp), content = content, ) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/BootMovieSection.kt b/app/src/main/java/com/droiddeck/launcher/ui/BootMovieSection.kt index a4227d4c2..c4d8053d9 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/BootMovieSection.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/BootMovieSection.kt @@ -676,14 +676,15 @@ private fun MoviePreview(url: String, modifier: Modifier = Modifier) { /** A dialog with one text box, for a search term or a page number; the Android keyboard comes up with it. */ @Composable -private fun TextEntryDialog( +internal fun TextEntryDialog( title: String, initial: String, number: Boolean, confirm: String, onSubmit: (String) -> Unit, onDismiss: () -> Unit, + id: String = "boot-movie-entry", ) { val shown = rememberShown(onDismiss) val close = { shown.targetState = false } var text by remember { mutableStateOf(initial) } val submit = { onSubmit(text.trim()); close() } - AppDialog(shown, close, "boot-movie-entry", wide = false, maxWidth = 460.dp) { + AppDialog(shown, close, id, wide = false, maxWidth = 460.dp) { DialogTitleRow(title, close) AdbTextField( value = text, onValueChange = { text = it }, label = "", keyboardType = if (number) KeyboardType.Number else KeyboardType.Text, diff --git a/app/src/main/java/com/droiddeck/launcher/ui/CardDetailHost.kt b/app/src/main/java/com/droiddeck/launcher/ui/CardDetailHost.kt new file mode 100644 index 000000000..f606e6008 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/CardDetailHost.kt @@ -0,0 +1,143 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay +import androidx.compose.runtime.setValue +import androidx.compose.runtime.getValue + +/** A grid of cards with a game page that floods out of the opened card and drains back into it. */ +internal val LocalDetailFloor = compositionLocalOf<() -> Float> { { Float.MAX_VALUE } } + +@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class) +@Composable +internal fun CardDetailHost( + open: String?, + detailScroll: ScrollState, + gridScrolled: () -> Boolean, + onDetailFocus: (Boolean) -> Unit, + modifier: Modifier = Modifier, + wrap: @Composable (key: String, content: @Composable () -> Unit) -> Unit = { _, content -> content() }, + grid: @Composable () -> Unit, + detail: @Composable (key: String) -> Unit, +) { + val ctx = LocalContext.current + val ff = LocalFrontFocus.current + val density = LocalDensity.current + var shown by remember { mutableStateOf<String?>(null) } + var leaving by remember { mutableStateOf(false) } + var from by remember { mutableStateOf<Origin?>(null) } + var art by remember { mutableStateOf<String?>(null) } + var viewportAt by remember { mutableStateOf(Offset.Zero) } + var viewportH by remember { mutableStateOf(0) } + LaunchedEffect(open) { + if (open != null) { + val mark = StoresMotion.takeCard(open) + from = mark?.let { Origin(it.bounds.translate(-viewportAt), it.corner) } + art = mark?.art + StoresMotion.heroLands = mark != null + leaving = false + shown = open + } else if (shown != null) { + if (from != null) { + leaving = true + delay(Motion.ms(PAGE_RETURN_MS).toLong()) + } + shown = null + leaving = false + from = null + } + } + LaunchedEffect(shown) { detailScroll.scrollTo(0) } + val covering = shown != null && !leaving + val coveringNow = rememberUpdatedState(covering) + val gridScale by animateFloatAsState(if (covering) 0.95f else 1f, if (covering) Motion.tw(480) else Motion.tw(460, 40), label = "gridSink") + val gridAlpha by animateFloatAsState(if (covering) 0f else 1f, if (covering) Motion.tw(320, 80) else Motion.tw(300, 100), label = "gridFade") + val fade = with(density) { 14.dp.toPx() } + Box( + modifier.clipToBounds() + .onGloballyPositioned { viewportAt = it.positionInRoot(); viewportH = it.size.height } + .graphicsLayer { compositingStrategy = androidx.compose.ui.graphics.CompositingStrategy.Offscreen } + .drawWithContent { + drawContent() + val scrolled = if (shown != null) detailScroll.value > 0 else gridScrolled() + if (scrolled && size.height > fade) drawRect( + Brush.verticalGradient(0f to Color.Transparent, fade / size.height to Color.Black), + blendMode = androidx.compose.ui.graphics.BlendMode.DstIn, + ) + }, + ) { + CompositionLocalProvider(LocalFrontFocus provides if (covering) null else ff) { + Column( + modifier = Modifier.fillMaxSize() + .graphicsLayer { scaleX = gridScale; scaleY = gridScale; alpha = gridAlpha } + .focusProperties { enter = { if (coveringNow.value) FocusRequester.Cancel else FocusRequester.Default } } + .focusGroup(), + ) { grid() } + } + shown?.let { key -> + wrap(key) { + val body: @Composable () -> Unit = { + Column( + Modifier.fillMaxSize().pointerInput(Unit) { detectTapGestures { } } + .verticalScroll(detailScroll).padding(top = 4.dp, bottom = 16.dp), + ) { + Column( + Modifier.fillMaxWidth() + .onFocusChanged { onDetailFocus(it.hasFocus) } + .focusProperties { exit = { if (coveringNow.value && Steps.step == null && StoresMotion.step == null) FocusRequester.Cancel else FocusRequester.Default } } + .focusGroup(), + ) { + val floor = { viewportAt.y + viewportH - with(density) { 16.dp.toPx() } - detailScroll.value } + CompositionLocalProvider(LocalDetailFloor provides floor) { detail(key) } + } + } + } + val origin = from + if (origin == null) body() + else { + val painter = art?.let { url -> + coil.compose.rememberAsyncImagePainter( + remember(url) { + coil.request.ImageRequest.Builder(ctx).data(url).size(1280, 720) + .placeholderMemoryCacheKey(url).build() + }, + ) + } + PageFlood(origin, leaving, painter, onto = { StoresMotion.heroAt }, ontoCorner = 16.dp) { body() } + } + } + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ColorPickerDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/ColorPickerDialog.kt index e94f193e9..d83e1abe1 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ColorPickerDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ColorPickerDialog.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable @@ -70,12 +71,15 @@ internal fun ColorPickerDialog(title: String, initial: Int, onDismiss: () -> Uni var hexText by remember { mutableStateOf(ThemeColors.hex(initial)) } val current = ThemeColors.fromHsv(hue, sat, value) val first = remember { FocusRequester() } + val scroll = rememberScrollState() + var typingHex by remember { mutableStateOf(false) } LaunchedEffect(Unit) { focusSoon { first } } + LaunchedEffect(typingHex, scroll.maxValue) { if (typingHex) scroll.animateScrollTo(scroll.maxValue) } fun picked(h: Float = hue, s: Float = sat, v: Float = value) { hue = h.coerceIn(0f, 360f); sat = s.coerceIn(0f, 1f); value = v.coerceIn(0f, 1f) hexText = ThemeColors.hex(ThemeColors.fromHsv(hue, sat, value)) } - AppDialog(shown, close, "colorPicker", wide = false, maxWidth = 460.dp) { + AppDialog(shown, close, "colorPicker", wide = false, maxWidth = 460.dp, scrollState = scroll) { DialogTitleRow(title, close) Row(horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { ColorWheel(hue, sat, value) { h, s -> picked(h = h, s = s) } @@ -95,6 +99,7 @@ internal fun ColorPickerDialog(title: String, initial: Int, onDismiss: () -> Uni ThemeColors.parseHex(text)?.let { argb -> ThemeColors.toHsv(argb).let { hsv -> hue = hsv.h; sat = hsv.s; value = hsv.v } } }, label = "", keyboardType = KeyboardType.Ascii, imeAction = ImeAction.Done, placeholder = stringResource(R.string.theme_color_hex), + onFocusChange = { typingHex = it }, onDone = { ThemeColors.parseHex(hexText)?.let { onApply(it); close() } }, ) Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { diff --git a/app/src/main/java/com/droiddeck/launcher/ui/DesktopPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/DesktopPage.kt index f6ce824ea..39862c082 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/DesktopPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/DesktopPage.kt @@ -70,6 +70,7 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.boundsInRoot import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource @@ -102,6 +103,7 @@ internal fun DesktopPage(s: FrontEndState, a: FrontEndActions, modifier: Modifie val ctx = LocalContext.current val density = LocalDensity.current val narrow = LocalNarrowPane.current + val short = compactDesktop() val colors = MaterialTheme.colorScheme LaunchedEffect(Unit) { UserAppsState.refresh(ctx) } LaunchedEffect(s.ready) { com.droiddeck.launcher.store.StoreState.refresh(ctx) } @@ -120,7 +122,7 @@ internal fun DesktopPage(s: FrontEndState, a: FrontEndActions, modifier: Modifie key = "desktop", tabs = tabs, onOpen = a.onDesktopSettings, onPrimary = a.onDesktop, modifier = modifier.clipToBounds(), - start = if (narrow) 20.dp else 44.dp, bottom = if (narrow) 20.dp else 44.dp, + start = if (narrow) 20.dp else 44.dp, bottom = 44.dp, background = { DesktopWall(installed.map { it.iconRes } + apps.mapNotNull { it.icon }) }, @@ -132,8 +134,8 @@ internal fun DesktopPage(s: FrontEndState, a: FrontEndActions, modifier: Modifie }, words = { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text( - stringResource(R.string.rail_desktop), fontSize = if (narrow) 30.sp else 42.sp, lineHeight = if (narrow) 34.sp else 46.sp, + if (LocalConfiguration.current.screenHeightDp >= 400) Text( + stringResource(R.string.rail_desktop), fontSize = if (short) 30.sp else 42.sp, lineHeight = if (short) 34.sp else 46.sp, fontWeight = FontWeight.Black, color = colors.onBackground, ) BusyChip(s) @@ -143,11 +145,12 @@ internal fun DesktopPage(s: FrontEndState, a: FrontEndActions, modifier: Modifie PrimaryButton(open, enabled = !s.busy, main = true, large = true, icon = Icons.Outlined.DesktopWindows, onClick = click) }, beside = { - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - HeroIconButton(Icons.Outlined.Storefront, stringResource(R.string.flathub_name), FlathubTeal, "desktop:flathub") { at -> openFrom(at, null, "store") } - HeroIconButton(Icons.Filled.Add, stringResource(R.string.user_apps_add), null, "desktop:add") { if (!s.busy) adding = true } - } + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.horizontalScroll(rememberScrollState()), + ) { + HeroIconButton(Icons.Outlined.Storefront, stringResource(R.string.flathub_name), FlathubTeal, "desktop:flathub") { at -> openFrom(at, null, "store") } + HeroIconButton(Icons.Filled.Add, stringResource(R.string.user_apps_add), null, "desktop:add") { if (!s.busy) adding = true } if (s.desktopPresent) UninstallDesktop(enabled = !s.runtimeActionsBlocked, onUninstall = a.onRemoveDesktop) } }, @@ -159,6 +162,9 @@ internal fun DesktopPage(s: FrontEndState, a: FrontEndActions, modifier: Modifie if (adding && !s.busy) AddAppDialog(s.ready, onDismiss = { adding = false }) { request, label -> UserAppsState.add(ctx, request, label) } } +@Composable +private fun compactDesktop() = LocalNarrowPane.current || LocalConfiguration.current.screenHeightDp < 600 + @Composable private fun DesktopDock( s: FrontEndState, a: FrontEndActions, installed: List<Library.Emulator>, apps: List<UserApps.App>, @@ -177,7 +183,7 @@ private fun DesktopDock( LaunchedEffect(Unit) { mounted[0] = true } val scroll = rememberScrollState() if (keys.isEmpty() && available.isEmpty() && UserAppsState.working == null && UserAppsState.lastError == null) return - Column(Modifier.fillMaxWidth().padding(top = if (narrow) 14.dp else 22.dp)) { + Column(Modifier.fillMaxWidth().padding(top = if (compactDesktop()) 14.dp else 22.dp)) { Text( stringResource(R.string.user_apps_section).uppercase(), fontSize = 11.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.6.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(start = if (narrow) 20.dp else 44.dp, bottom = 8.dp), diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ExePickerDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/ExePickerDialog.kt index 901bf6485..9fd363155 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ExePickerDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ExePickerDialog.kt @@ -27,6 +27,8 @@ import androidx.compose.material.icons.outlined.Image import androidx.compose.material.icons.outlined.InsertDriveFile import androidx.compose.material.icons.outlined.SdCard import androidx.compose.material.icons.outlined.Smartphone +import androidx.compose.material.icons.outlined.Visibility +import androidx.compose.material.icons.outlined.VisibilityOff import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -81,8 +83,8 @@ internal class PickFolder(val dir: File, val readable: Boolean) internal class PickListing(val folders: List<PickFolder>, val files: List<File>) -internal fun pickListing(dir: File, kind: PickKind): PickListing { - val all = dir.listFiles()?.filter { !it.name.startsWith(".") }.orEmpty().sortedBy { it.name.lowercase() } +internal fun pickListing(dir: File, kind: PickKind, showHidden: Boolean = false): PickListing { + val all = dir.listFiles()?.filter { showHidden || !it.name.startsWith(".") }.orEmpty().sortedBy { it.name.lowercase() } return PickListing( all.filter { it.isDirectory }.map { PickFolder(it, it.canRead() && it.list() != null) }, all.filter { it.isFile && it.extension.lowercase() in kind.extensions }, @@ -92,11 +94,13 @@ internal fun pickListing(dir: File, kind: PickKind): PickListing { @Composable internal fun ExePickerDialog( title: String, kind: PickKind = PickKind.EXE, start: File? = null, - onAddAll: ((File) -> Unit)? = null, onPick: (File) -> Unit, onDismiss: () -> Unit, + onAddAll: ((File, Boolean) -> Unit)? = null, onPick: (File) -> Unit, onDismiss: () -> Unit, ) { val ctx = LocalContext.current val colors = MaterialTheme.colorScheme val density = LocalDensity.current + val prefs = remember(ctx) { ctx.getSharedPreferences("game_picker", Context.MODE_PRIVATE) } + var showHidden by remember { mutableStateOf(prefs.getBoolean("show_hidden", false)) } val shown = rememberShown(onDismiss) val close = { shown.targetState = false } val volumes = remember { pickVolumes(ctx) } @@ -107,14 +111,23 @@ internal fun ExePickerDialog( val back = { if (dir == null) close() else up() } val first = remember(dir) { FocusRequester() } LaunchedEffect(dir) { focusWithinFrames({ false }) { first } } - val listing = remember(dir) { dir?.let { pickListing(it, kind) } } + val listing = remember(dir, showHidden) { dir?.let { pickListing(it, kind, showHidden) } } var locked by remember { mutableStateOf<Dp?>(null) } AppDialog( shown, close, "filePicker", wide = false, maxWidth = 520.dp, width = locked, fitContent = locked == null, back = back, modifier = Modifier.onSizeChanged { if (locked == null && it.width > 0) locked = with(density) { it.width.toDp() } }, ) { Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) { - DialogTitleRow(title, onClose = close) + DialogTitleRow(title, onClose = close, leading = { + SmallIconButton( + if (showHidden) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, + stringResource(if (showHidden) R.string.fm_hide_hidden else R.string.fm_show_hidden), + if (showHidden) colors.primary else colors.onSurfaceVariant, + ) { + showHidden = !showHidden + prefs.edit().putBoolean("show_hidden", showHidden).apply() + } + }) if (dir != null && volume != null) { val crumbs = listOf(volume.label) + dir!!.path.removePrefix(volume.root.path).split('/').filter { it.isNotEmpty() } Text(crumbs.joinToString(" › "), fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) @@ -135,7 +148,7 @@ internal fun ExePickerDialog( val shownListing = listing ?: PickListing(emptyList(), emptyList()) PickerRow(Icons.AutoMirrored.Outlined.ArrowBack, "..", null, focus = first, onClick = up) if (onAddAll != null && shownListing.folders.any { it.readable }) { - PickerRow(Icons.Filled.Add, stringResource(R.string.games_add_all), null, accent = true) { onAddAll(dir!!); close() } + PickerRow(Icons.Filled.Add, stringResource(R.string.games_add_all), null, accent = true) { onAddAll(dir!!, showHidden); close() } } for (f in shownListing.folders) key(f.dir.path) { PickerRow(Icons.Outlined.Folder, f.dir.name, null, enabled = f.readable) { dir = f.dir } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt index db6850226..b70f9bb5e 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt @@ -254,7 +254,7 @@ private fun Content( return } if (selected == "games" || selected.startsWith("app:")) { - GamesPage(s, a, selected, onSelect, modifier) + LibraryPage(s, a, selected, onSelect, modifier) return } if (selected == "desktop") { diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt index dcb5eece6..efc00297c 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt @@ -5,7 +5,6 @@ import com.droiddeck.launcher.R import androidx.compose.ui.res.stringResource import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.MutableInteractionSource @@ -13,143 +12,39 @@ import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.runtime.key -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.BoxScope -import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Delete -import androidx.compose.material.icons.outlined.Edit import androidx.compose.ui.semantics.semantics import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import kotlinx.coroutines.flow.first -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import coil.compose.AsyncImage import com.droiddeck.launcher.frontend.Library -import com.droiddeck.launcher.stores.epic.EpicClient import kotlinx.coroutines.launch +import androidx.compose.runtime.setValue +import androidx.compose.runtime.getValue @Composable -internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, onSelect: (String) -> Unit, modifier: Modifier) { - val pageContext = androidx.compose.ui.platform.LocalContext.current - LaunchedEffect(Unit) { EpicClient.refresh(pageContext) } - val launcherName = stringResource(R.string.epic_client_title) - val epic = EpicClient.status - val launcherArt by androidx.compose.runtime.produceState<java.io.File?>(null, epic.version) { - value = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { EpicClient.icon(pageContext, epic.version) } - } - val games = remember(s.steamGames, s.shortcutPicker, epic, launcherName, launcherArt) { - val sorted = s.steamGames.sortedByDescending { it.lastPlayed } - if (s.shortcutPicker) sorted else EpicClient.withLauncher(sorted, EpicClient.launcherEntry(pageContext, launcherName, epic, launcherArt)) - } - val gameCount = games.count { it.library != Library.EPIC_LAUNCHER } - val narrow = LocalNarrowPane.current - val current = games.firstOrNull { "app:${it.appId}" == selected } ?: games.firstOrNull() - GamesDialogs(a, s.steamGames) - val onAdd: (() -> Unit)? = if (s.shortcutPicker) null else ({ GamesDialogState.adding = true }) - val launch: (Library.SteamGame) -> Unit = { g -> if (g.library == Library.EPIC_LAUNCHER) a.onEpicLauncher() else a.onSteamGame(g) } - val onEdit: (Library.SteamGame) -> Unit = { g -> - g.gameFiles?.let { GamesDialogState.editing = EditRequest(it.path, openTarget = false, fromSummary = false, preview = EditPreview.of(pageContext, g, it.path)) } - } - if (current == null || gameCount == 0) { - Column(modifier = modifier.padding(horizontal = if (narrow) 16.dp else 22.dp, vertical = if (narrow) 12.dp else 18.dp)) { - Rise(0) { - PageHeader(stringResource(R.string.content_games)) { - if (s.steamGamesX64) ClientTag() - onAdd?.let { AddGameButton(it) } - } - } - Rise(1) { Note(stringResource(if (s.shortcutPicker && s.shortcutLibraryScanning) R.string.game_shortcut_scanning else R.string.games_empty)) } - if (!s.shortcutPicker) Rise(2) { - Actions { - PrimaryButton(stringResource(R.string.games_play_steam), enabled = !s.busy, main = true, icon = Icons.Filled.PlayArrow, modifier = Modifier.padding(top = 12.dp).testTag("play-steam"), onClick = a.onPlay) - games.firstOrNull { it.library == Library.EPIC_LAUNCHER }?.let { SecondaryButton(it.name, enabled = !s.busy, modifier = Modifier.padding(top = 12.dp)) { a.onEpicLauncher() } } - } - } - GameFileFolderActions(s, a) - } - return - } - val host = rememberMenuHost() - if (games.size == 1) { - Column(modifier = modifier.verticalScroll(rememberScrollState()).padding(horizontal = if (narrow) 16.dp else 22.dp, vertical = if (narrow) 12.dp else 18.dp)) { - if (onAdd != null) Rise(0) { GamesHeader(gameCount, s.steamGamesX64, onAdd, Modifier.padding(bottom = 10.dp)) } - Rise(0) { - Row(verticalAlignment = Alignment.Bottom) { - GameHero(current, Modifier.weight(1f).heightIn(min = if (narrow) 190.dp else 250.dp), corner = { GameRemoveButton(current, s, a) }) { - GameHeroCopy(current, if (narrow) 28.sp else 38.sp) - GameActions(current, s, a, onEdit, launch) - } - if (!narrow) Poster(current.art, current.name, Modifier.width(168.dp)) - } - } - Rise(1) { SectionTitle(stringResource(R.string.games_launch_settings), null) } - Rise(2) { LaunchSettings(s, a, host, current) } - Rise(3) { GameFileFolderActions(s, a) } - } - return - } - val pal = LocalPalette.current - Row(modifier = modifier) { - GameList(games, gameCount, s.steamGamesX64, current, onSelect = { onSelect("app:${it.appId}") }, onLaunch = launch, onAdd = onAdd, - modifier = Modifier.width(if (narrow) 168.dp else 250.dp).fillMaxHeight()) - Box(Modifier.width(1.dp).fillMaxHeight().background(pal.line)) - Column( - modifier = Modifier.weight(1f).fillMaxHeight().verticalScroll(rememberScrollState()) - .padding(horizontal = if (narrow) 14.dp else 20.dp, vertical = 16.dp), - ) { - GameHero(current, Modifier.fillMaxWidth().heightIn(min = if (narrow) 170.dp else 200.dp), corner = { GameRemoveButton(current, s, a) }) { - GameHeroCopy(current, if (narrow) 24.sp else 32.sp) - GameActions(current, s, a, onEdit, launch) - } - SectionTitle(stringResource(R.string.games_launch_settings), null) - LaunchSettings(s, a, host, current) - GameFileFolderActions(s, a) - } - } -} - -@Composable -private fun GameFileFolderActions(s: FrontEndState, a: FrontEndActions) { +internal fun GameFileFolderActions(s: FrontEndState, a: FrontEndActions) { if (s.shortcutPicker) return var open by remember { androidx.compose.runtime.mutableStateOf(false) } - Box(Modifier.padding(top = 12.dp)) { + Box { SecondaryButton(stringResource(R.string.game_frontend_files), compact = true) { open = !open } AnchoredMenu(open, onDismiss = { open = false }, title = s.gameSyncFolder ?: stringResource(R.string.game_frontend_files)) { first -> MenuItem(stringResource(R.string.game_file_sync), checked = false, focusRequester = first) { @@ -164,31 +59,7 @@ private fun GameFileFolderActions(s: FrontEndState, a: FrontEndActions) { @Composable -private fun GameActions(g: Library.SteamGame, s: FrontEndState, a: FrontEndActions, onEdit: (Library.SteamGame) -> Unit, launch: (Library.SteamGame) -> Unit) { - Actions { - PrimaryButton(stringResource(if (s.shortcutPicker) R.string.game_shortcut_choose else R.string.games_launch), enabled = !s.busy, main = true, icon = Icons.Filled.PlayArrow) { launch(g) } - if (g.library == Library.EPIC_LAUNCHER) { - if (!s.shortcutPicker) UninstallEpicLauncher(s, a) - BusyChip(s) - return@Actions - } - g.gameFiles?.takeIf { it.isDirectory }?.let { dir -> - SecondaryButton(stringResource(R.string.games_files), compact = true) { a.onBrowseFiles(dir) } - } - g.protonPrefix?.takeIf { it.isDirectory }?.let { dir -> - SecondaryButton(stringResource(R.string.games_prefix), compact = true) { a.onBrowseFiles(dir) } - if (g.library == Library.ADDED) ManageSaves(g, dir, a) - } - if (!s.shortcutPicker) GameShortcutMenu(g, a) - if (!s.shortcutPicker && g.library == Library.ADDED && g.source == Library.ADDED && g.gameFiles != null) EditGameButton { onEdit(g) } - else if (!s.shortcutPicker && g.library != Library.ADDED && g.source == Library.SOURCE_STEAM) EditGameButton { GamesDialogState.steamArt = g } - else if (!s.shortcutPicker && g.library == Library.ADDED && g.source != Library.ADDED && g.gameFiles != null) EditGameButton { GamesDialogState.storeArt = g } - BusyChip(s) - } -} - -@Composable -private fun UninstallEpicLauncher(s: FrontEndState, a: FrontEndActions) { +internal fun UninstallEpicLauncher(s: FrontEndState, a: FrontEndActions) { var asking by remember { androidx.compose.runtime.mutableStateOf(false) } SecondaryButton(stringResource(R.string.common_uninstall), enabled = !s.busy, compact = true) { asking = true } if (asking) { @@ -203,71 +74,6 @@ private fun UninstallEpicLauncher(s: FrontEndState, a: FrontEndActions) { } } -@Composable -private fun GameShortcutMenu(g: Library.SteamGame, a: FrontEndActions) { - var open by remember(g.gameId) { androidx.compose.runtime.mutableStateOf(false) } - Box { - SecondaryButton(stringResource(R.string.game_shortcut), compact = true) { open = !open } - AnchoredMenu(open, onDismiss = { open = false }, title = stringResource(R.string.game_shortcut)) { first -> - MenuItem(stringResource(R.string.game_shortcut_add), checked = false, focusRequester = first) { - open = false; a.onGameShortcut(g) - } - MenuItem(stringResource(R.string.game_file_export), checked = false) { - open = false; a.onExportGameFile(g) - } - MenuItem(stringResource(R.string.game_link_copy), checked = false) { - open = false; a.onCopyGameLink(g) - } - } - } -} - -@Composable -private fun ManageSaves(g: Library.SteamGame, prefix: java.io.File, a: FrontEndActions) { - var open by remember(g.gameId) { androidx.compose.runtime.mutableStateOf(false) } - val saves by androidx.compose.runtime.produceState<List<com.droiddeck.launcher.session.GameSaves.SaveDir>?>(null, g.gameId, open) { - if (open) value = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { - runCatching { com.droiddeck.launcher.session.GameSaves.locate(prefix, g) }.getOrDefault(emptyList()) - } - } - val found = saves - val summary = when { - found == null -> stringResource(R.string.games_saves_looking) - found.isEmpty() -> stringResource(R.string.games_saves_none) - else -> { - val mb = found.sumOf { it.bytes } / 1048576.0 - stringResource( - R.string.games_saves_summary, - if (found.size == 1) found[0].relPath else stringResource(R.string.games_saves_folders, found.size), - found.sumOf { it.files }, - if (mb < 1) stringResource(R.string.games_kb, (mb * 1024).toInt()) else stringResource(R.string.common_size_mb, mb), - ) - } - } - Box { - val cloudGame = com.droiddeck.launcher.stores.Store.byId(g.source).let { it == com.droiddeck.launcher.stores.Store.GOG || it == com.droiddeck.launcher.stores.Store.EPIC } - val label = stringResource(if (cloudGame) R.string.store_cloud_saves else R.string.games_manage_saves) - SecondaryButton(label, compact = true) { open = !open } - AnchoredMenu(open, onDismiss = { open = false }, title = if (cloudGame) label else stringResource(R.string.games_saves), note = summary) { first -> - if (cloudGame) CloudRows(g, open, first) - MenuItem(stringResource(R.string.games_import_saves), checked = false, focusRequester = if (cloudGame) null else first) { - open = false; a.onSaveImport(g) - } - MenuItem(stringResource(R.string.games_export_gamehub), checked = false) { - open = false; a.onSaveExport(g, com.droiddeck.launcher.session.GameSaves.Layout.GAMEHUB) - } - MenuItem(stringResource(R.string.games_export_winlator), checked = false) { - open = false; a.onSaveExport(g, com.droiddeck.launcher.session.GameSaves.Layout.WINLATOR) - } - found?.firstOrNull()?.let { d -> - MenuItem(stringResource(R.string.games_open_save_folder), checked = false, detail = d.relPath) { - open = false; a.onBrowseFiles(java.io.File(prefix, "drive_c/users/steamuser/" + d.relPath)) - } - } - } - } -} - /** A skipped sync is not a success: "0 files" used to show for a signed-out store or cloud saves turned off. */ internal fun cloudMessage(context: android.content.Context, r: com.droiddeck.launcher.stores.CloudSaves.Result): String = when (r.result) { "ok" -> when { @@ -284,112 +90,7 @@ internal fun cloudMessage(context: android.content.Context, r: com.droiddeck.lau } @Composable -private fun CloudRows(g: Library.SteamGame, open: Boolean, first: androidx.compose.ui.focus.FocusRequester) { - val store = com.droiddeck.launcher.stores.Store.byId(g.source)?.takeIf { it != com.droiddeck.launcher.stores.Store.AMAZON } ?: return - val id = g.storeId ?: return - val context = androidx.compose.ui.platform.LocalContext.current.applicationContext - var busy by remember(g.gameId) { androidx.compose.runtime.mutableStateOf<String?>(null) } - var tick by remember(g.gameId) { androidx.compose.runtime.mutableStateOf(0) } - val status by androidx.compose.runtime.produceState<com.droiddeck.launcher.stores.CloudSaves.Status?>(null, g.gameId, open, tick) { - if (open) value = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { - runCatching { com.droiddeck.launcher.stores.CloudSaves.status(context, store, id) }.getOrNull() - } - } - val folder = g.gameFiles - var enabled by remember(g.gameId, open) { androidx.compose.runtime.mutableStateOf(folder?.let { com.droiddeck.launcher.stores.StoreGameSidecar.read(it)?.cloud } ?: true) } - val st = status - val supported = st?.supported ?: true - val conflict = st != null && st.conflicts.isNotEmpty() - fun sync(up: Boolean, force: Boolean = false) { - if (busy != null) return - busy = if (up) "up" else "down" - Thread({ - val r = if (up) com.droiddeck.launcher.stores.CloudSaves.upload(context, store, id, force) else com.droiddeck.launcher.stores.CloudSaves.download(context, store, id, force) - com.droiddeck.launcher.stores.StoresState.post { busy = null; tick++; android.widget.Toast.makeText(context, cloudMessage(context, r), android.widget.Toast.LENGTH_SHORT).show() } - }, "cloud-manual").start() - } - MenuItem(stringResource(R.string.store_cloud_saves), checked = enabled && supported, enabled = supported, focusRequester = first, - detail = if (!supported) stringResource(R.string.cloud_none) else null) { - val f = folder ?: return@MenuItem - val next = !enabled - Thread({ - val written = runCatching { com.droiddeck.launcher.stores.StoreGameSidecar.updateCloud(f, next) }.getOrNull() - com.droiddeck.launcher.stores.StoresState.post { enabled = written?.cloud ?: enabled } - }, "cloud-switch").start() - } - val ready = st != null && supported && enabled && busy == null - val pending = stringResource(R.string.cloud_pending) - val summary = when { - st == null || busy != null -> pending - !supported -> stringResource(R.string.cloud_none) - conflict -> stringResource(R.string.cloud_conflict_files, st.conflicts.size) - st.lastSync > 0 -> stringResource(R.string.cloud_last_sync, android.text.format.DateUtils.getRelativeTimeSpanString(st.lastSync).toString()) - else -> stringResource(R.string.cloud_never) - } - MenuItem(stringResource(if (conflict) R.string.cloud_keep_local else R.string.cloud_upload), checked = false, enabled = ready, detail = summary) { sync(up = true, force = conflict) } - MenuItem(stringResource(if (conflict) R.string.cloud_keep_cloud else R.string.cloud_download), checked = false, enabled = ready, detail = if (st == null || busy != null) pending else null) { sync(up = false, force = conflict) } -} - -@Composable -private fun ColumnScope.GameHeroCopy(g: Library.SteamGame, titleSize: androidx.compose.ui.unit.TextUnit) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - SourceChip(g.source) - val eyebrow = lastPlayedText(g.lastPlayed) ?: libraryLabel(g) - if (eyebrow.isNotEmpty()) Text( - eyebrow.uppercase(), - fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.2.sp, color = LocalPalette.current.signal, - maxLines = 1, overflow = TextOverflow.Ellipsis, - ) - } - Text( - g.name, fontSize = titleSize, lineHeight = titleSize * 1.15f, fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis, - ) - Spacer(Modifier.height(8.dp)) -} - -@Composable -private fun GameList( - games: List<Library.SteamGame>, count: Int, x64: Boolean, current: Library.SteamGame, - onSelect: (Library.SteamGame) -> Unit, onLaunch: (Library.SteamGame) -> Unit, onAdd: (() -> Unit)?, modifier: Modifier, -) { - val colors = MaterialTheme.colorScheme - Column( - verticalArrangement = Arrangement.spacedBy(2.dp), - modifier = modifier.verticalScroll(rememberScrollState()).padding(start = 12.dp, end = 10.dp, top = 16.dp, bottom = 16.dp), - ) { - GamesHeader(count, x64, onAdd, Modifier.padding(start = 6.dp, bottom = 10.dp)) - val seen = remember { HashSet<Int>() } - var settled by remember { androidx.compose.runtime.mutableStateOf(false) } - for (g in games) key(g.appId) { - Box(Modifier.arriveIn(settled && g.appId !in seen)) { - GameRow(g, g.appId == current.appId, onSelect = { onSelect(g) }, onLaunch = { onLaunch(g) }) - } - } - androidx.compose.runtime.SideEffect { - seen.addAll(games.map { it.appId }) - if (games.isNotEmpty()) settled = true - } - } -} - -@Composable -private fun GamesHeader(count: Int, x64: Boolean, onAdd: (() -> Unit)?, modifier: Modifier) { - val colors = MaterialTheme.colorScheme - val narrow = LocalNarrowPane.current - Column(modifier) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Text(stringResource(R.string.content_games), fontSize = 22.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 1) - Text(count.toString(), fontSize = 13.sp, color = colors.onSurfaceVariant) - if (x64 && !narrow) ClientTag() - if (onAdd != null) AddGameButton(onAdd) - } - if (x64 && narrow) Box(Modifier.padding(top = 4.dp)) { ClientTag() } - } -} - -@Composable -private fun ClientTag() { +internal fun ClientTag() { Box(Modifier.testTag("games-client-x64")) { StateChip(stringResource(R.string.mode_client_arch_x64), ChipTone.PLAIN) } } @@ -404,13 +105,13 @@ internal object GamesDialogState { } @Composable -private fun GamesDialogs(a: FrontEndActions, games: List<Library.SteamGame>) { +internal fun GamesDialogs(a: FrontEndActions, games: List<Library.SteamGame>) { val ctx = androidx.compose.ui.platform.LocalContext.current val scope = androidx.compose.runtime.rememberCoroutineScope() val st = GamesDialogState if (st.adding) ExePickerDialog( stringResource(R.string.games_add_title), - onAddAll = { dir -> + onAddAll = { dir, showHidden -> st.summary = emptyList<com.droiddeck.launcher.frontend.AddedGames.Game>() to 0 st.checking = dir.name scope.launch { @@ -419,6 +120,7 @@ private fun GamesDialogs(a: FrontEndActions, games: List<Library.SteamGame>) { ctx.applicationContext, dir, onGame = { g -> scope.launch { st.summary?.let { (list, n) -> st.summary = (list + g) to n } } }, onChecking = { name -> scope.launch { st.checking = name } }, + showHidden = showHidden, ) } st.checking = null @@ -457,68 +159,7 @@ private fun GamesDialogs(a: FrontEndActions, games: List<Library.SteamGame>) { } @Composable -private fun GameRemoveButton(g: Library.SteamGame, s: FrontEndState, a: FrontEndActions) { - val kind = com.droiddeck.launcher.frontend.GameRemoval.kind(g) - if (s.shortcutPicker || kind == com.droiddeck.launcher.frontend.GameRemoval.Kind.LAUNCHER) return - if (g.gameFiles?.isDirectory != true) return - val colors = MaterialTheme.colorScheme - val pal = LocalPalette.current - val src = remember { MutableInteractionSource() } - val hot = rememberHot(src) - var asking by remember(g.gameId) { androidx.compose.runtime.mutableStateOf(false) } - val shortcut = kind == com.droiddeck.launcher.frontend.GameRemoval.Kind.SHORTCUT - val label = stringResource(if (shortcut) R.string.edit_remove else R.string.common_uninstall) - val title = if (shortcut) stringResource(R.string.games_remove_title, g.name) else stringResource(R.string.stores_uninstall_title, g.name) - val body = when { - shortcut -> stringResource(R.string.games_remove_body) - g.library == Library.ADDED -> stringResource(R.string.stores_uninstall_body) - else -> stringResource(R.string.games_uninstall_body) - } - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.paneItem("game:remove").downToFirstTile().size(44.dp).clip(Shape12) - .background(if (hot) colors.error.copy(alpha = 0.22f) else Color.Black.copy(alpha = 0.34f)) - .glideBorder(hot, Shape12, colors.error, pal.line2) - .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = androidx.compose.ui.semantics.Role.Button) { asking = true } - .controllerConfirm { asking = true } - .semantics { contentDescription = label }, - ) { - androidx.compose.material3.Icon( - Icons.Outlined.Delete, contentDescription = null, - tint = if (hot) colors.error else Color.White, modifier = Modifier.size(18.dp), - ) - } - if (asking) ConfirmDialog( - title = title, - text = body, - confirm = label, - onConfirm = { a.onGameRemoved(g) }, - onDismiss = { asking = false }, - ) -} - -@Composable -private fun EditGameButton(onClick: () -> Unit) { - val colors = MaterialTheme.colorScheme - val pal = LocalPalette.current - val src = remember { MutableInteractionSource() } - val hot = rememberHot(src) - val label = stringResource(R.string.common_edit) - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.paneItem("game:edit").downToFirstTile().size(44.dp).clip(Shape12) - .background(if (hot) pal.signal.copy(alpha = 0.14f) else Color.White.copy(alpha = 0.03f)) - .glideBorder(hot, Shape12, pal.signal, pal.line2) - .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = androidx.compose.ui.semantics.Role.Button, onClick = onClick) - .controllerConfirm(onClick = onClick) - .semantics { contentDescription = label }, - ) { - androidx.compose.material3.Icon(Icons.Outlined.Edit, contentDescription = null, tint = colors.onBackground, modifier = Modifier.size(18.dp)) - } -} - -@Composable -private fun AddGameButton(onClick: () -> Unit) { +internal fun AddGameButton(onClick: () -> Unit) { val colors = MaterialTheme.colorScheme val pal = LocalPalette.current val src = remember { MutableInteractionSource() } @@ -538,48 +179,6 @@ private fun AddGameButton(onClick: () -> Unit) { } } -@Composable -internal fun Modifier.arriveIn(arriving: Boolean): Modifier { - val t = remember { androidx.compose.animation.core.Animatable(if (arriving) 0f else 1f) } - LaunchedEffect(Unit) { if (t.value < 1f) t.animateTo(1f, Motion.tw(280)) } - return graphicsLayer { alpha = t.value; translationY = (1f - t.value) * 12.dp.toPx() } -} - -@Composable -private fun GameRow(g: Library.SteamGame, selected: Boolean, onSelect: () -> Unit, onLaunch: () -> Unit) { - val colors = MaterialTheme.colorScheme - val pal = LocalPalette.current - val src = remember { MutableInteractionSource() } - val focused by src.collectIsFocusedAsState() - val hovered by src.collectIsHoveredAsState() - LaunchedEffect(focused) { if (focused && !selected) onSelect() } - Row( - verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.fillMaxWidth().paneItem("game:${g.appId}") - .clip(Shape12) - .background(if (selected) pal.signal.copy(alpha = 0.14f) else if (hovered) Color.White.copy(alpha = 0.05f) else Color.Transparent) - .glideBorder(focused, Shape12, pal.signal) - .hoverable(src).clickable(role = androidx.compose.ui.semantics.Role.Button, interactionSource = src, indication = LocalIndication.current, onClick = onSelect) - .controllerConfirm(onClick = onLaunch) - .padding(horizontal = 10.dp, vertical = 6.dp), - ) { - Box(Modifier.width(30.dp).height(45.dp).clip(RoundedCornerShape(5.dp)).background(artBrush(hueOf(g.name)))) { - if (g.art != null) AsyncImage(model = g.art, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize()) - } - Column(modifier = Modifier.weight(1f)) { - Text(g.name, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { - SourceChip(g.source, small = true) - Text( - if (g.library == Library.EPIC_LAUNCHER) libraryLabel(g) - else playedSpan(g.lastPlayed)?.replaceFirstChar { it.uppercase() } ?: stringResource(R.string.games_never_played), - fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, - ) - } - } - } -} - @Composable internal fun PageHeader(title: String, trailing: @Composable RowScope.() -> Unit = {}) { Row( @@ -608,11 +207,7 @@ internal fun RuntimeChip(s: FrontEndState) = when { else -> Chip(stringResource(R.string.games_runtime_ready), ok = true) } -@Composable -private fun lastPlayedText(lastPlayed: Long): String? = - playedSpan(lastPlayed)?.let { stringResource(R.string.games_last_played, it.replaceFirstChar { c -> c.lowercase() }) } - -private fun playedSpan(lastPlayed: Long): String? { +internal fun playedSpan(lastPlayed: Long): String? { if (lastPlayed <= 0L) return null return android.text.format.DateUtils.getRelativeTimeSpanString( lastPlayed * 1000L, System.currentTimeMillis(), android.text.format.DateUtils.MINUTE_IN_MILLIS, @@ -620,37 +215,9 @@ private fun playedSpan(lastPlayed: Long): String? { } @Composable -private fun libraryLabel(g: Library.SteamGame): String = when { - g.library == Library.EPIC_LAUNCHER -> EpicClient.status.version?.substringBefore('+')?.let { stringResource(R.string.epic_client_version, it) } ?: "" +internal fun libraryLabel(g: Library.SteamGame): String = when { + g.library == Library.XBOX_CLOUD -> stringResource(R.string.xbox_cloud_gaming) g.library != Library.ADDED -> if (g.library == "internal") stringResource(R.string.games_internal) else g.library g.source != Library.ADDED -> stringResource(R.string.games_store_storage) else -> "" } - -@Composable -private fun GameHero(g: Library.SteamGame, modifier: Modifier, corner: @Composable BoxScope.() -> Unit = {}, content: @Composable ColumnScope.() -> Unit) { - val colors = MaterialTheme.colorScheme - Box(modifier = modifier.clip(Shape16).background(artBrush(hueOf(g.name)))) { - val image = g.hero ?: g.art.takeIf { g.library != Library.EPIC_LAUNCHER } - if (image != null) AsyncImage( - model = image, contentDescription = null, contentScale = ContentScale.Crop, - modifier = Modifier.matchParentSize() - .then(if (g.hero == null) Modifier.blur(24.dp).graphicsLayer { scaleX = 1.3f; scaleY = 1.3f } else Modifier), - ) - Spacer( - Modifier.matchParentSize().background( - Brush.horizontalGradient( - 0f to colors.background.copy(alpha = 0.92f), - 0.55f to colors.background.copy(alpha = 0.6f), - 1f to Color.Transparent, - ), - ), - ) - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier.align(Alignment.BottomStart).padding(horizontal = 22.dp, vertical = 18.dp), - content = content, - ) - Box(Modifier.align(Alignment.TopEnd).padding(12.dp), content = corner) - } -} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt index 1840d0f07..a3c685b75 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt @@ -100,6 +100,10 @@ class FrontEndState( val logsEnabled: Boolean, val vacDiagnostics: Boolean = false, val agentCommands: Boolean = false, + val clearCachedApps: Boolean = false, + val adbPaired: Boolean = false, + val backgroundAppLimit: Int = -1, + val memoryHud: String = "off", val steamGames: List<Library.SteamGame>, val steamGamesX64: Boolean = false, val emulators: List<Library.Emulator>, @@ -124,6 +128,7 @@ class FrontEndState( val packageStage: String? = null, val packagePercent: Int = -1, val sessionRunning: Boolean = false, + val battleNetInstalled: Boolean = false, val removalPending: Boolean = false, val runtimeActionsBlocked: Boolean = false, val backActionsInverted: Boolean = false, @@ -132,16 +137,16 @@ class FrontEndState( val controller: com.droiddeck.launcher.input.ControllerPrefs.Settings? = null, val phantomProcessStatus: PhantomProcessStatus = PhantomProcessStatus.NOT_APPLICABLE, val showPhantomGate: Boolean = false, - val orientation: Int = android.content.pm.ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE, + val orientation: Int = android.content.pm.ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE, val launcherFullscreen: Boolean = true, val downloadSource: String = DownloadSource.MODE_AUTO, val downloadMirror: String = "", val downloadPrefix: String = "", + val downloadProxy: String = "", val animationsEnabled: Boolean = true, val launcherIcon: String = com.droiddeck.launcher.LauncherIcon.DARK, val gameStoresEnabled: Boolean = true, val gameStoresSpeedTier: String = "fast", - val storesOpenTab: String = SessionPrefs.STORES_OPEN_LIBRARY, val navigate: String? = null, val addedGamesArt: Boolean = true, val sgdbUserKey: Boolean = false, @@ -176,6 +181,7 @@ class FrontEndActions( val onEmulator: (Library.Emulator) -> Unit, val onFlatpakApp: (String, String) -> Unit = { _, _ -> }, val onUserApp: (com.droiddeck.launcher.runtime.UserApps.App) -> Unit = {}, + val onBattleNet: () -> Unit = {}, val onRom: (Library.Rom) -> Unit, val onResume: () -> Unit, val onSteamSettings: () -> Unit, @@ -200,6 +206,9 @@ class FrontEndActions( val onLogs: () -> Unit, val onVacDiagnostics: () -> Unit = {}, val onAgentCommands: () -> Unit = {}, + val onClearCachedApps: (Boolean) -> Unit = {}, + val onMemoryHud: (String) -> Unit = {}, + val onBackgroundAppLimit: (Int) -> Unit = {}, val onShareLogs: () -> Unit = {}, val onClearLogs: () -> Unit = {}, val onResetSettings: () -> Unit = {}, @@ -215,9 +224,9 @@ class FrontEndActions( val onDownloadSource: (String) -> Unit = {}, val onDownloadMirror: (String) -> Unit = {}, val onDownloadPrefix: (String) -> Unit = {}, + val onDownloadProxy: (String) -> Unit = {}, val onGameStoresEnabled: (Boolean) -> Unit = {}, val onGameStoresSpeedTier: (String) -> Unit = {}, - val onStoresOpenTab: (String) -> Unit = {}, val onNavigated: () -> Unit = {}, val onStoresShowMature: (Boolean) -> Unit = {}, val onStoresSort: (String) -> Unit = {}, @@ -256,6 +265,12 @@ internal object Motion { if (scale == 0f) snap() else spring(damping, stiffness) } +internal object Ambient { + var poked by mutableStateOf(0L) + private set + fun poke() { poked = android.os.SystemClock.uptimeMillis() } +} + private val Shape10 = RoundedCornerShape(10.dp) internal val Shape12 = RoundedCornerShape(12.dp) @@ -395,6 +410,9 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com } a.onNavigated() } + LaunchedEffect(com.droiddeck.launcher.stores.StoresState.openGameRequest) { + if (com.droiddeck.launcher.stores.StoresState.openGameRequest != null) selected = "stores" + } var showWirelessAdbFix by rememberSaveable { mutableStateOf(false) } var showDeveloperDisplayChoice by rememberSaveable { mutableStateOf(false) } var wirelessAdbDesiredEnabled by rememberSaveable { mutableStateOf(false) } @@ -437,7 +455,7 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com } LaunchedEffect(s.isHomeApp) { if (!s.isHomeApp && selected == "android-apps") selected = "steam" } LaunchedEffect(s.steamGames.isEmpty()) { - if (s.steamGames.isEmpty() && selected.startsWith("app:")) selected = "games" + if (s.steamGames.isEmpty() && selected.startsWith("app:") && GameRef.parse(selected).appId != null) selected = "games" } remember { Motion.refresh(ctx); true } @@ -550,6 +568,9 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com } FlightsLayer(screenAt) + GamesDialogs(a, s.steamGames) + com.droiddeck.launcher.stores.StoresState.pendingInstall?.let { item -> StoreTheme(item.store) { InstallWhere(item) } } + com.droiddeck.launcher.stores.StoresState.itchPicking?.let { item -> StoreTheme(item.store) { ItchBuildPicker(item) { com.droiddeck.launcher.stores.StoresState.itchPicking = null } } } StepsLayer(screenAt) appToChooseDisplay?.let { app -> diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt index b5bbaa2e9..1bf99f069 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt @@ -21,6 +21,7 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row @@ -174,6 +175,7 @@ internal fun SetupPanel( var showLimitDetails by rememberSaveable { mutableStateOf(false) } var editSgdbKey by remember { mutableStateOf(false) } var editDownloadPrefix by remember { mutableStateOf(false) } + var editDownloadProxy by remember { mutableStateOf(false) } if (editSgdbKey) SgdbKeyDialog(s.sgdbUserKey, onSave = { a.onSgdbKey(it); editSgdbKey = false }, onDismiss = { editSgdbKey = false }) if (editDownloadPrefix) TextEntryDialog( @@ -181,6 +183,11 @@ internal fun SetupPanel( onDismiss = { editDownloadPrefix = false }, onSave = { a.onDownloadPrefix(it); editDownloadPrefix = false }, ) + if (editDownloadProxy) TextEntryDialog( + stringResource(R.string.setup_download_proxy), s.downloadProxy, + onDismiss = { editDownloadProxy = false }, + onSave = { a.onDownloadProxy(it); editDownloadProxy = false }, + ) val tabs = listOf(stringResource(R.string.setup_tab_overview), stringResource(R.string.setup_tab_controller), stringResource(R.string.setup_tab_launcher), stringResource(R.string.setup_tab_storage), stringResource(R.string.setup_tab_diagnostics), stringResource(R.string.setup_tab_glasses)) var tab by rememberSaveable { mutableStateOf(if (SetupTabs.openLauncher) LAUNCHER_TAB else 0) } SetupTabs.openLauncher = false @@ -200,7 +207,6 @@ internal fun SetupPanel( runCatching { chipFocus[tab].requestFocus() } } } - val wide = !LocalNarrowPane.current val runtimeOk = s.ready && !s.busy && !s.removalPending val checks = listOf(gpuOk, runtimeOk, !limitBlocks, signedIn) val passing = checks.count { it } @@ -359,6 +365,7 @@ internal fun SetupPanel( } } } + if (s.theme == Themes.INNER_GAMER) RgbSpeedRow() if (s.theme == Themes.GRADIENT) GradientColorRows() ChoiceRow( host, "app-scale", stringResource(R.string.setup_app_scale), stringResource(R.string.setup_app_scale_hint), @@ -438,12 +445,20 @@ internal fun SetupPanel( onClick = { editDownloadPrefix = true }, ) } + ActionRow( + stringResource(R.string.setup_download_proxy), + s.downloadProxy.takeIf { it.isNotEmpty() }, + stringResource(R.string.mode_sgdb_edit), + onClick = { editDownloadProxy = true }, + ) } } - if (wide) Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.Top) { - Column(Modifier.weight(1f)) { look() } - Column(Modifier.weight(1f)) { device(); art(); download() } - } else { look(); device(); art(); download() } + BoxWithConstraints(Modifier.fillMaxWidth()) { + if (maxWidth >= 840.dp && !LocalNarrowPane.current) Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.Top) { + Column(Modifier.weight(1f)) { look() } + Column(Modifier.weight(1f)) { device(); art(); download() } + } else Column { look(); device(); art(); download() } + } } STORAGE_TAB -> StoragePanel(s, a) 4 -> { @@ -457,6 +472,38 @@ internal fun SetupPanel( ActionRow(stringResource(R.string.setup_reset_settings), stringResource(R.string.setup_reset_settings_hint), stringResource(R.string.setup_reset_settings_button), a.onResetSettings) } + SettingsGroup(stringResource(R.string.setup_group_memory)) { + ToggleRow( + host, "clear-cached-apps", stringResource(R.string.setup_clear_cached), + stringResource(if (s.adbPaired) R.string.setup_clear_cached_hint else R.string.setup_clear_cached_unpaired), + s.clearCachedApps, enabled = s.adbPaired || s.clearCachedApps, + ) { a.onClearCachedApps(it) } + ChoiceRow( + host, "background-app-limit", stringResource(R.string.setup_bg_limit), + stringResource(if (s.adbPaired) R.string.setup_bg_limit_hint else R.string.setup_clear_cached_unpaired), + listOf( + com.droiddeck.launcher.core.BackgroundAppLimit.OFF to stringResource(R.string.game_env_off), + 4 to stringResource(R.string.setup_bg_limit_4), + 1 to stringResource(R.string.setup_bg_limit_1), + 0 to stringResource(R.string.setup_bg_limit_0), + ), + s.backgroundAppLimit, enabled = s.adbPaired || s.backgroundAppLimit != com.droiddeck.launcher.core.BackgroundAppLimit.OFF, + onPick = a.onBackgroundAppLimit, + ) + ChoiceRow( + host, "memory-hud", stringResource(R.string.setup_memory_hud), stringResource(R.string.setup_memory_hud_hint), + listOf( + com.droiddeck.launcher.session.SessionPrefs.MEMORY_HUD_OFF to stringResource(R.string.game_env_off), + com.droiddeck.launcher.session.SessionPrefs.MEMORY_HUD_SIMPLE to stringResource(R.string.setup_memory_hud_simple), + com.droiddeck.launcher.session.SessionPrefs.MEMORY_HUD_ADVANCED to stringResource(R.string.setup_memory_hud_advanced), + ), + s.memoryHud, onPick = a.onMemoryHud, + ) + if (!s.adbPaired) ActionRow( + stringResource(R.string.setup_adb_pair), stringResource(R.string.setup_adb_pair_hint), + stringResource(R.string.setup_adb_pair_button), { onRequestWirelessAdb(false) }, + ) + } } GLASSES_TAB -> GlassesSetupPanel() } @@ -507,7 +554,7 @@ private fun FrameGenMenu(s: FrontEndState, a: FrontEndActions, host: MenuHost) { } @Composable -internal fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost, game: com.droiddeck.launcher.frontend.Library.SteamGame? = null) { +internal fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost, game: com.droiddeck.launcher.frontend.Library.SteamGame? = null, hopRail: String = "games") { val columns = if (LocalNarrowPane.current) 2 else 3 val controller = a.controller val context = androidx.compose.ui.platform.LocalContext.current @@ -526,7 +573,7 @@ internal fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost val at = Hops.sources["card:components"] val open = { if (game == null) a.onComponents(true) else a.onGameComponents(game) } if (at == null) open() - else Hops.go(HopOrigin("games", HOP_SURFACE_GAMES, null, "card:components", at, back, dest = "components", page = "components"), open) + else Hops.go(HopOrigin(hopRail, HOP_SURFACE_GAMES, null, "card:components", at, back, dest = "components", page = "components"), open) } } add { m -> @@ -569,18 +616,11 @@ internal fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost } } -@Composable -private fun GameSettingsCard(host: MenuHost, game: com.droiddeck.launcher.frontend.Library.SteamGame, scope: String) { - val appContext = androidx.compose.ui.platform.LocalContext.current.applicationContext - val colors = MaterialTheme.colorScheme - var config by remember(scope) { mutableStateOf(runCatching { com.droiddeck.launcher.session.GameEnvironmentStore.read(appContext) }.getOrNull()) } - var saving by remember(scope) { mutableStateOf(false) } - val current = config - val noSteam = current != null && com.droiddeck.launcher.core.GameEnvironment.gameNoSteam(current, scope) - val fex = current?.let { runCatching { com.droiddeck.launcher.core.GameEnvironment.gameFexPreset(it, scope) }.getOrNull() } - val sync = current?.let { runCatching { com.droiddeck.launcher.core.GameEnvironment.gameSync(it, scope) }.getOrNull() } - val ready = current != null && !saving - val launcher = game.library == com.droiddeck.launcher.frontend.Library.EPIC_LAUNCHER +internal class GameSettingsState(private val appContext: android.content.Context, val scope: String) { + var config by mutableStateOf(runCatching { com.droiddeck.launcher.session.GameEnvironmentStore.read(appContext) }.getOrNull()) + var saving by mutableStateOf(false) + val noSteam: Boolean get() = config?.let { com.droiddeck.launcher.core.GameEnvironment.gameNoSteam(it, scope) } == true + fun save(change: (android.content.Context) -> com.droiddeck.launcher.core.GameEnvironment.Config?) { if (saving) return saving = true @@ -594,7 +634,37 @@ private fun GameSettingsCard(host: MenuHost, game: com.droiddeck.launcher.fronte } }, "game-settings").start() } - val key = "game-settings" +} + +@Composable +internal fun rememberGameSettings(scope: String): GameSettingsState { + val appContext = androidx.compose.ui.platform.LocalContext.current.applicationContext + return remember(scope) { GameSettingsState(appContext, scope) } +} + +@Composable +private fun GameSettingsCard(host: MenuHost, game: com.droiddeck.launcher.frontend.Library.SteamGame, scope: String) { + val state = rememberGameSettings(scope) + SettingCard(stringResource(R.string.game_settings_card), stringResource(if (state.noSteam) R.string.game_steam_without else R.string.game_steam_with), "card:$GAME_SETTINGS_MENU", Modifier.fillMaxSize()) { + host.open = if (host.open == GAME_SETTINGS_MENU) null else GAME_SETTINGS_MENU + } + GameSettingsMenus(host, game, state) +} + +/** The per-game settings menus, opened by setting [MenuHost.open] to [GAME_SETTINGS_MENU]. */ +@Composable +internal fun GameSettingsMenus(host: MenuHost, game: com.droiddeck.launcher.frontend.Library.SteamGame, state: GameSettingsState) { + val appContext = androidx.compose.ui.platform.LocalContext.current.applicationContext + val colors = MaterialTheme.colorScheme + val scope = state.scope + val current = state.config + val noSteam = state.noSteam + val fex = current?.let { runCatching { com.droiddeck.launcher.core.GameEnvironment.gameFexPreset(it, scope) }.getOrNull() } + val sync = current?.let { runCatching { com.droiddeck.launcher.core.GameEnvironment.gameSync(it, scope) }.getOrNull() } + val ready = current != null && !state.saving + val launcher = game.library == com.droiddeck.launcher.frontend.Library.EPIC_LAUNCHER + fun save(change: (android.content.Context) -> com.droiddeck.launcher.core.GameEnvironment.Config?) = state.save(change) + val key = GAME_SETTINGS_MENU val fexKey = "game-settings-fex" val syncKey = "game-settings-sync" val syncLabels = mapOf( @@ -608,9 +678,6 @@ private fun GameSettingsCard(host: MenuHost, game: com.droiddeck.launcher.fronte val custom = stringResource(R.string.comp_game_fex_custom) val customSync = stringResource(R.string.game_sync_custom) val resetDone = stringResource(R.string.comp_game_reset_done, game.name) - SettingCard(stringResource(R.string.game_settings_card), stringResource(if (noSteam) R.string.game_steam_without else R.string.game_steam_with), "card:$key", Modifier.fillMaxSize()) { - host.open = if (host.open == key) null else key - } AnchoredMenu(host.open == key, onDismiss = { if (host.open == key) host.open = null }, title = stringResource(R.string.game_settings_menu_title, game.name)) { first -> MenuItem(stringResource(R.string.game_no_steam_title), checked = noSteam, enabled = ready, detail = stringResource(R.string.game_no_steam_detail), focusRequester = first) { @@ -685,6 +752,8 @@ private fun GameSettingsCard(host: MenuHost, game: com.droiddeck.launcher.fronte } } +internal const val GAME_SETTINGS_MENU = "game-settings" + private const val EPIC_RESOLVE_URL = "https://www.epicgames.com/id/login?redirectUrl=https%3A%2F%2Fwww.epicgames.com%2Faccount%2Fpersonal" @Composable @@ -792,6 +861,13 @@ private fun LanguageRow(host: MenuHost, chosen: String, onPick: (String) -> Unit } } +/** How fast Inner Gamer cycles its colours. */ +@Composable +private fun RgbSpeedRow() { + val ctx = androidx.compose.ui.platform.LocalContext.current.applicationContext + SliderRow(stringResource(R.string.theme_rgb_speed), stringResource(R.string.theme_rgb_speed_hint), Themes.rgbSpeed, 1..Themes.RGB_SPEEDS) { Themes.setRgbSpeed(ctx, it) } +} + /** The two colours of the Gradient theme, each a small square that opens the colour picker. */ @Composable private fun GradientColorRows() { diff --git a/app/src/main/java/com/droiddeck/launcher/ui/GameDetail.kt b/app/src/main/java/com/droiddeck/launcher/ui/GameDetail.kt new file mode 100644 index 000000000..7c9cdc74c --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/GameDetail.kt @@ -0,0 +1,845 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.gestures.animateScrollBy +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.compose.AsyncImage +import com.droiddeck.launcher.R +import com.droiddeck.launcher.frontend.GameRemoval +import com.droiddeck.launcher.frontend.Library +import com.droiddeck.launcher.session.GameSaves +import com.droiddeck.launcher.stores.CatalogItem +import com.droiddeck.launcher.stores.CloudSaves +import com.droiddeck.launcher.stores.InstalledStoreGame +import com.droiddeck.launcher.stores.Lookup +import com.droiddeck.launcher.stores.Store +import com.droiddeck.launcher.stores.StoreAchievements +import com.droiddeck.launcher.stores.StoreGameSidecar +import com.droiddeck.launcher.stores.StoreInstallRoot +import com.droiddeck.launcher.stores.StoreLanguage +import com.droiddeck.launcher.stores.StoreSizes +import com.droiddeck.launcher.stores.StoresState +import com.droiddeck.launcher.stores.download.DownloadQueue +import com.droiddeck.launcher.stores.download.DownloadState +import com.droiddeck.launcher.stores.formatBytes +import com.droiddeck.launcher.stores.xbox.XboxBackend +import com.droiddeck.launcher.stores.xbox.XboxCloudGames +import com.droiddeck.launcher.stores.xbox.XboxContent +import com.droiddeck.launcher.stores.xbox.xboxCloud +import com.droiddeck.launcher.stores.xbox.xboxPc +import com.droiddeck.launcher.stores.xbox.XboxRuntime +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import androidx.compose.runtime.setValue +import androidx.compose.runtime.getValue + +/** What a game page shows: `app:<appId>` for a game in the library, `[app:]<store>:<id>` for a store product. */ +internal class GameRef(val appId: Int?, val store: Store?, val storeId: String?) { + fun game(games: List<Library.SteamGame>): Library.SteamGame? = when { + appId != null -> games.firstOrNull { it.appId == appId } + store != null && storeId != null -> games.firstOrNull { it.source == store.id && it.storeId == storeId } + else -> null + } + + companion object { + fun parse(key: String): GameRef { + val rest = key.removePrefix("app:") + rest.toIntOrNull()?.let { return GameRef(it, null, null) } + val store = Store.byId(rest.substringBefore(':')) + return GameRef(null, store, rest.substringAfter(':', "").takeIf { store != null && it.isNotEmpty() }) + } + + fun key(game: Library.SteamGame) = "app:${game.appId}" + fun key(item: CatalogItem) = "app:${item.key}" + } +} + +internal enum class GameTab { ABOUT, ACHIEVEMENTS, LAUNCH, SAVES, INSTALL } + +@Composable +internal fun GameDetail( + key: String, s: FrontEndState, a: FrontEndActions, lead: FocusRequester, backLabel: String, hopRail: String?, onBack: () -> Unit, +) { + val narrow = LocalNarrowPane.current + val stacked = narrow || LocalConfiguration.current.screenWidthDp < 700 + val ref = remember(key) { GameRef.parse(key) } + val games = s.steamGames + rememberXboxCloudRows(s) + val found = ref.game(games) + val known = remember(key) { arrayOfNulls<Pair<Store, String>>(1) } + val store = ref.store ?: Store.byId(found?.source) ?: known[0]?.first + val id = ref.storeId ?: found?.storeId ?: known[0]?.second + SideEffect { if (store != null && id != null) known[0] = store to id } + val game = found ?: if (store != null && id != null) s.steamGames.firstOrNull { it.source == store.id && it.storeId == id } else null + val item = if (store != null && id != null) StoresState.detailItem(store, id) else null + val installed = if (store != null && id != null) StoresState.installedGame(store, id) else null + val rawDownload = if (store != null && id != null) StoresState.download("${store.id}:$id") else null + val download = rawDownload?.takeIf { it.isActive } + val failed = rawDownload?.takeIf { it.state == DownloadState.FAILED } + val nothing = game == null && store == null + LaunchedEffect(nothing) { if (nothing) onBack() } + if (nothing) return + val source = game?.source ?: store?.id ?: Library.SOURCE_STEAM + val title = game?.name ?: item?.title ?: installed?.sidecar?.title ?: id.orEmpty() + val scores = if (source != Library.ADDED) rememberGameScores(title.takeIf { it.isNotBlank() && it != id }) else null + val achievements = remember(store, id, item?.accessible, installed) { + when { + store == null || StoreAchievements.forSource(store.id) == null -> null + item?.accessible == true -> StoreAchievements.Game.of(item) + installed != null -> StoreAchievements.Game.of(installed.sidecar) + else -> null + } + } + val achievementState = rememberAchievements(achievements) + val listed = if (store == null) null else (StoresState.found[store].orEmpty() + StoresState.shelves[store]?.all.orEmpty()).firstOrNull { it.id == id } + val developer = item?.developer?.ifBlank { null } ?: listed?.developer.orEmpty() + val publisher = item?.publisher?.ifBlank { null } ?: listed?.publisher.orEmpty() + val releaseDate = item?.releaseDate?.ifBlank { null } ?: listed?.releaseDate.orEmpty() + val description = item?.let { com.droiddeck.launcher.stores.cleanStoreText(it.description) }.orEmpty() + .takeUnless { it.trim().equals(title.trim(), ignoreCase = true) }.orEmpty() + val scored = scores != null && (scores.metacritic != Lookup.None || scores.hltb != Lookup.None) + val prefix = game?.protonPrefix?.takeIf { it.isDirectory } + val tabs = buildList { + if (description.isNotBlank() || developer.isNotBlank() || publisher.isNotBlank() || releaseDate.isNotBlank() || (scored && stacked)) add(GameTab.ABOUT) + if (achievementState != null) add(GameTab.ACHIEVEMENTS) + val cloud = game?.library == Library.XBOX_CLOUD + if (game != null && !cloud) add(GameTab.LAUNCH) + if (game != null && game.library == Library.ADDED && prefix != null) add(GameTab.SAVES) + if ((game != null && !cloud) || installed != null) add(GameTab.INSTALL) + } + var picked by remember(key) { mutableStateOf<GameTab?>(null) } + val tab = picked?.takeIf { it in tabs } ?: tabs.firstOrNull() + val tabFocus = remember(key) { GameTab.entries.associateWith { FocusRequester() } } + var inPanel by remember(key) { mutableStateOf(false) } + var inPage by remember(key) { mutableStateOf(false) } + var onStrip by remember(key) { mutableStateOf(false) } + var dropped by remember(key) { mutableStateOf(0) } + // A tab's content is swapped or refreshed under the focused row; focus goes back to the tabs, not to the rail. + LaunchedEffect(dropped) { + if (dropped == 0) return@LaunchedEffect + androidx.compose.runtime.withFrameNanos { } + val t = tab ?: return@LaunchedEffect + if (!inPage) focusWithinFrames({ onStrip }) { tabFocus.getValue(t) } + } + fun cycle(step: Int) { + if (tabs.size < 2) return + val at = tabs.indexOf(tab).coerceAtLeast(0) + picked = tabs[(at + step + tabs.size) % tabs.size] + } + Column(Modifier.fillMaxWidth().onFocusChanged { inPage = it.hasFocus }.bumpers(onPrevious = { cycle(-1) }, onNext = { cycle(1) })) { + Rise(0) { + Box(Modifier.padding(bottom = 12.dp)) { BackLink(backLabel, compact = true, onClick = onBack) } + } + val hero: @Composable () -> Unit = { + DisposableEffect(Unit) { onDispose { StoresMotion.heroAt = null } } + GameHero( + title, item, installed, game, + Modifier.fillMaxWidth().heightIn(min = if (narrow) 170.dp else 210.dp).onGloballyPositioned { StoresMotion.heroAt = it.boundsInRoot() }, + corner = { if (!s.shortcutPicker) RemoveButton(title, game, installed, a) }, + scores = scores.takeIf { !stacked }, + ) { + HeroEyebrow(source, game, item, installed, download?.location ?: failed?.location) + GameTitle(title, game?.logo ?: item?.logoUrl, if (narrow) 26.sp else 34.sp) + if (!failed?.error.isNullOrBlank()) Text( + failed!!.error!!, fontSize = 13.sp, lineHeight = 17.sp, fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.error, maxLines = 3, overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.height(4.dp)) + GameActions(store, id, game, item, installed, s, a, Modifier.focusRequester(lead)) + } + } + if (StoresMotion.heroLands) hero() else Rise(1) { hero() } + if (tab != null) Rise(2) { + Column(Modifier.fillMaxWidth().padding(top = 14.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + val labels = tabs.map { t -> + when (t) { + GameTab.ABOUT -> stringResource(R.string.game_tab_about) + GameTab.ACHIEVEMENTS -> achievementState?.list?.takeIf { it.isNotEmpty() }?.let { list -> + stringResource(R.string.game_tab_achievements_count, stringResource(R.string.achievements_title), achievementState.done, list.size) + } ?: stringResource(R.string.achievements_title) + GameTab.LAUNCH -> stringResource(R.string.game_tab_launch) + GameTab.SAVES -> stringResource(if (Store.byId(source).let { it == Store.GOG || it == Store.EPIC }) R.string.game_tab_saves_cloud else R.string.games_saves) + GameTab.INSTALL -> stringResource(R.string.game_tab_install) + } + } + TabStrip( + labels, tabs.indexOf(tab), { picked = tabs[it] }, Modifier.onFocusChanged { onStrip = it.hasFocus }, + focusRequesters = tabs.map { tabFocus.getValue(it) }, bumpers = tabs.size > 1, compact = narrow, + ) + Box(Modifier.fillMaxWidth().onFocusChanged { if (inPanel && !it.hasFocus) dropped++; inPanel = it.hasFocus }.testTag("game-tab-${tab.name.lowercase()}")) { + when (tab) { + GameTab.ABOUT -> AboutTab(developer, publisher, releaseDate, description, scores, stacked) + GameTab.ACHIEVEMENTS -> achievementState?.let { st -> AchievementsTab(StoreAchievements.forSource(store?.id)?.label.orEmpty(), st) } + GameTab.LAUNCH -> if (game != null) LaunchTab(game, s, a, hopRail) + GameTab.SAVES -> if (game != null && prefix != null) SavesTab(game, prefix, a) + GameTab.INSTALL -> InstallTab(game, installed, item, store) + } + } + } + } + } +} + +@Composable +private fun GameHero( + title: String, item: CatalogItem?, installed: InstalledStoreGame?, game: Library.SteamGame?, modifier: Modifier, + corner: @Composable BoxScope.() -> Unit, scores: GameScores?, content: @Composable ColumnScope.() -> Unit, +) { + val colors = MaterialTheme.colorScheme + Box(modifier.clip(Shape16).background(artBrush(hueOf(title)))) { + val wide: Any? = game?.hero ?: item?.imageUrl ?: installed?.sidecar?.hero ?: installed?.sidecar?.cover + if (wide != null) { + // The card it came from keeps its own bitmap under the hero until the wide art is decoded. + val request = if (wide is String) cardArtRequest(wide, item?.tallImageUrl) else wide + AsyncImage(model = request, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize()) + } else game?.art?.let { art -> + AsyncImage( + model = art, contentDescription = null, contentScale = ContentScale.Crop, + modifier = Modifier.matchParentSize().blur(24.dp).graphicsLayer { scaleX = 1.3f; scaleY = 1.3f }, + ) + } + Spacer(Modifier.matchParentSize().background(Brush.horizontalGradient(0f to colors.background.copy(alpha = 0.94f), 0.55f to colors.background.copy(alpha = 0.58f), 1f to Color.Transparent))) + if (scores != null) { + Spacer(Modifier.matchParentSize().background(Brush.horizontalGradient(0.45f to Color.Transparent, 1f to colors.background.copy(alpha = 0.75f)))) + HeroScores(scores, Modifier.align(Alignment.BottomEnd).padding(end = 16.dp, bottom = 18.dp)) + } + Column( + verticalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.align(Alignment.BottomStart).padding(horizontal = 22.dp, vertical = 18.dp), + content = content, + ) + Box(Modifier.align(Alignment.TopEnd).padding(12.dp), content = corner) + } +} + +@Composable +private fun HeroEyebrow(source: String, game: Library.SteamGame?, item: CatalogItem?, installed: InstalledStoreGame?, downloadAt: String?) { + val ctx = LocalContext.current + val pal = LocalPalette.current + val folder = installed?.folder ?: game?.gameFiles + val passName = item?.includedWith.orEmpty() + val state = when { + installed != null -> listOf(stringResource(R.string.stores_eyebrow_installed), StoreInstallRoot.labelFor(ctx, installed.folder)) + game?.library == Library.XBOX_CLOUD -> listOf(libraryLabel(game)) + game != null -> listOf(stringResource(R.string.stores_eyebrow_installed), libraryLabel(game)) + !downloadAt.isNullOrBlank() -> listOf(stringResource(R.string.stores_eyebrow_library), downloadAt) + item?.owned == true -> listOf(stringResource(R.string.stores_eyebrow_library)) + item?.subscription == true -> listOf(passName.ifBlank { stringResource(R.string.stores_game_pass) }) + passName.isNotBlank() -> listOf(stringResource(R.string.stores_eyebrow_pass, passName)) + item?.accessible == true -> listOf(stringResource(R.string.stores_eyebrow_library)) + else -> listOf(stringResource(R.string.stores_eyebrow_store)) + } + val size = if (folder != null && (installed != null || game != null)) diskSize(installed, folder) else (item?.sizeBytes ?: 0L).takeIf { it > 0 } + val line = (state + listOfNotNull(size?.let { formatBytes(it) })).filter { it.isNotBlank() }.joinToString(" · ") + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + SourceChip(source) + Text(line.uppercase(), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.2.sp, color = pal.signal, maxLines = 1, overflow = TextOverflow.Ellipsis) + } +} + +@Composable +private fun GameTitle(title: String, logo: Any?, titleSize: TextUnit) { + var failed by remember(logo) { mutableStateOf(false) } + if (logo != null && !failed) AsyncImage( + model = logo, contentDescription = title, contentScale = ContentScale.Fit, alignment = Alignment.CenterStart, + onError = { failed = true }, + modifier = Modifier.padding(vertical = 4.dp).height((titleSize.value * 2.2f).dp).widthIn(max = (titleSize.value * 9f).dp).testTag("game-logo"), + ) else Text( + title, fontSize = titleSize, lineHeight = titleSize * 1.12f, fontWeight = FontWeight.Black, + color = MaterialTheme.colorScheme.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis, + ) +} + +@Composable +private fun GameActions( + store: Store?, id: String?, game: Library.SteamGame?, item: CatalogItem?, installed: InstalledStoreGame?, + s: FrontEndState, a: FrontEndActions, leadOn: Modifier, +) { + val ctx = LocalContext.current + val storeKey = if (store != null && id != null) "${store.id}:$id" else null + val rawDownload = storeKey?.let { StoresState.download(it) } + val download = rawDownload?.takeIf { it.isActive } + val failed = rawDownload?.takeIf { it.state == DownloadState.FAILED } + Actions { + when { + s.shortcutPicker && game != null -> { + PrimaryButton(stringResource(R.string.game_shortcut_choose), enabled = !s.busy, main = true, icon = Icons.Filled.PlayArrow, modifier = leadOn) { a.onSteamGame(game) } + FileButtons(game, a) + } + game?.library == Library.XBOX_CLOUD -> { + val cloudId = game.storeId.orEmpty() + XboxStreamButton(cloudId, game.name, main = true, modifier = leadOn) + if (download != null) ActionChip(downloadLabel(download), ok = false) + else if (item?.installable == true) SecondaryButton( + if (item.sizeBytes > 0) stringResource(R.string.stores_install_size, formatBytes(item.sizeBytes)) else stringResource(R.string.stores_install), + ) { StoresState.requestInstall(ctx, item) } + XboxLibraryButton(cloudId, item) + } + game != null || installed != null -> { + val gone = storeKey?.let { StoresState.uninstalling[it] } + val xboxBusy = store == Store.XBOX && id != null && XboxContent.busy(id) + PrimaryButton( + stringResource(R.string.stores_play), enabled = !s.busy && gone == null && download == null && !xboxBusy, + main = true, icon = Icons.Filled.PlayArrow, modifier = leadOn, + ) { + if (game != null) a.onSteamGame(game) else if (store != null && id != null) launchStoreGame(ctx, store, id, s, a) + } + if ((game?.source ?: store?.id) != Library.SOURCE_STEAM) ActionChip(stringResource(R.string.stores_in_steam), ok = true) + if (game != null) { + FileButtons(game, a) + GameShortcutMenu(game, a) + EditArtButton(game) + } + if (store == Store.XBOX && id != null) XboxInstalledStream(id, game?.name ?: item?.title.orEmpty(), item) + if (store == Store.XBOX && installed != null && id != null) { + SecondaryButton(stringResource(R.string.stores_xbox_update), enabled = download == null && !XboxRuntime.isActive(id)) { + runCatching { XboxBackend.update(ctx, installed) } + .onFailure { StoresState.problems[Store.XBOX] = it.message ?: ctx.getString(R.string.stores_xbox_close_first) } + } + SecondaryButton(stringResource(R.string.stores_xbox_manage), enabled = download == null) { XboxContent.manage(ctx, installed) } + } + if (store == Store.BATTLENET) SecondaryButton(stringResource(R.string.bnet_open)) { a.onBattleNet() } + } + download != null -> { + ActionChip(downloadLabel(download), ok = false) + if (download.state != DownloadState.PAUSED) SecondaryButton(stringResource(R.string.stores_dl_pause), modifier = leadOn) { DownloadQueue.pause(download.key) } + else PrimaryButton(stringResource(R.string.stores_dl_resume), main = true, modifier = leadOn) { DownloadQueue.resume(ctx, download.key) } + ConfirmButton(stringResource(R.string.stores_dl_cancel), stringResource(R.string.stores_dl_cancel_confirm)) { DownloadQueue.cancel(ctx, download.key) } + } + failed != null -> { + ActionChip(stringResource(R.string.stores_dl_failed), ok = false) + PrimaryButton(stringResource(R.string.stores_dl_resume), main = true, modifier = leadOn) { + if (item != null) StoresState.requestInstall(ctx, item) else DownloadQueue.retry(ctx, failed.key) + } + ConfirmButton(stringResource(R.string.stores_dl_clear), stringResource(R.string.stores_dl_cancel_confirm), compact = true) { DownloadQueue.clear(ctx, failed.key) } + } + store == Store.BATTLENET && item != null -> InstallButton(stringResource(R.string.stores_install), leadOn) { StoresState.requestInstall(ctx, item) } + store == Store.XBOX && item != null && !item.xboxPc && item.xboxCloud -> { + XboxStreamButton(item.id, item.title, main = true, modifier = leadOn) + XboxLibraryButton(item.id, item) + } + item?.installable == true && StoresState.isUnfinished(item) -> { + InstallButton(stringResource(R.string.stores_resume_install), leadOn) { StoresState.requestInstall(ctx, item) } + ConfirmButton(stringResource(R.string.stores_dl_clear), stringResource(R.string.stores_dl_cancel_confirm), compact = true) { StoresState.clearUnfinished(ctx, item) } + } + item?.installable == true -> { + InstallButton( + if (item.sizeBytes > 0) stringResource(R.string.stores_install_size, formatBytes(item.sizeBytes)) else stringResource(R.string.stores_install), + leadOn, + ) { StoresState.requestInstall(ctx, item) } + if (store == Store.XBOX && item.xboxCloud) { + XboxStreamButton(item.id, item.title) + XboxLibraryButton(item.id, item) + } + if (!item.accessible) SecondaryButton(buyLabel(item)) { openStoreUrl(ctx, item) } + } + item != null && item.store == Store.ITCH && item.isFree -> { + InstallButton(stringResource(R.string.stores_install), leadOn) { StoresState.requestInstall(ctx, item) } + SecondaryButton(stringResource(R.string.stores_view_on, item.store.shortLabel)) { openStoreUrl(ctx, item) } + } + item != null && item.isFree -> PrimaryButton(stringResource(R.string.stores_get_free), main = true, modifier = leadOn) { openStoreUrl(ctx, item) } + item != null -> PrimaryButton(buyLabel(item), main = true, modifier = leadOn) { openStoreUrl(ctx, item) } + } + BusyChip(s) + } +} + +/** Xbox Cloud Gaming rows the user added to the Library, for games not installed here. */ +@Composable +internal fun rememberXboxCloudRows(s: FrontEndState): List<Library.SteamGame> { + val ctx = LocalContext.current + LaunchedEffect(Unit) { XboxCloudGames.load(ctx) } + if (s.shortcutPicker) return emptyList() + val added = XboxCloudGames.entries.toList() + return remember(added, s.steamGames) { + val installedXbox = s.steamGames.filter { it.source == Store.XBOX.id }.mapNotNull { it.storeId }.toSet() + XboxCloudGames.rows(ctx, installedXbox) + } +} + +@Composable +private fun XboxStreamButton(id: String, title: String, main: Boolean = false, modifier: Modifier = Modifier) { + val ctx = LocalContext.current + LaunchedEffect(Unit) { XboxCloudGames.prefetch(ctx) } + val label = stringResource(R.string.game_stream) + if (main) PrimaryButton(label, main = true, icon = Icons.Filled.PlayArrow, modifier = modifier) { XboxCloudGames.stream(ctx, id, title) } + else SecondaryButton(label, modifier = modifier) { XboxCloudGames.stream(ctx, id, title) } +} + +@Composable +private fun XboxInstalledStream(id: String, title: String, item: CatalogItem?) { + val ctx = LocalContext.current + val streams by produceState(item?.xboxCloud == true, id) { + if (!value) value = withContext(Dispatchers.IO) { XboxCloudGames.streams(ctx, id) } + } + if (streams) XboxStreamButton(id, title) +} + +@Composable +private fun XboxLibraryButton(id: String, item: CatalogItem?) { + val ctx = LocalContext.current + LaunchedEffect(Unit) { XboxCloudGames.load(ctx) } + // Add and Remove replace each other; the controller focus moves to the new button instead of getting lost. + val slot = remember { FocusRequester() } + val inputMode = androidx.compose.ui.platform.LocalInputModeManager.current + val scope = rememberCoroutineScope() + fun refocus() { if (inputMode.inputMode == androidx.compose.ui.input.InputMode.Keyboard) scope.launch { focusSoon { slot } } } + if (XboxCloudGames.isAdded(id)) { + ActionChip(stringResource(R.string.library_in), ok = true) + SecondaryButton(stringResource(R.string.library_remove), compact = true, modifier = Modifier.focusRequester(slot)) { XboxCloudGames.remove(ctx, id); refocus() } + } else if (item != null) SecondaryButton(stringResource(R.string.library_add), modifier = Modifier.focusRequester(slot)) { XboxCloudGames.add(ctx, item); refocus() } +} + +@Composable +private fun buyLabel(item: CatalogItem): String = when { + item.isFree -> stringResource(R.string.stores_get_free) + item.hasPrice && item.finalPrice.isNotBlank() -> stringResource(R.string.stores_buy_price, item.finalPrice) + else -> stringResource(R.string.stores_view_on, item.store.shortLabel) +} + +@Composable +private fun FileButtons(game: Library.SteamGame, a: FrontEndActions) { + game.gameFiles?.takeIf { it.isDirectory }?.let { dir -> SecondaryButton(stringResource(R.string.games_files), compact = true) { a.onBrowseFiles(dir) } } + game.protonPrefix?.takeIf { it.isDirectory }?.let { dir -> SecondaryButton(stringResource(R.string.games_prefix), compact = true) { a.onBrowseFiles(dir) } } +} + +@Composable +private fun GameShortcutMenu(g: Library.SteamGame, a: FrontEndActions) { + var open by remember(g.gameId) { mutableStateOf(false) } + Box { + SecondaryButton(stringResource(R.string.game_shortcut), compact = true) { open = !open } + AnchoredMenu(open, onDismiss = { open = false }, title = stringResource(R.string.game_shortcut)) { first -> + MenuItem(stringResource(R.string.game_shortcut_add), checked = false, focusRequester = first) { open = false; a.onGameShortcut(g) } + MenuItem(stringResource(R.string.game_file_export), checked = false) { open = false; a.onExportGameFile(g) } + MenuItem(stringResource(R.string.game_link_copy), checked = false) { open = false; a.onCopyGameLink(g) } + } + } +} + +/** The pencil opens the editor each kind of game always had: the shortcut editor for added games, art for the rest. */ +@Composable +private fun EditArtButton(g: Library.SteamGame) { + val ctx = LocalContext.current + val folder = g.gameFiles + val open: (() -> Unit)? = when { + g.library == Library.ADDED && g.source == Library.ADDED && folder != null -> { + { GamesDialogState.editing = EditRequest(folder.path, openTarget = false, fromSummary = false, preview = EditPreview.of(ctx, g, folder.path)) } + } + g.library != Library.ADDED && g.source == Library.SOURCE_STEAM -> { { GamesDialogState.steamArt = g } } + g.library == Library.ADDED && g.source != Library.ADDED && folder != null -> { { GamesDialogState.storeArt = g } } + else -> null + } + if (open != null) IconAction(Icons.Outlined.Edit, stringResource(R.string.common_edit), "game:edit", onClick = open) +} + +@Composable +private fun IconAction(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, id: String, danger: Boolean = false, enabled: Boolean = true, onClick: () -> Unit) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) && enabled + val accent = if (danger) colors.error else pal.signal + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.paneItem(id).downToFirstTile().size(44.dp).clip(Shape12) + .background(if (hot) accent.copy(alpha = if (danger) 0.22f else 0.14f) else if (danger) Color.Black.copy(alpha = 0.34f) else Color.White.copy(alpha = 0.03f)) + .glideBorder(hot, Shape12, accent, pal.line2) + .alpha(if (enabled) 1f else 0.45f) + .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = androidx.compose.ui.semantics.Role.Button, enabled = enabled, onClick = onClick) + .controllerConfirm(enabled = enabled, onClick = onClick) + .semantics { contentDescription = label }, + ) { + Icon(icon, contentDescription = null, tint = if (danger && hot) colors.error else if (danger) Color.White else colors.onBackground, modifier = Modifier.size(18.dp)) + } +} + +@Composable +private fun RemoveButton(title: String, game: Library.SteamGame?, installed: InstalledStoreGame?, a: FrontEndActions) { + val ctx = LocalContext.current + val colors = MaterialTheme.colorScheme + if ((installed?.sidecar?.store?.id ?: game?.source) == Store.BATTLENET.id) return + if (installed != null) { + val key = installed.key + val gone = StoresState.uninstalling[key] + if (gone != null) { + val uninstalling = stringResource(R.string.stores_uninstalling) + Box(contentAlignment = Alignment.Center, modifier = Modifier.size(44.dp).clip(Shape12).background(Color.Black.copy(alpha = 0.34f)).semantics { contentDescription = uninstalling }) { + if (gone > 0f) CircularProgressIndicator(progress = { gone }, strokeWidth = 2.dp, color = colors.error, trackColor = colors.error.copy(alpha = 0.22f), modifier = Modifier.size(20.dp)) + else CircularProgressIndicator(strokeWidth = 2.dp, color = colors.error, modifier = Modifier.size(20.dp)) + } + return + } + val heading = stringResource(R.string.stores_uninstall_title, title) + val size = StoreSizes.onDisk[key] + val body = if (size != null && size > 0) stringResource(R.string.stores_uninstall_body_size, formatBytes(size)) else stringResource(R.string.stores_uninstall_body) + val keep = stringResource(R.string.stores_notification_keep) + val remove = stringResource(R.string.stores_uninstall) + val busy = StoresState.download(key)?.isActive == true || (installed.sidecar.store == Store.XBOX && XboxContent.busy(installed.sidecar.id)) + val placed = remember { arrayOfNulls<androidx.compose.ui.layout.LayoutCoordinates>(1) } + Box(Modifier.onGloballyPositioned { placed[0] = it }) { + IconAction(Icons.Outlined.Delete, remove, "game:remove", danger = true, enabled = !busy) { + val at = placed[0]?.takeIf { it.isAttached }?.boundsInRoot() ?: return@IconAction + var confirmed = false + Steps.ask(StepAsk(anchor = at, side = StepSide.Left, accent = colors.error, items = 3, pillCorner = 12.dp, maxWidth = 340.dp, + onDismiss = { if (confirmed) StoresState.uninstall(ctx, installed) { StoresState.refresh(ctx); a.onLibraryChanged() } }, + handle = { Icon(Icons.Outlined.Delete, null, tint = colors.error, modifier = Modifier.size(18.dp)) }) { + StepTitle(heading) + Text(body, fontSize = 13.sp, lineHeight = 18.sp, color = colors.onSurfaceVariant, modifier = Modifier.stepItem(1).padding(start = 4.dp, bottom = 12.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), modifier = Modifier.fillMaxWidth().stepItem(2)) { + StepChoice(keep, enabled = open, modifier = Modifier.focusRequester(first)) { Steps.fold() } + StepChoice(remove, danger = true, enabled = open) { confirmed = true; Steps.fold() } + } + }) + } + } + return + } + val g = game ?: return + val kind = GameRemoval.kind(g) + if (kind == GameRemoval.Kind.LAUNCHER || g.gameFiles?.isDirectory != true) return + var asking by remember(g.gameId) { mutableStateOf(false) } + val shortcut = kind == GameRemoval.Kind.SHORTCUT + val label = stringResource(if (shortcut) R.string.edit_remove else R.string.common_uninstall) + IconAction(Icons.Outlined.Delete, label, "game:remove", danger = true) { asking = true } + if (asking) ConfirmDialog( + title = if (shortcut) stringResource(R.string.games_remove_title, g.name) else stringResource(R.string.stores_uninstall_title, g.name), + text = when { + shortcut -> stringResource(R.string.games_remove_body) + g.library == Library.ADDED -> stringResource(R.string.stores_uninstall_body) + else -> stringResource(R.string.games_uninstall_body) + }, + confirm = label, + onConfirm = { a.onGameRemoved(g) }, + onDismiss = { asking = false }, + ) +} + +@Composable +private fun InstallButton(label: String, modifier: Modifier = Modifier, onInstall: () -> Unit) { + val placed = remember { arrayOfNulls<androidx.compose.ui.layout.LayoutCoordinates>(1) } + Box(Modifier.onGloballyPositioned { placed[0] = it }) { + PrimaryButton(label, main = true, modifier = modifier) { + StoresMotion.markInstall(placed[0]?.takeIf { it.isAttached }?.boundsInRoot(), label) + onInstall() + } + } +} + +@Composable +private fun AboutTab(developer: String, publisher: String, releaseDate: String, description: String, scores: GameScores?, stacked: Boolean) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().tabPanel()) { + if (scores != null) Box(Modifier.readable()) { StoreGameFacts(developer, publisher, releaseDate, scores, scoresInHero = !stacked) } + else if (developer.isNotBlank() || publisher.isNotBlank() || releaseDate.isNotBlank()) { + val parts = listOf(developer, publisher, com.droiddeck.launcher.stores.GameFacts.releaseLabel(releaseDate).orEmpty()).filter { it.isNotBlank() }.distinct() + Text(parts.joinToString(" · "), fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.readable()) + } + if (description.isNotBlank()) { + val floor = LocalDetailFloor.current + val density = LocalDensity.current + var room by remember { mutableStateOf(AboutHeight) } + val fill = Modifier.onGloballyPositioned { c -> room = maxOf(AboutHeight, with(density) { (floor() - c.positionInRoot().y).toDp() - 14.dp }) } + StoreAbout(description, room, Modifier.fillMaxWidth().then(fill)) + } + } +} + +private val AboutHeight = 260.dp + +@Composable +internal fun Modifier.tabPanel(): Modifier { + val shape = RoundedCornerShape(16.dp) + return clip(shape).background(MaterialTheme.colorScheme.surface).border(1.dp, LocalPalette.current.line, shape).padding(horizontal = 16.dp, vertical = 14.dp) +} + +@Composable +private fun StoreAbout(text: String, height: Dp, modifier: Modifier = Modifier) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val scroll = rememberScrollState() + val scope = rememberCoroutineScope() + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + val shape = RoundedCornerShape(12.dp) + Box( + modifier.heightIn(max = height).clip(shape) + .glideBorder(hot, shape, pal.signal, restColor = Color.Transparent) + .paneItem("about") + .focusable(interactionSource = src) + .onPreviewKeyEvent { e -> + val step = when (e.nativeKeyEvent.keyCode) { + android.view.KeyEvent.KEYCODE_DPAD_DOWN -> if (scroll.value < scroll.maxValue) 120f else return@onPreviewKeyEvent false + android.view.KeyEvent.KEYCODE_DPAD_UP -> if (scroll.value > 0) -120f else return@onPreviewKeyEvent false + else -> return@onPreviewKeyEvent false + } + if (e.nativeKeyEvent.action == android.view.KeyEvent.ACTION_DOWN) scope.launch { scroll.animateScrollBy(step) } + true + }, + ) { + Text(text, fontSize = 14.sp, lineHeight = 21.sp, color = colors.onSurfaceVariant, modifier = Modifier.verticalScroll(scroll).padding(horizontal = 4.dp, vertical = 6.dp)) + if (scroll.value < scroll.maxValue) Box( + Modifier.align(Alignment.BottomCenter).fillMaxWidth().height(28.dp) + .background(Brush.verticalGradient(listOf(Color.Transparent, colors.surface))), + ) + } +} + +@Composable +private fun LaunchTab(game: Library.SteamGame, s: FrontEndState, a: FrontEndActions, hopRail: String?) { + val host = rememberMenuHost() + Column(verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) { + LaunchSettings(s, a, host, game, hopRail ?: GameRef.key(game)) + val folder = game.gameFiles + if (game.library == Library.ADDED && game.source == Library.ADDED && !s.shortcutPicker && folder != null) ShortcutFields(game, folder) + } +} + +@Composable +private fun ShortcutFields(game: Library.SteamGame, folder: File) { + val ctx = LocalContext.current + val preview = remember(game.gameId, game.name, GamesDialogState.editing) { EditPreview.of(ctx, game, folder.path) } + fun edit(target: Boolean) { GamesDialogState.editing = EditRequest(folder.path, openTarget = target, fromSummary = false, preview = EditPreview.of(ctx, game, folder.path)) } + Column(Modifier.fillMaxWidth().tabPanel(), verticalArrangement = Arrangement.spacedBy(2.dp)) { + SectionLabel(stringResource(R.string.game_launch_shortcut)) + MenuItem(stringResource(R.string.edit_name), checked = false, detail = preview.name) { edit(false) } + MenuItem(stringResource(R.string.edit_target), checked = false, detail = preview.exeName ?: "—") { edit(true) } + MenuItem(stringResource(R.string.edit_start_in), checked = false, detail = preview.startIn ?: "—") { edit(false) } + MenuItem(stringResource(R.string.edit_launch_options), checked = false, detail = preview.launchOptions.ifEmpty { "—" }) { edit(false) } + } +} + +@Composable +private fun SavesTab(g: Library.SteamGame, prefix: File, a: FrontEndActions) { + val saves by produceState<List<GameSaves.SaveDir>?>(null, g.gameId) { + value = withContext(Dispatchers.IO) { runCatching { GameSaves.locate(prefix, g) }.getOrDefault(emptyList()) } + } + val found = saves + val summary = when { + found == null -> stringResource(R.string.games_saves_looking) + found.isEmpty() -> stringResource(R.string.games_saves_none) + else -> { + val mb = found.sumOf { it.bytes } / 1048576.0 + stringResource( + R.string.games_saves_summary, + if (found.size == 1) found[0].relPath else stringResource(R.string.games_saves_folders, found.size), + found.sumOf { it.files }, + if (mb < 1) stringResource(R.string.games_kb, (mb * 1024).toInt()) else stringResource(R.string.common_size_mb, mb), + ) + } + } + Column(Modifier.fillMaxWidth().tabPanel(), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(summary, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(bottom = 6.dp).readable().padding(4.dp)) + if (Store.byId(g.source).let { it == Store.GOG || it == Store.EPIC }) { + SectionLabel(stringResource(R.string.store_cloud_saves)) + CloudRows(g) + SectionLabel(stringResource(R.string.games_saves)) + } + MenuItem(stringResource(R.string.games_import_saves), checked = false) { a.onSaveImport(g) } + MenuItem(stringResource(R.string.games_export_gamehub), checked = false) { a.onSaveExport(g, GameSaves.Layout.GAMEHUB) } + MenuItem(stringResource(R.string.games_export_winlator), checked = false) { a.onSaveExport(g, GameSaves.Layout.WINLATOR) } + found?.firstOrNull()?.let { d -> + MenuItem(stringResource(R.string.games_open_save_folder), checked = false, detail = d.relPath) { + a.onBrowseFiles(File(prefix, "drive_c/users/steamuser/" + d.relPath)) + } + } + } +} + +@Composable +private fun CloudRows(g: Library.SteamGame) { + val store = Store.byId(g.source)?.takeIf { it != Store.AMAZON } ?: return + val id = g.storeId ?: return + val context = LocalContext.current.applicationContext + var busy by remember(g.gameId) { mutableStateOf<String?>(null) } + var tick by remember(g.gameId) { mutableStateOf(0) } + val status by produceState<CloudSaves.Status?>(null, g.gameId, tick) { + value = withContext(Dispatchers.IO) { runCatching { CloudSaves.status(context, store, id) }.getOrNull() } + } + val folder = g.gameFiles + var enabled by remember(g.gameId) { mutableStateOf(folder?.let { StoreGameSidecar.read(it)?.cloud } ?: true) } + val st = status + val supported = st?.supported ?: true + val conflict = st != null && st.conflicts.isNotEmpty() + fun sync(up: Boolean, force: Boolean = false) { + if (busy != null) return + busy = if (up) "up" else "down" + Thread({ + val r = if (up) CloudSaves.upload(context, store, id, force) else CloudSaves.download(context, store, id, force) + StoresState.post { busy = null; tick++; android.widget.Toast.makeText(context, cloudMessage(context, r), android.widget.Toast.LENGTH_SHORT).show() } + }, "cloud-manual").start() + } + MenuItem(stringResource(R.string.store_cloud_saves), checked = enabled && supported, enabled = supported, + detail = if (!supported) stringResource(R.string.cloud_none) else null) { + val f = folder ?: return@MenuItem + val next = !enabled + Thread({ + val written = runCatching { StoreGameSidecar.updateCloud(f, next) }.getOrNull() + StoresState.post { enabled = written?.cloud ?: enabled } + }, "cloud-switch").start() + } + val ready = st != null && supported && enabled && busy == null + val pending = stringResource(R.string.cloud_pending) + val summary = when { + st == null || busy != null -> pending + !supported -> stringResource(R.string.cloud_none) + conflict -> stringResource(R.string.cloud_conflict_files, st.conflicts.size) + st.lastSync > 0 -> stringResource(R.string.cloud_last_sync, android.text.format.DateUtils.getRelativeTimeSpanString(st.lastSync).toString()) + else -> stringResource(R.string.cloud_never) + } + MenuItem(stringResource(if (conflict) R.string.cloud_keep_local else R.string.cloud_upload), checked = false, enabled = ready, detail = summary) { sync(up = true, force = conflict) } + MenuItem(stringResource(if (conflict) R.string.cloud_keep_cloud else R.string.cloud_download), checked = false, enabled = ready, detail = if (st == null || busy != null) pending else null) { sync(up = false, force = conflict) } +} + +@Composable +private fun InstallTab(game: Library.SteamGame?, installed: InstalledStoreGame?, item: CatalogItem?, store: Store?) { + val ctx = LocalContext.current + val folder = installed?.folder ?: game?.gameFiles + val sidecar = installed?.sidecar + val size = folder?.let { diskSize(installed, it) } + val rows = buildList { + add(stringResource(R.string.game_install_location) to when { + installed != null -> StoreInstallRoot.labelFor(ctx, installed.folder) + game != null -> libraryLabel(game).ifBlank { stringResource(R.string.games_internal) } + else -> "" + }) + if (folder != null) { + add(stringResource(R.string.game_install_folder) to folder.path) + add(stringResource(R.string.game_install_size) to (size?.let { formatBytes(it) } ?: stringResource(R.string.cloud_pending))) + } + sidecar?.extra?.get(StoreLanguage.EXTRA)?.takeIf { it.isNotBlank() }?.let { add(stringResource(R.string.stores_install_language) to StoreLanguage.displayName(it)) } + sidecar?.installVersion?.takeIf { it.isNotBlank() }?.let { add(stringResource(R.string.game_install_version) to it) } + sidecar?.installedAt?.takeIf { it > 0 }?.let { + add(stringResource(R.string.game_install_date) to java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM).format(java.util.Date(it))) + } + if (game != null) add(stringResource(R.string.game_install_played) to (playedSpan(game.lastPlayed)?.replaceFirstChar { it.uppercase() } ?: stringResource(R.string.games_never_played))) + if (store == Store.XBOX) { + val access = sidecar?.extra?.get("access") ?: if (item?.subscription == true) "gamepass" else if (item?.owned == true) "owned" else null + if (access != null) add(stringResource(R.string.game_install_access) to stringResource(if (access == "gamepass") R.string.stores_game_pass else R.string.game_install_access_owned)) + } + }.filter { it.second.isNotBlank() } + Column(Modifier.fillMaxWidth().tabPanel(), verticalArrangement = Arrangement.spacedBy(2.dp)) { + for ((label, value) in rows) FactRow(label, value) + } +} + +/** Text a controller has to land on to bring it into view: focusable, with the focus ring. */ +@Composable +internal fun Modifier.readable(): Modifier { + val pal = LocalPalette.current + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + val shape = RoundedCornerShape(8.dp) + return clip(shape).glideBorder(hot, shape, pal.signal, restColor = Color.Transparent).focusable(interactionSource = src) +} + +@Composable +private fun FactRow(label: String, value: String) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.fillMaxWidth().readable().padding(horizontal = 8.dp, vertical = 7.dp), + ) { + Text(label, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.width(150.dp)) + Text(value, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onBackground, modifier = Modifier.weight(1f), maxLines = 2, overflow = TextOverflow.Ellipsis) + } +} + +/** Store installs are measured by [StoreSizes]; Steam and added games by walking their folder once. */ +@Composable +internal fun diskSize(installed: InstalledStoreGame?, folder: File): Long? { + val ctx = LocalContext.current + if (installed != null) { + LaunchedEffect(installed.key) { StoreSizes.requestDisk(ctx, installed.key, installed) } + return StoreSizes.onDisk[installed.key] + } + LaunchedEffect(folder.path) { FolderSizes.request(folder) } + return FolderSizes.known[folder.path] +} + +internal object FolderSizes { + val known = androidx.compose.runtime.mutableStateMapOf<String, Long>() + private val asked = java.util.Collections.synchronizedSet(HashSet<String>()) + + fun request(folder: File) { + if (!asked.add(folder.path)) return + Thread({ + var total = 0L + val walked = runCatching { + java.nio.file.Files.walkFileTree(folder.toPath(), object : java.nio.file.SimpleFileVisitor<java.nio.file.Path>() { + override fun visitFile(file: java.nio.file.Path, attrs: java.nio.file.attribute.BasicFileAttributes): java.nio.file.FileVisitResult { + if (attrs.isRegularFile) total += attrs.size() + return java.nio.file.FileVisitResult.CONTINUE + } + override fun visitFileFailed(file: java.nio.file.Path, exc: java.io.IOException): java.nio.file.FileVisitResult = java.nio.file.FileVisitResult.CONTINUE + }) + }.isSuccess + if (walked) StoresState.post { known[folder.path] = total } else asked.remove(folder.path) + }, "folder-size").start() + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/GameEnvironmentEditor.kt b/app/src/main/java/com/droiddeck/launcher/ui/GameEnvironmentEditor.kt index f8f50a5eb..8055f0864 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/GameEnvironmentEditor.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/GameEnvironmentEditor.kt @@ -101,7 +101,8 @@ internal fun GameEnvironmentEditor(byPad: Boolean, initialScope: String = "", on LaunchedEffect(Unit) { runCatching { withContext(Dispatchers.IO) { - GameEnvironmentStore.read(context) to Library.steamGames(context).map { it.appId.toString() to it.name } + GameEnvironmentStore.read(context) to Library.steamGames(context).map { it.appId.toString() to it.name } + + listOfNotNull(com.droiddeck.launcher.runtime.BattleNet.profile(context)) } }.onSuccess { (settings, installed) -> config = settings; games = installed } .onFailure { error = true } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/GpuDriversPanel.kt b/app/src/main/java/com/droiddeck/launcher/ui/GpuDriversPanel.kt index 47905485d..8474050c3 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/GpuDriversPanel.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/GpuDriversPanel.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.AnnotatedString import com.droiddeck.launcher.gpu.DeviceInfo +import com.droiddeck.launcher.session.SessionPrefs import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import androidx.compose.ui.res.stringResource @@ -128,6 +129,8 @@ class GpuDriversState( val runtime: DriverSlot = DriverSlot(), val canRestart: Boolean = true, val checkedAt: Long = 0L, + val tpUbwcMode: String? = null, + val tpUbwcRestartRequired: Boolean = false, ) { val activePair: PairRow? get() = pairs.firstOrNull { it.active } } @@ -147,6 +150,7 @@ class GpuDriversActions( val onRestoreBundled: () -> Unit = {}, val onMode: (String) -> Unit = {}, val onRestart: () -> Unit = {}, + val onTpUbwcMode: (String) -> Unit = {}, ) @Composable @@ -259,6 +263,27 @@ internal fun GpuDriversBody( GPU_MODE_PAIR -> PairBody(gpu, actions) else -> MixBody(single, actions, slotFocus, onOpenSlot) } + gpu.tpUbwcMode?.let { selected -> + var advanced by rememberSaveable { mutableStateOf(false) } + SecondaryButton(stringResource(R.string.gpu_advanced) + if (advanced) " ▴" else " ▾", modifier = Modifier.fillMaxWidth()) { advanced = !advanced } + AnimatedVisibility(advanced) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + ChoiceRow( + rememberMenuHost(), "tp-ubwc", stringResource(R.string.gpu_ubwc_title), + stringResource(R.string.gpu_ubwc_hint), + listOf( + SessionPrefs.TP_UBWC_AUTO to stringResource(R.string.common_auto), + SessionPrefs.TP_UBWC_ON to stringResource(R.string.widgets_on), + SessionPrefs.TP_UBWC_OFF to stringResource(R.string.widgets_off), + ), selected, onPick = actions.onTpUbwcMode, + ) + if (gpu.tpUbwcRestartRequired) { + Text(stringResource(R.string.gpu_ubwc_restart), fontSize = 13.sp, color = AttentionAmber) + if (gpu.canRestart) SecondaryButton(stringResource(R.string.gpu_restart_now), onClick = actions.onRestart) + } + } + } + } } } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ItchBrowseBar.kt b/app/src/main/java/com/droiddeck/launcher/ui/ItchBrowseBar.kt new file mode 100644 index 000000000..276d131fe --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/ItchBrowseBar.kt @@ -0,0 +1,145 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.droiddeck.launcher.R +import com.droiddeck.launcher.stores.itch.ItchBrowse +import com.droiddeck.launcher.stores.itch.ItchBrowseState + +@Composable +internal fun ItchBrowseBar() { + val ctx = LocalContext.current + val query = ItchBrowseState.query + var picking by remember { mutableStateOf(false) } + Column(Modifier.fillMaxWidth().padding(top = 6.dp, bottom = 8.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + ChipRow { + for (sort in ItchBrowse.sorts) BrowseChip(sortLabel(sort), query.sort == sort) { ItchBrowseState.choose(ctx, query.copy(sort = sort)) } + } + ChipRow { + for (price in ItchBrowse.prices) BrowseChip(priceLabel(price), query.price == price) { ItchBrowseState.choose(ctx, query.copy(price = price)) } + } + ChipRow { + BrowseChip(stringResource(R.string.stores_itch_platform_any), query.platforms.isEmpty()) { ItchBrowseState.choose(ctx, query.copy(platforms = emptySet())) } + for (platform in ItchBrowse.platformNames) { + val on = platform in query.platforms + BrowseChip(platformLabel(platform), on) { ItchBrowseState.choose(ctx, query.copy(platforms = if (on) query.platforms - platform else query.platforms + platform)) } + } + } + ChipRow { + for (tag in query.tags) BrowseChip("$tag ✕", true) { ItchBrowseState.choose(ctx, query.copy(tags = query.tags - tag)) } + BrowseChip(stringResource(R.string.stores_itch_tag_add), false) { picking = true } + } + } + if (picking) TextEntryDialog( + stringResource(R.string.stores_itch_tag_title), "", number = false, confirm = stringResource(R.string.common_ok), + onSubmit = { value -> ItchBrowse.tagSlug(value).takeIf { it.isNotEmpty() }?.let { ItchBrowseState.choose(ctx, query.copy(tags = (query.tags + it).distinct())) } }, + onDismiss = { picking = false }, id = "itch-tag", + ) +} + +@Composable +internal fun ItchLoadMore() { + val ctx = LocalContext.current + val count = ItchBrowseState.items.size + LaunchedEffect(Unit) { ItchBrowseState.more(ctx) } + if (ItchBrowseState.loading) LinearProgressIndicator(Modifier.fillMaxWidth().padding(vertical = 12.dp)) + else if (ItchBrowseState.failed) Box(Modifier.fillMaxWidth().padding(vertical = 8.dp), contentAlignment = Alignment.Center) { + BrowseChip(stringResource(R.string.stores_dl_retry), false) { ItchBrowseState.more(ctx, retry = true) } + } + else if (!ItchBrowseState.ended && count > 0) Box(Modifier.fillMaxWidth().padding(vertical = 8.dp), contentAlignment = Alignment.Center) { + BrowseChip(stringResource(R.string.stores_itch_load_more), false) { ItchBrowseState.more(ctx) } + } +} + +@Composable +private fun ChipRow(content: @Composable () -> Unit) { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).selectableGroup(), + ) { content() } +} + +@Composable +private fun BrowseChip(label: String, selected: Boolean, onClick: () -> Unit) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val shape = RoundedCornerShape(8.dp) + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + Text( + label, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, + color = if (selected) pal.onSignal else if (hot) colors.onBackground else colors.onSurfaceVariant, + modifier = Modifier.clip(shape).background(if (selected) pal.signal else colors.surfaceVariant) + .glideBorder(hot, shape, pal.signal) + .hoverable(src).selectable(selected = selected, interactionSource = src, indication = LocalIndication.current, role = Role.Tab, onClick = onClick) + .controllerConfirm(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 7.dp), + ) +} + +@Composable +private fun sortLabel(sort: String): String = stringResource( + when (sort) { + ItchBrowse.SORT_NEW_POPULAR -> R.string.stores_itch_sort_new_popular + ItchBrowse.SORT_TOP_SELLERS -> R.string.stores_itch_sort_top_sellers + ItchBrowse.SORT_TOP_RATED -> R.string.stores_itch_sort_top_rated + ItchBrowse.SORT_NEWEST -> R.string.stores_itch_sort_new + else -> R.string.stores_itch_sort_popular + }, +) + +@Composable +private fun platformLabel(platform: String): String = stringResource( + when (platform) { + "windows" -> R.string.stores_itch_platform_windows + "osx" -> R.string.stores_itch_platform_macos + "linux" -> R.string.stores_itch_platform_linux + "android" -> R.string.stores_itch_platform_android + "ios" -> R.string.stores_itch_platform_ios + "web" -> R.string.stores_itch_platform_web + else -> R.string.stores_itch_platform_mobile_web + }, +) + +@Composable +private fun priceLabel(price: String): String = stringResource( + when (price) { + ItchBrowse.PRICE_FREE -> R.string.stores_itch_price_free + ItchBrowse.PRICE_SALE -> R.string.stores_itch_price_sale + ItchBrowse.PRICE_PAID -> R.string.stores_itch_price_paid + ItchBrowse.PRICE_UNDER_5 -> R.string.stores_itch_price_under_5 + ItchBrowse.PRICE_UNDER_15 -> R.string.stores_itch_price_under_15 + else -> R.string.stores_itch_price_any + }, +) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ItchBuildPicker.kt b/app/src/main/java/com/droiddeck/launcher/ui/ItchBuildPicker.kt new file mode 100644 index 000000000..09c6d08f3 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/ItchBuildPicker.kt @@ -0,0 +1,117 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.key +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.droiddeck.launcher.R +import com.droiddeck.launcher.stores.CatalogItem +import com.droiddeck.launcher.stores.StoresState +import com.droiddeck.launcher.stores.formatBytes +import com.droiddeck.launcher.stores.itch.ItchApi +import com.droiddeck.launcher.stores.itch.ItchAuth +import com.droiddeck.launcher.stores.itch.ItchBuild +import com.droiddeck.launcher.stores.itch.ItchGame +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +private class BuildList(val gameId: String, val builds: List<ItchBuild>) + +@Composable +internal fun ItchBuildPicker(item: CatalogItem, onDismiss: () -> Unit) { + val ctx = LocalContext.current + val app = ctx.applicationContext + val colors = MaterialTheme.colorScheme + val shown = rememberShown(onDismiss) + val close = { shown.targetState = false } + var loaded by remember(item) { mutableStateOf<BuildList?>(null) } + var failed by remember(item) { mutableStateOf(false) } + + val install: (BuildList, ItchBuild) -> Unit = install@{ list, build -> + if (!shown.targetState) return@install + val tagged = item.copy( + extra = item.extra + mapOf("gameId" to list.gameId, "uploadId" to build.uploadId), + sizeBytes = build.size.takeIf { it > 0 } ?: item.sizeBytes, + ) + close() + StoresState.requestInstall(ctx, tagged) + } + + LaunchedEffect(item) { + val result = withContext(Dispatchers.IO) { + val key = ItchAuth.key(app) + val id = if (key.isBlank()) null else ItchGame.resolveId(item) + val uploads = id?.let { ItchApi.uploads(key, it, item.extra["downloadKeyId"].orEmpty()) } + if (id == null || uploads == null) null else BuildList(id, ItchGame.builds(uploads)) + } + if (result == null) { failed = true; return@LaunchedEffect } + loaded = result + if (shown.targetState) result.builds.singleOrNull()?.takeIf { it.installable }?.let { install(result, it) } + } + + AppDialog(shown, close, "itchBuilds", wide = false, maxWidth = 460.dp) { + DialogTitleRow(stringResource(R.string.stores_itch_pick_title), close) + Text(item.title, fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + val list = loaded + when { + failed -> Text(stringResource(R.string.stores_itch_pick_failed), fontSize = 13.sp, color = colors.onSurfaceVariant) + list == null -> LinearProgressIndicator(Modifier.fillMaxWidth().padding(vertical = 8.dp)) + list.builds.isEmpty() -> Text(stringResource(R.string.stores_itch_pick_none), fontSize = 13.sp, color = colors.onSurfaceVariant) + else -> Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + for (build in list.builds) key(build.uploadId) { BuildRow(item, build) { install(list, build) } } + } + } + } +} + +@Composable +private fun BuildRow(item: CatalogItem, build: ItchBuild, onInstall: () -> Unit) { + val ctx = LocalContext.current + val colors = MaterialTheme.colorScheme + val linux = "linux" in build.platforms && !build.installable + val detail = listOfNotNull( + build.platforms.map { platformName(it) }.joinToString(", "), + build.size.takeIf { it > 0 }?.let { formatBytes(it) }, + build.updatedAt.take(10).takeIf { it.isNotBlank() }, + if (build.demo) stringResource(R.string.stores_itch_build_demo) else null, + ).joinToString(" · ") + ListRow(enabled = !linux, onClick = { if (build.installable) onInstall() else openStoreUrl(ctx, item) }) { + Column(Modifier.weight(1f)) { + Text(build.filename.ifBlank { item.title }, fontSize = 13.sp, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(detail, fontSize = 11.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + val label = when { + build.installable -> stringResource(R.string.stores_install) + linux -> stringResource(R.string.stores_itch_build_linux_soon) + else -> stringResource(R.string.stores_itch_build_open) + } + Text(label, fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1) + } +} + +@Composable +private fun platformName(platform: String): String = stringResource( + when (platform) { + "windows" -> R.string.stores_itch_build_windows + "linux" -> R.string.stores_itch_build_linux + "osx" -> R.string.stores_itch_build_macos + "android" -> R.string.stores_itch_build_android + else -> R.string.stores_itch_build_web + }, +) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/LibraryPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/LibraryPage.kt new file mode 100644 index 000000000..773f44ce7 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/LibraryPage.kt @@ -0,0 +1,691 @@ +package com.droiddeck.launcher.ui + +import android.view.KeyEvent +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyGridScope +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.FilterList +import androidx.compose.material.icons.filled.GridView +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Sort +import androidx.compose.material.icons.filled.ViewList +import androidx.compose.material.icons.filled.ViewModule +import androidx.compose.material.icons.outlined.Download +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.compose.AsyncImage +import com.droiddeck.launcher.R +import com.droiddeck.launcher.frontend.Library +import com.droiddeck.launcher.session.SessionPrefs +import com.droiddeck.launcher.stores.CatalogItem +import com.droiddeck.launcher.stores.Store +import com.droiddeck.launcher.stores.StoreSizes +import com.droiddeck.launcher.stores.StoresState +import com.droiddeck.launcher.stores.download.DownloadEntry +import com.droiddeck.launcher.stores.formatBytes +import com.droiddeck.launcher.stores.retainOrder +import com.droiddeck.launcher.stores.xbox.XboxLibraryFilter +import com.droiddeck.launcher.stores.sortGames +import kotlinx.coroutines.launch +import androidx.compose.runtime.setValue + +/** One owned game: installed ones carry their [game], store products not installed yet only their [item]. */ +internal class LibraryEntry( + val key: String, val source: String, val title: String, + val game: Library.SteamGame?, val item: CatalogItem?, val installed: Boolean, +) { + val storeKey: String? get() = item?.key ?: game?.storeId?.let { "${game.source}:$it" } +} + +/** Installed games first claim their store product, so a game owned and installed shows once. */ +internal fun libraryEntries( + games: List<Library.SteamGame>, owned: Map<Store, List<CatalogItem>>, installedStore: Set<String>, +): List<LibraryEntry> { + val claimed = HashSet<String>() + val out = ArrayList<LibraryEntry>() + for (g in games) { + if (g.library == Library.EPIC_LAUNCHER) continue + val storeKey = g.storeId?.let { "${g.source}:$it" } + val item = storeKey?.let { k -> Store.byId(g.source)?.let { st -> owned[st]?.firstOrNull { it.key == k } } } + if (storeKey != null) claimed += storeKey + out += LibraryEntry(GameRef.key(g), g.source, g.name, g, item, installed = true) + } + for (store in Store.entries) for (item in owned[store].orEmpty()) { + if (!claimed.add(item.key)) continue + out += LibraryEntry(GameRef.key(item), store.id, item.title, null, item, installed = item.key in installedStore) + } + return out.distinctBy { it.key } +} + +internal fun installedFirst(entries: List<LibraryEntry>): List<LibraryEntry> = entries.partition { it.installed }.let { (on, off) -> on + off } + +internal const val LIBRARY_ALL = "all" +internal val LIBRARY_SOURCES = listOf(Library.SOURCE_STEAM) + Store.entries.map { it.id } + Library.ADDED + +/** Survives leaving the page, so Stores can open the Library on one store's owned games. */ +internal object LibraryView { + var source by mutableStateOf(LIBRARY_ALL) + var query by mutableStateOf("") + var focusFirst = false + + fun showOwned(store: Store) { + source = store.id + query = "" + focusFirst = true + } +} + +@Composable +internal fun LibraryPage(s: FrontEndState, a: FrontEndActions, selected: String, onSelect: (String) -> Unit, modifier: Modifier) { + val ctx = LocalContext.current + val narrow = LocalNarrowPane.current + val ff = LocalFrontFocus.current + val focusManager = androidx.compose.ui.platform.LocalFocusManager.current + val inputMode = androidx.compose.ui.platform.LocalInputModeManager.current + LaunchedEffect(Unit) { StoresState.refresh(ctx) } + val stores = s.gameStoresEnabled && !s.shortcutPicker + val signedIn = Store.entries.filter { StoresState.isSignedIn(it) } + LaunchedEffect(stores, signedIn) { if (stores) signedIn.forEach { StoresState.loadLibrary(ctx, it) } } + val owned = if (stores) signedIn.associateWith { StoresState.library[it].orEmpty() } else emptyMap() + val installedStore = StoresState.installed.map { it.key }.toSet() + val syncing = if (stores) signedIn.mapNotNull { st -> StoresState.status[st]?.let { st to it } } else emptyList() + val cloudRows = rememberXboxCloudRows(s) + val all = remember(s.steamGames, cloudRows, owned, installedStore) { libraryEntries(s.steamGames + cloudRows, owned, installedStore) } + LaunchedEffect(Unit) { XboxLibraryFilter.load(ctx) } + val sources = LIBRARY_SOURCES.filter { src -> all.any { it.source == src } } + val source = LibraryView.source.takeIf { it == LIBRARY_ALL || it in sources } ?: LIBRARY_ALL + val q = LibraryView.query.trim().lowercase() + val xboxFilter = if (source == Store.XBOX.id) XboxLibraryFilter.current else XboxLibraryFilter.ALL + val candidates = all.filter { + (source == LIBRARY_ALL || it.source == source) && (q.isEmpty() || it.title.lowercase().contains(q)) && keepsXbox(xboxFilter, it) + } + val sort = s.storesSort + val sizeSort = sort == "sizeAsc" || sort == "sizeDesc" + val installedAt = StoresState.installed.associate { it.key to it.sidecar.installedAt } + val lastPlayed = s.steamGames.filter { it.storeId != null }.associate { "${it.source}:${it.storeId}" to it.lastPlayed } + fun sizeOf(e: LibraryEntry): Long? { + val key = e.storeKey + return when { + key != null && key in installedStore -> StoreSizes.onDisk[key] + e.game?.gameFiles != null -> FolderSizes.known[e.game.gameFiles.path] + e.item != null -> StoreSizes.size(e.item).takeIf { it > 0 } + else -> 0L + } + } + LaunchedEffect(sizeSort, candidates.map { it.key }) { + if (!sizeSort) return@LaunchedEffect + for (e in candidates) { + val key = e.storeKey + val inst = key?.let { k -> StoresState.installed.firstOrNull { it.key == k } } + when { + inst != null -> StoreSizes.requestDisk(ctx, inst.key, inst) + e.game?.gameFiles != null -> FolderSizes.request(e.game.gameFiles) + e.item != null && e.item.accessible -> StoreSizes.request(ctx, e.item) + } + } + } + var sizesWaited by remember(sizeSort) { mutableStateOf(false) } + LaunchedEffect(sizeSort) { if (sizeSort) { kotlinx.coroutines.delay(8_000); sizesWaited = true } } + val sizesPending = sizeSort && !sizesWaited && candidates.any { sizeOf(it) == null } + val sorted = sortGames( + candidates, sort, title = { it.title }, releaseDate = { it.item?.releaseDate.orEmpty() }, + lastPlayed = { e -> e.game?.lastPlayed ?: e.storeKey?.let { lastPlayed[it] } ?: 0L }, + installedAt = { e -> e.storeKey?.let { installedAt[it] } ?: 0L }, + size = { e -> sizeOf(e) ?: 0L }, + ) + var stable by remember { mutableStateOf(emptyList<LibraryEntry>()) } + val ordered = if (sizesPending) retainOrder(candidates, stable) { it.key } else sorted + SideEffect { if (!sizesPending) stable = sorted } + val visible = installedFirst(ordered) + val split = visible.count { it.installed }.takeIf { it in 1 until visible.size } ?: 0 + + val open = selected.takeIf { it.startsWith("app:") } + val back = { onSelect("games") } + BackHandler(enabled = open == null && LibraryView.query.isNotEmpty()) { LibraryView.query = "" } + BackHandler(enabled = open != null) { back() } + val lead = remember { FocusRequester() } + var detailFocused by remember { mutableStateOf(false) } + var lastOpened by remember { mutableStateOf<String?>(null) } + val wasOpen = remember { booleanArrayOf(false) } + val backLabel = stringResource(R.string.rail_games) + LaunchedEffect(open) { + val closing = open == null && wasOpen[0] + wasOpen[0] = open != null + if (open != null) lastOpened = open + else if (!closing) return@LaunchedEffect + if (inputMode.inputMode != androidx.compose.ui.input.InputMode.Keyboard || ff == null) return@LaunchedEffect + repeat(60) { if (!HeldKeys.confirm) return@repeat; androidx.compose.runtime.withFrameNanos { } } + if (open != null) { + val backId = "back:$backLabel" + repeat(45) { n -> + androidx.compose.runtime.withFrameNanos { } + if (detailFocused) return@LaunchedEffect + val req = if (n < 20) lead else ff.items[backId]?.takeIf { (ff.attached[backId] ?: 0) > 0 } ?: lead + runCatching { req.requestFocus() } + } + return@LaunchedEffect + } + val id = lastOpened?.let { "card:$it" } + repeat(45) { + androidx.compose.runtime.withFrameNanos { } + if (id != null && ff.last == id) return@LaunchedEffect + val card = id?.let { ff.items[it] }?.takeIf { (ff.attached[id] ?: 0) > 0 } + if (card != null) runCatching { card.requestFocus() } + else if (ff.firstTileAttached > 0 && runCatching { ff.firstTile.requestFocus() }.isSuccess) return@LaunchedEffect + } + } + LaunchedEffect(Unit) { + if (!LibraryView.focusFirst || ff == null) return@LaunchedEffect + LibraryView.focusFirst = false + if (inputMode.inputMode != androidx.compose.ui.input.InputMode.Keyboard) return@LaunchedEffect + kotlinx.coroutines.delay(Motion.ms(240).toLong()) + focusWithinFrames({ ff.last?.startsWith("card:") == true }) { if (ff.firstTileAttached > 0) ff.firstTile else FocusRequester.Default } + } + if (all.isEmpty() && open == null) { + LibraryEmpty(s, a, modifier) + return + } + val grid = remember { LazyGridState() } + val detailScroll = rememberScrollState() + var cycled by remember { mutableIntStateOf(0) } + var gridFocused by remember { mutableStateOf(false) } + LaunchedEffect(source, sort) { grid.scrollToItem(0) } + LaunchedEffect(cycled) { + if (cycled == 0 || ff == null || inputMode.inputMode != androidx.compose.ui.input.InputMode.Keyboard) return@LaunchedEffect + focusWithinFrames({ gridFocused }) { + if (ff.firstTileAttached > 0) ff.firstTile else ff.items["libchip:$source"] ?: FocusRequester.Default + } + } + CardDetailHost( + open, detailScroll, + gridScrolled = { grid.firstVisibleItemIndex > 0 || grid.firstVisibleItemScrollOffset > 0 }, + onDetailFocus = { detailFocused = it }, + modifier = modifier.padding(horizontal = if (narrow) 14.dp else 22.dp, vertical = if (narrow) 10.dp else 14.dp), + wrap = { key, content -> StoreTheme(GameRef.parse(key).let { r -> r.store ?: r.game(s.steamGames)?.source?.let { Store.byId(it) } }) { content() } }, + grid = { + LibraryGrid( + s, a, grid, visible, split, all.size, sources, source, sizesPending, syncing, + onSource = { LibraryView.source = it }, + onCycle = { step -> + val ids = listOf(LIBRARY_ALL) + sources + LibraryView.source = ids[(ids.indexOf(source).coerceAtLeast(0) + step + ids.size) % ids.size] + cycled++ + }, + onOpen = { focusManager.clearFocus(force = true); onSelect(it) }, + searchCleared = open != null, + onFocus = { gridFocused = it }, + ) + }, + ) { key -> GameDetail(key, s, a, lead, backLabel, hopRail = "games", onBack = back) } +} + +@Composable +private fun LibraryEmpty(s: FrontEndState, a: FrontEndActions, modifier: Modifier) { + val narrow = LocalNarrowPane.current + Column(modifier = modifier.padding(horizontal = if (narrow) 16.dp else 22.dp, vertical = if (narrow) 12.dp else 18.dp)) { + Rise(0) { + PageHeader(stringResource(R.string.rail_games)) { + if (s.steamGamesX64) ClientTag() + if (!s.shortcutPicker) AddGameButton { GamesDialogState.adding = true } + } + } + Rise(1) { Note(stringResource(if (s.shortcutPicker && s.shortcutLibraryScanning) R.string.game_shortcut_scanning else R.string.games_empty)) } + if (!s.shortcutPicker) Rise(2) { + Actions { + PrimaryButton(stringResource(R.string.games_play_steam), enabled = !s.busy, main = true, icon = Icons.Filled.PlayArrow, modifier = Modifier.padding(top = 12.dp).testTag("play-steam"), onClick = a.onPlay) + } + } + Box(Modifier.padding(top = 12.dp)) { GameFileFolderActions(s, a) } + } +} + +@Composable +private fun LibraryGrid( + s: FrontEndState, a: FrontEndActions, grid: LazyGridState, visible: List<LibraryEntry>, split: Int, total: Int, + sources: List<String>, source: String, sizesPending: Boolean, syncing: List<Pair<Store, String>>, + onSource: (String) -> Unit, onCycle: (Int) -> Unit, onOpen: (String) -> Unit, onFocus: (Boolean) -> Unit, searchCleared: Boolean, +) { + val listMode = s.storesView == "list" + val capsules = s.storesView == "capsules" + val tileWidth = if (capsules) CapsuleWidth else CardWidth + val focus = LocalFrontFocus.current + val scope = rememberCoroutineScope() + val density = androidx.compose.ui.platform.LocalDensity.current + var columns by remember { mutableIntStateOf(1) } + val headerItems = 2 + (if (sizesPending) 1 else 0) + syncing.size + val tile: @Composable (LibraryEntry) -> Unit = { e -> + val first = e.key == visible.firstOrNull()?.key + val navigation = if (focus == null) Modifier else Modifier.onPreviewKeyEvent { event -> + val key = event.nativeKeyEvent + val direction = when (key.keyCode) { + KeyEvent.KEYCODE_DPAD_DOWN -> 1 + KeyEvent.KEYCODE_DPAD_UP -> -1 + else -> 0 + } + val index = visible.indexOfFirst { it.key == e.key } + val next = storeGridNeighbor(index, visible.size, if (listMode) 1 else columns, direction, split) + when { + direction == 0 || index < 0 -> false + next == null -> direction > 0 + else -> { + if (key.action == KeyEvent.ACTION_DOWN) { + val target = "card:${visible[next].key}" + val targetIndex = headerItems + next + if (split == 0) 0 else if (next < split) 1 else 2 + val shown = grid.layoutInfo.visibleItemsInfo.any { it.index == targetIndex } + if (!shown || (focus.attached[target] ?: 0) == 0 || + runCatching { focus.items.getValue(target).requestFocus() }.isFailure) scope.launch { + grid.scrollToItem(targetIndex) + focusWithinFrames({ focus.last == target }) { focus.items[target] ?: FocusRequester.Default } + } + } + true + } + } + } + val picker = s.shortcutPicker && e.game != null + val choose = if (picker) ({ a.onSteamGame(e.game!!) }) else null + if (listMode) LibraryRow(e, first, navigation, choose, onOpen) else LibraryTile(e, capsules, first, navigation, choose, onOpen) + } + LazyVerticalGrid( + columns = if (listMode) GridCells.Fixed(1) else GridCells.Adaptive(tileWidth), state = grid, + modifier = Modifier.fillMaxSize().onFocusChanged { onFocus(it.hasFocus) }.focusGroup().bumpers(onPrevious = { onCycle(-1) }, onNext = { onCycle(1) }).onSizeChanged { + val gap = with(density) { 10.dp.roundToPx() } + val minimum = with(density) { (if (listMode) CardWidth * 2 else tileWidth).roundToPx() } + columns = ((it.width + gap) / (minimum + gap)).coerceAtLeast(1) + }, + horizontalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp), + contentPadding = PaddingValues(top = 4.dp, bottom = 16.dp), + ) { + item(key = "header", span = { GridItemSpan(maxLineSpan) }) { Rise(0) { LibraryHeader(s, a, total, searchCleared, xbox = source == Store.XBOX.id) } } + item(key = "filters", span = { GridItemSpan(maxLineSpan) }) { + Rise(1) { LibraryFilters(sources, source, onSource) } + } + for ((store, line) in syncing) item(key = "sync-${store.id}", span = { GridItemSpan(maxLineSpan) }) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.fillMaxWidth()) { + Text(store.shortLabel + " · " + line, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + if (syncCount(line) != null) SyncBar(line) + else androidx.compose.material3.LinearProgressIndicator(Modifier.fillMaxWidth().padding(bottom = 8.dp).height(3.dp)) + } + } + if (sizesPending) item(key = "size-sort-progress", span = { GridItemSpan(maxLineSpan) }) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp)) { + androidx.compose.material3.LinearProgressIndicator(Modifier.weight(1f).height(3.dp)) + Text(stringResource(R.string.store_section_loading), fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + val empty = R.string.stores_nothing_matches + if (split == 0) libraryGrid(visible, empty, tile) + else { + item(key = "installed-label", span = { GridItemSpan(maxLineSpan) }) { SectionLabel(stringResource(R.string.stores_section_installed)) } + libraryGrid(visible.take(split), empty, tile) + item(key = "not-installed-label", span = { GridItemSpan(maxLineSpan) }) { SectionLabel(stringResource(R.string.stores_section_not_installed)) } + libraryGrid(visible.drop(split), empty, tile) + } + } +} + +private fun LazyGridScope.libraryGrid(entries: List<LibraryEntry>, empty: Int, tile: @Composable (LibraryEntry) -> Unit) { + if (entries.isEmpty()) item(key = "empty", span = { GridItemSpan(maxLineSpan) }) { + Box(Modifier.padding(vertical = 32.dp).fillMaxWidth(), contentAlignment = Alignment.Center) { + Text(stringResource(empty), fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + else items(entries, key = { it.key }, contentType = { "game" }) { tile(it) } +} + +@Composable +private fun LibraryHeader(s: FrontEndState, a: FrontEndActions, total: Int, searchCleared: Boolean, xbox: Boolean) { + val colors = MaterialTheme.colorScheme + val narrow = LocalNarrowPane.current + ModeHeader( + stringResource(R.string.rail_games), LibraryView.query, { LibraryView.query = it }, + placeholder = stringResource(R.string.stores_search_library), + clearSearchFocus = searchCleared, + lead = { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(start = 2.dp, end = 4.dp)) { + Text(stringResource(R.string.rail_games), fontSize = if (narrow) 20.sp else 24.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 1) + Text(pluralStringResource(R.plurals.mode_added_count, total, total), fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1) + if (s.steamGamesX64 && !narrow) ClientTag() + } + }, + ) { + if (xbox) XboxFilterButton() + SortButton(s, a) + ViewButton(s, a) + GameFileFolderActions(s, a) + if (!s.shortcutPicker) AddGameButton { GamesDialogState.adding = true } + } + if (s.steamGamesX64 && narrow) Box(Modifier.padding(top = 4.dp)) { ClientTag() } +} + +@Composable +private fun LibraryFilters(sources: List<String>, source: String, onSource: (String) -> Unit) { + if (sources.size < 2) return + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(top = 4.dp, bottom = 2.dp), + ) { + SourceFilterChip(LIBRARY_ALL, stringResource(R.string.stores_tab_all), source == LIBRARY_ALL, dot = null) { onSource(LIBRARY_ALL) } + for (src in sources) { + val store = Store.byId(src) + val dot = when { + store != null -> sourceColours(src).dot.copy(alpha = if (StoresState.isSignedIn(store) && StoresState.expired[store] != true) 1f else 0.35f) + src == Library.SOURCE_STEAM -> sourceColours(src).dot + else -> null + } + SourceFilterChip(src, if (src == Library.ADDED) stringResource(R.string.library_filter_added) else store?.shortLabel ?: sourceLabel(src), source == src, dot) { onSource(src) } + } + } +} + +@Composable +private fun SourceFilterChip(key: String, label: String, on: Boolean, dot: Color?, onClick: () -> Unit) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + val shape = RoundedCornerShape(99.dp) + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(7.dp), + modifier = Modifier.paneItem("libchip:$key").testTag("library-source-$key").heightIn(min = 40.dp).clip(shape) + .background(if (on) pal.signal.copy(alpha = 0.16f) else colors.surface) + .glideBorder(hot, shape, pal.signal, if (on) pal.signal else pal.line) + .hoverable(src).selectable(selected = on, interactionSource = src, indication = LocalIndication.current, role = Role.Tab, onClick = onClick) + .controllerConfirm(onClick = onClick) + .padding(horizontal = 14.dp, vertical = 8.dp), + ) { + if (dot != null) Box(Modifier.size(9.dp).background(dot, CircleShape)) + Text(label, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = if (on || hot) colors.onBackground else colors.onSurfaceVariant, maxLines = 1) + } +} + +private fun entryArt(e: LibraryEntry, tall: Boolean): Any? { + val inst = e.storeKey?.let { k -> StoresState.installed.firstOrNull { it.key == k } } + val wide: Any? = e.game?.hero ?: e.item?.imageUrl ?: inst?.sidecar?.hero ?: inst?.sidecar?.cover + val portrait: Any? = e.game?.art ?: e.item?.tallImageUrl ?: inst?.sidecar?.cover + return if (tall) portrait ?: wide else wide ?: portrait +} + +@Composable +private fun LibraryTile(e: LibraryEntry, capsule: Boolean, first: Boolean, navigation: Modifier, choose: (() -> Unit)?, onOpen: (String) -> Unit) { + val pal = LocalPalette.current + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + val pressed by src.collectIsPressedAsState() + val scale by animateFloatAsState(if (pressed) 0.985f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "tileScale") + val download = e.storeKey?.let { StoresState.download(it) }?.takeIf { it.isActive } + val placed = remember { arrayOfNulls<androidx.compose.ui.layout.LayoutCoordinates>(1) } + val corner = with(androidx.compose.ui.platform.LocalDensity.current) { 10.dp.toPx() } + val wide = entryArt(e, tall = false) + val tall = entryArt(e, tall = true) + RequestSize(e) + val open = { + val art = if (capsule) tall else wide + placed[0]?.takeIf { it.isAttached }?.let { StoresMotion.markCard(CardMark(e.key, it.boundsInRoot(), corner, (art as? java.io.File)?.path ?: art as? String)) } + onOpen(e.key) + } + Column( + modifier = Modifier.fillMaxWidth().onGloballyPositioned { placed[0] = it } + .then(navigation).paneItem("card:${e.key}").then(if (first) Modifier.firstTile().paneEntry() else Modifier).graphicsLayer { scaleX = scale; scaleY = scale } + .clip(RoundedCornerShape(10.dp)).glideBorder(hot, RoundedCornerShape(10.dp), pal.signal, pal.line) + .hoverable(src).clickable(role = Role.Button, interactionSource = src, indication = LocalIndication.current, onClick = open) + .controllerConfirm(onClick = choose ?: open), + ) { + CardFace(e.title, wide, tall, download, hot, capsule, dim = !e.installed && download == null, + badge = { SourceChip(e.source, small = true, modifier = Modifier.align(Alignment.TopStart).padding(6.dp)) }) { + LibraryMarker(e, download) + } + } +} + +@Composable +private fun RequestSize(e: LibraryEntry) { + val ctx = LocalContext.current + val item = e.item?.takeIf { !e.installed && it.accessible && it.sizeBytes <= 0 } ?: return + LaunchedEffect(item.key) { StoreSizes.request(ctx, item) } +} + +@Composable +private fun LibraryMarker(e: LibraryEntry, download: DownloadEntry?) { + val pal = LocalPalette.current + when { + download != null -> Text("${download.percent}%", fontSize = 10.sp, fontWeight = FontWeight.Bold, color = pal.signal, maxLines = 1) + e.installed -> Unit + e.item != null && StoresState.isUnfinished(e.item) -> Text(stringResource(R.string.stores_resume), fontSize = 10.sp, fontWeight = FontWeight.Bold, color = pal.signal, maxLines = 1) + else -> Icon(Icons.Outlined.Download, stringResource(R.string.stores_section_not_installed), tint = Color.White, modifier = Modifier.size(12.dp)) + } +} + +@Composable +private fun LibraryRow(e: LibraryEntry, first: Boolean, navigation: Modifier, choose: (() -> Unit)?, onOpen: (String) -> Unit) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + val download = e.storeKey?.let { StoresState.download(it) }?.takeIf { it.isActive } + val placed = remember { arrayOfNulls<androidx.compose.ui.layout.LayoutCoordinates>(1) } + val corner = with(androidx.compose.ui.platform.LocalDensity.current) { 10.dp.toPx() } + val wide = entryArt(e, tall = false) + val open = { + placed[0]?.takeIf { it.isAttached }?.let { StoresMotion.markCard(CardMark(e.key, it.boundsInRoot(), corner, (wide as? java.io.File)?.path ?: wide as? String)) } + onOpen(e.key) + } + RequestSize(e) + val inst = e.storeKey?.let { k -> StoresState.installed.firstOrNull { it.key == k } } + val folder = inst?.folder ?: e.game?.gameFiles + val size = if (e.installed && folder != null) diskSize(inst, folder) else e.item?.let { StoreSizes.size(it) }?.takeIf { it > 0 } + val status = when { + download != null -> downloadLabel(download) + !e.installed -> stringResource(R.string.stores_section_not_installed) + e.game != null -> playedSpan(e.game.lastPlayed)?.replaceFirstChar { it.uppercase() } ?: stringResource(R.string.games_never_played) + else -> stringResource(R.string.stores_installed_chip) + } + val meta = listOfNotNull(status, size?.let { formatBytes(it) }?.takeIf { it.isNotEmpty() }) + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth().heightIn(min = 64.dp).onGloballyPositioned { placed[0] = it }.then(navigation).paneItem("card:${e.key}") + .then(if (first) Modifier.firstTile().paneEntry() else Modifier) + .clip(RoundedCornerShape(10.dp)).glideBorder(hot, RoundedCornerShape(10.dp), pal.signal, pal.line) + .hoverable(src).clickable(role = Role.Button, interactionSource = src, indication = LocalIndication.current, onClick = open) + .controllerConfirm(onClick = choose ?: open) + .padding(horizontal = 10.dp, vertical = 8.dp), + ) { + Box(Modifier.width(96.dp).height(54.dp).clip(RoundedCornerShape(8.dp)).background(artBrush(hueOf(e.title))).graphicsLayer { alpha = if (e.installed || download != null) 1f else 0.55f }) { + if (wide != null) AsyncImage(model = wide, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize()) + } + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(e.title, fontSize = 14.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + SourceChip(e.source, small = true) + Text(meta.joinToString(" · "), fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + if (download != null) Text("${download.percent}%", fontSize = 11.sp, fontWeight = FontWeight.Bold, color = pal.signal, maxLines = 1) + else if (!e.installed) Icon(Icons.Outlined.Download, null, tint = colors.onSurfaceVariant, modifier = Modifier.size(16.dp)) + } +} + +@Composable +private fun HeaderIconButton(description: String, icon: androidx.compose.ui.graphics.vector.ImageVector, key: String, onClick: () -> Unit) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.paneItem("btn:$key").size(32.dp).clip(CircleShape).background(if (hot) pal.signal.copy(alpha = 0.14f) else Color.Transparent) + .glideBorder(hot, CircleShape, pal.signal) + .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick) + .controllerConfirm(onClick = onClick) + .semantics { contentDescription = description }, + ) { Icon(icon, null, tint = if (hot) pal.signal else colors.onSurfaceVariant, modifier = Modifier.size(18.dp)) } +} + +/** Choosing Library in the rail lands on the first game, as Play Steam does on the Steam page. */ +@Composable +private fun Modifier.paneEntry(): Modifier { + val ff = LocalFrontFocus.current ?: return this + androidx.compose.runtime.DisposableEffect(ff) { + ff.primaryAttached++ + onDispose { ff.primaryAttached-- } + } + return focusRequester(ff.primary) +} + +internal fun keepsXbox(filter: String, e: LibraryEntry): Boolean = when { + filter == XboxLibraryFilter.ALL || e.source != Store.XBOX.id -> true + e.game?.library == Library.XBOX_CLOUD -> filter == XboxLibraryFilter.CLOUD + else -> e.item?.let { XboxLibraryFilter.keeps(filter, it) } == true +} + +@Composable +private fun xboxFilterLabel(filter: String): String = when (filter) { + XboxLibraryFilter.OWNED -> stringResource(R.string.game_install_access_owned) + XboxLibraryFilter.GAME_PASS -> stringResource(R.string.xbox_filter_game_pass) + XboxLibraryFilter.CLOUD -> stringResource(R.string.xbox_filter_cloud) + else -> stringResource(R.string.stores_tab_all) +} + +@Composable +private fun XboxFilterButton() { + val pal = LocalPalette.current + val ctx = LocalContext.current + val title = stringResource(R.string.xbox_filter_show) + var at by remember { mutableStateOf<Rect?>(null) } + Box(Modifier.onGloballyPositioned { at = it.boundsInRoot() }) { + HeaderIconButton(title + " · " + xboxFilterLabel(XboxLibraryFilter.current), Icons.Filled.FilterList, "xbox-filter") { + val from = at ?: return@HeaderIconButton + Steps.ask(StepAsk( + anchor = from, side = StepSide.Left, accent = pal.signal, pillCorner = 8.dp, items = XboxLibraryFilter.ALL_FILTERS.size, + handle = { Text(title, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = pal.signal, maxLines = 1, overflow = TextOverflow.Ellipsis) }, + ) { + StepTitle(title) + StepList { + XboxLibraryFilter.ALL_FILTERS.forEachIndexed { i, filter -> + StepChoice( + xboxFilterLabel(filter), selected = filter == XboxLibraryFilter.current, enabled = open, + modifier = Modifier.fillMaxWidth().stepItem(i).then(if (filter == XboxLibraryFilter.current) Modifier.focusRequester(first) else Modifier), + ) { + XboxLibraryFilter.set(ctx, filter) + Steps.fold() + } + } + } + }) + } + } +} + +@Composable +private fun SortButton(s: FrontEndState, a: FrontEndActions) { + val pal = LocalPalette.current + val title = stringResource(R.string.stores_sort) + var at by remember { mutableStateOf<Rect?>(null) } + Box(Modifier.onGloballyPositioned { at = it.boundsInRoot() }) { + HeaderIconButton(title, Icons.Filled.Sort, "sort") { + val from = at ?: return@HeaderIconButton + Steps.ask(StepAsk( + anchor = from, side = StepSide.Left, accent = pal.signal, pillCorner = 8.dp, items = SessionPrefs.STORES_SORTS.size, + handle = { Text(title, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = pal.signal, maxLines = 1, overflow = TextOverflow.Ellipsis) }, + ) { + StepTitle(title) + StepList { + SessionPrefs.STORES_SORTS.forEachIndexed { i, sort -> + val label = when (sort) { + "lastPlayed" -> stringResource(R.string.stores_sort_last_played) + "recentlyInstalled" -> stringResource(R.string.stores_sort_recently_installed) + "titleAsc" -> stringResource(R.string.stores_sort_title_asc) + "titleDesc" -> stringResource(R.string.stores_sort_title_desc) + "sizeDesc" -> stringResource(R.string.stores_sort_size_desc) + "sizeAsc" -> stringResource(R.string.stores_sort_size_asc) + else -> stringResource(R.string.stores_sort_release_date) + } + StepChoice( + label, selected = sort == s.storesSort, enabled = open, + modifier = Modifier.fillMaxWidth().stepItem(i).then(if (sort == s.storesSort) Modifier.focusRequester(first) else Modifier), + ) { + a.onStoresSort(sort) + Steps.fold() + } + } + } + }) + } + } +} + +@Composable +private fun ViewButton(s: FrontEndState, a: FrontEndActions) { + val views = SessionPrefs.STORES_VIEWS + val next = views[(views.indexOf(s.storesView) + 1) % views.size] + HeaderIconButton( + stringResource(when (next) { "capsules" -> R.string.stores_view_capsules; "list" -> R.string.stores_view_list; else -> R.string.stores_view_cards }), + when (next) { "capsules" -> Icons.Filled.ViewModule; "list" -> Icons.Filled.ViewList; else -> Icons.Filled.GridView }, + "view", + ) { a.onStoresView(next) } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/LiftSheet.kt b/app/src/main/java/com/droiddeck/launcher/ui/LiftSheet.kt index c428b8b97..fae0f318d 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/LiftSheet.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/LiftSheet.kt @@ -641,11 +641,7 @@ internal fun LiftHero( } @Composable -internal fun Handle(height: Dp, above: Boolean = false) { - val colors = MaterialTheme.colorScheme - val pal = LocalPalette.current - val shape = if (above) RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) else RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp) - val still = Motion.scale == 0f +private fun handleBob(): Float { val bob = androidx.compose.animation.core.rememberInfiniteTransition(label = "handle") val lift by bob.animateFloat( 0f, 0f, @@ -660,6 +656,16 @@ internal fun Handle(height: Dp, above: Boolean = false) { ), label = "handleBob", ) + return lift +} + +@Composable +internal fun Handle(height: Dp, above: Boolean = false) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val shape = if (above) RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) else RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp) + val still = Motion.scale == 0f || !ambientAwake() + val lift = if (still) 0f else handleBob() Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.height(height).clip(shape).background(colors.surface) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt index 778673e92..393971446 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt @@ -60,13 +60,12 @@ class ModeSettings( val hdrReason: String?, val gpuDrivers: String? = null, val fpsLimit: Int = 0, + val performanceMode: Boolean = false, val upscaler: Int = 0, val upscaleSharpness: Int = 75, val touchMode: String, val suspendPolicy: String, val steamDownloadsInBackground: Boolean = false, - val zramCompression: Boolean? = null, - val zramSupported: Boolean? = null, val pipSupported: Boolean = false, val pipAutoEnter: Boolean = false, val oscMode: String?, @@ -84,6 +83,8 @@ class ModeSettings( val steamClientArch: String? = null, /** Steam only: a game launched from the front end returns there when it closes; null hides the row. */ val returnAfterGame: Boolean? = null, + /** Steam only: the client's Shader Pre-Caching setting; null hides the row. */ + val shaderPrecache: Boolean? = null, val steamRepairQueued: Boolean = false, val mangoapp: Boolean = true, val steamController: String? = null, @@ -116,12 +117,12 @@ class ModeSettingsActions( val onHdr: (Boolean) -> Unit, val onGpuDrivers: () -> Unit = {}, val onFpsLimit: (Int) -> Unit = {}, + val onPerformanceMode: (Boolean) -> Unit = {}, val onUpscaler: (Int) -> Unit = {}, val onUpscaleSharpness: (Int) -> Unit = {}, val onTouch: (String) -> Unit, val onSuspendPolicy: (String) -> Unit, val onSteamDownloadsInBackground: (Boolean) -> Unit = {}, - val onZramCompression: (Boolean) -> Unit = {}, val onPipAutoEnter: (Boolean) -> Unit = {}, val onOsc: (String) -> Unit, val onBackActionsInverted: (Boolean) -> Unit = {}, @@ -139,6 +140,7 @@ class ModeSettingsActions( val onSteamClientArch: (String) -> Unit = {}, val onMangoapp: (Boolean) -> Unit = {}, val onReturnAfterGame: (Boolean) -> Unit = {}, + val onShaderPrecache: (Boolean) -> Unit = {}, val onSteamController: (String) -> Unit = {}, val onUnfoldedControls: (Boolean) -> Unit = {}, val onUnfoldedControlsWithController: (Boolean) -> Unit = {}, @@ -221,7 +223,8 @@ fun ModeSheetRows(s: ModeSettings, a: ModeSettingsActions, tab: String, extras: } if (s.stretch16x9 != null && (com.droiddeck.launcher.session.SessionDisplay.canStretch16x9(s.panelSize) || com.droiddeck.launcher.session.SessionDisplay.canStretch16x9(displaySize))) { - val canStretch = com.droiddeck.launcher.session.SessionDisplay.canStretch16x9(displaySize) + val canStretch = s.resolution != com.droiddeck.launcher.session.SessionDisplay.FOLLOW_SCREEN && + com.droiddeck.launcher.session.SessionDisplay.canStretch16x9(displaySize) ChoiceRow(host, "game-fit", stringResource(R.string.display_game_fit), stringResource(if (canStretch) R.string.display_game_fit_hint else R.string.display_game_fit_unavailable), listOf(false to stringResource(R.string.display_preserve), true to stringResource(R.string.display_stretch)), @@ -240,6 +243,8 @@ fun ModeSheetRows(s: ModeSettings, a: ModeSettingsActions, tab: String, extras: ) } SettingsGroup(stringResource(R.string.display_frame_rate)) { + ToggleRow(host, "performance", stringResource(R.string.mode_performance), stringResource(R.string.mode_performance_hint), + s.performanceMode, onChange = a.onPerformanceMode) ChoiceRow(host, "fps", stringResource(R.string.mode_fps), stringResource(R.string.common_applies_next_session), SessionPrefs.fpsLimitChoices(LocalContext.current), s.fpsLimit, note = stringResource(R.string.mode_fps_note), onPick = a.onFpsLimit) } @@ -333,12 +338,6 @@ fun ModeSheetRows(s: ModeSettings, a: ModeSettingsActions, tab: String, extras: stringResource(R.string.mode_background_downloads_hint), s.steamDownloadsInBackground, onChange = a.onSteamDownloadsInBackground, ) - if (s.zramCompression != null) ToggleRow( - host, "zram", stringResource(R.string.mode_zram), - stringResource(if (s.zramSupported == false) R.string.mode_zram_unsupported else R.string.mode_zram_hint), - s.zramCompression && s.zramSupported != false, enabled = s.zramSupported == true, - onChange = a.onZramCompression, - ) if (s.syncBackend != null) ChoiceRow( host, "sync", stringResource(R.string.sync_backend_title), stringResource(R.string.sync_backend_hint), listOf( @@ -487,6 +486,11 @@ fun ModeSheetRows(s: ModeSettings, a: ModeSettingsActions, tab: String, extras: stringResource(R.string.mode_return_after_game_hint), s.returnAfterGame, onChange = a.onReturnAfterGame, ) + if (s.shaderPrecache != null) ToggleRow( + host, "shaderprecache", stringResource(R.string.mode_shader_precache), + stringResource(R.string.mode_shader_precache_hint), + s.shaderPrecache, onChange = a.onShaderPrecache, + ) SettingsRow( stringResource(R.string.mode_steam_repair), stringResource(if (s.steamRepairQueued) R.string.mode_steam_repair_queued else R.string.mode_steam_repair_hint), diff --git a/app/src/main/java/com/droiddeck/launcher/ui/PcKeyboard.kt b/app/src/main/java/com/droiddeck/launcher/ui/PcKeyboard.kt index 0fc576277..6c68c9cfc 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/PcKeyboard.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/PcKeyboard.kt @@ -1,7 +1,9 @@ package com.droiddeck.launcher.ui import com.droiddeck.launcher.R -import androidx.annotation.StringRes +import com.droiddeck.launcher.input.PC_KEY_ROWS +import com.droiddeck.launcher.input.PC_MODIFIERS +import com.droiddeck.launcher.input.PcKey import androidx.compose.ui.res.stringResource import android.view.KeyEvent import androidx.compose.animation.animateColorAsState @@ -54,46 +56,6 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -private data class PcKey(val label: String, val code: Int, val weight: Float = 1f, val shifted: String? = null, @StringRes val labelRes: Int = 0) - -private fun k(label: String, code: Int, weight: Float = 1f, shifted: String? = null) = PcKey(label, code, weight, shifted) - -private fun k(@StringRes labelRes: Int, code: Int, weight: Float = 1f) = PcKey("", code, weight, null, labelRes) - -private val 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), - ), -) - -private val MODIFIERS = setOf(42, 54, 29, 97, 56, 100, 125) private const val MOD_OFF = 0 private const val MOD_ONCE = 1 private const val MOD_LOCKED = 2 @@ -115,7 +77,7 @@ fun PcKeyboard( for ((code, state) in mods.toMap()) if (state == MOD_ONCE) { sendKey(code, false); mods[code] = MOD_OFF } } fun press(key: PcKey) { - if (key.code in MODIFIERS) { + if (key.code in PC_MODIFIERS) { when (mods[key.code] ?: MOD_OFF) { MOD_OFF -> { sendKey(key.code, true); mods[key.code] = MOD_ONCE } MOD_ONCE -> mods[key.code] = MOD_LOCKED @@ -126,7 +88,7 @@ fun PcKeyboard( sendKey(key.code, true) } fun release(key: PcKey) { - if (key.code in MODIFIERS) return + if (key.code in PC_MODIFIERS) return sendKey(key.code, false) if (key.code == 58) capsOn = !capsOn releaseOnceModifiers() @@ -153,7 +115,7 @@ fun PcKeyboard( Spacer(Modifier.width(8.dp)) HeaderButton(stringResource(R.string.pckb_hide), onClose) } - ROWS.forEachIndexed { r, row -> + PC_KEY_ROWS.forEachIndexed { r, row -> Row(horizontalArrangement = Arrangement.spacedBy(3.dp), modifier = Modifier.fillMaxWidth().height(rowHeight)) { row.forEachIndexed { i, key -> val state = mods[key.code] ?: MOD_OFF diff --git a/app/src/main/java/com/droiddeck/launcher/ui/PerformanceDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/PerformanceDialog.kt index b46133463..9c1424dd4 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/PerformanceDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/PerformanceDialog.kt @@ -28,6 +28,8 @@ import com.droiddeck.launcher.session.SessionPrefs class CoreRow(val core: Int, val label: String) +private const val MEMORY_CAP_STEP_MB = 256 + @Composable fun PerformancePage( cores: List<CoreRow>, @@ -44,8 +46,14 @@ fun PerformancePage( fsyncFirst: Boolean, gamescopeRealtime: Boolean, gpuClockPin: Boolean, + ramCapMb: Int, + zramClient: Boolean, + vramCapMb: Int, + deviceRamMb: Int, + ramCapAvailable: Boolean, prootNoSeccomp: Boolean, prootFastPath: Boolean, + cursorComposite: Boolean, guestHostname: String, phantomWarning: String?, onClientOverride: (Boolean) -> Unit, @@ -59,8 +67,12 @@ fun PerformancePage( onFsyncFirst: (Boolean) -> Unit, onGamescopeRealtime: (Boolean) -> Unit, onGpuClockPin: (Boolean) -> Unit, + onRamCapMb: (Int) -> Unit, + onZramClient: (Boolean) -> Unit, + onVramCapMb: (Int) -> Unit, onProotNoSeccomp: (Boolean) -> Unit, onProotFastPath: (Boolean) -> Unit, + onCursorComposite: (Boolean) -> Unit, onGuestHostname: (String) -> Unit, onClientCore: (Int, Boolean) -> Unit, onGameCore: (Int, Boolean) -> Unit, @@ -121,6 +133,27 @@ fun PerformancePage( gpuClockPin, onChange = onGpuClockPin, ) } + SettingsGroup(stringResource(R.string.perf_memory)) { + val top = (deviceRamMb / MEMORY_CAP_STEP_MB * MEMORY_CAP_STEP_MB).coerceAtLeast(MEMORY_CAP_STEP_MB) + val off = stringResource(R.string.perf_cap_off) + val auto = stringResource(R.string.perf_cap_auto) + ToggleRow( + host, "zramclient", stringResource(R.string.perf_zram_client), + if (ramCapAvailable) stringResource(R.string.perf_zram_client_hint) else stringResource(R.string.perf_ram_cap_unavailable), + zramClient && ramCapAvailable, enabled = ramCapAvailable, onChange = onZramClient, + ) + SliderRow( + stringResource(R.string.perf_ram_cap), + if (ramCapAvailable) stringResource(R.string.perf_ram_cap_hint) else stringResource(R.string.perf_ram_cap_unavailable), + ramCapMb.coerceAtMost(top), 0..top, step = MEMORY_CAP_STEP_MB, enabled = ramCapAvailable, + format = { if (it == 0) off else "$it MB" }, onChange = onRamCapMb, + ) + SliderRow( + stringResource(R.string.perf_vram_cap), stringResource(R.string.perf_vram_cap_hint), + vramCapMb.coerceAtMost(top), 0..top, step = MEMORY_CAP_STEP_MB, + format = { if (it == 0) auto else "$it MB" }, onChange = onVramCapMb, + ) + } SettingsGroup(stringResource(R.string.perf_fixes)) { ToggleRow( host, "sysmem", stringResource(R.string.perf_sysmem), @@ -158,6 +191,11 @@ fun PerformancePage( else stringResource(R.string.perf_fastpath_hint), prootFastPath && !prootNoSeccomp, enabled = !prootNoSeccomp, onChange = onProotFastPath, ) + ToggleRow( + host, "cursorcomposite", stringResource(R.string.perf_cursor_composite), + stringResource(R.string.perf_cursor_composite_hint), + cursorComposite, onChange = onCursorComposite, + ) } SettingsGroup(stringResource(R.string.perf_identity)) { var draft by remember(guestHostname) { mutableStateOf(guestHostname) } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ScreenKeyEditor.kt b/app/src/main/java/com/droiddeck/launcher/ui/ScreenKeyEditor.kt new file mode 100644 index 000000000..3cb99d605 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/ScreenKeyEditor.kt @@ -0,0 +1,368 @@ +package com.droiddeck.launcher.ui + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.DragIndicator +import androidx.compose.material.icons.filled.Image +import androidx.compose.material.icons.filled.Keyboard +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.droiddeck.launcher.R +import com.droiddeck.launcher.input.SCREEN_KEY_CIRCLE +import com.droiddeck.launcher.input.SCREEN_KEY_DPAD +import com.droiddeck.launcher.input.SCREEN_KEY_PILL +import com.droiddeck.launcher.input.SCREEN_KEY_RECT +import com.droiddeck.launcher.input.SCREEN_KEY_ROUND +import com.droiddeck.launcher.input.SCREEN_KEY_STICK +import com.droiddeck.launcher.input.SCREEN_KEY_TRIGGER +import com.droiddeck.launcher.input.ScreenKeys +import com.droiddeck.launcher.input.pcKeyLabel +import com.droiddeck.launcher.input.screenKeyDirections +import java.io.File +import kotlin.math.roundToInt + +private val PANEL_WIDTH = 600.dp +private val CHIP_PADDING = androidx.compose.foundation.layout.PaddingValues(horizontal = 6.dp, vertical = 2.dp) + +@Composable +fun ScreenKeyEditor( + codes: List<Int>, + keyY: Float, + size: Int, + opacity: Float, + shape: Int, + toggle: Boolean, + labels: Boolean, + text: String, + hasImage: Boolean, + onResize: (Int) -> Unit, + onOpacity: (Float) -> Unit, + onShape: (Int) -> Unit, + onToggle: (Boolean) -> Unit, + onLabels: (Boolean) -> Unit, + onText: (String) -> Unit, + onImage: (String) -> Unit, + onClearImage: () -> Unit, + onBind: () -> Unit, + onTextFocus: () -> Unit, + onTextBlur: () -> Unit, + onDirection: (Int) -> Unit, + onRemove: () -> Unit, + onDismiss: () -> Unit, +) { + val p = LocalPalette.current + val context = LocalContext.current + var offset by remember(codes) { mutableStateOf(Offset.Zero) } + val density = LocalDensity.current + val screen = LocalConfiguration.current + val panelWidthPx = with(density) { PANEL_WIDTH.toPx() } + val screenWidthPx = screen.screenWidthDp * density.density + val screenHeightPx = screen.screenHeightDp * density.density + val atTop = keyY > 0.45f + val button = shape <= SCREEN_KEY_RECT + val directions = screenKeyDirections(shape) + val pickImage = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> + if (uri != null) importImage(context, uri, codes)?.let(onImage) + } + Box( + Modifier.fillMaxSize().systemBarsPadding().padding(horizontal = 10.dp, vertical = 6.dp), + contentAlignment = if (atTop) Alignment.TopCenter else Alignment.BottomCenter, + ) { + Surface( + Modifier.widthIn(max = PANEL_WIDTH).fillMaxWidth() + .offset { IntOffset(offset.x.roundToInt(), offset.y.roundToInt()) }, + shape = RoundedCornerShape(18.dp), + color = p.background, contentColor = p.onBackground, border = BorderStroke(1.dp, p.line2), + ) { + Column(Modifier.padding(start = 10.dp, end = 6.dp, top = 2.dp, bottom = 6.dp)) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.DragIndicator, null, + tint = p.onBackground.copy(alpha = 0.45f), + modifier = Modifier.pointerInput(panelWidthPx, screenWidthPx, screenHeightPx) { + detectDragGestures { change, drag -> + change.consume() + offset = Offset( + (offset.x + drag.x).coerceIn( + -(screenWidthPx - panelWidthPx) / 2f + 8f, + (screenWidthPx - panelWidthPx) / 2f - 8f, + ), + (offset.y + drag.y).coerceIn( + -screenHeightPx * if (atTop) 0f else 0.75f, + screenHeightPx * if (atTop) 0.75f else 0f, + ), + ) + } + }, + ) + Box( + Modifier.weight(1f).height(36.dp) + .background(p.line2.copy(alpha = 0.24f), RoundedCornerShape(9.dp)) + .padding(horizontal = 10.dp), + contentAlignment = Alignment.CenterStart, + ) { + BasicTextField( + value = text, + onValueChange = { onText(it.take(24)) }, + singleLine = true, + textStyle = TextStyle( + color = p.onBackground, fontSize = 13.sp, + fontWeight = FontWeight.Medium, + ), + cursorBrush = androidx.compose.ui.graphics.SolidColor(p.primary), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + modifier = Modifier.fillMaxWidth().onFocusChanged { + if (it.isFocused) onTextFocus() else onTextBlur() + }, + ) + if (text.isEmpty()) { + Text( + stringResource(R.string.screen_key_text), + color = p.onBackground.copy(alpha = 0.4f), + style = MaterialTheme.typography.bodySmall, + ) + } + } + if (button) { + IconButton(onClick = onBind, modifier = Modifier.width(38.dp)) { + Icon( + Icons.Default.Keyboard, stringResource(R.string.screen_key_bind), + tint = p.onBackground.copy(alpha = 0.8f), + ) + } + } + IconButton(onClick = { pickImage.launch("image/*") }, modifier = Modifier.width(38.dp)) { + Icon( + Icons.Default.Image, stringResource(R.string.screen_key_image), + tint = p.onBackground.copy(alpha = 0.8f), + ) + } + if (hasImage) IconButton(onClick = onClearImage, modifier = Modifier.width(34.dp)) { + Icon(Icons.Default.Close, stringResource(R.string.screen_key_image_clear), tint = p.error) + } + IconButton(onClick = onRemove, modifier = Modifier.width(38.dp)) { + Icon(Icons.Default.Delete, stringResource(R.string.common_delete), tint = p.error) + } + IconButton(onClick = onDismiss, modifier = Modifier.width(34.dp)) { + Icon(Icons.Default.Close, null, tint = p.onBackground) + } + } + Row( + Modifier.fillMaxWidth().padding(vertical = 2.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + listOf<Pair<Int?, Int>>( + (if (button) SCREEN_KEY_PILL else null) to R.string.screen_key_type_button, + SCREEN_KEY_TRIGGER to R.string.screen_key_shape_trigger, + SCREEN_KEY_DPAD to R.string.touch_dpad, + SCREEN_KEY_STICK to R.string.deck_surface_stick, + ).forEach { (value, label) -> + val selected = if (value == null) button else shape == value + Chip(stringResource(label), selected, Modifier.weight(1f)) { + onShape(if (value == null) (if (button) shape else SCREEN_KEY_PILL) else value) + } + } + Box(Modifier.width(1.dp).height(20.dp).background(p.line2)) + listOf( + SCREEN_KEY_PILL to R.string.screen_key_shape_pill, + SCREEN_KEY_ROUND to R.string.screen_key_shape_round, + SCREEN_KEY_CIRCLE to R.string.screen_key_shape_circle, + SCREEN_KEY_RECT to R.string.screen_key_shape_rect, + ).forEach { (value, label) -> + Chip( + stringResource(label), button && shape == value, Modifier.weight(1f), enabled = button, + ) { onShape(value) } + } + Box(Modifier.width(1.dp).height(20.dp).background(p.line2)) + Text( + stringResource(R.string.screen_key_toggle), Modifier.padding(start = 6.dp, end = 2.dp), + style = MaterialTheme.typography.bodySmall, maxLines = 1, + ) + Switch(checked = toggle, onCheckedChange = onToggle, modifier = Modifier.height(30.dp)) + } + if (directions.isNotEmpty()) { + Row( + Modifier.fillMaxWidth().padding(vertical = 2.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + directions.forEachIndexed { index, arrow -> + val code = codes.getOrNull(index) ?: 0 + Chip( + if (code == 0) arrow else "$arrow ${pcKeyLabel(context, code)}", + code != 0, Modifier.weight(1f), + ) { onDirection(index) } + } + Text( + stringResource(R.string.screen_key_text), Modifier.padding(start = 6.dp, end = 2.dp), + style = MaterialTheme.typography.bodySmall, maxLines = 1, + ) + Switch(checked = labels, onCheckedChange = onLabels, modifier = Modifier.height(30.dp)) + } + } + Row( + Modifier.fillMaxWidth().padding(top = 2.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + KeySlider( + label = stringResource(R.string.screen_key_size), + value = "$size", + current = size.toFloat(), + min = ScreenKeys.MIN_SIZE.toFloat(), + max = ScreenKeys.MAX_SIZE.toFloat(), + modifier = Modifier.weight(1f), + ) { onResize(it.roundToInt()) } + KeySlider( + label = stringResource(R.string.screen_key_opacity), + value = "${(opacity * 100).roundToInt()}%", + current = opacity, + min = 0.2f, + max = 1f, + modifier = Modifier.weight(1f), + onChange = onOpacity, + ) + } + } + } + } +} + +@Composable +private fun KeySlider( + label: String, + value: String, + current: Float, + min: Float, + max: Float, + modifier: Modifier, + onChange: (Float) -> Unit, +) { + Row(modifier, verticalAlignment = Alignment.CenterVertically) { + Text(label, style = MaterialTheme.typography.bodySmall, maxLines = 1) + Slider(current, onChange, Modifier.weight(1f).height(28.dp), valueRange = min..max) + Text( + value, Modifier.width(32.dp), style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.SemiBold, maxLines = 1, + ) + } +} + +@Composable +private fun Chip( + label: String, + selected: Boolean, + modifier: Modifier = Modifier, + enabled: Boolean = true, + onClick: () -> Unit, +) { + val p = LocalPalette.current + Box( + modifier + .background( + if (selected) p.primary.copy(alpha = 0.18f) else p.line2.copy(alpha = 0.28f), + CircleShape, + ) + .clickable(enabled = enabled, onClick = onClick) + .padding(vertical = 6.dp), + contentAlignment = Alignment.Center, + ) { + Text( + label, style = MaterialTheme.typography.labelSmall, maxLines = 1, overflow = TextOverflow.Ellipsis, + color = when { + !enabled -> p.onBackground.copy(alpha = 0.28f) + selected -> p.primary + else -> p.onBackground.copy(alpha = 0.7f) + }, + ) + } +} + +private fun importImage(context: Context, uri: Uri, codes: List<Int>): String? { + val dir = File(context.filesDir, ScreenKeys.IMAGE_DIR).apply { mkdirs() } + val name = "key_" + codes.joinToString("_") + "_" + System.currentTimeMillis() + ".png" + val longest = (ScreenKeys.MAX_SIZE * context.resources.displayMetrics.density).toInt() + return runCatching { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + context.contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, bounds) } + val source = maxOf(bounds.outWidth, bounds.outHeight) + if (source <= 0) return@runCatching null + var sample = 1 + while (source / (sample * 2) >= longest) sample *= 2 + val bitmap = context.contentResolver.openInputStream(uri)?.use { + BitmapFactory.decodeStream(it, null, BitmapFactory.Options().apply { inSampleSize = sample }) + } ?: return@runCatching null + val scale = longest.toFloat() / maxOf(bitmap.width, bitmap.height) + val scaled = if (scale < 1f) { + Bitmap.createScaledBitmap( + bitmap, + (bitmap.width * scale).toInt().coerceAtLeast(1), + (bitmap.height * scale).toInt().coerceAtLeast(1), + true, + ) + } else { + bitmap + } + try { + val saved = File(dir, name).outputStream().use { scaled.compress(Bitmap.CompressFormat.PNG, 100, it) } + if (saved) name else null + } finally { + if (scaled !== bitmap) bitmap.recycle() + scaled.recycle() + } + }.getOrNull().also { if (it == null) File(dir, name).delete() } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ScreenKeyPicker.kt b/app/src/main/java/com/droiddeck/launcher/ui/ScreenKeyPicker.kt new file mode 100644 index 000000000..787854d08 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/ScreenKeyPicker.kt @@ -0,0 +1,168 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.droiddeck.launcher.R +import com.droiddeck.launcher.input.PC_KEYS +import com.droiddeck.launcher.input.PC_KEY_ROWS +import com.droiddeck.launcher.input.PC_MODIFIERS +import com.droiddeck.launcher.input.PcKey +import com.droiddeck.launcher.input.pcKeyLabel + +@Composable +fun ScreenKeyPicker( + onDismiss: () -> Unit, + onAdd: (List<Int>) -> Unit, + initial: List<Int> = emptyList(), + sequence: Boolean = false, + sequenceLimit: Int = 4, + slots: List<String> = emptyList(), +) { + var picked by remember { mutableStateOf(initial) } + val p = LocalPalette.current + val toggle: (Int) -> Unit = { code -> + picked = if (sequence) { + if (code in picked) picked - code else (picked + code).take(sequenceLimit) + } else if (code in PC_MODIFIERS) { + if (code in picked) picked - code else picked + code + } else { + picked.filter { it in PC_MODIFIERS } + code + } + } + val rows = remember { + val functionRow = PC_KEY_ROWS[0] + val nav = listOfNotNull(PC_KEYS[110], PC_KEYS[111], PC_KEYS[102], PC_KEYS[107], PC_KEYS[104], PC_KEYS[109]) + + listOfNotNull(PC_KEYS[103], PC_KEYS[105], PC_KEYS[108], PC_KEYS[106]) + listOf(functionRow.take(13), nav) + PC_KEY_ROWS.drop(1) + } + val unit = remember { rows.maxOf { row -> row.fold(0f) { sum, key -> sum + key.weight } } } + Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) { + Box(Modifier.fillMaxSize().systemBarsPadding().padding(10.dp), contentAlignment = Alignment.Center) { + Surface( + Modifier.widthIn(max = 820.dp).fillMaxWidth().fillMaxHeight(), shape = RoundedCornerShape(20.dp), + color = p.background, contentColor = p.onBackground, border = BorderStroke(1.dp, p.line2), + ) { + Column { + Row(Modifier.fillMaxWidth().padding(start = 16.dp, end = 6.dp), verticalAlignment = Alignment.CenterVertically) { + Text(stringResource(R.string.screen_keys_add), Modifier.weight(1f), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + IconButton(onClick = onDismiss) { Icon(Icons.Default.Close, null) } + } + Row( + Modifier.fillMaxWidth().heightIn(min = 26.dp).horizontalScroll(rememberScrollState()).padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically, + ) { + if (sequence && slots.size >= sequenceLimit) { + slots.take(sequenceLimit).forEachIndexed { index, arrow -> + val code = picked.getOrNull(index) + Surface( + shape = RoundedCornerShape(8.dp), + color = if (code == null) p.line2.copy(alpha = 0.2f) else p.primary.copy(alpha = 0.16f), + modifier = if (code == null) Modifier else Modifier.clickable { picked = picked - code }, + ) { + Text( + if (code == null) arrow else "$arrow ${pcKeyLabel(LocalContext.current, code)}", + Modifier.padding(horizontal = 8.dp, vertical = 2.dp), + style = MaterialTheme.typography.labelMedium, + ) + } + } + } else { + picked.forEach { code -> + Surface( + shape = RoundedCornerShape(8.dp), color = p.primary.copy(alpha = 0.16f), + modifier = Modifier.clickable { picked = picked - code }, + ) { + Text(pcKeyLabel(LocalContext.current, code), Modifier.padding(horizontal = 8.dp, vertical = 2.dp), style = MaterialTheme.typography.labelMedium) + } + } + } + } + LazyColumn(Modifier.weight(1f).fillMaxWidth(), contentPadding = PaddingValues(vertical = 6.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + items(rows.size) { index -> + val row = rows[index] + Row( + Modifier.fillMaxWidth().padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + row.forEach { key -> KeyCell(key, key.code in picked, Modifier.weight(key.weight)) { toggle(key.code) } } + if (row.sumOf { it.weight.toDouble() } < unit) { + Spacer(Modifier.weight(unit - row.fold(0f) { sum, key -> sum + key.weight })) + } + } + } + } + Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button( + onClick = { onAdd(picked) }, + enabled = picked.isNotEmpty(), + modifier = Modifier.weight(1f), + ) { + Text(stringResource(R.string.screen_keys_add)) + } + } + } + } + } + } +} + +@Composable +private fun KeyCell(key: PcKey, selected: Boolean, modifier: Modifier, onClick: () -> Unit) { + val p = LocalPalette.current + val label = pcKeyLabel(LocalContext.current, key.code) + Surface( + shape = RoundedCornerShape(8.dp), + color = if (selected) p.primary.copy(alpha = 0.2f) else p.surface, + border = BorderStroke(1.dp, if (selected) p.primary else p.line), + modifier = modifier.height(36.dp).clickable(onClick = onClick), + ) { + Box(Modifier.fillMaxSize().padding(horizontal = 2.dp), contentAlignment = Alignment.Center) { + Text( + label, style = MaterialTheme.typography.labelMedium, maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontSize = if (label.length > 4) 10.sp else 12.sp, + ) + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ScreenLayoutPanel.kt b/app/src/main/java/com/droiddeck/launcher/ui/ScreenLayoutPanel.kt new file mode 100644 index 000000000..7e195d5e8 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/ScreenLayoutPanel.kt @@ -0,0 +1,138 @@ +package com.droiddeck.launcher.ui + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.droiddeck.launcher.R + +@Composable +fun ScreenLayoutPanel( + layouts: List<String>, + onSave: (String) -> Unit, + onLoad: (String) -> Unit, + onDelete: (String) -> Unit, + onImport: (String) -> Unit, + exportText: () -> String, + onDismiss: () -> Unit, +) { + val p = LocalPalette.current + val context = LocalContext.current + var naming by remember { mutableStateOf(false) } + var name by remember { mutableStateOf("") } + + val importer = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + val text = runCatching { + context.contentResolver.openInputStream(uri)?.use { it.readBytes().toString(Charsets.UTF_8) } + }.getOrNull() + if (text != null) onImport(text) + } + val exporter = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + runCatching { context.contentResolver.openOutputStream(uri)?.use { it.write(exportText().toByteArray()) } } + } + + Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) { + Box(Modifier.fillMaxSize().systemBarsPadding().padding(16.dp), contentAlignment = Alignment.Center) { + Surface( + Modifier.widthIn(max = 520.dp).fillMaxWidth(), shape = RoundedCornerShape(22.dp), + color = p.background, contentColor = p.onBackground, border = BorderStroke(1.dp, p.line2), + ) { + Column(Modifier.padding(start = 20.dp, end = 8.dp, top = 6.dp, bottom = 12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + stringResource(R.string.screen_key_layout), Modifier.weight(1f), + style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, + ) + IconButton(onClick = onDismiss) { Icon(Icons.Default.Close, null) } + } + Row(Modifier.fillMaxWidth().padding(end = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton(onClick = { naming = true }) { Text(stringResource(R.string.screen_key_layout_save)) } + TextButton(onClick = { importer.launch(arrayOf("application/json", "text/plain", "*/*")) }) { + Text(stringResource(R.string.boot_movie_import)) + } + TextButton(onClick = { exporter.launch("droiddeck-layout.icp") }) { + Text(stringResource(R.string.screen_key_layout_export)) + } + } + Column(Modifier.padding(end = 12.dp).heightIn(max = 320.dp).verticalScroll(rememberScrollState())) { + layouts.forEach { layout -> + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text( + layout, Modifier.weight(1f).clickable { onLoad(layout) }.padding(vertical = 14.dp), + style = MaterialTheme.typography.bodyLarge, + ) + TextButton(onClick = { onDelete(layout) }) { + Text(stringResource(R.string.common_delete), color = p.error) + } + } + } + } + } + } + } + } + + if (naming) { + Dialog(onDismissRequest = { naming = false }, properties = DialogProperties(usePlatformDefaultWidth = false)) { + Box(Modifier.fillMaxSize().systemBarsPadding().padding(16.dp), contentAlignment = Alignment.Center) { + Surface( + Modifier.widthIn(max = 520.dp).fillMaxWidth(), shape = RoundedCornerShape(22.dp), + color = p.background, contentColor = p.onBackground, border = BorderStroke(1.dp, p.line2), + ) { + Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + OutlinedTextField( + name, { name = it.take(24) }, Modifier.fillMaxWidth(), singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + ) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + TextButton(onClick = { naming = false }) { Text(stringResource(R.string.common_cancel)) } + Button(onClick = { onSave(name.trim()); naming = false }, enabled = name.isNotBlank()) { + Text(stringResource(R.string.screen_key_layout_save)) + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionLoading.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionLoading.kt index 4a0adea2d..cdde71b58 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionLoading.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionLoading.kt @@ -24,7 +24,6 @@ import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.tween import androidx.compose.material.icons.Icons import androidx.compose.foundation.layout.heightIn -import androidx.compose.material.icons.filled.Pause import androidx.compose.foundation.rememberScrollState import androidx.compose.material3.Icon import androidx.compose.foundation.Canvas @@ -223,7 +222,7 @@ fun LoadingOverlay( .background(colors.background) .blockTouches(), ) { - CapsuleWall(games, driftMs = 65_000, modifier = Modifier.alpha(0.3f)) + CapsuleWall(games, driftMs = 65_000, modifier = Modifier.alpha(0.3f), pauseWhenIdle = false) Box( Modifier.fillMaxSize().drawBehind { drawRect( @@ -345,37 +344,3 @@ private fun CancelHint(onCancel: () -> Unit, modifier: Modifier = Modifier) { Text(stringResource(R.string.common_cancel), fontSize = 13.sp, color = colors.onSurfaceVariant) } } - -@Composable -fun SessionPausedOverlay(title: String = stringResource(R.string.load_steam_paused), onResume: () -> Unit) { - val colors = MaterialTheme.colorScheme - val pal = LocalPalette.current - val resumeFocus = remember { FocusRequester() } - LaunchedEffect(Unit) { - androidx.compose.runtime.withFrameNanos { } - runCatching { resumeFocus.requestFocus() } - } - Box( - modifier = Modifier - .fillMaxSize() - .background(pal.background.copy(alpha = 0.94f)) - .blockTouches(), - contentAlignment = Alignment.Center, - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(10.dp), - modifier = Modifier.widthIn(max = 440.dp).padding(32.dp), - ) { - Box(contentAlignment = Alignment.Center, modifier = Modifier.padding(bottom = 6.dp).size(64.dp).border(2.dp, pal.line2, CircleShape)) { - Icon(Icons.Filled.Pause, contentDescription = null, tint = colors.onBackground, modifier = Modifier.size(30.dp)) - } - Text(title, fontSize = 28.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, textAlign = TextAlign.Center) - PrimaryButton( - stringResource(R.string.resume_session), - modifier = Modifier.padding(top = 14.dp).focusRequester(resumeFocus).controllerConfirm(onClick = onResume), - onClick = onResume, - ) - } - } -} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt index 58b4c11ee..9f8ceb270 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt @@ -193,6 +193,7 @@ private class DrawerFocus { class DrawerActions( val steam: Boolean, + val direct: Boolean = false, val title: String? = null, val isHomeApp: Boolean, val androidApps: List<HomeApp.LaunchableApp>, @@ -208,6 +209,9 @@ class DrawerActions( val onScreenButtonsVisible: Boolean, val suspendPolicy: String, val steamDownloadsInBackground: Boolean = false, + val micAvailable: Boolean = false, + val micEnabled: Boolean = false, + val onMicEnabled: (Boolean) -> Unit = {}, val backActionsInverted: Boolean, val padType: String? = null, val onPadType: (String) -> Unit = {}, @@ -258,6 +262,10 @@ class DrawerActions( val onLowLatencyTouch: (Boolean) -> Unit = {}, val onTouch: (String) -> Unit, val onGestureSettings: () -> Unit = {}, + val onScreenKeys: (() -> Unit)? = null, + val onScreenLayouts: (() -> Unit)? = null, + val screenKeysShown: Boolean = true, + val onScreenKeysShown: (Boolean) -> Unit = {}, val onFexPreset: (String) -> Unit, val onUpscaler: (Int) -> Unit = {}, val onUpscaleSharpness: (Int) -> Unit = {}, @@ -423,7 +431,10 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont ) { Text(a.title ?: if (a.steam) stringResource(R.string.drawer_steam) else stringResource(R.string.drawer_desktop), fontSize = if (short) 18.sp else 20.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) - if (a.onSteamMenu != null && a.onQam != null) { + if (a.pipSupported) SteamActionPill( + stringResource(R.string.pip_title), Icons.Outlined.PictureInPictureAlt, focus.track(page, "pip"), + ) { host.open = null; a.onPip() } + if (a.steam && a.onSteamMenu != null && a.onQam != null) { val qamInteraction = remember { MutableInteractionSource() } var qamStartedOnPress by remember { mutableStateOf(false) } LaunchedEffect(qamInteraction) { @@ -435,13 +446,13 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont } } } - SteamActionPill(stringResource(R.string.drawer_steam_menu), Icons.Outlined.Home, focus.track(page, "steam")) { + SteamActionPill(stringResource(R.string.drawer_steam_menu), Icons.Outlined.Home, focus.track(page, "steam"), shortcut = "⧉") { host.open = null a.onSteamMenu.invoke() } SteamActionPill( stringResource(R.string.drawer_open_qam), Icons.Outlined.MoreHoriz, focus.track(page, "qam"), - interactionSource = qamInteraction, + interactionSource = qamInteraction, shortcut = "☰", onConfirm = { host.open = null; a.onQam.invoke() }, ) { host.open = null @@ -549,6 +560,23 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont ActionRow(stringResource(R.string.gesture_settings), stringResource(R.string.gesture_settings_hint), stringResource(R.string.ctrl_edit), a.onGestureSettings, modifier = focus.track(page, "gesture-settings")) + if (a.onScreenKeys != null) { + SettingsRow(stringResource(R.string.screen_keys), null) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + SecondaryButton(stringResource(R.string.ctrl_edit), + modifier = focus.track(page, "screen-keys"), onClick = a.onScreenKeys) + a.onScreenLayouts?.let { open -> + SecondaryButton(stringResource(R.string.screen_key_layout), + modifier = focus.track(page, "screen-key-layouts"), onClick = open) + } + ToggleSwitch(a.screenKeysShown, true, + modifier = focus.track(page, "screen-keys-shown"), onChange = a.onScreenKeysShown) + } + } + } ChoiceRow(host, "osc", stringResource(R.string.mode_osc), null, if (a.steam) listOf(SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto), SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always), SessionPrefs.OSC_STEAM_TOUCH to stringResource(R.string.osc_steam_touch), SessionPrefs.OSC_STEAM_QAM to stringResource(R.string.mode_osc_qam), SessionPrefs.OSC_NEVER to stringResource(R.string.common_never)) else listOf(SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto), SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always), SessionPrefs.OSC_NEVER to stringResource(R.string.common_never)), @@ -613,11 +641,11 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont steamVr = { SteamVrRow(a) }) } SessionDrawerPage.GAMES -> { - if (a.steam) SelectedGameProfileGroup(host, a) { key -> focus.track(page, key) } + if (a.steam || a.direct) SelectedGameProfileGroup(host, a) { key -> focus.track(page, key) } Text(stringResource(R.string.fex_next_launch), fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp)) TextureFilteringGroup(host, a) { key -> focus.track(page, key) } - if (a.steam) SettingsGroup(stringResource(R.string.game_settings_title)) { + if (a.steam || a.direct) SettingsGroup(stringResource(R.string.game_settings_title)) { DrawerFexPresetRow(host, a, focus.track(page, "fex")) DrawerNoSteamRow(host, a, focus.track(page, "no-steam")) GameEnvironmentRow( @@ -669,6 +697,9 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont } } SettingsGroup(stringResource(R.string.drawer_session_behavior)) { + if (a.micAvailable) ToggleRow(host, "microphone", stringResource(R.string.mode_mic), + stringResource(R.string.drawer_mic_hint), a.micEnabled, + chipModifier = focus.track(page, "microphone"), onChange = a.onMicEnabled) ChoiceRow( host, "suspend", stringResource(R.string.mode_suspend), null, @@ -705,8 +736,6 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont a.pipAutoEnter, onChange = a.onPipAutoEnter) } Column(Modifier.fillMaxWidth().padding(top = 12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - if (a.pipSupported) DrawerActionButton(stringResource(R.string.pip_title), Icons.Outlined.PictureInPictureAlt, - modifier = focus.track(page, "pip")) { host.open = null; a.onPip() } if (!a.isHomeApp) DrawerActionButton(stringResource(R.string.drawer_background), Icons.AutoMirrored.Outlined.ExitToApp, modifier = focus.track(page, "background")) { host.open = null; a.onBackground() } } @@ -812,6 +841,7 @@ private fun SteamActionPill( description: String, icon: androidx.compose.ui.graphics.vector.ImageVector, modifier: Modifier, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, onConfirm: (() -> Unit)? = null, + shortcut: String? = null, onClick: () -> Unit, ) { val colors = MaterialTheme.colorScheme @@ -820,7 +850,7 @@ private fun SteamActionPill( val shape = RoundedCornerShape(22.dp) Box( contentAlignment = Alignment.Center, - modifier = modifier.size(width = 52.dp, height = 44.dp).semantics { contentDescription = description } + modifier = modifier.size(44.dp).semantics { contentDescription = description } .clip(shape).background(if (hot) pal.signal.copy(alpha = 0.14f) else Color.Transparent) .glideBorder(hot, shape, pal.signal, pal.line) .hoverable(interactionSource) @@ -828,6 +858,10 @@ private fun SteamActionPill( .controllerConfirm(onClick = onConfirm ?: onClick), ) { Icon(icon, contentDescription = null, tint = if (hot) pal.signal else colors.onBackground, modifier = Modifier.size(20.dp)) + if (shortcut != null) Text( + shortcut, fontSize = 8.sp, lineHeight = 9.sp, color = colors.onSurfaceVariant, + modifier = Modifier.align(Alignment.BottomEnd).padding(end = 8.dp, bottom = 6.dp), + ) } } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionPausedOverlay.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionPausedOverlay.kt new file mode 100644 index 000000000..b7f5cbfeb --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionPausedOverlay.kt @@ -0,0 +1,276 @@ +package com.droiddeck.launcher.ui + +import android.os.Build +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.FastOutLinearInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.keyframes +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.ColorMatrix +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.rotate +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import com.droiddeck.launcher.R +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull + +/** The last game frame, captured as the session left the screen, and where it sat in the overlay. */ +class PausedFrame(val image: ImageBitmap, val bounds: android.graphics.Rect) + +private const val CARD_SCALE = 0.82f +private const val FRAME_WAIT_MS = 600L + +private fun tone(saturation: Float, brightness: Float): ColorMatrix = + ColorMatrix().apply { + setToSaturation(saturation) + timesAssign(ColorMatrix().apply { setToScale(brightness, brightness, brightness, 1f) }) + } + +@Composable +fun SessionPausedOverlay( + suspended: Boolean, + engaged: Boolean, + failures: Int, + frame: PausedFrame?, + title: String, + onResume: () -> Unit, + onFrameNeeded: () -> Unit, + onHidden: () -> Unit, +) { + val currentFrame by rememberUpdatedState(frame) + var shown by remember { mutableStateOf(suspended) } + val lift = remember { Animatable(if (suspended) 1f else 0f) } + val orb = remember { Animatable(if (suspended) 1f else 0f) } + val press = remember { Animatable(1f) } + val ring = remember { Animatable(0f) } + val shake = remember { Animatable(0f) } + + LaunchedEffect(suspended, engaged) { + when { + suspended && !engaged -> { + if (!shown) { + if (currentFrame == null) { + onFrameNeeded() + withTimeoutOrNull(FRAME_WAIT_MS) { snapshotFlow { currentFrame }.first { it != null } } + } + // Pausing mostly happens while the screen is off or the app is away, so it appears already settled. + press.snapTo(1f) + ring.snapTo(0f) + lift.snapTo(1f) + orb.snapTo(1f) + shown = true + return@LaunchedEffect + } + launch { press.animateTo(1f, Motion.sp(0.5f, Spring.StiffnessMedium)) } + launch { ring.animateTo(0f, Motion.tw(200)) } + orb.animateTo(1f, Motion.tw(200)) + } + suspended -> { + if (!shown) return@LaunchedEffect + launch { orb.animateTo(1f, Motion.tw(200)) } + launch { press.animateTo(0.92f, Motion.tw(140)) } + ring.animateTo(0.25f, Motion.tw(140)) + ring.animateTo(0.8f, Motion.tw(650, easing = CubicBezierEasing(0.1f, 0.6f, 0.3f, 1f))) + ring.animateTo(0.92f, Motion.tw(2000, easing = LinearOutSlowInEasing)) + } + shown -> { + ring.animateTo(1f, Motion.tw(110)) + coroutineScope { + launch { orb.animateTo(0f, Motion.tw(220, easing = FastOutLinearInEasing)) } + lift.animateTo(0f, Motion.sp(0.62f, 380f)) + } + shown = false + ring.snapTo(0f) + press.snapTo(1f) + onHidden() + } + } + } + + val seenFailures = remember { mutableIntStateOf(failures) } + LaunchedEffect(failures) { + if (failures == seenFailures.intValue) return@LaunchedEffect + seenFailures.intValue = failures + if (!shown || Motion.scale == 0f) return@LaunchedEffect + shake.animateTo(0f, keyframes { + durationMillis = Motion.ms(360) + 10f at Motion.ms(60) + -8f at Motion.ms(140) + 5f at Motion.ms(220) + -2f at Motion.ms(300) + }) + } + + if (!shown) return + + val presence = lift.value.coerceIn(0f, 1f) + val idle = suspended && !engaged + val focus = remember { FocusRequester() } + LaunchedEffect(Unit) { + withFrameNanos { } + runCatching { focus.requestFocus() } + } + val resumeLabel = stringResource(R.string.resume_session) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxSize() + .semantics { contentDescription = title } + .focusRequester(focus) + .controllerConfirm(enabled = idle, onClick = onResume) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + role = Role.Button, + onClickLabel = resumeLabel, + ) { if (idle) onResume() }, + ) { + if (frame != null) { + PausedCard(frame, lift.value, presence) + } else { + Box(Modifier.fillMaxSize().graphicsLayer { alpha = presence }.background(Color.Black.copy(alpha = 0.55f))) + } + ResumeOrb(orb.value, press.value, ring.value, shake.value, idle) + } +} + +@Composable +private fun BoxScope.PausedCard(frame: PausedFrame, lift: Float, presence: Float) { + val density = LocalDensity.current + val bounds = frame.bounds + val ambient = remember { ColorFilter.colorMatrix(tone(0.1f, 0.3f)) } + Box(Modifier.fillMaxSize().graphicsLayer { alpha = presence }.background(Color.Black)) { + Image( + frame.image, contentDescription = null, contentScale = ContentScale.Crop, colorFilter = ambient, + modifier = Modifier.fillMaxSize().then(if (Build.VERSION.SDK_INT >= 31) Modifier.blur(32.dp) else Modifier), + ) + } + Image( + frame.image, contentDescription = null, contentScale = ContentScale.FillBounds, + colorFilter = ColorFilter.colorMatrix(tone(1f - 0.92f * presence, 1f - 0.45f * presence)), + modifier = Modifier + .align(Alignment.TopStart) + .offset { IntOffset(bounds.left, bounds.top) } + .size(with(density) { bounds.width().toDp() }, with(density) { bounds.height().toDp() }) + .graphicsLayer { + val scale = 1f - (1f - CARD_SCALE) * lift + scaleX = scale + scaleY = scale + shape = RoundedCornerShape((20f * presence).dp) + clip = true + shadowElevation = 24.dp.toPx() * presence + }, + ) +} + +@Composable +private fun ResumeOrb(presence: Float, press: Float, ring: Float, shake: Float, idle: Boolean) { + val glintAlpha by animateFloatAsState(if (idle) 1f else 0f, Motion.tw(160), label = "glintAlpha") + val glint: State<Float>? = if (Motion.scale > 0f) { + rememberInfiniteTransition(label = "glint").animateFloat( + 0f, 360f, infiniteRepeatable(tween(Motion.ms(3600), easing = LinearEasing)), label = "glintAngle", + ) + } else null + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(96.dp) + .graphicsLayer { + val scale = press * (0.4f + 0.6f * presence) + scaleX = scale + scaleY = scale + alpha = presence.coerceIn(0f, 1f) + translationX = shake.dp.toPx() + } + .drawBehind { + val halo = size.minDimension * 0.8f + drawCircle(Brush.radialGradient(0.6f to Color.Black.copy(alpha = 0.35f), 1f to Color.Transparent, radius = halo), radius = halo) + } + .clip(CircleShape) + .background(Color.White.copy(alpha = 0.08f)) + .drawWithContent { + drawContent() + val stroke = 2.5.dp.toPx() + val radius = size.minDimension / 2f * 0.92f + if (glint != null && glintAlpha > 0f) { + rotate(glint.value) { + drawCircle( + Brush.sweepGradient( + 0f to Color.Transparent, + 0.083f to Color.White.copy(alpha = 0.4f * glintAlpha), + 0.166f to Color.Transparent, + 1f to Color.Transparent, + ), + radius = radius, style = Stroke(stroke), + ) + } + } + if (ring > 0f) { + drawArc( + Color.White, startAngle = -90f, sweepAngle = 360f * ring.coerceAtMost(1f), useCenter = false, + topLeft = Offset(center.x - radius, center.y - radius), size = Size(radius * 2f, radius * 2f), + style = Stroke(stroke, cap = StrokeCap.Round), + ) + } + }, + ) { + Icon(Icons.Rounded.PlayArrow, contentDescription = null, tint = Color.White, modifier = Modifier.size(48.dp)) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SteamHome.kt b/app/src/main/java/com/droiddeck/launcher/ui/SteamHome.kt index dfb6d2525..888d87e23 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SteamHome.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SteamHome.kt @@ -5,12 +5,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import com.droiddeck.launcher.R import androidx.compose.ui.res.stringResource import androidx.compose.animation.core.FastOutSlowInEasing -import androidx.compose.animation.core.RepeatMode -import androidx.compose.runtime.State -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -76,6 +70,38 @@ private const val WALL_MIN_GAMES = 4 private const val WALL_DISTINCT = 40 private const val DRIFT_STEP_NS = 15_000_000L +private const val AMBIENT_IDLE_NS = 20_000_000_000L + +@Composable +internal fun ambientAwake(): Boolean { + val poked = Ambient.poked + var awake by remember { mutableStateOf(true) } + LaunchedEffect(poked) { + awake = true + kotlinx.coroutines.delay(AMBIENT_IDLE_NS / 1_000_000) + awake = false + } + return awake +} + +@Composable +internal fun AmbientFrames(pauseWhenIdle: Boolean = true, step: (Long) -> Unit) { + val onStep = androidx.compose.runtime.rememberUpdatedState(step) + val poked = if (pauseWhenIdle) Ambient.poked else 0L + if (Motion.scale != 0f) LaunchedEffect(poked, pauseWhenIdle) { + var shown = 0L + var end = Long.MAX_VALUE + while (shown < end) withFrameNanos { now -> + if (shown == 0L) { + shown = now + if (pauseWhenIdle) end = now + AMBIENT_IDLE_NS + } else if (now - shown >= DRIFT_STEP_NS) { + onStep.value(now - shown) + shown = now + } + } + } +} @Composable internal fun SteamHome(s: FrontEndState, a: FrontEndActions, modifier: Modifier) { @@ -179,6 +205,7 @@ internal fun CapsuleWall( modifier: Modifier = Modifier, owned: List<Pair<Int, java.io.File>> = emptyList(), featured: List<com.droiddeck.launcher.frontend.SteamFeatured.Game> = emptyList(), + pauseWhenIdle: Boolean = true, ) { val pal = LocalPalette.current val blank = remember(pal) { WallCapsule(Brush.linearGradient(listOf(pal.surface, pal.background)), edge = pal.line) } @@ -188,7 +215,7 @@ internal fun CapsuleWall( val store = if (owned.isEmpty()) featured.filter { shown.add(it.appId) }.map { it.name to it.art } else emptyList() (library + store).take((WALL_DISTINCT - games.size).coerceAtLeast(0)) } - TiltedWall(games.size + padding.size, driftMs, modifier) { i -> + TiltedWall(games.size + padding.size, driftMs, modifier, pauseWhenIdle = pauseWhenIdle) { i -> when { i < games.size -> WallCapsule(artBrush(hueOf(games[i].name)), games[i].art) i - games.size < padding.size -> padding[i - games.size].let { (seed, art) -> WallCapsule(artBrush(hueOf(seed)), art) } @@ -208,20 +235,13 @@ internal fun TiltedWall( capH: androidx.compose.ui.unit.Dp = 168.dp, gap: androidx.compose.ui.unit.Dp = 14.dp, corner: androidx.compose.ui.unit.Dp = 10.dp, + pauseWhenIdle: Boolean = true, capsule: (index: Int) -> WallCapsule, ) { val still = Motion.scale == 0f val drift = remember { mutableFloatStateOf(0f) } - val period = androidx.compose.runtime.rememberUpdatedState(driftMs) - if (!still) LaunchedEffect(Unit) { - var shown = 0L - while (true) withFrameNanos { now -> - if (shown == 0L) shown = now - else if (now - shown >= DRIFT_STEP_NS) { - drift.floatValue = ((drift.floatValue + (now - shown) / (period.value * 1_000_000.0 * Motion.scale)) % 1.0).toFloat() - shown = now - } - } + AmbientFrames(pauseWhenIdle) { dt -> + drift.floatValue = ((drift.floatValue + dt / (driftMs * 1_000_000.0 * Motion.scale)) % 1.0).toFloat() } val density = LocalDensity.current BoxWithConstraints(modifier.fillMaxSize()) { @@ -307,10 +327,9 @@ private fun DrawScope.drawCapsule(look: WallCapsule, art: Painter?, radius: Floa private fun CapsuleFan(games: List<Library.SteamGame>) { val pal = LocalPalette.current val density = LocalDensity.current - val transition = rememberInfiniteTransition(label = "fan") - val floats = listOf(9_000, 11_000, 13_000).map { ms -> - transition.animateFloat(0f, 1f, infiniteRepeatable(tween(ms, easing = FastOutSlowInEasing), RepeatMode.Reverse), label = "float") - } + val clock = remember { androidx.compose.runtime.mutableLongStateOf(0L) } + AmbientFrames { clock.longValue += it } + val floatMs = listOf(9_000, 11_000, 13_000) val still = Motion.scale == 0f val slots = listOf( FanSlot(0.54f, 0.14f, 0.62f, -9f), @@ -331,7 +350,7 @@ private fun CapsuleFan(games: List<Library.SteamGame>) { for (i in games.indices.reversed()) { val slot = slots[i] val h = maxHeight * slot.height - val v: State<Float> = floats[i] + val ms = floatMs[i] val look = WallCapsule(artBrush(hueOf(games[i].name)), games[i].art) val art = look.art?.let { rememberCapsuleArt(it, with(density) { Size((h * 2f / 3f).toPx(), h.toPx()) }, radius) } Spacer( @@ -339,7 +358,10 @@ private fun CapsuleFan(games: List<Library.SteamGame>) { .offset(x = maxWidth * slot.x, y = maxHeight * slot.y) .size(h * 2f / 3f, h) .graphicsLayer { - val t = if (still) 0f else v.value + val t = if (still) 0f else { + val phase = (clock.longValue / (ms * 1_000_000.0 * Motion.scale)) % 2.0 + FastOutSlowInEasing.transform((if (phase < 1.0) phase else 2.0 - phase).toFloat()) + } rotationZ = slot.lean + 2f * t translationY = -12.dp.toPx() * t shadowElevation = 24.dp.toPx() diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoragePage.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoragePage.kt index 8b1ec5340..e820ed8ee 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoragePage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoragePage.kt @@ -1,5 +1,9 @@ package com.droiddeck.launcher.ui +import android.app.Activity +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.LocalIndication @@ -51,6 +55,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable @@ -65,6 +70,7 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role @@ -74,6 +80,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil.compose.AsyncImage import com.droiddeck.launcher.R +import com.droiddeck.launcher.files.InAppFilePicker import com.droiddeck.launcher.session.SessionPrefs import com.droiddeck.launcher.storage.Bucket import com.droiddeck.launcher.storage.CleanItem @@ -84,6 +91,8 @@ import com.droiddeck.launcher.storage.StorageState import com.droiddeck.launcher.storage.ToolReason import com.droiddeck.launcher.storage.ToolState import com.droiddeck.launcher.storage.ToolUsage +import com.droiddeck.launcher.stores.StoreInstallRoot +import java.io.File import java.text.DateFormat import java.util.Date @@ -144,6 +153,113 @@ internal fun StoragePanel(s: FrontEndState, a: FrontEndActions) { } } +@Composable +private fun DefaultStorageGroup() { + val ctx = LocalContext.current + var value by remember { mutableStateOf(SessionPrefs.defaultStorage(ctx)) } + var open by remember { mutableStateOf(false) } + var revision by remember { mutableIntStateOf(0) } + val customPath = SessionPrefs.defaultStorageFolder(ctx) + val customTarget = remember(customPath, revision) { StoreInstallRoot.customTarget(ctx) } + val detected = remember(customPath, revision) { StoreInstallRoot.detectedExternalTarget(ctx) } + val customLabel = SessionPrefs.defaultStorageFolderLabel(ctx).ifBlank { + customPath.takeIf { it.isNotBlank() }?.let { File(it).name }.orEmpty() + } + val pickFolder = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult + val path = InAppFilePicker.pickedPath(result.data) ?: return@rememberLauncherForActivityResult + val problem = StoreInstallRoot.prepareDefaultFolder(ctx, File(path)) + if (problem != null) { + Toast.makeText(ctx, ctx.getString(R.string.main_storage_not_usable, problem), Toast.LENGTH_LONG).show() + return@rememberLauncherForActivityResult + } + SessionPrefs.setDefaultStorageFolder(ctx, path, StoreInstallRoot.folderLabel(ctx, File(path))) + SessionPrefs.setDefaultStorage(ctx, SessionPrefs.DEFAULT_STORAGE_EXTERNAL) + value = SessionPrefs.DEFAULT_STORAGE_EXTERNAL + revision++ + open = false + } + fun chooseFolder() { + open = false + val initial = customPath.takeIf { it.isNotBlank() && File(it).isDirectory } + ?: detected?.root?.parentFile?.takeIf { it.isDirectory }?.absolutePath + pickFolder.launch(InAppFilePicker.buildDirIntent(ctx, ctx.getString(R.string.storage_default_choose_folder), initial)) + } + val title = stringResource(R.string.storage_default_title) + val chip = when (value) { + SessionPrefs.DEFAULT_STORAGE_INTERNAL -> stringResource(R.string.storage_default_internal) + SessionPrefs.DEFAULT_STORAGE_EXTERNAL -> when { + customPath.isNotBlank() -> customTarget?.label + ?: "$customLabel · ${stringResource(R.string.storage_default_folder_missing)}" + detected != null -> detected.label + else -> stringResource(R.string.storage_default_external) + } + else -> stringResource(R.string.storage_default_ask) + } + fun pick(next: String) { + SessionPrefs.setDefaultStorage(ctx, next) + value = next + open = false + } + SettingsGroup(stringResource(R.string.storage_default_group)) { + SettingsRow(label = title, hint = stringResource(R.string.storage_default_hint)) { + Box { + ValueChip(chip, open, modifier = Modifier.testTag("setting-default-storage")) { open = !open } + AnchoredMenu( + open, onDismiss = { open = false }, title = title, + note = stringResource(R.string.storage_default_note), + ) { firstItemFocus -> + MenuItem( + stringResource(R.string.storage_default_ask), value == SessionPrefs.DEFAULT_STORAGE_ASK, + detail = stringResource(R.string.storage_default_ask_detail), + focusRequester = firstItemFocus, + ) { pick(SessionPrefs.DEFAULT_STORAGE_ASK) } + MenuItem( + stringResource(R.string.storage_default_internal), value == SessionPrefs.DEFAULT_STORAGE_INTERNAL, + ) { pick(SessionPrefs.DEFAULT_STORAGE_INTERNAL) } + if (detected == null && customPath.isBlank()) { + MenuItem( + stringResource(R.string.storage_default_external), value == SessionPrefs.DEFAULT_STORAGE_EXTERNAL, + detail = stringResource(R.string.storage_default_choose_folder_detail), + ) { chooseFolder() } + } else { + if (detected != null) { + MenuItem( + stringResource(R.string.storage_default_external), + value == SessionPrefs.DEFAULT_STORAGE_EXTERNAL && customPath.isBlank(), + detail = detected.let { target -> + val free = target.freeBytes + if (free >= 0) stringResource(R.string.storage_default_external_detail, target.label, gb(free)) + else target.label + }, + ) { + SessionPrefs.clearDefaultStorageFolder(ctx) + pick(SessionPrefs.DEFAULT_STORAGE_EXTERNAL) + revision++ + } + } + if (customPath.isNotBlank()) { + MenuItem( + stringResource(R.string.storage_default_folder, customLabel), + value == SessionPrefs.DEFAULT_STORAGE_EXTERNAL, + detail = customTarget?.let { target -> + val free = target.freeBytes + if (free >= 0) stringResource(R.string.storage_default_folder_free, gb(free)) + else stringResource(R.string.storage_default_folder_missing) + } ?: stringResource(R.string.storage_default_folder_missing), + ) { if (customTarget != null) pick(SessionPrefs.DEFAULT_STORAGE_EXTERNAL) else chooseFolder() } + } + MenuItem( + stringResource(R.string.storage_default_choose_folder), checked = false, + detail = stringResource(R.string.storage_default_choose_folder_detail), + ) { chooseFolder() } + } + } + } + } + } +} + @OptIn(ExperimentalLayoutApi::class) @Composable private fun Overview(report: StorageReport, sessionRunning: Boolean, onCleanUp: () -> Unit, onOpen: (String) -> Unit) { @@ -189,6 +305,7 @@ private fun Overview(report: StorageReport, sessionRunning: Boolean, onCleanUp: Spacer(Modifier.height(8.dp)) Note(stringResource(R.string.storage_session_running)) } + DefaultStorageGroup() SectionTitle(stringResource(R.string.storage_where), null) val unused = report.unusedTools.sumOf { it.bytes } val tiles = listOf<@Composable (Modifier) -> Unit>( diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoreAchievementsUi.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoreAchievementsUi.kt index 1492362f1..e2ab2a77f 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoreAchievementsUi.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoreAchievementsUi.kt @@ -270,6 +270,23 @@ internal fun StoreAchievementsCardContent(label: String, state: AchievementsStat } } +@Composable +internal fun AchievementsTab(label: String, state: AchievementsState, modifier: Modifier = Modifier) { + val colors = MaterialTheme.colorScheme + val list = state.list + Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier.fillMaxWidth().tabPanel()) { + if (list.isNullOrEmpty()) { + Text(achievementsNote(state, label).orEmpty(), fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.readable().padding(4.dp)) + return@Column + } + Box(Modifier.fillMaxWidth().readable().padding(4.dp)) { AchievementsProgress(state) } + for ((group, items) in groups(list)) { + AchievementGroupLabel(stringResource(group, items.size)) + for (a in items) androidx.compose.runtime.key(a.id) { AchievementRow(a, compact = true) } + } + } +} + private fun LazyListScope.achievementItems(list: List<Achievement>, compact: Boolean, first: FocusRequester, labels: Boolean = true) { var index = 0 for ((label, group) in groups(list)) { diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoreChips.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoreChips.kt index e913dc6af..40bfd22c4 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoreChips.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoreChips.kt @@ -31,6 +31,7 @@ internal fun sourceColours(source: String): SourceColours = when (source) { Store.AMAZON.id -> SourceColours(Color(0xFF3A2A0A), Color(0xFFFFC266), Color(0xFFFF9900)) Store.XBOX.id -> SourceColours(Color(0xFF17381B), Color(0xFFC6EDC2), Color(0xFF70C850)) Store.ITCH.id -> SourceColours(Color(0xFF3A1A1E), Color(0xFFFFB3B3), Color(0xFFFF5C5C)) + Store.BATTLENET.id -> SourceColours(Color(0xFF0A2440), Color(0xFF9DD1FF), Color(0xFF148EFF)) Library.SOURCE_STEAM -> SourceColours(Color(0xFF16293D), Color(0xFF7CC4FF), Color(0xFF1A9FFF)) FLATHUB -> SourceColours(Color(0xFF0B2E2B), Color(0xFF9FE3DD), FlathubTeal) else -> SourceColours(Color(0xFF1A1D22), Color(0xFF9AA3AF), Color(0xFF9AA3AF)) @@ -44,6 +45,7 @@ internal fun sourceLabel(source: String): String = when (source) { Store.AMAZON.id -> Store.AMAZON.label Store.XBOX.id -> Store.XBOX.label Store.ITCH.id -> Store.ITCH.label + Store.BATTLENET.id -> Store.BATTLENET.label else -> stringResource(R.string.source_added) } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoresDetail.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoresDetail.kt deleted file mode 100644 index b377f02ec..000000000 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoresDetail.kt +++ /dev/null @@ -1,292 +0,0 @@ -package com.droiddeck.launcher.ui - -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.layout.boundsInRoot -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.foundation.background -import androidx.compose.foundation.focusable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.gestures.animateScrollBy -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.input.key.onPreviewKeyEvent -import kotlinx.coroutines.launch -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import coil.compose.AsyncImage -import com.droiddeck.launcher.R -import com.droiddeck.launcher.stores.Store -import com.droiddeck.launcher.stores.StoreAchievements -import com.droiddeck.launcher.stores.StoresState -import com.droiddeck.launcher.stores.download.DownloadState -import com.droiddeck.launcher.stores.formatBytes - - -@Composable -internal fun StoreGameDetail(store: Store, key: String, s: FrontEndState, a: FrontEndActions, lead: androidx.compose.ui.focus.FocusRequester, onBack: () -> Unit) { - val leadOn = Modifier.focusRequester(lead) - val ctx = LocalContext.current - val colors = MaterialTheme.colorScheme - val pal = LocalPalette.current - val narrow = LocalNarrowPane.current - val stacked = narrow || LocalConfiguration.current.screenWidthDp < 700 - val id = key.substringAfter(':') - val item = StoresState.detailItem(store, id) - val installed = StoresState.installedGame(store, id) - val rawDownload = StoresState.download("${store.id}:$id") - val download = rawDownload?.takeIf { it.isActive } - val failedDownload = rawDownload?.takeIf { it.state == DownloadState.FAILED } - val title = item?.title ?: installed?.sidecar?.title ?: id - Rise(0) { - Box(Modifier.padding(bottom = 12.dp)) { BackLink(stringResource(R.string.common_back), compact = true, onClick = onBack) } - } - val scores = if (store == Store.GOG || store == Store.EPIC) rememberGameScores(title.takeIf { it != id }) else null - val hero: @Composable () -> Unit = { - androidx.compose.runtime.DisposableEffect(Unit) { onDispose { StoresMotion.heroAt = null } } - Box( - Modifier.fillMaxWidth().heightIn(min = if (narrow) 150.dp else 180.dp) - .onGloballyPositioned { StoresMotion.heroAt = it.boundsInRoot() } - .clip(Shape16).background(artBrush(hueOf(title))), - ) { - val art = item?.imageUrl ?: installed?.sidecar?.hero ?: installed?.sidecar?.cover - // The card it came from keeps its own bitmap under the hero until the wide art is decoded. - val heroArt = cardArtRequest(art, item?.tallImageUrl) - if (heroArt != null) AsyncImage(model = heroArt, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize()) - Spacer(Modifier.matchParentSize().background(Brush.horizontalGradient(0f to colors.background.copy(alpha = 0.94f), 0.5f to colors.background.copy(alpha = 0.55f), 1f to Color.Transparent))) - if (scores != null && !stacked) { - Spacer(Modifier.matchParentSize().background(Brush.horizontalGradient(0.45f to Color.Transparent, 1f to colors.background.copy(alpha = 0.75f)))) - HeroScores(scores, Modifier.align(Alignment.BottomEnd).padding(end = 16.dp, bottom = 18.dp)) - } - Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.align(Alignment.BottomStart).padding(horizontal = 22.dp, vertical = 18.dp)) { - val passName = item?.includedWith.orEmpty() - val state = when { - installed != null -> stringResource(R.string.stores_eyebrow_installed) + " · " + com.droiddeck.launcher.stores.StoreInstallRoot.labelFor(ctx, installed.folder) - download != null && download.location.isNotBlank() -> stringResource(R.string.stores_eyebrow_library) + " · " + download.location - failedDownload != null && failedDownload.location.isNotBlank() -> stringResource(R.string.stores_eyebrow_library) + " · " + failedDownload.location - item?.owned == true -> stringResource(R.string.stores_eyebrow_library) - item?.subscription == true -> passName.ifBlank { "PC Game Pass" } - passName.isNotBlank() -> "Available with $passName" - item?.accessible == true -> stringResource(R.string.stores_eyebrow_library) - else -> stringResource(R.string.stores_eyebrow_store) - } - val size = (item?.sizeBytes ?: 0L).takeIf { it > 0 }?.let { " · " + formatBytes(it) } ?: "" - Text((state + size).uppercase(), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.2.sp, color = pal.signal) - Text(title, fontSize = if (narrow) 24.sp else 30.sp, lineHeight = if (narrow) 27.sp else 33.sp, fontWeight = FontWeight.Black, color = colors.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis) - if (!failedDownload?.error.isNullOrBlank()) { - Text( - failedDownload!!.error!!, - fontSize = 13.sp, - lineHeight = 17.sp, - fontWeight = FontWeight.Medium, - color = colors.error, - maxLines = 3, - overflow = TextOverflow.Ellipsis - ) - } - Actions { - when { - installed != null -> { - val gone = StoresState.uninstalling["${store.id}:$id"] - PrimaryButton(stringResource(R.string.stores_play), enabled = !s.busy && gone == null && download == null && (store != Store.XBOX || !com.droiddeck.launcher.stores.xbox.XboxContent.busy(id)), main = true, icon = Icons.Filled.PlayArrow, modifier = leadOn) { - launchStoreGame(ctx, store, id, s, a) - } - ActionChip(stringResource(R.string.stores_in_steam), ok = true) - if (store == Store.XBOX) { - SecondaryButton("Update / verify", enabled = download == null && !com.droiddeck.launcher.stores.xbox.XboxRuntime.isActive(id)) { - runCatching { com.droiddeck.launcher.stores.xbox.XboxBackend.update(ctx, installed) } - .onFailure { StoresState.problems[Store.XBOX] = it.message ?: "Close this Xbox game before updating it." } - } - SecondaryButton("Manage content", enabled = download == null) { - com.droiddeck.launcher.stores.xbox.XboxContent.manage(ctx, installed) - } - } - if (gone != null) SecondaryButton(stringResource(R.string.stores_uninstalling), enabled = false, progress = gone) {} - else UninstallButton(title, installed, enabled = download == null && (store != Store.XBOX || !com.droiddeck.launcher.stores.xbox.XboxContent.busy(id))) { - StoresState.uninstall(ctx, installed) { StoresState.refresh(ctx); a.onLibraryChanged() } - } - } - download != null -> { - ActionChip(downloadLabel(download), ok = false) - if (download.state != DownloadState.PAUSED) SecondaryButton(stringResource(R.string.stores_dl_pause), modifier = leadOn) { com.droiddeck.launcher.stores.download.DownloadQueue.pause(download.key) } - else PrimaryButton(stringResource(R.string.stores_dl_resume), main = true, modifier = leadOn) { com.droiddeck.launcher.stores.download.DownloadQueue.resume(ctx, download.key) } - ConfirmButton(stringResource(R.string.stores_dl_cancel), stringResource(R.string.stores_dl_cancel_confirm)) { com.droiddeck.launcher.stores.download.DownloadQueue.cancel(ctx, download.key) } - } - failedDownload != null -> { - ActionChip(stringResource(R.string.stores_dl_failed), ok = false) - PrimaryButton(stringResource(R.string.stores_dl_resume), main = true, modifier = leadOn) { - if (item != null) StoresState.requestInstall(ctx, item) - else com.droiddeck.launcher.stores.download.DownloadQueue.retry(ctx, failedDownload.key) - } - ConfirmButton(stringResource(R.string.stores_dl_clear), stringResource(R.string.stores_dl_cancel_confirm), compact = true) { - com.droiddeck.launcher.stores.download.DownloadQueue.clear(ctx, failedDownload.key) - } - } - item?.installable == true && StoresState.isUnfinished(item) -> { - InstallButton(stringResource(R.string.stores_resume_install), leadOn) { StoresState.requestInstall(ctx, item) } - ConfirmButton(stringResource(R.string.stores_dl_clear), stringResource(R.string.stores_dl_cancel_confirm), compact = true) { StoresState.clearUnfinished(ctx, item) } - } - item?.installable == true -> { - InstallButton( - if (item.sizeBytes > 0) stringResource(R.string.stores_install_size, formatBytes(item.sizeBytes)) else stringResource(R.string.stores_install), - leadOn, - ) { StoresState.requestInstall(ctx, item) } - if (!item.accessible) { - SecondaryButton( - when { - item.isFree -> stringResource(R.string.stores_get_free) - item.hasPrice && item.finalPrice.isNotBlank() -> stringResource(R.string.stores_buy_price, item.finalPrice) - else -> stringResource(R.string.stores_view_on, store.shortLabel) - }, - ) { openStoreUrl(ctx, item) } - } - } - item != null && item.isFree -> PrimaryButton(stringResource(R.string.stores_get_free), main = true, modifier = leadOn) { openStoreUrl(ctx, item) } - item != null -> PrimaryButton(if (item.hasPrice && item.finalPrice.isNotBlank()) stringResource(R.string.stores_buy_price, item.finalPrice) else stringResource(R.string.stores_view_on, store.shortLabel), main = true, modifier = leadOn) { openStoreUrl(ctx, item) } - } - BusyChip(s) - } - } - } - } - if (StoresMotion.heroLands) hero() else Rise(1) { hero() } - if (scores != null) Rise(2) { - val listed = (StoresState.found[store].orEmpty() + StoresState.shelves[store]?.all.orEmpty()).firstOrNull { it.id == id } - val developer = item?.developer?.ifBlank { null } ?: listed?.developer.orEmpty() - val publisher = item?.publisher?.ifBlank { null } ?: listed?.publisher.orEmpty() - val releaseDate = item?.releaseDate?.ifBlank { null } ?: listed?.releaseDate.orEmpty() - StoreGameFacts(developer, publisher, releaseDate, scores, scoresInHero = !stacked) - } - val description = item?.let { com.droiddeck.launcher.stores.cleanStoreText(it.description) }.orEmpty() - .takeUnless { it.trim().equals(title.trim(), ignoreCase = true) }.orEmpty() - val achievements = remember(store, id, item?.accessible, installed) { - when { - StoreAchievements.forSource(store.id) == null -> null - item?.accessible == true -> StoreAchievements.Game.of(item) - installed != null -> StoreAchievements.Game.of(installed.sidecar) - else -> null - } - } - if (description.isNotBlank() || achievements != null) Rise(3) { - val about: @Composable (Modifier, Dp) -> Unit = { m, h -> if (description.isNotBlank()) StoreAbout(description, h, m) } - val card: @Composable (Modifier) -> Unit = { m -> if (achievements != null) StoreAchievementsCard(achievements, title, m) } - if (stacked) Column(verticalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.fillMaxWidth().padding(top = 14.dp)) { - about(Modifier.fillMaxWidth(), AboutHeight) - card(Modifier.fillMaxWidth()) - } else Row(horizontalArrangement = Arrangement.spacedBy(24.dp), modifier = Modifier.fillMaxWidth().padding(top = 14.dp)) { - val density = LocalDensity.current - var cardHeight by remember { mutableStateOf<Dp?>(null) } - about(Modifier.weight(1f), cardHeight ?: AboutHeight) - card(Modifier.width(340.dp).onSizeChanged { cardHeight = with(density) { it.height.toDp() } }) - } - } -} - -private val AboutHeight = 150.dp - -@Composable -private fun StoreAbout(text: String, height: Dp, modifier: Modifier = Modifier) { - val colors = MaterialTheme.colorScheme - val pal = LocalPalette.current - val scroll = rememberScrollState() - val scope = rememberCoroutineScope() - val src = remember { MutableInteractionSource() } - val more = scroll.maxValue > 0 - val hot = more && rememberHot(src) - val shape = RoundedCornerShape(12.dp) - Box( - modifier.heightIn(max = height).clip(shape) - .glideBorder(hot, shape, pal.signal, restColor = Color.Transparent) - .then(if (more) Modifier.paneItem("about") else Modifier) - .focusable(enabled = more, interactionSource = src) - .onPreviewKeyEvent { e -> - val step = when (e.nativeKeyEvent.keyCode) { - android.view.KeyEvent.KEYCODE_DPAD_DOWN -> if (scroll.value < scroll.maxValue) 120f else return@onPreviewKeyEvent false - android.view.KeyEvent.KEYCODE_DPAD_UP -> if (scroll.value > 0) -120f else return@onPreviewKeyEvent false - else -> return@onPreviewKeyEvent false - } - if (e.nativeKeyEvent.action == android.view.KeyEvent.ACTION_DOWN) scope.launch { scroll.animateScrollBy(step) } - true - }, - ) { - Text(text, fontSize = 14.sp, lineHeight = 21.sp, color = colors.onSurfaceVariant, modifier = Modifier.verticalScroll(scroll).padding(horizontal = 10.dp, vertical = 6.dp)) - if (scroll.value < scroll.maxValue) Box( - Modifier.align(Alignment.BottomCenter).fillMaxWidth().height(28.dp) - .background(Brush.verticalGradient(listOf(Color.Transparent, colors.background))), - ) - } -} - -@Composable -private fun UninstallButton(title: String, game: com.droiddeck.launcher.stores.InstalledStoreGame, enabled: Boolean, modifier: Modifier = Modifier, onUninstall: () -> Unit) { - val colors = MaterialTheme.colorScheme - val placed = remember { arrayOfNulls<androidx.compose.ui.layout.LayoutCoordinates>(1) } - val heading = stringResource(R.string.stores_uninstall_title, title) - val size = com.droiddeck.launcher.stores.StoreSizes.onDisk["${game.sidecar.store.id}:${game.sidecar.id}"] - val body = if (size != null && size > 0) stringResource(R.string.stores_uninstall_body_size, formatBytes(size)) else stringResource(R.string.stores_uninstall_body) - val keep = stringResource(R.string.stores_notification_keep) - val remove = stringResource(R.string.stores_uninstall) - Box(Modifier.onGloballyPositioned { placed[0] = it }) { - SecondaryButton(remove, enabled = enabled, modifier = modifier) { - val at = placed[0]?.takeIf { it.isAttached }?.boundsInRoot() ?: return@SecondaryButton - var confirmed = false - StoresMotion.ask(StepAsk(anchor = at, side = StepSide.Right, accent = colors.error, items = 3, pillCorner = 12.dp, maxWidth = 340.dp, - onDismiss = { if (confirmed) onUninstall() }, handle = { - Text(remove, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.error, maxLines = 1) - }) { - StepTitle(heading) - Text(body, fontSize = 13.sp, lineHeight = 18.sp, color = colors.onSurfaceVariant, modifier = Modifier.stepItem(1).padding(start = 4.dp, bottom = 12.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), modifier = Modifier.fillMaxWidth().stepItem(2)) { - StepChoice(keep, enabled = open, modifier = Modifier.focusRequester(first)) { StoresMotion.fold() } - StepChoice(remove, danger = true, enabled = open) { confirmed = true; StoresMotion.fold() } - } - }) - } - } -} - -@Composable -private fun InstallButton(label: String, modifier: Modifier = Modifier, onInstall: () -> Unit) { - val placed = remember { arrayOfNulls<androidx.compose.ui.layout.LayoutCoordinates>(1) } - Box(Modifier.onGloballyPositioned { placed[0] = it }) { - PrimaryButton(label, main = true, modifier = modifier) { - StoresMotion.markInstall(placed[0]?.takeIf { it.isAttached }?.boundsInRoot(), label) - onInstall() - } - } -} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoresDownloads.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoresDownloads.kt index 2bf7dcac0..e52185681 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoresDownloads.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresDownloads.kt @@ -221,7 +221,7 @@ private fun FinishedRow(d: DownloadEntry, s: FrontEndState, a: FrontEndActions, ArtStrip(d.cover, d.name) { StripLabel(d.name, d.store.id, stringResource(if (d.state == DownloadState.INSTALLED) R.string.stores_installed_chip else R.string.stores_dl_cancelled), Color.White.copy(alpha = 0.72f)) if (d.state == DownloadState.INSTALLED) Box(Modifier.bloomIn(bloom, GATHER_HOLD_MS + 150)) { - RoundAction(Icons.Filled.PlayArrow, stringResource(R.string.stores_play), primary = true) { if (d.key == EpicClient.DOWNLOAD_KEY) a.onEpicLauncher() else launchStoreGame(ctx, d.store, d.id, s, a) } + RoundAction(Icons.Filled.PlayArrow, stringResource(R.string.stores_play), primary = true) { if (d.key == EpicClient.DOWNLOAD_KEY) a.onEpicLauncher() else if (d.key == com.droiddeck.launcher.stores.battlenet.BattleNetClient.DOWNLOAD_KEY) a.onBattleNet() else launchStoreGame(ctx, d.store, d.id, s, a) } } } } @@ -372,7 +372,7 @@ private fun DownloadCard(d: DownloadEntry, s: FrontEndState, a: FrontEndActions) CancelAction { DownloadQueue.clear(ctx, d.key) } } DownloadState.INSTALLED -> Box(Modifier.bloomIn(true, 420)) { - RoundAction(Icons.Filled.PlayArrow, stringResource(R.string.stores_play), primary = true) { if (d.key == EpicClient.DOWNLOAD_KEY) a.onEpicLauncher() else launchStoreGame(ctx, d.store, d.id, s, a) } + RoundAction(Icons.Filled.PlayArrow, stringResource(R.string.stores_play), primary = true) { if (d.key == EpicClient.DOWNLOAD_KEY) a.onEpicLauncher() else if (d.key == com.droiddeck.launcher.stores.battlenet.BattleNetClient.DOWNLOAD_KEY) a.onBattleNet() else launchStoreGame(ctx, d.store, d.id, s, a) } } DownloadState.CANCELLED -> {} } @@ -498,16 +498,10 @@ internal fun storesSettingsStep(at: Rect, s: androidx.compose.runtime.State<Fron val st = s.value val context = androidx.compose.ui.platform.LocalContext.current SettingsRow(stringResource(R.string.stores_tabs_arrange), null) { - RoundAction(Icons.Filled.SwapHoriz, stringResource(R.string.stores_tabs_arrange), size = 36.dp) { + Box(Modifier.focusRequester(first).focusGroup()) { RoundAction(Icons.Filled.SwapHoriz, stringResource(R.string.stores_tabs_arrange), size = 36.dp) { StoresMotion.fold() onArrange() - } - } - SettingsRow(stringResource(R.string.setup_stores_open_on), null, stacked = true) { - Box(Modifier.focusRequester(first).focusGroup()) { SegmentedTabs( - listOf(SessionPrefs.STORES_OPEN_LIBRARY to stringResource(R.string.stores_tab_library), SessionPrefs.STORES_OPEN_STORE to stringResource(R.string.stores_tab_store)), - st.storesOpenTab, - ) { a.onStoresOpenTab(it) } } + } } } SettingsRow(stringResource(R.string.stores_show_mature), null) { ToggleSwitch(st.storesShowMature, label = stringResource(R.string.stores_show_mature)) { a.onStoresShowMature(it) } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt index 175cbecd8..23a996fb9 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt @@ -5,13 +5,9 @@ import androidx.compose.animation.SizeTransform import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleOut -import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.togetherWith -import androidx.compose.animation.core.Animatable import androidx.compose.foundation.gestures.detectDragGestures -import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material.icons.outlined.Folder import androidx.compose.material.icons.outlined.SdCard @@ -26,13 +22,13 @@ import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.luminance import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.zIndex +import androidx.compose.ui.platform.testTag import androidx.compose.ui.layout.boundsInParent import androidx.compose.ui.layout.boundsInRoot import androidx.compose.ui.layout.positionInRoot import com.droiddeck.launcher.stores.StoreInstallRoot import com.droiddeck.launcher.stores.StoreLanguage import com.droiddeck.launcher.stores.epic.EpicClient -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch import androidx.compose.ui.layout.boundsInWindow @@ -43,7 +39,6 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.SolidColor @@ -58,10 +53,11 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.hoverable import androidx.compose.foundation.selection.selectable -import androidx.compose.foundation.selection.selectableGroup import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Arrangement @@ -74,7 +70,6 @@ import androidx.compose.foundation.layout.Row import kotlin.math.roundToInt import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn @@ -93,17 +88,10 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.automirrored.filled.Login -import androidx.compose.material.icons.filled.GridView -import androidx.compose.material.icons.filled.Sort -import androidx.compose.material.icons.filled.ViewList -import androidx.compose.material.icons.filled.ViewModule -import com.droiddeck.launcher.stores.sortCatalogItems -import com.droiddeck.launcher.stores.retainCatalogOrder -import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.basicMarquee -import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.outlined.Download import androidx.compose.material3.Icon @@ -111,14 +99,12 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.SideEffect import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha @@ -128,6 +114,7 @@ import androidx.compose.ui.graphics.lerp import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription @@ -135,16 +122,15 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil.compose.AsyncImage import com.droiddeck.launcher.R -import com.droiddeck.launcher.frontend.Library import com.droiddeck.launcher.session.SessionPrefs import com.droiddeck.launcher.stores.CatalogItem +import com.droiddeck.launcher.stores.itch.ItchBrowseState import com.droiddeck.launcher.stores.Store import com.droiddeck.launcher.stores.StoreTab import com.droiddeck.launcher.stores.StoresState @@ -152,36 +138,28 @@ import com.droiddeck.launcher.stores.arrangeStoreTabs import com.droiddeck.launcher.stores.download.DownloadStage import com.droiddeck.launcher.stores.download.DownloadState import com.droiddeck.launcher.stores.formatBytes -import java.util.concurrent.CompletableFuture -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException -import kotlinx.coroutines.suspendCancellableCoroutine +import androidx.compose.runtime.setValue private const val DOWNLOADS = "downloads" -private val TABS = listOf("library", "store") - -internal fun tabsFor(store: Store?): List<String> = if (store == Store.AMAZON) TABS.take(1) else TABS - -internal fun tabFor(store: Store?, tab: String): String = if (tab in tabsFor(store)) tab else "library" - -private data class PaneKey(val chip: String, val tab: String, val signedIn: Boolean, val accountsLoaded: Boolean) +private data class PaneKey(val chip: String, val signedIn: Boolean, val accountsLoaded: Boolean) @OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class, androidx.compose.animation.ExperimentalAnimationApi::class) @Composable internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier) { val ctx = LocalContext.current + val focusManager = LocalFocusManager.current com.droiddeck.launcher.stores.xbox.XboxContent.plan?.let { content -> androidx.compose.material3.AlertDialog( onDismissRequest = { com.droiddeck.launcher.stores.xbox.XboxContent.dismiss() }, - title = { Text("Xbox content") }, + title = { Text(stringResource(R.string.stores_xbox_content_title)) }, text = { Column { if (content.error != null) Text(content.error) - else if (content.offers.isEmpty()) Text("No licensed PC content packages are available for this game.") + else if (content.offers.isEmpty()) Text(stringResource(R.string.stores_xbox_content_none)) content.offers.forEach { offer -> androidx.compose.material3.TextButton(onClick = { com.droiddeck.launcher.stores.xbox.XboxContent.install(ctx, offer.item) }) { - Text(offer.item.title + if (offer.installed) " · Update / verify" else " · Install") + Text(stringResource(if (offer.installed) R.string.stores_xbox_content_update else R.string.stores_xbox_content_install, offer.item.title)) } } } }, @@ -191,17 +169,16 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier com.droiddeck.launcher.stores.xbox.XboxBackend.packageChoice?.let { pending -> androidx.compose.material3.AlertDialog( onDismissRequest = { com.droiddeck.launcher.stores.xbox.XboxBackend.dismissPackages() }, - title = { Text("Choose PC content") }, + title = { Text(stringResource(R.string.stores_xbox_choose_content)) }, text = { Column { pending.choices.forEach { choice -> androidx.compose.material3.TextButton(onClick = { com.droiddeck.launcher.stores.xbox.XboxBackend.selectPackage(ctx, choice) }) { - Text(choice.getString("title") + " (" + choice.getString("kind") + ")") + Text(stringResource(R.string.stores_xbox_choice, choice.getString("title"), choice.getString("kind"))) } } } }, confirmButton = {}, dismissButton = { androidx.compose.material3.TextButton(onClick = { com.droiddeck.launcher.stores.xbox.XboxBackend.dismissPackages() }) { Text(stringResource(android.R.string.cancel)) } }, ) } - val colors = MaterialTheme.colorScheme val narrow = LocalNarrowPane.current val padH = if (narrow) 14.dp else 22.dp val tabs = remember { @@ -243,7 +220,6 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier val picked = Store.byId(chip) ?: return@LaunchedEffect if (tabs.none { it.store == picked && it.shown }) chip = tabs.firstOrNull { it.shown }?.store?.id ?: chip } - var tab by rememberSaveable { mutableStateOf(if (SessionPrefs.storesOpenTab(ctx) == SessionPrefs.STORES_OPEN_STORE) "store" else "library") } var query by rememberSaveable { mutableStateOf("") } var openGame by rememberSaveable { mutableStateOf<String?>(null) } val liveState = androidx.compose.runtime.rememberUpdatedState(s) @@ -252,9 +228,8 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier LaunchedEffect(tabs.toList()) { if (chip != DOWNLOADS && Store.byId(chip) !in shownStores) chip = shownStores.firstOrNull()?.id ?: DOWNLOADS } - val shownTab = tabFor(store, tab) val pane = PaneKey( - chip, if (store == null) "" else shownTab, + chip, store == null || (StoresState.isSignedIn(store) && StoresState.expired[store] != true), store == null || StoresState.accountsLoaded, ) @@ -263,11 +238,17 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier LaunchedEffect(Unit) { StoresState.refresh(ctx) } val lifecycle = androidx.compose.ui.platform.LocalLifecycleOwner.current.lifecycle LaunchedEffect(chip) { + if (chip == Store.BATTLENET.id) { + while (true) { + if (lifecycle.currentState.isAtLeast(androidx.lifecycle.Lifecycle.State.RESUMED)) com.droiddeck.launcher.stores.battlenet.BattleNetClient.refresh(ctx) + delay(5_000) + } + } if (chip == Store.EPIC.id) { EpicClient.ensure(ctx) while (true) { - delay(5_000) if (lifecycle.currentState.isAtLeast(androidx.lifecycle.Lifecycle.State.RESUMED)) EpicClient.refresh(ctx) + delay(5_000) } } } @@ -279,22 +260,22 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier val heroFocus = remember { androidx.compose.ui.focus.FocusRequester() } var detailFocused by remember { mutableStateOf(false) } fun move(kind: String) { focusMove = kind to (focusMove?.second ?: 0) + 1 } - fun open(key: String) { lastOpened = key; openGame = key; move("detail") } + fun open(key: String) { focusManager.clearFocus(force = true); lastOpened = key; openGame = key; move("detail") } fun closeGame() { openGame = null; move("card") } - fun switchTab(to: String) { tab = to; move("first") } val pickChip: (String) -> Unit = { id -> chip = id; openGame = null; query = "" - if (id != DOWNLOADS) tab = tabFor(Store.byId(id), if (s.storesOpenTab == SessionPrefs.STORES_OPEN_STORE) "store" else "library") move("first") } + LaunchedEffect(StoresState.openGameRequest) { + val key = StoresState.openGameRequest ?: return@LaunchedEffect + StoresState.openGameRequest = null + Store.byId(key.substringBefore(':'))?.let { store -> chip = store.id; query = ""; open(key) } + } var focusedChip by remember { mutableStateOf<String?>(null) } LaunchedEffect(focusedChip) { val id = focusedChip ?: return@LaunchedEffect delay(140) - if (id != chip) { - chip = id; openGame = null; query = "" - if (id != DOWNLOADS) tab = tabFor(Store.byId(id), if (s.storesOpenTab == SessionPrefs.STORES_OPEN_STORE) "store" else "library") - } + if (id != chip) { chip = id; openGame = null; query = "" } } val pal = LocalPalette.current fun cycleStore(step: Int) { @@ -319,8 +300,7 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier } val scroll = rememberScrollState() val detailScroll = rememberScrollState() - LaunchedEffect(chip, tab) { scroll.scrollTo(0); grid.scrollToItem(0) } - LaunchedEffect(openGame) { detailScroll.scrollTo(0) } + LaunchedEffect(chip) { scroll.scrollTo(0); grid.scrollToItem(0) } LaunchedEffect(focusMove, openGame) { if (inputMode.inputMode != androidx.compose.ui.input.InputMode.Keyboard || ff == null) return@LaunchedEffect var frames = 0 @@ -356,53 +336,21 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier } } - var detail by remember { mutableStateOf<String?>(null) } - var leaving by remember { mutableStateOf(false) } - var detailFrom by remember { mutableStateOf<Origin?>(null) } - var detailArt by remember { mutableStateOf<String?>(null) } - var viewportAt by remember { mutableStateOf(Offset.Zero) } - var viewportH by remember { mutableStateOf(0.dp) } - val density = androidx.compose.ui.platform.LocalDensity.current var hostAt by remember { mutableStateOf(Offset.Zero) } - LaunchedEffect(openGame) { - val g = openGame - if (g != null) { - val mark = StoresMotion.takeCard(g) - detailFrom = mark?.let { Origin(it.bounds.translate(-viewportAt), it.corner) } - detailArt = mark?.art - StoresMotion.heroLands = mark != null - leaving = false - detail = g - } else if (detail != null) { - if (detailFrom != null) { - leaving = true - delay(Motion.ms(PAGE_RETURN_MS).toLong()) - } - detail = null - leaving = false - detailFrom = null - } - } - val covering = detail != null && !leaving - val coveringNow = androidx.compose.runtime.rememberUpdatedState(covering) - val gridScale by animateFloatAsState(if (covering) 0.95f else 1f, if (covering) Motion.tw(480) else Motion.tw(460, 40), label = "gridSink") - val gridAlpha by animateFloatAsState(if (covering) 0f else 1f, if (covering) Motion.tw(320, 80) else Motion.tw(300, 100), label = "gridFade") - + val backLabel = stringResource(R.string.common_back) Box(modifier.onGloballyPositioned { hostAt = it.positionInRoot() }) { Column( modifier = Modifier.fillMaxSize().padding(horizontal = padH, vertical = if (narrow) 10.dp else 14.dp) - .bumpers( + .then(if (openGame != null && !arranging) Modifier else Modifier.bumpers( onPrevious = { val picked = Store.byId(focusedChip) - if (arranging) { if (picked != null) shiftTab(picked, -1) } - else if (openGame == null) cycleStore(-1) + if (arranging) { if (picked != null) shiftTab(picked, -1) } else cycleStore(-1) }, onNext = { val picked = Store.byId(focusedChip) - if (arranging) { if (picked != null) shiftTab(picked, 1) } - else if (openGame == null) cycleStore(1) + if (arranging) { if (picked != null) shiftTab(picked, 1) } else cycleStore(1) }, - ), + )), ) { Box(Modifier.fillMaxWidth().padding(bottom = 8.dp)) { StoreTheme(Store.byId(focusedChip ?: chip)) { @@ -420,103 +368,52 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier } } } - val fade = with(androidx.compose.ui.platform.LocalDensity.current) { 14.dp.toPx() } - Box( - Modifier.fillMaxWidth().weight(1f).clipToBounds() - .onGloballyPositioned { viewportAt = it.positionInRoot(); viewportH = with(density) { it.size.height.toDp() } } - .graphicsLayer { compositingStrategy = androidx.compose.ui.graphics.CompositingStrategy.Offscreen } - .drawWithContent { - drawContent() - val scrolled = if (detail != null) detailScroll.value > 0 - else if (store != null && pane.signedIn) grid.firstVisibleItemIndex > 0 || grid.firstVisibleItemScrollOffset > 0 - else scroll.value > 0 - if (scrolled && size.height > fade) drawRect( - Brush.verticalGradient(0f to Color.Transparent, fade / size.height to Color.Black), - blendMode = androidx.compose.ui.graphics.BlendMode.DstIn, - ) - }, - ) { - val gridFocus = if (covering) null else ff - CompositionLocalProvider(LocalFrontFocus provides gridFocus) { - Column( - modifier = Modifier.fillMaxSize() - .graphicsLayer { scaleX = gridScale; scaleY = gridScale; alpha = gridAlpha } - .focusProperties { enter = { if (coveringNow.value) androidx.compose.ui.focus.FocusRequester.Cancel else androidx.compose.ui.focus.FocusRequester.Default } } - .focusGroup(), - ) { - AnimatedContent( - targetState = pane, - modifier = Modifier.fillMaxSize(), - transitionSpec = { - val sameStore = initialState.chip == targetState.chip && initialState.signedIn == targetState.signedIn - (if (sameStore && initialState.tab != targetState.tab) { - val dir = if (TABS.indexOf(targetState.tab) > TABS.indexOf(initialState.tab)) 1 else -1 - (slideInHorizontally(Motion.sp(0.7f, Spring.StiffnessMediumLow)) { it / 6 * dir } + fadeIn(Motion.tw(220, 60))) - .togetherWith(slideOutHorizontally(Motion.tw(170)) { -it / 6 * dir } + fadeOut(Motion.tw(170)) + scaleOut(Motion.tw(170), targetScale = 0.97f)) - } else { - (fadeIn(Motion.tw(300, 80)) + slideInVertically(Motion.tw(420, 80)) { it / 24 }) - .togetherWith(fadeOut(Motion.tw(170)) + scaleOut(Motion.tw(170), targetScale = 0.97f)) - }).using(SizeTransform(clip = false)) - }, - label = "storesPane", - ) { k -> - val outgoing = transition.targetState == androidx.compose.animation.EnterExitState.PostExit - CompositionLocalProvider(LocalFrontFocus provides if (outgoing) null else LocalFrontFocus.current) { - val leavingNow = androidx.compose.runtime.rememberUpdatedState(outgoing) - val st = Store.byId(k.chip) - StoreTheme(st) { - Column( - Modifier.fillMaxSize() - .focusProperties { enter = { if (leavingNow.value) androidx.compose.ui.focus.FocusRequester.Cancel else androidx.compose.ui.focus.FocusRequester.Default } } - .focusGroup(), - ) { - when { - k.chip == DOWNLOADS -> Column(Modifier.fillMaxWidth().verticalScroll(scroll).padding(top = 4.dp, bottom = 16.dp)) { StoresDownloadsPane(s, a) } - st == null -> {} - !k.accountsLoaded -> Box(Modifier.fillMaxWidth().padding(top = 16.dp)) { - androidx.compose.material3.LinearProgressIndicator(Modifier.fillMaxWidth().height(3.dp)) - } - !k.signedIn -> Box(Modifier.fillMaxWidth().verticalScroll(scroll).padding(top = 4.dp, bottom = 16.dp)) { SignInCard(st, viewportH - 20.dp) } - else -> Storefront(st, k.tab, query, s, a, grids.getOrPut(k) { LazyGridState() }, onQuery = { query = it }, onOpen = { open(it) }, onTab = { switchTab(it) }) + var viewportH by remember { mutableStateOf(0.dp) } + val density = androidx.compose.ui.platform.LocalDensity.current + CardDetailHost( + openGame, detailScroll, + gridScrolled = { + if (store != null && pane.signedIn) grid.firstVisibleItemIndex > 0 || grid.firstVisibleItemScrollOffset > 0 + else scroll.value > 0 + }, + onDetailFocus = { detailFocused = it }, + modifier = Modifier.fillMaxWidth().weight(1f).onSizeChanged { viewportH = with(density) { it.height.toDp() } }, + wrap = { key, content -> StoreTheme(Store.byId(key.substringBefore(':')) ?: store) { content() } }, + grid = { + AnimatedContent( + targetState = pane, + modifier = Modifier.fillMaxSize(), + transitionSpec = { + ((fadeIn(Motion.tw(300, 80)) + slideInVertically(Motion.tw(420, 80)) { it / 24 }) + .togetherWith(fadeOut(Motion.tw(170)) + scaleOut(Motion.tw(170), targetScale = 0.97f))).using(SizeTransform(clip = false)) + }, + label = "storesPane", + ) { k -> + val outgoing = transition.targetState == androidx.compose.animation.EnterExitState.PostExit + CompositionLocalProvider(LocalFrontFocus provides if (outgoing) null else LocalFrontFocus.current) { + val leavingNow = androidx.compose.runtime.rememberUpdatedState(outgoing) + val st = Store.byId(k.chip) + StoreTheme(st) { + Column( + Modifier.fillMaxSize() + .focusProperties { enter = { if (leavingNow.value) androidx.compose.ui.focus.FocusRequester.Cancel else androidx.compose.ui.focus.FocusRequester.Default } } + .focusGroup(), + ) { + when { + k.chip == DOWNLOADS -> Column(Modifier.fillMaxWidth().verticalScroll(scroll).padding(top = 4.dp, bottom = 16.dp)) { StoresDownloadsPane(s, a) } + st == null -> {} + !k.accountsLoaded -> Box(Modifier.fillMaxWidth().padding(top = 16.dp)) { + androidx.compose.material3.LinearProgressIndicator(Modifier.fillMaxWidth().height(3.dp)) } - } + !k.signedIn -> Box(Modifier.fillMaxWidth().verticalScroll(scroll).padding(top = 4.dp, bottom = 16.dp)) { SignInCard(st, viewportH - 20.dp) } + else -> Storefront(st, query, s, a, grids.getOrPut(k) { LazyGridState() }, clearSearchFocus = openGame != null, onQuery = { query = it }, onOpen = { open(it) }) + } } - } + } } } - } - detail?.let { key -> - val detailStore = Store.byId(key.substringBefore(':')) ?: store - StoreTheme(detailStore) { - val body: @Composable () -> Unit = { - Column( - Modifier.fillMaxSize().pointerInput(Unit) { detectTapGestures { } } - .verticalScroll(detailScroll).padding(top = 4.dp, bottom = 16.dp), - ) { - if (detailStore != null) Column( - Modifier.fillMaxWidth() - .onFocusChanged { detailFocused = it.hasFocus } - .focusProperties { exit = { if (coveringNow.value) androidx.compose.ui.focus.FocusRequester.Cancel else androidx.compose.ui.focus.FocusRequester.Default } } - .focusGroup(), - ) { StoreGameDetail(detailStore, key, s, a, heroFocus, onBack = { closeGame() }) } - } - } - val from = detailFrom - if (from == null) body() - else { - val art = detailArt?.let { url -> - coil.compose.rememberAsyncImagePainter( - remember(url) { - coil.request.ImageRequest.Builder(ctx).data(url).size(1280, 720) - .placeholderMemoryCacheKey(url).build() - }, - ) - } - PageFlood(from, leaving, art, onto = { StoresMotion.heroAt }, ontoCorner = 16.dp) { body() } - } - } - } - } + }, + ) { key -> GameDetail(key, s, a, heroFocus, backLabel, hopRail = null, onBack = { closeGame() }) } } FlightLayer(hostAt) StoresMotion.step?.let { ask -> @@ -538,11 +435,10 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier seenActive.clear(); seenActive.addAll(activeKeys) if (fresh.isNotEmpty()) StoresMotion.takeInstall()?.let { StoresMotion.fly(it) } } - StoresState.pendingInstall?.let { item -> InstallWhere(item) } } @Composable -private fun InstallWhere(item: CatalogItem) { +internal fun InstallWhere(item: CatalogItem) { val ctx = LocalContext.current val pal = LocalPalette.current val from = remember(item) { StoresMotion.recentInstall() } @@ -557,7 +453,7 @@ private fun InstallWhere(item: CatalogItem) { val targets = StoreInstallRoot.targets(ctx) val remembered = SessionPrefs.storesInstallTarget(ctx) val defaultIndex = targets.indexOfFirst { it.root.absolutePath == remembered }.coerceAtLeast(0) - StoresMotion.ask(StepAsk( + Steps.ask(StepAsk( anchor = from, side = StepSide.Right, accent = pal.signal, pillCorner = 12.dp, items = 1 + targets.size, maxWidth = (28 + 192 * targets.size - 8).dp, onDismiss = { if (StoresState.pendingInstall === item) StoresState.pendingInstall = null }, @@ -587,7 +483,7 @@ private fun InstallWhere(item: CatalogItem) { SessionPrefs.setStoresInstallTarget(ctx, t.root.absolutePath) StoresMotion.markInstall(from, label) StoresState.install(ctx, item, t.root) - StoresMotion.fold() + Steps.fold() } } } @@ -717,6 +613,7 @@ private fun RowScope.ArrangeStoreChip( ) { onToggleTab(store) } } +@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class) @Composable private fun StoreChips( selected: String, focused: String?, active: Int, tabs: List<StoreTab>, arranging: Boolean, @@ -726,7 +623,6 @@ private fun StoreChips( val pal = LocalPalette.current val keys = tabs.filter { it.shown }.map { it.store.id } + DOWNLOADS val bounds = remember { androidx.compose.runtime.mutableStateMapOf<String, Rect>() } - val rootBounds = remember { HashMap<String, Rect>() } val highlighted = focused ?: selected val inputMode = androidx.compose.ui.platform.LocalInputModeManager.current.inputMode val fillShown = inputMode != androidx.compose.ui.input.InputMode.Keyboard && !arranging @@ -736,47 +632,59 @@ private fun StoreChips( val gap = 8.dp val gapPx = with(androidx.compose.ui.platform.LocalDensity.current) { gap.toPx() } val drag = remember { ChipDrag() } + val tabScroll = rememberScrollState() Box(Modifier.fillMaxWidth()) { Row(horizontalArrangement = Arrangement.spacedBy(gap), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { - for (tab in tabs) { - val store = tab.store - if (arranging) { - val index = tabs.indexOfFirst { it.store == store } - key(store) { ArrangeStoreChip(tab, index, tabs.lastIndex, gapPx, drag, onFocusChip, onMoveTab, onToggleTab) } - } else if (tab.shown) { - val signedIn = StoresState.isSignedIn(store) && StoresState.expired[store] != true - key(store) { - StoreChip( - label = store.shortLabel, on = selected == store.id, under = fillShown && highlighted == store.id, key = store.id, mark = sourceColours(store.id).dot, - modifier = Modifier.weight(1f).onGloballyPositioned { bounds[store.id] = it.boundsInParent(); rootBounds[store.id] = it.boundsInRoot() }, - lead = { StoreDot(store, signedIn) }, - description = store.label + if (signedIn || !StoresState.accountsLoaded) "" else " " + stringResource(R.string.stores_chip_signed_out), - onFocused = onFocusChip, - ) { onStore(store) } + Box(Modifier.weight(1f).then(if (arranging) Modifier else Modifier.edgeFade(tabScroll).horizontalScroll(tabScroll))) { + Row(horizontalArrangement = Arrangement.spacedBy(gap), verticalAlignment = Alignment.CenterVertically, + modifier = (if (arranging) Modifier.fillMaxWidth() else Modifier).focusGroup(), + ) { + for (tab in tabs) { + val store = tab.store + if (arranging) { + val index = tabs.indexOfFirst { it.store == store } + key(store) { ArrangeStoreChip(tab, index, tabs.lastIndex, gapPx, drag, onFocusChip, onMoveTab, onToggleTab) } + } else if (tab.shown) { + val signedIn = StoresState.isSignedIn(store) && StoresState.expired[store] != true + key(store) { + val reveal = remember { BringIntoViewRequester() } + LaunchedEffect(selected) { if (selected == store.id) reveal.bringIntoView() } + StoreChip( + label = store.shortLabel, on = selected == store.id, under = fillShown && highlighted == store.id, key = store.id, mark = sourceColours(store.id).dot, + modifier = Modifier.bringIntoViewRequester(reveal).onGloballyPositioned { bounds[store.id] = it.boundsInParent() }, + lead = { StoreDot(store, signedIn) }, + description = store.label + if (signedIn || !StoresState.accountsLoaded) "" else " " + stringResource(R.string.stores_chip_signed_out), + onFocused = onFocusChip, + ) { onStore(store) } + } + } } } + ChipGlide(keys.indexOf(highlighted), bounds[highlighted], tint, visible = fillShown && highlighted != DOWNLOADS) } if (arranging) { IconChip(Icons.Filled.Check, stringResource(R.string.stores_tabs_arrange_done)) { onArrangeDone() } } else { - StoreChip( - label = null, on = selected == DOWNLOADS, under = fillShown && highlighted == DOWNLOADS, key = DOWNLOADS, mark = pal.signal, - modifier = Modifier.onGloballyPositioned { - bounds[DOWNLOADS] = it.boundsInParent() - StoresMotion.downloadsChip = it.boundsInRoot() - }, - lead = { Icon(Icons.Outlined.Download, null, modifier = Modifier.size(18.dp)) }, - trail = if (active > 0) { { CountPill(active) } } else null, - description = stringResource(R.string.stores_tab_downloads), - onFocused = onFocusChip, - ) { onDownloads() } + Box { + StoreChip( + label = null, on = selected == DOWNLOADS, under = fillShown && highlighted == DOWNLOADS, key = DOWNLOADS, mark = pal.signal, + modifier = Modifier.onGloballyPositioned { + bounds[DOWNLOADS] = it.boundsInParent() + StoresMotion.downloadsChip = it.boundsInRoot() + }, + lead = { Icon(Icons.Outlined.Download, null, modifier = Modifier.size(18.dp)) }, + trail = if (active > 0) { { CountPill(active) } } else null, + description = stringResource(R.string.stores_tab_downloads), + onFocused = onFocusChip, + ) { onDownloads() } + ChipGlide(keys.indexOf(DOWNLOADS), bounds[DOWNLOADS], tint, visible = fillShown && highlighted == DOWNLOADS) + } var cogAt by remember { mutableStateOf<Rect?>(null) } Box(Modifier.onGloballyPositioned { cogAt = it.boundsInRoot() }) { IconChip(Icons.Filled.Settings, stringResource(R.string.stores_settings)) { onSettings(cogAt) } } } } - ChipGlide(keys.indexOf(highlighted), bounds[highlighted], tint, visible = fillShown) } } @@ -935,77 +843,35 @@ internal fun StoreLogo(store: Store, size: Dp) { @Composable private fun Storefront( - store: Store, tab: String, query: String, s: FrontEndState, a: FrontEndActions, + store: Store, query: String, s: FrontEndState, a: FrontEndActions, grid: LazyGridState, - onQuery: (String) -> Unit, onOpen: (String) -> Unit, onTab: (String) -> Unit, + clearSearchFocus: Boolean, + onQuery: (String) -> Unit, onOpen: (String) -> Unit, ) { + BackHandler(enabled = query.isNotEmpty() && !clearSearchFocus) { onQuery("") } val ctx = LocalContext.current - val colors = MaterialTheme.colorScheme val library = StoresState.library[store] ?: emptyList() val shelves = StoresState.shelves[store] val found = StoresState.found[store] ?: emptyList() - val installedItems = remember(StoresState.installed, library) { com.droiddeck.launcher.stores.installedCards(store, StoresState.installed, library) } - val installedKeys = remember(installedItems) { installedItems.map { it.id }.toSet() + StoresState.installed.filter { it.sidecar.store == store }.map { it.sidecar.id } } + val installedKeys = remember(StoresState.installed) { StoresState.installed.filter { it.sidecar.store == store }.map { it.sidecar.id }.toSet() } val q = query.trim().lowercase() val foundKeys = remember(found) { found.map { it.key }.toSet() } - fun matches(i: CatalogItem) = q.isEmpty() || i.title.lowercase().contains(q) || i.key in foundKeys fun shown(i: CatalogItem) = s.storesShowMature || !i.mature || i.accessible fun shelf(l: List<CatalogItem>?) = l?.filter(::shown) - val everything = remember(library, shelves, found) { (found + library + (shelves?.all ?: emptyList())).distinctBy { it.id } } LaunchedEffect(store, q) { StoresState.search(ctx, store, q) } - val loaded = StoresState.library.containsKey(store) - val showGrid = tab != "store" || q.isNotEmpty() - val sort = s.storesSort - val lastPlayedFor = remember(s.steamGames) { - val byKey = s.steamGames.associate { "${it.source}:${it.storeId}" to it.lastPlayed } - val lookup: (CatalogItem) -> Long = { i -> byKey[i.key] ?: 0L } - lookup + val browsing = store == Store.ITCH && q.isEmpty() && ItchBrowseState.active + val browseItems = if (browsing) ItchBrowseState.items else emptyList() + val visible = remember(found, library, shelves, q, s.storesShowMature, browsing, browseItems) { + if (browsing) { + val owned = library.associateBy { it.id } + browseItems.map { owned[it.id] ?: it }.filter(::shown) + } else if (q.isEmpty()) emptyList() + else (found + library + (shelves?.all ?: emptyList())).distinctBy { it.id } + .filter { (it.title.lowercase().contains(q) || it.key in foundKeys) && shown(it) } } - val installedAtFor = remember(StoresState.installed) { - val byKey = StoresState.installed.associate { "${it.sidecar.store.id}:${it.sidecar.id}" to it.sidecar.installedAt } - val lookup: (CatalogItem) -> Long = { i -> byKey[i.key] ?: 0L } - lookup - } - val isSizeSort = tab == "library" && (sort == "sizeAsc" || sort == "sizeDesc") - val candidates = remember(library, everything, tab, q, s.storesShowMature) { - if (tab == "library") library.filter(::matches) else everything.filter { matches(it) && shown(it) } - } - val candidateKeys = remember(candidates) { candidates.map { it.key } } - var resolvedSizeSortKey by remember(store, tab) { mutableStateOf<List<String>?>(null) } - val sizeSortPending = isSizeSort && resolvedSizeSortKey != candidateKeys - var stableVisible by remember(store, tab) { mutableStateOf(emptyList<CatalogItem>()) } - val resolvedSizes = if (isSizeSort) com.droiddeck.launcher.stores.StoreSizes.known.toMap() else emptyMap() - val sizeFor = remember(resolvedSizes) { - val resolved = resolvedSizes - val lookup: (CatalogItem) -> Long = { item -> resolved[item.key] ?: item.sizeBytes } - lookup - } - val sortedVisible = remember(candidates, tab, sort, lastPlayedFor, installedAtFor, sizeFor, installedKeys) { - if (tab == "library") installedFirst(sortCatalogItems(candidates, sort, lastPlayed = lastPlayedFor, installedAt = installedAtFor, size = sizeFor), installedKeys) - else candidates - } - val visible = remember(sizeSortPending, candidates, stableVisible, sortedVisible) { - if (sizeSortPending) retainCatalogOrder(candidates, stableVisible) else sortedVisible - } - val installedShown = if (tab == "library") visible.count { it.id in installedKeys } else 0 - val split = installedShown.takeIf { it in 1 until visible.size } ?: 0 - SideEffect { - if (!sizeSortPending && stableVisible != sortedVisible) stableVisible = sortedVisible - } - LaunchedEffect(store, tab, sort, candidates, candidateKeys) { - if (!isSizeSort || resolvedSizeSortKey == candidateKeys) return@LaunchedEffect - val requests = candidates.map { item -> - item to com.droiddeck.launcher.stores.StoreSizes.resolve(ctx, item) - } - CompletableFuture.allOf(*requests.map { it.second }.toTypedArray()).await() - requests.forEach { (item, future) -> - val bytes = future.getNow(null) ?: return@forEach - if (item.accessible && item.sizeBytes <= 0) com.droiddeck.launcher.stores.StoreSizes.known[item.key] = bytes - } - resolvedSizeSortKey = candidateKeys - } - val empty = stringResource(when { - tab == "library" && library.isEmpty() && StoresState.status[store] != null -> R.string.stores_library_loading + val nothing = stringResource(when { + browsing && ItchBrowseState.loading -> R.string.stores_shelves_loading + browsing && ItchBrowseState.failed -> R.string.stores_itch_browse_failed else -> R.string.stores_nothing_matches }) var firstPlaced = false @@ -1013,18 +879,16 @@ private fun Storefront( val density = androidx.compose.ui.platform.LocalDensity.current val focus = LocalFrontFocus.current val scope = rememberCoroutineScope() - // A bar only where the tab has nothing else on it: shelves, the shelf of owned games, or a grid still loading. - val gridEmpty = if (showGrid) visible.isEmpty() else (shelves == null || shelves.isEmpty) && library.isEmpty() - val barLine = StoresState.status[store]?.takeIf { gridEmpty && syncCount(it) != null } - val headerItems = 1 + (if (sizeSortPending) 1 else 0) + (if (barLine != null) 1 else 0) + - (if (StoresState.problems[store] != null) 1 else 0) - val listMode = s.storesView == "list" && tab == "library" - val capsules = s.storesView == "capsules" && tab == "library" - val tileWidth = if (capsules) CapsuleWidth else CardWidth + val searching = q.isNotEmpty() || browsing + // A bar only while there is nothing else to look at: no shelves yet and no search results. + val bareTab = if (searching) visible.isEmpty() else shelves == null || shelves.isEmpty + val barLine = StoresState.status[store]?.takeIf { bareTab && syncCount(it) != null } + val itchBar = store == Store.ITCH && q.isEmpty() + val headerItems = 2 + (if (barLine != null) 1 else 0) + (if (StoresState.problems[store] != null) 1 else 0) + (if (itchBar) 1 else 0) val card: @Composable (CatalogItem) -> Unit = { item -> - val first = if (showGrid) item.key == visible.firstOrNull()?.key else !firstPlaced + val first = if (searching) item.key == visible.firstOrNull()?.key else !firstPlaced firstPlaced = true - val navigation = if (!showGrid || focus == null) Modifier else Modifier.onPreviewKeyEvent { event -> + val navigation = if (!searching || focus == null) Modifier else Modifier.onPreviewKeyEvent { event -> val key = event.nativeKeyEvent val direction = when (key.keyCode) { KeyEvent.KEYCODE_DPAD_DOWN -> 1 @@ -1032,14 +896,14 @@ private fun Storefront( else -> 0 } val index = visible.indexOfFirst { it.key == item.key } - val next = storeGridNeighbor(index, visible.size, if (listMode) 1 else columns, direction, split) + val next = storeGridNeighbor(index, visible.size, columns, direction) when { direction == 0 || index < 0 -> false next == null -> direction > 0 else -> { if (key.action == KeyEvent.ACTION_DOWN) { val target = "card:${visible[next].key}" - val targetIndex = headerItems + next + if (split == 0) 0 else if (next < split) 1 else 2 + val targetIndex = headerItems + next val visibleTarget = grid.layoutInfo.visibleItemsInfo.any { it.index == targetIndex } if (!visibleTarget || (focus.attached[target] ?: 0) == 0 || runCatching { focus.items.getValue(target).requestFocus() }.isFailure) scope.launch { @@ -1051,127 +915,172 @@ private fun Storefront( } } } - if (listMode) GameListRow(item, store, installedKeys, onOpen, first, navigation) - else GameCard(item, store, s, a, installedKeys, onOpen, first, navigation, capsule = capsules) + GameCard(item, store, installedKeys, onOpen, first, navigation) } LazyVerticalGrid( - columns = if (listMode) GridCells.Fixed(1) else GridCells.Adaptive(tileWidth), state = grid, modifier = Modifier.fillMaxSize().focusGroup().onSizeChanged { + columns = GridCells.Adaptive(CardWidth), state = grid, modifier = Modifier.fillMaxSize().focusGroup().onSizeChanged { val gap = with(density) { 10.dp.roundToPx() } - val minimum = with(density) { (if (listMode) CardWidth * 2 else tileWidth).roundToPx() } - columns = ((it.width + gap) / (minimum + gap)).toInt().coerceAtLeast(1) + val minimum = with(density) { CardWidth.roundToPx() } + columns = ((it.width + gap) / (minimum + gap)).coerceAtLeast(1) }, horizontalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp), contentPadding = PaddingValues(top = 4.dp, bottom = 16.dp), ) { - item(key = "header", span = { GridItemSpan(maxLineSpan) }) { - Rise(1) { - val tabs = tabsFor(store) - val libraryLabel = if (loaded) stringResource(R.string.stores_tab_count, stringResource(R.string.stores_tab_library), library.size) - else stringResource(R.string.stores_tab_library) - ModeHeader( - libraryLabel, - query, onQuery, - placeholder = if (tab == "store") stringResource(R.string.stores_search_catalog, store.label) else stringResource(R.string.stores_search_library), - lead = if (tabs.size < 2) null else { { - val labels = tabs.map { if (it == "store") stringResource(R.string.stores_tab_store) else libraryLabel } - HeaderTabs(labels, tabs.indexOf(tab).coerceAtLeast(0)) { onTab(tabs[it]) } - } }, - ) { - if (tab == "library") { - SortButton(s, a) - ViewButton(s, a) - } - if (store == Store.EPIC) SmallTextButton(stringResource(R.string.epic_client_title)) { - if (EpicClient.status.installed) a.onEpicLauncher() - else { EpicClient.install(ctx); StoresState.openDownloads = true } - } - SmallTextButton(stringResource(R.string.stores_refresh)) { StoresState.open(ctx, store, force = true) } - val name = StoresState.accounts[store] ?: "" - SignOutButton(store, name) - } - } - } - if (sizeSortPending) item(key = "size-sort-progress", span = { GridItemSpan(maxLineSpan) }) { - Row( - verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), - ) { - androidx.compose.material3.LinearProgressIndicator(Modifier.weight(1f).height(3.dp)) - Text(stringResource(R.string.store_section_loading), fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) - } + item(key = "profile", span = { GridItemSpan(maxLineSpan) }) { Rise(1) { ProfileStrip(store, library.size, StoresState.library.containsKey(store), s, a) } } + item(key = "search", span = { GridItemSpan(maxLineSpan) }) { + Rise(2) { ModeHeader(store.label, query, onQuery, placeholder = stringResource(R.string.stores_search_catalog, store.label), clearSearchFocus = clearSearchFocus) {} } } barLine?.let { line -> item(key = "sync", span = { GridItemSpan(maxLineSpan) }) { Rise(3) { SyncBar(line) } } } StoresState.problems[store]?.let { line -> item(key = "problem", span = { GridItemSpan(maxLineSpan) }) { Rise(3) { Box(Modifier.padding(bottom = 8.dp)) { Note(line) } } } } - when (tab) { - "library" -> if (!loaded) Unit - else if (split == 0) storeGrid(visible, empty, card) - else { - item(key = "installed-label", span = { GridItemSpan(maxLineSpan) }) { SectionLabel(stringResource(R.string.stores_section_installed)) } - storeGrid(visible.take(split), empty, card) - item(key = "not-installed-label", span = { GridItemSpan(maxLineSpan) }) { SectionLabel(stringResource(R.string.stores_section_not_installed)) } - storeGrid(visible.drop(split), empty, card) + if (itchBar) item(key = "itch-browse-bar", span = { GridItemSpan(maxLineSpan) }) { ItchBrowseBar() } + if (searching) { + storeGrid(visible, nothing, card) + if (browsing) item(key = "itch-more", span = { GridItemSpan(maxLineSpan) }) { ItchLoadMore() } + } else item(key = "shelves", span = { GridItemSpan(maxLineSpan) }) { + Column { + Shelf(stringResource(R.string.stores_shelf_new), shelf(shelves?.whatsNew), card) + Shelf(stringResource(R.string.stores_shelf_deals), shelf(shelves?.deals), card) + Shelf(stringResource(R.string.stores_shelf_free), shelf(shelves?.free), card) + Shelf(stringResource(if (store == Store.BATTLENET) R.string.bnet_store else R.string.stores_shelf_trending), shelf(shelves?.trending), card) + if (shelves == null) Rise(4) { Box(Modifier.padding(top = 8.dp)) { Note(stringResource(R.string.stores_shelves_loading)) } } + else if (shelves.isEmpty && store == Store.AMAZON) Rise(4) { Box(Modifier.padding(top = 8.dp)) { Note(stringResource(R.string.stores_amazon_no_catalog)) } } + } + } + } +} + +@Composable +private fun ProfileStrip(store: Store, owned: Int, loaded: Boolean, s: FrontEndState, a: FrontEndActions) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val ctx = LocalContext.current + val show = LocalShowSection.current + val name = StoresState.accounts[store] ?: "" + val c = sourceColours(store.id) + val shape = RoundedCornerShape(16.dp) + Column( + verticalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp).clip(shape).background(colors.surface).border(1.dp, pal.line, shape) + .padding(horizontal = 14.dp, vertical = 10.dp).testTag("stores-profile"), + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.size(36.dp).clip(CircleShape).background(c.dot)) { + Text(name.take(1).uppercase().ifEmpty { store.shortLabel.take(1) }, fontSize = 16.sp, fontWeight = FontWeight.Black, color = if (c.dot.luminance() > 0.5f) Color.Black else Color.White) + } + Column(Modifier.weight(1f)) { + Text(name.ifEmpty { store.label }, fontSize = 15.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + if (loaded) store.shortLabel + " · " + androidx.compose.ui.res.pluralStringResource(R.plurals.mode_added_count, owned, owned) else store.label, + fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, + ) + } + Actions { + SmallTextButton(stringResource(R.string.stores_owned_in_library), key = "owned-in-library") { + LibraryView.showOwned(store) + show("games") } - else -> { - if (q.isNotEmpty()) storeGrid(visible, empty, card) - else { - item(key = "shelves", span = { GridItemSpan(maxLineSpan) }) { - Column { - Shelf(stringResource(R.string.stores_shelf_new), shelf(shelves?.whatsNew), card) - Shelf(stringResource(R.string.stores_shelf_deals), shelf(shelves?.deals), card) - Shelf(stringResource(R.string.stores_shelf_free), shelf(shelves?.free), card) - Shelf(stringResource(R.string.stores_shelf_trending), shelf(shelves?.trending), card) - Shelf(stringResource(R.string.stores_shelf_library), library, card) - if (shelves == null && library.isEmpty()) Rise(4) { Note(if (StoresState.status[store] != null) stringResource(R.string.stores_library_loading) else stringResource(R.string.stores_shelves_loading)) } - else if (shelves != null && shelves.isEmpty && store == Store.AMAZON) Rise(4) { Note(stringResource(R.string.stores_amazon_no_catalog)) } - } + SmallTextButton(stringResource(R.string.stores_refresh)) { StoresState.open(ctx, store, force = true) } + if (store == Store.XBOX) SmallTextButton(stringResource(R.string.stores_xbox_cloud_settings), key = "xbox-cloud-settings") { + ctx.startActivity(android.content.Intent(ctx, com.droiddeck.launcher.stores.xbox.XboxCloudSettingsActivity::class.java)) + } + SignOutButton(store, name) + } + } + if (store == Store.EPIC) EpicLauncherRow(s, a) + if (store == Store.BATTLENET) BattleNetLauncherRow(s, a) + } +} + +@Composable +private fun BattleNetLauncherRow(s: FrontEndState, a: FrontEndActions) { + val ctx = LocalContext.current + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val title = stringResource(R.string.bnet_name) + Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line)) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth().testTag("stores-battlenet-launcher")) { + val icon by androidx.compose.runtime.produceState<java.io.File?>(null, s.battleNetInstalled) { + value = if (!s.battleNetInstalled) null + else kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { com.droiddeck.launcher.runtime.BattleNet.icon(ctx) } + } + if (icon != null) AsyncImage(model = icon, contentDescription = null, contentScale = ContentScale.Fit, modifier = Modifier.size(36.dp).clip(RoundedCornerShape(8.dp))) + Text(title, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) + Actions { + SecondaryButton(stringResource(R.string.bnet_open), enabled = !s.busy, compact = true) { a.onBattleNet() } + val entry = remember(s.battleNetInstalled) { com.droiddeck.launcher.runtime.BattleNet.launcherEntry(s.battleNetInstalled) } + val scope = entry?.let { com.droiddeck.launcher.session.NoSteamLaunch.scope(it) } + if (entry != null && scope != null) { + val host = rememberMenuHost() + val settings = rememberGameSettings(scope) + Box { + SecondaryButton(stringResource(R.string.game_settings_card), compact = true) { + host.open = if (host.open == GAME_SETTINGS_MENU) null else GAME_SETTINGS_MENU } + GameSettingsMenus(host, entry, settings) } } } } } -private suspend fun <T> CompletableFuture<T>.await(): T = - suspendCancellableCoroutine { continuation -> - whenComplete { value, error -> - if (error == null) continuation.resume(value) - else continuation.resumeWithException(error) +@Composable +private fun EpicLauncherRow(s: FrontEndState, a: FrontEndActions) { + val ctx = LocalContext.current + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val epic = EpicClient.status + val title = stringResource(R.string.epic_client_title) + Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line)) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth().testTag("stores-epic-launcher")) { + val icon by androidx.compose.runtime.produceState<java.io.File?>(null, epic.version) { + value = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { EpicClient.icon(ctx, epic.version) } + } + if (icon != null) AsyncImage(model = icon, contentDescription = null, contentScale = ContentScale.Fit, modifier = Modifier.size(36.dp).clip(RoundedCornerShape(8.dp))) + Column(Modifier.weight(1f)) { + Text(title, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) + val version = epic.version?.substringBefore('+') + Text( + if (epic.installed && version != null) stringResource(R.string.epic_client_version, version) else stringResource(R.string.epic_client_missing), + fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, + ) + } + Actions { + if (epic.installed) { + SecondaryButton(stringResource(R.string.epic_client_open), enabled = !s.busy, compact = true) { a.onEpicLauncher() } + val entry = remember(epic, title) { EpicClient.launcherEntry(ctx, title, epic, null) } + val scope = entry?.let { com.droiddeck.launcher.session.NoSteamLaunch.scope(it) } + if (entry != null && scope != null) { + val host = rememberMenuHost() + val settings = rememberGameSettings(scope) + Box { + SecondaryButton(stringResource(R.string.game_settings_card), compact = true) { + host.open = if (host.open == GAME_SETTINGS_MENU) null else GAME_SETTINGS_MENU + } + GameSettingsMenus(host, entry, settings) + } + } + UninstallEpicLauncher(s, a) + } else SecondaryButton(stringResource(R.string.epic_client_install), compact = true) { + EpicClient.install(ctx) + StoresState.openDownloads = true + } } } - -@Composable -private fun SectionLabel(title: String) { - Text( - headingUppercase(title), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.fillMaxWidth().padding(top = 6.dp, start = 2.dp), - ) } @Composable -private fun HeaderTabs(labels: List<String>, selected: Int, onSelect: (Int) -> Unit) { - val colors = MaterialTheme.colorScheme +internal fun SectionLabel(title: String) { val pal = LocalPalette.current - val shape = RoundedCornerShape(8.dp) - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.selectableGroup()) { - labels.forEachIndexed { i, label -> - val src = remember { MutableInteractionSource() } - val hot = rememberHot(src) - val pick = { onSelect(i) } - Text( - label, fontSize = 15.sp, fontWeight = FontWeight.Bold, maxLines = 1, - color = if (i == selected || hot) colors.onBackground else colors.onSurfaceVariant.copy(alpha = 0.7f), - modifier = Modifier.clip(shape).glideBorder(hot, shape, pal.signal) - .hoverable(src).selectable(selected = i == selected, interactionSource = src, indication = LocalIndication.current, role = Role.Tab, onClick = pick) - .controllerConfirm(onClick = pick) - .padding(horizontal = 4.dp, vertical = 6.dp), - ) - } + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(top = 6.dp, start = 2.dp)) { + Text(headingUppercase(title), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1) + Box(Modifier.weight(1f).height(1.dp).background(pal.line)) } } @Composable internal fun ModeHeader( title: String, query: String, onQuery: (String) -> Unit, placeholder: String, + clearSearchFocus: Boolean = false, lead: (@Composable () -> Unit)? = null, trailing: @Composable () -> Unit, ) { val colors = MaterialTheme.colorScheme @@ -1179,6 +1088,12 @@ internal fun ModeHeader( var searching by remember { mutableStateOf(query.isNotEmpty()) } var focusOnOpen by remember { mutableStateOf(false) } val open by animateFloatAsState(if (searching) 1f else 0f, Motion.sp(0.75f, 420f), label = "searchOpen") + val close = { + searching = false + focusOnOpen = false + if (query.isNotEmpty()) onQuery("") + } + BackHandler(enabled = searching && !clearSearchFocus) { close() } Column(Modifier.fillMaxWidth()) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp).padding(vertical = 4.dp)) { if (lead != null) lead() @@ -1188,13 +1103,18 @@ internal fun ModeHeader( ) BoxWithConstraints(Modifier.weight(1f), contentAlignment = Alignment.CenterStart) { if (searching || open > 0.01f) { - val w = androidx.compose.ui.unit.lerp(44.dp, maxWidth.coerceAtMost(520.dp), open) - Box(Modifier.width(w).graphicsLayer { alpha = (open * 1.6f).coerceAtMost(1f) }) { - AdbTextField( - value = query, onValueChange = onQuery, label = "", keyboardType = KeyboardType.Text, imeAction = ImeAction.Search, - placeholder = placeholder, compact = true, focusOnShow = focusOnOpen, - onFocusChange = { focused -> if (!focused && query.isEmpty()) { searching = false; focusOnOpen = false } }, - ) + val closable = maxWidth >= 80.dp + val w = androidx.compose.ui.unit.lerp(44.dp, (maxWidth - if (closable) 36.dp else 0.dp).coerceIn(44.dp, 520.dp), open) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Box(Modifier.width(w).graphicsLayer { alpha = (open * 1.6f).coerceAtMost(1f) }) { + AdbTextField( + value = query, onValueChange = onQuery, label = "", keyboardType = KeyboardType.Text, imeAction = ImeAction.Search, + placeholder = placeholder, compact = true, focusOnShow = focusOnOpen, + clearFocus = clearSearchFocus || !searching, + onFocusChange = { focused -> if (!focused && query.isEmpty()) { searching = false; focusOnOpen = false } }, + ) + } + if (searching && closable) SmallIconButton(Icons.Filled.Close, stringResource(R.string.common_close), tint = colors.onSurfaceVariant, onClick = close) } } else SearchIcon(stringResource(R.string.stores_search_open)) { searching = true; focusOnOpen = true } } @@ -1211,6 +1131,7 @@ internal fun storePalette(base: Palette, store: Store?): Palette { Store.AMAZON -> Color(0xFFFF9900) to Color(0xFFE07800) Store.XBOX -> Color(0xFF70C850) to Color(0xFF107C10) Store.ITCH -> Color(0xFFFF5C5C) to Color(0xFFD93B3B) + Store.BATTLENET -> Color(0xFF148EFF) to Color(0xFF0B6BD3) null -> return base } val on = if (accent.luminance() > 0.18f) Color(0xFF0B0B0D) else Color.White @@ -1244,137 +1165,24 @@ internal fun StoreTheme(store: Store?, content: @Composable () -> Unit) { } @Composable -private fun SmallTextButton(text: String, key: String = text, onClick: () -> Unit) { - val colors = MaterialTheme.colorScheme - val pal = LocalPalette.current - val src = remember { MutableInteractionSource() } - val hot = rememberHot(src) - Text( - text, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = if (hot) colors.onBackground else colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.paneItem("btn:$key").clip(RoundedCornerShape(8.dp)).glideBorder(hot, RoundedCornerShape(8.dp), pal.signal) - .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick) - .controllerConfirm(onClick = onClick).padding(horizontal = 8.dp, vertical = 6.dp), - ) -} - -@Composable -private fun HeaderIconButton(description: String, icon: androidx.compose.ui.graphics.vector.ImageVector, key: String, onClick: () -> Unit) { +private fun SmallTextButton(text: String, key: String = text, danger: Boolean = false, onClick: () -> Unit) { val colors = MaterialTheme.colorScheme val pal = LocalPalette.current val src = remember { MutableInteractionSource() } val hot = rememberHot(src) + val shape = RoundedCornerShape(10.dp) Box( contentAlignment = Alignment.Center, - modifier = Modifier.paneItem("btn:$key").size(32.dp).clip(CircleShape).background(if (hot) pal.signal.copy(alpha = 0.14f) else Color.Transparent) - .glideBorder(hot, CircleShape, pal.signal) + modifier = Modifier.paneItem("btn:$key").heightIn(min = 40.dp).clip(shape) + .background(if (hot) pal.signal.copy(alpha = 0.14f) else Color.White.copy(alpha = 0.03f)) + .glideBorder(hot, shape, pal.signal, pal.line2) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick) - .controllerConfirm(onClick = onClick), - ) { Icon(icon, description, tint = if (hot) pal.signal else colors.onSurfaceVariant, modifier = Modifier.size(18.dp)) } -} - -@Composable -private fun SortButton(s: FrontEndState, a: FrontEndActions) { - val pal = LocalPalette.current - val title = stringResource(R.string.stores_sort) - var at by remember { mutableStateOf<Rect?>(null) } - Box(Modifier.onGloballyPositioned { at = it.boundsInRoot() }) { - HeaderIconButton(title, Icons.Filled.Sort, "sort") { - val from = at ?: return@HeaderIconButton - StoresMotion.ask(StepAsk( - anchor = from, side = StepSide.Left, accent = pal.signal, pillCorner = 8.dp, items = SessionPrefs.STORES_SORTS.size, - onDismiss = {}, - handle = { Text(title, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = pal.signal, maxLines = 1, overflow = TextOverflow.Ellipsis) }, - ) { - StepTitle(title) - StepList { - SessionPrefs.STORES_SORTS.forEachIndexed { i, sort -> - val label = when (sort) { - "lastPlayed" -> stringResource(R.string.stores_sort_last_played) - "recentlyInstalled" -> stringResource(R.string.stores_sort_recently_installed) - "titleAsc" -> stringResource(R.string.stores_sort_title_asc) - "titleDesc" -> stringResource(R.string.stores_sort_title_desc) - "sizeDesc" -> stringResource(R.string.stores_sort_size_desc) - "sizeAsc" -> stringResource(R.string.stores_sort_size_asc) - else -> stringResource(R.string.stores_sort_release_date) - } - StepChoice( - label, selected = sort == s.storesSort, enabled = open, - modifier = Modifier.fillMaxWidth().stepItem(i) - .then(if (sort == s.storesSort) Modifier.focusRequester(first) else Modifier), - ) { - a.onStoresSort(sort) - StoresMotion.fold() - } - } - } - }) - } - } -} - -@Composable -private fun ViewButton(s: FrontEndState, a: FrontEndActions) { - val views = SessionPrefs.STORES_VIEWS - val next = views[(views.indexOf(s.storesView) + 1) % views.size] - HeaderIconButton( - stringResource(when (next) { "capsules" -> R.string.stores_view_capsules; "list" -> R.string.stores_view_list; else -> R.string.stores_view_cards }), - when (next) { "capsules" -> Icons.Filled.ViewModule; "list" -> Icons.Filled.ViewList; else -> Icons.Filled.GridView }, - "view", - ) { a.onStoresView(next) } -} - -@Composable -private fun GameListRow( - item: CatalogItem, store: Store, installedKeys: Set<String>, onOpen: (String) -> Unit, - first: Boolean = false, navigation: Modifier = Modifier, -) { - val colors = MaterialTheme.colorScheme - val pal = LocalPalette.current - val src = remember { MutableInteractionSource() } - val hot = rememberHot(src) - val installed = item.id in installedKeys - val download = StoresState.download("${store.id}:${item.id}")?.takeIf { it.isActive } - if (installed) { - val ctx = LocalContext.current - val game = StoresState.installedGame(store, item.id) - if (game != null) androidx.compose.runtime.LaunchedEffect(item.key) { com.droiddeck.launcher.stores.StoreSizes.requestDisk(ctx, item.key, game) } - } - val placed = remember { arrayOfNulls<androidx.compose.ui.layout.LayoutCoordinates>(1) } - val corner = with(androidx.compose.ui.platform.LocalDensity.current) { 10.dp.toPx() } - val open = { - placed[0]?.takeIf { it.isAttached }?.let { StoresMotion.markCard(CardMark(item.key, it.boundsInRoot(), corner, item.imageUrl ?: item.tallImageUrl)) } - onOpen(item.key) - } - val meta = listOfNotNull( - item.developer.takeIf { it.isNotBlank() }, - item.releaseDate.take(4).takeIf { it.length == 4 }, - when { - installed -> { - val bytes = com.droiddeck.launcher.stores.StoreSizes.onDisk[item.key] - ?: com.droiddeck.launcher.stores.StoreSizes.size(item).takeIf { it > 0 } ?: 0L - formatBytes(bytes).takeIf { it.isNotEmpty() } - } - item.isFree -> stringResource(R.string.stores_free) - item.isDiscounted -> "-${item.discountPercent}%" - item.hasPrice -> item.finalPrice.takeIf { it.isNotBlank() } - else -> null - }, - ) - Row( - verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.fillMaxWidth().heightIn(min = 64.dp).then(navigation).paneItem("card:${item.key}") - .then(if (first) Modifier.firstTile() else Modifier) - .clip(RoundedCornerShape(10.dp)).glideBorder(hot, RoundedCornerShape(10.dp), pal.signal, pal.line) - .hoverable(src).clickable(role = androidx.compose.ui.semantics.Role.Button, interactionSource = src, indication = LocalIndication.current, onClick = open).controllerConfirm(onClick = open) - .padding(horizontal = 10.dp, vertical = 8.dp), + .controllerConfirm(onClick = onClick).padding(horizontal = 12.dp, vertical = 8.dp), ) { - CardArt(item, Modifier.width(96.dp).height(54.dp).clip(RoundedCornerShape(8.dp))) - Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text(item.title, fontSize = 14.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text(meta.joinToString(" · "), fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) - } - if (installed) Icon(Icons.Filled.Check, stringResource(R.string.stores_installed_chip), tint = pal.good, modifier = Modifier.size(16.dp)) - else if (download != null) Text("${download.percent}%", fontSize = 11.sp, fontWeight = FontWeight.Bold, color = pal.signal, maxLines = 1) + Text( + text, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis, + color = if (danger) colors.error else if (hot) colors.onBackground else colors.onSurfaceVariant, + ) } } @@ -1389,7 +1197,7 @@ private fun SignOutButton(store: Store, name: String) { val go = stringResource(R.string.stores_signout_go) var at by remember { mutableStateOf<Rect?>(null) } Box(Modifier.onGloballyPositioned { at = it.boundsInRoot() }) { - SmallTextButton(label, key = "signout") { + SmallTextButton(label, key = "signout", danger = true) { val from = at if (from == null) { StoresState.signOut(ctx, store); return@SmallTextButton } StoresMotion.ask(StepAsk( @@ -1425,18 +1233,12 @@ internal fun SearchIcon(description: String, onClick: () -> Unit) { @Composable private fun Shelf(title: String, items: List<CatalogItem>?, card: @Composable (CatalogItem) -> Unit) { if (items.isNullOrEmpty()) return - val colors = MaterialTheme.colorScheme - Text(title, fontSize = 16.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, modifier = Modifier.padding(top = 14.dp, bottom = 6.dp)) + Box(Modifier.padding(top = 10.dp, bottom = 8.dp)) { SectionLabel(title) } Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(vertical = 2.dp)) { for (item in items.take(24)) key(item.key) { Box(Modifier.width(CardWidth)) { card(item) } } } } -internal fun installedFirst(items: List<CatalogItem>, installed: Set<String>): List<CatalogItem> { - val (on, off) = items.partition { it.id in installed } - return on + off -} - private fun LazyGridScope.storeGrid(games: List<CatalogItem>, empty: String, card: @Composable (CatalogItem) -> Unit) { if (games.isEmpty()) item(key = "empty", span = { GridItemSpan(maxLineSpan) }) { Box(Modifier.padding(vertical = 32.dp).fillMaxWidth(), contentAlignment = Alignment.Center) { @@ -1446,16 +1248,15 @@ private fun LazyGridScope.storeGrid(games: List<CatalogItem>, empty: String, car else items(games, key = { it.key }, contentType = { "game" }) { card(it) } } -private val CardWidth = 112.dp -private val CapsuleWidth = 92.dp +internal val CardWidth = 112.dp +internal val CapsuleWidth = 92.dp @Composable private fun GameCard( - item: CatalogItem, store: Store, s: FrontEndState, a: FrontEndActions, installedKeys: Set<String>, onOpen: (String) -> Unit, - first: Boolean = false, navigation: Modifier = Modifier, capsule: Boolean = false, + item: CatalogItem, store: Store, installedKeys: Set<String>, onOpen: (String) -> Unit, + first: Boolean = false, navigation: Modifier = Modifier, ) { val ctx = LocalContext.current - val colors = MaterialTheme.colorScheme val pal = LocalPalette.current val src = remember { MutableInteractionSource() } val hot = rememberHot(src) @@ -1466,8 +1267,7 @@ private fun GameCard( val placed = remember { arrayOfNulls<androidx.compose.ui.layout.LayoutCoordinates>(1) } val corner = with(androidx.compose.ui.platform.LocalDensity.current) { 10.dp.toPx() } val open = { - val art = if (capsule) item.tallImageUrl ?: item.imageUrl else item.imageUrl ?: item.tallImageUrl - placed[0]?.takeIf { it.isAttached }?.let { StoresMotion.markCard(CardMark(item.key, it.boundsInRoot(), corner, art)) } + placed[0]?.takeIf { it.isAttached }?.let { StoresMotion.markCard(CardMark(item.key, it.boundsInRoot(), corner, item.imageUrl ?: item.tallImageUrl)) } onOpen(item.key) } val screen = with(androidx.compose.ui.platform.LocalDensity.current) { androidx.compose.ui.platform.LocalConfiguration.current.screenHeightDp.dp.toPx() } @@ -1485,11 +1285,11 @@ private fun GameCard( .clip(RoundedCornerShape(10.dp)).glideBorder(hot, RoundedCornerShape(10.dp), pal.signal, pal.line) .hoverable(src).clickable(role = androidx.compose.ui.semantics.Role.Button, interactionSource = src, indication = LocalIndication.current, onClick = open).controllerConfirm(onClick = open), ) { - CardFace(item, download, hot, capsule) { CardMarker(item, installed, download, compact = capsule) } + CardFace(item.title, item.imageUrl, item.tallImageUrl, download, hot) { CardMarker(item, installed, download) } } } -private fun syncCount(line: String): Pair<Int, Int>? { +internal fun syncCount(line: String): Pair<Int, Int>? { val match = Regex("(\\d+)\\s*/\\s*(\\d+)").find(line) ?: return null val done = match.groupValues[1].toIntOrNull() ?: return null val total = match.groupValues[2].toIntOrNull()?.takeIf { it > 0 } ?: return null @@ -1497,7 +1297,7 @@ private fun syncCount(line: String): Pair<Int, Int>? { } @Composable -private fun SyncBar(line: String) { +internal fun SyncBar(line: String) { val count = syncCount(line) ?: return val done = count.first val total = count.second @@ -1510,9 +1310,9 @@ private fun SyncBar(line: String) { private val CardStrip = 26.dp @Composable -internal fun cardArtRequest(url: String?, cover: String?): coil.request.ImageRequest? { +internal fun cardArtRequest(url: Any?, cover: Any?): coil.request.ImageRequest? { val ctx = LocalContext.current - val under = cover?.takeIf { it != url } + val under = (cover as? String)?.takeIf { it != url } return remember(url, under) { url?.let { coil.request.ImageRequest.Builder(ctx).data(it).apply { if (under != null) placeholderMemoryCacheKey(under) }.build() } } @@ -1520,29 +1320,31 @@ internal fun cardArtRequest(url: String?, cover: String?): coil.request.ImageReq @OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class) @Composable -private fun CardFace( - item: CatalogItem, download: com.droiddeck.launcher.stores.download.DownloadEntry?, hot: Boolean, capsule: Boolean = false, - marker: @Composable () -> Unit, +internal fun CardFace( + title: String, wide: Any?, tall: Any?, download: com.droiddeck.launcher.stores.download.DownloadEntry?, hot: Boolean, capsule: Boolean = false, + dim: Boolean = false, badge: (@Composable BoxScope.() -> Unit)? = null, marker: @Composable () -> Unit, ) { - val url = if (capsule) item.tallImageUrl ?: item.imageUrl else item.imageUrl ?: item.tallImageUrl - val art = cardArtRequest(url, item.tallImageUrl) + val url = if (capsule) tall ?: wide else wide ?: tall + val art = cardArtRequest(url, tall) val blurs = android.os.Build.VERSION.SDK_INT >= 31 var landscape by remember(url) { mutableStateOf(false) } BoxWithConstraints(Modifier.fillMaxWidth()) { val total = if (capsule) maxWidth * 7f / 5f + CardStrip else maxWidth * 9f / 16f + CardStrip - Box(Modifier.fillMaxWidth().height(total).background(Color(0xFF101318)).then(if (url == null) Modifier.background(artBrush(hueOf(item.title))) else Modifier)) { + Box(Modifier.fillMaxWidth().height(total).background(Color(0xFF101318)).then(if (url == null) Modifier.background(artBrush(hueOf(title))) else Modifier)) { if (art != null && capsule && landscape) { // Stores without portrait covers (itch) would lose most of the art to a portrait crop. AsyncImage(model = art, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize().blur(24.dp)) Spacer(Modifier.matchParentSize().background(Color.Black.copy(alpha = if (blurs) 0.35f else 0.7f))) AsyncImage(model = art, contentDescription = null, contentScale = ContentScale.Fit, modifier = Modifier.matchParentSize().padding(bottom = CardStrip)) } else if (art != null) AsyncImage( - model = art, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize(), + model = art, contentDescription = null, contentScale = if (capsule) ContentScale.Fit else ContentScale.Crop, + modifier = Modifier.matchParentSize().padding(bottom = if (capsule) CardStrip else 0.dp), onSuccess = { r -> val drawn = r.result.drawable if (capsule && drawn.intrinsicWidth > 0 && drawn.intrinsicHeight > 0) landscape = drawn.intrinsicWidth > drawn.intrinsicHeight * 0.95f }, ) + if (dim) Spacer(Modifier.matchParentSize().background(Color.Black.copy(alpha = 0.5f))) Box(Modifier.align(Alignment.BottomCenter).fillMaxWidth().height(CardStrip).clipToBounds()) { if (art != null) AsyncImage( model = art, contentDescription = null, contentScale = ContentScale.Crop, @@ -1554,7 +1356,7 @@ private fun CardFace( modifier = Modifier.matchParentSize().padding(horizontal = 8.dp), ) { Text( - item.title, fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color.White, maxLines = 1, + title, fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color.White, maxLines = 1, overflow = if (hot) TextOverflow.Clip else TextOverflow.Ellipsis, modifier = Modifier.weight(1f).then(if (hot) Modifier.basicMarquee(iterations = Int.MAX_VALUE, initialDelayMillis = 700, velocity = 40.dp) else Modifier), ) @@ -1565,6 +1367,7 @@ private fun CardFace( if (download != null) Box(Modifier.align(Alignment.BottomCenter).padding(start = 6.dp, end = 6.dp, bottom = CardStrip + 5.dp)) { ProgressBarThin(download.fraction, paused = download.state == DownloadState.PAUSED, verify = download.stage == DownloadStage.VERIFY) } + badge?.invoke(this) } } } @@ -1595,18 +1398,6 @@ private fun CardMarker(item: CatalogItem, installed: Boolean, download: com.droi } } -@Composable -internal fun CardArt(item: CatalogItem, modifier: Modifier) { - val url = item.imageUrl ?: item.tallImageUrl - val art = cardArtRequest(url, item.tallImageUrl) - val colors = MaterialTheme.colorScheme - Box(modifier.background(colors.surfaceVariant).then(if (url == null) Modifier.background(artBrush(hueOf(item.title))) else Modifier)) { - if (art != null) AsyncImage(model = art, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize()) - else Text(item.title, fontSize = 10.sp, fontWeight = FontWeight.Black, color = Color.White, maxLines = 3, overflow = TextOverflow.Ellipsis, modifier = Modifier.align(Alignment.BottomStart).padding(6.dp)) - Spacer(Modifier.matchParentSize().background(androidx.compose.ui.graphics.Brush.verticalGradient(0.6f to Color.Transparent, 1f to colors.surface.copy(alpha = 0.85f)))) - } -} - @Composable internal fun ProgressBarThin(fraction: Float, paused: Boolean = false, verify: Boolean = false, height: Dp = 4.dp, done: Boolean = false) { val pal = LocalPalette.current @@ -1663,7 +1454,7 @@ internal fun openUrl(ctx: android.content.Context, url: String) { internal fun launchStoreGame(ctx: android.content.Context, store: Store, id: String, s: FrontEndState, a: FrontEndActions) { val game = s.steamGames.firstOrNull { it.source == store.id && it.storeId == id } if (game != null) { - com.droiddeck.launcher.stores.StoreLaunch.prepare(ctx, store, id) { a.onSteamGame(game) } + a.onSteamGame(game) return } val app = ctx.applicationContext @@ -1671,7 +1462,7 @@ internal fun launchStoreGame(ctx: android.content.Context, store: Store, id: Str val installed = runCatching { com.droiddeck.launcher.stores.StoreLaunch.installedGame(app, store, id) }.getOrNull() StoresState.post { if (installed == null) android.widget.Toast.makeText(ctx, R.string.stores_launch_not_registered, android.widget.Toast.LENGTH_LONG).show() - else com.droiddeck.launcher.stores.StoreLaunch.prepare(ctx, store, id) { a.onSteamGame(installed) } + else a.onSteamGame(installed) } }, "store-play").start() } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/Theme.kt b/app/src/main/java/com/droiddeck/launcher/ui/Theme.kt index 0f3843fca..a02eb8d36 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/Theme.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/Theme.kt @@ -2,8 +2,20 @@ package com.droiddeck.launcher.ui import androidx.annotation.StringRes import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextDirection import androidx.compose.material3.darkColorScheme +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.getValue @@ -13,6 +25,7 @@ import android.content.Context import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext import com.droiddeck.launcher.R import androidx.compose.ui.platform.LocalConfiguration @@ -33,6 +46,7 @@ object Themes { const val PAPER = "paper" const val ICON_BLUE = "blue" const val ELECTRIC = "electric" + const val INNER_GAMER = "inner_gamer" const val GRADIENT = "gradient" const val DEFAULT_PRIMARY = 0xFF8B5CF6.toInt() @@ -42,10 +56,42 @@ object Themes { var gradientColors by mutableStateOf(DEFAULT_PRIMARY to DEFAULT_SECONDARY) private set + const val RGB_SPEEDS = 10 + const val DEFAULT_RGB_SPEED = 3 + const val RGB_HUE_STEP = 3f + + /** How fast Inner Gamer cycles, 1 (slow) to [RGB_SPEEDS] (fast). */ + var rgbSpeed by mutableStateOf(DEFAULT_RGB_SPEED) + private set + + /** Where Inner Gamer is on the colour wheel, in whole [RGB_HUE_STEP] steps so the app restyles only when the colour visibly moves. */ + var rgbHue by mutableStateOf(0f) + private set + + fun moveRgbHue(hue: Float) { + rgbHue = hue + } + + fun setRgbSpeed(context: Context, speed: Int) { + val value = speed.coerceIn(1, RGB_SPEEDS) + SessionPrefs.setRgbSpeed(context, value) + rgbSpeed = value + } + + /** Milliseconds for one trip round the colour wheel. */ + fun rgbCycleMillis(speed: Int): Int = 30_000 / speed.coerceIn(1, RGB_SPEEDS) + fun loadGradient(context: Context) { gradientColors = SessionPrefs.gradientColors(context) + rgbSpeed = SessionPrefs.rgbSpeed(context) } + /** The Inner Gamer palette at one point on the colour wheel. */ + fun innerGamer(hue: Float): Palette = ThemeColors.palette( + Color.hsv(hue % 360f, 0.9f, 1f).toArgb(), Color.hsv((hue + 60f) % 360f, 0.9f, 1f).toArgb(), + R.string.theme_inner_gamer, R.string.theme_inner_gamer_detail, INNER_GAMER, + ) + fun setGradient(context: Context, primary: Int, secondary: Int) { SessionPrefs.setGradientColors(context, primary, secondary) gradientColors = primary to secondary @@ -54,7 +100,7 @@ object Themes { /** The theme the person chose, with the custom colours read first; for code that is not in a composition. */ fun current(context: Context): Palette { val id = SessionPrefs.theme(context) - if (id == GRADIENT) loadGradient(context) + if (id == GRADIENT || id == INNER_GAMER) loadGradient(context) return byId(id) } @@ -87,10 +133,12 @@ object Themes { primary = Color(0xFF2E86FF), primary2 = Color(0xFF2684FF), onPrimary = Color(0xFF03111F), signal = Color(0xFF2E86FF), good = Color(0xFF4CD37F), ), + innerGamer(0f), ThemeColors.palette(DEFAULT_PRIMARY, DEFAULT_SECONDARY, R.string.theme_gradient, R.string.theme_gradient_detail), ) fun byId(id: String): Palette { + if (id == INNER_GAMER) return innerGamer(rgbHue) if (id == GRADIENT) return ThemeColors.palette(gradientColors.first, gradientColors.second, R.string.theme_gradient, R.string.theme_gradient_detail) return all.firstOrNull { it.id == id } ?: all[0] } @@ -103,6 +151,7 @@ val Palette.onSignal: Color get() = if (signal.luminance() > 0.18f) Color(0xFF03 @Composable fun DroidDeckTheme(theme: String? = null, appScale: Int = AppUiPrefs.DEFAULT_SCALE, content: @Composable () -> Unit) { val id = theme ?: SessionPrefs.theme(LocalContext.current) + if (id == Themes.INNER_GAMER) InnerGamerHue(Themes.rgbSpeed) val p = Themes.byId(id) val scheme = darkColorScheme( primary = p.primary, onPrimary = p.onPrimary, secondary = p.onSurfaceVariant, @@ -119,5 +168,38 @@ fun DroidDeckTheme(theme: String? = null, appScale: Int = AppUiPrefs.DEFAULT_SCA LocalPalette provides p, LocalDensity provides density, LocalConfiguration provides configuration, - ) { MaterialTheme(colorScheme = scheme, content = content) } + ) { + MaterialTheme(colorScheme = scheme, typography = ContentDirected) { + ProvideTextStyle(TextStyle(textDirection = TextDirection.Content), content) + } + } +} + +private val ContentDirected = Typography().run { + fun TextStyle.byContent() = copy(textDirection = TextDirection.Content) + Typography( + displayLarge.byContent(), displayMedium.byContent(), displaySmall.byContent(), + headlineLarge.byContent(), headlineMedium.byContent(), headlineSmall.byContent(), + titleLarge.byContent(), titleMedium.byContent(), titleSmall.byContent(), + bodyLarge.byContent(), bodyMedium.byContent(), bodySmall.byContent(), + labelLarge.byContent(), labelMedium.byContent(), labelSmall.byContent(), + ) +} + +/** Turns the colour wheel on the system's own animation clock; with animations off, in the app or in Android, the colour holds still. */ +@Composable +private fun InnerGamerHue(speed: Int) { + if (Motion.scale == 0f) return + key(speed) { InnerGamerCycle(speed) } +} + +/** One run of the cycle at one speed, picking up from the colour on screen so a new speed takes over at once without a jump. */ +@Composable +private fun InnerGamerCycle(speed: Int) { + val start = remember { Themes.rgbHue } + val hue by rememberInfiniteTransition(label = "innerGamer").animateFloat( + start, start + 360f, infiniteRepeatable(tween(Themes.rgbCycleMillis(speed).coerceAtLeast(1), easing = LinearEasing), RepeatMode.Restart), label = "hue", + ) + val stepped = ((hue % 360f) / Themes.RGB_HUE_STEP).toInt() * Themes.RGB_HUE_STEP + SideEffect { Themes.moveRgbHue(stepped) } } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ThemeColors.kt b/app/src/main/java/com/droiddeck/launcher/ui/ThemeColors.kt index b4b57737e..b2db63562 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ThemeColors.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ThemeColors.kt @@ -132,7 +132,7 @@ internal object ThemeColors { } } - fun palette(primary: Int, secondary: Int, label: Int, detail: Int): Palette { + fun palette(primary: Int, secondary: Int, label: Int, detail: Int, id: String = Themes.GRADIENT): Palette { val seen = readable(primary) val seenSecond = readable(secondary) val ink = inkFor(seen, seenSecond) @@ -146,7 +146,7 @@ internal object ThemeColors { second = nextSecond } return Palette( - Themes.GRADIENT, label, detail, + id, label, detail, background = tint(first, 0.50f, 0.045f), surface = tint(first, 0.45f, 0.075f), surfaceVariant = tint(first, 0.42f, 0.115f), line = tint(first, 0.40f, 0.16f), line2 = tint(first, 0.36f, 0.22f), onBackground = tint(first, 0.05f, 0.97f), onSurfaceVariant = tint(first, 0.16f, 0.68f), diff --git a/app/src/main/java/com/droiddeck/launcher/ui/UpdatesPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/UpdatesPage.kt index a44f5ca19..c11d5874d 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/UpdatesPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/UpdatesPage.kt @@ -9,6 +9,7 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.background +import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.hoverable @@ -88,6 +89,7 @@ class UpdatesActions( val onDismissPermission: () -> Unit = {}, ) +@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class) @Composable internal fun UpdatesPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier) { val u = s.updates @@ -95,10 +97,14 @@ internal fun UpdatesPage(s: FrontEndState, a: FrontEndActions, modifier: Modifie val me = remember { AppUpdates.installed() } val colors = MaterialTheme.colorScheme Column(modifier = modifier) { - PageHeader(stringResource(R.string.upd_title)) { + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp), + ) { Text( stringResource(R.string.upd_build, me.version, s.buildLabel), fontSize = 12.5.sp, color = colors.onSurfaceVariant, - maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f).padding(top = 6.dp), + maxLines = 1, modifier = Modifier.weight(1f) + .basicMarquee(iterations = Int.MAX_VALUE, initialDelayMillis = 700, velocity = 40.dp), ) val checked = u.catalog?.checkedAt?.takeIf { it > 0 } if (!LocalNarrowPane.current) Text( diff --git a/app/src/main/java/com/droiddeck/launcher/ui/WinComponentsEditor.kt b/app/src/main/java/com/droiddeck/launcher/ui/WinComponentsEditor.kt index 155c6e9b2..235f2f98b 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/WinComponentsEditor.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/WinComponentsEditor.kt @@ -56,7 +56,7 @@ import java.io.File private fun installable(name: String, all: Map<String, WinComponents.Component>): String = WinComponents.installable(name, all) @Composable -private fun originName(origin: String): String = stringResource( +private fun originName(origin: String): String = if (origin == WinCompSources.Origin.XBOX.name) "Xbox" else stringResource( when (origin) { WinCompSources.Origin.GOG.name -> R.string.wincomp_src_gog WinCompSources.Origin.EPIC.name -> R.string.wincomp_src_epic @@ -71,6 +71,7 @@ private fun autoReason(r: WinComponents.AutoReason): String = when (r.kind) { WinCompSources.Origin.GOG.name -> stringResource(R.string.wincomp_auto_gog) WinCompSources.Origin.EPIC.name -> stringResource(R.string.wincomp_auto_epic) WinCompSources.Origin.AMAZON.name -> stringResource(R.string.wincomp_auto_amazon) + WinCompSources.Origin.XBOX.name -> "Xbox games install it with the game (${r.args.getOrElse(0) { "VC14" }})" else -> "" } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/WirelessAdbFixPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/WirelessAdbFixPage.kt index 8117c3ee5..5c719ca90 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/WirelessAdbFixPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/WirelessAdbFixPage.kt @@ -356,6 +356,7 @@ internal fun AdbTextField( placeholder: String? = null, compact: Boolean = false, onFocusChange: (Boolean) -> Unit = {}, + clearFocus: Boolean = false, onNext: (() -> Unit)? = null, onDone: (() -> Unit)? = null, focusOnShow: Boolean = false, @@ -444,6 +445,11 @@ internal fun AdbTextField( ?.showSoftInput(editText, android.view.inputmethod.InputMethodManager.SHOW_IMPLICIT) } } + if (clearFocus && editText.hasFocus()) { + editText.clearFocus() + (editText.context.getSystemService(android.content.Context.INPUT_METHOD_SERVICE) as? android.view.inputmethod.InputMethodManager) + ?.hideSoftInputFromWindow(editText.windowToken, 0) + } editText.setTextColor(colors.onBackground.toArgb()) editText.setHintTextColor(colors.onSurfaceVariant.toArgb()) editText.hint = placeholder diff --git a/app/src/main/java/com/droiddeck/launcher/update/SelfInstaller.kt b/app/src/main/java/com/droiddeck/launcher/update/SelfInstaller.kt index 4ba3a20cc..87d958889 100644 --- a/app/src/main/java/com/droiddeck/launcher/update/SelfInstaller.kt +++ b/app/src/main/java/com/droiddeck/launcher/update/SelfInstaller.kt @@ -91,7 +91,8 @@ object SelfInstaller { } } - fun install(context: Context, apk: File) { + /** Reports the session id before the commit, so a status broadcast that arrives right away is recognised. */ + fun install(context: Context, apk: File, onSession: (Int) -> Unit = {}) { val installer = context.packageManager.packageInstaller val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL).apply { setAppPackageName(context.packageName) @@ -108,10 +109,23 @@ object SelfInstaller { } val status = Intent(ACTION_STATUS).setPackage(context.packageName) val flags = PendingIntent.FLAG_UPDATE_CURRENT or (if (Build.VERSION.SDK_INT >= 31) PendingIntent.FLAG_MUTABLE else 0) + onSession(id) session.commit(PendingIntent.getBroadcast(context, id, status, flags).intentSender) } } + /** Only a system installer activity may be launched from the status broadcast, without URI grants. */ + fun safeConfirmIntent(context: Context, confirm: Intent): Intent? { + val target = confirm.resolveActivity(context.packageManager) ?: return null + val info = runCatching { context.packageManager.getApplicationInfo(target.packageName, 0) }.getOrNull() ?: return null + if (info.flags and android.content.pm.ApplicationInfo.FLAG_SYSTEM == 0) return null + return Intent(confirm).setComponent(target).apply { + flags = flags and (Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION or + Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or Intent.FLAG_GRANT_PREFIX_URI_PERMISSION).inv() + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + } + fun describeFailure(context: Context, status: Int, message: String?): String = when { message?.contains("VERSION_DOWNGRADE") == true -> context.getString(R.string.upd_fail_downgrade) status == PackageInstaller.STATUS_FAILURE_CONFLICT || message?.contains("UPDATE_INCOMPATIBLE") == true -> diff --git a/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt b/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt index bb466c133..348e8ff1f 100644 --- a/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt +++ b/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt @@ -23,6 +23,7 @@ object CompositorHost { @Synchronized fun startOrAttach( + context: android.content.Context, surface: Surface, xdgRuntimeDir: String, driverPath: String?, @@ -41,7 +42,7 @@ object CompositorHost { resumeVsync() return false } - com.droiddeck.launcher.gpu.TpUbwcHint.applyToProcess() + com.droiddeck.launcher.gpu.TpUbwcHint.applyToProcess(context) WaylandCompositor.nativeStartWithSurface( surface, xdgRuntimeDir, driverPath, libraryName, nativeLibDir, ) diff --git a/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java b/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java index 48d9b3576..4111cea2c 100644 --- a/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java +++ b/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java @@ -307,6 +307,13 @@ public final class WaylandCompositor { public static native float[] nativeFrameGenStats(); + /** Stream mode: the scene is the Xbox Cloud Gaming native player's picture, handed over frame by frame. */ + public static native void nativeStreamMode(boolean on); + + public static native void nativeStreamFrame(android.hardware.HardwareBuffer buffer); + + public static native int nativeStreamFrames(); + public static void sendPointerDelta(int dx, int dy) { if (dx == 0 && dy == 0) return; nativeSendSceneInput(6, dx * 256, dy * 256); diff --git a/app/src/main/java/org/webrtc/DroidDeckLowLatencyDecoderFactory.java b/app/src/main/java/org/webrtc/DroidDeckLowLatencyDecoderFactory.java new file mode 100644 index 000000000..2a196ed2d --- /dev/null +++ b/app/src/main/java/org/webrtc/DroidDeckLowLatencyDecoderFactory.java @@ -0,0 +1,257 @@ +package org.webrtc; + +import android.graphics.ImageFormat; +import android.media.Image; +import android.media.ImageReader; +import android.media.MediaCodec; +import android.media.MediaCodecInfo; +import android.media.MediaCodecList; +import android.media.MediaCrypto; +import android.media.MediaFormat; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.SystemClock; +import android.view.Surface; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.HashMap; + +/** + * DroidDeck's hardware video decoder factory for the native Xbox Cloud Gaming player. It builds WebRTC's own + * AndroidVideoDecoder (hence this package) around a MediaCodec wrapper that can: + * - ask the decoder for low-latency output (Android's KEY_LOW_LATENCY and Qualcomm's low-latency / picture-order + * vendor keys; unknown keys are ignored, and a decoder that refuses them is configured without them); + * - for zero-copy, decode straight onto the player's SurfaceView instead of WebRTC's texture, rendering every + * output buffer there (no GL copy, no compositor; WebRTC then gets no decoded frames to draw). + * Without either option it behaves like WebRTC's HardwareVideoDecoderFactory. + * The route is switchable while the decoder runs (setOutputSurface): {@link #useScreen} sends frames straight to the + * screen (zero-copy) or, with null, back to WebRTC's texture (for the Vulkan compositor and frame generation); + * {@link #setZeroCopySurface} moves a zero-copy decoder to a stand-in surface while the screen surface is gone. + * The factory also reports the decoded size and the decode time (input queued to output ready), which WebRTC's + * stats lack in zero-copy. + */ +public class DroidDeckLowLatencyDecoderFactory implements VideoDecoderFactory { + /** Counts frames sent to the display in zero-copy mode (WebRTC's own stats see none). */ + public interface FrameCounter { void onFrameShown(); } + + private final HardwareVideoDecoderFactory hardware; + private final EglBase.Context sharedContext; + private final boolean lowLatency; + /** Frames go to {@link #zeroCopySurface} (the screen or the stand-in), not WebRTC's texture. */ + private volatile boolean zeroCopy; + private volatile Surface zeroCopySurface; + /** Back on WebRTC's texture: render the next buffer there, so WebRTC's pending texture frame completes. */ + private volatile boolean renderNext; + private final FrameCounter counter; + private volatile Wrapper live; + private ImageReader standIn; + private HandlerThread standInThread; + private volatile int width, height; + private final Object decodeLock = new Object(); + private final HashMap<Long, Long> queuedAt = new HashMap<>(); + private double decodeSumMs; + private int decodeCount; + + public DroidDeckLowLatencyDecoderFactory(EglBase.Context sharedContext, boolean lowLatency, Surface zeroCopySurface, FrameCounter counter) { + this.hardware = new HardwareVideoDecoderFactory(sharedContext); + this.sharedContext = sharedContext; + this.lowLatency = lowLatency; + this.zeroCopy = zeroCopySurface != null; + this.zeroCopySurface = zeroCopySurface; + this.counter = counter; + } + + /** + * Zero-copy: the surface to decode onto from now on; null = a stand-in that drops every frame (the screen + * surface is being destroyed). Must be called before a destroyed surface's callback returns. + */ + public synchronized void setZeroCopySurface(Surface surface) { + if (!zeroCopy) return; + moveTo(surface != null ? surface : standInSurface(), surface != null ? "the screen" : "a stand-in"); + } + + /** + * Switch the route live: a surface = zero-copy onto it; null = WebRTC's texture again. Returns false when the + * decoder refused the new surface (the route is then unchanged). + */ + public synchronized boolean useScreen(Surface screen) { + if (screen != null) { + Wrapper w = live; + if (w != null && !moveTo(screen, "the screen")) return false; + zeroCopySurface = screen; + zeroCopy = true; + return true; + } + if (!zeroCopy) return true; + Wrapper w = live; + if (w != null && w.texture != null && !moveTo(w.texture, "WebRTC's texture")) return false; + zeroCopy = false; + renderNext = true; + return true; + } + + private boolean moveTo(Surface target, String what) { + Wrapper w = live; + if (w == null || target == null) { if (zeroCopy) zeroCopySurface = target; return true; } + try { + w.raw.setOutputSurface(target); + if (zeroCopy) zeroCopySurface = target; + Logging.d("DroidDeckDecoder", "decoder output moved to " + what); + return true; + } catch (RuntimeException e) { + Logging.w("DroidDeckDecoder", "decoder output change to " + what + " failed: " + e); + return false; + } + } + + private Surface standInSurface() { + if (standIn == null) { + standInThread = new HandlerThread("bl-decoder-standin"); + standInThread.start(); + standIn = ImageReader.newInstance(Math.max(width, 16), Math.max(height, 16), ImageFormat.PRIVATE, 3); + standIn.setOnImageAvailableListener(reader -> { + Image image = reader.acquireLatestImage(); + if (image != null) image.close(); + }, new Handler(standInThread.getLooper())); + } + return standIn.getSurface(); + } + + /** The decoder's picture size, or null before the first decoder is configured. */ + public String videoSize() { + return width > 0 ? width + "x" + height : null; + } + + /** Average decode time since the last call, in ms (-1 when no frame was decoded in between). */ + public double takeDecodeMs() { + synchronized (decodeLock) { + double avg = decodeCount > 0 ? decodeSumMs / decodeCount : -1; + decodeSumMs = 0; decodeCount = 0; + return avg; + } + } + + public synchronized void release() { + if (standIn != null) { standIn.close(); standIn = null; } + if (standInThread != null) { standInThread.quitSafely(); standInThread = null; } + } + + @Override + public VideoCodecInfo[] getSupportedCodecs() { + return hardware.getSupportedCodecs(); + } + + @Override + public VideoDecoder createDecoder(VideoCodecInfo info) { + VideoCodecMimeType type; + try { type = VideoCodecMimeType.valueOf(info.getName()); } catch (IllegalArgumentException e) { return hardware.createDecoder(info); } + MediaCodecInfo codec = findHardwareDecoder(type); + if (codec == null) return hardware.createDecoder(info); + Integer color = MediaCodecUtils.selectColorFormat(MediaCodecUtils.DECODER_COLOR_FORMATS, codec.getCapabilitiesForType(type.mimeType())); + if (color == null) return hardware.createDecoder(info); + return new AndroidVideoDecoder(new WrapperFactory(), codec.getName(), type, color, sharedContext); + } + + private static MediaCodecInfo findHardwareDecoder(VideoCodecMimeType type) { + for (MediaCodecInfo info : new MediaCodecList(MediaCodecList.REGULAR_CODECS).getCodecInfos()) { + if (info.isEncoder()) continue; + if (MediaCodecUtils.codecSupportsType(info, type) && MediaCodecUtils.isHardwareAccelerated(info)) return info; + } + return null; + } + + private class WrapperFactory implements MediaCodecWrapperFactory { + @Override + public MediaCodecWrapper createByCodecName(String name) throws IOException { + return new Wrapper(MediaCodec.createByCodecName(name)); + } + } + + /** MediaCodec itself (not WebRTC's wrapper) so the output surface can be changed while it runs. */ + private class Wrapper implements MediaCodecWrapper { + final MediaCodec raw; + /** WebRTC's texture surface, the route back from zero-copy. */ + Surface texture; + Wrapper(MediaCodec raw) { this.raw = raw; } + + @Override + public void configure(MediaFormat format, Surface surface, MediaCrypto crypto, int flags) { + width = format.getInteger(MediaFormat.KEY_WIDTH); + height = format.getInteger(MediaFormat.KEY_HEIGHT); + texture = surface; + live = this; + Surface target = surface; + if (zeroCopy) { synchronized (DroidDeckLowLatencyDecoderFactory.this) { target = zeroCopySurface != null ? zeroCopySurface : standInSurface(); } } + if (lowLatency) { + MediaFormat fast = MediaFormat.createVideoFormat(format.getString(MediaFormat.KEY_MIME), width, height); + copyInt(format, fast, MediaFormat.KEY_COLOR_FORMAT); + if (Build.VERSION.SDK_INT >= 30) fast.setInteger(MediaFormat.KEY_LOW_LATENCY, 1); + fast.setInteger(MediaFormat.KEY_PRIORITY, 0); + fast.setInteger("vendor.qti-ext-dec-low-latency.enable", 1); + fast.setInteger("vendor.qti-ext-dec-picture-order.enable", 1); + try { + raw.configure(fast, target, crypto, flags); + Logging.d("DroidDeckDecoder", "low-latency decoder configured" + (zeroCopy ? " (zero-copy)" : "")); + return; + } catch (RuntimeException e) { + Logging.w("DroidDeckDecoder", "decoder refused low-latency keys: " + e); + } + } + raw.configure(format, target, crypto, flags); + } + + private void copyInt(MediaFormat from, MediaFormat to, String key) { + if (from.containsKey(key)) to.setInteger(key, from.getInteger(key)); + } + + // Zero-copy: AndroidVideoDecoder renders one buffer to its texture and drops the rest until the texture + // hands the frame back, which never happens with the display surface; show every buffer instead. + @Override public void releaseOutputBuffer(int index, boolean render) { + if (zeroCopy) { raw.releaseOutputBuffer(index, true); if (counter != null) counter.onFrameShown(); } + else if (renderNext) { renderNext = false; raw.releaseOutputBuffer(index, true); } + else raw.releaseOutputBuffer(index, render); + } + + @Override public void queueInputBuffer(int index, int offset, int size, long presentationTimeUs, int flags) { + synchronized (decodeLock) { + if (queuedAt.size() > 64) queuedAt.clear(); + queuedAt.put(presentationTimeUs, SystemClock.elapsedRealtimeNanos()); + } + raw.queueInputBuffer(index, offset, size, presentationTimeUs, flags); + } + + @Override public int dequeueOutputBuffer(MediaCodec.BufferInfo info, long timeoutUs) { + int index = raw.dequeueOutputBuffer(info, timeoutUs); + if (index >= 0) synchronized (decodeLock) { + Long at = queuedAt.remove(info.presentationTimeUs); + if (at != null) { decodeSumMs += (SystemClock.elapsedRealtimeNanos() - at) / 1e6; decodeCount++; } + } + return index; + } + + @Override public MediaFormat getOutputFormat() { + MediaFormat f = raw.getOutputFormat(); + if (f.containsKey(MediaFormat.KEY_WIDTH) && f.containsKey(MediaFormat.KEY_HEIGHT)) { + width = f.containsKey("crop-right") ? f.getInteger("crop-right") - f.getInteger("crop-left") + 1 : f.getInteger(MediaFormat.KEY_WIDTH); + height = f.containsKey("crop-bottom") ? f.getInteger("crop-bottom") - f.getInteger("crop-top") + 1 : f.getInteger(MediaFormat.KEY_HEIGHT); + } + return f; + } + + @Override public void start() { raw.start(); } + @Override public void flush() { synchronized (decodeLock) { queuedAt.clear(); } raw.flush(); } + @Override public void stop() { raw.stop(); } + @Override public void release() { if (live == this) live = null; raw.release(); } + @Override public int dequeueInputBuffer(long timeoutUs) { return raw.dequeueInputBuffer(timeoutUs); } + @Override public MediaFormat getInputFormat() { return raw.getInputFormat(); } + @Override public MediaFormat getOutputFormat(int index) { return raw.getOutputFormat(index); } + @Override public ByteBuffer getInputBuffer(int index) { return raw.getInputBuffer(index); } + @Override public ByteBuffer getOutputBuffer(int index) { return raw.getOutputBuffer(index); } + @Override public Surface createInputSurface() { return raw.createInputSurface(); } + @Override public void setParameters(Bundle params) { raw.setParameters(params); } + @Override public MediaCodecInfo getCodecInfo() { return raw.getCodecInfo(); } + } +} diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index d60a59fb9..fa1c13e5e 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -267,6 +267,24 @@ <string name="osc_steam_touch">لمس Steam (تجريبي)</string> <string name="osc_style_next_session">تبدأ عناصر التحكم باللمس من Steam مع جلسة Steam التالية</string> <string name="osc_steam_layout">تخطيط لمس Steam</string> + <string name="screen_keys">مفاتيح الشاشة</string> + <string name="screen_keys_add">إضافة مفتاح</string> + <string name="screen_key_text">نص</string> + <string name="screen_key_size">الحجم</string> + <string name="screen_key_opacity">العتامة</string> + <string name="screen_key_type_button">مفتاح</string> + <string name="screen_key_shape_trigger">زناد</string> + <string name="screen_key_toggle">تبديل</string> + <string name="screen_key_layout_export">تصدير</string> + <string name="screen_key_shape_pill">كبسولة</string> + <string name="screen_key_shape_round">مستدير</string> + <string name="screen_key_shape_circle">دائرة</string> + <string name="screen_key_shape_rect">مستطيل</string> + <string name="screen_key_bind">مفتاح</string> + <string name="screen_key_image">صورة</string> + <string name="screen_key_image_clear">مسح</string> + <string name="screen_key_layout">التخطيط</string> + <string name="screen_key_layout_save">حفظ</string> <string name="osc_steam_layout_hint">انقل عناصر التحكم باللمس لهذه اللعبة أو غيّر حجمها أو أخفها؛ ويتم الحفظ في Steam</string> <string name="mode_controller">وحدة التحكم</string> <string name="mode_controller_hint">كيف يرى Steam وحدة التحكم لديك. يُطبَّق في الجلسة التالية.</string> @@ -287,9 +305,6 @@ <string name="mode_suspend_steam_note">«تلقائي» يوقف مؤقتًا ويستأنف عند الظهور. «أصلي» يجهّز Steam للسكون أولًا. «يدوي» ينتظر الضغط على «استئناف». «أبدًا» يُبقيه قيد التشغيل.</string> <string name="mode_background_downloads">مواصلة التنزيلات</string> <string name="mode_background_downloads_hint">تأخير التعليق في الخلفية أثناء قيام Steam بالتنزيل.</string> - <string name="mode_zram">ضغط zRAM</string> - <string name="mode_zram_hint">أثناء تشغيل اللعبة يتم ضغط ذاكرة عميل Steam إلى zram في الجهاز، فتحصل اللعبة على ذاكرة RAM أكبر. يسري عند تشغيل Steam في المرة القادمة.</string> - <string name="mode_zram_unsupported">غير متاح: لا يحتوي هذا الجهاز على zram أو swap، أو أن النواة أقدم من 5.4.</string> <string name="mode_suspend_note">«تلقائي» يوقف مؤقتًا في الخلفية ويستأنف عند الظهور. «يدوي» ينتظر الضغط على «استئناف». «أبدًا» يُبقيه قيد التشغيل.</string> <string name="mode_startup">بدء التشغيل</string> <string name="mode_steam_startup">تشغيل Steam مع DroidDeck</string> @@ -552,8 +567,8 @@ <string name="perf_sysmem">Turnip: التصيير عبر sysmem</string> <string name="perf_sysmem_hint">أسرع لعميل Steam؛ في الألعاب يختار برنامج التشغيل بين العرض المقسّم إلى مربعات والعرض في ذاكرة النظام لكل مرحلة. مطلوب على Adreno 710/720/722.</string> <string name="perf_xalia">تجاوز مساعد xalia في Steam</string> - <string name="perf_fastsync">droiddeck-ntsync (تجريبي)</string> - <string name="perf_fastsync_hint">بديل في مساحة المستخدم لبرنامج تشغيل ntsync في Linux، للاختبار. أثناء تفعيله، يستخدمه Proton بدلًا من droiddeck-esync وdroiddeck-fsync. يعمل مع Proton Experimental ARM64 وGE-Proton وproton-cachyos. يُطبَّق في الجلسة التالية.</string> + <string name="perf_fastsync">droiddeck-ntsync</string> + <string name="perf_fastsync_hint">بديل في مساحة المستخدم لبرنامج تشغيل ntsync في Linux. أثناء تفعيله، يستخدمه Proton بدلًا من droiddeck-esync وdroiddeck-fsync. يعمل مع Proton Experimental ARM64 وGE-Proton وproton-cachyos. يُطبَّق في الجلسة التالية.</string> <string name="perf_esync">droiddeck-esync</string> <string name="perf_fsync">droiddeck-fsync أولًا</string> <string name="perf_fsync_hint">يشغّل خاصية fsync المدمجة في Proton على ملفات Proton الأصلية، مع توفير futex_waitv في مساحة المستخدم، فلا يحتاج إلى حزمة ويستمر في العمل لحظة تحديث Steam لـ Proton. أثناء تفعيله، تستخدمه الألعاب قبل droiddeck-esync، باستثناء القليل الذي يبقيه Proton معطّلًا لـ fsync. يُطبَّق في الجلسة التالية.</string> @@ -1050,8 +1065,6 @@ <string name="games_empty">الألعاب التي تثبّتها في Steam تظهر هنا بعد الجلسة.</string> <string name="games_play_steam">تشغيل Steam</string> <string name="games_play_steamvr">تشغيل SteamVR</string> - <string name="games_launch_settings">إعدادات التشغيل</string> - <string name="games_launch">تشغيل</string> <string name="games_files">ملفات اللعبة</string> <string name="games_prefix">بادئة Proton</string> <string name="games_saves_looking">جارٍ البحث عن الحفظ…</string> @@ -1059,7 +1072,6 @@ <string name="games_saves_folders">%1$d مجلدات حفظ</string> <string name="games_saves_summary">%1$s · %2$d ملفات · %3$s</string> <string name="games_kb">%1$d KB</string> - <string name="games_manage_saves">إدارة الحفظ</string> <string name="games_saves">حفظ اللعبة</string> <string name="games_import_saves">استيراد الحفظ…</string> <string name="games_export_gamehub">تصدير بصيغة GameHub المضغوطة…</string> @@ -1071,7 +1083,6 @@ <string name="games_runtime_first_play">تُثبَّت بيئة التشغيل عند أول تشغيل</string> <string name="games_runtime_update">يتوفر تحديث لبيئة التشغيل</string> <string name="games_runtime_ready">● بيئة التشغيل جاهزة</string> - <string name="games_last_played">آخر تشغيل %1$s</string> <string name="games_internal">التخزين الداخلي</string> <string name="games_added">لعبة مُضافة</string> <string name="games_custom">لعبة مخصّصة</string> @@ -1140,7 +1151,7 @@ <string name="second_terminal">الوحدة الطرفية لـ Linux</string> <string name="second_terminal_not_ready">جلسة Linux ليست جاهزة للوحدة الطرفية بعد.</string> <string name="rail_steam">Steam</string> - <string name="rail_games">الألعاب</string> + <string name="rail_games">المكتبة</string> <string name="rail_desktop">سطح المكتب</string> <string name="rail_store">المتجر</string> <string name="rail_components">المكوّنات</string> @@ -1460,6 +1471,10 @@ <string name="theme_icon_blue_detail">خلفية سوداء ونص أبيض، والأزرق لكل عنصر محدَّد أو مُركَّز عليه أو مضغوط.</string> <string name="theme_electric">كحلي كهربائي</string> <string name="theme_electric_detail">أزرق أكثر سطوعًا، على أسطح تميل إلى الكحلي بدلاً من الرمادي.</string> + <string name="theme_inner_gamer">Inner Gamer</string> + <string name="theme_inner_gamer_detail">ألوان تتدرّج عبر ألوان قوس قزح. تتبع إعدادات الرسوم المتحركة في Android.</string> + <string name="theme_rgb_speed">السرعة</string> + <string name="theme_rgb_speed_hint">مدى سرعة تغيّر الألوان.</string> <string name="theme_gradient">تدرج لوني</string> <string name="theme_gradient_detail">لونك الأساسي والثانوي، ممزوجان على الأزرار وعناصر التمييز.</string> <string name="theme_gradient_primary">الأساسي</string> @@ -1601,6 +1616,13 @@ <string name="fm_conflict_skip">تخطّي</string> <string name="fm_select_this_folder">اختيار هذا المجلد</string> <string name="fm_back">رجوع</string> + <string name="fm_exit">خروج</string> + <string name="fm_return_to_start">العودة إلى المجلد الأول</string> + <string name="fm_start_unavailable">لم يعد المجلد الأول متاحًا</string> + <string name="fm_cancel_operation_title">إلغاء العملية؟</string> + <string name="fm_cancel_operation_confirmation">سيتم إلغاء العملية. هل أنت متأكد؟</string> + <string name="fm_accept">موافق</string> + <string name="fm_decline">رفض</string> <string name="fm_drive_not_readable">تم تركيب %1$s لكن تتعذّر قراءته حاليًا</string> <string name="fm_list_view">عرض القائمة</string> <string name="fm_grid_view">عرض الشبكة</string> @@ -2023,7 +2045,6 @@ <string name="source_steam">Steam</string> <string name="source_added">مخصَّص</string> <string name="setup_stores">المتاجر</string> - <string name="setup_stores_open_on">فتح متجر على</string> <string name="setup_stores_speed">فئة سرعة التنزيل</string> <string name="setup_stores_engine">محرك المتاجر</string> <string name="setup_stores_engine_chip_ready">● المحرك جاهز</string> @@ -2037,11 +2058,9 @@ <string name="stores_tabs_arrange">ترتيب التبويبات</string> <string name="stores_tabs_arrange_done">تم</string> <string name="stores_chip_signed_out">(لم يتم تسجيل الدخول)</string> - <string name="stores_tab_store">المتجر</string> <string name="stores_tab_library">المكتبة</string> <string name="stores_tab_all">الكل</string> <string name="stores_tab_downloads">التنزيلات</string> - <string name="stores_tab_count">%1$s (%2$d)</string> <string name="stores_signin">تسجيل الدخول</string> <string name="stores_signout_named">%1$s · تسجيل الخروج</string> <string name="stores_signout_title">تسجيل الخروج من %1$s؟</string> @@ -2059,9 +2078,10 @@ <string name="stores_shelf_deals">العروض</string> <string name="stores_shelf_free">ألعاب مجانية</string> <string name="stores_shelf_trending">الرائج</string> - <string name="stores_shelf_library">مكتبتك</string> <string name="stores_play">تشغيل</string> <string name="stores_install">تثبيت</string> + <string name="bnet_open">فتح Battle.net</string> + <string name="bnet_store">ألعاب Blizzard</string> <string name="stores_resume_install">استئناف التثبيت</string> <string name="stores_dl_interrupted">منقطع</string> <string name="stores_install_size">تثبيت · %1$s</string> @@ -2080,8 +2100,32 @@ <string name="stores_itch_no_exe">لم يتم العثور على لعبة Windows في التنزيل</string> <string name="stores_itch_extract_failed">تعذر استخراج التنزيل (رمز الخروج %1$d)</string> <string name="stores_itch_no_catalog">لم يستجب itch.io</string> + <string name="stores_itch_sort_popular">الأكثر شعبية</string> + <string name="stores_itch_sort_new">الأحدث</string> + <string name="stores_itch_sort_new_popular">جديد ورائج</string> + <string name="stores_itch_price_paid">مدفوع</string> + <string name="stores_itch_price_under_5">5 دولارات أو أقل</string> + <string name="stores_itch_price_under_15">15 دولارًا أو أقل</string> + <string name="stores_itch_sort_top_rated">الأعلى تقييمًا</string> + <string name="stores_itch_sort_top_sellers">الأكثر مبيعًا</string> + <string name="stores_itch_price_any">أي سعر</string> + <string name="stores_itch_price_free">مجاني</string> + <string name="stores_itch_price_sale">مخفّض</string> + <string name="stores_itch_load_more">تحميل المزيد</string> + <string name="stores_itch_platform_any">أي منصة</string> + <string name="stores_itch_platform_mobile_web">ويب الجوال</string> + <string name="stores_itch_tag_add">إضافة وسم</string> + <string name="stores_itch_tag_title">التصفية حسب الوسم</string> + <string name="stores_itch_browse_failed">تعذّر تحميل الألعاب من itch.io</string> <string name="stores_itch_archive">أرشيفات %1$s غير مدعومة</string> <string name="stores_itch_download_failed">استجاب itch.io بـ HTTP %1$d</string> + <string name="stores_itch_pick_title">اختر إصدارًا</string> + <string name="stores_itch_pick_none">لم يُعثر على تنزيلات لهذه اللعبة</string> + <string name="stores_itch_pick_failed">تعذر تحميل التنزيلات من itch.io</string> + <string name="stores_itch_build_demo">نسخة تجريبية</string> + <string name="stores_itch_build_open">فتح على itch.io</string> + <string name="stores_itch_build_linux_soon">غير مدعوم بعد</string> + <string name="stores_itch_no_game">تعذر العثور على هذه اللعبة على itch.io</string> <string name="stores_free">مجاني</string> <string name="stores_resume">استئناف</string> <string name="stores_refresh">تحديث</string> @@ -2234,6 +2278,7 @@ <string name="stores_login_close">إغلاق</string> <string name="stores_login_retry">حاوِل مرة أخرى</string> <string name="stores_login_finishing">جارٍ إنهاء تسجيل الدخول…</string> + <string name="stores_xbox_sign_in_again">رفضت Microsoft تسجيل الدخول إلى Xbox. سجّل الخروج من Xbox في المتاجر، ثم سجّل الدخول مجددًا، ثم استأنف.</string> <string name="stores_login_error_network">تعذّر تحميل صفحة تسجيل الدخول: %1$s</string> <string name="stores_login_error_idp_blocked">يرفض هذا المزوّد تسجيل الدخول من صفحة مضمَّنة. سجِّل الدخول باستخدام كلمة مرور المتجر بدلاً من ذلك.</string> <string name="stores_login_error_http">استجابت صفحة تسجيل الدخول بخطأ (HTTP %1$d).</string> @@ -3118,4 +3163,57 @@ <string name="spatial_saved_views_hint">يمكن إعادة حساب المشاهدات المحفوظة. ابدأ معايرة جديدة لالتقاطها في نظام إحداثيات التتبع الحالي.</string> <string name="spatial_model_checksum_error">المجموع الاختباري لنموذج تتبع اليدين غير مطابق</string> <string name="spatial_opencv_load_error">تعذر تحميل OpenCV</string> + <string name="setup_group_memory">الذاكرة</string> + <string name="setup_clear_cached">إغلاق التطبيقات المخزّنة مؤقتًا قبل الجلسة</string> + <string name="setup_clear_cached_hint">عند بدء الجلسة، يُغلق DroidDeck التطبيقات التي يُبقيها Android في الخلفية، لتحصل اللعبة على ذاكرة أكبر. يجب تفعيل تصحيح الأخطاء اللاسلكي.</string> + <string name="setup_clear_cached_unpaired">يتطلّب إقران DroidDeck بتصحيح الأخطاء اللاسلكي (إقران، أدناه).</string> + <string name="setup_memory_hud">مؤشر الذاكرة</string> + <string name="setup_memory_hud_hint">الذاكرة وswap المستخدمة، على شاشة الجلسة. يقسّمها الوضع المتقدّم بين اللعبة وDroidDeck وAndroid وGPU.</string> + <string name="setup_memory_hud_simple">بسيط</string> + <string name="setup_memory_hud_advanced">متقدّم</string> + <string name="memory_hud_simple">RAM %1$s/%2$s GB · swap %3$s/%4$s GB</string> + <string name="memory_hud_split">اللعبة %1$s · DroidDeck %2$s · Android %3$s GB</string> + <string name="memory_hud_gpu">GPU %1$s GB</string> + <string name="setup_bg_limit">التطبيقات في الخلفية أثناء الجلسة</string> + <string name="setup_bg_limit_hint">أثناء تشغيل الجلسة، يُبقي Android هذا العدد من التطبيقات كحد أقصى في الذاكرة المؤقتة ويُغلق الباقي، لتحصل اللعبة على ذاكرتها. يُعيد DroidDeck الحد إلى ما كان عليه عند انتهاء الجلسة. يجب تفعيل تصحيح الأخطاء اللاسلكي.</string> + <string name="setup_bg_limit_4">4 كحد أقصى</string> + <string name="setup_bg_limit_1">1 كحد أقصى</string> + <string name="setup_bg_limit_0">لا شيء</string> + <string name="setup_adb_pair">تصحيح الأخطاء اللاسلكي</string> + <string name="setup_adb_pair_hint">يُقرن DroidDeck بتصحيح الأخطاء اللاسلكي في Android للخيارات أعلاه. يبقى حد العمليات الفرعية متوقفًا.</string> + <string name="setup_adb_pair_button">إقران</string> + <string name="epic_client_missing">غير مثبَّت</string> + <string name="epic_client_open">فتح</string> + <string name="epic_client_install">تثبيت</string> + <string name="game_tab_about">حول</string> + <string name="game_tab_install">تثبيت</string> + <string name="game_install_location">الموقع</string> + <string name="game_install_folder">مجلد</string> + <string name="game_install_size">الحجم على القرص</string> + <string name="game_install_date">مثبّت</string> + <string name="stores_xbox_update">تحديث / تحقق</string> + <string name="stores_xbox_manage">إدارة المحتوى</string> + <string name="stores_xbox_close_first">أغلق لعبة Xbox هذه قبل تحديثها.</string> + <string name="stores_xbox_content_title">محتوى Xbox</string> + <string name="stores_xbox_content_none">لا توجد حزم محتوى PC مرخّصة لهذه اللعبة.</string> + <string name="stores_xbox_content_update">%1$s · تحديث / تحقق</string> + <string name="stores_xbox_content_install">%1$s · تثبيت</string> + <string name="stores_xbox_choose_content">اختر محتوى PC</string> + <string name="stores_eyebrow_pass">متاحة مع %1$s</string> + <string name="stores_owned_in_library">الألعاب المملوكة في المكتبة</string> + <string name="library_filter_added">مُضافة</string> + <string name="game_tab_launch">التشغيل</string> + <string name="game_tab_saves_cloud">الحفظ والسحابة</string> + <string name="game_launch_shortcut">اختصار Steam</string> + <string name="game_install_version">الإصدار</string> + <string name="game_install_played">آخر لعب</string> + <string name="game_install_access">الوصول</string> + <string name="game_install_access_owned">مملوكة</string> + <string name="game_stream">بث</string> + <string name="library_add">إضافة إلى المكتبة</string> + <string name="library_remove">إزالة من المكتبة</string> + <string name="library_in">في المكتبة</string> + <string name="xbox_filter_show">عرض</string> + <string name="xbox_filter_cloud">سحابية</string> + <string name="stores_xbox_cloud_settings">إعدادات السحابة</string> </resources> diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index b924266f4..acbf053f1 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -267,6 +267,24 @@ <string name="osc_steam_touch">Táctil de Steam (beta)</string> <string name="osc_style_next_session">Los controles táctiles de Steam se activan en la próxima sesión de Steam</string> <string name="osc_steam_layout">Disposición táctil de Steam</string> + <string name="screen_keys">Teclas en pantalla</string> + <string name="screen_keys_add">Agregar tecla</string> + <string name="screen_key_text">Texto</string> + <string name="screen_key_size">Tamaño</string> + <string name="screen_key_opacity">Opacidad</string> + <string name="screen_key_type_button">Tecla</string> + <string name="screen_key_shape_trigger">Gatillo</string> + <string name="screen_key_toggle">Alternar</string> + <string name="screen_key_layout_export">Exportar</string> + <string name="screen_key_shape_pill">Píldora</string> + <string name="screen_key_shape_round">Redondeado</string> + <string name="screen_key_shape_circle">Círculo</string> + <string name="screen_key_shape_rect">Rectángulo</string> + <string name="screen_key_bind">Tecla</string> + <string name="screen_key_image">Imagen</string> + <string name="screen_key_image_clear">Borrar</string> + <string name="screen_key_layout">Disposición</string> + <string name="screen_key_layout_save">Guardar</string> <string name="osc_steam_layout_hint">Mueve, cambia de tamaño u oculta los controles táctiles de este juego; se guarda en Steam</string> <string name="mode_controller">Control</string> <string name="mode_controller_hint">Cómo ve Steam tu control. Se aplica en la próxima sesión.</string> @@ -287,9 +305,6 @@ <string name="mode_suspend_steam_note">Auto pausa y reanuda al mostrarse. Nativo prepara Steam para la suspensión primero. Manual espera a Reanudar. Nunca sigue ejecutándose.</string> <string name="mode_background_downloads">Mantener las descargas activas</string> <string name="mode_background_downloads_hint">Retrasa la suspensión en segundo plano mientras Steam esté descargando.</string> - <string name="mode_zram">Compresión zRAM</string> - <string name="mode_zram_hint">Mientras se ejecuta un juego, la memoria del cliente de Steam se comprime en la zram del dispositivo, lo que deja más RAM para el juego. Se aplica la próxima vez que se inicie Steam.</string> - <string name="mode_zram_unsupported">No disponible: este dispositivo no tiene swap activo, o su kernel es anterior a la 5.4.</string> <string name="mode_suspend_note">Auto pausa en segundo plano y reanuda al mostrarse. Manual espera a Reanudar. Nunca sigue ejecutándose.</string> <string name="mode_startup">Inicio</string> <string name="mode_steam_startup">Iniciar Steam con DroidDeck</string> @@ -539,8 +554,8 @@ <string name="perf_sysmem">Turnip: renderizado sysmem</string> <string name="perf_sysmem_hint">Más rápido para el cliente de Steam; en los juegos, el controlador elige entre renderizado por mosaicos o en memoria del sistema en cada pasada. Obligatorio en Adreno 710/720/722.</string> <string name="perf_xalia">Omitir el asistente xalia de Steam</string> - <string name="perf_fastsync">droiddeck-ntsync (experimental)</string> - <string name="perf_fastsync_hint">Un sustituto en espacio de usuario del controlador ntsync de Linux, para pruebas. Mientras está activado, Proton lo usa en lugar de droiddeck-esync y droiddeck-fsync. Funciona con Proton Experimental ARM64, GE-Proton y proton-cachyos. Se aplica en la próxima sesión.</string> + <string name="perf_fastsync">droiddeck-ntsync</string> + <string name="perf_fastsync_hint">Un sustituto en espacio de usuario del controlador ntsync de Linux. Mientras está activado, Proton lo usa en lugar de droiddeck-esync y droiddeck-fsync. Funciona con Proton Experimental ARM64, GE-Proton y proton-cachyos. Se aplica en la próxima sesión.</string> <string name="perf_esync">droiddeck-esync</string> <string name="perf_fsync">droiddeck-fsync primero</string> <string name="perf_fsync_hint">Usa el fsync propio de Proton con los archivos originales de Proton, con futex_waitv proporcionado en espacio de usuario, así que no necesita paquete y sigue funcionando en cuanto Steam actualiza Proton. Mientras está activado, los juegos lo usan antes que droiddeck-esync, salvo los pocos que Proton mantiene sin fsync. Se aplica en la próxima sesión.</string> @@ -1089,8 +1104,6 @@ <string name="games_empty">Los juegos que instales en Steam aparecen aquí después de la sesión.</string> <string name="games_play_steam">Jugar en Steam</string> <string name="games_play_steamvr">Jugar en SteamVR</string> - <string name="games_launch_settings">Ajustes de inicio</string> - <string name="games_launch">Iniciar</string> <string name="games_files">Archivos del juego</string> <string name="games_prefix">Prefijo de Proton</string> <string name="games_saves_looking">Buscando partidas guardadas…</string> @@ -1098,7 +1111,6 @@ <string name="games_saves_folders">%1$d carpetas de guardado</string> <string name="games_saves_summary">%1$s · %2$d archivos · %3$s</string> <string name="games_kb">%1$d KB</string> - <string name="games_manage_saves">Administrar partidas guardadas</string> <string name="games_saves">Partidas guardadas</string> <string name="games_import_saves">Importar partidas guardadas…</string> <string name="games_export_gamehub">Exportar como zip de GameHub…</string> @@ -1110,7 +1122,6 @@ <string name="games_runtime_first_play">El runtime se instala al jugar</string> <string name="games_runtime_update">Actualización del runtime disponible</string> <string name="games_runtime_ready">● Runtime listo</string> - <string name="games_last_played">Última partida %1$s</string> <string name="games_internal">Almacenamiento interno</string> <string name="games_added">Juego agregado</string> <string name="games_custom">Juego personalizado</string> @@ -1179,7 +1190,7 @@ <string name="second_terminal">Terminal de Linux</string> <string name="second_terminal_not_ready">La sesión de Linux aún no está lista para un terminal.</string> <string name="rail_steam">Steam</string> - <string name="rail_games">Juegos</string> + <string name="rail_games">Biblioteca</string> <string name="rail_desktop">Escritorio</string> <string name="rail_store">Tienda</string> <string name="rail_components">Componentes</string> @@ -1469,6 +1480,10 @@ <string name="theme_icon_blue_detail">Fondo negro, texto blanco y el azul para todo lo seleccionado, enfocado o presionado.</string> <string name="theme_electric">Azul marino eléctrico</string> <string name="theme_electric_detail">Un azul más vivo, sobre superficies que tiran a azul marino en vez de gris.</string> + <string name="theme_inner_gamer">Inner Gamer</string> + <string name="theme_inner_gamer_detail">Colores que recorren el arcoíris. Respeta los ajustes de animación de Android.</string> + <string name="theme_rgb_speed">Velocidad</string> + <string name="theme_rgb_speed_hint">Qué tan rápido cambian los colores.</string> <string name="theme_gradient">Degradado</string> <string name="theme_gradient_detail">Tus propios colores primario y secundario, combinados en botones y resaltados.</string> <string name="theme_gradient_primary">Primario</string> @@ -1595,6 +1610,13 @@ <string name="fm_conflict_skip">Omitir</string> <string name="fm_select_this_folder">Seleccionar esta carpeta</string> <string name="fm_back">Atrás</string> + <string name="fm_exit">Salir</string> + <string name="fm_return_to_start">Volver a la carpeta inicial</string> + <string name="fm_start_unavailable">La carpeta inicial ya no está disponible</string> + <string name="fm_cancel_operation_title">¿Cancelar la operación?</string> + <string name="fm_cancel_operation_confirmation">La operación se cancelará. ¿Estás seguro?</string> + <string name="fm_accept">Aceptar</string> + <string name="fm_decline">Rechazar</string> <string name="fm_drive_not_readable">%1$s está montado, pero ahora mismo no se puede leer</string> <string name="fm_list_view">Vista de lista</string> <string name="fm_grid_view">Vista de cuadrícula</string> @@ -1991,7 +2013,6 @@ <string name="source_steam">Steam</string> <string name="source_added">Personalizados</string> <string name="setup_stores">Tiendas</string> - <string name="setup_stores_open_on">Abrir una tienda en</string> <string name="setup_stores_speed">Nivel de velocidad de descarga</string> <string name="setup_stores_engine">Motor de las tiendas</string> <string name="setup_stores_engine_chip_ready">● Motor listo</string> @@ -2005,11 +2026,9 @@ <string name="stores_tabs_arrange">Organizar pestañas</string> <string name="stores_tabs_arrange_done">Listo</string> <string name="stores_chip_signed_out">(sin iniciar sesión)</string> - <string name="stores_tab_store">Tienda</string> <string name="stores_tab_library">Biblioteca</string> <string name="stores_tab_all">Todos</string> <string name="stores_tab_downloads">Descargas</string> - <string name="stores_tab_count">%1$s (%2$d)</string> <string name="stores_signin">Iniciar sesión</string> <string name="stores_signout_named">%1$s · cerrar sesión</string> <string name="stores_signout_title">¿Cerrar sesión en %1$s?</string> @@ -2027,9 +2046,10 @@ <string name="stores_shelf_deals">Ofertas</string> <string name="stores_shelf_free">Juegos gratis</string> <string name="stores_shelf_trending">En tendencia</string> - <string name="stores_shelf_library">Tu biblioteca</string> <string name="stores_play">Jugar</string> <string name="stores_install">Instalar</string> + <string name="bnet_open">Abrir Battle.net</string> + <string name="bnet_store">Juegos de Blizzard</string> <string name="stores_resume_install">Reanudar instalación</string> <string name="stores_dl_interrupted">Interrumpida</string> <string name="stores_install_size">Instalar · %1$s</string> @@ -2186,6 +2206,7 @@ <string name="stores_login_close">Cerrar</string> <string name="stores_login_retry">Reintentar</string> <string name="stores_login_finishing">Terminando de iniciar sesión…</string> + <string name="stores_xbox_sign_in_again">Microsoft rechazó el inicio de sesión de Xbox. Cierra sesión de Xbox en Tiendas, vuelve a iniciar sesión y luego reanuda.</string> <string name="stores_login_error_network">No se pudo cargar la página de inicio de sesión: %1$s</string> <string name="stores_login_error_idp_blocked">Este proveedor no permite iniciar sesión desde una página integrada. Inicia sesión con la contraseña de la tienda.</string> <string name="stores_login_error_http">La página de inicio de sesión respondió con un error (HTTP %1$d).</string> @@ -2763,8 +2784,32 @@ <string name="stores_itch_no_exe">No se encontró un juego de Windows en la descarga</string> <string name="stores_itch_extract_failed">No se pudo extraer la descarga (salida %1$d)</string> <string name="stores_itch_no_catalog">itch.io no respondió</string> + <string name="stores_itch_sort_popular">Populares</string> + <string name="stores_itch_sort_new">Más recientes</string> + <string name="stores_itch_sort_new_popular">Nuevos y populares</string> + <string name="stores_itch_price_paid">De pago</string> + <string name="stores_itch_price_under_5">$5 o menos</string> + <string name="stores_itch_price_under_15">$15 o menos</string> + <string name="stores_itch_sort_top_rated">Mejor valorados</string> + <string name="stores_itch_sort_top_sellers">Más vendidos</string> + <string name="stores_itch_price_any">Cualquier precio</string> + <string name="stores_itch_price_free">Gratis</string> + <string name="stores_itch_price_sale">En oferta</string> + <string name="stores_itch_load_more">Cargar más</string> + <string name="stores_itch_platform_any">Cualquier plataforma</string> + <string name="stores_itch_platform_mobile_web">Web móvil</string> + <string name="stores_itch_tag_add">Agregar una etiqueta</string> + <string name="stores_itch_tag_title">Filtrar por etiqueta</string> + <string name="stores_itch_browse_failed">No se pudieron cargar los juegos de itch.io</string> <string name="stores_itch_archive">Los archivos %1$s no son compatibles</string> <string name="stores_itch_download_failed">itch.io respondió HTTP %1$d</string> + <string name="stores_itch_pick_title">Elige una versión</string> + <string name="stores_itch_pick_none">No se encontraron descargas para este juego</string> + <string name="stores_itch_pick_failed">No se pudieron cargar las descargas de itch.io</string> + <string name="stores_itch_build_demo">Demo</string> + <string name="stores_itch_build_open">Abrir en itch.io</string> + <string name="stores_itch_build_linux_soon">Aún no es compatible</string> + <string name="stores_itch_no_game">No se encontró este juego en itch.io</string> <string name="glasses_experimental">Cámaras Luma Ultra — Experimental</string> <string name="glasses_joycons">Seguimiento de posición de los Joy-Con</string> <string name="glasses_joycons_hint">Usa las cámaras de las gafas inteligentes para posicionar tus mandos Joy-Con emparejados. La rotación y las pulsaciones de los botones provienen de los mandos Joy-Con. La posición se estima cuando tus muñecas están ocultas. Reemplaza los mandos operados con las manos desnudas y pausa el seguimiento de la cámara del teléfono. Apunta los mandos Joy-Con hacia adelante al conectar o restablecer el centro de seguimiento.</string> @@ -2968,4 +3013,57 @@ <string name="spatial_saved_views_hint">Las vistas guardadas se pueden recalcular. Inicie una nueva calibración para capturar en el sistema de coordenadas de seguimiento actual.</string> <string name="spatial_model_checksum_error">La suma de comprobación del modelo de mano no coincide</string> <string name="spatial_opencv_load_error">OpenCV no se pudo cargar</string> + <string name="setup_group_memory">Memoria</string> + <string name="setup_clear_cached">Cerrar apps en caché antes de una sesión</string> + <string name="setup_clear_cached_hint">Al iniciar una sesión, DroidDeck cierra las apps que Android mantiene en segundo plano para que el juego tenga más memoria. La Depuración inalámbrica debe estar activada.</string> + <string name="setup_clear_cached_unpaired">DroidDeck debe estar vinculado con la Depuración inalámbrica (Vincular, abajo).</string> + <string name="setup_memory_hud">Indicador de memoria</string> + <string name="setup_memory_hud_hint">Memoria y swap en uso, en la pantalla de la sesión. Avanzado lo divide entre el juego, DroidDeck, Android y la GPU.</string> + <string name="setup_memory_hud_simple">Simple</string> + <string name="setup_memory_hud_advanced">Avanzado</string> + <string name="memory_hud_simple">RAM %1$s/%2$s GB · swap %3$s/%4$s GB</string> + <string name="memory_hud_split">juego %1$s · DroidDeck %2$s · Android %3$s GB</string> + <string name="memory_hud_gpu">GPU %1$s GB</string> + <string name="setup_bg_limit">Apps en segundo plano durante una sesión</string> + <string name="setup_bg_limit_hint">Mientras una sesión está en curso, Android mantiene en caché como máximo esta cantidad de apps y cierra las demás para que el juego use su memoria. DroidDeck restablece el límite anterior cuando termina la sesión. La Depuración inalámbrica debe estar activada.</string> + <string name="setup_bg_limit_4">Como máximo 4</string> + <string name="setup_bg_limit_1">Como máximo 1</string> + <string name="setup_bg_limit_0">Ninguna</string> + <string name="setup_adb_pair">Depuración inalámbrica</string> + <string name="setup_adb_pair_hint">Vincula DroidDeck con la Depuración inalámbrica de Android para las opciones de arriba. El límite de procesos secundarios sigue desactivado.</string> + <string name="setup_adb_pair_button">Vincular</string> + <string name="epic_client_missing">No instalada</string> + <string name="epic_client_open">Abrir</string> + <string name="epic_client_install">Instalar</string> + <string name="game_tab_about">Acerca de</string> + <string name="game_tab_install">Instalar</string> + <string name="game_install_location">Ubicación</string> + <string name="game_install_folder">Carpeta</string> + <string name="game_install_size">Tamaño en disco</string> + <string name="game_install_date">Instalado</string> + <string name="stores_xbox_update">Actualizar / verificar</string> + <string name="stores_xbox_manage">Administrar contenido</string> + <string name="stores_xbox_close_first">Cierra este juego de Xbox antes de actualizarlo.</string> + <string name="stores_xbox_content_title">Contenido de Xbox</string> + <string name="stores_xbox_content_none">No hay paquetes de contenido de PC con licencia para este juego.</string> + <string name="stores_xbox_content_update">%1$s · Actualizar / verificar</string> + <string name="stores_xbox_content_install">%1$s · Instalar</string> + <string name="stores_xbox_choose_content">Elegir contenido de PC</string> + <string name="stores_eyebrow_pass">disponible con %1$s</string> + <string name="stores_owned_in_library">Juegos comprados en Biblioteca</string> + <string name="library_filter_added">Agregados</string> + <string name="game_tab_launch">Inicio</string> + <string name="game_tab_saves_cloud">Partidas y nube</string> + <string name="game_launch_shortcut">Acceso directo de Steam</string> + <string name="game_install_version">Versión</string> + <string name="game_install_played">Jugado por última vez</string> + <string name="game_install_access">Acceso</string> + <string name="game_install_access_owned">Comprado</string> + <string name="game_stream">Transmitir</string> + <string name="library_add">Agregar a Biblioteca</string> + <string name="library_remove">Quitar de Biblioteca</string> + <string name="library_in">En Biblioteca</string> + <string name="xbox_filter_show">Mostrar</string> + <string name="xbox_filter_cloud">Nube</string> + <string name="stores_xbox_cloud_settings">Configuración de nube</string> </resources> diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index f6d820e9e..137b761f8 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -267,6 +267,24 @@ <string name="osc_steam_touch">Докосване в Steam (бета)</string> <string name="osc_style_next_session">Сензорните контроли на Steam ще стартират от следващата сесия на Steam</string> <string name="osc_steam_layout">Разположение на докосването в Steam</string> + <string name="screen_keys">Клавиши на екрана</string> + <string name="screen_keys_add">Добавяне на клавиш</string> + <string name="screen_key_text">Текст</string> + <string name="screen_key_size">Размер</string> + <string name="screen_key_opacity">Прозрачност</string> + <string name="screen_key_type_button">Клавиш</string> + <string name="screen_key_shape_trigger">Спускът</string> + <string name="screen_key_toggle">Превключване</string> + <string name="screen_key_layout_export">Експорт</string> + <string name="screen_key_shape_pill">Капсула</string> + <string name="screen_key_shape_round">Заоблено</string> + <string name="screen_key_shape_circle">Кръг</string> + <string name="screen_key_shape_rect">Правоъгълник</string> + <string name="screen_key_bind">Клавиш</string> + <string name="screen_key_image">Изображение</string> + <string name="screen_key_image_clear">Изчистване</string> + <string name="screen_key_layout">Разположение</string> + <string name="screen_key_layout_save">Запиши</string> <string name="osc_steam_layout_hint">Премествайте, преоразмерявайте или скривайте сензорните контроли на тази игра; записва се в Steam</string> <string name="mode_controller">Контролер</string> <string name="mode_controller_hint">Как Steam разпознава контролера ви. Прилага се от следващата сесия.</string> @@ -287,9 +305,6 @@ <string name="mode_suspend_steam_note">Автоматично поставя на пауза и продължава, когато се покаже отново. Системно подготвя Steam за заспиване първо. Ръчно изчаква „Продължи“. Никога продължава да работи.</string> <string name="mode_background_downloads">Продължаване на изтеглянията</string> <string name="mode_background_downloads_hint">Отлага фоновото спиране, докато Steam активно изтегля.</string> - <string name="mode_zram">zRAM компресия</string> - <string name="mode_zram_hint">Докато играта работи, паметта на Steam клиента се компресира в zram на устройството, така че играта получава повече RAM. Важи при следващото стартиране на Steam.</string> - <string name="mode_zram_unsupported">Недостъпно: устройството няма zram или swap, или ядрото е по-старо от 5.4.</string> <string name="mode_suspend_note">Автоматично поставя на пауза във фонов режим и продължава при показване. Ръчно изчаква „Продължи“. Никога продължава да работи.</string> <string name="mode_startup">Стартиране</string> <string name="mode_steam_startup">Стартиране на Steam заедно с DroidDeck</string> @@ -544,8 +559,8 @@ <string name="perf_sysmem">Turnip: рендиране sysmem</string> <string name="perf_sysmem_hint">По-бързо за клиента на Steam; в игрите драйверът избира за всеки проход между рендериране на плочки и в системната памет. Задължително на Adreno 710/720/722.</string> <string name="perf_xalia">Пропускане на помощника xalia на Steam</string> - <string name="perf_fastsync">droiddeck-ntsync (експериментално)</string> - <string name="perf_fastsync_hint">Заместител на потребителско ниво за драйвера ntsync на Linux, за тестване. Докато е включен, Proton го използва вместо droiddeck-esync и droiddeck-fsync. Работи с Proton Experimental ARM64, GE-Proton и proton-cachyos. Прилага се от следващата сесия.</string> + <string name="perf_fastsync">droiddeck-ntsync</string> + <string name="perf_fastsync_hint">Заместител на потребителско ниво за драйвера ntsync на Linux. Докато е включен, Proton го използва вместо droiddeck-esync и droiddeck-fsync. Работи с Proton Experimental ARM64, GE-Proton и proton-cachyos. Прилага се от следващата сесия.</string> <string name="perf_esync">droiddeck-esync</string> <string name="perf_fsync">droiddeck-fsync първо</string> <string name="perf_fsync_hint">Стартира собствения fsync на Proton върху оригиналните файлове на Proton, с futex_waitv, подаден от потребителско ниво, затова не изисква пакет и продължава да работи веднага след актуализация на Proton от Steam. Докато е включен, игрите го използват преди droiddeck-esync, освен малкото, които Proton държи извън fsync. Прилага се от следващата сесия.</string> @@ -1026,8 +1041,6 @@ <string name="games_empty">Игрите, които инсталирате в Steam, се показват тук след сесията.</string> <string name="games_play_steam">Пускане на Steam</string> <string name="games_play_steamvr">Пускане на SteamVR</string> - <string name="games_launch_settings">Настройки за стартиране</string> - <string name="games_launch">Стартиране</string> <string name="games_files">Файлове на играта</string> <string name="games_prefix">Префикс на Proton</string> <string name="games_saves_looking">Търсене на записи…</string> @@ -1035,7 +1048,6 @@ <string name="games_saves_folders">%1$d папки за записи</string> <string name="games_saves_summary">%1$s · %2$d файла · %3$s</string> <string name="games_kb">%1$d KB</string> - <string name="games_manage_saves">Управление на записите</string> <string name="games_saves">Записи на играта</string> <string name="games_import_saves">Импортиране на записи…</string> <string name="games_export_gamehub">Експортиране като GameHub zip…</string> @@ -1047,7 +1059,6 @@ <string name="games_runtime_first_play">Средата за изпълнение се инсталира при първото Play</string> <string name="games_runtime_update">Налична актуализация на средата за изпълнение</string> <string name="games_runtime_ready">● Средата за изпълнение е готова</string> - <string name="games_last_played">Последна игра %1$s</string> <string name="games_internal">Вътрешна памет</string> <string name="games_added">Добавена игра</string> <string name="games_custom">Персонализирана игра</string> @@ -1116,7 +1127,7 @@ <string name="second_terminal">Терминал на Linux</string> <string name="second_terminal_not_ready">Сесията на Linux все още не е готова за терминал.</string> <string name="rail_steam">Steam</string> - <string name="rail_games">Игри</string> + <string name="rail_games">Библиотека</string> <string name="rail_desktop">Работен плот</string> <string name="rail_store">Магазин</string> <string name="rail_components">Компоненти</string> @@ -1404,6 +1415,10 @@ <string name="theme_icon_blue_detail">Черен фон, бял текст, синьото за всичко избрано, на фокус или натиснато.</string> <string name="theme_electric">Яркосиньо</string> <string name="theme_electric_detail">По-яркото синьо, върху повърхности, по-близки до тъмносиньо, вместо сиво.</string> + <string name="theme_inner_gamer">Inner Gamer</string> + <string name="theme_inner_gamer_detail">Цветове, които преминават през дъгата. Следва настройките за анимации на Android.</string> + <string name="theme_rgb_speed">Скорост</string> + <string name="theme_rgb_speed_hint">Колко бързо се сменят цветовете.</string> <string name="theme_gradient">Градиент</string> <string name="theme_gradient_detail">Вашите основен и вторичен цвят, смесени в бутоните и акцентите.</string> <string name="theme_gradient_primary">Основен</string> @@ -1525,6 +1540,13 @@ <string name="fm_conflict_skip">Пропускане</string> <string name="fm_select_this_folder">Избор на тази папка</string> <string name="fm_back">Назад</string> + <string name="fm_exit">Изход</string> + <string name="fm_return_to_start">Връщане в началната папка</string> + <string name="fm_start_unavailable">Началната папка вече не е достъпна</string> + <string name="fm_cancel_operation_title">Отмяна на операцията?</string> + <string name="fm_cancel_operation_confirmation">Операцията ще бъде отменена. Сигурни ли сте?</string> + <string name="fm_accept">Приемам</string> + <string name="fm_decline">Отказвам</string> <string name="fm_drive_not_readable">%1$s е монтирано, но в момента не може да се прочете</string> <string name="fm_list_view">Изглед като списък</string> <string name="fm_grid_view">Изглед като мрежа</string> @@ -1939,7 +1961,6 @@ <string name="source_steam">Steam</string> <string name="source_added">Персонализирана</string> <string name="setup_stores">Магазини</string> - <string name="setup_stores_open_on">Отваряне на магазин на</string> <string name="setup_stores_speed">Скорост на изтегляне</string> <string name="setup_stores_engine">Ядро на магазините</string> <string name="setup_stores_engine_chip_ready">● Ядрото е готово</string> @@ -1953,11 +1974,9 @@ <string name="stores_tabs_arrange">Подреждане на раздели</string> <string name="stores_tabs_arrange_done">Готово</string> <string name="stores_chip_signed_out">(не е вписан)</string> - <string name="stores_tab_store">Магазин</string> <string name="stores_tab_library">Библиотека</string> <string name="stores_tab_all">Всички</string> <string name="stores_tab_downloads">Изтегляния</string> - <string name="stores_tab_count">%1$s (%2$d)</string> <string name="stores_signin">Вход</string> <string name="stores_signout_named">%1$s · изход</string> <string name="stores_signout_title">Изход от %1$s?</string> @@ -1975,9 +1994,10 @@ <string name="stores_shelf_deals">Промоции</string> <string name="stores_shelf_free">Безплатни игри</string> <string name="stores_shelf_trending">Актуални</string> - <string name="stores_shelf_library">Твоята библиотека</string> <string name="stores_play">Играй</string> <string name="stores_install">Инсталирай</string> + <string name="bnet_open">Отвори Battle.net</string> + <string name="bnet_store">Игри на Blizzard</string> <string name="stores_resume_install">Продължи инсталацията</string> <string name="stores_dl_interrupted">Прекъснато</string> <string name="stores_install_size">Инсталация · %1$s</string> @@ -1996,8 +2016,32 @@ <string name="stores_itch_no_exe">В изтеглянето не е намерена игра за Windows</string> <string name="stores_itch_extract_failed">Изтеглянето не можа да бъде разархивирано (код %1$d)</string> <string name="stores_itch_no_catalog">itch.io не отговори</string> + <string name="stores_itch_sort_popular">Популярни</string> + <string name="stores_itch_sort_new">Най-нови</string> + <string name="stores_itch_sort_new_popular">Нови и популярни</string> + <string name="stores_itch_price_paid">Платени</string> + <string name="stores_itch_price_under_5">До $5</string> + <string name="stores_itch_price_under_15">До $15</string> + <string name="stores_itch_sort_top_rated">Най-високо оценени</string> + <string name="stores_itch_sort_top_sellers">Най-продавани</string> + <string name="stores_itch_price_any">Всякаква цена</string> + <string name="stores_itch_price_free">Безплатни</string> + <string name="stores_itch_price_sale">С намаление</string> + <string name="stores_itch_load_more">Зареди още</string> + <string name="stores_itch_platform_any">Всяка платформа</string> + <string name="stores_itch_platform_mobile_web">Мобилен уеб</string> + <string name="stores_itch_tag_add">Добавяне на таг</string> + <string name="stores_itch_tag_title">Филтриране по таг</string> + <string name="stores_itch_browse_failed">Игрите от itch.io не можаха да се заредят</string> <string name="stores_itch_archive">Архивите %1$s не се поддържат</string> <string name="stores_itch_download_failed">itch.io отговори HTTP %1$d</string> + <string name="stores_itch_pick_title">Изберете версия</string> + <string name="stores_itch_pick_none">Не са намерени изтегляния за тази игра</string> + <string name="stores_itch_pick_failed">Изтеглянията от itch.io не можаха да се заредят</string> + <string name="stores_itch_build_demo">Демо</string> + <string name="stores_itch_build_open">Отваряне в itch.io</string> + <string name="stores_itch_build_linux_soon">Все още не се поддържа</string> + <string name="stores_itch_no_game">Играта не беше намерена в itch.io</string> <string name="stores_free">Безплатна</string> <string name="stores_resume">Продължи</string> <string name="stores_refresh">Обнови</string> @@ -2150,6 +2194,7 @@ <string name="stores_login_close">Затвори</string> <string name="stores_login_retry">Опитай отново</string> <string name="stores_login_finishing">Завършване на входа…</string> + <string name="stores_xbox_sign_in_again">Microsoft отхвърли входа в Xbox. Излезте от Xbox в Магазини, влезте отново и след това продължете.</string> <string name="stores_login_error_network">Страницата за вход не можа да се зареди: %1$s</string> <string name="stores_login_error_idp_blocked">Този доставчик отказва вход от вградена страница. Впиши се с паролата си за магазина.</string> <string name="stores_login_error_http">Страницата за вход отговори с грешка (HTTP %1$d).</string> @@ -2982,4 +3027,57 @@ <string name="spatial_saved_views_hint">Запазените изгледи могат да бъдат преизчислени. Започнете ново калибриране за заснемане в текущата координатна система за проследяване.</string> <string name="spatial_model_checksum_error">Несъответствие на контролната сума на модела на ръката</string> <string name="spatial_opencv_load_error">OpenCV не можа да се зареди</string> + <string name="setup_group_memory">Памет</string> + <string name="setup_clear_cached">Затваряне на кешираните приложения преди сесия</string> + <string name="setup_clear_cached_hint">При стартиране на сесия DroidDeck затваря приложенията, които Android държи във фонов режим, за да има играта повече памет. Безжичното отстраняване на грешки трябва да е включено.</string> + <string name="setup_clear_cached_unpaired">Изисква DroidDeck да е сдвоен чрез безжично отстраняване на грешки (Сдвояване, по-долу).</string> + <string name="setup_memory_hud">Показател за паметта</string> + <string name="setup_memory_hud_hint">Използвана памет и swap на екрана на сесията. Разширеният режим я разделя между играта, DroidDeck, Android и GPU.</string> + <string name="setup_memory_hud_simple">Опростен</string> + <string name="setup_memory_hud_advanced">Разширен</string> + <string name="memory_hud_simple">RAM %1$s/%2$s GB · swap %3$s/%4$s GB</string> + <string name="memory_hud_split">игра %1$s · DroidDeck %2$s · Android %3$s GB</string> + <string name="memory_hud_gpu">GPU %1$s GB</string> + <string name="setup_bg_limit">Фонови приложения по време на сесия</string> + <string name="setup_bg_limit_hint">Докато сесията работи, Android държи в кеша най-много толкова приложения и затваря останалите, за да получи играта тяхната памет. DroidDeck връща предишното ограничение, когато сесията приключи. Безжичното отстраняване на грешки трябва да е включено.</string> + <string name="setup_bg_limit_4">Най-много 4</string> + <string name="setup_bg_limit_1">Най-много 1</string> + <string name="setup_bg_limit_0">Нито едно</string> + <string name="setup_adb_pair">Безжично отстраняване на грешки</string> + <string name="setup_adb_pair_hint">Сдвоява DroidDeck с безжичното отстраняване на грешки на Android за опциите по-горе. Ограничението на дъщерните процеси остава изключено.</string> + <string name="setup_adb_pair_button">Сдвояване</string> + <string name="epic_client_missing">Не е инсталирано</string> + <string name="epic_client_open">Отвори</string> + <string name="epic_client_install">Инсталиране</string> + <string name="game_tab_about">За приложението</string> + <string name="game_tab_install">Инсталиране</string> + <string name="game_install_location">Местоположение</string> + <string name="game_install_folder">Папка</string> + <string name="game_install_size">Размер на диска</string> + <string name="game_install_date">Инсталиран</string> + <string name="stores_xbox_update">Обнови / провери</string> + <string name="stores_xbox_manage">Управление на съдържанието</string> + <string name="stores_xbox_close_first">Затворете тази Xbox игра, преди да я обновите.</string> + <string name="stores_xbox_content_title">Xbox съдържание</string> + <string name="stores_xbox_content_none">Няма лицензирани пакети с PC съдържание за тази игра.</string> + <string name="stores_xbox_content_update">%1$s · Обнови / провери</string> + <string name="stores_xbox_content_install">%1$s · Инсталирай</string> + <string name="stores_xbox_choose_content">Изберете PC съдържание</string> + <string name="stores_eyebrow_pass">налична с %1$s</string> + <string name="stores_owned_in_library">Притежавани игри в Библиотека</string> + <string name="library_filter_added">Добавени</string> + <string name="game_tab_launch">Стартиране</string> + <string name="game_tab_saves_cloud">Записи и облак</string> + <string name="game_launch_shortcut">Пряк път в Steam</string> + <string name="game_install_version">Версия</string> + <string name="game_install_played">Последно игране</string> + <string name="game_install_access">Достъп</string> + <string name="game_install_access_owned">Притежавана</string> + <string name="game_stream">Стрийминг</string> + <string name="library_add">Добави в Библиотека</string> + <string name="library_remove">Премахни от Библиотека</string> + <string name="library_in">В Библиотека</string> + <string name="xbox_filter_show">Показване</string> + <string name="xbox_filter_cloud">Облак</string> + <string name="stores_xbox_cloud_settings">Настройки на облака</string> </resources> diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 74c7e327f..d12e8ff40 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -267,6 +267,24 @@ <string name="osc_steam_touch">Dotykové ovládání Steamu (beta)</string> <string name="osc_style_next_session">Dotykové ovládání Steamu se spustí od příští relace Steamu</string> <string name="osc_steam_layout">Rozvržení dotykového ovládání Steamu</string> + <string name="screen_keys">Klávesy na obrazovce</string> + <string name="screen_keys_add">Přidat klávesu</string> + <string name="screen_key_text">Text</string> + <string name="screen_key_size">Velikost</string> + <string name="screen_key_opacity">Průhlednost</string> + <string name="screen_key_type_button">Klávesa</string> + <string name="screen_key_shape_trigger">Spoušť</string> + <string name="screen_key_toggle">Přepínač</string> + <string name="screen_key_layout_export">Exportovat</string> + <string name="screen_key_shape_pill">Kapsle</string> + <string name="screen_key_shape_round">Zaoblené</string> + <string name="screen_key_shape_circle">Kruh</string> + <string name="screen_key_shape_rect">Obdélník</string> + <string name="screen_key_bind">Klávesa</string> + <string name="screen_key_image">Obrázek</string> + <string name="screen_key_image_clear">Vymazat</string> + <string name="screen_key_layout">Rozložení</string> + <string name="screen_key_layout_save">Uložit</string> <string name="osc_steam_layout_hint">Přesuňte, změňte velikost nebo skryjte dotykové ovládání této hry; uloží se do Steamu</string> <string name="mode_controller">Ovladač</string> <string name="mode_controller_hint">Jak Steam vidí váš ovladač. Použije se od příští relace.</string> @@ -287,9 +305,6 @@ <string name="mode_suspend_steam_note">Automaticky pozastaví relaci a obnoví ji při zobrazení. Nativně nejprve připraví Steam na spánek. Manuálně čeká na Obnovit. Nikdy běh nezastavuje.</string> <string name="mode_background_downloads">Nechat stahování běžet</string> <string name="mode_background_downloads_hint">Oddálí pozastavení na pozadí, pokud Steam aktivně stahuje.</string> - <string name="mode_zram">Komprese zRAM</string> - <string name="mode_zram_hint">Během hraní se paměť klienta Steam komprimuje do zram zařízení, takže hra má víc RAM. Platí při příštím spuštění Steamu.</string> - <string name="mode_zram_unsupported">Není k dispozici: zařízení nemá zram ani swap, nebo je jádro starší než 5.4.</string> <string name="mode_suspend_note">Automaticky pozastaví na pozadí a obnoví při zobrazení. Manuálně čeká na Obnovit. Nikdy běh nezastavuje.</string> <string name="mode_startup">Spouštění</string> <string name="mode_steam_startup">Spustit Steam s DroidDeck</string> @@ -548,8 +563,8 @@ <string name="perf_sysmem">Turnip: vykreslování sysmem</string> <string name="perf_sysmem_hint">Rychlejší pro klienta Steam; ve hrách ovladač pro každý průchod volí dlaždicové vykreslování, nebo vykreslování v systémové paměti. Vyžadováno na Adreno 710/720/722.</string> <string name="perf_xalia">Vynechat Steamova pomocníka xalia</string> - <string name="perf_fastsync">droiddeck-ntsync (experimentální)</string> - <string name="perf_fastsync_hint">Uživatelská náhrada linuxového ovladače ntsync, pro testování. Když je zapnuto, Proton ho používá místo droiddeck-esync a droiddeck-fsync. Funguje s Proton Experimental ARM64, GE-Proton a proton-cachyos. Použije se od příští relace.</string> + <string name="perf_fastsync">droiddeck-ntsync</string> + <string name="perf_fastsync_hint">Uživatelská náhrada linuxového ovladače ntsync. Když je zapnuto, Proton ho používá místo droiddeck-esync a droiddeck-fsync. Funguje s Proton Experimental ARM64, GE-Proton a proton-cachyos. Použije se od příští relace.</string> <string name="perf_esync">droiddeck-esync</string> <string name="perf_fsync">droiddeck-fsync jako první</string> <string name="perf_fsync_hint">Spouští vlastní fsync Protonu na původních souborech Protonu, s futex_waitv dodaným v uživatelském prostoru, takže nepotřebuje balíček a funguje i hned po aktualizaci Protonu Steamem. Když je zapnuto, hry ho používají před droiddeck-esync, kromě těch pár, které Proton u fsync nechává vypnuté. Použije se od příští relace.</string> @@ -1038,8 +1053,6 @@ <string name="games_empty">Hry, které nainstalujete ve Steamu, se zde zobrazí po relaci.</string> <string name="games_play_steam">Hrát ve Steamu</string> <string name="games_play_steamvr">Hrát ve SteamVR</string> - <string name="games_launch_settings">Nastavení spuštění</string> - <string name="games_launch">Spustit</string> <string name="games_files">Soubory hry</string> <string name="games_prefix">Prefix Protonu</string> <string name="games_saves_looking">Hledání uložených pozic…</string> @@ -1047,7 +1060,6 @@ <string name="games_saves_folders">%1$d složek s uloženými pozicemi</string> <string name="games_saves_summary">%1$s · %2$d souborů · %3$s</string> <string name="games_kb">%1$d KB</string> - <string name="games_manage_saves">Správa uložených pozic</string> <string name="games_saves">Uložené pozice hry</string> <string name="games_import_saves">Importovat uložené pozice…</string> <string name="games_export_gamehub">Exportovat jako GameHub zip…</string> @@ -1059,7 +1071,6 @@ <string name="games_runtime_first_play">Runtime se nainstaluje při prvním spuštění hry</string> <string name="games_runtime_update">K dispozici aktualizace runtime</string> <string name="games_runtime_ready">● Runtime připraven</string> - <string name="games_last_played">Poslední hraní %1$s</string> <string name="games_internal">Interní úložiště</string> <string name="games_added">Přidaná hra</string> <string name="games_custom">Vlastní hra</string> @@ -1128,7 +1139,7 @@ <string name="second_terminal">Terminál Linuxu</string> <string name="second_terminal_not_ready">Relace Linuxu ještě není připravena na terminál.</string> <string name="rail_steam">Steam</string> - <string name="rail_games">Hry</string> + <string name="rail_games">Knihovna</string> <string name="rail_desktop">Plocha</string> <string name="rail_store">Obchod</string> <string name="rail_components">Komponenty</string> @@ -1432,6 +1443,10 @@ <string name="theme_icon_blue_detail">Černé pozadí, bílé písmo, modrá pro vše vybrané, zaměřené nebo stisknuté.</string> <string name="theme_electric">Elektrická modř</string> <string name="theme_electric_detail">Jasnější modrá na povrchách, které jsou spíše tmavomodré než šedé.</string> + <string name="theme_inner_gamer">Inner Gamer</string> + <string name="theme_inner_gamer_detail">Barvy, které procházejí celou duhu. Respektuje nastavení animací v Androidu.</string> + <string name="theme_rgb_speed">Rychlost</string> + <string name="theme_rgb_speed_hint">Jak rychle se barvy střídají.</string> <string name="theme_gradient">Přechod</string> <string name="theme_gradient_detail">Vaše vlastní primární a sekundární barva, prolnuté na tlačítkách a zvýrazněních.</string> <string name="theme_gradient_primary">Primární</string> @@ -1563,6 +1578,13 @@ <string name="fm_conflict_skip">Přeskočit</string> <string name="fm_select_this_folder">Vybrat tuto složku</string> <string name="fm_back">Zpět</string> + <string name="fm_exit">Ukončit</string> + <string name="fm_return_to_start">Zpět do výchozí složky</string> + <string name="fm_start_unavailable">Výchozí složka již není dostupná</string> + <string name="fm_cancel_operation_title">Zrušit operaci?</string> + <string name="fm_cancel_operation_confirmation">Operace bude zrušena. Opravdu chcete pokračovat?</string> + <string name="fm_accept">Přijmout</string> + <string name="fm_decline">Odmítnout</string> <string name="fm_drive_not_readable">%1$s je připojeno, ale momentálně není čitelné</string> <string name="fm_list_view">Zobrazení seznamu</string> <string name="fm_grid_view">Zobrazení mřížky</string> @@ -1981,7 +2003,6 @@ <string name="source_steam">Steam</string> <string name="source_added">Vlastní</string> <string name="setup_stores">Obchody</string> - <string name="setup_stores_open_on">Otevřít obchod na</string> <string name="setup_stores_speed">Úroveň rychlosti stahování</string> <string name="setup_stores_engine">Jádro obchodů</string> <string name="setup_stores_engine_chip_ready">● Jádro je připraveno</string> @@ -1995,11 +2016,9 @@ <string name="stores_tabs_arrange">Uspořádat karty</string> <string name="stores_tabs_arrange_done">Hotovo</string> <string name="stores_chip_signed_out">(nepřihlášen)</string> - <string name="stores_tab_store">Obchod</string> <string name="stores_tab_library">Knihovna</string> <string name="stores_tab_all">Vše</string> <string name="stores_tab_downloads">Stahování</string> - <string name="stores_tab_count">%1$s (%2$d)</string> <string name="stores_signin">Přihlásit se</string> <string name="stores_signout_named">%1$s · odhlásit</string> <string name="stores_signout_title">Odhlásit se z %1$s?</string> @@ -2017,9 +2036,10 @@ <string name="stores_shelf_deals">Nabídky</string> <string name="stores_shelf_free">Hry zdarma</string> <string name="stores_shelf_trending">Populární</string> - <string name="stores_shelf_library">Vaše knihovna</string> <string name="stores_play">Hrát</string> <string name="stores_install">Instalovat</string> + <string name="bnet_open">Otevřít Battle.net</string> + <string name="bnet_store">Hry od Blizzardu</string> <string name="stores_resume_install">Pokračovat v instalaci</string> <string name="stores_dl_interrupted">Přerušeno</string> <string name="stores_install_size">Instalace · %1$s</string> @@ -2038,8 +2058,32 @@ <string name="stores_itch_no_exe">Ve stahování nebyla nalezena hra pro Windows</string> <string name="stores_itch_extract_failed">Stažený soubor nelze rozbalit (kód %1$d)</string> <string name="stores_itch_no_catalog">itch.io neodpovědělo</string> + <string name="stores_itch_sort_popular">Populární</string> + <string name="stores_itch_sort_new">Nejnovější</string> + <string name="stores_itch_sort_new_popular">Nové a populární</string> + <string name="stores_itch_price_paid">Placené</string> + <string name="stores_itch_price_under_5">Do $5</string> + <string name="stores_itch_price_under_15">Do $15</string> + <string name="stores_itch_sort_top_rated">Nejlépe hodnocené</string> + <string name="stores_itch_sort_top_sellers">Nejprodávanější</string> + <string name="stores_itch_price_any">Jakákoli cena</string> + <string name="stores_itch_price_free">Zdarma</string> + <string name="stores_itch_price_sale">Ve slevě</string> + <string name="stores_itch_load_more">Načíst další</string> + <string name="stores_itch_platform_any">Libovolná platforma</string> + <string name="stores_itch_platform_mobile_web">Mobilní web</string> + <string name="stores_itch_tag_add">Přidat štítek</string> + <string name="stores_itch_tag_title">Filtrovat podle štítku</string> + <string name="stores_itch_browse_failed">Hry z itch.io se nepodařilo načíst</string> <string name="stores_itch_archive">Archivy %1$s nejsou podporovány</string> <string name="stores_itch_download_failed">itch.io odpovědělo HTTP %1$d</string> + <string name="stores_itch_pick_title">Vyberte verzi</string> + <string name="stores_itch_pick_none">Pro tuto hru nebyla nalezena žádná stažení</string> + <string name="stores_itch_pick_failed">Stahování z itch.io se nepodařilo načíst</string> + <string name="stores_itch_build_demo">Demo</string> + <string name="stores_itch_build_open">Otevřít na itch.io</string> + <string name="stores_itch_build_linux_soon">Zatím nepodporováno</string> + <string name="stores_itch_no_game">Tuto hru se na itch.io nepodařilo najít</string> <string name="stores_free">Zdarma</string> <string name="stores_resume">Pokračovat</string> <string name="stores_refresh">Obnovit</string> @@ -2192,6 +2236,7 @@ <string name="stores_login_close">Zavřít</string> <string name="stores_login_retry">Zkusit znovu</string> <string name="stores_login_finishing">Dokončování přihlášení…</string> + <string name="stores_xbox_sign_in_again">Microsoft odmítl přihlášení k Xboxu. V Obchodech se z Xboxu odhlaste, znovu se přihlaste a pak pokračujte.</string> <string name="stores_login_error_network">Přihlašovací stránku se nepodařilo načíst: %1$s</string> <string name="stores_login_error_idp_blocked">Tento poskytovatel odmítá přihlášení ze vložené stránky. Přihlaste se místo toho heslem k obchodu.</string> <string name="stores_login_error_http">Přihlašovací stránka odpověděla chybou (HTTP %1$d).</string> @@ -3050,4 +3095,57 @@ <string name="spatial_saved_views_hint">Uložená zobrazení lze přepočítat. Spusťte novou kalibraci pro zachycení v aktuálním sledovacím souřadnicovém systému.</string> <string name="spatial_model_checksum_error">Neshoda kontrolního součtu modelu ruky</string> <string name="spatial_opencv_load_error">OpenCV se nepodařilo načíst</string> + <string name="setup_group_memory">Paměť</string> + <string name="setup_clear_cached">Zavřít aplikace v mezipaměti před relací</string> + <string name="setup_clear_cached_hint">Při spuštění relace DroidDeck zavře aplikace, které Android drží na pozadí, aby hra měla více paměti. Musí být zapnuté Bezdrátové ladění.</string> + <string name="setup_clear_cached_unpaired">Vyžaduje DroidDeck spárovaný přes Bezdrátové ladění (Spárovat, níže).</string> + <string name="setup_memory_hud">Ukazatel paměti</string> + <string name="setup_memory_hud_hint">Využitá paměť a swap na obrazovce relace. Pokročilé zobrazení ji rozdělí na hru, DroidDeck, Android a GPU.</string> + <string name="setup_memory_hud_simple">Jednoduché</string> + <string name="setup_memory_hud_advanced">Pokročilé</string> + <string name="memory_hud_simple">RAM %1$s/%2$s GB · swap %3$s/%4$s GB</string> + <string name="memory_hud_split">hra %1$s · DroidDeck %2$s · Android %3$s GB</string> + <string name="memory_hud_gpu">GPU %1$s GB</string> + <string name="setup_bg_limit">Aplikace na pozadí během relace</string> + <string name="setup_bg_limit_hint">Během relace Android ponechá v mezipaměti nejvýše tolik aplikací a ostatní zavře, aby hra dostala jejich paměť. Po skončení relace DroidDeck vrátí limit na původní hodnotu. Musí být zapnuté Bezdrátové ladění.</string> + <string name="setup_bg_limit_4">Nejvýše 4</string> + <string name="setup_bg_limit_1">Nejvýše 1</string> + <string name="setup_bg_limit_0">Žádné</string> + <string name="setup_adb_pair">Bezdrátové ladění</string> + <string name="setup_adb_pair_hint">Spáruje DroidDeck s Bezdrátovým laděním Androidu pro výše uvedené možnosti. Omezení pro dětské procesy zůstane vypnuté.</string> + <string name="setup_adb_pair_button">Spárovat</string> + <string name="epic_client_missing">Není nainstalováno</string> + <string name="epic_client_open">Otevřít</string> + <string name="epic_client_install">Instalovat</string> + <string name="game_tab_about">O aplikaci</string> + <string name="game_tab_install">Instalovat</string> + <string name="game_install_location">Umístění</string> + <string name="game_install_folder">Složka</string> + <string name="game_install_size">Velikost na disku</string> + <string name="game_install_date">Nainstalováno</string> + <string name="stores_xbox_update">Aktualizovat / ověřit</string> + <string name="stores_xbox_manage">Spravovat obsah</string> + <string name="stores_xbox_close_first">Před aktualizací tuto hru pro Xbox zavřete.</string> + <string name="stores_xbox_content_title">Obsah Xbox</string> + <string name="stores_xbox_content_none">Pro tuto hru nejsou k dispozici žádné licencované balíčky obsahu pro PC.</string> + <string name="stores_xbox_content_update">%1$s · Aktualizovat / ověřit</string> + <string name="stores_xbox_content_install">%1$s · Instalovat</string> + <string name="stores_xbox_choose_content">Vyberte obsah pro PC</string> + <string name="stores_eyebrow_pass">dostupné s %1$s</string> + <string name="stores_owned_in_library">Vlastněné hry v knihovně</string> + <string name="library_filter_added">Přidané</string> + <string name="game_tab_launch">Spuštění</string> + <string name="game_tab_saves_cloud">Uložené hry a cloud</string> + <string name="game_launch_shortcut">Zástupce ve Steamu</string> + <string name="game_install_version">Verze</string> + <string name="game_install_played">Naposledy hráno</string> + <string name="game_install_access">Přístup</string> + <string name="game_install_access_owned">Vlastněno</string> + <string name="game_stream">Streamovat</string> + <string name="library_add">Přidat do knihovny</string> + <string name="library_remove">Odebrat z knihovny</string> + <string name="library_in">V knihovně</string> + <string name="xbox_filter_show">Zobrazit</string> + <string name="xbox_filter_cloud">Cloud</string> + <string name="stores_xbox_cloud_settings">Nastavení cloudu</string> </resources> diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 062bcb4b5..caf758e57 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -267,6 +267,24 @@ <string name="osc_steam_touch">Steam-berøring (beta)</string> <string name="osc_style_next_session">Steams berøringskontroller starter med næste Steam-session</string> <string name="osc_steam_layout">Steam-berøringslayout</string> + <string name="screen_keys">Skærmtaster</string> + <string name="screen_keys_add">Tilføj tast</string> + <string name="screen_key_text">Tekst</string> + <string name="screen_key_size">Størrelse</string> + <string name="screen_key_opacity">Gennemsigtighed</string> + <string name="screen_key_type_button">Tast</string> + <string name="screen_key_shape_trigger">Udløser</string> + <string name="screen_key_toggle">Skift</string> + <string name="screen_key_layout_export">Eksportér</string> + <string name="screen_key_shape_pill">Kapsel</string> + <string name="screen_key_shape_round">Afrundet</string> + <string name="screen_key_shape_circle">Cirkel</string> + <string name="screen_key_shape_rect">Rektangel</string> + <string name="screen_key_bind">Tast</string> + <string name="screen_key_image">Billede</string> + <string name="screen_key_image_clear">Ryd</string> + <string name="screen_key_layout">Layout</string> + <string name="screen_key_layout_save">Gem</string> <string name="osc_steam_layout_hint">Flyt, tilpas størrelsen på eller skjul dette spils berøringskontroller; gemmes til Steam</string> <string name="mode_controller">Controller</string> <string name="mode_controller_hint">Hvordan Steam ser din controller. Gælder fra næste session.</string> @@ -287,9 +305,6 @@ <string name="mode_suspend_steam_note">Auto sætter på pause og genoptager, når den vises. Native forbereder Steam til dvale først. Manuel venter på Genoptag. Aldrig fortsætter med at køre.</string> <string name="mode_background_downloads">Fortsæt downloads</string> <string name="mode_background_downloads_hint">Udskyder baggrundspause, mens Steam aktivt downloader.</string> - <string name="mode_zram">zRAM-komprimering</string> - <string name="mode_zram_hint">Mens et spil kører, komprimeres Steam-klientens hukommelse til enhedens zram, så spillet får mere RAM. Gælder næste gang Steam starter.</string> - <string name="mode_zram_unsupported">Ikke tilgængeligt: enheden har ingen zram eller swap, eller kernen er ældre end 5.4.</string> <string name="mode_suspend_note">Auto sætter på pause i baggrunden og genoptager, når den vises. Manuel venter på Genoptag. Aldrig fortsætter med at køre.</string> <string name="mode_startup">Opstart</string> <string name="mode_steam_startup">Start Steam med DroidDeck</string> @@ -544,8 +559,8 @@ <string name="perf_sysmem">Turnip: sysmem-rendering</string> <string name="perf_sysmem_hint">Hurtigere til Steam-klienten; i spil vælger driveren flise- eller systemhukommelses-rendering for hvert pass. Påkrævet på Adreno 710/720/722.</string> <string name="perf_xalia">Spring Steams xalia-hjælper over</string> - <string name="perf_fastsync">droiddeck-ntsync (eksperimentel)</string> - <string name="perf_fastsync_hint">En stand-in til ntsync-driveren i Linux-kernen, i brugerrum, til test. Mens den er til, bruger Proton den i stedet for droiddeck-esync og droiddeck-fsync. Fungerer med Proton Experimental ARM64, GE-Proton og proton-cachyos. Gælder fra næste session.</string> + <string name="perf_fastsync">droiddeck-ntsync</string> + <string name="perf_fastsync_hint">En stand-in til ntsync-driveren i Linux-kernen, i brugerrum. Mens den er til, bruger Proton den i stedet for droiddeck-esync og droiddeck-fsync. Fungerer med Proton Experimental ARM64, GE-Proton og proton-cachyos. Gælder fra næste session.</string> <string name="perf_esync">droiddeck-esync</string> <string name="perf_fsync">droiddeck-fsync først</string> <string name="perf_fsync_hint">Kører Protons egen fsync på de uændrede Proton-filer, med futex_waitv leveret i brugerrum, så det kræver ingen pack og fortsætter med at virke, lige så snart Steam opdaterer Proton. Mens den er til, bruger spil den før droiddeck-esync, undtagen de få, Proton holder uden for fsync. Gælder fra næste session.</string> @@ -1026,8 +1041,6 @@ <string name="games_empty">Spil, du installerer i Steam, vises her efter sessionen.</string> <string name="games_play_steam">Spil Steam</string> <string name="games_play_steamvr">Spil SteamVR</string> - <string name="games_launch_settings">Startindstillinger</string> - <string name="games_launch">Start</string> <string name="games_files">Spilfiler</string> <string name="games_prefix">Proton-prefix</string> <string name="games_saves_looking">Søger efter gemte spil…</string> @@ -1035,7 +1048,6 @@ <string name="games_saves_folders">%1$d savemapper</string> <string name="games_saves_summary">%1$s · %2$d filer · %3$s</string> <string name="games_kb">%1$d KB</string> - <string name="games_manage_saves">Administrer gemte spil</string> <string name="games_saves">Gemte spil</string> <string name="games_import_saves">Importer gemte spil…</string> <string name="games_export_gamehub">Eksporter som GameHub-zip…</string> @@ -1047,7 +1059,6 @@ <string name="games_runtime_first_play">Runtime installeres ved første Spil</string> <string name="games_runtime_update">Runtimeopdatering tilgængelig</string> <string name="games_runtime_ready">● Runtime klar</string> - <string name="games_last_played">Sidst spillet %1$s</string> <string name="games_internal">Internt lager</string> <string name="games_added">Tilføjet spil</string> <string name="games_custom">Brugerdefineret spil</string> @@ -1116,7 +1127,7 @@ <string name="second_terminal">Linux-terminal</string> <string name="second_terminal_not_ready">Linux-sessionen er ikke klar til en terminal endnu.</string> <string name="rail_steam">Steam</string> - <string name="rail_games">Spil</string> + <string name="rail_games">Bibliotek</string> <string name="rail_desktop">Skrivebord</string> <string name="rail_store">Butik</string> <string name="rail_components">Komponenter</string> @@ -1404,6 +1415,10 @@ <string name="theme_icon_blue_detail">Sort baggrund, hvid skrift, blå til alt, der er valgt, fokuseret eller trykket.</string> <string name="theme_electric">Elektrisk marineblå</string> <string name="theme_electric_detail">Den blå er klarere, på overflader der trækker mod marineblåt i stedet for gråt.</string> + <string name="theme_inner_gamer">Inner Gamer</string> + <string name="theme_inner_gamer_detail">Farver, der skifter gennem regnbuen. Følger Androids animationsindstillinger.</string> + <string name="theme_rgb_speed">Hastighed</string> + <string name="theme_rgb_speed_hint">Hvor hurtigt farverne skifter.</string> <string name="theme_gradient">Gradient</string> <string name="theme_gradient_detail">Dine egne primære og sekundære farver, blandet på knapper og fremhævninger.</string> <string name="theme_gradient_primary">Primær</string> @@ -1525,6 +1540,13 @@ <string name="fm_conflict_skip">Spring over</string> <string name="fm_select_this_folder">Vælg denne mappe</string> <string name="fm_back">Tilbage</string> + <string name="fm_exit">Afslut</string> + <string name="fm_return_to_start">Tilbage til startmappen</string> + <string name="fm_start_unavailable">Startmappen er ikke længere tilgængelig</string> + <string name="fm_cancel_operation_title">Annuller handlingen?</string> + <string name="fm_cancel_operation_confirmation">Handlingen bliver annulleret. Er du sikker?</string> + <string name="fm_accept">Accepter</string> + <string name="fm_decline">Afvis</string> <string name="fm_drive_not_readable">%1$s er monteret, men kan ikke læses lige nu</string> <string name="fm_list_view">Listevisning</string> <string name="fm_grid_view">Gittervisning</string> @@ -1939,7 +1961,6 @@ <string name="source_steam">Steam</string> <string name="source_added">Brugerdefineret</string> <string name="setup_stores">Butikker</string> - <string name="setup_stores_open_on">Åbn en butik på</string> <string name="setup_stores_speed">Downloadhastighed</string> <string name="setup_stores_engine">Butiksmotor</string> <string name="setup_stores_engine_chip_ready">● Motor klar</string> @@ -1953,11 +1974,9 @@ <string name="stores_tabs_arrange">Arranger faner</string> <string name="stores_tabs_arrange_done">Færdig</string> <string name="stores_chip_signed_out">(ikke logget ind)</string> - <string name="stores_tab_store">Butik</string> <string name="stores_tab_library">Bibliotek</string> <string name="stores_tab_all">Alle</string> <string name="stores_tab_downloads">Downloads</string> - <string name="stores_tab_count">%1$s (%2$d)</string> <string name="stores_signin">Log ind</string> <string name="stores_signout_named">%1$s · log ud</string> <string name="stores_signout_title">Log ud af %1$s?</string> @@ -1975,9 +1994,10 @@ <string name="stores_shelf_deals">Tilbud</string> <string name="stores_shelf_free">Gratis spil</string> <string name="stores_shelf_trending">Populært</string> - <string name="stores_shelf_library">Dit bibliotek</string> <string name="stores_play">Spil</string> <string name="stores_install">Installer</string> + <string name="bnet_open">Åbn Battle.net</string> + <string name="bnet_store">Blizzard-spil</string> <string name="stores_resume_install">Genoptag installation</string> <string name="stores_dl_interrupted">Afbrudt</string> <string name="stores_install_size">Installation · %1$s</string> @@ -1996,8 +2016,32 @@ <string name="stores_itch_no_exe">Der blev ikke fundet et Windows-spil i downloaden</string> <string name="stores_itch_extract_failed">Downloaden kunne ikke udpakkes (kode %1$d)</string> <string name="stores_itch_no_catalog">itch.io svarede ikke</string> + <string name="stores_itch_sort_popular">Populære</string> + <string name="stores_itch_sort_new">Seneste</string> + <string name="stores_itch_sort_new_popular">Nye og populære</string> + <string name="stores_itch_price_paid">Betalt</string> + <string name="stores_itch_price_under_5">$5 eller mindre</string> + <string name="stores_itch_price_under_15">$15 eller mindre</string> + <string name="stores_itch_sort_top_rated">Bedst bedømte</string> + <string name="stores_itch_sort_top_sellers">Mest solgte</string> + <string name="stores_itch_price_any">Alle priser</string> + <string name="stores_itch_price_free">Gratis</string> + <string name="stores_itch_price_sale">På tilbud</string> + <string name="stores_itch_load_more">Indlæs flere</string> + <string name="stores_itch_platform_any">Alle platforme</string> + <string name="stores_itch_platform_mobile_web">Mobilweb</string> + <string name="stores_itch_tag_add">Tilføj et tag</string> + <string name="stores_itch_tag_title">Filtrer efter tag</string> + <string name="stores_itch_browse_failed">Kunne ikke hente spil fra itch.io</string> <string name="stores_itch_archive">%1$s-arkiver understøttes ikke</string> <string name="stores_itch_download_failed">itch.io svarede HTTP %1$d</string> + <string name="stores_itch_pick_title">Vælg en version</string> + <string name="stores_itch_pick_none">Ingen downloads fundet til dette spil</string> + <string name="stores_itch_pick_failed">Kunne ikke indlæse downloads fra itch.io</string> + <string name="stores_itch_build_demo">Demo</string> + <string name="stores_itch_build_open">Åbn på itch.io</string> + <string name="stores_itch_build_linux_soon">Understøttes ikke endnu</string> + <string name="stores_itch_no_game">Kunne ikke finde dette spil på itch.io</string> <string name="stores_free">Gratis</string> <string name="stores_resume">Genoptag</string> <string name="stores_refresh">Opdater</string> @@ -2150,6 +2194,7 @@ <string name="stores_login_close">Luk</string> <string name="stores_login_retry">Forsøg igen</string> <string name="stores_login_finishing">Afslutter login…</string> + <string name="stores_xbox_sign_in_again">Microsoft afviste Xbox-login. Log ud af Xbox under Butikker, log ind igen, og genoptag derefter.</string> <string name="stores_login_error_network">Loginsiden kunne ikke indlæses: %1$s</string> <string name="stores_login_error_idp_blocked">Denne udbyder afviser login fra en indlejret side. Log i stedet ind med din butikskodeord.</string> <string name="stores_login_error_http">Loginsiden svarede med en fejl (HTTP %1$d).</string> @@ -2982,4 +3027,57 @@ <string name="spatial_saved_views_hint">Gemte visninger kan genberegnes. Start en ny kalibrering for at fange i det aktuelle sporingskoordinatsystem.</string> <string name="spatial_model_checksum_error">Håndmodel kontrolsum uoverensstemmelse</string> <string name="spatial_opencv_load_error">OpenCV kunne ikke indlæse</string> + <string name="setup_group_memory">Hukommelse</string> + <string name="setup_clear_cached">Luk cachelagrede apps før en session</string> + <string name="setup_clear_cached_hint">Når en session starter, lukker DroidDeck de apps, Android holder parkeret i baggrunden, så spillet får mere hukommelse. Trådløs fejlretning skal være slået til.</string> + <string name="setup_clear_cached_unpaired">Kræver, at DroidDeck er parret med Trådløs fejlretning (Par, nedenfor).</string> + <string name="setup_memory_hud">Hukommelsesvisning</string> + <string name="setup_memory_hud_hint">Hukommelse og swap i brug på sessionsskærmen. Avanceret fordeler det på spillet, DroidDeck, Android og GPU\'en.</string> + <string name="setup_memory_hud_simple">Enkel</string> + <string name="setup_memory_hud_advanced">Avanceret</string> + <string name="memory_hud_simple">RAM %1$s/%2$s GB · swap %3$s/%4$s GB</string> + <string name="memory_hud_split">spil %1$s · DroidDeck %2$s · Android %3$s GB</string> + <string name="memory_hud_gpu">GPU %1$s GB</string> + <string name="setup_bg_limit">Baggrundsapps under en session</string> + <string name="setup_bg_limit_hint">Mens en session kører, holder Android højst så mange apps i cache og lukker resten, så spillet får deres hukommelse. DroidDeck sætter grænsen tilbage, som den var, når sessionen slutter. Trådløs fejlretning skal være slået til.</string> + <string name="setup_bg_limit_4">Højst 4</string> + <string name="setup_bg_limit_1">Højst 1</string> + <string name="setup_bg_limit_0">Ingen</string> + <string name="setup_adb_pair">Trådløs fejlretning</string> + <string name="setup_adb_pair_hint">Parrer DroidDeck med Androids Trådløs fejlretning til indstillingerne ovenfor. Grænsen for underprocesser forbliver slået fra.</string> + <string name="setup_adb_pair_button">Par</string> + <string name="epic_client_missing">Ikke installeret</string> + <string name="epic_client_open">Åbn</string> + <string name="epic_client_install">Installer</string> + <string name="game_tab_about">Om</string> + <string name="game_tab_install">Installer</string> + <string name="game_install_location">Placering</string> + <string name="game_install_folder">Mappe</string> + <string name="game_install_size">Størrelse på disk</string> + <string name="game_install_date">Installeret</string> + <string name="stores_xbox_update">Opdater / bekræft</string> + <string name="stores_xbox_manage">Administrer indhold</string> + <string name="stores_xbox_close_first">Luk dette Xbox-spil, før du opdaterer det.</string> + <string name="stores_xbox_content_title">Xbox-indhold</string> + <string name="stores_xbox_content_none">Der er ingen licenserede pc-indholdspakker til dette spil.</string> + <string name="stores_xbox_content_update">%1$s · Opdater / bekræft</string> + <string name="stores_xbox_content_install">%1$s · Installer</string> + <string name="stores_xbox_choose_content">Vælg pc-indhold</string> + <string name="stores_eyebrow_pass">tilgængelig med %1$s</string> + <string name="stores_owned_in_library">Ejede spil i Bibliotek</string> + <string name="library_filter_added">Tilføjet</string> + <string name="game_tab_launch">Start</string> + <string name="game_tab_saves_cloud">Gemte spil og sky</string> + <string name="game_launch_shortcut">Steam-genvej</string> + <string name="game_install_version">Version</string> + <string name="game_install_played">Sidst spillet</string> + <string name="game_install_access">Adgang</string> + <string name="game_install_access_owned">Ejet</string> + <string name="game_stream">Stream</string> + <string name="library_add">Føj til Bibliotek</string> + <string name="library_remove">Fjern fra Bibliotek</string> + <string name="library_in">I Bibliotek</string> + <string name="xbox_filter_show">Vis</string> + <string name="xbox_filter_cloud">Sky</string> + <string name="stores_xbox_cloud_settings">Skyindstillinger</string> </resources> diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index d46e0c575..fbdf383ab 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -267,6 +267,24 @@ <string name="osc_steam_touch">Steam-Touch (Beta)</string> <string name="osc_style_next_session">Steams Touch-Steuerung startet mit der nächsten Steam-Sitzung</string> <string name="osc_steam_layout">Steam-Touch-Layout</string> + <string name="screen_keys">Bildschirmtasten</string> + <string name="screen_keys_add">Taste hinzufügen</string> + <string name="screen_key_text">Text</string> + <string name="screen_key_size">Größe</string> + <string name="screen_key_opacity">Deckkraft</string> + <string name="screen_key_type_button">Taste</string> + <string name="screen_key_shape_trigger">Trigger</string> + <string name="screen_key_toggle">Umschalten</string> + <string name="screen_key_layout_export">Exportieren</string> + <string name="screen_key_shape_pill">Kapsel</string> + <string name="screen_key_shape_round">Rund</string> + <string name="screen_key_shape_circle">Kreis</string> + <string name="screen_key_shape_rect">Rechteck</string> + <string name="screen_key_bind">Taste</string> + <string name="screen_key_image">Bild</string> + <string name="screen_key_image_clear">Entfernen</string> + <string name="screen_key_layout">Layout</string> + <string name="screen_key_layout_save">Speichern</string> <string name="osc_steam_layout_hint">Touch-Steuerung dieses Spiels verschieben, skalieren oder ausblenden; wird in Steam gespeichert</string> <string name="mode_controller">Controller</string> <string name="mode_controller_hint">Wie Steam deinen Controller erkennt. Gilt ab der nächsten Sitzung.</string> @@ -541,8 +559,8 @@ <string name="perf_sysmem">Turnip: Sysmem-Rendering</string> <string name="perf_sysmem_hint">Schneller für den Steam-Client; in Spielen wählt der Treiber pro Durchgang zwischen Kachel- und Systemspeicher-Rendering. Erforderlich bei Adreno 710/720/722.</string> <string name="perf_xalia">Steams xalia-Helfer überspringen</string> - <string name="perf_fastsync">droiddeck-ntsync (experimentell)</string> - <string name="perf_fastsync_hint">Ein Userspace-Ersatz für den Linux-Treiber ntsync, zum Testen. Solange er aktiv ist, nutzt Proton ihn statt droiddeck-esync und droiddeck-fsync. Funktioniert mit Proton Experimental ARM64, GE-Proton und proton-cachyos. Gilt ab der nächsten Sitzung.</string> + <string name="perf_fastsync">droiddeck-ntsync</string> + <string name="perf_fastsync_hint">Ein Userspace-Ersatz für den Linux-Treiber ntsync. Solange er aktiv ist, nutzt Proton ihn statt droiddeck-esync und droiddeck-fsync. Funktioniert mit Proton Experimental ARM64, GE-Proton und proton-cachyos. Gilt ab der nächsten Sitzung.</string> <string name="perf_esync">droiddeck-esync</string> <string name="perf_fsync">droiddeck-fsync bevorzugen</string> <string name="perf_fsync_hint">Führt Protons eigenes fsync mit den unveränderten Proton-Dateien aus, wobei futex_waitv im Userspace bereitgestellt wird. Es braucht daher kein Pack und funktioniert weiter, sobald Steam Proton aktualisiert. Solange es aktiv ist, nutzen Spiele es vor droiddeck-esync, außer den wenigen, die Proton von fsync fernhält. Gilt ab der nächsten Sitzung.</string> @@ -1015,8 +1033,6 @@ <string name="games_empty">Spiele, die du in Steam installierst, erscheinen hier nach der Sitzung.</string> <string name="games_play_steam">Mit Steam spielen</string> <string name="games_play_steamvr">Mit SteamVR spielen</string> - <string name="games_launch_settings">Starteinstellungen</string> - <string name="games_launch">Starten</string> <string name="games_files">Spieldateien</string> <string name="games_prefix">Proton-Präfix</string> <string name="games_saves_looking">Suche nach Spielständen…</string> @@ -1024,7 +1040,6 @@ <string name="games_saves_folders">%1$d Spielstandordner</string> <string name="games_saves_summary">%1$s · %2$d Dateien · %3$s</string> <string name="games_kb">%1$d KB</string> - <string name="games_manage_saves">Spielstände verwalten</string> <string name="games_saves">Spielstände</string> <string name="games_import_saves">Spielstände importieren…</string> <string name="games_export_gamehub">Als GameHub-ZIP exportieren…</string> @@ -1036,7 +1051,6 @@ <string name="games_runtime_first_play">Laufzeit wird beim ersten „Spielen“ installiert</string> <string name="games_runtime_update">Laufzeit-Update verfügbar</string> <string name="games_runtime_ready">● Laufzeit bereit</string> - <string name="games_last_played">Zuletzt gespielt: %1$s</string> <string name="games_internal">Interner Speicher</string> <string name="games_added">Hinzugefügtes Spiel</string> <string name="games_custom">Eigenes Spiel</string> @@ -1105,7 +1119,7 @@ <string name="second_terminal">Linux-Terminal</string> <string name="second_terminal_not_ready">Die Linux-Sitzung ist noch nicht bereit für ein Terminal.</string> <string name="rail_steam">Steam</string> - <string name="rail_games">Spiele</string> + <string name="rail_games">Bibliothek</string> <string name="rail_desktop">Desktop</string> <string name="rail_store">Store</string> <string name="rail_components">Komponenten</string> @@ -1393,6 +1407,10 @@ <string name="theme_icon_blue_detail">Schwarzer Grund, weiße Schrift, das Blau für alles Ausgewählte, Fokussierte oder Gedrückte.</string> <string name="theme_electric">Elektrisches Marineblau</string> <string name="theme_electric_detail">Das Blau heller, auf Flächen, die eher zu Marineblau als zu Grau tendieren.</string> + <string name="theme_inner_gamer">Inner Gamer</string> + <string name="theme_inner_gamer_detail">Farben, die durch den Regenbogen wechseln. Folgt den Animationseinstellungen von Android.</string> + <string name="theme_rgb_speed">Geschwindigkeit</string> + <string name="theme_rgb_speed_hint">Wie schnell die Farben wechseln.</string> <string name="theme_gradient">Farbverlauf</string> <string name="theme_gradient_detail">Eigene Primär- und Sekundärfarbe, als Verlauf auf Schaltflächen und Hervorhebungen.</string> <string name="theme_gradient_primary">Primär</string> @@ -1514,6 +1532,13 @@ <string name="fm_conflict_skip">Überspringen</string> <string name="fm_select_this_folder">Diesen Ordner auswählen</string> <string name="fm_back">Zurück</string> + <string name="fm_exit">Beenden</string> + <string name="fm_return_to_start">Zurück zum Startordner</string> + <string name="fm_start_unavailable">Startordner ist nicht mehr verfügbar</string> + <string name="fm_cancel_operation_title">Vorgang abbrechen?</string> + <string name="fm_cancel_operation_confirmation">Der Vorgang wird abgebrochen. Bist du sicher?</string> + <string name="fm_accept">Akzeptieren</string> + <string name="fm_decline">Ablehnen</string> <string name="fm_drive_not_readable">%1$s ist eingebunden, aber derzeit nicht lesbar</string> <string name="fm_list_view">Listenansicht</string> <string name="fm_grid_view">Rasteransicht</string> @@ -1928,7 +1953,6 @@ <string name="source_steam">Steam</string> <string name="source_added">Eigene</string> <string name="setup_stores">Stores</string> - <string name="setup_stores_open_on">Store öffnen auf</string> <string name="setup_stores_speed">Download-Tempo</string> <string name="setup_stores_engine">Store-Engine</string> <string name="setup_stores_engine_chip_ready">● Engine bereit</string> @@ -1942,11 +1966,9 @@ <string name="stores_tabs_arrange">Tabs anordnen</string> <string name="stores_tabs_arrange_done">Fertig</string> <string name="stores_chip_signed_out">(nicht angemeldet)</string> - <string name="stores_tab_store">Store</string> <string name="stores_tab_library">Bibliothek</string> <string name="stores_tab_all">Alle</string> <string name="stores_tab_downloads">Downloads</string> - <string name="stores_tab_count">%1$s (%2$d)</string> <string name="stores_signin">Anmelden</string> <string name="stores_signout_named">%1$s · abmelden</string> <string name="stores_signout_title">Von %1$s abmelden?</string> @@ -1964,9 +1986,10 @@ <string name="stores_shelf_deals">Angebote</string> <string name="stores_shelf_free">Kostenlose Spiele</string> <string name="stores_shelf_trending">Im Trend</string> - <string name="stores_shelf_library">Deine Bibliothek</string> <string name="stores_play">Spielen</string> <string name="stores_install">Installieren</string> + <string name="bnet_open">Battle.net öffnen</string> + <string name="bnet_store">Blizzard-Spiele</string> <string name="stores_resume_install">Installation fortsetzen</string> <string name="stores_dl_interrupted">Unterbrochen</string> <string name="stores_install_size">Installieren · %1$s</string> @@ -1985,8 +2008,32 @@ <string name="stores_itch_no_exe">Im Download wurde kein Windows-Spiel gefunden</string> <string name="stores_itch_extract_failed">Download konnte nicht entpackt werden (Code %1$d)</string> <string name="stores_itch_no_catalog">itch.io hat nicht geantwortet</string> + <string name="stores_itch_sort_popular">Beliebt</string> + <string name="stores_itch_sort_new">Neueste</string> + <string name="stores_itch_sort_new_popular">Neu und beliebt</string> + <string name="stores_itch_price_paid">Kostenpflichtig</string> + <string name="stores_itch_price_under_5">5 $ oder weniger</string> + <string name="stores_itch_price_under_15">15 $ oder weniger</string> + <string name="stores_itch_sort_top_rated">Bestbewertet</string> + <string name="stores_itch_sort_top_sellers">Meistverkauft</string> + <string name="stores_itch_price_any">Beliebiger Preis</string> + <string name="stores_itch_price_free">Kostenlos</string> + <string name="stores_itch_price_sale">Im Angebot</string> + <string name="stores_itch_load_more">Mehr laden</string> + <string name="stores_itch_platform_any">Beliebige Plattform</string> + <string name="stores_itch_platform_mobile_web">Mobiles Web</string> + <string name="stores_itch_tag_add">Tag hinzufügen</string> + <string name="stores_itch_tag_title">Nach Tag filtern</string> + <string name="stores_itch_browse_failed">Spiele von itch.io konnten nicht geladen werden</string> <string name="stores_itch_archive">%1$s-Archive werden nicht unterstützt</string> <string name="stores_itch_download_failed">itch.io antwortete mit HTTP %1$d</string> + <string name="stores_itch_pick_title">Version auswählen</string> + <string name="stores_itch_pick_none">Für dieses Spiel wurden keine Downloads gefunden</string> + <string name="stores_itch_pick_failed">Die Downloads von itch.io konnten nicht geladen werden</string> + <string name="stores_itch_build_demo">Demo</string> + <string name="stores_itch_build_open">Auf itch.io öffnen</string> + <string name="stores_itch_build_linux_soon">Noch nicht unterstützt</string> + <string name="stores_itch_no_game">Dieses Spiel wurde auf itch.io nicht gefunden</string> <string name="stores_free">Kostenlos</string> <string name="stores_resume">Fortsetzen</string> <string name="stores_refresh">Aktualisieren</string> @@ -2136,6 +2183,7 @@ <string name="stores_login_close">Schließen</string> <string name="stores_login_retry">Erneut versuchen</string> <string name="stores_login_finishing">Anmeldung wird abgeschlossen…</string> + <string name="stores_xbox_sign_in_again">Microsoft hat die Xbox-Anmeldung abgelehnt. Melde dich unter Stores von Xbox ab, melde dich erneut an und setze dann fort.</string> <string name="stores_login_error_network">Die Anmeldeseite konnte nicht geladen werden: %1$s</string> <string name="stores_login_error_idp_blocked">Dieser Anbieter lässt keine Anmeldung über eine eingebettete Seite zu. Melde dich stattdessen mit deinem Store-Passwort an.</string> <string name="stores_login_error_http">Die Anmeldeseite antwortete mit einem Fehler (HTTP %1$d).</string> @@ -2529,9 +2577,6 @@ <string name="stores_hltb_completionist">Komplettierer</string> <string name="stores_hltb_hours">%1$s Std.</string> <string name="stores_hltb_minutes">%1$d Min.</string> - <string name="mode_zram">zRAM-Komprimierung</string> - <string name="mode_zram_hint">Während ein Spiel läuft, wird der Arbeitsspeicher des Steam-Clients in das zram des Geräts komprimiert, sodass mehr RAM für das Spiel bleibt. Wird beim nächsten Start von Steam angewendet.</string> - <string name="mode_zram_unsupported">Nicht verfügbar: Dieses Gerät hat kein zram oder keinen Swap, oder sein Kernel ist älter als 5.4.</string> <string name="setup_reset_settings">Alle Einstellungen zurücksetzen</string> <string name="setup_reset_settings_title">Alle Einstellungen zurücksetzen?</string> <string name="setup_reset_settings_body">Anzeige-, Leistungs-, Controller-, Design-, Sprach- und Launcher-Einstellungen werden auf ihre Standardwerte zurückgesetzt. Deine Spiele, die Steam-Anmeldung, Shop-Konten, heruntergeladene Treiber und Speicherordner bleiben erhalten.</string> @@ -2982,4 +3027,57 @@ <string name="spatial_saved_views_hint">Gespeicherte Ansichten können neu berechnet werden. Starten Sie eine neue Kalibrierung zur Erfassung im aktuellen Tracking-Koordinatensystem.</string> <string name="spatial_model_checksum_error">Die Prüfsumme des Handmodells stimmt nicht überein</string> <string name="spatial_opencv_load_error">OpenCV konnte nicht geladen werden</string> + <string name="setup_group_memory">Arbeitsspeicher</string> + <string name="setup_clear_cached">Apps im Cache vor einer Sitzung schließen</string> + <string name="setup_clear_cached_hint">Beim Start einer Sitzung schließt DroidDeck die Apps, die Android im Hintergrund vorhält, damit das Spiel mehr Arbeitsspeicher hat. „Debugging über WLAN“ muss aktiviert sein.</string> + <string name="setup_clear_cached_unpaired">DroidDeck muss über „Debugging über WLAN“ gekoppelt sein (Koppeln, unten).</string> + <string name="setup_memory_hud">Speicheranzeige</string> + <string name="setup_memory_hud_hint">Belegter Arbeitsspeicher und Swap auf dem Sitzungsbildschirm. „Erweitert“ schlüsselt ihn nach Spiel, DroidDeck, Android und GPU auf.</string> + <string name="setup_memory_hud_simple">Einfach</string> + <string name="setup_memory_hud_advanced">Erweitert</string> + <string name="memory_hud_simple">RAM %1$s/%2$s GB · Swap %3$s/%4$s GB</string> + <string name="memory_hud_split">Spiel %1$s · DroidDeck %2$s · Android %3$s GB</string> + <string name="memory_hud_gpu">GPU %1$s GB</string> + <string name="setup_bg_limit">Hintergrund-Apps während einer Sitzung</string> + <string name="setup_bg_limit_hint">Während eine Sitzung läuft, hält Android höchstens so viele Apps im Cache und schließt die übrigen, damit das Spiel deren Speicher bekommt. DroidDeck stellt das vorherige Limit wieder her, wenn die Sitzung endet. „Debugging über WLAN“ muss aktiviert sein.</string> + <string name="setup_bg_limit_4">Höchstens 4</string> + <string name="setup_bg_limit_1">Höchstens 1</string> + <string name="setup_bg_limit_0">Keine</string> + <string name="setup_adb_pair">Debugging über WLAN</string> + <string name="setup_adb_pair_hint">Koppelt DroidDeck mit „Debugging über WLAN“ von Android für die Optionen oben. Das Limit für untergeordnete Prozesse bleibt aus.</string> + <string name="setup_adb_pair_button">Koppeln</string> + <string name="epic_client_missing">Nicht installiert</string> + <string name="epic_client_open">Öffnen</string> + <string name="epic_client_install">Installieren</string> + <string name="game_tab_about">Über</string> + <string name="game_tab_install">Installieren</string> + <string name="game_install_location">Speicherort</string> + <string name="game_install_folder">Ordner</string> + <string name="game_install_size">Speicherbedarf</string> + <string name="game_install_date">Installiert</string> + <string name="stores_xbox_update">Aktualisieren / prüfen</string> + <string name="stores_xbox_manage">Inhalte verwalten</string> + <string name="stores_xbox_close_first">Schließe dieses Xbox-Spiel, bevor du es aktualisierst.</string> + <string name="stores_xbox_content_title">Xbox-Inhalte</string> + <string name="stores_xbox_content_none">Für dieses Spiel gibt es keine lizenzierten PC-Inhaltspakete.</string> + <string name="stores_xbox_content_update">%1$s · Aktualisieren / prüfen</string> + <string name="stores_xbox_content_install">%1$s · Installieren</string> + <string name="stores_xbox_choose_content">PC-Inhalt wählen</string> + <string name="stores_eyebrow_pass">verfügbar mit %1$s</string> + <string name="stores_owned_in_library">Eigene Spiele in der Bibliothek</string> + <string name="library_filter_added">Hinzugefügt</string> + <string name="game_tab_launch">Start</string> + <string name="game_tab_saves_cloud">Spielstände & Cloud</string> + <string name="game_launch_shortcut">Steam-Verknüpfung</string> + <string name="game_install_version">Version</string> + <string name="game_install_played">Zuletzt gespielt</string> + <string name="game_install_access">Zugang</string> + <string name="game_install_access_owned">Gekauft</string> + <string name="game_stream">Streamen</string> + <string name="library_add">Zur Bibliothek hinzufügen</string> + <string name="library_remove">Aus der Bibliothek entfernen</string> + <string name="library_in">In der Bibliothek</string> + <string name="xbox_filter_show">Anzeigen</string> + <string name="xbox_filter_cloud">Cloud</string> + <string name="stores_xbox_cloud_settings">Cloud-Einstellungen</string> </resources> diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index dd6458776..7e21ae76c 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -267,6 +267,24 @@ <string name="osc_steam_touch">Αφή Steam (beta)</string> <string name="osc_style_next_session">Τα χειριστήρια αφής του Steam ξεκινούν από την επόμενη συνεδρία Steam</string> <string name="osc_steam_layout">Διάταξη αφής Steam</string> + <string name="screen_keys">Πλήκτρα οθόνης</string> + <string name="screen_keys_add">Προσθήκη πλήκτρου</string> + <string name="screen_key_text">Κείμενο</string> + <string name="screen_key_size">Μέγεθος</string> + <string name="screen_key_opacity">Διαφάνεια</string> + <string name="screen_key_type_button">Πλήκτρο</string> + <string name="screen_key_shape_trigger">Σκανδάλη</string> + <string name="screen_key_toggle">Εναλλαγή</string> + <string name="screen_key_layout_export">Εξαγωγή</string> + <string name="screen_key_shape_pill">Κάψουλα</string> + <string name="screen_key_shape_round">Στρογγυλεμένο</string> + <string name="screen_key_shape_circle">Κύκλος</string> + <string name="screen_key_shape_rect">Ορθογώνιο</string> + <string name="screen_key_bind">Πλήκτρο</string> + <string name="screen_key_image">Εικόνα</string> + <string name="screen_key_image_clear">Εκκαθάριση</string> + <string name="screen_key_layout">Διάταξη</string> + <string name="screen_key_layout_save">Αποθήκευση</string> <string name="osc_steam_layout_hint">Μετακινήστε, αλλάξτε μέγεθος ή αποκρύψτε τα χειριστήρια αφής αυτού του παιχνιδιού· αποθηκεύεται στο Steam</string> <string name="mode_controller">Χειριστήριο</string> <string name="mode_controller_hint">Πώς βλέπει το Steam το χειριστήριό σας. Εφαρμόζεται στην επόμενη συνεδρία.</string> @@ -287,9 +305,6 @@ <string name="mode_suspend_steam_note">Το Αυτόματο θέτει σε παύση και συνεχίζει όταν εμφανίζεται. Το Εγγενής προετοιμάζει πρώτα το Steam για αδρανοποίηση. Το Μη αυτόματο περιμένει το Συνέχιση. Το Ποτέ συνεχίζει να εκτελείται.</string> <string name="mode_background_downloads">Διατήρηση των λήψεων σε εξέλιξη</string> <string name="mode_background_downloads_hint">Καθυστερεί την αναστολή παρασκηνίου όσο το Steam κάνει ενεργά λήψη.</string> - <string name="mode_zram">Συμπίεση zRAM</string> - <string name="mode_zram_hint">Όσο τρέχει ένα παιχνίδι, η μνήμη του Steam συμπιέζεται στο zram της συσκευής, ώστε το παιχνίδι να έχει περισσότερη RAM. Ισχύει την επόμενη φορά που θα ξεκινήσει το Steam.</string> - <string name="mode_zram_unsupported">Μη διαθέσιμο: η συσκευή δεν έχει zram ή swap, ή ο πυρήνας είναι παλαιότερος από 5.4.</string> <string name="mode_suspend_note">Το Αυτόματο θέτει σε παύση στο παρασκήνιο και συνεχίζει όταν εμφανίζεται. Το Μη αυτόματο περιμένει το Συνέχιση. Το Ποτέ συνεχίζει να εκτελείται.</string> <string name="mode_startup">Εκκίνηση</string> <string name="mode_steam_startup">Εκκίνηση του Steam με το DroidDeck</string> @@ -544,8 +559,8 @@ <string name="perf_sysmem">Turnip: απόδοση sysmem</string> <string name="perf_sysmem_hint">Ταχύτερο για το πρόγραμμα-πελάτη Steam· στα παιχνίδια ο οδηγός επιλέγει σε κάθε πέρασμα απόδοση με πλακίδια ή στη μνήμη συστήματος. Απαιτείται σε Adreno 710/720/722.</string> <string name="perf_xalia">Παράβλεψη του βοηθού xalia του Steam</string> - <string name="perf_fastsync">droiddeck-ntsync (πειραματικό)</string> - <string name="perf_fastsync_hint">Ένα υποκατάστατο σε επίπεδο χρήστη για το πρόγραμμα οδήγησης ntsync του Linux, για δοκιμές. Όσο είναι ενεργό, το Proton το χρησιμοποιεί αντί για το droiddeck-esync και το droiddeck-fsync. Λειτουργεί με Proton Experimental ARM64, GE-Proton και proton-cachyos. Εφαρμόζεται στην επόμενη συνεδρία.</string> + <string name="perf_fastsync">droiddeck-ntsync</string> + <string name="perf_fastsync_hint">Ένα υποκατάστατο σε επίπεδο χρήστη για το πρόγραμμα οδήγησης ntsync του Linux. Όσο είναι ενεργό, το Proton το χρησιμοποιεί αντί για το droiddeck-esync και το droiddeck-fsync. Λειτουργεί με Proton Experimental ARM64, GE-Proton και proton-cachyos. Εφαρμόζεται στην επόμενη συνεδρία.</string> <string name="perf_esync">droiddeck-esync</string> <string name="perf_fsync">droiddeck-fsync πρώτα</string> <string name="perf_fsync_hint">Εκτελεί το δικό του fsync του Proton στα απλά αρχεία Proton, με το futex_waitv παρεχόμενο σε επίπεδο χρήστη, οπότε δεν χρειάζεται πακέτο και συνεχίζει να λειτουργεί τη στιγμή που το Steam ενημερώνει το Proton. Όσο είναι ενεργό, τα παιχνίδια το χρησιμοποιούν πριν από το droiddeck-esync, εκτός από τα λίγα που το Proton κρατά εκτός fsync. Εφαρμόζεται στην επόμενη συνεδρία.</string> @@ -1026,8 +1041,6 @@ <string name="games_empty">Τα παιχνίδια που εγκαθιστάτε στο Steam εμφανίζονται εδώ μετά τη συνεδρία.</string> <string name="games_play_steam">Αναπαραγωγή Steam</string> <string name="games_play_steamvr">Αναπαραγωγή SteamVR</string> - <string name="games_launch_settings">Ρυθμίσεις εκκίνησης</string> - <string name="games_launch">Εκκίνηση</string> <string name="games_files">Αρχεία παιχνιδιού</string> <string name="games_prefix">Prefix Proton</string> <string name="games_saves_looking">Αναζήτηση αποθηκευμένων αρχείων…</string> @@ -1035,7 +1048,6 @@ <string name="games_saves_folders">%1$d φάκελοι αποθήκευσης</string> <string name="games_saves_summary">%1$s · %2$d αρχεία · %3$s</string> <string name="games_kb">%1$d KB</string> - <string name="games_manage_saves">Διαχείριση αποθηκευμένων</string> <string name="games_saves">Αποθηκευμένα παιχνιδιού</string> <string name="games_import_saves">Εισαγωγή αποθηκευμένων…</string> <string name="games_export_gamehub">Εξαγωγή ως zip GameHub…</string> @@ -1047,7 +1059,6 @@ <string name="games_runtime_first_play">Το runtime εγκαθίσταται στο πρώτο Play</string> <string name="games_runtime_update">Υπάρχει ενημέρωση runtime</string> <string name="games_runtime_ready">● Το runtime είναι έτοιμο</string> - <string name="games_last_played">Παίχτηκε τελευταία φορά %1$s</string> <string name="games_internal">Εσωτερική αποθήκευση</string> <string name="games_added">Προστέθηκε παιχνίδι</string> <string name="games_custom">Προσαρμοσμένο παιχνίδι</string> @@ -1116,7 +1127,7 @@ <string name="second_terminal">Τερματικό Linux</string> <string name="second_terminal_not_ready">Η συνεδρία Linux δεν είναι ακόμα έτοιμη για τερματικό.</string> <string name="rail_steam">Steam</string> - <string name="rail_games">Παιχνίδια</string> + <string name="rail_games">Βιβλιοθήκη</string> <string name="rail_desktop">Επιφάνεια</string> <string name="rail_store">Κατάστημα</string> <string name="rail_components">Στοιχεία</string> @@ -1404,6 +1415,10 @@ <string name="theme_icon_blue_detail">Μαύρο φόντο, λευκά γράμματα, το μπλε για ό,τι είναι επιλεγμένο, εστιασμένο ή πατημένο.</string> <string name="theme_electric">Ηλεκτρικό ναυτικό μπλε</string> <string name="theme_electric_detail">Το μπλε πιο έντονο, σε επιφάνειες που τείνουν προς το ναυτικό μπλε αντί για γκρι.</string> + <string name="theme_inner_gamer">Inner Gamer</string> + <string name="theme_inner_gamer_detail">Χρώματα που διατρέχουν το ουράνιο τόξο. Ακολουθεί τις ρυθμίσεις animation του Android.</string> + <string name="theme_rgb_speed">Ταχύτητα</string> + <string name="theme_rgb_speed_hint">Πόσο γρήγορα αλλάζουν τα χρώματα.</string> <string name="theme_gradient">Διαβάθμιση</string> <string name="theme_gradient_detail">Δικά σας πρωτεύον και δευτερεύον χρώμα, σε μίξη στα κουμπιά και τις επισημάνσεις.</string> <string name="theme_gradient_primary">Πρωτεύον</string> @@ -1525,6 +1540,13 @@ <string name="fm_conflict_skip">Παράλειψη</string> <string name="fm_select_this_folder">Επιλογή αυτού του φακέλου</string> <string name="fm_back">Πίσω</string> + <string name="fm_exit">Έξοδος</string> + <string name="fm_return_to_start">Επιστροφή στον αρχικό φάκελο</string> + <string name="fm_start_unavailable">Ο αρχικός φάκελος δεν είναι πλέον διαθέσιμος</string> + <string name="fm_cancel_operation_title">Ακύρωση ενέργειας;</string> + <string name="fm_cancel_operation_confirmation">Η ενέργεια θα ακυρωθεί. Είστε σίγουροι;</string> + <string name="fm_accept">Αποδοχή</string> + <string name="fm_decline">Απόρριψη</string> <string name="fm_drive_not_readable">Το %1$s είναι προσαρτημένο αλλά δεν είναι αναγνώσιμο αυτή τη στιγμή</string> <string name="fm_list_view">Προβολή λίστας</string> <string name="fm_grid_view">Προβολή πλέγματος</string> @@ -1939,7 +1961,6 @@ <string name="source_steam">Steam</string> <string name="source_added">Προσαρμοσμένο</string> <string name="setup_stores">Καταστήματα</string> - <string name="setup_stores_open_on">Άνοιγμα καταστήματος σε</string> <string name="setup_stores_speed">Επίπεδο ταχύτητας λήψης</string> <string name="setup_stores_engine">Μηχανή καταστημάτων</string> <string name="setup_stores_engine_chip_ready">● Η μηχανή είναι έτοιμη</string> @@ -1953,11 +1974,9 @@ <string name="stores_tabs_arrange">Τακτοποίηση καρτελών</string> <string name="stores_tabs_arrange_done">Έγινε</string> <string name="stores_chip_signed_out">(μη συνδεδεμένος)</string> - <string name="stores_tab_store">Κατάστημα</string> <string name="stores_tab_library">Βιβλιοθήκη</string> <string name="stores_tab_all">Όλα</string> <string name="stores_tab_downloads">Λήψεις</string> - <string name="stores_tab_count">%1$s (%2$d)</string> <string name="stores_signin">Σύνδεση</string> <string name="stores_signout_named">%1$s · αποσύνδεση</string> <string name="stores_signout_title">Αποσύνδεση από %1$s;</string> @@ -1975,9 +1994,10 @@ <string name="stores_shelf_deals">Προσφορές</string> <string name="stores_shelf_free">Δωρεάν παιχνίδια</string> <string name="stores_shelf_trending">Σε άνοδο</string> - <string name="stores_shelf_library">Η βιβλιοθήκη σας</string> <string name="stores_play">Αναπαραγωγή</string> <string name="stores_install">Εγκατάσταση</string> + <string name="bnet_open">Άνοιγμα του Battle.net</string> + <string name="bnet_store">Παιχνίδια της Blizzard</string> <string name="stores_resume_install">Συνέχιση εγκατάστασης</string> <string name="stores_dl_interrupted">Διακόπηκε</string> <string name="stores_install_size">Εγκατάσταση · %1$s</string> @@ -1996,8 +2016,32 @@ <string name="stores_itch_no_exe">Δεν βρέθηκε παιχνίδι Windows στη λήψη</string> <string name="stores_itch_extract_failed">Δεν ήταν δυνατή η αποσυμπίεση της λήψης (κωδικός %1$d)</string> <string name="stores_itch_no_catalog">Το itch.io δεν απάντησε</string> + <string name="stores_itch_sort_popular">Δημοφιλή</string> + <string name="stores_itch_sort_new">Πιο πρόσφατα</string> + <string name="stores_itch_sort_new_popular">Νέα και δημοφιλή</string> + <string name="stores_itch_price_paid">Επί πληρωμή</string> + <string name="stores_itch_price_under_5">$5 ή λιγότερο</string> + <string name="stores_itch_price_under_15">$15 ή λιγότερο</string> + <string name="stores_itch_sort_top_rated">Κορυφαία βαθμολογία</string> + <string name="stores_itch_sort_top_sellers">Μπεστ σέλερ</string> + <string name="stores_itch_price_any">Οποιαδήποτε τιμή</string> + <string name="stores_itch_price_free">Δωρεάν</string> + <string name="stores_itch_price_sale">Σε προσφορά</string> + <string name="stores_itch_load_more">Φόρτωση περισσότερων</string> + <string name="stores_itch_platform_any">Οποιαδήποτε πλατφόρμα</string> + <string name="stores_itch_platform_mobile_web">Mobile web</string> + <string name="stores_itch_tag_add">Προσθήκη ετικέτας</string> + <string name="stores_itch_tag_title">Φιλτράρισμα ανά ετικέτα</string> + <string name="stores_itch_browse_failed">Δεν ήταν δυνατή η φόρτωση παιχνιδιών από το itch.io</string> <string name="stores_itch_archive">Τα αρχεία %1$s δεν υποστηρίζονται</string> <string name="stores_itch_download_failed">Το itch.io απάντησε HTTP %1$d</string> + <string name="stores_itch_pick_title">Επιλέξτε έκδοση</string> + <string name="stores_itch_pick_none">Δεν βρέθηκαν λήψεις για αυτό το παιχνίδι</string> + <string name="stores_itch_pick_failed">Δεν ήταν δυνατή η φόρτωση των λήψεων από το itch.io</string> + <string name="stores_itch_build_demo">Demo</string> + <string name="stores_itch_build_open">Άνοιγμα στο itch.io</string> + <string name="stores_itch_build_linux_soon">Δεν υποστηρίζεται ακόμη</string> + <string name="stores_itch_no_game">Δεν βρέθηκε αυτό το παιχνίδι στο itch.io</string> <string name="stores_free">Δωρεάν</string> <string name="stores_resume">Συνέχιση</string> <string name="stores_refresh">Ανανέωση</string> @@ -2150,6 +2194,7 @@ <string name="stores_login_close">Κλείσιμο</string> <string name="stores_login_retry">Δοκιμάστε ξανά</string> <string name="stores_login_finishing">Ολοκλήρωση σύνδεσης…</string> + <string name="stores_xbox_sign_in_again">Η Microsoft απέρριψε τη σύνδεση στο Xbox. Αποσυνδεθείτε από το Xbox στα Καταστήματα, συνδεθείτε ξανά και μετά συνεχίστε.</string> <string name="stores_login_error_network">Δεν ήταν δυνατή η φόρτωση της σελίδας σύνδεσης: %1$s</string> <string name="stores_login_error_idp_blocked">Αυτός ο πάροχος αρνείται να συνδεθεί από μια ενσωματωμένη σελίδα. Συνδεθείτε με τον κωδικό πρόσβασης του καταστήματός σας αντ\' αυτού.</string> <string name="stores_login_error_http">Η σελίδα σύνδεσης απάντησε με σφάλμα (HTTP %1$d).</string> @@ -2982,4 +3027,57 @@ <string name="spatial_saved_views_hint">Οι αποθηκευμένες προβολές μπορούν να υπολογιστούν εκ νέου. Ξεκινήστε μια νέα βαθμονόμηση για αποτύπωση στο τρέχον σύστημα συντεταγμένων παρακολούθησης.</string> <string name="spatial_model_checksum_error">Αναντιστοιχία αθροίσματος ελέγχου μοντέλου χεριού</string> <string name="spatial_opencv_load_error">Δεν ήταν δυνατή η φόρτωση του OpenCV</string> + <string name="setup_group_memory">Μνήμη</string> + <string name="setup_clear_cached">Κλείσιμο εφαρμογών στην προσωρινή μνήμη πριν από τη συνεδρία</string> + <string name="setup_clear_cached_hint">Όταν ξεκινά μια συνεδρία, το DroidDeck κλείνει τις εφαρμογές που το Android κρατά στο παρασκήνιο, ώστε το παιχνίδι να έχει περισσότερη μνήμη. Ο Ασύρματος εντοπισμός σφαλμάτων πρέπει να είναι ενεργός.</string> + <string name="setup_clear_cached_unpaired">Απαιτεί σύζευξη του DroidDeck με τον Ασύρματο εντοπισμό σφαλμάτων (Σύζευξη, παρακάτω).</string> + <string name="setup_memory_hud">Ένδειξη μνήμης</string> + <string name="setup_memory_hud_hint">Μνήμη και swap σε χρήση, στην οθόνη της συνεδρίας. Με την επιλογή «Για προχωρημένους» χωρίζεται σε παιχνίδι, DroidDeck, Android και GPU.</string> + <string name="setup_memory_hud_simple">Απλή</string> + <string name="setup_memory_hud_advanced">Για προχωρημένους</string> + <string name="memory_hud_simple">RAM %1$s/%2$s GB · swap %3$s/%4$s GB</string> + <string name="memory_hud_split">παιχνίδι %1$s · DroidDeck %2$s · Android %3$s GB</string> + <string name="memory_hud_gpu">GPU %1$s GB</string> + <string name="setup_bg_limit">Εφαρμογές παρασκηνίου κατά τη συνεδρία</string> + <string name="setup_bg_limit_hint">Όσο εκτελείται μια συνεδρία, το Android κρατά στην προσωρινή μνήμη το πολύ τόσες εφαρμογές και κλείνει τις υπόλοιπες, ώστε το παιχνίδι να πάρει τη μνήμη τους. Το DroidDeck επαναφέρει το όριο όπως ήταν όταν τελειώσει η συνεδρία. Ο Ασύρματος εντοπισμός σφαλμάτων πρέπει να είναι ενεργός.</string> + <string name="setup_bg_limit_4">Το πολύ 4</string> + <string name="setup_bg_limit_1">Το πολύ 1</string> + <string name="setup_bg_limit_0">Καμία</string> + <string name="setup_adb_pair">Ασύρματος εντοπισμός σφαλμάτων</string> + <string name="setup_adb_pair_hint">Συζευγνύει το DroidDeck με τον Ασύρματο εντοπισμό σφαλμάτων του Android για τις παραπάνω επιλογές. Το όριο θυγατρικών διαδικασιών παραμένει απενεργοποιημένο.</string> + <string name="setup_adb_pair_button">Σύζευξη</string> + <string name="epic_client_missing">Δεν είναι εγκατεστημένο</string> + <string name="epic_client_open">Άνοιγμα</string> + <string name="epic_client_install">Εγκατάσταση</string> + <string name="game_tab_about">Σχετικά</string> + <string name="game_tab_install">Εγκατάσταση</string> + <string name="game_install_location">Τοποθεσία</string> + <string name="game_install_folder">Φάκελος</string> + <string name="game_install_size">Μέγεθος στον δίσκο</string> + <string name="game_install_date">Εγκατεστημένο</string> + <string name="stores_xbox_update">Ενημέρωση / επαλήθευση</string> + <string name="stores_xbox_manage">Διαχείριση περιεχομένου</string> + <string name="stores_xbox_close_first">Κλείστε αυτό το παιχνίδι Xbox πριν το ενημερώσετε.</string> + <string name="stores_xbox_content_title">Περιεχόμενο Xbox</string> + <string name="stores_xbox_content_none">Δεν υπάρχουν αδειοδοτημένα πακέτα περιεχομένου PC για αυτό το παιχνίδι.</string> + <string name="stores_xbox_content_update">%1$s · Ενημέρωση / επαλήθευση</string> + <string name="stores_xbox_content_install">%1$s · Εγκατάσταση</string> + <string name="stores_xbox_choose_content">Επιλογή περιεχομένου PC</string> + <string name="stores_eyebrow_pass">διαθέσιμο με %1$s</string> + <string name="stores_owned_in_library">Παιχνίδια σας στη Βιβλιοθήκη</string> + <string name="library_filter_added">Προστέθηκαν</string> + <string name="game_tab_launch">Εκκίνηση</string> + <string name="game_tab_saves_cloud">Αποθηκεύσεις και cloud</string> + <string name="game_launch_shortcut">Συντόμευση Steam</string> + <string name="game_install_version">Έκδοση</string> + <string name="game_install_played">Τελευταίο παιχνίδι</string> + <string name="game_install_access">Πρόσβαση</string> + <string name="game_install_access_owned">Δικό σας</string> + <string name="game_stream">Ροή</string> + <string name="library_add">Προσθήκη στη Βιβλιοθήκη</string> + <string name="library_remove">Αφαίρεση από τη Βιβλιοθήκη</string> + <string name="library_in">Στη Βιβλιοθήκη</string> + <string name="xbox_filter_show">Εμφάνιση</string> + <string name="xbox_filter_cloud">Cloud</string> + <string name="stores_xbox_cloud_settings">Ρυθμίσεις cloud</string> </resources> diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 9060bac1e..1e3859f58 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -267,6 +267,24 @@ <string name="osc_steam_touch">Táctil de Steam (beta)</string> <string name="osc_style_next_session">Los controles táctiles de Steam se activan en la próxima sesión de Steam</string> <string name="osc_steam_layout">Disposición táctil de Steam</string> + <string name="screen_keys">Teclas en pantalla</string> + <string name="screen_keys_add">Añadir tecla</string> + <string name="screen_key_text">Texto</string> + <string name="screen_key_size">Tamaño</string> + <string name="screen_key_opacity">Opacidad</string> + <string name="screen_key_type_button">Tecla</string> + <string name="screen_key_shape_trigger">Gatillo</string> + <string name="screen_key_toggle">Alternar</string> + <string name="screen_key_layout_export">Exportar</string> + <string name="screen_key_shape_pill">Píldora</string> + <string name="screen_key_shape_round">Redondeado</string> + <string name="screen_key_shape_circle">Círculo</string> + <string name="screen_key_shape_rect">Rectángulo</string> + <string name="screen_key_bind">Tecla</string> + <string name="screen_key_image">Imagen</string> + <string name="screen_key_image_clear">Borrar</string> + <string name="screen_key_layout">Disposición</string> + <string name="screen_key_layout_save">Guardar</string> <string name="osc_steam_layout_hint">Mueve, cambia de tamaño u oculta los controles táctiles de este juego; se guarda en Steam</string> <string name="mode_controller">Mando</string> <string name="mode_controller_hint">Cómo ve Steam tu mando. Se aplica en la próxima sesión.</string> @@ -545,8 +563,8 @@ <string name="perf_sysmem">Turnip: renderizado sysmem</string> <string name="perf_sysmem_hint">Más rápido para el cliente de Steam; en los juegos, el controlador elige entre renderizado por mosaicos o en memoria del sistema en cada pasada. Obligatorio en Adreno 710/720/722.</string> <string name="perf_xalia">Omitir el asistente xalia de Steam</string> - <string name="perf_fastsync">droiddeck-ntsync (experimental)</string> - <string name="perf_fastsync_hint">Un sustituto en espacio de usuario del controlador ntsync de Linux, para pruebas. Mientras está activado, Proton lo usa en lugar de droiddeck-esync y droiddeck-fsync. Funciona con Proton Experimental ARM64, GE-Proton y proton-cachyos. Se aplica en la próxima sesión.</string> + <string name="perf_fastsync">droiddeck-ntsync</string> + <string name="perf_fastsync_hint">Un sustituto en espacio de usuario del controlador ntsync de Linux. Mientras está activado, Proton lo usa en lugar de droiddeck-esync y droiddeck-fsync. Funciona con Proton Experimental ARM64, GE-Proton y proton-cachyos. Se aplica en la próxima sesión.</string> <string name="perf_esync">droiddeck-esync</string> <string name="perf_fsync">droiddeck-fsync primero</string> <string name="perf_fsync_hint">Usa el fsync propio de Proton con los archivos originales de Proton, con futex_waitv proporcionado en espacio de usuario, así que no necesita paquete y sigue funcionando en cuanto Steam actualiza Proton. Mientras está activado, los juegos lo usan antes que droiddeck-esync, salvo los pocos que Proton mantiene sin fsync. Se aplica en la próxima sesión.</string> @@ -1031,8 +1049,6 @@ <string name="games_empty">Los juegos que instales en Steam aparecen aquí después de la sesión.</string> <string name="games_play_steam">Jugar en Steam</string> <string name="games_play_steamvr">Jugar en SteamVR</string> - <string name="games_launch_settings">Ajustes de inicio</string> - <string name="games_launch">Iniciar</string> <string name="games_files">Archivos del juego</string> <string name="games_prefix">Prefijo de Proton</string> <string name="games_saves_looking">Buscando partidas guardadas…</string> @@ -1040,7 +1056,6 @@ <string name="games_saves_folders">%1$d carpetas de guardado</string> <string name="games_saves_summary">%1$s · %2$d archivos · %3$s</string> <string name="games_kb">%1$d KB</string> - <string name="games_manage_saves">Gestionar partidas guardadas</string> <string name="games_saves">Partidas guardadas</string> <string name="games_import_saves">Importar partidas guardadas…</string> <string name="games_export_gamehub">Exportar como zip de GameHub…</string> @@ -1052,7 +1067,6 @@ <string name="games_runtime_first_play">El runtime se instala al jugar</string> <string name="games_runtime_update">Actualización del runtime disponible</string> <string name="games_runtime_ready">● Runtime listo</string> - <string name="games_last_played">Última partida %1$s</string> <string name="games_internal">Almacenamiento interno</string> <string name="games_added">Juego añadido</string> <string name="games_custom">Juego personalizado</string> @@ -1121,7 +1135,7 @@ <string name="second_terminal">Terminal de Linux</string> <string name="second_terminal_not_ready">La sesión de Linux aún no está lista para un terminal.</string> <string name="rail_steam">Steam</string> - <string name="rail_games">Juegos</string> + <string name="rail_games">Biblioteca</string> <string name="rail_desktop">Escritorio</string> <string name="rail_store">Tienda</string> <string name="rail_components">Componentes</string> @@ -1417,6 +1431,10 @@ <string name="theme_icon_blue_detail">Fondo negro, texto blanco y el azul para todo lo seleccionado, enfocado o pulsado.</string> <string name="theme_electric">Azul marino eléctrico</string> <string name="theme_electric_detail">Un azul más vivo, sobre superficies que tiran a azul marino en vez de gris.</string> + <string name="theme_inner_gamer">Inner Gamer</string> + <string name="theme_inner_gamer_detail">Colores que recorren el arcoíris. Respeta los ajustes de animación de Android.</string> + <string name="theme_rgb_speed">Velocidad</string> + <string name="theme_rgb_speed_hint">Con qué rapidez cambian los colores.</string> <string name="theme_gradient">Degradado</string> <string name="theme_gradient_detail">Tus propios colores primario y secundario, combinados en botones y resaltados.</string> <string name="theme_gradient_primary">Primario</string> @@ -1543,6 +1561,13 @@ <string name="fm_conflict_skip">Omitir</string> <string name="fm_select_this_folder">Seleccionar esta carpeta</string> <string name="fm_back">Atrás</string> + <string name="fm_exit">Salir</string> + <string name="fm_return_to_start">Volver a la carpeta inicial</string> + <string name="fm_start_unavailable">La carpeta inicial ya no está disponible</string> + <string name="fm_cancel_operation_title">¿Cancelar la operación?</string> + <string name="fm_cancel_operation_confirmation">La operación se cancelará. ¿Estás seguro?</string> + <string name="fm_accept">Aceptar</string> + <string name="fm_decline">Rechazar</string> <string name="fm_drive_not_readable">%1$s está montado, pero ahora mismo no se puede leer</string> <string name="fm_list_view">Vista de lista</string> <string name="fm_grid_view">Vista de cuadrícula</string> @@ -1959,7 +1984,6 @@ <string name="source_steam">Steam</string> <string name="source_added">Personalizados</string> <string name="setup_stores">Tiendas</string> - <string name="setup_stores_open_on">Abrir una tienda en</string> <string name="setup_stores_speed">Nivel de velocidad de descarga</string> <string name="setup_stores_engine">Motor de las tiendas</string> <string name="setup_stores_engine_chip_ready">● Motor listo</string> @@ -1973,11 +1997,9 @@ <string name="stores_tabs_arrange">Organizar pestañas</string> <string name="stores_tabs_arrange_done">Listo</string> <string name="stores_chip_signed_out">(sin iniciar sesión)</string> - <string name="stores_tab_store">Tienda</string> <string name="stores_tab_library">Biblioteca</string> <string name="stores_tab_all">Todos</string> <string name="stores_tab_downloads">Descargas</string> - <string name="stores_tab_count">%1$s (%2$d)</string> <string name="stores_signin">Iniciar sesión</string> <string name="stores_signout_named">%1$s · cerrar sesión</string> <string name="stores_signout_title">¿Cerrar sesión en %1$s?</string> @@ -1995,9 +2017,10 @@ <string name="stores_shelf_deals">Ofertas</string> <string name="stores_shelf_free">Juegos gratis</string> <string name="stores_shelf_trending">En tendencia</string> - <string name="stores_shelf_library">Tu biblioteca</string> <string name="stores_play">Jugar</string> <string name="stores_install">Instalar</string> + <string name="bnet_open">Abrir Battle.net</string> + <string name="bnet_store">Juegos de Blizzard</string> <string name="stores_resume_install">Reanudar instalación</string> <string name="stores_dl_interrupted">Interrumpida</string> <string name="stores_install_size">Instalar · %1$s</string> @@ -2016,8 +2039,32 @@ <string name="stores_itch_no_exe">No se ha encontrado un juego de Windows en la descarga</string> <string name="stores_itch_extract_failed">No se ha podido extraer la descarga (código %1$d)</string> <string name="stores_itch_no_catalog">itch.io no respondió</string> + <string name="stores_itch_sort_popular">Populares</string> + <string name="stores_itch_sort_new">Más recientes</string> + <string name="stores_itch_sort_new_popular">Nuevos y populares</string> + <string name="stores_itch_price_paid">De pago</string> + <string name="stores_itch_price_under_5">5 $ o menos</string> + <string name="stores_itch_price_under_15">15 $ o menos</string> + <string name="stores_itch_sort_top_rated">Mejor valorados</string> + <string name="stores_itch_sort_top_sellers">Más vendidos</string> + <string name="stores_itch_price_any">Cualquier precio</string> + <string name="stores_itch_price_free">Gratis</string> + <string name="stores_itch_price_sale">En oferta</string> + <string name="stores_itch_load_more">Cargar más</string> + <string name="stores_itch_platform_any">Cualquier plataforma</string> + <string name="stores_itch_platform_mobile_web">Web móvil</string> + <string name="stores_itch_tag_add">Añadir una etiqueta</string> + <string name="stores_itch_tag_title">Filtrar por etiqueta</string> + <string name="stores_itch_browse_failed">No se pudieron cargar los juegos de itch.io</string> <string name="stores_itch_archive">Los archivos %1$s no son compatibles</string> <string name="stores_itch_download_failed">itch.io respondió HTTP %1$d</string> + <string name="stores_itch_pick_title">Elige una versión</string> + <string name="stores_itch_pick_none">No se han encontrado descargas para este juego</string> + <string name="stores_itch_pick_failed">No se pudieron cargar las descargas de itch.io</string> + <string name="stores_itch_build_demo">Demo</string> + <string name="stores_itch_build_open">Abrir en itch.io</string> + <string name="stores_itch_build_linux_soon">Aún no es compatible</string> + <string name="stores_itch_no_game">No se ha encontrado este juego en itch.io</string> <string name="stores_free">Gratis</string> <string name="stores_resume">Reanudar</string> <string name="stores_refresh">Actualizar</string> @@ -2167,6 +2214,7 @@ <string name="stores_login_close">Cerrar</string> <string name="stores_login_retry">Reintentar</string> <string name="stores_login_finishing">Terminando de iniciar sesión…</string> + <string name="stores_xbox_sign_in_again">Microsoft ha rechazado el inicio de sesión de Xbox. Cierra sesión de Xbox en Tiendas, vuelve a iniciar sesión y reanuda.</string> <string name="stores_login_error_network">No se pudo cargar la página de inicio de sesión: %1$s</string> <string name="stores_login_error_idp_blocked">Este proveedor no permite iniciar sesión desde una página integrada. Inicia sesión con la contraseña de la tienda.</string> <string name="stores_login_error_http">La página de inicio de sesión respondió con un error (HTTP %1$d).</string> @@ -2564,9 +2612,6 @@ <string name="storage_tool_x64_client">Lo usa el cliente de Steam x86-64</string> <string name="storage_uninstall_in_x64">Esta copia está en el cliente de Steam x86-64. Elige x86-64 en Cliente de Steam y desinstálala aquí.</string> <string name="storage_uninstall_in_arm64">Esta copia está en el cliente de Steam ARM64. Elige ARM64 en Cliente de Steam y desinstálala aquí.</string> - <string name="mode_zram">Compresión zRAM</string> - <string name="mode_zram_hint">Mientras se ejecuta un juego, la memoria del cliente de Steam se comprime en la zram del dispositivo, lo que deja más RAM para el juego. Se aplica la próxima vez que se inicie Steam.</string> - <string name="mode_zram_unsupported">No disponible: este dispositivo no tiene swap activo, o su kernel es anterior a la 5.4.</string> <string name="stores_storage_insufficient">No hay suficiente espacio libre: se necesitan %1$s y solo hay %2$s libres</string> <string name="stores_storage_low">Espacio insuficiente (%1$s libres, se necesitan %2$s)</string> <string name="stores_target_free_unknown">Espacio libre desconocido</string> @@ -3018,4 +3063,57 @@ <string name="spatial_saved_views_hint">Las vistas guardadas se pueden recalcular. Inicie una nueva calibración para capturar en el sistema de coordenadas de seguimiento actual.</string> <string name="spatial_model_checksum_error">La suma de comprobación del modelo de mano no coincide</string> <string name="spatial_opencv_load_error">OpenCV no se pudo cargar</string> + <string name="setup_group_memory">Memoria</string> + <string name="setup_clear_cached">Cerrar apps en caché antes de una sesión</string> + <string name="setup_clear_cached_hint">Al empezar una sesión, cierra las apps que Android mantiene en segundo plano para que el juego tenga más memoria. La depuración inalámbrica debe estar activada.</string> + <string name="setup_clear_cached_unpaired">DroidDeck debe estar vinculado con la depuración inalámbrica (Vincular, abajo).</string> + <string name="setup_memory_hud">Indicador de memoria</string> + <string name="setup_memory_hud_hint">Memoria y swap en uso, en la pantalla de la sesión. Avanzado lo divide entre el juego, DroidDeck, Android y la GPU.</string> + <string name="setup_memory_hud_simple">Simple</string> + <string name="setup_memory_hud_advanced">Avanzado</string> + <string name="memory_hud_simple">RAM %1$s/%2$s GB · swap %3$s/%4$s GB</string> + <string name="memory_hud_split">juego %1$s · DroidDeck %2$s · Android %3$s GB</string> + <string name="memory_hud_gpu">GPU %1$s GB</string> + <string name="setup_bg_limit">Apps en segundo plano durante una sesión</string> + <string name="setup_bg_limit_hint">Mientras una sesión está en marcha, Android mantiene en caché como máximo este número de apps y cierra las demás, para que el juego tenga su memoria. Se restablece al terminar la sesión. La depuración inalámbrica debe estar activada.</string> + <string name="setup_bg_limit_4">Como máximo 4</string> + <string name="setup_bg_limit_1">Como máximo 1</string> + <string name="setup_bg_limit_0">Ninguna</string> + <string name="setup_adb_pair">Depuración inalámbrica</string> + <string name="setup_adb_pair_hint">Vincula DroidDeck con la depuración inalámbrica de Android para las opciones de arriba. El límite de procesos secundarios sigue desactivado.</string> + <string name="setup_adb_pair_button">Vincular</string> + <string name="epic_client_missing">No instalada</string> + <string name="epic_client_open">Abrir</string> + <string name="epic_client_install">Instalar</string> + <string name="game_tab_about">Acerca de</string> + <string name="game_tab_install">Instalar</string> + <string name="game_install_location">Ubicación</string> + <string name="game_install_folder">Carpeta</string> + <string name="game_install_size">Tamaño en disco</string> + <string name="game_install_date">Instalado</string> + <string name="stores_xbox_update">Actualizar / verificar</string> + <string name="stores_xbox_manage">Gestionar contenido</string> + <string name="stores_xbox_close_first">Cierra este juego de Xbox antes de actualizarlo.</string> + <string name="stores_xbox_content_title">Contenido de Xbox</string> + <string name="stores_xbox_content_none">No hay paquetes de contenido de PC con licencia para este juego.</string> + <string name="stores_xbox_content_update">%1$s · Actualizar / verificar</string> + <string name="stores_xbox_content_install">%1$s · Instalar</string> + <string name="stores_xbox_choose_content">Elegir contenido de PC</string> + <string name="stores_eyebrow_pass">disponible con %1$s</string> + <string name="stores_owned_in_library">Juegos comprados en la Biblioteca</string> + <string name="library_filter_added">Añadidos</string> + <string name="game_tab_launch">Inicio</string> + <string name="game_tab_saves_cloud">Partidas y nube</string> + <string name="game_launch_shortcut">Acceso directo de Steam</string> + <string name="game_install_version">Versión</string> + <string name="game_install_played">Jugado por última vez</string> + <string name="game_install_access">Acceso</string> + <string name="game_install_access_owned">Comprado</string> + <string name="game_stream">Transmitir</string> + <string name="library_add">Añadir a la Biblioteca</string> + <string name="library_remove">Quitar de la Biblioteca</string> + <string name="library_in">En la Biblioteca</string> + <string name="xbox_filter_show">Mostrar</string> + <string name="xbox_filter_cloud">Nube</string> + <string name="stores_xbox_cloud_settings">Ajustes de nube</string> </resources> diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index a6e749372..30f334161 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -267,6 +267,24 @@ <string name="osc_steam_touch">Steam-kosketus (beta)</string> <string name="osc_style_next_session">Steamin kosketusohjaimet otetaan käyttöön seuraavassa Steam-istunnossa</string> <string name="osc_steam_layout">Steamin kosketusasettelu</string> + <string name="screen_keys">Näytön näppäimet</string> + <string name="screen_keys_add">Lisää näppäin</string> + <string name="screen_key_text">Teksti</string> + <string name="screen_key_size">Koko</string> + <string name="screen_key_opacity">Peittävyys</string> + <string name="screen_key_type_button">Näppäin</string> + <string name="screen_key_shape_trigger">Liipaisin</string> + <string name="screen_key_toggle">Vaihda</string> + <string name="screen_key_layout_export">Vie</string> + <string name="screen_key_shape_pill">Kapseli</string> + <string name="screen_key_shape_round">Pyöristetty</string> + <string name="screen_key_shape_circle">Ympyrä</string> + <string name="screen_key_shape_rect">Suorakaide</string> + <string name="screen_key_bind">Näppäin</string> + <string name="screen_key_image">Kuva</string> + <string name="screen_key_image_clear">Tyhjennä</string> + <string name="screen_key_layout">Asettelu</string> + <string name="screen_key_layout_save">Tallenna</string> <string name="osc_steam_layout_hint">Siirrä, muuta kokoa tai piilota tämän pelin kosketusohjaimet; tallennetaan Steamiin</string> <string name="mode_controller">Ohjain</string> <string name="mode_controller_hint">Miten Steam näkee ohjaimesi. Tulee voimaan seuraavassa istunnossa.</string> @@ -287,9 +305,6 @@ <string name="mode_suspend_steam_note">Automaattinen keskeyttää ja jatkaa, kun sovellus tulee näkyviin. Natiivi valmistelee Steamin lepotilaan ensin. Manuaalinen odottaa Jatka-painiketta. Ei koskaan pitää sovelluksen aina käynnissä.</string> <string name="mode_background_downloads">Pidä lataukset käynnissä</string> <string name="mode_background_downloads_hint">Viivästytä taustakeskeytystä, kun Steam lataa aktiivisesti.</string> - <string name="mode_zram">zRAM-pakkaus</string> - <string name="mode_zram_hint">Pelin pyöriessä Steam-asiakkaan muisti pakataan laitteen zramiin, jolloin pelille jää enemmän RAM-muistia. Astuu voimaan, kun Steam käynnistetään seuraavan kerran.</string> - <string name="mode_zram_unsupported">Ei käytettävissä: laitteessa ei ole zramia tai swappia, tai ydin on vanhempi kuin 5.4.</string> <string name="mode_suspend_note">Automaattinen keskeyttää taustalla ja jatkaa, kun sovellus tulee näkyviin. Manuaalinen odottaa Jatka-painiketta. Ei koskaan pitää sovelluksen aina käynnissä.</string> <string name="mode_startup">Käynnistys</string> <string name="mode_steam_startup">Käynnistä Steam DroidDeckin kanssa</string> @@ -544,8 +559,8 @@ <string name="perf_sysmem">Turnip: sysmem-renderöinti</string> <string name="perf_sysmem_hint">Nopeampi Steam-asiakasohjelmalle; peleissä ajuri valitsee jokaiselle vaiheelle ruuduittaisen tai järjestelmämuistiin tehtävän renderöinnin. Pakollinen Adreno 710/720/722:lla.</string> <string name="perf_xalia">Ohita Steamin xalia-avustaja</string> - <string name="perf_fastsync">droiddeck-ntsync (kokeellinen)</string> - <string name="perf_fastsync_hint">Käyttäjätilainen korvike Linuxin ntsync-ajurille, testausta varten. Kun se on päällä, Proton käyttää sitä droiddeck-esyncin ja droiddeck-fsyncin sijaan. Toimii Proton Experimental ARM64:n, GE-Protonin ja proton-cachyosin kanssa. Tulee voimaan seuraavassa istunnossa.</string> + <string name="perf_fastsync">droiddeck-ntsync</string> + <string name="perf_fastsync_hint">Käyttäjätilainen korvike Linuxin ntsync-ajurille. Kun se on päällä, Proton käyttää sitä droiddeck-esyncin ja droiddeck-fsyncin sijaan. Toimii Proton Experimental ARM64:n, GE-Protonin ja proton-cachyosin kanssa. Tulee voimaan seuraavassa istunnossa.</string> <string name="perf_esync">droiddeck-esync</string> <string name="perf_fsync">droiddeck-fsync etusijalle</string> <string name="perf_fsync_hint">Käynnistää Protonin omaa fsynciä vakiotiedostoilla, futex_waitv tarjottuna käyttäjätilassa, niin se ei tarvitse pakettia ja toimii edelleen heti kun Steam päivittää Protonin. Kun se on päällä, pelit käyttävät sitä droiddeck-esyncin edellä, paitsi harvat, jotka Proton pitää pois fsyncistä. Tulee voimaan seuraavassa istunnossa.</string> @@ -1026,8 +1041,6 @@ <string name="games_empty">Steamissa asentamasi pelit näkyvät tässä istunnon jälkeen.</string> <string name="games_play_steam">Pelaa Steamissa</string> <string name="games_play_steamvr">Pelaa SteamVR:ssä</string> - <string name="games_launch_settings">Käynnistysasetukset</string> - <string name="games_launch">Käynnistä</string> <string name="games_files">Pelitiedostot</string> <string name="games_prefix">Proton-prefix</string> <string name="games_saves_looking">Etsitään tallennuksia…</string> @@ -1035,7 +1048,6 @@ <string name="games_saves_folders">%1$d tallennuskansiota</string> <string name="games_saves_summary">%1$s · %2$d tiedostoa · %3$s</string> <string name="games_kb">%1$d kt</string> - <string name="games_manage_saves">Hallitse tallennuksia</string> <string name="games_saves">Pelitallennukset</string> <string name="games_import_saves">Tuo tallennuksia…</string> <string name="games_export_gamehub">Vie GameHub-zipinä…</string> @@ -1047,7 +1059,6 @@ <string name="games_runtime_first_play">Ajoympäristö asentuu ensimmäisellä Pelaa-painalluksella</string> <string name="games_runtime_update">Ajoympäristön päivitys saatavilla</string> <string name="games_runtime_ready">● Ajoympäristö valmis</string> - <string name="games_last_played">Viimeksi pelattu %1$s</string> <string name="games_internal">Sisäinen tallennustila</string> <string name="games_added">Lisätty peli</string> <string name="games_custom">Mukautettu peli</string> @@ -1116,7 +1127,7 @@ <string name="second_terminal">Linux-pääte</string> <string name="second_terminal_not_ready">Linux-istunto ei ole vielä valmis päätteelle.</string> <string name="rail_steam">Steam</string> - <string name="rail_games">Pelit</string> + <string name="rail_games">Kirjasto</string> <string name="rail_desktop">Työpöytä</string> <string name="rail_store">Kauppa</string> <string name="rail_components">Komponentit</string> @@ -1404,6 +1415,10 @@ <string name="theme_icon_blue_detail">Musta pohja, valkoinen teksti, sininen kaikessa valitussa, kohdistetussa tai painetussa.</string> <string name="theme_electric">Sähköinen laivastonsininen</string> <string name="theme_electric_detail">Sininen kirkkaampana pinnoilla, jotka taittuvat harmaan sijaan laivastonsiniseen.</string> + <string name="theme_inner_gamer">Inner Gamer</string> + <string name="theme_inner_gamer_detail">Värit, jotka kiertävät sateenkaaren läpi. Noudattaa Androidin animaatioasetuksia.</string> + <string name="theme_rgb_speed">Nopeus</string> + <string name="theme_rgb_speed_hint">Kuinka nopeasti värit vaihtuvat.</string> <string name="theme_gradient">Liukuväri</string> <string name="theme_gradient_detail">Omat ensisijainen ja toissijainen värisi, sulautettuina painikkeisiin ja korostuksiin.</string> <string name="theme_gradient_primary">Ensisijainen</string> @@ -1525,6 +1540,13 @@ <string name="fm_conflict_skip">Ohita</string> <string name="fm_select_this_folder">Valitse tämä kansio</string> <string name="fm_back">Takaisin</string> + <string name="fm_exit">Poistu</string> + <string name="fm_return_to_start">Palaa aloituskansioon</string> + <string name="fm_start_unavailable">Aloituskansio ei ole enää käytettävissä</string> + <string name="fm_cancel_operation_title">Peruutetaanko toiminto?</string> + <string name="fm_cancel_operation_confirmation">Toiminto peruutetaan. Oletko varma?</string> + <string name="fm_accept">Hyväksy</string> + <string name="fm_decline">Hylkää</string> <string name="fm_drive_not_readable">%1$s on liitetty, mutta ei ole luettavissa juuri nyt</string> <string name="fm_list_view">Luettelonäkymä</string> <string name="fm_grid_view">Ruudukkonäkymä</string> @@ -1939,7 +1961,6 @@ <string name="source_steam">Steam</string> <string name="source_added">Mukautettu</string> <string name="setup_stores">Kaupat</string> - <string name="setup_stores_open_on">Avaa kauppa näytöllä</string> <string name="setup_stores_speed">Latausnopeuden taso</string> <string name="setup_stores_engine">Kauppojen moottori</string> <string name="setup_stores_engine_chip_ready">● Moottori valmis</string> @@ -1953,11 +1974,9 @@ <string name="stores_tabs_arrange">Järjestä välilehdet</string> <string name="stores_tabs_arrange_done">Valmis</string> <string name="stores_chip_signed_out">(ei kirjautunut)</string> - <string name="stores_tab_store">Kauppa</string> <string name="stores_tab_library">Kirjasto</string> <string name="stores_tab_all">Kaikki</string> <string name="stores_tab_downloads">Lataukset</string> - <string name="stores_tab_count">%1$s (%2$d)</string> <string name="stores_signin">Kirjaudu sisään</string> <string name="stores_signout_named">%1$s · kirjaudu ulos</string> <string name="stores_signout_title">Kirjaudutaanko ulos %1$s:stä?</string> @@ -1975,9 +1994,10 @@ <string name="stores_shelf_deals">Tarjoukset</string> <string name="stores_shelf_free">Ilmaiset pelit</string> <string name="stores_shelf_trending">Nousussa</string> - <string name="stores_shelf_library">Kirjastosi</string> <string name="stores_play">Pelaa</string> <string name="stores_install">Asenna</string> + <string name="bnet_open">Avaa Battle.net</string> + <string name="bnet_store">Blizzardin pelit</string> <string name="stores_resume_install">Jatka asennusta</string> <string name="stores_dl_interrupted">Keskeytynyt</string> <string name="stores_install_size">Asenna · %1$s</string> @@ -1996,8 +2016,32 @@ <string name="stores_itch_no_exe">Latauksesta ei löytynyt Windows-peliä</string> <string name="stores_itch_extract_failed">Latausta ei voitu purkaa (koodi %1$d)</string> <string name="stores_itch_no_catalog">itch.io ei vastannut</string> + <string name="stores_itch_sort_popular">Suositut</string> + <string name="stores_itch_sort_new">Uusimmat</string> + <string name="stores_itch_sort_new_popular">Uudet ja suositut</string> + <string name="stores_itch_price_paid">Maksulliset</string> + <string name="stores_itch_price_under_5">Enintään 5 $</string> + <string name="stores_itch_price_under_15">Enintään 15 $</string> + <string name="stores_itch_sort_top_rated">Parhaiten arvioidut</string> + <string name="stores_itch_sort_top_sellers">Myydyimmät</string> + <string name="stores_itch_price_any">Mikä tahansa hinta</string> + <string name="stores_itch_price_free">Ilmaiset</string> + <string name="stores_itch_price_sale">Alennuksessa</string> + <string name="stores_itch_load_more">Lataa lisää</string> + <string name="stores_itch_platform_any">Mikä tahansa alusta</string> + <string name="stores_itch_platform_mobile_web">Mobiiliweb</string> + <string name="stores_itch_tag_add">Lisää tunniste</string> + <string name="stores_itch_tag_title">Suodata tunnisteella</string> + <string name="stores_itch_browse_failed">Pelien lataaminen itch.io:sta epäonnistui</string> <string name="stores_itch_archive">%1$s-arkistot eivät ole tuettuja</string> <string name="stores_itch_download_failed">itch.io vastasi HTTP %1$d</string> + <string name="stores_itch_pick_title">Valitse versio</string> + <string name="stores_itch_pick_none">Tälle pelille ei löytynyt latauksia</string> + <string name="stores_itch_pick_failed">Latausten haku itch.iosta epäonnistui</string> + <string name="stores_itch_build_demo">Demo</string> + <string name="stores_itch_build_open">Avaa itch.iossa</string> + <string name="stores_itch_build_linux_soon">Ei vielä tuettu</string> + <string name="stores_itch_no_game">Peliä ei löytynyt itch.iosta</string> <string name="stores_free">Ilmainen</string> <string name="stores_resume">Jatka</string> <string name="stores_refresh">Päivitä</string> @@ -2150,6 +2194,7 @@ <string name="stores_login_close">Sulje</string> <string name="stores_login_retry">Yritä uudelleen</string> <string name="stores_login_finishing">Viimeistellään kirjautumista…</string> + <string name="stores_xbox_sign_in_again">Microsoft hylkäsi Xbox-kirjautumisen. Kirjaudu ulos Xboxista Kaupoissa, kirjaudu uudelleen sisään ja jatka sitten.</string> <string name="stores_login_error_network">Kirjautumissivua ei voitu latata: %1$s</string> <string name="stores_login_error_idp_blocked">Tämä palveluntarjoaja ei salli kirjautumista upotetulta sivulta. Kirjaudu sisään kaupan salasanalla sen sijaan.</string> <string name="stores_login_error_http">Kirjautumissivu vastasi virheellä (HTTP %1$d).</string> @@ -2982,4 +3027,57 @@ <string name="spatial_saved_views_hint">Tallennetut näkymät voidaan laskea uudelleen. Aloita uusi kalibrointi kaapataksesi nykyiseen seurantakoordinaattijärjestelmään.</string> <string name="spatial_model_checksum_error">Käsimallin tarkistussumman epäsuhta</string> <string name="spatial_opencv_load_error">OpenCV:tä ei voitu ladata</string> + <string name="setup_group_memory">Muisti</string> + <string name="setup_clear_cached">Sulje välimuistissa olevat sovellukset ennen istuntoa</string> + <string name="setup_clear_cached_hint">Kun istunto alkaa, DroidDeck sulkee sovellukset, joita Android pitää taustalla, jotta pelillä on enemmän muistia. Langattoman virheenkorjauksen on oltava päällä.</string> + <string name="setup_clear_cached_unpaired">Vaatii, että DroidDeck on pariutettu Langattoman virheenkorjauksen kautta (Pariuta, alla).</string> + <string name="setup_memory_hud">Muistinäyttö</string> + <string name="setup_memory_hud_hint">Käytössä oleva muisti ja swap istuntonäytöllä. Laajennettu näyttää erikseen pelin, DroidDeckin, Androidin ja GPU:n osuuden.</string> + <string name="setup_memory_hud_simple">Yksinkertainen</string> + <string name="setup_memory_hud_advanced">Laajennettu</string> + <string name="memory_hud_simple">RAM %1$s/%2$s GB · swap %3$s/%4$s GB</string> + <string name="memory_hud_split">peli %1$s · DroidDeck %2$s · Android %3$s GB</string> + <string name="memory_hud_gpu">GPU %1$s GB</string> + <string name="setup_bg_limit">Taustasovellukset istunnon aikana</string> + <string name="setup_bg_limit_hint">Istunnon aikana Android pitää välimuistissa enintään näin monta sovellusta ja sulkee loput, jotta peli saa niiden muistin. DroidDeck palauttaa rajan ennalleen, kun istunto päättyy. Langattoman virheenkorjauksen on oltava päällä.</string> + <string name="setup_bg_limit_4">Enintään 4</string> + <string name="setup_bg_limit_1">Enintään 1</string> + <string name="setup_bg_limit_0">Ei mitään</string> + <string name="setup_adb_pair">Langaton virheenkorjaus</string> + <string name="setup_adb_pair_hint">Pariuttaa DroidDeckin Androidin Langattomaan virheenkorjaukseen yllä olevia asetuksia varten. Lapsiprosessien raja pysyy pois päältä.</string> + <string name="setup_adb_pair_button">Pariuta</string> + <string name="epic_client_missing">Ei asennettu</string> + <string name="epic_client_open">Avaa</string> + <string name="epic_client_install">Asenna</string> + <string name="game_tab_about">Tietoja</string> + <string name="game_tab_install">Asenna</string> + <string name="game_install_location">Sijainti</string> + <string name="game_install_folder">Kansio</string> + <string name="game_install_size">Koko levyllä</string> + <string name="game_install_date">Asennettu</string> + <string name="stores_xbox_update">Päivitä / tarkista</string> + <string name="stores_xbox_manage">Hallitse sisältöä</string> + <string name="stores_xbox_close_first">Sulje tämä Xbox-peli ennen päivittämistä.</string> + <string name="stores_xbox_content_title">Xbox-sisältö</string> + <string name="stores_xbox_content_none">Tälle pelille ei ole lisensoituja PC-sisältöpaketteja.</string> + <string name="stores_xbox_content_update">%1$s · Päivitä / tarkista</string> + <string name="stores_xbox_content_install">%1$s · Asenna</string> + <string name="stores_xbox_choose_content">Valitse PC-sisältö</string> + <string name="stores_eyebrow_pass">saatavilla palvelussa %1$s</string> + <string name="stores_owned_in_library">Omistetut pelit kirjastossa</string> + <string name="library_filter_added">Lisätyt</string> + <string name="game_tab_launch">Käynnistys</string> + <string name="game_tab_saves_cloud">Tallennukset ja pilvi</string> + <string name="game_launch_shortcut">Steam-pikakuvake</string> + <string name="game_install_version">Versio</string> + <string name="game_install_played">Viimeksi pelattu</string> + <string name="game_install_access">Käyttöoikeus</string> + <string name="game_install_access_owned">Omistettu</string> + <string name="game_stream">Striimaa</string> + <string name="library_add">Lisää kirjastoon</string> + <string name="library_remove">Poista kirjastosta</string> + <string name="library_in">Kirjastossa</string> + <string name="xbox_filter_show">Näytä</string> + <string name="xbox_filter_cloud">Pilvi</string> + <string name="stores_xbox_cloud_settings">Pilviasetukset</string> </resources> diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 9e7d00dcc..76bf5abc2 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -267,6 +267,24 @@ <string name="osc_steam_touch">Tactile Steam (bêta)</string> <string name="osc_style_next_session">Les commandes tactiles de Steam s\'activeront à la prochaine session Steam</string> <string name="osc_steam_layout">Disposition tactile Steam</string> + <string name="screen_keys">Touches à l\'écran</string> + <string name="screen_keys_add">Ajouter une touche</string> + <string name="screen_key_text">Texte</string> + <string name="screen_key_size">Taille</string> + <string name="screen_key_opacity">Opacité</string> + <string name="screen_key_type_button">Touche</string> + <string name="screen_key_shape_trigger">Gâchette</string> + <string name="screen_key_toggle">Basculer</string> + <string name="screen_key_layout_export">Exporter</string> + <string name="screen_key_shape_pill">Pilule</string> + <string name="screen_key_shape_round">Arrondi</string> + <string name="screen_key_shape_circle">Cercle</string> + <string name="screen_key_shape_rect">Rectangle</string> + <string name="screen_key_bind">Touche</string> + <string name="screen_key_image">Image</string> + <string name="screen_key_image_clear">Effacer</string> + <string name="screen_key_layout">Disposition</string> + <string name="screen_key_layout_save">Enregistrer</string> <string name="osc_steam_layout_hint">Déplacez, redimensionnez ou masquez les commandes tactiles de ce jeu ; enregistré dans Steam</string> <string name="mode_controller">Manette</string> <string name="mode_controller_hint">Comment Steam reconnaît votre manette. Prendra effet à la prochaine session.</string> @@ -545,8 +563,8 @@ <string name="perf_sysmem">Turnip : rendu sysmem</string> <string name="perf_sysmem_hint">Le client Steam sera plus rapide ; en jeu, le pilote choisit à chaque passe entre rendu par tuiles et rendu en mémoire système. Requis pour Adreno 710/720/722.</string> <string name="perf_xalia">Ignorer l\'assistant xalia de Steam</string> - <string name="perf_fastsync">droiddeck-ntsync (expérimental)</string> - <string name="perf_fastsync_hint">Un substitut en espace utilisateur du pilote Linux ntsync, pour les tests. Lorsqu\'il est activé, Proton l\'utilise à la place de droiddeck-esync et de droiddeck-fsync. Fonctionne avec Proton Experimental ARM64, GE-Proton et proton-cachyos. Prendra effet à la prochaine session.</string> + <string name="perf_fastsync">droiddeck-ntsync</string> + <string name="perf_fastsync_hint">Un substitut en espace utilisateur du pilote Linux ntsync. Lorsqu\'il est activé, Proton l\'utilise à la place de droiddeck-esync et de droiddeck-fsync. Fonctionne avec Proton Experimental ARM64, GE-Proton et proton-cachyos. Prendra effet à la prochaine session.</string> <string name="perf_esync">droiddeck-esync</string> <string name="perf_fsync">droiddeck-fsync en priorité</string> <string name="perf_fsync_hint">Exécute le fsync propre à Proton sur les fichiers Proton d\'origine, avec futex_waitv fourni en espace utilisateur : aucun pack n\'est nécessaire et il continue de fonctionner dès que Steam met à jour Proton. Lorsqu\'il est activé, les jeux l\'utilisent avant droiddeck-esync, sauf les quelques-uns que Proton exclut de fsync. Prendra effet à la prochaine session.</string> @@ -1031,8 +1049,6 @@ <string name="games_empty">Les jeux installés sur Steam apparaîtront ici après la fin de la session.</string> <string name="games_play_steam">Lancer Steam</string> <string name="games_play_steamvr">Lancer SteamVR</string> - <string name="games_launch_settings">Paramètres de lancement</string> - <string name="games_launch">Lancer</string> <string name="games_files">Fichiers du jeu</string> <string name="games_prefix">Préfixe Proton</string> <string name="games_saves_looking">Recherche des sauvegardes…</string> @@ -1040,7 +1056,6 @@ <string name="games_saves_folders">%1$d dossiers de sauvegarde</string> <string name="games_saves_summary">%1$s · %2$d fichiers · %3$s</string> <string name="games_kb">%1$d Ko</string> - <string name="games_manage_saves">Gérer les sauvegardes</string> <string name="games_saves">Sauvegardes de jeu</string> <string name="games_import_saves">Importer des sauvegardes…</string> <string name="games_export_gamehub">Exporter en tant qu\'archive GameHub…</string> @@ -1052,7 +1067,6 @@ <string name="games_runtime_first_play">Installation de l\'environnement d\'exécution lors du premier lancement du jeu</string> <string name="games_runtime_update">Mise à jour de l\'environnement d\'exécution disponible</string> <string name="games_runtime_ready">● Environnement d\'exécution prêt</string> - <string name="games_last_played">Dernier lancement %1$s</string> <string name="games_internal">Stockage interne</string> <string name="games_added">Jeux ajoutés</string> <string name="games_custom">Jeu personnalisé</string> @@ -1121,7 +1135,7 @@ <string name="second_terminal">Terminal Linux</string> <string name="second_terminal_not_ready">La session Linux n\'est pas prête, le terminal ne peut pas être utilisé.</string> <string name="rail_steam">Steam</string> - <string name="rail_games">Jeux</string> + <string name="rail_games">Bibliothèque</string> <string name="rail_desktop">Bureau</string> <string name="rail_store">Boutique</string> <string name="rail_components">Composants</string> @@ -1417,6 +1431,10 @@ <string name="theme_icon_blue_detail">Fond noir, texte blanc, et le bleu pour tout élément sélectionné, ciblé ou pressé.</string> <string name="theme_electric">Marine électrique</string> <string name="theme_electric_detail">Un bleu plus vif, sur des surfaces qui tirent vers le marine plutôt que vers le gris.</string> + <string name="theme_inner_gamer">Inner Gamer</string> + <string name="theme_inner_gamer_detail">Des couleurs qui parcourent l\'arc-en-ciel. Respecte les réglages d\'animation d\'Android.</string> + <string name="theme_rgb_speed">Vitesse</string> + <string name="theme_rgb_speed_hint">La vitesse de défilement des couleurs.</string> <string name="theme_gradient">Dégradé</string> <string name="theme_gradient_detail">Vos propres couleurs primaire et secondaire, fondues sur les boutons et les éléments en surbrillance.</string> <string name="theme_gradient_primary">Primaire</string> @@ -1543,6 +1561,13 @@ <string name="fm_conflict_skip">Ignorer</string> <string name="fm_select_this_folder">Sélectionner ce dossier</string> <string name="fm_back">Retour</string> + <string name="fm_exit">Quitter</string> + <string name="fm_return_to_start">Revenir au dossier de départ</string> + <string name="fm_start_unavailable">Le dossier de départ est indisponible</string> + <string name="fm_cancel_operation_title">Annuler l’opération ?</string> + <string name="fm_cancel_operation_confirmation">Cette opération sera annulée. Confirmez-vous ?</string> + <string name="fm_accept">Accepter</string> + <string name="fm_decline">Refuser</string> <string name="fm_drive_not_readable">%1$s est monté, mais illisible pour le moment</string> <string name="fm_list_view">Vue en liste</string> <string name="fm_grid_view">Vue en grille</string> @@ -1959,7 +1984,6 @@ <string name="source_steam">Steam</string> <string name="source_added">Personnalisé</string> <string name="setup_stores">Boutiques</string> - <string name="setup_stores_open_on">Ouvrir une boutique sur</string> <string name="setup_stores_speed">Niveau de vitesse de téléchargement</string> <string name="setup_stores_engine">Moteur des boutiques</string> <string name="setup_stores_engine_chip_ready">● Moteur prêt</string> @@ -1973,11 +1997,9 @@ <string name="stores_tabs_arrange">Organiser les onglets</string> <string name="stores_tabs_arrange_done">Terminé</string> <string name="stores_chip_signed_out">(non connecté)</string> - <string name="stores_tab_store">Boutique</string> <string name="stores_tab_library">Bibliothèque</string> <string name="stores_tab_all">Tous</string> <string name="stores_tab_downloads">Téléchargements</string> - <string name="stores_tab_count">%1$s (%2$d)</string> <string name="stores_signin">Se connecter</string> <string name="stores_signout_named">%1$s · se déconnecter</string> <string name="stores_signout_title">Se déconnecter de %1$s ?</string> @@ -1995,9 +2017,10 @@ <string name="stores_shelf_deals">Promotions</string> <string name="stores_shelf_free">Jeux gratuits</string> <string name="stores_shelf_trending">Tendances</string> - <string name="stores_shelf_library">Votre bibliothèque</string> <string name="stores_play">Jouer</string> <string name="stores_install">Installer</string> + <string name="bnet_open">Ouvrir Battle.net</string> + <string name="bnet_store">Jeux Blizzard</string> <string name="stores_resume_install">Reprendre l\'installation</string> <string name="stores_dl_interrupted">Interrompu</string> <string name="stores_install_size">Installer · %1$s</string> @@ -2016,8 +2039,32 @@ <string name="stores_itch_no_exe">Aucun jeu Windows trouvé dans le téléchargement</string> <string name="stores_itch_extract_failed">Impossible d\'extraire le téléchargement (code %1$d)</string> <string name="stores_itch_no_catalog">itch.io n\'a pas répondu</string> + <string name="stores_itch_sort_popular">Populaires</string> + <string name="stores_itch_sort_new">Plus récents</string> + <string name="stores_itch_sort_new_popular">Nouveautés populaires</string> + <string name="stores_itch_price_paid">Payants</string> + <string name="stores_itch_price_under_5">5 $ ou moins</string> + <string name="stores_itch_price_under_15">15 $ ou moins</string> + <string name="stores_itch_sort_top_rated">Les mieux notés</string> + <string name="stores_itch_sort_top_sellers">Meilleures ventes</string> + <string name="stores_itch_price_any">Tous les prix</string> + <string name="stores_itch_price_free">Gratuits</string> + <string name="stores_itch_price_sale">En promotion</string> + <string name="stores_itch_load_more">Charger plus</string> + <string name="stores_itch_platform_any">Toute plateforme</string> + <string name="stores_itch_platform_mobile_web">Web mobile</string> + <string name="stores_itch_tag_add">Ajouter un tag</string> + <string name="stores_itch_tag_title">Filtrer par tag</string> + <string name="stores_itch_browse_failed">Impossible de charger les jeux d\'itch.io</string> <string name="stores_itch_archive">Les archives %1$s ne sont pas prises en charge</string> <string name="stores_itch_download_failed">itch.io a répondu HTTP %1$d</string> + <string name="stores_itch_pick_title">Choisissez une version</string> + <string name="stores_itch_pick_none">Aucun téléchargement trouvé pour ce jeu</string> + <string name="stores_itch_pick_failed">Impossible de charger les téléchargements depuis itch.io</string> + <string name="stores_itch_build_demo">Démo</string> + <string name="stores_itch_build_open">Ouvrir sur itch.io</string> + <string name="stores_itch_build_linux_soon">Pas encore pris en charge</string> + <string name="stores_itch_no_game">Ce jeu est introuvable sur itch.io</string> <string name="stores_free">Gratuit</string> <string name="stores_resume">Reprendre</string> <string name="stores_refresh">Actualiser</string> @@ -2167,6 +2214,7 @@ <string name="stores_login_close">Fermer</string> <string name="stores_login_retry">Réessayer</string> <string name="stores_login_finishing">Finalisation de la connexion…</string> + <string name="stores_xbox_sign_in_again">Microsoft a refusé la connexion Xbox. Déconnectez-vous de Xbox dans Boutiques, reconnectez-vous, puis reprenez.</string> <string name="stores_login_error_network">Impossible de charger la page de connexion : %1$s</string> <string name="stores_login_error_idp_blocked">Ce fournisseur refuse la connexion depuis une page intégrée. Connectez-vous plutôt avec le mot de passe de la boutique.</string> <string name="stores_login_error_http">La page de connexion a renvoyé une erreur (HTTP %1$d).</string> @@ -2564,9 +2612,6 @@ <string name="drawer_component_downloads">Télécharger des composants</string> <string name="drawer_component_downloads_hint">Les téléchargements sont partagés avec la page « Composants ». Choisissez une version téléchargée ci-dessus pour l\'utiliser au prochain lancement du jeu.</string> <string name="drawer_component_choose_download">Choisissez une version à télécharger</string> - <string name="mode_zram">Compression zRAM</string> - <string name="mode_zram_hint">Pendant qu\'un jeu tourne, la mémoire du client Steam est compressée dans la zram de l\'appareil, ce qui laisse plus de RAM au jeu. S\'applique au prochain démarrage de Steam.</string> - <string name="mode_zram_unsupported">Indisponible : cet appareil n\'a pas de swap actif, ou son noyau est antérieur à la version 5.4.</string> <string name="stores_storage_insufficient">Espace libre insuffisant : %1$s nécessaires, seulement %2$s libres</string> <string name="stores_storage_low">Espace insuffisant (%1$s libres, %2$s nécessaires)</string> <string name="stores_target_free_unknown">Espace libre inconnu</string> @@ -3018,4 +3063,57 @@ <string name="spatial_saved_views_hint">Les vues enregistrées peuvent être recalculées. Démarrez un nouvel étalonnage pour capturer dans le système de coordonnées de suivi actuel.</string> <string name="spatial_model_checksum_error">Incohérence de la somme de contrôle du modèle manuel</string> <string name="spatial_opencv_load_error">OpenCV n\'a pas pu charger</string> + <string name="setup_group_memory">Mémoire</string> + <string name="setup_clear_cached">Fermer les applis en cache avant une session</string> + <string name="setup_clear_cached_hint">Au début d\'une session, ferme les applis qu\'Android garde en arrière-plan, pour laisser plus de mémoire au jeu. Le débogage sans fil doit être activé.</string> + <string name="setup_clear_cached_unpaired">DroidDeck doit être associé au débogage sans fil (Associer, ci-dessous).</string> + <string name="setup_memory_hud">Indicateur de mémoire</string> + <string name="setup_memory_hud_hint">Mémoire et swap utilisés, sur l\'écran de la session. Avancé détaille le jeu, DroidDeck, Android et le GPU.</string> + <string name="setup_memory_hud_simple">Simple</string> + <string name="setup_memory_hud_advanced">Avancé</string> + <string name="memory_hud_simple">RAM %1$s/%2$s Go · swap %3$s/%4$s Go</string> + <string name="memory_hud_split">jeu %1$s · DroidDeck %2$s · Android %3$s Go</string> + <string name="memory_hud_gpu">GPU %1$s Go</string> + <string name="setup_bg_limit">Applis en arrière-plan pendant une session</string> + <string name="setup_bg_limit_hint">Pendant une session, Android garde au plus ce nombre d\'applis en cache et ferme les autres, pour laisser leur mémoire au jeu. Rétabli à la fin de la session. Le débogage sans fil doit être activé.</string> + <string name="setup_bg_limit_4">4 au maximum</string> + <string name="setup_bg_limit_1">1 au maximum</string> + <string name="setup_bg_limit_0">Aucune</string> + <string name="setup_adb_pair">Débogage sans fil</string> + <string name="setup_adb_pair_hint">Associe DroidDeck au débogage sans fil d\'Android pour les options ci-dessus. La limite des processus enfants reste désactivée.</string> + <string name="setup_adb_pair_button">Associer</string> + <string name="epic_client_missing">Non installé</string> + <string name="epic_client_open">Ouvrir</string> + <string name="epic_client_install">Installer</string> + <string name="game_tab_about">À propos</string> + <string name="game_tab_install">Installer</string> + <string name="game_install_location">Emplacement</string> + <string name="game_install_folder">Dossier</string> + <string name="game_install_size">Taille sur le disque</string> + <string name="game_install_date">Installé</string> + <string name="stores_xbox_update">Mettre à jour / vérifier</string> + <string name="stores_xbox_manage">Gérer le contenu</string> + <string name="stores_xbox_close_first">Fermez ce jeu Xbox avant de le mettre à jour.</string> + <string name="stores_xbox_content_title">Contenu Xbox</string> + <string name="stores_xbox_content_none">Aucun pack de contenu PC sous licence n\'est disponible pour ce jeu.</string> + <string name="stores_xbox_content_update">%1$s · Mettre à jour / vérifier</string> + <string name="stores_xbox_content_install">%1$s · Installer</string> + <string name="stores_xbox_choose_content">Choisir le contenu PC</string> + <string name="stores_eyebrow_pass">disponible avec %1$s</string> + <string name="stores_owned_in_library">Jeux possédés dans la Bibliothèque</string> + <string name="library_filter_added">Ajoutés</string> + <string name="game_tab_launch">Lancement</string> + <string name="game_tab_saves_cloud">Sauvegardes et cloud</string> + <string name="game_launch_shortcut">Raccourci Steam</string> + <string name="game_install_version">Version</string> + <string name="game_install_played">Dernière partie</string> + <string name="game_install_access">Accès</string> + <string name="game_install_access_owned">Possédé</string> + <string name="game_stream">Streamer</string> + <string name="library_add">Ajouter à la Bibliothèque</string> + <string name="library_remove">Retirer de la Bibliothèque</string> + <string name="library_in">Dans la Bibliothèque</string> + <string name="xbox_filter_show">Afficher</string> + <string name="xbox_filter_cloud">Cloud</string> + <string name="stores_xbox_cloud_settings">Paramètres cloud</string> </resources> diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index c7d0f6005..68cb4c185 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -267,6 +267,24 @@ <string name="osc_steam_touch">Steam टच (बीटा)</string> <string name="osc_style_next_session">Steam के टच कंट्रोल अगले Steam सेशन से शुरू होंगे</string> <string name="osc_steam_layout">Steam टच लेआउट</string> + <string name="screen_keys">स्क्रीन कुंजियाँ</string> + <string name="screen_keys_add">कुंजी जोड़ें</string> + <string name="screen_key_text">टेक्स्ट</string> + <string name="screen_key_size">आकार</string> + <string name="screen_key_opacity">ओपैसिटी</string> + <string name="screen_key_type_button">कुंजी</string> + <string name="screen_key_shape_trigger">ट्रिगर</string> + <string name="screen_key_toggle">टॉगल</string> + <string name="screen_key_layout_export">निर्यात</string> + <string name="screen_key_shape_pill">कैप्सूल</string> + <string name="screen_key_shape_round">गोल</string> + <string name="screen_key_shape_circle">वृत्त</string> + <string name="screen_key_shape_rect">आयत</string> + <string name="screen_key_bind">कुंजी</string> + <string name="screen_key_image">छवि</string> + <string name="screen_key_image_clear">हटाएं</string> + <string name="screen_key_layout">लेआउट</string> + <string name="screen_key_layout_save">सेव करें</string> <string name="osc_steam_layout_hint">इस गेम के टच कंट्रोल को घुमाएं, आकार बदलें या छिपाएं; Steam में सेव होता है</string> <string name="mode_controller">कंट्रोलर</string> <string name="mode_controller_hint">Steam आपके कंट्रोलर को किस तरह पहचानता है। अगले सेशन से लागू होता है।</string> @@ -287,9 +305,6 @@ <string name="mode_suspend_steam_note">ऑटो दिखने पर रुकता है और फिर से शुरू होता है। नेटिव पहले Steam को स्लीप के लिए तैयार करता है। मैनुअल फिर से शुरू करें का इंतज़ार करता है। कभी नहीं वाला चलता रहता है।</string> <string name="mode_background_downloads">डाउनलोड चलते रहने दें</string> <string name="mode_background_downloads_hint">Steam के सक्रिय रूप से डाउनलोड करते समय बैकग्राउंड सस्पेंशन को टालें।</string> - <string name="mode_zram">zRAM कम्प्रेशन</string> - <string name="mode_zram_hint">गेम चलने के दौरान Steam क्लाइंट की मेमोरी डिवाइस के zram में कम्प्रेस होती है, जिससे गेम को ज़्यादा RAM मिलती है। अगली बार Steam शुरू होने पर लागू होगा।</string> - <string name="mode_zram_unsupported">उपलब्ध नहीं: इस डिवाइस में zram या swap नहीं है, या कर्नेल 5.4 से पुराना है।</string> <string name="mode_suspend_note">ऑटो बैकग्राउंड में रुकता है और दिखने पर फिर से शुरू होता है। मैनुअल फिर से शुरू करें का इंतज़ार करता है। कभी नहीं वाला चलता रहता है।</string> <string name="mode_startup">स्टार्टअप</string> <string name="mode_steam_startup">DroidDeck के साथ Steam शुरू करें</string> @@ -544,8 +559,8 @@ <string name="perf_sysmem">Turnip: sysmem रेंडरिंग</string> <string name="perf_sysmem_hint">Steam क्लाइंट के लिए तेज़; गेम में ड्राइवर हर पास के लिए टाइल या सिस्टम मेमोरी रेंडरिंग चुनता है। Adreno 710/720/722 पर ज़रूरी।</string> <string name="perf_xalia">Steam के xalia हेल्पर को छोड़ें</string> - <string name="perf_fastsync">droiddeck-ntsync (एक्सपेरिमेंटल)</string> - <string name="perf_fastsync_hint">जांच के लिए Linux ntsync ड्राइवर की यूज़रस्पेस जगह लेने वाली चीज़। चालू रहते हुए, Proton इसे droiddeck-esync और droiddeck-fsync के बदले इस्तेमाल करता है। यह Proton Experimental ARM64, GE-Proton और proton-cachyos के साथ काम करता है। अगले सेशन से लागू होता है।</string> + <string name="perf_fastsync">droiddeck-ntsync</string> + <string name="perf_fastsync_hint">Linux ntsync ड्राइवर की यूज़रस्पेस जगह लेने वाली चीज़। चालू रहते हुए, Proton इसे droiddeck-esync और droiddeck-fsync के बदले इस्तेमाल करता है। यह Proton Experimental ARM64, GE-Proton और proton-cachyos के साथ काम करता है। अगले सेशन से लागू होता है।</string> <string name="perf_esync">droiddeck-esync</string> <string name="perf_fsync">droiddeck-fsync पहले</string> <string name="perf_fsync_hint">स्टॉक Proton फ़ाइलों पर Proton का अपना fsync चलाता है, futex_waitv यूज़रस्पेस में दिया जाता है, इसलिए इसे किसी पैक की ज़रूरत नहीं और Steam के Proton अपडेट करते ही यह काम करता रहता है। चालू रहते हुए, गेम इसे droiddeck-esync से पहले इस्तेमाल करते हैं, सिवाय उन कुछ के जिन्हें Proton fsync से दूर रखता है। अगले सेशन से लागू होता है।</string> @@ -1026,8 +1041,6 @@ <string name="games_empty">Steam में इंस्टॉल किए गेम सेशन के बाद यहां दिखते हैं।</string> <string name="games_play_steam">Steam चलाएं</string> <string name="games_play_steamvr">SteamVR चलाएं</string> - <string name="games_launch_settings">लॉन्च सेटिंग</string> - <string name="games_launch">लॉन्च करें</string> <string name="games_files">गेम फ़ाइलें</string> <string name="games_prefix">Proton प्रीफ़िक्स</string> <string name="games_saves_looking">सेव खोजे जा रहे हैं…</string> @@ -1035,7 +1048,6 @@ <string name="games_saves_folders">%1$d सेव फ़ोल्डर</string> <string name="games_saves_summary">%1$s · %2$d फ़ाइलें · %3$s</string> <string name="games_kb">%1$d KB</string> - <string name="games_manage_saves">सेव मैनेज करें</string> <string name="games_saves">गेम सेव</string> <string name="games_import_saves">सेव इंपोर्ट करें…</string> <string name="games_export_gamehub">GameHub zip के रूप में एक्सपोर्ट करें…</string> @@ -1047,7 +1059,6 @@ <string name="games_runtime_first_play">पहली बार Play पर रनटाइम इंस्टॉल होता है</string> <string name="games_runtime_update">रनटाइम अपडेट उपलब्ध</string> <string name="games_runtime_ready">● रनटाइम तैयार</string> - <string name="games_last_played">आख़िरी बार %1$s को खेला</string> <string name="games_internal">इंटरनल स्टोरेज</string> <string name="games_added">जोड़ा गया गेम</string> <string name="games_custom">कस्टम गेम</string> @@ -1116,7 +1127,7 @@ <string name="second_terminal">Linux टर्मिनल</string> <string name="second_terminal_not_ready">Linux सेशन अभी टर्मिनल के लिए तैयार नहीं है।</string> <string name="rail_steam">Steam</string> - <string name="rail_games">गेम</string> + <string name="rail_games">लाइब्रेरी</string> <string name="rail_desktop">डेस्कटॉप</string> <string name="rail_store">स्टोर</string> <string name="rail_components">कॉम्पोनेंट</string> @@ -1404,6 +1415,10 @@ <string name="theme_icon_blue_detail">काली ज़मीन, सफ़ेद टाइप, चुने, फ़ोकस या दबाए गए हर चीज़ के लिए ब्लू रंग।</string> <string name="theme_electric">इलेक्ट्रिक नेवी</string> <string name="theme_electric_detail">ब्लू रंग ज़्यादा चमकीला, उन सतहों पर जो ग्रे के बजाय नेवी की ओर झुकती हैं।</string> + <string name="theme_inner_gamer">Inner Gamer</string> + <string name="theme_inner_gamer_detail">इंद्रधनुष के रंगों में बदलते रंग। Android की एनिमेशन सेटिंग का पालन करती है।</string> + <string name="theme_rgb_speed">गति</string> + <string name="theme_rgb_speed_hint">रंग कितनी तेज़ी से बदलें।</string> <string name="theme_gradient">ग्रेडिएंट</string> <string name="theme_gradient_detail">आपके अपने प्राथमिक और द्वितीयक रंग, बटनों और हाइलाइट पर मिश्रित।</string> <string name="theme_gradient_primary">प्राथमिक</string> @@ -1525,6 +1540,13 @@ <string name="fm_conflict_skip">छोड़ें</string> <string name="fm_select_this_folder">यह फ़ोल्डर चुनें</string> <string name="fm_back">बैक</string> + <string name="fm_exit">बाहर निकलें</string> + <string name="fm_return_to_start">शुरुआती फ़ोल्डर पर लौटें</string> + <string name="fm_start_unavailable">शुरुआती फ़ोल्डर अब उपलब्ध नहीं है</string> + <string name="fm_cancel_operation_title">कार्रवाई रद्द करें?</string> + <string name="fm_cancel_operation_confirmation">कार्रवाई रद्द कर दी जाएगी। क्या आप सुनिश्चित हैं?</string> + <string name="fm_accept">स्वीकार करें</string> + <string name="fm_decline">अस्वीकार करें</string> <string name="fm_drive_not_readable">%1$s माउंट है लेकिन अभी पढ़ा नहीं जा सकता</string> <string name="fm_list_view">लिस्ट व्यू</string> <string name="fm_grid_view">ग्रिड व्यू</string> @@ -1939,7 +1961,6 @@ <string name="source_steam">Steam</string> <string name="source_added">कस्टम</string> <string name="setup_stores">स्टोर</string> - <string name="setup_stores_open_on">स्टोर खोलें</string> <string name="setup_stores_speed">डाउनलोड स्पीड टियर</string> <string name="setup_stores_engine">स्टोर इंजन</string> <string name="setup_stores_engine_chip_ready">● इंजन तैयार</string> @@ -1953,11 +1974,9 @@ <string name="stores_tabs_arrange">टैब व्यवस्थित करें</string> <string name="stores_tabs_arrange_done">हो गया</string> <string name="stores_chip_signed_out">(साइन इन नहीं है)</string> - <string name="stores_tab_store">स्टोर</string> <string name="stores_tab_library">लाइब्रेरी</string> <string name="stores_tab_all">सभी</string> <string name="stores_tab_downloads">डाउनलोड</string> - <string name="stores_tab_count">%1$s (%2$d)</string> <string name="stores_signin">साइन इन करें</string> <string name="stores_signout_named">%1$s · साइन आउट करें</string> <string name="stores_signout_title">%1$s से साइन आउट करें?</string> @@ -1975,9 +1994,10 @@ <string name="stores_shelf_deals">डील</string> <string name="stores_shelf_free">फ़्री गेम</string> <string name="stores_shelf_trending">ट्रेंडिंग</string> - <string name="stores_shelf_library">आपकी लाइब्रेरी</string> <string name="stores_play">Play</string> <string name="stores_install">इंस्टॉल करें</string> + <string name="bnet_open">Battle.net खोलें</string> + <string name="bnet_store">Blizzard के गेम</string> <string name="stores_resume_install">इंस्टॉल फिर से शुरू करें</string> <string name="stores_dl_interrupted">बाधित</string> <string name="stores_install_size">इंस्टॉल · %1$s</string> @@ -1996,8 +2016,32 @@ <string name="stores_itch_no_exe">डाउनलोड में कोई Windows गेम नहीं मिला</string> <string name="stores_itch_extract_failed">डाउनलोड एक्सट्रैक्ट नहीं हो सका (कोड %1$d)</string> <string name="stores_itch_no_catalog">itch.io ने जवाब नहीं दिया</string> + <string name="stores_itch_sort_popular">लोकप्रिय</string> + <string name="stores_itch_sort_new">सबसे हाल के</string> + <string name="stores_itch_sort_new_popular">नए और लोकप्रिय</string> + <string name="stores_itch_price_paid">सशुल्क</string> + <string name="stores_itch_price_under_5">$5 या कम</string> + <string name="stores_itch_price_under_15">$15 या कम</string> + <string name="stores_itch_sort_top_rated">सबसे ऊँची रेटिंग</string> + <string name="stores_itch_sort_top_sellers">सबसे ज़्यादा बिकने वाले</string> + <string name="stores_itch_price_any">कोई भी कीमत</string> + <string name="stores_itch_price_free">मुफ़्त</string> + <string name="stores_itch_price_sale">सेल में</string> + <string name="stores_itch_load_more">और लोड करें</string> + <string name="stores_itch_platform_any">कोई भी प्लेटफ़ॉर्म</string> + <string name="stores_itch_platform_mobile_web">मोबाइल वेब</string> + <string name="stores_itch_tag_add">टैग जोड़ें</string> + <string name="stores_itch_tag_title">टैग के अनुसार फ़िल्टर करें</string> + <string name="stores_itch_browse_failed">itch.io से गेम लोड नहीं हो सके</string> <string name="stores_itch_archive">%1$s आर्काइव समर्थित नहीं हैं</string> <string name="stores_itch_download_failed">itch.io ने HTTP %1$d लौटाया</string> + <string name="stores_itch_pick_title">संस्करण चुनें</string> + <string name="stores_itch_pick_none">इस गेम के लिए कोई डाउनलोड नहीं मिला</string> + <string name="stores_itch_pick_failed">itch.io से डाउनलोड लोड नहीं हो सके</string> + <string name="stores_itch_build_demo">डेमो</string> + <string name="stores_itch_build_open">itch.io पर खोलें</string> + <string name="stores_itch_build_linux_soon">अभी समर्थित नहीं है</string> + <string name="stores_itch_no_game">यह गेम itch.io पर नहीं मिला</string> <string name="stores_free">फ़्री</string> <string name="stores_resume">फिर से शुरू करें</string> <string name="stores_refresh">रीफ़्रेश करें</string> @@ -2150,6 +2194,7 @@ <string name="stores_login_close">बंद करें</string> <string name="stores_login_retry">फिर कोशिश करें</string> <string name="stores_login_finishing">साइन-इन पूरा हो रहा है…</string> + <string name="stores_xbox_sign_in_again">Microsoft ने Xbox साइन-इन अस्वीकार कर दिया। स्टोर्स में Xbox से साइन आउट करें, फिर से साइन इन करें, फिर जारी रखें।</string> <string name="stores_login_error_network">साइन-इन पेज लोड नहीं हो सका: %1$s</string> <string name="stores_login_error_idp_blocked">यह प्रोवाइडर एम्बेडेड पेज से साइन इन करने से मना करता है। इसके बदले अपने स्टोर पासवर्ड से साइन इन करें।</string> <string name="stores_login_error_http">साइन-इन पेज ने एक एरर के साथ जवाब दिया (HTTP %1$d)।</string> @@ -2982,4 +3027,57 @@ <string name="spatial_saved_views_hint">सहेजे गए दृश्यों की पुनर्गणना की जा सकती है। वर्तमान ट्रैकिंग समन्वय प्रणाली में कैप्चर करने के लिए एक नया अंशांकन प्रारंभ करें।</string> <string name="spatial_model_checksum_error">हैंड मॉडल चेकसम बेमेल</string> <string name="spatial_opencv_load_error">OpenCV लोड नहीं हो सका</string> + <string name="setup_group_memory">मेमोरी</string> + <string name="setup_clear_cached">सेशन से पहले कैश्ड ऐप बंद करें</string> + <string name="setup_clear_cached_hint">सेशन शुरू होने पर DroidDeck उन ऐप को बंद कर देता है जिन्हें Android बैकग्राउंड में रखता है, ताकि गेम को ज़्यादा मेमोरी मिले। वायरलेस डीबगिंग चालू होनी चाहिए।</string> + <string name="setup_clear_cached_unpaired">DroidDeck को वायरलेस डीबगिंग से पेयर करना ज़रूरी है (नीचे, पेयर करें)।</string> + <string name="setup_memory_hud">मेमोरी रीडआउट</string> + <string name="setup_memory_hud_hint">सेशन स्क्रीन पर इस्तेमाल हो रही मेमोरी और swap। एडवांस्ड इसे गेम, DroidDeck, Android और GPU में बांटकर दिखाता है।</string> + <string name="setup_memory_hud_simple">सिंपल</string> + <string name="setup_memory_hud_advanced">एडवांस्ड</string> + <string name="memory_hud_simple">RAM %1$s/%2$s GB · swap %3$s/%4$s GB</string> + <string name="memory_hud_split">गेम %1$s · DroidDeck %2$s · Android %3$s GB</string> + <string name="memory_hud_gpu">GPU %1$s GB</string> + <string name="setup_bg_limit">सेशन के दौरान बैकग्राउंड ऐप</string> + <string name="setup_bg_limit_hint">सेशन चलते समय Android ज़्यादा से ज़्यादा इतने ऐप कैश में रखता है और बाकी बंद कर देता है, ताकि गेम को उनकी मेमोरी मिले। सेशन खत्म होने पर DroidDeck सीमा को पहले जैसा कर देता है। वायरलेस डीबगिंग चालू होनी चाहिए।</string> + <string name="setup_bg_limit_4">ज़्यादा से ज़्यादा 4</string> + <string name="setup_bg_limit_1">ज़्यादा से ज़्यादा 1</string> + <string name="setup_bg_limit_0">कोई नहीं</string> + <string name="setup_adb_pair">वायरलेस डीबगिंग</string> + <string name="setup_adb_pair_hint">ऊपर दिए गए विकल्पों के लिए DroidDeck को Android की वायरलेस डीबगिंग से पेयर करता है। चाइल्ड-प्रोसेस सीमा बंद ही रहती है।</string> + <string name="setup_adb_pair_button">पेयर करें</string> + <string name="epic_client_missing">इंस्टॉल नहीं है</string> + <string name="epic_client_open">खोलें</string> + <string name="epic_client_install">इंस्टॉल करें</string> + <string name="game_tab_about">परिचय</string> + <string name="game_tab_install">इंस्टॉल करें</string> + <string name="game_install_location">जगह</string> + <string name="game_install_folder">फ़ोल्डर</string> + <string name="game_install_size">डिस्क पर आकार</string> + <string name="game_install_date">इंस्टॉल है</string> + <string name="stores_xbox_update">अपडेट / सत्यापित करें</string> + <string name="stores_xbox_manage">कंटेंट प्रबंधित करें</string> + <string name="stores_xbox_close_first">अपडेट करने से पहले यह Xbox गेम बंद करें।</string> + <string name="stores_xbox_content_title">Xbox कंटेंट</string> + <string name="stores_xbox_content_none">इस गेम के लिए कोई लाइसेंस वाला PC कंटेंट पैकेज उपलब्ध नहीं है।</string> + <string name="stores_xbox_content_update">%1$s · अपडेट / सत्यापित करें</string> + <string name="stores_xbox_content_install">%1$s · इंस्टॉल करें</string> + <string name="stores_xbox_choose_content">PC कंटेंट चुनें</string> + <string name="stores_eyebrow_pass">%1$s के साथ उपलब्ध</string> + <string name="stores_owned_in_library">लाइब्रेरी में आपके गेम</string> + <string name="library_filter_added">जोड़े गए</string> + <string name="game_tab_launch">लॉन्च</string> + <string name="game_tab_saves_cloud">सेव और क्लाउड</string> + <string name="game_launch_shortcut">Steam शॉर्टकट</string> + <string name="game_install_version">संस्करण</string> + <string name="game_install_played">आखिरी बार खेला</string> + <string name="game_install_access">एक्सेस</string> + <string name="game_install_access_owned">आपका</string> + <string name="game_stream">स्ट्रीम करें</string> + <string name="library_add">लाइब्रेरी में जोड़ें</string> + <string name="library_remove">लाइब्रेरी से हटाएँ</string> + <string name="library_in">लाइब्रेरी में</string> + <string name="xbox_filter_show">दिखाएँ</string> + <string name="xbox_filter_cloud">क्लाउड</string> + <string name="stores_xbox_cloud_settings">क्लाउड सेटिंग्स</string> </resources> diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index c146857a5..a7e0ca975 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -267,6 +267,24 @@ <string name="osc_steam_touch">Steam érintés (béta)</string> <string name="osc_style_next_session">A Steam érintésvezérlői a következő Steam-munkamenettől lépnek életbe</string> <string name="osc_steam_layout">Steam érintéskiosztás</string> + <string name="screen_keys">Képernyőgombok</string> + <string name="screen_keys_add">Gomb hozzáadása</string> + <string name="screen_key_text">Szöveg</string> + <string name="screen_key_size">Méret</string> + <string name="screen_key_opacity">Átlátszatlanság</string> + <string name="screen_key_type_button">Gomb</string> + <string name="screen_key_shape_trigger">Trigger</string> + <string name="screen_key_toggle">Váltás</string> + <string name="screen_key_layout_export">Exportálás</string> + <string name="screen_key_shape_pill">Kapszula</string> + <string name="screen_key_shape_round">Lekerekített</string> + <string name="screen_key_shape_circle">Kör</string> + <string name="screen_key_shape_rect">Téglalap</string> + <string name="screen_key_bind">Billentyű</string> + <string name="screen_key_image">Kép</string> + <string name="screen_key_image_clear">Törlés</string> + <string name="screen_key_layout">Elrendezés</string> + <string name="screen_key_layout_save">Mentés</string> <string name="osc_steam_layout_hint">Mozgassa, méretezze vagy rejtse el a játék érintésvezérlőit; a Steamben mentve</string> <string name="mode_controller">Kontroller</string> <string name="mode_controller_hint">Hogyan látja a Steam a kontrollerét. A következő munkamenettől érvényes.</string> @@ -287,9 +305,6 @@ <string name="mode_suspend_steam_note">Az Automatikus szüneteltet, és megjelenéskor folytatja. A Natív előbb felkészíti a Steamet az altatásra. A Manuális megvárja a Folytatást. A Soha mindig futva tartja.</string> <string name="mode_background_downloads">Letöltések futtatásának fenntartása</string> <string name="mode_background_downloads_hint">Elhalasztja a háttérbeli felfüggesztést, amíg a Steam aktívan letölt.</string> - <string name="mode_zram">zRAM-tömörítés</string> - <string name="mode_zram_hint">Játék közben a Steam kliens memóriája a készülék zramjába tömörül, így a játéknak több RAM jut. A Steam következő indításakor lép életbe.</string> - <string name="mode_zram_unsupported">Nem elérhető: a készüléken nincs zram vagy swap, vagy a kernel régebbi az 5.4-nél.</string> <string name="mode_suspend_note">Az Automatikus szüneteltet a háttérben, és megjelenéskor folytatja. A Manuális megvárja a Folytatást. A Soha mindig futva tartja.</string> <string name="mode_startup">Indítás</string> <string name="mode_steam_startup">Steam indítása a DroidDeckkel</string> @@ -544,8 +559,8 @@ <string name="perf_sysmem">Turnip: sysmem renderelés</string> <string name="perf_sysmem_hint">Gyorsabb a Steam kliensnek; játékokban a meghajtó menetenként választ a csempés és a rendszermemóriás renderelés között. Adreno 710/720/722-n szükséges.</string> <string name="perf_xalia">A Steam xalia segítőjének kihagyása</string> - <string name="perf_fastsync">droiddeck-ntsync (kísérleti)</string> - <string name="perf_fastsync_hint">A Linux ntsync illesztőprogram felhasználói szintű helyettesítője, teszteléshez. Amíg be van kapcsolva, a Proton ezt használja a droiddeck-esync és a droiddeck-fsync helyett. A Proton Experimental ARM64-gyel, a GE-Protonnal és a proton-cachyosszal működik. A következő munkamenettől érvényes.</string> + <string name="perf_fastsync">droiddeck-ntsync</string> + <string name="perf_fastsync_hint">A Linux ntsync illesztőprogram felhasználói szintű helyettesítője. Amíg be van kapcsolva, a Proton ezt használja a droiddeck-esync és a droiddeck-fsync helyett. A Proton Experimental ARM64-gyel, a GE-Protonnal és a proton-cachyosszal működik. A következő munkamenettől érvényes.</string> <string name="perf_esync">droiddeck-esync</string> <string name="perf_fsync">droiddeck-fsync előre</string> <string name="perf_fsync_hint">A Proton saját fsyncjét futtatja az eredeti Proton-fájlokon, felhasználói szinten biztosított futex_waitv-vel, így nincs szükség csomagra, és azonnal működik, amint a Steam frissíti a Protont. Amíg be van kapcsolva, a játékok ezt használják a droiddeck-esync előtt, kivéve azt a néhányat, amelyet a Proton kizár az fsyncből. A következő munkamenettől érvényes.</string> @@ -1026,8 +1041,6 @@ <string name="games_empty">A Steamben telepített játékok a munkamenet után itt jelennek meg.</string> <string name="games_play_steam">Steam elindítása</string> <string name="games_play_steamvr">SteamVR elindítása</string> - <string name="games_launch_settings">Indítási beállítások</string> - <string name="games_launch">Indítás</string> <string name="games_files">Játékfájlok</string> <string name="games_prefix">Proton-prefix</string> <string name="games_saves_looking">Mentések keresése…</string> @@ -1035,7 +1048,6 @@ <string name="games_saves_folders">%1$d mentésmappa</string> <string name="games_saves_summary">%1$s · %2$d fájl · %3$s</string> <string name="games_kb">%1$d KB</string> - <string name="games_manage_saves">Mentések kezelése</string> <string name="games_saves">Játékmentések</string> <string name="games_import_saves">Mentések importálása…</string> <string name="games_export_gamehub">Exportálás GameHub zipként…</string> @@ -1047,7 +1059,6 @@ <string name="games_runtime_first_play">A futtatókörnyezet az első Játék gombnyomáskor települ</string> <string name="games_runtime_update">Elérhető futtatókörnyezet-frissítés</string> <string name="games_runtime_ready">● A futtatókörnyezet kész</string> - <string name="games_last_played">Utoljára játszva: %1$s</string> <string name="games_internal">Belső tárhely</string> <string name="games_added">Hozzáadott játék</string> <string name="games_custom">Egyéni játék</string> @@ -1116,7 +1127,7 @@ <string name="second_terminal">Linux terminál</string> <string name="second_terminal_not_ready">A Linux-munkamenet még nem áll készen a terminálra.</string> <string name="rail_steam">Steam</string> - <string name="rail_games">Játékok</string> + <string name="rail_games">Könyvtár</string> <string name="rail_desktop">Asztal</string> <string name="rail_store">Áruház</string> <string name="rail_components">Összetevők</string> @@ -1404,6 +1415,10 @@ <string name="theme_icon_blue_detail">Fekete alap, fehér betűk, a kék minden kijelölt, fókuszált vagy lenyomott elemhez.</string> <string name="theme_electric">Elektromos tengerészkék</string> <string name="theme_electric_detail">Fényesebb kék, szürke helyett tengerészkék felületeken.</string> + <string name="theme_inner_gamer">Inner Gamer</string> + <string name="theme_inner_gamer_detail">A szivárvány színein végigfutó színek. Követi az Android animációs beállításait.</string> + <string name="theme_rgb_speed">Sebesség</string> + <string name="theme_rgb_speed_hint">Milyen gyorsan váltanak a színek.</string> <string name="theme_gradient">Színátmenet</string> <string name="theme_gradient_detail">Saját elsődleges és másodlagos színed, átmenettel a gombokon és a kiemeléseken.</string> <string name="theme_gradient_primary">Elsődleges</string> @@ -1525,6 +1540,13 @@ <string name="fm_conflict_skip">Kihagyás</string> <string name="fm_select_this_folder">Ennek a mappának a kiválasztása</string> <string name="fm_back">Vissza</string> + <string name="fm_exit">Kilépés</string> + <string name="fm_return_to_start">Vissza a kezdőmappába</string> + <string name="fm_start_unavailable">A kezdőmappa már nem érhető el</string> + <string name="fm_cancel_operation_title">Művelet megszakítása?</string> + <string name="fm_cancel_operation_confirmation">A művelet megszakad. Biztos benne?</string> + <string name="fm_accept">Elfogadom</string> + <string name="fm_decline">Elutasítom</string> <string name="fm_drive_not_readable">A %1$s csatolva van, de jelenleg nem olvasható</string> <string name="fm_list_view">Listanézet</string> <string name="fm_grid_view">Rácsnézet</string> @@ -1939,7 +1961,6 @@ <string name="source_steam">Steam</string> <string name="source_added">Egyéni</string> <string name="setup_stores">Áruházak</string> - <string name="setup_stores_open_on">Áruház megnyitása itt</string> <string name="setup_stores_speed">Letöltési sebességi szint</string> <string name="setup_stores_engine">Áruházmotor</string> <string name="setup_stores_engine_chip_ready">● A motor kész</string> @@ -1953,11 +1974,9 @@ <string name="stores_tabs_arrange">Fülek rendezése</string> <string name="stores_tabs_arrange_done">Kész</string> <string name="stores_chip_signed_out">(nincs bejelentkezve)</string> - <string name="stores_tab_store">Áruház</string> <string name="stores_tab_library">Könyvtár</string> <string name="stores_tab_all">Összes</string> <string name="stores_tab_downloads">Letöltések</string> - <string name="stores_tab_count">%1$s (%2$d)</string> <string name="stores_signin">Bejelentkezés</string> <string name="stores_signout_named">%1$s · kijelentkezés</string> <string name="stores_signout_title">Kijelentkezik a %1$s-ből?</string> @@ -1975,9 +1994,10 @@ <string name="stores_shelf_deals">Akciók</string> <string name="stores_shelf_free">Ingyenes játékok</string> <string name="stores_shelf_trending">Felkapott</string> - <string name="stores_shelf_library">A könyvtára</string> <string name="stores_play">Játék</string> <string name="stores_install">Telepítés</string> + <string name="bnet_open">Battle.net megnyitása</string> + <string name="bnet_store">Blizzard-játékok</string> <string name="stores_resume_install">Telepítés folytatása</string> <string name="stores_dl_interrupted">Megszakítva</string> <string name="stores_install_size">Telepítés · %1$s</string> @@ -1996,8 +2016,32 @@ <string name="stores_itch_no_exe">A letöltésben nem található Windows-játék</string> <string name="stores_itch_extract_failed">A letöltés nem bontható ki (kód: %1$d)</string> <string name="stores_itch_no_catalog">Az itch.io nem válaszolt</string> + <string name="stores_itch_sort_popular">Népszerű</string> + <string name="stores_itch_sort_new">Legfrissebb</string> + <string name="stores_itch_sort_new_popular">Új és népszerű</string> + <string name="stores_itch_price_paid">Fizetős</string> + <string name="stores_itch_price_under_5">Legfeljebb 5 $</string> + <string name="stores_itch_price_under_15">Legfeljebb 15 $</string> + <string name="stores_itch_sort_top_rated">Legjobbra értékelt</string> + <string name="stores_itch_sort_top_sellers">Legkelendőbb</string> + <string name="stores_itch_price_any">Bármilyen ár</string> + <string name="stores_itch_price_free">Ingyenes</string> + <string name="stores_itch_price_sale">Akciós</string> + <string name="stores_itch_load_more">Továbbiak betöltése</string> + <string name="stores_itch_platform_any">Bármely platform</string> + <string name="stores_itch_platform_mobile_web">Mobilweb</string> + <string name="stores_itch_tag_add">Címke hozzáadása</string> + <string name="stores_itch_tag_title">Szűrés címke szerint</string> + <string name="stores_itch_browse_failed">Nem sikerült betölteni a játékokat az itch.io-ról</string> <string name="stores_itch_archive">A(z) %1$s archívumok nem támogatottak</string> <string name="stores_itch_download_failed">Az itch.io HTTP %1$d választ adott</string> + <string name="stores_itch_pick_title">Válassz verziót</string> + <string name="stores_itch_pick_none">Ehhez a játékhoz nem található letöltés</string> + <string name="stores_itch_pick_failed">A letöltések betöltése az itch.io-ról nem sikerült</string> + <string name="stores_itch_build_demo">Demó</string> + <string name="stores_itch_build_open">Megnyitás az itch.io-n</string> + <string name="stores_itch_build_linux_soon">Még nem támogatott</string> + <string name="stores_itch_no_game">A játék nem található az itch.io-n</string> <string name="stores_free">Ingyenes</string> <string name="stores_resume">Folytatás</string> <string name="stores_refresh">Frissítés</string> @@ -2150,6 +2194,7 @@ <string name="stores_login_close">Bezárás</string> <string name="stores_login_retry">Próbálja újra</string> <string name="stores_login_finishing">A bejelentkezés befejezése…</string> + <string name="stores_xbox_sign_in_again">A Microsoft elutasította az Xbox-bejelentkezést. Az Áruházakban jelentkezz ki az Xboxból, jelentkezz be újra, majd folytasd.</string> <string name="stores_login_error_network">Nem sikerült betölteni a bejelentkezési oldalt: %1$s</string> <string name="stores_login_error_idp_blocked">Ez a szolgáltató nem engedi a bejelentkezést egy beágyazott oldalról. Jelentkezzen be helyette az áruház jelszavával.</string> <string name="stores_login_error_http">A bejelentkezési oldal hibával válaszolt (HTTP %1$d).</string> @@ -2980,6 +3025,59 @@ <string name="spatial_saved_views_hint">Az elmentett nézetek újraszámíthatók. Indítson el egy új kalibrálást, hogy rögzítse az aktuális követési koordináta-rendszerben.</string> <string name="spatial_model_checksum_error">A kézi modell ellenőrzőösszegének eltérése</string> <string name="spatial_opencv_load_error">Az OpenCV betöltése nem sikerült</string> + <string name="setup_group_memory">Memória</string> + <string name="setup_clear_cached">Gyorsítótárazott alkalmazások bezárása munkamenet előtt</string> + <string name="setup_clear_cached_hint">Munkamenet indításakor a DroidDeck bezárja azokat az alkalmazásokat, amelyeket az Android a háttérben tart, így a játéknak több memória jut. A vezeték nélküli hibakeresésnek bekapcsolva kell lennie.</string> + <string name="setup_clear_cached_unpaired">A DroidDecket párosítani kell a vezeték nélküli hibakereséssel (Párosítás, lent).</string> + <string name="setup_memory_hud">Memóriakijelzés</string> + <string name="setup_memory_hud_hint">A használt memória és swap a munkamenet képernyőjén. A Speciális nézet szétbontja a játék, a DroidDeck, az Android és a GPU között.</string> + <string name="setup_memory_hud_simple">Egyszerű</string> + <string name="setup_memory_hud_advanced">Speciális</string> + <string name="memory_hud_simple">RAM %1$s/%2$s GB · swap %3$s/%4$s GB</string> + <string name="memory_hud_split">játék %1$s · DroidDeck %2$s · Android %3$s GB</string> + <string name="memory_hud_gpu">GPU %1$s GB</string> + <string name="setup_bg_limit">Háttéralkalmazások munkamenet közben</string> + <string name="setup_bg_limit_hint">Munkamenet közben az Android legfeljebb ennyi alkalmazást tart a gyorsítótárban, a többit bezárja, így azok memóriája a játéké lesz. A munkamenet végén a DroidDeck visszaállítja a korábbi korlátot. A vezeték nélküli hibakeresésnek bekapcsolva kell lennie.</string> + <string name="setup_bg_limit_4">Legfeljebb 4</string> + <string name="setup_bg_limit_1">Legfeljebb 1</string> + <string name="setup_bg_limit_0">Nincs</string> + <string name="setup_adb_pair">Vezeték nélküli hibakeresés</string> + <string name="setup_adb_pair_hint">Párosítja a DroidDecket az Android vezeték nélküli hibakeresésével a fenti beállításokhoz. A gyermekfolyamat-korlát kikapcsolva marad.</string> + <string name="setup_adb_pair_button">Párosítás</string> <string name="glasses_3dof">3DoF virtuális képernyő</string> <string name="glasses_6dof">6DoF virtuális képernyő</string> + <string name="epic_client_missing">Nincs telepítve</string> + <string name="epic_client_open">Megnyitás</string> + <string name="epic_client_install">Telepítés</string> + <string name="game_tab_about">Névjegy</string> + <string name="game_tab_install">Telepítés</string> + <string name="game_install_location">Hely</string> + <string name="game_install_folder">Mappa</string> + <string name="game_install_size">Méret a tárhelyen</string> + <string name="game_install_date">Telepítve</string> + <string name="stores_xbox_update">Frissítés / ellenőrzés</string> + <string name="stores_xbox_manage">Tartalom kezelése</string> + <string name="stores_xbox_close_first">Frissítés előtt zárd be ezt az Xbox-játékot.</string> + <string name="stores_xbox_content_title">Xbox-tartalom</string> + <string name="stores_xbox_content_none">Ehhez a játékhoz nincs licencelt PC-s tartalomcsomag.</string> + <string name="stores_xbox_content_update">%1$s · Frissítés / ellenőrzés</string> + <string name="stores_xbox_content_install">%1$s · Telepítés</string> + <string name="stores_xbox_choose_content">PC-s tartalom választása</string> + <string name="stores_eyebrow_pass">elérhető ezzel: %1$s</string> + <string name="stores_owned_in_library">Saját játékok a könyvtárban</string> + <string name="library_filter_added">Hozzáadott</string> + <string name="game_tab_launch">Indítás</string> + <string name="game_tab_saves_cloud">Mentések és felhő</string> + <string name="game_launch_shortcut">Steam-parancsikon</string> + <string name="game_install_version">Verzió</string> + <string name="game_install_played">Utoljára játszva</string> + <string name="game_install_access">Hozzáférés</string> + <string name="game_install_access_owned">Saját</string> + <string name="game_stream">Streamelés</string> + <string name="library_add">Hozzáadás a könyvtárhoz</string> + <string name="library_remove">Eltávolítás a könyvtárból</string> + <string name="library_in">A könyvtárban</string> + <string name="xbox_filter_show">Megjelenítés</string> + <string name="xbox_filter_cloud">Felhő</string> + <string name="stores_xbox_cloud_settings">Felhőbeállítások</string> </resources> diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 8208d2fa3..d5fc612c3 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -267,6 +267,24 @@ <string name="osc_steam_touch">Sentuhan Steam (beta)</string> <string name="osc_style_next_session">Kontrol sentuh Steam dimulai pada sesi Steam berikutnya</string> <string name="osc_steam_layout">Tata letak sentuh Steam</string> + <string name="screen_keys">Tombol layar</string> + <string name="screen_keys_add">Tambah tombol</string> + <string name="screen_key_text">Teks</string> + <string name="screen_key_size">Ukuran</string> + <string name="screen_key_opacity">Opasitas</string> + <string name="screen_key_type_button">Tombol</string> + <string name="screen_key_shape_trigger">Pemicu</string> + <string name="screen_key_toggle">Alih</string> + <string name="screen_key_layout_export">Ekspor</string> + <string name="screen_key_shape_pill">Kapsul</string> + <string name="screen_key_shape_round">Membulat</string> + <string name="screen_key_shape_circle">Lingkaran</string> + <string name="screen_key_shape_rect">Persegi panjang</string> + <string name="screen_key_bind">Tombol</string> + <string name="screen_key_image">Gambar</string> + <string name="screen_key_image_clear">Hapus</string> + <string name="screen_key_layout">Tata letak</string> + <string name="screen_key_layout_save">Simpan</string> <string name="osc_steam_layout_hint">Pindahkan, ubah ukuran, atau sembunyikan kontrol sentuh gim ini; disimpan ke Steam</string> <string name="mode_controller">Pengontrol</string> <string name="mode_controller_hint">Cara Steam mendeteksi pengontrol Anda. Berlaku pada sesi berikutnya.</string> @@ -287,9 +305,6 @@ <string name="mode_suspend_steam_note">Otomatis menjeda dan melanjutkan saat ditampilkan. Native menyiapkan Steam untuk tidur terlebih dahulu. Manual menunggu Lanjutkan. Tidak pernah terus berjalan.</string> <string name="mode_background_downloads">Pertahankan unduhan tetap berjalan</string> <string name="mode_background_downloads_hint">Menunda penjedaan latar belakang saat Steam sedang aktif mengunduh.</string> - <string name="mode_zram">Kompresi zRAM</string> - <string name="mode_zram_hint">Saat bermain, memori klien Steam dipadatkan ke zram perangkat sehingga gim mendapat lebih banyak RAM. Berlaku saat Steam dijalankan berikutnya.</string> - <string name="mode_zram_unsupported">Tidak tersedia: perangkat ini tidak punya zram atau swap, atau kernelnya lebih lama dari 5.4.</string> <string name="mode_suspend_note">Otomatis menjeda di latar belakang dan melanjutkan saat ditampilkan. Manual menunggu Lanjutkan. Tidak pernah terus berjalan.</string> <string name="mode_startup">Mulai</string> <string name="mode_steam_startup">Mulai Steam bersama DroidDeck</string> @@ -542,8 +557,8 @@ <string name="perf_sysmem">Turnip: rendering sysmem</string> <string name="perf_sysmem_hint">Lebih cepat untuk klien Steam; di gim, driver memilih rendering ubin atau memori sistem untuk setiap pass. Diperlukan pada Adreno 710/720/722.</string> <string name="perf_xalia">Lewati bantuan xalia milik Steam</string> - <string name="perf_fastsync">droiddeck-ntsync (eksperimental)</string> - <string name="perf_fastsync_hint">Pengganti userspace untuk driver ntsync Linux, untuk pengujian. Saat aktif, Proton menggunakannya sebagai pengganti droiddeck-esync dan droiddeck-fsync. Berfungsi dengan Proton Experimental ARM64, GE-Proton, dan proton-cachyos. Berlaku pada sesi berikutnya.</string> + <string name="perf_fastsync">droiddeck-ntsync</string> + <string name="perf_fastsync_hint">Pengganti userspace untuk driver ntsync Linux. Saat aktif, Proton menggunakannya sebagai pengganti droiddeck-esync dan droiddeck-fsync. Berfungsi dengan Proton Experimental ARM64, GE-Proton, dan proton-cachyos. Berlaku pada sesi berikutnya.</string> <string name="perf_esync">droiddeck-esync</string> <string name="perf_fsync">droiddeck-fsync lebih dulu</string> <string name="perf_fsync_hint">Menjalankan fsync milik Proton sendiri pada file Proton standar, dengan futex_waitv yang disediakan di userspace, sehingga tidak memerlukan paket dan tetap berfungsi begitu Steam memperbarui Proton. Saat aktif, gim menggunakannya sebelum droiddeck-esync, kecuali beberapa gim yang oleh Proton dijaga tetap di luar fsync. Berlaku pada sesi berikutnya.</string> @@ -1020,8 +1035,6 @@ <string name="games_empty">Gim yang Anda instal di Steam akan muncul di sini setelah sesi.</string> <string name="games_play_steam">Mainkan Steam</string> <string name="games_play_steamvr">Mainkan SteamVR</string> - <string name="games_launch_settings">Setelan peluncuran</string> - <string name="games_launch">Luncurkan</string> <string name="games_files">File gim</string> <string name="games_prefix">Prefix Proton</string> <string name="games_saves_looking">Mencari data simpanan…</string> @@ -1029,7 +1042,6 @@ <string name="games_saves_folders">%1$d folder simpanan</string> <string name="games_saves_summary">%1$s · %2$d file · %3$s</string> <string name="games_kb">%1$d KB</string> - <string name="games_manage_saves">Kelola data simpanan</string> <string name="games_saves">Data simpanan gim</string> <string name="games_import_saves">Impor data simpanan…</string> <string name="games_export_gamehub">Ekspor sebagai zip GameHub…</string> @@ -1041,7 +1053,6 @@ <string name="games_runtime_first_play">Runtime terinstal saat Main pertama kali</string> <string name="games_runtime_update">Pembaruan runtime tersedia</string> <string name="games_runtime_ready">● Runtime siap</string> - <string name="games_last_played">Terakhir dimainkan %1$s</string> <string name="games_internal">Penyimpanan internal</string> <string name="games_added">Gim ditambahkan</string> <string name="games_custom">Gim khusus</string> @@ -1110,7 +1121,7 @@ <string name="second_terminal">Terminal Linux</string> <string name="second_terminal_not_ready">Sesi Linux belum siap untuk terminal.</string> <string name="rail_steam">Steam</string> - <string name="rail_games">Game</string> + <string name="rail_games">Pustaka</string> <string name="rail_desktop">Desktop</string> <string name="rail_store">Toko</string> <string name="rail_components">Komponen</string> @@ -1390,6 +1401,10 @@ <string name="theme_icon_blue_detail">Dasar hitam, tipe putih, biru untuk semua yang dipilih, difokuskan, atau ditekan.</string> <string name="theme_electric">Navy elektrik</string> <string name="theme_electric_detail">Biru yang lebih cerah, pada permukaan yang condong ke navy, bukan abu-abu.</string> + <string name="theme_inner_gamer">Inner Gamer</string> + <string name="theme_inner_gamer_detail">Warna yang berganti menyusuri pelangi. Mengikuti pengaturan animasi Android.</string> + <string name="theme_rgb_speed">Kecepatan</string> + <string name="theme_rgb_speed_hint">Seberapa cepat warna berganti.</string> <string name="theme_gradient">Gradasi</string> <string name="theme_gradient_detail">Warna primer dan sekunder pilihan Anda, dipadukan pada tombol dan sorotan.</string> <string name="theme_gradient_primary">Primer</string> @@ -1506,6 +1521,13 @@ <string name="fm_conflict_skip">Lewati</string> <string name="fm_select_this_folder">Pilih folder ini</string> <string name="fm_back">Kembali</string> + <string name="fm_exit">Keluar</string> + <string name="fm_return_to_start">Kembali ke folder awal</string> + <string name="fm_start_unavailable">Folder awal tidak lagi tersedia</string> + <string name="fm_cancel_operation_title">Batalkan operasi?</string> + <string name="fm_cancel_operation_confirmation">Operasi akan dibatalkan. Anda yakin?</string> + <string name="fm_accept">Terima</string> + <string name="fm_decline">Tolak</string> <string name="fm_drive_not_readable">%1$s terpasang tetapi tidak dapat dibaca saat ini</string> <string name="fm_list_view">Tampilan daftar</string> <string name="fm_grid_view">Tampilan kisi</string> @@ -1918,7 +1940,6 @@ <string name="source_steam">Steam</string> <string name="source_added">Khusus</string> <string name="setup_stores">Toko</string> - <string name="setup_stores_open_on">Buka toko di</string> <string name="setup_stores_speed">Tingkat kecepatan unduhan</string> <string name="setup_stores_engine">Mesin toko</string> <string name="setup_stores_engine_chip_ready">● Mesin siap</string> @@ -1932,11 +1953,9 @@ <string name="stores_tabs_arrange">Atur tab</string> <string name="stores_tabs_arrange_done">Selesai</string> <string name="stores_chip_signed_out">(belum masuk)</string> - <string name="stores_tab_store">Toko</string> <string name="stores_tab_library">Pustaka</string> <string name="stores_tab_all">Semua</string> <string name="stores_tab_downloads">Unduhan</string> - <string name="stores_tab_count">%1$s (%2$d)</string> <string name="stores_signin">Masuk</string> <string name="stores_signout_named">%1$s · keluar</string> <string name="stores_signout_title">Keluar dari %1$s?</string> @@ -1954,9 +1973,10 @@ <string name="stores_shelf_deals">Penawaran</string> <string name="stores_shelf_free">Gim gratis</string> <string name="stores_shelf_trending">Sedang tren</string> - <string name="stores_shelf_library">Pustaka Anda</string> <string name="stores_play">Mainkan</string> <string name="stores_install">Instal</string> + <string name="bnet_open">Buka Battle.net</string> + <string name="bnet_store">Game Blizzard</string> <string name="stores_resume_install">Lanjutkan instalasi</string> <string name="stores_dl_interrupted">Terhenti</string> <string name="stores_install_size">Instal · %1$s</string> @@ -1975,8 +1995,32 @@ <string name="stores_itch_no_exe">Tidak ada game Windows di unduhan</string> <string name="stores_itch_extract_failed">Tidak dapat mengekstrak unduhan (kode %1$d)</string> <string name="stores_itch_no_catalog">itch.io tidak menjawab</string> + <string name="stores_itch_sort_popular">Populer</string> + <string name="stores_itch_sort_new">Terbaru</string> + <string name="stores_itch_sort_new_popular">Baru dan populer</string> + <string name="stores_itch_price_paid">Berbayar</string> + <string name="stores_itch_price_under_5">$5 atau kurang</string> + <string name="stores_itch_price_under_15">$15 atau kurang</string> + <string name="stores_itch_sort_top_rated">Rating tertinggi</string> + <string name="stores_itch_sort_top_sellers">Terlaris</string> + <string name="stores_itch_price_any">Harga apa saja</string> + <string name="stores_itch_price_free">Gratis</string> + <string name="stores_itch_price_sale">Diskon</string> + <string name="stores_itch_load_more">Muat lebih banyak</string> + <string name="stores_itch_platform_any">Platform apa saja</string> + <string name="stores_itch_platform_mobile_web">Web seluler</string> + <string name="stores_itch_tag_add">Tambah tag</string> + <string name="stores_itch_tag_title">Filter berdasarkan tag</string> + <string name="stores_itch_browse_failed">Tidak dapat memuat game dari itch.io</string> <string name="stores_itch_archive">Arsip %1$s tidak didukung</string> <string name="stores_itch_download_failed">itch.io menjawab HTTP %1$d</string> + <string name="stores_itch_pick_title">Pilih versi</string> + <string name="stores_itch_pick_none">Tidak ada unduhan untuk game ini</string> + <string name="stores_itch_pick_failed">Tidak dapat memuat unduhan dari itch.io</string> + <string name="stores_itch_build_demo">Demo</string> + <string name="stores_itch_build_open">Buka di itch.io</string> + <string name="stores_itch_build_linux_soon">Belum didukung</string> + <string name="stores_itch_no_game">Game ini tidak ditemukan di itch.io</string> <string name="stores_free">Gratis</string> <string name="stores_resume">Lanjutkan</string> <string name="stores_refresh">Segarkan</string> @@ -2129,6 +2173,7 @@ <string name="stores_login_close">Tutup</string> <string name="stores_login_retry">Coba lagi</string> <string name="stores_login_finishing">Menyelesaikan proses masuk…</string> + <string name="stores_xbox_sign_in_again">Microsoft menolak masuk Xbox. Keluar dari Xbox di Toko, masuk lagi, lalu lanjutkan.</string> <string name="stores_login_error_network">Halaman masuk tidak dapat dimuat: %1$s</string> <string name="stores_login_error_idp_blocked">Penyedia ini menolak proses masuk dari halaman tersemat. Masuk dengan kata sandi toko Anda sebagai gantinya.</string> <string name="stores_login_error_http">Halaman masuk merespons dengan kesalahan (HTTP %1$d).</string> @@ -2535,6 +2580,21 @@ <string name="storage_safe_hint">Runtime yang tidak dipakai, sisa, log lama</string> <string name="storage_clean_up">Membersihkan…</string> <string name="storage_session_running">Hentikan sesi untuk membersihkan atau menghapus apa pun. Ukuran tetap terlihat.</string> + <string name="storage_default_group">Lokasi instalasi</string> + <string name="storage_default_title">Penyimpanan default</string> + <string name="storage_default_hint">Instalasi toko baru akan disimpan di lokasi yang dipilih. Game yang sudah ada tetap di lokasinya. Library Steam memakai pengaturannya sendiri.</string> + <string name="storage_default_ask">Tanyakan setiap kali</string> + <string name="storage_default_ask_detail">Pilih target untuk setiap instalasi</string> + <string name="storage_default_internal">Penyimpanan internal</string> + <string name="storage_default_external">Penyimpanan eksternal</string> + <string name="storage_default_external_detail">%1$s · %2$s tersedia</string> + <string name="storage_default_external_missing">Tidak terhubung</string> + <string name="storage_default_folder">Folder · %1$s</string> + <string name="storage_default_folder_free">%1$s tersedia</string> + <string name="storage_default_folder_missing">Tidak terhubung</string> + <string name="storage_default_choose_folder">Pilih folder…</string> + <string name="storage_default_choose_folder_detail">Gunakan folder apa pun di kartu SD atau drive USB</string> + <string name="storage_default_note">Game akan disimpan di folder Games di dalam lokasi yang dipilih. Game yang sudah terpasang tidak dipindahkan.</string> <string name="storage_where">Ke mana perginya</string> <string name="storage_games">Gim Steam</string> <string name="storage_game_data">Data gim</string> @@ -2957,4 +3017,57 @@ <string name="spatial_saved_views_hint">Tampilan yang disimpan dapat dihitung ulang. Mulai kalibrasi baru untuk menangkap sistem koordinat pelacakan saat ini.</string> <string name="spatial_model_checksum_error">Ketidakcocokan checksum model tangan</string> <string name="spatial_opencv_load_error">OpenCV tidak dapat memuat</string> + <string name="setup_group_memory">Memori</string> + <string name="setup_clear_cached">Tutup aplikasi dalam cache sebelum sesi</string> + <string name="setup_clear_cached_hint">Saat sesi dimulai, DroidDeck menutup aplikasi yang disimpan Android di latar belakang agar gim mendapat lebih banyak memori. Debugging nirkabel harus aktif.</string> + <string name="setup_clear_cached_unpaired">DroidDeck harus disandingkan dengan Debugging nirkabel (Pasangkan, di bawah).</string> + <string name="setup_memory_hud">Tampilan memori</string> + <string name="setup_memory_hud_hint">Memori dan swap yang digunakan, di layar sesi. Lanjutan merincinya menjadi gim, DroidDeck, Android, dan GPU.</string> + <string name="setup_memory_hud_simple">Sederhana</string> + <string name="setup_memory_hud_advanced">Lanjutan</string> + <string name="memory_hud_simple">RAM %1$s/%2$s GB · swap %3$s/%4$s GB</string> + <string name="memory_hud_split">gim %1$s · DroidDeck %2$s · Android %3$s GB</string> + <string name="memory_hud_gpu">GPU %1$s GB</string> + <string name="setup_bg_limit">Aplikasi latar belakang selama sesi</string> + <string name="setup_bg_limit_hint">Selama sesi berjalan, Android menyimpan paling banyak sejumlah ini aplikasi di cache dan menutup sisanya agar gim mendapat memorinya. DroidDeck mengembalikan batas seperti semula saat sesi berakhir. Debugging nirkabel harus aktif.</string> + <string name="setup_bg_limit_4">Paling banyak 4</string> + <string name="setup_bg_limit_1">Paling banyak 1</string> + <string name="setup_bg_limit_0">Tidak ada</string> + <string name="setup_adb_pair">Debugging nirkabel</string> + <string name="setup_adb_pair_hint">Menyandingkan DroidDeck dengan Debugging nirkabel Android untuk opsi di atas. Batas proses turunan tetap nonaktif.</string> + <string name="setup_adb_pair_button">Pasangkan</string> + <string name="epic_client_missing">Belum terinstal</string> + <string name="epic_client_open">Buka</string> + <string name="epic_client_install">Pasang</string> + <string name="game_tab_about">Tentang</string> + <string name="game_tab_install">Pasang</string> + <string name="game_install_location">Lokasi</string> + <string name="game_install_folder">Folder</string> + <string name="game_install_size">Ukuran di disk</string> + <string name="game_install_date">Terinstal</string> + <string name="stores_xbox_update">Perbarui / verifikasi</string> + <string name="stores_xbox_manage">Kelola konten</string> + <string name="stores_xbox_close_first">Tutup game Xbox ini sebelum memperbaruinya.</string> + <string name="stores_xbox_content_title">Konten Xbox</string> + <string name="stores_xbox_content_none">Tidak ada paket konten PC berlisensi untuk game ini.</string> + <string name="stores_xbox_content_update">%1$s · Perbarui / verifikasi</string> + <string name="stores_xbox_content_install">%1$s · Instal</string> + <string name="stores_xbox_choose_content">Pilih konten PC</string> + <string name="stores_eyebrow_pass">tersedia dengan %1$s</string> + <string name="stores_owned_in_library">Game yang dimiliki di Pustaka</string> + <string name="library_filter_added">Ditambahkan</string> + <string name="game_tab_launch">Peluncuran</string> + <string name="game_tab_saves_cloud">Simpanan & cloud</string> + <string name="game_launch_shortcut">Pintasan Steam</string> + <string name="game_install_version">Versi</string> + <string name="game_install_played">Terakhir dimainkan</string> + <string name="game_install_access">Akses</string> + <string name="game_install_access_owned">Dimiliki</string> + <string name="game_stream">Streaming</string> + <string name="library_add">Tambahkan ke Pustaka</string> + <string name="library_remove">Hapus dari Pustaka</string> + <string name="library_in">Di Pustaka</string> + <string name="xbox_filter_show">Tampilkan</string> + <string name="xbox_filter_cloud">Cloud</string> + <string name="stores_xbox_cloud_settings">Pengaturan cloud</string> </resources> diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 75e464513..d070b01e3 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -267,6 +267,24 @@ <string name="osc_steam_touch">Touch di Steam (beta)</string> <string name="osc_style_next_session">I controlli touch di Steam partono dalla prossima sessione Steam</string> <string name="osc_steam_layout">Layout touch di Steam</string> + <string name="screen_keys">Tasti a schermo</string> + <string name="screen_keys_add">Aggiungi tasto</string> + <string name="screen_key_text">Testo</string> + <string name="screen_key_size">Dimensione</string> + <string name="screen_key_opacity">Opacità</string> + <string name="screen_key_type_button">Tasto</string> + <string name="screen_key_shape_trigger">Grilletto</string> + <string name="screen_key_toggle">Attiva</string> + <string name="screen_key_layout_export">Esporta</string> + <string name="screen_key_shape_pill">Capsula</string> + <string name="screen_key_shape_round">Arrotondato</string> + <string name="screen_key_shape_circle">Cerchio</string> + <string name="screen_key_shape_rect">Rettangolo</string> + <string name="screen_key_bind">Tasto</string> + <string name="screen_key_image">Immagine</string> + <string name="screen_key_image_clear">Cancella</string> + <string name="screen_key_layout">Layout</string> + <string name="screen_key_layout_save">Salva</string> <string name="osc_steam_layout_hint">Sposta, ridimensiona o nascondi i controlli touch di questo gioco; vengono salvati su Steam</string> <string name="mode_controller">Controller</string> <string name="mode_controller_hint">Come Steam vede il tuo controller. Si applica dalla prossima sessione.</string> @@ -543,8 +561,8 @@ <string name="perf_sysmem">Turnip: rendering sysmem</string> <string name="perf_sysmem_hint">Più veloce per il client di Steam; nei giochi il driver sceglie per ogni passata tra rendering a tile e in memoria di sistema. Necessario su Adreno 710/720/722.</string> <string name="perf_xalia">Salta l\'assistente xalia di Steam</string> - <string name="perf_fastsync">droiddeck-ntsync (sperimentale)</string> - <string name="perf_fastsync_hint">Un sostituto in userspace del driver ntsync di Linux, per i test. Quando è attivo, Proton lo usa al posto di droiddeck-esync e droiddeck-fsync. Funziona con Proton Experimental ARM64, GE-Proton e proton-cachyos. Si applica dalla prossima sessione.</string> + <string name="perf_fastsync">droiddeck-ntsync</string> + <string name="perf_fastsync_hint">Un sostituto in userspace del driver ntsync di Linux. Quando è attivo, Proton lo usa al posto di droiddeck-esync e droiddeck-fsync. Funziona con Proton Experimental ARM64, GE-Proton e proton-cachyos. Si applica dalla prossima sessione.</string> <string name="perf_esync">droiddeck-esync</string> <string name="perf_fsync">Prima droiddeck-fsync</string> <string name="perf_fsync_hint">Esegue il fsync di Proton sui file Proton originali, con futex_waitv fornito in userspace, quindi non richiede pacchetti e continua a funzionare appena Steam aggiorna Proton. Quando è attivo, i giochi lo usano prima di droiddeck-esync, tranne i pochi che Proton esclude da fsync. Si applica dalla prossima sessione.</string> @@ -1021,8 +1039,6 @@ <string name="games_empty">I giochi che installi in Steam compaiono qui dopo la sessione.</string> <string name="games_play_steam">Gioca su Steam</string> <string name="games_play_steamvr">Gioca su SteamVR</string> - <string name="games_launch_settings">Impostazioni di avvio</string> - <string name="games_launch">Avvia</string> <string name="games_files">File di gioco</string> <string name="games_prefix">Prefisso Proton</string> <string name="games_saves_looking">Ricerca dei salvataggi…</string> @@ -1030,7 +1046,6 @@ <string name="games_saves_folders">%1$d cartelle di salvataggio</string> <string name="games_saves_summary">%1$s · %2$d file · %3$s</string> <string name="games_kb">%1$d KB</string> - <string name="games_manage_saves">Gestisci salvataggi</string> <string name="games_saves">Salvataggi di gioco</string> <string name="games_import_saves">Importa salvataggi…</string> <string name="games_export_gamehub">Esporta come zip GameHub…</string> @@ -1042,7 +1057,6 @@ <string name="games_runtime_first_play">Il runtime si installa al primo Gioca</string> <string name="games_runtime_update">Aggiornamento del runtime disponibile</string> <string name="games_runtime_ready">● Runtime pronto</string> - <string name="games_last_played">Giocato l\'ultima volta %1$s</string> <string name="games_internal">Memoria interna</string> <string name="games_added">Gioco aggiunto</string> <string name="games_custom">Gioco personalizzato</string> @@ -1111,7 +1125,7 @@ <string name="second_terminal">Terminale Linux</string> <string name="second_terminal_not_ready">La sessione Linux non è ancora pronta per un terminale.</string> <string name="rail_steam">Steam</string> - <string name="rail_games">Giochi</string> + <string name="rail_games">Libreria</string> <string name="rail_desktop">Desktop</string> <string name="rail_store">Negozio</string> <string name="rail_components">Componenti</string> @@ -1407,6 +1421,10 @@ <string name="theme_icon_blue_detail">Fondo nero, testo bianco, il blu per tutto ciò che è selezionato, attivo o premuto.</string> <string name="theme_electric">Blu elettrico</string> <string name="theme_electric_detail">Il blu più brillante, su superfici che tendono al blu navy invece che al grigio.</string> + <string name="theme_inner_gamer">Inner Gamer</string> + <string name="theme_inner_gamer_detail">Colori che scorrono lungo l\'arcobaleno. Rispetta le impostazioni di animazione di Android.</string> + <string name="theme_rgb_speed">Velocità</string> + <string name="theme_rgb_speed_hint">Quanto velocemente cambiano i colori.</string> <string name="theme_gradient">Gradiente</string> <string name="theme_gradient_detail">I tuoi colori primario e secondario, sfumati su pulsanti ed evidenziazioni.</string> <string name="theme_gradient_primary">Primario</string> @@ -1533,6 +1551,13 @@ <string name="fm_conflict_skip">Salta</string> <string name="fm_select_this_folder">Seleziona questa cartella</string> <string name="fm_back">Indietro</string> + <string name="fm_exit">Esci</string> + <string name="fm_return_to_start">Torna alla cartella iniziale</string> + <string name="fm_start_unavailable">La cartella iniziale non è più disponibile</string> + <string name="fm_cancel_operation_title">Annullare operazione?</string> + <string name="fm_cancel_operation_confirmation">Questa operazione verrà annullata. Confermi?</string> + <string name="fm_accept">Accetta</string> + <string name="fm_decline">Rifiuta</string> <string name="fm_drive_not_readable">%1$s: montata ma al momento non leggibile</string> <string name="fm_list_view">Visualizzazione elenco</string> <string name="fm_grid_view">Visualizzazione griglia</string> @@ -1949,7 +1974,6 @@ <string name="source_steam">Steam</string> <string name="source_added">Personalizzati</string> <string name="setup_stores">Negozi</string> - <string name="setup_stores_open_on">Apri un negozio su</string> <string name="setup_stores_speed">Livello di velocità di download</string> <string name="setup_stores_engine">Motore dei negozi</string> <string name="setup_stores_engine_chip_ready">● Motore pronto</string> @@ -1963,11 +1987,9 @@ <string name="stores_tabs_arrange">Disponi schede</string> <string name="stores_tabs_arrange_done">Fine</string> <string name="stores_chip_signed_out">(accesso non eseguito)</string> - <string name="stores_tab_store">Negozio</string> <string name="stores_tab_library">Libreria</string> <string name="stores_tab_all">Tutti</string> <string name="stores_tab_downloads">Download</string> - <string name="stores_tab_count">%1$s (%2$d)</string> <string name="stores_signin">Accedi</string> <string name="stores_signout_named">%1$s · esci</string> <string name="stores_signout_title">Uscire da %1$s?</string> @@ -1985,9 +2007,10 @@ <string name="stores_shelf_deals">Offerte</string> <string name="stores_shelf_free">Giochi gratuiti</string> <string name="stores_shelf_trending">Di tendenza</string> - <string name="stores_shelf_library">La tua libreria</string> <string name="stores_play">Gioca</string> <string name="stores_install">Installa</string> + <string name="bnet_open">Apri Battle.net</string> + <string name="bnet_store">Giochi Blizzard</string> <string name="stores_resume_install">Riprendi installazione</string> <string name="stores_dl_interrupted">Interrotto</string> <string name="stores_install_size">Installa · %1$s</string> @@ -2006,8 +2029,32 @@ <string name="stores_itch_no_exe">Nessun gioco Windows trovato nel download</string> <string name="stores_itch_extract_failed">Impossibile estrarre il download (codice %1$d)</string> <string name="stores_itch_no_catalog">itch.io non ha risposto</string> + <string name="stores_itch_sort_popular">Popolari</string> + <string name="stores_itch_sort_new">Più recenti</string> + <string name="stores_itch_sort_new_popular">Nuovi e popolari</string> + <string name="stores_itch_price_paid">A pagamento</string> + <string name="stores_itch_price_under_5">5 $ o meno</string> + <string name="stores_itch_price_under_15">15 $ o meno</string> + <string name="stores_itch_sort_top_rated">I più votati</string> + <string name="stores_itch_sort_top_sellers">I più venduti</string> + <string name="stores_itch_price_any">Qualsiasi prezzo</string> + <string name="stores_itch_price_free">Gratis</string> + <string name="stores_itch_price_sale">In offerta</string> + <string name="stores_itch_load_more">Carica altri</string> + <string name="stores_itch_platform_any">Qualsiasi piattaforma</string> + <string name="stores_itch_platform_mobile_web">Web mobile</string> + <string name="stores_itch_tag_add">Aggiungi un tag</string> + <string name="stores_itch_tag_title">Filtra per tag</string> + <string name="stores_itch_browse_failed">Impossibile caricare i giochi da itch.io</string> <string name="stores_itch_archive">Gli archivi %1$s non sono supportati</string> <string name="stores_itch_download_failed">itch.io ha risposto HTTP %1$d</string> + <string name="stores_itch_pick_title">Scegli una versione</string> + <string name="stores_itch_pick_none">Nessun download trovato per questo gioco</string> + <string name="stores_itch_pick_failed">Impossibile caricare i download da itch.io</string> + <string name="stores_itch_build_demo">Demo</string> + <string name="stores_itch_build_open">Apri su itch.io</string> + <string name="stores_itch_build_linux_soon">Non ancora supportato</string> + <string name="stores_itch_no_game">Gioco non trovato su itch.io</string> <string name="stores_free">Gratis</string> <string name="stores_resume">Riprendi</string> <string name="stores_refresh">Aggiorna</string> @@ -2157,6 +2204,7 @@ <string name="stores_login_close">Chiudi</string> <string name="stores_login_retry">Riprova</string> <string name="stores_login_finishing">Completamento dell\'accesso…</string> + <string name="stores_xbox_sign_in_again">Microsoft ha rifiutato l\'accesso a Xbox. Esci da Xbox in Negozi, accedi di nuovo, poi riprendi.</string> <string name="stores_login_error_network">Impossibile caricare la pagina di accesso: %1$s</string> <string name="stores_login_error_idp_blocked">Questo provider non consente l\'accesso da una pagina incorporata. Accedi invece con la password del negozio.</string> <string name="stores_login_error_http">La pagina di accesso ha risposto con un errore (HTTP %1$d).</string> @@ -2554,9 +2602,6 @@ <string name="stores_hltb_completionist">Completista</string> <string name="stores_hltb_hours">%1$s h</string> <string name="stores_hltb_minutes">%1$d min</string> - <string name="mode_zram">Compressione zRAM</string> - <string name="mode_zram_hint">Mentre un gioco è in esecuzione, la memoria del client Steam viene compressa nella zram del dispositivo, lasciando più RAM al gioco. Si applica al prossimo avvio di Steam.</string> - <string name="mode_zram_unsupported">Non disponibile: questo dispositivo non ha zram o swap, oppure il suo kernel è precedente alla 5.4.</string> <string name="setup_reset_settings">Ripristina tutte le impostazioni</string> <string name="setup_reset_settings_title">Ripristinare tutte le impostazioni?</string> <string name="setup_reset_settings_body">Le impostazioni di schermo, prestazioni, controller, tema, lingua e launcher tornano ai valori predefiniti. Giochi, accesso a Steam, account degli store, driver scaricati e cartelle di archiviazione vengono mantenuti.</string> @@ -3014,6 +3059,59 @@ <string name="spatial_saved_views_hint">Le visualizzazioni salvate possono essere ricalcolate. Avvia una nuova calibrazione per acquisire il sistema di coordinate di tracciamento corrente.</string> <string name="spatial_model_checksum_error">Mancata corrispondenza del checksum del modello di mano</string> <string name="spatial_opencv_load_error">Impossibile caricare OpenCV</string> + <string name="setup_group_memory">Memoria</string> + <string name="setup_clear_cached">Chiudi le app in cache prima di una sessione</string> + <string name="setup_clear_cached_hint">All\'avvio di una sessione, DroidDeck chiude le app che Android tiene parcheggiate in background, così il gioco ha più memoria. Il Debug wireless deve essere attivo.</string> + <string name="setup_clear_cached_unpaired">Richiede DroidDeck accoppiato con il Debug wireless (Accoppia, qui sotto).</string> + <string name="setup_memory_hud">Indicatore della memoria</string> + <string name="setup_memory_hud_hint">Memoria e swap in uso, nella schermata della sessione. Avanzato la suddivide tra gioco, DroidDeck, Android e GPU.</string> + <string name="setup_memory_hud_simple">Semplice</string> + <string name="setup_memory_hud_advanced">Avanzato</string> + <string name="memory_hud_simple">RAM %1$s/%2$s GB · swap %3$s/%4$s GB</string> + <string name="memory_hud_split">gioco %1$s · DroidDeck %2$s · Android %3$s GB</string> + <string name="memory_hud_gpu">GPU %1$s GB</string> + <string name="setup_bg_limit">App in background durante una sessione</string> + <string name="setup_bg_limit_hint">Durante una sessione, Android tiene in cache al massimo questo numero di app e chiude le altre, così il gioco ha la loro memoria. Al termine della sessione DroidDeck ripristina il limite precedente. Il Debug wireless deve essere attivo.</string> + <string name="setup_bg_limit_4">Al massimo 4</string> + <string name="setup_bg_limit_1">Al massimo 1</string> + <string name="setup_bg_limit_0">Nessuna</string> + <string name="setup_adb_pair">Debug wireless</string> + <string name="setup_adb_pair_hint">Accoppia DroidDeck al Debug wireless di Android per le opzioni qui sopra. Il limite dei processi figlio resta disattivato.</string> + <string name="setup_adb_pair_button">Accoppia</string> <string name="setup_glasses_scope">Le impostazioni dello schermo e del tracciamento della testa sono condivise tra menu, giochi e sessioni desktop di DroidDeck.</string> <string name="spatial_print_job">DroidDeck ChArUco 30 mm</string> + <string name="epic_client_missing">Non installata</string> + <string name="epic_client_open">Apri</string> + <string name="epic_client_install">Installa</string> + <string name="game_tab_about">Informazioni</string> + <string name="game_tab_install">Installa</string> + <string name="game_install_location">Posizione</string> + <string name="game_install_folder">Cartella</string> + <string name="game_install_size">Spazio su disco</string> + <string name="game_install_date">Installati</string> + <string name="stores_xbox_update">Aggiorna / verifica</string> + <string name="stores_xbox_manage">Gestisci contenuti</string> + <string name="stores_xbox_close_first">Chiudi questo gioco Xbox prima di aggiornarlo.</string> + <string name="stores_xbox_content_title">Contenuti Xbox</string> + <string name="stores_xbox_content_none">Non ci sono pacchetti di contenuti PC con licenza per questo gioco.</string> + <string name="stores_xbox_content_update">%1$s · Aggiorna / verifica</string> + <string name="stores_xbox_content_install">%1$s · Installa</string> + <string name="stores_xbox_choose_content">Scegli i contenuti PC</string> + <string name="stores_eyebrow_pass">disponibile con %1$s</string> + <string name="stores_owned_in_library">Giochi posseduti nella Libreria</string> + <string name="library_filter_added">Aggiunti</string> + <string name="game_tab_launch">Avvio</string> + <string name="game_tab_saves_cloud">Salvataggi e cloud</string> + <string name="game_launch_shortcut">Collegamento Steam</string> + <string name="game_install_version">Versione</string> + <string name="game_install_played">Ultima partita</string> + <string name="game_install_access">Accesso</string> + <string name="game_install_access_owned">Posseduto</string> + <string name="game_stream">Riproduci in streaming</string> + <string name="library_add">Aggiungi alla Libreria</string> + <string name="library_remove">Rimuovi dalla Libreria</string> + <string name="library_in">Nella Libreria</string> + <string name="xbox_filter_show">Mostra</string> + <string name="xbox_filter_cloud">Cloud</string> + <string name="stores_xbox_cloud_settings">Impostazioni cloud</string> </resources> diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index f1e78ddb8..9bb40dcd1 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -267,6 +267,24 @@ <string name="osc_steam_touch">Steamタッチ(ベータ版)</string> <string name="osc_style_next_session">Steamのタッチコントロールは次回のSteamセッションから有効になります</string> <string name="osc_steam_layout">Steamタッチのレイアウト</string> + <string name="screen_keys">画面のキー</string> + <string name="screen_keys_add">キーを追加</string> + <string name="screen_key_text">テキスト</string> + <string name="screen_key_size">サイズ</string> + <string name="screen_key_opacity">不透明度</string> + <string name="screen_key_type_button">キー</string> + <string name="screen_key_shape_trigger">トリガー</string> + <string name="screen_key_toggle">トグル</string> + <string name="screen_key_layout_export">エクスポート</string> + <string name="screen_key_shape_pill">カプセル</string> + <string name="screen_key_shape_round">角丸</string> + <string name="screen_key_shape_circle">円</string> + <string name="screen_key_shape_rect">四角</string> + <string name="screen_key_bind">キー</string> + <string name="screen_key_image">画像</string> + <string name="screen_key_image_clear">クリア</string> + <string name="screen_key_layout">レイアウト</string> + <string name="screen_key_layout_save">保存</string> <string name="osc_steam_layout_hint">このゲームのタッチコントロールを移動、サイズ変更、非表示にします。Steamに保存されます</string> <string name="mode_controller">コントローラー</string> <string name="mode_controller_hint">Steamがコントローラーを認識する方法。次回のセッションで有効になります。</string> @@ -287,9 +305,6 @@ <string name="mode_suspend_steam_note">自動では一時停止し、表示すると再開します。ネイティブでは先にSteamをスリープに備えさせます。手動では「再開」をタップするまで待機します。「しない」では実行し続けます。</string> <string name="mode_background_downloads">ダウンロードを継続</string> <string name="mode_background_downloads_hint">Steamがダウンロード中は、バックグラウンドでの一時停止を遅らせます。</string> - <string name="mode_zram">zRAM 圧縮</string> - <string name="mode_zram_hint">ゲームの実行中、Steam クライアントのメモリがデバイスの zram に圧縮され、ゲーム用のメモリが増えます。次回 Steam を起動したときに反映されます。</string> - <string name="mode_zram_unsupported">使用できません:このデバイスに zram もしくは swap がないか、カーネルが 5.4 未満です。</string> <string name="mode_suspend_note">自動ではバックグラウンドで一時停止し、表示すると再開します。手動では「再開」をタップするまで待機します。「しない」では実行し続けます。</string> <string name="mode_startup">起動</string> <string name="mode_steam_startup">DroidDeck起動時にSteamを起動</string> @@ -544,8 +559,8 @@ <string name="perf_sysmem">Turnip:sysmemレンダリング</string> <string name="perf_sysmem_hint">Steamクライアントが高速になります。ゲームではドライバーがパスごとにタイルレンダリングとシステムメモリレンダリングを選びます。Adreno 710/720/722では必須です。</string> <string name="perf_xalia">Steamのxalia補助をスキップ</string> - <string name="perf_fastsync">droiddeck-ntsync(実験的)</string> - <string name="perf_fastsync_hint">Linuxのntsyncドライバーをユーザー空間で代替するテスト用機能です。オンの間、Protonはdroiddeck-esyncとdroiddeck-fsyncの代わりにこれを使用します。Proton Experimental ARM64、GE-Proton、proton-cachyosで動作します。次回のセッションで有効になります。</string> + <string name="perf_fastsync">droiddeck-ntsync</string> + <string name="perf_fastsync_hint">Linuxのntsyncドライバーをユーザー空間で代替する機能です。オンの間、Protonはdroiddeck-esyncとdroiddeck-fsyncの代わりにこれを使用します。Proton Experimental ARM64、GE-Proton、proton-cachyosで動作します。次回のセッションで有効になります。</string> <string name="perf_esync">droiddeck-esync</string> <string name="perf_fsync">droiddeck-fsyncを優先</string> <string name="perf_fsync_hint">futex_waitvをユーザー空間で提供し、標準のProtonファイルのままProton自身のfsyncを実行します。パックが不要なため、SteamがProtonを更新してもすぐに動作し続けます。オンの間、Protonがfsyncを使わない一部のゲームを除き、ゲームはdroiddeck-esyncよりこちらを優先します。次回のセッションで有効になります。</string> @@ -1022,8 +1037,6 @@ <string name="games_empty">Steamでインストールされたゲームは、セッション終了後にここに表示されます。</string> <string name="games_play_steam">Steamを実行</string> <string name="games_play_steamvr">SteamVRを実行</string> - <string name="games_launch_settings">起動設定</string> - <string name="games_launch">起動</string> <string name="games_files">ゲームファイル</string> <string name="games_prefix">Protonプレフィックス</string> <string name="games_saves_looking">セーブデータを検索中…</string> @@ -1031,7 +1044,6 @@ <string name="games_saves_folders">%1$d 個のセーブフォルダ</string> <string name="games_saves_summary">%1$s · %2$d 個のファイル · %3$s</string> <string name="games_kb">%1$d KB</string> - <string name="games_manage_saves">セーブデータの管理</string> <string name="games_saves">ゲームセーブデータ</string> <string name="games_import_saves">セーブデータをインポート…</string> <string name="games_export_gamehub">GameHubアーカイブとしてエクスポート…</string> @@ -1043,7 +1055,6 @@ <string name="games_runtime_first_play">初回実行時に実行環境を導入</string> <string name="games_runtime_update">実行環境にアップデートがあります</string> <string name="games_runtime_ready">● 実行環境の準備完了</string> - <string name="games_last_played">最終プレイ %1$s</string> <string name="games_internal">内部ストレージ</string> <string name="games_added">追加されたゲーム</string> <string name="games_custom">カスタムゲーム</string> @@ -1112,7 +1123,7 @@ <string name="second_terminal">Linuxターミナル</string> <string name="second_terminal_not_ready">Linuxセッションの準備ができていないため、ターミナルを使用できません。</string> <string name="rail_steam">Steam</string> - <string name="rail_games">ゲーム</string> + <string name="rail_games">ライブラリ</string> <string name="rail_desktop">デスクトップ</string> <string name="rail_store">ストア</string> <string name="rail_components">コンポーネント</string> @@ -1392,6 +1403,10 @@ <string name="theme_icon_blue_detail">黒の背景に白の文字、選択・フォーカス・押下時には青を使用します。</string> <string name="theme_electric">エレクトリックネイビー</string> <string name="theme_electric_detail">より明るい青を、グレーではなくネイビー寄りの画面に使用します。</string> + <string name="theme_inner_gamer">Inner Gamer</string> + <string name="theme_inner_gamer_detail">虹色に変化し続けるカラー。Android のアニメーション設定に従います。</string> + <string name="theme_rgb_speed">速度</string> + <string name="theme_rgb_speed_hint">色が切り替わる速さ。</string> <string name="theme_gradient">グラデーション</string> <string name="theme_gradient_detail">自分で選んだプライマリとセカンダリの色を、ボタンやハイライトにグラデーションで使います。</string> <string name="theme_gradient_primary">プライマリ</string> @@ -1508,6 +1523,13 @@ <string name="fm_conflict_skip">スキップ</string> <string name="fm_select_this_folder">このフォルダを選択</string> <string name="fm_back">戻る</string> + <string name="fm_exit">終了</string> + <string name="fm_return_to_start">開始フォルダーに戻る</string> + <string name="fm_start_unavailable">開始フォルダーは利用できなくなりました</string> + <string name="fm_cancel_operation_title">操作をキャンセルしますか?</string> + <string name="fm_cancel_operation_confirmation">操作がキャンセルされます。よろしいですか?</string> + <string name="fm_accept">承諾</string> + <string name="fm_decline">拒否</string> <string name="fm_drive_not_readable">%1$sはマウントされていますが、現在読み取れません</string> <string name="fm_list_view">リスト表示</string> <string name="fm_grid_view">グリッド表示</string> @@ -1920,7 +1942,6 @@ <string name="source_steam">Steam</string> <string name="source_added">カスタム</string> <string name="setup_stores">ストア</string> - <string name="setup_stores_open_on">ストアページを開くときの初期タブ</string> <string name="setup_stores_speed">ダウンロード速度レベル</string> <string name="setup_stores_engine">ストアエンジン</string> <string name="setup_stores_engine_chip_ready">● エンジン準備完了</string> @@ -1934,11 +1955,9 @@ <string name="stores_tabs_arrange">タブを整理</string> <string name="stores_tabs_arrange_done">完了</string> <string name="stores_chip_signed_out">(未ログイン)</string> - <string name="stores_tab_store">ストア</string> <string name="stores_tab_library">ライブラリ</string> <string name="stores_tab_all">すべて</string> <string name="stores_tab_downloads">ダウンロード</string> - <string name="stores_tab_count">%1$s(%2$d)</string> <string name="stores_signin">ログイン</string> <string name="stores_signout_named">%1$s · ログアウト</string> <string name="stores_signout_title">%1$sからログアウトしますか?</string> @@ -1956,9 +1975,10 @@ <string name="stores_shelf_deals">セール</string> <string name="stores_shelf_free">無料ゲーム</string> <string name="stores_shelf_trending">トレンド</string> - <string name="stores_shelf_library">マイライブラリ</string> <string name="stores_play">プレイ</string> <string name="stores_install">インストール</string> + <string name="bnet_open">Battle.net を開く</string> + <string name="bnet_store">Blizzard のゲーム</string> <string name="stores_resume_install">インストールを再開</string> <string name="stores_dl_interrupted">中断</string> <string name="stores_install_size">インストール · %1$s</string> @@ -1977,8 +1997,32 @@ <string name="stores_itch_no_exe">ダウンロードに Windows 版のゲームが見つかりません</string> <string name="stores_itch_extract_failed">ダウンロードを展開できませんでした (終了コード %1$d)</string> <string name="stores_itch_no_catalog">itch.io から応答がありません</string> + <string name="stores_itch_sort_popular">人気</string> + <string name="stores_itch_sort_new">新着順</string> + <string name="stores_itch_sort_new_popular">新着&人気</string> + <string name="stores_itch_price_paid">有料</string> + <string name="stores_itch_price_under_5">5ドル以下</string> + <string name="stores_itch_price_under_15">15ドル以下</string> + <string name="stores_itch_sort_top_rated">高評価</string> + <string name="stores_itch_sort_top_sellers">売れ筋</string> + <string name="stores_itch_price_any">すべての価格</string> + <string name="stores_itch_price_free">無料</string> + <string name="stores_itch_price_sale">セール中</string> + <string name="stores_itch_load_more">さらに読み込む</string> + <string name="stores_itch_platform_any">すべてのプラットフォーム</string> + <string name="stores_itch_platform_mobile_web">モバイルWeb</string> + <string name="stores_itch_tag_add">タグを追加</string> + <string name="stores_itch_tag_title">タグで絞り込む</string> + <string name="stores_itch_browse_failed">itch.io のゲームを読み込めませんでした</string> <string name="stores_itch_archive">%1$s 形式のアーカイブには対応していません</string> <string name="stores_itch_download_failed">itch.io が HTTP %1$d を返しました</string> + <string name="stores_itch_pick_title">バージョンを選択</string> + <string name="stores_itch_pick_none">このゲームのダウンロードが見つかりません</string> + <string name="stores_itch_pick_failed">itch.io からダウンロードを読み込めませんでした</string> + <string name="stores_itch_build_demo">デモ</string> + <string name="stores_itch_build_open">itch.io で開く</string> + <string name="stores_itch_build_linux_soon">まだ対応していません</string> + <string name="stores_itch_no_game">itch.io でこのゲームが見つかりませんでした</string> <string name="stores_free">無料</string> <string name="stores_resume">再開</string> <string name="stores_refresh">更新</string> @@ -2131,6 +2175,7 @@ <string name="stores_login_close">閉じる</string> <string name="stores_login_retry">再試行</string> <string name="stores_login_finishing">ログインを完了しています…</string> + <string name="stores_xbox_sign_in_again">Microsoft が Xbox のサインインを拒否しました。ストアで Xbox からサインアウトし、もう一度サインインしてから再開してください。</string> <string name="stores_login_error_network">ログインページを読み込めませんでした:%1$s</string> <string name="stores_login_error_idp_blocked">このプロバイダーは埋め込みページからのログインを拒否しています。代わりにストアのパスワードでログインしてください。</string> <string name="stores_login_error_http">ログインページがエラーを返しました(HTTP %1$d)。</string> @@ -2959,4 +3004,57 @@ <string name="spatial_opencv_load_error">OpenCVをロードできませんでした</string> <string name="tracking_recovery_look">明るい部屋でゆっくりと周囲を見回してください。\nトラッキングカメラを遮るものがないように、家具などの見えるものに顔を向けてください。</string> <string name="spatial_print_job">DroidDeck ChArUco 30 mm</string> + <string name="setup_group_memory">メモリ</string> + <string name="setup_clear_cached">セッション前にキャッシュ済みアプリを閉じる</string> + <string name="setup_clear_cached_hint">セッションの開始時に、Android がバックグラウンドに保持しているアプリを閉じ、ゲームに使えるメモリを増やします。ワイヤレスデバッグをオンにする必要があります。</string> + <string name="setup_clear_cached_unpaired">DroidDeck をワイヤレスデバッグとペア設定する必要があります(下の「ペア設定」)。</string> + <string name="setup_memory_hud">メモリ表示</string> + <string name="setup_memory_hud_hint">使用中のメモリとスワップをセッション画面に表示します。詳細設定では、ゲーム、DroidDeck、Android、GPU に分けて表示します。</string> + <string name="setup_memory_hud_simple">シンプル</string> + <string name="setup_memory_hud_advanced">詳細設定</string> + <string name="memory_hud_simple">RAM %1$s/%2$s GB · スワップ %3$s/%4$s GB</string> + <string name="memory_hud_split">ゲーム %1$s · DroidDeck %2$s · Android %3$s GB</string> + <string name="memory_hud_gpu">GPU %1$s GB</string> + <string name="setup_bg_limit">セッション中のバックグラウンドアプリ</string> + <string name="setup_bg_limit_hint">セッション中、Android がキャッシュに保持するアプリをこの数までに抑え、残りを閉じてゲームにメモリを回します。セッション終了時に元に戻します。ワイヤレスデバッグをオンにする必要があります。</string> + <string name="setup_bg_limit_4">最大 4 個</string> + <string name="setup_bg_limit_1">最大 1 個</string> + <string name="setup_bg_limit_0">なし</string> + <string name="setup_adb_pair">ワイヤレスデバッグ</string> + <string name="setup_adb_pair_hint">上のオプションのために DroidDeck を Android のワイヤレスデバッグとペア設定します。子プロセス制限はオフのままです。</string> + <string name="setup_adb_pair_button">ペア設定</string> + <string name="epic_client_missing">未インストール</string> + <string name="epic_client_open">開く</string> + <string name="epic_client_install">インストール</string> + <string name="game_tab_about">概要</string> + <string name="game_tab_install">インストール</string> + <string name="game_install_location">場所</string> + <string name="game_install_folder">フォルダ</string> + <string name="game_install_size">ディスク上のサイズ</string> + <string name="game_install_date">インストール済み</string> + <string name="stores_xbox_update">更新 / 検証</string> + <string name="stores_xbox_manage">コンテンツを管理</string> + <string name="stores_xbox_close_first">更新する前にこの Xbox ゲームを閉じてください。</string> + <string name="stores_xbox_content_title">Xbox コンテンツ</string> + <string name="stores_xbox_content_none">このゲームにはライセンス済みの PC コンテンツパッケージがありません。</string> + <string name="stores_xbox_content_update">%1$s · 更新 / 検証</string> + <string name="stores_xbox_content_install">%1$s · インストール</string> + <string name="stores_xbox_choose_content">PC コンテンツを選択</string> + <string name="stores_eyebrow_pass">%1$s で利用可能</string> + <string name="stores_owned_in_library">ライブラリの所有ゲーム</string> + <string name="library_filter_added">追加したゲーム</string> + <string name="game_tab_launch">起動</string> + <string name="game_tab_saves_cloud">セーブとクラウド</string> + <string name="game_launch_shortcut">Steam ショートカット</string> + <string name="game_install_version">バージョン</string> + <string name="game_install_played">最終プレイ</string> + <string name="game_install_access">アクセス</string> + <string name="game_install_access_owned">所有</string> + <string name="game_stream">ストリーミング</string> + <string name="library_add">ライブラリに追加</string> + <string name="library_remove">ライブラリから削除</string> + <string name="library_in">ライブラリにあります</string> + <string name="xbox_filter_show">表示</string> + <string name="xbox_filter_cloud">クラウド</string> + <string name="stores_xbox_cloud_settings">クラウド設定</string> </resources> diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 6b7efb33a..b76da8d13 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -267,6 +267,24 @@ <string name="osc_steam_touch">Steam 터치(베타)</string> <string name="osc_style_next_session">Steam 터치 컨트롤은 다음 Steam 세션부터 적용됩니다</string> <string name="osc_steam_layout">Steam 터치 레이아웃</string> + <string name="screen_keys">화면 키</string> + <string name="screen_keys_add">키 추가</string> + <string name="screen_key_text">텍스트</string> + <string name="screen_key_size">크기</string> + <string name="screen_key_opacity">불투명도</string> + <string name="screen_key_type_button">키</string> + <string name="screen_key_shape_trigger">트리거</string> + <string name="screen_key_toggle">토글</string> + <string name="screen_key_layout_export">내보내기</string> + <string name="screen_key_shape_pill">캡슐</string> + <string name="screen_key_shape_round">둥근 모서리</string> + <string name="screen_key_shape_circle">원</string> + <string name="screen_key_shape_rect">사각형</string> + <string name="screen_key_bind">키</string> + <string name="screen_key_image">이미지</string> + <string name="screen_key_image_clear">지우기</string> + <string name="screen_key_layout">레이아웃</string> + <string name="screen_key_layout_save">저장</string> <string name="osc_steam_layout_hint">이 게임의 터치 컨트롤을 옮기거나 크기를 바꾸거나 숨깁니다. Steam에 저장됩니다</string> <string name="mode_controller">컨트롤러</string> <string name="mode_controller_hint">Steam이 컨트롤러를 인식하는 방식입니다. 다음 세션에 적용됩니다.</string> @@ -287,9 +305,6 @@ <string name="mode_suspend_steam_note">자동: 일시 중지되고 다시 표시되면 재개됩니다. 네이티브: 먼저 Steam을 절전에 대비시킵니다. 수동: \"재개\"를 탭할 때까지 기다립니다. 안 함: 계속 실행됩니다.</string> <string name="mode_background_downloads">다운로드 계속 진행</string> <string name="mode_background_downloads_hint">Steam이 다운로드 중일 때는 백그라운드 일시 중지를 미룹니다.</string> - <string name="mode_zram">zRAM 압축</string> - <string name="mode_zram_hint">게임을 실행하는 동안 Steam 클라이언트의 메모리가 기기의 zram으로 압축되어 게임에 더 많은 메모리가 남습니다. 다음에 Steam을 시작할 때 적용됩니다.</string> - <string name="mode_zram_unsupported">사용할 수 없음: 이 기기에 zram 또는 swap이 없거나 커널이 5.4 미만입니다.</string> <string name="mode_suspend_note">자동: 백그라운드에서 일시 중지되고 다시 표시되면 재개됩니다. 수동: \"재개\"를 탭할 때까지 기다립니다. 안 함: 계속 실행됩니다.</string> <string name="mode_startup">시작</string> <string name="mode_steam_startup">DroidDeck을 열 때 Steam 시작</string> @@ -544,8 +559,8 @@ <string name="perf_sysmem">Turnip: sysmem 렌더링</string> <string name="perf_sysmem_hint">Steam 클라이언트가 빨라집니다. 게임에서는 드라이버가 패스마다 타일 렌더링과 시스템 메모리 렌더링 중 하나를 고릅니다. Adreno 710/720/722에 필수입니다.</string> <string name="perf_xalia">Steam의 xalia 도우미 건너뛰기</string> - <string name="perf_fastsync">droiddeck-ntsync(실험적)</string> - <string name="perf_fastsync_hint">테스트용으로 Linux ntsync 드라이버를 사용자 공간에서 대신하는 기능입니다. 켜져 있으면 Proton이 droiddeck-esync와 droiddeck-fsync 대신 이 기능을 사용합니다. Proton Experimental ARM64, GE-Proton 및 proton-cachyos에서 작동합니다. 다음 세션에 적용됩니다.</string> + <string name="perf_fastsync">droiddeck-ntsync</string> + <string name="perf_fastsync_hint">Linux ntsync 드라이버를 사용자 공간에서 대신하는 기능입니다. 켜져 있으면 Proton이 droiddeck-esync와 droiddeck-fsync 대신 이 기능을 사용합니다. Proton Experimental ARM64, GE-Proton 및 proton-cachyos에서 작동합니다. 다음 세션에 적용됩니다.</string> <string name="perf_esync">droiddeck-esync</string> <string name="perf_fsync">droiddeck-fsync 우선</string> <string name="perf_fsync_hint">futex_waitv를 사용자 공간에서 제공하여 기본 Proton 파일로 Proton 자체의 fsync를 실행하므로, 팩이 필요 없고 Steam이 Proton을 업데이트해도 바로 작동합니다. 켜져 있으면 게임이 droiddeck-esync보다 이 기능을 우선 사용합니다. 단, Proton이 fsync를 끄는 일부 게임은 예외입니다. 다음 세션에 적용됩니다.</string> @@ -1022,8 +1037,6 @@ <string name="games_empty">Steam에서 설치한 게임은 세션 종료 후 여기에 표시됩니다.</string> <string name="games_play_steam">Steam 실행</string> <string name="games_play_steamvr">SteamVR 실행</string> - <string name="games_launch_settings">실행 설정</string> - <string name="games_launch">실행</string> <string name="games_files">게임 파일</string> <string name="games_prefix">Proton 프리픽스</string> <string name="games_saves_looking">저장 데이터 검색 중…</string> @@ -1031,7 +1044,6 @@ <string name="games_saves_folders">%1$d개 저장 폴더</string> <string name="games_saves_summary">%1$s · %2$d개 파일 · %3$s</string> <string name="games_kb">%1$d KB</string> - <string name="games_manage_saves">저장 데이터 관리</string> <string name="games_saves">게임 저장 데이터</string> <string name="games_import_saves">저장 데이터 가져오기…</string> <string name="games_export_gamehub">GameHub 아카이브로 내보내기…</string> @@ -1043,7 +1055,6 @@ <string name="games_runtime_first_play">처음 \"Steam 실행\" 시 런타임 설치</string> <string name="games_runtime_update">런타임 업데이트 있음</string> <string name="games_runtime_ready">● 런타임 준비됨</string> - <string name="games_last_played">마지막 실행 %1$s</string> <string name="games_internal">내부 저장 공간</string> <string name="games_added">추가된 게임</string> <string name="games_custom">사용자 정의 게임</string> @@ -1112,7 +1123,7 @@ <string name="second_terminal">Linux 터미널</string> <string name="second_terminal_not_ready">Linux 세션이 준비되지 않아 터미널을 사용할 수 없습니다.</string> <string name="rail_steam">Steam</string> - <string name="rail_games">게임</string> + <string name="rail_games">라이브러리</string> <string name="rail_desktop">데스크톱</string> <string name="rail_store">스토어</string> <string name="rail_components">구성 요소</string> @@ -1392,6 +1403,10 @@ <string name="theme_icon_blue_detail">검은색 배경에 흰색 글자, 선택·포커스·누름 상태에는 파란색을 사용합니다.</string> <string name="theme_electric">일렉트릭 네이비</string> <string name="theme_electric_detail">회색 대신 네이비 톤의 화면에 더 밝은 파란색을 사용합니다.</string> + <string name="theme_inner_gamer">Inner Gamer</string> + <string name="theme_inner_gamer_detail">무지개 색으로 계속 바뀌는 색상입니다. Android의 애니메이션 설정을 따릅니다.</string> + <string name="theme_rgb_speed">속도</string> + <string name="theme_rgb_speed_hint">색상이 바뀌는 속도입니다.</string> <string name="theme_gradient">그라데이션</string> <string name="theme_gradient_detail">직접 고른 기본색과 보조색이 버튼과 강조 표시에 섞여 적용됩니다.</string> <string name="theme_gradient_primary">기본색</string> @@ -1508,6 +1523,13 @@ <string name="fm_conflict_skip">건너뛰기</string> <string name="fm_select_this_folder">이 폴더 선택</string> <string name="fm_back">뒤로</string> + <string name="fm_exit">종료</string> + <string name="fm_return_to_start">시작 폴더로 돌아가기</string> + <string name="fm_start_unavailable">시작 폴더를 더 이상 사용할 수 없습니다</string> + <string name="fm_cancel_operation_title">작업을 취소할까요?</string> + <string name="fm_cancel_operation_confirmation">작업이 취소됩니다. 계속하시겠습니까?</string> + <string name="fm_accept">수락</string> + <string name="fm_decline">거절</string> <string name="fm_drive_not_readable">%1$s이(가) 마운트되었지만 지금은 읽을 수 없습니다</string> <string name="fm_list_view">목록 보기</string> <string name="fm_grid_view">격자 보기</string> @@ -1920,7 +1942,6 @@ <string name="source_steam">Steam</string> <string name="source_added">사용자 정의</string> <string name="setup_stores">스토어</string> - <string name="setup_stores_open_on">스토어를 열 때 표시</string> <string name="setup_stores_speed">다운로드 속도 단계</string> <string name="setup_stores_engine">스토어 엔진</string> <string name="setup_stores_engine_chip_ready">● 엔진 준비됨</string> @@ -1934,11 +1955,9 @@ <string name="stores_tabs_arrange">탭 정리</string> <string name="stores_tabs_arrange_done">완료</string> <string name="stores_chip_signed_out">(로그인 안 됨)</string> - <string name="stores_tab_store">스토어</string> <string name="stores_tab_library">라이브러리</string> <string name="stores_tab_all">전체</string> <string name="stores_tab_downloads">다운로드</string> - <string name="stores_tab_count">%1$s (%2$d)</string> <string name="stores_signin">로그인</string> <string name="stores_signout_named">%1$s · 로그아웃</string> <string name="stores_signout_title">%1$s에서 로그아웃하시겠습니까?</string> @@ -1956,9 +1975,10 @@ <string name="stores_shelf_deals">할인</string> <string name="stores_shelf_free">무료 게임</string> <string name="stores_shelf_trending">인기 급상승</string> - <string name="stores_shelf_library">내 라이브러리</string> <string name="stores_play">플레이</string> <string name="stores_install">설치</string> + <string name="bnet_open">Battle.net 열기</string> + <string name="bnet_store">Blizzard 게임</string> <string name="stores_resume_install">설치 재개</string> <string name="stores_dl_interrupted">중단됨</string> <string name="stores_install_size">설치 · %1$s</string> @@ -1977,8 +1997,32 @@ <string name="stores_itch_no_exe">다운로드에서 Windows 게임을 찾을 수 없습니다</string> <string name="stores_itch_extract_failed">다운로드한 파일의 압축을 풀 수 없습니다(코드 %1$d)</string> <string name="stores_itch_no_catalog">itch.io가 응답하지 않았습니다</string> + <string name="stores_itch_sort_popular">인기</string> + <string name="stores_itch_sort_new">최신순</string> + <string name="stores_itch_sort_new_popular">신규 및 인기</string> + <string name="stores_itch_price_paid">유료</string> + <string name="stores_itch_price_under_5">$5 이하</string> + <string name="stores_itch_price_under_15">$15 이하</string> + <string name="stores_itch_sort_top_rated">평점 높은 순</string> + <string name="stores_itch_sort_top_sellers">베스트셀러</string> + <string name="stores_itch_price_any">모든 가격</string> + <string name="stores_itch_price_free">무료</string> + <string name="stores_itch_price_sale">할인 중</string> + <string name="stores_itch_load_more">더 불러오기</string> + <string name="stores_itch_platform_any">모든 플랫폼</string> + <string name="stores_itch_platform_mobile_web">모바일 웹</string> + <string name="stores_itch_tag_add">태그 추가</string> + <string name="stores_itch_tag_title">태그로 필터링</string> + <string name="stores_itch_browse_failed">itch.io에서 게임을 불러오지 못했습니다</string> <string name="stores_itch_archive">%1$s 압축 파일은 지원하지 않습니다</string> <string name="stores_itch_download_failed">itch.io가 HTTP %1$d을(를) 반환했습니다</string> + <string name="stores_itch_pick_title">버전 선택</string> + <string name="stores_itch_pick_none">이 게임의 다운로드를 찾을 수 없습니다</string> + <string name="stores_itch_pick_failed">itch.io에서 다운로드를 불러오지 못했습니다</string> + <string name="stores_itch_build_demo">데모</string> + <string name="stores_itch_build_open">itch.io에서 열기</string> + <string name="stores_itch_build_linux_soon">아직 지원되지 않음</string> + <string name="stores_itch_no_game">itch.io에서 이 게임을 찾을 수 없습니다</string> <string name="stores_free">무료</string> <string name="stores_resume">재개</string> <string name="stores_refresh">새로 고침</string> @@ -2131,6 +2175,7 @@ <string name="stores_login_close">닫기</string> <string name="stores_login_retry">다시 시도</string> <string name="stores_login_finishing">로그인 완료 중…</string> + <string name="stores_xbox_sign_in_again">Microsoft에서 Xbox 로그인을 거부했습니다. 스토어에서 Xbox 로그아웃 후 다시 로그인한 다음 재개하세요.</string> <string name="stores_login_error_network">로그인 페이지를 불러올 수 없습니다: %1$s</string> <string name="stores_login_error_idp_blocked">이 제공업체는 앱에 포함된 페이지에서 로그인을 허용하지 않습니다. 대신 스토어 비밀번호로 로그인하세요.</string> <string name="stores_login_error_http">로그인 페이지에서 오류를 반환했습니다(HTTP %1$d).</string> @@ -2959,4 +3004,57 @@ <string name="spatial_distance_result">카메라 측정 %1$s; 선택한 거리 %2$s. %3$s</string> <string name="spatial_wait_previews">보정을 시작하기 전에 두 카메라 미리보기를 모두 기다리세요.</string> <string name="spatial_saved_views_hint">저장된 뷰는 다시 계산될 수 있습니다. 현재 추적 좌표계에서 캡처하려면 새 보정을 시작하세요.</string> + <string name="setup_group_memory">메모리</string> + <string name="setup_clear_cached">세션 전에 캐시된 앱 닫기</string> + <string name="setup_clear_cached_hint">세션이 시작될 때 Android가 백그라운드에 보관 중인 앱을 닫아 게임이 메모리를 더 쓸 수 있게 합니다. 무선 디버깅이 켜져 있어야 합니다.</string> + <string name="setup_clear_cached_unpaired">DroidDeck을 무선 디버깅과 페어링해야 합니다(아래 페어링).</string> + <string name="setup_memory_hud">메모리 표시</string> + <string name="setup_memory_hud_hint">사용 중인 메모리와 스왑을 세션 화면에 표시합니다. 고급에서는 게임, DroidDeck, Android, GPU로 나눠 보여 줍니다.</string> + <string name="setup_memory_hud_simple">간단</string> + <string name="setup_memory_hud_advanced">고급</string> + <string name="memory_hud_simple">RAM %1$s/%2$s GB · 스왑 %3$s/%4$s GB</string> + <string name="memory_hud_split">게임 %1$s · DroidDeck %2$s · Android %3$s GB</string> + <string name="memory_hud_gpu">GPU %1$s GB</string> + <string name="setup_bg_limit">세션 중 백그라운드 앱</string> + <string name="setup_bg_limit_hint">세션이 실행되는 동안 Android가 캐시에 두는 앱을 이 개수까지로 제한하고 나머지는 닫아 게임이 그 메모리를 쓰게 합니다. 세션이 끝나면 원래대로 돌아갑니다. 무선 디버깅이 켜져 있어야 합니다.</string> + <string name="setup_bg_limit_4">최대 4개</string> + <string name="setup_bg_limit_1">최대 1개</string> + <string name="setup_bg_limit_0">없음</string> + <string name="setup_adb_pair">무선 디버깅</string> + <string name="setup_adb_pair_hint">위 옵션을 위해 DroidDeck을 Android 무선 디버깅과 페어링합니다. 하위 프로세스 제한은 꺼진 상태로 유지됩니다.</string> + <string name="setup_adb_pair_button">페어링</string> + <string name="epic_client_missing">설치되지 않음</string> + <string name="epic_client_open">열기</string> + <string name="epic_client_install">설치</string> + <string name="game_tab_about">정보</string> + <string name="game_tab_install">설치</string> + <string name="game_install_location">위치</string> + <string name="game_install_folder">폴더</string> + <string name="game_install_size">디스크 사용량</string> + <string name="game_install_date">설치됨</string> + <string name="stores_xbox_update">업데이트 / 확인</string> + <string name="stores_xbox_manage">콘텐츠 관리</string> + <string name="stores_xbox_close_first">업데이트하기 전에 이 Xbox 게임을 닫으세요.</string> + <string name="stores_xbox_content_title">Xbox 콘텐츠</string> + <string name="stores_xbox_content_none">이 게임에 사용할 수 있는 라이선스된 PC 콘텐츠 패키지가 없습니다.</string> + <string name="stores_xbox_content_update">%1$s · 업데이트 / 확인</string> + <string name="stores_xbox_content_install">%1$s · 설치</string> + <string name="stores_xbox_choose_content">PC 콘텐츠 선택</string> + <string name="stores_eyebrow_pass">%1$s 이용 가능</string> + <string name="stores_owned_in_library">라이브러리의 보유 게임</string> + <string name="library_filter_added">추가됨</string> + <string name="game_tab_launch">실행</string> + <string name="game_tab_saves_cloud">저장 및 클라우드</string> + <string name="game_launch_shortcut">Steam 바로가기</string> + <string name="game_install_version">버전</string> + <string name="game_install_played">마지막 플레이</string> + <string name="game_install_access">이용 권한</string> + <string name="game_install_access_owned">보유</string> + <string name="game_stream">스트리밍</string> + <string name="library_add">라이브러리에 추가</string> + <string name="library_remove">라이브러리에서 제거</string> + <string name="library_in">라이브러리에 있음</string> + <string name="xbox_filter_show">표시</string> + <string name="xbox_filter_cloud">클라우드</string> + <string name="stores_xbox_cloud_settings">클라우드 설정</string> </resources> diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index ef8c7d5f2..e036753b5 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -267,6 +267,24 @@ <string name="osc_steam_touch">Sentuhan Steam (beta)</string> <string name="osc_style_next_session">Kawalan sentuh Steam bermula pada sesi Steam seterusnya</string> <string name="osc_steam_layout">Susun atur sentuh Steam</string> + <string name="screen_keys">Kekunci skrin</string> + <string name="screen_keys_add">Tambah kekunci</string> + <string name="screen_key_text">Teks</string> + <string name="screen_key_size">Saiz</string> + <string name="screen_key_opacity">Kelegapan</string> + <string name="screen_key_type_button">Kekunci</string> + <string name="screen_key_shape_trigger">Pencetus</string> + <string name="screen_key_toggle">Togol</string> + <string name="screen_key_layout_export">Eksport</string> + <string name="screen_key_shape_pill">Kapsul</string> + <string name="screen_key_shape_round">Bulat</string> + <string name="screen_key_shape_circle">Bulatan</string> + <string name="screen_key_shape_rect">Segi empat</string> + <string name="screen_key_bind">Kekunci</string> + <string name="screen_key_image">Imej</string> + <string name="screen_key_image_clear">Kosongkan</string> + <string name="screen_key_layout">Susun atur</string> + <string name="screen_key_layout_save">Simpan</string> <string name="osc_steam_layout_hint">Gerakkan, ubah saiz atau sembunyikan kawalan sentuh permainan ini; disimpan ke Steam</string> <string name="mode_controller">Pengawal</string> <string name="mode_controller_hint">Cara Steam mengesan pengawal anda. Berkuat kuasa pada sesi seterusnya.</string> @@ -287,9 +305,6 @@ <string name="mode_suspend_steam_note">Auto menjeda dan menyambung semula apabila dipaparkan. Asli menyediakan Steam untuk tidur dahulu. Manual menunggu Sambung. Jangan sekali-kali terus berjalan.</string> <string name="mode_background_downloads">Teruskan muat turun</string> <string name="mode_background_downloads_hint">Lambatkan penjedaan latar belakang semasa Steam aktif memuat turun.</string> - <string name="mode_zram">Pemampatan zRAM</string> - <string name="mode_zram_hint">Semasa bermain, memori klien Steam dimampatkan ke zram peranti supaya permainan mendapat lebih RAM. Berkuat kuasa pada permulaan Steam seterusnya.</string> - <string name="mode_zram_unsupported">Tidak tersedia: peranti ini tiada zram atau swap, atau kernelnya lebih lama daripada 5.4.</string> <string name="mode_suspend_note">Auto menjeda di latar belakang dan menyambung semula apabila dipaparkan. Manual menunggu Sambung. Jangan sekali-kali terus berjalan.</string> <string name="mode_startup">Permulaan</string> <string name="mode_steam_startup">Mulakan Steam bersama DroidDeck</string> @@ -542,8 +557,8 @@ <string name="perf_sysmem">Turnip: rendering sysmem</string> <string name="perf_sysmem_hint">Lebih pantas untuk klien Steam; dalam permainan, pemacu memilih pemaparan jubin atau memori sistem bagi setiap laluan. Diperlukan pada Adreno 710/720/722.</string> <string name="perf_xalia">Langkau pembantu xalia Steam</string> - <string name="perf_fastsync">droiddeck-ntsync (eksperimen)</string> - <string name="perf_fastsync_hint">Pengganti ruang pengguna untuk pemacu ntsync Linux, untuk pengujian. Semasa ia hidup, Proton menggunakannya sebagai ganti droiddeck-esync dan droiddeck-fsync. Berfungsi dengan Proton Experimental ARM64, GE-Proton dan proton-cachyos. Berkuat kuasa pada sesi seterusnya.</string> + <string name="perf_fastsync">droiddeck-ntsync</string> + <string name="perf_fastsync_hint">Pengganti ruang pengguna untuk pemacu ntsync Linux. Semasa ia hidup, Proton menggunakannya sebagai ganti droiddeck-esync dan droiddeck-fsync. Berfungsi dengan Proton Experimental ARM64, GE-Proton dan proton-cachyos. Berkuat kuasa pada sesi seterusnya.</string> <string name="perf_esync">droiddeck-esync</string> <string name="perf_fsync">droiddeck-fsync dahulu</string> <string name="perf_fsync_hint">Menjalankan fsync Proton sendiri pada fail Proton standard, dengan futex_waitv dibekalkan dalam ruang pengguna, jadi ia tidak memerlukan pek dan terus berfungsi sebaik sahaja Steam mengemas kini Proton. Semasa ia hidup, permainan menggunakannya mendahului droiddeck-esync, kecuali beberapa Proton yang mematikan fsync. Berkuat kuasa pada sesi seterusnya.</string> @@ -1020,8 +1035,6 @@ <string name="games_empty">Permainan yang anda pasang dalam Steam muncul di sini selepas sesi.</string> <string name="games_play_steam">Main Steam</string> <string name="games_play_steamvr">Main SteamVR</string> - <string name="games_launch_settings">Tetapan pelancaran</string> - <string name="games_launch">Lancarkan</string> <string name="games_files">Fail permainan</string> <string name="games_prefix">Prefix Proton</string> <string name="games_saves_looking">Mencari simpanan…</string> @@ -1029,7 +1042,6 @@ <string name="games_saves_folders">%1$d folder simpanan</string> <string name="games_saves_summary">%1$s · %2$d fail · %3$s</string> <string name="games_kb">%1$d KB</string> - <string name="games_manage_saves">Urus simpanan</string> <string name="games_saves">Simpanan permainan</string> <string name="games_import_saves">Import simpanan…</string> <string name="games_export_gamehub">Eksport sebagai zip GameHub…</string> @@ -1041,7 +1053,6 @@ <string name="games_runtime_first_play">Masa jalan dipasang pada Main pertama</string> <string name="games_runtime_update">Kemas kini masa jalan tersedia</string> <string name="games_runtime_ready">● Masa jalan sedia</string> - <string name="games_last_played">Terakhir dimainkan %1$s</string> <string name="games_internal">Storan dalaman</string> <string name="games_added">Permainan ditambah</string> <string name="games_custom">Permainan tersuai</string> @@ -1110,7 +1121,7 @@ <string name="second_terminal">Terminal Linux</string> <string name="second_terminal_not_ready">Sesi Linux belum sedia untuk terminal.</string> <string name="rail_steam">Steam</string> - <string name="rail_games">Permainan</string> + <string name="rail_games">Pustaka</string> <string name="rail_desktop">Desktop</string> <string name="rail_store">Kedai</string> <string name="rail_components">Komponen</string> @@ -1390,6 +1401,10 @@ <string name="theme_icon_blue_detail">Latar hitam, teks putih, biru untuk semua yang dipilih, difokus atau ditekan.</string> <string name="theme_electric">Navy elektrik</string> <string name="theme_electric_detail">Biru lebih cerah, pada permukaan yang lebih cenderung navy daripada kelabu.</string> + <string name="theme_inner_gamer">Inner Gamer</string> + <string name="theme_inner_gamer_detail">Warna yang bertukar merentasi pelangi. Mengikut tetapan animasi Android.</string> + <string name="theme_rgb_speed">Kelajuan</string> + <string name="theme_rgb_speed_hint">Seberapa pantas warna bertukar.</string> <string name="theme_gradient">Kecerunan</string> <string name="theme_gradient_detail">Warna utama dan sekunder pilihan anda, diadun pada butang dan sorotan.</string> <string name="theme_gradient_primary">Utama</string> @@ -1506,6 +1521,13 @@ <string name="fm_conflict_skip">Langkau</string> <string name="fm_select_this_folder">Pilih folder ini</string> <string name="fm_back">Kembali</string> + <string name="fm_exit">Keluar</string> + <string name="fm_return_to_start">Kembali ke folder asal</string> + <string name="fm_start_unavailable">Folder asal tidak lagi tersedia</string> + <string name="fm_cancel_operation_title">Batalkan operasi?</string> + <string name="fm_cancel_operation_confirmation">Operasi akan dibatalkan. Adakah anda pasti?</string> + <string name="fm_accept">Terima</string> + <string name="fm_decline">Tolak</string> <string name="fm_drive_not_readable">%1$s dilekapkan tetapi tidak dapat dibaca sekarang</string> <string name="fm_list_view">Paparan senarai</string> <string name="fm_grid_view">Paparan grid</string> @@ -1918,7 +1940,6 @@ <string name="source_steam">Steam</string> <string name="source_added">Tersuai</string> <string name="setup_stores">Kedai</string> - <string name="setup_stores_open_on">Buka kedai pada</string> <string name="setup_stores_speed">Tahap kelajuan muat turun</string> <string name="setup_stores_engine">Enjin kedai</string> <string name="setup_stores_engine_chip_ready">● Enjin sedia</string> @@ -1932,11 +1953,9 @@ <string name="stores_tabs_arrange">Susun tab</string> <string name="stores_tabs_arrange_done">Selesai</string> <string name="stores_chip_signed_out">(belum log masuk)</string> - <string name="stores_tab_store">Kedai</string> <string name="stores_tab_library">Pustaka</string> <string name="stores_tab_all">Semua</string> <string name="stores_tab_downloads">Muat turun</string> - <string name="stores_tab_count">%1$s (%2$d)</string> <string name="stores_signin">Log masuk</string> <string name="stores_signout_named">%1$s · log keluar</string> <string name="stores_signout_title">Log keluar daripada %1$s?</string> @@ -1954,9 +1973,10 @@ <string name="stores_shelf_deals">Tawaran</string> <string name="stores_shelf_free">Permainan percuma</string> <string name="stores_shelf_trending">Tular</string> - <string name="stores_shelf_library">Pustaka anda</string> <string name="stores_play">Main</string> <string name="stores_install">Pasang</string> + <string name="bnet_open">Buka Battle.net</string> + <string name="bnet_store">Permainan Blizzard</string> <string name="stores_resume_install">Sambung pemasangan</string> <string name="stores_dl_interrupted">Terganggu</string> <string name="stores_install_size">Pasang · %1$s</string> @@ -1975,8 +1995,32 @@ <string name="stores_itch_no_exe">Tiada permainan Windows ditemui dalam muat turun</string> <string name="stores_itch_extract_failed">Tidak dapat mengekstrak muat turun (kod %1$d)</string> <string name="stores_itch_no_catalog">itch.io tidak menjawab</string> + <string name="stores_itch_sort_popular">Popular</string> + <string name="stores_itch_sort_new">Terkini</string> + <string name="stores_itch_sort_new_popular">Baharu dan popular</string> + <string name="stores_itch_price_paid">Berbayar</string> + <string name="stores_itch_price_under_5">$5 atau kurang</string> + <string name="stores_itch_price_under_15">$15 atau kurang</string> + <string name="stores_itch_sort_top_rated">Penilaian tertinggi</string> + <string name="stores_itch_sort_top_sellers">Terlaris</string> + <string name="stores_itch_price_any">Sebarang harga</string> + <string name="stores_itch_price_free">Percuma</string> + <string name="stores_itch_price_sale">Diskaun</string> + <string name="stores_itch_load_more">Muat lagi</string> + <string name="stores_itch_platform_any">Mana-mana platform</string> + <string name="stores_itch_platform_mobile_web">Web mudah alih</string> + <string name="stores_itch_tag_add">Tambah tag</string> + <string name="stores_itch_tag_title">Tapis mengikut tag</string> + <string name="stores_itch_browse_failed">Tidak dapat memuatkan permainan daripada itch.io</string> <string name="stores_itch_archive">Arkib %1$s tidak disokong</string> <string name="stores_itch_download_failed">itch.io menjawab HTTP %1$d</string> + <string name="stores_itch_pick_title">Pilih versi</string> + <string name="stores_itch_pick_none">Tiada muat turun ditemui untuk permainan ini</string> + <string name="stores_itch_pick_failed">Tidak dapat memuatkan muat turun daripada itch.io</string> + <string name="stores_itch_build_demo">Demo</string> + <string name="stores_itch_build_open">Buka di itch.io</string> + <string name="stores_itch_build_linux_soon">Belum disokong</string> + <string name="stores_itch_no_game">Permainan ini tidak ditemui di itch.io</string> <string name="stores_free">Percuma</string> <string name="stores_resume">Sambung</string> <string name="stores_refresh">Segar semula</string> @@ -2129,6 +2173,7 @@ <string name="stores_login_close">Tutup</string> <string name="stores_login_retry">Cuba lagi</string> <string name="stores_login_finishing">Menyelesaikan log masuk…</string> + <string name="stores_xbox_sign_in_again">Microsoft menolak log masuk Xbox. Log keluar daripada Xbox di Kedai, log masuk semula, kemudian sambung.</string> <string name="stores_login_error_network">Halaman log masuk tidak dapat dimuatkan: %1$s</string> <string name="stores_login_error_idp_blocked">Penyedia ini menolak log masuk daripada halaman terbenam. Log masuk dengan kata laluan kedai anda sebaliknya.</string> <string name="stores_login_error_http">Halaman log masuk menjawab dengan ralat (HTTP %1$d).</string> @@ -2955,6 +3000,59 @@ <string name="spatial_saved_views_hint">Pandangan yang disimpan boleh dikira semula. Mulakan penentukuran baharu untuk ditangkap dalam sistem koordinat penjejakan semasa.</string> <string name="spatial_model_checksum_error">Ketakpadanan checksum model tangan</string> <string name="spatial_opencv_load_error">OpenCV tidak dapat dimuatkan</string> + <string name="setup_group_memory">Memori</string> + <string name="setup_clear_cached">Tutup apl dalam cache sebelum sesi</string> + <string name="setup_clear_cached_hint">Apabila sesi bermula, DroidDeck menutup apl yang disimpan Android di latar belakang supaya permainan mendapat lebih banyak memori. Debugging Wayarles mesti dihidupkan.</string> + <string name="setup_clear_cached_unpaired">Memerlukan DroidDeck digandingkan dengan Debugging Wayarles (Gandingkan, di bawah).</string> + <string name="setup_memory_hud">Paparan memori</string> + <string name="setup_memory_hud_hint">Memori dan swap yang digunakan, pada skrin sesi. Lanjutan memecahkannya kepada permainan, DroidDeck, Android dan GPU.</string> + <string name="setup_memory_hud_simple">Ringkas</string> + <string name="setup_memory_hud_advanced">Lanjutan</string> + <string name="memory_hud_simple">RAM %1$s/%2$s GB · swap %3$s/%4$s GB</string> + <string name="memory_hud_split">permainan %1$s · DroidDeck %2$s · Android %3$s GB</string> + <string name="memory_hud_gpu">GPU %1$s GB</string> + <string name="setup_bg_limit">Apl latar belakang semasa sesi</string> + <string name="setup_bg_limit_hint">Semasa sesi berjalan, Android menyimpan paling banyak bilangan apl ini dalam cache dan menutup selebihnya, supaya permainan mendapat memori apl tersebut. DroidDeck memulihkan had seperti asal apabila sesi tamat. Debugging Wayarles mesti dihidupkan.</string> + <string name="setup_bg_limit_4">Paling banyak 4</string> + <string name="setup_bg_limit_1">Paling banyak 1</string> + <string name="setup_bg_limit_0">Tiada</string> + <string name="setup_adb_pair">Debugging Wayarles</string> + <string name="setup_adb_pair_hint">Menggandingkan DroidDeck dengan Debugging Wayarles Android untuk pilihan di atas. Had proses anak kekal dimatikan.</string> + <string name="setup_adb_pair_button">Gandingkan</string> <string name="spatial_print_title">DroidDeck - Penentukuran Luma Ultra</string> <string name="spatial_base_loaded">Penentukuran asas Luma Ultra dimuatkan</string> + <string name="epic_client_missing">Belum dipasang</string> + <string name="epic_client_open">Buka</string> + <string name="epic_client_install">Pasang</string> + <string name="game_tab_about">Perihal</string> + <string name="game_tab_install">Pasang</string> + <string name="game_install_location">Lokasi</string> + <string name="game_install_folder">Folder</string> + <string name="game_install_size">Saiz pada cakera</string> + <string name="game_install_date">Dipasang</string> + <string name="stores_xbox_update">Kemas kini / sahkan</string> + <string name="stores_xbox_manage">Urus kandungan</string> + <string name="stores_xbox_close_first">Tutup permainan Xbox ini sebelum mengemas kininya.</string> + <string name="stores_xbox_content_title">Kandungan Xbox</string> + <string name="stores_xbox_content_none">Tiada pakej kandungan PC berlesen untuk permainan ini.</string> + <string name="stores_xbox_content_update">%1$s · Kemas kini / sahkan</string> + <string name="stores_xbox_content_install">%1$s · Pasang</string> + <string name="stores_xbox_choose_content">Pilih kandungan PC</string> + <string name="stores_eyebrow_pass">tersedia dengan %1$s</string> + <string name="stores_owned_in_library">Permainan dimiliki dalam Pustaka</string> + <string name="library_filter_added">Ditambah</string> + <string name="game_tab_launch">Pelancaran</string> + <string name="game_tab_saves_cloud">Simpanan & awan</string> + <string name="game_launch_shortcut">Pintasan Steam</string> + <string name="game_install_version">Versi</string> + <string name="game_install_played">Terakhir dimainkan</string> + <string name="game_install_access">Akses</string> + <string name="game_install_access_owned">Dimiliki</string> + <string name="game_stream">Strim</string> + <string name="library_add">Tambah ke Pustaka</string> + <string name="library_remove">Alih keluar dari Pustaka</string> + <string name="library_in">Dalam Pustaka</string> + <string name="xbox_filter_show">Tunjukkan</string> + <string name="xbox_filter_cloud">Awan</string> + <string name="stores_xbox_cloud_settings">Tetapan awan</string> </resources> diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml index 5dc36a00c..a9f9489c1 100644 --- a/app/src/main/res/values-nb/strings.xml +++ b/app/src/main/res/values-nb/strings.xml @@ -267,6 +267,24 @@ <string name="osc_steam_touch">Steam-berøring (beta)</string> <string name="osc_style_next_session">Steams berøringskontroller starter med neste Steam-sesjon</string> <string name="osc_steam_layout">Steam-berøringslayout</string> + <string name="screen_keys">Skjermtaster</string> + <string name="screen_keys_add">Legg til tast</string> + <string name="screen_key_text">Tekst</string> + <string name="screen_key_size">Størrelse</string> + <string name="screen_key_opacity">Opasitet</string> + <string name="screen_key_type_button">Tast</string> + <string name="screen_key_shape_trigger">Utløser</string> + <string name="screen_key_toggle">Veksle</string> + <string name="screen_key_layout_export">Eksporter</string> + <string name="screen_key_shape_pill">Kapsel</string> + <string name="screen_key_shape_round">Avrundet</string> + <string name="screen_key_shape_circle">Sirkel</string> + <string name="screen_key_shape_rect">Rektangel</string> + <string name="screen_key_bind">Tast</string> + <string name="screen_key_image">Bilde</string> + <string name="screen_key_image_clear">Fjern</string> + <string name="screen_key_layout">Oppsett</string> + <string name="screen_key_layout_save">Lagre</string> <string name="osc_steam_layout_hint">Flytt, endre størrelse på eller skjul dette spillets berøringskontroller; lagres til Steam</string> <string name="mode_controller">Kontroller</string> <string name="mode_controller_hint">Hvordan Steam ser kontrolleren din. Gjelder fra neste sesjon.</string> @@ -287,9 +305,6 @@ <string name="mode_suspend_steam_note">Auto setter på pause og gjenopptar når den vises. Nativ forbereder Steam på hvilemodus først. Manuell venter på Gjenoppta. Aldri lar den fortsette å kjøre.</string> <string name="mode_background_downloads">La nedlastinger fortsette</string> <string name="mode_background_downloads_hint">Utsetter bakgrunnspause mens Steam laster aktivt ned.</string> - <string name="mode_zram">zRAM-komprimering</string> - <string name="mode_zram_hint">Mens et spill kjører, komprimeres minnet til Steam-klienten til enhetens zram, slik at spillet får mer RAM. Gjelder neste gang Steam starter.</string> - <string name="mode_zram_unsupported">Ikke tilgjengelig: enheten har ingen zram eller swap, eller kjernen er eldre enn 5.4.</string> <string name="mode_suspend_note">Auto setter på pause i bakgrunnen og gjenopptar når den vises. Manuell venter på Gjenoppta. Aldri lar den fortsette å kjøre.</string> <string name="mode_startup">Oppstart</string> <string name="mode_steam_startup">Start Steam med DroidDeck</string> @@ -544,8 +559,8 @@ <string name="perf_sysmem">Turnip: sysmem-rendering</string> <string name="perf_sysmem_hint">Raskere for Steam-klienten; i spill velger driveren flis- eller systemminnerendering for hvert pass. Påkrevd på Adreno 710/720/722.</string> <string name="perf_xalia">Hopp over Steams xalia-hjelper</string> - <string name="perf_fastsync">droiddeck-ntsync (eksperimentell)</string> - <string name="perf_fastsync_hint">En erstatning i brukerrom for Linux-driveren ntsync, for testing. Mens dette er på, bruker Proton den i stedet for droiddeck-esync og droiddeck-fsync. Fungerer med Proton Experimental ARM64, GE-Proton og proton-cachyos. Gjelder fra neste sesjon.</string> + <string name="perf_fastsync">droiddeck-ntsync</string> + <string name="perf_fastsync_hint">En erstatning i brukerrom for Linux-driveren ntsync. Mens dette er på, bruker Proton den i stedet for droiddeck-esync og droiddeck-fsync. Fungerer med Proton Experimental ARM64, GE-Proton og proton-cachyos. Gjelder fra neste sesjon.</string> <string name="perf_esync">droiddeck-esync</string> <string name="perf_fsync">droiddeck-fsync først</string> <string name="perf_fsync_hint">Kjører Protons egen fsync på de uendrede Proton-filene, med futex_waitv levert i brukerrom, så den trenger ingen pakke og fortsetter å fungere i det Steam oppdaterer Proton. Mens dette er på, bruker spill den foran droiddeck-esync, med unntak av de få Proton holder utenfor fsync. Gjelder fra neste sesjon.</string> @@ -1026,8 +1041,6 @@ <string name="games_empty">Spill du installerer i Steam, vises her etter sesjonen.</string> <string name="games_play_steam">Spill Steam</string> <string name="games_play_steamvr">Spill SteamVR</string> - <string name="games_launch_settings">Startinnstillinger</string> - <string name="games_launch">Start</string> <string name="games_files">Spillfiler</string> <string name="games_prefix">Proton-prefiks</string> <string name="games_saves_looking">Ser etter lagringer…</string> @@ -1035,7 +1048,6 @@ <string name="games_saves_folders">%1$d lagringsmapper</string> <string name="games_saves_summary">%1$s · %2$d filer · %3$s</string> <string name="games_kb">%1$d KB</string> - <string name="games_manage_saves">Behandle lagringer</string> <string name="games_saves">Spilllagringer</string> <string name="games_import_saves">Importer lagringer…</string> <string name="games_export_gamehub">Eksporter som GameHub-zip…</string> @@ -1047,7 +1059,6 @@ <string name="games_runtime_first_play">Kjøretiden installeres ved første Spill</string> <string name="games_runtime_update">Kjøretidsoppdatering tilgjengelig</string> <string name="games_runtime_ready">● Kjøretid klar</string> - <string name="games_last_played">Sist spilt %1$s</string> <string name="games_internal">Intern lagring</string> <string name="games_added">Lagt til spill</string> <string name="games_custom">Egendefinert spill</string> @@ -1116,7 +1127,7 @@ <string name="second_terminal">Linux-terminal</string> <string name="second_terminal_not_ready">Linux-sesjonen er ikke klar for en terminal ennå.</string> <string name="rail_steam">Steam</string> - <string name="rail_games">Spill</string> + <string name="rail_games">Bibliotek</string> <string name="rail_desktop">Skrivebord</string> <string name="rail_store">Butikk</string> <string name="rail_components">Komponenter</string> @@ -1404,6 +1415,10 @@ <string name="theme_icon_blue_detail">Svart bakgrunn, hvit skrift, det blå for alt som er valgt, fokusert eller trykket.</string> <string name="theme_electric">Elektrisk marineblå</string> <string name="theme_electric_detail">Det blå lysere, på flater som lener mer mot marineblått enn grått.</string> + <string name="theme_inner_gamer">Inner Gamer</string> + <string name="theme_inner_gamer_detail">Farger som går gjennom regnbuen. Følger Androids animasjonsinnstillinger.</string> + <string name="theme_rgb_speed">Hastighet</string> + <string name="theme_rgb_speed_hint">Hvor raskt fargene skifter.</string> <string name="theme_gradient">Gradient</string> <string name="theme_gradient_detail">Dine egne primær- og sekundærfarger, blandet på knapper og uthevinger.</string> <string name="theme_gradient_primary">Primær</string> @@ -1525,6 +1540,13 @@ <string name="fm_conflict_skip">Hopp over</string> <string name="fm_select_this_folder">Velg denne mappen</string> <string name="fm_back">Tilbake</string> + <string name="fm_exit">Avslutt</string> + <string name="fm_return_to_start">Tilbake til startmappen</string> + <string name="fm_start_unavailable">Startmappen er ikke lenger tilgjengelig</string> + <string name="fm_cancel_operation_title">Avbryt operasjonen?</string> + <string name="fm_cancel_operation_confirmation">Operasjonen blir avbrutt. Er du sikker?</string> + <string name="fm_accept">Godta</string> + <string name="fm_decline">Avslå</string> <string name="fm_drive_not_readable">%1$s er montert, men ikke lesbar akkurat nå</string> <string name="fm_list_view">Listevisning</string> <string name="fm_grid_view">Rutenettvisning</string> @@ -1939,7 +1961,6 @@ <string name="source_steam">Steam</string> <string name="source_added">Egendefinert</string> <string name="setup_stores">Butikker</string> - <string name="setup_stores_open_on">Åpne en butikk på</string> <string name="setup_stores_speed">Nedlastingshastighetsnivå</string> <string name="setup_stores_engine">Butikkmotor</string> <string name="setup_stores_engine_chip_ready">● Motor klar</string> @@ -1953,11 +1974,9 @@ <string name="stores_tabs_arrange">Ordne faner</string> <string name="stores_tabs_arrange_done">Ferdig</string> <string name="stores_chip_signed_out">(ikke innlogget)</string> - <string name="stores_tab_store">Butikk</string> <string name="stores_tab_library">Bibliotek</string> <string name="stores_tab_all">Alle</string> <string name="stores_tab_downloads">Nedlastinger</string> - <string name="stores_tab_count">%1$s (%2$d)</string> <string name="stores_signin">Logg inn</string> <string name="stores_signout_named">%1$s · logg ut</string> <string name="stores_signout_title">Logge ut av %1$s?</string> @@ -1975,9 +1994,10 @@ <string name="stores_shelf_deals">Tilbud</string> <string name="stores_shelf_free">Gratisspill</string> <string name="stores_shelf_trending">Trender</string> - <string name="stores_shelf_library">Biblioteket ditt</string> <string name="stores_play">Spill</string> <string name="stores_install">Installer</string> + <string name="bnet_open">Åpne Battle.net</string> + <string name="bnet_store">Blizzard-spill</string> <string name="stores_resume_install">Gjenoppta installasjon</string> <string name="stores_dl_interrupted">Avbrutt</string> <string name="stores_install_size">Installer · %1$s</string> @@ -1996,8 +2016,32 @@ <string name="stores_itch_no_exe">Fant ikke noe Windows-spill i nedlastingen</string> <string name="stores_itch_extract_failed">Kunne ikke pakke ut nedlastingen (kode %1$d)</string> <string name="stores_itch_no_catalog">itch.io svarte ikke</string> + <string name="stores_itch_sort_popular">Populære</string> + <string name="stores_itch_sort_new">Nyeste</string> + <string name="stores_itch_sort_new_popular">Nye og populære</string> + <string name="stores_itch_price_paid">Betalt</string> + <string name="stores_itch_price_under_5">$5 eller mindre</string> + <string name="stores_itch_price_under_15">$15 eller mindre</string> + <string name="stores_itch_sort_top_rated">Best vurdert</string> + <string name="stores_itch_sort_top_sellers">Mest solgt</string> + <string name="stores_itch_price_any">Alle priser</string> + <string name="stores_itch_price_free">Gratis</string> + <string name="stores_itch_price_sale">På salg</string> + <string name="stores_itch_load_more">Last inn flere</string> + <string name="stores_itch_platform_any">Alle plattformer</string> + <string name="stores_itch_platform_mobile_web">Web på mobil</string> + <string name="stores_itch_tag_add">Legg til en tagg</string> + <string name="stores_itch_tag_title">Filtrer etter tagg</string> + <string name="stores_itch_browse_failed">Kunne ikke laste spill fra itch.io</string> <string name="stores_itch_archive">%1$s-arkiver støttes ikke</string> <string name="stores_itch_download_failed">itch.io svarte HTTP %1$d</string> + <string name="stores_itch_pick_title">Velg en versjon</string> + <string name="stores_itch_pick_none">Fant ingen nedlastinger for dette spillet</string> + <string name="stores_itch_pick_failed">Kunne ikke laste inn nedlastingene fra itch.io</string> + <string name="stores_itch_build_demo">Demo</string> + <string name="stores_itch_build_open">Åpne på itch.io</string> + <string name="stores_itch_build_linux_soon">Støttes ikke ennå</string> + <string name="stores_itch_no_game">Fant ikke dette spillet på itch.io</string> <string name="stores_free">Gratis</string> <string name="stores_resume">Gjenoppta</string> <string name="stores_refresh">Oppdater</string> @@ -2150,6 +2194,7 @@ <string name="stores_login_close">Lukk</string> <string name="stores_login_retry">Prøv igjen</string> <string name="stores_login_finishing">Fullfører innlogging…</string> + <string name="stores_xbox_sign_in_again">Microsoft avviste Xbox-påloggingen. Logg ut av Xbox under Butikker, logg på igjen, og fortsett deretter.</string> <string name="stores_login_error_network">Innloggingssiden kunne ikke lastes: %1$s</string> <string name="stores_login_error_idp_blocked">Denne leverandøren tillater ikke innlogging fra en innebygd side. Logg inn med butikkpassordet ditt i stedet.</string> <string name="stores_login_error_http">Innloggingssiden svarte med en feil (HTTP %1$d).</string> @@ -2982,4 +3027,57 @@ <string name="spatial_saved_views_hint">Lagrede visninger kan beregnes på nytt. Start en ny kalibrering for å fange opp i gjeldende sporingskoordinatsystem.</string> <string name="spatial_model_checksum_error">Uoverensstemmelse med håndmodellsjekksum</string> <string name="spatial_opencv_load_error">OpenCV kunne ikke laste</string> + <string name="setup_group_memory">Minne</string> + <string name="setup_clear_cached">Lukk bufrede apper før en sesjon</string> + <string name="setup_clear_cached_hint">Når en sesjon starter, lukker DroidDeck appene Android holder parkert i bakgrunnen, slik at spillet får mer minne. Trådløs feilsøking må være på.</string> + <string name="setup_clear_cached_unpaired">Krever at DroidDeck er paret med trådløs feilsøking (Koble sammen, nedenfor).</string> + <string name="setup_memory_hud">Minnevisning</string> + <string name="setup_memory_hud_hint">Minne og swap i bruk, på sesjonsskjermen. Avansert fordeler det på spillet, DroidDeck, Android og GPU-en.</string> + <string name="setup_memory_hud_simple">Enkel</string> + <string name="setup_memory_hud_advanced">Avansert</string> + <string name="memory_hud_simple">RAM %1$s/%2$s GB · swap %3$s/%4$s GB</string> + <string name="memory_hud_split">spill %1$s · DroidDeck %2$s · Android %3$s GB</string> + <string name="memory_hud_gpu">GPU %1$s GB</string> + <string name="setup_bg_limit">Bakgrunnsapper under en sesjon</string> + <string name="setup_bg_limit_hint">Mens en sesjon kjører, holder Android høyst så mange apper i hurtiglageret og lukker resten, slik at spillet får minnet deres. DroidDeck setter grensen tilbake slik den var når sesjonen avsluttes. Trådløs feilsøking må være på.</string> + <string name="setup_bg_limit_4">Høyst 4</string> + <string name="setup_bg_limit_1">Høyst 1</string> + <string name="setup_bg_limit_0">Ingen</string> + <string name="setup_adb_pair">Trådløs feilsøking</string> + <string name="setup_adb_pair_hint">Kobler DroidDeck sammen med trådløs feilsøking i Android for valgene ovenfor. Grensen for underprosesser forblir av.</string> + <string name="setup_adb_pair_button">Koble sammen</string> + <string name="epic_client_missing">Ikke installert</string> + <string name="epic_client_open">Åpne</string> + <string name="epic_client_install">Installer</string> + <string name="game_tab_about">Om</string> + <string name="game_tab_install">Installer</string> + <string name="game_install_location">Plassering</string> + <string name="game_install_folder">Mappe</string> + <string name="game_install_size">Størrelse på disk</string> + <string name="game_install_date">Installert</string> + <string name="stores_xbox_update">Oppdater / bekreft</string> + <string name="stores_xbox_manage">Administrer innhold</string> + <string name="stores_xbox_close_first">Lukk dette Xbox-spillet før du oppdaterer det.</string> + <string name="stores_xbox_content_title">Xbox-innhold</string> + <string name="stores_xbox_content_none">Det finnes ingen lisensierte PC-innholdspakker for dette spillet.</string> + <string name="stores_xbox_content_update">%1$s · Oppdater / bekreft</string> + <string name="stores_xbox_content_install">%1$s · Installer</string> + <string name="stores_xbox_choose_content">Velg PC-innhold</string> + <string name="stores_eyebrow_pass">tilgjengelig med %1$s</string> + <string name="stores_owned_in_library">Eide spill i Bibliotek</string> + <string name="library_filter_added">Lagt til</string> + <string name="game_tab_launch">Start</string> + <string name="game_tab_saves_cloud">Lagrede spill og sky</string> + <string name="game_launch_shortcut">Steam-snarvei</string> + <string name="game_install_version">Versjon</string> + <string name="game_install_played">Sist spilt</string> + <string name="game_install_access">Tilgang</string> + <string name="game_install_access_owned">Eid</string> + <string name="game_stream">Strøm</string> + <string name="library_add">Legg til i Bibliotek</string> + <string name="library_remove">Fjern fra Bibliotek</string> + <string name="library_in">I Bibliotek</string> + <string name="xbox_filter_show">Vis</string> + <string name="xbox_filter_cloud">Sky</string> + <string name="stores_xbox_cloud_settings">Skyinnstillinger</string> </resources> diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 02b1493d5..d74633be9 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -267,6 +267,24 @@ <string name="osc_steam_touch">Steam-aanraakbediening (bèta)</string> <string name="osc_style_next_session">De aanraakbediening van Steam start bij de volgende Steam-sessie</string> <string name="osc_steam_layout">Steam-aanraakindeling</string> + <string name="screen_keys">Schermtoetsen</string> + <string name="screen_keys_add">Toets toevoegen</string> + <string name="screen_key_text">Tekst</string> + <string name="screen_key_size">Grootte</string> + <string name="screen_key_opacity">Transparantie</string> + <string name="screen_key_type_button">Toets</string> + <string name="screen_key_shape_trigger">Trigger</string> + <string name="screen_key_toggle">Schakelen</string> + <string name="screen_key_layout_export">Exporteren</string> + <string name="screen_key_shape_pill">Capsule</string> + <string name="screen_key_shape_round">Afgerond</string> + <string name="screen_key_shape_circle">Cirkel</string> + <string name="screen_key_shape_rect">Rechthoek</string> + <string name="screen_key_bind">Toets</string> + <string name="screen_key_image">Afbeelding</string> + <string name="screen_key_image_clear">Wissen</string> + <string name="screen_key_layout">Indeling</string> + <string name="screen_key_layout_save">Opslaan</string> <string name="osc_steam_layout_hint">Verplaats, schaal of verberg de aanraakbediening van deze game; opgeslagen in Steam</string> <string name="mode_controller">Controller</string> <string name="mode_controller_hint">Hoe Steam je controller ziet. Geldt vanaf de volgende sessie.</string> @@ -287,9 +305,6 @@ <string name="mode_suspend_steam_note">Automatisch pauzeert en hervat zodra de app weer zichtbaar is. Systeemeigen maakt Steam eerst klaar voor de slaapstand. Handmatig wacht op Hervatten. Nooit laat alles doorlopen.</string> <string name="mode_background_downloads">Downloads laten doorlopen</string> <string name="mode_background_downloads_hint">Stel onderbreken op de achtergrond uit zolang Steam actief downloadt.</string> - <string name="mode_zram">zRAM-compressie</string> - <string name="mode_zram_hint">Terwijl een game draait wordt het geheugen van de Steam-client gecomprimeerd naar de zram van het apparaat, zodat de game meer RAM heeft. Geldt de volgende keer dat Steam start.</string> - <string name="mode_zram_unsupported">Niet beschikbaar: dit apparaat heeft geen zram of swap, of de kernel is ouder dan 5.4.</string> <string name="mode_suspend_note">Automatisch pauzeert op de achtergrond en hervat zodra de app weer zichtbaar is. Handmatig wacht op Hervatten. Nooit laat alles doorlopen.</string> <string name="mode_startup">Opstarten</string> <string name="mode_steam_startup">Steam starten met DroidDeck</string> @@ -544,8 +559,8 @@ <string name="perf_sysmem">Turnip: sysmem-rendering</string> <string name="perf_sysmem_hint">Sneller voor de Steam-client; in games kiest het stuurprogramma per pass tussen tegel- en systeemgeheugenrendering. Vereist op Adreno 710/720/722.</string> <string name="perf_xalia">Sla de xalia-helper van Steam over</string> - <string name="perf_fastsync">droiddeck-ntsync (experimenteel)</string> - <string name="perf_fastsync_hint">Een userspace-vervanger voor de ntsync-driver van Linux, voor tests. Terwijl dit aan staat, gebruikt Proton dit in plaats van droiddeck-esync en droiddeck-fsync. Werkt met Proton Experimental ARM64, GE-Proton en proton-cachyos. Geldt vanaf de volgende sessie.</string> + <string name="perf_fastsync">droiddeck-ntsync</string> + <string name="perf_fastsync_hint">Een userspace-vervanger voor de ntsync-driver van Linux. Terwijl dit aan staat, gebruikt Proton dit in plaats van droiddeck-esync en droiddeck-fsync. Werkt met Proton Experimental ARM64, GE-Proton en proton-cachyos. Geldt vanaf de volgende sessie.</string> <string name="perf_esync">droiddeck-esync</string> <string name="perf_fsync">droiddeck-fsync eerst</string> <string name="perf_fsync_hint">Draait de eigen fsync van Proton op de standaard Proton-bestanden, met futex_waitv in userspace geleverd, zodat er geen pakket nodig is en dit blijft werken zodra Steam Proton bijwerkt. Terwijl dit aan staat, gebruiken games dit vóór droiddeck-esync, behalve de paar die Proton niet op fsync laat draaien. Geldt vanaf de volgende sessie.</string> @@ -1026,8 +1041,6 @@ <string name="games_empty">Games die je in Steam installeert, verschijnen hier na de sessie.</string> <string name="games_play_steam">Steam spelen</string> <string name="games_play_steamvr">SteamVR spelen</string> - <string name="games_launch_settings">Opstartinstellingen</string> - <string name="games_launch">Starten</string> <string name="games_files">Gamebestanden</string> <string name="games_prefix">Proton-prefix</string> <string name="games_saves_looking">Opslagbestanden zoeken…</string> @@ -1035,7 +1048,6 @@ <string name="games_saves_folders">%1$d opslagmappen</string> <string name="games_saves_summary">%1$s · %2$d bestanden · %3$s</string> <string name="games_kb">%1$d KB</string> - <string name="games_manage_saves">Opslagbestanden beheren</string> <string name="games_saves">Gameopslag</string> <string name="games_import_saves">Opslagbestanden importeren…</string> <string name="games_export_gamehub">Exporteren als GameHub-zip…</string> @@ -1047,7 +1059,6 @@ <string name="games_runtime_first_play">Runtime wordt geïnstalleerd bij de eerste keer Spelen</string> <string name="games_runtime_update">Runtime-update beschikbaar</string> <string name="games_runtime_ready">● Runtime gereed</string> - <string name="games_last_played">Laatst gespeeld %1$s</string> <string name="games_internal">Interne opslag</string> <string name="games_added">Toegevoegde game</string> <string name="games_custom">Aangepaste game</string> @@ -1116,7 +1127,7 @@ <string name="second_terminal">Linux-terminal</string> <string name="second_terminal_not_ready">De Linux-sessie is nog niet klaar voor een terminal.</string> <string name="rail_steam">Steam</string> - <string name="rail_games">Games</string> + <string name="rail_games">Bibliotheek</string> <string name="rail_desktop">Desktop</string> <string name="rail_store">Winkel</string> <string name="rail_components">Componenten</string> @@ -1404,6 +1415,10 @@ <string name="theme_icon_blue_detail">Zwarte achtergrond, witte tekst, het blauw voor alles wat geselecteerd, gefocust of ingedrukt is.</string> <string name="theme_electric">Elektrisch marineblauw</string> <string name="theme_electric_detail">Het blauw feller, op vlakken die naar marineblauw neigen in plaats van grijs.</string> + <string name="theme_inner_gamer">Inner Gamer</string> + <string name="theme_inner_gamer_detail">Kleuren die door de regenboog lopen. Volgt de animatie-instellingen van Android.</string> + <string name="theme_rgb_speed">Snelheid</string> + <string name="theme_rgb_speed_hint">Hoe snel de kleuren wisselen.</string> <string name="theme_gradient">Kleurverloop</string> <string name="theme_gradient_detail">Je eigen primaire en secundaire kleur, doorlopend op knoppen en accenten.</string> <string name="theme_gradient_primary">Primair</string> @@ -1525,6 +1540,13 @@ <string name="fm_conflict_skip">Overslaan</string> <string name="fm_select_this_folder">Deze map selecteren</string> <string name="fm_back">Terug</string> + <string name="fm_exit">Afsluiten</string> + <string name="fm_return_to_start">Terug naar beginmap</string> + <string name="fm_start_unavailable">De beginmap is niet meer beschikbaar</string> + <string name="fm_cancel_operation_title">Bewerking annuleren?</string> + <string name="fm_cancel_operation_confirmation">De bewerking wordt geannuleerd. Weet je het zeker?</string> + <string name="fm_accept">Accepteren</string> + <string name="fm_decline">Weigeren</string> <string name="fm_drive_not_readable">%1$s is gekoppeld, maar nu niet leesbaar</string> <string name="fm_list_view">Lijstweergave</string> <string name="fm_grid_view">Rasterweergave</string> @@ -1939,7 +1961,6 @@ <string name="source_steam">Steam</string> <string name="source_added">Aangepast</string> <string name="setup_stores">Winkels</string> - <string name="setup_stores_open_on">Open een winkel op</string> <string name="setup_stores_speed">Niveau voor downloadsnelheid</string> <string name="setup_stores_engine">Winkels-engine</string> <string name="setup_stores_engine_chip_ready">● Engine gereed</string> @@ -1953,11 +1974,9 @@ <string name="stores_tabs_arrange">Tabbladen rangschikken</string> <string name="stores_tabs_arrange_done">Klaar</string> <string name="stores_chip_signed_out">(niet aangemeld)</string> - <string name="stores_tab_store">Winkel</string> <string name="stores_tab_library">Bibliotheek</string> <string name="stores_tab_all">Alle</string> <string name="stores_tab_downloads">Downloads</string> - <string name="stores_tab_count">%1$s (%2$d)</string> <string name="stores_signin">Aanmelden</string> <string name="stores_signout_named">%1$s · afmelden</string> <string name="stores_signout_title">Afmelden bij %1$s?</string> @@ -1975,9 +1994,10 @@ <string name="stores_shelf_deals">Aanbiedingen</string> <string name="stores_shelf_free">Gratis games</string> <string name="stores_shelf_trending">Populair</string> - <string name="stores_shelf_library">Jouw bibliotheek</string> <string name="stores_play">Spelen</string> <string name="stores_install">Installeren</string> + <string name="bnet_open">Battle.net openen</string> + <string name="bnet_store">Blizzard-games</string> <string name="stores_resume_install">Installatie hervatten</string> <string name="stores_dl_interrupted">Onderbroken</string> <string name="stores_install_size">Installeren · %1$s</string> @@ -1996,8 +2016,32 @@ <string name="stores_itch_no_exe">Geen Windows-game gevonden in de download</string> <string name="stores_itch_extract_failed">Download kan niet worden uitgepakt (code %1$d)</string> <string name="stores_itch_no_catalog">itch.io gaf geen antwoord</string> + <string name="stores_itch_sort_popular">Populair</string> + <string name="stores_itch_sort_new">Meest recent</string> + <string name="stores_itch_sort_new_popular">Nieuw en populair</string> + <string name="stores_itch_price_paid">Betaald</string> + <string name="stores_itch_price_under_5">$5 of minder</string> + <string name="stores_itch_price_under_15">$15 of minder</string> + <string name="stores_itch_sort_top_rated">Hoogst beoordeeld</string> + <string name="stores_itch_sort_top_sellers">Bestverkocht</string> + <string name="stores_itch_price_any">Elke prijs</string> + <string name="stores_itch_price_free">Gratis</string> + <string name="stores_itch_price_sale">In de aanbieding</string> + <string name="stores_itch_load_more">Meer laden</string> + <string name="stores_itch_platform_any">Elk platform</string> + <string name="stores_itch_platform_mobile_web">Mobiel web</string> + <string name="stores_itch_tag_add">Tag toevoegen</string> + <string name="stores_itch_tag_title">Filteren op tag</string> + <string name="stores_itch_browse_failed">Games van itch.io konden niet worden geladen</string> <string name="stores_itch_archive">%1$s-archieven worden niet ondersteund</string> <string name="stores_itch_download_failed">itch.io antwoordde HTTP %1$d</string> + <string name="stores_itch_pick_title">Kies een versie</string> + <string name="stores_itch_pick_none">Geen downloads gevonden voor deze game</string> + <string name="stores_itch_pick_failed">De downloads van itch.io konden niet worden geladen</string> + <string name="stores_itch_build_demo">Demo</string> + <string name="stores_itch_build_open">Openen op itch.io</string> + <string name="stores_itch_build_linux_soon">Nog niet ondersteund</string> + <string name="stores_itch_no_game">Deze game is niet gevonden op itch.io</string> <string name="stores_free">Gratis</string> <string name="stores_resume">Hervatten</string> <string name="stores_refresh">Vernieuwen</string> @@ -2150,6 +2194,7 @@ <string name="stores_login_close">Sluiten</string> <string name="stores_login_retry">Probeer het opnieuw</string> <string name="stores_login_finishing">Aanmelden afronden…</string> + <string name="stores_xbox_sign_in_again">Microsoft heeft de Xbox-aanmelding geweigerd. Meld je af bij Xbox in Winkels, meld je opnieuw aan en hervat daarna.</string> <string name="stores_login_error_network">De aanmeldpagina kon niet worden geladen: %1$s</string> <string name="stores_login_error_idp_blocked">Deze provider staat geen aanmelding via een ingesloten pagina toe. Meld je in plaats daarvan aan met je winkelwachtwoord.</string> <string name="stores_login_error_http">De aanmeldpagina gaf een foutmelding (HTTP %1$d).</string> @@ -2980,6 +3025,59 @@ <string name="spatial_saved_views_hint">Opgeslagen weergaven kunnen opnieuw worden berekend. Start een nieuwe kalibratie om vast te leggen in het huidige volgcoördinatensysteem.</string> <string name="spatial_model_checksum_error">Controlesom handmodel komt niet overeen</string> <string name="spatial_opencv_load_error">OpenCV kon niet laden</string> + <string name="setup_group_memory">Geheugen</string> + <string name="setup_clear_cached">Gecachete apps sluiten voor een sessie</string> + <string name="setup_clear_cached_hint">Als een sessie start, sluit DroidDeck de apps die Android op de achtergrond geparkeerd houdt, zodat de game meer geheugen heeft. Draadloos debuggen moet aanstaan.</string> + <string name="setup_clear_cached_unpaired">DroidDeck moet gekoppeld zijn met Draadloos debuggen (Koppelen, hieronder).</string> + <string name="setup_memory_hud">Geheugenweergave</string> + <string name="setup_memory_hud_hint">Gebruikt geheugen en swap, op het sessiescherm. Geavanceerd verdeelt het over de game, DroidDeck, Android en de GPU.</string> + <string name="setup_memory_hud_simple">Eenvoudig</string> + <string name="setup_memory_hud_advanced">Geavanceerd</string> + <string name="memory_hud_simple">RAM %1$s/%2$s GB · swap %3$s/%4$s GB</string> + <string name="memory_hud_split">game %1$s · DroidDeck %2$s · Android %3$s GB</string> + <string name="memory_hud_gpu">GPU %1$s GB</string> + <string name="setup_bg_limit">Achtergrond-apps tijdens een sessie</string> + <string name="setup_bg_limit_hint">Tijdens een sessie houdt Android maximaal dit aantal apps in de cache en sluit de rest, zodat de game hun geheugen krijgt. DroidDeck zet de limiet terug zoals hij was als de sessie eindigt. Draadloos debuggen moet aanstaan.</string> + <string name="setup_bg_limit_4">Maximaal 4</string> + <string name="setup_bg_limit_1">Maximaal 1</string> + <string name="setup_bg_limit_0">Geen</string> + <string name="setup_adb_pair">Draadloos debuggen</string> + <string name="setup_adb_pair_hint">Koppelt DroidDeck aan Draadloos debuggen van Android voor de opties hierboven. De limiet voor subprocessen blijft uit.</string> + <string name="setup_adb_pair_button">Koppelen</string> <string name="glasses_6dof">Virtueel scherm met 6DoF</string> <string name="spatial_print_job">DroidDeck ChArUco 30 mm</string> + <string name="epic_client_missing">Niet geïnstalleerd</string> + <string name="epic_client_open">Openen</string> + <string name="epic_client_install">Installeren</string> + <string name="game_tab_about">Over</string> + <string name="game_tab_install">Installeren</string> + <string name="game_install_location">Locatie</string> + <string name="game_install_folder">Map</string> + <string name="game_install_size">Grootte op schijf</string> + <string name="game_install_date">Geïnstalleerd</string> + <string name="stores_xbox_update">Bijwerken / controleren</string> + <string name="stores_xbox_manage">Inhoud beheren</string> + <string name="stores_xbox_close_first">Sluit deze Xbox-game voordat je hem bijwerkt.</string> + <string name="stores_xbox_content_title">Xbox-inhoud</string> + <string name="stores_xbox_content_none">Er zijn geen gelicentieerde pc-inhoudspakketten voor deze game.</string> + <string name="stores_xbox_content_update">%1$s · Bijwerken / controleren</string> + <string name="stores_xbox_content_install">%1$s · Installeren</string> + <string name="stores_xbox_choose_content">Pc-inhoud kiezen</string> + <string name="stores_eyebrow_pass">beschikbaar met %1$s</string> + <string name="stores_owned_in_library">Games in je Bibliotheek</string> + <string name="library_filter_added">Toegevoegd</string> + <string name="game_tab_launch">Starten</string> + <string name="game_tab_saves_cloud">Opslag en cloud</string> + <string name="game_launch_shortcut">Steam-snelkoppeling</string> + <string name="game_install_version">Versie</string> + <string name="game_install_played">Laatst gespeeld</string> + <string name="game_install_access">Toegang</string> + <string name="game_install_access_owned">In bezit</string> + <string name="game_stream">Streamen</string> + <string name="library_add">Toevoegen aan Bibliotheek</string> + <string name="library_remove">Verwijderen uit Bibliotheek</string> + <string name="library_in">In Bibliotheek</string> + <string name="xbox_filter_show">Weergeven</string> + <string name="xbox_filter_cloud">Cloud</string> + <string name="stores_xbox_cloud_settings">Cloudinstellingen</string> </resources> diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index a135e012d..121d78164 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -267,6 +267,24 @@ <string name="osc_steam_touch">Dotyk Steam (beta)</string> <string name="osc_style_next_session">Sterowanie dotykowe Steam zacznie działać od następnej sesji Steam</string> <string name="osc_steam_layout">Układ dotykowy Steam</string> + <string name="screen_keys">Klawisze ekranu</string> + <string name="screen_keys_add">Dodaj klawisz</string> + <string name="screen_key_text">Tekst</string> + <string name="screen_key_size">Rozmiar</string> + <string name="screen_key_opacity">Przezroczystość</string> + <string name="screen_key_type_button">Klawisz</string> + <string name="screen_key_shape_trigger">Spust</string> + <string name="screen_key_toggle">Przełącznik</string> + <string name="screen_key_layout_export">Eksportuj</string> + <string name="screen_key_shape_pill">Kapsułka</string> + <string name="screen_key_shape_round">Zaokrąglony</string> + <string name="screen_key_shape_circle">Koło</string> + <string name="screen_key_shape_rect">Prostokąt</string> + <string name="screen_key_bind">Klawisz</string> + <string name="screen_key_image">Obraz</string> + <string name="screen_key_image_clear">Wyczyść</string> + <string name="screen_key_layout">Układ</string> + <string name="screen_key_layout_save">Zapisz</string> <string name="osc_steam_layout_hint">Przesuwaj, zmieniaj rozmiar lub ukrywaj sterowanie dotykowe tej gry; zapisywane w Steam</string> <string name="mode_controller">Kontroler</string> <string name="mode_controller_hint">Jak Steam widzi Twój kontroler. Działa od następnej sesji.</string> @@ -287,9 +305,6 @@ <string name="mode_suspend_steam_note">„Auto” wstrzymuje i wznawia po powrocie. „Natywnie” najpierw przygotowuje Steam do uśpienia. „Ręcznie” czeka na „Wznów”. „Nigdy” nie przerywa działania.</string> <string name="mode_background_downloads">Nie przerywaj pobierania</string> <string name="mode_background_downloads_hint">Opóźnia wstrzymanie w tle, gdy Steam aktywnie pobiera.</string> - <string name="mode_zram">Kompresja zRAM</string> - <string name="mode_zram_hint">Gdy gra działa, pamięć klienta Steam jest kompresowana do zram urządzenia, więc gra ma więcej RAM-u. Zadziała przy następnym uruchomieniu Steam.</string> - <string name="mode_zram_unsupported">Niedostępne: to urządzenie nie ma zram ani swapu, albo jądro jest starsze niż 5.4.</string> <string name="mode_suspend_note">„Auto” wstrzymuje w tle i wznawia po powrocie. „Ręcznie” czeka na „Wznów”. „Nigdy” nie przerywa działania.</string> <string name="mode_startup">Uruchamianie</string> <string name="mode_steam_startup">Uruchamiaj Steam z DroidDeck</string> @@ -548,8 +563,8 @@ <string name="perf_sysmem">Turnip: renderowanie sysmem</string> <string name="perf_sysmem_hint">Szybsze dla klienta Steam; w grach sterownik dla każdego przebiegu wybiera renderowanie kafelkowe lub w pamięci systemowej. Wymagane na Adreno 710/720/722.</string> <string name="perf_xalia">Pomijaj pomocnika xalia w Steam</string> - <string name="perf_fastsync">droiddeck-ntsync (eksperymentalne)</string> - <string name="perf_fastsync_hint">Zastępnik sterownika Linux ntsync w przestrzeni użytkownika, do testów. Gdy jest włączony, Proton używa go zamiast droiddeck-esync i droiddeck-fsync. Zgodny z Proton Experimental ARM64, GE-Proton i proton-cachyos. Działa od następnej sesji.</string> + <string name="perf_fastsync">droiddeck-ntsync</string> + <string name="perf_fastsync_hint">Zastępnik sterownika Linux ntsync w przestrzeni użytkownika. Gdy jest włączony, Proton używa go zamiast droiddeck-esync i droiddeck-fsync. Zgodny z Proton Experimental ARM64, GE-Proton i proton-cachyos. Działa od następnej sesji.</string> <string name="perf_esync">droiddeck-esync</string> <string name="perf_fsync">Najpierw droiddeck-fsync</string> <string name="perf_fsync_hint">Uruchamia własny fsync Proton na standardowych plikach Proton, z futex_waitv dostarczanym w przestrzeni użytkownika, więc nie wymaga pakietu i działa od razu po aktualizacji Proton przez Steam. Gdy jest włączony, gry używają go przed droiddeck-esync, z wyjątkiem nielicznych, którym Proton wyłącza fsync. Działa od następnej sesji.</string> @@ -1038,8 +1053,6 @@ <string name="games_empty">Gry, które instalujesz w Steam, pojawią się tutaj po sesji.</string> <string name="games_play_steam">Zagraj w Steam</string> <string name="games_play_steamvr">Zagraj w SteamVR</string> - <string name="games_launch_settings">Ustawienia uruchamiania</string> - <string name="games_launch">Uruchom</string> <string name="games_files">Pliki gry</string> <string name="games_prefix">Prefiks Proton</string> <string name="games_saves_looking">Wyszukiwanie zapisów…</string> @@ -1047,7 +1060,6 @@ <string name="games_saves_folders">%1$d folderów zapisów</string> <string name="games_saves_summary">%1$s · %2$d plików · %3$s</string> <string name="games_kb">%1$d KB</string> - <string name="games_manage_saves">Zarządzaj zapisami</string> <string name="games_saves">Zapisy gry</string> <string name="games_import_saves">Importuj zapisy…</string> <string name="games_export_gamehub">Eksportuj jako zip GameHub…</string> @@ -1059,7 +1071,6 @@ <string name="games_runtime_first_play">Środowisko instaluje się przy pierwszym uruchomieniu</string> <string name="games_runtime_update">Dostępna aktualizacja środowiska</string> <string name="games_runtime_ready">● Środowisko gotowe</string> - <string name="games_last_played">Ostatnio grano %1$s</string> <string name="games_internal">Pamięć wewnętrzna</string> <string name="games_added">Dodana gra</string> <string name="games_custom">Własna gra</string> @@ -1128,7 +1139,7 @@ <string name="second_terminal">Terminal Linux</string> <string name="second_terminal_not_ready">Sesja Linux nie jest jeszcze gotowa na terminal.</string> <string name="rail_steam">Steam</string> - <string name="rail_games">Gry</string> + <string name="rail_games">Biblioteka</string> <string name="rail_desktop">Pulpit</string> <string name="rail_store">Sklep</string> <string name="rail_components">Komponenty</string> @@ -1432,6 +1443,10 @@ <string name="theme_icon_blue_detail">Czarne tło, biały tekst i niebieski dla wszystkiego, co zaznaczone, aktywne lub naciśnięte.</string> <string name="theme_electric">Elektryczny granat</string> <string name="theme_electric_detail">Jaśniejszy niebieski na powierzchniach wpadających w granat zamiast w szarość.</string> + <string name="theme_inner_gamer">Inner Gamer</string> + <string name="theme_inner_gamer_detail">Kolory przechodzące przez całą tęczę. Szanuje ustawienia animacji Androida.</string> + <string name="theme_rgb_speed">Prędkość</string> + <string name="theme_rgb_speed_hint">Jak szybko zmieniają się kolory.</string> <string name="theme_gradient">Gradient</string> <string name="theme_gradient_detail">Własny kolor podstawowy i dodatkowy, płynnie przechodzące na przyciskach i wyróżnieniach.</string> <string name="theme_gradient_primary">Podstawowy</string> @@ -1563,6 +1578,13 @@ <string name="fm_conflict_skip">Pomiń</string> <string name="fm_select_this_folder">Wybierz ten folder</string> <string name="fm_back">Wstecz</string> + <string name="fm_exit">Wyjdź</string> + <string name="fm_return_to_start">Wróć do folderu początkowego</string> + <string name="fm_start_unavailable">Folder początkowy nie jest już dostępny</string> + <string name="fm_cancel_operation_title">Anulować operację?</string> + <string name="fm_cancel_operation_confirmation">Operacja zostanie anulowana. Czy na pewno?</string> + <string name="fm_accept">Akceptuj</string> + <string name="fm_decline">Odrzuć</string> <string name="fm_drive_not_readable">%1$s: zamontowano, ale obecnie nie można odczytać</string> <string name="fm_list_view">Widok listy</string> <string name="fm_grid_view">Widok siatki</string> @@ -1981,7 +2003,6 @@ <string name="source_steam">Steam</string> <string name="source_added">Własna</string> <string name="setup_stores">Sklepy</string> - <string name="setup_stores_open_on">Otwórz sklep w</string> <string name="setup_stores_speed">Poziom szybkości pobierania</string> <string name="setup_stores_engine">Mechanizm sklepów</string> <string name="setup_stores_engine_chip_ready">● Mechanizm gotowy</string> @@ -1995,11 +2016,9 @@ <string name="stores_tabs_arrange">Ułóż karty</string> <string name="stores_tabs_arrange_done">Gotowe</string> <string name="stores_chip_signed_out">(niezalogowano)</string> - <string name="stores_tab_store">Sklep</string> <string name="stores_tab_library">Biblioteka</string> <string name="stores_tab_all">Wszystkie</string> <string name="stores_tab_downloads">Pobierania</string> - <string name="stores_tab_count">%1$s (%2$d)</string> <string name="stores_signin">Zaloguj się</string> <string name="stores_signout_named">%1$s · wyloguj się</string> <string name="stores_signout_title">Wylogować się z %1$s?</string> @@ -2017,9 +2036,10 @@ <string name="stores_shelf_deals">Promocje</string> <string name="stores_shelf_free">Darmowe gry</string> <string name="stores_shelf_trending">Popularne</string> - <string name="stores_shelf_library">Twoja biblioteka</string> <string name="stores_play">Graj</string> <string name="stores_install">Zainstaluj</string> + <string name="bnet_open">Otwórz Battle.net</string> + <string name="bnet_store">Gry Blizzarda</string> <string name="stores_resume_install">Wznów instalację</string> <string name="stores_dl_interrupted">Przerwano</string> <string name="stores_install_size">Instalacja · %1$s</string> @@ -2038,8 +2058,32 @@ <string name="stores_itch_no_exe">W pobranych plikach nie znaleziono gry na Windows</string> <string name="stores_itch_extract_failed">Nie można rozpakować pobranego pliku (kod %1$d)</string> <string name="stores_itch_no_catalog">itch.io nie odpowiedziało</string> + <string name="stores_itch_sort_popular">Popularne</string> + <string name="stores_itch_sort_new">Najnowsze</string> + <string name="stores_itch_sort_new_popular">Nowe i popularne</string> + <string name="stores_itch_price_paid">Płatne</string> + <string name="stores_itch_price_under_5">Do 5 $</string> + <string name="stores_itch_price_under_15">Do 15 $</string> + <string name="stores_itch_sort_top_rated">Najwyżej oceniane</string> + <string name="stores_itch_sort_top_sellers">Najlepiej sprzedające się</string> + <string name="stores_itch_price_any">Dowolna cena</string> + <string name="stores_itch_price_free">Za darmo</string> + <string name="stores_itch_price_sale">Na wyprzedaży</string> + <string name="stores_itch_load_more">Wczytaj więcej</string> + <string name="stores_itch_platform_any">Dowolna platforma</string> + <string name="stores_itch_platform_mobile_web">Web mobilny</string> + <string name="stores_itch_tag_add">Dodaj tag</string> + <string name="stores_itch_tag_title">Filtruj według tagu</string> + <string name="stores_itch_browse_failed">Nie udało się wczytać gier z itch.io</string> <string name="stores_itch_archive">Archiwa %1$s nie są obsługiwane</string> <string name="stores_itch_download_failed">itch.io odpowiedziało HTTP %1$d</string> + <string name="stores_itch_pick_title">Wybierz wersję</string> + <string name="stores_itch_pick_none">Nie znaleziono plików do pobrania dla tej gry</string> + <string name="stores_itch_pick_failed">Nie udało się wczytać plików z itch.io</string> + <string name="stores_itch_build_demo">Demo</string> + <string name="stores_itch_build_open">Otwórz w itch.io</string> + <string name="stores_itch_build_linux_soon">Jeszcze nieobsługiwane</string> + <string name="stores_itch_no_game">Nie znaleziono tej gry w itch.io</string> <string name="stores_free">Bezpłatna</string> <string name="stores_resume">Wznów</string> <string name="stores_refresh">Odśwież</string> @@ -2195,6 +2239,7 @@ <string name="stores_login_close">Zamknij</string> <string name="stores_login_retry">Spróbuj ponownie</string> <string name="stores_login_finishing">Kończenie logowania…</string> + <string name="stores_xbox_sign_in_again">Microsoft odrzucił logowanie do Xbox. Wyloguj się z Xbox w Sklepach, zaloguj się ponownie, a potem wznów.</string> <string name="stores_login_error_network">Nie udało się wczytać strony logowania: %1$s</string> <string name="stores_login_error_idp_blocked">Ten dostawca odmawia logowania z osadzonej strony. Zaloguj się za pomocą hasła do sklepu.</string> <string name="stores_login_error_http">Strona logowania odpowiedziała błędem (HTTP %1$d).</string> @@ -3049,8 +3094,61 @@ <string name="spatial_saved_views_hint">Zapisane widoki można przeliczyć. Rozpocznij nową kalibrację, aby przechwycić bieżący układ współrzędnych śledzenia.</string> <string name="spatial_model_checksum_error">Niezgodność sumy kontrolnej modelu ręki</string> <string name="spatial_opencv_load_error">Nie można załadować OpenCV</string> + <string name="setup_group_memory">Pamięć</string> + <string name="setup_clear_cached">Zamykaj aplikacje z pamięci podręcznej przed sesją</string> + <string name="setup_clear_cached_hint">Na początku sesji DroidDeck zamyka aplikacje, które Android trzyma w tle, aby gra miała więcej pamięci. Debugowanie bezprzewodowe musi być włączone.</string> + <string name="setup_clear_cached_unpaired">Wymaga sparowania DroidDeck przez Debugowanie bezprzewodowe (Sparuj, poniżej).</string> + <string name="setup_memory_hud">Wskaźnik pamięci</string> + <string name="setup_memory_hud_hint">Używana pamięć i swap na ekranie sesji. Tryb Zaawansowany dzieli ją na grę, DroidDeck, Androida i GPU.</string> + <string name="setup_memory_hud_simple">Prosty</string> + <string name="setup_memory_hud_advanced">Zaawansowany</string> + <string name="memory_hud_simple">RAM %1$s/%2$s GB · swap %3$s/%4$s GB</string> + <string name="memory_hud_split">gra %1$s · DroidDeck %2$s · Android %3$s GB</string> + <string name="memory_hud_gpu">GPU %1$s GB</string> + <string name="setup_bg_limit">Aplikacje w tle podczas sesji</string> + <string name="setup_bg_limit_hint">W trakcie sesji Android trzyma w pamięci podręcznej najwyżej tyle aplikacji, a resztę zamyka, aby gra dostała ich pamięć. Po zakończeniu sesji DroidDeck przywraca poprzedni limit. Debugowanie bezprzewodowe musi być włączone.</string> + <string name="setup_bg_limit_4">Najwyżej 4</string> + <string name="setup_bg_limit_1">Najwyżej 1</string> + <string name="setup_bg_limit_0">Brak</string> + <string name="setup_adb_pair">Debugowanie bezprzewodowe</string> + <string name="setup_adb_pair_hint">Paruje DroidDeck z Debugowaniem bezprzewodowym Androida na potrzeby opcji powyżej. Limit procesów podrzędnych pozostaje wyłączony.</string> + <string name="setup_adb_pair_button">Sparuj</string> <string name="spatial_gpu_hint">Domyślnie używany jest CPU. GPU jest opcją eksperymentalną i może powodować ponowne uruchamianie SteamVR. Wybór jest resetowany po ponownym uruchomieniu śledzenia.</string> <string name="spatial_print_job">DroidDeck ChArUco 30 mm</string> <string name="spatial_connect_hint">Podłącz Luma Ultra i włącz śledzenie pozycji.</string> <string name="spatial_inference_error">Wykrywanie dłoni jest niedostępne: %1$s. Zmień ustawienie CPU/GPU lub wyłącz i ponownie włącz sterowanie dłońmi, aby spróbować ponownie.</string> + <string name="epic_client_missing">Nie zainstalowano</string> + <string name="epic_client_open">Otwórz</string> + <string name="epic_client_install">Zainstaluj</string> + <string name="game_tab_about">Informacje</string> + <string name="game_tab_install">Zainstaluj</string> + <string name="game_install_location">Lokalizacja</string> + <string name="game_install_folder">Folder</string> + <string name="game_install_size">Rozmiar na dysku</string> + <string name="game_install_date">Zainstalowane</string> + <string name="stores_xbox_update">Aktualizuj / zweryfikuj</string> + <string name="stores_xbox_manage">Zarządzaj zawartością</string> + <string name="stores_xbox_close_first">Zamknij tę grę Xbox przed aktualizacją.</string> + <string name="stores_xbox_content_title">Zawartość Xbox</string> + <string name="stores_xbox_content_none">Dla tej gry nie ma licencjonowanych pakietów zawartości na PC.</string> + <string name="stores_xbox_content_update">%1$s · Aktualizuj / zweryfikuj</string> + <string name="stores_xbox_content_install">%1$s · Zainstaluj</string> + <string name="stores_xbox_choose_content">Wybierz zawartość na PC</string> + <string name="stores_eyebrow_pass">dostępna w %1$s</string> + <string name="stores_owned_in_library">Posiadane gry w bibliotece</string> + <string name="library_filter_added">Dodane</string> + <string name="game_tab_launch">Uruchamianie</string> + <string name="game_tab_saves_cloud">Zapisy i chmura</string> + <string name="game_launch_shortcut">Skrót Steam</string> + <string name="game_install_version">Wersja</string> + <string name="game_install_played">Ostatnio grano</string> + <string name="game_install_access">Dostęp</string> + <string name="game_install_access_owned">Posiadana</string> + <string name="game_stream">Streamuj</string> + <string name="library_add">Dodaj do biblioteki</string> + <string name="library_remove">Usuń z biblioteki</string> + <string name="library_in">W bibliotece</string> + <string name="xbox_filter_show">Pokaż</string> + <string name="xbox_filter_cloud">Chmura</string> + <string name="stores_xbox_cloud_settings">Ustawienia chmury</string> </resources> diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index b29e753e2..61fe3b5ff 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -267,6 +267,24 @@ <string name="osc_steam_touch">Controlos táteis do Steam (beta)</string> <string name="osc_style_next_session">Os controlos táteis do Steam começam na próxima sessão do Steam</string> <string name="osc_steam_layout">Esquema tátil do Steam</string> + <string name="screen_keys">Teclas no ecrã</string> + <string name="screen_keys_add">Adicionar tecla</string> + <string name="screen_key_text">Texto</string> + <string name="screen_key_size">Tamanho</string> + <string name="screen_key_opacity">Opacidade</string> + <string name="screen_key_type_button">Tecla</string> + <string name="screen_key_shape_trigger">Gatilho</string> + <string name="screen_key_toggle">Alternar</string> + <string name="screen_key_layout_export">Exportar</string> + <string name="screen_key_shape_pill">Cápsula</string> + <string name="screen_key_shape_round">Arredondado</string> + <string name="screen_key_shape_circle">Círculo</string> + <string name="screen_key_shape_rect">Retângulo</string> + <string name="screen_key_bind">Tecla</string> + <string name="screen_key_image">Imagem</string> + <string name="screen_key_image_clear">Limpar</string> + <string name="screen_key_layout">Esquema</string> + <string name="screen_key_layout_save">Guardar</string> <string name="osc_steam_layout_hint">Mova, redimensione ou oculte os controlos táteis deste jogo; é guardado no Steam</string> <string name="mode_controller">Comando</string> <string name="mode_controller_hint">Como o Steam vê o seu comando. Aplica-se na próxima sessão.</string> @@ -543,8 +561,8 @@ <string name="perf_sysmem">Turnip: renderização em sysmem</string> <string name="perf_sysmem_hint">Mais rápido para o cliente Steam; nos jogos, o controlador escolhe em cada passagem entre renderização em mosaico ou na memória do sistema. Obrigatório no Adreno 710/720/722.</string> <string name="perf_xalia">Ignorar o assistente xalia do Steam</string> - <string name="perf_fastsync">droiddeck-ntsync (experimental)</string> - <string name="perf_fastsync_hint">Um substituto em espaço de utilizador para o controlador ntsync do Linux, para testes. Enquanto estiver ativado, o Proton utiliza-o em vez do droiddeck-esync e do droiddeck-fsync. Funciona com o Proton Experimental ARM64, o GE-Proton e o proton-cachyos. Aplica-se na próxima sessão.</string> + <string name="perf_fastsync">droiddeck-ntsync</string> + <string name="perf_fastsync_hint">Um substituto em espaço de utilizador para o controlador ntsync do Linux. Enquanto estiver ativado, o Proton utiliza-o em vez do droiddeck-esync e do droiddeck-fsync. Funciona com o Proton Experimental ARM64, o GE-Proton e o proton-cachyos. Aplica-se na próxima sessão.</string> <string name="perf_esync">droiddeck-esync</string> <string name="perf_fsync">droiddeck-fsync primeiro</string> <string name="perf_fsync_hint">Executa o fsync do próprio Proton sobre os ficheiros originais do Proton, com o futex_waitv fornecido em espaço de utilizador, por isso não precisa de pacote e continua a funcionar assim que o Steam atualiza o Proton. Enquanto estiver ativado, os jogos utilizam-no antes do droiddeck-esync, exceto os poucos que o Proton mantém fora do fsync. Aplica-se na próxima sessão.</string> @@ -1021,8 +1039,6 @@ <string name="games_empty">Os jogos que instalar no Steam aparecem aqui após a sessão.</string> <string name="games_play_steam">Jogar no Steam</string> <string name="games_play_steamvr">Jogar no SteamVR</string> - <string name="games_launch_settings">Definições de arranque</string> - <string name="games_launch">Iniciar</string> <string name="games_files">Ficheiros do jogo</string> <string name="games_prefix">Prefixo do Proton</string> <string name="games_saves_looking">A procurar gravações…</string> @@ -1030,7 +1046,6 @@ <string name="games_saves_folders">%1$d pastas de gravações</string> <string name="games_saves_summary">%1$s · %2$d ficheiros · %3$s</string> <string name="games_kb">%1$d KB</string> - <string name="games_manage_saves">Gerir gravações</string> <string name="games_saves">Gravações do jogo</string> <string name="games_import_saves">Importar gravações…</string> <string name="games_export_gamehub">Exportar como zip do GameHub…</string> @@ -1042,7 +1057,6 @@ <string name="games_runtime_first_play">O ambiente de execução é instalado ao tocar em Jogar pela primeira vez</string> <string name="games_runtime_update">Atualização do ambiente de execução disponível</string> <string name="games_runtime_ready">● Ambiente de execução pronto</string> - <string name="games_last_played">Jogado pela última vez %1$s</string> <string name="games_internal">Armazenamento interno</string> <string name="games_added">Jogo adicionado</string> <string name="games_custom">Jogo personalizado</string> @@ -1111,7 +1125,7 @@ <string name="second_terminal">Terminal Linux</string> <string name="second_terminal_not_ready">A sessão Linux ainda não está pronta para um terminal.</string> <string name="rail_steam">Steam</string> - <string name="rail_games">Jogos</string> + <string name="rail_games">Biblioteca</string> <string name="rail_desktop">Ambiente de trabalho</string> <string name="rail_store">Loja</string> <string name="rail_components">Componentes</string> @@ -1407,6 +1421,10 @@ <string name="theme_icon_blue_detail">Fundo preto, texto branco e o azul para tudo o que está selecionado, focado ou premido.</string> <string name="theme_electric">Azul-marinho elétrico</string> <string name="theme_electric_detail">O azul mais vivo, em superfícies que tendem para o azul-marinho em vez do cinzento.</string> + <string name="theme_inner_gamer">Inner Gamer</string> + <string name="theme_inner_gamer_detail">Cores que percorrem o arco-íris. Respeita as definições de animação do Android.</string> + <string name="theme_rgb_speed">Velocidade</string> + <string name="theme_rgb_speed_hint">Com que rapidez as cores mudam.</string> <string name="theme_gradient">Gradiente</string> <string name="theme_gradient_detail">As suas próprias cores primária e secundária, combinadas nos botões e destaques.</string> <string name="theme_gradient_primary">Primária</string> @@ -1533,6 +1551,13 @@ <string name="fm_conflict_skip">Ignorar</string> <string name="fm_select_this_folder">Selecionar esta pasta</string> <string name="fm_back">Voltar</string> + <string name="fm_exit">Sair</string> + <string name="fm_return_to_start">Voltar à pasta inicial</string> + <string name="fm_start_unavailable">A pasta inicial já não está disponível</string> + <string name="fm_cancel_operation_title">Cancelar operação?</string> + <string name="fm_cancel_operation_confirmation">A operação será cancelada. Tem a certeza?</string> + <string name="fm_accept">Aceitar</string> + <string name="fm_decline">Recusar</string> <string name="fm_drive_not_readable">%1$s está montado, mas não é possível lê-lo de momento</string> <string name="fm_list_view">Vista de lista</string> <string name="fm_grid_view">Vista de grelha</string> @@ -1949,7 +1974,6 @@ <string name="source_steam">Steam</string> <string name="source_added">Personalizados</string> <string name="setup_stores">Lojas</string> - <string name="setup_stores_open_on">Abrir uma loja em</string> <string name="setup_stores_speed">Nível de velocidade de transferência</string> <string name="setup_stores_engine">Motor das lojas</string> <string name="setup_stores_engine_chip_ready">● Motor pronto</string> @@ -1963,11 +1987,9 @@ <string name="stores_tabs_arrange">Organizar separadores</string> <string name="stores_tabs_arrange_done">Concluído</string> <string name="stores_chip_signed_out">(sessão não iniciada)</string> - <string name="stores_tab_store">Loja</string> <string name="stores_tab_library">Biblioteca</string> <string name="stores_tab_all">Todos</string> <string name="stores_tab_downloads">Transferências</string> - <string name="stores_tab_count">%1$s (%2$d)</string> <string name="stores_signin">Iniciar sessão</string> <string name="stores_signout_named">%1$s · terminar sessão</string> <string name="stores_signout_title">Terminar sessão em %1$s?</string> @@ -1985,9 +2007,10 @@ <string name="stores_shelf_deals">Promoções</string> <string name="stores_shelf_free">Jogos gratuitos</string> <string name="stores_shelf_trending">Tendências</string> - <string name="stores_shelf_library">A sua biblioteca</string> <string name="stores_play">Jogar</string> <string name="stores_install">Instalar</string> + <string name="bnet_open">Abrir o Battle.net</string> + <string name="bnet_store">Jogos da Blizzard</string> <string name="stores_resume_install">Retomar instalação</string> <string name="stores_dl_interrupted">Interrompida</string> <string name="stores_install_size">Instalar · %1$s</string> @@ -2006,8 +2029,32 @@ <string name="stores_itch_no_exe">Não foi encontrado nenhum jogo para Windows na transferência</string> <string name="stores_itch_extract_failed">Não foi possível extrair a transferência (código %1$d)</string> <string name="stores_itch_no_catalog">O itch.io não respondeu</string> + <string name="stores_itch_sort_popular">Populares</string> + <string name="stores_itch_sort_new">Mais recentes</string> + <string name="stores_itch_sort_new_popular">Novos e populares</string> + <string name="stores_itch_price_paid">Pagos</string> + <string name="stores_itch_price_under_5">5 $ ou menos</string> + <string name="stores_itch_price_under_15">15 $ ou menos</string> + <string name="stores_itch_sort_top_rated">Mais bem avaliados</string> + <string name="stores_itch_sort_top_sellers">Mais vendidos</string> + <string name="stores_itch_price_any">Qualquer preço</string> + <string name="stores_itch_price_free">Grátis</string> + <string name="stores_itch_price_sale">Em promoção</string> + <string name="stores_itch_load_more">Carregar mais</string> + <string name="stores_itch_platform_any">Qualquer plataforma</string> + <string name="stores_itch_platform_mobile_web">Web móvel</string> + <string name="stores_itch_tag_add">Adicionar uma etiqueta</string> + <string name="stores_itch_tag_title">Filtrar por etiqueta</string> + <string name="stores_itch_browse_failed">Não foi possível carregar os jogos do itch.io</string> <string name="stores_itch_archive">Ficheiros %1$s não são suportados</string> <string name="stores_itch_download_failed">O itch.io respondeu HTTP %1$d</string> + <string name="stores_itch_pick_title">Escolha uma versão</string> + <string name="stores_itch_pick_none">Não foram encontradas transferências para este jogo</string> + <string name="stores_itch_pick_failed">Não foi possível carregar as transferências do itch.io</string> + <string name="stores_itch_build_demo">Demo</string> + <string name="stores_itch_build_open">Abrir no itch.io</string> + <string name="stores_itch_build_linux_soon">Ainda não suportado</string> + <string name="stores_itch_no_game">Não foi possível encontrar este jogo no itch.io</string> <string name="stores_free">Grátis</string> <string name="stores_resume">Retomar</string> <string name="stores_refresh">Atualizar</string> @@ -2157,6 +2204,7 @@ <string name="stores_login_close">Fechar</string> <string name="stores_login_retry">Tentar novamente</string> <string name="stores_login_finishing">A concluir o início de sessão…</string> + <string name="stores_xbox_sign_in_again">A Microsoft recusou o início de sessão da Xbox. Termine a sessão da Xbox em Lojas, inicie sessão novamente e retome.</string> <string name="stores_login_error_network">Não foi possível carregar a página de início de sessão: %1$s</string> <string name="stores_login_error_idp_blocked">Este fornecedor recusa o início de sessão a partir de uma página incorporada. Inicie sessão com a palavra-passe da loja.</string> <string name="stores_login_error_http">A página de início de sessão respondeu com um erro (HTTP %1$d).</string> @@ -2562,9 +2610,6 @@ <string name="setup_reset_settings_button">Repor</string> <string name="setup_reset_settings_profiles">Repor também os perfis de jogo (Proton, FEX, componentes e ambiente de cada jogo e o ambiente do perfil Padrão)</string> <string name="setup_reset_settings_session_running">Termine a sessão antes de repor as definições.</string> - <string name="mode_zram">Compressão zRAM</string> - <string name="mode_zram_unsupported">Indisponível: este dispositivo não tem swap ativo ou o kernel é anterior à versão 5.4.</string> - <string name="mode_zram_hint">Enquanto um jogo está a correr, a memória do cliente Steam é comprimida na zram do dispositivo, deixando mais RAM para o jogo. Aplica-se a partir da próxima vez que o Steam iniciar.</string> <string name="stores_storage_low">Espaço insuficiente (%1$s livres, são necessários %2$s)</string> <string name="stores_storage_insufficient">Espaço livre insuficiente: são necessários %1$s, só há %2$s livres</string> <string name="stores_target_free_unknown">Espaço livre desconhecido</string> @@ -3015,5 +3060,58 @@ <string name="spatial_saved_views_hint">As visualizações guardadas podem ser recalculadas. Inicie uma nova calibração para captar no sistema de coordenadas de seguimento atual.</string> <string name="spatial_model_checksum_error">Incompatibilidade de checksum do modelo de mãos</string> <string name="spatial_opencv_load_error">O OpenCV não conseguiu carregar</string> + <string name="setup_group_memory">Memória</string> + <string name="setup_clear_cached">Fechar apps em cache antes de uma sessão</string> + <string name="setup_clear_cached_hint">Quando uma sessão começa, o DroidDeck fecha as apps que o Android mantém em segundo plano, para que o jogo tenha mais memória. A Depuração sem fios tem de estar ativada.</string> + <string name="setup_clear_cached_unpaired">É necessário sincronizar o DroidDeck com a Depuração sem fios (Sincronizar, abaixo).</string> + <string name="setup_memory_hud">Indicador de memória</string> + <string name="setup_memory_hud_hint">Memória e swap em uso, no ecrã da sessão. O modo Avançado divide-a entre o jogo, o DroidDeck, o Android e a GPU.</string> + <string name="setup_memory_hud_simple">Simples</string> + <string name="setup_memory_hud_advanced">Avançado</string> + <string name="memory_hud_simple">RAM %1$s/%2$s GB · swap %3$s/%4$s GB</string> + <string name="memory_hud_split">jogo %1$s · DroidDeck %2$s · Android %3$s GB</string> + <string name="memory_hud_gpu">GPU %1$s GB</string> + <string name="setup_bg_limit">Apps em segundo plano durante uma sessão</string> + <string name="setup_bg_limit_hint">Enquanto uma sessão decorre, o Android mantém em cache no máximo este número de apps e fecha as restantes, para que o jogo fique com a memória delas. O DroidDeck repõe o limite anterior quando a sessão termina. A Depuração sem fios tem de estar ativada.</string> + <string name="setup_bg_limit_4">No máximo 4</string> + <string name="setup_bg_limit_1">No máximo 1</string> + <string name="setup_bg_limit_0">Nenhuma</string> + <string name="setup_adb_pair">Depuração sem fios</string> + <string name="setup_adb_pair_hint">Sincroniza o DroidDeck com a Depuração sem fios do Android para as opções acima. O limite de processos secundários continua desativado.</string> + <string name="setup_adb_pair_button">Sincronizar</string> <string name="tracking_recovery_look">Olhe em redor lentamente num ambiente bem iluminado.\nMantenha as câmaras de seguimento desobstruídas e viradas para móveis ou outros detalhes visíveis.</string> + <string name="epic_client_missing">Não instalado</string> + <string name="epic_client_open">Abrir</string> + <string name="epic_client_install">Instalar</string> + <string name="game_tab_about">Acerca de</string> + <string name="game_tab_install">Instalar</string> + <string name="game_install_location">Localização</string> + <string name="game_install_folder">Pasta</string> + <string name="game_install_size">Tamanho no disco</string> + <string name="game_install_date">Instalado</string> + <string name="stores_xbox_update">Atualizar / verificar</string> + <string name="stores_xbox_manage">Gerir conteúdo</string> + <string name="stores_xbox_close_first">Feche este jogo Xbox antes de o atualizar.</string> + <string name="stores_xbox_content_title">Conteúdo Xbox</string> + <string name="stores_xbox_content_none">Não há pacotes de conteúdo para PC licenciados para este jogo.</string> + <string name="stores_xbox_content_update">%1$s · Atualizar / verificar</string> + <string name="stores_xbox_content_install">%1$s · Instalar</string> + <string name="stores_xbox_choose_content">Escolher conteúdo para PC</string> + <string name="stores_eyebrow_pass">disponível com %1$s</string> + <string name="stores_owned_in_library">Jogos adquiridos na Biblioteca</string> + <string name="library_filter_added">Adicionados</string> + <string name="game_tab_launch">Arranque</string> + <string name="game_tab_saves_cloud">Gravações e nuvem</string> + <string name="game_launch_shortcut">Atalho do Steam</string> + <string name="game_install_version">Versão</string> + <string name="game_install_played">Jogado pela última vez</string> + <string name="game_install_access">Acesso</string> + <string name="game_install_access_owned">Adquirido</string> + <string name="game_stream">Transmitir</string> + <string name="library_add">Adicionar à Biblioteca</string> + <string name="library_remove">Remover da Biblioteca</string> + <string name="library_in">Na Biblioteca</string> + <string name="xbox_filter_show">Mostrar</string> + <string name="xbox_filter_cloud">Nuvem</string> + <string name="stores_xbox_cloud_settings">Definições da nuvem</string> </resources> diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 79239e0c5..c9e1e7b1b 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -267,6 +267,24 @@ <string name="osc_steam_touch">Toque da Steam (beta)</string> <string name="osc_style_next_session">Os controles de toque da Steam começam na próxima sessão da Steam</string> <string name="osc_steam_layout">Layout de toque da Steam</string> + <string name="screen_keys">Teclas na tela</string> + <string name="screen_keys_add">Adicionar tecla</string> + <string name="screen_key_text">Texto</string> + <string name="screen_key_size">Tamanho</string> + <string name="screen_key_opacity">Opacidade</string> + <string name="screen_key_type_button">Tecla</string> + <string name="screen_key_shape_trigger">Gatilho</string> + <string name="screen_key_toggle">Alternar</string> + <string name="screen_key_layout_export">Exportar</string> + <string name="screen_key_shape_pill">Cápsula</string> + <string name="screen_key_shape_round">Arredondado</string> + <string name="screen_key_shape_circle">Círculo</string> + <string name="screen_key_shape_rect">Retângulo</string> + <string name="screen_key_bind">Tecla</string> + <string name="screen_key_image">Imagem</string> + <string name="screen_key_image_clear">Limpar</string> + <string name="screen_key_layout">Layout</string> + <string name="screen_key_layout_save">Salvar</string> <string name="osc_steam_layout_hint">Mova, redimensione ou oculte os controles de toque deste jogo; salvo na Steam</string> <string name="mode_controller">Controle</string> <string name="mode_controller_hint">Como a Steam vê seu controle. Vale a partir da próxima sessão.</string> @@ -543,8 +561,8 @@ <string name="perf_sysmem">Turnip: renderização sysmem</string> <string name="perf_sysmem_hint">Mais rápido para o cliente da Steam; nos jogos, o driver escolhe a cada passagem entre renderização em blocos ou na memória do sistema. Obrigatório nas Adreno 710/720/722.</string> <string name="perf_xalia">Pular o assistente xalia da Steam</string> - <string name="perf_fastsync">droiddeck-ntsync (experimental)</string> - <string name="perf_fastsync_hint">Um substituto em espaço de usuário para o driver ntsync do Linux, para testes. Enquanto estiver ativado, o Proton o usa no lugar do droiddeck-esync e do droiddeck-fsync. Funciona com o Proton Experimental ARM64, o GE-Proton e o proton-cachyos. Vale a partir da próxima sessão.</string> + <string name="perf_fastsync">droiddeck-ntsync</string> + <string name="perf_fastsync_hint">Um substituto em espaço de usuário para o driver ntsync do Linux. Enquanto estiver ativado, o Proton o usa no lugar do droiddeck-esync e do droiddeck-fsync. Funciona com o Proton Experimental ARM64, o GE-Proton e o proton-cachyos. Vale a partir da próxima sessão.</string> <string name="perf_esync">droiddeck-esync</string> <string name="perf_fsync">droiddeck-fsync primeiro</string> <string name="perf_fsync_hint">Roda o fsync do próprio Proton nos arquivos originais do Proton, com o futex_waitv fornecido em espaço de usuário, então não precisa de pacote e continua funcionando assim que a Steam atualiza o Proton. Enquanto estiver ativado, os jogos o usam antes do droiddeck-esync, exceto os poucos que o Proton mantém fora do fsync. Vale a partir da próxima sessão.</string> @@ -1021,8 +1039,6 @@ <string name="games_empty">Os jogos que você instala na Steam aparecem aqui após a sessão.</string> <string name="games_play_steam">Jogar na Steam</string> <string name="games_play_steamvr">Jogar no SteamVR</string> - <string name="games_launch_settings">Configurações de inicialização</string> - <string name="games_launch">Iniciar</string> <string name="games_files">Arquivos do jogo</string> <string name="games_prefix">Prefixo do Proton</string> <string name="games_saves_looking">Procurando jogos salvos…</string> @@ -1030,7 +1046,6 @@ <string name="games_saves_folders">%1$d pastas de jogos salvos</string> <string name="games_saves_summary">%1$s · %2$d arquivos · %3$s</string> <string name="games_kb">%1$d KB</string> - <string name="games_manage_saves">Gerenciar jogos salvos</string> <string name="games_saves">Jogos salvos</string> <string name="games_import_saves">Importar jogos salvos…</string> <string name="games_export_gamehub">Exportar como zip do GameHub…</string> @@ -1042,7 +1057,6 @@ <string name="games_runtime_first_play">O runtime é instalado no primeiro Jogar</string> <string name="games_runtime_update">Atualização do runtime disponível</string> <string name="games_runtime_ready">● Runtime pronto</string> - <string name="games_last_played">Jogado por último: %1$s</string> <string name="games_internal">Armazenamento interno</string> <string name="games_added">Jogo adicionado</string> <string name="games_custom">Jogo personalizado</string> @@ -1111,7 +1125,7 @@ <string name="second_terminal">Terminal Linux</string> <string name="second_terminal_not_ready">A sessão Linux ainda não está pronta para um terminal.</string> <string name="rail_steam">Steam</string> - <string name="rail_games">Jogos</string> + <string name="rail_games">Biblioteca</string> <string name="rail_desktop">Área de trabalho</string> <string name="rail_store">Loja</string> <string name="rail_components">Componentes</string> @@ -1407,6 +1421,10 @@ <string name="theme_icon_blue_detail">Fundo preto, texto branco e o azul em tudo que estiver selecionado, em foco ou pressionado.</string> <string name="theme_electric">Marinho elétrico</string> <string name="theme_electric_detail">O azul mais vivo, sobre superfícies puxadas para o marinho em vez do cinza.</string> + <string name="theme_inner_gamer">Inner Gamer</string> + <string name="theme_inner_gamer_detail">Cores que percorrem o arco-íris. Respeita as configurações de animação do Android.</string> + <string name="theme_rgb_speed">Velocidade</string> + <string name="theme_rgb_speed_hint">Com que rapidez as cores mudam.</string> <string name="theme_gradient">Gradiente</string> <string name="theme_gradient_detail">Suas próprias cores primária e secundária, combinadas em botões e destaques.</string> <string name="theme_gradient_primary">Primária</string> @@ -1533,6 +1551,13 @@ <string name="fm_conflict_skip">Pular</string> <string name="fm_select_this_folder">Selecionar esta pasta</string> <string name="fm_back">Voltar</string> + <string name="fm_exit">Sair</string> + <string name="fm_return_to_start">Voltar à pasta inicial</string> + <string name="fm_start_unavailable">A pasta inicial não está mais disponível</string> + <string name="fm_cancel_operation_title">Cancelar operação?</string> + <string name="fm_cancel_operation_confirmation">A operação será cancelada. Tem certeza?</string> + <string name="fm_accept">Aceitar</string> + <string name="fm_decline">Recusar</string> <string name="fm_drive_not_readable">%1$s está montado, mas não pode ser lido agora</string> <string name="fm_list_view">Visualização em lista</string> <string name="fm_grid_view">Visualização em grade</string> @@ -1949,7 +1974,6 @@ <string name="source_steam">Steam</string> <string name="source_added">Personalizado</string> <string name="setup_stores">Lojas</string> - <string name="setup_stores_open_on">Abrir uma loja em</string> <string name="setup_stores_speed">Nível de velocidade de download</string> <string name="setup_stores_engine">Mecanismo das lojas</string> <string name="setup_stores_engine_chip_ready">● Mecanismo pronto</string> @@ -1963,11 +1987,9 @@ <string name="stores_tabs_arrange">Organizar abas</string> <string name="stores_tabs_arrange_done">Concluído</string> <string name="stores_chip_signed_out">(sem login)</string> - <string name="stores_tab_store">Loja</string> <string name="stores_tab_library">Biblioteca</string> <string name="stores_tab_all">Todos</string> <string name="stores_tab_downloads">Downloads</string> - <string name="stores_tab_count">%1$s (%2$d)</string> <string name="stores_signin">Fazer login</string> <string name="stores_signout_named">%1$s · sair</string> <string name="stores_signout_title">Sair de %1$s?</string> @@ -1985,9 +2007,10 @@ <string name="stores_shelf_deals">Ofertas</string> <string name="stores_shelf_free">Jogos grátis</string> <string name="stores_shelf_trending">Em alta</string> - <string name="stores_shelf_library">Sua biblioteca</string> <string name="stores_play">Jogar</string> <string name="stores_install">Instalar</string> + <string name="bnet_open">Abrir o Battle.net</string> + <string name="bnet_store">Jogos da Blizzard</string> <string name="stores_resume_install">Retomar instalação</string> <string name="stores_dl_interrupted">Interrompido</string> <string name="stores_install_size">Instalar · %1$s</string> @@ -2006,8 +2029,32 @@ <string name="stores_itch_no_exe">Nenhum jogo para Windows encontrado no download</string> <string name="stores_itch_extract_failed">Não foi possível extrair o download (código %1$d)</string> <string name="stores_itch_no_catalog">O itch.io não respondeu</string> + <string name="stores_itch_sort_popular">Populares</string> + <string name="stores_itch_sort_new">Mais recentes</string> + <string name="stores_itch_sort_new_popular">Novos e populares</string> + <string name="stores_itch_price_paid">Pagos</string> + <string name="stores_itch_price_under_5">US$ 5 ou menos</string> + <string name="stores_itch_price_under_15">US$ 15 ou menos</string> + <string name="stores_itch_sort_top_rated">Mais bem avaliados</string> + <string name="stores_itch_sort_top_sellers">Mais vendidos</string> + <string name="stores_itch_price_any">Qualquer preço</string> + <string name="stores_itch_price_free">Grátis</string> + <string name="stores_itch_price_sale">Em promoção</string> + <string name="stores_itch_load_more">Carregar mais</string> + <string name="stores_itch_platform_any">Qualquer plataforma</string> + <string name="stores_itch_platform_mobile_web">Web mobile</string> + <string name="stores_itch_tag_add">Adicionar uma tag</string> + <string name="stores_itch_tag_title">Filtrar por tag</string> + <string name="stores_itch_browse_failed">Não foi possível carregar os jogos do itch.io</string> <string name="stores_itch_archive">Arquivos %1$s não são suportados</string> <string name="stores_itch_download_failed">O itch.io respondeu HTTP %1$d</string> + <string name="stores_itch_pick_title">Escolha uma versão</string> + <string name="stores_itch_pick_none">Nenhum download encontrado para este jogo</string> + <string name="stores_itch_pick_failed">Não foi possível carregar os downloads do itch.io</string> + <string name="stores_itch_build_demo">Demo</string> + <string name="stores_itch_build_open">Abrir no itch.io</string> + <string name="stores_itch_build_linux_soon">Ainda não suportado</string> + <string name="stores_itch_no_game">Não foi possível encontrar este jogo no itch.io</string> <string name="stores_free">Grátis</string> <string name="stores_resume">Retomar</string> <string name="stores_refresh">Atualizar</string> @@ -2157,6 +2204,7 @@ <string name="stores_login_close">Fechar</string> <string name="stores_login_retry">Tentar de novo</string> <string name="stores_login_finishing">Concluindo o login…</string> + <string name="stores_xbox_sign_in_again">A Microsoft recusou o login do Xbox. Saia do Xbox em Lojas, entre novamente e depois retome.</string> <string name="stores_login_error_network">Não foi possível carregar a página de login: %1$s</string> <string name="stores_login_error_idp_blocked">Este provedor não permite login a partir de uma página incorporada. Faça login com a senha da loja.</string> <string name="stores_login_error_http">A página de login respondeu com um erro (HTTP %1$d).</string> @@ -2554,9 +2602,6 @@ <string name="stores_hltb_completionist">Completista</string> <string name="stores_hltb_hours">%1$s h</string> <string name="stores_hltb_minutes">%1$d min</string> - <string name="mode_zram">Compressão zRAM</string> - <string name="mode_zram_hint">Enquanto um jogo roda, a memória do cliente Steam é comprimida na zram do dispositivo, deixando mais RAM para o jogo. Vale a partir da próxima vez que a Steam iniciar.</string> - <string name="mode_zram_unsupported">Indisponível: este dispositivo não tem swap ativo, ou o kernel é anterior à versão 5.4.</string> <string name="setup_reset_settings">Redefinir todas as configurações</string> <string name="setup_reset_settings_title">Redefinir todas as configurações?</string> <string name="setup_reset_settings_body">As configurações de tela, desempenho, controle, tema, idioma e inicializador voltam ao padrão. Seus jogos, o login da Steam, as contas das lojas, os drivers baixados e as pastas de armazenamento são mantidos.</string> @@ -3015,5 +3060,58 @@ <string name="spatial_saved_views_hint">As visualizações salvas podem ser recalculadas. Inicie uma nova calibração para capturar no sistema de coordenadas de rastreamento atual.</string> <string name="spatial_model_checksum_error">Incompatibilidade de soma de verificação do modelo de mãos</string> <string name="spatial_opencv_load_error">OpenCV não pôde carregar</string> + <string name="setup_group_memory">Memória</string> + <string name="setup_clear_cached">Fechar apps em cache antes de uma sessão</string> + <string name="setup_clear_cached_hint">Quando uma sessão começa, o DroidDeck fecha os apps que o Android mantém em segundo plano, para que o jogo tenha mais memória. A Depuração por Wi-Fi precisa estar ativada.</string> + <string name="setup_clear_cached_unpaired">O DroidDeck precisa estar pareado com a Depuração por Wi-Fi (Parear, abaixo).</string> + <string name="setup_memory_hud">Indicador de memória</string> + <string name="setup_memory_hud_hint">Memória e swap em uso, na tela da sessão. O modo Avançado divide entre o jogo, o DroidDeck, o Android e a GPU.</string> + <string name="setup_memory_hud_simple">Simples</string> + <string name="setup_memory_hud_advanced">Avançado</string> + <string name="memory_hud_simple">RAM %1$s/%2$s GB · swap %3$s/%4$s GB</string> + <string name="memory_hud_split">jogo %1$s · DroidDeck %2$s · Android %3$s GB</string> + <string name="memory_hud_gpu">GPU %1$s GB</string> + <string name="setup_bg_limit">Apps em segundo plano durante uma sessão</string> + <string name="setup_bg_limit_hint">Enquanto uma sessão está em andamento, o Android mantém no máximo esse número de apps em cache e fecha o restante, para que o jogo fique com a memória deles. O DroidDeck restaura o limite anterior quando a sessão termina. A Depuração por Wi-Fi precisa estar ativada.</string> + <string name="setup_bg_limit_4">No máximo 4</string> + <string name="setup_bg_limit_1">No máximo 1</string> + <string name="setup_bg_limit_0">Nenhum</string> + <string name="setup_adb_pair">Depuração por Wi-Fi</string> + <string name="setup_adb_pair_hint">Pareia o DroidDeck com a Depuração por Wi-Fi do Android para as opções acima. O limite de processos filhos continua desativado.</string> + <string name="setup_adb_pair_button">Parear</string> <string name="tracking_recovery_look">Olhe ao redor lentamente em um ambiente bem iluminado.\nMantenha as câmeras de rastreamento desobstruídas e voltadas para móveis ou outros detalhes visíveis.</string> + <string name="epic_client_missing">Não instalado</string> + <string name="epic_client_open">Abrir</string> + <string name="epic_client_install">Instalar</string> + <string name="game_tab_about">Sobre</string> + <string name="game_tab_install">Instalar</string> + <string name="game_install_location">Local</string> + <string name="game_install_folder">Pasta</string> + <string name="game_install_size">Tamanho em disco</string> + <string name="game_install_date">Instalados</string> + <string name="stores_xbox_update">Atualizar / verificar</string> + <string name="stores_xbox_manage">Gerenciar conteúdo</string> + <string name="stores_xbox_close_first">Feche este jogo do Xbox antes de atualizá-lo.</string> + <string name="stores_xbox_content_title">Conteúdo do Xbox</string> + <string name="stores_xbox_content_none">Não há pacotes de conteúdo para PC licenciados para este jogo.</string> + <string name="stores_xbox_content_update">%1$s · Atualizar / verificar</string> + <string name="stores_xbox_content_install">%1$s · Instalar</string> + <string name="stores_xbox_choose_content">Escolher conteúdo para PC</string> + <string name="stores_eyebrow_pass">disponível com %1$s</string> + <string name="stores_owned_in_library">Jogos comprados na Biblioteca</string> + <string name="library_filter_added">Adicionados</string> + <string name="game_tab_launch">Inicialização</string> + <string name="game_tab_saves_cloud">Salvamentos e nuvem</string> + <string name="game_launch_shortcut">Atalho do Steam</string> + <string name="game_install_version">Versão</string> + <string name="game_install_played">Jogado pela última vez</string> + <string name="game_install_access">Acesso</string> + <string name="game_install_access_owned">Comprado</string> + <string name="game_stream">Transmitir</string> + <string name="library_add">Adicionar à Biblioteca</string> + <string name="library_remove">Remover da Biblioteca</string> + <string name="library_in">Na Biblioteca</string> + <string name="xbox_filter_show">Mostrar</string> + <string name="xbox_filter_cloud">Nuvem</string> + <string name="stores_xbox_cloud_settings">Configurações da nuvem</string> </resources> diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index c0736cfe0..b8e3b4954 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -267,6 +267,24 @@ <string name="osc_steam_touch">Atingere Steam (beta)</string> <string name="osc_style_next_session">Comenzile tactile Steam pornesc odată cu următoarea sesiune Steam</string> <string name="osc_steam_layout">Aranjament tactil Steam</string> + <string name="screen_keys">Taste pe ecran</string> + <string name="screen_keys_add">Adaugă tastă</string> + <string name="screen_key_text">Text</string> + <string name="screen_key_size">Dimensiune</string> + <string name="screen_key_opacity">Opacitate</string> + <string name="screen_key_type_button">Tastă</string> + <string name="screen_key_shape_trigger">Trăgaci</string> + <string name="screen_key_toggle">Comutare</string> + <string name="screen_key_layout_export">Exportă</string> + <string name="screen_key_shape_pill">Capsulă</string> + <string name="screen_key_shape_round">Rotunjit</string> + <string name="screen_key_shape_circle">Cerc</string> + <string name="screen_key_shape_rect">Dreptunghi</string> + <string name="screen_key_bind">Tastă</string> + <string name="screen_key_image">Imagine</string> + <string name="screen_key_image_clear">Șterge</string> + <string name="screen_key_layout">Aranjament</string> + <string name="screen_key_layout_save">Salvează</string> <string name="osc_steam_layout_hint">Mută, redimensionează sau ascunde comenzile tactile ale acestui joc; salvate în Steam</string> <string name="mode_controller">Controller</string> <string name="mode_controller_hint">Cum vede Steam controllerul tău. Se aplică la sesiunea următoare.</string> @@ -287,9 +305,6 @@ <string name="mode_suspend_steam_note">Automat pune pe pauză și reia atunci când este afișată. Nativ pregătește Steam pentru repaus mai întâi. Manual așteaptă Reluare. Niciodată continuă să ruleze.</string> <string name="mode_background_downloads">Continuă descărcările</string> <string name="mode_background_downloads_hint">Întârzie suspendarea în fundal cât timp Steam descarcă activ.</string> - <string name="mode_zram">Compresie zRAM</string> - <string name="mode_zram_hint">În timpul jocului, memoria clientului Steam este comprimată în zram-ul dispozitivului, astfel încât jocul primește mai mult RAM. Se aplică la următoarea pornire a Steam.</string> - <string name="mode_zram_unsupported">Indisponibil: dispozitivul nu are zram sau swap, ori kernelul e mai vechi decât 5.4.</string> <string name="mode_suspend_note">Automat pune pe pauză în fundal și reia atunci când este afișată. Manual așteaptă Reluare. Niciodată continuă să ruleze.</string> <string name="mode_startup">Pornire</string> <string name="mode_steam_startup">Pornește Steam odată cu DroidDeck</string> @@ -546,8 +561,8 @@ <string name="perf_sysmem">Turnip: randare sysmem</string> <string name="perf_sysmem_hint">Mai rapidă pentru clientul Steam; în jocuri, driverul alege la fiecare trecere între randarea pe dale și cea în memoria sistemului. Necesară pe Adreno 710/720/722.</string> <string name="perf_xalia">Omite asistentul xalia al Steam</string> - <string name="perf_fastsync">droiddeck-ntsync (experimental)</string> - <string name="perf_fastsync_hint">Un substitut la nivel de utilizator pentru driverul Linux ntsync, pentru testare. Cât timp este activ, Proton îl folosește în locul droiddeck-esync și droiddeck-fsync. Funcționează cu Proton Experimental ARM64, GE-Proton și proton-cachyos. Se aplică la sesiunea următoare.</string> + <string name="perf_fastsync">droiddeck-ntsync</string> + <string name="perf_fastsync_hint">Un substitut la nivel de utilizator pentru driverul Linux ntsync. Cât timp este activ, Proton îl folosește în locul droiddeck-esync și droiddeck-fsync. Funcționează cu Proton Experimental ARM64, GE-Proton și proton-cachyos. Se aplică la sesiunea următoare.</string> <string name="perf_esync">droiddeck-esync</string> <string name="perf_fsync">droiddeck-fsync primul</string> <string name="perf_fsync_hint">Rulează propriul fsync al Proton pe fișierele Proton standard, cu futex_waitv furnizat la nivel de utilizator, deci nu are nevoie de pachet și continuă să funcționeze imediat ce Steam actualizează Proton. Cât timp este activ, jocurile îl folosesc înaintea droiddeck-esync, cu excepția celor puține pe care Proton le ține în afara fsync. Se aplică la sesiunea următoare.</string> @@ -1032,8 +1047,6 @@ <string name="games_empty">Jocurile pe care le instalezi în Steam apar aici după sesiune.</string> <string name="games_play_steam">Joacă Steam</string> <string name="games_play_steamvr">Joacă SteamVR</string> - <string name="games_launch_settings">Setări de lansare</string> - <string name="games_launch">Lansează</string> <string name="games_files">Fișierele jocului</string> <string name="games_prefix">Prefix Proton</string> <string name="games_saves_looking">Se caută salvările…</string> @@ -1041,7 +1054,6 @@ <string name="games_saves_folders">%1$d foldere de salvare</string> <string name="games_saves_summary">%1$s · %2$d fișiere · %3$s</string> <string name="games_kb">%1$d KB</string> - <string name="games_manage_saves">Gestionează salvările</string> <string name="games_saves">Salvările jocului</string> <string name="games_import_saves">Importă salvări…</string> <string name="games_export_gamehub">Exportă ca arhivă GameHub…</string> @@ -1053,7 +1065,6 @@ <string name="games_runtime_first_play">Mediul de execuție se instalează la prima apăsare pe Play</string> <string name="games_runtime_update">Actualizare disponibilă pentru mediul de execuție</string> <string name="games_runtime_ready">● Mediu de execuție pregătit</string> - <string name="games_last_played">Jucat ultima dată %1$s</string> <string name="games_internal">Stocare internă</string> <string name="games_added">Joc adăugat</string> <string name="games_custom">Joc personalizat</string> @@ -1122,7 +1133,7 @@ <string name="second_terminal">Terminal Linux</string> <string name="second_terminal_not_ready">Sesiunea Linux nu este încă pregătită pentru un terminal.</string> <string name="rail_steam">Steam</string> - <string name="rail_games">Jocuri</string> + <string name="rail_games">Bibliotecă</string> <string name="rail_desktop">Desktop</string> <string name="rail_store">Magazin</string> <string name="rail_components">Componente</string> @@ -1418,6 +1429,10 @@ <string name="theme_icon_blue_detail">Fundal negru, text alb, albastrul pentru tot ce este selectat, focalizat sau apăsat.</string> <string name="theme_electric">Bleumarin electric</string> <string name="theme_electric_detail">Albastrul mai strălucitor, pe suprafețe care tind spre bleumarin în loc de gri.</string> + <string name="theme_inner_gamer">Inner Gamer</string> + <string name="theme_inner_gamer_detail">Culori care parcurg curcubeul. Respectă setările de animație din Android.</string> + <string name="theme_rgb_speed">Viteză</string> + <string name="theme_rgb_speed_hint">Cât de repede se schimbă culorile.</string> <string name="theme_gradient">Gradient</string> <string name="theme_gradient_detail">Culorile tale primară și secundară, combinate pe butoane și evidențieri.</string> <string name="theme_gradient_primary">Primară</string> @@ -1544,6 +1559,13 @@ <string name="fm_conflict_skip">Omite</string> <string name="fm_select_this_folder">Selectează acest folder</string> <string name="fm_back">Înapoi</string> + <string name="fm_exit">Ieșire</string> + <string name="fm_return_to_start">Înapoi la dosarul inițial</string> + <string name="fm_start_unavailable">Dosarul inițial nu mai este disponibil</string> + <string name="fm_cancel_operation_title">Anulați operația?</string> + <string name="fm_cancel_operation_confirmation">Operația va fi anulată. Sunteți sigur?</string> + <string name="fm_accept">Acceptă</string> + <string name="fm_decline">Refuză</string> <string name="fm_drive_not_readable">%1$s este montat, dar nu poate fi citit acum</string> <string name="fm_list_view">Vizualizare listă</string> <string name="fm_grid_view">Vizualizare grilă</string> @@ -1960,7 +1982,6 @@ <string name="source_steam">Steam</string> <string name="source_added">Personalizat</string> <string name="setup_stores">Magazine</string> - <string name="setup_stores_open_on">Deschide un magazin pe</string> <string name="setup_stores_speed">Nivelul vitezei de descărcare</string> <string name="setup_stores_engine">Motorul magazinelor</string> <string name="setup_stores_engine_chip_ready">● Motor pregătit</string> @@ -1974,11 +1995,9 @@ <string name="stores_tabs_arrange">Aranjează filele</string> <string name="stores_tabs_arrange_done">Gata</string> <string name="stores_chip_signed_out">(neautentificat)</string> - <string name="stores_tab_store">Magazin</string> <string name="stores_tab_library">Bibliotecă</string> <string name="stores_tab_all">Toate</string> <string name="stores_tab_downloads">Descărcări</string> - <string name="stores_tab_count">%1$s (%2$d)</string> <string name="stores_signin">Autentificare</string> <string name="stores_signout_named">%1$s · deconectare</string> <string name="stores_signout_title">Te deconectezi de la %1$s?</string> @@ -1996,9 +2015,10 @@ <string name="stores_shelf_deals">Oferte</string> <string name="stores_shelf_free">Jocuri gratuite</string> <string name="stores_shelf_trending">În tendințe</string> - <string name="stores_shelf_library">Biblioteca ta</string> <string name="stores_play">Joacă</string> <string name="stores_install">Instalează</string> + <string name="bnet_open">Deschide Battle.net</string> + <string name="bnet_store">Jocuri Blizzard</string> <string name="stores_resume_install">Reia instalarea</string> <string name="stores_dl_interrupted">Întreruptă</string> <string name="stores_install_size">Instalare · %1$s</string> @@ -2017,8 +2037,32 @@ <string name="stores_itch_no_exe">Nu s-a găsit niciun joc Windows în descărcare</string> <string name="stores_itch_extract_failed">Descărcarea nu a putut fi extrasă (cod %1$d)</string> <string name="stores_itch_no_catalog">itch.io nu a răspuns</string> + <string name="stores_itch_sort_popular">Populare</string> + <string name="stores_itch_sort_new">Cele mai recente</string> + <string name="stores_itch_sort_new_popular">Noi și populare</string> + <string name="stores_itch_price_paid">Cu plată</string> + <string name="stores_itch_price_under_5">5 $ sau mai puțin</string> + <string name="stores_itch_price_under_15">15 $ sau mai puțin</string> + <string name="stores_itch_sort_top_rated">Cele mai bine cotate</string> + <string name="stores_itch_sort_top_sellers">Cele mai vândute</string> + <string name="stores_itch_price_any">Orice preț</string> + <string name="stores_itch_price_free">Gratuite</string> + <string name="stores_itch_price_sale">La reducere</string> + <string name="stores_itch_load_more">Încarcă mai multe</string> + <string name="stores_itch_platform_any">Orice platformă</string> + <string name="stores_itch_platform_mobile_web">Web mobil</string> + <string name="stores_itch_tag_add">Adaugă o etichetă</string> + <string name="stores_itch_tag_title">Filtrează după etichetă</string> + <string name="stores_itch_browse_failed">Nu s-au putut încărca jocurile de pe itch.io</string> <string name="stores_itch_archive">Arhivele %1$s nu sunt acceptate</string> <string name="stores_itch_download_failed">itch.io a răspuns HTTP %1$d</string> + <string name="stores_itch_pick_title">Alege o versiune</string> + <string name="stores_itch_pick_none">Nu s-au găsit descărcări pentru acest joc</string> + <string name="stores_itch_pick_failed">Descărcările de pe itch.io nu au putut fi încărcate</string> + <string name="stores_itch_build_demo">Demo</string> + <string name="stores_itch_build_open">Deschide pe itch.io</string> + <string name="stores_itch_build_linux_soon">Încă nu este acceptat</string> + <string name="stores_itch_no_game">Jocul nu a fost găsit pe itch.io</string> <string name="stores_free">Gratuit</string> <string name="stores_resume">Reluare</string> <string name="stores_refresh">Reîmprospătează</string> @@ -2171,6 +2215,7 @@ <string name="stores_login_close">Închide</string> <string name="stores_login_retry">Încearcă din nou</string> <string name="stores_login_finishing">Se finalizează autentificarea…</string> + <string name="stores_xbox_sign_in_again">Microsoft a respins conectarea la Xbox. Deconectează-te de la Xbox în Magazine, conectează-te din nou, apoi reia.</string> <string name="stores_login_error_network">Pagina de autentificare nu a putut fi încărcată: %1$s</string> <string name="stores_login_error_idp_blocked">Acest furnizor refuză autentificarea dintr-o pagină încorporată. Autentifică-te cu parola magazinului în schimb.</string> <string name="stores_login_error_http">Pagina de autentificare a răspuns cu o eroare (HTTP %1$d).</string> @@ -3016,4 +3061,57 @@ <string name="spatial_saved_views_hint">Vizualizările salvate pot fi recalculate. Începe o nouă calibrare pentru a captura în sistemul de coordonate de urmărire curent.</string> <string name="spatial_model_checksum_error">Nepotrivire a sumei de control a modelului de mâini</string> <string name="spatial_opencv_load_error">OpenCV nu s-a putut încărca</string> + <string name="setup_group_memory">Memorie</string> + <string name="setup_clear_cached">Închide aplicațiile din cache înainte de o sesiune</string> + <string name="setup_clear_cached_hint">La pornirea unei sesiuni, DroidDeck închide aplicațiile pe care Android le ține în fundal, ca jocul să aibă mai multă memorie. Depanarea wireless trebuie să fie activată.</string> + <string name="setup_clear_cached_unpaired">DroidDeck trebuie asociat prin Depanarea wireless (Asociază, mai jos).</string> + <string name="setup_memory_hud">Indicator de memorie</string> + <string name="setup_memory_hud_hint">Memoria și swap-ul folosite, pe ecranul sesiunii. Avansat le împarte între joc, DroidDeck, Android și GPU.</string> + <string name="setup_memory_hud_simple">Simplu</string> + <string name="setup_memory_hud_advanced">Avansat</string> + <string name="memory_hud_simple">RAM %1$s/%2$s GB · swap %3$s/%4$s GB</string> + <string name="memory_hud_split">joc %1$s · DroidDeck %2$s · Android %3$s GB</string> + <string name="memory_hud_gpu">GPU %1$s GB</string> + <string name="setup_bg_limit">Aplicații în fundal în timpul unei sesiuni</string> + <string name="setup_bg_limit_hint">Cât timp rulează o sesiune, Android păstrează în cache cel mult atâtea aplicații și le închide pe celelalte, ca jocul să primească memoria lor. DroidDeck readuce limita la valoarea anterioară când se termină sesiunea. Depanarea wireless trebuie să fie activată.</string> + <string name="setup_bg_limit_4">Cel mult 4</string> + <string name="setup_bg_limit_1">Cel mult 1</string> + <string name="setup_bg_limit_0">Niciuna</string> + <string name="setup_adb_pair">Depanare wireless</string> + <string name="setup_adb_pair_hint">Asociază DroidDeck cu Depanarea wireless din Android pentru opțiunile de mai sus. Limita proceselor-copil rămâne dezactivată.</string> + <string name="setup_adb_pair_button">Asociază</string> + <string name="epic_client_missing">Neinstalat</string> + <string name="epic_client_open">Deschide</string> + <string name="epic_client_install">Instalează</string> + <string name="game_tab_about">Despre</string> + <string name="game_tab_install">Instalează</string> + <string name="game_install_location">Locație</string> + <string name="game_install_folder">Folder</string> + <string name="game_install_size">Dimensiune pe disc</string> + <string name="game_install_date">Instalat</string> + <string name="stores_xbox_update">Actualizează / verifică</string> + <string name="stores_xbox_manage">Gestionează conținutul</string> + <string name="stores_xbox_close_first">Închide acest joc Xbox înainte de a-l actualiza.</string> + <string name="stores_xbox_content_title">Conținut Xbox</string> + <string name="stores_xbox_content_none">Nu există pachete de conținut PC licențiate pentru acest joc.</string> + <string name="stores_xbox_content_update">%1$s · Actualizează / verifică</string> + <string name="stores_xbox_content_install">%1$s · Instalează</string> + <string name="stores_xbox_choose_content">Alege conținutul PC</string> + <string name="stores_eyebrow_pass">disponibil cu %1$s</string> + <string name="stores_owned_in_library">Jocuri deținute în Bibliotecă</string> + <string name="library_filter_added">Adăugate</string> + <string name="game_tab_launch">Lansare</string> + <string name="game_tab_saves_cloud">Salvări și cloud</string> + <string name="game_launch_shortcut">Scurtătură Steam</string> + <string name="game_install_version">Versiune</string> + <string name="game_install_played">Jucat ultima dată</string> + <string name="game_install_access">Acces</string> + <string name="game_install_access_owned">Deținut</string> + <string name="game_stream">Redă în flux</string> + <string name="library_add">Adaugă în Bibliotecă</string> + <string name="library_remove">Elimină din Bibliotecă</string> + <string name="library_in">În Bibliotecă</string> + <string name="xbox_filter_show">Afișează</string> + <string name="xbox_filter_cloud">Cloud</string> + <string name="stores_xbox_cloud_settings">Setări cloud</string> </resources> diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 6e4145bb3..59e74118e 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -267,6 +267,24 @@ <string name="osc_steam_touch">Сенсорное управление Steam (бета)</string> <string name="osc_style_next_session">Сенсорное управление Steam включится со следующей сессии Steam</string> <string name="osc_steam_layout">Сенсорная раскладка Steam</string> + <string name="screen_keys">Экранные клавиши</string> + <string name="screen_keys_add">Добавить клавишу</string> + <string name="screen_key_text">Текст</string> + <string name="screen_key_size">Размер</string> + <string name="screen_key_opacity">Непрозрачность</string> + <string name="screen_key_type_button">Клавиша</string> + <string name="screen_key_shape_trigger">Триггер</string> + <string name="screen_key_toggle">Переключение</string> + <string name="screen_key_layout_export">Экспорт</string> + <string name="screen_key_shape_pill">Капсула</string> + <string name="screen_key_shape_round">Скругление</string> + <string name="screen_key_shape_circle">Круг</string> + <string name="screen_key_shape_rect">Прямоугольник</string> + <string name="screen_key_bind">Клавиша</string> + <string name="screen_key_image">Изображение</string> + <string name="screen_key_image_clear">Очистить</string> + <string name="screen_key_layout">Раскладка</string> + <string name="screen_key_layout_save">Сохранить</string> <string name="osc_steam_layout_hint">Перемещайте, меняйте размер или скрывайте сенсорные кнопки этой игры; сохраняется в Steam</string> <string name="mode_controller">Контроллер</string> <string name="mode_controller_hint">Как Steam распознает ваш контроллер. Вступит в силу в следующей сессии.</string> @@ -547,8 +565,8 @@ <string name="perf_sysmem">Turnip: рендеринг sysmem</string> <string name="perf_sysmem_hint">Клиент Steam будет быстрее; в играх драйвер для каждого прохода выбирает тайловый рендеринг или рендеринг в системной памяти. Требуется для Adreno 710/720/722.</string> <string name="perf_xalia">Пропустить помощник xalia в Steam</string> - <string name="perf_fastsync">droiddeck-ntsync (экспериментально)</string> - <string name="perf_fastsync_hint">Замена драйвера ntsync Linux в пространстве пользователя, для тестирования. Пока включено, Proton использует ее вместо droiddeck-esync и droiddeck-fsync. Работает с Proton Experimental ARM64, GE-Proton и proton-cachyos. Вступит в силу в следующей сессии.</string> + <string name="perf_fastsync">droiddeck-ntsync</string> + <string name="perf_fastsync_hint">Замена драйвера ntsync Linux в пространстве пользователя. Пока включено, Proton использует ее вместо droiddeck-esync и droiddeck-fsync. Работает с Proton Experimental ARM64, GE-Proton и proton-cachyos. Вступит в силу в следующей сессии.</string> <string name="perf_esync">droiddeck-esync</string> <string name="perf_fsync">droiddeck-fsync в приоритете</string> <string name="perf_fsync_hint">Запускает собственный fsync Proton на стандартных файлах Proton, а futex_waitv предоставляется в пространстве пользователя: пакет не нужен, и все продолжает работать сразу после обновления Proton в Steam. Пока включено, игры используют его раньше droiddeck-esync, кроме тех немногих, для которых Proton отключает fsync. Вступит в силу в следующей сессии.</string> @@ -1037,8 +1055,6 @@ <string name="games_empty">Игры, установленные в Steam, появятся здесь после завершения сессии.</string> <string name="games_play_steam">Запустить Steam</string> <string name="games_play_steamvr">Запустить SteamVR</string> - <string name="games_launch_settings">Настройки запуска</string> - <string name="games_launch">Запустить</string> <string name="games_files">Файлы игры</string> <string name="games_prefix">Префикс Proton</string> <string name="games_saves_looking">Поиск сохранений…</string> @@ -1046,7 +1062,6 @@ <string name="games_saves_folders">Папок сохранений: %1$d</string> <string name="games_saves_summary">%1$s · файлов: %2$d · %3$s</string> <string name="games_kb">%1$d КБ</string> - <string name="games_manage_saves">Управление сохранениями</string> <string name="games_saves">Сохранения игры</string> <string name="games_import_saves">Импорт сохранений…</string> <string name="games_export_gamehub">Экспорт как архив GameHub…</string> @@ -1058,7 +1073,6 @@ <string name="games_runtime_first_play">Среда выполнения установится при первом запуске игры</string> <string name="games_runtime_update">Доступно обновление среды выполнения</string> <string name="games_runtime_ready">● Среда выполнения готова</string> - <string name="games_last_played">Последний запуск %1$s</string> <string name="games_internal">Внутреннее хранилище</string> <string name="games_added">Добавленная игра</string> <string name="games_custom">Своя игра</string> @@ -1127,7 +1141,7 @@ <string name="second_terminal">Терминал Linux</string> <string name="second_terminal_not_ready">Сессия Linux не готова, терминал недоступен.</string> <string name="rail_steam">Steam</string> - <string name="rail_games">Игры</string> + <string name="rail_games">Библиотека</string> <string name="rail_desktop">Рабочий стол</string> <string name="rail_store">Магазин</string> <string name="rail_components">Компоненты</string> @@ -1431,6 +1445,10 @@ <string name="theme_icon_blue_detail">Черный фон, белый текст, синий — для всего выбранного, активного или нажатого.</string> <string name="theme_electric">Электрик</string> <string name="theme_electric_detail">Более яркий синий на поверхностях темно-синего, а не серого оттенка.</string> + <string name="theme_inner_gamer">Inner Gamer</string> + <string name="theme_inner_gamer_detail">Цвета, плавно переходящие по всей радуге. Учитывает настройки анимации Android.</string> + <string name="theme_rgb_speed">Скорость</string> + <string name="theme_rgb_speed_hint">Как быстро меняются цвета.</string> <string name="theme_gradient">Градиент</string> <string name="theme_gradient_detail">Ваши основной и дополнительный цвета, плавно переходящие на кнопках и выделениях.</string> <string name="theme_gradient_primary">Основной</string> @@ -1562,6 +1580,13 @@ <string name="fm_conflict_skip">Пропустить</string> <string name="fm_select_this_folder">Выбрать эту папку</string> <string name="fm_back">Назад</string> + <string name="fm_exit">Выход</string> + <string name="fm_return_to_start">Вернуться в начальную папку</string> + <string name="fm_start_unavailable">Начальная папка больше недоступна</string> + <string name="fm_cancel_operation_title">Отменить операцию?</string> + <string name="fm_cancel_operation_confirmation">Операция будет отменена. Вы уверены?</string> + <string name="fm_accept">Принять</string> + <string name="fm_decline">Отклонить</string> <string name="fm_drive_not_readable">%1$s: подключено, но сейчас недоступно для чтения</string> <string name="fm_list_view">Список</string> <string name="fm_grid_view">Сетка</string> @@ -1980,7 +2005,6 @@ <string name="source_steam">Steam</string> <string name="source_added">Свои</string> <string name="setup_stores">Магазины</string> - <string name="setup_stores_open_on">Открывать магазин на</string> <string name="setup_stores_speed">Уровень скорости загрузки</string> <string name="setup_stores_engine">Движок магазинов</string> <string name="setup_stores_engine_chip_ready">● Движок готов</string> @@ -1994,11 +2018,9 @@ <string name="stores_tabs_arrange">Упорядочить вкладки</string> <string name="stores_tabs_arrange_done">Готово</string> <string name="stores_chip_signed_out">(вход не выполнен)</string> - <string name="stores_tab_store">Магазин</string> <string name="stores_tab_library">Библиотека</string> <string name="stores_tab_all">Все</string> <string name="stores_tab_downloads">Загрузки</string> - <string name="stores_tab_count">%1$s (%2$d)</string> <string name="stores_signin">Войти</string> <string name="stores_signout_named">%1$s · выйти</string> <string name="stores_signout_title">Выйти из %1$s?</string> @@ -2016,9 +2038,10 @@ <string name="stores_shelf_deals">Скидки</string> <string name="stores_shelf_free">Бесплатные игры</string> <string name="stores_shelf_trending">Популярное</string> - <string name="stores_shelf_library">Ваша библиотека</string> <string name="stores_play">Играть</string> <string name="stores_install">Установить</string> + <string name="bnet_open">Открыть Battle.net</string> + <string name="bnet_store">Игры Blizzard</string> <string name="stores_resume_install">Продолжить установку</string> <string name="stores_dl_interrupted">Прервано</string> <string name="stores_install_size">Установить · %1$s</string> @@ -2037,8 +2060,32 @@ <string name="stores_itch_no_exe">В загрузке не найдена игра для Windows</string> <string name="stores_itch_extract_failed">Не удалось распаковать загрузку (код %1$d)</string> <string name="stores_itch_no_catalog">itch.io не ответил</string> + <string name="stores_itch_sort_popular">Популярные</string> + <string name="stores_itch_sort_new">Самые новые</string> + <string name="stores_itch_sort_new_popular">Новые и популярные</string> + <string name="stores_itch_price_paid">Платные</string> + <string name="stores_itch_price_under_5">До $5</string> + <string name="stores_itch_price_under_15">До $15</string> + <string name="stores_itch_sort_top_rated">С лучшими оценками</string> + <string name="stores_itch_sort_top_sellers">Бестселлеры</string> + <string name="stores_itch_price_any">Любая цена</string> + <string name="stores_itch_price_free">Бесплатные</string> + <string name="stores_itch_price_sale">Со скидкой</string> + <string name="stores_itch_load_more">Загрузить ещё</string> + <string name="stores_itch_platform_any">Любая платформа</string> + <string name="stores_itch_platform_mobile_web">Мобильный веб</string> + <string name="stores_itch_tag_add">Добавить тег</string> + <string name="stores_itch_tag_title">Фильтр по тегу</string> + <string name="stores_itch_browse_failed">Не удалось загрузить игры с itch.io</string> <string name="stores_itch_archive">Архивы %1$s не поддерживаются</string> <string name="stores_itch_download_failed">itch.io ответил HTTP %1$d</string> + <string name="stores_itch_pick_title">Выберите версию</string> + <string name="stores_itch_pick_none">Для этой игры не найдено загрузок</string> + <string name="stores_itch_pick_failed">Не удалось загрузить список файлов с itch.io</string> + <string name="stores_itch_build_demo">Демо</string> + <string name="stores_itch_build_open">Открыть на itch.io</string> + <string name="stores_itch_build_linux_soon">Пока не поддерживается</string> + <string name="stores_itch_no_game">Не удалось найти эту игру на itch.io</string> <string name="stores_free">Бесплатно</string> <string name="stores_resume">Продолжить</string> <string name="stores_refresh">Обновить</string> @@ -2188,6 +2235,7 @@ <string name="stores_login_close">Закрыть</string> <string name="stores_login_retry">Повторить</string> <string name="stores_login_finishing">Завершение входа…</string> + <string name="stores_xbox_sign_in_again">Microsoft отклонила вход в Xbox. Выйдите из Xbox в разделе «Магазины», войдите снова и продолжите.</string> <string name="stores_login_error_network">Не удалось загрузить страницу входа: %1$s</string> <string name="stores_login_error_idp_blocked">Этот сервис не разрешает вход со встроенной страницы. Войдите с паролем от магазина.</string> <string name="stores_login_error_http">Страница входа вернула ошибку (HTTP %1$d).</string> @@ -2589,9 +2637,6 @@ <string name="drawer_component_downloads">Скачать компоненты</string> <string name="drawer_component_downloads_hint">Загрузки общие со страницей «Компоненты». Выберите выше скачанную версию, чтобы использовать её при следующем запуске игры.</string> <string name="drawer_component_choose_download">Выберите версию для скачивания</string> - <string name="mode_zram">Сжатие zRAM</string> - <string name="mode_zram_hint">Пока идёт игра, память клиента Steam сжимается в zram устройства, и игре остаётся больше ОЗУ. Применяется при следующем запуске Steam.</string> - <string name="mode_zram_unsupported">Недоступно: на устройстве нет zram или swap, либо его ядро старше 5.4.</string> <string name="stores_storage_insufficient">Недостаточно свободного места: нужно %1$s, свободно только %2$s</string> <string name="stores_storage_low">Недостаточно места (свободно %1$s, нужно %2$s)</string> <string name="stores_target_free_unknown">Свободное место неизвестно</string> @@ -3052,4 +3097,57 @@ <string name="spatial_gpu">Распознавание рук на GPU</string> <string name="spatial_gpu_hint">По умолчанию используется CPU. Режим GPU экспериментальный и может вызывать перезапуск SteamVR. Выбор сбрасывается при перезапуске отслеживания.</string> <string name="spatial_inference_error">Распознавание рук недоступно: %1$s. Измените режим CPU/GPU или выключите и снова включите управление руками, чтобы повторить попытку.</string> + <string name="setup_group_memory">Память</string> + <string name="setup_clear_cached">Закрывать фоновые приложения перед сеансом</string> + <string name="setup_clear_cached_hint">При запуске сеанса закрывает приложения, которые Android держит в фоне, чтобы игре досталось больше памяти. Отладка по Wi-Fi должна быть включена.</string> + <string name="setup_clear_cached_unpaired">DroidDeck должен быть сопряжён через отладку по Wi-Fi (кнопка «Сопрячь» ниже).</string> + <string name="setup_memory_hud">Индикатор памяти</string> + <string name="setup_memory_hud_hint">Используемая память и подкачка на экране сеанса. «Дополнительно» показывает отдельно игру, DroidDeck, Android и GPU.</string> + <string name="setup_memory_hud_simple">Простой</string> + <string name="setup_memory_hud_advanced">Дополнительно</string> + <string name="memory_hud_simple">ОЗУ %1$s/%2$s ГБ · подкачка %3$s/%4$s ГБ</string> + <string name="memory_hud_split">игра %1$s · DroidDeck %2$s · Android %3$s ГБ</string> + <string name="memory_hud_gpu">GPU %1$s ГБ</string> + <string name="setup_bg_limit">Фоновые приложения во время сеанса</string> + <string name="setup_bg_limit_hint">Пока идёт сеанс, Android хранит в кэше не больше этого числа приложений и закрывает остальные, чтобы их память досталась игре. После сеанса всё возвращается как было. Отладка по Wi-Fi должна быть включена.</string> + <string name="setup_bg_limit_4">Не больше 4</string> + <string name="setup_bg_limit_1">Не больше 1</string> + <string name="setup_bg_limit_0">Нет</string> + <string name="setup_adb_pair">Отладка по Wi-Fi</string> + <string name="setup_adb_pair_hint">Сопрягает DroidDeck с отладкой Android по Wi-Fi для параметров выше. Ограничение дочерних процессов остаётся отключённым.</string> + <string name="setup_adb_pair_button">Сопрячь</string> + <string name="epic_client_missing">Не установлено</string> + <string name="epic_client_open">Открыть</string> + <string name="epic_client_install">Установить</string> + <string name="game_tab_about">О приложении</string> + <string name="game_tab_install">Установить</string> + <string name="game_install_location">Расположение</string> + <string name="game_install_folder">Папка</string> + <string name="game_install_size">Размер на диске</string> + <string name="game_install_date">Установлен</string> + <string name="stores_xbox_update">Обновить / проверить</string> + <string name="stores_xbox_manage">Управление контентом</string> + <string name="stores_xbox_close_first">Закройте эту игру Xbox перед обновлением.</string> + <string name="stores_xbox_content_title">Контент Xbox</string> + <string name="stores_xbox_content_none">Для этой игры нет лицензированных пакетов контента для ПК.</string> + <string name="stores_xbox_content_update">%1$s · Обновить / проверить</string> + <string name="stores_xbox_content_install">%1$s · Установить</string> + <string name="stores_xbox_choose_content">Выберите контент для ПК</string> + <string name="stores_eyebrow_pass">доступно с %1$s</string> + <string name="stores_owned_in_library">Ваши игры в библиотеке</string> + <string name="library_filter_added">Добавленные</string> + <string name="game_tab_launch">Запуск</string> + <string name="game_tab_saves_cloud">Сохранения и облако</string> + <string name="game_launch_shortcut">Ярлык Steam</string> + <string name="game_install_version">Версия</string> + <string name="game_install_played">Последний запуск</string> + <string name="game_install_access">Доступ</string> + <string name="game_install_access_owned">Куплена</string> + <string name="game_stream">Стриминг</string> + <string name="library_add">Добавить в библиотеку</string> + <string name="library_remove">Убрать из библиотеки</string> + <string name="library_in">В библиотеке</string> + <string name="xbox_filter_show">Показать</string> + <string name="xbox_filter_cloud">Облако</string> + <string name="stores_xbox_cloud_settings">Настройки облака</string> </resources> diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 1da71dc1c..2c62e8b13 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -267,6 +267,24 @@ <string name="osc_steam_touch">Steam-pekstyrning (beta)</string> <string name="osc_style_next_session">Steams pekstyrning startar med nästa Steam-session</string> <string name="osc_steam_layout">Steams peklayout</string> + <string name="screen_keys">Skärmtangenter</string> + <string name="screen_keys_add">Lägg till tangent</string> + <string name="screen_key_text">Text</string> + <string name="screen_key_size">Storlek</string> + <string name="screen_key_opacity">Opacitet</string> + <string name="screen_key_type_button">Tangent</string> + <string name="screen_key_shape_trigger">Avtryckare</string> + <string name="screen_key_toggle">Växla</string> + <string name="screen_key_layout_export">Exportera</string> + <string name="screen_key_shape_pill">Kapsel</string> + <string name="screen_key_shape_round">Rundad</string> + <string name="screen_key_shape_circle">Cirkel</string> + <string name="screen_key_shape_rect">Rektangel</string> + <string name="screen_key_bind">Tangent</string> + <string name="screen_key_image">Bild</string> + <string name="screen_key_image_clear">Rensa</string> + <string name="screen_key_layout">Layout</string> + <string name="screen_key_layout_save">Spara</string> <string name="osc_steam_layout_hint">Flytta, ändra storlek på eller dölj spelets pekkontroller; sparas i Steam</string> <string name="mode_controller">Handkontroll</string> <string name="mode_controller_hint">Hur Steam ser din handkontroll. Gäller från nästa session.</string> @@ -287,9 +305,6 @@ <string name="mode_suspend_steam_note">Auto pausar och återupptar när den visas. Inbyggd förbereder Steam för vila först. Manuell väntar på Återuppta. Aldrig fortsätter köra.</string> <string name="mode_background_downloads">Håll nedladdningar igång</string> <string name="mode_background_downloads_hint">Skjut upp bakgrundspausning medan Steam aktivt laddar ned.</string> - <string name="mode_zram">zRAM-komprimering</string> - <string name="mode_zram_hint">Medan ett spel körs komprimeras Steam-klientens minne till enhetens zram så att spelet får mer RAM. Gäller nästa gång Steam startar.</string> - <string name="mode_zram_unsupported">Inte tillgängligt: enheten har ingen zram eller swap, eller så är kärnan äldre än 5.4.</string> <string name="mode_suspend_note">Auto pausar i bakgrunden och återupptar när den visas. Manuell väntar på Återuppta. Aldrig fortsätter köra.</string> <string name="mode_startup">Uppstart</string> <string name="mode_steam_startup">Starta Steam med DroidDeck</string> @@ -544,8 +559,8 @@ <string name="perf_sysmem">Turnip: sysmem-rendering</string> <string name="perf_sysmem_hint">Snabbare för Steam-klienten; i spel väljer drivrutinen kakel- eller systemminnesrendering för varje pass. Krävs på Adreno 710/720/722.</string> <string name="perf_xalia">Hoppa över Steams xalia-hjälp</string> - <string name="perf_fastsync">droiddeck-ntsync (experimentell)</string> - <string name="perf_fastsync_hint">En ersättning i användarutrymmet för Linux ntsync-drivrutinen, för testning. Medan det är på använder Proton det i stället för droiddeck-esync och droiddeck-fsync. Fungerar med Proton Experimental ARM64, GE-Proton och proton-cachyos. Gäller från nästa session.</string> + <string name="perf_fastsync">droiddeck-ntsync</string> + <string name="perf_fastsync_hint">En ersättning i användarutrymmet för Linux ntsync-drivrutinen. Medan det är på använder Proton det i stället för droiddeck-esync och droiddeck-fsync. Fungerar med Proton Experimental ARM64, GE-Proton och proton-cachyos. Gäller från nästa session.</string> <string name="perf_esync">droiddeck-esync</string> <string name="perf_fsync">droiddeck-fsync först</string> <string name="perf_fsync_hint">Kör Protons egen fsync på de ursprungliga Proton-filerna, med futex_waitv tillhandahållen i användarutrymmet, så det behöver inget paket och fortsätter fungera så snart Steam uppdaterar Proton. Medan det är på använder spel det före droiddeck-esync, förutom de få som Proton håller borta från fsync. Gäller från nästa session.</string> @@ -1026,8 +1041,6 @@ <string name="games_empty">Spel du installerar i Steam visas här efter sessionen.</string> <string name="games_play_steam">Spela Steam</string> <string name="games_play_steamvr">Spela SteamVR</string> - <string name="games_launch_settings">Startinställningar</string> - <string name="games_launch">Starta</string> <string name="games_files">Spelfiler</string> <string name="games_prefix">Proton-prefix</string> <string name="games_saves_looking">Letar efter sparfiler…</string> @@ -1035,7 +1048,6 @@ <string name="games_saves_folders">%1$d sparmappar</string> <string name="games_saves_summary">%1$s · %2$d filer · %3$s</string> <string name="games_kb">%1$d KB</string> - <string name="games_manage_saves">Hantera sparfiler</string> <string name="games_saves">Sparfiler</string> <string name="games_import_saves">Importera sparfiler…</string> <string name="games_export_gamehub">Exportera som GameHub-zip…</string> @@ -1047,7 +1059,6 @@ <string name="games_runtime_first_play">Körmiljön installeras första gången du trycker på Play</string> <string name="games_runtime_update">Uppdatering av körmiljön tillgänglig</string> <string name="games_runtime_ready">● Körmiljö redo</string> - <string name="games_last_played">Senast spelad %1$s</string> <string name="games_internal">Internt lagringsutrymme</string> <string name="games_added">Tillagt spel</string> <string name="games_custom">Anpassat spel</string> @@ -1116,7 +1127,7 @@ <string name="second_terminal">Linux-terminal</string> <string name="second_terminal_not_ready">Linux-sessionen är inte redo för en terminal än.</string> <string name="rail_steam">Steam</string> - <string name="rail_games">Spel</string> + <string name="rail_games">Bibliotek</string> <string name="rail_desktop">Skrivbord</string> <string name="rail_store">Butik</string> <string name="rail_components">Komponenter</string> @@ -1404,6 +1415,10 @@ <string name="theme_icon_blue_detail">Svart bakgrund, vit text, den blå färgen för allt som är markerat, i fokus eller nedtryckt.</string> <string name="theme_electric">Elektrisk marinblå</string> <string name="theme_electric_detail">Den blå färgen klarare, på ytor som lutar åt marinblått i stället för grått.</string> + <string name="theme_inner_gamer">Inner Gamer</string> + <string name="theme_inner_gamer_detail">Färger som växlar genom regnbågen. Följer Androids animationsinställningar.</string> + <string name="theme_rgb_speed">Hastighet</string> + <string name="theme_rgb_speed_hint">Hur snabbt färgerna växlar.</string> <string name="theme_gradient">Gradient</string> <string name="theme_gradient_detail">Dina egna primära och sekundära färger, blandade på knappar och markeringar.</string> <string name="theme_gradient_primary">Primär</string> @@ -1525,6 +1540,13 @@ <string name="fm_conflict_skip">Hoppa över</string> <string name="fm_select_this_folder">Välj den här mappen</string> <string name="fm_back">Tillbaka</string> + <string name="fm_exit">Avsluta</string> + <string name="fm_return_to_start">Tillbaka till startmappen</string> + <string name="fm_start_unavailable">Startmappen är inte längre tillgänglig</string> + <string name="fm_cancel_operation_title">Avbryt åtgärden?</string> + <string name="fm_cancel_operation_confirmation">Åtgärden avbryts. Är du säker?</string> + <string name="fm_accept">Acceptera</string> + <string name="fm_decline">Avböj</string> <string name="fm_drive_not_readable">%1$s är monterad men inte läsbar just nu</string> <string name="fm_list_view">Listvy</string> <string name="fm_grid_view">Rutnätsvy</string> @@ -1939,7 +1961,6 @@ <string name="source_steam">Steam</string> <string name="source_added">Egna</string> <string name="setup_stores">Butiker</string> - <string name="setup_stores_open_on">Öppna en butik på</string> <string name="setup_stores_speed">Nedladdningshastighet</string> <string name="setup_stores_engine">Butiksmotor</string> <string name="setup_stores_engine_chip_ready">● Motor redo</string> @@ -1953,11 +1974,9 @@ <string name="stores_tabs_arrange">Ordna flikar</string> <string name="stores_tabs_arrange_done">Klart</string> <string name="stores_chip_signed_out">(inte inloggad)</string> - <string name="stores_tab_store">Butik</string> <string name="stores_tab_library">Bibliotek</string> <string name="stores_tab_all">Alla</string> <string name="stores_tab_downloads">Nedladdningar</string> - <string name="stores_tab_count">%1$s (%2$d)</string> <string name="stores_signin">Logga in</string> <string name="stores_signout_named">%1$s · logga ut</string> <string name="stores_signout_title">Logga ut från %1$s?</string> @@ -1975,9 +1994,10 @@ <string name="stores_shelf_deals">Erbjudanden</string> <string name="stores_shelf_free">Gratisspel</string> <string name="stores_shelf_trending">Populärt nu</string> - <string name="stores_shelf_library">Ditt bibliotek</string> <string name="stores_play">Spela</string> <string name="stores_install">Installera</string> + <string name="bnet_open">Öppna Battle.net</string> + <string name="bnet_store">Blizzard-spel</string> <string name="stores_resume_install">Återuppta installation</string> <string name="stores_dl_interrupted">Avbruten</string> <string name="stores_install_size">Installation · %1$s</string> @@ -1996,8 +2016,32 @@ <string name="stores_itch_no_exe">Inget Windows-spel hittades i nedladdningen</string> <string name="stores_itch_extract_failed">Det gick inte att packa upp nedladdningen (kod %1$d)</string> <string name="stores_itch_no_catalog">itch.io svarade inte</string> + <string name="stores_itch_sort_popular">Populära</string> + <string name="stores_itch_sort_new">Senaste</string> + <string name="stores_itch_sort_new_popular">Nya och populära</string> + <string name="stores_itch_price_paid">Betalda</string> + <string name="stores_itch_price_under_5">$5 eller mindre</string> + <string name="stores_itch_price_under_15">$15 eller mindre</string> + <string name="stores_itch_sort_top_rated">Högst betyg</string> + <string name="stores_itch_sort_top_sellers">Mest sålda</string> + <string name="stores_itch_price_any">Alla priser</string> + <string name="stores_itch_price_free">Gratis</string> + <string name="stores_itch_price_sale">På rea</string> + <string name="stores_itch_load_more">Ladda fler</string> + <string name="stores_itch_platform_any">Alla plattformar</string> + <string name="stores_itch_platform_mobile_web">Mobilwebb</string> + <string name="stores_itch_tag_add">Lägg till en tagg</string> + <string name="stores_itch_tag_title">Filtrera efter tagg</string> + <string name="stores_itch_browse_failed">Det gick inte att läsa in spel från itch.io</string> <string name="stores_itch_archive">%1$s-arkiv stöds inte</string> <string name="stores_itch_download_failed">itch.io svarade HTTP %1$d</string> + <string name="stores_itch_pick_title">Välj en version</string> + <string name="stores_itch_pick_none">Inga nedladdningar hittades för det här spelet</string> + <string name="stores_itch_pick_failed">Det gick inte att läsa in nedladdningarna från itch.io</string> + <string name="stores_itch_build_demo">Demo</string> + <string name="stores_itch_build_open">Öppna på itch.io</string> + <string name="stores_itch_build_linux_soon">Stöds inte ännu</string> + <string name="stores_itch_no_game">Spelet hittades inte på itch.io</string> <string name="stores_free">Gratis</string> <string name="stores_resume">Återuppta</string> <string name="stores_refresh">Uppdatera</string> @@ -2150,6 +2194,7 @@ <string name="stores_login_close">Stäng</string> <string name="stores_login_retry">Försök igen</string> <string name="stores_login_finishing">Slutför inloggningen…</string> + <string name="stores_xbox_sign_in_again">Microsoft avvisade Xbox-inloggningen. Logga ut från Xbox under Butiker, logga in igen och återuppta sedan.</string> <string name="stores_login_error_network">Inloggningssidan kunde inte läsas in: %1$s</string> <string name="stores_login_error_idp_blocked">Den här leverantören vägrar logga in från en inbäddad sida. Logga in med ditt butikslösenord istället.</string> <string name="stores_login_error_http">Inloggningssidan svarade med ett fel (HTTP %1$d).</string> @@ -2981,5 +3026,58 @@ <string name="spatial_saved_views_hint">Sparade vyer kan räknas om. Starta en ny kalibrering för att fånga i det aktuella spårningskoordinatsystemet.</string> <string name="spatial_model_checksum_error">Kontrollsumman för handmodellen inte överensstämmer</string> <string name="spatial_opencv_load_error">OpenCV kunde inte laddas</string> + <string name="setup_group_memory">Minne</string> + <string name="setup_clear_cached">Stäng cachade appar före en session</string> + <string name="setup_clear_cached_hint">När en session startar stänger DroidDeck apparna som Android håller parkerade i bakgrunden, så att spelet får mer minne. Trådlös felsökning måste vara på.</string> + <string name="setup_clear_cached_unpaired">Kräver att DroidDeck är ihopparat via Trådlös felsökning (Para ihop, nedan).</string> + <string name="setup_memory_hud">Minnesvisning</string> + <string name="setup_memory_hud_hint">Minne och swap som används, på sessionsskärmen. Avancerat delar upp det på spelet, DroidDeck, Android och GPU:n.</string> + <string name="setup_memory_hud_simple">Enkel</string> + <string name="setup_memory_hud_advanced">Avancerat</string> + <string name="memory_hud_simple">RAM %1$s/%2$s GB · swap %3$s/%4$s GB</string> + <string name="memory_hud_split">spel %1$s · DroidDeck %2$s · Android %3$s GB</string> + <string name="memory_hud_gpu">GPU %1$s GB</string> + <string name="setup_bg_limit">Bakgrundsappar under en session</string> + <string name="setup_bg_limit_hint">Medan en session pågår håller Android högst så här många appar cachade och stänger resten, så att spelet får deras minne. DroidDeck återställer gränsen som den var när sessionen slutar. Trådlös felsökning måste vara på.</string> + <string name="setup_bg_limit_4">Högst 4</string> + <string name="setup_bg_limit_1">Högst 1</string> + <string name="setup_bg_limit_0">Ingen</string> + <string name="setup_adb_pair">Trådlös felsökning</string> + <string name="setup_adb_pair_hint">Parar ihop DroidDeck med Androids Trådlös felsökning för alternativen ovan. Gränsen för underprocesser förblir av.</string> + <string name="setup_adb_pair_button">Para ihop</string> <string name="glasses_6dof">Virtuell skärm med 6DoF</string> + <string name="epic_client_missing">Inte installerad</string> + <string name="epic_client_open">Öppna</string> + <string name="epic_client_install">Installera</string> + <string name="game_tab_about">Om</string> + <string name="game_tab_install">Installera</string> + <string name="game_install_location">Plats</string> + <string name="game_install_folder">Mapp</string> + <string name="game_install_size">Storlek på disk</string> + <string name="game_install_date">Installerad</string> + <string name="stores_xbox_update">Uppdatera / verifiera</string> + <string name="stores_xbox_manage">Hantera innehåll</string> + <string name="stores_xbox_close_first">Stäng det här Xbox-spelet innan du uppdaterar det.</string> + <string name="stores_xbox_content_title">Xbox-innehåll</string> + <string name="stores_xbox_content_none">Det finns inga licensierade PC-innehållspaket för det här spelet.</string> + <string name="stores_xbox_content_update">%1$s · Uppdatera / verifiera</string> + <string name="stores_xbox_content_install">%1$s · Installera</string> + <string name="stores_xbox_choose_content">Välj PC-innehåll</string> + <string name="stores_eyebrow_pass">tillgängligt med %1$s</string> + <string name="stores_owned_in_library">Ägda spel i Bibliotek</string> + <string name="library_filter_added">Tillagda</string> + <string name="game_tab_launch">Start</string> + <string name="game_tab_saves_cloud">Sparade spel och moln</string> + <string name="game_launch_shortcut">Steam-genväg</string> + <string name="game_install_version">Version</string> + <string name="game_install_played">Senast spelat</string> + <string name="game_install_access">Åtkomst</string> + <string name="game_install_access_owned">Ägt</string> + <string name="game_stream">Strömma</string> + <string name="library_add">Lägg till i Bibliotek</string> + <string name="library_remove">Ta bort från Bibliotek</string> + <string name="library_in">I Bibliotek</string> + <string name="xbox_filter_show">Visa</string> + <string name="xbox_filter_cloud">Moln</string> + <string name="stores_xbox_cloud_settings">Molninställningar</string> </resources> diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 75dc4f572..38337b0fd 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -267,6 +267,24 @@ <string name="osc_steam_touch">การสัมผัสของ Steam (ทดลอง)</string> <string name="osc_style_next_session">ปุ่มสัมผัสของ Steam จะเริ่มใช้งานในเซสชัน Steam ถัดไป</string> <string name="osc_steam_layout">รูปแบบปุ่มสัมผัสของ Steam</string> + <string name="screen_keys">ปุ่มบนหน้าจอ</string> + <string name="screen_keys_add">เพิ่มปุ่ม</string> + <string name="screen_key_text">ข้อความ</string> + <string name="screen_key_size">ขนาด</string> + <string name="screen_key_opacity">ความโปร่งใส</string> + <string name="screen_key_type_button">ปุ่ม</string> + <string name="screen_key_shape_trigger">ไก</string> + <string name="screen_key_toggle">สลับ</string> + <string name="screen_key_layout_export">ส่งออก</string> + <string name="screen_key_shape_pill">แคปซูล</string> + <string name="screen_key_shape_round">มน</string> + <string name="screen_key_shape_circle">วงกลม</string> + <string name="screen_key_shape_rect">สี่เหลี่ยม</string> + <string name="screen_key_bind">ปุ่ม</string> + <string name="screen_key_image">รูปภาพ</string> + <string name="screen_key_image_clear">ล้าง</string> + <string name="screen_key_layout">การจัดวาง</string> + <string name="screen_key_layout_save">บันทึก</string> <string name="osc_steam_layout_hint">ย้าย ปรับขนาด หรือซ่อนปุ่มสัมผัสของเกมนี้ บันทึกไปยัง Steam</string> <string name="mode_controller">คอนโทรลเลอร์</string> <string name="mode_controller_hint">วิธีที่ Steam มองเห็นคอนโทรลเลอร์ของคุณ จะมีผลในเซสชันถัดไป</string> @@ -287,9 +305,6 @@ <string name="mode_suspend_steam_note">อัตโนมัติจะหยุดชั่วคราวและดำเนินการต่อเมื่อแสดงผล ดั้งเดิมเตรียม Steam เข้าสู่โหมดพักก่อน กำหนดเองรอการกดดำเนินการต่อ ไม่มีวันทำงานต่อเนื่องตลอด</string> <string name="mode_background_downloads">ให้การดาวน์โหลดทำงานต่อไป</string> <string name="mode_background_downloads_hint">เลื่อนการหยุดพื้นหลังชั่วคราวออกไปขณะ Steam กำลังดาวน์โหลดอยู่</string> - <string name="mode_zram">การบีบอัด zRAM</string> - <string name="mode_zram_hint">ระหว่างเล่นเกม หน่วยความจำของไคลเอนต์ Steam จะถูกบีบอัดลง zram ของอุปกรณ์ ทำให้เกมได้ RAM มากขึ้น มีผลเมื่อเริ่ม Steam ครั้งถัดไป</string> - <string name="mode_zram_unsupported">ใช้ไม่ได้: อุปกรณ์นี้ไม่มี zram หรือ swap หรือเคอร์เนลเก่ากว่า 5.4</string> <string name="mode_suspend_note">อัตโนมัติจะหยุดชั่วคราวเมื่ออยู่ในพื้นหลังและดำเนินการต่อเมื่อแสดงผล กำหนดเองรอการกดดำเนินการต่อ ไม่มีวันทำงานต่อเนื่องตลอด</string> <string name="mode_startup">การเริ่มทำงาน</string> <string name="mode_steam_startup">เริ่ม Steam พร้อมกับ DroidDeck</string> @@ -542,8 +557,8 @@ <string name="perf_sysmem">Turnip: การเรนเดอร์ sysmem</string> <string name="perf_sysmem_hint">เร็วขึ้นสำหรับไคลเอนต์ Steam ในเกมไดรเวอร์จะเลือกการเรนเดอร์แบบไทล์หรือในหน่วยความจำระบบในแต่ละพาส จำเป็นสำหรับ Adreno 710/720/722</string> <string name="perf_xalia">ข้ามตัวช่วย xalia ของ Steam</string> - <string name="perf_fastsync">droiddeck-ntsync (ทดลอง)</string> - <string name="perf_fastsync_hint">ตัวแทนในระดับยูสเซอร์สเปซของไดรเวอร์ ntsync ของ Linux สำหรับการทดสอบ เมื่อเปิดใช้ Proton จะใช้สิ่งนี้แทน droiddeck-esync และ droiddeck-fsync ใช้ได้กับ Proton Experimental ARM64, GE-Proton และ proton-cachyos จะมีผลในเซสชันถัดไป</string> + <string name="perf_fastsync">droiddeck-ntsync</string> + <string name="perf_fastsync_hint">ตัวแทนในระดับยูสเซอร์สเปซของไดรเวอร์ ntsync ของ Linux เมื่อเปิดใช้ Proton จะใช้สิ่งนี้แทน droiddeck-esync และ droiddeck-fsync ใช้ได้กับ Proton Experimental ARM64, GE-Proton และ proton-cachyos จะมีผลในเซสชันถัดไป</string> <string name="perf_esync">droiddeck-esync</string> <string name="perf_fsync">droiddeck-fsync ก่อน</string> <string name="perf_fsync_hint">รัน fsync ของ Proton เองบนไฟล์ Proton มาตรฐาน โดยมี futex_waitv ที่จัดเตรียมในระดับยูสเซอร์สเปซ จึงไม่ต้องใช้แพ็กและยังคงทำงานได้ทันทีที่ Steam อัปเดต Proton เมื่อเปิดใช้ เกมจะใช้สิ่งนี้ก่อน droiddeck-esync ยกเว้นบางส่วนที่ Proton ปิด fsync ไว้ จะมีผลในเซสชันถัดไป</string> @@ -1020,8 +1035,6 @@ <string name="games_empty">เกมที่คุณติดตั้งใน Steam จะปรากฏที่นี่หลังจากจบเซสชัน</string> <string name="games_play_steam">เล่น Steam</string> <string name="games_play_steamvr">เล่น SteamVR</string> - <string name="games_launch_settings">การตั้งค่าการเปิดเกม</string> - <string name="games_launch">เปิดเกม</string> <string name="games_files">ไฟล์เกม</string> <string name="games_prefix">Prefix ของ Proton</string> <string name="games_saves_looking">กำลังค้นหาเซฟ…</string> @@ -1029,7 +1042,6 @@ <string name="games_saves_folders">%1$d โฟลเดอร์เซฟ</string> <string name="games_saves_summary">%1$s · %2$d ไฟล์ · %3$s</string> <string name="games_kb">%1$d KB</string> - <string name="games_manage_saves">จัดการเซฟ</string> <string name="games_saves">เซฟของเกม</string> <string name="games_import_saves">นำเข้าเซฟ…</string> <string name="games_export_gamehub">ส่งออกเป็น GameHub zip…</string> @@ -1041,7 +1053,6 @@ <string name="games_runtime_first_play">รันไทม์จะติดตั้งเมื่อกด Play ครั้งแรก</string> <string name="games_runtime_update">มีอัปเดตรันไทม์</string> <string name="games_runtime_ready">● รันไทม์พร้อมใช้งาน</string> - <string name="games_last_played">เล่นล่าสุด %1$s</string> <string name="games_internal">พื้นที่จัดเก็บภายใน</string> <string name="games_added">เกมที่เพิ่ม</string> <string name="games_custom">เกมกำหนดเอง</string> @@ -1110,7 +1121,7 @@ <string name="second_terminal">เทอร์มินัล Linux</string> <string name="second_terminal_not_ready">เซสชัน Linux ยังไม่พร้อมสำหรับเทอร์มินัล</string> <string name="rail_steam">Steam</string> - <string name="rail_games">เกม</string> + <string name="rail_games">ไลบรารี</string> <string name="rail_desktop">เดสก์ท็อป</string> <string name="rail_store">สโตร์</string> <string name="rail_components">Components</string> @@ -1390,6 +1401,10 @@ <string name="theme_icon_blue_detail">พื้นดำ ตัวอักษรขาว สีฟ้าสำหรับทุกสิ่งที่ถูกเลือก โฟกัส หรือกด</string> <string name="theme_electric">กรมท่าไฟฟ้า</string> <string name="theme_electric_detail">สีฟ้าสว่างขึ้น บนพื้นผิวที่เอียงไปทางกรมท่ามากกว่าเทา</string> + <string name="theme_inner_gamer">Inner Gamer</string> + <string name="theme_inner_gamer_detail">สีที่เปลี่ยนวนไปตามสายรุ้ง ทำงานตามการตั้งค่าแอนิเมชันของ Android</string> + <string name="theme_rgb_speed">ความเร็ว</string> + <string name="theme_rgb_speed_hint">สีเปลี่ยนเร็วแค่ไหน</string> <string name="theme_gradient">ไล่เฉดสี</string> <string name="theme_gradient_detail">สีหลักและสีรองที่คุณเลือกเอง ผสมกันบนปุ่มและส่วนที่เน้น</string> <string name="theme_gradient_primary">สีหลัก</string> @@ -1506,6 +1521,13 @@ <string name="fm_conflict_skip">ข้าม</string> <string name="fm_select_this_folder">เลือกโฟลเดอร์นี้</string> <string name="fm_back">กลับ</string> + <string name="fm_exit">ออก</string> + <string name="fm_return_to_start">กลับไปยังโฟลเดอร์เริ่มต้น</string> + <string name="fm_start_unavailable">โฟลเดอร์เริ่มต้นไม่พร้อมใช้งานแล้ว</string> + <string name="fm_cancel_operation_title">ยกเลิกการดำเนินการ?</string> + <string name="fm_cancel_operation_confirmation">การดำเนินการจะถูกยกเลิก คุณแน่ใจหรือไม่?</string> + <string name="fm_accept">ยอมรับ</string> + <string name="fm_decline">ปฏิเสธ</string> <string name="fm_drive_not_readable">%1$s ติดตั้งอยู่แต่อ่านไม่ได้ในขณะนี้</string> <string name="fm_list_view">มุมมองรายการ</string> <string name="fm_grid_view">มุมมองตาราง</string> @@ -1918,7 +1940,6 @@ <string name="source_steam">Steam</string> <string name="source_added">กำหนดเอง</string> <string name="setup_stores">สโตร์</string> - <string name="setup_stores_open_on">เปิดสโตร์บน</string> <string name="setup_stores_speed">ระดับความเร็วการดาวน์โหลด</string> <string name="setup_stores_engine">เอนจินสโตร์</string> <string name="setup_stores_engine_chip_ready">● เอนจินพร้อมใช้งาน</string> @@ -1932,11 +1953,9 @@ <string name="stores_tabs_arrange">จัดเรียงแท็บ</string> <string name="stores_tabs_arrange_done">เสร็จสิ้น</string> <string name="stores_chip_signed_out">(ยังไม่ลงชื่อเข้าใช้)</string> - <string name="stores_tab_store">สโตร์</string> <string name="stores_tab_library">ไลบรารี</string> <string name="stores_tab_all">ทั้งหมด</string> <string name="stores_tab_downloads">การดาวน์โหลด</string> - <string name="stores_tab_count">%1$s (%2$d)</string> <string name="stores_signin">ลงชื่อเข้าใช้</string> <string name="stores_signout_named">%1$s · ลงชื่อออก</string> <string name="stores_signout_title">ลงชื่อออกจาก %1$s?</string> @@ -1954,9 +1973,10 @@ <string name="stores_shelf_deals">ดีล</string> <string name="stores_shelf_free">เกมฟรี</string> <string name="stores_shelf_trending">กำลังเป็นที่นิยม</string> - <string name="stores_shelf_library">ไลบรารีของคุณ</string> <string name="stores_play">เล่น</string> <string name="stores_install">ติดตั้ง</string> + <string name="bnet_open">เปิด Battle.net</string> + <string name="bnet_store">เกมของ Blizzard</string> <string name="stores_resume_install">ดำเนินการติดตั้งต่อ</string> <string name="stores_dl_interrupted">ถูกขัดจังหวะ</string> <string name="stores_install_size">ติดตั้ง · %1$s</string> @@ -1975,8 +1995,32 @@ <string name="stores_itch_no_exe">ไม่พบเกม Windows ในไฟล์ที่ดาวน์โหลด</string> <string name="stores_itch_extract_failed">แตกไฟล์ที่ดาวน์โหลดไม่ได้ (รหัส %1$d)</string> <string name="stores_itch_no_catalog">itch.io ไม่ตอบสนอง</string> + <string name="stores_itch_sort_popular">ยอดนิยม</string> + <string name="stores_itch_sort_new">ล่าสุด</string> + <string name="stores_itch_sort_new_popular">ใหม่และยอดนิยม</string> + <string name="stores_itch_price_paid">เสียเงิน</string> + <string name="stores_itch_price_under_5">ไม่เกิน $5</string> + <string name="stores_itch_price_under_15">ไม่เกิน $15</string> + <string name="stores_itch_sort_top_rated">คะแนนสูงสุด</string> + <string name="stores_itch_sort_top_sellers">ขายดี</string> + <string name="stores_itch_price_any">ทุกราคา</string> + <string name="stores_itch_price_free">ฟรี</string> + <string name="stores_itch_price_sale">ลดราคา</string> + <string name="stores_itch_load_more">โหลดเพิ่มเติม</string> + <string name="stores_itch_platform_any">ทุกแพลตฟอร์ม</string> + <string name="stores_itch_platform_mobile_web">เว็บมือถือ</string> + <string name="stores_itch_tag_add">เพิ่มแท็ก</string> + <string name="stores_itch_tag_title">กรองตามแท็ก</string> + <string name="stores_itch_browse_failed">โหลดเกมจาก itch.io ไม่ได้</string> <string name="stores_itch_archive">ไม่รองรับไฟล์ %1$s</string> <string name="stores_itch_download_failed">itch.io ตอบกลับ HTTP %1$d</string> + <string name="stores_itch_pick_title">เลือกเวอร์ชัน</string> + <string name="stores_itch_pick_none">ไม่พบไฟล์ดาวน์โหลดสำหรับเกมนี้</string> + <string name="stores_itch_pick_failed">โหลดรายการดาวน์โหลดจาก itch.io ไม่ได้</string> + <string name="stores_itch_build_demo">เดโม</string> + <string name="stores_itch_build_open">เปิดบน itch.io</string> + <string name="stores_itch_build_linux_soon">ยังไม่รองรับ</string> + <string name="stores_itch_no_game">ไม่พบเกมนี้บน itch.io</string> <string name="stores_free">ฟรี</string> <string name="stores_resume">ดำเนินการต่อ</string> <string name="stores_refresh">รีเฟรช</string> @@ -2129,6 +2173,7 @@ <string name="stores_login_close">ปิด</string> <string name="stores_login_retry">ลองใหม่</string> <string name="stores_login_finishing">กำลังเสร็จสิ้นการลงชื่อเข้าใช้…</string> + <string name="stores_xbox_sign_in_again">Microsoft ปฏิเสธการลงชื่อเข้าใช้ Xbox ลงชื่อออกจาก Xbox ในสโตร์ ลงชื่อเข้าใช้อีกครั้ง แล้วดำเนินการต่อ</string> <string name="stores_login_error_network">ไม่สามารถโหลดหน้าลงชื่อเข้าใช้ได้: %1$s</string> <string name="stores_login_error_idp_blocked">ผู้ให้บริการนี้ปฏิเสธการลงชื่อเข้าใช้จากหน้าที่ฝังอยู่ ให้ลงชื่อเข้าใช้ด้วยรหัสผ่านของสโตร์แทน</string> <string name="stores_login_error_http">หน้าลงชื่อเข้าใช้ตอบกลับด้วยข้อผิดพลาด (HTTP %1$d)</string> @@ -2956,5 +3001,58 @@ <string name="spatial_saved_views_hint">มุมมองที่บันทึกไว้สามารถคำนวณใหม่ได้ เริ่มการสอบเทียบใหม่เพื่อบันทึกในระบบพิกัดการติดตามปัจจุบัน</string> <string name="spatial_model_checksum_error">ผลรวมตรวจสอบแบบจำลองมือไม่ตรงกัน</string> <string name="spatial_opencv_load_error">OpenCV ไม่สามารถโหลดได้</string> + <string name="setup_group_memory">หน่วยความจำ</string> + <string name="setup_clear_cached">ปิดแอปที่แคชไว้ก่อนเริ่มเซสชัน</string> + <string name="setup_clear_cached_hint">เมื่อเซสชันเริ่ม DroidDeck จะปิดแอปที่ Android พักไว้ในพื้นหลัง เพื่อให้เกมมีหน่วยความจำมากขึ้น ต้องเปิด Wireless debugging ไว้</string> + <string name="setup_clear_cached_unpaired">ต้องจับคู่ DroidDeck กับ Wireless debugging ก่อน (จับคู่ ด้านล่าง)</string> + <string name="setup_memory_hud">การแสดงหน่วยความจำ</string> + <string name="setup_memory_hud_hint">หน่วยความจำและ swap ที่ใช้อยู่ บนหน้าจอเซสชัน โหมดขั้นสูงจะแยกเป็นเกม, DroidDeck, Android และ GPU</string> + <string name="setup_memory_hud_simple">แบบง่าย</string> + <string name="setup_memory_hud_advanced">ขั้นสูง</string> + <string name="memory_hud_simple">RAM %1$s/%2$s GB · swap %3$s/%4$s GB</string> + <string name="memory_hud_split">เกม %1$s · DroidDeck %2$s · Android %3$s GB</string> + <string name="memory_hud_gpu">GPU %1$s GB</string> + <string name="setup_bg_limit">แอปพื้นหลังระหว่างเซสชัน</string> + <string name="setup_bg_limit_hint">ระหว่างที่เซสชันทำงาน Android จะเก็บแอปไว้ในแคชไม่เกินจำนวนนี้และปิดแอปที่เหลือ เพื่อให้เกมได้หน่วยความจำของแอปเหล่านั้น DroidDeck จะคืนค่าขีดจำกัดเดิมเมื่อเซสชันสิ้นสุด ต้องเปิด Wireless debugging ไว้</string> + <string name="setup_bg_limit_4">ไม่เกิน 4</string> + <string name="setup_bg_limit_1">ไม่เกิน 1</string> + <string name="setup_bg_limit_0">ไม่มี</string> + <string name="setup_adb_pair">Wireless debugging</string> + <string name="setup_adb_pair_hint">จับคู่ DroidDeck กับ Wireless debugging ของ Android สำหรับตัวเลือกด้านบน ขีดจำกัดโพรเซสลูกยังคงปิดอยู่</string> + <string name="setup_adb_pair_button">จับคู่</string> <string name="spatial_restored">กู้คืนมุมมองที่บันทึกไว้แล้ว %1$d รายการ คำนวณการสอบเทียบเพื่อลองอีกครั้ง หรือเริ่มชุดใหม่เพื่อบันทึกข้อมูลเพิ่มเติม</string> + <string name="epic_client_missing">ยังไม่ติดตั้ง</string> + <string name="epic_client_open">เปิด</string> + <string name="epic_client_install">ติดตั้ง</string> + <string name="game_tab_about">เกี่ยวกับ</string> + <string name="game_tab_install">ติดตั้ง</string> + <string name="game_install_location">ตำแหน่ง</string> + <string name="game_install_folder">โฟลเดอร์</string> + <string name="game_install_size">ขนาดบนดิสก์</string> + <string name="game_install_date">ติดตั้งแล้ว</string> + <string name="stores_xbox_update">อัปเดต / ตรวจสอบ</string> + <string name="stores_xbox_manage">จัดการเนื้อหา</string> + <string name="stores_xbox_close_first">ปิดเกม Xbox นี้ก่อนอัปเดต</string> + <string name="stores_xbox_content_title">เนื้อหา Xbox</string> + <string name="stores_xbox_content_none">ไม่มีแพ็กเกจเนื้อหา PC ที่มีลิขสิทธิ์สำหรับเกมนี้</string> + <string name="stores_xbox_content_update">%1$s · อัปเดต / ตรวจสอบ</string> + <string name="stores_xbox_content_install">%1$s · ติดตั้ง</string> + <string name="stores_xbox_choose_content">เลือกเนื้อหา PC</string> + <string name="stores_eyebrow_pass">มีให้เล่นกับ %1$s</string> + <string name="stores_owned_in_library">เกมที่เป็นเจ้าของในไลบรารี</string> + <string name="library_filter_added">ที่เพิ่มเอง</string> + <string name="game_tab_launch">การเปิดเกม</string> + <string name="game_tab_saves_cloud">เซฟและคลาวด์</string> + <string name="game_launch_shortcut">ทางลัด Steam</string> + <string name="game_install_version">เวอร์ชัน</string> + <string name="game_install_played">เล่นล่าสุด</string> + <string name="game_install_access">สิทธิ์เข้าถึง</string> + <string name="game_install_access_owned">เป็นเจ้าของ</string> + <string name="game_stream">สตรีม</string> + <string name="library_add">เพิ่มในไลบรารี</string> + <string name="library_remove">นำออกจากไลบรารี</string> + <string name="library_in">อยู่ในไลบรารี</string> + <string name="xbox_filter_show">แสดง</string> + <string name="xbox_filter_cloud">คลาวด์</string> + <string name="stores_xbox_cloud_settings">การตั้งค่าคลาวด์</string> </resources> diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index da288eb3e..c1bbb9d33 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -267,6 +267,24 @@ <string name="osc_steam_touch">Steam dokunmatik (beta)</string> <string name="osc_style_next_session">Steam\'in dokunmatik kontrolleri sonraki Steam oturumunda başlar</string> <string name="osc_steam_layout">Steam dokunmatik düzeni</string> + <string name="screen_keys">Ekran tuşları</string> + <string name="screen_keys_add">Tuş ekle</string> + <string name="screen_key_text">Metin</string> + <string name="screen_key_size">Boyut</string> + <string name="screen_key_opacity">Opaklık</string> + <string name="screen_key_type_button">Tuş</string> + <string name="screen_key_shape_trigger">Tetik</string> + <string name="screen_key_toggle">Aç/Kapa</string> + <string name="screen_key_layout_export">Dışa aktar</string> + <string name="screen_key_shape_pill">Kapsül</string> + <string name="screen_key_shape_round">Yuvarlak</string> + <string name="screen_key_shape_circle">Daire</string> + <string name="screen_key_shape_rect">Dikdörtgen</string> + <string name="screen_key_bind">Tuş</string> + <string name="screen_key_image">Görsel</string> + <string name="screen_key_image_clear">Temizle</string> + <string name="screen_key_layout">Düzen</string> + <string name="screen_key_layout_save">Kaydet</string> <string name="osc_steam_layout_hint">Bu oyunun dokunmatik kontrollerini taşıyın, boyutlandırın veya gizleyin; Steam\'e kaydedilir</string> <string name="mode_controller">Kumanda</string> <string name="mode_controller_hint">Steam\'in kumandanızı nasıl gördüğü. Sonraki oturumda uygulanır.</string> @@ -541,8 +559,8 @@ <string name="perf_sysmem">Turnip: sysmem işleme</string> <string name="perf_sysmem_hint">Steam istemcisi için daha hızlı; oyunlarda sürücü her geçişte döşeme tabanlı veya sistem belleğinde işlemeyi seçer. Adreno 710/720/722\'de gereklidir.</string> <string name="perf_xalia">Steam\'in xalia yardımcısını atla</string> - <string name="perf_fastsync">droiddeck-ntsync (deneysel)</string> - <string name="perf_fastsync_hint">Linux ntsync sürücüsünün test amaçlı, kullanıcı alanında çalışan bir yedeği. Açıkken Proton, droiddeck-esync ve droiddeck-fsync yerine bunu kullanır. Proton Experimental ARM64, GE-Proton ve proton-cachyos ile çalışır. Sonraki oturumda uygulanır.</string> + <string name="perf_fastsync">droiddeck-ntsync</string> + <string name="perf_fastsync_hint">Linux ntsync sürücüsünün kullanıcı alanında çalışan bir yedeği. Açıkken Proton, droiddeck-esync ve droiddeck-fsync yerine bunu kullanır. Proton Experimental ARM64, GE-Proton ve proton-cachyos ile çalışır. Sonraki oturumda uygulanır.</string> <string name="perf_esync">droiddeck-esync</string> <string name="perf_fsync">Önce droiddeck-fsync</string> <string name="perf_fsync_hint">Proton\'un kendi fsync\'ini standart Proton dosyalarıyla, futex_waitv kullanıcı alanında sağlanarak çalıştırır; bu yüzden paket gerektirmez ve Steam Proton\'u güncellediği anda da çalışmaya devam eder. Açıkken oyunlar, Proton\'un fsync dışında tuttuğu birkaç oyun hariç, bunu droiddeck-esync\'ten önce kullanır. Sonraki oturumda uygulanır.</string> @@ -1015,8 +1033,6 @@ <string name="games_empty">Steam\'de yüklediğiniz oyunlar oturumdan sonra burada görünür.</string> <string name="games_play_steam">Steam\'de oyna</string> <string name="games_play_steamvr">SteamVR\'da oyna</string> - <string name="games_launch_settings">Başlatma ayarları</string> - <string name="games_launch">Başlat</string> <string name="games_files">Oyun dosyaları</string> <string name="games_prefix">Proton öneki</string> <string name="games_saves_looking">Kayıtlar aranıyor…</string> @@ -1024,7 +1040,6 @@ <string name="games_saves_folders">%1$d kayıt klasörü</string> <string name="games_saves_summary">%1$s · %2$d dosya · %3$s</string> <string name="games_kb">%1$d KB</string> - <string name="games_manage_saves">Kayıtları yönet</string> <string name="games_saves">Oyun kayıtları</string> <string name="games_import_saves">Kayıtları içe aktar…</string> <string name="games_export_gamehub">GameHub zip\'i olarak dışa aktar…</string> @@ -1036,7 +1051,6 @@ <string name="games_runtime_first_play">Çalışma ortamı ilk Oyna\'da yüklenir</string> <string name="games_runtime_update">Çalışma ortamı güncellemesi var</string> <string name="games_runtime_ready">● Çalışma ortamı hazır</string> - <string name="games_last_played">Son oynanma %1$s</string> <string name="games_internal">Dahili depolama</string> <string name="games_added">Eklenen oyun</string> <string name="games_custom">Özel oyun</string> @@ -1105,7 +1119,7 @@ <string name="second_terminal">Linux terminali</string> <string name="second_terminal_not_ready">Linux oturumu henüz terminal için hazır değil.</string> <string name="rail_steam">Steam</string> - <string name="rail_games">Oyunlar</string> + <string name="rail_games">Kütüphane</string> <string name="rail_desktop">Masaüstü</string> <string name="rail_store">Mağaza</string> <string name="rail_components">Bileşenler</string> @@ -1395,6 +1409,10 @@ <string name="theme_icon_blue_detail">Siyah zemin, beyaz yazı; seçilen, odaklanan veya basılan her şey için mavi.</string> <string name="theme_electric">Elektrik laciverti</string> <string name="theme_electric_detail">Daha parlak mavi; gri yerine laciverte çalan yüzeylerde.</string> + <string name="theme_inner_gamer">Inner Gamer</string> + <string name="theme_inner_gamer_detail">Gökkuşağında dolaşan renkler. Android\'in animasyon ayarlarına uyar.</string> + <string name="theme_rgb_speed">Hız</string> + <string name="theme_rgb_speed_hint">Renklerin ne kadar hızlı değiştiği.</string> <string name="theme_gradient">Geçiş</string> <string name="theme_gradient_detail">Kendi birincil ve ikincil renklerin, düğmelerde ve vurgularda harmanlanır.</string> <string name="theme_gradient_primary">Birincil</string> @@ -1516,6 +1534,13 @@ <string name="fm_conflict_skip">Atla</string> <string name="fm_select_this_folder">Bu klasörü seç</string> <string name="fm_back">Geri</string> + <string name="fm_exit">Çıkış</string> + <string name="fm_return_to_start">Başlangıç klasörüne dön</string> + <string name="fm_start_unavailable">Başlangıç klasörü artık kullanılamıyor</string> + <string name="fm_cancel_operation_title">İşlem iptal edilsin mi?</string> + <string name="fm_cancel_operation_confirmation">İşlem iptal edilecek. Emin misiniz?</string> + <string name="fm_accept">Kabul et</string> + <string name="fm_decline">Reddet</string> <string name="fm_drive_not_readable">%1$s bağlı ancak şu anda okunamıyor</string> <string name="fm_list_view">Liste görünümü</string> <string name="fm_grid_view">Izgara görünümü</string> @@ -1930,7 +1955,6 @@ <string name="source_steam">Steam</string> <string name="source_added">Özel</string> <string name="setup_stores">Mağazalar</string> - <string name="setup_stores_open_on">Bir mağazayı şurada aç</string> <string name="setup_stores_speed">İndirme hızı katmanı</string> <string name="setup_stores_engine">Mağazalar motoru</string> <string name="setup_stores_engine_chip_ready">● Motor hazır</string> @@ -1944,11 +1968,9 @@ <string name="stores_tabs_arrange">Sekmeleri düzenle</string> <string name="stores_tabs_arrange_done">Bitti</string> <string name="stores_chip_signed_out">(oturum açılmadı)</string> - <string name="stores_tab_store">Mağaza</string> <string name="stores_tab_library">Kütüphane</string> <string name="stores_tab_all">Tümü</string> <string name="stores_tab_downloads">İndirmeler</string> - <string name="stores_tab_count">%1$s (%2$d)</string> <string name="stores_signin">Oturum aç</string> <string name="stores_signout_named">%1$s · oturumu kapat</string> <string name="stores_signout_title">%1$s\'ten oturum kapatılsın mı?</string> @@ -1966,9 +1988,10 @@ <string name="stores_shelf_deals">Fırsatlar</string> <string name="stores_shelf_free">Ücretsiz oyunlar</string> <string name="stores_shelf_trending">Gündemde</string> - <string name="stores_shelf_library">Kütüphaneniz</string> <string name="stores_play">Oyna</string> <string name="stores_install">Yükle</string> + <string name="bnet_open">Battle.net\'i aç</string> + <string name="bnet_store">Blizzard oyunları</string> <string name="stores_resume_install">Yüklemeyi sürdür</string> <string name="stores_dl_interrupted">Kesintiye uğradı</string> <string name="stores_install_size">Yükle · %1$s</string> @@ -1987,8 +2010,32 @@ <string name="stores_itch_no_exe">İndirmede Windows oyunu bulunamadı</string> <string name="stores_itch_extract_failed">İndirme ayıklanamadı (kod %1$d)</string> <string name="stores_itch_no_catalog">itch.io yanıt vermedi</string> + <string name="stores_itch_sort_popular">Popüler</string> + <string name="stores_itch_sort_new">En yeniler</string> + <string name="stores_itch_sort_new_popular">Yeni ve popüler</string> + <string name="stores_itch_price_paid">Ücretli</string> + <string name="stores_itch_price_under_5">$5 veya altı</string> + <string name="stores_itch_price_under_15">$15 veya altı</string> + <string name="stores_itch_sort_top_rated">En yüksek puanlı</string> + <string name="stores_itch_sort_top_sellers">En çok satanlar</string> + <string name="stores_itch_price_any">Herhangi bir fiyat</string> + <string name="stores_itch_price_free">Ücretsiz</string> + <string name="stores_itch_price_sale">İndirimde</string> + <string name="stores_itch_load_more">Daha fazla yükle</string> + <string name="stores_itch_platform_any">Herhangi bir platform</string> + <string name="stores_itch_platform_mobile_web">Mobil web</string> + <string name="stores_itch_tag_add">Etiket ekle</string> + <string name="stores_itch_tag_title">Etikete göre filtrele</string> + <string name="stores_itch_browse_failed">itch.io oyunları yüklenemedi</string> <string name="stores_itch_archive">%1$s arşivleri desteklenmiyor</string> <string name="stores_itch_download_failed">itch.io HTTP %1$d yanıtı verdi</string> + <string name="stores_itch_pick_title">Bir sürüm seçin</string> + <string name="stores_itch_pick_none">Bu oyun için indirme bulunamadı</string> + <string name="stores_itch_pick_failed">İndirmeler itch.io\'dan yüklenemedi</string> + <string name="stores_itch_build_demo">Demo</string> + <string name="stores_itch_build_open">itch.io\'da aç</string> + <string name="stores_itch_build_linux_soon">Henüz desteklenmiyor</string> + <string name="stores_itch_no_game">Bu oyun itch.io\'da bulunamadı</string> <string name="stores_free">Ücretsiz</string> <string name="stores_resume">Sürdür</string> <string name="stores_refresh">Yenile</string> @@ -2138,6 +2185,7 @@ <string name="stores_login_close">Kapat</string> <string name="stores_login_retry">Yeniden dene</string> <string name="stores_login_finishing">Oturum açma tamamlanıyor…</string> + <string name="stores_xbox_sign_in_again">Microsoft, Xbox oturum açmayı reddetti. Mağazalar\'da Xbox oturumunu kapatın, yeniden oturum açın ve devam edin.</string> <string name="stores_login_error_network">Oturum açma sayfası yüklenemedi: %1$s</string> <string name="stores_login_error_idp_blocked">Bu sağlayıcı yerleşik bir sayfadan oturum açmayı reddediyor. Bunun yerine mağaza parolanızla oturum açın.</string> <string name="stores_login_error_http">Oturum açma sayfası bir hatayla yanıt verdi (HTTP %1$d).</string> @@ -2531,9 +2579,6 @@ <string name="stores_hltb_completionist">Tamamlayıcı</string> <string name="stores_hltb_hours">%1$s sa</string> <string name="stores_hltb_minutes">%1$d dk</string> - <string name="mode_zram">zRAM sıkıştırma</string> - <string name="mode_zram_hint">Bir oyun çalışırken Steam istemcisinin belleği cihazın zram\'ına sıkıştırılır ve oyuna daha fazla RAM kalır. Steam\'in bir sonraki başlatılışında uygulanır.</string> - <string name="mode_zram_unsupported">Kullanılamıyor: bu cihazda zram veya swap yok ya da çekirdeği 5.4\'ten eski.</string> <string name="setup_reset_settings">Tüm ayarları sıfırla</string> <string name="setup_reset_settings_title">Tüm ayarlar sıfırlansın mı?</string> <string name="setup_reset_settings_body">Ekran, performans, denetleyici, tema, dil ve başlatıcı ayarları varsayılanlarına döner. Oyunlarınız, Steam oturum bilgileriniz, mağaza hesaplarınız, indirilen sürücüler ve depolama klasörleriniz korunur.</string> @@ -2981,7 +3026,60 @@ <string name="spatial_saved_views_hint">Kaydedilen görünümler yeniden hesaplanabilir. Geçerli izleme koordinat sistemini yakalamak için yeni bir kalibrasyon başlatın.</string> <string name="spatial_model_checksum_error">El modeli sağlama toplamı uyuşmazlığı</string> <string name="spatial_opencv_load_error">OpenCV yüklenemedi</string> + <string name="setup_group_memory">Bellek</string> + <string name="setup_clear_cached">Oturumdan önce önbellekteki uygulamaları kapat</string> + <string name="setup_clear_cached_hint">Bir oturum başladığında DroidDeck, Android\'in arka planda tuttuğu uygulamaları kapatır; böylece oyuna daha fazla bellek kalır. Kablosuz hata ayıklama açık olmalıdır.</string> + <string name="setup_clear_cached_unpaired">DroidDeck\'in Kablosuz hata ayıklama ile eşlenmesi gerekir (Eşle, aşağıda).</string> + <string name="setup_memory_hud">Bellek göstergesi</string> + <string name="setup_memory_hud_hint">Oturum ekranında kullanılan bellek ve swap. Gelişmiş, bunu oyun, DroidDeck, Android ve GPU olarak ayırır.</string> + <string name="setup_memory_hud_simple">Basit</string> + <string name="setup_memory_hud_advanced">Gelişmiş</string> + <string name="memory_hud_simple">RAM %1$s/%2$s GB · swap %3$s/%4$s GB</string> + <string name="memory_hud_split">oyun %1$s · DroidDeck %2$s · Android %3$s GB</string> + <string name="memory_hud_gpu">GPU %1$s GB</string> + <string name="setup_bg_limit">Oturum sırasında arka plan uygulamaları</string> + <string name="setup_bg_limit_hint">Oturum sürerken Android önbellekte en fazla bu kadar uygulama tutar ve gerisini kapatır; böylece onların belleği oyuna kalır. Oturum bittiğinde DroidDeck sınırı eski haline getirir. Kablosuz hata ayıklama açık olmalıdır.</string> + <string name="setup_bg_limit_4">En fazla 4</string> + <string name="setup_bg_limit_1">En fazla 1</string> + <string name="setup_bg_limit_0">Yok</string> + <string name="setup_adb_pair">Kablosuz hata ayıklama</string> + <string name="setup_adb_pair_hint">Yukarıdaki seçenekler için DroidDeck\'i Android\'in Kablosuz hata ayıklama özelliğiyle eşler. Alt süreç sınırı kapalı kalır.</string> + <string name="setup_adb_pair_button">Eşle</string> <string name="tracking_recovery_look">İyi aydınlatılmış bir odada yavaşça etrafa bakın.\nİzleme kameralarını temiz tutun ve mobilyalara veya diğer görünür ayrıntılara doğru bakın.</string> <string name="spatial_need_views">Sabit kalibrasyon hedefinin en az 24 farklı görünümünü yakalayın.</string> <string name="spatial_need_rotation">Akıllı gözlükleri sola ve sağa çevirin, yukarı ve aşağı eğin; dönme olmadan hareket, kamera hizalamasını kalibre edemez.</string> + <string name="epic_client_missing">Yüklü değil</string> + <string name="epic_client_open">Aç</string> + <string name="epic_client_install">Yükle</string> + <string name="game_tab_about">Hakkında</string> + <string name="game_tab_install">Yükle</string> + <string name="game_install_location">Konum</string> + <string name="game_install_folder">Klasör</string> + <string name="game_install_size">Diskteki boyut</string> + <string name="game_install_date">Yüklü</string> + <string name="stores_xbox_update">Güncelle / doğrula</string> + <string name="stores_xbox_manage">İçeriği yönet</string> + <string name="stores_xbox_close_first">Güncellemeden önce bu Xbox oyununu kapatın.</string> + <string name="stores_xbox_content_title">Xbox içeriği</string> + <string name="stores_xbox_content_none">Bu oyun için lisanslı PC içerik paketi yok.</string> + <string name="stores_xbox_content_update">%1$s · Güncelle / doğrula</string> + <string name="stores_xbox_content_install">%1$s · Yükle</string> + <string name="stores_xbox_choose_content">PC içeriği seçin</string> + <string name="stores_eyebrow_pass">%1$s ile kullanılabilir</string> + <string name="stores_owned_in_library">Kütüphane içindeki oyunlarınız</string> + <string name="library_filter_added">Eklenenler</string> + <string name="game_tab_launch">Başlatma</string> + <string name="game_tab_saves_cloud">Kayıtlar ve bulut</string> + <string name="game_launch_shortcut">Steam kısayolu</string> + <string name="game_install_version">Sürüm</string> + <string name="game_install_played">Son oynanma</string> + <string name="game_install_access">Erişim</string> + <string name="game_install_access_owned">Sahip olunan</string> + <string name="game_stream">Akış</string> + <string name="library_add">Kütüphane içine ekle</string> + <string name="library_remove">Kütüphane içinden kaldır</string> + <string name="library_in">Kütüphane içinde</string> + <string name="xbox_filter_show">Göster</string> + <string name="xbox_filter_cloud">Bulut</string> + <string name="stores_xbox_cloud_settings">Bulut ayarları</string> </resources> diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 002514a82..2cadb53c5 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -267,6 +267,24 @@ <string name="osc_steam_touch">Сенсорне керування Steam (бета)</string> <string name="osc_style_next_session">Сенсорне керування Steam увімкнеться з наступним сеансом Steam</string> <string name="osc_steam_layout">Розкладка сенсорного керування Steam</string> + <string name="screen_keys">Екранні клавіші</string> + <string name="screen_keys_add">Додати клавішу</string> + <string name="screen_key_text">Текст</string> + <string name="screen_key_size">Розмір</string> + <string name="screen_key_opacity">Непрозорість</string> + <string name="screen_key_type_button">Клавіша</string> + <string name="screen_key_shape_trigger">Тригер</string> + <string name="screen_key_toggle">Перемикання</string> + <string name="screen_key_layout_export">Експорт</string> + <string name="screen_key_shape_pill">Капсула</string> + <string name="screen_key_shape_round">Заокруглення</string> + <string name="screen_key_shape_circle">Коло</string> + <string name="screen_key_shape_rect">Прямокутник</string> + <string name="screen_key_bind">Клавіша</string> + <string name="screen_key_image">Зображення</string> + <string name="screen_key_image_clear">Очистити</string> + <string name="screen_key_layout">Розкладка</string> + <string name="screen_key_layout_save">Зберегти</string> <string name="osc_steam_layout_hint">Переміщуйте, змінюйте розмір або приховуйте сенсорні елементи керування цієї гри; зберігається в Steam</string> <string name="mode_controller">Контролер</string> <string name="mode_controller_hint">Яким Steam бачить ваш контролер. Застосовується в наступному сеансі.</string> @@ -287,9 +305,6 @@ <string name="mode_suspend_steam_note">«Авто» призупиняє сеанс і відновлює його, коли додаток знову на екрані. «Нативний» спершу готує Steam до сну. «Вручну» чекає на «Відновити». «Ніколи» — сеанс працює далі.</string> <string name="mode_background_downloads">Не переривати завантаження</string> <string name="mode_background_downloads_hint">Відкладає призупинення у фоні, поки Steam активно щось завантажує.</string> - <string name="mode_zram">Стиснення zRAM</string> - <string name="mode_zram_hint">Під час гри пам\'ять клієнта Steam стискається в zram пристрою, тож гра отримує більше RAM. Застосується під час наступного запуску Steam.</string> - <string name="mode_zram_unsupported">Недоступно: на пристрої немає zram або swap, чи ядро старіше за 5.4.</string> <string name="mode_suspend_note">«Авто» призупиняє сеанс у фоні й відновлює, коли додаток знову на екрані. «Вручну» чекає на «Відновити». «Ніколи» — сеанс працює далі.</string> <string name="mode_startup">Запуск</string> <string name="mode_steam_startup">Запускати Steam разом із DroidDeck</string> @@ -548,8 +563,8 @@ <string name="perf_sysmem">Turnip: рендеринг через sysmem</string> <string name="perf_sysmem_hint">Швидше для клієнта Steam; в іграх драйвер для кожного проходу вибирає тайловий рендеринг або рендеринг у системній пам’яті. Потрібно для Adreno 710/720/722.</string> <string name="perf_xalia">Пропускати помічник Steam xalia</string> - <string name="perf_fastsync">droiddeck-ntsync (експериментально)</string> - <string name="perf_fastsync_hint">Програмна заміна драйвера ntsync ядра Linux у просторі користувача, для тестування. Поки це увімкнено, Proton використовує її замість droiddeck-esync і droiddeck-fsync. Працює з Proton Experimental ARM64, GE-Proton і proton-cachyos. Застосовується в наступному сеансі.</string> + <string name="perf_fastsync">droiddeck-ntsync</string> + <string name="perf_fastsync_hint">Програмна заміна драйвера ntsync ядра Linux у просторі користувача. Поки це увімкнено, Proton використовує її замість droiddeck-esync і droiddeck-fsync. Працює з Proton Experimental ARM64, GE-Proton і proton-cachyos. Застосовується в наступному сеансі.</string> <string name="perf_esync">droiddeck-esync</string> <string name="perf_fsync">droiddeck-fsync першим</string> <string name="perf_fsync_hint">Запускає власний fsync Proton на стандартних файлах Proton, а futex_waitv надається в просторі користувача, тому не потрібен пакет і все працює відразу після оновлення Proton у Steam. Поки це увімкнено, ігри використовують його раніше за droiddeck-esync, окрім кількох, де Proton тримає fsync вимкненим. Застосовується в наступному сеансі.</string> @@ -1038,8 +1053,6 @@ <string name="games_empty">Ігри, які ви встановите в Steam, з’являться тут після сеансу.</string> <string name="games_play_steam">Грати в Steam</string> <string name="games_play_steamvr">Грати в SteamVR</string> - <string name="games_launch_settings">Налаштування запуску</string> - <string name="games_launch">Запустити</string> <string name="games_files">Файли гри</string> <string name="games_prefix">Префікс Proton</string> <string name="games_saves_looking">Пошук збережень…</string> @@ -1047,7 +1060,6 @@ <string name="games_saves_folders">%1$d папок збережень</string> <string name="games_saves_summary">%1$s · %2$d файлів · %3$s</string> <string name="games_kb">%1$d KB</string> - <string name="games_manage_saves">Керування збереженнями</string> <string name="games_saves">Збереження гри</string> <string name="games_import_saves">Імпортувати збереження…</string> <string name="games_export_gamehub">Експортувати як zip GameHub…</string> @@ -1059,7 +1071,6 @@ <string name="games_runtime_first_play">Середовище встановлюється під час першого натискання «Грати»</string> <string name="games_runtime_update">Є оновлення середовища</string> <string name="games_runtime_ready">● Середовище готове</string> - <string name="games_last_played">Останній раз грали %1$s</string> <string name="games_internal">Внутрішнє сховище</string> <string name="games_added">Додана гра</string> <string name="games_custom">Власна гра</string> @@ -1128,7 +1139,7 @@ <string name="second_terminal">Термінал Linux</string> <string name="second_terminal_not_ready">Сеанс Linux ще не готовий до роботи з терміналом.</string> <string name="rail_steam">Steam</string> - <string name="rail_games">Ігри</string> + <string name="rail_games">Бібліотека</string> <string name="rail_desktop">Робочий стіл</string> <string name="rail_store">Магазин</string> <string name="rail_components">Компоненти</string> @@ -1432,6 +1443,10 @@ <string name="theme_icon_blue_detail">Чорне тло, білий текст і синій для всього вибраного, у фокусі чи натиснутого.</string> <string name="theme_electric">Електричний темно-синій</string> <string name="theme_electric_detail">Яскравіший синій на поверхнях із темно-синім відтінком замість сірого.</string> + <string name="theme_inner_gamer">Inner Gamer</string> + <string name="theme_inner_gamer_detail">Кольори, що плавно змінюються по всій веселці. Враховує налаштування анімації Android.</string> + <string name="theme_rgb_speed">Швидкість</string> + <string name="theme_rgb_speed_hint">Як швидко змінюються кольори.</string> <string name="theme_gradient">Градієнт</string> <string name="theme_gradient_detail">Ваші основний і додатковий кольори, що плавно переходять на кнопках і виділеннях.</string> <string name="theme_gradient_primary">Основний</string> @@ -1563,6 +1578,13 @@ <string name="fm_conflict_skip">Пропустити</string> <string name="fm_select_this_folder">Вибрати цю папку</string> <string name="fm_back">Назад</string> + <string name="fm_exit">Вийти</string> + <string name="fm_return_to_start">Повернутися до початкової папки</string> + <string name="fm_start_unavailable">Початкова папка більше недоступна</string> + <string name="fm_cancel_operation_title">Скасувати операцію?</string> + <string name="fm_cancel_operation_confirmation">Операцію буде скасовано. Ви впевнені?</string> + <string name="fm_accept">Прийняти</string> + <string name="fm_decline">Відхилити</string> <string name="fm_drive_not_readable">%1$s: підключено, але читання зараз недоступне</string> <string name="fm_list_view">Список</string> <string name="fm_grid_view">Сітка</string> @@ -1981,7 +2003,6 @@ <string name="source_steam">Steam</string> <string name="source_added">Власна</string> <string name="setup_stores">Магазини</string> - <string name="setup_stores_open_on">Відкривати магазин на</string> <string name="setup_stores_speed">Рівень швидкості завантаження</string> <string name="setup_stores_engine">Рушій магазинів</string> <string name="setup_stores_engine_chip_ready">● Рушій готовий</string> @@ -1995,11 +2016,9 @@ <string name="stores_tabs_arrange">Упорядкувати вкладки</string> <string name="stores_tabs_arrange_done">Готово</string> <string name="stores_chip_signed_out">(вхід не виконано)</string> - <string name="stores_tab_store">Магазин</string> <string name="stores_tab_library">Бібліотека</string> <string name="stores_tab_all">Усі</string> <string name="stores_tab_downloads">Завантаження</string> - <string name="stores_tab_count">%1$s (%2$d)</string> <string name="stores_signin">Увійти</string> <string name="stores_signout_named">%1$s · вийти</string> <string name="stores_signout_title">Вийти з %1$s?</string> @@ -2017,9 +2036,10 @@ <string name="stores_shelf_deals">Знижки</string> <string name="stores_shelf_free">Безкоштовні ігри</string> <string name="stores_shelf_trending">Популярне</string> - <string name="stores_shelf_library">Ваша бібліотека</string> <string name="stores_play">Грати</string> <string name="stores_install">Встановити</string> + <string name="bnet_open">Відкрити Battle.net</string> + <string name="bnet_store">Ігри Blizzard</string> <string name="stores_resume_install">Продовжити встановлення</string> <string name="stores_dl_interrupted">Перервано</string> <string name="stores_install_size">Встановити · %1$s</string> @@ -2038,8 +2058,32 @@ <string name="stores_itch_no_exe">У завантаженні не знайдено гри для Windows</string> <string name="stores_itch_extract_failed">Не вдалося розпакувати завантаження (код %1$d)</string> <string name="stores_itch_no_catalog">itch.io не відповів</string> + <string name="stores_itch_sort_popular">Популярні</string> + <string name="stores_itch_sort_new">Найновіші</string> + <string name="stores_itch_sort_new_popular">Нові й популярні</string> + <string name="stores_itch_price_paid">Платні</string> + <string name="stores_itch_price_under_5">До $5</string> + <string name="stores_itch_price_under_15">До $15</string> + <string name="stores_itch_sort_top_rated">З найвищими оцінками</string> + <string name="stores_itch_sort_top_sellers">Бестселери</string> + <string name="stores_itch_price_any">Будь-яка ціна</string> + <string name="stores_itch_price_free">Безкоштовні</string> + <string name="stores_itch_price_sale">Зі знижкою</string> + <string name="stores_itch_load_more">Завантажити ще</string> + <string name="stores_itch_platform_any">Будь-яка платформа</string> + <string name="stores_itch_platform_mobile_web">Мобільний веб</string> + <string name="stores_itch_tag_add">Додати тег</string> + <string name="stores_itch_tag_title">Фільтр за тегом</string> + <string name="stores_itch_browse_failed">Не вдалося завантажити ігри з itch.io</string> <string name="stores_itch_archive">Архіви %1$s не підтримуються</string> <string name="stores_itch_download_failed">itch.io відповів HTTP %1$d</string> + <string name="stores_itch_pick_title">Виберіть версію</string> + <string name="stores_itch_pick_none">Для цієї гри не знайдено завантажень</string> + <string name="stores_itch_pick_failed">Не вдалося завантажити список файлів з itch.io</string> + <string name="stores_itch_build_demo">Демо</string> + <string name="stores_itch_build_open">Відкрити на itch.io</string> + <string name="stores_itch_build_linux_soon">Ще не підтримується</string> + <string name="stores_itch_no_game">Не вдалося знайти цю гру на itch.io</string> <string name="stores_free">Безкоштовно</string> <string name="stores_resume">Продовжити</string> <string name="stores_refresh">Оновити</string> @@ -2192,6 +2236,7 @@ <string name="stores_login_close">Закрити</string> <string name="stores_login_retry">Спробувати ще раз</string> <string name="stores_login_finishing">Завершення входу…</string> + <string name="stores_xbox_sign_in_again">Microsoft відхилила вхід у Xbox. Вийдіть з Xbox у розділі «Магазини», увійдіть знову й продовжте.</string> <string name="stores_login_error_network">Не вдалося завантажити сторінку входу: %1$s</string> <string name="stores_login_error_idp_blocked">Цей провайдер не дозволяє вхід із вбудованої сторінки. Увійдіть за допомогою пароля магазину.</string> <string name="stores_login_error_http">Сторінка входу відповіла помилкою (HTTP %1$d).</string> @@ -3049,5 +3094,58 @@ <string name="spatial_saved_views_hint">Збережені перегляди можна перерахувати. Розпочніть нове калібрування для запису в поточній системі координат відстеження.</string> <string name="spatial_model_checksum_error">Невідповідність контрольної суми моделі руки</string> <string name="spatial_opencv_load_error">Не вдалося завантажити OpenCV</string> + <string name="setup_group_memory">Пам\'ять</string> + <string name="setup_clear_cached">Закривати кешовані додатки перед сеансом</string> + <string name="setup_clear_cached_hint">Коли починається сеанс, DroidDeck закриває додатки, які Android тримає у фоні, щоб грі дісталося більше пам\'яті. Бездротове налагодження має бути ввімкнене.</string> + <string name="setup_clear_cached_unpaired">Потрібно поєднати DroidDeck через бездротове налагодження («Поєднати», нижче).</string> + <string name="setup_memory_hud">Показник пам\'яті</string> + <string name="setup_memory_hud_hint">Використана пам\'ять і swap на екрані сеансу. «Розширений» розподіляє її між грою, DroidDeck, Android і GPU.</string> + <string name="setup_memory_hud_simple">Простий</string> + <string name="setup_memory_hud_advanced">Розширений</string> + <string name="memory_hud_simple">RAM %1$s/%2$s GB · swap %3$s/%4$s GB</string> + <string name="memory_hud_split">гра %1$s · DroidDeck %2$s · Android %3$s GB</string> + <string name="memory_hud_gpu">GPU %1$s GB</string> + <string name="setup_bg_limit">Фонові додатки під час сеансу</string> + <string name="setup_bg_limit_hint">Під час сеансу Android тримає в кеші не більше ніж стільки додатків і закриває решту, щоб гра отримала їхню пам\'ять. Коли сеанс завершується, DroidDeck повертає попереднє обмеження. Бездротове налагодження має бути ввімкнене.</string> + <string name="setup_bg_limit_4">Не більше 4</string> + <string name="setup_bg_limit_1">Не більше 1</string> + <string name="setup_bg_limit_0">Жодного</string> + <string name="setup_adb_pair">Бездротове налагодження</string> + <string name="setup_adb_pair_hint">Поєднує DroidDeck з бездротовим налагодженням Android для параметрів вище. Обмеження дочірніх процесів залишається вимкненим.</string> + <string name="setup_adb_pair_button">Поєднати</string> <string name="spatial_gpu_hint">Типово використовується CPU. Режим GPU є експериментальним і може спричинити перезапуск SteamVR. Вибір скидається після перезапуску відстеження.</string> + <string name="epic_client_missing">Не встановлено</string> + <string name="epic_client_open">Відкрити</string> + <string name="epic_client_install">Установити</string> + <string name="game_tab_about">Про додаток</string> + <string name="game_tab_install">Установити</string> + <string name="game_install_location">Розташування</string> + <string name="game_install_folder">Папка</string> + <string name="game_install_size">Розмір на диску</string> + <string name="game_install_date">Встановлено</string> + <string name="stores_xbox_update">Оновити / перевірити</string> + <string name="stores_xbox_manage">Керувати вмістом</string> + <string name="stores_xbox_close_first">Закрийте цю гру Xbox перед оновленням.</string> + <string name="stores_xbox_content_title">Вміст Xbox</string> + <string name="stores_xbox_content_none">Для цієї гри немає ліцензованих пакетів вмісту для ПК.</string> + <string name="stores_xbox_content_update">%1$s · Оновити / перевірити</string> + <string name="stores_xbox_content_install">%1$s · Установити</string> + <string name="stores_xbox_choose_content">Виберіть вміст для ПК</string> + <string name="stores_eyebrow_pass">доступно з %1$s</string> + <string name="stores_owned_in_library">Ваші ігри в бібліотеці</string> + <string name="library_filter_added">Додані</string> + <string name="game_tab_launch">Запуск</string> + <string name="game_tab_saves_cloud">Збереження й хмара</string> + <string name="game_launch_shortcut">Ярлик Steam</string> + <string name="game_install_version">Версія</string> + <string name="game_install_played">Остання гра</string> + <string name="game_install_access">Доступ</string> + <string name="game_install_access_owned">Придбана</string> + <string name="game_stream">Стримінг</string> + <string name="library_add">Додати до бібліотеки</string> + <string name="library_remove">Прибрати з бібліотеки</string> + <string name="library_in">У бібліотеці</string> + <string name="xbox_filter_show">Показати</string> + <string name="xbox_filter_cloud">Хмара</string> + <string name="stores_xbox_cloud_settings">Налаштування хмари</string> </resources> diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 14e1ffd7e..b8a7da87a 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -267,6 +267,24 @@ <string name="osc_steam_touch">Chạm Steam (beta)</string> <string name="osc_style_next_session">Các nút chạm của Steam bắt đầu từ phiên Steam kế tiếp</string> <string name="osc_steam_layout">Bố cục chạm Steam</string> + <string name="screen_keys">Phím trên màn hình</string> + <string name="screen_keys_add">Thêm phím</string> + <string name="screen_key_text">Văn bản</string> + <string name="screen_key_size">Kích thước</string> + <string name="screen_key_opacity">Độ mờ</string> + <string name="screen_key_type_button">Phím</string> + <string name="screen_key_shape_trigger">Cò</string> + <string name="screen_key_toggle">Bật tắt</string> + <string name="screen_key_layout_export">Xuất</string> + <string name="screen_key_shape_pill">Viên nang</string> + <string name="screen_key_shape_round">Bo tròn</string> + <string name="screen_key_shape_circle">Tròn</string> + <string name="screen_key_shape_rect">Chữ nhật</string> + <string name="screen_key_bind">Phím</string> + <string name="screen_key_image">Hình ảnh</string> + <string name="screen_key_image_clear">Xóa</string> + <string name="screen_key_layout">Bố cục</string> + <string name="screen_key_layout_save">Lưu</string> <string name="osc_steam_layout_hint">Di chuyển, đổi kích thước hoặc ẩn các nút chạm của game này; được lưu vào Steam</string> <string name="mode_controller">Tay cầm</string> <string name="mode_controller_hint">Cách Steam nhận dạng tay cầm của bạn. Áp dụng từ phiên sau.</string> @@ -287,9 +305,6 @@ <string name="mode_suspend_steam_note">Tự động tạm dừng và tiếp tục khi được hiển thị lại. Gốc chuẩn bị Steam để ngủ trước. Thủ công chờ nhấn Tiếp tục. Không bao giờ giữ nó luôn chạy.</string> <string name="mode_background_downloads">Giữ tải xuống tiếp tục chạy</string> <string name="mode_background_downloads_hint">Trì hoãn tạm dừng nền khi Steam đang tải xuống.</string> - <string name="mode_zram">Nén zRAM</string> - <string name="mode_zram_hint">Khi chơi, bộ nhớ của ứng dụng Steam được nén vào zram của thiết bị để game có thêm RAM. Áp dụng từ lần khởi động Steam tiếp theo.</string> - <string name="mode_zram_unsupported">Không khả dụng: thiết bị không có zram hoặc swap, hoặc nhân cũ hơn 5.4.</string> <string name="mode_suspend_note">Tự động tạm dừng ở nền và tiếp tục khi hiển thị lại. Thủ công chờ nhấn Tiếp tục. Không bao giờ giữ nó luôn chạy.</string> <string name="mode_startup">Khởi động</string> <string name="mode_steam_startup">Khởi động Steam cùng DroidDeck</string> @@ -542,8 +557,8 @@ <string name="perf_sysmem">Turnip: render sysmem</string> <string name="perf_sysmem_hint">Nhanh hơn cho ứng dụng Steam; trong game, trình điều khiển chọn kết xuất theo ô hoặc trong bộ nhớ hệ thống cho từng lượt. Bắt buộc trên Adreno 710/720/722.</string> <string name="perf_xalia">Bỏ qua trình trợ giúp xalia của Steam</string> - <string name="perf_fastsync">droiddeck-ntsync (thử nghiệm)</string> - <string name="perf_fastsync_hint">Một bản thay thế ở không gian người dùng cho driver ntsync của Linux, dùng để thử nghiệm. Khi đang bật, Proton dùng nó thay cho droiddeck-esync và droiddeck-fsync. Hoạt động với Proton Experimental ARM64, GE-Proton và proton-cachyos. Áp dụng từ phiên sau.</string> + <string name="perf_fastsync">droiddeck-ntsync</string> + <string name="perf_fastsync_hint">Một bản thay thế ở không gian người dùng cho driver ntsync của Linux. Khi đang bật, Proton dùng nó thay cho droiddeck-esync và droiddeck-fsync. Hoạt động với Proton Experimental ARM64, GE-Proton và proton-cachyos. Áp dụng từ phiên sau.</string> <string name="perf_esync">droiddeck-esync</string> <string name="perf_fsync">droiddeck-fsync trước</string> <string name="perf_fsync_hint">Chạy fsync riêng của Proton trên các tệp Proton gốc, với futex_waitv được cung cấp ở không gian người dùng, nên không cần pack và vẫn hoạt động ngay khi Steam cập nhật Proton. Khi đang bật, game dùng nó trước droiddeck-esync, trừ một số ít Proton giữ fsync tắt. Áp dụng từ phiên sau.</string> @@ -1020,8 +1035,6 @@ <string name="games_empty">Game bạn cài trong Steam sẽ xuất hiện ở đây sau phiên.</string> <string name="games_play_steam">Chơi trên Steam</string> <string name="games_play_steamvr">Chơi trên SteamVR</string> - <string name="games_launch_settings">Thiết lập khởi chạy</string> - <string name="games_launch">Khởi chạy</string> <string name="games_files">Tệp game</string> <string name="games_prefix">Prefix Proton</string> <string name="games_saves_looking">Đang tìm các lần lưu…</string> @@ -1029,7 +1042,6 @@ <string name="games_saves_folders">%1$d thư mục lưu</string> <string name="games_saves_summary">%1$s · %2$d tệp · %3$s</string> <string name="games_kb">%1$d KB</string> - <string name="games_manage_saves">Quản lý tệp lưu</string> <string name="games_saves">Tệp lưu game</string> <string name="games_import_saves">Nhập tệp lưu…</string> <string name="games_export_gamehub">Xuất dưới dạng zip GameHub…</string> @@ -1041,7 +1053,6 @@ <string name="games_runtime_first_play">Runtime sẽ cài ở lần Play đầu tiên</string> <string name="games_runtime_update">Có cập nhật runtime</string> <string name="games_runtime_ready">● Runtime đã sẵn sàng</string> - <string name="games_last_played">Chơi lần cuối %1$s</string> <string name="games_internal">Bộ nhớ trong</string> <string name="games_added">Game đã thêm</string> <string name="games_custom">Game tùy chỉnh</string> @@ -1110,7 +1121,7 @@ <string name="second_terminal">Terminal Linux</string> <string name="second_terminal_not_ready">Phiên Linux chưa sẵn sàng cho terminal.</string> <string name="rail_steam">Steam</string> - <string name="rail_games">Games</string> + <string name="rail_games">Thư viện</string> <string name="rail_desktop">Desktop</string> <string name="rail_store">Cửa hàng</string> <string name="rail_components">Thành phần</string> @@ -1390,6 +1401,10 @@ <string name="theme_icon_blue_detail">Nền đen, chữ trắng, xanh cho mọi thứ được chọn, lấy nét hoặc nhấn.</string> <string name="theme_electric">Xanh điện navy</string> <string name="theme_electric_detail">Màu xanh sáng hơn, trên các bề mặt nghiêng về navy thay vì xám.</string> + <string name="theme_inner_gamer">Inner Gamer</string> + <string name="theme_inner_gamer_detail">Màu sắc chuyển dần qua cả dải cầu vồng. Tuân theo cài đặt hiệu ứng động của Android.</string> + <string name="theme_rgb_speed">Tốc độ</string> + <string name="theme_rgb_speed_hint">Màu đổi nhanh đến mức nào.</string> <string name="theme_gradient">Chuyển màu</string> <string name="theme_gradient_detail">Màu chính và màu phụ do bạn chọn, hòa trộn trên các nút và phần nổi bật.</string> <string name="theme_gradient_primary">Chính</string> @@ -1506,6 +1521,13 @@ <string name="fm_conflict_skip">Bỏ qua</string> <string name="fm_select_this_folder">Chọn thư mục này</string> <string name="fm_back">Quay lại</string> + <string name="fm_exit">Thoát</string> + <string name="fm_return_to_start">Quay lại thư mục ban đầu</string> + <string name="fm_start_unavailable">Thư mục ban đầu không còn khả dụng</string> + <string name="fm_cancel_operation_title">Hủy thao tác?</string> + <string name="fm_cancel_operation_confirmation">Thao tác sẽ bị hủy. Bạn có chắc không?</string> + <string name="fm_accept">Chấp nhận</string> + <string name="fm_decline">Từ chối</string> <string name="fm_drive_not_readable">%1$s đã được gắn nhưng hiện không đọc được</string> <string name="fm_list_view">Xem dạng danh sách</string> <string name="fm_grid_view">Xem dạng lưới</string> @@ -1918,7 +1940,6 @@ <string name="source_steam">Steam</string> <string name="source_added">Tùy chỉnh</string> <string name="setup_stores">Cửa hàng</string> - <string name="setup_stores_open_on">Mở một store trên</string> <string name="setup_stores_speed">Mức tốc độ tải</string> <string name="setup_stores_engine">Engine stores</string> <string name="setup_stores_engine_chip_ready">● Engine sẵn sàng</string> @@ -1932,11 +1953,9 @@ <string name="stores_tabs_arrange">Sắp xếp thẻ</string> <string name="stores_tabs_arrange_done">Xong</string> <string name="stores_chip_signed_out">(chưa đăng nhập)</string> - <string name="stores_tab_store">Cửa hàng</string> <string name="stores_tab_library">Thư viện</string> <string name="stores_tab_all">Tất cả</string> <string name="stores_tab_downloads">Tải xuống</string> - <string name="stores_tab_count">%1$s (%2$d)</string> <string name="stores_signin">Đăng nhập</string> <string name="stores_signout_named">%1$s · đăng xuất</string> <string name="stores_signout_title">Đăng xuất khỏi %1$s?</string> @@ -1954,9 +1973,10 @@ <string name="stores_shelf_deals">Ưu đãi</string> <string name="stores_shelf_free">Game miễn phí</string> <string name="stores_shelf_trending">Xu hướng</string> - <string name="stores_shelf_library">Thư viện của bạn</string> <string name="stores_play">Chơi</string> <string name="stores_install">Cài</string> + <string name="bnet_open">Mở Battle.net</string> + <string name="bnet_store">Trò chơi Blizzard</string> <string name="stores_resume_install">Tiếp tục cài</string> <string name="stores_dl_interrupted">Bị gián đoạn</string> <string name="stores_install_size">Cài · %1$s</string> @@ -1975,8 +1995,32 @@ <string name="stores_itch_no_exe">Không tìm thấy trò chơi Windows trong bản tải xuống</string> <string name="stores_itch_extract_failed">Không thể giải nén bản tải xuống (mã %1$d)</string> <string name="stores_itch_no_catalog">itch.io không phản hồi</string> + <string name="stores_itch_sort_popular">Phổ biến</string> + <string name="stores_itch_sort_new">Mới nhất</string> + <string name="stores_itch_sort_new_popular">Mới và phổ biến</string> + <string name="stores_itch_price_paid">Trả phí</string> + <string name="stores_itch_price_under_5">Từ $5 trở xuống</string> + <string name="stores_itch_price_under_15">Từ $15 trở xuống</string> + <string name="stores_itch_sort_top_rated">Đánh giá cao nhất</string> + <string name="stores_itch_sort_top_sellers">Bán chạy nhất</string> + <string name="stores_itch_price_any">Mọi mức giá</string> + <string name="stores_itch_price_free">Miễn phí</string> + <string name="stores_itch_price_sale">Đang giảm giá</string> + <string name="stores_itch_load_more">Tải thêm</string> + <string name="stores_itch_platform_any">Mọi nền tảng</string> + <string name="stores_itch_platform_mobile_web">Web di động</string> + <string name="stores_itch_tag_add">Thêm thẻ</string> + <string name="stores_itch_tag_title">Lọc theo thẻ</string> + <string name="stores_itch_browse_failed">Không tải được game từ itch.io</string> <string name="stores_itch_archive">Không hỗ trợ tệp %1$s</string> <string name="stores_itch_download_failed">itch.io trả về HTTP %1$d</string> + <string name="stores_itch_pick_title">Chọn phiên bản</string> + <string name="stores_itch_pick_none">Không tìm thấy bản tải xuống cho trò chơi này</string> + <string name="stores_itch_pick_failed">Không thể tải danh sách tải xuống từ itch.io</string> + <string name="stores_itch_build_demo">Bản demo</string> + <string name="stores_itch_build_open">Mở trên itch.io</string> + <string name="stores_itch_build_linux_soon">Chưa được hỗ trợ</string> + <string name="stores_itch_no_game">Không tìm thấy trò chơi này trên itch.io</string> <string name="stores_free">Miễn phí</string> <string name="stores_resume">Tiếp tục</string> <string name="stores_refresh">Làm mới</string> @@ -2129,6 +2173,7 @@ <string name="stores_login_close">Đóng</string> <string name="stores_login_retry">Thử lại</string> <string name="stores_login_finishing">Đang hoàn tất đăng nhập…</string> + <string name="stores_xbox_sign_in_again">Microsoft đã từ chối đăng nhập Xbox. Hãy đăng xuất Xbox trong Cửa hàng game, đăng nhập lại rồi tiếp tục.</string> <string name="stores_login_error_network">Không thể tải trang đăng nhập: %1$s</string> <string name="stores_login_error_idp_blocked">Nhà cung cấp này từ chối đăng nhập từ một trang nhúng. Hãy đăng nhập bằng mật khẩu store của bạn thay vào đó.</string> <string name="stores_login_error_http">Trang đăng nhập trả về lỗi (HTTP %1$d).</string> @@ -2956,5 +3001,58 @@ <string name="spatial_saved_views_hint">Các lượt xem đã lưu có thể được tính toán lại. Bắt đầu hiệu chuẩn mới để chụp trong hệ tọa độ theo dõi hiện tại.</string> <string name="spatial_model_checksum_error">Tổng kiểm tra mô hình bàn tay không khớp</string> <string name="spatial_opencv_load_error">OpenCV không thể tải</string> + <string name="setup_group_memory">Bộ nhớ</string> + <string name="setup_clear_cached">Đóng ứng dụng đã lưu đệm trước phiên</string> + <string name="setup_clear_cached_hint">Khi phiên bắt đầu, DroidDeck đóng các ứng dụng mà Android giữ ở nền để game có thêm bộ nhớ. Phải bật Gỡ lỗi qua mạng không dây.</string> + <string name="setup_clear_cached_unpaired">Cần ghép nối DroidDeck với Gỡ lỗi qua mạng không dây (Ghép nối, bên dưới).</string> + <string name="setup_memory_hud">Chỉ báo bộ nhớ</string> + <string name="setup_memory_hud_hint">Bộ nhớ và swap đang dùng, trên màn hình phiên. Nâng cao chia theo game, DroidDeck, Android và GPU.</string> + <string name="setup_memory_hud_simple">Đơn giản</string> + <string name="setup_memory_hud_advanced">Nâng cao</string> + <string name="memory_hud_simple">RAM %1$s/%2$s GB · swap %3$s/%4$s GB</string> + <string name="memory_hud_split">game %1$s · DroidDeck %2$s · Android %3$s GB</string> + <string name="memory_hud_gpu">GPU %1$s GB</string> + <string name="setup_bg_limit">Ứng dụng nền trong phiên</string> + <string name="setup_bg_limit_hint">Trong khi phiên chạy, Android chỉ giữ tối đa chừng này ứng dụng trong bộ nhớ đệm và đóng phần còn lại để game có bộ nhớ của chúng. DroidDeck đặt lại giới hạn như cũ khi phiên kết thúc. Phải bật Gỡ lỗi qua mạng không dây.</string> + <string name="setup_bg_limit_4">Tối đa 4</string> + <string name="setup_bg_limit_1">Tối đa 1</string> + <string name="setup_bg_limit_0">Không có</string> + <string name="setup_adb_pair">Gỡ lỗi qua mạng không dây</string> + <string name="setup_adb_pair_hint">Ghép nối DroidDeck với Gỡ lỗi qua mạng không dây của Android cho các tùy chọn ở trên. Giới hạn tiến trình con vẫn tắt.</string> + <string name="setup_adb_pair_button">Ghép nối</string> <string name="tracking_recovery_look">Nhìn xung quanh chậm rãi trong phòng có đủ ánh sáng.\nGiữ cho camera theo dõi rõ ràng và hướng về phía đồ nội thất hoặc các chi tiết có thể nhìn thấy khác.</string> + <string name="epic_client_missing">Chưa cài</string> + <string name="epic_client_open">Mở</string> + <string name="epic_client_install">Cài đặt</string> + <string name="game_tab_about">Giới thiệu</string> + <string name="game_tab_install">Cài đặt</string> + <string name="game_install_location">Vị trí</string> + <string name="game_install_folder">Thư mục</string> + <string name="game_install_size">Dung lượng trên đĩa</string> + <string name="game_install_date">Đã cài</string> + <string name="stores_xbox_update">Cập nhật / xác minh</string> + <string name="stores_xbox_manage">Quản lý nội dung</string> + <string name="stores_xbox_close_first">Hãy đóng trò chơi Xbox này trước khi cập nhật.</string> + <string name="stores_xbox_content_title">Nội dung Xbox</string> + <string name="stores_xbox_content_none">Không có gói nội dung PC có giấy phép cho trò chơi này.</string> + <string name="stores_xbox_content_update">%1$s · Cập nhật / xác minh</string> + <string name="stores_xbox_content_install">%1$s · Cài đặt</string> + <string name="stores_xbox_choose_content">Chọn nội dung PC</string> + <string name="stores_eyebrow_pass">có trong %1$s</string> + <string name="stores_owned_in_library">Trò chơi đã sở hữu trong Thư viện</string> + <string name="library_filter_added">Đã thêm</string> + <string name="game_tab_launch">Khởi chạy</string> + <string name="game_tab_saves_cloud">Bản lưu và đám mây</string> + <string name="game_launch_shortcut">Lối tắt Steam</string> + <string name="game_install_version">Phiên bản</string> + <string name="game_install_played">Chơi lần cuối</string> + <string name="game_install_access">Quyền truy cập</string> + <string name="game_install_access_owned">Đã sở hữu</string> + <string name="game_stream">Phát trực tuyến</string> + <string name="library_add">Thêm vào Thư viện</string> + <string name="library_remove">Xóa khỏi Thư viện</string> + <string name="library_in">Trong Thư viện</string> + <string name="xbox_filter_show">Hiển thị</string> + <string name="xbox_filter_cloud">Đám mây</string> + <string name="stores_xbox_cloud_settings">Cài đặt đám mây</string> </resources> diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index f95690ecf..2726014a0 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -267,6 +267,24 @@ <string name="osc_steam_touch">Steam 触控(测试版)</string> <string name="osc_style_next_session">Steam 的触控按键将从下次 Steam 会话开始生效</string> <string name="osc_steam_layout">Steam 触控布局</string> + <string name="screen_keys">屏幕按键</string> + <string name="screen_keys_add">添加按键</string> + <string name="screen_key_text">文本</string> + <string name="screen_key_size">大小</string> + <string name="screen_key_opacity">透明</string> + <string name="screen_key_type_button">单键</string> + <string name="screen_key_shape_trigger">扳机</string> + <string name="screen_key_toggle">切换</string> + <string name="screen_key_layout_export">导出</string> + <string name="screen_key_shape_pill">胶囊</string> + <string name="screen_key_shape_round">圆角</string> + <string name="screen_key_shape_circle">圆形</string> + <string name="screen_key_shape_rect">矩形</string> + <string name="screen_key_bind">绑定</string> + <string name="screen_key_image">图片</string> + <string name="screen_key_image_clear">清除</string> + <string name="screen_key_layout">布局</string> + <string name="screen_key_layout_save">保存</string> <string name="osc_steam_layout_hint">移动、调整大小或隐藏此游戏的触控按键;保存到 Steam</string> <string name="mode_controller">手柄</string> <string name="mode_controller_hint">决定 Steam 把你的手柄识别为哪种类型,将在下次会话生效。</string> @@ -287,9 +305,6 @@ <string name="mode_suspend_steam_note">自动会暂停并在显示时恢复。原生会先让 Steam 做好睡眠准备。手动会等待点按恢复。“从不”则保持运行。</string> <string name="mode_background_downloads">后台继续下载</string> <string name="mode_background_downloads_hint">Steam 正在下载时推迟后台暂停。</string> - <string name="mode_zram">zRAM 压缩</string> - <string name="mode_zram_hint">游戏运行时,Steam 客户端的内存会压缩进设备的 zram,为游戏留出更多内存。下次启动 Steam 时生效。</string> - <string name="mode_zram_unsupported">不可用:此设备没有 zram 或 swap,或其内核低于 5.4。</string> <string name="mode_suspend_note">自动会在后台暂停并在显示时恢复。手动会等待点按恢复。“从不”则保持运行。</string> <string name="mode_startup">启动</string> <string name="mode_steam_startup">随 DroidDeck 启动 Steam</string> @@ -544,8 +559,8 @@ <string name="perf_sysmem">Turnip:sysmem 渲染</string> <string name="perf_sysmem_hint">Steam 客户端更快;游戏中驱动会为每个渲染通道选择分块渲染或系统内存渲染。Adreno 710/720/722 必需。</string> <string name="perf_xalia">跳过 Steam 的 xalia 助手</string> - <string name="perf_fastsync">droiddeck-ntsync(实验性)</string> - <string name="perf_fastsync_hint">Linux ntsync 驱动的用户态替代品,供测试使用。开启时,Proton 使用它代替 droiddeck-esync 和 droiddeck-fsync。适用于 Proton Experimental ARM64、GE-Proton 和 proton-cachyos。将在下次会话生效。</string> + <string name="perf_fastsync">droiddeck-ntsync</string> + <string name="perf_fastsync_hint">Linux ntsync 驱动的用户态替代品。开启时,Proton 使用它代替 droiddeck-esync 和 droiddeck-fsync。适用于 Proton Experimental ARM64、GE-Proton 和 proton-cachyos。将在下次会话生效。</string> <string name="perf_esync">droiddeck-esync</string> <string name="perf_fsync">droiddeck-fsync 优先</string> <string name="perf_fsync_hint">在原始 Proton 文件上运行 Proton 自带的 fsync,由用户态提供 futex_waitv,因此无需任何包,且 Steam 更新 Proton 后立即继续可用。开启时,除 Proton 自身禁用 fsync 的少数游戏外,各游戏优先使用它而非 droiddeck-esync。将在下次会话生效。</string> @@ -841,6 +856,7 @@ <string name="setup_download_source_custom">自定义前缀</string> <string name="setup_download_mirror">镜像站点</string> <string name="setup_download_prefix">前缀</string> + <string name="setup_download_proxy">HTTP 代理</string> <string name="setup_tool_files">文件</string> <string name="setup_tool_files_hint">浏览和管理文件</string> <string name="setup_tool_protons">Proton 版本</string> @@ -1104,8 +1120,6 @@ <string name="games_empty">你在 Steam 安装的游戏会在会话结束后显示在这里。</string> <string name="games_play_steam">游玩 Steam</string> <string name="games_play_steamvr">游玩 SteamVR</string> - <string name="games_launch_settings">启动设置</string> - <string name="games_launch">启动</string> <string name="games_files">游戏文件</string> <string name="games_prefix">Proton 前缀</string> <string name="games_saves_looking">正在查找存档…</string> @@ -1113,7 +1127,6 @@ <string name="games_saves_folders">%1$d 个存档文件夹</string> <string name="games_saves_summary">%1$s · %2$d 个文件 · %3$s</string> <string name="games_kb">%1$d KB</string> - <string name="games_manage_saves">管理存档</string> <string name="games_saves">游戏存档</string> <string name="games_import_saves">导入存档…</string> <string name="games_export_gamehub">导出为 GameHub zip 包…</string> @@ -1125,7 +1138,6 @@ <string name="games_runtime_first_play">运行时在首次“游玩”时安装</string> <string name="games_runtime_update">运行时有可用更新</string> <string name="games_runtime_ready">● 运行时就绪</string> - <string name="games_last_played">上次游玩于 %1$s</string> <string name="games_internal">内部存储</string> <string name="games_added">已添加游戏</string> <string name="games_custom">自定义游戏</string> @@ -1194,7 +1206,7 @@ <string name="second_terminal">Linux 终端</string> <string name="second_terminal_not_ready">Linux 会话尚未准备好使用终端。</string> <string name="rail_steam">Steam</string> - <string name="rail_games">游戏</string> + <string name="rail_games">库</string> <string name="rail_desktop">桌面</string> <string name="rail_store">商店</string> <string name="rail_components">组件</string> @@ -1468,6 +1480,10 @@ <string name="theme_icon_blue_detail">黑色背景、白色文字,选中、聚焦或按下的元素使用蓝色。</string> <string name="theme_electric">电光藏青</string> <string name="theme_electric_detail">更明亮的蓝色,配以偏藏青而非灰色的界面。</string> + <string name="theme_inner_gamer">Inner Gamer</string> + <string name="theme_inner_gamer_detail">在彩虹色之间循环变化的颜色。遵循 Android 的动画设置。</string> + <string name="theme_rgb_speed">速度</string> + <string name="theme_rgb_speed_hint">颜色循环的快慢。</string> <string name="theme_gradient">渐变</string> <string name="theme_gradient_detail">你自己选择的主色和辅色,在按钮和高亮处渐变呈现。</string> <string name="theme_gradient_primary">主色</string> @@ -1584,6 +1600,13 @@ <string name="fm_conflict_skip">跳过</string> <string name="fm_select_this_folder">选择此文件夹</string> <string name="fm_back">返回</string> + <string name="fm_exit">退出</string> + <string name="fm_return_to_start">返回初始文件夹</string> + <string name="fm_start_unavailable">初始文件夹已不可用</string> + <string name="fm_cancel_operation_title">取消操作?</string> + <string name="fm_cancel_operation_confirmation">操作将被取消。确定吗?</string> + <string name="fm_accept">接受</string> + <string name="fm_decline">拒绝</string> <string name="fm_drive_not_readable">%1$s 已挂载,但当前无法读取</string> <string name="fm_list_view">列表视图</string> <string name="fm_grid_view">网格视图</string> @@ -1997,7 +2020,6 @@ <string name="source_steam">Steam</string> <string name="source_added">自定义</string> <string name="setup_stores">游戏商店</string> - <string name="setup_stores_open_on">打开商店时显示</string> <string name="setup_stores_speed">下载速度档位</string> <string name="setup_stores_engine">商店引擎</string> <string name="setup_stores_engine_chip_ready">● 引擎就绪</string> @@ -2011,11 +2033,9 @@ <string name="stores_tabs_arrange">整理标签</string> <string name="stores_tabs_arrange_done">完成</string> <string name="stores_chip_signed_out">(未登录)</string> - <string name="stores_tab_store">商店</string> <string name="stores_tab_library">库</string> <string name="stores_tab_all">全部</string> <string name="stores_tab_downloads">下载</string> - <string name="stores_tab_count">%1$s(%2$d)</string> <string name="stores_signin">登录</string> <string name="stores_signout_named">%1$s · 退出登录</string> <string name="stores_signout_title">要从 %1$s 退出登录吗?</string> @@ -2033,9 +2053,10 @@ <string name="stores_shelf_deals">特惠</string> <string name="stores_shelf_free">免费游戏</string> <string name="stores_shelf_trending">趋势</string> - <string name="stores_shelf_library">你的库</string> <string name="stores_play">游玩</string> <string name="stores_install">安装</string> + <string name="bnet_open">打开 Battle.net</string> + <string name="bnet_store">暴雪游戏</string> <string name="stores_resume_install">继续安装</string> <string name="stores_dl_interrupted">已中断</string> <string name="stores_install_size">安装 · %1$s</string> @@ -2054,8 +2075,32 @@ <string name="stores_itch_no_exe">下载内容中未找到 Windows 游戏</string> <string name="stores_itch_extract_failed">无法解压下载内容(代码 %1$d)</string> <string name="stores_itch_no_catalog">itch.io 没有响应</string> + <string name="stores_itch_sort_popular">热门</string> + <string name="stores_itch_sort_new">最新发布</string> + <string name="stores_itch_sort_new_popular">新品与热门</string> + <string name="stores_itch_price_paid">付费</string> + <string name="stores_itch_price_under_5">5 美元以下</string> + <string name="stores_itch_price_under_15">15 美元以下</string> + <string name="stores_itch_sort_top_rated">评分最高</string> + <string name="stores_itch_sort_top_sellers">热销</string> + <string name="stores_itch_price_any">任意价格</string> + <string name="stores_itch_price_free">免费</string> + <string name="stores_itch_price_sale">特价</string> + <string name="stores_itch_load_more">加载更多</string> + <string name="stores_itch_platform_any">任意平台</string> + <string name="stores_itch_platform_mobile_web">移动网页</string> + <string name="stores_itch_tag_add">添加标签</string> + <string name="stores_itch_tag_title">按标签筛选</string> + <string name="stores_itch_browse_failed">无法从 itch.io 加载游戏</string> <string name="stores_itch_archive">不支持 %1$s 压缩包</string> <string name="stores_itch_download_failed">itch.io 返回 HTTP %1$d</string> + <string name="stores_itch_pick_title">选择版本</string> + <string name="stores_itch_pick_none">未找到此游戏的下载</string> + <string name="stores_itch_pick_failed">无法从 itch.io 加载下载内容</string> + <string name="stores_itch_build_demo">试玩版</string> + <string name="stores_itch_build_open">在 itch.io 上打开</string> + <string name="stores_itch_build_linux_soon">暂不支持</string> + <string name="stores_itch_no_game">在 itch.io 上找不到这款游戏</string> <string name="stores_free">免费</string> <string name="stores_resume">继续</string> <string name="stores_refresh">刷新</string> @@ -2205,6 +2250,7 @@ <string name="stores_login_close">关闭</string> <string name="stores_login_retry">重试</string> <string name="stores_login_finishing">正在完成登录…</string> + <string name="stores_xbox_sign_in_again">Microsoft 拒绝了 Xbox 登录。请在游戏商店中退出 Xbox,重新登录,然后继续。</string> <string name="stores_login_error_network">无法加载登录页面:%1$s</string> <string name="stores_login_error_idp_blocked">该第三方登录方式不允许在内嵌页面中登录。请改用商店密码登录。</string> <string name="stores_login_error_http">登录页面返回了错误(HTTP %1$d)。</string> @@ -2960,4 +3006,57 @@ <string name="glasses_6dof">6DoF 虚拟屏幕</string> <string name="tracking_recovery_look">在光线充足的房间内缓慢环顾四周。\n不要遮挡追踪摄像头,并面向家具等有明显细节的物体。</string> <string name="spatial_print_job">DroidDeck ChArUco 30 mm</string> + <string name="setup_group_memory">内存</string> + <string name="setup_clear_cached">会话前关闭缓存的应用</string> + <string name="setup_clear_cached_hint">会话开始时关闭 Android 留在后台的应用,让游戏有更多内存可用。需要开启无线调试。</string> + <string name="setup_clear_cached_unpaired">需要先将 DroidDeck 与无线调试配对(见下方“配对”)。</string> + <string name="setup_memory_hud">内存读数</string> + <string name="setup_memory_hud_hint">在会话画面上显示已用内存和交换空间。高级模式会分别显示游戏、DroidDeck、Android 和 GPU。</string> + <string name="setup_memory_hud_simple">简单</string> + <string name="setup_memory_hud_advanced">高级</string> + <string name="memory_hud_simple">内存 %1$s/%2$s GB · 交换 %3$s/%4$s GB</string> + <string name="memory_hud_split">游戏 %1$s · DroidDeck %2$s · Android %3$s GB</string> + <string name="memory_hud_gpu">GPU %1$s GB</string> + <string name="setup_bg_limit">会话期间的后台应用</string> + <string name="setup_bg_limit_hint">会话运行期间,Android 最多缓存这么多个应用并关闭其余应用,让游戏使用它们的内存。会话结束后恢复原状。需要开启无线调试。</string> + <string name="setup_bg_limit_4">最多 4 个</string> + <string name="setup_bg_limit_1">最多 1 个</string> + <string name="setup_bg_limit_0">无</string> + <string name="setup_adb_pair">无线调试</string> + <string name="setup_adb_pair_hint">为上面的选项将 DroidDeck 与 Android 无线调试配对。子进程限制保持关闭。</string> + <string name="setup_adb_pair_button">配对</string> + <string name="epic_client_missing">未安装</string> + <string name="epic_client_open">打开</string> + <string name="epic_client_install">安装</string> + <string name="game_tab_about">关于</string> + <string name="game_tab_install">安装</string> + <string name="game_install_location">位置</string> + <string name="game_install_folder">文件夹</string> + <string name="game_install_size">占用空间</string> + <string name="game_install_date">已安装</string> + <string name="stores_xbox_update">更新 / 验证</string> + <string name="stores_xbox_manage">管理内容</string> + <string name="stores_xbox_close_first">更新前请先关闭此 Xbox 游戏。</string> + <string name="stores_xbox_content_title">Xbox 内容</string> + <string name="stores_xbox_content_none">此游戏没有已授权的 PC 内容包。</string> + <string name="stores_xbox_content_update">%1$s · 更新 / 验证</string> + <string name="stores_xbox_content_install">%1$s · 安装</string> + <string name="stores_xbox_choose_content">选择 PC 内容</string> + <string name="stores_eyebrow_pass">%1$s 可玩</string> + <string name="stores_owned_in_library">游戏库中已拥有的游戏</string> + <string name="library_filter_added">已添加</string> + <string name="game_tab_launch">启动</string> + <string name="game_tab_saves_cloud">存档和云</string> + <string name="game_launch_shortcut">Steam 快捷方式</string> + <string name="game_install_version">版本</string> + <string name="game_install_played">上次游玩</string> + <string name="game_install_access">访问权限</string> + <string name="game_install_access_owned">已拥有</string> + <string name="game_stream">串流</string> + <string name="library_add">添加到游戏库</string> + <string name="library_remove">从游戏库移除</string> + <string name="library_in">已在游戏库</string> + <string name="xbox_filter_show">显示</string> + <string name="xbox_filter_cloud">云</string> + <string name="stores_xbox_cloud_settings">云设置</string> </resources> diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index eab8f6837..cdcc40b76 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -267,6 +267,24 @@ <string name="osc_steam_touch">Steam 觸控(測試版)</string> <string name="osc_style_next_session">Steam 的觸控控制會在下次 Steam 會話啟用</string> <string name="osc_steam_layout">Steam 觸控佈局</string> + <string name="screen_keys">螢幕按鍵</string> + <string name="screen_keys_add">新增按鍵</string> + <string name="screen_key_text">文本</string> + <string name="screen_key_size">大小</string> + <string name="screen_key_opacity">透明</string> + <string name="screen_key_type_button">單鍵</string> + <string name="screen_key_shape_trigger">扳機</string> + <string name="screen_key_toggle">切換</string> + <string name="screen_key_layout_export">匯出</string> + <string name="screen_key_shape_pill">膠囊</string> + <string name="screen_key_shape_round">圓角</string> + <string name="screen_key_shape_circle">圓形</string> + <string name="screen_key_shape_rect">矩形</string> + <string name="screen_key_bind">綁定</string> + <string name="screen_key_image">圖片</string> + <string name="screen_key_image_clear">清除</string> + <string name="screen_key_layout">佈局</string> + <string name="screen_key_layout_save">儲存</string> <string name="osc_steam_layout_hint">移動、調整大小或隱藏此遊戲的觸控控制;會儲存至 Steam</string> <string name="mode_controller">控制器</string> <string name="mode_controller_hint">Steam 如何識別你的控制器。下次會話生效。</string> @@ -287,9 +305,6 @@ <string name="mode_suspend_steam_note">「自動」會暫停,並在重新顯示時繼續;「原生」會先讓 Steam 準備好休眠;「手動」會等你按「繼續」;「從不」會保持執行。</string> <string name="mode_background_downloads">保持下載進行</string> <string name="mode_background_downloads_hint">Steam 正在下載時,延遲在背景暫停。</string> - <string name="mode_zram">zRAM 壓縮</string> - <string name="mode_zram_hint">遊戲運行時,Steam 用戶端的記憶體會壓縮進裝置的 zram,為遊戲騰出更多記憶體。下次啟動 Steam 時生效。</string> - <string name="mode_zram_unsupported">無法使用:此裝置沒有 zram 或 swap,或核心版本低於 5.4。</string> <string name="mode_suspend_note">「自動」會在背景時暫停,並在重新顯示時繼續;「手動」會等你按「繼續」;「從不」會保持執行。</string> <string name="mode_startup">啟動</string> <string name="mode_steam_startup">隨 DroidDeck 啟動 Steam</string> @@ -544,8 +559,8 @@ <string name="perf_sysmem">Turnip:sysmem 渲染</string> <string name="perf_sysmem_hint">Steam 用戶端會更快;遊戲中驅動程式會為每個渲染通道選擇分塊渲染或系統記憶體渲染。Adreno 710/720/722 必須開啟。</string> <string name="perf_xalia">略過 Steam 的 xalia 輔助程式</string> - <string name="perf_fastsync">droiddeck-ntsync(實驗性)</string> - <string name="perf_fastsync_hint">在用戶空間代替 Linux ntsync 驅動程式,供測試使用。開啟時,Proton 會使用它而非 droiddeck-esync 和 droiddeck-fsync。適用於 Proton Experimental ARM64、GE-Proton 和 proton-cachyos。下次會話生效。</string> + <string name="perf_fastsync">droiddeck-ntsync</string> + <string name="perf_fastsync_hint">在用戶空間代替 Linux ntsync 驅動程式。開啟時,Proton 會使用它而非 droiddeck-esync 和 droiddeck-fsync。適用於 Proton Experimental ARM64、GE-Proton 和 proton-cachyos。下次會話生效。</string> <string name="perf_esync">droiddeck-esync</string> <string name="perf_fsync">優先使用 droiddeck-fsync</string> <string name="perf_fsync_hint">在原裝 Proton 檔案上執行 Proton 本身的 fsync,並在用戶空間提供 futex_waitv,因此無需套件,Steam 更新 Proton 後亦能立即繼續運作。開啟時,遊戲會優先使用它而非 droiddeck-esync,Proton 刻意不用 fsync 的少數遊戲除外。下次會話生效。</string> @@ -1022,8 +1037,6 @@ <string name="games_empty">你在 Steam 安裝的遊戲會在會話結束後顯示在這裡。</string> <string name="games_play_steam">開啟 Steam</string> <string name="games_play_steamvr">開啟 SteamVR</string> - <string name="games_launch_settings">啟動設定</string> - <string name="games_launch">啟動</string> <string name="games_files">遊戲檔案</string> <string name="games_prefix">Proton 前綴</string> <string name="games_saves_looking">正在尋找存檔…</string> @@ -1031,7 +1044,6 @@ <string name="games_saves_folders">%1$d 個存檔資料夾</string> <string name="games_saves_summary">%1$s · %2$d 個檔案 · %3$s</string> <string name="games_kb">%1$d KB</string> - <string name="games_manage_saves">管理存檔</string> <string name="games_saves">遊戲存檔</string> <string name="games_import_saves">匯入存檔…</string> <string name="games_export_gamehub">匯出為 GameHub zip…</string> @@ -1043,7 +1055,6 @@ <string name="games_runtime_first_play">首次開啟 Steam 時安裝運行環境</string> <string name="games_runtime_update">運行環境有可用更新</string> <string name="games_runtime_ready">● 運行環境已就緒</string> - <string name="games_last_played">上次遊玩:%1$s</string> <string name="games_internal">內部儲存空間</string> <string name="games_added">已新增的遊戲</string> <string name="games_custom">自訂遊戲</string> @@ -1112,7 +1123,7 @@ <string name="second_terminal">Linux 終端機</string> <string name="second_terminal_not_ready">Linux 會話尚未準備好使用終端機。</string> <string name="rail_steam">Steam</string> - <string name="rail_games">遊戲</string> + <string name="rail_games">收藏庫</string> <string name="rail_desktop">桌面</string> <string name="rail_store">商店</string> <string name="rail_components">組件</string> @@ -1392,6 +1403,10 @@ <string name="theme_icon_blue_detail">黑色背景、白色文字,所有已選取、焦點所在或按下的項目都使用藍色。</string> <string name="theme_electric">電光海軍藍</string> <string name="theme_electric_detail">更明亮的藍色,搭配偏海軍藍而非灰色的介面。</string> + <string name="theme_inner_gamer">Inner Gamer</string> + <string name="theme_inner_gamer_detail">在彩虹色之間循環變化的顏色。遵循 Android 的動畫設定。</string> + <string name="theme_rgb_speed">速度</string> + <string name="theme_rgb_speed_hint">顏色循環的快慢。</string> <string name="theme_gradient">漸層</string> <string name="theme_gradient_detail">你自行選擇的主色與輔色,在按鈕與強調處以漸層呈現。</string> <string name="theme_gradient_primary">主色</string> @@ -1508,6 +1523,13 @@ <string name="fm_conflict_skip">略過</string> <string name="fm_select_this_folder">選擇此資料夾</string> <string name="fm_back">返回</string> + <string name="fm_exit">離開</string> + <string name="fm_return_to_start">返回初始資料夾</string> + <string name="fm_start_unavailable">初始資料夾已無法使用</string> + <string name="fm_cancel_operation_title">取消操作?</string> + <string name="fm_cancel_operation_confirmation">操作將會取消。確定嗎?</string> + <string name="fm_accept">接受</string> + <string name="fm_decline">拒絕</string> <string name="fm_drive_not_readable">%1$s 已掛載,但目前無法讀取</string> <string name="fm_list_view">清單檢視</string> <string name="fm_grid_view">格狀檢視</string> @@ -1921,7 +1943,6 @@ <string name="source_steam">Steam</string> <string name="source_added">自訂</string> <string name="setup_stores">商店</string> - <string name="setup_stores_open_on">開啟商店時顯示</string> <string name="setup_stores_speed">下載速度等級</string> <string name="setup_stores_engine">商店引擎</string> <string name="setup_stores_engine_chip_ready">● 引擎已就緒</string> @@ -1935,11 +1956,9 @@ <string name="stores_tabs_arrange">整理標籤</string> <string name="stores_tabs_arrange_done">完成</string> <string name="stores_chip_signed_out">(未登入)</string> - <string name="stores_tab_store">商店</string> <string name="stores_tab_library">收藏庫</string> <string name="stores_tab_all">全部</string> <string name="stores_tab_downloads">下載</string> - <string name="stores_tab_count">%1$s(%2$d)</string> <string name="stores_signin">登入</string> <string name="stores_signout_named">%1$s · 登出</string> <string name="stores_signout_title">登出 %1$s?</string> @@ -1957,9 +1976,10 @@ <string name="stores_shelf_deals">優惠</string> <string name="stores_shelf_free">免費遊戲</string> <string name="stores_shelf_trending">趨勢</string> - <string name="stores_shelf_library">你的收藏庫</string> <string name="stores_play">開啟遊戲</string> <string name="stores_install">安裝</string> + <string name="bnet_open">開啟 Battle.net</string> + <string name="bnet_store">Blizzard 遊戲</string> <string name="stores_resume_install">繼續安裝</string> <string name="stores_dl_interrupted">已中斷</string> <string name="stores_install_size">安裝 · %1$s</string> @@ -1978,8 +1998,32 @@ <string name="stores_itch_no_exe">下載內容中找不到 Windows 遊戲</string> <string name="stores_itch_extract_failed">無法解壓下載內容(代碼 %1$d)</string> <string name="stores_itch_no_catalog">itch.io 沒有回應</string> + <string name="stores_itch_sort_popular">熱門</string> + <string name="stores_itch_sort_new">最新發布</string> + <string name="stores_itch_sort_new_popular">新品與熱門</string> + <string name="stores_itch_price_paid">付費</string> + <string name="stores_itch_price_under_5">5 美元以下</string> + <string name="stores_itch_price_under_15">15 美元以下</string> + <string name="stores_itch_sort_top_rated">評分最高</string> + <string name="stores_itch_sort_top_sellers">熱賣</string> + <string name="stores_itch_price_any">任何價格</string> + <string name="stores_itch_price_free">免費</string> + <string name="stores_itch_price_sale">特價</string> + <string name="stores_itch_load_more">載入更多</string> + <string name="stores_itch_platform_any">任何平台</string> + <string name="stores_itch_platform_mobile_web">行動網頁</string> + <string name="stores_itch_tag_add">新增標籤</string> + <string name="stores_itch_tag_title">按標籤篩選</string> + <string name="stores_itch_browse_failed">無法從 itch.io 載入遊戲</string> <string name="stores_itch_archive">不支援 %1$s 壓縮檔</string> <string name="stores_itch_download_failed">itch.io 回應 HTTP %1$d</string> + <string name="stores_itch_pick_title">選擇版本</string> + <string name="stores_itch_pick_none">找不到此遊戲的下載</string> + <string name="stores_itch_pick_failed">無法從 itch.io 載入下載內容</string> + <string name="stores_itch_build_demo">試玩版</string> + <string name="stores_itch_build_open">在 itch.io 開啟</string> + <string name="stores_itch_build_linux_soon">暫不支援</string> + <string name="stores_itch_no_game">在 itch.io 找不到這款遊戲</string> <string name="stores_free">免費</string> <string name="stores_resume">繼續</string> <string name="stores_refresh">重新整理</string> @@ -2132,6 +2176,7 @@ <string name="stores_login_close">關閉</string> <string name="stores_login_retry">再試一次</string> <string name="stores_login_finishing">正在完成登入…</string> + <string name="stores_xbox_sign_in_again">Microsoft 拒絕了 Xbox 登入。請在商店中登出 Xbox,重新登入,然後繼續。</string> <string name="stores_login_error_network">無法載入登入頁面:%1$s</string> <string name="stores_login_error_idp_blocked">此提供者不允許從內嵌頁面登入。請改用商店密碼登入。</string> <string name="stores_login_error_http">登入頁面回應錯誤(HTTP %1$d)。</string> @@ -2960,4 +3005,57 @@ <string name="glasses_6dof">6DoF 虛擬螢幕</string> <string name="tracking_recovery_look">在光線充足的房間內緩慢環顧四周。\n不要遮擋追蹤攝影機,並面向家具等有明顯細節的物體。</string> <string name="spatial_print_job">DroidDeck ChArUco 30 mm</string> + <string name="setup_group_memory">記憶體</string> + <string name="setup_clear_cached">工作階段前關閉快取的應用程式</string> + <string name="setup_clear_cached_hint">工作階段開始時關閉 Android 留在背景的應用程式,讓遊戲有更多記憶體可用。需要開啟無線偵錯。</string> + <string name="setup_clear_cached_unpaired">需要先將 DroidDeck 與無線偵錯配對(見下方「配對」)。</string> + <string name="setup_memory_hud">記憶體讀數</string> + <string name="setup_memory_hud_hint">在工作階段畫面上顯示已用記憶體和交換空間。進階模式會分別顯示遊戲、DroidDeck、Android 和 GPU。</string> + <string name="setup_memory_hud_simple">簡單</string> + <string name="setup_memory_hud_advanced">進階</string> + <string name="memory_hud_simple">記憶體 %1$s/%2$s GB · 交換 %3$s/%4$s GB</string> + <string name="memory_hud_split">遊戲 %1$s · DroidDeck %2$s · Android %3$s GB</string> + <string name="memory_hud_gpu">GPU %1$s GB</string> + <string name="setup_bg_limit">工作階段期間的背景應用程式</string> + <string name="setup_bg_limit_hint">工作階段執行期間,Android 最多快取這麼多個應用程式並關閉其餘的,讓遊戲使用它們的記憶體。工作階段結束後還原。需要開啟無線偵錯。</string> + <string name="setup_bg_limit_4">最多 4 個</string> + <string name="setup_bg_limit_1">最多 1 個</string> + <string name="setup_bg_limit_0">無</string> + <string name="setup_adb_pair">無線偵錯</string> + <string name="setup_adb_pair_hint">為上面的選項將 DroidDeck 與 Android 無線偵錯配對。子進程限制保持關閉。</string> + <string name="setup_adb_pair_button">配對</string> + <string name="epic_client_missing">未安裝</string> + <string name="epic_client_open">開啟</string> + <string name="epic_client_install">安裝</string> + <string name="game_tab_about">關於</string> + <string name="game_tab_install">安裝</string> + <string name="game_install_location">位置</string> + <string name="game_install_folder">資料夾</string> + <string name="game_install_size">佔用空間</string> + <string name="game_install_date">已安裝</string> + <string name="stores_xbox_update">更新 / 驗證</string> + <string name="stores_xbox_manage">管理內容</string> + <string name="stores_xbox_close_first">更新前請先關閉此 Xbox 遊戲。</string> + <string name="stores_xbox_content_title">Xbox 內容</string> + <string name="stores_xbox_content_none">此遊戲沒有已授權的 PC 內容套件。</string> + <string name="stores_xbox_content_update">%1$s · 更新 / 驗證</string> + <string name="stores_xbox_content_install">%1$s · 安裝</string> + <string name="stores_xbox_choose_content">選擇 PC 內容</string> + <string name="stores_eyebrow_pass">%1$s 可玩</string> + <string name="stores_owned_in_library">收藏庫中已擁有的遊戲</string> + <string name="library_filter_added">已加入</string> + <string name="game_tab_launch">啟動</string> + <string name="game_tab_saves_cloud">存檔及雲端</string> + <string name="game_launch_shortcut">Steam 捷徑</string> + <string name="game_install_version">版本</string> + <string name="game_install_played">上次遊玩</string> + <string name="game_install_access">存取權</string> + <string name="game_install_access_owned">已擁有</string> + <string name="game_stream">串流</string> + <string name="library_add">加入收藏庫</string> + <string name="library_remove">從收藏庫移除</string> + <string name="library_in">已在收藏庫</string> + <string name="xbox_filter_show">顯示</string> + <string name="xbox_filter_cloud">雲端</string> + <string name="stores_xbox_cloud_settings">雲端設定</string> </resources> diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index af4ac4b74..43fb5e443 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -267,6 +267,24 @@ <string name="osc_steam_touch">Steam 觸控(測試版)</string> <string name="osc_style_next_session">Steam 觸控控制會在下次 Steam 工作階段啟用</string> <string name="osc_steam_layout">Steam 觸控版面配置</string> + <string name="screen_keys">螢幕按鍵</string> + <string name="screen_keys_add">新增按鍵</string> + <string name="screen_key_text">文本</string> + <string name="screen_key_size">大小</string> + <string name="screen_key_opacity">透明</string> + <string name="screen_key_type_button">單鍵</string> + <string name="screen_key_shape_trigger">扳機</string> + <string name="screen_key_toggle">切換</string> + <string name="screen_key_layout_export">匯出</string> + <string name="screen_key_shape_pill">膠囊</string> + <string name="screen_key_shape_round">圓角</string> + <string name="screen_key_shape_circle">圓形</string> + <string name="screen_key_shape_rect">矩形</string> + <string name="screen_key_bind">綁定</string> + <string name="screen_key_image">圖片</string> + <string name="screen_key_image_clear">清除</string> + <string name="screen_key_layout">版面配置</string> + <string name="screen_key_layout_save">儲存</string> <string name="osc_steam_layout_hint">移動、調整大小或隱藏這款遊戲的觸控控制;會儲存到 Steam</string> <string name="mode_controller">控制器</string> <string name="mode_controller_hint">Steam 辨識你的控制器的方式。下次工作階段生效。</string> @@ -287,9 +305,6 @@ <string name="mode_suspend_steam_note">「自動」會暫停,並在重新顯示時繼續。「原生」會先讓 Steam 做好睡眠準備。「手動」會等你點擊「繼續」。「從不」則會持續執行。</string> <string name="mode_background_downloads">讓下載持續進行</string> <string name="mode_background_downloads_hint">Steam 正在下載時,延後在背景暫停。</string> - <string name="mode_zram">zRAM 壓縮</string> - <string name="mode_zram_hint">遊戲執行時,Steam 用戶端記憶體會壓縮進裝置的 zram,為遊戲騰出更多記憶體。下次啟動 Steam 時生效。</string> - <string name="mode_zram_unsupported">無法使用:此裝置沒有 zram 或 swap,或核心版本低於 5.4。</string> <string name="mode_suspend_note">「自動」會在背景時暫停,並在重新顯示時繼續。「手動」會等你點擊「繼續」。「從不」則會持續執行。</string> <string name="mode_startup">啟動</string> <string name="mode_steam_startup">隨 DroidDeck 啟動 Steam</string> @@ -544,8 +559,8 @@ <string name="perf_sysmem">Turnip:sysmem 渲染</string> <string name="perf_sysmem_hint">Steam 用戶端會更快;遊戲中驅動程式會為每個算繪通道選擇分塊算繪或系統記憶體算繪。Adreno 710/720/722 必需。</string> <string name="perf_xalia">跳過 Steam 的 xalia 輔助程式</string> - <string name="perf_fastsync">droiddeck-ntsync(實驗性)</string> - <string name="perf_fastsync_hint">以使用者空間替代 Linux ntsync 驅動程式,供測試使用。開啟時,Proton 會使用它來取代 droiddeck-esync 和 droiddeck-fsync。適用於 Proton Experimental ARM64、GE-Proton 和 proton-cachyos。下次工作階段生效。</string> + <string name="perf_fastsync">droiddeck-ntsync</string> + <string name="perf_fastsync_hint">以使用者空間替代 Linux ntsync 驅動程式。開啟時,Proton 會使用它來取代 droiddeck-esync 和 droiddeck-fsync。適用於 Proton Experimental ARM64、GE-Proton 和 proton-cachyos。下次工作階段生效。</string> <string name="perf_esync">droiddeck-esync</string> <string name="perf_fsync">優先使用 droiddeck-fsync</string> <string name="perf_fsync_hint">在原版 Proton 檔案上執行 Proton 自己的 fsync,並在使用者空間提供 futex_waitv,因此不需要套件,Steam 更新 Proton 後也能立即繼續運作。開啟時,遊戲會優先使用它而非 droiddeck-esync,Proton 本身停用 fsync 的少數遊戲除外。下次工作階段生效。</string> @@ -1022,8 +1037,6 @@ <string name="games_empty">在 Steam 中安裝的遊戲會在工作階段結束後顯示在這裡。</string> <string name="games_play_steam">執行 Steam</string> <string name="games_play_steamvr">執行 SteamVR</string> - <string name="games_launch_settings">啟動設定</string> - <string name="games_launch">啟動</string> <string name="games_files">遊戲檔案</string> <string name="games_prefix">Proton 前綴</string> <string name="games_saves_looking">正在尋找存檔…</string> @@ -1031,7 +1044,6 @@ <string name="games_saves_folders">%1$d 個存檔資料夾</string> <string name="games_saves_summary">%1$s · %2$d 個檔案 · %3$s</string> <string name="games_kb">%1$d KB</string> - <string name="games_manage_saves">管理存檔</string> <string name="games_saves">遊戲存檔</string> <string name="games_import_saves">匯入存檔…</string> <string name="games_export_gamehub">匯出為 GameHub 壓縮檔…</string> @@ -1043,7 +1055,6 @@ <string name="games_runtime_first_play">首次點擊「執行」時安裝執行環境</string> <string name="games_runtime_update">執行環境有可用更新</string> <string name="games_runtime_ready">● 執行環境已就緒</string> - <string name="games_last_played">上次執行 %1$s</string> <string name="games_internal">內部儲存空間</string> <string name="games_added">已新增的遊戲</string> <string name="games_custom">自訂遊戲</string> @@ -1112,7 +1123,7 @@ <string name="second_terminal">Linux 終端機</string> <string name="second_terminal_not_ready">Linux 工作階段尚未準備好,無法使用終端機。</string> <string name="rail_steam">Steam</string> - <string name="rail_games">遊戲</string> + <string name="rail_games">收藏庫</string> <string name="rail_desktop">桌面</string> <string name="rail_store">商店</string> <string name="rail_components">元件</string> @@ -1392,6 +1403,10 @@ <string name="theme_icon_blue_detail">黑色背景、白色文字,選取、聚焦或按下的項目使用藍色。</string> <string name="theme_electric">電光藏青</string> <string name="theme_electric_detail">更亮的藍色,搭配偏藏青而非灰色的介面。</string> + <string name="theme_inner_gamer">Inner Gamer</string> + <string name="theme_inner_gamer_detail">在彩虹色之間循環變化的顏色。遵循 Android 的動畫設定。</string> + <string name="theme_rgb_speed">速度</string> + <string name="theme_rgb_speed_hint">顏色循環的快慢。</string> <string name="theme_gradient">漸層</string> <string name="theme_gradient_detail">你自行選擇的主色與輔色,在按鈕與強調處以漸層呈現。</string> <string name="theme_gradient_primary">主色</string> @@ -1508,6 +1523,13 @@ <string name="fm_conflict_skip">略過</string> <string name="fm_select_this_folder">選擇此資料夾</string> <string name="fm_back">返回</string> + <string name="fm_exit">離開</string> + <string name="fm_return_to_start">返回起始資料夾</string> + <string name="fm_start_unavailable">起始資料夾已無法使用</string> + <string name="fm_cancel_operation_title">取消操作?</string> + <string name="fm_cancel_operation_confirmation">操作將會取消。確定嗎?</string> + <string name="fm_accept">接受</string> + <string name="fm_decline">拒絕</string> <string name="fm_drive_not_readable">%1$s 已掛載,但目前無法讀取</string> <string name="fm_list_view">清單檢視</string> <string name="fm_grid_view">格狀檢視</string> @@ -1921,7 +1943,6 @@ <string name="source_steam">Steam</string> <string name="source_added">自訂</string> <string name="setup_stores">遊戲商店</string> - <string name="setup_stores_open_on">開啟商店時顯示</string> <string name="setup_stores_speed">下載速度等級</string> <string name="setup_stores_engine">商店引擎</string> <string name="setup_stores_engine_chip_ready">● 引擎已就緒</string> @@ -1935,11 +1956,9 @@ <string name="stores_tabs_arrange">整理分頁</string> <string name="stores_tabs_arrange_done">完成</string> <string name="stores_chip_signed_out">(未登入)</string> - <string name="stores_tab_store">商店</string> <string name="stores_tab_library">收藏庫</string> <string name="stores_tab_all">全部</string> <string name="stores_tab_downloads">下載</string> - <string name="stores_tab_count">%1$s(%2$d)</string> <string name="stores_signin">登入</string> <string name="stores_signout_named">%1$s · 登出</string> <string name="stores_signout_title">要登出 %1$s 嗎?</string> @@ -1957,9 +1976,10 @@ <string name="stores_shelf_deals">特惠</string> <string name="stores_shelf_free">免費遊戲</string> <string name="stores_shelf_trending">趨勢</string> - <string name="stores_shelf_library">你的收藏庫</string> <string name="stores_play">執行</string> <string name="stores_install">安裝</string> + <string name="bnet_open">開啟 Battle.net</string> + <string name="bnet_store">Blizzard 遊戲</string> <string name="stores_resume_install">繼續安裝</string> <string name="stores_dl_interrupted">已中斷</string> <string name="stores_install_size">安裝 · %1$s</string> @@ -1978,8 +1998,32 @@ <string name="stores_itch_no_exe">下載內容中找不到 Windows 遊戲</string> <string name="stores_itch_extract_failed">無法解壓縮下載內容(代碼 %1$d)</string> <string name="stores_itch_no_catalog">itch.io 沒有回應</string> + <string name="stores_itch_sort_popular">熱門</string> + <string name="stores_itch_sort_new">最新發布</string> + <string name="stores_itch_sort_new_popular">新品與熱門</string> + <string name="stores_itch_price_paid">付費</string> + <string name="stores_itch_price_under_5">5 美元以下</string> + <string name="stores_itch_price_under_15">15 美元以下</string> + <string name="stores_itch_sort_top_rated">評分最高</string> + <string name="stores_itch_sort_top_sellers">熱銷</string> + <string name="stores_itch_price_any">任何價格</string> + <string name="stores_itch_price_free">免費</string> + <string name="stores_itch_price_sale">特價</string> + <string name="stores_itch_load_more">載入更多</string> + <string name="stores_itch_platform_any">任何平台</string> + <string name="stores_itch_platform_mobile_web">行動網頁</string> + <string name="stores_itch_tag_add">新增標籤</string> + <string name="stores_itch_tag_title">依標籤篩選</string> + <string name="stores_itch_browse_failed">無法從 itch.io 載入遊戲</string> <string name="stores_itch_archive">不支援 %1$s 壓縮檔</string> <string name="stores_itch_download_failed">itch.io 回應 HTTP %1$d</string> + <string name="stores_itch_pick_title">選擇版本</string> + <string name="stores_itch_pick_none">找不到此遊戲的下載</string> + <string name="stores_itch_pick_failed">無法從 itch.io 載入下載內容</string> + <string name="stores_itch_build_demo">試玩版</string> + <string name="stores_itch_build_open">在 itch.io 開啟</string> + <string name="stores_itch_build_linux_soon">尚未支援</string> + <string name="stores_itch_no_game">在 itch.io 找不到這款遊戲</string> <string name="stores_free">免費</string> <string name="stores_resume">繼續</string> <string name="stores_refresh">重新整理</string> @@ -2132,6 +2176,7 @@ <string name="stores_login_close">關閉</string> <string name="stores_login_retry">再試一次</string> <string name="stores_login_finishing">正在完成登入…</string> + <string name="stores_xbox_sign_in_again">Microsoft 拒絕了 Xbox 登入。請在遊戲商店中登出 Xbox,重新登入,然後繼續。</string> <string name="stores_login_error_network">無法載入登入頁面:%1$s</string> <string name="stores_login_error_idp_blocked">此提供者拒絕從內嵌頁面登入。請改用商店密碼登入。</string> <string name="stores_login_error_http">登入頁面回應錯誤(HTTP %1$d)。</string> @@ -2960,4 +3005,57 @@ <string name="glasses_6dof">6DoF 虛擬螢幕</string> <string name="tracking_recovery_look">在光線充足的房間內緩慢環顧四周。\n不要遮擋追蹤攝影機,並面向家具等有明顯細節的物體。</string> <string name="spatial_print_job">DroidDeck ChArUco 30 mm</string> + <string name="setup_group_memory">記憶體</string> + <string name="setup_clear_cached">工作階段前關閉快取的應用程式</string> + <string name="setup_clear_cached_hint">工作階段開始時關閉 Android 留在背景的應用程式,讓遊戲有更多記憶體可用。需要開啟無線偵錯。</string> + <string name="setup_clear_cached_unpaired">需要先將 DroidDeck 與無線偵錯配對(見下方「配對」)。</string> + <string name="setup_memory_hud">記憶體讀數</string> + <string name="setup_memory_hud_hint">在工作階段畫面上顯示已用記憶體和交換空間。進階模式會分別顯示遊戲、DroidDeck、Android 和 GPU。</string> + <string name="setup_memory_hud_simple">簡單</string> + <string name="setup_memory_hud_advanced">進階</string> + <string name="memory_hud_simple">記憶體 %1$s/%2$s GB · 交換 %3$s/%4$s GB</string> + <string name="memory_hud_split">遊戲 %1$s · DroidDeck %2$s · Android %3$s GB</string> + <string name="memory_hud_gpu">GPU %1$s GB</string> + <string name="setup_bg_limit">工作階段期間的背景應用程式</string> + <string name="setup_bg_limit_hint">工作階段執行期間,Android 最多快取這麼多個應用程式並關閉其餘的,讓遊戲使用它們的記憶體。工作階段結束後還原。需要開啟無線偵錯。</string> + <string name="setup_bg_limit_4">最多 4 個</string> + <string name="setup_bg_limit_1">最多 1 個</string> + <string name="setup_bg_limit_0">無</string> + <string name="setup_adb_pair">無線偵錯</string> + <string name="setup_adb_pair_hint">為上面的選項將 DroidDeck 與 Android 無線偵錯配對。子處理程序限制保持關閉。</string> + <string name="setup_adb_pair_button">配對</string> + <string name="epic_client_missing">未安裝</string> + <string name="epic_client_open">開啟</string> + <string name="epic_client_install">安裝</string> + <string name="game_tab_about">關於</string> + <string name="game_tab_install">安裝</string> + <string name="game_install_location">位置</string> + <string name="game_install_folder">資料夾</string> + <string name="game_install_size">佔用空間</string> + <string name="game_install_date">已安裝</string> + <string name="stores_xbox_update">更新 / 驗證</string> + <string name="stores_xbox_manage">管理內容</string> + <string name="stores_xbox_close_first">更新前請先關閉這款 Xbox 遊戲。</string> + <string name="stores_xbox_content_title">Xbox 內容</string> + <string name="stores_xbox_content_none">這款遊戲沒有已授權的 PC 內容套件。</string> + <string name="stores_xbox_content_update">%1$s · 更新 / 驗證</string> + <string name="stores_xbox_content_install">%1$s · 安裝</string> + <string name="stores_xbox_choose_content">選擇 PC 內容</string> + <string name="stores_eyebrow_pass">%1$s 可玩</string> + <string name="stores_owned_in_library">收藏庫中已擁有的遊戲</string> + <string name="library_filter_added">已加入</string> + <string name="game_tab_launch">啟動</string> + <string name="game_tab_saves_cloud">存檔與雲端</string> + <string name="game_launch_shortcut">Steam 捷徑</string> + <string name="game_install_version">版本</string> + <string name="game_install_played">上次遊玩</string> + <string name="game_install_access">存取權</string> + <string name="game_install_access_owned">已擁有</string> + <string name="game_stream">串流</string> + <string name="library_add">加入收藏庫</string> + <string name="library_remove">從收藏庫移除</string> + <string name="library_in">已在收藏庫</string> + <string name="xbox_filter_show">顯示</string> + <string name="xbox_filter_cloud">雲端</string> + <string name="stores_xbox_cloud_settings">雲端設定</string> </resources> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 312dcd2c7..416e25e4e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -85,6 +85,8 @@ <string name="game_env_vkd3d_config">VKD3D options for D3D12. nodxr disables ray tracing; use only for a game that needs it.</string> <string name="game_env_proton_log">Set to 1 for a Proton diagnostic log. Logging can reduce performance and use storage.</string> <string name="game_env_wined3d">Set to 1 to use OpenGL-based WineD3D instead of DXVK for D3D9–11 compatibility testing.</string> + <string name="game_env_wined3d_renderer">Renderer for DirectDraw, Direct3D 1-7 and games run with WineD3D. Vulkan skips the OpenGL-on-Zink path and is much faster for old DirectDraw and Direct3D 7 games on Adreno.</string> + <string name="game_env_dxvk_d3d8">Set to 1 to run Direct3D 8 through DXVK instead of WineD3D. Faster in AIO Graphics Test\'s Direct3D 8 test on Adreno.</string> <string name="game_env_no_ntsync">Set to 1 to keep this game off ntsync (droiddeck-ntsync or a kernel driver); it uses droiddeck-esync where a pack exists, otherwise droiddeck-fsync.</string> <string name="game_env_no_esync">Set to 1 to turn droiddeck-esync off for this game; it uses droiddeck-fsync instead, or droiddeck-ntsync where fsync is off too.</string> <string name="game_env_no_fsync">Set to 1 to keep this game off droiddeck-fsync; it uses droiddeck-esync where a pack exists, otherwise droiddeck-ntsync.</string> @@ -93,6 +95,7 @@ <string name="game_env_anticheat_offline">On by default. Starts an Easy Anti-Cheat game without its anti-cheat when the game supports offline play. Turn off to launch through Easy Anti-Cheat as usual. Online modes that require Easy Anti-Cheat remain unavailable.</string> <string name="game_env_hud">DXVK overlay for D3D9–11. fps displays frame rate; unset hides it.</string> <string name="game_env_dxvk_config">DXVK settings separated by semicolons. Recent builds use dxvk.maxFrameRate for frame limiting; prefer the session frame limiter for ordinary play.</string> + <string name="game_env_cores">CPU cores for this game, such as 0-4 or 0,2,4, in place of the session\'s game cores. Fewer cores run cooler.</string> <string name="game_env_vkd3d_limit">D3D12 frame-rate limit. 0 disables the limit.</string> <string name="game_env_glthread">Mesa OpenGL threading. true can help CPU-limited OpenGL games; test per game.</string> <string name="sync_backend_title">Wine sync</string> @@ -143,6 +146,7 @@ <string name="user_apps_remove">Remove</string> <string name="user_apps_remove_confirm">Press again to remove</string> <string name="user_apps_stop_session">Stop the session to remove</string> + <string name="bnet_name" translatable="false">Battle.net</string> <string name="user_apps_script_linked">Runs %1$s from its folder, linked into the session where it lies. Removing the app leaves that folder alone.</string> <string name="user_apps_script_copied">Runs %1$s from a copy of its folder inside DroidDeck. Removing the app deletes the copy, not the original.</string> <string name="user_apps_runtime_required">Install the Linux runtime first</string> @@ -253,6 +257,8 @@ <string name="mode_ratio_custom">Set by the custom resolution.</string> <string name="mode_ratio_auto">Auto uses at least 16:9.</string> <string name="mode_fps">Frame limit</string> + <string name="mode_performance">Performance mode</string> + <string name="mode_performance_hint">With no frame limit, games run as fast as they can: frames go back to the game without waiting for the screen. With a frame limit, frames stay paced to it. Either way, gamescope and Wine skip repeated window checks. Uses more power and runs warmer without a limit. Applies next session.</string> <string name="mode_fps_note">Caps the whole session. The display switches to a refresh rate the cap divides evenly.</string> <string name="mode_hdr">HDR</string> <string name="mode_hdr10">HDR10 output</string> @@ -272,6 +278,24 @@ <string name="osc_steam_touch">Steam touch (beta)</string> <string name="osc_style_next_session">Steam\'s touch controls start with the next Steam session</string> <string name="osc_steam_layout">Steam touch layout</string> + <string name="screen_keys">Screen keys</string> + <string name="screen_keys_add">Add key</string> + <string name="screen_key_text">Text</string> + <string name="screen_key_size">Size</string> + <string name="screen_key_opacity">Alpha</string> + <string name="screen_key_type_button">Key</string> + <string name="screen_key_shape_pill">Pill</string> + <string name="screen_key_shape_round">Round</string> + <string name="screen_key_shape_circle">Circle</string> + <string name="screen_key_shape_rect">Rect</string> + <string name="screen_key_shape_trigger">Trigger</string> + <string name="screen_key_bind">Bind</string> + <string name="screen_key_image">Image</string> + <string name="screen_key_image_clear">Clear</string> + <string name="screen_key_toggle">Toggle</string> + <string name="screen_key_layout">Layout</string> + <string name="screen_key_layout_save">Save</string> + <string name="screen_key_layout_export">Export</string> <string name="osc_steam_layout_hint">Move, resize or hide this game\'s touch controls; saved to Steam</string> <string name="mode_controller">Controller</string> <string name="mode_controller_hint">How Steam sees your controller. Applies next session.</string> @@ -292,9 +316,6 @@ <string name="mode_suspend_steam_note">Auto pauses and resumes when shown. Native prepares Steam for sleep first. Manual waits for Resume. Never keeps running.</string> <string name="mode_background_downloads">Keep downloads running</string> <string name="mode_background_downloads_hint">Delay background suspension while Steam is actively downloading.</string> - <string name="mode_zram">zRAM compression</string> - <string name="mode_zram_hint">While a game runs, the Steam client\'s memory is compressed into the device\'s zram, leaving more RAM for the game. Applies the next time Steam starts.</string> - <string name="mode_zram_unsupported">Unavailable: this device has no zram or swap, or its kernel is older than 5.4.</string> <string name="mode_suspend_note">Auto pauses in the background and resumes when shown. Manual waits for Resume. Never keeps running.</string> <string name="mode_startup">Startup</string> <string name="mode_steam_startup">Start Steam with DroidDeck</string> @@ -323,6 +344,8 @@ <string name="mode_mangoapp_hint">Turn off if games crash or go black in Deck mode. Applies next session.</string> <string name="mode_return_after_game">Return to DroidDeck after a game</string> <string name="mode_return_after_game_hint">When a game you started from DroidDeck or another launcher closes, Steam exits cleanly (cloud saves finish first) and you go back there. Off: stay in Steam.</string> + <string name="mode_shader_precache">Shader pre-caching</string> + <string name="mode_shader_precache_hint">Steam downloads shaders prebuilt for your games and processes them in the background, so games stutter less. Off saves data, storage and battery; games build shaders as they play instead. The same setting as Steam’s Downloads > Shader Pre-Caching. Applies next session.</string> <string name="mode_steam_repair">Repair Steam client</string> <string name="mode_steam_repair_hint">Downloads the Steam client again the next time Steam starts. Use it if Steam stays on its loading screen. Games, sign-in and settings are kept.</string> <string name="mode_steam_repair_queued">The Steam client will be downloaded again the next time Steam starts.</string> @@ -390,6 +413,8 @@ <string name="mode_directaudio_hint">Bypasses PulseAudio for lower latency.</string> <string name="mode_mic">Microphone</string> <string name="mode_mic_hint">For voice chat.</string> + <string name="drawer_mic_hint">Turn microphone capture on or off for this session.</string> + <string name="drawer_mic_failed">Could not change the microphone. Please try again.</string> <string name="mode_network">Network</string> <string name="mode_wifi_discovery">Wi-Fi discovery</string> <string name="mode_wifi_discovery_hint">Show Wi-Fi names and nearby networks in Steam.</string> @@ -551,14 +576,24 @@ <string name="perf_gsrealtime">gamescope: realtime GPU queue</string> <string name="perf_gsrealtime_hint">Prioritizes the compositor\'s GPU work. Can smooth frame pacing at some cost to games.</string> <string name="perf_gpu">GPU</string> + <string name="perf_memory">Memory</string> + <string name="perf_cap_off">Off</string> + <string name="perf_cap_auto">Driver default</string> + <string name="perf_zram_client">Compress Steam while a game runs</string> + <string name="perf_zram_client_hint">20 s after a game starts, the Steam client is compressed into zRAM to leave the game more memory, and read back when the game closes. Turn this off if the session closes shortly after games start. A RAM cap above still compresses Steam when the session goes over it.</string> + <string name="perf_ram_cap">RAM cap</string> + <string name="perf_ram_cap_hint">Applies while the Steam client runs, in Steam mode or from the desktop. When the session uses more than this, Steam and then the session\'s other supported processes are compressed into zRAM. A soft cap: nothing is closed, but games may stutter while pages come back.</string> + <string name="perf_ram_cap_unavailable">Needs zRAM swap, which this device does not have.</string> + <string name="perf_vram_cap">VRAM cap</string> + <string name="perf_vram_cap_hint">The video memory DXVK reports to Direct3D games, so they budget textures for less. A game\'s own DXVK settings still win. Takes effect at the next session.</string> <string name="perf_gpuclock">Hold the GPU at its top clock</string> <string name="perf_gpuclock_hint">Adreno only. Fewer hitches, more battery use and heat.</string> <string name="perf_fixes">Session fixes</string> <string name="perf_sysmem">Turnip: sysmem rendering</string> <string name="perf_sysmem_hint">Faster for the Steam client; in games the driver picks tile or system-memory rendering per pass. Required on Adreno 710/720/722.</string> <string name="perf_xalia">Skip Steam\'s xalia helper</string> - <string name="perf_fastsync">droiddeck-ntsync (experimental)</string> - <string name="perf_fastsync_hint">A userspace stand-in for the Linux ntsync driver, for testing. While it is on, Proton uses it instead of droiddeck-esync and droiddeck-fsync. Works with Proton Experimental ARM64, GE-Proton and proton-cachyos. Applies next session.</string> + <string name="perf_fastsync">droiddeck-ntsync</string> + <string name="perf_fastsync_hint">A userspace stand-in for the Linux ntsync driver. While it is on, Proton uses it instead of droiddeck-esync and droiddeck-fsync. Works with Proton Experimental ARM64, GE-Proton and proton-cachyos. Applies next session.</string> <string name="perf_esync">droiddeck-esync</string> <string name="perf_fsync">droiddeck-fsync first</string> <string name="perf_fsync_hint">Runs Proton\'s own fsync on the stock Proton files, with futex_waitv supplied in userspace, so it needs no pack and keeps working the moment Steam updates Proton. While it is on, games use it ahead of droiddeck-esync, except the few Proton keeps off fsync. Applies next session.</string> @@ -569,6 +604,8 @@ <string name="perf_fastpath">proot fast path</string> <string name="perf_fastpath_needs_seccomp">Needs proot\'s seccomp filter.</string> <string name="perf_fastpath_hint">Faster file lookups and less stutter. Turn off if a program can\'t find its files.</string> + <string name="perf_cursor_composite">Draw the cursor inside gamescope</string> + <string name="perf_cursor_composite_hint">Gamescope paints the pointer itself instead of handing it to Android. Turn on for a controller-driven cursor. Off is faster on a phone. Applies next session.</string> <string name="perf_identity">Session identity</string> <string name="perf_hostname">Host name</string> <string name="perf_hostname_hint">The name Steam sees for this device. Blank restores %1$s.</string> @@ -1066,8 +1103,6 @@ <string name="games_empty">Games you install in Steam show up here after the session.</string> <string name="games_play_steam">Play Steam</string> <string name="games_play_steamvr">Play SteamVR</string> - <string name="games_launch_settings">Launch settings</string> - <string name="games_launch">Launch</string> <string name="games_files">Game files</string> <string name="games_prefix">Proton prefix</string> <string name="games_saves_looking">Looking for saves…</string> @@ -1075,7 +1110,6 @@ <string name="games_saves_folders">%1$d save folders</string> <string name="games_saves_summary">%1$s · %2$d files · %3$s</string> <string name="games_kb">%1$d KB</string> - <string name="games_manage_saves">Manage saves</string> <string name="games_saves">Game saves</string> <string name="games_import_saves">Import saves…</string> <string name="games_export_gamehub">Export as GameHub zip…</string> @@ -1087,7 +1121,6 @@ <string name="games_runtime_first_play">Runtime installs on first Play</string> <string name="games_runtime_update">Runtime update available</string> <string name="games_runtime_ready">● Runtime ready</string> - <string name="games_last_played">Last played %1$s</string> <string name="games_internal">Internal storage</string> <string name="games_added">Added game</string> <string name="games_custom">Custom game</string> @@ -1102,6 +1135,9 @@ <string name="gpu_hide_other">Hide drivers for other GPUs</string> <string name="gpu_show_other">Show drivers for other GPUs</string> <string name="gpu_advanced">Advanced</string> + <string name="gpu_ubwc_title">UBWC compatibility</string> + <string name="gpu_ubwc_hint">Try On or Off if Steam or Android shows stripes or flickering. Applies to both drivers after restarting DroidDeck.</string> + <string name="gpu_ubwc_restart">Restart DroidDeck to apply.</string> <string name="gpu_set_with_display">Set together with the display driver: %1$s carries both.</string> <string name="gpu_set_with_runtime">Set together with the runtime driver: %1$s carries both.</string> <string name="gpu_runtime_hint">Used by games and the desktop.</string> @@ -1156,7 +1192,7 @@ <string name="second_terminal">Linux terminal</string> <string name="second_terminal_not_ready">The Linux session isn\'t ready for a terminal yet.</string> <string name="rail_steam">Steam</string> - <string name="rail_games">Games</string> + <string name="rail_games">Library</string> <string name="rail_desktop">Desktop</string> <string name="rail_store">Store</string> <string name="rail_components">Components</string> @@ -1461,6 +1497,10 @@ <string name="theme_icon_blue_detail">Black ground, white type, the blue for everything selected, focused or pressed.</string> <string name="theme_electric">Electric navy</string> <string name="theme_electric_detail">The blue brighter, on surfaces that lean navy instead of grey.</string> + <string name="theme_inner_gamer">Inner Gamer</string> + <string name="theme_inner_gamer_detail">Colors that cycle through the rainbow. Follows Android\'s animation settings.</string> + <string name="theme_rgb_speed">Speed</string> + <string name="theme_rgb_speed_hint">How fast the colors cycle.</string> <string name="theme_gradient">Gradient</string> <string name="theme_gradient_detail">Your own primary and secondary colors, blended on buttons and highlights.</string> <string name="theme_gradient_primary">Primary</string> @@ -1582,6 +1622,13 @@ <string name="fm_conflict_skip">Skip</string> <string name="fm_select_this_folder">Select this folder</string> <string name="fm_back">Back</string> + <string name="fm_exit">Exit</string> + <string name="fm_return_to_start">Return to start</string> + <string name="fm_start_unavailable">Starting folder is no longer available</string> + <string name="fm_cancel_operation_title">Cancel operation?</string> + <string name="fm_cancel_operation_confirmation">The operation will be cancelled. Are you sure?</string> + <string name="fm_accept">Accept</string> + <string name="fm_decline">Decline</string> <string name="fm_drive_not_readable">%1$s is mounted but not readable right now</string> <string name="fm_list_view">List view</string> <string name="fm_grid_view">Grid view</string> @@ -1982,7 +2029,7 @@ <string name="display_resolution">Resolution</string> <string name="display_game_fit">16:9 game fit</string> <string name="display_game_fit_hint">How 16:9 games fit this display. Next session.</string> - <string name="display_game_fit_unavailable">Choose Match screen or a custom size narrower than 16:9.</string> + <string name="display_game_fit_unavailable">Choose Match screen or a custom size that isn\'t 16:9.</string> <string name="display_preserve">Keep proportions</string> <string name="display_stretch">Stretch to display</string> <string name="display_image_scaling">Image scaling</string> @@ -2009,7 +2056,6 @@ <string name="source_steam">Steam</string> <string name="source_added">Custom</string> <string name="setup_stores">Stores</string> - <string name="setup_stores_open_on">Open a store on</string> <string name="setup_stores_speed">Download speed tier</string> <string name="setup_stores_engine">Stores engine</string> <string name="setup_stores_engine_chip_ready">● Engine ready</string> @@ -2023,11 +2069,9 @@ <string name="stores_tabs_arrange">Arrange tabs</string> <string name="stores_tabs_arrange_done">Done</string> <string name="stores_chip_signed_out">(not signed in)</string> - <string name="stores_tab_store">Store</string> <string name="stores_tab_library">Library</string> <string name="stores_tab_all">All</string> <string name="stores_tab_downloads">Downloads</string> - <string name="stores_tab_count">%1$s (%2$d)</string> <string name="stores_signin">Sign in</string> <string name="stores_signout_named">%1$s · sign out</string> <string name="stores_signout_title">Sign out of %1$s?</string> @@ -2045,9 +2089,10 @@ <string name="stores_shelf_deals">Deals</string> <string name="stores_shelf_free">Free games</string> <string name="stores_shelf_trending">Trending</string> - <string name="stores_shelf_library">Your library</string> <string name="stores_play">Play</string> <string name="stores_install">Install</string> + <string name="bnet_open">Open Battle.net</string> + <string name="bnet_store">Blizzard games</string> <string name="stores_resume_install">Resume install</string> <string name="stores_dl_interrupted">Interrupted</string> <string name="stores_install_size">Install · %1$s</string> @@ -2066,8 +2111,43 @@ <string name="stores_itch_no_exe">No Windows game found in the download</string> <string name="stores_itch_extract_failed">Could not extract the download (exit %1$d)</string> <string name="stores_itch_no_catalog">itch.io did not answer</string> + <string name="stores_itch_sort_popular">Popular</string> + <string name="stores_itch_sort_new">Most recent</string> + <string name="stores_itch_sort_new_popular">New & popular</string> + <string name="stores_itch_price_paid">Paid</string> + <string name="stores_itch_price_under_5">$5 or less</string> + <string name="stores_itch_price_under_15">$15 or less</string> + <string name="stores_itch_sort_top_rated">Top rated</string> + <string name="stores_itch_sort_top_sellers">Top sellers</string> + <string name="stores_itch_price_any">Any price</string> + <string name="stores_itch_price_free">Free</string> + <string name="stores_itch_price_sale">On sale</string> + <string name="stores_itch_load_more">Load more</string> + <string name="stores_itch_platform_any">Any platform</string> + <string name="stores_itch_platform_mobile_web">Mobile web</string> + <string name="stores_itch_tag_add">Add a tag</string> + <string name="stores_itch_tag_title">Filter by tag</string> + <string name="stores_itch_platform_windows" translatable="false">Windows</string> + <string name="stores_itch_platform_macos" translatable="false">macOS</string> + <string name="stores_itch_platform_linux" translatable="false">Linux</string> + <string name="stores_itch_platform_android" translatable="false">Android</string> + <string name="stores_itch_platform_ios" translatable="false">iOS</string> + <string name="stores_itch_platform_web" translatable="false">Web</string> + <string name="stores_itch_browse_failed">Could not load games from itch.io</string> <string name="stores_itch_archive">%1$s archives are not supported</string> <string name="stores_itch_download_failed">itch.io answered HTTP %1$d</string> + <string name="stores_itch_pick_title">Choose a version</string> + <string name="stores_itch_pick_none">No downloads found for this game</string> + <string name="stores_itch_pick_failed">Could not load the downloads from itch.io</string> + <string name="stores_itch_build_demo">Demo</string> + <string name="stores_itch_build_open">Open on itch.io</string> + <string name="stores_itch_build_linux_soon">Not supported yet</string> + <string name="stores_itch_no_game">Could not find this game on itch.io</string> + <string name="stores_itch_build_windows" translatable="false">Windows</string> + <string name="stores_itch_build_macos" translatable="false">macOS</string> + <string name="stores_itch_build_linux" translatable="false">Linux</string> + <string name="stores_itch_build_android" translatable="false">Android</string> + <string name="stores_itch_build_web" translatable="false">Web</string> <string name="stores_free">Free</string> <string name="stores_resume">Resume</string> <string name="stores_refresh">Refresh</string> @@ -2234,6 +2314,7 @@ <string name="stores_login_close">Close</string> <string name="stores_login_retry">Try again</string> <string name="stores_login_finishing">Finishing sign-in…</string> + <string name="stores_xbox_sign_in_again">Microsoft rejected the Xbox sign-in. Sign out of Xbox in Stores, sign in again, then resume.</string> <string name="stores_login_error_network">The sign-in page could not be loaded: %1$s</string> <string name="stores_login_error_idp_blocked">This provider refuses to sign in from an embedded page. Sign in with your store password instead.</string> <string name="stores_login_error_http">The sign-in page answered with an error (HTTP %1$d).</string> @@ -2379,6 +2460,7 @@ <string name="setup_download_source_custom">Custom prefix</string> <string name="setup_download_mirror">Site</string> <string name="setup_download_prefix">Prefix</string> + <string name="setup_download_proxy">HTTP proxy</string> <string name="flathub_eyebrow">LINUX APPS</string> <string name="flathub_name">Flathub</string> <string name="flathub_line">Desktop apps for the Linux desktop, opened from Desktop.</string> @@ -2668,6 +2750,21 @@ <string name="storage_safe_hint">Unused runtimes, leftovers, old logs</string> <string name="storage_clean_up">Clean up…</string> <string name="storage_session_running">Stop the session to clean up or remove anything. Sizes are still shown.</string> + <string name="storage_default_group">Install location</string> + <string name="storage_default_title">Default storage</string> + <string name="storage_default_hint">New store installs go to the selected location. Existing games stay where they are. Steam libraries keep their own setting.</string> + <string name="storage_default_ask">Ask every time</string> + <string name="storage_default_ask_detail">Choose a target for each install</string> + <string name="storage_default_internal">Internal storage</string> + <string name="storage_default_external">External storage</string> + <string name="storage_default_external_detail">%1$s · %2$s free</string> + <string name="storage_default_external_missing">Not connected</string> + <string name="storage_default_folder">Folder · %1$s</string> + <string name="storage_default_folder_free">%1$s free</string> + <string name="storage_default_folder_missing">Not connected</string> + <string name="storage_default_choose_folder">Choose folder…</string> + <string name="storage_default_choose_folder_detail">Use any folder on an SD card or USB drive</string> + <string name="storage_default_note">Games go into a Games folder inside the selected location. Existing games are not moved.</string> <string name="storage_where">Where it goes</string> <string name="storage_games">Steam games</string> <string name="storage_game_data">Game data</string> @@ -3049,4 +3146,62 @@ <string name="spatial_saved_views_hint">Saved views can be recalculated. Start a new calibration to capture in the current tracking frame.</string> <string name="spatial_model_checksum_error">Hand model checksum mismatch</string> <string name="spatial_opencv_load_error">OpenCV could not load</string> + <string name="setup_group_memory">Memory</string> + <string name="setup_clear_cached">Clear cached apps before a session</string> + <string name="setup_clear_cached_hint">When a session starts, DroidDeck closes the apps Android keeps parked in the background, so the game has more memory. Wireless debugging must be on.</string> + <string name="setup_clear_cached_unpaired">Needs DroidDeck paired with Wireless debugging (Pair, below).</string> + <string name="setup_memory_hud">Memory readout</string> + <string name="setup_memory_hud_hint">Memory and swap in use, on the session screen. Advanced splits it into the game, DroidDeck, Android and the GPU.</string> + <string name="setup_memory_hud_simple">Simple</string> + <string name="setup_memory_hud_advanced">Advanced</string> + <string name="memory_hud_simple">RAM %1$s/%2$s GB · swap %3$s/%4$s GB</string> + <string name="memory_hud_split">game %1$s · DroidDeck %2$s · Android %3$s GB</string> + <string name="memory_hud_gpu">GPU %1$s GB</string> + <string name="setup_bg_limit">Background apps during a session</string> + <string name="setup_bg_limit_hint">While a session runs, Android keeps at most this many apps cached and closes the rest, so the game has their memory. DroidDeck puts the limit back as it was when the session ends. Wireless debugging must be on.</string> + <string name="setup_bg_limit_4">At most 4</string> + <string name="setup_bg_limit_1">At most 1</string> + <string name="setup_bg_limit_0">None</string> + <string name="setup_adb_pair">Wireless debugging</string> + <string name="setup_adb_pair_hint">Pairs DroidDeck with Android\'s Wireless debugging for the options above. The child-process limit stays off.</string> + <string name="setup_adb_pair_button">Pair</string> + <string name="stores_xbox_update">Update / verify</string> + <string name="stores_xbox_manage">Manage content</string> + <string name="stores_xbox_close_first">Close this Xbox game before updating it.</string> + <string name="stores_xbox_content_title">Xbox content</string> + <string name="stores_xbox_content_none">No licensed PC content packages are available for this game.</string> + <string name="stores_xbox_content_update">%1$s · Update / verify</string> + <string name="stores_xbox_content_install">%1$s · Install</string> + <string name="stores_xbox_choose_content">Choose PC content</string> + <string name="stores_xbox_choice" translatable="false">%1$s (%2$s)</string> + <string name="stores_game_pass" translatable="false">PC Game Pass</string> + <string name="stores_eyebrow_pass">available with %1$s</string> + <string name="stores_owned_in_library">Owned games in Library</string> + <string name="epic_client_missing">Not installed</string> + <string name="epic_client_open">Open</string> + <string name="epic_client_install">Install</string> + <string name="library_filter_added">Added</string> + <string name="game_tab_about">About</string> + <string name="game_tab_achievements_count" translatable="false">%1$s %2$d/%3$d</string> + <string name="game_tab_launch">Launch</string> + <string name="game_tab_saves_cloud">Saves & cloud</string> + <string name="game_tab_install">Install</string> + <string name="game_launch_shortcut">Steam shortcut</string> + <string name="game_install_location">Location</string> + <string name="game_install_folder">Folder</string> + <string name="game_install_size">Size on disk</string> + <string name="game_install_version">Version</string> + <string name="game_install_date">Installed</string> + <string name="game_install_played">Last played</string> + <string name="game_install_access">Access</string> + <string name="game_install_access_owned">Owned</string> + <string name="game_stream">Stream</string> + <string name="library_add">Add to Library</string> + <string name="library_remove">Remove from Library</string> + <string name="library_in">In Library</string> + <string name="xbox_filter_show">Show</string> + <string name="xbox_filter_cloud">Cloud</string> + <string name="stores_xbox_cloud_settings">Cloud settings</string> + <string name="xbox_cloud_gaming" translatable="false">Xbox Cloud Gaming</string> + <string name="xbox_filter_game_pass" translatable="false">Game Pass</string> </resources> diff --git a/app/src/main/rust/stores/JNI.md b/app/src/main/rust/stores/JNI.md index c5cd07744..ee7e5a827 100644 --- a/app/src/main/rust/stores/JNI.md +++ b/app/src/main/rust/stores/JNI.md @@ -160,13 +160,15 @@ synchronous on the calling thread (network calls block: call them off the main t | `cloud.list` | `accessToken`, `accountId`, `appName` | `files[]` of `name`, `modified` (ms, -1 unknown), `hash`, `readLink` | | `cloud.writeLinks` | `accessToken`, `accountId`, `appName`, `names[]` | `links`: name → write link | | `launch.args` | `displayName`, `accountId`, `namespace`, `locale`, `appName`, `deploymentId`, `additional` | `args[]` | +| `launch.split` | `command` | `args[]` | | `locale` | `language`, `region` | `key`, `tags[]`, `launchLocale` | `nativeFetch` ops: `manifest.download` (`info`, optional `cdns`) → the manifest bytes; `cloud.read` (`url`) → the file. `null` = failed. `nativeSend` op: `cloud.write` (`url`) with the body → `{"ok": true}` or an error. `nativeInspect` parses manifest bytes: `{"op":"summary"}` → `buildVersion`, `launchExe`, `prereqName`, `prereqPath`, `prereqRead`, `files[]` (`path`, `size`, -`tags`); `{"op":"select","tags":[…]}` → `indices[]` of the files an install with those tags gets. +`tags`); `{"op":"select","tags":[…]}` → `indices[]` of the files an install with those tags gets; +`{"op":"launch"}` → the manifest's `buildVersion`, its `command` line and the `args[]` split from it. ### `nativeInstall` @@ -196,7 +198,7 @@ completes. On-disk protocol: game files are staged in `<gameDir>/.droiddeck-epic-staging/*.stage` (owner marker `owner` = `DroidDeck Epic staging v1\n`) and renamed over their destination after size and -SHA-1 checks; reusable chunks live, decompressed, as `<GUID>` (extra copies `<GUID>.<n>`, writes via +SHA-1 checks; a paused run keeps each unfinished file there as `*.part` with its written parts in `*.part.list`; reusable chunks live, decompressed, as `<GUID>` (extra copies `<GUID>.<n>`, writes via `.part`) in `<gameDir>/.chunks` or the scratch directory. ## `com.droiddeck.launcher.stores.amazon.AmazonNative` diff --git a/app/src/main/rust/stores/src/amazon/library.rs b/app/src/main/rust/stores/src/amazon/library.rs index ec9ba55f8..46360a907 100644 --- a/app/src/main/rust/stores/src/amazon/library.rs +++ b/app/src/main/rust/stores/src/amazon/library.rs @@ -21,6 +21,7 @@ pub struct Game { pub parent_product_id: String, pub art: String, pub hero: String, + pub logo: String, pub developer: String, pub publisher: String, } @@ -39,6 +40,7 @@ impl Game { "sku": self.sku, "art": self.art, "hero": self.hero, + "logo": self.logo, "developer": self.developer, "publisher": self.publisher, "dlc": self.is_dlc(), @@ -83,6 +85,7 @@ pub fn read_entitlement(e: &Value) -> Option<Game> { ], ) .to_string(), + logo: str_at(product, &["productDetail", "details", "logoUrl"]).to_string(), developer: str_at(product, &["productDetail", "details", "developer"]).to_string(), publisher: str_at(product, &["productDetail", "details", "publisher"]).to_string(), }) @@ -178,17 +181,20 @@ mod tests { fn entitlement_fields_and_fallbacks() { let e = json!({"id":"ent-1","product":{"id":"amzn1.adg.product.x","title":"Game","sku":"SKU1", "productDetail":{"iconUrl":"","details":{"pgCrownImageUrl":"crown","backgroundUrl1":"bg1", - "developer":"Dev","publisher":"Pub","productType":"GAME"}}}}); + "logoUrl":"logo","developer":"Dev","publisher":"Pub","productType":"GAME"}}}}); let g = read_entitlement(&e).unwrap(); assert_eq!(g.entitlement_id, "ent-1"); assert_eq!(g.art, "crown"); assert_eq!(g.hero, "bg1"); + assert_eq!(g.logo, "logo"); + assert_eq!(g.to_json()["logo"], "logo"); assert_eq!(g.product_type, "GAME"); assert!(!g.is_dlc()); let untitled = read_entitlement(&json!({"product":{"id":"p","type":"DLC","baseProductId":"q"}})).unwrap(); assert_eq!(untitled.title, "Unknown"); assert_eq!(untitled.entitlement_id, ""); assert_eq!(untitled.parent_product_id, "q"); + assert_eq!(untitled.logo, ""); assert!(untitled.is_dlc()); assert!(read_entitlement(&json!({"id":"x"})).is_none()); assert!(read_entitlement(&json!({"id":"x","product":{"id":""}})).is_none()); diff --git a/app/src/main/rust/stores/src/epic/catalog.rs b/app/src/main/rust/stores/src/epic/catalog.rs index 4589ce276..420d656e8 100644 --- a/app/src/main/rust/stores/src/epic/catalog.rs +++ b/app/src/main/rust/stores/src/epic/catalog.rs @@ -71,6 +71,7 @@ pub fn details_of(entry: &Value) -> Value { "releaseDate": release, "tall": image(entry, &["DieselGameBoxTall"]), "wide": image(entry, &["DieselGameBox", "Thumbnail"]), + "logo": image(entry, &["DieselGameBoxLogo"]), "canRunOffline": offline, "dlc": main_game.is_some_and(|m| !m.is_null()), "mainGameId": main_game.map(|m| net::str_of(m, "id")).unwrap_or(""), @@ -102,7 +103,8 @@ mod tests { "keyImages": [ {"type": "Thumbnail", "url": "thumb"}, {"type": "DieselGameBox", "url": ""}, - {"type": "DieselGameBoxTall", "url": "tall"} + {"type": "DieselGameBoxTall", "url": "tall"}, + {"type": "DieselGameBoxLogo", "url": "logo"} ], "customAttributes": {"CanRunOffline": {"value": "FALSE"}, "publisherName": {"value": " Pub "}}, "seller": {"name": "Seller"}, @@ -113,6 +115,7 @@ mod tests { assert_eq!(d["title"], "Game"); assert_eq!(d["tall"], "tall"); assert_eq!(d["wide"], "thumb"); + assert_eq!(d["logo"], "logo"); assert_eq!(d["publisher"], "Pub"); assert_eq!(d["canRunOffline"], false); assert_eq!(d["releaseDate"], "2020-01-01T00:00:00.000Z"); @@ -123,6 +126,7 @@ mod tests { assert_eq!(dlc["publisher"], "S"); assert_eq!(dlc["releaseDate"], "v"); assert_eq!(dlc["canRunOffline"], true); + assert_eq!(dlc["logo"], ""); assert_eq!(pick_entry(&json!({"x": {"title": "Only"}}), "y").unwrap()["title"], "Only"); } } diff --git a/app/src/main/rust/stores/src/epic/install/mod.rs b/app/src/main/rust/stores/src/epic/install/mod.rs index 426e3bdcf..df20412ac 100644 --- a/app/src/main/rust/stores/src/epic/install/mod.rs +++ b/app/src/main/rust/stores/src/epic/install/mod.rs @@ -5,7 +5,7 @@ pub mod space; pub mod staging; pub mod writer; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::fs; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; @@ -140,6 +140,17 @@ pub fn fetch_manifest(info: &BuildInfo, log: &dyn Fn(&str)) -> Result<Manifest, }) } +fn resume_from(stage: &Path, parts: &[PlanPart], size: u64) -> Option<HashSet<u64>> { + let meta = fs::symlink_metadata(stage).ok()?; + if !meta.is_file() || meta.len() > size { + return None; + } + let list = fs::read_to_string(staging::list_path(stage)).ok()?; + let kept: HashSet<u64> = list.lines().map(|l| l.parse().ok()).collect::<Option<_>>()?; + let fits = |o: &u64| parts.iter().any(|p| p.file_offset == *o && p.file_offset + p.size as u64 <= meta.len()); + (!kept.is_empty() && kept.iter().all(fits)).then_some(kept) +} + pub fn run(req: &Request, cancel: &AtomicBool, ev: &dyn Events) -> Result<Finished, Stop> { let failed = |m: String| Stop::Failed(m); ev.status("Downloading the manifest…"); @@ -191,6 +202,7 @@ pub fn run(req: &Request, cancel: &AtomicBool, ev: &dyn Events) -> Result<Finish } let pending_bytes: u64 = pending.iter().map(|&(i, _)| manifest.files[i].size).sum(); ev.sizes(pending_bytes, disk_bytes); + let mut partials = staging::partials(&stage_dir); let mut finished = Finished { build_version: manifest.build_version.clone(), @@ -211,6 +223,9 @@ pub fn run(req: &Request, cancel: &AtomicBool, ev: &dyn Events) -> Result<Finish staging::remove_if_idle(&stage_dir); }; if pending.is_empty() { + for stale in partials.values() { + staging::remove_partial(stale); + } ev.log("every selected file is already up to date"); clean_caches(); ev.installing(disk_bytes, disk_bytes, total_selected, total_selected); @@ -219,6 +234,8 @@ pub fn run(req: &Request, cancel: &AtomicBool, ev: &dyn Events) -> Result<Finish let mut plan_files = Vec::with_capacity(pending.len()); let mut engine_files = Vec::with_capacity(pending.len()); + let mut resumed_bytes = 0u64; + let mut kept_sizes = Vec::with_capacity(pending.len()); for &(i, old_size) in &pending { let f = &manifest.files[i]; let mut parts = Vec::with_capacity(f.parts.len()); @@ -234,6 +251,22 @@ pub fn run(req: &Request, cancel: &AtomicBool, ev: &dyn Events) -> Result<Finish parts.push(PlanPart { chunk: c as u32, chunk_offset: part.offset, size: part.size, file_offset: offset }); offset += part.size as u64; } + let mut resume = None; + let mut kept_size = 0u64; + if let Some(stage) = partials.remove(&staging::partial_name(&f.path, f.size, f.sha1)) { + match resume_from(&stage, &parts, f.size).filter(|_| f.sha1.is_some()) { + Some(kept) => { + let bytes: u64 = parts.iter().filter(|p| kept.contains(&p.file_offset)).map(|p| p.size as u64).sum(); + ev.log(&format!("resuming {} with {bytes} of {} bytes written", f.path, f.size)); + resumed_bytes += bytes; + kept_size = bytes; + parts.retain(|p| !kept.contains(&p.file_offset)); + resume = Some(writer::Resume { stage, kept: kept.into_iter().collect() }); + } + None => staging::remove_partial(&stage), + } + } + kept_sizes.push(kept_size); plan_files.push(PlanFile { size: f.size, parts: parts.clone() }); engine_files.push(writer::EngineFile { path: f.path.clone(), @@ -242,8 +275,14 @@ pub fn run(req: &Request, cancel: &AtomicBool, ev: &dyn Events) -> Result<Finish sha1: f.sha1, replacing: old_size > 0, parts, + resume, }); } + for stale in partials.values() { + staging::remove_partial(stale); + } + let fresh = pending.iter().all(|&(_, old_size)| old_size == 0); + let progress_base = resumed_bytes + if fresh { disk_bytes - pending_bytes } else { 0 }; let scratch = req .scratch_dir @@ -252,7 +291,8 @@ pub fn run(req: &Request, cancel: &AtomicBool, ev: &dyn Events) -> Result<Finish .filter(|s| !space::same_volume(s, &req.game_dir)); let sizes: Vec<space::PendingSize> = pending .iter() - .map(|&(i, old)| space::PendingSize { new_size: manifest.files[i].size, old_size: old }) + .zip(&kept_sizes) + .map(|(&(i, old), kept)| space::PendingSize { new_size: manifest.files[i].size - kept, old_size: old }) .collect(); let free_game = space::free_bytes(&req.game_dir); let scratch_space = scratch.as_ref().map(|s| space::Scratch { free: space::free_bytes(s) }); @@ -308,6 +348,8 @@ cache_budget_mib={} overlap_mib={} free_game={free_game} workers={} process_work process_workers: req.process_workers, ca_bundle: req.ca_bundle.clone(), user_agent: net::user_agent(), + progress_base, + progress_total: progress_base + pending_bytes - resumed_bytes, }; let stats = writer::run(&input, cancel, ev)?; finished.bytes_written = stats.bytes_written; diff --git a/app/src/main/rust/stores/src/epic/install/staging.rs b/app/src/main/rust/stores/src/epic/install/staging.rs index 31b0b4ab6..53be6cceb 100644 --- a/app/src/main/rust/stores/src/epic/install/staging.rs +++ b/app/src/main/rust/stores/src/epic/install/staging.rs @@ -1,12 +1,19 @@ +use std::collections::HashMap; use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; +use sha1::{Digest, Sha1}; + +use crate::epic::manifest::Sha1 as Hash; + pub const DIR_NAME: &str = ".droiddeck-epic-staging"; pub const OWNER_FILE: &str = "owner"; pub const OWNER_MARKER: &[u8] = b"DroidDeck Epic staging v1\n"; const SUFFIX: &str = ".stage"; +pub const PARTIAL_SUFFIX: &str = ".part"; +const LIST_SUFFIX: &str = ".part.list"; static SEQUENCE: AtomicU64 = AtomicU64::new(0); @@ -15,6 +22,37 @@ pub fn conflicts(manifest_path: &str) -> bool { first.eq_ignore_ascii_case(DIR_NAME) } +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +pub fn partial_name(path: &str, size: u64, sha1: Option<Hash>) -> String { + let id: Hash = Sha1::digest(path.as_bytes()).into(); + let content = sha1.map(|h| hex(&h)).unwrap_or_else(|| "0".into()); + format!("{}-{content}-{size}{PARTIAL_SUFFIX}", hex(&id[..8])) +} + +pub fn list_path(partial: &Path) -> PathBuf { + let mut name = partial.as_os_str().to_owned(); + name.push(".list"); + PathBuf::from(name) +} + +pub fn remove_partial(partial: &Path) { + let _ = fs::remove_file(partial); + let _ = fs::remove_file(list_path(partial)); +} + +pub fn partials(dir: &Path) -> HashMap<String, PathBuf> { + let Ok(entries) = fs::read_dir(dir) else { return HashMap::new() }; + entries + .flatten() + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.ends_with(PARTIAL_SUFFIX)) + .map(|n| (n.clone(), dir.join(n))) + .collect() +} + pub fn unique_name() -> String { let nanos = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -82,6 +120,8 @@ pub fn recover(game_dir: &Path) -> Result<PathBuf, String> { } else if name.ends_with(SUFFIX) { stale.push(path); others += 1; + } else if name.ends_with(PARTIAL_SUFFIX) || name.ends_with(LIST_SUFFIX) { + others += 1; } else { return refuse("it holds an unexpected file"); } @@ -130,8 +170,13 @@ mod tests { fs::write(dir.join("file-1.stage"), b"x").unwrap(); fs::write(dir.join("stream-1-2-3.stage"), b"y").unwrap(); fs::write(game.join("keep.stage"), b"z").unwrap(); + let partial = partial_name("Game/a.pak", 10, None); + fs::write(dir.join(&partial), b"p").unwrap(); + fs::write(list_path(&dir.join(&partial)), b"0\n").unwrap(); recover(&game).unwrap(); - assert_eq!(fs::read_dir(&dir).unwrap().count(), 1); + assert_eq!(fs::read_dir(&dir).unwrap().count(), 3); + assert_eq!(partials(&dir).into_keys().collect::<Vec<_>>(), vec![partial.clone()]); + remove_partial(&dir.join(&partial)); assert!(game.join("keep.stage").exists()); remove_if_idle(&dir); assert!(!dir.exists()); @@ -170,5 +215,9 @@ mod tests { let (a, b) = (unique_name(), unique_name()); assert_ne!(a, b); assert!(a.ends_with(".stage")); + let p = partial_name("Game/a.pak", 10, Some([0xab; 20])); + assert!(p.ends_with(&format!("-{}-10.part", "ab".repeat(20)))); + assert_ne!(p, partial_name("Game/b.pak", 10, Some([0xab; 20]))); + assert_ne!(p, partial_name("Game/a.pak", 10, Some([0xac; 20]))); } } diff --git a/app/src/main/rust/stores/src/epic/install/tests.rs b/app/src/main/rust/stores/src/epic/install/tests.rs index 2a75fb8a6..127d772de 100644 --- a/app/src/main/rust/stores/src/epic/install/tests.rs +++ b/app/src/main/rust/stores/src/epic/install/tests.rs @@ -59,6 +59,7 @@ struct Recorder { replaced: AtomicUsize, refuse: bool, last_progress: Mutex<(u64, u64)>, + first_progress: Mutex<Option<(u64, u64)>>, sizes: Mutex<(u64, u64)>, } @@ -72,6 +73,7 @@ impl Events for Recorder { *self.sizes.lock().unwrap() = (pending, disk); } fn progress(&self, written: u64, total: u64, _f: u32, _ft: u32) { + self.first_progress.lock().unwrap().get_or_insert((written, total)); let mut p = self.last_progress.lock().unwrap(); if written >= p.0 { *p = (written, total); @@ -262,6 +264,85 @@ fn a_cancelled_run_reports_cancel() { let _ = fs::remove_dir_all(&game); } +fn place_partial(fx: &Fixture, game: &Path, data: &[u8], list: &str) -> PathBuf { + let stage = staging::recover(game).unwrap(); + let a = &fx.expected[0].1; + let partial = stage.join(staging::partial_name("a.bin", 4000, Some(Sha1::digest(a).into()))); + fs::write(&partial, data).unwrap(); + fs::write(staging::list_path(&partial), list).unwrap(); + partial +} + +fn place_done_c(fx: &Fixture, game: &Path) { + fs::create_dir_all(game.join("b")).unwrap(); + fs::write(game.join("b/c.bin"), &fx.expected[1].1).unwrap(); +} + +#[test] +fn a_paused_install_resumes_from_its_written_parts() { + let fx = fixture(); + let game = temp_game("resume"); + place_done_c(&fx, &game); + place_partial(&fx, &game, &fx.expected[0].1[..3000], "0\n"); + let stage = game.join(staging::DIR_NAME); + fs::write(stage.join(staging::partial_name("gone.bin", 9, None)), b"old").unwrap(); + + let events = Recorder::default(); + run(&request(&fx, &game), &AtomicBool::new(false), &events).unwrap(); + for (path, bytes) in &fx.expected { + assert_eq!(&fs::read(game.join(path)).unwrap(), bytes, "{path}"); + } + assert_eq!(fx.hits.load(Ordering::Relaxed), 2, "the manifest and the one chunk still missing"); + assert_eq!(*events.first_progress.lock().unwrap(), Some((2096 + 3000, 6196))); + assert_eq!(*events.last_progress.lock().unwrap(), (6196, 6196)); + assert!(events.logs.lock().unwrap().iter().any(|l| l == "resuming a.bin with 3000 of 4000 bytes written")); + assert!(!stage.exists()); + let _ = fs::remove_dir_all(&game); +} + +#[test] +fn a_part_written_out_of_order_is_kept_too() { + let fx = fixture(); + let game = temp_game("resume-tail"); + place_done_c(&fx, &game); + let mut data = vec![0u8; 3000]; + data.extend_from_slice(&fx.expected[0].1[3000..]); + place_partial(&fx, &game, &data, "3000\n"); + let events = Recorder::default(); + run(&request(&fx, &game), &AtomicBool::new(false), &events).unwrap(); + assert_eq!(fs::read(game.join("a.bin")).unwrap(), fx.expected[0].1); + assert_eq!(*events.first_progress.lock().unwrap(), Some((2096 + 1000, 6196))); + assert!(!game.join(staging::DIR_NAME).exists()); + let _ = fs::remove_dir_all(&game); +} + +#[test] +fn a_partial_that_no_longer_matches_is_dropped() { + let fx = fixture(); + let game = temp_game("badpartial"); + place_partial(&fx, &game, &fx.expected[0].1[..1234], "1234\n"); + let events = Recorder::default(); + run(&request(&fx, &game), &AtomicBool::new(false), &events).unwrap(); + assert_eq!(fs::read(game.join("a.bin")).unwrap(), fx.expected[0].1); + assert_eq!(*events.first_progress.lock().unwrap(), Some((0, 6196))); + assert!(!game.join(staging::DIR_NAME).exists()); + let _ = fs::remove_dir_all(&game); +} + +#[test] +fn a_damaged_partial_fails_its_check_and_is_not_kept() { + let fx = fixture(); + let game = temp_game("damagedpartial"); + place_partial(&fx, &game, &[0u8; 3000], "0\n"); + let err = run(&request(&fx, &game), &AtomicBool::new(false), &Recorder::default()).unwrap_err(); + assert_eq!(err, Stop::Failed("SHA-1 mismatch for a.bin".into())); + assert!(staging::partials(&game.join(staging::DIR_NAME)).is_empty()); + let retry = Recorder::default(); + run(&request(&fx, &game), &AtomicBool::new(false), &retry).unwrap(); + assert_eq!(fs::read(game.join("a.bin")).unwrap(), fx.expected[0].1); + let _ = fs::remove_dir_all(&game); +} + #[test] fn guid_names_are_cache_names() { let c = chunk(5, 10); diff --git a/app/src/main/rust/stores/src/epic/install/writer.rs b/app/src/main/rust/stores/src/epic/install/writer.rs index 3a7e93a0e..66cab176b 100644 --- a/app/src/main/rust/stores/src/epic/install/writer.rs +++ b/app/src/main/rust/stores/src/epic/install/writer.rs @@ -1,6 +1,6 @@ use std::fs::{self, File, OpenOptions}; use std::os::unix::fs::FileExt; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; use std::sync::{Arc, Condvar, Mutex}; use std::time::{Duration, Instant}; @@ -25,6 +25,12 @@ pub struct EngineFile { pub sha1: Option<Hash>, pub replacing: bool, pub parts: Vec<PlanPart>, + pub resume: Option<Resume>, +} + +pub struct Resume { + pub stage: PathBuf, + pub kept: Vec<u64>, } pub struct EngineInput<'a> { @@ -40,6 +46,8 @@ pub struct EngineInput<'a> { pub process_workers: usize, pub ca_bundle: String, pub user_agent: String, + pub progress_base: u64, + pub progress_total: u64, } #[derive(Debug, Default)] @@ -120,7 +128,7 @@ impl<'a> Engine<'a> { return; } self.events.progress( - self.written.load(Ordering::Relaxed), + self.input.progress_base + self.written.load(Ordering::Relaxed), self.total_bytes, self.files_done.load(Ordering::Relaxed) as u32, self.input.files.len() as u32, @@ -232,10 +240,14 @@ impl<'a> Engine<'a> { .sync_all() .map_err(|e| format!("Could not assemble or atomically replace {}: {e}", file.path))?; let len = handle.metadata().map(|m| m.len()).unwrap_or(u64::MAX); - drop(handle); if len != file.size { return Err(format!("Size mismatch for {}", file.path)); } + let hash = match (hash, &file.resume) { + (None, Some(_)) if file.sha1.is_some() => Some(hash_whole(&handle, file)?), + (hash, _) => hash, + }; + drop(handle); if let (Some(expected), Some(got)) = (file.sha1, hash) { if expected != got { return Err(format!("SHA-1 mismatch for {}", file.path)); @@ -260,6 +272,9 @@ impl<'a> Engine<'a> { fs::rename(&stage, &file.dest) .map_err(|e| format!("Could not assemble or atomically replace {}: {e}", file.path)) })(); + if file.resume.is_some() { + let _ = fs::remove_file(staging::list_path(&stage)); + } if let Err(e) = result { let _ = fs::remove_file(&stage); return Err(e); @@ -278,13 +293,16 @@ impl<'a> Engine<'a> { } fn open_stage(&self, f: usize) -> Result<(), String> { - let path = self.input.stage_dir.join(staging::unique_name()); - let handle = OpenOptions::new() - .read(true) - .write(true) - .create_new(true) - .open(&path) - .map_err(|e| format!("Could not stage {}: {e}", self.input.files[f].path))?; + let file = &self.input.files[f]; + let (path, opened) = match &file.resume { + Some(r) => (r.stage.clone(), OpenOptions::new().read(true).write(true).open(&r.stage)), + None => { + let path = self.input.stage_dir.join(staging::unique_name()); + let opened = OpenOptions::new().read(true).write(true).create_new(true).open(&path); + (path, opened) + } + }; + let handle = opened.map_err(|e| format!("Could not stage {}: {e}", file.path))?; let mut slot = self.slots[f].lock().expect("slot lock"); slot.file = Some(Arc::new(handle)); slot.stage = Some(path); @@ -439,7 +457,7 @@ pub fn run(input: &EngineInput<'_>, stop: &AtomicBool, events: &dyn Events) -> R stage: None, written: vec![false; f.parts.len()], next_hash: 0, - hasher: f.sha1.map(|_| Sha1::new()), + hasher: f.sha1.filter(|_| f.resume.is_none()).map(|_| Sha1::new()), left: f.parts.len(), done: false, }) @@ -466,7 +484,7 @@ pub fn run(input: &EngineInput<'_>, stop: &AtomicBool, events: &dyn Events) -> R started: Instant::now(), replace_ok: Mutex::new(None), events, - total_bytes: input.files.iter().map(|f| f.size).sum(), + total_bytes: input.progress_total, }; for (i, p) in schedule.producers.iter().enumerate() { if p.preloaded && remaining[i] == 0 { @@ -503,7 +521,7 @@ pub fn run(input: &EngineInput<'_>, stop: &AtomicBool, events: &dyn Events) -> R stream: false, }; - events.progress(0, engine.total_bytes, 0, input.files.len() as u32); + events.progress(input.progress_base, engine.total_bytes, 0, input.files.len() as u32); let log = |line: &str| events.log(line); let gate = |i: usize| -> bool { engine.hint.fetch_max(i, Ordering::Relaxed); @@ -546,12 +564,14 @@ pub fn run(input: &EngineInput<'_>, stop: &AtomicBool, events: &dyn Events) -> R let complete = failure.is_none() && engine.files_done.load(Ordering::Relaxed) == input.files.len(); if !complete { - for slot in &engine.slots { + for (file, slot) in input.files.iter().zip(&engine.slots) { let mut s = slot.lock().expect("slot lock"); if !s.done { s.file.take(); if let Some(stage) = s.stage.take() { - let _ = fs::remove_file(stage); + if failure.is_some() || !keep_partial(file, &s.written, &stage, &input.stage_dir) { + let _ = fs::remove_file(stage); + } } } } @@ -588,6 +608,100 @@ fetches={} refetches={} peak_cache_mib={} hosts={}", Err(Stop::Failed("The download ended before every file was written".into())) } +fn hash_whole(handle: &File, file: &EngineFile) -> Result<Hash, String> { + let mut h = Sha1::new(); + let mut buf = vec![0u8; 1024 * 1024]; + let mut at = 0u64; + while at < file.size { + let n = buf.len().min((file.size - at) as usize); + handle + .read_exact_at(&mut buf[..n], at) + .map_err(|e| format!("Could not read back {}: {e}", file.path))?; + h.update(&buf[..n]); + at += n as u64; + } + Ok(h.finalize().into()) +} + +fn keep_partial(file: &EngineFile, written: &[bool], stage: &Path, dir: &Path) -> bool { + let mut kept: Vec<u64> = file.resume.as_ref().map(|r| r.kept.clone()).unwrap_or_default(); + kept.extend(file.parts.iter().zip(written).filter(|(_, w)| **w).map(|(p, _)| p.file_offset)); + if kept.is_empty() || file.sha1.is_none() { + return false; + } + kept.sort_unstable(); + let partial = dir.join(staging::partial_name(&file.path, file.size, file.sha1)); + let list: String = kept.iter().map(|o| format!("{o}\n")).collect(); + let synced = OpenOptions::new().write(true).open(stage).and_then(|h| h.sync_all()); + if synced.is_err() || fs::rename(stage, &partial).is_err() { + return false; + } + let resumed = file.resume.as_ref().is_some_and(|r| r.stage == partial); + let temp = dir.join(staging::unique_name()); + let listed = fs::write(&temp, list).and_then(|_| fs::rename(&temp, staging::list_path(&partial))).is_ok(); + if !listed { + let _ = fs::remove_file(&temp); + if !resumed { + let _ = fs::remove_file(&partial); + } + } + listed || resumed +} + fn tidy_fetch_error(message: &str) -> String { message.strip_prefix("epic: ").unwrap_or(message).to_string() } + +#[cfg(test)] +mod tests { + use super::*; + + fn part(file_offset: u64, size: u32) -> PlanPart { + PlanPart { chunk: 0, chunk_offset: 0, size, file_offset } + } + + #[test] + fn a_pause_keeps_every_written_part_and_lists_them() { + let dir = std::env::temp_dir().join(format!("dd-epic-keep-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + let file = EngineFile { + path: "Game/a.pak".into(), + dest: dir.join("a.pak"), + size: 30, + sha1: Some([1; 20]), + replacing: false, + parts: vec![part(0, 10), part(10, 10), part(20, 10)], + resume: None, + }; + let kept = dir.join(staging::partial_name("Game/a.pak", 30, Some([1; 20]))); + let stage = dir.join("x.stage"); + + fs::write(&stage, [7u8; 30]).unwrap(); + assert!(!keep_partial(&file, &[false, false, false], &stage, &dir)); + let unhashed = EngineFile { + path: file.path.clone(), + dest: file.dest.clone(), + size: 30, + sha1: None, + replacing: false, + parts: file.parts.clone(), + resume: None, + }; + assert!(!keep_partial(&unhashed, &[true, true, true], &stage, &dir)); + assert!(stage.exists()); + assert!(keep_partial(&file, &[false, true, true], &stage, &dir)); + assert_eq!(fs::read(&kept).unwrap(), [7u8; 30]); + assert_eq!(fs::read_to_string(staging::list_path(&kept)).unwrap(), "10\n20\n"); + assert!(!stage.exists()); + + let resumed = EngineFile { + parts: vec![part(0, 10)], + resume: Some(Resume { stage: kept.clone(), kept: vec![20, 10] }), + ..file + }; + assert!(keep_partial(&resumed, &[false], &kept, &dir)); + assert_eq!(fs::read_to_string(staging::list_path(&kept)).unwrap(), "10\n20\n"); + let _ = fs::remove_dir_all(&dir); + } +} diff --git a/app/src/main/rust/stores/src/epic/launch.rs b/app/src/main/rust/stores/src/epic/launch.rs index 481e9b825..8c5a10499 100644 --- a/app/src/main/rust/stores/src/epic/launch.rs +++ b/app/src/main/rust/stores/src/epic/launch.rs @@ -39,10 +39,72 @@ pub fn arguments(d: &LaunchData) -> Vec<String> { args } +pub fn split_command(command: &str) -> Vec<String> { + let mut args = Vec::new(); + let mut current = String::new(); + let mut quoted = false; + let mut started = false; + let mut slashes = 0usize; + let mut chars = command.chars().map(|c| if c == '\t' { ' ' } else { c }).filter(|&c| c >= ' ' && c != '\u{7f}').peekable(); + while let Some(c) = chars.next() { + if c == '\\' { + slashes += 1; + started = true; + continue; + } + if c == '"' { + current.extend(std::iter::repeat('\\').take(slashes / 2)); + if slashes % 2 == 1 { + current.push('"'); + } else if quoted && chars.peek() == Some(&'"') { + chars.next(); + current.push('"'); + } else { + quoted = !quoted; + } + slashes = 0; + started = true; + continue; + } + current.extend(std::iter::repeat('\\').take(slashes)); + slashes = 0; + if c == ' ' && !quoted { + if started { + args.push(std::mem::take(&mut current)); + started = false; + } + } else { + current.push(c); + started = true; + } + } + current.extend(std::iter::repeat('\\').take(slashes)); + if started { + args.push(current); + } + args.retain(|a| !a.is_empty()); + args +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn launch_command_splits_like_a_command_line() { + assert_eq!(split_command("-PCStore=EGS"), vec!["-PCStore=EGS"]); + assert_eq!( + split_command(" -a\t-b=\"c d\" \"-e f\" \"\" -g\r\n"), + vec!["-a", "-b=c d", "-e f", "-g"] + ); + assert_eq!( + split_command(r#"-name="a\" b" -dir=C:\Games\ "-q=\\" -r"#), + vec![r#"-name=a" b"#, r"-dir=C:\Games\", r"-q=\", "-r"] + ); + assert_eq!(split_command("-name=\"a\"\" b\" -s=\"\"\""), vec!["-name=a\" b", "-s=\""]); + assert!(split_command(" ").is_empty()); + } + #[test] fn arguments_in_order() { let args = arguments(&LaunchData { diff --git a/app/src/main/rust/stores/src/epic/manifest.rs b/app/src/main/rust/stores/src/epic/manifest.rs index 2690e0f19..98d31f3d9 100644 --- a/app/src/main/rust/stores/src/epic/manifest.rs +++ b/app/src/main/rust/stores/src/epic/manifest.rs @@ -68,6 +68,7 @@ pub struct Manifest { pub version: i64, pub build_version: String, pub launch_exe: String, + pub launch_command: String, pub prereq_name: String, pub prereq_path: String, pub prereq_read: bool, @@ -279,6 +280,7 @@ fn parse_binary(bytes: &[u8]) -> Result<Manifest, String> { version, build_version: meta.build_version, launch_exe: meta.launch_exe, + launch_command: meta.launch_command, prereq_name: meta.prereq_name, prereq_path: meta.prereq_path, prereq_read: meta.complete, @@ -292,6 +294,7 @@ fn parse_binary(bytes: &[u8]) -> Result<Manifest, String> { struct Meta { build_version: String, launch_exe: String, + launch_command: String, prereq_name: String, prereq_path: String, complete: bool, @@ -308,7 +311,7 @@ fn read_meta(data: &[u8]) -> Meta { r.fstring()?; meta.build_version = r.fstring()?; meta.launch_exe = normalise_path(&r.fstring()?); - r.fstring()?; + meta.launch_command = r.fstring()?; let ids = r.count(4)?; for _ in 0..ids { r.fstring()?; @@ -548,6 +551,7 @@ fn parse_json(bytes: &[u8]) -> Result<Manifest, String> { version, build_version: str_field("BuildVersionString"), launch_exe: normalise_path(launch), + launch_command: str_field("LaunchCommand"), prereq_name: str_field("PrereqName"), prereq_path: normalise_path(&str_field("PrereqPath")), prereq_read: true, @@ -611,7 +615,7 @@ pub mod testing { fstring(&mut meta, "App"); fstring(&mut meta, &m.build); fstring(&mut meta, &m.exe); - fstring(&mut meta, ""); + fstring(&mut meta, "-PCStore=EGS \"-log=a b.txt\""); if let Some((name, path)) = &m.prereq { meta.extend_from_slice(&1i32.to_le_bytes()); fstring(&mut meta, "prereq-id"); @@ -763,6 +767,7 @@ mod tests { assert_eq!(m.chunk_folder(), "ChunksV4"); assert_eq!(m.build_version, "1.2.3-CL-99"); assert_eq!(m.launch_exe, "Binaries/Win64/Game.exe"); + assert_eq!(m.launch_command, "-PCStore=EGS \"-log=a b.txt\""); assert_eq!(m.prereq_name, "UE4 Prerequisites (x64)"); assert_eq!(m.prereq_path, "Engine/Extras/UE4PrereqSetup_x64.exe"); assert!(m.prereq_read); @@ -851,6 +856,7 @@ mod tests { let doc = r#"{ "ManifestFileVersion": "013000000000", "LaunchExeString": "Bin\\Game.exe", + "LaunchCommand": "-PCStore=EGS", "BuildVersionString": "++Game+Release-1.0", "PrereqName": "Redist", "PrereqPath": "Redist\\setup.exe", "ChunkHashList": { @@ -872,6 +878,7 @@ mod tests { assert_eq!(m.version, 13); assert_eq!(m.chunk_folder(), "ChunksV3"); assert_eq!(m.launch_exe, "Bin/Game.exe"); + assert_eq!(m.launch_command, "-PCStore=EGS"); assert!(m.prereq_read); assert_eq!(m.prereq_path, "Redist/setup.exe"); assert_eq!(m.chunks.len(), 2); diff --git a/app/src/main/rust/stores/src/epic/service.rs b/app/src/main/rust/stores/src/epic/service.rs index 67c1995bd..2264f6c77 100644 --- a/app/src/main/rust/stores/src/epic/service.rs +++ b/app/src/main/rust/stores/src/epic/service.rs @@ -113,6 +113,7 @@ fn dispatch(op: &str, req: &Value) -> Value { }; json!({ "args": launch::arguments(&data) }) } + "launch.split" => json!({ "args": launch::split_command(str_of(req, "command")) }), "locale" => { let key = locale::language_key(str_of(req, "language"), str_of(req, "region")); json!({ "key": key, "tags": locale::tags_for_key(key), "launchLocale": locale::launch_locale(key) }) @@ -160,6 +161,11 @@ pub fn inspect(bytes: &[u8], request: &str) -> String { .map(|a| a.iter().filter_map(|t| t.as_str()).map(str::to_string).collect()); json!({ "indices": locale::select(&m.files, tags.as_deref()) }) } + "launch" => json!({ + "buildVersion": m.build_version, + "command": m.launch_command, + "args": launch::split_command(&m.launch_command), + }), _ => json!({ "buildVersion": m.build_version, "launchExe": m.launch_exe, @@ -187,6 +193,8 @@ mod tests { assert!(v["error"].is_string()); let v: Value = serde_json::from_str(&call("launch.args", r#"{"namespace":"ns","appName":"A"}"#)).unwrap(); assert_eq!(v["args"][0], "-EpicPortal"); + let v: Value = serde_json::from_str(&call("launch.split", r#"{"command":"-PCStore=EGS \"-log=a b.txt\""}"#)).unwrap(); + assert_eq!(v["args"], json!(["-PCStore=EGS", "-log=a b.txt"])); } #[test] @@ -210,6 +218,10 @@ mod tests { assert_eq!(v["files"].as_array().unwrap().len(), 2); let v: Value = serde_json::from_str(&inspect(&bytes, r#"{"op":"select","tags":["English"]}"#)).unwrap(); assert_eq!(v["indices"], json!([0])); + let v: Value = serde_json::from_str(&inspect(&bytes, r#"{"op":"launch"}"#)).unwrap(); + assert_eq!(v["buildVersion"], "b"); + assert_eq!(v["command"], "-PCStore=EGS \"-log=a b.txt\""); + assert_eq!(v["args"], json!(["-PCStore=EGS", "-log=a b.txt"])); let v: Value = serde_json::from_str(&inspect(b"nonsense", "{}")).unwrap(); assert!(v["error"].is_string()); } diff --git a/app/src/main/rust/stores/src/fetch_core.rs b/app/src/main/rust/stores/src/fetch_core.rs index e0c10112c..f01973440 100644 --- a/app/src/main/rust/stores/src/fetch_core.rs +++ b/app/src/main/rust/stores/src/fetch_core.rs @@ -1,7 +1,8 @@ use crate::fetch_tuning::{ budget_admits, connect_timeout, inflight_budget_bytes, retry_backoff_millis, AsyncFetchError, FetchFailKind, BOOTSTRAP_WINDOW, MAX_CHUNK_ATTEMPTS, NOMINAL_CHUNK_RESERVE_BYTES, - RATE_LIMIT_COOLDOWN_MS, SERVER_EXPLORE_EVERY, USER_AGENT, WINDOW_BPS_EWMA_ALPHA, + RATE_LIMIT_COOLDOWN_MS, SERVER_EXPLORE_EVERY, SERVER_FAULT_COOLDOWN_MS, USER_AGENT, + WINDOW_BPS_EWMA_ALPHA, WINDOW_COOLDOWN_MS, WINDOW_DECLINE_EPS, WINDOW_ERR_BURST_IMMEDIATE, WINDOW_ERR_RATE_HIGH, WINDOW_ERR_RATE_LOW, WINDOW_HARD_CAP, WINDOW_IMPROVE_EPS, WINDOW_LOG_EVERY_PROBES, WINDOW_MIN_FLOOR, WINDOW_PLATEAU_PATIENCE, WINDOW_PLATEAU_REARM_MAX_MS, @@ -607,6 +608,10 @@ fn classify_reqwest_error(err: &reqwest::Error) -> FetchFailKind { FetchFailKind::Timeout } else if err.is_connect() { FetchFailKind::Connect + } else if err.is_body() || err.is_decode() { + // A truncated or undecodable body comes from the CDN, not the request. Rest that host so + // the retry goes to another mirror instead of reading the same bad object again. + FetchFailKind::ServerFault } else { FetchFailKind::Other } @@ -663,14 +668,9 @@ async fn fetch_once( } Ok(None) => break, Err(err) => { - let kind = if err.is_timeout() { - FetchFailKind::Timeout - } else { - FetchFailKind::Other - }; return Err(AsyncFetchError { message: format!("http body: {err}"), - kind, + kind: classify_reqwest_error(&err), }); } } @@ -1150,7 +1150,7 @@ async fn run_driver(ctx: DriverCtx<'_>) { Ok(Fetched::Body(body)) => { let raw_len = body.len() as u64; meter.record(done.server_idx, raw_len); - sched.on_success(done.server_idx, raw_len, done.elapsed); + sched.on_success(done.server_idx, raw_len, done.elapsed, now); window.record_ok(done.elapsed.as_secs_f64() * 1000.0); if raw_len >= done.reserve { in_flight.fetch_add(raw_len - done.reserve, Ordering::Relaxed); @@ -1172,7 +1172,7 @@ async fn run_driver(ctx: DriverCtx<'_>) { } Ok(Fetched::Streamed(total_len)) => { meter.record(done.server_idx, total_len); - sched.on_success(done.server_idx, total_len, done.elapsed); + sched.on_success(done.server_idx, total_len, done.elapsed, now); window.record_ok(done.elapsed.as_secs_f64() * 1000.0); outstanding += 1; if txs[done.idx % txs.len()] @@ -1335,6 +1335,7 @@ struct ServerHealth { samples: u32, consecutive_errors: u32, cooldown_until: Option<Instant>, + fault_until: Option<Instant>, } struct FetchScheduler { @@ -1412,7 +1413,7 @@ impl FetchScheduler { }) } - fn on_success(&mut self, server_idx: usize, bytes: u64, elapsed: Duration) { + fn on_success(&mut self, server_idx: usize, bytes: u64, elapsed: Duration, now: Instant) { let secs = elapsed.as_secs_f64().max(0.001); let bps = bytes as f64 / secs; let h = &mut self.health[server_idx]; @@ -1423,7 +1424,15 @@ impl FetchScheduler { }; h.samples = h.samples.saturating_add(1); h.consecutive_errors = 0; - h.cooldown_until = None; + // A success can belong to a request that was already in flight when another response from + // this host failed. Keep a server-fault cooldown until it expires instead of clearing it + // early and sending the retry straight back to the same mirror. + if let Some(fault) = h.fault_until.filter(|until| *until > now) { + h.cooldown_until = Some(h.cooldown_until.map_or(fault, |current| current.max(fault))); + } else { + h.fault_until = None; + h.cooldown_until = None; + } } fn rest(&mut self, server_idx: usize, now: Instant, rest: Duration) { @@ -1436,10 +1445,18 @@ impl FetchScheduler { let h = &mut self.health[server_idx]; h.consecutive_errors = h.consecutive_errors.saturating_add(1); let mut backoff = retry_backoff_millis(h.consecutive_errors); - if kind == FetchFailKind::RateLimited { - backoff = backoff.max(RATE_LIMIT_COOLDOWN_MS); + match kind { + FetchFailKind::RateLimited => backoff = backoff.max(RATE_LIMIT_COOLDOWN_MS), + FetchFailKind::ServerFault => backoff = backoff.max(SERVER_FAULT_COOLDOWN_MS), + _ => {} } - h.cooldown_until = Some(now + Duration::from_millis(backoff)); + let until = now + Duration::from_millis(backoff); + if kind == FetchFailKind::ServerFault { + h.fault_until = Some(now + Duration::from_millis(SERVER_FAULT_COOLDOWN_MS)); + } + let current = h.cooldown_until.unwrap_or(until); + let fault = h.fault_until.unwrap_or(until); + h.cooldown_until = Some(current.max(fault).max(until)); h.ewma_bps *= 0.5; } } @@ -1806,6 +1823,7 @@ mod tests { use super::*; use crate::fetch_tuning::{ FETCH_INFLIGHT_BUDGET_FLOOR_BYTES, FETCH_INFLIGHT_BUDGET_HARD_CAP_BYTES, PER_HOST_CAP, + SERVER_FAULT_COOLDOWN_MS, }; use std::collections::HashMap; use std::io::{Read, Write}; @@ -1900,9 +1918,9 @@ mod tests { let mut s = FetchScheduler::new(&hosts, 2); let now = Instant::now(); assert_eq!(s.pick(now), Some(0)); - s.on_success(0, 1_000_000, Duration::from_millis(100)); + s.on_success(0, 1_000_000, Duration::from_millis(100), now); assert_eq!(s.pick(now), Some(1)); - s.on_success(1, 1_000_000, Duration::from_millis(500)); + s.on_success(1, 1_000_000, Duration::from_millis(500), now); assert_eq!(s.pick(now), Some(0)); s.on_error(0, now, FetchFailKind::Timeout); assert_eq!(s.pick(now), Some(1)); @@ -1911,6 +1929,57 @@ mod tests { assert_eq!(s.pick(now + Duration::from_secs(6)), Some(0)); } + #[test] + fn a_server_fault_rests_the_host_for_the_other_mirror() { + let hosts = vec!["a".to_string(), "b".to_string()]; + let mut s = FetchScheduler::new(&hosts, 2); + let now = Instant::now(); + s.on_success(0, 1_000_000, Duration::from_millis(100), now); + s.on_success(1, 1_000_000, Duration::from_millis(100), now); + s.on_error(0, now, FetchFailKind::ServerFault); + assert_eq!(s.pick(now), Some(1)); + assert_eq!( + s.pick(now + Duration::from_millis(SERVER_FAULT_COOLDOWN_MS - 1)), + Some(1) + ); + assert!(!s.eligible(0, now + Duration::from_millis(SERVER_FAULT_COOLDOWN_MS - 1))); + assert!(s.eligible(0, now + Duration::from_millis(SERVER_FAULT_COOLDOWN_MS))); + } + + #[test] + fn a_success_from_an_earlier_request_does_not_clear_a_server_fault_cooldown() { + let hosts = vec!["a".to_string(), "b".to_string()]; + let mut s = FetchScheduler::new(&hosts, 2); + let now = Instant::now(); + s.on_error(0, now, FetchFailKind::ServerFault); + s.on_success(0, 1_000_000, Duration::from_millis(100), now + Duration::from_millis(100)); + assert!(!s.eligible(0, now + Duration::from_millis(100))); + assert!(s.eligible(0, now + Duration::from_millis(SERVER_FAULT_COOLDOWN_MS))); + } + + #[test] + fn a_success_keeps_a_longer_cooldown_than_the_server_fault() { + let hosts = vec!["a".to_string(), "b".to_string()]; + let mut s = FetchScheduler::new(&hosts, 2); + let now = Instant::now(); + s.on_error(0, now, FetchFailKind::ServerFault); + s.rest(0, now, Duration::from_secs(30)); + s.on_success(0, 1_000_000, Duration::from_millis(100), now + Duration::from_millis(100)); + assert!(!s.eligible(0, now + Duration::from_secs(29))); + assert!(s.eligible(0, now + Duration::from_secs(30))); + } + + #[test] + fn a_server_fault_keeps_a_longer_host_stall_cooldown() { + let hosts = vec!["a".to_string(), "b".to_string()]; + let mut s = FetchScheduler::new(&hosts, 2); + let now = Instant::now(); + s.rest(0, now, Duration::from_secs(30)); + s.on_error(0, now, FetchFailKind::ServerFault); + assert!(!s.eligible(0, now + Duration::from_secs(29))); + assert!(s.eligible(0, now + Duration::from_secs(30))); + } + #[test] fn window_shrinks_only_on_error_bursts() { let now = Instant::now(); diff --git a/app/src/main/rust/stores/src/fetch_tuning.rs b/app/src/main/rust/stores/src/fetch_tuning.rs index 4f1f7074f..5c380d537 100644 --- a/app/src/main/rust/stores/src/fetch_tuning.rs +++ b/app/src/main/rust/stores/src/fetch_tuning.rs @@ -68,6 +68,10 @@ pub const SERVER_EXPLORE_EVERY: u64 = 8; pub const NOMINAL_CHUNK_RESERVE_BYTES: u64 = 1024 * 1024; pub const RATE_LIMIT_COOLDOWN_MS: u64 = 5_000; +// Long enough that every retry of the item (5 attempts, at most ~4.5 s of backoff) goes to another +// mirror after this one served a truncated or undecodable body. +pub const SERVER_FAULT_COOLDOWN_MS: u64 = 10_000; + pub fn retry_backoff_millis(attempt: u32) -> u64 { if attempt == 0 { return 0; diff --git a/app/src/test/java/com/droiddeck/launcher/DriverMenusAutoTest.kt b/app/src/test/java/com/droiddeck/launcher/DriverMenusAutoTest.kt index afcb0d4b5..4be7a14e6 100644 --- a/app/src/test/java/com/droiddeck/launcher/DriverMenusAutoTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/DriverMenusAutoTest.kt @@ -6,9 +6,11 @@ import android.os.Handler import android.os.Looper import com.droiddeck.launcher.session.QuestExperimental import com.droiddeck.launcher.session.SessionPrefs +import com.droiddeck.launcher.gpu.TpUbwcHint import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -17,11 +19,15 @@ import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment import org.robolectric.annotation.Config import org.robolectric.shadows.ShadowBuild +import org.robolectric.util.ReflectionHelpers @RunWith(RobolectricTestRunner::class) @Config(sdk = [28], manifest = Config.NONE) class DriverMenusAutoTest { @Before fun reset() { + ReflectionHelpers.setStaticField(TpUbwcHint::class.java, "resolved", false) + ReflectionHelpers.setStaticField(TpUbwcHint::class.java, "cached", null) + ReflectionHelpers.setStaticField(TpUbwcHint::class.java, "activeMode", null) RuntimeEnvironment.getApplication().getSharedPreferences("session", Context.MODE_PRIVATE) .edit().clear().commit() ShadowBuild.setModel("Quest 3") @@ -53,4 +59,36 @@ class DriverMenusAutoTest { assertEquals("chosen-display", SessionPrefs.androidDriver(activity)) assertEquals("chosen-linux", SessionPrefs.linuxDriver(activity)) } + + @Test fun driverMenusCanBeConstructedBeforeActivityContextIsAttached() { + val menus = DriverMenus(Activity(), Handler(Looper.getMainLooper())) + assertEquals(SessionPrefs.TP_UBWC_AUTO, menus.tpUbwcMode) + } + + @Test fun refreshingDriversLoadsTheSavedUbwcModeAfterContextAttachment() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + SessionPrefs.setTpUbwcMode(activity, SessionPrefs.TP_UBWC_ON) + val menus = DriverMenus(activity, Handler(Looper.getMainLooper())) + menus.refreshDrivers() + assertEquals(SessionPrefs.TP_UBWC_ON, menus.tpUbwcMode) + assertFalse(menus.state().tpUbwcRestartRequired) + } + + @Test fun changingUbwcBeforeTheFirstSessionDoesNotRequireRestart() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val menus = DriverMenus(activity, Handler(Looper.getMainLooper())) + menus.refreshDrivers() + SessionPrefs.setTpUbwcMode(activity, SessionPrefs.TP_UBWC_ON) + menus.refreshDrivers() + assertFalse(menus.state().tpUbwcRestartRequired) + } + + @Test fun recreatingTheMenuKeepsTheActiveHintRestartWarning() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + TpUbwcHint.forThisDevice(activity) + SessionPrefs.setTpUbwcMode(activity, SessionPrefs.TP_UBWC_ON) + val menus = DriverMenus(activity, Handler(Looper.getMainLooper())) + menus.refreshDrivers() + assertTrue(menus.state().tpUbwcRestartRequired) + } } diff --git a/app/src/test/java/com/droiddeck/launcher/ProtonMenuAutoTest.kt b/app/src/test/java/com/droiddeck/launcher/ProtonMenuAutoTest.kt new file mode 100644 index 000000000..5f3dddf96 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ProtonMenuAutoTest.kt @@ -0,0 +1,269 @@ +package com.droiddeck.launcher + +import android.app.Activity +import android.content.Intent +import android.os.Handler +import android.os.Looper +import androidx.compose.ui.geometry.Rect +import com.droiddeck.launcher.frontend.AutoComponents +import com.droiddeck.launcher.runtime.LinuxRuntime +import com.droiddeck.launcher.session.ProtonExtras +import com.droiddeck.launcher.session.SessionState +import com.droiddeck.launcher.session.SessionPhase +import com.droiddeck.launcher.session.LoadingState +import com.droiddeck.launcher.ui.LaunchOrigin +import com.droiddeck.launcher.ui.Motion +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.util.ReflectionHelpers +import java.io.File +import java.util.concurrent.TimeUnit + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class ProtonMenuAutoTest { + private val online = AutoComponents.online + private val constrained = AutoComponents.unfavourable + private lateinit var activity: Activity + private lateinit var menu: ProtonMenu + private lateinit var root: File + private val attempts = mutableListOf<String>() + private var idle = true + + @Before fun setup() { + activity = Robolectric.buildActivity(Activity::class.java).setup().get() + root = LinuxRuntime.rootDir(activity) + for (path in listOf("usr/bin/gamescope", "usr/local/bin/droiddeck-session", "opt/android-host/proot", "opt/android-host/loader")) { + File(root, path).apply { parentFile!!.mkdirs(); writeText("") } + } + AutoComponents.online = { true } + AutoComponents.unfavourable = { false } + SessionState.running = false + SessionState.phase = SessionPhase.IDLE + SessionState.stopRequested = false + LaunchOrigin.take() + LaunchOrigin.flooding = null + val latest = JSONObject() + for (tool in ProtonExtras.tools) latest.put(tool.id, JSONObject() + .put("tag", "${tool.prefix}-11-12").put("at", System.currentTimeMillis())) + File(activity.filesDir, "proton-latest.json").writeText(latest.toString()) + menu = ProtonMenu(activity, Handler(Looper.getMainLooper())) { idle } + menu.installer = { tool, progress -> + progress("Installing ${tool.name}", 50) + install("${tool.prefix}-11-12") + null + } + } + + @After fun reset() { + AutoComponents.online = online + AutoComponents.unfavourable = constrained + SessionState.running = false + SessionState.phase = SessionPhase.IDLE + SessionState.stopRequested = false + LaunchOrigin.take() + LaunchOrigin.flooding = null + } + + private fun install(name: String): File = File(root, "root/.local/share/Steam/compatibilitytools.d/$name").apply { + mkdirs() + File(this, "toolmanifest.vdf").writeText("manifest") + } + + private fun waitUntil(done: () -> Boolean) { + val deadline = System.nanoTime() + 5_000_000_000L + while (System.nanoTime() < deadline) { + shadowOf(Looper.getMainLooper()).idle() + if (done()) return + Thread.sleep(10) + } + fail("Proton work did not finish") + } + + @Test fun aCachedCheckUpdatesOnlyInstalledFamiliesAndReplacesOlderDroidDeckProton() { + val older = install("DroidDeck-Proton-11-11.1") + val request = File(root, "root/.local/share/droiddeck-compat/request.json").apply { + parentFile!!.mkdirs() + writeText("{\"seq\":1,\"dir\":\"DroidDeck-Proton-11-11.1\",\"valve\":false}") + } + val before = request.readText() + assertFalse(ProtonExtras.checkLatest(activity)) + menu.checkUpdates() + waitUntil { menu.protonRows.singleOrNull { it.id == "droiddeck" }?.installed == "DroidDeck-Proton-11-12" && menu.protonBusyId == null && !older.exists() } + assertEquals(listOf("DroidDeck-Proton-11-12"), ProtonExtras.installedAll(activity, ProtonExtras.tools.first { it.id == "droiddeck" })) + assertEquals(before, request.readText()) + assertTrue(ProtonExtras.installedAll(activity, ProtonExtras.tools.first { it.id == "ge" }).isEmpty()) + assertTrue(ProtonExtras.installedAll(activity, ProtonExtras.tools.first { it.id == "cachyos" }).isEmpty()) + } + + @Test fun downloadsWaitForTheSessionNetworkPowerAndOtherInstallWork() { + install("DroidDeck-Proton-11-11.1") + menu.installer = { tool, _ -> attempts.add(tool.id); "failed" } + SessionState.running = true + menu.ensureAutoUpdates() + SessionState.running = false + AutoComponents.online = { false } + menu.ensureAutoUpdates() + AutoComponents.online = { true } + AutoComponents.unfavourable = { true } + menu.ensureAutoUpdates() + AutoComponents.unfavourable = { false } + idle = false + menu.ensureAutoUpdates() + assertNull(menu.protonBusyId) + assertTrue(attempts.isEmpty()) + idle = true + menu.ensureAutoUpdates() + waitUntil { menu.protonBusyId == null } + assertEquals(listOf("droiddeck"), attempts) + } + + @Test fun aFailedUpdateDoesNotLoopOrBlockOtherInstalledFamilies() { + val ge = install("GE-Proton11-11") + install("DroidDeck-Proton-11-11.1") + menu.installer = { tool, _ -> + attempts.add(tool.id) + if (tool.id == "ge") throw IllegalStateException("download failed") + install("${tool.prefix}-11-12") + null + } + menu.ensureAutoUpdates() + waitUntil { menu.protonBusyId == null } + menu.ensureAutoUpdates() + waitUntil { menu.protonBusyId == null } + assertEquals(listOf("ge", "droiddeck"), attempts) + assertTrue(ge.isDirectory) + assertEquals(listOf("DroidDeck-Proton-11-12"), ProtonExtras.installedAll(activity, ProtonExtras.tools.first { it.id == "droiddeck" })) + assertNull(menu.protonStage) + assertEquals(-1, menu.protonPercent) + } + + @Test fun anUninstalledRuntimeOrCurrentProtonDoesNotStartADownload() { + install("DroidDeck-Proton-11-12") + menu.installer = { tool, _ -> attempts.add(tool.id); null } + menu.ensureAutoUpdates() + assertTrue(attempts.isEmpty()) + install("GE-Proton11-11") + File(root, "usr/bin/gamescope").delete() + menu.ensureAutoUpdates() + assertTrue(attempts.isEmpty()) + assertNull(menu.protonBusyId) + } + + @Test fun aFailedReleaseIsNotRetriedAfterRecreatingTheLauncher() { + install("DroidDeck-Proton-11-11.1") + menu.installer = { tool, _ -> attempts.add(tool.id); "failed" } + menu.ensureAutoUpdates() + waitUntil { menu.protonBusyId == null } + val recreated = Robolectric.buildActivity(Activity::class.java).setup().get() + val next = ProtonMenu(recreated, Handler(Looper.getMainLooper())) + next.installer = { tool, _ -> attempts.add(tool.id); "failed" } + next.ensureAutoUpdates() + assertNull(next.protonBusyId) + assertEquals(listOf("droiddeck"), attempts) + } + + @Test fun automaticUpdatesWaitForEveryNonEndedSessionPhaseEvenWhenRunningIsFalse() { + install("DroidDeck-Proton-11-11.1") + for (phase in SessionPhase.entries.filter { it != SessionPhase.IDLE && it != SessionPhase.FAILED }) { + SessionState.phase = phase + menu.ensureAutoUpdates() + assertNull("Must wait during $phase", menu.protonBusyId) + } + SessionState.phase = SessionPhase.FAILED + menu.ensureAutoUpdates() + waitUntil { menu.protonBusyId == null } + assertEquals("DroidDeck-Proton-11-12", ProtonExtras.installed(activity, ProtonExtras.tools.first { it.id == "droiddeck" })) + } + + @Test fun droidDeckProtonIsInstalledWhenMissingUnlessTheUserRemovedIt() { + menu.installer = { tool, _ -> attempts.add(tool.id); install("${tool.prefix}-11-12"); null } + ProtonExtras.setDeclined(activity, true) + menu.ensureAutoUpdates() + assertNull(menu.protonBusyId) + assertTrue(attempts.isEmpty()) + ProtonExtras.setDeclined(activity, false) + menu.ensureAutoUpdates() + waitUntil { menu.protonBusyId == null } + assertEquals(listOf("droiddeck"), attempts) + assertEquals("DroidDeck-Proton-11-12", ProtonExtras.installed(activity, ProtonExtras.tools.first { it.id == "droiddeck" })) + assertTrue(ProtonExtras.installedAll(activity, ProtonExtras.tools.first { it.id == "ge" }).isEmpty()) + menu.removeProton("droiddeck") + waitUntil { menu.protonBusyId == null } + assertTrue(ProtonExtras.declined(activity)) + menu.installer = { _, _ -> "failed" } + menu.installProton("droiddeck") + waitUntil { menu.protonBusyId == null } + assertTrue(ProtonExtras.declined(activity)) + menu.installer = { tool, _ -> install("${tool.prefix}-11-12"); null } + menu.installProton("droiddeck") + waitUntil { menu.protonBusyId == null } + assertFalse(ProtonExtras.declined(activity)) + } + + @Test fun aPendingAnimatedLaunchPreventsAnAutomaticInstall() { + install("DroidDeck-Proton-11-11.1") + val launcher = Robolectric.buildActivity(FrontEndActivity::class.java).get() + val protons = ReflectionHelpers.getField<ProtonMenu>(launcher, "protons") + protons.installer = { tool, _ -> attempts.add(tool.id); "failed" } + Motion.refresh(launcher) + LaunchOrigin.mark(Rect(0f, 0f, 100f, 100f), "game") + launcher.startActivity(Intent(launcher, SessionActivity::class.java)) + assertNotNull(ReflectionHelpers.callInstanceMethod<Any?>(launcher, "getFlood")) + assertNull(shadowOf(launcher).nextStartedActivity) + protons.ensureAutoUpdates() + assertNull(protons.protonBusyId) + assertTrue(attempts.isEmpty()) + } + + @Test fun anAnimatedLaunchRechecksTheProtonInstallGuardBeforeStarting() { + val launcher = Robolectric.buildActivity(FrontEndActivity::class.java).get() + Motion.refresh(launcher) + LaunchOrigin.mark(Rect(0f, 0f, 100f, 100f), "game") + launcher.startActivity(Intent(launcher, SessionActivity::class.java)) + val pending = requireNotNull(ReflectionHelpers.callInstanceMethod<Any?>(launcher, "getFlood")) + ReflectionHelpers.getField<ProtonMenu>(launcher, "protons").protonBusyId = "droiddeck" + FrontEndActivity::class.java.getDeclaredMethod("launchFlooded", pending.javaClass) + .apply { isAccessible = true }.invoke(launcher, pending) + assertNull(shadowOf(launcher).nextStartedActivity) + assertNull(ReflectionHelpers.callInstanceMethod<Any?>(launcher, "getFlood")) + assertNull(LaunchOrigin.flooding) + } + + @Test fun aCancellationDuringTeardownCannotPublishIdleEarly() { + val session = watchCancellation(SessionPhase.STOPPING) + assertEquals(SessionPhase.STOPPING, SessionState.phase) + assertTrue(SessionState.stopRequested) + assertFalse(session.isFinishing) + } + + @Test fun cancellationBeforeSessionStartupStillFinishesTheActivity() { + val session = watchCancellation(SessionPhase.PREPARING) + assertEquals(SessionPhase.IDLE, SessionState.phase) + assertFalse(SessionState.stopRequested) + assertTrue(session.isFinishing) + } + + private fun watchCancellation(phase: SessionPhase): SessionActivity { + val session = Robolectric.buildActivity(SessionActivity::class.java).get() + ReflectionHelpers.setField(session, "loading", LoadingState(session).apply { visible = false }) + SessionState.phase = phase + SessionState.stopRequested = true + try { + ReflectionHelpers.callInstanceMethod<Void>(session, "watchSession") + shadowOf(Looper.getMainLooper()).idle() + } finally { + ReflectionHelpers.setField(session, "watching", false) + shadowOf(Looper.getMainLooper()).idleFor(500, TimeUnit.MILLISECONDS) + } + return session + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/agent/AgentSecurityTest.kt b/app/src/test/java/com/droiddeck/launcher/agent/AgentSecurityTest.kt index df4bb15fc..f625d6b41 100644 --- a/app/src/test/java/com/droiddeck/launcher/agent/AgentSecurityTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/agent/AgentSecurityTest.kt @@ -9,6 +9,8 @@ import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.text.AnnotatedString import com.droiddeck.launcher.R import com.droiddeck.launcher.runtime.LinuxRuntime +import com.droiddeck.launcher.runtime.ProotFastPath +import com.droiddeck.launcher.session.SessionState import org.json.JSONObject import org.junit.Assert.* import org.junit.After @@ -179,6 +181,31 @@ class AgentSecurityTest { assertEquals(listOf("TEST=old", "STICKY=1"), AgentEnv.beginSession(context)) } + @Test fun fastPathLibraryIsValidatedAndSelectedForOneSession() { + AgentAccess.setCommandsEnabled(context, true) + val source = File(AgentEnv.inbox(context).apply { mkdirs() }, "libblfastpath.so") + source.writeText("wrong architecture") + refused("INVALID_LIBRARY") { AgentEnv.override(context, source.name) } + val header = ByteArray(64) + byteArrayOf(0x7f, 0x45, 0x4c, 0x46, 2, 1, 1).copyInto(header) + header[16] = 3 + header[18] = 183.toByte() + source.writeBytes(header) + SessionState.running = true + try { + refused("SESSION_ACTIVE") { AgentEnv.override(context, source.name) } + } finally { + SessionState.running = false + } + val response = AgentEnv.override(context, source.name) + assertEquals(64, response.getString("sha256").length) + assertEquals(ProotFastPath.LIBRARY, AgentEnv.fastPathLibrary(context)) + AgentEnv.beginSession(context) + assertEquals("/opt/droiddeck-agent/bin/libblfastpath.so", AgentEnv.fastPathLibrary(context)) + AgentEnv.endSession(context) + assertEquals(ProotFastPath.LIBRARY, AgentEnv.fastPathLibrary(context)) + } + @Test fun environmentValuesAreNotLogged() { AgentAccess.setCommandsEnabled(context, true) AgentEnv.set(context, listOf("CUSTOM=synthetic-private-value"), false) diff --git a/app/src/test/java/com/droiddeck/launcher/audio/DirectAudioMicrophoneTest.kt b/app/src/test/java/com/droiddeck/launcher/audio/DirectAudioMicrophoneTest.kt new file mode 100644 index 000000000..88a211b85 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/audio/DirectAudioMicrophoneTest.kt @@ -0,0 +1,86 @@ +package com.droiddeck.launcher.audio + +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.rules.TemporaryFolder +import org.robolectric.RuntimeEnvironment +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.util.ReflectionHelpers +import java.io.File +import java.util.function.Consumer + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28]) +class DirectAudioMicrophoneTest { + @get:Rule val folder = TemporaryFolder() + + @Test fun toggleOnlyChangesTheSessionCaptureControl() { + val fifo = File(folder.root, "mic.fifo") + val relay = DirectAudioRelayComponent(File(folder.root, "relay.sock"), fifo) + val changes = mutableListOf<Boolean>() + relay.onMicEnabled = Consumer { changes.add(it) } + val control = File(folder.root, "mic-enabled") + assertTrue(relay.setMicEnabled(false)) + assertEquals("0", control.readText()) + assertTrue(changes.isEmpty()) + log(relay, "DA-Relay: mic enabled=0") + assertEquals(listOf(false), changes) + assertTrue(relay.setMicEnabled(true)) + assertEquals("1", control.readText()) + log(relay, "DA-Relay: mic enabled=1") + assertEquals(listOf(false, true), changes) + assertFalse(fifo.exists()) + } + + @Test fun sessionWithoutCaptureCannotEnableIt() { + val relay = DirectAudioRelayComponent(File(folder.root, "relay.sock"), null) + assertFalse(relay.setMicEnabled(true)) + assertFalse(File(folder.root, "mic-enabled").exists()) + } + + @Test fun captureDemandDoesNotOverrideTheAcknowledgedToggle() { + val relay = DirectAudioRelayComponent(File(folder.root, "relay.sock"), File(folder.root, "mic.fifo")) + val changes = mutableListOf<Boolean>() + relay.onMicEnabled = Consumer { changes.add(it) } + log(relay, "DA-Relay: mic enabled=0") + log(relay, "DA-Relay: mic start (game wants it, 1 consumer(s))") + log(relay, "DA-Relay: mic stop (game)") + assertEquals(listOf(false), changes) + assertTrue(relay.awaitMicReleased(0)) + } + + @Test fun controlWriteFailureStillStartsPlaybackWithCaptureDisabled() { + val context = RuntimeEnvironment.getApplication() + val info = context.applicationInfo + val previous = info.nativeLibraryDir + val nativeDir = folder.newFolder("native") + val args = File(folder.root, "arguments") + File(nativeDir, DirectAudioRelayComponent.BINARY).apply { + writeText("#!/bin/sh\nprintf '%s\\n' \"\$@\" > '${args.path}.new'\nmv '${args.path}.new' '${args.path}'\n") + setExecutable(true) + } + val fifo = folder.newFile("mic.fifo") + File(folder.root, "mic-enabled.new").mkdir() + val relay = DirectAudioRelayComponent(File(folder.root, "relay.sock"), fifo) + relay.attach(context) + try { + info.nativeLibraryDir = nativeDir.path + relay.start() + val deadline = System.nanoTime() + 3_000_000_000L + while (!args.isFile && System.nanoTime() < deadline) Thread.sleep(10) + assertTrue("Playback helper did not start", args.isFile) + val command = args.readLines() + assertTrue(command.contains("--socket")) + assertFalse(command.contains("--mic-fifo")) + assertEquals(listOf("--mic-control", "/dev/null"), command.takeLast(2)) + } finally { + info.nativeLibraryDir = previous + } + } + + private fun log(relay: DirectAudioRelayComponent, line: String) = + ReflectionHelpers.callInstanceMethod<Unit>(relay, "trackMic", ReflectionHelpers.ClassParameter.from(String::class.java, line)) +} diff --git a/app/src/test/java/com/droiddeck/launcher/core/BackgroundAppLimitTest.kt b/app/src/test/java/com/droiddeck/launcher/core/BackgroundAppLimitTest.kt new file mode 100644 index 000000000..1f9d3391e --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/core/BackgroundAppLimitTest.kt @@ -0,0 +1,40 @@ +package com.droiddeck.launcher.core + +import com.droiddeck.launcher.session.SessionPrefs +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class BackgroundAppLimitTest { + @Test fun theLimitIsActivityManagersCachedProcessCap() { + assertEquals("device_config get activity_manager max_cached_processes", BackgroundAppLimit.readCommand()) + assertEquals("device_config put activity_manager max_cached_processes 4", BackgroundAppLimit.setCommand(4)) + assertEquals("device_config put activity_manager max_cached_processes 0", BackgroundAppLimit.setCommand(0)) + assertThrows(IllegalArgumentException::class.java) { BackgroundAppLimit.setCommand(-1) } + } + + @Test fun whatWasThereIsWhatGoesBack() { + assertNull(BackgroundAppLimit.parse("null\n")) + assertNull(BackgroundAppLimit.parse("")) + assertEquals("16", BackgroundAppLimit.parse("16\n")) + assertEquals("device_config delete activity_manager max_cached_processes", BackgroundAppLimit.restoreCommand(null)) + assertEquals("device_config put activity_manager max_cached_processes 16", BackgroundAppLimit.restoreCommand("16")) + } + + @Test fun setupOffersOffAndDeveloperOptionsOwnLimits() { + val context = RuntimeEnvironment.getApplication() + assertEquals(BackgroundAppLimit.OFF, SessionPrefs.backgroundAppLimit(context)) + for (limit in listOf(4, 1, 0, BackgroundAppLimit.OFF)) { + SessionPrefs.setBackgroundAppLimit(context, limit) + assertEquals(limit, SessionPrefs.backgroundAppLimit(context)) + } + assertThrows(IllegalArgumentException::class.java) { SessionPrefs.setBackgroundAppLimit(context, 7) } + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/core/CpuAllocationDefaultsTest.kt b/app/src/test/java/com/droiddeck/launcher/core/CpuAllocationDefaultsTest.kt deleted file mode 100644 index ba6e31a10..000000000 --- a/app/src/test/java/com/droiddeck/launcher/core/CpuAllocationDefaultsTest.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.droiddeck.launcher.core - -import org.junit.Assert.* -import org.junit.Test - -class CpuAllocationDefaultsTest { - private val thor = (0..7).associateWith { when (it) { in 0..2 -> 2.016; in 3..6 -> 2.8032; else -> 3.1872 } } - - @Test fun gen2UsesTheFourMiddleCoresAndOnePrime() { - for (model in listOf("SM8550", "QCS8550", "sm8550-ac")) { - assertEquals(CpuAllocationDefaults.Allocation("7", "3,4,5,6"), CpuAllocationDefaults.forDevice(model, "", thor)) - } - assertEquals(CpuAllocationDefaults.Allocation("7", "3,4,5,6"), CpuAllocationDefaults.forDevice("", "kalama", thor)) - } - - @Test fun coreNumbersAndClockVariantsAreDetectedInsteadOfHardcoded() { - val reordered = mapOf(0 to 3.36, 1 to 2.0, 2 to 2.0, 3 to 2.0, 4 to 2.8, 5 to 2.8, 6 to 2.8, 7 to 2.8) - assertEquals(CpuAllocationDefaults.Allocation("0", "4,5,6,7"), CpuAllocationDefaults.forDevice("SM8550-AC", "kalama", reordered)) - } - - @Test fun anotherOrUnknownChipDoesNotAcquireThisDefault() { - assertNull(CpuAllocationDefaults.forDevice("SM8650", "pineapple", thor)) - assertNull(CpuAllocationDefaults.forDevice("SM8650", "kalama", thor)) - assertNull(CpuAllocationDefaults.forDevice("", "", thor)) - } - - @Test fun missingOrUnexpectedTopologyKeepsTheSchedulerDefault() { - assertNull(CpuAllocationDefaults.forDevice("SM8550", "kalama", thor - 7)) - assertNull(CpuAllocationDefaults.forDevice("SM8550", "kalama", thor + (7 to null))) - assertNull(CpuAllocationDefaults.forDevice("SM8550", "kalama", thor + (7 to 0.0))) - assertNull(CpuAllocationDefaults.forDevice("SM8550", "kalama", thor + (7 to Double.NaN))) - assertNull(CpuAllocationDefaults.forDevice("SM8550", "kalama", (0..7).associateWith { 2.8 })) - } - - @Test fun allCoreGamesUndoTheClientsInheritedRestriction() { - val all = CpuCores.listOrAll("") - assertEquals(all, CpuCores.gameLaunchList("", clientPinned = true)) - assertEquals(all, CpuCores.gameLaunchList(all, clientPinned = true)) - assertEquals("", CpuCores.gameLaunchList("", clientPinned = false)) - assertEquals("0", CpuCores.gameLaunchList("0", clientPinned = true)) - } -} diff --git a/app/src/test/java/com/droiddeck/launcher/core/DownloadSourceProxyTest.kt b/app/src/test/java/com/droiddeck/launcher/core/DownloadSourceProxyTest.kt new file mode 100644 index 000000000..46725fc7f --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/core/DownloadSourceProxyTest.kt @@ -0,0 +1,36 @@ +package com.droiddeck.launcher.core + +import java.net.InetSocketAddress +import java.net.Proxy +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class DownloadSourceProxyTest { + @Test fun hostAndPortBecomeAnHttpProxy() { + val proxy = DownloadSource.proxyOf("127.0.0.1:9910") + assertEquals(Proxy.Type.HTTP, proxy?.type()) + val address = proxy?.address() as InetSocketAddress + assertEquals("127.0.0.1", address.hostString) + assertEquals(9910, address.port) + } + + @Test fun missingOrInvalidPortIsIgnored() { + assertNull(DownloadSource.proxyOf("")) + assertNull(DownloadSource.proxyOf("127.0.0.1")) + assertNull(DownloadSource.proxyOf("127.0.0.1:")) + assertNull(DownloadSource.proxyOf("127.0.0.1:notaport")) + } + + @Test fun normalizationAcceptsSchemeAndTrailingSlash() { + assertEquals("127.0.0.1:9910", DownloadSource.normalizeProxy(" http://127.0.0.1:9910/ ")) + assertEquals("192.168.1.2:8080", DownloadSource.normalizeProxy("192.168.1.2:8080")) + } + + @Test fun invalidInputDisablesTheProxy() { + assertEquals("", DownloadSource.normalizeProxy("")) + assertEquals("", DownloadSource.normalizeProxy("127.0.0.1")) + assertEquals("", DownloadSource.normalizeProxy("127.0.0.1:70000")) + assertEquals("", DownloadSource.normalizeProxy("http://127.0.0.1:notaport")) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/core/HostProcessTest.kt b/app/src/test/java/com/droiddeck/launcher/core/HostProcessTest.kt new file mode 100644 index 000000000..6fbe7ed10 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/core/HostProcessTest.kt @@ -0,0 +1,37 @@ +package com.droiddeck.launcher.core + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class HostProcessTest { + @get:Rule val temporary = TemporaryFolder() + + @Test fun appendsBothOutputStreamsToTheExistingLog() { + val windows = System.getProperty("os.name").orEmpty().startsWith("Windows") + val log = temporary.newFile("session.log").apply { writeText("guest output\n") } + val script = temporary.newFile(if (windows) "tracer.cmd" else "tracer.sh").apply { + writeText(if (windows) "@echo off\r\necho tracer stdout\r\necho tracer stderr>&2\r\n" + else "printf 'tracer stdout\\n'\nprintf 'tracer stderr\\n' >&2\n") + } + val finished = CountDownLatch(1) + var exitCode = -1 + val shell = if (windows) "cmd.exe /c" else "/bin/sh" + HostProcess.start("$shell ${script.name}", null, temporary.root, { + exitCode = it + finished.countDown() + }, null, log) + assertTrue(finished.await(5, TimeUnit.SECONDS)) + assertEquals(0, exitCode) + assertEquals(listOf("guest output", "tracer stdout", "tracer stderr"), log.readLines()) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/core/LogRedactorTest.kt b/app/src/test/java/com/droiddeck/launcher/core/LogRedactorTest.kt index a67093e93..38849c41a 100644 --- a/app/src/test/java/com/droiddeck/launcher/core/LogRedactorTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/core/LogRedactorTest.kt @@ -195,6 +195,7 @@ class LogRedactorTest { "auth: login=maskeduser42&remember=1", "accountName: maskeduser42", "{\"username\": \"masked user 42\"}", + "[Clubs] {Main} Club membership subscription created: |accountId=maskeduser42", ) for (line in lines) { val out = LogRedactor.redactForShare(line) diff --git a/app/src/test/java/com/droiddeck/launcher/core/ZramSupportTest.kt b/app/src/test/java/com/droiddeck/launcher/core/ZramSupportTest.kt index 056e3a055..033efb578 100644 --- a/app/src/test/java/com/droiddeck/launcher/core/ZramSupportTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/core/ZramSupportTest.kt @@ -14,20 +14,6 @@ class ZramSupportTest { assertFalse(ZramSupport.kernelAtLeast("", 5, 4)) } - @Test - fun underTwelveGbFromAdvertisedOrTotalMemory() { - val gb = 1_000_000_000L - val gib = 1L shl 30 - assertTrue(ZramSupport.belowTwelveGb(8 * gb, 0)) - assertTrue(ZramSupport.belowTwelveGb(8 * gib, 0)) - assertFalse(ZramSupport.belowTwelveGb(12 * gb, 0)) - assertFalse(ZramSupport.belowTwelveGb(12 * gib, 0)) - assertTrue(ZramSupport.belowTwelveGb(0, 7_400_000L * 1024)) - assertTrue(ZramSupport.belowTwelveGb(0, 9_300_000L * 1024)) - assertFalse(ZramSupport.belowTwelveGb(0, 11_100_000L * 1024)) - assertFalse(ZramSupport.belowTwelveGb(0, 15_600_380L * 1024)) - } - @Test fun swapTotalComesFromMeminfo() { assertEquals(12582908L, ZramSupport.swapTotalKb(listOf("MemTotal: 15600380 kB", "SwapTotal: 12582908 kB"))) diff --git a/app/src/test/java/com/droiddeck/launcher/files/FileManagerNavigationTest.kt b/app/src/test/java/com/droiddeck/launcher/files/FileManagerNavigationTest.kt new file mode 100644 index 000000000..cbcf20755 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/files/FileManagerNavigationTest.kt @@ -0,0 +1,20 @@ +package com.droiddeck.launcher.files + +import java.io.File +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class FileManagerNavigationTest { + @get:Rule val tmp = TemporaryFolder() + + @Test fun backCannotNavigateAboveTheCurrentDriveRoot() { + val drive = tmp.newFolder("drive") + val child = File(drive, "folder").apply { mkdir() } + + assertTrue(canNavigateUp(child, drive)) + assertFalse(canNavigateUp(drive, drive)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/files/FileOpsCancellationTest.kt b/app/src/test/java/com/droiddeck/launcher/files/FileOpsCancellationTest.kt new file mode 100644 index 000000000..8ba15591e --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/files/FileOpsCancellationTest.kt @@ -0,0 +1,121 @@ +package com.droiddeck.launcher.files + +import java.io.File +import java.util.concurrent.CancellationException +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class FileOpsCancellationTest { + @get:Rule val tmp = TemporaryFolder() + + @Test fun cancellingCopyRemovesAnIncompleteNewFile() { + val source = tmp.newFile("source").apply { writeBytes(ByteArray(5 * 1024 * 1024)) } + val destination = File(tmp.root, "destination") + var cancelled = false + + assertThrows(CancellationException::class.java) { + FileOps.copyWithProgress(source, destination, { _, _ -> cancelled = true }) { cancelled } + } + + assertTrue(source.exists()) + assertFalse(destination.exists()) + } + + @Test fun cancellingMoveFallbackPreservesTheSourceAndExistingDestination() { + val source = tmp.newFile("source").apply { writeText("replacement") } + val destination = tmp.newFile("destination").apply { writeText("original") } + var cancelled = false + + assertThrows(CancellationException::class.java) { + FileOps.moveWithProgress(source, destination, { _, _ -> cancelled = true }) { cancelled } + } + + assertEquals("replacement", source.readText()) + assertEquals("original", destination.readText()) + } + + @Test fun aCommittedMoveFinishesRemovingTheSourceDespiteCancellation() { + val source = tmp.newFile("source").apply { writeText("replacement") } + val destination = tmp.newFile("destination").apply { writeText("original") } + val isCancelled = { destination.readText() == "replacement" } + + assertTrue(FileOps.moveWithProgress(source, destination, null, isCancelled)) + + assertTrue(isCancelled()) + assertEquals("replacement", destination.readText()) + assertFalse(source.exists()) + assertEquals(setOf("destination"), tmp.root.list()!!.toSet()) + } + + @Test fun cancellingTheSizeScanStopsBeforeCreatingTheDestination() { + val source = tmp.newFolder("source") + repeat(3) { File(source, "file-$it").writeText("content") } + val destination = File(tmp.root, "destination") + var checks = 0 + + assertThrows(CancellationException::class.java) { + FileOps.copyWithProgress(source, destination, null) { + assertFalse(destination.exists()) + ++checks > 2 + } + } + + assertEquals(3, checks) + assertFalse(destination.exists()) + assertEquals(3, source.listFiles()?.size) + } + + @Test fun cancellingCopyRemovesANewDestinationDirectory() { + val source = tmp.newFolder("source") + val payload = File(source, "nested/payload").apply { + parentFile!!.mkdirs() + writeText("content") + } + val destination = File(tmp.root, "destination") + + assertThrows(CancellationException::class.java) { + FileOps.copyWithProgress(source, destination, { _, _ -> throw CancellationException("cancel") }) + } + + assertEquals("content", payload.readText()) + assertFalse(destination.exists()) + } + + @Test fun cancellingCopyPreservesAnExistingDestinationDirectory() { + val source = tmp.newFolder("source") + File(source, "nested/payload").apply { + parentFile!!.mkdirs() + writeText("replacement") + } + val destination = tmp.newFolder("destination") + val existing = File(destination, "keep").apply { writeText("original") } + + assertThrows(CancellationException::class.java) { + FileOps.copyWithProgress(source, destination, { _, _ -> throw CancellationException("cancel") }) + } + + assertEquals("original", existing.readText()) + assertEquals(setOf("keep"), destination.list()!!.toSet()) + } + + @Test fun cancellingRecursiveDeleteStopsBeforeTheNextFile() { + val directory = tmp.newFolder("directory") + repeat(3) { File(directory, "file-$it").writeText("content") } + var checks = 0 + + assertThrows(CancellationException::class.java) { + FileOps.delete(directory) { ++checks > 3 } + } + + assertTrue(directory.exists()) + assertEquals(2, directory.listFiles()?.size) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/files/FileOpsCopyTest.kt b/app/src/test/java/com/droiddeck/launcher/files/FileOpsCopyTest.kt index ac13ac781..e92ca2aec 100644 --- a/app/src/test/java/com/droiddeck/launcher/files/FileOpsCopyTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/files/FileOpsCopyTest.kt @@ -53,27 +53,25 @@ class FileOpsCopyTest { assertEquals("replacement", src.readText()) } - @Test fun cancellingStopsATreeOfEmptyFiles() { + @Test fun cancellingRemovesANewTreeOfEmptyFiles() { val src = tmp.newFolder("source") for (name in listOf("a", "b", "c")) File(src, name).createNewFile() val dst = File(tmp.root, "dest") - var checks = 0 assertThrows(CancellationException::class.java) { - FileOps.copyWithProgress(src, dst, null) { checks++ > 1 } + FileOps.copyWithProgress(src, dst, null) { dst.list()?.isNotEmpty() == true } } - assertEquals(1, dst.list()!!.size) + assertFalse(dst.exists()) assertEquals(3, src.list()!!.size) } - @Test fun cancellingStopsATreeOfEmptyFolders() { + @Test fun cancellingRemovesANewTreeOfEmptyFolders() { val src = tmp.newFolder("source") for (name in listOf("a", "b", "c")) File(src, name).mkdir() val dst = File(tmp.root, "dest") - var checks = 0 assertThrows(CancellationException::class.java) { - FileOps.copyWithProgress(src, dst, null) { checks++ > 1 } + FileOps.copyWithProgress(src, dst, null) { dst.list()?.isNotEmpty() == true } } - assertEquals(1, dst.list()!!.size) + assertFalse(dst.exists()) } @Test fun aCompletedCopyReplacesTheDestination() { diff --git a/app/src/test/java/com/droiddeck/launcher/files/StorageRootsTest.kt b/app/src/test/java/com/droiddeck/launcher/files/StorageRootsTest.kt new file mode 100644 index 000000000..279c69b2a --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/files/StorageRootsTest.kt @@ -0,0 +1,71 @@ +package com.droiddeck.launcher.files + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class StorageRootsTest { + @Test fun usbAndSdMountsAreFound() { + val mounts = """ + /dev/fuse /mnt/user/0/emulated fuse rw,nosuid,nodev 0 0 + /dev/block/vold/public:8,1 /mnt/media_rw/A1B2-C3D4 vfat rw,dirsync 0 0 + /mnt/media_rw/A1B2-C3D4 /storage/A1B2-C3D4 fuse rw,nosuid,nodev 0 0 + /dev/block/vold/public:179,65 /mnt/media_rw/9C33-1A2B exfat rw,dirsync 0 0 + /mnt/media_rw/9C33-1A2B /storage/9C33-1A2B fuse rw,nosuid,nodev 0 0 + /dev/block/dm-5 /data ext4 rw,seclabel 0 0 + """.trimIndent() + + val points = StorageRoots.parseExternalMounts(mounts).map { it.path } + + assertTrue(points.contains("/storage/A1B2-C3D4")) + assertTrue(points.contains("/storage/9C33-1A2B")) + assertTrue(points.contains("/mnt/media_rw/A1B2-C3D4")) + } + + @Test fun voldLocationsCountWhateverTheFstypeIs() { + val mounts = """ + /dev/block/sda1 /storage/0D19-CC0A fuse rw,nosuid,nodev 0 0 + /dev/block/sda1 /mnt/media_rw/0D19-CC0A exfat rw,dirsync 0 0 + """.trimIndent() + + val points = StorageRoots.parseExternalMounts(mounts).map { it.path } + + assertTrue(points.contains("/storage/0D19-CC0A")) + assertTrue(points.contains("/mnt/media_rw/0D19-CC0A")) + } + + @Test fun usbMountsDirectlyUnderMntAreFound() { + val mounts = """ + /dev/block/sda1 /mnt/usb_storage exfat rw,dirsync 0 0 + tmpfs /mnt/media_rw tmpfs rw,seclabel 0 0 + /dev/fuse /mnt/user/0/emulated fuse rw,nosuid,nodev 0 0 + """.trimIndent() + + val points = StorageRoots.parseExternalMounts(mounts).map { it.path } + + assertTrue(points.contains("/mnt/usb_storage")) + assertEquals(1, points.size) + } + + @Test fun emulatedAndSystemMountsAreIgnored() { + val mounts = """ + /dev/fuse /mnt/user/0/emulated fuse rw,nosuid,nodev 0 0 + /mnt/media_rw/emulated /storage/emulated sdcardfs rw,nosuid,nodev 0 0 + tmpfs /storage/self tmpfs rw,seclabel 0 0 + overlay /system ext4 ro,seclabel 0 0 + /dev/block/sda1 /data ext4 rw,seclabel 0 0 + """.trimIndent() + + assertEquals(emptyList<String>(), StorageRoots.parseExternalMounts(mounts).map { it.path }) + } + + @Test fun escapedSpacesAndNtfsAreHandled() { + val mounts = "/dev/block/vold/public:8,1 /mnt/media_rw/My\\040USB ntfs rw,dirsync 0 0\n" + + "/mnt/media_rw/My\\040USB /storage/My\\040USB fuse rw,nosuid,nodev 0 0\n" + + val points = StorageRoots.parseExternalMounts(mounts).map { it.path } + + assertTrue(points.contains("/mnt/media_rw/My USB")) + assertTrue(points.contains("/storage/My USB")) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/frontend/AddedGameArtFillTest.kt b/app/src/test/java/com/droiddeck/launcher/frontend/AddedGameArtFillTest.kt new file mode 100644 index 000000000..473c43922 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/frontend/AddedGameArtFillTest.kt @@ -0,0 +1,163 @@ +package com.droiddeck.launcher.frontend + +import com.droiddeck.launcher.frontend.AddedGameArt.Slot +import com.droiddeck.launcher.session.SessionPrefs +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.io.File + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class AddedGameArtFillTest { + private val app get() = RuntimeEnvironment.getApplication() + @get:Rule val tmp = TemporaryFolder() + + private val coverBytes = byteArrayOf(9, 9, 9) + + private fun game(name: String, appId: Long, cover: Boolean = true, source: String = Library.ADDED): AddedGames.Game { + val dir = tmp.newFolder(name) + File(dir, "steam_appid.txt").writeText("504230") + if (cover) File(dir, "cover.jpg").writeBytes(coverBytes) + val exe = File(dir, "$name.exe").apply { writeText("x") } + return AddedGames.Game(dir, name, exe, "/g/$name.exe", "/g", appId, appId, emptyList(), source = source) + } + + private class Calls { + val steam = ArrayList<Set<Slot>>() + val sgdb = ArrayList<Set<Slot>>() + var lookups = 0 + var store = 0 + } + + private val steamNames = mapOf(Slot.COVER to "p.jpg", Slot.WIDE to "header.jpg", Slot.BACKGROUND to "hero.jpg", Slot.LOGO to "logo.png") + + private fun sources( + calls: Calls, steamHas: Set<Slot> = emptySet(), sgdbHas: Set<Slot> = emptySet(), + storeHas: Boolean = false, sgdbFails: Boolean = false, + ) = AddedGameArt.Sources( + storeLogo = { g -> + calls.store++ + if (storeHas) File(g.folder, "logo.png").writeBytes(byteArrayOf(4)) + storeHas + }, + steamSearch = { fail("searched although the game names its appid"); null }, + steam = { id, cache, slots -> + assertEquals("504230", id) + calls.steam += slots + (slots intersect steamHas).onEach { File(cache, steamNames.getValue(it)).writeBytes(byteArrayOf(1)) }.isNotEmpty() + }, + sgdbGame = { _, steamId, _ -> + calls.lookups++ + assertEquals(504230, steamId) + 77 + }, + sgdb = { _, id, cache, slots -> + assertEquals(77, id) + if (sgdbFails) throw SteamGridDb.Failed(SteamGridDb.Status.OFFLINE) + calls.sgdb += slots + (slots intersect sgdbHas).onEach { File(cache, SteamGridDb.cacheName(it)).writeBytes(byteArrayOf(2)) }.isNotEmpty() + }, + ) + + @Test fun aGameWithItsOwnCoverStillGetsItsLogoAndIcon() { + val g = game("Celeste", 0x81000001L) + val calls = Calls() + assertTrue(AddedGameArt.fetchMissing(app, listOf(g), "key", sources(calls, steamHas = setOf(Slot.LOGO), sgdbHas = setOf(Slot.ICON)))) + assertEquals(listOf(setOf(Slot.LOGO)), calls.steam) + assertEquals(listOf(setOf(Slot.ICON)), calls.sgdb) + assertEquals(0, calls.store) + val cache = AddedGameArt.cacheDir(app, g) + assertFalse(File(cache, "p.jpg").exists()) + assertArrayEquals(coverBytes, File(g.folder, "cover.jpg").readBytes()) + val art = AddedGameArt.resolve(app, g) + assertEquals(File(g.folder, "cover.jpg"), art.portrait) + assertEquals(File(cache, "logo.png"), art.logo) + assertEquals(File(cache, "icon-sgdb.png"), art.icon) + assertFalse(File(cache, AddedGameArt.EXTRAS_TRIED).exists()) + } + + @Test fun filledOrChosenSlotsAreNeverAskedFor() { + val g = game("Hades", 0x81000002L) + File(g.folder, "logo.png").writeBytes(byteArrayOf(5)) + val picked = File(tmp.root, "mine.png").apply { writeBytes(byteArrayOf(6)) } + assertTrue(AddedGameArt.store(AddedGameArt.cacheDir(app, g), Slot.ICON, AddedGameArt.Option(AddedGameArt.FILE, picked.path, picked.path))) + val calls = Calls() + assertFalse(AddedGameArt.fetchMissing(app, listOf(g), "key", sources(calls, steamHas = Slot.values().toSet(), sgdbHas = Slot.values().toSet()))) + assertTrue(calls.steam.isEmpty() && calls.sgdb.isEmpty() && calls.lookups == 0) + assertArrayEquals(byteArrayOf(5), File(g.folder, "logo.png").readBytes()) + } + + @Test fun withoutAKeyAMissingIconAloneAsksNothing() { + val g = game("Inside", 0x81000003L) + File(g.folder, "logo.png").writeBytes(byteArrayOf(5)) + val calls = Calls() + assertFalse(AddedGameArt.fetchMissing(app, listOf(g), "", sources(calls))) + assertTrue(calls.steam.isEmpty() && calls.sgdb.isEmpty() && calls.lookups == 0) + assertFalse(AddedGameArt.cacheDir(app, g).exists()) + } + + @Test fun aMissIsAskedAgainOnlyAfterAWeek() { + val g = game("Limbo", 0x81000004L) + val calls = Calls() + assertFalse(AddedGameArt.fetchMissing(app, listOf(g), "key", sources(calls))) + assertEquals(listOf(setOf(Slot.LOGO)), calls.steam) + assertEquals(listOf(setOf(Slot.LOGO, Slot.ICON)), calls.sgdb) + AddedGameArt.fetchMissing(app, listOf(g), "key", sources(calls)) + assertEquals(1, calls.steam.size) + assertEquals(1, calls.sgdb.size) + val tried = File(AddedGameArt.cacheDir(app, g), AddedGameArt.EXTRAS_TRIED) + assertTrue(tried.setLastModified(System.currentTimeMillis() - 8L * 24 * 3600 * 1000)) + assertTrue(AddedGameArt.fetchMissing(app, listOf(g), "key", sources(calls, steamHas = setOf(Slot.LOGO), sgdbHas = setOf(Slot.ICON)))) + assertEquals(2, calls.steam.size) + assertEquals(setOf(Slot.ICON), calls.sgdb.last()) + assertFalse(tried.exists()) + } + + @Test fun theStoresOwnLogoComesFirst() { + val g = game("Metalstorm", 0x81000005L, source = "gog") + val calls = Calls() + assertTrue(AddedGameArt.fetchMissing(app, listOf(g), "key", sources(calls, storeHas = true, steamHas = setOf(Slot.LOGO), sgdbHas = setOf(Slot.ICON)))) + assertEquals(1, calls.store) + assertTrue(calls.steam.isEmpty()) + assertEquals(listOf(setOf(Slot.ICON)), calls.sgdb) + assertEquals(File(g.folder, "logo.png"), AddedGameArt.resolve(app, g).logo) + } + + @Test fun aGameWithoutACoverStillGetsEverySlot() { + val g = game("Tunic", 0x81000006L, cover = false) + val calls = Calls() + assertTrue(AddedGameArt.fetchMissing(app, listOf(g), "key", + sources(calls, steamHas = setOf(Slot.COVER, Slot.WIDE, Slot.BACKGROUND), sgdbHas = setOf(Slot.LOGO, Slot.ICON)))) + assertEquals(listOf(setOf(Slot.COVER, Slot.WIDE, Slot.BACKGROUND, Slot.LOGO)), calls.steam) + assertEquals(listOf(setOf(Slot.LOGO, Slot.ICON)), calls.sgdb) + assertTrue(AddedGameArt.missing(app, g).isEmpty()) + } + + @Test fun aSteamGridDbFailureIsNotRememberedAndStopsThePass() { + val first = game("Gris", 0x81000007L) + val second = game("Hollow", 0x81000008L) + val calls = Calls() + AddedGameArt.fetchMissing(app, listOf(first, second), "key", sources(calls, sgdbFails = true)) + assertEquals(2, calls.steam.size) + assertEquals(1, calls.lookups) + assertFalse(File(AddedGameArt.cacheDir(app, first), AddedGameArt.EXTRAS_TRIED).exists()) + assertFalse(File(AddedGameArt.cacheDir(app, second), AddedGameArt.EXTRAS_TRIED).exists()) + } + + @Test fun nothingIsFetchedWhenArtIsOff() { + SessionPrefs.setAddedGamesArt(app, false) + val calls = Calls() + assertFalse(AddedGameArt.fetchMissing(app, listOf(game("Off", 0x81000009L)), "key", sources(calls))) + assertTrue(calls.steam.isEmpty()) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/frontend/AddedGameArtLookupTest.kt b/app/src/test/java/com/droiddeck/launcher/frontend/AddedGameArtLookupTest.kt index 1f1e30c81..095cf9a5c 100644 --- a/app/src/test/java/com/droiddeck/launcher/frontend/AddedGameArtLookupTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/frontend/AddedGameArtLookupTest.kt @@ -1,5 +1,6 @@ package com.droiddeck.launcher.frontend +import com.droiddeck.launcher.BuildConfig import com.droiddeck.launcher.stores.CredentialCipher import org.json.JSONArray import org.json.JSONObject @@ -92,8 +93,9 @@ class AddedGameArtLookupTest { assertEquals("build", SteamGridDb.pick("", "build")) assertEquals("build", SteamGridDb.pick(" ", "build")) assertEquals("", SteamGridDb.pick("", "")) - assertEquals("", SteamGridDb.builtInKey()) - assertEquals("", SteamGridDb.key(app)) + val builtIn = SteamGridDb.builtInKey() + assertTrue(builtIn == BuildConfig.SGDB_API_KEY) + assertTrue(SteamGridDb.key(app) == builtIn.trim()) } @Test fun theUsersKeyIsSealedOnDiskAndClears() { @@ -106,7 +108,7 @@ class AddedGameArtLookupTest { assertEquals("abcdef0123456789secretkey", SteamGridDb.key(app)) SteamGridDb.setUserKey(app, "") assertFalse(file.exists()) - assertEquals("", SteamGridDb.key(app)) + assertTrue(SteamGridDb.key(app) == SteamGridDb.builtInKey().trim()) } @Test fun aFailedLookupIsNotRememberedAsNoMatch() { diff --git a/app/src/test/java/com/droiddeck/launcher/frontend/AddedGamesAccountTest.kt b/app/src/test/java/com/droiddeck/launcher/frontend/AddedGamesAccountTest.kt new file mode 100644 index 000000000..610f00bb4 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/frontend/AddedGamesAccountTest.kt @@ -0,0 +1,43 @@ +package com.droiddeck.launcher.frontend + +import java.io.File +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class AddedGamesAccountTest { + @get:Rule val tmp = TemporaryFolder() + + private fun steamRoot(vararg blocks: Pair<Long, Map<String, String>>): File = tmp.newFolder("Steam").also { root -> + File(root, "config").mkdirs() + File(root, "userdata/123/config").mkdirs() + File(root, "userdata/456/config").mkdirs() + val body = blocks.joinToString("") { (account, keys) -> + "\t\"${76561197960265728L + account}\"\n\t{\n" + keys.entries.joinToString("") { "\t\t\"${it.key}\"\t\t\"${it.value}\"\n" } + "\t}\n" + } + File(root, "config/loginusers.vdf").writeText("\"users\"\n{\n$body}\n") + } + + @Test fun theAccountMarkedMostRecentIsUsed() { + val root = steamRoot(123L to mapOf("MostRecent" to "1", "AutoLogin" to "0", "timestamp" to "100"), + 456L to mapOf("MostRecent" to "0", "AutoLogin" to "1", "timestamp" to "200")) + assertEquals(File(root, "userdata/123/config"), AddedGames.accountConfig(root)) + } + + @Test fun withoutMostRecentTheAccountThatSignsInIsUsed() { + val root = steamRoot(123L to mapOf("AutoLogin" to "1", "timestamp" to "100"), 456L to mapOf("AutoLogin" to "0", "timestamp" to "200")) + assertEquals(File(root, "userdata/123/config"), AddedGames.accountConfig(root)) + } + + @Test fun withoutEitherMarkTheAccountUsedLastIsUsed() { + val root = steamRoot(456L to mapOf("AutoLogin" to "0", "timestamp" to "100"), 123L to mapOf("AutoLogin" to "0", "timestamp" to "200")) + assertEquals(File(root, "userdata/123/config"), AddedGames.accountConfig(root)) + } + + @Test fun twoAccountsWithoutAnyMarkGiveNoAccount() { + val root = steamRoot(123L to mapOf("AccountName" to "a"), 456L to mapOf("AccountName" to "b")) + assertNull(AddedGames.accountConfig(root)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/frontend/GameLaunchLinkTest.kt b/app/src/test/java/com/droiddeck/launcher/frontend/GameLaunchLinkTest.kt index 9493a630b..1fbd8c491 100644 --- a/app/src/test/java/com/droiddeck/launcher/frontend/GameLaunchLinkTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/frontend/GameLaunchLinkTest.kt @@ -4,7 +4,7 @@ import android.content.Intent import android.net.Uri import android.app.Activity import com.droiddeck.launcher.LauncherIcon -import com.droiddeck.launcher.MainActivity +import com.droiddeck.launcher.FrontEndActivity import com.droiddeck.launcher.frontend.Library import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull @@ -54,7 +54,7 @@ class GameShortcutIntentTest { assertEquals(Intent.ACTION_VIEW, intent.action) assertEquals(Uri.parse("droiddeck://game/$id"), intent.data) - assertEquals(MainActivity::class.java.name, intent.component?.className) + assertEquals(FrontEndActivity::class.java.name, intent.component?.className) assertEquals(android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP or android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP, intent.flags) } @@ -69,8 +69,8 @@ class GameShortcutIntentTest { val launch = requireNotNull(shortcut.intent) assertEquals("game:8400", shortcut.id) assertEquals("Geometry Wars: Retro Evolved", shortcut.shortLabel.toString()) - assertEquals("com.droiddeck.launcher.LauncherDark", shortcut.activity?.className) - assertEquals(MainActivity::class.java.name, launch.component?.className) + assertEquals("com.droiddeck.launcher.MainActivity", shortcut.activity?.className) + assertEquals(FrontEndActivity::class.java.name, launch.component?.className) assertEquals(Intent.ACTION_VIEW, launch.action) assertEquals(Uri.parse("droiddeck://game/8400"), launch.data) assertEquals(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP, launch.flags) @@ -93,4 +93,38 @@ class GameShortcutIntentTest { assertNotNull(GameShortcuts.result(activity, game)) } + + @Test fun theDefaultLauncherEntryKeepsTheMainActivityName() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val launchers = activity.packageManager.queryIntentActivities( + Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER).setPackage(activity.packageName), 0) + + assertEquals(listOf("com.droiddeck.launcher.MainActivity"), launchers.map { it.activityInfo.name }) + assertEquals(android.content.ComponentName(activity, "com.droiddeck.launcher.MainActivity"), LauncherIcon.activity(activity)) + } + + @Test fun repairDropsTheDefaultIconLeftBesideAChosenLightIcon() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val pm = activity.packageManager + pm.setComponentEnabledSetting(android.content.ComponentName(activity, "com.droiddeck.launcher.LauncherLight"), + android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_ENABLED, android.content.pm.PackageManager.DONT_KILL_APP) + val dark = android.content.ComponentName(activity, "com.droiddeck.launcher.MainActivity") + + LauncherIcon.repair(activity, dark) + assertEquals(android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_DEFAULT, pm.getComponentEnabledSetting(dark)) + + LauncherIcon.repair(activity, android.content.ComponentName(activity, "com.droiddeck.launcher.LauncherLight")) + assertEquals(android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_DISABLED, pm.getComponentEnabledSetting(dark)) + assertEquals(LauncherIcon.LIGHT, LauncherIcon.current(activity)) + } + + @Test fun repairLeavesASingleIconAlone() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val dark = android.content.ComponentName(activity, "com.droiddeck.launcher.MainActivity") + + LauncherIcon.repair(activity, null) + + assertEquals(android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_DEFAULT, activity.packageManager.getComponentEnabledSetting(dark)) + assertEquals(LauncherIcon.DARK, LauncherIcon.current(activity)) + } } diff --git a/app/src/test/java/com/droiddeck/launcher/frontend/GameLogoTest.kt b/app/src/test/java/com/droiddeck/launcher/frontend/GameLogoTest.kt new file mode 100644 index 000000000..505249979 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/frontend/GameLogoTest.kt @@ -0,0 +1,102 @@ +package com.droiddeck.launcher.frontend + +import com.droiddeck.launcher.session.SessionPrefs +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.io.File + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class GameLogoTest { + private val app get() = RuntimeEnvironment.getApplication() + @get:Rule val tmp = TemporaryFolder() + + @Before fun noGamesFolders() { + AddedGamesPublisher.now = true + SessionPrefs.setGameStorage(app, SessionPrefs.GAME_STORAGE_OFF, "") + SessionPrefs.setAddedGamesDirs(app, emptyList()) + } + + private fun image(name: String) = File(tmp.root, name).apply { parentFile?.mkdirs(); writeBytes(ByteArray(8) { 1 }) } + + @Test fun steamsCachedLogoIsFoundInTheAppFolderOrAHashedOne() { + val cache = tmp.newFolder("librarycache") + val logo = File(cache, "620/3f1c/logo.png").apply { parentFile?.mkdirs(); writeBytes(ByteArray(4) { 1 }) } + assertEquals(logo, Library.steamCacheImage(cache, 620, Library.STEAM_LOGOS)) + File(cache, "730").mkdirs() + File(cache, "730/logo.png").writeBytes(ByteArray(0)) + assertNull(Library.steamCacheImage(cache, 730, Library.STEAM_LOGOS)) + } + + @Test fun aSteamScanKeepsEveryArtworkSlotAndSeesNewHashDirectories() { + val root = SessionPrefs.steamRoot(app, "arm64") + File(root, "steamapps/appmanifest_620.acf").apply { + parentFile!!.mkdirs() + writeText("\"AppState\"\n{\n\"name\" \"Portal 2\"\n\"StateFlags\" \"4\"\n}\n") + } + val cache = File(root, "appcache/librarycache/620") + fun cached(path: String) = File(cache, path).apply { + parentFile!!.mkdirs() + writeBytes(byteArrayOf(1)) + } + val cover = cached("library_capsule.jpg") + val hero = cached("hash/library_hero.jpg") + val icon = cached("${"a".repeat(40)}.jpg") + val game = Library.steamGames(app, arch = "arm64").single() + assertEquals(listOf(cover, hero, icon, null), listOf(game.art, game.hero, game.icon, game.logo)) + + val logo = cached("new-hash/logo.png") + val refreshed = Library.steamGames(app, arch = "arm64").single() + assertEquals(listOf(cover, hero, icon, logo), listOf(refreshed.art, refreshed.hero, refreshed.icon, refreshed.logo)) + } + + @Test fun aChosenLogoWinsOverSteamsAndKeepsTheRest() { + val stock = image("logo.png") + val game = Library.SteamGame(620, "Portal 2", null, "Steam library", logo = stock) + assertTrue(SteamGameArt.withOverrides(app, game) === game) + val picked = image("mine.png") + assertTrue(SteamGameArt.choose(app, game, AddedGameArt.Slot.LOGO, AddedGameArt.Option(AddedGameArt.FILE, picked.path, picked.path))) + val shown = SteamGameArt.withOverrides(app, game) + assertTrue(shown.logo!!.name.startsWith("chosen-logo")) + assertNull(shown.art) + SteamGameArt.reset(app, game, AddedGameArt.Slot.LOGO) + assertEquals(stock, SteamGameArt.withOverrides(app, game).logo) + } + + @Test fun anAddedGameShowsItsFolderLogoUnlessOneWasChosen() { + val dir = tmp.newFolder("Celeste") + val own = File(dir, "logo.png").apply { writeBytes(ByteArray(4) { 1 }) } + val exe = File(dir, "Celeste.exe").apply { writeText("x") } + val g = AddedGames.Game(dir, "Celeste", exe, "/g/Celeste.exe", "/g", 0x82000001L, 0x82000001L, emptyList(), source = "gog") + assertEquals(own, Library.addedGame(app, g, emptyList()).logo) + val picked = image("pick.png") + assertTrue(AddedGameArt.choose(app, g, AddedGameArt.Slot.LOGO, AddedGameArt.Option(AddedGameArt.FILE, picked.path, picked.path))) + assertTrue(Library.addedGame(app, g, emptyList()).logo!!.name.startsWith("chosen-logo")) + val bare = AddedGames.Game(tmp.newFolder("Bare"), "Bare", exe, "/g/Bare.exe", "/g", 0x82000002L, 0x82000002L, emptyList()) + assertNull(Library.addedGame(app, bare, emptyList()).logo) + } + + @Test fun theLibraryCacheKeepsTheLogoAndReadsOlderEntries() { + val logo = image("logo.png") + val game = Library.SteamGame(620, "Portal 2", null, "Steam library", logo = logo) + LibraryCache.save(app, listOf(game, Library.SteamGame(730, "CS2", null, "Steam library"))) + val back = LibraryCache.load(app) + assertEquals(logo, back[0].logo) + assertNull(back[1].logo) + assertEquals(logo, game.withArt(null, null, null).logo) + val file = File(app.filesDir, "library-cache.json") + file.writeText(JSONArray().put(JSONObject().put("appId", 1).put("name", "Old").put("library", "x").put("gameId", 1L)).toString()) + assertNull(LibraryCache.load(app).single().logo) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/frontend/LibraryArchTest.kt b/app/src/test/java/com/droiddeck/launcher/frontend/LibraryArchTest.kt index 8ecd411b0..0b05b0e84 100644 --- a/app/src/test/java/com/droiddeck/launcher/frontend/LibraryArchTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/frontend/LibraryArchTest.kt @@ -23,6 +23,20 @@ class LibraryArchTest { assertEquals(emptyList<String>(), Library.launchableGames(app, added = emptyList(), arch = "arm64").map { it.name }) } + @Test fun aGameTheOtherClientSharesIsListedFromItsFiles() { + SessionPrefs.setGameStorage(app, SessionPrefs.GAME_STORAGE_OFF, "") + val apps = File(SessionPrefs.steamRoot(app, "x86_64"), "steamapps").apply { mkdirs() } + File(apps, "common/Team Fortress 2").mkdirs() + val manifest = "\"AppState\"\n{\n\t\"appid\"\t\t\"440\"\n\t\"name\"\t\t\"Team Fortress 2\"\n\t\"StateFlags\"\t\t\"4\"\n\t\"installdir\"\t\t\"Team Fortress 2\"\n}\n" + File(apps, "appmanifest_440.acf").writeText(manifest) + val view = File(com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(app), "root/.droiddeck-x64/shared/x86-64/steamapps").apply { mkdirs() } + File(view, "appmanifest_440.acf").writeText(manifest) + val shared = Library.steamGames(app, arch = "arm64").single() + assertEquals("Team Fortress 2", shared.name) + assertEquals(File(apps, "common/Team Fortress 2"), shared.gameFiles) + assertEquals(listOf("Team Fortress 2"), Library.steamGames(app, arch = "x86_64").map { it.name }) + } + @Test fun anAddedGameSteamAlreadyListsFromItsFolderIsShownOnce() { SessionPrefs.setGameStorage(app, SessionPrefs.GAME_STORAGE_OFF, "") val folder = File(app.filesDir, "games/Example").apply { mkdirs() } diff --git a/app/src/test/java/com/droiddeck/launcher/frontend/LibraryShortcutsLastPlayedTest.kt b/app/src/test/java/com/droiddeck/launcher/frontend/LibraryShortcutsLastPlayedTest.kt new file mode 100644 index 000000000..cc9a1ac7d --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/frontend/LibraryShortcutsLastPlayedTest.kt @@ -0,0 +1,54 @@ +package com.droiddeck.launcher.frontend + +import java.io.ByteArrayOutputStream +import java.io.File +import java.nio.ByteBuffer +import java.nio.ByteOrder +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class LibraryShortcutsLastPlayedTest { + @get:Rule val tmp = TemporaryFolder() + + private fun steamRoot(): File = tmp.newFolder("Steam").also { root -> + File(root, "config").mkdirs() + File(root, "config/loginusers.vdf").writeText( + "\"users\"\n{\n\t\"76561198352563669\"\n\t{\n\t\t\"AccountName\"\t\t\"a\"\n\t\t\"MostRecent\"\t\t\"1\"\n\t}\n}\n") + } + + private class Vdf { + val out = ByteArrayOutputStream() + fun key(type: Int, k: String) { out.write(type); out.write(k.toByteArray()); out.write(0) } + fun map(k: String, body: Vdf.() -> Unit) { key(0, k); body(); out.write(8) } + fun str(k: String, v: String) { key(1, k); out.write(v.toByteArray()); out.write(0) } + fun int(k: String, v: Long) { key(2, k); out.write(ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putInt(v.toInt()).array()) } + } + + @Test fun readsEachShortcutsLastPlayTimeByUnsignedAppId() { + val root = steamRoot() + val vdf = Vdf().apply { + map("shortcuts") { + map("0") { int("appid", 3388195926L); str("AppName", "Rayman 2"); int("LastPlayTime", 1792931476L) } + map("1") { int("appid", 2228102537L); str("AppName", "Do Not Disturb"); int("LastPlayTime", 0L) } + map("2") { int("appid", 1234L); str("appname", "Old"); int("lastplaytime", 1700000000L) } + } + out.write(8) + } + File(root, "userdata/392297941/config").apply { mkdirs() }.resolve("shortcuts.vdf").writeBytes(vdf.out.toByteArray()) + assertEquals(mapOf(3388195926L to 1792931476L, 1234L to 1700000000L), Library.shortcutsLastPlayed(root)) + } + + @Test fun missingOrBrokenShortcutsGiveNothing() { + val root = steamRoot() + assertEquals(emptyMap<Long, Long>(), Library.shortcutsLastPlayed(root)) + File(root, "userdata/392297941/config").apply { mkdirs() }.resolve("shortcuts.vdf").writeBytes(byteArrayOf(0, 's'.code.toByte())) + assertEquals(emptyMap<Long, Long>(), Library.shortcutsLastPlayed(root)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/frontend/ModeSettingsLoadingTest.kt b/app/src/test/java/com/droiddeck/launcher/frontend/ModeSettingsLoadingTest.kt new file mode 100644 index 000000000..d9c0dd8f3 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/frontend/ModeSettingsLoadingTest.kt @@ -0,0 +1,72 @@ +package com.droiddeck.launcher.frontend + +import android.os.Looper +import com.droiddeck.launcher.FrontEndActivity +import com.droiddeck.launcher.runtime.LinuxRuntime +import com.droiddeck.launcher.session.SessionPrefs +import com.droiddeck.launcher.session.SessionService +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.LooperMode +import java.io.File + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28]) +@LooperMode(LooperMode.Mode.PAUSED) +class ModeSettingsLoadingTest { + @Test fun steamPublishesItsShaderChoiceThroughTheMainQueue() { + val activity = Robolectric.buildActivity(FrontEndActivity::class.java).get() + SessionPrefs.setShaderPrecache(activity, false) + load(activity, SessionService.MODE_STEAM) + awaitRead() + assertNull(value(activity)) + shadowOf(Looper.getMainLooper()).idle() + assertEquals(false, value(activity)) + } + + @Test fun desktopDoesNotConsumeSteamsAppliedShaderChoice() { + val activity = Robolectric.buildActivity(FrontEndActivity::class.java).get() + SessionPrefs.setShaderPrecache(activity, false) + val applied = File(LinuxRuntime.rootDir(activity), "root/.bl-shader-precache-applied") + applied.parentFile!!.mkdirs() + applied.writeText("0") + load(activity, SessionService.MODE_DESKTOP) + awaitRead() + shadowOf(Looper.getMainLooper()).idle() + assertTrue(applied.isFile) + assertNull(value(activity)) + } + + @Test fun switchingToDesktopDiscardsAQueuedSteamResult() { + val activity = Robolectric.buildActivity(FrontEndActivity::class.java).get() + load(activity, SessionService.MODE_STEAM) + awaitRead() + load(activity, SessionService.MODE_DESKTOP) + shadowOf(Looper.getMainLooper()).idle() + assertNull(value(activity)) + } + + private fun load(activity: FrontEndActivity, mode: String) { + FrontEndActivity::class.java.getDeclaredMethod("loadShaderPrecache", String::class.java) + .apply { isAccessible = true }.invoke(activity, mode) + } + + private fun value(activity: FrontEndActivity): Boolean? = + FrontEndActivity::class.java.getDeclaredMethod("getShaderPrecache") + .apply { isAccessible = true }.invoke(activity) as Boolean? + + private fun awaitRead() { + Thread.getAllStackTraces().keys.filter { it.name == "shader-precache" }.forEach { + it.join(5_000) + assertFalse("Shader settings read did not finish", it.isAlive) + } + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/frontend/SteamCacheImagesTest.kt b/app/src/test/java/com/droiddeck/launcher/frontend/SteamCacheImagesTest.kt new file mode 100644 index 000000000..c64a11fcc --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/frontend/SteamCacheImagesTest.kt @@ -0,0 +1,118 @@ +package com.droiddeck.launcher.frontend + +import java.io.File +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class SteamCacheImagesTest { + @get:Rule val tmp = TemporaryFolder() + + private fun image(path: String, bytes: ByteArray = byteArrayOf(1)): File = + File(tmp.root, path).apply { parentFile!!.mkdirs(); writeBytes(bytes) } + + private class CountingDirectory(directory: File) : File(directory.path) { + var listings = 0 + private set + + override fun listFiles(): Array<File>? { + listings++ + return super.listFiles()?.sortedByDescending { it.name }?.toTypedArray() + } + } + + @Test fun allArtworkAndTheHashIconShareOneDirectoryListing() { + val cover = image("620/library_capsule.jpg") + val hero = image("620/b/library_hero.jpg") + val logo = image("620/a/logo.png") + val icon = image("620/${"a".repeat(40)}.png") + val directory = CountingDirectory(File(tmp.root, "620")) + val images = Library.SteamCacheImages(directory) + + assertEquals(cover, images.find(listOf("library_capsule.jpg", "library_600x900.jpg"))) + assertEquals(hero, images.find(listOf("library_hero.jpg"))) + assertEquals(logo, images.find(Library.STEAM_LOGOS)) + assertEquals(icon, images.icon()) + assertEquals(1, directory.listings) + } + + @Test fun preferredFilenamesWinEvenWhenOnlyTheAlternativeIsInTheRoot() { + image("620/library_600x900.jpg") + val preferred = image("620/hash/library_capsule.jpg") + + assertEquals(preferred, Library.steamCacheImage( + tmp.root, 620, listOf("library_capsule.jpg", "library_600x900.jpg"), + )) + } + + @Test fun theRootWinsOverHashDirectoriesForTheSameFilename() { + image("620/hash/logo.png") + val root = image("620/logo.png") + + assertEquals(root, Library.steamCacheImage(tmp.root, 620, Library.STEAM_LOGOS)) + } + + @Test fun hashDirectoriesAreSearchedByNameNotListingOrder() { + image("620/z/logo.png") + val first = image("620/a/logo.png") + val directory = CountingDirectory(File(tmp.root, "620")) + + assertEquals(first, Library.SteamCacheImages(directory).find(Library.STEAM_LOGOS)) + assertEquals(1, directory.listings) + } + + @Test fun emptyFilesAndDirectoriesAreNotArtwork() { + image("620/logo.png", byteArrayOf()) + File(tmp.root, "620/a/logo.png").mkdirs() + val valid = image("620/b/logo.png") + + assertEquals(valid, Library.steamCacheImage(tmp.root, 620, Library.STEAM_LOGOS)) + } + + @Test fun namedIconsKeepTheirFilenamePriorityOverHashIcons() { + image("620/${"a".repeat(40)}.png") + image("620/icon.png") + val preferred = image("620/hash/icon.jpg") + + assertEquals(preferred, Library.SteamCacheImages(File(tmp.root, "620")).icon()) + } + + @Test fun hashIconsKeepTheOriginalRootListingOrderAndAllowEmptyFiles() { + image("620/${"0".repeat(40)}.jpg") + val first = image("620/${"F".repeat(40)}.png", byteArrayOf()) + File(tmp.root, "620/${"z".repeat(40)}.png").mkdirs() + image("620/${"g".repeat(40)}.png") + val directory = CountingDirectory(File(tmp.root, "620")) + val images = Library.SteamCacheImages(directory) + + assertEquals(first, images.icon()) + assertEquals(1, directory.listings) + } + + @Test fun hashIconsOutsideTheRootAndInvalidHashesAreIgnored() { + image("620/hash/${"a".repeat(40)}.jpg") + image("620/${"g".repeat(40)}.png") + image("620/${"a".repeat(39)}.png") + + assertNull(Library.SteamCacheImages(File(tmp.root, "620")).icon()) + } + + @Test fun missingDirectoriesAreListedOnlyOnce() { + val directory = CountingDirectory(File(tmp.root, "missing")) + val images = Library.SteamCacheImages(directory) + + assertNull(images.find(Library.STEAM_LOGOS)) + assertNull(images.icon()) + assertEquals(1, directory.listings) + } + + @Test fun aNewLookupSeesHashDirectoriesAddedAfterThePreviousScan() { + image("620/unrelated.txt") + assertNull(Library.steamCacheImage(tmp.root, 620, Library.STEAM_LOGOS)) + val added = image("620/new-hash/logo.png") + + assertEquals(added, Library.steamCacheImage(tmp.root, 620, Library.STEAM_LOGOS)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/frontend/SteamGridTest.kt b/app/src/test/java/com/droiddeck/launcher/frontend/SteamGridTest.kt index cc243e743..7d0618d03 100644 --- a/app/src/test/java/com/droiddeck/launcher/frontend/SteamGridTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/frontend/SteamGridTest.kt @@ -238,4 +238,17 @@ class SteamGridTest { assertEquals(names.sorted(), names) assertEquals(5L, AddedGameArt.chosen(dir, AddedGameArt.Slot.COVER)?.length()) } + + @Test fun aChoiceSurvivesTheGridFolderBeingWipedAndIsPlacedAgain() { + File(LinuxRuntime.rootDir(app), "root/.local/share/Steam/userdata/123/config").mkdirs() + val picked = image("mine.png", 12) + SteamGameArt.choose(app, game(), AddedGameArt.Slot.COVER, AddedGameArt.Option(AddedGameArt.FILE, picked.path, picked.path)) + assertEquals(12L, File(grid, "620p.png").length()) + + assertTrue(grid.deleteRecursively()) + grid.mkdirs() + assertFalse(SteamGrid.sync(app, listOf(target()))) + assertEquals(12L, SteamGameArt.chosenFile(app, 620, AddedGameArt.Slot.COVER)?.length()) + assertEquals(12L, File(grid, "620p.png").length()) + } } diff --git a/app/src/test/java/com/droiddeck/launcher/frontend/WinCompSourcesTest.kt b/app/src/test/java/com/droiddeck/launcher/frontend/WinCompSourcesTest.kt index 78ac65445..b6c98b00a 100644 --- a/app/src/test/java/com/droiddeck/launcher/frontend/WinCompSourcesTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/frontend/WinCompSourcesTest.kt @@ -56,6 +56,17 @@ class WinCompSourcesTest { assertEquals(emptyList<String>(), WinCompSources.epic("Installers/LauncherHelper.exe", "Launcher Helper")) } + @Test fun xboxGamesAlwaysGetVisualCppAndTheirDeclaredRuntimes() { + val folder = tmp.newFolder("xbox") + val vc = listOf("vcredist2022" to "VC14", "vcredist2022_arm64" to "VC14") + assertEquals(vc, WinCompSources.xbox(folder)) + File(folder, "MicrosoftGame.Config").writeText("""<Game><DesktopRegistration><DependencyList><KnownDependency Name="VC14"/></DependencyList></DesktopRegistration></Game>""") + assertEquals(vc, WinCompSources.xbox(folder)) + sidecar(folder, Store.XBOX, emptyMap()) + assertEquals(listOf("vcredist2022" to WinCompSources.Origin.XBOX, "vcredist2022_arm64" to WinCompSources.Origin.XBOX), + auto(WinCompSources.forGame(app, game(folder, Store.XBOX.id))).distinct()) + } + @Test fun steamworksSharedDepotsMapToComponents() { assertEquals(listOf("vcredist2015_dll" to "VC++ 2015", "d3dx9" to "DirectX (June 2010)"), WinCompSources.steamDepots(listOf("228986", "228990", "999"))) } diff --git a/app/src/test/java/com/droiddeck/launcher/frontend/WinComponentDetectorsTest.kt b/app/src/test/java/com/droiddeck/launcher/frontend/WinComponentDetectorsTest.kt index fbf077910..cdad5c09b 100644 --- a/app/src/test/java/com/droiddeck/launcher/frontend/WinComponentDetectorsTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/frontend/WinComponentDetectorsTest.kt @@ -42,6 +42,51 @@ class WinComponentDetectorsTest { assertEquals(emptyList<DependencyDetector.Recommendation>(), SteamRedists.detect(game, 1)) } + @Test fun skyrimGetsTheXactAudioItsInstallScriptRunsOnWindows() { + val steamapps = tmp.newFolder("steamapps") + val game = File(steamapps, "common/Skyrim Special Edition").apply { mkdirs() } + File(steamapps, "appmanifest_489830.acf").writeText("\"AppState\"\n{\n\t\"SharedDepots\"\n\t{\n\t\t\"228990\"\t\t\"228980\"\n\t}\n}\n") + val context = androidx.test.core.app.ApplicationProvider.getApplicationContext<android.content.Context>() + val found = WinCompSources.steamList(context, game, 489830, "Skyrim Special Edition", auto = true) + assertEquals(listOf("d3dx9", "xact_x64"), found.map { it.component }) + assertTrue(found.all { it.auto && it.origin == WinCompSources.Origin.STEAM }) + assertEquals(listOf("DirectX XACT audio (64-bit)", "Skyrim Special Edition", "489830"), found.last().args) + assertEquals(emptyList<String?>(), WinCompSources.steamList(context, game, 409710, "Other", auto = true).map { it.component }) + } + + @Test fun theGamesInstallScriptRedistsAreInstalledAsSteamWould() { + val steamapps = tmp.newFolder("steamapps") + val game = File(steamapps, "common/DiRT 3 Complete Edition").apply { mkdirs() } + File(steamapps, "appmanifest_321040.acf").writeText("\"AppState\"\n{\n\t\"SharedDepots\"\n\t{\n\t\t\"228983\"\t\t\"228980\"\n\t}\n}\n") + File(game, "installscript.vdf").writeText( + """ + "InstallScript" + { + "Registry" { "HKEY_LOCAL_MACHINE\\Software\\Codemasters" { "string" { "Path" "%INSTALLDIR%" } } } + "Run Process" + { + "OpenAL" + { + "HasRunKey" "HKEY_LOCAL_MACHINE\\Software\\Valve\\Steam\\Apps\\321040" + "process 1" "%INSTALLDIR%\\_CommonRedist\\OpenAL\\2.0.7.0\\oalinst.exe" + "command 1" "/s" + } + "VC2010" + { + "process 1" "%INSTALLDIR%\\_CommonRedist\\vcredist\\2010\\vcredist_x86.exe" + } + } + } + """.trimIndent(), + ) + assertEquals(listOf("oalinst", "vcredist2010"), SteamRedists.installScript(game).map { it.first }) + val context = androidx.test.core.app.ApplicationProvider.getApplicationContext<android.content.Context>() + val found = WinCompSources.steamList(context, game, 321040, "DiRT 3 Complete Edition", auto = true) + assertEquals(listOf("vcredist2010_dll", "oalinst"), found.map { it.component }) + assertTrue(found.all { it.auto && it.origin == WinCompSources.Origin.STEAM }) + assertEquals(emptyList<Pair<String, String>>(), SteamRedists.installScript(File(steamapps, "missing"))) + } + @Test fun thePrefixCountsOnlyWhatDroidDeckDidNotPutThere() { val compat = tmp.newFolder("compatdata", "409710") val pfx = File(compat, "pfx") diff --git a/app/src/test/java/com/droiddeck/launcher/glasses/GlassesUsbTest.kt b/app/src/test/java/com/droiddeck/launcher/glasses/GlassesUsbTest.kt index 225805dc6..14dcf26d6 100644 --- a/app/src/test/java/com/droiddeck/launcher/glasses/GlassesUsbTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/glasses/GlassesUsbTest.kt @@ -269,6 +269,17 @@ class GlassesUsbTest { assertTrue(Glasses.isGlassesDisplayName("Rokid Max")) } + @Test fun rokidMax2RequestsPermissionForItsOwnProductId() { + val max2 = device(0x2002, 0x04d2, "Rokid Max2") + manager.devices[max2.deviceName] = max2 + refresh(ask = true) + assertEquals("Rokid Max2", state.value.model) + assertTrue(state.value.controllable) + assertEquals(listOf(max2), manager.requests) + assertTrue(Glasses.isGlassesDisplayName("Rokid Max 2")) + assertEquals(null, Glasses.deviceModel(device(0x2002, 0x04d2, "Generic HID"))) + } + private fun device(pid: Int, vid: Int = VitureProtocol.VENDOR_ID, name: String = "VITURE device"): UsbDevice = ReflectionHelpers.callConstructor( UsbDevice::class.java, from(String::class.java, "/dev/bus/usb/$pid"), diff --git a/app/src/test/java/com/droiddeck/launcher/glasses/RokidProtocolTest.kt b/app/src/test/java/com/droiddeck/launcher/glasses/RokidProtocolTest.kt index c2cf38e33..538958801 100644 --- a/app/src/test/java/com/droiddeck/launcher/glasses/RokidProtocolTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/glasses/RokidProtocolTest.kt @@ -13,6 +13,10 @@ class RokidProtocolTest { @Test fun identityRequiresRokidProductString() { assertEquals("Rokid Max", RokidModel.model(0x04d2, 0x162f, "Rokid Max")?.name) assertEquals("Rokid Air", RokidModel.model(0x04d2, 0x162f, "Rokid Air")?.name) + assertEquals("Rokid Max2", RokidModel.model(0x04d2, 0x2002, "Rokid Max2")?.name) + assertNull(RokidModel.model(0x04d2, 0x2002, "USB Keyboard")) + assertNull(RokidModel.model(0x04d2, 0x2002, null)) + assertNull(RokidModel.model(0x1234, 0x2002, "Rokid Max2")) assertNull(RokidModel.model(0x04d2, 0x162f, "USB Keyboard")) assertNull(RokidModel.model(0x04d2, 0x162f, null)) assertNull(RokidModel.model(0x04d2, 0x1234, "Rokid Max")) @@ -40,6 +44,21 @@ class RokidProtocolTest { assertEquals(listOf(60), air.rates2d) } + @Test fun max2UsesMaxModesAndParsesItsCapturedCombinedReport() { + val max2 = RokidModel.model(0x04d2, 0x2002, "Rokid Max2")!! + assertEquals(RokidModel.MODE_2D_120, max2.mode(120, false, null)) + assertEquals(RokidModel.MODE_SBS_1080_60, max2.mode(60, true, GlassesResolution(1920, 1080))) + assertEquals(RokidModel.MODE_SBS_1200_90, max2.mode(90, true, null)) + val packet = hex("119ceac6a47b000000542389bf2f430f41de9f6fc000967a38666144bbd0d4413b66661bc2cdcc2c4066e6e142000000ea70a2a47b0000000000006464000000") + val sample = RokidProtocol.Parser().parse(packet)!! + assertEquals(0x7ba4c6ea9cL, sample.timeNs) + assertEquals(8.954f, sample.accel.y, 0.001f) + assertEquals(100, sample.brightness) + val pose = ImuFusion().update(sample.timeNs, sample.accel, sample.gyro) + assertNotNull(pose) + assertTrue(pose!!.all { it.isFinite() }) + } + @Test fun brightnessStepsRoundTripTheDevicePercent() { assertEquals(8, RokidModel.brightnessStep(80)) assertEquals(10, RokidModel.brightnessStep(100)) diff --git a/app/src/test/java/com/droiddeck/launcher/gpu/TpUbwcHintTest.kt b/app/src/test/java/com/droiddeck/launcher/gpu/TpUbwcHintTest.kt index fe5183cfa..3335a55aa 100644 --- a/app/src/test/java/com/droiddeck/launcher/gpu/TpUbwcHintTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/gpu/TpUbwcHintTest.kt @@ -3,13 +3,23 @@ package com.droiddeck.launcher.gpu import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test +import com.droiddeck.launcher.session.SessionPrefs class TpUbwcHintTest { @Test fun a740OnABlobV6xxTurnsTheHintOff() { assertEquals("enable_tp_ubwc_flag_hint=0", TpUbwcHint.features(740, "512.676.53")) + assertEquals("enable_tp_ubwc_flag_hint=0", TpUbwcHint.features(740, "512.676.75")) + assertEquals("enable_tp_ubwc_flag_hint=0", TpUbwcHint.features(740, "512.676")) + assertEquals("enable_tp_ubwc_flag_hint=0", TpUbwcHint.features(740, "512.699.10")) + } + + @Test fun a740OnThePocoBlobTurnsTheHintOn() { + assertEquals("enable_tp_ubwc_flag_hint=1", TpUbwcHint.features(740, "512.676.76")) + assertEquals("enable_tp_ubwc_flag_hint=1", TpUbwcHint.features(740, "512.676.80")) } @Test fun a740OnANewerBlobTurnsTheHintOn() { + assertEquals("enable_tp_ubwc_flag_hint=1", TpUbwcHint.features(740, "512.700.0")) assertEquals("enable_tp_ubwc_flag_hint=1", TpUbwcHint.features(740, "512.762.10")) } @@ -18,6 +28,24 @@ class TpUbwcHintTest { assertEquals("enable_tp_ubwc_flag_hint=1", TpUbwcHint.features(740, null, samsung = true)) } + @Test fun onOverridesThePocoRuleEvenWhenTheVersionIsUnreadable() { + for (version in listOf("512.676.1", "512.676.53", "garbage", null)) { + assertEquals("enable_tp_ubwc_flag_hint=1", TpUbwcHint.features(740, version, mode = SessionPrefs.TP_UBWC_ON)) + } + } + + @Test fun offOverridesSamsungAndNewerBlobs() { + assertEquals("enable_tp_ubwc_flag_hint=0", TpUbwcHint.features(740, "512.762.10", mode = SessionPrefs.TP_UBWC_OFF)) + assertEquals("enable_tp_ubwc_flag_hint=0", TpUbwcHint.features(740, null, samsung = true, mode = SessionPrefs.TP_UBWC_OFF)) + } + + @Test fun overridesDoNotApplyToOtherGpus() { + for (mode in listOf(SessionPrefs.TP_UBWC_ON, SessionPrefs.TP_UBWC_OFF)) { + assertNull(TpUbwcHint.features(750, "512.762.10", mode = mode)) + assertNull(TpUbwcHint.features(0, null, mode = mode)) + } + } + @Test fun otherGpusKeepTheirDriverDefault() { assertNull(TpUbwcHint.features(750, "512.762.10")) assertNull(TpUbwcHint.features(830, "512.676.53")) diff --git a/app/src/test/java/com/droiddeck/launcher/input/DirectTouchHoldTest.kt b/app/src/test/java/com/droiddeck/launcher/input/DirectTouchHoldTest.kt index d7dd5307a..513ee330f 100644 --- a/app/src/test/java/com/droiddeck/launcher/input/DirectTouchHoldTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/input/DirectTouchHoldTest.kt @@ -22,7 +22,7 @@ class DirectTouchHoldTest { } override fun onWheel(steps: Int) {} override fun onLongPress() {} - }) { events += "cancel touch" } + }) { events += "cancel touch" }.also { it.configure(TouchGestureConfig(longPressEnabled = true, longPressDelay = 500)) } @Test fun holdReleasesRawTouchBeforeClickAndConsumesTheLift() { assertFalse(touch(MotionEvent.ACTION_DOWN)) @@ -35,6 +35,25 @@ class DirectTouchHoldTest { hold.cancel() } + @Test fun stillHoldKeepsTheTouchWhenLongPressIsOff() { + hold.configure(TouchGestureConfig()) + assertFalse(touch(MotionEvent.ACTION_DOWN)) + advance(2000) + assertFalse(touch(MotionEvent.ACTION_MOVE)) + assertFalse(touch(MotionEvent.ACTION_UP)) + assertTrue(events.isEmpty()) + } + + @Test fun longPressFollowsTheConfiguredDelay() { + hold.configure(TouchGestureConfig(longPressEnabled = true, longPressDelay = 800)) + touch(MotionEvent.ACTION_DOWN) + advance(700) + assertTrue(events.isEmpty()) + advance(100) + assertEquals(listOf("cancel touch", "true at 40.0,30.0"), events) + hold.cancel() + } + @Test fun tapDragSecondFingerAndCancellationDoNotRightClick() { for (action in listOf(MotionEvent.ACTION_UP, MotionEvent.ACTION_MOVE, MotionEvent.ACTION_POINTER_DOWN, MotionEvent.ACTION_CANCEL)) { diff --git a/app/src/test/java/com/droiddeck/launcher/input/JoyConPadTest.kt b/app/src/test/java/com/droiddeck/launcher/input/JoyConPadTest.kt index 43f0a8d82..a32563213 100644 --- a/app/src/test/java/com/droiddeck/launcher/input/JoyConPadTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/input/JoyConPadTest.kt @@ -19,6 +19,7 @@ import org.robolectric.annotation.Config import org.robolectric.annotation.Implementation import org.robolectric.annotation.Implements import org.robolectric.shadows.InputDeviceBuilder +import org.robolectric.shadows.ShadowBuild @RunWith(RobolectricTestRunner::class) @Config(sdk = [34], manifest = Config.NONE, instrumentedPackages = ["com.droiddeck.launcher.input.FakeInputWriter"], @@ -82,6 +83,34 @@ class JoyConPadTest { assertEquals(0, RecordingWriter.grips) } + @Test fun novaControllerUsesCForTheLeftPaddleAndZForTheRight() { + ShadowBuild.setModel("Retroid Pocket Nova") + for (product in listOf(0x3001, 0x3002)) { + val pad = controller(0x2022, product) + assertPaddles(pad, KeyEvent.KEYCODE_BUTTON_C, KeyEvent.KEYCODE_BUTTON_Z) + bridge.onDeviceRemoved(pad.id) + } + } + + @Test fun theSameControllerIdsKeepTheDefaultPaddlesOnOtherHandhelds() { + for (product in listOf(0x3001, 0x3002)) { + val pad = controller(0x2022, product) + for (model in listOf("Retroid Pocket 6", "Retroid Pocket 5", "AYN Thor")) { + ShadowBuild.setModel(model) + assertPaddles(pad, KeyEvent.KEYCODE_BUTTON_Z, KeyEvent.KEYCODE_BUTTON_C) + } + bridge.onDeviceRemoved(pad.id) + } + } + + @Test fun otherControllerIdsKeepTheDefaultPaddlesOnANova() { + ShadowBuild.setModel("Retroid Pocket Nova") + for (pad in listOf(controller(0x045e, 0x0b13), controller(0x2022, 0x3003), controller(0x2020, 0x3001))) { + assertPaddles(pad, KeyEvent.KEYCODE_BUTTON_Z, KeyEvent.KEYCODE_BUTTON_C) + bridge.onDeviceRemoved(pad.id) + } + } + @Test fun whenOneJoyConLeavesTheOtherKeepsThePlayer() { pair() assertTrue(press(right, KeyEvent.KEYCODE_BUTTON_A, BTN_SOUTH)) @@ -140,6 +169,17 @@ class JoyConPadTest { private fun pad(slot: Int): PadState = RecordingWriter.pads[slot] ?: PadState() + private fun assertPaddles(device: InputDevice, leftCode: Int, rightCode: Int) { + assertTrue(bridge.onKeyEvent(key(device, leftCode, 0))) + assertEquals(DeckControls.L4, RecordingWriter.grips) + assertTrue(bridge.onKeyEvent(key(device, rightCode, 0))) + assertEquals(DeckControls.L4 or DeckControls.R4, RecordingWriter.grips) + assertTrue(bridge.onKeyEvent(key(device, leftCode, 0, KeyEvent.ACTION_UP))) + assertEquals(DeckControls.R4, RecordingWriter.grips) + assertTrue(bridge.onKeyEvent(key(device, rightCode, 0, KeyEvent.ACTION_UP))) + assertEquals(0, RecordingWriter.grips) + } + private fun press(device: InputDevice, code: Int, scanCode: Int): Boolean = bridge.onKeyEvent(JoyCon.normalize(key(device, code, scanCode))) @@ -162,10 +202,12 @@ class JoyConPadTest { private var nextId = 60 - private fun joyCon(product: Int, vararg axes: Int): InputDevice { + private fun joyCon(product: Int, vararg axes: Int): InputDevice = controller(NINTENDO, product, *axes) + + private fun controller(vendor: Int, product: Int, vararg axes: Int): InputDevice { val id = nextId++ val builder = InputDeviceBuilder.newBuilder().setId(id).setName("Joy-Con $id").setDescriptor("joycon-$id") - .setExternal(true).setVendorId(NINTENDO).setProductId(product) + .setExternal(true).setVendorId(vendor).setProductId(product) .setSources(InputDevice.SOURCE_GAMEPAD or InputDevice.SOURCE_JOYSTICK or InputDevice.SOURCE_KEYBOARD) .setKeyboardType(InputDevice.KEYBOARD_TYPE_NON_ALPHABETIC) .setKeyCharacterMap(KeyCharacterMap.load(KeyCharacterMap.VIRTUAL_KEYBOARD)) diff --git a/app/src/test/java/com/droiddeck/launcher/input/ScreenKeysTest.kt b/app/src/test/java/com/droiddeck/launcher/input/ScreenKeysTest.kt new file mode 100644 index 000000000..8a95dd512 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/input/ScreenKeysTest.kt @@ -0,0 +1,124 @@ +package com.droiddeck.launcher.input + +import android.view.InputDevice +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.View +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class ScreenKeysTest { + private val context = RuntimeEnvironment.getApplication() + + @Before fun clearKeys() { + ControllerPrefs.setScreenKeys(context, emptyList()) + } + + @Test fun missesAreForwardedAsTheirOwnTouchStreamWhileAKeyIsHeld() { + val sent = mutableListOf<Pair<Int, Boolean>>() + val keys = ScreenKeys(context) { code, down -> sent += code to down } + keys.layout(0, 0, 1000, 600) + keys.add(listOf(KeyEvent.KEYCODE_W)) + val forwarded = mutableListOf<Pair<Int, Int>>() + keys.onTouchMiss = { forwarded += it.actionMasked to it.getPointerId(0) } + + send(keys, MotionEvent.ACTION_DOWN, listOf(0), listOf(300f), listOf(372f)) + send(keys, MotionEvent.ACTION_POINTER_DOWN or (1 shl MotionEvent.ACTION_POINTER_INDEX_SHIFT), listOf(0, 1), listOf(300f, 900f), listOf(372f, 550f)) + send(keys, MotionEvent.ACTION_MOVE, listOf(0, 1), listOf(310f, 880f), listOf(372f, 530f)) + send(keys, MotionEvent.ACTION_POINTER_UP or (1 shl MotionEvent.ACTION_POINTER_INDEX_SHIFT), listOf(0, 1), listOf(310f, 880f), listOf(372f, 530f)) + send(keys, MotionEvent.ACTION_UP, listOf(0), listOf(310f), listOf(372f)) + + assertEquals( + listOf( + MotionEvent.ACTION_DOWN to 1, + MotionEvent.ACTION_MOVE to 1, + MotionEvent.ACTION_UP to 1, + ), + forwarded, + ) + assertEquals(listOf(KeyEvent.KEYCODE_W to true, KeyEvent.KEYCODE_W to false), sent) + } + + @Test fun gestureRecognizerKeepsOneSplitTargetOwnerUntilItsStreamEnds() { + val gate = GestureTouchStreamGate() + fun accepts(source: GestureTouchStreamGate.Source, action: Int): Boolean { + val event = MotionEvent.obtain(0L, 1L, action, 1f, 1f, 0) + return try { gate.accepts(source, event) } finally { event.recycle() } + } + + assertTrue(accepts(GestureTouchStreamGate.Source.SURFACE, MotionEvent.ACTION_DOWN)) + assertFalse(accepts(GestureTouchStreamGate.Source.SCREEN_KEYS, MotionEvent.ACTION_DOWN)) + assertTrue(accepts(GestureTouchStreamGate.Source.SURFACE, MotionEvent.ACTION_MOVE)) + assertTrue(accepts(GestureTouchStreamGate.Source.SURFACE, MotionEvent.ACTION_UP)) + assertTrue(accepts(GestureTouchStreamGate.Source.SCREEN_KEYS, MotionEvent.ACTION_DOWN)) + assertFalse(accepts(GestureTouchStreamGate.Source.SURFACE, MotionEvent.ACTION_DOWN)) + assertTrue(accepts(GestureTouchStreamGate.Source.SCREEN_KEYS, MotionEvent.ACTION_CANCEL)) + assertTrue(accepts(GestureTouchStreamGate.Source.SURFACE, MotionEvent.ACTION_DOWN)) + assertFalse(accepts(GestureTouchStreamGate.Source.SCREEN_KEYS, MotionEvent.ACTION_DOWN)) + gate.reset() + assertTrue(accepts(GestureTouchStreamGate.Source.SURFACE, MotionEvent.ACTION_DOWN)) + } + + @Test fun loadingLayoutReleasesLatchedKeysBeforeReplacingEntries() { + val sent = mutableListOf<Pair<Int, Boolean>>() + val keys = ScreenKeys(context) { code, down -> sent += code to down } + keys.layout(0, 0, 1000, 600) + keys.add(listOf(KeyEvent.KEYCODE_SHIFT_LEFT)) + keys.setToggle(listOf(KeyEvent.KEYCODE_SHIFT_LEFT), true) + send(keys, MotionEvent.ACTION_DOWN, listOf(0), listOf(300f), listOf(372f)) + send(keys, MotionEvent.ACTION_UP, listOf(0), listOf(300f), listOf(372f)) + + keys.load("") + + assertEquals(listOf(KeyEvent.KEYCODE_SHIFT_LEFT to true, KeyEvent.KEYCODE_SHIFT_LEFT to false), sent) + } + + @Test fun hidingTheViewReleasesLatchedKeys() { + val sent = mutableListOf<Pair<Int, Boolean>>() + val keys = ScreenKeys(context) { code, down -> sent += code to down } + keys.layout(0, 0, 1000, 600) + keys.add(listOf(KeyEvent.KEYCODE_SHIFT_LEFT)) + keys.setToggle(listOf(KeyEvent.KEYCODE_SHIFT_LEFT), true) + send(keys, MotionEvent.ACTION_DOWN, listOf(0), listOf(300f), listOf(372f)) + send(keys, MotionEvent.ACTION_UP, listOf(0), listOf(300f), listOf(372f)) + + keys.visibility = View.GONE + + assertEquals(listOf(KeyEvent.KEYCODE_SHIFT_LEFT to true, KeyEvent.KEYCODE_SHIFT_LEFT to false), sent) + } + + private fun send(view: View, action: Int, ids: List<Int>, xs: List<Float>, ys: List<Float>) { + val properties = Array(ids.size) { index -> + MotionEvent.PointerProperties().apply { + id = ids[index] + toolType = MotionEvent.TOOL_TYPE_FINGER + } + } + val coordinates = Array(ids.size) { index -> + MotionEvent.PointerCoords().apply { + x = xs[index] + y = ys[index] + pressure = 1f + size = 1f + } + } + val event = MotionEvent.obtain( + 0L, 1L, action, ids.size, properties, coordinates, 0, 0, + 1f, 1f, 0, 0, InputDevice.SOURCE_TOUCHSCREEN, 0, + ) + try { + view.onTouchEvent(event) + } finally { + event.recycle() + } + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/input/SessionGestureRoutingTest.kt b/app/src/test/java/com/droiddeck/launcher/input/SessionGestureRoutingTest.kt index 7b2a55e2e..7b33c7170 100644 --- a/app/src/test/java/com/droiddeck/launcher/input/SessionGestureRoutingTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/input/SessionGestureRoutingTest.kt @@ -172,7 +172,7 @@ class SessionGestureRoutingTest { val event = MotionEvent.obtain(now, now, MotionEvent.ACTION_DOWN, 400f, 300f, 0) try { assertTrue(activity.onTouchEvent(event)) } finally { event.recycle() } val gestureMode = mode == SessionPrefs.TOUCH_GESTURES - shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(if (gestureMode) 350 else 500)) + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(if (gestureMode) 350 else 320)) assertEquals(if (gestureMode) listOf(18 to true) else emptyList(), keys) assertEquals(if (gestureMode) emptyList() else listOf(PointerGestures.BTN_RIGHT to true), directButtons) ReflectionHelpers.callInstanceMethod<Unit>(activity, "onPause") @@ -220,7 +220,7 @@ class SessionGestureRoutingTest { override fun onButton(button: Int, pressed: Boolean, x: Float, y: Float) { directButtons += button to pressed } override fun onWheel(steps: Int) {} override fun onLongPress() {} - }) {} + }) {}.also { it.configure(config) } setField("directTouchHold", directHold) launcherScroll = ScrollTouchGestures(8f, 50f, object : ScrollTouchGestures.Listener { override fun onMove(x: Float, y: Float) { scrollPositions += x to y } diff --git a/app/src/test/java/com/droiddeck/launcher/runtime/BattleNetTest.kt b/app/src/test/java/com/droiddeck/launcher/runtime/BattleNetTest.kt new file mode 100644 index 000000000..d23a2eac2 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/runtime/BattleNetTest.kt @@ -0,0 +1,378 @@ +package com.droiddeck.launcher.runtime + +import android.content.Context +import com.droiddeck.launcher.session.ComponentsManager +import com.droiddeck.launcher.session.GameEnvironmentStore +import com.github.luben.zstd.ZstdOutputStream +import org.apache.commons.compress.archivers.tar.TarArchiveEntry +import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.io.File +import java.nio.file.Files +import java.util.zip.CRC32 + +private val REGISTRY = """ +WINE REGISTRY Version 2 +;; All keys relative to \\Machine + +[Software\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\Battle.net] 1760380000 +#time=1dc3c6e0a5e1f2a +"DisplayName"="Battle.net" +"InstallLocation"="C:\\Program Files (x86)\\Battle.net" +"UninstallString"="\"C:\\Program Files (x86)\\Battle.net\\Battle.net Uninstaller.exe\" --lang=enUS" + +[Software\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\Diablo IV] 1760380001 +"DisplayIcon"="C:\\Program Files (x86)\\Diablo IV\\Diablo IV Launcher.exe" +"DisplayName"="Diablo\xae IV" +"InstallLocation"="C:\\Program Files (x86)\\Diablo IV" +"Publisher"="Blizzard Entertainment" +"UninstallString"="\"C:\\ProgramData\\Battle.net\\Agent\\Blizzard Uninstaller.exe\" --lang=enUS --uid=fenris --displayname=\"Diablo IV\"" + +[Software\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\Overwatch] 1760380002 +"InstallLocation"="C:\\Program Files (x86)\\Overwatch" +"UninstallString"="\"C:\\ProgramData\\Battle.net\\Agent\\Blizzard Uninstaller.exe\" --lang=enUS --uid=prometheus --displayname=\"Overwatch\"" + +[Software\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\Steam] 1760380003 +"InstallLocation"="C:\\Program Files (x86)\\Steam" +"UninstallString"="C:\\Program Files (x86)\\Steam\\uninstall.exe --uid=steam" + +[Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\Diablo II Resurrected] 1760380004 +"UninstallString"=str(2):"\"C:\\ProgramData\\Battle.net\\Agent\\Blizzard Uninstaller.exe\" --lang=enUS --uid=osi --displayname=\"Diablo II: Resurrected\"" +"InstallLocation"="C:/Games/Diablo II Resurrected" + +[Software\\Wine\\Fonts] 1760380005 +"InstallLocation"="C:\\x" +"UninstallString"="Battle.net --uid=wow" +""".trimStart() + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class BattleNetTest { + private lateinit var context: Context + + @Before fun setUp() { + context = RuntimeEnvironment.getApplication() + File(context.filesDir, "components").deleteRecursively() + LinuxRuntime.rootDir(context).deleteRecursively() + } + + @Test fun theIdIsTheShortcutIdTheScriptNames() { + val crc = CRC32().apply { update("\"droiddeck-battlenet\"Battle.net".toByteArray()) }.value + assertEquals(crc or 0x80000000L, BattleNet.APP_ID) + } + + @Test fun theNewestD2rfixIsTheHighestFexThenTheHighestFix() { + val items = listOf( + item("FEXCore-2610-linux.wcp"), + item("FEXCore-2609-d2rfix25-linux.wcp"), + item("FEXCore-2610-d2rfix9-linux.wcp"), + item("FEXCore-2610-d2rfix28-linux.wcp"), + item("FEXCore-2509.1-d2rfix40-linux.wcp"), + ) + assertEquals("FEXCore-2610-d2rfix28-linux.wcp", BattleNet.newestD2rFix(items)?.file) + assertEquals("FEXCore-2610.1-d2rfix1-linux.wcp", BattleNet.newestD2rFix(items + item("FEXCore-2610.1-d2rfix1-linux.wcp"))?.file) + assertEquals("FexCore-2611-D2RFIX2-linux.wcp", BattleNet.newestD2rFix(items + item("FexCore-2611-D2RFIX2-linux.wcp"))?.file) + } + + @Test fun onlyFexD2rfixPackagesCount() { + assertNull(BattleNet.newestD2rFix(listOf(item("FEXCore-2610-linux.wcp"), item("FEXCore-2610-d2rfix28-linux.wcp", comp = "dxvk")))) + assertNull(BattleNet.d2rfix("FEXCore-2610-d2rfix28-linux.wcp.part")) + assertNull(BattleNet.d2rfix("FEXCore-2610-d2rfix-linux.wcp")) + assertEquals(listOf(2610L) to 28L, BattleNet.d2rfix("FEXCore-2610-d2rfix28-linux.wcp")) + } + + @Test fun theNewestStoredBuildIsSetForThePrefix() { + catalog("FEXCore-2609-d2rfix25-linux.wcp", "FEXCore-2610-d2rfix28-linux.wcp", "FEXCore-2610-linux.wcp") + store("FEXCore-2610-d2rfix28-linux.wcp") + + assertEquals("FEXCore-2610-d2rfix28-linux.wcp", BattleNet.ensureFex(context, ::noRefresh)) + + assertEquals(mapOf("fex" to "FEXCore-2610-d2rfix28-linux.wcp"), ComponentsManager.gameComponents(context, BattleNet.APP_ID.toString())) + assertEquals("FEXCore-2610-d2rfix28-linux.wcp", BattleNet.ensureFex(context, ::noRefresh)) + } + + @Test fun aNewerBuildReplacesTheOldOne() { + catalog("FEXCore-2609-d2rfix25-linux.wcp") + store("FEXCore-2609-d2rfix25-linux.wcp") + BattleNet.ensureFex(context, ::noRefresh) + catalog("FEXCore-2609-d2rfix25-linux.wcp", "FEXCore-2610-d2rfix28-linux.wcp") + store("FEXCore-2610-d2rfix28-linux.wcp") + + BattleNet.ensureFex(context, ::noRefresh) + + assertEquals("FEXCore-2610-d2rfix28-linux.wcp", ComponentsManager.gameComponents(context, BattleNet.APP_ID.toString())["fex"]) + } + + @Test fun aStaleListingIsFetchedAgain() { + catalog("FEXCore-2609-d2rfix25-linux.wcp", fetchedAt = 1) + store("FEXCore-2610-d2rfix28-linux.wcp") + + BattleNet.ensureFex(context) { listOf(item("FEXCore-2609-d2rfix25-linux.wcp"), item("FEXCore-2610-d2rfix28-linux.wcp")) } + + assertEquals("FEXCore-2610-d2rfix28-linux.wcp", ComponentsManager.gameComponents(context, BattleNet.APP_ID.toString())["fex"]) + } + + @Test fun aFailedFetchFallsBackOnTheCachedListing() { + catalog("FEXCore-2609-d2rfix25-linux.wcp", fetchedAt = 1) + store("FEXCore-2609-d2rfix25-linux.wcp") + + assertEquals("FEXCore-2609-d2rfix25-linux.wcp", BattleNet.ensureFex(context) { error("offline") }) + } + + @Test fun aListingWithoutD2rfixIsAnError() { + catalog("FEXCore-2610-linux.wcp") + var refreshed = 0 + assertThrows(IllegalStateException::class.java) { BattleNet.ensureFex(context) { refreshed++; emptyList() } } + assertEquals(1, refreshed) + assertEquals(emptyMap<String, String>(), ComponentsManager.gameComponents(context, BattleNet.APP_ID.toString())) + } + + @Test fun installedIsTheLauncherInThePrefix() { + assertFalse(BattleNet.installed(context)) + launcher().apply { parentFile!!.mkdirs(); writeText("MZ") } + assertTrue(BattleNet.installed(context)) + } + + @Test fun theEnvironmentEditorNamesBattleNetsProfileOnceItIsInstalled() { + assertNull(BattleNet.profile(context)) + launcher().apply { parentFile!!.mkdirs(); writeText("MZ") } + assertEquals(BattleNet.APP_ID.toString() to "Battle.net", BattleNet.profile(context)) + } + + @Test fun removeTakesThePrefixItsMenuEntryAndCacheButNothingItLinksTo() { + catalog("FEXCore-2610-d2rfix28-linux.wcp") + store("FEXCore-2610-d2rfix28-linux.wcp") + BattleNet.ensureFex(context, ::noRefresh) + val root = LinuxRuntime.rootDir(context) + fun put(relative: String) = File(root, relative).apply { parentFile!!.mkdirs(); writeText("x") } + launcher().apply { parentFile!!.mkdirs(); writeText("MZ") } + val outside = File(context.cacheDir, "bnet-outside").apply { mkdirs() } + val kept = File(outside, "kept").apply { writeText("x") } + val drives = File(BattleNet.prefix(context), "pfx/dosdevices").apply { mkdirs() } + Files.createSymbolicLink(File(drives, "z:").toPath(), outside.toPath()) + val menu = put("usr/local/share/applications/droiddeck-battlenet.desktop") + val otherMenu = put("usr/local/share/applications/other.desktop") + val log = put("root/.cache/droiddeck/battlenet/battlenet.log") + val otherPrefix = put("root/.local/share/Steam/steamapps/compatdata/123/pfx/system.reg") + + BattleNet.remove(context) + + assertFalse(BattleNet.prefix(context).exists()) + assertFalse(BattleNet.installed(context)) + assertTrue(kept.isFile) + assertFalse(menu.exists()) + assertTrue(otherMenu.isFile) + assertFalse(log.parentFile!!.exists()) + assertTrue(otherPrefix.isFile) + assertNull(BattleNet.fexInUse(context)) + outside.deleteRecursively() + } + + @Test fun anIdNamesItsTitleWithOrWithoutItsLocale() { + assertEquals("Pro", BattleNet.title("prometheus")?.code) + assertEquals("D3", BattleNet.title("diablo3_enus")?.code) + assertEquals("Fen", BattleNet.title("Fenris")?.code) + assertEquals("WTCG", BattleNet.title("hs_beta")?.code) + assertEquals("Diablo II: Resurrected", BattleNet.title("osi_dede")?.name) + assertEquals("WoWC", BattleNet.title("wow_classic")?.code) + assertEquals("WoWC", BattleNet.title("wow_classic_era_enus")?.code) + assertNull(BattleNet.title("agent")) + assertNull(BattleNet.title("osi_beta")) + } + + @Test fun theUninstallEntriesOfBattleNetsGamesAreRead() { + val entries = BattleNet.uninstallEntries(REGISTRY.lineSequence()) + assertEquals(listOf( + "fenris" to "C:\\Program Files (x86)\\Diablo IV", + "prometheus" to "C:\\Program Files (x86)\\Overwatch", + "osi" to "C:/Games/Diablo II Resurrected", + ), entries) + } + + @Test fun productDbsInstallsAreRead() { + val db = message( + field(1, message(field(1, "agent"), field(2, "agent"), field(3, message(field(1, "C:/ProgramData/Battle.net/Agent"))))), + varintField(7, 300), + field(1, message(field(1, "hs_beta"), field(2, "wtcg"), varintField(9, 1), field(3, message(field(1, "C:/Program Files (x86)/Hearthstone"), field(2, "US"))))), + field(1, message(field(1, "s2"))), + field(1, message(field(1, "s1"), field(3, message(field(1, "Z:/mnt/droiddeck-sd/Games/Battle.net/StarCraft"))), field(4, message(field(1, message(varintField(1, 0), varintField(2, 0))))))), + field(1, message(field(1, "wow_classic_anniversary"), field(3, message(field(1, "Z:/root/Games/World of Warcraft"))), field(4, message(field(1, message(varintField(1, 1), varintField(2, 1))))))), + ) + assertEquals(listOf("agent" to "C:/ProgramData/Battle.net/Agent", "hs_beta" to "C:/Program Files (x86)/Hearthstone", "wow_classic_anniversary" to "Z:/root/Games/World of Warcraft"), + BattleNet.productDbEntries(db)) + assertEquals(emptyList<Pair<String, String>>(), BattleNet.productDbEntries(db.copyOf(db.size - 3))) + assertEquals(emptyList<Pair<String, String>>(), BattleNet.productDbEntries(byteArrayOf(0x0a, 0x7f, 0x01))) + } + + @Test fun theGamesAreTheTitlesInstalledInTheirFolders() { + val pfx = File(BattleNet.prefix(context), "pfx").apply { mkdirs() } + File(pfx, "system.reg").writeText(REGISTRY) + File(pfx, "drive_c/ProgramData/Battle.net/Agent").mkdirs() + File(pfx, "drive_c/ProgramData/Battle.net/Agent/product.db").writeBytes(message( + field(1, message(field(1, "hs_beta"), field(3, message(field(1, "C:/Program Files (x86)/Hearthstone"))))), + field(1, message(field(1, "fenris"), field(3, message(field(1, "C:/Program Files (x86)/Diablo IV"))))), + field(1, message(field(1, "wow_classic"), field(3, message(field(1, "C:/Program Files (x86)/World of Warcraft"))))), + )) + for (folder in listOf("Program Files (x86)/Diablo IV", "Games/Diablo II Resurrected", "Program Files (x86)/Hearthstone", "Program Files (x86)/World of Warcraft")) { + File(pfx, "drive_c/$folder").mkdirs() + } + val games = BattleNet.games(context) + assertEquals(listOf("Fen", "OSI", "WTCG", "WoWC"), games.map { it.code }) + assertEquals(listOf("Diablo IV", "Diablo II: Resurrected", "Hearthstone", "World of Warcraft Classic"), games.map { it.name }) + assertEquals(File(pfx, "drive_c/Games/Diablo II Resurrected"), games[1].folder) + assertNull(BattleNet.hostFolder(context, "D:\\Games\\X")) + assertNull(BattleNet.hostFolder(context, "C:\\..\\..\\etc")) + assertNull(BattleNet.hostFolder(context, "Z:\\root\\..\\..\\etc")) + assertEquals(File(LinuxRuntime.rootDir(context), "root/Games/Stores/Battle.net/Hearthstone"), + BattleNet.hostFolder(context, "Z:/root/Games/Stores/Battle.net/Hearthstone/")) + } + + @Test fun aGamesShortcutHasBattleNetLaunchItInItsPrefix() { + val folder = File(BattleNet.prefix(context), "pfx/drive_c/Program Files (x86)/Diablo IV").apply { mkdirs() } + File(folder, "Diablo IV Launcher.exe").writeBytes(byteArrayOf(0x4d, 0x5a)) + val shortcut = BattleNet.shortcut(context, BattleNet.Game("fenris", "Diablo IV", "Fen", folder))!! + val exe = "/root/.local/share/Steam/steamapps/compatdata/2596113170/pfx/drive_c/Program Files (x86)/Battle.net/Battle.net.exe" + assertEquals(exe, shortcut.guestExe) + assertEquals(exe.substringBeforeLast('/'), shortcut.guestDir) + assertEquals("STEAM_COMPAT_DATA_PATH=/root/.local/share/Steam/steamapps/compatdata/2596113170 %command% --exec=\"launch Fen\"", shortcut.launchOptions) + val crc = CRC32().apply { update("\"$exe\"Diablo IV".toByteArray()) }.value + assertEquals(crc or 0x80000000L, shortcut.appId) + assertEquals((shortcut.appId shl 32) or 0x02000000L, shortcut.gameId) + assertEquals(com.droiddeck.launcher.stores.Store.BATTLENET.id, shortcut.source) + assertEquals("Diablo IV Launcher.exe", shortcut.exe.name) + val listing = com.droiddeck.launcher.frontend.AddedGames.writeListing(context, listOf(shortcut)).readText() + val entry = JSONArray(listing).getJSONObject(0) + assertEquals(com.droiddeck.launcher.stores.Store.BATTLENET.id, entry.getString("store")) + assertEquals(shortcut.launchOptions, entry.getString("launch")) + assertEquals(exe, entry.getString("exe")) + } + + @Test fun titlesSharingALaunchCodeShareTheirArt() { + val cover = File(context.filesDir, "battlenet-art/fenris/p.jpg").apply { parentFile!!.mkdirs(); writeBytes(byteArrayOf(1)) } + assertEquals(cover, BattleNet.art(context, BattleNet.title("fen")!!).cover) + val wide = File(context.filesDir, "battlenet-art/wow_classic/blizzard.jpg").apply { parentFile!!.mkdirs(); writeBytes(byteArrayOf(1)) } + assertEquals(wide, BattleNet.art(context, BattleNet.title("wow_classic_anniversary")!!).header) + } + + @Test fun theStoreOffersEachTitleOnceAndHidesWhatIsInstalled() { + val codes = BattleNet.STORE.map { it.code } + assertEquals(codes.distinct(), codes) + assertTrue("Fen" in codes && "OSI" in codes && "WTCG" in codes) + assertFalse(BattleNet.STORE.any { it.uid == "d3cn" || it.uid == "fen" }) + assertEquals("fenris", BattleNet.STORE.first { it.code == "Fen" }.uid) + assertTrue(BattleNet.STORE.all { it.uid.matches(Regex("[a-z0-9_]{1,32}")) }) + val installed = listOf("fenris", "diablo3_enus", null) + val offered = BattleNet.STORE.filter { BattleNet.notInstalled(it, installed) }.map { it.code } + assertFalse("Fen" in offered || "D3" in offered) + assertTrue("OSI" in offered) + } + + @Test fun aBattleNetGameStartsWithoutSteamThroughItsShortcutUntilThatIsSwitchedOff() { + File(context.filesDir, "game-environment.json").delete() + val folder = File(BattleNet.prefix(context), "pfx/drive_c/Program Files (x86)/Diablo IV").apply { mkdirs() } + File(folder, "Diablo IV Launcher.exe").writeBytes(byteArrayOf(0x4d, 0x5a)) + val shortcut = BattleNet.shortcut(context, BattleNet.Game("fenris", "Diablo IV", "Fen", folder))!! + val game = com.droiddeck.launcher.frontend.Library.SteamGame( + shortcut.appId.toInt(), shortcut.name, null, com.droiddeck.launcher.frontend.Library.ADDED, shortcut.gameId, + source = com.droiddeck.launcher.stores.Store.BATTLENET.id, storeId = shortcut.storeId) + val noSteam = com.droiddeck.launcher.session.NoSteamLaunch + assertEquals(BattleNet.APP_ID.toString(), noSteam.scope(game)) + assertFalse(noSteam.enabled(context, game)) + assertTrue(BattleNet.ensureNoSteam(context)) + assertTrue(noSteam.enabled(context, game)) + val command = noSteam.command(game, shortcut)!! + assertEquals(noSteam.BATTLENET, command.program) + assertEquals(listOf("launch", "Fen"), command.args) + GameEnvironmentStore.setGameNoSteam(context, BattleNet.APP_ID.toString(), false) + assertFalse(BattleNet.ensureNoSteam(context)) + assertFalse(noSteam.enabled(context, game)) + } + + @Test fun theGamesAreToldOfAFifthOfThePhonesMemory() { + assertEquals(2048, BattleNet.videoMemory(11113)) + assertEquals(1536, BattleNet.videoMemory(7468)) + assertEquals(2560, BattleNet.videoMemory(15200)) + assertEquals(3584, BattleNet.videoMemory(60000)) + assertTrue(BattleNet.videoMemory(Long.MAX_VALUE / 2) < 4096) + assertEquals( + "dxgi.maxDeviceMemory = 2048; dxgi.maxSharedMemory = 2048; d3d9.maxAvailableMemory = 2048", + BattleNet.videoMemoryOptions(2048), + ) + } + + @Test fun theCapIsTheBattleNetProfilesOwnDxvkConfigUnlessItHasOne() { + File(context.filesDir, "game-environment.json").delete() + val manager = context.getSystemService(android.app.ActivityManager::class.java) + org.robolectric.Shadows.shadowOf(manager).setMemoryInfo(android.app.ActivityManager.MemoryInfo().apply { totalMem = 11113L shl 20 }) + val scope = BattleNet.APP_ID.toString() + assertTrue(BattleNet.ensureVideoMemory(context)) + assertEquals(BattleNet.videoMemoryOptions(2048), GameEnvironmentStore.read(context).games[scope]?.get("DXVK_CONFIG")) + val published = File(LinuxRuntime.rootDir(context), "root/.config/droiddeck/game-environment.json").readText() + assertTrue("dxgi.maxDeviceMemory = 2048" in JSONObject(published).getJSONObject("games").getJSONObject(scope).getString("DXVK_CONFIG")) + assertFalse(BattleNet.ensureVideoMemory(context)) + for (own in listOf("dxvk.maxFrameRate = 30", null)) { + GameEnvironmentStore.save(context, GameEnvironmentStore.read(context).let { it.withEntries(scope, it.entries(scope) + ("DXVK_CONFIG" to own)) }) + assertFalse(BattleNet.ensureVideoMemory(context)) + assertEquals(own, GameEnvironmentStore.read(context).games[scope]?.get("DXVK_CONFIG")) + } + GameEnvironmentStore.save(context, GameEnvironmentStore.read(context).let { it.withEntries(scope, it.entries(scope) - "DXVK_CONFIG") }) + assertTrue(BattleNet.ensureVideoMemory(context)) + } + + private fun launcher() = File(BattleNet.prefix(context), "pfx/drive_c/Program Files (x86)/Battle.net/Battle.net Launcher.exe") + + private fun varint(value: Long): ByteArray { + val out = java.io.ByteArrayOutputStream() + var v = value + while (v >= 0x80) { out.write(((v and 0x7f) or 0x80).toInt()); v = v ushr 7 } + out.write(v.toInt()) + return out.toByteArray() + } + + private fun field(number: Int, bytes: ByteArray) = varint((number shl 3 or 2).toLong()) + varint(bytes.size.toLong()) + bytes + private fun field(number: Int, text: String) = field(number, text.toByteArray()) + private fun varintField(number: Int, value: Long) = varint((number shl 3).toLong()) + varint(value) + private fun message(vararg fields: ByteArray) = fields.fold(ByteArray(0)) { all, f -> all + f } + + private fun noRefresh(context: Context): List<ComponentsManager.CatalogItem> = error("the cached listing was fresh") + + private fun item(file: String, comp: String = "fex") = ComponentsManager.CatalogItem( + file, comp, "FexCore-Linux", "https://github.com/The412Banner/Nightlies/releases/download/FexCore-Linux/$file", 4, "sha256:" + "ab".repeat(32), + ) + + private fun catalog(vararg files: String, fetchedAt: Long = System.currentTimeMillis() / 1000) { + val cache = File(context.filesDir, "components/catalog.json") + cache.parentFile!!.mkdirs() + val items = JSONArray() + for (file in files) items.put(JSONObject().put("file", file).put("comp", "fex").put("release", "FexCore-Linux") + .put("url", item(file).url).put("size", 4).put("digest", item(file).digest)) + cache.writeText(JSONObject().put("items", items).put("fetchedAt", fetchedAt).toString()) + } + + private fun store(file: String) { + val wcp = File(context.filesDir, "components/packages/$file") + wcp.parentFile!!.mkdirs() + TarArchiveOutputStream(ZstdOutputStream(wcp.outputStream())).use { tar -> + fun put(name: String, bytes: ByteArray) { + tar.putArchiveEntry(TarArchiveEntry(name).apply { size = bytes.size.toLong() }) + tar.write(bytes) + tar.closeArchiveEntry() + } + put("profile.json", JSONObject().put("type", "FEXCore-Linux").put("versionName", file.removeSuffix(".wcp")).toString().toByteArray()) + put("files/lib/wine/aarch64-windows/libarm64ecfex.dll", "arm64ec $file".toByteArray()) + put("files/lib/wine/aarch64-windows/libwow64fex.dll", "wow64 $file".toByteArray()) + } + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/runtime/BwrapSpawnerTest.kt b/app/src/test/java/com/droiddeck/launcher/runtime/BwrapSpawnerTest.kt index 2cb5e5edd..dfb328651 100644 --- a/app/src/test/java/com/droiddeck/launcher/runtime/BwrapSpawnerTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/runtime/BwrapSpawnerTest.kt @@ -119,6 +119,21 @@ class BwrapSpawnerPlanTest { assertEquals("org.example.Game", plan.env["FLATPAK_ID"]) } + @Test fun sandboxedAppsSeeTheSessionsNetworkLink() { + val context = RuntimeEnvironment.getApplication() + val rootfs = LinuxRuntime.rootDir(context) + val lib = File(rootfs, "usr/local/lib/libblsession.so").apply { parentFile?.mkdirs(); writeText("") } + val preload = File(rootfs, "etc/ld.so.preload").apply { parentFile?.mkdirs(); writeText("/usr/local/lib/libblsession.so\n") } + val link = File(rootfs, "etc/droiddeck-net").apply { writeText("if wlan0 2 1500\n") } + try { + val plan = BwrapSpawner.plan(context, request(op("t" to "dir", "dest" to "/tmp")), dir) + assertEquals("/run/droiddeck-preload/libblsession.so", plan.env["LD_PRELOAD"]) + assertEquals(link.path, plan.binds.single { it.guest == "/etc/droiddeck-net" }.host) + } finally { + listOf(lib, preload, link).forEach { it.delete() } + } + } + @Test fun zypakLaunchesTheBrowserWithoutItsSandbox() { val context = RuntimeEnvironment.getApplication() val app = File(runtime, "app").apply { File(this, "bin").mkdirs() } diff --git a/app/src/test/java/com/droiddeck/launcher/runtime/RuntimeInstallRollbackTest.kt b/app/src/test/java/com/droiddeck/launcher/runtime/RuntimeInstallRollbackTest.kt new file mode 100644 index 000000000..45b8019bb --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/runtime/RuntimeInstallRollbackTest.kt @@ -0,0 +1,140 @@ +package com.droiddeck.launcher.runtime + +import android.app.Application +import android.content.Context +import android.content.ContextWrapper +import com.droiddeck.launcher.BuildConfig +import com.droiddeck.launcher.core.AddonPack +import java.io.ByteArrayOutputStream +import java.io.File +import java.net.ServerSocket +import java.security.MessageDigest +import java.util.concurrent.atomic.AtomicBoolean +import java.util.function.Predicate +import org.apache.commons.compress.archivers.tar.TarArchiveEntry +import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream +import org.apache.commons.compress.compressors.zstandard.ZstdCompressorOutputStream +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], application = Application::class) +class RuntimeInstallRollbackTest { + @get:Rule val tmp = TemporaryFolder() + @After fun resetFaults() { RuntimeFileTree.refuseForTest = null } + + private fun put(root: File, rel: String, data: String) = File(root, rel).apply { + checkNotNull(parentFile).mkdirs() + writeText(data) + } + + private data class Attempt(val root: File, val staging: File, val old: File, val result: Boolean) + + private fun attempt(failRollback: Boolean): Attempt { + val files = tmp.newFolder("files") + val cache = tmp.newFolder("cache") + val context = object : ContextWrapper(RuntimeEnvironment.getApplication()) { + override fun getFilesDir(): File = files + override fun getCacheDir(): File = cache + override fun getApplicationContext(): Context = this + } + for ((pack, pin, required) in listOf( + Triple(AddonPack.PROOT, BuildConfig.PACK_PROOT_SHA256, listOf("libproot.so", "libproot-loader.so")), + Triple(AddonPack.DIRECTAUDIO, BuildConfig.PACK_DIRECTAUDIO_SHA256, + listOf("linux-wine11/version.txt", "linux-wine11-systhread/version.txt")), + )) { + val version = pin.ifEmpty { "0".repeat(64) } + val directory = File(files, "addons/${pack.name}/$version") + required.forEach { put(directory, it, "test pack") } + put(directory, AddonPack.COMPLETE, version) + } + val root = LinuxRuntime.rootDir(context) + val staging = File(files, LinuxRuntime.DIR + ".new") + val old = File(files, LinuxRuntime.DIR + ".old") + put(root, "root/save.dat", "original user data") + put(root, "opt/appimages/user/game/app.AppImage", "original app") + put(root, "usr/bin/usable", "previous runtime") + + val payload = ByteArrayOutputStream().also { bytes -> + ZstdCompressorOutputStream(bytes).use { zstd -> + TarArchiveOutputStream(zstd).use { tar -> + for ((name, value) in listOf("root/.bashrc" to "shipped home", "usr/bin/new" to "new runtime", + "opt/appimages/user/block" to "shipped app")) { + val data = value.toByteArray() + tar.putArchiveEntry(TarArchiveEntry(name).apply { size = data.size.toLong() }) + tar.write(data) + tar.closeArchiveEntry() + } + } + } + }.toByteArray() + val digest = MessageDigest.getInstance("SHA-256").digest(payload).joinToString("") { "%02x".format(it) } + val faultSeen = AtomicBoolean() + RuntimeFileTree.refuseForTest = Predicate { path -> + if (path == File(staging, "opt/appimages/user/block").toPath() && faultSeen.compareAndSet(false, true)) { + if (failRollback) put(old, "root/rollback-block", "concurrent data") + true + } else failRollback && path == File(old, "root/rollback-block").toPath() + } + val server = ServerSocket(0) + val thread = Thread { + runCatching { + server.accept().use { socket -> + socket.soTimeout = 5000 + val input = socket.getInputStream().bufferedReader(Charsets.ISO_8859_1) + while (true) { + val line = input.readLine() ?: break + if (line.isEmpty()) break + } + val out = socket.getOutputStream() + out.write(("HTTP/1.1 200 OK\r\nContent-Length: ${payload.size}\r\nConnection: close\r\n\r\n").toByteArray()) + out.write(payload) + out.flush() + } + } + }.apply { isDaemon = true; start() } + try { + val release = LinuxRuntimeInstaller.Release("synthetic", "http://127.0.0.1:${server.localPort}/runtime.tar.zst", digest, payload.size.toLong()) + val result = LinuxRuntimeInstaller.install(context, release, null) + assertTrue("the intended carry fault was not reached", faultSeen.get()) + return Attempt(root, staging, old, result) + } finally { + server.close() + thread.join(1000) + RuntimeFileTree.refuseForTest = null + } + } + + @Test fun aFailedCarryAndFailedRollbackKeepTheOnlyStagedCopy() { + val attempt = attempt(failRollback = true) + assertFalse(attempt.result) + assertTrue("the only staged copy was deleted", File(attempt.staging, "root/save.dat").isFile) + assertEquals("original user data", File(attempt.staging, "root/save.dat").readText()) + assertEquals("original app", File(attempt.root, "opt/appimages/user/game/app.AppImage").readText()) + assertEquals("previous runtime", File(attempt.root, "usr/bin/usable").readText()) + repeat(2) { + assertThrows(java.io.IOException::class.java) { + LinuxRuntimeInstaller.recoverInterruptedSwap(attempt.root, attempt.staging, attempt.old) + } + assertEquals("original user data", File(attempt.staging, "root/save.dat").readText()) + } + } + + @Test fun aCompleteRollbackStillRestoresAndCleansTheFailedCandidate() { + val attempt = attempt(failRollback = false) + assertFalse(attempt.result) + assertEquals("original user data", File(attempt.root, "root/save.dat").readText()) + assertEquals("original app", File(attempt.root, "opt/appimages/user/game/app.AppImage").readText()) + assertFalse(attempt.staging.exists()) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/runtime/RuntimeRemovalTest.kt b/app/src/test/java/com/droiddeck/launcher/runtime/RuntimeRemovalTest.kt index 318e6b980..83d43250f 100644 --- a/app/src/test/java/com/droiddeck/launcher/runtime/RuntimeRemovalTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/runtime/RuntimeRemovalTest.kt @@ -69,6 +69,7 @@ class RuntimeRemovalTest { val old = File(dir, "linuxfs.old").apply { File(this, "root").mkdirs() } val staging = File(dir, "linuxfs.new").apply { File(this, "root").mkdirs() } File(staging, "root/save").writeText("user data") + File(staging, LinuxRuntimeInstaller.CARRY_MARKER).writeText("1") for (tree in listOf(old, staging)) Files.setPosixFilePermissions(tree.toPath(), PosixFilePermissions.fromString("r-xr-xr-x")) LinuxRuntimeInstaller.recoverInterruptedSwap(root, staging, old) diff --git a/app/src/test/java/com/droiddeck/launcher/runtime/RuntimeUserDataCarryTest.kt b/app/src/test/java/com/droiddeck/launcher/runtime/RuntimeUserDataCarryTest.kt new file mode 100644 index 000000000..d977ffb8c --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/runtime/RuntimeUserDataCarryTest.kt @@ -0,0 +1,243 @@ +package com.droiddeck.launcher.runtime + +import java.io.File +import java.io.IOException +import java.nio.file.Files +import org.junit.After +import org.junit.Assume.assumeTrue +import org.junit.Assert.assertThrows +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class RuntimeUserDataCarryTest { + @get:Rule val tmp = TemporaryFolder() + + @After fun resetFaults() { RuntimeFileTree.refuseForTest = null } + + private fun put(root: File, rel: String, text: String = rel) = File(root, rel).apply { parentFile.mkdirs(); writeText(text) } + + private fun userTree(): File = tmp.newFolder("old").also { old -> + put(old, "root/save.dat", "mine") + put(old, "opt/appimages/user/app1/app.AppImage") + put(old, "opt/droiddeck-apps/game/run.sh") + put(old, "usr/local/share/applications/droiddeck-app-game.desktop") + put(old, "usr/local/share/applications/droiddeck-appimage-app1.desktop") + put(old, "usr/local/share/applications/other.desktop") + put(old, "opt/appimages/dolphin.AppImage") + put(old, "usr/share/applications/droiddeck-dolphin.desktop") + put(old, ".droiddeck-pkg-dolphin", "1.0") + put(old, "usr/bin/tool", "old system file") + } + + @Test fun anUpdateKeepsImportedAppsCatalogAppImagesAndHome() { + val old = userTree() + val staging = tmp.newFolder("new").also { put(it, "root/.bashrc", "shipped"); put(it, "usr/bin/tool", "new system file") } + + val moved = RuntimeFileTree.carryUserData(old, staging, false) + + assertEquals("mine", File(staging, "root/save.dat").readText()) + assertFalse("the shipped home is replaced, not merged", File(staging, "root/.bashrc").exists()) + for (rel in listOf("opt/appimages/user/app1/app.AppImage", "opt/droiddeck-apps/game/run.sh", + "usr/local/share/applications/droiddeck-app-game.desktop", "usr/local/share/applications/droiddeck-appimage-app1.desktop", + "opt/appimages/dolphin.AppImage", "usr/share/applications/droiddeck-dolphin.desktop", ".droiddeck-pkg-dolphin")) { + assertTrue(rel, File(staging, rel).exists()) + assertTrue(rel, rel in moved || moved.any { rel.startsWith("$it/") }) + } + assertEquals("system files come from the new runtime", "new system file", File(staging, "usr/bin/tool").readText()) + assertFalse("only DroidDeck's own menu entries are user data", File(staging, "usr/local/share/applications/other.desktop").exists()) + } + + @Test fun aFailureHalfwayPutsEverythingBack() { + val old = userTree() + val staging = tmp.newFolder("new") + put(staging, "usr/local", "a file where a folder is needed") + + try { + RuntimeFileTree.carryUserData(old, staging, false) + fail("expected the carry to fail") + } catch (expected: IOException) { + } + + assertEquals("mine", File(old, "root/save.dat").readText()) + assertTrue(File(old, "opt/appimages/user/app1/app.AppImage").exists()) + assertTrue(File(old, "opt/droiddeck-apps/game/run.sh").exists()) + assertFalse(File(staging, "root").exists()) + } + + @Test fun recoveryOnlyFillsWhatIsMissing() { + val target = tmp.newFolder("target").also { put(it, "opt/droiddeck-apps/game/run.sh", "kept") } + val staging = tmp.newFolder("staging").also { + put(it, "opt/droiddeck-apps/game/run.sh", "stale") + put(it, "opt/appimages/user/app1/app.AppImage") + } + + val moved = RuntimeFileTree.carryUserData(staging, target, true) + + assertEquals(listOf("opt/appimages/user"), moved) + assertEquals("kept", File(target, "opt/droiddeck-apps/game/run.sh").readText()) + assertTrue(File(target, "opt/appimages/user/app1/app.AppImage").exists()) + } + @Test fun recoveryFindsCompanionsWhoseAppImageWasAlreadyPutBack() { + val root = tmp.newFolder("runtime") + val staging = tmp.newFolder("runtime.new") + val old = File(tmp.root, "runtime.old") + put(root, "opt/appimages/dolphin.AppImage", "user app") + put(staging, LinuxRuntimeInstaller.CARRY_MARKER, "1") + put(staging, "usr/share/applications/droiddeck-dolphin.desktop", "menu") + put(staging, ".droiddeck-pkg-dolphin", "version") + put(staging, ".droiddeck-pkg-desktop-kde", "system package marker") + + LinuxRuntimeInstaller.recoverInterruptedSwap(root, staging, old) + assertEquals("menu", File(root, "usr/share/applications/droiddeck-dolphin.desktop").readText()) + assertEquals("version", File(root, ".droiddeck-pkg-dolphin").readText()) + assertFalse(File(root, ".droiddeck-pkg-desktop-kde").exists()) + assertEquals("user app", File(root, "opt/appimages/dolphin.AppImage").readText()) + } + + @Test fun unmarkedExtractedStagingDoesNotBlockRecovery() { + val root = tmp.newFolder("runtime") + val staging = tmp.newFolder("runtime.new") + val old = File(tmp.root, "runtime.old") + put(root, "root/save.dat", "current user data") + put(staging, "root/.bashrc", "shipped home from interrupted extract") + + LinuxRuntimeInstaller.recoverInterruptedSwap(root, staging, old) + assertEquals("current user data", File(root, "root/save.dat").readText()) + assertEquals("shipped home from interrupted extract", File(staging, "root/.bashrc").readText()) + } + + @Test fun anUnreadableAppImageDirectoryAbortsBeforeMovingHome() { + assumeTrue(Files.getFileStore(tmp.root.toPath()).supportsFileAttributeView("posix")) + val old = userTree() + val images = File(old, "opt/appimages") + val staging = tmp.newFolder("staging") + assertTrue(images.setReadable(false, false)) + assertTrue(images.setExecutable(false, false)) + try { + assertThrows(IOException::class.java) { RuntimeFileTree.carryUserData(old, staging, false) } + assertEquals("mine", File(old, "root/save.dat").readText()) + assertFalse(File(staging, "root/save.dat").exists()) + } finally { + images.setReadable(true, true) + images.setExecutable(true, true) + } + } + + @Test fun recoveryRestoresTheRuntimeBeforeTryingAFallibleHomeMove() { + val root = File(tmp.root, "runtime") + val old = tmp.newFolder("runtime.old") + val staging = tmp.newFolder("runtime.new") + put(old, "usr/bin/usable", "previous runtime") + File(old, "root").mkdirs() + put(staging, LinuxRuntimeInstaller.CARRY_MARKER, "1") + put(staging, "root/save.dat", "staged user data") + RuntimeFileTree.refuseForTest = java.util.function.Predicate { it.fileName.toString() == "root" } + + assertThrows(IOException::class.java) { LinuxRuntimeInstaller.recoverInterruptedSwap(root, staging, old) } + assertTrue("the previous runtime was not restored", root.isDirectory) + assertEquals("previous runtime", File(root, "usr/bin/usable").readText()) + assertEquals("staged user data", File(staging, "root/save.dat").readText()) + } + + @Test fun conflictingRecoveryDataIsRetainedOnEveryRetry() { + val root = tmp.newFolder("runtime") + val staging = tmp.newFolder("runtime.new") + val old = File(tmp.root, "runtime.old") + put(root, "root/current.dat", "current user data") + put(staging, LinuxRuntimeInstaller.CARRY_MARKER, "1") + put(staging, "root/older.dat", "unrecovered user data") + + repeat(2) { + assertThrows(IOException::class.java) { LinuxRuntimeInstaller.recoverInterruptedSwap(root, staging, old) } + assertEquals("current user data", File(root, "root/current.dat").readText()) + assertEquals("unrecovered user data", File(staging, "root/older.dat").readText()) + } + } + + @Test fun stagedDataWithoutASurvivingRuntimeIsKeptForRecovery() { + val root = File(tmp.root, "runtime") + val old = File(tmp.root, "runtime.old") + val staging = tmp.newFolder("runtime.new") + put(staging, LinuxRuntimeInstaller.CARRY_MARKER, "1") + put(staging, "root/save.dat", "only surviving user data") + assertThrows(IOException::class.java) { LinuxRuntimeInstaller.recoverInterruptedSwap(root, staging, old) } + assertEquals("only surviving user data", File(staging, "root/save.dat").readText()) + } + + @Test fun conflictingIntendedHomesAreRetainedWithoutProofOfTheirContents() { + val root = File(tmp.root, "runtime") + val old = tmp.newFolder("runtime.old") + val staging = tmp.newFolder("runtime.new") + put(old, "root/save.dat", "user data") + put(staging, LinuxRuntimeInstaller.CARRY_MARKER, "root") + put(staging, "root/.bashrc", "shipped default") + + assertThrows(IOException::class.java) { LinuxRuntimeInstaller.recoverInterruptedSwap(root, staging, old) } + + assertEquals("user data", File(root, "root/save.dat").readText()) + assertEquals("shipped default", File(staging, "root/.bashrc").readText()) + assertTrue(File(staging, LinuxRuntimeInstaller.CARRY_MARKER).exists()) + } + + @Test fun anUnreadableCarryMarkerStopsRecovery() { + val root = tmp.newFolder("runtime") + val staging = tmp.newFolder("runtime.new") + val old = File(tmp.root, "runtime.old") + val marker = File(staging, LinuxRuntimeInstaller.CARRY_MARKER).apply { mkdirs() } + + assertThrows(IOException::class.java) { LinuxRuntimeInstaller.recoverInterruptedSwap(root, staging, old) } + assertTrue(marker.isDirectory) + } + + @Test fun recoveryMovesOnlyAnIntendedPathThatWasAlreadyCarried() { + val root = File(tmp.root, "runtime") + val old = tmp.newFolder("runtime.old") + val staging = tmp.newFolder("runtime.new") + put(staging, LinuxRuntimeInstaller.CARRY_MARKER, "root") + put(staging, "root/save.dat", "moved user data") + + LinuxRuntimeInstaller.recoverInterruptedSwap(root, staging, old) + + assertEquals("moved user data", File(root, "root/save.dat").readText()) + assertFalse(File(staging, LinuxRuntimeInstaller.CARRY_MARKER).exists()) + } + + @Test fun aSymlinkedParentCannotRedirectAUserDataMoveOutsideTheRuntime() { + val old = tmp.newFolder("old") + val staging = tmp.newFolder("staging") + val outside = tmp.newFolder("outside") + put(outside, "droiddeck-apps/game/run.sh", "outside data") + Files.createSymbolicLink(File(old, "opt").toPath(), outside.toPath()) + + assertThrows(IOException::class.java) { RuntimeFileTree.carryUserData(old, staging, false) } + + assertEquals("outside data", File(outside, "droiddeck-apps/game/run.sh").readText()) + assertFalse(File(staging, "opt/droiddeck-apps").exists()) + } + + @Test fun anInaccessibleLiveHomeIsNeverTreatedAsMissing() { + assumeTrue(Files.getFileStore(tmp.root.toPath()).supportsFileAttributeView("posix")) + val root = tmp.newFolder("runtime") + val staging = tmp.newFolder("runtime.new") + val old = File(tmp.root, "runtime.old") + put(root, "root/keep.dat", "current user data") + put(staging, LinuxRuntimeInstaller.CARRY_MARKER, "1") + put(staging, "root/staged.dat", "staged user data") + assertTrue(root.setExecutable(false, false)) + val failure = try { + runCatching { LinuxRuntimeInstaller.recoverInterruptedSwap(root, staging, old) }.exceptionOrNull() + } finally { + root.setExecutable(true, true) + } + + assertTrue("the existing live home was deleted", File(root, "root/keep.dat").isFile) + assertEquals("current user data", File(root, "root/keep.dat").readText()) + assertEquals("staged user data", File(staging, "root/staged.dat").readText()) + assertTrue("inaccessible data must abort recovery", failure is IOException) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/runtime/SeededMacTest.kt b/app/src/test/java/com/droiddeck/launcher/runtime/SeededMacTest.kt new file mode 100644 index 000000000..e7a521263 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/runtime/SeededMacTest.kt @@ -0,0 +1,21 @@ +package com.droiddeck.launcher.runtime + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test + +class SeededMacTest { + @Test + fun theAndroidIdSeedsAStableLocalMac() { + val mac = LinuxNetworkLinkComponent.seededMac { "0123456789abcdef" } + assertEquals(mac, LinuxNetworkLinkComponent.seededMac { "0123456789abcdef" }) + assertNotEquals(mac, LinuxNetworkLinkComponent.seededMac { null }) + assertEquals(0x02, mac.substringBefore(':').toInt(16) and 0x03) + } + + @Test + fun aRomThatThrowsOnTheAndroidIdReadFallsBackToTheDefaultSeed() { + val mac = LinuxNetworkLinkComponent.seededMac { throw IllegalArgumentException("Illegal char: - at offset 8") } + assertEquals(LinuxNetworkLinkComponent.seededMac { null }, mac) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/BatteryComponentTest.kt b/app/src/test/java/com/droiddeck/launcher/session/BatteryComponentTest.kt index 0c94f8225..443559cf1 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/BatteryComponentTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/BatteryComponentTest.kt @@ -49,11 +49,25 @@ class BatteryComponentTest { assertEquals("-2000000", File(supplies, "BAT1/current_now").readText().trim()) } - private fun start(supplies: File, vpower: File, currentUa: Long) { + @Test fun chargingAtFullLevelIsReportedFullSoSteamStopsSaying99() { + val supplies = tmp.newFolder("power_supply") + val vpower = tmp.newFolder("vpower") + start(supplies, vpower, 2_000_000L, BatteryManager.BATTERY_STATUS_CHARGING, 100) + assertEquals("Full", File(supplies, "BAT1/status").readText().trim()) + assertEquals("Full", File(vpower, "battery_status").readText().trim()) + battery?.stop() + start(supplies, vpower, 2_000_000L, BatteryManager.BATTERY_STATUS_CHARGING, 99) + assertEquals("Charging", File(supplies, "BAT1/status").readText().trim()) + } + + private fun start( + supplies: File, vpower: File, currentUa: Long, + status: Int = BatteryManager.BATTERY_STATUS_DISCHARGING, level: Int = 50, + ) { val context = RuntimeEnvironment.getApplication() context.sendStickyBroadcast(Intent(Intent.ACTION_BATTERY_CHANGED).apply { - putExtra(BatteryManager.EXTRA_STATUS, BatteryManager.BATTERY_STATUS_DISCHARGING) - putExtra(BatteryManager.EXTRA_LEVEL, 50) + putExtra(BatteryManager.EXTRA_STATUS, status) + putExtra(BatteryManager.EXTRA_LEVEL, level) putExtra(BatteryManager.EXTRA_SCALE, 100) putExtra(BatteryManager.EXTRA_VOLTAGE, 4000) }) diff --git a/app/src/test/java/com/droiddeck/launcher/session/BootMovieLibraryTest.kt b/app/src/test/java/com/droiddeck/launcher/session/BootMovieLibraryTest.kt index ca68f5867..2c1d90ca0 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/BootMovieLibraryTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/BootMovieLibraryTest.kt @@ -178,17 +178,39 @@ class BootMovieLibraryTest { assertNull(SessionFiles.startupMoviePath(BootMovieLibrary.steamConfig(app))) } - @Test fun onlyTheThreeStartupMoviesOfSteamAreListed() { - listOf("deck_startup", "oled_startup", "startup_machine", "controller_generic_thumbstick_move").forEach { + @Test fun onlyTheStartupMoviesOfSteamAreListed() { + listOf("deck_startup", "oled_startup", "startup_machine", "steam_os_startup", "controller_generic_thumbstick_move").forEach { File(BootMovieLibrary.steamRoot(app), "steamui/movies/$it.webm").also { f -> f.parentFile!!.mkdirs(); f.writeText("movie") } } val ids = BootMovieLibrary.list(app).map { it.id }.filter { it.startsWith("steam:") } assertEquals( - listOf("steam:/movies/deck_startup.webm", "steam:/movies/oled_startup.webm", "steam:/movies/startup_machine.webm"), + listOf( + "steam:/movies/deck_startup.webm", "steam:/movies/oled_startup.webm", "steam:/movies/startup_machine.webm", + "steam:/movies/steam_os_startup.webm", + ), ids.sorted(), ) } + @Test fun steamsDefaultPickIsTakenOverAsItsOwnStartupMovie() { + val native = File(BootMovieLibrary.steamRoot(app), "steamui/movies/steam_os_startup.webm").also { it.parentFile!!.mkdirs(); it.writeText("movie") } + movie("a.webm") + steamPicksAt(SessionFiles.BOOT_MOVIE_PATH) + BootMovie.select(app, "file:a.webm") + steamPicksAt("") + assertTrue(BootMovie.syncFromSteam(app)) + assertEquals("steam:/movies/steam_os_startup.webm", BootMovie.choice(app)) + assertEquals(native, BootMovie.currentFile(app)) + assertFalse(BootMovie.syncFromSteam(app)) + } + + @Test fun anEmptySteamPickThatWasNeverSeenChangesNothing() { + File(BootMovieLibrary.steamRoot(app), "steamui/movies/steam_os_startup.webm").also { it.parentFile!!.mkdirs(); it.writeText("movie") } + steamPicksAt("") + assertFalse(BootMovie.syncFromSteam(app)) + assertEquals(BootMovie.DROIDDECK_30, BootMovie.choice(app)) + } + @Test fun aMovieBoughtInThePointsShopIsListedAndAdopted() { val file = File(BootMovieLibrary.steamRoot(app), "appcache/startupmovies/Bought.webm").also { it.parentFile!!.mkdirs(); it.writeText("movie") } val entry = BootMovieLibrary.list(app).first { it.id == "steam:/appcache/startupmovies/Bought.webm" } diff --git a/app/src/test/java/com/droiddeck/launcher/session/ComponentImportTest.kt b/app/src/test/java/com/droiddeck/launcher/session/ComponentImportTest.kt new file mode 100644 index 000000000..db42f4df1 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/ComponentImportTest.kt @@ -0,0 +1,135 @@ +package com.droiddeck.launcher.session + +import android.content.Context +import com.droiddeck.launcher.core.Hashes +import com.droiddeck.launcher.runtime.LinuxRuntime +import com.github.luben.zstd.ZstdOutputStream +import org.apache.commons.compress.archivers.tar.TarArchiveEntry +import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.io.BufferedOutputStream +import java.io.File +import java.io.FileOutputStream + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class ComponentImportTest { + private lateinit var context: Context + + @Before fun setUp() { + context = RuntimeEnvironment.getApplication() + File(context.filesDir, "components").deleteRecursively() + LinuxRuntime.rootDir(context).deleteRecursively() + } + + @Test fun refusesOverwritingReferencedPackageWithDifferentContent() { + val wcpA = createWcp("src-a.wcp", "A") + val wcpB = createWcp("src-b.wcp", "B") + val shaA = Hashes.sha256(wcpA) + + ComponentsManager.importPackage(context, wcpA, "dxvk.wcp") + ComponentsManager.setGameComponent(context, "10", "dxvk", "dxvk.wcp") + + assertThrows(IllegalStateException::class.java) { + ComponentsManager.importPackage(context, wcpB, "dxvk.wcp") + } + + val stored = File(context.filesDir, "components/packages/dxvk.wcp") + assertEquals(shaA, Hashes.sha256(stored)) + assertFalse(File(context.filesDir, "components/packages/dxvk.wcp.part").exists()) + } + + @Test fun allowsReimportingIdenticalPackageWhileReferenced() { + val wcpA = createWcp("src-a.wcp", "A") + val shaA = Hashes.sha256(wcpA) + + ComponentsManager.importPackage(context, wcpA, "dxvk.wcp") + ComponentsManager.setGameComponent(context, "10", "dxvk", "dxvk.wcp") + + val reimported = ComponentsManager.importPackage(context, wcpA, "dxvk.wcp") + assertEquals("A", reimported.version) + assertEquals("dxvk.wcp", reimported.file) + + val stored = File(context.filesDir, "components/packages/dxvk.wcp") + assertEquals(shaA, Hashes.sha256(stored)) + assertFalse(File(context.filesDir, "components/packages/dxvk.wcp.part").exists()) + } + + @Test fun invalidatesUnpackedStoreWhenReplacingUnreferencedPackage() { + val wcpA = createWcp("src-a.wcp", "A") + val wcpB = createWcp("src-b.wcp", "B") + val shaB = Hashes.sha256(wcpB) + + ComponentsManager.importPackage(context, wcpA, "dxvk.wcp") + val unpacked = File(LinuxRuntime.rootDir(context), "root/.local/share/droiddeck-components/store/dxvk") + unpacked.mkdirs() + File(unpacked, ".complete").writeText("ok") + assertTrue(File(unpacked, ".complete").isFile) + + val replaced = ComponentsManager.importPackage(context, wcpB, "dxvk.wcp") + assertEquals("B", replaced.version) + + val stored = File(context.filesDir, "components/packages/dxvk.wcp") + assertEquals(shaB, Hashes.sha256(stored)) + assertFalse(unpacked.exists()) + } + + @Test fun deletingAPackageAGameProfileUsesClearsThatProfileEntry() { + val wcpA = createWcp("src-a.wcp", "A") + ComponentsManager.importPackage(context, wcpA, "dxvk.wcp") + writeGameProfile("10", "dxvk", "dxvk.wcp") + + ComponentsManager.deletePackage(context, "dxvk.wcp") + + assertFalse(File(context.filesDir, "components/packages/dxvk.wcp").exists()) + val state = JSONObject(File(context.filesDir, "components/state.json").readText()) + assertFalse(state.optJSONObject("profiles")?.optJSONObject("10")?.has("dxvk") == true) + } + + private fun writeGameProfile(scope: String, comp: String, file: String) { + val state = File(context.filesDir, "components/state.json") + state.parentFile!!.mkdirs() + state.writeText( + JSONObject() + .put("active", JSONObject()) + .put("queued", JSONObject()) + .put("profiles", JSONObject().put(scope, JSONObject().put(comp, file))) + .toString(), + ) + } + + private fun createWcp(fileName: String, version: String): File { + val out = File(context.cacheDir, fileName) + val profile = JSONObject() + .put("type", "DXVK-Linux") + .put("versionName", version) + .put("description", "DXVK $version") + .toString() + .toByteArray(Charsets.UTF_8) + val dll = "dxvk-dll-$version".toByteArray(Charsets.UTF_8) + + TarArchiveOutputStream(ZstdOutputStream(BufferedOutputStream(FileOutputStream(out)))).use { tar -> + tar.setLongFileMode(TarArchiveOutputStream.LONGFILE_POSIX) + val profEntry = TarArchiveEntry("profile.json").apply { size = profile.size.toLong() } + tar.putArchiveEntry(profEntry) + tar.write(profile) + tar.closeArchiveEntry() + + val dllEntry = TarArchiveEntry("files/lib/wine/dxvk/aarch64-windows/d3d11.dll").apply { size = dll.size.toLong() } + tar.putArchiveEntry(dllEntry) + tar.write(dll) + tar.closeArchiveEntry() + } + return out + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/ComponentsCarryOverTest.kt b/app/src/test/java/com/droiddeck/launcher/session/ComponentsCarryOverTest.kt new file mode 100644 index 000000000..60c110451 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/ComponentsCarryOverTest.kt @@ -0,0 +1,30 @@ +package com.droiddeck.launcher.session + +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class ComponentsCarryOverTest { + @Test + fun swappedPackagesFollowTheBuildThatReplacesTheirProton() { + val state = JSONObject(""" + {"active": {"DroidDeck-Proton_11-11.2": { + "dxvk": {"file": "dxvk-2.7.wcp", "label": "2.7", "protonVersion": "11-11.2"}, + "vkd3d": {"file": "original 11-11.1", "label": "Original from 11-11.1"}, + "fex": {"file": "gone.wcp", "label": "old"}}}, + "queued": {"DroidDeck-Proton_11-11.2": { + "vkd3d": {"kind": "package", "file": "vkd3d-3.0.wcp", "label": "3.0"}, + "fex": {"kind": "original", "protonVersion": "11-11.1", "label": "Original 11-11.1"}}}} + """.trimIndent()) + val carried = ComponentsManager.carried(state, "DroidDeck-Proton_11-11.2") { it != "gone.wcp" } + assertEquals(setOf("dxvk", "vkd3d"), carried.keys) + assertEquals("package", carried.getValue("dxvk").getString("kind")) + assertEquals("dxvk-2.7.wcp", carried.getValue("dxvk").getString("file")) + assertEquals("2.7", carried.getValue("dxvk").getString("label")) + assertEquals("vkd3d-3.0.wcp", carried.getValue("vkd3d").getString("file")) + assertEquals(emptyMap<String, JSONObject>(), ComponentsManager.carried(state, "GE-Proton11-7") { true }) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/ComponentsSelectionTest.kt b/app/src/test/java/com/droiddeck/launcher/session/ComponentsSelectionTest.kt new file mode 100644 index 000000000..517d6ad2b --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/ComponentsSelectionTest.kt @@ -0,0 +1,32 @@ +package com.droiddeck.launcher.session + +import org.json.JSONObject +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.File + +@RunWith(RobolectricTestRunner::class) +class ComponentsSelectionTest { + private val proton = ComponentsManager.Proton("DroidDeck-Proton_11-11.1", "DroidDeck-Proton 11-11.1", File("/p"), "/p", "DroidDeck-Proton-11-11.1", false) + private val entry = JSONObject().put("file", "dxvk-low-latency-arm64ec-3.1.1-2-linux.wcp").put("protonVersion", "DroidDeck-Proton-11-11.1") + + @Test + fun `a package swapped into an installed Proton is in use`() { + assertTrue(ComponentsManager.liveSelection("active", entry, proton)) + assertTrue(ComponentsManager.liveSelection("queued", JSONObject().put("file", "x.wcp"), proton)) + } + + @Test + fun `a package left on a removed Proton is not in use`() { + assertFalse(ComponentsManager.liveSelection("active", entry, null)) + assertFalse(ComponentsManager.liveSelection("queued", entry, null)) + } + + @Test + fun `a package from before a Proton update is not in use`() { + assertFalse(ComponentsManager.liveSelection("active", JSONObject(entry.toString()).put("protonVersion", "DroidDeck-Proton-11-9.1"), proton)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/CoreAllocationPrefsTest.kt b/app/src/test/java/com/droiddeck/launcher/session/CoreAllocationPrefsTest.kt index 8a25bf4e7..901a82d3f 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/CoreAllocationPrefsTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/CoreAllocationPrefsTest.kt @@ -1,7 +1,6 @@ package com.droiddeck.launcher.session import android.content.Context -import com.droiddeck.launcher.core.CpuAllocationDefaults import org.junit.Assert.* import org.junit.Before import org.junit.Test @@ -14,36 +13,22 @@ import org.robolectric.annotation.Config @Config(sdk = [33]) class CoreAllocationPrefsTest { private val app get() = RuntimeEnvironment.getApplication() - private val defaults = CpuAllocationDefaults.Allocation("7", "3,4,5,6") private val saved get() = app.getSharedPreferences("session", Context.MODE_PRIVATE) @Before fun clear() { saved.edit().clear().commit() } - @Test fun absentPreferencesUseTheDeviceDefaultWithoutWritingIt() { - assertEquals(SessionPrefs.CoreAllocation(true, "7", "3,4,5,6"), SessionPrefs.coreAllocation(app, defaults)) - assertTrue(saved.all.isEmpty()) - assertEquals(SessionPrefs.CoreAllocation(false, "", ""), SessionPrefs.coreAllocation(app, null)) + @Test fun absentPreferencesLeaveEveryCoreToTheScheduler() { + assertFalse(SessionPrefs.clientCpusOverride(app)) + assertEquals("", SessionPrefs.clientCpus(app)) + assertEquals("", SessionPrefs.gameCpus(app)) } - @Test fun explicitDisableAndAllCoreChoicesSurviveAnUpdate() { - saved.edit().putBoolean("clientCpusOverride", false).putString("clientCpus", "").putString("gameCpus", "").commit() - assertEquals(SessionPrefs.CoreAllocation(false, "", ""), SessionPrefs.coreAllocation(app, defaults)) - } - - @Test fun existingCustomMasksArePreserved() { - saved.edit().putBoolean("clientCpusOverride", true).putString("clientCpus", "0,1").putString("gameCpus", "2,7").commit() - assertEquals(SessionPrefs.CoreAllocation(true, "0,1", "2,7"), SessionPrefs.coreAllocation(app, defaults)) - } - - @Test fun anOldClientMaskIsNotEnabledWithoutAnOverrideChoice() { - saved.edit().putString("clientCpus", "0,1").commit() - assertEquals(SessionPrefs.CoreAllocation(false, "0,1", "3,4,5,6"), SessionPrefs.coreAllocation(app, defaults)) - } - - @Test fun changingOneChoiceDoesNotReplaceTheOtherDefaults() { - SessionPrefs.setGameCpus(app, "0,1,2,3,4,5,6,7") - assertEquals(SessionPrefs.CoreAllocation(true, "7", "0,1,2,3,4,5,6,7"), SessionPrefs.coreAllocation(app, defaults)) - SessionPrefs.setClientCpusOverride(app, false) - assertEquals(SessionPrefs.CoreAllocation(false, "7", "0,1,2,3,4,5,6,7"), SessionPrefs.coreAllocation(app, defaults)) + @Test fun savedChoicesAreKept() { + SessionPrefs.setClientCpusOverride(app, true) + SessionPrefs.setClientCpus(app, "7") + SessionPrefs.setGameCpus(app, "3,4,5,6") + assertTrue(SessionPrefs.clientCpusOverride(app)) + assertEquals("7", SessionPrefs.clientCpus(app)) + assertEquals("3,4,5,6", SessionPrefs.gameCpus(app)) } } diff --git a/app/src/test/java/com/droiddeck/launcher/session/ForcedPackageTest.kt b/app/src/test/java/com/droiddeck/launcher/session/ForcedPackageTest.kt index 3f5e6e8e4..ba026b474 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/ForcedPackageTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/ForcedPackageTest.kt @@ -29,6 +29,18 @@ class ForcedPackageTest { LinuxRuntime.rootDir(context).deleteRecursively() } + @Test fun unpackedCacheNameCannotEscapeStoreDirectory() { + assertEquals("dxvk-2.7.1-linux", ComponentsManager.unpackedCacheKey(dxvk2)) + for (filename in listOf("..wcp", "...wcp")) { + try { + ComponentsManager.unpackedCacheKey(filename) + fail("Expected cache traversal name to be rejected: $filename") + } catch (_: IllegalArgumentException) { + // Expected: the derived path would be a dot segment. + } + } + } + @Test fun adreno6xxForcesDxvk2AndNothingElseDoes() { val rule = ComponentsManager.FORCED_PACKAGES.single { GpuInfo.Family.A6XX in it.families } assertEquals("dxvk", rule.comp) diff --git a/app/src/test/java/com/droiddeck/launcher/session/LoadingStateTest.kt b/app/src/test/java/com/droiddeck/launcher/session/LoadingStateTest.kt new file mode 100644 index 000000000..c06282349 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/LoadingStateTest.kt @@ -0,0 +1,36 @@ +package com.droiddeck.launcher.session + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class LoadingStateTest { + @Test fun clientDownloadCountsMegabytesWhenTheRuntimeSaysThem() { + assertEquals( + "45 / 360 MB" to 12, + LoadingState.clientDownload("== STEP 17:38:20 downloading Steam: bins_linuxarm64_linuxarm64 (2/17), 45 of 360 MB"), + ) + } + + @Test fun aStalledDownloadStillShowsWhereItIs() { + assertEquals( + "45 / 360 MB" to 12, + LoadingState.clientDownload( + "== STEP 17:39:20 downloading Steam: bins_linuxarm64_linuxarm64 (2/17), 45 of 360 MB" + + " - interrupted (curl exit 28), trying again in 5 s", + ), + ) + } + + @Test fun anOlderRuntimeShowsThePartAndItsNumber() { + assertEquals( + "bins_linuxarm64_linuxarm64" to 11, + LoadingState.clientDownload("== STEP 17:38:17 downloading Steam: bins_linuxarm64_linuxarm64 (2/17)"), + ) + assertEquals("a_all" to 50, LoadingState.clientDownload("== STEP 00:00:00 downloading Steam: a_all (1/2), 0 of 0 MB")) + } + + @Test fun otherStepsAreNotClientDownloads() { + assertNull(LoadingState.clientDownload("== STEP 17:38:14 checking the Steam client (a first run downloads it)")) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/MemoryReadoutTest.kt b/app/src/test/java/com/droiddeck/launcher/session/MemoryReadoutTest.kt new file mode 100644 index 000000000..a4e495131 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/MemoryReadoutTest.kt @@ -0,0 +1,66 @@ +package com.droiddeck.launcher.session + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class MemoryReadoutTest { + private val meminfo = """ + MemTotal: 11379920 kB + MemFree: 527864 kB + MemAvailable: 3235768 kB + Buffers: 1024 kB + SwapTotal: 16777212 kB + SwapFree: 10358080 kB + """.trimIndent() + + @Test fun memoryAndSwapInUseComeFromMeminfo() { + val device = MemoryReadout.parseMeminfo(meminfo)!! + assertEquals(11379920L - 3235768L, device.usedKb) + assertEquals(16777212L - 10358080L, device.swapUsedKb) + assertEquals("7.8", MemoryReadout.gb(device.usedKb)) + assertEquals("10.9", MemoryReadout.gb(device.totalKb)) + assertEquals(0L, MemoryReadout.parseMeminfo("MemTotal: 100 kB\nMemAvailable: 40 kB\n")!!.swapUsedKb) + assertNull(MemoryReadout.parseMeminfo("MemTotal: 100 kB\n")) + } + + @Test fun aProcessCountsItsResidentAnonymousAndSharedMemory() { + val status = """ + Name: Diablo IV.exe + VmRSS: 2000000 kB + RssAnon: 1500000 kB + RssFile: 400000 kB + RssShmem: 100000 kB + VmSwap: 300000 kB + """.trimIndent() + assertEquals(1600000L, MemoryReadout.residentKb(status)) + assertEquals(0L, MemoryReadout.residentKb("Name:\tkthreadd\n")) + } + + @Test fun theGameIsWhatRunsInAWinePrefix() { + fun env(vararg vars: String) = vars.joinToString("\u0000", postfix = "\u0000").toByteArray() + assertTrue(MemoryReadout.inWinePrefix(env("HOME=/root", "WINEPREFIX=/root/pfx", "DISPLAY=:0"))) + assertTrue(MemoryReadout.inWinePrefix("WINEPREFIX=/x".toByteArray())) + assertFalse(MemoryReadout.inWinePrefix(env("HOME=/root", "STEAM_COMPAT_DATA_PATH=/x", "MY_WINEPREFIX=/y"))) + assertFalse(MemoryReadout.inWinePrefix(ByteArray(0))) + } + + @Test fun androidIsWhatIsLeftOfTheMemoryInUse() { + val device = MemoryReadout.Device(totalKb = 10_000, availableKb = 3_000, swapTotalKb = 0, swapFreeKb = 0) + assertEquals(3_000L, MemoryReadout.androidKb(device, MemoryReadout.Split(gameKb = 2_000, droiddeckKb = 1_000, gpuKb = 1_000))) + assertEquals(4_000L, MemoryReadout.androidKb(device, MemoryReadout.Split(gameKb = 2_000, droiddeckKb = 1_000, gpuKb = null))) + assertEquals(0L, MemoryReadout.androidKb(device, MemoryReadout.Split(gameKb = 6_000, droiddeckKb = 2_000, gpuKb = 1_000))) + } + + @Test fun theLogLineSaysEveryPartInMegabytes() { + val device = MemoryReadout.Device(totalKb = 11_379_920, availableKb = 1_048_576, swapTotalKb = 16_777_212, swapFreeKb = 8_388_606) + val split = MemoryReadout.Split(gameKb = 2_097_152, droiddeckKb = 524_288, gpuKb = 3_145_728) + assertEquals( + "used 10089/11113 MB, available 1024 MB, swap 8191/16383 MB | game 2048, droiddeck 512, android 4457, gpu 3072 MB", + MemoryLog.line(device, split), + ) + assertTrue(MemoryLog.line(device, split.copy(gpuKb = null)).endsWith("gpu ? MB")) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/NoSteamLaunchTest.kt b/app/src/test/java/com/droiddeck/launcher/session/NoSteamLaunchTest.kt index 84c995ee2..d0739df4a 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/NoSteamLaunchTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/NoSteamLaunchTest.kt @@ -61,6 +61,8 @@ class NoSteamLaunchTest { listOf("--app", "2733369024", "--dir", game.guestDir, "--options", "-windowed", "--", game.guestExe), added.args, ) + val plain = NoSteamLaunch.addedCommand(AddedGames.Game(game.folder, game.name, game.exe, game.guestExe, game.guestDir, 2733369024L, 0L, emptyList(), source = "gog")) + assertEquals(listOf("--app", "2733369024", "--dir", game.guestDir, "--", game.guestExe), plain.args) assertNull(NoSteamLaunch.command(shortcut, null)) val steam = NoSteamLaunch.command(steamGame, null)!! assertEquals(listOf("--app", "413150"), steam.args) diff --git a/app/src/test/java/com/droiddeck/launcher/session/OrientationTest.kt b/app/src/test/java/com/droiddeck/launcher/session/OrientationTest.kt index 5ce8030d5..5d67d041a 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/OrientationTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/OrientationTest.kt @@ -20,7 +20,7 @@ class OrientationTest { } @Test fun landscapeChoicesPersistGloballyAndCanRestoreAutomaticRotation() { - assertEquals(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE, SessionPrefs.orientation(context)) + assertEquals(ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE, SessionPrefs.orientation(context)) for (value in listOf( ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE, ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE, @@ -34,7 +34,7 @@ class OrientationTest { @Test fun invalidStoredOrientationFallsBackAndInvalidChoicePreservesTheSavedValue() { val prefs = context.getSharedPreferences("session", Context.MODE_PRIVATE) prefs.edit().putInt("orientation", ActivityInfo.SCREEN_ORIENTATION_PORTRAIT).commit() - assertEquals(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE, SessionPrefs.orientation(context)) + assertEquals(ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE, SessionPrefs.orientation(context)) SessionPrefs.setOrientation(context, ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE) assertThrows(IllegalArgumentException::class.java) { SessionPrefs.setOrientation(context, ActivityInfo.SCREEN_ORIENTATION_PORTRAIT) diff --git a/app/src/test/java/com/droiddeck/launcher/session/PerformanceModeTest.kt b/app/src/test/java/com/droiddeck/launcher/session/PerformanceModeTest.kt new file mode 100644 index 000000000..f59157c3b --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/PerformanceModeTest.kt @@ -0,0 +1,42 @@ +package com.droiddeck.launcher.session + +import android.content.Context +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class PerformanceModeTest { + private val app get() = RuntimeEnvironment.getApplication() + private val session get() = app.getSharedPreferences("session", Context.MODE_PRIVATE) + + @Before fun clear() { session.edit().clear().commit() } + + @Test fun offByDefaultAndPerMode() { + assertFalse(SessionPrefs.performanceMode(app, SessionService.MODE_STEAM)) + SessionPrefs.setPerformanceMode(app, SessionService.MODE_STEAM, true) + assertTrue(SessionPrefs.performanceMode(app, SessionService.MODE_STEAM)) + assertFalse(SessionPrefs.performanceMode(app, SessionService.MODE_DESKTOP)) + } + + @Test fun offKeepsTodaysPacing() { + assertEquals(0, SessionPrefs.compositorFpsLimit(0, false)) + assertEquals(60, SessionPrefs.compositorFpsLimit(60, false)) + } + + @Test fun onWithoutALimitIsUnpacedAndAChosenLimitStillPaces() { + assertEquals(SessionPrefs.FPS_UNPACED, SessionPrefs.compositorFpsLimit(0, true)) + assertEquals(60, SessionPrefs.compositorFpsLimit(60, true)) + } + + @Test fun anOldUncappedFrameLimitReadsAsOff() { + session.edit().putInt("fpsLimit.steam", -1).commit() + assertEquals(0, SessionPrefs.fpsLimit(app, SessionService.MODE_STEAM)) + assertFalse(SessionPrefs.fpsLimitChoices.any { it.first < 0 }) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/ProtonDefaultTest.kt b/app/src/test/java/com/droiddeck/launcher/session/ProtonDefaultTest.kt index fcf85ddb9..5f0651bf2 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/ProtonDefaultTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/ProtonDefaultTest.kt @@ -103,4 +103,28 @@ class ProtonDefaultTest { assertNull(ProtonDefault.gameSelectedId(context, "42", listOf(proton("GE-Proton11-7", false)))) assertEquals(ProtonDefault.GameChoice(false, "GE-Proton9-1"), ProtonDefault.parseGames(file.readText())["42"]) } + + @Test + fun aReplacedBuildsPerGamePicksMoveToTheNewest() { + val old = ProtonDefault.GameChoice(false, "DroidDeck-Proton 11-11.2") + val ge = ProtonDefault.GameChoice(false, "GE-Proton11-7") + val valve = ProtonDefault.GameChoice(true, "DroidDeck-Proton 11-11.2") + val moved = ProtonDefault.movedGames(mapOf("42" to old, "43" to ge, "44" to null, "45" to valve), setOf("DroidDeck-Proton 11-11.2"), "DroidDeck-Proton 11-11.4") + assertEquals(mapOf("42" to ProtonDefault.GameChoice(false, "DroidDeck-Proton 11-11.4"), "43" to ge, "44" to null, "45" to valve), moved) + assertNull(ProtonDefault.movedGames(mapOf("43" to ge, "44" to null), setOf("DroidDeck-Proton 11-11.2"), "DroidDeck-Proton 11-11.4")) + } + + @Test + fun movingPicksRewritesTheGamesFile() { + val context = RuntimeEnvironment.getApplication() + val file = File(LinuxRuntime.rootDir(context), "root/.local/share/droiddeck-compat/games.json") + file.parentFile?.deleteRecursively() + ProtonDefault.requestGame(context, "42", proton("DroidDeck-Proton 11-11.2", false)) + ProtonDefault.requestGame(context, "43", null) + ProtonDefault.moveGames(context, setOf("DroidDeck-Proton 11-11.2"), "DroidDeck-Proton 11-11.4") + val games = ProtonDefault.parseGames(file.readText()) + assertEquals(ProtonDefault.GameChoice(false, "DroidDeck-Proton 11-11.4"), games["42"]) + assertTrue(games.containsKey("43")) + assertNull(games["43"]) + } } diff --git a/app/src/test/java/com/droiddeck/launcher/session/ProtonExtrasTest.kt b/app/src/test/java/com/droiddeck/launcher/session/ProtonExtrasTest.kt index 0e0f16cf2..411268ef6 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/ProtonExtrasTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/ProtonExtrasTest.kt @@ -171,4 +171,62 @@ class ProtonExtrasTest { assertEquals(listOf("DroidDeck-Proton 11-7.10"), ProtonExtras.installedAll(context, tool("droiddeck"))) assertEquals("DroidDeck-Proton 11-7.10", ProtonExtras.installed(context, tool("droiddeck"))) } + + @Test + fun removingABuildDropsItsStoredOriginalsAndSelections() { + val context = RuntimeEnvironment.getApplication() + val tools = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/compatibilitytools.d") + File(LinuxRuntime.rootDir(context), "root/.local/share/droiddeck-components").mkdirs() + installTool(tools, "GE-Proton11-7") + val kept = installTool(tools, "GE-Proton11-8").apply { + File(this, "files/lib/wine").mkdirs() + File(this, "proton").writeText("") + } + val originals = File(context.filesDir, "components/originals") + for (id in listOf("GE-Proton11-7", "GE-Proton11-8")) File(originals, "$id/$id/dxvk.wcp").apply { parentFile?.mkdirs(); writeText("x") } + val pick = JSONObject().put("dxvk", JSONObject().put("file", "dxvk.wcp").put("protonVersion", "GE-Proton11-7")) + File(context.filesDir, "components/state.json").writeText(JSONObject() + .put("active", JSONObject().put("GE-Proton11-7", pick).put("GE-Proton11-8", pick)) + .put("queued", JSONObject().put("GE-Proton11-7", pick)).toString()) + ProtonExtras.remove(context, tool("ge"), "GE-Proton11-7") + assertFalse(File(originals, "GE-Proton11-7").exists()) + assertTrue(File(originals, "GE-Proton11-8/GE-Proton11-8/dxvk.wcp").isFile) + val state = JSONObject(File(context.filesDir, "components/state.json").readText()) + assertFalse(state.getJSONObject("active").has("GE-Proton11-7")) + assertFalse(state.getJSONObject("queued").has("GE-Proton11-7")) + assertTrue(state.getJSONObject("active").has("GE-Proton11-8")) + ComponentsManager.forgetProton(context, kept.name) + assertTrue(File(originals, "GE-Proton11-8/GE-Proton11-8/dxvk.wcp").isFile) + } + + @Test + fun anUpdateKeepsTheUsersProtonSettingsFile() { + val context = RuntimeEnvironment.getApplication() + val tools = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/compatibilitytools.d") + tools.deleteRecursively() + File(installTool(tools, "DroidDeck-Proton 11-11.1"), "user_settings.py").writeText("old") + File(installTool(tools, "DroidDeck-Proton 11-11.4"), "user_settings.py").writeText("mine") + installTool(tools, "DroidDeck-Proton 11-11.5") + ProtonExtras.tidy(context) + assertEquals(listOf("DroidDeck-Proton 11-11.5"), ProtonExtras.installedAll(context, tool("droiddeck"))) + assertEquals("mine", File(tools, "DroidDeck-Proton 11-11.5/user_settings.py").readText()) + } + + @Test + fun droidDeckProtonIsFetchedWhenMissingUnlessTheUserRemovedIt() { + val context = RuntimeEnvironment.getApplication() + val tools = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/compatibilitytools.d") + tools.deleteRecursively() + File(context.filesDir, "proton-latest.json").writeText( + """{"droiddeck":{"tag":"DroidDeck-Proton-11-11.4","at":1},"ge":{"tag":"GE-Proton11-7","at":1}}""") + ProtonExtras.setDeclined(context, false) + assertEquals("DroidDeck-Proton-11-11.4", ProtonExtras.missing(context, tool("droiddeck"))) + assertNull(ProtonExtras.missing(context, tool("ge"))) + ProtonExtras.setDeclined(context, true) + assertNull(ProtonExtras.missing(context, tool("droiddeck"))) + ProtonExtras.setDeclined(context, false) + installTool(tools, "DroidDeck-Proton 11-11.2") + assertNull(ProtonExtras.missing(context, tool("droiddeck"))) + assertEquals("DroidDeck-Proton-11-11.4", ProtonExtras.update(context, tool("droiddeck"))) + } } diff --git a/app/src/test/java/com/droiddeck/launcher/session/SessionDisplayTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SessionDisplayTest.kt index 48d77d0b0..7962ef6fc 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/SessionDisplayTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/SessionDisplayTest.kt @@ -96,7 +96,10 @@ class SessionDisplayTest { @Test fun gameStretchAvailabilityMatchesTheRuntimeIncludingRounding() { assertTrue(SessionDisplay.canStretch16x9(960 to 720)) assertFalse(SessionDisplay.canStretch16x9(1280 to 720)) - assertFalse(SessionDisplay.canStretch16x9(1600 to 720)) + assertTrue(SessionDisplay.canStretch16x9(1600 to 720)) + assertTrue(SessionDisplay.canStretch16x9(2400 to 1080)) + assertFalse(SessionDisplay.canStretch16x9(1920 to 1080)) + assertFalse(SessionDisplay.canStretch16x9(1366 to 768)) val wide = SessionDisplay.resolve(1440 to 1080, 0, SessionPrefs.SHAPE_WIDE) assertFalse(SessionDisplay.canStretch16x9(wide)) } diff --git a/app/src/test/java/com/droiddeck/launcher/session/SessionLogShareZipTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SessionLogShareZipTest.kt index 95893adde..012d2a916 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/SessionLogShareZipTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/SessionLogShareZipTest.kt @@ -50,6 +50,34 @@ class SessionLogShareZipTest { assertFalse(zip.exists()) } + @Test fun battleNetAndBlizzardLogsComeAlongScrubbedAndNewestFirst() { + val session = File(LinuxRuntime.logDir(app), "2026-10-25-05-battle-net").apply { mkdirs() } + File(session, "session.log").writeText("droiddeck-battlenet: open (from the menu)\n") + val root = LinuxRuntime.rootDir(app) + File(root, "root/.cache/droiddeck/battlenet").apply { mkdirs() }.apply { + resolve("battlenet.log").writeText("launching WoW\n") + resolve("other.log").writeText("other\n") + } + val drive = File(com.droiddeck.launcher.runtime.BattleNet.prefix(app), "pfx/drive_c") + val client = File(drive, "users/steamuser/AppData/Local/Battle.net/Logs").apply { mkdirs() } + val now = System.currentTimeMillis() + for (i in 1..8) client.resolve("battle.net-$i.log").apply { writeText("client $i accountId=1:0:5ca1ab1e\n"); setLastModified(now - i * 60_000L) } + client.resolve("battle.net-old.log").apply { writeText("old\n"); setLastModified(now - 30L * 86_400_000L) } + File(drive, "ProgramData/Battle.net/Agent/Agent.9824/Logs").apply { mkdirs() }.resolve("Agent-1.log").writeText("agent someone@example.com\n") + File(drive, "ProgramData/Battle.net/Agent/Agent.9900/Logs").apply { mkdirs() }.resolve("Agent-1.log").writeText("agent 9900\n") + File(drive, "ProgramData/Battle.net/Setup/bna_2/Logs").apply { mkdirs() }.resolve("battle.net-setup-1.log").writeText("setup\n") + + val zip = SessionLogShare.zipFolder(app, session)!! + val text = ZipFile(zip).use { z -> z.entries().toList().associate { e -> e.name to z.getInputStream(e).bufferedReader().readText() } } + assertTrue(text.keys.containsAll(listOf("battlenet/droiddeck/battlenet.log", "battlenet/agent/Agent.9824/Agent-1.log", "battlenet/agent/Agent.9900/Agent-1.log", "battlenet/setup/bna_2/battle.net-setup-1.log"))) + assertFalse(text.containsKey("battlenet/droiddeck/other.log")) + assertEquals((1..6).map { "battlenet/client/battle.net-$it.log" }.toSet(), text.keys.filter { it.startsWith("battlenet/client/") }.toSet()) + val all = text.values.joinToString("\n") + assertFalse(all.contains("5ca1ab1e")) + assertFalse(all.contains("someone@example.com")) + SessionLogShare.clear(app) + } + @Test fun theSharePassChangesNothingInACleanLine() { val line = "dxvk: Device 0x5 (Adreno 750) https://github.com/doitsujin/dxvk/releases code 403" assertEquals(line, com.droiddeck.launcher.core.LogRedactor.redactForShare(line)) diff --git a/app/src/test/java/com/droiddeck/launcher/session/SessionSleepRequestTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SessionSleepRequestTest.kt new file mode 100644 index 000000000..b136d5fd0 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/SessionSleepRequestTest.kt @@ -0,0 +1,77 @@ +package com.droiddeck.launcher.session + +import android.os.Looper +import java.util.concurrent.AbstractExecutorService +import java.util.concurrent.TimeUnit +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.LooperMode +import org.robolectric.util.ReflectionHelpers + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +@LooperMode(LooperMode.Mode.PAUSED) +class SessionSleepRequestTest { + private lateinit var service: SessionService + private val work = ArrayDeque<Runnable>() + private val executor = object : AbstractExecutorService() { + override fun execute(command: Runnable) { work.addLast(command) } + override fun shutdown() {} + override fun shutdownNow(): MutableList<Runnable> = mutableListOf() + override fun isShutdown() = false + override fun isTerminated() = false + override fun awaitTermination(timeout: Long, unit: TimeUnit) = false + } + + @Before fun setUp() { + service = Robolectric.buildService(SessionService::class.java).get() + SessionState.running = true + SessionState.suspended = false + SessionState.mode = SessionService.MODE_RUN + ReflectionHelpers.setField(service, "suspendPolicy", SessionPrefs.SUSPEND_NEVER) + ReflectionHelpers.setField(service, "suspendController", SessionSuspendController( + sessionRoot = { -1 }, helperRoots = { emptyList() }, + suspendAudio = {}, resumeAudio = {}, executor = executor, + )) + } + + @After fun tearDown() { + SessionState.running = false + SessionState.suspended = false + SessionState.mode = SessionService.MODE_STEAM + shadowOf(Looper.getMainLooper()).idle() + work.clear() + } + + @Test fun cancelledFreezePreservesAndRetriesANewerSteamRequest() { + ReflectionHelpers.setField(service, "steamSleepToken", "old-request") + ReflectionHelpers.callInstanceMethod<Void>(service, "updateSuspendPolicy") + assertEquals(1, work.size) + ReflectionHelpers.setField(service, "freezeCancelled", true) + ReflectionHelpers.setField(service, "steamSleepToken", "new-request") + work.removeFirst().run() + shadowOf(Looper.getMainLooper()).idle() + assertEquals("new-request", ReflectionHelpers.getField<String>(service, "steamSleepToken")) + assertEquals(1, work.size) + assertFalse(ReflectionHelpers.getField(service, "suspendAttemptFailed")) + } + + @Test fun cancelledFreezeCompletesItsOwnRequestWithoutRetryingIt() { + ReflectionHelpers.setField(service, "steamSleepToken", "old-request") + ReflectionHelpers.callInstanceMethod<Void>(service, "updateSuspendPolicy") + ReflectionHelpers.setField(service, "freezeCancelled", true) + work.removeFirst().run() + shadowOf(Looper.getMainLooper()).idle() + assertEquals(null, ReflectionHelpers.getField<String?>(service, "steamSleepToken")) + assertEquals(0, work.size) + assertFalse(ReflectionHelpers.getField(service, "suspendAttemptFailed")) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/SessionSteamLogsTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SessionSteamLogsTest.kt new file mode 100644 index 000000000..60c57ecfd --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/SessionSteamLogsTest.kt @@ -0,0 +1,62 @@ +package com.droiddeck.launcher.session + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class SessionSteamLogsTest { + @get:Rule val tmp = TemporaryFolder() + + private fun names(files: List<File>) = files.map { it.name }.sorted() + + @Test fun onlyWhatTheSessionWroteIsCollected() { + val logs = tmp.newFolder("logs") + val session = tmp.newFolder("session") + val out = tmp.newFolder("out") + File(logs, "console_log.txt").writeText("old line\n") + File(logs, "cef_log.txt").writeText("old cef\n") + File(logs, "cef_log.previous.txt").writeText("older cef\n") + File(logs, "shrunk.txt").writeText("a long old line\n") + File(logs, "untouched.txt").writeText("nothing new\n") + SessionArtifacts.markSteamLogs(session, logs) + + File(logs, "console_log.txt").appendText("new line\n") + File(logs, "cef_log.previous.txt").delete() + File(logs, "cef_log.txt").renameTo(File(logs, "cef_log.previous.txt")) + File(logs, "cef_log.txt").writeText("this session's cef\n") + File(logs, "shrunk.txt").writeText("short\n") + File(logs, "content_log.txt").writeText("created this session\n") + + val copied = SessionArtifacts.copySessionLogs(logs, session, out) + assertEquals(listOf("cef_log.txt", "console_log.txt", "content_log.txt", "shrunk.txt"), names(copied)) + assertEquals("new line\n", File(out, "console_log.txt").readText()) + assertEquals("this session's cef\n", File(out, "cef_log.txt").readText()) + assertEquals("short\n", File(out, "shrunk.txt").readText()) + assertFalse(File(out, "cef_log.previous.txt").exists()) + assertFalse(File(out, "untouched.txt").exists()) + } + + @Test fun aLineCutByTheMarkIsLeftOut() { + val logs = tmp.newFolder("logs") + val session = tmp.newFolder("session") + val out = tmp.newFolder("out") + File(logs, "web.txt").writeText("old\nGET /x?sessionid=") + SessionArtifacts.markSteamLogs(session, logs) + File(logs, "web.txt").appendText("abcdef123456 200\nnext line\n") + SessionArtifacts.copySessionLogs(logs, session, out) + assertEquals("next line\n", File(out, "web.txt").readText()) + } + + @Test fun withoutMarksEveryLogUnderTheCapIsCollectedAndRedacted() { + val logs = tmp.newFolder("logs") + val out = tmp.newFolder("out") + File(logs, "web.txt").writeText("GET /x?sessionid=abcdef123456 200\n") + File(logs, "big.txt").outputStream().use { it.write(ByteArray(8 * 1024 * 1024)) } + val copied = SessionArtifacts.copySessionLogs(logs, tmp.newFolder("session"), out) + assertEquals(listOf("web.txt"), names(copied)) + assertFalse(File(out, "web.txt").readText().contains("abcdef123456")) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/SessionSuspendControllerTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SessionSuspendControllerTest.kt index 61e95da49..02b38143e 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/SessionSuspendControllerTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/SessionSuspendControllerTest.kt @@ -26,6 +26,66 @@ class SessionSuspendControllerTest { } } + @Test fun pageOutRunsInPreloadedGuestsBeforeTheyStop() { + val fixture = fixture(17) + fixture.control.writeText("0") + fixture.controller.freeze(pageOut = true) { assertTrue(it) } + val urgent = fixture.signals.filter { it.signal == URG } + assertEquals(listOf(GAME), urgent.map { it.pid }) + assertTrue(fixture.signals.indexOfFirst { it.signal == URG } < fixture.signals.indexOfFirst { it.signal == STOP }) + assertEquals("1", fixture.control.readText()) + assertEquals(listOf(Triple(1, 1_000_000L, 3_000_000L)), fixture.pagedOut) + assertEquals(0, fixture.unacknowledged) + assertTrue(fixture.game.threads.all { it.state == 't' }) + assertFalse(File(fixture.control.parentFile, ".bl-zram.ack.$GAME").exists()) + } + + @Test fun unchangedSwapIsNotTakenForAFinishedPageOut() { + val fixture = fixture(17) + fixture.control.writeText("0") + fixture.handlerStalls = true + var stoppedEarly = false + fixture.afterSleep = { + if (fixture.now == 3000L) fixture.handlerStalls = false + if (fixture.handlerStalls && fixture.signals.any { it.signal == STOP }) stoppedEarly = true + } + fixture.controller.freeze(pageOut = true) { assertTrue(it) } + assertFalse(stoppedEarly) + assertEquals(0, fixture.unacknowledged) + } + + @Test fun replacedHandlerIsNotWaitedForPastTheStartWindow() { + val fixture = fixture(17) + fixture.control.writeText("0") + fixture.handlerReplaced = true + fixture.controller.freeze(pageOut = true) { assertTrue(it) } + assertEquals(1, fixture.unacknowledged) + assertTrue(fixture.now in 2000L..3000L) + } + + @Test fun cancelledFreezeRollsBackWithoutStopping() { + val fixture = fixture(17) + fixture.control.writeText("0") + fixture.handlerStalls = true + fixture.afterSleep = { if (fixture.now >= 500) fixture.controller.cancelFreeze() } + fixture.controller.freeze(pageOut = true) { assertFalse(it) } + assertFalse(fixture.signals.any { it.signal == STOP }) + assertTrue(fixture.game.threads.all { it.state == 'S' }) + assertEquals(1, fixture.audioResumes) + } + + @Test fun noPageOutWithoutTheRequestOrTheControlFile() { + val visible = fixture(17) + visible.control.writeText("0") + visible.controller.freeze { assertTrue(it) } + assertFalse(visible.signals.any { it.signal == URG }) + assertEquals("0", visible.control.readText()) + val disabled = fixture(17) + disabled.controller.freeze(pageOut = true) { assertTrue(it) } + assertFalse(disabled.signals.any { it.signal == URG }) + assertTrue(disabled.pagedOut.isEmpty()) + } + @Test fun cpuMustBeStableAcrossTwoCompleteSnapshots() { val fixture = fixture(17) var changes = 0 @@ -253,13 +313,13 @@ class SessionSuspendControllerTest { } private fun fixture(count: Int, executor: QueueExecutor = InlineExecutor()) = - Fixture(temporary.newFolder(), count, executor) + Fixture(temporary.newFolder(), temporary.newFolder(), count, executor) private data class Task(val tid: Int, var started: Long, var state: Char, var cpu: Long = 0) private data class Group(val pid: Int, var parent: Int, var tracer: Int, val threads: MutableList<Task>) private data class SentSignal(val pid: Int, val signal: Int) - private class Fixture(val proc: File, count: Int, executor: QueueExecutor) { + private class Fixture(val proc: File, tmp: File, count: Int, executor: QueueExecutor) { val root = Group(ROOT, 9000, 0, mutableListOf(Task(ROOT, 1000, 'S'))) val game = Group(GAME, ROOT, ROOT, MutableList(count) { Task(GAME + it, 2000L + it, 'S') }) val groups = mutableListOf(root, game) @@ -276,10 +336,23 @@ class SessionSuspendControllerTest { var emptyGameTasks = false var rootGone = false var afterSleep: (() -> Unit)? = null + val control = File(tmp, ".bl-zram") + val pagedOut = mutableListOf<Triple<Int, Long, Long>>() + private var gameResidentKb = 4_000_000L + private var gameSwapKb = 0L + private var pagingOut = false + var unacknowledged = -1 + var handlerStalls = false + var handlerReplaced = false private var continuing = false val controller = SessionSuspendController( { rootPid }, { helpers }, { audioSuspends++ }, { audioResumes++ }, proc, ::signal, ::threadSignal, ::sleep, { now }, executor, + zramControl = control, + pagedOut = { processes, unacknowledged, resident, swap, _, _ -> + pagedOut += Triple(processes, resident, swap) + this.unacknowledged = unacknowledged + }, ) init { sync() } @@ -293,6 +366,8 @@ class SessionSuspendControllerTest { else group.threads.firstOrNull { it.state !in listOf('T', 't', 'Z', 'X') && !(neverStop && it == game.threads.last()) }?.state = 't' + } else if (signal == URG) { + pagingOut = true } else if (signal == CONT) { continuing = true if (!(pid == GAME && blockGameContinue)) group.threads.forEach { if (it.state != 'Z') it.state = 'S' } @@ -311,6 +386,18 @@ class SessionSuspendControllerTest { private fun sleep(millis: Long) { now += millis + if (pagingOut && !handlerReplaced) { + File(control.parentFile, "${control.name}.ack.$GAME").apply { if (!exists()) writeText("") } + } + if (pagingOut && !handlerStalls && !handlerReplaced) { + if (gameResidentKb > 1_000_000L) { + gameResidentKb -= 1_000_000L + gameSwapKb += 1_000_000L + } else { + File(control.parentFile, "${control.name}.ack.$GAME").writeText("1") + pagingOut = false + } + } if (root.threads.first().state != 'T' && !continuing) game.threads.forEach { if (it.state != 'Z') { it.state = 'S'; it.cpu++ } } @@ -326,7 +413,11 @@ class SessionSuspendControllerTest { val stat = File(dir, "stat") if (group == game && missingGameStat) stat.delete() else stat.writeText(stat(group, group.threads.first())) - File(dir, "status").writeText("TracerPid: ${group.tracer}\n") + File(dir, "status").writeText( + "TracerPid: ${group.tracer}\n" + + if (group == game) "VmRSS:\t$gameResidentKb kB\nVmSwap:\t$gameSwapKb kB\n" else "", + ) + if (group == game) File(dir, "maps").writeText("7f00000000-7f00001000 r-xp 00000000 fe:05 42 /usr/local/lib/libblsession.so\n") val taskDir = File(dir, "task") if (group == game && missingGameTasks) { taskDir.deleteRecursively(); continue } taskDir.mkdirs() @@ -375,5 +466,6 @@ class SessionSuspendControllerTest { const val GAME = 200 const val STOP = 19 const val CONT = 18 + const val URG = 23 } } diff --git a/app/src/test/java/com/droiddeck/launcher/session/SteamDownloadMonitorTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SteamDownloadMonitorTest.kt index e60c6dcd9..52920aefc 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/SteamDownloadMonitorTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/SteamDownloadMonitorTest.kt @@ -101,6 +101,28 @@ class SteamDownloadMonitorTest { } } + @Test fun aLongLogIsFollowedFromWhereTheLastPollStopped() { + val root = Files.createTempDirectory("droiddeck-steam-download-").toFile() + try { + val contentLog = File(root, "root/.local/share/Steam/logs/content_log.txt").apply { + check(parentFile?.mkdirs() != false) + writeText(buildString { + repeat(5_000) { append("[2026-10-06 14:40:00] Detected write gap 64 MB in file \"data_$it.dat\"\r\n") } + append("[2026-10-06 14:44:17] AppID 44 App update changed : Running Update,Downloading,\r\n") + }) + } + val monitor = SteamDownloadMonitor() + + assertTrue(monitor.poll(root, now = 1_000L)) + contentLog.appendText("[2026-10-06 14:45:03] AppID 44 App update changed : None\r\n") + assertFalse(monitor.poll(root, now = 2_000L)) + contentLog.appendText("[2026-10-06 14:46:00] AppID 45 App update changed : Running Update,Downloading,\r\n") + assertTrue(monitor.poll(root, now = 3_000L)) + } finally { + root.deleteRecursively() + } + } + @Test fun theOtherClientsLogIsReadFromItsStart() { val root = Files.createTempDirectory("droiddeck-steam-download-").toFile() try { @@ -203,6 +225,38 @@ class SteamDownloadMonitorTest { } } + @Test fun theClientIsPendingUntilItIsInstalledAndStamped() { + val root = Files.createTempDirectory("droiddeck-steam-client-").toFile() + try { + val steam = File(root, "root/.local/share/Steam") + assertTrue(SteamDownloadMonitor.clientInstallPending(root)) + File(steam, "steamrtarm64").mkdirs() + File(steam, "steamrtarm64/steam").writeText("client") + assertTrue(SteamDownloadMonitor.clientInstallPending(root)) + File(steam, "package").mkdirs() + File(steam, "package/droiddeck-installed").writeText("1791686101\n") + assertFalse(SteamDownloadMonitor.clientInstallPending(root)) + } finally { + root.deleteRecursively() + } + } + + @Test fun anOlderStampCountsAndARepairRequestIsPending() { + val root = Files.createTempDirectory("droiddeck-steam-client-").toFile() + try { + val steam = File(root, "root/.local/share/Steam") + File(steam, "steamrtarm64").mkdirs() + File(steam, "steamrtarm64/steam").writeText("client") + File(steam, "package").mkdirs() + File(steam, "package/bannerlator-installed").writeText("1\n") + assertFalse(SteamDownloadMonitor.clientInstallPending(root)) + File(root, "root/.bl-steam-repair").writeText("1\n") + assertTrue(SteamDownloadMonitor.clientInstallPending(root)) + } finally { + root.deleteRecursively() + } + } + private fun manifest(root: File, appId: String, downloaded: Long, total: Long, flags: Int): File { val file = File(root, "steamapps/appmanifest_$appId.acf") check(file.parentFile?.mkdirs() != false) diff --git a/app/src/test/java/com/droiddeck/launcher/session/SyncBackendTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SyncBackendTest.kt index 844931bbb..85cf481e9 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/SyncBackendTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/SyncBackendTest.kt @@ -13,9 +13,33 @@ class SyncBackendTest { private val context: Context = RuntimeEnvironment.getApplication() @Test - fun aFreshInstallStartsOnEsync() { - assertEquals(SessionPrefs.SYNC_ESYNC, SessionPrefs.syncBackend(context)) + fun aFreshInstallStartsOnNtsync() { + assertEquals(SessionPrefs.SYNC_NTSYNC, SessionPrefs.syncBackend(context)) assertEquals(false, SessionPrefs.fsyncFirst(context)) + SessionPrefs.applyNtsyncDefault(context) + assertEquals(SessionPrefs.SYNC_NTSYNC, SessionPrefs.syncBackend(context)) + } + + @Test + fun anEarlierChoiceMovesToNtsyncOnce() { + for (earlier in listOf(SessionPrefs.SYNC_ESYNC, SessionPrefs.SYNC_FSYNC, SessionPrefs.SYNC_WINESERVER)) { + context.getSharedPreferences("session", Context.MODE_PRIVATE).edit().clear().commit() + SessionPrefs.setSyncBackend(context, earlier) + SessionPrefs.applyNtsyncDefault(context) + assertEquals(SessionPrefs.SYNC_NTSYNC, SessionPrefs.syncBackend(context)) + } + } + + @Test + fun aChoiceMadeAfterTheMoveIsKept() { + SessionPrefs.setSyncBackend(context, SessionPrefs.SYNC_ESYNC) + SessionPrefs.applyNtsyncDefault(context) + SessionPrefs.setSyncBackend(context, SessionPrefs.SYNC_ESYNC) + SessionPrefs.applyNtsyncDefault(context) + assertEquals(SessionPrefs.SYNC_ESYNC, SessionPrefs.syncBackend(context)) + SessionPrefs.setSyncBackend(context, SessionPrefs.SYNC_WINESERVER) + SessionPrefs.applyNtsyncDefault(context) + assertEquals(SessionPrefs.SYNC_WINESERVER, SessionPrefs.syncBackend(context)) } @Test @@ -34,6 +58,7 @@ class SyncBackendTest { @Test fun pickingATabTurnsSyncBackOn() { + SessionPrefs.setFastSync(context, false) SessionPrefs.setSyncFallback(context, false) SessionPrefs.setFsyncFirst(context, false) assertEquals(SessionPrefs.SYNC_WINESERVER, SessionPrefs.syncBackend(context)) diff --git a/app/src/test/java/com/droiddeck/launcher/session/TpUbwcPrefsTest.kt b/app/src/test/java/com/droiddeck/launcher/session/TpUbwcPrefsTest.kt new file mode 100644 index 000000000..2315fada3 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/TpUbwcPrefsTest.kt @@ -0,0 +1,55 @@ +package com.droiddeck.launcher.session + +import android.content.Context +import com.droiddeck.launcher.gpu.TpUbwcHint +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.robolectric.util.ReflectionHelpers + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class TpUbwcPrefsTest { + private val context: Context get() = RuntimeEnvironment.getApplication() + + @Before fun reset() { + ReflectionHelpers.setStaticField(TpUbwcHint::class.java, "resolved", false) + ReflectionHelpers.setStaticField(TpUbwcHint::class.java, "cached", null) + ReflectionHelpers.setStaticField(TpUbwcHint::class.java, "activeMode", null) + context.getSharedPreferences("session", Context.MODE_PRIVATE).edit().clear().commit() + } + + @Test fun missingOrInvalidChoiceUsesAuto() { + assertEquals(SessionPrefs.TP_UBWC_AUTO, SessionPrefs.tpUbwcMode(context)) + context.getSharedPreferences("session", Context.MODE_PRIVATE).edit().putString("tpUbwcMode", "invalid").commit() + assertEquals(SessionPrefs.TP_UBWC_AUTO, SessionPrefs.tpUbwcMode(context)) + } + + @Test fun overrideIsStoredWithoutChangingDriverChoices() { + SessionPrefs.setAndroidDriver(context, "display") + SessionPrefs.setLinuxDriver(context, "runtime") + for (mode in listOf(SessionPrefs.TP_UBWC_ON, SessionPrefs.TP_UBWC_OFF, SessionPrefs.TP_UBWC_AUTO)) { + SessionPrefs.setTpUbwcMode(context, mode) + assertEquals(mode, SessionPrefs.tpUbwcMode(context)) + assertEquals("display", SessionPrefs.androidDriver(context)) + assertEquals("runtime", SessionPrefs.linuxDriver(context)) + } + } + + @Test fun changingTheSavedChoiceKeepsTheProcessChoiceUntilRestart() { + SessionPrefs.setTpUbwcMode(context, SessionPrefs.TP_UBWC_ON) + val initial = TpUbwcHint.forThisDevice(context) + SessionPrefs.setTpUbwcMode(context, SessionPrefs.TP_UBWC_OFF) + assertTrue(TpUbwcHint.restartRequired(context)) + assertEquals(SessionPrefs.TP_UBWC_ON, TpUbwcHint.activeMode) + assertEquals(initial, TpUbwcHint.forThisDevice(context)) + SessionPrefs.setTpUbwcMode(context, SessionPrefs.TP_UBWC_ON) + assertFalse(TpUbwcHint.restartRequired(context)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/WinComponentsSupportTest.kt b/app/src/test/java/com/droiddeck/launcher/session/WinComponentsSupportTest.kt index 656c56c9a..831173699 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/WinComponentsSupportTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/WinComponentsSupportTest.kt @@ -31,6 +31,14 @@ class WinComponentsSupportTest { step("install_msi", "url" to "$base/ps-x86.msi"), step("install_msi", "url" to "$base/ps-x64.msi")))) } + @Test fun builtInArm64RuntimeIsRegistryOnlyInBothViews() { + val c = WinComponents.builtInArm64Runtime() + assertEquals(Support.READY, support(c)) + assertEquals(12, c.steps.count { it.action == "set_register_key" }) + assertEquals(setOf("HKLM\\Software\\Microsoft\\VisualStudio\\14.0\\VC\\Runtimes\\arm64", + "HKLM\\Software\\Wow6432Node\\Microsoft\\VisualStudio\\14.0\\VC\\Runtimes\\arm64"), c.steps.map { it.str("key") }.toSet()) + } + @Test fun installerWrappersInstallHere() { assertEquals(Support.READY, support(component("vcredist2022", step("install_exe", "url" to "$base/vcredist2022__VC_redist.x86.exe", "file_name" to "VC_redist.x86.exe"), diff --git a/app/src/test/java/com/droiddeck/launcher/session/WineProcessesTest.kt b/app/src/test/java/com/droiddeck/launcher/session/WineProcessesTest.kt new file mode 100644 index 000000000..ee591d713 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/WineProcessesTest.kt @@ -0,0 +1,23 @@ +package com.droiddeck.launcher.session + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class WineProcessesTest { + private fun argv(vararg args: String) = args.joinToString("\u0000", postfix = "\u0000").toByteArray() + + @Test fun aWineserverIsKnownByItsProgramOrItsName() { + assertTrue(WineProcesses.isWineserver(argv("/root/.local/share/droiddeck-esync/dist/valve-11/files/bin-arm64/wineserver"), "wineserver\n")) + assertTrue(WineProcesses.isWineserver(argv("wineserver", "-p0"), null)) + assertTrue(WineProcesses.isWineserver(null, "wineserver\n")) + assertTrue(WineProcesses.isWineserver(ByteArray(0), "wineserver")) + } + + @Test fun nothingElseIs() { + assertFalse(WineProcesses.isWineserver(argv("C:\\windows\\system32\\services.exe"), "services.exe\n")) + assertFalse(WineProcesses.isWineserver(argv("/usr/bin/python3", "/x/files/bin-arm64/wineserver"), "python3\n")) + assertFalse(WineProcesses.isWineserver(argv("/opt/wineserver-helper"), "wineserver-help")) + assertFalse(WineProcesses.isWineserver(null, null)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/storage/SteamStorageTest.kt b/app/src/test/java/com/droiddeck/launcher/storage/SteamStorageTest.kt index 794e01d44..0cfbf2e05 100644 --- a/app/src/test/java/com/droiddeck/launcher/storage/SteamStorageTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/storage/SteamStorageTest.kt @@ -92,6 +92,30 @@ class SteamStorageTest { assertEquals(emptyMap<String, String>(), SteamStorage.compatMappings("\"x\" {}")) } + @Test fun readsShaderPrecacheFromConfig() { + val vdf = """ + "InstallConfigStore" + { + "Software" { "Valve" { "Steam" { "ShaderCacheManager" + { + "HasCurrentBucket" "1" + "DisableShaderCache" "1" + } } } } + } + """.trimIndent() + assertEquals(false, SteamStorage.shaderPrecache(vdf)) + assertEquals(true, SteamStorage.shaderPrecache(vdf.replace("\"DisableShaderCache\"\t\t\"1\"", "\"DisableShaderCache\"\t\t\"0\""))) + assertEquals(true, SteamStorage.shaderPrecache("\"InstallConfigStore\" {}")) + assertEquals(true, SteamStorage.shaderPrecache(""" + "InstallConfigStore" + { + // "DisableShaderCache" "1" + "Other" { "DisableShaderCache" "1" } + "Software" { "Valve" { "Steam" { "ShaderCacheManager" { "DisableShaderCache" "0" } } } } + } + """.trimIndent())) + } + @Test fun readsCustomToolName() { val vdf = "\"compatibilitytools\"\n{\n \"compat_tools\"\n {\n \"GE-Proton11-9-aarch64\"\n {\n \"install_path\" \".\"\n }\n }\n}" assertEquals("GE-Proton11-9-aarch64", SteamStorage.compatToolName(vdf)) diff --git a/app/src/test/java/com/droiddeck/launcher/stores/CloudSavePathsTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/CloudSavePathsTest.kt index a88c8b954..675c1e009 100644 --- a/app/src/test/java/com/droiddeck/launcher/stores/CloudSavePathsTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/stores/CloudSavePathsTest.kt @@ -47,4 +47,34 @@ class CloudSavePathsTest { File(profile, "AppData/Local/My-Game").mkdirs() assertEquals(File(profile, "AppData/Local/My-Game").path, CloudSavePaths.epic("{AppData}/MyGame", prefix, install, "acc", "app")!!.path) } + + @Test fun gogGalaxySdkEmptyLocationsUseDefaultLocation() { + val loc = CloudSavePaths.gogSaveFromRemote(true, emptyList(), "1")!! + assertEquals( + "%LocalAppData%/GOG.com/Galaxy/Applications/1/Storage/Shared/Files", + loc.template, + ) + assertEquals(CloudSavePaths.GOG_DEFAULT_NAMESPACE, loc.name) + val dir = CloudSavePaths.gog(loc.template, prefix, install) + assertEquals( + File(profile, "AppData/Local/GOG.com/Galaxy/Applications/1/Storage/Shared/Files").path, + dir!!.path, + ) + } + + @Test fun gogCloudDisabledOrUnknownClientIsNone() { + assertNull(CloudSavePaths.gogSaveFromRemote(false, emptyList(), "1")) + assertNull(CloudSavePaths.gogSaveFromRemote(true, emptyList(), "")) + assertNull(CloudSavePaths.gogSaveFromRemote(true, listOf("saves" to ""), "")) + } + + @Test fun gogNamedLocationKeepsItsNameAndPreferSaves() { + val loc = CloudSavePaths.gogSaveFromRemote( + true, + listOf("other" to "<?SAVED_GAMES?>/A", "Saves" to "<?APPLICATION_DATA_LOCAL_LOW?>/Hyperstrange/ELDERBORN"), + "1", + )!! + assertEquals("<?APPLICATION_DATA_LOCAL_LOW?>/Hyperstrange/ELDERBORN", loc.template) + assertEquals("Saves", loc.name) + } } diff --git a/app/src/test/java/com/droiddeck/launcher/stores/CloudSavesMigrationTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/CloudSavesMigrationTest.kt new file mode 100644 index 000000000..ea4e953e0 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/stores/CloudSavesMigrationTest.kt @@ -0,0 +1,76 @@ +package com.droiddeck.launcher.stores + +import java.io.File +import java.nio.file.Files +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class CloudSavesMigrationTest { + private val dir = Files.createTempDirectory("cloud-migrate").toFile() + private fun entry(bytes: ByteArray) = CloudPlan.Entry(CloudSaves.md5(bytes), null, 1000L) + + private fun nested(name: String, path: String, text: String): File = + File(dir, "$name/$path").apply { parentFile?.mkdirs(); writeText(text) } + + @Test fun movesOnlyFilesTheOldBaselineOwns() { + val a = "old save".toByteArray() + val game = "game wrote this".toByteArray() + nested("saves", "a.dat", String(a)) + nested("saves", "game.dat", String(game)) + val base = mapOf("saves/a.dat" to entry(a)) + val result = CloudSaves.migrateNested(dir, "saves", base)!! + assertEquals(1, result.moved) + assertEquals(0, result.cleaned) + assertEquals(String(a), File(dir, "a.dat").readText()) + assertEquals(String(game), File(dir, "saves/game.dat").readText()) + assertTrue("saves/a.dat" !in result.baseline) + assertEquals(entry(a), result.baseline["a.dat"]) + } + + @Test fun anIdenticalLeftoverIsRemovedAndItsBaselineKeyRewritten() { + val a = "old save".toByteArray() + File(dir, "a.dat").writeText(String(a)) + nested("saves", "a.dat", String(a)) + val result = CloudSaves.migrateNested(dir, "saves", mapOf("saves/a.dat" to entry(a)))!! + assertEquals(0, result.moved) + assertEquals(1, result.cleaned) + assertFalse(File(dir, "saves").exists()) + assertEquals(entry(a), result.baseline["a.dat"]) + assertTrue("saves/a.dat" !in result.baseline) + } + + @Test fun aDifferentDestinationKeepsTheGameFileAndTheBaseline() { + val a = "old save".toByteArray() + File(dir, "a.dat").writeText("newer game save") + nested("saves", "a.dat", String(a)) + val base = mapOf("saves/a.dat" to entry(a)) + val result = CloudSaves.migrateNested(dir, "saves", base)!! + assertEquals(0, result.moved) + assertEquals(0, result.cleaned) + assertEquals("newer game save", File(dir, "a.dat").readText()) + assertEquals(String(a), File(dir, "saves/a.dat").readText()) + assertEquals(base, result.baseline) + } + + @Test fun aSymlinkedAncestorIsNotMigrated() { + val outside = Files.createTempDirectory("cloud-outside").toFile() + File(outside, "a.dat").writeText("outside") + File(dir, "saves").mkdirs() + Files.createSymbolicLink(File(dir, "saves/link").toPath(), outside.toPath()) + val result = CloudSaves.migrateNested(dir, "saves", + mapOf("saves/link/a.dat" to entry("outside".toByteArray())))!! + assertEquals(0, result.moved) + assertEquals(0, result.cleaned) + assertTrue(File(outside, "a.dat").isFile) + } + + @Test fun aLocationNameThatCanLeaveTheFolderIsRefused() { + nested("saves", "a.dat", "x") + assertNull(CloudSaves.migrateNested(dir, "../saves", emptyMap())) + assertNull(CloudSaves.migrateNested(dir, "..", emptyMap())) + assertNull(CloudSaves.migrateNested(dir, "saves/..", emptyMap())) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/stores/CloudSavesTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/CloudSavesTest.kt index 0c3044743..0b8003262 100644 --- a/app/src/test/java/com/droiddeck/launcher/stores/CloudSavesTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/stores/CloudSavesTest.kt @@ -4,6 +4,7 @@ import java.io.File import java.util.concurrent.Executor import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith @@ -77,12 +78,22 @@ class CloudSavesTest { assertTrue(CloudSaves.dirty(app).contains(Store.GOG to gameId)) CloudSaves.uploadAllDirty(app, "recovery", running = true) assertTrue(CloudSaves.dirty(app).contains(Store.GOG to gameId)) - com.droiddeck.launcher.stores.gog.GogPrefs.get(app).edit().putString("cloud_template_$gameId", "").apply() + com.droiddeck.launcher.stores.gog.GogPrefs.get(app).edit() + .putString("cloud_template_$gameId", "none") + .putLong("cloud_template_at_$gameId", System.currentTimeMillis()) + .apply() assertEquals("no-cloud-saves", CloudSaves.uploadDirty(app, Store.GOG, gameId, "exit")!!.reason) assertTrue(CloudSaves.dirty(app).none { it.second == gameId }) assertEquals(null, CloudSaves.uploadDirty(app, Store.GOG, gameId, "session-end")) } + @Test fun aNegativeCloudTemplateIsRetriedAfterADay() { + val now = 1_000_000L + assertTrue(CloudSaves.noneTemplateIsCurrent(now, now + 60_000L)) + assertFalse(CloudSaves.noneTemplateIsCurrent(now, now + CloudSaves.TEMPLATE_NONE_TTL_MS)) + assertFalse(CloudSaves.noneTemplateIsCurrent(0L, now)) + } + @Test fun aFirstLaunchWithoutAPrefixIsDeferredNotBlocked() { val deferred = ArrayList<Runnable>() val folder = File(StoreInstallRoot.storeDir(StoreInstallRoot.internalRoot(app), Store.GOG), "ELDERBORN").apply { mkdirs() } diff --git a/app/src/test/java/com/droiddeck/launcher/stores/GogCloudKeysTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/GogCloudKeysTest.kt new file mode 100644 index 000000000..df7ba8f2b --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/stores/GogCloudKeysTest.kt @@ -0,0 +1,61 @@ +package com.droiddeck.launcher.stores + +import java.io.ByteArrayOutputStream +import java.util.zip.GZIPOutputStream +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class GogCloudKeysTest { + @Test fun defaultLocationIsStrippedLikeAnyNamedLocation() { + val files = listOf(CloudFile("__default/savegame0.sav", 1L), CloudFile("saves/slot0.sav", 2L)) + val (chosen, relative) = gogRelativeNames(files, "__default") + assertEquals("__default", chosen) + assertEquals(listOf("savegame0.sav"), relative.map { it.name }) + } + + @Test fun gzipGogPutMatchesPythonGzipCompressLevel6Mtime0() { + val raw = "savegame0".toByteArray() + val expected = "1f8b08000000000000032b4e2c4b4d4fcc4d35000065fc333209000000".hex() + assertArrayEquals(expected, gzipGogPut(raw)) + assertArrayEquals(raw, gunzip(expected)) + } + + @Test fun contentEncodingGzipIsPeeledOnce() { + val raw = "savegame0".toByteArray() + val gz = ByteArrayOutputStream().also { GZIPOutputStream(it).use { z -> z.write(raw) } }.toByteArray() + assertArrayEquals(raw, gogDecodeCloudBody(gz, "gzip")) + assertArrayEquals(raw, gogDecodeCloudBody(raw, "gzip")) + } + + @Test fun corruptDeclaredGzipIsRejected() { + val corrupt = gzipGogPut("savegame0".toByteArray()).dropLast(2).toByteArray() + assertTrue(isGzip(corrupt)) + assertTrue(runCatching { gogDecodeCloudBody(corrupt, "gzip") }.isFailure) + } + + @Test fun isoDateHonorsOffsetAndFractionalSeconds() { + val expected = java.time.Instant.parse("2024-01-02T01:04:05.123Z").toEpochMilli() + assertEquals(expected, isoDate("2024-01-02T03:04:05.123+02:00")) + assertEquals(expected, isoDate("2024-01-02T01:04:05.123")) + } + + @Test fun gogPutWrappedBodyWithoutEncodingIsPeeled() { + val raw = "savegame0".toByteArray() + assertArrayEquals(raw, gogDecodeCloudBody(gzipGogPut(raw), null)) + assertArrayEquals(raw, gogDecodeCloudBody(raw, null)) + } + + @Test fun gzipThatIsTheSaveIsLeftIntact() { + val raw = "savegame0".toByteArray() + val gameGz = ByteArrayOutputStream().also { GZIPOutputStream(it).use { z -> z.write(raw) } }.toByteArray() + assertTrue(isGzip(gameGz)) + assertFalse(gzipGogPut(raw).contentEquals(gameGz)) + assertArrayEquals(gameGz, gogDecodeCloudBody(gameGz, null)) + } +} + +private fun String.hex() = chunked(2).map { it.toInt(16).toByte() }.toByteArray() diff --git a/app/src/test/java/com/droiddeck/launcher/stores/GogCloudNamesTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/GogCloudNamesTest.kt new file mode 100644 index 000000000..33959c731 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/stores/GogCloudNamesTest.kt @@ -0,0 +1,52 @@ +package com.droiddeck.launcher.stores + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class GogCloudNamesTest { + private fun files(vararg names: String) = names.map { CloudFile(it, 1000L) } + + @Test fun silksongLocationIsDroppedFromThePath() { + val (location, mapped) = gogRelativeNames( + files("saves/user1.dat", "saves/shared.dat", "saves/Restore_Points1/restoreData2.dat"), "saves") + assertEquals("saves", location) + assertEquals(listOf("user1.dat", "shared.dat", "Restore_Points1/restoreData2.dat"), mapped.map { it.name }) + } + + @Test fun anotherLocationIsFilteredOut() { + val (_, mapped) = gogRelativeNames(files("saves/a.dat", "__default/settings.ini"), "saves") + assertEquals(listOf("a.dat"), mapped.map { it.name }) + } + + @Test fun theOnlyLocationIsInferredWhenItsNameWasNotCached() { + val (location, mapped) = gogRelativeNames(files("saves/a.dat", "saves/b.dat"), null) + assertEquals("saves", location) + assertEquals(listOf("a.dat", "b.dat"), mapped.map { it.name }) + } + + @Test fun namesWithoutALocationPrefixAreLeftAlone() { + val (location, mapped) = gogRelativeNames(files("a.dat", "b.dat"), null) + assertNull(location) + assertEquals(listOf("a.dat", "b.dat"), mapped.map { it.name }) + } + + @Test fun aConfiguredLocationDoesNotYieldToAnotherLocation() { + val (location, mapped) = gogRelativeNames(files("__default/settings.ini"), "saves") + assertEquals("saves", location) + assertTrue(mapped.isEmpty()) + } + + @Test fun aMixedListingWithOneSubfolderIsLeftAlone() { + val (location, mapped) = gogRelativeNames(files("a.dat", "sub/b.dat"), "saves") + assertNull(location) + assertEquals(listOf("a.dat", "sub/b.dat"), mapped.map { it.name }) + } + + @Test fun aMissingCachedLocationDoesNotHideUnprefixedNames() { + val (location, mapped) = gogRelativeNames(files("a.dat"), "saves") + assertNull(location) + assertEquals(listOf("a.dat"), mapped.map { it.name }) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/stores/SortCatalogItemsTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/SortCatalogItemsTest.kt index d0e7ef94f..8a315f3a1 100644 --- a/app/src/test/java/com/droiddeck/launcher/stores/SortCatalogItemsTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/stores/SortCatalogItemsTest.kt @@ -7,6 +7,13 @@ class SortCatalogItemsTest { private fun item(id: String, title: String, size: Long = 0, releaseDate: String = "") = CatalogItem(Store.GOG, id, title, imageUrl = null, sizeBytes = size, releaseDate = releaseDate, owned = true) + private fun sortCatalogItems( + items: List<CatalogItem>, sort: String, + lastPlayed: (CatalogItem) -> Long = { 0L }, installedAt: (CatalogItem) -> Long = { 0L }, + ) = sortGames(items, sort, { it.title }, { it.releaseDate }, lastPlayed, installedAt, { it.sizeBytes }) + + private fun retainCatalogOrder(items: List<CatalogItem>, previous: List<CatalogItem>) = retainOrder(items, previous) { it.key } + @Test fun titleOrderIsCaseInsensitiveAndDescendingIsItsMirror() { val items = listOf(item("1", "Zelda"), item("2", "alpha"), item("3", "Beta")) assertEquals(listOf("alpha", "Beta", "Zelda"), sortCatalogItems(items, "titleAsc").map { it.title }) diff --git a/app/src/test/java/com/droiddeck/launcher/stores/StoreAccountsCipherTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/StoreAccountsCipherTest.kt index e0c657a14..0aeb49bb3 100644 --- a/app/src/test/java/com/droiddeck/launcher/stores/StoreAccountsCipherTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/stores/StoreAccountsCipherTest.kt @@ -131,6 +131,25 @@ class StoreAccountsCipherTest { assertEquals("test@example.test", StoreAccounts.signedInAs(app, Store.XBOX)) } + @Test fun xboxShowsTheGamertagElseAMaskedEmail() { + StoreAccounts.write(app, Store.XBOX, JSONObject().put("username", "player@example.test")) + assertEquals("p•••@example.test", StoreAccounts.displayName(app, Store.XBOX)) + StoreAccounts.write(app, Store.XBOX, JSONObject().put("username", "player@example.test").put("gamertag", "Some Gamertag")) + assertEquals("Some Gamertag", StoreAccounts.displayName(app, Store.XBOX)) + assertEquals("player@example.test", StoreAccounts.signedInAs(app, Store.XBOX)) + StoreAccounts.write(app, Store.EPIC, creds) + assertEquals("Someone", StoreAccounts.displayName(app, Store.EPIC)) + assertEquals("n•••", StoreAccounts.maskEmail("nickname")) + } + + @Test fun xboxDisplayNameDoesNotRevealEmailWhenCredentialsAreTemporarilyUnavailable() { + StoreAccounts.write(app, Store.XBOX, JSONObject().put("username", "player@example.test")) + StoreAccounts.keys = Flaky(key, 1) { java.security.ProviderException("busy") } + assertEquals(Store.XBOX.label, StoreAccounts.displayName(app, Store.XBOX)) + assertTrue(StoreAccounts.isUnavailable(Store.XBOX)) + assertTrue(file(Store.XBOX).exists()) + } + @Test fun signOutRemovesTheFile() { StoreAccounts.write(app, Store.GOG, creds) StoreAccounts.clear(app, Store.GOG) @@ -182,4 +201,20 @@ class StoreAccountsCipherTest { assertFalse(StoreAccounts.isUnavailable(Store.AMAZON)) assertFalse(file(Store.AMAZON).exists()) } + + @Test + fun teeEnvelopesUseTheCurrentKeyAndOlderOnesTheLegacyKey() { + val legacy = SecretKeySpec(ByteArray(32) { 7 }, "AES") + val keys = object : CredentialCipher.KeyProvider { + override fun key(): SecretKey = key + override fun legacy(): SecretKey = legacy + override val version: Int get() = CredentialCipher.VERSION_TEE + } + val old = CredentialCipher.seal("old", CredentialCipher.KeyProvider { legacy }) + assertEquals(CredentialCipher.VERSION, old.getInt("v")) + assertEquals("old", CredentialCipher.open(old, keys)) + val new = CredentialCipher.seal("new", keys) + assertEquals(CredentialCipher.VERSION_TEE, new.getInt("v")) + assertEquals("new", CredentialCipher.open(new, keys)) + } } diff --git a/app/src/test/java/com/droiddeck/launcher/stores/StoreGameSidecarTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/StoreGameSidecarTest.kt index 213cbf9a1..b7ad90b56 100644 --- a/app/src/test/java/com/droiddeck/launcher/stores/StoreGameSidecarTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/stores/StoreGameSidecarTest.kt @@ -34,6 +34,17 @@ class StoreGameSidecarTest { assertEquals("ns", back.extra["namespace"]) } + @Test fun theStoreLogoRoundTripsAndOlderFilesHaveNone() { + val withLogo = sample.copy(logo = "https://x/logo.png") + val back = StoreGameSidecar.parse(withLogo.toJson().toString())!! + assertEquals("https://x/logo.png", back.logo) + assertEquals("https://x/logo.png", back.copy(state = StoreGameSidecar.STATE_UPDATING).logo) + assertFalse(sample.toJson().has("logo")) + assertNull(StoreGameSidecar.parse(sample.toJson().toString())!!.logo) + assertNull(StoreGameSidecar.parse("""{"store":"gog","id":"1","title":"T","exe":"game.exe","cover":"c","hero":"h"}""")!!.logo) + assertNull(StoreGameSidecar.parse("""{"store":"gog","id":"1","title":"T","exe":"game.exe","logo":""}""")!!.logo) + } + @Test fun defaultsWhenFieldsAreAbsent() { val back = StoreGameSidecar.parse("""{"store":"gog","id":"1","title":"T","exe":"bin\\game.exe","addToSteam":false}""")!! assertEquals("bin/game.exe", back.exe) diff --git a/app/src/test/java/com/droiddeck/launcher/stores/StoreInstallRootTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/StoreInstallRootTest.kt index c40ef5b20..126001797 100644 --- a/app/src/test/java/com/droiddeck/launcher/stores/StoreInstallRootTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/stores/StoreInstallRootTest.kt @@ -5,7 +5,10 @@ import com.droiddeck.launcher.session.SessionPrefs import java.io.File import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue +import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -18,6 +21,12 @@ import org.robolectric.shadows.ShadowStatFs class StoreInstallRootTest { private val app get() = RuntimeEnvironment.getApplication() + @Before fun resetStoragePreferences() { + SessionPrefs.setDefaultStorageFolder(app, "", "") + SessionPrefs.setDefaultStorage(app, SessionPrefs.DEFAULT_STORAGE_ASK) + SessionPrefs.setGameStorage(app, SessionPrefs.GAME_STORAGE_OFF, "") + } + @Test fun theDefaultRootIsInternalWhateverTheSteamLibrarySettingSays() { val card = File(app.filesDir, "fake-card/steam").apply { mkdirs() } SessionPrefs.setGameStorage(app, card.path, "Card") @@ -26,6 +35,72 @@ class StoreInstallRootTest { assertFalse(StoreInstallRoot.targets(app).first().removable) } + @Test fun defaultStoragePicksTheInternalOrTheConfiguredExternalTarget() { + val library = File(app.filesDir, "default-card/steam").apply { mkdirs() } + SessionPrefs.setGameStorage(app, library.path, "Card") + val external = File(library, "Games") + + SessionPrefs.setDefaultStorage(app, SessionPrefs.DEFAULT_STORAGE_INTERNAL) + assertEquals(StoreInstallRoot.internalRoot(app).absolutePath, StoreInstallRoot.defaultTarget(app)?.root?.absolutePath) + + SessionPrefs.setDefaultStorage(app, SessionPrefs.DEFAULT_STORAGE_EXTERNAL) + assertEquals(external.absolutePath, StoreInstallRoot.defaultTarget(app)?.root?.absolutePath) + + SessionPrefs.setDefaultStorage(app, SessionPrefs.DEFAULT_STORAGE_ASK) + assertNull(StoreInstallRoot.defaultTarget(app)) + + SessionPrefs.setDefaultStorage(app, "bogus") + assertEquals(SessionPrefs.DEFAULT_STORAGE_ASK, SessionPrefs.defaultStorage(app)) + } + + @Test fun defaultExternalStorageFallsBackToTheInstallDialogWhenNoCardIsPresent() { + SessionPrefs.setGameStorage(app, SessionPrefs.GAME_STORAGE_OFF, "") + SessionPrefs.setDefaultStorage(app, SessionPrefs.DEFAULT_STORAGE_EXTERNAL) + assertNull(StoreInstallRoot.defaultTarget(app)) + } + + @Test fun aChosenFolderIsUsedForDefaultExternalStorage() { + val folder = File(app.filesDir, "usb/SSD").apply { mkdirs() } + assertNull(StoreInstallRoot.prepareDefaultFolder(app, folder)) + val root = File(folder, "Games") + assertTrue(root.isDirectory) + SessionPrefs.setDefaultStorageFolder(app, folder.path, "SSD") + SessionPrefs.setDefaultStorage(app, SessionPrefs.DEFAULT_STORAGE_EXTERNAL) + + assertEquals(root.absolutePath, StoreInstallRoot.defaultTarget(app)?.root?.absolutePath) + assertTrue(StoreInstallRoot.targets(app).any { it.root.absolutePath == root.absolutePath && it.folder }) + assertTrue(StoreInstallRoot.roots(app).any { it.absolutePath == root.absolutePath }) + assertTrue(StoreInstallRoot.externalRoots(app).any { it.first.absolutePath == root.absolutePath }) + assertFalse(File(root, ".droiddeck-write-probe").exists()) + } + + @Test fun aDisconnectedChosenFolderDoesNotFallBackToAnotherTarget() { + val card = File(app.filesDir, "card/steam").apply { mkdirs() } + SessionPrefs.setGameStorage(app, card.path, "Card") + val folder = File(app.filesDir, "usb/SSD").apply { mkdirs() } + SessionPrefs.setDefaultStorageFolder(app, folder.path, "SSD") + SessionPrefs.setDefaultStorage(app, SessionPrefs.DEFAULT_STORAGE_EXTERNAL) + assertNotNull(StoreInstallRoot.defaultTarget(app)) + + assertTrue(folder.delete()) + assertNull(StoreInstallRoot.defaultTarget(app)) + assertEquals(File(card, "Games").absolutePath, StoreInstallRoot.detectedExternalTarget(app)?.root?.absolutePath) + } + + @Test fun filesOnAChosenFolderAreMappedIntoTheGuest() { + val folder = File(app.filesDir, "usb/SSD").apply { mkdirs() } + SessionPrefs.setDefaultStorageFolder(app, folder.path, "SSD") + val game = File(folder, "Games/GOG/Test/game.exe").apply { + parentFile?.mkdirs() + writeText("MZ") + } + + val guest = StoreInstallRoot.externalGuestPath(app, game) + assertNotNull(guest) + assertTrue(guest!!.startsWith(StoreInstallRoot.GUEST_EXTERNAL + "/")) + assertTrue(guest.endsWith("/GOG/Test/game.exe")) + } + @Test fun theSteamLibraryFolderIsOfferedAsAnInstallTarget() { val library = File(app.filesDir, "Documents/Games").apply { mkdirs() } SessionPrefs.setGameStorage(app, library.path, "Games") @@ -38,6 +113,43 @@ class StoreInstallRootTest { assertEquals(1, StoreInstallRoot.targets(app).size) } + @Test fun externalStoreInstallPathsMapIntoTheirGuestMountWithoutPrefixCollisions() { + val cardRoot = File(app.filesDir, "card/Games").apply { mkdirs() } + val guestRoot = "/mnt/droiddeck-stores/card" + val roots = listOf(cardRoot to guestRoot) + + assertEquals("$guestRoot/Stores/Xbox/Doom", StoreInstallRoot.externalGuestPath( + File(cardRoot, "Stores/Xbox/Doom"), roots)) + assertEquals(null, StoreInstallRoot.externalGuestPath( + File(cardRoot.parentFile, "Games-old/Stores/Xbox/Doom"), roots)) + } + + @Test fun xodusMapsTheSelectedSteamLibraryOnAnExternalVolume() { + val library = File(app.filesDir, "card/steam").apply { mkdirs() } + val storeRoot = File(library, "Games").apply { mkdirs() } + SessionPrefs.setGameStorage(app, library.path, "Card") + + val xodusRoot = StoreInstallRoot.externalRootsForXodus(app).single { it.first.absolutePath == storeRoot.absolutePath } + val gameFolder = File(storeRoot, "Xbox/Doom") + assertEquals("${xodusRoot.second}/Xbox/Doom", StoreInstallRoot.guestPathForXodus(app, gameFolder)) + + val bindSpecs = StoreInstallRoot.externalBindSpecsForXodus(app) + assertTrue(bindSpecs.contains("${storeRoot.path}:${xodusRoot.second}")) + assertTrue(File(LinuxRuntime.rootDir(app), xodusRoot.second.removePrefix("/")).isDirectory) + } + + @Test fun externalStoreBindsCreateGuestTargetsAndSkipUnavailableVolumes() { + val host = File(app.filesDir, "card/Games").apply { mkdirs() } + val guestRoot = "/mnt/droiddeck-stores/card" + val guestFs = File(app.filesDir, "linuxfs").apply { mkdirs() } + val missing = File(app.filesDir, "missing/Games") + + assertEquals(listOf("${host.path}:$guestRoot"), StoreInstallRoot.externalBindSpecs( + listOf(host to guestRoot, missing to "/mnt/droiddeck-stores/missing"), guestFs)) + assertTrue(File(guestFs, "mnt/droiddeck-stores/card").isDirectory) + assertFalse(File(guestFs, "mnt/droiddeck-stores/missing").exists()) + } + @Test fun anExistingInstallWinsOverAFreshFolderUnderTheChosenRoot() { SessionPrefs.setGameStorage(app, SessionPrefs.GAME_STORAGE_OFF, "") val existing = File(StoreInstallRoot.storeDir(StoreInstallRoot.internalRoot(app), Store.GOG), "Old Name").apply { mkdirs() } diff --git a/app/src/test/java/com/droiddeck/launcher/stores/StoreLogoTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/StoreLogoTest.kt new file mode 100644 index 000000000..71a6c1d8e --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/stores/StoreLogoTest.kt @@ -0,0 +1,88 @@ +package com.droiddeck.launcher.stores + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Color +import com.droiddeck.launcher.stores.amazon.AmazonLibrary +import com.droiddeck.launcher.stores.gog.GogBackend +import org.json.JSONObject +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import java.io.ByteArrayOutputStream +import java.io.File + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class StoreLogoTest { + @get:Rule val tmp = TemporaryFolder() + + private fun encoded(format: Bitmap.CompressFormat): ByteArray { + val bmp = Bitmap.createBitmap(8, 4, Bitmap.Config.ARGB_8888) + bmp.eraseColor(Color.TRANSPARENT) + bmp.setPixel(1, 1, Color.RED) + return ByteArrayOutputStream().also { bmp.compress(format, 100, it) }.toByteArray() + } + + @Test fun aPngLogoIsKeptAsItCameWithItsTransparency() { + val png = encoded(Bitmap.CompressFormat.PNG) + val dst = File(tmp.root, StoreArt.LOGO) + assertTrue(StoreArt.writeLogo(png, dst)) + assertArrayEquals(png, dst.readBytes()) + val back = BitmapFactory.decodeFile(dst.path) + assertEquals(0, Color.alpha(back.getPixel(0, 0))) + assertEquals(Color.RED, back.getPixel(1, 1)) + assertFalse(File(dst.path + ".tmp").exists()) + } + + @Test fun anotherImageFormatIsStoredAsPng() { + val dst = File(tmp.root, StoreArt.LOGO) + assertTrue(StoreArt.writeLogo(encoded(Bitmap.CompressFormat.JPEG), dst)) + val head = dst.readBytes().take(4) + assertEquals(listOf(0x89.toByte(), 'P'.code.toByte(), 'N'.code.toByte(), 'G'.code.toByte()), head) + assertEquals(8, BitmapFactory.decodeFile(dst.path).width) + } + + @Test fun whatIsNotAnImageIsRefusedAndAnExistingLogoStays() { + val dst = File(tmp.root, StoreArt.LOGO) + assertFalse(StoreArt.writeLogo("<html>not found</html>".toByteArray(), dst)) + assertFalse(dst.exists()) + dst.writeBytes(byteArrayOf(1, 2, 3)) + assertTrue(StoreArt.writeLogo(encoded(Bitmap.CompressFormat.PNG), dst)) + assertArrayEquals(byteArrayOf(1, 2, 3), dst.readBytes()) + assertTrue(StoreArt.fetchLogo(tmp.root, "https://invalid.example/logo.png")) + } + + @Test fun gogTakesTheV2LogoLinkOnly() { + val v2 = JSONObject("""{"_links":{"logo":{"href":"https://images.gog-statics.com/abc_logo.png"},"galaxyBackgroundImage":{"href":"https://x/bg.jpg"}}}""") + assertEquals("https://images.gog-statics.com/abc_logo.png", GogBackend.logoOf(v2)) + assertEquals("https://images.gog-statics.com/rel.png", GogBackend.logoOf(JSONObject("""{"_links":{"logo":{"href":"//images.gog-statics.com/rel.png"}}}"""))) + assertNull(GogBackend.logoOf(JSONObject("""{"_links":{}}"""))) + assertNull(GogBackend.logoOf(JSONObject("""{"images":{"logo2x":"https://x/glx_logo_2x.jpg"}}"""))) + assertNull(GogBackend.logoOf(JSONObject("""{"_links":{"logo":{"href":"http://x/logo.png"}}}"""))) + } + + @Test fun amazonKeepsTheLogoFromTheLibraryAndItsCache() { + val app = RuntimeEnvironment.getApplication() + val fresh = AmazonLibrary.gameFrom(JSONObject().put("productId", "p").put("title", "Game").put("logo", "https://m/logo.png"), null) + assertEquals("https://m/logo.png", fresh.logoUrl) + assertEquals("https://m/logo.png", AmazonLibrary.toCatalogItem(app, fresh).logoUrl) + assertEquals(listOf(fresh), AmazonLibrary.fromCacheJson(AmazonLibrary.toCacheJson(listOf(fresh)))) + val kept = AmazonLibrary.gameFrom(JSONObject().put("productId", "p").put("title", "Game"), fresh) + assertEquals("https://m/logo.png", kept.logoUrl) + val none = AmazonLibrary.gameFrom(JSONObject().put("productId", "q").put("title", "Other"), null) + assertNull(AmazonLibrary.toCatalogItem(app, none).logoUrl) + assertEquals("", AmazonLibrary.fromCacheJson("""[{"productId":"old","title":"Old"}]""").single().logoUrl) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/stores/battlenet/BattleNetRequestTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/battlenet/BattleNetRequestTest.kt new file mode 100644 index 000000000..f1141a871 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/stores/battlenet/BattleNetRequestTest.kt @@ -0,0 +1,38 @@ +package com.droiddeck.launcher.stores.battlenet + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class BattleNetRequestTest { + @get:Rule val tmp = TemporaryFolder() + + @Test fun requestsLeftBeforeThePollAreAllKeptInOrder() { + val root = tmp.root + assertTrue(BattleNetClient.request(root, listOf("install-game", "wow"))) + assertTrue(BattleNetClient.request(root, listOf("install-game", "fenris"))) + assertTrue(BattleNetClient.request(root, listOf("launch", "OSI"))) + val names = root.list()!!.sorted() + assertEquals(3, names.size) + assertTrue(names.all { Regex("battlenet-request\\.\\d{20}").matches(it) }) + assertEquals(listOf("install-game\nwow\n", "install-game\nfenris\n", "launch\nOSI\n"), names.map { java.io.File(root, it).readText() }) + } + + @Test fun aLineBreakInAnArgumentIsRefused() { + assertFalse(BattleNetClient.request(tmp.root, listOf("launch", "OSI\nreboot"))) + assertEquals(0, tmp.root.list()!!.size) + } + + @Test fun aNewSessionClearsEveryLeftRequest() { + val root = tmp.root + BattleNetClient.request(root, listOf("launch", "WTCG")) + java.io.File(root, "battlenet-request").writeText("launch\nPro\n") + java.io.File(root, "battlenet-request.1.tmp").writeText("launch\nPro\n") + java.io.File(root, "battlenet-fex.request").writeText("x") + BattleNetClient.clearRequests(root) + assertEquals(listOf("battlenet-fex.request"), root.list()!!.toList()) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/stores/battlenet/BlizzardArtTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/battlenet/BlizzardArtTest.kt new file mode 100644 index 000000000..415c6c7cf --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/stores/battlenet/BlizzardArtTest.kt @@ -0,0 +1,30 @@ +package com.droiddeck.launcher.stores.battlenet + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class BlizzardArtTest { + @Test fun theShopsKeyArtIsUsed() { + val page = """<meta property="og:image" content="https://blz-contentstack-images.akamaized.net/v3/assets/a/b/c/art.png">""" + assertEquals("https://blz-contentstack-images.akamaized.net/v3/assets/a/b/c/art.png", BlizzardArt.imageUrl(page)) + } + + @Test fun aVideoStillFallsBackToTheFirstThumbnailThatIsNotABundle() { + val page = """<meta property="og:image" content="https://img.youtube.com/vi/x/maxresdefault.jpg">""" + + """\"imageUrl\":\"//catalog.blzstatic.com//ff9/a-prod-thumb_bundle.jpg\",""" + + """\"imageUrl\":\"//catalog.blzstatic.com//feb/b-prod-thumb.jpg\"""" + assertEquals("https://catalog.blzstatic.com//feb/b-prod-thumb.jpg", BlizzardArt.imageUrl(page)) + } + + @Test fun anOpenGraphImageFromAnotherHostFallsBackToTheThumbnail() { + val page = """<meta property="og:image" content="https://example.com/art.png">""" + + """\"imageUrl\":\"//catalog.blzstatic.com//feb/b-prod-thumb.jpg\"""" + assertEquals("https://catalog.blzstatic.com//feb/b-prod-thumb.jpg", BlizzardArt.imageUrl(page)) + } + + @Test fun artFromOtherHostsIsRefused() { + assertNull(BlizzardArt.imageUrl("""<meta property="og:image" content="https://example.com/art.png">""")) + assertNull(BlizzardArt.imageUrl("""\"imageUrl\":\"//catalog.blzstatic.com//ff9/a-prod-thumb_bundle.jpg\"""")) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/stores/epic/EpicClientTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/epic/EpicClientTest.kt index 720e122c1..60df152e0 100644 --- a/app/src/test/java/com/droiddeck/launcher/stores/epic/EpicClientTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/stores/epic/EpicClientTest.kt @@ -16,17 +16,6 @@ import java.security.MessageDigest class EpicClientTest { @get:Rule val tmp = TemporaryFolder() - private fun game(appId: Int, source: String) = - com.droiddeck.launcher.frontend.Library.SteamGame(appId, "g$appId", null, "internal", source = source) - - @Test fun theLauncherSitsRightAboveTheFirstEpicGame() { - val launcher = game(EpicClient.LAUNCHER_APP_ID, "epic") - val games = listOf(game(1, "steam"), game(2, "epic"), game(3, "steam"), game(4, "epic")) - assertEquals(listOf(1, 0, 2, 3, 4), EpicClient.withLauncher(games, launcher).map { it.appId }) - assertEquals(listOf(1, 3, 0), EpicClient.withLauncher(listOf(games[0], games[2]), launcher).map { it.appId }) - assertEquals(listOf(1, 2), EpicClient.withLauncher(games.take(2), null).map { it.appId }) - } - private fun added(name: String, source: String, storeId: String?) = com.droiddeck.launcher.frontend.AddedGames.Game(File(tmp.root, name), name, File(tmp.root, "$name/$name.exe"), "/root/Games/$name/$name.exe", "/root/Games/$name", 3_000_000_000L, 0L, emptyList(), source = source, storeId = storeId) @@ -285,6 +274,28 @@ class EpicClientTest { assertTrue(EpicClient.loopback("http://localhost:5/launcher/authorized?code=1")) } + @Test fun theManifestLaunchCommandJoinsTheGameArguments() { + val store = com.droiddeck.launcher.stores.Store.EPIC + val epicArgs = listOf("-EpicPortal", "-epicapp=Fig", "-epicenv=Prod") + val installed = com.droiddeck.launcher.stores.StoreGameSidecar(store, "Fig", "Fig Game", exe = "Fig.exe", args = epicArgs) + val first = EpicClientLibrary.withLaunchArgs(installed, listOf("-PCStore=EGS")) + assertEquals(epicArgs + "-PCStore=EGS", first.args) + assertTrue(EpicClientLibrary.withLaunchArgs(first, listOf("-PCStore=EGS")) === first) + val updated = EpicClientLibrary.withLaunchArgs(first, listOf("-PCStore=EGS", "-log=a b.txt")) + assertEquals(epicArgs + listOf("-PCStore=EGS", "-log=a b.txt"), updated.args) + val dropped = EpicClientLibrary.withLaunchArgs(updated, emptyList()) + assertEquals(epicArgs, dropped.args) + assertFalse(dropped.extra.containsKey("launchArgs")) + val reinstalled = first.copy(args = epicArgs) + assertEquals(epicArgs + "-PCStore=EGS", EpicClientLibrary.withLaunchArgs(reinstalled, listOf("-PCStore=EGS")).args) + assertTrue(EpicClientLibrary.withLaunchArgs(installed, emptyList()) === installed) + val own = installed.copy(args = epicArgs + "-PCStore=EGS") + assertTrue(EpicClientLibrary.withLaunchArgs(own, listOf("-PCStore=EGS")) === own) + val ownUpdated = EpicClientLibrary.withLaunchArgs(own, listOf("-PCStore=EGS", "-log=a b.txt")) + assertEquals(epicArgs + listOf("-PCStore=EGS", "-log=a b.txt"), ownUpdated.args) + assertEquals(epicArgs + "-PCStore=EGS", EpicClientLibrary.withLaunchArgs(ownUpdated, emptyList()).args) + } + @Test fun itemsDescribeTheInstallForTheLauncher() { val install = EpicClientLibrary.Install( "Fig", "ns1", "item1", "Fig Game", "1.0.5", "Binaries/Win64/Fig.exe", "/root/Games/Stores/Epic/Fig Game", @@ -445,4 +456,26 @@ class EpicClientTest { settings.writeText("""{"defaultInstallDir":"/mnt/droiddeck-sd/Stores/Epic"}""") assertEquals("/root/Games/Stores/Epic", EpicClient.prepareInstallDir(context)) } + + @Test fun aDisconnectedDefaultFolderKeepsTheLauncherInstallDir() { + val context = org.robolectric.RuntimeEnvironment.getApplication() + val previousStorage = com.droiddeck.launcher.session.SessionPrefs.defaultStorage(context) + val previousFolder = com.droiddeck.launcher.session.SessionPrefs.defaultStorageFolder(context) + val previousLabel = com.droiddeck.launcher.session.SessionPrefs.defaultStorageFolderLabel(context) + val settings = File(EpicClient.dataDir(context), "settings/app-settings.json").apply { parentFile!!.mkdirs() } + val current = "/mnt/droiddeck-stores/1234/Epic" + settings.writeText("""{"defaultInstallDir":"$current"}""") + com.droiddeck.launcher.session.SessionPrefs.setDefaultStorage( + context, com.droiddeck.launcher.session.SessionPrefs.DEFAULT_STORAGE_EXTERNAL) + com.droiddeck.launcher.session.SessionPrefs.setDefaultStorageFolder( + context, File(context.filesDir, "missing-${System.nanoTime()}").path, "SSD") + try { + assertEquals(current, EpicClient.prepareInstallDir(context)) + assertEquals(current, org.json.JSONObject(settings.readText()).getString("defaultInstallDir")) + } finally { + com.droiddeck.launcher.session.SessionPrefs.setDefaultStorage(context, previousStorage) + if (previousFolder.isBlank()) com.droiddeck.launcher.session.SessionPrefs.clearDefaultStorageFolder(context) + else com.droiddeck.launcher.session.SessionPrefs.setDefaultStorageFolder(context, previousFolder, previousLabel) + } + } } diff --git a/app/src/test/java/com/droiddeck/launcher/stores/epic/EpicLibraryTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/epic/EpicLibraryTest.kt index 4eb819043..a3f156e1b 100644 --- a/app/src/test/java/com/droiddeck/launcher/stores/epic/EpicLibraryTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/stores/epic/EpicLibraryTest.kt @@ -83,6 +83,18 @@ class EpicLibraryTest { assertEquals("Fig", EpicLibrary.gameFrom(record, null, null).title) } + @Test fun theCatalogLogoIsKeptInTheCacheAndHandedToTheInstall() { + val record = JSONObject().put("appName", "Fig").put("namespace", "ns").put("catalogItemId", "cat") + val fresh = EpicLibrary.gameFrom(record, JSONObject().put("logo", "https://e/logo.png"), null) + assertEquals("https://e/logo.png", fresh.artLogo) + assertEquals("https://e/logo.png", EpicLibrary.gameFrom(record, JSONObject().put("title", "Fig"), fresh).artLogo) + assertEquals(listOf(fresh), EpicLibrary.fromCacheJson(EpicLibrary.toCacheJson(listOf(fresh)))) + assertEquals("https://e/logo.png", EpicLibrary.toCatalogItem(app, fresh).logoUrl) + val plain = EpicGame("Old", "ns", "c", "Old") + assertFalse(JSONArray(EpicLibrary.toCacheJson(listOf(plain))).getJSONObject(0).has("artLogo")) + assertEquals(null, EpicLibrary.toCatalogItem(app, plain).logoUrl) + } + @Test fun signOutForgetsOnlyTheLibrary() { EpicPrefs.get(app).edit().putString(EpicLibrary.KEY_CACHE, "[]").putLong(EpicLibrary.KEY_SYNCED_AT, 1L) .putString("cloud_template_Fig", "{AppData}/x").commit() diff --git a/app/src/test/java/com/droiddeck/launcher/stores/gog/GogInstallerPartsTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/gog/GogInstallerPartsTest.kt new file mode 100644 index 000000000..e60775b0e --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/stores/gog/GogInstallerPartsTest.kt @@ -0,0 +1,90 @@ +package com.droiddeck.launcher.stores.gog + +import java.io.File +import java.io.IOException +import java.nio.file.Files +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class GogInstallerPartsTest { + private fun downloads(json: String) = JSONObject("""{"downloads":$json}""").getJSONObject("downloads") + + @Test fun everyFileOfTheWindowsInstallerIsKeptAndThePartsAreNamedAfterItsSetup() { + val parts = GogDownloadManager.installerParts( + downloads( + """{"installers":[ + {"os":"windows","language":"en","files":[ + {"id":"en1installer0","size":1048576,"downlink":"https://api.gog.com/products/1947927225/downlink/installer/en1installer0"}, + {"id":"en1installer1","size":3786407936,"downlink":"https://api.gog.com/products/1947927225/downlink/installer/en1installer1"} + ]}, + {"os":"linux","files":[{"downlink":"https://example.invalid/linux"}]} + ]}""", + ), + "1947927225", + ) + assertEquals(listOf("setup_1947927225.exe", "setup_1947927225-1.bin"), parts.map { it.filename }) + assertEquals(listOf(1048576L, 3786407936L), parts.map { it.size }) + assertTrue(parts[1].url.endsWith("en1installer1")) + } + + @Test fun explicitNamesAreKeptAndUnnamedPartsFollowTheFirstSetupExe() { + val parts = GogDownloadManager.installerParts( + downloads( + """{"installers":[{"os":"windows","files":[ + {"filename":"setup_witcher_2.1.0.exe","downlink":"https://example.invalid/setup"}, + {"downlink":"https://example.invalid/data"}, + {"filename":"readme.bin","downlink":"https://example.invalid/readme"} + ]}]}""", + ), + "1207658924", + ) + assertEquals(listOf("setup_witcher_2.1.0.exe", "setup_witcher_2.1.0-1.bin", "readme.bin"), parts.map { it.filename }) + } + + @Test fun aSingleManualInstallerIsNamedForTheGameAndSanitised() { + val parts = GogDownloadManager.installerParts( + downloads("""{"installers":[{"os":"windows","manualUrl":"https://example.invalid/setup","total_size":42}]}"""), + "../game id", + ) + assertEquals(1, parts.size) + assertEquals("setup_.._game_id.exe", parts[0].filename) + assertEquals(42L, parts[0].size) + } + + @Test fun installerPartsStayInsideTheGameFolder() { + val root = kotlin.io.path.createTempDirectory("gog-installer").toFile() + try { + assertEquals( + File(root, "setup_1.exe").canonicalFile, + GogDownloadManager.installerDestination(root, "setup_1.exe"), + ) + assertThrows(IOException::class.java) { + GogDownloadManager.installerDestination(root, "../outside.exe") + } + val outside = File(root.parentFile, "outside-${System.nanoTime()}.bin").apply { writeText("x") } + try { + Files.createSymbolicLink(File(root, "setup_1-1.bin").toPath(), outside.toPath()) + assertThrows(IOException::class.java) { + GogDownloadManager.installerDestination(root, "setup_1-1.bin") + } + } finally { + outside.delete() + } + } finally { + root.deleteRecursively() + } + } + + @Test fun anotherPlatformOrNoDownloadsAddsNothing() { + assertEquals(0, GogDownloadManager.installerParts(downloads("""{"installers":[{"os":"linux","manualUrl":"https://example.invalid/x"}]}"""), "1").size) + assertEquals(0, GogDownloadManager.installerParts(downloads("{}"), "1").size) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/stores/itch/ItchBrowseTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/itch/ItchBrowseTest.kt new file mode 100644 index 000000000..d3ebf12e5 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/stores/itch/ItchBrowseTest.kt @@ -0,0 +1,83 @@ +package com.droiddeck.launcher.stores.itch + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ItchBrowseTest { + private fun path(sort: String = ItchBrowse.SORT_POPULAR, price: String = ItchBrowse.PRICE_ANY, platforms: Set<String> = emptySet(), tags: List<String> = emptyList()) = + ItchBrowse.path(BrowseQuery(sort, price, platforms, tags)) + + @Test fun theDefaultQueryIsTheFrontPage() { + assertTrue(BrowseQuery().isDefault) + assertFalse(BrowseQuery(sort = ItchBrowse.SORT_NEWEST).isDefault) + assertFalse(BrowseQuery(price = ItchBrowse.PRICE_FREE).isDefault) + } + + @Test fun sortsUseItchsOwnPaths() { + assertEquals("https://itch.io/games", path()) + assertEquals("https://itch.io/games/new-and-popular", path(sort = ItchBrowse.SORT_NEW_POPULAR)) + assertEquals("https://itch.io/games/top-sellers", path(sort = ItchBrowse.SORT_TOP_SELLERS)) + assertEquals("https://itch.io/games/top-rated", path(sort = ItchBrowse.SORT_TOP_RATED)) + assertEquals("https://itch.io/games/newest", path(sort = ItchBrowse.SORT_NEWEST)) + } + + @Test fun pricesUseItchsOwnPaths() { + assertEquals("https://itch.io/games/free", path(price = ItchBrowse.PRICE_FREE)) + assertEquals("https://itch.io/games/on-sale", path(price = ItchBrowse.PRICE_SALE)) + assertEquals("https://itch.io/games/store", path(price = ItchBrowse.PRICE_PAID)) + assertEquals("https://itch.io/games/5-dollars-or-less", path(price = ItchBrowse.PRICE_UNDER_5)) + assertEquals("https://itch.io/games/15-dollars-or-less", path(price = ItchBrowse.PRICE_UNDER_15)) + } + + @Test fun platformsUseItchsOwnNames() { + val expected = mapOf( + "windows" to "platform-windows", "osx" to "platform-osx", "linux" to "platform-linux", "android" to "platform-android", + "ios" to "platform-ios", "web" to "platform-web", "mobile-web" to "platform-mobile-web", + ) + assertEquals(expected.keys.toList(), ItchBrowse.platformNames) + for ((name, segment) in expected) assertEquals("https://itch.io/games/$segment", path(platforms = setOf(name))) + } + + @Test fun aSortAPriceAndAPlatformCombineInThatOrder() { + assertEquals( + "https://itch.io/games/top-rated/free/platform-windows", + path(ItchBrowse.SORT_TOP_RATED, ItchBrowse.PRICE_FREE, setOf("windows")), + ) + } + + @Test fun aTagIsItsLowercaseNameWithHyphens() { + assertEquals("https://itch.io/games/tag-pixel-art", path(tags = listOf("Pixel Art"))) + assertEquals("pixel-art", ItchBrowse.tagSlug(" Pixel Art ")) + } + + @Test fun aSortAPricePlatformAndTagCombineInThatOrder() { + assertEquals( + "https://itch.io/games/top-rated/free/platform-windows/tag-pixel-art.xml", + ItchBrowse.url(BrowseQuery(ItchBrowse.SORT_TOP_RATED, ItchBrowse.PRICE_FREE, setOf("windows"), listOf("pixel-art")), 1), + ) + } + + @Test fun severalPlatformsAndTagsAreAllInTheAddress() { + assertEquals( + "https://itch.io/games/platform-windows/platform-linux/tag-pixel-art/tag-2d", + path(platforms = setOf("linux", "windows"), tags = listOf("pixel-art", "2d")), + ) + } + + @Test fun aTypedTagBecomesItsSlug() { + assertEquals("visual-novel", ItchBrowse.tagSlug("Visual Novel!")) + assertEquals("", ItchBrowse.tagSlug(" !! ")) + } + + @Test fun theSamePlatformsInAnyOrderGiveTheSameAddress() { + assertEquals(path(platforms = setOf("web", "linux")), path(platforms = setOf("linux", "web"))) + } + + @Test fun laterPagesAskForThePageNumberAndTheFirstDoesNot() { + val query = BrowseQuery(sort = ItchBrowse.SORT_TOP_RATED) + assertEquals("https://itch.io/games/top-rated.xml", ItchBrowse.url(query, 1)) + assertEquals("https://itch.io/games/top-rated.xml?page=2", ItchBrowse.url(query, 2)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/stores/itch/ItchGameTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/itch/ItchGameTest.kt new file mode 100644 index 000000000..1b7d8336f --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/stores/itch/ItchGameTest.kt @@ -0,0 +1,66 @@ +package com.droiddeck.launcher.stores.itch + +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +@org.junit.runner.RunWith(org.robolectric.RobolectricTestRunner::class) +@org.robolectric.annotation.Config(sdk = [33], application = android.app.Application::class) +class ItchGameTest { + private fun upload(id: String, filename: String, vararg traits: String, type: String = "default", storage: String = "hosted", demo: Boolean = false, updated: String = "2026-09-16T02:26:06Z") = + JSONObject().put("id", id).put("filename", filename).put("size", 1000).put("type", type).put("storage", storage) + .put("demo", demo).put("traits", JSONArray(traits.toList())).put("updated_at", updated) + + @Test fun theGameNumberComesFromThePageMeta() { + assertEquals("1576684", ItchGame.idFromPage("""<head><meta name="itch:path" content="games/1576684"></head>""")) + assertEquals("1576684", ItchGame.idFromPage("""<meta content="games/1576684" name="itch:path">""")) + assertNull(ItchGame.idFromPage("<html>no meta here</html>")) + } + + @Test fun platformsComeFromTheTraits() { + assertEquals(setOf("windows"), ItchUploads.platforms(upload("1", "a.zip", "p_windows"))) + assertEquals(setOf("linux", "osx"), ItchUploads.platforms(upload("2", "a.zip", "p_linux", "p_osx"))) + assertEquals(setOf("android"), ItchUploads.platforms(upload("3", "a.apk", "p_android"))) + assertEquals(setOf("web"), ItchUploads.platforms(upload("4", "a.zip", type = "html"))) + } + + @Test fun anUploadWithNoPlatformIsTreatedAsWindows() { + assertEquals(setOf("windows"), ItchUploads.platforms(upload("5", "a.zip"))) + } + + @Test fun platformsAlsoReadTheFlagObject() { + val flags = JSONObject().put("windows", false).put("linux", true) + assertEquals(setOf("linux"), ItchUploads.platforms(JSONObject().put("platforms", flags))) + } + + @Test fun buildsSkipExternalUploadsAndPutWindowsFirst() { + val uploads = JSONArray() + .put(upload("10", "linux.tar.gz", "p_linux", updated = "2026-10-01T00:00:00Z")) + .put(upload("11", "win.zip", "p_windows", updated = "2026-09-01T00:00:00Z")) + .put(upload("12", "site", "p_windows", storage = "external")) + val builds = ItchGame.builds(uploads) + assertEquals(listOf("11", "10"), builds.map { it.uploadId }) + assertTrue(builds[0].installable) + assertFalse(builds[1].installable) + } + + @Test fun aDemoIsFlagged() { + assertTrue(ItchGame.builds(JSONArray().put(upload("20", "demo.zip", "p_windows", demo = true))).single().demo) + } + + @Test fun anUploadIsFoundById() { + val uploads = JSONArray().put(upload("30", "a.zip")).put(upload("31", "b.zip")) + assertEquals("b.zip", ItchUploads.byId(uploads, "31")?.optString("filename")) + assertNull(ItchUploads.byId(uploads, "99")) + assertNull(ItchUploads.byId(uploads, "")) + } + + @Test fun noDownloadKeyMeansNoKeyInTheAddress() { + assertEquals("https://api.itch.io/uploads/13619666/download", ItchApi.downloadUrl("13619666", "")) + assertEquals("https://api.itch.io/uploads/13619666/download?download_key_id=7", ItchApi.downloadUrl("13619666", "7")) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxCatalogTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxCatalogTest.kt index c4f95c091..a59450e34 100644 --- a/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxCatalogTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxCatalogTest.kt @@ -83,13 +83,14 @@ class XboxCatalogTest { assertEquals(mapOf(game to "gamepass", parent to "gamepass"), access(row(game, parent), row(parent, plan), row(plan))) } - @Test fun onlyPcPlansGrantSubscriptionAccess() { - for (pc in listOf("CFQ7TTC0KGQ8", "CFQ7TTC0KHS0", "CFQ7TTC0P85B", "CFQ7TTC0K5DJ", "CFQ7TTC10QFD")) { - assertEquals(mapOf(game to "gamepass"), access(row(game, pc), row(pc))) + @Test fun everyActivePlanGrantsSubscriptionAccess() { + for (plan in listOf("CFQ7TTC0KGQ8", "CFQ7TTC0KHS0", "CFQ7TTC0P85B", "CFQ7TTC0K5DJ", "CFQ7TTC10QFD", "CFQ7TTC0K6L8")) { + assertEquals(mapOf(game to "gamepass"), access(row(game, plan), row(plan))) } val console = "CFQ7TTC0K6L8" - assertTrue(access(row(game, console), row(console)).isEmpty()) - assertTrue(XboxCatalog.access(withMembership(envelope(row(game, console)), membership(console)), + assertEquals(mapOf(game to "gamepass"), XboxCatalog.access(withMembership(envelope(row(game, console)), membership(console)), + "test@example.test", "US", now)) + assertTrue(XboxCatalog.access(withMembership(envelope(row(game, console)), membership(console).put("status", "Expired")), "test@example.test", "US", now).isEmpty()) } @@ -165,4 +166,29 @@ class XboxCatalogTest { assertEquals(game, XboxCatalog.product(product("Windows.Desktop", "MSIXVC").put("ProductId", game.lowercase()), mapOf(game to "owned"))!!.id) assertTrue(pc.accessible); assertTrue(pc.subscription); assertFalse(pc.owned) } + + private fun product(id: String, platform: String?) = JSONObject().put("ProductId", id).put("ProductType", "Game") + .put("LocalizedProperties", JSONArray().put(JSONObject().put("ProductTitle", "A Game"))) + .put("DisplaySkuAvailabilities", JSONArray().put(JSONObject().put("Sku", JSONObject().put("Properties", JSONObject() + .put("Packages", if (platform == null) JSONArray() else JSONArray().put(JSONObject().put("PackageFormat", "MSIXVC") + .put("PlatformDependencies", JSONArray().put(JSONObject().put("PlatformName", platform))))))))) + + @Test fun cloudListsKeepStreamOnlyGamesAndMarkStreamablePcGames() { + val cloud = XboxCloudCatalog(gamePass = setOf(game), all = setOf(game, parent)) + val pc = XboxCatalog.product(product(game, "Windows.Desktop"), mapOf(game to "gamepass"), cloud)!! + assertTrue(pc.xboxPc); assertTrue(pc.xboxCloud) + val consoleOnly = XboxCatalog.product(product(parent, "Xbox.One"), mapOf(parent to "owned"), cloud)!! + assertFalse(consoleOnly.xboxPc); assertTrue(consoleOnly.xboxCloud); assertEquals(0L, consoleOnly.sizeBytes) + assertNull(XboxCatalog.product(product(parent, "Xbox.One"), mapOf(parent to "gamepass"), cloud)) + assertNull(XboxCatalog.product(product(parent, "Xbox.One"), mapOf(parent to "owned"), null)) + assertFalse(XboxCatalog.product(product(game, "Windows.Desktop"), mapOf(game to "gamepass"), null)!!.xboxCloud) + } + + @Test fun aCloudOnlyEditionFoldsIntoThePcCardWithTheSameTitle() { + val cloud = XboxCloudCatalog(gamePass = setOf(parent), all = emptySet()) + val pc = XboxCatalog.product(product(game, "Windows.Desktop"), mapOf(game to "gamepass"), cloud)!! + val console = XboxCatalog.product(product(parent, "Xbox.One"), mapOf(parent to "gamepass"), cloud)!! + val merged = mergeEditions(listOf(pc, console)).single() + assertEquals(game, merged.id); assertTrue(merged.xboxPc); assertTrue(merged.xboxCloud); assertEquals(parent, merged.xboxCloudId) + } } diff --git a/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxCloudTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxCloudTest.kt new file mode 100644 index 000000000..bbe2e11f9 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxCloudTest.kt @@ -0,0 +1,49 @@ +package com.droiddeck.launcher.stores.xbox + +import com.droiddeck.launcher.stores.CatalogItem +import com.droiddeck.launcher.stores.Store +import org.junit.Assert.* +import org.junit.Test + +@org.junit.runner.RunWith(org.robolectric.RobolectricTestRunner::class) +@org.robolectric.annotation.Config(sdk = [33], application = android.app.Application::class) +class XboxCloudTest { + @Test fun settingsRoundTripAndOutOfRangeValuesAreClamped() { + val settings = XboxCloudSettings(resolution = "720p", bitrateMbps = 20, volume = 200, sharpen = 4, fxaa = true, statsCorner = "left", + playerEngine = "native", frameGen = "lsfg3", fpsLimit = 120, fpsCustomOn = true, fpsCustom = 90, smallBuffer = true, zeroCopy = true) + assertEquals(settings, XboxCloudSettings.decode(settings.toJson())) + val wild = XboxCloudSettings.decode("""{"resolution":"4k","codec":"x","bitrateMbps":900,"volume":5,"statsLevel":9,"gamma":40}""") + assertEquals("1080p", wild.resolution); assertEquals("default", wild.codec); assertEquals(100, wild.bitrateMbps) + assertEquals(100, wild.volume); assertEquals(4, wild.statsLevel); assertEquals(3f, wild.gamma) + assertEquals("right", XboxCloudSettings.decode("""{"statsCorner":"middle"}""").statsCorner) + assertEquals("ddfg", XboxCloudSettings.decode("""{"frameGen":"winfg"}""").frameGen) + assertEquals(0, XboxCloudSettings.decode("""{"fpsLimit":77,"fpsCustom":500}""").fpsLimit) + assertEquals(165, XboxCloudSettings.decode("""{"fpsCustomOn":true,"fpsCustom":500}""").fpsTarget) + assertEquals(XboxCloudSettings(), XboxCloudSettings.decode("not json")) + assertEquals("android", XboxCloudSettings(resolution = "720p").osName) + assertEquals("tizen", XboxCloudSettings(resolution = "1080p_hq").osName) + } + + @Test fun addedCloudGamesKeepValidRowsOnly() { + val list = listOf(XboxCloudGames.Entry("9AAAAAAAAAAA", "A game", "https://store-images.s-microsoft.com/p", null)) + val decoded = XboxCloudGames.decode(XboxCloudGames.encode(list)) + assertEquals(1, decoded.size); assertEquals("A game", decoded[0].title); assertNull(decoded[0].hero) + assertTrue(XboxCloudGames.decode("""[{"id":"bad","title":"x"},{"id":"9BBBBBBBBBBB","title":""}]""").isEmpty()) + assertTrue(XboxCloudGames.decode("nope").isEmpty()) + assertTrue(XboxCloudGames.rowId("9AAAAAAAAAAA") < 0) + } + + @Test fun libraryFilterSplitsOwnedGamePassAndCloud() { + fun item(owned: Boolean, pass: Boolean, cloud: Boolean) = CatalogItem(Store.XBOX, "9AAAAAAAAAAA", "A", null, + owned = owned, subscription = pass, extra = mapOf("cloud" to if (cloud) "1" else "0")) + val owned = item(true, false, false); val pass = item(false, true, true) + assertTrue(XboxLibraryFilter.keeps(XboxLibraryFilter.ALL, owned)) + assertTrue(XboxLibraryFilter.keeps(XboxLibraryFilter.OWNED, owned)); assertFalse(XboxLibraryFilter.keeps(XboxLibraryFilter.OWNED, pass)) + assertTrue(XboxLibraryFilter.keeps(XboxLibraryFilter.GAME_PASS, pass)); assertFalse(XboxLibraryFilter.keeps(XboxLibraryFilter.GAME_PASS, owned)) + assertTrue(XboxLibraryFilter.keeps(XboxLibraryFilter.CLOUD, pass)); assertFalse(XboxLibraryFilter.keeps(XboxLibraryFilter.CLOUD, owned)) + } + + @Test fun cloudLaunchUrlUsesTheStreamingEdition() { + assertEquals("https://www.xbox.com/play/launch/ball-x-pit/9BBBBBBBBBBB", XboxCatalog.cloudUrl("9AAAAAAAAAAA", "BALL x PIT", "9BBBBBBBBBBB")) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxLibraryCacheTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxLibraryCacheTest.kt index 8d545d20c..ca0735091 100644 --- a/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxLibraryCacheTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxLibraryCacheTest.kt @@ -13,7 +13,7 @@ class XboxLibraryCacheTest { private fun game(id: String, subscription: Boolean) = CatalogItem(Store.XBOX, id, "A game", "https://example.test/hero", tallImageUrl = "https://example.test/poster", description = "Description", developer = "Developer", publisher = "Publisher", sizeBytes = 12345, owned = !subscription, subscription = subscription, storeUrl = "https://www.xbox.com/games/store/$id", - extra = mapOf("market" to "US", "access" to if (subscription) "gamepass" else "owned")) + extra = mapOf("market" to "US", "access" to if (subscription) "gamepass" else "owned", "pc" to "1", "cloud" to "0", "cloudId" to "")) @Test fun restartRestoresMetadataWithoutTurningSubscriptionsIntoPurchases() { val games = listOf(game("9AAAAAAAAAAA", false), game("9BBBBBBBBBBB", true)) @@ -28,7 +28,7 @@ class XboxLibraryCacheTest { val text = XboxLibraryCache.encode(account, "US", listOf(game("9AAAAAAAAAAA", false)), now) assertNull(XboxLibraryCache.decode(text, "other@example.test", "US", now)) assertNull(XboxLibraryCache.decode(text, account, "GB", now)) - assertNull(XboxLibraryCache.decode(text.replace("\"schema\":1", "\"schema\":2"), account, "US", now)) + assertNull(XboxLibraryCache.decode(text.replace("\"schema\":2", "\"schema\":1"), account, "US", now)) assertNull(XboxLibraryCache.decode("invalid", account, "US", now)) assertNull(XboxLibraryCache.decode(text, account, "US", now - 120_000)) } @@ -50,4 +50,13 @@ class XboxLibraryCacheTest { XboxLibraryCache.clear(app) assertNull(XboxLibraryCache.read(app, account, "US")) } + + @Test fun cloudFlagsAndTheStreamingEditionSurviveARestart() { + val stream = game("9CCCCCCCCCCC", true).copy(extra = mapOf("market" to "US", "access" to "gamepass", "pc" to "0", "cloud" to "1", "cloudId" to "")) + val folded = game("9AAAAAAAAAAA", true).copy(extra = mapOf("market" to "US", "access" to "gamepass", "pc" to "1", "cloud" to "1", "cloudId" to "9DDDDDDDDDDD")) + val cached = XboxLibraryCache.decode(XboxLibraryCache.encode(account, "US", listOf(stream, folded), now), account, "US", now)!! + assertEquals(listOf(stream, folded), cached.games) + assertFalse(cached.games[0].xboxPc); assertTrue(cached.games[0].xboxCloud) + assertEquals("9DDDDDDDDDDD", cached.games[1].xboxCloudId) + } } diff --git a/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxSignInMessagesTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxSignInMessagesTest.kt new file mode 100644 index 000000000..7726ca42f --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxSignInMessagesTest.kt @@ -0,0 +1,33 @@ +package com.droiddeck.launcher.stores.xbox + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +@org.junit.runner.RunWith(org.robolectric.RobolectricTestRunner::class) +@org.robolectric.annotation.Config(sdk = [33], application = android.app.Application::class) +class XboxSignInMessagesTest { + @Test fun ownCheckMessagesAreShownAndOtherTextIsNot() { + assertEquals("the Xbox helper stopped with exit code 3", XboxLoginActivity.failure(IllegalStateException("the Xbox helper stopped with exit code 3"))) + assertEquals("IllegalStateException", XboxLoginActivity.failure(IllegalStateException("Check failed."))) + assertEquals("JSONException", XboxLoginActivity.failure(org.json.JSONException("token=secret-value"))) + assertEquals("IllegalArgumentException", XboxLoginActivity.failure(IllegalArgumentException("secret-value"))) + } + + @Test fun gamertagMustBeShortPrintableText() { + assertTrue(XboxGamertag.valid("Some Gamertag")) + assertFalse(XboxGamertag.valid("")) + assertFalse(XboxGamertag.valid("a\nb")) + assertFalse(XboxGamertag.valid("x".repeat(65))) + } + + @Test fun gamertagRefreshIsOncePerAccountGeneration() { + val first = "account-generation-one-${System.nanoTime()}" + val second = "account-generation-two-${System.nanoTime()}" + assertTrue(XboxGamertag.shouldTryRefresh(first)) + assertFalse(XboxGamertag.shouldTryRefresh(first)) + assertTrue(XboxGamertag.shouldTryRefresh(second)) + assertFalse(XboxGamertag.shouldTryRefresh(second)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/stores/xbox/XodusDiagnosticsTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XodusDiagnosticsTest.kt new file mode 100644 index 000000000..c2d723f7b --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XodusDiagnosticsTest.kt @@ -0,0 +1,19 @@ +package com.droiddeck.launcher.stores.xbox + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class XodusDiagnosticsTest { + private val rejected = Regex(Xodus.SIGN_IN_REJECTED) + + @Test fun aContentLicenseRejectionIsRecognised() { + assertTrue("Microsoft content licensing rejected sign-in (HTTP 401). Sign in again in Settings.".matches(rejected)) + } + + @Test fun otherStderrTextIsNotTakenForARejection() { + assertFalse("Microsoft content licensing rejected sign-in (HTTP 401). Sign in again in Settings. token=abc".matches(rejected)) + assertFalse("Microsoft content licensing rejected sign-in (HTTP 403). Sign in again in Settings.".matches(rejected)) + assertFalse("NEXUS_CONTENT_LICENSE_DIAGNOSTIC_V1 content-http 401".matches(rejected)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/ui/GamesClientTagTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/GamesClientTagTest.kt index 9f89330c1..a86bf350f 100644 --- a/app/src/test/java/com/droiddeck/launcher/ui/GamesClientTagTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/ui/GamesClientTagTest.kt @@ -48,7 +48,7 @@ class GamesClientTagTest { DroidDeckTheme { CompositionLocalProvider(LocalFrontFocus provides FrontFocus(), LocalNarrowPane provides narrow) { FocusGlideHost { - GamesPage(FrontEndState( + LibraryPage(FrontEndState( installed = "r9", ready = true, available = null, busy = false, stage = "", percent = -1, desktopInstalled = true, offlineAccount = null, offline = false, frameGenLabel = "", romsDir = null, logsEnabled = false, steamGames = games, steamGamesX64 = x64, emulators = emptyList(), running = null, @@ -60,7 +60,7 @@ class GamesClientTagTest { onRoms = {}, onFiles = {}, onLogs = {}, onOffline = {}, onWirelessAdbPair = { _, _, _, _ -> }, onWirelessAdbApply = { _, _, _, _ -> }, onSetPhantomProcessLimit = { _, _ -> }, - ), selected = "app:440", onSelect = {}, modifier = Modifier.fillMaxSize()) + ), selected = "games", onSelect = {}, modifier = Modifier.fillMaxSize()) } } } diff --git a/app/src/test/java/com/droiddeck/launcher/ui/LibraryEntriesTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/LibraryEntriesTest.kt new file mode 100644 index 000000000..65a3a72b0 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/LibraryEntriesTest.kt @@ -0,0 +1,51 @@ +package com.droiddeck.launcher.ui + +import com.droiddeck.launcher.frontend.Library +import com.droiddeck.launcher.stores.CatalogItem +import com.droiddeck.launcher.stores.Store +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class LibraryEntriesTest { + private fun owned(store: Store, id: String, title: String = id) = CatalogItem(store, id, title, imageUrl = null, owned = true) + + @Test fun anInstalledStoreGameClaimsItsOwnedProductSoItShowsOnce() { + val games = listOf( + Library.SteamGame(440, "Team Fortress 2", null, "Steam library"), + Library.SteamGame(-5, "Cyberpunk", null, Library.ADDED, source = "gog", storeId = "1423049311"), + ) + val library = mapOf(Store.GOG to listOf(owned(Store.GOG, "1423049311", "Cyberpunk 2077"), owned(Store.GOG, "1207658924", "Witcher"))) + val entries = libraryEntries(games, library, emptySet()) + assertEquals(listOf("app:440", "app:-5", "app:gog:1207658924"), entries.map { it.key }) + assertEquals("gog:1423049311", entries[1].item?.key) + assertTrue(entries[1].installed) + assertFalse(entries[2].installed) + assertNull(entries[2].game) + } + + @Test fun theEpicLauncherIsNotAGame() { + val launcher = Library.SteamGame(0, Library.EPIC_LAUNCHER_NAME, null, Library.EPIC_LAUNCHER) + assertEquals(emptyList<String>(), libraryEntries(listOf(launcher), emptyMap(), emptySet()).map { it.key }) + } + + @Test fun aStoreInstallTheLibraryHasNotScannedYetCountsAsInstalled() { + val entries = libraryEntries(emptyList(), mapOf(Store.EPIC to listOf(owned(Store.EPIC, "fn"))), setOf("epic:fn")) + assertTrue(entries.single().installed) + } + + @Test fun installedGamesComeFirstKeepingTheirSortedOrder() { + val entries = listOf("a", "b", "c", "d").map { LibraryEntry(it, "steam", it, null, null, installed = it == "b" || it == "d") } + assertEquals(listOf("b", "d", "a", "c"), installedFirst(entries).map { it.key }) + } + + @Test fun gameKeysNameEitherAnAppOrAStoreProduct() { + GameRef.parse("app:440").let { assertEquals(440, it.appId); assertNull(it.store) } + GameRef.parse("app:-12").let { assertEquals(-12, it.appId) } + GameRef.parse("app:gog:1207658924").let { assertEquals(Store.GOG, it.store); assertEquals("1207658924", it.storeId); assertNull(it.appId) } + GameRef.parse("xbox:9NBLGGH4R315").let { assertEquals(Store.XBOX, it.store); assertEquals("9NBLGGH4R315", it.storeId) } + GameRef.parse("app:nope:1").let { assertNull(it.store); assertNull(it.appId) } + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/ui/PickListingTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/PickListingTest.kt index 9ed572671..d1487fcac 100644 --- a/app/src/test/java/com/droiddeck/launcher/ui/PickListingTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/ui/PickListingTest.kt @@ -29,6 +29,17 @@ class PickListingTest { assertEquals(listOf("alarms", "Android", "DCIM", "Documents", "Download", "Games", "Winlator"), listing.folders.map { it.dir.name }) } + @Test fun hiddenDirectoriesAndExecutablesCanBeShownOnRequest() { + val root = storage() + val hidden = File(root, ".Games").apply { mkdirs() } + File(hidden, "Game.exe").writeText("game") + assertFalse(pickListing(root, PickKind.EXE).folders.any { it.dir.name == ".Games" }) + val listing = pickListing(root, PickKind.EXE, showHidden = true) + assertTrue(listing.folders.any { it.dir.name == ".Games" }) + assertTrue(listing.files.any { it.name == ".hidden.exe" }) + assertEquals(listOf("Game.exe"), pickListing(hidden, PickKind.EXE).files.map { it.name }) + } + @Test fun onlyExesForAGameOnlyImagesForArt() { val root = storage() assertEquals(listOf("game.EXE", "Setup.exe"), pickListing(root, PickKind.EXE).files.map { it.name }) diff --git a/app/src/test/java/com/droiddeck/launcher/ui/SessionSecondScreenTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/SessionSecondScreenTest.kt index 04d3f5632..d4c5aa4b2 100644 --- a/app/src/test/java/com/droiddeck/launcher/ui/SessionSecondScreenTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/ui/SessionSecondScreenTest.kt @@ -9,6 +9,7 @@ import android.view.View import android.view.ViewGroup import android.widget.Button import androidx.compose.ui.test.* +import androidx.compose.ui.semantics.SemanticsProperties.StateDescription import androidx.compose.runtime.MutableState import com.droiddeck.launcher.R import com.droiddeck.launcher.SessionActivity @@ -97,6 +98,69 @@ class SessionSecondScreenTest { } } + @Test + @Config(sdk = [34], shadows = [PadBridgeRoutingTest.RegisteredKeyEvent::class]) + fun drawerShortcutsConsumeRepeatsAndReleasesAfterClosing() { + val device = InputDeviceBuilder.newBuilder().setId(43).setName("Drawer shortcut test pad") + .setDescriptor("drawer-shortcut-pad").setExternal(true).setSources(InputDevice.SOURCE_GAMEPAD).build() + shadowOf(context.getSystemService(InputManager::class.java)).addInputDevice(device) + SessionState.mode = SessionService.MODE_STEAM + val activity = Robolectric.buildActivity(SessionActivity::class.java).get() + val open: MutableState<Boolean> = ReflectionHelpers.getField(activity, "drawerOpen\$delegate") + val controller: MutableState<Boolean> = ReflectionHelpers.getField(activity, "drawerControllerActive\$delegate") + controller.value = true + for (button in listOf(KeyEvent.KEYCODE_BUTTON_SELECT, KeyEvent.KEYCODE_BUTTON_START)) { + fun event(action: Int, repeat: Int = 0) = KeyEvent(0L, 0L, action, button, repeat, 0, + device.id, 0, 0, InputDevice.SOURCE_GAMEPAD) + open.value = true + assertTrue(activity.dispatchKeyEvent(event(KeyEvent.ACTION_DOWN))) + assertEquals(false, open.value) + open.value = true + assertTrue(activity.dispatchKeyEvent(event(KeyEvent.ACTION_DOWN, 1))) + assertEquals(true, open.value) + open.value = false + assertTrue(activity.dispatchKeyEvent(event(KeyEvent.ACTION_UP))) + val held: Set<Pair<Int, Int>> = ReflectionHelpers.getField(activity, "drawerShortcutKeysDown") + assertTrue(held.isEmpty()) + } + } + + @Test fun sessionMicrophoneCanBeTurnedOff() { + val changes = mutableListOf<Boolean>() + compose.setContent { + DroidDeckTheme { + SessionDrawer(true, SessionDrawerPage.SESSION, false, {}, + actions(true, emptyList(), micAvailable = true, micEnabled = true, onMic = changes::add)) + } + } + compose.onNodeWithTag("setting-microphone").assertIsEnabled() + .assert(SemanticsMatcher.expectValue(StateDescription, context.getString(R.string.widgets_on))).performClick() + assertEquals(listOf(false), changes) + } + + @Test fun sessionMicrophoneCanBeTurnedBackOn() { + val changes = mutableListOf<Boolean>() + compose.setContent { + DroidDeckTheme { + SessionDrawer(true, SessionDrawerPage.SESSION, false, {}, + actions(true, emptyList(), micAvailable = true, onMic = changes::add)) + } + } + compose.onNodeWithTag("setting-microphone").assertIsEnabled() + .assert(SemanticsMatcher.expectValue(StateDescription, context.getString(R.string.widgets_off))).performClick() + assertEquals(listOf(true), changes) + } + + @Test fun sessionWithoutMicrophoneOmitsCaptureControl() { + compose.setContent { + DroidDeckTheme { + SessionDrawer(true, SessionDrawerPage.SESSION, false, {}, actions(false, emptyList())) + } + } + compose.onNodeWithTag("setting-microphone").assertDoesNotExist() + compose.onNodeWithText(context.getString(R.string.mode_mic)).assertDoesNotExist() + } + private fun checkModes(steam: Boolean) { SessionState.mode = if (steam) SessionService.MODE_STEAM else SessionService.MODE_DESKTOP val selected = mutableListOf<SecondScreenMode>() @@ -145,8 +209,11 @@ class SessionSecondScreenTest { if (view is ViewGroup) for (index in 0 until view.childCount) yieldAll(descendants(view.getChildAt(index))) } - private fun actions(steam: Boolean, displays: List<SecondScreenDisplay>, select: (SecondScreenMode) -> Unit = {}) = + private fun actions(steam: Boolean, displays: List<SecondScreenDisplay>, + micAvailable: Boolean = false, micEnabled: Boolean = false, onMic: (Boolean) -> Unit = {}, + select: (SecondScreenMode) -> Unit = {}) = DrawerActions(steam = steam, isHomeApp = false, androidApps = emptyList(), hudOn = false, + micAvailable = micAvailable, micEnabled = micEnabled, onMicEnabled = onMic, frameGen = FrameGen.Mode.OFF, lossless = Lossless.State.NONE, oscMode = "never", onScreenButtonsVisible = false, suspendPolicy = "auto", backActionsInverted = false, touchMode = "auto", touchAuto = "trackpad", fexPreset = "default", diff --git a/app/src/test/java/com/droiddeck/launcher/ui/StoreGridNavigationTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/StoreGridNavigationTest.kt index ad1b13df7..2fe9509d8 100644 --- a/app/src/test/java/com/droiddeck/launcher/ui/StoreGridNavigationTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/ui/StoreGridNavigationTest.kt @@ -46,9 +46,4 @@ class StoreGridNavigationTest { assertEquals(10, storeGridNeighbor(6, 11, 4, 1, split = 6)) assertNull(storeGridNeighbor(1, 11, 4, -1, split = 6)) } - - @Test fun installedGamesComeFirstKeepingTheirSortedOrder() { - val items = listOf("a", "b", "c", "d").map { com.droiddeck.launcher.stores.CatalogItem(com.droiddeck.launcher.stores.Store.GOG, it, it, null) } - assertEquals(listOf("b", "d", "a", "c"), installedFirst(items, setOf("d", "b")).map { it.id }) - } } diff --git a/app/src/test/java/com/droiddeck/launcher/ui/StoreTabsTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/StoreTabsTest.kt deleted file mode 100644 index a5578a0f7..000000000 --- a/app/src/test/java/com/droiddeck/launcher/ui/StoreTabsTest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.droiddeck.launcher.ui - -import com.droiddeck.launcher.stores.Store -import org.junit.Assert.assertEquals -import org.junit.Test - -class StoreTabsTest { - @Test fun libraryAndStoreAmazonLibraryOnly() { - assertEquals(listOf("library", "store"), tabsFor(Store.GOG)) - assertEquals(listOf("library", "store"), tabsFor(Store.ITCH)) - assertEquals(listOf("library"), tabsFor(Store.AMAZON)) - assertEquals("library", tabFor(Store.AMAZON, "store")) - assertEquals("library", tabFor(Store.GOG, "all")) - assertEquals("library", tabFor(Store.EPIC, "installed")) - assertEquals("store", tabFor(Store.GOG, "store")) - } -} diff --git a/app/src/test/java/com/droiddeck/launcher/ui/ThemeColorsTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/ThemeColorsTest.kt index b6f4de204..fdbb8b44b 100644 --- a/app/src/test/java/com/droiddeck/launcher/ui/ThemeColorsTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/ui/ThemeColorsTest.kt @@ -132,4 +132,40 @@ class ThemeColorsTest { assertTrue(Themes.all.any { it.id == Themes.GRADIENT }) assertEquals(Themes.GRAPHITE, Themes.byId("nonsense").id) } + + @Test fun innerGamerSitsJustAboveGradientInTheThemeList() { + val ids = Themes.all.map { it.id } + assertEquals(ids.indexOf(Themes.GRADIENT) - 1, ids.indexOf(Themes.INNER_GAMER)) + } + + @Test fun innerGamerButtonTextIsReadableAlongTheGradientAtEveryHue() { + var hue = 0f + while (hue < 360f) { + val p = Themes.innerGamer(hue) + assertEquals(Themes.INNER_GAMER, p.id) + for (i in 0..100) { + val f = i / 100f + val blend = Color( + p.primary.red + (p.primary2.red - p.primary.red) * f, + p.primary.green + (p.primary2.green - p.primary.green) * f, + p.primary.blue + (p.primary2.blue - p.primary.blue) * f, + ) + assertTrue("hue $hue at $f", contrast(p.onPrimary, blend) >= 4.49f) + } + hue += Themes.RGB_HUE_STEP + } + } + + @Test fun innerGamerFollowsTheHueAndTheSpeedIsSavedAndKeptInRange() { + Themes.moveRgbHue(120f) + assertEquals(Themes.innerGamer(120f).primary, Themes.byId(Themes.INNER_GAMER).primary) + Themes.setRgbSpeed(app, 7) + assertEquals(7, SessionPrefs.rgbSpeed(app)) + Themes.setRgbSpeed(app, 99) + assertEquals(Themes.RGB_SPEEDS, Themes.rgbSpeed) + Themes.setRgbSpeed(app, 0) + assertEquals(1, SessionPrefs.rgbSpeed(app)) + Themes.setRgbSpeed(app, 1) + assertTrue(Themes.rgbCycleMillis(1) > Themes.rgbCycleMillis(10)) + } } diff --git a/docs/agent-control.md b/docs/agent-control.md index 98cf552e3..5dc16253e 100644 --- a/docs/agent-control.md +++ b/docs/agent-control.md @@ -113,6 +113,10 @@ tools/droiddeckctl env set DXVK_HUD=fps tools/droiddeckctl env list tools/droiddeckctl env clear [next|persistent|all] tools/droiddeckctl override gamescope ./gamescope +tools/droiddeckctl override libblfastpath.so ./libblfastpath.so +tools/droiddeckctl proot-profile enable --slow-us 1000 +tools/droiddeckctl proot-profile disable +tools/droiddeckctl proot-profile report ./session.log --sort wall tools/droiddeckctl prefs get session tools/droiddeckctl prefs set session some_key true --type bool @@ -338,8 +342,11 @@ Automator show them: | Tag | Where | |---|---| -| `rail-steam`, `rail-games`, `rail-desktop`, `rail-store`, `rail-components`, `rail-android-apps`, `rail-setup`, `rail-updates` | the front end's rail | -| `play-steam` | Play on the Steam and Games pages | +| `rail-steam`, `rail-games` (the Library), `rail-desktop`, `rail-store`, `rail-components`, `rail-android-apps`, `rail-setup`, `rail-updates` | the front end's rail | +| `play-steam` | Play on the Steam page, and on the Library when it has no games | +| `library-source-<source>` | the Library's source filter chips (`all`, `steam`, `gog`, ...) | +| `game-tab-<about\|achievements\|launch\|saves\|install>` | the open tab of a game page | +| `stores-profile`, `stores-epic-launcher` | a store's profile strip, and the Epic Games Launcher row in Epic's | | `setting-<key>` | every toggle and choice row, by its key (`setting-logs`, `setting-agent-commands`, ...) | | `tab-<n>` | a page's tab strip (Setup's Overview, Controller, Session, Launcher...), by position | | `drawer-tab-controller`, `-second_screen`, `-display`, `-effects`, `-vr`, `-games`, `-session` | the session drawer's pages | @@ -364,3 +371,14 @@ sees it, and quits it: the test that catches a game left behind Steam's loading shows up in some fraction of launches. A game with a launcher of its own needs the launcher pressed through first, since the launcher window carries the game's app id too: `tools/scenarios/skyrim-launcher-focus.json` taps Skyrim's Play before checking. + +## PRoot performance experiments + +`proot-profile enable` queues handler profiling for the next session using the existing debugging +consent gate. Counters and bounded slow samples appear in `session.log`; `proot-profile report` +ranks a saved local log without ADB. This measures PRoot handler work, not full syscall latency. +`override libblfastpath.so FILE` selects a glibc ARM64 shared library in the next session's preload +list, reports its SHA-256, and refuses installation until the current session stops. It lasts for +one session; the next session stages the bundled library again. See +[the profiling workflow](development/proot-performance.md#finding-the-next-fast-path) for timing +boundaries, build commands, path diagnostics, and A/B validation. diff --git a/docs/development/added-games.md b/docs/development/added-games.md index c39ae8d14..625d8188e 100644 --- a/docs/development/added-games.md +++ b/docs/development/added-games.md @@ -1,6 +1,6 @@ # Added games -Windows games the user adds to DroidDeck's Games tab, each a non-Steam shortcut in the +Windows games the user adds to DroidDeck's Library, each a non-Steam shortcut in the Steam client that runs under `droiddeck-proton-arm64`. They show with the Custom chip. ## Adding diff --git a/docs/development/arm64-steam-client.md b/docs/development/arm64-steam-client.md new file mode 100644 index 000000000..bb9120017 --- /dev/null +++ b/docs/development/arm64-steam-client.md @@ -0,0 +1,59 @@ +# Native arm64 Steam client: channel and runtime + +The default client is Valve's arm64 Linux build (`steamrtarm64/steam`). Valve does not support it, but +publishes it on the Steam Deck client channels. `droiddeck-steam-install` installs it and +`droiddeck-session` starts it with `STEAM_RUNTIME=1`. + +## Channel + +- The default is `steamdeck_publicbeta` (manifest `steam_client_steamdeck_publicbeta_linuxarm64`). + Valve also publishes `steam_client_steamdeck_stable_linuxarm64`. +- `droiddeck-steam-install --channel` prints the channel in use. The session passes that value as + `-clientbeta` and writes it to `package/beta`. The two must match, or the client loops on rc 42. +- Setting: `BL_STEAM_CHANNEL`, otherwise `~/.config/droiddeck/steam-channel`, set to `beta` or + `stable`. A set channel never changes on its own. +- With no setting, the client falls back to `steamdeck_stable` automatically. It stays on stable + (`package/droiddeck-channel`) until a setting says otherwise. The fallback happens: + - when the client was started two more times without being shown after the start-failure repair + (`package/droiddeck-unconfirmed`: two unconfirmed starts trigger a repair, which writes + `repaired`; two more unconfirmed starts trigger the fallback); + - when the beta manifest has no `*_linuxarm64_linuxarm64` component. The install log then lists the + manifest's block names, so a renamed or removed arm64 build can be seen. +- The x86-64 client keeps `steamdeck_publicbeta` (see [x86-64-steam-client.md](x86-64-steam-client.md)). + +## Steam Runtime container + +Valve's 2026-10-15 beta made the x86 Linux client 64-bit and put it in a Steam Runtime container +(pressure-vessel). The container needs unprivileged user and mount namespaces and a D-Bus session bus, +which an Android app does not have. The x86-64 path already works around this. The arm64 client gets the +same treatment if Valve containerizes it too: + +- `STEAM_STEAMRT_RECURSION_GUARD=1` is exported for both clients. It tells the client that it is + already inside its container, so after an update it does not re-execute itself through + `pressure-vessel-wrap` (bwrap). +- `droiddeck-steamrt-shim <client dir> <shim dir>` builds the shim when the client carries + `pv-runtime/steam-runtime-steamrt` or a `steam-runtime-check-requirements` probe: + - Valve's runtime is linked file by file; + - the probe is replaced with `exit 0`; + - its own `steam-runtime-launch-client` runs commands directly. + + For the arm64 client the session then starts it with `STEAM_RUNTIME_STEAMRT` and `PATH` pointing + at `~/.droiddeck-steamrt-shim` and logs "Valve containerized the arm64 Steam client". Without those + files the shim is not built and the start is unchanged. + +## What Valve's manifests carry (checked 2026-10-22) + +- `steam_client_steamdeck_publicbeta_linuxarm64` (1792626604) and `steam_client_steamdeck_stable_linuxarm64` + (1792618285) have the same blocks. Three platforms share them: + - `*_linuxarm64_linuxarm64`: the native client (`steamrtarm64/`, `linuxarm64/`). No pressure-vessel, no + `steam-runtime-check-requirements`. These are the only platform blocks the installer keeps. + - `*_steamrt_linuxarm64`: Valve's x86-64 SteamRT 3c client (`steamrt64/steam` is an x86-64 ELF) with + `steamrt64/pv-runtime/steam-runtime-steamrt` (pressure-vessel, `steam-runtime-check-requirements`, + `steam-runtime-launch-client`). This is the containerized client the 2026-10-15 beta made the default on + x86. Valve ships it to arm64 hosts as well, presumably to run under FEX. The installer skips it. + - `bins_androidarm64_linuxarm64`: `androidarm64/libsteamclient.so`, `steamservice.so` and friends. +- `steam_client_steamdeck_publicbeta_ubuntu12` has stopped at 1792108788, while stable `ubuntu12` follows + stable (1792618285). On beta the 32-bit `ubuntu12` client no longer gets updates. +- If a future manifest drops `*_linuxarm64_linuxarm64`, the installer logs the block list and moves to + stable. If stable drops it too, the remaining option is the x86-64 client under FEX + ([x86-64-steam-client.md](x86-64-steam-client.md)). diff --git a/docs/development/epic-cleanroom.md b/docs/development/epic-cleanroom.md index e121d4caf..1dbaf7131 100644 --- a/docs/development/epic-cleanroom.md +++ b/docs/development/epic-cleanroom.md @@ -116,6 +116,10 @@ Numbers in brackets refer to the spec's list of unknowns. several native threads. 19. **Network window** is the speed tier's value clamped to 1–128; the per-host cap is `max(6, ceil(window / hosts))`. +20. **Pause keeps half-written files**: a stopped run keeps each unfinished stage file as a `.part` + file named after the file's path, hash and size, with a `.part.list` of the parts already + written. The next run writes only the other parts and hashes the whole file when it is complete; + that SHA-1 check still decides whether the file is kept, so files without one start over. **Kept at parity (known weak spots)** @@ -131,7 +135,7 @@ Numbers in brackets refer to the spec's list of unknowns. - `AdditionalCommandLine` is one argument, frozen at install [11]. - Shop search, an unused "wants an exchange code" helper and per-game uninstall work are not ported; DLC stay hidden and are never installed separately; optional (non-language) install tags - are never selected; resume restarts a partially written file from zero [15–18]. + are never selected [15–18]. - Library details are fetched one game at a time, as before. ## What needs device testing diff --git a/docs/development/game-environment.md b/docs/development/game-environment.md index a6e02b8e2..62530ca48 100644 --- a/docs/development/game-environment.md +++ b/docs/development/game-environment.md @@ -52,6 +52,12 @@ environment entries and is intentionally narrower than **Reset game profile**. Values are literal strings, with no shell expansion; quotes are only needed when the consuming program expects them. +A Unity game under FEX (`UnityPlayer.dll` for Windows builds under Proton, +`UnityPlayer.so` or `GameAssembly.so` for native Linux builds started by +**Linux x86 (FEX)**) keeps FEX's TSO on when the Default profile's FEX preset +turns it off: without TSO such games can deadlock or crash (seen with Megabonk +and Crab Game). A FEX preset chosen for the game itself still applies. + ## Defaults and available suggestions The initial configuration enables Mesa shader caching with diff --git a/docs/development/itch-browse.md b/docs/development/itch-browse.md new file mode 100644 index 000000000..aa1f26a6b --- /dev/null +++ b/docs/development/itch-browse.md @@ -0,0 +1,40 @@ +# itch.io browsing + +The itch Store tab opens on the front page shelves. A row of chips above them (`ui/ItchBrowseBar.kt`) +sets an order (Popular, New, Top rated, Top sellers) and a price (Any, Free, On sale). Leave both at +their defaults and the shelves stay; pick anything else and the tab shows that query's games as a grid +that loads the next page as it scrolls into view. + +## Data + +`ItchBrowse` (`stores/itch/ItchBrowse.kt`) turns a `BrowseQuery` into itch's listing feed: + +``` +https://itch.io/games[/<sort>][/<price>][/platform-<name>...][/tag-<slug>...].xml[?page=N] +``` + +`BrowseQuery` already carries platforms and tags, so the URL builder is complete; the controls for them +come in a later change. The path pieces are itch's own, copied from its links: sorts `new-and-popular`, `top-sellers`, `top-rated`, +`newest` (Popular has none); prices `free`, `on-sale`, `store` (Paid), `5-dollars-or-less`, +`15-dollars-or-less`; platforms `platform-windows`, `-osx`, `-linux`, `-android`, `-ios`, `-web`, +`-mobile-web`; a tag is `tag-` and its lowercase name with hyphens. `ItchBrowse.path()` builds the page +address, `url()` adds `.xml` and `?page=N`. Every request is logged +(`itch.io browse: <url> answered <code> with <n> games`). + +Requests go out one at a time, at least 1.2 s apart. A 429 waits 8 s and tries once more; if that fails +the games already on screen stay. Each page is kept under the app cache for three hours, so going back to +a query does not ask itch again. Results are parsed by `ItchShelves.parse`, the same reader the shelves +use, so a game looks the same wherever it appears. + +`ItchBrowseState` holds the query, the games loaded so far and whether more can be asked for. A newer +query bumps a generation counter, so a slow answer for an older one is dropped. A game already in the +library replaces its browse copy on the grid, so Install and the owned markers are right; the detail page +looks browse results up through `StoresState.detailItem`. + +## When a page fails + +A fetch that throws or answers with a non-2xx status marks the list as failed but leaves it open. The end of the list then shows a Retry button that asks for the same page again. Scrolling does not retry on its own, so a failing page cannot loop. Only a successful page with no new games ends the list. + +## Platform and tag + +The platform and tag rows take several choices, and the address lists them as itch does (`platform-windows/platform-linux/tag-pixel-art/tag-2d`, platforms in the order of the chip row). A typed tag becomes its slug (`Pixel Art` is `pixel-art`) and the address gets `tag-<slug>`. diff --git a/docs/development/itch-install.md b/docs/development/itch-install.md new file mode 100644 index 000000000..3db9fde5d --- /dev/null +++ b/docs/development/itch-install.md @@ -0,0 +1,21 @@ +# itch.io install and version picker + +Installing an itch.io game opens a version picker first. It lists the game's uploads from `/games/{id}/uploads`, with the platform, size and date of each. + +- Windows builds install. A game with a single Windows build skips the picker and installs at once. +- Other builds (macOS, Android, web) open the game's page on itch.io. +- Linux builds are listed but cannot be installed yet. + +## Free games + +A free game does not need to be in the library. The game's number is read from the `itch:path` meta tag of its page (`games/<id>`), and both the upload list and the download work with the API key alone and no download key. This was checked against a live free game: the upload list answered 200 and a one-byte range request on the download answered 206. + +A game that costs money and has no download key in the library is still refused. + +## Pieces + +`ItchGame` finds the game number and turns the upload list into builds. `ItchUploads.platforms` reads the platform from the upload's flags or `p_*` traits, and treats an upload with none as Windows. `ItchBuildPicker` is the pop-up. The chosen upload is carried in the item's `extra` as `uploadId`, and `ItchInstaller` downloads that upload. + +If the upload the user chose is no longer in the game's list when the install starts, the install fails with "No Windows download for this game" and does not pick another build. Only an install with no chosen upload falls back to the newest Windows build. The saved queue keeps whether a game is free, so an interrupted free-game install resumes after a restart. + +Before an install extracts, a leftover partial install of the same game (a folder whose marker said "installing" with this game's ID when the install started) is emptied, so a different build chosen after an interruption never mixes with files from the first. A finished or updating install, or another game's unfinished folder that happens to share the folder name, is never emptied. diff --git a/docs/development/proot-performance.md b/docs/development/proot-performance.md index 217650b53..bcf3424c1 100644 --- a/docs/development/proot-performance.md +++ b/docs/development/proot-performance.md @@ -314,3 +314,93 @@ it needs `patches/0014`, enabled with `PROOT_FASTPATH=1`. and `PROOT_FP_STATS` hit rates for steam, steamwebhelper, wine and wineserver. - **FEX**, inside Proton as a DLL, translates x86 path syscalls through glibc's `syscall()`, not the wrappers, so they still trap. A `syscall()` hook could route `openat` and `newfstatat` there too. + +## Finding the next fast path + +The prototype notes above describe the original experiments. Main now includes the fast path, +expanded wrappers, FEX raw-open routing ([#1283](https://github.com/Droid-Deck/DroidDeck/pull/1283)), +and SD-library bind-name resolution ([#1331](https://github.com/Droid-Deck/DroidDeck/pull/1331)). +Those fixes show why wrapper hit rates alone are insufficient: a caller can bypass the wrappers, +or repeatedly hit a path the wrappers cannot resolve. + +### Opt-in tracer profiling + +With **Setup > Diagnostics > Debugging tools** enabled, queue profiling for one session: + +```sh +tools/droiddeckctl proot-profile enable --slow-us 1000 +tools/droiddeckctl start steam --wait +# Reproduce the startup, game load, or streaming workload. +tools/droiddeckctl stop +tools/droiddeckctl logs latest ./profile-run +tools/droiddeckctl proot-profile report ./profile-run/session.log --sort wall +# Also inspect --sort cpu and --sort calls. +``` + +`PROOT_PROFILE=1` is forwarded from the session environment to the host tracer. Patch 0018 records +entry/exit **handler visits**, including PRoot's chained calls, by portable syscall name. It emits +`proot-profile` JSON lines to the existing `session.log`: cumulative totals every five seconds while +handling calls, totals at normal exit, and up to 32 slow-handler samples per five-second window exceeding `PROOT_PROFILE_SLOW_US` +(default 1,000 microseconds). The report uses each counter's last snapshot rather than adding +snapshots together and retains the most recent 128 slow samples. Logs can be pulled and reported while the session is running; final totals can +be lost if the tracer is killed. An empty report (`hasCounters: false`) is not evidence of zero +PRoot cost: check for the `start` record and that the installed PRoot pack contains patch 0018. + +**What the clocks mean:** handler wall time includes PRoot's path translation, filesystem waits +inside the tracer, and time the tracer is descheduled inside the handler. Thread CPU time measures +CPU used by that handler. Neither clock includes the tracee's subsequent kernel syscall, the +wake-up/queue delay before handler entry, or work outside `translate_syscall` (such as standalone +SIGSYS emulation, signal delivery, and process-event handling). Fast-path calls that avoid PRoot +are absent. Counts are handler visits, not a count of all guest syscalls. No extra ptrace stops or +syscall-exit interception are enabled for profiling. Profiling still costs clock reads, counters, +and log output; turn it off for final performance measurements. + +Rank cumulative time and frequency before chasing individual outliers. High handler CPU suggests +translation work; high handler wall with low CPU suggests filesystem or scheduling waits. Neither +proves how much a new fast path would save. Reproduce with the same app/PRoot/library build, +workload, storage, CPU policy, and cache state, then alternate A/B runs and measure the real load +milestone without profiling too. Keep `PROOT_FP_OFF=1` runs for correctness comparisons. + +For path context, the existing `PROOT_FP_LOG=/tmp/fastpath-misses.log` logs wrapper fallbacks and +`PROOT_FP_STATS=1` prints per-process hit/miss counts at normal exit. These are separate opt-ins: +miss logging writes on every fallback, may contain personal paths, and cannot see callers that +bypass the wrappers. Capture it separately from the session artifact folder. The new profiler +records syscall names, numeric IDs, and timings without paths or arguments. + +### Test a replacement library without rebuilding the APK + +Build a **glibc ARM64** library using the same toolchain as the shipped fast path: + +```sh +docker run --rm --platform linux/amd64 --user "$(id -u):$(id -g)" \ + -v "$PWD:/src" -w /src droiddeck-local-cross:24.04-v3 \ + aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -pthread \ + -o /src/libblfastpath.so tools/proot/fastpath/fastpath.c -ldl + +tools/droiddeckctl stop +tools/droiddeckctl override libblfastpath.so ./libblfastpath.so +tools/droiddeckctl proot-profile enable +tools/droiddeckctl start steam --wait +``` + +The existing override bridge now recognizes `libblfastpath.so`: it checks ELF class, endianness, +type, and machine, reports SHA-256, and selects that file in `/etc/ld.so.preload` for one session. +It rejects installation while a session is active. This is an architecture check, not validation +of libc compatibility or the library's behavior. The fast path still requires its normal enabled +setting and the patched PRoot pack. Its basename stays `libblfastpath.so`, so `libblsession`'s FEX +routing finds it. Guest-command restaging retains the active selection. After stopping, the next +session stages the bundled preload again; `env clear all` cancels queued overrides. + +This replaces a library **for a fresh session**. It does not rebind already-resolved symbols in +running Wine/FEX processes, unload old code, or load arbitrary plug-ins through a new ABI. True +in-process patching would need an explicit dispatch API and synchronization across every process. +[The dynamic loader](https://man7.org/linux/man-pages/man3/dlopen.3.html) resolves symbols within each process. +For now, restarting the session avoids those lifetime and binding problems while removing APK +build/install time from a fast-path iteration. Changes to the trampoline/filter contract still +require a matching PRoot build, not just a library swap. The profiler itself also needs a new +PRoot pack; CI builds and pins that pack alongside the APK. + +The next useful extensions are bounded path sampling tied to handler time, separate process/signal +stop counters, and scenario reports that compare the same game-load milestone across builds. +Keep candidate fast paths behind equivalence tests: a slow symlink, `/proc` lookup, unsupported +flag, or ambiguous bind must retain PRoot's semantics. diff --git a/docs/development/steam-display-scaling.md b/docs/development/steam-display-scaling.md index 9551de73a..3cc677db7 100644 --- a/docs/development/steam-display-scaling.md +++ b/docs/development/steam-display-scaling.md @@ -227,7 +227,7 @@ flag for an experiment. Device check (Thor): Alan Wake's American Nightmare with its 1280x720 Game Resolution runs on `:1` at 1280x720 with no `--force-windows-fullscreen` in the session, 2 frame-size changes, no crash -in logcat. The Games tab and the drawer render without the switch. +in logcat. The Library and the drawer render without the switch. ## Steam picture position diff --git a/docs/development/steam-game-imports.md b/docs/development/steam-game-imports.md index 7967dd650..78ae2cc11 100644 --- a/docs/development/steam-game-imports.md +++ b/docs/development/steam-game-imports.md @@ -48,6 +48,15 @@ inherit those routes. Managed shortcuts are replaced; user-created shortcuts and unreadable shortcut files are preserved. Both routes use the existing ARM64 Proton registration. +## Launcher artwork + +The installed Steam game scan lists each game's `appcache/librarycache/<appid>` +directory once for its cover, background, icon and logo. Filename preference is +checked before directory preference: the app folder, then hash folders sorted by +name. Hash-named fallback icons are still taken only from the app folder in its +listing order. The directory snapshot is not kept between scans, so the next +scan sees new artwork folders. + ## Validation Run `python3 -m unittest discover -s tools/tests -p 'test_*.py'` and Android unit tests. diff --git a/docs/development/stores.md b/docs/development/stores.md index 4fb78f4da..1209b7c47 100644 --- a/docs/development/stores.md +++ b/docs/development/stores.md @@ -3,6 +3,21 @@ How the Stores section (`app/src/main/java/com/droiddeck/launcher/stores/`, `ui/Stores*.kt`) fits the rest of the app, and the on-disk contract that makes a store game an ordinary added game. +## Library and Stores + +The rail's Library (`ui/LibraryPage.kt`, rail key `games`) lists every owned game: installed ones +from the scanned library (`FrontEndState.steamGames`: Steam, added folders, store installs), then, +under Not installed, each signed-in store's `StoresState.library` products that are not. An +installed game claims its product by `"<source>:<storeId>" == CatalogItem.key`, so nothing shows +twice. Stores is only for getting games: a store opens on its storefront (search, then the shelves) +under a profile strip whose "Owned games in Library" opens the Library filtered to that store. The +Epic Games Launcher (open, settings, uninstall, install) lives in Epic's profile strip. + +Every game opens one page (`ui/GameDetail.kt`) from either place: the hero and its actions by state +(installed, downloading, failed, unfinished, owned, not owned), then tabs LB/RB cycle through: +About, Achievements, Launch, Saves & cloud and Install, each hidden when it has nothing to show. +Library pages are `app:<appId>` for an installed game and `app:<store>:<id>` for a store product. + ## Where a store game lives ``` @@ -14,9 +29,11 @@ the rest of the app, and the on-disk contract that makes a store game an ordinar ``` The default root is the app's internal storage - the runtime's tree, `/root/Games/Stores`, no bind -needed - whatever the session's Game storage setting says. When a card the app may write is in the -device (`GameStorage.options`), Install asks "Install to" (internal / the card, free space shown; -the last pick is only the dialog's default). A card's root is `<card app folder>/Games`; when the +needed - whatever the session's Game storage setting says. **Setup > Storage > Default storage** +can send new installs straight to internal storage or the first external target; with **Ask every +time**, Install asks "Install to" when more than one target exists (internal / the card, free space +shown; the last pick is only the dialog's default). An existing install is always resumed in place, +and changing the default does not move it. A card's root is `<card app folder>/Games`; when the card is the Steam library the library's bind (`/mnt/droiddeck-sd`) already covers it, otherwise `SessionService` binds the root at `/mnt/droiddeck-stores/<volume uuid>`, so a shortcut's guest path - and its appid - is the same from one session to the next. Every root is @@ -85,18 +102,22 @@ installed record (`StoreInstalls.complete`). Resume, update and repair are the s no cache (needs only the net peak); when even that does not fit the install is refused before any chunk request, naming the storage, the need, the free space and the shortfall. - **Progress**: the bar counts pending-file bytes written (not compressed chunk bytes), at most every - 50 ms plus once per finished file. The run logs `epic: plan …` and `epic: summary mode=adaptive|narrow + 50 ms plus once per finished file. A resumed run starts the bar at what earlier runs kept; on a + fresh install that includes the files already complete, so the bar carries on from the pause. The run logs `epic: plan …` and `epic: summary mode=adaptive|narrow files=… bytes_written=… avg_mbps=… fetches=… refetches=… peak_cache_mib=… hosts=…`. - **Cancel / pause / failure**: the cancel flag is forwarded within 250 ms; the run stops its - requests, joins its threads, removes every unfinished stage file and `.part` cache entry, and only - then returns. Swapped files stay; unswapped destinations keep their old content; the chunk cache + requests, joins its threads, removes every `.part` cache entry, and only then returns. On a cancel + or a download error each unfinished stage file is kept in the staging folder as + `<path id>-<sha1>-<size>.part`, with the written parts' offsets in `<same>.part.list`; the next run + fetches only the other parts and hashes the whole file once it is complete (a partial that no + longer matches the manifest, or whose file has no SHA-1 in it, is deleted). Any other failure removes the unfinished stage files. Swapped files stay; unswapped destinations keep their old content; the chunk cache stays for the next run. Success removes the cache and the staging folder (when only its marker is left). The staging folder is recognised by its `owner` marker (`DroidDeck Epic staging v1` + LF). Before the delta pass the run checks that the folder is a real directory at the expected canonical path, -that the marker matches and that every other entry is a plain `.stage` file directly inside it; only -then are those orphans deleted. Anything unexpected fails the run without deleting. A manifest path +that the marker matches and that every other entry is a plain `.stage`, `.part` or `.part.list` file +directly inside it; only then are the `.stage` orphans deleted. Anything unexpected fails the run without deleting. A manifest path naming the staging folder, an absolute path or a `..` segment is refused. `.stage` / `.part` files elsewhere in the game folder are never touched. @@ -176,8 +197,8 @@ The appid Steam derives (CRC32 of the quoted exe plus the name, high bit set) is listing carries, so the live add and the writer never disagree. Uninstall removes the folder and the listing drops the entry; the live client is asked to remove it too. -`Library.SteamGame.source` is `steam`, `gog`, `epic`, `amazon` or `added`; the Games tab shows it as -a chip on every row and in the hero. +`Library.SteamGame.source` is `steam`, `gog`, `epic`, `amazon` or `added`; the Library shows it as +a chip on every tile and in the hero, and filters by it. ## Launch @@ -190,7 +211,7 @@ game that needs arguments or environment gets `.droiddeck-launch.bat` (`StoreLau the variable it read the code into is cleared on that same line, so nothing of it is left in the game's environment. A launcher from an earlier build is rewritten before the next Epic launch. -The code is minted for every launch, wherever it starts. The Games tab and Stores call +The code is minted for every launch, wherever it starts. The game page calls `StoreLaunch.prepare` before they launch; the compat tool (`droiddeck-proton`, `droiddeck-proton-wrap`) runs `droiddeck-store-launch` before Proton on every real launch, the Steam client's Play button included. It finds the store launcher among Steam's arguments, reads the @@ -198,7 +219,7 @@ sidecar beside it, and for an Epic game asks the app through `<session>/stores/r (`StoreLaunchRequests` → `StoreLaunch.epicCode`), waiting up to 5 s; the app writes the code into the game's folder itself. Each attempt logs `epic launch id=… code=yes|no reason=…`, never the code. -An Epic game's launch choices are the Epic card in the Games tab's launch settings - "Epic sign-in +An Epic game's launch choices are the Epic card in its page's Launch tab - "Epic sign-in (EOS)" (on) and "Launch offline" (off), stored in the sidecar's `epic` block so both launch paths read the same thing - and "Resolve Epic sign-in", which opens Epic's account page behind its sign-in in Android's browser. @@ -249,7 +270,20 @@ the prefix's user folder appears, within a minute. Every path logs `CloudSaveFolder`, e.g. `{AppData}/Game/Saved/SaveGames`. `CloudSavePaths` expands it inside the game's prefix (`compatdata/<shortcut appid>/pfx/drive_c/users/steamuser`) or its install folder, case-insensitively, and refuses a path that leaves its boundary. A game whose store gives none - has no cloud saves. + has no cloud saves; a cached none is retried after a day. +- **Galaxy SDK saves:** `cloudStorage.enabled` with an empty `locations` list is Galaxy SDK + IStorage, whose objects live under a location named `__default` in + `%LocalAppData%/GOG.com/Galaxy/Applications/<clientId>/Storage/Shared/Files`. It goes through the + same location-qualified handling below. GET peels gzip when `Content-Encoding` is gzip, or when + the object round-trips as GOG's PUT gzip (deflate 6, mtime 0); a gzip-format save is left intact. +- **GOG names are location-qualified:** the cloud API lists `saves/user1.dat`, where `saves` is the + `name` of the selected `cloudStorage.locations` entry and the folder is that entry's `location` + (`.../Hollow Knight Silksong/default`). `GogCloud` strips the location segment for the local path + and puts it back for `HEAD`/`GET`/`PUT`; files from other locations are ignored. Builds before + this wrote `<location>/...` one folder too deep (`default/saves/user1.dat`); `migrateGogNested` + moves those copies up, deletes byte-identical leftovers and rewrites their baseline keys. The + migration runs once per game (`cloud_layout_<id>`), so a game that legitimately keeps a folder + named like the location is not relocated again; a non-prefixed listing is left unchanged. - **The services:** GOG `cloudstorage.gog.com/v1/<user>/<client>` with a token issued to the game's own client (the client secret is kept at install; the Galaxy token is the fallback); Epic's savesync data storage with signed read and write links (`EpicCloud` → `EpicSaveSync` → the Rust @@ -289,7 +323,6 @@ report "the store engine is not available". | Setting | Pref | Where | |---|---|---| | Show Stores in the rail | `SessionPrefs.gameStoresEnabled` (off) | Setup › Stores | -| Open a store on: Library · Store | `SessionPrefs.storesOpenTab` (library) | the chip row's cog, Setup › Stores | | Download speed tier | `SessionPrefs.gameStoresSpeedTier` (fast) | the cog, Setup › Stores, Downloads | | Downloads at a time | `SessionPrefs.gameStoresParallel` (1) | Downloads | | Install to (last pick, dialog default only) | `SessionPrefs.storesInstallTarget` | the Install dialog | diff --git a/docs/development/themes.md b/docs/development/themes.md index b7f1a01eb..4bb122c6f 100644 --- a/docs/development/themes.md +++ b/docs/development/themes.md @@ -23,3 +23,17 @@ Setup shows Primary and Secondary under the theme when Gradient is chosen, each square. It opens `ColorPickerDialog`: a wheel (hue and saturation, touch), hue / saturation / brightness sliders (they work with a controller) and a hex box, with the old and new colour side by side. OK applies it. + +## Inner Gamer theme + +`Themes.INNER_GAMER` is an RGB theme: the accent walks round the colour wheel. It sits just above +Gradient in the list, and Setup shows a Speed slider (1 to 10, `SessionPrefs.rgbSpeed`) under the +theme while it is chosen. + +`Themes.innerGamer(hue)` builds the palette for one hue through `ThemeColors.palette`, so button text +keeps the same contrast along the gradient at every hue. `DroidDeckTheme` runs one +`rememberInfiniteTransition` while Inner Gamer is chosen, so the system's animation clock drives it. +Compose applies Android's animator scale to it, and the colour holds still when animations are off +in the app (`Motion.scale`) or in Android's own settings. The hue is written to `Themes.rgbHue` in 3° steps, which restyles the +app only when the colour visibly moves; a slow cycle costs few recompositions. + diff --git a/docs/development/x86-64-steam-client.md b/docs/development/x86-64-steam-client.md index f11923fe7..b3e2c5caf 100644 --- a/docs/development/x86-64-steam-client.md +++ b/docs/development/x86-64-steam-client.md @@ -3,7 +3,7 @@ Valve's x86-64 Linux Steam client, run unchanged under FEX beside the default arm64 client. It is opt-in: Steam settings, **Steam client: x86-64** (`steamClientArch`, passed to the session as `BL_STEAM_CLIENT_ARCH=x86_64`, applies from the next session), or the desktop menu entry -**Steam x86-64 (FEX, experimental)**. The Games tab lists the active client's games and tags its +**Steam x86-64 (FEX, experimental)**. The Library lists the active client's games and tags its header "x86-64" while that client's list is shown. This page records why each workaround exists and what was seen on devices. The timeline is in `docs/progress-log.md`. @@ -53,7 +53,8 @@ arm64 client down first. ("Steam now requires user namespaces") when `steam-runtime-check-requirements`' bwrap probe cannot create a user namespace, which no Android app can. `STEAM_RUNTIME_STEAMRT` points at `steamrt-shim`: Valve's runtime linked file by file with only the probe replaced (`exit 0`). proot - is the container, as for the arm64 client. + is the container, as for the arm64 client. `droiddeck-steamrt-shim` builds it, and builds the same + shim for the arm64 client if that one is containerized too ([arm64-steam-client.md](arm64-steam-client.md)). - **Re-exec after an update.** The client re-executes itself inside the SteamRT container through `pressure-vessel-wrap` (bwrap). `STEAM_STEAMRT_RECURSION_GUARD=1` says it is already inside one; the FEX rootfs with the client's `steamrt64` libraries is that container. @@ -290,13 +291,29 @@ alone would then read as an installed client. ## Libraries, SD card and sharing between clients -- **Separate libraries.** With folders shared, each client could update, verify or switch the - other's games. A title with a Linux build runs as that in the x86 client and as the Windows build in - the arm64 one, so opening it in the other client switched the files and the first client downloaded - its version again (Left 4 Dead 2, 8.6 GB). Hard links do not help: switching platform makes Steam - download depot 551 anyway. A game wanted in both clients is installed in both. Folders an earlier - version shared are taken out of the x86 client's lists (written only while no x86 client runs); the - games stay in the arm64 library. +- **Shared games.** Before either client starts, `droiddeck-steam-share` gives it a view of the games + the other client installed: `~/.droiddeck-x64/shared/arm64` for the x86 client ("ARM64 client + games") and `shared/x86-64` for the arm64 one ("x86-64 client games"). A view holds a copy of each + finished game's manifest and links to its `common` folder and `compatdata`, so the game shows as + installed in both clients, runs from the same files and keeps one prefix and one set of saves. + Runtimes, Proton and FEX are not shared. Device: the x86 client listed the 14 arm64 games as + installed (13 ready to launch, Valheim fetching its shader cache) and started Stardew Valley from the + arm64 files with no game data downloaded; the arm64 client listed Spacewar installed only in the x86 + client. +- **Same build in both.** A title with a Linux build ran as that in the x86 client and as the Windows + build in the arm64 one, so opening it in the other client switched the files and the first client + downloaded its version again (Left 4 Dead 2, 8.6 GB; hard links do not help, switching platform makes + Steam download depot 551 anyway). A shared game gets the tool its own client uses: `droiddeck-fex` + there becomes `droiddeck-direct` here and the other way round, a Proton build keeps its Proton. Only + an automatic choice that would switch the build is replaced; any other choice, including another + Proton, stays. +- **Updates and uninstall.** The newer manifest wins: an update made through a view is written back to + the client that owns the game. Uninstalling a shared game removes it from that client only; its files + stay with the client that installed it (Steam does not delete through the link) and it stays hidden + there until that client updates or reinstalls it. A game installed in both clients is each client's + own and is not shared. A view with nothing in it is not offered as an install location. Views are + written only while no client runs. Shader pre-caching stays each client's own: the x86 client may + fetch a shared game's shader cache into its own folder (Valheim, 1.8 GB). - **SD card.** The x86 client's card library is a folder inside the arm64 one (`/mnt/droiddeck-sd/x86-64`, `GameStorage.X64_CARD_DIR`, passed as `BL_X64_STEAM_LIBRARIES`), so the install dialog offers Internal Storage and SD card. compatdata, shadercache and runtime tools stay private, as in the arm64 card @@ -333,9 +350,11 @@ alone would then read as an installed client. redactor learns account names from both clients. The startup movie is staged into the x86 client's config only once its first start has created it; `--prepare` links the arm64 `config/uioverrides` and copies the `StartupMovie` choice. -- **Storage page.** It lists both clients' games, the x86 ones labelled; a game can be installed in - both and an id left over in several libraries. `steam://uninstall` reaches only the session's client, - so the other client's copy is uninstalled after switching clients. Tools in the x86 client's +- **Storage page.** It lists both clients' games, the x86 ones labelled, each where its files are, so a + shared game is counted once; a game can be installed in both and an id left over in several + libraries. The Games page of each client lists the games shared with it. `steam://uninstall` + reaches only the session's client, so the other client's own copy is uninstalled after switching + clients. Tools in the x86 client's libraries are shown as required (it runs its Linux games with Valve's runtimes). Proton tool files live only in the arm64 root; the x86 client has wrappers. An x86 client without `config.vdf` has never mapped a game. diff --git a/docs/releases/0.6.0.md b/docs/releases/0.6.0.md new file mode 100644 index 000000000..c14bda97c --- /dev/null +++ b/docs/releases/0.6.0.md @@ -0,0 +1,172 @@ +# DroidDeck 0.6.0 + +<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> + +<!-- Maintainer check: Steam, Proton downloads and the games themselves come from Valve and the stores and are not part of DroidDeck; the intro below only claims DroidDeck's own code and the packs it builds. --> + +> **DroidDeck is 100% open source. No ads. No telemetry. No source withheld.** +> +> DroidDeck is a community project in the truest sense. Because all of its code is public, the people who use it can read it, fix it and improve it, and in 0.6.0 you all sure did: +> +> **153 pull requests · 23 contributors · 10 first-timers · 73% of the pull requests came from the community.** +> +> Thank you to everyone who sent code, translations, device testing and bug reports. Here’s everything we did together for this release: + +### Features + +<!-- GIF: stores-capsules --> +<img src="media/0.6.0/stores-capsules.gif" width="50%" alt="The Epic library switching from cards to the capsule view, the Xbox library, an itch.io store search, then an installed game's page with its Play button. Account names are blurred"> + +- Added experimental **Xbox and PC Game Pass** as a store. Sign in with your Microsoft account to see your owned and Game Pass games, then download them with pause and resume, update, verify and manage add-on content. Games launch through Proton or FEX from the Games tab or straight from Steam. Search results open with their artwork and details, and Game Pass titles you have not installed yet can be installed from their page. (@xXJSONDeruloXx, @moronigranja, [#1156](https://github.com/Droid-Deck/DroidDeck/pull/1156), [#1164](https://github.com/Droid-Deck/DroidDeck/pull/1164), [#1170](https://github.com/Droid-Deck/DroidDeck/pull/1170), [#1193](https://github.com/Droid-Deck/DroidDeck/pull/1193), [#1279](https://github.com/Droid-Deck/DroidDeck/pull/1279)) +- Added an **itch.io** store. Sign in with your itch.io API key to see your library, then search the store or browse the newest, on-sale, free and top-rated shelves. DRM-free Windows builds install and launch through Steam like games from the other stores. (@kingokksa, [#1064](https://github.com/Droid-Deck/DroidDeck/pull/1064)) +- **Redesigned store pages.** Each store has **Library** and **Store** tabs, and the library lists installed games first. A new **capsule view** shows portrait DVD-case art in every store. LB and RB switch between stores, and installed games get a **Play** button on their page. You can reorder and hide store tabs in the stores settings, and stores you are not signed in to are dimmed. (@xXJSONDeruloXx, @kingokksa, [#1116](https://github.com/Droid-Deck/DroidDeck/pull/1116), [#1117](https://github.com/Droid-Deck/DroidDeck/pull/1117), [#1121](https://github.com/Droid-Deck/DroidDeck/pull/1121), [#1125](https://github.com/Droid-Deck/DroidDeck/pull/1125), [#1236](https://github.com/Droid-Deck/DroidDeck/pull/1236)) + +<!-- GIF: touch-gestures --> +<img src="media/0.6.0/touch-gestures.gif" width="50%" alt="Setting Touch to Gestures, going through the one-, two- and three-finger tabs and the searchable action picker, turning on Show recognized gesture, then two-finger drags moving through the Steam library and a pinch zoom, each gesture named at the top"> + +- Added configurable **touchscreen gestures**: taps, double taps, holds, one-, two- and three-finger drags and pinch zoom. Each one can be mapped to an action from a searchable list, with defaults and per-game overrides. (@Nightwalker743, @kingokksa, [#940](https://github.com/Droid-Deck/DroidDeck/pull/940), [#1214](https://github.com/Droid-Deck/DroidDeck/pull/1214)) +- **Steam Remote Play from DroidDeck** now streams video as well as audio and input. The picture is captured through PipeWire, as on SteamOS, and encoded by the phone's hardware encoder. On a Thor that takes 7.3–8.0 ms per frame, down from 50–90 ms with software encoding. Touch on the client can tap and drag, and games are heard on the stream. (@xXJSONDeruloXx, [#1139](https://github.com/Droid-Deck/DroidDeck/pull/1139)) + + <!-- Chart: remote-play-encode --> + <img src="media/0.6.0/remote-play-encode.png" width="80%" alt="Remote Play encode time per frame on an AYN Thor: 50 to 90 ms with software x264, 7.3 to 8.0 ms with the hardware encoder."> + +<!-- GIF: boot-movies --> +<img src="media/0.6.0/boot-movies.gif" width="50%" alt="Opening SteamDeckRepo, filtering to Steam Deck and Most liked, installing Handheld History by KnightMD and pressing Use, then starting Steam with the new boot movie. Waits are cut"> + +- **Boot movies from SteamDeckRepo.** Search SteamDeckRepo, filter by device and browse the Trending, Most downloaded, Most liked and Newest tabs, then install a movie and use it. Installed movies are listed with covers. Imported movies go into Steam's own movie folder, and the sound has volume and length sliders. (@FX192, [#1036](https://github.com/Droid-Deck/DroidDeck/pull/1036), [#1229](https://github.com/Droid-Deck/DroidDeck/pull/1229)) + +<!-- GIF: performance-hud --> +<img src="media/0.6.0/performance-hud.gif" width="50%" alt="In Valheim's intro, turning on the battery temperature and the frame-time graph, picking the Amber color and raising the opacity, then a close-up of the HUD over the game"> + +- The **performance HUD** can show a colored frame-time graph and the battery temperature. Each part can be shown or hidden, and its colors and opacity can be changed. (@0x1Nawaf, [#1256](https://github.com/Droid-Deck/DroidDeck/pull/1256)) + +<!-- GIF: gradient-theme --> +<img src="media/0.6.0/gradient-theme.gif" width="50%" alt="Choosing the Gradient theme, dragging the primary color around the wheel, typing a hex code for the secondary color, then the Games and Steam tabs in the new colors"> + +- Added a custom **Gradient theme**, with primary and secondary colors chosen from a color wheel, sliders or a hex code, and a **light app icon** option. (@FX192, @xXJSONDeruloXx, [#1309](https://github.com/Droid-Deck/DroidDeck/pull/1309), [#1104](https://github.com/Droid-Deck/DroidDeck/pull/1104)) +- **SteamVR controller types.** Without VR controllers, the gamepad can act as Valve Index controllers (the default), Meta Quest 3 Touch Plus, HTC Vive wands, or a regular gamepad. The sticks move you in the game by default, and pressing both sticks switches them to moving the hands. The type can be changed while SteamVR runs. With glasses, the phone becomes a control panel with Recenter, the SteamVR menu, the keyboard, brightness and battery. Flat games started from VR open as a SteamVR panel. (@xXJSONDeruloXx, [#1268](https://github.com/Droid-Deck/DroidDeck/pull/1268)) +- Added **Rokid Max and Air** glasses, with 3DoF head tracking, SBS modes, 120 Hz on the Max and brightness control, and experimental head tracking for **VITURE Pro 2** without the vendor SDK. (@xXJSONDeruloXx, @informalTechCode, @kingokksa, [#1266](https://github.com/Droid-Deck/DroidDeck/pull/1266), [#1115](https://github.com/Droid-Deck/DroidDeck/pull/1115)) +- **Easy Anti-Cheat games with an offline mode** start without the anti-cheat bootstrapper. This is on by default and can be turned off per game. Online modes that need Easy Anti-Cheat stay unavailable. (@xXJSONDeruloXx, [#1039](https://github.com/Droid-Deck/DroidDeck/pull/1039)) +- The **built-in controller can be turned off** (Steam settings > Controller > None), so a connected controller becomes player 1 for games that only read the first pad. (@kingokksa, [#1207](https://github.com/Droid-Deck/DroidDeck/pull/1207), [#1304](https://github.com/Droid-Deck/DroidDeck/pull/1304)) +- On foldables, the **unfolded controls can stay while a controller is connected**, for players who use a telescopic controller and still want the trackpads and back buttons. (@ovurrsl, [#996](https://github.com/Droid-Deck/DroidDeck/pull/996)) +- The component manager offers **nightly DXVK, VKD3D-Proton and FEX builds** from DroidDeck-Components, with a **Stable | Nightly** switch on each row. (@The412Banner, [#1284](https://github.com/Droid-Deck/DroidDeck/pull/1284)) +- When a game started from the Games tab closes, DroidDeck **returns you to where you launched it** instead of leaving you in Big Picture. (@The412Banner, [#395](https://github.com/Droid-Deck/DroidDeck/pull/395)) +- **Decky Loader** installs from the upstream SteamDeckHomebrew release, has a Stable or Prerelease update channel, and shows Decky's own third-party warning before a plugin ZIP is installed. (@xXJSONDeruloXx, [#1090](https://github.com/Droid-Deck/DroidDeck/pull/1090), [#1263](https://github.com/Droid-Deck/DroidDeck/pull/1263), [#1264](https://github.com/Droid-Deck/DroidDeck/pull/1264)) +- **Desktop mode can be uninstalled** from beside Open desktop, including a desktop install that was interrupted. Games, Steam, added apps and settings are kept. (@xXJSONDeruloXx, [#1311](https://github.com/Droid-Deck/DroidDeck/pull/1311)) +- **GOG and Epic installs can use your language.** The install dialog has a **Game language** choice, which follows the app's language by default, and the stores settings can pin a default. GOG games no longer download only their English files. (@LooLL, [#1199](https://github.com/Droid-Deck/DroidDeck/pull/1199)) +- Games can be **uninstalled or removed from their page in the Games tab**. Store games use their store's uninstaller. Steam games delete their install but keep their saves and Proton prefix. Added games only lose their shortcut, so their files stay. (@kingokksa, [#1190](https://github.com/Droid-Deck/DroidDeck/pull/1190)) + +### Improvements + +- **Performance** + - **Faster games and session starts on Adreno.** Games now use Turnip's tile rendering while the Steam interface keeps system memory. In PEAK on an Adreno 840 tablet, the frame rate rose 11.7% and frames slower than 25 ms dropped by 26%. Steam is ready 35.9% sooner after a session starts (5.57 s to 3.57 s). (@maxjivi05, [#1188](https://github.com/Droid-Deck/DroidDeck/pull/1188)) + + <!-- Chart: adreno-peak-and-steam-start --> + <img src="media/0.6.0/adreno-peak-and-steam-start.png" width="80%" alt="PEAK on an Adreno 840 tablet: 46.8 fps before and 52.3 fps in 0.6.0 (+11.7%). Frames shown for longer than 25 ms fall from 12.90% to 9.53%. Steam is ready 5.57 s after session start before and 3.57 s in 0.6.0 (-35.9%)."> + - **Faster game launches.** Steam's install-script step is skipped once it has finished for a game. In testing, Moonlighter went from 10.5 s to 7.7 s from Play to the game window. FEX's file lookups skip proot, so Graveyard Keeper reaches its first scene 0.57 s sooner with 48% fewer proot stops. (@maxjivi05, [#1273](https://github.com/Droid-Deck/DroidDeck/pull/1273), [#1283](https://github.com/Droid-Deck/DroidDeck/pull/1283)) + - **Games on an SD card library load far faster.** proot's fast path now names files and follows links inside a library the same way proot does, instead of handing every lookup back to proot. In testing, Red Dead Redemption 2's Story mode went from still loading after 16 minutes to the opening scene in about 2 minutes 10 seconds. (@maxjivi05, [#1331](https://github.com/Droid-Deck/DroidDeck/pull/1331)) + - **Less overhead from the performance overlay.** gamescope's CPU use dropped from about 12.9% to 7.7% of a core with the overlay hidden, and a 60 fps game on a 120 Hz panel no longer gets extra composites while the overlay is shown. Wine's network queries also cost less. (@maxjivi05, [#1272](https://github.com/Droid-Deck/DroidDeck/pull/1272), [#1274](https://github.com/Droid-Deck/DroidDeck/pull/1274), [#1275](https://github.com/Droid-Deck/DroidDeck/pull/1275)) + + <!-- Chart: speed-launch-load-overlay --> + <img src="media/0.6.0/speed-launch-load-overlay.png" width="80%" alt="Moonlighter from Play to game window: 10.5 s before, 7.7 s in 0.6.0. Graveyard Keeper to its first scene: 9.70 s before, 9.13 s in 0.6.0. Red Dead Redemption 2 Story mode from an SD card library: still loading after 16 minutes before (a lower bound), about 2 minutes 10 seconds in 0.6.0. gamescope CPU with Moonlighter at 120 fps: 12.85% of a core before and 7.65% in 0.6.0 with the overlay hidden, 19.05% and 10.35% with it visible."> + - The **Steam client responds sooner**: it paces itself to the panel's real refresh rate, and its interface sounds no longer queue about 112 ms of audio. The DirectAudio sink is now built from @The412Banner's [DirectAudio](https://github.com/The412Banner/directaudio) source. (@maxjivi05, [#1094](https://github.com/Droid-Deck/DroidDeck/pull/1094)) + + <!-- Chart: steam-client-responsiveness --> + <img src="media/0.6.0/steam-client-responsiveness.png" width="80%" alt="Steam client on a REDMAGIC Astra: per-run median press-to-response 41.0 to 41.2 ms before and 28.5 to 33.0 ms in 0.6.0. The interface frame interval falls from 10.8 ms (about 92 fps) to 8.3 ms (120 fps), and the interface sound queue from 105 to 120 ms to 28 to 45 ms."> + - The **GOG tab opens in seconds**, because the whole library comes from one listing instead of a request per game. (@kingokksa, [#786](https://github.com/Droid-Deck/DroidDeck/pull/786)) +- **Reduced the APK size** from 124.4 MB to 66.4 MB. (@xXJSONDeruloXx, [#1198](https://github.com/Droid-Deck/DroidDeck/pull/1198), [#1204](https://github.com/Droid-Deck/DroidDeck/pull/1204), [#1265](https://github.com/Droid-Deck/DroidDeck/pull/1265)) +- **Better battery life while a session is suspended.** The device can now really sleep: every guest thread stops and the audio stream is stopped. Before, a Thor did not suspend once in 13 minutes. Steam downloads keep running with the Manual policy, including downloads to SD-card libraries, and a switched-off Steam Controller no longer wakes Bluetooth all night. (@xXJSONDeruloXx, @Detinator10, [#1220](https://github.com/Droid-Deck/DroidDeck/pull/1220), [#1262](https://github.com/Droid-Deck/DroidDeck/pull/1262), [#1312](https://github.com/Droid-Deck/DroidDeck/pull/1312)) +- **zRAM is always on** where supported, and a session is paged out to zRAM before it is suspended, so Android is less likely to close it overnight. In testing, Bulletstorm's resident memory went from 1,886 MB to 318 MB while suspended, with the rest moved to zRAM. The zRAM setting has been removed. (@xXJSONDeruloXx, [#1317](https://github.com/Droid-Deck/DroidDeck/pull/1317)) + + <!-- Chart: suspend-sleep-and-memory --> + <img src="media/0.6.0/suspend-sleep-and-memory.png" width="80%" alt="On an AYN Thor, guest threads still running while suspended: Steam 59 of 72 and gamescope 8 of 15 before, none in 0.6.0. Bulletstorm's resident memory while suspended falls from 1,886 MB to 318 MB, with 1,570 MB moved to zRAM."> +- Sessions on an **external display or DeX** keep running while the phone's screen is off. (@maxjivi05, @xXJSONDeruloXx, [#1211](https://github.com/Droid-Deck/DroidDeck/pull/1211), [#1320](https://github.com/Droid-Deck/DroidDeck/pull/1320)) +- **Game cores** now apply to games started with **Linux x86 (FEX)** too. (@coffeemeist3r, [#1294](https://github.com/Droid-Deck/DroidDeck/pull/1294)) +- **GPU drivers:** Auto's picks come from a manifest in Drivers-CI, so a default can change without an app update. The Snapdragon SM8975 is recognized and Adreno 850 support is marked experimental. A trial driver that crashes at startup now rolls back automatically. (@xXJSONDeruloXx, @Danbro007, @ovurrsl, [#977](https://github.com/Droid-Deck/DroidDeck/pull/977), [#1249](https://github.com/Droid-Deck/DroidDeck/pull/1249), [#1031](https://github.com/Droid-Deck/DroidDeck/pull/1031)) +- The KDE desktop is updated to **KWin 6.8.0**. (@xXJSONDeruloXx, [#1328](https://github.com/Droid-Deck/DroidDeck/pull/1328), [#1330](https://github.com/Droid-Deck/DroidDeck/pull/1330)) +- New sessions default to **landscape**, and the Deck controls open on **both trackpads**. (@xXJSONDeruloXx, [#1287](https://github.com/Droid-Deck/DroidDeck/pull/1287), [#1295](https://github.com/Droid-Deck/DroidDeck/pull/1295)) +- **Accessibility:** buttons, tiles and list rows are announced as buttons by screen readers, the Electric theme's buttons meet WCAG contrast, and section headings are capitalized correctly in the app's language. (@ovurrsl, [#891](https://github.com/Droid-Deck/DroidDeck/pull/891), [#1149](https://github.com/Droid-Deck/DroidDeck/pull/1149), [#1145](https://github.com/Droid-Deck/DroidDeck/pull/1145), [#1148](https://github.com/Droid-Deck/DroidDeck/pull/1148)) +- **Translations:** Turkish covers every new screen, every language has the plural forms it needs, and English left in the translations is translated. New features in this release ship in all 32 languages. The KDE desktop's translations are now built as per-language packs, ready for a translated desktop. (@ovurrsl, @maxjivi05, @kingokksa, [#989](https://github.com/Droid-Deck/DroidDeck/pull/989), [#1102](https://github.com/Droid-Deck/DroidDeck/pull/1102), [#942](https://github.com/Droid-Deck/DroidDeck/pull/942)) +- **Building and testing DroidDeck yourself** is easier: local builds find Java 17 installed with mise, reuse add-on packs a CI build installed, and `tools/device-smoke.sh` checks a build on a real device over plain adb. Session logs time each setup step to the millisecond. (@ugreg, @xXJSONDeruloXx, @ovurrsl, @maxjivi05, [#1231](https://github.com/Droid-Deck/DroidDeck/pull/1231), [#1265](https://github.com/Droid-Deck/DroidDeck/pull/1265), [#911](https://github.com/Droid-Deck/DroidDeck/pull/911), [#993](https://github.com/Droid-Deck/DroidDeck/pull/993), [#1091](https://github.com/Droid-Deck/DroidDeck/pull/1091), [#1151](https://github.com/Droid-Deck/DroidDeck/pull/1151)) + +### Fixed + +- Fixed **Ubisoft games** stopping with "Error at hooking API" (for example Immortals Fenyx Rising), and other programs that patch their own code under FEX. (@maxjivi05, [#1163](https://github.com/Droid-Deck/DroidDeck/pull/1163)) +- Fixed **Steam going back to sleep** right after a long-paused session resumed. (@maxjivi05, [#1162](https://github.com/Droid-Deck/DroidDeck/pull/1162)) +- Fixed **controllers in Rewired (Unity) games** such as Drova and Graveyard Keeper, and in **Factorio**'s ARM64 build. (@Delete228, @maxjivi05, [#1195](https://github.com/Droid-Deck/DroidDeck/pull/1195), [#1222](https://github.com/Droid-Deck/DroidDeck/pull/1222), [#1227](https://github.com/Droid-Deck/DroidDeck/pull/1227)) +- Fixed **Linux games under FEX**: games such as Stardew Valley start again, have sound, find OpenSSL 3 and reach FEXServer. CJK text shows in x86 apps such as the Epic Games Launcher, and clearing a game's compatibility tool in Steam keeps its Linux build. x86 Proton runs in the Steam runtime it requires. (@maxjivi05, @xXJSONDeruloXx, [#1100](https://github.com/Droid-Deck/DroidDeck/pull/1100), [#1166](https://github.com/Droid-Deck/DroidDeck/pull/1166), [#1167](https://github.com/Droid-Deck/DroidDeck/pull/1167), [#1213](https://github.com/Droid-Deck/DroidDeck/pull/1213), [#1168](https://github.com/Droid-Deck/DroidDeck/pull/1168), [#1250](https://github.com/Droid-Deck/DroidDeck/pull/1250), [#1034](https://github.com/Droid-Deck/DroidDeck/pull/1034)) +- Fixed the **x86-64 Steam client** staying on a blank screen, asking for the password again and listing games twice. (@maxjivi05, [#1096](https://github.com/Droid-Deck/DroidDeck/pull/1096)) +- Fixed **garbled Chinese, Japanese and Korean text** in Steam's interface. (@zooluis, @xXJSONDeruloXx, [#1319](https://github.com/Droid-Deck/DroidDeck/pull/1319)) +- Fixed **flicker and green flashes on Samsung One UI devices with a Snapdragon 8 Gen 2** (Galaxy S23 series, Tab S9). (@xXJSONDeruloXx, [#1128](https://github.com/Droid-Deck/DroidDeck/pull/1128)) +- Fixed the **GPU load reading 0%** in the performance overlay on Adreno phones. (@angelrenefm, @xXJSONDeruloXx, [#1326](https://github.com/Droid-Deck/DroidDeck/pull/1326), [#1327](https://github.com/Droid-Deck/DroidDeck/pull/1327)) +- Fixed gamescope running out of file handles in long sessions with lots of touch input. (@asdf17128, [#1189](https://github.com/Droid-Deck/DroidDeck/pull/1189)) +- Fixed **GOG games missing their registry entries**, such as Fallout: New Vegas saying it is not installed, and the "already installed" markers of Windows redistributables never reaching the user registry. (@maxjivi05, @kaosjammo, [#1159](https://github.com/Droid-Deck/DroidDeck/pull/1159), [#1286](https://github.com/Droid-Deck/DroidDeck/pull/1286)) +- Fixed a store game's **first launch missing its cloud saves**, and manual cloud-save Upload and Download reporting success when nothing was synced. (@maxjivi05, @ovurrsl, [#1215](https://github.com/Droid-Deck/DroidDeck/pull/1215), [#1068](https://github.com/Droid-Deck/DroidDeck/pull/1068)) +- Fixed the **on-screen pad covering Steam's keyboard** and DroidDeck's PC keyboard. (@maxjivi05, @ovurrsl, [#1217](https://github.com/Droid-Deck/DroidDeck/pull/1217), [#956](https://github.com/Droid-Deck/DroidDeck/pull/956), [#1221](https://github.com/Droid-Deck/DroidDeck/pull/1221)) +- Fixed **Steam touch layouts**: third-party and added games load their own layout, layouts of non-Steam shortcuts such as the Epic Games Launcher can be edited and saved, and held controls stay pressed across action-set and layer changes. Labels with quotes no longer break the config. (@kingokksa, @maxjivi05, @ovurrsl, [#1058](https://github.com/Droid-Deck/DroidDeck/pull/1058), [#1171](https://github.com/Droid-Deck/DroidDeck/pull/1171), [#1143](https://github.com/Droid-Deck/DroidDeck/pull/1143), [#1017](https://github.com/Droid-Deck/DroidDeck/pull/1017)) +- Fixed touch input: long-press right-click in Direct touch, the saved touch mode coming back when Steam Touch is turned off, and the touchpad cursor jumping after a two-finger scroll. (@xXJSONDeruloXx, @ovurrsl, [#677](https://github.com/Droid-Deck/DroidDeck/pull/677), [#1142](https://github.com/Droid-Deck/DroidDeck/pull/1142)) +- Fixed controller input: moving a stick no longer releases a held D-pad direction or trigger, Xiaomi HyperOS's virtual input device no longer takes player 1, and back-to-back reports to a Bluetooth Steam Controller are no longer dropped. (@ovurrsl, [#1060](https://github.com/Droid-Deck/DroidDeck/pull/1060), [#954](https://github.com/Droid-Deck/DroidDeck/pull/954), [#1032](https://github.com/Droid-Deck/DroidDeck/pull/1032)) +- Fixed Steam's **"Steam Controller detected!" tour** opening at every start, and the emulated Steam Controller looking out of date to Steam. (@xXJSONDeruloXx, [#1321](https://github.com/Droid-Deck/DroidDeck/pull/1321), [#1323](https://github.com/Droid-Deck/DroidDeck/pull/1323)) +- Fixed **SteamVR starting in a normal Steam session** after a VR session had been used. (@xXJSONDeruloXx, [#1137](https://github.com/Droid-Deck/DroidDeck/pull/1137)) +- Fixed **adding games from an SD card**, both a whole games folder and a single EXE, and adding a game whose folder sits inside another added game's folder. (@The412Banner, @maxjivi05, [#1153](https://github.com/Droid-Deck/DroidDeck/pull/1153), [#1173](https://github.com/Droid-Deck/DroidDeck/pull/1173), [#1155](https://github.com/Droid-Deck/DroidDeck/pull/1155)) +- Fixed the games-folder scan hiding games such as Reach, Breach, Crashday and Observer, and Unreal Engine games being named "BootstrapPackagedGame". (@ovurrsl, @charan-rathore, [#1023](https://github.com/Droid-Deck/DroidDeck/pull/1023), [#1020](https://github.com/Droid-Deck/DroidDeck/pull/1020)) +- Added games start sooner on large Steam libraries, and game files keep the full game name. (@ovurrsl, @maxjivi05, [#994](https://github.com/Droid-Deck/DroidDeck/pull/994), [#1230](https://github.com/Droid-Deck/DroidDeck/pull/1230)) +- Fixed a game's **Components** button opening the default profile instead of the game's own. (@jmarti326, [#1146](https://github.com/Droid-Deck/DroidDeck/pull/1146)) +- Fixed **desktop apps and emulators**: RPCS3 opens from the emulator rail, RetroArch fills the screen, Chromium browsers start, browsers added as non-Steam games show their window, Flatpak apps open in the renamed app variants, user apps open at the Desktop's resolution, and programs that need an executable stack start. Apps started from the front end show their own cursor, and Steam shortcuts are kept out of the desktop's controls. (@maxjivi05, @Delete228, @xXJSONDeruloXx, [#1191](https://github.com/Droid-Deck/DroidDeck/pull/1191), [#1194](https://github.com/Droid-Deck/DroidDeck/pull/1194), [#1280](https://github.com/Droid-Deck/DroidDeck/pull/1280), [#1243](https://github.com/Droid-Deck/DroidDeck/pull/1243), [#1192](https://github.com/Droid-Deck/DroidDeck/pull/1192), [#1172](https://github.com/Droid-Deck/DroidDeck/pull/1172), [#1208](https://github.com/Droid-Deck/DroidDeck/pull/1208), [#1154](https://github.com/Droid-Deck/DroidDeck/pull/1154), [#1318](https://github.com/Droid-Deck/DroidDeck/pull/1318)) +- Fixed **store sign-in**: GOG lets you sign in with a different account, and one failing image or script no longer replaces the Epic sign-in page with an SSL error. (@maxjivi05, [#1235](https://github.com/Droid-Deck/DroidDeck/pull/1235), [#1238](https://github.com/Droid-Deck/DroidDeck/pull/1238)) +- Fixed the **file manager**: copy and move keep symbolic links, Cancel really stops a paste, a double tap no longer starts two pastes, selection follows the search filter, and a slow folder no longer replaces the one you moved to. (@ovurrsl, [#1027](https://github.com/Droid-Deck/DroidDeck/pull/1027), [#1029](https://github.com/Droid-Deck/DroidDeck/pull/1029), [#1025](https://github.com/Droid-Deck/DroidDeck/pull/1025), [#1021](https://github.com/Droid-Deck/DroidDeck/pull/1021), [#1028](https://github.com/Droid-Deck/DroidDeck/pull/1028)) +- Fixed an interrupted desktop or package install breaking the Linux runtime, which used to need an app reinstall. (@xXJSONDeruloXx, [#1078](https://github.com/Droid-Deck/DroidDeck/pull/1078)) +- A failed Steam client download now says your network or VPN may be blocking Steam and offers Retry, instead of silently ending the session. (@ovurrsl, [#997](https://github.com/Droid-Deck/DroidDeck/pull/997)) +- Fixed **in-app updates on Android 9 and 10**. (@ovurrsl, [#1066](https://github.com/Droid-Deck/DroidDeck/pull/1066)) +- Fixed crashes with a full disk while fetching cover art and with an empty App ID in the game environment editor. (@ovurrsl, [#1030](https://github.com/Droid-Deck/DroidDeck/pull/1030), [#1014](https://github.com/Droid-Deck/DroidDeck/pull/1014)) +- Fixed DirectAudio setup changing a game's audio choice during Steam's path queries and install scripts. (@xXJSONDeruloXx, [#1044](https://github.com/Droid-Deck/DroidDeck/pull/1044), [#1046](https://github.com/Droid-Deck/DroidDeck/pull/1046)) +- Hidden and private Steam games no longer show on the Steam home art wall. (@xXJSONDeruloXx, [#1097](https://github.com/Droid-Deck/DroidDeck/pull/1097)) +- Storage asks before bulk-removing unused components, and store search no longer reopens the keyboard when you scroll back up. (@ovurrsl, [#1140](https://github.com/Droid-Deck/DroidDeck/pull/1140), [#1141](https://github.com/Droid-Deck/DroidDeck/pull/1141)) +- Moving a mouse no longer redraws the whole session overlay. (@maxjivi05, [#1253](https://github.com/Droid-Deck/DroidDeck/pull/1253)) + +### Community + +DroidDeck is built in the open by the people who use it. These are everyone whose code is in 0.6.0: + +| Contributor | Pull requests | +|---|---| +| @xXJSONDeruloXx | 48: Xbox and Game Pass, store redesign, Remote Play, add-on packs, SteamVR controller types, Rokid glasses, suspend and zRAM, Decky, KWin 6.8 and more | +| @maxjivi05 | 41: performance, FEX and Linux games, Steam client, Ubisoft and GOG fixes, Rewired pads, desktop apps and emulators | +| @ovurrsl | 33: file manager, controller and touch fixes, security, accessibility, Turkish, cloud saves, device smoke tests | +| @kingokksa | 10: itch.io store, uninstall from Games, store tab order, controller off, GOG speed-up, Steam touch layouts | +| @The412Banner | 3: return to launcher, SD-card games folders, nightly components (and the DirectAudio sink) | +| @FX192 | 3: SteamDeckRepo boot movies, Gradient theme | +| @Nightwalker743 | [#940](https://github.com/Droid-Deck/DroidDeck/pull/940): touchscreen gestures | +| @0x1Nawaf | [#1256](https://github.com/Droid-Deck/DroidDeck/pull/1256): performance HUD graph and options | +| @LooLL | [#1199](https://github.com/Droid-Deck/DroidDeck/pull/1199): game language for GOG and Epic | +| @moronigranja | [#1279](https://github.com/Droid-Deck/DroidDeck/pull/1279): Xbox search results and Game Pass installs | +| @informalTechCode | [#1115](https://github.com/Droid-Deck/DroidDeck/pull/1115): VITURE Pro 2 head tracking | +| @Delete228 | [#1195](https://github.com/Droid-Deck/DroidDeck/pull/1195), [#1318](https://github.com/Droid-Deck/DroidDeck/pull/1318): Factorio controller input, second-screen controls in the desktop | +| @Danbro007 | [#1249](https://github.com/Droid-Deck/DroidDeck/pull/1249): SM8975 and Adreno 850 detection | +| @coffeemeist3r | [#1294](https://github.com/Droid-Deck/DroidDeck/pull/1294): Game cores for FEX games | +| @angelrenefm | [#1326](https://github.com/Droid-Deck/DroidDeck/pull/1326): GPU load in the overlay | +| @asdf17128 | [#1189](https://github.com/Droid-Deck/DroidDeck/pull/1189): gamescope cursor buffer leak | +| @kaosjammo | [#1286](https://github.com/Droid-Deck/DroidDeck/pull/1286): Windows redistributable registry markers | +| @jmarti326 | [#1146](https://github.com/Droid-Deck/DroidDeck/pull/1146): Components opens the game's profile | +| @charan-rathore | [#1020](https://github.com/Droid-Deck/DroidDeck/pull/1020): Unreal Engine game names | +| @wallentx | [#1063](https://github.com/Droid-Deck/DroidDeck/pull/1063): readable library names in logs | +| @ugreg | [#1231](https://github.com/Droid-Deck/DroidDeck/pull/1231): Java 17 through mise for local builds | +| @zooluis | [#1319](https://github.com/Droid-Deck/DroidDeck/pull/1319): CJK fonts in Steam | +| @Detinator10 | [#1220](https://github.com/Droid-Deck/DroidDeck/pull/1220): full session freeze for sleep | + +<!-- Full PR lists per contributor are in contributors.md. --> + +### New contributors + +- @Nightwalker743 made their first contribution in [#940](https://github.com/Droid-Deck/DroidDeck/pull/940). +- @charan-rathore made their first contribution in [#1020](https://github.com/Droid-Deck/DroidDeck/pull/1020). +- @Delete228 made their first contribution in [#1195](https://github.com/Droid-Deck/DroidDeck/pull/1195). +- @LooLL made their first contribution in [#1199](https://github.com/Droid-Deck/DroidDeck/pull/1199). +- @Detinator10 made their first contribution in [#1220](https://github.com/Droid-Deck/DroidDeck/pull/1220). +- @ugreg made their first contribution in [#1231](https://github.com/Droid-Deck/DroidDeck/pull/1231). +- @Danbro007 made their first contribution in [#1249](https://github.com/Droid-Deck/DroidDeck/pull/1249). +- @moronigranja made their first contribution in [#1279](https://github.com/Droid-Deck/DroidDeck/pull/1279). +- @coffeemeist3r made their first contribution in [#1294](https://github.com/Droid-Deck/DroidDeck/pull/1294). +- @zooluis made their first contribution in [#1319](https://github.com/Droid-Deck/DroidDeck/pull/1319). + +[Full changelog](https://github.com/Droid-Deck/DroidDeck/compare/0.5.0...0.6.0) diff --git a/docs/releases/media/0.6.0/adreno-peak-and-steam-start.png b/docs/releases/media/0.6.0/adreno-peak-and-steam-start.png new file mode 100644 index 000000000..89de2c6ee Binary files /dev/null and b/docs/releases/media/0.6.0/adreno-peak-and-steam-start.png differ diff --git a/docs/releases/media/0.6.0/boot-movies.gif b/docs/releases/media/0.6.0/boot-movies.gif new file mode 100644 index 000000000..da773d55b Binary files /dev/null and b/docs/releases/media/0.6.0/boot-movies.gif differ diff --git a/docs/releases/media/0.6.0/gradient-theme.gif b/docs/releases/media/0.6.0/gradient-theme.gif new file mode 100644 index 000000000..5d0f97552 Binary files /dev/null and b/docs/releases/media/0.6.0/gradient-theme.gif differ diff --git a/docs/releases/media/0.6.0/performance-hud.gif b/docs/releases/media/0.6.0/performance-hud.gif new file mode 100644 index 000000000..0ee90601c Binary files /dev/null and b/docs/releases/media/0.6.0/performance-hud.gif differ diff --git a/docs/releases/media/0.6.0/remote-play-encode.png b/docs/releases/media/0.6.0/remote-play-encode.png new file mode 100644 index 000000000..8088bbf2e Binary files /dev/null and b/docs/releases/media/0.6.0/remote-play-encode.png differ diff --git a/docs/releases/media/0.6.0/speed-launch-load-overlay.png b/docs/releases/media/0.6.0/speed-launch-load-overlay.png new file mode 100644 index 000000000..9936c139e Binary files /dev/null and b/docs/releases/media/0.6.0/speed-launch-load-overlay.png differ diff --git a/docs/releases/media/0.6.0/steam-client-responsiveness.png b/docs/releases/media/0.6.0/steam-client-responsiveness.png new file mode 100644 index 000000000..a31314d2f Binary files /dev/null and b/docs/releases/media/0.6.0/steam-client-responsiveness.png differ diff --git a/docs/releases/media/0.6.0/stores-capsules.gif b/docs/releases/media/0.6.0/stores-capsules.gif new file mode 100644 index 000000000..4c5726b2d Binary files /dev/null and b/docs/releases/media/0.6.0/stores-capsules.gif differ diff --git a/docs/releases/media/0.6.0/suspend-sleep-and-memory.png b/docs/releases/media/0.6.0/suspend-sleep-and-memory.png new file mode 100644 index 000000000..b1d42ea09 Binary files /dev/null and b/docs/releases/media/0.6.0/suspend-sleep-and-memory.png differ diff --git a/docs/releases/media/0.6.0/touch-gestures.gif b/docs/releases/media/0.6.0/touch-gestures.gif new file mode 100644 index 000000000..dedfce85f Binary files /dev/null and b/docs/releases/media/0.6.0/touch-gestures.gif differ diff --git a/docs/smart-glasses-support.md b/docs/smart-glasses-support.md index 416b0c3f4..19f59c200 100644 --- a/docs/smart-glasses-support.md +++ b/docs/smart-glasses-support.md @@ -1,6 +1,6 @@ # Smart-glasses support -This describes the integrations implemented in DroidDeck as of 2026-10-17. A recognized product name alone does not establish that every firmware, phone, cable, or adapter supports every mode. Controls become available from the connected device's capabilities and the display modes Android actually exposes. +This describes the integrations implemented in DroidDeck as of 2026-10-24. A recognized product name alone does not establish that every firmware, phone, cable, or adapter supports every mode. Controls become available from the connected device's capabilities and the display modes Android actually exposes. **3DoF** tracks head rotation: turn, tilt, and roll around a virtual screen. **6DoF** also tracks head position, so leaning or moving changes the view. Both apply to DroidDeck menus, non-Steam launchers, desktop sessions, and games when the tracked virtual screen is enabled. Direct SteamVR rendering uses the corresponding headset pose instead of the virtual-screen layout. @@ -23,10 +23,10 @@ This describes the integrations implemented in DroidDeck as of 2026-10-17. A rec | Recognized RayNeo / TCL Air-series devices | Integrated RayNeo USB transport | Yes, with orientation calibration controls | No | No | | RayNeo GT-series devices matching the supported USB identity, including GT Max identification | Integrated GT USB/IMU path | Yes, with orientation calibration controls | No | No | | **XREAL 1S** | **Experimental**; exact USB identity plus the device's USB-Ethernet control/IMU connection | Yes, when that connection is ready | No | No | -| Rokid Max; Rokid Air under the same USB identity | Integrated Rokid USB transport (vendor control requests and HID IMU reports) | Yes | No | No | +| Rokid Max / Max 2; Rokid Air under the Max USB identity | Integrated Rokid USB transport (vendor control requests and HID IMU reports) | Yes | No | No | | Other XREAL models; unrecognized glasses; display adapters and docks | Display-only where Android exposes a usable display; automatic glasses routing also requires display recognition | No supported app tracking connection | No | No | -RayNeo's Air-series recognition uses its known USB identity or a RayNeo/TCL Air name under the accepted vendor IDs; it is not a per-model certification list. The GT vendor path requires the specific supported product ID. Rokid recognition requires USB 04d2:162f plus a Rokid product name, since that vendor ID is shared with unrelated devices; it was verified on a Rokid Max, and the Air follows the same published protocol untested. XREAL support currently targets **1S only**: One, One Pro, Air-family devices, and recovery-mode products are not enabled by the 1S transport. VITURE Pro 2 is identified through SDK validation rather than a guessed product ID. A dock does not supply the connected glasses' sensors by itself. +RayNeo's Air-series recognition uses its known USB identity or a RayNeo/TCL Air name under the accepted vendor IDs; it is not a per-model certification list. The GT vendor path requires the specific supported product ID. Rokid recognition requires USB 04d2:162f (Max/Air) or 04d2:2002 (Max 2), plus a Rokid product name, since that vendor ID is shared with unrelated devices. Max 2 reports the USB product name "Rokid Max2". Hardware checks cover the Max and Max 2; the Air follows the same published protocol untested. XREAL support currently targets **1S only**: One, One Pro, Air-family devices, and recovery-mode products are not enabled by the 1S transport. VITURE Pro 2 is identified through SDK validation rather than a guessed product ID. A dock does not supply the connected glasses' sensors by itself. The final column refers specifically to Luma Ultra's stereo tracking cameras. DroidDeck's separate phone-camera and physical-controller features are independent of this matrix. @@ -43,7 +43,7 @@ All display modes depend on the phone's video output, the cable/adapter, and And | VITURE Pro 2 | 1080p per eye; 2D and SBS | Brightness and volume; no electrochromic-film control | | RayNeo Air/GT | 2D/SBS switching; resolution and refresh follow available Android modes | Orientation calibration; no app brightness, volume, or dimming commands | | XREAL 1S | Supported 1S display-mode commands, including native 1200p/60 Hz and SBS | Brightness and dimming toggle; no app volume command | -| Rokid Max | 1080p at 60 or 120 Hz in 2D; SBS at 1080p/60, 1200p/60, or 1200p/90 | Brightness; volume follows Android's USB audio volume; no dimming film | +| Rokid Max / Max 2 | 1080p at 60 or 120 Hz in 2D; SBS at 1080p/60, 1200p/60, or 1200p/90 | Brightness; volume follows Android's USB audio volume; no dimming film | | Rokid Air | 1080p/60 in 2D and SBS | Brightness; volume follows Android's USB audio volume | DroidDeck saves separate **2D resolution**, **3D resolution**, and **VR resolution** preferences. Unsupported saved choices fall back to the connected model's automatic behavior. Native **30 Hz** is offered only when Android advertises a matching mode and glasses presentation output is enabled. Higher refresh selections are constrained by the active 2D/SBS layout and model; a firmware-rejected switch is not treated as successful until the physical display confirms it. diff --git a/docs/testing/steam-in-kde.md b/docs/testing/steam-in-kde.md index c199aca81..306e9a20d 100644 --- a/docs/testing/steam-in-kde.md +++ b/docs/testing/steam-in-kde.md @@ -8,6 +8,6 @@ Validation on AYN Thor uses the full wireless ADB serial `adb-d234a848-fv2FDl (2 Rapid repeated desktop-icon taps were also tested: one client started with software CEF and the library remained on KDE. -Host regression tests cover desktop display preservation, game URLs, duplicate launches before and after IPC is ready, explicit Big Picture handoff, non-desktop callers, invalid targets, and software CEF limited to the KDE desktop client. Gameplay through Steam's library on KDE has not been validated; the desktop's existing GPU presentation limitations remain. +Host regression tests cover desktop display preservation, game URLs, duplicate launches before and after IPC is ready, explicit Big Picture handoff, non-desktop callers, invalid targets, and software CEF limited to the KDE desktop client. Games started from this client present through Mesa's software WSI path (`MESA_VK_WSI_DEBUG=sw`), because KDE's nested Xwayland has no DRI3, so Vulkan swapchains on `:0` otherwise fail with `VK_ERROR_SURFACE_LOST_KHR` and the game exits with "X connection to :0 broken". Among Us (Proton, DXVK) now opens and renders in a KDE window on a REDMAGIC NP06J. The final release-signed APK was built with `tools/build_local.sh`, verified to contain the exact tested Steam scripts, and installed with `adb install -r`. APK SHA-256: `d104b1e6e20fb10e85fcdd95eaf2a8ca69061547164640b8590e41e4495663e4`. On that installation, two rapid desktop-icon taps started one software-rendered Steam client; the library displayed on KDE and restored to a desktop window. Final session: `2026-10-12-09-desktop`, READY, mode `lxqt`, with no gamescope process or handoff request. The Thor was left running this desktop Steam client. diff --git a/tools/ci/install-ubuntu-packages.sh b/tools/ci/install-ubuntu-packages.sh new file mode 100644 index 000000000..1597613aa --- /dev/null +++ b/tools/ci/install-ubuntu-packages.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail +missing=() +for package in "$@"; do + if [ "$(dpkg-query -W -f='${Status}' "$package" 2>/dev/null || true)" != 'install ok installed' ]; then + missing+=("$package") + fi +done +if [ "${#missing[@]}" = 0 ]; then + echo "Ubuntu build dependencies are already installed." + exit 0 +fi +options=( + -o Dir::Etc::sourcelist=sources.list.d/ubuntu.sources + -o Dir::Etc::sourceparts=- + -o Acquire::Retries=1 + -o Acquire::http::Timeout=15 + -o Acquire::https::Timeout=15 + -o DPkg::Lock::Timeout=30 +) +install_packages() { + echo "Updating Ubuntu package indexes." + sudo timeout --kill-after=10s 60s apt-get "${options[@]}" -o APT::Update::Error-Mode=any update -q || return $? + echo "Installing: ${missing[*]}" + sudo timeout --kill-after=10s 120s env DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get "${options[@]}" install -y -q --no-install-recommends "${missing[@]}" +} +if install_packages; then + exit 0 +fi +echo "::warning::Runner mirror failed or timed out; retrying with the official Ubuntu mirror." +sources=$(mktemp --suffix=.sources) +trap 'rm -f "$sources"' EXIT +mirror=https://archive.ubuntu.com/ubuntu +case "$(dpkg --print-architecture)" in + arm64) mirror=https://ports.ubuntu.com/ubuntu-ports ;; +esac +sed -E "s@^URIs:.*@URIs: $mirror@" /etc/apt/sources.list.d/ubuntu.sources > "$sources" +chmod 644 "$sources" +options[1]="Dir::Etc::sourcelist=$sources" +install_packages diff --git a/tools/ci/resolve-ndk.sh b/tools/ci/resolve-ndk.sh new file mode 100644 index 000000000..61df33c50 --- /dev/null +++ b/tools/ci/resolve-ndk.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail +repo=$(cd "$(dirname "$0")/../.." && pwd) +source "$repo/tools/ci/toolchains.env" +ndk="$ANDROID_HOME/ndk/$CI_ANDROID_NDK_VERSION" +if [ ! -f "$ndk/source.properties" ]; then + sdkmanager --sdk_root="$ANDROID_HOME" --install "ndk;$CI_ANDROID_NDK_VERSION" +fi +actual=$(sed -n 's/^Pkg.Revision *= *//p' "$ndk/source.properties" | tr -d '\r') +test "$actual" = "$CI_ANDROID_NDK_VERSION" +{ + echo "NDK_VERSION=$actual" + echo "ANDROID_NDK_HOME=$ndk" + echo "ANDROID_NDK_ROOT=$ndk" + echo "ANDROID_NDK_LATEST_HOME=$ndk" +} >> "$GITHUB_ENV" diff --git a/tools/ci/toolchains.env b/tools/ci/toolchains.env new file mode 100644 index 000000000..ac365f2bb --- /dev/null +++ b/tools/ci/toolchains.env @@ -0,0 +1 @@ +CI_ANDROID_NDK_VERSION=30.0.16248370 diff --git a/tools/components/README.md b/tools/components/README.md new file mode 100644 index 000000000..782d8b9f1 --- /dev/null +++ b/tools/components/README.md @@ -0,0 +1,65 @@ +# Runtime component preparation + +`recipes.json` owns component source inputs, builders, outputs, package requirements +and pinned downloads. `prepare.py` runs those recipes, calculates cache identities, +installs the union of missing tools and verifies the prepared artifact handoff. +Builders live in `recipes/`; the workflow describes scheduling and gates. + +```mermaid +flowchart LR + Source[Pin source tree] --> Components[Prepare verified components] + Source --> Xbox[Xbox ARM64 backend] + Source --> Tests[Python and native tests] + Components --> APK[Compile APK and Android tests] + Xbox --> APK + APK --> Gate[Compile and test APK gate] + Tests --> Gate + Gate --> Publish[Main only: signing and publishing] +``` + +All workers verify the same source tree. The component artifact records that tree, +recipe identities and every output hash; the APK worker verifies the inventory +before building. This handoff verifies consistency within the same CI run. It is +not independent provenance for arbitrary artifacts. Source policy checks, tests, +APK content/ABI checks and signing/publication gates remain in place. + +`tools/ci/toolchains.env` pins the Android NDK shared by component, APK and native +test workers. Linux builds use Ubuntu 24.04. Recipe and input changes invalidate +the relevant component cache; ordinary caches remain performance aids. + +## Verified preload snapshots + +`preloads.lock.json` pins a small immutable preload archive. It records each +binary's hash, its producing main CI run, the original APK artifact identity and +digest, source/header hashes, the ARM64 build recipe and x86 compiler-image inputs. +It covers core ARM64 preloads and the x86 preload libraries. VR, Android native +libraries, Comet and Xbox use their registered recipes and existing release pins +or component caches. + +Every build compares current source inputs, including transitive quoted headers, +and build recipes with this manifest. Changed groups rebuild from source. A +missing release also selects a rebuild. Corrupt archives, unexpected files and +binary hash mismatches fail the build. Existing preload checks, runtime packaging +checks and all test jobs still run. + +The trust anchor is the manifest in the pinned main base. A new or changed PR +manifest is independently checked against a successful `push` build of main: +GitHub must confirm the run/source/workflow and original APK artifact digest, and +every claimed binary must match that APK. The claimed source inputs and recipe +must match that run's Git source. A PR cannot establish trust by changing both a +binary and its claimed checksum. This is approved-build reuse, not a claim of +bit-for-bit reproducible compilation or a signed SLSA attestation. + +To refresh after source or compiler changes: + +1. Let a main build compile and test the new inputs. +2. Run `python tools/components/preloads.py snapshot --run RUN_ID`. +3. Publish `app/build/component-snapshot/preloads.tar.gz` to + DroidDeck-Components as `preloads-<full archive SHA-256>`, without replacing an + existing release. Include the manifest and producing run in the release notes. +4. Review and commit the updated manifest. CI independently checks its origin. + +The workflow dispatch `rebuild_preloads` option forces source compilation even +when an approved snapshot matches. Use it when refreshing a compiler or system +header revision. Runner image updates do not silently replace an approved binary; +the producing run remains the record of its compiler environment. diff --git a/tools/components/build-arm64-preloads.sh b/tools/components/build-arm64-preloads.sh new file mode 100644 index 000000000..a38a1edaa --- /dev/null +++ b/tools/components/build-arm64-preloads.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail +D=${1:?output directory} +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" diff --git a/tools/components/preloads.lock.json b/tools/components/preloads.lock.json new file mode 100644 index 000000000..45beb4152 --- /dev/null +++ b/tools/components/preloads.lock.json @@ -0,0 +1,126 @@ +{ + "archive_sha256": "cb6487a94f25a3068fd07958174eb5b5e14f709655ff4ef0a8cdbd99138513b2", + "arm64_recipe": "1c92a1b0669277de3e78e9f292634affb5b5a19d5ea49d3ad8610c1dc638c2fb", + "groups": { + "arm64": { + "@runner": "84f825635599f81ed33f62c5fc4c73f66044d1468b060d65f83855eb9c8bfe77", + "app/src/main/cpp/adrenotools/src/hook/kgsl.h": "396c56761034a4ed783543ee3312c6ec64d63b2a1cf7eecf1939c23251f25adf", + "app/src/main/cpp/fakeinput_steam.cpp": "3d11e859ca58a42a9da92a3fc589d404df790e151f7e18ea6114645750e70ef5", + "app/src/main/cpp/venc/venc_proto.h": "2242ddbc8981d14a33bac70264bb3e7f701fd1c3ebb988e17bbb2b19b6b808f7", + "tools/linuxfs/clipboard/clipboard.c": "96291c2bbc040c83caabf059ab3e95a9ed90d6a768c759d7cb4c0b64706bddfa", + "tools/linuxfs/preload/binfmt.c": "0933cc49cb53f0aa1206e2eae516d38dc3337e74174be07e10619c42aa56792b", + "tools/linuxfs/preload/dircache.c": "8e7f19ea4a1198783d53a3f357326c6177da157b0cc7e0d22e1670963e1c26e7", + "tools/linuxfs/preload/drm.c": "e0eabab0d8ed9e841918e827bf87168efa7f2dfb8404169e9bed3ab5eda2836c", + "tools/linuxfs/preload/fsync.c": "795c114c2a05d3f250d07145d04c5658091699da3b96290da36348ccadfb0c09", + "tools/linuxfs/preload/inputudev.c": "2648248d3d235a582eb8459709e0a85de537c03ac10c5040fbfb50d9846c68eb", + "tools/linuxfs/preload/kgslid.c": "88a7875fd5289930204d155d92ac9d15492cd06db16ed87aa9c95af37f3e7089", + "tools/linuxfs/preload/kgslpoll.c": "f0d853d919a52487c04cbda5fb3fa22269da54b7f4ee2f7dd3b5bd9909418670", + "tools/linuxfs/preload/links.c": "b7bbcf9112393b152f358fe6f8dc9a1955f0c8532e1eddf016a971415cd13c64", + "tools/linuxfs/preload/lock.c": "f71b3fd40e6a2b74908d3cdc601e8748699ae9c7a89b542d2445b999015ffd66", + "tools/linuxfs/preload/net.c": "335853c8ea0e9a8ebbc5a5500dc4bded9c34c4310a6ab8aa46b07d5ce1c2b55e", + "tools/linuxfs/preload/netif.c": "cc26d50b8d4639d8dd4697811bbf85c9d9f31761f9504a4af42473de3cb22b3c", + "tools/linuxfs/preload/noexec.c": "1855aeaeda3d506329bd9af1107580c74d1f56787c411b2e01c0a8a9d4059c7b", + "tools/linuxfs/preload/ntsync.c": "0614c48af0c062bfcb4c30141efe1b792b340767a67d2fe92ee9877d2ba16e0d", + "tools/linuxfs/preload/opens.c": "92ae4aea16aa457a898dedf4a70eb332d80d3b1e77f3a60bace7715a4317399e", + "tools/linuxfs/preload/pathcache.c": "eb0d4b5238fe6c8891e805d90bd837e466754100c978fad2d0ff717116b9b8a3", + "tools/linuxfs/preload/perms.c": "f600619c4ca130f69001ed7ad7bd74a3dc215b14c61479c45bb770f916d244b9", + "tools/linuxfs/preload/pulseguard.c": "57674ea40283e53a27bc78362de1ba41ee972d172ea38b1f818b1d6eed0abe65", + "tools/linuxfs/preload/quest_kgsl.c": "59d9b8416be6a1c680f1d1e4c2427d2cbb7b415344261adb357d9d211ec95cfe", + "tools/linuxfs/preload/robust.c": "9eb6c0ed50e0f48a8cfb86bd92587f471354bdf2531b9177f4b3ab83bdfed39a", + "tools/linuxfs/preload/rptouch.c": "0e3f8bf2f9a41324361f38fa4da8d2d47593c9555f0cabf8228767397846c7c3", + "tools/linuxfs/preload/space.c": "118389bcc89dfa2b4230b80915e21d2d4331c4cff1f5a58265f85e6ecb5c29d2", + "tools/linuxfs/preload/storage.c": "08a8b46c3588f3160bb8608489768010ec36d5ccdb2b25a431ec9d6fcd413a6e", + "tools/linuxfs/preload/sysv.c": "49774ee5e2e39b84dcfadf63abdc741b3df236cb11783744a1280adee36965e8", + "tools/linuxfs/preload/tracefs.c": "be3f8a2f032b56e6e08a931e77a9386aea4bb93421a482271e06386ec0c469b6", + "tools/linuxfs/preload/tracer.c": "f34e5377c891b0a8d00489245d66f96bc352df4cd90f47a951dcadae980af4c1", + "tools/linuxfs/preload/udevmon.c": "2045dfb883cfb6e2016c359a3dc3af17f3b8f383f6dd0d5dfee407ef0d344852", + "tools/linuxfs/preload/usr2.c": "6ae704380b45a9b3a74c4fe2b3ce4482470dae59ced26cf0aee64f4b4dcc11f9", + "tools/linuxfs/preload/venc.c": "24c3c46fe49d28adfc8c977e52c9cc407f534b26f56d94b3425b8bb01803dba8", + "tools/linuxfs/preload/vmwrite.c": "1a3ea2ec4354e0716c17f7db537b19ab82bcd524f2aece2fa5dec89b076a2188", + "tools/linuxfs/preload/webhelper.c": "a8213a7a96b407459c83fb93365cfe58fd05964e7bb262b8061c1c0792a6aac8", + "tools/linuxfs/preload/writewatch.c": "61e395d1caa184d329dead0e36c4e92cb1069b1154ac36581f37406c1c242240", + "tools/linuxfs/preload/xmode.c": "9a09995bf52d40b07f16828bcc545b59b8fb1ba60d9cb09f14f7f31dba1e65a6", + "tools/linuxfs/preload/zram.c": "12bcf07459728d4a7ede6fa0fea8deb330f7c5d0f0dd61e610e81b9efecd4262", + "tools/linuxfs/ssbs/ssbs_adapter.c": "9e4e0fe637f7b8f91f12d8151f02e8d31bcb4294be2b2c62d3cd3ce76f200306", + "tools/proot/fastpath/audit.c": "bfe347a2ddaceef40a78d7d590e4e679724ee4c82f3f1f1e3c7017a18ad536d4", + "tools/proot/fastpath/fastpath.c": "4cd07b25ba14f6faa6bac5091ae323ded6b646dbb34b9e5a92ea6385e4d37cb5", + "tools/quest/kgsl_compat.c": "111afffda7a6df2469610f6c342ae086b1a97a559c586efc4a28015b86efd6bd", + "tools/quest/kgsl_compat.h": "2e9e0eb647530a35277954b7e61a22ee958067fe46056a608fb32f40f273f1bf" + }, + "x86": { + "@runner": "84f825635599f81ed33f62c5fc4c73f66044d1468b060d65f83855eb9c8bfe77", + "app/src/main/cpp/adrenotools/src/hook/kgsl.h": "396c56761034a4ed783543ee3312c6ec64d63b2a1cf7eecf1939c23251f25adf", + "app/src/main/cpp/fakeinput_steam.cpp": "3d11e859ca58a42a9da92a3fc589d404df790e151f7e18ea6114645750e70ef5", + "app/src/main/cpp/venc/venc_proto.h": "2242ddbc8981d14a33bac70264bb3e7f701fd1c3ebb988e17bbb2b19b6b808f7", + "tools/linuxfs/build-x86-preloads.sh": "d0820252809881db89db5a17427e74b9aa4964aae70623cb7f4b474e90c7a23b", + "tools/linuxfs/debian-image.env": "1c79dea4314c784042a5c59208765c8401771098f293185d61a157d89edf6e52", + "tools/linuxfs/fex/faultreport.c": "573bf780f32f7256dc2fbf40fe850d67e67c354509fdedcac3ff7f1a261daac3", + "tools/linuxfs/fex/vulkanpin.c": "f2693ac2be8d15f7881115e61b1c6b3fe4aeec8f603584c6fce5b0f046563d51", + "tools/linuxfs/fex/webkitinput.c": "693ffd6c8c8c97e87156444ad2255c84dbf22cd0053d135fb8d9be094b5534d1", + "tools/linuxfs/preload/binfmt.c": "0933cc49cb53f0aa1206e2eae516d38dc3337e74174be07e10619c42aa56792b", + "tools/linuxfs/preload/dircache.c": "8e7f19ea4a1198783d53a3f357326c6177da157b0cc7e0d22e1670963e1c26e7", + "tools/linuxfs/preload/drm.c": "e0eabab0d8ed9e841918e827bf87168efa7f2dfb8404169e9bed3ab5eda2836c", + "tools/linuxfs/preload/fsync.c": "795c114c2a05d3f250d07145d04c5658091699da3b96290da36348ccadfb0c09", + "tools/linuxfs/preload/inputudev.c": "2648248d3d235a582eb8459709e0a85de537c03ac10c5040fbfb50d9846c68eb", + "tools/linuxfs/preload/kgslid.c": "88a7875fd5289930204d155d92ac9d15492cd06db16ed87aa9c95af37f3e7089", + "tools/linuxfs/preload/kgslpoll.c": "f0d853d919a52487c04cbda5fb3fa22269da54b7f4ee2f7dd3b5bd9909418670", + "tools/linuxfs/preload/links.c": "b7bbcf9112393b152f358fe6f8dc9a1955f0c8532e1eddf016a971415cd13c64", + "tools/linuxfs/preload/lock.c": "f71b3fd40e6a2b74908d3cdc601e8748699ae9c7a89b542d2445b999015ffd66", + "tools/linuxfs/preload/net.c": "335853c8ea0e9a8ebbc5a5500dc4bded9c34c4310a6ab8aa46b07d5ce1c2b55e", + "tools/linuxfs/preload/netif.c": "cc26d50b8d4639d8dd4697811bbf85c9d9f31761f9504a4af42473de3cb22b3c", + "tools/linuxfs/preload/noexec.c": "1855aeaeda3d506329bd9af1107580c74d1f56787c411b2e01c0a8a9d4059c7b", + "tools/linuxfs/preload/ntsync.c": "0614c48af0c062bfcb4c30141efe1b792b340767a67d2fe92ee9877d2ba16e0d", + "tools/linuxfs/preload/opens.c": "92ae4aea16aa457a898dedf4a70eb332d80d3b1e77f3a60bace7715a4317399e", + "tools/linuxfs/preload/pathcache.c": "eb0d4b5238fe6c8891e805d90bd837e466754100c978fad2d0ff717116b9b8a3", + "tools/linuxfs/preload/perms.c": "f600619c4ca130f69001ed7ad7bd74a3dc215b14c61479c45bb770f916d244b9", + "tools/linuxfs/preload/pulseguard.c": "57674ea40283e53a27bc78362de1ba41ee972d172ea38b1f818b1d6eed0abe65", + "tools/linuxfs/preload/quest_kgsl.c": "59d9b8416be6a1c680f1d1e4c2427d2cbb7b415344261adb357d9d211ec95cfe", + "tools/linuxfs/preload/robust.c": "9eb6c0ed50e0f48a8cfb86bd92587f471354bdf2531b9177f4b3ab83bdfed39a", + "tools/linuxfs/preload/rptouch.c": "0e3f8bf2f9a41324361f38fa4da8d2d47593c9555f0cabf8228767397846c7c3", + "tools/linuxfs/preload/space.c": "118389bcc89dfa2b4230b80915e21d2d4331c4cff1f5a58265f85e6ecb5c29d2", + "tools/linuxfs/preload/storage.c": "08a8b46c3588f3160bb8608489768010ec36d5ccdb2b25a431ec9d6fcd413a6e", + "tools/linuxfs/preload/sysv.c": "49774ee5e2e39b84dcfadf63abdc741b3df236cb11783744a1280adee36965e8", + "tools/linuxfs/preload/tracefs.c": "be3f8a2f032b56e6e08a931e77a9386aea4bb93421a482271e06386ec0c469b6", + "tools/linuxfs/preload/tracer.c": "f34e5377c891b0a8d00489245d66f96bc352df4cd90f47a951dcadae980af4c1", + "tools/linuxfs/preload/udevmon.c": "2045dfb883cfb6e2016c359a3dc3af17f3b8f383f6dd0d5dfee407ef0d344852", + "tools/linuxfs/preload/usr2.c": "6ae704380b45a9b3a74c4fe2b3ce4482470dae59ced26cf0aee64f4b4dcc11f9", + "tools/linuxfs/preload/venc.c": "24c3c46fe49d28adfc8c977e52c9cc407f534b26f56d94b3425b8bb01803dba8", + "tools/linuxfs/preload/vmwrite.c": "1a3ea2ec4354e0716c17f7db537b19ab82bcd524f2aece2fa5dec89b076a2188", + "tools/linuxfs/preload/webhelper.c": "a8213a7a96b407459c83fb93365cfe58fd05964e7bb262b8061c1c0792a6aac8", + "tools/linuxfs/preload/writewatch.c": "61e395d1caa184d329dead0e36c4e92cb1069b1154ac36581f37406c1c242240", + "tools/linuxfs/preload/xmode.c": "9a09995bf52d40b07f16828bcc545b59b8fb1ba60d9cb09f14f7f31dba1e65a6", + "tools/linuxfs/preload/zram.c": "12bcf07459728d4a7ede6fa0fea8deb330f7c5d0f0dd61e610e81b9efecd4262", + "tools/quest/kgsl_compat.c": "111afffda7a6df2469610f6c342ae086b1a97a559c586efc4a28015b86efd6bd", + "tools/quest/kgsl_compat.h": "2e9e0eb647530a35277954b7e61a22ee958067fe46056a608fb32f40f273f1bf" + } + }, + "origin": { + "artifact": 12520502054, + "artifact_sha256": "b7e800a1884a516ec52b050b7f904f9881427bca4579f660b909fd18f15e49ff", + "commit": "b76f13f7acc0af0cc00369dde61fb88e40529e6d", + "run": 39992155294, + "url": "https://github.com/Droid-Deck/DroidDeck/actions/runs/39992155294" + }, + "outputs": { + "i386/libblsession.so": "5343cdb704217ddc81ee593e67d5987363dc7b5ac78bd4bd36000219e5063268", + "i386/libfakeinput.so": "188d88dfb13b6e2f2df948e7b8b4d9ec604770f55d1ea5b6b9e21ad56a74ce5e", + "i386/libfaultreport.so": "95f9ef60f04a13ee60cfbd4d2d2efb60a7063b722e646c78c9dc27162292877b", + "libblaudit.so": "6c6abe89baf5d7309babee0c3372a4b00c2b689e2a63de38c5e9a660626509b5", + "libblfastpath.so": "0e1f151b5c1c5ea9dd380ad58d09f8fe7ba3ae037d0991cb3eef6899e9d5ec83", + "libblsession.so": "29d4b5ea46dba941107e02c0ee544244b137a339cada43bff65c350f2ef2755a", + "libfakeinput.so": "5f6dc3aea05de2dc1fbe0bbfac2ce4d11e3d47f761c6b06c8badb15fe1904392", + "libssbs.so": "c288ed0da26c2481aff270d1cdafe1205061c4a300c2c46ae1b53c6e0c94f44b", + "usr/local/bin/droiddeck-clipboard": "ec50e8f67fdf76372927189b0fdb670f9b70fd3484f2fe3e833bc8f9fdc44205", + "x86_64/libblsession.so": "61d19f06aebab6a789f8c99f687af9903376bfc853e46b70c48a2ce5e497accc", + "x86_64/libfakeinput.so": "8663dd2086bf8ae53069a480dd9ae4619109d32f0947a29e2ce38f07a21ae2e6", + "x86_64/libfaultreport.so": "bb0dc9165d0d764473e552508650edd586df16d620fc5b16b8b1c5740a28ce22", + "x86_64/libvulkan-thunk.so": "74f01b652d7e8179561a096a552328b68da640cc4bad3981edd38e6afbf90243", + "x86_64/libwebkitinput.so": "9b07380a7b017397b9c56bb7bb1161e046b0c6a647fc413888904606b0def6d0" + }, + "toolchain": { + "arm64": "Ubuntu 24.04 GCC cross compiler; exact build commands pinned by arm64_recipe", + "x86": "Debian bullseye compiler image and recipe pinned by source inputs" + }, + "version": 1, + "x86_recipe": "36d06d49f7a4bff5ef675c1e03ce5a5b61498df4c36b9d15a938b41564f755cc" +} diff --git a/tools/components/preloads.py b/tools/components/preloads.py new file mode 100644 index 000000000..5f16610c3 --- /dev/null +++ b/tools/components/preloads.py @@ -0,0 +1,290 @@ +"""Reuse reviewed preload snapshots; changed snapshots must prove their main-build origin.""" +import argparse +import gzip +import http.client +import hashlib +import io +import json +import os +import posixpath +import re +from pathlib import Path +import subprocess +import tarfile +import textwrap +import urllib.error +import urllib.request +from zipfile import ZipFile + +ROOT = Path(__file__).resolve().parents[2] +REPO = 'Droid-Deck/DroidDeck' +LOCK = 'tools/components/preloads.lock.json' +DEFINITIONS = json.loads((ROOT / 'tools/components/recipes.json').read_text()) +INPUTS = {group: DEFINITIONS[name]['source_inputs'] for group, name in + [('arm64', 'arm64-preloads'), ('x86', 'x86-preloads')]} +OUTPUTS = {group: DEFINITIONS[name]['snapshot_outputs'] for group, name in + [('arm64', 'arm64-preloads'), ('x86', 'x86-preloads')]} +COMMON = [name for name in INPUTS['arm64'] if name in INPUTS['x86']] +FILES = set(sum(OUTPUTS.values(), [])) + + +def sha(data): + return hashlib.sha256(data).hexdigest() + + +def run(*args, root=ROOT): + return subprocess.check_output(args, cwd=root) + + +def api(path): + return json.loads(run('gh', 'api', 'repos/' + REPO + '/' + path)) + + +def source_files(group, root=ROOT, commit=None, patterns=None): + patterns = patterns if patterns is not None else INPUTS[group] + if commit: + paths = run('git', 'ls-tree', '-r', '--name-only', commit, '--', *patterns, root=root).decode().splitlines() + read = lambda path: run('git', 'show', f'{commit}:{path}', root=root) + else: + paths = run('git', 'ls-files', '--cached', '--others', '--exclude-standard', '--', *patterns, root=root).decode().splitlines() + read = lambda path: (root / path).read_bytes() + paths = [p for p in paths if not p.endswith('.md') and '__pycache__' not in p and not p.endswith('.pyc')] + if not paths: + raise ValueError('No preload inputs found') + result = {} + pending = list(paths) + while pending: + path = pending.pop() + if path in result: + continue + if path.startswith('../') or path.startswith('/'): + raise ValueError('Preload include escapes repository') + content = read(path).replace(b'\r\n', b'\n') + result[path] = sha(content) + for include in re.findall(rb'^\s*#\s*include\s*"([^"\n]+)"', content, re.M): + pending.append(posixpath.normpath(posixpath.join(posixpath.dirname(path), include.decode()))) + workflow = (run('git', 'show', f'{commit}:.github/workflows/build.yml', root=root).decode() if commit + else (root / '.github/workflows/build.yml').read_text()) + job = 'components' if re.search(r'(?m)^ components:', workflow) else 'build' + runner = re.search(r'(?ms)^ ' + job + r':\n.*?^ runs-on: ([^\n]+)', workflow) + if runner is None: + raise ValueError('APK build runner policy is missing') + result['@runner'] = sha(runner[1].strip().encode()) + return dict(sorted(result.items())) + + +def recipe(root=ROOT, commit=None): + def read(path): + return (run('git', 'show', f'{commit}:{path}', root=root).decode() if commit + else (root / path).read_text()) + script = 'tools/components/build-arm64-preloads.sh' + if commit and run('git', 'ls-tree', commit, script, root=root) == b'': + workflow = read('.github/workflows/build.yml') + text = workflow[workflow.index(' aarch64-linux-gnu-g++ -shared'):] + text = textwrap.dedent(text[:text.index(' VK_INCLUDE=')]) + else: + text = read(script) + preamble = '#!/usr/bin/env bash\nset -euo pipefail\nD=${1:?output directory}\nmkdir -p "$D"\n' + if not text.startswith(preamble): + return sha(text.encode()) + text = text[len(preamble):] + return sha(text.replace('\r\n', '\n').encode()) + + +def x86_recipe(root=ROOT, commit=None): + path = 'tools/components/recipes/x86-preloads.sh' + if commit and not run('git', 'ls-tree', commit, path, root=root): + workflow = run('git', 'show', f'{commit}:.github/workflows/build.yml', root=root).decode() + section = workflow[workflow.index(' - name: Build the x86 preloads'):] + text = section.split(' run: |\n', 1)[1].split('\n - name:', 1)[0] + text = textwrap.dedent(text).rstrip() + '\n' + else: + text = (run('git', 'show', f'{commit}:{path}', root=root).decode() if commit + else (root / path).read_text()) + text = text.removeprefix('#!/usr/bin/env bash\n') + return sha(text.replace('\r\n', '\n').encode()) + + +def artifact_bytes(origin, cache): + build = api(f"actions/runs/{origin['run']}") + if (build['event'] != 'push' or build['head_branch'] != 'main' or build['conclusion'] != 'success' + or build['head_sha'] != origin['commit'] or build['path'] != '.github/workflows/build.yml' + or build['repository']['full_name'] != REPO): + raise ValueError('Snapshot is not from a successful main APK build') + artifact = api(f"actions/artifacts/{origin['artifact']}") + if (artifact['name'] != 'droiddeck-apk' or artifact['workflow_run']['id'] != origin['run'] + or artifact['digest'] != 'sha256:' + origin['artifact_sha256']): + raise ValueError('Original APK artifact identity or digest mismatch') + cache.mkdir(parents=True, exist_ok=True) + path = cache / (origin['artifact_sha256'] + '.zip') + if not path.exists(): + path.write_bytes(run('gh', 'api', f"repos/{REPO}/actions/artifacts/{origin['artifact']}/zip")) + content = path.read_bytes() + if sha(content) != origin['artifact_sha256']: + raise ValueError('Original APK archive checksum mismatch') + with ZipFile(io.BytesIO(content)) as archive: + names = [name for name in archive.namelist() if name.endswith('.apk')] + if len(names) != 1: + raise ValueError('Expected one original APK') + with ZipFile(io.BytesIO(archive.read(names[0]))) as apk: + return {name: apk.read('assets/linuxfs/' + name) for name in FILES} + + +def validate_lock(data): + if data.get('version') != 1 or set(data['groups']) != set(INPUTS): + raise ValueError('Unsupported preload snapshot') + if set(data['outputs']) != FILES: + raise ValueError('Unexpected preload output set') + for digest in [data['archive_sha256'], *data['outputs'].values()]: + if len(digest) != 64 or any(c not in '0123456789abcdef' for c in digest): + raise ValueError('Invalid preload checksum') + + +def producer_sources(group, commit, root=ROOT): + config = 'tools/components/recipes.json' + if run('git', 'ls-tree', commit, config, root=root): + definitions = json.loads(run('git', 'show', f'{commit}:{config}', root=root)) + name = 'arm64-preloads' if group == 'arm64' else 'x86-preloads' + patterns = definitions[name]['source_inputs'] + else: + # Older approved builds kept ARM64 commands inline, before the Vulkan/VR build. + if group == 'arm64': + text = run('git', 'show', f'{commit}:.github/workflows/build.yml', root=root).decode() + text = text[text.index(' aarch64-linux-gnu-g++ -shared'):] + text = text[:text.index(' VK_INCLUDE=')] + else: + text = run('git', 'show', f'{commit}:tools/linuxfs/build-x86-preloads.sh', root=root).decode() + patterns = re.findall(r'\b((?:tools|app)/[\w./*+-]+\.(?:cpp|c|h))(?=\s|$)', text) + if group == 'x86': + patterns += ['tools/linuxfs/fex', 'tools/linuxfs/build-x86-preloads.sh', 'tools/linuxfs/debian-image.env'] + return source_files(group, root, commit, patterns) + + +def prove_origin(data, cache, root=ROOT): + content = artifact_bytes(data['origin'], cache) + if {name: sha(value) for name, value in content.items()} != data['outputs']: + raise ValueError('Snapshot binaries differ from the original main APK') + commit = data['origin']['commit'] + run('git', 'fetch', '--no-tags', 'origin', commit, root=root) + for group in INPUTS: + if source_files(group, root, commit) != data['groups'][group]: + raise ValueError('Snapshot inputs differ from the producing source') + for name, checksum in producer_sources(group, commit, root).items(): + if data['groups'][group].get(name) != checksum: + raise ValueError('Snapshot omits or changes a producing build dependency') + if recipe(root, commit) != data['arm64_recipe']: + raise ValueError('Snapshot recipe differs from the producing source') + if x86_recipe(root, commit) != data['x86_recipe']: + raise ValueError('Snapshot x86 recipe differs from the producing source') + + +def unpack(content, data): + if sha(content) != data['archive_sha256']: + raise ValueError('Preload archive checksum mismatch') + result = {} + total = 0 + with tarfile.open(fileobj=io.BytesIO(content), mode='r:gz') as archive: + for entry in archive: + if entry.name not in FILES or entry.name in result or not entry.isfile() or entry.size > 32 * 1024 * 1024: + raise ValueError('Unsafe or unexpected preload archive entry') + total += entry.size + if total > 64 * 1024 * 1024: + raise ValueError('Preload archive exceeds expanded size limit') + value = archive.extractfile(entry).read() + if sha(value) != data['outputs'][entry.name]: + raise ValueError('Preload binary checksum mismatch') + result[entry.name] = value + if set(result) != FILES: + raise ValueError('Incomplete preload archive') + return result + + +def restore(root=ROOT): + data = json.loads((root / LOCK).read_text()) + validate_lock(data) + matching = {group: source_files(group, root) == data['groups'][group] for group in INPUTS} + matching['arm64'] &= recipe(root) == data['arm64_recipe'] + matching['x86'] &= x86_recipe(root) == data.get('x86_recipe') + if os.environ.get('FORCE_PRELOAD_REBUILD') == 'true': + matching = dict.fromkeys(INPUTS, False) + if any(matching.values()): + base = os.environ.get('COMPONENT_TRUSTED_BASE') + approved = None + if base: + result = subprocess.run(['git', 'show', f'{base}:{LOCK}'], cwd=root, capture_output=True) + if result.returncode == 0: + approved = json.loads(result.stdout) + if approved != data: + try: + prove_origin(data, root / 'app/build/component-downloads', root) + except (subprocess.CalledProcessError, OSError, http.client.HTTPException): + print('Snapshot origin is unavailable; rebuilding without trusting the snapshot.') + matching = dict.fromkeys(INPUTS, False) + if any(matching.values()): + url = (f"https://github.com/Droid-Deck/DroidDeck-Components/releases/download/" + f"preloads-{data['archive_sha256']}/preloads.tar.gz") + try: + with urllib.request.urlopen(url, timeout=60) as response: + content = response.read(64 * 1024 * 1024) + except (OSError, http.client.HTTPException) as error: + if isinstance(error, urllib.error.HTTPError) and error.code not in (404, 429) and error.code < 500: + raise + print('Preload snapshot is unavailable; rebuilding from source.') + matching = dict.fromkeys(INPUTS, False) + else: + binaries = unpack(content, data) + for group, matches in matching.items(): + if matches: + for name in OUTPUTS[group]: + path = root / 'app/src/main/assets/linuxfs' / name + if not path.resolve().is_relative_to(root.resolve()) or path.is_symlink(): + raise ValueError('Preload output escapes repository or is a symlink') + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(binaries[name]) + path.chmod(0o755 if name.endswith('droiddeck-clipboard') else 0o644) + for group, matches in matching.items(): + print(f"{group}: {'verified snapshot reused' if matches else 'inputs changed or snapshot unavailable; rebuild required'}") + if os.environ.get('GITHUB_OUTPUT'): + with open(os.environ['GITHUB_OUTPUT'], 'a') as output: + output.write(f'{group}={str(matches).lower()}\n') + return matching + + +def snapshot(run_id, destination, root=ROOT): + build = api(f'actions/runs/{run_id}') + candidates = api(f'actions/runs/{run_id}/artifacts?per_page=100')['artifacts'] + artifact = next(a for a in candidates if a['name'] == 'droiddeck-apk') + origin = dict(run=run_id, commit=build['head_sha'], artifact=artifact['id'], + artifact_sha256=artifact['digest'].removeprefix('sha256:'), + url=build['html_url']) + content = artifact_bytes(origin, root / 'app/build/component-downloads') + run('git', 'fetch', '--no-tags', 'origin', origin['commit'], root=root) + data = dict(version=1, origin=origin, groups={g: source_files(g, root, origin['commit']) for g in INPUTS}, + arm64_recipe=recipe(root, origin['commit']), x86_recipe=x86_recipe(root, origin['commit']), + outputs={name: sha(value) for name, value in content.items()}, + toolchain=dict(arm64='Ubuntu 24.04 GCC cross compiler; exact build commands pinned by arm64_recipe', + x86='Debian bullseye compiler image and recipe pinned by source inputs')) + stream = io.BytesIO() + with gzip.GzipFile(fileobj=stream, mode='wb', mtime=0) as compressed: + with tarfile.open(fileobj=compressed, mode='w') as archive: + for name, value in sorted(content.items()): + entry = tarfile.TarInfo(name) + entry.size, entry.mode = len(value), 0o644 + archive.addfile(entry, io.BytesIO(value)) + destination.mkdir(parents=True, exist_ok=True) + (destination / 'preloads.tar.gz').write_bytes(stream.getvalue()) + data['archive_sha256'] = sha(stream.getvalue()) + (root / LOCK).write_text(json.dumps(data, indent=2, sort_keys=True) + '\n') + print('Snapshot SHA-256: ' + data['archive_sha256']) + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('operation', choices=['restore', 'snapshot']) + parser.add_argument('--run', type=int) + parser.add_argument('--out', type=Path, default=ROOT / 'app/build/component-snapshot') + args = parser.parse_args() + if args.operation == 'restore': + restore() + else: + snapshot(args.run, args.out) diff --git a/tools/components/prepare.py b/tools/components/prepare.py new file mode 100644 index 000000000..064b452be --- /dev/null +++ b/tools/components/prepare.py @@ -0,0 +1,167 @@ +"""One entry point for runtime recipes, cache identities and verified job handoff.""" +import argparse +import hashlib +import json +import os +from pathlib import Path, PurePosixPath +import shutil +import subprocess +import tarfile + +ROOT = Path(__file__).resolve().parents[2] +REGISTRY = 'tools/components/recipes.json' +TREES = ['app/src/main/assets/linuxfs', 'app/src/main/assets/directaudio', + 'app/src/main/assets/droiddeck-esync', 'app/build/prepared-assets', + 'app/build/component-records'] +LIBRARIES = ['app/src/main/jniLibs/arm64-v8a/' + name for name in + ('libproot.so', 'libproot-loader.so', 'libdirectaudiorelay.so')] +MANIFEST = 'app/build/prepared-components.json' + + +def digest(path): + with path.open('rb') as stream: + return hashlib.file_digest(stream, 'sha256').hexdigest() + + +def registry(root=ROOT): + return json.loads((root / REGISTRY).read_text()) + + +def identity(name, root=ROOT): + spec = registry(root)[name] + paths = subprocess.check_output(['git', 'ls-files', '--cached', '--others', '--exclude-standard', + '--', *spec['inputs'], *spec.get('source_inputs', [])], cwd=root, text=True).splitlines() + values = {} + for name_ in sorted(set(paths)): + if '__pycache__' in name_ or name_.endswith(('.pyc', '.md')): + continue + content = (root / name_).read_bytes() + if b'\0' not in content: + content = content.replace(b'\r\n', b'\n') + values[name_] = hashlib.sha256(content).hexdigest() + policy = dict(spec=spec, sources=values, runner=os.environ.get('RUNNER_OS', 'Linux'), + architecture=os.environ.get('RUNNER_ARCH', 'X64')) + entrypoint = root / 'tools/components/prepare.py' + if entrypoint.is_file(): + policy['entrypoint'] = hashlib.sha256(entrypoint.read_bytes().replace(b'\r\n', b'\n')).hexdigest() + installer = root / 'tools/ci/install-ubuntu-packages.sh' + if spec.get('packages') and installer.is_file(): + policy['installer'] = hashlib.sha256(installer.read_bytes().replace(b'\r\n', b'\n')).hexdigest() + if spec.get('ndk'): + policy['ndk'] = os.environ.get('NDK_VERSION', '') + policy['ndk_policy'] = hashlib.sha256((root / 'tools/ci/toolchains.env').read_bytes().replace(b'\r\n', b'\n')).hexdigest() + return hashlib.sha256(json.dumps(policy, sort_keys=True).encode()).hexdigest() + + +def prepare(name, root=ROOT): + spec = registry(root)[name] + prepare_tools([name], root) + if 'command' in spec: + command = spec['command'] + else: + script = (root / spec['script']).resolve() + if not script.is_relative_to((root / 'tools/components').resolve()): + raise ValueError('Recipe script escapes components directory') + command = ['bash', str(script)] + environment = dict(os.environ) + if spec.get('source'): + environment.update(COMPONENT_SOURCE_URL=spec['source']['url'], + COMPONENT_SOURCE_SHA256=spec['source']['sha256']) + subprocess.run(command, cwd=root, env=environment, check=True) + missing = [name_ for name_ in spec['outputs'] if not (root / name_).is_file()] + if missing: + raise ValueError(f'{name} did not prepare required outputs: {missing}') + record = dict(component=name, input_sha256=identity(name, root), + outputs={p: digest(root / p) for p in spec['outputs']}) + destination = root / 'app/build/component-records' / (name + '.json') + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text(json.dumps(record, sort_keys=True, indent=2) + '\n') + + +def prepare_tools(names, root=ROOT): + packages = sorted({package for name in names for package in registry(root)[name].get('packages', [])}) + if packages: + subprocess.run(['bash', 'tools/ci/install-ubuntu-packages.sh', *packages], cwd=root, check=True) + + +def files(root=ROOT): + result = [] + for name in TREES: + folder = root / name + if not folder.is_dir() or folder.is_symlink(): + raise ValueError('Missing prepared component tree: ' + name) + for path in folder.rglob('*'): + if path.is_symlink(): + raise ValueError('Symlink in prepared components: ' + str(path)) + if path.is_file(): + result.append(path.relative_to(root).as_posix()) + for name in LIBRARIES: + if not (root / name).is_file() or (root / name).is_symlink(): + raise ValueError('Missing prepared component library: ' + name) + result.append(name) + return sorted(result) + + +def pack(destination, root=ROOT): + names = files(root) + tree = subprocess.check_output(['git', 'rev-parse', 'HEAD^{tree}'], cwd=root, text=True).strip() + data = dict(version=1, source_tree=tree, run=os.environ.get('GITHUB_RUN_ID', ''), + recipes={name: identity(name, root) for name in registry(root)}, + files={name: digest(root / name) for name in names}) + (root / MANIFEST).write_text(json.dumps(data, sort_keys=True, indent=2) + '\n') + destination.parent.mkdir(parents=True, exist_ok=True) + with tarfile.open(destination, mode='w:gz', compresslevel=1) as archive: + for name in names + [MANIFEST]: + archive.add(root / name, arcname=name, recursive=False) + + +def unpack(archive_path, source_tree, root=ROOT): + total = 0 + seen = set() + with tarfile.open(archive_path, mode='r:gz') as archive: + for entry in archive: + allowed = entry.name in LIBRARIES + [MANIFEST] or any(entry.name.startswith(p + '/') for p in TREES) + path = root / entry.name + total += entry.size + if (not allowed or '\\' in entry.name or '..' in PurePosixPath(entry.name).parts + or PurePosixPath(entry.name).as_posix() != entry.name + or not entry.isfile() or entry.name in seen or len(seen) >= 100000 + or entry.size > 512 * 1024 * 1024 + or total > 2 * 1024**3 or not path.resolve().is_relative_to(root.resolve()) + or path.is_symlink()): + raise ValueError('Unsafe prepared component archive') + seen.add(entry.name) + path.parent.mkdir(parents=True, exist_ok=True) + with path.open('wb') as output: + shutil.copyfileobj(archive.extractfile(entry), output) + path.chmod(entry.mode & 0o777) + data = json.loads((root / MANIFEST).read_text()) + actual = {name: digest(root / name) for name in files(root)} + recipes = {name: identity(name, root) for name in registry(root)} + if (data.get('version') != 1 or data['source_tree'] != source_tree or data['files'] != actual + or data['recipes'] != recipes): + raise ValueError('Prepared components do not match the pinned source tree and file hashes') + print('Prepared component source identity and all output hashes verified.') + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('recipe') + parser.add_argument('--archive', type=Path, default=ROOT / 'app/build/prepared-components.tar.gz') + parser.add_argument('--tree') + args = parser.parse_args() + if args.recipe == 'plan': + for name in registry(): + value = identity(name) + print(f'{name}: {value}') + if os.environ.get('GITHUB_OUTPUT'): + with open(os.environ['GITHUB_OUTPUT'], 'a') as stream: + stream.write(f'{name}={value}\n') + elif args.recipe == 'tools': + prepare_tools(os.environ.get('COMPONENTS_TO_BUILD', '').split()) + elif args.recipe == 'pack': + pack(args.archive) + elif args.recipe == 'unpack': + unpack(args.archive, args.tree) + else: + prepare(args.recipe) diff --git a/tools/components/recipes.json b/tools/components/recipes.json new file mode 100644 index 000000000..602fb379e --- /dev/null +++ b/tools/components/recipes.json @@ -0,0 +1,226 @@ +{ + "runtime": { + "script": "tools/components/recipes/runtime.sh", + "inputs": [ + "tools/components/recipes/runtime.sh", + "tools/quest", + "tools/linuxfs/overlay", + "tools/linuxfs/desktop", + "tools/linuxfs/licenses", + "tools/linuxfs/uruntime.env", + "tools/gamescope/release.env", + "tools/wlroots/release.env", + "tools/mangoapp", + "tools/msitools", + "tools/pipewire", + "tools/vr", + "app/src/main/cpp/waylandcomp/generated", + "app/src/main/cpp/waylandcomp/src/headpose_shm.h", + "app/src/main/cpp/waylandcomp/src/spatialpose_shm.h", + "app/src/main/cpp/venc/venc_proto.h" + ], + "outputs": [ + "app/src/main/assets/linuxfs/usr/local/bin/gamescope", + "app/src/main/assets/linuxfs/usr/local/lib/droiddeck-vr/droiddeck/bin/linuxarm64/driver_droiddeck.so" + ], + "ndk": true, + "packages": [ + "gcc-aarch64-linux-gnu", + "g++-aarch64-linux-gnu", + "libvulkan1", + "mesa-vulkan-drivers" + ] + }, + "esync": { + "script": "tools/components/recipes/esync.sh", + "inputs": [ + "tools/components/recipes/esync.sh", + "tools/droiddeck-esync/release.env" + ], + "outputs": [ + "app/src/main/assets/droiddeck-esync/index.json", + "app/src/main/assets/droiddeck-esync/index.json.sig" + ] + }, + "debian-image": { + "script": "tools/components/recipes/debian-image.sh", + "inputs": [ + "tools/components/recipes/debian-image.sh" + ], + "outputs": [] + }, + "x86-preloads": { + "script": "tools/components/recipes/x86-preloads.sh", + "inputs": [ + "tools/components/recipes/x86-preloads.sh", + "tools/linuxfs/build-x86-preloads.sh", + "tools/linuxfs/preload", + "tools/linuxfs/fex" + ], + "outputs": [ + "app/src/main/assets/linuxfs/x86_64/libfakeinput.so", + "app/src/main/assets/linuxfs/x86_64/libblsession.so", + "app/src/main/assets/linuxfs/x86_64/libfaultreport.so", + "app/src/main/assets/linuxfs/i386/libfakeinput.so", + "app/src/main/assets/linuxfs/i386/libblsession.so", + "app/src/main/assets/linuxfs/i386/libfaultreport.so", + "app/src/main/assets/linuxfs/x86_64/libwebkitinput.so", + "app/src/main/assets/linuxfs/x86_64/libvulkan-thunk.so" + ], + "source_inputs": [ + "app/src/main/cpp/fakeinput_steam.cpp", + "app/src/main/cpp/adrenotools/src/hook/kgsl.h", + "app/src/main/cpp/venc/venc_proto.h", + "tools/linuxfs/preload", + "tools/quest/kgsl_compat.c", + "tools/quest/kgsl_compat.h", + "tools/linuxfs/fex", + "tools/linuxfs/build-x86-preloads.sh", + "tools/linuxfs/debian-image.env" + ], + "snapshot_outputs": [ + "x86_64/libfakeinput.so", + "x86_64/libblsession.so", + "x86_64/libfaultreport.so", + "i386/libfakeinput.so", + "i386/libblsession.so", + "i386/libfaultreport.so", + "x86_64/libwebkitinput.so", + "x86_64/libvulkan-thunk.so" + ] + }, + "pulseaudio-source": { + "script": "tools/components/recipes/pulseaudio-source.sh", + "inputs": [ + "tools/components/recipes/pulseaudio-source.sh" + ], + "outputs": [ + "pa13/src/pulsecore/module.h" + ], + "source": { + "url": "https://github.com/pulseaudio/pulseaudio/archive/refs/tags/v13.0.tar.gz", + "sha256": "bd7980bc9bd44bd3b3363545dabd19476ebf4d6bb6d2527e7d179256394a6758" + } + }, + "directaudio": { + "script": "tools/components/recipes/directaudio.sh", + "inputs": [ + "tools/components/recipes/directaudio.sh", + "tools/directaudio" + ], + "outputs": [ + "app/src/main/jniLibs/arm64-v8a/libdirectaudiorelay.so" + ], + "ndk": true + }, + "audio": { + "script": "tools/components/recipes/audio.sh", + "inputs": [ + "tools/aaudio-sink", + "tools/directaudio", + "tools/components/recipes/audio.sh", + "app/src/main/assets/pulseaudio.tzst" + ], + "outputs": [ + "app/build/prepared-assets/pulseaudio.tzst" + ], + "ndk": true + }, + "proot": { + "script": "tools/components/recipes/proot.sh", + "inputs": [ + "tools/proot", + "tools/components/recipes/proot.sh" + ], + "outputs": [ + "app/src/main/jniLibs/arm64-v8a/libproot.so", + "app/src/main/jniLibs/arm64-v8a/libproot-loader.so" + ], + "ndk": true + }, + "comet": { + "script": "tools/components/recipes/comet.sh", + "inputs": [ + "tools/comet", + "tools/components/recipes/comet.sh" + ], + "outputs": [ + "app/src/main/assets/linuxfs/usr/local/lib/droiddeck-comet/comet", + "app/src/main/assets/linuxfs/usr/local/lib/droiddeck-comet/version" + ], + "packages": [ + "gcc-aarch64-linux-gnu" + ] + }, + "arm64-preloads": { + "script": "tools/components/recipes/arm64-preloads.sh", + "inputs": [ + "tools/components/build-arm64-preloads.sh", + "tools/linuxfs/preload", + "app/src/main/cpp/fakeinput_steam.cpp", + "tools/components/recipes/arm64-preloads.sh" + ], + "outputs": [ + "app/src/main/assets/linuxfs/libfakeinput.so", + "app/src/main/assets/linuxfs/libblsession.so", + "app/src/main/assets/linuxfs/libblfastpath.so", + "app/src/main/assets/linuxfs/libblaudit.so", + "app/src/main/assets/linuxfs/libssbs.so", + "app/src/main/assets/linuxfs/usr/local/bin/droiddeck-clipboard" + ], + "packages": [ + "gcc-aarch64-linux-gnu", + "g++-aarch64-linux-gnu" + ], + "source_inputs": [ + "app/src/main/cpp/fakeinput_steam.cpp", + "app/src/main/cpp/adrenotools/src/hook/kgsl.h", + "app/src/main/cpp/venc/venc_proto.h", + "tools/linuxfs/preload", + "tools/quest/kgsl_compat.c", + "tools/quest/kgsl_compat.h", + "tools/proot/fastpath", + "tools/linuxfs/ssbs", + "tools/linuxfs/clipboard" + ], + "snapshot_outputs": [ + "libfakeinput.so", + "libblsession.so", + "libblfastpath.so", + "libblaudit.so", + "libssbs.so", + "usr/local/bin/droiddeck-clipboard" + ] + }, + "preload-snapshot": { + "command": [ + "python3", + "tools/components/preloads.py", + "restore" + ], + "inputs": [ + "tools/components/preloads.py", + "tools/components/preloads.lock.json" + ], + "outputs": [] + }, + "xbox": { + "script": "tools/components/recipes/xbox.sh", + "inputs": [ + "tools/xbox", + "tools/components/recipes/xbox.sh" + ], + "outputs": [ + "app/src/main/assets/linuxfs/usr/local/lib/droiddeck-xbox/xodus-cli", + "app/src/main/assets/linuxfs/usr/local/lib/droiddeck-xbox/xodus-service" + ], + "packages": [ + "build-essential", + "pkg-config", + "libssl-dev", + "protobuf-compiler", + "cmake", + "xz-utils" + ] + } +} diff --git a/tools/components/recipes/arm64-preloads.sh b/tools/components/recipes/arm64-preloads.sh new file mode 100644 index 000000000..c6a227c2e --- /dev/null +++ b/tools/components/recipes/arm64-preloads.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +bash tools/components/build-arm64-preloads.sh app/src/main/assets/linuxfs diff --git a/tools/components/recipes/audio.sh b/tools/components/recipes/audio.sh new file mode 100644 index 000000000..819421d7c --- /dev/null +++ b/tools/components/recipes/audio.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +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 diff --git a/tools/components/recipes/comet.sh b/tools/components/recipes/comet.sh new file mode 100644 index 000000000..e47d3e3d5 --- /dev/null +++ b/tools/components/recipes/comet.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail +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 diff --git a/tools/components/recipes/debian-image.sh b/tools/components/recipes/debian-image.sh new file mode 100644 index 000000000..f982225dc --- /dev/null +++ b/tools/components/recipes/debian-image.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +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" diff --git a/tools/components/recipes/directaudio.sh b/tools/components/recipes/directaudio.sh new file mode 100644 index 000000000..c684f0262 --- /dev/null +++ b/tools/components/recipes/directaudio.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +tools/directaudio/fetch.sh "$PWD" "$PWD/sink-out" \ No newline at end of file diff --git a/tools/components/recipes/esync.sh b/tools/components/recipes/esync.sh new file mode 100644 index 000000000..691f77e99 --- /dev/null +++ b/tools/components/recipes/esync.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +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 diff --git a/tools/components/recipes/proot.sh b/tools/components/recipes/proot.sh new file mode 100644 index 000000000..9bf7699e8 --- /dev/null +++ b/tools/components/recipes/proot.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +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 diff --git a/tools/components/recipes/pulseaudio-source.sh b/tools/components/recipes/pulseaudio-source.sh new file mode 100644 index 000000000..5a2a6ed09 --- /dev/null +++ b/tools/components/recipes/pulseaudio-source.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail +curl -fsSL --retry 3 -o pa.tar.gz "$COMPONENT_SOURCE_URL" +echo "$COMPONENT_SOURCE_SHA256 pa.tar.gz" | sha256sum -c - +mkdir -p pa13 && tar -xzf pa.tar.gz -C pa13 --strip-components=1 && rm pa.tar.gz diff --git a/tools/components/recipes/runtime.sh b/tools/components/recipes/runtime.sh new file mode 100644 index 000000000..0e640ab11 --- /dev/null +++ b/tools/components/recipes/runtime.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +set -euo pipefail +D=app/src/main/assets/linuxfs +mkdir -p "$D" +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" diff --git a/tools/components/recipes/x86-preloads.sh b/tools/components/recipes/x86-preloads.sh new file mode 100644 index 000000000..c107c543b --- /dev/null +++ b/tools/components/recipes/x86-preloads.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +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 diff --git a/tools/components/recipes/xbox.sh b/tools/components/recipes/xbox.sh new file mode 100644 index 000000000..87c953656 --- /dev/null +++ b/tools/components/recipes/xbox.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +rustup toolchain install 1.99.0 --profile minimal +bash tools/xbox/build.sh diff --git a/tools/directaudio/build-relay.sh b/tools/directaudio/build-relay.sh new file mode 100755 index 000000000..59c071616 --- /dev/null +++ b/tools/directaudio/build-relay.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail +repo_root=$1 +source_dir=$2 +: "${NDK:=${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-${ANDROID_NDK_ROOT:-}}}}" +: "${NDK:?set NDK to the Android NDK root}" +case "$(uname -s)" in + Darwin) host=darwin-x86_64 ;; + Linux) host=linux-x86_64 ;; + *) echo 'Unsupported NDK host' >&2; exit 1 ;; +esac +toolchain="$NDK/toolchains/llvm/prebuilt/$host/bin" +patch -d "$source_dir" -p1 < "$repo_root/tools/directaudio/microphone-toggle.patch" +"$toolchain/aarch64-linux-android28-clang" -O2 -Wl,-z,max-page-size=16384 \ + "$source_dir/directaudio-relay.c" -o "$repo_root/app/src/main/jniLibs/arm64-v8a/libdirectaudiorelay.so" \ + -laaudio -llog -pthread +"$toolchain/llvm-strip" --strip-unneeded "$repo_root/app/src/main/jniLibs/arm64-v8a/libdirectaudiorelay.so" diff --git a/tools/directaudio/fetch.sh b/tools/directaudio/fetch.sh index af886304f..35e327382 100755 --- a/tools/directaudio/fetch.sh +++ b/tools/directaudio/fetch.sh @@ -3,7 +3,7 @@ set -euo pipefail repo_root=$1 sink_out=$2 . "${repo_root}/tools/directaudio/release.env" -for v in DIRECTAUDIO_REPO DIRECTAUDIO_TAG DIRECTAUDIO_WINE11_SHA256 DIRECTAUDIO_SYSTHREAD_SHA256 DIRECTAUDIO_RELAY_SHA256 DIRECTAUDIO_SINK_SHA256; do +for v in DIRECTAUDIO_REPO DIRECTAUDIO_TAG DIRECTAUDIO_WINE11_SHA256 DIRECTAUDIO_SYSTHREAD_SHA256 DIRECTAUDIO_SINK_SHA256 DIRECTAUDIO_SOURCE_COMMIT DIRECTAUDIO_SOURCE_SHA256; do [[ -n "${!v:-}" && "${!v}" != TBD ]] || { echo "tools/directaudio/release.env: ${v} is not set" >&2; exit 1; } done work=$(mktemp -d) @@ -24,7 +24,15 @@ fetch() { } fetch directaudio-linux-wine11.zip "${DIRECTAUDIO_WINE11_SHA256}" fetch directaudio-linux-wine11-systhread.zip "${DIRECTAUDIO_SYSTHREAD_SHA256}" -fetch directaudio-linux-relay.zip "${DIRECTAUDIO_RELAY_SHA256}" +source_archive="${cache}/${DIRECTAUDIO_SOURCE_SHA256}-directaudio-source.tar.gz" +if ! echo "${DIRECTAUDIO_SOURCE_SHA256} ${source_archive}" | sha256sum -c - >/dev/null 2>&1; then + curl -fsSL --retry 3 -o "${work}/source.tar.gz" \ + "https://github.com/${DIRECTAUDIO_REPO}/archive/${DIRECTAUDIO_SOURCE_COMMIT}.tar.gz" + echo "${DIRECTAUDIO_SOURCE_SHA256} ${work}/source.tar.gz" | sha256sum -c - >/dev/null + mv "${work}/source.tar.gz" "${source_archive}" +fi +mkdir "${work}/source" +tar -xzf "${source_archive}" --strip-components=1 -C "${work}/source" fetch directaudio-linux-sink.zip "${DIRECTAUDIO_SINK_SHA256}" assets="${repo_root}/app/src/main/assets/directaudio" @@ -40,9 +48,8 @@ done grep -q '^abi wine11$' "${assets}/linux-wine11/version.txt" grep -q '^abi systhread$' "${assets}/linux-wine11-systhread/version.txt" -mkdir -p "${work}/relay" "${work}/sink" "${sink_out}" "${repo_root}/app/src/main/jniLibs/arm64-v8a" -unzip -q "${work}/directaudio-linux-relay.zip" -d "${work}/relay" -install -m755 "${work}/relay/libdirectaudiorelay.so" "${repo_root}/app/src/main/jniLibs/arm64-v8a/libdirectaudiorelay.so" +mkdir -p "${work}/sink" "${sink_out}" "${repo_root}/app/src/main/jniLibs/arm64-v8a" +"${repo_root}/tools/directaudio/build-relay.sh" "${repo_root}" "${work}/source" unzip -q "${work}/directaudio-linux-sink.zip" -d "${work}/sink" install -m755 "${work}/sink/module-directaudio-native-sink.so" "${sink_out}/" echo "DirectAudio ${DIRECTAUDIO_TAG}: two driver sets, the relay helper and the client sink in place" diff --git a/tools/directaudio/microphone-toggle.patch b/tools/directaudio/microphone-toggle.patch new file mode 100644 index 000000000..22af228c0 --- /dev/null +++ b/tools/directaudio/microphone-toggle.patch @@ -0,0 +1,123 @@ +--- a/directaudio-relay.c ++++ b/directaudio-relay.c +@@ -70,6 +70,9 @@ + * session log captures it too. Event-level lines are always on: they fire a + * handful of times per session and are what makes a field report actionable. */ + static int g_verbose; ++static const char *g_mic_control; ++static int g_mic_enabled = 1; ++static pthread_mutex_t g_mic_operations = PTHREAD_MUTEX_INITIALIZER; + + static void rlog(const char *fmt, ...) + { +@@ -735,6 +738,9 @@ + (void)user; + if (aq != __atomic_load_n(&g_mic.cq, __ATOMIC_SEQ_CST) && __atomic_load_n(&g_mic.cq, __ATOMIC_SEQ_CST)) + return AAUDIO_CALLBACK_RESULT_CONTINUE; ++ if (!__atomic_load_n(&g_mic_enabled, __ATOMIC_ACQUIRE)) ++ memset(audioData, 0, numFrames * g_mic.in_channels * ++ (g_mic.in_format == AAUDIO_FORMAT_PCM_I16 ? sizeof(int16_t) : sizeof(float))); + __atomic_add_fetch(&g_mic.cb_count, 1, __ATOMIC_RELAXED); + + pthread_mutex_lock(&g_clients_lock); +@@ -832,13 +838,14 @@ + AAudioStream *aq = NULL; + int start = 0, stop = 0; + ++ pthread_mutex_lock(&g_mic_operations); + pthread_mutex_lock(&g_mic.lock); + g_mic.want += delta; + if (g_mic.want < 0) g_mic.want = 0; + if (g_mic.cq) + { +- if (g_mic.want > 0 && !g_mic.started) { g_mic.started = 1; start = 1; aq = g_mic.cq; } +- if (g_mic.want == 0 && g_mic.started) { g_mic.started = 0; stop = 1; aq = g_mic.cq; } ++ if (g_mic.want > 0 && __atomic_load_n(&g_mic_enabled, __ATOMIC_ACQUIRE) && !g_mic.started) { g_mic.started = 1; start = 1; aq = g_mic.cq; } ++ if ((g_mic.want == 0 || !__atomic_load_n(&g_mic_enabled, __ATOMIC_ACQUIRE)) && g_mic.started) { g_mic.started = 0; stop = 1; aq = g_mic.cq; } + } + pthread_mutex_unlock(&g_mic.lock); + +@@ -856,8 +863,23 @@ + else if (stop) + { + AAudioStream_requestStop(aq); +- REVENT("mic stop (%s was the last consumer)", who); +- } ++ REVENT("mic stop (%s)", who); ++ } ++ pthread_mutex_unlock(&g_mic_operations); ++} ++ ++static void mic_control_update(void) ++{ ++ FILE *f; ++ int enabled; ++ if (!g_mic_control) return; ++ f = fopen(g_mic_control, "r"); ++ enabled = f && fgetc(f) == '1'; ++ if (f) fclose(f); ++ if (enabled == __atomic_load_n(&g_mic_enabled, __ATOMIC_ACQUIRE)) return; ++ __atomic_store_n(&g_mic_enabled, enabled, __ATOMIC_RELEASE); ++ mic_want(0, "session toggle"); ++ REVENT("mic enabled=%d", enabled); + } + + static void *mic_reopen_thread(void *unused) +@@ -870,8 +892,9 @@ + + __atomic_store_n(&g_mic.reopen_redo, 0, __ATOMIC_SEQ_CST); + ++ pthread_mutex_lock(&g_mic_operations); + pthread_mutex_lock(&g_mic.lock); +- want_start = g_mic.want > 0; ++ want_start = g_mic.want > 0 && __atomic_load_n(&g_mic_enabled, __ATOMIC_ACQUIRE); + pthread_mutex_unlock(&g_mic.lock); + + if (open_input(&neu) == AAUDIO_OK) +@@ -902,6 +925,7 @@ + AAudioStream_close(old); + } + ++ pthread_mutex_unlock(&g_mic_operations); + if (__atomic_load_n(&g_mic.reopen_redo, __ATOMIC_SEQ_CST)) { usleep(20000); continue; } + __atomic_store_n(&g_mic.reopen_running, 0, __ATOMIC_SEQ_CST); + if (!__atomic_load_n(&g_mic.reopen_redo, __ATOMIC_SEQ_CST)) break; +@@ -1348,7 +1372,8 @@ + /* ---- housekeeping: watchdog tick + mailbox watch, once a second ------------- */ + static void *housekeeping_thread(void *unused) + { +- struct timespec iv = { 1, 0 }; ++ struct timespec iv = { 0, 100000000 }; ++ unsigned tick = 0; + + (void)unused; + for (;;) +@@ -1357,6 +1382,8 @@ + int reload = 0; + + nanosleep(&iv, NULL); ++ mic_control_update(); ++ if (++tick % 10) continue; + + if (g_rt_path[0]) + { +@@ -1402,8 +1429,9 @@ + { + if (!strcmp(argv[i], "--socket") && i + 1 < argc) path = argv[++i]; + else if (!strcmp(argv[i], "--mic-fifo") && i + 1 < argc) fifo_path = argv[++i]; ++ else if (!strcmp(argv[i], "--mic-control") && i + 1 < argc) g_mic_control = argv[++i]; + else if (!strcmp(argv[i], "--log")) g_verbose = 1; +- else { fprintf(stderr, "usage: directaudio-relay --socket <path> [--mic-fifo <path>] [--log]\n"); return 2; } ++ else { fprintf(stderr, "usage: directaudio-relay --socket <path> [--mic-fifo <path>] [--mic-control <path>] [--log]\n"); return 2; } + } + if (!path) + { +@@ -1429,6 +1457,8 @@ + chmod(path, 0666); + if (listen(srv, 8) != 0) { REVENT("listen: %d", errno); return 1; } + ++ mic_control_update(); ++ REVENT("mic enabled=%d", __atomic_load_n(&g_mic_enabled, __ATOMIC_ACQUIRE)); + if (pthread_create(&th, NULL, housekeeping_thread, NULL) == 0) pthread_detach(th); + if (fifo_path && fifo_setup(fifo_path) != 0) + REVENT("mic-fifo: disabled"); diff --git a/tools/directaudio/release.env b/tools/directaudio/release.env index 5e052f0bb..95359b3ae 100644 --- a/tools/directaudio/release.env +++ b/tools/directaudio/release.env @@ -2,5 +2,6 @@ DIRECTAUDIO_REPO=The412Banner/directaudio DIRECTAUDIO_TAG=directaudio-linux-v1.1.0 DIRECTAUDIO_WINE11_SHA256=835e6cab072f5148893c43379e60506483aeb3f1444e777b430c295cbeb472c9 DIRECTAUDIO_SYSTHREAD_SHA256=2ddc47f446142d5940540ad40a423ca8d47fd536e3225b888df1077c6be7329e -DIRECTAUDIO_RELAY_SHA256=b68eb6d7a892fd92a1cd73fac36dc05cabb77cc73792e2c582d3eeccad355942 DIRECTAUDIO_SINK_SHA256=1d5d3ed709e5389ef7603f019c62b07e4e8428bb42882193d4a144a5c15425b6 +DIRECTAUDIO_SOURCE_COMMIT=52aedbf8ddd42874a5bbd21df20e1046367f442e +DIRECTAUDIO_SOURCE_SHA256=a20c9a0684ae3b1e60a6b9c17b7f1d5ec2501627ae9683aa9f391576a6c1974d diff --git a/tools/docker_preflight.py b/tools/docker_preflight.py index b4e923275..b9cb3dbd9 100644 --- a/tools/docker_preflight.py +++ b/tools/docker_preflight.py @@ -17,7 +17,7 @@ if __name__ == '__main__': sys.exit('Docker is unavailable: ' + engine.stderr.strip()) image = run('image', 'inspect', sys.argv[1]) if image.returncode: - if 'No such image' not in image.stderr: + if 'No such image' not in image.stderr and 'image not known' not in image.stderr: # Docker, Podman sys.exit('Cannot inspect the build image: ' + image.stderr.strip()) repo = Path(__file__).resolve().parents[1] subprocess.run(['docker', 'build', '--platform', 'linux/amd64', '-t', sys.argv[1], diff --git a/tools/droiddeckctl b/tools/droiddeckctl index 9544e65c7..b3a6c2d5b 100755 --- a/tools/droiddeckctl +++ b/tools/droiddeckctl @@ -21,6 +21,7 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) from adb_device import DeviceResolutionError, resolve_serial +from proot_profile import report as proot_profile_report # tools/release/variants.txt: the same app also ships as other packages (e.g. com.antutu.benchmark.full). @@ -37,6 +38,13 @@ class ControlError(RuntimeError): self.code = code +def profile_slow_us(value: str) -> int: + number = int(value) + if not 1 <= number <= 1_000_000_000: + raise argparse.ArgumentTypeError("--slow-us must be between 1 and 1000000000") + return number + + def adb_path(configured: str | None) -> str: if configured: return configured @@ -628,6 +636,10 @@ def execute(args: argparse.Namespace, adb: str, serial: str) -> dict: request["text"] = args.text return add_serial(checked(provider_call(adb, serial, "ui", request=request), "UI_FAILED"), serial) + if command == "proot-profile": + lines = ["PROOT_PROFILE=1", f"PROOT_PROFILE_SLOW_US={args.slow_us}"] if args.profile_op == "enable" else ["PROOT_PROFILE=0"] + return add_serial(checked(provider_call(adb, serial, "env", request={"op": "set", "lines": lines, "persistent": False}), "ENV_FAILED"), serial) + if command == "env": if args.env_op == "set": request = {"op": "set", "lines": args.lines, "persistent": args.persistent} @@ -828,6 +840,16 @@ def build_parser() -> argparse.ArgumentParser: if name == "set-text": op.add_argument("--text", required=True) + profile = commands.add_parser("proot-profile", help="profile PRoot syscall handlers in the next session, or rank a saved log") + profile_ops = profile.add_subparsers(dest="profile_op", required=True) + profile_enable = profile_ops.add_parser("enable", help="enable counters and bounded slow samples for the next session") + profile_enable.add_argument("--slow-us", type=profile_slow_us, default=1000, metavar="US") + profile_ops.add_parser("disable", help="disable profiling for the next session") + profile_report = profile_ops.add_parser("report", help="read a local session.log without a device") + profile_report.add_argument("path", type=Path) + profile_report.add_argument("--sort", choices=["wall", "cpu", "calls"], default="wall") + profile_report.add_argument("--limit", type=int, default=20) + env = commands.add_parser("env", help="session environment lines (debugging tools)") env_ops = env.add_subparsers(dest="env_op", required=True) env_ops.add_parser("list") @@ -837,7 +859,7 @@ def build_parser() -> argparse.ArgumentParser: env_clear = env_ops.add_parser("clear") env_clear.add_argument("scope", nargs="?", choices=["next", "persistent", "all"], default="next") - override = commands.add_parser("override", help="put a binary first on the next session's PATH (debugging tools)") + override = commands.add_parser("override", help="override a program or libblfastpath.so for the next session (debugging tools)") override.add_argument("name", help="the program name, e.g. gamescope") override.add_argument("path", help="the local file to push") @@ -941,6 +963,15 @@ def main(argv: list[str] | None = None) -> int: try: if not PACKAGE_NAME.fullmatch(PACKAGE): # it reaches the device shell through `am start` and `content call` raise ControlError(f"DROIDDECK_PACKAGE is not an Android package name: {PACKAGE!r}", "INVALID_REQUEST") + if args.command == "proot-profile" and args.profile_op == "report": + if args.limit < 1: + raise ControlError("--limit must be positive", "INVALID_ARGUMENTS") + try: + result = proot_profile_report(args.path.expanduser(), args.sort, args.limit) + except OSError as exc: + raise ControlError(str(exc), "FILE_IO") from exc + print(json.dumps(result, indent=2, sort_keys=True)) + return 0 adb = adb_path(args.adb) serial = resolve_serial(adb, args.serial, log=True) result = execute(args, adb, serial) diff --git a/tools/gamescope/PATCHES.md b/tools/gamescope/PATCHES.md index a34e97e35..ab53e5ad9 100644 --- a/tools/gamescope/PATCHES.md +++ b/tools/gamescope/PATCHES.md @@ -85,3 +85,17 @@ the patched component retained 1/20, then 0 after destruction. The unassigned control retained 20/20 and then 0. In Backpack Battles, two batches of ten touch drags leaked 188 and 197 SHM descriptors before the fix; the same batches added zero after the fix (112 total descriptors before and after each batch). + +## 0117 WSI: cache the present-time Xwayland bypass check + +`QueuePresentKHR` in the WSI layer calls `canBypassXWayland` for every swapchain on +every present. Each call makes several synchronous X requests (window geometry, +`_WINE_ALLOW_FLIP`, parent window, a child-window scan, the toplevel window). At +high frame rates that is thousands of round trips a second: on an AYANEO Pocket FIT +running AIO Graphics Test uncapped, Xwayland used 24-32% of a core and the game's +present thread sat about half idle. + +The patch can keep the result for `DROIDDECK_WSI_BYPASS_CHECK_MS` milliseconds. Unset +or 0 keeps the per-present check; the app sets 100 only in Performance mode and 0 +otherwise. With 100, a window resize or a flip/obscure change is seen up to 100 ms +later than before; surface creation and capability queries still check every time. diff --git a/tools/gamescope/build-in-arch.sh b/tools/gamescope/build-in-arch.sh index 2853e593a..79d4fd869 100755 --- a/tools/gamescope/build-in-arch.sh +++ b/tools/gamescope/build-in-arch.sh @@ -49,7 +49,11 @@ tar --use-compress-program=unzstd -xf "$PKG" -C out --strip-components=2 usr/bin mv out/gamescope out/usr/local/bin/gamescope chmod 755 out/usr/local/bin/gamescope strip --strip-unneeded out/usr/local/bin/gamescope || true -NEEDED=$(readelf -d out/usr/local/bin/gamescope | sed -n 's/.*NEEDED.*\[\(.*\)\]/\1/p') +# The WSI layer carries patches too (0117); the runtime's stock copy is replaced. +mkdir -p out/usr/lib +tar --use-compress-program=unzstd -xf "$PKG" -C out usr/lib/libVkLayer_FROG_gamescope_wsi_aarch64.so +strip --strip-unneeded out/usr/lib/libVkLayer_FROG_gamescope_wsi_aarch64.so || true +NEEDED=$(for f in out/usr/local/bin/gamescope out/usr/lib/libVkLayer_FROG_gamescope_wsi_aarch64.so; do readelf -d "$f" | sed -n 's/.*NEEDED.*\[\(.*\)\]/\1/p'; done | sort -u) echo "NEEDED: $NEEDED" MISSING="" for lib in $NEEDED; do grep -qx "$lib" tools/gamescope/runtime-sonames.txt || MISSING="$MISSING $lib"; done diff --git a/tools/gamescope/patches/0117-wsi-cache-present-bypass-check.patch b/tools/gamescope/patches/0117-wsi-cache-present-bypass-check.patch new file mode 100644 index 000000000..7ce0ec48d --- /dev/null +++ b/tools/gamescope/patches/0117-wsi-cache-present-bypass-check.patch @@ -0,0 +1,55 @@ +--- a/layer/VkLayer_FROG_gamescope_wsi.cpp 2026-10-25 07:56:48.179978889 +0000 ++++ b/layer/VkLayer_FROG_gamescope_wsi.cpp 2026-10-25 07:56:48.207978889 +0000 +@@ -23,6 +23,8 @@ + #include <optional> + + #include <poll.h> ++#include <time.h> ++#include <cstdlib> + #include <sys/stat.h> + // For limiter file. + #include <time.h> +@@ -476,6 +478,34 @@ + // Cached for comparison. + std::optional<VkRect2D> cachedWindowRect; + ++ // canBypassXWayland costs several X round trips; at present time it is refreshed at most ++ // every DROIDDECK_WSI_BYPASS_CHECK_MS (default 0 = every present; DroidDeck's Performance mode sets 100). ++ std::optional<bool> presentBypass; ++ bool presentBypassHdr = false; ++ uint64_t presentBypassNs = 0; ++ ++ static uint64_t presentBypassIntervalNs() { ++ static const uint64_t interval = [] { ++ const char *v = getenv("DROIDDECK_WSI_BYPASS_CHECK_MS"); ++ return uint64_t(v && *v ? strtoull(v, nullptr, 10) : 0) * 1000000ull; ++ }(); ++ return interval; ++ } ++ ++ bool canBypassXWaylandAtPresent(bool hdrColorspace) { ++ if (isWayland()) ++ return true; ++ timespec ts; ++ clock_gettime(CLOCK_MONOTONIC, &ts); ++ const uint64_t now = uint64_t(ts.tv_sec) * 1000000000ull + uint64_t(ts.tv_nsec); ++ if (!presentBypass || presentBypassHdr != hdrColorspace || now - presentBypassNs >= presentBypassIntervalNs()) { ++ presentBypass = canBypassXWayland(hdrColorspace); ++ presentBypassHdr = hdrColorspace; ++ presentBypassNs = now; ++ } ++ return *presentBypass; ++ } ++ + bool isWayland() const { + // Is native Wayland? + return connection == nullptr; +@@ -1597,7 +1627,7 @@ + continue; + } + +- const bool canBypass = gamescopeSurface->canBypassXWayland(gamescopeSwapchain->isHdrColorspace); ++ const bool canBypass = gamescopeSurface->canBypassXWaylandAtPresent(gamescopeSwapchain->isHdrColorspace); + if (canBypass != gamescopeSwapchain->isBypassingXWayland) { + if (canBypass) { + if (!(gamescopeSurface->flags & GamescopeLayerClient::Flag::NoSuboptimal)) diff --git a/tools/gamescope/release.env b/tools/gamescope/release.env index 1e741f4aa..a505f40a8 100644 --- a/tools/gamescope/release.env +++ b/tools/gamescope/release.env @@ -1,3 +1,3 @@ GAMESCOPE_REPO=Droid-Deck/DroidDeck-Components -GAMESCOPE_TAG=gamescope-3.16.29-p12 -GAMESCOPE_SHA256=07b69944a1ebf2741d4a867afa6327dcc63ec458f432a8f7483089dce2197010 +GAMESCOPE_TAG=gamescope-3.16.29-p13 +GAMESCOPE_SHA256=93c789dc9e08828496f1caa2849d56acdf205838de723a24b296547a29c0b6af diff --git a/tools/linuxfs/innoextract/linuxfs/usr/local/bin/droiddeck-innoextract b/tools/linuxfs/innoextract/linuxfs/usr/local/bin/droiddeck-innoextract new file mode 100755 index 000000000..217878a36 --- /dev/null +++ b/tools/linuxfs/innoextract/linuxfs/usr/local/bin/droiddeck-innoextract @@ -0,0 +1,7 @@ +#!/bin/sh +# Runs DroidDeck's bundled innoextract with its own boost libraries. The guest's +# env -i startup has no LD_LIBRARY_PATH, and the rootfs must not grow a second +# copy of boost for the installer fallback. +LD_LIBRARY_PATH=/usr/local/lib/droiddeck-inno +export LD_LIBRARY_PATH +exec /usr/local/lib/droiddeck-inno/innoextract "$@" diff --git a/tools/linuxfs/innoextract/linuxfs/usr/local/lib/droiddeck-inno/innoextract b/tools/linuxfs/innoextract/linuxfs/usr/local/lib/droiddeck-inno/innoextract new file mode 100755 index 000000000..ef18119f4 Binary files /dev/null and b/tools/linuxfs/innoextract/linuxfs/usr/local/lib/droiddeck-inno/innoextract differ diff --git a/tools/linuxfs/innoextract/linuxfs/usr/local/lib/droiddeck-inno/libboost_container.so.1.92.0 b/tools/linuxfs/innoextract/linuxfs/usr/local/lib/droiddeck-inno/libboost_container.so.1.92.0 new file mode 100755 index 000000000..c7ea48d83 Binary files /dev/null and b/tools/linuxfs/innoextract/linuxfs/usr/local/lib/droiddeck-inno/libboost_container.so.1.92.0 differ diff --git a/tools/linuxfs/innoextract/linuxfs/usr/local/lib/droiddeck-inno/libboost_filesystem.so.1.92.0 b/tools/linuxfs/innoextract/linuxfs/usr/local/lib/droiddeck-inno/libboost_filesystem.so.1.92.0 new file mode 100755 index 000000000..cb8712b8d Binary files /dev/null and b/tools/linuxfs/innoextract/linuxfs/usr/local/lib/droiddeck-inno/libboost_filesystem.so.1.92.0 differ diff --git a/tools/linuxfs/innoextract/linuxfs/usr/local/lib/droiddeck-inno/libboost_iostreams.so.1.92.0 b/tools/linuxfs/innoextract/linuxfs/usr/local/lib/droiddeck-inno/libboost_iostreams.so.1.92.0 new file mode 100755 index 000000000..aceb212bd Binary files /dev/null and b/tools/linuxfs/innoextract/linuxfs/usr/local/lib/droiddeck-inno/libboost_iostreams.so.1.92.0 differ diff --git a/tools/linuxfs/innoextract/linuxfs/usr/local/lib/droiddeck-inno/libboost_program_options.so.1.92.0 b/tools/linuxfs/innoextract/linuxfs/usr/local/lib/droiddeck-inno/libboost_program_options.so.1.92.0 new file mode 100755 index 000000000..bfa0bb6f7 Binary files /dev/null and b/tools/linuxfs/innoextract/linuxfs/usr/local/lib/droiddeck-inno/libboost_program_options.so.1.92.0 differ diff --git a/tools/linuxfs/innoextract/linuxfs/usr/local/lib/droiddeck-inno/libboost_random.so.1.92.0 b/tools/linuxfs/innoextract/linuxfs/usr/local/lib/droiddeck-inno/libboost_random.so.1.92.0 new file mode 100755 index 000000000..a8ea17594 Binary files /dev/null and b/tools/linuxfs/innoextract/linuxfs/usr/local/lib/droiddeck-inno/libboost_random.so.1.92.0 differ diff --git a/tools/linuxfs/innoextract/linuxfs/usr/local/share/licenses/droiddeck-inno/LICENSE.boost b/tools/linuxfs/innoextract/linuxfs/usr/local/share/licenses/droiddeck-inno/LICENSE.boost new file mode 100644 index 000000000..36b7cd93c --- /dev/null +++ b/tools/linuxfs/innoextract/linuxfs/usr/local/share/licenses/droiddeck-inno/LICENSE.boost @@ -0,0 +1,23 @@ +Boost Software License - Version 1.0 - August 17th, 2003 + +Permission is hereby granted, free of charge, to any person or organization +obtaining a copy of the software and accompanying documentation covered by +this license (the "Software") to use, reproduce, display, distribute, +execute, and transmit the Software, and to prepare derivative works of the +Software, and to permit third-parties to whom the Software is furnished to +do so, all subject to the following: + +The copyright notices in the Software and this entire statement, including +the above license grant, this restriction and the following disclaimer, +must be included in all copies of the Software, in whole or in part, and +all derivative works of the Software, unless such copies or derivative +works are solely in the form of machine-executable object code generated by +a source language processor. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT. IN NO EVENT +SHALL THE COPYRIGHT HOLDERS OR ANYONE DISTRIBUTING THE SOFTWARE BE LIABLE +FOR ANY DAMAGES OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/tools/linuxfs/innoextract/linuxfs/usr/local/share/licenses/droiddeck-inno/LICENSE.innoextract b/tools/linuxfs/innoextract/linuxfs/usr/local/share/licenses/droiddeck-inno/LICENSE.innoextract new file mode 100644 index 000000000..446f0c782 --- /dev/null +++ b/tools/linuxfs/innoextract/linuxfs/usr/local/share/licenses/droiddeck-inno/LICENSE.innoextract @@ -0,0 +1,18 @@ + +Copyright (C) 2011-2020 Daniel Scharrer <daniel@constexpr.org> + +This software is provided 'as-is', without any express or implied +warranty. In no event will the author(s) be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would be + appreciated but is not required. +2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. +3. This notice may not be removed or altered from any source distribution. diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-agent b/tools/linuxfs/overlay/usr/local/bin/droiddeck-agent index 8a0ba6a2c..162d7ebc2 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-agent +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-agent @@ -259,24 +259,91 @@ def focus_snapshot(): STEAM_APP_CLASS = re.compile(r"steam_app_(\d+)$") -def tag_game_windows(snap): - if os.environ.get("BL_STEAM_CLIENT_ARCH") != "x86_64": - return False +def process_env(pid, proc=Path("/proc")): + try: + return dict(entry.split(b"=", 1) for entry in (proc / str(pid) / "environ").read_bytes().split(b"\0") if b"=" in entry) + except OSError: + return {} + + +def steam_app_id(env): + try: + app = int(env.get(b"SteamAppId", b"0")) + except ValueError: + return None + return app if 0 < app < 2**32 else None + + +def under_reaper(pid, proc=Path("/proc")): + for _ in range(64): + try: + if (proc / str(pid) / "comm").read_text().strip() == "reaper": + return True + stat = (proc / str(pid) / "stat").read_text() + pid = int(stat[stat.rindex(")") + 2:].split()[1]) + except (OSError, ValueError, IndexError): + return False + if pid <= 1: + return False + return False + + +def sandboxed_apps(proc=Path("/proc")): + apps = {} + try: + pids = [entry for entry in os.listdir(proc) if entry.isdigit()] + except OSError: + return apps + for pid in pids: + env = process_env(pid, proc) + flatpak, app = env.get(b"FLATPAK_ID"), steam_app_id(env) + if flatpak and app: + name = flatpak.decode(errors="replace").lower() + for key in {name, name.rsplit(".", 1)[-1]}: + apps[key] = app if apps.get(key, app) == app else None + return apps + + +def window_steam_app(win, flatpaks, proc=Path("/proc")): + match = STEAM_APP_CLASS.match(win.get("class") or "") + if match: + return match.group(1) if os.environ.get("BL_STEAM_CLIENT_ARCH") == "x86_64" else None + pid = win.get("pid") + if pid: + if under_reaper(pid, proc): + return None + app = steam_app_id(process_env(pid, proc)) + if app: + return str(app) + app = flatpaks().get((win.get("class") or "").lower()) + return str(app) if app else None + + +def tag_game_windows(snap, proc=Path("/proc")): tagged = False + found = [] + + def flatpaks(): + if not found: + found.append(sandboxed_apps(proc)) + return found[0] + for display, wins in snap.get("displays", {}).items(): if display == os.environ.get("DISPLAY"): continue for win in wins: - match = STEAM_APP_CLASS.match(win.get("class") or "") - if not match or win.get("appId") or win.get("map") != "viewable" or win.get("overrideRedirect"): + if win.get("appId") or win.get("map") != "viewable" or win.get("overrideRedirect") or win.get("inputOnly"): + continue + app = window_steam_app(win, flatpaks, proc) + if not app: continue try: done = subprocess.run(["xprop", "-display", display, "-id", str(win["window"]), "-f", "STEAM_GAME", "32c", - "-set", "STEAM_GAME", match.group(1)], + "-set", "STEAM_GAME", app], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=5).returncode == 0 except (OSError, subprocess.TimeoutExpired): done = False - log("game window %s on %s tagged STEAM_GAME=%s: %s" % (win["window"], display, match.group(1), "ok" if done else "failed")) + log("game window %s on %s tagged STEAM_GAME=%s: %s" % (win["window"], display, app, "ok" if done else "failed")) tagged = tagged or done return tagged diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-battlenet b/tools/linuxfs/overlay/usr/local/bin/droiddeck-battlenet new file mode 100755 index 000000000..d3f3fd770 --- /dev/null +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-battlenet @@ -0,0 +1,965 @@ +#!/usr/bin/python3 +import fcntl +import http.client +import json +import math +import os +import re +import shutil +import signal +import subprocess +import sys +import threading +import time +import urllib.request + +HOME = os.environ.get("HOME") or "/root" +BIN = "/usr/local/bin" +PROTON_RUN = os.path.join(BIN, "droiddeck-proton-run") +APP_ID = "2596113170" +INSTALLER_URL = "https://downloader.battle.net/download/getInstallerForGame?os=win&gameProgram=BATTLENET_APP&version=Live" +CLIENT_DIR = ("Program Files (x86)", "Battle.net") +LAUNCHER = "Battle.net Launcher.exe" +CLIENT = "Battle.net.exe" +CONFIG = ("users", "steamuser", "AppData", "Roaming", "Battle.net", "Battle.net.config") +DEFAULT_CONFIG = {"Client": {"HardwareAcceleration": "false", "Streaming": {"StreamingEnabled": "false"}}} +LAUNCH_BEHAVIOR = ("GameLaunchWindowBehavior", "2") +CLIENT_32_BIT = ("s1",) +CACHE = os.path.join(HOME, ".cache", "droiddeck", "battlenet") +MENU = "/usr/local/share/applications/droiddeck-battlenet.desktop" +WALLPAPER_DIR = ("users", "steamuser", "AppData", "Local", "DroidDeck") +DESKTOP_KEY = "[Control Panel\\\\Desktop]" +COLORS_KEY = "[Control Panel\\\\Colors]" +WALLPAPER_EDGE = (4, 6, 11) +WALLPAPER_MAX = (1280, 720) +FEX_REQUEST = "battlenet-fex.request" +FEX_RESULT = "battlenet-fex.result" +FEX_WAIT = 120 +START_GRACE = 90 +POLL = 2 +STOP_WAIT = 5 +CLOSE_GRACE = 15 +HANDOFF_WAIT = 120 +GAME_SETTLE = 20 +AGENT_GONE = 30 +AGENT = "agent.exe" +CODE = re.compile(r"[A-Za-z0-9_]{1,16}") +GAME_UID = re.compile(r"[a-z0-9_]{1,32}") +HANDOVER_WAIT = 1800 +CLIENT_READY = 5 +READY_SETTLE = 5 +CLIENT_WAIT = 300 +GAME_GRACE = 15 +LAUNCH_WAIT = 15 +LAUNCH_ASKS = 3 +CLIENT_PATH = re.compile(r"[a-z]:\\(program files \(x86\)|programdata)\\battle\.net\\") +IDLE = {"agent.exe", "conhost.exe", "explorer.exe", "lsass.exe", "plugplay.exe", "rpcss.exe", "services.exe", + "start.exe", "steam.exe", "svchost.exe", "tabtip.exe", "winedevice.exe", "wineserver"} +NOT_GAME = IDLE | {"rundll32.exe", "wineboot.exe", "winemenubuilder.exe"} +MIN_INSTALLER = 1 << 20 +SETUP = "Battle.net-Setup.exe" +REQUEST = "battlenet-request" +REQUEST_FILE = re.compile(re.escape(REQUEST) + r"\.\d+") +LIBRARY = None +WOW_UID = re.compile(r"--uid=wow[a-z0-9_]*", re.I) +GAMEPAD_SETTING = re.compile(r"(?im)^\s*SET\s+GamePadEnable\s") +GX_API_SETTING = re.compile(r"(?im)^[ \t]*SET[ \t]+gxApi[ \t].*$") +GX_API_MARK = ".droiddeck-d3d11" +STEAM_CLIENT = {"steam", "steamwebhelper"} +LOG = os.path.join(CACHE, "battlenet.log") +LOG_LIMIT = 512 << 10 + + +def say(message): + line = "droiddeck-battlenet: %s" % message + print(line, file=sys.stderr, flush=True) + try: + os.makedirs(CACHE, exist_ok=True) + if os.path.exists(LOG) and os.path.getsize(LOG) > LOG_LIMIT: + os.replace(LOG, LOG + ".1") + with open(LOG, "a", encoding="utf-8") as f: + f.write("%s [%d] %s\n" % (time.strftime("%Y-%m-%d %H:%M:%S"), os.getpid(), message)) + except OSError: + pass + + +def notify(title, body, icon="dialog-information"): + if not os.environ.get("DBUS_SESSION_BUS_ADDRESS") or not shutil.which("notify-send"): + return + try: + subprocess.run(["notify-send", "-a", "DroidDeck", "-i", icon, title, body], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=5) + except (OSError, subprocess.SubprocessError): + pass + + +def steam_root(): + return os.environ.get("STEAM_COMPAT_CLIENT_INSTALL_PATH") or os.path.join(HOME, ".local", "share", "Steam") + + +def prefix(root): + return os.path.join(root, "steamapps", "compatdata", APP_ID) + + +def drive_c(root): + return os.path.join(prefix(root), "pfx", "drive_c") + + +def client_dir(root): + return os.path.join(drive_c(root), *CLIENT_DIR) + + +def launcher(root): + return os.path.join(client_dir(root), LAUNCHER) + + +def installed(root): + return os.path.isfile(launcher(root)) + + +def install_path(library): + return "Z:" + library.rstrip("/") + + +def write_config(root, library=None): + path = os.path.join(drive_c(root), *CONFIG) + key, value = LAUNCH_BEHAVIOR + try: + with open(path, encoding="utf-8-sig") as f: + config = json.load(f) + except FileNotFoundError: + config = None + except (OSError, ValueError): + return None + first = config is None + if first: + config = json.loads(json.dumps(DEFAULT_CONFIG)) + elif not (isinstance(config, dict) and isinstance(config.setdefault("Client", {}), dict)): + return None + client = config["Client"] + changes = [] + if client.get(key) != value: + client[key] = value + changes.append("launch") + if client.get("HardwareAcceleration") != "false": + client["HardwareAcceleration"] = "false" + changes.append("acceleration") + if library and isinstance(client.setdefault("Install", {}), dict) \ + and str(client["Install"].get("DefaultInstallPath", "")).rstrip("/\\") != install_path(library): + client["Install"]["DefaultInstallPath"] = install_path(library) + changes.append("library") + games = config.setdefault("Games", {}) + for uid in CLIENT_32_BIT if isinstance(games, dict) else (): + game = games.setdefault(uid, {}) + if isinstance(game, dict) and game.get("Run64BitSet") != "true": + game.update(Run64Bit="false", Run64BitSet="true") + changes.append("32-bit") + if not changes: + return None + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path + ".tmp", "w", encoding="utf-8") as f: + json.dump(config, f, indent=4) + os.replace(path + ".tmp", path) + return "first" if first else " ".join(changes) + + +def wallpaper_bmp(width, height): + scale = min(width / 1920.0, height / 1080.0) + ox, oy = (width - 1920 * scale) / 2, (height - 1080 * scale) / 2 + stops = ((0.0, (20, 33, 58)), (0.5, (10, 17, 32)), (1.0, WALLPAPER_EDGE)) + glow = (26, 159, 255) + logo_px = scale * 1.6 + + def background(u, v): + t = min(math.hypot(u - 960, v - 540) / 1150, 1.0) + (t0, c0), (t1, c1) = (stops[0], stops[1]) if t <= 0.5 else (stops[1], stops[2]) + f = (t - t0) / (t1 - t0) + color = [c0[i] + (c1[i] - c0[i]) * f for i in range(3)] + d = math.hypot(u - 947.21, v - 540) / 420 + if d < 1: + a = 0.22 * (1 - d) + color = [color[i] * (1 - a) + glow[i] * a for i in range(3)] + return color + + def box(x, y, x0, y0, x1, y1): + dx, dy = max(x0 - x, x - x1), max(y0 - y, y - y1) + return math.hypot(max(dx, 0), max(dy, 0)) + min(max(dx, dy), 0) + + def white(x, y): + half = max(math.hypot(x - 63.97, y - 111.86) - 111.86, 63.97 - x) + shape = min(box(x, y, -31.98, 0, 63.97, 223.72), half) + hole = min(math.hypot(x - 63.98, y - 111.86) - 71.81, + box(x, y, 56.055, -1, 71.885, 45), box(x, y, 56.055, 178.72, 71.885, 225)) + return max(shape, -hole) + + rows = [] + pad = b"\0" * ((4 - width * 3 % 4) % 4) + for y in range(height - 1, -1, -1): + v = (y + 0.5 - oy) / scale + ly = (v - 361.02) / 1.6 + row = bytearray() + for x in range(width): + u = (x + 0.5 - ox) / scale + color = background(u, v) + lx = (u - 844.84) / 1.6 + if -40 < lx < 185 and -8 < ly < 232: + for sd, ink in ((white(lx, ly), (255, 255, 255)), (math.hypot(lx - 63.98, ly - 111.86) - 55.98, glow)): + cover = min(max(0.5 - sd * logo_px, 0.0), 1.0) + if cover: + color = [color[i] * (1 - cover) + ink[i] * cover for i in range(3)] + row += bytes((int(color[2] + 0.5), int(color[1] + 0.5), int(color[0] + 0.5))) + rows.append(bytes(row) + pad) + pixels = b"".join(rows) + header = b"BM" + (54 + len(pixels)).to_bytes(4, "little") + b"\0\0\0\0" + (54).to_bytes(4, "little") + info = (40).to_bytes(4, "little") + width.to_bytes(4, "little") + height.to_bytes(4, "little") + \ + (1).to_bytes(2, "little") + (24).to_bytes(2, "little") + b"\0" * 4 + len(pixels).to_bytes(4, "little") + \ + (2835).to_bytes(4, "little") * 2 + b"\0" * 8 + return header + info + pixels + + +def reg_value(reg, key, name, value): + line = '"%s"="%s"' % (name, value.replace("\\", "\\\\").replace('"', '\\"')) + try: + with open(reg, encoding="utf-8", errors="replace") as f: + rows = f.read().splitlines() + except OSError: + return False + inside, last = False, None + for row in rows: + if row.startswith("["): + inside = row.startswith(key + " ") or row == key + elif inside and row.startswith('"%s"=' % name): + last = row + if last != line: + with open(reg, "a", encoding="utf-8") as out: + out.write("\n%s %d\n%s\n" % (key, int(time.time()), line)) + return True + + +def desktop_size(): + width, height = os.environ.get("BL_WIDTH", ""), os.environ.get("BL_HEIGHT", "") + if width.isdigit() and height.isdigit() and 0 < int(width) <= 7680 and 0 < int(height) <= 4320: + return int(width), int(height) + return 1280, 720 + + +def wallpaper(root): + width, height = desktop_size() + shrink = min(1.0, WALLPAPER_MAX[0] / width, WALLPAPER_MAX[1] / height) + width, height = max(1, round(width * shrink)), max(1, round(height * shrink)) + name = "wallpaper-%dx%d.bmp" % (width, height) + folder = os.path.join(drive_c(root), *WALLPAPER_DIR) + path = os.path.join(folder, name) + reg = os.path.join(prefix(root), "pfx", "user.reg") + if not os.path.isfile(reg): + return False + if not os.path.isfile(path): + os.makedirs(folder, exist_ok=True) + with open(path + ".tmp", "wb") as f: + f.write(wallpaper_bmp(width, height)) + os.replace(path + ".tmp", path) + say("drew DroidDeck's wallpaper for Battle.net's %dx%d desktop" % (width, height)) + windows = "C:\\" + "\\".join(WALLPAPER_DIR + (name,)) + reg_value(reg, DESKTOP_KEY, "Wallpaper", windows) + reg_value(reg, DESKTOP_KEY, "TileWallpaper", "0") + reg_value(reg, DESKTOP_KEY, "WallpaperStyle", "2" if shrink < 1 else "0") + reg_value(reg, COLORS_KEY, "Background", "%d %d %d" % WALLPAPER_EDGE) + return True + + +def windows_path(root, path): + found = re.match(r"(?i)^([cz]):[\\/](.*)$", path.strip()) + if not found: + return None + parts = [part for part in re.split(r"[\\/]+", found.group(2)) if part] + if not parts or ".." in parts: + return None + return os.path.join(drive_c(root) if found.group(1).lower() == "c" else "/", *parts) + + +def wow_folders(root): + try: + with open(os.path.join(prefix(root), "pfx", "system.reg"), encoding="utf-8", errors="replace") as f: + rows = f.read().splitlines() + except OSError: + return [] + folders, location, wow = [], None, False + for row in rows + ["["]: + if row.startswith("["): + if wow and location: + folders.append(location) + location, wow = None, False + elif row.startswith('"InstallLocation"='): + location = windows_path(root, row.split("=", 1)[1].strip().strip('"').replace("\\\\", "\\")) + elif row.startswith('"UninstallString"=') and WOW_UID.search(row): + wow = True + return [folder for folder in dict.fromkeys(folders) if os.path.isdir(folder)] + + +def wow_settings(root): + changed = [] + for folder in wow_folders(root): + try: + flavors = sorted(name for name in os.listdir(folder) if len(name) > 2 and name.startswith("_") and name.endswith("_")) + except OSError: + continue + for name in flavors: + if not os.path.isdir(os.path.join(folder, name)): + continue + wtf = os.path.join(folder, name, "WTF") + path = os.path.join(wtf, "Config.wtf") + mark = os.path.join(wtf, GX_API_MARK) + try: + with open(path, encoding="utf-8", errors="replace") as f: + text = f.read() + except FileNotFoundError: + text = "" + except OSError: + continue + updated, notes = text, [] + if not GAMEPAD_SETTING.search(updated): + updated += ("\n" if updated and not updated.endswith("\n") else "") + 'SET GamePadEnable "1"\n' + notes.append("controller support on") + first = not os.path.exists(mark) + if first: + before = updated + updated, found = GX_API_SETTING.subn('SET gxApi "D3D11"', updated) + if not found: + updated += ("\n" if updated and not updated.endswith("\n") else "") + 'SET gxApi "D3D11"\n' + if updated != before: + notes.append("DirectX 11") + if updated == text and not first: + continue + try: + os.makedirs(wtf, exist_ok=True) + if updated != text: + with open(path + ".tmp", "w", encoding="utf-8") as f: + f.write(updated) + os.replace(path + ".tmp", path) + if first: + with open(mark, "w", encoding="utf-8"): + pass + except OSError as error: + say("World of Warcraft's settings not written in %s: %s" % (path, error)) + continue + if notes: + changed.append(path) + say("World of Warcraft's settings in %s: %s" % (path, ", ".join(notes))) + return changed + + +def steam_running(proc="/proc"): + try: + pids = [p for p in os.listdir(proc) if p.isdigit()] + except OSError: + return False + for pid in pids: + try: + with open(os.path.join(proc, pid, "cmdline"), "rb") as f: + argv = [os.path.basename(a.decode("utf-8", "replace")) for a in f.read().split(b"\0")[:2] if a] + except OSError: + continue + if argv[:1] and argv[0] in ("FEX", "FEXInterpreter"): + argv = argv[1:] + if argv[:1] and argv[0] in STEAM_CLIENT: + return True + return False + + +def proton_env(): + env = dict(os.environ) + if not steam_running(): + env.setdefault("PROTON_SPOOF_STEAMINPUT_VIDPID", "1") + return env + + +def configure(root, library=None): + if library: + try: + os.makedirs(library, exist_ok=True) + except OSError as error: + say("%s not made: %s" % (library, error)) + try: + change = write_config(root, library) + except OSError as error: + say("Battle.net's config not written: %s" % error) + return + try: + wallpaper(root) + except OSError as error: + say("Battle.net's desktop wallpaper not set: %s" % error) + wow_settings(root) + if change == "first": + say("wrote Battle.net's first config: hardware acceleration off, streaming off, closing itself as it launches a game, " + "StarCraft on its 32-bit client") + elif change: + if "launch" in change: + say("Battle.net's config: closing itself as it launches a game") + if "library" in change: + say("Battle.net's config: games install to %s" % library) + if "acceleration" in change: + say("Battle.net's config: browser hardware acceleration off") + if "32-bit" in change: + say("Battle.net's config: StarCraft starts its 32-bit client") + + +def write_menu(path=MENU): + body = ("[Desktop Entry]\nType=Application\nName=Battle.net\nComment=Blizzard's games and launcher\n" + "Exec=%s\nIcon=applications-games\nTerminal=false\nCategories=Network;\n" + % os.path.join(BIN, "droiddeck-battlenet")) + try: + with open(path, encoding="utf-8") as f: + if f.read() == body: + return + except OSError: + pass + try: + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path + ".tmp", "w", encoding="utf-8") as f: + f.write(body) + os.replace(path + ".tmp", path) + except OSError as error: + say("menu entry not written: %s" % error) + + +def ask_fex(launch_dir, wait=FEX_WAIT): + if not launch_dir or not os.path.isdir(launch_dir): + say("not inside a DroidDeck session; FEX stays as Components has it") + return None + result = os.path.join(launch_dir, FEX_RESULT) + request = os.path.join(launch_dir, FEX_REQUEST) + try: + os.unlink(result) + except OSError: + pass + with open(request + ".tmp", "w", encoding="utf-8") as f: + f.write("appid=%s\n" % APP_ID) + os.replace(request + ".tmp", request) + deadline = time.monotonic() + wait + while time.monotonic() < deadline: + try: + with open(result, encoding="utf-8") as f: + answer = f.read().strip() + except OSError: + time.sleep(0.5) + continue + os.unlink(result) + return answer + return None + + +def wine_processes(pfx, proc="/proc"): + want = os.path.realpath(pfx).rstrip("/") + found = [] + try: + pids = [p for p in os.listdir(proc) if p.isdigit()] + except OSError: + return found + for pid in pids: + try: + with open(os.path.join(proc, pid, "environ"), "rb") as f: + env = f.read().split(b"\0") + with open(os.path.join(proc, pid, "cmdline"), "rb") as f: + argv = [a.decode("utf-8", "replace") for a in f.read().split(b"\0") if a] + except OSError: + continue + for item in env: + if item.startswith(b"WINEPREFIX="): + value = item[len(b"WINEPREFIX="):].decode("utf-8", "replace") + if os.path.realpath(value).rstrip("/") == want: + found.append((int(pid), argv)) + break + return found + + +def program(argv): + return re.split(r"[\\/]", argv[0])[-1].lower() if argv else "" + + +def is_client(argv): + return program(argv) == CLIENT.lower() + + +def idle(processes): + return all(program(argv) in IDLE for _, argv in processes) + + +def running(pfx): + return any(is_client(argv) for _, argv in wine_processes(pfx)) + + +def is_battlenet(argv): + return bool(argv) and bool(CLIENT_PATH.match(argv[0].replace("/", "\\").lower())) + + +def is_game(argv): + if not argv or program(argv) in NOT_GAME or not re.match(r"[A-Za-z]:[\\/]", argv[0]): + return False + return not is_battlenet(argv) + + +def launched(argv): + return is_game(argv) and "-uid" in (arg.lower() for arg in argv[1:]) + + +def shares_sign_in(argv): + return "-sso" in (arg.lower() for arg in argv[1:]) + + +def agent_up(processes): + return any(is_client(argv) for _, argv in processes) and any(program(argv) == AGENT for _, argv in processes) + + +class Tidy: + + def __init__(self, settle=None, gone=None): + self.settle = GAME_SETTLE if settle is None else settle + self.gone = AGENT_GONE if gone is None else gone + self.game_since = None + self.closed_for_game = False + self.agent_seen = False + self.agent_lost = None + + def __call__(self, processes, now): + own = [(pid, argv) for pid, argv in processes if is_battlenet(argv)] + games = [argv for _, argv in processes if launched(argv)] + if games: + self.game_since = self.game_since if self.game_since is not None else now + if own and not self.closed_for_game and now - self.game_since >= self.settle: + self.closed_for_game, self.agent_seen, self.agent_lost = True, False, None + if any(is_client(argv) for _, argv in own) and any(shares_sign_in(argv) for argv in games): + say("Battle.net stayed open beside a game signing in with its token: left open") + return False + say("the game is up: closing what is left of Battle.net (%d process(es)) to leave it their memory" % len(own)) + stop(own) + return True + return False + self.game_since, self.closed_for_game = None, False + if not any(is_client(argv) for _, argv in processes): + self.agent_seen, self.agent_lost = False, None + elif agent_up(processes): + self.agent_seen, self.agent_lost = True, None + elif self.agent_seen: + self.agent_lost = self.agent_lost if self.agent_lost is not None else now + if now - self.agent_lost >= self.gone: + say("Battle.net has outlived its Agent: closing it (%d process(es))" % len(own)) + stop(own) + notify("Battle.net was closed", "It had lost Blizzard's update service. Open it again to reconnect.", + "dialog-warning") + self.agent_seen, self.agent_lost = False, None + return True + return False + + +def proton_options(): + if os.environ.get("BL_DESKTOP") == "1": + return ["--window"] + width, height = os.environ.get("BL_WIDTH", ""), os.environ.get("BL_HEIGHT", "") + options = ["--desktop", "%sx%s" % (width, height)] if width.isdigit() and height.isdigit() else [] + game = os.environ.get("SteamGameId", "") + return options + (["--game-id", game] if game.isdigit() and game != "0" else []) + + +def stop(processes, wait=STOP_WAIT): + pids = [pid for pid, _ in processes] + for sig in (signal.SIGTERM, signal.SIGKILL): + for pid in pids: + try: + os.kill(pid, sig) + except OSError: + pass + deadline = time.monotonic() + wait + while time.monotonic() < deadline: + pids = [pid for pid in pids if os.path.exists("/proc/%d" % pid)] + if not pids: + return + time.sleep(0.2) + + +def queued_requests(launch_dir): + try: + names = os.listdir(launch_dir) + except OSError: + return [] + return sorted(name for name in names if REQUEST_FILE.fullmatch(name)) + + +def serve_requests(pfx, processes=None, launch_dir=None): + launch_dir = launch_dir or os.environ.get("BL_LAUNCH_DIR") + names = queued_requests(launch_dir) if launch_dir else [] + if not names or not agent_up(wine_processes(pfx) if processes is None else processes): + return False + client = os.path.join(pfx, "drive_c", *CLIENT_DIR, CLIENT) + served = False + for name in names: + path = os.path.join(launch_dir, name) + try: + with open(path, encoding="utf-8") as f: + args = [line for line in f.read().splitlines() if line] + os.unlink(path) + except OSError: + continue + if len(args) == 2 and args[0] == "install-game" and GAME_UID.fullmatch(args[1]): + spawn(client, "run", ["--install", "--game=%s" % args[1]]) + elif len(args) == 2 and args[0] == "launch" and CODE.fullmatch(args[1]): + spawn(client, "run", ["--exec=launch %s" % args[1]]) + else: + say("not a request: %s" % " ".join(args)) + continue + say("the app asked for %s" % " ".join(args)) + served = True + return served + + +def wait_while_open(pfx, grace, poll=None): + deadline = time.monotonic() + grace + seen = False + while True: + serve_requests(pfx) + if running(pfx): + seen = True + elif seen or time.monotonic() >= deadline: + return seen + time.sleep(POLL if poll is None else poll) + + +def proton(exe, args=(), verb=None, watch=None, poll=None, close_grace=None, tidy=None): + cmd = [PROTON_RUN] + proton_options() + ["--prefix", APP_ID] + (["--verb", verb] if verb else []) + [exe] + list(args) + say(" ".join(cmd)) + try: + process = subprocess.Popen(cmd, env=proton_env()) + except OSError as error: + say("%s: %s" % (PROTON_RUN, error)) + return 127, False + seen = False + closed_at = None + grace = CLOSE_GRACE if close_grace is None else close_grace + tidy = Tidy() if tidy is None else tidy + while True: + try: + status = process.wait(timeout=POLL if poll is None else poll) + return status, seen + except subprocess.TimeoutExpired: + pass + if watch is None: + continue + processes = wine_processes(watch) + serve_requests(watch, processes) + now = time.monotonic() + if tidy(processes, now): + seen = True + processes = wine_processes(watch) + if any(is_client(argv) for _, argv in processes): + seen, closed_at = True, None + elif seen and idle(processes): + closed_at = closed_at if closed_at is not None else now + if now - closed_at >= grace: + say("Battle.net closed; stopping %d process(es) it left in its prefix" % len(processes)) + stop(processes) + closed_at = None + else: + closed_at = None + + +def setup_ready(path): + try: + with open(path, "rb") as f: + return f.read(2) == b"MZ" and os.path.getsize(path) >= MIN_INSTALLER + except OSError: + return False + + +def download(url=INSTALLER_URL, folder=CACHE): + os.makedirs(folder, exist_ok=True) + dest = os.path.join(folder, SETUP) + if setup_ready(dest): + return dest + part = dest + ".part" + try: + try: + request = urllib.request.Request(url, headers={"User-Agent": "DroidDeck"}) + with urllib.request.urlopen(request, timeout=60) as response, open(part, "wb") as f: + shutil.copyfileobj(response, f) + length = response.headers.get("Content-Length", "") + if length.isdigit() and f.tell() != int(length): + raise OSError("the download ended early") + except (OSError, http.client.HTTPException) as error: + if not shutil.which("curl"): + raise + say("download through Python failed (%s); trying curl" % error) + if subprocess.call(["curl", "-fsSL", "--max-time", "300", "-o", part, url]) != 0: + raise OSError("curl could not download it") + if not setup_ready(part): + raise OSError("not a Windows program") + os.replace(part, dest) + except (OSError, http.client.HTTPException) as error: + say("installer download failed: %s" % error) + try: + os.unlink(part) + except OSError: + pass + return dest if os.path.isfile(dest) else None + + +def install(root, pfx, verb=None): + setup = download() + if setup is None: + notify("Battle.net could not be downloaded", "Check the connection and try again.", "dialog-error") + return False, False + configure(root, LIBRARY) + notify("Installing Battle.net", "Blizzard's installer opens in a window. It can take a few minutes.") + _, seen = proton(setup, verb=verb, watch=pfx) + return installed(root), seen + + +def acquire(folder=CACHE): + os.makedirs(folder, exist_ok=True) + handle = open(os.path.join(folder, "battlenet.lock"), "w") + try: + fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + handle.close() + return None + return handle + + +def status(root): + pfx = os.path.join(prefix(root), "pfx") + return {"appid": int(APP_ID), "prefix": prefix(root), "installed": installed(root), + "launcher": launcher(root), "running": running(pfx)} + + +def spawn(program, verb, args=()): + cmd = [PROTON_RUN] + proton_options() + ["--prefix", APP_ID, "--verb", verb, program] + list(args) + say(" ".join(cmd)) + try: + os.makedirs(CACHE, exist_ok=True) + with open(os.path.join(CACHE, "spawned.log"), "ab") as log: + child = subprocess.Popen(cmd, stdin=subprocess.DEVNULL, stdout=log, stderr=log, start_new_session=True, env=proton_env()) + threading.Thread(target=child.wait, daemon=True).start() + except OSError as error: + say("%s: %s" % (PROTON_RUN, error)) + return 127 + return 0 + + +def show(root, wait=None, poll=None, args=()): + pfx = os.path.join(prefix(root), "pfx") + client = os.path.join(client_dir(root), CLIENT) + deadline = time.monotonic() + (HANDOFF_WAIT if wait is None else wait) + while True: + processes = wine_processes(pfx) + if any(is_client(argv) for _, argv in processes) and os.path.isfile(client) \ + and (agent_up(processes) or time.monotonic() >= deadline): + spawn(client, "run", args) + return None + lock = acquire() + if lock is not None: + say("the other start has ended; opening Battle.net") + return lock + if time.monotonic() >= deadline: + say("the other start neither opened Battle.net nor ended") + notify("Battle.net is busy", "Another start is still running; try again in a moment.", "dialog-warning") + return None + time.sleep(POLL if poll is None else poll) + + +def stop_leftovers(pfx): + processes = wine_processes(pfx) + if not processes: + return True + if not idle(processes): + say("the prefix is in use (%s); Battle.net starts beside it" % ", ".join(sorted({program(a) for _, a in processes} - IDLE))) + return False + say("stopping %d process(es) a closed Battle.net left in its prefix" % len(processes)) + stop(processes) + return True + + +def open_client(root, reinstall=False): + pfx = os.path.join(prefix(root), "pfx") + write_menu() + answer = ask_fex(os.environ.get("BL_LAUNCH_DIR")) + say("FEX: %s" % (answer or "no answer from the app; Components' choice stays")) + if running(pfx): + say("Battle.net is already running in its prefix; waiting for it to close") + wait_while_open(pfx, grace=0) + return 0 + verb = None if stop_leftovers(pfx) else "run" + if reinstall or not installed(root): + done, seen = install(root, pfx, verb) + if not done: + notify("Battle.net did not install", "The session's desktop.log says why.", "dialog-error") + return 1 + if wait_while_open(pfx, grace=0 if seen else START_GRACE) or seen: + return 0 + verb = None if stop_leftovers(pfx) else "run" + configure(root, LIBRARY) + status_code, seen = proton(launcher(root), verb=verb, watch=pfx) + say("Proton returned %d; Battle.net %s" % (status_code, "ran" if seen else "has not been seen yet")) + if not (wait_while_open(pfx, grace=0 if seen else START_GRACE) or seen): + say("Battle.net did not start (the launcher exited with %d)" % status_code) + notify("Battle.net did not start", "The session's desktop.log says why.", "dialog-error") + return status_code or 1 + say("Battle.net closed") + return 0 + + +def start(exe, verb=None): + cmd = [PROTON_RUN] + proton_options() + ["--prefix", APP_ID] + (["--verb", verb] if verb else []) + [exe] + say(" ".join(cmd)) + try: + return subprocess.Popen(cmd, env=proton_env()) + except OSError as error: + say("%s: %s" % (PROTON_RUN, error)) + return None + + +def wait_ready(pfx, wait=None, poll=None, settle=None): + deadline = time.monotonic() + (CLIENT_WAIT if wait is None else wait) + while True: + count = sum(1 for _, argv in wine_processes(pfx) if is_client(argv)) + if count >= CLIENT_READY: + time.sleep(READY_SETTLE if settle is None else settle) + return True + if time.monotonic() >= deadline: + return count > 0 + time.sleep(POLL if poll is None else poll) + + +def follow(pfx, poll=None, grace=None, tidy=None, ask=None): + seen, gone_at = False, None + asked, asks = None, 1 + grace = GAME_GRACE if grace is None else grace + tidy = Tidy() if tidy is None else tidy + while True: + processes = wine_processes(pfx) + serve_requests(pfx, processes) + now = time.monotonic() + if tidy(processes, now): + processes = wine_processes(pfx) + games = sorted({program(argv) for _, argv in processes if is_game(argv)}) + if games: + if not seen: + say("game running: %s" % ", ".join(games)) + seen, gone_at = True, None + elif seen or not any(is_client(argv) for _, argv in processes): + gone_at = gone_at if gone_at is not None else now + if now - gone_at >= grace: + say("the game has exited" if seen else "Battle.net closed before a game started") + return seen + else: + gone_at = None + asked = now if asked is None else asked + if ask is not None and asks < LAUNCH_ASKS and now - asked >= LAUNCH_WAIT: + say("Battle.net has not started the game yet; asking again") + ask() + asked, asks = now, asks + 1 + time.sleep(POLL if poll is None else poll) + + +def launch(root, code): + pfx = os.path.join(prefix(root), "pfx") + client = os.path.join(client_dir(root), CLIENT) + if not installed(root) or not os.path.isfile(client): + say("Battle.net is not installed; %s is not launched" % code) + notify("Battle.net is not installed", "Install it from its tile on DroidDeck's Desktop tab.", "dialog-error") + return 1 + lock = acquire() + try: + own = None + if lock is not None and not running(pfx): + answer = ask_fex(os.environ.get("BL_LAUNCH_DIR")) + say("FEX: %s" % (answer or "no answer from the app; Components' choice stays")) + verb = None if stop_leftovers(pfx) else "run" + configure(root, LIBRARY) + own = start(launcher(root), verb) + if own is None: + return 127 + if not wait_ready(pfx): + say("Battle.net did not open; %s is not launched" % code) + notify("Battle.net did not start", "The session's desktop.log says why.", "dialog-error") + return 1 + if code.lower().startswith("wow"): + wow_settings(root) + say("launching %s" % code) + spawn(client, "run", ["--exec=launch %s" % code]) + played = follow(pfx, ask=lambda: spawn(client, "run", ["--exec=launch %s" % code])) + if own is not None: + leftovers = wine_processes(pfx) + say("stopping what the Battle.net opened for %s left: %d process(es)" % (code, len(leftovers))) + stop(leftovers) + try: + own.wait(timeout=STOP_WAIT * 4) + except subprocess.TimeoutExpired: + say("its Proton is still ending") + return 0 if played else 1 + finally: + if lock is not None: + lock.close() + + +def hand_over(root, args, wait=None): + if not wait_ready(os.path.join(prefix(root), "pfx"), wait=HANDOVER_WAIT if wait is None else wait): + say("Battle.net did not come up; %s not asked for" % " ".join(args)) + return False + spawn(os.path.join(client_dir(root), CLIENT), "run", args) + return True + + +def install_game(root, uid): + args = ["--install", "--game=%s" % uid] + if installed(root) and agent_up(wine_processes(os.path.join(prefix(root), "pfx"))): + say("asking the open Battle.net to install %s" % uid) + spawn(os.path.join(client_dir(root), CLIENT), "run", args) + return 0 + lock = acquire() + if lock is None: + say("another start holds the lock") + lock = show(root, args=args) + if lock is None: + return 0 + threading.Thread(target=hand_over, args=(root, args), daemon=True).start() + with lock: + return open_client(root) + + +def main(argv): + global LIBRARY + argv = list(argv) + if len(argv) >= 2 and argv[0] == "--library": + if not argv[1].startswith("/") or "\n" in argv[1]: + say("%s is not a folder" % argv[1]) + return 2 + LIBRARY = os.path.normpath(argv[1]) + argv = argv[2:] + action = argv[0] if argv else "open" + launching = action == "launch" and len(argv) == 2 and CODE.fullmatch(argv[1]) + getting = action == "install-game" and len(argv) == 2 and GAME_UID.fullmatch(argv[1]) + if not (launching or getting) and (action not in ("open", "install", "status") or len(argv) > 1): + say("usage: droiddeck-battlenet [--library DIR] [open | install | status | launch CODE | install-game ID]") + return 2 + root = steam_root() + if action == "status": + print(json.dumps(status(root))) + return 0 + os.environ["BL_BATTLENET"] = "1" + if launching: + say("launch %s" % argv[1]) + return launch(root, argv[1]) + if getting: + say("install-game %s" % argv[1]) + return install_game(root, argv[1]) + say("%s (%s)" % (action, "from the menu" if not sys.stderr.isatty() else "from a terminal")) + lock = acquire() + if lock is None: + say("another start holds the lock") + lock = show(root) + if lock is None: + return 0 + with lock: + return open_client(root, reinstall=action == "install") + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-desktop-games b/tools/linuxfs/overlay/usr/local/bin/droiddeck-desktop-games index d86d10b6c..fe1cf0d97 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-desktop-games +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-desktop-games @@ -8,7 +8,7 @@ X64 = os.environ.get("BL_STEAM_CLIENT_ARCH") == "x86_64" CLIENT_HOME = (os.environ.get("BL_X64_HOME") or os.path.join(HOME, ".droiddeck-x64")) if X64 else HOME STEAM_ROOT = sys.argv[1] if len(sys.argv) > 1 else os.path.join(CLIENT_HOME, ".local/share/Steam") LIBRARIES = ((STEAM_ROOT, "Steam Games"), ("/mnt/droiddeck-sd/x86-64" if X64 else "/mnt/droiddeck-sd", "SD Games")) -NOT_GAMES = {228980, 1070560, 1391110, 1493710, 1628350, 3127680, 4183110, 4185400, 4427310, 4628740} +NOT_GAMES = {228980, 1070560, 1161040, 1391110, 1493710, 1628350, 1826330, 3127680, 4183110, 4185400, 4427310, 4628740} APPS_DIR = os.path.join(HOME, ".local/share/applications") PREFIX = "droiddeck-game-" NAME = re.compile(r'^\s*"name"\s*"([^"]*)"', re.M) diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex b/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex index 331dcfecc..6ca792143 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex @@ -589,7 +589,8 @@ def guest_environment(fex, env): result["FEX_THUNKHOSTLIBS"] = fex["host_thunks"] result["FEX_THUNKGUESTLIBS"] = fex["guest_thunks"] result["FEX_THUNKCONFIG"] = os.path.join(CONFIG_DIR, "ThunksConfig.json") - preload = [part for part in split_paths(result.get("LD_PRELOAD")) if os.path.exists(part) and not is_arm(part)] + preload = [part for part in split_paths(result.get("LD_PRELOAD")) + if "/" not in part or (os.path.exists(part) and not is_arm(part))] library_path = [part for part in (result.get("LD_LIBRARY_PATH") or "").split(":") if part and "arm64" not in part and "aarch64" not in part] for name, parts in (("LD_PRELOAD", preload), ("LD_LIBRARY_PATH", library_path)): @@ -671,15 +672,26 @@ def launch(command, env, mode, decide=None): return os.execve(fex["bin"], [fex["bin"], program] + command[1:], guest) -def game_environment(env): +def game_environment(env, command): try: game_env = runpy.run_path(os.path.join(BIN_DIR, "droiddeck-game-env")) env = game_env["game_gpu"](env) app = game_env["game_id"](env) or env.get("SteamAppId") or env.get("SteamGameId") if not app: return env - with open(GAME_ENVIRONMENT, encoding="utf-8") as source: - return game_env["apply_config"](env, json.load(source), str(app)) + fixes = {} + game = game_env["Game"](resolve(command[0], env), command[1:]) + for check in game_env["FIXES"]: + found = check(game) + if found and found[0]: + fixes.update(found[0]) + say(found[2]) + try: + with open(GAME_ENVIRONMENT, encoding="utf-8") as source: + config = json.load(source) + except FileNotFoundError: + config = {"version": 1} + return game_env["apply_config"](env, config, str(app), fixes) except FileNotFoundError: return env except Exception as error: @@ -810,7 +822,7 @@ def main(argv): return 0 if not command: return 2 - return launch(command, game_environment(dict(os.environ)), os.environ.get("DROIDDECK_FEX", "auto")) + return launch(command, game_environment(dict(os.environ), command), os.environ.get("DROIDDECK_FEX", "auto")) if action == "run": try: mode, decide, command = parse_run(args) diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-flatpak-setup b/tools/linuxfs/overlay/usr/local/bin/droiddeck-flatpak-setup index 24806fa2e..bd25b6089 100644 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-flatpak-setup +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-flatpak-setup @@ -3,6 +3,7 @@ set -u export LANG=C.UTF-8 step() { echo "== STEP $*"; } fail() { echo "== FAIL $*"; exit 1; } +[ "$(id -u)" = 0 ] || fail "needs root: install Flatpak from the launcher's Store, which runs this as root" PACKAGES="flatpak ostree appstream libxmlb libmalcontent libfyaml accountsservice composefs xdg-dbus-proxy" while read -r pkg probe; do @@ -48,10 +49,13 @@ if [ ! -f /etc/pacman.d/gnupg/pubring.gpg ] && [ ! -f /etc/pacman.d/gnupg/pubrin fi step "Reading the package lists" -pacman --config "$CONF" --dbpath "$WORK/db" --noconfirm -Sy >/dev/null 2>&1 \ - || pacman --config "$CONF" --dbpath "$WORK/db" --noconfirm -Sy 2>&1 | tail -3 \ - || fail "could not reach the Arch Linux ARM mirrors" -[ -f "$WORK/db/sync/extra.db" ] || fail "could not reach the Arch Linux ARM mirrors" +sync_log=$(pacman --config "$CONF" --dbpath "$WORK/db" --noconfirm -Sy 2>&1) \ + || sync_log=$(pacman --config "$CONF" --dbpath "$WORK/db" --noconfirm -Sy 2>&1) +if [ ! -f "$WORK/db/sync/extra.db" ]; then + printf '%s\n' "$sync_log" | tail -3 + reason=$(printf '%s\n' "$sync_log" | grep -m1 -i '^error' | cut -c1-160) + fail "could not reach the Arch Linux ARM mirrors${reason:+ ($reason)}" +fi step "Downloading Flatpak" out=$(pacman --config "$CONF" --dbpath "$WORK/db" --cachedir "$WORK/pkg" --noconfirm -Sw -dd $PACKAGES 2>&1) diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env b/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env index a38d77d8c..f8b4acaeb 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env @@ -6,6 +6,7 @@ import re import runpy import subprocess import sys +import tempfile from pathlib import Path GAME_TU_DEBUG = "DROIDDECK_GAME_TU_DEBUG" @@ -87,6 +88,12 @@ def dotnet_memory_ordering(game): return DOTNET_FEX, [], ".NET runtime: FEX with full TSO and without multiblock" +@fix +def unity_memory_ordering(game): + if {"unityplayer.so", "gameassembly.so", "unityplayer.dll"} & game.files: + return {"FEX_TSOENABLED": "1"}, [], "Unity: FEX with TSO" + + @fix def godot_vulkan(game): if is_godot(game) and not game.has_arg("--rendering-driver"): @@ -114,6 +121,35 @@ def arkham_city_x87(game): return {"FEX_X87REDUCEDPRECISION": "0"}, [], "Batman: Arkham City: FEX with full x87 precision" + +@fix +def protonfixes_off(game): + if game.stem in ("blackops3", "darksoulsremastered"): + return {"PROTONFIXES_DISABLE": "1"}, [], "umu-protonfixes off (its ALSA audio and VC++ installer steps fail here)" + + +@fix +def caravan_sandwitch_feature_level(game): + if "caravansandwitch.exe" in game.files or game.stem == "caravansandwitch-win64-shipping": + return {"VKD3D_FEATURE_LEVEL": "12_1"}, [], "Caravan SandWitch: D3D12 feature level 12_1 (12_2 hands it mesh shader pipelines vkd3d-proton cannot build)" + + +@fix +def robocop_rogue_city_feature_level(game): + """vkd3d-proton turns mesh shaders off on Adreno, but 12_2 still advertises them, and UE5 then builds mesh + pipelines with no pipeline layout that crash Turnip on launch.""" + if "robocop.exe" in game.files or game.stem == "robocop-win64-shipping": + return {"VKD3D_FEATURE_LEVEL": "12_1"}, [], "RoboCop: Rogue City: D3D12 feature level 12_1" + + +@fix +def cyberpunk_feature_level(game): + if game.stem == "cyberpunk2077" or ( + game.stem == "redprelauncher" + and os.path.isfile(os.path.join(os.path.dirname(game.exe), "bin", "x64", "Cyberpunk2077.exe")) + ): + return {"VKD3D_FEATURE_LEVEL": "12_1"}, [], "Cyberpunk 2077: D3D12 feature level 12_1" + def skip_red_launcher(command): """Cyberpunk 2077's prelauncher opens REDlauncher, a Qt WebEngine window that hands over to the game half a minute later and then stays up beside it, well over a core busy for as long as the @@ -127,6 +163,61 @@ def skip_red_launcher(command): return command +UBISOFT_LAUNCHER = "drive_c/Program Files (x86)/Ubisoft/Ubisoft Game Launcher" +UBISOFT_SETTINGS = "drive_c/users/steamuser/AppData/Local/Ubisoft Game Launcher/settings.yaml" + + +def overlay_off(text): + lines = text.splitlines() + try: + start = next(i for i, line in enumerate(lines) if re.fullmatch(r"overlay:\s*(#.*)?", line)) + except StopIteration: + if lines and not lines[-1].strip(): + lines.pop() + return "\n".join(lines + ["overlay:", " enabled: false", " warning_enabled: false"]) + "\n" + end = start + 1 + for index in range(start + 1, len(lines)): + if lines[index].strip() and not lines[index].startswith(" "): + break + if lines[index].strip(): + end = index + 1 + block = lines[start + 1:end] + for key in ("enabled", "warning_enabled"): + entry = " %s: false" % key + found = [i for i, line in enumerate(block) if line.split(":", 1)[0] == " " + key] + if found: + block[found[0]] = entry + else: + block.append(entry) + return "\n".join(lines[:start + 1] + block + lines[end:]) + "\n" + + +def ubisoft_overlay_off(env): + prefix = Path(env.get("STEAM_COMPAT_DATA_PATH", "/nonexistent")) / "pfx" + if not (prefix / UBISOFT_LAUNCHER).is_dir(): + return + settings = prefix / UBISOFT_SETTINGS + try: + text = settings.read_text(encoding="utf-8") if settings.exists() else "" + updated = overlay_off(text) + if updated == text: + return + settings.parent.mkdir(parents=True, exist_ok=True) + handle, staged = tempfile.mkstemp(prefix=".settings-", dir=settings.parent) + try: + with os.fdopen(handle, "w", encoding="utf-8") as target: + target.write(updated) + if settings.exists(): + os.chmod(staged, settings.stat().st_mode & 0o7777) + os.replace(staged, settings) + except BaseException: + os.unlink(staged) + raise + print("droiddeck-game-env: Ubisoft Connect overlay turned off", file=sys.stderr) + except (OSError, UnicodeDecodeError) as error: + print("droiddeck-game-env: Ubisoft Connect overlay left as is (%s)" % error, file=sys.stderr) + + def offline_without_anticheat(command, env): """Start the configured game executable when the player opts into launching without EAC.""" if (env.get("DROIDDECK_ANTICHEAT_OFFLINE") != "1" or len(command) <= 2 @@ -196,6 +287,58 @@ def game_gpu(env): return result +def vram_cap(env): + """Puts the session's VRAM cap ahead of the game's own DXVK settings, so a game profile can still raise it.""" + value = env.get("BL_VRAM_MB", "") + if not re.fullmatch(r"[1-9][0-9]{0,6}", value): + return env + result = env.copy() + cap = "dxgi.maxDeviceMemory = %d" % int(value) + own = result.get("DXVK_CONFIG", "").strip().strip(";").strip() + result["DXVK_CONFIG"] = cap + "; " + own if own else cap + return result + + +def parse_cores(text): + """CPU numbers from a list such as "0-3,6"; None when the text is not one.""" + cores = set() + for part in text.replace(" ", "").split(","): + match = re.fullmatch(r"(\d{1,3})(?:-(\d{1,3}))?", part) + if not match: + return None + first, last = int(match.group(1)), int(match.group(2) or match.group(1)) + if last < first: + return None + cores.update(range(first, last + 1)) + return cores or None + + +def online_cores(): + try: + with open("/sys/devices/system/cpu/online") as f: + cores = parse_cores(f.read().strip()) + except OSError: + cores = None + return cores or os.sched_getaffinity(0) | set(range(os.cpu_count() or 0)) + + +def game_cores(env): + """Pins the game to the cores its profile names, in place of the session's game cores.""" + text = env.get("DROIDDECK_GAME_CORES", "").strip() + if not text: + return + cores = parse_cores(text) + chosen = cores & online_cores() if cores else None + if not chosen: + print("droiddeck-game-env: ignoring game cores %r" % text, file=sys.stderr) + return + try: + os.sched_setaffinity(0, chosen) + print("droiddeck-game-env: game cores %s" % ",".join(map(str, sorted(chosen))), file=sys.stderr) + except OSError as error: + print("droiddeck-game-env: could not use game cores %r (%s)" % (text, error), file=sys.stderr) + + def apply_config(env, config, app_id, fixes=None): if config.get("version") != 1: raise ValueError("unsupported environment configuration") @@ -443,6 +586,7 @@ def main(): fixes = {} if waiting: command = skip_red_launcher(command) + ubisoft_overlay_off(env) fixes, extra, notes = engine_fixes(command[2], command[3:]) if len(command) > 2 else ({}, [], []) for note in notes: print("droiddeck-game-env: " + note, file=sys.stderr) @@ -468,7 +612,9 @@ def main(): command = offline_without_anticheat(command, env) if mic_enabled == "0": env["BANNER_AUDIO_DIRECT_MIC"] = "0" + env = vram_cap(env) env = force_ssbs(command, env) + game_cores(env) try: xbox = runpy.run_path(str(Path(__file__).with_name("droiddeck-xbox"))) except Exception as error: diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-pipewire b/tools/linuxfs/overlay/usr/local/bin/droiddeck-pipewire index 11ed41fb2..2677599bf 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-pipewire +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-pipewire @@ -21,6 +21,30 @@ unpack() { { rm -rf "$dir.new"; echo "droiddeck-pipewire: could not unpack $bundle" >&2; return 1; } } +access_config() { + mkdir -p "$dir/share/pipewire/pipewire.conf.d" "$dir/share/wireplumber/wireplumber.conf.d" || return 1 + cat > "$dir/share/pipewire/pipewire.conf.d/droiddeck-access.conf" <<'EOF' || return 1 +context.properties = { + module.access = false +} +context.modules = [ + { name = libpipewire-module-access + args = { + access.socket = { pipewire-0 = "unrestricted", pipewire-0-manager = "unrestricted" } + } + } +] +EOF + cat > "$dir/share/wireplumber/wireplumber.conf.d/droiddeck-access.conf" <<'EOF' || return 1 +access.rules = [ + { + matches = [ { pipewire.access = "unrestricted" } ] + actions = { update-props = { default_permissions = "all" } } + } +] +EOF +} + stop() { for pid in $(cat "$state/pids" 2>/dev/null); do kill "$pid" 2>/dev/null; done rm -f "$state/pids" @@ -32,6 +56,7 @@ env) ;; start) unpack || exit 1 + access_config || { echo "droiddeck-pipewire: could not write the access settings" >&2; exit 1; } stop mkdir -p "$state" eval "$(client_env)" diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-proton-run b/tools/linuxfs/overlay/usr/local/bin/droiddeck-proton-run index 5d59829bf..624d42074 100644 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-proton-run +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-proton-run @@ -1,7 +1,7 @@ #!/usr/bin/python3 """Runs a Windows program with the Proton the Steam client has chosen for it. - droiddeck-proton-run [--window] PROGRAM [ARGS...] + droiddeck-proton-run [--window] [--prefix ID] [--verb run] [--game-id ID] [--desktop WxH] PROGRAM [ARGS...] droiddeck-proton-run --app ID [--dir FOLDER] [--options TEXT] [PROGRAM [ARGS...]] The program belongs to an installed Steam game when it sits in one's folder, to a prefix when it @@ -10,10 +10,15 @@ its own, under the id the client gives a new shortcut. That title's CompatToolMa config.vdf picks the tool, else the client's default for all other titles ("0"). The tool is started as the client starts it - its toolmanifest's command line, the waitforexitandrun verb and the STEAM_COMPAT_* environment - so its wrapper applies the game's settings, components and audio. ---window keeps the program in the desktop's own window, drawn with WineD3D. +--window keeps the program in the desktop's own window, drawn with WineD3D on the desktop's software +GL (llvmpipe): KWin there composites in software, and the session's zink finds no device. +--prefix ID puts the program in compatdata/ID whatever it is. --verb run starts it beside what already +runs in the prefix, which waitforexitandrun would wait for; the wrapper's launch-time steps are skipped. +--game-id ID keeps the id of the launch it runs for: gamescope shows the windows that carry the id Steam launched. --app starts one game without the Steam client (its Start without Steam): a Steam game's default Windows launch option from appinfo.vdf when no program is given, else the program in that -shortcut's own prefix, started in --dir with the shortcut's launch options (--options). +shortcut's own prefix, started in --dir with the shortcut's launch options (--options). Without +--options it takes the launch options set in the client's Properties for that game or shortcut. """ import json import os @@ -30,7 +35,10 @@ BIN_DIR = os.path.dirname(os.path.abspath(__file__)) STATE = os.path.join(HOME, ".local", "share", "droiddeck-compat", "state.json") EXTRA_LIBRARIES = ("/mnt/droiddeck", "/mnt/droiddeck-sd") SYSTEM_TOOLS = ("/usr/share/steam/compatibilitytools.d", "/usr/local/share/steam/compatibilitytools.d") +DESKTOP_GL = {"LIBGL_ALWAYS_SOFTWARE": "1", "GALLIUM_DRIVER": "llvmpipe"} +NOT_DESKTOP_GL = ("MESA_LOADER_DRIVER_OVERRIDE", "LIBGL_KOPPER_DRI2") VERB = "waitforexitandrun" +VERBS = (VERB, "run") SHORTCUT_BIT = 0x80000000 STEAM_APP_LIMIT = 2 ** 31 @@ -185,9 +193,9 @@ def steam_game(program, libs): def prefix_of(program): for folder, below in ancestors(program): parent = os.path.dirname(folder) - if below == "pfx" and os.path.basename(parent) == "compatdata" and os.path.basename(folder).isdigit(): + if below == "pfx" and os.path.basename(parent) == "compatdata" and re.fullmatch(r"-?[0-9]+", os.path.basename(folder)): library = os.path.dirname(os.path.dirname(parent)) - return Target(os.path.basename(folder), folder, os.path.dirname(program), library, "prefix") + return Target(str(int(os.path.basename(folder)) % 2 ** 32), folder, os.path.dirname(program), library, "prefix") return None @@ -230,10 +238,68 @@ def shortcut_entries(root): return entries +def prefix_folder(root, appid): + """The prefix folder the client made for [appid], under either spelling of the id.""" + folder = os.path.join(root, "steamapps", "compatdata") + try: + names = sorted(os.listdir(folder)) + except OSError: + names = [] + candidates = [] + for name in names: + if not re.fullmatch(r"-?[0-9]+", name): + continue + try: + matches = int(name) % 2 ** 32 == appid % 2 ** 32 + except ValueError: + continue + match = os.path.join(folder, name) + if matches and os.path.isdir(match): + candidates.append((os.path.isdir(os.path.join(match, "pfx")), + name == str(appid % 2 ** 32), name, match)) + if candidates: + return min(candidates, key=lambda item: (not item[0], not item[1], item[2]))[3] + return os.path.join(folder, str(appid % 2 ** 32)) + + +def vdf_string(token): + return re.sub(r'\\(.)', r'\1', token[1:-1]) if token.startswith('"') else "" + + +def steam_options(app, root): + """The launch options the client keeps for a game (localconfig.vdf) or a shortcut (shortcuts.vdf).""" + try: + tool = runpy.run_path(os.path.join(BIN_DIR, "droiddeck-steam-shortcuts")) + account = tool["account_dir"](root) + if not account: + return "" + if int(app) >= STEAM_APP_LIMIT: + with open(os.path.join(account, "config", "shortcuts.vdf"), "rb") as f: + shortcuts = tool["parse"](f.read()).get("shortcuts", {}) + for value in shortcuts.values(): + if isinstance(value, dict) and (value.get("appid", value.get("AppId")) or 0) & 0xFFFFFFFF == int(app): + options = value.get("LaunchOptions", value.get("launchoptions", "")) + return options if isinstance(options, str) else "" + return "" + tokens = compat()["tokenize"](read_text(os.path.join(account, "config", "localconfig.vdf")) or "") + start = compat()["find_block"](tokens, ["UserLocalConfigStore", "Software", "Valve", "Steam", "apps", app]) + if start is None: + return "" + end, depth = compat()["block_end"](tokens, start), 0 + for i in range(start + 1, end): + if tokens[i] in ("{", "}"): + depth += 1 if tokens[i] == "{" else -1 + elif depth == 0 and tokens[i].lower() == '"launchoptions"' and i + 1 < end: + return vdf_string(tokens[i + 1]) + except (OSError, ValueError, IndexError, KeyError, TypeError, AttributeError, struct.error): + pass + return "" + + def shortcut(program, root): for appid, exe, start in shortcut_entries(root): if same_file(program, exe): - return Target(appid, os.path.join(root, "steamapps", "compatdata", str(appid)), + return Target(appid, prefix_folder(root, appid), os.path.dirname(program), root, "shortcut", start if os.path.isdir(start) else None) return None @@ -250,10 +316,19 @@ def locate(program, root): if found: return found appid = own_id(program) - return Target(appid, os.path.join(root, "steamapps", "compatdata", str(appid)), + return Target(appid, prefix_folder(root, appid), os.path.dirname(program), root, "own") +def fixed(program, root, appid): + return Target(appid, os.path.join(root, "steamapps", "compatdata", str(appid)), + os.path.dirname(program), root, "fixed") + + +def prefix_id(value): + return value if value.isdigit() and not value.startswith("0") and int(value) <= 0xFFFFFFFF else None + + def split_words(text): try: return shlex.split(text or "") @@ -325,7 +400,8 @@ def steam_launch(appid, root): def shortcut_target(appid, program, start, root): - return Target(appid, os.path.join(root, "steamapps", "compatdata", str(appid)), os.path.dirname(program), root, + appid = int(appid) % 2 ** 32 + return Target(str(appid), prefix_folder(root, appid), os.path.dirname(program), root, "shortcut", start if start and os.path.isdir(start) else None) @@ -337,7 +413,7 @@ def steam_launches(target, program, root): return os.path.relpath(program, target.install).replace(os.sep, "/").lower() in entry.get("launch", ()) -def environment(env, target, tool_folder, root, window=False): +def environment(env, target, tool_folder, root, window=False, game_id=None): env = dict(env) steamapps = [os.path.join(lib, "steamapps") for lib in libraries(root)] env.update({ @@ -355,28 +431,52 @@ def environment(env, target, tool_folder, root, window=False): else: game = str(number << 32 | 0x02000000) env.update(SteamAppId="0", STEAM_COMPAT_APP_ID="0", SteamGameId=game, SteamOverlayGameId=game) + if game_id: + env.update(SteamGameId=game_id, SteamOverlayGameId=game_id) if window: env.setdefault("PROTON_USE_WINED3D", "1") + env.update(DESKTOP_GL) + for name in NOT_DESKTOP_GL: + env.pop(name, None) return env -def windows_path(path): +def windows_path(path, compatdata=None): + if compatdata: + drive = os.path.join(compatdata, "pfx", "drive_c") + inside = os.path.relpath(path, drive) + if inside != "." and not inside.startswith(".."): + return "C:\\" + inside.replace("/", "\\") return "Z:" + path.replace("/", "\\") -def arguments(program, args): +def arguments(program, args, desktop=None, compatdata=None): if program.lower().endswith(".msi"): return ["msiexec", "/i", windows_path(program)] + args + if desktop: + return ["explorer.exe", "/desktop=droiddeck,%s" % desktop, windows_path(program, compatdata)] + args return [program] + args def main(argv): window = False - app, start, options = None, None, "" + prefix = None + verb = VERB + game_id = None + desktop = None + app, start, options = None, None, None while argv and argv[0].startswith("--"): option = argv.pop(0) if option == "--window": window = True + elif option == "--prefix" and argv and prefix_id(argv[0]): + prefix = argv.pop(0) + elif option == "--verb" and argv and argv[0] in VERBS: + verb = argv.pop(0) + elif option == "--game-id" and argv and argv[0].isdigit() and argv[0] != "0": + game_id = argv.pop(0) + elif option == "--desktop" and argv and re.fullmatch(r"[1-9][0-9]{2,4}x[1-9][0-9]{2,4}", argv[0]): + desktop = argv.pop(0) elif option in ("--app", "--dir", "--options") and argv: value = argv.pop(0) if option == "--app": @@ -394,9 +494,11 @@ def main(argv): say("%s is not an app id" % app) return 2 if not argv and app is None: - say("usage: droiddeck-proton-run [--window] PROGRAM [ARGS...]") + say("usage: droiddeck-proton-run [--window] [--prefix ID] [--verb run] [--game-id ID] [--desktop WxH] PROGRAM [ARGS...]") return 2 root = os.environ.get("STEAM_COMPAT_CLIENT_INSTALL_PATH") or STEAM_ROOT + if options is None: + options = steam_options(app, root) if app is not None else "" extra, args = split_options(options) if not argv: if int(app) >= STEAM_APP_LIMIT: @@ -416,6 +518,8 @@ def main(argv): args = args + argv[1:] if app is not None and int(app) >= STEAM_APP_LIMIT: target = shortcut_target(app, program, start, root) + elif prefix: + target = fixed(program, root, prefix) else: target = locate(program, root) available = tools(root) @@ -425,7 +529,7 @@ def main(argv): return 69 folder, command = available[name] os.makedirs(target.compatdata, exist_ok=True) - env = environment(os.environ, target, folder, root, window) + env = environment(os.environ, target, folder, root, window, game_id) env.update(extra) say("%s with %s (%s), %s prefix %s%s" % (program, name, why, target.kind, target.compatdata, ", without the Steam client" if app is not None else "")) @@ -434,7 +538,7 @@ def main(argv): except OSError: os.chdir(os.path.dirname(program)) entry = os.path.join(folder, command) - os.execve(entry, [entry, VERB] + arguments(program, args), env) + os.execve(entry, [entry, verb] + arguments(program, args, desktop, target.compatdata), env) if __name__ == "__main__": diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-seed-redists b/tools/linuxfs/overlay/usr/local/bin/droiddeck-seed-redists index 831a0e4ce..86d3f1c31 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-seed-redists +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-seed-redists @@ -24,7 +24,7 @@ read_scripts() { xargs -0 -r awk -v want="${1:-redists}" ' proc[depth] = 1 program = tolower(part[4]) sub(/.*[\\\/]/, "", program) - if (program ~ /vulkan/) vulkan[depth] = 1 + if (program ~ /vulkan|uplay|ubisoft/) vulkan[depth] = 1 next } if (n == 3) pending = part[2] @@ -243,7 +243,7 @@ for system in "${regs[@]}"; do had=() for line in "${kept[@]}"; do had[$line]=1; done printf '%s\n' "${other[@]}" "${kept[@]}" >"$seeded" - echo "== took Vulkan runtime markers out of $reg" + echo "== took Vulkan runtime and Ubisoft Connect markers out of $reg" fi if [ ${#had[@]} -gt 0 ] && { [ -z "$id" ] || [ "$id" != "$prev" ]; }; then declare -A held=() diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session index 236ff8598..bc3febb33 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session @@ -60,6 +60,9 @@ clear_stale_tmp() { return 0 } [ -z "${BL_INSIDE:-}" ] && clear_stale_tmp +if [ -z "${BL_INSIDE:-}" ]; then + if [ "${BL_ZRAM:-0}" = 1 ]; then printf 0 > /tmp/.bl-zram; else rm -f /tmp/.bl-zram; fi +fi steam_update_stuck() { [ "$1" -lt 60 ] || steam_updates=0 @@ -83,12 +86,6 @@ run_gamescope() ( collect_steam_logs() { [ -n "${BL_DEBUG_DIR:-}" ] || return 0 - local steam_root=${XDG_DATA_HOME:-$HOME/.local/share}/Steam - [ "${steam_x64:-0}" = 1 ] && steam_root=${BL_X64_HOME:-$HOME/.droiddeck-x64}/.local/share/Steam - mkdir -p "$BL_DEBUG_DIR/steam" 2>/dev/null || return 0 - [ -d "$steam_root/logs" ] && find "$steam_root/logs" -maxdepth 1 -type f -size -8192k \ - -exec cp {} "$BL_DEBUG_DIR/steam/" \; 2>/dev/null - [ "${steam_x64:-0}" = 0 ] && [ -f "$HOME/.steam/steam/logs/steamwebhelper.log" ] && cp "$HOME/.steam/steam/logs/steamwebhelper.log" "$BL_DEBUG_DIR/steam/" 2>/dev/null local sync_store=$HOME/.local/share/droiddeck-esync sync_file if [ -d "$sync_store" ] && mkdir -p "$BL_DEBUG_DIR/droiddeck-esync" 2>/dev/null; then for sync_file in launches.log tools.tsv wanted.tsv prefixes.json; do @@ -99,7 +96,6 @@ collect_steam_logs() { if [ -d "$fex_state" ] && mkdir -p "$BL_DEBUG_DIR/droiddeck-fex" 2>/dev/null; then cp "$fex_state/status.json" "$fex_state/config/Config.json" "$BL_DEBUG_DIR/droiddeck-fex/" 2>/dev/null fi - echo "== steam logs copied to $BL_DEBUG_DIR/steam" } trap 'collect_steam_logs 2>/dev/null || true' TERM INT HUP if [ -n "${BL_VK_DRIVER:-}" ]; then @@ -215,20 +211,31 @@ if [ -z "${BL_INSIDE:-}" ]; then width=${BL_WIDTH:-1280} height=${BL_HEIGHT:-720} nested=$width + nested_h=$height if [ "${BL_FOLLOW_OUTPUT:-0}" = 1 ]; then echo "== gamescope: the output follows the screen (${width}x${height} to start)" elif [ "$mode" = steam ] && [ "${BL_GAMESCOPE_STRETCH_16X9:-0}" = 1 ]; then wide=$(( (height * 16 + 4) / 9 / 2 * 2 )) + tall=$(( (width * 9 + 8) / 16 / 2 * 2 )) if [ "$wide" -gt "$width" ]; then nested=$wide - echo "== gamescope: games at ${nested}x${height} stretched to the ${width}x${height} output" + elif [ "$tall" -gt "$height" ]; then + nested_h=$tall + fi + if [ "$nested" != "$width" ] || [ "$nested_h" != "$height" ]; then + echo "== gamescope: games at ${nested}x${nested_h} stretched to the ${width}x${height} output" fi fi - if [ "$mode" = steam ]; then + # Off unless Performance asks for it. Compositing the cursor here adds a second + # plane on the phone's compositor; a controller-driven cursor is the reason to turn it on. + if [ "$mode" = steam ] && [ "${BL_CURSOR_COMPOSITE:-0}" = 1 ]; then export gamescope_cursor_composite=2 fi - args=(--backend wayland --expose-wayland -f -W "$width" -H "$height" -w "$nested" -h "$height") - [ "$nested" != "$width" ] && args+=(-S stretch) && export BL_STRETCHED=1 + args=(--backend wayland --expose-wayland -f -W "$width" -H "$height" -w "$nested" -h "$nested_h") + if [ "$nested" != "$width" ] || [ "$nested_h" != "$height" ]; then + args+=(-S stretch) + export BL_STRETCHED=1 + fi if [ "${BL_FPS:-0}" -gt 0 ]; then args+=(-r "$BL_FPS") elif [ "${BL_REFRESH:-0}" -gt 0 ]; then @@ -398,8 +405,14 @@ case $mode in steam_root=$XDG_DATA_HOME/Steam if [ -f "${BL_X64_HOME:-$HOME/.droiddeck-x64}/.local/share/Steam/local.vdf" ]; then droiddeck-steam-x64 --sync-login || true + droiddeck-steam-x64 --share || true fi steam_client=("$steam_root/steamrtarm64/steam") + _bl_srt_shim=$HOME/.droiddeck-steamrt-shim + if droiddeck-steamrt-shim "$steam_root/steamrtarm64" "$_bl_srt_shim"; then + echo "== steam: Valve containerized the arm64 Steam client (it carries a Steam Runtime container); starting it through $_bl_srt_shim" + steam_client=(env STEAM_RUNTIME_STEAMRT="$_bl_srt_shim" PATH="$_bl_srt_shim/bin:$PATH" "${steam_client[@]}") + fi fi _bl_proton_root=$steam_root if [ "$steam_x64" = 1 ]; then @@ -441,57 +454,141 @@ case $mode in step "client cores: $BL_CLIENT_CPUS" fi if [ "${BL_ZRAM:-0}" = 1 ]; then - printf 0 > /tmp/.bl-zram step "zRAM compression: on" + [ "${BL_ZRAM_CLIENT:-1}" = 1 ] || step "zRAM: Steam is not compressed while games run (turned off in Performance)" + _bl_ram_cap=0 + case ${BL_RAM_CAP_MB:-} in + ''|0) ;; + *[!0-9]*|0*|???????*) step "RAM cap: ignored invalid value '$BL_RAM_CAP_MB'" ;; + *) _bl_ram_cap=$((BL_RAM_CAP_MB * 1024)); step "RAM cap: ${BL_RAM_CAP_MB} MB" ;; + esac ( bl_zram_scan() { - _bl_game=0 _bl_pids=() + _bl_game=0 _bl_pids=() _bl_others=() for _d in /proc/[0-9]*; do + [ "${_d#/proc/}" != "$BASHPID" ] || continue # Under FEX the program is FEX's first argument. { IFS= read -r -d '' _c && case ${_c##*/} in FEX|FEXInterpreter) IFS= read -r -d '' _c ;; esac; } \ 2>/dev/null < "$_d/cmdline" || continue case ${_c##*/} in reaper) _bl_game=1 ;; steam|steamwebhelper) _bl_pids+=("${_d#/proc/}") ;; + *) _bl_others+=("${_d#/proc/}") ;; esac done } bl_zram_usage() { local _p _k _v _u _rss=0 _swap=0 - for _p in "${_bl_pids[@]}"; do + for _p in "$@"; do while read -r _k _v _u; do case $_k in VmRSS:) _rss=$((_rss + _v)) ;; VmSwap:) _swap=$((_swap + _v)) ;; esac done 2>/dev/null < "/proc/$_p/status" done - echo "${#_bl_pids[@]} processes, resident $((_rss / 1024)) MB, in zram $((_swap / 1024)) MB" + _bl_rss=$_rss + echo "$# processes, resident $((_rss / 1024)) MB, in zram $((_swap / 1024)) MB" + } + # Only processes that loaded libblsession are the session's own and answer SIGURG. + bl_session_others() { + local _p + _bl_session=() + for _p in "${_bl_others[@]}"; do + grep -q -m1 '/libblsession\.so$' "/proc/$_p/maps" 2>/dev/null && _bl_session+=("$_p") + done + } + bl_zram_pick() { + if [ "$1" = session ]; then + bl_session_others + _pids=("${_bl_session[@]}") + else + _pids=("${_bl_pids[@]}") + fi + } + # Same protocol as SessionSuspendController: an empty ack when the page-out starts, a byte when it is done. + # A process whose SIGURG handler was replaced never acks, so it is not waited for after the first 2 s. + bl_zram_wait() { + local _p _left _start=$SECONDS + while [ $((SECONDS - _start)) -lt 12 ]; do + _left=0 + for _p in "$@"; do + [ -s "/tmp/.bl-zram.ack.$_p" ] && continue + [ -d "/proc/$_p" ] || continue + [ $((SECONDS - _start)) -ge 2 ] && [ ! -e "/tmp/.bl-zram.ack.$_p" ] && continue + _left=1 + done + [ "$_left" = 1 ] || break + nap 0.2 + done + for _p in "$@"; do rm -f "/tmp/.bl-zram.ack.$_p"; done } bl_zram_send() { - step "zRAM: $2 ($(bl_zram_usage))" - printf '%s' "$1" > /tmp/.bl-zram - [ "${#_bl_pids[@]}" = 0 ] || kill -URG "${_bl_pids[@]}" 2>/dev/null - nap 5 + local _mode=$1 _what=$2 _which=$3 _pids _p + bl_zram_pick "$_which" + step "zRAM: $_what ($(bl_zram_usage "${_pids[@]}"))" + for _p in "${_pids[@]}"; do rm -f "/tmp/.bl-zram.ack.$_p"; done + printf '%s' "$_mode" > /tmp/.bl-zram + [ "${#_pids[@]}" = 0 ] || kill -URG "${_pids[@]}" 2>/dev/null + if [ "$_mode" = 1 ]; then bl_zram_wait "${_pids[@]}"; else nap 5; fi bl_zram_scan - step "zRAM: done ($(bl_zram_usage))" + bl_zram_pick "$_which" + step "zRAM: done ($(bl_zram_usage "${_pids[@]}"))" } - _bl_since=-1 _bl_packed=-1 + bl_over_cap() { + [ "$_bl_ram_cap" -gt 0 ] || return 1 + _bl_measured=$SECONDS + bl_session_others + bl_zram_usage "${_bl_pids[@]}" "${_bl_session[@]}" > /dev/null + [ "$_bl_rss" -gt "$_bl_ram_cap" ] + } + bl_cap_due() { + [ "$_bl_ram_cap" -gt 0 ] && { [ "$_bl_measured" -lt 0 ] || [ $((SECONDS - _bl_measured)) -ge 30 ]; } + } + _bl_since=-1 _bl_packed=-1 _bl_measured=-1 _bl_unpack=0 _bl_capped=-1 _bl_backoff=30 while kill -0 $$ 2>/dev/null; do bl_zram_scan + if bl_cap_due; then + if bl_over_cap; then + # A cap below the game's working set cannot be met; paging out again every 30 s only churns. + if [ "$_bl_capped" -lt 0 ] || [ $((SECONDS - _bl_capped)) -ge "$_bl_backoff" ]; then + bl_zram_usage "${_bl_pids[@]}" > /dev/null + if [ "$_bl_rss" -ge $((512 * 1024)) ]; then + bl_zram_send 1 "over the ${BL_RAM_CAP_MB} MB RAM cap, compressing the client" client + [ "$_bl_game" = 1 ] && _bl_packed=$SECONDS + else + step "RAM cap: over, client already small ($((_bl_rss / 1024)) MB resident), skipping it" + fi + bl_over_cap && bl_zram_send 1 "still over the RAM cap, compressing the rest of the session" session + if bl_over_cap; then + _bl_backoff=$((_bl_backoff * 2 > 600 ? 600 : _bl_backoff * 2)) + step "RAM cap: still over after paging out, next try in ${_bl_backoff} s" + else + _bl_backoff=30 + fi + _bl_capped=$SECONDS + fi + else + _bl_backoff=30 + if [ "$_bl_unpack" = 1 ] && [ "$_bl_game" = 0 ]; then + bl_zram_send 0 "back under the RAM cap, reading the client back" client + _bl_unpack=0 + fi + fi + fi if [ "$_bl_game" = 1 ]; then [ "$_bl_since" -ge 0 ] || _bl_since=$SECONDS - if [ $((SECONDS - _bl_since)) -ge 20 ] && [ "${#_bl_pids[@]}" -gt 0 ] && + if [ "${BL_ZRAM_CLIENT:-1}" = 1 ] && [ $((SECONDS - _bl_since)) -ge 20 ] && [ "${#_bl_pids[@]}" -gt 0 ] && { [ "$_bl_packed" -lt 0 ] || [ $((SECONDS - _bl_packed)) -ge 300 ]; }; then - bl_zram_send 1 "compressing the client" + bl_zram_send 1 "compressing the client" client _bl_packed=$SECONDS fi elif [ "$_bl_since" -ge 0 ]; then - [ "$_bl_packed" -lt 0 ] || bl_zram_send 0 "game closed, reading the client back" + if [ "$_bl_packed" -ge 0 ]; then + if bl_over_cap; then _bl_unpack=1; else bl_zram_send 0 "game closed, reading the client back" client; _bl_unpack=0; fi + fi _bl_since=-1 _bl_packed=-1 fi nap 5 done ) & - else - rm -f /tmp/.bl-zram fi if [ "${BL_LAUNCHER_CORES:-1}" != 0 ] && [ -n "${BL_LAUNCH_DIR:-}" ] && [ -z "${BL_DESKTOP:-}" ] \ && [ "${BL_STEAM_UI:-}" != desktop ] && [ -x /usr/local/bin/droiddeck-launcher-cores ]; then @@ -526,6 +623,11 @@ case $mode in fi droiddeck-seed-redists "$steam_root" || true fi + if [ -n "${BL_SHADER_PRECACHE:-}" ]; then + droiddeck-steam-shaders "$BL_SHADER_PRECACHE" "$XDG_DATA_HOME/Steam" \ + "${BL_X64_HOME:-$HOME/.droiddeck-x64}/.local/share/Steam" && + echo "$BL_SHADER_PRECACHE" > "$HOME/.bl-shader-precache-applied" || true + fi if [ "$steam_x64" = 0 ] && [ ! -f "$steam_root/steamapps/appmanifest_4427310.acf" ]; then step "asking the client to fetch the ARM64 Proton" set -- "$@" steam://install/4427310 @@ -746,10 +848,13 @@ BUSCONF fi # The UI bridge launches SteamVR, which needs the guest agent's library environment. - _bl_compat_env=(env -u LD_LIBRARY_PATH -u STEAM_RUNTIME) + _bl_compat_env=(env -u LD_LIBRARY_PATH -u STEAM_RUNTIME -u STEAM_STEAMRT_RECURSION_GUARD) [ "${LD_LIBRARY_PATH+x}" ] && _bl_compat_env+=("LD_LIBRARY_PATH=$LD_LIBRARY_PATH") [ "${STEAM_RUNTIME+x}" ] && _bl_compat_env+=("STEAM_RUNTIME=$STEAM_RUNTIME") + [ "${STEAM_STEAMRT_RECURSION_GUARD+x}" ] && _bl_compat_env+=("STEAM_STEAMRT_RECURSION_GUARD=$STEAM_STEAMRT_RECURSION_GUARD") export STEAM_RUNTIME=1 + # A containerized client re-executes itself through pressure-vessel-wrap (bwrap) unless told it is already inside. + export STEAM_STEAMRT_RECURSION_GUARD=1 export LD_LIBRARY_PATH=$steam_root/steamrtarm64:$steam_root/steamrtarm64/panorama${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH} export SDL_VIDEO_X11_DGAMOUSE=0 decky_loader=$HOME/homebrew/services/PluginLoader @@ -799,7 +904,11 @@ BUSCONF echo "== steam: devtools port $BL_CDP_PORT, guest processes only" fi [ -n "${BL_LAUNCH_DIR:-}" ] && mkdir -p "$BL_LAUNCH_DIR/agent" && echo "$BL_CDP_PORT" > "$BL_LAUNCH_DIR/agent/cdp-port" - [ "$steam_x64" = 1 ] || "${_bl_compat_env[@]}" droiddeck-steam-compat "$steam_root" 2>&1 & + if [ "$steam_x64" = 1 ]; then + { droiddeck-steam-compat --files-only "$steam_root" 2>&1 & } + else + { "${_bl_compat_env[@]}" droiddeck-steam-compat "$steam_root" 2>&1 & } + fi _bl_ui=(-gamepadui) [ "${BL_STEAM_UI:-}" = desktop ] && _bl_ui=() _bl_cef=(-cef-force-gpu -cef-use-gl=angle -cef-use-angle=vulkan) @@ -829,6 +938,9 @@ BUSCONF unset _stale _f fi steam_channel=steamdeck_publicbeta + if [ "$steam_x64" = 0 ]; then + steam_channel=$(droiddeck-steam-install --channel 2>/dev/null || echo steamdeck_publicbeta) + fi _bl_lang=() [ -n "${BL_STEAM_LANGUAGE:-}" ] && _bl_lang=(-language "$BL_STEAM_LANGUAGE") steam_beta=$steam_root/package/beta diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-compat b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-compat index 3972faa9b..ba739235b 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-compat +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-compat @@ -196,6 +196,51 @@ TOUR_JS = r""" return "off"; })() """ +FILE_BINDING = "droiddeckOpenFileDialog" +FILE_JS = r""" +(() => { + const cancelled = () => ({ result: 2, message: "" }); + if (window.__droiddeckFiles) { + window.__droiddeckFiles.cancelAll(); + return "present"; + } + const self = { next: 0, pending: new Map() }; + const open = function (options) { + return new Promise((resolve, reject) => { + if (typeof window.__BINDING__ !== "function") { reject(cancelled()); return; } + const id = ++self.next; + self.pending.set(id, { resolve, reject }); + try { window.__BINDING__(JSON.stringify({ id, options: options || {} })); } + catch (e) { self.pending.delete(id); reject(cancelled()); } + }); + }; + self.done = (id, path) => { + const item = self.pending.get(id); + if (!item) return false; + self.pending.delete(id); + if (typeof path === "string" && path) item.resolve(path); else item.reject(cancelled()); + return true; + }; + self.cancelAll = () => { + const items = Array.from(self.pending.values()); + self.pending.clear(); + for (const item of items) item.reject(cancelled()); + }; + const patch = (w) => { + const system = w?.SteamClient?.System; + if (system && system.OpenFileDialog !== open) system.OpenFileDialog = open; + }; + patch(window); + if (SteamClient.System.OpenFileDialog !== open) throw new Error("Steam OpenFileDialog could not be redirected"); + const popups = window.g_PopupManager; + if (popups) { + for (const popup of popups.GetPopups?.() || []) patch(popup.window); + popups.AddPopupCreatedCallback?.((popup) => patch(popup.window)); + } + window.__droiddeckFiles = self; + return "installed"; +})() +""".replace("__BINDING__", FILE_BINDING) VR_BINDING = "droiddeckLaunchSteamVr" VR_HELPER = "/usr/local/bin/droiddeck-steamvr" VR_JS = r""" @@ -274,6 +319,62 @@ def launch_steamvr(): say("SteamVR library launch failed: %s" % e) +def choose_file(options): + import gi + gi.require_version("Gdk", "3.0") + gi.require_version("Gtk", "3.0") + from gi.repository import Gdk, Gtk + directory = bool(options.get("bChooseDirectory")) + save = bool(options.get("bSaveMode")) and not directory + action = (Gtk.FileChooserAction.SELECT_FOLDER if directory else + Gtk.FileChooserAction.SAVE if save else Gtk.FileChooserAction.OPEN) + dialog = Gtk.FileChooserDialog(title=str(options.get("strTitle") or ""), action=action) + dialog.add_buttons("gtk-cancel", Gtk.ResponseType.CANCEL, + "gtk-save" if save else "gtk-open", Gtk.ResponseType.ACCEPT) + dialog.set_default_response(Gtk.ResponseType.ACCEPT) + dialog.set_local_only(True) + dialog.set_do_overwrite_confirmation(True) + for folder in ("/root/Storage", "/mnt/droiddeck-sd"): + if os.path.isdir(folder): + try: + dialog.add_shortcut_folder(folder) + except Exception: + pass + if not directory: + for entry in options.get("rgFilters") or []: + patterns = [str(p) for p in entry.get("rFilePatterns") or [] if p] + if not patterns: + continue + chosen = Gtk.FileFilter() + chosen.set_name(str(entry.get("strFileTypeName") or ", ".join(patterns))) + for pattern in patterns: + pattern = "*" if pattern == "*.*" else pattern + chosen.add_pattern(pattern) + if pattern.lower() != pattern.upper(): + chosen.add_pattern(pattern.upper()) + dialog.add_filter(chosen) + if entry.get("bUseAsDefault"): + dialog.set_filter(chosen) + initial = str(options.get("strInitialFile") or "").strip('"') + if initial: + if os.path.isdir(initial): + dialog.set_current_folder(initial) + elif save: + if os.path.isdir(os.path.dirname(initial)): + dialog.set_current_folder(os.path.dirname(initial)) + dialog.set_current_name(os.path.basename(initial)) + elif os.path.exists(initial): + dialog.set_filename(initial) + elif os.path.isdir(os.path.dirname(initial)): + dialog.set_current_folder(os.path.dirname(initial)) + screen = Gdk.Screen.get_default() + if screen is not None: + dialog.set_default_size(screen.get_width(), screen.get_height()) + chosen_path = dialog.get_filename() if dialog.run() == Gtk.ResponseType.ACCEPT else None + dialog.destroy() + return chosen_path + + class Closed(Exception): pass @@ -519,10 +620,11 @@ class Watcher: class Helper: - def __init__(self, steam_root, port, steamos=False): + def __init__(self, steam_root, port, steamos=False, files_only=False): self.steam_root = steam_root self.port = port self.steamos = steamos + self.files_only = files_only self.page = None self.known = set() self.installed = None @@ -530,6 +632,70 @@ class Helper: self.tools_asked = {} self.tools_checked = None self.vr_launch = None + self.chooser = None + + def open_chooser(self, payload): + try: + request = json.loads(payload) + ident = int(request["id"]) + options = request.get("options", {}) + if not isinstance(options, dict): + raise ValueError("options are not an object") + except (ValueError, KeyError, TypeError) as e: + say("ignored a file dialog request (%s)" % e) + return + if self.chooser is not None: + self.finish_chooser(ident, None) + return + command = [sys.executable, os.path.realpath(__file__), "--choose-file"] + # Gamescope shows the dialog over the client by the AppId on a reaper above it; the x86-64 client has no + # native reaper, so the arm64 client's is borrowed when it is installed. + data = os.environ.get("XDG_DATA_HOME") or os.path.join(os.path.expanduser("~"), ".local", "share") + for root in (self.steam_root, os.path.join(data, "Steam")): + reaper = os.path.join(root, "steamrtarm64", "reaper") + if os.access(reaper, os.X_OK): + command = [reaper, "SteamLaunch", "AppId=769", "--"] + command + break + try: + proc = subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, start_new_session=True) + proc.stdin.write(json.dumps(options).encode("utf-8")) + proc.stdin.close() + except OSError as e: + say("could not open a file dialog (%s)" % e) + self.finish_chooser(ident, None) + return + say("file dialog open: %s" % (options.get("strTitle") or "untitled")) + self.chooser = (ident, proc) + + def chooser_done(self): + ident, proc = self.chooser + self.chooser = None + out = proc.stdout.read().decode("utf-8", "replace") + proc.stdout.close() + proc.wait() + if proc.returncode == 2: + say("file dialog needs GTK 3 and PyGObject, which come with the desktop") + try: + path = json.loads(out) if proc.returncode == 0 else None + except ValueError: + path = None + self.finish_chooser(ident, path if isinstance(path, str) and path else None) + + def finish_chooser(self, ident, path): + say("file dialog %s" % ("chose " + path if path else "cancelled")) + self.page.evaluate("window.__droiddeckFiles?.done(%d, %s)" % (ident, json.dumps(path))) + + def close_chooser(self): + if self.chooser is not None: + _, proc = self.chooser + self.chooser = None + try: + os.killpg(proc.pid, signal.SIGTERM) + except OSError: + pass + proc.stdout.close() + proc.wait() def attach(self): path = shared_context(self.port) @@ -539,9 +705,16 @@ class Helper: try: if page.evaluate("typeof SteamClient === 'object' && typeof SteamClient.WebUITransport === 'object'") is not True: raise LookupError("SteamClient is not ready") + if self.files_only: + page.call("Runtime.addBinding", {"name": FILE_BINDING}) + page.evaluate(FILE_JS) + self.page = page + return True page.call("Runtime.addBinding", {"name": BINDING}) page.call("Runtime.addBinding", {"name": VR_BINDING}) page.evaluate(VR_JS) + page.call("Runtime.addBinding", {"name": FILE_BINDING}) + page.evaluate(FILE_JS) try: page.evaluate(TOUR_JS) except RuntimeError: @@ -583,9 +756,8 @@ class Helper: picked = COMPAT["picked_apps"](state) runnable = {t["name"].lower() for t in live} apps = sorted(set(installed) | set(picks) | set(prior_auto) | native_choices, key=COMPAT["app_order"]) - overrides = COMPAT["requested_games"]( - COMPAT["game_requests"](), catalog, [t["name"] for t in live]) - apps = sorted(set(apps) | set(overrides), key=COMPAT["app_order"]) + asked = [app for app, request in COMPAT["game_requests"]().items() if COMPAT["valid_game_request"](app, request)] + apps = sorted(set(apps) | set(asked), key=COMPAT["app_order"]) current = dict(page.compat("many(%s)" % json.dumps(apps)) or {}) refused = set(apps) - set(current) refused_native = native_choices & refused @@ -597,7 +769,6 @@ class Helper: if isinstance(state.get("auto"), dict): state["auto"] = prior_auto native_choices -= refused - overrides = {app: tool for app, tool in overrides.items() if app not in refused} current["0"] = top["selected"] native = COMPAT["native_targets"](self.steam_root, apps) raw_mapping = COMPAT["read_config_mapping"]( @@ -619,6 +790,9 @@ class Helper: current[app] = "" elif not current.get(app): current.pop(app, None) + requests = COMPAT["settled_game_requests"](state, current, catalog) + overrides = COMPAT["requested_games"](requests or {}, catalog, [t["name"] for t in live]) + overrides = {app: tool for app, tool in overrides.items() if app not in refused} changes, auto = COMPAT["plan_mapping"]( current, installed, [t["name"] for t in live], default, COMPAT["followers"](state, current), native, picks, overrides, @@ -630,7 +804,8 @@ class Helper: state.update( auto={app: tool for app, tool in auto.items() if tool is not COMPAT["NATIVE_CHOICE"]}, native_choices=sorted(refused_native | {app for app, tool in auto.items() if tool is COMPAT["NATIVE_CHOICE"]}), - picked=COMPAT["picked_after"](current, changes), tools=catalog, live=True) + picked=COMPAT["picked_after"](current, changes), tools=catalog, live=True, + games_applied=COMPAT["applied_game_requests"](requests, overrides, state)) state.pop("native", None) if waiting: state["pending"] = wanted @@ -678,9 +853,27 @@ class Helper: if vr_games and (self.vr_launch is None or self.vr_launch.poll() is not None): say("preparing SteamVR for library app(s): " + ", ".join(sorted(vr_games))) self.vr_launch = launch_steamvr() + for e in events: + if called(e, FILE_BINDING) and isinstance(e.get("params", {}).get("payload"), str): + self.open_chooser(e["params"]["payload"]) return events + def serve_files(self): + say("attached to the client on port %d for its file dialogs" % self.port) + while True: + sources = [self.page.ws] + ([self.chooser[1].stdout] if self.chooser is not None else []) + queued = self.page.ws.pending() or bool(self.page.events) + ready = [self.page.ws] if queued else select.select(sources, [], [], 30)[0] + if self.chooser is not None and self.chooser[1].stdout in ready: + self.chooser_done() + if self.page.ws in ready or self.page.events: + for e in self.page.drain(): + if called(e, FILE_BINDING) and isinstance(e.get("params", {}).get("payload"), str): + self.open_chooser(e["params"]["payload"]) + def serve(self, watcher): + if self.files_only: + return self.serve_files() with open(COMPAT["state_path"]("live.pid"), "w") as f: f.write("%d\n" % os.getpid()) say("attached to the client on port %d" % self.port) @@ -688,7 +881,11 @@ class Helper: self.ensure_tools() while True: sources = [self.page.ws] + ([watcher] if watcher.fileno() >= 0 else []) + if self.chooser is not None: + sources.append(self.chooser[1].stdout) ready = [self.page.ws] if self.page.ws.pending() else select.select(sources, [], [], 30 if watcher.fileno() >= 0 else 2)[0] + if self.chooser is not None and self.chooser[1].stdout in ready: + self.chooser_done() due = False if self.page.ws in ready: due = any(called(e, BINDING) for e in self.drain()) @@ -716,13 +913,45 @@ def detach(): COMPAT["write_json"](state_file, state) +def chooser_main(): + try: + options = json.load(sys.stdin) + except ValueError: + return 1 + try: + path = choose_file(options if isinstance(options, dict) else {}) + except (ImportError, ValueError): + return 2 + if not path: + return 1 + print(json.dumps(path)) + return 0 + + def main(): - steam_root = sys.argv[1] + files_only = sys.argv[1:2] == ["--files-only"] + steam_root = sys.argv[2 if files_only else 1] try: port = int(os.environ.get("BL_CDP_PORT", "")) except ValueError: say("no devtools port for this session; the client's mapping is kept through config.vdf") return 0 + if files_only: + helper = Helper(steam_root, port, files_only=True) + failures = 0 + while True: + try: + helper.attach() + failures = 0 + helper.serve(None) + except Exception as e: + helper.close_chooser() + if helper.page is not None: + say("lost the client (%s); reattaching" % e) + helper.page.ws.close() + helper.page = None + failures += 1 + time.sleep(min(2 * max(failures, 1), 10)) os.makedirs(COMPAT["COMPAT_DIR"], exist_ok=True) watcher = Watcher([COMPAT["state_path"]("request.json"), COMPAT["state_path"]("games.json")] + [os.path.join(lib, "steamapps", "libraryfolders.vdf") for lib in (steam_root, COMPAT["LIBRARY"] + "-sd")]) @@ -737,6 +966,7 @@ def main(): last = None helper.serve(watcher) except Exception as e: + helper.close_chooser() if helper.page is not None: say("lost the client (%s); reattaching" % e) helper.page.ws.close() @@ -750,8 +980,11 @@ def main(): if __name__ == "__main__": + if sys.argv[1:2] == ["--choose-file"]: + sys.exit(chooser_main()) signal.signal(signal.SIGTERM, lambda *_: sys.exit(0)) try: sys.exit(main()) finally: - detach() + if sys.argv[1:2] != ["--files-only"]: + detach() diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-install b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-install index 3ce263c0d..45bd96c25 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-install +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-install @@ -1,25 +1,57 @@ #!/bin/bash set -eu cdn_list="https://client-update.steamstatic.com https://client-update.akamai.steamstatic.com https://client-update.fastly.steamstatic.com" -steam_channel=steamdeck_publicbeta -manifest=steam_client_${steam_channel}_linuxarm64 +beta_channel=steamdeck_publicbeta +stable_channel=steamdeck_stable steam_root=${XDG_DATA_HOME:-$HOME/.local/share}/Steam +channel_state=$steam_root/package/droiddeck-channel +pinned=1 +wanted=${BL_STEAM_CHANNEL:-$(cat "$HOME/.config/droiddeck/steam-channel" 2>/dev/null || true)} +case $wanted in + stable|"$stable_channel") steam_channel=$stable_channel ;; + beta|"$beta_channel") steam_channel=$beta_channel ;; + *) + pinned= + steam_channel=$beta_channel + [ "$(cat "$channel_state" 2>/dev/null)" = "$stable_channel" ] && steam_channel=$stable_channel + ;; +esac +if [ "${1:-}" = --channel ]; then + echo "$steam_channel" + exit 0 +fi +manifest=steam_client_${steam_channel}_linuxarm64 stamp=$steam_root/package/droiddeck-installed legacy_stamp=$steam_root/package/bannerlator-installed unconfirmed=$steam_root/package/droiddeck-unconfirmed repair_request=$HOME/.bl-steam-repair +parts=$steam_root/package/droiddeck-download if [ -f "$legacy_stamp" ] && [ ! -f "$stamp" ]; then mv -f "$legacy_stamp" "$stamp" || true fi repair="" +fallback="" +starts=0 +[ -f "$unconfirmed" ] && starts=$(grep -cx start "$unconfirmed" || true) if [ -e "$repair_request" ]; then repair="requested" -elif [ -f "$unconfirmed" ] && ! grep -qx repaired "$unconfirmed" \ - && [ "$(grep -cx start "$unconfirmed")" -ge 2 ]; then - repair="the client was started $(grep -cx start "$unconfirmed") times without showing anything" +elif [ "$starts" -ge 2 ] && ! grep -qx repaired "$unconfirmed"; then + repair="the client was started $starts times without showing anything" +elif [ "$starts" -ge 2 ] && [ -z "$pinned" ] && [ "$steam_channel" = "$beta_channel" ] && [ "${1:-}" != --sync ]; then + repair="the repaired $beta_channel client was started $starts more times without showing anything" + fallback=1 +fi +if [ -n "$fallback" ]; then + echo "== STEP $(date -u +%H:%M:%S) falling back to Valve's $stable_channel Steam client: $repair" + steam_channel=$stable_channel + manifest=steam_client_${steam_channel}_linuxarm64 fi if [ -n "$repair" ]; then echo "== STEP $(date -u +%H:%M:%S) repairing the Steam client ($repair)" + if [ ! -f "$parts/.repair" ]; then + rm -rf "$parts" + mkdir -p "$parts" && : > "$parts/.repair" + fi rm -f "$stamp" "$legacy_stamp" "$steam_root/.crash" "$steam_root/steam.pid" \ "$steam_root"/package/steam_client_*.manifest "$steam_root"/package/steam_client_*.installed for part in "$steam_root"/package/*.zip.*; do @@ -42,20 +74,99 @@ fi work=$(mktemp -d) trap 'rm -rf "$work"' EXIT -cdn="" -for candidate in $cdn_list; do - if curl -fsSL --connect-timeout 10 --max-time 30 --retry 2 --retry-connrefused --retry-delay 3 \ - "$candidate/$manifest" -o "$work/manifest"; then - cdn=$candidate - break + +nap() { + [ -n "${nap_fd:-}" ] || exec {nap_fd}<> <(:) + read -r -t "$1" -u "$nap_fd" _ 2>/dev/null || : +} +size_of() { + local n=0 + [ -f "$1" ] && n=$(wc -c < "$1") + echo $((n)) +} +limits=(--connect-timeout 30 --speed-limit 4096 --speed-time 60) +read -r -a delays <<< "${BL_STEAM_RETRY_DELAYS:-5 10 20 30 30}" + +step() { echo "== STEP $(date -u +%H:%M:%S) $*"; } +progress() { + local mb=$(( (done_bytes + $(size_of "$1")) / 1000000 )) + [ -z "${3:-}" ] && [ "$mb" = "${shown_mb:-}" ] && [ "$2" = "${shown_part:-}" ] && return 0 + shown_mb=$mb shown_part=$2 + step "downloading Steam: $2 ($i/$total), $mb of $((total_bytes / 1000000)) MB${3:+ - $3}" +} + +fetch() { + local url=$1 dest=$2 size=$3 sum=$4 part=$5 failures=0 bad=0 finished=0 before rc fd wait + while :; do + if [ -f "$dest" ] && { [ "$finished" = 1 ] || { [ "$size" -gt 0 ] && [ "$(size_of "$dest")" -ge "$size" ]; }; }; then + echo "$sum $dest" | sha256sum -c --quiet 2>/dev/null && return 0 + rm -f "$dest" + finished=0 bad=$((bad + 1)) + [ "$bad" -lt 3 ] || { echo "droiddeck-steam-install: $part does not match its checksum" >&2; return 1; } + fi + before=$(size_of "$dest") + exec {fd}< <(code=$(curl -fsSL "${limits[@]}" -w '%{http_code}' -C - -o "$dest" "$url") && echo "0 $code" || echo "$? $code") + until read -r -t 5 -u "$fd" rc http; do + if [ $? -le 128 ]; then rc=1; break; fi + progress "$dest" "$part" + done + exec {fd}<&- + if [ "$rc" = 0 ]; then finished=1; continue; fi + case "$rc:${http:-}" in + 22:416 | 33:*) rm -f "$dest" ;; + 22:4??) + echo "droiddeck-steam-install: $part: HTTP $http from ${url%/*}" >&2 + return 1 + ;; + esac + if [ "$(size_of "$dest")" -gt "$before" ]; then failures=0; else failures=$((failures + 1)); fi + if [ "$failures" -gt "${#delays[@]}" ]; then + echo "droiddeck-steam-install: could not download $part (curl exit $rc); what arrived is kept for the next try" >&2 + return 1 + fi + wait=${delays[$((failures > 0 ? failures - 1 : 0))]} + progress "$dest" "$part" "interrupted (curl exit $rc), trying again in ${wait} s" + nap "$wait" + done +} + +fetch_manifest() { + cdn="" + for candidate in $cdn_list; do + if curl -fsSL --connect-timeout 10 --max-time 30 --retry 2 --retry-connrefused --retry-delay 3 \ + "$candidate/$manifest" -o "$work/manifest"; then + cdn=$candidate + break + fi + done + [ -n "$cdn" ] || { echo "droiddeck-steam-install: no Steam client CDN answered for $manifest" >&2; exit 1; } + cdn_order=("$cdn") + for candidate in $cdn_list; do + [ "$candidate" = "$cdn" ] || cdn_order+=("$candidate") + done + version=$(awk -F'"' '$2 == "version" { print $4; exit }' "$work/manifest") + awk -F'"' ' + /^\t"/ && NF >= 3 && $4 == "" { block = $2 } + $2 == "file" { file[block] = $4 } + $2 == "sha2" { sha[block] = $4 } + $2 == "size" { size[block] = $4 } + END { for (b in file) if (b ~ /_all$/ || b ~ /_linuxarm64_linuxarm64$/) print b, file[b], sha[b], size[b] + 0 } + ' "$work/manifest" | sort > "$work/parts" + [ -s "$work/parts" ] && return 0 + echo "droiddeck-steam-install: no client components in $manifest; its blocks are:" \ + "$(awk -F'"' '/^\t"/ && NF >= 3 && $4 == "" { printf "%s ", $2 }' "$work/manifest")" >&2 + return 1 +} +if ! fetch_manifest; then + if [ -n "$sync" ] || [ -n "$pinned" ] || [ "$steam_channel" = "$stable_channel" ]; then + exit 1 fi -done -[ -n "$cdn" ] || { echo "droiddeck-steam-install: no Steam client CDN answered for $manifest" >&2; exit 1; } -cdn_order=("$cdn") -for candidate in $cdn_list; do - [ "$candidate" = "$cdn" ] || cdn_order+=("$candidate") -done -version=$(awk -F'"' '$2 == "version" { print $4; exit }' "$work/manifest") + echo "== STEP $(date -u +%H:%M:%S) falling back to Valve's $stable_channel Steam client: $manifest has no arm64 client" + steam_channel=$stable_channel + manifest=steam_client_${steam_channel}_linuxarm64 + fallback=1 + fetch_manifest || exit 1 +fi if [ -n "$sync" ]; then installed=$(awk -F'"' '$2 == "version" { print $4; exit }' "$steam_root/package/$manifest.manifest" 2>/dev/null || true) if [ -x "$steam_root/steamrtarm64/steam" ] && [ "$installed" = "$version" ]; then @@ -64,31 +175,36 @@ if [ -n "$sync" ]; then fi echo "== STEP $(date -u +%H:%M:%S) updating the arm64 Steam client to $version (was ${installed:-none}) for the x86-64 client" fi -awk -F'"' ' - /^\t"/ && NF >= 3 && $4 == "" { block = $2 } - $2 == "file" { file[block] = $4 } - $2 == "sha2" { sha[block] = $4 } - END { for (b in file) if (b ~ /_all$/ || b ~ /_linuxarm64_linuxarm64$/) print b, file[b], sha[b] } -' "$work/manifest" | sort > "$work/parts" -[ -s "$work/parts" ] || { echo "droiddeck-steam-install: no client components in $manifest" >&2; exit 1; } total=$(grep -c . "$work/parts" 2>/dev/null || echo 0) +total_bytes=$(awk '{ n += $4 } END { print n + 0 }' "$work/parts") -mkdir -p "$steam_root/package" -while read -r block file sum; do +mkdir -p "$parts" +for left in "$parts"/*; do + [ -e "$left" ] || continue + name=${left##*/} + name=${name%.unpacked} + grep -qF " $name " "$work/parts" || rm -rf "$left" +done +done_bytes=0 +while read -r block file sum size; do i=$((${i:-0} + 1)) - echo "== STEP $(date -u +%H:%M:%S) downloading Steam: $block ($i/$total)" + if [ -f "$parts/$file.unpacked" ]; then + done_bytes=$((done_bytes + size)) + continue + fi + progress "$parts/$file" "$block" fetched="" for host in "${cdn_order[@]}"; do - if curl -fsSL --connect-timeout 10 --speed-limit 1024 --speed-time 60 --max-time 1800 \ - "$host/$file" -o "$work/$file" && echo "$sum $work/$file" | sha256sum -c --quiet; then + if fetch "$host/$file" "$parts/$file" "$size" "$sum" "$block"; then fetched=$host break fi - rm -f "$work/$file" done [ -n "$fetched" ] || { echo "droiddeck-steam-install: $file failed on every CDN" >&2; exit 1; } - unzip -qo "$work/$file" -d "$steam_root" - rm -f "$work/$file" + unzip -qo "$parts/$file" -d "$steam_root" + : > "$parts/$file.unpacked" + rm -f "$parts/$file" + done_bytes=$((done_bytes + size)) done < "$work/parts" find "$steam_root" -depth -name '*\\*' | while read -r odd; do fixed=${odd//\\//} @@ -117,6 +233,11 @@ ln -sfn "$steam_root/linuxarm64" "$HOME/.steam/sdkarm64" ln -sfn "$steam_root/steamrtarm64" "$HOME/.steam/binarm64" ln -sfn "$steam_root/linux32" "$HOME/.steam/sdk32" echo "$version" > "$stamp" +rm -rf "$parts" +if [ -n "$fallback" ]; then + echo "$stable_channel" > "$channel_state" + echo "== STEP $(date -u +%H:%M:%S) the Steam client now follows $stable_channel (beta in ~/.config/droiddeck/steam-channel returns to $beta_channel)" +fi if [ -n "$repair" ]; then echo repaired > "$unconfirmed" rm -f "$repair_request" diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-launch b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-launch index ec5453f5b..731f64dc2 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-launch +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-launch @@ -12,6 +12,10 @@ case $target in esac if [ "$ui" = desktop ] && [ "${BL_DESKTOP:-}" = 1 ]; then + case ",${MESA_VK_WSI_DEBUG:-}," in + *,sw,*) ;; + *) export MESA_VK_WSI_DEBUG=${MESA_VK_WSI_DEBUG:+$MESA_VK_WSI_DEBUG,}sw ;; + esac if [ "${BL_STEAM_CLIENT_ARCH:-}" = x86_64 ]; then client_home=${BL_X64_HOME:-${HOME:-/root}/.droiddeck-x64} client=(/usr/local/bin/droiddeck-steam-x64 --exec) diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-shaders b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-shaders new file mode 100755 index 000000000..da93ebec4 --- /dev/null +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-shaders @@ -0,0 +1,61 @@ +#!/usr/bin/python3 +"""Sets the Steam client's shader pre-caching (Settings > Downloads), before the client starts. + + droiddeck-steam-shaders <0|1> <steam root>... + +The client stores the toggle as InstallConfigStore/Software/Valve/Steam/ShaderCacheManager/ +DisableShaderCache in config/config.vdf: "1" when off, "0" when on. It keeps the setting in memory +and writes config.vdf back on exit, so this runs only while the client is stopped. The launcher +passes a value only after its own toggle changes; otherwise the client's toggle stays the user's.""" +from pathlib import Path +import runpy +import sys + + +VDF = runpy.run_path(str(Path(__file__).with_name("droiddeck-steam-ui-scale"))) +PATH = ("Software", "Valve", "Steam", "ShaderCacheManager", "DisableShaderCache") + + +def set_text(text, enabled): + value = "0" if enabled else "1" + updated, changed = VDF["seed_value"](text, PATH, value) + if changed: + return updated, True + block = VDF["parse"](text).get("InstallConfigStore") + for section in PATH[:-1]: + block = block.get(section) + current = block.get(PATH[-1]) + if isinstance(current, VDF["Block"]): + raise ValueError("invalid DisableShaderCache setting") + if current == value: + return text, False + at = block.offset(PATH[-1]) + end = VDF["TOKEN"].match(text, at).end() + return text[:at] + f'"{value}"' + text[end:], True + + +def apply(steam_root, enabled): + config = Path(steam_root) / "config/config.vdf" + text = config.read_text() if config.exists() else '"InstallConfigStore"\n{\n}\n' + updated, changed = set_text(text, enabled) + if changed: + VDF["atomic_write"](config, updated) + return changed + + +if __name__ == "__main__": + if len(sys.argv) < 3 or sys.argv[1] not in ("0", "1"): + print("usage: droiddeck-steam-shaders <0|1> <steam root>...", file=sys.stderr) + sys.exit(2) + enabled = sys.argv[1] == "1" + status = 0 + for root in sys.argv[2:]: + if not Path(root).is_dir(): + continue + try: + if apply(root, enabled): + print(f"== steam: shader pre-caching {'on' if enabled else 'off'} in {root}") + except (OSError, ValueError, RecursionError) as error: + print(f"== steam: left shader pre-caching unchanged in {root}: {error}", file=sys.stderr) + status = 1 + sys.exit(status) diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-share b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-share new file mode 100755 index 000000000..9ab21e781 --- /dev/null +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-share @@ -0,0 +1,273 @@ +#!/usr/bin/python3 +import json +import os +import re +import runpy +import sys + +NOT_GAMES = frozenset(( + "228980", "1070560", "1391110", "1628350", "1493710", "3127680", + "4183110", "4185400", "4427310", "4628740", +)) +TOOL_DIR = re.compile(r"(steam ?linux ?runtime.*|proton.*|steamworks shared|fex|steamvr)", re.I) +AUTOMATIC = ("", "droiddeck-direct", "droiddeck-fex", "droiddeck-proton-arm64", "droiddeck-proton-11-arm64") +LINUX_TOOLS = ("droiddeck-direct", "droiddeck-fex") +STATE = ".droiddeck-shared.json" +LABELS = {"arm64": "ARM64 client games", "x86-64": "x86-64 client games"} + + +def say(message): + print("droiddeck-steam-share: " + message) + + +def read(path): + try: + with open(path, encoding="utf-8", errors="surrogateescape") as f: + return f.read() + except OSError: + return None + + +def write(path, text): + staged = path + ".droiddeck-new" + with open(staged, "w", encoding="utf-8", errors="surrogateescape") as f: + f.write(text) + os.replace(staged, path) + + +def field(text, key): + m = re.search(r'"%s"\s+"([^"]*)"' % re.escape(key), text or "", re.I) + return m.group(1) if m else None + + +def number(text, key): + value = field(text, key) + return int(value) if value and value.isdigit() else 0 + + +def same(a, b): + return os.path.realpath(a) == os.path.realpath(b) + + +def libraries(root, skip): + found = [root] + [p.replace("\\\\", "\\") for p in + re.findall(r'"path"\s+"((?:[^"\\]|\\.)*)"', read(os.path.join(root, "steamapps", "libraryfolders.vdf")) or "")] + return [p for p in dict.fromkeys(found) if not any(same(p, s) for s in skip)] + + +def installs(libs): + games = {} + for library in libs: + steamapps = os.path.join(library, "steamapps") + try: + names = sorted(os.listdir(steamapps)) + except OSError: + continue + for name in names: + m = re.fullmatch(r"appmanifest_(\d+)\.acf", name) + if not m or m.group(1) in NOT_GAMES or m.group(1) in games: + continue + text = read(os.path.join(steamapps, name)) + folder = field(text, "installdir") + if (not folder or folder in (".", "..") or "/" in folder or TOOL_DIR.fullmatch(folder) + or not number(text, "StateFlags") & 4 + or not os.path.isdir(os.path.join(steamapps, "common", folder))): + continue + games[m.group(1)] = (steamapps, text, folder) + return games + + +def link(path, target): + if os.path.islink(path): + if os.readlink(path) == target: + return True + os.unlink(path) + elif os.path.lexists(path): + return False + os.symlink(target, path) + return True + + +def unlink(path): + if os.path.islink(path): + os.unlink(path) + + +def emptied(src, folder): + path = os.path.join(src, "common", folder) + return not os.path.isdir(path) or not os.listdir(path) + + +def sync(view, source, own): + steamapps = os.path.join(view, "steamapps") + for sub in ("common", "compatdata"): + os.makedirs(os.path.join(steamapps, sub), exist_ok=True) + state = {} + try: + state = json.loads(read(os.path.join(view, STATE)) or "{}") + except ValueError: + pass + managed = {app: tuple(v) for app, v in state.get("shared", {}).items() if isinstance(v, list) and len(v) == 2} + hidden = state.get("hidden", {}) + shared, back = {}, [] + for app, (src, folder) in managed.items(): + if app in source and app not in own: + continue + manifest = os.path.join(steamapps, "appmanifest_%s.acf" % app) + if app not in own and not os.path.isfile(manifest) and emptied(src, folder) and os.path.isfile(os.path.join(src, "appmanifest_%s.acf" % app)): + os.unlink(os.path.join(src, "appmanifest_%s.acf" % app)) + unlink(os.path.join(steamapps, "common", folder)) + unlink(os.path.join(steamapps, "compatdata", app)) + if os.path.isfile(manifest): + os.unlink(manifest) + for app, (src, text, folder) in sorted(source.items(), key=lambda item: int(item[0])): + if app in own: + continue + stamp = number(text, "LastUpdated") + if hidden.get(app) == stamp: + continue + hidden.pop(app, None) + manifest = os.path.join(steamapps, "appmanifest_%s.acf" % app) + mine = read(manifest) + if app in managed and mine is None: + unlink(os.path.join(steamapps, "common", folder)) + unlink(os.path.join(steamapps, "compatdata", app)) + if emptied(src, folder): + os.unlink(os.path.join(src, "appmanifest_%s.acf" % app)) + else: + hidden[app] = stamp + continue + if not link(os.path.join(steamapps, "common", folder), os.path.join(src, "common", folder)): + continue + if os.path.isdir(os.path.join(src, "compatdata", app)): + link(os.path.join(steamapps, "compatdata", app), os.path.join(src, "compatdata", app)) + if mine is not None and number(mine, "LastUpdated") > stamp: + write(os.path.join(src, "appmanifest_%s.acf" % app), mine) + back.append(app) + elif mine != text: + write(manifest, text) + shared[app] = [src, folder] + state = {"shared": shared, "hidden": hidden} + write(os.path.join(view, STATE), json.dumps(state, indent=1, sort_keys=True) + "\n") + return shared, back + + +def tools(root): + names = set() + folder = os.path.join(root, "compatibilitytools.d") + try: + entries = os.listdir(folder) + except OSError: + return names + for entry in entries: + text = read(os.path.join(folder, entry, "compatibilitytool.vdf")) or "" + m = re.search(r'"compat_tools"\s*\{\s*"([^"]+)"', text) + if m: + names.add(m.group(1)) + return names + + +def mapping_block(text): + m = re.search(r'"CompatToolMapping"\s*\{', text) + if not m: + return None + depth, k = 1, m.end() + while depth and k < len(text): + depth += {"{": 1, "}": -1}.get(text[k], 0) + k += 1 + return m.end(), k - 1 + + +def entries(text): + span = mapping_block(text) + found = {} + if not span: + return found + inner = text[span[0]:span[1]] + for m in re.finditer(r'"(\d+)"\s*\{([^{}]*)\}', inner): + name = re.search(r'"name"\s*"([^"]*)"', m.group(2)) + found.setdefault(m.group(1), name.group(1) if name else "") + return found + + +def set_tools(config, wanted): + text = read(config) + if text is None or not wanted: + return [] + span = mapping_block(text) + if not span: + return [] + inner = text[span[0]:span[1]] + changed = [] + for app, tool in sorted(wanted.items(), key=lambda item: int(item[0])): + m = re.search(r'"%s"\s*\{([^{}]*)\}' % app, inner) + if m: + name = re.search(r'"name"\s*"([^"]*)"', m.group(1)) + name = name.group(1) if name else "" + if name == tool or not (canonical(name) in map(canonical, AUTOMATIC) or name.startswith("proton_")): + continue + if name and (name in LINUX_TOOLS) == (tool in LINUX_TOOLS): + continue + inner = inner[:m.start()] + inner[m.end():] + inner = ('\n\t\t\t\t\t"%s"\n\t\t\t\t\t{\n\t\t\t\t\t\t"name"\t\t"%s"\n\t\t\t\t\t\t"config"\t\t""\n' + '\t\t\t\t\t\t"priority"\t\t"250"\n\t\t\t\t\t}' % (app, tool)) + inner + changed.append(app) + if changed: + write(config, text[:span[0]] + inner + text[span[1]:]) + return changed + + +def canonical(name): + return re.sub(r"arm64|aarch64", "a64", name) + + +def translate(name, here, available): + if name == "droiddeck-fex" and here == "x86-64": + return "droiddeck-direct" + if name == "droiddeck-direct" and here == "arm64": + return "droiddeck-fex" + return {canonical(a): a for a in available}.get(canonical(name)) + + +def library_helper(): + sibling = os.path.join(os.path.dirname(os.path.realpath(__file__)), "droiddeck-steam-library") + return runpy.run_path(sibling if os.path.isfile(sibling) else "/usr/local/bin/droiddeck-steam-library") + + +def main(argv): + if len(argv) != 5 or argv[0] not in LABELS: + say("usage: droiddeck-steam-share arm64|x86-64 ARM_STEAM X64_STEAM X64_CARD SHARED_DIR") + return 2 + here, arm, x64, x64_card, shared_dir = argv + views = {name: os.path.join(shared_dir, name) for name in LABELS} + roots = {"arm64": arm, "x86-64": x64} + other = "x86-64" if here == "arm64" else "arm64" + skip = list(views.values()) + [x64, x64_card] + arm_libs = libraries(arm, skip) + x64_libs = [x64] + [p for p in libraries(x64, list(views.values()) + [arm]) if not same(p, x64)] + games = {"arm64": installs(arm_libs), "x86-64": installs(x64_libs)} + view = views[other] + shared, back = sync(view, games[other], games[here]) + helper = library_helper() + if not any(re.fullmatch(r"appmanifest_\d+\.acf", n) for n in os.listdir(os.path.join(view, "steamapps"))): + helper["forget"](roots[here], view) + return 0 + helper["register"](roots[here], view, LABELS[other]) + helper["relabel"](roots[here], view, LABELS[other]) + picks = entries(read(os.path.join(roots[other], "config", "config.vdf")) or "") + available = tools(roots[here]) | {"droiddeck-direct" if here == "x86-64" else "droiddeck-fex"} + wanted = {} + for app in shared: + tool = translate(picks.get(app) or picks.get("0", ""), here, available) + if tool: + wanted[app] = tool + changed = set_tools(os.path.join(roots[here], "config", "config.vdf"), wanted) + say("%d game(s) from the %s client%s%s" % ( + len(shared), other, + "; tools set for %s" % " ".join(changed) if changed else "", + "; manifests sent back for %s" % " ".join(back) if back else "")) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-shortcuts b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-shortcuts index 3764e4a92..0910cf6fa 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-shortcuts +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-shortcuts @@ -18,13 +18,17 @@ TAG = "droiddeck-app" def account_dir(steam_root): """The most recent account's userdata folder, else the only one that is not 0.""" users = os.path.join(steam_root, "config", "loginusers.vdf") - recent = None + recent, best = None, None try: with open(users, encoding="utf-8", errors="replace") as f: text = f.read() for block in re.finditer(r'"(\d{5,})"\s*\{([^}]*)\}', text): - if re.search(r'"MostRecent"\s*"1"', block.group(2)): - recent = int(block.group(1)) + stamp = re.search(r'"timestamp"\s*"(\d+)"', block.group(2)) + rank = (bool(re.search(r'"MostRecent"\s*"1"', block.group(2))), + bool(re.search(r'"AutoLogin"\s*"1"', block.group(2))), + int(stamp.group(1)) if stamp else 0) + if any(rank) and (best is None or rank >= best): + recent, best = int(block.group(1)), rank except OSError: pass userdata = os.path.join(steam_root, "userdata") diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-ui-scale b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-ui-scale index 4a5997684..af809d124 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-ui-scale +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-ui-scale @@ -26,9 +26,10 @@ TOKEN = re.compile(r'\s+|//[^\n]*|"(?:\\.|[^"\\])*"|[{}]') class Block: - def __init__(self, pairs, end): + def __init__(self, pairs, end, offsets=()): self.pairs = pairs self.end = end + self.offsets = offsets def get(self, name): matches = [value for key, value in self.pairs if key.lower() == name.lower()] @@ -36,6 +37,9 @@ class Block: raise ValueError("duplicate UI setting") return matches[0] if matches else None + def offset(self, name): + return next(at for (key, _), at in zip(self.pairs, self.offsets) if key.lower() == name.lower()) + def parse(text): tokens = [] @@ -51,17 +55,17 @@ def parse(text): raise ValueError("invalid config.vdf") def block(index, nested=False): - pairs = [] + pairs, offsets = [], [] while index < len(tokens): token, offset = tokens[index] if token == '}': if not nested: raise ValueError("unexpected closing brace") - return Block(pairs, offset), index + 1 + return Block(pairs, offset, offsets), index + 1 if not token.startswith('"') or index + 1 == len(tokens): raise ValueError("missing config value") key = token[1:-1] - value = tokens[index + 1][0] + value, at = tokens[index + 1] index += 2 if value == '{': value, index = block(index, True) @@ -70,9 +74,10 @@ def parse(text): else: raise ValueError("invalid config value") pairs.append((key, value)) + offsets.append(at) if nested: raise ValueError("unclosed config block") - return Block(pairs, len(text)), index + return Block(pairs, len(text), offsets), index return block(0)[0] diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-x64 b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-x64 index e52a4e985..8461fdf8a 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-x64 +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-x64 @@ -87,7 +87,7 @@ x64_map_direct() { [ -f "$STEAM_X64/config/config.vdf" ] || return 0 if [ "$mode" = write ] && pgrep -f 'steamrt64/steam( |$)' >/dev/null 2>&1; then return 0; fi python3 - "$mode" "$STEAM_X64/config/config.vdf" "$STEAM_X64/appcache/appinfo.vdf" "$X64_HOME/direct-auto.json" \ - "$X64_HOME/direct-sniper" "$STEAM_X64/steamapps:/mnt/droiddeck-sd/x86-64/steamapps" "$(x64_default_tool)" \ + "$X64_HOME/direct-sniper" "$STEAM_X64/steamapps:/mnt/droiddeck-sd/x86-64/steamapps:$X64_HOME/shared/arm64/steamapps" "$(x64_default_tool)" \ "$REAL_HOME/.bl-compat-pending" ${BL_X64_DIRECT_APPS:-550} \ <<'PY' || { [ "$mode" = live ] || step "could not map titles to DroidDeck direct"; } # Steam does not swap an installed build when the tool changes: a title installed for Windows stays on Proton. @@ -520,39 +520,8 @@ ln -sfn "$STEAM_X64/ubuntu12_32" "$X64_HOME/.steam/bin32" ln -sfn "$STEAM_X64/ubuntu12_64" "$X64_HOME/.steam/bin64" ln -sfn "$STEAM_X64/linux32" "$X64_HOME/.steam/sdk32" ln -sfn "$STEAM_X64/linux64" "$X64_HOME/.steam/sdk64" -# The client's bwrap probe (steam-runtime-check-requirements) needs user namespaces, which no Android app has. -srt_real=$STEAM_X64/steamrt64/pv-runtime/steam-runtime-steamrt -srt_shim=$X64_HOME/steamrt-shim -if [ -d "$srt_real" ]; then - rm -rf "$srt_shim" - mkdir -p "$srt_shim/bin" - for e in "$srt_real"/* "$srt_real"/.[!.]*; do - [ -e "$e" ] || continue - [ "$(basename "$e")" = bin ] || ln -s "$e" "$srt_shim/$(basename "$e")" - done - for e in "$srt_real"/bin/*; do ln -s "$e" "$srt_shim/bin/$(basename "$e")"; done - rm -f "$srt_shim/bin/steam-runtime-check-requirements" - printf '#!/bin/sh -exit 0 -' > "$srt_shim/bin/steam-runtime-check-requirements" - chmod 755 "$srt_shim/bin/steam-runtime-check-requirements" - rm -f "$srt_shim/bin/steam-runtime-launch-client" - cat > "$srt_shim/bin/steam-runtime-launch-client" <<'LAUNCH' -#!/bin/sh -while [ $# -gt 0 ]; do - case $1 in - --) shift; break ;; - --directory=*) cd "${1#--directory=}" || exit 127 ;; - --env=*) export "${1#--env=}" ;; - --unset-env=*) unset "${1#--unset-env=}" ;; - esac - shift -done -[ $# -gt 0 ] || exit 0 -exec "$@" -LAUNCH - chmod 755 "$srt_shim/bin/steam-runtime-launch-client" -fi +# Valve's Steam Runtime container needs user namespaces and a session-bus launcher; steamrt-shim stands in for both. +droiddeck-steamrt-shim "$STEAM_X64/steamrt64" "$X64_HOME/steamrt-shim" || rm -rf "$X64_HOME/steamrt-shim" # Valve's platform libraries ship without soname links (pressure-vessel makes them); ldconfig adds them here. srt_libs=$X64_HOME/steamrt-libs rm -rf "$srt_libs" @@ -641,6 +610,7 @@ RUN printf '"InstallConfigStore"\n{\n\t"Software"\n\t{\n\t\t"Valve"\n\t\t{\n\t\t\t"Steam"\n\t\t\t{\n\t\t\t\t"CompatToolMapping"\n\t\t\t\t{\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n' \ > "$STEAM_X64/config/config.vdf" fi + x64_share x86-64 x64_map_direct # Each wrapper execs droiddeck-x64-arm-proton through an arm64 binary, which FEX hands straight to the CPU. arm_tools=$ARM_STEAM/compatibilitytools.d @@ -690,9 +660,9 @@ VDF # Each client keeps its own libraries; written only while no x86 client runs, as it rewrites these on exit. if ! pgrep -f 'steamrt64/steam( |$)' >/dev/null 2>&1; then listing_ok=0 - if python3 - "$STEAM_X64" "${BL_X64_STEAM_LIBRARIES:-}" "$STEAM_X64/config/libraryfolders.vdf" "$STEAM_X64/steamapps/libraryfolders.vdf" <<'PY'; then + if BL_X64_SHARED=$X64_HOME/shared/arm64 python3 - "$STEAM_X64" "${BL_X64_STEAM_LIBRARIES:-}" "$STEAM_X64/config/libraryfolders.vdf" "$STEAM_X64/steamapps/libraryfolders.vdf" <<'PY'; then import json, os, re, sys -keep = {os.path.realpath(sys.argv[1])} +keep = {os.path.realpath(sys.argv[1]), os.path.realpath(os.environ["BL_X64_SHARED"])} # Without the session's list nothing is known about the card: leave the folders as they are. if not sys.argv[2]: sys.exit(3) @@ -942,6 +912,14 @@ x64_exec() { exec "${BL_FEX:-/usr/local/bin/FEX}" "$STEAM_X64/steamrt64/steam" "$@" } +x64_share() { + pgrep -f 'steamrt64/steam( |$)' >/dev/null 2>&1 && return 0 + pgrep -f 'steamrtarm64/steam( |$)' >/dev/null 2>&1 && return 0 + [ -d "$STEAM_X64/steamapps" ] && [ -d "$ARM_STEAM/steamapps" ] || return 0 + droiddeck-steam-share "$1" "$ARM_STEAM" "$STEAM_X64" /mnt/droiddeck-sd/x86-64 "$X64_HOME/shared" || + step "could not share the games between the clients" +} + # Steam renews its token every few days and refuses old copies: copy the newest every start, never while a client runs. x64_sync_login() { pgrep -f 'steamrt64/steam( |$)' >/dev/null 2>&1 && return 0 @@ -1058,6 +1036,7 @@ PY case "${1:-}" in --sync-login) x64_sync_login; exit 0 ;; + --share) x64_share arm64; exit 0 ;; --prepare) x64_prepare; exit 0 ;; --exec) shift; x64_exec "$@" ;; --gpu) x64_gpu; exit $? ;; diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steamrt-shim b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steamrt-shim new file mode 100755 index 000000000..feb4c5711 --- /dev/null +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steamrt-shim @@ -0,0 +1,58 @@ +#!/bin/bash +# droiddeck-steamrt-shim <client runtime dir> <shim dir> +# Builds <shim dir> from the Steam Runtime container the client carries and exits 0, or removes it and exits 1 +# when the client carries none. +set -eu +client=$1 +shim=$2 +srt_real=$client/pv-runtime/steam-runtime-steamrt +if [ ! -d "$srt_real" ]; then + probe=$(find "$client" -maxdepth 4 -name steam-runtime-check-requirements -path '*/bin/*' -print -quit 2>/dev/null || true) + srt_real=${probe:+${probe%/bin/*}} +fi +if [ -z "$srt_real" ] || [ ! -d "$srt_real" ]; then + rm -rf "$shim" + exit 1 +fi +rm -rf "$shim" +trap 'rm -rf "$shim"' ERR +mkdir -p "$shim/bin" +for e in "$srt_real"/* "$srt_real"/.[!.]*; do + [ -e "$e" ] || continue + [ "$(basename "$e")" = bin ] || ln -s "$e" "$shim/$(basename "$e")" +done +for e in "$srt_real"/bin/*; do + [ -e "$e" ] || continue + ln -s "$e" "$shim/bin/$(basename "$e")" +done +# The client's bwrap probe (steam-runtime-check-requirements) needs user namespaces, which no Android app has. +rm -f "$shim/bin/steam-runtime-check-requirements" +printf '#!/bin/sh +exit 0 +' > "$shim/bin/steam-runtime-check-requirements" +chmod 755 "$shim/bin/steam-runtime-check-requirements" +# Valve's launcher asks a service on the session bus; proot is already the container, so the command runs here. +rm -f "$shim/bin/steam-runtime-launch-client" +cat > "$shim/bin/steam-runtime-launch-client" <<'LAUNCH' +#!/bin/sh +while [ $# -gt 0 ]; do + case $1 in + --) shift; break ;; + --directory=*) cd "${1#--directory=}" || exit 127 ;; + --env=*) export "${1#--env=}" ;; + --unset-env=*) unset "${1#--unset-env=}" ;; + --directory|--env|--unset-env) + [ $# -ge 2 ] || exit 127 + case $1 in + --directory) cd "$2" || exit 127 ;; + --env) export "$2" ;; + --unset-env) unset "$2" ;; + esac + shift ;; + esac + shift +done +[ $# -gt 0 ] || exit 0 +exec "$@" +LAUNCH +chmod 755 "$shim/bin/steam-runtime-launch-client" diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-store-launch b/tools/linuxfs/overlay/usr/local/bin/droiddeck-store-launch index f59c56052..718498dff 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-store-launch +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-store-launch @@ -25,6 +25,8 @@ Android: an EOS sign-in step that falls back to a browser (a consent Epic asks f is finished there. An EOS overlay pointer (HKCU\Software\Epic Games\EOS "OverlayPath") an earlier build wrote is removed - with it the game exited while loading. +Battle.net.exe in Battle.net's prefix is handed to droiddeck-battlenet, which runs the whole launch. + droiddeck-store-launch <the arguments Steam gave the compat tool> """ @@ -291,6 +293,27 @@ def create_prefix(compat_data, proton, timeout=120): CLOUD_TIMEOUT = 15.0 CLOUD_STORES = ("gog", "epic") STORE_GAME = 10 +RAN = 11 +RAN_FAILED = 12 +BATTLENET = "/usr/local/bin/droiddeck-battlenet" +BATTLENET_EXE = re.compile(r"(?i)/compatdata/2596113170/pfx/drive_c/Program Files \(x86\)/Battle\.net/Battle\.net\.exe$") +BATTLENET_LAUNCH = re.compile(r'(?i)^--exec=\s*"?launch\s+([A-Za-z0-9_]{1,16})"?$') + + +def battlenet(argv, env=os.environ): + if env.get("BL_BATTLENET"): + return None + exe, code = False, None + for raw in argv: + arg = raw.strip().strip('"').strip("'") + if BATTLENET_EXE.search(arg.replace("\\", "/")): + exe = True + found = BATTLENET_LAUNCH.match(arg) + if found: + code = found.group(1) + if not exe: + return None + return [BATTLENET, "launch", code] if code else [BATTLENET, "open"] def cloud_on(sidecar): @@ -511,6 +534,10 @@ def main(argv): pass rest = rest[2:] return exited(rest, code) + command = battlenet(argv) + if command is not None: + log("battlenet " + " ".join(command[1:])) + return RAN if subprocess.call(command) == 0 else RAN_FAILED found = find_store_game(argv) if found is None: return 0 diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-xbox b/tools/linuxfs/overlay/usr/local/bin/droiddeck-xbox index c07a6d833..f8aaff7b1 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-xbox +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-xbox @@ -15,7 +15,18 @@ import threading BIN = Path(__file__).resolve().parent RUNTIME = Path('/usr/local/lib/droiddeck-xbox') -DLLS = ('xgameruntime.dll', 'winhttp.dll', 'windows.web.dll', 'twinapi.appcore.dll', 'libnghttp2.dll') +DLLS = ('xgameruntime.dll', 'winhttp.dll', 'windows.web.dll', 'twinapi.appcore.dll', 'wintypes.dll', 'libnghttp2.dll') +CLASSES = { + 'twinapi.appcore.dll': ('Windows.ApplicationModel.DataTransfer.DataTransferManager', + 'Windows.Security.ExchangeActiveSyncProvisioning.EasClientDeviceInformation', + 'Windows.System.Profile.AnalyticsInfo', 'Windows.System.UserProfile.AdvertisingManager', + 'Windows.UI.ViewManagement.ApplicationView'), + 'windows.web.dll': ('Windows.Data.Json.JsonArray', 'Windows.Data.Json.JsonObject', 'Windows.Data.Json.JsonValue'), + 'wintypes.dll': ('Windows.Foundation.PropertyValue', 'Windows.Foundation.Collections.PropertySet', + 'Windows.Foundation.Metadata.ApiInformation', 'Windows.Storage.Streams.Buffer', + 'Windows.Storage.Streams.DataWriter'), +} +CLASS_KEY = 'Software\\Microsoft\\WindowsRuntime\\ActivatableClassId\\' def bridge(upstream): @@ -102,6 +113,30 @@ def bridge(upstream): return server.getsockname()[1], key.decode('ascii'), close +def register_classes(proton, prefix, env): + try: + registry = (prefix / 'system.reg').read_text(encoding='utf-8', errors='replace') + except OSError: + return + missing = [(name, dll) for dll, names in CLASSES.items() for name in names + if '[%s]' % (CLASS_KEY + name).replace('\\', '\\\\') not in registry] + if not missing: + return + text = 'Windows Registry Editor Version 5.00\r\n\r\n' + ''.join( + '[HKEY_LOCAL_MACHINE\\%s%s]\r\n"DllPath"="C:\\\\windows\\\\system32\\\\%s"\r\n\r\n' % (CLASS_KEY, name, dll) + for name, dll in missing) + with tempfile.TemporaryDirectory(prefix='droiddeck-xbox-') as temporary: + source = Path(temporary) / 'classes.reg' + source.write_bytes(b'\xff\xfe' + text.encode('utf-16-le')) + try: + failed = subprocess.run([proton, 'run', 'reg.exe', 'import', 'Z:' + str(source).replace('/', '\\')], + stdout=sys.stderr, env=env, timeout=90).returncode != 0 + except subprocess.TimeoutExpired: + failed = True + if failed: + print('droiddeck-xbox: Windows Runtime class registration failed; launching without it', file=sys.stderr) + + def wine(argv, source_env=None): source_env = os.environ if source_env is None else source_env with open(source_env['DROIDDECK_XBOX_COMMAND_FILE'], encoding='utf-8') as file: @@ -109,7 +144,8 @@ def wine(argv, source_env=None): if len(command) < 3 or command[1] != 'waitforexitandrun' or len(argv) != 1: raise ValueError('Invalid Xbox Proton invocation') env = dict(source_env) - env.pop('DROIDDECK_XBOX_CREDENTIAL_SOCKET', None) + for name in ('DROIDDECK_XBOX_CREDENTIAL_SOCKET', 'DROIDDECK_XBOX_CREDENTIAL_PORT', 'DROIDDECK_XBOX_CREDENTIAL_KEY'): + env.pop(name, None) env.pop('DROIDDECK_XBOX_COMMAND_FILE', None) prefix = Path(source_env['STEAM_COMPAT_DATA_PATH']) / 'pfx' prefix.parent.mkdir(parents=True, exist_ok=True) @@ -124,6 +160,7 @@ def wine(argv, source_env=None): if target.is_symlink(): target.unlink() shutil.copyfile(RUNTIME / 'windows' / name, target) + register_classes(command[0], prefix, env) overrides = ';'.join(name[:-4] + '=n' for name in DLLS if name != 'libnghttp2.dll') env['WINEDLLOVERRIDES'] = overrides + ';' + env.get('WINEDLLOVERRIDES', '') return subprocess.call([*command[:2], argv[0], *command[3:]], env=env) @@ -171,7 +208,8 @@ def launch(command, env): owned_env = dict(env, DROIDDECK_XBOX_ACTIVE='1', DROIDDECK_XBOX_COMMAND_FILE=str(context), XODUS_SOCKET=reply['socket'], XODUS_BRIDGE_PORT=str(port), XODUS_BRIDGE_KEY=key, XODUS_LOG='off', RUST_LOG='off', XODUS_SAVE_SYNC='auto' if sidecar.get('cloud', True) else 'off') - owned_env.pop('DROIDDECK_XBOX_CREDENTIAL_SOCKET', None) + for name in ('DROIDDECK_XBOX_CREDENTIAL_SOCKET', 'DROIDDECK_XBOX_CREDENTIAL_PORT', 'DROIDDECK_XBOX_CREDENTIAL_KEY'): + owned_env.pop(name, None) if reply.get('playfab'): owned_env['XODUS_XSTS_PLAYFAB'] = reply['playfab'] content = folder / '.xbox-content' diff --git a/tools/linuxfs/overlay/usr/local/bin/steam-compatibility b/tools/linuxfs/overlay/usr/local/bin/steam-compatibility index 41a37a9aa..09dd3dd02 100755 --- a/tools/linuxfs/overlay/usr/local/bin/steam-compatibility +++ b/tools/linuxfs/overlay/usr/local/bin/steam-compatibility @@ -69,7 +69,10 @@ bl_store_launch() { fi [ -x /usr/local/bin/droiddeck-store-launch ] || return 0 /usr/local/bin/droiddeck-store-launch "$@" - [ $? -eq 10 ] && BL_STORE_EXIT=1 + rc=$? + [ $rc -eq 10 ] && BL_STORE_EXIT=1 + [ $rc -eq 11 ] && exit 0 + [ $rc -eq 12 ] && exit 1 return 0 } @@ -1113,10 +1116,89 @@ def requested_tool(request, catalog): def game_requests(directory=None): - value = read_json(state_path("games.json", directory)) - if value.get("version") != 1 or not isinstance(value.get("games"), dict): - return {} - return value["games"] + return read_game_requests(directory)[0] + + +def read_game_requests(directory=None): + try: + with open(state_path("games.json", directory), encoding="utf-8") as f: + text = f.read() + value = json.loads(text) + except (OSError, ValueError): + return {}, None + if not isinstance(value, dict) or value.get("version") != 1 or not isinstance(value.get("games"), dict): + return {}, text + return value["games"], text + + +def write_game_requests(requests, original, directory=None): + path = state_path("games.json", directory) + try: + with open(path, encoding="utf-8") as f: + if f.read() != original: + return False + except OSError: + if original is not None: + return False + return write_json(path, {"version": 1, "games": requests}) + + +def valid_game_request(app, request): + return (isinstance(app, str) and re.fullmatch(r"[1-9][0-9]*", app) is not None and int(app) <= 0xFFFFFFFF and + (request is None or isinstance(request, dict) and isinstance(request.get("dir"), str) and + request["dir"] != "" and isinstance(request.get("valve"), bool))) + + +def settle_game_requests(requests, state, current, catalog, missing_is_cleared=False): + recorded = state.get("games_applied") + if isinstance(recorded, dict): + applied = dict(recorded) + else: + applied = dict(requests) if "tools" in state else {} + by_name = {t["name"]: t for t in catalog} + out = dict(requests) + for app, request in requests.items(): + if not valid_game_request(app, request) or app not in applied or applied[app] != request: + continue + if request is None: + del out[app] + continue + wanted = requested_tool(request, catalog) + if wanted is None: + continue + if app in current: + picked = current[app] + elif missing_is_cleared: + picked = "" + else: + continue + if picked == wanted: + continue + tool = by_name.get(picked) + if tool and tool["dir"]: + out[app] = {"valve": tool["valve"], "dir": tool["dir"]} + else: + del out[app] + return out, out != requests + + +def settled_game_requests(state, current, catalog, missing_is_cleared=False, directory=None): + for _ in range(2): + requests, text = read_game_requests(directory) + settled, moved = settle_game_requests(requests, state, current, catalog, missing_is_cleared) + if not moved or write_game_requests(settled, text, directory): + return settled + return None + + +def applied_game_requests(requests, overrides, state): + recorded = state.get("games_applied") + before = recorded if isinstance(recorded, dict) else {} + if requests is None: + return dict(before) + return {app: request if app in overrides and overrides[app] is not DEFERRED_GAME_TOOL else before[app] + for app, request in requests.items() + if app in overrides and overrides[app] is not DEFERRED_GAME_TOOL or app in before} def requested_games(requests, catalog, runnable=None): @@ -1140,6 +1222,32 @@ def requested_games(requests, catalog, runnable=None): return resolved +OWN_PROTON = "DroidDeck-Proton" +ARCH_WORDS = re.compile(r"(?i)aarch64|arm64|x86_64|x86-64|amd64") + + +def own_builds(tools): + return [t for t in tools if isinstance(t, dict) and not t.get("valve") and + isinstance(t.get("dir"), str) and t["dir"].startswith(OWN_PROTON) and t.get("name")] + + +def build_key(name): + return [int(n) for n in re.findall(r"\d+", ARCH_WORDS.sub(" ", name))] + + +def own_proton_moves(state, catalog): + names = {t["name"] for t in catalog} + before = state.get("tools") if isinstance(state.get("tools"), list) else () + seen = state.get("own_builds") if isinstance(state.get("own_builds"), list) else [] + known = {n for n in seen if isinstance(n, str)} | {t["name"] for t in own_builds(before)} | {t["name"] for t in own_builds(catalog)} + state["own_builds"] = sorted(known) + builds = own_builds(catalog) + if not builds: + return None, set() + newest = max(builds, key=lambda t: (build_key(t["dir"]), t["dir"]))["name"] + return newest, known - names + + def now_ms(): import time return int(time.time() * 1000) @@ -1300,13 +1408,29 @@ def register_default(config_path, apps=(), protect=(), default=None, catalog=Non catalog = tool_catalog(os.path.dirname(os.path.dirname(os.path.abspath(config_path))), dict(protect) if isinstance(protect, dict) else {name: name for name in protect}) names = [t["name"] for t in catalog] + newest, gone = own_proton_moves(state, catalog) + previous, applied, chosen = state.get("default"), state.get("applied"), state.get("seq") if default is None: if settle: default, state = choose_default(current.get("0", ""), state, read_json(state_path("request.json", directory)), catalog) else: default = state.get("default") if state.get("default") in names else TOOL + if settle and newest and default == TOOL and state.get("applied") == applied and state.get("seq") == chosen and ( + previous in gone or not state.get("own_proton")): + default = newest + state.update(default=newest, seq=now_ms(), source="update") + if settle and newest: + state["own_proton"] = True + if newest: + for app, name in list(current.items()): + if app != "0" and name in gone: + set_mapping(tokens, mapping, app, newest) + current[app] = newest + if isinstance(state.get("auto"), dict): + state["auto"] = {app: newest if name in gone else name for app, name in state["auto"].items()} steam_root = os.path.dirname(os.path.dirname(os.path.abspath(config_path))) - overrides = requested_games(game_requests(directory), catalog) + requests = settled_game_requests(state, current, catalog, not steam_running(), directory) + overrides = requested_games(requests or {}, catalog) managed = state.get("auto", {}).keys() if isinstance(state.get("auto"), dict) else () native_choices = state.get("native_choices", ()) native = native_targets(steam_root, set(apps) | set(picks) | set(managed) | set(native_choices)) @@ -1325,6 +1449,7 @@ def register_default(config_path, apps=(), protect=(), default=None, catalog=Non state["auto"] = {app: tool for app, tool in auto.items() if tool is not NATIVE_CHOICE} state["native_choices"] = sorted(app for app, tool in auto.items() if tool is NATIVE_CHOICE) state["picked"] = picked_after(current, changes) + state["games_applied"] = applied_game_requests(requests, overrides, state) state.pop("native", None) state["tools"] = catalog state.setdefault("default", default) diff --git a/tools/linuxfs/preload/netif.c b/tools/linuxfs/preload/netif.c index 91ce93a06..1782d0f0d 100644 --- a/tools/linuxfs/preload/netif.c +++ b/tools/linuxfs/preload/netif.c @@ -409,6 +409,8 @@ int bl_drm_gem_close(int fd, unsigned long request, void *arg, int *rc) __attrib int bl_venc_ioctl(int fd, unsigned long request, void *arg, int *rc) __attribute__((visibility("hidden"))); int bl_winebus_rumble_ioctl(int (*real)(int, unsigned long, void *), int fd, unsigned long request, void *arg, void *caller, int *rc) __attribute__((visibility("hidden"))); +int bl_writewatch_ioctl(int (*real)(int, unsigned long, void *), int fd, unsigned long request, void *arg, + int *rc) __attribute__((visibility("hidden"))); int ioctl(int fd, unsigned long request, ...) { typedef int (*ioctl_fn)(int, unsigned long, void *); @@ -424,6 +426,7 @@ int ioctl(int fd, unsigned long request, ...) { if (bl_drm_gem_close(fd, request, arg, &rc)) return rc; if (real == NULL) real = (ioctl_fn) dlsym(RTLD_NEXT, "ioctl"); if (bl_winebus_rumble_ioctl(real, fd, request, arg, __builtin_return_address(0), &rc)) return rc; + if (bl_writewatch_ioctl(real, fd, request, arg, &rc)) return rc; if (!bl_kgsl_poll(real, fd, request, arg, &rc)) rc = quest_kgsl_ioctl(real, fd, request, arg); bl_kgsl_chip_id_fixup(request, arg, rc); if (arg == NULL || !is_link_query(request)) return rc; diff --git a/tools/linuxfs/preload/ntsync.c b/tools/linuxfs/preload/ntsync.c index b920b49f9..d49bb42b7 100644 --- a/tools/linuxfs/preload/ntsync.c +++ b/tools/linuxfs/preload/ntsync.c @@ -17,6 +17,7 @@ #include <sys/syscall.h> #include <sys/timerfd.h> #include <time.h> +#include <ucontext.h> #include <unistd.h> struct ns_sem_args { @@ -68,12 +69,13 @@ struct ns_wait_args { #define NS_PROCS 256u #define NS_FDS 65536 #define NS_DEVS 8 -#define NS_VERSION 1u +#define NS_VERSION 2u #define NS_HEAD_MAGIC 0x31434e5953544e44ull #define NS_DEV_MAGIC 0x4e54445600000000ull #define NS_OBJ_MAGIC 0x27ull #define NS_WAITERS 0x80000000u #define NS_SWEEP_NS 2000000000ull +#define NS_SPIN_NS 4000ull #define NS_DEAD 0xffffffffu #ifndef CLOSE_RANGE_CLOEXEC @@ -144,6 +146,7 @@ struct ns_q { uint32_t waiting; uint32_t inuse; uint32_t next_free; + uint32_t sleeping; struct ns_ent ent[NS_ENTS]; }; @@ -168,6 +171,7 @@ struct ns_tls { struct ns_dev *d; uint32_t q; int waiting; + int sleep; }; static struct ns_dev ns_devs[NS_DEVS]; @@ -255,19 +259,148 @@ static void ns_unlock(uint32_t *w) { } } -static void ns_block(sigset_t *old) { - sigset_t s; - sigfillset(&s); - sigdelset(&s, SIGSEGV); - sigdelset(&s, SIGBUS); - sigdelset(&s, SIGILL); - sigdelset(&s, SIGFPE); - sigdelset(&s, SIGTRAP); - pthread_sigmask(SIG_BLOCK, &s, old); +#define NS_DEFER 4 +#define NS_LEVELS 8 + +struct ns_cs { + int depth; + int count; + int level; + unsigned held_levels; + int sigs[NS_DEFER]; + siginfo_t info[NS_DEFER]; + sigset_t held[NS_LEVELS]; +}; + +static __thread struct ns_cs ns_cs; +static uintptr_t ns_handlers[_NSIG]; +static uint8_t ns_siginfo[_NSIG]; + +int bl_from_ntdll(const void *caller) __attribute__((visibility("hidden"))); + +static int ns_deferrable(int sig) { + return sig > 0 && sig < _NSIG && sig != SIGKILL && sig != SIGSTOP && sig != SIGSEGV && sig != SIGBUS && sig != SIGILL + && sig != SIGFPE && sig != SIGTRAP && sig != SIGSYS; } -static void ns_restore(const sigset_t *old) { - pthread_sigmask(SIG_SETMASK, old, NULL); +static int ns_defer(int sig, const siginfo_t *si) { + struct ns_cs *cs = &ns_cs; + if (sig < SIGRTMIN) + for (int i = 0; i < NS_DEFER; i++) + if (__atomic_load_n(&cs->sigs[i], __ATOMIC_RELAXED) == sig) return 1; + int i = __atomic_fetch_add(&cs->count, 1, __ATOMIC_RELAXED); + if (i >= NS_DEFER) { + __atomic_fetch_sub(&cs->count, 1, __ATOMIC_RELAXED); + return 0; + } + if (si) memcpy(&cs->info[i], si, sizeof(*si)); + else memset(&cs->info[i], 0, sizeof(*si)); + __atomic_signal_fence(__ATOMIC_SEQ_CST); + __atomic_store_n(&cs->sigs[i], sig, __ATOMIC_RELAXED); + return 1; +} + +static int ns_park(int sig, const siginfo_t *si, ucontext_t *uc) { + int level = ns_cs.level; + if (level < 0 || level >= NS_LEVELS) return 0; + sigset_t one; + sigemptyset(&one); + sigaddset(&one, sig); + int saved = errno; + pthread_sigmask(SIG_BLOCK, &one, NULL); + pid_t pid = getpid(), tid = (pid_t)ns_self(); + int ok = (si && syscall(SYS_rt_tgsigqueueinfo, pid, tid, sig, si) == 0) || syscall(SYS_tgkill, pid, tid, sig) == 0; + if (!ok) pthread_sigmask(SIG_UNBLOCK, &one, NULL); + errno = saved; + if (!ok) return 0; + sigaddset(&uc->uc_sigmask, sig); + unsigned long *words = (unsigned long *)&ns_cs.held[level]; + unsigned bits = 8 * sizeof(unsigned long); + __atomic_fetch_or(&words[(unsigned)(sig - 1) / bits], 1ul << ((unsigned)(sig - 1) % bits), __ATOMIC_RELAXED); + __atomic_fetch_or(&ns_cs.held_levels, 1u << level, __ATOMIC_RELAXED); + return 1; +} + +static void ns_trampoline(int sig, siginfo_t *si, void *uc) { + if (__atomic_load_n(&ns_cs.depth, __ATOMIC_RELAXED) > 0 && (ns_defer(sig, si) || (uc && ns_park(sig, si, uc)))) return; + uintptr_t h = __atomic_load_n(&ns_handlers[sig], __ATOMIC_ACQUIRE); + if (!h) return; + ns_cs.level++; + __atomic_signal_fence(__ATOMIC_SEQ_CST); + if (__atomic_load_n(&ns_siginfo[sig], __ATOMIC_RELAXED)) ((void (*)(int, siginfo_t *, void *))h)(sig, si, uc); + else ((void (*)(int))h)(sig); + __atomic_signal_fence(__ATOMIC_SEQ_CST); + ns_cs.level--; +} + +static void ns_flush(void) { + struct ns_cs *cs = &ns_cs; + sigset_t all, mask; + sigfillset(&all); + int saved = errno; + pthread_sigmask(SIG_BLOCK, &all, &mask); + int n = cs->count < NS_DEFER ? cs->count : NS_DEFER; + pid_t pid = getpid(), tid = (pid_t)ns_self(); + for (int i = 0; i < n; i++) + if (cs->sigs[i] > 0 && syscall(SYS_rt_tgsigqueueinfo, pid, tid, cs->sigs[i], &cs->info[i]) != 0) syscall(SYS_tgkill, pid, tid, cs->sigs[i]); + memset(cs->sigs, 0, sizeof(cs->sigs)); + cs->count = 0; + int level = cs->level; + if (level >= 0 && level < NS_LEVELS && (cs->held_levels & (1u << level))) { + for (int sig = 1; sig < _NSIG; sig++) + if (sigismember(&cs->held[level], sig) == 1) sigdelset(&mask, sig); + sigemptyset(&cs->held[level]); + cs->held_levels &= ~(1u << level); + } + pthread_sigmask(SIG_SETMASK, &mask, NULL); + errno = saved; +} + +static void ns_block(void) { + ns_cs.depth++; + __atomic_signal_fence(__ATOMIC_SEQ_CST); +} + +static void ns_restore(void) { + __atomic_signal_fence(__ATOMIC_SEQ_CST); + if (--ns_cs.depth == 0) { + __atomic_signal_fence(__ATOMIC_SEQ_CST); + if (ns_cs.count || (ns_cs.held_levels && ns_cs.level >= 0 && ns_cs.level < NS_LEVELS && (ns_cs.held_levels & (1u << ns_cs.level)))) + ns_flush(); + } +} + +int sigaction(int sig, const struct sigaction *act, struct sigaction *old) { + static int (*real)(int, const struct sigaction *, struct sigaction *); + if (!real) real = (int (*)(int, const struct sigaction *, struct sigaction *))dlsym(RTLD_NEXT, "sigaction"); + if (!real) { + errno = ENOSYS; + return -1; + } + if (!ns_deferrable(sig) || !ns_on()) return real(sig, act, old); + uintptr_t prev = __atomic_load_n(&ns_handlers[sig], __ATOMIC_ACQUIRE); + uint8_t prev_info = __atomic_load_n(&ns_siginfo[sig], __ATOMIC_RELAXED); + struct sigaction mine; + const struct sigaction *use = act; + if (act && act->sa_handler != SIG_DFL && act->sa_handler != SIG_IGN && !(act->sa_flags & SA_RESETHAND) && + bl_from_ntdll(__builtin_return_address(0))) { + mine = *act; + mine.sa_sigaction = ns_trampoline; + mine.sa_flags |= SA_SIGINFO; + __atomic_store_n(&ns_siginfo[sig], (act->sa_flags & SA_SIGINFO) ? 1 : 0, __ATOMIC_RELAXED); + __atomic_store_n(&ns_handlers[sig], (uintptr_t)act->sa_sigaction, __ATOMIC_RELEASE); + use = &mine; + } + int r = real(sig, use, old); + if (r != 0 && use == &mine) { + __atomic_store_n(&ns_siginfo[sig], prev_info, __ATOMIC_RELAXED); + __atomic_store_n(&ns_handlers[sig], prev, __ATOMIC_RELEASE); + } + if (r == 0 && old && old->sa_sigaction == ns_trampoline) { + old->sa_sigaction = (void (*)(int, siginfo_t *, void *))prev; + if (!prev_info) old->sa_flags &= ~SA_SIGINFO; + } + return r; } static inline struct ns_obj *ns_obj(struct ns_head *h, uint32_t s) { @@ -378,10 +511,11 @@ static int ns_claim(struct ns_q *q, int32_t index) { if (dead) return 0; } int32_t want = -1; - return __atomic_compare_exchange_n(&q->signaled, &want, index, 0, __ATOMIC_ACQ_REL, __ATOMIC_RELAXED); + return __atomic_compare_exchange_n(&q->signaled, &want, index, 0, __ATOMIC_SEQ_CST, __ATOMIC_RELAXED); } static void ns_wake(struct ns_q *q) { + if (!__atomic_load_n(&q->sleeping, __ATOMIC_SEQ_CST)) return; int saved = errno; ns_futex(&q->signaled, FUTEX_WAKE, 1, NULL, 0); errno = saved; @@ -618,14 +752,13 @@ static void ns_dev_put_locked(struct ns_dev *d) { static void ns_dev_put(struct ns_dev *d) { if (__atomic_sub_fetch(&d->refs, 1, __ATOMIC_ACQ_REL)) return; - sigset_t old; int saved = errno; - ns_block(&old); + ns_block(); pthread_mutex_lock(&ns_mx); uint32_t zero = 0; if (d->h && __atomic_compare_exchange_n(&d->refs, &zero, NS_DEAD, 0, __ATOMIC_ACQ_REL, __ATOMIC_ACQUIRE)) ns_release(d); pthread_mutex_unlock(&ns_mx); - ns_restore(&old); + ns_restore(); errno = saved; } @@ -682,20 +815,18 @@ static void ns_forget_locked(int fd) { } static void ns_forget(int fd) { - sigset_t old; int saved = errno; - ns_block(&old); + ns_block(); pthread_mutex_lock(&ns_mx); ns_forget_locked(fd); pthread_mutex_unlock(&ns_mx); - ns_restore(&old); + ns_restore(); errno = saved; } static uint64_t ns_adopt(struct ns_dev *d, uint32_t tag, int fd, uint32_t gen, uint32_t s) { uint64_t e = 0; - sigset_t old; - ns_block(&old); + ns_block(); pthread_mutex_lock(&ns_mx); if (!d->h || d->tag != tag || !ns_table() || ns_attach(d) < 0 || s >= NS_SLOTS) goto out; if ((e = __atomic_load_n(&ns_fdt[fd], __ATOMIC_ACQUIRE))) goto out; @@ -710,7 +841,7 @@ static uint64_t ns_adopt(struct ns_dev *d, uint32_t tag, int fd, uint32_t gen, u ns_unlock(&o->lock); out: pthread_mutex_unlock(&ns_mx); - ns_restore(&old); + ns_restore(); return e; } @@ -719,8 +850,7 @@ static uint64_t ns_note_device(int fd) { uint64_t e = 0; if (syscall(SYS_fstat, fd, &st) != 0 || !S_ISREG(st.st_mode)) return 0; if (((uint64_t)st.st_size & ~0xffffull) != NS_DEV_MAGIC) return 0; - sigset_t old; - ns_block(&old); + ns_block(); pthread_mutex_lock(&ns_mx); struct ns_dev *d = ns_table() ? ns_map(fd, (uint32_t)st.st_size & 0xffff, (uint64_t)st.st_ino) : NULL; if (d) { @@ -732,7 +862,7 @@ static uint64_t ns_note_device(int fd) { } } pthread_mutex_unlock(&ns_mx); - ns_restore(&old); + ns_restore(); return e; } @@ -773,15 +903,14 @@ static int ns_ready(struct ns_dev *d) { static void ns_thread_gone(void *p) { struct ns_tls *t = p; - if (!t || !t->d || !t->q) return; - sigset_t old; + if (!t || !t->d) return; struct ns_dev *d = t->d; - ns_block(&old); - ns_free_q(d->h, t->q - 1); + ns_block(); + if (t->q) ns_free_q(d->h, t->q - 1); t->q = 0; t->waiting = 0; t->d = NULL; - ns_restore(&old); + ns_restore(); ns_dev_put(d); } @@ -789,24 +918,28 @@ static void ns_key_init(void) { pthread_key_create(&ns_key, ns_thread_gone); } -static struct ns_q *ns_thread_q(struct ns_dev *d, uint32_t *qi) { - struct ns_head *h = d->h; - if (ns_tq.d && ns_tq.q && ns_tq.waiting) { - ns_drop_q(ns_tq.d->h, ns_tq.q - 1); - ns_tq.waiting = 0; - } - if (ns_tq.d == d && ns_tq.q) { - *qi = ns_tq.q - 1; - return ns_q(h, *qi); - } - if (ns_tq.d && ns_tq.q) { - struct ns_dev *old = ns_tq.d; - ns_free_q(old->h, ns_tq.q - 1); +static int ns_hold(struct ns_dev *d) { + if (ns_tq.d == d) return 1; + if (!ns_dev_get(d)) return 0; + if (ns_tq.sleep) return 2; + struct ns_dev *old = ns_tq.d; + if (old) { + ns_block(); + if (ns_tq.q) ns_free_q(old->h, ns_tq.q - 1); ns_tq.q = 0; + ns_tq.waiting = 0; ns_tq.d = NULL; + ns_restore(); ns_dev_put(old); } pthread_once(&ns_once, ns_key_init); + ns_tq.d = d; + pthread_setspecific(ns_key, &ns_tq); + return 1; +} + +static struct ns_q *ns_new_q(struct ns_dev *d, uint32_t *qi) { + struct ns_head *h = d->h; uint32_t i; if (!ns_pop(h, &h->q_free, 0, &i) && !ns_grow(&h->q_hwm, NS_QS, &i)) { ns_sweep_dead(h); @@ -815,22 +948,35 @@ static struct ns_q *ns_thread_q(struct ns_dev *d, uint32_t *qi) { struct ns_q *q = ns_q(h, i); q->proc = (uint32_t)d->proc; q->waiting = 0; + q->sleeping = 0; q->inuse = 1; - __atomic_add_fetch(&d->refs, 1, __ATOMIC_ACQ_REL); - ns_tq.d = d; - ns_tq.q = i + 1; - ns_tq.waiting = 0; - pthread_setspecific(ns_key, &ns_tq); *qi = i; return q; } +static struct ns_q *ns_thread_q(struct ns_dev *d, uint32_t *qi) { + struct ns_head *h = d->h; + if (ns_tq.d != d) return NULL; + if (ns_tq.q && ns_tq.waiting) { + ns_drop_q(h, ns_tq.q - 1); + ns_tq.waiting = 0; + } + if (ns_tq.q) { + *qi = ns_tq.q - 1; + return ns_q(h, *qi); + } + struct ns_q *q = ns_new_q(d, qi); + if (!q) return NULL; + ns_tq.q = *qi + 1; + ns_tq.waiting = 0; + return q; +} + static int ns_create(struct ns_dev *d, int type, uint32_t a, uint32_t b) { struct ns_head *h = d->h; - sigset_t old; int fd = -1, err = 0; uint32_t s = 0; - ns_block(&old); + ns_block(); pthread_mutex_lock(&ns_mx); if (!ns_table() || ns_attach(d) < 0) { err = ENOMEM; @@ -879,7 +1025,7 @@ static int ns_create(struct ns_dev *d, int type, uint32_t a, uint32_t b) { ns_unlock(&o->lock); out: pthread_mutex_unlock(&ns_mx); - ns_restore(&old); + ns_restore(); if (fd < 0) errno = err; return fd; } @@ -890,6 +1036,20 @@ static int ns_expired(const struct ns_wait_args *args) { return (uint64_t)now.tv_sec * 1000000000ull + (uint64_t)now.tv_nsec >= args->timeout; } +static uint64_t ns_now(void) { + struct timespec now; + clock_gettime(CLOCK_MONOTONIC, &now); + return (uint64_t)now.tv_sec * 1000000000ull + (uint64_t)now.tv_nsec; +} + +static void ns_spin(struct ns_q *q) { + uint64_t end = ns_now() + NS_SPIN_NS; + for (unsigned i = 1; __atomic_load_n(&q->signaled, __ATOMIC_ACQUIRE) == -1; i++) { + ns_relax(); + if (!(i & 63) && ns_now() >= end) return; + } +} + static int ns_sleep(struct ns_q *q, const struct ns_wait_args *args) { struct timespec ts, *tsp = NULL; int op = FUTEX_WAIT_BITSET | ((args->flags & NS_WAIT_REALTIME) ? FUTEX_CLOCK_REALTIME : 0); @@ -898,14 +1058,45 @@ static int ns_sleep(struct ns_q *q, const struct ns_wait_args *args) { ts.tv_nsec = (long)(args->timeout % 1000000000ull); tsp = &ts; } - while (__atomic_load_n(&q->signaled, __ATOMIC_ACQUIRE) == -1) { - if (tsp && ns_expired(args)) return ETIMEDOUT; - if (ns_futex(&q->signaled, op, (uint32_t)-1, tsp, FUTEX_BITSET_MATCH_ANY) == -1) { - if (errno == ETIMEDOUT) return ETIMEDOUT; - if (errno == EINTR) return EINTR; - } + ns_spin(q); + int ret = 0; + __atomic_store_n(&q->sleeping, 1, __ATOMIC_SEQ_CST); + while (!ret && __atomic_load_n(&q->signaled, __ATOMIC_SEQ_CST) == -1) { + if (tsp && ns_expired(args)) ret = ETIMEDOUT; + else if (ns_futex(&q->signaled, op, (uint32_t)-1, tsp, FUTEX_BITSET_MATCH_ANY) == -1 && (errno == ETIMEDOUT || errno == EINTR)) + ret = errno; } - return 0; + __atomic_store_n(&q->sleeping, 0, __ATOMIC_RELAXED); + return ret; +} + +static int ns_try_any(struct ns_head *h, const uint32_t *slots, const uint32_t *gens, uint32_t total, uint32_t owner, int *dead) { + for (uint32_t i = 0; i < total; i++) { + struct ns_obj *o = ns_obj(h, slots[i]); + ns_lock(&o->lock); + int busy = o->type == NS_FREE || o->gen != gens[i] || o->any_head || o->all_hint; + if (!busy && ns_signaled(o, owner)) { + switch (o->type) { + case NS_SEM: + o->a--; + break; + case NS_MUTEX: + *dead = (int)o->ownerdead; + o->ownerdead = 0; + o->a++; + o->b = owner; + break; + case NS_EVENT: + if (!o->a) o->b = 0; + break; + } + ns_unlock(&o->lock); + return (int)i; + } + ns_unlock(&o->lock); + if (busy) return -1; + } + return -2; } static int ns_wait(struct ns_dev *d, struct ns_wait_args *ua, int all) { @@ -927,12 +1118,24 @@ static int ns_wait(struct ns_dev *d, struct ns_wait_args *ua, int all) { for (uint32_t j = 0; j < i; j++) if (slots[j] == slots[i]) return EINVAL; } - sigset_t old; - ns_block(&old); + int nested = ns_tq.sleep > 0; + ns_block(); + if (!all && (nested || !ns_tq.waiting)) { + int dead = 0, got = ns_try_any(h, slots, gens, total, args.owner, &dead); + if (got >= 0) { + ns_restore(); + ua->index = (uint32_t)got; + return dead ? EOWNERDEAD : 0; + } + if (got == -2 && args.timeout != UINT64_MAX && ns_expired(&args)) { + ns_restore(); + return ETIMEDOUT; + } + } uint32_t qi; - struct ns_q *q = ns_ready(d) ? ns_thread_q(d, &qi) : NULL; + struct ns_q *q = !ns_ready(d) ? NULL : nested ? ns_new_q(d, &qi) : ns_thread_q(d, &qi); if (!q) { - ns_restore(&old); + ns_restore(); return ENOMEM; } ns_maybe_sweep(h); @@ -952,7 +1155,7 @@ static int ns_wait(struct ns_dev *d, struct ns_wait_args *ua, int all) { } q->total = total; q->waiting = 1; - ns_tq.waiting = 1; + if (!nested) ns_tq.waiting = 1; int bad = 0; if (!all) { for (uint32_t i = 0; i < total && !bad; i++) { @@ -1004,16 +1207,18 @@ static int ns_wait(struct ns_dev *d, struct ns_wait_args *ua, int all) { } int ret = 0; if (!bad && __atomic_load_n(&q->signaled, __ATOMIC_ACQUIRE) == -1) { - sigset_t held; - ns_restore(&old); + ns_tq.sleep++; + ns_restore(); ret = ns_sleep(q, &args); - ns_block(&held); + ns_block(); + ns_tq.sleep--; } ns_drop_q(h, qi); - ns_tq.waiting = 0; + if (!nested) ns_tq.waiting = 0; int32_t got = __atomic_load_n(&q->signaled, __ATOMIC_ACQUIRE); int dead = (int)q->ownerdead; - ns_restore(&old); + if (nested) ns_free_q(h, qi); + ns_restore(); if (bad) return EINVAL; if (got != -1) { ua->index = (uint32_t)got; @@ -1061,12 +1266,11 @@ static int ns_obj_ioctl(struct ns_dev *d, uint64_t e, unsigned long req, void *a return ENOTTY; } if (type != (uint32_t)want) return EINVAL; - sigset_t old; - ns_block(&old); + ns_block(); int all = ns_lock_obj(h, o); if (o->type != (uint32_t)want || o->gen != gen) { ns_unlock_obj(h, o, all); - ns_restore(&old); + ns_restore(); return EINVAL; } switch (req) { @@ -1133,7 +1337,7 @@ static int ns_obj_ioctl(struct ns_dev *d, uint64_t e, unsigned long req, void *a } } ns_unlock_obj(h, o, all); - ns_restore(&old); + ns_restore(); if (ret) return ret; if (req == NS_SEM_RELEASE || req == NS_EVENT_SET || req == NS_EVENT_RESET || req == NS_EVENT_PULSE) memcpy(arg, &prev, sizeof(prev)); @@ -1176,9 +1380,10 @@ int bl_ntsync_ioctl(int fd, unsigned long req, void *arg, int *rc) { uint64_t e = ns_lookup(fd); if (!e) return 0; struct ns_dev *d = &ns_devs[(e >> 56) & 7]; - if (!ns_dev_get(d)) return 0; + int held = ns_hold(d); + if (!held) return 0; int out = 0, err = (e & NS_E_DEVICE) ? ns_dev_ioctl(d, req, arg, &out) : ns_obj_ioctl(d, e, req, arg); - ns_dev_put(d); + if (held == 2) ns_dev_put(d); if (err) { errno = err; *rc = -1; diff --git a/tools/linuxfs/preload/robust.c b/tools/linuxfs/preload/robust.c index 81bf5d8cc..e156e82c5 100644 --- a/tools/linuxfs/preload/robust.c +++ b/tools/linuxfs/preload/robust.c @@ -329,6 +329,8 @@ static int execveat_is_execve(const long *args) { return path[0] == '/' || (int)args[0] == AT_FDCWD; } +int bl_writewatch_syscall(long number, const long *args, long (*real)(long, ...), long *rc) __attribute__((visibility("hidden"))); + long syscall(long number, ...) { va_list ap; long args[6]; @@ -354,6 +356,9 @@ long syscall(long number, ...) { if (!real_syscall) { real_syscall = (long (*)(long, ...)) dlsym(RTLD_NEXT, "syscall"); } + if (bl_writewatch_syscall(number, args, real_syscall, &handled)) { + return handled; + } if (number == SYS_openat2) { if (!__atomic_load_n(&openat2_missing, __ATOMIC_RELAXED)) { long fd = real_syscall(number, args[0], args[1], args[2], args[3]); diff --git a/tools/linuxfs/preload/rptouch.c b/tools/linuxfs/preload/rptouch.c index 3d6f584ed..7d39f286e 100644 --- a/tools/linuxfs/preload/rptouch.c +++ b/tools/linuxfs/preload/rptouch.c @@ -51,9 +51,16 @@ static void rt_send(void *dpy, uint32_t action) { } } +static void *rt_xtst(const char *name) { + void *fn = dlsym(RTLD_NEXT, name); + if (fn) return fn; + void *lib = dlopen("libXtst.so.6", RTLD_LAZY | RTLD_NOLOAD); + return lib ? dlsym(lib, name) : NULL; +} + int XTestFakeMotionEvent(void *dpy, int screen, int x, int y, unsigned long delay) { static int (*real)(void *, int, int, int, unsigned long); - if (!real) real = (int (*)(void *, int, int, int, unsigned long))dlsym(RTLD_NEXT, "XTestFakeMotionEvent"); + if (!real) real = (int (*)(void *, int, int, int, unsigned long))rt_xtst("XTestFakeMotionEvent"); if (rt_enabled()) { pthread_mutex_lock(&rt_lock); rt_x = x; @@ -69,7 +76,7 @@ int XTestFakeMotionEvent(void *dpy, int screen, int x, int y, unsigned long dela int XTestFakeButtonEvent(void *dpy, unsigned int button, int press, unsigned long delay) { static int (*real)(void *, unsigned int, int, unsigned long); - if (!real) real = (int (*)(void *, unsigned int, int, unsigned long))dlsym(RTLD_NEXT, "XTestFakeButtonEvent"); + if (!real) real = (int (*)(void *, unsigned int, int, unsigned long))rt_xtst("XTestFakeButtonEvent"); if (button == 1) { pthread_mutex_lock(&rt_lock); int handled = 0; diff --git a/tools/linuxfs/preload/tracer.c b/tools/linuxfs/preload/tracer.c index 7d8c741c4..572cf13e0 100644 --- a/tools/linuxfs/preload/tracer.c +++ b/tools/linuxfs/preload/tracer.c @@ -16,6 +16,8 @@ #define STATUS_MAX 16384 #define TRACER_FIELD "\nTracerPid:\t" +int bl_from_ntdll(const void *caller) __attribute__((visibility("hidden"))); + static const char *skip_digits(const char *s) { const char *end = s; while (isdigit((unsigned char)*end)) end++; @@ -77,7 +79,8 @@ __attribute__((visibility("hidden"))) int bl_status_without_tracer(const char *p int out = -1; long pid; - if ((flags & O_ACCMODE) != O_RDONLY || (pid = status_pid(path)) < 0 || is_web_helper(caller)) return -1; + if ((flags & O_ACCMODE) != O_RDONLY || (pid = status_pid(path)) < 0 || is_web_helper(caller) || bl_from_ntdll(caller)) + return -1; int in = (int)syscall(SYS_openat, AT_FDCWD, path, O_RDONLY | O_CLOEXEC); if (in < 0) goto done; for (;;) { diff --git a/tools/linuxfs/preload/usr2.c b/tools/linuxfs/preload/usr2.c new file mode 100644 index 000000000..a7129fdc0 --- /dev/null +++ b/tools/linuxfs/preload/usr2.c @@ -0,0 +1,218 @@ +#define _GNU_SOURCE +#include <dlfcn.h> +#include <link.h> +#include <signal.h> +#include <stddef.h> +#include <stdint.h> +#include <string.h> +#include <sys/auxv.h> +#include <sys/syscall.h> +#include <ucontext.h> +#include <unistd.h> + +static uintptr_t ntdll_lo, ntdll_hi; + +static int find_ntdll(struct dl_phdr_info *info, size_t size, void *data) { + (void)size; + (void)data; + const char *slash = strrchr(info->dlpi_name, '/'); + if (strcmp(slash ? slash + 1 : info->dlpi_name, "ntdll.so") != 0) return 0; + for (int i = 0; i < info->dlpi_phnum; i++) { + const ElfW(Phdr) *ph = &info->dlpi_phdr[i]; + if (ph->p_type != PT_LOAD || !(ph->p_flags & PF_X)) continue; + uintptr_t lo = info->dlpi_addr + ph->p_vaddr; + __atomic_store_n(&ntdll_hi, lo + ph->p_memsz, __ATOMIC_RELAXED); + __atomic_store_n(&ntdll_lo, lo, __ATOMIC_RELEASE); + return 1; + } + return 0; +} + +__attribute__((visibility("hidden"))) int bl_from_ntdll(const void *caller) { + uintptr_t pc = (uintptr_t)caller, lo = __atomic_load_n(&ntdll_lo, __ATOMIC_ACQUIRE); + if (!lo) { + dl_iterate_phdr(find_ntdll, NULL); + lo = __atomic_load_n(&ntdll_lo, __ATOMIC_ACQUIRE); + if (!lo) return 0; + } + return pc >= lo && pc < __atomic_load_n(&ntdll_hi, __ATOMIC_RELAXED); +} + +#if defined(__aarch64__) +#include <asm/hwcap.h> +#include <asm/sigcontext.h> + +#define BL_STR2(x) #x +#define BL_STR(x) BL_STR2(x) + +#define BL_HWCAP_GCS (1UL << 32) +#define BL_FRAME_SIZE 4688 +#define BL_REGS 312 +#define BL_SP 560 +#define BL_PC 568 +#define BL_PSTATE 576 +#define BL_FPSIMD 592 + +struct bl_sigframe { + siginfo_t info; + ucontext_t uc; +}; + +struct bl_kernel_sigaction { + void (*handler)(int, siginfo_t *, void *); + unsigned long flags; + void *restorer; + uint64_t mask; +}; + +struct bl_usr2_call { + void (*handler)(int, siginfo_t *, void *); + uint64_t old_mask; +}; + +_Static_assert(sizeof(struct bl_sigframe) == BL_FRAME_SIZE, "rt_sigframe size"); +_Static_assert(offsetof(struct bl_sigframe, uc.uc_mcontext.regs) == BL_REGS, "regs offset"); +_Static_assert(offsetof(struct bl_sigframe, uc.uc_mcontext.sp) == BL_SP, "sp offset"); +_Static_assert(offsetof(struct bl_sigframe, uc.uc_mcontext.pc) == BL_PC, "pc offset"); +_Static_assert(offsetof(struct bl_sigframe, uc.uc_mcontext.pstate) == BL_PSTATE, "pstate offset"); +_Static_assert(offsetof(struct bl_sigframe, uc.uc_mcontext.__reserved) == BL_FPSIMD, "reserved offset"); +_Static_assert(sizeof(struct fpsimd_context) + sizeof(struct _aarch64_ctx) <= + sizeof(((mcontext_t *)0)->__reserved), "reserved space"); + +__attribute__((visibility("hidden"))) void bl_usr2_frame(struct bl_usr2_call *call); +__attribute__((visibility("hidden"), used)) void bl_usr2_run(struct bl_usr2_call *call, struct bl_sigframe *frame); + +__asm__(".text\n" + ".p2align 2\n" + ".type bl_usr2_frame, %function\n" + "bl_usr2_frame:\n" + " mov x16, #" BL_STR(BL_FRAME_SIZE) "\n" + " sub sp, sp, x16\n" + " add x17, sp, #" BL_STR(BL_REGS) "\n" + " stp x0, x1, [x17, #0]\n" + " stp x2, x3, [x17, #16]\n" + " stp x4, x5, [x17, #32]\n" + " stp x6, x7, [x17, #48]\n" + " stp x8, x9, [x17, #64]\n" + " stp x10, x11, [x17, #80]\n" + " stp x12, x13, [x17, #96]\n" + " stp x14, x15, [x17, #112]\n" + " stp xzr, xzr, [x17, #128]\n" + " stp x18, x19, [x17, #144]\n" + " stp x20, x21, [x17, #160]\n" + " stp x22, x23, [x17, #176]\n" + " stp x24, x25, [x17, #192]\n" + " stp x26, x27, [x17, #208]\n" + " stp x28, x29, [x17, #224]\n" + " str x30, [x17, #240]\n" + " str xzr, [x17, #0]\n" + " add x16, sp, x16\n" + " str x16, [sp, #" BL_STR(BL_SP) "]\n" + " adr x16, 1f\n" + " str x16, [sp, #" BL_STR(BL_PC) "]\n" + " mrs x16, nzcv\n" + " str x16, [sp, #" BL_STR(BL_PSTATE) "]\n" + " add x17, sp, #" BL_STR(BL_FPSIMD) "\n" + " mrs x16, fpsr\n" + " str w16, [x17, #8]\n" + " mrs x16, fpcr\n" + " str w16, [x17, #12]\n" + " stp q0, q1, [x17, #16]\n" + " stp q2, q3, [x17, #48]\n" + " stp q4, q5, [x17, #80]\n" + " stp q6, q7, [x17, #112]\n" + " stp q8, q9, [x17, #144]\n" + " stp q10, q11, [x17, #176]\n" + " stp q12, q13, [x17, #208]\n" + " stp q14, q15, [x17, #240]\n" + " stp q16, q17, [x17, #272]\n" + " stp q18, q19, [x17, #304]\n" + " stp q20, q21, [x17, #336]\n" + " stp q22, q23, [x17, #368]\n" + " stp q24, q25, [x17, #400]\n" + " stp q26, q27, [x17, #432]\n" + " stp q28, q29, [x17, #464]\n" + " stp q30, q31, [x17, #496]\n" + " mov x1, sp\n" + " bl bl_usr2_run\n" + " mov x8, #" BL_STR(__NR_rt_sigreturn) "\n" + " svc #0\n" + " brk #0\n" + "1:\n" + " ret\n" + ".size bl_usr2_frame, .-bl_usr2_frame\n"); + +static int (*usr2_real_raise)(int); + +static long usr2_sys(long nr, long a, long b, long c, long d) { + register long x8 __asm__("x8") = nr; + register long x0 __asm__("x0") = a; + register long x1 __asm__("x1") = b; + register long x2 __asm__("x2") = c; + register long x3 __asm__("x3") = d; + __asm__ volatile("svc #0" : "+r"(x0) : "r"(x8), "r"(x1), "r"(x2), "r"(x3) : "memory"); + return x0; +} + +static uint64_t usr2_ssbs(void) { + uint64_t value = 0; + if (getauxval(AT_HWCAP) & HWCAP_SSBS) __asm__ volatile("mrs %0, s3_3_c4_c2_6" : "=r"(value)); + return value & (1UL << 12); +} + +void bl_usr2_run(struct bl_usr2_call *call, struct bl_sigframe *frame) { + struct fpsimd_context *fp = (struct fpsimd_context *)frame->uc.uc_mcontext.__reserved; + struct _aarch64_ctx *end = (struct _aarch64_ctx *)(fp + 1); + + memset(&frame->info, 0, sizeof(frame->info)); + frame->info.si_signo = SIGUSR2; + frame->info.si_code = SI_TKILL; + frame->info.si_pid = (pid_t)usr2_sys(__NR_getpid, 0, 0, 0, 0); + frame->info.si_uid = (uid_t)usr2_sys(__NR_getuid, 0, 0, 0, 0); + frame->uc.uc_flags = 0; + frame->uc.uc_link = NULL; + if (usr2_sys(__NR_sigaltstack, 0, (long)&frame->uc.uc_stack, 0, 0) != 0) { + memset(&frame->uc.uc_stack, 0, sizeof(frame->uc.uc_stack)); + frame->uc.uc_stack.ss_flags = SS_DISABLE; + } + memset(&frame->uc.uc_sigmask, 0, sizeof(frame->uc.uc_sigmask)); + memcpy(&frame->uc.uc_sigmask, &call->old_mask, sizeof(call->old_mask)); + frame->uc.uc_mcontext.fault_address = 0; + frame->uc.uc_mcontext.pstate |= usr2_ssbs(); + fp->head.magic = FPSIMD_MAGIC; + fp->head.size = sizeof(*fp); + end->magic = 0; + end->size = 0; + call->handler(SIGUSR2, &frame->info, &frame->uc); +} + +int raise(int sig) { + int (*real)(int) = __atomic_load_n(&usr2_real_raise, __ATOMIC_ACQUIRE); + if (!real) { + real = (int (*)(int))dlsym(RTLD_NEXT, "raise"); + __atomic_store_n(&usr2_real_raise, real, __ATOMIC_RELEASE); + } + if (sig != SIGUSR2 || (getauxval(AT_HWCAP) & BL_HWCAP_GCS) || !bl_from_ntdll(__builtin_return_address(0))) { + return real(sig); + } + + struct bl_kernel_sigaction action; + if (usr2_sys(__NR_rt_sigaction, SIGUSR2, 0, (long)&action, sizeof(action.mask)) != 0) return real(sig); + if (!(action.flags & SA_SIGINFO) || (action.flags & SA_RESETHAND) || (uintptr_t)action.handler <= 1) { + return real(sig); + } + + uint64_t bit = 1ULL << (SIGUSR2 - 1); + uint64_t block = action.mask | ((action.flags & SA_NODEFER) ? 0 : bit); + struct bl_usr2_call call = {action.handler, 0}; + if (usr2_sys(__NR_rt_sigprocmask, SIG_BLOCK, (long)&block, (long)&call.old_mask, sizeof(block)) != 0) { + return real(sig); + } + if (call.old_mask & bit) { + usr2_sys(__NR_rt_sigprocmask, SIG_SETMASK, (long)&call.old_mask, 0, sizeof(call.old_mask)); + return real(sig); + } + bl_usr2_frame(&call); + return 0; +} +#endif diff --git a/tools/linuxfs/preload/writewatch.c b/tools/linuxfs/preload/writewatch.c new file mode 100644 index 000000000..b804e3f39 --- /dev/null +++ b/tools/linuxfs/preload/writewatch.c @@ -0,0 +1,113 @@ +#define _GNU_SOURCE +#include <errno.h> +#include <fcntl.h> +#include <linux/fs.h> +#include <linux/userfaultfd.h> +#include <stdint.h> +#include <sys/ioctl.h> +#include <sys/syscall.h> +#include <unistd.h> + +#define WINE_ARM64_USERFAULTFD 374 +#define PM_UFFD_WP (1ULL << 57) +#define PM_SWAP (1ULL << 62) +#define PM_PRESENT (1ULL << 63) + +#if defined(PAGEMAP_SCAN) && defined(UFFD_USER_MODE_ONLY) +static int ww_uffd = -1; +static int scan_blocked; +#endif + +__attribute__((visibility("hidden"))) int bl_writewatch_syscall(long number, const long *args, long (*real)(long, ...), long *rc) { +#if defined(__aarch64__) && defined(PAGEMAP_SCAN) && defined(UFFD_USER_MODE_ONLY) + if (number != WINE_ARM64_USERFAULTFD || (args[0] & ~(long)(O_CLOEXEC | O_NONBLOCK | UFFD_USER_MODE_ONLY))) return 0; + *rc = real(SYS_userfaultfd, args[0]); + if (*rc >= 0) __atomic_store_n(&ww_uffd, (int)*rc, __ATOMIC_RELEASE); + return 1; +#else + (void)number; + (void)args; + (void)real; + (void)rc; + return 0; +#endif +} + +#if defined(PAGEMAP_SCAN) && defined(UFFD_USER_MODE_ONLY) +static int protect(int uffd, uint64_t start, uint64_t end) { + if (start == end) return 0; + struct uffdio_writeprotect wp = { .range = { start, end - start }, .mode = UFFDIO_WRITEPROTECT_MODE_WP }; + return ioctl(uffd, UFFDIO_WRITEPROTECT, &wp); +} + +static long emulate_scan(int fd, int uffd, struct pm_scan_arg *a) { + uint64_t page = (uint64_t)getpagesize(); + if (a->size < sizeof(*a) || (a->flags & ~(uint64_t)PM_SCAN_WP_MATCHING) || a->category_inverted || a->category_anyof_mask + || a->category_mask != PAGE_IS_WRITTEN || (a->return_mask & ~(uint64_t)PAGE_IS_WRITTEN) || a->end < a->start + || ((a->start | a->end) & (page - 1)) || (a->vec_len && !a->vec)) { + errno = EINVAL; + return -1; + } + struct page_region *vec = (struct page_region *)(uintptr_t)a->vec; + uint64_t regions = 0, pages = 0, addr = a->start, wp_start = 0, wp_end = 0; + uint64_t entries[512]; + int wp = (a->flags & PM_SCAN_WP_MATCHING) != 0; + while (addr < a->end) { + uint64_t want = (a->end - addr) / page; + if (want > 512) want = 512; + ssize_t got = pread(fd, entries, want * sizeof(entries[0]), (off_t)(addr / page * sizeof(entries[0]))); + if (got < 0) return -1; + if (got == 0) break; + for (uint64_t i = 0; i < (uint64_t)got / sizeof(entries[0]); i++, addr += page) { + uint64_t e = entries[i]; + if (!(e & (PM_PRESENT | PM_SWAP)) || (e & PM_UFFD_WP)) continue; + if (a->max_pages && pages >= a->max_pages) goto done; + if (a->vec_len) { + if (regions && vec[regions - 1].end == addr) { + vec[regions - 1].end += page; + } else { + if (regions == a->vec_len) goto done; + vec[regions].start = addr; + vec[regions].end = addr + page; + vec[regions].categories = PAGE_IS_WRITTEN & a->return_mask; + regions++; + } + } + pages++; + if (!wp) continue; + if (wp_end != addr) { + if (protect(uffd, wp_start, wp_end) < 0) return -1; + wp_start = addr; + } + wp_end = addr + page; + } + } +done: + if (wp && protect(uffd, wp_start, wp_end) < 0) return -1; + a->walk_end = addr < a->end ? addr : a->end; + return (long)regions; +} +#endif + +__attribute__((visibility("hidden"))) int bl_writewatch_ioctl(int (*real)(int, unsigned long, void *), int fd, unsigned long request, + void *arg, int *rc) { +#if defined(PAGEMAP_SCAN) && defined(UFFD_USER_MODE_ONLY) + if (request != PAGEMAP_SCAN) return 0; + int uffd = __atomic_load_n(&ww_uffd, __ATOMIC_ACQUIRE); + if (uffd < 0) return 0; + if (!__atomic_load_n(&scan_blocked, __ATOMIC_RELAXED)) { + *rc = real(fd, request, arg); + if (*rc >= 0 || errno != EACCES) return 1; + __atomic_store_n(&scan_blocked, 1, __ATOMIC_RELAXED); + } + *rc = (int)emulate_scan(fd, uffd, (struct pm_scan_arg *)arg); + return 1; +#else + (void)real; + (void)fd; + (void)request; + (void)arg; + (void)rc; + return 0; +#endif +} diff --git a/tools/linuxfs/preload/zram.c b/tools/linuxfs/preload/zram.c index 565e0f7dd..8f31d19eb 100644 --- a/tools/linuxfs/preload/zram.c +++ b/tools/linuxfs/preload/zram.c @@ -126,6 +126,25 @@ static void zr_walk(int compress) { zr_sys(SYS_close, maps); } +// Before it stops a session for suspend, the app waits for <ZR_CTL>.ack.<pid> to appear (the handler +// runs here, not one a program installed over it) and then to hold a byte (the page-out finished). +static void zr_ack(int done) { + static const char suffix[] = ".ack."; + char path[sizeof(ZR_CTL) + sizeof(suffix) + 20]; + char digits[20]; + size_t n = 0, d = 0; + for (const char *p = ZR_CTL; *p; p++) path[n++] = *p; + for (const char *p = suffix; *p; p++) path[n++] = *p; + long pid = zr_sys(SYS_getpid); + do digits[d++] = (char)('0' + pid % 10); while ((pid /= 10) > 0); + while (d > 0) path[n++] = digits[--d]; + path[n] = 0; + int fd = (int)zr_sys(SYS_openat, AT_FDCWD, path, O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0600); + if (fd < 0) return; + if (done) zr_sys(SYS_write, fd, "1", 1UL); + zr_sys(SYS_close, fd); +} + static void zr_handler(int sig, siginfo_t *info, void *context) { (void)sig; (void)context; @@ -136,7 +155,9 @@ static void zr_handler(int sig, siginfo_t *info, void *context) { && !__atomic_exchange_n(&zr_busy, 1, __ATOMIC_SEQ_CST)) { __atomic_store_n(&zr_pending, 0, __ATOMIC_SEQ_CST); int mode = zr_mode(); + if (mode == 1) zr_ack(0); if (mode >= 0) zr_walk(mode); + if (mode == 1) zr_ack(1); __atomic_store_n(&zr_busy, 0, __ATOMIC_SEQ_CST); } errno = saved; @@ -148,8 +169,6 @@ static void zr_child(void) { } __attribute__((constructor)) static void zr_init(void) { - const char *name = program_invocation_short_name; - if (strcmp(name, "steam") != 0 && strcmp(name, "steamwebhelper") != 0) return; void *libc = dlopen("libc.so.6", RTLD_LAZY | RTLD_NOLOAD); if (libc == NULL) return; zr_sys = (long (*)(long, ...))dlsym(libc, "syscall"); diff --git a/tools/msitools/packages.txt b/tools/msitools/packages.txt index 69a9dedba..3d9637840 100644 --- a/tools/msitools/packages.txt +++ b/tools/msitools/packages.txt @@ -2,4 +2,4 @@ 4028f44d83b2956ba2931d32aee8cceecbd6481e2febf86a3b6bfed69ef403d5 https://github.com/Droid-Deck/DroidDeck/releases/download/msitools-0.106-r1/libgsf-1.14.60-1-aarch64.pkg.tar.xz 3d7bd103313cb36d6cb703a5e583363e56ff255f105bcc0727916d195967976d https://github.com/Droid-Deck/DroidDeck/releases/download/msitools-0.106-r1/gcab-1.6-2-aarch64.pkg.tar.xz 87fe375a22de1519982234ce99f909ae13c06500784baaa1e8d7a0c0be0d14ee https://github.com/Droid-Deck/DroidDeck/releases/download/msitools-0.106-r1/cabextract-1.11-3-aarch64.pkg.tar.xz -e3bc83ac5960a4363f7ce05eb1d50b03d60d627a4a29f9b02c8e0466a5381b1f http://mirror.archlinuxarm.org/aarch64/extra/7zip-26.04-1-aarch64.pkg.tar.xz +e3bc83ac5960a4363f7ce05eb1d50b03d60d627a4a29f9b02c8e0466a5381b1f https://github.com/Droid-Deck/DroidDeck-Components/releases/download/build-dependency-7zip-26.04-r1/7zip-26.04-1-aarch64.pkg.tar.xz diff --git a/tools/packs/build.sh b/tools/packs/build.sh index d06a4c8fb..27611ab0b 100755 --- a/tools/packs/build.sh +++ b/tools/packs/build.sh @@ -25,14 +25,7 @@ trap 'rm -rf "$work"' EXIT sha256() { if command -v sha256sum >/dev/null; then sha256sum "$1" | cut -d' ' -f1; else shasum -a 256 "$1" | cut -d' ' -f1; fi; } fetch() { - local url=$1 expected=$2 dest=$3 - curl -fsSL --retry 4 -o "$dest" "$url" - local actual - actual=$(sha256 "$dest") - if [ "$actual" != "$expected" ]; then - echo "checksum mismatch for $url: $actual" >&2 - exit 1 - fi + bash "$here/fetch-verified.sh" "$@" } pack() { diff --git a/tools/packs/fetch-verified.sh b/tools/packs/fetch-verified.sh new file mode 100644 index 000000000..41bba2819 --- /dev/null +++ b/tools/packs/fetch-verified.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail +url=${1:?URL required} +expected=${2:?SHA-256 required} +destination=${3:?destination required} +[[ "$expected" =~ ^[a-f0-9]{64}$ ]] || { echo 'Invalid download SHA-256' >&2; exit 1; } +cache=${DROIDDECK_PACK_CACHE:-"$HOME/.cache/droiddeck-pack-inputs"} +mkdir -p "$cache" "$(dirname "$destination")" +kept="$cache/$expected" +verify() { + if command -v sha256sum >/dev/null; then + echo "$expected $1" | sha256sum -c - >/dev/null 2>&1 + else + echo "$expected $1" | shasum -a 256 -c - >/dev/null 2>&1 + fi +} +if [ -f "$kept" ] && verify "$kept"; then + echo "Verified pack input cache hit: $expected" +else + part=$(mktemp "$cache/$expected.part.XXXXXX") + trap 'rm -f "$part"' EXIT + curl -fsSL --retry 4 --retry-all-errors --connect-timeout 15 --max-time 180 --retry-max-time 240 \ + -o "$part" "$url" + verify "$part" || { echo "Checksum mismatch for $url" >&2; exit 1; } + if [ -d "$kept" ]; then + rm -rf -- "$kept" + fi + mv -f "$part" "$kept" + echo "Downloaded and verified pack input: $expected" +fi +cp "$kept" "$destination" +verify "$destination" || { echo 'Copied pack input checksum mismatch' >&2; exit 1; } diff --git a/tools/proot/PATCHES.md b/tools/proot/PATCHES.md index f379b4d7d..a0faf4704 100644 --- a/tools/proot/PATCHES.md +++ b/tools/proot/PATCHES.md @@ -124,3 +124,11 @@ Old kernel fixes: by the kernel instead. The original clone flags are preserved. When the kernel supplies a valid ID in the notification, normal setup is unchanged. No timers or background polling are added. Completely missing notifications and ambiguous matches for separate processes are not recovered. + +Development diagnostics: + +- `0018-syscall-handler-profile.patch` - `PROOT_PROFILE=1` enables per-syscall entry/exit handler + counts, wall/CPU time, periodic cumulative JSON totals, and bounded slow-handler samples on + stderr. `profile.h` is copied into the source tree by `build.sh`. It adds no ptrace stops; + handler timing excludes tracee kernel time and scheduling before handler entry. See + `docs/development/proot-performance.md` for the capture and library-override workflow. diff --git a/tools/proot/build.sh b/tools/proot/build.sh index 718190552..5b76dd38f 100755 --- a/tools/proot/build.sh +++ b/tools/proot/build.sh @@ -31,6 +31,7 @@ fetch() { fetch proot.tar.gz "https://github.com/termux/proot/archive/${PROOT_COMMIT}.tar.gz" "$PROOT_SHA256" fetch talloc.tar.gz "https://www.samba.org/ftp/talloc/talloc-${TALLOC_VERSION}.tar.gz" "$TALLOC_SHA256" cp "$HERE/statx_mnt_id.h" "$WORK/proot/src/tracee/statx_mnt_id.h" +cp "$HERE/profile.h" "$WORK/proot/src/syscall/profile.h" for patch in "$HERE"/patches/*.patch; do patch -d "$WORK/proot" -p1 --forward --quiet < "$patch" done diff --git a/tools/proot/fastpath/fastpath.c b/tools/proot/fastpath/fastpath.c index 5056ec41e..bd3fc0d21 100644 --- a/tools/proot/fastpath/fastpath.c +++ b/tools/proot/fastpath/fastpath.c @@ -337,34 +337,73 @@ static int to_guest(const char *h, char *g, size_t size) { return 0; } -static int guest_text(const char *h, char *g) { - int in = h[0] == '/' && under(h, root, rootlen); - for (int i = 0; h[0] == '/' && !in && i < nbinds; i++) { - int live = held(&binds[i]); - if (live < 0) return -1; - in = live && under(h, binds[i].host, binds[i].hlen); +static int host_order[MAX_BINDS + 1], norder, ordered; + +static int contains(const char *a, const char *b) { + size_t la = strlen(a); + return strlen(b) > la && under(b, a, la); +} + +static const char *side_of(int i, int host) { + return host ? (i < 0 ? root : binds[i].host) : (i < 0 ? "/" : binds[i].guest); +} + +static int insort(int *list, int m, int i, int host) { + const char *p = side_of(i, host); + int prev = -1; + for (int j = 0; j < m; j++) { + const char *q = side_of(list[j], host); + if (!strcmp(p, q) && !host) { + list[j] = i; + return m; + } + if (!strcmp(p, q) || contains(p, q)) prev = j; } - if (in) return to_guest(h, g, PATH_MAX); - memcpy(g, h, strlen(h) + 1); + memmove(list + prev + 2, list + prev + 1, (m - prev - 1) * sizeof *list); + list[prev + 1] = i; + return m + 1; +} + +static int order_binds(void) { + if (__atomic_load_n(&ordered, __ATOMIC_ACQUIRE)) return 0; + for (int i = 0; i < nbinds; i++) + if (held(&binds[i]) < 0) return -1; + if (pthread_mutex_trylock(&bind_lock) != 0) return -1; + if (!ordered) { + int pending[MAX_BINDS + 1], m = 1; + pending[0] = -1; + for (int i = 0; i < nbinds; i++) + if (!binds[i].dropped) m = insort(pending, m, i, 0); + norder = 0; + for (int j = m - 1; j >= 0; j--) norder = insort(host_order, norder, pending[j], 1); + __atomic_store_n(&ordered, 1, __ATOMIC_RELEASE); + } + pthread_mutex_unlock(&bind_lock); return 0; } static int host_bind(const char *h) { - int found = -1; - for (int i = 0; i < nbinds; i++) { - int live = held(&binds[i]); - if (live < 0) return -2; - if (!live || !under(h, binds[i].host, binds[i].hlen)) continue; - if (found < 0 || binds[i].hlen > binds[found].hlen) found = i; - else if (binds[i].hlen == binds[found].hlen && strcmp(binds[i].guest, binds[found].guest) != 0) found = -3; - } - return found == -3 ? -2 : found; + if (order_binds() < 0) return -2; + for (int j = 0; j < norder; j++) + if (host_order[j] >= 0 && under(h, binds[host_order[j]].host, binds[host_order[j]].hlen)) return host_order[j]; + return -1; +} + +static int guest_text(const char *h, char *g) { + if (h[0] == '/' && under(h, root, rootlen)) return to_guest(h, g, PATH_MAX); + int i = h[0] == '/' ? host_bind(h) : -1; + if (i == -2) return -1; + if (i >= 0) + return snprintf(g, PATH_MAX, "%s%s", binds[i].guest, h + binds[i].hlen) >= PATH_MAX || reserved(g) ? -1 : 0; + memcpy(g, h, strlen(h) + 1); + return 0; } static long detranslated(char *text, const char *at) { if (text[0] != '/') return 0; if (!under(at, root, rootlen) && !under(text, root, rootlen)) { int own = host_bind(at), to = host_bind(text); + if (to == -1) return 0; if (own == -2 || to == -2) return FP_SLOW; if (own >= 0 && to >= 0 && strcmp(binds[own].host, binds[to].host) == 0) { const struct bind *bd = &binds[to]; @@ -635,8 +674,9 @@ static long resolve_in(const char *base, const char *path, int follow, struct re char norm[PATH_MAX]; size_t o = 0; int dotdot = 0; - size_t plen = strlen(path); - int trailing = plen > 1 && path[plen - 1] == '/'; + size_t plen = strlen(path), end = plen; + while (end > 1 && path[end - 1] == '/') end--; + int trailing = (plen > 1 && path[plen - 1] == '/') || (end > 0 && path[end - 1] == '.' && (end == 1 || path[end - 2] == '/')); for (int part = base ? 0 : 1; part < 2; part++) { for (const char *c = part ? path : base; *c;) { while (*c == '/') c++; @@ -835,19 +875,84 @@ int __fxstat(int ver, int fd, struct stat *st) { } int __fxstat64(int ver, int fd, struct stat64 *st) { return __fxstat(ver, fd, (struct stat *)st); } +#ifndef STATX__RESERVED +#define STATX__RESERVED 0x80000000U +#endif + +static int statx_missing; + +static unsigned int dev_major(unsigned long long dev) { + return ((dev >> 8) & 0xfff) | ((unsigned int)(dev >> 32) & ~0xfffU); +} +static unsigned int dev_minor(unsigned long long dev) { + return (dev & 0xff) | ((unsigned int)(dev >> 12) & ~0xffU); +} + +static void fill_statx(struct statx *out, const struct stat *st) { + memset(out, 0, sizeof *out); + out->stx_mask = STATX_BASIC_STATS; + out->stx_blksize = st->st_blksize; + out->stx_nlink = st->st_nlink; + out->stx_uid = st->st_uid; + out->stx_gid = st->st_gid; + out->stx_mode = st->st_mode; + out->stx_ino = st->st_ino; + out->stx_size = st->st_size; + out->stx_blocks = st->st_blocks; + out->stx_atime.tv_sec = st->st_atim.tv_sec; + out->stx_atime.tv_nsec = st->st_atim.tv_nsec; + out->stx_ctime.tv_sec = st->st_ctim.tv_sec; + out->stx_ctime.tv_nsec = st->st_ctim.tv_nsec; + out->stx_mtime.tv_sec = st->st_mtim.tv_sec; + out->stx_mtime.tv_nsec = st->st_mtim.tv_nsec; + out->stx_rdev_major = dev_major(st->st_rdev); + out->stx_rdev_minor = dev_minor(st->st_rdev); + out->stx_dev_major = dev_major(st->st_dev); + out->stx_dev_minor = dev_minor(st->st_dev); +} + +/* Emulate statx on older kernels < 4.11 */ +static long statx_call(int dirfd, const char *path, int flags, unsigned mask, struct statx *out) { + struct stat st; + long rs; + if ((flags & ~(AT_EMPTY_PATH | AT_SYMLINK_NOFOLLOW | AT_NO_AUTOMOUNT | AT_STATX_SYNC_TYPE)) || + (flags & AT_STATX_SYNC_TYPE) == AT_STATX_SYNC_TYPE) + return -EINVAL; + if (!statx_missing) { + rs = sc(SYS_statx, dirfd, (long)path, flags, mask, (long)out); + if (rs != -ENOSYS) return rs; + statx_missing = 1; + } + if (flags & AT_STATX_SYNC_TYPE) return -ENOSYS; + rs = sc(SYS_newfstatat, dirfd, (long)path, (long)&st, + flags & (AT_EMPTY_PATH | AT_SYMLINK_NOFOLLOW | AT_NO_AUTOMOUNT), 0); + if (rs < 0) return rs; + fill_statx(out, &st); + return 0; +} + int statx(int dirfd, const char *path, int flags, unsigned mask, struct statx *out) { REAL(statx, int (*)(int, const char *, int, unsigned, struct statx *)); struct res r; long rs; + if (mask & STATX__RESERVED) { errno = EINVAL; return -1; } +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wnonnull-compare" + if (!out) { errno = EFAULT; return -1; } + if (!path) { + if (!(flags & AT_EMPTY_PATH)) { errno = EFAULT; return -1; } + return real_statx ? real_statx(dirfd, path, flags, mask, out) : ret(sc(SYS_statx, dirfd, (long)path, flags, mask, (long)out)); + } +#pragma GCC diagnostic pop if ((flags & AT_EMPTY_PATH) && !path[0] && fp_ready()) { hits++; - return ret(sc(SYS_statx, dirfd, (long)"", flags, mask, (long)out)); + return ret(statx_call(dirfd, "", flags, mask, out)); } rs = (flags & ~(AT_SYMLINK_NOFOLLOW | AT_NO_AUTOMOUNT | AT_STATX_SYNC_TYPE)) ? FP_SLOW : resolve(dirfd, path, (flags & AT_SYMLINK_NOFOLLOW ? 0 : 1) | MAY_BE_UNSEEN, &r); if (rs == 1) rs = -ENOENT; if (rs == 0 || rs == UNSEEN) - rs = sc(SYS_statx, AT_FDCWD, (long)r.host, flags | (rs == 0 && !r.magic ? AT_SYMLINK_NOFOLLOW : 0), mask, (long)out); + rs = statx_call(AT_FDCWD, r.host, flags | (rs == 0 && !r.magic ? AT_SYMLINK_NOFOLLOW : 0), mask, out); ANSWER(rs, dirfd, path, real_statx(dirfd, path, flags, mask, out)); } diff --git a/tools/proot/patches/0018-syscall-handler-profile.patch b/tools/proot/patches/0018-syscall-handler-profile.patch new file mode 100644 index 000000000..20d4859c5 --- /dev/null +++ b/tools/proot/patches/0018-syscall-handler-profile.patch @@ -0,0 +1,38 @@ +--- a/src/syscall/syscall.c ++++ b/src/syscall/syscall.c +@@ -32,6 +32,7 @@ + #include "tracee/reg.h" + #include "tracee/mem.h" + #include "cli/note.h" ++#include "syscall/profile.h" + + /** + * Copy in @path a C string (PATH_MAX bytes max.) from the @tracee's +@@ -100,7 +101,7 @@ + return set_sysarg_data(tracee, path, strlen(path) + 1, reg); + } + +-void translate_syscall(Tracee *tracee) ++static void translate_syscall_impl(Tracee *tracee) + { + const bool is_enter_stage = IS_IN_SYSENTER(tracee); + int status; +@@ -283,3 +284,18 @@ + else + print_current_regs(tracee, 4, "sysexit end"); + } ++ ++void translate_syscall(Tracee *tracee) ++{ ++ if (!dd_profile_enabled()) { ++ translate_syscall_impl(tracee); ++ return; ++ } ++ int saved = errno; ++ const bool entering = IS_IN_SYSENTER(tracee); ++ uint64_t wall = dd_profile_clock(CLOCK_MONOTONIC); ++ uint64_t cpu = dd_profile_clock(CLOCK_THREAD_CPUTIME_ID); ++ errno = saved; ++ translate_syscall_impl(tracee); ++ dd_profile_record(get_sysnum(tracee, ORIGINAL), entering, tracee->pid, wall, cpu); ++} diff --git a/tools/proot/profile.h b/tools/proot/profile.h new file mode 100644 index 000000000..71173b31e --- /dev/null +++ b/tools/proot/profile.h @@ -0,0 +1,99 @@ +/* SPDX-License-Identifier: GPL-2.0-or-later */ +#ifndef DROIDDECK_PROOT_PROFILE_H +#define DROIDDECK_PROOT_PROFILE_H + +#include <errno.h> +#include <inttypes.h> +#include <stdint.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <time.h> +#include <unistd.h> + +struct dd_profile_counter { + uint64_t calls, wall_ns, cpu_ns, max_ns; +}; +static struct dd_profile_counter dd_profile_counts[PR_NB_SYSNUM][2]; +static int dd_profile_on = -1; +static uint64_t dd_profile_id, dd_profile_next, dd_profile_slow_ns = 1000000; +static unsigned dd_profile_samples; + +static uint64_t dd_profile_clock(clockid_t clock) +{ + struct timespec ts; + if (clock_gettime(clock, &ts) != 0) return 0; + return (uint64_t)ts.tv_sec * 1000000000 + ts.tv_nsec; +} + +static void dd_profile_flush(void) +{ + int saved = errno; + for (unsigned nr = 0; nr < PR_NB_SYSNUM; nr++) { + for (unsigned stage = 0; stage < 2; stage++) { + const struct dd_profile_counter *c = &dd_profile_counts[nr][stage]; + if (!c->calls) continue; + fprintf(stderr, "proot-profile {\"type\":\"total\",\"tracer\":%d,\"id\":%" PRIu64 + ",\"syscall\":\"%s\",\"stage\":\"%s\",\"calls\":%" PRIu64 + ",\"wall_ns\":%" PRIu64 ",\"cpu_ns\":%" PRIu64 ",\"max_ns\":%" PRIu64 "}\n", + getpid(), dd_profile_id, stringify_sysnum(nr), stage ? "exit" : "enter", + c->calls, c->wall_ns, c->cpu_ns, c->max_ns); + } + } + errno = saved; +} + +static int dd_profile_enabled(void) +{ + if (dd_profile_on >= 0) return dd_profile_on; + int saved = errno; + const char *value = getenv("PROOT_PROFILE"); + dd_profile_on = value && strcmp(value, "1") == 0; + if (dd_profile_on) { + value = getenv("PROOT_PROFILE_SLOW_US"); + if (value && *value) { + char *end; + unsigned long long us = strtoull(value, &end, 10); + if (!*end && us > 0 && us <= 1000000000ULL) dd_profile_slow_ns = us * 1000; + } + dd_profile_id = dd_profile_clock(CLOCK_MONOTONIC); + dd_profile_next = dd_profile_id + 5000000000ULL; + atexit(dd_profile_flush); + fprintf(stderr, "proot-profile {\"type\":\"start\",\"tracer\":%d,\"id\":%" PRIu64 + ",\"slow_ns\":%" PRIu64 ",\"sample_limit\":32,\"sample_window_ms\":5000,\"scope\":\"syscall-handler\"}\n", + getpid(), dd_profile_id, dd_profile_slow_ns); + } + errno = saved; + return dd_profile_on; +} + +static void dd_profile_record(unsigned nr, int entering, pid_t pid, + uint64_t start_wall, uint64_t start_cpu) +{ + int saved = errno; + uint64_t cpu = dd_profile_clock(CLOCK_THREAD_CPUTIME_ID); + uint64_t wall = dd_profile_clock(CLOCK_MONOTONIC); + if (nr >= PR_NB_SYSNUM) nr = PR_void; + if (!start_wall || !start_cpu || wall < start_wall || cpu < start_cpu) goto done; + uint64_t elapsed = wall - start_wall, used = cpu - start_cpu; + struct dd_profile_counter *c = &dd_profile_counts[nr][!entering]; + c->calls++; + c->wall_ns += elapsed; + c->cpu_ns += used; + if (elapsed > c->max_ns) c->max_ns = elapsed; + if (wall >= dd_profile_next) { + dd_profile_flush(); + dd_profile_next = wall + 5000000000ULL; + dd_profile_samples = 0; + } + if (elapsed >= dd_profile_slow_ns && dd_profile_samples < 32) { + dd_profile_samples++; + fprintf(stderr, "proot-profile {\"type\":\"slow\",\"tracer\":%d,\"id\":%" PRIu64 + ",\"pid\":%d,\"syscall\":\"%s\",\"stage\":\"%s\",\"wall_ns\":%" PRIu64 + ",\"cpu_ns\":%" PRIu64 "}\n", getpid(), dd_profile_id, pid, + stringify_sysnum(nr), entering ? "enter" : "exit", elapsed, used); + } + done: + errno = saved; +} +#endif diff --git a/tools/proot_profile.py b/tools/proot_profile.py new file mode 100644 index 000000000..bfe4418f4 --- /dev/null +++ b/tools/proot_profile.py @@ -0,0 +1,56 @@ +"""Read cumulative PRoot handler counters from a session log without double counting snapshots.""" +from __future__ import annotations + +import json +from collections import deque +from pathlib import Path + +PREFIX = "proot-profile " + + +def report(path: Path, sort: str = "wall", limit: int = 20) -> dict: + latest = {} + starts = set() + samples = deque(maxlen=128) + malformed = 0 + with path.open(errors="replace") as stream: + for line in stream: + if PREFIX not in line: + continue + try: + row = json.loads(line.split(PREFIX, 1)[1]) + identity = (int(row["tracer"]), int(row["id"])) + if row["type"] == "start": + starts.add(identity) + elif row["type"] in {"total", "slow"}: + if row["stage"] not in {"enter", "exit"} or not isinstance(row["syscall"], str): + raise ValueError("invalid syscall/stage") + fields = ("calls", "wall_ns", "cpu_ns", "max_ns") if row["type"] == "total" else ("wall_ns", "cpu_ns", "pid") + if any(type(row[key]) is not int or row[key] < 0 for key in fields): + raise ValueError("invalid counter") + if row["type"] == "total": + latest[identity + (row["syscall"], row["stage"])] = row + else: + samples.append(row) + except (ValueError, TypeError, KeyError): + malformed += 1 + groups = {} + for (_, _, syscall, stage), row in latest.items(): + total = groups.setdefault((syscall, stage), dict(syscall=syscall, stage=stage, calls=0, wall_ns=0, cpu_ns=0, max_ns=0)) + for key in ("calls", "wall_ns", "cpu_ns"): + total[key] += row[key] + total["max_ns"] = max(total["max_ns"], row["max_ns"]) + for row in groups.values(): + row["wallMs"] = round(row["wall_ns"] / 1_000_000, 3) + row["cpuMs"] = round(row["cpu_ns"] / 1_000_000, 3) + row["meanUs"] = round(row["wall_ns"] / max(row["calls"], 1) / 1000, 3) + row["maxUs"] = round(row["max_ns"] / 1000, 3) + rows = sorted(groups.values(), key=lambda row: row[{"wall": "wall_ns", "cpu": "cpu_ns", "calls": "calls"}[sort]], reverse=True) + return dict(ok=True, command="proot-profile", scope="syscall-handler", + note="Handler wall time includes tracer descheduling and filesystem waits; it excludes tracee kernel time and wake-up/queue delay before the handler. Counts are handler visits, including chained calls, not unique syscalls.", + path=str(path), tracers=len(starts | {key[:2] for key in latest}), + handlerCalls=sum(row["calls"] for row in rows), + handlerWallNs=sum(row["wall_ns"] for row in rows), + handlerCpuNs=sum(row["cpu_ns"] for row in rows), + malformedRecords=malformed, totals=rows[:limit], slowSamples=list(samples), + hasCounters=bool(latest)) diff --git a/tools/release/announce-preview.sh b/tools/release/announce-preview.sh new file mode 100644 index 000000000..c4b63e727 --- /dev/null +++ b/tools/release/announce-preview.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +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" diff --git a/tools/release/authorship_hook.py b/tools/release/authorship_hook.py index 3129f7615..82d0842c9 100644 --- a/tools/release/authorship_hook.py +++ b/tools/release/authorship_hook.py @@ -4,7 +4,7 @@ from pathlib import Path import subprocess import sys -from check_authorship import BOT, COAUTHOR, check_revision +from check_authorship import COAUTHOR, check_revision, is_bot def main(): @@ -23,13 +23,13 @@ def main(): return 1 else: identity = subprocess.check_output(["git", "var", "GIT_AUTHOR_IDENT"], text=True) - if BOT.search(identity): + if is_bot(identity): print("Bot commit author is forbidden: " + identity.strip(), file=sys.stderr) return 1 message = Path(args[0]) original = message.read_text(encoding="utf-8") cleaned = "".join(line for line in original.splitlines(keepends=True) - if not (COAUTHOR.match(line) and BOT.search(line))) + if not (COAUTHOR.match(line) and is_bot(line))) if cleaned != original: message.write_text(cleaned, encoding="utf-8") print("Removed bot co-author trailers; human credits preserved.", file=sys.stderr) diff --git a/tools/release/check_authorship.py b/tools/release/check_authorship.py index 9d952acc0..37e2a716a 100644 --- a/tools/release/check_authorship.py +++ b/tools/release/check_authorship.py @@ -6,15 +6,34 @@ import re import subprocess import sys -BOT = re.compile( - r"Claude|Anthropic|OpenAI|ChatGPT|GPT|Copilot|Cursor|Gemini|Codex|Devin|" - r"Windsurf|Codeium|Amazon Q|Tabnine|Cody|\[bot\]|github-actions|" - r"dependabot|renovate(?:\[bot\])?", +PRODUCT = re.compile( + r"(?<![a-z0-9])(?:Anthropic|OpenAI|ChatGPT|GPT(?:-?\d[\w.]*)?|Copilot|Codex|Codeium|Windsurf|" + r"Amazon Q|Tabnine|github-actions|dependabot|renovate)(?![a-z0-9])|\[bot\]", + re.IGNORECASE, +) +# These are also people's names, so they only count alone or followed by a product word. +ASSISTANT = r"(?:Claude|Cursor|Devin|Cody|Gemini)" +ASSISTANT_ALONE = re.compile(ASSISTANT, re.IGNORECASE) +ASSISTANT_PRODUCT = re.compile( + ASSISTANT + r"[\s_-]+(?:Code|Opus|Sonnet|Haiku|Fable|Instant|AI|Agent|Bot|Assistant|CLI|Pro|Flash|Ultra|\d)(?![a-z])", + re.IGNORECASE, +) +BOT_DOMAIN = re.compile( + r"@(?:[\w-]+\.)*(?:anthropic\.com|openai\.com|cursor\.(?:com|sh)|cognition\.ai|sourcegraph\.com|" + r"codeium\.com|tabnine\.com)$", re.IGNORECASE, ) COAUTHOR = re.compile(r"^\s*Co-authored-by\s*:", re.IGNORECASE) +def is_bot(identity): + name, _, rest = identity.partition("<") + email = rest.partition(">")[0].strip() + name = COAUTHOR.sub("", name).strip() + return bool(PRODUCT.search(name) or PRODUCT.search(email) or BOT_DOMAIN.search(email) + or ASSISTANT_ALONE.fullmatch(name) or ASSISTANT_PRODUCT.search(name)) + + def commit_violations(commit, raw): header, separator, message = raw.partition(b"\n\n") if not separator: @@ -24,10 +43,10 @@ def commit_violations(commit, raw): return [f"{commit}: expected one author"] identity = authors[0].decode("utf-8", errors="replace") errors = [] - if BOT.search(identity): + if is_bot(identity): errors.append(f"{commit}: bot author: {identity}") for line in message.decode("utf-8", errors="replace").splitlines(): - if COAUTHOR.match(line) and BOT.search(line): + if COAUTHOR.match(line) and is_bot(line): errors.append(f"{commit}: bot co-author: {line.strip()}") return errors @@ -36,11 +55,11 @@ def event_violations(event): pr = event.get("pull_request", {}) user = pr.get("user", {}) errors = [] - if user.get("type") == "Bot" or BOT.search(user.get("login", "")): + if user.get("type") == "Bot" or is_bot(user.get("login", "")): errors.append("pull request: bot authorship is forbidden") for field in ("title", "body"): for line in (pr.get(field) or "").splitlines(): - if COAUTHOR.match(line) and BOT.search(line): + if COAUTHOR.match(line) and is_bot(line): errors.append(f"pull request {field}: bot co-author trailer") return errors diff --git a/tools/release/describe-build.sh b/tools/release/describe-build.sh new file mode 100644 index 000000000..46bc339ba --- /dev/null +++ b/tools/release/describe-build.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +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 diff --git a/tools/release/publish-ci.sh b/tools/release/publish-ci.sh new file mode 100644 index 000000000..987ef622d --- /dev/null +++ b/tools/release/publish-ci.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +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" diff --git a/tools/release/report-build.sh b/tools/release/report-build.sh new file mode 100644 index 000000000..75b16f666 --- /dev/null +++ b/tools/release/report-build.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +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 "After the parallel regression tests pass, a release-signed tester build that installs over Stable or Preview will be published 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(.user.login == \"github-actions[bot]\" and (.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 diff --git a/tools/release/runtime_inputs.py b/tools/release/runtime_inputs.py index a0ac10bfd..ccce1f0f0 100644 --- a/tools/release/runtime_inputs.py +++ b/tools/release/runtime_inputs.py @@ -21,9 +21,9 @@ PACKED_FILES = {'lib/arm64-v8a/libproot.so': ('proot', 'libproot.so'), PACKED_LIBRARIES = ['lib/arm64-v8a/libopencv_java4.so', 'lib/arm64-v8a/libmediapipe_tasks_jni.so'] PACK_NAMES = ['proot', 'directaudio', 'tracking', 'xbox', 'comet'] SOURCE_PATHS = ['app/build.gradle', 'app/src/main/cpp/fakeinput_steam.cpp', 'app/src/main/assets/pulseaudio.tzst', - 'tools/linuxfs', 'tools/proot', 'tools/aaudio-sink', 'tools/directaudio', + 'tools/linuxfs', 'tools/proot', 'tools/aaudio-sink', 'tools/directaudio', 'tools/packs', 'tools/gamescope/release.env', 'tools/droiddeck-esync/release.env', - 'tools/mangoapp', 'tools/msitools', 'tools/pipewire', 'tools/comet', 'tools/xbox', 'tools/build_local.sh', + 'tools/mangoapp', 'tools/msitools', 'tools/pipewire', 'tools/comet', 'tools/xbox', 'tools/components', 'tools/ci', 'tools/build_local.sh', 'tools/release/runtime_inputs.py', 'app/src/main/assets/desktop-removal', 'tools/quest', 'tools/vr/src', 'tools/vr/driver_droiddeck', 'tools/vr/layer', 'tools/vr/build.sh', 'app/src/main/cpp/waylandcomp/src/headpose_shm.h', diff --git a/tools/tests/test_authorship.py b/tools/tests/test_authorship.py index 03d0003b0..6734eb92d 100644 --- a/tools/tests/test_authorship.py +++ b/tools/tests/test_authorship.py @@ -40,6 +40,27 @@ class AuthorshipTest(unittest.TestCase): def test_bot_pr_author_is_rejected(self): self.assertTrue(check.event_violations({"pull_request": {"user": {"login": "service", "type": "Bot"}}})) + def test_people_named_like_assistants_are_not_bots(self): + for author in ("Claudemirovsky <63046606+Claudemirovsky@users.noreply.github.com>", + "Claude Monet <claude@example.com>", "Devin Smith <devin@example.com>", + "Cody Allen <cody@example.com>", "Gemini Patel <gp@example.com>"): + with self.subTest(author=author): + self.assertEqual([], check.commit_violations("abc", self.commit(author=author))) + self.assertEqual([], check.event_violations({"pull_request": {"user": {"login": "Claudemirovsky", "type": "User"}}})) + + def test_assistant_identities_are_still_bots(self): + for author in ("Claude Opus 5.5 <noreply@anthropic.com>", "Claude Code <cc@example.com>", + "Cursor Agent <cursoragent@cursor.com>", + "Devin AI <devin-ai-integration[bot]@users.noreply.github.com>", + "Gemini <gemini@example.com>", "GPT-5 <a@example.com>"): + with self.subTest(author=author): + self.assertTrue(check.commit_violations("abc", self.commit(author=author))) + for login in ("chatgpt-codex-connector", "Copilot", "claude"): + with self.subTest(login=login): + self.assertTrue(check.event_violations({"pull_request": {"user": {"login": login, "type": "User"}}})) + message = "Fix\n\nCo-authored-by: Claude Opus 5.5 <noreply@anthropic.com>\n" + self.assertEqual(1, len(check.commit_violations("abc", self.commit(message=message)))) + def test_missing_author_fails_closed(self): self.assertTrue(check.commit_violations("abc", b"tree 000\n\nmessage\n")) diff --git a/tools/tests/test_battlenet.py b/tools/tests/test_battlenet.py new file mode 100644 index 000000000..fe5aed720 --- /dev/null +++ b/tools/tests/test_battlenet.py @@ -0,0 +1,1041 @@ +import json +import os +from pathlib import Path +import re +import runpy +import shutil +import tempfile +import threading +import time +import unittest +from unittest import mock +import zlib + +REPO = Path(__file__).resolve().parents[2] +BIN = REPO / "tools/linuxfs/overlay/usr/local/bin" +BNET = runpy.run_path(str(BIN / "droiddeck-battlenet"))["main"].__globals__ +BNET["CACHE"] = tempfile.mkdtemp(prefix="bnet-test-cache-") +BNET["LOG"] = os.path.join(BNET["CACHE"], "battlenet.log") +RUN = runpy.run_path(str(BIN / "droiddeck-proton-run"))["main"].__globals__ +STORE = runpy.run_path(str(BIN / "droiddeck-store-launch"))["main"].__globals__ +KOTLIN = REPO / "app/src/main/java/com/droiddeck/launcher/runtime/BattleNet.kt" + + +class Temp(unittest.TestCase): + def setUp(self): + self.tmp = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, self.tmp, True) + + def write(self, relative, data, mode=0o644): + path = self.tmp / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data if isinstance(data, bytes) else data.encode()) + path.chmod(mode) + return path + + +class LogTest(Temp): + def test_each_line_is_kept_and_a_long_log_rotates(self): + log = self.tmp / "battlenet.log" + with open(os.devnull, "w") as null, \ + mock.patch.dict(BNET, {"CACHE": str(self.tmp), "LOG": str(log), "LOG_LIMIT": 64}), \ + mock.patch.object(BNET["sys"], "stderr", null): + BNET["say"]("first line, long enough to pass the limit of sixty-four bytes") + BNET["say"]("second") + self.assertIn("first line", (self.tmp / "battlenet.log.1").read_text()) + self.assertRegex(log.read_text(), r"^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d \[\d+\] second\n$") + + +class IdTest(unittest.TestCase): + def test_the_prefix_id_is_the_shortcut_id_the_app_names(self): + self.assertEqual(int(BNET["APP_ID"]), zlib.crc32(b'"droiddeck-battlenet"Battle.net') | 0x80000000) + found = re.search(r"const val APP_ID = (\d+)L", KOTLIN.read_text()) + self.assertEqual(found.group(1), BNET["APP_ID"]) + for name in ("FEX_REQUEST", "FEX_RESULT"): + self.assertIn('const val %s = "%s"' % (name, BNET[name]), KOTLIN.read_text()) + + +class ProtonRunOptionsTest(Temp): + def setUp(self): + super().setUp() + self.root = self.tmp / "Steam" + folder = self.root / "compatibilitytools.d/droiddeck-proton-arm64" + self.write("Steam/compatibilitytools.d/droiddeck-proton-arm64/compatibilitytool.vdf", + '"compatibilitytools"\n{\n "compat_tools"\n {\n "droiddeck-proton-arm64"\n {\n' + ' "install_path" "."\n "display_name" "x"\n "from_oslist" "windows"\n' + ' "to_oslist" "linux"\n }\n }\n}\n') + self.write("Steam/compatibilitytools.d/droiddeck-proton-arm64/toolmanifest.vdf", + '"manifest"\n{\n "commandline" "/droiddeck-proton %verb%"\n}\n') + self.write("Steam/compatibilitytools.d/droiddeck-proton-arm64/droiddeck-proton", b"#!/bin/sh\n", 0o755) + self.entry = str(folder / "droiddeck-proton") + patcher = mock.patch.dict(RUN, {"EXTRA_LIBRARIES": (), "SYSTEM_TOOLS": (), "STATE": str(self.tmp / "state.json"), + "say": lambda message: None}) + patcher.start() + self.addCleanup(patcher.stop) + + def run_main(self, argv): + calls = [] + with mock.patch.dict(os.environ, {"STEAM_COMPAT_CLIENT_INSTALL_PATH": str(self.root)}), \ + mock.patch.object(RUN["os"], "execve", lambda *a: calls.append(a)), mock.patch.object(RUN["os"], "chdir"): + status = RUN["main"](argv) + return status, calls + + def test_a_named_prefix_wins_over_where_the_program_lies(self): + setup = str(self.write("Download/Battle.net-Setup.exe", b"MZ")) + status, calls = self.run_main(["--window", "--prefix", "2596113170", setup]) + self.assertIsNone(status) + entry, argv, env = calls[0] + self.assertEqual(argv, [self.entry, "waitforexitandrun", setup]) + self.assertEqual(env["STEAM_COMPAT_DATA_PATH"], str(self.root / "steamapps/compatdata/2596113170")) + self.assertEqual((env["SteamAppId"], env["SteamGameId"], env["PROTON_USE_WINED3D"]), + ("0", str(2596113170 << 32 | 0x02000000), "1")) + self.assertTrue((self.root / "steamapps/compatdata/2596113170").is_dir()) + + def test_run_starts_beside_what_runs_in_the_prefix(self): + client = str(self.write("Steam/steamapps/compatdata/2596113170/pfx/drive_c/Battle.net.exe", b"MZ")) + _, calls = self.run_main(["--verb", "run", client]) + self.assertEqual(calls[0][1], [self.entry, "run", client]) + + def test_a_launch_from_steam_keeps_its_game_id_for_gamescope(self): + client = str(self.write("Steam/steamapps/compatdata/2596113170/pfx/drive_c/Battle.net.exe", b"MZ")) + _, calls = self.run_main(["--prefix", "2596113170", "--game-id", "11151176786366644224", client]) + env = calls[0][2] + self.assertEqual((env["SteamGameId"], env["SteamOverlayGameId"]), ("11151176786366644224", "11151176786366644224")) + self.assertEqual(env["STEAM_COMPAT_DATA_PATH"], str(self.root / "steamapps/compatdata/2596113170")) + self.assertNotIn("PROTON_USE_WINED3D", env) + self.assertEqual(self.run_main(["--game-id", "0", client]), (2, [])) + + def test_start_without_steam_has_droiddeck_battlenet_launch_a_games_shortcut(self): + client = str(self.write("Steam/steamapps/compatdata/2596113170/pfx/drive_c/Program Files (x86)/Battle.net/Battle.net.exe", b"MZ")) + prefix = str(self.root / "steamapps/compatdata/2596113170") + options = 'STEAM_COMPAT_DATA_PATH=%s %%command%% --exec="launch Fen"' % prefix + status, calls = self.run_main(["--app", "3000000000", "--dir", os.path.dirname(client), "--options", options, "--", client]) + self.assertIsNone(status) + _, argv, env = calls[0] + self.assertEqual(argv, [self.entry, "waitforexitandrun", client, "--exec=launch Fen"]) + self.assertEqual(env["STEAM_COMPAT_DATA_PATH"], prefix) + self.assertEqual(STORE["battlenet"](argv[2:], env={}), [STORE["BATTLENET"], "launch", "Fen"]) + + def test_a_bad_prefix_or_verb_is_refused(self): + setup = str(self.write("Download/setup.exe", b"MZ")) + for argv in (["--prefix", "0", setup], ["--prefix", "07", setup], ["--prefix", "4294967296", setup], + ["--prefix", "abc", setup], ["--prefix"], ["--verb", "getcompatpath", setup]): + with self.subTest(argv): + self.assertEqual(self.run_main(list(argv)), (2, [])) + + +class ConfigAndMenuTest(Temp): + def config_path(self): + return self.tmp / ("steamapps/compatdata/%s/pfx/drive_c/users/steamuser/AppData/Roaming/Battle.net/Battle.net.config" + % BNET["APP_ID"]) + + def test_the_config_keeps_browser_hardware_acceleration_off(self): + root = str(self.tmp) + self.assertEqual(BNET["write_config"](root), "first") + path = self.config_path() + config = json.loads(path.read_text()) + self.assertEqual(config["Client"]["HardwareAcceleration"], "false") + self.assertEqual(config["Client"]["Streaming"]["StreamingEnabled"], "false") + self.assertEqual(config["Client"]["GameLaunchWindowBehavior"], "2") + self.assertIsNone(BNET["write_config"](root)) + path.write_text('{"Client": {"HardwareAcceleration": "false", "GameLaunchWindowBehavior": "0"}, "Games": {"osi": {}}}') + self.assertEqual(BNET["write_config"](root), "launch 32-bit") + config = json.loads(path.read_text()) + self.assertEqual(config, {"Client": {"HardwareAcceleration": "false", "GameLaunchWindowBehavior": "2"}, + "Games": {"osi": {}, "s1": {"Run64Bit": "false", "Run64BitSet": "true"}}}) + path.write_text('{"Client": {"HardwareAcceleration": "true", "GameLaunchWindowBehavior": "2"}}') + self.assertEqual(BNET["write_config"](root), "acceleration 32-bit") + self.assertEqual(json.loads(path.read_text())["Client"]["HardwareAcceleration"], "false") + + def test_starcraft_starts_its_32_bit_client_until_a_player_picks_one(self): + root = str(self.tmp) + BNET["write_config"](root) + self.assertEqual(json.loads(self.config_path().read_text())["Games"], {"s1": {"Run64Bit": "false", "Run64BitSet": "true"}}) + self.config_path().write_text('{"Client": {"HardwareAcceleration": "false", "GameLaunchWindowBehavior": "2"}, ' + '"Games": {"s1": {"ServerUid": "s1", "Run64Bit": "true"}}}') + self.assertEqual(BNET["write_config"](root), "32-bit") + self.assertEqual(json.loads(self.config_path().read_text())["Games"]["s1"], + {"ServerUid": "s1", "Run64Bit": "false", "Run64BitSet": "true"}) + self.config_path().write_text('{"Client": {"HardwareAcceleration": "false", "GameLaunchWindowBehavior": "2"}, ' + '"Games": {"s1": {"Run64Bit": "true", "Run64BitSet": "true"}}}') + self.assertIsNone(BNET["write_config"](root)) + self.config_path().write_text('{"Client": {"HardwareAcceleration": "false", "GameLaunchWindowBehavior": "2"}, "Games": []}') + self.assertIsNone(BNET["write_config"](root)) + + def test_games_install_to_the_chosen_library(self): + root = str(self.tmp) + BNET["write_config"](root) + self.assertEqual(BNET["write_config"](root, "/root/Games/Stores/Battle.net"), "library") + config = json.loads(self.config_path().read_text()) + self.assertEqual(config["Client"]["Install"]["DefaultInstallPath"], "Z:/root/Games/Stores/Battle.net") + self.assertIsNone(BNET["write_config"](root, "/root/Games/Stores/Battle.net/")) + config["Client"]["Install"]["DefaultInstallPath"] = "Z:/root/Games/Stores/Battle.net/" + self.config_path().write_text(json.dumps(config)) + self.assertIsNone(BNET["write_config"](root, "/root/Games/Stores/Battle.net")) + self.assertEqual(BNET["write_config"](root, "/mnt/droiddeck-sd/Games/Battle.net"), "library") + + def test_a_config_that_is_not_json_is_left_alone(self): + path = self.config_path() + path.parent.mkdir(parents=True) + for text in ("{not json", "[1, 2]", '{"Client": "x"}'): + with self.subTest(text): + path.write_text(text) + self.assertIsNone(BNET["write_config"](str(self.tmp))) + self.assertEqual(path.read_text(), text) + + def test_the_menu_entry_is_a_window_not_a_game(self): + path = self.tmp / "applications/droiddeck-battlenet.desktop" + BNET["write_menu"](str(path)) + text = path.read_text() + self.assertIn("Exec=/usr/local/bin/droiddeck-battlenet\n", text) + self.assertIn("Name=Battle.net\n", text) + self.assertNotIn("Game", re.search(r"^Categories=(.*)$", text, re.M).group(1)) + self.assertNotIn("StartupWMClass", text) + mtime = path.stat().st_mtime_ns + time.sleep(0.01) + BNET["write_menu"](str(path)) + self.assertEqual(path.stat().st_mtime_ns, mtime) + + +class GamepadTest(Temp): + def registry(self, *entries): + rows = ["WINE REGISTRY Version 2"] + for name, uid, location in entries: + rows += ["", "[Software\\\\Wow6432Node\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\Uninstall\\\\%s] 1" % name, + '"InstallLocation"="%s"' % location.replace("\\", "\\\\"), + '"UninstallString"="\\"C:\\\\ProgramData\\\\Battle.net\\\\Agent\\\\Blizzard Uninstaller.exe\\" --uid=%s"' % uid] + self.write("Steam/steamapps/compatdata/%s/pfx/system.reg" % BNET["APP_ID"], "\n".join(rows) + "\n") + + def z(self, path): + return "Z:" + str(path).replace("/", "\\") + + def test_every_world_of_warcraft_flavor_gets_controllers_on(self): + wow = self.tmp / "Games/World of Warcraft" + retail = self.write("Games/World of Warcraft/_retail_/WTF/Config.wtf", 'SET gxApi "D3D11"') + (wow / "_classic_era_").mkdir(parents=True) + (wow / "Data").mkdir() + self.registry(("World of Warcraft", "wow", self.z(wow))) + with mock.patch.dict(BNET, {"say": lambda message: None}): + self.assertEqual(len(BNET["wow_settings"](str(self.tmp / "Steam"))), 2) + self.assertEqual(BNET["wow_settings"](str(self.tmp / "Steam")), []) + self.assertEqual(retail.read_text(), 'SET gxApi "D3D11"\nSET GamePadEnable "1"\n') + self.assertEqual((wow / "_classic_era_/WTF/Config.wtf").read_text(), 'SET GamePadEnable "1"\nSET gxApi "D3D11"\n') + self.assertFalse((wow / "Data/WTF").exists()) + + def test_directx_11_is_set_once_and_a_later_pick_is_kept(self): + wow = self.tmp / "Games/World of Warcraft" + retail = self.write("Games/World of Warcraft/_retail_/WTF/Config.wtf", 'SET GxApi "D3D12"\nSET GamePadEnable "1"\nSET vsync "1"\n') + self.registry(("World of Warcraft", "wow", self.z(wow))) + with mock.patch.dict(BNET, {"say": lambda message: None}): + self.assertEqual(len(BNET["wow_settings"](str(self.tmp / "Steam"))), 1) + self.assertEqual(retail.read_text(), 'SET gxApi "D3D11"\nSET GamePadEnable "1"\nSET vsync "1"\n') + self.assertTrue((wow / "_retail_/WTF/.droiddeck-d3d11").exists()) + retail.write_text('SET gxApi "d3d12"\nSET GamePadEnable "1"\nSET vsync "1"\n') + self.assertEqual(BNET["wow_settings"](str(self.tmp / "Steam")), []) + self.assertEqual(retail.read_text(), 'SET gxApi "d3d12"\nSET GamePadEnable "1"\nSET vsync "1"\n') + + def test_a_players_own_choice_and_other_games_are_left_alone(self): + wow = self.tmp / "Games/World of Warcraft Classic" + config = self.write("Games/World of Warcraft Classic/_anniversary_/WTF/Config.wtf", 'SET GamePadEnable "0"\n') + other = self.tmp / "Games/Diablo IV" + (other / "_retail_").mkdir(parents=True) + self.registry(("World of Warcraft Classic", "wow_classic_anniversary", self.z(wow)), ("Diablo IV", "fenris", self.z(other))) + with mock.patch.dict(BNET, {"say": lambda message: None}): + self.assertEqual(len(BNET["wow_settings"](str(self.tmp / "Steam"))), 1) + self.assertEqual(config.read_text(), 'SET GamePadEnable "0"\nSET gxApi "D3D11"\n') + self.assertFalse((other / "_retail_/WTF").exists()) + + def test_pads_look_like_an_xbox_360_pad_only_without_the_steam_client(self): + proc = self.tmp / "proc" + self.write("proc/10/cmdline", b"/usr/bin/python3\0/usr/local/bin/droiddeck-battlenet\0") + self.write("proc/11/cmdline", b"c:\\windows\\system32\\steam.exe\0") + self.assertFalse(BNET["steam_running"](str(proc))) + with mock.patch.dict(os.environ, {}, clear=False): + os.environ.pop("PROTON_SPOOF_STEAMINPUT_VIDPID", None) + with mock.patch.dict(BNET, {"steam_running": lambda: False}): + self.assertEqual(BNET["proton_env"]()["PROTON_SPOOF_STEAMINPUT_VIDPID"], "1") + with mock.patch.dict(BNET, {"steam_running": lambda: True}): + self.assertNotIn("PROTON_SPOOF_STEAMINPUT_VIDPID", BNET["proton_env"]()) + os.environ["PROTON_SPOOF_STEAMINPUT_VIDPID"] = "0" + with mock.patch.dict(BNET, {"steam_running": lambda: False}): + self.assertEqual(BNET["proton_env"]()["PROTON_SPOOF_STEAMINPUT_VIDPID"], "0") + self.write("proc/12/cmdline", b"/usr/bin/FEX\0/root/.local/share/Steam/ubuntu12_32/steam\0-silent\0") + self.assertTrue(BNET["steam_running"](str(proc))) + + +class ProcessTest(Temp): + def proc(self, pid, argv, prefix=None): + env = b"HOME=/root\0" + (("WINEPREFIX=%s\0" % prefix).encode() if prefix else b"") + self.write("proc/%d/environ" % pid, env) + self.write("proc/%d/cmdline" % pid, b"\0".join(a.encode() for a in argv) + b"\0") + + def test_the_prefixs_processes_are_found_by_their_wineprefix(self): + pfx = self.tmp / "compatdata/2596113170/pfx" + pfx.mkdir(parents=True) + self.proc(10, ["C:\\Program Files (x86)\\Battle.net\\Battle.net.exe", "--from-launcher"], str(pfx) + "/") + self.proc(11, ["C:\\ProgramData\\Battle.net\\Agent\\Agent.9142\\Agent.exe"], str(pfx)) + self.proc(12, ["C:\\Games\\Other.exe"], str(self.tmp / "compatdata/42/pfx")) + self.proc(13, ["/usr/bin/bash"]) + self.write("proc/self/environ", b"") + found = BNET["wine_processes"](str(pfx), str(self.tmp / "proc")) + self.assertEqual(sorted(pid for pid, _ in found), [10, 11]) + self.assertEqual([pid for pid, argv in found if BNET["is_client"](argv)], [10]) + + def test_only_battle_net_itself_counts_as_open(self): + self.assertTrue(BNET["is_client"](["C:\\Program Files (x86)\\Battle.net\\Battle.net.exe"])) + self.assertTrue(BNET["is_client"](["/root/.local/share/Steam/x/Battle.net.exe", "--x"])) + self.assertTrue(BNET["is_client"](["battle.net.exe"])) + self.assertFalse(BNET["is_client"](["C:\\Program Files (x86)\\Battle.net\\Battle.net Launcher.exe"])) + self.assertFalse(BNET["is_client"](["C:\\users\\steamuser\\Temp\\Battle.net-Setup.exe"])) + self.assertFalse(BNET["is_client"]([])) + + def test_battle_nets_own_processes_are_those_under_its_folders(self): + for argv in (["C:\\Program Files (x86)\\Battle.net\\Battle.net.exe", "--type=renderer"], + ["C:/ProgramData/Battle.net/Agent/Agent.9824/Agent.exe"], + ["C:\\Program Files (x86)\\Battle.net\\Battle.net Launcher.exe"]): + with self.subTest(argv): + self.assertTrue(BNET["is_battlenet"](argv)) + for argv in (["Z:\\root\\.cache\\droiddeck\\battlenet\\Battle.net-Setup.exe"], + ["Z:\\root\\Games\\Stores\\Battle.net\\Hearthstone\\Hearthstone.exe"], + ["C:\\Program Files (x86)\\Diablo IV\\Diablo IV.exe"], ["C:\\windows\\system32\\services.exe"], []): + with self.subTest(argv): + self.assertFalse(BNET["is_battlenet"](argv)) + + def test_a_game_battle_net_launched_carries_its_uid(self): + self.assertTrue(BNET["launched"](["C:\\Program Files (x86)\\Diablo II Resurrected\\D2R.exe", "-uid", "osi"])) + self.assertTrue(BNET["launched"](["C:\\Program Files (x86)\\Diablo IV\\Diablo IV.exe", "-launch", "-UID", "fenris"])) + for argv in (["C:\\Program Files (x86)\\Diablo IV\\Diablo IV.exe"], + ["C:\\Program Files (x86)\\Diablo IV\\vc_redist.x64.exe", "/quiet"], + ["C:\\Program Files (x86)\\Battle.net\\Battle.net.exe", "-uid", "battle.net"]): + with self.subTest(argv): + self.assertFalse(BNET["launched"](argv)) + + def test_a_game_that_signs_in_with_battle_nets_token_says_so(self): + self.assertTrue(BNET["shares_sign_in"](["Diablo IV.exe", "-nostreamline", "-sso", "-launch", "-uid", "fenris"])) + self.assertFalse(BNET["shares_sign_in"](["D2R.exe", "-uid", "osi"])) + self.assertFalse(BNET["shares_sign_in"](["-sso.exe"])) + + def test_battle_net_takes_work_once_its_agent_is_up(self): + client = (1, ["C:\\Program Files (x86)\\Battle.net\\Battle.net.exe"]) + agent = (2, ["C:\\ProgramData\\Battle.net\\Agent\\Agent.9824\\Agent.exe"]) + self.assertTrue(BNET["agent_up"]([client, agent])) + self.assertFalse(BNET["agent_up"]([client])) + self.assertFalse(BNET["agent_up"]([agent])) + + def test_open_is_waited_for_then_closed(self): + states = iter([False, False, True, True, False]) + with mock.patch.dict(BNET, {"running": lambda pfx: next(states)}), mock.patch.object(BNET["time"], "sleep"): + self.assertTrue(BNET["wait_while_open"]("/pfx", grace=60, poll=0)) + with mock.patch.dict(BNET, {"running": lambda pfx: False}): + self.assertFalse(BNET["wait_while_open"]("/pfx", grace=0, poll=0)) + + +class ProtonWaitTest(Temp): + class Process: + def __init__(self, test, waits): + self.test, self.waits = test, waits + + def wait(self, timeout=None): + self.waits -= 1 + if self.test.stopped or self.waits < 0: + return 0 + raise BNET["subprocess"].TimeoutExpired("proton", timeout) + + def setUp(self): + super().setUp() + self.stopped = [] + + def run_proton(self, states, close_grace=0, waits=100): + states = iter(states) + last = [] + + def processes(pfx, proc="/proc"): + last[:] = next(states, last) + return list(last) + + with mock.patch.dict(BNET, {"wine_processes": processes, "say": lambda message: None, + "stop": lambda found, wait=0: self.stopped.extend(pid for pid, _ in found)}), \ + mock.patch.object(BNET["subprocess"], "Popen", lambda cmd, **kw: self.Process(self, waits)): + return BNET["proton"]("launcher.exe", watch="/pfx", poll=0, close_grace=close_grace) + + def test_agent_and_wine_left_behind_are_stopped_once_battle_net_closed(self): + client = (1, ["C:\\Program Files (x86)\\Battle.net\\Battle.net.exe"]) + agent = (2, ["C:/ProgramData/Battle.net/Agent/Agent.9824/Agent.exe", "--locale=enUS"]) + wine = (3, ["C:\\windows\\system32\\services.exe"]) + self.assertEqual(self.run_proton([[client, agent, wine], [agent, wine], [agent, wine]]), (0, True)) + self.assertEqual(sorted(self.stopped), [2, 3]) + + def test_nothing_is_stopped_while_a_game_still_runs(self): + client = (1, ["Battle.net.exe"]) + game = (4, ["C:\\Games\\Diablo IV\\Diablo IV.exe"]) + self.assertEqual(self.run_proton([[client, game]] + [[game]] * 5, waits=6), (0, True)) + self.assertEqual(self.stopped, []) + + def test_a_restart_to_update_is_not_a_close(self): + client = (1, ["Battle.net.exe"]) + agent = (2, ["Agent.exe"]) + states = [[client, agent], [agent], [client, agent], [agent]] + with mock.patch.object(BNET["time"], "monotonic", side_effect=[0, 0, 5, 5, 10, 10, 30, 30, 30, 30]): + self.run_proton(states, close_grace=15) + self.assertEqual(self.stopped, [2]) + + def test_battle_net_is_closed_once_a_game_is_up_and_what_is_left_once_it_exits(self): + client = (1, ["C:\\Program Files (x86)\\Battle.net\\Battle.net.exe"]) + agent = (2, ["C:\\ProgramData\\Battle.net\\Agent\\Agent.9824\\Agent.exe"]) + wine = (3, ["C:\\windows\\system32\\services.exe"]) + game = (4, ["C:\\Program Files (x86)\\Diablo IV\\Diablo IV.exe", "-launch", "-uid", "fenris"]) + tidy = BNET["Tidy"](settle=0, gone=60) + states = [[client, agent, wine], [client, agent, wine, game], [wine, game], [wine, game], [wine], [wine]] + with mock.patch.dict(BNET, {"notify": lambda *a, **k: self.fail("notified")}): + self.assertEqual(self.run_proton_tidy(states, tidy), (0, True)) + self.assertEqual(self.stopped, [1, 2, 3]) + + def run_proton_tidy(self, states, tidy): + states = iter(states) + last = [] + test = self + + def processes(pfx, proc="/proc"): + last[:] = next(states, last) + return list(last) + + class Process: + def wait(self, timeout=None): + if 3 in test.stopped: + return 0 + raise BNET["subprocess"].TimeoutExpired("proton", timeout) + + with mock.patch.dict(BNET, {"wine_processes": processes, "say": lambda message: None, + "stop": lambda found, wait=0: self.stopped.extend(pid for pid, _ in found)}), \ + mock.patch.object(BNET["subprocess"], "Popen", lambda cmd, **kw: Process()): + return BNET["proton"]("launcher.exe", watch="/pfx", poll=0, close_grace=0, tidy=tidy) + + def test_only_wine_and_agent_are_idle(self): + self.assertTrue(BNET["idle"]([(1, ["C:\\windows\\system32\\explorer.exe", "/desktop"]), + (2, ["/x/files/bin/wineserver"]), (3, ["c:\\windows\\system32\\steam.exe", "a"])])) + self.assertFalse(BNET["idle"]([(1, ["Battle.net.exe"])])) + self.assertFalse(BNET["idle"]([(1, ["Z:\\root\\.cache\\droiddeck\\battlenet\\Battle.net-Setup.exe"])])) + + +class RequestTest(Temp): + UP = [(1, ["C:\\Program Files (x86)\\Battle.net\\Battle.net.exe"]), + (2, ["C:\\ProgramData\\Battle.net\\Agent\\Agent.9437\\Agent.exe"])] + + def test_the_apps_request_waits_for_battle_net_then_goes_to_it(self): + (self.tmp / "battlenet-request.00000000000000000001").write_text("install-game\nhs_beta\n") + calls = [] + with mock.patch.dict(BNET, {"spawn": lambda exe, verb, args=(): calls.append((exe, verb, list(args))) or 0}): + self.assertFalse(BNET["serve_requests"]("/p/pfx", self.UP[:1], str(self.tmp))) + self.assertTrue((self.tmp / "battlenet-request.00000000000000000001").exists()) + self.assertTrue(BNET["serve_requests"]("/p/pfx", self.UP, str(self.tmp))) + (self.tmp / "battlenet-request.00000000000000000002").write_text("launch\nWTCG\n") + self.assertTrue(BNET["serve_requests"]("/p/pfx", self.UP, str(self.tmp))) + (self.tmp / "battlenet-request.00000000000000000003").write_text("launch\nWTCG;reboot\n") + self.assertFalse(BNET["serve_requests"]("/p/pfx", self.UP, str(self.tmp))) + client = "/p/pfx/drive_c/Program Files (x86)/Battle.net/Battle.net.exe" + self.assertEqual(calls, [(client, "run", ["--install", "--game=hs_beta"]), (client, "run", ["--exec=launch WTCG"])]) + self.assertEqual(list(self.tmp.iterdir()), []) + + def test_requests_left_before_the_poll_are_all_served_in_order(self): + (self.tmp / "battlenet-request.00000000000000000020").write_text("launch\nOSI\n") + (self.tmp / "battlenet-request.00000000000000000010").write_text("install-game\nwow\n") + (self.tmp / "battlenet-request.00000000000000000030.tmp").write_text("install-game\nfenris\n") + calls = [] + with mock.patch.dict(BNET, {"spawn": lambda exe, verb, args=(): calls.append(list(args)) or 0}): + self.assertTrue(BNET["serve_requests"]("/p/pfx", self.UP, str(self.tmp))) + self.assertEqual(calls, [["--install", "--game=wow"], ["--exec=launch OSI"]]) + self.assertEqual([p.name for p in self.tmp.iterdir()], ["battlenet-request.00000000000000000030.tmp"]) + + +class TidyTest(unittest.TestCase): + CLIENT = (1, ["C:\\Program Files (x86)\\Battle.net\\Battle.net.exe"]) + PAGE = (2, ["C:\\Program Files (x86)\\Battle.net\\Battle.net.exe", "--type=renderer"]) + AGENT = (3, ["C:\\ProgramData\\Battle.net\\Agent\\Agent.9824\\Agent.exe"]) + WINE = (4, ["C:\\windows\\system32\\services.exe"]) + GAME = (5, ["C:\\Program Files (x86)\\Diablo II Resurrected\\D2R.exe", "-uid", "osi"]) + + def setUp(self): + self.stopped = [] + self.notified = [] + patcher = mock.patch.dict(BNET, { + "say": lambda message: None, + "stop": lambda found, wait=0: self.stopped.append(sorted(pid for pid, _ in found)), + "notify": lambda title, body, icon=None: self.notified.append(title), + }) + patcher.start() + self.addCleanup(patcher.stop) + + def run_tidy(self, tidy, timeline): + return [tidy(processes, now) for now, processes in timeline] + + def test_battle_net_and_its_agent_close_once_the_game_has_run_a_while(self): + up = [self.CLIENT, self.PAGE, self.AGENT, self.WINE] + tidy = BNET["Tidy"](settle=20, gone=30) + closed = self.run_tidy(tidy, [(0, up), (5, up + [self.GAME]), (24, up + [self.GAME]), (25, up + [self.GAME]), + (40, up + [self.GAME])]) + self.assertEqual(closed, [False, False, False, True, False]) + self.assertEqual(self.stopped, [[1, 2, 3]]) + self.assertEqual(self.notified, []) + + def test_battle_net_beside_a_game_signing_in_with_its_token_is_left_open(self): + d4 = (6, ["C:\\Program Files (x86)\\Diablo IV\\Diablo IV.exe", "-nostreamline", "-sso", "-launch", "-uid", "fenris"]) + up = [self.CLIENT, self.PAGE, self.AGENT] + tidy = BNET["Tidy"](settle=20, gone=30) + closed = self.run_tidy(tidy, [(0, up + [d4]), (25, up + [d4]), (30, [self.CLIENT, self.PAGE, d4]), + (100, [self.CLIENT, self.PAGE, d4])]) + self.assertEqual(closed, [False, False, False, False]) + self.assertEqual(self.stopped, []) + self.assertEqual(self.notified, []) + + def test_what_battle_net_left_beside_such_a_game_once_closed_itself_is_stopped(self): + d4 = (6, ["C:\\Program Files (x86)\\Diablo IV\\Diablo IV.exe", "-sso", "-launch", "-uid", "fenris"]) + tidy = BNET["Tidy"](settle=20, gone=30) + self.run_tidy(tidy, [(0, [self.CLIENT, self.AGENT, d4]), (5, [self.AGENT, d4]), (20, [self.AGENT, d4])]) + self.assertEqual(self.stopped, [[3]]) + + def test_what_battle_net_did_not_launch_closes_nothing(self): + up = [self.CLIENT, self.AGENT] + redist = (6, ["C:\\Program Files (x86)\\Diablo IV\\vc_redist.x64.exe", "/install"]) + tidy = BNET["Tidy"](settle=0, gone=30) + self.assertEqual(self.run_tidy(tidy, [(0, up + [redist]), (100, up + [redist])]), [False, False]) + self.assertEqual(self.stopped, []) + + def test_a_game_that_exits_and_starts_again_counts_afresh(self): + tidy = BNET["Tidy"](settle=20, gone=30) + up = [self.CLIENT, self.AGENT] + self.run_tidy(tidy, [(0, up + [self.GAME]), (20, up + [self.GAME]), (30, up), (40, up + [self.GAME]), + (55, up + [self.GAME]), (60, up + [self.GAME])]) + self.assertEqual(self.stopped, [[1, 3], [1, 3]]) + + def test_battle_net_outliving_its_agent_is_closed(self): + tidy = BNET["Tidy"](settle=20, gone=30) + with_agent = [self.CLIENT, self.PAGE, self.AGENT, self.WINE] + without = [self.CLIENT, self.PAGE, self.WINE] + closed = self.run_tidy(tidy, [(0, with_agent), (10, without), (39, without), (40, without), (41, without)]) + self.assertEqual(closed, [False, False, False, True, False]) + self.assertEqual(self.stopped, [[1, 2]]) + self.assertEqual(self.notified, ["Battle.net was closed"]) + + def test_an_agent_back_within_the_grace_keeps_battle_net(self): + tidy = BNET["Tidy"](settle=20, gone=30) + with_agent = [self.CLIENT, self.AGENT] + without = [self.CLIENT] + self.run_tidy(tidy, [(0, with_agent), (10, without), (35, with_agent), (50, without), (79, without)]) + self.assertEqual(self.stopped, []) + + def test_a_battle_net_still_starting_its_agent_is_left_alone(self): + tidy = BNET["Tidy"](settle=20, gone=30) + self.run_tidy(tidy, [(0, [self.CLIENT]), (100, [self.CLIENT]), (1000, [self.CLIENT, self.WINE])]) + self.assertEqual(self.stopped, []) + + def test_a_closed_battle_net_starts_its_count_over(self): + tidy = BNET["Tidy"](settle=20, gone=30) + self.run_tidy(tidy, [(0, [self.CLIENT, self.AGENT]), (10, [self.AGENT]), (20, [self.CLIENT]), (100, [self.CLIENT])]) + self.assertEqual(self.stopped, []) + + +class FexRequestTest(Temp): + def answer(self, text, delay=0.2): + def respond(): + request = self.tmp / BNET["FEX_REQUEST"] + deadline = time.monotonic() + 5 + while not request.exists() and time.monotonic() < deadline: + time.sleep(0.02) + self.requested = request.read_text() + request.unlink() + time.sleep(delay) + (self.tmp / (BNET["FEX_RESULT"] + ".tmp")).write_text(text + "\n") + os.replace(self.tmp / (BNET["FEX_RESULT"] + ".tmp"), self.tmp / BNET["FEX_RESULT"]) + thread = threading.Thread(target=respond) + thread.start() + self.addCleanup(thread.join) + + def test_the_apps_answer_comes_back(self): + (self.tmp / BNET["FEX_RESULT"]).write_text("ok an-old-answer\n") + self.answer("ok FEXCore-2610-d2rfix28-linux.wcp") + self.assertEqual(BNET["ask_fex"](str(self.tmp), wait=5), "ok FEXCore-2610-d2rfix28-linux.wcp") + self.assertEqual(self.requested, "appid=%s\n" % BNET["APP_ID"]) + self.assertFalse((self.tmp / BNET["FEX_RESULT"]).exists()) + + def test_no_answer_or_no_session_starts_without_one(self): + with mock.patch.dict(BNET, {"say": lambda message: None}): + self.assertIsNone(BNET["ask_fex"](str(self.tmp), wait=0.3)) + self.assertIsNone(BNET["ask_fex"](None)) + self.assertIsNone(BNET["ask_fex"](str(self.tmp / "gone"))) + + +class OpenTest(Temp): + def setUp(self): + super().setUp() + self.root = str(self.tmp / "Steam") + self.calls = [] + self.alive = [] + self.menu = self.tmp / "droiddeck-battlenet.desktop" + patcher = mock.patch.dict(BNET, { + "ask_fex": lambda launch_dir, wait=0: "ok fex.wcp", + "say": lambda message: None, "notify": lambda *a, **k: self.calls.append(("notify", a[0])), + "wine_processes": lambda pfx, proc="/proc": list(self.alive), + "stop": lambda processes, wait=0: self.calls.append(("stop", [pid for pid, _ in processes])) or self.alive.clear(), + "write_menu": lambda path=None: self.calls.append(("menu",)), + "START_GRACE": 0, "POLL": 0, + }) + patcher.start() + self.addCleanup(patcher.stop) + + def client(self): + return self.write("Steam/steamapps/compatdata/%s/pfx/drive_c/Program Files (x86)/Battle.net/Battle.net Launcher.exe" + % BNET["APP_ID"], b"MZ") + + def proton(self, effects): + effects = iter(effects) + + def fake(program, args=(), verb=None, watch=None, poll=0): + status, seen, side = next(effects) + self.calls.append(("proton", os.path.basename(program), verb)) + if side: + side() + return status, seen + return fake + + def test_an_installed_client_opens_and_the_start_waits_for_it(self): + self.client() + with mock.patch.dict(BNET, {"proton": self.proton([(0, True, None)])}): + self.assertEqual(BNET["open_client"](self.root), 0) + self.assertEqual(self.calls, [("menu",), ("proton", "Battle.net Launcher.exe", None)]) + + def test_what_a_closed_battle_net_left_is_stopped_first(self): + self.client() + self.alive.append((40, ["C:\\ProgramData\\Battle.net\\Agent\\Agent.exe"])) + with mock.patch.dict(BNET, {"proton": self.proton([(0, True, None)])}): + self.assertEqual(BNET["open_client"](self.root), 0) + self.assertEqual(self.calls[:2], [("menu",), ("stop", [40])]) + + def test_without_a_client_the_installer_runs_and_hands_over(self): + setup = self.write("cache/Battle.net-Setup.exe", b"MZ") + with mock.patch.dict(BNET, {"download": lambda: str(setup), "proton": self.proton([(0, True, self.client)])}): + self.assertEqual(BNET["open_client"](self.root), 0) + self.assertEqual([c for c in self.calls if c[0] != "notify"], [("menu",), ("proton", "Battle.net-Setup.exe", None)]) + config = Path(self.root) / ("steamapps/compatdata/%s/pfx/drive_c/users/steamuser/AppData/Roaming/Battle.net/Battle.net.config" + % BNET["APP_ID"]) + self.assertTrue(config.is_file()) + + def test_an_installer_that_did_not_start_it_is_followed_by_the_launcher(self): + setup = self.write("cache/Battle.net-Setup.exe", b"MZ") + effects = [(0, False, self.client), (0, True, None)] + with mock.patch.dict(BNET, {"download": lambda: str(setup), "proton": self.proton(effects)}): + self.assertEqual(BNET["open_client"](self.root), 0) + self.assertEqual([c[1] for c in self.calls if c[0] == "proton"], ["Battle.net-Setup.exe", "Battle.net Launcher.exe"]) + + def test_a_failed_install_says_so(self): + setup = self.write("cache/Battle.net-Setup.exe", b"MZ") + with mock.patch.dict(BNET, {"download": lambda: str(setup), "proton": self.proton([(1, False, None)])}): + self.assertEqual(BNET["open_client"](self.root), 1) + self.assertIn(("notify", "Battle.net did not install"), self.calls) + + def test_no_download_is_no_install(self): + with mock.patch.dict(BNET, {"download": lambda: None, "proton": self.proton([])}): + self.assertEqual(BNET["open_client"](self.root), 1) + self.assertIn(("notify", "Battle.net could not be downloaded"), self.calls) + self.assertEqual(self.calls[0], ("menu",)) + + def test_a_cut_off_installer_download_is_not_kept(self): + import http.client + import io + + class Response(io.BytesIO): + def __init__(self, body, length): + super().__init__(body) + self.headers = {"Content-Length": str(length)} + + folder = str(self.tmp / "dl") + body = b"MZ" + b"\0" * (BNET["MIN_INSTALLER"] + 10) + with mock.patch.object(BNET["urllib"].request, "urlopen", lambda *a, **k: Response(body, len(body) + 100)), \ + mock.patch.object(BNET["shutil"], "which", lambda name: None): + self.assertIsNone(BNET["download"]("https://example.invalid/setup.exe", folder)) + self.assertEqual(os.listdir(folder), []) + + def broken(*a, **k): + raise http.client.IncompleteRead(b"MZ") + with mock.patch.object(BNET["urllib"].request, "urlopen", broken), \ + mock.patch.object(BNET["shutil"], "which", lambda name: None): + self.assertIsNone(BNET["download"]("https://example.invalid/setup.exe", folder)) + self.assertEqual(os.listdir(folder), []) + + with mock.patch.object(BNET["urllib"].request, "urlopen", lambda *a, **k: Response(body, len(body))): + self.assertEqual(BNET["download"]("https://example.invalid/setup.exe", folder), os.path.join(folder, BNET["SETUP"])) + + def test_a_client_that_never_starts_is_reported(self): + self.client() + with mock.patch.dict(BNET, {"proton": self.proton([(3, False, None)])}): + self.assertEqual(BNET["open_client"](self.root), 3) + self.assertIn(("notify", "Battle.net did not start"), self.calls) + + def test_a_second_start_shows_the_open_window_and_returns(self): + client = self.write("Steam/steamapps/compatdata/%s/pfx/drive_c/Program Files (x86)/Battle.net/Battle.net.exe" + % BNET["APP_ID"], b"MZ") + self.alive.extend([(50, [str(client)]), (51, ["C:\\ProgramData\\Battle.net\\Agent\\Agent.9824\\Agent.exe"])]) + started = [] + with mock.patch.object(BNET["subprocess"], "Popen", lambda cmd, **kw: started.append((cmd, kw)) or mock.Mock()), \ + mock.patch.dict(os.environ, {"BL_DESKTOP": "1"}), \ + mock.patch.dict(BNET, {"CACHE": str(self.tmp / "cache"), "acquire": lambda: self.fail("took the lock")}): + self.assertIsNone(BNET["show"](self.root, wait=60, poll=0)) + cmd, kw = started[0] + self.assertEqual(cmd, [BNET["PROTON_RUN"], "--window", "--prefix", BNET["APP_ID"], "--verb", "run", str(client)]) + self.assertTrue(kw["start_new_session"]) + self.assertEqual(kw["stdin"], BNET["subprocess"].DEVNULL) + self.assertEqual(kw["stdout"].name, str(self.tmp / "cache/spawned.log")) + self.assertIs(kw["stderr"], kw["stdout"]) + + def test_a_battle_net_without_its_agent_is_not_handed_the_start(self): + self.write("Steam/steamapps/compatdata/%s/pfx/drive_c/Program Files (x86)/Battle.net/Battle.net.exe" + % BNET["APP_ID"], b"MZ") + self.alive.append((50, ["C:\\Program Files (x86)\\Battle.net\\Battle.net.exe"])) + tries = iter([None, None, "the lock"]) + with mock.patch.dict(BNET, {"acquire": lambda: next(tries), "spawn": lambda *a, **k: self.fail("handed over")}): + self.assertEqual(BNET["show"](self.root, wait=60, poll=0), "the lock") + + def test_a_second_start_while_the_first_ends_opens_battle_net_itself(self): + tries = iter([None, None, "the lock"]) + with mock.patch.dict(BNET, {"acquire": lambda: next(tries)}): + self.assertEqual(BNET["show"](self.root, wait=60, poll=0), "the lock") + + def test_a_second_start_gives_up_when_the_first_neither_opens_nor_ends(self): + with mock.patch.dict(BNET, {"acquire": lambda: None}): + self.assertIsNone(BNET["show"](self.root, wait=0, poll=0)) + self.assertEqual(self.calls[-1], ("notify", "Battle.net is busy")) + + def test_a_game_in_the_prefix_is_left_running_and_battle_net_starts_beside_it(self): + self.client() + self.alive.append((41, ["C:\\Program Files (x86)\\Overwatch\\_retail_\\Overwatch.exe"])) + with mock.patch.dict(BNET, {"proton": self.proton([(0, True, None)])}): + self.assertEqual(BNET["open_client"](self.root), 0) + self.assertNotIn("stop", [c[0] for c in self.calls]) + self.assertIn(("proton", "Battle.net Launcher.exe", "run"), self.calls) + + def test_one_start_at_a_time(self): + first = BNET["acquire"](str(self.tmp / "lock")) + self.assertIsNotNone(first) + self.assertIsNone(BNET["acquire"](str(self.tmp / "lock"))) + first.close() + again = BNET["acquire"](str(self.tmp / "lock")) + self.assertIsNotNone(again) + again.close() + + def test_status_says_where_it_is(self): + self.client() + status = BNET["status"](self.root) + self.assertEqual((status["appid"], status["installed"], status["running"]), (int(BNET["APP_ID"]), True, False)) + self.assertTrue(status["launcher"].endswith("Battle.net/Battle.net Launcher.exe")) + + +class Harness(Temp): + CLIENT = ["C:\\Program Files (x86)\\Battle.net\\Battle.net.exe"] + GAME = ["C:\\Program Files (x86)\\Diablo IV\\Diablo IV.exe"] + AGENT = ["C:\\ProgramData\\Battle.net\\Agent\\Agent.9824\\Agent.exe"] + + class Lock: + def __init__(self): + self.closed = False + + def close(self): + self.closed = True + + def __enter__(self): + return self + + def __exit__(self, *exc): + self.close() + + class Proton: + def wait(self, timeout=None): + return 0 + + def setUp(self): + super().setUp() + env = mock.patch.dict(os.environ) + env.start() + self.addCleanup(env.stop) + self.root = str(self.tmp / "Steam") + self.calls = [] + self.lock = self.Lock() + self.states = [] + self.last = [] + patcher = mock.patch.dict(BNET, { + "ask_fex": lambda launch_dir, wait=0: self.calls.append(("fex",)) or "ok fex.wcp", + "say": lambda message: None, "notify": lambda *a, **k: self.calls.append(("notify", a[0])), + "wine_processes": self.processes, + "stop": lambda processes, wait=0: self.calls.append(("stop", sorted(pid for pid, _ in processes))), + "start": lambda exe, verb=None: self.calls.append(("start", os.path.basename(exe), verb)) or self.Proton(), + "spawn": lambda program, verb, args=(): self.calls.append(("spawn", os.path.basename(program), verb, list(args))), + "acquire": lambda folder=None: self.lock, + "POLL": 0, "READY_SETTLE": 0, "GAME_GRACE": 0, "CLIENT_WAIT": 0, + }) + patcher.start() + self.addCleanup(patcher.stop) + + def processes(self, pfx, proc="/proc"): + if self.states: + self.last = self.states.pop(0) + return list(self.last) + + def install(self): + for name in ("Battle.net Launcher.exe", "Battle.net.exe"): + self.write("Steam/steamapps/compatdata/%s/pfx/drive_c/Program Files (x86)/Battle.net/%s" % (BNET["APP_ID"], name), b"MZ") + + def clients(self, n=5, first=10): + return [(first + i, self.CLIENT + (["--type=renderer"] if i else [])) for i in range(n)] + +class LaunchTest(Harness): + def test_battle_net_is_opened_for_the_game_and_closed_after_it(self): + self.install() + up = self.clients() + [(30, self.AGENT)] + self.states = [[], [], up, up + [(40, self.GAME)], up, up] + self.assertEqual(BNET["launch"](self.root, "Fen"), 0) + self.assertEqual(self.calls, [ + ("fex",), ("start", "Battle.net Launcher.exe", None), + ("spawn", "Battle.net.exe", "run", ["--exec=launch Fen"]), + ("stop", [10, 11, 12, 13, 14, 30]), + ]) + self.assertTrue(self.lock.closed) + + def test_battle_net_is_closed_once_the_game_is_up(self): + self.install() + up = self.clients() + [(30, self.AGENT)] + game = (40, self.GAME + ["-launch", "-uid", "fenris"]) + wine = (50, ["C:\\windows\\system32\\services.exe"]) + self.states = [[], [], up, up + [game, wine], [game, wine], [game, wine], [wine], [wine]] + with mock.patch.dict(BNET, {"GAME_SETTLE": 0}): + self.assertEqual(BNET["launch"](self.root, "Fen"), 0) + self.assertEqual(self.calls, [ + ("fex",), ("start", "Battle.net Launcher.exe", None), + ("spawn", "Battle.net.exe", "run", ["--exec=launch Fen"]), + ("stop", [10, 11, 12, 13, 14, 30]), + ("stop", [50]), + ]) + + def test_a_battle_net_opened_for_a_game_closes_itself_as_it_launches_it(self): + self.install() + self.states = [[], [], [], []] + BNET["launch"](self.root, "Fen") + config = Path(self.root) / ("steamapps/compatdata/%s/pfx/drive_c/users/steamuser/AppData/Roaming/Battle.net/Battle.net.config" + % BNET["APP_ID"]) + self.assertEqual(json.loads(config.read_text())["Client"]["GameLaunchWindowBehavior"], "2") + + def test_battle_net_beside_a_game_signing_in_with_its_token_closes_only_after_it(self): + self.install() + up = self.clients() + [(30, self.AGENT)] + d4 = (40, self.GAME + ["-sso", "-launch", "-uid", "fenris"]) + self.states = [[], [], up, up + [d4], up + [d4], up + [d4], up, up] + with mock.patch.dict(BNET, {"GAME_SETTLE": 0}): + self.assertEqual(BNET["launch"](self.root, "Fen"), 0) + self.assertEqual([c for c in self.calls if c[0] == "stop"], [("stop", [10, 11, 12, 13, 14, 30])]) + + def test_battle_net_closing_itself_as_it_launches_the_game_is_not_an_end(self): + game = (40, self.GAME + ["-uid", "fenris"]) + self.states = [[], [game], [game], [], []] + times = iter([0, 5, 6, 10, 30]) + with mock.patch.object(BNET["time"], "monotonic", lambda: next(times)): + self.assertTrue(BNET["follow"]("/pfx", poll=0, grace=15)) + + def test_battle_net_that_only_selected_the_game_is_asked_again(self): + up = self.clients() + game = (40, self.GAME + ["-uid", "s1"]) + self.states = [up, up, up, up + [game], [game], [], []] + asks = [] + times = iter([0, 16, 32, 33, 34, 50, 70]) + with mock.patch.object(BNET["time"], "monotonic", lambda: next(times)): + self.assertTrue(BNET["follow"]("/pfx", poll=0, grace=15, ask=lambda: asks.append(1))) + self.assertEqual(len(asks), 2) + + def test_battle_net_is_asked_at_most_three_times(self): + up = self.clients() + self.states = [up] * 6 + [[], []] + asks = [] + times = iter([0, 20, 40, 60, 80, 100, 101, 130]) + with mock.patch.object(BNET["time"], "monotonic", lambda: next(times)): + self.assertFalse(BNET["follow"]("/pfx", poll=0, grace=15, ask=lambda: asks.append(1))) + self.assertEqual(len(asks), 2) + + def test_the_wallpaper_is_droiddecks_logo_on_its_dark_gradient(self): + bmp = BNET["wallpaper_bmp"](192, 108) + self.assertEqual(bmp[:2], b"BM") + self.assertEqual(int.from_bytes(bmp[2:6], "little"), len(bmp)) + self.assertEqual((int.from_bytes(bmp[18:22], "little"), int.from_bytes(bmp[22:26], "little")), (192, 108)) + + def pixel(x, y): + at = 54 + (107 - y) * 192 * 3 + x * 3 + return tuple(bmp[at + 2 - i] for i in range(3)) + self.assertEqual(pixel(95, 54), (26, 159, 255)) + self.assertTrue(all(c < 16 for c in pixel(0, 0))) + + def test_battle_nets_desktop_shows_the_wallpaper(self): + self.install() + pfx = Path(self.root) / ("steamapps/compatdata/%s/pfx" % BNET["APP_ID"]) + (pfx / "user.reg").write_text("WINE REGISTRY Version 2\n") + with mock.patch.dict(os.environ, {"BL_WIDTH": "160", "BL_HEIGHT": "90"}), \ + mock.patch.dict(BNET, {"wallpaper_bmp": lambda w, h: b"BM%dx%d" % (w, h)}): + self.assertTrue(BNET["wallpaper"](self.root)) + self.assertTrue(BNET["wallpaper"](self.root)) + image = pfx / "drive_c/users/steamuser/AppData/Local/DroidDeck/wallpaper-160x90.bmp" + self.assertEqual(image.read_bytes(), b"BM160x90") + reg = (pfx / "user.reg").read_text() + self.assertEqual(reg.count('"Wallpaper"="C:\\\\users\\\\steamuser\\\\AppData\\\\Local\\\\DroidDeck\\\\wallpaper-160x90.bmp"'), 1) + self.assertIn('[Control Panel\\\\Desktop]', reg) + self.assertIn('"Background"="4 6 11"', reg) + self.assertIn('"WallpaperStyle"="0"', reg) + + def test_a_large_desktop_gets_a_stretched_smaller_wallpaper(self): + self.install() + pfx = Path(self.root) / ("steamapps/compatdata/%s/pfx" % BNET["APP_ID"]) + (pfx / "user.reg").write_text("WINE REGISTRY Version 2\n") + with mock.patch.dict(os.environ, {"BL_WIDTH": "2560", "BL_HEIGHT": "1600"}), \ + mock.patch.dict(BNET, {"wallpaper_bmp": lambda w, h: b"BM%dx%d" % (w, h)}): + self.assertTrue(BNET["wallpaper"](self.root)) + image = pfx / "drive_c/users/steamuser/AppData/Local/DroidDeck/wallpaper-1152x720.bmp" + self.assertEqual(image.read_bytes(), b"BM1152x720") + self.assertIn('"WallpaperStyle"="2"', (pfx / "user.reg").read_text()) + + def test_an_open_battle_net_is_handed_the_game(self): + self.install() + up = self.clients() + self.states = [up, up, up + [(40, self.GAME)], up] + self.assertEqual(BNET["launch"](self.root, "OSI"), 0) + self.assertEqual(self.calls, [("spawn", "Battle.net.exe", "run", ["--exec=launch OSI"])]) + + def test_the_desktops_battle_net_holding_the_lock_is_handed_the_game(self): + self.install() + up = self.clients() + self.states = [up, up + [(40, self.GAME)], up] + with mock.patch.dict(BNET, {"acquire": lambda folder=None: None}): + self.assertEqual(BNET["launch"](self.root, "Pro"), 0) + self.assertEqual(self.calls, [("spawn", "Battle.net.exe", "run", ["--exec=launch Pro"])]) + + def test_battle_net_closed_before_the_game_started(self): + self.install() + up = self.clients() + self.states = [[], [], up, up, [(30, self.AGENT)], [(30, self.AGENT)]] + self.assertEqual(BNET["launch"](self.root, "Fen"), 1) + self.assertEqual(self.calls[-1], ("stop", [30])) + + def test_no_client_no_launch(self): + self.assertEqual(BNET["launch"](self.root, "Fen"), 1) + self.assertEqual(self.calls, [("notify", "Battle.net is not installed")]) + + def test_a_battle_net_that_never_opens_launches_nothing(self): + self.install() + self.states = [[], [], []] + self.assertEqual(BNET["launch"](self.root, "Fen"), 1) + self.assertNotIn("spawn", [c[0] for c in self.calls]) + self.assertIn(("notify", "Battle.net did not start"), self.calls) + + def test_a_game_is_a_windows_program_neither_wines_nor_battle_nets(self): + self.assertTrue(BNET["is_game"](self.GAME)) + self.assertTrue(BNET["is_game"](["c:/Program Files (x86)/Overwatch/_retail_/Overwatch.exe", "-uid", "prometheus"])) + self.assertTrue(BNET["is_game"](["C:\\Program Files (x86)\\Diablo IV\\BlizzardError\\BlizzardError.exe"])) + for argv in (self.CLIENT, self.AGENT, ["C:\\Program Files (x86)\\Battle.net\\Battle.net Launcher.exe"], + ["C:\\windows\\system32\\services.exe"], ["C:\\windows\\system32\\lsass.exe"], ["C:\\windows\\system32\\rundll32.exe", "setupapi"], + ["c:\\windows\\system32\\steam.exe", "x"], ["/x/files/bin/wineserver"], ["/usr/bin/python3", "proton"], []): + with self.subTest(argv): + self.assertFalse(BNET["is_game"](argv)) + + def test_battle_net_shows_in_the_desktops_window_or_as_the_steam_launch(self): + with mock.patch.dict(os.environ, {"BL_DESKTOP": "1", "SteamGameId": "123"}): + self.assertEqual(BNET["proton_options"](), ["--window"]) + with mock.patch.dict(os.environ, {"SteamGameId": "11151176786366644224"}): + os.environ.pop("BL_DESKTOP", None) + self.assertEqual(BNET["proton_options"](), ["--game-id", "11151176786366644224"]) + os.environ["SteamGameId"] = "0" + self.assertEqual(BNET["proton_options"](), []) + + def test_under_gamescope_battle_net_gets_a_desktop_of_the_screens_size(self): + with mock.patch.dict(os.environ, {"BL_WIDTH": "1280", "BL_HEIGHT": "720", "SteamGameId": "0"}): + os.environ.pop("BL_DESKTOP", None) + self.assertEqual(BNET["proton_options"](), ["--desktop", "1280x720"]) + os.environ["BL_DESKTOP"] = "1" + self.assertEqual(BNET["proton_options"](), ["--window"]) + + def test_the_library_comes_before_the_action(self): + seen = [] + self.addCleanup(BNET.__setitem__, "LIBRARY", None) + with mock.patch.dict(BNET, {"launch": lambda root, code: seen.append((BNET["LIBRARY"], code)) or 0}): + self.assertEqual(BNET["main"](["--library", "/root/Games/Stores/Battle.net", "launch", "WTCG"]), 0) + self.assertEqual(BNET["main"](["--library", "Games", "launch", "WTCG"]), 2) + self.assertEqual(seen, [("/root/Games/Stores/Battle.net", "WTCG")]) + + def test_only_a_code_is_launched(self): + with mock.patch.dict(BNET, {"launch": lambda root, code: self.calls.append(("launch", code)) or 0}): + for argv in (["launch"], ["launch", "Fen;rm -rf /"], ["launch", "Fen", "x"], ["open", "x"], ["bogus"]): + with self.subTest(argv): + self.assertEqual(BNET["main"](argv), 2) + self.assertEqual(BNET["main"](["launch", "Fen"]), 0) + self.assertEqual(self.calls, [("launch", "Fen")]) + self.assertEqual(os.environ.get("BL_BATTLENET"), "1") + + +class InstallGameTest(Harness): + def setUp(self): + super().setUp() + BNET_PATCH = mock.patch.dict(BNET, { + "open_client": lambda root, reinstall=False: self.calls.append(("open",)) or 0, + "hand_over": lambda root, args, wait=None: self.calls.append(("hand_over", list(args))), + "show": lambda root, wait=None, poll=None, args=(): self.calls.append(("show", list(args))), + }) + BNET_PATCH.start() + self.addCleanup(BNET_PATCH.stop) + thread = mock.patch.object(BNET["threading"], "Thread", lambda target, args, daemon: mock.Mock(start=lambda: target(*args))) + thread.start() + self.addCleanup(thread.stop) + + def test_the_open_battle_net_is_asked(self): + self.install() + self.states = [self.clients() + [(30, self.AGENT)]] + self.assertEqual(BNET["install_game"](self.root, "fenris"), 0) + self.assertEqual(self.calls, [("spawn", "Battle.net.exe", "run", ["--install", "--game=fenris"])]) + self.assertFalse(self.lock.closed) + + def test_an_open_battle_net_without_its_agent_is_asked_once_it_has_one(self): + self.install() + self.states = [self.clients()] + with mock.patch.dict(BNET, {"acquire": lambda folder=None: None}): + self.assertEqual(BNET["install_game"](self.root, "fenris"), 0) + self.assertEqual(self.calls, [("show", ["--install", "--game=fenris"])]) + + def test_battle_net_is_opened_and_asked_once_it_is_up(self): + self.install() + self.assertEqual(BNET["install_game"](self.root, "osi"), 0) + self.assertEqual(self.calls, [("hand_over", ["--install", "--game=osi"]), ("open",)]) + + def test_without_battle_net_it_is_installed_first_then_asked(self): + self.assertEqual(BNET["install_game"](self.root, "hs_beta"), 0) + self.assertEqual(self.calls, [("hand_over", ["--install", "--game=hs_beta"]), ("open",)]) + + def test_another_start_holding_the_lock_hands_it_on(self): + with mock.patch.dict(BNET, {"acquire": lambda folder=None: None}): + self.assertEqual(BNET["install_game"](self.root, "prometheus"), 0) + self.assertEqual(self.calls, [("show", ["--install", "--game=prometheus"])]) + + def test_only_an_id_is_asked_for(self): + with mock.patch.dict(BNET, {"install_game": lambda root, uid: self.calls.append(("get", uid)) or 0}): + for argv in (["install-game"], ["install-game", "Fenris"], ["install-game", "osi;rm"], ["install-game", "osi", "x"]): + with self.subTest(argv): + self.assertEqual(BNET["main"](argv), 2) + self.assertEqual(BNET["main"](["install-game", "hs_beta"]), 0) + self.assertEqual(self.calls, [("get", "hs_beta")]) + + +class HandOverTest(Harness): + def test_once_battle_net_is_up_it_is_asked(self): + self.install() + self.states = [[], self.clients()] + self.assertTrue(BNET["hand_over"](self.root, ["--install", "--game=osi"], wait=60)) + self.assertEqual(self.calls, [("spawn", "Battle.net.exe", "run", ["--install", "--game=osi"])]) + + def test_a_battle_net_that_never_comes_up_is_not_asked(self): + self.install() + self.states = [[]] + self.assertFalse(BNET["hand_over"](self.root, ["--install", "--game=osi"], wait=0)) + self.assertEqual(self.calls, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/tests/test_ci_checks.py b/tools/tests/test_ci_checks.py new file mode 100644 index 000000000..b7c29fceb --- /dev/null +++ b/tools/tests/test_ci_checks.py @@ -0,0 +1,95 @@ +import itertools +import json +import os +from pathlib import Path +import shlex +import shutil +import subprocess +import tempfile +import unittest + +import yaml + + +ROOT = Path(__file__).resolve().parents[2] + + +class CiChecksTest(unittest.TestCase): + def test_pr_cache_writes_and_source_timestamp_policy(self): + jobs = yaml.safe_load((ROOT / '.github/workflows/build.yml').read_text())['jobs'] + cache = next(step for step in jobs['build']['steps'] + if step.get('uses') == 'gradle/actions/setup-gradle@v4')['with'] + self.assertEqual(cache['cache-read-only'], + "${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository }}") + gradle = (ROOT / 'app/build.gradle').read_text() + self.assertIn("gitValue(['show', '-s', '--format=%ct', '--end-of-options', sourceCommit])", gradle) + + def test_report_comment_lookup_ignores_human_marker(self): + if not shutil.which('jq'): + self.skipTest('jq required to execute comment selection') + script = (ROOT / 'tools/release/report-build.sh').read_text() + line = next(line for line in script.splitlines() if line.startswith('ID=')) + arguments = shlex.split(line[len('ID=$('):].split(' | head', 1)[0]) + query = arguments[arguments.index('--jq') + 1].replace('$MARK', '<!-- build -->') + comments = [dict(id=1, user=dict(login='someone'), body='<!-- build --> forged'), + dict(id=2, user=dict(login='github-actions[bot]'), body='<!-- build --> result')] + result = subprocess.check_output(['jq', '-r', query], input=json.dumps(comments), text=True) + self.assertEqual(result.strip(), '2') + + def test_build_and_tests_use_identical_generated_metadata(self): + jobs = yaml.safe_load((ROOT / '.github/workflows/build.yml').read_text())['jobs'] + steps = {step.get('id'): step for step in jobs['build']['steps']} + self.assertEqual(steps['build']['env'], steps['unit-tests']['env']) + self.assertIn('DROIDDECK_BUILD_LABEL', steps['unit-tests']['env']) + self.assertIn('SGDB_API_KEY', steps['unit-tests']['env']) + gradle = (ROOT / 'app/build.gradle').read_text() + self.assertIn("outputs.doNotCacheIf('CI executes the test suite on every run') { true }", gradle) + self.assertIn('outputs.upToDateWhen { false }', gradle) + + def test_independent_jobs_wait_only_for_the_pinned_source(self): + jobs = yaml.safe_load((ROOT / '.github/workflows/build.yml').read_text())['jobs'] + for name in ('xbox', 'components', 'python-tests'): + self.assertEqual(jobs[name]['needs'], 'source') + self.assertEqual(set(jobs['build']['needs']), {'source', 'components', 'xbox'}) + + def test_dependency_update_errors_block_installation(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + sudo = root / 'sudo' + sudo.write_text('#!/bin/bash\nprintf "%s\\n" "$*" >> "$APT_LOG"\nexit "${APT_RESULT:-0}"\n') + sudo.chmod(0o755) + query = root / 'dpkg-query' + query.write_text('#!/bin/sh\nexit 1\n') + query.chmod(0o755) + log = root / 'apt.log' + env = dict(os.environ, PATH=str(root) + os.pathsep + os.environ['PATH'], APT_LOG=str(log)) + installer = ROOT / 'tools/ci/install-ubuntu-packages.sh' + result = subprocess.run(['bash', str(installer), 'qemu-user'], env=dict(env, APT_RESULT='17')) + self.assertEqual(result.returncode, 17) + self.assertEqual(len(log.read_text().splitlines()), 2) + log.unlink() + subprocess.run(['bash', str(installer), 'qemu-user'], env=env, check=True) + update, install = log.read_text().splitlines() + for command in (update, install): + self.assertIn('Dir::Etc::sourceparts=-', command) + self.assertIn('Acquire::https::Timeout=15', command) + self.assertIn('Acquire::Retries=1', command) + self.assertIn('APT::Update::Error-Mode=any', update) + self.assertIn('install -y -q --no-install-recommends qemu-user', install) + + def test_required_status_rejects_failed_cancelled_or_skipped_test_jobs(self): + jobs = yaml.safe_load((ROOT / '.github/workflows/build.yml').read_text())['jobs'] + gate = jobs['checks'] + self.assertEqual(gate['name'], 'Compile and test APK') + self.assertEqual(set(gate['needs']), {'build', 'python-tests'}) + self.assertIn('always()', gate['if']) + for build, python in itertools.product(('success', 'failure', 'cancelled', 'skipped'), repeat=2): + with self.subTest(build=build, python=python): + result = subprocess.run( + ['bash', '-c', gate['steps'][0]['run']], + env=dict(os.environ, BUILD_RESULT=build, PYTHON_RESULT=python), + capture_output=True) + self.assertEqual(result.returncode == 0, build == python == 'success') + for publisher in ('sign', 'packs'): + self.assertIn('checks', jobs[publisher]['needs']) + diff --git a/tools/tests/test_ci_ndk.py b/tools/tests/test_ci_ndk.py new file mode 100644 index 000000000..eef737db6 --- /dev/null +++ b/tools/tests/test_ci_ndk.py @@ -0,0 +1,27 @@ +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[2] + + +class CiNdkTest(unittest.TestCase): + def test_installed_ndk_must_match_the_declared_version(self): + version = (ROOT / 'tools/ci/toolchains.env').read_text().strip().split('=', 1)[1] + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + ndk = root / 'ndk' / version + ndk.mkdir(parents=True) + properties = ndk / 'source.properties' + properties.write_text('Pkg.Revision = ' + version + '\n') + output = root / 'environment' + env = dict(os.environ, ANDROID_HOME=str(root), GITHUB_ENV=str(output)) + script = ROOT / 'tools/ci/resolve-ndk.sh' + subprocess.run(['bash', str(script)], env=env, check=True) + self.assertIn('NDK_VERSION=' + version, output.read_text()) + self.assertIn('ANDROID_NDK_HOME=' + str(ndk), output.read_text()) + properties.write_text('Pkg.Revision = unexpected\n') + self.assertNotEqual(subprocess.run(['bash', str(script)], env=env).returncode, 0) diff --git a/tools/tests/test_component_prepare.py b/tools/tests/test_component_prepare.py new file mode 100644 index 000000000..b513c8fe7 --- /dev/null +++ b/tools/tests/test_component_prepare.py @@ -0,0 +1,106 @@ +import importlib.util +import io +import json +import os +from pathlib import Path +import subprocess +import tarfile +import tempfile +import unittest +from unittest.mock import patch + +SPEC = importlib.util.spec_from_file_location('component_prepare', Path(__file__).resolve().parents[1] / 'components/prepare.py') +components = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(components) + + +class ComponentPrepareTest(unittest.TestCase): + def setUp(self): + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + self.root = Path(directory.name) + self.fixture(self.root) + + def fixture(self, root): + for name in components.TREES: + (root / name).mkdir(parents=True) + (root / 'tools/components/recipes').mkdir(parents=True) + (root / 'input.h').write_text('#define VALUE 1\n') + (root / 'tools/components/recipes/fake.sh').write_text('printf binary > app/src/main/assets/linuxfs/demo.so\n') + spec = dict(script='tools/components/recipes/fake.sh', inputs=['input.h', 'tools/components/recipes/fake.sh'], + outputs=['app/src/main/assets/linuxfs/demo.so']) + (root / components.REGISTRY).write_text(json.dumps(dict(fake=spec))) + for name in components.LIBRARIES: + (root / name).parent.mkdir(parents=True, exist_ok=True) + (root / name).write_bytes(b'library') + subprocess.run(['git', 'init', '-q', str(root)], check=True) + subprocess.run(['git', '-C', str(root), 'config', 'core.autocrlf', 'false'], check=True) + subprocess.run(['git', '-C', str(root), 'add', '.'], check=True) + subprocess.run(['git', '-C', str(root), '-c', 'user.name=Test Developer', '-c', 'user.email=test@example.test', + 'commit', '-qm', 'baseline'], check=True) + + def test_recipe_failure_or_missing_output_does_not_succeed(self): + script = self.root / 'tools/components/recipes/fake.sh' + script.write_text('exit 7\n') + with self.assertRaises(subprocess.CalledProcessError): + components.prepare('fake', self.root) + script.write_text('exit 0\n') + with self.assertRaisesRegex(ValueError, 'required outputs'): + components.prepare('fake', self.root) + + def test_identity_covers_source_and_recipe_changes(self): + original = components.identity('fake', self.root) + (self.root / 'input.h').write_text('#define VALUE 2\n') + changed = components.identity('fake', self.root) + self.assertNotEqual(original, changed) + script = self.root / 'tools/components/recipes/fake.sh' + script.write_text(script.read_text() + '\nprintf changed\n') + self.assertNotEqual(changed, components.identity('fake', self.root)) + + def test_tool_requirements_are_installed_in_one_union(self): + definitions = dict(one=dict(packages=['gcc', 'cmake']), two=dict(packages=['gcc', 'qemu-user'])) + with patch.object(components, 'registry', return_value=definitions), patch.object(components.subprocess, 'run') as run: + components.prepare_tools(['one', 'two'], self.root) + run.assert_called_once_with(['bash', 'tools/ci/install-ubuntu-packages.sh', 'cmake', 'gcc', 'qemu-user'], + cwd=self.root, check=True) + + def test_prepared_handoff_verifies_every_file_and_source_tree(self): + components.prepare('fake', self.root) + archive = self.root / 'prepared.tar.gz' + components.pack(archive, self.root) + tree = subprocess.check_output(['git', 'rev-parse', 'HEAD^{tree}'], cwd=self.root, text=True).strip() + target = self.root / 'target' + self.fixture(target) + components.unpack(archive, tree, target) + self.assertEqual((target / 'app/src/main/assets/linuxfs/demo.so').read_bytes(), b'binary') + with self.assertRaisesRegex(ValueError, 'pinned source'): + components.unpack(archive, 'wrong-tree', target) + + def test_tampered_component_bytes_fail_handoff_verification(self): + components.prepare('fake', self.root) + archive = self.root / 'prepared.tar.gz' + components.pack(archive, self.root) + corrupt = self.root / 'corrupt.tar.gz' + with tarfile.open(archive) as original, tarfile.open(corrupt, 'w:gz') as output: + for entry in original: + content = original.extractfile(entry).read() + if entry.name.endswith('demo.so'): + content = b'tampered' + entry.size = len(content) + output.addfile(entry, io.BytesIO(content)) + target = self.root / 'target' + self.fixture(target) + tree = subprocess.check_output(['git', 'rev-parse', 'HEAD^{tree}'], cwd=self.root, text=True).strip() + with self.assertRaisesRegex(ValueError, 'file hashes'): + components.unpack(corrupt, tree, target) + + def test_archive_cannot_overwrite_source_through_traversal(self): + archive = self.root / 'bad.tar.gz' + with tarfile.open(archive, mode='w:gz') as tar: + name = 'app/src/main/assets/linuxfs/../../../../../input.h' + entry = tarfile.TarInfo(name) + entry.size = 3 + tar.addfile(entry, io.BytesIO(b'bad')) + with self.assertRaisesRegex(ValueError, 'Unsafe'): + components.unpack(archive, 'tree', self.root) + self.assertEqual((self.root / 'input.h').read_text(), '#define VALUE 1\n') diff --git a/tools/tests/test_desktop_open.py b/tools/tests/test_desktop_open.py index de98e75c6..ef299963b 100644 --- a/tools/tests/test_desktop_open.py +++ b/tools/tests/test_desktop_open.py @@ -456,6 +456,15 @@ class ProtonRunTest(Temp): ("0", "0", str(appid << 32 | 0x02000000), "1")) self.assertEqual(env["STEAM_COMPAT_DATA_PATH"], str(self.root / ("steamapps/compatdata/%d" % appid))) + def test_a_window_draws_with_the_desktops_software_gl(self): + target = RUN["Target"]("2596113170", "/c", "/i", str(self.root), "fixed") + session = {"MESA_LOADER_DRIVER_OVERRIDE": "zink", "GALLIUM_DRIVER": "zink", "LIBGL_KOPPER_DRI2": "true"} + env = RUN["environment"](session, target, "/tool", str(self.root), window=True) + self.assertEqual((env["LIBGL_ALWAYS_SOFTWARE"], env["GALLIUM_DRIVER"]), ("1", "llvmpipe")) + self.assertNotIn("MESA_LOADER_DRIVER_OVERRIDE", env) + self.assertNotIn("LIBGL_KOPPER_DRI2", env) + self.assertEqual(RUN["environment"](session, target, "/tool", str(self.root))["GALLIUM_DRIVER"], "zink") + def test_a_steam_game_keeps_its_own_id(self): target = RUN["Target"]("413150", "/c", "/i", str(self.root), "steam") env = RUN["environment"]({"PROTON_USE_WINED3D": "0"}, target, "/tool", str(self.root), window=True) @@ -465,6 +474,13 @@ class ProtonRunTest(Temp): self.assertEqual(RUN["arguments"]("/root/x/setup.msi", ["/qn"]), ["msiexec", "/i", "Z:\\root\\x\\setup.msi", "/qn"]) self.assertEqual(RUN["arguments"]("/root/x/a.exe", ["-w"]), ["/root/x/a.exe", "-w"]) + def test_a_virtual_desktop_runs_the_program_by_its_windows_path(self): + pfx = "/root/Steam/steamapps/compatdata/2596113170" + self.assertEqual(RUN["arguments"](pfx + "/pfx/drive_c/Program Files (x86)/Battle.net/Battle.net Launcher.exe", [], "1280x720", pfx), + ["explorer.exe", "/desktop=droiddeck,1280x720", "C:\\Program Files (x86)\\Battle.net\\Battle.net Launcher.exe"]) + self.assertEqual(RUN["arguments"]("/root/.cache/setup.exe", ["--lang=enUS"], "1920x1080", pfx), + ["explorer.exe", "/desktop=droiddeck,1920x1080", "Z:\\root\\.cache\\setup.exe", "--lang=enUS"]) + def test_a_games_own_program_is_left_to_steam(self): self.write("Steam/appcache/appinfo.vdf", appinfo({413150: STARDEW})) self.assertEqual(RUN["compat"]()["launch_programs"](STARDEW["config"]), @@ -554,6 +570,50 @@ class ProtonRunTest(Temp): self.game("70", "Linux Game", "run.sh") self.assertEqual(self.run_main(["--app", "70"])[0], 2) + def test_without_steam_a_shortcut_reuses_a_signed_prefix_folder(self): + bat = self.write("Games/Epic/Game/.droiddeck-launch.bat", b"@echo off") + start = self.tmp / "Games/Epic/Game/Binaries" + start.mkdir() + signed = self.root / "steamapps/compatdata/-1561598272" + signed.mkdir(parents=True) + code, calls, dirs = self.run_main(["--app", "2733369024", "--dir", str(start), "--", str(bat)]) + self.assertIsNone(code) + env = calls[0][2] + self.assertEqual(env["STEAM_COMPAT_DATA_PATH"], str(signed)) + self.assertFalse((self.root / "steamapps/compatdata/2733369024").exists()) + + def test_prefix_folder_prefers_initialized_unsigned_over_empty_signed(self): + signed = self.root / "steamapps/compatdata/-1561598272" + signed.mkdir(parents=True) + initialized = self.root / "steamapps/compatdata/2733369024/pfx" + initialized.mkdir(parents=True) + + self.assertEqual(RUN["prefix_folder"](str(self.root), 2733369024), str(initialized.parent)) + + def test_prefix_folder_prefers_initialized_signed_over_empty_unsigned(self): + unsigned = self.root / "steamapps/compatdata/2733369024" + unsigned.mkdir(parents=True) + initialized = self.root / "steamapps/compatdata/-1561598272/pfx" + initialized.mkdir(parents=True) + + self.assertEqual(RUN["prefix_folder"](str(self.root), 2733369024), str(initialized.parent)) + + def test_prefix_folder_prefers_unsigned_when_initialization_ties(self): + unsigned = self.root / "steamapps/compatdata/2733369024/pfx" + unsigned.mkdir(parents=True) + signed = self.root / "steamapps/compatdata/-1561598272/pfx" + signed.mkdir(parents=True) + + self.assertEqual(RUN["prefix_folder"](str(self.root), 2733369024), str(unsigned.parent)) + + def test_prefix_folder_skips_numeric_names_too_long_to_convert(self): + from unittest.mock import patch + folder = self.root / "steamapps/compatdata" + folder.mkdir(parents=True) + with patch.object(RUN["os"], "listdir", return_value=["9" * 5000]): + self.assertEqual(RUN["prefix_folder"](str(self.root), 2733369024), + str(folder / "2733369024")) + def test_without_steam_a_shortcut_keeps_its_prefix_folder_and_launch_options(self): bat = self.write("Games/Epic/Game/.droiddeck-launch.bat", b"@echo off") start = self.tmp / "Games/Epic/Game/Binaries" @@ -568,6 +628,43 @@ class ProtonRunTest(Temp): self.assertTrue((self.root / "steamapps/compatdata/2733369024").is_dir()) self.assertEqual(dirs, [str(start)]) + def write_account(self): + self.write("Steam/config/loginusers.vdf", + '"users"\n{\n\t"76561198352563669"\n\t{\n\t\t"MostRecent"\t\t"1"\n\t}\n}\n') + return "Steam/userdata/392297941/config/" + + def test_without_steam_and_options_a_game_takes_its_steam_launch_options(self): + config = self.write_account() + self.write(config + "localconfig.vdf", '"UserLocalConfigStore"\n{\n\t"Software"\n\t{\n\t\t"Valve"\n\t\t{\n' + '\t\t\t"Steam"\n\t\t\t{\n\t\t\t\t"apps"\n\t\t\t\t{\n\t\t\t\t\t"413150"\n\t\t\t\t\t{\n' + '\t\t\t\t\t\t"cloud"\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t"LaunchOptions"\t\t"-wrong"\n\t\t\t\t\t\t}\n' + '\t\t\t\t\t\t"LaunchOptions"\t\t"DXVK_HUD=1 %command% -d3d9 \\"-name=Two Words\\""\n' + '\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n') + shortcuts = runpy.run_path(str(BIN / "droiddeck-steam-shortcuts")) + self.write(config + "shortcuts.vdf", shortcuts["write"]({"shortcuts": { + "0": {"appid": -1561598272, "AppName": "Batman", "LaunchOptions": "-d3d9"}, + "1": {"appid": -1, "AppName": "Other", "LaunchOptions": "-x"}, + }})) + root = str(self.root) + self.assertEqual(RUN["steam_options"]("413150", root), 'DXVK_HUD=1 %command% -d3d9 "-name=Two Words"') + self.assertEqual(RUN["steam_options"]("2733369024", root), "-d3d9") + self.assertEqual(RUN["steam_options"]("70", root), "") + self.assertEqual(RUN["steam_options"]("2733369025", root), "") + info = {"common": {"type": "Game"}, "config": {"launch": { + "0": {"executable": "Game.exe", "type": "default", "config": {"oslist": "windows"}}}}} + self.write("Steam/appcache/appinfo.vdf", appinfo({413150: info})) + program = self.game("413150", "Some Game", "Game.exe") + code, calls, _ = self.run_main(["--app", "413150"]) + self.assertIsNone(code) + _, argv, env = calls[0] + self.assertEqual(argv[1:], ["waitforexitandrun", str(program), "-d3d9", "-name=Two Words"]) + self.assertEqual(env["DXVK_HUD"], "1") + bat = self.write("Games/GOG/Batman/Batman.exe", b"MZ") + code, calls, _ = self.run_main(["--app", "2733369024", "--", str(bat)]) + self.assertEqual(calls[0][1][1:], ["waitforexitandrun", str(bat), "-d3d9"]) + code, calls, _ = self.run_main(["--app", "2733369024", "--options", "", "--", str(bat)]) + self.assertEqual(calls[0][1][1:], ["waitforexitandrun", str(bat)]) + def test_launch_options_split_like_steam_does(self): split = RUN["split_options"] self.assertEqual(split(""), ({}, [])) diff --git a/tools/tests/test_droiddeck_agent.py b/tools/tests/test_droiddeck_agent.py index 66744b6d5..e9fae8862 100644 --- a/tools/tests/test_droiddeck_agent.py +++ b/tools/tests/test_droiddeck_agent.py @@ -196,6 +196,63 @@ class AgentTest(unittest.TestCase): answer = self.ask({"kind": "vr", "action": "status"}) self.assertEqual("UNAVAILABLE", answer["error"]["code"]) + def fake_proc(self, pid, parent, comm, env): + root = self.dir / "proc" / str(pid) + root.mkdir(parents=True) + (root / "comm").write_text(comm + "\n") + (root / "stat").write_text("%d (%s) S %d 0 0" % (pid, comm, parent)) + (root / "environ").write_bytes(b"\0".join(b"%s=%s" % (k.encode(), v.encode()) for k, v in env.items()) + b"\0") + + def tag(self, wins, arch="arm64"): + snap = {"displays": {":0": [], ":1": wins}} + with patch.dict(os.environ, {"DISPLAY": ":0", "BL_STEAM_CLIENT_ARCH": arch}), \ + patch.object(agent.subprocess, "run") as run, patch.object(agent, "log"): + run.return_value.returncode = 0 + tagged = agent.tag_game_windows(snap, proc=self.dir / "proc") + return tagged, [call.args[0][-1] for call in run.call_args_list] + + def test_a_sandboxed_flatpak_window_is_tagged_with_its_shortcut(self): + self.fake_proc(40, 1, "gjs", {"FLATPAK_ID": "com.github.tchx84.Flatseal", "SteamAppId": "2166984148"}) + win = {"window": 7, "map": "viewable", "class": "com.github.tchx84.Flatseal", "pid": None} + self.assertEqual(self.tag([win]), (True, ["2166984148"])) + + def test_a_flatpak_window_named_after_the_app_is_matched(self): + self.fake_proc(40, 1, "vacuumtube", {"FLATPAK_ID": "rocks.shy.VacuumTube", "SteamAppId": "3000000001"}) + win = {"window": 7, "map": "viewable", "class": "VacuumTube", "pid": None} + self.assertEqual(self.tag([win]), (True, ["3000000001"])) + + def test_a_window_whose_process_has_no_app_id_falls_back_to_its_flatpak(self): + self.fake_proc(40, 1, "bwrap", {"FLATPAK_ID": "com.github.tchx84.Flatseal", "SteamAppId": "2166984148"}) + self.fake_proc(41, 1, "gjs", {}) + win = {"window": 7, "map": "viewable", "class": "com.github.tchx84.Flatseal", "pid": 41} + self.assertEqual(self.tag([win]), (True, ["2166984148"])) + + def test_a_flatpak_shared_by_two_shortcuts_is_not_guessed(self): + self.fake_proc(40, 1, "gjs", {"FLATPAK_ID": "com.github.tchx84.Flatseal", "SteamAppId": "2166984148"}) + self.fake_proc(42, 1, "gjs", {"FLATPAK_ID": "com.github.tchx84.Flatseal", "SteamAppId": "3000000003"}) + win = {"window": 7, "map": "viewable", "class": "com.github.tchx84.Flatseal", "pid": None} + self.assertEqual(self.tag([win]), (False, [])) + + def test_a_window_outside_the_reaper_takes_its_process_app_id(self): + self.fake_proc(30, 1, "proot", {}) + self.fake_proc(31, 30, "electron", {"SteamAppId": "3000000002"}) + win = {"window": 8, "map": "viewable", "class": "electron", "pid": 31} + self.assertEqual(self.tag([win]), (True, ["3000000002"])) + + def test_windows_gamescope_already_resolves_are_left_alone(self): + self.fake_proc(20, 1, "reaper", {}) + self.fake_proc(21, 20, "game", {"SteamAppId": "413150"}) + wins = [{"window": 9, "map": "viewable", "class": "game", "pid": 21}, + {"window": 10, "map": "viewable", "class": "tagged", "pid": None, "appId": 5}, + {"window": 11, "map": "viewable", "class": "other", "pid": None}, + {"window": 12, "map": "unmapped", "class": "game", "pid": 21}, + {"window": 13, "map": "viewable", "class": "steam_app_550", "pid": None}] + self.assertEqual(self.tag(wins), (False, [])) + + def test_the_x86_client_still_tags_its_game_classes(self): + win = {"window": 13, "map": "viewable", "class": "steam_app_550", "pid": None} + self.assertEqual(self.tag([win], arch="x86_64"), (True, ["550"])) + def test_exec_output_and_status(self): answer = self.ask({"kind": "exec", "argv": ["sh", "-c", "echo out; echo err >&2; exit 3"], "env": {"X": "1"}}) self.assertTrue(answer["ok"]) diff --git a/tools/tests/test_droiddeck_fex.py b/tools/tests/test_droiddeck_fex.py index fed546ddd..e65b21277 100644 --- a/tools/tests/test_droiddeck_fex.py +++ b/tools/tests/test_droiddeck_fex.py @@ -549,11 +549,12 @@ class LaunchTest(FexTestCase): def test_the_guest_environment(self): arm = self.write(self.tmp / "arm.so", elf(183)) x86 = self.write(self.tmp / "x86.so", elf(62)) - env = {"LD_PRELOAD": "%s:%s %s" % (arm, x86, self.tmp / "gone.so"), + env = {"LD_PRELOAD": "%s:%s %s libMelonLoader.Bootstrap.so" % (arm, x86, self.tmp / "gone.so"), "LD_LIBRARY_PATH": "/steam/steamrtarm64:/usr/lib/aarch64-linux-gnu:/game/lib:/steam/linuxarm64", "FEX_ROOTFS": "/usr/share/guestos/fex-mesa", "FEX_TSOENABLED": "1"} guest = FEX["guest_environment"]({"portable": True}, env) - self.assertEqual((guest["LD_PRELOAD"], guest["LD_LIBRARY_PATH"], guest["FEX_PORTABLE"]), (str(x86), "/game/lib", "1")) + self.assertEqual((guest["LD_PRELOAD"], guest["LD_LIBRARY_PATH"], guest["FEX_PORTABLE"]), + ("%s:libMelonLoader.Bootstrap.so" % x86, "/game/lib", "1")) self.assertNotIn("FEX_ROOTFS", guest) self.assertEqual(guest["FEX_TSOENABLED"], "1") self.assertTrue(guest["FEX_APP_CONFIG_LOCATION"].endswith("/config/")) @@ -690,6 +691,32 @@ class LaunchTest(FexTestCase): command, env, mode = G["launch"].call_args[0] self.assertEqual((command, mode), (["/game/run.sh", "-a"], "auto")) + def test_steam_games_get_their_settings_and_linux_unity_keeps_tso(self): + config = self.home / ".config/droiddeck/game-environment.json" + unity = self.write(self.tmp / "Megabonk/Megabonk.x86_64", elf(62), 0o755) + self.write(self.tmp / "Megabonk/UnityPlayer.so", elf(62)) + self.write(self.tmp / "Megabonk/GameAssembly.so", elf(62)) + other = self.write(self.tmp / "Other/game.x86_64", elf(62), 0o755) + self.write(config, json.dumps({"version": 1, "shared": {"FEX_TSOENABLED": "0", "FEX_MULTIBLOCK": "1"}, + "games": {"20": {"FEX_TSOENABLED": "0"}}})) + + def started(program, app): + with mock.patch.dict(G, {"launch": mock.Mock(return_value=0), "GAME_ENVIRONMENT": str(config)}), \ + mock.patch.dict(os.environ, {"SteamAppId": app}, clear=True): + _, said = self.quiet(FEX["main"], ["steam", "waitforexitandrun", str(program), "-arg"]) + command, env, _ = G["launch"].call_args[0] + self.assertEqual(command, [str(program), "-arg"]) + return env.get("FEX_TSOENABLED"), env.get("FEX_MULTIBLOCK"), said + + tso, multiblock, said = started(unity, "10") + self.assertEqual((tso, multiblock), ("1", "1")) + self.assertIn("Unity: FEX with TSO", said) + self.assertEqual(started(other, "10")[:2], ("0", "1")) + self.assertEqual(started(unity, "20")[:2], ("0", "1")) + config.unlink() + self.assertEqual(started(unity, "10")[:2], ("1", None)) + self.assertEqual(started(other, "10")[:2], (None, None)) + class AppInfoTest(unittest.TestCase): COMPAT = runpy.run_path(str(BIN / "steam-compatibility")) diff --git a/tools/tests/test_droiddeck_proton_default.py b/tools/tests/test_droiddeck_proton_default.py new file mode 100644 index 000000000..6c0665d57 --- /dev/null +++ b/tools/tests/test_droiddeck_proton_default.py @@ -0,0 +1,95 @@ +import json +import runpy +import tempfile +import unittest +from pathlib import Path + +BIN = Path(__file__).resolve().parents[1] / "linuxfs/overlay/usr/local/bin" +COMPAT = runpy.run_path(str(BIN / "steam-compatibility")) +TOOL = COMPAT["TOOL"] +OLD = {"name": "DroidDeck-Proton-11-11.2", "display": "DroidDeck-Proton 11-11.2", "dir": "DroidDeck-Proton 11-11.2", "valve": False} +NEW = {"name": "DroidDeck-Proton-11-11.4", "display": "DroidDeck-Proton 11-11.4", "dir": "DroidDeck-Proton 11-11.4", "valve": False} +VALVE = {"name": TOOL, "display": "Proton Experimental ARM64", "dir": "Proton - Experimental (ARM64)", "valve": True} +GE = {"name": "GE-Proton11-7", "display": "GE-Proton11-7", "dir": "GE-Proton11-7", "valve": False} + + +def config_text(entries): + mapping = "".join('"%s" { "name" "%s" "config" "" "priority" "%s" }' % (app, name, "75" if app == "0" else "250") for app, name in entries) + return '"InstallConfigStore" { "Software" { "Valve" { "Steam" { "CompatToolMapping" { %s } } } } }' % mapping + + +class DroidDeckProtonDefaultTest(unittest.TestCase): + def run_default(self, state, mapping, catalog, apps=(), request=None, settle=True): + with tempfile.TemporaryDirectory() as tmp: + directory = Path(tmp) + (directory / "state.json").write_text(json.dumps(state)) + if request is not None: + (directory / "request.json").write_text(json.dumps(request)) + config = directory / "config.vdf" + config.write_text(config_text(mapping)) + default = COMPAT["register_default"](str(config), list(apps), catalog=catalog, directory=str(directory), settle=settle) + return default, COMPAT["read_config_mapping"](str(config)), json.loads((directory / "state.json").read_text()) + + def test_a_replacement_seen_while_steam_runs_still_moves_the_default_later(self): + _, mapping, state = self.run_default( + {"default": OLD["name"], "own_proton": True, "tools": [VALVE, OLD]}, [("0", OLD["name"])], [VALVE, NEW], settle=False) + self.assertEqual(state["tools"], [VALVE, NEW]) + self.assertIn(OLD["name"], state["own_builds"]) + default, mapping, state = self.run_default(state, [("0", OLD["name"])], [VALVE, NEW]) + self.assertEqual(default, NEW["name"]) + self.assertEqual(mapping, {"0": NEW["name"]}) + + def test_a_valve_default_picked_in_steam_after_a_replacement_is_kept(self): + default, mapping, _ = self.run_default( + {"default": OLD["name"], "own_proton": True, "tools": [VALVE, OLD]}, [("0", TOOL)], [VALVE, NEW]) + self.assertEqual(default, TOOL) + self.assertEqual(mapping, {"0": TOOL}) + + def test_the_first_start_with_droiddeck_proton_makes_it_the_default_for_following_titles(self): + default, mapping, state = self.run_default( + {"default": TOOL, "auto": {"42": TOOL}}, [("0", TOOL), ("42", TOOL), ("43", TOOL)], [VALVE, NEW], ["42", "43"]) + self.assertEqual(default, NEW["name"]) + self.assertEqual(mapping, {"0": NEW["name"], "42": NEW["name"], "43": TOOL}) + self.assertEqual(state["default"], NEW["name"]) + self.assertTrue(state["own_proton"]) + + def test_a_default_picked_after_the_switch_is_kept(self): + default, mapping, state = self.run_default( + {"default": NEW["name"], "own_proton": True, "applied": 1, "tools": [VALVE, NEW]}, + [("0", NEW["name"])], [VALVE, NEW], request={"seq": 2, "valve": True, "dir": VALVE["dir"]}) + self.assertEqual(default, TOOL) + self.assertEqual(mapping, {"0": TOOL}) + default, mapping, state = self.run_default( + {"default": TOOL, "own_proton": True, "tools": [VALVE, NEW]}, [("0", TOOL)], [VALVE, NEW]) + self.assertEqual(default, TOOL) + self.assertEqual(mapping, {"0": TOOL}) + + def test_a_replaced_build_moves_its_default_and_titles_to_the_newest(self): + default, mapping, state = self.run_default( + {"default": OLD["name"], "own_proton": True, "auto": {"42": OLD["name"]}, "tools": [VALVE, OLD, GE]}, + [("0", OLD["name"]), ("42", OLD["name"]), ("43", OLD["name"]), ("44", GE["name"]), ("45", TOOL)], + [VALVE, NEW, GE], ["42", "43", "44", "45"]) + self.assertEqual(default, NEW["name"]) + self.assertEqual(mapping, {"0": NEW["name"], "42": NEW["name"], "43": NEW["name"], "44": GE["name"], "45": TOOL}) + self.assertEqual(state["auto"], {"42": NEW["name"]}) + + def test_a_replaced_build_does_not_override_a_valve_default(self): + default, mapping, _ = self.run_default( + {"default": TOOL, "own_proton": True, "tools": [VALVE, OLD]}, [("0", TOOL), ("43", OLD["name"])], [VALVE, NEW], ["43"]) + self.assertEqual(default, TOOL) + self.assertEqual(mapping, {"0": TOOL, "43": NEW["name"]}) + + def test_nothing_changes_without_droiddeck_proton(self): + default, mapping, state = self.run_default({"default": TOOL}, [("0", TOOL)], [VALVE, GE]) + self.assertEqual(default, TOOL) + self.assertEqual(mapping, {"0": TOOL}) + self.assertNotIn("own_proton", state) + + def test_the_newest_build_is_found_by_version(self): + newest, gone = COMPAT["own_proton_moves"]({"tools": [OLD]}, [dict(NEW, name="a", dir="DroidDeck-Proton 11-9.10"), dict(NEW, name="b", dir="DroidDeck-Proton 11-11.1")]) + self.assertEqual(newest, "b") + self.assertEqual(gone, {OLD["name"]}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/tests/test_droiddeckctl.py b/tools/tests/test_droiddeckctl.py index f6d29030a..82ff7daa0 100644 --- a/tools/tests/test_droiddeckctl.py +++ b/tools/tests/test_droiddeckctl.py @@ -413,6 +413,21 @@ class DroiddeckctlTest(FakeAdbTest): self.assertEqual("Awake", out["wakefulness"]) self.assertIn(["shell", "input", "keyevent", "KEYCODE_WAKEUP"], [c["args"] for c in self.calls()]) + def test_proot_profile_queues_only_the_next_session(self): + self.configure({"env": {"ok": True}}) + code, out = self.run_ctl("proot-profile", "enable", "--slow-us", "250") + self.assertEqual(0, code, out) + self.assertEqual({"op": "set", "lines": ["PROOT_PROFILE=1", "PROOT_PROFILE_SLOW_US=250"], "persistent": False}, + self.calls("env")[0]["request"]) + + def test_proot_profile_report_needs_no_adb_or_device(self): + path = self.dir / "session.log" + path.write_text('ordinary log') + with unittest.mock.patch.object(ctl, "adb_path", side_effect=AssertionError("must stay offline")): + code, out = self.run_ctl("proot-profile", "report", str(path)) + self.assertEqual(0, code, out) + self.assertFalse(out["hasCounters"]) + def test_override_pushes_to_the_inbox_first(self): self.configure({ "access": {"ok": True, "commands": True, "inbox": "/sdcard/Android/data/x/files/agent-inbox"}, diff --git a/tools/tests/test_esync_packs.py b/tools/tests/test_esync_packs.py index 863a1003b..7ba20b448 100644 --- a/tools/tests/test_esync_packs.py +++ b/tools/tests/test_esync_packs.py @@ -1160,8 +1160,12 @@ class BundleTest(unittest.TestCase): except ImportError: self.skipTest("PyYAML is not installed") apk = yaml.safe_load((self.ROOT / ".github/workflows/build.yml").read_text()) - steps = {step.get("name"): step for step in apk["jobs"]["build"]["steps"]} - bundle = steps["Bundle the droiddeck-esync packs"] + steps = {step.get("name"): step for step in apk["jobs"]["components"]["steps"]} + import json + recipes = json.loads((self.ROOT / "tools/components/recipes.json").read_text()) + bundle = dict(steps["Bundle the droiddeck-esync packs"]) + self.assertIn("prepare.py esync", bundle["run"]) + bundle["run"] = (self.ROOT / recipes["esync"]["script"]).read_text() self.assertNotIn("if", bundle) self.assertIn("tools/droiddeck-esync/release.env", bundle["run"]) self.assertIn('release_url="https://github.com/${SYNC_BUNDLE_REPO}/releases/download/${SYNC_BUNDLE_TAG}"', bundle["run"]) @@ -1169,7 +1173,7 @@ class BundleTest(unittest.TestCase): self.assertIn('curl -fsSL --retry 3 -o sync-index.json "$release_url/index.json"', bundle["run"]) self.assertIn("sha256sum -c -", bundle["run"]) self.assertNotIn("revoked.txt", bundle["run"]) - stage = next(step for step in apk["jobs"]["build"]["steps"] if "overlay/usr/local/bin/droiddeck-*" in step.get("run", "")) + stage = {"run": (self.ROOT / recipes["runtime"]["script"]).read_text()} self.assertIn("tools/linuxfs/overlay/usr/local/bin/droiddeck-* ", stage["run"]) local = (self.ROOT / "tools/build_local.sh").read_text() self.assertIn("tools/linuxfs/overlay/usr/local/bin/droiddeck-* ", local) diff --git a/tools/tests/test_fastpath_bind_names.py b/tools/tests/test_fastpath_bind_names.py new file mode 100644 index 000000000..2e8a8b11f --- /dev/null +++ b/tools/tests/test_fastpath_bind_names.py @@ -0,0 +1,139 @@ +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +FASTPATH = Path(__file__).resolve().parents[1] / "proot/fastpath/fastpath.c" + +PROGRAM = r""" +#include "FASTPATH" + +static long host_sys(long nr, long a, long b, long c, long d, long e, long f) { + long r = syscall(nr, a, b, c, d, e, f); + return r < 0 ? -errno : r; +} + +int main(int argc, char **argv) { + static char hosts[MAX_BINDS][PATH_MAX], guests[MAX_BINDS][512]; + fp_sys = host_sys; + snprintf(root, sizeof root, "%s", argv[1]); + rootlen = strlen(root); + int i = 2; + for (; i + 1 < argc && strcmp(argv[i], "--"); i += 2) { + struct bind *bd = &binds[nbinds]; + bd->host = hosts[nbinds]; + bd->guest = guests[nbinds]; + snprintf(bd->host, PATH_MAX, "%s", argv[i]); + snprintf(bd->guest, sizeof guests[0], "%s", argv[i + 1]); + bd->hlen = strlen(bd->host); + bd->glen = strlen(bd->guest); + nbinds++; + } + pthread_once(&init_once, fp_init); + fp_on = 1; + for (i++; i < argc; i++) { + char text[PATH_MAX]; + if (!strcmp(argv[i], "name")) { + if (guest_text(argv[++i], text) < 0) strcpy(text, "SLOW"); + puts(text); + } else if (!strcmp(argv[i], "link")) { + snprintf(text, sizeof text, "%s", argv[i + 2]); + if (detranslated(text, argv[i + 1]) == FP_SLOW) strcpy(text, "SLOW"); + i += 2; + puts(text); + } else if (!strcmp(argv[i], "lstat")) { + struct stat st; + long r = fstatat(AT_FDCWD, argv[++i], &st, AT_SYMLINK_NOFOLLOW); + puts(r < 0 ? "error" : S_ISDIR(st.st_mode) ? "dir" : S_ISLNK(st.st_mode) ? "link" : "other"); + } + } + return 0; +} +""" + + +class FastPathBindNamesTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + if shutil.which("gcc") is None: + raise unittest.SkipTest("gcc is not available") + cls.work = Path(tempfile.mkdtemp()) + source = cls.work / "names.c" + source.write_text(PROGRAM.replace("FASTPATH", str(FASTPATH))) + cls.program = cls.work / "names" + subprocess.run(["gcc", "-O1", "-w", "-pthread", "-o", str(cls.program), str(source), "-ldl"], check=True) + t = cls.work / "tree" + for d in ["root/etc", "root/mnt", "root/opt", "root/d/real", "lib/common/game", "fonts", "a", "b"]: + (t / d).mkdir(parents=True, exist_ok=True) + for f in ["root/etc/x", "lib/f", "lib/common/game/f", "fonts/f", "a/f", "b/f"]: + (t / f).touch() + os.symlink(t / "lib/f", t / "lib/l2") + os.symlink(t / "lib/common", t / "lib/l") + os.symlink("real", t / "root/d/link") + cls.tree = t + cls.binds = [ + (t / "lib", "/mnt/droiddeck-sd"), + (t / "lib", "/mnt/bannerlator-sd"), + (t / "lib", "/mnt/DroidDeck-SD"), + (t / "lib/common", "/opt/common"), + (t / "fonts", "/usr/share/fonts/android"), + (t / "a", "/opt/g"), + (t / "b", "/opt/g"), + (t / "fonts", "/mnt/x"), + ] + + @classmethod + def tearDownClass(cls): + shutil.rmtree(cls.work, ignore_errors=True) + + def ask(self, *queries): + argv = [str(self.program), str(self.tree / "root")] + for host, guest in self.binds: + argv += [str(host), guest] + argv.append("--") + for q in queries: + argv += [str(x) for x in q] + out = subprocess.run(argv, capture_output=True, text=True, timeout=20, check=True).stdout + return out.splitlines() + + def test_a_host_file_takes_the_name_proot_gives_it(self): + t = self.tree + self.assertEqual(self.ask( + ("name", t / "lib/f"), + ("name", t / "lib/common/game/f"), + ("name", t / "fonts/f"), + ("name", t / "b/f"), + ("name", t / "a/f"), + ("name", t / "root/etc/x"), + ), [ + "/mnt/droiddeck-sd/f", + "/opt/common/game/f", + "/usr/share/fonts/android/f", + "/opt/g/f", + str(t / "a/f"), + "/etc/x", + ]) + + def test_a_link_inside_a_library_reads_back_like_proot(self): + t = self.tree + self.assertEqual(self.ask( + ("link", t / "lib/l2", t / "lib/f"), + ("link", t / "lib/l", t / "lib/common"), + ), [ + "/mnt/droiddeck-sd/f", + str(t / "lib/common"), + ]) + + def test_a_final_dot_follows_the_link(self): + self.assertEqual(self.ask( + ("lstat", "/d/link"), + ("lstat", "/d/link/."), + ("lstat", "/d/link/./"), + ("lstat", "/d/link/"), + ), ["link", "dir", "dir", "dir"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/tests/test_flatpak_setup.py b/tools/tests/test_flatpak_setup.py new file mode 100644 index 000000000..b5714b9a4 --- /dev/null +++ b/tools/tests/test_flatpak_setup.py @@ -0,0 +1,45 @@ +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + + +SCRIPT = Path(__file__).resolve().parents[1] / "linuxfs/overlay/usr/local/bin/droiddeck-flatpak-setup" + + +class FlatpakSetupTest(unittest.TestCase): + def run_setup(self, uid, pacman): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + bin_dir = root / "bin" + bin_dir.mkdir() + stubs = {"id": "echo %d" % uid, "curl": "exit 7", "pacman": pacman, "pacman-key": "exit 0"} + for name, body in stubs.items(): + (bin_dir / name).write_text("#!/bin/sh\n%s\n" % body) + (bin_dir / name).chmod(0o755) + script = root / "setup" + script.write_text(SCRIPT.read_text().replace("WORK=/var/cache/droiddeck-flatpak", "WORK=%s/work" % root) + .replace("/etc/pacman.d/gnupg/pubring.gpg", str(root / "pubring.gpg")) + .replace(" /etc/pacman.conf >", " %s >" % (root / "pacman.conf"))) + (root / "pacman.conf").write_text("[options]\n[core]\nInclude = /etc/pacman.d/mirrorlist\n") + (root / "pubring.gpg").write_text("") + env = dict(os.environ, PATH="%s:%s" % (bin_dir, os.environ["PATH"])) + result = subprocess.run(["bash", str(script)], env=env, capture_output=True, text=True, timeout=30) + return result.returncode, result.stdout + + def test_a_manual_run_without_root_says_so(self): + code, out = self.run_setup(1000, "exit 0") + self.assertEqual(code, 1) + self.assertIn("== FAIL needs root", out) + self.assertNotIn("Reading the package lists", out) + + def test_an_unreachable_mirror_reports_pacmans_error(self): + code, out = self.run_setup(0, "echo \"error: failed retrieving file 'core.db' : Could not resolve host\" >&2; exit 1") + self.assertEqual(code, 1) + self.assertIn("== FAIL could not reach the Arch Linux ARM mirrors (error: failed retrieving file 'core.db' : " + "Could not resolve host)", out) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/tests/test_fsync_preload.py b/tools/tests/test_fsync_preload.py index c2242f494..63007f373 100644 --- a/tools/tests/test_fsync_preload.py +++ b/tools/tests/test_fsync_preload.py @@ -319,7 +319,7 @@ class FsyncPreloadTest(unittest.TestCase): source = base / "fsync-test.c" source.write_text(PROGRAM) subprocess.run(["cc", "-O2", "-Wall", "-fPIC", "-shared", "-pthread", "-o", str(cls.lib), - str(PRELOAD / "fsync.c"), str(PRELOAD / "robust.c"), str(PRELOAD / "noexec.c"), "-ldl"], check=True) + str(PRELOAD / "fsync.c"), str(PRELOAD / "robust.c"), str(PRELOAD / "noexec.c"), str(PRELOAD / "writewatch.c"), "-ldl"], check=True) subprocess.run(["cc", "-O2", "-Wall", "-pthread", "-o", str(cls.exe), str(source)], check=True) @classmethod diff --git a/tools/tests/test_game_environment.py b/tools/tests/test_game_environment.py index c5c308c8c..3895ebc69 100644 --- a/tools/tests/test_game_environment.py +++ b/tools/tests/test_game_environment.py @@ -8,6 +8,7 @@ import shlex import subprocess import sys import tempfile +from unittest import mock import unittest BIN = Path(__file__).resolve().parents[1] / "linuxfs/overlay/usr/local/bin" @@ -180,6 +181,14 @@ class GameEnvironmentTest(unittest.TestCase): self.assertEqual(MODULE["apply_config"](env, config, "42")["TU_DEBUG"], "sysmem") self.assertNotIn("TU_DEBUG", MODULE["apply_config"](env, config, "43")) + def test_the_vram_cap_comes_before_the_games_own_dxvk_settings(self): + cap = MODULE["vram_cap"] + self.assertEqual(cap({"BL_VRAM_MB": "4096"})["DXVK_CONFIG"], "dxgi.maxDeviceMemory = 4096") + self.assertEqual(cap({"BL_VRAM_MB": "2048", "DXVK_CONFIG": "dxvk.maxFrameRate = 60;"})["DXVK_CONFIG"], + "dxgi.maxDeviceMemory = 2048; dxvk.maxFrameRate = 60") + for off in ({}, {"BL_VRAM_MB": "0"}, {"BL_VRAM_MB": "lots"}, {"BL_VRAM_MB": "²"}, {"BL_VRAM_MB": "-1"}): + self.assertNotIn("DXVK_CONFIG", cap(off)) + def test_a_games_wine_sync_choice_also_reaches_its_setup_runs(self): env = {"BL_SYNC": "1", "BL_SYNC_FALLBACK": "1", "BL_FSYNC_FIRST": "0", "OTHER": "kept"} config = {"version": 1, "games": {"42": {"BL_SYNC": "0", "BL_FSYNC_FIRST": "1", "PROTON_NO_ESYNC": "1", "FEX_TSOENABLED": "1"}}} @@ -357,6 +366,90 @@ class GameEnvironmentTest(unittest.TestCase): (asylum / name).touch() self.assertEqual(MODULE["engine_fixes"](str(asylum / "BmLauncher.exe"), []), ({}, [], [])) + def test_black_ops_iii_and_dark_souls_remastered_skip_protonfixes(self): + for exe in ("/game/Call of Duty Black Ops III/BlackOps3.exe", "/game/DARK SOULS REMASTERED/DarkSoulsRemastered.exe"): + env, extra, notes = MODULE["engine_fixes"](exe, []) + self.assertEqual((env, extra), ({"PROTONFIXES_DISABLE": "1"}, [])) + self.assertTrue(notes) + self.assertEqual(MODULE["engine_fixes"]("/game/Game/DarkSoulsII.exe", []), ({}, [], [])) + + def test_game_cores_are_parsed_from_lists_and_ranges(self): + parse = MODULE["parse_cores"] + self.assertEqual(parse("0-3,6"), {0, 1, 2, 3, 6}) + self.assertEqual(parse(" 4, 5 "), {4, 5}) + for bad in ("", "a", "3-1", "1,,2", "-1", "1-"): + self.assertIsNone(parse(bad), bad) + + def test_a_profiles_game_cores_pin_the_game_within_the_online_cores(self): + calls = [] + os_mod = MODULE["os"] + with mock.patch.dict(MODULE["game_cores"].__globals__, {"online_cores": lambda: {0, 1, 2, 3, 4, 5, 6, 7}}), \ + mock.patch.object(os_mod, "sched_setaffinity", side_effect=lambda pid, cores: calls.append(set(cores)), create=True): + MODULE["game_cores"]({"DROIDDECK_GAME_CORES": "0-2,9"}) + MODULE["game_cores"]({"DROIDDECK_GAME_CORES": "junk"}) + MODULE["game_cores"]({"DROIDDECK_GAME_CORES": "12-13"}) + MODULE["game_cores"]({}) + self.assertEqual(calls, [{0, 1, 2}]) + self.assertEqual(MODULE["parse_cores"]("0,2-7"), {0, 2, 3, 4, 5, 6, 7}) + + def test_caravan_sandwitch_robocop_and_cyberpunk_run_at_feature_level_12_1(self): + with tempfile.TemporaryDirectory() as tmp: + caravan = Path(tmp) / "Caravan SandWitch" + shipping = caravan / "CaravanSandWitch/Binaries/Win64" + shipping.mkdir(parents=True) + for name in ("CaravanSandWitch.exe", ".droiddeck-launch.bat"): + (caravan / name).touch() + (shipping / "CaravanSandWitch-Win64-Shipping.exe").touch() + cyberpunk = Path(tmp) / "Cyberpunk 2077" + (cyberpunk / "bin/x64").mkdir(parents=True) + for name in ("REDprelauncher.exe", "bin/x64/Cyberpunk2077.exe"): + (cyberpunk / name).touch() + robocop = Path(tmp) / "RoboCop Rogue City" + (robocop / "RoboCop/Binaries/Win64").mkdir(parents=True) + for name in ("RoboCop.exe", "RoboCop/Binaries/Win64/RoboCop-Win64-Shipping.exe"): + (robocop / name).touch() + games = [caravan / "CaravanSandWitch.exe", caravan / ".droiddeck-launch.bat", + shipping / "CaravanSandWitch-Win64-Shipping.exe", + robocop / "RoboCop.exe", robocop / "RoboCop/Binaries/Win64/RoboCop-Win64-Shipping.exe", + cyberpunk / "REDprelauncher.exe", cyberpunk / "bin/x64/Cyberpunk2077.exe"] + for exe in games: + env, extra, notes = MODULE["engine_fixes"](str(exe), []) + self.assertEqual((env, extra), ({"VKD3D_FEATURE_LEVEL": "12_1"}, []), exe) + self.assertTrue(notes) + config = {"version": 1, "shared": {"VKD3D_FEATURE_LEVEL": "12_2"}} + self.assertEqual(MODULE["apply_config"]({}, config, "1582650", env)["VKD3D_FEATURE_LEVEL"], "12_1") + config["games"] = {"1582650": {"VKD3D_FEATURE_LEVEL": "12_2"}} + self.assertEqual(MODULE["apply_config"]({}, config, "1582650", env)["VKD3D_FEATURE_LEVEL"], "12_2") + other = Path(tmp) / "Other/Binaries/Win64" + other.mkdir(parents=True) + (other / "Other-Win64-Shipping.exe").touch() + self.assertEqual(MODULE["engine_fixes"](str(other / "Other-Win64-Shipping.exe"), []), ({}, [], [])) + witcher = Path(tmp) / "The Witcher 3" + witcher.mkdir() + (witcher / "REDprelauncher.exe").touch() + self.assertNotIn("VKD3D_FEATURE_LEVEL", MODULE["engine_fixes"](str(witcher / "REDprelauncher.exe"), [])[0]) + + def test_unity_players_keep_tso_over_the_preset(self): + with tempfile.TemporaryDirectory() as tmp: + def game(*names): + for name in names: + (Path(tmp) / name).parent.mkdir(parents=True, exist_ok=True) + (Path(tmp) / name).touch() + return str(Path(tmp) / names[0]) + + il2cpp = game("Megabonk/Megabonk.x86_64", "Megabonk/UnityPlayer.so", "Megabonk/GameAssembly.so") + mono = game("Mono/Game.x86_64", "Mono/UnityPlayer.so") + windows = game("Crab Game/Crab Game.exe", "Crab Game/UnityPlayer.dll", "Crab Game/GameAssembly.dll") + windows_mono = game("RoR2/Risk of Rain 2.exe", "RoR2/UnityPlayer.dll", "RoR2/MonoBleedingEdge/mono-2.0-bdwgc.dll") + for exe in (il2cpp, mono, windows, windows_mono): + env, extra, notes = MODULE["engine_fixes"](exe, []) + self.assertEqual((env, extra, notes), ({"FEX_TSOENABLED": "1"}, [], ["Unity: FEX with TSO"])) + performance = {"version": 1, "shared": {"FEX_TSOENABLED": "0", "FEX_VECTORTSOENABLED": "0"}} + result = MODULE["apply_config"]({}, performance, "3405340", env) + self.assertEqual((result["FEX_TSOENABLED"], result["FEX_VECTORTSOENABLED"]), ("1", "0")) + performance["games"] = {"3405340": {"FEX_TSOENABLED": "0"}} + self.assertEqual(MODULE["apply_config"]({}, performance, "3405340", env)["FEX_TSOENABLED"], "0") + def test_a_new_fix_only_needs_registering(self): with tempfile.TemporaryDirectory() as tmp: (Path(tmp) / "Game.exe").touch() @@ -1163,6 +1256,52 @@ class DirectAudioPrefixTest(unittest.TestCase): self.assertIsNone(self.link("system32")) +class UbisoftOverlayTest(unittest.TestCase): + SETTINGS = ( + "language:\n code: en-US\n" + "overlay:\n enabled: true\n forceunhookgame: false\n fps_enabled: false\n warning_enabled: true\n" + "patchTrigger:\n source: USER_RESTART\n" + ) + + def test_the_overlay_and_its_prompt_are_turned_off_and_the_rest_is_kept(self): + got = MODULE["overlay_off"](self.SETTINGS) + self.assertEqual(got, self.SETTINGS.replace("enabled: true", "enabled: false")) + self.assertEqual(MODULE["overlay_off"](got), got) + + def test_missing_keys_or_section_are_added(self): + self.assertEqual(MODULE["overlay_off"]("overlay:\n fps_enabled: true\nmisc:\n a: 1\n"), + "overlay:\n fps_enabled: true\n enabled: false\n warning_enabled: false\nmisc:\n a: 1\n") + self.assertEqual(MODULE["overlay_off"](""), "overlay:\n enabled: false\n warning_enabled: false\n") + + def test_blank_lines_inside_the_overlay_section_do_not_end_it(self): + text = "overlay:\n enabled: true\n\n warning_enabled: true\n\nmisc:\n a: 1\n" + self.assertEqual(MODULE["overlay_off"](text), text.replace("true", "false")) + self.assertEqual(MODULE["overlay_off"]("overlay:\n fps_enabled: true\n\nmisc:\n a: 1\n"), + "overlay:\n fps_enabled: true\n enabled: false\n warning_enabled: false\n\nmisc:\n a: 1\n") + + def test_an_annotated_overlay_header_is_the_same_section(self): + self.assertEqual(MODULE["overlay_off"]("overlay: # in-game\n enabled: true\n"), + "overlay: # in-game\n enabled: false\n warning_enabled: false\n") + + def test_only_prefixes_with_ubisoft_connect_are_touched(self): + with tempfile.TemporaryDirectory() as tmp: + prefix = Path(tmp) / "447040/pfx" + env = {"STEAM_COMPAT_DATA_PATH": str(prefix.parent)} + settings = prefix / MODULE["UBISOFT_SETTINGS"] + prefix.mkdir(parents=True) + MODULE["ubisoft_overlay_off"](env) + self.assertFalse(settings.exists()) + (prefix / MODULE["UBISOFT_LAUNCHER"]).mkdir(parents=True) + MODULE["ubisoft_overlay_off"](env) + self.assertEqual(settings.read_text(), "overlay:\n enabled: false\n warning_enabled: false\n") + settings.write_text(self.SETTINGS) + settings.chmod(0o644) + MODULE["ubisoft_overlay_off"](env) + self.assertEqual(settings.read_text(), self.SETTINGS.replace("enabled: true", "enabled: false")) + self.assertEqual(settings.stat().st_mode & 0o777, 0o644) + self.assertEqual(sorted(p.name for p in settings.parent.iterdir()), ["settings.yaml"]) + + if __name__ == "__main__": unittest.main() diff --git a/tools/tests/test_gpu_clock_pin.c b/tools/tests/test_gpu_clock_pin.c new file mode 100644 index 000000000..5fdbe6033 --- /dev/null +++ b/tools/tests/test_gpu_clock_pin.c @@ -0,0 +1,188 @@ +#include <assert.h> +#include <errno.h> +#include <pthread.h> +#include <stdarg.h> +#include <stdatomic.h> +#include <stdint.h> +#include <stdio.h> +#include <string.h> +#include <sys/mman.h> +#include <unistd.h> + +#include "../../app/src/main/cpp/adrenotools/src/hook/kgsl.h" +#include "../../app/src/main/cpp/waylandcomp/src/gpu_clock_pin.h" + +enum { NONE, OPEN, DEVICE, CONTEXT, CONSTRAINT, MEMORY, MAP, SYNC, SUBMIT, THREAD, WAIT }; +static atomic_int failure, descriptors, contexts, memories, mappings, submissions, waits; +static atomic_bool timeout_wait; +static bool legacy_core; +static uint32_t commands[1024]; + +int __android_log_print(int priority, const char *tag, const char *format, ...) { + (void)priority; (void)tag; (void)format; + return 0; +} + +int __wrap_open(const char *path, int flags, ...) { + (void)flags; + assert(!strcmp(path, "/dev/kgsl-3d0")); + if (atomic_load(&failure) == OPEN) { errno = EACCES; return -1; } + assert(atomic_fetch_add(&descriptors, 1) == 0); + return 91; +} + +int __wrap_close(int fd) { + assert(fd == 91); + assert(atomic_fetch_sub(&descriptors, 1) == 1); + errno = EBADF; + return 0; +} + +void *__wrap_mmap(void *address, size_t size, int protection, int flags, int fd, off_t offset) { + (void)address; (void)protection; (void)flags; + assert(fd == 91 && size == sizeof(commands) && offset == (23 << 12)); + if (atomic_load(&failure) == MAP) { errno = EACCES; return MAP_FAILED; } + assert(atomic_fetch_add(&mappings, 1) == 0); + return commands; +} + +int __wrap_munmap(void *address, size_t size) { + assert(address == commands && size == sizeof(commands)); + assert(atomic_fetch_sub(&mappings, 1) == 1); + return 0; +} + +#ifdef __ANDROID__ +int __wrap_ioctl(int fd, int request, ...) { +#else +int __wrap_ioctl(int fd, unsigned long request, ...) { +#endif + assert(fd == 91); + va_list args; + va_start(args, request); + void *arg = va_arg(args, void *); + va_end(args); + int step = NONE; + switch ((unsigned int)request) { + case IOCTL_KGSL_DEVICE_GETPROPERTY: step = DEVICE; break; + case IOCTL_KGSL_DRAWCTXT_CREATE: step = CONTEXT; break; + case IOCTL_KGSL_SETPROPERTY: step = CONSTRAINT; break; + case IOCTL_KGSL_GPUMEM_ALLOC_ID: step = MEMORY; break; + case IOCTL_KGSL_GPUMEM_SYNC_CACHE: step = SYNC; break; + case IOCTL_KGSL_GPU_COMMAND: step = SUBMIT; break; + case IOCTL_KGSL_DEVICE_WAITTIMESTAMP_CTXTID: step = WAIT; break; + } + if (step && atomic_load(&failure) == step) { errno = EACCES; return -1; } + switch ((unsigned int)request) { + case IOCTL_KGSL_DEVICE_GETPROPERTY: { + struct kgsl_device_getproperty *property = arg; + assert(property->type == KGSL_PROP_DEVICE_INFO); + ((struct kgsl_devinfo *)property->value)->chip_id = legacy_core ? 0x04030000 : 0x07030000; + break; + } + case IOCTL_KGSL_DRAWCTXT_CREATE: { + struct kgsl_drawctxt_create *context = arg; + assert(context->flags & KGSL_CONTEXT_PWR_CONSTRAINT); + context->drawctxt_id = 17; + atomic_fetch_add(&contexts, 1); + break; + } + case IOCTL_KGSL_SETPROPERTY: { + struct kgsl_device_getproperty *property = arg; + assert(property->type == KGSL_PROP_PWR_CONSTRAINT); + struct kgsl_device_constraint *constraint = property->value; + assert(constraint->context_id == 17 && constraint->type == KGSL_CONSTRAINT_PWRLEVEL); + assert(((struct kgsl_device_constraint_pwrlevel *)constraint->data)->level == KGSL_CONSTRAINT_PWR_MAX); + break; + } + case IOCTL_KGSL_GPUMEM_ALLOC_ID: { + struct kgsl_gpumem_alloc_id *memory = arg; + memory->id = 23; memory->gpuaddr = 0x100000; memory->mmapsize = sizeof(commands); + atomic_fetch_add(&memories, 1); + break; + } + case IOCTL_KGSL_GPUMEM_SYNC_CACHE: + assert(((struct kgsl_gpumem_sync_cache *)arg)->op == KGSL_GPUMEM_CACHE_CLEAN); + break; + case IOCTL_KGSL_GPU_COMMAND: { + struct kgsl_gpu_command *command = arg; + struct kgsl_command_object *object = (void *)(uintptr_t)command->cmdlist; + assert(command->context_id == 17 && command->numcmds == 1); + assert(command->flags & KGSL_CMDBATCH_PWR_CONSTRAINT); + assert(object->id == 23 && object->gpuaddr == 0x100000 && object->size == 8); + assert(commands[0] == (legacy_core ? 0xc0001000 : 0x70100001) && commands[1] == 0); + command->timestamp = atomic_fetch_add(&submissions, 1) + 1; + break; + } + case IOCTL_KGSL_DEVICE_WAITTIMESTAMP_CTXTID: + atomic_fetch_add(&waits, 1); + if (atomic_load(&timeout_wait)) { errno = ETIMEDOUT; return -1; } + break; + case IOCTL_KGSL_DRAWCTXT_DESTROY: + assert(atomic_fetch_sub(&contexts, 1) == 1); + break; + case IOCTL_KGSL_GPUMEM_FREE_ID: + assert(atomic_fetch_sub(&memories, 1) == 1); + break; + default: assert(!"unexpected ioctl"); + } + return 0; +} + +int __real_pthread_create(pthread_t *, const pthread_attr_t *, void *(*)(void *), void *); +int __wrap_pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start)(void *), void *arg) { + if (atomic_load(&failure) == THREAD) return EAGAIN; + return __real_pthread_create(thread, attr, start, arg); +} + +static void assert_released(void) { + assert(!atomic_load(&descriptors) && !atomic_load(&contexts)); + assert(!atomic_load(&memories) && !atomic_load(&mappings)); +} + +int main(void) { + assert(gpu_clock_pin_set(false)); + assert_released(); + legacy_core = true; + assert(gpu_clock_pin_set(true)); + assert(gpu_clock_pin_set(false)); + assert_released(); + legacy_core = false; + for (int step = OPEN; step <= THREAD; ++step) { + atomic_store(&failure, step); + assert(!gpu_clock_pin_set(true)); + assert(errno == (step == THREAD ? EAGAIN : EACCES)); + assert_released(); + } + atomic_store(&failure, NONE); + atomic_store(&submissions, 0); + assert(gpu_clock_pin_set(true)); + assert(gpu_clock_pin_set(true)); + for (int i = 0; i < 100 && atomic_load(&submissions) < 2; ++i) usleep(10000); + assert(atomic_load(&submissions) > 1); + assert(gpu_clock_pin_set(false)); + assert(gpu_clock_pin_set(false)); + assert_released(); + + atomic_store(&timeout_wait, true); + atomic_store(&submissions, 0); + atomic_store(&waits, 0); + assert(gpu_clock_pin_set(true)); + for (int i = 0; i < 100 && atomic_load(&waits) < 2; ++i) usleep(10000); + assert(atomic_load(&waits) >= 2); + assert(atomic_load(&submissions) == 1); + assert(gpu_clock_pin_set(false)); + assert_released(); + atomic_store(&timeout_wait, false); + + atomic_store(&failure, WAIT); + assert(gpu_clock_pin_set(true)); + for (int i = 0; i < 100 && atomic_load(&descriptors); ++i) usleep(10000); + assert_released(); + atomic_store(&failure, NONE); + assert(gpu_clock_pin_set(true)); + assert(gpu_clock_pin_set(false)); + assert_released(); + puts("gpu clock pin: failure cleanup, renewal, bounded queue and restart passed"); + return 0; +} diff --git a/tools/tests/test_gpu_clock_pin.py b/tools/tests/test_gpu_clock_pin.py new file mode 100644 index 000000000..f6f466871 --- /dev/null +++ b/tools/tests/test_gpu_clock_pin.py @@ -0,0 +1,30 @@ +import pathlib +import shutil +import subprocess +import sys +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] + + +class GpuClockPinTest(unittest.TestCase): + @unittest.skipUnless(sys.platform.startswith('linux') and shutil.which('cc'), 'requires a Linux C compiler') + def test_constraint_lifecycle(self): + with tempfile.TemporaryDirectory() as tmp: + directory = pathlib.Path(tmp) + (directory / 'android').mkdir() + (directory / 'android/log.h').write_text( + '#define ANDROID_LOG_WARN 5\n' + 'int __android_log_print(int, const char *, const char *, ...);\n') + binary = directory / 'gpu-clock-pin-test' + subprocess.run([ + 'cc', '-std=c11', '-D_DEFAULT_SOURCE', '-D__user=', '-Wall', '-Wextra', '-Werror', + '-I', str(directory), '-pthread', + str(ROOT / 'tools/tests/test_gpu_clock_pin.c'), + str(ROOT / 'app/src/main/cpp/waylandcomp/src/gpu_clock_pin.c'), + '-Wl,--wrap=open,--wrap=close,--wrap=mmap,--wrap=munmap,--wrap=ioctl,--wrap=pthread_create', + '-o', str(binary), + ], check=True) + subprocess.run([str(binary)], check=True, timeout=10) diff --git a/tools/tests/test_noexec_preload.py b/tools/tests/test_noexec_preload.py index 0e563ec49..20a437d4c 100644 --- a/tools/tests/test_noexec_preload.py +++ b/tools/tests/test_noexec_preload.py @@ -43,7 +43,7 @@ class NoexecPreloadTest(unittest.TestCase): cls.tmp = Path(tempfile.mkdtemp()) cls.lib = cls.tmp / "libnoexec.so" subprocess.run(["cc", "-O2", "-Wall", "-Werror", "-fPIC", "-shared", "-pthread", "-DNOEXEC_TEST", "-o", str(cls.lib), - str(PRELOAD / "noexec.c"), str(PRELOAD / "fsync.c"), str(PRELOAD / "robust.c"), "-ldl"], check=True) + str(PRELOAD / "noexec.c"), str(PRELOAD / "fsync.c"), str(PRELOAD / "robust.c"), str(PRELOAD / "writewatch.c"), "-ldl"], check=True) source = cls.tmp / "program.c" source.write_text(PROGRAM) for name in ("FEX", "game"): diff --git a/tools/tests/test_ntsync_preload.py b/tools/tests/test_ntsync_preload.py new file mode 100644 index 000000000..a2c50a7d6 --- /dev/null +++ b/tools/tests/test_ntsync_preload.py @@ -0,0 +1,580 @@ +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +PRELOAD = Path(__file__).resolve().parents[1] / "linuxfs/preload" + +NTDLL = r""" +#define _GNU_SOURCE +#include <signal.h> +#include <string.h> + +int install_handler(int sig, void (*fn)(int, siginfo_t *, void *), void **seen) { + struct sigaction sa, old; + memset(&sa, 0, sizeof(sa)); + sa.sa_sigaction = fn; + sa.sa_flags = SA_SIGINFO | SA_RESTART; + sigemptyset(&sa.sa_mask); + if (sigaction(sig, &sa, NULL) != 0) return -1; + if (sigaction(sig, NULL, &old) != 0) return -1; + *seen = (void *)old.sa_sigaction; + return 0; +} + +int install_oneshot(int sig, void (*fn)(int, siginfo_t *, void *)) { + struct sigaction sa; + memset(&sa, 0, sizeof(sa)); + sa.sa_sigaction = fn; + sa.sa_flags = SA_SIGINFO | SA_RESTART | SA_RESETHAND; + sigemptyset(&sa.sa_mask); + return sigaction(sig, &sa, NULL); +} +""" + +PROGRAM = r""" +#define _GNU_SOURCE +#include <errno.h> +#include <fcntl.h> +#include <linux/ioctl.h> +#include <pthread.h> +#include <semaphore.h> +#include <signal.h> +#include <stdint.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <sys/ioctl.h> +#include <sys/socket.h> +#include <sys/wait.h> +#include <time.h> +#include <unistd.h> + +struct sem_args { uint32_t count, max; }; +struct mutex_args { uint32_t owner, count; }; +struct event_args { uint32_t manual, signaled; }; +struct wait_args { uint64_t timeout, objs; uint32_t count, index, flags, owner, alert, pad; }; +#define NT_CREATE_SEM _IOW('N', 0x80, struct sem_args) +#define NT_SEM_RELEASE _IOWR('N', 0x81, uint32_t) +#define NT_WAIT_ANY _IOWR('N', 0x82, struct wait_args) +#define NT_WAIT_ALL _IOWR('N', 0x83, struct wait_args) +#define NT_CREATE_MUTEX _IOW('N', 0x84, struct mutex_args) +#define NT_MUTEX_UNLOCK _IOWR('N', 0x85, struct mutex_args) +#define NT_MUTEX_KILL _IOW('N', 0x86, uint32_t) +#define NT_CREATE_EVENT _IOW('N', 0x87, struct event_args) +#define NT_EVENT_SET _IOR('N', 0x88, uint32_t) +#define NT_EVENT_RESET _IOR('N', 0x89, uint32_t) +#define NT_EVENT_PULSE _IOR('N', 0x8a, uint32_t) +#define NT_SEM_READ _IOR('N', 0x8b, struct sem_args) +#define NT_MUTEX_READ _IOR('N', 0x8c, struct mutex_args) +#define NT_EVENT_READ _IOR('N', 0x8d, struct event_args) + +int install_handler(int sig, void (*fn)(int, siginfo_t *, void *), void **seen); +int install_oneshot(int sig, void (*fn)(int, siginfo_t *, void *)); + +static int dev, fails; +#define CHECK(name, cond) do { if (!(cond)) { printf("FAIL %s line %d errno %d\n", name, __LINE__, errno); fails++; } } while (0) + +static uint64_t mono_ns(int64_t delta_ms) { + struct timespec t; + clock_gettime(CLOCK_MONOTONIC, &t); + return (uint64_t)t.tv_sec * 1000000000ull + (uint64_t)t.tv_nsec + (uint64_t)(delta_ms * 1000000); +} + +static int wait_on(unsigned long req, const int *objs, uint32_t count, uint32_t owner, int alert, uint64_t timeout, uint32_t *index) { + struct wait_args w = {timeout, (uintptr_t)objs, count, 0xdead, 0, owner, (uint32_t)alert, 0}; + int r = ioctl(dev, req, &w); + if (index) *index = w.index; + return r < 0 ? errno : 0; +} + +static int event(int manual, int signaled) { + struct event_args a = {(uint32_t)manual, (uint32_t)signaled}; + return ioctl(dev, NT_CREATE_EVENT, &a); +} + +static int sem(uint32_t count, uint32_t max) { + struct sem_args a = {count, max}; + return ioctl(dev, NT_CREATE_SEM, &a); +} + +static int mutex(uint32_t owner, uint32_t count) { + struct mutex_args a = {owner, count}; + return ioctl(dev, NT_CREATE_MUTEX, &a); +} + +static uint32_t prev_of(int fd, unsigned long req, uint32_t in) { + uint32_t v = in; + if (ioctl(fd, req, &v) < 0) return 0xffffffff; + return v; +} + +static void semaphores(void) { + struct sem_args bad = {3, 2}, r; + CHECK("sem_create_invalid", ioctl(dev, NT_CREATE_SEM, &bad) < 0 && errno == EINVAL); + int s = sem(2, 2); + CHECK("sem_create", s >= 0); + uint32_t idx; + CHECK("sem_wait1", wait_on(NT_WAIT_ANY, &s, 1, 1, 0, 0, &idx) == 0 && idx == 0); + CHECK("sem_wait2", wait_on(NT_WAIT_ANY, &s, 1, 1, 0, 0, &idx) == 0); + CHECK("sem_empty", wait_on(NT_WAIT_ANY, &s, 1, 1, 0, 0, NULL) == ETIMEDOUT); + CHECK("sem_release_prev", prev_of(s, NT_SEM_RELEASE, 1) == 0); + CHECK("sem_overflow", prev_of(s, NT_SEM_RELEASE, 2) == 0xffffffff && errno == EOVERFLOW); + CHECK("sem_read", ioctl(s, NT_SEM_READ, &r) == 0 && r.count == 1 && r.max == 2); + close(s); +} + +static void mutexes(void) { + struct mutex_args bad = {0, 1}, r, u; + CHECK("mutex_create_invalid", ioctl(dev, NT_CREATE_MUTEX, &bad) < 0 && errno == EINVAL); + int m = mutex(0, 0); + uint32_t idx; + CHECK("mutex_owner_zero", wait_on(NT_WAIT_ANY, &m, 1, 0, 0, 0, NULL) == EINVAL); + CHECK("mutex_take", wait_on(NT_WAIT_ANY, &m, 1, 7, 0, 0, &idx) == 0 && idx == 0); + CHECK("mutex_recurse", wait_on(NT_WAIT_ANY, &m, 1, 7, 0, 0, NULL) == 0); + CHECK("mutex_other", wait_on(NT_WAIT_ANY, &m, 1, 8, 0, 0, NULL) == ETIMEDOUT); + CHECK("mutex_read", ioctl(m, NT_MUTEX_READ, &r) == 0 && r.owner == 7 && r.count == 2); + u = (struct mutex_args){8, 0}; + CHECK("mutex_unlock_eperm", ioctl(m, NT_MUTEX_UNLOCK, &u) < 0 && errno == EPERM); + u = (struct mutex_args){0, 0}; + CHECK("mutex_unlock_einval", ioctl(m, NT_MUTEX_UNLOCK, &u) < 0 && errno == EINVAL); + u = (struct mutex_args){7, 0}; + CHECK("mutex_unlock_prev", ioctl(m, NT_MUTEX_UNLOCK, &u) == 0 && u.count == 2); + uint32_t who = 8; + CHECK("mutex_kill_eperm", ioctl(m, NT_MUTEX_KILL, &who) < 0 && errno == EPERM); + who = 7; + CHECK("mutex_kill", ioctl(m, NT_MUTEX_KILL, &who) == 0); + CHECK("mutex_read_dead", ioctl(m, NT_MUTEX_READ, &r) < 0 && errno == EOWNERDEAD && r.owner == 0 && r.count == 0); + CHECK("mutex_abandoned", wait_on(NT_WAIT_ANY, &m, 1, 9, 0, 0, &idx) == EOWNERDEAD && idx == 0); + CHECK("mutex_after_abandon", ioctl(m, NT_MUTEX_READ, &r) == 0 && r.owner == 9 && r.count == 1); + close(m); +} + +static void events(void) { + int a = event(0, 1), m = event(1, 0); + struct event_args r; + CHECK("auto_take", wait_on(NT_WAIT_ANY, &a, 1, 1, 0, 0, NULL) == 0); + CHECK("auto_consumed", wait_on(NT_WAIT_ANY, &a, 1, 1, 0, 0, NULL) == ETIMEDOUT); + CHECK("set_prev0", prev_of(m, NT_EVENT_SET, 0) == 0); + CHECK("set_prev1", prev_of(m, NT_EVENT_SET, 0) == 1); + CHECK("manual_kept", wait_on(NT_WAIT_ANY, &m, 1, 1, 0, 0, NULL) == 0 && wait_on(NT_WAIT_ANY, &m, 1, 1, 0, 0, NULL) == 0); + CHECK("reset_prev", prev_of(m, NT_EVENT_RESET, 0) == 1); + CHECK("pulse_prev", prev_of(m, NT_EVENT_PULSE, 0) == 0); + CHECK("pulse_leaves_unsignaled", ioctl(m, NT_EVENT_READ, &r) == 0 && r.manual == 1 && r.signaled == 0); + close(a); + close(m); +} + +static void any_order(void) { + int o[4] = {event(1, 0), sem(1, 5), event(1, 1), sem(0, 5)}; + int alert = event(0, 1); + uint32_t idx; + CHECK("any_lowest", wait_on(NT_WAIT_ANY, o, 4, 1, alert, 0, &idx) == 0 && idx == 1); + CHECK("any_next", wait_on(NT_WAIT_ANY, o, 4, 1, alert, 0, &idx) == 0 && idx == 2); + prev_of(o[2], NT_EVENT_RESET, 0); + CHECK("any_alert", wait_on(NT_WAIT_ANY, o, 4, 1, alert, 0, &idx) == 0 && idx == 4); + CHECK("any_alert_consumed", wait_on(NT_WAIT_ANY, o, 4, 1, alert, 0, NULL) == ETIMEDOUT); + int dup[3] = {o[3], o[0], o[3]}; + prev_of(o[3], NT_SEM_RELEASE, 1); + CHECK("any_duplicate_lowest", wait_on(NT_WAIT_ANY, dup, 3, 1, 0, 0, &idx) == 0 && idx == 0); + int many[65]; + for (int i = 0; i < 65; i++) many[i] = o[0]; + CHECK("any_too_many", wait_on(NT_WAIT_ANY, many, 65, 1, 0, 0, NULL) == EINVAL); + int badfd = 0; + CHECK("any_invalid_fd", wait_on(NT_WAIT_ANY, &badfd, 1, 1, 0, 0, NULL) == EINVAL); + CHECK("any_timeout", wait_on(NT_WAIT_ANY, o, 1, 1, 0, mono_ns(30), NULL) == ETIMEDOUT); + for (int i = 0; i < 4; i++) close(o[i]); + close(alert); +} + +static void all_semantics(void) { + int s = sem(1, 5), e = event(0, 0), m = mutex(0, 0); + int o[3] = {s, e, m}; + struct sem_args sr; + uint32_t idx; + CHECK("all_not_ready", wait_on(NT_WAIT_ALL, o, 3, 4, 0, 0, NULL) == ETIMEDOUT); + CHECK("all_untouched", ioctl(s, NT_SEM_READ, &sr) == 0 && sr.count == 1); + prev_of(e, NT_EVENT_SET, 0); + CHECK("all_ready", wait_on(NT_WAIT_ALL, o, 3, 4, 0, 0, &idx) == 0 && idx == 0); + CHECK("all_took_sem", ioctl(s, NT_SEM_READ, &sr) == 0 && sr.count == 0); + int dup[2] = {s, s}; + CHECK("all_duplicate", wait_on(NT_WAIT_ALL, dup, 2, 4, 0, 0, NULL) == EINVAL); + int alert = event(1, 1); + CHECK("all_alert", wait_on(NT_WAIT_ALL, o, 3, 4, alert, 0, &idx) == 0 && idx == 3); + close(s); + close(e); + close(m); + close(alert); +} + +static int wake_obj; +static uint32_t wake_index; +static int wake_ret; +static void *waiter(void *p) { + int all = (int)(intptr_t)p; + int objs[2] = {wake_obj, wake_obj + 0}; + wake_ret = wait_on(all ? NT_WAIT_ALL : NT_WAIT_ANY, objs, 1, 3, 0, UINT64_MAX, &wake_index); + return NULL; +} + +static void cross_thread(void) { + wake_obj = event(0, 0); + pthread_t t; + pthread_create(&t, NULL, waiter, NULL); + usleep(30000); + prev_of(wake_obj, NT_EVENT_SET, 0); + pthread_join(t, NULL); + CHECK("thread_wake", wake_ret == 0 && wake_index == 0); + struct event_args r; + CHECK("thread_auto_reset", ioctl(wake_obj, NT_EVENT_READ, &r) == 0 && r.signaled == 0); + close(wake_obj); + int s = sem(0, 10); + wake_obj = s; + pthread_t ts[3]; + for (int i = 0; i < 3; i++) pthread_create(&ts[i], NULL, waiter, NULL); + usleep(30000); + CHECK("sem_release_three", prev_of(s, NT_SEM_RELEASE, 3) == 0); + for (int i = 0; i < 3; i++) pthread_join(ts[i], NULL); + struct sem_args sr; + CHECK("sem_all_taken", ioctl(s, NT_SEM_READ, &sr) == 0 && sr.count == 0); + close(s); +} + +static void cross_process(void) { + int sv[2]; + CHECK("socketpair", socketpair(AF_UNIX, SOCK_STREAM, 0, sv) == 0); + int inherited = event(0, 0); + pid_t pid = fork(); + if (pid == 0) { + uint32_t idx; + int r1 = wait_on(NT_WAIT_ANY, &inherited, 1, 5, 0, mono_ns(5000), &idx); + char c; + struct iovec io = {&c, 1}; + char cbuf[CMSG_SPACE(sizeof(int))]; + struct msghdr msg = {0}; + msg.msg_iov = &io; + msg.msg_iovlen = 1; + msg.msg_control = cbuf; + msg.msg_controllen = sizeof(cbuf); + int got = -1; + if (recvmsg(sv[1], &msg, 0) == 1) memcpy(&got, CMSG_DATA(CMSG_FIRSTHDR(&msg)), sizeof(int)); + int r2 = wait_on(NT_WAIT_ANY, &got, 1, 5, 0, mono_ns(5000), NULL); + _exit(r1 == 0 && r2 == 0 ? 0 : 1); + } + usleep(30000); + prev_of(inherited, NT_EVENT_SET, 0); + int sent = sem(0, 1); + char c = 'x'; + struct iovec io = {&c, 1}; + char cbuf[CMSG_SPACE(sizeof(int))]; + struct msghdr msg = {0}; + msg.msg_iov = &io; + msg.msg_iovlen = 1; + msg.msg_control = cbuf; + msg.msg_controllen = sizeof(cbuf); + struct cmsghdr *cm = CMSG_FIRSTHDR(&msg); + cm->cmsg_level = SOL_SOCKET; + cm->cmsg_type = SCM_RIGHTS; + cm->cmsg_len = CMSG_LEN(sizeof(int)); + memcpy(CMSG_DATA(cm), &sent, sizeof(int)); + sendmsg(sv[0], &msg, 0); + usleep(30000); + prev_of(sent, NT_SEM_RELEASE, 1); + int status = 0; + waitpid(pid, &status, 0); + CHECK("process_wake", WIFEXITED(status) && WEXITSTATUS(status) == 0); + close(inherited); + close(sent); + close(sv[0]); + close(sv[1]); +} + +static int ping_obj, pong_obj, stress_sem; +static void *stress_ponger(void *p) { + long n = (long)p; + uint32_t v; + for (long i = 0; i < n; i++) { + if (wait_on(NT_WAIT_ANY, &ping_obj, 1, 21, 0, UINT64_MAX, NULL) != 0) return (void *)1; + ioctl(pong_obj, NT_EVENT_SET, &v); + } + return NULL; +} +static void *stress_hammer(void *p) { + long n = (long)p; + for (long i = 0; i < n; i++) { + if (wait_on(NT_WAIT_ANY, &stress_sem, 1, 22, 0, UINT64_MAX, NULL) != 0) return (void *)1; + uint32_t c = 1; + ioctl(stress_sem, NT_SEM_RELEASE, &c); + } + return NULL; +} + +static void stress(void) { + long rounds = 20000; + ping_obj = event(0, 0); + pong_obj = event(0, 0); + pthread_t t; + void *bad = NULL; + pthread_create(&t, NULL, stress_ponger, (void *)rounds); + uint32_t v; + int ok = 1; + for (long i = 0; i < rounds && ok; i++) { + ioctl(ping_obj, NT_EVENT_SET, &v); + ok = wait_on(NT_WAIT_ANY, &pong_obj, 1, 20, 0, UINT64_MAX, NULL) == 0; + } + pthread_join(t, &bad); + CHECK("stress_pingpong", ok && bad == NULL); + stress_sem = sem(1, 1); + pthread_t th[4]; + for (int i = 0; i < 4; i++) pthread_create(&th[i], NULL, stress_hammer, (void *)5000L); + int hammer_ok = 1; + for (int i = 0; i < 4; i++) { + pthread_join(th[i], &bad); + hammer_ok &= bad == NULL; + } + struct sem_args sr; + CHECK("stress_contended", hammer_ok && ioctl(stress_sem, NT_SEM_READ, &sr) == 0 && sr.count == 1); + close(ping_obj); + close(pong_obj); + close(stress_sem); +} + +static int busy_obj; +static volatile int handled, stop_worker; +static sem_t parked, unpark; +static void reentrant(int sig, siginfo_t *si, void *uc) { + (void)sig; (void)si; (void)uc; + uint32_t v = 0; + ioctl(busy_obj, NT_EVENT_SET, &v); + handled++; +} +static void suspending(int sig, siginfo_t *si, void *uc) { + (void)sig; (void)si; (void)uc; + sem_post(&parked); + while (sem_wait(&unpark) != 0) {} + handled++; +} +static void *worker(void *p) { + (void)p; + uint32_t v; + while (!stop_worker) { + ioctl(busy_obj, NT_EVENT_SET, &v); + wait_on(NT_WAIT_ANY, &busy_obj, 1, 11, 0, 0, NULL); + } + return NULL; +} + +static void signals(void) { + void *seen = NULL; + busy_obj = event(0, 0); + CHECK("install_reentrant", install_handler(SIGUSR1, reentrant, &seen) == 0); + CHECK("query_original", seen == (void *)reentrant); + pthread_t w; + stop_worker = 0; + handled = 0; + pthread_create(&w, NULL, worker, NULL); + for (int i = 0; i < 2000; i++) { + pthread_kill(w, SIGUSR1); + if (i % 64 == 0) usleep(100); + } + usleep(20000); + stop_worker = 1; + pthread_join(w, NULL); + CHECK("reentrant_ran", handled > 0); + sem_init(&parked, 0, 0); + sem_init(&unpark, 0, 0); + CHECK("install_suspending", install_handler(SIGUSR1, suspending, &seen) == 0); + stop_worker = 0; + handled = 0; + pthread_create(&w, NULL, worker, NULL); + uint32_t v; + for (int i = 0; i < 300; i++) { + pthread_kill(w, SIGUSR1); + while (sem_wait(&parked) != 0) {} + ioctl(busy_obj, NT_EVENT_SET, &v); + wait_on(NT_WAIT_ANY, &busy_obj, 1, 12, 0, 0, NULL); + sem_post(&unpark); + } + stop_worker = 1; + pthread_join(w, NULL); + CHECK("suspend_ran", handled == 300); + close(busy_obj); +} + +static volatile int burst_count; +static void bursting(int sig, siginfo_t *si, void *uc) { + (void)sig; (void)si; (void)uc; + uint32_t v = 0; + ioctl(busy_obj, NT_EVENT_SET, &v); + __atomic_add_fetch(&burst_count, 1, __ATOMIC_RELAXED); +} + +static void bursts(void) { + void *seen = NULL; + busy_obj = event(0, 0); + for (int s = 0; s < 8; s++) CHECK("install_burst", install_handler(SIGRTMIN + s, bursting, &seen) == 0); + pthread_t w; + stop_worker = 0; + burst_count = 0; + pthread_create(&w, NULL, worker, NULL); + union sigval val = {0}; + int sent = 0; + for (int i = 0; i < 400; i++) { + for (int s = 0; s < 8; s++) + if (pthread_sigqueue(w, SIGRTMIN + s, val) == 0) sent++; + usleep(200); + } + for (int i = 0; i < 2000 && __atomic_load_n(&burst_count, __ATOMIC_RELAXED) < sent; i++) usleep(1000); + stop_worker = 1; + pthread_join(w, NULL); + CHECK("burst_all_handled", burst_count == sent && sent > 0); + close(busy_obj); +} + +static volatile int oneshot_count; +static void oneshot(int sig, siginfo_t *si, void *uc) { + (void)sig; (void)si; (void)uc; + __atomic_add_fetch(&oneshot_count, 1, __ATOMIC_RELAXED); +} + +static void oneshots(void) { + busy_obj = event(0, 0); + pthread_t w; + stop_worker = 0; + oneshot_count = 0; + pthread_create(&w, NULL, worker, NULL); + int fired = 0; + for (int i = 0; i < 300; i++) { + if (install_oneshot(SIGWINCH, oneshot) != 0) break; + int before = __atomic_load_n(&oneshot_count, __ATOMIC_RELAXED); + pthread_kill(w, SIGWINCH); + fired++; + for (int j = 0; j < 2000 && __atomic_load_n(&oneshot_count, __ATOMIC_RELAXED) == before; j++) usleep(50); + } + stop_worker = 1; + pthread_join(w, NULL); + CHECK("oneshot_handlers_run", oneshot_count == fired && fired == 300); + close(busy_obj); +} + +static int outer_obj, inner_obj, other_dev, other_obj; +static volatile int nested_ok, nested_done, outer_done; +static void nesting(int sig, siginfo_t *si, void *uc) { + (void)sig; (void)si; (void)uc; + uint32_t idx = 99; + int ok = wait_on(NT_WAIT_ANY, &inner_obj, 1, 21, 0, mono_ns(50), &idx) == 0 && idx == 0; + struct wait_args w = {mono_ns(2), (uintptr_t)&other_obj, 1, 0xdead, 0, 22, 0, 0}; + ok = ok && ioctl(other_dev, NT_WAIT_ANY, &w) < 0 && errno == ETIMEDOUT; + nested_ok += ok; + __atomic_store_n(&nested_done, 1, __ATOMIC_RELEASE); +} +static void *outer_waiter(void *p) { + struct wait_args w = {mono_ns(3000), (uintptr_t)&outer_obj, 1, 99, 0, 23, 0, 0}; + int r; + do { r = ioctl(dev, NT_WAIT_ANY, &w); } while (r < 0 && errno == EINTR); + *(int *)p = r == 0 && w.index == 0; + __atomic_store_n(&outer_done, 1, __ATOMIC_RELEASE); + return NULL; +} + +static void nested_waits(void) { + void *seen = NULL; + other_dev = open("/dev/ntsync", O_RDWR | O_CLOEXEC); + CHECK("second_device", other_dev >= 0); + struct event_args ea = {0, 0}; + other_obj = ioctl(other_dev, NT_CREATE_EVENT, &ea); + inner_obj = event(1, 1); + CHECK("install_nesting", install_handler(SIGUSR2, nesting, &seen) == 0); + int good = 0, early = 0; + nested_ok = 0; + for (int i = 0; i < 40; i++) { + outer_obj = event(0, 0); + int result = 0; + pthread_t t; + nested_done = outer_done = 0; + pthread_create(&t, NULL, outer_waiter, &result); + usleep(5000); + pthread_kill(t, SIGUSR2); + while (!__atomic_load_n(&nested_done, __ATOMIC_ACQUIRE)) usleep(200); + usleep(5000); + early += __atomic_load_n(&outer_done, __ATOMIC_ACQUIRE); + uint32_t v = 0; + ioctl(outer_obj, NT_EVENT_SET, &v); + pthread_join(t, NULL); + good += result; + close(outer_obj); + } + CHECK("outer_wait_not_woken_by_nested_wait", early == 0); + CHECK("outer_wait_survives_nested_waits", good == 40); + CHECK("nested_waits_complete", nested_ok == 40); + close(inner_obj); + close(other_obj); + close(other_dev); +} + +int main(void) { + alarm(60); + dev = open("/dev/ntsync", O_RDWR | O_CLOEXEC); + if (dev < 0) { + printf("no device %d\n", errno); + return 1; + } + semaphores(); + mutexes(); + events(); + any_order(); + all_semantics(); + cross_thread(); + cross_process(); + stress(); + signals(); + bursts(); + oneshots(); + nested_waits(); + printf("done fails=%d\n", fails); + return fails != 0; +} +""" + + +class NtsyncPreloadTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + if not shutil.which("cc"): + raise unittest.SkipTest("no compiler") + cls.tmp = tempfile.TemporaryDirectory() + tmp = Path(cls.tmp.name) + (tmp / "ntdll.c").write_text(NTDLL) + (tmp / "prog.c").write_text(PROGRAM) + sources = sorted(str(p) for p in PRELOAD.glob("*.c")) + subprocess.run(["cc", "-shared", "-fPIC", "-O2", "-Wall", "-pthread", "-o", str(tmp / "libblsession.so"), *sources, "-ldl"], + check=True) + subprocess.run(["cc", "-shared", "-fPIC", "-O2", "-o", str(tmp / "ntdll.so"), str(tmp / "ntdll.c")], check=True) + subprocess.run(["cc", "-O1", "-pthread", "-o", str(tmp / "prog"), str(tmp / "prog.c"), str(tmp / "ntdll.so"), + "-Wl,-rpath," + str(tmp)], check=True) + cls.program = tmp / "prog" + cls.preload = tmp / "libblsession.so" + + @classmethod + def tearDownClass(cls): + cls.tmp.cleanup() + + def run_program(self): + env = dict(os.environ, LD_PRELOAD=str(self.preload), BL_SYNC="1") + return subprocess.run([str(self.program)], env=env, capture_output=True, text=True, timeout=90) + + def test_emulation_matches_the_ntsync_driver(self): + out = self.run_program() + self.assertEqual(out.returncode, 0, out.stdout + out.stderr) + self.assertIn("done fails=0", out.stdout) + + def test_repeated_runs_stay_consistent(self): + for _ in range(3): + out = self.run_program() + self.assertEqual(out.returncode, 0, out.stdout + out.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/tests/test_openat2_preload.py b/tools/tests/test_openat2_preload.py index a30aa74dd..eb27bcc7e 100644 --- a/tools/tests/test_openat2_preload.py +++ b/tools/tests/test_openat2_preload.py @@ -230,7 +230,7 @@ class Openat2PreloadTest(unittest.TestCase): cls.tmp = Path(tempfile.mkdtemp()) cls.lib = cls.tmp / "libopenat2.so" subprocess.run(["cc", "-O2", "-Wall", "-Werror", "-fPIC", "-shared", "-pthread", "-o", str(cls.lib), - str(PRELOAD / "robust.c"), str(PRELOAD / "fsync.c"), str(PRELOAD / "noexec.c"), "-ldl"], check=True) + str(PRELOAD / "robust.c"), str(PRELOAD / "fsync.c"), str(PRELOAD / "noexec.c"), str(PRELOAD / "writewatch.c"), "-ldl"], check=True) source = cls.tmp / "openat2.c" source.write_text(PROGRAM) subprocess.run(["cc", "-O2", "-Wall", "-Werror", "-o", str(cls.tmp / "openat2"), str(source), "-ldl"], check=True) diff --git a/tools/tests/test_output_refresh.py b/tools/tests/test_output_refresh.py index 7b252649e..456e230d4 100644 --- a/tools/tests/test_output_refresh.py +++ b/tools/tests/test_output_refresh.py @@ -66,6 +66,8 @@ static void send_toplevel_configure(struct surface *s) { (void)s; resizes++; } #define pointer_button(...) ((void)0) #define scroll_event(...) ((void)0) #define axis_event(...) ((void)0) +#define stream_take(...) ((void)0) +#define stream_forget_all(...) ((void)0) #define pointer_delta(...) ((void)0) #define deliver_touch(m) ((void)0) #define deliver_pointer(m) (pointers++) diff --git a/tools/tests/test_pack_downloads.py b/tools/tests/test_pack_downloads.py new file mode 100644 index 000000000..0b5eb6060 --- /dev/null +++ b/tools/tests/test_pack_downloads.py @@ -0,0 +1,125 @@ +import hashlib +import http.server +import json +import os +from pathlib import Path +import shutil +import socket +import subprocess +import tempfile +import threading +import unittest + + +ROOT = Path(__file__).resolve().parents[2] +FETCH = ROOT / 'tools/packs/fetch-verified.sh' + + +class PackDownloadsTest(unittest.TestCase): + def setUp(self): + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + self.root = Path(directory.name) + self.cache = self.root / 'cache' + self.cache.mkdir() + self.destination = self.root / 'download' + self.remote = self.root / 'remote' + self.remote.write_bytes(b'verified input') + self.checksum = hashlib.sha256(self.remote.read_bytes()).hexdigest() + self.log = self.root / 'curl.log' + tool = self.root / 'bin/curl' + tool.parent.mkdir() + tool.write_text('#!/usr/bin/env python3\nimport json,os,shutil,sys\n' + 'args=sys.argv[1:]\n' + 'with open(os.environ["CURL_LOG"],"a") as f: f.write(json.dumps(args)+"\\n")\n' + 'shutil.copyfile(os.environ["REMOTE"],args[args.index("-o")+1])\n') + tool.chmod(0o755) + self.env = dict(os.environ, DROIDDECK_PACK_CACHE=str(self.cache), CURL_LOG=str(self.log), + REMOTE=str(self.remote), PATH=str(tool.parent) + os.pathsep + os.environ['PATH']) + + def fetch(self, checksum=None, url='https://example.test/pinned', env=None): + return subprocess.run(['bash', str(FETCH), url, checksum or self.checksum, str(self.destination)], + env=env or self.env, capture_output=True, text=True) + + def test_cold_download_then_warm_reuse_and_corruption_repair(self): + self.assertEqual(self.fetch().returncode, 0) + self.assertEqual(self.fetch().returncode, 0) + self.assertEqual(len(self.log.read_text().splitlines()), 1) + self.assertEqual(self.destination.read_bytes(), b'verified input') + (self.cache / self.checksum).write_bytes(b'corrupt') + self.assertEqual(self.fetch().returncode, 0) + self.assertEqual(len(self.log.read_text().splitlines()), 2) + self.assertEqual((self.cache / self.checksum).read_bytes(), b'verified input') + + def test_directory_cache_entry_is_replaced_only_after_verification(self): + entry = self.cache / self.checksum + entry.mkdir() + sentinel = entry / 'old-entry' + sentinel.write_bytes(b'preserve until verified') + self.remote.write_bytes(b'tampered download') + self.assertNotEqual(self.fetch().returncode, 0) + self.assertEqual(sentinel.read_bytes(), b'preserve until verified') + self.assertFalse(self.destination.exists()) + self.remote.write_bytes(b'verified input') + result = self.fetch() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue(entry.is_file()) + self.assertEqual(entry.read_bytes(), b'verified input') + self.assertEqual(self.destination.read_bytes(), b'verified input') + self.assertEqual(list(self.cache.iterdir()), [entry]) + self.assertEqual(self.fetch().returncode, 0) + self.assertEqual(len(self.log.read_text().splitlines()), 2) + + def test_changed_pin_cannot_reuse_old_bytes(self): + self.assertEqual(self.fetch().returncode, 0) + self.remote.write_bytes(b'new approved input') + updated = hashlib.sha256(self.remote.read_bytes()).hexdigest() + self.assertEqual(self.fetch(updated).returncode, 0) + self.assertEqual(len(self.log.read_text().splitlines()), 2) + self.assertEqual(self.destination.read_bytes(), b'new approved input') + + def test_bad_download_is_not_cached_or_written(self): + self.remote.write_bytes(b'tampered download') + self.assertNotEqual(self.fetch().returncode, 0) + self.assertFalse((self.cache / self.checksum).exists()) + self.assertFalse(self.destination.exists()) + self.assertEqual(list(self.cache.iterdir()), []) + arguments = json.loads(self.log.read_text()) + self.assertIn('--retry-all-errors', arguments) + self.assertIn('--max-time', arguments) + self.assertIn('--retry-max-time', arguments) + + def test_connection_reset_retries_then_verifies_real_download(self): + if not shutil.which('curl'): + self.skipTest('curl required for transport test') + content = self.remote.read_bytes() + attempts = [] + + class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + attempts.append(True) + if len(attempts) == 1: + self.connection.shutdown(socket.SHUT_RDWR) + self.connection.close() + return + self.send_response(200) + self.send_header('Content-Length', str(len(content))) + self.end_headers() + self.wfile.write(content) + + def log_message(self, *args): + pass + + server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + env = dict(self.env, PATH=os.environ['PATH']) + result = self.fetch(url=f'http://127.0.0.1:{server.server_port}/input', env=env) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertGreaterEqual(len(attempts), 2) + self.assertEqual(self.destination.read_bytes(), content) + finally: + server.shutdown() + server.server_close() + thread.join() diff --git a/tools/tests/test_pipewire_helper.py b/tools/tests/test_pipewire_helper.py index e8abd59fc..8e0c2343b 100644 --- a/tools/tests/test_pipewire_helper.py +++ b/tools/tests/test_pipewire_helper.py @@ -74,6 +74,36 @@ class PipeWireHelperTest(unittest.TestCase): self.assertIn('did not open', result.stderr) self.assertFalse((self.runtime / 'droiddeck-pipewire/pids').exists()) + def test_clients_get_full_access_without_the_flatpak_check(self): + self.pack('python3 -c "import socket,sys; s=socket.socket(socket.AF_UNIX); s.bind(sys.argv[1]); ' + 'import time; time.sleep(30)" "$XDG_RUNTIME_DIR/pipewire-0"') + for _ in range(2): + result = self.run_helper('start') + self.assertEqual(result.returncode, 0, result.stderr) + daemon = (self.dir / 'share/pipewire/pipewire.conf.d/droiddeck-access.conf').read_text() + self.assertIn('module.access = false', daemon) + self.assertIn('name = libpipewire-module-access', daemon) + self.assertIn('access.socket = { pipewire-0 = "unrestricted", pipewire-0-manager = "unrestricted" }', + daemon) + self.assertNotIn('access.legacy', daemon) + manager = (self.dir / 'share/wireplumber/wireplumber.conf.d/droiddeck-access.conf').read_text() + self.assertIn('matches = [ { pipewire.access = "unrestricted" } ]', manager) + self.assertIn('default_permissions = "all"', manager) + + def test_start_fails_cleanly_when_the_access_settings_cannot_be_written(self): + self.pack('exec sleep 30') + self.run_helper('start') + self.run_helper('stop') + conf = self.dir / 'share/pipewire/pipewire.conf.d' + for child in conf.iterdir(): + child.unlink() + conf.rmdir() + conf.write_text('') + result = self.run_helper('start') + self.assertNotEqual(result.returncode, 0) + self.assertIn('could not write the access settings', result.stderr) + self.assertFalse((self.runtime / 'droiddeck-pipewire/pids').exists()) + def test_unchanged_bundle_is_not_unpacked_again(self): self.pack('exec sleep 30') self.run_helper('start') diff --git a/tools/tests/test_preload_snapshots.py b/tools/tests/test_preload_snapshots.py new file mode 100644 index 000000000..464b24e7b --- /dev/null +++ b/tools/tests/test_preload_snapshots.py @@ -0,0 +1,187 @@ +import copy +import importlib.util +import io +import json +import os +from pathlib import Path +import subprocess +import tarfile +import tempfile +import unittest +from unittest.mock import patch + +SPEC = importlib.util.spec_from_file_location('preload_snapshots', Path(__file__).resolve().parents[1] / 'components/preloads.py') +snapshots = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(snapshots) + + +def archive(files): + stream = io.BytesIO() + with tarfile.open(fileobj=stream, mode='w:gz') as tar: + for name, content in files.items(): + entry = tarfile.TarInfo(name) + entry.size = len(content) + tar.addfile(entry, io.BytesIO(content)) + return stream.getvalue() + + +class PreloadSnapshotTest(unittest.TestCase): + def setUp(self): + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + self.root = Path(directory.name) + # Real repository inputs exercise the transitive include walker and build recipe. + for group in snapshots.INPUTS: + for name in snapshots.source_files(group): + if name.startswith('@'): + continue + target = self.root / name + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes((snapshots.ROOT / name).read_bytes()) + recipe = 'tools/components/build-arm64-preloads.sh' + (self.root / recipe).parent.mkdir(parents=True, exist_ok=True) + (self.root / recipe).write_text((snapshots.ROOT / recipe).read_text()) + x86 = 'tools/components/recipes/x86-preloads.sh' + (self.root / x86).parent.mkdir(parents=True, exist_ok=True) + (self.root / x86).write_text((snapshots.ROOT / x86).read_text()) + (self.root / '.github/workflows').mkdir(parents=True) + (self.root / '.github/workflows/build.yml').write_text((snapshots.ROOT / '.github/workflows/build.yml').read_text()) + self.git('init', '-q') + self.git('config', 'core.autocrlf', 'false') + self.git('config', 'user.name', 'Test Developer') + self.git('config', 'user.email', 'test@example.test') + self.git('add', '.') + self.binaries = {name: ('verified-' + name).encode() for name in snapshots.FILES} + self.content = archive(self.binaries) + self.data = dict(version=1, origin=dict(run=123, commit='abc', artifact=456, artifact_sha256='0' * 64), + groups={g: snapshots.source_files(g, self.root) for g in snapshots.INPUTS}, + arm64_recipe=snapshots.recipe(self.root), x86_recipe=snapshots.x86_recipe(self.root), + archive_sha256=snapshots.sha(self.content), + outputs={name: snapshots.sha(value) for name, value in self.binaries.items()}) + (self.root / snapshots.LOCK).write_text(json.dumps(self.data)) + self.git('add', '.') + self.git('commit', '-qm', 'Approved baseline') + self.base = self.git('rev-parse', 'HEAD').strip() + + def git(self, *args): + return subprocess.check_output(['git', *args], cwd=self.root, text=True) + + def restore(self, base=None): + with patch.dict(os.environ, COMPONENT_TRUSTED_BASE=base or self.base, FORCE_PRELOAD_REBUILD='false'), \ + patch.object(snapshots.urllib.request, 'urlopen', return_value=io.BytesIO(self.content)) as fetch, \ + patch.object(snapshots, 'prove_origin') as prove: + result = snapshots.restore(self.root) + return result, fetch, prove + + def test_reviewed_unchanged_binaries_reused_and_written(self): + result, fetch, prove = self.restore() + self.assertEqual(result, dict(arm64=True, x86=True)) + prove.assert_not_called() + fetch.assert_called_once() + for name, value in self.binaries.items(): + self.assertEqual((self.root / 'app/src/main/assets/linuxfs' / name).read_bytes(), value) + + def test_changed_common_source_requires_both_rebuilds(self): + path = self.root / snapshots.COMMON[0] + path.write_text(path.read_text() + '\n// Changed source\n') + result, fetch, prove = self.restore() + self.assertEqual(result, dict(arm64=False, x86=False)) + fetch.assert_not_called() + prove.assert_not_called() + + def test_new_transitive_header_is_an_input(self): + source = self.root / snapshots.COMMON[0] + source.write_text(source.read_text() + '\n#include "new-header.h"\n') + header = source.with_name('new-header.h') + header.write_text('#define VALUE 1\n') + before = snapshots.source_files('arm64', self.root) + header.write_text('#define VALUE 2\n') + self.assertNotEqual(before, snapshots.source_files('arm64', self.root)) + + def test_changed_recipe_rebuilds_arm64_only(self): + script = self.root / 'tools/components/build-arm64-preloads.sh' + script.write_text(script.read_text().replace('-O2', '-O3')) + result, _, _ = self.restore() + self.assertEqual(result, dict(arm64=False, x86=True)) + + def test_shell_newline_changes_invalidate_the_recipe(self): + script = self.root / 'tools/components/build-arm64-preloads.sh' + original = snapshots.recipe(self.root) + text = script.read_text() + script.write_text(text.replace('-ldl\n', '-ldl ', 1)) + self.assertNotEqual(original, snapshots.recipe(self.root)) + + def test_x86_container_recipe_changes_rebuild_x86_only(self): + script = self.root / 'tools/components/recipes/x86-preloads.sh' + script.write_text(script.read_text().replace('debian:bullseye', 'debian:bookworm')) + result, _, _ = self.restore() + self.assertEqual(result, dict(arm64=True, x86=False)) + + def test_new_manifest_requires_main_build_proof(self): + result, _, prove = self.restore('HEAD~1') + self.assertTrue(all(result.values())) + prove.assert_called_once() + + def test_forged_binary_hash_cannot_prove_main_origin(self): + data = copy.deepcopy(self.data) + data['outputs']['libblsession.so'] = 'f' * 64 + with patch.object(snapshots, 'artifact_bytes', return_value=self.binaries): + with self.assertRaisesRegex(ValueError, 'original main APK'): + snapshots.prove_origin(data, self.root / 'cache', self.root) + + def test_modified_input_policy_cannot_omit_producer_dependencies(self): + data = copy.deepcopy(self.data) + data['origin']['commit'] = self.base + omitted = snapshots.COMMON[0] + del data['groups']['arm64'][omitted] + with patch.object(snapshots, 'artifact_bytes', return_value=self.binaries), \ + patch.object(snapshots, 'run', return_value=b''), \ + patch.object(snapshots, 'source_files', side_effect=lambda group, *args: data['groups'][group]), \ + patch.object(snapshots, 'producer_sources', return_value={omitted: self.data['groups']['arm64'][omitted]}): + with self.assertRaisesRegex(ValueError, 'producing build dependency'): + snapshots.prove_origin(data, self.root / 'cache', self.root) + + def test_non_main_or_unsuccessful_run_cannot_prove_origin(self): + build = dict(event='pull_request', head_branch='main', conclusion='success', head_sha='abc', + path='.github/workflows/build.yml', repository=dict(full_name=snapshots.REPO)) + for field, value in [('event', 'pull_request'), ('head_branch', 'feature'), ('conclusion', 'failure')]: + candidate = dict(build, event='push') + candidate[field] = value + with self.subTest(field=field), patch.object(snapshots, 'api', return_value=candidate): + with self.assertRaisesRegex(ValueError, 'successful main'): + snapshots.artifact_bytes(self.data['origin'], self.root / 'cache') + + def test_corrupt_archive_is_rejected(self): + with self.assertRaisesRegex(ValueError, 'checksum'): + snapshots.unpack(self.content + b'tampered', self.data) + + def test_missing_duplicate_or_traversal_entries_are_rejected(self): + for files in ({}, {'../escaped': b'bad'}, {'libblsession.so': b'wrong'}): + content = archive(files) + data = dict(self.data, archive_sha256=snapshots.sha(content)) + with self.subTest(names=list(files)), self.assertRaises(ValueError): + snapshots.unpack(content, data) + + def test_missing_release_rebuilds_without_accepting_unverified_bytes(self): + error = snapshots.urllib.error.HTTPError('https://example.test', 404, 'missing', {}, None) + with patch.dict(os.environ, COMPONENT_TRUSTED_BASE=self.base), \ + patch.object(snapshots.urllib.request, 'urlopen', side_effect=error): + self.assertEqual(snapshots.restore(self.root), dict(arm64=False, x86=False)) + + def test_transient_download_failure_rebuilds_but_integrity_errors_fail(self): + errors = [TimeoutError('network'), snapshots.http.client.IncompleteRead(b'partial'), + snapshots.urllib.error.HTTPError('https://example.test', 503, 'unavailable', {}, None)] + for error in errors: + with self.subTest(error=type(error).__name__), patch.dict(os.environ, COMPONENT_TRUSTED_BASE=self.base), \ + patch.object(snapshots.urllib.request, 'urlopen', side_effect=error): + self.assertEqual(snapshots.restore(self.root), dict(arm64=False, x86=False)) + with patch.dict(os.environ, COMPONENT_TRUSTED_BASE=self.base), \ + patch.object(snapshots.urllib.request, 'urlopen', return_value=io.BytesIO(b'corrupt')): + with self.assertRaisesRegex(ValueError, 'checksum'): + snapshots.restore(self.root) + + def test_manual_rebuild_does_not_download_snapshot(self): + with patch.dict(os.environ, FORCE_PRELOAD_REBUILD='true'), \ + patch.object(snapshots.urllib.request, 'urlopen') as fetch: + self.assertEqual(snapshots.restore(self.root), dict(arm64=False, x86=False)) + fetch.assert_not_called() diff --git a/tools/tests/test_proot_profile.py b/tools/tests/test_proot_profile.py new file mode 100644 index 000000000..954870515 --- /dev/null +++ b/tools/tests/test_proot_profile.py @@ -0,0 +1,91 @@ +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest + +TOOLS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(TOOLS)) +from proot_profile import report + +PROGRAM = r''' +#define _POSIX_C_SOURCE 200809L +#define PR_NB_SYSNUM 2 +#define PR_void 0 +static const char *stringify_sysnum(unsigned nr) { return nr ? "openat" : "void"; } +#include "profile.h" +int main(void) { + errno = EBUSY; + if (!dd_profile_enabled()) return errno != EBUSY; + if (errno != EBUSY) return 1; + for (int i = 0; i < 40; i++) { + uint64_t wall = dd_profile_clock(CLOCK_MONOTONIC); + uint64_t cpu = dd_profile_clock(CLOCK_THREAD_CPUTIME_ID); + struct timespec wait = {0, 2000000}; + nanosleep(&wait, 0); + errno = ENOENT; + dd_profile_record(1, 1, 123, wall, cpu); + if (errno != ENOENT) return 2; + if (i == 9) dd_profile_flush(); + } + dd_profile_next = 0; + uint64_t wall = dd_profile_clock(CLOCK_MONOTONIC); + uint64_t cpu = dd_profile_clock(CLOCK_THREAD_CPUTIME_ID); + struct timespec wait = {0, 2000000}; + nanosleep(&wait, 0); + dd_profile_record(1, 0, 123, wall, cpu); + return 0; +} +''' + + +class ProotProfileTest(unittest.TestCase): + def test_snapshots_replace_instead_of_adding_and_tracers_remain_separate(self): + with tempfile.TemporaryDirectory() as work: + path = Path(work) / 'session.log' + records = [dict(type='total', tracer=1, id=2, syscall='statx', stage='enter', + calls=n, wall_ns=n * 100, cpu_ns=n * 10, max_ns=100) for n in (3, 8)] + records.append(dict(records[-1], tracer=2, calls=2)) + records += [dict(type='total', tracer=1, id=3, syscall='openat', stage='enter', + calls=1, wall_ns=1000, cpu_ns=1000, max_ns=1000)] + path.write_text('unrelated log\n' + ''.join('prefix proot-profile ' + json.dumps(r) + '\n' for r in records) + + 'proot-profile {truncated\n' + 'proot-profile []\n') + result = report(path) + self.assertEqual(11, result['handlerCalls']) + self.assertEqual(3, result['tracers']) + self.assertEqual(2, result['malformedRecords']) + self.assertEqual('statx', result['totals'][0]['syscall']) + self.assertEqual('openat', report(path, 'cpu')['totals'][0]['syscall']) + + def test_empty_logs_do_not_claim_profiling_ran(self): + with tempfile.TemporaryDirectory() as work: + path = Path(work) / 'session.log' + path.write_text('ordinary log\n') + self.assertFalse(report(path)['hasCounters']) + + @unittest.skipUnless(shutil.which('cc'), 'C compiler required') + def test_real_clocks_bounded_samples_errno_and_periodic_counters(self): + with tempfile.TemporaryDirectory() as work: + source = Path(work) / 'profile.c' + binary = Path(work) / 'profile' + source.write_text(PROGRAM) + subprocess.run(['cc', '-Wall', '-Wextra', '-Werror', '-std=c99', '-I', str(TOOLS / 'proot'), + str(source), '-o', str(binary)], check=True) + for setting in ('0', ''): + result = subprocess.run([binary], env=dict(os.environ, PROOT_PROFILE=setting), capture_output=True, check=True) + self.assertEqual(b'', result.stderr) + result = subprocess.run([binary], env=dict(os.environ, PROOT_PROFILE='1', PROOT_PROFILE_SLOW_US='1'), + capture_output=True, check=True) + rows = [json.loads(line.split(b'proot-profile ', 1)[1]) for line in result.stderr.splitlines()] + self.assertEqual(33, sum(row['type'] == 'slow' for row in rows)) + self.assertEqual(32, sum(row['type'] == 'slow' and row['stage'] == 'enter' for row in rows)) + self.assertGreater(sum(row['type'] == 'total' for row in rows), 2) + log = Path(work) / 'session.log' + log.write_bytes(result.stderr) + counters = report(log) + self.assertEqual(41, counters['handlerCalls']) + self.assertGreater(counters['handlerWallNs'], counters['handlerCpuNs']) + self.assertEqual(40, counters['totals'][0]['calls']) diff --git a/tools/tests/test_seed_redists.py b/tools/tests/test_seed_redists.py index 042ab87d4..bc71403f8 100644 --- a/tools/tests/test_seed_redists.py +++ b/tools/tests/test_seed_redists.py @@ -45,6 +45,26 @@ VULKAN_SCRIPT = r'''"InstallScript" ''' +UBISOFT_SCRIPT = r'''"installscript" +{ + "Run Process" + { + "Ubisoft Connect PC Client" + { + "process 1" "%INSTALLDIR%\\UbisoftConnectInstaller.exe" + "command 1" "/S" + } + "easyanticheat" + { + "HasRunKey" "HKEY_CURRENT_USER\\SOFTWARE\\Ubisoft\\WatchDogs2EAC" + "process 1" "%INSTALLDIR%\\EasyAntiCheat\\InstallWD2EAC.cmd" + "command 1" "" + } + } +} +''' + + class SeedRedistsTest(unittest.TestCase): def setUp(self): self.tmp = Path(tempfile.mkdtemp()) @@ -353,5 +373,33 @@ class SeedRedistsTest(unittest.TestCase): self.assertEqual(self.reg.read_text().count('"Vulkan111080"'), 2) + def ubisoft(self): + script = self.tmp / "legacycompat/evaluatorscript_447040.vdf" + script.parent.mkdir(parents=True) + script.write_text('"evaluatorscript"\n{\n\t"appid"\t\t"447040"\n\t"1"\n' + + "\n".join("\t" + line for line in UBISOFT_SCRIPT.splitlines()) + "\n}\n") + env = dict(os.environ, DROIDDECK_LIBRARIES="", DROIDDECK_SEED_SCRIPTS=str(script)) + return subprocess.run(["bash", str(SCRIPT), str(self.steam)], env=env, capture_output=True, text=True, check=True) + + def test_the_ubisoft_connect_installer_is_left_to_run(self): + self.ubisoft() + self.assertIn('"easyanticheat"=dword:00000001', self.user.read_text()) + self.assertNotIn("Ubisoft Connect PC Client", self.reg.read_text()) + self.assertNotIn("Ubisoft Connect PC Client", (self.reg.parent / ".droiddeck-redists").read_text()) + + def test_a_ubisoft_connect_marker_an_earlier_seeding_wrote_comes_out(self): + marker = "HKEY_LOCAL_MACHINE\\\\Software\\\\Valve\\\\Steam\\\\Apps\\\\447040\tUbisoft Connect PC Client" + self.reg.write_text( + "WINE REGISTRY Version 2\n" + "\n[Software\\\\Valve\\\\Steam\\\\Apps\\\\447040] 1791729070\n" + '"Ubisoft Connect PC Client"=dword:00000001\n' + "\n[Software\\\\Wow6432Node\\\\Valve\\\\Steam\\\\Apps\\\\447040] 1791729070\n" + '"Ubisoft Connect PC Client"=dword:00000001\n') + (self.reg.parent / ".droiddeck-redists").write_text(marker + "\n") + result = self.ubisoft() + self.assertNotIn("Ubisoft Connect PC Client", self.reg.read_text()) + self.assertNotIn("Ubisoft Connect PC Client", (self.reg.parent / ".droiddeck-redists").read_text()) + self.assertIn("Ubisoft Connect markers out", result.stdout) + if __name__ == "__main__": unittest.main() diff --git a/tools/tests/test_session_cursor_routing.py b/tools/tests/test_session_cursor_routing.py index fdc88f75d..60d28edc4 100644 --- a/tools/tests/test_session_cursor_routing.py +++ b/tools/tests/test_session_cursor_routing.py @@ -1,4 +1,4 @@ -"""The XTest cursor path is enabled only for Steam sessions.""" +"""Gamescope composites the cursor only when a Steam session asks for it.""" import os from pathlib import Path import re @@ -8,29 +8,37 @@ import unittest SESSION = Path(__file__).resolve().parents[1] / 'linuxfs/overlay/usr/local/bin/droiddeck-session' SOURCE = SESSION.read_text() MATCH = re.search( - r'^ if \[ "\$mode" = steam \]; then\n.*?^ fi\n', SOURCE, re.M | re.S) + r'^ if \[ "\$mode" = steam \] && \[ "\$\{BL_CURSOR_COMPOSITE:-0\}" = 1 \]; then\n.*?^ fi\n', + SOURCE, re.M | re.S) assert MATCH is not None CURSOR_SETUP = MATCH.group(0) class SessionCursorRoutingTest(unittest.TestCase): - def run_setup(self, mode, initial=None): + def run_setup(self, mode, initial=None, composite=None): env = dict(os.environ, mode=mode) if initial is not None: env['gamescope_cursor_composite'] = initial else: env.pop('gamescope_cursor_composite', None) + if composite is not None: + env['BL_CURSOR_COMPOSITE'] = composite + else: + env.pop('BL_CURSOR_COMPOSITE', None) result = subprocess.run( ['bash', '-c', CURSOR_SETUP + 'printf "%s\n" "${gamescope_cursor_composite-}"'], env=env, text=True, capture_output=True, check=True) return result.stdout.strip() - def test_steam_enables_manual_composition_for_the_nested_cursor(self): - self.assertEqual('2', self.run_setup('steam')) + def test_steam_leaves_the_cursor_with_the_host_unless_asked(self): + self.assertEqual('', self.run_setup('steam')) + + def test_steam_composites_the_cursor_when_the_setting_is_on(self): + self.assertEqual('2', self.run_setup('steam', composite='1')) def test_other_sessions_keep_their_existing_cursor_composition(self): - self.assertEqual('', self.run_setup('desktop')) - self.assertEqual('1', self.run_setup('run', '1')) + self.assertEqual('', self.run_setup('desktop', composite='1')) + self.assertEqual('1', self.run_setup('run', '1', composite='1')) if __name__ == '__main__': diff --git a/tools/tests/test_session_guest_command.py b/tools/tests/test_session_guest_command.py index 9c319ce95..d6f352a8e 100644 --- a/tools/tests/test_session_guest_command.py +++ b/tools/tests/test_session_guest_command.py @@ -22,7 +22,7 @@ PACKAGES = [line.split()[1] for line in VARIANTS.read_text().splitlines() def guest_prefix(package): source = SERVICE.read_text() before_environment = source.split("val guest = ArrayList<String>()", 1)[1].split( - "addClientEnvironment(guest, steamHere)", 1)[0] + "addClientEnvironment(guest,", 1)[0] environment = source.split("private fun addClientEnvironment(", 1)[1].split( 'guest.add("HOME=/root")', 1)[0] prefix = [] diff --git a/tools/tests/test_steam_compat_environment.py b/tools/tests/test_steam_compat_environment.py index 22a15e0d4..5ecaba177 100644 --- a/tools/tests/test_steam_compat_environment.py +++ b/tools/tests/test_steam_compat_environment.py @@ -45,6 +45,68 @@ class SteamCompatEnvironmentTest(unittest.TestCase): client_path += ':' + inherited['LD_LIBRARY_PATH'] self.assertEqual(result.stdout.splitlines(), ['1', client_path]) + def test_session_exit_stops_the_library_helper(self): + source = (BIN / 'droiddeck-session').read_text() + launch = next(line for line in source.splitlines() + if 'droiddeck-steam-compat "$steam_root"' in line) + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + pid = root / 'pid' + helper = root / 'droiddeck-steam-compat' + helper.write_text('#!/bin/sh\necho $$ > "$PID_FILE"\nexec sleep 30\n') + helper.chmod(0o755) + env = dict(os.environ, PATH=str(root) + os.pathsep + os.environ['PATH'], + PID_FILE=str(pid), steam_root='/steam', steam_x64='0') + subprocess.run(['bash', '-c', '_bl_compat_env=(env)\n' + launch + '\n' + 'until [ -s "$PID_FILE" ]; do sleep 0.05; done\n' + 'kill $(jobs -p)\nwait'], + env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10) + helper_pid = int(pid.read_text()) + try: + os.kill(helper_pid, 0) + except ProcessLookupError: + return + os.kill(helper_pid, 9) + self.fail('droiddeck-steam-compat outlived the session') + + def test_the_x86_64_client_gets_only_the_file_dialog_bridge(self): + source = (BIN / 'droiddeck-session').read_text() + start = source.index(' if [ "$steam_x64" = 1 ]; then\n { droiddeck-steam-compat --files-only') + block = source[start:source.index(' fi\n', start) + len(' fi\n')] + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + helper = root / 'droiddeck-steam-compat' + helper.write_text('#!/bin/sh\necho "$@" >> "$ARGS_FILE"\n') + helper.chmod(0o755) + for x64, expected in (('1', '--files-only /steam'), ('0', '/steam')): + with self.subTest(x64=x64): + args = root / ('args-' + x64) + env = dict(os.environ, PATH=str(root) + os.pathsep + os.environ['PATH'], + ARGS_FILE=str(args), steam_root='/steam', steam_x64=x64) + subprocess.run(['bash', '-c', '_bl_compat_env=(env)\n' + block + 'wait\n'], env=env, timeout=10, check=True) + self.assertEqual(args.read_text().split('\n')[0], expected) + + def test_files_only_mode_installs_only_the_file_dialog_hook(self): + import runpy + compat = runpy.run_path(str(BIN / 'droiddeck-steam-compat')) + calls = [] + + class Page: + def __init__(self, port, path): + self.ws = None + def evaluate(self, expression): + calls.append(('evaluate', expression[:40])) + return True + def call(self, method, params): + calls.append((method, params['name'])) + + helper = compat['Helper']('/steam', 1234, files_only=True) + helper.attach.__globals__.update(shared_context=lambda port: '/devtools/page/1', Page=Page) + self.assertTrue(helper.attach()) + bindings = [name for method, name in calls if method == 'Runtime.addBinding'] + self.assertEqual(bindings, [compat['FILE_BINDING']]) + self.assertIn(('evaluate', compat['FILE_JS'][:40]), calls) + if __name__ == '__main__': unittest.main() diff --git a/tools/tests/test_steam_compat_live.py b/tools/tests/test_steam_compat_live.py index a86aab864..af2d8cfb6 100644 --- a/tools/tests/test_steam_compat_live.py +++ b/tools/tests/test_steam_compat_live.py @@ -641,6 +641,114 @@ class ReconcileTest(unittest.TestCase): self.assertEqual(page.mapping["10"], "GE-Proton9-1") self.assertEqual(state["auto"], {"11": TOOL}) + def games(self): + return json.loads((self.state / "games.json").read_text())["games"] + + def write_games(self, games): + (self.state / "games.json").write_text(json.dumps({"version": 1, "games": games})) + + def test_a_steam_pick_after_the_app_choice_is_kept_live(self): + page = FakePage({"0": TOOL, "10": TOOL, "11": TOOL}, [TOOL, TOOL_11, COMPAT["FEX_TOOL"]]) + self.write_games({"10": {"valve": True, "dir": "Proton 11.0 (ARM64)"}}) + state = self.reconcile(page) + self.assertEqual(page.sets, [("10", TOOL_11)]) + self.assertEqual(state["games_applied"], {"10": {"valve": True, "dir": "Proton 11.0 (ARM64)"}}) + + page.sets.clear() + page.mapping["10"] = COMPAT["FEX_TOOL"] + state = self.reconcile(page) + self.assertEqual(page.sets, []) + self.assertEqual(page.mapping["10"], COMPAT["FEX_TOOL"]) + self.assertEqual(self.games(), {}) + self.assertEqual(state["games_applied"], {}) + + self.write_games({"10": {"valve": True, "dir": "Proton 11.0 (ARM64)"}}) + self.reconcile(page) + self.assertEqual(page.sets, [("10", TOOL_11)]) + + def test_a_steam_pick_of_our_proton_becomes_the_app_choice_live(self): + page = FakePage({"0": TOOL, "10": TOOL, "11": TOOL}, [TOOL, TOOL_11]) + self.write_games({"10": {"valve": True, "dir": "Proton 11.0 (ARM64)"}}) + self.reconcile(page) + page.sets.clear() + page.mapping["10"] = TOOL + state = self.reconcile(page) + self.assertEqual(page.sets, []) + self.assertEqual(self.games(), {"10": {"valve": True, "dir": "Proton Experimental (ARM64)"}}) + self.assertEqual(state["games_applied"], self.games()) + + def test_use_the_default_applies_once_then_steam_picks_stand_live(self): + page = FakePage({"0": TOOL, "10": TOOL_11, "11": TOOL}, [TOOL, TOOL_11, COMPAT["FEX_TOOL"]]) + self.write_games({"10": None}) + self.reconcile(page) + self.assertEqual(page.sets, [("10", TOOL)]) + + page.sets.clear() + self.reconcile(page) + self.assertEqual(self.games(), {}) + page.mapping["10"] = COMPAT["FEX_TOOL"] + self.reconcile(page) + self.assertEqual(page.sets, []) + self.assertEqual(page.mapping["10"], COMPAT["FEX_TOOL"]) + + def test_choices_an_earlier_build_applied_give_way_to_steam_picks_live(self): + (self.state / "state.json").write_text(json.dumps({"default": TOOL, "auto": {"11": TOOL}, "tools": []})) + self.write_games({"10": None, "11": {"valve": True, "dir": "Proton 11.0 (ARM64)"}}) + page = FakePage({"0": TOOL, "10": COMPAT["FEX_TOOL"], "11": "steamlinuxruntime_sniper"}, + [TOOL, TOOL_11, COMPAT["FEX_TOOL"]]) + self.reconcile(page) + self.assertEqual(page.sets, []) + self.assertEqual(self.games(), {}) + + def test_an_app_choice_written_during_a_sync_is_not_overwritten(self): + self.write_games({"10": {"valve": True, "dir": "Proton 11.0 (ARM64)"}}) + requests, text = COMPAT["read_game_requests"]() + self.write_games({"10": {"valve": True, "dir": "Proton Experimental (ARM64)"}}) + self.assertFalse(COMPAT["write_game_requests"]({}, text)) + self.assertEqual(self.games(), {"10": {"valve": True, "dir": "Proton Experimental (ARM64)"}}) + + def test_an_app_the_client_briefly_refuses_keeps_its_applied_choice_live(self): + page = FakePage({"0": TOOL, "10": TOOL, "11": TOOL}, [TOOL, TOOL_11, COMPAT["FEX_TOOL"]]) + self.write_games({"10": {"valve": True, "dir": "Proton 11.0 (ARM64)"}}) + self.reconcile(page) + page.refused = {"10"} + state = self.reconcile(page) + self.assertEqual(state["games_applied"], {"10": {"valve": True, "dir": "Proton 11.0 (ARM64)"}}) + page.refused = set() + page.sets.clear() + page.mapping["10"] = COMPAT["FEX_TOOL"] + self.reconcile(page) + self.assertEqual(page.sets, []) + self.assertEqual(page.mapping["10"], COMPAT["FEX_TOOL"]) + + def test_an_app_rewriting_its_choices_during_both_tries_applies_none_of_them_live(self): + page = FakePage({"0": TOOL, "10": TOOL, "11": TOOL}, [TOOL, TOOL_11, COMPAT["FEX_TOOL"]]) + self.write_games({"10": {"valve": True, "dir": "Proton 11.0 (ARM64)"}}) + self.reconcile(page) + page.sets.clear() + page.mapping["10"] = COMPAT["FEX_TOOL"] + self.write_games({"10": {"valve": True, "dir": "Proton 11.0 (ARM64)"}, "11": None}) + with mock.patch.dict(COMPAT["settled_game_requests"].__globals__, write_game_requests=lambda *_: False): + state = self.reconcile(page) + self.assertEqual(page.sets, []) + self.assertEqual(state["games_applied"], {"10": {"valve": True, "dir": "Proton 11.0 (ARM64)"}}) + + def test_a_steam_pick_is_kept_at_the_next_start(self): + config = self.steam / "config/config.vdf" + config.parent.mkdir(parents=True) + vdf = '"InstallConfigStore" { "Software" { "Valve" { "Steam" { "CompatToolMapping" { %s } } } } }' + entry = '"%s" { "name" "%s" "config" "" "priority" "250" }' + catalog = COMPAT["tool_catalog"](str(self.steam)) + self.write_games({"10": {"valve": True, "dir": "Proton 11.0 (ARM64)"}}) + config.write_text(vdf % (entry % ("0", TOOL) + entry % ("10", TOOL))) + with mock.patch.dict(COMPAT["register_default"].__globals__, steam_running=lambda: False): + COMPAT["register_default"](str(config), ["10"], catalog=catalog, directory=str(self.state), settle=True) + self.assertIn(entry % ("10", TOOL_11), " ".join(config.read_text().split())) + config.write_text(vdf % (entry % ("0", TOOL) + entry % ("10", COMPAT["FEX_TOOL"]))) + COMPAT["register_default"](str(config), ["10"], catalog=catalog, directory=str(self.state), settle=True) + self.assertIn(entry % ("10", COMPAT["FEX_TOOL"]), " ".join(config.read_text().split())) + self.assertEqual(self.games(), {}) + def test_file_mode_defers_to_the_live_helper(self): (self.state / "live.pid").write_text("%d\n" % os.getpid()) self.assertTrue(COMPAT["live_helper_running"]()) diff --git a/tools/tests/test_steam_desktop_launch.py b/tools/tests/test_steam_desktop_launch.py index 626631e7f..1e40f8e98 100644 --- a/tools/tests/test_steam_desktop_launch.py +++ b/tools/tests/test_steam_desktop_launch.py @@ -42,6 +42,15 @@ class DesktopSteamLaunchTest(unittest.TestCase): self.assertEqual(result.stdout.splitlines(), ['1', 'desktop', ':7', 'steam', 'steam://open/library']) self.assertFalse((self.session / 'steam-launch').exists()) + def test_desktop_games_present_without_dri3(self): + (self.root / 'supervisor').write_text('#!/bin/bash\nprintf "%s\\n" "$MESA_VK_WSI_DEBUG"\n') + self.env.pop('MESA_VK_WSI_DEBUG', None) + self.assertEqual(self.launch('desktop', '945360').stdout.splitlines(), ['sw']) + self.env['MESA_VK_WSI_DEBUG'] = 'noshm' + self.assertEqual(self.launch('desktop', '945360').stdout.splitlines(), ['noshm,sw']) + self.env['MESA_VK_WSI_DEBUG'] = 'sw,noshm' + self.assertEqual(self.launch('desktop', '945360').stdout.splitlines(), ['sw,noshm']) + def test_desktop_game_url_goes_to_client(self): result = self.launch('desktop', '501590') self.assertEqual(result.returncode, 0, result.stderr) diff --git a/tools/tests/test_steam_file_dialog.py b/tools/tests/test_steam_file_dialog.py new file mode 100644 index 000000000..f557b3557 --- /dev/null +++ b/tools/tests/test_steam_file_dialog.py @@ -0,0 +1,142 @@ +import io +import json +import os +import runpy +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +BIN = Path(__file__).resolve().parents[1] / "linuxfs/overlay/usr/local/bin" +LIVE = runpy.run_path(str(BIN / "droiddeck-steam-compat")) + +HARNESS = r""" +const assert = require('assert'); +globalThis.window = globalThis; +const native = () => Promise.reject({ result: 2, message: '' }); +const popupCallbacks = []; +const popup = { window: { SteamClient: { System: { OpenFileDialog: native } } } }; +const later = { window: { SteamClient: { System: { OpenFileDialog: native } } } }; +globalThis.SteamClient = { System: { OpenFileDialog: native } }; +window.g_PopupManager = { GetPopups: () => [popup], AddPopupCreatedCallback: cb => popupCallbacks.push(cb) }; +const asked = []; +window.droiddeckOpenFileDialog = payload => asked.push(JSON.parse(payload)); +const script = require('fs').readFileSync(process.argv[2], 'utf8'); +(async () => { + assert.strictEqual(eval(script), 'installed'); + const orphan = SteamClient.System.OpenFileDialog({ strTitle: 'Before reconnect' }); + assert.strictEqual(eval(script), 'present'); + await assert.rejects(orphan, e => e.result === 2); + assert.strictEqual(window.__droiddeckFiles.done(1, '/late'), false); + asked.length = 0; + assert.notStrictEqual(SteamClient.System.OpenFileDialog, native); + assert.strictEqual(popup.window.SteamClient.System.OpenFileDialog, SteamClient.System.OpenFileDialog); + popupCallbacks.forEach(cb => cb(later)); + assert.strictEqual(later.window.SteamClient.System.OpenFileDialog, SteamClient.System.OpenFileDialog); + const options = { strTitle: 'Pick', bChooseDirectory: true }; + const chosen = SteamClient.System.OpenFileDialog(options); + const cancelled = later.window.SteamClient.System.OpenFileDialog({ strTitle: 'Other' }); + assert.deepStrictEqual(asked, [{ id: 2, options }, { id: 3, options: { strTitle: 'Other' } }]); + assert.strictEqual(window.__droiddeckFiles.done(2, '/root/Storage/Games'), true); + assert.strictEqual(window.__droiddeckFiles.done(2, '/again'), false); + assert.strictEqual(await chosen, '/root/Storage/Games'); + window.__droiddeckFiles.done(3, null); + await assert.rejects(cancelled, e => e.result === 2); + delete window.droiddeckOpenFileDialog; + await assert.rejects(SteamClient.System.OpenFileDialog({}), e => e.result === 2); +})().catch(e => { console.error(e); process.exit(1); }); +""" + + +def bound(payload): + return {"method": "Runtime.bindingCalled", "params": {"name": LIVE["FILE_BINDING"], "payload": payload}} + + +class FileDialogScriptTest(unittest.TestCase): + def test_every_window_asks_the_bridge_and_gets_the_path_or_a_cancel(self): + with tempfile.TemporaryDirectory() as tmp: + script = Path(tmp) / "files.js" + script.write_text(LIVE["FILE_JS"]) + harness = Path(tmp) / "harness.js" + harness.write_text(HARNESS) + result = subprocess.run(["node", str(harness), str(script)], capture_output=True, text=True, timeout=20) + self.assertEqual(result.returncode, 0, result.stderr) + + +class FileDialogBridgeTest(unittest.TestCase): + def setUp(self): + self.globals = LIVE["Helper"].attach.__globals__ + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) + self.helper = LIVE["Helper"](str(self.root), 1) + self.helper.page = mock.Mock() + + def answer(self): + return self.helper.page.evaluate.call_args.args[0] + + def test_the_chooser_runs_under_a_steam_reaper_with_the_options_on_stdin(self): + reaper = self.root / "steamrtarm64/reaper" + reaper.parent.mkdir() + reaper.write_text("#!/bin/sh\n") + reaper.chmod(0o755) + proc = mock.Mock(stdin=io.BytesIO()) + proc.stdin.close = lambda: None + with mock.patch.object(self.globals["subprocess"], "Popen", return_value=proc) as popen: + self.helper.page.drain.return_value = [bound(json.dumps({"id": 3, "options": {"strTitle": "Pick"}}))] + self.helper.drain() + command = popen.call_args.args[0] + self.assertEqual(command[:4], [str(reaper), "SteamLaunch", "AppId=769", "--"]) + self.assertEqual(command[-1], "--choose-file") + self.assertEqual(json.loads(proc.stdin.getvalue()), {"strTitle": "Pick"}) + self.assertEqual(self.helper.chooser, (3, proc)) + + def test_a_second_request_while_one_is_open_is_cancelled(self): + first = mock.Mock() + self.helper.chooser = (1, first) + self.helper.open_chooser(json.dumps({"id": 2, "options": {}})) + self.assertEqual(self.answer(), "window.__droiddeckFiles?.done(2, null)") + self.assertEqual(self.helper.chooser, (1, first)) + + def test_the_chosen_path_or_a_cancel_goes_back_to_the_page(self): + for code, out, expected in ((0, b'"/root/Storage/My Game/game.sh"\n', '"/root/Storage/My Game/game.sh"'), + (0, b'" spaced name "\n', '" spaced name "'), + (1, b"", "null"), (2, b"", "null"), (0, b"\n", "null"), (0, b"not json\n", "null")): + proc = mock.Mock(stdout=io.BytesIO(out), returncode=code) + self.helper.chooser = (7, proc) + self.helper.chooser_done() + self.assertEqual(self.answer(), "window.__droiddeckFiles?.done(7, %s)" % expected) + self.assertIsNone(self.helper.chooser) + + def test_bad_requests_are_ignored(self): + with mock.patch.object(self.globals["subprocess"], "Popen") as popen: + for payload in ("not json", json.dumps({"options": {}}), json.dumps({"id": 1, "options": []})): + self.helper.open_chooser(payload) + popen.assert_not_called() + self.helper.page.evaluate.assert_not_called() + + def test_choose_file_mode_prints_the_path_and_fails_on_cancel(self): + for chooser, code, printed in ((mock.Mock(return_value="/root/Storage/a.exe "), 0, '"/root/Storage/a.exe "\n'), + (mock.Mock(return_value=None), 1, ""), + (mock.Mock(side_effect=ValueError("Namespace Gtk not available for version 3.0")), 2, ""), + (mock.Mock(side_effect=ImportError("No module named 'gi'")), 2, "")): + with mock.patch.dict(LIVE["chooser_main"].__globals__, {"choose_file": chooser}), \ + mock.patch.object(sys, "stdin", io.StringIO('{"strTitle": "Pick"}')), \ + mock.patch.object(sys, "stdout", io.StringIO()) as out: + self.assertEqual(LIVE["chooser_main"](), code) + self.assertEqual(out.getvalue(), printed) + + def test_attach_installs_the_file_dialog_bridge(self): + page = mock.Mock() + page.evaluate.return_value = True + with mock.patch.dict(self.globals, {"shared_context": lambda port: "/devtools/page/1", + "Page": mock.Mock(return_value=page)}): + self.helper.attach() + page.call.assert_any_call("Runtime.addBinding", {"name": LIVE["FILE_BINDING"]}) + page.evaluate.assert_any_call(LIVE["FILE_JS"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/tests/test_steam_install.py b/tools/tests/test_steam_install.py index 8692d5649..6a02bcb0a 100644 --- a/tools/tests/test_steam_install.py +++ b/tools/tests/test_steam_install.py @@ -26,25 +26,48 @@ class SteamInstallTest(unittest.TestCase): self.bin = self.root / 'bin' self.bin.mkdir() curl = self.bin / 'curl' - curl.write_text('''#!/bin/bash -set -eu -url="" -out="" -while [ $# -gt 0 ]; do - case $1 in - -o) out=$2; shift 2 ;; - --connect-timeout|--max-time|--speed-limit|--speed-time|--retry|--retry-delay) shift 2 ;; - -*) shift ;; - *) url=$1; shift ;; - esac -done -printf '%s\\n' "$url" >> "$TEST_REQUESTS" -if [ -n "${TEST_FAIL_HOST:-}" ]; then - case $url in *"$TEST_FAIL_HOST"*) exit 22 ;; esac -fi -cp "$TEST_CDN/${url##*/}" "$out" + curl.write_text('''#!/usr/bin/env python3 +import os, sys +args = sys.argv[1:] +url = next(a for a in args if a.startswith('https://')) +out = args[args.index('-o') + 1] +name = url.rsplit('/', 1)[1] +offset = os.path.getsize(out) if '-C' in args and os.path.exists(out) else 0 +with open(os.environ['TEST_REQUESTS'], 'a') as log: + log.write(url + '\\n') +with open(os.environ['TEST_REQUESTS'] + '.offsets', 'a') as log: + log.write(f'{name} {offset}\\n') +def done(status, code='200'): + if '-w' in args: + sys.stdout.write(code) + sys.exit(status) +host_fail = os.environ.get('TEST_FAIL_HOST') +if host_fail and host_fail in url: + done(22, '000') +action = [] +plan = os.environ.get('TEST_PLAN') +if plan and os.path.exists(plan): + lines = open(plan).read().splitlines() + for n, line in enumerate(lines): + if line.split()[0] == name: + action = line.split()[1:] + del lines[n] + open(plan, 'w').write(''.join(l + '\\n' for l in lines)) + break +if action[:1] == ['fail']: + done(int(action[1]), '000') +if action[:1] == ['http']: + done(22, action[1]) +if action[:1] == ['norange'] and offset: + done(33) +data = open(os.path.join(os.environ['TEST_CDN'], name), 'rb').read() +end = int(action[1]) if action[:1] == ['cut'] else len(data) +with open(out, 'ab' if offset else 'wb') as target: + target.write(data[offset:end]) +done(28 if end < len(data) else 0) ''') curl.chmod(0o755) + self.plan = self.root / 'plan' if shutil.which('sha256sum') is None: sha = self.bin / 'sha256sum' sha.write_text('#!/bin/bash\nexec shasum -a 256 "$@"\n') @@ -55,8 +78,9 @@ cp "$TEST_CDN/${url##*/}" "$out" sha1.chmod(0o755) self.env = dict(os.environ, HOME=str(self.home), XDG_DATA_HOME=str(self.data), PATH=str(self.bin) + os.pathsep + os.environ['PATH'], - TEST_CDN=str(self.cdn), TEST_REQUESTS=str(self.requests)) - for channel in ('steamdeck_publicbeta',): + TEST_CDN=str(self.cdn), TEST_REQUESTS=str(self.requests), + TEST_PLAN=str(self.plan), BL_STEAM_RETRY_DELAYS='0 0 0') + for channel in ('steamdeck_publicbeta', 'steamdeck_stable'): archive = self.cdn / (channel + '.zip') with zipfile.ZipFile(archive, 'w') as package: package.writestr('steamrtarm64/steam', channel) @@ -68,12 +92,17 @@ cp "$TEST_CDN/${url##*/}" "$out" f'\t\t"file" "{archive.name}"\n\t\t"sha2" "{digest}"\n' '\t}\n}\n') - def install(self, fail_host=None): + def install(self, fail_host=None, *args, **extra): env = self.env if fail_host is None else dict(self.env, TEST_FAIL_HOST=fail_host) - return subprocess.run(['bash', str(SCRIPT)], env=env, text=True, capture_output=True) + return subprocess.run(['bash', str(SCRIPT), *args], env=dict(env, **extra), text=True, capture_output=True) - def assert_installed(self, channel='steamdeck_publicbeta', fail_host=None, expected=None): - result = self.install(fail_host) + def channel(self, **extra): + result = self.install(None, '--channel', **extra) + self.assertEqual(0, result.returncode, result.stderr) + return result.stdout.strip() + + def assert_installed(self, channel='steamdeck_publicbeta', fail_host=None, expected=None, **extra): + result = self.install(fail_host, **extra) self.assertEqual(0, result.returncode, result.stderr) self.assertEqual(channel + '\n', (self.steam / 'package/beta').read_text()) self.assertEqual('123456\n', (self.steam / 'package/droiddeck-installed').read_text()) @@ -87,6 +116,7 @@ cp "$TEST_CDN/${url##*/}" "$out" 'https://client-update.steamstatic.com/' + channel + '.zip', ] self.assertEqual(expected, self.requests.read_text().splitlines()) + return result def test_downloads_the_deck_beta_client(self): self.assert_installed() @@ -155,8 +185,80 @@ cp "$TEST_CDN/${url##*/}" "$out" self.requests.unlink() with (package / 'droiddeck-unconfirmed').open('a') as marker: marker.write('start\nstart\nstart\n') + self.assertEqual(0, self.install(BL_STEAM_CHANNEL='beta').returncode) + self.assertFalse(self.requests.exists()) + + def test_the_default_channel_is_the_deck_beta(self): + self.assertEqual('steamdeck_publicbeta', self.channel()) + + def stable_requests(self, beta_manifest=True): + stable = [ + 'https://client-update.steamstatic.com/steam_client_steamdeck_stable_linuxarm64', + 'https://client-update.steamstatic.com/steamdeck_stable.zip', + ] + if beta_manifest: + stable.insert(0, 'https://client-update.steamstatic.com/steam_client_steamdeck_publicbeta_linuxarm64') + return stable + + def test_a_repaired_beta_client_that_still_shows_nothing_falls_back_to_stable(self): + package = self.break_client(2) + self.assertEqual(0, self.install().returncode) + self.requests.unlink() + with (package / 'droiddeck-unconfirmed').open('a') as marker: + marker.write('start\nstart\n') + result = self.assert_installed('steamdeck_stable', expected=self.stable_requests(False)) + self.assertIn("falling back to Valve's steamdeck_stable Steam client", result.stdout) + self.assertEqual('steamdeck_stable\n', (package / 'droiddeck-channel').read_text()) + self.assertEqual('repaired\n', (package / 'droiddeck-unconfirmed').read_text()) + self.assertEqual('steamdeck_stable', self.channel()) + + def test_the_stable_fallback_is_not_repeated(self): + self.test_a_repaired_beta_client_that_still_shows_nothing_falls_back_to_stable() + self.requests.unlink() + with (self.steam / 'package/droiddeck-unconfirmed').open('a') as marker: + marker.write('start\nstart\nstart\n') self.assertEqual(0, self.install().returncode) self.assertFalse(self.requests.exists()) + self.assertEqual('steamdeck_stable\n', (self.steam / 'package/beta').read_text()) + + def test_a_pinned_beta_channel_never_falls_back(self): + package = self.break_client(2) + self.assertEqual(0, self.install().returncode) + with (package / 'droiddeck-unconfirmed').open('a') as marker: + marker.write('start\nstart\n') + self.assertEqual('steamdeck_publicbeta', self.channel(BL_STEAM_CHANNEL='beta')) + self.assertEqual(0, self.install(BL_STEAM_CHANNEL='beta').returncode) + self.assertFalse((package / 'droiddeck-channel').exists()) + + def test_the_channel_setting_selects_stable(self): + setting = self.home / '.config/droiddeck/steam-channel' + setting.parent.mkdir(parents=True) + setting.write_text('stable\n') + self.assertEqual('steamdeck_stable', self.channel()) + self.assert_installed('steamdeck_stable', expected=self.stable_requests(False)) + self.assertFalse((self.steam / 'package/droiddeck-channel').exists()) + + def empty_beta_manifest(self): + (self.cdn / 'steam_client_steamdeck_publicbeta_linuxarm64').write_text( + '"linuxarm64"\n{\n\t"version" "123457"\n' + '\t"client_steamrtarm64_linuxarm64"\n\t{\n' + '\t\t"file" "client.zip"\n\t\t"sha2" "00"\n' + '\t}\n}\n') + + def test_a_beta_manifest_without_an_arm64_client_falls_back_to_stable(self): + self.empty_beta_manifest() + result = self.assert_installed('steamdeck_stable', expected=self.stable_requests()) + self.assertIn('no client components in steam_client_steamdeck_publicbeta_linuxarm64', result.stderr) + self.assertIn('client_steamrtarm64_linuxarm64', result.stderr) + self.assertEqual('steamdeck_stable\n', (self.steam / 'package/droiddeck-channel').read_text()) + self.assertEqual('steamdeck_stable', self.channel()) + + def test_a_pinned_beta_manifest_without_an_arm64_client_fails_and_names_its_blocks(self): + self.empty_beta_manifest() + result = self.install(BL_STEAM_CHANNEL='beta') + self.assertNotEqual(0, result.returncode) + self.assertIn('client_steamrtarm64_linuxarm64', result.stderr) + self.assertFalse((self.steam / 'package/droiddeck-installed').exists()) def test_requested_repair_runs_once_and_is_retried_until_it_completes(self): package = self.break_client(0) @@ -177,6 +279,143 @@ cp "$TEST_CDN/${url##*/}" "$out" self.assertEqual(0, self.install().returncode) self.assertFalse(self.requests.exists()) + def publish(self, **parts): + """The CDN's client as these parts (block name -> byte count of incompressible content), + listed with sizes as Valve's manifest lists them.""" + files = {} + blocks = '' + for block, length in parts.items(): + archive = self.cdn / f'{block}.zip.{hashlib.sha1(block.encode()).hexdigest()}' + with zipfile.ZipFile(archive, 'w', zipfile.ZIP_STORED) as package: + package.writestr(f'parts/{block}', os.urandom(length)) + if block.endswith('_linuxarm64_linuxarm64'): + package.writestr('steamrtarm64/steam', 'steamdeck_publicbeta') + data = archive.read_bytes() + files[block] = archive.name + blocks += (f'\t"{block}"\n\t{{\n\t\t"file"\t\t"{archive.name}"\n\t\t"size"\t\t"{len(data)}"\n' + f'\t\t"sha2"\t\t"{hashlib.sha256(data).hexdigest()}"\n\t}}\n') + (self.cdn / 'steam_client_steamdeck_publicbeta_linuxarm64').write_text( + '"linuxarm64"\n{\n\t"version"\t\t"123456"\n' + blocks + '}\n') + return files + + def two_parts(self): + return self.publish(a_all=1_200_000, client_linuxarm64_linuxarm64=1_300_000) + + def plan_for(self, name, *actions): + with self.plan.open('a') as plan: + plan.writelines(f'{name} {action}\n' for action in actions) + + def offsets(self, name): + log = Path(str(self.requests) + '.offsets') + return [int(line.split()[1]) for line in log.read_text().splitlines() if line.split()[0] == name] + + def requested(self): + return [line.rsplit('/', 1)[1] for line in self.requests.read_text().splitlines()] + + @property + def download(self): + return self.steam / 'package/droiddeck-download' + + def test_progress_is_counted_in_megabytes_across_parts(self): + self.two_parts() + result = self.install() + self.assertEqual(0, result.returncode, result.stderr) + self.assertIn('downloading Steam: a_all (1/2), 0 of 2 MB\n', result.stdout) + self.assertIn('downloading Steam: client_linuxarm64_linuxarm64 (2/2), 1 of 2 MB\n', result.stdout) + self.assertTrue((self.steam / 'parts/a_all').is_file()) + self.assertFalse(self.download.exists()) + + def test_a_dropped_connection_resumes_from_what_arrived(self): + files = self.two_parts() + client = files['client_linuxarm64_linuxarm64'] + self.plan_for(client, 'cut 500000') + result = self.install() + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual([0, 500000], self.offsets(client)) + self.assertIn('interrupted (curl exit 28), trying again in 0 s', result.stdout) + self.assertEqual('123456\n', (self.steam / 'package/droiddeck-installed').read_text()) + + def test_a_failed_session_keeps_what_it_finished_and_fetched(self): + files = self.two_parts() + client = files['client_linuxarm64_linuxarm64'] + self.plan_for(client, 'cut 400000', *['fail 6'] * 12) + result = self.install() + self.assertNotEqual(0, result.returncode) + self.assertIn('could not download client_linuxarm64_linuxarm64 (curl exit 6)', result.stderr) + self.assertFalse((self.steam / 'package/droiddeck-installed').exists()) + self.assertTrue((self.download / (files['a_all'] + '.unpacked')).is_file()) + self.assertFalse((self.download / files['a_all']).exists()) + self.assertEqual(400000, (self.download / client).stat().st_size) + self.requests.unlink() + Path(str(self.requests) + '.offsets').unlink() + self.assertEqual(0, self.install().returncode) + self.assertEqual(['steam_client_steamdeck_publicbeta_linuxarm64', client], self.requested()) + self.assertEqual([400000], self.offsets(client)) + self.assertFalse(self.download.exists()) + + def test_a_server_that_will_not_resume_starts_the_part_over(self): + files = self.two_parts() + client = files['client_linuxarm64_linuxarm64'] + self.plan_for(client, 'cut 400000', 'norange') + result = self.install() + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual([0, 400000, 0], self.offsets(client)) + + def test_a_missing_part_hands_over_to_the_next_cdn_at_once(self): + files = self.two_parts() + client = files['client_linuxarm64_linuxarm64'] + self.plan_for(client, 'http 404') + result = self.install() + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual(['https://client-update.steamstatic.com/' + client, + 'https://client-update.akamai.steamstatic.com/' + client], + [line for line in self.requests.read_text().splitlines() if line.endswith(client)]) + self.assertIn('HTTP 404 from https://client-update.steamstatic.com', result.stderr) + self.assertNotIn('trying again', result.stdout) + + def test_a_server_error_is_retried_on_the_same_cdn(self): + files = self.two_parts() + client = files['client_linuxarm64_linuxarm64'] + self.plan_for(client, 'http 503') + result = self.install() + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual(['https://client-update.steamstatic.com/' + client] * 2, + [line for line in self.requests.read_text().splitlines() if line.endswith(client)]) + self.assertIn('interrupted (curl exit 22), trying again in 0 s', result.stdout) + + def test_bytes_that_do_not_check_out_are_fetched_again(self): + files = self.two_parts() + client = files['client_linuxarm64_linuxarm64'] + self.download.mkdir(parents=True) + (self.download / client).write_bytes(b'x' * (self.cdn / client).stat().st_size) + result = self.install() + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual([0], self.offsets(client)) + + def test_parts_left_for_another_client_version_are_dropped(self): + files = self.two_parts() + self.download.mkdir(parents=True) + (self.download / 'a_all.zip.0123').write_bytes(b'older') + (self.download / 'b_all.zip.4567.unpacked').write_bytes(b'') + self.plan_for(files['client_linuxarm64_linuxarm64'], *['fail 7'] * 12) + self.assertNotEqual(0, self.install().returncode) + self.assertEqual([files['a_all'] + '.unpacked'], sorted(p.name for p in self.download.iterdir())) + + def test_a_repair_unpacks_every_part_again_once(self): + files = self.two_parts() + self.assertEqual(0, self.install().returncode) + (self.home / '.bl-steam-repair').write_text('1\n') + self.requests.unlink() + self.plan_for(files['client_linuxarm64_linuxarm64'], *['fail 7'] * 12) + self.assertNotEqual(0, self.install().returncode) + self.assertIn(files['a_all'], self.requested()) + self.requests.unlink() + self.assertEqual(0, self.install().returncode) + self.assertEqual(['steam_client_steamdeck_publicbeta_linuxarm64', files['client_linuxarm64_linuxarm64']], + self.requested()) + self.assertFalse((self.home / '.bl-steam-repair').exists()) + self.assertFalse(self.download.exists()) + if __name__ == '__main__': unittest.main() diff --git a/tools/tests/test_steam_libraries.py b/tools/tests/test_steam_libraries.py index 4f416a840..95ae20c24 100644 --- a/tools/tests/test_steam_libraries.py +++ b/tools/tests/test_steam_libraries.py @@ -153,6 +153,8 @@ class DesktopGamesTest(unittest.TestCase): steam = root / "Steam" manifest(steam / "steamapps", 40, name="Game") manifest(steam / "steamapps", 1628350, name="Steam Linux Runtime 3.0 (sniper)", installdir="SteamLinuxRuntime_sniper") + manifest(steam / "steamapps", 1826330, name="Proton EasyAntiCheat Runtime", installdir="Proton EasyAntiCheat Runtime") + manifest(steam / "steamapps", 1161040, name="Proton BattlEye Runtime", installdir="Proton BattlEye Runtime") manifest(steam / "steamapps", 5000, name="Some Proton", installdir="Some Proton") (steam / "steamapps/common/Some Proton").mkdir(parents=True) (steam / "steamapps/common/Some Proton/toolmanifest.vdf").write_text('"manifest"\n{\n}\n') diff --git a/tools/tests/test_steam_shaders.py b/tools/tests/test_steam_shaders.py new file mode 100644 index 000000000..1d273215a --- /dev/null +++ b/tools/tests/test_steam_shaders.py @@ -0,0 +1,88 @@ +from pathlib import Path +import runpy +import tempfile +import unittest + + +BIN = Path(__file__).resolve().parents[1] / 'linuxfs/overlay/usr/local/bin' +MODULE = runpy.run_path(str(BIN / 'droiddeck-steam-shaders')) +apply = MODULE['apply'] +set_text = MODULE['set_text'] +parse = runpy.run_path(str(BIN / 'droiddeck-steam-ui-scale'))['parse'] + +THOR = '''"InstallConfigStore" +{ +\t"Software" +\t{ +\t\t"Valve" +\t\t{ +\t\t\t"Steam" +\t\t\t{ +\t\t\t\t"ShaderCacheManager" +\t\t\t\t{ +\t\t\t\t\t"HasCurrentBucket"\t\t"1" +\t\t\t\t\t"ProcessingQueue"\t\t"207140;848350;" +\t\t\t\t\t"App" +\t\t\t\t\t{ +\t\t\t\t\t} +\t\t\t\t} +\t\t\t} +\t\t} +\t} +} +''' + + +def disabled(text): + steam = parse(text).get('InstallConfigStore').get('Software').get('Valve').get('Steam') + return steam.get('ShaderCacheManager').get('DisableShaderCache') + + +class SteamShadersTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.config = self.root / 'config/config.vdf' + self.config.parent.mkdir() + + def test_off_adds_the_key_the_client_writes(self): + text, changed = set_text(THOR, False) + self.assertTrue(changed) + self.assertEqual('1', disabled(text)) + self.assertIn('\t\t\t\t\t"DisableShaderCache"\t\t"1"\n\t\t\t\t}', text) + self.assertIn('"ProcessingQueue"\t\t"207140;848350;"', text) + + def test_existing_value_is_replaced_in_place(self): + off, _ = set_text(THOR, False) + on, changed = set_text(off, True) + self.assertTrue(changed) + self.assertEqual('0', disabled(on)) + self.assertEqual(off.replace('"DisableShaderCache"\t\t"1"', '"DisableShaderCache"\t\t"0"'), on) + + def test_matching_value_leaves_the_file_alone(self): + off, _ = set_text(THOR, False) + self.assertEqual((off, False), set_text(off, False)) + + def test_missing_sections_are_created(self): + text, changed = set_text('"InstallConfigStore"\n{\n}\n', False) + self.assertTrue(changed) + self.assertEqual('1', disabled(text)) + + def test_apply_writes_config_and_creates_a_fresh_one(self): + self.config.write_text(THOR) + self.assertTrue(apply(self.root, False)) + self.assertEqual('1', disabled(self.config.read_text())) + self.assertFalse(apply(self.root, False)) + fresh = Path(self.temp.name) / 'fresh' + fresh.mkdir() + self.assertTrue(apply(fresh, True)) + self.assertEqual('0', disabled((fresh / 'config/config.vdf').read_text())) + + def test_invalid_config_is_rejected(self): + with self.assertRaises(ValueError): + set_text('"InstallConfigStore" {', False) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/tests/test_steam_share.py b/tools/tests/test_steam_share.py new file mode 100644 index 000000000..214543357 --- /dev/null +++ b/tools/tests/test_steam_share.py @@ -0,0 +1,167 @@ +from pathlib import Path +import contextlib +import io +import json +import os +import runpy +import tempfile +import unittest + + +SCRIPT = Path(__file__).resolve().parents[1] / 'linuxfs/overlay/usr/local/bin/droiddeck-steam-share' +SHARE = runpy.run_path(str(SCRIPT)) + +CONFIG = ('"InstallConfigStore"\n{\n\t"Software"\n\t{\n\t\t"Valve"\n\t\t{\n\t\t\t"Steam"\n\t\t\t{\n' + '\t\t\t\t"CompatToolMapping"\n\t\t\t\t{\n%s\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n') + + +def mapping(**tools): + return ''.join('\t\t\t\t\t"%s"\n\t\t\t\t\t{\n\t\t\t\t\t\t"name"\t\t"%s"\n\t\t\t\t\t\t"config"\t\t""\n' + '\t\t\t\t\t\t"priority"\t\t"250"\n\t\t\t\t\t}\n' % (app.lstrip('_'), name) + for app, name in tools.items()) + + +def manifest(app, folder, updated=100, flags=4): + return ('"AppState"\n{\n\t"appid"\t\t"%s"\n\t"name"\t\t"%s"\n\t"StateFlags"\t\t"%d"\n' + '\t"installdir"\t\t"%s"\n\t"LastUpdated"\t\t"%d"\n}\n' % (app, folder, flags, folder, updated)) + + +class SteamShareTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + home = Path(self.temp.name) + self.arm = home / 'arm/Steam' + self.x64 = home / 'x64/Steam' + self.card = home / 'card' + self.shared = home / 'x64/shared' + for root, tool in ((self.arm, 'droiddeck-proton-arm64'), (self.x64, 'droiddeck-proton-a64')): + (root / 'steamapps/common').mkdir(parents=True) + (root / 'config').mkdir() + (root / 'compatibilitytools.d/proton').mkdir(parents=True) + (root / 'compatibilitytools.d/proton/compatibilitytool.vdf').write_text( + '"compatibilitytools"\n{\n\t"compat_tools"\n\t{\n\t\t"%s"\n\t\t{\n\t\t}\n\t}\n}\n' % tool) + (self.arm / 'config/config.vdf').write_text(CONFIG % mapping(_0='droiddeck-proton-arm64', _413150='droiddeck-fex')) + (self.x64 / 'config/config.vdf').write_text(CONFIG % '') + self.install(self.arm, '413150', 'Stardew Valley') + self.install(self.arm, '13210', 'Unreal Tournament 3') + self.install(self.arm, '1493710', 'Proton - Experimental') + + def install(self, root, app, folder, updated=100): + (root / 'steamapps/common' / folder).mkdir(parents=True, exist_ok=True) + (root / 'steamapps/common' / folder / 'game.bin').write_text('x') + (root / ('steamapps/appmanifest_%s.acf' % app)).write_text(manifest(app, folder, updated)) + + def run_share(self, here): + with contextlib.redirect_stdout(io.StringIO()): + return SHARE['main']([here, str(self.arm), str(self.x64), str(self.card), str(self.shared)]) + + def view(self, *parts): + return self.shared.joinpath('arm64', 'steamapps', *parts) + + def test_x64_client_sees_arm64_games_in_place(self): + self.assertEqual(self.run_share('x86-64'), 0) + self.assertEqual(os.readlink(self.view('common/Stardew Valley')), str(self.arm / 'steamapps/common/Stardew Valley')) + self.assertTrue(self.view('appmanifest_413150.acf').is_file()) + self.assertTrue(self.view('appmanifest_13210.acf').is_file()) + self.assertFalse(self.view('appmanifest_1493710.acf').exists()) + folders = (self.x64 / 'steamapps/libraryfolders.vdf').read_text() + self.assertIn(str(self.shared / 'arm64'), folders) + self.assertIn('ARM64 client games', folders) + + def test_tools_follow_the_other_client(self): + self.run_share('x86-64') + tools = SHARE['entries']((self.x64 / 'config/config.vdf').read_text()) + self.assertEqual(tools['413150'], 'droiddeck-direct') + self.assertEqual(tools['13210'], 'droiddeck-proton-a64') + + def test_a_proton_pick_stays_when_the_other_client_uses_another_proton(self): + (self.x64 / 'config/config.vdf').write_text(CONFIG % mapping(_13210='droiddeck-proton-11-a64')) + self.run_share('x86-64') + self.assertEqual(SHARE['entries']((self.x64 / 'config/config.vdf').read_text())['13210'], 'droiddeck-proton-11-a64') + + def test_an_automatic_proton_gives_way_to_the_installed_linux_build(self): + self.install(self.x64, '550', 'Left 4 Dead 2') + (self.x64 / 'config/config.vdf').write_text(CONFIG % mapping(_550='droiddeck-direct')) + (self.arm / 'config/config.vdf').write_text(CONFIG % mapping(_0='droiddeck-proton-arm64', _550='droiddeck-proton-arm64')) + self.run_share('arm64') + self.assertEqual(SHARE['entries']((self.arm / 'config/config.vdf').read_text())['550'], 'droiddeck-fex') + + def test_an_x64_proton_pick_reaches_the_arm64_client(self): + self.install(self.x64, '550', 'Left 4 Dead 2') + (self.x64 / 'config/config.vdf').write_text(CONFIG % mapping(_550='droiddeck-proton-a64')) + self.run_share('arm64') + self.assertEqual(SHARE['entries']((self.arm / 'config/config.vdf').read_text())['550'], 'droiddeck-proton-arm64') + + def test_a_tool_the_user_chose_is_kept(self): + (self.x64 / 'config/config.vdf').write_text(CONFIG % mapping(_13210='my-proton')) + self.run_share('x86-64') + self.assertEqual(SHARE['entries']((self.x64 / 'config/config.vdf').read_text())['13210'], 'my-proton') + + def test_own_install_wins(self): + self.install(self.x64, '413150', 'Stardew Valley') + self.run_share('x86-64') + self.assertFalse(self.view('appmanifest_413150.acf').exists()) + self.assertFalse(os.path.lexists(self.view('common/Stardew Valley'))) + + def test_newer_update_goes_back_to_the_source(self): + self.run_share('x86-64') + self.view('appmanifest_413150.acf').write_text(manifest('413150', 'Stardew Valley', 200)) + self.run_share('x86-64') + self.assertIn('"LastUpdated"\t\t"200"', (self.arm / 'steamapps/appmanifest_413150.acf').read_text()) + + def test_uninstall_from_the_view_hides_until_reinstalled(self): + self.run_share('x86-64') + self.view('appmanifest_13210.acf').unlink() + self.run_share('x86-64') + self.assertFalse(os.path.lexists(self.view('common/Unreal Tournament 3'))) + self.assertTrue((self.arm / 'steamapps/common/Unreal Tournament 3/game.bin').is_file()) + self.run_share('x86-64') + self.assertFalse(self.view('appmanifest_13210.acf').exists()) + self.install(self.arm, '13210', 'Unreal Tournament 3', 300) + self.run_share('x86-64') + self.assertTrue(self.view('appmanifest_13210.acf').is_file()) + + def test_files_deleted_through_the_view_drop_the_source_manifest(self): + self.run_share('x86-64') + (self.arm / 'steamapps/common/Unreal Tournament 3/game.bin').unlink() + self.view('appmanifest_13210.acf').unlink() + self.run_share('x86-64') + self.assertFalse((self.arm / 'steamapps/appmanifest_13210.acf').exists()) + + def test_removed_source_leaves_the_view(self): + self.run_share('x86-64') + (self.arm / 'steamapps/appmanifest_413150.acf').unlink() + self.run_share('x86-64') + self.assertFalse(self.view('appmanifest_413150.acf').exists()) + self.assertFalse(os.path.lexists(self.view('common/Stardew Valley'))) + state = json.loads((self.shared / 'arm64/.droiddeck-shared.json').read_text()) + self.assertNotIn('413150', state['shared']) + + def test_arm64_client_sees_x64_games(self): + self.install(self.x64, '550', 'Left 4 Dead 2') + (self.x64 / 'config/config.vdf').write_text(CONFIG % mapping(_550='droiddeck-direct')) + self.run_share('arm64') + self.assertTrue(self.shared.joinpath('x86-64/steamapps/appmanifest_550.acf').is_file()) + self.assertEqual(SHARE['entries']((self.arm / 'config/config.vdf').read_text())['550'], 'droiddeck-fex') + self.assertIn('x86-64 client games', (self.arm / 'steamapps/libraryfolders.vdf').read_text()) + + def test_an_empty_view_is_not_offered(self): + self.run_share('x86-64') + for app in ('413150', '13210', '1493710'): + (self.arm / ('steamapps/appmanifest_%s.acf' % app)).unlink() + self.run_share('x86-64') + self.assertNotIn(str(self.shared / 'arm64'), (self.x64 / 'steamapps/libraryfolders.vdf').read_text()) + + def test_unfinished_installs_are_not_shared(self): + (self.arm / 'steamapps/appmanifest_413150.acf').write_text(manifest('413150', 'Stardew Valley', flags=1026)) + self.run_share('x86-64') + self.assertFalse(self.view('appmanifest_413150.acf').exists()) + + def test_bad_arguments(self): + with contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(SHARE['main'](['mips']), 2) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/tests/test_steam_shortcuts.py b/tools/tests/test_steam_shortcuts.py index 60a2998a6..a927d4db0 100644 --- a/tools/tests/test_steam_shortcuts.py +++ b/tools/tests/test_steam_shortcuts.py @@ -203,6 +203,30 @@ class ShortcutsTest(unittest.TestCase): self.assertEqual('-vulkan', steam['LaunchOptions']) self.assertEqual('Mine', steam['AppName']) + def users(self, *blocks): + body = ''.join('\t"%d"\n\t{\n%s\t}\n' % (76561197960265728 + acct, ''.join('\t\t"%s"\t\t"%s"\n' % kv for kv in keys.items())) + for acct, keys in blocks) + (self.steam / 'config').mkdir(exist_ok=True) + (self.steam / 'config/loginusers.vdf').write_text('"users"\n{\n%s}\n' % body) + (self.steam / 'userdata/456/config').mkdir(parents=True, exist_ok=True) + + def test_the_account_marked_most_recent_is_used(self): + self.users((123, dict(MostRecent='1', AutoLogin='0', timestamp='100')), (456, dict(MostRecent='0', AutoLogin='1', timestamp='200'))) + self.assertEqual(str(self.steam / 'userdata/123'), shortcuts.account_dir(str(self.steam))) + + def test_without_most_recent_the_account_that_signs_in_is_used(self): + self.users((123, dict(AutoLogin='1', timestamp='100')), (456, dict(AutoLogin='0', timestamp='200'))) + self.assertEqual(str(self.steam / 'userdata/123'), shortcuts.account_dir(str(self.steam))) + self.assertIn(APPID, self.run_writer(self.game())) + + def test_without_either_mark_the_account_used_last_is_used(self): + self.users((456, dict(AutoLogin='0', timestamp='100')), (123, dict(AutoLogin='0', timestamp='200'))) + self.assertEqual(str(self.steam / 'userdata/123'), shortcuts.account_dir(str(self.steam))) + + def test_two_accounts_without_any_mark_write_nothing(self): + self.users((123, dict(AccountName='a')), (456, dict(AccountName='b'))) + self.assertIsNone(shortcuts.account_dir(str(self.steam))) + def test_an_older_record_without_launch_options_still_takes_the_apps(self): self.run_writer(self.game()) record = self.record() diff --git a/tools/tests/test_steamrt_shim.py b/tools/tests/test_steamrt_shim.py new file mode 100644 index 000000000..05f7345fe --- /dev/null +++ b/tools/tests/test_steamrt_shim.py @@ -0,0 +1,123 @@ +import os +from pathlib import Path +import re +import subprocess +import tempfile +import unittest + + +BIN = Path(__file__).resolve().parents[1] / 'linuxfs/overlay/usr/local/bin' +SHIM = BIN / 'droiddeck-steamrt-shim' +SESSION = (BIN / 'droiddeck-session').read_text() +X64 = (BIN / 'droiddeck-steam-x64').read_text() + + +class SteamrtShimTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.client = self.root / 'steamrtarm64' + self.client.mkdir() + self.shim = self.root / 'shim' + + def runtime(self, path): + runtime = self.client / path + (runtime / 'bin').mkdir(parents=True) + (runtime / 'bin/steam-runtime-check-requirements').write_text('#!/bin/sh\nexit 1\n') + (runtime / 'bin/steam-runtime-system-info').write_text('#!/bin/sh\necho info\n') + (runtime / 'files').mkdir() + (runtime / 'files/marker').write_text('runtime files') + return runtime + + def build(self): + return subprocess.run(['bash', str(SHIM), str(self.client), str(self.shim)], + capture_output=True, text=True, timeout=30) + + def sh(self, *args): + return subprocess.run(['sh', *map(str, args)], capture_output=True, text=True, timeout=30) + + def assert_shim(self): + result = self.build() + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual(0, self.sh(self.shim / 'bin/steam-runtime-check-requirements').returncode) + self.assertEqual('info\n', self.sh(self.shim / 'bin/steam-runtime-system-info').stdout) + self.assertEqual('runtime files', (self.shim / 'files/marker').read_text()) + launched = self.sh(self.shim / 'bin/steam-runtime-launch-client', '--env=BL_SHIM_TEST=yes', + f'--directory={self.root}', '--', 'sh', '-c', 'echo "$BL_SHIM_TEST $(basename "$PWD")"') + self.assertEqual(f'yes {self.root.name}\n', launched.stdout) + launched = self.sh(self.shim / 'bin/steam-runtime-launch-client', '--env', 'BL_SHIM_TEST=split', + '--directory', self.root, '--', 'sh', '-c', 'echo "$BL_SHIM_TEST $(basename "$PWD")"') + self.assertEqual(f'split {self.root.name}\n', launched.stdout) + missing = self.sh(self.shim / 'bin/steam-runtime-launch-client', '--directory', self.root / 'missing', + '--', 'true') + self.assertEqual(127, missing.returncode) + + def test_a_client_without_a_container_gets_no_shim(self): + self.shim.mkdir() + self.assertEqual(1, self.build().returncode) + self.assertFalse(self.shim.exists()) + + def test_the_pressure_vessel_runtime_is_shimmed(self): + self.runtime('pv-runtime/steam-runtime-steamrt') + self.assert_shim() + + def test_a_requirements_probe_elsewhere_is_shimmed(self): + self.runtime('steam-runtime') + self.assert_shim() + + def test_the_arm64_session_starts_a_containerized_client_through_the_shim(self): + block = re.search(r'\n( +steam_client=\("\$steam_root/steamrtarm64/steam"\)\n.*?\n fi\n)', SESSION, re.S).group(1) + stub = self.root / 'droiddeck-steamrt-shim' + for status, shimmed in ((1, False), (0, True)): + stub.write_text(f'#!/bin/bash\nexit {status}\n') + stub.chmod(0o755) + script = 'if :; then\n' + block + 'printf "%s\\n" "${steam_client[@]}"\n' + env = dict(os.environ, HOME=str(self.root), steam_root='/steam', + PATH=str(self.root) + os.pathsep + os.environ['PATH']) + result = subprocess.run(['bash', '-c', script], env=env, capture_output=True, text=True, timeout=30) + self.assertEqual(0, result.returncode, result.stderr) + lines = result.stdout.splitlines() + self.assertEqual('/steam/steamrtarm64/steam', lines[-1]) + self.assertEqual(shimmed, 'Valve containerized the arm64 Steam client' in result.stdout) + self.assertEqual(shimmed, any(line.startswith('STEAM_RUNTIME_STEAMRT=') and + line.endswith('/.droiddeck-steamrt-shim') for line in lines)) + + def test_both_clients_are_told_they_already_run_inside_the_container(self): + guard = SESSION.index('export STEAM_STEAMRT_RECURSION_GUARD=1') + self.assertLess(guard, SESSION.index('"${steam_client[@]}" "${_bl_ui[@]}"')) + self.assertIn('-u STEAM_STEAMRT_RECURSION_GUARD', SESSION[:guard]) + self.assertIn('export STEAM_STEAMRT_RECURSION_GUARD=1', X64) + self.assertIn('droiddeck-steamrt-shim "$STEAM_X64/steamrt64" "$X64_HOME/steamrt-shim" || rm -rf "$X64_HOME/steamrt-shim"', X64) + + +class SessionChannelTest(unittest.TestCase): + BLOCK = re.search(r'\n( +steam_channel=steamdeck_publicbeta\n.*?\n +fi\n)', SESSION, re.S).group(1) + + def channel(self, x64, installer): + with tempfile.TemporaryDirectory() as temp: + stub = Path(temp) / 'droiddeck-steam-install' + stub.write_text('#!/bin/bash\n' + installer + '\n') + stub.chmod(0o755) + env = dict(os.environ, steam_x64=x64, PATH=temp + os.pathsep + os.environ['PATH']) + result = subprocess.run(['bash', '-c', self.BLOCK + 'echo "$steam_channel"\n'], env=env, + capture_output=True, text=True, timeout=30) + self.assertEqual(0, result.returncode, result.stderr) + return result.stdout.strip() + + def test_the_arm64_client_follows_the_installed_channel(self): + self.assertEqual('steamdeck_stable', self.channel('0', '[ "$1" = --channel ] && echo steamdeck_stable')) + + def test_an_installer_without_an_answer_keeps_the_deck_beta(self): + self.assertEqual('steamdeck_publicbeta', self.channel('0', 'exit 1')) + + def test_the_x86_client_keeps_its_own_channel(self): + self.assertEqual('steamdeck_publicbeta', self.channel('1', 'echo steamdeck_stable')) + + def test_the_client_branch_on_disk_and_clientbeta_use_the_same_channel(self): + self.assertIn('-clientbeta "$steam_channel"', SESSION) + self.assertIn('echo "$steam_channel" > "$steam_beta"', SESSION) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/tests/test_store_launch.py b/tools/tests/test_store_launch.py index 5da2972ed..c42448424 100644 --- a/tools/tests/test_store_launch.py +++ b/tools/tests/test_store_launch.py @@ -277,11 +277,40 @@ class CloudHooks(unittest.TestCase): for sh in (compat.LAUNCHER_SH, compat.EXTRA_WRAPPER_SH): self.assertIn('bl_run ${BL_TASKSET:-} /usr/local/bin/droiddeck-game-env', sh) self.assertNotIn('exec ${BL_TASKSET:-} /usr/local/bin/droiddeck-game-env', sh) - self.assertIn('[ $? -eq 10 ] && BL_STORE_EXIT=1', compat.BL_STORE_SETUP) + self.assertIn('[ $rc -eq 10 ] && BL_STORE_EXIT=1', compat.BL_STORE_SETUP) self.assertIn('droiddeck-store-launch --exited --code "$rc" "${BL_STORE_ARGS[@]}"', compat.BL_STORE_SETUP) self.assertIn('exec "$@"', compat.BL_STORE_SETUP) +BNET_EXE = '/root/.local/share/Steam/steamapps/compatdata/2596113170/pfx/drive_c/Program Files (x86)/Battle.net/Battle.net.exe' + + +class BattleNet(unittest.TestCase): + def test_a_games_shortcut_is_handed_to_droiddeck_battlenet(self): + self.assertEqual([store.BATTLENET, 'launch', 'Fen'], store.battlenet(['waitforexitandrun', BNET_EXE, '--exec=launch Fen'], env={})) + self.assertEqual([store.BATTLENET, 'launch', 'OSI'], store.battlenet(['"%s"' % BNET_EXE, '--exec="launch OSI"'], env={})) + self.assertEqual([store.BATTLENET, 'open'], store.battlenet([BNET_EXE], env={})) + + def test_anything_else_and_its_own_starts_are_left_alone(self): + other = BNET_EXE.replace('2596113170', '123') + self.assertIsNone(store.battlenet([other, '--exec=launch Fen'], env={})) + self.assertIsNone(store.battlenet(['/x/Diablo IV/Diablo IV.exe'], env={})) + self.assertIsNone(store.battlenet([BNET_EXE, '--exec=launch Fen'], env={'BL_BATTLENET': '1'})) + self.assertEqual([store.BATTLENET, 'open'], store.battlenet([BNET_EXE, '--exec=launch Fen;rm'], env={})) + + def test_the_launch_runs_there_and_the_wrapper_ends_with_it(self): + with patch.dict(os.environ, {}, clear=False), patch.object(store.subprocess, 'call', return_value=0) as call: + os.environ.pop('BL_BATTLENET', None) + self.assertEqual(store.RAN, store.main([BNET_EXE, '--exec=launch Pro'])) + call.assert_called_once_with([store.BATTLENET, 'launch', 'Pro']) + with patch.dict(os.environ, {}, clear=False), patch.object(store.subprocess, 'call', return_value=1): + os.environ.pop('BL_BATTLENET', None) + self.assertEqual(store.RAN_FAILED, store.main([BNET_EXE, '--exec=launch Pro'])) + self.assertIn('[ $rc -eq 11 ] && exit 0', compat.BL_STORE_SETUP) + self.assertIn('[ $rc -eq 12 ] && exit 1', compat.BL_STORE_SETUP) + self.assertLess(compat.BL_STORE_SETUP.index('droiddeck-store-launch "$@"'), compat.BL_STORE_SETUP.index('[ $rc -eq 11 ] && exit 0')) + + class Launchers(unittest.TestCase): def test_both_proton_launchers_ask_before_proton_starts(self): sh = compat.LAUNCHER_SH diff --git a/tools/tests/test_tracer_preload.py b/tools/tests/test_tracer_preload.py new file mode 100644 index 000000000..90a444224 --- /dev/null +++ b/tools/tests/test_tracer_preload.py @@ -0,0 +1,154 @@ +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +PRELOAD = Path(__file__).resolve().parents[1] / "linuxfs/preload" + +NTDLL = r""" +#define _GNU_SOURCE +#include <fcntl.h> +#include <stdio.h> +#include <string.h> +#include <unistd.h> + +__attribute__((noinline)) int ntdll_open(const char *path) { + int fd = open(path, O_RDONLY | O_CLOEXEC); + __asm__ volatile("" ::: "memory"); + return fd; +} + +__attribute__((noinline)) int ntdll_openat(const char *path) { + int fd = openat(AT_FDCWD, path, O_RDONLY | O_CLOEXEC); + __asm__ volatile("" ::: "memory"); + return fd; +} + +__attribute__((noinline)) FILE *ntdll_fopen(const char *path) { + FILE *f = fopen(path, "r"); + __asm__ volatile("" ::: "memory"); + return f; +} +""" + +PROGRAM = r""" +#define _GNU_SOURCE +#include <fcntl.h> +#include <signal.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <sys/ptrace.h> +#include <sys/wait.h> +#include <unistd.h> + +int ntdll_open(const char *path); +int ntdll_openat(const char *path); +FILE *ntdll_fopen(const char *path); + +static void report(const char *name, int fd) { + char link[64], target[256] = "", text[16384], *field; + ssize_t n; + long tracer = -1; + if (fd < 0) { + printf("%s failed\n", name); + return; + } + snprintf(link, sizeof(link), "/proc/self/fd/%d", fd); + n = readlink(link, target, sizeof(target) - 1); + target[n > 0 ? n : 0] = '\0'; + n = pread(fd, text, sizeof(text) - 1, 0); + text[n > 0 ? n : 0] = '\0'; + if ((field = strstr(text, "\nTracerPid:\t")) != NULL) tracer = strtol(field + 12, NULL, 10); + const char *kind = strncmp(target, "/proc/", 6) == 0 ? "proc" + : strncmp(target, "/memfd:", 7) == 0 ? "memfd" + : strncmp(target, "/tmp/.droiddeck-status-", 23) == 0 ? "copy" : target; + printf("%s kind=%s tracer=%s rdonly=%d vm=%d\n", name, kind, tracer == 0 ? "hidden" : tracer > 0 ? "shown" : "missing", + (fcntl(fd, F_GETFL) & O_ACCMODE) == O_RDONLY, strstr(text, "\nVmRSS:") != NULL); + close(fd); +} + +static void report_stream(const char *name, FILE *f) { + if (f == NULL) { + printf("%s failed\n", name); + return; + } + report(name, dup(fileno(f))); + fclose(f); +} + +int main(void) { + int ready[2]; + if (pipe(ready) != 0) return 1; + pid_t child = fork(); + if (child == 0) { + char ok = ptrace(PTRACE_TRACEME, 0, NULL, NULL) == 0; + if (write(ready[1], &ok, 1) != 1) _exit(1); + for (;;) pause(); + } + char ok = 0; + if (read(ready[0], &ok, 1) != 1 || !ok) { + kill(child, SIGKILL); + waitpid(child, NULL, 0); + puts("no ptrace"); + return 0; + } + char other[64], self[64]; + snprintf(other, sizeof(other), "/proc/%d/status", (int)child); + snprintf(self, sizeof(self), "/proc/%d/status", (int)getpid()); + report("main_other_open", open(other, O_RDONLY | O_CLOEXEC)); + report_stream("main_other_fopen", fopen(other, "r")); + report("main_self_open", open("/proc/self/status", O_RDONLY | O_CLOEXEC)); + report("ntdll_other_open", ntdll_open(other)); + report("ntdll_other_openat", ntdll_openat(other)); + report_stream("ntdll_other_fopen", ntdll_fopen(other)); + report_stream("ntdll_self_fopen", ntdll_fopen(self)); + kill(child, SIGKILL); + waitpid(child, NULL, 0); + return 0; +} +""" + + +class TracerPreloadTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + if not shutil.which("cc"): + raise unittest.SkipTest("no compiler") + cls.tmp = tempfile.TemporaryDirectory() + tmp = Path(cls.tmp.name) + (tmp / "ntdll.c").write_text(NTDLL) + (tmp / "prog.c").write_text(PROGRAM) + sources = sorted(str(p) for p in PRELOAD.glob("*.c")) + subprocess.run(["cc", "-shared", "-fPIC", "-O2", "-Wall", "-pthread", "-o", str(tmp / "libblsession.so"), *sources, "-ldl"], + check=True) + subprocess.run(["cc", "-shared", "-fPIC", "-O2", "-o", str(tmp / "ntdll.so"), str(tmp / "ntdll.c")], check=True) + subprocess.run(["cc", "-O1", "-o", str(tmp / "prog"), str(tmp / "prog.c"), str(tmp / "ntdll.so"), "-Wl,-rpath," + str(tmp)], + check=True) + env = dict(os.environ, LD_PRELOAD=str(tmp / "libblsession.so")) + out = subprocess.run([str(tmp / "prog")], env=env, capture_output=True, text=True, timeout=60) + if out.returncode != 0: + raise AssertionError(out.stdout + out.stderr) + if "no ptrace" in out.stdout: + raise unittest.SkipTest("ptrace not allowed") + cls.lines = dict(line.split(" ", 1) for line in out.stdout.splitlines()) + + @classmethod + def tearDownClass(cls): + cls.tmp.cleanup() + + def test_other_programs_still_see_no_tracer(self): + self.assertEqual(self.lines["main_other_open"], "kind=copy tracer=hidden rdonly=1 vm=1") + self.assertEqual(self.lines["main_other_fopen"], "kind=copy tracer=hidden rdonly=1 vm=1") + self.assertEqual(self.lines["main_self_open"], "kind=memfd tracer=hidden rdonly=0 vm=1") + + def test_wine_reads_the_real_status_file(self): + for name in ("ntdll_other_open", "ntdll_other_openat", "ntdll_other_fopen"): + self.assertEqual(self.lines[name], "kind=proc tracer=shown rdonly=1 vm=1", name) + self.assertEqual(self.lines["ntdll_self_fopen"], "kind=proc tracer=hidden rdonly=1 vm=1") + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/tests/test_usr2_preload.py b/tools/tests/test_usr2_preload.py new file mode 100644 index 000000000..4a180df39 --- /dev/null +++ b/tools/tests/test_usr2_preload.py @@ -0,0 +1,301 @@ +import ctypes +import os +from pathlib import Path +import platform +import shutil +import subprocess +import tempfile +import unittest + +PRELOAD = Path(__file__).resolve().parents[1] / "linuxfs/preload" + +NTDLL = r""" +#include <signal.h> +__attribute__((noinline)) void syscall_dispatcher_return_slowpath(void) { raise(SIGUSR2); } +void slowpath(void) { + syscall_dispatcher_return_slowpath(); + __asm__ volatile("" ::: "memory"); +} +void slowpath_other(int sig) { + raise(sig); + __asm__ volatile("" ::: "memory"); +} +""" + +PROGRAM = r""" +#define _GNU_SOURCE +#include <asm/sigcontext.h> +#include <pthread.h> +#include <setjmp.h> +#include <signal.h> +#include <stdint.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <ucontext.h> +#include <unistd.h> + +void slowpath(void); +void slowpath_other(int sig); + +static char altstack[65536]; +static char landing_stack[65536] __attribute__((aligned(16))); +static volatile int mode; +static int calls; +static volatile int on_alt, usr2_blocked, usr1_blocked, code_ok, stack_ok; +static volatile long handler_sp; +static jmp_buf back; +static uint64_t saved[31]; +static unsigned __int128 saved_v[32]; +static uint32_t saved_fpsr; + +void landing(void); +__asm__(".text\n.globl landing\n.type landing, %function\nlanding:\n" + " sub sp, sp, #784\n" + " stp x0, x1, [sp, #0]\n stp x2, x3, [sp, #16]\n stp x4, x5, [sp, #32]\n stp x6, x7, [sp, #48]\n" + " stp x8, x9, [sp, #64]\n stp x10, x11, [sp, #80]\n stp x12, x13, [sp, #96]\n stp x14, x15, [sp, #112]\n" + " stp x16, x17, [sp, #128]\n stp x18, x19, [sp, #144]\n stp x20, x21, [sp, #160]\n stp x22, x23, [sp, #176]\n" + " stp x24, x25, [sp, #192]\n stp x26, x27, [sp, #208]\n stp x28, x29, [sp, #224]\n str x30, [sp, #240]\n" + " add x9, sp, #256\n" + " stp q0, q1, [x9, #0]\n stp q2, q3, [x9, #32]\n stp q4, q5, [x9, #64]\n stp q6, q7, [x9, #96]\n" + " stp q8, q9, [x9, #128]\n stp q10, q11, [x9, #160]\n stp q12, q13, [x9, #192]\n stp q14, q15, [x9, #224]\n" + " stp q16, q17, [x9, #256]\n stp q18, q19, [x9, #288]\n stp q20, q21, [x9, #320]\n stp q22, q23, [x9, #352]\n" + " stp q24, q25, [x9, #384]\n stp q26, q27, [x9, #416]\n stp q28, q29, [x9, #448]\n stp q30, q31, [x9, #480]\n" + " mrs x10, fpsr\n str w10, [sp, #768]\n" + " mov x0, sp\n bl landed\n brk #0\n"); + +void landed(uint64_t *regs) { + memcpy(saved, regs, sizeof(saved)); + memcpy(saved_v, regs + 32, sizeof(saved_v)); + saved_fpsr = *(uint32_t *)(regs + 96); + longjmp(back, 1); +} + +static struct fpsimd_context *fpsimd_of(ucontext_t *uc) { + struct _aarch64_ctx *c = (struct _aarch64_ctx *)uc->uc_mcontext.__reserved; + while (c->magic) { + if (c->magic == FPSIMD_MAGIC) return (struct fpsimd_context *)c; + c = (struct _aarch64_ctx *)((char *)c + c->size); + } + return NULL; +} + +static void handler(int sig, siginfo_t *info, void *ctx) { + ucontext_t *uc = ctx; + sigset_t now; + long sp = (long)&now; + __atomic_add_fetch(&calls, 1, __ATOMIC_RELAXED); + handler_sp = sp; + on_alt = sp >= (long)altstack && sp < (long)altstack + (long)sizeof(altstack); + pthread_sigmask(SIG_BLOCK, NULL, &now); + usr2_blocked = sigismember(&now, SIGUSR2); + usr1_blocked = sigismember(&now, SIGUSR1); + code_ok = sig == SIGUSR2 && info->si_signo == SIGUSR2 && info->si_code == SI_TKILL && info->si_pid == getpid(); + stack_ok = uc->uc_stack.ss_sp == altstack && uc->uc_stack.ss_size == sizeof(altstack) && !(uc->uc_stack.ss_flags & SS_DISABLE); + if (mode == 1) { + struct fpsimd_context *fp = fpsimd_of(uc); + for (int i = 0; i <= 28; i++) uc->uc_mcontext.regs[i] = 0x1000 + i; + uc->uc_mcontext.regs[29] = 0x2900; + uc->uc_mcontext.regs[30] = 0x3000; + uc->uc_mcontext.sp = (uint64_t)(landing_stack + sizeof(landing_stack)); + uc->uc_mcontext.pc = (uint64_t)landing; + uc->uc_mcontext.pstate = 0x60000000; + for (int i = 0; i < 32; i++) fp->vregs[i] = ((unsigned __int128)(0x7700 + i) << 64) | (0x5500 + i); + fp->fpsr = 0x8; + } +} + +static void usr1(int sig, siginfo_t *info, void *ctx) { + (void)sig; + (void)info; + (void)ctx; +} + +uint64_t kept_x[10]; +double kept_d[8]; + +static int keep_callee_saved(void) { + uint64_t *out = kept_x; + double *d = kept_d; + __asm__ volatile( + "mov x19, #19\n mov x20, #20\n mov x21, #21\n mov x22, #22\n mov x23, #23\n" + "mov x24, #24\n mov x25, #25\n mov x26, #26\n mov x27, #27\n mov x28, #28\n" + "fmov d8, #1.0\n fmov d9, #2.0\n fmov d10, #3.0\n fmov d11, #4.0\n" + "fmov d12, #5.0\n fmov d13, #6.0\n fmov d14, #7.0\n fmov d15, #8.0\n" + "bl slowpath\n" + "adrp x0, kept_x\n add x0, x0, :lo12:kept_x\n" + "stp x19, x20, [x0, #0]\n stp x21, x22, [x0, #16]\n stp x23, x24, [x0, #32]\n" + "stp x25, x26, [x0, #48]\n stp x27, x28, [x0, #64]\n" + "adrp x0, kept_d\n add x0, x0, :lo12:kept_d\n" + "stp d8, d9, [x0, #0]\n stp d10, d11, [x0, #16]\n stp d12, d13, [x0, #32]\n stp d14, d15, [x0, #48]\n" + : + : + : "x0", "x1", "x2", "x3", "x4", "x5", "x6", "x7", "x8", "x9", "x10", "x11", "x12", "x13", "x14", "x15", + "x16", "x17", "x19", "x20", "x21", "x22", "x23", "x24", "x25", "x26", "x27", "x28", "x30", + "v0", "v1", "v2", "v3", "v4", "v5", "v6", "v7", "v8", "v9", "v10", "v11", "v12", "v13", "v14", "v15", + "v16", "v17", "v18", "v19", "v20", "v21", "v22", "v23", "v24", "v25", "v26", "v27", "v28", "v29", "v30", + "v31", "memory", "cc"); + for (int i = 0; i < 10; i++) + if (out[i] != (uint64_t)(19 + i)) return 0; + for (int i = 0; i < 8; i++) + if (d[i] != (double)(i + 1)) return 0; + return 1; +} + +static void *stress(void *arg) { + (void)arg; + for (int i = 0; i < 2000; i++) slowpath(); + return NULL; +} + +int main(int argc, char **argv) { + stack_t ss = {.ss_sp = altstack, .ss_size = sizeof(altstack), .ss_flags = 0}; + struct sigaction sa; + sigset_t mask, now; + sigaltstack(&ss, NULL); + memset(&sa, 0, sizeof(sa)); + sa.sa_sigaction = handler; + sa.sa_flags = SA_SIGINFO | SA_ONSTACK | SA_RESTART; + sigemptyset(&sa.sa_mask); + sigaddset(&sa.sa_mask, SIGUSR1); + sigaction(SIGUSR2, &sa, NULL); + sa.sa_sigaction = usr1; + sigemptyset(&sa.sa_mask); + sigaction(SIGUSR1, &sa, NULL); + + if (argc > 1 && strcmp(argv[1], "bench") == 0) { + int n = argc > 2 ? atoi(argv[2]) : 20000; + struct timespec a, b; + clock_gettime(CLOCK_MONOTONIC, &a); + for (int i = 0; i < n; i++) slowpath(); + clock_gettime(CLOCK_MONOTONIC, &b); + printf("%.2f us per raise, alt %d\n", ((b.tv_sec - a.tv_sec) * 1e9 + (b.tv_nsec - a.tv_nsec)) / 1e3 / n, on_alt); + return 0; + } + + mode = 0; + calls = 0; + int kept = keep_callee_saved(); + pthread_sigmask(SIG_BLOCK, NULL, &now); + printf("returned calls=%d kept=%d usr2_blocked=%d usr1_blocked=%d code=%d stack=%d after_usr2=%d after_usr1=%d\n", + calls, kept, usr2_blocked, usr1_blocked, code_ok, stack_ok, sigismember(&now, SIGUSR2), + sigismember(&now, SIGUSR1)); + printf("path %s\n", on_alt ? "kernel" : "inline"); + + mode = 1; + calls = 0; + if (!setjmp(back)) { + slowpath(); + printf("redirect did not happen\n"); + } else { + int ok = 1; + for (int i = 0; i <= 28; i++) + if (saved[i] != (uint64_t)(0x1000 + i)) ok = 0; + if (saved[29] != 0x2900 || saved[30] != 0x3000) ok = 0; + for (int i = 0; i < 32; i++) + if (saved_v[i] != (((unsigned __int128)(0x7700 + i) << 64) | (0x5500 + i))) ok = 0; + printf("redirect calls=%d regs=%d fpsr=%u\n", calls, ok, saved_fpsr & 0x8); + } + mode = 0; + + sigemptyset(&mask); + sigaddset(&mask, SIGUSR2); + pthread_sigmask(SIG_BLOCK, &mask, NULL); + calls = 0; + slowpath(); + int while_blocked = calls; + pthread_sigmask(SIG_UNBLOCK, &mask, NULL); + printf("blocked during=%d after=%d\n", while_blocked, calls); + + calls = 0; + slowpath_other(SIGUSR1); + raise(SIGUSR2); + printf("other calls=%d main_path=%s\n", calls, on_alt ? "kernel" : "inline"); + + calls = 0; + pthread_t t[4]; + for (int i = 0; i < 4; i++) pthread_create(&t[i], NULL, stress, NULL); + for (int i = 0; i < 4; i++) pthread_join(t[i], NULL); + printf("threads calls=%d\n", calls); + return 0; +} +""" + + +def has_gcs_hwcap(): + try: + getauxval = ctypes.CDLL(None).getauxval + except (AttributeError, OSError): + return False + getauxval.restype = ctypes.c_ulong + getauxval.argtypes = [ctypes.c_ulong] + return bool(getauxval(16) & (1 << 32)) + + +def runner(): + if platform.machine() in ("aarch64", "arm64") and shutil.which("gcc") and not has_gcs_hwcap(): + return "gcc", [] + if shutil.which("aarch64-linux-gnu-gcc") and shutil.which("qemu-aarch64") and Path("/usr/aarch64-linux-gnu").is_dir(): + return "aarch64-linux-gnu-gcc", ["qemu-aarch64", "-cpu", "neoverse-n1", "-L", "/usr/aarch64-linux-gnu"] + return None, None + + +class Usr2PreloadTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.cc, cls.prefix = runner() + if cls.cc is None: + raise unittest.SkipTest("no aarch64 compiler and runner") + cls.tmp = tempfile.TemporaryDirectory() + tmp = Path(cls.tmp.name) + (tmp / "ntdll.c").write_text(NTDLL) + (tmp / "prog.c").write_text(PROGRAM) + sources = sorted(str(p) for p in PRELOAD.glob("*.c")) + subprocess.run([cls.cc, "-shared", "-fPIC", "-O2", "-Wall", "-pthread", "-o", str(tmp / "libblsession.so"), + *sources, "-ldl"], check=True) + subprocess.run([cls.cc, "-shared", "-fPIC", "-O2", "-o", str(tmp / "ntdll.so"), str(tmp / "ntdll.c")], check=True) + subprocess.run([cls.cc, "-O1", "-U_FORTIFY_SOURCE", "-pthread", "-o", str(tmp / "prog"), str(tmp / "prog.c"), str(tmp / "ntdll.so"), + "-Wl,-rpath," + str(tmp)], check=True) + cls.program = tmp / "prog" + cls.preload = tmp / "libblsession.so" + + @classmethod + def tearDownClass(cls): + cls.tmp.cleanup() + + def run_program(self, preload): + env = dict(os.environ) + env.pop("LD_PRELOAD", None) + cmd = list(self.prefix) + [str(self.program)] + if preload: + if self.prefix: + cmd = list(self.prefix) + ["-E", "LD_PRELOAD=" + str(self.preload), str(self.program)] + else: + env["LD_PRELOAD"] = str(self.preload) + out = subprocess.run(cmd, env=env, capture_output=True, text=True, timeout=120) + self.assertEqual(out.returncode, 0, out.stderr) + return out.stdout.splitlines() + + def test_a_ntdll_raise_runs_the_handler_without_a_kernel_signal(self): + lines = self.run_program(True) + self.assertIn("path inline", lines) + self.assertIn("returned calls=1 kept=1 usr2_blocked=1 usr1_blocked=1 code=1 stack=1 after_usr2=0 after_usr1=0", lines) + + def test_b_context_the_handler_writes_is_the_one_that_resumes(self): + self.assertIn("redirect calls=1 regs=1 fpsr=8", self.run_program(True)) + + def test_c_behaves_like_the_kernel_signal(self): + stock = [line for line in self.run_program(False) if not line.startswith("path ")] + inline = [line for line in self.run_program(True) if not line.startswith("path ")] + self.assertIn("returned calls=1 kept=1 usr2_blocked=1 usr1_blocked=1 code=1 stack=1 after_usr2=0 after_usr1=0", stock) + self.assertEqual(stock, inline) + + def test_d_blocked_other_signals_and_other_callers_use_the_kernel(self): + lines = self.run_program(True) + self.assertIn("blocked during=0 after=1", lines) + self.assertIn("other calls=1 main_path=kernel", lines) + self.assertIn("threads calls=8000", lines) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/tests/test_venc_emulator.py b/tools/tests/test_venc_emulator.py index 9fcf1f10d..416f6b61b 100644 --- a/tools/tests/test_venc_emulator.py +++ b/tools/tests/test_venc_emulator.py @@ -223,5 +223,35 @@ class VencEmulatorTest(unittest.TestCase): self.assertEqual(server.touches, [(0, 65535, 32767), (1, 32767, 0), (2, 32767, 0)]) + def test_xtest_calls_from_a_locally_loaded_library_still_reach_libxtst(self): + with tempfile.TemporaryDirectory() as tmp: + tmp = Path(tmp) + (tmp / 'xtst.c').write_text( + 'int XTestFakeButtonEvent(void *d, unsigned b, int p, unsigned long t) { return 7; }\n' + 'int XTestFakeMotionEvent(void *d, int s, int x, int y, unsigned long t) { return 7; }\n') + (tmp / 'ui.c').write_text( + 'int XTestFakeButtonEvent(void *, unsigned, int, unsigned long);\n' + 'int XTestFakeMotionEvent(void *, int, int, int, unsigned long);\n' + 'int ui_click(void) { return XTestFakeButtonEvent(0, 1, 1, 0) + XTestFakeButtonEvent(0, 3, 0, 0); }\n' + 'int ui_move(void) { return XTestFakeMotionEvent(0, 0, 10, 20, 0); }\n') + (tmp / 'main.c').write_text( + '#include <dlfcn.h>\n#include <stdio.h>\n' + 'int main(int argc, char **argv) {\n' + ' void *ui = dlopen(argv[1], RTLD_NOW | RTLD_LOCAL);\n' + ' if (!ui) return 2;\n' + ' int (*click)(void) = dlsym(ui, "ui_click"), (*move)(void) = dlsym(ui, "ui_move");\n' + ' printf("%d %d\\n", click(), move());\n' + ' return click() == 14 && move() == 7 ? 0 : 1;\n' + '}\n') + run = lambda *a: subprocess.run(['cc', *a], check=True, cwd=tmp) + run('-shared', '-fPIC', '-Wl,-soname,libXtst.so.6', '-o', 'libXtst.so.6', 'xtst.c') + run('-shared', '-fPIC', '-o', 'libui.so', 'ui.c', '-L.', '-l:libXtst.so.6', "-Wl,-rpath,$ORIGIN") + run('-shared', '-fPIC', '-pthread', '-o', 'libhook.so', str(VENC), str(TOUCH), '-ldl') + run('-o', 'main', 'main.c', '-ldl') + done = subprocess.run([str(tmp / 'main'), str(tmp / 'libui.so')], + env=dict(os.environ, LD_PRELOAD=str(tmp / 'libhook.so')), + capture_output=True, text=True, timeout=20) + self.assertEqual(done.returncode, 0, done.stdout + done.stderr) + if __name__ == '__main__': unittest.main() diff --git a/tools/tests/test_vrclient_loader.py b/tools/tests/test_vrclient_loader.py index f46826992..fa1a778bf 100644 --- a/tools/tests/test_vrclient_loader.py +++ b/tools/tests/test_vrclient_loader.py @@ -32,6 +32,7 @@ int probe(void) { #include <assert.h> #include <dlfcn.h> int bl_fsync_syscall(long n, const long *a, long *r) { (void)n; (void)a; (void)r; return 0; } +int bl_writewatch_syscall(long n, const long *a, long (*real)(long, ...), long *r) { (void)n; (void)a; (void)real; (void)r; return 0; } #include """ + '"' + robust.as_posix() + '"' + r""" int main(int argc, char **argv) { assert(argc == 3); diff --git a/tools/tests/test_writewatch_preload.py b/tools/tests/test_writewatch_preload.py new file mode 100644 index 000000000..38f296755 --- /dev/null +++ b/tools/tests/test_writewatch_preload.py @@ -0,0 +1,119 @@ +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +PRELOAD = Path(__file__).resolve().parents[1] / "linuxfs/preload" + +PROGRAM = r""" +#include "WRITEWATCH" +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <sys/mman.h> + +#define PAGES 96 + +static int uffd, pagemap; +static uint64_t page; + +static char *region(void) { + char *m = mmap(NULL, PAGES * page, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + madvise(m, PAGES * page, MADV_NOHUGEPAGE); + struct uffdio_register reg = { .range = { (uintptr_t)m, PAGES * page }, .mode = UFFDIO_REGISTER_MODE_WP }; + if (m == MAP_FAILED || ioctl(uffd, UFFDIO_REGISTER, ®)) exit(3); + protect(uffd, (uintptr_t)m, (uintptr_t)m + PAGES * page); + return m; +} + +static long scan(char *m, int emulated, uint64_t flags, uint64_t vec_len, uint64_t max_pages, uint64_t from, uint64_t to, + struct page_region *vec, uint64_t *walk_end) { + struct pm_scan_arg a = { .size = sizeof a, .flags = flags, .start = (uintptr_t)m + from * page, .end = (uintptr_t)m + to * page, + .vec = vec_len ? (uintptr_t)vec : 0, .vec_len = vec_len, .max_pages = max_pages, + .category_mask = PAGE_IS_WRITTEN, .return_mask = PAGE_IS_WRITTEN }; + long n = emulated ? emulate_scan(pagemap, uffd, &a) : ioctl(pagemap, PAGEMAP_SCAN, &a); + *walk_end = (a.walk_end - (uintptr_t)m) / page; + return n; +} + +int main(int argc, char **argv) { + page = (uint64_t)getpagesize(); + uffd = syscall(SYS_userfaultfd, O_CLOEXEC | O_NONBLOCK | UFFD_USER_MODE_ONLY); + struct uffdio_api api = { .api = UFFD_API, .features = UFFD_FEATURE_WP_ASYNC | UFFD_FEATURE_WP_UNPOPULATED }; + pagemap = open("/proc/self/pagemap", O_RDONLY | O_CLOEXEC); + if (uffd < 0 || ioctl(uffd, UFFDIO_API, &api) || pagemap < 0) { puts("unsupported"); return 0; } + char *kernel = region(), *ours = region(); + struct page_region probe[1]; + uint64_t end; + if (scan(kernel, 0, 0, 1, 0, 0, PAGES, probe, &end) < 0) { puts("unsupported"); return 0; } + srand(atoi(argv[1])); + int rounds = atoi(argv[2]), differing = 0; + for (int r = 0; r < rounds; r++) { + int writes = rand() % 24; + for (int w = 0; w < writes; w++) { + int p = rand() % PAGES; + if (rand() % 4 == 0) { + volatile char x = kernel[p * page] + ours[p * page]; + (void)x; + continue; + } + kernel[p * page + rand() % page] = (char)r; + ours[p * page + rand() % page] = (char)r; + } + uint64_t flags = rand() % 2 ? PM_SCAN_WP_MATCHING : 0; + uint64_t vec_len = rand() % 3 == 0 ? 0 : 1 + rand() % 5; + uint64_t max_pages = rand() % 3 == 0 ? 1 + rand() % 8 : 0; + if (!vec_len) flags = PM_SCAN_WP_MATCHING, max_pages = 0; + uint64_t from = rand() % PAGES, to = from + rand() % (PAGES - from + 1); + struct page_region kv[8], ov[8]; + uint64_t kend, oend; + long kn = scan(kernel, 0, flags, vec_len, max_pages, from, to, kv, &kend); + long on = scan(ours, 1, flags, vec_len, max_pages, from, to, ov, &oend); + int same = kn == on && (kn < 0 || kend == oend); + for (long i = 0; same && i < kn; i++) + same = kv[i].start - (uintptr_t)kernel == ov[i].start - (uintptr_t)ours + && kv[i].end - (uintptr_t)kernel == ov[i].end - (uintptr_t)ours && kv[i].categories == ov[i].categories; + struct page_region kall[PAGES], oall[PAGES]; + long ka = scan(kernel, 0, 0, PAGES, 0, 0, PAGES, kall, &kend), oa = scan(ours, 1, 0, PAGES, 0, 0, PAGES, oall, &oend); + same = same && ka == oa; + for (long i = 0; same && i < ka; i++) + same = kall[i].start - (uintptr_t)kernel == oall[i].start - (uintptr_t)ours && kall[i].end - (uintptr_t)kernel == oall[i].end - (uintptr_t)ours; + if (!same) { + differing++; + printf("round %d flags %llu vec %llu max %llu range %llu-%llu: kernel %ld end %llu, ours %ld end %llu\n", r, + (unsigned long long)flags, (unsigned long long)vec_len, (unsigned long long)max_pages, (unsigned long long)from, + (unsigned long long)to, kn, (unsigned long long)kend, on, (unsigned long long)oend); + } + } + printf("rounds %d differing %d\n", rounds, differing); + return 0; +} +""" + + +class WriteWatchPreloadTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + if shutil.which("gcc") is None: + raise unittest.SkipTest("gcc is not available") + cls.work = Path(tempfile.mkdtemp()) + source = cls.work / "writewatch.c" + source.write_text(PROGRAM.replace("WRITEWATCH", str(PRELOAD / "writewatch.c"))) + cls.program = cls.work / "writewatch" + subprocess.run(["gcc", "-O1", "-Wall", "-o", str(cls.program), str(source)], check=True) + + @classmethod + def tearDownClass(cls): + shutil.rmtree(cls.work, ignore_errors=True) + + def test_a_scan_android_refuses_answers_like_the_kernel_one(self): + for seed in range(4): + out = subprocess.run([str(self.program), str(seed), "400"], capture_output=True, text=True, timeout=60).stdout + if out.strip() == "unsupported": + self.skipTest("this kernel has no asynchronous userfaultfd write protection") + self.assertTrue(out.rstrip().endswith("differing 0"), out) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/tests/test_xbox_build_source.py b/tools/tests/test_xbox_build_source.py index 8177a1cbc..427cea342 100644 --- a/tools/tests/test_xbox_build_source.py +++ b/tools/tests/test_xbox_build_source.py @@ -31,14 +31,14 @@ class XboxBuildSourceTest(unittest.TestCase): git('switch', '-q', 'main') (root / 'backend').write_text('merged backend') git('commit', '-qam', 'backend changed after branch') - for name in ('xbox', 'apk'): + for name in ('source', 'xbox', 'components', 'apk', 'tests', 'dispatch'): git('clone', '-q', str(root), str(root / name)) git('checkout', '-q', head, cwd=root / name) output = root / 'output' env['GITHUB_OUTPUT'] = str(output) - subprocess.run(['bash', '-c', step('xbox', 'Pin and merge the APK source')], cwd=root / 'xbox', env=env, check=True, capture_output=True) + subprocess.run(['bash', '-c', step('source', 'Pin and merge the APK source')], cwd=root / 'source', env=env, check=True, capture_output=True) base = output.read_text().strip().split('=', 1)[1] - tree = git('rev-parse', 'HEAD^{tree}', cwd=root / 'xbox') + tree = git('rev-parse', 'HEAD^{tree}', cwd=root / 'source') (root / 'backend').write_text('newer main, after backend build') git('commit', '-qam', 'main advanced again') env.update(BUILD_BASE_SHA=base, XBOX_SOURCE_TREE=tree) @@ -46,3 +46,19 @@ class XboxBuildSourceTest(unittest.TestCase): subprocess.run(['bash', '-c', step('build', 'Verify backend and APK source match')], cwd=root / 'apk', env=env, check=True, capture_output=True) self.assertEqual((root / 'apk/backend').read_text(), 'merged backend') self.assertEqual(git('rev-parse', 'HEAD^{tree}', cwd=root / 'apk'), tree) + + subprocess.run(['bash', '-c', step('python-tests', 'Merge the pinned APK base')], cwd=root / 'tests', env=env, check=True, capture_output=True) + subprocess.run(['bash', '-c', step('python-tests', 'Verify backend and test source match')], cwd=root / 'tests', env=env, check=True, capture_output=True) + self.assertEqual(git('rev-parse', 'HEAD^{tree}', cwd=root / 'tests'), tree) + + env['SOURCE_TREE'] = tree + for job in ('xbox', 'components'): + subprocess.run(['bash', '-c', step(job, 'Merge the pinned APK base')], cwd=root / job, env=env, check=True, capture_output=True) + subprocess.run(['bash', '-c', step(job, 'Verify pinned source tree')], cwd=root / job, env=env, check=True, capture_output=True) + self.assertEqual(git('rev-parse', 'HEAD^{tree}', cwd=root / job), tree) + + trusted = root / 'trusted-output' + env['GITHUB_OUTPUT'] = str(trusted) + subprocess.run(['bash', '-c', step('source', 'Resolve trusted manifest base')], cwd=root / 'dispatch', env=env, check=True, capture_output=True) + self.assertEqual(trusted.read_text().strip(), 'base-sha=' + git('rev-parse', 'main')) + self.assertNotEqual(git('rev-parse', 'HEAD', cwd=root / 'dispatch'), git('rev-parse', 'main')) diff --git a/tools/tests/test_xbox_launch.py b/tools/tests/test_xbox_launch.py index fb7bfdc0d..3ed2cf6c1 100644 --- a/tools/tests/test_xbox_launch.py +++ b/tools/tests/test_xbox_launch.py @@ -1,8 +1,10 @@ +import io import json import os from pathlib import Path import runpy import socket +import subprocess import tempfile import threading import unittest @@ -54,11 +56,13 @@ class XboxLaunchTest(unittest.TestCase): patch.dict(xbox['launch'].__globals__, wine=unittest.mock.Mock(return_value=7)): launch = xbox['launch'] self.assertEqual(launch(['/proton', 'waitforexitandrun', '/encrypted/Game.exe'], - {'BL_LAUNCH_DIR': '/launch', 'DROIDDECK_XBOX_CREDENTIAL_SOCKET': 'stale'}), 7) + {'BL_LAUNCH_DIR': '/launch', 'DROIDDECK_XBOX_CREDENTIAL_SOCKET': 'stale', + 'DROIDDECK_XBOX_CREDENTIAL_PORT': '4100', 'DROIDDECK_XBOX_CREDENTIAL_KEY': 'stale-key'}), 7) wine = launch.__globals__['wine'] self.assertEqual(wine.call_args.args[0], ['/private/Game.exe']) env = wine.call_args.args[1] - self.assertNotIn('DROIDDECK_XBOX_CREDENTIAL_SOCKET', env) + for name in ('DROIDDECK_XBOX_CREDENTIAL_SOCKET', 'DROIDDECK_XBOX_CREDENTIAL_PORT', 'DROIDDECK_XBOX_CREDENTIAL_KEY'): + self.assertNotIn(name, env) self.assertEqual(env['XODUS_XSTS_PLAYFAB'], 'game-token') close.assert_called_once() self.assertEqual(store['ask'].call_args.args[1], {'op': 'xbox-released', 'lease': 'lease-1'}) @@ -115,7 +119,8 @@ class XboxLaunchTest(unittest.TestCase): command = root / 'command.json' command.write_text(json.dumps(['/selected/proton', 'waitforexitandrun', '/encrypted/Game.exe', '--custom', 'two words'])) env = {'STEAM_COMPAT_DATA_PATH': str(prefix.parent), 'DROIDDECK_XBOX_COMMAND_FILE': str(command), - 'DROIDDECK_XBOX_CREDENTIAL_SOCKET': 'private-account-channel', 'XODUS_BRIDGE_KEY': 'runtime-key'} + 'DROIDDECK_XBOX_CREDENTIAL_SOCKET': 'private-account-channel', 'DROIDDECK_XBOX_CREDENTIAL_PORT': '4100', + 'DROIDDECK_XBOX_CREDENTIAL_KEY': 'private-key', 'XODUS_BRIDGE_KEY': 'runtime-key'} with patch.dict(os.environ, env), patch.dict(xbox['wine'].__globals__, RUNTIME=runtime.parent), patch('subprocess.run') as initialize, patch('subprocess.call', return_value=7) as call: initialize.return_value.returncode = 0 self.assertEqual(xbox['wine'](['/private-view/Game.exe']), 7) @@ -126,6 +131,43 @@ class XboxLaunchTest(unittest.TestCase): initialize.assert_called_once() for child in (initialize, call): child_env = child.call_args.kwargs['env'] - self.assertNotIn('DROIDDECK_XBOX_CREDENTIAL_SOCKET', child_env) + for name in ('DROIDDECK_XBOX_CREDENTIAL_SOCKET', 'DROIDDECK_XBOX_CREDENTIAL_PORT', 'DROIDDECK_XBOX_CREDENTIAL_KEY'): + self.assertNotIn(name, child_env) self.assertNotIn('DROIDDECK_XBOX_COMMAND_FILE', child_env) self.assertEqual(child_env['XODUS_BRIDGE_KEY'], 'runtime-key') + + def test_missing_runtime_classes_are_registered_once(self): + with tempfile.TemporaryDirectory() as root: + prefix = Path(root) + key = xbox['CLASS_KEY'].replace('\\', '\\\\') + present = [name for names in xbox['CLASSES'].values() for name in names + if name != 'Windows.ApplicationModel.DataTransfer.DataTransferManager'] + (prefix / 'system.reg').write_text(''.join('[%s%s] 1\n"DllPath"="x"\n\n' % (key, name) for name in present)) + imported = [] + def run(argv, **kwargs): + imported.append(Path(argv[4][2:].replace('\\', '/')).read_bytes()[2:].decode('utf-16-le')) + return subprocess.CompletedProcess(argv, 0) + with patch('subprocess.run', side_effect=run) as child: + xbox['register_classes']('/selected/proton', prefix, {'A': '1'}) + self.assertEqual(child.call_args.args[0][:4], ['/selected/proton', 'run', 'reg.exe', 'import']) + self.assertEqual(child.call_args.kwargs['env'], {'A': '1'}) + self.assertEqual(imported, ['Windows Registry Editor Version 5.00\r\n\r\n[HKEY_LOCAL_MACHINE\\Software\\Microsoft\\WindowsRuntime' + '\\ActivatableClassId\\Windows.ApplicationModel.DataTransfer.DataTransferManager]\r\n' + '"DllPath"="C:\\\\windows\\\\system32\\\\twinapi.appcore.dll"\r\n\r\n']) + with (prefix / 'system.reg').open('a') as file: + file.write('[%sWindows.ApplicationModel.DataTransfer.DataTransferManager] 1\n' % key) + with patch('subprocess.run') as child: + xbox['register_classes']('/selected/proton', prefix, {}) + (prefix / 'system.reg').unlink() + xbox['register_classes']('/selected/proton', prefix, {}) + child.assert_not_called() + + def test_failed_class_registration_is_reported_and_launch_continues(self): + with tempfile.TemporaryDirectory() as root: + prefix = Path(root) + (prefix / 'system.reg').write_text('') + for outcome in ({'return_value': subprocess.CompletedProcess([], 1)}, + {'side_effect': subprocess.TimeoutExpired('reg.exe', 90)}): + with patch('subprocess.run', **outcome), patch('sys.stderr', new_callable=io.StringIO) as err: + self.assertIsNone(xbox['register_classes']('/selected/proton', prefix, {})) + self.assertIn('registration failed', err.getvalue()) diff --git a/tools/tests/test_zram_preload.py b/tools/tests/test_zram_preload.py index a9a437e3e..88d5c7302 100644 --- a/tools/tests/test_zram_preload.py +++ b/tools/tests/test_zram_preload.py @@ -129,6 +129,8 @@ class ZramPreloadTest(unittest.TestCase): def setUp(self): self.ctl.write_text("0") + for ack in self.dir.glob("ctl.ack.*"): + ack.unlink() def run_program(self, program=None, mb=48, cycles=1, *extra): environment = dict(os.environ, LD_PRELOAD=str(self.lib)) @@ -140,9 +142,10 @@ class ZramPreloadTest(unittest.TestCase): out.setdefault(key, []).append(int(value)) return out - def test_only_the_client_takes_the_signal(self): + def test_every_process_takes_the_signal(self): self.assertEqual([1], self.run_program()["handler"]) - self.assertEqual([0], self.run_program(self.game)["handler"]) + self.assertEqual([1], self.run_program(self.game)["handler"]) + self.assertEqual(2, len(list(self.dir.glob("ctl.ack.*")))) def test_nothing_is_installed_without_the_control_file(self): self.ctl.unlink() @@ -157,6 +160,7 @@ class ZramPreloadTest(unittest.TestCase): self.assertGreater(out["out"][0], 32 * 1024) self.assertLess(out["back"][0], out["out"][0] // 4) self.assertEqual([1], out["intact"]) + self.assertEqual(["1"], [ack.read_text() for ack in self.dir.glob("ctl.ack.*")]) @unittest.skipUnless(swap_total_kb() > 256 * 1024, "needs swap") def test_reads_back_what_the_kernel_swapped_elsewhere(self): diff --git a/tools/xbox/README.md b/tools/xbox/README.md index abe39f8c9..986fc1bd7 100644 --- a/tools/xbox/README.md +++ b/tools/xbox/README.md @@ -7,6 +7,12 @@ checksums. `runtime.lock.json` pins the Nexus Windows runtime release and each extracted PE DLL. `prepare.py` verifies both source revisions and all Nexus patch checksums before applying the local transport, launch and library patches. +`droiddeck-cloud-token.patch` adds `xodus-cli cloud-token`: the stored account's +Xbox Cloud Gaming sign-in (XSTS for `http://gssv.xboxlive.com/`, then the streaming +service's `/v2/login/user`) and the login proof token a session's `/connect` step +takes. It prints one JSON object and never opens a login or provisions a device. +Ported from Bannerlator. + `droiddeck-library-subscriptions.patch` queries fresh subscription membership alongside product entitlements using the same Store session, account and market. Android can validate a Game Pass child's parent plan when that plan is absent @@ -30,6 +36,16 @@ patched backend source archive and the checksum-verified runtime source archive in the build cache and publishes both in CI's `xbox-corresponding-source` artifact; distribute corresponding sources with published binaries. +`droiddeck-credential-fallback.patch` names the reason when the backend cannot reach the +app's credential socket (`NEXUS_CREDENTIAL_ERROR_V1 socket-refused`, `-denied`, ...) and then +uses a keyed loopback port the app also listens on; some devices refuse the abstract socket +from inside PRoot. The key is passed in the backend's environment only. + +`droiddeck-credential-diagnostics.patch` names the step behind a `credential-io` failure: the +session lock (`lock-dir-owner`, `lock-file-mode`, `lock-flock-<reason>`, ...), a socket or loopback +exchange that broke after connecting (`socket-exchange-<reason>`), a malformed host reply, or a +missing socket name, port or key. Errno values without a name are spelled with letters, a = 0 ... j = 9. + `droiddeck-credentials.patch` replaces Linux Secret Service with a bounded, Android abstract socket restricted to the backend process started by the host. The Android host encrypts every persistent write using its existing Keystore cipher, verifies backend read-backs, and diff --git a/tools/xbox/build-local.sh b/tools/xbox/build-local.sh index 6a3eb90ce..979c95b12 100755 --- a/tools/xbox/build-local.sh +++ b/tools/xbox/build-local.sh @@ -1,14 +1,7 @@ #!/usr/bin/env bash set -euo pipefail repo=$(cd "$(dirname "$0")/../.." && pwd) -cache=${DROIDDECK_XBOX_CACHE:-"${HOME}/.cache/droiddeck-xbox-build"} -mkdir -p "$cache" -docker run --rm --platform linux/arm64 -v "$repo:/src" -v "$cache:/work" -w /src \ - -e CARGO_HOME=/work/cargo -e RUSTUP_HOME=/work/rustup -e XBOX_BUILD_DIR=/work/build ubuntu:24.04 bash -lc ' - set -euo pipefail - apt-get update -qq - DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \ - build-essential pkg-config libssl-dev protobuf-compiler cmake curl git ca-certificates xz-utils python3 >/dev/null +build=' export PATH=/work/cargo/bin:$PATH if ! command -v rustup >/dev/null; then version=$(python3 -c '\''import json;print(json.load(open("tools/xbox/backend.lock.json"))["rust"])'\'') @@ -16,3 +9,23 @@ docker run --rm --platform linux/arm64 -v "$repo:/src" -v "$cache:/work" -w /src fi bash tools/xbox/build.sh ' +if [[ $(uname -m) = x86_64 ]]; then + cache=${DROIDDECK_XBOX_CACHE:-"${HOME}/.cache/droiddeck-xbox-cross"} + platform=linux/amd64 + image=droiddeck-xbox-cross:$(shasum -a 256 "$repo/tools/xbox/cross.Dockerfile" | cut -c1-12) + docker image inspect "$image" >/dev/null 2>&1 \ + || docker build --platform "$platform" -t "$image" -f "$repo/tools/xbox/cross.Dockerfile" "$repo/tools/xbox" +else + cache=${DROIDDECK_XBOX_CACHE:-"${HOME}/.cache/droiddeck-xbox-build"} + platform=linux/arm64 + image=ubuntu:24.04 + build=' + apt-get update -qq + DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \ + build-essential pkg-config libssl-dev protobuf-compiler cmake curl git ca-certificates xz-utils python3 >/dev/null +'"$build" +fi +mkdir -p "$cache" +docker run --rm --platform "$platform" -v "$repo:/src" -v "$cache:/work" -w /src \ + -e CARGO_HOME=/work/cargo -e RUSTUP_HOME=/work/rustup -e XBOX_BUILD_DIR=/work/build "$image" \ + bash -lc "set -euo pipefail$build" diff --git a/tools/xbox/build.sh b/tools/xbox/build.sh index f5598aee1..99bee8c76 100755 --- a/tools/xbox/build.sh +++ b/tools/xbox/build.sh @@ -13,7 +13,7 @@ export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=${CARGO_TARGET_AARCH64_UNKN if [[ $(uname -m) = aarch64 ]]; then export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=cc; fi cd "$source_dir" cargo build --locked --release --no-default-features --target aarch64-unknown-linux-gnu -p xodus-cli -p xodus-service -if [[ $(uname -m) = aarch64 ]]; then +if [[ $(uname -m) = aarch64 || -n ${CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_RUNNER:-} ]]; then cargo test --locked --release --no-default-features --target aarch64-unknown-linux-gnu -p xodus-cli cargo test --locked --release --no-default-features --target aarch64-unknown-linux-gnu -p xodus credential_ cargo test --locked --release --no-default-features --target aarch64-unknown-linux-gnu -p msixvc streaming_ diff --git a/tools/xbox/cross.Dockerfile b/tools/xbox/cross.Dockerfile new file mode 100644 index 000000000..665dd17d7 --- /dev/null +++ b/tools/xbox/cross.Dockerfile @@ -0,0 +1,28 @@ +FROM ubuntu:24.04 + +ENV DEBIAN_FRONTEND=noninteractive + +RUN dpkg --add-architecture arm64 \ + && sed -i '/^Types:/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources \ + && printf '%s\n' \ + 'Types: deb' \ + 'URIs: http://ports.ubuntu.com/ubuntu-ports/' \ + 'Suites: noble noble-updates noble-security' \ + 'Components: main universe restricted multiverse' \ + 'Architectures: arm64' \ + 'Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg' \ + > /etc/apt/sources.list.d/ubuntu-ports.sources \ + && apt-get update -qq \ + && apt-get install -y -qq --no-install-recommends \ + build-essential pkg-config protobuf-compiler cmake curl git ca-certificates xz-utils python3 \ + gcc-aarch64-linux-gnu g++-aarch64-linux-gnu qemu-user \ + libssl-dev:arm64 libstdc++6:arm64 \ + && rm -rf /var/lib/apt/lists/* + +ENV CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc \ + CXX_aarch64_unknown_linux_gnu=aarch64-linux-gnu-g++ \ + AR_aarch64_unknown_linux_gnu=aarch64-linux-gnu-ar \ + PKG_CONFIG_ALLOW_CROSS=1 \ + PKG_CONFIG_LIBDIR_aarch64_unknown_linux_gnu=/usr/lib/aarch64-linux-gnu/pkgconfig:/usr/share/pkgconfig \ + CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc \ + CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_RUNNER=qemu-aarch64 diff --git a/tools/xbox/droiddeck-cloud-token.patch b/tools/xbox/droiddeck-cloud-token.patch new file mode 100644 index 000000000..57a0b6172 --- /dev/null +++ b/tools/xbox/droiddeck-cloud-token.patch @@ -0,0 +1,171 @@ +diff --git a/crates/xodus-cli/src/commands.rs b/crates/xodus-cli/src/commands.rs +index 5a66715..5b310e4 100644 +--- a/crates/xodus-cli/src/commands.rs ++++ b/crates/xodus-cli/src/commands.rs +@@ -6,6 +6,7 @@ pub mod license; + pub mod login; + pub mod logout; + pub mod profile; ++pub mod cloud_token; + pub mod game_activity; + pub mod achievements; + pub mod subscription; +diff --git a/crates/xodus-cli/src/commands/cloud_token.rs b/crates/xodus-cli/src/commands/cloud_token.rs +new file mode 100644 +index 0000000..fbd0ed0 +--- /dev/null ++++ b/crates/xodus-cli/src/commands/cloud_token.rs +@@ -0,0 +1,124 @@ ++//! Xbox Cloud Gaming streaming token for the stored account (DroidDeck). Same bearer ++//! RPS -> Xbox user -> XSTS exchange as the profile read, for the cloud gaming relying party, ++//! then the streaming service's user login (as xbox.com and Greenlight's xal-node do). ++//! Never provisions a device or opens login. ++use std::{process::ExitCode, time::Duration}; ++use serde_json::{Value, json}; ++use xodus::{models::{live::ExchangeUserTokenOutcome, secrets::Token, soap}, tokens::TokenManager}; ++ ++const GSSV_RELYING_PARTY: &str = "http://gssv.xboxlive.com/"; ++const OFFERINGS: [&str; 2] = ["xgpuweb", "xgpuwebf2p"]; ++ ++/// The stored account's compact Microsoft account ticket for one service target and policy. ++async fn ticket(tokens: &TokenManager, client: &reqwest::Client, target: &str, policy: soap::PolicyReference) -> Result<(String, String), &'static str> { ++ let (account, device, user) = super::session::prepare(tokens, client).await.map_err(|stage| { ++ super::session::diagnostic(stage); stage ++ })?; ++ let outcome = super::session::exchange(|| async { xodus::api::live::exchange_user_token(client, user.clone(), account.clone(), device.clone(), ++ None, Some("Silent".into()), "{d6d5a677-0872-4ab0-9442-bb792fce85c5}".into(), ++ &[(target.into(), Some(policy.clone()))]) ++ .await.map_err(|error| super::session::classify(&error)) }).await?; ++ let response = match outcome { ++ ExchangeUserTokenOutcome::Issued(soap::BodyContent::RequestSecurityTokenResponseCollection(mut collection)) if !collection.security_tokens.is_empty() => collection.security_tokens.remove(0), ++ ExchangeUserTokenOutcome::Issued(soap::BodyContent::RequestSecurityTokenResponse(response)) => *response, ++ _ => return Err("msa-response"), ++ }; ++ // Token::from panics on token types it does not know; only take the compact forms here. The cloud ++ // transfer purpose answers with a login proof token, which is what /connect takes (Greenlight ++ // sends the MSAL response's "lpt"). ++ let kind = response.token_type.clone(); ++ if kind == "urn:passport:loginprooftoken" { ++ let shape = match (&response.requested_security_token.binary_security_token, &response.requested_security_token.encrypted_data) { ++ (Some(token), _) if !token.value.is_empty() => "binary", ++ (_, Some(_)) => "encrypted", ++ _ => "empty", ++ }; ++ eprintln!("NEXUS_SESSION_DIAGNOSTIC_V1 login-proof-{shape}"); ++ return match response.requested_security_token.binary_security_token { ++ Some(token) if !token.value.is_empty() => Ok((account, token.value)), ++ _ => Err("msa-ticket"), ++ }; ++ } ++ let compact = matches!(kind.as_str(), "urn:passport:compact" | "urn:passport:delegationcompact") ++ && response.requested_security_token.binary_security_token.is_some(); ++ if !compact { ++ let slug: String = kind.chars().map(|c| if c.is_ascii_alphabetic() { c.to_ascii_lowercase() } else { '-' }).take(40).collect(); ++ eprintln!("NEXUS_SESSION_DIAGNOSTIC_V1 ticket-type-{slug}"); ++ return Err("msa-ticket"); ++ } ++ let Token::Compact(ticket) = Token::from(response) else { return Err("msa-ticket"); }; ++ Ok((account, ticket)) ++} ++ ++async fn xsts(tokens: &TokenManager, client: &reqwest::Client) -> Result<(String, String), &'static str> { ++ let (account, ticket) = ticket(tokens, client, "user.auth.xboxlive.com", soap::PolicyReference::mbi_ssl()).await?; ++ let user = xodus::api::xbox::authenticate_xbox_user(client, ticket).await.map_err(|_| "xbox-user")?; ++ let xsts = xodus::api::xbox::request_xsts_token(client, user.token, GSSV_RELYING_PARTY).await.map_err(|_| "xsts")?; ++ Ok((account, xsts.token)) ++} ++ ++/// The Microsoft account token a cloud session's /connect step takes (xbox.com asks MSAL for ++/// "service::http://Passport.NET/purpose::PURPOSE_XBOX_CLOUD_CONSOLE_TRANSFER_TOKEN"). ++async fn transfer_token(tokens: &TokenManager, client: &reqwest::Client) -> Result<String, &'static str> { ++ let policy = soap::PolicyReference { uri: "PURPOSE_XBOX_CLOUD_CONSOLE_TRANSFER_TOKEN".into(), val: String::default() }; ++ Ok(ticket(tokens, client, "http://Passport.NET/purpose", policy).await?.1) ++} ++ ++fn regions(value: &Value) -> Vec<Value> { ++ value["offeringSettings"]["regions"].as_array().map(|list| list.iter().filter_map(|region| { ++ let name = region["name"].as_str()?; ++ let base = reqwest::Url::parse(region["baseUri"].as_str()?).ok()?; ++ let host = base.host_str()?; ++ if base.scheme() != "https" || !host.ends_with(".xboxlive.com") || !base.username().is_empty() { return None; } ++ Some(json!({ "name": name, "baseUri": base.as_str().trim_end_matches('/'), "isDefault": region["isDefault"].as_bool().unwrap_or(false) })) ++ }).take(32).collect()).unwrap_or_default() ++} ++ ++async fn login(tokens: &TokenManager) -> Result<Value, String> { ++ let client = reqwest::Client::builder().redirect(reqwest::redirect::Policy::none()) ++ .timeout(super::session::REQUEST_TIMEOUT).user_agent("xodus-nexus-cloud/1").build().map_err(|_| "http-client")?; ++ let (account, xsts) = xsts(tokens, &client).await?; ++ let mut failures = Vec::new(); ++ for offering in OFFERINGS { ++ let response = client.post(format!("https://{offering}.gssv-play-prod.xboxlive.com/v2/login/user")) ++ .header("x-gssv-client", "XboxComBrowser").header("Cache-Control", "no-store, must-revalidate, no-cache") ++ .json(&json!({ "token": xsts, "offeringId": offering })).send().await.map_err(|_| "gssv-request")?; ++ let status = response.status().as_u16(); ++ let body = super::profile::bounded(response, 256 * 1024).await; ++ let Ok(body) = body else { failures.push(format!("{offering}:{status}")); continue; }; ++ let value: Value = serde_json::from_slice(&body).map_err(|_| "gssv-json")?; ++ let token = value["gsToken"].as_str().filter(|token| !token.is_empty() && token.len() <= 16384).ok_or("gs-token")?; ++ // Diagnostic lines are letters and dashes only (the app's allow-list). ++ eprintln!("NEXUS_SESSION_DIAGNOSTIC_V1 gssv-login-ok-{}", if offering == "xgpuweb" { "gamepass" } else { "free" }); ++ let transfer = transfer_token(tokens, &client).await; ++ eprintln!("NEXUS_SESSION_DIAGNOSTIC_V1 transfer-token-{}", if transfer.is_ok() { "ok" } else { "missing" }); ++ if tokens.get_user().map_err(|_| "account-changed")?.username != account { return Err("account-changed".into()); } ++ return Ok(json!({ "schema": 1, "account": account, "offering": offering, "gsToken": token, "userToken": transfer.ok(), ++ "durationInSeconds": value["durationInSeconds"].as_u64().unwrap_or(0), "market": value["market"].as_str().unwrap_or_default(), ++ "regions": regions(&value) })); ++ } ++ Err(format!("gssv-login {}", failures.join(" "))) ++} ++ ++pub async fn run(tokens: &TokenManager) -> ExitCode { ++ match tokio::time::timeout(Duration::from_secs(45), login(tokens)).await { ++ Ok(Ok(value)) => { println!("{value}"); ExitCode::SUCCESS } ++ Ok(Err(stage)) => { eprintln!("Xbox Cloud Gaming sign-in failed ({stage})."); ExitCode::FAILURE } ++ Err(_) => { eprintln!("Xbox Cloud Gaming sign-in timed out."); ExitCode::FAILURE } ++ } ++} ++ ++#[cfg(test)] ++mod tests { ++ use super::*; ++ #[test] ++ fn only_https_xboxlive_regions_are_kept() { ++ let value = json!({"offeringSettings":{"regions":[ ++ {"name":"WestEurope","baseUri":"https://weu.core.gssv-play-prod.xboxlive.com/","isDefault":true}, ++ {"name":"Evil","baseUri":"https://evil.test/"}, ++ {"name":"Plain","baseUri":"http://eus.core.gssv-play-prod.xboxlive.com"}]}}); ++ let list = regions(&value); ++ assert_eq!(list.len(), 1); ++ assert_eq!(list[0]["baseUri"], "https://weu.core.gssv-play-prod.xboxlive.com"); ++ } ++} +diff --git a/crates/xodus-cli/src/main.rs b/crates/xodus-cli/src/main.rs +index 1ae4167..4b0ac26 100644 +--- a/crates/xodus-cli/src/main.rs ++++ b/crates/xodus-cli/src/main.rs +@@ -68,6 +68,8 @@ enum SubCommand { + #[arg(long, default_value_t = false)] + json: bool, + }, ++ #[command(about = "Get an Xbox Cloud Gaming streaming token for the stored account")] ++ CloudToken, + #[command(about = "Read Xbox gamertag and gamerpic for the stored account")] + Profile { + #[arg(long, default_value_t = false)] +@@ -239,6 +241,7 @@ async fn main() -> ExitCode { + args.command, + SubCommand::Account { .. } + | SubCommand::Profile { .. } ++ | SubCommand::CloudToken + | SubCommand::GameActivity { .. } + | SubCommand::Achievements { .. } + | SubCommand::Library { .. } +@@ -304,6 +307,7 @@ async fn main() -> ExitCode { + } + }, + SubCommand::Profile { json } => commands::profile::run(&tokens, json).await, ++ SubCommand::CloudToken => commands::cloud_token::run(&tokens).await, + SubCommand::GameActivity { title_id, json } => commands::game_activity::run(&tokens, title_id, json).await, + SubCommand::Achievements { title_id, json } => commands::achievements::run(&tokens, title_id, json).await, + SubCommand::Login => commands::login::run(&client, &tokens).await, diff --git a/tools/xbox/droiddeck-credential-diagnostics.patch b/tools/xbox/droiddeck-credential-diagnostics.patch new file mode 100644 index 000000000..67576e1f5 --- /dev/null +++ b/tools/xbox/droiddeck-credential-diagnostics.patch @@ -0,0 +1,237 @@ +diff --git a/crates/xodus/src/tokens/backend/keychain.rs b/crates/xodus/src/tokens/backend/keychain.rs +index 19480da..dc8f136 100644 +--- a/crates/xodus/src/tokens/backend/keychain.rs ++++ b/crates/xodus/src/tokens/backend/keychain.rs +@@ -13,29 +13,24 @@ pub struct KeychainBackend; + // keyed loopback port the app also listens on is used for the rest of the process. + static LOOPBACK: AtomicBool = AtomicBool::new(false); + static REPORTED: AtomicBool = AtomicBool::new(false); ++// The app seals credentials with the Android Keystore, which took over 10 s on some phones. ++const REPLY_TIMEOUT: Duration = Duration::from_secs(30); + +-/// Letters and dashes only: the app passes `NEXUS_CREDENTIAL_ERROR_V1 [a-z-]{1,48}` lines to its log. +-fn reason(error: &std::io::Error) -> &'static str { +- match error.raw_os_error() { +- Some(111) => "refused", +- Some(13) => "denied", +- Some(1) => "not-permitted", +- Some(2) => "missing", +- Some(110) => "timed-out", +- Some(38) => "unsupported", +- Some(97) => "family-unsupported", +- Some(22) => "invalid", +- Some(104) => "reset", +- Some(32) => "broken-pipe", +- _ => match error.kind() { +- std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock => "timed-out", +- std::io::ErrorKind::UnexpectedEof => "closed", +- _ => "other", +- }, +- } ++use crate::tokens::store::io_reason as reason; ++ ++fn exchange<S: Read + Write>(stream: S, prefix: Option<&str>, value: &Value) -> Result<Value, TokenStoreError> { ++ let route = if prefix.is_some() { "loopback" } else { "socket" }; ++ transfer(stream, prefix, value).inspect_err(|error| { ++ // Kind Other is the host's own refusal, already reported. ++ if let TokenStoreError::Io(error) = error ++ && error.kind() != std::io::ErrorKind::Other ++ { ++ eprintln!("NEXUS_CREDENTIAL_ERROR_V1 {route}-exchange-{}", reason(error)); ++ } ++ }) + } + +-fn exchange<S: Read + Write>(mut stream: S, prefix: Option<&str>, value: &Value) -> Result<Value, TokenStoreError> { ++fn transfer<S: Read + Write>(mut stream: S, prefix: Option<&str>, value: &Value) -> Result<Value, TokenStoreError> { + if let Some(key) = prefix { + stream.write_all(key.as_bytes())?; + stream.write_all(b"\n")?; +@@ -45,6 +40,7 @@ fn exchange<S: Read + Write>(mut stream: S, prefix: Option<&str>, value: &Value) + let mut bytes = Vec::new(); + std::io::BufReader::new(stream).take(2_097_153).read_until(b'\n', &mut bytes)?; + if bytes.len() > 2_097_152 || bytes.last() != Some(&b'\n') { ++ eprintln!("NEXUS_CREDENTIAL_ERROR_V1 host-response-invalid"); + return Err(std::io::Error::other("Invalid Android credential response").into()); + } + let response: Value = serde_json::from_slice(&bytes)?; +@@ -56,28 +52,36 @@ fn exchange<S: Read + Write>(mut stream: S, prefix: Option<&str>, value: &Value) + } + + fn loopback(value: &Value) -> Result<Value, TokenStoreError> { +- let port: u16 = std::env::var("DROIDDECK_XBOX_CREDENTIAL_PORT").ok().and_then(|v| v.parse().ok()) +- .ok_or_else(|| std::io::Error::other("Android credential port is unavailable"))?; +- let key = std::env::var("DROIDDECK_XBOX_CREDENTIAL_KEY") +- .map_err(|_| std::io::Error::other("Android credential key is unavailable"))?; ++ let port: u16 = std::env::var("DROIDDECK_XBOX_CREDENTIAL_PORT").ok().and_then(|v| v.parse().ok()).ok_or_else(|| { ++ eprintln!("NEXUS_CREDENTIAL_ERROR_V1 loopback-port-missing"); ++ std::io::Error::other("Android credential port is unavailable") ++ })?; ++ let key = std::env::var("DROIDDECK_XBOX_CREDENTIAL_KEY").map_err(|_| { ++ eprintln!("NEXUS_CREDENTIAL_ERROR_V1 loopback-key-missing"); ++ std::io::Error::other("Android credential key is unavailable") ++ })?; + let address = SocketAddrV4::new(Ipv4Addr::LOCALHOST, port).into(); + let stream = TcpStream::connect_timeout(&address, Duration::from_secs(5)).inspect_err(|error| { + eprintln!("NEXUS_CREDENTIAL_ERROR_V1 loopback-{}", reason(error)); + })?; +- stream.set_read_timeout(Some(Duration::from_secs(10)))?; +- stream.set_write_timeout(Some(Duration::from_secs(10)))?; ++ stream.set_read_timeout(Some(REPLY_TIMEOUT))?; ++ stream.set_write_timeout(Some(REPLY_TIMEOUT))?; + exchange(stream, Some(&key), value) + } + + fn request(value: Value) -> Result<Value, TokenStoreError> { + if LOOPBACK.load(Ordering::Relaxed) { return loopback(&value); } +- let name = std::env::var("DROIDDECK_XBOX_CREDENTIAL_SOCKET") +- .map_err(|_| std::io::Error::other("Android credential host is unavailable"))?; +- let address = SocketAddr::from_abstract_name(name)?; ++ let name = std::env::var("DROIDDECK_XBOX_CREDENTIAL_SOCKET").map_err(|_| { ++ eprintln!("NEXUS_CREDENTIAL_ERROR_V1 socket-name-missing"); ++ std::io::Error::other("Android credential host is unavailable") ++ })?; ++ let address = SocketAddr::from_abstract_name(name).inspect_err(|_| { ++ eprintln!("NEXUS_CREDENTIAL_ERROR_V1 socket-name-invalid"); ++ })?; + match UnixStream::connect_addr(&address) { + Ok(stream) => { +- stream.set_read_timeout(Some(Duration::from_secs(10)))?; +- stream.set_write_timeout(Some(Duration::from_secs(10)))?; ++ stream.set_read_timeout(Some(REPLY_TIMEOUT))?; ++ stream.set_write_timeout(Some(REPLY_TIMEOUT))?; + exchange(stream, None, &value) + } + Err(error) => { +diff --git a/crates/xodus/src/tokens/session.rs b/crates/xodus/src/tokens/session.rs +index 38c4b2b..254c14f 100644 +--- a/crates/xodus/src/tokens/session.rs ++++ b/crates/xodus/src/tokens/session.rs +@@ -44,14 +44,30 @@ async fn renewal_lock_at( + wait: WaitDuration, + ) -> std::io::Result<std::fs::File> { + use std::os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt}; ++ fn report(stage: &'static str) -> impl Fn(&std::io::Error) { ++ move |error| eprintln!("NEXUS_CREDENTIAL_ERROR_V1 lock-{stage}-{}", super::store::io_reason(error)) ++ } ++ fn unsafe_lock(stage: &str, check: &str) -> std::io::Error { ++ eprintln!("NEXUS_CREDENTIAL_ERROR_V1 lock-{stage}-{check}"); ++ std::io::Error::other(if stage == "dir" { "Unsafe session lock directory" } else { "Unsafe session lock" }) ++ } + match std::fs::DirBuilder::new().mode(0o700).create(&root) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} +- Err(error) => return Err(error), ++ Err(error) => { ++ report("dir-create")(&error); ++ return Err(error); ++ } ++ } ++ let dir = std::fs::symlink_metadata(&root).inspect_err(report("dir-stat"))?; ++ if !dir.is_dir() { ++ return Err(unsafe_lock("dir", "type")); ++ } ++ if dir.uid() != uid { ++ return Err(unsafe_lock("dir", "owner")); + } +- let dir = std::fs::symlink_metadata(&root)?; +- if !dir.is_dir() || dir.uid() != uid || dir.mode() & 0o077 != 0 { +- return Err(std::io::Error::other("Unsafe session lock directory")); ++ if dir.mode() & 0o077 != 0 { ++ return Err(unsafe_lock("dir", "mode")); + } + let file = std::fs::OpenOptions::new() + .read(true) +@@ -60,10 +76,20 @@ async fn renewal_lock_at( + .truncate(false) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC) +- .open(root.join("device.lock"))?; +- let meta = file.metadata()?; +- if !meta.is_file() || meta.uid() != uid || meta.nlink() != 1 || meta.mode() & 0o077 != 0 { +- return Err(std::io::Error::other("Unsafe session lock")); ++ .open(root.join("device.lock")) ++ .inspect_err(report("file-open"))?; ++ let meta = file.metadata().inspect_err(report("file-stat"))?; ++ if !meta.is_file() { ++ return Err(unsafe_lock("file", "type")); ++ } ++ if meta.uid() != uid { ++ return Err(unsafe_lock("file", "owner")); ++ } ++ if meta.nlink() != 1 { ++ return Err(unsafe_lock("file", "links")); ++ } ++ if meta.mode() & 0o077 != 0 { ++ return Err(unsafe_lock("file", "mode")); + } + let deadline = tokio::time::Instant::now() + wait; + loop { +@@ -75,7 +101,10 @@ async fn renewal_lock_at( + { + tokio::time::sleep(WaitDuration::from_millis(100)).await + } +- Err(error) => return Err(error), ++ Err(error) => { ++ report("flock")(&error); ++ return Err(error); ++ } + } + } + } +diff --git a/crates/xodus/src/tokens/store.rs b/crates/xodus/src/tokens/store.rs +index 78b73d0..15cbff0 100644 +--- a/crates/xodus/src/tokens/store.rs ++++ b/crates/xodus/src/tokens/store.rs +@@ -50,3 +50,51 @@ impl TokenStoreError { + eprintln!("{self}"); + } + } ++ ++/// Letters and dashes only: DroidDeck passes `NEXUS_CREDENTIAL_ERROR_V1 [a-z-]{1,48}` lines to its ++/// log. Unlisted errno values are spelled with letters, a = 0 ... j = 9 (`os-dh` = errno 37). ++pub(crate) fn io_reason(error: &std::io::Error) -> String { ++ let known = match error.raw_os_error() { ++ Some(111) => "refused", ++ Some(13) => "denied", ++ Some(1) => "not-permitted", ++ Some(2) => "missing", ++ Some(110) => "timed-out", ++ Some(38) => "unsupported", ++ Some(97) => "family-unsupported", ++ Some(22) => "invalid", ++ Some(104) => "reset", ++ Some(32) => "broken-pipe", ++ Some(4) => "interrupted", ++ Some(11) => "would-block", ++ Some(17) => "exists", ++ Some(20) => "not-directory", ++ Some(21) => "is-directory", ++ Some(28) => "no-space", ++ Some(30) => "read-only", ++ Some(37) => "no-locks", ++ Some(40) => "symlink-loop", ++ Some(95) => "not-supported", ++ Some(code) => { ++ let letters: String = code.unsigned_abs().to_string().bytes().map(|b| (b - b'0' + b'a') as char).collect(); ++ return format!("os-{letters}"); ++ } ++ None => match error.kind() { ++ std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock => "timed-out", ++ std::io::ErrorKind::UnexpectedEof => "closed", ++ _ => "other", ++ }, ++ }; ++ known.to_string() ++} ++ ++#[cfg(test)] ++mod io_reason_tests { ++ #[test] ++ fn io_reason_is_letters_and_dashes() { ++ for (code, expected) in [(37, "no-locks"), (13, "denied"), (99, "os-jj"), (125, "os-bcf")] { ++ assert_eq!(super::io_reason(&std::io::Error::from_raw_os_error(code)), expected); ++ } ++ assert_eq!(super::io_reason(&std::io::Error::other("x")), "other"); ++ } ++} diff --git a/tools/xbox/droiddeck-credential-fallback.patch b/tools/xbox/droiddeck-credential-fallback.patch new file mode 100644 index 000000000..859afd4f6 --- /dev/null +++ b/tools/xbox/droiddeck-credential-fallback.patch @@ -0,0 +1,113 @@ +diff --git a/crates/xodus/src/tokens/backend/keychain.rs b/crates/xodus/src/tokens/backend/keychain.rs +index 1903638..19480da 100644 +--- a/crates/xodus/src/tokens/backend/keychain.rs ++++ b/crates/xodus/src/tokens/backend/keychain.rs +@@ -1,20 +1,46 @@ + use crate::tokens::store::{TokenBackend, TokenStoreError}; + use serde_json::{Value, json}; + use std::io::{BufRead, Read, Write}; ++use std::net::{Ipv4Addr, SocketAddrV4, TcpStream}; + use std::os::linux::net::SocketAddrExt; + use std::os::unix::net::{SocketAddr, UnixStream}; ++use std::sync::atomic::{AtomicBool, Ordering}; + use std::time::Duration; + + pub struct KeychainBackend; + +-fn request(value: Value) -> Result<Value, TokenStoreError> { +- let name = std::env::var("DROIDDECK_XBOX_CREDENTIAL_SOCKET") +- .map_err(|_| std::io::Error::other("Android credential host is unavailable"))?; +- let address = SocketAddr::from_abstract_name(name)?; +- let mut stream = UnixStream::connect_addr(&address)?; +- stream.set_read_timeout(Some(Duration::from_secs(10)))?; +- stream.set_write_timeout(Some(Duration::from_secs(10)))?; +- serde_json::to_writer(&mut stream, &value)?; ++// Some Android devices refuse the abstract socket from inside PRoot; after one failure the ++// keyed loopback port the app also listens on is used for the rest of the process. ++static LOOPBACK: AtomicBool = AtomicBool::new(false); ++static REPORTED: AtomicBool = AtomicBool::new(false); ++ ++/// Letters and dashes only: the app passes `NEXUS_CREDENTIAL_ERROR_V1 [a-z-]{1,48}` lines to its log. ++fn reason(error: &std::io::Error) -> &'static str { ++ match error.raw_os_error() { ++ Some(111) => "refused", ++ Some(13) => "denied", ++ Some(1) => "not-permitted", ++ Some(2) => "missing", ++ Some(110) => "timed-out", ++ Some(38) => "unsupported", ++ Some(97) => "family-unsupported", ++ Some(22) => "invalid", ++ Some(104) => "reset", ++ Some(32) => "broken-pipe", ++ _ => match error.kind() { ++ std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock => "timed-out", ++ std::io::ErrorKind::UnexpectedEof => "closed", ++ _ => "other", ++ }, ++ } ++} ++ ++fn exchange<S: Read + Write>(mut stream: S, prefix: Option<&str>, value: &Value) -> Result<Value, TokenStoreError> { ++ if let Some(key) = prefix { ++ stream.write_all(key.as_bytes())?; ++ stream.write_all(b"\n")?; ++ } ++ serde_json::to_writer(&mut stream, value)?; + stream.write_all(b"\n")?; + let mut bytes = Vec::new(); + std::io::BufReader::new(stream).take(2_097_153).read_until(b'\n', &mut bytes)?; +@@ -22,10 +48,52 @@ fn request(value: Value) -> Result<Value, TokenStoreError> { + return Err(std::io::Error::other("Invalid Android credential response").into()); + } + let response: Value = serde_json::from_slice(&bytes)?; +- if response["ok"] != true { return Err(std::io::Error::other("Android credential storage unavailable").into()); } ++ if response["ok"] != true { ++ eprintln!("NEXUS_CREDENTIAL_ERROR_V1 host-refused"); ++ return Err(std::io::Error::other("Android credential storage unavailable").into()); ++ } + Ok(response) + } + ++fn loopback(value: &Value) -> Result<Value, TokenStoreError> { ++ let port: u16 = std::env::var("DROIDDECK_XBOX_CREDENTIAL_PORT").ok().and_then(|v| v.parse().ok()) ++ .ok_or_else(|| std::io::Error::other("Android credential port is unavailable"))?; ++ let key = std::env::var("DROIDDECK_XBOX_CREDENTIAL_KEY") ++ .map_err(|_| std::io::Error::other("Android credential key is unavailable"))?; ++ let address = SocketAddrV4::new(Ipv4Addr::LOCALHOST, port).into(); ++ let stream = TcpStream::connect_timeout(&address, Duration::from_secs(5)).inspect_err(|error| { ++ eprintln!("NEXUS_CREDENTIAL_ERROR_V1 loopback-{}", reason(error)); ++ })?; ++ stream.set_read_timeout(Some(Duration::from_secs(10)))?; ++ stream.set_write_timeout(Some(Duration::from_secs(10)))?; ++ exchange(stream, Some(&key), value) ++} ++ ++fn request(value: Value) -> Result<Value, TokenStoreError> { ++ if LOOPBACK.load(Ordering::Relaxed) { return loopback(&value); } ++ let name = std::env::var("DROIDDECK_XBOX_CREDENTIAL_SOCKET") ++ .map_err(|_| std::io::Error::other("Android credential host is unavailable"))?; ++ let address = SocketAddr::from_abstract_name(name)?; ++ match UnixStream::connect_addr(&address) { ++ Ok(stream) => { ++ stream.set_read_timeout(Some(Duration::from_secs(10)))?; ++ stream.set_write_timeout(Some(Duration::from_secs(10)))?; ++ exchange(stream, None, &value) ++ } ++ Err(error) => { ++ if !REPORTED.swap(true, Ordering::Relaxed) { ++ eprintln!("NEXUS_CREDENTIAL_ERROR_V1 socket-{}", reason(&error)); ++ } ++ let result = loopback(&value); ++ if result.is_ok() { ++ LOOPBACK.store(true, Ordering::Relaxed); ++ eprintln!("NEXUS_CREDENTIAL_ERROR_V1 loopback-in-use"); ++ } ++ result ++ } ++ } ++} ++ + impl TokenBackend for KeychainBackend { + fn get(&self, key: &str) -> Result<Option<Vec<u8>>, TokenStoreError> { + let response = request(json!({"op":"get", "key":key}))?; diff --git a/tools/xbox/prepare.py b/tools/xbox/prepare.py index 30afc1b01..47564ad11 100755 --- a/tools/xbox/prepare.py +++ b/tools/xbox/prepare.py @@ -9,7 +9,8 @@ import sys recipe = Path(__file__).resolve().parent cache = Path(sys.argv[1]).resolve() lock = json.loads((recipe / 'backend.lock.json').read_text()) -local_patches = ['droiddeck-credentials.patch', 'droiddeck-launch-view.patch', 'droiddeck-library-subscriptions.patch'] +local_patches = ['droiddeck-credentials.patch', 'droiddeck-launch-view.patch', 'droiddeck-library-subscriptions.patch', + 'droiddeck-credential-fallback.patch', 'droiddeck-credential-diagnostics.patch', 'droiddeck-cloud-token.patch'] identity = hashlib.sha256((recipe / 'backend.lock.json').read_bytes() + b''.join((recipe / name).read_bytes() for name in local_patches)).hexdigest() source = cache / ('source-' + identity[:16]) if not (source / '.prepared').is_file(): diff --git a/tools/xbox/runtime.lock.json b/tools/xbox/runtime.lock.json index d8e3de855..eb3042fa4 100644 --- a/tools/xbox/runtime.lock.json +++ b/tools/xbox/runtime.lock.json @@ -12,6 +12,7 @@ "windows.web.dll": "1a510c73ca31475b163692337d3cf37fef3381078df9729662095fc92b2e4e83", "libnghttp2.dll": "2b171eba9296b207fe6016560e5381bc6e7405f3ad5ee5cd4add316358204ec0", "xgameruntime.dll": "060120e0ec6bccfbab516128315a0658b11d3e316f62a917c653daf1a1dd3e05", - "winhttp.dll": "81e1da7a731d83fbd24dbe2fa2ecb8f155bd477a9f9b6f4cc3932063cc326c27" + "winhttp.dll": "81e1da7a731d83fbd24dbe2fa2ecb8f155bd477a9f9b6f4cc3932063cc326c27", + "wintypes.dll": "7d06083e3d8b3a7e11555a91e7918f11c7d444a74755d41c8d1990db3697c895" } } diff --git a/tools/xcloud-player/NOTICE.md b/tools/xcloud-player/NOTICE.md new file mode 100644 index 000000000..851957ad2 --- /dev/null +++ b/tools/xcloud-player/NOTICE.md @@ -0,0 +1,20 @@ +# Xbox Cloud Gaming player + +`greenlight/lib` is the WebRTC client of **Greenlight** by **UnknownSKL (Jim Kroon)** +(https://github.com/unknownskl/greenlight, `packages/player/src/client/lib`, commit ef852c3, MIT — +see `greenlight/LICENSE`). DroidDeck changes: `player.ts` renders video with the `<video>` +component instead of WebGPU, `render/video.ts` sets a transparent poster, and `input/gamepad.ts` treats missing buttons/axes as unpressed (Android reports no guide button); `uuid` is replaced by `uuid.ts` (crypto.randomUUID). + +`sdp-tweaks.ts` (H.264 profile preference, bitrate cap), the CAS / unsharp-mask sharpening in +`renderer.ts` and the "stop quality drops" metadata change follow **Better xCloud** by **redphx** +(https://github.com/redphx/better-xcloud, MIT). The colour, deband, FXAA, Toon, CRT and NTSC shaders are +ported from Bannerlator's screen effects (The412Banner). `streamstats.ts` is DroidDeck's. + +`entry.ts` is DroidDeck's: it drives a cloud session through the app (`DDCloud` bridge; the +streaming and Microsoft account tokens never reach the page) and attaches the first controller. + +Build: `bash tools/xcloud-player/build.sh` (esbuild) writes +`app/src/main/assets/xcloud/player.js`; the built file is committed. + +The page, its sources and the Android side (`stores/xbox/XboxCloud*.kt`) are ported from +Bannerlator's Xbox Cloud Gaming player (https://github.com/The412Banner/Bannerlator). diff --git a/tools/xcloud-player/build.sh b/tools/xcloud-player/build.sh new file mode 100644 index 000000000..8839c5693 --- /dev/null +++ b/tools/xcloud-player/build.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Bundles the cloud player page script with esbuild 0.24.0 (ESBUILD=/path/to/esbuild, default: esbuild on PATH). +set -euo pipefail +here=$(cd "$(dirname "$0")" && pwd) +repo=$(cd "$here/../.." && pwd) +"${ESBUILD:-esbuild}" "$here/entry.ts" --bundle --format=iife --target=chrome110 --minify \ + --alias:uuid="$here/uuid.ts" --legal-comments=inline \ + --banner:js="/* DroidDeck Xbox Cloud Gaming player. Includes Greenlight's player (c) UnknownSKL (Jim Kroon), MIT. */" \ + --outfile="$repo/app/src/main/assets/xcloud/player.js" diff --git a/tools/xcloud-player/entry.ts b/tools/xcloud-player/entry.ts new file mode 100644 index 000000000..9237be3dd --- /dev/null +++ b/tools/xcloud-player/entry.ts @@ -0,0 +1,212 @@ +// DroidDeck cloud player page: runs one Xbox Cloud Gaming session. Every request to Microsoft goes +// through the app (DDCloud), which holds the tokens; this page only does WebRTC, video, sound and input. +import xCloudPlayer from './greenlight/lib/player' +import Gamepad from './greenlight/lib/input/gamepad' +import { EffectRenderer, Effects, needsRenderer } from './renderer' +import { StreamStats } from './streamstats' +import { capVideoBitrate, preferH264Profile } from './sdp-tweaks' + +interface Reply { status: number, body: string } +declare const DDCloud: { call(id: number, op: string, arg: string): void, status(text: string): void, failed(stage: string, detail: string): void, playing(): void, log(text: string): void, settings(): string } + +/** The player settings the app hands over (XboxCloudSettings.toJson); stream-start ones are applied by the app. */ +interface Settings extends Effects { + bitrateMbps: number // 0 = Microsoft's own limit + codec: string // 'default' | 'high' | 'normal' | 'low' + preventDrops: boolean + statsLevel: number // 0 off, 1 fps, 2 basic, 3 detailed, 4 full + statsLayout: string // 'horizontal' | 'vertical' + statsCorner: string // 'right' | 'left' + server: string // the region's host, for the full overlay + volume: number // 100..300 % + rumble: number // 0..100 % +} +let settings: Settings = JSON.parse(DDCloud.settings()) + +// Codec profile and bitrate cap go into our offer (Better xCloud's approach). +const nativeSetLocalDescription = RTCPeerConnection.prototype.setLocalDescription +RTCPeerConnection.prototype.setLocalDescription = function (this: RTCPeerConnection, description?: RTCLocalSessionDescriptionInit) { + if (description?.sdp) { + if (settings.codec === 'high' || settings.codec === 'normal' || settings.codec === 'low') description.sdp = preferH264Profile(description.sdp, settings.codec) + if (settings.bitrateMbps > 0) description.sdp = capVideoBitrate(description.sdp, settings.bitrateMbps * 1000) + } + return nativeSetLocalDescription.call(this, description as RTCLocalSessionDescriptionInit) +} as typeof RTCPeerConnection.prototype.setLocalDescription +;(window as any).__ddRumble = settings.rumble / 100 + +// Page errors go to the app's Xbox log (no tokens ever reach this page). +window.addEventListener('error', event => DDCloud.log(`error: ${event.message} @${event.lineno}`)) +window.addEventListener('unhandledrejection', event => DDCloud.log(`rejection: ${String(event.reason).slice(0, 200)}`)) +window.addEventListener('gamepadconnected', event => DDCloud.log(`controller: ${event.gamepad.id.slice(0, 60)} mapping=${event.gamepad.mapping || 'none'} buttons=${event.gamepad.buttons.length} axes=${event.gamepad.axes.length}`)) + +const pending = new Map<number, (reply: Reply) => void>() +let nextId = 1 +;(window as any).__ddResolve = (id: number, status: number, body: string) => { + const done = pending.get(id) + pending.delete(id) + if (done) done({ status, body }) +} + +function call(op: string, arg: unknown = ''): Promise<Reply> { + return new Promise(resolve => { + const id = nextId++ + pending.set(id, resolve) + DDCloud.call(id, op, typeof arg === 'string' ? arg : JSON.stringify(arg)) + }) +} + +const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) +const ok = (reply: Reply) => reply.status >= 200 && reply.status < 300 + +function show(text: string) { + const element = document.getElementById('status') + if (element) element.textContent = text + DDCloud.status(text) +} + +class Failure extends Error { + constructor(public stage: string, public detail: string) { super(stage) } +} + +function expect(stage: string, reply: Reply): Reply { + if (!ok(reply)) throw new Failure(stage, `${reply.status} ${reply.body.slice(0, 300)}`) + return reply +} + +function waitForCandidates(pc: RTCPeerConnection, timeout: number): Promise<void> { + return new Promise(resolve => { + if (pc.iceGatheringState === 'complete') return resolve() + const timer = setTimeout(resolve, timeout) + pc.addEventListener('icegatheringstatechange', () => { + if (pc.iceGatheringState === 'complete') { clearTimeout(timer); resolve() } + }) + }) +} + +async function run() { + show('Starting the game on Xbox Cloud Gaming…') + expect('start', await call('start')) + + let connected = false + let waited = 0 + for (;;) { + const reply = expect('state', await call('state')) + const state = JSON.parse(reply.body || '{}') + if (state.state === 'Provisioned') break + if (state.state === 'Failed') throw new Failure('provision', JSON.stringify(state.errorDetails ?? {}).slice(0, 300)) + if (state.state === 'ReadyToConnect' && !connected) { + connected = true + show('Signing in to the cloud console…') + expect('connect', await call('connect')) + } else if (state.state === 'WaitingForResources') { + show(`In the queue for a cloud console… ${Math.round(waited / 1000)} s`) + } else { + show(`Getting a cloud console ready… ${Math.round(waited / 1000)} s`) + } + await sleep(1000) + waited += 1000 + } + + show('Connecting…') + const player = new xCloudPlayer('player') + player.init() + const pc = player._peerConnection + + // "Stop quality drops": report a short decode time so the server does not lower the resolution + // for a slow decoder (Better xCloud's patchStreamMetadata). + const input = player._channels.input as any + const queueMetadata = input.queueMetadataFrame.bind(input) + input.queueMetadataFrame = (frame: any) => { + if (settings.preventDrops) { + const render = frame.frameRenderedTimeMs - frame.frameDecodedTimeMs + frame.frameDecodedTimeMs = frame.frameSubmittedTimeMs + 10 + frame.frameRenderedTimeMs = frame.frameDecodedTimeMs + render + } + queueMetadata(frame) + } + + // Volume above 100 %: the stream's sound goes through a gain node instead of the <audio> element. + let gain: GainNode | undefined + let audioStream: MediaStream | undefined + const applyVolume = () => { + if (audioStream === undefined || (gain === undefined && settings.volume === 100)) return + if (gain === undefined) { + const context = new AudioContext() + gain = context.createGain() + context.createMediaStreamSource(audioStream).connect(gain).connect(context.destination) + const element = document.querySelector('#player audio') as HTMLAudioElement | null + if (element) element.muted = true + } + gain.gain.value = settings.volume / 100 + } + pc.addEventListener('track', event => { + if (event.track.kind === 'audio') { audioStream = event.streams[0]; applyVolume() } + }) + + const stats = new StreamStats(pc) + let renderer: EffectRenderer | undefined + const applyEffects = () => { + const video = player.getVideoElement() as HTMLVideoElement | undefined + if (video === undefined) return + if (!needsRenderer(settings)) { renderer?.pause(); return } + if (renderer === undefined) { + try { renderer = new EffectRenderer(video, settings) } + catch (error) { DDCloud.log(`effects unavailable: ${String(error).slice(0, 200)}`) } + } else renderer.resume(settings) + } + // Live changes from the in-player menu. + ;(window as any).__ddApply = (json: string) => { + settings = JSON.parse(json) + ;(window as any).__ddRumble = settings.rumble / 100 + stats.show(settings.statsLevel, settings.statsLayout === 'vertical', settings.server, settings.statsCorner === 'left') + applyVolume() + applyEffects() + } + pc.addEventListener('connectionstatechange', () => { + if (pc.connectionState === 'connected') { + show(''); DDCloud.playing() + stats.show(settings.statsLevel, settings.statsLayout === 'vertical', settings.server, settings.statsCorner === 'left') + // The video element exists once its track arrived; give it a moment for its first frame. + setTimeout(applyEffects, 500) + } + else if (pc.connectionState === 'failed') DDCloud.failed('webrtc', 'connection failed') + else if (pc.connectionState === 'disconnected') show('Connection lost…') + }) + + const offer = await player.createOffer() + const sdp = expect('sdp', await call('sdp', offer.sdp ?? '')) + const answer = JSON.parse(JSON.parse(sdp.body).exchangeResponse) + if (answer.status && answer.status !== 'success') throw new Failure('sdp-answer', JSON.stringify(answer).slice(0, 300)) + await player.setRemoteSDP(answer.sdp) + + await waitForCandidates(pc, 3000) + const local = (await player.getICECandidates()).map(c => JSON.stringify({ + candidate: c.candidate, sdpMid: c.sdpMid, sdpMLineIndex: c.sdpMLineIndex, usernameFragment: c.usernameFragment, + })) + const ice = expect('ice', await call('ice', local)) + player.setRemoteIceCandidates(JSON.parse(JSON.parse(ice.body).exchangeResponse)) + + // The control channel's authorization (sent on the message handshake) announces controller 0 and + // then removes it, clearing any handler attached before; attach the controller after that. + const handshake = Date.now() + while (!player._channels.message._handshakeCompleted && Date.now() - handshake < 20_000) await sleep(100) + DDCloud.log(`handshake ${player._channels.message._handshakeCompleted ? 'done' : 'missing'} after ${Date.now() - handshake} ms`) + await sleep(300) + const pad = new Gamepad(0, { enable_keyboard: false }) + pad.attach(player) + DDCloud.log(`controllers at attach: ${Array.from(navigator.getGamepads()).filter(Boolean).length}`) + // The app's "Xbox button" (in the leave dialog) presses Nexus for a moment. + ;(window as any).__ddNexus = () => { pad.sendButtonState('Nexus', 1); setTimeout(() => pad.sendButtonState('Nexus', 0), 200) } + // The app's menu button / a controller's Guide button: Nexus follows the physical button. + ;(window as any).__ddButton = (name: string, value: number) => pad.sendButtonState(name, value) + // The in-player menu pauses controller input to the game while it is open. + ;(window as any).__ddInput = (enabled: boolean) => { if (enabled) pad.onWindowFocus(); else pad.onWindowBlur() } + + setInterval(() => { call('keepalive') }, 30_000) + ;(window as any).__ddStop = () => { try { renderer?.destroy(); stats.destroy(); pad.detach(); player.destroy() } catch (e) { /* closing anyway */ } } +} + +run().catch((error: unknown) => { + if (error instanceof Failure) DDCloud.failed(error.stage, error.detail) + else DDCloud.failed('page', String(error).slice(0, 300)) +}) diff --git a/tools/xcloud-player/greenlight/LICENSE b/tools/xcloud-player/greenlight/LICENSE new file mode 100644 index 000000000..92583d295 --- /dev/null +++ b/tools/xcloud-player/greenlight/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2019 UnknownSKL (Jim Kroon) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/tools/xcloud-player/greenlight/lib/channel/channel.ts b/tools/xcloud-player/greenlight/lib/channel/channel.ts new file mode 100644 index 000000000..897b9a92e --- /dev/null +++ b/tools/xcloud-player/greenlight/lib/channel/channel.ts @@ -0,0 +1,69 @@ +import xCloudPlayer from '../player' + +export default class Channel { + private _player:xCloudPlayer + private _dataChannel:RTCDataChannel + + constructor(player:xCloudPlayer) { + this._player = player + // console.log('creating data channel: ', this.constructor.name, this, this.getChannelConfig()) + + 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) + } + + // Channel config functions + getChannelName() { + return 'channel' + } + + getChannelConfig() { + return { } + } + + // Default channel functions + onOpen(event:Event) { + console.log(this.getChannelName(), 'Opening channel:', event) + } + + onMessage(event:MessageEvent) { + console.log(this.getChannelName(), 'Message channel:', event) + } + + onClosing(event:Event) { + console.log(this.getChannelName(), 'Closing channel:', event) + } + + onClose(event:Event) { + console.log(this.getChannelName(), 'Closed channel:', event) + } + + onError(event:Event) { + console.log(this.getChannelName(), 'Error channel:', event) + } + + send(data:any) { + if(this._dataChannel.readyState !== 'open'){ + console.log('['+this.getChannelName()+'] Tried to send data while channel is not open. readyState is:', this._dataChannel.readyState) + return + } + + if(typeof data === 'string'){ + data = (new TextEncoder).encode(data) + } + + return this._dataChannel.send(data) + } + + getPlayer(){ + return this._player + } + + // Channel destroy function + destroy() {} +} \ No newline at end of file diff --git a/tools/xcloud-player/greenlight/lib/channel/chat.ts b/tools/xcloud-player/greenlight/lib/channel/chat.ts new file mode 100644 index 000000000..b230c5abb --- /dev/null +++ b/tools/xcloud-player/greenlight/lib/channel/chat.ts @@ -0,0 +1,84 @@ +import Channel from './channel' + +export default class ChatChannel extends Channel { + + _micPermissions:'granted'|'denied'|'prompt' = 'prompt' + _micStream:MediaStream | undefined + + constructor(player:any){ + super(player) + + navigator.permissions.query({ name: 'microphone' }).then((permissionStatus) => { + this._micPermissions = permissionStatus.state + + permissionStatus.onchange = () => { + this._micPermissions = permissionStatus.state + } + }) + } + + getChannelName() { + return 'chat' + } + + getChannelConfig() { + return { + ordered: true, + protocol: 'chatV1', + } + } + + // startMicrophone() { + // if(this._micPermissions === 'denied'){ + // alert('Microphone permissions are denied. Please enable them in your browser settings.') + // return + // } + // if(this._micStream !== undefined){ + // console.log('Microphone already started. You need to stop it first by calling stopMicrophone()') + // return + // } + + // navigator.mediaDevices.getUserMedia({ audio: { + // channelCount: 1, + // sampleRate: 24e3, + // } }).then((stream) => { + // if(this._micStream !== undefined){ + // return + // } + + // this._micStream = stream + + // // Add to SDP + // this.getPlayer()._peerConnection.addTrack(stream.getAudioTracks()[0], stream) + // this.getPlayer().createOffer().then((offer) => { + // if(this.getPlayer()._sdpHandler){ + // this.getPlayer()._sdpHandler(offer) + // } else { + // console.log('No SDP handler set. Set an SDP Handler via player.setChatSdpHandler()') + // } + // }) + // }).catch((err) => { + // alert(`Error connecting to microphone: ${err}`) + // }) + // } + + // stopMicrophone() { + // if(this._micStream === undefined) {return} + + // this._micStream.getTracks().forEach((track) => { + // track.stop() + // }) + + // this.getPlayer()._peerConnection.getSenders().forEach((sender) => { + // if(sender.track && sender.track.kind === 'audio'){ + // this.getPlayer()._peerConnection.removeTrack(sender) + // } + // }) + + // this._micStream = undefined + // } + + destroy() { + // console.log('DebugChannel destroy() called') + } +} \ No newline at end of file diff --git a/tools/xcloud-player/greenlight/lib/channel/control.ts b/tools/xcloud-player/greenlight/lib/channel/control.ts new file mode 100644 index 000000000..7e409aca7 --- /dev/null +++ b/tools/xcloud-player/greenlight/lib/channel/control.ts @@ -0,0 +1,108 @@ +import Gamepad from '../input/gamepad' +import MouseKeyboard from '../input/mousekeyboard' +import Touch from '../input/touch' +import Channel from './channel' + +interface GamepadHandlers { + 0: undefined|Gamepad|MouseKeyboard|Touch; + 1: undefined|Gamepad|MouseKeyboard|Touch; + 2: undefined|Gamepad|MouseKeyboard|Touch; + 3: undefined|Gamepad|MouseKeyboard|Touch; +} + +export default class ControlChannel extends Channel { + _keyframeInterval + _gamepadHandlers:GamepadHandlers = { + 0: undefined, + 1: undefined, + 2: undefined, + 3: undefined, + } + + getChannelName() { + return 'control' + } + + getChannelConfig() { + return { + protocol: 'controlV1', + ordered: true, + } + } + + destroy() { + // console.log('DebugChannel destroy() called') + clearInterval(this._keyframeInterval) + + this._gamepadHandlers[0]?.detach() + this._gamepadHandlers[1]?.detach() + this._gamepadHandlers[2]?.detach() + this._gamepadHandlers[3]?.detach() + } + + sendAuthorization(){ + const authRequest = JSON.stringify({ + 'message':'authorizationRequest', + 'accessKey':'4BDB3609-C1F1-4195-9B37-FEFF45DA8B8E', + }) + + this.send(authRequest) + + this.sendGamepadState(0, true) + this.sendGamepadState(0, false) + + // if(this.getPlayer()._config.keyframe_interval > 0) { + // this._keyframeInterval = setInterval(() => { + // this.requestKeyframeRequest(true) + // }, this.getPlayer()._config.keyframe_interval*1000) + // } + } + + sendGamepadState(gamepadIndex, wasAdded = true, handler:undefined|Gamepad|MouseKeyboard|Touch = undefined) { + + if(wasAdded === true){ + if(handler !== undefined){ + if(this._gamepadHandlers[gamepadIndex] !== undefined){ + this._gamepadHandlers[gamepadIndex]?.detach() + } + this._gamepadHandlers[gamepadIndex] = handler + } + } else { + this._gamepadHandlers[gamepadIndex] = undefined + } + + const gamepadRequest = JSON.stringify({ + 'message': 'gamepadChanged', + 'gamepadIndex': gamepadIndex, + 'wasAdded': wasAdded, + }) + this.send(gamepadRequest) + } + + requestKeyframeRequest(ifrRequested = false) { + // console.log('xCloudPlayer Channel/Control.ts - ['+this._channelName+'] User requested Video KeyFrame') + const keyframeRequest = JSON.stringify({ + message: 'videoKeyframeRequested', + ifrRequested: ifrRequested, + }) + + this.send(keyframeRequest) + } + + getGamepadHandlers(){ + return this._gamepadHandlers + } + + getGamepadHandler(index):Gamepad|undefined{ + for(const gamepad in this.getPlayer()._channels.control.getGamepadHandlers()){ + if( + this.getPlayer()._channels.control.getGamepadHandlers()[gamepad] instanceof Gamepad && + (this.getPlayer()._channels.control.getGamepadHandlers()[gamepad] as Gamepad).getPhysicalGamepadId() === index + ) { + return this.getPlayer()._channels.control.getGamepadHandlers()[gamepad] + } + } + + return undefined + } +} \ No newline at end of file diff --git a/tools/xcloud-player/greenlight/lib/channel/debug.ts b/tools/xcloud-player/greenlight/lib/channel/debug.ts new file mode 100644 index 000000000..e8d89938c --- /dev/null +++ b/tools/xcloud-player/greenlight/lib/channel/debug.ts @@ -0,0 +1,15 @@ +import Channel from './channel' + +export default class DebugChannel extends Channel { + getChannelName() { + return 'debug' + } + + getChannelConfig() { + return { } + } + + destroy() { + // console.log('DebugChannel destroy() called') + } +} \ No newline at end of file diff --git a/tools/xcloud-player/greenlight/lib/channel/input.ts b/tools/xcloud-player/greenlight/lib/channel/input.ts new file mode 100644 index 000000000..c24e472b9 --- /dev/null +++ b/tools/xcloud-player/greenlight/lib/channel/input.ts @@ -0,0 +1,142 @@ +import Channel from './channel' +import InputPacket, { MetadataFrame, ReportTypes, InputFrame as GamepadFrame, MouseFrame, KeyboardFrame, PointerFrame } from './input/packet' +import InputQueue from './input/queue' + +import Gamepad from '../input/gamepad' + +export interface VibrationFrame { + gamepadIndex: number; + leftMotorPercent: number; + rightMotorPercent: number; + leftTriggerMotorPercent: number; + rightTriggerMotorPercent: number; + durationMs: number; + delayMs: number; + repeat: number; +} + +export default class InputChannel extends Channel { + private _serverVideoWidth = 0 + private _serverVideoHeight = 0 + + private _inputQueue = new InputQueue(this.getPlayer()) + + getChannelName() { + return 'input' + } + + getChannelConfig() { + return { + ordered: true, + protocol: '1.0', + } + } + + getServerVideoWidth(){ + return this._serverVideoWidth + } + + getServerVideoHeight(){ + return this._serverVideoHeight + } + + start(){ + const Packet = new InputPacket(0) + Packet.setMetadata(navigator.maxTouchPoints > 1 ? navigator.maxTouchPoints : 1) + this.send(Packet.toBuffer()) + + console.log('[InputChannel] Sent metadata:', Packet, Packet.toBuffer()) + + setTimeout(this.gamepadStateLoop.bind(this), 16) + } + + onMessage(event: MessageEvent<any>) { + const dataView = new DataView(event.data) + const reportType = dataView.getUint8(0) + + console.log('[InputChannel] received:', dataView) + + if(reportType === ReportTypes.Vibration){ + console.log('Received a vibration report') + dataView.getUint8(2) // rumbleType: 0 = FourMotorRumble + const gamepadIndex = dataView.getUint8(3) + + const gamepad = this.getPlayer()._channels.control.getGamepadHandler(gamepadIndex) + + if(gamepad !== undefined){ + const leftMotorPercent = dataView.getUint8(4) / 100 + const rightMotorPercent = dataView.getUint8(5) / 100 + const leftTriggerMotorPercent = dataView.getUint8(6) / 100 + const rightTriggerMotorPercent = dataView.getUint8(7) / 100 + const durationMs = dataView.getUint16(8, true) + const delayMs = dataView.getUint16(10, true) + const repeat = dataView.getUint8(12) + + const rumble:VibrationFrame = { + gamepadIndex, + leftMotorPercent, + rightMotorPercent, + leftTriggerMotorPercent, + rightTriggerMotorPercent, + durationMs, + delayMs, + repeat, + } + + gamepad.handleVibration(rumble) + } else { + console.log('[InputChannel] Received a vibration report but no gamepad handler is available') + } + + } else if(reportType === ReportTypes.ServerMetadata){ + console.log('[InputChannel] Received server video dimensions:', dataView.getUint32(2, true), dataView.getUint32(6, true)) + this._serverVideoHeight = dataView.getUint32(2, true) + this._serverVideoWidth = dataView.getUint32(6, true) + } + + } + + gamepadStateLoop(){ + const gamepadHandlers = this.getPlayer()._channels.control.getGamepadHandlers() + // console.log('[InputChannel] gamepadStateLoop() called', gamepadHandlers) + const gamepadFrames:Array<GamepadFrame> = [] + for(const gamepad in gamepadHandlers){ + if(gamepadHandlers[gamepad] instanceof Gamepad){ + const frame = (gamepadHandlers[gamepad] as Gamepad).getGamepadState() + if(frame !== undefined){ + gamepadFrames.push(frame) + } + } + } + + if(gamepadFrames.length > 0){ + this.queueGamepadFrames(gamepadFrames) + } + + setTimeout(this.gamepadStateLoop.bind(this), 16) + } + + queueMetadataFrame(data:MetadataFrame){ + return this._inputQueue.queueMetadataFrame(data) + } + + queueGamepadFrames(frames:Array<GamepadFrame>, forceSend = false){ + this._inputQueue.queueGamepadFrames(frames, forceSend) + } + + queueMouseFrame(data:MouseFrame){ + this._inputQueue.queueMouseFrame(data) + } + + queueKeyboardFrame(data:KeyboardFrame){ + this._inputQueue.queueKeyboardFrame(data) + } + + queuePointerFrame(data:PointerFrame){ + this._inputQueue.queuePointerFrame(data) + } + + destroy() { + // console.log('DebugChannel destroy() called') + } +} \ No newline at end of file diff --git a/tools/xcloud-player/greenlight/lib/channel/input/packet.ts b/tools/xcloud-player/greenlight/lib/channel/input/packet.ts new file mode 100644 index 000000000..6b8562db3 --- /dev/null +++ b/tools/xcloud-player/greenlight/lib/channel/input/packet.ts @@ -0,0 +1,404 @@ +// import { InputFrame, PointerFrame, MouseFrame, KeyboardFrame } from '../Input' + +export interface InputFrame { + GamepadIndex: number; + Nexus: number; + Menu: number; + View: number; + A: number; + B: number; + X: number; + Y: number; + DPadUp: number; + DPadDown: number; + DPadLeft: number; + DPadRight: number; + LeftShoulder: number; + RightShoulder: number; + LeftThumb: number; + RightThumb: number; + + LeftThumbXAxis: number; + LeftThumbYAxis: number; + RightThumbXAxis: number; + RightThumbYAxis: number; + LeftTrigger: number; + RightTrigger: number; +} + +export interface PointerFrame { + events: Array<any>; +} + +export interface MouseFrame { + X: number; + Y: number; + WheelX: number; + WheelY: number; + Buttons: number; + Relative: number; +} + +export interface KeyboardFrame { + pressed: boolean; + keyCode: number; + key: string; +} + +export enum ReportTypes { + None = 0, + Metadata = 1, + Gamepad = 2, + Pointer = 4, + ClientMetadata = 8, + ServerMetadata = 16, + Mouse = 32, + Keyboard = 64, + Vibration = 128, + Sensor = 256, + UnreliableInputReport = 512, + UnreliableInputAck = 1024, +} + +export interface MetadataFrame { + serverDataKey: number; + firstFramePacketArrivalTimeMs: number; + frameSubmittedTimeMs: number; + frameDecodedTimeMs: number; + frameRenderedTimeMs: number; +} + +export interface StreamConfig { + width: number; + height: number; +} + +export default class InputPacket { + + _reportType = ReportTypes.None + _totalSize = -1 + _sequence = -1 + + _metadataFrames:Array<MetadataFrame> = [] + _gamepadFrames:Array<InputFrame> = [] + _pointerFrames:Array<PointerFrame> = [] + _mouseFrames:Array<MouseFrame> = [] + _keyboardFrames:Array<KeyboardFrame> = [] + + _maxTouchpoints = 0 + _serverVideoWidth = 0 + _serverVideoHeight = 0 + + constructor(sequence:number){ + this._sequence = sequence + } + + setStreamConfig(options:StreamConfig){ + this._serverVideoWidth = options.width + this._serverVideoHeight = options.height + } + + setMetadata(maxTouchpoints = 1){ + this._reportType = ReportTypes.ClientMetadata + this._totalSize = 15 + this._maxTouchpoints = maxTouchpoints + } + + setData(metadataQueue:Array<any>, gamepadQueue:Array<InputFrame>, pointerQueue:Array<PointerFrame>, mouseQueue:Array<MouseFrame>, keyboardQueue:Array<KeyboardFrame>){ + let size = 14 + + if(metadataQueue.length > 0){ + this._reportType |= ReportTypes.Metadata + size = size+this._calculateMetadataSize(metadataQueue) + this._metadataFrames = metadataQueue + } + + if(gamepadQueue.length > 0){ + this._reportType |= ReportTypes.Gamepad + size = size+this._calculateGamepadSize(gamepadQueue) + this._gamepadFrames = gamepadQueue + } + + if(pointerQueue.length > 0){ + this._reportType |= ReportTypes.Pointer + size = size+this._calculatePointerSize(pointerQueue) + this._pointerFrames = pointerQueue + } + + if(mouseQueue.length > 0){ + this._reportType |= ReportTypes.Mouse + size = size+this._calculateMouseSize(mouseQueue) + this._mouseFrames = mouseQueue + } + + if(keyboardQueue.length > 0){ + this._reportType |= ReportTypes.Keyboard + size = size+this._calculateKeyboardSize(keyboardQueue) + this._keyboardFrames = keyboardQueue + } + + this._totalSize = size + } + + _calculateMetadataSize(frames:Array<MetadataFrame>){ + return 1 + ((7 * 4) * frames.length) + } + + _calculateGamepadSize(frames:Array<InputFrame>){ + return 1 + (23 * frames.length) + } + + _calculatePointerSize(frames:Array<PointerFrame>){ + let pointerSize = 1 + for(const frame in frames){ + pointerSize = pointerSize + 1 + (frames[frame].events.length * 20) + } + + return pointerSize + } + + _calculateMouseSize(frames:Array<MouseFrame>){ + return 1 + (18 * frames.length) + } + + _calculateKeyboardSize(frames:Array<KeyboardFrame>){ + return 1 + (3 * frames.length) + } + + + + _writeMetadataData(packet:DataView, offset:number, frames:Array<MetadataFrame>){ + packet.setUint8(offset, frames.length) + offset++ + + if(frames.length >= 30){ + console.warn('metadataQueue is bigger then 30. This might impact reliability!') + } + + for(let i = 0; i < frames.length; i++) { + const frame = frames[i] + + const firstFramePacketArrivalTimeMs = frame.firstFramePacketArrivalTimeMs + const frameSubmittedTimeMs = frame.frameSubmittedTimeMs + const frameDecodedTimeMs = frame.frameDecodedTimeMs + const frameRenderedTimeMs = frame.frameRenderedTimeMs + const framePacketTime = performance.now() + const frameDateNow = performance.now() + + packet.setUint32(offset, frame.serverDataKey, true) + packet.setUint32(offset+4, firstFramePacketArrivalTimeMs, true) + packet.setUint32(offset+8, frameSubmittedTimeMs, true) + packet.setUint32(offset+12, frameDecodedTimeMs, true) + packet.setUint32(offset+16, frameRenderedTimeMs, true) + packet.setUint32(offset+20, framePacketTime, true) + packet.setUint32(offset+24, frameDateNow, true) + + offset += 28 + } + + return offset + } + + _writeGamepadData(packet:DataView, offset:number, frames:Array<InputFrame>){ + packet.setUint8(offset, frames.length) + offset++ + + if(frames.length >= 30){ + console.warn('gamepadQueue is bigger then 30. This might impact reliability!') + } + + for(let i = 0; i < frames.length; i++) { + const input:InputFrame = frames[i] + + packet.setUint8(offset, input.GamepadIndex) + offset++ + + let buttonMask = 0 + if(input.Nexus > 0){ buttonMask |= 2 } + if(input.Menu > 0){ buttonMask |= 4 } + if(input.View > 0){ buttonMask |= 8 } + if(input.A > 0){ buttonMask |= 16 } + if(input.B > 0){ buttonMask |= 32 } + if(input.X > 0){ buttonMask |= 64 } + if(input.Y > 0){ buttonMask |= 128 } + if(input.DPadUp > 0){ buttonMask |= 256 } + if(input.DPadDown > 0){ buttonMask |= 512 } + if(input.DPadLeft > 0){ buttonMask |= 1024 } + if(input.DPadRight > 0){ buttonMask |= 2048 } + if(input.LeftShoulder > 0){ buttonMask |= 4096 } + if(input.RightShoulder > 0){ buttonMask |= 8192 } + if(input.LeftThumb > 0){ buttonMask |= 16384 } + if(input.RightThumb > 0){ buttonMask |= 32768 } + + packet.setUint16(offset, buttonMask, true) + packet.setInt16(offset+2, this._normalizeAxisValue(input.LeftThumbXAxis), true) // LeftThumbXAxis + packet.setInt16(offset+4, this._normalizeAxisValue(-input.LeftThumbYAxis), true) // LeftThumbYAxis + packet.setInt16(offset+6, this._normalizeAxisValue(input.RightThumbXAxis), true) // RightThumbXAxis + packet.setInt16(offset+8, this._normalizeAxisValue(-input.RightThumbYAxis), true) // RightThumbYAxis + packet.setUint16(offset+10, this._normalizeTriggerValue(input.LeftTrigger), true) // LeftTrigger + packet.setUint16(offset+12, this._normalizeTriggerValue(input.RightTrigger), true) // RightTrigger + + packet.setUint32(offset+14, 1, true) // PhysicalPhysicality + packet.setUint32(offset+18, 1, false) // VirtualPhysicality + offset += 22 + } + + return offset + } + + _writePointerData(packet:DataView, offset:number, frames:Array<PointerFrame>){ + packet.setUint8(offset, 1) + // packet.setUint8(offset, frames.length) + offset++ + + if(frames.length >= 2){ + console.warn('pointerQueue is bigger then 1. Only one event will be sent.') + } + + // for (; frames.length > 0;) { + // this._inputFps.count() + const frame = frames[0] + if(frame !== undefined){ + packet.setUint8(offset, frame.events.length) + offset++ + + const screenWidth = this._serverVideoWidth + const screenHeight = this._serverVideoHeight + + for(let i = 0; i < frame.events.length; i++){ + const event = frame.events[i] + const rect = event.target.getBoundingClientRect() + + let e = 0.06575749909301447 * (screenHeight / 1) + let n = 0.06575749909301447 * (screenWidth / 1) + + // @TODO: Check if this is correct + e =1 + n =1 + + if(event.type === 'pointerup'){ + e = 0 + n = 0 + } + + packet.setUint16(offset, e, true) + packet.setUint16(offset+2, n, true) + packet.setUint8(offset+4, 255*event.pressure) + packet.setUint16(offset+5, event.twist, true) + packet.setUint32(offset+7, 0, true) + let o = (event.x - rect.left) * (screenWidth / rect.width) + , l = (event.y - rect.top) * (screenHeight / rect.height) + + if(event.type === 'pointerup'){ + // Reset x and y to 0 on pointerup + o = 0 + l = 0 + } + + packet.setUint32(offset+11, o, true) + packet.setUint32(offset+15, l, true) + packet.setUint8(offset+19, (event.type === 'pointerdown') ? 1 : + (event.type === 'pointerup') ? 2 : (event.type === 'pointermove') ? 3 : 0) + + offset = offset+20 + } + } + + return offset + } + + _writeMouseData(packet:DataView, offset:number, frames:Array<MouseFrame>){ + packet.setUint8(offset, frames.length) + offset++ + + if(frames.length >= 30){ + console.warn('mouseQueue is bigger then 30. This might impact reliability!') + } + + for(let i = 0; i < frames.length; i++) { + const input:MouseFrame = frames[i] + + packet.setUint32(offset, input.X, true) + packet.setUint32(offset+4, input.Y, true) + packet.setUint32(offset+8, input.WheelX, true) + packet.setUint32(offset+12, input.WheelY, true) + + packet.setUint8(offset+16, input.Buttons) + packet.setUint8(offset+17, input.Relative) + offset += 18 + } + + return offset + } + + _writeKeyboardData(packet:DataView, offset:number, frames:Array<KeyboardFrame>){ + packet.setUint8(offset, frames.length) + offset++ + + if(frames.length >= 30){ + console.warn('keyboardQueue is bigger then 30. This might impact reliability!') + } + + for(let i = 0; i < frames.length; i++) { + const input:KeyboardFrame = frames[i] + + packet.setUint8(offset, 2) // 1 = Known, 2 = VKey, 3 = AppCommand, 0 = Unknown + packet.setUint8(offset+1, input.pressed ? 1 : 0) + packet.setUint8(offset+2, input.keyCode) + // packet.setUint8(offset+3, 1) + // packet.setUint8(offset+4, input.keyCode) + + offset += 3 + } + + return offset + } + + toBuffer(){ + const metadataAlloc = new Uint8Array(this._totalSize) + const packet = new DataView(metadataAlloc.buffer) + + packet.setUint16(0, this._reportType, true) + packet.setUint32(2, this._sequence, true) + packet.setFloat64(6, performance.now(), true) + + let offset = 14 + + if(this._metadataFrames.length > 0) {offset = this._writeMetadataData(packet, offset, this._metadataFrames)} + if(this._gamepadFrames.length > 0) {offset = this._writeGamepadData(packet, offset, this._gamepadFrames)} + if(this._pointerFrames.length > 0) {offset = this._writePointerData(packet, offset, this._pointerFrames)} + if(this._mouseFrames.length > 0) {offset = this._writeMouseData(packet, offset, this._mouseFrames)} + if(this._keyboardFrames.length > 0) {offset = this._writeKeyboardData(packet, offset, this._keyboardFrames)} + + if(this._reportType === ReportTypes.ClientMetadata){ + packet.setUint8(offset, this._maxTouchpoints) + offset++ + } + + if(offset !== offset){ + throw new Error('Packet length mismatch. Something is wrong!') + } + + return packet + } + + _normalizeTriggerValue(value: number): number { + if (value <= 0 || Number.isNaN(value)) { + return 0 + } + + if (value >= 1) { + return 65535 + } + + // Fast float -> int conversion in range [0, 65535] + return (value * 65535) | 0 + } + + _normalizeAxisValue(value: number) { + const max = 32767 + const min = -32767 + const scaled = Math.round(value * max) + return scaled > max ? max : scaled < min ? min : scaled + } +} \ No newline at end of file diff --git a/tools/xcloud-player/greenlight/lib/channel/input/queue.ts b/tools/xcloud-player/greenlight/lib/channel/input/queue.ts new file mode 100644 index 000000000..0aa4dadeb --- /dev/null +++ b/tools/xcloud-player/greenlight/lib/channel/input/queue.ts @@ -0,0 +1,171 @@ +import xCloudPlayer from '../../player' +import InputPacket, { InputFrame as GamepadFrame, KeyboardFrame, MetadataFrame, MouseFrame, PointerFrame } from './packet' + +export default class InputQueue { + private _player:xCloudPlayer + + private _inputSequenceNum = 0 + + private _metadataQueue:Array<MetadataFrame> = [] + private _gamepadQueue:Array<GamepadFrame> = [] + private _pointerQueue:Array<PointerFrame> = [] + private _mouseQueue:Array<MouseFrame> = [] + private _keyboardQueue:Array<KeyboardFrame> = [] + + // Axis change detection and heartbeat tracking + private _gamepadHeartbeatTimers = new Map<number, number>() + private _lastGamepadState = new Map<number, GamepadFrame>() + private _axisChangeThreshold = 0.08 + + constructor(player:xCloudPlayer) { + this._player = player + } + + private _hasQueuedData() { + return this._metadataQueue.length > 0 || + this._gamepadQueue.length > 0 || + this._pointerQueue.length > 0 || + this._mouseQueue.length > 0 || + this._keyboardQueue.length > 0 + } + + private _upsertGamepadFrame(data:GamepadFrame) { + for(let i = 0; i < this._gamepadQueue.length; i++){ + if(this._gamepadQueue[i].GamepadIndex === data.GamepadIndex){ + this._gamepadQueue[i] = data + return + } + } + + this._gamepadQueue.push(data) + } + + private _hasSignificantAxisChange(current:GamepadFrame, last:GamepadFrame | undefined):boolean { + if(!last) return true // First frame always send + + const stickNames = ['LeftThumbXAxis', 'LeftThumbYAxis', 'RightThumbXAxis', 'RightThumbYAxis'] as const + const triggerNames = ['LeftTrigger', 'RightTrigger'] as const + + // For analog sticks: only skip if BOTH change is small AND value is near center + for(const stick of stickNames){ + const delta = Math.abs(current[stick] - last[stick]) + const value = Math.abs(current[stick]) + if(delta > this._axisChangeThreshold || value > 0.15){ + return true + } + } + + // For triggers: always send if they changed (higher sensitivity needed) + for(const trigger of triggerNames){ + if(Math.abs(current[trigger] - last[trigger]) > 0.01){ + return true + } + } + + return false + } + + private _isHeartbeatDue(gamepadIndex:number):boolean { + const lastHeartbeat = this._gamepadHeartbeatTimers.get(gamepadIndex) ?? 0 + return performance.now() - lastHeartbeat > 33 // Send at least every 33ms + } + + queueMetadataFrame(data:MetadataFrame) { + this._metadataQueue.push(data) + this.checkQueueAndSend() + } + + queueGamepadFrame(data:GamepadFrame) { + const lastFrame = this._lastGamepadState.get(data.GamepadIndex) + const hasChange = this._hasSignificantAxisChange(data, lastFrame) + const isDue = this._isHeartbeatDue(data.GamepadIndex) + + if(hasChange || isDue){ + this._upsertGamepadFrame(data) + this._lastGamepadState.set(data.GamepadIndex, data) + this._gamepadHeartbeatTimers.set(data.GamepadIndex, performance.now()) + this.checkQueueAndSend() + } + } + + queueGamepadFrames(frames:Array<GamepadFrame>, forceSend = false) { + for(const frame of frames){ + this._upsertGamepadFrame(frame) + } + this.checkQueueAndSend(forceSend) + } + + queueMouseFrame(data:MouseFrame) { + this._mouseQueue.push(data) + this.checkQueueAndSend() + } + + queueKeyboardFrame(data:KeyboardFrame) { + this._keyboardQueue.push(data) + this.checkQueueAndSend() + } + + queuePointerFrame(data:PointerFrame) { + if(this._pointerQueue.length > 0){ + this._pointerQueue[0].events = this._pointerQueue[0].events.concat(data.events) + } else { + this._pointerQueue.push(data) + } + this.checkQueueAndSend() + } + + checkQueueAndSend(forceSend = false) { + if(forceSend === true){ + if(this._hasQueuedData()){ + this.sendQueue() + } + return + } + + // Prioritize low-latency gamepad delivery. + if(this._gamepadQueue.length > 0){ + this.sendQueue() + return + } + + // Non-metadata input should also not wait. + if(this._mouseQueue.length > 0 || this._keyboardQueue.length > 0 || this._pointerQueue.length > 0){ + this.sendQueue() + return + } + + // Metadata-only can batch slightly. + if(this._metadataQueue.length > 5){ + this.sendQueue() + } + } + + sendQueue() { + // console.log('Sending queues:', this._metadataQueue.length, this._gamepadQueue.length, this._pointerQueue.length, this._mouseQueue.length, this._keyboardQueue.length) + + const packet = new InputPacket(this.getSequenceNum()) + if(this._pointerQueue.length > 0){ + packet.setStreamConfig({ + width: this._player._channels.input.getServerVideoWidth(), + height: this._player._channels.input.getServerVideoHeight(), + }) + } + packet.setData(this._metadataQueue, this._gamepadQueue, this._pointerQueue, this._mouseQueue, this._keyboardQueue) + this._player._channels.input.send(packet.toBuffer()) + + this.clearQueues() + } + + clearQueues() { + this._metadataQueue = [] + this._gamepadQueue = [] + this._pointerQueue = [] + this._mouseQueue = [] + this._keyboardQueue = [] + } + + getSequenceNum() { + this._inputSequenceNum++ + return this._inputSequenceNum + } +} \ No newline at end of file diff --git a/tools/xcloud-player/greenlight/lib/channel/message.ts b/tools/xcloud-player/greenlight/lib/channel/message.ts new file mode 100644 index 000000000..de94758c2 --- /dev/null +++ b/tools/xcloud-player/greenlight/lib/channel/message.ts @@ -0,0 +1,120 @@ +import { v4 as uuidv4 } from 'uuid' + +import Channel from './channel' +import MessageHandler from './message/handler' + +export default class MessageChannel extends Channel { + _handshakeCompleted = false + + getChannelName() { + return 'message' + } + + getChannelConfig() { + return { + protocol: 'messageV1', + ordered: true, + } + } + + onOpen() { + console.log('[MessageChannel] Sending handshake...') + const handshake = JSON.stringify({ + 'type':'Handshake', + 'version':'messageV1', + 'id':'be0bfc6d-1e83-4c8a-90ed-fa8601c5a179', + 'cv':'0', + }) + this.send(handshake) + } + + onMessage(event: MessageEvent<any>) { + const data = JSON.parse(event.data) + // console.log('[MessageChannel] received:', data) + + switch(data.type){ + case 'HandshakeAck': + console.log('[MessageChannel] Handshake completed') + this._handshakeCompleted = true + // Start control & input channels + this.getPlayer()._channels.control.sendAuthorization() + this.getPlayer()._channels.input.start() + this.sendConfig() + break + + case 'TransactionStart': + case 'Message': + new MessageHandler(this, data) + break + + default: + console.log('[MessageChannel] Unhandled message type:', data.type, data) + } + + } + + sendConfig(){ + const systemUis = [] + const systemVersion = [0, 2, 0] + const uiConfig = JSON.stringify(this.generateMessage('/streaming/systemUi/configuration', { + 'version': systemVersion, + 'systemUis': systemUis, // Xbox Windows app has [33], xCloud has [10,19,31,27,32,-41] + + // 8 = unknown + // 10 = ShowVirtualKeyboard + // 13 = unknown + // 15 = unknown + // 19 = ShowMessageDialog + // 31 = ShowApplication + // 27 = ShowPurchase + // 32 = ShowTimerExtensions + // 33 = Xbox windows app, disables the nexus menu on xCloud (Alt nexus menu?) + // -44 = unknown + // 40 = unknown + // 41 = unknown + // -43 = unknown + + // Possible options: Keyboard, PurchaseModal + })) + this.send(uiConfig) + + const clientConfig = JSON.stringify(this.generateMessage('/streaming/properties/clientappinstallidchanged', { 'clientAppInstallId': 'c97d7ee0-73b2-4239-bf1d-9d805a338429' })) + this.send(clientConfig) + + const orientationConfig = JSON.stringify(this.generateMessage('/streaming/characteristics/orientationchanged', { 'orientation': 0 })) + this.send(orientationConfig) + + const touchConfig = JSON.stringify(this.generateMessage('/streaming/characteristics/touchinputenabledchanged', { 'touchInputEnabled': false })) + this.send(touchConfig) + + const deviceConfig = JSON.stringify(this.generateMessage('/streaming/characteristics/clientdevicecapabilities', {})) + this.send(deviceConfig) + + const dimensionsConfig = JSON.stringify(this.generateMessage('/streaming/characteristics/dimensionschanged', { + 'horizontal': 1920, + 'vertical': 1080, + 'preferredWidth': 1920, + 'preferredHeight': 1080, + 'safeAreaLeft': 0, + 'safeAreaTop': 0, + 'safeAreaRight': 1920, + 'safeAreaBottom': 1080, + 'supportsCustomResolution':true, + })) + this.send(dimensionsConfig) + } + + generateMessage(path, data) { + return { + 'type': 'Message', + 'content': JSON.stringify(data), + 'id': uuidv4(), + 'target': path, + 'cv': '', + } + } + + destroy() { + // console.log('DebugChannel destroy() called') + } +} \ No newline at end of file diff --git a/tools/xcloud-player/greenlight/lib/channel/message/handler.ts b/tools/xcloud-player/greenlight/lib/channel/message/handler.ts new file mode 100644 index 000000000..18c6cfa74 --- /dev/null +++ b/tools/xcloud-player/greenlight/lib/channel/message/handler.ts @@ -0,0 +1,73 @@ +import Channel from '../../lib/channel' + +export default class MessageHandler { + private _channel:Channel + private _data:object + + constructor(channel:Channel, data:object) { + this._channel = channel + this._data = data + + this.handle(this._data) + } + + handle(data) { + console.log('[MessageHandler] Handling:', data) + switch(data.target){ + case '/streaming/sessionLifetimeManagement/serverInitiatedDisconnect': + this.completeTransaction(data.id, '') + this._channel.getPlayer().destroy() + break + + case '/streaming/properties/titleinfo': + console.log('Title update:', data) + break + + case '/streaming/touchcontrols/showlayoutv2': + // console.log('Showing touch layout:', data) + break + + case '/streaming/systemUi/messages/ShowMessageDialog': + this.handleMessage(data) + break + + default: + console.log('[MessageChannel] Unhandled transaction:', data) + } + + } + + completeTransaction(id, data) { + const transaction = JSON.stringify({ + 'type': 'TransactionComplete', + 'content': JSON.stringify(data), + // 'content':'{\'Result\':0}', + 'id': id, + 'cv': '', + }) + + this._channel.send(transaction) + } + + cancelTransaction(id, data) { + const transaction = JSON.stringify({ + 'type': 'ReceiverCancel', + 'content': JSON.stringify(data), + 'id': id, + 'cv': '', + }) + + this._channel.send(transaction) + } + + handleMessage(data) { + const jsonData = JSON.parse(data.content) + console.log(jsonData) + if(confirm(jsonData.TitleText+'\n\n'+jsonData.ContentText)){ + this.completeTransaction(data.id, { Result: 0 }) + } else { + this.completeTransaction(data.id, { Result: 1 }) + // this.cancelTransaction(data.id, '') + } + } +} \ No newline at end of file diff --git a/tools/xcloud-player/greenlight/lib/ice.ts b/tools/xcloud-player/greenlight/lib/ice.ts new file mode 100644 index 000000000..eae0eee90 --- /dev/null +++ b/tools/xcloud-player/greenlight/lib/ice.ts @@ -0,0 +1,56 @@ +import xCloudPlayer from './player' +import Teredo from './teredo' + +export default class Ice { + private _peerConnection:RTCPeerConnection + private _iceCandidates: RTCIceCandidate[] = [] + + constructor(player:xCloudPlayer) { + this._peerConnection = player._peerConnection + + this._peerConnection.addEventListener('icecandidate', event => { + if(event.candidate) { + this._iceCandidates.push(event.candidate) + + // @TODO: Implement Teredo port selection for remote streaming + } + }) + } + + getCandidates(){ + return this._iceCandidates + } + + setRemoteCandidates(candidates:RTCIceCandidate[]){ + if(candidates.length === 0){ + console.warn('Error: No candidates received!') + } + + for(const candidate in candidates){ + if(candidates[candidate].candidate === 'a=end-of-candidates') {continue} + + const candidateAddress = candidates[candidate].candidate.split(' ') + + if(candidateAddress.length > 4 && candidateAddress[4].substr(0, 4) === '2001'){ + const teredo = new Teredo(candidateAddress[4]) + + this._peerConnection.addIceCandidate({ + candidate: 'a=candidate:10 1 UDP 1 '+teredo.getIpv4Address()+' 9002 typ host ', + sdpMLineIndex: candidates[candidate].sdpMLineIndex, + sdpMid: candidates[candidate].sdpMid, + }) + this._peerConnection.addIceCandidate({ + candidate: 'a=candidate:11 1 UDP 1 '+teredo.getIpv4Address()+' '+teredo.getIpv4Port()+' typ host ', + sdpMLineIndex: candidates[candidate].sdpMLineIndex, + sdpMid: candidates[candidate].sdpMid, + }) + } + + this._peerConnection.addIceCandidate({ + candidate: candidates[candidate].candidate, + sdpMid: candidates[candidate].sdpMid, + sdpMLineIndex: candidates[candidate].sdpMLineIndex, + }) + } + } +} \ No newline at end of file diff --git a/tools/xcloud-player/greenlight/lib/input/gamepad.ts b/tools/xcloud-player/greenlight/lib/input/gamepad.ts new file mode 100644 index 000000000..9475e1d69 --- /dev/null +++ b/tools/xcloud-player/greenlight/lib/input/gamepad.ts @@ -0,0 +1,441 @@ +import xCloudPlayer from '../player' +import { VibrationFrame } from '../channel/input' + +export interface GamepadOptions { + enable_keyboard?: boolean; + enable_gamepad?: boolean; + enable_vibration?: boolean; + keyboard_mapping?: { + [key:string]: string; + }; + gamepad_mapping?: { + [key:string]: string; + }; + gamepad_axes_mapping?: { + [key:string]: string; + }; + gamepad_deadzone?: number; + gamepad_force_capture?: boolean; +} + +export interface GamepadOptionsDefaults extends Required<GamepadOptions> {} + +export default class Gamepad { + private _player: xCloudPlayer | undefined + private _index: number + private _physicalGamepadId = -1 + private _rumbleInterval + private _isFocused = true + private _gamepadOverride = { + buttons: {}, + } + + private _options:GamepadOptionsDefaults = { + enable_keyboard: false, + enable_gamepad: true, + enable_vibration: true, + 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: 0.08, + gamepad_force_capture: false, + } + + private _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(index:number, options:GamepadOptions = {}){ + this._index = index + + this._options = { + ...this._options, + ...options, + } + } + + attach(xCloudPlayer:xCloudPlayer){ + this._player = xCloudPlayer + + this._player._channels.control.sendGamepadState(this._index, true, this) + + if(this._options.enable_keyboard === true){ + window.addEventListener('keydown', this._listener.keyDown) + window.addEventListener('keyup', this._listener.keyUp) + } + + if(this._options.enable_gamepad === true){ + window.addEventListener('gamepadconnected', this._listener.gamepadConnected) + window.addEventListener('gamepaddisconnected', this._listener.gamepadDisconnected) + } + + if(this._options.enable_gamepad === true && this._options.gamepad_force_capture === false){ + window.addEventListener('blur', this._listener.windowBlur) + window.addEventListener('focus', this._listener.windowFocus) + } + + this.detectActiveGamepad() + } + + detach(){ + if(this._player === undefined){ + console.log('[Gamepad] Player is not attached. this._player is:', this._player) + return + } + this._player._channels.control.sendGamepadState(this._index, false) + this._physicalGamepadId = -1 + + if(this._rumbleInterval !== undefined){ + clearInterval(this._rumbleInterval) + } + + if(this._options.enable_keyboard === true){ + window.removeEventListener('keydown', this._listener.keyDown) + window.removeEventListener('keyup', this._listener.keyUp) + } + + if(this._options.enable_gamepad === true){ + window.removeEventListener('gamepadconnected', this._listener.gamepadConnected) + window.removeEventListener('gamepaddisconnected', this._listener.gamepadDisconnected) + } + + if(this._options.enable_gamepad === true && this._options.gamepad_force_capture === false){ + window.removeEventListener('blur', this._listener.windowBlur) + window.removeEventListener('focus', this._listener.windowFocus) + } + } + + sendButtonState(button, value){ + if(this._player === undefined){ + console.log('[Gamepad] Player is not attached. this._player is:', this._player) + return + } + + if(! (button in this.getDefaultFamepadFrame())){ + console.log('[Gamepad] Invalid button:', button) + return + } + + const buttonPreset = this.getDefaultFamepadFrame() + buttonPreset.GamepadIndex = this._index + this._gamepadOverride.buttons[button] = value + } + + onKeyDown(event:KeyboardEvent){ + if(this._isFocused === false){ + return + } + + for(const button in this._options.keyboard_mapping){ + if(this._options.keyboard_mapping[button] === event.key){ + this._gamepadOverride.buttons[button] = 1 + event.preventDefault() + } + } + } + + onKeyUp(event:KeyboardEvent){ + if(this._isFocused === false){ + return + } + + for(const button in this._options.keyboard_mapping){ + if(this._options.keyboard_mapping[button] === event.key){ + this._gamepadOverride.buttons[button] = 0 + event.preventDefault() + } + } + } + + onWindowFocus(){ + this._isFocused = true + } + + onWindowBlur(){ + // this._isFocused = false + } + + 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, + RightShoulder: 0, + LeftThumb: 0, + RightThumb: 0, + + LeftThumbXAxis: 0, + LeftThumbYAxis: 0, + RightThumbXAxis: 0, + RightThumbYAxis: 0, + LeftTrigger: 0, + RightTrigger: 0, + } + } + + detectActiveGamepad(){ + if(this._physicalGamepadId < 0){ + for(const gamepadBrowser in navigator.getGamepads()){ + const gamepadIndex = navigator.getGamepads()[gamepadBrowser]?.index + if(gamepadIndex === undefined){ + continue + } + + let gamepadFoundInHandler = false + for(const gamepad in this._player?._channels.control.getGamepadHandlers()){ + if( + this._player?._channels.control.getGamepadHandlers()[gamepad] instanceof Gamepad && + (this._player?._channels.control.getGamepadHandlers()[gamepad] as Gamepad)._physicalGamepadId === navigator.getGamepads()[gamepadBrowser]?.index + ) { + console.log('[Gamepad] Active gamepad already detected:', gamepadIndex, (this._player?._channels.control.getGamepadHandlers()[gamepad] as Gamepad)._physicalGamepadId) + gamepadFoundInHandler = true + continue + } + } + + if(gamepadFoundInHandler === false){ + this._physicalGamepadId = gamepadIndex + console.log('[Gamepad] Active gamepad detected:', this._physicalGamepadId) + } + } + } else { + console.log('[Gamepad] Physical gamepad already detected:', this._physicalGamepadId) + } + } + + onGamepadConnected(event:GamepadEvent){ + if(this._physicalGamepadId < 0){ + for(const gamepad in this._player?._channels.control.getGamepadHandlers()){ + if( + this._player?._channels.control.getGamepadHandlers()[gamepad] instanceof Gamepad && + (this._player?._channels.control.getGamepadHandlers()[gamepad] as Gamepad)._physicalGamepadId === event.gamepad.index + ) { + return + } + } + + this._physicalGamepadId = event.gamepad.index + } + } + + onGamepadDisconnected(event:GamepadEvent){ + if(event.gamepad.index === this._physicalGamepadId){ + this._physicalGamepadId = -1 + } + } + + hasOverrideButtons(){ + return Object.keys(this._gamepadOverride.buttons).length > 0 + } + + getGamepadState(){ + const gamepad = this.getGamepad(this._physicalGamepadId) + if((this._physicalGamepadId < 0 || gamepad === undefined || this._isFocused === false) && (this.hasOverrideButtons() === false)){ + return undefined + } + + const frame = this.getDefaultFamepadFrame() + frame.GamepadIndex = this._index + + // Set buttons + if(gamepad !== undefined){ + for(const button in this._options.gamepad_mapping){ + // DroidDeck: Android often reports no guide button (index 16); a missing button is "not pressed". + frame[button] = gamepad.buttons[this._options.gamepad_mapping[button]]?.value ?? 0 + } + + // Set axis + for(const axis in this._options.gamepad_axes_mapping){ + frame[axis] = this.normaliseAxis(gamepad.axes[this._options.gamepad_axes_mapping[axis]] ?? 0) + } + } + + // Check for button overrides from the keyboard + // for(const button in this._gamepadOverride.buttons){ + // frame[button] = this._gamepadOverride.buttons[button] + + // // Clean up existing keys if they are 0 + // if(this._gamepadOverride.buttons[button] === 0){ + // // Remove key from override + // const newOverrideButtons = {} + // for(const oButton in this._gamepadOverride.buttons){ + // if(this._gamepadOverride.buttons[oButton] !== 0){ + // newOverrideButtons[oButton] = this._gamepadOverride.buttons[oButton] + // } + // } + // this._gamepadOverride.buttons = newOverrideButtons + // } + // } + + // Check for button overrides from the keyboard + for(const button in this._gamepadOverride.buttons){ + frame[button] = this._gamepadOverride.buttons[button] + } + + // Clean up released buttons in a separate pass + const buttonsToRemove = [] + for(const button in this._gamepadOverride.buttons){ + if(this._gamepadOverride.buttons[button] === 0){ + buttonsToRemove.push(button) + } + } + + // Delete released buttons instead of recreating the object + for(const button of buttonsToRemove){ + delete this._gamepadOverride.buttons[button] + } + + // Start + Select Nexus menu workaround + if(frame.View > 0 && frame.Menu > 0){ + frame.View = 0 + frame.Menu = 0 + + frame.Nexus = 1 + } + + return frame + } + + getGamepad(index = -1){ + const gamepad = navigator.getGamepads()[index] + if(gamepad !== null) { + return gamepad + } else { + return undefined + } + } + + getPhysicalGamepadId(){ + return this._physicalGamepadId + } + + handleVibration(report:VibrationFrame){ + if(this._physicalGamepadId < 0){ + console.log('[Gamepad] Received a vibration report but no physical gamepad is connected') + return + } + + const gamepad = this.getGamepad(this._physicalGamepadId) + if(gamepad === undefined){ + console.log('[Gamepad] Received a vibration report but no physical gamepad is connected') + return + } + + if(this._options.enable_vibration === false){ + return + } + + const rumbleData = { + startDelay: 0, + duration: report.durationMs, + // DroidDeck: the player's rumble strength setting (0..1). + weakMagnitude: report.rightMotorPercent * ((window as any).__ddRumble ?? 1), + strongMagnitude: report.leftMotorPercent * ((window as any).__ddRumble ?? 1), + + leftTrigger: report.leftTriggerMotorPercent, + rightTrigger: report.rightTriggerMotorPercent, + } + + // @ts-expect-error - dual-rumble is not in the types + if(gamepad.vibrationActuator?.type === 'dual-rumble') { + const intensityRumble = report.rightMotorPercent < .6 ? (.6 - report.rightMotorPercent) / 2 : 0 + const intensityRumbleTriggers = (report.leftTriggerMotorPercent + report.rightTriggerMotorPercent) / 4 + const endIntensity = Math.min(intensityRumble, intensityRumbleTriggers) + + rumbleData.weakMagnitude = Math.min(1, (report.rightMotorPercent + endIntensity) * ((window as any).__ddRumble ?? 1)) + + // Set triggers to 0 as dual-rumble does not support Triggers + // @TODO: Check if we can improve rumble: https://github.com/MicrosoftEdge/MSEdgeExplainers/blob/main/GamepadHapticsActuatorTriggerRumble/explainer.md + rumbleData.leftTrigger = 0 + rumbleData.rightTrigger = 0 + + gamepad.vibrationActuator?.playEffect('dual-rumble', rumbleData) + + clearInterval(this._rumbleInterval) + if(report.repeat > 0) { + let repeatCount = report.repeat + + this._rumbleInterval = setInterval(() => { + if(repeatCount <= 0){ + clearInterval(this._rumbleInterval) + } + + if(gamepad.vibrationActuator !== undefined) { + gamepad.vibrationActuator?.playEffect('dual-rumble', rumbleData) + } + repeatCount-- + + }, report.delayMs + report.durationMs) + } + } else { + console.log('[Gamepad] Unknown vibration type:', (gamepad.vibrationActuator as any).type) + } + } + + normaliseAxis(value:number) { + if(Math.abs(value) < this._options.gamepad_deadzone) { + return 0 + } + + value = value - Math.sign(value) * this._options.gamepad_deadzone + value /= (1.0 - this._options.gamepad_deadzone) + + return value + } +} \ No newline at end of file diff --git a/tools/xcloud-player/greenlight/lib/input/mousekeyboard.ts b/tools/xcloud-player/greenlight/lib/input/mousekeyboard.ts new file mode 100644 index 000000000..54dc7ff32 --- /dev/null +++ b/tools/xcloud-player/greenlight/lib/input/mousekeyboard.ts @@ -0,0 +1,143 @@ +import xCloudPlayer from '../player' + +export default class MouseKeyboard { + private _player: xCloudPlayer | undefined + private _index: number + + private _listener = { + keyDown: this.onKeyDown.bind(this), + keyUp: this.onKeyUp.bind(this), + pointerMove: this.onPointerMove.bind(this), + pointerClick: this.onPointerClick.bind(this), + pointerScroll: this.onPointerScroll.bind(this), + lockMouseKeyboard: this.lockMouseKeyboard.bind(this), + } + + + constructor(index:number){ + this._index = index + } + + attach(xCloudPlayer:xCloudPlayer){ + this._player = xCloudPlayer + + this._player._channels.control.sendGamepadState(this._index, true, this) + + // Mod the video UI to capture MKB input + if(this._player.getVideoElement() === undefined){ + console.log('[MKB] VideoComponent is not attached. this._player._videoComponent is:', this._player.getVideoElement()) + return + } else { + this._player.getVideoElement()?.addEventListener('pointermove', this._listener.pointerMove) + this._player.getVideoElement()?.addEventListener('pointerdown', this._listener.pointerClick) + this._player.getVideoElement()?.addEventListener('pointerup', this._listener.pointerClick) + this._player.getVideoElement()?.addEventListener('wheel', this._listener.pointerScroll) + this._player.getVideoElement()?.addEventListener('click', this._listener.lockMouseKeyboard) + + window.addEventListener('keydown', this._listener.keyDown) + window.addEventListener('keyup', this._listener.keyUp) + + this._player.getVideoElement()?.addEventListener('contextmenu', event => { + event.preventDefault() + }) + } + + } + + detach(){ + if(this._player === undefined){ + console.log('[MKB] Player is not attached. this._player is:', this._player) + return + } + + this._player._channels.control.sendGamepadState(this._index, false) + + window.removeEventListener('keydown', this._listener.keyDown) + window.removeEventListener('keyup', this._listener.keyUp) + + this._player.getVideoElement()?.removeEventListener('pointermove', this._listener.pointerMove) + this._player.getVideoElement()?.removeEventListener('pointerdown', this._listener.pointerClick) + this._player.getVideoElement()?.removeEventListener('pointerup', this._listener.pointerClick) + this._player.getVideoElement()?.removeEventListener('wheel', this._listener.pointerScroll) + this._player.getVideoElement()?.removeEventListener('click', this._listener.lockMouseKeyboard) + } + + onPointerMove(event:PointerEvent){ + this._player?._channels.input.queueMouseFrame({ + X: event.movementX*2, + Y: event.movementY*2, + WheelX: 0, + WheelY: 0, + Buttons: event.buttons, + Relative: 0, // 0 = Relative, 1 = Absolute + }) + } + + onPointerClick(event:PointerEvent){ + // console.log('Pointer clicked:', event) + this._player?._channels.input.queueMouseFrame({ + X: event.movementX*2, + Y: event.movementY*2, + WheelX: 0, + WheelY: 0, + Buttons: event.buttons, + Relative: 0, // 0 = Relative, 1 = Absolute + }) + } + + onPointerScroll(event:WheelEvent){ + console.log('[MKB] Pointer scrolled:', event) + } + + onKeyDown(event:KeyboardEvent){ + this._player?._channels.input.queueKeyboardFrame({ + key: event.key, + keyCode: event.keyCode, + pressed: true, + }) + event.preventDefault() + } + + onKeyUp(event:KeyboardEvent){ + this._player?._channels.input.queueKeyboardFrame({ + key: event.key, + keyCode: event.keyCode, + pressed: false, + }) + } + + lockMouseKeyboard(){ + if(this._player === undefined || this._player.getVideoElement() === undefined){ + console.log('[MKB] Player or VideoComponent is not attached. Failed to lock Mouse & Keyboard because there is nothing to lock for.') + return + } + + // @disable ts-expect-error requestPointerLock() is not defined to consume arguments + const promise = this._player?.getVideoElement()?.requestPointerLock({ + unadjustedMovement: true, + }) as Promise<undefined> | undefined + + if(promise === undefined){ + console.log('[MKB] Failed to lock Mouse & Keyboard because the requestPointerLock() is undefined') + return + } + + if ('keyboard' in navigator && 'lock' in (navigator.keyboard as any)) { + document.body.requestFullscreen().then(() => { + (navigator as any).keyboard.lock() + }) + } else { + console.log('[MKB] Keyboard lock is not supported. Error:', 'NotSupportedError') + return + } + + promise.then(() => { + console.log('[MKB] pointer is locked') + }).catch((error) => { + if (error.name === 'NotSupportedError') { + console.log('[MKB] Mouse pointer lock is not supported. Error:', 'NotSupportedError') + } + }) + } + +} \ No newline at end of file diff --git a/tools/xcloud-player/greenlight/lib/input/touch.ts b/tools/xcloud-player/greenlight/lib/input/touch.ts new file mode 100644 index 000000000..0eedc0262 --- /dev/null +++ b/tools/xcloud-player/greenlight/lib/input/touch.ts @@ -0,0 +1,70 @@ +import xCloudPlayer from '../player' + +export default class Touch { + private _player: xCloudPlayer | undefined + private _index: number + + private _pointerEvents = {} + private _lastPointerId = -1 + + private _mouseDown = false + + private _listener = { + onPointer: this.onPointer.bind(this), + } + + constructor(index:number){ + this._index = index + } + + attach(xCloudPlayer:xCloudPlayer){ + this._player = xCloudPlayer + + this._player._channels.control.sendGamepadState(this._index, true, this) + + // Mod the video UI to capture MKB input + if(this._player.getVideoElement() === undefined){ + console.log('VideoComponent is not attached. this._player._videoComponent is:', this._player.getVideoElement()) + return + } else { + this._player.getVideoElement()?.addEventListener('pointermove', this._listener.onPointer, { passive: false }) + this._player.getVideoElement()?.addEventListener('pointerdown', this._listener.onPointer, { passive: false }) + this._player.getVideoElement()?.addEventListener('pointerup', this._listener.onPointer, { passive: false }) + } + + } + + detach(){ + if(this._player === undefined){ + console.log('Player is not attached. this._player is:', this._player) + return + } + this._player._channels.control.sendGamepadState(this._index, false) + + this._player.getVideoElement()?.removeEventListener('pointermove', this._listener.onPointer) + this._player.getVideoElement()?.removeEventListener('pointerdown', this._listener.onPointer) + this._player.getVideoElement()?.removeEventListener('pointerup', this._listener.onPointer) + } + + onPointer(event:PointerEvent) { + this._lastPointerId = event.pointerId + + if(event.pointerType === 'mouse' && event.type === 'pointerdown'){ + this._mouseDown = true + } + + if(event.pointerType === 'mouse' && event.type === 'pointerup'){ + this._mouseDown = false + } + + if(event.pointerType === 'mouse' && event.type === 'pointermove' && this._mouseDown === false){ + // ignore events if the pointer type is mouse and is not pressed. + } else { + this._player?._channels.input.queuePointerFrame({ + events: [event], + }) + } + event.preventDefault() + } + +} \ No newline at end of file diff --git a/tools/xcloud-player/greenlight/lib/player.ts b/tools/xcloud-player/greenlight/lib/player.ts new file mode 100644 index 000000000..7020ed823 --- /dev/null +++ b/tools/xcloud-player/greenlight/lib/player.ts @@ -0,0 +1,143 @@ +import ChatChannel from './channel/chat' +import ControlChannel from './channel/control' +import InputChannel from './channel/input' +import MessageChannel from './channel/message' + +import Ice from './ice' +import Sdp from './sdp' +import Stats from './stats' + +import VideoComponent from './render/video' +import WebGpuComponent from './render/webgpu' +import AudioComponent from './render/audio' + +export default class xCloudPlayer { + + _peerConnection = new RTCPeerConnection({}) + _channels = { + chat: new ChatChannel(this), + control: new ControlChannel(this), + input: new InputChannel(this), + message: new MessageChannel(this), + } + + private _elementId: string + private _isDestoyed: boolean = false + private _iceHelper = new Ice(this) + private _sdpHelper = new Sdp(this) + private _statsHelper = new Stats(this) + + private _videoComponent: VideoComponent | WebGpuComponent | undefined + private _audioComponent: AudioComponent | undefined + + constructor(elementId: string) { + console.log('xCloudPlayer constructing...'); + + this._elementId = elementId; + this._peerConnection.addTransceiver('audio', { direction: 'sendrecv' }) + const videoTransceiver = this._peerConnection.addTransceiver('video', { direction: 'recvonly' }) + videoTransceiver.setCodecPreferences(this._sdpHelper.getDefaultCodecPreferences()) + + this._peerConnection.ontrack = (event) => { + + if(event.track.kind === 'video'){ + // DroidDeck: a plain <video> element (hardware decode); WebGPU is not dependable in Android WebView. + this._videoComponent = new VideoComponent(this) + this._videoComponent.create(event.streams[0]) + + } else if(event.track.kind === 'audio'){ + this._audioComponent = new AudioComponent(this) + this._audioComponent.create(event.streams[0]) + + } else { + console.log('[xPlayer] Detected an unknown stream type: ', event.track.kind) + } + } + + console.log('xCloudPlayer constructed.'); + } + + init() { + console.log('xCloudPlayer init called.'); + } + + getElementId() { + return this._elementId + } + + async createOffer() { + const offer = await this._peerConnection.createOffer({ + offerToReceiveAudio: true, + offerToReceiveVideo: true, + }) + const finalOffer = this._sdpHelper.setLocalSDP(offer) + this._peerConnection.setLocalDescription(finalOffer) + + return finalOffer + } + + async setRemoteSDP(sdp: string) { + const finalSdp = this._sdpHelper.setRemoteSDP(sdp) + return await this._peerConnection.setRemoteDescription({ + type: 'answer', + sdp: finalSdp, + }) + } + + async getICECandidates() { + return this._iceHelper.getCandidates() + } + + async setRemoteIceCandidates(candidates:RTCIceCandidate[]) { + // @TODO: Sort ipv4 and ipv6, and use best route as preference (ipv6 only etc) + + this._iceHelper.setRemoteCandidates(candidates) + } + + getVideoElement() { + if(this._videoComponent){ + return this._videoComponent.getElement() + } else { + return undefined + } + } + + getAudioElement() { + if(this._audioComponent){ + return this._audioComponent.getElement() + } else { + return undefined + } + } + + toggleDebugOverlay() { + if(this._videoComponent){ + this._videoComponent.toggleDebugOverlay() + } + } + + getStats() { + return this._statsHelper + } + + destroy() { + if(this._isDestoyed === false){ + this._peerConnection.close() + + if(this._peerConnection.onconnectionstatechange){ + this._peerConnection.onconnectionstatechange(new Event('connectionstatechanged')) + } + + for(const channel in this._channels) { + this._channels[channel].destroy() + } + + if(this._videoComponent){ this._videoComponent.destroy() } + if(this._audioComponent){ this._audioComponent.destroy() } + + this._isDestoyed = true + } else { + console.log('Cannot destroy because the player is already destroyed.') + } + } +} \ No newline at end of file diff --git a/tools/xcloud-player/greenlight/lib/render/audio.ts b/tools/xcloud-player/greenlight/lib/render/audio.ts new file mode 100644 index 000000000..0821c67e1 --- /dev/null +++ b/tools/xcloud-player/greenlight/lib/render/audio.ts @@ -0,0 +1,38 @@ +import xCloudPlayer from '../player' + +export default class AudioComponent { + private _player:xCloudPlayer + + private _element:HTMLAudioElement | undefined + + constructor(player:any){ + this._player = player + } + + create(stream:MediaStream) { + const audioElement = document.createElement('audio') + audioElement.srcObject = stream + audioElement.autoplay = true + audioElement.muted = false + + this._element = audioElement + + const element = document.getElementById(this._player.getElementId()) + if(element === null) {return} + + element.appendChild(this._element) + } + + getElement(){ + return this._element + } + + destroy(){ + const streamHolder = document.getElementById(this._player.getElementId()) + const element = streamHolder?.querySelector('audio') + + if(element){ + element.remove() + } + } +} \ No newline at end of file diff --git a/tools/xcloud-player/greenlight/lib/render/overlay.ts b/tools/xcloud-player/greenlight/lib/render/overlay.ts new file mode 100644 index 000000000..90c0321f2 --- /dev/null +++ b/tools/xcloud-player/greenlight/lib/render/overlay.ts @@ -0,0 +1,118 @@ +import xCloudPlayer from '../player' +import VideoComponent from './video' +import WebGPUComponent from './webgpu' + +interface OverlayInterface { + debug: undefined | HTMLElement; +} + +export default class Overlay { + private _player:xCloudPlayer + private _videoComponent:VideoComponent | WebGPUComponent + + private _overlays:OverlayInterface = { + debug: undefined, + } + + constructor(videoComponent:VideoComponent | WebGPUComponent, player:xCloudPlayer){ + this._videoComponent = videoComponent + this._player = player + } + + toggleDebug(){ + if(this._overlays.debug === undefined){ + this.createDebugOverlay() + } else { + 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 = undefined + } + + refreshDebugOverlay(){ + if(this._overlays.debug !== undefined){ + 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')) + + // Display rendering FPS if available + const frameStats = (this._videoComponent as any).getFrameStats?.(); + if (frameStats) { + this._overlays.debug.appendChild(this.createLabel('Render FPS', frameStats.renderingFps.toString(), frameStats.renderingFps >= 58 ? 'ok' : 'warning')) + this._overlays.debug.appendChild(this.createLabel('Render Delay', frameStats.renderDelayMs.toString() + ' ms', frameStats.renderDelayMs < 16 ? 'ok' : frameStats.renderDelayMs < 33 ? 'warning' : 'error')) + this._overlays.debug.appendChild(this.createLabel('Dropped Frames', frameStats.droppedFrames.toString(), frameStats.droppedFrames === 0 ? 'ok' : frameStats.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')) + const rttButtonState = (this._player.getStats()._rtt*1000 > 75) ? 'error' : (this._player.getStats()._rtt*1000 > 40) ? 'warning' : 'ok' + this._overlays.debug.appendChild(this.createLabel('RTT', (this._player.getStats()._rtt*1000).toString()+' ms', rttButtonState)) + + setTimeout(() => { + this.refreshDebugOverlay() + }, 500) + } + } + + createLabel(text:string, value:string = '', style:string = ''){ + const labelStyle = { + 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', + } + + const element = document.createElement('span') + + const colors = { + 'error': 'rgb(220, 53, 69)', + 'warning': 'rgb(255, 193, 7)', + 'ok': 'rgb(25, 135, 84)', + } + + if(value !== ''){ + element.innerHTML = `${text}: <span style="background: ${colors[style]}; border-radius: 5px; padding: 4px; padding-left: 8px; padding-right: 8px;">${value}</span>` + } else { + element.innerText = text + } + + + for(const key in labelStyle){ + element.style[key] = labelStyle[key] + } + return element + } + +} \ No newline at end of file diff --git a/tools/xcloud-player/greenlight/lib/render/video.ts b/tools/xcloud-player/greenlight/lib/render/video.ts new file mode 100644 index 000000000..9ea899d75 --- /dev/null +++ b/tools/xcloud-player/greenlight/lib/render/video.ts @@ -0,0 +1,76 @@ +import xCloudPlayer from '../player' +import Overlay from './overlay' + +export default class VideoComponent { + private _player:xCloudPlayer + + private _element:HTMLVideoElement | undefined + private _overlay:Overlay + + constructor(player:any){ + this._player = player + this._overlay = new Overlay(this, this._player) + } + + create(stream:MediaStream) { + const videoElement = document.createElement('video') + videoElement.srcObject = stream + videoElement.autoplay = true + videoElement.muted = true + videoElement.playsInline = true + videoElement.style.width = '100%' + videoElement.style.height = '100%' + videoElement.style.objectFit = 'contain' + videoElement.style.backgroundColor = 'black' + videoElement.style.touchAction = 'none' + // DroidDeck: a transparent poster hides Android WebView's grey play icon before the first frame. + videoElement.poster = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7' + + const element = document.getElementById(this._player.getElementId()) + if(element === null) {return} + + this._element = videoElement + element.appendChild(this._element) + const isStatic = getComputedStyle(element).position === 'static' ? true : false + if(isStatic === true){ + element.style.position = 'relative' + } + + this._element.requestVideoFrameCallback(this.processVideoMetadata.bind(this)) + } + + processVideoMetadata(timestamp, data:VideoFrameCallbackMetadata) { + if(this._element === undefined) {return} + + this._element.requestVideoFrameCallback(this.processVideoMetadata.bind(this)) + + this._player._channels.input.queueMetadataFrame({ + serverDataKey: data.rtpTimestamp as number, + firstFramePacketArrivalTimeMs: data.receiveTime as number, + frameSubmittedTimeMs: data.receiveTime as number, + frameDecodedTimeMs: data.expectedDisplayTime, + frameRenderedTimeMs: data.expectedDisplayTime, + }) + } + + getElement(){ + return this._element + } + + toggleDebugOverlay(){ + this._overlay.toggleDebug() + } + + destroy(){ + const streamHolder = document.getElementById(this._player.getElementId()) + const element = streamHolder?.querySelector('video') + + if(this._overlay !== undefined){ + this._overlay.destroy() + } + + if(element){ + element.remove() + } + } +} \ No newline at end of file diff --git a/tools/xcloud-player/greenlight/lib/render/webgpu.ts b/tools/xcloud-player/greenlight/lib/render/webgpu.ts new file mode 100644 index 000000000..673ee1d02 --- /dev/null +++ b/tools/xcloud-player/greenlight/lib/render/webgpu.ts @@ -0,0 +1,483 @@ +import xCloudPlayer from '../player' +import Overlay from './overlay' + +export default class WebGpuComponent { + private _player:xCloudPlayer + + private _element:HTMLCanvasElement | undefined + private _overlay:Overlay + private _isActive: boolean = true + + private _gpuReader:GPUCanvasContext | undefined + private _gpuVideoTexture:GPUTexture | null = null + private _gpuDevice:GPUDevice | null = null + private _gpuContext:GPUCanvasContext | null = null + private _gpuBindGroup:GPUBindGroup | null = null + private _gpuPipeline:GPURenderPipeline | null = null + private _gpuSampler:GPUSampler | null = null + private _resizeObserver:ResizeObserver | null = null + private _containerElement:HTMLElement | null = null + private _isProcessingFrame: boolean = false + private _frameQueueSize: number = 0 + private _droppedFrames: number = 0 + private _lastFrameTime: number = 0 + private _renderingFps: number = 0 + private _frameTimeSamples: number[] = [] + private _renderDelayMs: number = 0 + private _renderDelaySamples: number[] = [] + + constructor(player:any){ + this._player = player + this._overlay = new Overlay(this, this._player) + } + + create(stream:MediaStream) { + const videoElement = document.createElement('canvas') + console.log('Creating WebGPU canvas element for stream:', stream); + + // videoElement.srcObject = stream + // videoElement.autoplay = true + // videoElement.muted = true + // videoElement.playsInline = true + videoElement.width = 1920 + videoElement.height = 1080 + videoElement.style.willChange = 'contents' + // videoElement.style.imageRendering = 'pixelated' // or 'crisp-edges' for faster rendering + // videoElement.style.objectFit = 'contain' + // videoElement.style.backgroundColor = 'black' + // videoElement.style.touchAction = 'none' + + const that:WebGpuComponent = this + + navigator.gpu.requestAdapter().then((adapter:any) => { + if (!adapter) { + console.error('Failed to get GPU adapter'); + return; + } + console.log('WebGPU adapter:', adapter); + adapter.requestDevice().then((device:any) => { + const context = videoElement.getContext("webgpu"); + if (!context) { + console.error('Failed to get WebGPU context'); + return; + } + const format = navigator.gpu.getPreferredCanvasFormat(); + context.configure({ + device, + format, + alphaMode: 'opaque', + // Enable desynchronized mode for lower latency and reduced jitter + usage: GPUTextureUsage.RENDER_ATTACHMENT + }); + + this._gpuDevice = device + this._gpuContext = context + + // Additional WebGPU setup and rendering logic would go here + + that._gpuVideoTexture = this._gpuDevice.createTexture({ + size: [videoElement.width, videoElement.height], + format: "rgba8unorm", + usage: GPUTextureUsage.TEXTURE_BINDING | + GPUTextureUsage.COPY_DST | + GPUTextureUsage.RENDER_ATTACHMENT + }); + + const vertexModule = this._gpuDevice.createShaderModule({ + code: ` + struct VertexOutput { + @builtin(position) position: vec4<f32>, + @location(0) uv: vec2<f32>, + } + + @vertex + fn main(@builtin(vertex_index) VertexIndex: u32) -> VertexOutput { + var pos = array<vec2<f32>, 4>( + vec2<f32>(-1.0, -1.0), + vec2<f32>( 1.0, -1.0), + vec2<f32>(-1.0, 1.0), + vec2<f32>( 1.0, 1.0) + ); + var uv = array<vec2<f32>, 4>( + vec2<f32>(0.0, 1.0), + vec2<f32>(1.0, 1.0), + vec2<f32>(0.0, 0.0), + vec2<f32>(1.0, 0.0) + ); + var output: VertexOutput; + output.position = vec4<f32>(pos[VertexIndex], 0.0, 1.0); + output.uv = uv[VertexIndex]; + return output; + } + ` + }); + + const fragmentModule = this._gpuDevice.createShaderModule({ + code: ` + @group(0) @binding(0) var myTexture: texture_2d<f32>; + @group(0) @binding(1) var mySampler: sampler; + + @fragment + fn main(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> { + // Get texture dimensions + let texSize = vec2<f32>(textureDimensions(myTexture)); + let invTexSize = 1.0 / texSize; + + // Edge-aware upscaling with Catmull-Rom interpolation + // Sample 3x3 neighborhood for edge detection + let p00 = textureSample(myTexture, mySampler, uv + vec2<f32>(-1.0, -1.0) * invTexSize).rgb; + let p10 = textureSample(myTexture, mySampler, uv + vec2<f32>( 0.0, -1.0) * invTexSize).rgb; + let p20 = textureSample(myTexture, mySampler, uv + vec2<f32>( 1.0, -1.0) * invTexSize).rgb; + + let p01 = textureSample(myTexture, mySampler, uv + vec2<f32>(-1.0, 0.0) * invTexSize).rgb; + let p11 = textureSample(myTexture, mySampler, uv).rgb; + let p21 = textureSample(myTexture, mySampler, uv + vec2<f32>( 1.0, 0.0) * invTexSize).rgb; + + let p02 = textureSample(myTexture, mySampler, uv + vec2<f32>(-1.0, 1.0) * invTexSize).rgb; + let p12 = textureSample(myTexture, mySampler, uv + vec2<f32>( 0.0, 1.0) * invTexSize).rgb; + let p22 = textureSample(myTexture, mySampler, uv + vec2<f32>( 1.0, 1.0) * invTexSize).rgb; + + // Calculate Sobel edge detection + let edgeX = -p00 + p20 - 2.0*p01 + 2.0*p21 - p02 + p22; + let edgeY = p00 + 2.0*p10 + p20 - p02 - 2.0*p12 - p22; + let edge = length(edgeX) + length(edgeY); + + // Sample with adaptive filtering based on edge strength + let centerSample = textureSample(myTexture, mySampler, uv); + var resultRgb = centerSample.rgb; + + // For smooth areas, apply slight smoothing + // For edge areas, keep sharp sampling + if (edge < 0.1) { + // Smooth region: blend neighboring pixels for smoothness + let neighbors = (p10 + p01 + p21 + p12) * 0.25; + resultRgb = mix(resultRgb, neighbors, 0.15); + } else { + // Edge region: apply subtle sharpening to maintain clarity + resultRgb = resultRgb * 1.1 - (p10 + p01 + p21 + p12) * 0.025; + } + + return vec4<f32>(resultRgb, centerSample.a); + } + ` + }); + + // Render pipeline (vertex + fragment shader) + this._gpuPipeline = this._gpuDevice.createRenderPipeline({ + vertex: { + module: vertexModule, + entryPoint: "main" + }, + fragment: { + module: fragmentModule, + entryPoint: "main", + targets: [{ format: navigator.gpu.getPreferredCanvasFormat() }] + }, + primitive: { topology: "triangle-strip" }, + layout: "auto", + }); + + // Create sampler once and reuse it + this._gpuSampler = this._gpuDevice.createSampler({ + magFilter: "linear", + minFilter: "linear", + addressModeU: "clamp-to-edge", + addressModeV: "clamp-to-edge", + }); + + // Process video frames from the MediaStream + // console.log('Stream tracks:', stream); + const track = stream.getVideoTracks()[0]; + const processor = new MediaStreamTrackProcessor({ track }); + this._gpuReader = processor.readable.getReader(); + + this._gpuBindGroup = this._gpuDevice.createBindGroup({ + layout: this._gpuPipeline.getBindGroupLayout(0), + entries: [ + { binding: 0, resource: this._gpuVideoTexture.createView() }, + { binding: 1, resource: this._gpuSampler }, + ] + }); + + // Start rendering loop + that.processVideoData(); + }); + }); + + // console.log('WebGPU adapter:', adapter, navigator.gpu); + // const device = adapter.requestDevice(); + // console.log('Device:', device); + // const context = videoElement.getContext("webgpu"); + // console.log('Context:', context); + + // const format = navigator.gpu.getPreferredCanvasFormat(); + // context.configure({ device, format }); + + // Rest + + const element = document.getElementById(this._player.getElementId()) + if(element === null) {return} + + this._element = videoElement + this._containerElement = element + element.appendChild(this._element) + const isStatic = getComputedStyle(element).position === 'static' ? true : false + if(isStatic === true){ + element.style.position = 'relative' + } + + // Make canvas fill the container + this._element.style.width = '100%' + this._element.style.height = '100%' + this._element.style.display = 'block' + + // Set up resize observer to handle canvas resizing + this._resizeObserver = new ResizeObserver(() => { + this.resizeCanvas() + }) + this._resizeObserver.observe(element) + + // Initial resize + this.resizeCanvas() + + // this._element.requestVideoFrameCallback(this.processVideoMetadata.bind(this)) + } + + async processVideoData(timestamp?:number) { + if (this._isProcessingFrame || !this._isActive) { + return; + } + + this._isProcessingFrame = true; + + try { + // Non-blocking read - check if frame is available without waiting + const { value: frame, done } = await this._gpuReader.read(); + if (done || !this._isActive || !frame) { + this._isProcessingFrame = false; + return; + } + + // Frame dropping: if multiple frames queued, skip older ones to maintain order and latency + // This prevents frames from being processed out of order + if (this._frameQueueSize > 0) { + frame.close(); + this._droppedFrames++; + this._isProcessingFrame = false; + // Continue processing next frame immediately without delay + this.videoLoop(); + return; + } + + this._frameQueueSize++; + + // Resize texture if video frame dimensions changed + if (this._gpuVideoTexture && + (this._gpuVideoTexture.width !== frame.codedWidth || + this._gpuVideoTexture.height !== frame.codedHeight)) { + + this._gpuVideoTexture.destroy(); + + this._gpuVideoTexture = this._gpuDevice.createTexture({ + size: [frame.codedWidth, frame.codedHeight], + format: "rgba8unorm", + usage: GPUTextureUsage.TEXTURE_BINDING | + GPUTextureUsage.COPY_DST | + GPUTextureUsage.RENDER_ATTACHMENT + }); + + // Recreate bind group with new texture + this._gpuBindGroup = this._gpuDevice.createBindGroup({ + layout: this._gpuPipeline.getBindGroupLayout(0), + entries: [ + { binding: 0, resource: this._gpuVideoTexture.createView() }, + { binding: 1, resource: this._gpuSampler }, + ] + }); + + console.log(`Video texture resized to ${frame.codedWidth}x${frame.codedHeight}`); + } + + const renderStartTime = performance.now(); + + // Track frame timing for FPS calculation + if (this._lastFrameTime > 0) { + const frameTime = renderStartTime - this._lastFrameTime; + this._frameTimeSamples.push(frameTime); + // Keep only last 60 samples for smooth FPS average + if (this._frameTimeSamples.length > 60) { + this._frameTimeSamples.shift(); + } + // Update FPS based on average frame time + const avgFrameTime = this._frameTimeSamples.reduce((a, b) => a + b, 0) / this._frameTimeSamples.length; + this._renderingFps = Math.round(1000 / avgFrameTime); + } + this._lastFrameTime = renderStartTime; + + // Upload the video frame to GPU + this._gpuDevice.queue.copyExternalImageToTexture( + { source: frame }, + { texture: this._gpuVideoTexture }, + [frame.codedWidth, frame.codedHeight] + ); + + // Render to canvas + const commandEncoder = this._gpuDevice.createCommandEncoder(); + const passEncoder = commandEncoder.beginRenderPass({ + colorAttachments: [{ + view: this._gpuContext.getCurrentTexture().createView(), + loadOp: 'clear', + storeOp: 'store', + clearValue: { r: 0, g: 0, b: 0, a: 1 }, + }], + }); + + passEncoder.setPipeline(this._gpuPipeline); + passEncoder.setBindGroup(0, this._gpuBindGroup); + passEncoder.draw(4); + passEncoder.end(); + + this._gpuDevice.queue.submit([commandEncoder.finish()]); + + const renderEndTime = performance.now(); + const renderDelay = renderEndTime - renderStartTime; + + // Track render delay (time to process and submit frame to GPU) + this._renderDelaySamples.push(renderDelay); + // Keep only last 60 samples for smooth average + if (this._renderDelaySamples.length > 60) { + this._renderDelaySamples.shift(); + } + // Update average render delay + this._renderDelayMs = Math.round( + this._renderDelaySamples.reduce((a, b) => a + b, 0) / this._renderDelaySamples.length * 100 + ) / 100; // Round to 2 decimal places + + // Queue metadata frames with actual render timing + this._player._channels.input.queueMetadataFrame({ + serverDataKey: frame.timestamp as number, + firstFramePacketArrivalTimeMs: frame.timestamp as number, + frameSubmittedTimeMs: renderStartTime, + frameDecodedTimeMs: renderStartTime, + frameRenderedTimeMs: renderEndTime, + }) + + frame.close(); + this._frameQueueSize--; + + } catch (error) { + console.error('Error processing video frame:', error); + } finally { + this._isProcessingFrame = false; + + // Continue processing next frame immediately without vsync delay for low-latency streaming + if (this._isActive) { + this.videoLoop(); + } + } + } + + videoLoop() { + this.processVideoData(); + } + + resizeCanvas() { + if (!this._element || !this._containerElement || !this._gpuContext || !this._gpuDevice) { + return + } + + // Get the display size (CSS pixels) + const displayWidth = this._containerElement.clientWidth + const displayHeight = this._containerElement.clientHeight + + // Get device pixel ratio for sharp rendering on high-DPI displays + const devicePixelRatio = window.devicePixelRatio || 1 + + // Calculate the actual canvas size in device pixels + const width = Math.floor(displayWidth * devicePixelRatio) + const height = Math.floor(displayHeight * devicePixelRatio) + + // Check if canvas needs resizing + if (this._element.width !== width || this._element.height !== height) { + this._element.width = width + this._element.height = height + + // Reconfigure the WebGPU context with the new size + const format = navigator.gpu.getPreferredCanvasFormat() + this._gpuContext.configure({ + device: this._gpuDevice, + format, + alphaMode: 'opaque', + usage: GPUTextureUsage.RENDER_ATTACHMENT + }) + + console.log(`Canvas resized to ${width}x${height} (display: ${displayWidth}x${displayHeight}, DPR: ${devicePixelRatio})`) + } + } + + // processVideoMetadata(timestamp, data:VideoFrameCallbackMetadata) { + // if(this._element === undefined) {return} + + // this._element.requestVideoFrameCallback(this.processVideoMetadata.bind(this)) + + // this._player._channels.input.queueMetadataFrame({ + // serverDataKey: data.rtpTimestamp as number, + // firstFramePacketArrivalTimeMs: data.receiveTime as number, + // frameSubmittedTimeMs: data.receiveTime as number, + // frameDecodedTimeMs: data.expectedDisplayTime, + // frameRenderedTimeMs: data.expectedDisplayTime, + // }) + // } + + getElement(){ + return this._element + } + + toggleDebugOverlay(){ + this._overlay.toggleDebug() + } + + getFrameStats() { + return { + droppedFrames: this._droppedFrames, + queueSize: this._frameQueueSize, + isProcessing: this._isProcessingFrame, + renderingFps: this._renderingFps, + renderDelayMs: this._renderDelayMs + } + } + + destroy(){ + const streamHolder = document.getElementById(this._player.getElementId()) + this._isActive = false + const element = streamHolder?.querySelector('canvas') + + // Clean up GPU reader + if(this._gpuReader){ + this._gpuReader.cancel(); + this._gpuReader = undefined; + } + + if(this._resizeObserver){ + this._resizeObserver.disconnect() + this._resizeObserver = null + } + + if(this._gpuVideoTexture){ + this._gpuVideoTexture.destroy(); + this._gpuVideoTexture = null; + } + + if(this._overlay !== undefined){ + this._overlay.destroy() + } + + if(element){ + element.remove() + } + + if(this._element){ + this._element.remove() + } + } +} \ No newline at end of file diff --git a/tools/xcloud-player/greenlight/lib/sdp.ts b/tools/xcloud-player/greenlight/lib/sdp.ts new file mode 100644 index 000000000..e89fb60f4 --- /dev/null +++ b/tools/xcloud-player/greenlight/lib/sdp.ts @@ -0,0 +1,127 @@ +import xCloudPlayer from './player' + +export default class Sdp { + private _player: xCloudPlayer + private _peerConnection:RTCPeerConnection + + constructor(player:xCloudPlayer) { + this._player = player + this._peerConnection = player._peerConnection + } + + getAvailableCodecs():Array<any> { + const capabilities = RTCRtpReceiver.getCapabilities('video')?.codecs + if(capabilities === undefined) {return []} + + const codecs:Array<string> = [] + for(const codec in capabilities) { + if(!codecs.includes(capabilities[codec].mimeType)) {codecs.push(capabilities[codec].mimeType)} + } + + return codecs + } + + // getServerCodecs() { + // for(const sender in this._peerConnection.getSenders()){ + // console.log('Sender:', this._peerConnection.getSenders()[sender]) + // } + // } + + setLocalSDP(sdp:RTCSessionDescriptionInit) { + console.log('[SDP] setLocalSDP() Setting local SDP:', sdp) + + // if(this._player._config.video_bitrate > 0){ + // console.log('[SDP] Set max video bitrate to:', this._player._config.video_bitrate, 'kbps') + // sdp.sdp = this._setBitrate(sdp.sdp, 'video', this._player._config.video_bitrate) + // } + + // if(this._player._config.audio_bitrate > 0){ + // console.log('[SDP] Set max audio bitrate to:', this._player._config.audio_bitrate, 'kbps') + // sdp.sdp = this._setBitrate(sdp.sdp, 'audio', this._player._config.audio_bitrate) + // } + + // if(this._player._config.audio_mono !== true){ + // console.log('[SDP] setLocalSDP() Set audio to stereo') + // sdp.sdp = sdp.sdp?.replace('useinbandfec=1', 'useinbandfec=1; stereo=1') + // } + + sdp.sdp = sdp.sdp?.replace('useinbandfec=1', 'useinbandfec=1; stereo=1') + + return sdp + } + + setRemoteSDP(sdp:string) { + console.log('[SDP] setRemoteSDP() Setting remote SDP:', sdp) + return sdp + } + + getDefaultCodecPreferences() { + const capabilities = RTCRtpReceiver.getCapabilities('video')?.codecs + if(capabilities === undefined) {return []} + + const t1:Array<any> = [] + const t2:Array<any> = [] + const t3:Array<any> = [] + const t4:Array<any> = [] + + for(const codec in capabilities) { + if(capabilities[codec].mimeType.includes('H264')) { + if(capabilities[codec].sdpFmtpLine?.includes('profile-level-id=4d')) { + t1.push(capabilities[codec]) + + } else if(capabilities[codec].sdpFmtpLine?.includes('profile-level-id=42e')) { + t2.push(capabilities[codec]) + + } else if(capabilities[codec].sdpFmtpLine?.includes('profile-level-id=420')) { + t3.push(capabilities[codec]) + } + } else if(capabilities[codec].mimeType.includes('ulpfec')) { + t4.push(capabilities[codec]) + } else if(capabilities[codec].mimeType.includes('flexfec')) { + t4.push(capabilities[codec]) + } else if(capabilities[codec].mimeType.includes('VP9')) { + t4.push(capabilities[codec]) + } else if(capabilities[codec].mimeType.includes('VP8')) { + t4.push(capabilities[codec]) + } + // else { + // t4.push(capabilities[codec]) + // } + } + + const codecOrder = [...t1, ...t2, ...t3, ...t4] + + return codecOrder + } + + _setBitrate(sdp, media, bitrate) { + const lines = sdp.split('\n') + let line = -1 + for(let i=0; i < lines.length; i++) { + if(lines[i].indexOf('m='+media) === 0) { + line = i + break + } + } + if (line === -1) { + console.debug('Could not find the m line for', media) + return sdp + } + line++ + + while(lines[line].indexOf('i=') === 0 || lines[line].indexOf('c=') === 0) { + line++ + } + + if (lines[line].indexOf('b') === 0) { + lines[line] = 'b=AS:'+bitrate + return lines.join('\n') + } + + let newLines = lines.slice(0, line) + newLines.push('b=AS:'+bitrate) + newLines = newLines.concat(lines.slice(line, lines.length)) + + return newLines.join('\n') + } +} \ No newline at end of file diff --git a/tools/xcloud-player/greenlight/lib/stats.ts b/tools/xcloud-player/greenlight/lib/stats.ts new file mode 100644 index 000000000..f6eeaacb3 --- /dev/null +++ b/tools/xcloud-player/greenlight/lib/stats.ts @@ -0,0 +1,107 @@ +import xCloudPlayer from '../player' + +export default class Stats { + private _player: xCloudPlayer + private _peerConnection:RTCPeerConnection + + _videoCodec:string = '' + _audioCodec:string = '' + + _videoWidth:number = 0 + _videoHeight:number = 0 + _videoFps:number = 0 + _rtt:number = 0.0 + + _remoteHost:string = '' + _remotePort:number = 0 + _remoteIsLocal = false + _remoteIsIpv6 = false + _activeRemoteCandidate:string = '' + + constructor(player:xCloudPlayer) { + this._player = player + this._peerConnection = player._peerConnection + + setInterval(this.loop.bind(this), 1000) + } + + loop(){ + this._peerConnection.getStats().then((stats) => { + stats.forEach((report) => { + // @TODO: Implement chat stats using type outbound-rtp + + if(report.type === 'inbound-rtp'){ + this.readInboundRtp(report) + + } else if(report.type === 'candidate-pair' && report.packetsReceived !== undefined && report.packetsReceived > 0){ + this.readCandidatePair(report) + + } else if(report.type === 'remote-candidate'){ + this.readRemoteCandidate(report) + } + }) + }) + } + + readInboundRtp(report:RTCInboundRtpStreamStats){ + if(report.kind === 'video' && report.codecId && report.packetsReceived !== undefined && report.packetsReceived > 0){ + + if(report.codecId.includes('profile-level-id=4d')){ + this._videoCodec = 'H264 (High)' + + } else if(report.codecId.includes('profile-level-id=42e')) { + this._videoCodec = 'H264 (Normal)' + + } else if(report.codecId.includes('profile-level-id=420')) { + this._videoCodec = 'H264 (Low)' + + } else { + this._videoCodec = 'Other ('+report.codecId+')' + } + + if(report.frameWidth){ this._videoWidth = report.frameWidth } + if(report.frameHeight){ this._videoHeight = report.frameHeight } + if(report.framesPerSecond){ this._videoFps = report.framesPerSecond } + // report.jitter + + } else if(report.kind === 'audio' && report.codecId && report.packetsReceived !== undefined && report.packetsReceived > 0){ + this._audioCodec = report.codecId + + // report.jitter + } + } + + readCandidatePair(report){ + this._activeRemoteCandidate = report.remoteCandidateId + this._rtt = report.currentRoundTripTime + } + + readRemoteCandidate(report){ + if(report.id !== this._activeRemoteCandidate) {return} + + if(this.isPrivateIP(report.address)){ + this._remoteIsLocal = true + } else { + this._remoteIsLocal = false + } + + if(this.isIpv6(report.address)){ + this._remoteIsIpv6 = true + } else { + this._remoteIsIpv6 = false + } + + this._remoteHost = report.address + this._remotePort = report.port + } + + isPrivateIP(ip) { + const parts = ip.split('.') + return parts[0] === '10' || (parts[0] === '172' && (parseInt(parts[1], 10) >= 16 && parseInt(parts[1], 10) <= 31)) || (parts[0] === '192' && parts[1] === '168') + } + + isIpv6(ip) { + const parts = ip.split(':') + return parts.length > 3 ? true : false + } +} \ No newline at end of file diff --git a/tools/xcloud-player/greenlight/lib/teredo.ts b/tools/xcloud-player/greenlight/lib/teredo.ts new file mode 100644 index 000000000..1b5108ed3 --- /dev/null +++ b/tools/xcloud-player/greenlight/lib/teredo.ts @@ -0,0 +1,31 @@ +export default class Teredo { + private _address:string + + private _ipv4Address:string + private _ipv4Port:number + + constructor(address:string){ + this._address = address + + const split_str = this._address.split(':') + const ipv4_part = split_str[6] + split_str[7] + const ipv4_port_part = split_str[5] + + const ip_1 =~ parseInt(ipv4_part.substring(0, 2), 16) & 0xFF + const ip_2 =~ parseInt(ipv4_part.substring(2, 4), 16) & 0xFF + const ip_3 =~ parseInt(ipv4_part.substring(4, 6), 16) & 0xFF + const ip_4 =~ parseInt(ipv4_part.substring(6, 8), 16) & 0xFF + const port_1 =~ parseInt(ipv4_port_part.substring(0, 4), 16) & 0xFFFF + + this._ipv4Address = ip_1+'.'+ip_2+'.'+ip_3+'.'+ip_4 + this._ipv4Port = port_1 + } + + getIpv4Address(){ + return this._ipv4Address + } + + getIpv4Port(){ + return this._ipv4Port + } +} \ No newline at end of file diff --git a/tools/xcloud-player/renderer.ts b/tools/xcloud-player/renderer.ts new file mode 100644 index 000000000..222333cc9 --- /dev/null +++ b/tools/xcloud-player/renderer.ts @@ -0,0 +1,385 @@ +// Picture effects for the stream: the video is drawn through WebGL2 shaders onto a canvas over it. +// Base pass: sharpening (CAS / unsharp mask, from Better xCloud by redphx, MIT) plus DroidDeck's colour +// grading (brightness, contrast, saturation, gamma) and deband dither. Extra passes are DroidDeck's own +// screen effects (FXAA, Toon, CRT, NTSC, from Bannerlator). Off (no canvas) when nothing is set. + +export interface Effects { + brightness: number // -50..50 (DroidDeck's slider units) + contrast: number // -100..100 + gamma: number // 0.5..3 + saturation: number // 0..200 % + sharpen: number // 0..10 + sharpenMode: string // 'cas' | 'usm' + deband: number // 0..8 (dither amplitude in 8-bit steps) + fxaa: boolean + toon: boolean + crt: boolean + ntsc: boolean + maxFps: number // 0 = every frame +} + +export function needsRenderer(e: Effects): boolean { + return e.brightness !== 0 || e.contrast !== 0 || Math.abs(e.gamma - 1) > 0.001 || e.saturation !== 100 || + e.sharpen > 0 || e.deband > 0 || e.fxaa || e.toon || e.crt || e.ntsc || e.maxFps > 0 +} + +const VERTEX_300 = `#version 300 es +in vec2 position; +out vec2 vUV; +void main() { vUV = position * 0.5 + 0.5; gl_Position = vec4(position, 0.0, 1.0); }` + +const VERTEX_100 = `attribute vec2 position; +varying vec2 vUV; +void main() { vUV = position * 0.5 + 0.5; gl_Position = vec4(position, 0.0, 1.0); }` + +const BASE = `#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); +}` + +// Screen-effect shaders from Bannerlator (GLSL ES 1.00), unchanged. +const FXAA = `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; +}` + +const TOON = `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); +}` + +const CRT = `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); +}` + +const NTSC = `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); +}` + +interface Pass { program: WebGLProgram, uniforms: Map<string, WebGLUniformLocation | null> } + +export class EffectRenderer { + private canvas: HTMLCanvasElement + private gl: WebGL2RenderingContext + private passes = new Map<string, Pass>() + private videoTexture: WebGLTexture + private targets: { framebuffer: WebGLFramebuffer, texture: WebGLTexture }[] = [] + private width = 0 + private height = 0 + private frame = 0 + private lastDraw = 0 + private stopped = false + private idle = false + private pending = false + + constructor(private video: HTMLVideoElement, private effects: Effects) { + this.canvas = document.createElement('canvas') + Object.assign(this.canvas.style, { position: 'absolute', inset: '0', width: '100%', height: '100%', objectFit: 'contain', zIndex: '1' }) + video.parentElement?.appendChild(this.canvas) + const gl = this.canvas.getContext('webgl2', { alpha: false, antialias: false, depth: false, desynchronized: true, powerPreference: 'high-performance' }) + if (gl === null) throw new Error('WebGL2 unavailable') + this.gl = gl + const buffer = gl.createBuffer() + gl.bindBuffer(gl.ARRAY_BUFFER, buffer) + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW) + this.passes.set('base', this.compile(VERTEX_300, BASE, ['screenTexture', 'resolution', 'filterId', 'sharpenFactor', 'brightness', 'contrast', 'gamma', 'saturation', 'deband'])) + this.videoTexture = this.texture() + this.schedule() + } + + update(effects: Effects) { this.effects = effects } + + // Turning effects off hides the canvas but keeps the GL context: losing and recreating it can stall the stream. + pause() { if (this.idle || this.stopped) return; this.idle = true; this.canvas.style.display = 'none' } + + resume(effects: Effects) { + this.effects = effects + if (!this.idle || this.stopped) return + this.idle = false + this.canvas.style.display = '' + this.schedule() + } + + private schedule() { + if (this.pending) return + this.pending = true + this.video.requestVideoFrameCallback(this.onFrame) + } + + private compile(vertex: string, fragment: string, names: string[]): Pass { + const gl = this.gl + const shader = (type: number, source: string) => { + const s = gl.createShader(type)! + gl.shaderSource(s, source) + gl.compileShader(s) + if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(`shader: ${gl.getShaderInfoLog(s)}`) + return s + } + const program = gl.createProgram()! + gl.attachShader(program, shader(gl.VERTEX_SHADER, vertex)) + gl.attachShader(program, shader(gl.FRAGMENT_SHADER, fragment)) + gl.bindAttribLocation(program, 0, 'position') + gl.linkProgram(program) + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) throw new Error(`program: ${gl.getProgramInfoLog(program)}`) + return { program, uniforms: new Map(names.map(name => [name, gl.getUniformLocation(program, name)])) } + } + + private extra(name: string): Pass { + let pass = this.passes.get(name) + if (pass === undefined) { + const source = { fxaa: FXAA, toon: TOON, crt: CRT, ntsc: NTSC }[name]! + pass = this.compile(VERTEX_100, source, ['screenTexture', 'resolution', 'TextureSize', 'FrameCount']) + this.passes.set(name, pass) + } + return pass + } + + private texture(): WebGLTexture { + const gl = this.gl + const texture = gl.createTexture()! + gl.bindTexture(gl.TEXTURE_2D, texture) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE) + return texture + } + + private resize(width: number, height: number) { + const gl = this.gl + this.width = width; this.height = height + this.canvas.width = width; this.canvas.height = height + for (const target of this.targets) { gl.deleteFramebuffer(target.framebuffer); gl.deleteTexture(target.texture) } + this.targets = [0, 1].map(() => { + const texture = this.texture() + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null) + const framebuffer = gl.createFramebuffer()! + gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer) + gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0) + return { framebuffer, texture } + }) + gl.bindFramebuffer(gl.FRAMEBUFFER, null) + } + + private onFrame = (now: number) => { + this.pending = false + if (this.stopped || this.idle) return + this.schedule() + const max = this.effects.maxFps + if (max > 0 && now - this.lastDraw < 1000 / max - 2) return + this.lastDraw = now + try { this.draw() } catch (error) { (window as any).DDCloud?.log(`effects: ${String(error).slice(0, 200)}`); this.destroy() } + } + + private draw() { + const gl = this.gl + const e = this.effects + const width = this.video.videoWidth, height = this.video.videoHeight + if (width === 0 || height === 0) return + if (width !== this.width || height !== this.height) this.resize(width, height) + this.frame++ + + gl.activeTexture(gl.TEXTURE0) + gl.bindTexture(gl.TEXTURE_2D, this.videoTexture) + gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true) + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, this.video) + + const extras = ['fxaa', 'toon', 'crt', 'ntsc'].filter(name => (e as any)[name]) + gl.viewport(0, 0, width, height) + gl.enableVertexAttribArray(0) + gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0) + + const base = this.passes.get('base')! + gl.bindFramebuffer(gl.FRAMEBUFFER, extras.length > 0 ? this.targets[0].framebuffer : null) + gl.useProgram(base.program) + const u = base.uniforms + gl.uniform1i(u.get('screenTexture')!, 0) + gl.uniform2f(u.get('resolution')!, width, height) + gl.uniform1i(u.get('filterId')!, e.sharpenMode === 'usm' ? 1 : 2) + gl.uniform1f(u.get('sharpenFactor')!, e.sharpen) + gl.uniform1f(u.get('brightness')!, e.brightness / 100) + gl.uniform1f(u.get('contrast')!, e.contrast / 100) + gl.uniform1f(u.get('gamma')!, Math.min(5, Math.max(0.1, e.gamma))) + gl.uniform1f(u.get('saturation')!, e.saturation / 100) + gl.uniform1f(u.get('deband')!, e.deband) + gl.drawArrays(gl.TRIANGLES, 0, 3) + + extras.forEach((name, index) => { + const pass = this.extra(name) + const source = this.targets[index % 2] + const last = index === extras.length - 1 + gl.bindFramebuffer(gl.FRAMEBUFFER, last ? null : this.targets[(index + 1) % 2].framebuffer) + gl.useProgram(pass.program) + gl.bindTexture(gl.TEXTURE_2D, source.texture) + const pu = pass.uniforms + gl.uniform1i(pu.get('screenTexture') ?? null, 0) + gl.uniform2f(pu.get('resolution') ?? null, width, height) + gl.uniform2f(pu.get('TextureSize') ?? null, width, height) + gl.uniform1i(pu.get('FrameCount') ?? null, this.frame) + gl.drawArrays(gl.TRIANGLES, 0, 3) + }) + } + + destroy() { + this.stopped = true + this.canvas.remove() + this.gl.getExtension('WEBGL_lose_context')?.loseContext() + } +} diff --git a/tools/xcloud-player/sdp-tweaks.ts b/tools/xcloud-player/sdp-tweaks.ts new file mode 100644 index 000000000..b500b52fa --- /dev/null +++ b/tools/xcloud-player/sdp-tweaks.ts @@ -0,0 +1,34 @@ +// Offer tweaks after Better xCloud by redphx (MIT, src/utils/sdp.ts): prefer an H.264 profile, cap bitrate. + +/** Moves the H.264 payloads of the chosen profile to the front of the video m-line. */ +export function preferH264Profile(sdp: string, profile: 'high' | 'normal' | 'low'): string { + const prefix = profile === 'high' ? '4d' : (profile === 'low' ? '420' : '42e') + const preferred: string[] = [] + for (const match of sdp.matchAll(/a=fmtp:(\d+).*profile-level-id=([0-9a-f]{6})/g)) { + if (match[2].startsWith(prefix)) preferred.push(match[1]) + } + if (preferred.length === 0) return sdp + const lines = sdp.split('\r\n') + const index = lines.findIndex(line => line.startsWith('m=video')) + if (index < 0) return sdp + const parts = lines[index].trim().split(' ') + const ids = parts.slice(3).filter(id => !preferred.includes(id)) + lines[index] = parts.slice(0, 3).concat(preferred, ids).join(' ') + return lines.join('\r\n') +} + +/** Sets b=AS (kbit/s) on the video m-section. */ +export function capVideoBitrate(sdp: string, kbps: number): string { + const lines = sdp.split('\r\n') + const start = lines.findIndex(line => line.startsWith('m=video')) + if (start < 0) return sdp + const bLine = `b=AS:${kbps}` + for (let i = start + 1; i <= lines.length; i++) { + const line = lines[i] + if (line === undefined || line.startsWith('m=')) { lines.splice(i, 0, bLine); break } + if (line.startsWith('i=') || line.startsWith('c=')) continue + if (line.startsWith('b=AS:')) { lines[i] = bLine; break } + lines.splice(i, 0, bLine); break + } + return lines.join('\r\n') +} diff --git a/tools/xcloud-player/streamstats.ts b/tools/xcloud-player/streamstats.ts new file mode 100644 index 000000000..04234a020 --- /dev/null +++ b/tools/xcloud-player/streamstats.ts @@ -0,0 +1,68 @@ +// Performance overlay in the top-right or top-left corner, from WebRTC getStats. Levels: 1 fps; 2 + ping, resolution; +// 3 + bitrate, decode time, packet loss; 4 + codec, jitter, dropped frames, server. Horizontal or vertical. + +export class StreamStats { + private element: HTMLDivElement + private timer: ReturnType<typeof setInterval> | undefined + private level = 0 + private vertical = false + private left = false + private server = '' + private last = { bytes: 0, time: 0, decode: 0, frames: 0, lost: 0, received: 0 } + + constructor(private pc: RTCPeerConnection) { + 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', + pointerEvents: 'none', display: 'none', whiteSpace: 'pre', textAlign: 'right', + }) + document.body.appendChild(this.element) + } + + show(level: number, vertical: boolean, server: string, left = false) { + this.level = level; this.vertical = vertical; this.server = server; this.left = left + // Top left keeps clear of the clock Xbox draws top right while its guide is open. + Object.assign(this.element.style, left ? { left: '8px', right: '', textAlign: 'left' } : { left: '', right: '8px', textAlign: 'right' }) + this.element.style.display = level > 0 ? 'block' : 'none' + if (level > 0 && this.timer === undefined) { this.timer = setInterval(() => this.update(), 1000); this.update() } + if (level === 0 && this.timer !== undefined) { clearInterval(this.timer); this.timer = undefined } + } + + private async update() { + const report = await this.pc.getStats() + let video: any, pair: any + const codecs = new Map<string, any>() + report.forEach((s: any) => { + if (s.type === 'inbound-rtp' && s.kind === 'video') video = s + else if (s.type === 'candidate-pair' && s.state === 'succeeded' && s.nominated) pair = s + else if (s.type === 'codec') codecs.set(s.id, s) + }) + if (video === undefined) return + const seconds = this.last.time > 0 ? (video.timestamp - this.last.time) / 1000 : 0 + const mbps = seconds > 0 ? (video.bytesReceived - this.last.bytes) * 8 / seconds / 1e6 : 0 + const frames = (video.framesDecoded ?? 0) - this.last.frames + const decodeMs = frames > 0 ? ((video.totalDecodeTime ?? 0) - this.last.decode) * 1000 / frames : 0 + const lost = (video.packetsLost ?? 0) - this.last.lost + const received = (video.packetsReceived ?? 0) - this.last.received + const loss = lost + received > 0 ? lost * 100 / (lost + received) : 0 + this.last = { bytes: video.bytesReceived, time: video.timestamp, decode: video.totalDecodeTime ?? 0, + frames: video.framesDecoded ?? 0, lost: video.packetsLost ?? 0, received: video.packetsReceived ?? 0 } + + const items: string[] = ['web', `${Math.round(video.framesPerSecond ?? 0)} fps`] + if (this.level >= 2) { + if (pair?.currentRoundTripTime !== undefined) items.push(`ping ${Math.round(pair.currentRoundTripTime * 1000)} ms`) + items.push(`${video.frameWidth ?? '?'}x${video.frameHeight ?? '?'}`) + } + if (this.level >= 3) items.push(`${mbps.toFixed(1)} Mb/s`, `decode ${decodeMs.toFixed(1)} ms`, `loss ${loss.toFixed(1)}%`) + if (this.level >= 4) { + const codec = codecs.get(video.codecId) + if (codec) items.push(String(codec.mimeType ?? '').replace('video/', '')) + items.push(`jitter ${Math.round((video.jitter ?? 0) * 1000)} ms`, `dropped ${video.framesDropped ?? 0}`) + if (this.server) items.push(this.server) + } + this.element.textContent = items.join(this.vertical ? '\n' : ' · ') + } + + destroy() { this.show(0, this.vertical, this.server, this.left); this.element.remove() } +} diff --git a/tools/xcloud-player/uuid.ts b/tools/xcloud-player/uuid.ts new file mode 100644 index 000000000..b13c23dfe --- /dev/null +++ b/tools/xcloud-player/uuid.ts @@ -0,0 +1,2 @@ +// Stand-in for the "uuid" package (only v4 is used by the Greenlight player). +export function v4(): string { return crypto.randomUUID() }