mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-16 20:27:07 -04:00
Compare commits
74
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df88872c98 | ||
|
|
5109fa4839 | ||
|
|
3c64c6b3b8 | ||
|
|
f5c343bde0 | ||
|
|
6626cf99b7 | ||
|
|
1d18b43bc2 | ||
|
|
62502877fc | ||
|
|
d62b96ac15 | ||
|
|
fd11ed82c4 | ||
|
|
39ff0a653d | ||
|
|
6a175f1097 | ||
|
|
cc981aa69f | ||
|
|
41422407c4 | ||
|
|
b323a0b33e | ||
|
|
0849a7c18e | ||
|
|
889e783520 | ||
|
|
90a8a526d5 | ||
|
|
1580e3ee64 | ||
|
|
935b70c585 | ||
|
|
bf811040ae | ||
|
|
272cd60b19 | ||
|
|
a844483080 | ||
|
|
1d1de10b2d | ||
|
|
7ab0c12c97 | ||
|
|
cd85eb1624 | ||
|
|
dd2b2b08c3 | ||
|
|
97a0f1553f | ||
|
|
78e4cb8369 | ||
|
|
64d5f92391 | ||
|
|
3dbc4ecc6d | ||
|
|
2f1e0a1348 | ||
|
|
ff9807ad29 | ||
|
|
37b6f336a7 | ||
|
|
1a527e8ed1 | ||
|
|
1379396b99 | ||
|
|
e835f5c523 | ||
|
|
4748a217b3 | ||
|
|
96d8678dd3 | ||
|
|
e0b0af4290 | ||
|
|
8940b0ca32 | ||
|
|
cb2f7c0109 | ||
|
|
a5f608b9d7 | ||
|
|
236eeddeec | ||
|
|
3a90ac0665 | ||
|
|
dd4c4a294c | ||
|
|
a0bd480576 | ||
|
|
01c7248965 | ||
|
|
b6903db58c | ||
|
|
33caa8a57c | ||
|
|
fbd068c6b8 | ||
|
|
35dfd99013 | ||
|
|
6ca3052eb7 | ||
|
|
2d70341214 | ||
|
|
5a1e5deba9 | ||
|
|
a2433f1965 | ||
|
|
f9db495e3f | ||
|
|
fdb9cbb7a5 | ||
|
|
79e1d30d6c | ||
|
|
a4841dff9d | ||
|
|
21aeab0126 | ||
|
|
fd0c6f124f | ||
|
|
9f5a2f39cd | ||
|
|
61a9a48e70 | ||
|
|
9c0f87a4c8 | ||
|
|
b5ae6cc63f | ||
|
|
4bcfca58e2 | ||
|
|
0c59f551af | ||
|
|
0687e3477e | ||
|
|
f467bb22e8 | ||
|
|
dad1aa2dc7 | ||
|
|
9f71b427bf | ||
|
|
0091b751f4 | ||
|
|
05097b0eea | ||
|
|
401a650b5b |
No files matched your search
@@ -1,101 +0,0 @@
|
||||
name: Build desktop package (KDE Plasma)
|
||||
|
||||
# The desktop the app installs into its Linux runtime on first use: KDE Plasma as SteamOS's desktop
|
||||
# mode has it, the closure of tools/desktop-kde/seeds.txt over Arch Linux ARM minus what the runtime
|
||||
# already carries, plus tools/desktop-kde/overlay (tools/desktop-kde/build.sh), every package
|
||||
# signature-checked. Published as a release in Droid-Deck/DroidDeck-Components with DroidDeck's
|
||||
# other components; the apk pins it in tools/desktop-kde/release.env (tag, sha256, size). A
|
||||
# published tag is never rebuilt: the packages are Arch Linux ARM's current ones, so a rebuild
|
||||
# would not match the pinned sha256. A changed package gets a new tag (desktop-kde-r2, ...) and a
|
||||
# release.env update.
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'tools/desktop-kde/**'
|
||||
- '.github/workflows/build-desktop-kde.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag to publish as (e.g. desktop-kde-r1); empty = artifact only'
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
kwin:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build KWin with nested desktop scaling
|
||||
run: |
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq zstd xz-utils gpgv patch binutils >/dev/null
|
||||
bash tools/desktop-kde/build-kwin.sh out/kwin
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop-kde-kwin
|
||||
retention-days: 7
|
||||
path: out/kwin/
|
||||
build:
|
||||
needs: kwin
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: desktop-kde-kwin
|
||||
path: out/kwin
|
||||
- uses: actions/create-github-app-token@v2
|
||||
id: components-token
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.tag != ''
|
||||
with:
|
||||
app-id: ${{ vars.DROIDDECK_CI_APP_ID }}
|
||||
private-key: ${{ secrets.DROIDDECK_CI_APP_KEY }}
|
||||
owner: Droid-Deck
|
||||
repositories: DroidDeck-Components
|
||||
- name: Refuse a published tag
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.tag != ''
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.components-token.outputs.token }}
|
||||
run: |
|
||||
if gh release view '${{ inputs.tag }}' -R Droid-Deck/DroidDeck-Components >/dev/null 2>&1; then
|
||||
echo "::error::${{ inputs.tag }} is already published, and published tags are never replaced: use a new tag"
|
||||
exit 1
|
||||
fi
|
||||
- name: Build
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq zstd xz-utils gpgv >/dev/null
|
||||
KDE_KWIN_OVERRIDE="$PWD/out/kwin" tools/desktop-kde/build.sh out
|
||||
cp out/kwin/desktop-kde.kwin.txt out/
|
||||
echo "size $(stat -c %s out/desktop-kde.tar.zst) sha256 $(cat out/desktop-kde.sha256)"
|
||||
# The package itself too, briefly: enough to try a build on a device before publishing it.
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop-kde
|
||||
retention-days: 7
|
||||
compression-level: 0
|
||||
path: |
|
||||
out/desktop-kde.tar.zst
|
||||
out/desktop-kde.packages.txt
|
||||
out/desktop-kde.kwin.txt
|
||||
out/desktop-kde.sha256
|
||||
- name: Publish
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.tag != ''
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.components-token.outputs.token }}
|
||||
GH_REPO: Droid-Deck/DroidDeck-Components
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG='${{ inputs.tag }}'
|
||||
if gh release view "$TAG" >/dev/null 2>&1; then
|
||||
echo "::error::$TAG is already published in DroidDeck-Components, and published tags are never replaced: use a new tag"
|
||||
exit 1
|
||||
fi
|
||||
gh release create "$TAG" --latest=false --prerelease=false --title "$TAG" \
|
||||
--notes "KDE Plasma desktop package for DroidDeck's Linux runtime ($GITHUB_REPOSITORY tools/desktop-kde, $GITHUB_SHA). size $(stat -c %s out/desktop-kde.tar.zst), sha256 $(cat out/desktop-kde.sha256). desktop-kde.packages.txt lists the signature-checked Arch Linux ARM packages it was built from." \
|
||||
out/desktop-kde.tar.zst out/desktop-kde.sha256 out/desktop-kde.packages.txt out/desktop-kde.kwin.txt
|
||||
gh release edit "$TAG" --latest=false --prerelease=false
|
||||
@@ -0,0 +1,49 @@
|
||||
name: Build wlroots (patched)
|
||||
|
||||
# The desktop's wlroots (Arch Linux ARM's wlroots0.20, what the desktop package's labwc links)
|
||||
# rebuilt with tools/wlroots/patches: a Vulkan DMA-BUF allocator for the Adreno node, which is not
|
||||
# a DRM device, so labwc's vulkan and gles2 renderers can start. Built in an Arch Linux ARM
|
||||
# container on an arm64 runner and published as a release asset the apk build fetches
|
||||
# (tools/wlroots/release.env names the tag and sha). The hosted desktop package is never changed.
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'tools/wlroots/**'
|
||||
- '.github/workflows/build-wlroots.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag to publish as (e.g. wlroots-0.20.2-p1); empty = artifact only'
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build in Arch Linux ARM
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker run --rm -v "$PWD:/work" -w /work menci/archlinuxarm:base-devel bash tools/wlroots/build-in-arch.sh
|
||||
sudo chown -R "$(id -u):$(id -g)" .
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: wlroots-patched
|
||||
path: |
|
||||
wlroots.tzst
|
||||
wlroots.tzst.sha256
|
||||
- name: Publish
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.tag != ''
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh release create "${{ inputs.tag }}" --latest=false --prerelease=false --title "${{ inputs.tag }}" --notes "wlroots 0.20.2 with tools/wlroots/patches (Vulkan DMA-BUF allocator for non-DRM GPU nodes); staged by the apk at /usr/local/lib/droiddeck-wlroots for the desktop's GPU renderers. sha256 $(cut -d' ' -f1 wlroots.tzst.sha256)" wlroots.tzst wlroots.tzst.sha256 || gh release upload "${{ inputs.tag }}" wlroots.tzst wlroots.tzst.sha256 --clobber
|
||||
# A component the apk fetches by tag, never something users install: it must not
|
||||
# become the repo's Latest release or appear as a pre-release, also when re-uploaded.
|
||||
gh release edit "${{ inputs.tag }}" --latest=false --prerelease=false
|
||||
+88
-59
@@ -44,19 +44,6 @@ jobs:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check co-authors
|
||||
if: github.event_name == 'pull_request'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch --no-tags origin main
|
||||
base=$(git merge-base origin/main HEAD)
|
||||
messages=$(git log --format=%B "$base..HEAD")
|
||||
messages+=$'\n'"$(jq -r '.pull_request.title, (.pull_request.body // "")' "$GITHUB_EVENT_PATH")"
|
||||
if grep -Eiq '^[[:space:]]*Co-authored-by:.*(Claude|Anthropic|OpenAI|ChatGPT|GPT|Copilot|Cursor|Gemini|Codex|Devin|Windsurf|Codeium|Amazon Q|Tabnine|Cody|\[bot\])' <<<"$messages"; then
|
||||
echo "::error::AI and bot identities cannot be co-authors; remove the Co-authored-by trailer."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: The packages a signed build ships as
|
||||
id: variants
|
||||
run: echo "list=$(awk '!/^#/ && NF { print $1 }' tools/release/variants.txt | jq -R . | jq -sc .)" >> "$GITHUB_OUTPUT"
|
||||
@@ -186,7 +173,7 @@ jobs:
|
||||
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', '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/linuxfs/debian-image.env') }}
|
||||
key: preloads-${{ env.PRELOAD_CACHE_VERSION }}-${{ hashFiles('app/src/main/cpp/fakeinput_steam.cpp', '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/**') }}
|
||||
|
||||
- name: Build libfakeinput.so and libblsession.so (glibc aarch64)
|
||||
if: steps.preloads.outputs.cache-hit != 'true'
|
||||
@@ -208,9 +195,6 @@ jobs:
|
||||
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"
|
||||
# Force-SSBS adapter, preloaded into a game's Wine only when the game asks for it.
|
||||
aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -pthread \
|
||||
-o "$D/libssbs.so" tools/linuxfs/ssbs/ssbs_adapter.c -ldl
|
||||
@@ -232,6 +216,14 @@ jobs:
|
||||
test -f "$D/usr/local/bin/gamescope"
|
||||
rm gamescope.tzst
|
||||
fi
|
||||
if [ -f tools/wlroots/release.env ]; then
|
||||
. tools/wlroots/release.env
|
||||
gh release download "${WLROOTS_TAG}" -R "${GITHUB_REPOSITORY}" -p wlroots.tzst -O wlroots.tzst
|
||||
echo "${WLROOTS_SHA256} wlroots.tzst" | sha256sum -c -
|
||||
tar --use-compress-program=unzstd -xf wlroots.tzst -C "$D"
|
||||
test -f "$D/usr/local/lib/droiddeck-wlroots/libwlroots-0.20.so"
|
||||
rm wlroots.tzst
|
||||
fi
|
||||
. tools/linuxfs/uruntime.env
|
||||
curl -fsSL --retry 3 -o uruntime "https://github.com/VHSgunzo/uruntime/releases/download/${URUNTIME_VERSION}/${URUNTIME_ASSET}"
|
||||
echo "${URUNTIME_SHA256} uruntime" | sha256sum -c -
|
||||
@@ -270,13 +262,10 @@ jobs:
|
||||
done
|
||||
install -m644 msi-pkgs/usr/bin/msiinfo "$T/msiinfo"
|
||||
install -m644 msi-pkgs/usr/bin/cabextract "$T/cabextract"
|
||||
# 7-Zip (the full build with its format plugin) opens installer .exe wrappers.
|
||||
install -m644 msi-pkgs/usr/lib/7zip/7z "$T/7z"
|
||||
install -m644 msi-pkgs/usr/lib/7zip/7z.so "$T/7z.so"
|
||||
for so in libmsi-1.0.so.0 libgsf-1.so.114 libgcab-1.0.so.0; do
|
||||
cp -L "msi-pkgs/usr/lib/$so" "$T/$so"
|
||||
done
|
||||
for f in NOTICE GPL-2 GPL-3 LGPL-2.1 7zip-License 7zip-unRarLicense; do
|
||||
for f in NOTICE GPL-2 GPL-3 LGPL-2.1; do
|
||||
install -Dm644 "tools/msitools/licenses/$f" "$D/usr/local/share/licenses/droiddeck-msitools/$f"
|
||||
done
|
||||
rm -rf msi-pkgs
|
||||
@@ -284,42 +273,17 @@ jobs:
|
||||
install -Dm644 tools/linuxfs/desktop/droiddeck-desktop "$D/usr/local/bin/droiddeck-desktop"
|
||||
install -Dm644 tools/linuxfs/desktop/droiddeck-gpu "$D/usr/local/bin/droiddeck-gpu"
|
||||
install -Dm644 tools/linuxfs/desktop/droiddeck-desktop-gpu "$D/usr/local/bin/droiddeck-desktop-gpu"
|
||||
install -Dm644 tools/linuxfs/desktop/kwin_wayland_wrapper "$D/usr/local/bin/kwin_wayland_wrapper"
|
||||
install -Dm644 tools/linuxfs/desktop/autostart "$D/etc/xdg/labwc/autostart"
|
||||
install -Dm644 tools/linuxfs/desktop/rc.xml "$D/etc/xdg/labwc/rc.xml"
|
||||
install -Dm644 tools/linuxfs/desktop/panel.conf "$D/etc/xdg/lxqt/panel.conf"
|
||||
install -Dm644 tools/linuxfs/desktop/firefox-droiddeck.js "$D/usr/lib/firefox/defaults/pref/droiddeck.js"
|
||||
ls -lR "$D"
|
||||
|
||||
# The Debian 11 image the preloads are compiled in comes from a release on this repo
|
||||
# (published once by ci-debian-image.yml, pinned in tools/linuxfs/debian-image.env): the
|
||||
# public registry mirrors rate-limit the shared runner addresses and failed builds on a
|
||||
# busy evening. Only if that download fails is the image pulled, with retries.
|
||||
- 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 '
|
||||
docker run --rm -v "$PWD:/src" -w /src debian:bullseye bash -c '
|
||||
set -euo pipefail
|
||||
printf "deb http://archive.debian.org/debian bullseye main\ndeb http://archive.debian.org/debian-security bullseye-security main\n" > /etc/apt/sources.list
|
||||
apt-get -o Acquire::Check-Valid-Until=false update -qq
|
||||
@@ -383,9 +347,8 @@ jobs:
|
||||
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$'
|
||||
mv pulseaudio.tzst app/src/main/assets/pulseaudio.tzst
|
||||
tar --use-compress-program=unzstd -tf app/src/main/assets/pulseaudio.tzst | grep -c '\.so$'
|
||||
rm -rf pa-bundle sink-out
|
||||
- name: Cache proot
|
||||
id: proot
|
||||
@@ -405,7 +368,42 @@ jobs:
|
||||
- name: Check the preloads
|
||||
run: |
|
||||
set -euo pipefail
|
||||
bash tools/linuxfs/check-preloads.sh app/src/main/assets/linuxfs
|
||||
D=app/src/main/assets/linuxfs
|
||||
NEED=$(readelf -d "$D/libfakeinput.so" | sed -n 's/.*NEEDED.*\[\(.*\)\]/\1/p')
|
||||
for bad in libstdc++.so.6 libgcc_s.so.1; do
|
||||
if printf '%s\n' "$NEED" | grep -qx "$bad"; then
|
||||
echo "::error::libfakeinput.so links $bad - the C++ runtime must stay static"; exit 1
|
||||
fi
|
||||
done
|
||||
syms() { readelf -Ws "$1" | awk '$4 == "FUNC" && $5 == "GLOBAL" {sub(/@.*/, "", $8); print $8}'; }
|
||||
FAKE=$(syms "$D/libfakeinput.so")
|
||||
for sym in open openat ioctl read close poll ppoll select stat fstat access scandir; do
|
||||
grep -qx "$sym" <<<"$FAKE" || { echo "::error::libfakeinput.so does not export $sym"; exit 1; }
|
||||
done
|
||||
# The static C++ runtime stays private: every session process preloads this library,
|
||||
# and an exported personality routine takes over other programs' unwinding (shadPS4
|
||||
# aborted in pthread_exit).
|
||||
if grep -qxE '__gxx_personality_v0|__cxa_throw|_Unwind_Resume' <<<"$FAKE"; then
|
||||
echo "::error::libfakeinput.so exports the C++ runtime"; exit 1
|
||||
fi
|
||||
SESSION=$(syms "$D/libblsession.so")
|
||||
for sym in socket bind getsockname setsockopt statfs statvfs syscall shm_open shm_unlink mmap mmap64 munmap drmPrimeFDToHandle drmCloseBufferHandle; do
|
||||
grep -qx "$sym" <<<"$SESSION" || { echo "::error::libblsession.so does not export $sym"; exit 1; }
|
||||
done
|
||||
# Zink's GEM_CLOSE is caught at ioctl(); an exported drmIoctl made NMS replay old frames.
|
||||
if grep -qx drmIoctl <<<"$SESSION"; then
|
||||
echo "::error::libblsession.so exports drmIoctl"; exit 1
|
||||
fi
|
||||
SSBS=$(syms "$D/libssbs.so")
|
||||
for sym in sigaction signal bsd_signal sysv_signal; do
|
||||
grep -qx "$sym" <<<"$SSBS" || { echo "::error::libssbs.so does not export $sym"; exit 1; }
|
||||
done
|
||||
test -f "$D/usr/local/bin/droiddeck-session"
|
||||
test -f "$D/usr/local/bin/droiddeck-proton-extra"
|
||||
test -f "$D/usr/local/bin/droiddeck-msi-install"
|
||||
for f in msiinfo cabextract libmsi-1.0.so.0 libgsf-1.so.114 libgcab-1.0.so.0; do
|
||||
test -f "$D/usr/local/lib/droiddeck-msitools/$f" || { echo "::error::droiddeck-msitools/$f missing"; exit 1; }
|
||||
done
|
||||
|
||||
# The store download engines (app/src/main/rust/stores) are Rust, built by Gradle's
|
||||
# buildRustStores task through cargo-ndk. rustup and cargo are on the runner image; the Android
|
||||
@@ -454,13 +452,10 @@ jobs:
|
||||
id: build
|
||||
env:
|
||||
DROIDDECK_BUILD_LABEL: ${{ steps.build-label.outputs.label }}
|
||||
SGDB_API_KEY: ${{ secrets.SGDB_API_KEY }}
|
||||
run: |
|
||||
set -o pipefail
|
||||
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
|
||||
python3 tools/release/runtime_inputs.py apk app/build/outputs/apk/release/app-release.apk
|
||||
|
||||
- name: Check the store engines' library
|
||||
# The exports Kotlin binds must be in the apk's libdroiddeckstores.so, or the stores fail on
|
||||
@@ -469,8 +464,21 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
NDK="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"
|
||||
python3 tools/release/check_android_libraries.py app/build/outputs/apk/release/app-release.apk \
|
||||
--readelf "$NDK/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-readelf"
|
||||
NM="$NDK/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-nm"
|
||||
SO=app/build/rustJniLibs/arm64-v8a/libdroiddeckstores.so
|
||||
test -s "$SO"
|
||||
ls -la "$SO"
|
||||
unzip -l app/build/outputs/apk/release/app-release.apk | grep -q 'lib/arm64-v8a/libdroiddeckstores.so' \
|
||||
|| { echo "::error::libdroiddeckstores.so is not in the apk"; exit 1; }
|
||||
for sym in \
|
||||
Java_com_droiddeck_launcher_stores_StoresNative_nativeVersion \
|
||||
Java_com_droiddeck_launcher_stores_gog_GogNative_nativeStart \
|
||||
Java_com_droiddeck_launcher_stores_epic_EpicNative_nativeStart \
|
||||
Java_com_droiddeck_launcher_stores_epic_EpicNative_nativeAssemble \
|
||||
Java_com_droiddeck_launcher_stores_amazon_AmazonNative_nativeStart; do
|
||||
"$NM" -D --defined-only "$SO" | grep -q " ${sym}\$" \
|
||||
|| { echo "::error::libdroiddeckstores.so does not export $sym"; exit 1; }
|
||||
done
|
||||
git diff --stat -- app/src/main/rust/stores/Cargo.lock
|
||||
git diff --exit-code -- app/src/main/rust/stores/Cargo.lock \
|
||||
|| { echo "::error::Cargo.lock changed during the build; commit the regenerated lockfile (uploaded as stores-cargo-lock)"; exit 1; }
|
||||
@@ -501,6 +509,27 @@ jobs:
|
||||
- name: Unit tests
|
||||
run: ./gradlew testReleaseUnitTest --console=plain -PndkVersion="$NDK_VERSION"
|
||||
|
||||
- name: Check the native dependency closure
|
||||
run: |
|
||||
set -euo pipefail
|
||||
APK=$(find app/build/outputs/apk -name '*.apk' | head -1)
|
||||
WORK=$(mktemp -d)
|
||||
unzip -q "$APK" 'lib/arm64-v8a/*' -d "$WORK"
|
||||
cd "$WORK/lib/arm64-v8a"
|
||||
SYSTEM="libc.so libm.so libdl.so liblog.so libandroid.so libz.so libvulkan.so libstdc++.so
|
||||
libGLESv2.so libEGL.so libnativewindow.so libjnigraphics.so libaaudio.so
|
||||
libOpenSLES.so libmediandk.so libcamera2ndk.so libsync.so libneuralnetworks.so"
|
||||
fail=0
|
||||
for so in *.so; do
|
||||
for need in $(readelf -d "$so" | sed -n 's/.*NEEDED.*\[\(.*\)\]/\1/p'); do
|
||||
[ -f "$need" ] && continue
|
||||
case " $(echo $SYSTEM) " in *" $need "*) continue ;; esac
|
||||
echo "::error::$so needs $need, which is neither in the apk nor a platform library"
|
||||
fail=1
|
||||
done
|
||||
done
|
||||
exit $fail
|
||||
|
||||
- name: Sign with the testkey (v1 + v2 + v3)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
name: Publish the CI Debian image
|
||||
|
||||
# Pulls the Debian 11 image the x86 preloads build in, once, and publishes it as a release asset
|
||||
# on this repo so build.yml never depends on a registry mirror's rate limit. Run it again (with a
|
||||
# new tag) only to refresh the image; tools/linuxfs/debian-image.env names the one builds use.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag to publish to (a new -rN tag for a refresh; builds pin the one in tools/linuxfs/debian-image.env)'
|
||||
required: true
|
||||
default: 'ci-debian-bullseye-r2'
|
||||
replace:
|
||||
description: 'Overwrite the asset if the release already has one (changes its checksum: re-pin it in debian-image.env)'
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Pull, save and publish
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
REPLACE: ${{ inputs.replace }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
image=public.ecr.aws/docker/library/debian:bullseye
|
||||
# A published image is pinned by checksum in the build; never replace one by accident.
|
||||
if [ "$REPLACE" != true ] && gh release view "$TAG" -R "$GITHUB_REPOSITORY" --json assets -q '.assets[].name' 2>/dev/null | grep -qx debian-bullseye.tar.zst; then
|
||||
echo "::error::$TAG already carries debian-bullseye.tar.zst; publish to a new tag, or set replace and re-pin tools/linuxfs/debian-image.env"
|
||||
exit 1
|
||||
fi
|
||||
for attempt in 1 2 3 4 5 6; do
|
||||
docker pull "$image" && break
|
||||
[ "$attempt" -lt 6 ] || { echo "::error::could not pull $image"; exit 1; }
|
||||
echo "pull attempt $attempt failed (registry busy?); retrying in $((attempt * 30)) s"
|
||||
sleep $((attempt * 30))
|
||||
done
|
||||
docker image inspect "$image" --format 'image id {{.Id}} created {{.Created}}'
|
||||
docker save "$image" | zstd -19 -T0 -o debian-bullseye.tar.zst
|
||||
sha256sum debian-bullseye.tar.zst | tee SHA256SUMS
|
||||
ls -l debian-bullseye.tar.zst
|
||||
if ! gh release view "$TAG" -R "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||
gh release create "$TAG" -R "$GITHUB_REPOSITORY" --title "CI Debian 11 image ($TAG)" --latest=false \
|
||||
--notes "The debian:bullseye image build.yml compiles the x86 preloads in (glibc 2.31), saved with docker save and compressed with zstd. Pulled from public.ecr.aws/docker/library/debian:bullseye on $(date -u +%Y-%m-%d); image id $(docker image inspect "$image" --format '{{.Id}}'). Pinned in tools/linuxfs/debian-image.env."
|
||||
fi
|
||||
gh release upload "$TAG" -R "$GITHUB_REPOSITORY" debian-bullseye.tar.zst SHA256SUMS --clobber
|
||||
{ echo "Published \`$TAG\`:"; echo; echo '```'; cat SHA256SUMS; echo '```'; } >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -13,7 +13,6 @@ captures/
|
||||
app/src/main/assets/linuxfs/
|
||||
app/src/main/jniLibs/arm64-v8a/libproot.so
|
||||
app/src/main/jniLibs/arm64-v8a/.proot-inputs
|
||||
app/src/main/jniLibs/arm64-v8a/.proot-outputs
|
||||
app/src/main/jniLibs/arm64-v8a/libproot-loader.so
|
||||
app/src/main/jniLibs/arm64-v8a/libdirectaudiorelay.so
|
||||
# The store download engines (app/src/main/rust/stores) are cross-compiled by Gradle into
|
||||
|
||||
@@ -21,19 +21,15 @@ Before Steam launches, you must turn off **Restrict child processes** in Develop
|
||||
|
||||
## Community
|
||||
|
||||
Join the [DroidDeck Discord](https://discord.gg/JRGAvawjsm) for help, Preview builds, and device reports. Bug reports go in its **#bug-reports** forum; attach the zip from **Share logs** (Setup, or the session drawer) so the logs come with it.
|
||||
Join the [DroidDeck Discord](https://discord.gg/JRGAvawjsm) for help, Preview builds, and device reports. Bug reports go in its **#bug-reports** forum; attach the session folder from `Download/DroidDeck/` so the logs come with it.
|
||||
|
||||
## Build
|
||||
|
||||
Run `tools/build_local.sh` with Docker, Java 17, the Android SDK/NDK, Rust through rustup, GitHub CLI, Python 3, and `zstd` installed. It prepares the same native libraries, pinned components and audio sinks as CI, then checks the APK's assets, JNI exports and dependencies. The APK is at `app/build/outputs/apk/release/app-release.apk`. Set `DROIDDECK_PA13_SOURCE_DIR` to an existing PulseAudio 13.0 source directory to skip downloading it. Set `DROIDDECK_SIGNING_ENV` to a signing environment file to install over a release build; relative keystore paths resolve beside that file. To install on an attached device, run `tools/deploy_local.sh`.
|
||||
|
||||
After preparation, ordinary `./gradlew :app:assembleRelease` builds can reuse the verified inputs. If native sources, component pins or staged files change, packaging stops with instructions to rerun the helper. Generated audio stays under `app/build/`; the tracked base bundle is never modified. `./gradlew -PskipRust=true :app:testDebugUnitTest` needs no native preparation. For an APK without store engines, use `DROIDDECK_SKIP_RUST=1 tools/build_local.sh`; it omits cached engine libraries too.
|
||||
|
||||
Run the helper and preload regression suite with `bash tools/test_local.sh`. On macOS it runs in Linux through Docker, matching the helpers' runtime and CI instead of compiling Linux preloads against macOS headers.
|
||||
Run `tools/build_local.sh` with Docker, Java 17, the Android SDK/NDK, and `zstd` installed. It builds the ARM64 audio sinks from PulseAudio 13.0 and packages them into the APK at `app/build/outputs/apk/release/app-release.apk`. Set `DROIDDECK_PA13_SOURCE_DIR` to an existing PulseAudio 13.0 source directory to skip downloading it. To install the APK on an attached device, run `tools/deploy_local.sh`.
|
||||
|
||||
## Limits
|
||||
|
||||
Compatibility and performance vary by device; hardware validation is limited. Desktop compositing uses software rendering. Firefox sandboxing is reduced under proot. Logs are kept app-private (`files/logs/`, the last 30 sessions); **Share logs** packs the newest session, with the stores and tool logs, into one scrubbed zip.
|
||||
Compatibility and performance vary by device; hardware validation is limited. Desktop compositing uses software rendering. Firefox sandboxing is reduced under proot. See the session logs in `Download/DroidDeck/` when diagnosing problems.
|
||||
|
||||
## Credits and licence
|
||||
|
||||
|
||||
+3
-58
@@ -59,19 +59,6 @@ def ciBuild = providers.environmentVariable('GITHUB_ACTIONS').getOrNull() == 'tr
|
||||
def releaseSigner = rootProject.file('keystore/release-signer.sha256').text.trim()
|
||||
// When the source was committed, for "built 3 hours ago": fixed per commit, so it costs no rebuilds.
|
||||
def commitTime = (gitValue(['show', '-s', '--format=%ct', 'HEAD']) ?: '0').with { it.isLong() ? it : '0' }
|
||||
// The desktop package this apk installs (tools/desktop-kde/release.env): published once per tag in
|
||||
// DroidDeck-Components, pinned here with its checksum like gamescope and the esync packs, so the
|
||||
// desktop scripts in the apk always get the package they were made for.
|
||||
def desktopKde = rootProject.file('tools/desktop-kde/release.env').readLines()
|
||||
.findAll { it.contains('=') && !it.startsWith('#') }
|
||||
.collectEntries { def kv = it.split('=', 2); [(kv[0].trim()): kv[1].trim()] }
|
||||
def desktopRemovalSources = new groovy.json.JsonSlurper().parse(file('src/main/assets/desktop-removal/sources.json'))
|
||||
if (desktopRemovalSources.archives[desktopKde.DESKTOP_KDE_TAG] != desktopKde.DESKTOP_KDE_SHA256) {
|
||||
throw new GradleException('Desktop removal inventories do not match the pinned archive')
|
||||
}
|
||||
if (!file('src/main/assets/desktop-removal/versions.txt').readLines().contains(desktopKde.DESKTOP_KDE_TAG)) {
|
||||
throw new GradleException('Regenerate desktop removal inventories for ' + desktopKde.DESKTOP_KDE_TAG)
|
||||
}
|
||||
// tools/release/variants.txt as "package=suffix;...", so the updater picks the apk of the package it runs as.
|
||||
def variantSuffixes = rootProject.file('tools/release/variants.txt').readLines()
|
||||
.findAll { it.trim() && !it.startsWith('#') }
|
||||
@@ -101,11 +88,6 @@ android {
|
||||
buildConfigField "String", "RELEASE_SIGNER", "\"${releaseSigner}\""
|
||||
buildConfigField "long", "COMMIT_TIME", "${commitTime}L * 1000L"
|
||||
buildConfigField "String", "VARIANT_SUFFIXES", "\"${variantSuffixes}\""
|
||||
buildConfigField "String", "SGDB_API_KEY", "\"${System.getenv("SGDB_API_KEY") ?: project.findProperty("sgdbApiKey") ?: ""}\""
|
||||
buildConfigField "String", "DESKTOP_KDE_TAG", "\"${desktopKde.DESKTOP_KDE_TAG}\""
|
||||
buildConfigField "String", "DESKTOP_KDE_URL", "\"https://github.com/${desktopKde.DESKTOP_KDE_REPO}/releases/download/${desktopKde.DESKTOP_KDE_TAG}/desktop-kde.tar.zst\""
|
||||
buildConfigField "String", "DESKTOP_KDE_SHA256", "\"${desktopKde.DESKTOP_KDE_SHA256}\""
|
||||
buildConfigField "long", "DESKTOP_KDE_SIZE", "${desktopKde.DESKTOP_KDE_SIZE}L"
|
||||
|
||||
ndk {
|
||||
abiFilters 'arm64-v8a'
|
||||
@@ -206,32 +188,6 @@ def stageSessionScripts = tasks.register('stageSessionScripts', Copy) {
|
||||
}
|
||||
tasks.named('preBuild').configure { dependsOn(stageSessionScripts) }
|
||||
|
||||
// Prepared audio lives under build/, so native builds never rewrite the tracked base bundle.
|
||||
// Copying into one asset root also prevents an old base bundle winning a duplicate asset merge.
|
||||
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(layout.buildDirectory.dir('prepared-assets'))
|
||||
into(runtimeAssetsDir)
|
||||
}
|
||||
android.sourceSets.main.assets.setSrcDirs([runtimeAssetsDir])
|
||||
tasks.named('preBuild').configure { dependsOn(stageRuntimeAssets) }
|
||||
|
||||
// Unit tests and previews remain usable without Docker. Packaging an APK requires the complete
|
||||
// runtime payload prepared by the local helper or CI, with matching sources and output hashes.
|
||||
def verifyRuntimeInputs = tasks.register('verifyRuntimeInputs', Exec) {
|
||||
// The session scripts are re-copied into the asset tree by stageSessionScripts; with parallel
|
||||
// task execution this check once hashed a file mid-copy and failed a main build. Hash only
|
||||
// once the staging has finished.
|
||||
mustRunAfter(stageSessionScripts, stageRuntimeAssets)
|
||||
workingDir rootProject.projectDir
|
||||
commandLine 'python3', 'tools/release/runtime_inputs.py', 'verify'
|
||||
}
|
||||
tasks.configureEach { task ->
|
||||
if (task.name in ['packageDebug', 'packageRelease']) task.dependsOn(verifyRuntimeInputs)
|
||||
}
|
||||
|
||||
// The GOG / Epic / Amazon download engines are Rust (app/src/main/rust/stores, ported from
|
||||
// Bannerlator's bl-steam-client crate). cargo-ndk cross-compiles them for arm64-v8a into the build
|
||||
// directory, which is one more jniLibs source below: nothing compiled is committed, the same rule
|
||||
@@ -248,11 +204,6 @@ def buildRustStores = tasks.register('buildRustStores', Exec) {
|
||||
onlyIf { !skipRust }
|
||||
inputs.dir(rustCrateDir.dir('src'))
|
||||
inputs.files(rustCrateDir.file('Cargo.toml'), rustCrateDir.file('Cargo.lock'))
|
||||
inputs.property('ndkVersion', android.ndkVersion)
|
||||
if (!skipRust) inputs.property('rustToolchain', providers.exec {
|
||||
environment 'PATH', "${System.getProperty('user.home')}/.cargo/bin${File.pathSeparator}${System.getenv('PATH')}"
|
||||
commandLine 'rustup', 'run', 'stable', 'rustc', '-Vv'
|
||||
}.standardOutput.asText)
|
||||
outputs.dir(rustJniLibsDir)
|
||||
workingDir rustCrateDir
|
||||
// The configuration cache serialises the task, so everything it needs is resolved here, not
|
||||
@@ -262,18 +213,12 @@ def buildRustStores = tasks.register('buildRustStores', Exec) {
|
||||
environment 'PATH', "${cargoBin}${File.pathSeparator}${System.getenv('PATH')}"
|
||||
environment 'ANDROID_NDK_HOME', android.ndkDirectory.absolutePath
|
||||
environment 'CARGO_TERM_COLOR', 'never'
|
||||
if (!skipRust) environment 'RUSTC', providers.exec {
|
||||
commandLine 'rustup', 'which', '--toolchain', 'stable', 'rustc'
|
||||
}.standardOutput.asText.get().trim()
|
||||
// --platform 26 = minSdkVersion: the engines link against the API 26 libc, like the C++ code.
|
||||
// Homebrew's cargo/rustc can precede rustup on macOS and use a different sysroot, despite
|
||||
// rustup reporting the Android target installed. Run both through the selected toolchain.
|
||||
commandLine 'rustup', 'run', 'stable', 'cargo', 'ndk', '--target', 'arm64-v8a', '--platform', '26',
|
||||
commandLine 'cargo', 'ndk', '--target', 'arm64-v8a', '--platform', '26',
|
||||
'--output-dir', rustJniLibsDir.get().asFile.absolutePath,
|
||||
'build', '--release', '--locked'
|
||||
'build', '--release'
|
||||
}
|
||||
// Skipping Rust must omit its library even if another build left a cached .so behind.
|
||||
if (!skipRust) android.sourceSets.main.jniLibs.srcDir(rustJniLibsDir)
|
||||
android.sourceSets.main.jniLibs.srcDir(rustJniLibsDir)
|
||||
tasks.named('preBuild').configure { dependsOn(buildRustStores) }
|
||||
|
||||
dependencies {
|
||||
|
||||
@@ -30,9 +30,6 @@
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<!-- Installs DroidDeck's own updates (Updates page) over itself, after the user allows it. -->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<!-- A paired Steam Controller, read over Bluetooth LE out of lizard mode (SteamControllerBle).
|
||||
Granted at install below targetSdk 31; no scan, so no location. -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" />
|
||||
|
||||
<uses-feature android:name="android.hardware.vulkan.version" android:version="0x401000" android:required="true" />
|
||||
<uses-feature android:name="android.hardware.gamepad" android:required="false" />
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,49 +0,0 @@
|
||||
{
|
||||
"archives": {
|
||||
"lxqt-r1": "c2bb860b3b1688287451e2495be5db313ec07f8c8af501dfac2768a9f417bc86",
|
||||
"desktop-kde-r1": "55747e75d0ec055ab7ea1e92a757f3be227cc3205bb9101f5b53cadef6318579",
|
||||
"desktop-kde-r2": "9ba832677b498654136c69d8c8330434d17e4d53cd099acac9bde3ba429b35bf",
|
||||
"emulators-r1": "a1f4806a9663d28f424b4cab1277e0ede80a92e69de5bc063cdc8b45af220164"
|
||||
},
|
||||
"desktopApplications": {
|
||||
"ark-26.08.1-1-aarch64.pkg.tar.xz": "93e2c2b75ff20cd29b4f312ed1979ef6f27255b93232b93bfcdb31239903c1d4",
|
||||
"breeze-6.7.5-1-aarch64.pkg.tar.xz": "1d7f4eb9d8d234caaea23621909f5233d19462c2f058afeeb54bed7b81bd03bd",
|
||||
"breeze-gtk-6.7.5-1-any.pkg.tar.xz": "a0f59bc4fc426879c8f8a97f6dcc707fb7d1b52913da5eb7e9db83777b6957b7",
|
||||
"breeze-icons-6.30.0-1-aarch64.pkg.tar.xz": "4bae4689032501156687a227024e56554820747644953c67fc8d2e2b6b0b045a",
|
||||
"dolphin-26.08.1-1-aarch64.pkg.tar.xz": "fef738ab8132d3eba1156b98c116699ce0a5efcb4cb462efbacdcf4c47f79a23",
|
||||
"firefox-157.0.1-1-aarch64.pkg.tar.xz": "d142fdb8a339a65eca7cea6a2bba5b29202386166abd9d59851d6757c9eb2163",
|
||||
"gwenview-26.08.1-1-aarch64.pkg.tar.xz": "a9dc7d5ab73fa677905d5a57248b1b9b404b6b16e862958cc7443d387f684a47",
|
||||
"kate-26.08.1-1-aarch64.pkg.tar.xz": "acf50710042746f243a3b219a1ae0f788e338f3dd9d75093e8bb671a618b83b6",
|
||||
"kde-cli-tools-6.7.5-1-aarch64.pkg.tar.xz": "af466398a912a994a97fadfe46aff401d88a90d7fdce7e843dc4218098fa38ac",
|
||||
"kde-gtk-config-6.7.5-1-aarch64.pkg.tar.xz": "ba6cb4cb01dbb3f2c247f2f6973ede3b64c0849f89340217b51dfbf11cb8cb5c",
|
||||
"kinfocenter-6.7.5-1-aarch64.pkg.tar.xz": "f3d5416db96b50f9a5b8a284d82ae21d834a8cce646010abdd7087491c399b0b",
|
||||
"konsole-26.08.1-1-aarch64.pkg.tar.xz": "1ecf87c0d11d620ebe4aeafb02788e0d5f4fe9019ad9c5d9f1011c43237dd5ec",
|
||||
"kscreen-6.7.5-1-aarch64.pkg.tar.xz": "8fbaa6fedf93edeba021a5416b551663654919b27c2ee237d696ec0b0ce7d5b7",
|
||||
"kwin-6.7.5-1-aarch64.pkg.tar.xz": "a7535710c74b90359f80306134b2b7581132cbac508dc84b7e07ef275cc8c3fe",
|
||||
"mpv-1:0.41.0-6-aarch64.pkg.tar.xz": "6c89d00736820210a3c7f32af4cf95d155191d463f9df8bd22137c45f30a554a",
|
||||
"plasma-desktop-6.7.5-1-aarch64.pkg.tar.xz": "b266dd42cea965b5ab1cc1ae600877e9d3bc83ec642c61fe1669c4457e641a55",
|
||||
"plasma-integration-6.7.5-1-aarch64.pkg.tar.xz": "daa96da14d9a4d12c0d9c0341e1be6836a9a437c469d61da5e9844d51e2606cf",
|
||||
"plasma-pa-6.7.5-1-aarch64.pkg.tar.xz": "02dbefe9d3e0069b2bb37cac2a6be18b8f0a1887c87704a0716e7a9268eadcd8",
|
||||
"plasma-workspace-6.7.5-1-aarch64.pkg.tar.xz": "3a1455857afaa9ccf342ab6167f0f15794ee9d248b0925398ea6f62f12730a86",
|
||||
"spectacle-1:6.7.5-1-aarch64.pkg.tar.xz": "0c9837412ea6119edef18bcb0a8fceb74634cb35be0ca78b3e1dd1ff2443866e",
|
||||
"systemsettings-6.7.5-1-aarch64.pkg.tar.xz": "b02b6a726857d5125c92d1ec95379216c7a3bc1f187a3b46e88c0d056b29ce70",
|
||||
"xdg-desktop-portal-kde-6.7.5-1-aarch64.pkg.tar.xz": "2c57d17d34d577023eb9cd7b0fca1ba9163549afe60450f6271aa148af099ef2"
|
||||
},
|
||||
"legacyApplications": {
|
||||
"featherpad-1.6.3-1-aarch64.pkg.tar.xz": "fcac55215815c0b712453f9bcc09abfe4b518e36025a111a7a7598ef04913f3d",
|
||||
"labwc-0.20.2-1-aarch64.pkg.tar.xz": "bcd4cc3945165b0fdd33c0117cd7b9dfd05cf8c327326c37f3c3a27ef23ee5ca",
|
||||
"lximage-qt-2.4.0-1-aarch64.pkg.tar.xz": "5d6bdd5472ea6bcb4b993b36df3ab7010bb2e62b36a78a649708fc9a52cb0928",
|
||||
"lxqt-config-2.4.0-1-aarch64.pkg.tar.xz": "4b601d798fc7689ee76787dbef8c8958c02429ee60248cd99d950c919dbefba5",
|
||||
"lxqt-panel-2.4.1-1-aarch64.pkg.tar.xz": "a141e0f8049e73f859d843510c6f45d6ed9087c990bcebf44f5f2430621024c4",
|
||||
"lxqt-qtplugin-2.4.0-3-aarch64.pkg.tar.xz": "61c8f828be73111ee0bd5e3163b0d64c2098dd0189710a190f42be11137695cf",
|
||||
"lxqt-runner-2.4.0-1-aarch64.pkg.tar.xz": "5afa74652a37366ea4e7774cf33379977582fbdca599cef1005ebf637cbcae4b",
|
||||
"lxqt-session-2.4.0-2-aarch64.pkg.tar.xz": "e9c7c8a0011859029622ad202e0d3826e4e5bc8d64659031f6efa211456ff6eb",
|
||||
"lxqt-themes-2.4.0-1-any.pkg.tar.xz": "7ea16c62a1fa70cdac13cb3f8fdcdaad299ae1f5bd514acfda671484755787bf",
|
||||
"lxqt-wayland-session-0.4.1-1-any.pkg.tar.xz": "cd8dc11f5035469d23b6bbd937a90593402428c43de37517412aa43fe1fc6b10",
|
||||
"papirus-icon-theme-20260801-1-any.pkg.tar.xz": "4aae3f47b800a60d6fd360e55adc581408ceaafc481c5a420a681741a3966510",
|
||||
"pcmanfm-qt-2.4.1-1-aarch64.pkg.tar.xz": "7ec1a6b39dd0e4055b39f0e2c0549ea263d83004e1b821fb9e42f1d7f5fff0f9",
|
||||
"qterminal-2.4.0-1-aarch64.pkg.tar.xz": "aa4adc9c9d47641b9452ec8d879038a9cb4762c3b6b5299045fac968fd0ed22d",
|
||||
"swaybg-1.2.2-1-aarch64.pkg.tar.xz": "a26caa16ca7d581b5ca05758677058b4b60af10dc8d83e447ad6824b762b48b8",
|
||||
"wlr-randr-0.5.0-1-aarch64.pkg.tar.xz": "58c3651c6a15dc8f95a310c7e225f759aa766be2fb3c0981a9a8b567854675b3"
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
desktop-kde-r1
|
||||
desktop-kde-r2
|
||||
File diff suppressed because it is too large.
Load diff
@@ -136,7 +136,6 @@ JNIEXPORT jint JNICALL Java_com_termux_terminal_JNI_createSubprocess(
|
||||
if (!arg_utf8) return throw_runtime_exception(env, "GetStringUTFChars() failed for argv");
|
||||
argv[i] = strdup(arg_utf8);
|
||||
(*env)->ReleaseStringUTFChars(env, arg_java_string, arg_utf8);
|
||||
(*env)->DeleteLocalRef(env, arg_java_string);
|
||||
}
|
||||
argv[size] = NULL;
|
||||
}
|
||||
@@ -152,7 +151,6 @@ JNIEXPORT jint JNICALL Java_com_termux_terminal_JNI_createSubprocess(
|
||||
if (!env_utf8) return throw_runtime_exception(env, "GetStringUTFChars() failed for env");
|
||||
envp[i] = strdup(env_utf8);
|
||||
(*env)->ReleaseStringUTFChars(env, env_java_string, env_utf8);
|
||||
(*env)->DeleteLocalRef(env, env_java_string);
|
||||
}
|
||||
envp[size] = NULL;
|
||||
}
|
||||
@@ -162,7 +160,7 @@ JNIEXPORT jint JNICALL Java_com_termux_terminal_JNI_createSubprocess(
|
||||
char const* cmd_utf8 = (*env)->GetStringUTFChars(env, cmd, NULL);
|
||||
int ptm = create_subprocess(env, cmd_utf8, cmd_cwd, argv, envp, &procId, rows, columns);
|
||||
(*env)->ReleaseStringUTFChars(env, cmd, cmd_utf8);
|
||||
(*env)->ReleaseStringUTFChars(env, cwd, cmd_cwd);
|
||||
(*env)->ReleaseStringUTFChars(env, cmd, cmd_cwd);
|
||||
|
||||
if (argv) {
|
||||
for (char** tmp = argv; *tmp; ++tmp) free(*tmp);
|
||||
|
||||
@@ -17,7 +17,6 @@ add_library(droiddeckwayland SHARED
|
||||
src/wl_clipboard.c
|
||||
src/wl_text_input.c
|
||||
src/wl_toplevel_icon.c
|
||||
src/wl_single_pixel.c
|
||||
src/wl_color_mgmt.c
|
||||
src/hdr_compose.c
|
||||
src/blend_pass.c
|
||||
@@ -32,7 +31,6 @@ add_library(droiddeckwayland SHARED
|
||||
generated/wlr-data-control-unstable-v1-protocol.c
|
||||
generated/text-input-unstable-v3-protocol.c
|
||||
generated/xdg-toplevel-icon-v1-protocol.c
|
||||
generated/single-pixel-buffer-v1-protocol.c
|
||||
generated/banner-ahb-v1-protocol.c
|
||||
src/framegen_bridge.c
|
||||
src/framegen_jni.c
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
/* Generated by wayland-scanner 1.26.0 */
|
||||
|
||||
/*
|
||||
* Copyright © 2022 Simon Ser
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a
|
||||
* copy of this software and associated documentation files (the "Software"),
|
||||
* to deal in the Software without restriction, including without limitation
|
||||
* the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
||||
* and/or sell copies of the Software, and to permit persons to whom the
|
||||
* Software is furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice (including the next
|
||||
* paragraph) shall be included in all copies or substantial portions of the
|
||||
* Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
|
||||
* THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
* DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdint.h>
|
||||
#include "wayland-util.h"
|
||||
|
||||
#ifndef __has_attribute
|
||||
# define __has_attribute(x) 0 /* Compatibility with non-clang compilers. */
|
||||
#endif
|
||||
|
||||
#if (__has_attribute(visibility) || defined(__GNUC__) && __GNUC__ >= 4)
|
||||
#define WL_PRIVATE __attribute__ ((visibility("hidden")))
|
||||
#else
|
||||
#define WL_PRIVATE
|
||||
#endif
|
||||
|
||||
extern const struct wl_interface wl_buffer_interface;
|
||||
|
||||
static const struct wl_interface *single_pixel_buffer_v1_types[] = {
|
||||
&wl_buffer_interface,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
};
|
||||
|
||||
static const struct wl_message wp_single_pixel_buffer_manager_v1_requests[] = {
|
||||
{ "destroy", "", single_pixel_buffer_v1_types + 0 },
|
||||
{ "create_u32_rgba_buffer", "nuuuu", single_pixel_buffer_v1_types + 0 },
|
||||
};
|
||||
|
||||
WL_PRIVATE const struct wl_interface wp_single_pixel_buffer_manager_v1_interface = {
|
||||
"wp_single_pixel_buffer_manager_v1", 1,
|
||||
2, wp_single_pixel_buffer_manager_v1_requests,
|
||||
0, NULL,
|
||||
};
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
/* Generated by wayland-scanner 1.26.0 */
|
||||
|
||||
#ifndef SINGLE_PIXEL_BUFFER_V1_SERVER_PROTOCOL_H
|
||||
#define SINGLE_PIXEL_BUFFER_V1_SERVER_PROTOCOL_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
#include "wayland-server.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
struct wl_client;
|
||||
struct wl_resource;
|
||||
|
||||
/**
|
||||
* @page page_single_pixel_buffer_v1 The single_pixel_buffer_v1 protocol
|
||||
* single pixel buffer factory
|
||||
*
|
||||
* @section page_desc_single_pixel_buffer_v1 Description
|
||||
*
|
||||
* This protocol extension allows clients to create single-pixel buffers.
|
||||
*
|
||||
* Compositors supporting this protocol extension should also support the
|
||||
* viewporter protocol extension. Clients may use viewporter to scale a
|
||||
* single-pixel buffer to a desired size.
|
||||
*
|
||||
* Warning! The protocol described in this file is currently in the testing
|
||||
* phase. Backward compatible changes may be added together with the
|
||||
* corresponding interface version bump. Backward incompatible changes can
|
||||
* only be done by creating a new major version of the extension.
|
||||
*
|
||||
* @section page_ifaces_single_pixel_buffer_v1 Interfaces
|
||||
* - @subpage page_iface_wp_single_pixel_buffer_manager_v1 - global factory for single-pixel buffers
|
||||
* @section page_copyright_single_pixel_buffer_v1 Copyright
|
||||
* <pre>
|
||||
*
|
||||
* Copyright © 2022 Simon Ser
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a
|
||||
* copy of this software and associated documentation files (the "Software"),
|
||||
* to deal in the Software without restriction, including without limitation
|
||||
* the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
||||
* and/or sell copies of the Software, and to permit persons to whom the
|
||||
* Software is furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice (including the next
|
||||
* paragraph) shall be included in all copies or substantial portions of the
|
||||
* Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
|
||||
* THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
* DEALINGS IN THE SOFTWARE.
|
||||
* </pre>
|
||||
*/
|
||||
struct wl_buffer;
|
||||
struct wp_single_pixel_buffer_manager_v1;
|
||||
|
||||
#ifndef WP_SINGLE_PIXEL_BUFFER_MANAGER_V1_INTERFACE
|
||||
#define WP_SINGLE_PIXEL_BUFFER_MANAGER_V1_INTERFACE
|
||||
/**
|
||||
* @page page_iface_wp_single_pixel_buffer_manager_v1 wp_single_pixel_buffer_manager_v1
|
||||
* @section page_iface_wp_single_pixel_buffer_manager_v1_desc Description
|
||||
*
|
||||
* The wp_single_pixel_buffer_manager_v1 interface is a factory for
|
||||
* single-pixel buffers.
|
||||
* @section page_iface_wp_single_pixel_buffer_manager_v1_api API
|
||||
* See @ref iface_wp_single_pixel_buffer_manager_v1.
|
||||
*/
|
||||
/**
|
||||
* @defgroup iface_wp_single_pixel_buffer_manager_v1 The wp_single_pixel_buffer_manager_v1 interface
|
||||
*
|
||||
* The wp_single_pixel_buffer_manager_v1 interface is a factory for
|
||||
* single-pixel buffers.
|
||||
*/
|
||||
extern const struct wl_interface wp_single_pixel_buffer_manager_v1_interface;
|
||||
#endif
|
||||
|
||||
/**
|
||||
* @ingroup iface_wp_single_pixel_buffer_manager_v1
|
||||
* @struct wp_single_pixel_buffer_manager_v1_interface
|
||||
*/
|
||||
struct wp_single_pixel_buffer_manager_v1_interface {
|
||||
/**
|
||||
* destroy the manager
|
||||
*
|
||||
* Destroy the wp_single_pixel_buffer_manager_v1 object.
|
||||
*
|
||||
* The child objects created via this interface are unaffected.
|
||||
*/
|
||||
void (*destroy)(struct wl_client *client,
|
||||
struct wl_resource *resource);
|
||||
/**
|
||||
* create a 1×1 buffer from 32-bit RGBA values
|
||||
*
|
||||
* Create a single-pixel buffer from four 32-bit RGBA values.
|
||||
*
|
||||
* Unless specified in another protocol extension, the RGBA values
|
||||
* use pre-multiplied alpha.
|
||||
*
|
||||
* The width and height of the buffer are 1.
|
||||
*
|
||||
* The r, g, b and a arguments valid range is from UINT32_MIN (0)
|
||||
* to UINT32_MAX (0xffffffff).
|
||||
*
|
||||
* These arguments should be interpreted as a percentage, i.e. -
|
||||
* UINT32_MIN = 0% of the given color component - UINT32_MAX = 100%
|
||||
* of the given color component
|
||||
* @param r value of the buffer's red channel
|
||||
* @param g value of the buffer's green channel
|
||||
* @param b value of the buffer's blue channel
|
||||
* @param a value of the buffer's alpha channel
|
||||
*/
|
||||
void (*create_u32_rgba_buffer)(struct wl_client *client,
|
||||
struct wl_resource *resource,
|
||||
uint32_t id,
|
||||
uint32_t r,
|
||||
uint32_t g,
|
||||
uint32_t b,
|
||||
uint32_t a);
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* @ingroup iface_wp_single_pixel_buffer_manager_v1
|
||||
*/
|
||||
#define WP_SINGLE_PIXEL_BUFFER_MANAGER_V1_DESTROY_SINCE_VERSION 1
|
||||
/**
|
||||
* @ingroup iface_wp_single_pixel_buffer_manager_v1
|
||||
*/
|
||||
#define WP_SINGLE_PIXEL_BUFFER_MANAGER_V1_CREATE_U32_RGBA_BUFFER_SINCE_VERSION 1
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
@@ -1,76 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<protocol name="single_pixel_buffer_v1">
|
||||
<copyright>
|
||||
Copyright © 2022 Simon Ser
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a
|
||||
copy of this software and associated documentation files (the "Software"),
|
||||
to deal in the Software without restriction, including without limitation
|
||||
the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
||||
and/or sell copies of the Software, and to permit persons to whom the
|
||||
Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice (including the next
|
||||
paragraph) shall be included in all copies or substantial portions of the
|
||||
Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
|
||||
THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
</copyright>
|
||||
|
||||
<description summary="single pixel buffer factory">
|
||||
This protocol extension allows clients to create single-pixel buffers.
|
||||
|
||||
Compositors supporting this protocol extension should also support the
|
||||
viewporter protocol extension. Clients may use viewporter to scale a
|
||||
single-pixel buffer to a desired size.
|
||||
|
||||
Warning! The protocol described in this file is currently in the testing
|
||||
phase. Backward compatible changes may be added together with the
|
||||
corresponding interface version bump. Backward incompatible changes can
|
||||
only be done by creating a new major version of the extension.
|
||||
</description>
|
||||
|
||||
<interface name="wp_single_pixel_buffer_manager_v1" version="1">
|
||||
<description summary="global factory for single-pixel buffers">
|
||||
The wp_single_pixel_buffer_manager_v1 interface is a factory for
|
||||
single-pixel buffers.
|
||||
</description>
|
||||
|
||||
<request name="destroy" type="destructor">
|
||||
<description summary="destroy the manager">
|
||||
Destroy the wp_single_pixel_buffer_manager_v1 object.
|
||||
|
||||
The child objects created via this interface are unaffected.
|
||||
</description>
|
||||
</request>
|
||||
|
||||
<request name="create_u32_rgba_buffer">
|
||||
<description summary="create a 1×1 buffer from 32-bit RGBA values">
|
||||
Create a single-pixel buffer from four 32-bit RGBA values.
|
||||
|
||||
Unless specified in another protocol extension, the RGBA values use
|
||||
pre-multiplied alpha.
|
||||
|
||||
The width and height of the buffer are 1.
|
||||
|
||||
The r, g, b and a arguments valid range is from UINT32_MIN (0)
|
||||
to UINT32_MAX (0xffffffff).
|
||||
|
||||
These arguments should be interpreted as a percentage, i.e.
|
||||
- UINT32_MIN = 0% of the given color component
|
||||
- UINT32_MAX = 100% of the given color component
|
||||
</description>
|
||||
<arg name="id" type="new_id" interface="wl_buffer"/>
|
||||
<arg name="r" type="uint" summary="value of the buffer's red channel"/>
|
||||
<arg name="g" type="uint" summary="value of the buffer's green channel"/>
|
||||
<arg name="b" type="uint" summary="value of the buffer's blue channel"/>
|
||||
<arg name="a" type="uint" summary="value of the buffer's alpha channel"/>
|
||||
</request>
|
||||
</interface>
|
||||
</protocol>
|
||||
@@ -745,21 +745,6 @@ static void take_shm(struct surface *s, struct wl_shm_buffer *shm, struct wl_res
|
||||
if (ci && ci->asked_feedback) ci->shm_frames++; /* since it asked for GPU buffers */
|
||||
}
|
||||
|
||||
/* A single-pixel buffer (wl_single_pixel.c) as a 1x1 image, which the surface's viewport stretches. */
|
||||
static void take_pixel(struct surface *s, const uint8_t bgra[4], struct wl_resource *buffer) {
|
||||
if (s->shm_img && (vkp_image_width(s->shm_img) != 1 || vkp_image_height(s->shm_img) != 1)) {
|
||||
vkp_image_destroy(s->shm_img);
|
||||
s->shm_img = NULL;
|
||||
}
|
||||
if (!s->shm_img) s->shm_img = vkp_image_create_shm(1, 1);
|
||||
if (s->shm_img) vkp_image_upload_shm(s->shm_img, bgra, 4);
|
||||
wl_buffer_send_release(buffer);
|
||||
s->buf_w = 1;
|
||||
s->buf_h = 1;
|
||||
s->buf_alpha = bgra[3] != 0xff;
|
||||
s->has_content = s->shm_img != NULL;
|
||||
}
|
||||
|
||||
/* The window the app's performance HUD follows: the latest one at least as big as the last to
|
||||
* start presenting GPU frames (take_dmabuf) (X11 binds the HUD to the _MESA_DRV window and counts X presents instead). JNI upcalls. */
|
||||
static struct surface *g_hud_surface;
|
||||
@@ -1099,7 +1084,6 @@ static void surface_commit(struct wl_client *c, struct wl_resource *r) {
|
||||
struct wl_resource *buffer = s->pending_buffer;
|
||||
struct dmabuf_buffer *db = get_dmabuf(buffer);
|
||||
struct wl_shm_buffer *shm = buffer && !db ? wl_shm_buffer_get(buffer) : NULL;
|
||||
uint8_t pixel[4];
|
||||
|
||||
/* The previous content is replaced before reaching the screen. */
|
||||
feedback_discard_all(&s->feedback);
|
||||
@@ -1129,9 +1113,6 @@ static void surface_commit(struct wl_client *c, struct wl_resource *r) {
|
||||
} else if (shm) {
|
||||
drop_dmabuf(s, 1);
|
||||
take_shm(s, shm, buffer);
|
||||
} else if (single_pixel_buffer_get(buffer, pixel)) {
|
||||
drop_dmabuf(s, 1);
|
||||
take_pixel(s, pixel, buffer);
|
||||
} else {
|
||||
drop_dmabuf(s, 1);
|
||||
s->has_content = 0;
|
||||
@@ -1680,24 +1661,15 @@ static void bind_desktop(struct wl_client *c, void *data, uint32_t ver, uint32_t
|
||||
static void output_release(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); }
|
||||
static const struct wl_output_interface output_impl = { .release = output_release };
|
||||
|
||||
/* Every bound wl_output, so a resized output (a foldable opening) can send its new mode. */
|
||||
static struct wl_list g_outputs;
|
||||
static void output_resource_destroy(struct wl_resource *r) { wl_list_remove(wl_resource_get_link(r)); }
|
||||
|
||||
static void output_send_mode(struct wl_resource *r) {
|
||||
wl_output_send_mode(r, WL_OUTPUT_MODE_CURRENT | WL_OUTPUT_MODE_PREFERRED,
|
||||
g_output_w > 0 ? g_output_w : 1920, g_output_h > 0 ? g_output_h : 1080,
|
||||
g_output_refresh_mhz > 0 ? g_output_refresh_mhz : 60000);
|
||||
}
|
||||
|
||||
static void bind_output(struct wl_client *c, void *data, uint32_t ver, uint32_t id) {
|
||||
struct wl_resource *r = wl_resource_create(c, &wl_output_interface, ver, id);
|
||||
if (!r) { wl_client_post_no_memory(c); return; }
|
||||
wl_resource_set_implementation(r, &output_impl, NULL, output_resource_destroy);
|
||||
wl_list_insert(&g_outputs, wl_resource_get_link(r));
|
||||
wl_resource_set_implementation(r, &output_impl, NULL, NULL);
|
||||
wl_output_send_geometry(r, 0, 0, 340, 190, WL_OUTPUT_SUBPIXEL_UNKNOWN,
|
||||
"DroidDeck", "Wayland", WL_OUTPUT_TRANSFORM_NORMAL);
|
||||
output_send_mode(r);
|
||||
wl_output_send_mode(r, WL_OUTPUT_MODE_CURRENT | WL_OUTPUT_MODE_PREFERRED,
|
||||
g_output_w > 0 ? g_output_w : 1920, g_output_h > 0 ? g_output_h : 1080,
|
||||
g_output_refresh_mhz > 0 ? g_output_refresh_mhz : 60000);
|
||||
if (ver >= WL_OUTPUT_NAME_SINCE_VERSION) {
|
||||
wl_output_send_name(r, "DroidDeck-1");
|
||||
wl_output_send_description(r, "DroidDeck display");
|
||||
@@ -3228,23 +3200,6 @@ static void key_event(uint32_t evdev, int pressed) {
|
||||
wl_display_flush_clients(g_display);
|
||||
}
|
||||
|
||||
/* The output changed size under a running session (a foldable opened or closed, with the
|
||||
* session following the screen). Every client that has had its first configure is told the new
|
||||
* size: gamescope's Wayland backend and the desktop's KWin both resize their output to it, and
|
||||
* the next buffers they commit fill the new panel instead of being letterboxed onto it. */
|
||||
static void output_resized(void) {
|
||||
struct surface *s;
|
||||
struct wl_resource *r;
|
||||
wl_resource_for_each(r, &g_outputs) {
|
||||
output_send_mode(r);
|
||||
if (wl_resource_get_version(r) >= 2) wl_output_send_done(r);
|
||||
}
|
||||
wl_list_for_each(s, &g_surfaces, link)
|
||||
if (s->xdg_toplevel && s->toplevel_committed) send_toplevel_configure(s);
|
||||
droiddeck_log("display", "output resized to %dx%d", g_output_w, g_output_h);
|
||||
wl_display_flush_clients(g_display);
|
||||
}
|
||||
|
||||
/* wl event-loop callback: drain queued input events written by the Android UI thread. */
|
||||
static int on_input_readable(int fd, uint32_t mask, void *data) {
|
||||
struct input_msg m;
|
||||
@@ -3257,7 +3212,6 @@ static int on_input_readable(int fd, uint32_t mask, void *data) {
|
||||
case 5: on_vsync(((int64_t)m.p1 << 32) | (uint32_t)m.p2); break;
|
||||
case 6: pointer_delta(m.p1 / 256.0, m.p2 / 256.0); break; /* relative motion, 1/256 px */
|
||||
case 7: deliver_touch(&m); break;
|
||||
case 8: output_resized(); break;
|
||||
default: deliver_pointer(&m); break;
|
||||
}
|
||||
}
|
||||
@@ -3288,14 +3242,6 @@ void droiddeck_wayland_send_key(int evdev, int state) {
|
||||
(void)n;
|
||||
}
|
||||
|
||||
/* Called from JNI after g_output_w/h changed: the compositor thread re-sends the size. */
|
||||
void droiddeck_wayland_output_resized(void) {
|
||||
if (g_input_pipe[1] < 0) return;
|
||||
struct input_msg m = { 8, 0, 0, 0, 0 };
|
||||
ssize_t n = write(g_input_pipe[1], &m, sizeof(m));
|
||||
(void)n;
|
||||
}
|
||||
|
||||
/* Called from JNI on every screen refresh (Choreographer). */
|
||||
void droiddeck_wayland_vsync(int64_t frame_time_ns) {
|
||||
if (g_input_pipe[1] < 0) return;
|
||||
@@ -3493,7 +3439,6 @@ static struct wl_listener g_client_created = { .notify = on_client_created };
|
||||
int droiddeck_wayland_run(void) {
|
||||
wl_list_init(&g_surfaces);
|
||||
wl_list_init(&g_toplevels);
|
||||
wl_list_init(&g_outputs);
|
||||
wl_list_init(&g_constraints);
|
||||
wl_list_init(&g_relative_pointers);
|
||||
open_session_log();
|
||||
|
||||
@@ -122,5 +122,4 @@ void droiddeck_ext_init(struct wl_display *display) {
|
||||
clipboard_init(display);
|
||||
text_input_init(display);
|
||||
toplevel_icon_init(display);
|
||||
single_pixel_init(display);
|
||||
}
|
||||
@@ -48,6 +48,3 @@ void text_input_host_commit(const char *utf8, size_t len);
|
||||
void text_input_host_preedit(const char *utf8, size_t len, int cursor_begin, int cursor_end);
|
||||
void text_input_host_delete(int before, int after);
|
||||
void toplevel_icon_init(struct wl_display *display);
|
||||
void single_pixel_init(struct wl_display *display);
|
||||
/* A wp_single_pixel_buffer_manager_v1 buffer: 1 and its pixel (B, G, R, A, premultiplied), else 0. */
|
||||
int single_pixel_buffer_get(struct wl_resource *buffer, uint8_t bgra[4]);
|
||||
@@ -160,11 +160,6 @@ static const char *vk_result_name(VkResult r);
|
||||
static pthread_mutex_t g_req_lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
static ANativeWindow *g_req_window;
|
||||
static int g_req_pending;
|
||||
/* A detach was asked for since the compositor thread last looked. A rebind (null, then the same
|
||||
* window) to rebuild the swapchain at a new size can land in full before the thread gets to it,
|
||||
* and would otherwise read as the same window handed over again - nothing rebuilt, and the old
|
||||
* size's buffers scaled onto the rotated or refolded panel. */
|
||||
static int g_req_detached;
|
||||
|
||||
/* Scale mode + alignment (app values, see vk_present.h); written from any thread, read per frame. */
|
||||
static volatile int g_mode = VKP_MODE_STRETCH, g_align = VKP_ALIGN_CENTER;
|
||||
@@ -257,7 +252,6 @@ void vk_present_set_window(ANativeWindow *window) {
|
||||
if (g_req_pending && g_req_window && g_req_window != window) superseded = g_req_window;
|
||||
g_req_window = window;
|
||||
g_req_pending = 1;
|
||||
if (!window) g_req_detached = 1;
|
||||
pthread_mutex_unlock(&g_req_lock);
|
||||
if (superseded) ANativeWindow_release(superseded);
|
||||
}
|
||||
@@ -267,20 +261,13 @@ int vkp_apply_window_request(void) {
|
||||
pthread_mutex_lock(&g_req_lock);
|
||||
if (!g_req_pending) { pthread_mutex_unlock(&g_req_lock); return 0; }
|
||||
w = g_req_window;
|
||||
const int detached = g_req_detached;
|
||||
g_req_window = NULL;
|
||||
g_req_pending = 0;
|
||||
g_req_detached = 0;
|
||||
pthread_mutex_unlock(&g_req_lock);
|
||||
if (w == g_window) {
|
||||
/* The same window handed over again (ANativeWindow_fromSurface adds a reference each time). */
|
||||
if (w) ANativeWindow_release(w);
|
||||
if (!w || !detached) return 0;
|
||||
/* ... after a detach: a rebind for a resized surface. Rebuilt at its size on the next frame. */
|
||||
sc_layer_window_gone();
|
||||
destroy_swapchain();
|
||||
droiddeck_log("gpu", "screen surface rebound: rebuilding the swapchain at its new size");
|
||||
return 1;
|
||||
return 0;
|
||||
}
|
||||
sc_layer_window_gone(); /* the layer (if any) belongs to the old window */
|
||||
destroy_swapchain(); /* recreated against the new window on the next frame */
|
||||
@@ -1529,7 +1516,6 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i
|
||||
const struct vkp_hdr_frame *hf, int *how) {
|
||||
if (how) *how = 0;
|
||||
vkp_apply_window_request();
|
||||
check_surface_changed(); /* a rotation or a fold the rebind did not cover: rebuild at the new size */
|
||||
if (g_dev_state == -2) return -1;
|
||||
if (dev_init() != 0 || !g_window || scene_w <= 0 || scene_h <= 0) return -1;
|
||||
|
||||
@@ -2113,7 +2099,6 @@ static int pass_begin_impl(int scene_w, int scene_h, const struct vkp_draw *draw
|
||||
const struct vkp_hdr_frame *hf, int *rw, int *rh) {
|
||||
if (g_pass.active) vkp_pass_abort();
|
||||
if (g_dev_state == -2 || dev_init() != 0 || !g_window || scene_w <= 0 || scene_h <= 0) return -1;
|
||||
check_surface_changed(); /* as in render_impl: the effects pass maps onto the output's size */
|
||||
if (!g_swapchain && swap_init() != 0) return -1; /* the mapping is in output pixels */
|
||||
update_map(scene_w, scene_h);
|
||||
if (!g_map.valid) return -1;
|
||||
|
||||
@@ -30,7 +30,6 @@ extern int droiddeck_cursor_serial(void);
|
||||
extern void droiddeck_wayland_send_key(int evdev, int state);
|
||||
extern void droiddeck_wayland_send_scene_input(int type, int a, int b);
|
||||
extern void droiddeck_wayland_vsync(int64_t frame_time_ns);
|
||||
extern void droiddeck_wayland_output_resized(void);
|
||||
extern volatile int g_fps_limit;
|
||||
extern volatile int g_hide_shell;
|
||||
extern volatile int g_zero_copy;
|
||||
@@ -402,17 +401,6 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetOutputSize(JNIEnv
|
||||
g_output_h = h > 0 ? h : 0;
|
||||
}
|
||||
|
||||
/* The output's size changed while the compositor runs (a foldable opening or closing with the
|
||||
* session following the screen): store it and have the compositor thread tell the clients. */
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeResizeOutput(JNIEnv *env, jclass clazz, jint w, jint h) {
|
||||
(void)env; (void)clazz;
|
||||
if (w <= 0 || h <= 0) return;
|
||||
g_output_w = w;
|
||||
g_output_h = h;
|
||||
droiddeck_wayland_output_resized();
|
||||
}
|
||||
|
||||
/* Fullscreen mode + screen alignment (Container.FULLSCREEN_* / ALIGN_* values): how the scene is
|
||||
* fitted onto the output. Any thread, any time; the next frame uses it. */
|
||||
JNIEXPORT void JNICALL
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
/*
|
||||
* wp_single_pixel_buffer_manager_v1 (version 1): a wl_buffer of one pixel of one colour, which a
|
||||
* client stretches over a surface with wp_viewporter. KWin's Wayland backend - the desktop's KWin
|
||||
* nested in this compositor - refuses to start without it. The surface takes the pixel as a 1x1
|
||||
* image, the way it takes a wl_shm buffer (compositor.c, take_pixel).
|
||||
*/
|
||||
#include <stdlib.h>
|
||||
#include "droiddeck_ext.h"
|
||||
#include "single-pixel-buffer-v1-server-protocol.h"
|
||||
|
||||
struct single_pixel {
|
||||
uint8_t bgra[4]; /* premultiplied, as wl_shm's ARGB8888 lies in memory */
|
||||
};
|
||||
|
||||
static void buffer_destroy(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); }
|
||||
static const struct wl_buffer_interface buffer_impl = { .destroy = buffer_destroy };
|
||||
|
||||
static void buffer_free(struct wl_resource *r) { free(wl_resource_get_user_data(r)); }
|
||||
|
||||
int single_pixel_buffer_get(struct wl_resource *buffer, uint8_t bgra[4]) {
|
||||
if (!buffer || !wl_resource_instance_of(buffer, &wl_buffer_interface, &buffer_impl)) return 0;
|
||||
struct single_pixel *p = wl_resource_get_user_data(buffer);
|
||||
for (int i = 0; i < 4; i++) bgra[i] = p->bgra[i];
|
||||
return 1;
|
||||
}
|
||||
|
||||
static void mgr_destroy(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); }
|
||||
static void mgr_create_buffer(struct wl_client *c, struct wl_resource *r, uint32_t id,
|
||||
uint32_t red, uint32_t green, uint32_t blue, uint32_t alpha) {
|
||||
struct single_pixel *p = calloc(1, sizeof(*p));
|
||||
struct wl_resource *buffer = p ? wl_resource_create(c, &wl_buffer_interface, 1, id) : NULL;
|
||||
if (!buffer) { free(p); wl_client_post_no_memory(c); return; }
|
||||
/* The protocol's values span the whole u32 range; the top byte is the 8-bit channel. */
|
||||
p->bgra[0] = blue >> 24;
|
||||
p->bgra[1] = green >> 24;
|
||||
p->bgra[2] = red >> 24;
|
||||
p->bgra[3] = alpha >> 24;
|
||||
wl_resource_set_implementation(buffer, &buffer_impl, p, buffer_free);
|
||||
}
|
||||
static const struct wp_single_pixel_buffer_manager_v1_interface mgr_impl = {
|
||||
.destroy = mgr_destroy, .create_u32_rgba_buffer = mgr_create_buffer,
|
||||
};
|
||||
static void bind_manager(struct wl_client *c, void *data, uint32_t ver, uint32_t id) {
|
||||
struct wl_resource *r = wl_resource_create(c, &wp_single_pixel_buffer_manager_v1_interface, ver, id);
|
||||
if (!r) { wl_client_post_no_memory(c); return; }
|
||||
wl_resource_set_implementation(r, &mgr_impl, NULL, NULL);
|
||||
}
|
||||
|
||||
void single_pixel_init(struct wl_display *display) {
|
||||
wl_global_create(display, &wp_single_pixel_buffer_manager_v1_interface, 1, NULL, bind_manager);
|
||||
}
|
||||
@@ -22,6 +22,8 @@ class App : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
CrashHandler.install(this)
|
||||
// Before anything else touches the session prefs, so the first session already runs in Deck mode.
|
||||
com.droiddeck.launcher.session.SessionPrefs.settleDeckModeDefault(this)
|
||||
com.droiddeck.launcher.session.SessionPrefs.settleGpuDriverMode(this)
|
||||
com.droiddeck.launcher.update.AppUpdates.init(this)
|
||||
GpuClockPin.clearLeftover(this)
|
||||
|
||||
@@ -14,7 +14,6 @@ class HomeActivity : Activity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
requestedOrientation = com.droiddeck.launcher.session.SessionPrefs.orientation(this)
|
||||
routeHome()
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import java.io.File
|
||||
import android.content.pm.PackageManager
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
@@ -18,7 +17,6 @@ import android.util.Log
|
||||
import android.view.Display
|
||||
import android.view.KeyEvent
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
@@ -121,6 +119,7 @@ class MainActivity : ComponentActivity() {
|
||||
private var showNonAdreno by mutableStateOf<LinuxRuntimeInstaller.Release?>(null)
|
||||
private var glThread by mutableStateOf(true)
|
||||
private var noGlError by mutableStateOf(true)
|
||||
private var steamDeckMode by mutableStateOf(false)
|
||||
private var steamRepairQueued by mutableStateOf(false)
|
||||
private var mangoapp by mutableStateOf(true)
|
||||
private var steamController by mutableStateOf(SessionPrefs.CONTROLLER_DECK)
|
||||
@@ -140,7 +139,6 @@ class MainActivity : ComponentActivity() {
|
||||
private var pkgStage by mutableStateOf<String?>(null)
|
||||
private var pkgPercent by mutableIntStateOf(-1)
|
||||
private var desktopInstalled by mutableStateOf(false)
|
||||
private var desktopPresent by mutableStateOf(false)
|
||||
private var offlineAccount by mutableStateOf<String?>(null)
|
||||
private var offline by mutableStateOf(false)
|
||||
private var showPerformance by mutableStateOf(false)
|
||||
@@ -163,7 +161,6 @@ class MainActivity : ComponentActivity() {
|
||||
private var showPhantomGate by mutableStateOf(false)
|
||||
private var directAudio by mutableStateOf(false)
|
||||
private var stretch16x9 by mutableStateOf(false)
|
||||
private var orientation by mutableStateOf(android.content.pm.ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE)
|
||||
private var launcherFullscreen by mutableStateOf(true)
|
||||
private var animationsEnabled by mutableStateOf(true)
|
||||
private var storeEnabled by mutableStateOf(false)
|
||||
@@ -304,34 +301,29 @@ class MainActivity : ComponentActivity() {
|
||||
GameSaves.savesDir().mkdirs()
|
||||
pickSaveDir.launch(InAppFilePicker.buildDirIntent(this, getString(R.string.main_saves_pick_folder, name, layout.label(this)), GameSaves.savesDir().path))
|
||||
}
|
||||
/** The Games tab's +: the picked .exe becomes a game (or selects / switches the one it belongs to). */
|
||||
private fun addGameExe(path: String) {
|
||||
val exe = java.io.File(path)
|
||||
Thread({
|
||||
val result = try { com.droiddeck.launcher.frontend.AddedGames.addExe(this, exe) } catch (e: Exception) {
|
||||
Log.w(TAG, "add game: ${e.message}"); com.droiddeck.launcher.frontend.AddedGames.AddResult.NotFound
|
||||
}
|
||||
val game = when (result) {
|
||||
is com.droiddeck.launcher.frontend.AddedGames.AddResult.Added -> result.game
|
||||
is com.droiddeck.launcher.frontend.AddedGames.AddResult.Existing -> result.game
|
||||
is com.droiddeck.launcher.frontend.AddedGames.AddResult.Switched -> result.game
|
||||
else -> null
|
||||
}
|
||||
ui.post {
|
||||
if (game == null) {
|
||||
if (result is com.droiddeck.launcher.frontend.AddedGames.AddResult.Unreachable)
|
||||
android.widget.Toast.makeText(this, R.string.games_add_unreachable, android.widget.Toast.LENGTH_LONG).show()
|
||||
return@post
|
||||
}
|
||||
updateAddedGames(listOf(game.folder.path), then = { fetchAddedGameArt() })
|
||||
navRequest = "app:${game.steamAppId ?: game.appId.toInt()}"
|
||||
}
|
||||
}, "add-game-exe").start()
|
||||
private val pickAddedGamesDir = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
|
||||
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedPath(r.data)?.let { path ->
|
||||
SessionPrefs.setAddedGamesDirs(this, addedGamesDirs + path)
|
||||
addedGamesDirs = SessionPrefs.addedGamesDirs(this)
|
||||
addedGamesArt = SessionPrefs.addedGamesArt(this)
|
||||
refreshAddedGames()
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
private var pendingAddedGame: String? = null
|
||||
private val pickAddedGameExe = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
|
||||
val folder = pendingAddedGame ?: return@registerForActivityResult
|
||||
pendingAddedGame = null
|
||||
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedPath(r.data)?.let { path ->
|
||||
SessionPrefs.setAddedGameExe(this, folder, path)
|
||||
refreshAddedGames()
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
private var addedGamesDirs by mutableStateOf<List<String>>(emptyList())
|
||||
private var addedGamesArt by mutableStateOf(true)
|
||||
@Volatile private var artFetchRunning = false
|
||||
/** The user entered a SteamGridDB API key of their own. */
|
||||
private var sgdbUserKey by mutableStateOf(false)
|
||||
private var addedGames by mutableStateOf<List<com.droiddeck.launcher.ui.AddedGameRow>>(emptyList())
|
||||
private val pickRomsDir = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
|
||||
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedPath(r.data)?.let { path ->
|
||||
SessionPrefs.setRomsDir(this, path)
|
||||
@@ -343,6 +335,7 @@ class MainActivity : ComponentActivity() {
|
||||
private var resolution by mutableStateOf(com.droiddeck.launcher.session.SessionDisplay.DEFAULT_RESOLUTION)
|
||||
private var fexPreset by mutableStateOf("")
|
||||
private var forceSsbs by mutableStateOf(false)
|
||||
private var steamChannel by mutableStateOf("steamdeck_publicbeta")
|
||||
private var runSteamAtStartup by mutableStateOf(false)
|
||||
private var theme by mutableStateOf("graphite")
|
||||
private var appScale by mutableStateOf(com.droiddeck.launcher.core.AppUiPrefs.DEFAULT_SCALE)
|
||||
@@ -356,6 +349,7 @@ class MainActivity : ComponentActivity() {
|
||||
private var steamDownloadsInBackground by mutableStateOf(false)
|
||||
private var oscMode by mutableStateOf(SessionPrefs.OSC_AUTO)
|
||||
private var backActionsInverted by mutableStateOf(false)
|
||||
private var renderer by mutableStateOf("vulkan")
|
||||
private var gameStorage by mutableStateOf("")
|
||||
private var storageDiagnostics by mutableStateOf(false)
|
||||
private var storageOptions by mutableStateOf<List<GameStorage.Option>>(emptyList())
|
||||
@@ -364,8 +358,6 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
private var romsDir by mutableStateOf<String?>(null)
|
||||
private var steamGames by mutableStateOf<List<Library.SteamGame>>(emptyList())
|
||||
@Volatile private var componentSweepDone = false
|
||||
@Volatile private var componentSweepSession: String? = null
|
||||
private var emulatorList by mutableStateOf<List<Library.Emulator>>(emptyList())
|
||||
private var runningLabel by mutableStateOf<String?>(null)
|
||||
private var logsEnabled by mutableStateOf(true)
|
||||
@@ -535,8 +527,6 @@ class MainActivity : ComponentActivity() {
|
||||
// Last start's game list, so the Steam wall is up on the first frame; refresh() replaces it.
|
||||
steamGames = if (shortcutPicker) emptyList() else com.droiddeck.launcher.frontend.LibraryCache.load(this)
|
||||
backActionsInverted = SessionPrefs.backActionsInverted(this)
|
||||
orientation = SessionPrefs.orientation(this)
|
||||
requestedOrientation = orientation
|
||||
launcherFullscreen = SessionPrefs.launcherFullscreen(this)
|
||||
animationsEnabled = SessionPrefs.animationsEnabled(this)
|
||||
storeEnabled = SessionPrefs.storeEnabled(this)
|
||||
@@ -566,7 +556,7 @@ class MainActivity : ComponentActivity() {
|
||||
shortcutLibraryScanning = shortcutLibraryScanning,
|
||||
gameSyncFolder = gameSyncFolder,
|
||||
busy = busy, stage = stage, percent = percent,
|
||||
desktopInstalled = desktopInstalled, desktopPresent = desktopPresent,
|
||||
desktopInstalled = desktopInstalled,
|
||||
offlineAccount = offlineAccount, offline = offline,
|
||||
frameGenLabel = frameGenLabel, romsDir = romsDir, logsEnabled = logsEnabled, agentCommands = agentCommands,
|
||||
steamGames = steamGames, emulators = emulatorList, running = runningLabel,
|
||||
@@ -594,7 +584,6 @@ class MainActivity : ComponentActivity() {
|
||||
updates = updates.state(),
|
||||
phantomProcessStatus = phantomProcessStatus,
|
||||
showPhantomGate = showPhantomGate,
|
||||
orientation = orientation,
|
||||
launcherFullscreen = launcherFullscreen,
|
||||
animationsEnabled = animationsEnabled,
|
||||
storeEnabled = storeEnabled,
|
||||
@@ -602,46 +591,23 @@ class MainActivity : ComponentActivity() {
|
||||
gameStoresSpeedTier = gameStoresSpeedTier,
|
||||
storesOpenTab = storesOpenTab,
|
||||
navigate = navRequest,
|
||||
addedGamesArt = addedGamesArt,
|
||||
sgdbUserKey = sgdbUserKey,
|
||||
storesShowMature = storesShowMature,
|
||||
storeDownloadsActive = com.droiddeck.launcher.stores.StoresState.activeDownloads,
|
||||
),
|
||||
FrontEndActions(
|
||||
onPlay = { startSteamSession() },
|
||||
// The client's desktop UI, with gamescope's Deck integration disabled.
|
||||
// Steam's desktop client as a window on the desktop: under gamescope the
|
||||
// client puts itself into Big Picture whatever it is started with.
|
||||
onPlayDesktopUi = {
|
||||
startSession(Intent(this, SessionActivity::class.java)
|
||||
.putExtra(SessionService.EXTRA_MODE, SessionService.MODE_STEAM)
|
||||
.putExtra(SessionService.EXTRA_MODE, SessionService.MODE_DESKTOP)
|
||||
.putExtra(SessionService.EXTRA_STEAM_UI, "desktop"), steamSession = true)
|
||||
},
|
||||
// A store game may need something done first (an Epic game its sign-in code).
|
||||
onSteamGame = { g ->
|
||||
if (shortcutPicker) chooseGameShortcut(g)
|
||||
else {
|
||||
// The launch takes the Windows components already on; the game's list is
|
||||
// looked at again in the background once the session is over.
|
||||
com.droiddeck.launcher.frontend.AutoComponents.beforeLaunch(this, g)
|
||||
com.droiddeck.launcher.stores.StoreLaunch.prepare(this, g) { launchGame(g) }
|
||||
}
|
||||
},
|
||||
onSteamGame = { g -> if (shortcutPicker) chooseGameShortcut(g) else com.droiddeck.launcher.stores.StoreLaunch.prepare(this, g) { launchGame(g) } },
|
||||
onGameShortcut = { g -> com.droiddeck.launcher.frontend.GameShortcuts.pin(this, g) },
|
||||
onExportGameFile = { g -> pickGameExport(g) },
|
||||
onSyncGameFiles = { pickGameExport(null) },
|
||||
onAddGameExe = { path -> addGameExe(path) },
|
||||
onAddedGamesChanged = { folders ->
|
||||
// New games want art; an edit does not.
|
||||
val known = steamGames.mapNotNull { it.gameFiles?.path }.toSet()
|
||||
updateAddedGames(folders, then = { if (folders.any { it !in known }) fetchAddedGameArt() })
|
||||
},
|
||||
onAddedGameRemoved = { folder, appId, name -> removeAddedGame(folder, appId, name) },
|
||||
onAddedGamesArt = { on -> SessionPrefs.setAddedGamesArt(this, on); addedGamesArt = on; if (on) refreshAddedGames() },
|
||||
onSgdbKey = { key ->
|
||||
Thread({
|
||||
com.droiddeck.launcher.frontend.SteamGridDb.setUserKey(this, key)
|
||||
ui.post { sgdbUserKey = key.isNotBlank(); refreshAddedGames() }
|
||||
}, "sgdb-key").start()
|
||||
},
|
||||
onStopGameFileSync = {
|
||||
Thread({
|
||||
GameFileSync.disable(this)
|
||||
@@ -684,7 +650,6 @@ class MainActivity : ComponentActivity() {
|
||||
// The activity re-attaches to the session that is running; nothing restarts.
|
||||
onResume = { startActivity(Intent(this, SessionActivity::class.java)) },
|
||||
onSteamSettings = { openModeSettings(SessionService.MODE_STEAM) },
|
||||
onRemoveDesktop = { removeDesktop() },
|
||||
onDesktopSettings = { openModeSettings(SessionService.MODE_DESKTOP) },
|
||||
onInstallPackage = { id -> installPackage(id) },
|
||||
onRemovePackage = { id -> removePackage(id) },
|
||||
@@ -749,11 +714,6 @@ class MainActivity : ComponentActivity() {
|
||||
com.droiddeck.launcher.core.AppUiPrefs.setScale(this, percent)
|
||||
appScale = com.droiddeck.launcher.core.AppUiPrefs.scale(this)
|
||||
},
|
||||
onOrientation = { value ->
|
||||
SessionPrefs.setOrientation(this, value)
|
||||
orientation = value
|
||||
requestedOrientation = value
|
||||
},
|
||||
onLauncherFullscreen = { on ->
|
||||
SessionPrefs.setLauncherFullscreen(this, on)
|
||||
launcherFullscreen = on
|
||||
@@ -843,7 +803,6 @@ class MainActivity : ComponentActivity() {
|
||||
onSteamButton = { on -> ControllerPrefs.setSteamButton(this, on); refreshController() },
|
||||
onQamButton = { on -> ControllerPrefs.setQamButton(this, on); refreshController() },
|
||||
onKeyboardButton = { on -> ControllerPrefs.setKeyboardButton(this, on); refreshController() },
|
||||
onSelectSteam = { on -> ControllerPrefs.setSelectSteam(this, on); refreshController() },
|
||||
onEditLayout = { startActivity(Intent(this, ControllerEditorActivity::class.java)) },
|
||||
onResetLayout = { ControllerPrefs.resetAllLayouts(this); refreshController() },
|
||||
onMapping = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showMapping = true },
|
||||
@@ -908,13 +867,8 @@ class MainActivity : ComponentActivity() {
|
||||
SessionPrefs.setMicAsked(this)
|
||||
}
|
||||
if (wanted.isNotEmpty()) requestPermissions(wanted.toTypedArray(), 1)
|
||||
// A store sign-in an earlier build left in plain text is sealed with the Keystore key.
|
||||
Thread({ com.droiddeck.launcher.stores.StoreAccounts.encryptAll(this) }, "store-credentials").start()
|
||||
// A session folder left without its ending - the process was killed - gets it now.
|
||||
if (!SessionState.running) Thread({
|
||||
com.droiddeck.launcher.session.LogMigration.run(this)
|
||||
com.droiddeck.launcher.frontend.GamesFolderMigration.run(this)
|
||||
SessionLogShare.clear(this)
|
||||
SessionArtifacts.finishAbandoned(this)
|
||||
SessionArtifacts.scrubOlder(this)
|
||||
SessionArtifacts.prune(this)
|
||||
@@ -955,20 +909,10 @@ class MainActivity : ComponentActivity() {
|
||||
} else {
|
||||
View.SYSTEM_UI_FLAG_LAYOUT_STABLE
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= 28) {
|
||||
window.attributes = window.attributes.apply {
|
||||
layoutInDisplayCutoutMode = if (launcherFullscreen) {
|
||||
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
|
||||
} else {
|
||||
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** On the Components page the pad's LB / RB step through GPU drivers, FEX, DXVK and VKD3D-Proton, wrapping around. */
|
||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||
if (event.keyCode == KeyEvent.KEYCODE_BUTTON_A) com.droiddeck.launcher.ui.HeldKeys.confirm = event.action == KeyEvent.ACTION_DOWN
|
||||
if (showComponents && (event.keyCode == KeyEvent.KEYCODE_BUTTON_L1 || event.keyCode == KeyEvent.KEYCODE_BUTTON_R1)) {
|
||||
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
|
||||
val all = listOf(com.droiddeck.launcher.ui.GPU_TAB) + ComponentsManager.COMPONENTS
|
||||
@@ -977,21 +921,11 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
return true
|
||||
}
|
||||
// Compose throws when its focus tree has lost the focused node; one key is dropped
|
||||
// instead of the app.
|
||||
return try {
|
||||
super.dispatchKeyEvent(event)
|
||||
} catch (e: IllegalStateException) {
|
||||
if (e.message?.contains("active focus target") != true) throw e
|
||||
android.util.Log.w("MainActivity", "key ${event.keyCode} dropped: ${e.message}")
|
||||
true
|
||||
}
|
||||
return super.dispatchKeyEvent(event)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
orientation = SessionPrefs.orientation(this)
|
||||
requestedOrientation = orientation
|
||||
refreshWifiDiscovery()
|
||||
// Back from a store's sign-in page: the Stores chips show the account at once. A store
|
||||
// install finishing while this screen is up rebuilds the Games list through the listener.
|
||||
@@ -1232,7 +1166,6 @@ class MainActivity : ComponentActivity() {
|
||||
private fun refreshPackages() {
|
||||
packageRows = catalog?.map { PackageRow(it.id, it.kind, it.notes) }
|
||||
desktopInstalled = DesktopCatalog.desktopInstalled(this)
|
||||
desktopPresent = DesktopCatalog.desktopPresent(this)
|
||||
}
|
||||
|
||||
private fun launchProgram(path: String) {
|
||||
@@ -1286,19 +1219,20 @@ class MainActivity : ComponentActivity() {
|
||||
steamDownloadsInBackground = steamDownloadsInBackground,
|
||||
pipSupported = com.droiddeck.launcher.session.SessionPipController.supported(this),
|
||||
pipAutoEnter = pipAutoEnter,
|
||||
unfoldedControls = if (mode == SessionService.MODE_STEAM &&
|
||||
com.droiddeck.launcher.session.SessionDisplay.foldable(this)) unfoldedControls else null,
|
||||
oscMode = if (mode == SessionService.MODE_STEAM) oscMode else null,
|
||||
backActionsInverted = backActionsInverted,
|
||||
directAudio = if (mode == SessionService.MODE_STEAM) directAudio else null,
|
||||
stretch16x9 = if (mode == SessionService.MODE_STEAM) stretch16x9 else null,
|
||||
mic = if (mode == SessionService.MODE_STEAM) mic else null,
|
||||
renderer = if (mode == SessionService.MODE_DESKTOP) renderer else null,
|
||||
gameStorage = if (mode == SessionService.MODE_STEAM) gameStorage else null,
|
||||
storageOptions = storageOptions,
|
||||
storageDiagnostics = mode == SessionService.MODE_STEAM && storageDiagnostics,
|
||||
fexPreset = if (mode == SessionService.MODE_STEAM) fexPreset else null,
|
||||
forceSsbs = forceSsbs,
|
||||
syncBackend = if (mode == SessionService.MODE_STEAM) SessionPrefs.syncBackendOf(fastSync, fsyncFirst, syncFallback) else null,
|
||||
steamChannel = if (mode == SessionService.MODE_STEAM) steamChannel else null,
|
||||
steamDeckMode = mode == SessionService.MODE_STEAM && steamDeckMode,
|
||||
steamRepairQueued = steamRepairQueued,
|
||||
mangoapp = mangoapp,
|
||||
steamController = if (mode == SessionService.MODE_STEAM) steamController else null,
|
||||
@@ -1308,6 +1242,9 @@ class MainActivity : ComponentActivity() {
|
||||
wifiDiscoveryLocation = wifiDiscoveryLocation,
|
||||
wifiDiscoveryAsked = wifiDiscoveryAsked,
|
||||
wifiDiscoveryBlocked = wifiDiscoveryBlocked,
|
||||
addedGamesDirs = if (mode == SessionService.MODE_STEAM) addedGamesDirs else null,
|
||||
addedGames = if (mode == SessionService.MODE_STEAM) addedGames else emptyList(),
|
||||
addedGamesArt = addedGamesArt,
|
||||
deckyInstalled = if (mode == SessionService.MODE_STEAM) decky.deckyInstalled else null,
|
||||
deckyLatestRelease = if (mode == SessionService.MODE_STEAM) decky.deckyReleases.firstOrNull() else null,
|
||||
deckyChecking = decky.deckyChecking, deckyStage = decky.deckyStage, deckyPercent = decky.deckyPercent,
|
||||
@@ -1343,7 +1280,6 @@ class MainActivity : ComponentActivity() {
|
||||
steamDownloadsInBackground = enabled
|
||||
},
|
||||
onPipAutoEnter = { on -> SessionPrefs.setPipAutoEnter(this, on); pipAutoEnter = on },
|
||||
onUnfoldedControls = { on -> SessionPrefs.setUnfoldedControls(this, on); unfoldedControls = on },
|
||||
onOsc = { o -> SessionPrefs.setOscMode(this, o); oscMode = o },
|
||||
onBackActionsInverted = { inverted ->
|
||||
SessionPrefs.setBackActionsInverted(this, inverted)
|
||||
@@ -1360,6 +1296,7 @@ class MainActivity : ComponentActivity() {
|
||||
requestPermissions(arrayOf(Manifest.permission.RECORD_AUDIO), 2)
|
||||
}
|
||||
},
|
||||
onRenderer = { r -> SessionPrefs.setDesktopRenderer(this, r); renderer = r },
|
||||
onGameStorage = { path, label -> setGameStorage(path, label) },
|
||||
onPickGameStorageFolder = {
|
||||
pickGameStorage.launch(InAppFilePicker.buildDirIntent(this, getString(R.string.main_pick_game_storage), gameStorage.ifEmpty { null }))
|
||||
@@ -1376,13 +1313,27 @@ class MainActivity : ComponentActivity() {
|
||||
fsyncFirst = SessionPrefs.fsyncFirst(this)
|
||||
syncFallback = SessionPrefs.syncFallback(this)
|
||||
},
|
||||
onSteamChannel = { id -> SessionPrefs.setSteamChannel(this, id); steamChannel = id },
|
||||
onSteamRepair = { steamRepairQueued = SteamRepair.queue(this) },
|
||||
onSteamDeckMode = { on ->
|
||||
SessionPrefs.setSteamDeckMode(this, on)
|
||||
steamDeckMode = on
|
||||
steamChannel = SessionPrefs.steamChannel(this)
|
||||
},
|
||||
onMangoapp = { on -> SessionPrefs.setMangoapp(this, on); mangoapp = on },
|
||||
onSteamController = { id -> SessionPrefs.setSteamController(this, id); steamController = id },
|
||||
onRunSteamAtStartup = { on ->
|
||||
SessionPrefs.setRunSteamAtStartup(this, on)
|
||||
runSteamAtStartup = on
|
||||
},
|
||||
onPickAddedGamesDir = { pickAddedGamesDir.launch(InAppFilePicker.buildDirIntent(this, getString(R.string.main_pick_added_games), addedGamesDirs.lastOrNull())) },
|
||||
onAddedGamesArt = { on -> SessionPrefs.setAddedGamesArt(this, on); addedGamesArt = on; if (on) refreshAddedGames() },
|
||||
onForgetAddedGamesDir = { dir -> SessionPrefs.setAddedGamesDirs(this, addedGamesDirs - dir); addedGamesDirs = SessionPrefs.addedGamesDirs(this); refreshAddedGames(); refresh() },
|
||||
onAddedGameExe = { folder, path -> SessionPrefs.setAddedGameExe(this, folder, path); refreshAddedGames(); refresh() },
|
||||
onPickAddedGameExe = { folder ->
|
||||
pendingAddedGame = folder
|
||||
pickAddedGameExe.launch(InAppFilePicker.buildIntent(this, listOf("exe"), getString(R.string.main_pick_game_exe), folder))
|
||||
},
|
||||
onDeckyInstall = { release -> decky.installDecky(release) },
|
||||
onDeckyCheck = { decky.refreshDecky() },
|
||||
onDeckyEnabled = { enabled -> DeckyManager.setSupervisorEnabled(this, enabled); decky.deckySupervisor = enabled },
|
||||
@@ -1437,44 +1388,18 @@ class MainActivity : ComponentActivity() {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The Games tab after these added games were added or edited: each read alone and put in
|
||||
* place of its old entry (or added), no library-wide scan. [then] runs after, on the main thread.
|
||||
*/
|
||||
private fun updateAddedGames(folders: List<String>, then: () -> Unit = {}) {
|
||||
if (folders.isEmpty()) return
|
||||
Thread({
|
||||
val fresh = folders.mapNotNull { f ->
|
||||
com.droiddeck.launcher.frontend.AddedGames.single(this, f)?.let { com.droiddeck.launcher.frontend.Library.addedGame(this, it) }
|
||||
}
|
||||
// An added or edited game's Windows components, from its source's list.
|
||||
fresh.forEach { com.droiddeck.launcher.frontend.AutoComponents.refreshLater(this, it) }
|
||||
ui.post {
|
||||
val paths = folders.toSet()
|
||||
steamGames = (steamGames.filterNot { it.library == Library.ADDED && it.gameFiles?.path in paths } + fresh).distinctBy { it.gameId }
|
||||
then()
|
||||
}
|
||||
}, "added-games-update").start()
|
||||
}
|
||||
|
||||
/** An added game removed: off the Games tab now; the rest (cache, Steam, listing) off the main thread. */
|
||||
private fun removeAddedGame(folder: String, appId: Long?, name: String) {
|
||||
steamGames = steamGames.filterNot { it.library == Library.ADDED && it.gameFiles?.path == folder }
|
||||
Thread({ com.droiddeck.launcher.frontend.AddedGames.remove(this, java.io.File(folder), appId, name) }, "added-game-remove").start()
|
||||
}
|
||||
|
||||
/** After the added games changed: art that is missing is fetched (off the main thread). */
|
||||
/** The added games as the settings page lists them; a scan of the folder, on this thread (one level, small). */
|
||||
/** The added games for the session settings: a folder walk, so off the main thread. */
|
||||
private fun refreshAddedGames() {
|
||||
sgdbKeyKnown()
|
||||
fetchAddedGameArt()
|
||||
Thread({
|
||||
val games = scanAddedGames()
|
||||
ui.post { addedGames = games; fetchAddedGameArt() }
|
||||
}, "added-games").start()
|
||||
}
|
||||
|
||||
/** Whether the user has a SteamGridDB key of their own, for Setup; read off the main thread. */
|
||||
private fun sgdbKeyKnown() {
|
||||
Thread({
|
||||
val set = com.droiddeck.launcher.frontend.SteamGridDb.userKey(this).isNotEmpty()
|
||||
ui.post { sgdbUserKey = set }
|
||||
}, "sgdb-key-state").start()
|
||||
/** Walks the added-games folders, which can sit on slow shared storage or an SD card. */
|
||||
private fun scanAddedGames() = com.droiddeck.launcher.frontend.AddedGames.scan(this).map { g ->
|
||||
com.droiddeck.launcher.ui.AddedGameRow(g.folder.path, g.folderName(), g.exe.path, g.exe.name, (g.candidates + g.exe).map { c -> c.path to c.name }.distinctBy { it.first })
|
||||
}
|
||||
|
||||
private fun fetchAddedGameArt() {
|
||||
@@ -1494,11 +1419,9 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
private var pipAutoEnter by mutableStateOf(false)
|
||||
private var unfoldedControls by mutableStateOf(false)
|
||||
|
||||
private fun openModeSettings(mode: String) {
|
||||
pipAutoEnter = SessionPrefs.pipAutoEnter(this)
|
||||
unfoldedControls = SessionPrefs.unfoldedControls(this)
|
||||
showPerformance = false
|
||||
showProtons = false
|
||||
showComponents = false
|
||||
@@ -1509,10 +1432,13 @@ class MainActivity : ComponentActivity() {
|
||||
fastSync = SessionPrefs.fastSync(this)
|
||||
fsyncFirst = SessionPrefs.fsyncFirst(this)
|
||||
syncFallback = SessionPrefs.syncFallback(this)
|
||||
steamChannel = SessionPrefs.steamChannel(this)
|
||||
steamDeckMode = SessionPrefs.steamDeckMode(this)
|
||||
steamRepairQueued = SteamRepair.queued(this)
|
||||
mangoapp = SessionPrefs.mangoapp(this)
|
||||
steamController = SessionPrefs.steamController(this)
|
||||
runSteamAtStartup = SessionPrefs.runSteamAtStartup(this)
|
||||
addedGamesDirs = SessionPrefs.addedGamesDirs(this)
|
||||
hdrOn = SessionPrefs.hdr(this, mode)
|
||||
fpsLimit = SessionPrefs.fpsLimit(this, mode)
|
||||
upscaler = SessionPrefs.upscaler(this)
|
||||
@@ -1527,6 +1453,7 @@ class MainActivity : ComponentActivity() {
|
||||
stretch16x9 = SessionPrefs.stretch16x9(this)
|
||||
mic = SessionPrefs.micEnabled(this)
|
||||
refreshWifiDiscovery()
|
||||
renderer = SessionPrefs.desktopRenderer(this)
|
||||
gameStorage = SessionPrefs.gameStorage(this)
|
||||
storageDiagnostics = SessionPrefs.storageDiagnosticsEnabled(this)
|
||||
settingsMode = mode
|
||||
@@ -1534,10 +1461,12 @@ class MainActivity : ComponentActivity() {
|
||||
// added-games folders, the storage volumes) lands while it animates in.
|
||||
Thread({
|
||||
drivers.refreshDrivers()
|
||||
val games = scanAddedGames()
|
||||
val storage = GameStorage.options(this)
|
||||
val deckyInstalled = if (mode == SessionService.MODE_STEAM) DeckyManager.installed(this) else null
|
||||
val deckySupervisor = mode == SessionService.MODE_STEAM && DeckyManager.supervisorEnabled(this)
|
||||
ui.post {
|
||||
addedGames = games
|
||||
storageOptions = storage
|
||||
if (mode == SessionService.MODE_STEAM) {
|
||||
decky.deckyInstalled = deckyInstalled
|
||||
@@ -1567,6 +1496,7 @@ class MainActivity : ComponentActivity() {
|
||||
clientOverride = SessionPrefs.clientCpusOverride(this)
|
||||
glThread = SessionPrefs.glThread(this)
|
||||
noGlError = SessionPrefs.noGlError(this)
|
||||
steamDeckMode = SessionPrefs.steamDeckMode(this)
|
||||
clientCores = CpuCores.parse(SessionPrefs.clientCpus(this)).ifEmpty { CpuCores.all.toSet() }
|
||||
gameCores = CpuCores.parse(SessionPrefs.gameCpus(this)).ifEmpty { CpuCores.all.toSet() }
|
||||
tuSysmem = SessionPrefs.tuSysmem(this)
|
||||
@@ -1585,9 +1515,7 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
private fun refresh() {
|
||||
if (!busy && LinuxRuntimeInstaller.isBusy()) followRuntimeOperation { LinuxRuntimeInstaller.attach(it) }
|
||||
addedGamesArt = SessionPrefs.addedGamesArt(this)
|
||||
desktopInstalled = DesktopCatalog.desktopInstalled(this)
|
||||
desktopPresent = DesktopCatalog.desktopPresent(this)
|
||||
offlineAccount = OfflineMode.account(this)
|
||||
offline = OfflineMode.enabled(this)
|
||||
installed = LinuxRuntimeInstaller.installedVersion(this)
|
||||
@@ -1607,39 +1535,13 @@ class MainActivity : ComponentActivity() {
|
||||
val scanGeneration = ++libraryScanGeneration
|
||||
if (shortcutPicker) shortcutLibraryScanning = true
|
||||
Thread({
|
||||
// Steam's own games first, from their manifests alone (quick), beside the added games
|
||||
// already listed: a game Steam installed during the session shows as the session ends,
|
||||
// without waiting for the added games' walk below. Its Windows components' list is
|
||||
// queued then too, on the low-priority worker, so its page is usually ready.
|
||||
if (ready) {
|
||||
val quick = runCatching { Library.steamGames(this) }.getOrNull()
|
||||
if (quick != null) ui.post {
|
||||
if (scanGeneration == libraryScanGeneration && steamGames.isNotEmpty()) {
|
||||
val before = steamGames.map { it.gameId }.toSet()
|
||||
val fresh = quick.filter { it.gameId !in before }
|
||||
if (fresh.isNotEmpty()) {
|
||||
steamGames = (quick + steamGames.filter { it.library == Library.ADDED }).distinctBy { it.gameId }
|
||||
fresh.forEach { com.droiddeck.launcher.frontend.AutoComponents.queueGame(this, it, asked = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Then one update with the whole list: the wall places games by their position in it,
|
||||
// so a partial list first would shuffle every capsule when the rest arrived.
|
||||
// LibraryCache covers the wait.
|
||||
// One update with the whole list: the wall places games by their position in it, so a
|
||||
// partial list first would shuffle every capsule when the rest arrived. LibraryCache
|
||||
// covers the wait.
|
||||
val games = if (ready) Library.launchableGames(this) else emptyList()
|
||||
val emus = Library.emulators(this) { id -> DesktopCatalog.installed(this, id) != null }
|
||||
val all = games.distinctBy { it.gameId }
|
||||
if (ready) com.droiddeck.launcher.frontend.LibraryCache.save(this, all)
|
||||
// Once per app start: the Steam games' Windows components from Steam's lists.
|
||||
// Queued only, after the launcher has been up a little; the worker takes them one at a time.
|
||||
// And again after each session (a game installed in it, files changed).
|
||||
val endedSession = SessionState.sessionId.takeIf { !SessionState.running && it != null && it != componentSweepSession }
|
||||
if (ready && (!componentSweepDone || endedSession != null)) {
|
||||
componentSweepDone = true
|
||||
endedSession?.let { componentSweepSession = it }
|
||||
ui.postDelayed({ Thread({ com.droiddeck.launcher.frontend.AutoComponents.sweep(this, all) }, "components-sweep").start() }, 10_000)
|
||||
}
|
||||
ui.post {
|
||||
if (scanGeneration == libraryScanGeneration) {
|
||||
steamGames = all
|
||||
@@ -1726,15 +1628,6 @@ class MainActivity : ComponentActivity() {
|
||||
protons.protonBusyId != null || ProtonExtras.installInProgress || pkgStage != null ||
|
||||
components.compBusy != null || saveBusy != null || com.droiddeck.launcher.store.UserAppsState.working != null
|
||||
|
||||
private fun removeDesktop() {
|
||||
if (runtimeChangesBlocked()) return
|
||||
val operation = LinuxRuntimeInstaller.beginMaintenance(this, getString(R.string.desktop_removing)) ?: return
|
||||
com.droiddeck.launcher.runtime.RuntimeInstallService.keepRemovalAlive(this)
|
||||
followRuntimeOperation { listener ->
|
||||
operation.run(listener) { progress -> DesktopCatalog.removeDesktop(this, progress) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeRuntime() {
|
||||
if (runtimeChangesBlocked()) return
|
||||
val removal = LinuxRuntimeInstaller.beginUninstall(this) ?: return
|
||||
@@ -1757,7 +1650,7 @@ class MainActivity : ComponentActivity() {
|
||||
private fun followRuntimeOperation(run: (LinuxRuntimeInstaller.ProgressListener) -> Boolean?) {
|
||||
busy = true
|
||||
failed = false
|
||||
stage = getString(if (LinuxRuntimeInstaller.isMaintaining()) R.string.desktop_removing else if (LinuxRuntimeInstaller.isRemoving()) R.string.main_removing_runtime else R.string.store_starting)
|
||||
stage = getString(if (LinuxRuntimeInstaller.isRemoving()) R.string.main_removing_runtime else R.string.store_starting)
|
||||
percent = -1
|
||||
Thread({
|
||||
val ok = run(LinuxRuntimeInstaller.ProgressListener { s, p ->
|
||||
|
||||
@@ -19,7 +19,6 @@ import android.view.MotionEvent
|
||||
import android.view.ViewConfiguration
|
||||
import android.view.Display
|
||||
import android.view.SurfaceHolder
|
||||
import android.view.Gravity
|
||||
import android.view.SurfaceView
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
@@ -52,7 +51,6 @@ import com.droiddeck.launcher.input.ControllerPrefs
|
||||
import com.droiddeck.launcher.input.SessionClipboard
|
||||
import com.droiddeck.launcher.input.OnScreenControls
|
||||
import com.droiddeck.launcher.input.PadBridge
|
||||
import com.droiddeck.launcher.input.SteamControllerBle
|
||||
import com.droiddeck.launcher.input.PointerGestures
|
||||
import com.droiddeck.launcher.input.SecondScreenDisplay
|
||||
import com.droiddeck.launcher.input.SecondScreenDisplays
|
||||
@@ -111,10 +109,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private lateinit var pip: com.droiddeck.launcher.session.SessionPipController
|
||||
private var pipUi by mutableStateOf(false)
|
||||
private var pipAutoEnter by mutableStateOf(false)
|
||||
/** Unfolded controls: the setting, and the Deck controls under the game while they show. */
|
||||
private var unfoldedControls by mutableStateOf(false)
|
||||
private var unfoldedPanel: com.droiddeck.launcher.ui.DeckControlsPanel? = null
|
||||
private lateinit var sessionRoot: FrameLayout
|
||||
private val guestKeysDown = mutableSetOf<Int>()
|
||||
private lateinit var surfaceView: SurfaceView
|
||||
private lateinit var sessionOverlay: ComposeView
|
||||
@@ -125,7 +119,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private var padMotion: com.droiddeck.launcher.input.PadMotion? = null
|
||||
/** Between onResume and onPause. */
|
||||
private var resumed = false
|
||||
private val deckPadListener: () -> Unit = { uiHandler.post { updatePadMotion(); openDeckRing("deck pad changed"); applyUnfoldedControls() } }
|
||||
private val deckPadListener: () -> Unit = { uiHandler.post { updatePadMotion(); openDeckRing("deck pad changed") } }
|
||||
|
||||
/**
|
||||
* A Deck's controller is there from the moment it boots, and the client looks for it once, as
|
||||
@@ -138,8 +132,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private fun openDeckRing(why: String) {
|
||||
val deck = SessionState.deckPad
|
||||
val opened = if (deck) padBridge?.start() else null
|
||||
// Every pad already connected is a player from the start, before its first press.
|
||||
padBridge?.claimConnected()
|
||||
Log.i(TAG, "deck ring ($why): deck pad $deck" + when (opened) {
|
||||
null -> ", left for first input"
|
||||
true -> ", ring open"
|
||||
@@ -150,21 +142,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
/** Motion is read while the session shows and only if the pad is a Deck controller - which
|
||||
* the service can decide after this activity has resumed (SessionState.deckPadListener). */
|
||||
private fun updatePadMotion() {
|
||||
// A Steam Controller brings its own gyro (SteamControllerBle); the phone's would fight it.
|
||||
if (resumed && !pipUi && SessionState.deckPad && !SteamControllerBle.connected) padMotion?.start()
|
||||
else padMotion?.stop()
|
||||
if (resumed && !pipUi && SessionState.deckPad) padMotion?.start() else padMotion?.stop()
|
||||
}
|
||||
private var steamController: SteamControllerBle? = null
|
||||
private var onScreenControls: OnScreenControls? = null
|
||||
/** Steam's touch controls (SteamTouchControls), made once the session offers the device. */
|
||||
private var steamTouchControls: com.droiddeck.launcher.input.SteamTouchControls? = null
|
||||
private var steamTouchAvailable by mutableStateOf(false)
|
||||
private val steamTouchRetry = Runnable { updateOnScreenControls() }
|
||||
/** While Steam's touch controls are up: how the game's touch config says touches outside them
|
||||
* act (Steam Link's input and mouse modes), in place of the drawer's touch mode. */
|
||||
private var steamTouchOptions: com.droiddeck.launcher.input.SteamTouchConfig.Options? = null
|
||||
/** The touch mode touches are routed by: the drawer's, or the game's while Steam's controls are up. */
|
||||
private var routedTouchMode = SessionPrefs.TOUCH_AUTO
|
||||
private var keyboard: KeyboardHost? = null
|
||||
private var controllerSettings by mutableStateOf<ControllerPrefs.Settings?>(null)
|
||||
private val sessionClipboard by lazy {
|
||||
@@ -252,8 +232,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private var drawerPage by mutableStateOf(SessionDrawerPage.CONTROLLER)
|
||||
private var drawerControllerActive by mutableStateOf(false)
|
||||
private var backActionsInverted by mutableStateOf(false)
|
||||
/** What the pad is to the Steam client (SessionPrefs.CONTROLLER_*), switched from the drawer. */
|
||||
private var padType by mutableStateOf(SessionPrefs.CONTROLLER_DECK)
|
||||
/** The on-screen PC keyboard (ui/PcKeyboard): real key presses, Esc and F1 included. */
|
||||
private var pcKeyboardOpen by mutableStateOf(false)
|
||||
private var hudOn by mutableStateOf(true)
|
||||
@@ -312,13 +290,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private val deviceListener = object : InputManager.InputDeviceListener {
|
||||
override fun onInputDeviceAdded(deviceId: Int) {
|
||||
logInputDevice("connected", deviceId)
|
||||
padBridge?.claimConnected()
|
||||
updateOnScreenControls()
|
||||
updatePointerCapture()
|
||||
}
|
||||
override fun onInputDeviceRemoved(deviceId: Int) {
|
||||
Log.i(TAG, "input device $deviceId disconnected")
|
||||
padBridge?.onDeviceRemoved(deviceId)
|
||||
updateOnScreenControls()
|
||||
updatePointerCapture()
|
||||
}
|
||||
@@ -329,9 +305,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
requestedOrientation = SessionPrefs.orientation(this)
|
||||
if (com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.isRemoving() ||
|
||||
com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.isMaintaining()) {
|
||||
if (com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.isRemoving()) {
|
||||
runtimeRemovalBlocked = true
|
||||
android.widget.Toast.makeText(this, R.string.session_wait_removal, android.widget.Toast.LENGTH_SHORT).show()
|
||||
finish()
|
||||
@@ -362,8 +336,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
)
|
||||
}
|
||||
Log.i(TAG, "perf: " + PerfMode.apply(this, SessionState.fpsLimit))
|
||||
if (!SessionState.running) SessionState.followScreen = followChoice()
|
||||
applyFollowOrientation(resources.configuration)
|
||||
goFullscreen()
|
||||
// The device's volume keys change the stream the session plays on (the relay and
|
||||
// PulseAudio are media playback); they are never forwarded to the guest.
|
||||
@@ -385,22 +357,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
Log.i(TAG, "pointer capture " + if (hasCapture) "on" else "off")
|
||||
}
|
||||
}
|
||||
sessionRoot = root
|
||||
surfaceView = SurfaceView(this)
|
||||
surfaceView.holder.addCallback(this)
|
||||
root.addView(surfaceView)
|
||||
// The window's size is only known once laid out, and changes as the phone folds or turns.
|
||||
root.addOnLayoutChangeListener { _, l, t, r, b, ol, ot, or, ob ->
|
||||
if (r - l != or - ol || b - t != ob - ot) uiHandler.post { applyUnfoldedControls() }
|
||||
}
|
||||
|
||||
val bridge = PadBridge(File(LinuxRuntime.sessionRoot(this), "dev/input"))
|
||||
bridge.setSelectSteam(ControllerPrefs.read(this).selectSteam)
|
||||
padBridge = bridge
|
||||
SessionState.padBridge = bridge
|
||||
// A paired Steam Controller is read over Bluetooth LE, out of lizard mode, as Steam Link reads it.
|
||||
SteamControllerBle.listener = { uiHandler.post { updatePadMotion(); updateOnScreenControls() } }
|
||||
steamController = SteamControllerBle(this).also { it.start() }
|
||||
padMotion = com.droiddeck.launcher.input.PadMotion(this) {
|
||||
@Suppress("DEPRECATION")
|
||||
(if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay)?.rotation ?: android.view.Surface.ROTATION_0
|
||||
@@ -592,30 +555,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
onKeyboardButton = { on -> updateControllerPrefs { ControllerPrefs.setKeyboardButton(this@SessionActivity, on) } },
|
||||
onSteamMenu = if (SessionState.mode == SessionService.MODE_STEAM) ({ sendSteamGuide() }) else null,
|
||||
onQam = if (SessionState.mode == SessionService.MODE_STEAM) ({ triggerSteamQam() }) else null,
|
||||
// Only where the session set the pad up to be switched (SessionService: deckPad).
|
||||
padType = if (SessionState.mode == SessionService.MODE_STEAM && SessionState.deckPad) padType else null,
|
||||
onPadType = { id ->
|
||||
SessionPrefs.setSteamController(this@SessionActivity, id)
|
||||
com.droiddeck.launcher.session.SteamDeckPad.showPad(SessionPrefs.padType(id))
|
||||
com.droiddeck.launcher.input.FakeInputWriter.setPadType(SessionPrefs.padType(id))
|
||||
padType = id
|
||||
},
|
||||
backActionsInverted = backActionsInverted,
|
||||
onBackActionsInverted = { inverted ->
|
||||
SessionPrefs.setBackActionsInverted(this@SessionActivity, inverted)
|
||||
backActionsInverted = inverted
|
||||
},
|
||||
onOsc = { v ->
|
||||
SessionPrefs.setOscMode(this@SessionActivity, v)
|
||||
readPrefs()
|
||||
updateOnScreenControls()
|
||||
if (v == SessionPrefs.OSC_STEAM_TOUCH && com.droiddeck.launcher.input.SteamTouchDevice.current == null)
|
||||
Toast.makeText(this@SessionActivity, R.string.osc_style_next_session, Toast.LENGTH_LONG).show()
|
||||
},
|
||||
onEditSteamTouch = if (steamTouchAvailable && oscMode == SessionPrefs.OSC_STEAM_TOUCH && onScreenButtonsVisible) ({
|
||||
drawerOpen = false
|
||||
steamTouchControls?.startEditing()
|
||||
}) else null,
|
||||
onOsc = { v -> SessionPrefs.setOscMode(this@SessionActivity, v); readPrefs(); updateOnScreenControls() },
|
||||
onSuspendPolicy = { policy ->
|
||||
SessionPrefs.setSuspendPolicy(this@SessionActivity, SessionState.mode, policy)
|
||||
suspendPolicy = policy
|
||||
@@ -652,8 +597,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
pipAutoEnter = on
|
||||
pip.refresh(true)
|
||||
},
|
||||
unfoldedControls = if (com.droiddeck.launcher.session.SessionDisplay.foldable(this@SessionActivity)) unfoldedControls else null,
|
||||
onUnfoldedControls = ::chooseUnfoldedControls,
|
||||
onBackground = { drawerOpen = false; pip.background(); moveTaskToBack(true) },
|
||||
onShareLogs = { drawerOpen = false; shareCurrentSessionLogs() },
|
||||
onStop = { drawerOpen = false; SessionService.stop(this@SessionActivity); finish() },
|
||||
@@ -800,9 +743,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
|
||||
private fun loadingTitle(): String = when (loadingMode()) {
|
||||
// Also when the desktop was asked for with Steam (Big Picture's Switch to Desktop): the client
|
||||
// does not open on it.
|
||||
SessionService.MODE_DESKTOP -> getString(R.string.session_starting_desktop)
|
||||
SessionService.MODE_DESKTOP ->
|
||||
if (!SessionState.running && intent.getStringExtra(SessionService.EXTRA_STEAM_UI) != null) getString(R.string.session_starting_steam_desktop)
|
||||
else getString(R.string.session_starting_desktop)
|
||||
SessionService.MODE_RUN -> {
|
||||
val program = if (SessionState.running) SessionState.program else intent.getStringExtra(SessionService.EXTRA_PROGRAM)
|
||||
com.droiddeck.launcher.frontend.Library.nameForProgram(program)?.let { getString(R.string.session_starting_named, it) }
|
||||
@@ -834,7 +777,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
|
||||
private fun readPrefs() {
|
||||
pipAutoEnter = SessionPrefs.pipAutoEnter(this)
|
||||
unfoldedControls = SessionPrefs.unfoldedControls(this)
|
||||
hudOn = SessionPrefs.hudEnabled(this)
|
||||
upscaler = SessionPrefs.upscaler(this)
|
||||
upscaleSharpness = SessionPrefs.upscaleSharpness(this)
|
||||
@@ -850,17 +792,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
WaylandCompositor.nativeSendSceneInput(3, it, 0)
|
||||
}
|
||||
}
|
||||
if (nextTouchMode == SessionPrefs.TOUCH_OFF || !usingTouchpad()) cursorVisible = false
|
||||
if (nextTouchMode == SessionPrefs.TOUCH_OFF) cursorVisible = false
|
||||
}
|
||||
touchMode = nextTouchMode
|
||||
routedTouchMode = effectiveTouchMode()
|
||||
frameGen = FrameGen.mode(this)
|
||||
fexPreset = SessionPrefs.fexPreset(this)
|
||||
suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode)
|
||||
steamDownloadsInBackground = SessionPrefs.steamDownloadsInBackground(this)
|
||||
oscMode = SessionPrefs.oscMode(this)
|
||||
backActionsInverted = SessionPrefs.backActionsInverted(this)
|
||||
padType = SessionPrefs.steamController(this)
|
||||
}
|
||||
|
||||
// ── Compositor ──────────────────────────────────────────────────────────────────────────
|
||||
@@ -987,7 +927,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
/** Null when the desktop package is in, else the loading screen's closing line. */
|
||||
private fun installDesktop(): String? {
|
||||
uiHandler.post { loading.percent = -1; loading.say(getString(DESKTOP_LINES.downloading), DESKTOP_LINES.topic, readable = true) }
|
||||
val entry = com.droiddeck.launcher.runtime.DesktopCatalog.desktopEntry(this)
|
||||
val entry = com.droiddeck.launcher.runtime.DesktopCatalog.fetch()?.firstOrNull { it.id == "desktop" }
|
||||
?: return getString(R.string.session_desktop_catalog_unreachable)
|
||||
val problem = com.droiddeck.launcher.runtime.DesktopCatalog.install(this, entry,
|
||||
progressFor(DESKTOP_LINES, entry.size / 1_000_000))
|
||||
return problem?.let { getString(R.string.session_desktop_install_failed, it) }
|
||||
@@ -1021,13 +962,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
// sized once when the session starts and cannot change. A foldable recreates the Surface
|
||||
// on the other panel, and recomputing the size there told the compositor a 21:9 buffer
|
||||
// was 16:9 - the picture came back squashed sideways. While a session runs, keep its size.
|
||||
// Follow screen is the exception: there the compositor tells gamescope the new size, so the
|
||||
// session takes the shape of the Surface it now has.
|
||||
val previous = SessionState.outputSize
|
||||
val live = if (SessionState.followScreen && !isInPictureInPictureMode)
|
||||
com.droiddeck.launcher.session.SessionDisplay.followSize(holder.surfaceFrame.width(), holder.surfaceFrame.height())
|
||||
else null
|
||||
val size = live ?: if (SessionState.running) previous else outputSize()
|
||||
val size = if (SessionState.running) SessionState.outputSize else outputSize()
|
||||
SessionState.outputSize = size
|
||||
if (!SessionState.running) SessionState.refreshHz = refreshHz()
|
||||
// How far the panel enlarges the session, for texture sharpness "Auto" (TextureFiltering):
|
||||
@@ -1086,10 +1021,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
driverId?.let { turnip.driverPath(it) }, driverId?.let { turnip.libraryName(it) },
|
||||
applicationInfo.nativeLibraryDir, size.first, size.second, refreshHz(), SessionState.fpsLimit,
|
||||
)
|
||||
if (SessionState.running && size != previous) {
|
||||
Log.i(TAG, "follow screen: ${previous.first}x${previous.second} -> ${size.first}x${size.second} on a new surface")
|
||||
WaylandCompositor.nativeResizeOutput(size.first, size.second)
|
||||
}
|
||||
if (!SessionState.running) SessionEvents.record("compositor.started")
|
||||
// The service owns everything below the compositor. It is started whenever no session is
|
||||
// running - NOT only when the compositor was just started: the compositor lives for the
|
||||
@@ -1118,52 +1049,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
if (resized) {
|
||||
Log.i(TAG, "surface resized to ${width}x$height - rebinding the compositor")
|
||||
CompositorHost.resize(holder.surface) { applyFrameGen() }
|
||||
followWindow(width, height)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow screen: the session's display takes the window's new size (a Fold opening onto its
|
||||
* inner panel, or closing onto the cover), so the picture fills the panel instead of being
|
||||
* letterboxed onto it. Not in picture-in-picture: that window is a preview of the session,
|
||||
* and resizing the guest to it would leave Steam laid out for a thumbnail.
|
||||
*/
|
||||
private fun followWindow(width: Int, height: Int) {
|
||||
if (!SessionState.followScreen || isInPictureInPictureMode || !CompositorHost.isStarted) return
|
||||
val size = com.droiddeck.launcher.session.SessionDisplay.followSize(width, height) ?: return
|
||||
if (size == SessionState.outputSize) return
|
||||
Log.i(TAG, "follow screen: ${SessionState.outputSize.first}x${SessionState.outputSize.second} -> ${size.first}x${size.second}")
|
||||
SessionState.outputSize = size
|
||||
val panel = panelBounds()
|
||||
SessionState.upscaleRatio = maxOf(panel.width(), panel.height()).toFloat() / maxOf(size.first, size.second).coerceAtLeast(1)
|
||||
WaylandCompositor.nativeResizeOutput(size.first, size.second)
|
||||
}
|
||||
|
||||
/** Whether this session's resolution is Follow screen; decided once, when it starts. */
|
||||
private fun followChoice(): Boolean {
|
||||
val mode = SessionPrefs.prefMode(intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM)
|
||||
return SessionPrefs.resolutionChoice(this, mode, com.droiddeck.launcher.session.SessionDisplay.panelSize(this)) ==
|
||||
com.droiddeck.launcher.session.SessionDisplay.FOLLOW_SCREEN
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow screen on a large panel (a Fold's inner screen) may turn with the device: a window
|
||||
* locked to landscape on a squarer panel is letterboxed by the system itself, which no output
|
||||
* size can undo. A phone-sized panel (the cover screen) stays landscape as before.
|
||||
*/
|
||||
private fun applyFollowOrientation(config: android.content.res.Configuration) {
|
||||
if (!SessionState.followScreen) return
|
||||
val wanted = if (config.smallestScreenWidthDp >= 600) android.content.pm.ActivityInfo.SCREEN_ORIENTATION_USER
|
||||
else android.content.pm.ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
||||
if (requestedOrientation != wanted) requestedOrientation = wanted
|
||||
}
|
||||
|
||||
override fun onConfigurationChanged(newConfig: android.content.res.Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
applyFollowOrientation(newConfig)
|
||||
applyUnfoldedControls()
|
||||
}
|
||||
|
||||
override fun surfaceDestroyed(holder: SurfaceHolder) {
|
||||
surfaceW = 0
|
||||
surfaceH = 0
|
||||
@@ -1389,7 +1277,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
return super.dispatchKeyEvent(event)
|
||||
}
|
||||
if (pipUi) return true
|
||||
val fromController = PadBridge.isControllerKeyEvent(event)
|
||||
val fromController = event.device != null && PadBridge.isFromController(event.device)
|
||||
if (fromController && event.action == KeyEvent.ACTION_DOWN) {
|
||||
if (drawerOpen && !drawerControllerActive) sessionOverlay.requestFocus()
|
||||
drawerControllerActive = true
|
||||
@@ -1441,11 +1329,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (pcKeyboardOpen && fromController) return super.dispatchKeyEvent(event)
|
||||
if (pcKeyboardOpen && event.device != null && PadBridge.isFromController(event.device)) return super.dispatchKeyEvent(event)
|
||||
if (event.keyCode != KeyEvent.KEYCODE_BACK && padBridge?.onKeyEvent(event) == true) return true
|
||||
// A hardware keyboard, forwarded to the compositor's wl_keyboard. Back is left to the
|
||||
// activity, which opens the drawer.
|
||||
if (CompositorHost.isStarted && event.keyCode != KeyEvent.KEYCODE_BACK && !fromController) {
|
||||
val fromPad = event.device != null && PadBridge.isFromController(event.device)
|
||||
if (CompositorHost.isStarted && event.keyCode != KeyEvent.KEYCODE_BACK && !fromPad) {
|
||||
val down = event.action == KeyEvent.ACTION_DOWN
|
||||
if (down || event.action == KeyEvent.ACTION_UP) {
|
||||
var evdev = EvdevKeys.fromKeyCode(event.keyCode)
|
||||
@@ -1483,7 +1372,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
|
||||
override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
|
||||
if (pipUi) return true
|
||||
if (drawerOpen && PadBridge.isControllerMotionEvent(event)) {
|
||||
if (drawerOpen && event.device != null && PadBridge.isFromController(event.device)) {
|
||||
if (!drawerControllerActive) sessionOverlay.requestFocus()
|
||||
drawerControllerActive = true
|
||||
if (event.actionMasked == MotionEvent.ACTION_MOVE &&
|
||||
@@ -1494,7 +1383,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
return true
|
||||
}
|
||||
if (drawerDirectionKey != KeyEvent.KEYCODE_UNKNOWN) releaseDrawerDirection()
|
||||
if (pcKeyboardOpen && PadBridge.isControllerMotionEvent(event)) return super.dispatchGenericMotionEvent(event)
|
||||
if (pcKeyboardOpen && event.device != null && PadBridge.isFromController(event.device)) return super.dispatchGenericMotionEvent(event)
|
||||
if (padBridge?.onMotionEvent(event) == true) return true
|
||||
if (event.isFromSource(android.view.InputDevice.SOURCE_MOUSE) && !drawerOpen && onMouse(event)) return true
|
||||
return super.dispatchGenericMotionEvent(event)
|
||||
@@ -1718,8 +1607,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
WaylandCompositor.nativeSendSceneInput(3, button, if (pressed) 1 else 0)
|
||||
},
|
||||
sendWheel = { steps -> WaylandCompositor.nativeSendSceneInput(4, steps, 0) },
|
||||
onSteamMenu = ::tapSteamButton,
|
||||
onQam = ::tapQam,
|
||||
onSteamMenu = {
|
||||
if (SessionState.mode == SessionService.MODE_STEAM) {
|
||||
padBridge?.applyTouch { st -> st.press(com.droiddeck.launcher.input.PadState.GUIDE, true) }
|
||||
Handler(Looper.getMainLooper()).postDelayed({
|
||||
padBridge?.applyTouch { st -> st.press(com.droiddeck.launcher.input.PadState.GUIDE, false) }
|
||||
}, 90)
|
||||
}
|
||||
},
|
||||
onQam = {
|
||||
if (SessionState.mode == SessionService.MODE_STEAM) padBridge?.triggerQam()
|
||||
},
|
||||
onClose = { selectSecondScreenMode(SecondScreenMode.NONE) },
|
||||
)
|
||||
secondScreenPresentation = presentation
|
||||
@@ -1736,64 +1634,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
}
|
||||
|
||||
private fun tapSteamButton() {
|
||||
if (SessionState.mode == SessionService.MODE_STEAM) {
|
||||
padBridge?.applyTouch { st -> st.press(com.droiddeck.launcher.input.PadState.GUIDE, true) }
|
||||
Handler(Looper.getMainLooper()).postDelayed({
|
||||
padBridge?.applyTouch { st -> st.press(com.droiddeck.launcher.input.PadState.GUIDE, false) }
|
||||
}, 90)
|
||||
}
|
||||
}
|
||||
|
||||
private fun tapQam() {
|
||||
if (SessionState.mode == SessionService.MODE_STEAM) padBridge?.triggerQam()
|
||||
}
|
||||
|
||||
private fun chooseUnfoldedControls(on: Boolean) {
|
||||
SessionPrefs.setUnfoldedControls(this, on)
|
||||
unfoldedControls = on
|
||||
applyUnfoldedControls()
|
||||
}
|
||||
|
||||
/**
|
||||
* Unfolded controls: on a foldable's inner screen, the game above and the Steam Deck controller's
|
||||
* trackpads and back grips below ([com.droiddeck.launcher.ui.DeckControlsPanel], as a second
|
||||
* screen shows them, plus a whole gamepad). The on-screen pad is hidden meanwhile. The surface shrinks to the top part, so a Follow screen session resizes
|
||||
* to it and any other is fitted into it. Folded onto the cover screen - a phone-sized panel -
|
||||
* or in picture-in-picture, the game is full screen again.
|
||||
*/
|
||||
private fun applyUnfoldedControls() {
|
||||
if (!::sessionRoot.isInitialized) return
|
||||
val width = sessionRoot.width
|
||||
val height = sessionRoot.height
|
||||
val wanted = unfoldedControls && com.droiddeck.launcher.session.SessionDisplay.foldable(this) &&
|
||||
SessionState.mode == SessionService.MODE_STEAM && SessionState.deckPad &&
|
||||
!pipUi && !isInPictureInPictureMode && width > 0 && height > 0 &&
|
||||
resources.configuration.smallestScreenWidthDp >= com.droiddeck.launcher.session.SessionDisplay.UNFOLDED_MIN_DP
|
||||
val gameHeight = if (wanted) com.droiddeck.launcher.session.SessionDisplay.unfoldedGameHeight(width, height)
|
||||
else FrameLayout.LayoutParams.MATCH_PARENT
|
||||
val params = surfaceView.layoutParams as FrameLayout.LayoutParams
|
||||
if (params.height != gameHeight) {
|
||||
surfaceView.layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, gameHeight, Gravity.TOP)
|
||||
Log.i(TAG, if (wanted) "unfolded controls: game ${width}x$gameHeight, controls ${width}x${height - gameHeight}"
|
||||
else "unfolded controls: game full screen")
|
||||
}
|
||||
var panel = unfoldedPanel
|
||||
if (!wanted) {
|
||||
if (panel != null) { sessionRoot.removeView(panel); unfoldedPanel = null; updateOnScreenControls() }
|
||||
return
|
||||
}
|
||||
if (panel == null) {
|
||||
panel = com.droiddeck.launcher.ui.DeckControlsPanel(this, onClose = { chooseUnfoldedControls(false) },
|
||||
onSteamMenu = ::tapSteamButton, onQam = ::tapQam, pad = padBridge)
|
||||
// Just above the surface: the on-screen pad, keyboard and drawer still draw over it.
|
||||
sessionRoot.addView(panel, sessionRoot.indexOfChild(surfaceView) + 1)
|
||||
unfoldedPanel = panel
|
||||
updateOnScreenControls()
|
||||
}
|
||||
panel.layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, height - gameHeight, Gravity.BOTTOM)
|
||||
}
|
||||
|
||||
private fun moveSecondScreenPointer(x: Float, y: Float, width: Float, height: Float) {
|
||||
if (width <= 0f || height <= 0f) return
|
||||
val nx = (x / width).coerceIn(0f, 1f)
|
||||
@@ -1814,32 +1654,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
}
|
||||
|
||||
/** The touch mode in force: the game's own while Steam's touch controls are shown. */
|
||||
private fun effectiveTouchMode(): String {
|
||||
val options = steamTouchOptions
|
||||
if (options == null || steamTouchControls?.visibility != View.VISIBLE) return SessionPrefs.touchMode(this)
|
||||
return when {
|
||||
options.inputMode == com.droiddeck.launcher.input.SteamTouchConfig.INPUT_CONTROLLER -> SessionPrefs.TOUCH_OFF
|
||||
options.mouseMode == com.droiddeck.launcher.input.SteamTouchConfig.MOUSE_RELATIVE -> SessionPrefs.TOUCH_PAD
|
||||
else -> SessionPrefs.TOUCH_DIRECT
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyTouchRouting() {
|
||||
val next = effectiveTouchMode()
|
||||
if (::touchpad.isInitialized) {
|
||||
val speed = steamTouchOptions?.trackpadSensitivity?.takeIf { steamTouchControls?.visibility == View.VISIBLE } ?: 1f
|
||||
touchpad.sensitivity = 1.15f * speed
|
||||
}
|
||||
if (next == routedTouchMode) return
|
||||
if (::touchpad.isInitialized) touchpad.cancel()
|
||||
if (CompositorHost.isStarted) WaylandCompositor.nativeSendTouch(3, -1, 0, 0)
|
||||
if (next == SessionPrefs.TOUCH_OFF) cursorVisible = false
|
||||
routedTouchMode = next
|
||||
}
|
||||
|
||||
/** Touchpad on the desktop, direct in Steam, unless the drawer says otherwise. */
|
||||
private fun usingTouchpad(): Boolean = when (routedTouchMode) {
|
||||
private fun usingTouchpad(): Boolean = when (SessionPrefs.touchMode(this)) {
|
||||
SessionPrefs.TOUCH_PAD -> true
|
||||
SessionPrefs.TOUCH_DIRECT -> false
|
||||
else -> SessionState.mode != SessionService.MODE_STEAM || SessionState.steamUi == "desktop"
|
||||
@@ -1861,7 +1677,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
if (event.isFromSource(android.view.InputDevice.SOURCE_MOUSE)) return onMouse(event)
|
||||
// Child views get the event first, so menus, keyboards and the on-screen pad still work.
|
||||
if (routedTouchMode == SessionPrefs.TOUCH_OFF) return true
|
||||
if (touchMode == SessionPrefs.TOUCH_OFF) return true
|
||||
if (usingTouchpad()) {
|
||||
val rect = drawnRect() ?: return false
|
||||
if (touchpad.bounds != rect) {
|
||||
@@ -1980,97 +1796,30 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
what, d.name, d.vendorId, d.productId, d.id, d.sources))
|
||||
}
|
||||
|
||||
/** Steam's touch controls, once the session has made the device (SessionService). */
|
||||
private fun steamTouch(): com.droiddeck.launcher.input.SteamTouchControls? {
|
||||
val device = com.droiddeck.launcher.input.SteamTouchDevice.current ?: return null
|
||||
steamTouchControls?.let { view ->
|
||||
if (view.device === device) return view
|
||||
// A new session made a new device: the view of the old one goes.
|
||||
view.releaseAll()
|
||||
(view.parent as? android.view.ViewGroup)?.removeView(view)
|
||||
steamTouchControls = null
|
||||
}
|
||||
val root = onScreenControls?.parent as? android.view.ViewGroup ?: return null
|
||||
val view = com.droiddeck.launcher.input.SteamTouchControls(this, device,
|
||||
onMenu = { drawerOpen = true }, onKeyboard = ::togglePcKeyboard)
|
||||
view.visibility = View.GONE
|
||||
view.onOptions = { options ->
|
||||
steamTouchOptions = options
|
||||
applyTouchRouting()
|
||||
}
|
||||
// Paste: the clipboard is shared with the session's X servers; Ctrl+V types it.
|
||||
view.onPaste = {
|
||||
sendGuestKey(29, true); sendGuestKey(47, true) // KEY_LEFTCTRL, KEY_V
|
||||
sendGuestKey(47, false); sendGuestKey(29, false)
|
||||
}
|
||||
// The client reads a controller's config when it connects: after a save, plug the device
|
||||
// out and back in so it takes the new one.
|
||||
view.onSaved = {
|
||||
device.plugged = false
|
||||
uiHandler.postDelayed({ updateOnScreenControls() }, 700)
|
||||
}
|
||||
root.addView(view, root.indexOfChild(onScreenControls) + 1)
|
||||
steamTouchControls = view
|
||||
steamTouchAvailable = true
|
||||
return view
|
||||
}
|
||||
|
||||
private fun updateOnScreenControls() {
|
||||
val forced = File(Environment.getExternalStorageDirectory(), "Download/droiddeck-osc")
|
||||
.takeIf { it.isFile }
|
||||
?.let { FileUtils.readString(it)?.trim()?.lowercase() }
|
||||
?: SessionPrefs.oscMode(this)
|
||||
val controls = onScreenControls ?: return
|
||||
// In picture-in-picture, or with the unfolded screen's gamepad below the game, the on-screen
|
||||
// pad would only cover the picture or that panel.
|
||||
if (pipUi || unfoldedPanel != null) {
|
||||
if (pipUi) {
|
||||
onScreenButtonsVisible = false
|
||||
controls.releaseAll()
|
||||
controls.visibility = View.GONE
|
||||
steamTouchControls?.let { it.releaseAll(); it.visibility = View.GONE }
|
||||
com.droiddeck.launcher.input.SteamTouchDevice.current?.plugged = false
|
||||
return
|
||||
}
|
||||
val buttonsOnly = forced == SessionPrefs.OSC_STEAM_QAM
|
||||
// Steam's touch controller stands in for the app's pad, and is plugged into the client only
|
||||
// while it is shown - as Steam Link withdraws its controls when a physical pad is in use.
|
||||
val wantsSteam = forced == SessionPrefs.OSC_STEAM_TOUCH && SessionState.mode == SessionService.MODE_STEAM
|
||||
val steam = if (wantsSteam) steamTouch() else null
|
||||
// The service makes the device while the session starts, after this activity: the app's
|
||||
// own pad until then, and another look in a moment.
|
||||
if (wantsSteam && steam == null && !isFinishing && !isDestroyed) {
|
||||
uiHandler.removeCallbacks(steamTouchRetry)
|
||||
uiHandler.postDelayed(steamTouchRetry, 1000)
|
||||
}
|
||||
if (steam == null) {
|
||||
steamTouchControls?.let { it.releaseAll(); it.visibility = View.GONE }
|
||||
com.droiddeck.launcher.input.SteamTouchDevice.current?.plugged = false
|
||||
}
|
||||
controls.setButtonsOnly(buttonsOnly)
|
||||
val show = when (forced) {
|
||||
SessionPrefs.OSC_ALWAYS -> true
|
||||
SessionPrefs.OSC_STEAM_QAM -> true
|
||||
SessionPrefs.OSC_NEVER -> false
|
||||
// Shown throughout a Steam session: a handheld's built-in pad would keep Auto off for good.
|
||||
// Outside Steam it is Auto.
|
||||
SessionPrefs.OSC_STEAM_TOUCH -> SessionState.mode == SessionService.MODE_STEAM ||
|
||||
(!PadBridge.anyControllerConnected() && SessionState.mode != SessionService.MODE_DESKTOP)
|
||||
// Auto: the touch pad when there is no controller - except on the desktop, where the
|
||||
// screen is a touchpad for the pointer and a pad over it would be in the way. A game
|
||||
// started from the rail, or Steam, gets it; the drawer turns it on anywhere.
|
||||
else -> !PadBridge.anyControllerConnected() && !SteamControllerBle.connected &&
|
||||
SessionState.mode != SessionService.MODE_DESKTOP
|
||||
else -> !PadBridge.anyControllerConnected() && SessionState.mode != SessionService.MODE_DESKTOP
|
||||
}
|
||||
onScreenButtonsVisible = show
|
||||
if (steam != null) {
|
||||
if (controls.visibility == View.VISIBLE) { controls.releaseAll(); controls.visibility = View.GONE }
|
||||
if (!show) steam.releaseAll()
|
||||
steam.visibility = if (show) View.VISIBLE else View.GONE
|
||||
com.droiddeck.launcher.input.SteamTouchDevice.current?.plugged = show
|
||||
applyTouchRouting()
|
||||
Log.i(TAG, "on-screen controls: Steam's touch controller " + if (show) "shown" else "hidden")
|
||||
return
|
||||
}
|
||||
if (show == (controls.visibility == View.VISIBLE)) return
|
||||
if (!show) controls.releaseAll()
|
||||
controls.visibility = if (show) View.VISIBLE else View.GONE
|
||||
@@ -2142,9 +1891,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
override fun onPictureInPictureModeChanged(inPip: Boolean, newConfig: android.content.res.Configuration) {
|
||||
super.onPictureInPictureModeChanged(inPip, newConfig)
|
||||
if (::pip.isInitialized) pip.changed(inPip)
|
||||
// The panel may have changed while the session was a thumbnail (folded or unfolded in PiP).
|
||||
if (!inPip && surfaceW > 0) followWindow(surfaceW, surfaceH)
|
||||
applyUnfoldedControls()
|
||||
}
|
||||
|
||||
override fun startActivityForResult(intent: Intent, requestCode: Int, options: Bundle?) {
|
||||
@@ -2201,7 +1947,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
requestedOrientation = SessionPrefs.orientation(this)
|
||||
if (runtimeRemovalBlocked) return
|
||||
com.droiddeck.launcher.ui.Motion.refresh(this)
|
||||
refreshHomeApp()
|
||||
@@ -2273,13 +2018,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
|
||||
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
|
||||
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION)
|
||||
// ALWAYS from Android 11: SHORT_EDGES left a black strip along a camera on the long edge,
|
||||
// which is where a Fold's inner-screen camera sits with the device held in landscape.
|
||||
if (Build.VERSION.SDK_INT >= 28) {
|
||||
window.attributes = window.attributes.apply {
|
||||
layoutInDisplayCutoutMode = if (Build.VERSION.SDK_INT >= 30)
|
||||
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS
|
||||
else WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
|
||||
layoutInDisplayCutoutMode =
|
||||
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2296,7 +2038,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
uiHandler.removeCallbacks(steamTouchRetry)
|
||||
if (runtimeRemovalBlocked) { super.onDestroy(); return }
|
||||
// Deliberately does NOT end the session: this activity can be destroyed while the user is
|
||||
// in another app, and the whole point of the service is that Steam survives that.
|
||||
@@ -2308,7 +2049,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
closeSecondScreen(reset = false)
|
||||
if (::hud.isInitialized) hud.stop()
|
||||
padBridge?.stop()
|
||||
steamController?.stop()
|
||||
if (SessionState.padBridge === padBridge) SessionState.padBridge = null
|
||||
padMotion?.stop()
|
||||
if (SessionState.deckPadListener === deckPadListener) SessionState.deckPadListener = null
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.droiddeck.launcher.agent
|
||||
|
||||
import com.droiddeck.launcher.session.SessionPaths
|
||||
import android.Manifest
|
||||
import android.content.ContentProvider
|
||||
import android.content.ContentValues
|
||||
@@ -108,7 +107,7 @@ class AgentBridgeProvider : ContentProvider() {
|
||||
|
||||
private fun state(context: Context): JSONObject {
|
||||
val runtimeVersion = LinuxRuntimeInstaller.installedVersion(context)
|
||||
val dir = SessionState.logDirectory ?: SessionState.logFile?.parentFile ?: SessionPaths.sessionFolders(context).lastOrNull()
|
||||
val dir = SessionState.logDirectory ?: SessionState.logFile?.parentFile ?: latestSessionDirectory()
|
||||
val session = JSONObject()
|
||||
.put("id", SessionState.sessionId ?: dir?.name ?: JSONObject.NULL)
|
||||
.put("phase", SessionState.phase.name)
|
||||
@@ -164,6 +163,12 @@ class AgentBridgeProvider : ContentProvider() {
|
||||
.put("session", session)
|
||||
}
|
||||
|
||||
private fun latestSessionDirectory(): File? {
|
||||
val parent = LinuxRuntime.debugLogDir()
|
||||
return parent.listFiles { file -> file.isDirectory && file.name.startsWith("session-") }
|
||||
?.maxWithOrNull(compareBy<File> { it.lastModified() }.thenBy { it.name })
|
||||
}
|
||||
|
||||
private fun stop(context: Context): JSONObject {
|
||||
if (SessionState.running) {
|
||||
SessionService.stop(context)
|
||||
|
||||
@@ -14,7 +14,6 @@ import org.json.JSONObject
|
||||
class AgentStartActivity : Activity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
requestedOrientation = com.droiddeck.launcher.session.SessionPrefs.orientation(this)
|
||||
val request = decodeRequest(intent.getStringExtra(EXTRA_REQUEST))
|
||||
if (request == null) {
|
||||
finish()
|
||||
|
||||
@@ -182,30 +182,26 @@ public class PulseAudioComponent extends SessionPart {
|
||||
* client is never left without an output. Says so in the audio log either way.
|
||||
*/
|
||||
private void ensureClientSink(java.io.PrintWriter out) {
|
||||
try {
|
||||
long deadline = System.currentTimeMillis() + 15000L;
|
||||
String sinks = null;
|
||||
while (System.currentTimeMillis() < deadline && pid > 1) {
|
||||
if (socket().exists() && (sinks = pactl("list", "short", "sinks")) != null) break;
|
||||
try { Thread.sleep(250L); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; }
|
||||
}
|
||||
String line;
|
||||
if (sinks == null) {
|
||||
line = "== client sink: the daemon did not answer in 15 s; no fallback attempted";
|
||||
} else if (sinks.contains("\tDirectAudio\t") || sinks.startsWith("DirectAudio\t") || sinks.contains("\tDirectAudio\n") || sinks.matches("(?s).*\\bDirectAudio\\b.*")) {
|
||||
line = "== client sink: DirectAudio (module-directaudio-native-sink)";
|
||||
} else {
|
||||
boolean loaded = pactl("load-module", "module-aaudio-sink", "sink_name=AAudioSink", "performance_mode=1", "adaptive=1", "volume=1.0") != null
|
||||
&& pactl("set-default-sink", "AAudioSink") != null;
|
||||
line = loaded ? "== client sink: DirectAudio did not come up; FALLBACK to the plain AAudio sink (AAudioSink)"
|
||||
: "== client sink: DirectAudio did not come up and the AAudio fallback failed too - no client sound";
|
||||
Log.w(TAG, line);
|
||||
}
|
||||
Log.i(TAG, line);
|
||||
if (out != null) synchronized (out) { out.println(line); out.flush(); }
|
||||
} catch (Throwable t) {
|
||||
Log.w(TAG, "ensureClientSink failed", t);
|
||||
long deadline = System.currentTimeMillis() + 15000L;
|
||||
String sinks = null;
|
||||
while (System.currentTimeMillis() < deadline && pid > 1) {
|
||||
if (socket().exists() && (sinks = pactl("list", "short", "sinks")) != null) break;
|
||||
try { Thread.sleep(250L); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; }
|
||||
}
|
||||
String line;
|
||||
if (sinks == null) {
|
||||
line = "== client sink: the daemon did not answer in 15 s; no fallback attempted";
|
||||
} else if (sinks.contains("\tDirectAudio\t") || sinks.startsWith("DirectAudio\t") || sinks.contains("\tDirectAudio\n") || sinks.matches("(?s).*\\bDirectAudio\\b.*")) {
|
||||
line = "== client sink: DirectAudio (module-directaudio-native-sink)";
|
||||
} else {
|
||||
boolean loaded = pactl("load-module", "module-aaudio-sink", "sink_name=AAudioSink", "performance_mode=1", "adaptive=1", "volume=1.0") != null
|
||||
&& pactl("set-default-sink", "AAudioSink") != null;
|
||||
line = loaded ? "== client sink: DirectAudio did not come up; FALLBACK to the plain AAudio sink (AAudioSink)"
|
||||
: "== client sink: DirectAudio did not come up and the AAudio fallback failed too - no client sound";
|
||||
Log.w(TAG, line);
|
||||
}
|
||||
Log.i(TAG, line);
|
||||
if (out != null) synchronized (out) { out.println(line); out.flush(); }
|
||||
}
|
||||
|
||||
/** Runs pactl against this daemon; its stdout, or null when it failed or timed out. */
|
||||
@@ -227,14 +223,8 @@ public class PulseAudioComponent extends SessionPart {
|
||||
builder.environment().put("PULSE_SERVER", "unix:" + socket().getAbsolutePath());
|
||||
process = builder.start();
|
||||
String output;
|
||||
try (InputStream in = process.getInputStream();
|
||||
java.io.ByteArrayOutputStream outStream = new java.io.ByteArrayOutputStream()) {
|
||||
byte[] buf = new byte[4096];
|
||||
int n;
|
||||
while ((n = in.read(buf)) != -1) {
|
||||
outStream.write(buf, 0, n);
|
||||
}
|
||||
output = outStream.toString(java.nio.charset.StandardCharsets.UTF_8.name());
|
||||
try (InputStream in = process.getInputStream()) {
|
||||
output = new String(in.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8);
|
||||
}
|
||||
if (!process.waitFor(5, TimeUnit.SECONDS)) {
|
||||
process.destroyForcibly();
|
||||
@@ -244,7 +234,7 @@ public class PulseAudioComponent extends SessionPart {
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return null;
|
||||
} catch (Throwable t) {
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
} finally {
|
||||
if (process != null && process.isAlive()) process.destroyForcibly();
|
||||
|
||||
@@ -53,7 +53,7 @@ object DeviceReport {
|
||||
b.append("DroidDeck session report\n")
|
||||
b.append("========================\n")
|
||||
k("Written", SimpleDateFormat("yyyy-MM-dd HH:mm:ss zzz", Locale.US).format(Date()))
|
||||
k("Session mode", when (mode) { SessionService.MODE_DESKTOP -> "desktop (KDE Plasma)"; SessionService.MODE_RUN -> "a program under gamescope"; else -> "Steam client (gamescope)" })
|
||||
k("Session mode", when (mode) { SessionService.MODE_DESKTOP -> "desktop (labwc/LXQt)"; SessionService.MODE_RUN -> "a program under gamescope"; else -> "Steam client (gamescope)" })
|
||||
|
||||
h("App")
|
||||
runCatching {
|
||||
@@ -152,6 +152,7 @@ object DeviceReport {
|
||||
k("Zink lazy descriptors", SessionPrefs.zinkLazy(context))
|
||||
k("Threaded GL (glthread)", SessionPrefs.glThread(context))
|
||||
k("No GL error checks", SessionPrefs.noGlError(context))
|
||||
k("Steam Deck mode", SessionPrefs.steamDeckMode(context))
|
||||
k("Steam controller", SessionPrefs.steamController(context))
|
||||
k("Upscaler", (SessionPrefs.upscalerChoices.firstOrNull { it.first == SessionPrefs.upscaler(context) }?.second ?: "Off") +
|
||||
" (sharpness ${SessionPrefs.upscaleSharpness(context)}%)")
|
||||
@@ -188,7 +189,7 @@ object DeviceReport {
|
||||
}
|
||||
for (name in listOf("droiddeck-tu-debug", "droiddeck-driver",
|
||||
"droiddeck-osc", "droiddeck-no-pad",
|
||||
"droiddeck-no-hud")) {
|
||||
"droiddeck-no-hud", "droiddeck-wlr-renderer")) {
|
||||
val f = File(Environment.getExternalStorageDirectory(), "Download/$name")
|
||||
if (f.isFile) k(name, FileUtils.readString(f)?.trim()?.replace('\n', ' ')?.ifEmpty { "(present, empty)" } ?: "(present)")
|
||||
}
|
||||
|
||||
@@ -23,8 +23,4 @@ object DeviceSupport {
|
||||
val soc = if (Build.VERSION.SDK_INT >= 31) Build.SOC_MODEL.takeIf { it.isNotBlank() && it != Build.UNKNOWN } else null
|
||||
return soc?.let { "$it (${Build.HARDWARE})" } ?: Build.HARDWARE.ifBlank { context.getString(R.string.gpu_this_gpu) }
|
||||
}
|
||||
|
||||
/** False on the Odin 3 (CQ8725S): a low-latency Wi-Fi lock makes its WLAN firmware hang, and
|
||||
* that firmware reboots the whole device instead of restarting Wi-Fi. */
|
||||
fun lowLatencyWifiSafe(): Boolean = Build.VERSION.SDK_INT < 31 || Build.SOC_MODEL != "CQ8725S"
|
||||
}
|
||||
@@ -31,8 +31,7 @@ object GameEnvironment {
|
||||
"VKD3D_SHADER_MODEL" to "6_9",
|
||||
// .NET 7 and later reserve 256 GB of address space for the GC heap at startup, more than an
|
||||
// Android process has free, and the runtime fails to start. 32 GB is plenty for a game.
|
||||
// Older NativeAOT runtimes require bare hex digits. CoreCLR accepts this form too.
|
||||
"DOTNET_GCRegionRange" to "800000000",
|
||||
"DOTNET_GCRegionRange" to "0x800000000",
|
||||
// .NET's W^X writes JIT code through a second mapping that FEX does not watch, so FEX keeps
|
||||
// running the stale translation and the game hangs at startup.
|
||||
"DOTNET_EnableWriteXorExecute" to "0",
|
||||
|
||||
@@ -75,30 +75,12 @@ object LogRedactor {
|
||||
* user who signs in with a plain account name, not an email, had it in every log. The single
|
||||
* space then a non-space keeps "OnLoginStateChange 0 1 0 0" (no account yet) as it is.
|
||||
*/
|
||||
private val LOGIN_STATE = Regex("(?i)(OnLoginStateChange:? )([^\\s<]\\S*)")
|
||||
private val LOGIN_STATE = Regex("(OnLoginStateChange )(\\S+)")
|
||||
private val LOGIN_USERS = Regex("(OnLoginUsersChanged )(\\S.*)$")
|
||||
|
||||
/**
|
||||
* An account name as a field: `AccountName`, `account_name`, `username` (`"AccountName"
|
||||
* "someone"` in a VDF, `account_name=...`, `"username": "..."`) and `login` - the last only
|
||||
* as `login=`, a quoted key or a VDF pair, so "Login: OnLoginStateChange ..." keeps its label.
|
||||
* A quoted value is blanked whole; one already blanked is left as it is.
|
||||
*/
|
||||
private const val ACCOUNT_VALUE = "(\"[^\"<\\r\\n]+\"|'[^'<\\r\\n]+'|[^\\s\"'<>&;,}]+)"
|
||||
private val ACCOUNT_KV = Regex(
|
||||
"(?i)(?<![A-Za-z0-9_])(account[_-]?name|user[_-]?name|epicusername)" +
|
||||
"([\"']?\\s*[=:]\\s*|[\"']\\s+)" + ACCOUNT_VALUE
|
||||
)
|
||||
private val LOGIN_KV = Regex(
|
||||
"(?i)(?<![A-Za-z0-9_])(login)(\\s*=\\s*|[\"']\\s*[=:]\\s*|[\"']\\s+)" + ACCOUNT_VALUE
|
||||
)
|
||||
|
||||
/** A learned pattern and the text (lowercase) a line must hold for it to match; null when none can be said. */
|
||||
private class Learned(val regex: Regex, val needs: String?)
|
||||
|
||||
/** The device's Steam accounts and persona names as patterns (see [learnAccounts]). */
|
||||
@Volatile
|
||||
private var accounts: List<Learned> = emptyList()
|
||||
private var accounts: List<Regex> = emptyList()
|
||||
|
||||
/**
|
||||
* Learns every Steam account on the device from the client's loginusers.vdf - its AccountName
|
||||
@@ -106,13 +88,12 @@ object LogRedactor {
|
||||
* Names under three characters are skipped: blanking every "a" would ruin a log.
|
||||
*/
|
||||
fun learnAccounts(loginUsers: java.io.File) {
|
||||
val found = ArrayList<Learned>()
|
||||
val found = ArrayList<Regex>()
|
||||
try {
|
||||
if (loginUsers.isFile) {
|
||||
val kv = Regex("\"(AccountName|PersonaName)\"\\s+\"([^\"]*)\"")
|
||||
kv.findAll(loginUsers.readText()).map { it.groupValues[2].trim() }.filter { it.length >= 3 }.distinct().forEach { name ->
|
||||
val needs = name.lowercase().takeIf { name.all { it.code < 0x80 } }
|
||||
found += Learned(Regex("(?i)(?<![A-Za-z0-9_])" + Regex.escape(name) + "(?![A-Za-z0-9_])"), needs)
|
||||
found += Regex("(?i)(?<![A-Za-z0-9_])" + Regex.escape(name) + "(?![A-Za-z0-9_])")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
@@ -129,7 +110,7 @@ object LogRedactor {
|
||||
|
||||
/** This device's public addresses as patterns (see [learnOwnAddresses]); empty until learned. */
|
||||
@Volatile
|
||||
private var own: List<Learned> = emptyList()
|
||||
private var own: List<Regex> = emptyList()
|
||||
|
||||
/**
|
||||
* Learns the device's addresses from the link file the app writes for the session
|
||||
@@ -137,7 +118,7 @@ object LogRedactor {
|
||||
* private, link-local and loopback addresses identify nobody and are left out.
|
||||
*/
|
||||
fun learnOwnAddresses(linkFile: java.io.File) {
|
||||
val found = ArrayList<Learned>()
|
||||
val found = ArrayList<Regex>()
|
||||
try {
|
||||
if (linkFile.isFile) linkFile.forEachLine { line ->
|
||||
val addr = line.trim().takeIf { it.startsWith("addr ") }?.split(Regex("\\s+"))?.getOrNull(1) ?: return@forEachLine
|
||||
@@ -146,12 +127,9 @@ object LogRedactor {
|
||||
val groups = expand6(addr)?.take(4) ?: return@forEachLine
|
||||
// Any spelling of an address in this /64: leading zeros dropped, :: anywhere after.
|
||||
val prefix = groups.joinToString(":") { g -> "0{0,3}" + Regex.escape(g.trimStart('0').ifEmpty { "0" }) }
|
||||
found += Learned(
|
||||
Regex("(?i)(?<![0-9A-Fa-f:])$prefix(?::[0-9A-Fa-f]{0,4}){1,4}(?:%[A-Za-z0-9_.]+)?"),
|
||||
groups[0].trimStart('0').ifEmpty { "0" },
|
||||
)
|
||||
found += Regex("(?i)(?<![0-9A-Fa-f:])$prefix(?::[0-9A-Fa-f]{0,4}){1,4}(?:%[A-Za-z0-9_.]+)?")
|
||||
}
|
||||
"public IPv4" -> found += Learned(Regex("(?<![0-9.])" + Regex.escape(addr) + "(?![0-9.])"), addr)
|
||||
"public IPv4" -> found += Regex("(?<![0-9.])" + Regex.escape(addr) + "(?![0-9.])")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
@@ -227,130 +205,46 @@ object LogRedactor {
|
||||
* only when that matches, so a folder scrubbed under older rules is scrubbed again on the way
|
||||
* out.
|
||||
*/
|
||||
const val RULES_VERSION = 3
|
||||
|
||||
/**
|
||||
* A private IPv4 address (10/8, 172.16/12, 192.168/16, link-local 169.254/16): the home
|
||||
* network's layout, e.g. its router as the resolver. Blanked in a shared zip only; public
|
||||
* server addresses stay. Not part of a longer dotted number (a version like 10.0.19041.1).
|
||||
*/
|
||||
private val LAN_IPV4 = Regex(
|
||||
"(?<![\\d.])(?:10\\.\\d{1,3}|192\\.168|172\\.(?:1[6-9]|2\\d|3[01])|169\\.254)\\.\\d{1,3}\\.\\d{1,3}(?!\\.?\\d)"
|
||||
)
|
||||
const val RULES_VERSION = 2
|
||||
|
||||
/** [src]'s lines, scrubbed, to [out]. */
|
||||
fun scrubTo(src: java.io.File, out: java.io.Writer) {
|
||||
src.forEachLine { line -> out.write(redact(line)); out.write("\n") }
|
||||
}
|
||||
|
||||
/**
|
||||
* The last pass for a file going into a shared zip: [redact], then the rules the stores' log
|
||||
* uses too ([SecretScrub]) - URLs without query, fragment and user:password and with any
|
||||
* token-bearing path segment blanked, token values and Authorization / Cookie headers blanked
|
||||
* - and private IPv4 addresses as `<lan-address>`. A line already clean comes out unchanged.
|
||||
*/
|
||||
fun redactForShare(line: String): String {
|
||||
if (line.isEmpty()) return line
|
||||
return try {
|
||||
val out = SecretScrub.scrub(redact(line), SecretScrub.Urls.KEEP_PATH, "<redacted:token>")
|
||||
LAN_IPV4.replace(out, "<lan-address>")
|
||||
} catch (t: Throwable) {
|
||||
"<redaction failed; line withheld>"
|
||||
}
|
||||
}
|
||||
|
||||
/** [src]'s lines through [redactForShare], to [out]: the pass every text file in a shared zip gets. */
|
||||
fun scrubForShare(src: java.io.File, out: java.io.Writer) {
|
||||
src.forEachLine { line -> out.write(redactForShare(line)); out.write("\n") }
|
||||
}
|
||||
|
||||
/** `<redacted:account>`, inside [value]'s quotes when it had them. */
|
||||
private fun quoted(value: String): String {
|
||||
val q = value.first().takeIf { it == '"' || it == '\'' }?.toString().orEmpty()
|
||||
return "$q<redacted:account>$q"
|
||||
}
|
||||
|
||||
/**
|
||||
* A line on its way through [redact]. Each rule runs only on a line holding what it cannot match
|
||||
* without - a word, enough separators, a long enough run: most of a Steam log is lines no rule
|
||||
* touches, and a session's logs went through every rule at about a megabyte a second. A line with anything but ASCII in it goes through
|
||||
* every case-insensitive rule, since Android's regex folds case more widely than lowercase().
|
||||
*/
|
||||
private class Line(var text: String) {
|
||||
private val unfolded = text.any { it.code >= 0x80 }
|
||||
private var lower: String? = null
|
||||
|
||||
fun has(vararg words: String): Boolean {
|
||||
if (unfolded) return true
|
||||
val folded = lower ?: text.lowercase().also { lower = it }
|
||||
return words.any { folded.contains(it) }
|
||||
}
|
||||
|
||||
fun set(next: String) {
|
||||
if (next != text) {
|
||||
text = next
|
||||
lower = null
|
||||
}
|
||||
}
|
||||
|
||||
fun holds(c: Char, times: Int): Boolean {
|
||||
var n = 0
|
||||
for (ch in text) if (ch == c && ++n >= times) return true
|
||||
return false
|
||||
}
|
||||
|
||||
inline fun hasRun(length: Int, inRun: (Char) -> Boolean): Boolean {
|
||||
var n = 0
|
||||
for (ch in text) if (!inRun(ch)) n = 0 else if (++n >= length) return true
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private fun isHex(c: Char) = c in '0'..'9' || c in 'a'..'f' || c in 'A'..'F'
|
||||
|
||||
/** [line] with every credential shape replaced. Null- and exception-safe by construction. */
|
||||
fun redact(line: String): String {
|
||||
if (line.isEmpty()) return line
|
||||
return try {
|
||||
val out = Line(line)
|
||||
if (out.has("mac")) out.set(MAC_FIELD.replace(out.text) { "${it.groupValues[1]}<redacted:mac>" })
|
||||
if (out.holds(':', 5) || out.holds('-', 5)) out.set(MAC.replace(out.text, "<redacted:mac>"))
|
||||
if (out.has("serial", "android")) out.set(DEVICE_IDENTIFIER.replace(out.text) {
|
||||
var out = line
|
||||
out = MAC_FIELD.replace(out) { "${it.groupValues[1]}<redacted:mac>" }
|
||||
out = MAC.replace(out, "<redacted:mac>")
|
||||
out = DEVICE_IDENTIFIER.replace(out) {
|
||||
val value = it.groupValues[2]
|
||||
val quote = value.first().takeIf { char -> char == '\"' || char == '\'' }?.toString().orEmpty()
|
||||
"${it.groupValues[1]}${quote}<redacted:serial>${quote}"
|
||||
})
|
||||
if (out.holds('-', 4)) out.set(GUID.replace(out.text, "<redacted:guid>"))
|
||||
if (out.has("jwt")) out.set(JWT_LABELLED.replace(out.text) { "${it.groupValues[1]}<redacted:jwt>" })
|
||||
if ("ey" in out.text) out.set(JWT_BASE64.replace(out.text, "<redacted:jwt>"))
|
||||
if (out.has("token", "auth", "ticket", "sessionid", "steamloginsecure", "api", "machine", "pass", "pwd", "secret")) {
|
||||
out.set(SECRET_KV.replace(out.text) { "${it.groupValues[1]}${it.groupValues[2]}<redacted:token>" })
|
||||
}
|
||||
if (out.has("guard", "factor", "2fa")) out.set(GUARD_CODE.replace(out.text) { "${it.groupValues[1]}<redacted:code>" })
|
||||
if (out.hasRun(32, ::isHex)) out.set(WEBAPI_KEY.replace(out.text, "<redacted:key>"))
|
||||
out = GUID.replace(out, "<redacted:guid>")
|
||||
out = JWT_LABELLED.replace(out) { "${it.groupValues[1]}<redacted:jwt>" }
|
||||
out = JWT_BASE64.replace(out, "<redacted:jwt>")
|
||||
out = SECRET_KV.replace(out) { "${it.groupValues[1]}${it.groupValues[2]}<redacted:token>" }
|
||||
out = GUARD_CODE.replace(out) { "${it.groupValues[1]}<redacted:code>" }
|
||||
out = WEBAPI_KEY.replace(out, "<redacted:key>")
|
||||
// Mask, not delete: the last four digits let a reader correlate lines to one account.
|
||||
if ("76561" in out.text) out.set(STEAMID64.replace(out.text) { "${it.groupValues[1]}********${it.groupValues[3]}" })
|
||||
if ("[U:1:" in out.text) out.set(STEAMID3.replace(out.text) { m ->
|
||||
out = STEAMID64.replace(out) { "${it.groupValues[1]}********${it.groupValues[3]}" }
|
||||
out = STEAMID3.replace(out) { m ->
|
||||
val id = m.groupValues[1]
|
||||
"[U:1:${if (id.length > 4) "*".repeat(id.length - 4) + id.takeLast(4) else id}]"
|
||||
})
|
||||
if (out.has("external")) out.set(EXTERNAL_ADDR.replace(out.text) { "${it.groupValues[1]}<redacted:ip>" })
|
||||
for (r in own) if (r.needs == null || out.has(r.needs)) out.set(r.regex.replace(out.text, "<redacted:ip>"))
|
||||
if (out.has("onloginstatechange")) out.set(LOGIN_STATE.replace(out.text) { "${it.groupValues[1]}<redacted:account>" })
|
||||
if ("OnLoginUsersChanged " in out.text) out.set(LOGIN_USERS.replace(out.text) { "${it.groupValues[1]}<redacted:account>" })
|
||||
if (out.has("account", "user")) {
|
||||
out.set(ACCOUNT_KV.replace(out.text) { "${it.groupValues[1]}${it.groupValues[2]}${quoted(it.groupValues[3])}" })
|
||||
}
|
||||
if (out.has("login")) out.set(LOGIN_KV.replace(out.text) { "${it.groupValues[1]}${it.groupValues[2]}${quoted(it.groupValues[3])}" })
|
||||
for (r in accounts) if (r.needs == null || out.has(r.needs)) out.set(r.regex.replace(out.text, "<redacted:account>"))
|
||||
if ('@' in out.text) out.set(EMAIL.replace(out.text, "<redacted:email>"))
|
||||
if (out.has("jwt", "token", "ticket", "sessionid", "steamloginsecure", "machineauth")) {
|
||||
out.set(RESIDUAL.replace(out.text) { "${it.groupValues[1]}=<redacted:token>" })
|
||||
}
|
||||
if (out.hasRun(88) { it in 'a'..'z' || it in 'A'..'Z' || it in '0'..'9' || it == '_' || it == '-' }) {
|
||||
out.set(LONG_TOKEN.replace(out.text, "<redacted:token>"))
|
||||
}
|
||||
out.text
|
||||
out = EXTERNAL_ADDR.replace(out) { "${it.groupValues[1]}<redacted:ip>" }
|
||||
for (r in own) out = r.replace(out, "<redacted:ip>")
|
||||
out = LOGIN_STATE.replace(out) { "${it.groupValues[1]}<redacted:account>" }
|
||||
out = LOGIN_USERS.replace(out) { "${it.groupValues[1]}<redacted:account>" }
|
||||
for (r in accounts) out = r.replace(out, "<redacted:account>")
|
||||
out = EMAIL.replace(out, "<redacted:email>")
|
||||
out = RESIDUAL.replace(out) { "${it.groupValues[1]}=<redacted:token>" }
|
||||
out = LONG_TOKEN.replace(out, "<redacted:token>")
|
||||
out
|
||||
} catch (t: Throwable) {
|
||||
// A log line is never worth crashing a session over, but an unscrubbed one must not
|
||||
// reach the file either.
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
package com.droiddeck.launcher.core
|
||||
|
||||
/**
|
||||
* The one set of rules that keeps URLs and tokens out of anything the app writes or shares: the
|
||||
* stores' log ([com.droiddeck.launcher.stores.StoreLog.redactLine]) and the last pass over every
|
||||
* text file in a shared log zip ([LogRedactor.redactForShare]).
|
||||
*
|
||||
* - Every URL loses its query, fragment and `user:password@`. [Urls.SHORT] then keeps only the host
|
||||
* and a plain first path segment (the stores' lines, where a GOG secure link carries its token in
|
||||
* the path); [Urls.KEEP_PATH] keeps the path but blanks any segment that carries a token.
|
||||
* - Outside URLs, the values of token-like keys (`token`, `__token__`, `f_token`, `hdnts`, access /
|
||||
* refresh / id tokens; exchange and authorization codes of four characters or more, and a bare
|
||||
* `code=` where it is one: in a query string or on a line with OAuth keys such as `client_id`,
|
||||
* `redirect_uri` or `state`, never an 8-hex exception code like Proton's `code=c0000005`) and of
|
||||
* Authorization / Cookie headers are blanked with [mark].
|
||||
*
|
||||
* A line already scrubbed comes out unchanged.
|
||||
*/
|
||||
object SecretScrub {
|
||||
enum class Urls { SHORT, KEEP_PATH }
|
||||
|
||||
private val URL = Regex("""[A-Za-z][A-Za-z0-9+.\-]*://[^\s"'<>]+""")
|
||||
private val SAFE_SEGMENT = Regex("""[A-Za-z0-9._\-]{1,40}""")
|
||||
private val TOKEN_SEGMENT = Regex("""(?i)(token|hdnts|hmac|signature|sig|exp)=""")
|
||||
private val TOKEN = Regex("""(?i)(?<![A-Za-z0-9_])(__token__|f_token|hdnts|access_token|refresh_token|id_token|token)(["']?\s*[=:]\s*["']?)[^\s&;,"'<>}]+""")
|
||||
/** Codes that are only ever OAuth grants. */
|
||||
private val GRANT_CODE = Regex("""(?i)(?<![A-Za-z0-9_])(exchange_code|authorization_?code)(["']?\s*=\s*["']?)[^\s&;,"'<>}]{4,}""")
|
||||
/** A bare `code=`: a grant only right after `?` / `&` (a query string) or beside OAuth keys ([OAUTH_CONTEXT]). */
|
||||
private val CODE = Regex("""(?i)(?<![A-Za-z0-9_])(code)(["']?\s*=\s*["']?)([^\s&;,"'<>}]{4,})""")
|
||||
private val OAUTH_CONTEXT = Regex("""(?i)(?<![A-Za-z0-9_])(client_id|client_secret|redirect_uri|state|grant_type|response_type|access_token|refresh_token|id_token)["']?\s*[=:]""")
|
||||
/** A Windows exception or status code (`code=c0000005`, `code=0x80000003`): never a grant. */
|
||||
private val STATUS_CODE = Regex("""(?i)(0x)?[0-9a-f]{8}""")
|
||||
private val HEADER = Regex("""(?i)\b(authorization|cookie|set-cookie)(\s*[:=]\s*).+""")
|
||||
|
||||
fun scrub(text: String, urls: Urls, mark: String): String {
|
||||
var out = URL.replace(text) { m -> if (urls == Urls.SHORT) short(m.value) else keepPath(m.value, mark) }
|
||||
out = TOKEN.replace(out) { m -> m.groupValues[1] + m.groupValues[2] + mark }
|
||||
out = GRANT_CODE.replace(out) { m -> m.groupValues[1] + m.groupValues[2] + mark }
|
||||
val oauth = OAUTH_CONTEXT.containsMatchIn(text)
|
||||
val src = out
|
||||
out = CODE.replace(src) { m ->
|
||||
val inQuery = m.range.first > 0 && src[m.range.first - 1] in "?&"
|
||||
val grant = !STATUS_CODE.matches(m.groupValues[3]) && (oauth || inQuery)
|
||||
if (grant) m.groupValues[1] + m.groupValues[2] + mark else m.value
|
||||
}
|
||||
out = HEADER.replace(out) { m -> m.groupValues[1] + m.groupValues[2] + mark }
|
||||
return out
|
||||
}
|
||||
|
||||
private fun split(url: String): Triple<String, String, String?> {
|
||||
val schemeEnd = url.indexOf("://")
|
||||
val rest = url.substring(schemeEnd + 3).substringBefore('#').substringBefore('?')
|
||||
val slash = rest.indexOf('/')
|
||||
val authority = (if (slash >= 0) rest.substring(0, slash) else rest).substringAfterLast('@')
|
||||
return Triple(url.substring(0, schemeEnd), authority, if (slash >= 0) rest.substring(slash + 1) else null)
|
||||
}
|
||||
|
||||
private fun short(url: String): String {
|
||||
val (scheme, authority, path) = split(url)
|
||||
if (path == null) return "$scheme://$authority"
|
||||
val first = path.substringBefore('/')
|
||||
if (first.isEmpty()) return "$scheme://$authority/"
|
||||
val shown = if (SAFE_SEGMENT.matches(first)) first else "…"
|
||||
return "$scheme://$authority/$shown" + if (path.length > first.length && shown != "…") "/…" else ""
|
||||
}
|
||||
|
||||
private fun keepPath(url: String, mark: String): String {
|
||||
val (scheme, authority, path) = split(url)
|
||||
val clean = path?.split('/')?.joinToString("/") { seg -> if (TOKEN_SEGMENT.containsMatchIn(seg)) mark else seg }
|
||||
return "$scheme://$authority" + (clean?.let { "/$it" } ?: "")
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,6 @@ class FileManagerActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
requestedOrientation = com.droiddeck.launcher.session.SessionPrefs.orientation(this)
|
||||
if (checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
|
||||
requestPermissions(arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), 1)
|
||||
}
|
||||
|
||||
@@ -1020,8 +1020,7 @@ fun FileManagerScreen(
|
||||
// The ROMs folder chosen on the main screen: what the session shows as /root/ROMs.
|
||||
SessionPrefs.romsDir(context).takeIf { it.isNotEmpty() }?.let(::File)?.takeIf { it.isDirectory }
|
||||
?.let { add(locItem(stringResource(R.string.fm_rail_roms), Icons.Filled.SportsEsports, it)) }
|
||||
// The app's own logs folder: private, which this file manager (the app itself) can read.
|
||||
com.droiddeck.launcher.runtime.LinuxRuntime.logDir(context).takeIf { it.isDirectory }?.let { add(locItem(stringResource(R.string.fm_rail_session_logs), Icons.Filled.Description, it)) }
|
||||
File("/storage/emulated/0/Download/DroidDeck").takeIf { it.isDirectory }?.let { add(locItem(stringResource(R.string.fm_rail_session_logs), Icons.Filled.Description, it)) }
|
||||
File("/storage/emulated/0/Pictures").takeIf { it.isDirectory }?.let { add(locItem(stringResource(R.string.fm_rail_pictures), Icons.Filled.Image, it)) }
|
||||
}
|
||||
val favItems = remember(favTick) { FavoritesStore.list(context).map(::File).filter { it.exists() } }
|
||||
|
||||
@@ -35,7 +35,6 @@ class FilePickerActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
requestedOrientation = com.droiddeck.launcher.session.SessionPrefs.orientation(this)
|
||||
// targetSdk 28: the old storage permission is exactly what lets us list the card.
|
||||
if (checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
|
||||
requestPermissions(arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), 1)
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import android.content.Context
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Games added from the Games tab's + rather than a Games folder: one picked .exe, or each game
|
||||
* "Add all games in this folder" found. Kept by the game's folder in `files/added-exes.json`,
|
||||
* which [AddedGames.scan] merges in. The exe chosen later, the shortcut appid, the edits and the
|
||||
* removal live where every added game keeps them ([com.droiddeck.launcher.session.SessionPrefs]);
|
||||
* this holds which folders are games and the exe they came with.
|
||||
*/
|
||||
object AddedExes {
|
||||
/** [picked]: the user chose [exe]; false = [GameExePicker]'s best guess, which a scan ranks again. */
|
||||
class Entry(val folder: String, val exe: String, val picked: Boolean = true)
|
||||
|
||||
private fun file(context: Context) = File(context.filesDir, "added-exes.json")
|
||||
|
||||
@Synchronized
|
||||
fun list(context: Context): List<Entry> = try {
|
||||
val array = JSONArray(file(context).takeIf { it.isFile }?.readText() ?: "[]")
|
||||
(0 until array.length()).mapNotNull { i ->
|
||||
val o = array.optJSONObject(i) ?: return@mapNotNull null
|
||||
val folder = o.optString("folder"); val exe = o.optString("exe")
|
||||
if (folder.isEmpty() || exe.isEmpty()) null else Entry(folder, exe, o.optBoolean("picked", true))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
/** Adds [entry], replacing one for the same folder. */
|
||||
@Synchronized
|
||||
fun put(context: Context, entry: Entry) = write(context, list(context).filter { it.folder != entry.folder } + entry)
|
||||
|
||||
@Synchronized
|
||||
fun drop(context: Context, folder: String) = write(context, list(context).filter { it.folder != folder })
|
||||
|
||||
private fun write(context: Context, entries: List<Entry>) {
|
||||
val array = JSONArray()
|
||||
entries.forEach { array.put(JSONObject().put("folder", it.folder).put("exe", it.exe).put("picked", it.picked)) }
|
||||
val f = file(context)
|
||||
val tmp = File(f.path + ".tmp")
|
||||
tmp.writeText(array.toString())
|
||||
if (!tmp.renameTo(f)) { f.delete(); tmp.renameTo(f) }
|
||||
}
|
||||
}
|
||||
@@ -12,9 +12,8 @@ import java.net.URL
|
||||
import java.net.URLEncoder
|
||||
|
||||
/**
|
||||
* Artwork for an added game: what the user put in the game's folder first, else what Steam has
|
||||
* for it - by the Steam appid its files name, else by a store search on its title - else what
|
||||
* SteamGridDB has ([SteamGridDb]), fetched once into the app's cache. Four pieces, named the way
|
||||
* Artwork for an added game: what the user put in the game's folder first, else what Steam's
|
||||
* store has for the same title, fetched once into the app's cache. Four pieces, named the way
|
||||
* the client names them in its grid folder: the portrait capsule, the wide header, the hero
|
||||
* background and the logo; plus an icon pulled out of the game's own .exe. The session hands
|
||||
* the paths to the runtime's shortcuts writer, which copies them into the client's grid.
|
||||
@@ -29,40 +28,13 @@ object AddedGameArt {
|
||||
val any get() = portrait != null || header != null || hero != null || logo != null
|
||||
}
|
||||
|
||||
/**
|
||||
* The pieces a user can choose, as the client takes them: [assetType] is its custom-artwork
|
||||
* number (null: the icon, set by path); [cacheName] the automatic file in the game's cache.
|
||||
*/
|
||||
enum class Slot(val key: String, val cacheName: String, val assetType: Int?) {
|
||||
COVER("p", "p.jpg", 0), BACKGROUND("hero", "hero.jpg", 1), LOGO("logo", "logo.png", 2), ICON("icon", "icon.png", null),
|
||||
}
|
||||
|
||||
/** Where a piece came from; [AUTO] = nothing found yet. */
|
||||
const val FOLDER = "folder"
|
||||
const val STEAM = "steam"
|
||||
const val SGDB = "sgdb"
|
||||
const val FILE = "file"
|
||||
const val EXE = "exe"
|
||||
const val AUTO = ""
|
||||
|
||||
/** A slot as it stands: the file shown and where it came from (one of [FOLDER].. [AUTO]); [chosen] = the user picked it. */
|
||||
class SlotState(val slot: Slot, val file: File?, val source: String, val chosen: Boolean)
|
||||
|
||||
/** One image the chooser offers: its group ([FOLDER], [STEAM], [SGDB]), a thumbnail and the full image (URL or path). */
|
||||
class Option(val group: String, val thumb: String, val full: String)
|
||||
|
||||
private val CHOSEN = Regex("""chosen-(\w+)\.(\w+)""")
|
||||
|
||||
private fun chosen(cache: File, slot: Slot): File? =
|
||||
cache.listFiles { f -> CHOSEN.matchEntire(f.name)?.groupValues?.get(1) == slot.key }?.firstOrNull()
|
||||
|
||||
private val IMAGE = listOf("png", "jpg", "jpeg")
|
||||
private val PORTRAIT = listOf("cover", "poster", "grid", "boxart", "capsule", "folder", "library_600x900", "portrait")
|
||||
private val HEADER = listOf("header", "banner", "library_header", "wide")
|
||||
private val HERO = listOf("hero", "background", "library_hero")
|
||||
private val LOGO = listOf("logo")
|
||||
|
||||
internal fun cacheDir(context: Context, game: AddedGames.Game) = File(context.filesDir, "added-art/${game.appId}")
|
||||
private fun cacheDir(context: Context, game: AddedGames.Game) = File(context.filesDir, "added-art/${game.appId}")
|
||||
|
||||
/** An image in the folder (or its `art` subfolder) under one of the given names, any image extension. */
|
||||
private fun find(folder: File, names: List<String>): File? {
|
||||
@@ -86,111 +58,20 @@ object AddedGameArt {
|
||||
)
|
||||
}
|
||||
|
||||
/** The user's choice for each piece, else the folder's art, else the cached art; no network. */
|
||||
/** The folder's art, else the cached Steam art, without touching the network. */
|
||||
fun resolve(context: Context, game: AddedGames.Game): Art {
|
||||
val own = local(game)
|
||||
val cache = cacheDir(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 = own.header ?: cached("header.jpg") ?: exeArt(context, game, cover = false),
|
||||
hero = chosen(cache, Slot.BACKGROUND) ?: own.hero ?: 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),
|
||||
portrait = own.portrait ?: cached("p.jpg"),
|
||||
header = own.header ?: cached("header.jpg"),
|
||||
hero = own.hero ?: cached("hero.jpg"),
|
||||
logo = own.logo ?: cached("logo.png"),
|
||||
icon = own.icon ?: exeIcon(context, game),
|
||||
)
|
||||
}
|
||||
|
||||
/** Each choosable piece with its file and where it came from, for the game's editor; no network. */
|
||||
fun describe(context: Context, game: AddedGames.Game): List<SlotState> {
|
||||
val own = local(game)
|
||||
val cache = cacheDir(context, game)
|
||||
fun cached(name: String): Pair<File, String>? = File(cache, name).takeIf { it.isFile }?.let { f ->
|
||||
f to (File(cache, "$name.src").takeIf { it.isFile }?.readText()?.trim()?.takeIf { it.isNotEmpty() } ?: STEAM)
|
||||
}
|
||||
return Slot.values().map { slot ->
|
||||
chosen(cache, slot)?.let { return@map SlotState(slot, it, chosenSource(context, game, slot).first.ifEmpty { FILE }, true) }
|
||||
val auto: Pair<File, String>? = when (slot) {
|
||||
Slot.COVER -> own.portrait?.let { it to FOLDER } ?: cached("p.jpg") ?: exeArt(context, game, cover = true)?.let { it to EXE }
|
||||
Slot.BACKGROUND -> own.hero?.let { it to FOLDER } ?: 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 }
|
||||
}
|
||||
SlotState(slot, auto?.first, auto?.second ?: AUTO, false)
|
||||
}
|
||||
}
|
||||
|
||||
private val CHOOSABLE = setOf("png", "jpg", "jpeg", "webp")
|
||||
|
||||
/** Images in the game's folder (and its art folders) that could stand for a piece. */
|
||||
internal fun folderImages(folder: File): List<File> =
|
||||
listOf(folder, File(folder, "art"), File(folder, ".art")).flatMap { dir ->
|
||||
dir.listFiles { f -> f.isFile && f.extension.lowercase() in CHOOSABLE }?.sortedBy { it.name.lowercase() }.orEmpty()
|
||||
}.take(12)
|
||||
|
||||
/**
|
||||
* What the chooser offers for [slot]: the game folder's images, Steam's official piece (when the
|
||||
* game has a Steam appid and Steam has the piece), and SteamGridDB's best few (with a key).
|
||||
* Blocking: it asks the network.
|
||||
*/
|
||||
fun options(context: Context, game: AddedGames.Game, slot: Slot): List<Option> {
|
||||
val out = ArrayList<Option>()
|
||||
folderImages(game.folder).forEach { out.add(Option(FOLDER, it.path, it.path)) }
|
||||
val cache = cacheDir(context, game)
|
||||
val steamId = SteamMatch.resolve(context, game.folder.path, game.exe, game.name, legacy = File(cache, "steam-appid")).appId?.toString()
|
||||
val remote = when (slot) { Slot.COVER -> "library_600x900.jpg"; Slot.BACKGROUND -> "library_hero.jpg"; Slot.LOGO -> "logo.png"; Slot.ICON -> null }
|
||||
if (steamId != null && remote != null) {
|
||||
val url = "$CDN$steamId/$remote".takeIf { exists(it) } ?: steamAssetUrl(steamId, remote)
|
||||
if (url != null) out.add(Option(STEAM, url, url))
|
||||
}
|
||||
val key = SteamGridDb.key(context)
|
||||
if (key.isNotEmpty()) {
|
||||
cache.mkdirs()
|
||||
val gridId = remembered(File(cache, "sgdb-id")) {
|
||||
runCatching { SteamGridDb.gameId(key, steamId?.toIntOrNull(), game.name) }.getOrNull()?.toString()
|
||||
}?.toIntOrNull()
|
||||
if (gridId != null) runCatching { SteamGridDb.images(key, gridId, slot, if (slot == Slot.ICON) 3 else 5) }.getOrNull()
|
||||
?.forEach { (thumb, full) -> out.add(Option(SGDB, thumb, full)) }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Sets [slot] to [option] (a file is copied, a URL fetched) and records where it came from. False when it could not be had. Blocking. */
|
||||
fun choose(context: Context, game: AddedGames.Game, slot: Slot, option: Option): Boolean {
|
||||
val bytes = if (option.full.startsWith("https://")) get(option.full) else runCatching { File(option.full).readBytes() }.getOrNull()
|
||||
bytes ?: return false
|
||||
val ext = option.full.substringAfterLast('.', "png").substringBefore('?').lowercase().takeIf { it in CHOOSABLE + "ico" } ?: "png"
|
||||
val cache = cacheDir(context, game).apply { mkdirs() }
|
||||
chosen(cache, slot)?.delete()
|
||||
val dst = File(cache, "chosen-${slot.key}.${if (ext == "jpeg") "jpg" else ext}")
|
||||
val tmp = File(cache, dst.name + ".tmp")
|
||||
tmp.writeBytes(bytes)
|
||||
if (!tmp.renameTo(dst)) return false
|
||||
SessionPrefs.setAddedGameArtSource(context, game.folder.path, slot.key, option.group + "|" + option.full)
|
||||
return true
|
||||
}
|
||||
|
||||
/** Where the user's choice for [slot] came from and which image it was ("" both when automatic). */
|
||||
fun chosenSource(context: Context, game: AddedGames.Game, slot: Slot): Pair<String, String> {
|
||||
val value = SessionPrefs.addedGameArtSource(context, game.folder.path, slot.key)
|
||||
return value.substringBefore('|') to value.substringAfter('|', "")
|
||||
}
|
||||
|
||||
/** [slot] back to what is found by itself. */
|
||||
fun reset(context: Context, game: AddedGames.Game, slot: Slot) {
|
||||
chosen(cacheDir(context, game), slot)?.delete()
|
||||
SessionPrefs.setAddedGameArtSource(context, game.folder.path, slot.key, "")
|
||||
}
|
||||
|
||||
private fun exists(url: String): Boolean = runCatching {
|
||||
val c = URL(url).openConnection() as HttpURLConnection
|
||||
c.requestMethod = "HEAD"
|
||||
c.connectTimeout = 8_000
|
||||
c.readTimeout = 8_000
|
||||
c.setRequestProperty("User-Agent", "DroidDeck-launcher")
|
||||
try { c.responseCode == 200 } finally { c.disconnect() }
|
||||
}.getOrDefault(false)
|
||||
|
||||
/** The first icon inside the game's .exe, kept as a PNG beside the cached art. */
|
||||
private fun exeIcon(context: Context, game: AddedGames.Game): File? {
|
||||
val cache = cacheDir(context, game)
|
||||
@@ -207,91 +88,58 @@ object AddedGameArt {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the art that is missing: Steam first (the appid the game's files name, else a store
|
||||
* search on its title whose result carries the same name), then SteamGridDB when a key is
|
||||
* set. Each lookup runs once per game; a miss is retried after a week. Blocking: run it off
|
||||
* the main thread. Returns true when anything new arrived.
|
||||
* Looks the games up on Steam's store and fetches the art that is missing, once per game
|
||||
* (a miss is retried after a week). Blocking: run it off the main thread. Returns true when
|
||||
* anything new arrived.
|
||||
*/
|
||||
fun fetchMissing(context: Context, games: List<AddedGames.Game>): Boolean {
|
||||
if (!SessionPrefs.addedGamesArt(context)) return false
|
||||
val sgdbKey = SteamGridDb.key(context)
|
||||
var changed = false
|
||||
for (game in games) {
|
||||
if (local(game).portrait != null) continue
|
||||
val cache = cacheDir(context, game)
|
||||
val lookup = File(cache, "steam-appid")
|
||||
if (File(cache, "p.jpg").isFile) 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 (lookup.isFile && (lookup.readText().trim() != "none" || System.currentTimeMillis() - lookup.lastModified() < RETRY_AFTER_MS)) {
|
||||
val id = lookup.readText().trim()
|
||||
if (id == "none") continue
|
||||
if (download(id, cache)) changed = true
|
||||
continue
|
||||
}
|
||||
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()) continue
|
||||
val gridId = remembered(File(cache, "sgdb-id")) {
|
||||
runCatching { SteamGridDb.gameId(sgdbKey, appId ?: id?.toIntOrNull(), game.name) }.getOrNull()?.toString()
|
||||
}?.toIntOrNull() ?: continue
|
||||
if (runCatching { SteamGridDb.download(sgdbKey, gridId, cache) }.getOrDefault(false)) changed = true
|
||||
cache.mkdirs()
|
||||
val id = runCatching { search(game.name) }.onFailure { Log.w(TAG, "${game.name}: store search failed (${it.message})") }.getOrNull()
|
||||
if (id == null) { lookup.writeText("none"); continue }
|
||||
lookup.writeText(id)
|
||||
Log.i(TAG, "${game.name}: Steam app $id")
|
||||
if (download(id, cache)) changed = true
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
/**
|
||||
* The game's Steam app, the one record its Windows components use too ([SteamMatch]): what its
|
||||
* files name, else a store search on its title ([search]) whose result has the same name.
|
||||
*/
|
||||
internal fun steamMatch(context: Context, game: AddedGames.Game, search: (String) -> Int?): SteamMatch.Match {
|
||||
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
|
||||
}
|
||||
|
||||
/** What [marker] remembers (an id, or "none" for a week), else [find]'s answer written to it. */
|
||||
private fun remembered(marker: File, find: () -> String?): String? {
|
||||
if (marker.isFile) {
|
||||
val value = marker.readText().trim()
|
||||
if (value != "none") return value
|
||||
if (System.currentTimeMillis() - marker.lastModified() < RETRY_AFTER_MS) return null
|
||||
}
|
||||
val found = find()
|
||||
marker.writeText(found ?: "none")
|
||||
return found
|
||||
}
|
||||
|
||||
private fun normalize(s: String) = s.lowercase().replace(Regex("[^a-z0-9]"), "")
|
||||
|
||||
private val EDITION = Regex("(?i)\\b(complete|definitive|legendary|goty|game of the year|deluxe|ultimate|remastered)( edition)?\\b")
|
||||
|
||||
/**
|
||||
* How well a result's name [got] matches the game's [name]: 3 the same, 2 the same once
|
||||
* edition words are dropped, 1 one starts with the other; 0 no match. Nothing is taken for
|
||||
* being the first result.
|
||||
*/
|
||||
internal fun nameMatch(name: String, got: String): Int {
|
||||
val want = normalize(name)
|
||||
val wantShort = normalize(name.replace(EDITION, " ").trim().ifEmpty { name })
|
||||
val have = normalize(got)
|
||||
if (have.isEmpty() || wantShort.isEmpty()) return 0
|
||||
return when {
|
||||
have == want -> 3
|
||||
have == wantShort -> 2
|
||||
have.startsWith(wantShort) || wantShort.startsWith(have) -> 1
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
|
||||
/** The id of the store result whose name matches [name] best, or null when none does. */
|
||||
internal fun pickStoreResult(name: String, items: List<Pair<String, String>>): String? =
|
||||
items.map { (id, got) -> id to nameMatch(name, got) }
|
||||
.filter { it.second > 0 && it.first.isNotEmpty() }
|
||||
.maxByOrNull { it.second }?.first
|
||||
|
||||
/** The Steam appid whose store name matches [name], or null for no match. */
|
||||
/** The Steam appid whose store name matches the folder name best, or null for no match. */
|
||||
private fun search(name: String): String? {
|
||||
val term = name.replace(EDITION, " ").trim().ifEmpty { name }
|
||||
val term = name.replace(Regex("(?i)\\b(complete|definitive|legendary|goty|game of the year|deluxe|ultimate|remastered)( edition)?\\b"), " ").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") })
|
||||
val want = normalize(name)
|
||||
val wantShort = normalize(term)
|
||||
var best: String? = null
|
||||
var bestScore = 0
|
||||
for (i in 0 until items.length()) {
|
||||
val item = items.getJSONObject(i)
|
||||
val got = normalize(item.optString("name"))
|
||||
val score = when {
|
||||
got == want -> 4
|
||||
got == wantShort -> 3
|
||||
got.startsWith(wantShort) || wantShort.startsWith(got) -> 2
|
||||
i == 0 -> 1
|
||||
else -> 0
|
||||
}
|
||||
if (score > bestScore) { bestScore = score; best = item.optString("id") }
|
||||
}
|
||||
return best?.takeIf { it.isNotEmpty() && bestScore >= 1 }
|
||||
}
|
||||
|
||||
private fun download(id: String, cache: File): Boolean {
|
||||
@@ -301,73 +149,15 @@ object AddedGameArt {
|
||||
)) {
|
||||
val dst = File(cache, localName)
|
||||
if (dst.isFile) continue
|
||||
val bytes = get("$CDN$id/$remote") ?: steamAssetUrl(id, remote)?.let { get(it) } ?: continue
|
||||
val bytes = get("$CDN$id/$remote") ?: continue
|
||||
val tmp = File(cache, "$localName.tmp")
|
||||
tmp.writeBytes(bytes)
|
||||
if (tmp.renameTo(dst)) { got = true; File(cache, "$localName.src").writeText(STEAM) }
|
||||
if (tmp.renameTo(dst)) got = true
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
/**
|
||||
* Newer Steam games keep their art under hashed paths (store_item_assets/steam/apps/<id>/<hash>/…),
|
||||
* where the fixed CDN names above 404. The store's item API names those paths; once per appid.
|
||||
*/
|
||||
private fun steamAssetUrl(id: String, remote: String): String? {
|
||||
val assets = steamAssets.getOrPut(id) {
|
||||
runCatching {
|
||||
val input = """{"ids":[{"appid":$id}],"context":{"language":"english","country_code":"US"},"data_request":{"include_assets":true}}"""
|
||||
val json = get("https://api.steampowered.com/IStoreBrowseService/GetItems/v1/?input_json=" + URLEncoder.encode(input, "UTF-8"))
|
||||
?.toString(Charsets.UTF_8) ?: return@runCatching emptyMap()
|
||||
val a = JSONObject(json).optJSONObject("response")?.optJSONArray("store_items")?.optJSONObject(0)?.optJSONObject("assets")
|
||||
?: return@runCatching emptyMap()
|
||||
val format = a.optString("asset_url_format").takeIf { it.contains("\${FILENAME}") } ?: return@runCatching emptyMap()
|
||||
fun url(vararg keys: String) = keys.firstNotNullOfOrNull { k -> a.optString(k).takeIf { it.isNotEmpty() } }
|
||||
?.let { "https://shared.akamai.steamstatic.com/store_item_assets/" + format.replace("\${FILENAME}", it) }
|
||||
mapOf(
|
||||
"library_600x900.jpg" to url("library_capsule_2x", "library_capsule"),
|
||||
"header.jpg" to url("header"),
|
||||
"library_hero.jpg" to url("library_hero_2x", "library_hero"),
|
||||
).filterValues { it != null }.mapValues { it.value!! }
|
||||
}.getOrDefault(emptyMap())
|
||||
}
|
||||
return assets[remote]
|
||||
}
|
||||
|
||||
private val steamAssets = java.util.concurrent.ConcurrentHashMap<String, Map<String, String>>()
|
||||
|
||||
/**
|
||||
* When nothing else gave a cover (or header), one made from the exe's icon: the icon centred on
|
||||
* its own colour, darkened toward the bottom, so the game still has a face in the list and in
|
||||
* Steam. Made once per game; real art found later takes over because it is looked at first.
|
||||
*/
|
||||
private fun exeArt(context: Context, game: AddedGames.Game, cover: Boolean): File? {
|
||||
val cache = cacheDir(context, game)
|
||||
val out = File(cache, if (cover) "p-exe.png" else "header-exe.png")
|
||||
if (out.isFile) return out
|
||||
val icon = exeIcon(context, game) ?: return null
|
||||
val src = android.graphics.BitmapFactory.decodeFile(icon.path) ?: return null
|
||||
val (w, h) = if (cover) 600 to 900 else 920 to 430
|
||||
val tint = android.graphics.Bitmap.createScaledBitmap(src, 1, 1, true).getPixel(0, 0)
|
||||
val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
||||
val canvas = android.graphics.Canvas(bmp)
|
||||
fun shade(c: Int, f: Float) = android.graphics.Color.rgb(
|
||||
(android.graphics.Color.red(c) * f).toInt(), (android.graphics.Color.green(c) * f).toInt(), (android.graphics.Color.blue(c) * f).toInt(),
|
||||
)
|
||||
canvas.drawPaint(android.graphics.Paint().apply {
|
||||
shader = android.graphics.LinearGradient(0f, 0f, 0f, h.toFloat(), shade(tint, 0.7f), shade(tint, 0.15f), android.graphics.Shader.TileMode.CLAMP)
|
||||
})
|
||||
val side = (minOf(w, h) * 0.5f).toInt()
|
||||
val left = (w - side) / 2f
|
||||
val top = (h - side) / 2f
|
||||
canvas.drawBitmap(src, null, android.graphics.RectF(left, top, left + side, top + side), android.graphics.Paint(android.graphics.Paint.FILTER_BITMAP_FLAG))
|
||||
cache.mkdirs()
|
||||
val tmp = File(cache, "${out.name}.tmp")
|
||||
tmp.outputStream().use { bmp.compress(Bitmap.CompressFormat.PNG, 100, it) }
|
||||
return if (tmp.renameTo(out)) out else null
|
||||
}
|
||||
|
||||
internal fun get(url: String): ByteArray? {
|
||||
private fun get(url: String): ByteArray? {
|
||||
val c = URL(url).openConnection() as HttpURLConnection
|
||||
c.connectTimeout = 10_000
|
||||
c.readTimeout = 15_000
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import android.content.Context
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import com.droiddeck.launcher.stores.SteamLiveShortcuts
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* The game editor's writes for an added game, Steam's non-Steam Properties in the app: name,
|
||||
* target, Start in, launch options and art. Each goes through the same shortcut - the listing the
|
||||
* shortcuts writer reads, and the running client - on the appid the game already has, so an edit
|
||||
* never makes a second shortcut. Each call is quick (the game alone, [AddedGames.single]); the
|
||||
* client and the listing follow on [AddedGamesPublisher], in order. Call off the main thread.
|
||||
*/
|
||||
object AddedGameEdits {
|
||||
/** The added game in [folder] as it stands now, or null when it is not listed. */
|
||||
fun game(context: Context, folder: String): AddedGames.Game? = AddedGames.single(context, folder)
|
||||
|
||||
/** The client told ([live]), then the listing rewritten, on [AddedGamesPublisher]. */
|
||||
private fun publish(context: Context, live: () -> Unit) = AddedGamesPublisher.later(context, live)
|
||||
|
||||
/** [name] as the game's name; "" goes back to the name found by itself. */
|
||||
fun setName(context: Context, folder: String, name: String): AddedGames.Game? {
|
||||
SessionPrefs.setAddedGameName(context, folder, name)
|
||||
return apply(context, folder)
|
||||
}
|
||||
|
||||
/** [exe] as the game's target; false when the session cannot see it. */
|
||||
fun setExe(context: Context, folder: String, exe: File): Boolean {
|
||||
if (!exe.isFile || AddedGames.guestPath(context, exe) == null) return false
|
||||
SessionPrefs.setAddedGameExe(context, folder, exe.path)
|
||||
apply(context, folder)
|
||||
return true
|
||||
}
|
||||
|
||||
/** [dir] (a host folder) as the game's Start in; "" = the exe's own folder. False when the session cannot see it. */
|
||||
fun setStartIn(context: Context, folder: String, dir: String): Boolean {
|
||||
val guest = if (dir.isBlank()) "" else AddedGames.guestPath(context, File(dir.trim())) ?: return false
|
||||
SessionPrefs.setAddedGameStartIn(context, folder, guest)
|
||||
apply(context, folder)
|
||||
return true
|
||||
}
|
||||
|
||||
fun setLaunchOptions(context: Context, folder: String, options: String): AddedGames.Game? {
|
||||
SessionPrefs.setAddedGameLaunch(context, folder, options)
|
||||
return apply(context, folder)
|
||||
}
|
||||
|
||||
/** [slot] set to [option], then handed to the shortcut. False when the image could not be had. */
|
||||
fun setArt(context: Context, folder: String, slot: AddedGameArt.Slot, option: AddedGameArt.Option): Boolean {
|
||||
val game = game(context, folder) ?: return false
|
||||
if (!AddedGameArt.choose(context, game, slot, option)) return false
|
||||
applyArt(context, game, slot)
|
||||
return true
|
||||
}
|
||||
|
||||
/** [slot] back to automatic, then handed to the shortcut. */
|
||||
fun resetArt(context: Context, folder: String, slot: AddedGameArt.Slot) {
|
||||
val game = game(context, folder) ?: return
|
||||
AddedGameArt.reset(context, game, slot)
|
||||
applyArt(context, game, slot)
|
||||
}
|
||||
|
||||
private fun applyArt(context: Context, game: AddedGames.Game, slot: AddedGameArt.Slot) {
|
||||
val file = AddedGameArt.describe(context, game).first { it.slot == slot }.file
|
||||
publish(context) {
|
||||
if (slot == AddedGameArt.Slot.ICON) SteamLiveShortcuts.setArt(context, game.appId, null, null, iconPath = file?.absolutePath.orEmpty())
|
||||
else SteamLiveShortcuts.setArt(context, game.appId, slot.assetType, file)
|
||||
}
|
||||
}
|
||||
|
||||
private fun apply(context: Context, folder: String): AddedGames.Game? {
|
||||
val game = game(context, folder)
|
||||
if (game != null) publish(context) { SteamLiveShortcuts.update(context, game) }
|
||||
return game
|
||||
}
|
||||
}
|
||||
@@ -33,27 +33,26 @@ object AddedGames {
|
||||
val source: String = Library.ADDED,
|
||||
/** The store's own id for the game, from its sidecar; null for a plain added folder. */
|
||||
val storeId: String? = null,
|
||||
/** The exe is the picker's guess and nothing clearly won ([GameExePicker.Ranking.uncertain]). */
|
||||
val exeUncertain: Boolean = false,
|
||||
/** Steam's launch options for the shortcut; "" for none. */
|
||||
val launchOptions: String = "",
|
||||
/** Added from the Games tab (an entry of its own), not found in a Games folder. */
|
||||
val single: Boolean = false,
|
||||
) {
|
||||
fun folderName(): String = folder.name
|
||||
}
|
||||
|
||||
/** A Games folder an earlier build had, and where the session sees it. */
|
||||
/** One Games folder and where the session sees it. */
|
||||
class Root(val host: File, val guest: String)
|
||||
|
||||
/**
|
||||
* The Games folders earlier builds had, bound into the session at the guest paths they had
|
||||
* then (/root/Games/<name>), so the games in them (entries now, [GamesFolderMigration]) keep
|
||||
* their paths and shortcuts. Nothing adds one any more.
|
||||
* The chosen folders with their guest paths: /root/Games/<folder name>, or with a short hash of
|
||||
* the host path when two chosen folders share a name (so a folder's guest path, and with it
|
||||
* every shortcut's appid, does not change when another folder is added or removed).
|
||||
*/
|
||||
fun roots(context: Context): List<Root> {
|
||||
GamesFolderMigration.run(context)
|
||||
return SessionPrefs.gameFolderBinds(context).map { (host, guest) -> Root(File(host), guest) }
|
||||
val hosts = SessionPrefs.addedGamesDirs(context).map { File(it) }
|
||||
val names = hosts.groupingBy { it.name.lowercase() }.eachCount()
|
||||
return hosts.map { host ->
|
||||
val name = host.name.ifEmpty { "games" }
|
||||
val guestName = if ((names[name.lowercase()] ?: 0) > 1) name + "-" + "%08x".format(CRC32().apply { update(host.path.toByteArray()) }.value).take(4) else name
|
||||
Root(host, "$GUEST_DIR/$guestName")
|
||||
}
|
||||
}
|
||||
|
||||
private val SKIP = Regex(
|
||||
@@ -87,286 +86,47 @@ object AddedGames {
|
||||
// root/Games/Stores): the tree is the session's / and needs no bind.
|
||||
val runtime = LinuxRuntime.rootDir(context).absolutePath
|
||||
if (path.startsWith("$runtime/")) return "/" + path.removePrefix("$runtime/")
|
||||
// Internal storage, bound at /root/Storage in every session: an exe picked anywhere on it.
|
||||
val storage = android.os.Environment.getExternalStorageDirectory().absolutePath
|
||||
if (path.startsWith("$storage/")) return "/root/Storage/" + path.removePrefix("$storage/")
|
||||
return null
|
||||
}
|
||||
|
||||
/** The .exe files a game folder offers, best first ([GameExePicker]); declared Store exes lead. */
|
||||
fun candidates(folder: File): List<File> = rank(folder).exes
|
||||
|
||||
/** [GameExePicker]'s ranking, with a Store-declared executable taking precedence when present. */
|
||||
fun rank(folder: File): GameExePicker.Ranking {
|
||||
val key = "${folder.path}|${folder.lastModified()}"
|
||||
val now = System.currentTimeMillis()
|
||||
ranked[key]?.takeIf { now - it.first < RANK_FRESH_MS }?.let { return it.second }
|
||||
val guessed = GameExePicker.rank(folder) { !SKIP.matches(it.name) }
|
||||
val declared = declaredExes(folder)
|
||||
val preferred = declared.map { it.path }.toSet()
|
||||
val result = if (declared.isEmpty()) guessed else GameExePicker.Ranking(
|
||||
declared + guessed.exes.filter { it.path !in preferred },
|
||||
List(declared.size) { Int.MAX_VALUE } + guessed.exes.zip(guessed.scores)
|
||||
.filter { it.first.path !in preferred }.map { it.second },
|
||||
uncertain = false,
|
||||
/** The .exe files a game folder offers, best first. */
|
||||
fun candidates(folder: File): List<File> {
|
||||
val exes = ArrayList<File>()
|
||||
val roots = listOf(folder) + (folder.listFiles { f -> f.isDirectory }?.sortedBy { it.name.lowercase() } ?: emptyList())
|
||||
for (dir in roots) {
|
||||
dir.listFiles { f -> f.isFile && f.name.endsWith(".exe", ignoreCase = true) && !SKIP.matches(f.name) }
|
||||
?.let { exes.addAll(it) }
|
||||
}
|
||||
val key = folder.name.lowercase().replace(Regex("[^a-z0-9]"), "")
|
||||
return exes.sortedWith(
|
||||
compareByDescending<File> { it.parentFile == folder }
|
||||
.thenByDescending { it.nameWithoutExtension.lowercase().replace(Regex("[^a-z0-9]"), "").let { n -> n == key || key.startsWith(n) || n.startsWith(key) } }
|
||||
.thenByDescending { it.length() },
|
||||
)
|
||||
if (ranked.size > 256) ranked.clear()
|
||||
ranked[key] = now to result
|
||||
return result
|
||||
}
|
||||
|
||||
/** A folder's ranking for a minute, so one add (rank, then the game built from it) walks it once. */
|
||||
private val ranked = java.util.concurrent.ConcurrentHashMap<String, Pair<Long, GameExePicker.Ranking>>()
|
||||
private const val RANK_FRESH_MS = 60_000L
|
||||
|
||||
/**
|
||||
* Names identified from an exe (Steam manifest, GOG info, version resource), by the exe's path,
|
||||
* size and time and its game folder's time (a GOG info file dropped in changes the latter).
|
||||
*/
|
||||
private val identified = java.util.concurrent.ConcurrentHashMap<String, String>()
|
||||
|
||||
/**
|
||||
* The game's own title for [exe] when the disk names one ([GameIdentifier]: a Steam manifest,
|
||||
* a GOG info file, the exe's version resource), else null (the folder name stands).
|
||||
*/
|
||||
internal fun identifiedName(folder: File, exe: File): String? {
|
||||
val key = "${exe.path}|${exe.length()}|${exe.lastModified()}|${folder.lastModified()}"
|
||||
identified[key]?.let { return it.ifEmpty { null } }
|
||||
val id = runCatching { GameIdentifier.identify(exe) }.getOrNull()
|
||||
val found = id?.name?.takeIf {
|
||||
id.nameSource in setOf(GameIdentifier.Source.STEAM_MANIFEST_ACF, GameIdentifier.Source.GOG_INFO, GameIdentifier.Source.PE_VERSION)
|
||||
}
|
||||
val name = found?.let { if (id?.nameSource == GameIdentifier.Source.PE_VERSION) betterSpelling(underscoresToSpaces(it), folder.name) else it }
|
||||
identified[key] = name.orEmpty()
|
||||
return name
|
||||
}
|
||||
|
||||
/**
|
||||
* The exe's version resource often spells the title the way a programmer typed it ("Insane2")
|
||||
* while the folder has the real spacing ("Insane 2"). When both spell the same letters and
|
||||
* digits, the one with more word breaks wins; a tie keeps mixed case over all one case
|
||||
* ("DiRT 3" over "dirt 3"), else the exe's spelling. Different titles keep the exe's.
|
||||
*/
|
||||
/** "Watch_Dogs" → "Watch Dogs": an underscore in a version-resource title is a programmer's space. */
|
||||
internal fun underscoresToSpaces(name: String): String =
|
||||
name.replace('_', ' ').replace(Regex("\\s+"), " ").trim().ifEmpty { name }
|
||||
|
||||
internal fun betterSpelling(peName: String, folderName: String): String {
|
||||
fun key(s: String) = s.lowercase().filter { it.isLetterOrDigit() }
|
||||
if (key(peName) != key(folderName) || folderName.isBlank()) return peName
|
||||
fun breaks(s: String) = s.count { !it.isLetterOrDigit() }
|
||||
fun mixed(s: String) = s.any { it.isUpperCase() } && s.any { it.isLowerCase() }
|
||||
return when {
|
||||
breaks(folderName) > breaks(peName) -> folderName.trim()
|
||||
breaks(folderName) < breaks(peName) -> peName
|
||||
mixed(folderName) && !mixed(peName) -> folderName.trim()
|
||||
else -> peName
|
||||
}
|
||||
}
|
||||
/** The executables MicrosoftGame.config or appxmanifest.xml declares that exist inside [folder]. */
|
||||
internal fun declaredExes(folder: File): List<File> {
|
||||
val names = DECLARED.flatMap { (file, pattern) ->
|
||||
runCatching { pattern.findAll(File(folder, file).readText()).map { it.groupValues[1] }.toList() }.getOrDefault(emptyList())
|
||||
}
|
||||
val root = runCatching { folder.canonicalPath + File.separator }.getOrNull() ?: return emptyList()
|
||||
return names.asSequence()
|
||||
.map { File(folder, it.trim().replace('\\', '/').trimStart('/')) }
|
||||
.filter { it.name.endsWith(".exe", ignoreCase = true) && !SKIP.matches(it.name) && it.isFile }
|
||||
.filter { runCatching { it.canonicalPath.startsWith(root) }.getOrDefault(false) }
|
||||
.distinctBy { it.path }
|
||||
.toList()
|
||||
}
|
||||
|
||||
private val DECLARED = listOf(
|
||||
"MicrosoftGame.config" to Regex("<Executable\\b[^>]*?\\bName\\s*=\\s*\"([^\"]+)\"", RegexOption.IGNORE_CASE),
|
||||
"appxmanifest.xml" to Regex("<Application\\b[^>]*?\\bExecutable\\s*=\\s*\"([^\"]+)\"", RegexOption.IGNORE_CASE),
|
||||
)
|
||||
|
||||
fun scan(context: Context): List<Game> {
|
||||
val out = ArrayList<Game>()
|
||||
GamesFolderMigration.run(context)
|
||||
val folders = listOfNotNull(
|
||||
val folders = roots(context).map { it.host } + listOfNotNull(
|
||||
GameStorage.effective(context)?.let { File(it.path) },
|
||||
GameStorage.effective(context)?.let { File(it.path, "steamapps/common") },
|
||||
)
|
||||
val removed = SessionPrefs.removedAddedGames(context).toSet()
|
||||
for (dir in folders.distinctBy { it.absolutePath }) {
|
||||
if (!dir.isDirectory) { Log.w(TAG, "$dir is not a folder; skipped"); continue }
|
||||
val steamInstalls = steamInstallDirs(dir)
|
||||
for (folder in dir.listFiles { f -> f.isDirectory }?.sortedBy { it.name.lowercase() } ?: emptyList()) {
|
||||
if (folder.name.lowercase() in steamInstalls || folder.name.equals("steamapps", ignoreCase = true)) continue
|
||||
// Removed from the list by the user (the folder stays on disk); restored from the settings page.
|
||||
if (folder.path in removed) continue
|
||||
// The store install root sits inside the Games storage; its games are scanned below, per store.
|
||||
if (folder.name == "Games" && StoreInstallRoot.roots(context).any { it.absolutePath == folder.absolutePath }) continue
|
||||
scanGame(context, folder, out)
|
||||
}
|
||||
}
|
||||
// Games added as one picked exe (the Games tab's +), by that exe's folder.
|
||||
for (entry in AddedExes.list(context)) {
|
||||
val folder = File(entry.folder)
|
||||
if (folder.path in removed || !folder.isDirectory) continue
|
||||
scanGame(context, folder, out, single = entry)
|
||||
}
|
||||
// Games the Stores section installed: one folder each under <root>/<Store>/, with a sidecar.
|
||||
// An unfinished one (sidecar still `installing`, or none at all) is neither a game nor Custom.
|
||||
for (folder in StoreInstallRoot.gameFolders(context)) if (StoreInstallRoot.isFinished(folder)) scanGame(context, folder, out)
|
||||
return out.distinctBy { it.folder.canonicalPath }
|
||||
}
|
||||
|
||||
/**
|
||||
* The game in [folderPath] alone, built by the same rules as [scan] (store sidecar, an entry
|
||||
* from the Games tab, the chosen exe, the edits) without walking every other game: what the
|
||||
* editor needs for one game. Null when that folder is not a game, or was removed.
|
||||
*/
|
||||
fun single(context: Context, folderPath: String): Game? {
|
||||
val folder = File(folderPath)
|
||||
if (!folder.isDirectory || folderPath in SessionPrefs.removedAddedGames(context)) return null
|
||||
val out = ArrayList<Game>(1)
|
||||
scanGame(context, folder, out, single = AddedExes.list(context).firstOrNull { it.folder == folderPath })
|
||||
return out.firstOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops listing the added game in [folder] at once: marked removed and dropped from the
|
||||
* library cache; the running client loses its shortcut ([appId], else the one stored for the
|
||||
* folder) and the listing is rewritten afterwards ([AddedGamesPublisher]). Nothing on disk is
|
||||
* deleted. Quick; call off the main thread (preferences and a small file).
|
||||
*/
|
||||
fun remove(context: Context, folder: File, appId: Long? = null, name: String = folder.name) {
|
||||
val app = context.applicationContext
|
||||
val started = android.os.SystemClock.elapsedRealtime()
|
||||
val id = appId ?: SessionPrefs.addedGameStoredAppId(app, folder.path)
|
||||
SessionPrefs.setAddedGameRemoved(app, folder.path, true)
|
||||
LibraryCache.save(app, LibraryCache.load(app).filterNot { it.library == Library.ADDED && it.gameFiles?.path == folder.path })
|
||||
Log.i(TAG, "games: remove $name took ${android.os.SystemClock.elapsedRealtime() - started}ms")
|
||||
AddedGamesPublisher.later(app) { if (id != null) com.droiddeck.launcher.stores.SteamLiveShortcuts.remove(app, id) }
|
||||
}
|
||||
|
||||
/** What [addExe] did with a picked exe. */
|
||||
sealed class AddResult {
|
||||
/** A new game, or the existing one the exe already is, or the listed game whose exe it now is. */
|
||||
class Added(val game: Game) : AddResult()
|
||||
class Existing(val game: Game) : AddResult()
|
||||
class Switched(val game: Game) : AddResult()
|
||||
/** The session cannot see the exe's path. */
|
||||
object Unreachable : AddResult()
|
||||
object NotFound : AddResult()
|
||||
}
|
||||
|
||||
private fun canonical(f: File): String = runCatching { f.canonicalPath }.getOrDefault(f.absolutePath)
|
||||
|
||||
private fun inside(path: String, folder: String) = path.startsWith(folder.trimEnd('/') + "/")
|
||||
|
||||
/**
|
||||
* The game folder [exe] belongs to, without a scan: an entry from the Games tab (Games folders
|
||||
* of earlier builds included), a removed game, a store install, or the first-level folder under
|
||||
* the Steam library that holds it. Null when it is in none.
|
||||
*/
|
||||
private fun ownerFolder(context: Context, exe: File): File? {
|
||||
GamesFolderMigration.run(context)
|
||||
val path = canonical(exe)
|
||||
fun holds(f: File) = inside(path, canonical(f))
|
||||
AddedExes.list(context).map { File(it.folder) }.firstOrNull(::holds)?.let { return it }
|
||||
SessionPrefs.removedAddedGames(context).map { File(it) }.firstOrNull(::holds)?.let { return it }
|
||||
StoreInstallRoot.gameFolders(context).firstOrNull(::holds)?.let { return it }
|
||||
// Games folders of earlier builds are entries now, found above.
|
||||
val dirs = listOfNotNull(
|
||||
GameStorage.effective(context)?.let { File(it.path, "steamapps/common") },
|
||||
GameStorage.effective(context)?.let { File(it.path) },
|
||||
)
|
||||
for (dir in dirs) {
|
||||
val base = canonical(dir)
|
||||
if (!inside(path, base)) continue
|
||||
val first = path.removePrefix("$base/").substringBefore('/')
|
||||
File(dir, first).takeIf { it.isDirectory && first != "steamapps" }?.let { return it }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the game [exe] is: the game itself when it is listed already, that game's exe switched
|
||||
* when [exe] sits in a listed (or removed) game's folder, else a new game for [exe]'s folder.
|
||||
* Only that one game is read; Steam (the running client, then the listing) follows on
|
||||
* [AddedGamesPublisher]. Nothing is added when the session cannot see the path.
|
||||
*/
|
||||
fun addExe(context: Context, exe: File): AddResult {
|
||||
val app = context.applicationContext
|
||||
if (!exe.isFile) return AddResult.NotFound
|
||||
val path = canonical(exe)
|
||||
val owner = ownerFolder(app, exe)
|
||||
if (owner != null && owner.path !in SessionPrefs.removedAddedGames(app)) {
|
||||
single(app, owner.path)?.takeIf { canonical(it.exe) == path }?.let { return AddResult.Existing(it) }
|
||||
}
|
||||
if (owner == null && guestPath(app, exe) == null) return AddResult.Unreachable
|
||||
val folder = owner ?: exe.parentFile ?: return AddResult.NotFound
|
||||
SessionPrefs.setAddedGameRemoved(app, folder.path, false)
|
||||
SessionPrefs.setAddedGameExe(app, folder.path, exe.path)
|
||||
if (owner == null) AddedExes.put(app, AddedExes.Entry(folder.path, exe.path))
|
||||
val game = single(app, folder.path) ?: return AddResult.NotFound
|
||||
AddedGamesPublisher.later(app) { com.droiddeck.launcher.stores.SteamLiveShortcuts.sync(app, listOf(game)) { true } }
|
||||
Log.i(TAG, "added ${game.name} from its exe")
|
||||
return if (owner == null) AddResult.Added(game) else AddResult.Switched(game)
|
||||
}
|
||||
|
||||
/** What [addFolder] did: the games it added, and how many of the folder's games were listed already. */
|
||||
class FolderResult(val added: List<Game>, val already: Int)
|
||||
|
||||
/**
|
||||
* "Add all games in this folder": every subfolder of [dir] with an .exe becomes a game with
|
||||
* [GameExePicker]'s best one (marked uncertain where nothing clearly won), as an entry of its
|
||||
* own, not a Games folder. A subfolder that is, holds or sits in a listed game counts as
|
||||
* already there; a removed one comes back. Steam follows once, on [AddedGamesPublisher]. Blocking.
|
||||
*/
|
||||
fun addFolder(context: Context, dir: File, onGame: (Game) -> Unit = {}, onChecking: (String?) -> Unit = {}): FolderResult {
|
||||
val app = context.applicationContext
|
||||
val removed = SessionPrefs.removedAddedGames(app).toSet()
|
||||
val listed = listedFolders(app, removed)
|
||||
var already = 0
|
||||
val fresh = ArrayList<String>()
|
||||
val added = ArrayList<Game>()
|
||||
for (sub in dir.listFiles { f -> f.isDirectory && !f.name.startsWith(".") }?.sortedBy { it.name.lowercase() }.orEmpty()) {
|
||||
val path = canonical(sub)
|
||||
if (listed.any { it == path || inside(path, it) || inside(it, path) }) { already++; continue }
|
||||
onChecking(sub.name)
|
||||
val best = rank(sub).exes.firstOrNull() ?: continue
|
||||
if (guestPath(app, best) == null) continue
|
||||
if (sub.path in removed) SessionPrefs.setAddedGameRemoved(app, sub.path, false)
|
||||
AddedExes.put(app, AddedExes.Entry(sub.path, best.path, picked = false))
|
||||
fresh.add(sub.path)
|
||||
single(app, sub.path)?.let { added.add(it); onGame(it) }
|
||||
}
|
||||
onChecking(null)
|
||||
if (fresh.isEmpty()) return FolderResult(emptyList(), already)
|
||||
AddedGamesPublisher.later(app) { com.droiddeck.launcher.stores.SteamLiveShortcuts.sync(app, added) { true } }
|
||||
Log.i(TAG, "added ${added.size} game(s) from ${dir.name}, $already already listed")
|
||||
return FolderResult(added, already)
|
||||
}
|
||||
|
||||
/**
|
||||
* The game folders already listed, without building any game: the first-level folders of the
|
||||
* Steam library (as [scan] walks them), the Games tab's entries (Games folders of earlier builds
|
||||
* included) and the store installs. Canonical paths; removed games are left out.
|
||||
*/
|
||||
private fun listedFolders(context: Context, removed: Set<String>): List<String> {
|
||||
val out = ArrayList<String>()
|
||||
GamesFolderMigration.run(context)
|
||||
val dirs = listOfNotNull(
|
||||
GameStorage.effective(context)?.let { File(it.path) },
|
||||
GameStorage.effective(context)?.let { File(it.path, "steamapps/common") },
|
||||
)
|
||||
for (dir in dirs.distinctBy { it.absolutePath }) {
|
||||
for (folder in dir.listFiles { f -> f.isDirectory }.orEmpty()) {
|
||||
if (folder.path in removed || folder.name.equals("steamapps", ignoreCase = true)) continue
|
||||
if (folder.name == "Games" && StoreInstallRoot.roots(context).any { it.absolutePath == folder.absolutePath }) continue
|
||||
out.add(canonical(folder))
|
||||
}
|
||||
}
|
||||
AddedExes.list(context).filter { it.folder !in removed }.forEach { out.add(canonical(File(it.folder))) }
|
||||
StoreInstallRoot.gameFolders(context).forEach { out.add(canonical(it)) }
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* The folders under a library's steamapps/common that a manifest beside it already claims.
|
||||
* Steam lists those itself, so a shortcut would only add a non-Steam copy of the game. Steam
|
||||
@@ -385,16 +145,13 @@ object AddedGames {
|
||||
|
||||
private val INSTALL_DIR = Regex("\"installdir\"\\s*\"([^\"]*)\"", RegexOption.IGNORE_CASE)
|
||||
|
||||
/** [single]: the entry a game added from the Games tab has; its exe stands in when no other is chosen and the user picked it. */
|
||||
private fun scanGame(context: Context, folder: File, out: MutableList<Game>, single: AddedExes.Entry? = null) {
|
||||
private fun scanGame(context: Context, folder: File, out: MutableList<Game>) {
|
||||
run {
|
||||
// A store install carries a sidecar; every one of them is a shortcut.
|
||||
val sidecar = StoreGameSidecar.read(folder)
|
||||
val ranking = rank(folder)
|
||||
val candidates = ranking.exes
|
||||
val candidates = candidates(folder)
|
||||
val picked = SessionPrefs.addedGameExe(context, folder.path)
|
||||
val chosen = picked.takeIf { it.isNotEmpty() }?.let { File(it) }?.takeIf { it.isFile }
|
||||
?: single?.takeIf { it.picked }?.let { File(it.exe) }?.takeIf { it.isFile }
|
||||
// A store install says which exe it is; the folder's guess only when that file is gone.
|
||||
val found = chosen ?: sidecar?.exeFile(folder)?.takeIf { it.isFile } ?: candidates.firstOrNull() ?: return
|
||||
// The store's launcher .bat stands in front of the exe when the game needs arguments or
|
||||
@@ -402,32 +159,23 @@ object AddedGames {
|
||||
val launch = if (chosen == null) sidecar?.launcherFile(folder)?.takeIf { it.isFile } ?: found else found
|
||||
val foundGuest = guestPath(context, launch)
|
||||
if (foundGuest == null) { Log.w(TAG, "${folder.name}: the session cannot see ${launch.path}"); return }
|
||||
// A store install's title; else the title the disk gives (the shortcut's appid is kept
|
||||
// per folder from its first scan, so a name found later does not move it); else the folder.
|
||||
val autoName = sidecar?.title ?: identifiedName(folder, found) ?: folder.name
|
||||
val name = sidecar?.title ?: folder.name
|
||||
// Keyed by the pre-rename path so shortcut ids, and the prefixes and saves under them, stay put.
|
||||
val crc = CRC32().apply { update(("\"${foundGuest.replaceFirst(Regex("^$LIBRARY/"), "$LEGACY_LIBRARY/")}\"" + autoName).toByteArray()) }.value
|
||||
val crc = CRC32().apply { update(("\"${foundGuest.replaceFirst(Regex("^$LIBRARY/"), "$LEGACY_LIBRARY/")}\"" + name).toByteArray()) }.value
|
||||
val appId = SessionPrefs.addedGameAppId(context, folder.path, crc or 0x80000000L)
|
||||
val config = accountConfig(File(LinuxRuntime.rootDir(context), "root/.local/share/Steam"))
|
||||
val record = config?.let { shortcutRecord(it, appId) }
|
||||
val rev = record?.optInt("rev", 0) ?: 0
|
||||
// Name, Start in and launch options edited in Steam's own Properties become the app's.
|
||||
if (sidecar == null && record != null) adoptSteamEdits(context, folder, record)
|
||||
val name = SessionPrefs.addedGameName(context, folder.path).takeIf { sidecar == null && it.isNotEmpty() } ?: autoName
|
||||
val adopted = guestPath(context, folder)
|
||||
?.let { steamTarget(folder, it, record, foundGuest, SessionPrefs.addedGameExeSeen(context, folder.path)) }
|
||||
?.takeIf { SessionPrefs.adoptAddedGameExe(context, folder.path, picked, it.path, rev) }
|
||||
val exe = adopted ?: found
|
||||
val guestExe = if (adopted != null) guestPath(context, adopted) ?: return else foundGuest
|
||||
val guestDir = SessionPrefs.addedGameStartIn(context, folder.path).takeIf { sidecar == null && it.isNotEmpty() }
|
||||
?: guestPath(context, (if (adopted != null) adopted else launch).parentFile ?: folder) ?: return
|
||||
val guestDir = guestPath(context, (if (adopted != null) adopted else launch).parentFile ?: folder) ?: return
|
||||
val steamId = config?.let { steamRoute(it, appId) }
|
||||
out.add(Game(folder, name, exe, guestExe, guestDir, appId,
|
||||
steamId?.toLong() ?: ((appId shl 32) or 0x02000000L), candidates, steamId,
|
||||
source = sidecar?.store?.id ?: Library.ADDED, storeId = sidecar?.id,
|
||||
exeUncertain = sidecar == null && chosen == null && adopted == null && ranking.uncertain,
|
||||
launchOptions = if (sidecar == null) SessionPrefs.addedGameLaunch(context, folder.path) else "",
|
||||
single = single != null))
|
||||
source = sidecar?.store?.id ?: Library.ADDED, storeId = sidecar?.id))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -460,35 +208,6 @@ object AddedGames {
|
||||
|
||||
private fun unquote(value: String?): String = value.orEmpty().trim().trim('"')
|
||||
|
||||
/**
|
||||
* What the user changed in Steam's Properties since the app last wrote the shortcut ([record]:
|
||||
* what the app asked for, and what Steam had): a new name, launch options, or a Start in other
|
||||
* than the exe's folder, taken over as the app's own. Each Steam value is taken once, so an app
|
||||
* edit made after it is not undone before the shortcut is written again.
|
||||
*/
|
||||
internal fun adoptSteamEdits(context: Context, folder: File, record: JSONObject) {
|
||||
val app = record.optJSONObject("app") ?: return
|
||||
val steam = record.optJSONObject("steam") ?: return
|
||||
fun adopt(field: String, value: String, apply: (String) -> Unit) {
|
||||
if (SessionPrefs.addedGameAdopted(context, folder.path, field) == value) return
|
||||
SessionPrefs.setAddedGameAdopted(context, folder.path, field, value)
|
||||
apply(value)
|
||||
}
|
||||
if (steam.has("AppName") && app.has("AppName")) {
|
||||
val s = steam.optString("AppName"); val a = app.optString("AppName")
|
||||
if (s.isNotEmpty() && s != a) adopt("AppName", s) { SessionPrefs.setAddedGameName(context, folder.path, it) }
|
||||
}
|
||||
if (steam.has("LaunchOptions") && app.has("LaunchOptions")) {
|
||||
val s = steam.optString("LaunchOptions"); val a = app.optString("LaunchOptions")
|
||||
if (s != a) adopt("LaunchOptions", s) { SessionPrefs.setAddedGameLaunch(context, folder.path, it) }
|
||||
}
|
||||
if (steam.has("StartDir") && app.has("StartDir")) {
|
||||
val s = unquote(steam.optString("StartDir")); val a = unquote(app.optString("StartDir"))
|
||||
val exeDir = unquote(steam.optString("Exe")).substringBeforeLast('/', "")
|
||||
if (s.isNotEmpty() && s != a) adopt("StartDir", s) { SessionPrefs.setAddedGameStartIn(context, folder.path, if (it == exeDir) "" else it) }
|
||||
}
|
||||
}
|
||||
|
||||
/** The list the session hands the runtime's shortcuts writer; one file per session start. */
|
||||
fun writeListing(context: Context, games: List<Game>): File {
|
||||
val file = File(context.filesDir, "session/added-games.json").apply { parentFile?.mkdirs() }
|
||||
@@ -499,7 +218,6 @@ object AddedGames {
|
||||
.append(",\"folder\":").append(quote(guestPath(context, g.folder) ?: g.guestDir))
|
||||
.append(",\"dir\":").append(quote(g.guestDir)).append(",\"appid\":").append(g.appId)
|
||||
.append(",\"seen\":").append(SessionPrefs.addedGameExeSeen(context, g.folder.path))
|
||||
if (g.launchOptions.isNotEmpty()) json.append(",\"launch\":").append(quote(g.launchOptions))
|
||||
// The art, as the session sees it: the app's cache is bound at its own path, a file in
|
||||
// the game's folder at the folder's guest path.
|
||||
val art = AddedGameArt.resolve(context, g)
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* The slow part of an added-game change, off the caller's path and in order: the running client
|
||||
* told ([later]'s work), then the listing the shortcuts writer reads at the next session start and
|
||||
* the library cache, both from one scan ([republish]). An add, an edit or a removal shows at once;
|
||||
* this follows.
|
||||
*/
|
||||
object AddedGamesPublisher {
|
||||
private const val TAG = "AddedGamesPublisher"
|
||||
|
||||
/** Tests publish at once, to read the listing right after a change. */
|
||||
@VisibleForTesting
|
||||
@Volatile
|
||||
internal var now = false
|
||||
|
||||
private val worker = Executors.newSingleThreadExecutor { r -> Thread(r, "added-games-publish") }
|
||||
|
||||
/** [live] (a DevTools call or nothing), then [republish], on the worker. */
|
||||
fun later(context: Context, live: () -> Unit = {}) {
|
||||
val app = context.applicationContext
|
||||
val work = Runnable {
|
||||
try { live() } catch (e: Exception) { Log.w(TAG, "live: ${e.message}") }
|
||||
try { republish(app) } catch (e: Exception) { Log.w(TAG, "listing: ${e.message}") }
|
||||
}
|
||||
if (now) work.run() else worker.execute(work)
|
||||
}
|
||||
|
||||
/** The listing and the library cache from one scan of the added games. Blocking. */
|
||||
fun republish(context: Context) {
|
||||
val games = AddedGames.scan(context)
|
||||
AddedGames.writeListing(context, games)
|
||||
LibraryCache.save(context, Library.launchableGames(context, added = games))
|
||||
}
|
||||
}
|
||||
@@ -1,419 +0,0 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import android.util.AtomicFile
|
||||
import android.util.Log
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import com.droiddeck.launcher.session.SessionState
|
||||
import com.droiddeck.launcher.session.WinComponents
|
||||
import com.droiddeck.launcher.stores.StoreGameSidecar
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.util.concurrent.PriorityBlockingQueue
|
||||
|
||||
/**
|
||||
* Turns on, per game, the Windows components its source's list names ([WinCompSources]). Only the
|
||||
* findings marked auto are turned on, only the ones that install here, and never one the user
|
||||
* switched off: the user's switches are kept apart ([WinComponents.Selection.user]) and win.
|
||||
*
|
||||
* All the work is done by one background thread (low priority, so are the runtime helpers it
|
||||
* starts), one job at a time from one queue: working out a game's list (kept, so a page opens on
|
||||
* it) and downloading a component (each once, whichever games want it). The queue waits while a
|
||||
* session runs; jobs nobody asked for (the start-up sweep, a game being launched) also wait on
|
||||
* battery saver, a warm device or a metered network. Nothing here runs on the caller's thread.
|
||||
*/
|
||||
object AutoComponents {
|
||||
private const val TAG = "AutoComponents"
|
||||
|
||||
/** The key a game's picks are kept under: the appid its prefix is named after. */
|
||||
fun appKey(game: Library.SteamGame): String? =
|
||||
if (game.library == Library.ADDED) Integer.toUnsignedString(game.appId) else game.appId.takeIf { it > 0 }?.toString()
|
||||
|
||||
/**
|
||||
* What turns on by itself for [findings], given [catalog] and what is [installed]: the catalog
|
||||
* name each auto finding installs as, with its reason. A component that needs a Windows
|
||||
* installer, or is not in the catalog, stays out (the editor lists it as a suggestion).
|
||||
*/
|
||||
fun autoPicks(findings: List<WinCompSources.Finding>, catalog: Map<String, WinComponents.Component>): Map<String, WinComponents.AutoReason> {
|
||||
val out = LinkedHashMap<String, WinComponents.AutoReason>()
|
||||
for (f in findings) {
|
||||
if (!f.auto || f.component == null) continue
|
||||
val id = WinComponents.installable(f.component, catalog)
|
||||
val c = catalog[id] ?: continue
|
||||
if (WinComponents.support(c, catalog) != WinComponents.Support.READY) continue
|
||||
if (id !in out) out[id] = WinComponents.AutoReason(f.origin.name, listOf(f.original) + f.args)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- seams for tests ----
|
||||
@VisibleForTesting internal var catalogOf: (Context, Boolean) -> List<WinComponents.Component>? =
|
||||
{ c, network -> if (network) WinComponents.fetch(c) else WinComponents.cached(c) }
|
||||
@VisibleForTesting internal var installedOf: (Context) -> Set<String> = { WinComponents.installedIds(it).toSet() }
|
||||
@VisibleForTesting internal var installer: (Context, WinComponents.Component, Map<String, WinComponents.Component>, (WinComponents.Progress) -> Unit) -> String? =
|
||||
{ c, comp, all, progress -> WinComponents.install(c, comp, all, progress) }
|
||||
@VisibleForTesting internal var sessionRunning: () -> Boolean = { SessionState.running }
|
||||
/** Battery saver, a warm device or a metered network: no work nobody asked for. */
|
||||
@VisibleForTesting internal var unfavourable: (Context) -> Boolean = { c -> constrained(c) }
|
||||
/** A usable network: a download waits for one. */
|
||||
@VisibleForTesting internal var online: (Context) -> Boolean = { c -> isOnline(c) }
|
||||
/** Tests run the queue on the caller's thread ([drain]) instead of the worker. */
|
||||
@VisibleForTesting @Volatile internal var runNow = false
|
||||
|
||||
/** Bumped whenever a game's list or automatic components change, so an open page reads them again. */
|
||||
val changes = MutableStateFlow(0)
|
||||
|
||||
/** The components downloading for an automatic pick right now, with their progress. */
|
||||
val downloading = MutableStateFlow<Map<String, WinComponents.Progress>>(emptyMap())
|
||||
|
||||
/** Games (by [appKey]) whose list is queued or being worked out: a page waits on it. */
|
||||
val pendingGames = MutableStateFlow<Set<String>>(emptySet())
|
||||
|
||||
/** Components queued for an automatic pick, not yet downloaded. */
|
||||
val pendingComponents = MutableStateFlow<Set<String>>(emptySet())
|
||||
|
||||
private fun mark(job: Job, on: Boolean) {
|
||||
if (job.pics != null) return
|
||||
val flow = if (job.component != null) pendingComponents else pendingGames
|
||||
val id = job.component ?: job.game?.let { appKey(it) } ?: return
|
||||
flow.value = if (on) flow.value + id else flow.value - id
|
||||
}
|
||||
|
||||
/** One piece of work: a game's list ([game]) or one component's download ([component]). [asked]: the user is waiting on it. */
|
||||
internal class Job(
|
||||
val game: Library.SteamGame?, val component: String?, val asked: Boolean, val seq: Long, val pics: List<Int>? = null,
|
||||
/** For a game Steam installed during the running session: may run in it, unless a game is running. */
|
||||
val inSession: Boolean = false,
|
||||
) {
|
||||
val key get() = when {
|
||||
pics != null -> "pics"
|
||||
component != null -> "c:$component"
|
||||
else -> "g:" + (game?.let { appKey(it) } ?: "")
|
||||
}
|
||||
/** Needs the network: a download, or Steam asked anonymously. */
|
||||
val online get() = component != null || pics != null
|
||||
}
|
||||
|
||||
private val seq = java.util.concurrent.atomic.AtomicLong()
|
||||
/** What the user asked for first, then in the order queued. */
|
||||
private val jobs = PriorityBlockingQueue<Job>(16, compareByDescending<Job> { it.asked }.thenBy { it.seq })
|
||||
@Volatile private var draining = false
|
||||
private val queuedKeys = java.util.Collections.synchronizedSet(HashSet<String>())
|
||||
/** The games waiting on a component's download, to turn it on for when it lands. */
|
||||
private val waiting = HashMap<String, MutableList<Library.SteamGame>>()
|
||||
@Volatile private var worker: Thread? = null
|
||||
private const val PAUSE_MS = 15_000L
|
||||
|
||||
/**
|
||||
* [game]'s list worked out again on the worker, and its components queued: [asked] when the
|
||||
* user is waiting (its page is open, or they installed or added it).
|
||||
*/
|
||||
fun queueGame(context: Context, game: Library.SteamGame, asked: Boolean) {
|
||||
if (appKey(game) == null) return
|
||||
enqueue(context, Job(game, null, asked, seq.incrementAndGet()))
|
||||
}
|
||||
|
||||
private fun queueDownload(context: Context, id: String, asked: Boolean, inSession: Boolean = false) =
|
||||
enqueue(context, Job(null, id, asked, seq.incrementAndGet(), inSession = inSession))
|
||||
|
||||
/** Adds [job] unless the same one is queued (a game or a component, once each). */
|
||||
private fun enqueue(context: Context, job: Job) {
|
||||
if (!queuedKeys.add(job.key)) {
|
||||
// Asked for now: ahead of what nobody asked for, so the open page does not wait behind the sweep.
|
||||
if (job.asked && jobs.removeIf { it.key == job.key && !it.asked }) jobs.add(job)
|
||||
if (runNow && !draining) drain(context)
|
||||
return
|
||||
}
|
||||
if (job.component != null) Log.i(TAG, "wincomp: queued ${job.component}")
|
||||
mark(job, true)
|
||||
jobs.add(job)
|
||||
if (runNow) { if (!draining) drain(context) } else ensureWorker(context.applicationContext)
|
||||
}
|
||||
|
||||
/** Whether [job] has to wait: always while a session runs; one nobody asked for also when it is unfavourable. */
|
||||
internal fun mustWait(context: Context, job: Job): Boolean =
|
||||
if (job.inSession && sessionRunning()) gameRunning() || (job.online && !online(context))
|
||||
else sessionRunning() || (job.online && !online(context)) || (!job.asked && unfavourable(context))
|
||||
|
||||
/** A game is running in the session (Steam's reaper, or Proton waiting on its game). */
|
||||
@VisibleForTesting internal var gameRunning: () -> Boolean = { gameProcessRunning() }
|
||||
|
||||
private fun gameProcessRunning(): Boolean = File("/proc").listFiles()?.any { p ->
|
||||
p.name.all(Char::isDigit) && runCatching { String(File(p, "cmdline").readBytes()).replace('\u0000', ' ') }.getOrNull()
|
||||
?.let { it.contains("SteamLaunch AppId=") || it.contains("proton waitforexitandrun") } == true
|
||||
} == true
|
||||
|
||||
/**
|
||||
* A game Steam installed during the running session: its list worked out and its components
|
||||
* readied in the session (one job, low priority), so a launch right after finds them; it
|
||||
* waits while a game runs. All other work stays paused until the session ends.
|
||||
*/
|
||||
fun queueInSession(context: Context, game: Library.SteamGame) {
|
||||
if (appKey(game) == null) return
|
||||
enqueue(context, Job(game, null, true, seq.incrementAndGet(), inSession = true))
|
||||
}
|
||||
|
||||
/** Runs the queued jobs on this thread until the queue is empty or the next one has to wait. Tests. */
|
||||
@VisibleForTesting
|
||||
internal fun drain(context: Context) {
|
||||
draining = true
|
||||
try {
|
||||
while (true) {
|
||||
val job = next(context) ?: return
|
||||
queuedKeys.remove(job.key)
|
||||
run(context, job)
|
||||
}
|
||||
} finally {
|
||||
draining = false
|
||||
}
|
||||
}
|
||||
|
||||
/** The first job in order that may run now, taken off the queue; null when none may. */
|
||||
private fun next(context: Context): Job? = synchronized(jobs) {
|
||||
val job = jobs.sortedWith(order).firstOrNull { !mustWait(context, it) } ?: return null
|
||||
jobs.remove(job)
|
||||
job
|
||||
}
|
||||
|
||||
private val order = compareByDescending<Job> { it.asked }.thenBy { it.seq }
|
||||
|
||||
@VisibleForTesting internal fun queued(): List<String> = jobs.sortedWith(compareByDescending<Job> { it.asked }.thenBy { it.seq }).map { it.key }
|
||||
|
||||
@Synchronized
|
||||
private fun ensureWorker(app: Context) {
|
||||
if (worker?.isAlive == true) return
|
||||
worker = Thread({
|
||||
android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_BACKGROUND)
|
||||
while (true) {
|
||||
// Something queued, then the first that may run (one for a game installed in the
|
||||
// session goes ahead of paused work); else a look again in a while.
|
||||
try { jobs.put(jobs.take()) } catch (e: InterruptedException) { return@Thread }
|
||||
val job = next(app)
|
||||
if (job == null) {
|
||||
try { Thread.sleep(PAUSE_MS) } catch (e: InterruptedException) { return@Thread }
|
||||
continue
|
||||
}
|
||||
queuedKeys.remove(job.key)
|
||||
WinComponents.background { run(app, job) }
|
||||
}
|
||||
}, "auto-components").apply { isDaemon = true; start() }
|
||||
}
|
||||
|
||||
private fun run(context: Context, job: Job) {
|
||||
try {
|
||||
when {
|
||||
job.pics != null -> SteamAppInfo.lookup(context, job.pics)
|
||||
job.component != null -> download(context, job.component, job.asked, job.inSession)
|
||||
else -> job.game?.let { apply(context, it, job.asked, job.inSession) }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "wincomp: ${job.key}: ${e.javaClass.simpleName}: ${e.message}")
|
||||
} finally {
|
||||
mark(job, false)
|
||||
changes.value = changes.value + 1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [game]'s list (worked out again only when what it is read from changed), its automatic
|
||||
* components that are downloaded turned on, and the rest queued for download.
|
||||
*/
|
||||
private fun apply(context: Context, game: Library.SteamGame, asked: Boolean, inSession: Boolean = false) {
|
||||
val key = appKey(game) ?: return
|
||||
val folder = game.gameFiles
|
||||
if (game.library == Library.ADDED && game.source == Library.ADDED && folder != null && SteamMatch.get(context, folder.path) == null) {
|
||||
AddedGames.single(context, folder.path)?.let { SteamMatch.resolve(context, folder.path, it.exe, it.name) }
|
||||
}
|
||||
// A GOG or Epic game installed before its sidecar kept the store's list: read it once from the store.
|
||||
val store = com.droiddeck.launcher.stores.Store.byId(game.source)
|
||||
if (folder != null && (store == com.droiddeck.launcher.stores.Store.GOG || store == com.droiddeck.launcher.stores.Store.EPIC)) StoreListBackfill.fill(context, folder)
|
||||
// Its Steam app, when the client never saw it: Steam asked anonymously now if the user is
|
||||
// waiting on this game (a pass over every game asks for all of them in one go, [sweep]).
|
||||
if (asked && online(context)) steamAppOf(context, game)?.let { SteamAppInfo.lookup(context, listOf(it)) }
|
||||
val findings = findings(context, game)
|
||||
val catalog = (catalogOf(context, false) ?: catalogOf(context, true))?.associateBy { it.name } ?: return
|
||||
val wanted = autoPicks(findings, catalog)
|
||||
val user = WinComponents.selection(context, key).user
|
||||
val installed = installedOf(context)
|
||||
val kept = LinkedHashMap<String, WinComponents.AutoReason>()
|
||||
val missing = ArrayList<String>()
|
||||
for ((id, reason) in wanted) {
|
||||
if (user[id] == false) { kept[id] = reason; continue }
|
||||
if (id in installed) { kept[id] = reason; continue }
|
||||
synchronized(waiting) { waiting.getOrPut(id) { ArrayList() }.let { if (it.none { g -> appKey(g) == key }) it.add(game) } }
|
||||
queueDownload(context, id, asked, inSession)
|
||||
missing.add(id)
|
||||
}
|
||||
// In a session the launch waits a little for these (droiddeck-wincomponents reads the list).
|
||||
if (inSession) Pending.set(context, key, missing)
|
||||
val before = WinComponents.selection(context, key).auto
|
||||
if (before.keys != kept.keys) {
|
||||
WinComponents.setAuto(context, key, kept)
|
||||
Log.i(TAG, "${game.name}: on by itself ${kept.keys.joinToString().ifEmpty { "(none)" }}")
|
||||
}
|
||||
changes.value = changes.value + 1
|
||||
}
|
||||
|
||||
/** One component downloaded and installed, then turned on for every game waiting on it. */
|
||||
private fun download(context: Context, id: String, asked: Boolean, inSession: Boolean = false) {
|
||||
val catalog = catalogOf(context, true)?.associateBy { it.name } ?: run { Pending.drop(context, id); return }
|
||||
if (id !in installedOf(context)) {
|
||||
val c = catalog[id] ?: run { Pending.drop(context, id); return }
|
||||
val started = System.currentTimeMillis()
|
||||
downloading.value = downloading.value + (id to WinComponents.Progress(id, "", WinComponents.Phase.DOWNLOAD, -1))
|
||||
val problem = try {
|
||||
installer(context, c, catalog) { p -> downloading.value = downloading.value + (id to p) }
|
||||
} finally {
|
||||
downloading.value = downloading.value - id
|
||||
}
|
||||
if (problem != null) { Log.w(TAG, "wincomp: $id did not install: $problem"); Pending.drop(context, id); return }
|
||||
Log.i(TAG, "wincomp: $id installed in ${System.currentTimeMillis() - started}ms")
|
||||
}
|
||||
val games = synchronized(waiting) { waiting.remove(id).orEmpty() }
|
||||
games.forEach { apply(context, it, asked, inSession) }
|
||||
Pending.drop(context, id)
|
||||
}
|
||||
|
||||
/**
|
||||
* The components still being readied for games installed in the session, where the launch
|
||||
* reads them (~/.config/droiddeck/wincomponents-pending.json in the runtime).
|
||||
*/
|
||||
internal object Pending {
|
||||
private fun file(context: Context) = File(com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(context), "root/.config/droiddeck/wincomponents-pending.json")
|
||||
|
||||
@Synchronized fun read(context: Context): JSONObject =
|
||||
runCatching { JSONObject(file(context).readText()) }.getOrDefault(JSONObject()).let { it.optJSONObject("games") ?: JSONObject() }
|
||||
|
||||
@Synchronized fun set(context: Context, key: String, ids: List<String>) {
|
||||
val games = read(context)
|
||||
if (ids.isEmpty()) games.remove(key) else games.put(key, JSONArray(ids))
|
||||
write(context, games)
|
||||
}
|
||||
|
||||
@Synchronized fun drop(context: Context, id: String) {
|
||||
val games = read(context)
|
||||
for (k in games.keys().asSequence().toList()) {
|
||||
val left = games.optJSONArray(k)?.let { a -> (0 until a.length()).map { a.optString(it) } }.orEmpty() - id
|
||||
if (left.isEmpty()) games.remove(k) else games.put(k, JSONArray(left))
|
||||
}
|
||||
write(context, games)
|
||||
}
|
||||
|
||||
private fun write(context: Context, games: JSONObject) {
|
||||
val f = file(context)
|
||||
runCatching {
|
||||
f.parentFile?.mkdirs()
|
||||
val tmp = File(f.path + ".tmp")
|
||||
tmp.writeText(JSONObject().put("games", games).toString())
|
||||
if (!tmp.renameTo(f)) { f.delete(); tmp.renameTo(f) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the list, kept per game ----
|
||||
|
||||
private const val FINDINGS = "wincomp-findings.json"
|
||||
|
||||
/** What the list is read from, by modification time: a change means it is worked out again. */
|
||||
internal fun signature(context: Context, game: Library.SteamGame): String {
|
||||
val folder = game.gameFiles
|
||||
val parts = listOfNotNull(
|
||||
folder?.lastModified(),
|
||||
folder?.let { SteamRedists.manifest(it, game.appId)?.lastModified() },
|
||||
folder?.let { StoreGameSidecar.file(it).lastModified() },
|
||||
folder?.let { File(it, "fuel.json").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) },
|
||||
)
|
||||
return parts.joinToString("|")
|
||||
}
|
||||
|
||||
/** [game]'s list: the one kept when nothing it is read from changed, else worked out again and kept. Worker only. */
|
||||
internal fun findings(context: Context, game: Library.SteamGame): List<WinCompSources.Finding> {
|
||||
val key = appKey(game) ?: return emptyList()
|
||||
val sig = signature(context, game)
|
||||
val all = readFindings(context)
|
||||
all.optJSONObject(key)?.takeIf { it.optString("sig") == sig }?.let { return decode(it.optJSONArray("findings")) }
|
||||
val fresh = WinCompSources.forGame(context, game)
|
||||
writeFindings(context, all.put(key, JSONObject().put("sig", sig).put("findings", encode(fresh))))
|
||||
return fresh
|
||||
}
|
||||
|
||||
/** The list kept for [appKey], without working anything out; null before the first time. */
|
||||
fun cachedFindings(context: Context, appKey: String): List<WinCompSources.Finding>? =
|
||||
readFindings(context).optJSONObject(appKey)?.let { decode(it.optJSONArray("findings")) }
|
||||
|
||||
@Synchronized
|
||||
private fun readFindings(context: Context): JSONObject =
|
||||
runCatching { JSONObject(AtomicFile(File(context.filesDir, FINDINGS)).readFully().toString(Charsets.UTF_8)) }.getOrDefault(JSONObject())
|
||||
|
||||
@Synchronized
|
||||
private fun writeFindings(context: Context, json: JSONObject) {
|
||||
val atomic = AtomicFile(File(context.filesDir, FINDINGS))
|
||||
val out = atomic.startWrite()
|
||||
try { out.write(json.toString().toByteArray()); atomic.finishWrite(out) } catch (e: Exception) { atomic.failWrite(out) }
|
||||
}
|
||||
|
||||
private fun encode(list: List<WinCompSources.Finding>) = JSONArray().apply {
|
||||
list.forEach { f ->
|
||||
put(JSONObject().put("original", f.original).put("origin", f.origin.name).put("auto", f.auto).put("args", JSONArray(f.args))
|
||||
.apply { f.component?.let { put("component", it) } })
|
||||
}
|
||||
}
|
||||
|
||||
private fun decode(arr: JSONArray?): List<WinCompSources.Finding> = if (arr == null) emptyList() else (0 until arr.length()).mapNotNull { i ->
|
||||
val o = arr.optJSONObject(i) ?: return@mapNotNull null
|
||||
val origin = runCatching { WinCompSources.Origin.valueOf(o.optString("origin")) }.getOrNull() ?: return@mapNotNull null
|
||||
val args = o.optJSONArray("args")?.let { a -> (0 until a.length()).map { a.optString(it) } }.orEmpty()
|
||||
WinCompSources.Finding(o.optString("component").takeIf { it.isNotEmpty() }, o.optString("original"), origin, o.optBoolean("auto"), args)
|
||||
}
|
||||
|
||||
// ---- when ----
|
||||
|
||||
/** Before a launch from DroidDeck: nothing but a queued look (it waits for the session to end). */
|
||||
fun beforeLaunch(context: Context, game: Library.SteamGame) = queueGame(context, game, asked = false)
|
||||
|
||||
/** After an install or an add: the user did it, so it does not wait on a metered network. */
|
||||
fun refreshLater(context: Context, game: Library.SteamGame) = queueGame(context, game, asked = true)
|
||||
|
||||
/**
|
||||
* Every game, whatever its source, at app start and after each session (Steam installs its
|
||||
* games without a word to DroidDeck; older GOG and Epic installs get their list read once):
|
||||
* only queued, one game at a time, behind anything asked for, under the same conditions. A
|
||||
* game whose files did not change costs a look at their times.
|
||||
*/
|
||||
fun sweep(context: Context, games: List<Library.SteamGame>) {
|
||||
// First every Steam app these games are that the client never saw, asked of Steam in one
|
||||
// connection; then each game's list.
|
||||
val apps = games.mapNotNull { steamAppOf(context, it) }.distinct()
|
||||
if (apps.isNotEmpty() && SteamAppInfo.needLookup(context, apps).isNotEmpty()) enqueue(context, Job(null, null, false, seq.incrementAndGet(), pics = apps))
|
||||
games.forEach { queueGame(context, it, asked = false) }
|
||||
}
|
||||
|
||||
/** The Steam app whose list a game takes: a Steam game's own, or an added game's match. */
|
||||
private fun steamAppOf(context: Context, game: Library.SteamGame): Int? =
|
||||
if (game.library != Library.ADDED) game.appId.takeIf { it > 0 }
|
||||
else game.gameFiles?.let { SteamMatch.get(context, it.path) }?.appId
|
||||
|
||||
/** The default network is there and validated (reaches the internet). */
|
||||
fun isOnline(context: Context): Boolean {
|
||||
val cm = context.getSystemService(ConnectivityManager::class.java) ?: return false
|
||||
val caps = cm.getNetworkCapabilities(cm.activeNetwork ?: return false) ?: return false
|
||||
return caps.hasCapability(android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
|
||||
caps.hasCapability(android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
||||
}
|
||||
|
||||
/** Battery saver, thermal status moderate or worse, or a metered network. */
|
||||
private fun constrained(context: Context): Boolean {
|
||||
val power = context.getSystemService(PowerManager::class.java)
|
||||
if (power?.isPowerSaveMode == true) return true
|
||||
if (Build.VERSION.SDK_INT >= 29 && (power?.currentThermalStatus ?: 0) >= PowerManager.THERMAL_STATUS_MODERATE) return true
|
||||
return context.getSystemService(ConnectivityManager::class.java)?.isActiveNetworkMetered == true
|
||||
}
|
||||
}
|
||||
@@ -134,9 +134,6 @@ object DependencyDetector {
|
||||
val lower = f.name.lowercase(Locale.US)
|
||||
if (f.isDirectory) {
|
||||
if (SKIP_DIR_RE.matches(lower)) continue
|
||||
// Unity's <Game>_Data/Managed holds the game's own Mono assemblies, not a .NET
|
||||
// Framework to install.
|
||||
if (lower == "managed" && dir.name.endsWith("_Data", ignoreCase = true)) continue
|
||||
val isRedistDir = insideRedist || REDIST_DIR_RE.matches(lower)
|
||||
if (isRedistDir) consider(f, isDir = true, insideRedist = true)
|
||||
// Aggressive coverage: descend redist containers fully, and otherwise sweep the game
|
||||
@@ -160,19 +157,6 @@ object DependencyDetector {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The catalog components one installer stands for, by its path ("_CommonRedist/vcredist/2015/
|
||||
* vc_redist.x64.exe", "DXSETUP.exe"): what a store's install list names. A VC++ installer
|
||||
* with no year is the latest; empty for one that is none of ours.
|
||||
*/
|
||||
fun componentsFor(path: String): List<String> {
|
||||
val p = path.replace('\\', '/').lowercase(Locale.US)
|
||||
return classify(p, p.substringAfterLast('/'), isDir = false).map { if (it == VCREDIST_LOOSE) "vcredist2022" else it }
|
||||
}
|
||||
|
||||
/** The name the catalog component [name] goes by, for a reason line. */
|
||||
fun label(name: String): String = LABELS[name] ?: name
|
||||
|
||||
/** A loose VC++ installer resolves to the latest 14.x only if no specific vcredistYYYY was found. */
|
||||
private fun resolveLooseVcredist(hits: LinkedHashMap<String, Recommendation>) {
|
||||
val loose = hits.remove(VCREDIST_LOOSE) ?: return
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Which `.exe` in a game folder is the game. A real install holds installers, crash handlers,
|
||||
* anti-cheat stubs and launchers beside the game, and the game itself may sit several levels down
|
||||
* (`Game/Binaries/Win64/Game-Win64-Shipping.exe`), so candidates are collected with a bounded walk
|
||||
* and ranked rather than guessed by position.
|
||||
*/
|
||||
object GameExePicker {
|
||||
/** How far below a game folder to look: Unreal puts the game three down, repacks deeper. */
|
||||
const val MAX_DEPTH = 5
|
||||
|
||||
/** Folders and .exe files looked at per game before the walk stops, so a huge game stays quick. */
|
||||
const val WALK_BUDGET = 400
|
||||
|
||||
/** Below this the pick is marked uncertain rather than trusted silently. */
|
||||
const val CONFIDENT_SCORE = 60
|
||||
|
||||
/** The best and the runner-up this close together: nothing clearly won. */
|
||||
const val AMBIGUOUS_MARGIN = 15
|
||||
|
||||
/** Folders that never hold the game itself. */
|
||||
private val SKIP_DIRS = setOf(
|
||||
"_commonredist", "commonredist", "redist", "redistributable", "redistributables",
|
||||
"directx", "dotnet", "dotnetfx", "vcredist", "vc_redist", "prerequisites", "prereq",
|
||||
"installer", "installers", "__installer", "support", "extras", "docs", "documentation", "manual",
|
||||
"soundtrack", "ost", "artbook", "bonus", "dlc", "mods", "saves", "savegames",
|
||||
"crashreportclient", "easyanticheat", "battleye", "punkbuster", "steamworks shared", ".git",
|
||||
)
|
||||
private val SKIP_DIR_PREFIX = listOf("dotnet", "vcredist", "$")
|
||||
private val SKIP_PATHS = listOf("engine/extras", "engine/binaries/thirdparty")
|
||||
|
||||
/** Exe names that are never the game. */
|
||||
private val JUNK_EXE_RE = Regex(
|
||||
"""(?i)^(unins\w*|setup|install\w*|vc_?redist.*|vcredist.*|dxsetup|dxwebsetup|directx.*|""" +
|
||||
"""oalinst|openal.*|dotnetfx.*|ndp\d.*|unitycrashhandler\d*|crashreport\w*|crashpad\w*|""" +
|
||||
"""crashsender\w*|easyanticheat\w*|eac\w*|battleye\w*|beservice\w*|punkbuster\w*|""" +
|
||||
"""steamerrorreporter\d*|gameoverlayui|touchup|cleanup|config|settings|benchmark|""" +
|
||||
"""activation\w*|register\w*|readme|report\w*|.*_debug|.*-debug)$""",
|
||||
)
|
||||
|
||||
/** A folder's exes best first, with their scores, and whether the first clearly won. */
|
||||
class Ranking(val exes: List<File>, val scores: List<Int>, val uncertain: Boolean)
|
||||
|
||||
/** Ranks the .exe files under [folder]; [keep] drops names the caller never offers. */
|
||||
fun rank(folder: File, keep: (File) -> Boolean = { true }): Ranking {
|
||||
val scored = collect(folder).filter { keep(it.first) }.map { (exe, depth) -> exe to score(exe, folder, depth) }
|
||||
.sortedWith(compareByDescending<Pair<File, Int>> { it.second }.thenBy { it.first.path.lowercase() })
|
||||
val best = scored.firstOrNull()?.second
|
||||
val runnerUp = scored.getOrNull(1)?.second
|
||||
val uncertain = best != null && (best < CONFIDENT_SCORE || (runnerUp != null && best - runnerUp < AMBIGUOUS_MARGIN))
|
||||
return Ranking(scored.map { it.first }, scored.map { it.second }, uncertain)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every plausible exe under [folder] with its depth (0 = in the folder itself): up to
|
||||
* [MAX_DEPTH] folders down, nearest levels first, at most [WALK_BUDGET] entries, never through a
|
||||
* symlink, past folders that never hold the game.
|
||||
*/
|
||||
private fun collect(folder: File): List<Pair<File, Int>> {
|
||||
val found = ArrayList<Pair<File, Int>>()
|
||||
var visited = 0
|
||||
var level = listOf(folder)
|
||||
var depth = 0
|
||||
walk@ while (level.isNotEmpty()) {
|
||||
val next = ArrayList<File>()
|
||||
for (dir in level) {
|
||||
val entries = dir.listFiles()?.sortedBy { it.name.lowercase() } ?: continue
|
||||
for (f in entries) {
|
||||
if (visited >= WALK_BUDGET) break@walk
|
||||
if (isLink(f)) continue
|
||||
if (f.isDirectory) {
|
||||
visited++
|
||||
if (depth < MAX_DEPTH && !skipDir(f, f.relativeTo(folder).invariantSeparatorsPath)) next.add(f)
|
||||
} else if (f.isFile && f.name.endsWith(".exe", ignoreCase = true)) {
|
||||
visited++
|
||||
if (!JUNK_EXE_RE.matches(f.nameWithoutExtension)) found.add(f to depth)
|
||||
}
|
||||
}
|
||||
}
|
||||
level = next
|
||||
depth++
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
/**
|
||||
* How likely [exe] is to be the game in [folder]. Relative only: the numbers rank candidates
|
||||
* against each other and decide whether anything won clearly enough to trust.
|
||||
*/
|
||||
internal fun score(exe: File, folder: File, depth: Int): Int {
|
||||
var score = 0
|
||||
val base = exe.nameWithoutExtension
|
||||
val exeKey = key(base)
|
||||
val folderKey = key(folder.name)
|
||||
// The exe named after its folder is the single strongest signal.
|
||||
if (exeKey.isNotEmpty() && folderKey.isNotEmpty()) {
|
||||
when {
|
||||
exeKey == folderKey -> score += 100
|
||||
folderKey.contains(exeKey) || exeKey.contains(folderKey) -> score += 60
|
||||
}
|
||||
}
|
||||
// Unreal ships as <Game>-Win64-Shipping.exe under Binaries/Win64.
|
||||
if (base.endsWith("-Win64-Shipping", true) || base.endsWith("-Win32-Shipping", true)) score += 70
|
||||
val path = exe.absolutePath.lowercase()
|
||||
if (path.contains("/binaries/win64") || path.contains("/binaries/win32")) score += 30
|
||||
if (path.contains("/bin/") || path.contains("/bin64/")) score += 10
|
||||
// A launcher is a real entry point, so it stays a candidate, just not the preferred one.
|
||||
if (GameIdentifier.isLauncherExeName(base)) score -= 40
|
||||
if (GameIdentifier.isJunkPeName(base)) score -= 30
|
||||
// Shallower is more likely the entry point; big binaries beat small helpers.
|
||||
score -= depth * 8
|
||||
score += (exe.length() / (8L * 1024 * 1024)).toInt().coerceAtMost(30)
|
||||
return score
|
||||
}
|
||||
|
||||
private fun skipDir(dir: File, relative: String): Boolean {
|
||||
val name = dir.name.lowercase().trim()
|
||||
if (name in SKIP_DIRS || SKIP_DIR_PREFIX.any { name.startsWith(it) }) return true
|
||||
val rel = relative.lowercase()
|
||||
return SKIP_PATHS.any { rel == it || rel.endsWith("/$it") }
|
||||
}
|
||||
|
||||
private fun isLink(f: File): Boolean = runCatching { java.nio.file.Files.isSymbolicLink(f.toPath()) }.getOrDefault(false)
|
||||
|
||||
/** Comparison key: letters and digits only, so "GTA IV" and "gta-iv" match. */
|
||||
private fun key(s: String) = s.lowercase().filter { it.isLetterOrDigit() }
|
||||
}
|
||||
@@ -48,7 +48,7 @@ object GameFileSync {
|
||||
check(File(previousStorage).isDirectory) { context.getString(R.string.game_sync_err_previous_sd) }
|
||||
}
|
||||
storage?.let { check(File(it.path).listFiles() != null) { context.getString(R.string.game_sync_err_game_storage) } }
|
||||
AddedGames.roots(context).forEach { check(it.host.listFiles() != null) { context.getString(R.string.game_sync_err_added_games) } }
|
||||
SessionPrefs.addedGamesDirs(context).forEach { check(File(it).listFiles() != null) { context.getString(R.string.game_sync_err_added_games) } }
|
||||
GameFiles.sync(context, folder, Library.launchableGames(context, strictRead = true))
|
||||
prefs(context).edit().putString("storageSetting", storageSetting).putString("storagePath", storage?.path).apply()
|
||||
}
|
||||
|
||||
@@ -1,328 +0,0 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Best-effort identification of a game from its on-disk footprint, given its .exe: a Steam appId
|
||||
* and/or a display name, so an added game is not just named after its folder or exe. Pure
|
||||
* filesystem and PE parsing, no network, never throws; it reports only what the disk tells.
|
||||
*
|
||||
* Signal chain (appId preferred; best name chosen across all hits):
|
||||
* 1. steam_appid.txt → appId (Goldberg-style installs)
|
||||
* 2. ColdClientLoader / emu ini → appId (AppId= key)
|
||||
* 3. appmanifest_<id>.acf → appId + name (real Steam install under steamapps/)
|
||||
* 4. goggame-<id>.info → name (+ gogId) (GOG installs)
|
||||
* 5. PE VS_VERSIONINFO → name (ProductName / FileDescription)
|
||||
* 6. cleaned folder / exe name → name (weak fallback)
|
||||
*/
|
||||
object GameIdentifier {
|
||||
|
||||
/** How far up the directory tree to look for marker files (games nest deep, e.g. Game/Binaries/Win64/x.exe). */
|
||||
private const val MAX_PARENT_DEPTH = 6
|
||||
|
||||
enum class Source { STEAM_APPID_TXT, STEAM_MANIFEST_ACF, STEAM_EMU_INI, GOG_INFO, PE_VERSION, FILENAME, NONE }
|
||||
enum class Confidence { HIGH, MEDIUM, LOW }
|
||||
|
||||
/**
|
||||
* @param appId Steam appId if one was found on disk, else null.
|
||||
* @param gogId GOG product id if a goggame info file was found, else null.
|
||||
* @param name Best display name found locally; may be null when only an appId was
|
||||
* found (caller resolves the name from the appId over the network).
|
||||
* @param source Which signal produced the primary identity.
|
||||
*/
|
||||
data class GameIdentity(
|
||||
val appId: Int? = null,
|
||||
val gogId: String? = null,
|
||||
val name: String? = null,
|
||||
val source: Source = Source.NONE,
|
||||
val confidence: Confidence = Confidence.LOW,
|
||||
/** Which signal gave [name]: [Source.FILENAME] means only a cleaned folder or exe name. */
|
||||
val nameSource: Source = Source.NONE,
|
||||
) {
|
||||
val hasStrongId: Boolean get() = appId != null
|
||||
}
|
||||
|
||||
/** Identify the game owning [exeFile]. Never throws; returns an empty identity if nothing matched. */
|
||||
fun identify(exeFile: File): GameIdentity {
|
||||
val dirs = ancestorDirs(exeFile)
|
||||
|
||||
// Steam appId from a bare steam_appid.txt (in the exe dir, a steam_settings/ subdir, or an ancestor).
|
||||
val steamAppId = dirs.firstNotNullOfOrNull { readSteamAppIdTxt(it) }
|
||||
// AppId= out of a ColdClientLoader.ini / steam_emu.ini next to the game.
|
||||
val emuAppId = dirs.firstNotNullOfOrNull { readEmuIniAppId(it) }
|
||||
// Real Steam install: appmanifest_<id>.acf up under steamapps/ — carries both appId and name.
|
||||
val acf = resolveSteamManifest(exeFile, dirs)
|
||||
// GOG: goggame-<id>.info JSON with the real title.
|
||||
val gog = dirs.firstNotNullOfOrNull { readGogInfo(it) }
|
||||
// PE ProductName / FileDescription — but not when it merely names a launcher/loader
|
||||
// ("Rockstar Games Launcher Redirector", "GSE", …), which must not become the game name.
|
||||
val peName = PeVersionInfo.bestName(PeVersionInfo.read(exeFile))?.takeUnless { isJunkPeName(it) }
|
||||
|
||||
val appId = acf?.appId ?: steamAppId ?: emuAppId
|
||||
|
||||
// Best name, most-trustworthy first.
|
||||
val nameHit: Pair<String, Source>? = when {
|
||||
acf?.name != null -> acf.name to Source.STEAM_MANIFEST_ACF
|
||||
gog?.name != null -> gog.name to Source.GOG_INFO
|
||||
peName != null -> peName to Source.PE_VERSION
|
||||
else -> cleanedFallbackName(exeFile)?.let { it to Source.FILENAME }
|
||||
}
|
||||
|
||||
// Primary source = whatever gave us the appId (strongest), else the name source.
|
||||
val primary: Source = when {
|
||||
acf?.appId != null -> Source.STEAM_MANIFEST_ACF
|
||||
steamAppId != null -> Source.STEAM_APPID_TXT
|
||||
emuAppId != null -> Source.STEAM_EMU_INI
|
||||
else -> nameHit?.second ?: Source.NONE
|
||||
}
|
||||
|
||||
val confidence = when {
|
||||
appId != null -> Confidence.HIGH
|
||||
nameHit?.second == Source.GOG_INFO -> Confidence.HIGH
|
||||
nameHit?.second == Source.PE_VERSION -> Confidence.MEDIUM
|
||||
else -> Confidence.LOW
|
||||
}
|
||||
|
||||
return GameIdentity(
|
||||
appId = appId,
|
||||
gogId = gog?.id,
|
||||
name = nameHit?.first?.let(::normalizeName)?.takeIf { it.isNotBlank() },
|
||||
source = primary,
|
||||
confidence = confidence,
|
||||
nameSource = nameHit?.second ?: Source.NONE,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an identified title readable and filesystem-safe (a Steam shortcut's name also names
|
||||
* files): "DARK SOULS: REMASTERED" becomes "DARK SOULS - REMASTERED". Strips trademark marks,
|
||||
* turns title punctuation into safe equivalents, and collapses whitespace.
|
||||
*/
|
||||
fun normalizeName(raw: String): String {
|
||||
var s = raw
|
||||
s = s.replace("™", "").replace("®", "").replace("©", "")
|
||||
s = s.replace(Regex("""(?i)\(\s*(tm|r|c)\s*\)"""), "") // "(TM)" / "(R)" / "(C)"
|
||||
s = s.replace(":", " - ") // colon → dash separator (safe + readable)
|
||||
s = s.replace(Regex("""[\\/]"""), " ") // path separators → space
|
||||
s = s.replace(Regex("""["*?<>|]"""), "") // other illegal chars → drop
|
||||
// Drop trailing generic descriptors a PE FileDescription tacks on ("DiRT 3 Executable" → "DiRT 3").
|
||||
s = s.replace(Regex("""(?i)[ \-]+(executable|application|launcher|redistributable|installer|setup)$"""), "")
|
||||
s = s.replace(Regex("""\s+"""), " ").trim() // collapse whitespace
|
||||
return s.trim(' ', '-').trim() // tidy stray leading/trailing dashes
|
||||
}
|
||||
|
||||
// ── directory walk ──
|
||||
|
||||
/** The exe's directory plus its ancestors, nearest-first, bounded by [MAX_PARENT_DEPTH]. */
|
||||
private fun ancestorDirs(exeFile: File): List<File> {
|
||||
val out = ArrayList<File>(MAX_PARENT_DEPTH + 1)
|
||||
var d: File? = exeFile.absoluteFile.parentFile
|
||||
var depth = 0
|
||||
while (d != null && depth <= MAX_PARENT_DEPTH) {
|
||||
out.add(d)
|
||||
d = d.parentFile
|
||||
depth++
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ── signal readers (all best-effort; return null on anything unexpected) ──
|
||||
|
||||
/** steam_appid.txt directly in [dir] or in a steam_settings/ subdir. */
|
||||
private fun readSteamAppIdTxt(dir: File): Int? {
|
||||
for (candidate in arrayOf(File(dir, "steam_appid.txt"), File(dir, "steam_settings/steam_appid.txt"))) {
|
||||
// Strip a leading UTF-8 BOM (trim() doesn't) before reading the digits.
|
||||
val id = readSmallTextFile(candidate)?.removePrefix("\uFEFF")?.trim()
|
||||
?.takeWhile { it.isDigit() }?.toIntOrNull()
|
||||
if (id != null && id > 0) return id
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* First `AppId=<n>` in a crack/emu config .ini next to the game (ColdClientLoader, steam_emu,
|
||||
* flt, hlm, …). Known names are tried first, then any other `.ini` in the dir — only crack
|
||||
* configs carry an `AppId=` key, game settings .ini files (settings.ini, graphics.ini) don't,
|
||||
* so the broad scan is safe.
|
||||
*/
|
||||
private fun readEmuIniAppId(dir: File): Int? {
|
||||
val re = Regex("""(?im)^\s*appid\s*=\s*(\d+)""")
|
||||
val named = arrayOf(
|
||||
"ColdClientLoader.ini", "steam_emu.ini", "flt.ini", "hlm.ini",
|
||||
"valve.ini", "cream_api.ini", "SmartSteamEmu.ini", "GreenLuma.ini",
|
||||
)
|
||||
val seen = HashSet<String>()
|
||||
val candidates = ArrayList<File>()
|
||||
for (n in named) File(dir, n).let { if (it.isFile) { candidates.add(it); seen.add(n.lowercase()) } }
|
||||
(dir.listFiles { f -> f.isFile && f.name.endsWith(".ini", ignoreCase = true) && f.name.lowercase() !in seen }
|
||||
?: emptyArray()).sortedBy { it.name }.forEach { candidates.add(it) }
|
||||
for (f in candidates) {
|
||||
val text = readSmallTextFile(f) ?: continue
|
||||
val id = re.find(text)?.groupValues?.getOrNull(1)?.toIntOrNull()
|
||||
if (id != null && id > 0) return id
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private data class Acf(val appId: Int?, val name: String?)
|
||||
|
||||
/**
|
||||
* Resolve the Steam appmanifest for this game: find the `common` ancestor on the exe's
|
||||
* path, read the sibling `steamapps/appmanifest_*.acf`, and match by installdir == the
|
||||
* game folder under common. No match, no manifest: a folder in steamapps/common that no
|
||||
* manifest claims is exactly the kind added games list, and the library's one other game is
|
||||
* not its name.
|
||||
*/
|
||||
private fun resolveSteamManifest(exeFile: File, dirs: List<File>): Acf? {
|
||||
val commonIdx = dirs.indexOfFirst { it.name.equals("common", ignoreCase = true) }
|
||||
if (commonIdx < 0) return null
|
||||
val commonDir = dirs[commonIdx]
|
||||
val steamapps = commonDir.parentFile ?: return null
|
||||
val manifests = steamapps.listFiles { f ->
|
||||
f.isFile && f.name.startsWith("appmanifest_") && f.name.endsWith(".acf")
|
||||
} ?: return null
|
||||
if (manifests.isEmpty()) return null
|
||||
|
||||
// The game's install folder is the child of `common` on our descent path.
|
||||
val gameFolder = if (commonIdx > 0) dirs[commonIdx - 1].name else null
|
||||
|
||||
val match = manifests.firstOrNull { m ->
|
||||
val text = readSmallTextFile(m) ?: return@firstOrNull false
|
||||
val installdir = vdfValue(text, "installdir")
|
||||
gameFolder != null && installdir != null && installdir.equals(gameFolder, ignoreCase = true)
|
||||
} ?: return null
|
||||
|
||||
val text = readSmallTextFile(match) ?: return null
|
||||
val appId = vdfValue(text, "appid")?.toIntOrNull()?.takeIf { it > 0 }
|
||||
val name = vdfValue(text, "name")?.takeIf { it.isNotBlank() }
|
||||
return if (appId == null && name == null) null else Acf(appId, name)
|
||||
}
|
||||
|
||||
private data class Gog(val id: String?, val name: String?)
|
||||
|
||||
/** goggame-<id>.info JSON: pull `name` (and the id from the filename). */
|
||||
private fun readGogInfo(dir: File): Gog? {
|
||||
val files = dir.listFiles { f ->
|
||||
f.isFile && f.name.startsWith("goggame-") && f.name.endsWith(".info")
|
||||
} ?: return null
|
||||
for (f in files) {
|
||||
val text = readSmallTextFile(f) ?: continue
|
||||
val name = jsonStringValue(text, "name")?.takeIf { it.isNotBlank() } ?: continue
|
||||
val id = f.name.removePrefix("goggame-").removeSuffix(".info").takeIf { it.isNotBlank() }
|
||||
return Gog(id, name)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ── filename fallback ──
|
||||
|
||||
private val REPACK_TOKENS = setOf(
|
||||
"fitgirl", "dodi", "codex", "plaza", "skidrow", "rune", "empress", "flt", "tenoke",
|
||||
"reloaded", "elamigos", "razor1911", "hoodlum", "gog", "repack", "multi", "goldberg",
|
||||
// repack/site tags that show up in folder names
|
||||
"ankergames", "steamrip", "onlinefix", "online-fix", "gload", "fitgirlrepacks",
|
||||
)
|
||||
private const val EXE_NOISE_WORDS =
|
||||
"""win64|win32|shipping|wingdk|client|launcher|game|x64|x86|64bit|32bit"""
|
||||
|
||||
/**
|
||||
* Does this exe basename look like engine/arch boilerplate rather than a title? Deliberately
|
||||
* UNANCHORED — "GameConTest" is boilerplate-ish wherever the giveaway sits, and the answer
|
||||
* decides whether we trust the folder name instead of the exe name.
|
||||
*/
|
||||
private val EXE_NOISE_RE = Regex("""(?i)[-_. ]*(?:$EXE_NOISE_WORDS)""")
|
||||
|
||||
/**
|
||||
* Trailing noise to strip off a title. ANCHORED to the end and allowed to repeat, because these
|
||||
* words are only junk as a suffix: "Witcher3-Win64-Shipping" -> "Witcher3", while
|
||||
* "Game of Thrones" and "Client Simulator" must survive intact.
|
||||
*
|
||||
* Kept separate from [EXE_NOISE_RE] on purpose. One regex used to serve both jobs, so
|
||||
* anchoring it to fix stripping ALSO narrowed the boilerplate test — and a game in
|
||||
* "Game Controller Test/GameConTest.exe" stopped preferring its folder and became "GameConTest".
|
||||
*/
|
||||
private val EXE_SUFFIX_RE = Regex("""(?i)(?:[-_. ]*(?:$EXE_NOISE_WORDS))+$""")
|
||||
private val VERSION_TOKEN_RE = Regex("""(?i)\b(v?\d+([._]\d+)+|build[-_ ]?\d+|update[-_ ]?\d+)\b""")
|
||||
|
||||
/**
|
||||
* True when an exe basename is a launcher/loader rather than the game — its parent folder is the
|
||||
* better title. Catches "PlayGTAV"/"Play - GTA IV"/"LaunchGame"/"MyLauncher"/"GSE" but NOT a real
|
||||
* title that merely starts with those letters ("PlayerUnknown"). A "play|launch|start|run" prefix
|
||||
* counts only when it's a whole token — followed by a separator or a camelCase uppercase boundary,
|
||||
* not another lowercase letter (checked case-sensitively, so it survives the case-insensitive JUNK
|
||||
* matching used elsewhere).
|
||||
*/
|
||||
/** True when an exe basename looks like a launcher/loader rather than the game itself. */
|
||||
fun isLauncherExeName(base: String): Boolean {
|
||||
val lower = base.lowercase()
|
||||
if (lower == "gse" || lower.endsWith("launcher") || lower.endsWith("redirector")) return true
|
||||
for (p in arrayOf("play", "launch", "start", "run")) {
|
||||
if (!lower.startsWith(p)) continue
|
||||
val rest = base.substring(p.length)
|
||||
if (rest.isEmpty()) return true // exactly "Play"
|
||||
val c = rest[0]
|
||||
if (!c.isLetterOrDigit() || c.isUpperCase()) return true // "Play - GTA IV" / camelCase "PlayGTAV"
|
||||
// lowercase continuation ("Player…") → part of a longer word, not a launcher
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** A PE ProductName/FileDescription that describes a launcher/loader, not the game — reject it so it
|
||||
* doesn't become the shortcut name (e.g. "Rockstar Games Launcher Redirector", "GSE", "Steam"). */
|
||||
private val JUNK_NAME_RE = Regex(
|
||||
"""(?i)^(gse|steam|steam client|goldberg.*|launcher|game|play|start|setup|.* launcher.*|.*launcher redirector|.* redirector|rockstar games launcher.*|epic games launcher|smartsteamemu|coldclient.*|steamemu.*)$"""
|
||||
)
|
||||
|
||||
/** True when a PE name is a launcher/loader label rather than a real game title. */
|
||||
fun isJunkPeName(name: String): Boolean {
|
||||
val n = name.trim()
|
||||
return n.isEmpty() || JUNK_NAME_RE.matches(n)
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefer the game's install-folder name (usually the real title) over the exe basename when the
|
||||
* exe is a UE-style shipping binary OR a launcher/loader; then strip repack/suffix noise.
|
||||
*/
|
||||
private fun cleanedFallbackName(exeFile: File): String? {
|
||||
val exeBase = exeFile.nameWithoutExtension
|
||||
val preferFolder = EXE_NOISE_RE.containsMatchIn(exeBase) ||
|
||||
isLauncherExeName(exeBase) ||
|
||||
exeBase.equals("game", true) || exeBase.equals("start", true)
|
||||
val folder = exeFile.absoluteFile.parentFile?.name
|
||||
val raw = if (preferFolder && !folder.isNullOrBlank()) folder else exeBase
|
||||
return cleanName(raw)
|
||||
}
|
||||
|
||||
/** Strip bracketed tags, repack-group names, engine/arch suffixes and version tokens; tidy separators. */
|
||||
private fun cleanName(raw: String): String? {
|
||||
var s = raw
|
||||
s = s.replace(Regex("""[\[(][^\])]*[\])]"""), " ") // (…) […]
|
||||
s = VERSION_TOKEN_RE.replace(s, " ")
|
||||
s = EXE_SUFFIX_RE.replace(s, " ")
|
||||
s = s.replace(Regex("""[._]+"""), " ")
|
||||
// Drop standalone repack-group words.
|
||||
s = s.split(Regex("""[\s-]+"""))
|
||||
.filter { it.isNotBlank() && it.lowercase() !in REPACK_TOKENS }
|
||||
.joinToString(" ")
|
||||
s = s.trim()
|
||||
return s.ifBlank { null }
|
||||
}
|
||||
|
||||
// ── tiny text/format helpers (regex-based → no Android/org.json dependency, unit-testable) ──
|
||||
|
||||
/** Read a small text file (guards against accidentally slurping a huge file). */
|
||||
private fun readSmallTextFile(f: File, maxBytes: Long = 1L * 1024 * 1024): String? = try {
|
||||
if (f.isFile && f.length() in 1..maxBytes) f.readText(Charsets.UTF_8) else null
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
|
||||
/** Value of a Valve KeyValues (.acf/.vdf) `"key" "value"` pair, first match, case-insensitive key. */
|
||||
private fun vdfValue(text: String, key: String): String? =
|
||||
Regex(""""${Regex.escape(key)}"\s*"([^"]*)"""", RegexOption.IGNORE_CASE).find(text)?.groupValues?.getOrNull(1)?.trim()
|
||||
|
||||
/** Value of a JSON `"key": "value"` pair, first match. Sufficient for the flat goggame .info files. */
|
||||
private fun jsonStringValue(text: String, key: String): String? =
|
||||
Regex(""""${Regex.escape(key)}"\s*:\s*"([^"\\]*(?:\\.[^"\\]*)*)"""").find(text)
|
||||
?.groupValues?.getOrNull(1)?.replace("\\\"", "\"")?.replace("\\\\", "\\")?.trim()
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import com.droiddeck.launcher.stores.StoreGameSidecar
|
||||
import com.droiddeck.launcher.stores.StoreInstallRoot
|
||||
import java.io.File
|
||||
import java.util.zip.CRC32
|
||||
|
||||
/**
|
||||
* Games folders (the roots earlier builds let the user add in Steam settings) become what the
|
||||
* Games tab's "Add all games in this folder" makes: one entry per game ([AddedExes]). Everything
|
||||
* a game had is keyed by its own folder and stays as it was - the chosen exe, the shortcut appid,
|
||||
* the name, Start in, launch options, art choices, a removal. The roots stay bound into the
|
||||
* session at the same guest paths ([SessionPrefs.gameFolderBinds]), so every exe keeps its path
|
||||
* inside the session and every Steam shortcut its target; they are no longer Steam libraries.
|
||||
* Runs whenever saved roots remain (so twice is the same as once) and clears them after.
|
||||
*/
|
||||
object GamesFolderMigration {
|
||||
private const val TAG = "GamesFolderMigration"
|
||||
|
||||
@Synchronized
|
||||
fun run(context: Context) {
|
||||
val dirs = SessionPrefs.addedGamesDirs(context)
|
||||
if (dirs.isEmpty()) return
|
||||
val roots = legacyRoots(SessionPrefs.gameFolderBinds(context).map { it.first } + dirs)
|
||||
.filter { root -> root.host.path in dirs }
|
||||
// The binds first: a game's guest path (and with it the shortcut's appid) depends on them.
|
||||
SessionPrefs.setGameFolderBinds(context, SessionPrefs.gameFolderBinds(context) + roots.map { it.host.path to it.guest })
|
||||
val removed = SessionPrefs.removedAddedGames(context).toSet()
|
||||
val entries = AddedExes.list(context).map { it.folder }.toMutableSet()
|
||||
val storeRoots = StoreInstallRoot.roots(context).map { it.absolutePath }
|
||||
for (root in roots) {
|
||||
var n = 0
|
||||
val steamInstalls = AddedGames.steamInstallDirs(root.host)
|
||||
for (folder in root.host.listFiles { f -> f.isDirectory }?.sortedBy { it.name.lowercase() }.orEmpty()) {
|
||||
if (folder.name.equals("steamapps", ignoreCase = true) || folder.name.lowercase() in steamInstalls) continue
|
||||
if (folder.absolutePath in storeRoots || StoreGameSidecar.read(folder) != null) continue
|
||||
if (folder.path in entries) continue
|
||||
val picked = SessionPrefs.addedGameExe(context, folder.path).takeIf { it.isNotEmpty() }?.let { File(it) }?.takeIf { it.isFile }
|
||||
val exe = picked ?: AddedGames.rank(folder).exes.firstOrNull() ?: continue
|
||||
AddedExes.put(context, AddedExes.Entry(folder.path, exe.path, picked = picked != null))
|
||||
entries.add(folder.path)
|
||||
if (folder.path !in removed) n++
|
||||
}
|
||||
Log.i(TAG, "games: migrated ${root.host} → $n games")
|
||||
}
|
||||
SessionPrefs.setAddedGamesDirs(context, emptyList())
|
||||
SessionPrefs.setGamesFoldersMigrated(context)
|
||||
}
|
||||
|
||||
/**
|
||||
* The guest paths earlier builds gave Games folders: /root/Games/<folder name>, with a short
|
||||
* hash of the host path when two share a name. Kept exactly, so nothing moves in the session.
|
||||
*/
|
||||
internal fun legacyRoots(paths: List<String>): List<AddedGames.Root> {
|
||||
val hosts = paths.distinct().map { File(it) }
|
||||
val names = hosts.groupingBy { it.name.lowercase() }.eachCount()
|
||||
return hosts.map { host ->
|
||||
val name = host.name.ifEmpty { "games" }
|
||||
val guestName = if ((names[name.lowercase()] ?: 0) > 1) name + "-" + "%08x".format(CRC32().apply { update(host.path.toByteArray()) }.value).take(4) else name
|
||||
AddedGames.Root(host, "${AddedGames.GUEST_DIR}/$guestName")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -152,24 +152,21 @@ object Library {
|
||||
}
|
||||
|
||||
/** The same installed-game inventory used for links, shortcuts and file exports. */
|
||||
/** [added]: the added games when the caller has just scanned them, so they are not scanned again. */
|
||||
fun launchableGames(context: Context, strictRead: Boolean = false, added: List<AddedGames.Game>? = null): List<SteamGame> {
|
||||
fun launchableGames(context: Context, strictRead: Boolean = false): List<SteamGame> {
|
||||
val roots = steamLibraries(context).map { it.first }
|
||||
return (steamGames(context, strictRead) + (added ?: AddedGames.scan(context)).map { g -> addedGame(context, g, roots) }).distinctBy { it.gameId }
|
||||
return (steamGames(context, strictRead) + AddedGames.scan(context).map { g ->
|
||||
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),
|
||||
icon = art.icon?.takeIf { it.extension.lowercase() != "ico" },
|
||||
source = g.source, storeId = g.storeId,
|
||||
)
|
||||
}
|
||||
}).distinctBy { it.gameId }
|
||||
}
|
||||
|
||||
/** One added game as the Games tab lists it; [roots] the Steam libraries, for its Proton prefix. */
|
||||
fun addedGame(context: Context, g: AddedGames.Game, roots: List<File> = steamLibraries(context).map { it.first }): 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),
|
||||
icon = art.icon?.takeIf { it.extension.lowercase() != "ico" },
|
||||
source = g.source, storeId = g.storeId,
|
||||
)
|
||||
}
|
||||
|
||||
/** Steam stores current library art inside hash-named folders under the app's cache dir. */
|
||||
private fun steamCacheImage(cache: File, appId: Int, names: List<String>): File? {
|
||||
val appDir = File(cache, appId.toString())
|
||||
|
||||
@@ -1,250 +0,0 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
|
||||
/**
|
||||
* Reads the StringFileInfo strings (ProductName, FileDescription, …) out of the
|
||||
* VS_VERSION_INFO resource embedded in a Windows PE executable (.exe).
|
||||
*
|
||||
* Walks the PE resource directory to the first RT_VERSION resource — the same
|
||||
* navigation [com.droiddeck.launcher.files.PeIconExtractor] uses for icons — then parses the alignment-heavy
|
||||
* VS_VERSIONINFO → StringFileInfo → StringTable → String tree by hand.
|
||||
*
|
||||
* Everything is best-effort: any malformed / oversized / unsupported input returns
|
||||
* null (or an empty map). No allocation beyond the single resource blob.
|
||||
*/
|
||||
object PeVersionInfo {
|
||||
|
||||
private const val RT_VERSION = 16
|
||||
private const val MAX_RES_SECTION = 64 * 1024 * 1024 // don't slurp absurd resource sections
|
||||
|
||||
/**
|
||||
* Returns the StringFileInfo key→value map (e.g. `"ProductName" -> "Palworld"`),
|
||||
* or null if the file has no readable version resource. Keys are the raw
|
||||
* VS_VERSIONINFO names; look up [productName]/[fileDescription] for the common ones.
|
||||
*/
|
||||
fun read(file: File): Map<String, String>? = try {
|
||||
RandomAccessFile(file, "r").use { raf ->
|
||||
val blob = versionResourceBytes(raf) ?: return@use null
|
||||
parseStringFileInfo(blob).takeIf { it.isNotEmpty() }
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
|
||||
/**
|
||||
* Best display name from a version map. ProductName is usually the cleanest title, but is
|
||||
* sometimes an internal abbreviation (e.g. God of War ships ProductName "GoW" while
|
||||
* FileDescription is "God of War"). When ProductName is a single spaceless token and
|
||||
* FileDescription is a normal multi-word title, prefer FileDescription. Trimmed; null if
|
||||
* neither is meaningful.
|
||||
*/
|
||||
fun bestName(map: Map<String, String>?): String? {
|
||||
if (map == null) return null
|
||||
val product = map["ProductName"]?.trim()?.takeIf { it.isNotEmpty() }
|
||||
val desc = map["FileDescription"]?.trim()?.takeIf { it.isNotEmpty() }
|
||||
if (product != null && desc != null &&
|
||||
!product.contains(' ') && desc.contains(' ') && desc.length <= 60
|
||||
) {
|
||||
return desc
|
||||
}
|
||||
return product ?: desc
|
||||
}
|
||||
|
||||
// ── locate the RT_VERSION resource blob (mirrors PeIconExtractor's navigation) ──
|
||||
|
||||
private fun versionResourceBytes(raf: RandomAccessFile): ByteArray? {
|
||||
val dos = read(raf, 0, 0x40) ?: return null
|
||||
if (u8(dos, 0) != 0x4D || u8(dos, 1) != 0x5A) return null // "MZ"
|
||||
val peOff = u32(dos, 0x3C).toInt()
|
||||
|
||||
val coff = read(raf, peOff.toLong(), 24) ?: return null
|
||||
if (u8(coff, 0) != 0x50 || u8(coff, 1) != 0x45 || u8(coff, 2) != 0 || u8(coff, 3) != 0) return null // "PE\0\0"
|
||||
val numSections = u16(coff, 6)
|
||||
val sizeOpt = u16(coff, 20)
|
||||
if (numSections <= 0 || numSections > 96 || sizeOpt <= 0) return null
|
||||
|
||||
val optOff = peOff + 24L
|
||||
val opt = read(raf, optOff, sizeOpt) ?: return null
|
||||
val magic = u16(opt, 0)
|
||||
val ddOff = if (magic == 0x20B) 112 else 96 // PE32+ vs PE32 data-directory offset
|
||||
val resDirEntry = ddOff + 2 * 8 // data directory index 2 = resource table
|
||||
if (resDirEntry + 8 > opt.size) return null
|
||||
val resRVA = u32(opt, resDirEntry).toInt()
|
||||
val resSize = u32(opt, resDirEntry + 4).toInt()
|
||||
if (resRVA == 0 || resSize == 0) return null
|
||||
|
||||
val secTabOff = optOff + sizeOpt
|
||||
val secTab = read(raf, secTabOff, numSections * 40) ?: return null
|
||||
|
||||
var secVA = 0
|
||||
var secPtr = 0L
|
||||
var secRaw = 0
|
||||
var found = false
|
||||
for (i in 0 until numSections) {
|
||||
val o = i * 40
|
||||
if (o + 40 > secTab.size) break
|
||||
val va = u32(secTab, o + 12).toInt()
|
||||
val vSize = u32(secTab, o + 8).toInt()
|
||||
val raw = u32(secTab, o + 16).toInt()
|
||||
val pRaw = u32(secTab, o + 20)
|
||||
val span = maxOf(vSize, raw)
|
||||
if (resRVA >= va && resRVA < va + span) {
|
||||
secVA = va; secPtr = pRaw; secRaw = raw; found = true; break
|
||||
}
|
||||
}
|
||||
if (!found || secRaw <= 0 || secRaw > MAX_RES_SECTION) return null
|
||||
|
||||
val res = read(raf, secPtr, secRaw) ?: return null
|
||||
val resDirStart = resRVA - secVA
|
||||
if (resDirStart < 0 || resDirStart + 16 > res.size) return null
|
||||
|
||||
val verTypeDir = findIdSubdir(res, resDirStart, RT_VERSION, resDirStart) ?: return null
|
||||
val verData = firstDataEntry(res, verTypeDir, resDirStart) ?: return null
|
||||
return dataEntryBytes(res, verData, secVA)
|
||||
}
|
||||
|
||||
/** Resolve the sub-directory under [dirIdx] whose id matches [wantId]; null if absent or not a directory. */
|
||||
private fun findIdSubdir(res: ByteArray, dirIdx: Int, wantId: Int, resDirStart: Int): Int? {
|
||||
if (dirIdx + 16 > res.size) return null
|
||||
val total = u16(res, dirIdx + 12) + u16(res, dirIdx + 14)
|
||||
val start = dirIdx + 16
|
||||
for (i in 0 until total) {
|
||||
val e = start + i * 8
|
||||
if (e + 8 > res.size) break
|
||||
val id = u32(res, e)
|
||||
val off = u32(res, e + 4)
|
||||
if (id and 0x80000000L == 0L && id.toInt() == wantId) {
|
||||
if (off and 0x80000000L != 0L) return resDirStart + (off and 0x7FFFFFFFL).toInt()
|
||||
return null
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Descend the first entry of each level until an IMAGE_RESOURCE_DATA_ENTRY; returns its index. */
|
||||
private fun firstDataEntry(res: ByteArray, dirIdx: Int, resDirStart: Int): Int? {
|
||||
if (dirIdx + 16 > res.size) return null
|
||||
if (u16(res, dirIdx + 12) + u16(res, dirIdx + 14) == 0) return null
|
||||
val e = dirIdx + 16
|
||||
if (e + 8 > res.size) return null
|
||||
val off = u32(res, e + 4)
|
||||
return if (off and 0x80000000L != 0L) {
|
||||
firstDataEntry(res, resDirStart + (off and 0x7FFFFFFFL).toInt(), resDirStart)
|
||||
} else {
|
||||
resDirStart + off.toInt()
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the bytes an IMAGE_RESOURCE_DATA_ENTRY points at (its OffsetToData is an RVA). */
|
||||
private fun dataEntryBytes(res: ByteArray, entryIdx: Int, secVA: Int): ByteArray? {
|
||||
if (entryIdx < 0 || entryIdx + 8 > res.size) return null
|
||||
val rva = u32(res, entryIdx).toInt()
|
||||
val size = u32(res, entryIdx + 4).toInt()
|
||||
val start = rva - secVA
|
||||
if (size <= 0 || start < 0 || start + size > res.size) return null
|
||||
return res.copyOfRange(start, start + size)
|
||||
}
|
||||
|
||||
// ── VS_VERSIONINFO tree parse ──
|
||||
//
|
||||
// Every node is: WORD wLength, WORD wValueLength, WORD wType, WCHAR szKey[],
|
||||
// then (32-bit aligned) an optional Value, then (32-bit aligned) child nodes.
|
||||
// We only care about: VS_VERSIONINFO → StringFileInfo → StringTable → String.
|
||||
|
||||
private fun parseStringFileInfo(b: ByteArray): Map<String, String> {
|
||||
val out = LinkedHashMap<String, String>()
|
||||
if (b.size < 6) return out
|
||||
val rootLen = minOf(u16(b, 0), b.size)
|
||||
// VS_VERSION_INFO header: skip szKey "VS_VERSION_INFO" then the FIXEDFILEINFO value.
|
||||
val rootValLen = u16(b, 2)
|
||||
var pos = align4(6 + (utf16zLen(b, 6) + 1) * 2)
|
||||
pos = align4(pos + rootValLen)
|
||||
|
||||
while (pos + 6 <= rootLen) {
|
||||
val childLen = u16(b, pos)
|
||||
if (childLen < 6 || pos + childLen > b.size) break
|
||||
val key = utf16z(b, pos + 6)
|
||||
if (key == "StringFileInfo") parseTables(b, pos, pos + childLen, out)
|
||||
pos = align4(pos + childLen)
|
||||
if (childLen == 0) break
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Iterate the StringTable children inside a StringFileInfo block [start, end). */
|
||||
private fun parseTables(b: ByteArray, start: Int, end: Int, out: MutableMap<String, String>) {
|
||||
var p = align4(start + 6 + (utf16zLen(b, start + 6) + 1) * 2)
|
||||
while (p + 6 <= end) {
|
||||
val tableLen = u16(b, p)
|
||||
if (tableLen < 6 || p + tableLen > end) break
|
||||
parseStrings(b, p, minOf(p + tableLen, end), out)
|
||||
p = align4(p + tableLen)
|
||||
if (tableLen == 0) break
|
||||
}
|
||||
}
|
||||
|
||||
/** Iterate the String children inside a StringTable block [start, end). */
|
||||
private fun parseStrings(b: ByteArray, start: Int, end: Int, out: MutableMap<String, String>) {
|
||||
var q = align4(start + 6 + (utf16zLen(b, start + 6) + 1) * 2)
|
||||
while (q + 6 <= end) {
|
||||
val strLen = u16(b, q)
|
||||
if (strLen < 6 || q + strLen > end) break
|
||||
val key = utf16z(b, q + 6)
|
||||
val valOff = align4(q + 6 + (key.length + 1) * 2)
|
||||
if (key.isNotEmpty() && valOff < q + strLen) {
|
||||
val value = utf16z(b, valOff, q + strLen).trim()
|
||||
if (value.isNotEmpty()) out[key] = value
|
||||
}
|
||||
q = align4(q + strLen)
|
||||
if (strLen == 0) break
|
||||
}
|
||||
}
|
||||
|
||||
// ── little-endian / UTF-16 readers ──
|
||||
|
||||
private fun align4(x: Int): Int = (x + 3) and 3.inv()
|
||||
|
||||
/** Number of UTF-16 code units before the null terminator starting at [off]. */
|
||||
private fun utf16zLen(b: ByteArray, off: Int): Int {
|
||||
var i = off
|
||||
while (i + 1 < b.size) {
|
||||
if (b[i].toInt() == 0 && b[i + 1].toInt() == 0) break
|
||||
i += 2
|
||||
}
|
||||
return (i - off) / 2
|
||||
}
|
||||
|
||||
/** Read a null-terminated UTF-16LE string starting at [off], not reading past [limit]. */
|
||||
private fun utf16z(b: ByteArray, off: Int, limit: Int = b.size): String {
|
||||
val sb = StringBuilder()
|
||||
var i = off
|
||||
val end = minOf(limit, b.size)
|
||||
while (i + 1 < end) {
|
||||
val c = u8(b, i) or (u8(b, i + 1) shl 8)
|
||||
if (c == 0) break
|
||||
sb.append(c.toChar())
|
||||
i += 2
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private fun u8(b: ByteArray, off: Int): Int = if (off < 0 || off >= b.size) 0 else b[off].toInt() and 0xFF
|
||||
private fun u16(b: ByteArray, off: Int): Int = u8(b, off) or (u8(b, off + 1) shl 8)
|
||||
private fun u32(b: ByteArray, off: Int): Long =
|
||||
u8(b, off).toLong() or (u8(b, off + 1).toLong() shl 8) or
|
||||
(u8(b, off + 2).toLong() shl 16) or (u8(b, off + 3).toLong() shl 24)
|
||||
|
||||
private fun read(raf: RandomAccessFile, pos: Long, len: Int): ByteArray? {
|
||||
if (len <= 0 || pos < 0) return null
|
||||
val fileLen = raf.length()
|
||||
if (pos >= fileLen) return null
|
||||
val n = minOf(len.toLong(), fileLen - pos).toInt()
|
||||
if (n <= 0) return null
|
||||
val buf = ByteArray(n)
|
||||
raf.seek(pos)
|
||||
raf.readFully(buf)
|
||||
return buf
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.session.GameStorage
|
||||
import com.droiddeck.launcher.session.SessionState
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* A Steam game whose download finishes during a session, noticed by its appmanifest (a new one,
|
||||
* or StateFlags turning to 4, fully installed) with a light look every five seconds while the
|
||||
* session runs, and handed to [AutoComponents] as a single job that may run in the session, so its
|
||||
* Windows components are in place when the user plays it right after the download.
|
||||
*/
|
||||
object SessionInstalls {
|
||||
private const val TAG = "SessionInstalls"
|
||||
private const val POLL_MS = 5_000L
|
||||
|
||||
/** One appmanifest as read: the app, its name and folder, whether it is fully installed. */
|
||||
class Manifest(val appId: Int, val name: String, val installDir: String, val installed: Boolean, val steamapps: File)
|
||||
|
||||
private val APPID = Regex("\"appid\"\\s*\"(\\d+)\"", RegexOption.IGNORE_CASE)
|
||||
private val NAME = Regex("\"name\"\\s*\"([^\"]*)\"", RegexOption.IGNORE_CASE)
|
||||
private val DIR = Regex("\"installdir\"\\s*\"([^\"]*)\"", RegexOption.IGNORE_CASE)
|
||||
private val STATE = Regex("\"StateFlags\"\\s*\"(\\d+)\"", RegexOption.IGNORE_CASE)
|
||||
|
||||
internal fun parse(file: File): Manifest? = runCatching {
|
||||
val text = file.readText()
|
||||
val id = APPID.find(text)?.groupValues?.get(1)?.toInt() ?: return null
|
||||
Manifest(id, NAME.find(text)?.groupValues?.get(1).orEmpty(), DIR.find(text)?.groupValues?.get(1).orEmpty(),
|
||||
STATE.find(text)?.groupValues?.get(1)?.toIntOrNull() == 4, file.parentFile!!)
|
||||
}.getOrNull()
|
||||
|
||||
/** The libraries the session's client installs to, as host folders. */
|
||||
internal fun steamappsDirs(context: Context): List<File> = listOfNotNull(
|
||||
File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/steamapps"),
|
||||
GameStorage.effective(context)?.let { File(it.path, "steamapps") },
|
||||
).filter { it.isDirectory }
|
||||
|
||||
/**
|
||||
* Keeps what the libraries hold between looks: a manifest re-read only when its time changed.
|
||||
* [poll] returns the apps that became fully installed since the last look (none on the first).
|
||||
*/
|
||||
internal class Watch(private val dirs: () -> List<File>) {
|
||||
private val seen = HashMap<String, Pair<Long, Manifest?>>()
|
||||
private var first = true
|
||||
|
||||
fun poll(): List<Manifest> {
|
||||
val fresh = ArrayList<Manifest>()
|
||||
for (dir in dirs()) for (f in dir.listFiles { x -> x.isFile && x.name.startsWith("appmanifest_") && x.name.endsWith(".acf") }.orEmpty()) {
|
||||
val before = seen[f.path]
|
||||
if (before != null && before.first == f.lastModified()) continue
|
||||
val now = parse(f)
|
||||
seen[f.path] = f.lastModified() to now
|
||||
if (!first && now != null && now.installed && before?.second?.installed != true) fresh.add(now)
|
||||
}
|
||||
first = false
|
||||
return fresh
|
||||
}
|
||||
}
|
||||
|
||||
/** A finished install as the Games tab would list it. */
|
||||
internal fun gameOf(m: Manifest): Library.SteamGame =
|
||||
Library.SteamGame(m.appId, m.name.ifEmpty { m.appId.toString() }, null, m.steamapps.parentFile?.path ?: "steam",
|
||||
gameFiles = File(m.steamapps, "common/${m.installDir}"), source = Library.SOURCE_STEAM)
|
||||
|
||||
/**
|
||||
* Looks every five seconds while the session runs, on a thread of its own at background
|
||||
* priority; each finished install becomes one job for its Windows components.
|
||||
*/
|
||||
fun watch(context: Context) {
|
||||
val app = context.applicationContext
|
||||
Thread({
|
||||
android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_BACKGROUND)
|
||||
val watch = Watch { steamappsDirs(app) }
|
||||
try {
|
||||
// The session is still starting when this is called: up to two minutes for it.
|
||||
var waited = 0L
|
||||
while (!SessionState.running && waited < 120_000L) { Thread.sleep(POLL_MS); waited += POLL_MS }
|
||||
watch.poll()
|
||||
while (SessionState.running) {
|
||||
for (m in watch.poll()) {
|
||||
Log.i(TAG, "wincomp: ${m.name} (${m.appId}) finished installing in the session")
|
||||
AutoComponents.queueInSession(app, gameOf(m))
|
||||
}
|
||||
Thread.sleep(POLL_MS)
|
||||
}
|
||||
} catch (e: InterruptedException) {
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "install watch: ${e.message}")
|
||||
}
|
||||
}, "session-installs").apply { isDaemon = true }.start()
|
||||
}
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AtomicFile
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.DataInputStream
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
/**
|
||||
* Steam's own data for an app, from the client in the runtime: its app-info cache
|
||||
* (Steam/appcache/appinfo.vdf, the PICS data the client keeps for every app it has seen). What is
|
||||
* read is which Steamworks Shared depots (the VC++, DirectX... redistributables of app 228980) the
|
||||
* app's depots point at, and its name. Kept per appid in the app's files, so it works offline; an
|
||||
* app the client has not seen is looked for again after a week.
|
||||
*/
|
||||
object SteamAppInfo {
|
||||
private const val TAG = "SteamAppInfo"
|
||||
private const val CACHE = "steam-appinfo.json"
|
||||
private const val RETRY_AFTER_MS = 7L * 24 * 3600 * 1000
|
||||
/** The Steamworks Shared app the redistributable depots belong to. */
|
||||
const val SHARED_APP = "228980"
|
||||
|
||||
class Info(val name: String, val sharedDepots: List<String>)
|
||||
|
||||
fun appinfoFile(context: Context) = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/appcache/appinfo.vdf")
|
||||
|
||||
/** The shared depots for [appId]: from the cache, else the client's app-info file; null when unknown. */
|
||||
@Synchronized
|
||||
fun info(context: Context, appId: Int, appinfo: File = appinfoFile(context)): Info? {
|
||||
val cacheFile = AtomicFile(File(context.filesDir, CACHE))
|
||||
val cache = runCatching { JSONObject(cacheFile.readFully().toString(Charsets.UTF_8)) }.getOrDefault(JSONObject())
|
||||
val now = System.currentTimeMillis()
|
||||
val mtime = appinfo.lastModified()
|
||||
val known = cache.optJSONObject(appId.toString())
|
||||
known?.let { o ->
|
||||
if (!o.optBoolean("miss")) return Info(o.optString("name"), o.optJSONArray("depots").strings())
|
||||
// A miss is read again when the client has written its cache since (the user may have
|
||||
// looked at the app in Steam), else a week later.
|
||||
if (now - o.optLong("at") < RETRY_AFTER_MS && o.optLong("mtime") == mtime) return null
|
||||
}
|
||||
val read = runCatching { read(appinfo, appId) }.onFailure { Log.w(TAG, "appinfo: ${it.javaClass.simpleName}") }.getOrNull()
|
||||
val entry = JSONObject().put("at", now).put("mtime", mtime)
|
||||
// When Steam was last asked anonymously ([lookup]), kept across re-reads of the file.
|
||||
known?.optLong("picsAt")?.takeIf { it > 0 }?.let { entry.put("picsAt", it) }
|
||||
if (read == null) entry.put("miss", true) else entry.put("name", read.name).put("depots", JSONArray(read.sharedDepots))
|
||||
cache.put(appId.toString(), entry)
|
||||
val out = cacheFile.startWrite()
|
||||
try { out.write(cache.toString().toByteArray()); cacheFile.finishWrite(out) } catch (e: Exception) { cacheFile.failWrite(out) }
|
||||
return read
|
||||
}
|
||||
|
||||
/** Steam asked anonymously ([SteamPics]); a test stands in for it. */
|
||||
@androidx.annotation.VisibleForTesting
|
||||
internal var pics: (List<Int>) -> Map<Int, Info?>? = { SteamPics.lookup(it) }
|
||||
|
||||
/**
|
||||
* Of [appIds], those neither the client's cache (appinfo.vdf, read first: free) nor an earlier
|
||||
* answer has, and that Steam was not asked about anonymously in the past week.
|
||||
*/
|
||||
fun needLookup(context: Context, appIds: List<Int>): List<Int> {
|
||||
val wanted = appIds.distinct().filter { it > 0 && info(context, it) == null }
|
||||
if (wanted.isEmpty()) return emptyList()
|
||||
val cache = cache(context)
|
||||
val now = System.currentTimeMillis()
|
||||
return wanted.filter { now - (cache.optJSONObject(it.toString())?.optLong("picsAt") ?: 0L) >= RETRY_AFTER_MS }
|
||||
}
|
||||
|
||||
/**
|
||||
* [appIds] the client never saw, asked of Steam anonymously in one connection ([SteamPics], in
|
||||
* batches) and every answer kept like the client's: an app's depots and name, or a miss tried
|
||||
* again in a week. False when nothing needed asking or Steam could not be reached. Blocking.
|
||||
*/
|
||||
@Synchronized
|
||||
fun lookup(context: Context, appIds: List<Int>): Boolean {
|
||||
val need = needLookup(context, appIds)
|
||||
if (need.isEmpty()) return false
|
||||
val answers = pics(need) ?: return false
|
||||
val cache = cache(context)
|
||||
val now = System.currentTimeMillis()
|
||||
val mtime = appinfoFile(context).lastModified()
|
||||
for (id in need) {
|
||||
val info = answers[id]
|
||||
val entry = JSONObject().put("at", now).put("mtime", mtime).put("picsAt", now)
|
||||
if (info == null) entry.put("miss", true) else entry.put("name", info.name).put("depots", JSONArray(info.sharedDepots)).put("source", "pics")
|
||||
cache.put(id.toString(), entry)
|
||||
}
|
||||
val file = AtomicFile(File(context.filesDir, CACHE))
|
||||
val out = file.startWrite()
|
||||
try { out.write(cache.toString().toByteArray()); file.finishWrite(out) } catch (e: Exception) { file.failWrite(out) }
|
||||
return true
|
||||
}
|
||||
|
||||
/** What is kept for [appId]: "hit", "miss" or "none" (part of a game's list fingerprint). */
|
||||
fun state(context: Context, appId: Int): String = cache(context).optJSONObject(appId.toString())?.let { if (it.optBoolean("miss")) "miss" else "hit" } ?: "none"
|
||||
|
||||
private fun cache(context: Context): JSONObject =
|
||||
runCatching { JSONObject(AtomicFile(File(context.filesDir, CACHE)).readFully().toString(Charsets.UTF_8)) }.getOrDefault(JSONObject())
|
||||
|
||||
private fun JSONArray?.strings(): List<String> = if (this == null) emptyList() else (0 until length()).map { optString(it) }
|
||||
|
||||
/**
|
||||
* [appId]'s entry in an appinfo.vdf (versions 27, 28 and 29, the last with its keys in a string
|
||||
* table), or null when the file does not have it.
|
||||
*/
|
||||
internal fun read(file: File, appId: Int): Info? {
|
||||
if (!file.isFile) return null
|
||||
RandomAccessFile(file, "r").use { raf ->
|
||||
val head = ByteBuffer.allocate(16).order(ByteOrder.LITTLE_ENDIAN)
|
||||
raf.readFully(head.array(), 0, 8)
|
||||
val magic = head.getInt(0)
|
||||
val version = magic and 0xFF
|
||||
if (magic ushr 8 != 0x075644 || version !in 0x27..0x29) return null
|
||||
var strings: List<String>? = null
|
||||
var pos = 8L
|
||||
if (version >= 0x29) {
|
||||
raf.readFully(head.array(), 8, 8)
|
||||
val tableAt = head.getLong(8)
|
||||
strings = readStrings(raf, tableAt)
|
||||
pos = 16L
|
||||
}
|
||||
val fixed = if (version >= 0x28) 4 + 4 + 8 + 20 + 4 + 20 else 4 + 4 + 8 + 20 + 4
|
||||
val entry = ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN)
|
||||
while (pos + 8 <= raf.length()) {
|
||||
raf.seek(pos)
|
||||
raf.readFully(entry.array(), 0, 8)
|
||||
val id = entry.getInt(0)
|
||||
val size = entry.getInt(4).toLong() and 0xFFFFFFFFL
|
||||
if (id == 0) return null
|
||||
if (id == appId) {
|
||||
if (size > 64L * 1024 * 1024) return null
|
||||
val body = ByteArray(size.toInt())
|
||||
raf.readFully(body)
|
||||
val kv = ByteBuffer.wrap(body, fixed, body.size - fixed).order(ByteOrder.LITTLE_ENDIAN)
|
||||
val tree = readMap(kv, strings)
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val app = (tree["appinfo"] as? Map<String, Any>) ?: tree
|
||||
return infoOf(app)
|
||||
}
|
||||
pos += 8 + size
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
internal fun infoOf(app: Map<String, Any>): Info {
|
||||
val name = (app["common"] as? Map<String, Any>)?.get("name") as? String ?: ""
|
||||
val depots = (app["depots"] as? Map<String, Any>).orEmpty()
|
||||
val shared = depots.mapNotNull { (key, value) ->
|
||||
val depot = value as? Map<String, Any> ?: return@mapNotNull null
|
||||
key.takeIf { (depot["depotfromapp"] as? String) == SHARED_APP || (key in SteamRedists.DEPOTS && depot["sharedinstall"] != null) }
|
||||
}
|
||||
return Info(name, shared)
|
||||
}
|
||||
|
||||
private fun readStrings(raf: RandomAccessFile, at: Long): List<String> {
|
||||
raf.seek(at)
|
||||
val count = Integer.reverseBytes(raf.readInt())
|
||||
val rest = ByteArray((raf.length() - at - 4).toInt().coerceAtLeast(0))
|
||||
raf.readFully(rest)
|
||||
val out = ArrayList<String>(count)
|
||||
var start = 0
|
||||
for (i in rest.indices) {
|
||||
if (out.size == count) break
|
||||
if (rest[i] == 0.toByte()) { out.add(String(rest, start, i - start, Charsets.UTF_8)); start = i + 1 }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun cstring(b: ByteBuffer): String {
|
||||
val start = b.position()
|
||||
while (b.get() != 0.toByte()) { }
|
||||
val bytes = ByteArray(b.position() - start - 1)
|
||||
b.position(start); b.get(bytes); b.get()
|
||||
return String(bytes, Charsets.UTF_8)
|
||||
}
|
||||
|
||||
/** One binary key-value map, to its end marker; keys by name, or by index into [strings]. */
|
||||
private fun readMap(b: ByteBuffer, strings: List<String>?): Map<String, Any> {
|
||||
val out = LinkedHashMap<String, Any>()
|
||||
while (b.hasRemaining()) {
|
||||
val type = b.get().toInt() and 0xFF
|
||||
if (type == 0x08) return out
|
||||
val key = if (strings != null) strings.getOrElse(b.int) { "" } else cstring(b)
|
||||
when (type) {
|
||||
0x00 -> out[key] = readMap(b, strings)
|
||||
0x01 -> out[key] = cstring(b)
|
||||
0x02 -> out[key] = b.int.toString()
|
||||
0x03 -> out[key] = b.float.toString()
|
||||
0x07, 0x0A -> out[key] = b.long.toString()
|
||||
else -> return out
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import com.droiddeck.launcher.stores.CredentialCipher
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.net.URLEncoder
|
||||
|
||||
/**
|
||||
* SteamGridDB, the art source after Steam's store: a game by its Steam appid, else by a search
|
||||
* whose result must carry the same name, then its best-rated grids, heroes, logos and icons.
|
||||
*
|
||||
* The API key: the user's own (Steam settings › Games), sealed with [CredentialCipher] in the
|
||||
* app's files, else the one the build carries (`BuildConfig.SGDB_API_KEY`, from a CI secret), else
|
||||
* none, and then this step is skipped. The key travels only in the Authorization header and is
|
||||
* never logged.
|
||||
*/
|
||||
object SteamGridDb {
|
||||
private const val TAG = "SteamGridDb"
|
||||
private const val API = "https://www.steamgriddb.com/api/v2"
|
||||
private const val QUERY = "nsfw=false&humor=false"
|
||||
|
||||
@VisibleForTesting
|
||||
@Volatile
|
||||
internal var keys: CredentialCipher.KeyProvider = CredentialCipher.Keystore
|
||||
|
||||
private fun keyFile(context: Context) = File(context.filesDir, "sgdb/key.json")
|
||||
|
||||
/** The key the build carries, or "" (a build without the secret, a fork's PR build). */
|
||||
fun builtInKey(): String = runCatching {
|
||||
Class.forName("com.droiddeck.launcher.BuildConfig").getField("SGDB_API_KEY").get(null) as? String
|
||||
}.getOrNull().orEmpty()
|
||||
|
||||
/** The user's key wins, then the build's; "" when there is neither. */
|
||||
fun pick(user: String, builtIn: String): String = user.trim().ifEmpty { builtIn.trim() }
|
||||
|
||||
fun key(context: Context): String = pick(userKey(context), builtInKey())
|
||||
|
||||
/** The key the user entered, or "". */
|
||||
@Synchronized
|
||||
fun userKey(context: Context): String {
|
||||
val f = keyFile(context)
|
||||
if (!f.isFile) return ""
|
||||
return try {
|
||||
val json = JSONObject(f.readText())
|
||||
if (CredentialCipher.isEnvelope(json)) CredentialCipher.open(json, keys) else json.optString("key")
|
||||
} catch (e: Exception) {
|
||||
if (CredentialCipher.isPermanent(e)) f.delete()
|
||||
Log.w(TAG, "the saved key cannot be read (${e.javaClass.simpleName})")
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
/** Saves the user's key sealed (plain only where the Keystore does not work); "" clears it. */
|
||||
@Synchronized
|
||||
fun setUserKey(context: Context, key: String) {
|
||||
val f = keyFile(context)
|
||||
val value = key.trim()
|
||||
if (value.isEmpty()) { f.delete(); return }
|
||||
f.parentFile?.mkdirs()
|
||||
val text = try {
|
||||
CredentialCipher.seal(value, keys).toString()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "keystore unavailable, the key stays plain (${e.javaClass.simpleName})")
|
||||
JSONObject().put("key", value).toString()
|
||||
}
|
||||
val tmp = File(f.path + ".tmp")
|
||||
tmp.writeText(text)
|
||||
tmp.setReadable(false, false); tmp.setReadable(true, true)
|
||||
if (!tmp.renameTo(f)) { f.delete(); tmp.renameTo(f) }
|
||||
}
|
||||
|
||||
/** The SteamGridDB game id for [steamAppId], else for a search on [name] that returns the same name; null when neither. */
|
||||
fun gameId(key: String, steamAppId: Int?, name: String): Int? {
|
||||
if (steamAppId != null) {
|
||||
json(key, "$API/games/steam/$steamAppId")?.optJSONObject("data")?.optInt("id")?.takeIf { it > 0 }?.let { return it }
|
||||
}
|
||||
val term = URLEncoder.encode(name, "UTF-8").replace("+", "%20")
|
||||
val results = json(key, "$API/search/autocomplete/$term")?.optJSONArray("data") ?: return null
|
||||
return match(name, (0 until results.length()).map { results.getJSONObject(it) }.map { it.optInt("id") to it.optString("name") })
|
||||
}
|
||||
|
||||
/** The id of the first result whose name matches [name] ([AddedGameArt.nameMatch]); no blind first pick. */
|
||||
internal fun match(name: String, results: List<Pair<Int, String>>): Int? =
|
||||
results.map { (id, got) -> id to AddedGameArt.nameMatch(name, got) }
|
||||
.filter { it.second > 0 && it.first > 0 }
|
||||
.maxByOrNull { it.second }?.first
|
||||
|
||||
/**
|
||||
* Fetches what is missing into [cache] under the names Steam's art uses there (p.jpg,
|
||||
* header.jpg, hero.jpg, logo.png) and the icon as icon-sgdb.png. True when anything arrived.
|
||||
*/
|
||||
fun download(key: String, id: Int, cache: File): 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",
|
||||
)) {
|
||||
val dst = File(cache, local)
|
||||
if (dst.isFile) continue
|
||||
val items = json(key, "$API/$path")?.optJSONArray("data") ?: continue
|
||||
val url = best(items, local.endsWith(".png")) ?: continue
|
||||
val bytes = AddedGameArt.get(url) ?: continue
|
||||
val tmp = File(cache, "$local.tmp")
|
||||
tmp.writeBytes(bytes)
|
||||
if (tmp.renameTo(dst)) { got = true; File(cache, "$local.src").writeText(AddedGameArt.SGDB) }
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
/** The best-scored [limit] images for [slot] of game [id], as (thumbnail, full image) URLs. */
|
||||
fun images(key: String, id: Int, slot: AddedGameArt.Slot, limit: Int): List<Pair<String, String>> {
|
||||
val path = when (slot) {
|
||||
AddedGameArt.Slot.COVER -> "grids/game/$id?dimensions=600x900&$QUERY"
|
||||
AddedGameArt.Slot.BACKGROUND -> "heroes/game/$id?$QUERY"
|
||||
AddedGameArt.Slot.LOGO -> "logos/game/$id?$QUERY"
|
||||
AddedGameArt.Slot.ICON -> "icons/game/$id?$QUERY"
|
||||
}
|
||||
val items = json(key, "$API/$path")?.optJSONArray("data") ?: return emptyList()
|
||||
return ranked(items, pngOnly = slot == AddedGameArt.Slot.LOGO || slot == AddedGameArt.Slot.ICON).take(limit)
|
||||
}
|
||||
|
||||
/** [items] best-scored first, as (thumbnail, full image) URLs; for a .png slot only PNGs. */
|
||||
internal fun ranked(items: JSONArray, pngOnly: Boolean): List<Pair<String, String>> =
|
||||
(0 until items.length()).map { items.getJSONObject(it) }
|
||||
.filter { it.optString("url").startsWith("https://") && (!pngOnly || it.optString("mime") == "image/png" || it.optString("url").endsWith(".png", true)) }
|
||||
.sortedByDescending { it.optInt("score", 0) }
|
||||
.map { (it.optString("thumb").takeIf { t -> t.startsWith("https://") } ?: it.optString("url")) to it.optString("url") }
|
||||
|
||||
/** The highest-scored image's URL; for a .png slot only a PNG. */
|
||||
internal fun best(items: JSONArray, pngOnly: Boolean): String? =
|
||||
(0 until items.length()).map { items.getJSONObject(it) }
|
||||
.filter { it.optString("url").startsWith("https://") && (!pngOnly || it.optString("mime") == "image/png" || it.optString("url").endsWith(".png", true)) }
|
||||
.maxByOrNull { it.optInt("score", 0) }?.optString("url")
|
||||
|
||||
/** A GET with the key in its Authorization header; the body as JSON, or null. Logs the path only. */
|
||||
private fun json(key: String, url: String): JSONObject? {
|
||||
val c = URL(url).openConnection() as HttpURLConnection
|
||||
c.connectTimeout = 10_000
|
||||
c.readTimeout = 15_000
|
||||
c.setRequestProperty("User-Agent", "DroidDeck-launcher")
|
||||
c.setRequestProperty("Authorization", "Bearer $key")
|
||||
return try {
|
||||
if (c.responseCode != 200) { Log.w(TAG, "${URL(url).path}: HTTP ${c.responseCode}"); null }
|
||||
else JSONObject(c.inputStream.use { it.readBytes() }.toString(Charsets.UTF_8)).takeIf { it.optBoolean("success", true) }
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "${URL(url).path}: ${e.javaClass.simpleName}"); null
|
||||
} finally {
|
||||
c.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AtomicFile
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Which Steam app a game added in DroidDeck is, kept once per game (by its folder) and used by
|
||||
* everything that asks: its art ([AddedGameArt]) and its Windows components ([WinCompSources]),
|
||||
* so the two never disagree.
|
||||
*
|
||||
* [Certainty.FILES]: the game's own files name the appid (steam_appid.txt, also under
|
||||
* steam_settings/ or a parent folder; an emulator ini; Steam's appmanifest) - certain.
|
||||
* [Certainty.NAME]: Steam's store search returned a game of the same name - a guess.
|
||||
* [Certainty.NONE]: neither; a miss is tried again after a week.
|
||||
*/
|
||||
object SteamMatch {
|
||||
enum class Certainty { FILES, NAME, NONE }
|
||||
|
||||
/** [source]: what gave the appid (GameIdentifier's source, or "store search"). */
|
||||
class Match(val appId: Int?, val certainty: Certainty, val source: String, val checkedAt: Long)
|
||||
|
||||
private const val FILE = "steam-matches.json"
|
||||
private const val RETRY_AFTER_MS = 7L * 24 * 3600 * 1000
|
||||
|
||||
private fun store(context: Context) = AtomicFile(File(context.filesDir, FILE))
|
||||
|
||||
@Synchronized
|
||||
private fun all(context: Context): JSONObject =
|
||||
runCatching { JSONObject(store(context).readFully().toString(Charsets.UTF_8)) }.getOrDefault(JSONObject())
|
||||
|
||||
@Synchronized
|
||||
fun get(context: Context, key: String): Match? = all(context).optJSONObject(key)?.let { o ->
|
||||
Match(
|
||||
o.optInt("appId", 0).takeIf { it > 0 },
|
||||
runCatching { Certainty.valueOf(o.optString("certainty")) }.getOrDefault(Certainty.NONE),
|
||||
o.optString("source"), o.optLong("at"),
|
||||
)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun put(context: Context, key: String, match: Match) {
|
||||
val json = all(context).put(
|
||||
key, JSONObject().put("certainty", match.certainty.name).put("source", match.source).put("at", match.checkedAt)
|
||||
.apply { match.appId?.let { put("appId", it) } },
|
||||
)
|
||||
val atomic = store(context)
|
||||
val out = atomic.startWrite()
|
||||
try { out.write(json.toString().toByteArray()); atomic.finishWrite(out) } catch (e: Exception) { atomic.failWrite(out) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The match for the game in [key] (its folder), [exe] its exe and [name] its title: its files
|
||||
* first, always read again; else the one kept (a name match stays, a miss for a week); else
|
||||
* [legacy], an appid an earlier build's art lookup kept; else [search] on the name, when given.
|
||||
* Blocking ([search] asks the network).
|
||||
*/
|
||||
fun resolve(context: Context, key: String, exe: File, name: String, legacy: File? = null, search: ((String) -> Int?)? = null): Match {
|
||||
val now = System.currentTimeMillis()
|
||||
val identity = runCatching { GameIdentifier.identify(exe) }.getOrNull()
|
||||
identity?.appId?.let { id ->
|
||||
return Match(id, Certainty.FILES, identity.source.name.lowercase(), now).also { put(context, key, it) }
|
||||
}
|
||||
val known = get(context, key)
|
||||
if (known != null && known.certainty == Certainty.NAME) return known
|
||||
if (known != null && known.certainty == Certainty.NONE && now - known.checkedAt < RETRY_AFTER_MS) return known
|
||||
legacy?.takeIf { it.isFile }?.let { runCatching { it.readText().trim().toInt() }.getOrNull() }?.takeIf { it > 0 }?.let { id ->
|
||||
return Match(id, Certainty.NAME, "store search", now).also { put(context, key, it) }
|
||||
}
|
||||
if (search == null) return known?.takeIf { it.certainty != Certainty.FILES } ?: Match(null, Certainty.NONE, "", 0)
|
||||
val id = runCatching { search(name) }.getOrNull()
|
||||
val match = if (id != null && id > 0) Match(id, Certainty.NAME, "store search", now) else Match(null, Certainty.NONE, "", now)
|
||||
put(context, key, match)
|
||||
return match
|
||||
}
|
||||
}
|
||||
@@ -1,334 +0,0 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import android.util.Log
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import org.json.JSONObject
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.DataInputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.security.SecureRandom
|
||||
import java.util.zip.GZIPInputStream
|
||||
import javax.net.ssl.SSLSocket
|
||||
import javax.net.ssl.SSLSocketFactory
|
||||
|
||||
/**
|
||||
* Steam's own app info (PICS) for apps the client in the runtime has never seen, asked of Steam
|
||||
* anonymously: a connection to one of Steam's connection managers (the list from Steam's public
|
||||
* ISteamDirectory/GetCMListForConnect, the WebSocket transport Steam's web clients use), an
|
||||
* anonymous logon - no account, nothing of the user's Steam sign-in - a request for the apps'
|
||||
* access tokens and then their product info, the app KeyValues read for their Steamworks Shared
|
||||
* depots and name, and the connection closed. What steamcmd's anonymous `app_info_print` sees.
|
||||
*/
|
||||
object SteamPics {
|
||||
private const val TAG = "SteamPics"
|
||||
private const val CM_LIST = "https://api.steampowered.com/ISteamDirectory/GetCMListForConnect/v1/?cellid=0&format=json"
|
||||
/** At most this many apps per request. */
|
||||
const val BATCH = 25
|
||||
private const val PROTO = 0x80000000.toInt()
|
||||
private const val EMSG_MULTI = 1
|
||||
private const val EMSG_LOGON = 5514
|
||||
private const val EMSG_LOGON_RESPONSE = 751
|
||||
private const val EMSG_LOGOFF = 706
|
||||
private const val EMSG_TOKENS_REQUEST = 8905
|
||||
private const val EMSG_TOKENS_RESPONSE = 8906
|
||||
private const val EMSG_INFO_REQUEST = 8903
|
||||
private const val EMSG_INFO_RESPONSE = 8904
|
||||
private const val PROTOCOL_VERSION = 65580
|
||||
/** An anonymous user's SteamID: universe Public, account type AnonUser, instance 0. */
|
||||
private const val ANON_STEAMID = (1L shl 56) or (10L shl 52)
|
||||
|
||||
/**
|
||||
* [appIds]' info, one connection for all of them in batches of [BATCH]: each known app's
|
||||
* info, null for an app Steam does not know. Null when Steam could not be reached. Blocking.
|
||||
*/
|
||||
fun lookup(appIds: List<Int>): Map<Int, SteamAppInfo.Info?>? {
|
||||
if (appIds.isEmpty()) return emptyMap()
|
||||
val host = runCatching { cmHost() }.getOrNull() ?: return null
|
||||
return try {
|
||||
Wss(host, 20_000).use { ws ->
|
||||
ws.send(message(EMSG_LOGON, header(ANON_STEAMID, 0), Proto().varint(1, PROTOCOL_VERSION.toLong()).varint(7, 16).string(6, "english").bytes()))
|
||||
var steamId = 0L
|
||||
var session = 0
|
||||
// The logon's answer (other messages before it are passed over).
|
||||
loop@ while (true) for ((emsg, hdr, body) in messages(ws.receive() ?: return null)) if (emsg == EMSG_LOGON_RESPONSE) {
|
||||
val h = fields(hdr); val b = fields(body)
|
||||
if ((b[1]?.firstOrNull() as? Long ?: 0L) != 1L) { Log.w(TAG, "anonymous logon refused: ${b[1]}"); return null }
|
||||
steamId = h[1]?.firstOrNull() as? Long ?: 0L
|
||||
session = (h[2]?.firstOrNull() as? Long ?: 0L).toInt()
|
||||
break@loop
|
||||
}
|
||||
val out = HashMap<Int, SteamAppInfo.Info?>()
|
||||
for (batch in batches(appIds)) {
|
||||
val tokens = HashMap<Int, Long>()
|
||||
ws.send(message(EMSG_TOKENS_REQUEST, header(steamId, session), Proto().apply { batch.forEach { varint(2, it.toLong()) } }.bytes()))
|
||||
tokens@ while (true) for ((emsg, _, body) in messages(ws.receive() ?: return null)) if (emsg == EMSG_TOKENS_RESPONSE) {
|
||||
for (t in fields(body)[3].orEmpty()) {
|
||||
val f = fields(t as ByteArray)
|
||||
val id = (f[1]?.firstOrNull() as? Long)?.toInt() ?: continue
|
||||
tokens[id] = f[2]?.firstOrNull() as? Long ?: 0L
|
||||
}
|
||||
break@tokens
|
||||
}
|
||||
ws.send(message(EMSG_INFO_REQUEST, header(steamId, session), Proto().apply {
|
||||
batch.forEach { id -> bytes(2, Proto().varint(1, id.toLong()).apply { tokens[id]?.takeIf { it != 0L }?.let { varint(2, it) } }.bytes()) }
|
||||
varint(3, 0)
|
||||
}.bytes()))
|
||||
var pending = true
|
||||
while (pending) for ((emsg, _, body) in messages(ws.receive() ?: return null)) if (emsg == EMSG_INFO_RESPONSE) {
|
||||
val r = parseInfoResponse(body)
|
||||
out.putAll(r.apps)
|
||||
r.unknown.forEach { out[it] = null }
|
||||
pending = r.pending
|
||||
}
|
||||
batch.forEach { if (it !in out) out[it] = null }
|
||||
}
|
||||
runCatching { ws.send(message(EMSG_LOGOFF, header(steamId, session), ByteArray(0))) }
|
||||
Log.i(TAG, "steam pics: ${out.size} apps, ${out.values.count { it?.sharedDepots?.isNotEmpty() == true }} with shared redists")
|
||||
out
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "steam pics: ${e.javaClass.simpleName}: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** [appIds] in requests of at most [BATCH], each once. */
|
||||
fun batches(appIds: List<Int>): List<List<Int>> = appIds.distinct().chunked(BATCH)
|
||||
|
||||
/** A connection manager for the WebSocket transport, least loaded first as Steam lists them. */
|
||||
private fun cmHost(): String? {
|
||||
val c = URL(CM_LIST).openConnection() as HttpURLConnection
|
||||
c.connectTimeout = 10_000; c.readTimeout = 10_000
|
||||
val body = try { c.inputStream.bufferedReader().readText() } finally { c.disconnect() }
|
||||
val list = JSONObject(body).optJSONObject("response")?.optJSONArray("serverlist") ?: return null
|
||||
return (0 until list.length()).mapNotNull { list.optJSONObject(it) }
|
||||
.firstOrNull { it.optString("type") == "websockets" }?.optString("endpoint")?.substringBefore(':')
|
||||
}
|
||||
|
||||
// ---- the product info answer ----
|
||||
|
||||
class InfoResponse(val apps: Map<Int, SteamAppInfo.Info>, val unknown: List<Int>, val pending: Boolean)
|
||||
|
||||
/** A CMsgClientPICSProductInfoResponse: each app's KeyValues read, the unknown ones, whether more follows. */
|
||||
@VisibleForTesting
|
||||
internal fun parseInfoResponse(body: ByteArray): InfoResponse {
|
||||
val f = fields(body)
|
||||
val apps = HashMap<Int, SteamAppInfo.Info>()
|
||||
for (a in f[1].orEmpty()) {
|
||||
val app = fields(a as ByteArray)
|
||||
val id = (app[1]?.firstOrNull() as? Long)?.toInt() ?: continue
|
||||
val buffer = app[5]?.firstOrNull() as? ByteArray ?: continue
|
||||
val text = String(buffer, Charsets.UTF_8).trimEnd('\u0000')
|
||||
infoFromKeyValues(text)?.let { apps[id] = it }
|
||||
}
|
||||
val unknown = f[2].orEmpty().mapNotNull { (it as? Long)?.toInt() } + f[2].orEmpty().filterIsInstance<ByteArray>().flatMap { packed(it) }
|
||||
return InfoResponse(apps, unknown, (f[6]?.firstOrNull() as? Long ?: 0L) != 0L)
|
||||
}
|
||||
|
||||
/** An app's KeyValues text ("appinfo" { "common" {...} "depots" {...} }) to its name and shared depots. */
|
||||
@VisibleForTesting
|
||||
internal fun infoFromKeyValues(text: String): SteamAppInfo.Info? {
|
||||
// The buffer ends in a NUL, kept or not by whoever handed it over.
|
||||
val tree = runCatching { KeyValues(text.trimEnd('\u0000', ' ', '\n', '\r', '\t')).read() }.getOrNull() ?: return null
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val app = tree["appinfo"] as? Map<String, Any> ?: return null
|
||||
return SteamAppInfo.infoOf(app)
|
||||
}
|
||||
|
||||
/** Valve's text KeyValues: quoted keys and values, nested braces. */
|
||||
private class KeyValues(private val s: String) {
|
||||
private var i = 0
|
||||
fun read(): Map<String, Any> = map(top = true)
|
||||
private fun map(top: Boolean): Map<String, Any> {
|
||||
val out = LinkedHashMap<String, Any>()
|
||||
while (true) {
|
||||
skip()
|
||||
if (i >= s.length) { if (top) return out else throw IOException("unclosed") }
|
||||
if (s[i] == '}') { i++; return out }
|
||||
val key = token()
|
||||
skip()
|
||||
if (i < s.length && s[i] == '{') { i++; out[key] = map(top = false) } else out[key] = token()
|
||||
}
|
||||
}
|
||||
private fun skip() {
|
||||
while (i < s.length) {
|
||||
if (s[i].isWhitespace() || s[i] == '\u0000') i++
|
||||
else if (s.startsWith("//", i)) { while (i < s.length && s[i] != '\n') i++ }
|
||||
else break
|
||||
}
|
||||
}
|
||||
private fun token(): String {
|
||||
if (i < s.length && s[i] == '"') {
|
||||
i++
|
||||
val b = StringBuilder()
|
||||
while (i < s.length && s[i] != '"') {
|
||||
if (s[i] == '\\' && i + 1 < s.length) { i++; b.append(when (s[i]) { 'n' -> '\n'; 't' -> '\t'; else -> s[i] }) } else b.append(s[i])
|
||||
i++
|
||||
}
|
||||
i++
|
||||
return b.toString()
|
||||
}
|
||||
val start = i
|
||||
while (i < s.length && !s[i].isWhitespace() && s[i] != '{' && s[i] != '}') i++
|
||||
if (start == i) throw IOException("token expected at $i")
|
||||
return s.substring(start, i)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- messages ----
|
||||
|
||||
private fun header(steamId: Long, session: Int) = Proto().fixed64(1, steamId).varint(2, session.toLong()).bytes()
|
||||
|
||||
private fun message(emsg: Int, header: ByteArray, body: ByteArray): ByteArray =
|
||||
ByteBuffer.allocate(8 + header.size + body.size).order(ByteOrder.LITTLE_ENDIAN)
|
||||
.putInt(emsg or PROTO).putInt(header.size).put(header).put(body).array()
|
||||
|
||||
/** One message off the wire as (emsg, header, body); a Multi unpacked (gunzipped when it says so) into the ones inside. */
|
||||
@VisibleForTesting
|
||||
internal fun messages(data: ByteArray): List<Triple<Int, ByteArray, ByteArray>> {
|
||||
if (data.size < 8) return emptyList()
|
||||
val b = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN)
|
||||
val raw = b.int
|
||||
if (raw and PROTO == 0) return emptyList()
|
||||
val emsg = raw and PROTO.inv()
|
||||
val hl = b.int
|
||||
if (hl < 0 || 8 + hl > data.size) return emptyList()
|
||||
val header = data.copyOfRange(8, 8 + hl)
|
||||
val body = data.copyOfRange(8 + hl, data.size)
|
||||
if (emsg != EMSG_MULTI) return listOf(Triple(emsg, header, body))
|
||||
val f = fields(body)
|
||||
var inner = f[2]?.firstOrNull() as? ByteArray ?: return emptyList()
|
||||
if ((f[1]?.firstOrNull() as? Long ?: 0L) > 0) inner = GZIPInputStream(ByteArrayInputStream(inner)).use { it.readBytes() }
|
||||
val out = ArrayList<Triple<Int, ByteArray, ByteArray>>()
|
||||
val ib = ByteBuffer.wrap(inner).order(ByteOrder.LITTLE_ENDIAN)
|
||||
while (ib.remaining() >= 4) {
|
||||
val len = ib.int
|
||||
if (len < 0 || len > ib.remaining()) break
|
||||
val one = ByteArray(len).also { ib.get(it) }
|
||||
out.addAll(messages(one))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- protobuf, the little needed ----
|
||||
|
||||
/** Writes protobuf fields. */
|
||||
@VisibleForTesting
|
||||
internal class Proto {
|
||||
private val out = ByteArrayOutputStream()
|
||||
private fun raw(v: Long) { var x = v; while (true) { val b = (x and 0x7F).toInt(); x = x ushr 7; if (x == 0L) { out.write(b); return }; out.write(b or 0x80) } }
|
||||
fun varint(field: Int, v: Long) = apply { raw((field shl 3).toLong()); raw(v) }
|
||||
fun fixed64(field: Int, v: Long) = apply { raw(((field shl 3) or 1).toLong()); for (k in 0 until 8) out.write(((v ushr (8 * k)) and 0xFF).toInt()) }
|
||||
fun bytes(field: Int, b: ByteArray) = apply { raw(((field shl 3) or 2).toLong()); raw(b.size.toLong()); out.write(b) }
|
||||
fun string(field: Int, s: String) = bytes(field, s.toByteArray())
|
||||
fun bytes(): ByteArray = out.toByteArray()
|
||||
}
|
||||
|
||||
/** Reads protobuf fields: number to values (Long for varint and fixed, ByteArray for length-delimited). */
|
||||
@VisibleForTesting
|
||||
internal fun fields(b: ByteArray): Map<Int, List<Any>> {
|
||||
val out = HashMap<Int, MutableList<Any>>()
|
||||
var i = 0
|
||||
fun varint(): Long { var v = 0L; var s = 0; while (true) { val c = b[i++].toInt() and 0xFF; v = v or ((c and 0x7F).toLong() shl s); s += 7; if (c < 0x80) return v } }
|
||||
while (i < b.size) {
|
||||
val key = varint()
|
||||
val field = (key ushr 3).toInt()
|
||||
val value: Any = when ((key and 7).toInt()) {
|
||||
0 -> varint()
|
||||
1 -> { var v = 0L; for (k in 0 until 8) v = v or ((b[i + k].toLong() and 0xFF) shl (8 * k)); i += 8; v }
|
||||
5 -> { var v = 0L; for (k in 0 until 4) v = v or ((b[i + k].toLong() and 0xFF) shl (8 * k)); i += 4; v }
|
||||
2 -> { val n = varint().toInt(); val v = b.copyOfRange(i, i + n); i += n; v }
|
||||
else -> throw IOException("wire type ${key and 7}")
|
||||
}
|
||||
out.getOrPut(field) { ArrayList() }.add(value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun packed(b: ByteArray): List<Int> {
|
||||
val out = ArrayList<Int>(); var i = 0
|
||||
while (i < b.size) { var v = 0L; var s = 0; while (true) { val c = b[i++].toInt() and 0xFF; v = v or ((c and 0x7F).toLong() shl s); s += 7; if (c < 0x80) break }; out.add(v.toInt()) }
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- WebSocket over TLS, binary frames ----
|
||||
|
||||
private class Wss(host: String, timeoutMs: Int) : AutoCloseable {
|
||||
// Made with the host name, so it goes out as SNI.
|
||||
private val socket: SSLSocket = (SSLSocketFactory.getDefault().createSocket(host, 443) as SSLSocket).apply { soTimeout = timeoutMs }
|
||||
private val out: OutputStream
|
||||
private val input: DataInputStream
|
||||
|
||||
init {
|
||||
// Host name checked against the certificate, as HTTPS does.
|
||||
socket.sslParameters = socket.sslParameters.apply { endpointIdentificationAlgorithm = "HTTPS" }
|
||||
socket.startHandshake()
|
||||
out = socket.outputStream
|
||||
input = DataInputStream(socket.inputStream)
|
||||
val key = ByteArray(16).also { SecureRandom().nextBytes(it) }
|
||||
out.write(("GET /cmsocket/ HTTP/1.1\r\nHost: $host\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" +
|
||||
"Sec-WebSocket-Key: ${android.util.Base64.encodeToString(key, android.util.Base64.NO_WRAP)}\r\nSec-WebSocket-Version: 13\r\n\r\n").toByteArray())
|
||||
out.flush()
|
||||
val status = line(input) ?: throw IOException("no upgrade response")
|
||||
if (!status.contains(" 101 ")) throw IOException("upgrade refused: $status")
|
||||
while (true) { val l = line(input) ?: break; if (l.isEmpty()) break }
|
||||
}
|
||||
|
||||
private fun line(s: InputStream): String? {
|
||||
val buf = ByteArrayOutputStream()
|
||||
while (true) {
|
||||
val b = s.read()
|
||||
if (b < 0) return if (buf.size() == 0) null else buf.toString("UTF-8")
|
||||
if (b == '\n'.code) return buf.toString("UTF-8").trimEnd('\r')
|
||||
buf.write(b)
|
||||
}
|
||||
}
|
||||
|
||||
fun send(payload: ByteArray) {
|
||||
val mask = ByteArray(4).also { SecureRandom().nextBytes(it) }
|
||||
val frame = ByteArrayOutputStream()
|
||||
frame.write(0x82)
|
||||
when {
|
||||
payload.size < 126 -> frame.write(0x80 or payload.size)
|
||||
payload.size < 65536 -> { frame.write(0x80 or 126); frame.write(payload.size ushr 8); frame.write(payload.size and 0xFF) }
|
||||
else -> { frame.write(0x80 or 127); for (k in 7 downTo 0) frame.write(((payload.size.toLong() ushr (8 * k)) and 0xFF).toInt()) }
|
||||
}
|
||||
frame.write(mask)
|
||||
for (k in payload.indices) frame.write((payload[k].toInt() xor mask[k % 4].toInt()) and 0xFF)
|
||||
out.write(frame.toByteArray()); out.flush()
|
||||
}
|
||||
|
||||
/** The next binary message (fragments joined); null on close. */
|
||||
fun receive(): ByteArray? {
|
||||
val message = ByteArrayOutputStream()
|
||||
while (true) {
|
||||
val b0 = input.read(); if (b0 < 0) return null
|
||||
val b1 = input.read(); if (b1 < 0) return null
|
||||
val fin = b0 and 0x80 != 0
|
||||
val opcode = b0 and 0x0F
|
||||
var len = (b1 and 0x7F).toLong()
|
||||
if (len == 126L) len = input.readUnsignedShort().toLong() else if (len == 127L) len = input.readLong()
|
||||
if (len > 32L * 1024 * 1024) throw IOException("frame too large")
|
||||
val data = ByteArray(len.toInt()).also { input.readFully(it) }
|
||||
when (opcode) {
|
||||
0x8 -> return null
|
||||
0x9 -> { out.write(byteArrayOf(0x8A.toByte(), 0x80.toByte(), 0, 0, 0, 0)); out.flush() }
|
||||
0x2, 0x0 -> { message.write(data); if (fin) return message.toByteArray() }
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
runCatching { out.write(byteArrayOf(0x88.toByte(), 0x80.toByte(), 0, 0, 0, 0)); out.flush() }
|
||||
runCatching { socket.close() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AtomicFile
|
||||
import android.util.Log
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoreGameSidecar
|
||||
import com.droiddeck.launcher.stores.epic.EpicApiClient
|
||||
import com.droiddeck.launcher.stores.epic.EpicCredentialStore
|
||||
import com.droiddeck.launcher.stores.epic.EpicDownloadManager
|
||||
import com.droiddeck.launcher.stores.gog.GogAuth
|
||||
import com.droiddeck.launcher.stores.gog.GogDownloadManager
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* For a GOG or Epic game installed before its sidecar kept the store's list (GOG's build
|
||||
* `dependencies`, Epic's manifest prerequisite): the list read once from the store's build
|
||||
* manifest with the saved sign-in - the manifest alone, never a game file - and kept in the sidecar
|
||||
* the way an install keeps it. Runs on the components' worker ([AutoComponents]). Without a
|
||||
* sign-in or a network it is skipped quietly and tried again a week later.
|
||||
*/
|
||||
object StoreListBackfill {
|
||||
private const val TAG = "StoreListBackfill"
|
||||
private const val FILE = "store-list-backfill.json"
|
||||
private const val RETRY_AFTER_MS = 7L * 24 * 3600 * 1000
|
||||
|
||||
/** The store's list for the game in a sidecar: GOG's dependency ids, or Epic's prerequisite {name, path}; null when it could not be had. */
|
||||
fun interface Fetcher {
|
||||
fun fetch(context: Context, sidecar: StoreGameSidecar): Map<String, String>?
|
||||
}
|
||||
|
||||
/** The store itself, with the saved sign-in. */
|
||||
internal val storeFetcher = Fetcher { context, sidecar -> fromStore(context, sidecar) }
|
||||
|
||||
@VisibleForTesting internal var fetcher: Fetcher = storeFetcher
|
||||
|
||||
/** Whether [sidecar] was written before the list was kept. */
|
||||
/**
|
||||
* Whether [sidecar] has no list read whole: written before the list was kept, or by a build
|
||||
* that could have written an empty one for a manifest it did not read right.
|
||||
*/
|
||||
fun needs(sidecar: StoreGameSidecar): Boolean =
|
||||
(sidecar.store == Store.GOG || sidecar.store == Store.EPIC) && WinCompSources.LIST_READ !in sidecar.extra
|
||||
|
||||
/**
|
||||
* The list for the game in [folder], fetched and kept when its sidecar lacks one and it was not
|
||||
* tried in the past week. True when the sidecar changed. Blocking (network).
|
||||
*/
|
||||
@Synchronized
|
||||
fun fill(context: Context, folder: File): Boolean {
|
||||
val sidecar = StoreGameSidecar.read(folder)?.takeIf { it.isInstalled && needs(it) } ?: return false
|
||||
val tries = runCatching { JSONObject(AtomicFile(File(context.filesDir, FILE)).readFully().toString(Charsets.UTF_8)) }.getOrDefault(JSONObject())
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - tries.optLong(folder.path, 0L) < RETRY_AFTER_MS) return false
|
||||
// Offline is no try: it waits for a network rather than a week.
|
||||
if (!AutoComponents.online(context)) return false
|
||||
val list = runCatching { fetcher.fetch(context, sidecar) }.getOrNull()
|
||||
if (list == null) {
|
||||
tries.put(folder.path, now)
|
||||
val atomic = AtomicFile(File(context.filesDir, FILE))
|
||||
val out = atomic.startWrite()
|
||||
try { out.write(tries.toString().toByteArray()); atomic.finishWrite(out) } catch (e: Exception) { atomic.failWrite(out) }
|
||||
Log.i(TAG, "${sidecar.title}: the ${sidecar.store.label} list could not be had; again in a week")
|
||||
return false
|
||||
}
|
||||
// Read fresh and written whole, as an install writes it: only the list is added.
|
||||
val current = StoreGameSidecar.read(folder) ?: return false
|
||||
current.copy(extra = current.extra + list + (WinCompSources.LIST_READ to "1")).write(folder)
|
||||
Log.i(TAG, "backfill: ${sidecar.title} " + when (sidecar.store) {
|
||||
Store.GOG -> "gog deps=[${list[WinCompSources.GOG_DEPENDENCIES].orEmpty()}]"
|
||||
else -> "epic prereq=" + (list[WinCompSources.EPIC_PREREQ_NAME].orEmpty().ifEmpty { list[WinCompSources.EPIC_PREREQ_PATH].orEmpty() }.ifEmpty { "none" })
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
private fun fromStore(context: Context, sidecar: StoreGameSidecar): Map<String, String>? {
|
||||
return when (sidecar.store) {
|
||||
Store.GOG -> {
|
||||
val token = GogAuth.validToken(context) ?: return null
|
||||
GogDownloadManager.fetchDependencies(sidecar.id, sidecar.installVersion, token)
|
||||
?.let { mapOf(WinCompSources.GOG_DEPENDENCIES to it.joinToString(",")) }
|
||||
}
|
||||
Store.EPIC -> {
|
||||
val token = EpicCredentialStore.getValidAccessToken(context) ?: return null
|
||||
val namespace = sidecar.extra["namespace"].orEmpty()
|
||||
val catalogItemId = sidecar.extra["catalogItemId"].orEmpty()
|
||||
if (namespace.isEmpty() || catalogItemId.isEmpty()) return null
|
||||
val json = EpicApiClient.getManifestApiJson(token, namespace, catalogItemId, sidecar.extra["appName"] ?: sidecar.id) ?: return null
|
||||
EpicDownloadManager.fetchPrereq(json)?.let { (name, path) ->
|
||||
mapOf(WinCompSources.EPIC_PREREQ_NAME to name, WinCompSources.EPIC_PREREQ_PATH to path)
|
||||
}
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import android.content.Context
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoreGameSidecar
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Which Windows components a game needs, from the list its source keeps - the source badge decides
|
||||
* which list is trusted:
|
||||
*
|
||||
* - **Steam**: the Steamworks Shared redistributables Steam installs with the game (its
|
||||
* appmanifest's SharedDepots, else the client's app info) - turned on by themselves.
|
||||
* - **GOG**: the build manifest's `dependencies`, kept in the sidecar at install - on by themselves.
|
||||
* - **Epic Games**: the manifest's prerequisite (usually UE4/UE5's prerequisite setup: VC++ and the
|
||||
* DirectX June 2010 runtime), kept in the sidecar at install - on by themselves.
|
||||
* - **Amazon Games**: fuel.json's PostInstall installers - on by themselves.
|
||||
* - **Custom** whose files name a Steam appid ([SteamMatch], certain): Steam's list for that app -
|
||||
* on by themselves. Matched by name only: the same list as Recommended. No match: the folder scan
|
||||
* ([DependencyDetector]) as Recommended.
|
||||
*
|
||||
* Every store entry is mapped to catalog names here, in one place; one that maps to nothing is a
|
||||
* suggestion under its own name. The folder scan is offered as Recommended for every game.
|
||||
*/
|
||||
object WinCompSources {
|
||||
/** Whose list a finding came from; the reason line says it. */
|
||||
enum class Origin { STEAM, GOG, EPIC, AMAZON, STEAM_NAME, FOLDER }
|
||||
|
||||
/**
|
||||
* One thing a list names: [component] the catalog name it maps to (null: none, a suggestion),
|
||||
* [original] what the list called it, [auto] whether it is turned on by itself, [args] what
|
||||
* the reason line names (Steam: the label, the game, the appid).
|
||||
*/
|
||||
data class Finding(val component: String?, val original: String, val origin: Origin, val auto: Boolean, val args: List<String> = emptyList())
|
||||
|
||||
// ---- mapping tables: a store's names -> catalog components ----
|
||||
|
||||
private val VC_YEAR = mapOf(
|
||||
"2005" to "vcredist2005", "2008" to "vcredist2008", "2010" to "vcredist2010", "2012" to "vcredist2012",
|
||||
"2013" to "vcredist2013", "2015" to "vcredist2015", "2017" to "vcredist2015", "2019" to "vcredist2019", "2022" to "vcredist2022",
|
||||
)
|
||||
private val DOTNET = mapOf(
|
||||
"35" to "dotnet35", "40" to "dotnet40", "45" to "dotnet45", "451" to "dotnet452", "452" to "dotnet452",
|
||||
"46" to "dotnet46", "461" to "dotnet46", "462" to "dotnet46", "47" to "dotnet472", "471" to "dotnet472",
|
||||
"472" to "dotnet472", "48" to "dotnet48", "481" to "dotnet48",
|
||||
)
|
||||
|
||||
/**
|
||||
* A GOG dependency id ("MSVC2017_x64", "DirectX", "DOTNET48", "PhysX", "OpenAL", "XNA4") to its
|
||||
* components; empty for one that is none of ours (ISI scripts, GOG's own helpers).
|
||||
*/
|
||||
fun gog(id: String): List<String> {
|
||||
val key = id.uppercase(Locale.US).replace(Regex("[^A-Z0-9]"), "")
|
||||
Regex("""^(?:MSVC|VCREDIST|VC)(\d{4})""").find(key)?.let { m -> return listOfNotNull(VC_YEAR[m.groupValues[1]]) }
|
||||
Regex("""^(?:DOTNET|NETFRAMEWORK|DOTNETFRAMEWORK)(\d{2,3})""").find(key)?.let { m -> return listOfNotNull(DOTNET[m.groupValues[1]]) }
|
||||
return when {
|
||||
key.startsWith("DIRECTX") || key.startsWith("DX9") || key == "DXSETUP" || key.startsWith("D3DX") -> listOf("d3dx9")
|
||||
key.startsWith("PHYSX") -> listOf("physx")
|
||||
key.startsWith("OPENAL") -> listOf("oalinst")
|
||||
key == "XNA31" || key == "XNA3" -> listOf("xna31")
|
||||
key.startsWith("XNA") -> listOf("xna40")
|
||||
key.startsWith("XLIVE") || key.startsWith("GFWL") -> listOf("XLiveRedist")
|
||||
key.startsWith("XACT") -> listOf("xact")
|
||||
else -> emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/** Unreal's prerequisite setup (UE4PrereqSetup_x64.exe, UEPrereqSetup_x64.exe): VC++ 2015-2022 and DirectX June 2010. */
|
||||
private val UE_PREREQ = Regex("""(?i)ue\d?prereqsetup""")
|
||||
|
||||
/** An Epic prerequisite ([path] the installer it runs, [name] what it is called) to its components. */
|
||||
fun epic(path: String, name: String): List<String> = when {
|
||||
UE_PREREQ.containsMatchIn(path) || name.contains("UE4 Prerequisites", true) || name.contains("UE5 Prerequisites", true) ||
|
||||
name.contains("Unreal Engine Prerequisites", true) -> listOf("vcredist2022", "d3dx9")
|
||||
else -> DependencyDetector.componentsFor(path).ifEmpty { if (path.isEmpty()) gog(name) else emptyList() }
|
||||
}
|
||||
|
||||
/** An installer a store runs after an install (Amazon's PostInstall "Command") to its components. */
|
||||
fun installer(path: String): List<String> = if (UE_PREREQ.containsMatchIn(path)) listOf("vcredist2022", "d3dx9") else DependencyDetector.componentsFor(path)
|
||||
|
||||
/** Steamworks Shared depot ids to (component, Steam's label). */
|
||||
fun steamDepots(ids: List<String>): List<Pair<String, String>> = ids.mapNotNull { SteamRedists.DEPOTS[it] }.distinctBy { it.first }
|
||||
|
||||
// ---- what each store left at install ----
|
||||
|
||||
/** Sidecar keys the store installs fill. */
|
||||
const val GOG_DEPENDENCIES = "dependencies"
|
||||
const val EPIC_PREREQ_PATH = "prereqPath"
|
||||
const val EPIC_PREREQ_NAME = "prereqName"
|
||||
/** Set beside the list when it was read whole (an empty list then means the store lists none). */
|
||||
const val LIST_READ = "listRead"
|
||||
|
||||
/** fuel.json's PostInstall commands. */
|
||||
fun amazonPostInstall(folder: File): List<String> = runCatching {
|
||||
val arr = JSONObject(File(folder, "fuel.json").readText()).optJSONArray("PostInstall") ?: return emptyList()
|
||||
(0 until arr.length()).mapNotNull { arr.optJSONObject(it)?.optString("Command")?.takeIf { c -> c.isNotEmpty() } }
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
// ---- per game ----
|
||||
|
||||
/** Steam's list for [appId]: the local appmanifest's SharedDepots, else the client's app info. */
|
||||
fun steamList(context: Context, gameDir: File?, appId: Int, gameName: String, auto: Boolean): List<Finding> {
|
||||
val local = SteamRedists.detect(gameDir, appId)
|
||||
val pairs = if (local.isNotEmpty()) local.map { it.componentName to it.label }
|
||||
else SteamAppInfo.info(context, appId)?.let { steamDepots(it.sharedDepots) }.orEmpty()
|
||||
return pairs.map { (component, label) ->
|
||||
Finding(component, label, if (auto) Origin.STEAM else Origin.STEAM_NAME, auto, listOf(label, gameName, appId.toString()))
|
||||
}
|
||||
}
|
||||
|
||||
/** Everything known for [game], blocking (files; the client's app info; no network). */
|
||||
fun forGame(context: Context, game: Library.SteamGame): List<Finding> {
|
||||
val folder = game.gameFiles
|
||||
val out = ArrayList<Finding>()
|
||||
val store = Store.byId(game.source)
|
||||
val sidecar = folder?.let { StoreGameSidecar.read(it) }
|
||||
when {
|
||||
game.library != Library.ADDED -> if (game.appId > 0) out += steamList(context, folder, game.appId, game.name, auto = true)
|
||||
store == Store.GOG -> sidecar?.extra?.get(GOG_DEPENDENCIES)?.split(',')?.map { it.trim() }?.filter { it.isNotEmpty() }?.forEach { id ->
|
||||
val parts = gog(id)
|
||||
if (parts.isEmpty()) out += Finding(null, id, Origin.GOG, false)
|
||||
parts.forEach { out += Finding(it, id, Origin.GOG, true) }
|
||||
}
|
||||
store == Store.EPIC -> {
|
||||
val path = sidecar?.extra?.get(EPIC_PREREQ_PATH).orEmpty()
|
||||
val name = sidecar?.extra?.get(EPIC_PREREQ_NAME).orEmpty()
|
||||
if (path.isNotEmpty() || name.isNotEmpty()) {
|
||||
val parts = epic(path, name)
|
||||
val original = name.ifEmpty { path.substringAfterLast('/') }
|
||||
if (parts.isEmpty()) out += Finding(null, original, Origin.EPIC, false)
|
||||
parts.forEach { out += Finding(it, original, Origin.EPIC, true) }
|
||||
}
|
||||
}
|
||||
store == Store.AMAZON -> folder?.let { amazonPostInstall(it) }?.forEach { command ->
|
||||
val parts = installer(command)
|
||||
val original = command.replace('\\', '/').substringAfterLast('/')
|
||||
if (parts.isEmpty()) out += Finding(null, original, Origin.AMAZON, false)
|
||||
parts.forEach { out += Finding(it, original, Origin.AMAZON, true) }
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
// A game added in DroidDeck (Custom, or a store's) that is a Steam app: Steam's list for it,
|
||||
// on by itself when its files say so, else only recommended.
|
||||
if (game.library == Library.ADDED && folder != null) SteamMatch.get(context, folder.path)?.let { match ->
|
||||
val id = match.appId ?: return@let
|
||||
when (match.certainty) {
|
||||
SteamMatch.Certainty.FILES -> out += steamList(context, folder, id, game.name, auto = true)
|
||||
SteamMatch.Certainty.NAME -> out += steamList(context, folder, id, game.name, auto = false)
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
// The folder scan, for every game: what it bundles, as Recommended.
|
||||
folder?.let { DependencyDetector.detect(it) }?.forEach { rec ->
|
||||
out += Finding(rec.componentName, rec.reason, Origin.FOLDER, false, listOf(rec.reason, rec.kind.name))
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ import java.util.zip.ZipInputStream;
|
||||
/**
|
||||
* Imported LINUX Vulkan drivers: glibc Turnip ICDs ({@code Turnip-<tag>[-variant]-Linux.zip} from
|
||||
* Banners-Turnip) for the runtime - the gamescope session that runs Valve's native ARM64 Steam
|
||||
* client, and the KDE Plasma desktop beside it. This is the driver that DRAWS there: the client's own UI
|
||||
* client, and the labwc desktop beside it. This is the driver that DRAWS there: the client's own UI
|
||||
* (OpenGL through the runtime's Zink), every game the client launches (D3D through Proton's
|
||||
* DXVK/VKD3D) and everything on the desktop. Putting the frame on the screen stays the Android
|
||||
* driver's job, in the app's compositor ({@link TurnipDriver}).
|
||||
|
||||
@@ -168,7 +168,7 @@ object Lossless {
|
||||
val libraries = listOfNotNull(
|
||||
File(LinuxRuntime.rootDir(context), "root/.local/share/Steam"),
|
||||
GameStorage.effective(context)?.path?.let(::File),
|
||||
) + com.droiddeck.launcher.frontend.AddedGames.roots(context).map { it.host }
|
||||
) + SessionPrefs.addedGamesDirs(context).map(::File)
|
||||
return libraries.map { File(it, STEAM_DLL) }.filter { it.isFile }.maxByOrNull { it.lastModified() }
|
||||
}
|
||||
|
||||
|
||||
@@ -10,23 +10,16 @@ import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.selection.toggleable
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.ui.Alignment
|
||||
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.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -47,7 +40,6 @@ class ControllerEditorActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
requestedOrientation = com.droiddeck.launcher.session.SessionPrefs.orientation(this)
|
||||
immersive()
|
||||
val root = FrameLayout(this)
|
||||
root.setBackgroundColor(0xFF0B0F14.toInt())
|
||||
@@ -56,27 +48,19 @@ class ControllerEditorActivity : ComponentActivity() {
|
||||
val bar = ComposeView(this).apply {
|
||||
setContent {
|
||||
DroidDeckTheme {
|
||||
var snap by remember { mutableStateOf(ControllerPrefs.read(context).snap) }
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(5.dp),
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.9f), RoundedCornerShape(20.dp))
|
||||
.padding(start = 5.dp, end = 5.dp, top = 8.dp, bottom = 5.dp),
|
||||
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.9f), RoundedCornerShape(50))
|
||||
.padding(start = 14.dp, end = 5.dp, top = 5.dp, bottom = 5.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.ctrl_editor_drag), fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
EditorButton(stringResource(R.string.ctrl_editor_snap), false, selected = snap) {
|
||||
snap = !snap
|
||||
ControllerPrefs.setSnap(context, snap)
|
||||
controls.setSnap(snap)
|
||||
}
|
||||
EditorButton(stringResource(R.string.ctrl_reset), false) { controls.resetLayout() }
|
||||
EditorButton(stringResource(R.string.common_cancel), false) { finish() }
|
||||
EditorButton(stringResource(R.string.ctrl_editor_save), true) {
|
||||
controls.saveLayout()
|
||||
finish()
|
||||
}
|
||||
Text(stringResource(R.string.ctrl_editor_drag), fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(end = 4.dp))
|
||||
EditorButton(stringResource(R.string.ctrl_reset), false) { controls.resetLayout() }
|
||||
EditorButton(stringResource(R.string.common_cancel), false) { finish() }
|
||||
EditorButton(stringResource(R.string.ctrl_editor_save), true) {
|
||||
controls.saveLayout()
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,27 +71,14 @@ class ControllerEditorActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EditorButton(label: String, primary: Boolean, selected: Boolean? = null, onClick: () -> Unit) {
|
||||
private fun EditorButton(label: String, primary: Boolean, onClick: () -> Unit) {
|
||||
val pal = LocalPalette.current
|
||||
Text(
|
||||
label, fontSize = 12.sp, fontWeight = FontWeight.SemiBold,
|
||||
color = when {
|
||||
primary -> pal.onSignal
|
||||
selected == true -> pal.signal
|
||||
else -> MaterialTheme.colorScheme.onBackground
|
||||
},
|
||||
color = if (primary) pal.onSignal else MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Modifier.clip(RoundedCornerShape(50))
|
||||
.background(
|
||||
when {
|
||||
primary -> pal.signal
|
||||
selected == true -> pal.signal.copy(alpha = 0.18f)
|
||||
else -> Color.White.copy(alpha = 0.06f)
|
||||
},
|
||||
)
|
||||
.then(
|
||||
if (selected == null) Modifier.clickable(onClick = onClick)
|
||||
else Modifier.toggleable(value = selected, role = Role.Switch, onValueChange = { onClick() }),
|
||||
)
|
||||
.background(if (primary) pal.signal else Color.White.copy(alpha = 0.06f))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -46,8 +46,6 @@ object ControllerPrefs {
|
||||
val steamButton: Boolean,
|
||||
val qamButton: Boolean,
|
||||
val keyboardButton: Boolean,
|
||||
val selectSteam: Boolean,
|
||||
val snap: Boolean,
|
||||
val customLayout: Boolean,
|
||||
val mapping: Map<String, String>,
|
||||
)
|
||||
@@ -66,8 +64,6 @@ object ControllerPrefs {
|
||||
steamButton = p.getBoolean("steamButton", true),
|
||||
qamButton = p.getBoolean("qamButton", true),
|
||||
keyboardButton = p.getBoolean("keyboardButton", true),
|
||||
selectSteam = p.getBoolean("selectSteam", false),
|
||||
snap = p.getBoolean("snap", true),
|
||||
customLayout = p.all.keys.any { it.startsWith("layout.") },
|
||||
mapping = mappable.associate { (id, _) -> id to target(context, id) },
|
||||
)
|
||||
@@ -123,15 +119,6 @@ object ControllerPrefs {
|
||||
prefs(context).edit().putBoolean("keyboardButton", on).apply()
|
||||
}
|
||||
|
||||
/** The physical Select button opens the Steam menu instead of View/Select. */
|
||||
fun setSelectSteam(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("selectSteam", on).apply()
|
||||
}
|
||||
|
||||
fun setSnap(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("snap", on).apply()
|
||||
}
|
||||
|
||||
fun layout(context: Context, width: Int, height: Int): Map<String, Pair<Float, Float>> {
|
||||
val raw = prefs(context).getString("layout.${width}x$height", null) ?: return emptyMap()
|
||||
return raw.split(';').mapNotNull { entry ->
|
||||
|
||||
@@ -94,52 +94,18 @@ public class FakeInputWriter {
|
||||
private static final int RING_SNAPSHOT_SEQ_OFFSET = 32;
|
||||
private static final int RING_SNAPSHOT_BUTTONS_OFFSET = 40;
|
||||
private static final int RING_SNAPSHOT_AXES_OFFSET = 44; // short[8]
|
||||
// After the events - MUST match the blocks in fakeinput_steam.cpp. DeckImu carries the
|
||||
// Deck-only motion, grips and trackpads. TritonShared carries the paired Steam Controller's
|
||||
// native BLE reports and output reports, so the Steam client can see it as its own device.
|
||||
// After the events - MUST match the block in fakeinput_steam.cpp (DeckImu): what a Deck has and
|
||||
// an Xbox pad does not - motion, back grips, trackpads - in the Steam Deck controller's axes and
|
||||
// units, under a seqlock like the snapshot's. Only libfakeinput's Deck controller reads it.
|
||||
private static final int IMU_OFFSET = RING_SIZE;
|
||||
private static final int IMU_MAGIC = 0x31554D49; // IMU1
|
||||
// Written by libfakeinput, not here: the client's SETTING_IMU_MODE | 0x80000000 (readImuMode).
|
||||
private static final int IMU_MODE_OFFSET = IMU_OFFSET + 4;
|
||||
private static final int IMU_SEQ_OFFSET = IMU_OFFSET + 8;
|
||||
private static final int IMU_ACCEL_OFFSET = IMU_OFFSET + 16; // short[3]
|
||||
private static final int IMU_GYRO_OFFSET = IMU_OFFSET + 22; // short[3]
|
||||
private static final int DECK_PADS_OFFSET = IMU_OFFSET + 28; // short[4]: left X, Y, right X, Y
|
||||
private static final int DECK_PRESSURE_OFFSET = IMU_OFFSET + 36; // short[2]: left, right
|
||||
private static final int DECK_CONTROLS_OFFSET = IMU_OFFSET + 40; // int: DeckControls bits
|
||||
// Slot 0 only: what the pad is to the Steam client (libfakeinput's PAD_TYPE_*), | PAD_TYPE_SET.
|
||||
private static final int PAD_TYPE_OFFSET = IMU_OFFSET + 44;
|
||||
private static final int PAD_TYPE_SET = 0x80000000;
|
||||
public static final int PAD_TYPE_DECK = 0;
|
||||
public static final int PAD_TYPE_STEAM = 1;
|
||||
public static final int PAD_TYPE_XBOX = 2;
|
||||
public static final int PAD_TYPE_DS4 = 3;
|
||||
public static final int PAD_TYPE_DUALSENSE = 4;
|
||||
/** The type chosen this process, written into a ring made after the choice too; -1 for none. */
|
||||
private static volatile int padType = -1;
|
||||
/** The paired controller's state, likewise written into a ring made after it connected. */
|
||||
private static volatile boolean tritonConnected = false;
|
||||
private static final int TRITON_OFFSET = RING_SIZE + 64;
|
||||
private static final int TRITON_MAGIC = 0x4e525454; // TTRN
|
||||
// Whether the paired controller is connected: libfakeinput offers its device only then.
|
||||
private static final int TRITON_CONNECTED_OFFSET = TRITON_OFFSET + 4;
|
||||
private static final int TRITON_INPUT_SEQ_OFFSET = TRITON_OFFSET + 8;
|
||||
private static final int TRITON_REPORT_ID_OFFSET = TRITON_OFFSET + 16;
|
||||
private static final int TRITON_REPORT_LENGTH_OFFSET = TRITON_OFFSET + 20;
|
||||
private static final int TRITON_REPORT_OFFSET = TRITON_OFFSET + 24;
|
||||
private static final int TRITON_OUTPUT_SEQ_OFFSET = TRITON_OFFSET + 88;
|
||||
private static final int TRITON_OUTPUT_FEATURE_OFFSET = TRITON_OFFSET + 96;
|
||||
private static final int TRITON_OUTPUT_LENGTH_OFFSET = TRITON_OFFSET + 100;
|
||||
private static final int TRITON_OUTPUT_OFFSET = TRITON_OFFSET + 104;
|
||||
// A feature read: libfakeinput bumps the request, the reply names the request it answers.
|
||||
private static final int TRITON_GET_REQUEST_OFFSET = TRITON_OFFSET + 168;
|
||||
private static final int TRITON_GET_REPLY_OFFSET = TRITON_OFFSET + 176;
|
||||
private static final int TRITON_GET_REPORT_ID_OFFSET = TRITON_OFFSET + 184;
|
||||
private static final int TRITON_GET_LENGTH_OFFSET = TRITON_OFFSET + 188;
|
||||
private static final int TRITON_GET_OFFSET = TRITON_OFFSET + 192;
|
||||
private static final int TRITON_REPORT_MAX = 64;
|
||||
private static final int TRITON_BLOCK_SIZE = 256;
|
||||
private static final int RING_FILE_SIZE = TRITON_OFFSET + TRITON_BLOCK_SIZE;
|
||||
private static final int RING_FILE_SIZE = RING_SIZE + 64;
|
||||
|
||||
private static final Object RING_LOCK = new Object();
|
||||
private static final RingSlot[] RING_SLOTS = new RingSlot[MAX_FAKE_INPUT_SLOTS];
|
||||
@@ -249,24 +215,11 @@ public class FakeInputWriter {
|
||||
data.putShort(RING_SNAPSHOT_AXES_OFFSET + (i * 2), (short) 0);
|
||||
}
|
||||
data.putInt(IMU_OFFSET, 0);
|
||||
data.putInt(IMU_MODE_OFFSET, 0);
|
||||
data.putLong(IMU_SEQ_OFFSET, 0L);
|
||||
for (int i = 0; i < 12; i++) {
|
||||
data.putShort(IMU_ACCEL_OFFSET + (i * 2), (short) 0);
|
||||
}
|
||||
data.putInt(DECK_CONTROLS_OFFSET, 0);
|
||||
int type = padType;
|
||||
data.putInt(PAD_TYPE_OFFSET, type >= 0 ? PAD_TYPE_SET | type : 0);
|
||||
data.putInt(TRITON_OFFSET, TRITON_MAGIC);
|
||||
data.putInt(TRITON_CONNECTED_OFFSET, tritonConnected ? 1 : 0);
|
||||
data.putLong(TRITON_INPUT_SEQ_OFFSET, 0L);
|
||||
data.putInt(TRITON_REPORT_ID_OFFSET, 0);
|
||||
data.putInt(TRITON_REPORT_LENGTH_OFFSET, 0);
|
||||
data.putLong(TRITON_OUTPUT_SEQ_OFFSET, 0L);
|
||||
data.putInt(TRITON_OUTPUT_FEATURE_OFFSET, 0);
|
||||
data.putInt(TRITON_OUTPUT_LENGTH_OFFSET, 0);
|
||||
data.putLong(TRITON_GET_REQUEST_OFFSET, 0L);
|
||||
data.putLong(TRITON_GET_REPLY_OFFSET, 0L);
|
||||
}
|
||||
|
||||
// Lock order: RING_LOCK, then the slot. Every writer holds the slot while it touches data, and
|
||||
@@ -523,23 +476,6 @@ public class FakeInputWriter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The motion the Steam client asked the Deck controller of slot {@code slot} for: its
|
||||
* SETTING_IMU_MODE (0 = off), or -1 until it has said. Safe from any thread.
|
||||
*/
|
||||
public static int readImuMode(int slot) {
|
||||
RingSlot ringSlot;
|
||||
synchronized (RING_LOCK) {
|
||||
ringSlot = slot >= 0 && slot < RING_SLOTS.length ? RING_SLOTS[slot] : null;
|
||||
}
|
||||
if (ringSlot == null) return -1;
|
||||
synchronized (ringSlot) {
|
||||
ByteBuffer ring = ringSlot.data;
|
||||
int value = ring == null ? 0 : ring.getInt(IMU_MODE_OFFSET);
|
||||
return value < 0 ? value & 0xffff : -1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes the Deck's back grips and trackpads for slot {@code slot} (see DeckControls):
|
||||
* {@code controls} is the DeckControls bit set, {@code pads} left X, Y, right X, Y and
|
||||
@@ -566,121 +502,6 @@ public class FakeInputWriter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches what the pad is to the Steam client while the session runs ([PAD_TYPE_DECK],
|
||||
* [PAD_TYPE_STEAM] or [PAD_TYPE_XBOX]); libfakeinput unplugs the old device and plugs in the new.
|
||||
*/
|
||||
public static void setPadType(int type) {
|
||||
padType = type;
|
||||
RingSlot ringSlot;
|
||||
synchronized (RING_LOCK) {
|
||||
ringSlot = RING_SLOTS[0];
|
||||
}
|
||||
if (ringSlot == null) return;
|
||||
synchronized (ringSlot) {
|
||||
ByteBuffer ring = ringSlot.data;
|
||||
if (ring == null) return;
|
||||
nativeStoreFence();
|
||||
ring.putInt(PAD_TYPE_OFFSET, PAD_TYPE_SET | type);
|
||||
}
|
||||
}
|
||||
|
||||
/** The paired Steam Controller came or went (SteamControllerBle): its device is plugged in or out. */
|
||||
public static void setTritonConnected(int slot, boolean connected) {
|
||||
tritonConnected = connected;
|
||||
RingSlot ringSlot = tritonSlot(slot);
|
||||
if (ringSlot == null) return;
|
||||
synchronized (ringSlot) {
|
||||
ByteBuffer ring = ringSlot.data;
|
||||
if (ring == null || ring.getInt(TRITON_OFFSET) != TRITON_MAGIC) return;
|
||||
nativeStoreFence();
|
||||
ring.putInt(TRITON_CONNECTED_OFFSET, connected ? 1 : 0);
|
||||
}
|
||||
}
|
||||
|
||||
/** Publishes a native Triton input report (without the HID report ID byte). */
|
||||
public static void writeTritonState(int slot, int reportId, byte[] report) {
|
||||
if (report == null || report.length == 0 || report.length > TRITON_REPORT_MAX) return;
|
||||
RingSlot ringSlot;
|
||||
synchronized (RING_LOCK) {
|
||||
ringSlot = slot >= 0 && slot < RING_SLOTS.length ? RING_SLOTS[slot] : null;
|
||||
}
|
||||
if (ringSlot == null) return;
|
||||
synchronized (ringSlot) {
|
||||
ByteBuffer ring = ringSlot.data;
|
||||
if (ring == null || ring.getInt(TRITON_OFFSET) != TRITON_MAGIC) return;
|
||||
long seq = ring.getLong(TRITON_INPUT_SEQ_OFFSET);
|
||||
if ((seq & 1) != 0) seq++;
|
||||
ring.putLong(TRITON_INPUT_SEQ_OFFSET, seq + 1);
|
||||
nativeStoreFence();
|
||||
ring.putInt(TRITON_REPORT_ID_OFFSET, reportId);
|
||||
ring.putInt(TRITON_REPORT_LENGTH_OFFSET, report.length);
|
||||
for (int i = 0; i < TRITON_REPORT_MAX; i++) {
|
||||
ring.put(TRITON_REPORT_OFFSET + i, i < report.length ? report[i] : 0);
|
||||
}
|
||||
nativeStoreFence();
|
||||
ring.putLong(TRITON_INPUT_SEQ_OFFSET, seq + 2);
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the newest Steam-client output report after {@code lastSequence}, or null. */
|
||||
public static byte[] readTritonOutput(int slot, long lastSequence) {
|
||||
RingSlot ringSlot;
|
||||
synchronized (RING_LOCK) {
|
||||
ringSlot = slot >= 0 && slot < RING_SLOTS.length ? RING_SLOTS[slot] : null;
|
||||
}
|
||||
if (ringSlot == null) return null;
|
||||
synchronized (ringSlot) {
|
||||
ByteBuffer ring = ringSlot.data;
|
||||
if (ring == null || ring.getInt(TRITON_OFFSET) != TRITON_MAGIC) return null;
|
||||
nativeStoreFence();
|
||||
long seq = ring.getLong(TRITON_OUTPUT_SEQ_OFFSET);
|
||||
if ((seq & 1) != 0 || seq <= lastSequence) return null;
|
||||
int length = ring.getInt(TRITON_OUTPUT_LENGTH_OFFSET);
|
||||
if (length <= 0 || length > TRITON_REPORT_MAX) return null;
|
||||
byte[] output = new byte[9 + length];
|
||||
ByteBuffer.wrap(output).order(ByteOrder.LITTLE_ENDIAN).putLong(seq);
|
||||
output[8] = (byte) (ring.getInt(TRITON_OUTPUT_FEATURE_OFFSET) != 0 ? 1 : 0);
|
||||
for (int i = 0; i < length; i++) output[9 + i] = ring.get(TRITON_OUTPUT_OFFSET + i);
|
||||
nativeStoreFence();
|
||||
return seq == ring.getLong(TRITON_OUTPUT_SEQ_OFFSET) ? output : null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The Steam client's newest unanswered feature read, or 0: its request number. */
|
||||
public static long readTritonFeatureRequest(int slot) {
|
||||
RingSlot ringSlot = tritonSlot(slot);
|
||||
if (ringSlot == null) return 0L;
|
||||
synchronized (ringSlot) {
|
||||
ByteBuffer ring = ringSlot.data;
|
||||
if (ring == null || ring.getInt(TRITON_OFFSET) != TRITON_MAGIC) return 0L;
|
||||
nativeStoreFence();
|
||||
long request = ring.getLong(TRITON_GET_REQUEST_OFFSET);
|
||||
return request != ring.getLong(TRITON_GET_REPLY_OFFSET) ? request : 0L;
|
||||
}
|
||||
}
|
||||
|
||||
/** Answers feature read {@code request} with the controller's report, or a failure when null. */
|
||||
public static void writeTritonFeatureReply(int slot, long request, byte[] report) {
|
||||
RingSlot ringSlot = tritonSlot(slot);
|
||||
if (ringSlot == null) return;
|
||||
synchronized (ringSlot) {
|
||||
ByteBuffer ring = ringSlot.data;
|
||||
if (ring == null || ring.getInt(TRITON_OFFSET) != TRITON_MAGIC) return;
|
||||
int length = report == null ? 0 : Math.min(report.length, TRITON_REPORT_MAX);
|
||||
for (int i = 0; i < TRITON_REPORT_MAX; i++) ring.put(TRITON_GET_OFFSET + i, i < length ? report[i] : 0);
|
||||
ring.putInt(TRITON_GET_LENGTH_OFFSET, length);
|
||||
nativeStoreFence();
|
||||
ring.putLong(TRITON_GET_REPLY_OFFSET, request);
|
||||
}
|
||||
}
|
||||
|
||||
private static RingSlot tritonSlot(int slot) {
|
||||
synchronized (RING_LOCK) {
|
||||
return slot >= 0 && slot < RING_SLOTS.length ? RING_SLOTS[slot] : null;
|
||||
}
|
||||
}
|
||||
|
||||
private static short clampShort(int value) {
|
||||
if (value > Short.MAX_VALUE) {
|
||||
return Short.MAX_VALUE;
|
||||
|
||||
@@ -9,7 +9,6 @@ import android.graphics.Path
|
||||
import android.graphics.Rect
|
||||
import android.graphics.RectF
|
||||
import android.os.Build
|
||||
import android.view.HapticFeedbackConstants
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.WindowInsets
|
||||
@@ -96,11 +95,8 @@ class OnScreenControls(
|
||||
private var quickPressedBy = -1
|
||||
private var keyboardPressedBy = -1
|
||||
private var settings = ControllerPrefs.read(context)
|
||||
private var snap = settings.snap
|
||||
private var safe = Rect()
|
||||
private var selected: String? = null
|
||||
private var snapX: Float? = null
|
||||
private var snapY: Float? = null
|
||||
private var grabX = 0f
|
||||
private var grabY = 0f
|
||||
private var ignoreSaved = false
|
||||
@@ -142,18 +138,10 @@ class OnScreenControls(
|
||||
fun reload() {
|
||||
releaseAll()
|
||||
settings = ControllerPrefs.read(context)
|
||||
setSnap(settings.snap)
|
||||
applySettings()
|
||||
relayout()
|
||||
}
|
||||
|
||||
private fun clearSnapGuides() {
|
||||
if (snapX == null && snapY == null) return
|
||||
snapX = null
|
||||
snapY = null
|
||||
invalidate()
|
||||
}
|
||||
|
||||
private fun applySettings() {
|
||||
for (control in controls) control.target = settings.mapping[control.id] ?: control.id
|
||||
val tint = settings.tint
|
||||
@@ -191,11 +179,6 @@ class OnScreenControls(
|
||||
invalidate()
|
||||
}
|
||||
|
||||
fun setSnap(on: Boolean) {
|
||||
snap = on
|
||||
if (!on) clearSnapGuides()
|
||||
}
|
||||
|
||||
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
||||
super.onSizeChanged(w, h, oldw, oldh)
|
||||
relayout()
|
||||
@@ -429,18 +412,15 @@ class OnScreenControls(
|
||||
}
|
||||
}
|
||||
if (editing) {
|
||||
val group = selected ?: return
|
||||
val (x, y) = centre(group)
|
||||
stroke.color = heldStroke
|
||||
snapX?.let { canvas.drawLine(it, safe.top.toFloat(), it, (height - safe.bottom).toFloat(), stroke) }
|
||||
snapY?.let { canvas.drawLine(safe.left.toFloat(), it, (width - safe.right).toFloat(), it, stroke) }
|
||||
selected?.let { group ->
|
||||
val (x, y) = centre(group)
|
||||
val single = members(group).singleOrNull()
|
||||
if (single?.wide == true) {
|
||||
val pad = dp(6f)
|
||||
box.set(x - single.halfW - pad, y - single.halfH - pad, x + single.halfW + pad, y + single.halfH + pad)
|
||||
canvas.drawRoundRect(box, single.halfH, single.halfH, stroke)
|
||||
} else canvas.drawCircle(x, y, extent(group) + dp(6f), stroke)
|
||||
}
|
||||
val single = members(group).singleOrNull()
|
||||
if (single?.wide == true) {
|
||||
val pad = dp(6f)
|
||||
box.set(x - single.halfW - pad, y - single.halfH - pad, x + single.halfW + pad, y + single.halfH + pad)
|
||||
canvas.drawRoundRect(box, single.halfH, single.halfH, stroke)
|
||||
} else canvas.drawCircle(x, y, extent(group) + dp(6f), stroke)
|
||||
} else if (!buttonsOnly) {
|
||||
val (x, y) = quickCenter()
|
||||
val radius = dp(18f)
|
||||
@@ -607,7 +587,6 @@ class OnScreenControls(
|
||||
private fun onEditTouch(event: MotionEvent): Boolean {
|
||||
when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
clearSnapGuides()
|
||||
val control = controlAt(event.x, event.y)
|
||||
selected = control?.group
|
||||
val group = selected ?: run { invalidate(); return false }
|
||||
@@ -619,29 +598,14 @@ class OnScreenControls(
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
val group = selected ?: return true
|
||||
ignoreSaved = false
|
||||
val rawX = event.x + grabX
|
||||
val rawY = event.y + grabY
|
||||
val others = groups.filter { it != group && members(it).any(::isVisible) }.map { centre(it) }
|
||||
val nextX = if (snap) closestSnap(rawX, others.map { it.first } + width / 2f) else null
|
||||
val nextY = if (snap) closestSnap(rawY, others.map { it.second }) else null
|
||||
put(group, nextX ?: rawX, nextY ?: rawY)
|
||||
put(group, event.x + grabX, event.y + grabY)
|
||||
clamp(group)
|
||||
val (x, y) = centre(group)
|
||||
val visibleX = nextX?.takeIf { abs(x - it) <= SNAP_TOLERANCE_PX }
|
||||
val visibleY = nextY?.takeIf { abs(y - it) <= SNAP_TOLERANCE_PX }
|
||||
if ((visibleX != null && visibleX != snapX) || (visibleY != null && visibleY != snapY)) performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK)
|
||||
snapX = visibleX
|
||||
snapY = visibleY
|
||||
invalidate()
|
||||
}
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> clearSnapGuides()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun closestSnap(position: Float, targets: List<Float>): Float? =
|
||||
targets.minByOrNull { abs(position - it) }?.takeIf { abs(position - it) <= dp(SNAP_DP) }
|
||||
|
||||
private fun controlAt(x: Float, y: Float): Control? =
|
||||
controls.firstOrNull {
|
||||
!quickHidden && isVisible(it) && (editing || !settings.adaptiveSticks || it.stick < 0) && it.contains(x, y, it.radius)
|
||||
@@ -751,8 +715,6 @@ class OnScreenControls(
|
||||
const val PHONE_MAX_HEIGHT_MM = 90f
|
||||
const val SHOULDER_WIDTH = 1.45f
|
||||
const val SHOULDER_HEIGHT = 0.8f
|
||||
const val SNAP_DP = 9f
|
||||
const val SNAP_TOLERANCE_PX = 1f
|
||||
val shoulderIds = setOf("lb", "rb", "lt", "rt")
|
||||
val directions = listOf("up", "right", "down", "left")
|
||||
}
|
||||
|
||||
@@ -10,8 +10,6 @@ import android.view.MotionEvent;
|
||||
import com.droiddeck.launcher.session.SessionState;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.Arrays;
|
||||
import java.util.concurrent.atomic.AtomicIntegerArray;
|
||||
|
||||
/**
|
||||
* A physical controller, republished as a synthetic evdev device the Steam client can see.
|
||||
@@ -27,29 +25,12 @@ import java.util.concurrent.atomic.AtomicIntegerArray;
|
||||
* to the client instead ({@link com.droiddeck.launcher.session.SteamDeckPad}), which has a Quick
|
||||
* Access button of its own; an Xbox pad has none, so there the client's menu is reached with the
|
||||
* Guide-then-A chord the client itself answers.
|
||||
*
|
||||
* <p>Each further controller is another player: slots 1 to 3, an Xbox 360 pad each, which in a
|
||||
* Steam session only the client reads (libfakeinput) and hands games through Steam Input. Slot 0
|
||||
* is the device's own controls wherever it has them: a Bluetooth pad pressed first used to take
|
||||
* it, and played as the handheld while its built-in pad became player 2.
|
||||
*/
|
||||
public final class PadBridge {
|
||||
private static final String TAG = "PadBridge";
|
||||
/** Player slots: the session prepares a ring for each (SessionService). */
|
||||
public static final int SLOTS = 4;
|
||||
/** Slot 0 is the one the session exports; extra slots would each show as another pad. */
|
||||
private static final int SLOT = 0;
|
||||
private static final float DEAD_ZONE = 0.12f;
|
||||
private static final int[] JOYSTICK_AXES = {
|
||||
MotionEvent.AXIS_X, MotionEvent.AXIS_Y, MotionEvent.AXIS_Z, MotionEvent.AXIS_RZ,
|
||||
MotionEvent.AXIS_HAT_X, MotionEvent.AXIS_HAT_Y, MotionEvent.AXIS_LTRIGGER,
|
||||
MotionEvent.AXIS_RTRIGGER, MotionEvent.AXIS_BRAKE, MotionEvent.AXIS_GAS
|
||||
};
|
||||
private static final int[] CONTROLLER_BUTTONS = {
|
||||
KeyEvent.KEYCODE_BUTTON_A, KeyEvent.KEYCODE_BUTTON_B, KeyEvent.KEYCODE_BUTTON_X,
|
||||
KeyEvent.KEYCODE_BUTTON_Y, KeyEvent.KEYCODE_BUTTON_L1, KeyEvent.KEYCODE_BUTTON_R1,
|
||||
KeyEvent.KEYCODE_BUTTON_SELECT, KeyEvent.KEYCODE_BUTTON_START,
|
||||
KeyEvent.KEYCODE_BUTTON_THUMBL, KeyEvent.KEYCODE_BUTTON_THUMBR,
|
||||
KeyEvent.KEYCODE_BUTTON_MODE, KeyEvent.KEYCODE_BUTTON_L2, KeyEvent.KEYCODE_BUTTON_R2
|
||||
};
|
||||
// The client takes A as part of the chord only once it has had Guide held for a while, and a
|
||||
// client starved of CPU needs longer. Too short, and it acts on A as well, selecting whatever it
|
||||
// had focused before opening QAM: with 80 ms of lead that was 3 times in 20 at rest, and 250 ms
|
||||
@@ -69,17 +50,11 @@ public final class PadBridge {
|
||||
chordHandler = new Handler(thread.getLooper());
|
||||
}
|
||||
|
||||
private final File fakeInputDir;
|
||||
private final FakeInputWriter writer;
|
||||
private final PadState state = new PadState();
|
||||
/** The input device id holding each slot, or {@link #NO_CONTROLLER}. */
|
||||
private final int[] owners = new int[SLOTS];
|
||||
private final FakeInputWriter[] playerWriters = new FakeInputWriter[SLOTS];
|
||||
private final PadState[] playerStates = new PadState[SLOTS];
|
||||
private final PadState effectiveState = new PadState();
|
||||
private final Handler mainHandler = new Handler(Looper.getMainLooper());
|
||||
private boolean open;
|
||||
private volatile boolean selectSteam;
|
||||
private boolean systemGuidePressed;
|
||||
private boolean systemQamPressed;
|
||||
private boolean qamChordActive;
|
||||
@@ -103,67 +78,30 @@ public final class PadBridge {
|
||||
|
||||
/** No physical controller is driving the pad: nothing yet, or the on-screen controls were last. */
|
||||
public static final int NO_CONTROLLER = -1;
|
||||
|
||||
// AYN's handhelds (the Thor) present their own pad as a USB device Android calls external,
|
||||
// beside the 2020:0111 it re-presents each Bluetooth Xbox pad as.
|
||||
private static final int AYN_VENDOR = 0x2020;
|
||||
private static final int AYN_BUILT_IN_PAD = 0x0112;
|
||||
|
||||
/** Whether this controller is the device's own controls rather than a pad connected to it. */
|
||||
static boolean isBuiltIn(InputDevice device) {
|
||||
return !device.isExternal()
|
||||
|| (device.getVendorId() == AYN_VENDOR && device.getProductId() == AYN_BUILT_IN_PAD);
|
||||
}
|
||||
/**
|
||||
* The Android input device id of the physical controller that last drove any slot, or
|
||||
* {@link #NO_CONTROLLER}: where rumble goes when it names no slot of ours (RumbleComponent).
|
||||
* The Android input device id of the physical controller that last drove the pad, or
|
||||
* {@link #NO_CONTROLLER}. Every pad feeds the one exported slot, so this is how a game's rumble
|
||||
* on that slot finds the motors in the player's hands (RumbleComponent).
|
||||
*/
|
||||
private static volatile int activeControllerId = NO_CONTROLLER;
|
||||
/** Per slot, the controller its rumble belongs to; slot 0's is none while the on-screen controls lead. */
|
||||
private static final AtomicIntegerArray slotControllers = new AtomicIntegerArray(SLOTS);
|
||||
static {
|
||||
for (int slot = 0; slot < SLOTS; slot++) slotControllers.set(slot, NO_CONTROLLER);
|
||||
}
|
||||
|
||||
public static int activeControllerId() { return activeControllerId; }
|
||||
|
||||
/** The controller playing in this ring slot, or the last one used for any other slot. */
|
||||
public static int controllerForSlot(int slot) {
|
||||
return slot >= 0 && slot < SLOTS ? slotControllers.get(slot) : activeControllerId;
|
||||
}
|
||||
|
||||
public PadBridge(File fakeInputDir) {
|
||||
this.fakeInputDir = fakeInputDir;
|
||||
writer = new FakeInputWriter(fakeInputDir.getAbsolutePath(), 0);
|
||||
Arrays.fill(owners, NO_CONTROLLER);
|
||||
for (int slot = 1; slot < SLOTS; slot++) playerStates[slot] = new PadState();
|
||||
}
|
||||
|
||||
/** The physical Select button opens the Steam menu instead of View/Select (Setup → Controller). */
|
||||
public void setSelectSteam(boolean on) {
|
||||
selectSteam = on;
|
||||
writer = new FakeInputWriter(fakeInputDir.getAbsolutePath(), SLOT);
|
||||
}
|
||||
|
||||
/** Opens the ring; safe to call more than once. */
|
||||
public synchronized boolean start() {
|
||||
if (!open) {
|
||||
open = writer.open();
|
||||
Log.i(TAG, "ring slot 0" + (open ? " open" : " NOT open"));
|
||||
Log.i(TAG, "ring slot " + SLOT + (open ? " open" : " NOT open"));
|
||||
}
|
||||
return open;
|
||||
}
|
||||
|
||||
public synchronized void stop() {
|
||||
activeControllerId = NO_CONTROLLER;
|
||||
for (int slot = 0; slot < SLOTS; slot++) {
|
||||
owners[slot] = NO_CONTROLLER;
|
||||
slotControllers.set(slot, NO_CONTROLLER);
|
||||
if (playerWriters[slot] != null) {
|
||||
playerWriters[slot].destroy();
|
||||
playerWriters[slot] = null;
|
||||
}
|
||||
if (playerStates[slot] != null) playerStates[slot].clear();
|
||||
}
|
||||
systemGuidePressed = false;
|
||||
systemQamPressed = false;
|
||||
qamChordActive = false;
|
||||
@@ -185,81 +123,8 @@ public final class PadBridge {
|
||||
// counting it would hide the on-screen controls with nothing to replace them.
|
||||
if (device.isVirtual()) return false;
|
||||
int sources = device.getSources();
|
||||
if ((sources & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD) return true;
|
||||
if ((sources & InputDevice.SOURCE_JOYSTICK) != InputDevice.SOURCE_JOYSTICK) return false;
|
||||
if (device.getKeyboardType() != InputDevice.KEYBOARD_TYPE_ALPHABETIC) return true;
|
||||
// Some composite keyboards advertise joystick input without any sticks or pad buttons.
|
||||
// A keyboard's mouse ranges do not establish a joystick; a real hybrid's joystick ranges
|
||||
// or physical controller buttons do. GAMEPAD above remains authoritative even with keys.
|
||||
for (int axis : JOYSTICK_AXES) {
|
||||
InputDevice.MotionRange range = device.getMotionRange(axis, InputDevice.SOURCE_JOYSTICK);
|
||||
if (range != null && range.getMax() > range.getMin()) return true;
|
||||
}
|
||||
for (boolean present : device.hasKeys(CONTROLLER_BUTTONS)) {
|
||||
if (present) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Whether this key belongs to the pad, rather than a keyboard on the same input device. */
|
||||
public static boolean isControllerKeyEvent(KeyEvent event) {
|
||||
InputDevice device = event.getDevice();
|
||||
if (device == null || device.isVirtual()) return false;
|
||||
// An explicit pad button is unambiguous even when a hybrid omits its capabilities.
|
||||
if (KeyEvent.isGamepadButton(event.getKeyCode())) {
|
||||
return device.supportsSource(InputDevice.SOURCE_GAMEPAD)
|
||||
|| device.supportsSource(InputDevice.SOURCE_JOYSTICK);
|
||||
}
|
||||
switch (event.getKeyCode()) {
|
||||
case KeyEvent.KEYCODE_DPAD_UP:
|
||||
case KeyEvent.KEYCODE_DPAD_RIGHT:
|
||||
case KeyEvent.KEYCODE_DPAD_DOWN:
|
||||
case KeyEvent.KEYCODE_DPAD_LEFT:
|
||||
case KeyEvent.KEYCODE_BACK:
|
||||
case KeyEvent.KEYCODE_MENU:
|
||||
case KeyEvent.KEYCODE_HOME:
|
||||
if (!isFromController(device)) return false;
|
||||
// Without GAMEPAD, an alphabetic hybrid's keyboard arrows remain keyboard keys.
|
||||
// Its ambiguous navigation keys need explicit pad/joystick event provenance;
|
||||
// DPAD alone also describes ordinary keyboard arrows.
|
||||
return device.supportsSource(InputDevice.SOURCE_GAMEPAD)
|
||||
|| device.getKeyboardType() != InputDevice.KEYBOARD_TYPE_ALPHABETIC
|
||||
|| event.isFromSource(InputDevice.SOURCE_GAMEPAD)
|
||||
|| event.isFromSource(InputDevice.SOURCE_JOYSTICK);
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Mouse motion on a composite controller still belongs to the pointer, not its sticks. */
|
||||
public static boolean isControllerMotionEvent(MotionEvent event) {
|
||||
InputDevice device = event.getDevice();
|
||||
// An actual joystick event, like a pad button, survives a hybrid's missing capabilities.
|
||||
return event.isFromSource(InputDevice.SOURCE_JOYSTICK) && device != null && !device.isVirtual()
|
||||
&& (device.supportsSource(InputDevice.SOURCE_GAMEPAD)
|
||||
|| device.supportsSource(InputDevice.SOURCE_JOYSTICK));
|
||||
}
|
||||
|
||||
/** A Bluetooth keyboard can share a device with a joystick. Classify the key, not just
|
||||
* the device, so its letters and modifiers still reach the desktop keyboard. */
|
||||
public static boolean isControllerKey(KeyEvent event) {
|
||||
return isFromController(event.getDevice())
|
||||
&& isPadKey(event.getKeyCode(), event.getDevice().getKeyboardType());
|
||||
}
|
||||
|
||||
public static boolean isPadKey(int keyCode, int keyboardType) {
|
||||
if ((keyCode >= KeyEvent.KEYCODE_BUTTON_A && keyCode <= KeyEvent.KEYCODE_BUTTON_MODE)
|
||||
|| (keyCode >= KeyEvent.KEYCODE_BUTTON_1 && keyCode <= KeyEvent.KEYCODE_BUTTON_16)) return true;
|
||||
if (keyboardType == InputDevice.KEYBOARD_TYPE_ALPHABETIC) return false;
|
||||
switch (keyCode) {
|
||||
case KeyEvent.KEYCODE_DPAD_UP:
|
||||
case KeyEvent.KEYCODE_DPAD_DOWN:
|
||||
case KeyEvent.KEYCODE_DPAD_LEFT:
|
||||
case KeyEvent.KEYCODE_DPAD_RIGHT:
|
||||
case KeyEvent.KEYCODE_BACK:
|
||||
case KeyEvent.KEYCODE_HOME:
|
||||
case KeyEvent.KEYCODE_MENU: return true;
|
||||
default: return false;
|
||||
}
|
||||
return (sources & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD
|
||||
|| (sources & InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK;
|
||||
}
|
||||
|
||||
/** Whether any real controller is attached right now. */
|
||||
@@ -290,81 +155,72 @@ public final class PadBridge {
|
||||
|
||||
/** @return true when the event was a pad button and has been consumed. */
|
||||
public synchronized boolean onKeyEvent(KeyEvent event) {
|
||||
if (!isControllerKeyEvent(event)) return false;
|
||||
int slot = slotFor(event.getDevice());
|
||||
PadState pad = stateFor(slot);
|
||||
if (!isFromController(event.getDevice())) return false;
|
||||
noteDevice(event.getDevice());
|
||||
boolean pressed = event.getAction() == KeyEvent.ACTION_DOWN;
|
||||
switch (event.getKeyCode()) {
|
||||
case KeyEvent.KEYCODE_BUTTON_A: pad.press(0, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_B: pad.press(1, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_X: pad.press(2, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_Y: pad.press(3, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_L1: pad.press(4, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_R1: pad.press(5, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_A: state.press(0, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_B: state.press(1, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_X: state.press(2, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_Y: state.press(3, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_L1: state.press(4, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_R1: state.press(5, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_SELECT:
|
||||
// Optionally (Setup → Controller) the physical Select button opens the Steam menu
|
||||
// instead of View/Select: on some handhelds it sits next to the Steam button.
|
||||
if (selectSteam) pad.press(PadState.GUIDE, pressed);
|
||||
else pad.press(6, pressed);
|
||||
break;
|
||||
case KeyEvent.KEYCODE_BACK: pad.press(6, pressed); break;
|
||||
case KeyEvent.KEYCODE_BACK: state.press(6, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_START:
|
||||
case KeyEvent.KEYCODE_MENU: pad.press(7, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_THUMBL: pad.press(8, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_THUMBR: pad.press(9, pressed); break;
|
||||
case KeyEvent.KEYCODE_MENU: state.press(7, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_THUMBL: state.press(8, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_THUMBR: state.press(9, pressed); break;
|
||||
// The client's own in-game menu is opened by this one; the interposer publishes it as
|
||||
// BTN_MODE, which SDL reports as the "guide" button.
|
||||
case KeyEvent.KEYCODE_BUTTON_MODE:
|
||||
case KeyEvent.KEYCODE_HOME: pad.press(PadState.GUIDE, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_L2: pad.leftTrigger = pressed ? 1f : 0f; break;
|
||||
case KeyEvent.KEYCODE_BUTTON_R2: pad.rightTrigger = pressed ? 1f : 0f; break;
|
||||
case KeyEvent.KEYCODE_DPAD_UP: pad.up = pressed; break;
|
||||
case KeyEvent.KEYCODE_DPAD_RIGHT: pad.right = pressed; break;
|
||||
case KeyEvent.KEYCODE_DPAD_DOWN: pad.down = pressed; break;
|
||||
case KeyEvent.KEYCODE_DPAD_LEFT: pad.left = pressed; break;
|
||||
case KeyEvent.KEYCODE_HOME: state.press(PadState.GUIDE, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_L2: state.leftTrigger = pressed ? 1f : 0f; break;
|
||||
case KeyEvent.KEYCODE_BUTTON_R2: state.rightTrigger = pressed ? 1f : 0f; break;
|
||||
case KeyEvent.KEYCODE_DPAD_UP: state.up = pressed; break;
|
||||
case KeyEvent.KEYCODE_DPAD_RIGHT: state.right = pressed; break;
|
||||
case KeyEvent.KEYCODE_DPAD_DOWN: state.down = pressed; break;
|
||||
case KeyEvent.KEYCODE_DPAD_LEFT: state.left = pressed; break;
|
||||
default: return false;
|
||||
}
|
||||
noteDevice(event.getDevice(), slot);
|
||||
if (pressed) notePlayerInput();
|
||||
statButtons++;
|
||||
scheduleStats();
|
||||
publish(slot);
|
||||
publish();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @return true when the event was a pad's sticks/triggers and has been consumed. */
|
||||
public synchronized boolean onMotionEvent(MotionEvent event) {
|
||||
if (!isControllerMotionEvent(event)) return false;
|
||||
if (!isFromController(event.getDevice())) return false;
|
||||
if (event.getAction() != MotionEvent.ACTION_MOVE) return false;
|
||||
int slot = slotFor(event.getDevice());
|
||||
PadState pad = stateFor(slot);
|
||||
noteDevice(event.getDevice(), slot);
|
||||
noteDevice(event.getDevice());
|
||||
statAxes++;
|
||||
scheduleStats();
|
||||
pad.leftX = axis(event, MotionEvent.AXIS_X);
|
||||
state.leftX = axis(event, MotionEvent.AXIS_X);
|
||||
// Android's Y axis grows downwards and evdev's ABS_Y does too, so no flip here: what the
|
||||
// pad reports as "down" is what the client is told.
|
||||
pad.leftY = axis(event, MotionEvent.AXIS_Y);
|
||||
pad.rightX = axis(event, MotionEvent.AXIS_Z);
|
||||
pad.rightY = axis(event, MotionEvent.AXIS_RZ);
|
||||
state.leftY = axis(event, MotionEvent.AXIS_Y);
|
||||
state.rightX = axis(event, MotionEvent.AXIS_Z);
|
||||
state.rightY = axis(event, MotionEvent.AXIS_RZ);
|
||||
float lt = event.getAxisValue(MotionEvent.AXIS_LTRIGGER);
|
||||
float rt = event.getAxisValue(MotionEvent.AXIS_RTRIGGER);
|
||||
// Some pads only report the triggers on BRAKE/GAS.
|
||||
if (lt == 0f) lt = event.getAxisValue(MotionEvent.AXIS_BRAKE);
|
||||
if (rt == 0f) rt = event.getAxisValue(MotionEvent.AXIS_GAS);
|
||||
pad.leftTrigger = lt;
|
||||
pad.rightTrigger = rt;
|
||||
state.leftTrigger = lt;
|
||||
state.rightTrigger = rt;
|
||||
float hatX = event.getAxisValue(MotionEvent.AXIS_HAT_X);
|
||||
float hatY = event.getAxisValue(MotionEvent.AXIS_HAT_Y);
|
||||
pad.up = hatY < -0.5f;
|
||||
pad.right = hatX > 0.5f;
|
||||
pad.down = hatY > 0.5f;
|
||||
pad.left = hatX < -0.5f;
|
||||
if (Math.max(Math.max(Math.abs(pad.leftX), Math.abs(pad.leftY)), Math.max(Math.abs(pad.rightX), Math.abs(pad.rightY))) > 0.5f
|
||||
|| lt > 0.5f || rt > 0.5f || pad.up || pad.right || pad.down || pad.left) {
|
||||
state.up = hatY < -0.5f;
|
||||
state.right = hatX > 0.5f;
|
||||
state.down = hatY > 0.5f;
|
||||
state.left = hatX < -0.5f;
|
||||
if (Math.max(Math.max(Math.abs(state.leftX), Math.abs(state.leftY)), Math.max(Math.abs(state.rightX), Math.abs(state.rightY))) > 0.5f
|
||||
|| lt > 0.5f || rt > 0.5f || state.up || state.right || state.down || state.left) {
|
||||
notePlayerInput();
|
||||
}
|
||||
publish(slot);
|
||||
publish();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -375,30 +231,12 @@ public final class PadBridge {
|
||||
public synchronized void applyTouch(java.util.function.Consumer<PadState> mutation) {
|
||||
mutation.accept(state);
|
||||
activeControllerId = NO_CONTROLLER;
|
||||
slotControllers.set(0, NO_CONTROLLER);
|
||||
notePlayerInput();
|
||||
statOnScreen++;
|
||||
scheduleStats();
|
||||
publish();
|
||||
}
|
||||
|
||||
/**
|
||||
* A controller the app reads itself rather than through Android's input (the Steam Controller
|
||||
* over Bluetooth, SteamControllerBle): its whole state, merged into the one exported slot as a
|
||||
* physical pad's events are. Android has no device for it, so a game's rumble cannot reach it.
|
||||
* It streams its state whether or not anything moved; {@code changed} is whether this one did.
|
||||
*/
|
||||
public synchronized void applyExternal(java.util.function.Consumer<PadState> mutation, boolean changed) {
|
||||
mutation.accept(state);
|
||||
if (changed) {
|
||||
activeControllerId = NO_CONTROLLER;
|
||||
notePlayerInput();
|
||||
statButtons++;
|
||||
scheduleStats();
|
||||
}
|
||||
publish();
|
||||
}
|
||||
|
||||
/** Touch-only Steam and QAM buttons, merged with physical input without changing its state. */
|
||||
public synchronized void setSystemButtons(boolean guidePressed, boolean qamPressed) {
|
||||
if (systemGuidePressed == guidePressed && systemQamPressed == qamPressed) return;
|
||||
@@ -417,31 +255,6 @@ public final class PadBridge {
|
||||
public synchronized void releaseAll() {
|
||||
state.clear();
|
||||
publish();
|
||||
for (int slot = 1; slot < SLOTS; slot++) {
|
||||
if (playerWriters[slot] == null) continue;
|
||||
playerStates[slot].clear();
|
||||
publish(slot);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gives every connected controller its slot now, so the client lists each one as it is
|
||||
* connected instead of on its first press: a further player's pad is plugged in, at rest.
|
||||
*/
|
||||
public synchronized void claimConnected() {
|
||||
for (int id : InputDevice.getDeviceIds()) {
|
||||
InputDevice device = InputDevice.getDevice(id);
|
||||
if (!isFromController(device)) continue;
|
||||
int slot = slotFor(device);
|
||||
if (slot > 0 && playerWriters[slot] == null) publish(slot);
|
||||
}
|
||||
}
|
||||
|
||||
/** A controller went away: its player's buttons are let go, and a further player's pad is unplugged. */
|
||||
public synchronized void onDeviceRemoved(int deviceId) {
|
||||
for (int slot = 0; slot < SLOTS; slot++) {
|
||||
if (owners[slot] == deviceId) free(slot);
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens the client's Quick Access menu: a tap of the Deck's button, or the Guide-then-A chord. */
|
||||
@@ -497,88 +310,16 @@ public final class PadBridge {
|
||||
publish();
|
||||
}
|
||||
|
||||
/**
|
||||
* The slot this controller plays in: the one it has, else slot 0 for the device's own controls
|
||||
* and a further player's slot for a connected pad (slot 0 too while the device has no controls
|
||||
* of its own), else player 1's.
|
||||
*/
|
||||
private int slotFor(InputDevice device) {
|
||||
int id = device.getId();
|
||||
for (int slot = 0; slot < SLOTS; slot++) {
|
||||
if (owners[slot] == id) return slot;
|
||||
}
|
||||
for (int slot = 0; slot < SLOTS; slot++) {
|
||||
if (owners[slot] != NO_CONTROLLER && InputDevice.getDevice(owners[slot]) == null) free(slot);
|
||||
}
|
||||
if (isBuiltIn(device)) {
|
||||
// A connected pad playing as the device's own controls moves to a player's slot of its own.
|
||||
int was = owners[0];
|
||||
if (was != NO_CONTROLLER) {
|
||||
free(0);
|
||||
InputDevice other = InputDevice.getDevice(was);
|
||||
if (other != null && !isBuiltIn(other)) {
|
||||
int to = claim(other, 1);
|
||||
if (to > 0) publish(to);
|
||||
}
|
||||
}
|
||||
owners[0] = id;
|
||||
Log.i(TAG, "\"" + device.getName() + "\" (id " + id + ") is player 1, the device's own controls");
|
||||
return 0;
|
||||
}
|
||||
int slot = claim(device, builtInConnected() ? 1 : 0);
|
||||
return slot >= 0 ? slot : 0;
|
||||
}
|
||||
|
||||
/** Takes the first free slot from {@code from} for this controller, or -1 if none is free. */
|
||||
private int claim(InputDevice device, int from) {
|
||||
for (int slot = from; slot < SLOTS; slot++) {
|
||||
if (owners[slot] != NO_CONTROLLER) continue;
|
||||
owners[slot] = device.getId();
|
||||
Log.i(TAG, "\"" + device.getName() + "\" (id " + device.getId() + ") is player " + (slot + 1));
|
||||
return slot;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static boolean builtInConnected() {
|
||||
for (int id : InputDevice.getDeviceIds()) {
|
||||
InputDevice device = InputDevice.getDevice(id);
|
||||
if (isFromController(device) && isBuiltIn(device)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private PadState stateFor(int slot) {
|
||||
return slot == 0 ? state : playerStates[slot];
|
||||
}
|
||||
|
||||
private void free(int slot) {
|
||||
Log.i(TAG, "player " + (slot + 1) + " (id " + owners[slot] + ") left");
|
||||
owners[slot] = NO_CONTROLLER;
|
||||
slotControllers.set(slot, NO_CONTROLLER);
|
||||
if (slot == 0) {
|
||||
state.clear();
|
||||
publish();
|
||||
return;
|
||||
}
|
||||
playerStates[slot].clear();
|
||||
if (playerWriters[slot] != null) {
|
||||
playerWriters[slot].destroy();
|
||||
playerWriters[slot] = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Per event, so the common case - the same pad as last time - is a single compare. */
|
||||
private void noteDevice(InputDevice device, int slot) {
|
||||
private void noteDevice(InputDevice device) {
|
||||
activeControllerId = device.getId();
|
||||
slotControllers.set(slot, device.getId());
|
||||
if (device.getId() == lastDeviceId) return;
|
||||
lastDeviceId = device.getId();
|
||||
statDevice = device.getName();
|
||||
if (seenDevices.add(device.getId())) {
|
||||
Log.i(TAG, String.format(java.util.Locale.ROOT, "first input from \"%s\" (%04x:%04x, id %d, sources 0x%x); slot %d, ring %s, deck pad %b",
|
||||
Log.i(TAG, String.format(java.util.Locale.ROOT, "first input from \"%s\" (%04x:%04x, id %d, sources 0x%x); ring %s, deck pad %b",
|
||||
device.getName(), device.getVendorId(), device.getProductId(), device.getId(), device.getSources(),
|
||||
slot, open ? "open" : "not open yet", SessionState.getDeckPad()));
|
||||
open ? "open" : "not open yet", SessionState.getDeckPad()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -598,23 +339,6 @@ public final class PadBridge {
|
||||
statButtons = statAxes = statOnScreen = statDropped = 0;
|
||||
}
|
||||
|
||||
private void publish(int slot) {
|
||||
if (slot == 0) {
|
||||
publish();
|
||||
return;
|
||||
}
|
||||
FakeInputWriter player = playerWriters[slot];
|
||||
if (player == null) {
|
||||
player = new FakeInputWriter(fakeInputDir.getAbsolutePath(), slot);
|
||||
if (!player.open()) {
|
||||
statDropped++;
|
||||
return;
|
||||
}
|
||||
playerWriters[slot] = player;
|
||||
}
|
||||
player.writePad(playerStates[slot]);
|
||||
}
|
||||
|
||||
private void publish() {
|
||||
if (!open && !start()) {
|
||||
statDropped++;
|
||||
|
||||
@@ -20,10 +20,9 @@ import kotlin.math.roundToInt
|
||||
* and units as SDL's Deck driver undoes them (SDL_hidapi_steamdeck.c): X right, Y away from the
|
||||
* player, Z up; 1 g = 16384, 2000 °/s = 32768. The Steam client calibrates the gyro itself.
|
||||
*
|
||||
* Sensors run only while the session is on screen with the pad a Deck controller, and the client
|
||||
* wants motion (see [follow]), sampled as often as the Deck reports (4 ms); each reading goes
|
||||
* straight into the pad's ring ([FakeInputWriter.writeMotion]), where libfakeinput's 4 ms report
|
||||
* picks up the latest.
|
||||
* Sensors run only while the session is on screen with the pad a Deck controller, sampled as often
|
||||
* as the Deck reports (4 ms); each reading goes straight into the pad's ring
|
||||
* ([FakeInputWriter.writeMotion]), where libfakeinput's 4 ms report picks up the latest.
|
||||
*/
|
||||
class PadMotion(private val context: Context, private val rotation: () -> Int) : SensorEventListener {
|
||||
private val sensors = context.getSystemService(SensorManager::class.java)
|
||||
@@ -33,11 +32,6 @@ class PadMotion(private val context: Context, private val rotation: () -> Int) :
|
||||
// Touched only on the worker thread.
|
||||
private val accel = ShortArray(3)
|
||||
private val gyro = ShortArray(3)
|
||||
private var reading = false
|
||||
private var idlePolls = 0
|
||||
// Asking the display costs more than the rest of a reading, which arrives every 2 ms.
|
||||
private var turn = Surface.ROTATION_0
|
||||
private var turnAt = 0L
|
||||
|
||||
fun start() {
|
||||
if (thread != null || sensors == null) return
|
||||
@@ -51,13 +45,8 @@ class PadMotion(private val context: Context, private val rotation: () -> Int) :
|
||||
thread = worker
|
||||
val handler = Handler(worker.looper)
|
||||
active = true
|
||||
handler.post(object : Runnable {
|
||||
override fun run() {
|
||||
if (!active) return
|
||||
follow(handler, gyroscope, accelerometer)
|
||||
handler.postDelayed(this, POLL_MS)
|
||||
}
|
||||
})
|
||||
gyroscope?.let { sensors.registerListener(this, it, SAMPLING_US, handler) }
|
||||
accelerometer?.let { sensors.registerListener(this, it, SAMPLING_US, handler) }
|
||||
Log.i(TAG, "pad motion: gyro ${gyroscope?.name ?: "none"}, accelerometer ${accelerometer?.name ?: "none"}")
|
||||
}
|
||||
|
||||
@@ -65,71 +54,30 @@ class PadMotion(private val context: Context, private val rotation: () -> Int) :
|
||||
val worker = thread ?: return
|
||||
thread = null
|
||||
active = false
|
||||
// On the worker, after any poll or reading already queued there, so nothing registers again
|
||||
// or overwrites the still controller.
|
||||
sensors?.unregisterListener(this)
|
||||
// A still controller, not one frozen mid-turn - published on the worker, after any reading
|
||||
// already queued there, so nothing can overwrite it.
|
||||
Handler(worker.looper).post {
|
||||
sensors?.unregisterListener(this)
|
||||
reading = false
|
||||
idlePolls = 0
|
||||
still()
|
||||
gyro.fill(0)
|
||||
FakeInputWriter.writeMotion(SLOT, accel, gyro)
|
||||
}
|
||||
worker.quitSafely()
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the sensors while the client wants motion: the Deck's IMU mode (unknown counts as
|
||||
* wanted), or Steam's touch controls asking for gyro. A game whose layout has no gyro turns the
|
||||
* mode off; Steam's own menus keep it on. The client also turns it off and on again for a few
|
||||
* ms whenever it re-applies a layout, so the sensors stop only after two polls without it.
|
||||
*/
|
||||
private fun follow(handler: Handler, gyroscope: Sensor?, accelerometer: Sensor?) {
|
||||
val touch = SteamTouchDevice.current
|
||||
val wanted = FakeInputWriter.readImuMode(SLOT) != 0 ||
|
||||
touch != null && touch.plugged && touch.gyroRequested && touch.motionEnabled
|
||||
idlePolls = if (wanted) 0 else idlePolls + 1
|
||||
if (wanted && !reading) {
|
||||
reading = true
|
||||
turnAt = 0L
|
||||
gyroscope?.let { sensors?.registerListener(this, it, SAMPLING_US, handler) }
|
||||
accelerometer?.let { sensors?.registerListener(this, it, SAMPLING_US, handler) }
|
||||
Log.i(TAG, "pad motion: reading")
|
||||
} else if (reading && idlePolls >= 2) {
|
||||
reading = false
|
||||
sensors?.unregisterListener(this)
|
||||
still()
|
||||
Log.i(TAG, "pad motion: paused, the client wants none")
|
||||
}
|
||||
}
|
||||
|
||||
/** A still controller, not one frozen mid-turn. */
|
||||
private fun still() {
|
||||
gyro.fill(0)
|
||||
FakeInputWriter.writeMotion(SLOT, accel, gyro)
|
||||
SteamTouchDevice.current?.setMotion(accel, gyro)
|
||||
}
|
||||
|
||||
override fun onSensorChanged(event: SensorEvent) {
|
||||
if (!active) return
|
||||
val values = event.values
|
||||
if (event.timestamp - turnAt > ROTATION_REFRESH_NS) {
|
||||
turn = rotation()
|
||||
turnAt = event.timestamp
|
||||
}
|
||||
// Natural orientation to the screen's: Android's display-rotation transform.
|
||||
val x: Float
|
||||
val y: Float
|
||||
when (turn) {
|
||||
Surface.ROTATION_90 -> { x = -values[1]; y = values[0] }
|
||||
Surface.ROTATION_180 -> { x = -values[0]; y = -values[1] }
|
||||
Surface.ROTATION_270 -> { x = values[1]; y = -values[0] }
|
||||
else -> { x = values[0]; y = values[1] }
|
||||
val (x, y) = when (rotation()) {
|
||||
Surface.ROTATION_90 -> -values[1] to values[0]
|
||||
Surface.ROTATION_180 -> -values[0] to -values[1]
|
||||
Surface.ROTATION_270 -> values[1] to -values[0]
|
||||
else -> values[0] to values[1]
|
||||
}
|
||||
val z = values[2]
|
||||
val target: ShortArray
|
||||
val scale: Float
|
||||
when (event.sensor.type) {
|
||||
Sensor.TYPE_GYROSCOPE -> { target = gyro; scale = GYRO_COUNTS_PER_RAD_S }
|
||||
Sensor.TYPE_ACCELEROMETER -> { target = accel; scale = ACCEL_COUNTS_PER_M_S2 }
|
||||
val (target, scale) = when (event.sensor.type) {
|
||||
Sensor.TYPE_GYROSCOPE -> gyro to GYRO_COUNTS_PER_RAD_S
|
||||
Sensor.TYPE_ACCELEROMETER -> accel to ACCEL_COUNTS_PER_M_S2
|
||||
else -> return
|
||||
}
|
||||
// Screen frame (right, up, towards the player) to the Deck's (right, away, up).
|
||||
@@ -137,7 +85,6 @@ class PadMotion(private val context: Context, private val rotation: () -> Int) :
|
||||
target[1] = counts(-z * scale)
|
||||
target[2] = counts(y * scale)
|
||||
FakeInputWriter.writeMotion(SLOT, accel, gyro)
|
||||
SteamTouchDevice.current?.setMotion(accel, gyro)
|
||||
}
|
||||
|
||||
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit
|
||||
@@ -150,9 +97,6 @@ class PadMotion(private val context: Context, private val rotation: () -> Int) :
|
||||
private const val SLOT = 0
|
||||
/** The Deck controller's report interval (libfakeinput: DECK_REPORT_INTERVAL_US). */
|
||||
private const val SAMPLING_US = 4000
|
||||
/** How often [follow] checks whether the client wants motion. */
|
||||
private const val POLL_MS = 500L
|
||||
private const val ROTATION_REFRESH_NS = 250_000_000L
|
||||
private const val ACCEL_COUNTS_PER_M_S2 = 16384f / SensorManager.GRAVITY_EARTH
|
||||
private const val GYRO_COUNTS_PER_RAD_S = (32768f / 2000f) * (180f / Math.PI.toFloat())
|
||||
}
|
||||
|
||||
@@ -1,362 +0,0 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothGatt
|
||||
import android.bluetooth.BluetoothGattCallback
|
||||
import android.bluetooth.BluetoothGattCharacteristic
|
||||
import android.bluetooth.BluetoothGattDescriptor
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.bluetooth.BluetoothProfile
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.util.Log
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.util.ArrayDeque
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Reads the Steam Controller (2025, Triton) through its Valve BLE service. State reports remain
|
||||
* native Triton packets and are published to the session's separate 28DE:1303 HID device. Steam's
|
||||
* output reports travel back to their matching BLE characteristics, preserving grip capacitance,
|
||||
* trackpads, sensors, and controller haptics without folding the controller into the Deck target.
|
||||
*
|
||||
* Lizard mode is refreshed while connected; when a session ends, the controller restores it itself.
|
||||
*/
|
||||
@SuppressLint("MissingPermission") // BLUETOOTH is granted at install below targetSdk 31
|
||||
class SteamControllerBle(private val context: Context) {
|
||||
private val thread = HandlerThread("steam-controller-ble").apply { start() }
|
||||
private val handler = Handler(thread.looper)
|
||||
private val outputReports = mutableMapOf<Int, UUID>()
|
||||
/** GATT allows one operation at a time: writes carry a value, a feature read none. */
|
||||
private val queuedWrites = ArrayDeque<Pair<UUID, ByteArray?>>()
|
||||
private var gatt: BluetoothGatt? = null
|
||||
private var running = false
|
||||
private var input: UUID? = null
|
||||
private var reportId = 0
|
||||
private var lizardOffSent = false
|
||||
private var reportedNone = false
|
||||
private var discoveries = 0
|
||||
private var outputSequence = 0L
|
||||
private var outputWritePending = false
|
||||
/** The Steam client's feature read being answered, so the poll queues each one once. */
|
||||
private var featureRead = 0L
|
||||
|
||||
/** Looks for a paired Steam Controller and keeps a connection to it until [stop]. */
|
||||
fun start() = handler.post {
|
||||
if (running) return@post
|
||||
running = true
|
||||
Log.i(TAG, "looking for a paired Steam Controller")
|
||||
connect()
|
||||
}
|
||||
|
||||
/** Lets the controller go (it returns to lizard mode by itself); this object is done after. */
|
||||
fun stop() = handler.post {
|
||||
running = false
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
disconnect("session ended")
|
||||
thread.quitSafely()
|
||||
}
|
||||
|
||||
private fun connect() {
|
||||
if (!running || gatt != null) return
|
||||
val adapter = (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter
|
||||
val bonded = runCatching { adapter?.takeIf(BluetoothAdapter::isEnabled)?.bondedDevices }
|
||||
.onFailure { Log.w(TAG, "paired devices unreadable", it) }
|
||||
.getOrNull()
|
||||
val device = bonded?.firstOrNull(::isSteamController)
|
||||
if (device == null) {
|
||||
if (!reportedNone) {
|
||||
reportedNone = true
|
||||
Log.i(TAG, "no paired Steam Controller (Bluetooth ${if (adapter?.isEnabled == true) "on" else "off"}; paired: " +
|
||||
(bonded?.joinToString { "\"${it.name}\" type ${it.type}" } ?: "unreadable") + "); looking again every ${RETRY_MS / 1000} s")
|
||||
}
|
||||
handler.postDelayed(::connect, RETRY_MS)
|
||||
return
|
||||
}
|
||||
Log.i(TAG, "connecting to \"${device.name}\"")
|
||||
gatt = device.connectGatt(context, false, callback, BluetoothDevice.TRANSPORT_LE, BluetoothDevice.PHY_LE_1M_MASK, handler)
|
||||
}
|
||||
|
||||
private fun disconnect(why: String) {
|
||||
val g = gatt ?: return
|
||||
gatt = null
|
||||
discoveries = 0
|
||||
input = null
|
||||
lizardOffSent = false
|
||||
outputWritePending = false
|
||||
featureRead = 0L
|
||||
queuedWrites.clear()
|
||||
outputReports.clear()
|
||||
connected = false
|
||||
FakeInputWriter.setTritonConnected(SLOT, false)
|
||||
g.disconnect()
|
||||
g.close()
|
||||
release()
|
||||
Log.i(TAG, "disconnected: $why")
|
||||
listener?.invoke()
|
||||
}
|
||||
|
||||
private val callback = object : BluetoothGattCallback() {
|
||||
override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) {
|
||||
if (g !== gatt) return
|
||||
if (newState == BluetoothProfile.STATE_CONNECTED) {
|
||||
g.discoverServices()
|
||||
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
|
||||
disconnect("link lost (status $status)")
|
||||
if (running) handler.postDelayed(::connect, RETRY_MS)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServicesDiscovered(g: BluetoothGatt, status: Int) {
|
||||
if (g !== gatt) return
|
||||
if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||
disconnect("service discovery failed (status $status)")
|
||||
if (running) handler.postDelayed(::connect, RETRY_MS)
|
||||
return
|
||||
}
|
||||
// The Triton needs data length extensions, which Android enables for a large MTU.
|
||||
if (!g.requestMtu(517)) subscribe(g)
|
||||
}
|
||||
|
||||
override fun onMtuChanged(g: BluetoothGatt, mtu: Int, status: Int) {
|
||||
if (g === gatt) subscribe(g)
|
||||
}
|
||||
|
||||
override fun onDescriptorWrite(g: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
|
||||
if (g !== gatt || descriptor.characteristic.uuid != input) return
|
||||
Log.i(TAG, "subscribed to report 0x%02x (status %d)".format(reportId, status))
|
||||
connected = status == BluetoothGatt.GATT_SUCCESS
|
||||
FakeInputWriter.setTritonConnected(SLOT, connected)
|
||||
listener?.invoke()
|
||||
if (connected) {
|
||||
refreshLizardMode()
|
||||
pollOutput()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCharacteristicWrite(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) {
|
||||
if (g !== gatt) return
|
||||
if (characteristic.uuid == REPORT && !lizardOffSent) {
|
||||
lizardOffSent = true
|
||||
Log.i(TAG, "lizard mode off (status $status)")
|
||||
} else if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||
Log.w(TAG, "output report write failed for ${characteristic.uuid} (status $status)")
|
||||
}
|
||||
outputWritePending = false
|
||||
writeNextOutput()
|
||||
}
|
||||
|
||||
override fun onCharacteristicRead(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray, status: Int) {
|
||||
if (g === gatt) onFeatureRead(value.takeIf { status == BluetoothGatt.GATT_SUCCESS })
|
||||
}
|
||||
|
||||
@Deprecated("Below Android 13 only", ReplaceWith(""))
|
||||
override fun onCharacteristicRead(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) {
|
||||
@Suppress("DEPRECATION")
|
||||
if (g === gatt) onFeatureRead(characteristic.value.takeIf { status == BluetoothGatt.GATT_SUCCESS })
|
||||
}
|
||||
|
||||
// Android 13 hands the value over with the call; older ones only through the characteristic.
|
||||
override fun onCharacteristicChanged(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray) {
|
||||
if (g === gatt && characteristic.uuid == input) onState(value)
|
||||
}
|
||||
|
||||
@Deprecated("Below Android 13 only", ReplaceWith(""))
|
||||
override fun onCharacteristicChanged(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
|
||||
@Suppress("DEPRECATION")
|
||||
if (g === gatt && characteristic.uuid == input) onState(characteristic.value ?: return)
|
||||
}
|
||||
}
|
||||
|
||||
private fun subscribe(g: BluetoothGatt) {
|
||||
val service = g.getService(SERVICE)
|
||||
if (service == null) {
|
||||
Log.w(TAG, "no Valve service on \"${g.device.name}\" (attempt ${discoveries + 1}); services: " +
|
||||
g.services.joinToString { it.uuid.toString().take(8) })
|
||||
// Android may answer from the service list its HID host cached, which can lack the
|
||||
// vendor service: ask the controller again before giving up on it.
|
||||
if (++discoveries < 3) {
|
||||
refreshCache(g)
|
||||
handler.postDelayed({ if (g === gatt) g.discoverServices() }, 500)
|
||||
} else {
|
||||
disconnect("no Valve service")
|
||||
if (running) handler.postDelayed(::connect, RETRY_MS)
|
||||
}
|
||||
return
|
||||
}
|
||||
val chr = service.getCharacteristic(INPUT_0X47)?.also { reportId = 0x47 }
|
||||
?: service.getCharacteristic(INPUT_0X45)?.also { reportId = 0x45 }
|
||||
if (chr == null) {
|
||||
disconnect("no state report characteristic")
|
||||
return
|
||||
}
|
||||
outputReports.clear()
|
||||
service.characteristics.forEach { characteristic ->
|
||||
val uuid = characteristic.uuid.toString().lowercase()
|
||||
if (uuid.startsWith(OUTPUT_UUID_PREFIX)) {
|
||||
val characteristicId = uuid.substring(6, 8).toIntOrNull(16) ?: return@forEach
|
||||
val outputId = characteristicId - OUTPUT_UUID_OFFSET
|
||||
if (outputId in 0x80..0x85) outputReports[outputId] = characteristic.uuid
|
||||
}
|
||||
}
|
||||
Log.i(TAG, "found Triton haptic reports: ${outputReports.keys.sorted().joinToString { "0x%02x".format(it) }}")
|
||||
input = chr.uuid
|
||||
g.setCharacteristicNotification(chr, true)
|
||||
val cccd = chr.getDescriptor(CCCD) ?: return disconnect("state report cannot notify")
|
||||
@Suppress("DEPRECATION")
|
||||
cccd.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
|
||||
@Suppress("DEPRECATION")
|
||||
g.writeDescriptor(cccd)
|
||||
}
|
||||
|
||||
/** BluetoothGatt.refresh(): hidden, but used to drop stale service caches. */
|
||||
private fun refreshCache(g: BluetoothGatt) {
|
||||
runCatching { g.javaClass.getMethod("refresh").invoke(g) }
|
||||
.onFailure { Log.w(TAG, "could not refresh the service cache", it) }
|
||||
}
|
||||
|
||||
/** SDL's DisableSteamTritonLizardMode: ID_SET_SETTINGS_VALUES (0x87), setting 9 = off. */
|
||||
private fun refreshLizardMode() {
|
||||
if (!connected) return
|
||||
val msg = ByteArray(FEATURE_BYTES)
|
||||
msg[0] = 0x87.toByte(); msg[1] = 3; msg[2] = 9
|
||||
enqueueOutput(REPORT, msg)
|
||||
handler.postDelayed(::refreshLizardMode, LIZARD_REFRESH_MS)
|
||||
}
|
||||
|
||||
/** Preserve the full 0x45 or 0x47 packet so Triton-only bits reach Steam's native driver. */
|
||||
private fun onState(data: ByteArray) {
|
||||
if (data.size < STATE_BYTES) return
|
||||
FakeInputWriter.writeTritonState(SLOT, reportId, data)
|
||||
}
|
||||
|
||||
/** A neutral report prevents a link lost mid-press from leaving controls held. */
|
||||
private fun release() {
|
||||
FakeInputWriter.writeTritonState(SLOT, if (reportId == 0x47) 0x47 else 0x45, ByteArray(STATE_BYTES))
|
||||
}
|
||||
|
||||
/** Drain Steam HID output reports and write each to Triton's matching BLE characteristic. */
|
||||
private fun pollOutput() {
|
||||
if (!running || !connected || gatt == null) return
|
||||
FakeInputWriter.readTritonOutput(SLOT, outputSequence)?.let { output ->
|
||||
outputSequence = ByteBuffer.wrap(output, 0, 8).order(ByteOrder.LITTLE_ENDIAN).long
|
||||
sendOutputReport(output[8] != 0.toByte(), output.copyOfRange(9, output.size))
|
||||
}
|
||||
val request = FakeInputWriter.readTritonFeatureRequest(SLOT)
|
||||
if (request != 0L && request != featureRead) {
|
||||
featureRead = request
|
||||
// Behind any queued write, so a read answers the feature report written before it.
|
||||
queuedWrites.addLast(REPORT to null)
|
||||
writeNextOutput()
|
||||
}
|
||||
handler.postDelayed(::pollOutput, OUTPUT_POLL_MS)
|
||||
}
|
||||
|
||||
/** The controller's answer to a feature read (its info, for one), or null when the read failed. */
|
||||
private fun onFeatureRead(value: ByteArray?) {
|
||||
outputWritePending = false
|
||||
if (featureRead != 0L) {
|
||||
if (value == null) Log.w(TAG, "feature read failed")
|
||||
FakeInputWriter.writeTritonFeatureReply(SLOT, featureRead, value)
|
||||
}
|
||||
writeNextOutput()
|
||||
}
|
||||
|
||||
private fun sendOutputReport(feature: Boolean, report: ByteArray) {
|
||||
if (report.size < 2) return
|
||||
val characteristic = if (feature) {
|
||||
REPORT
|
||||
} else {
|
||||
val id = report[0].toInt() and 0xff
|
||||
outputReports[id] ?: run {
|
||||
Log.w(TAG, "no BLE output characteristic for report 0x%02x".format(id))
|
||||
return
|
||||
}
|
||||
}
|
||||
// SDL's BLE driver removes the HID report ID and trailing padding byte before transmission.
|
||||
enqueueOutput(characteristic, report.copyOfRange(1, report.size - 1))
|
||||
}
|
||||
|
||||
private fun enqueueOutput(characteristic: UUID, value: ByteArray?) {
|
||||
queuedWrites.addLast(characteristic to value)
|
||||
writeNextOutput()
|
||||
}
|
||||
|
||||
private fun writeNextOutput() {
|
||||
if (outputWritePending) return
|
||||
val g = gatt ?: return
|
||||
val (uuid, value) = queuedWrites.pollFirst() ?: return
|
||||
val characteristic = g.getService(SERVICE)?.getCharacteristic(uuid)
|
||||
if (characteristic == null) {
|
||||
Log.w(TAG, "BLE output characteristic $uuid disappeared")
|
||||
return
|
||||
}
|
||||
if (value == null) {
|
||||
if (g.readCharacteristic(characteristic)) {
|
||||
outputWritePending = true
|
||||
} else {
|
||||
queuedWrites.addFirst(uuid to null)
|
||||
handler.postDelayed(::writeNextOutput, OUTPUT_RETRY_MS)
|
||||
}
|
||||
return
|
||||
}
|
||||
@Suppress("DEPRECATION")
|
||||
characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
|
||||
@Suppress("DEPRECATION")
|
||||
characteristic.value = value
|
||||
@Suppress("DEPRECATION")
|
||||
if (!g.writeCharacteristic(characteristic)) {
|
||||
queuedWrites.addFirst(uuid to value)
|
||||
handler.postDelayed(::writeNextOutput, OUTPUT_RETRY_MS)
|
||||
} else {
|
||||
outputWritePending = true
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SteamControllerBle"
|
||||
/** The paired controller's native Triton reports live in slot 0's extended ring. */
|
||||
private const val SLOT = 0
|
||||
private const val RETRY_MS = 5_000L
|
||||
private const val LIZARD_REFRESH_MS = 3_000L
|
||||
private const val OUTPUT_POLL_MS = 4L
|
||||
private const val OUTPUT_RETRY_MS = 10L
|
||||
/** SDL's 64-byte feature report less the report ID and the trailing byte. */
|
||||
private const val FEATURE_BYTES = 62
|
||||
private const val STATE_BYTES = 45
|
||||
private const val OUTPUT_UUID_PREFIX = "100f6c"
|
||||
private const val OUTPUT_UUID_OFFSET = 0x35
|
||||
|
||||
private val SERVICE = UUID.fromString("100F6C32-1735-4313-B402-38567131E5F3")
|
||||
private val INPUT_0X45 = UUID.fromString("100F6C7A-1735-4313-B402-38567131E5F3")
|
||||
private val INPUT_0X47 = UUID.fromString("100F6C7C-1735-4313-B402-38567131E5F3")
|
||||
private val REPORT = UUID.fromString("100F6C34-1735-4313-B402-38567131E5F3")
|
||||
private val CCCD = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
|
||||
|
||||
private fun isSteamController(device: BluetoothDevice): Boolean =
|
||||
device.type and BluetoothDevice.DEVICE_TYPE_LE != 0 &&
|
||||
(device.name ?: "").let { it.startsWith("Steam Ctrl") || it.startsWith("SteamController") }
|
||||
|
||||
/** Whether a session should expose a Triton HID node for a paired controller. */
|
||||
@JvmStatic
|
||||
fun hasPairedController(context: Context): Boolean {
|
||||
val adapter = (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter ?: return false
|
||||
return runCatching { adapter.isEnabled && adapter.bondedDevices.any(::isSteamController) }
|
||||
.onFailure { Log.w(TAG, "paired devices unreadable", it) }
|
||||
.getOrDefault(false)
|
||||
}
|
||||
|
||||
/** A Steam Controller is connected and feeding its native HID reports. */
|
||||
@Volatile @JvmStatic
|
||||
var connected = false
|
||||
private set
|
||||
|
||||
/** Notifies the activity when [connected] changes. */
|
||||
@Volatile @JvmStatic
|
||||
var listener: (() -> Unit)? = null
|
||||
}
|
||||
}
|
||||
@@ -1,285 +0,0 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.LruCache
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* What each touch control is bound to, and the icon it shows - Steam's, as its configurator keeps
|
||||
* them.
|
||||
*
|
||||
* A Steam Input binding is one string: `<action>, <label>, <icon>, <#foreground #background>`, e.g.
|
||||
* `xinput_button A, Jump, ghost_045_move_0400.png, #232323 #E4E4E4`. The icon is a file name looked
|
||||
* up as Steam does (steamui.so): the game's own `TouchMenuIcons` folder, then the binding icon
|
||||
* library `tenfoot/resource/images/library/controller/binding_icons`. Steam draws it filled with
|
||||
* the foreground colour through its own alpha (mask-image) on a button of the background colour.
|
||||
* Steam Link is sent these per control by the client it streams from; here they are read from the
|
||||
* game's touch config, and editing one writes the binding string back, as the configurator does.
|
||||
*/
|
||||
object SteamTouchBindings {
|
||||
|
||||
/** Steam's binding icon colours (the configurator's palette, steamui sp.js), and its defaults. */
|
||||
val PALETTE = listOf(
|
||||
"#FFFFFF", "#E4E4E4", "#AAAAAA", "#787878", "#434343", "#222222", "#0000AD", "#0045AD", "#0074AD",
|
||||
"#00ADAD", "#33AD69", "#00AD3D", "#00AD00", "#48B119", "#74AD00", "#96AD00", "#ADA200", "#AD5D00",
|
||||
"#AD3A00", "#AD0000", "#AD0051", "#AD007F", "#AD00AD", "#6800AD", "#4800AD",
|
||||
)
|
||||
const val DEFAULT_FOREGROUND = "#232323"
|
||||
const val DEFAULT_BACKGROUND = "#E4E4E4"
|
||||
|
||||
/** A parsed binding string. Empty strings for absent fields. */
|
||||
data class Binding(val action: String, val label: String, val icon: String, val foreground: String, val background: String) {
|
||||
fun compose(): String =
|
||||
if (icon.isEmpty() && foreground.isEmpty() && background.isEmpty()) "$action, $label, "
|
||||
else "$action, $label, $icon, ${listOf(foreground, background).filter { it.isNotEmpty() }.joinToString(" ")}"
|
||||
|
||||
val hasIcon get() = icon.isNotEmpty()
|
||||
}
|
||||
|
||||
fun parse(binding: String): Binding {
|
||||
val parts = binding.split(',').map { it.trim() }
|
||||
val colors = parts.getOrNull(3).orEmpty().split(' ').filter { it.startsWith("#") }
|
||||
return Binding(parts.getOrNull(0).orEmpty(), parts.getOrNull(1).orEmpty(), parts.getOrNull(2).orEmpty(),
|
||||
colors.getOrNull(0).orEmpty(), colors.getOrNull(1).orEmpty())
|
||||
}
|
||||
|
||||
/** Where a control's binding lives: its input in the group the preset puts on its source. */
|
||||
data class Ref(val groupId: String, val input: String)
|
||||
|
||||
/** The source and input names a control reads (D-pad: its four directions). */
|
||||
private fun inputsOf(type: Int): List<Pair<String, String>> = when (type) {
|
||||
SteamTouchConfig.A -> listOf("button_diamond" to "button_a")
|
||||
SteamTouchConfig.B -> listOf("button_diamond" to "button_b")
|
||||
SteamTouchConfig.X -> listOf("button_diamond" to "button_x")
|
||||
SteamTouchConfig.Y -> listOf("button_diamond" to "button_y")
|
||||
SteamTouchConfig.SELECT -> listOf("switch" to "button_menu")
|
||||
SteamTouchConfig.START -> listOf("switch" to "button_escape")
|
||||
SteamTouchConfig.BUMPER_LEFT -> listOf("switch" to "left_bumper")
|
||||
SteamTouchConfig.BUMPER_RIGHT -> listOf("switch" to "right_bumper")
|
||||
SteamTouchConfig.TRIGGER_LEFT -> listOf("left_trigger" to "click")
|
||||
SteamTouchConfig.TRIGGER_RIGHT -> listOf("right_trigger" to "click")
|
||||
SteamTouchConfig.JOYSTICK_LEFT_BUTTON -> listOf("joystick" to "click")
|
||||
SteamTouchConfig.JOYSTICK_RIGHT_BUTTON -> listOf("right_joystick" to "click")
|
||||
SteamTouchConfig.MACRO_1_FINGER -> listOf("switch" to "button_macro_1finger")
|
||||
SteamTouchConfig.MACRO_2_FINGER -> listOf("switch" to "button_macro_2finger")
|
||||
SteamTouchConfig.DPAD -> DPAD_INPUTS.map { "dpad" to it }
|
||||
in SteamTouchConfig.MACRO_0..SteamTouchConfig.MACRO_0 + 7 -> listOf("switch" to "button_macro${type - SteamTouchConfig.MACRO_0}")
|
||||
else -> emptyList()
|
||||
}
|
||||
|
||||
/** D-pad directions, in the order [refsFor] returns them: up, down, left, right. */
|
||||
val DPAD_INPUTS = listOf("dpad_north", "dpad_south", "dpad_west", "dpad_east")
|
||||
|
||||
/**
|
||||
* Where control [type]'s bindings are for preset [preset] (the action set less one) and its
|
||||
* active [layers]: a layer's group wins over the base's for the same source.
|
||||
*/
|
||||
fun refsFor(mappings: KeyValues.Node, type: Int, preset: Int, layers: List<Int> = emptyList()): List<Ref?> {
|
||||
val presets = mappings.children("preset").associateBy { it.string("id")?.toIntOrNull() ?: -1 }
|
||||
val stack = layers.reversed().mapNotNull { presets[it - 1] } + listOfNotNull(presets[preset] ?: presets.values.firstOrNull())
|
||||
return inputsOf(type).map { (source, input) ->
|
||||
stack.firstNotNullOfOrNull { p -> groupFor(p, source)?.let { Ref(it, input) } }
|
||||
}
|
||||
}
|
||||
|
||||
private fun groupFor(preset: KeyValues.Node, source: String): String? =
|
||||
preset.child("group_source_bindings")?.entries?.firstOrNull { (_, v) ->
|
||||
val parts = (v as? String)?.split(' ') ?: return@firstOrNull false
|
||||
parts.getOrNull(0) == source && parts.getOrNull(1) == "active" && !parts.contains("modeshift")
|
||||
}?.first
|
||||
|
||||
/** The binding at [ref]: its first activator's first binding (Full_Press when there is one). */
|
||||
fun bindingAt(mappings: KeyValues.Node, ref: Ref): Binding? {
|
||||
val group = mappings.children("group").firstOrNull { it.string("id") == ref.groupId } ?: return null
|
||||
val activators = group.child("inputs")?.child(ref.input)?.child("activators") ?: return null
|
||||
val activator = activators.child("Full_Press") ?: activators.entries.firstNotNullOfOrNull { it.second as? KeyValues.Node }
|
||||
return activator?.child("bindings")?.string("binding")?.let(::parse)
|
||||
}
|
||||
|
||||
/** A short glyph for a binding with no icon or label: what it presses. */
|
||||
fun glyph(binding: Binding?): String? {
|
||||
val words = binding?.action?.split(' ') ?: return null
|
||||
val arg = words.getOrNull(1)?.uppercase() ?: return null
|
||||
return when (words[0]) {
|
||||
"xinput_button" -> XINPUT_GLYPHS[arg] ?: arg
|
||||
"key_press" -> KEY_GLYPHS[arg] ?: arg.take(4)
|
||||
"mouse_button" -> when (arg) { "LEFT" -> "LMB"; "RIGHT" -> "RMB"; "MIDDLE" -> "MMB"; else -> "M" + arg.take(2) }
|
||||
"mouse_wheel" -> if (arg.contains("UP")) "▲" else "▼"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private val XINPUT_GLYPHS = mapOf(
|
||||
"SHOULDER_LEFT" to "LB", "SHOULDER_RIGHT" to "RB", "TRIGGER_LEFT" to "LT", "TRIGGER_RIGHT" to "RT",
|
||||
"JOYSTICK_LEFT" to "L3", "JOYSTICK_RIGHT" to "R3", "START" to "☰", "SELECT" to "⧉", "BACK" to "⧉",
|
||||
"DPAD_UP" to "▲", "DPAD_DOWN" to "▼", "DPAD_LEFT" to "◀", "DPAD_RIGHT" to "▶",
|
||||
)
|
||||
private val KEY_GLYPHS = mapOf(
|
||||
"RETURN" to "⏎", "SPACE" to "␣", "ESCAPE" to "Esc", "TAB" to "Tab", "BACKSPACE" to "⌫",
|
||||
"LEFT_SHIFT" to "⇧", "RIGHT_SHIFT" to "⇧", "LEFT_CONTROL" to "Ctrl", "RIGHT_CONTROL" to "Ctrl",
|
||||
"LEFT_ALT" to "Alt", "RIGHT_ALT" to "Alt", "UP_ARROW" to "↑", "DOWN_ARROW" to "↓",
|
||||
"LEFT_ARROW" to "←", "RIGHT_ARROW" to "→",
|
||||
)
|
||||
|
||||
// ---- Writing a binding back ----
|
||||
|
||||
/**
|
||||
* Rewrites every binding of [ref]'s input in config [text] (VDF) with [change], keeping the rest
|
||||
* of the file byte for byte. Returns the new text, or null when the input is not there.
|
||||
*/
|
||||
fun rewrite(text: String, ref: Ref, change: (Binding) -> Binding): String? {
|
||||
val tokens = Vdf.tokens(text)
|
||||
val group = Vdf.blocks(tokens, 0, tokens.size, "group").firstOrNull { (s, e) ->
|
||||
Vdf.value(tokens, s, e, "id") == ref.groupId
|
||||
} ?: return null
|
||||
val inputs = Vdf.blocks(tokens, group.first, group.second, "inputs").firstOrNull() ?: return null
|
||||
val input = Vdf.blocks(tokens, inputs.first, inputs.second, ref.input).firstOrNull() ?: return null
|
||||
val targets = (input.first until input.second).filter { i ->
|
||||
tokens[i].text.equals("binding", true) && tokens[i].quoted && i + 1 < input.second && tokens[i + 1].quoted
|
||||
}.map { tokens[it + 1] }
|
||||
if (targets.isEmpty()) return null
|
||||
val out = StringBuilder(text)
|
||||
for (t in targets.sortedByDescending { it.start }) out.replace(t.start, t.end, change(parse(t.text)).compose())
|
||||
return out.toString()
|
||||
}
|
||||
|
||||
/** A VDF tokenizer that keeps where each token sits, for edits that leave the rest untouched. */
|
||||
private object Vdf {
|
||||
class Token(val text: String, val quoted: Boolean, val start: Int, val end: Int)
|
||||
|
||||
fun tokens(text: String): List<Token> {
|
||||
val out = mutableListOf<Token>()
|
||||
var i = 0
|
||||
while (i < text.length) {
|
||||
val c = text[i]
|
||||
when {
|
||||
c.isWhitespace() -> i++
|
||||
c == '/' && i + 1 < text.length && text[i + 1] == '/' -> while (i < text.length && text[i] != '\n') i++
|
||||
c == '{' || c == '}' -> { out += Token(c.toString(), false, i, i + 1); i++ }
|
||||
c == '"' -> {
|
||||
val start = i + 1
|
||||
i++
|
||||
while (i < text.length && text[i] != '"') { if (text[i] == '\\') i++; i++ }
|
||||
out += Token(text.substring(start, minOf(i, text.length)), true, start, minOf(i, text.length))
|
||||
i++
|
||||
}
|
||||
else -> {
|
||||
val start = i
|
||||
while (i < text.length && !text[i].isWhitespace() && text[i] != '{' && text[i] != '}' && text[i] != '"') i++
|
||||
out += Token(text.substring(start, i), false, start, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** The bodies (token index ranges inside the braces) of direct children named [key] of the
|
||||
* block spanning [from, to). */
|
||||
fun blocks(tokens: List<Token>, from: Int, to: Int, key: String): List<Pair<Int, Int>> {
|
||||
val found = mutableListOf<Pair<Int, Int>>()
|
||||
var i = from
|
||||
var depth = 0
|
||||
while (i < to) {
|
||||
val t = tokens[i]
|
||||
if (t.text == "{" && !t.quoted) depth++
|
||||
else if (t.text == "}" && !t.quoted) depth--
|
||||
else if (depth == 0 && t.quoted && t.text.equals(key, true) && i + 1 < to && tokens[i + 1].text == "{" && !tokens[i + 1].quoted) {
|
||||
val open = i + 1
|
||||
var d = 0
|
||||
var j = open
|
||||
while (j < to) {
|
||||
if (tokens[j].text == "{" && !tokens[j].quoted) d++
|
||||
if (tokens[j].text == "}" && !tokens[j].quoted) { d--; if (d == 0) break }
|
||||
j++
|
||||
}
|
||||
found += (open + 1) to j
|
||||
i = j + 1
|
||||
continue
|
||||
}
|
||||
i++
|
||||
}
|
||||
// The whole file is one top-level block ("controller_mappings"): look inside it too.
|
||||
if (found.isEmpty() && from == 0) {
|
||||
val top = tokens.indexOfFirst { it.text == "{" && !it.quoted }
|
||||
if (top > 0) {
|
||||
var d = 0
|
||||
var j = top
|
||||
while (j < to) {
|
||||
if (tokens[j].text == "{" && !tokens[j].quoted) d++
|
||||
if (tokens[j].text == "}" && !tokens[j].quoted) { d--; if (d == 0) break }
|
||||
j++
|
||||
}
|
||||
return blocks(tokens, top + 1, j, key)
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
/** A direct key-value of the block spanning [from, to). */
|
||||
fun value(tokens: List<Token>, from: Int, to: Int, key: String): String? {
|
||||
var depth = 0
|
||||
var i = from
|
||||
while (i < to) {
|
||||
val t = tokens[i]
|
||||
if (t.text == "{" && !t.quoted) depth++
|
||||
else if (t.text == "}" && !t.quoted) depth--
|
||||
else if (depth == 0 && t.quoted && t.text.equals(key, true) && i + 1 < to && tokens[i + 1].quoted) return tokens[i + 1].text
|
||||
i++
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Icons ----
|
||||
|
||||
private val cache = object : LruCache<String, Bitmap>(24 * 1024 * 1024) {
|
||||
override fun sizeOf(key: String, value: Bitmap) = value.byteCount
|
||||
}
|
||||
private val missing = HashSet<String>()
|
||||
|
||||
fun libraryDir(context: Context) = File(SteamTouchConfig.steamRoot(context), "tenfoot/resource/images/library/controller/binding_icons")
|
||||
|
||||
/** The game's own icons folder (`TouchMenuIcons` in its install directory), when it has one. */
|
||||
fun gameIconsDir(context: Context, appId: Int): File? = try {
|
||||
val manifest = File(SteamTouchConfig.steamRoot(context), "steamapps/appmanifest_$appId.acf")
|
||||
val installDir = KeyValues.parse(manifest.readText()).let { it.child("AppState") ?: it }.string("installdir")
|
||||
installDir?.let { File(SteamTouchConfig.steamRoot(context), "steamapps/common/$it/TouchMenuIcons") }?.takeIf { it.isDirectory }
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
fun iconFile(context: Context, appId: Int, name: String): File? {
|
||||
if (name.isEmpty() || name.contains('/')) return null
|
||||
gameIconsDir(context, appId)?.let { File(it, name) }?.takeIf { it.isFile }?.let { return it }
|
||||
File(libraryDir(context), name).takeIf { it.isFile }?.let { return it }
|
||||
return File(SteamTouchConfig.steamRoot(context), "steamui/images/controller/$name").takeIf { it.isFile }
|
||||
}
|
||||
|
||||
/** An icon, decoded at about [size] px. Blocking (reads a file); cached. */
|
||||
fun icon(context: Context, appId: Int, name: String, size: Int = 128): Bitmap? {
|
||||
val key = "$appId/$name/$size"
|
||||
cache.get(key)?.let { return it }
|
||||
if (key in missing) return null
|
||||
val file = iconFile(context, appId, name)
|
||||
val bitmap = file?.let {
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeFile(it.path, bounds)
|
||||
var sample = 1
|
||||
while (bounds.outWidth / (sample * 2) >= size) sample *= 2
|
||||
BitmapFactory.decodeFile(it.path, BitmapFactory.Options().apply { inSampleSize = sample })
|
||||
}
|
||||
if (bitmap == null) missing += key else cache.put(key, bitmap)
|
||||
return bitmap
|
||||
}
|
||||
|
||||
fun cachedIcon(appId: Int, name: String, size: Int = 128): Bitmap? = cache.get("$appId/$name/$size")
|
||||
|
||||
/** Icon names for the picker: the game's own first, then Steam's library by category. */
|
||||
fun iconNames(context: Context, appId: Int): List<String> {
|
||||
val own = gameIconsDir(context, appId)?.list()?.filter { it.endsWith(".png", true) }?.sorted().orEmpty()
|
||||
val library = libraryDir(context).list()?.filter { it.endsWith(".png", true) && it != "special_blank.png" }?.sorted().orEmpty()
|
||||
return own + library.filter { it !in own }
|
||||
}
|
||||
}
|
||||
@@ -1,654 +0,0 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
|
||||
/**
|
||||
* Steam's touch configs, read and written where Steam keeps them.
|
||||
*
|
||||
* A touch config is a Steam Input config (`controller_mobile_touch`) like any other - bindings per
|
||||
* action set, synced to the account, shared as community configs - with one extra key,
|
||||
* `touch_layout`: where each on-screen control sits, per action set, as a hex-encoded
|
||||
* `CVirtualControllerLayouts` protobuf (steammessages_virtualcontroller.proto). Steam Link is
|
||||
* handed both by the client it streams from; here the client tells the device only the app and
|
||||
* action set it is on (SteamTouchDevice), so the config is found on disk: the file the client's
|
||||
* console log says it loaded for the touch controller, else the app's entry in
|
||||
* `configset_controller_mobile_touch.vdf`. With no layout in it, controls go where Steam Link puts
|
||||
* them by default. Saving a layout writes `touch_layout` into the app's autosave config, as the
|
||||
* client does when Steam Link saves one; Steam Cloud picks that file up like any autosave.
|
||||
* (docs/development/steam-touch-controller.md.)
|
||||
*/
|
||||
object SteamTouchConfig {
|
||||
private const val TAG = "SteamTouch"
|
||||
|
||||
// EControllerElementType.
|
||||
const val THUMB = 0
|
||||
const val STEAM = 1
|
||||
const val JOYSTICK_LEFT = 2
|
||||
const val JOYSTICK_LEFT_BUTTON = 3
|
||||
const val JOYSTICK_RIGHT = 4
|
||||
const val JOYSTICK_RIGHT_BUTTON = 5
|
||||
const val DPAD = 6
|
||||
const val A = 7
|
||||
const val B = 8
|
||||
const val X = 9
|
||||
const val Y = 10
|
||||
const val SELECT = 11
|
||||
const val START = 12
|
||||
const val TRIGGER_LEFT = 13
|
||||
const val TRIGGER_RIGHT = 14
|
||||
const val BUMPER_LEFT = 15
|
||||
const val BUMPER_RIGHT = 16
|
||||
const val MACRO_0 = 17 // 17..24 = macros 0-7
|
||||
const val TRACKPAD_CENTER = 25
|
||||
const val TRACKPAD_LEFT = 26
|
||||
const val TRACKPAD_RIGHT = 27
|
||||
const val KEYBOARD = 28
|
||||
const val MACRO_1_FINGER = 30
|
||||
const val MACRO_2_FINGER = 31
|
||||
const val MAGNIFYING_GLASS = 29
|
||||
const val PASTE = 34
|
||||
|
||||
/** Controls a layout may place though no binding asks for them (Steam Link's tray). */
|
||||
val OPTIONAL = setOf(PASTE)
|
||||
|
||||
/** A control's place: centre as a fraction of the screen, and its scale. */
|
||||
data class Element(val type: Int, val visible: Boolean, val x: Float, val y: Float, val xScale: Float = 1f, val yScale: Float = 1f)
|
||||
|
||||
data class Layout(val actionSet: Int, val elements: List<Element>, val color: FloatArray?, val version: Int?)
|
||||
|
||||
/** A decoded `touch_layout`; [rest] is every other top-level field, written back untouched. */
|
||||
class Layouts(val layouts: List<Layout>, val rest: ByteArray) {
|
||||
fun forActionSet(id: Int): Layout? = layouts.firstOrNull { it.actionSet == id }
|
||||
|
||||
/** The per-game options Steam Link keeps beside the layouts (CVirtualControllerLayouts
|
||||
* fields 2-4): input mode, mouse mode, trackpad sensitivity. */
|
||||
val options: Options get() = Options.decode(rest)
|
||||
}
|
||||
|
||||
/** EInputMode: 1 mouse, 2 controller, 3 both. EMouseMode: 2 absolute cursor (direct touch),
|
||||
* 3 touch, 4 relative (trackpad); 0 = not set. */
|
||||
data class Options(val inputMode: Int = INPUT_CONTROLLER, val mouseMode: Int = 0, val trackpadSensitivity: Float = 1f) {
|
||||
/** [rest] with these options in place of its own fields 2-4, the rest kept as it was. */
|
||||
fun encodeInto(rest: ByteArray): ByteArray {
|
||||
val out = ByteArrayOutputStream()
|
||||
val p = Proto(rest)
|
||||
while (p.more()) {
|
||||
val start = p.pos
|
||||
val (field, wire) = p.key()
|
||||
p.skip(wire)
|
||||
if (field !in 2..4) out.write(rest, start, p.pos - start)
|
||||
}
|
||||
tag(out, 2, 0); varint(out, inputMode.toLong())
|
||||
if (mouseMode != 0) { tag(out, 3, 0); varint(out, mouseMode.toLong()) }
|
||||
tag(out, 4, 5); fixed(out, trackpadSensitivity)
|
||||
return out.toByteArray()
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun decode(rest: ByteArray): Options {
|
||||
var input = INPUT_CONTROLLER
|
||||
var mouse = 0
|
||||
var sensitivity = 1f
|
||||
val p = Proto(rest)
|
||||
while (p.more()) {
|
||||
val (field, wire) = p.key()
|
||||
when {
|
||||
field == 2 && wire == 0 -> input = p.varint().toInt()
|
||||
field == 3 && wire == 0 -> mouse = p.varint().toInt()
|
||||
field == 4 && wire == 5 -> sensitivity = p.float()
|
||||
else -> p.skip(wire)
|
||||
}
|
||||
}
|
||||
return Options(input, mouse, sensitivity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const val INPUT_MOUSE = 1
|
||||
const val INPUT_CONTROLLER = 2
|
||||
const val INPUT_BOTH = 3
|
||||
const val MOUSE_ABSOLUTE = 2
|
||||
const val MOUSE_TOUCH = 3
|
||||
const val MOUSE_RELATIVE = 4
|
||||
|
||||
/** The config's action sets in preset order: id as the device is told (preset id + 1) and title. */
|
||||
fun actionSets(config: Config): List<Pair<Int, String>> {
|
||||
val mappings = config.mappings ?: return listOf(1 to "Default")
|
||||
val titles = mappings.child("actions")?.entries?.mapNotNull { (key, v) ->
|
||||
(v as? KeyValues.Node)?.let { key to (it.string("title") ?: key) }
|
||||
}?.toMap().orEmpty()
|
||||
val sets = mappings.children("preset").mapNotNull { preset ->
|
||||
val id = preset.string("id")?.toIntOrNull() ?: return@mapNotNull null
|
||||
val name = preset.string("name") ?: ""
|
||||
(id + 1) to (titles[name]?.takeUnless { it.startsWith("#") } ?: name.ifEmpty { "Set ${id + 1}" })
|
||||
}.sortedBy { it.first }
|
||||
return sets.ifEmpty { listOf(1 to "Default") }
|
||||
}
|
||||
|
||||
/** A config as the overlay needs it. */
|
||||
class Config(
|
||||
val file: File?,
|
||||
val text: String?,
|
||||
val layouts: Layouts?,
|
||||
/** Bound controls per preset (action set id - 1). */
|
||||
val available: Map<Int, Set<Int>>,
|
||||
/** The parsed config, for its bindings (SteamTouchBindings). */
|
||||
val mappings: KeyValues.Node? = null,
|
||||
) {
|
||||
/** The config's own title, as Steam Link's tray shows it ("Default", "Gamepad"…). */
|
||||
val title: String? get() = mappings?.string("title")?.let { t ->
|
||||
if (t.startsWith("#")) mappings.child("localization")?.child("english")?.string("title") ?: "Default" else t
|
||||
}
|
||||
|
||||
/** The controls to show for [actionSet] (as the device is told it) and [layers]. */
|
||||
fun availableFor(actionSet: Int, layers: List<Int>): Set<Int> {
|
||||
val base = available[presetOf(actionSet)] ?: available.values.firstOrNull() ?: DEFAULT_SET
|
||||
return layers.fold(base) { set, layer -> set + (available[presetOf(layer)] ?: emptySet()) } + ALWAYS
|
||||
}
|
||||
}
|
||||
|
||||
/** Big Picture's own config uses Steam's basic UI bindings: the default set. */
|
||||
private val DEFAULT_SET = setOf(DPAD, A, B, X, Y, SELECT, START)
|
||||
private val ALWAYS = setOf(STEAM, THUMB, KEYBOARD)
|
||||
|
||||
/** The layout entry for the action set the device was told about (the client counts from 1,
|
||||
* 0 for an app with none). */
|
||||
fun layoutIdOf(actionSet: Int) = if (actionSet <= 0) 1 else actionSet
|
||||
private fun presetOf(actionSet: Int) = layoutIdOf(actionSet) - 1
|
||||
|
||||
// ---- Where Steam keeps them ----
|
||||
|
||||
/** A game's name from its app manifest, when it is installed. */
|
||||
fun gameName(context: Context, appId: Int): String? = try {
|
||||
val kv = KeyValues.parse(File(steamRoot(context), "steamapps/appmanifest_$appId.acf").readText())
|
||||
(kv.child("AppState") ?: kv).string("name")
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
fun steamRoot(context: Context) = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
|
||||
|
||||
fun configDir(context: Context): File? {
|
||||
val base = File(steamRoot(context), "steamapps/common/Steam Controller Configs")
|
||||
val account = accountId(context)
|
||||
if (account != null) File(base, "$account/config").takeIf { it.isDirectory }?.let { return it }
|
||||
return base.listFiles()?.filter { it.name.all(Char::isDigit) && File(it, "config").isDirectory }
|
||||
?.maxByOrNull { it.lastModified() }?.let { File(it, "config") }
|
||||
}
|
||||
|
||||
/** The signed-in account (loginusers.vdf's MostRecent) as a 32-bit account id. */
|
||||
private fun accountId(context: Context): Long? = try {
|
||||
val users = KeyValues.parse(File(steamRoot(context), "config/loginusers.vdf").readText())
|
||||
val node = users.child("users")
|
||||
val entries = node?.entries?.mapNotNull { (k, v) -> (v as? KeyValues.Node)?.let { k to it } }.orEmpty()
|
||||
val chosen = entries.firstOrNull { it.second.string("MostRecent") == "1" } ?: entries.firstOrNull()
|
||||
chosen?.first?.toLongOrNull()?.minus(76561197960265728L)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
/** The config the client has for app [appId] on the touch controller. Blocking: reads files. */
|
||||
fun load(context: Context, appId: Int): Config {
|
||||
val dir = configDir(context)
|
||||
// The app's own saved config is what the client loads when there is one (its configset
|
||||
// entry); otherwise what it logged loading - a template, an official or a workshop config.
|
||||
val file = dir?.let { autosaveConfig(it, appId) }
|
||||
?: loggedConfig(context, appId)?.takeIf { isTouchConfig(it) }
|
||||
?: dir?.let { configsetConfig(context, it, appId) }
|
||||
if (file == null) {
|
||||
Log.i(TAG, "steam touch: app $appId has no touch config on disk; default layout")
|
||||
return Config(null, null, null, emptyMap())
|
||||
}
|
||||
return try {
|
||||
val text = file.readText()
|
||||
val kv = KeyValues.parse(text.removePrefix(""))
|
||||
val mappings = kv.child("controller_mappings") ?: kv
|
||||
val layouts = mappings.string("touch_layout")?.let { hex -> decodeLayouts(hexToBytes(hex)) }
|
||||
Log.i(TAG, "steam touch: app $appId uses ${file.path} (${layouts?.layouts?.size ?: 0} layouts)")
|
||||
Config(file, text, layouts, availability(mappings), mappings)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "steam touch: ${file.path} unreadable: $e")
|
||||
Config(file, null, null, emptyMap())
|
||||
}
|
||||
}
|
||||
|
||||
private fun isTouchConfig(file: File) = file.isFile &&
|
||||
file.bufferedReader().use { r -> generateSequence { r.readLine() }.take(40).any { it.contains("controller_mobile_touch") } }
|
||||
|
||||
/** From the client's console log: the last config it loaded for [appId] on the touch controller
|
||||
* (`Loaded Config for ... App ID <n>, Controller <i>: <path>`, where controller i reported
|
||||
* type 43). Guest paths come back as the app's own. */
|
||||
private fun loggedConfig(context: Context, appId: Int): File? = try {
|
||||
val log = File(steamRoot(context), "logs/console_log.txt")
|
||||
val tail = RandomAccessFile(log, "r").use { raf ->
|
||||
val start = maxOf(0L, raf.length() - 2_000_000L)
|
||||
raf.seek(start)
|
||||
val bytes = ByteArray((raf.length() - start).toInt())
|
||||
raf.readFully(bytes)
|
||||
String(bytes)
|
||||
}
|
||||
val touchIndex = Regex("""!! Controller (\d+) attributes:\s*\n\s*Type: 43""").findAll(tail).lastOrNull()?.groupValues?.get(1)
|
||||
if (touchIndex == null) null else {
|
||||
val loaded = Regex("""Loaded Config for [^\n]*? Path for App ID $appId, Controller $touchIndex: ([^\n]+)""")
|
||||
.findAll(tail).lastOrNull()?.groupValues?.get(1)?.trim()
|
||||
loaded?.let { guestPath -> File(LinuxRuntime.rootDir(context), guestPath.trimStart('/').replace("//", "/")) }
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
private fun autosaveConfig(dir: File, appId: Int): File? = try {
|
||||
val set = KeyValues.parse(File(dir, CONFIGSET).readText()).child("controller_config")?.child(appId.toString())
|
||||
if (set?.string("autosave") == "1") File(dir, "$appId/controller_mobile_touch.vdf").takeIf { it.isFile } else null
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
/** The app's entry in configset_controller_mobile_touch.vdf. */
|
||||
private fun configsetConfig(context: Context, dir: File, appId: Int): File? = try {
|
||||
val set = KeyValues.parse(File(dir, CONFIGSET).readText()).child("controller_config")?.child(appId.toString())
|
||||
when {
|
||||
set == null -> null
|
||||
set.string("autosave") == "1" -> File(dir, "$appId/controller_mobile_touch.vdf")
|
||||
set.string("template") != null -> File(steamRoot(context), "controller_base/templates/" + set.string("template"))
|
||||
else -> null
|
||||
}?.takeIf { it.isFile }
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
private const val CONFIGSET = "configset_controller_mobile_touch.vdf"
|
||||
|
||||
// ---- What a config binds ----
|
||||
|
||||
/** Per preset, the controls its active sources bind - what Steam Link shows (UpdateElementAvailability). */
|
||||
private fun availability(mappings: KeyValues.Node): Map<Int, Set<Int>> {
|
||||
val groups = mappings.children("group").associateBy({ it.string("id") ?: "" }) { group ->
|
||||
group.child("inputs")?.entries?.filter { (_, v) -> (v as? KeyValues.Node)?.let(::hasBinding) == true }
|
||||
?.map { it.first }?.toSet().orEmpty()
|
||||
}
|
||||
return mappings.children("preset").associate { preset ->
|
||||
val id = preset.string("id")?.toIntOrNull() ?: 0
|
||||
val set = mutableSetOf<Int>()
|
||||
preset.child("group_source_bindings")?.entries?.forEach { (groupId, value) ->
|
||||
val parts = (value as? String)?.split(' ') ?: return@forEach
|
||||
if (parts.getOrNull(1) != "active" || parts.contains("modeshift")) return@forEach
|
||||
val bound = groups[groupId].orEmpty()
|
||||
when (parts[0]) {
|
||||
"button_diamond" -> mapOf("button_a" to A, "button_b" to B, "button_x" to X, "button_y" to Y)
|
||||
.forEach { (input, element) -> if (input in bound) set += element }
|
||||
"dpad" -> set += DPAD
|
||||
"joystick" -> { set += JOYSTICK_LEFT; if ("click" in bound) set += JOYSTICK_LEFT_BUTTON }
|
||||
"right_joystick" -> { set += JOYSTICK_RIGHT; if ("click" in bound) set += JOYSTICK_RIGHT_BUTTON }
|
||||
"left_trigger" -> set += TRIGGER_LEFT
|
||||
"right_trigger" -> set += TRIGGER_RIGHT
|
||||
"left_trackpad" -> set += TRACKPAD_LEFT
|
||||
"right_trackpad" -> set += TRACKPAD_RIGHT
|
||||
"center_trackpad" -> set += TRACKPAD_CENTER
|
||||
"switch" -> bound.forEach { input ->
|
||||
when (input) {
|
||||
"button_escape" -> set += START
|
||||
"button_menu" -> set += SELECT
|
||||
"left_bumper" -> set += BUMPER_LEFT
|
||||
"right_bumper" -> set += BUMPER_RIGHT
|
||||
"button_macro_1finger" -> set += MACRO_1_FINGER
|
||||
"button_macro_2finger" -> set += MACRO_2_FINGER
|
||||
else -> Regex("button_macro(\\d)").matchEntire(input)?.let { set += MACRO_0 + it.groupValues[1].toInt() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
id to set.toSet()
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasBinding(input: KeyValues.Node): Boolean =
|
||||
input.child("activators")?.entries?.any { (_, activator) ->
|
||||
(activator as? KeyValues.Node)?.child("bindings")?.entries?.any { (k, v) -> k == "binding" && (v as? String)?.isNotBlank() == true } == true
|
||||
} == true
|
||||
|
||||
// ---- Steam Link's defaults ----
|
||||
|
||||
/** Where Steam Link puts a control with no layout (CVirtualController::BInitializeDefaultElement,
|
||||
* on a 1280x720 screen), and the same scheme for the controls its table leaves out, made room
|
||||
* for when a config binds sticks. */
|
||||
fun defaultElement(type: Int, available: Set<Int>): Element? {
|
||||
val leftStick = JOYSTICK_LEFT in available
|
||||
val rightStick = JOYSTICK_RIGHT in available
|
||||
val faceY = if (rightStick) -0.30f else 0f
|
||||
val p: Pair<Float, Float> = when (type) {
|
||||
THUMB -> 75f / 1280 to 75f / 720
|
||||
STEAM -> 636f / 1280 to 75f / 720
|
||||
SELECT -> 516f / 1280 to 75f / 720
|
||||
START -> 756f / 1280 to 75f / 720
|
||||
KEYBOARD -> 1205f / 1280 to 75f / 720
|
||||
PASTE -> 1115f / 1280 to 75f / 720
|
||||
DPAD -> if (leftStick) 0.147f to 0.47f else 200f / 1280 to 525f / 720
|
||||
A -> 1083f / 1280 to 607f / 720 + faceY
|
||||
B -> 1163f / 1280 to 527f / 720 + faceY
|
||||
X -> 1003f / 1280 to 527f / 720 + faceY
|
||||
Y -> 1083f / 1280 to 447f / 720 + faceY
|
||||
JOYSTICK_LEFT -> 0.137f to 0.76f
|
||||
JOYSTICK_RIGHT -> 0.85f to 0.76f
|
||||
JOYSTICK_LEFT_BUTTON -> 0.29f to 0.88f
|
||||
JOYSTICK_RIGHT_BUTTON -> 0.71f to 0.88f
|
||||
BUMPER_LEFT -> 0.06f to 0.205f
|
||||
TRIGGER_LEFT -> 0.16f to 0.205f
|
||||
BUMPER_RIGHT -> 0.94f to 0.205f
|
||||
TRIGGER_RIGHT -> 0.84f to 0.205f
|
||||
TRACKPAD_LEFT -> 0.3f to 0.55f
|
||||
TRACKPAD_CENTER -> 0.5f to 0.55f
|
||||
TRACKPAD_RIGHT -> 0.7f to 0.55f
|
||||
MACRO_1_FINGER -> 0.42f to 0.92f
|
||||
MACRO_2_FINGER -> 0.58f to 0.92f
|
||||
in MACRO_0..MACRO_0 + 7 -> (0.3f + 0.057f * (type - MACRO_0)) to 0.22f
|
||||
else -> return null
|
||||
}
|
||||
return Element(type, true, p.first, p.second)
|
||||
}
|
||||
|
||||
/** The controls to draw: the layout's own where it has one, Steam Link's defaults otherwise,
|
||||
* only those the config binds. */
|
||||
fun elementsFor(config: Config, actionSet: Int, layers: List<Int>): List<Element> {
|
||||
val available = config.availableFor(actionSet, layers)
|
||||
val layout = config.layouts?.forActionSet(layoutIdOf(actionSet))
|
||||
val placed = layout?.elements?.associateBy { it.type }.orEmpty()
|
||||
val optional = OPTIONAL.filter { placed[it]?.visible == true && it !in available }
|
||||
return (available + optional).mapNotNull { type ->
|
||||
val own = placed[type]
|
||||
when {
|
||||
own != null && !own.visible -> null
|
||||
own != null -> own
|
||||
else -> defaultElement(type, available)
|
||||
}
|
||||
}.sortedBy { it.type }
|
||||
}
|
||||
|
||||
fun layoutColor(config: Config, actionSet: Int): FloatArray? = config.layouts?.forActionSet(layoutIdOf(actionSet))?.color
|
||||
|
||||
// ---- Saving ----
|
||||
|
||||
/**
|
||||
* Saves [elements] as the layout of [actionSet] in app [appId]'s touch config: into the app's
|
||||
* autosave config (`<appid>/controller_mobile_touch.vdf`, made from the config in use when the
|
||||
* app has none) and selected for the app in the configset, as the client saves a layout Steam
|
||||
* Link sends it. Returns the file written, or null.
|
||||
*/
|
||||
fun saveLayout(
|
||||
context: Context,
|
||||
appId: Int,
|
||||
config: Config,
|
||||
actionSet: Int,
|
||||
elements: List<Element>,
|
||||
color: FloatArray? = null,
|
||||
bindings: Map<SteamTouchBindings.Ref, (SteamTouchBindings.Binding) -> SteamTouchBindings.Binding> = emptyMap(),
|
||||
): File? = saveLayouts(context, appId, config, mapOf(layoutIdOf(actionSet) to elements), color?.let { c -> setOf(layoutIdOf(actionSet)).associateWith { c } } ?: emptyMap(), bindings)
|
||||
|
||||
/**
|
||||
* Saves the layouts of several action sets (by layout id) at once, with their colours, any
|
||||
* binding edits and the per-game [options], into the game's autosave touch config.
|
||||
*/
|
||||
fun saveLayouts(
|
||||
context: Context,
|
||||
appId: Int,
|
||||
config: Config,
|
||||
sets: Map<Int, List<Element>>,
|
||||
colors: Map<Int, FloatArray> = emptyMap(),
|
||||
bindings: Map<SteamTouchBindings.Ref, (SteamTouchBindings.Binding) -> SteamTouchBindings.Binding> = emptyMap(),
|
||||
options: Options? = null,
|
||||
): File? {
|
||||
val dir = configDir(context) ?: return null
|
||||
// Icons, labels and colours live in the binding strings (SteamTouchBindings), as the
|
||||
// configurator writes them.
|
||||
val source = bindings.entries.fold(config.text ?: return null) { text, (ref, change) ->
|
||||
SteamTouchBindings.rewrite(text, ref, change) ?: text
|
||||
}
|
||||
val target = File(dir, "$appId/controller_mobile_touch.vdf")
|
||||
val old = config.layouts
|
||||
val keep = old?.layouts?.filter { it.actionSet !in sets.keys }.orEmpty()
|
||||
val written = sets.map { (id, elements) ->
|
||||
Layout(id, elements, colors[id] ?: old?.forActionSet(id)?.color ?: floatArrayOf(1f, 1f, 1f, 0.4f), old?.forActionSet(id)?.version)
|
||||
}
|
||||
val rest = old?.rest ?: byteArrayOf(0x10, 0x02) // input_mode = controller
|
||||
val layouts = Layouts((keep + written).sortedBy { it.actionSet }, options?.encodeInto(rest) ?: rest)
|
||||
val hex = bytesToHex(encodeLayouts(layouts))
|
||||
val line = "\t\"touch_layout\"\t\t\"$hex\""
|
||||
val existing = Regex("""\n\s*"touch_layout"\s+"[0-9a-fA-F]*"""")
|
||||
val text = if (existing.containsMatchIn(source)) source.replace(existing, "\n$line")
|
||||
else source.replaceFirst(Regex("""("controller_type"\s+"controller_mobile_touch"[^\n]*)"""), "$1\n$line")
|
||||
if (!text.contains("touch_layout")) return null
|
||||
target.parentFile?.mkdirs()
|
||||
val tmp = File(target.path + ".tmp")
|
||||
tmp.writeText(text)
|
||||
if (!tmp.renameTo(target)) return null
|
||||
selectAutosave(dir, appId)
|
||||
Log.i(TAG, "steam touch: saved layouts ${sets.keys} for app $appId to ${target.path}")
|
||||
return target
|
||||
}
|
||||
|
||||
/** Points the app's configset entry at its autosave config. */
|
||||
private fun selectAutosave(dir: File, appId: Int) {
|
||||
val file = File(dir, CONFIGSET)
|
||||
val text = if (file.isFile) file.readText() else "\"controller_config\"\n{\n}\n"
|
||||
val entry = "\t\"$appId\"\n\t{\n\t\t\"autosave\"\t\t\"1\"\n\t}"
|
||||
val block = Regex("""\n\s*"$appId"\s*\{[^}]*\}""")
|
||||
val next = if (block.containsMatchIn(text)) text.replace(block, "\n$entry")
|
||||
else text.substring(0, text.lastIndexOf('}')).trimEnd() + "\n$entry\n}\n"
|
||||
File(file.path + ".tmp").apply { writeText(next) }.renameTo(file)
|
||||
}
|
||||
|
||||
// ---- touch_layout: CVirtualControllerLayouts ----
|
||||
|
||||
fun decodeLayouts(bytes: ByteArray): Layouts {
|
||||
val layouts = mutableListOf<Layout>()
|
||||
val rest = ByteArrayOutputStream()
|
||||
val p = Proto(bytes)
|
||||
while (p.more()) {
|
||||
val start = p.pos
|
||||
val (field, wire) = p.key()
|
||||
if (field == 1 && wire == 2) layouts += decodeLayout(p.bytes())
|
||||
else { p.skip(wire); rest.write(bytes, start, p.pos - start) }
|
||||
}
|
||||
return Layouts(layouts, rest.toByteArray())
|
||||
}
|
||||
|
||||
private fun decodeLayout(bytes: ByteArray): Layout {
|
||||
var actionSet = 0
|
||||
var version: Int? = null
|
||||
var color: FloatArray? = null
|
||||
val elements = mutableListOf<Element>()
|
||||
val p = Proto(bytes)
|
||||
while (p.more()) {
|
||||
val (field, wire) = p.key()
|
||||
when {
|
||||
field == 1 && wire == 0 -> version = p.varint().toInt()
|
||||
field == 2 && wire == 0 -> actionSet = p.varint().toInt()
|
||||
field == 4 && wire == 2 -> elements += decodeElement(p.bytes())
|
||||
field == 5 && wire == 2 -> color = decodeColor(p.bytes())
|
||||
else -> p.skip(wire)
|
||||
}
|
||||
}
|
||||
return Layout(actionSet, elements, color, version)
|
||||
}
|
||||
|
||||
private fun decodeElement(bytes: ByteArray): Element {
|
||||
var type = -1
|
||||
var visible = false
|
||||
var x = 0f
|
||||
var y = 0f
|
||||
var sx = 1f
|
||||
var sy = 1f
|
||||
val p = Proto(bytes)
|
||||
while (p.more()) {
|
||||
val (field, wire) = p.key()
|
||||
when {
|
||||
field == 1 && wire == 0 -> type = p.varint().toInt()
|
||||
field == 2 && wire == 0 -> visible = p.varint() != 0L
|
||||
field == 3 && wire == 5 -> x = p.float()
|
||||
field == 4 && wire == 5 -> y = p.float()
|
||||
field == 5 && wire == 5 -> sx = p.float()
|
||||
field == 6 && wire == 5 -> sy = p.float()
|
||||
else -> p.skip(wire)
|
||||
}
|
||||
}
|
||||
return Element(type, visible, x, y, sx, sy)
|
||||
}
|
||||
|
||||
private fun decodeColor(bytes: ByteArray): FloatArray {
|
||||
val c = floatArrayOf(1f, 1f, 1f, 1f)
|
||||
val p = Proto(bytes)
|
||||
while (p.more()) {
|
||||
val (field, wire) = p.key()
|
||||
if (field in 1..4 && wire == 5) c[field - 1] = p.float() else p.skip(wire)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
fun encodeLayouts(layouts: Layouts): ByteArray {
|
||||
val out = ByteArrayOutputStream()
|
||||
layouts.layouts.forEach { layout ->
|
||||
val l = ByteArrayOutputStream()
|
||||
layout.version?.let { tag(l, 1, 0); varint(l, it.toLong()) }
|
||||
tag(l, 2, 0); varint(l, layout.actionSet.toLong())
|
||||
layout.elements.forEach { e ->
|
||||
val m = ByteArrayOutputStream()
|
||||
tag(m, 1, 0); varint(m, e.type.toLong())
|
||||
tag(m, 2, 0); varint(m, if (e.visible) 1 else 0)
|
||||
tag(m, 3, 5); fixed(m, e.x)
|
||||
tag(m, 4, 5); fixed(m, e.y)
|
||||
tag(m, 5, 5); fixed(m, e.xScale)
|
||||
tag(m, 6, 5); fixed(m, e.yScale)
|
||||
tag(l, 4, 2); bytes(l, m.toByteArray())
|
||||
}
|
||||
layout.color?.let { c ->
|
||||
val m = ByteArrayOutputStream()
|
||||
for (i in 0 until 4) { tag(m, i + 1, 5); fixed(m, c[i]) }
|
||||
tag(l, 5, 2); bytes(l, m.toByteArray())
|
||||
}
|
||||
tag(out, 1, 2); bytes(out, l.toByteArray())
|
||||
}
|
||||
out.write(layouts.rest)
|
||||
return out.toByteArray()
|
||||
}
|
||||
|
||||
private fun tag(out: ByteArrayOutputStream, field: Int, wire: Int) = varint(out, (field shl 3 or wire).toLong())
|
||||
private fun varint(out: ByteArrayOutputStream, value: Long) {
|
||||
var v = value
|
||||
while (true) {
|
||||
if (v and 0x7fL.inv() == 0L) { out.write(v.toInt()); return }
|
||||
out.write((v and 0x7f or 0x80).toInt())
|
||||
v = v ushr 7
|
||||
}
|
||||
}
|
||||
private fun fixed(out: ByteArrayOutputStream, f: Float) {
|
||||
val bits = java.lang.Float.floatToIntBits(f)
|
||||
for (i in 0 until 4) out.write(bits ushr (8 * i) and 0xff)
|
||||
}
|
||||
private fun bytes(out: ByteArrayOutputStream, b: ByteArray) { varint(out, b.size.toLong()); out.write(b) }
|
||||
|
||||
private class Proto(val data: ByteArray) {
|
||||
var pos = 0
|
||||
fun varint(): Long {
|
||||
var shift = 0
|
||||
var result = 0L
|
||||
while (pos < data.size) {
|
||||
val b = data[pos++].toInt() and 0xff
|
||||
result = result or ((b and 0x7f).toLong() shl shift)
|
||||
if (b and 0x80 == 0) break
|
||||
shift += 7
|
||||
}
|
||||
return result
|
||||
}
|
||||
fun float(): Float {
|
||||
var bits = 0
|
||||
for (i in 0 until 4) bits = bits or ((data[pos + i].toInt() and 0xff) shl (8 * i))
|
||||
pos += 4
|
||||
return java.lang.Float.intBitsToFloat(bits)
|
||||
}
|
||||
fun bytes(): ByteArray {
|
||||
val n = varint().toInt()
|
||||
return data.copyOfRange(pos, pos + n).also { pos += n }
|
||||
}
|
||||
fun skip(wire: Int) {
|
||||
when (wire) {
|
||||
0 -> varint()
|
||||
1 -> pos += 8
|
||||
2 -> pos += varint().toInt()
|
||||
5 -> pos += 4
|
||||
else -> pos = data.size
|
||||
}
|
||||
}
|
||||
fun more() = pos < data.size
|
||||
fun key(): Pair<Int, Int> = varint().toInt().let { (it ushr 3) to (it and 7) }
|
||||
}
|
||||
|
||||
fun hexToBytes(hex: String) = ByteArray(hex.length / 2) { hex.substring(2 * it, 2 * it + 2).toInt(16).toByte() }
|
||||
fun bytesToHex(b: ByteArray) = b.joinToString("") { "%02x".format(it.toInt() and 0xff) }
|
||||
}
|
||||
|
||||
/** Valve's KeyValues text format, as far as controller configs use it. */
|
||||
class KeyValues private constructor() {
|
||||
class Node(val entries: MutableList<Pair<String, Any>> = mutableListOf()) {
|
||||
fun child(key: String): Node? = entries.firstOrNull { it.first.equals(key, true) && it.second is Node }?.second as Node?
|
||||
fun children(key: String): List<Node> = entries.filter { it.first.equals(key, true) && it.second is Node }.map { it.second as Node }
|
||||
fun string(key: String): String? = entries.firstOrNull { it.first.equals(key, true) && it.second is String }?.second as String?
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun parse(text: String): Node {
|
||||
val tokens = tokenize(text)
|
||||
var i = 0
|
||||
fun node(): Node {
|
||||
val n = Node()
|
||||
while (i < tokens.size) {
|
||||
val t = tokens[i++]
|
||||
if (t == "}" ) return n
|
||||
if (t == "{") continue
|
||||
if (i >= tokens.size) break
|
||||
val next = tokens[i]
|
||||
if (next == "{") { i++; n.entries += t to node() } else { i++; n.entries += t to next }
|
||||
}
|
||||
return n
|
||||
}
|
||||
return node()
|
||||
}
|
||||
|
||||
private fun tokenize(text: String): List<String> {
|
||||
val out = mutableListOf<String>()
|
||||
var i = 0
|
||||
while (i < text.length) {
|
||||
val c = text[i]
|
||||
when {
|
||||
c.isWhitespace() -> i++
|
||||
c == '/' && i + 1 < text.length && text[i + 1] == '/' -> while (i < text.length && text[i] != '\n') i++
|
||||
c == '{' || c == '}' -> { out += c.toString(); i++ }
|
||||
c == '"' -> {
|
||||
val sb = StringBuilder()
|
||||
i++
|
||||
while (i < text.length && text[i] != '"') {
|
||||
if (text[i] == '\\' && i + 1 < text.length) {
|
||||
i++
|
||||
sb.append(when (text[i]) { 'n' -> '\n'; 't' -> '\t'; else -> text[i] })
|
||||
} else sb.append(text[i])
|
||||
i++
|
||||
}
|
||||
i++
|
||||
out += sb.toString()
|
||||
}
|
||||
c == '[' -> { while (i < text.length && text[i] != ']') i++; i++ }
|
||||
else -> {
|
||||
val start = i
|
||||
while (i < text.length && !text[i].isWhitespace() && text[i] != '{' && text[i] != '}' && text[i] != '"') i++
|
||||
out += text.substring(start, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,171 +0,0 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
import java.nio.ByteOrder
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.channels.FileChannel
|
||||
|
||||
/**
|
||||
* Steam's touch controller, as the app's side of it.
|
||||
*
|
||||
* libfakeinput offers the Steam client a HID device with the ids of Steam Link's touch controls
|
||||
* (0000:11fb), and the client builds its own touch controller for it: Steam Input's Mobile Touch
|
||||
* type, with the game's touch config, action sets, community configs and cloud sync all Steam's
|
||||
* (docs/development/steam-touch-controller.md). This class is the file the two share
|
||||
* (fakeinput_steam.cpp: TouchRingFile): the app writes the device's 40-byte input report and
|
||||
* whether it is plugged in; libfakeinput writes back what the client told the device - the app and
|
||||
* action set it is on, its layers, rumble.
|
||||
*/
|
||||
class SteamTouchDevice private constructor(private val buffer: ByteBuffer) {
|
||||
|
||||
/** What the client last told the device. */
|
||||
data class State(
|
||||
val opened: Int,
|
||||
val appId: Int,
|
||||
val actionSet: Int,
|
||||
val layers: List<Int>,
|
||||
val actionSeq: Int,
|
||||
)
|
||||
|
||||
// The input report (Steam Link's CVirtualController, docs/development/steam-touch-controller.md):
|
||||
// bytes 0-7 buttons, 8-15 sticks, 16-27 trackpads, 28-33 accelerometer, 34-39 gyro.
|
||||
private val report = ByteArray(REPORT_BYTES)
|
||||
private var reportSeq = 0L
|
||||
|
||||
/** The controls part of the report: button bits, sticks (LX, LY, RX, RY, already in the report's
|
||||
* units) and trackpads (centre, left, right: x, y each). */
|
||||
@Synchronized
|
||||
fun setControls(buttons: Long, sticks: ShortArray, pads: ShortArray) {
|
||||
val next = report.copyOf()
|
||||
for (i in 0 until 8) next[i] = (buttons ushr (8 * i)).toByte()
|
||||
for (i in 0 until 4) putShort(next, 8 + 2 * i, sticks[i])
|
||||
for (i in 0 until 6) putShort(next, 16 + 2 * i, pads[i])
|
||||
publish(next)
|
||||
}
|
||||
|
||||
/** Motion in the Deck's axes and units (PadMotion), which the touch report shares. */
|
||||
@Synchronized
|
||||
fun setMotion(accel: ShortArray, gyro: ShortArray) {
|
||||
// Sent only while the client asks for it (setting report 0x30, which Steam Link reads as
|
||||
// "gyro on") and the player has not turned motion off - as Steam Link does.
|
||||
if (!plugged || !gyroRequested || !motionEnabled) return
|
||||
val next = report.copyOf()
|
||||
for (i in 0 until 3) {
|
||||
// Accelerometer: the Deck's 1 g = 16384 is the touch report's ±2 g = ±32767 already.
|
||||
putShort(next, 28 + 2 * i, accel[i])
|
||||
// Gyro: the Deck's 2000 °/s = 32768; the touch report's full scale is 1000 °/s
|
||||
// (Steam Link: rad/s × 180/π / 1000 × 32767).
|
||||
putShort(next, 34 + 2 * i, (gyro[i] * 2).coerceIn(-32767, 32767).toShort())
|
||||
}
|
||||
publish(next)
|
||||
}
|
||||
|
||||
/** Whether the client has asked the device for motion (setting report 0x30 non-zero). */
|
||||
val gyroRequested get() = buffer.getInt(OFF_SETTING30) != 0
|
||||
|
||||
/** The player's motion switch (Steam Link's gyroscope option). */
|
||||
@Volatile var motionEnabled = true
|
||||
set(value) {
|
||||
field = value
|
||||
if (!value) synchronized(this) {
|
||||
val next = report.copyOf()
|
||||
for (i in 28 until 40) next[i] = 0
|
||||
publish(next)
|
||||
}
|
||||
}
|
||||
|
||||
private fun putShort(into: ByteArray, at: Int, value: Short) {
|
||||
into[at] = value.toInt().toByte()
|
||||
into[at + 1] = (value.toInt() shr 8).toByte()
|
||||
}
|
||||
|
||||
/** Writes the report under the seqlock libfakeinput reads it with. */
|
||||
private fun publish(next: ByteArray) {
|
||||
if (next.contentEquals(report)) return
|
||||
next.copyInto(report)
|
||||
buffer.putLong(OFF_REPORT_SEQ, ++reportSeq)
|
||||
for (i in 0 until REPORT_BYTES) buffer.put(OFF_REPORT + i, report[i])
|
||||
buffer.putLong(OFF_REPORT_SEQ, ++reportSeq)
|
||||
}
|
||||
|
||||
var plugged: Boolean
|
||||
get() = buffer.getInt(OFF_PLUGGED) != 0
|
||||
set(value) {
|
||||
if (value == plugged) return
|
||||
if (!value) synchronized(this) { publish(ByteArray(REPORT_BYTES)) }
|
||||
buffer.putInt(OFF_PLUGGED, if (value) 1 else 0)
|
||||
Log.i(TAG, "steam touch: " + if (value) "plugged in" else "unplugged")
|
||||
}
|
||||
|
||||
fun setBattery(percent: Int) = buffer.putInt(OFF_BATTERY, percent.coerceIn(0, 100))
|
||||
|
||||
fun state(): State {
|
||||
repeat(8) {
|
||||
val seq = buffer.getLong(OFF_STATE_SEQ)
|
||||
if (seq and 1L != 0L) return@repeat
|
||||
val count = buffer.getInt(OFF_LAYER_COUNT).coerceIn(0, 8)
|
||||
val read = State(
|
||||
opened = buffer.getInt(OFF_OPENED),
|
||||
appId = buffer.getInt(OFF_APPID),
|
||||
actionSet = buffer.getInt(OFF_ACTION_SET),
|
||||
layers = (0 until count).map { buffer.getInt(OFF_LAYERS + 4 * it) },
|
||||
actionSeq = buffer.getInt(OFF_ACTION_SEQ),
|
||||
)
|
||||
if (seq == buffer.getLong(OFF_STATE_SEQ)) return read
|
||||
}
|
||||
return State(0, 0, 0, emptyList(), -1)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SteamTouch"
|
||||
const val REPORT_BYTES = 40
|
||||
private const val SIZE = 256
|
||||
private const val MAGIC = 0x31484354 // TCH1
|
||||
private const val OFF_REPORT_SEQ = 8
|
||||
private const val OFF_REPORT = 16
|
||||
private const val OFF_PLUGGED = 56
|
||||
private const val OFF_BATTERY = 60
|
||||
private const val OFF_STATE_SEQ = 64
|
||||
private const val OFF_OPENED = 72
|
||||
private const val OFF_APPID = 76
|
||||
private const val OFF_ACTION_SET = 80
|
||||
private const val OFF_LAYER_COUNT = 84
|
||||
private const val OFF_LAYERS = 88
|
||||
private const val OFF_SETTING30 = 132
|
||||
private const val OFF_ACTION_SEQ = 136
|
||||
|
||||
@Volatile
|
||||
var current: SteamTouchDevice? = null
|
||||
private set
|
||||
|
||||
/** The shared file in the session's pad directory, made fresh each session. */
|
||||
fun ringFile(fakeInputDir: File) = File(fakeInputDir, "touchctl")
|
||||
|
||||
/** Creates the file for a new session (unplugged) and maps it. */
|
||||
fun prepare(fakeInputDir: File): File? = try {
|
||||
val file = ringFile(fakeInputDir)
|
||||
file.delete()
|
||||
RandomAccessFile(file, "rw").use { raf ->
|
||||
raf.setLength(SIZE.toLong())
|
||||
val map = raf.channel.map(FileChannel.MapMode.READ_WRITE, 0, SIZE.toLong()).order(ByteOrder.LITTLE_ENDIAN)
|
||||
map.putInt(4, 1)
|
||||
map.putInt(0, MAGIC)
|
||||
current = SteamTouchDevice(map)
|
||||
}
|
||||
file.setReadable(true, false)
|
||||
file.setWritable(true, false)
|
||||
file
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "steam touch: no shared file: $e")
|
||||
current = null
|
||||
null
|
||||
}
|
||||
|
||||
fun release() {
|
||||
current?.plugged = false
|
||||
current = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,274 +0,0 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.PorterDuff
|
||||
import android.graphics.PorterDuffColorFilter
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.text.InputType
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.BaseAdapter
|
||||
import android.widget.EditText
|
||||
import android.widget.GridView
|
||||
import android.widget.HorizontalScrollView
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* Picks a touch control's icon, label and colours the way Steam's configurator does: an icon from
|
||||
* the game's own `TouchMenuIcons` and Steam's binding icon library, a foreground and a background
|
||||
* from Steam's palette. [onPicked] gets the binding with the new fields (the action untouched).
|
||||
*/
|
||||
class SteamTouchIconPicker(
|
||||
private val context: Context,
|
||||
private val host: ViewGroup,
|
||||
private val appId: Int,
|
||||
private val title: String,
|
||||
private val current: SteamTouchBindings.Binding,
|
||||
private val onPicked: (SteamTouchBindings.Binding) -> Unit,
|
||||
) {
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
private val loader = Executors.newFixedThreadPool(2)
|
||||
private var icon = current.icon
|
||||
private var foreground = current.foreground.ifEmpty { SteamTouchBindings.DEFAULT_FOREGROUND }
|
||||
private var background = current.background.ifEmpty { SteamTouchBindings.DEFAULT_BACKGROUND }
|
||||
private val density = context.resources.displayMetrics.density
|
||||
|
||||
private fun dp(v: Int) = (v * density).toInt()
|
||||
|
||||
fun show() {
|
||||
// Side by side, for a landscape screen: what is picked on the left, the icons on the right.
|
||||
val root = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL; setPadding(dp(16), dp(8), dp(16), 0) }
|
||||
val left = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL }
|
||||
val label = EditText(context).apply {
|
||||
hint = "Label (optional)"
|
||||
setText(current.label)
|
||||
inputType = InputType.TYPE_CLASS_TEXT
|
||||
setSingleLine()
|
||||
}
|
||||
left.addView(label)
|
||||
val preview = ImageView(context)
|
||||
val names = SteamTouchBindings.iconNames(context, appId)
|
||||
val grid = GridView(context).apply {
|
||||
numColumns = GridView.AUTO_FIT
|
||||
columnWidth = dp(56)
|
||||
stretchMode = GridView.STRETCH_COLUMN_WIDTH
|
||||
verticalSpacing = dp(6)
|
||||
horizontalSpacing = dp(6)
|
||||
}
|
||||
val adapter = IconAdapter(names)
|
||||
grid.adapter = adapter
|
||||
grid.setOnItemClickListener { _, _, position, _ ->
|
||||
icon = names[position]
|
||||
adapter.notifyDataSetChanged()
|
||||
updatePreview(preview)
|
||||
}
|
||||
left.addView(LinearLayout(context).apply {
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
setPadding(0, dp(8), 0, 0)
|
||||
addView(preview, LinearLayout.LayoutParams(dp(56), dp(56)))
|
||||
addView(TextView(context).apply {
|
||||
text = if (names.isEmpty()) "Steam's icons were not found" else "${names.size} icons from Steam"
|
||||
setPadding(dp(12), 0, 0, 0)
|
||||
})
|
||||
})
|
||||
left.addView(paletteRow("Icon colour", { foreground }) { foreground = it; adapter.notifyDataSetChanged(); updatePreview(preview) })
|
||||
left.addView(paletteRow("Button colour", { background }) { background = it; adapter.notifyDataSetChanged(); updatePreview(preview) })
|
||||
root.addView(left, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
|
||||
root.addView(grid, LinearLayout.LayoutParams(0, dp(250), 1.2f).apply { marginStart = dp(16) })
|
||||
updatePreview(preview)
|
||||
showPanel(host, title, root, listOf(
|
||||
"No icon" to {
|
||||
onPicked(current.copy(label = label.text.toString().replace(",", " ").trim(), icon = "", foreground = "", background = ""))
|
||||
},
|
||||
"Cancel" to {},
|
||||
"Use" to {
|
||||
onPicked(current.copy(label = label.text.toString().replace(",", " ").trim(), icon = icon,
|
||||
foreground = if (icon.isEmpty()) "" else foreground, background = if (icon.isEmpty()) "" else background))
|
||||
},
|
||||
)) { loader.shutdown() }
|
||||
}
|
||||
|
||||
private fun paletteRow(name: String, value: () -> String, onPick: (String) -> Unit): View {
|
||||
val row = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL; gravity = Gravity.CENTER_VERTICAL }
|
||||
val swatches = mutableListOf<View>()
|
||||
fun refresh() = swatches.forEachIndexed { i, v ->
|
||||
(v.background as GradientDrawable).setStroke(dp(if (SteamTouchBindings.PALETTE[i].equals(value(), true)) 3 else 1),
|
||||
if (SteamTouchBindings.PALETTE[i].equals(value(), true)) Color.rgb(102, 192, 244) else Color.GRAY)
|
||||
}
|
||||
SteamTouchBindings.PALETTE.forEach { hex ->
|
||||
val swatch = View(context).apply {
|
||||
background = GradientDrawable().apply { shape = GradientDrawable.OVAL; setColor(Color.parseColor(hex)) }
|
||||
setOnClickListener { onPick(hex); refresh() }
|
||||
}
|
||||
swatches += swatch
|
||||
row.addView(swatch, LinearLayout.LayoutParams(dp(30), dp(30)).apply { marginEnd = dp(6) })
|
||||
}
|
||||
refresh()
|
||||
return LinearLayout(context).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setPadding(0, dp(8), 0, 0)
|
||||
addView(TextView(context).apply { text = name })
|
||||
addView(HorizontalScrollView(context).apply { addView(row) })
|
||||
}
|
||||
}
|
||||
|
||||
private fun updatePreview(view: ImageView) {
|
||||
if (icon.isEmpty()) { view.setImageDrawable(null); view.background = null; return }
|
||||
loader.execute {
|
||||
val bitmap = SteamTouchBindings.icon(context, appId, icon, 128)?.let { render(it, dp(56)) }
|
||||
main.post { view.setImageBitmap(bitmap) }
|
||||
}
|
||||
}
|
||||
|
||||
/** The icon as Steam draws it: on a disc of the background colour, filled with the foreground
|
||||
* through its own alpha. */
|
||||
private fun render(icon: Bitmap, size: Int): Bitmap {
|
||||
val out = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(out)
|
||||
val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG)
|
||||
paint.color = Color.parseColor(background)
|
||||
canvas.drawCircle(size / 2f, size / 2f, size / 2f, paint)
|
||||
drawSteamIcon(canvas, icon, android.graphics.RectF(size * 0.18f, size * 0.18f, size * 0.82f, size * 0.82f), Color.parseColor(foreground), paint)
|
||||
return out
|
||||
}
|
||||
|
||||
private inner class IconAdapter(val names: List<String>) : BaseAdapter() {
|
||||
private val loading = HashSet<String>()
|
||||
private var refreshQueued = false
|
||||
|
||||
/** Loads an icon in the background; the grid redraws from the cache once some have come in. */
|
||||
private fun load(name: String) {
|
||||
if (!loading.add(name)) return
|
||||
loader.execute {
|
||||
SteamTouchBindings.icon(context, appId, name, 96)
|
||||
main.post {
|
||||
loading.remove(name)
|
||||
if (refreshQueued) return@post
|
||||
refreshQueued = true
|
||||
main.postDelayed({ refreshQueued = false; notifyDataSetChanged() }, 120)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getCount() = names.size
|
||||
override fun getItem(position: Int) = names[position]
|
||||
override fun getItemId(position: Int) = position.toLong()
|
||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View {
|
||||
val view = (convertView as? ImageView) ?: ImageView(context).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(56))
|
||||
setPadding(dp(8), dp(8), dp(8), dp(8))
|
||||
}
|
||||
val name = names[position]
|
||||
view.tag = name
|
||||
view.background = GradientDrawable().apply {
|
||||
cornerRadius = dp(8).toFloat()
|
||||
setColor(Color.parseColor(background))
|
||||
if (name == icon) setStroke(dp(3), Color.rgb(102, 192, 244))
|
||||
}
|
||||
view.colorFilter = null
|
||||
val cached = SteamTouchBindings.cachedIcon(appId, name, 96)
|
||||
if (cached != null) {
|
||||
val t = tinted(name, cached)
|
||||
if ((view.drawable as? android.graphics.drawable.BitmapDrawable)?.bitmap !== t) view.setImageBitmap(t)
|
||||
} else {
|
||||
view.setImageDrawable(null)
|
||||
load(name)
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
// One thumbnail per icon and colour, reused when the grid rebinds.
|
||||
private val thumbs = android.util.LruCache<String, Bitmap>(64)
|
||||
|
||||
private fun tinted(name: String, icon: Bitmap): Bitmap {
|
||||
val key = "$name|$foreground"
|
||||
thumbs.get(key)?.let { return it }
|
||||
val out = Bitmap.createBitmap(icon.width, icon.height, Bitmap.Config.ARGB_8888)
|
||||
drawSteamIcon(Canvas(out), icon, android.graphics.RectF(0f, 0f, icon.width.toFloat(), icon.height.toFloat()),
|
||||
Color.parseColor(foreground), Paint(Paint.FILTER_BITMAP_FLAG))
|
||||
thumbs.put(key, out)
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* A panel over the session, in the session's own window: a dialog's window of its own left
|
||||
* new bitmaps and layers undrawn in the session window afterwards on the Thor.
|
||||
*/
|
||||
fun showPanel(host: ViewGroup, title: String, content: View, buttons: List<Pair<String, () -> Unit>>, onClose: () -> Unit = {}): () -> Unit {
|
||||
val context = host.context
|
||||
val density = context.resources.displayMetrics.density
|
||||
fun dp(v: Int) = (v * density).toInt()
|
||||
val scrim = android.widget.FrameLayout(context).apply {
|
||||
setBackgroundColor(Color.argb(140, 0, 0, 0))
|
||||
isClickable = true
|
||||
isFocusable = true
|
||||
}
|
||||
// DroidDeck's panel: dark surface, rounded, a hairline border.
|
||||
val card = LinearLayout(context).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
background = GradientDrawable().apply {
|
||||
cornerRadius = dp(18).toFloat(); setColor(Color.rgb(18, 20, 23)); setStroke(dp(1), Color.rgb(38, 42, 49))
|
||||
}
|
||||
setPadding(dp(12), dp(14), dp(12), dp(10))
|
||||
isClickable = true
|
||||
}
|
||||
card.addView(TextView(context).apply {
|
||||
text = title
|
||||
textSize = 15f
|
||||
setTypeface(android.graphics.Typeface.DEFAULT_BOLD)
|
||||
gravity = Gravity.CENTER
|
||||
setTextColor(Color.WHITE)
|
||||
setPadding(dp(16), 0, dp(16), dp(4))
|
||||
})
|
||||
card.addView(content)
|
||||
val row = LinearLayout(context).apply { gravity = Gravity.CENTER; setPadding(dp(8), dp(10), dp(8), 0) }
|
||||
fun close() {
|
||||
(scrim.parent as? ViewGroup)?.removeView(scrim)
|
||||
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as android.view.inputmethod.InputMethodManager
|
||||
imm.hideSoftInputFromWindow(host.windowToken, 0)
|
||||
onClose()
|
||||
}
|
||||
buttons.forEachIndexed { i, (name, action) ->
|
||||
row.addView(TextView(context).apply {
|
||||
text = name
|
||||
gravity = Gravity.CENTER
|
||||
textSize = 14f
|
||||
setTextColor(Color.WHITE)
|
||||
val primary = i == buttons.size - 1
|
||||
setTextColor(if (primary) Color.rgb(3, 17, 31) else Color.rgb(242, 244, 247))
|
||||
background = GradientDrawable().apply {
|
||||
cornerRadius = dp(12).toFloat()
|
||||
if (primary) setColor(Color.rgb(26, 159, 255)) else { setColor(Color.rgb(26, 29, 34)); setStroke(dp(1), Color.rgb(52, 58, 67)) }
|
||||
}
|
||||
setPadding(dp(20), dp(10), dp(20), dp(10))
|
||||
setOnClickListener { close(); action() }
|
||||
}, LinearLayout.LayoutParams(0, dp(44), 1f).apply { marginStart = dp(6); marginEnd = dp(6) })
|
||||
}
|
||||
card.addView(row)
|
||||
val width = minOf(dp(640), (context.resources.displayMetrics.widthPixels * 0.9f).toInt())
|
||||
scrim.addView(card, android.widget.FrameLayout.LayoutParams(width, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.CENTER))
|
||||
scrim.setOnClickListener { close() }
|
||||
host.addView(scrim, ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
|
||||
return { if (scrim.parent != null) close() }
|
||||
}
|
||||
|
||||
/** Steam's binding icon look (steamui: the icon multiplied with its foreground colour,
|
||||
* background-blend-mode: multiply, keeping the icon's own alpha). */
|
||||
fun drawSteamIcon(canvas: Canvas, icon: Bitmap, into: android.graphics.RectF, foreground: Int, paint: Paint) {
|
||||
val tint = Paint(paint).apply { colorFilter = PorterDuffColorFilter(foreground, PorterDuff.Mode.MULTIPLY) }
|
||||
canvas.drawBitmap(icon, null, into, tint)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -242,7 +242,7 @@ object AppImageManager {
|
||||
|
||||
private fun menuEntry(context: Context, id: String) =
|
||||
// A system directory, not ~/.local/share/applications: the desktop copies game entries from
|
||||
// here into the user's with a GPU launcher (droiddeck-desktop-gpu), since it composites in software.
|
||||
// here into the user's with a GPU launcher (droiddeck-desktop-gpu) while it runs on pixman.
|
||||
File(LinuxRuntime.rootDir(context), "usr/local/share/applications/droiddeck-appimage-$id.desktop")
|
||||
|
||||
/** The Linux desktop's menu entry: the desktop's own Exec rules (and droiddeck-gpu) apply to it. */
|
||||
@@ -256,20 +256,6 @@ object AppImageManager {
|
||||
"Terminal=false\nCategories=$categories\nX-DroidDeck-AppImage=$id\n")
|
||||
}
|
||||
|
||||
/** The desktop's import of [key]; older ones of the image at [path], since replaced, are removed. */
|
||||
fun desktopImport(context: Context, key: String, path: String): String? {
|
||||
var found: String? = null
|
||||
root(context).listFiles()?.forEach { dir ->
|
||||
val asked = FileUtils.readString(File(dir, UserApps.DESKTOP_IMPORT))?.lines() ?: return@forEach
|
||||
if (asked.firstOrNull() == key) {
|
||||
if (File(dir, "app").isDirectory) found = dir.name
|
||||
} else if (asked.getOrNull(1) == path) {
|
||||
remove(context, dir.name)
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
fun remove(context: Context, id: String) {
|
||||
if (id.isEmpty() || '/' in id || id.startsWith(".")) return
|
||||
FileUtils.delete(File(root(context), id))
|
||||
|
||||
@@ -232,10 +232,10 @@ object BwrapSpawner {
|
||||
val driver = File(lib, "libvulkan_freedreno.so")
|
||||
if (!driver.isFile || !File("/dev/kgsl-3d0").exists()) return null
|
||||
if (env.containsKey("VK_ICD_FILENAMES") || env.containsKey("VK_DRIVER_FILES")) return null
|
||||
// A window on the desktop gets shared-memory buffers only (KWin composites in software);
|
||||
// Zink cannot present there, so such an app keeps Flathub's own Mesa (llvmpipe). From the
|
||||
// desktop, games go through droiddeck-gpu's gamescope and do get the GPU.
|
||||
if (env["BL_DESKTOP"] == "1" && env["GAMESCOPE_WAYLAND_DISPLAY"] == null) return null
|
||||
// A window on the desktop while labwc composites with pixman gets shared-memory buffers
|
||||
// only; Zink cannot present there, so such an app keeps Flathub's own Mesa (llvmpipe).
|
||||
// From the desktop, games go through droiddeck-gpu's gamescope and do get the GPU.
|
||||
if (env["WLR_RENDERER"] == "pixman" && env["GAMESCOPE_WAYLAND_DISPLAY"] == null) return null
|
||||
val binds = ArrayList<Bind>()
|
||||
for (name in listOf("libvulkan_freedreno.so", "libdisplay-info.so.3", "libSPIRV-Tools.so", "libSPIRV-Tools-opt.so")) {
|
||||
val f = File(lib, name)
|
||||
|
||||
@@ -3,7 +3,6 @@ package com.droiddeck.launcher.runtime
|
||||
import com.droiddeck.launcher.core.Hashes
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.BuildConfig
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.core.Downloader
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
@@ -20,12 +19,12 @@ import java.io.File
|
||||
*/
|
||||
object DesktopCatalog {
|
||||
private const val TAG = "DesktopCatalog"
|
||||
const val CATALOG_URL = "https://raw.githubusercontent.com/Droid-Deck/DroidDeck-Components/main/desktop.json"
|
||||
const val CATALOG_URL = "https://raw.githubusercontent.com/The412Banner/winlator-contents/main/desktop.json"
|
||||
/**
|
||||
* Valve's Proton Experimental (ARM64) with its appmanifest, laid out as the client keeps it.
|
||||
* In a catalog of its own so it never shows among the packages a user picks from.
|
||||
*/
|
||||
const val STEAM_SEED_URL = "https://raw.githubusercontent.com/Droid-Deck/DroidDeck-Components/main/steam-seed.json"
|
||||
const val STEAM_SEED_URL = "https://raw.githubusercontent.com/The412Banner/winlator-contents/main/steam-seed.json"
|
||||
const val PROTON_SEED_ID = "proton-arm64"
|
||||
|
||||
class Entry(
|
||||
@@ -75,46 +74,10 @@ object DesktopCatalog {
|
||||
installed(context, PROTON_SEED_ID) == null &&
|
||||
!File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/steamapps/appmanifest_4427310.acf").isFile
|
||||
|
||||
/**
|
||||
* The desktop: KDE Plasma (tools/desktop-kde). It replaced an LXQt/labwc package of id
|
||||
* "desktop", which a runtime may still carry; that one does not count, so the first desktop
|
||||
* after the update installs this one over it.
|
||||
*/
|
||||
const val DESKTOP_ID = "desktop-kde"
|
||||
|
||||
/**
|
||||
* The desktop package this apk installs: pinned in tools/desktop-kde/release.env and published
|
||||
* in DroidDeck-Components, not read from the catalog, so the desktop scripts staged by this
|
||||
* apk always get the package they were made for.
|
||||
*/
|
||||
fun desktopEntry(context: Context) = Entry(
|
||||
DESKTOP_ID, context.getString(R.string.content_linux_desktop), 1, BuildConfig.DESKTOP_KDE_TAG, "tar",
|
||||
BuildConfig.DESKTOP_KDE_URL, BuildConfig.DESKTOP_KDE_SHA256, BuildConfig.DESKTOP_KDE_SIZE, "",
|
||||
"", "",
|
||||
)
|
||||
|
||||
// The desktop package installed to the end: KWin is there and the marker install() writes once
|
||||
// the whole package is extracted. An install cut short (the app killed, storage full) leaves
|
||||
// KWin without the marker, and the next desktop installs it again. A newer pinned package also
|
||||
// replaces the installed one, keeping the user's home and settings. The launcher is staged by
|
||||
// the app at every session, so it cannot tell. SessionFiles uses the same test.
|
||||
fun desktopInstalled(context: Context): Boolean = desktopInstalled(LinuxRuntime.rootDir(context))
|
||||
|
||||
fun desktopInstalled(root: File): Boolean =
|
||||
!File(root, DesktopRemoval.PENDING).exists() &&
|
||||
File(root, "usr/bin/kwin_wayland").isFile &&
|
||||
FileUtils.readString(File(root, ".droiddeck-pkg-$DESKTOP_ID"))?.trim() == BuildConfig.DESKTOP_KDE_TAG
|
||||
|
||||
fun desktopPresent(context: Context): Boolean {
|
||||
val root = LinuxRuntime.rootDir(context)
|
||||
return File(root, DesktopRemoval.PENDING).exists() || marker(context, DESKTOP_ID).exists()
|
||||
}
|
||||
|
||||
fun cleanLegacy(context: Context, listener: LinuxRuntimeInstaller.ProgressListener? = null) =
|
||||
DesktopRemoval.cleanLegacy(context, listener)
|
||||
|
||||
fun removeDesktop(context: Context, listener: LinuxRuntimeInstaller.ProgressListener): String? =
|
||||
DesktopRemoval.remove(context, listener)
|
||||
// labwc comes with the hosted desktop package itself (its launcher is staged by the app at
|
||||
// every session, so it cannot tell whether the package is there); SessionFiles uses the same test.
|
||||
fun desktopInstalled(context: Context): Boolean =
|
||||
File(LinuxRuntime.rootDir(context), "usr/bin/labwc").isFile
|
||||
|
||||
/** Downloads, verifies and installs one package. Returns null on success, else a message. */
|
||||
fun install(context: Context, entry: Entry, listener: LinuxRuntimeInstaller.ProgressListener?): String? {
|
||||
@@ -148,20 +111,9 @@ object DesktopCatalog {
|
||||
"Exec=env APPIMAGE_EXTRACT_AND_RUN=1 /opt/appimages/${entry.id}.AppImage\n" +
|
||||
"Icon=${entry.icon}\nTerminal=false\nCategories=${entry.category};\n")
|
||||
}
|
||||
else -> {
|
||||
// Not complete until written again at the end: a reinstall cut short must not
|
||||
// leave the last install's marker saying it is.
|
||||
marker(context, entry.id).delete()
|
||||
if (!LinuxRuntimeInstaller.extract(context, download, root, listener)) return context.getString(R.string.deskpkg_extract_failed)
|
||||
}
|
||||
}
|
||||
if (entry.id == DESKTOP_ID) {
|
||||
cleanLegacy(context, listener)
|
||||
val pending = File(root, DesktopRemoval.PENDING)
|
||||
check(!pending.exists() || pending.delete()) { "Could not finish desktop installation" }
|
||||
else -> if (!LinuxRuntimeInstaller.extract(context, download, root, listener)) return context.getString(R.string.deskpkg_extract_failed)
|
||||
}
|
||||
FileUtils.writeString(marker(context, entry.id), entry.version)
|
||||
check(installed(context, entry.id) == entry.version) { "Could not finish package installation" }
|
||||
return null
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "install ${entry.id}", e)
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
package com.droiddeck.launcher.runtime
|
||||
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.LinkOption.NOFOLLOW_LINKS
|
||||
import java.nio.file.attribute.BasicFileAttributes
|
||||
import java.security.MessageDigest
|
||||
|
||||
/** Removes only unchanged, recorded package files. Never visits home or follows a symlink. */
|
||||
internal object DesktopFileRemoval {
|
||||
data class Entry(val kind: String, val size: Long, val identity: String, val path: String) {
|
||||
companion object {
|
||||
fun parse(line: String): Entry {
|
||||
val parts = line.split('\t', limit = 4)
|
||||
require(parts.size == 4 && parts[0] in setOf("F", "L")) { "Invalid removal inventory" }
|
||||
return Entry(parts[0], parts[1].toLong(), parts[2], parts[3])
|
||||
}
|
||||
}
|
||||
}
|
||||
data class Result(val removed: Int, val bytes: Long, val kept: Int)
|
||||
|
||||
fun remove(root: File, entries: Iterable<Entry>, protected: Set<String>, progress: (Int) -> Unit = {}): Result {
|
||||
var removed = 0
|
||||
var bytes = 0L
|
||||
var kept = 0
|
||||
val buffer = ByteArray(65536)
|
||||
val total = (entries as? Collection<*>)?.size ?: 0
|
||||
val directories = mutableSetOf<File>()
|
||||
for ((index, entry) in entries.withIndex()) {
|
||||
val file = safeFile(root, entry.path)
|
||||
if (file == null || entry.path in protected) { kept++; continue }
|
||||
val attrs = try { Files.readAttributes(file.toPath(), BasicFileAttributes::class.java, NOFOLLOW_LINKS) }
|
||||
catch (_: java.nio.file.NoSuchFileException) { continue }
|
||||
val matches = when (entry.kind) {
|
||||
"L" -> attrs.isSymbolicLink && Files.readSymbolicLink(file.toPath()).toString() == entry.identity
|
||||
else -> attrs.isRegularFile && attrs.size() == entry.size && sha(file, buffer) == entry.identity
|
||||
}
|
||||
if (!matches) { kept++; continue }
|
||||
Files.delete(file.toPath()) // A failure propagates; the pending marker makes retry possible.
|
||||
removed++
|
||||
bytes += if (attrs.isRegularFile) attrs.size() else 0
|
||||
var parent = file.parentFile
|
||||
while (parent != root && parent != null) { directories.add(parent); parent = parent.parentFile }
|
||||
if (index % 200 == 0) progress(if (total > 0) index * 99 / total else -1)
|
||||
}
|
||||
// Only now-empty parents of removed files, never a recursive delete of a package directory.
|
||||
for (dir in directories.sortedByDescending { it.path.length }) {
|
||||
if (dir.list()?.isEmpty() == true && !dir.delete()) throw IOException("Cannot remove $dir")
|
||||
}
|
||||
progress(99)
|
||||
return Result(removed, bytes, kept)
|
||||
}
|
||||
|
||||
internal fun safeFile(root: File, name: String): File? {
|
||||
if (Files.isSymbolicLink(root.toPath())) return null
|
||||
val parts = name.split('/')
|
||||
if (parts.firstOrNull() !in setOf("usr", "etc") || parts.any { it.isEmpty() || it == "." || it == ".." }) return null
|
||||
// Even an in-root symlink in a parent can redirect deletion into a user's data.
|
||||
var parent = root
|
||||
for (part in parts.dropLast(1)) {
|
||||
parent = File(parent, part)
|
||||
if (Files.isSymbolicLink(parent.toPath())) return null
|
||||
}
|
||||
return File(root, name)
|
||||
}
|
||||
|
||||
internal fun sha(file: File, buffer: ByteArray = ByteArray(65536)): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
file.inputStream().buffered().use { input ->
|
||||
while (true) { val n = input.read(buffer); if (n < 0) break; digest.update(buffer, 0, n) }
|
||||
}
|
||||
val digits = "0123456789abcdef"
|
||||
return buildString(64) {
|
||||
for (byte in digest.digest()) { val b = byte.toInt() and 255; append(digits[b ushr 4]); append(digits[b and 15]) }
|
||||
}
|
||||
}
|
||||
|
||||
/** The base runtime's pacman database is still present: desktop archives never replace it. */
|
||||
fun runtimeFiles(root: File): Set<String> = buildSet {
|
||||
val database = File(root, "var/lib/pacman/local")
|
||||
if (!database.isDirectory || Files.isSymbolicLink(database.toPath())) throw IOException("Runtime package database missing")
|
||||
for (pkg in database.listFiles().orEmpty()) {
|
||||
val files = File(pkg, "files")
|
||||
if (!pkg.isDirectory || Files.isSymbolicLink(pkg.toPath()) || !files.isFile || Files.isSymbolicLink(files.toPath())) continue
|
||||
var inFiles = false
|
||||
files.forEachLine { line ->
|
||||
when {
|
||||
line == "%FILES%" -> inFiles = true
|
||||
line.startsWith('%') -> inFiles = false
|
||||
inFiles && line.isNotEmpty() && !line.endsWith('/') -> add(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isEmpty()) throw IOException("Runtime package inventory missing")
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
package com.droiddeck.launcher.runtime
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import java.io.File
|
||||
import java.util.zip.GZIPInputStream
|
||||
|
||||
/** Inventories are generated from pinned archives by tools/desktop-kde/removal-manifests.py. */
|
||||
internal object DesktopRemoval {
|
||||
const val PENDING = ".droiddeck-desktop-removing"
|
||||
private const val CLEANING = ".droiddeck-lxqt-cleaning-v1"
|
||||
private const val CLEANED = ".droiddeck-lxqt-cleaned-v1"
|
||||
|
||||
private fun lines(context: Context, name: String) =
|
||||
GZIPInputStream(context.assets.open("desktop-removal/$name")).bufferedReader().use { it.readLines() }
|
||||
|
||||
private fun protected(context: Context, root: File) =
|
||||
DesktopFileRemoval.runtimeFiles(root) + lines(context, "emulators-keep.txt.gzip")
|
||||
|
||||
/** Called before a guest starts or just after KDE extraction; retries after a process death. */
|
||||
fun cleanLegacy(context: Context, listener: LinuxRuntimeInstaller.ProgressListener? = null) {
|
||||
val root = LinuxRuntime.rootDir(context)
|
||||
if (FileUtils.readString(File(root, CLEANED))?.trim() == "1") return
|
||||
if (!File(root, CLEANING).exists() && !File(root, ".droiddeck-pkg-desktop").exists() && !File(root, "usr/bin/lxqt-session").exists()) return
|
||||
listener?.onProgress(context.getString(R.string.desktop_cleaning_legacy), -1)
|
||||
val keep = protected(context, root) + lines(context, "kde-keep.txt.gzip")
|
||||
val pending = File(root, CLEANING)
|
||||
FileUtils.writeString(pending, "1")
|
||||
check(FileUtils.readString(pending)?.trim() == "1") { "Could not reserve LXQt cleanup" }
|
||||
val result = GZIPInputStream(context.assets.open("desktop-removal/lxqt-r1.tsv.gzip")).bufferedReader().use { reader ->
|
||||
DesktopFileRemoval.remove(root, reader.lineSequence().map(DesktopFileRemoval.Entry::parse).asIterable(), keep)
|
||||
}
|
||||
FileUtils.writeString(File(root, CLEANED), "1")
|
||||
check(FileUtils.readString(File(root, CLEANED))?.trim() == "1") { "Could not finish LXQt cleanup" }
|
||||
File(root, ".droiddeck-pkg-desktop").delete()
|
||||
pending.delete()
|
||||
Log.i("DesktopRemoval", "LXQt: $result")
|
||||
}
|
||||
|
||||
fun remove(context: Context, listener: LinuxRuntimeInstaller.ProgressListener): String? = try {
|
||||
val root = LinuxRuntime.rootDir(context)
|
||||
val pending = File(root, PENDING)
|
||||
val marker = File(root, ".droiddeck-pkg-${DesktopCatalog.DESKTOP_ID}")
|
||||
val version = FileUtils.readString(pending)?.trim() ?: FileUtils.readString(marker)?.trim()
|
||||
val versions = context.assets.open("desktop-removal/versions.txt").bufferedReader().use { it.readLines() }
|
||||
check(version in versions) { context.getString(R.string.desktop_removal_unknown) }
|
||||
// Validate all inventories and the runtime database before dropping the installed marker.
|
||||
val entries = lines(context, "kde-remove.tsv.gzip").map(DesktopFileRemoval.Entry::parse)
|
||||
val keep = protected(context, root)
|
||||
cleanLegacy(context, listener)
|
||||
FileUtils.writeString(pending, version!!)
|
||||
check(FileUtils.readString(pending)?.trim() == version) { "Could not reserve desktop removal" }
|
||||
if (marker.exists() && !marker.delete()) error("Could not clear desktop marker")
|
||||
val removing = context.getString(R.string.desktop_removing)
|
||||
val result = DesktopFileRemoval.remove(root, entries, keep) { listener.onProgress(removing, it) }
|
||||
// APK-staged desktop files are matched against the APK too; keep user-modified copies.
|
||||
for ((asset, path) in com.droiddeck.launcher.session.SessionFiles.DESKTOP_FILES) {
|
||||
val target = DesktopFileRemoval.safeFile(root, path) ?: continue
|
||||
if (!isRegularFile(target)) continue
|
||||
val content = context.assets.open("linuxfs/$asset").use { it.readBytes() }
|
||||
if (target.length() == content.size.toLong() && target.inputStream().use { it.readBytes() }.contentEquals(content)) {
|
||||
if (!target.delete()) error("Could not remove $path")
|
||||
}
|
||||
}
|
||||
if (!pending.delete()) error("Could not finish desktop removal")
|
||||
Log.i("DesktopRemoval", "KDE: $result")
|
||||
listener.onProgress(context.getString(R.string.desktop_removed), 100)
|
||||
null
|
||||
} catch (e: Exception) {
|
||||
Log.e("DesktopRemoval", "remove", e)
|
||||
e.message ?: context.getString(R.string.deskpkg_install_failed)
|
||||
}
|
||||
|
||||
private fun isRegularFile(file: File) = java.nio.file.Files.isRegularFile(file.toPath(), java.nio.file.LinkOption.NOFOLLOW_LINKS)
|
||||
}
|
||||
@@ -87,7 +87,7 @@ object FlatpakManager {
|
||||
?.maxByOrNull { it.first }?.second
|
||||
}
|
||||
|
||||
/** Runs [argv] in the runtime, its output also in files/logs/tools/flatpak-<verb>.log. */
|
||||
/** Runs [argv] in the runtime, its output also in Download/DroidDeck/flatpak-<verb>.log. */
|
||||
private fun runGuest(context: Context, argv: List<String>, fakeRoot: Boolean, onLine: (String) -> Unit): Int {
|
||||
val name = "flatpak-" + (argv.getOrNull(2)?.takeIf { argv.getOrNull(1) == HELPER } ?: "setup")
|
||||
return GuestCommand.run(context, argv, fakeRoot, name, onLine = onLine)
|
||||
|
||||
@@ -17,7 +17,7 @@ object GuestCommand {
|
||||
/**
|
||||
* Runs [argv] and hands each output line to [onLine]; returns the exit status. [fakeRoot] is
|
||||
* for the package tools, which refuse any uid but 0. With [logName], everything the command
|
||||
* says also goes to files/logs/tools/<logName>.log beside the session logs, so a problem can
|
||||
* says also goes to Download/DroidDeck/tools/<logName>.log beside the session logs, so a problem can
|
||||
* be handed over like a session's. With [linkDir], hard links (which Android denies apps)
|
||||
* become symlinks to files proot keeps there (its link2symlink).
|
||||
*/
|
||||
@@ -25,7 +25,7 @@ object GuestCommand {
|
||||
linkDir: File? = null, onLine: (String) -> Unit): Int {
|
||||
val log = logName?.let {
|
||||
try {
|
||||
File(File(LinuxRuntime.logDir(context), SessionPaths.TOOLS_DIR).apply { mkdirs() }, "$it.log").printWriter()
|
||||
File(File(LinuxRuntime.debugLogDir(), SessionPaths.TOOLS_DIR).apply { mkdirs() }, "$it.log").printWriter()
|
||||
} catch (e: Exception) { null }
|
||||
}
|
||||
log?.println("== ${java.util.Date()} ${argv.joinToString(" ")}")
|
||||
|
||||
@@ -44,20 +44,10 @@ public final class LinuxRuntime {
|
||||
public static final String GUEST_RUNTIME_DIR = "/run/droiddeck";
|
||||
private static final String SYSTEM_FONTS = "/system/fonts";
|
||||
private static final String GUEST_SYSTEM_FONTS = "/usr/local/share/fonts/android";
|
||||
/** The folder under Downloads where builds before private logs wrote them; read only to move them. */
|
||||
/** Where every Linux session's debug log lands: public, so a user can just hand the folder over. */
|
||||
public static final String DEBUG_LOG_DIR = "DroidDeck";
|
||||
|
||||
/**
|
||||
* Where every log lands: app-private, {@code files/logs}. Session folders, tools/ (one-off
|
||||
* commands) and stores/ sit under it; the files directory is bound into every session at the
|
||||
* same path, so the guest writes there too. Share logs is how a folder leaves the device.
|
||||
*/
|
||||
public static File logDir(Context context) {
|
||||
return new File(context.getFilesDir(), "logs");
|
||||
}
|
||||
|
||||
/** {@code Download/DroidDeck}, where earlier builds wrote logs. Only LogMigration reads it. */
|
||||
public static File legacyLogDir() {
|
||||
public static File debugLogDir() {
|
||||
return new File(android.os.Environment.getExternalStoragePublicDirectory(
|
||||
android.os.Environment.DIRECTORY_DOWNLOADS), DEBUG_LOG_DIR);
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ public final class LinuxRuntimeInstaller {
|
||||
|
||||
/** Catalog row, beside the other component catalogs in winlator-contents. */
|
||||
public static final String CATALOG_URL =
|
||||
"https://raw.githubusercontent.com/Droid-Deck/DroidDeck-Components/main/linuxfs.json";
|
||||
"https://raw.githubusercontent.com/The412Banner/winlator-contents/main/linuxfs.json";
|
||||
|
||||
private static final String VERSION_FILE = ".version";
|
||||
|
||||
@@ -85,7 +85,6 @@ public final class LinuxRuntimeInstaller {
|
||||
volatile int percent = -1;
|
||||
volatile boolean ok;
|
||||
final boolean removal;
|
||||
boolean maintenance;
|
||||
Job(boolean removal, String stage) { this.removal = removal; this.stage = stage; }
|
||||
}
|
||||
|
||||
@@ -140,7 +139,7 @@ public final class LinuxRuntimeInstaller {
|
||||
Job job;
|
||||
boolean owner;
|
||||
synchronized (JOB_LOCK) {
|
||||
if (running != null && (running.removal || running.maintenance)) return false;
|
||||
if (running != null && running.removal) return false;
|
||||
owner = running == null;
|
||||
if (owner) { running = new Job(false, context.getString(R.string.user_apps_starting)); removalError = null; }
|
||||
job = running;
|
||||
@@ -172,7 +171,7 @@ public final class LinuxRuntimeInstaller {
|
||||
/** True while an install is running in this process, whoever started it. */
|
||||
public static boolean isInstalling() {
|
||||
synchronized (JOB_LOCK) {
|
||||
return running != null && !running.removal && !running.maintenance;
|
||||
return running != null && !running.removal;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,53 +183,6 @@ public final class LinuxRuntimeInstaller {
|
||||
synchronized (JOB_LOCK) { return running != null && running.removal; }
|
||||
}
|
||||
|
||||
public static boolean isMaintaining() {
|
||||
synchronized (JOB_LOCK) { return running != null && running.maintenance; }
|
||||
}
|
||||
|
||||
/** Reserve an in-place package operation with the same exclusion/progress as runtime installs. */
|
||||
public static Maintenance beginMaintenance(Context context, String stage) {
|
||||
if (com.droiddeck.launcher.session.SessionState.INSTANCE.getRunning()) return null;
|
||||
com.droiddeck.launcher.session.SessionPhase phase = com.droiddeck.launcher.session.SessionState.INSTANCE.getPhase();
|
||||
if (phase != com.droiddeck.launcher.session.SessionPhase.IDLE && phase != com.droiddeck.launcher.session.SessionPhase.FAILED) return null;
|
||||
synchronized (JOB_LOCK) {
|
||||
if (running != null) return null;
|
||||
removalError = null;
|
||||
running = new Job(false, stage);
|
||||
running.maintenance = true;
|
||||
return new Maintenance(running);
|
||||
}
|
||||
}
|
||||
|
||||
public interface MaintenanceWork { String run(ProgressListener listener); }
|
||||
|
||||
public static final class Maintenance {
|
||||
private final Job job;
|
||||
private final java.util.concurrent.atomic.AtomicBoolean started = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
private Maintenance(Job job) { this.job = job; }
|
||||
public boolean run(ProgressListener listener, MaintenanceWork work) {
|
||||
if (!started.compareAndSet(false, true)) return join(job, listener);
|
||||
if (listener != null) job.listeners.add(listener);
|
||||
try {
|
||||
removalError = work.run((stage, percent) -> {
|
||||
job.stage = stage;
|
||||
job.percent = percent;
|
||||
for (ProgressListener l : job.listeners) l.onProgress(stage, percent);
|
||||
});
|
||||
job.ok = removalError == null;
|
||||
return job.ok;
|
||||
} catch (Exception e) {
|
||||
removalError = e.getMessage();
|
||||
Log.e(TAG, "maintenance", e);
|
||||
return false;
|
||||
} finally {
|
||||
synchronized (JOB_LOCK) { running = null; }
|
||||
job.done.countDown();
|
||||
if (listener != null) job.listeners.remove(listener);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static String removalError() { return removalError; }
|
||||
|
||||
public static boolean hasRemovalPending(Context context) {
|
||||
@@ -275,8 +227,7 @@ public final class LinuxRuntimeInstaller {
|
||||
// Its quarantined home is not user data to carry into an update.
|
||||
File pendingRemoval = removalDirectory(LinuxRuntime.rootDir(context));
|
||||
if (pendingRemoval.exists() && listener != null) listener.onProgress(context.getString(R.string.rtinst_removing_leftovers), -1);
|
||||
if (!discard(pendingRemoval, null)) throw new IOException("Cannot clear the previous runtime: " + pendingRemoval);
|
||||
emptyTrash(LinuxRuntime.rootDir(context).getParentFile());
|
||||
RuntimeFileTree.delete(pendingRemoval, null);
|
||||
String downloading = context.getString(R.string.rtinst_downloading);
|
||||
if (listener != null) listener.onProgress(Step.DOWNLOADING, downloading, 0);
|
||||
// Downloader reports a 0..1 fraction, or -1 while the total size is unknown.
|
||||
@@ -304,7 +255,8 @@ public final class LinuxRuntimeInstaller {
|
||||
File staging = new File(root.getParentFile(), LinuxRuntime.DIR + ".new");
|
||||
File old = new File(root.getParentFile(), LinuxRuntime.DIR + ".old");
|
||||
recoverInterruptedSwap(root, staging, old);
|
||||
if (!discard(staging, null) || !staging.mkdirs()) return false;
|
||||
RuntimeFileTree.delete(staging, null);
|
||||
if (!staging.mkdirs()) return false;
|
||||
if (listener != null) listener.onProgress(context.getString(R.string.rtinst_extracting), -1);
|
||||
if (!extract(context, archive, staging, listener)) {
|
||||
RuntimeFileTree.delete(staging, null);
|
||||
@@ -312,10 +264,7 @@ public final class LinuxRuntimeInstaller {
|
||||
}
|
||||
FileUtils.writeString(new File(staging, VERSION_FILE), release.version);
|
||||
|
||||
if (!discard(old, null)) {
|
||||
discard(staging, null);
|
||||
return false;
|
||||
}
|
||||
RuntimeFileTree.delete(old, null);
|
||||
if (root.isDirectory() && !root.renameTo(old)) {
|
||||
RuntimeFileTree.delete(staging, null);
|
||||
return false;
|
||||
@@ -343,7 +292,7 @@ public final class LinuxRuntimeInstaller {
|
||||
if (old.isDirectory()) old.renameTo(root);
|
||||
return false;
|
||||
}
|
||||
discard(old, null);
|
||||
RuntimeFileTree.delete(old, null);
|
||||
return LinuxRuntime.isInstalled(context);
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "install", e);
|
||||
@@ -366,35 +315,6 @@ public final class LinuxRuntimeInstaller {
|
||||
}
|
||||
}
|
||||
|
||||
/** Where entries the app cannot unlink are set aside, out of the way of the runtime's names. */
|
||||
static final String TRASH = ".runtime-trash";
|
||||
|
||||
/**
|
||||
* Gets {@code tree} out from under its name. What can be deleted is; if something inside
|
||||
* cannot be (a file made by root or {@code su} carries another SELinux category, and the app
|
||||
* may not unlink it), the remainder is moved aside into {@link #TRASH} instead of blocking
|
||||
* every later install and removal on it. False only when even that move is refused.
|
||||
*/
|
||||
static boolean discard(File tree, java.util.function.LongConsumer progress) {
|
||||
if (RuntimeFileTree.deleteWhatCan(tree, progress)) return true;
|
||||
File trash = new File(tree.getParentFile(), TRASH);
|
||||
trash.mkdirs();
|
||||
File aside = new File(trash, tree.getName() + "-" + System.currentTimeMillis());
|
||||
if (tree.renameTo(aside)) {
|
||||
Log.w(TAG, "could not delete all of " + tree + "; set the rest aside in " + aside);
|
||||
return true;
|
||||
}
|
||||
Log.w(TAG, "could not delete or set aside " + tree);
|
||||
return !tree.exists();
|
||||
}
|
||||
|
||||
/** Another try at what {@link #discard} set aside. Whatever still cannot go stays there. */
|
||||
static void emptyTrash(File parent) {
|
||||
File trash = new File(parent, TRASH);
|
||||
if (trash.exists() && !RuntimeFileTree.deleteWhatCan(trash, null))
|
||||
Log.i(TAG, "some runtime leftovers still cannot be deleted: " + trash);
|
||||
}
|
||||
|
||||
private static boolean isEmptyDir(File dir) {
|
||||
String[] names = dir.list();
|
||||
return dir.isDirectory() && names != null && names.length == 0;
|
||||
@@ -449,21 +369,15 @@ public final class LinuxRuntimeInstaller {
|
||||
// Invalidate the installation before traversing it. A process killed halfway
|
||||
// through leaves a named removal to resume, never a launchable partial runtime.
|
||||
if (root.exists()) {
|
||||
if (!discard(pending, progress) || !root.renameTo(pending))
|
||||
throw new IOException("Cannot prepare the runtime for removal");
|
||||
RuntimeFileTree.delete(pending, progress);
|
||||
if (!root.renameTo(pending)) throw new IOException("Cannot prepare the runtime for removal");
|
||||
} else if (!pending.isDirectory() && !pending.mkdirs()) {
|
||||
throw new IOException("Cannot prepare the runtime for removal");
|
||||
}
|
||||
// Keep the quarantine until all leftovers are gone, so a failure stays retryable.
|
||||
// An entry the app may not delete is set aside rather than failing the removal:
|
||||
// a quarantine that can never clear would block reinstalling for good.
|
||||
for (File leftover : new File[]{
|
||||
new File(root.getParentFile(), root.getName() + ".new"),
|
||||
new File(root.getParentFile(), root.getName() + ".old"),
|
||||
pending}) {
|
||||
if (!discard(leftover, progress)) throw new IOException("Cannot remove " + leftover);
|
||||
}
|
||||
emptyTrash(root.getParentFile());
|
||||
RuntimeFileTree.delete(new File(root.getParentFile(), root.getName() + ".new"), progress);
|
||||
RuntimeFileTree.delete(new File(root.getParentFile(), root.getName() + ".old"), progress);
|
||||
RuntimeFileTree.delete(pending, progress);
|
||||
job.ok = true;
|
||||
report(text(app, R.string.rtinst_removed), 100);
|
||||
return true;
|
||||
|
||||
@@ -24,15 +24,8 @@ object ProotFastPath {
|
||||
/** MAX_BINDS in fastpath.c: past it the library turns itself off, so the session keeps it off. */
|
||||
const val MAX_BINDS = 256
|
||||
|
||||
/**
|
||||
* Only the app's own proot (libproot.so, built with patches/0014) lets the trampoline through.
|
||||
* The runtime's fallback copy (LinuxRuntime.prootBinary) is Termux's, which traps those calls
|
||||
* too and translates their host paths a second time: every lookup then fails inside the
|
||||
* process and goes to proot anyway, paying for both.
|
||||
*/
|
||||
fun enabled(context: Context): Boolean =
|
||||
SessionPrefs.prootFastPath(context) && !SessionPrefs.prootNoSeccomp(context) &&
|
||||
LinuxRuntime.prootBinary(context).parent == context.applicationInfo.nativeLibraryDir
|
||||
SessionPrefs.prootFastPath(context) && !SessionPrefs.prootNoSeccomp(context)
|
||||
|
||||
/** The key for this rootfs and these binds, or null when the library cannot be told them. */
|
||||
fun key(root: File, binds: List<String>): String? {
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.droiddeck.launcher.runtime;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.AccessDeniedException;
|
||||
import java.nio.file.DirectoryStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
@@ -10,15 +9,11 @@ import java.nio.file.NoSuchFileException;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.util.function.LongConsumer;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
/** Checked removal of an app-owned runtime tree, including read-only directories. */
|
||||
final class RuntimeFileTree {
|
||||
private RuntimeFileTree() {}
|
||||
|
||||
/** Unit tests stand in for an entry the app cannot unlink (a file another SELinux user made). */
|
||||
static volatile Predicate<Path> refuseForTest;
|
||||
|
||||
static void delete(File root, LongConsumer progress) throws IOException {
|
||||
delete(root.toPath(), progress, new long[]{0});
|
||||
}
|
||||
@@ -35,46 +30,8 @@ final class RuntimeFileTree {
|
||||
for (Path child : children) delete(child, progress, removed);
|
||||
}
|
||||
}
|
||||
unlink(path);
|
||||
if (progress != null) progress.accept(++removed[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes what it can of {@code root} and carries on past entries it cannot, rather than
|
||||
* stopping at the first. Returns true when nothing is left.
|
||||
*/
|
||||
static boolean deleteWhatCan(File root, LongConsumer progress) {
|
||||
return deleteWhatCan(root.toPath(), progress, new long[]{0});
|
||||
}
|
||||
|
||||
private static boolean deleteWhatCan(Path path, LongConsumer progress, long[] removed) {
|
||||
BasicFileAttributes attrs;
|
||||
try { attrs = Files.readAttributes(path, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); }
|
||||
catch (NoSuchFileException e) { return true; }
|
||||
catch (IOException e) { return false; }
|
||||
boolean emptied = true;
|
||||
if (attrs.isDirectory()) {
|
||||
try {
|
||||
makeWritable(path.toFile());
|
||||
try (DirectoryStream<Path> children = Files.newDirectoryStream(path)) {
|
||||
for (Path child : children) emptied &= deleteWhatCan(child, progress, removed);
|
||||
}
|
||||
} catch (IOException | RuntimeException e) {
|
||||
emptied = false;
|
||||
}
|
||||
}
|
||||
if (!emptied) return false;
|
||||
try { unlink(path); }
|
||||
catch (NoSuchFileException e) { return true; }
|
||||
catch (IOException e) { return false; }
|
||||
if (progress != null) progress.accept(++removed[0]);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void unlink(Path path) throws IOException {
|
||||
Predicate<Path> refuse = refuseForTest;
|
||||
if (refuse != null && refuse.test(path)) throw new AccessDeniedException(path.toString());
|
||||
Files.delete(path);
|
||||
if (progress != null) progress.accept(++removed[0]);
|
||||
}
|
||||
|
||||
private static void makeWritable(File dir) throws IOException {
|
||||
|
||||
@@ -30,7 +30,7 @@ class RuntimeInstallService : Service() {
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
val removal = intent?.action == ACTION_REMOVE
|
||||
val release = intent?.let(::releaseFrom)
|
||||
startForeground(NOTIFICATION_ID, notification(getString(if (LinuxRuntimeInstaller.isMaintaining()) R.string.desktop_removing else if (removal) R.string.rtinst_removing else R.string.user_apps_starting), -1))
|
||||
startForeground(NOTIFICATION_ID, notification(getString(if (removal) R.string.rtinst_removing else R.string.user_apps_starting), -1))
|
||||
if (release == null && !removal) {
|
||||
stopSelf(startId)
|
||||
return START_NOT_STICKY
|
||||
@@ -68,7 +68,7 @@ class RuntimeInstallService : Service() {
|
||||
PendingIntent.FLAG_IMMUTABLE)
|
||||
return Notification.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_stat_session)
|
||||
.setContentTitle(getString(if (LinuxRuntimeInstaller.isMaintaining()) R.string.desktop_removing else if (LinuxRuntimeInstaller.isRemoving()) R.string.rtinst_title_removing else R.string.rtinst_title_installing))
|
||||
.setContentTitle(getString(if (LinuxRuntimeInstaller.isRemoving()) R.string.rtinst_title_removing else R.string.rtinst_title_installing))
|
||||
.setContentText(stage)
|
||||
.setProgress(100, percent.coerceIn(0, 100), percent < 0)
|
||||
.setContentIntent(open)
|
||||
|
||||
@@ -32,8 +32,6 @@ object UserApps {
|
||||
private const val TAG = "UserApps"
|
||||
const val GUEST_DIR = "/opt/droiddeck-apps"
|
||||
const val SCRIPT_LAUNCHER = "/usr/local/bin/droiddeck-script-run"
|
||||
/** An app the desktop asked for (droiddeck-open): its key, then the path it was started from there. */
|
||||
const val DESKTOP_IMPORT = "desktop-import"
|
||||
private const val FLATPAK_OVERRIDES = ".flatpak"
|
||||
private const val ICON_SIZE = 256
|
||||
private const val ELF_SCAN_LIMIT = 4000
|
||||
@@ -74,8 +72,8 @@ object UserApps {
|
||||
class FlatpakBundle(val file: File) : Source()
|
||||
}
|
||||
|
||||
/** [icon]: a picked file's path or a suggested icon's URL. [meta]: files written into the added app's folder. */
|
||||
class Request(val source: Source, val name: String?, val icon: String?, val meta: Map<String, String> = emptyMap())
|
||||
/** [icon]: a picked file's path or a suggested icon's URL. */
|
||||
class Request(val source: Source, val name: String?, val icon: String?)
|
||||
|
||||
/** How a script's folder reaches the session: linked in place, or copied in ([bytes] to copy). */
|
||||
class ScriptPlan(val folder: File, val copy: Boolean, val bytes: Long)
|
||||
@@ -123,8 +121,8 @@ object UserApps {
|
||||
val name = request.name?.trim()?.takeIf { it.isNotEmpty() }
|
||||
return withIcon(context, request.icon) { icon ->
|
||||
when (val s = request.source) {
|
||||
is Source.Script -> addScript(context, s.file, name, icon, request.meta, onProgress)
|
||||
is Source.AppImage -> AppImageManager.import(context, s.file, name, icon, request.meta) { onProgress(it, -1) }
|
||||
is Source.Script -> addScript(context, s.file, name, icon, onProgress)
|
||||
is Source.AppImage -> AppImageManager.import(context, s.file, name, icon) { onProgress(it, -1) }
|
||||
is Source.GitHub -> addFromGitHub(context, s.repo, name, icon, onProgress)
|
||||
is Source.Flatpak -> addFlatpak(context, s.id, name, icon, onProgress)
|
||||
is Source.FlatpakBundle -> addFlatpakBundle(context, s.file, name, icon, onProgress)
|
||||
@@ -199,14 +197,6 @@ object UserApps {
|
||||
}.distinct()
|
||||
}
|
||||
|
||||
/** The desktop's import of [key] as the session sees it: an added script's folder, or an AppImage's. */
|
||||
fun desktopImport(context: Context, key: String, path: String): String? {
|
||||
root(context).listFiles()?.firstOrNull { dir ->
|
||||
File(dir, "entry").isFile && FileUtils.readString(File(dir, DESKTOP_IMPORT))?.lineSequence()?.firstOrNull() == key
|
||||
}?.let { return "$GUEST_DIR/${it.name}" }
|
||||
return AppImageManager.desktopImport(context, key, path)?.let { "${AppImageManager.GUEST_DIR}/$it" }
|
||||
}
|
||||
|
||||
fun planScript(script: File): ScriptPlan {
|
||||
val folder = script.canonicalFile.parentFile ?: script.parentFile!!
|
||||
var scanned = 0
|
||||
@@ -219,9 +209,7 @@ object UserApps {
|
||||
RandomAccessFile(f, "r").use { val b = ByteArray(4); it.read(b) == 4 && b[0] == 0x7f.toByte() && b[1] == 'E'.code.toByte() && b[2] == 'L'.code.toByte() && b[3] == 'F'.code.toByte() }
|
||||
} catch (e: Exception) { false }
|
||||
|
||||
private fun addScript(
|
||||
context: Context, script: File, name: String?, icon: File?, meta: Map<String, String>, onProgress: (String, Int) -> Unit,
|
||||
): String? {
|
||||
private fun addScript(context: Context, script: File, name: String?, icon: File?, onProgress: (String, Int) -> Unit): String? {
|
||||
if (!script.isFile) return context.getString(R.string.user_apps_file_gone)
|
||||
val plan = planScript(script)
|
||||
val base = root(context).apply { mkdirs() }
|
||||
@@ -244,7 +232,6 @@ object UserApps {
|
||||
FileUtils.writeString(File(dir, "name"), title)
|
||||
FileUtils.writeString(File(dir, "source"), script.canonicalPath)
|
||||
if (icon != null && !saveIcon(icon, File(dir, "icon.png"))) Log.w(TAG, "icon ${icon.path} could not be read")
|
||||
meta.forEach { (k, v) -> FileUtils.writeString(File(dir, k), v) }
|
||||
writeScriptEntry(context, dir)
|
||||
return null
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -68,19 +68,12 @@ object GameStorage {
|
||||
|
||||
class Library(val host: File, val guest: String, val label: String)
|
||||
|
||||
/**
|
||||
* Games folders an earlier build registered as Steam libraries that Steam has installed games
|
||||
* into (an appmanifest in their steamapps/): those stay libraries, so the games are not lost.
|
||||
* Any other Games folder is no longer one; the session's library list leaves it out and the
|
||||
* runtime's droiddeck-steam-library forgets it.
|
||||
*/
|
||||
fun gamesFolderLibraries(context: Context): List<Library> {
|
||||
if (SessionPrefs.gameStorage(context).isNotEmpty()) return emptyList()
|
||||
val card = effective(context)?.let { canonical(File(it.path)) }
|
||||
val sm = context.getSystemService(StorageManager::class.java)
|
||||
return AddedGames.roots(context).mapNotNull { root ->
|
||||
val host = canonical(root.host)
|
||||
if (!hasInstalledGames(root.host)) return@mapNotNull null
|
||||
if ('"' in root.guest || '\\' in root.guest || !root.host.isDirectory || !root.host.canWrite()) return@mapNotNull null
|
||||
if (card != null && (host == card || host.startsWith("$card/") || card.startsWith("$host/"))) return@mapNotNull null
|
||||
val volume = try { sm?.getStorageVolume(root.host)?.getDescription(context) } catch (e: Exception) { null }
|
||||
@@ -90,10 +83,6 @@ object GameStorage {
|
||||
}
|
||||
}
|
||||
|
||||
/** Steam has installed a game into [root] as a library: a manifest in its steamapps/. */
|
||||
internal fun hasInstalledGames(root: File): Boolean =
|
||||
File(root, "steamapps").listFiles { f -> f.isFile && f.name.startsWith("appmanifest_") && f.name.endsWith(".acf") }?.isNotEmpty() == true
|
||||
|
||||
private fun canonical(file: File): String = runCatching { file.canonicalPath }.getOrDefault(file.absolutePath)
|
||||
|
||||
/** The label the client shows for a chosen folder: its last name, or the volume's. */
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Once, on the first start of a build with private logs: the session folders, tools/ and the loose
|
||||
* tool logs that earlier builds left in `Download/DroidDeck/` move into `files/logs/`. Best effort:
|
||||
* an item that cannot be moved stays where it is. Moved session folders lose their old scrub markers
|
||||
* ([SessionArtifacts.unmarkMoved]), so [SessionArtifacts.scrubOlder] scrubs them under the current rules. Nothing else in that folder is touched - game
|
||||
* save backups (Saves/) stay public - and after this pass the app never reads it again.
|
||||
*/
|
||||
object LogMigration {
|
||||
private const val TAG = "LogMigration"
|
||||
private const val MARKER = ".moved-from-downloads"
|
||||
|
||||
@Synchronized
|
||||
fun run(context: Context) {
|
||||
val target = LinuxRuntime.logDir(context).apply { mkdirs() }
|
||||
val marker = File(target, MARKER)
|
||||
if (marker.exists()) return
|
||||
val legacy = LinuxRuntime.legacyLogDir()
|
||||
val items = if (legacy.isDirectory) legacy.listFiles() else emptyArray()
|
||||
// Unreadable (no storage access yet): try again next start rather than give up on them.
|
||||
if (items == null) { Log.w(TAG, "$legacy cannot be listed; logs there stay until it can"); return }
|
||||
var moved = 0
|
||||
val movedFolders = ArrayList<File>()
|
||||
for (f in items) {
|
||||
val isLog = SessionPaths.isSessionFolder(f) ||
|
||||
(f.isDirectory && f.name == SessionPaths.TOOLS_DIR) ||
|
||||
(f.isFile && f.name.endsWith(".log"))
|
||||
if (!isLog) continue
|
||||
val dest = File(if (f.isFile) File(target, SessionPaths.TOOLS_DIR).apply { mkdirs() } else target, f.name)
|
||||
if (dest.exists() && f.name != SessionPaths.TOOLS_DIR) continue
|
||||
if (move(f, dest)) {
|
||||
moved++
|
||||
if (SessionPaths.isSessionFolder(dest)) movedFolders.add(dest)
|
||||
} else Log.w(TAG, "could not move ${f.name}; left in place")
|
||||
}
|
||||
// Earlier builds scrubbed these under older rules (or not at all) and marked them done:
|
||||
// without their markers, the pass over older folders that follows at the same start puts
|
||||
// them through the current redactor.
|
||||
SessionArtifacts.unmarkMoved(movedFolders)
|
||||
try { marker.writeText("moved $moved item(s)\n") } catch (e: Exception) { Log.w(TAG, "could not mark the move: ${e.message}") }
|
||||
if (moved > 0) Log.i(TAG, "moved $moved log item(s) from $legacy into $target")
|
||||
}
|
||||
|
||||
/** A rename where it works (same volume), else a copy then the source's removal; false = left as it was. */
|
||||
private fun move(src: File, dest: File): Boolean {
|
||||
if (src.renameTo(dest)) return true
|
||||
return try {
|
||||
copy(src, dest)
|
||||
FileUtils.delete(src)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "copy of ${src.name} failed: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun copy(src: File, dest: File) {
|
||||
if (src.isDirectory) {
|
||||
dest.mkdirs()
|
||||
src.listFiles()?.forEach { copy(it, File(dest, it.name)) }
|
||||
} else {
|
||||
dest.parentFile?.mkdirs()
|
||||
src.inputStream().use { input -> dest.outputStream().use { input.copyTo(it) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import android.net.LocalServerSocket
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Build
|
||||
import android.os.CombinedVibration
|
||||
import android.os.SystemClock
|
||||
import android.os.VibrationEffect
|
||||
import android.os.Vibrator
|
||||
import android.os.VibratorManager
|
||||
@@ -23,21 +22,19 @@ import kotlin.math.max
|
||||
* When a game plays a force-feedback effect on the fake pad (or Steam Input sends the Deck's
|
||||
* rumble report), the fake evdev layer inside the guest connects to the abstract socket [NAME]
|
||||
* and sends one packet: strong, weak, duration in ms and the pad slot, four little-endian 16-bit
|
||||
* values. This listens for those and plays the effect on the physical controller playing in that
|
||||
* slot (PadBridge.controllerForSlot) when Android exposes its motors - strong and weak separately
|
||||
* where it has two - and otherwise on the device's own vibrator, with the stronger motor's
|
||||
* strength: the on-screen pad, or a controller without rumble. Best effort both ways: a missing
|
||||
* listener costs the guest nothing, and a bad packet is dropped.
|
||||
* values. This listens for those and plays the effect on the physical controller that last drove
|
||||
* the pad (PadBridge.activeControllerId) when Android exposes its motors - strong and weak
|
||||
* separately where it has two - and otherwise on the device's own vibrator, with the stronger
|
||||
* motor's strength: the on-screen pad, or a controller without rumble. Best effort both ways: a
|
||||
* missing listener costs the guest nothing, and a bad packet is dropped.
|
||||
*/
|
||||
class RumbleComponent : SessionPart() {
|
||||
@Volatile private var server: LocalServerSocket? = null
|
||||
private var phone: Motors? = null
|
||||
/** Per slot, where its last effect went, so the next one (or a stop) can end it there. */
|
||||
private val playing = HashMap<Int, Motors>()
|
||||
/** Per slot, the last effect handed to the motors and when, to drop the repeats games stream. */
|
||||
private val sent = HashMap<Int, Sent>()
|
||||
/** Each controller's motors, looked up once; null when it has none. */
|
||||
private val controllers = HashMap<Int, Motors?>()
|
||||
/** Where the last effect went, so the next one (or a stop) can end it there. */
|
||||
private var playing: Motors? = null
|
||||
private var controllerId = PadBridge.NO_CONTROLLER
|
||||
private var controller: Motors? = null
|
||||
private var preferences: SharedPreferences? = null
|
||||
private var enabled = false
|
||||
private val preferenceListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
||||
@@ -49,7 +46,7 @@ class RumbleComponent : SessionPart() {
|
||||
|
||||
@Synchronized private fun refreshEnabled() {
|
||||
enabled = app()?.let { ControllerPrefs.rumbleEnabled(it) } == true
|
||||
if (!enabled) cancelAll()
|
||||
if (!enabled) { playing?.cancel(); playing = null }
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
@@ -67,7 +64,7 @@ class RumbleComponent : SessionPart() {
|
||||
try {
|
||||
val bytes = ByteArray(8)
|
||||
DataInputStream(client.inputStream).readFully(bytes)
|
||||
buzz(u16(bytes, 0), u16(bytes, 2), u16(bytes, 4), u16(bytes, 6))
|
||||
buzz(u16(bytes, 0), u16(bytes, 2), u16(bytes, 4))
|
||||
} catch (e: Exception) {
|
||||
// A partial packet or a closed peer: nothing to play.
|
||||
} finally {
|
||||
@@ -82,9 +79,11 @@ class RumbleComponent : SessionPart() {
|
||||
preferences?.unregisterOnSharedPreferenceChangeListener(preferenceListener)
|
||||
preferences = null
|
||||
enabled = false
|
||||
cancelAll()
|
||||
playing?.cancel()
|
||||
playing = null
|
||||
phone = null
|
||||
controllers.clear()
|
||||
controller = null
|
||||
controllerId = PadBridge.NO_CONTROLLER
|
||||
val s = server
|
||||
server = null
|
||||
try { s?.close() } catch (e: Exception) { /* the accept loop ends on the next wake */ }
|
||||
@@ -92,48 +91,33 @@ class RumbleComponent : SessionPart() {
|
||||
|
||||
private fun u16(b: ByteArray, at: Int): Int = (b[at].toInt() and 0xFF) or ((b[at + 1].toInt() and 0xFF) shl 8)
|
||||
|
||||
@Synchronized private fun buzz(strong: Int, weak: Int, ms: Int, slot: Int) {
|
||||
@Synchronized private fun buzz(strong: Int, weak: Int, ms: Int) {
|
||||
if (!enabled) return
|
||||
val target = target(slot)
|
||||
val previous = playing[slot]
|
||||
if (target !== previous) {
|
||||
previous?.cancel()
|
||||
sent.remove(slot)
|
||||
if (target == null) playing.remove(slot) else playing[slot] = target
|
||||
target?.let { Log.i(TAG, "rumble: slot $slot playing on ${it.name}") }
|
||||
val target = target()
|
||||
if (target !== playing) {
|
||||
playing?.cancel()
|
||||
playing = target
|
||||
target?.let { Log.i(TAG, "rumble: playing on ${it.name}") }
|
||||
}
|
||||
if (target == null) return
|
||||
if ((strong == 0 && weak == 0) || ms == 0) { target.cancel(); sent.remove(slot); return }
|
||||
// Games re-send their effect every frame; restarting the motor that often floods the haptics
|
||||
// driver. ponytail: a change inside MIN_GAP_MS is dropped, the game's next packet brings it.
|
||||
val now = SystemClock.uptimeMillis()
|
||||
val last = sent[slot]
|
||||
if (last != null && (now - last.at < MIN_GAP_MS || (last.strong == strong && last.weak == weak && now < last.until))) return
|
||||
val duration = ms.toLong().coerceIn(1L, 5000L)
|
||||
if ((strong == 0 && weak == 0) || ms == 0) { target.cancel(); return }
|
||||
try {
|
||||
target.play(strong, weak, duration)
|
||||
sent[slot] = Sent(strong, weak, now, now + duration)
|
||||
target.play(strong, weak, ms.toLong().coerceIn(1L, 5000L))
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "rumble: ${target.name}: $e")
|
||||
}
|
||||
}
|
||||
|
||||
/** The motors of the controller playing in this slot when it has any, else the phone's. */
|
||||
private fun target(slot: Int): Motors? {
|
||||
val id = PadBridge.controllerForSlot(slot)
|
||||
if (id == PadBridge.NO_CONTROLLER) return phone
|
||||
if (!controllers.containsKey(id)) controllers[id] = try { controllerMotors(id) } catch (e: Exception) { null }
|
||||
return controllers[id] ?: phone
|
||||
/** The active controller's motors when it has any, else the phone's. */
|
||||
private fun target(): Motors? {
|
||||
val id = PadBridge.activeControllerId()
|
||||
if (id != controllerId) {
|
||||
controllerId = id
|
||||
controller = if (id == PadBridge.NO_CONTROLLER) null else try { controllerMotors(id) } catch (e: Exception) { null }
|
||||
}
|
||||
return controller ?: phone
|
||||
}
|
||||
|
||||
private fun cancelAll() {
|
||||
playing.values.toSet().forEach { it.cancel() }
|
||||
playing.clear()
|
||||
sent.clear()
|
||||
}
|
||||
|
||||
private data class Sent(val strong: Int, val weak: Int, val at: Long, val until: Long)
|
||||
|
||||
/** One place an effect can play. Strengths are the evdev 0..65535 magnitudes. */
|
||||
internal interface Motors {
|
||||
val name: String
|
||||
@@ -174,8 +158,6 @@ class RumbleComponent : SessionPart() {
|
||||
private const val TAG = "SessionService"
|
||||
/** Must match the fake evdev layer (fakeinput_steam.cpp). */
|
||||
const val NAME = "droiddeck-rumble"
|
||||
/** The shortest time between two effects started on one slot's motors. */
|
||||
const val MIN_GAP_MS = 50L
|
||||
|
||||
private fun oneShot(vibrator: Vibrator, strength: Int, ms: Long): VibrationEffect {
|
||||
val amplitude = if (vibrator.hasAmplitudeControl()) (strength * 255L / 65535L).toInt().coerceIn(1, 255)
|
||||
|
||||
@@ -31,20 +31,16 @@ object SessionArtifacts {
|
||||
/** Written last; a folder without it did not get its ending. */
|
||||
const val COMPLETE_MARKER = ".complete"
|
||||
|
||||
/**
|
||||
* Every file in the folder has been through the redactor under its current rules. Named after
|
||||
* [LogRedactor.RULES_VERSION], so a folder scrubbed under older rules is scrubbed again.
|
||||
*/
|
||||
private val SCRUBBED_MARKER = ".scrubbed-r${LogRedactor.RULES_VERSION}"
|
||||
private val OLD_SCRUBBED_MARKER = Regex("""\.scrubbed-(r?\d+)""")
|
||||
/** Every file in the folder has been through the redactor, own addresses and accounts included (-2: accounts added). */
|
||||
private const val SCRUBBED_MARKER = ".scrubbed-2"
|
||||
|
||||
/**
|
||||
* Written by a session's own ending, and only when every file in the folder, subfolders
|
||||
* included (whatever the session script copied into steam/ and droiddeck-esync/ as well as what
|
||||
* the app wrote), went through the redactor without a failure. It lists each of those files
|
||||
* with its size and modification time, under the redactor's rules version ([scrubbedFiles]).
|
||||
* A share no longer relies on it: every text file goes through the share pass on the way into
|
||||
* the zip regardless.
|
||||
* with its size and modification time, under the redactor's rules version: a share copies a
|
||||
* file as it is only when all three still match ([scrubbedFiles]), and scrubs anything else -
|
||||
* a file added or changed since, a hidden one, or every file of a folder without it.
|
||||
*/
|
||||
const val SCRUBBED_TREE_MARKER = ".scrubbed-tree"
|
||||
|
||||
@@ -71,48 +67,24 @@ object SessionArtifacts {
|
||||
}
|
||||
|
||||
/**
|
||||
* Every session folder not yet scrubbed under the redactor's current rules - written before
|
||||
* every file was scrubbed, before the device's own addresses or the Steam account were, or
|
||||
* moved in from Download/ ([LogMigration] strips their markers first, [unmarkMoved]): the
|
||||
* whole folder, steam/ and every other subfolder included, through the redactor, and the
|
||||
* current marker written in place of the old ones. Runs at app start with [finishAbandoned].
|
||||
*
|
||||
* A folder holds some 30 MB of Steam logs, about half a minute's work, so folders go through
|
||||
* a few at a time; one the process does not get to keeps its old marker and is taken up at
|
||||
* the next start.
|
||||
* Once, for session folders written before every file was scrubbed and before the device's own
|
||||
* addresses were (network.txt listed them; the client's IPv6 check logs "external address"
|
||||
* into steam/connection_log.txt): the whole folder, steam/ included, through the redactor.
|
||||
* A marker records it. Runs at app start with [finishAbandoned].
|
||||
*/
|
||||
@Synchronized
|
||||
fun scrubOlder(context: Context) {
|
||||
val current = SessionPaths.current()
|
||||
val dirs = LinuxRuntime.logDir(context).listFiles { f ->
|
||||
val dirs = LinuxRuntime.debugLogDir().listFiles { f ->
|
||||
SessionPaths.isSessionFolder(f) && f != current && !File(f, SCRUBBED_MARKER).exists()
|
||||
}?.toList() ?: return
|
||||
} ?: return
|
||||
if (dirs.isEmpty()) return
|
||||
LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context))
|
||||
val workers = (Runtime.getRuntime().availableProcessors() / 2).coerceIn(1, 4)
|
||||
val pool = java.util.concurrent.Executors.newFixedThreadPool(workers) { r ->
|
||||
Thread({ android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_BACKGROUND); r.run() }, "scrub-logs")
|
||||
}
|
||||
val done = try {
|
||||
pool.invokeAll(dirs.map { dir -> java.util.concurrent.Callable { scrubAndMark(dir) } })
|
||||
.count { f -> try { f.get() } catch (e: Exception) { false } }
|
||||
} finally {
|
||||
pool.shutdown()
|
||||
}
|
||||
val failed = dirs.size - done
|
||||
Log.i(TAG, "logs: scrubbed $done folders under r${LogRedactor.RULES_VERSION}" +
|
||||
if (failed > 0) ", $failed not fully (again at the next start)" else "")
|
||||
}
|
||||
|
||||
/**
|
||||
* Folders [LogMigration] just moved in lose every scrub marker an earlier build left, so
|
||||
* [scrubOlder], which runs right after it, puts them through the current rules whatever those
|
||||
* markers claimed.
|
||||
*/
|
||||
fun unmarkMoved(dirs: List<File>) {
|
||||
dirs.filter { SessionPaths.isSessionFolder(it) }.forEach { dir ->
|
||||
dir.listFiles { f -> OLD_SCRUBBED_MARKER.matches(f.name) || f.name == SCRUBBED_TREE_MARKER }?.forEach { it.delete() }
|
||||
dirs.forEach { dir ->
|
||||
Record(dir).let { r -> scrubFolder(dir, r); File(dir, "steam").takeIf { it.isDirectory }?.let { scrubFolder(it, r) } }
|
||||
try { File(dir, SCRUBBED_MARKER).writeText("scrubbed ${now()}\n") } catch (e: Exception) {}
|
||||
}
|
||||
Log.i(TAG, "scrubbed ${dirs.size} older session folder(s)")
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -171,17 +143,15 @@ object SessionArtifacts {
|
||||
* Records [record]'s folder as scrubbed - every file went through without a failure - or
|
||||
* leaves it unmarked, so the next share and the pass over older folders scrub it again.
|
||||
*/
|
||||
private fun markScrubbed(record: Record): Boolean {
|
||||
private fun markScrubbed(record: Record) {
|
||||
val dir = record.dir
|
||||
val tree = File(dir, SCRUBBED_TREE_MARKER)
|
||||
// A marker from older rules vouches for nothing now, whether or not this pass succeeded.
|
||||
dir.listFiles { f -> OLD_SCRUBBED_MARKER.matches(f.name) }?.forEach { it.delete() }
|
||||
if (record.failed) {
|
||||
tree.delete()
|
||||
Log.w(TAG, "$dir is not fully scrubbed; it is scrubbed again when shared")
|
||||
return false
|
||||
return
|
||||
}
|
||||
return try {
|
||||
try {
|
||||
File(dir, SCRUBBED_MARKER).writeText("scrubbed ${now()}\n")
|
||||
val lines = StringBuilder("rules ${LogRedactor.RULES_VERSION}\n")
|
||||
record.files.forEach { (f, s) ->
|
||||
@@ -189,11 +159,8 @@ object SessionArtifacts {
|
||||
.append(f.relativeTo(dir).path).append('\n')
|
||||
}
|
||||
tree.writeText(lines.toString())
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
tree.delete()
|
||||
File(dir, SCRUBBED_MARKER).delete()
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,8 +181,9 @@ object SessionArtifacts {
|
||||
}.toSet()
|
||||
}
|
||||
|
||||
/** The end-of-session scrub of [dir] on its own (steam/ already in place); also the pass over older and moved folders. */
|
||||
internal fun scrubAndMark(dir: File): Boolean = markScrubbed(scrubTree(Record(dir)))
|
||||
/** The end-of-session scrub of [dir] on its own (steam/ already in place), for tests. */
|
||||
@androidx.annotation.VisibleForTesting
|
||||
internal fun scrubAndMark(dir: File) = markScrubbed(scrubTree(Record(dir)))
|
||||
|
||||
/**
|
||||
* [record]'s folder and every folder under it (steam/, droiddeck-esync/): the session script
|
||||
@@ -255,7 +223,7 @@ object SessionArtifacts {
|
||||
*/
|
||||
@Synchronized
|
||||
fun finishAbandoned(context: Context) {
|
||||
val parent = LinuxRuntime.logDir(context)
|
||||
val parent = LinuxRuntime.debugLogDir()
|
||||
val abandoned = parent.listFiles { f ->
|
||||
SessionPaths.isSessionFolder(f) && !File(f, COMPLETE_MARKER).exists()
|
||||
}?.sortedWith(SessionPaths.chronological) ?: return
|
||||
@@ -314,7 +282,9 @@ object SessionArtifacts {
|
||||
val current = SessionPaths.current()
|
||||
val gone = SessionPaths.sessionFolders(context).filter { it != current }
|
||||
gone.forEach { com.droiddeck.launcher.core.FileUtils.delete(it) }
|
||||
com.droiddeck.launcher.core.FileUtils.delete(File(LinuxRuntime.logDir(context), SessionPaths.TOOLS_DIR))
|
||||
com.droiddeck.launcher.core.FileUtils.delete(File(LinuxRuntime.debugLogDir(), SessionPaths.TOOLS_DIR))
|
||||
// Before tools/, those logs sat loose beside the session folders.
|
||||
LinuxRuntime.debugLogDir().listFiles { f -> f.isFile && f.name.endsWith(".log") }?.forEach { it.delete() }
|
||||
Log.i(TAG, "cleared ${gone.size} session folder(s)")
|
||||
return gone.size
|
||||
}
|
||||
|
||||
@@ -6,11 +6,6 @@ import android.view.WindowManager
|
||||
/** The virtual display advertised to the guest, before it is fitted onto the Android panel. */
|
||||
object SessionDisplay {
|
||||
const val MATCH_SCREEN = "screen"
|
||||
/** The window's size, kept in step while the session runs: a foldable opening or closing, a
|
||||
* split screen or a freeform window resizes the guest's display instead of letterboxing it. */
|
||||
const val FOLLOW_SCREEN = "follow"
|
||||
/** Below this a side is a picture-in-picture or a sliver of split screen: not worth a resize. */
|
||||
const val FOLLOW_MIN_SIDE = 480
|
||||
|
||||
/**
|
||||
* Presets are heights, not sizes: "720p" is the panel's own shape at 720 lines, never narrower
|
||||
@@ -30,7 +25,7 @@ object SessionDisplay {
|
||||
(maxOf(panel.first, panel.second) and 1.inv()) to (minOf(panel.first, panel.second) and 1.inv())
|
||||
|
||||
fun resolveChoice(panel: Pair<Int, Int>, choice: String): Pair<Int, Int> {
|
||||
if (choice == MATCH_SCREEN || choice == FOLLOW_SCREEN) return screenSize(panel)
|
||||
if (choice == MATCH_SCREEN) return screenSize(panel)
|
||||
presetHeight(choice)?.let { return resolve(panel, it, SessionPrefs.SHAPE_AUTO) }
|
||||
// Fixed sizes (custom, or a preset saved before presets were heights) keep their exact size,
|
||||
// including legacy ones past the custom dialog's limit.
|
||||
@@ -39,39 +34,13 @@ object SessionDisplay {
|
||||
else resolve(panel, 720, SessionPrefs.SHAPE_AUTO)
|
||||
}
|
||||
|
||||
/** The height presets the panel is tall enough for, then the panel itself, with no duplicate
|
||||
* sizes, then Follow screen. */
|
||||
/** The height presets the panel is tall enough for, then the panel itself, with no duplicate sizes. */
|
||||
fun resolutionOptions(panel: Pair<Int, Int>): List<String> {
|
||||
val panelHeight = minOf(panel.first, panel.second)
|
||||
return PRESET_HEIGHTS.filter { it <= panelHeight }.map { "${it}p" }
|
||||
.filter { resolveChoice(panel, it) != screenSize(panel) } + MATCH_SCREEN + FOLLOW_SCREEN
|
||||
.filter { resolveChoice(panel, it) != screenSize(panel) } + MATCH_SCREEN
|
||||
}
|
||||
|
||||
/** A Follow screen session's display for a window of this size: its own shape, square or
|
||||
* portrait included, with even sides; null when it is too small to follow (PiP). */
|
||||
fun followSize(width: Int, height: Int): Pair<Int, Int>? =
|
||||
if (minOf(width, height) < FOLLOW_MIN_SIDE) null else (width and 1.inv()) to (height and 1.inv())
|
||||
|
||||
/** Unfolded controls: the most of the window the game may take, so a window near 16:9 itself
|
||||
* still leaves the Deck controls room. */
|
||||
const val UNFOLDED_MAX_GAME_SHARE = 0.8f
|
||||
|
||||
/** A foldable: Android 11's hinge angle sensor, which foldables report and slates do not. Unfolded
|
||||
* controls are offered only there - untested on tablets, whose 16:10 landscape leaves them
|
||||
* little room under a 16:9 game. */
|
||||
fun foldable(context: Context): Boolean =
|
||||
context.packageManager.hasSystemFeature("android.hardware.sensor.hinge_angle")
|
||||
|
||||
/** A panel at least this wide on its shortest side (dp) is a foldable's unfolded inner screen. */
|
||||
const val UNFOLDED_MIN_DP = 600
|
||||
|
||||
/** Unfolded controls: the game's height in a window of this size - 16:9 across the full width,
|
||||
* held either way, with even sides - and the Deck controls take the rest below it, laid out to
|
||||
* whatever that leaves (DeckControlsPanel's GamepadLayout). */
|
||||
fun unfoldedGameHeight(width: Int, height: Int): Int =
|
||||
minOf(width * 9 / 16, (height * UNFOLDED_MAX_GAME_SHARE).toInt()) and 1.inv()
|
||||
|
||||
|
||||
fun panelSize(context: Context): Pair<Int, Int> {
|
||||
val manager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||
@Suppress("DEPRECATION")
|
||||
|
||||
@@ -5,7 +5,6 @@ import android.os.Environment
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import com.droiddeck.launcher.runtime.DesktopCatalog
|
||||
import com.droiddeck.launcher.runtime.LegacyNames
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.runtime.ProotFastPath
|
||||
@@ -20,19 +19,6 @@ object SessionFiles {
|
||||
/** The driver sets staged under it: classic Wine 11 (Valve, GE) and the system-thread interface (Proton-CachyOS). */
|
||||
val DIRECTAUDIO_SETS = arrayOf("linux-wine11", "linux-wine11-systhread")
|
||||
|
||||
// Staged only with a complete desktop; removal matches these against the APK as well.
|
||||
internal val DESKTOP_FILES = arrayOf(
|
||||
"usr/local/bin/droiddeck-desktop" to "usr/local/bin/droiddeck-desktop",
|
||||
// Games and emulators from the menu, full screen in a gamescope of their own.
|
||||
"usr/local/bin/droiddeck-gpu" to "usr/local/bin/droiddeck-gpu",
|
||||
"usr/local/bin/droiddeck-desktop-gpu" to "usr/local/bin/droiddeck-desktop-gpu",
|
||||
"usr/local/bin/droiddeck-desktop-window-rules" to "usr/local/bin/droiddeck-desktop-window-rules",
|
||||
// KWin at the app's surface size, for Plasma's session (it starts KWin through this name).
|
||||
"usr/local/bin/kwin_wayland_wrapper" to "usr/local/bin/kwin_wayland_wrapper",
|
||||
"usr/lib/firefox/defaults/pref/droiddeck.js" to "usr/lib/firefox/defaults/pref/droiddeck.js",
|
||||
"etc/xdg/autostart/droiddeck-clipboard.desktop" to "etc/xdg/autostart/droiddeck-clipboard.desktop",
|
||||
)
|
||||
|
||||
/**
|
||||
* The libraries and scripts the session runs, refreshed from the apk at every launch.
|
||||
*
|
||||
@@ -43,8 +29,6 @@ object SessionFiles {
|
||||
* rename, so a session that still has one mapped keeps the file it opened.
|
||||
*/
|
||||
fun stage(context: Context, root: File) {
|
||||
if (DesktopCatalog.desktopInstalled(root)) try { DesktopCatalog.cleanLegacy(context) }
|
||||
catch (e: Exception) { Log.w(TAG, "LXQt cleanup will retry next session", e) }
|
||||
GameEnvironmentStore.publish(context)
|
||||
WinComponents.publish(context)
|
||||
val files = arrayOf(
|
||||
@@ -57,19 +41,15 @@ object SessionFiles {
|
||||
"libblsession.so" to "usr/local/lib/libblsession.so",
|
||||
"libfakeinput.so" to "usr/local/lib/libfakeinput.so",
|
||||
"libblfastpath.so" to "usr/local/lib/libblfastpath.so",
|
||||
"libblaudit.so" to "usr/local/lib/libblaudit.so",
|
||||
"libssbs.so" to "usr/local/lib/libssbs.so",
|
||||
"usr/local/bin/droiddeck-session" to "usr/local/bin/droiddeck-session",
|
||||
"usr/local/bin/droiddeck-fonts" to "usr/local/bin/droiddeck-fonts",
|
||||
"usr/local/bin/droiddeck-agent" to "usr/local/bin/droiddeck-agent",
|
||||
"usr/local/bin/steam-compatibility" to "usr/local/bin/steam-compatibility",
|
||||
"usr/local/bin/droiddeck-clipboard" to "usr/local/bin/droiddeck-clipboard",
|
||||
"usr/local/bin/droiddeck-desktop-clipboard" to "usr/local/bin/droiddeck-desktop-clipboard",
|
||||
"usr/local/bin/droiddeck-gpu-window" to "usr/local/bin/droiddeck-gpu-window",
|
||||
"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-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-seed-redists" to "usr/local/bin/droiddeck-seed-redists",
|
||||
"usr/local/bin/droiddeck-proton-extra" to "usr/local/bin/droiddeck-proton-extra",
|
||||
@@ -94,10 +74,6 @@ object SessionFiles {
|
||||
// The user's own AppImages, extracted at import (AppImageManager).
|
||||
"usr/local/bin/droiddeck-appimage-run" to "usr/local/bin/droiddeck-appimage-run",
|
||||
"usr/local/bin/droiddeck-script-run" to "usr/local/bin/droiddeck-script-run",
|
||||
// The desktop's Run entries and its exec fallback: Windows programs through Steam's Proton,
|
||||
// x86 Linux programs through FEX.
|
||||
"usr/local/bin/droiddeck-open" to "usr/local/bin/droiddeck-open",
|
||||
"usr/local/bin/droiddeck-proton-run" to "usr/local/bin/droiddeck-proton-run",
|
||||
// The SteamOS helpers the client calls in Deck mode: the two Armada found it needs, plus
|
||||
// the three under /usr/bin, all no-ops that answer "nothing to do" (see each file).
|
||||
"usr/bin/steamos-update" to "usr/bin/steamos-update",
|
||||
@@ -114,6 +90,18 @@ object SessionFiles {
|
||||
"usr/bin/steamos-polkit-helpers/jupiter-biosupdate" to "usr/bin/steamos-polkit-helpers/jupiter-biosupdate",
|
||||
"usr/bin/steamos-polkit-helpers/jupiter-dock-updater" to "usr/bin/steamos-polkit-helpers/jupiter-dock-updater",
|
||||
)
|
||||
// The desktop's launcher and labwc defaults, only where the desktop package is installed:
|
||||
// staging them into a runtime without it would make the desktop look present when it is not.
|
||||
val desktop = arrayOf(
|
||||
"usr/local/bin/droiddeck-desktop" to "usr/local/bin/droiddeck-desktop",
|
||||
// Games and emulators from the menu, full screen in a gamescope of their own.
|
||||
"usr/local/bin/droiddeck-gpu" to "usr/local/bin/droiddeck-gpu",
|
||||
"usr/local/bin/droiddeck-desktop-gpu" to "usr/local/bin/droiddeck-desktop-gpu",
|
||||
"etc/xdg/labwc/autostart" to "etc/xdg/labwc/autostart",
|
||||
"etc/xdg/labwc/rc.xml" to "etc/xdg/labwc/rc.xml",
|
||||
"etc/xdg/lxqt/panel.conf" to "etc/xdg/lxqt/panel.conf",
|
||||
"usr/lib/firefox/defaults/pref/droiddeck.js" to "usr/lib/firefox/defaults/pref/droiddeck.js",
|
||||
)
|
||||
// The patched gamescope (tools/gamescope): the runtime's own version rebuilt with the ARM64
|
||||
// client fixes, over /usr/local/bin so it comes first in the session's PATH. Only when the
|
||||
// apk carries it - a build without the asset leaves the runtime's copy alone.
|
||||
@@ -129,21 +117,25 @@ object SessionFiles {
|
||||
"usr/local/lib/mangoapp/libtracefs.so.1",
|
||||
).map { it to it }
|
||||
// What the Windows components installer reads .msi packages with (tools/msitools).
|
||||
val msitools = listOf("msiinfo", "cabextract", "7z", "7z.so", "libmsi-1.0.so.0", "libgsf-1.so.114", "libgcab-1.0.so.0")
|
||||
val msitools = listOf("msiinfo", "cabextract", "libmsi-1.0.so.0", "libgsf-1.so.114", "libgcab-1.0.so.0")
|
||||
.map { "usr/local/lib/droiddeck-msitools/$it" } +
|
||||
listOf("NOTICE", "GPL-2", "GPL-3", "LGPL-2.1", "7zip-License", "7zip-unRarLicense").map { "usr/local/share/licenses/droiddeck-msitools/$it" }
|
||||
listOf("NOTICE", "GPL-2", "GPL-3", "LGPL-2.1").map { "usr/local/share/licenses/droiddeck-msitools/$it" }
|
||||
// The patched wlroots (tools/wlroots) the desktop loads for its vulkan / gles2 renderers.
|
||||
val wlroots = if (File(root, "usr/bin/labwc").isFile) {
|
||||
arrayOf("usr/local/lib/droiddeck-wlroots/libwlroots-0.20.so" to "usr/local/lib/droiddeck-wlroots/libwlroots-0.20.so")
|
||||
} else emptyArray()
|
||||
val fexPreloads = listOf("x86_64", "i386").flatMap { arch ->
|
||||
listOf("libblsession.so", "libfakeinput.so", "libfaultreport.so").map { "$arch/$it" to "usr/local/lib/droiddeck-fex/$arch/$it" }
|
||||
} + listOf("libthunkaudit.so", "libvulkan-thunk.so").map { "x86_64/$it" to "usr/local/lib/droiddeck-fex/x86_64/$it" }
|
||||
listOf("libblsession.so", "libfakeinput.so").map { "$arch/$it" to "usr/local/lib/droiddeck-fex/$arch/$it" }
|
||||
} + listOf("libfaultreport.so", "libthunkaudit.so", "libvulkan-thunk.so").map { "x86_64/$it" to "usr/local/lib/droiddeck-fex/x86_64/$it" }
|
||||
val optional = (arrayOf(
|
||||
"usr/local/bin/gamescope" to "usr/local/bin/gamescope",
|
||||
"usr/local/lib/droiddeck/uruntime" to "usr/local/lib/droiddeck/uruntime",
|
||||
"usr/local/share/licenses/uruntime/LICENSE" to "usr/local/share/licenses/uruntime/LICENSE",
|
||||
) + mangoapp + msitools.map { it to it } + fexPreloads).filter { (asset, _) ->
|
||||
) + wlroots + mangoapp + msitools.map { it to it } + fexPreloads).filter { (asset, _) ->
|
||||
val dir = asset.substringBeforeLast('/')
|
||||
runCatching { context.assets.list("linuxfs/$dir")?.contains(asset.substringAfterLast('/')) == true }.getOrDefault(false)
|
||||
}
|
||||
val all = (if (DesktopCatalog.desktopInstalled(root)) files + DESKTOP_FILES else files) + optional
|
||||
val all = (if (File(root, "usr/bin/labwc").isFile) files + desktop else files) + optional
|
||||
for ((asset, relative) in all) {
|
||||
val target = File(root, relative)
|
||||
val staged = File(target.parentFile, target.name + ".staged")
|
||||
@@ -373,9 +365,25 @@ object SessionFiles {
|
||||
).joinToString(newline)
|
||||
|
||||
/**
|
||||
* Where the session writes its log: app-private `files/logs`, bound into the session at the same
|
||||
* path, so the session script's own redirection always opens. A folder leaves the device only
|
||||
* through Share logs, which scrubs every file again on the way into the zip.
|
||||
* Where the session writes its log. Downloads is the point - a failed run is handed over as a
|
||||
* folder rather than dug out of app-private storage - but the session script redirects its own
|
||||
* output there with `exec`, and a redirection a non-interactive shell cannot open ends that
|
||||
* shell. So a public directory is used only once it is proven writable; otherwise the app's
|
||||
* own files directory, which is bound into the session anyway, stands in.
|
||||
*/
|
||||
fun logDirectory(context: Context): File = LinuxRuntime.logDir(context).apply { mkdirs() }
|
||||
fun logDirectory(context: Context): File {
|
||||
val public = LinuxRuntime.debugLogDir()
|
||||
if (public.isDirectory || public.mkdirs()) {
|
||||
val probe = File(public, ".writable")
|
||||
try {
|
||||
if (probe.createNewFile() || probe.isFile) {
|
||||
probe.delete()
|
||||
return public
|
||||
}
|
||||
} catch (ignored: Exception) {
|
||||
}
|
||||
}
|
||||
Log.w(TAG, "$public is not writable (storage permission?); logging to files/logs")
|
||||
return File(context.filesDir, "logs").apply { mkdirs() }
|
||||
}
|
||||
}
|
||||
@@ -18,11 +18,9 @@ import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
/**
|
||||
* Share logs: one zip, made when asked, of a session folder (the newest by default) with the last
|
||||
* seven days of the stores' log and the one-off command logs beside it, handed to Android's share
|
||||
* sheet. Logs live in app-private `files/logs`; this is how they leave the device. Every text file
|
||||
* goes through [LogRedactor.redactForShare] on the way in, so even a file written before a rule
|
||||
* existed is clean in the zip. The zip sits in `cache/share/` until the next share or app start.
|
||||
* Zips the most recent session's log folder and hands it to Android's share sheet. Works where
|
||||
* the folder could not be written to Downloads (no storage permission, or a device that refuses
|
||||
* it) and landed in the app's private files instead, which a user cannot otherwise reach.
|
||||
*/
|
||||
object SessionLogShare {
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
@@ -64,7 +62,7 @@ object SessionLogShare {
|
||||
val files = folder.walkTopDown().filter { it.isFile }
|
||||
.filterNot { it.parentFile?.name == "steam" && it.length() > STEAM_LOG_MAX_BYTES }.toList()
|
||||
if (files.isEmpty()) return null
|
||||
val out = shareDir(context).apply { deleteRecursively(); mkdirs() }
|
||||
val out = File(context.cacheDir, "shared-logs").apply { deleteRecursively(); mkdirs() }
|
||||
val zip = File(out, "DroidDeck-${folder.name}.zip")
|
||||
// Scrubbed on the way into the zip: a session shared while it runs has not had its end-of-
|
||||
// session pass yet, and the redactor changes nothing in a line that is already clean.
|
||||
@@ -73,11 +71,11 @@ object SessionLogShare {
|
||||
// since, goes in as it is; anything else (added or changed since, hidden, or any file of a
|
||||
// session still running) is scrubbed again. Scrubbing tens of MB of the client's logs on
|
||||
// every share kept the share sheet from appearing for ten seconds or more.
|
||||
val scrubbed = SessionArtifacts.scrubbedFiles(folder)
|
||||
val live = liveSteamLogs(context, folder)
|
||||
val 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)
|
||||
// The stores' own log (last 7 days, app-private, already redacted), under stores/.
|
||||
val storeLogs = com.droiddeck.launcher.stores.StoreLogFiles.files(context)
|
||||
val total = (files + live + storeLogs).sumOf { it.length() }.coerceAtLeast(1L)
|
||||
var done = 0L
|
||||
var shown = -1
|
||||
fun advance(f: File) {
|
||||
@@ -86,20 +84,18 @@ object SessionLogShare {
|
||||
if (percent != shown) { shown = percent; onProgress?.invoke(percent / 100f) }
|
||||
}
|
||||
ZipOutputStream(zip.outputStream().buffered()).use { z ->
|
||||
files.forEach { f -> addEntry(z, folder.name + "/" + f.relativeTo(folder).path, f); advance(f) }
|
||||
live.forEach { f -> addEntry(z, folder.name + "/steam/" + f.name, f); advance(f) }
|
||||
stores.forEach { f -> addEntry(z, "$STORES_DIR/" + f.name, f); advance(f) }
|
||||
tools.forEach { f -> addEntry(z, SessionPaths.TOOLS_DIR + "/" + f.name, f); advance(f) }
|
||||
files.forEach { f -> addEntry(z, folder.name + "/" + f.relativeTo(folder).path, f, f.relativeTo(folder).path in scrubbed); advance(f) }
|
||||
live.forEach { f -> addEntry(z, folder.name + "/steam/" + f.name, f, false); advance(f) }
|
||||
storeLogs.forEach { f -> addEntry(z, folder.name + "/stores/" + f.name, f, false); advance(f) }
|
||||
}
|
||||
return zip
|
||||
}
|
||||
|
||||
private fun addEntry(z: ZipOutputStream, name: String, f: File) {
|
||||
private fun addEntry(z: ZipOutputStream, name: String, f: File, scrubbed: Boolean) {
|
||||
z.putNextEntry(ZipEntry(name))
|
||||
// Every text file, every time: the folder's own scrub may predate a rule.
|
||||
if (LogRedactor.isText(f)) {
|
||||
if (!scrubbed && LogRedactor.isText(f)) {
|
||||
val w = z.bufferedWriter()
|
||||
LogRedactor.scrubForShare(f, w)
|
||||
LogRedactor.scrubTo(f, w)
|
||||
w.flush()
|
||||
} else {
|
||||
f.inputStream().use { it.copyTo(z) }
|
||||
@@ -123,25 +119,6 @@ object SessionLogShare {
|
||||
/** The client's content and bootstrap logs grow large over months and say nothing about a session. */
|
||||
private const val STEAM_LOG_MAX_BYTES = 8L * 1024 * 1024
|
||||
|
||||
/** The stores' engine log, one file a day, under files/logs. */
|
||||
const val STORES_DIR = "stores"
|
||||
private const val RECENT_DAYS = 7
|
||||
|
||||
private fun shareDir(context: Context): File = File(context.cacheDir, "share")
|
||||
|
||||
/** The files in [dir] changed in the last [RECENT_DAYS] days. */
|
||||
private fun recent(dir: File): List<File> {
|
||||
val since = System.currentTimeMillis() - RECENT_DAYS * 86_400_000L
|
||||
return dir.listFiles { f -> f.isFile && f.lastModified() >= since }?.sortedBy { it.name }.orEmpty()
|
||||
}
|
||||
|
||||
/** Deletes the last share's zip; at app start, so a zip never outlives the share it was made for. */
|
||||
fun clear(context: Context) {
|
||||
shareDir(context).deleteRecursively()
|
||||
// Where builds before this one put it.
|
||||
File(context.cacheDir, "shared-logs").deleteRecursively()
|
||||
}
|
||||
|
||||
fun shareIntent(context: Context, zip: File): Intent {
|
||||
val uri = FileProvider.getUriForFile(context, context.packageName + ".logs", zip)
|
||||
val send = Intent(Intent.ACTION_SEND)
|
||||
|
||||
@@ -8,20 +8,19 @@ import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* One folder per session under app-private `files/logs/`, holding everything that session recorded,
|
||||
* One folder per session under `Download/DroidDeck/`, holding everything that session recorded,
|
||||
* named for the day, that day's session number and what was run, so the one a report is about can
|
||||
* be picked out without opening any:
|
||||
*
|
||||
* ```
|
||||
* files/logs/2026-09-30-03-steam/
|
||||
* Download/DroidDeck/2026-09-30-03-steam/
|
||||
* device.txt what this device is, and every setting the session ran with
|
||||
* session.log the guest session: proot, gamescope, the client's stdout
|
||||
* wayland.log the app's compositor
|
||||
* steam.log the Steam client's own log, scrubbed (Steam mode)
|
||||
* steam/ the rest of the client's logs, scrubbed (Steam mode)
|
||||
* desktop.log KWin, Plasma and the programs on it (desktop mode)
|
||||
* files/logs/tools/ one-off commands: Flatpak installs, AppImage imports
|
||||
* files/logs/stores/ the stores' engine log, one file a day
|
||||
* desktop.log labwc, the panel and the programs on it (desktop mode)
|
||||
* Download/DroidDeck/tools/ one-off commands: Flatpak installs, AppImage imports
|
||||
* ```
|
||||
*
|
||||
* Folders from before this naming (`session-20260930-180642`) are still recognised, shared and
|
||||
@@ -138,7 +137,8 @@ object SessionPaths {
|
||||
|
||||
/** Every session folder in the places logs are written, oldest first. */
|
||||
fun sessionFolders(context: Context): List<File> =
|
||||
(com.droiddeck.launcher.runtime.LinuxRuntime.logDir(context).listFiles { f -> isSessionFolder(f) }?.toList() ?: emptyList())
|
||||
listOf(com.droiddeck.launcher.runtime.LinuxRuntime.debugLogDir(), File(context.filesDir, "logs"))
|
||||
.flatMap { parent -> parent.listFiles { f -> isSessionFolder(f) }?.toList() ?: emptyList() }
|
||||
.sortedWith(chronological)
|
||||
|
||||
internal fun slug(text: String): String =
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.ActivityInfo
|
||||
import androidx.annotation.StringRes
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.core.TextureFiltering
|
||||
@@ -19,15 +18,10 @@ object SessionPrefs {
|
||||
|
||||
const val CONTROLLER_DECK = "deck"
|
||||
const val CONTROLLER_XBOX360 = "xbox360"
|
||||
const val CONTROLLER_STEAM = "steamcontroller"
|
||||
const val CONTROLLER_DS4 = "dualshock4"
|
||||
const val CONTROLLER_DUALSENSE = "dualsense"
|
||||
const val OSC_AUTO = "auto"
|
||||
const val OSC_ALWAYS = "always"
|
||||
const val OSC_STEAM_QAM = "steam-qam"
|
||||
const val OSC_NEVER = "never"
|
||||
/** Steam's touch controller (SteamTouchControls) in place of the app's pad; Steam sessions only. */
|
||||
const val OSC_STEAM_TOUCH = "steam-touch"
|
||||
|
||||
/** What Back does in a Steam session, first press then second: the labels of the two orders. */
|
||||
val BACK_MENU_THEN_QAM = R.string.back_menu_then_qam
|
||||
@@ -39,22 +33,6 @@ object SessionPrefs {
|
||||
|
||||
private fun prefs(context: Context) = context.getSharedPreferences("session", Context.MODE_PRIVATE)
|
||||
|
||||
val orientationOptions = listOf(
|
||||
ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE to R.string.setup_orientation_auto,
|
||||
ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE to R.string.setup_orientation_landscape,
|
||||
ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE to R.string.setup_orientation_reverse,
|
||||
)
|
||||
|
||||
fun orientation(context: Context): Int =
|
||||
prefs(context).getInt("orientation", ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE)
|
||||
.takeIf { value -> orientationOptions.any { it.first == value } }
|
||||
?: ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
||||
|
||||
fun setOrientation(context: Context, orientation: Int) {
|
||||
require(orientationOptions.any { it.first == orientation })
|
||||
prefs(context).edit().putInt("orientation", orientation).apply()
|
||||
}
|
||||
|
||||
/** Whether the launcher hides Android's status and navigation bars. */
|
||||
fun launcherFullscreen(context: Context): Boolean = prefs(context).getBoolean("launcherFullscreen", true)
|
||||
|
||||
@@ -134,9 +112,9 @@ object SessionPrefs {
|
||||
|
||||
private fun hudKey(context: Context) = if (mangoappSession(context)) "hudDeck" else "hud"
|
||||
|
||||
/** A Steam session with Deck mode's performance overlay (mangoapp). */
|
||||
/** A Steam session that runs Deck mode with its performance overlay (mangoapp). */
|
||||
private fun mangoappSession(context: Context) =
|
||||
SessionState.mode == SessionService.MODE_STEAM && mangoapp(context)
|
||||
SessionState.mode == SessionService.MODE_STEAM && steamDeckMode(context) && mangoapp(context)
|
||||
|
||||
/** When enabled, a single Back opens Steam QAM and a double Back opens the session menu. */
|
||||
fun backActionsInverted(context: Context): Boolean = prefs(context).getBoolean("backActionsInverted", false)
|
||||
@@ -452,21 +430,27 @@ object SessionPrefs {
|
||||
|
||||
/**
|
||||
* What the pad is to the Steam client: [CONTROLLER_DECK], a Steam Deck controller (Quick Access
|
||||
* button, gyro, Steam Input's full treatment - SteamDeckPad), [CONTROLLER_STEAM], a 2025 Steam
|
||||
* Controller (the same, plus grip sense and its own haptics), [CONTROLLER_DS4] or
|
||||
* [CONTROLLER_DUALSENSE], a PlayStation pad with its touchpad fed by the trackpads, or
|
||||
* [CONTROLLER_XBOX360], the plain Xbox 360 pad of earlier versions (QAM by the Guide+A chord).
|
||||
* button, gyro, Steam Input's full treatment - SteamDeckPad), or [CONTROLLER_XBOX360], the plain
|
||||
* Xbox 360 pad of earlier versions (QAM by the Guide+A chord).
|
||||
*/
|
||||
fun steamController(context: Context): String =
|
||||
prefs(context).getString("steamController", CONTROLLER_DECK) ?: CONTROLLER_DECK
|
||||
fun setSteamController(context: Context, id: String) { prefs(context).edit().putString("steamController", id).apply() }
|
||||
/** [steamController]'s choice as libfakeinput's pad type (FakeInputWriter.PAD_TYPE_*). */
|
||||
fun padType(id: String): Int = when (id) {
|
||||
CONTROLLER_STEAM -> com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_STEAM
|
||||
CONTROLLER_XBOX360 -> com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_XBOX
|
||||
CONTROLLER_DS4 -> com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_DS4
|
||||
CONTROLLER_DUALSENSE -> com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_DUALSENSE
|
||||
else -> com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_DECK
|
||||
|
||||
/** Runs the SteamOS gamepad client with its Quick Access performance controls. On by default (settleDeckModeDefault). */
|
||||
fun steamDeckMode(context: Context): Boolean = prefs(context).getBoolean("steamDeckMode", true)
|
||||
fun setSteamDeckMode(context: Context, on: Boolean) { prefs(context).edit().putBoolean("steamDeckMode", on).apply() }
|
||||
|
||||
/**
|
||||
* Deck mode is the default: every install, new or from before, is moved to it once. An install
|
||||
* from before 0.3.0 had off written down for it whether or not anyone chose it, so the move
|
||||
* can't tell a choice from that default and moves everyone; turning it off afterwards sticks.
|
||||
* Run once at process start, before anything reads or writes these prefs.
|
||||
*/
|
||||
fun settleDeckModeDefault(context: Context) {
|
||||
val p = prefs(context)
|
||||
if (p.getBoolean("deckModeMoved", false)) return
|
||||
p.edit().putBoolean("steamDeckMode", true).putBoolean("deckModeMoved", true).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -509,7 +493,7 @@ object SessionPrefs {
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a session keeps its folder under files/logs. Off, the same logs are kept in
|
||||
* Whether a session writes its folder under Download/DroidDeck. Off, the same logs are kept in
|
||||
* the app's cache for the session's lifetime (the scripts need somewhere to write) and thrown
|
||||
* away at the end, so nothing accumulates in Downloads.
|
||||
*/
|
||||
@@ -549,8 +533,7 @@ object SessionPrefs {
|
||||
fun resolutionChoice(context: Context, mode: String, panel: Pair<Int, Int>): String {
|
||||
val saved = prefs(context)
|
||||
saved.getString("displayResolution.$mode", null)?.let { value ->
|
||||
if (value == SessionDisplay.MATCH_SCREEN || value == SessionDisplay.FOLLOW_SCREEN ||
|
||||
SessionDisplay.presetHeight(value) != null) return value
|
||||
if (value == SessionDisplay.MATCH_SCREEN || SessionDisplay.presetHeight(value) != null) return value
|
||||
parseResolution(value)?.let { return "${it.first}x${it.second}" }
|
||||
}
|
||||
if (!resolutionChosen(context, mode) && !saved.contains("shape")) return SessionDisplay.DEFAULT_RESOLUTION
|
||||
@@ -560,8 +543,7 @@ object SessionPrefs {
|
||||
}
|
||||
|
||||
fun setResolutionChoice(context: Context, mode: String, choice: String) {
|
||||
val value = if (choice == SessionDisplay.MATCH_SCREEN || choice == SessionDisplay.FOLLOW_SCREEN ||
|
||||
SessionDisplay.presetHeight(choice) != null) choice else {
|
||||
val value = if (choice == SessionDisplay.MATCH_SCREEN || SessionDisplay.presetHeight(choice) != null) choice else {
|
||||
val size = requireNotNull(parseResolution(choice)) { "Invalid resolution" }
|
||||
"${size.first}x${size.second}"
|
||||
}
|
||||
@@ -600,6 +582,24 @@ object SessionPrefs {
|
||||
.onFailure { android.util.Log.e("GameEnvironment", "Could not update game environment", it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The Steam client branch used for the first download and forced on the command line:
|
||||
* "steamdeck_publicbeta" by default, or "publicbeta" when chosen with Deck mode off.
|
||||
* Deck mode always takes the Deck branch, whatever was chosen:
|
||||
* with -steamos3 the client picks its own branch as SteamOS does, and on publicbeta it settled
|
||||
* on steamdeck_stable - an older client it then offered as a "Software Update" in every session,
|
||||
* which applying turns into the exit-42 restart loop (seen on device 2026-09-30). Earlier, Deck
|
||||
* mode on publicbeta also reinstalled the same client at every start (2026-09-23). On
|
||||
* steamdeck_publicbeta the client finds no update. The choice applies with Deck mode off.
|
||||
*/
|
||||
fun steamChannel(context: Context): String =
|
||||
if (steamDeckMode(context)) "steamdeck_publicbeta"
|
||||
else prefs(context).getString("steamChannel", null) ?: "steamdeck_publicbeta"
|
||||
|
||||
fun setSteamChannel(context: Context, id: String) {
|
||||
prefs(context).edit().putString("steamChannel", id).apply()
|
||||
}
|
||||
|
||||
/** Whether opening DroidDeck starts a Steam session instead of showing the front end. */
|
||||
fun runSteamAtStartup(context: Context): Boolean = prefs(context).getBoolean("runSteamAtStartup", false)
|
||||
|
||||
@@ -608,9 +608,8 @@ object SessionPrefs {
|
||||
}
|
||||
|
||||
/**
|
||||
* The Games folders earlier builds let the user add (one game per subfolder); read only by
|
||||
* GamesFolderMigration, which turns them into entries and clears them. The single folder an
|
||||
* even earlier build kept is carried in.
|
||||
* The folders of the user's own Windows games (one game per subfolder), any number of them
|
||||
* from anywhere on the device. The single folder an earlier build kept is carried in.
|
||||
*/
|
||||
fun addedGamesDirs(context: Context): List<String> {
|
||||
val p = prefs(context)
|
||||
@@ -624,80 +623,6 @@ object SessionPrefs {
|
||||
prefs(context).edit().putString("addedGamesDirs", dirs.distinct().joinToString("\n")).remove("addedGamesDir").apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* The Games folders earlier builds had (host path, guest path), still bound into the session so
|
||||
* their games keep their paths; written once by GamesFolderMigration.
|
||||
*/
|
||||
fun gameFolderBinds(context: Context): List<Pair<String, String>> =
|
||||
(prefs(context).getString("gameFolderBinds", "") ?: "").split('\n').mapNotNull { line ->
|
||||
line.split('\t').takeIf { it.size == 2 && it[0].isNotEmpty() && it[1].isNotEmpty() }?.let { it[0] to it[1] }
|
||||
}
|
||||
|
||||
fun setGameFolderBinds(context: Context, binds: List<Pair<String, String>>) {
|
||||
prefs(context).edit().putString("gameFolderBinds", binds.distinctBy { it.first }.joinToString("\n") { "${it.first}\t${it.second}" }).apply()
|
||||
}
|
||||
|
||||
/** The Games folders were turned into entries (GamesFolderMigration); a record, not a gate. */
|
||||
fun gamesFoldersMigrated(context: Context): Boolean = prefs(context).getBoolean("gamesFoldersMigrated", false)
|
||||
|
||||
fun setGamesFoldersMigrated(context: Context) {
|
||||
prefs(context).edit().putBoolean("gamesFoldersMigrated", true).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Game folders (by path) the user removed from the added games: a scan skips them, so they
|
||||
* stay off the Games tab and out of Steam until restored. Nothing on disk is touched.
|
||||
*/
|
||||
fun removedAddedGames(context: Context): List<String> =
|
||||
(prefs(context).getString("removedAddedGames", "") ?: "").split('\n').filter { it.isNotEmpty() }
|
||||
|
||||
fun setAddedGameRemoved(context: Context, folderPath: String, removed: Boolean) {
|
||||
synchronized(addedGameLock) {
|
||||
val now = removedAddedGames(context).filter { it != folderPath } + listOfNotNull(folderPath.takeIf { removed })
|
||||
prefs(context).edit().putString("removedAddedGames", now.joinToString("\n")).apply()
|
||||
}
|
||||
}
|
||||
|
||||
/** An added game's name as the user set it (in the app, or in Steam's Properties); "" = automatic. */
|
||||
fun addedGameName(context: Context, folderPath: String): String = prefs(context).getString("addedName:$folderPath", "") ?: ""
|
||||
|
||||
fun setAddedGameName(context: Context, folderPath: String, name: String) {
|
||||
prefs(context).edit().putString("addedName:$folderPath", name.trim()).apply()
|
||||
}
|
||||
|
||||
/** An added game's Start in, as the session sees it; "" = the exe's own folder. */
|
||||
fun addedGameStartIn(context: Context, folderPath: String): String = prefs(context).getString("addedStartIn:$folderPath", "") ?: ""
|
||||
|
||||
fun setAddedGameStartIn(context: Context, folderPath: String, guestDir: String) {
|
||||
prefs(context).edit().putString("addedStartIn:$folderPath", guestDir.trim()).apply()
|
||||
}
|
||||
|
||||
/** An added game's launch options, as Steam takes them ("-dx11", "VAR=1 %command%"). */
|
||||
fun addedGameLaunch(context: Context, folderPath: String): String = prefs(context).getString("addedLaunch:$folderPath", "") ?: ""
|
||||
|
||||
fun setAddedGameLaunch(context: Context, folderPath: String, options: String) {
|
||||
prefs(context).edit().putString("addedLaunch:$folderPath", options.trim()).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* The last value of [field] (AppName, StartDir, LaunchOptions) taken over from Steam's own
|
||||
* Properties for this game, so the same Steam edit is not taken again over a later app edit.
|
||||
*/
|
||||
fun addedGameAdopted(context: Context, folderPath: String, field: String): String? =
|
||||
prefs(context).getString("addedAdopted:$field:$folderPath", null)
|
||||
|
||||
fun setAddedGameAdopted(context: Context, folderPath: String, field: String, value: String) {
|
||||
prefs(context).edit().putString("addedAdopted:$field:$folderPath", value).apply()
|
||||
}
|
||||
|
||||
/** Where an added game's art for [slot] comes from when the user chose it ("folder", "steam", "sgdb", "file"); "" = automatic. */
|
||||
fun addedGameArtSource(context: Context, folderPath: String, slot: String): String =
|
||||
prefs(context).getString("addedArt:$slot:$folderPath", "") ?: ""
|
||||
|
||||
fun setAddedGameArtSource(context: Context, folderPath: String, slot: String, source: String) {
|
||||
prefs(context).edit().putString("addedArt:$slot:$folderPath", source).apply()
|
||||
}
|
||||
|
||||
/** Whether added games without art of their own get Steam's store art fetched for them. */
|
||||
fun addedGamesArt(context: Context): Boolean = prefs(context).getBoolean("addedGamesArt", true)
|
||||
|
||||
@@ -723,10 +648,6 @@ object SessionPrefs {
|
||||
unchanged
|
||||
}
|
||||
|
||||
/** The shortcut appid stored for the folder, or null before its first scan. */
|
||||
fun addedGameStoredAppId(context: Context, folderPath: String): Long? =
|
||||
prefs(context).getLong("addedAppId:$folderPath", 0L).takeIf { it != 0L }
|
||||
|
||||
fun addedGameAppId(context: Context, folderPath: String, first: Long): Long = synchronized(addedGameLock) {
|
||||
val p = prefs(context)
|
||||
p.getLong("addedAppId:$folderPath", 0L).takeIf { it != 0L }
|
||||
@@ -757,6 +678,21 @@ object SessionPrefs {
|
||||
return Pair(w and 1.inv(), h and 1.inv())
|
||||
}
|
||||
|
||||
/**
|
||||
* What the desktop shell composites with: vulkan (the default) or gles2 on the GPU, through the
|
||||
* app's patched wlroots (tools/wlroots) - the Adreno stand-in is not a DRM device and stock
|
||||
* wlroots cannot allocate on it - or pixman in software. A GPU renderer a device cannot start
|
||||
* falls back to pixman by itself (droiddeck-desktop). `Download/droiddeck-wlr-renderer` still
|
||||
* overrides it.
|
||||
*/
|
||||
fun desktopRenderer(context: Context): String = prefs(context).getString("desktopRenderer", "vulkan") ?: "vulkan"
|
||||
|
||||
fun setDesktopRenderer(context: Context, renderer: String) {
|
||||
prefs(context).edit().putString("desktopRenderer", renderer).apply()
|
||||
// A choice made again is a retry: forget that a renderer failed to start here before.
|
||||
java.io.File(com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(context), "root/.droiddeck-renderer-failed").delete()
|
||||
}
|
||||
|
||||
/**
|
||||
* HDR10 output for MODE_STEAM / MODE_DESKTOP. Off by default. Honoured only when the panel
|
||||
* lists HDR10 (HdrSupport), and decided when the compositor starts, which is once per app
|
||||
@@ -900,26 +836,6 @@ object SessionPrefs {
|
||||
prefs(context).edit().putBoolean("pipAutoEnter", enabled).apply()
|
||||
}
|
||||
|
||||
/** A foldable's unfolded panel shows the game above the Deck controls (SessionDisplay.unfoldedGameHeight). */
|
||||
fun unfoldedControls(context: Context): Boolean = prefs(context).getBoolean("unfoldedControls", false)
|
||||
|
||||
fun setUnfoldedControls(context: Context, enabled: Boolean) {
|
||||
prefs(context).edit().putBoolean("unfoldedControls", enabled).apply()
|
||||
}
|
||||
|
||||
const val SURFACE_STICK = "stick"
|
||||
const val SURFACE_TRACKPAD = "trackpad"
|
||||
|
||||
/** The Deck controls' gamepad tab: what each side is - an analog stick or a Steam trackpad.
|
||||
* Left a stick and right a trackpad until changed, as a Deck is mostly played. */
|
||||
fun gamepadSurface(context: Context, right: Boolean): String =
|
||||
prefs(context).getString(if (right) "gamepadRight" else "gamepadLeft", null)
|
||||
?: if (right) SURFACE_TRACKPAD else SURFACE_STICK
|
||||
|
||||
fun setGamepadSurface(context: Context, right: Boolean, surface: String) {
|
||||
prefs(context).edit().putString(if (right) "gamepadRight" else "gamepadLeft", surface).apply()
|
||||
}
|
||||
|
||||
fun suspendPolicy(context: Context, mode: String): String =
|
||||
prefs(context).getString("suspendPolicy.${prefMode(mode)}", SUSPEND_MANUAL)
|
||||
?.let { if (it == LEGACY_SUSPEND_DOWNLOADS) SUSPEND_AUTO else it }
|
||||
|
||||
@@ -29,7 +29,6 @@ import com.droiddeck.launcher.audio.PulseAudioComponent
|
||||
import com.droiddeck.launcher.core.CpuCores
|
||||
import com.droiddeck.launcher.core.DeviceReport
|
||||
import com.droiddeck.launcher.core.HostEnvironment
|
||||
import com.droiddeck.launcher.core.DeviceSupport
|
||||
import com.droiddeck.launcher.core.SessionLogCapture
|
||||
import com.droiddeck.launcher.core.NetworkReport
|
||||
import com.droiddeck.launcher.core.SessionPart
|
||||
@@ -37,12 +36,9 @@ import com.droiddeck.launcher.core.FileUtils
|
||||
import com.droiddeck.launcher.core.HostProcess
|
||||
import com.droiddeck.launcher.frontend.GameLaunchLink
|
||||
import com.droiddeck.launcher.input.FakeInputWriter
|
||||
import com.droiddeck.launcher.input.SteamControllerBle
|
||||
import com.droiddeck.launcher.runtime.BwrapSpawner
|
||||
import com.droiddeck.launcher.runtime.LinuxNetworkLinkComponent
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.runtime.ProotFastPath
|
||||
import com.droiddeck.launcher.runtime.UserApps
|
||||
import com.droiddeck.launcher.wayland.WaylandCompositor
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
@@ -209,10 +205,6 @@ class SessionService : Service() {
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
}
|
||||
if (com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.isMaintaining()) {
|
||||
stopSelf(startId)
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
startForeground(NOTIFICATION_ID, buildNotification())
|
||||
if (SessionState.running) return START_NOT_STICKY
|
||||
if (SessionState.stopRequested) {
|
||||
@@ -362,9 +354,6 @@ class SessionService : Service() {
|
||||
|
||||
guest.add("BL_WIDTH=" + size.first)
|
||||
guest.add("BL_HEIGHT=" + size.second)
|
||||
// Follow screen: the output is resized under gamescope when a foldable opens or closes,
|
||||
// and the session script asks gamescope to resize Steam's X screen along with it.
|
||||
if (SessionState.followScreen) guest.add("BL_FOLLOW_OUTPUT=1")
|
||||
if (SessionState.hdr) {
|
||||
// The activity opened the compositor's HDR gate: gamescope offers HDR to its clients
|
||||
// and DXVK takes the HDR10 swapchain when a game asks for one.
|
||||
@@ -382,6 +371,16 @@ class SessionService : Service() {
|
||||
SessionState.deckPad = false
|
||||
deckBinds = emptyList()
|
||||
if (controllersOn) addControllerEnvironment(guest, fakeInputDir, sessionDir)
|
||||
// The desktop is wlroots (labwc). Stock wlroots allocates through gbm on a real DRM render
|
||||
// node, and ours is a KGSL stand-in - labwc died at "unable to create allocator" - so the
|
||||
// default is pixman (software, shm), where a Vulkan program cannot draw at all; those run
|
||||
// through droiddeck-gpu instead. vulkan / gles2 use the app's patched wlroots and fall back
|
||||
// to pixman by themselves. droiddeck-wlr-renderer in Downloads overrides the choice.
|
||||
if (SessionState.mode == MODE_DESKTOP) {
|
||||
val override = File(Environment.getExternalStorageDirectory(), "Download/droiddeck-wlr-renderer")
|
||||
.takeIf { it.isFile }?.let { FileUtils.readString(it)?.trim() }
|
||||
guest.add("BL_WLR_RENDERER=" + (override?.takeIf { it.isNotEmpty() } ?: SessionPrefs.desktopRenderer(this)))
|
||||
}
|
||||
// The user's own games, for the runtime's shortcuts writer to put in the client's library
|
||||
// before the client starts (see frontend/AddedGames and droiddeck-steam-shortcuts).
|
||||
if (steamHere) {
|
||||
@@ -397,6 +396,9 @@ class SessionService : Service() {
|
||||
// The second library's name, for droiddeck-steam-library; the bind itself is made below.
|
||||
GameStorage.effective(this)?.let { guest.add("BL_LIBRARY_LABEL=" + it.label.replace('"', ' ')) }
|
||||
if (SessionState.mode == MODE_STEAM && SessionState.steamUi == "desktop") guest.add("BL_STEAM_UI=desktop")
|
||||
// The desktop asked for with Steam in it (the front end's "Steam Desktop UI", Big Picture's
|
||||
// "Switch to Desktop"): the desktop's autostart opens the client's desktop UI there.
|
||||
if (SessionState.mode == MODE_DESKTOP && SessionState.steamUi == "desktop") guest.add("BL_DESKTOP_STEAM=1")
|
||||
val shellGuest = ArrayList(guest).apply {
|
||||
add("SHELL=/bin/bash")
|
||||
add("TERM=xterm-256color")
|
||||
@@ -410,11 +412,9 @@ class SessionService : Service() {
|
||||
guest.add(it)
|
||||
Log.i(TAG, "steam: handing the client $it")
|
||||
}
|
||||
// A game whose download finishes in this session gets its Windows components readied in it.
|
||||
if (SessionState.mode == MODE_STEAM) com.droiddeck.launcher.frontend.SessionInstalls.watch(this)
|
||||
// A program under gamescope: the script's run mode takes the path (an AppImage, a script
|
||||
// or a binary inside the runtime). This is how an emulator gets the GPU - the desktop's
|
||||
// KWin composites in software and offers no dma-buf, so a Vulkan swapchain cannot exist
|
||||
// labwc composites in software and offers no dma-buf, so a Vulkan swapchain cannot exist
|
||||
// there (RPCS3 died with VK_ERROR_SURFACE_LOST); gamescope's Xwayland is the path the
|
||||
// Steam games already render through.
|
||||
if (SessionState.mode == MODE_RUN) {
|
||||
@@ -625,7 +625,8 @@ class SessionService : Service() {
|
||||
guest.add("ZINK_DEBUG=compact")
|
||||
}
|
||||
// The rest of the client-interface switches (SessionPrefs): GL marshalled off the calling
|
||||
// thread and no GL error checks.
|
||||
// thread, no GL error checks, and the client run as SteamOS runs it (the script reads
|
||||
// BL_STEAMDECK; it is the one that builds the command line).
|
||||
if (SessionPrefs.glThread(this)) guest.add("mesa_glthread=true")
|
||||
if (SessionPrefs.noGlError(this)) guest.add("MESA_NO_ERROR=1")
|
||||
// Mesa's shader cache as one database instead of a file per entry. Every lookup in the
|
||||
@@ -638,7 +639,9 @@ class SessionService : Service() {
|
||||
// every thread start paid a SIGSYS that proot answers; the malloc top pad grows the heap
|
||||
// 16 MB at a time instead of 128 KB, and every brk(2) is a proot stop too.
|
||||
guest.add("GLIBC_TUNABLES=glibc.pthread.rseq=0:glibc.malloc.top_pad=16777216")
|
||||
if (SessionState.mode == MODE_STEAM) guest.add("BL_STEAMDECK=" + (if (SessionPrefs.steamDeckMode(this)) "1" else "0"))
|
||||
if (SessionState.mode == MODE_STEAM) guest.add("BL_MANGOAPP=" + (if (SessionPrefs.mangoapp(this)) "1" else "0"))
|
||||
if (steamHere) guest.add("BL_STEAM_CHANNEL=" + SessionPrefs.steamChannel(this))
|
||||
if (SessionState.mode == MODE_STEAM) {
|
||||
guest.add("BL_GAMESCOPE_STRETCH_16X9=" + (if (SessionPrefs.stretch16x9(this)) "1" else "0"))
|
||||
}
|
||||
@@ -682,9 +685,7 @@ class SessionService : Service() {
|
||||
// changes what games do and leaves the client alone. The microphone is its own opt-in on
|
||||
// top, and the helper only opens an input stream when asked - so a user who wants game
|
||||
// sound but no recording gets exactly that, and Android's recording indicator stays off.
|
||||
// The desktop starts Windows programs through the same Proton wrappers (droiddeck-proton-run).
|
||||
val wantsDirectAudio = (SessionState.mode == MODE_STEAM || SessionState.mode == MODE_DESKTOP) &&
|
||||
SessionPrefs.directAudio(this)
|
||||
val wantsDirectAudio = SessionState.mode == MODE_STEAM && SessionPrefs.directAudio(this)
|
||||
val wantsMic = SessionPrefs.micEnabled(this) &&
|
||||
checkSelfPermission(android.Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
|
||||
// Both paths sit under the app's files directory, which the session binds at its own path,
|
||||
@@ -742,18 +743,10 @@ class SessionService : Service() {
|
||||
// hidraw (SteamDeckPad), which only works with Steam Input's virtual pad for games to read.
|
||||
// Decided only once its sysfs is in place: without it the client would find no Deck, and
|
||||
// with the Deck asked for the pad's own nodes are withdrawn - no controller at all.
|
||||
// Set up whatever the pad is to be, so the drawer can switch it between a Deck, a Steam
|
||||
// Controller and an Xbox 360 pad while the session runs (FakeInputWriter.setPadType).
|
||||
val wantsDeck = uinput && SessionState.mode == MODE_STEAM &&
|
||||
SessionState.steamUi != "desktop" &&
|
||||
SessionPrefs.steamController(this) == SessionPrefs.CONTROLLER_DECK &&
|
||||
!File(Environment.getExternalStorageDirectory(), NO_DECK_PAD_SWITCH).exists()
|
||||
// Steam's touch controller rides on the Deck's sysfs and is offered only beside it.
|
||||
val wantsTouch = wantsDeck && !File(Environment.getExternalStorageDirectory(), NO_STEAM_TOUCH_SWITCH).exists()
|
||||
// A paired Triton is exposed as a second native Steam Controller HID, with its original
|
||||
// report IDs and output path, beside whatever the handheld's own pad is.
|
||||
val wantsTriton = wantsDeck && SteamControllerBle.hasPairedController(this)
|
||||
deckBinds = if (wantsDeck) SteamDeckPad.prepare(this, fakeInputDir.parentFile!!.parentFile!!, wantsTouch, wantsTriton,
|
||||
SessionPrefs.padType(SessionPrefs.steamController(this))) else emptyList()
|
||||
deckBinds = if (wantsDeck) SteamDeckPad.prepare(this, fakeInputDir.parentFile!!.parentFile!!) else emptyList()
|
||||
SessionState.deckPad = deckBinds.isNotEmpty()
|
||||
if (wantsDeck && !SessionState.deckPad) Log.w(TAG, "deck pad: not available this session; the pad stays an Xbox 360 controller")
|
||||
logControllersAtStart()
|
||||
@@ -763,15 +756,6 @@ class SessionService : Service() {
|
||||
guest.add("FAKE_EVDEV_UINPUT=1")
|
||||
if (SessionState.deckPad) {
|
||||
guest.add("FAKE_EVDEV_DECK=1")
|
||||
if (wantsTriton) guest.add("FAKE_EVDEV_TRITON=1")
|
||||
// What the pad starts as; the drawer's changes reach libfakeinput through the ring.
|
||||
val padType = SessionPrefs.steamController(this)
|
||||
guest.add("FAKE_EVDEV_PAD_TYPE=$padType")
|
||||
com.droiddeck.launcher.input.FakeInputWriter.setPadType(SessionPrefs.padType(padType))
|
||||
// Steam's touch controller (SteamTouchDevice): the file the app and libfakeinput share.
|
||||
if (wantsTouch) com.droiddeck.launcher.input.SteamTouchDevice.prepare(fakeInputDir)?.let {
|
||||
guest.add("FAKE_TOUCHCTL_RING=" + it.path)
|
||||
}
|
||||
guest.add("FAKE_DECK_SYSFS_LISTING=" + SteamDeckPad.listingDir(fakeInputDir.parentFile!!.parentFile!!).path)
|
||||
}
|
||||
} else if (SessionState.mode == MODE_STEAM) {
|
||||
@@ -1228,13 +1212,6 @@ class SessionService : Service() {
|
||||
}
|
||||
return
|
||||
}
|
||||
if (path != null && path.startsWith("desktop-import-") && path.endsWith(".request")) {
|
||||
val request = File(dir, path)
|
||||
val text = runCatching { request.readText() }.getOrNull() ?: return
|
||||
request.delete()
|
||||
importForDesktop(dir, text)
|
||||
return
|
||||
}
|
||||
if (path != "steam-launch") return
|
||||
val file = File(dir, path)
|
||||
val text = try { file.readText() } catch (e: Exception) { return }
|
||||
@@ -1264,48 +1241,6 @@ class SessionService : Service() {
|
||||
launchWatcher = watcher
|
||||
}
|
||||
|
||||
private val desktopImports = java.util.concurrent.ConcurrentHashMap<String, Any>()
|
||||
|
||||
/**
|
||||
* What the desktop cannot run where it lies (droiddeck-open), added as Add an app adds it, menu entry
|
||||
* included: an AppImage of sharun's, whose hard links Android denies apps and only the app's own proot
|
||||
* (link2symlink) makes, or a script or program whose folder of Linux programs is on noexec shared
|
||||
* storage, copied in. The answer goes beside the request, "ok <folder>" or "error <why>".
|
||||
*/
|
||||
private fun importForDesktop(dir: File, text: String) {
|
||||
val fields = text.lineSequence().mapNotNull { line ->
|
||||
line.split('=', limit = 2).takeIf { it.size == 2 }?.let { it[0] to it[1].trim() }
|
||||
}.toMap()
|
||||
val key = fields["id"]?.takeIf { it.matches(Regex("[0-9a-f]{16}")) } ?: return
|
||||
val guestPath = fields["path"]?.takeIf { it.startsWith("/") && !it.contains("/../") } ?: return
|
||||
val folder = fields["kind"] == "folder"
|
||||
val binds = LinuxRuntime.lastBinds(this).map { BwrapSpawner.Bind.parse(it) }
|
||||
val file = File(BwrapSpawner.hostPath(binds, LinuxRuntime.rootDir(this).path, guestPath))
|
||||
val context = applicationContext
|
||||
// A request for what is already being added waits for that, then finds it.
|
||||
val lock = desktopImports.computeIfAbsent(key) { Any() }
|
||||
Thread({
|
||||
fun imported() = UserApps.desktopImport(context, key, guestPath)?.let { "ok $it" }
|
||||
val request = UserApps.Request(
|
||||
if (folder) UserApps.Source.Script(file) else UserApps.Source.AppImage(file),
|
||||
if (folder) file.parentFile?.name else null, null, mapOf(UserApps.DESKTOP_IMPORT to "$key\n$guestPath"),
|
||||
)
|
||||
val answer = synchronized(lock) {
|
||||
try {
|
||||
imported() ?: UserApps.add(context, request) { _, _ -> }?.let { "error $it" }
|
||||
?: imported() ?: "error " + context.getString(R.string.user_apps_failed)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Desktop import of $guestPath", e)
|
||||
"error " + (e.message ?: context.getString(R.string.user_apps_failed))
|
||||
}
|
||||
}
|
||||
Log.i(TAG, "Desktop import of $guestPath: $answer")
|
||||
val result = File(dir, "desktop-import-$key.result")
|
||||
val partial = File(dir, result.name + ".tmp")
|
||||
runCatching { partial.writeText(answer.replace('\n', ' ') + "\n"); partial.renameTo(result) }
|
||||
}, "desktop-import").start()
|
||||
}
|
||||
|
||||
@Volatile private var syncWatcher: android.os.FileObserver? = null
|
||||
|
||||
private fun watchSyncWanted(root: File) {
|
||||
@@ -1576,7 +1511,6 @@ class SessionService : Service() {
|
||||
}
|
||||
components.clear()
|
||||
FakeInputWriter.releaseAllRingSlots()
|
||||
com.droiddeck.launcher.input.SteamTouchDevice.release()
|
||||
val steamClientMayRun = SessionState.mode == MODE_STEAM || SessionState.mode == MODE_DESKTOP
|
||||
val finishAfterTeardown: () -> Unit = {
|
||||
if (prootPid > 1 || auxiliary.isNotEmpty()) {
|
||||
@@ -1639,10 +1573,8 @@ class SessionService : Service() {
|
||||
}
|
||||
try {
|
||||
val wifi = applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager
|
||||
val allowed = DeviceSupport.lowLatencyWifiSafe()
|
||||
if (!allowed) Log.i(TAG, "wifi lock skipped: its low-latency mode resets this device")
|
||||
@Suppress("DEPRECATION") // deprecated from API 29, still honoured; targetSdk is 28
|
||||
if (allowed && wifiLock?.isHeld != true) {
|
||||
if (wifiLock?.isHeld != true) {
|
||||
wifiLock = wifi?.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "DroidDeck:session-wifi")
|
||||
?.apply {
|
||||
setReferenceCounted(false)
|
||||
@@ -1782,7 +1714,7 @@ class SessionService : Service() {
|
||||
pads.add(String.format(java.util.Locale.ROOT, "\"%s\" (%04x:%04x)", d.name, d.vendorId, d.productId))
|
||||
}
|
||||
Log.i(TAG, "controllers at start: " + (if (pads.isEmpty()) "none" else pads.joinToString(", ")) +
|
||||
"; presented to the guest as " + if (SessionState.deckPad) "switchable, starting as " + SessionPrefs.steamController(this) else "an Xbox 360 controller")
|
||||
"; presented to the guest as " + if (SessionState.deckPad) "a Steam Deck controller" else "an Xbox 360 controller")
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -1839,7 +1771,6 @@ class SessionService : Service() {
|
||||
private const val NO_PAD_SWITCH = "Download/droiddeck-no-pad"
|
||||
private const val NO_UINPUT_SWITCH = "Download/droiddeck-no-uinput"
|
||||
private const val NO_DECK_PAD_SWITCH = "Download/droiddeck-no-deck-pad"
|
||||
private const val NO_STEAM_TOUCH_SWITCH = "Download/droiddeck-no-steam-touch"
|
||||
/** libfakeinput numbers the pads made through its /dev/uinput stand-in from here. */
|
||||
private const val FIRST_VIRTUAL_PAD = 16
|
||||
|
||||
|
||||
@@ -96,10 +96,6 @@ object SessionState {
|
||||
@Volatile
|
||||
var outputSize: Pair<Int, Int> = Pair(1920, 1080)
|
||||
|
||||
/** The session's resolution is "Follow screen": outputSize tracks the window while it runs. */
|
||||
@Volatile
|
||||
var followScreen = false
|
||||
|
||||
@Volatile
|
||||
var refreshHz: Float = 60f
|
||||
|
||||
|
||||
@@ -44,54 +44,6 @@ object SteamDeckPad {
|
||||
private const val HIDRAW = "$HID/hidraw/$NODE"
|
||||
private const val SERIAL = "DROIDDECK0001"
|
||||
|
||||
// The paired Bluetooth Steam Controller is a separate Triton HID device. It must not inherit
|
||||
// the Deck's identity or report format. Must match fakeinput_steam.cpp (TRITON_HIDRAW_*).
|
||||
private const val TRITON_MINOR = 18
|
||||
private const val TRITON_HID = "$GUEST_DEVICES/bluetooth/0005:28DE:1303.0003"
|
||||
private const val TRITON_SERIAL = "DROIDDECKSC01"
|
||||
// The handheld's own pad as the Steam Controller (SessionPrefs.CONTROLLER_STEAM): the wired
|
||||
// controller (28de:1302) on a USB device of its own, since it is built in.
|
||||
private const val TRITON_USB = "$GUEST_DEVICES/usb3"
|
||||
private const val TRITON_INTERFACE = "$TRITON_USB/3-1:1.0"
|
||||
private const val TRITON_WIRED_HID = "$TRITON_INTERFACE/0003:28DE:1302.0004"
|
||||
/** Must match libfakeinput (DS4_HIDRAW_MINOR, DUALSENSE_HIDRAW_MINOR). */
|
||||
private const val SONY_DS4_MINOR = 20
|
||||
private const val SONY_DUALSENSE_MINOR = 21
|
||||
/** Must match libfakeinput's kDs4ReportDescriptor and kDualSenseReportDescriptor. */
|
||||
private val DS4_REPORT_DESCRIPTOR = intArrayOf(
|
||||
0x05, 0x01, 0x09, 0x05, 0xa1, 0x01, 0x06, 0x00, 0xff, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08,
|
||||
0x85, 0x01, 0x09, 0x20, 0x95, 0x3f, 0x81, 0x02, 0x85, 0x05, 0x09, 0x21, 0x95, 0x1f, 0x91, 0x02,
|
||||
0x85, 0x02, 0x09, 0x24, 0x95, 0x24, 0xb1, 0x02, 0x85, 0x12, 0x09, 0x25, 0x95, 0x0f, 0xb1, 0x02,
|
||||
0x85, 0xa3, 0x09, 0x26, 0x95, 0x30, 0xb1, 0x02, 0x85, 0x81, 0x09, 0x27, 0x95, 0x3f, 0xb1, 0x02,
|
||||
0xc0,
|
||||
).map { it.toByte() }.toByteArray()
|
||||
private val DUALSENSE_REPORT_DESCRIPTOR = intArrayOf(
|
||||
0x05, 0x01, 0x09, 0x05, 0xa1, 0x01, 0x06, 0x00, 0xff, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08,
|
||||
0x85, 0x01, 0x09, 0x20, 0x95, 0x3f, 0x81, 0x02, 0x85, 0x02, 0x09, 0x21, 0x95, 0x2f, 0x91, 0x02,
|
||||
0x85, 0x05, 0x09, 0x22, 0x95, 0x28, 0xb1, 0x02, 0x85, 0x09, 0x09, 0x23, 0x95, 0x13, 0xb1, 0x02,
|
||||
0x85, 0x20, 0x09, 0x24, 0x95, 0x3f, 0xb1, 0x02, 0xc0,
|
||||
).map { it.toByte() }.toByteArray()
|
||||
/** Must match libfakeinput (TRITON_PAD_HIDRAW_*): its own node, so a paired controller keeps 18. */
|
||||
private const val TRITON_PAD_MINOR = 19
|
||||
|
||||
// Steam's touch controller (SteamTouchDevice), a second device beside the Deck's: 0000:11fb,
|
||||
// "Mobile Touch Control", on a USB device of its own. Must match libfakeinput (TOUCH_*).
|
||||
private const val TOUCH_MINOR = 17
|
||||
private const val TOUCH_NODE = "hidraw$TOUCH_MINOR"
|
||||
private const val TOUCH_USB = "$GUEST_DEVICES/usb2"
|
||||
private const val TOUCH_INTERFACE = "$TOUCH_USB/2-1:1.0"
|
||||
private const val TOUCH_HID = "$TOUCH_INTERFACE/0003:0000:11FB.0002"
|
||||
private const val TOUCH_HIDRAW = "$TOUCH_HID/hidraw/$TOUCH_NODE"
|
||||
private const val TOUCH_SERIAL = "DROIDDECK0001"
|
||||
|
||||
/** libfakeinput's kTouchReportDescriptor: Generic Desktop / Game Pad, 40-byte input report. */
|
||||
private val TOUCH_REPORT_DESCRIPTOR = intArrayOf(
|
||||
0x05, 0x01, 0x09, 0x05, 0xa1, 0x01,
|
||||
0x09, 0x02, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x28, 0x81, 0x02,
|
||||
0x09, 0x03, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x40, 0x91, 0x02,
|
||||
0x09, 0x04, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x40, 0xb1, 0x02, 0xc0,
|
||||
).map { it.toByte() }.toByteArray()
|
||||
|
||||
/** InputPlumber's CONTROLLER_DESCRIPTOR, as libfakeinput answers HIDIOCGRDESC. */
|
||||
private val REPORT_DESCRIPTOR = intArrayOf(
|
||||
0x06, 0xff, 0xff, 0x09, 0x01, 0xa1, 0x01, 0x09, 0x02, 0x09, 0x03, 0x15, 0x00,
|
||||
@@ -99,65 +51,11 @@ object SteamDeckPad {
|
||||
0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x40, 0xb1, 0x02, 0xc0,
|
||||
).map { it.toByte() }.toByteArray()
|
||||
|
||||
/** Triton's native 0x45/0x47 input and 0x80-0x85 haptic output report IDs. */
|
||||
private val TRITON_REPORT_DESCRIPTOR = intArrayOf(
|
||||
0x06, 0x00, 0xff, 0x09, 0x01, 0xa1, 0x01,
|
||||
0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08,
|
||||
0x85, 0x45, 0x09, 0x02, 0x95, 0x2d, 0x81, 0x02,
|
||||
0x85, 0x47, 0x09, 0x03, 0x95, 0x2d, 0x81, 0x02,
|
||||
0x85, 0x80, 0x09, 0x04, 0x95, 0x09, 0x91, 0x02,
|
||||
0x85, 0x81, 0x09, 0x05, 0x95, 0x07, 0x91, 0x02,
|
||||
0x85, 0x82, 0x09, 0x06, 0x95, 0x03, 0x91, 0x02,
|
||||
0x85, 0x83, 0x09, 0x07, 0x95, 0x09, 0x91, 0x02,
|
||||
0x85, 0x84, 0x09, 0x08, 0x95, 0x08, 0x91, 0x02,
|
||||
0x85, 0x85, 0x09, 0x09, 0x95, 0x03, 0x91, 0x02,
|
||||
0x85, 0x01, 0x09, 0x0a, 0x95, 0x3f, 0xb1, 0x02,
|
||||
0xc0,
|
||||
).map { it.toByte() }.toByteArray()
|
||||
|
||||
/** The wired controller's: its 0x42 state report in place of the Bluetooth 0x45. */
|
||||
private val TRITON_WIRED_REPORT_DESCRIPTOR = TRITON_REPORT_DESCRIPTOR.copyOf().also { it[15] = 0x42 }
|
||||
|
||||
/** Where [prepare] puts the stand-in listings; bound into the guest at the same path. */
|
||||
fun listingDir(sessionRoot: File) = File(sessionRoot, "sys/deck/listing")
|
||||
|
||||
/** Writes the tree and returns the `host:guest` binds for it, or none if it could not be made. */
|
||||
/** The pad's own devices by minor (the Deck, its Steam Controller, the PlayStation pads): guest paths. */
|
||||
private val padNodes = mutableMapOf<Int, String>()
|
||||
@Volatile private var hidrawClassDir: File? = null
|
||||
|
||||
/**
|
||||
* Lists only the device the pad is now ([padType], FakeInputWriter.PAD_TYPE_*) in
|
||||
* /sys/class/hidraw, none for an Xbox 360 pad. Device scans then find what is plugged in: SDL's
|
||||
* (the PlayStation pads' driver) keeps a device it found and could not open, and never tries it
|
||||
* again, while one that disappears from the listing is let go. Before the shim is told.
|
||||
*/
|
||||
@Synchronized
|
||||
fun showPad(padType: Int) {
|
||||
val dir = hidrawClassDir ?: return
|
||||
val shown = when (padType) {
|
||||
com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_DECK -> MINOR
|
||||
com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_STEAM -> TRITON_PAD_MINOR
|
||||
com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_DS4 -> SONY_DS4_MINOR
|
||||
com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_DUALSENSE -> SONY_DUALSENSE_MINOR
|
||||
else -> -1
|
||||
}
|
||||
for ((minor, target) in padNodes) {
|
||||
val link = File(dir, "hidraw$minor")
|
||||
try {
|
||||
if (minor == shown) {
|
||||
if (!link.exists()) { link.delete(); Os.symlink(target, link.path) }
|
||||
} else {
|
||||
link.delete()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "pad: could not list /dev/hidraw$minor: $e")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun prepare(context: Context, sessionRoot: File, touch: Boolean = false, triton: Boolean = false,
|
||||
padType: Int = com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_DECK): List<String> {
|
||||
fun prepare(context: Context, sessionRoot: File): List<String> {
|
||||
val base = File(sessionRoot, "sys/deck")
|
||||
val devices = File(base, "devices")
|
||||
val hidrawClass = File(base, "class-hidraw")
|
||||
@@ -197,8 +95,7 @@ object SteamDeckPad {
|
||||
link(File(dir(HIDRAW), "device"), HID)
|
||||
|
||||
hidrawClass.mkdirs()
|
||||
padNodes.clear()
|
||||
padNodes[MINOR] = HIDRAW
|
||||
link(File(hidrawClass, NODE), HIDRAW)
|
||||
|
||||
// Only the names matter: libudev reads a listing for the subsystems to descend into and
|
||||
// then opens /sys/class/hidraw itself, which is the bind above.
|
||||
@@ -210,116 +107,6 @@ object SteamDeckPad {
|
||||
val udevData = File(LinuxRuntime.rootDir(context), "run/udev/data").apply { mkdirs() }
|
||||
File(udevData, "c$MAJOR:$MINOR").writeText("I:1\nE:ID_INPUT=1\nE:ID_INPUT_JOYSTICK=1\n")
|
||||
|
||||
if (touch) {
|
||||
write(TOUCH_USB, "uevent", "DEVTYPE=usb_device\nPRODUCT=0/11fb/0\nTYPE=0/0/0\nBUSNUM=002\nDEVNUM=002\n")
|
||||
write(TOUCH_USB, "idVendor", "0000\n")
|
||||
write(TOUCH_USB, "idProduct", "11fb\n")
|
||||
write(TOUCH_USB, "bcdDevice", "0000\n")
|
||||
write(TOUCH_USB, "manufacturer", "Valve Software\n")
|
||||
write(TOUCH_USB, "product", "Mobile Touch Control\n")
|
||||
write(TOUCH_USB, "serial", "$TOUCH_SERIAL\n")
|
||||
link(File(dir(TOUCH_USB), "subsystem"), "/sys/bus/usb")
|
||||
write(TOUCH_INTERFACE, "uevent", "DEVTYPE=usb_interface\nPRODUCT=0/11fb/0\nINTERFACE=3/0/0\n")
|
||||
write(TOUCH_INTERFACE, "bInterfaceNumber", "00\n")
|
||||
write(TOUCH_INTERFACE, "bInterfaceClass", "03\n")
|
||||
link(File(dir(TOUCH_INTERFACE), "subsystem"), "/sys/bus/usb")
|
||||
write(TOUCH_HID, "uevent", "DRIVER=hid-generic\nHID_ID=0003:00000000:000011FB\n" +
|
||||
"HID_NAME=Mobile Touch Control\nHID_PHYS=usb-droiddeck-2/input0\n" +
|
||||
"HID_UNIQ=$TOUCH_SERIAL\nMODALIAS=hid:b0003g0001v00000000p000011FB\n")
|
||||
File(dir(TOUCH_HID), "report_descriptor").writeBytes(TOUCH_REPORT_DESCRIPTOR)
|
||||
link(File(dir(TOUCH_HID), "subsystem"), "/sys/bus/hid")
|
||||
write(TOUCH_HIDRAW, "uevent", "MAJOR=$MAJOR\nMINOR=$TOUCH_MINOR\nDEVNAME=$TOUCH_NODE\n")
|
||||
write(TOUCH_HIDRAW, "dev", "$MAJOR:$TOUCH_MINOR\n")
|
||||
link(File(dir(TOUCH_HIDRAW), "subsystem"), "/sys/class/hidraw")
|
||||
link(File(dir(TOUCH_HIDRAW), "device"), TOUCH_HID)
|
||||
link(File(hidrawClass, TOUCH_NODE), TOUCH_HIDRAW)
|
||||
link(File(context.cacheDir, "drm/sys/$MAJOR:$TOUCH_MINOR"), TOUCH_HIDRAW)
|
||||
File(udevData, "c$MAJOR:$TOUCH_MINOR").writeText("I:1\nE:ID_INPUT=1\nE:ID_INPUT_JOYSTICK=1\n")
|
||||
Log.i(TAG, "deck pad: /dev/$TOUCH_NODE described as Steam's touch controller (0000:11fb)")
|
||||
}
|
||||
|
||||
// A Steam Controller's HID device: the handheld's own pad as the wired one, or the paired
|
||||
// one on Bluetooth. Both are always described; libfakeinput offers whichever is in use.
|
||||
fun describeTriton(wired: Boolean) {
|
||||
val hid = if (wired) TRITON_WIRED_HID else TRITON_HID
|
||||
val product = if (wired) "1302" else "1303"
|
||||
val bus = if (wired) "0003" else "0005"
|
||||
val minor = if (wired) TRITON_PAD_MINOR else TRITON_MINOR
|
||||
val node = "hidraw$minor"
|
||||
if (wired) {
|
||||
write(TRITON_USB, "uevent", "DEVTYPE=usb_device\nPRODUCT=28de/1302/100\nTYPE=0/0/0\nBUSNUM=003\nDEVNUM=002\n")
|
||||
write(TRITON_USB, "idVendor", "28de\n")
|
||||
write(TRITON_USB, "idProduct", "1302\n")
|
||||
write(TRITON_USB, "bcdDevice", "0100\n")
|
||||
write(TRITON_USB, "manufacturer", "Valve Software\n")
|
||||
write(TRITON_USB, "product", "Steam Controller\n")
|
||||
write(TRITON_USB, "serial", "$TRITON_SERIAL\n")
|
||||
link(File(dir(TRITON_USB), "subsystem"), "/sys/bus/usb")
|
||||
write(TRITON_INTERFACE, "uevent", "DEVTYPE=usb_interface\nPRODUCT=28de/1302/100\nINTERFACE=3/0/0\n")
|
||||
write(TRITON_INTERFACE, "bInterfaceNumber", "00\n")
|
||||
write(TRITON_INTERFACE, "bInterfaceClass", "03\n")
|
||||
link(File(dir(TRITON_INTERFACE), "subsystem"), "/sys/bus/usb")
|
||||
}
|
||||
write(hid, "uevent", "DRIVER=hid-generic\nHID_ID=$bus:000028DE:0000$product\n" +
|
||||
"HID_NAME=Valve Software Steam Controller\n" +
|
||||
"HID_PHYS=${if (wired) "usb-droiddeck-3/input0" else "bluetooth-droiddeck-1"}\n" +
|
||||
"HID_UNIQ=$TRITON_SERIAL\nMODALIAS=hid:b${bus}g0001v000028DEp0000$product\n")
|
||||
File(dir(hid), "report_descriptor").writeBytes(if (wired) TRITON_WIRED_REPORT_DESCRIPTOR else TRITON_REPORT_DESCRIPTOR)
|
||||
link(File(dir(hid), "subsystem"), "/sys/bus/hid")
|
||||
val hidraw = "$hid/hidraw/$node"
|
||||
write(hidraw, "uevent", "MAJOR=$MAJOR\nMINOR=$minor\nDEVNAME=$node\n")
|
||||
write(hidraw, "dev", "$MAJOR:$minor\n")
|
||||
link(File(dir(hidraw), "subsystem"), "/sys/class/hidraw")
|
||||
link(File(dir(hidraw), "device"), hid)
|
||||
if (wired) padNodes[minor] = hidraw else link(File(hidrawClass, node), hidraw)
|
||||
link(File(context.cacheDir, "drm/sys/$MAJOR:$minor"), hidraw)
|
||||
File(udevData, "c$MAJOR:$minor").writeText("I:1\nE:ID_INPUT=1\nE:ID_INPUT_JOYSTICK=1\n")
|
||||
Log.i(TAG, "steam controller: /dev/$node described as Triton (28de:$product)")
|
||||
}
|
||||
// The handheld's own pad as a PlayStation controller, each a USB device of its own with
|
||||
// the report descriptor libfakeinput serves (kDs4/kDualSenseReportDescriptor).
|
||||
fun describeSony(dualSense: Boolean) {
|
||||
val bus = if (dualSense) 5 else 4
|
||||
val product = if (dualSense) "0ce6" else "09cc"
|
||||
val minor = if (dualSense) SONY_DUALSENSE_MINOR else SONY_DS4_MINOR
|
||||
val node = "hidraw$minor"
|
||||
val name = if (dualSense) "DualSense Wireless Controller" else "Wireless Controller"
|
||||
val usb = "$GUEST_DEVICES/usb$bus"
|
||||
val iface = "$usb/$bus-1:1.3"
|
||||
val hid = "$iface/0003:054C:${product.uppercase()}.000${bus + 1}"
|
||||
write(usb, "uevent", "DEVTYPE=usb_device\nPRODUCT=54c/$product/100\nTYPE=0/0/0\nBUSNUM=00$bus\nDEVNUM=002\n")
|
||||
write(usb, "idVendor", "054c\n")
|
||||
write(usb, "idProduct", "$product\n")
|
||||
write(usb, "bcdDevice", "0100\n")
|
||||
write(usb, "manufacturer", "Sony Interactive Entertainment\n")
|
||||
write(usb, "product", "$name\n")
|
||||
link(File(dir(usb), "subsystem"), "/sys/bus/usb")
|
||||
write(iface, "uevent", "DEVTYPE=usb_interface\nPRODUCT=54c/$product/100\nINTERFACE=3/0/0\n")
|
||||
write(iface, "bInterfaceNumber", "03\n")
|
||||
write(iface, "bInterfaceClass", "03\n")
|
||||
link(File(dir(iface), "subsystem"), "/sys/bus/usb")
|
||||
write(hid, "uevent", "DRIVER=hid-generic\nHID_ID=0003:0000054C:0000${product.uppercase()}\n" +
|
||||
"HID_NAME=Sony Interactive Entertainment $name\nHID_PHYS=usb-droiddeck-$bus/input3\n" +
|
||||
"HID_UNIQ=5e:43:4b:44:44:02\nMODALIAS=hid:b0003g0001v0000054Cp0000${product.uppercase()}\n")
|
||||
File(dir(hid), "report_descriptor").writeBytes(if (dualSense) DUALSENSE_REPORT_DESCRIPTOR else DS4_REPORT_DESCRIPTOR)
|
||||
link(File(dir(hid), "subsystem"), "/sys/bus/hid")
|
||||
val hidraw = "$hid/hidraw/$node"
|
||||
write(hidraw, "uevent", "MAJOR=$MAJOR\nMINOR=$minor\nDEVNAME=$node\n")
|
||||
write(hidraw, "dev", "$MAJOR:$minor\n")
|
||||
link(File(dir(hidraw), "subsystem"), "/sys/class/hidraw")
|
||||
link(File(dir(hidraw), "device"), hid)
|
||||
padNodes[minor] = hidraw
|
||||
link(File(context.cacheDir, "drm/sys/$MAJOR:$minor"), hidraw)
|
||||
File(udevData, "c$MAJOR:$minor").writeText("I:1\nE:ID_INPUT=1\nE:ID_INPUT_JOYSTICK=1\n")
|
||||
Log.i(TAG, "playstation pad: /dev/$node described as 054c:$product")
|
||||
}
|
||||
describeSony(dualSense = false)
|
||||
describeSony(dualSense = true)
|
||||
describeTriton(wired = true)
|
||||
if (triton) describeTriton(wired = false)
|
||||
hidrawClassDir = hidrawClass
|
||||
showPad(padType)
|
||||
|
||||
Log.i(TAG, "deck pad: /dev/$NODE described as a Steam Deck controller (28de:1205)")
|
||||
listOf(devices.path + ":" + GUEST_DEVICES, hidrawClass.path + ":/sys/class/hidraw")
|
||||
} catch (e: Exception) {
|
||||
|
||||
Loaded 100 of 333 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user