mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 19:27:09 -04:00
Compare commits
97
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40461caf4a | ||
|
|
1e877fa602 | ||
|
|
906009a33a | ||
|
|
08559a02f8 | ||
|
|
1d93626453 | ||
|
|
9d0989374b | ||
|
|
3278afaa1a | ||
|
|
bf221ffbbd | ||
|
|
c636eea355 | ||
|
|
ffaa906cd1 | ||
|
|
d61af961c1 | ||
|
|
bc01b77752 | ||
|
|
a1b01a04a8 | ||
|
|
b44235c495 | ||
|
|
b80d5db176 | ||
|
|
d3c0d258b7 | ||
|
|
9dcdbf245a | ||
|
|
369e218afb | ||
|
|
119d24bf6b | ||
|
|
2a905510d5 | ||
|
|
da29919b5a | ||
|
|
05608ac4d4 | ||
|
|
135b84a501 | ||
|
|
e7cbba37d1 | ||
|
|
d9bb5e15b0 | ||
|
|
46cdc32cf1 | ||
|
|
387572e733 | ||
|
|
9f46c7a50b | ||
|
|
0607bb0b3a | ||
|
|
b89d87cbde | ||
|
|
06e06b9673 | ||
|
|
b896e7a3b9 | ||
|
|
c91daa9147 | ||
|
|
31ea194f3d | ||
|
|
472bb28c88 | ||
|
|
eaa4a7a047 | ||
|
|
4fa467c800 | ||
|
|
beb96e6f0c | ||
|
|
26cb9e2f03 | ||
|
|
6dc74523c3 | ||
|
|
3858a69867 | ||
|
|
49a5f4aa94 | ||
|
|
4cc9f2a3f3 | ||
|
|
b0d7fc01d8 | ||
|
|
9cc40246a3 | ||
|
|
423cf11919 | ||
|
|
33f7213599 | ||
|
|
6f1fb462ed | ||
|
|
336b8851f7 | ||
|
|
5da3cf673c | ||
|
|
9e54e094fb | ||
|
|
eb9ca4ab6b | ||
|
|
e7b6876877 | ||
|
|
c535f6ef6e | ||
|
|
db2e1d7da2 | ||
|
|
f5e204eb1d | ||
|
|
705a5e7b2a | ||
|
|
e60eae3646 | ||
|
|
b04900b1b2 | ||
|
|
6357543b44 | ||
|
|
67fb338e5d | ||
|
|
ba140c4ab5 | ||
|
|
f5fdbc7941 | ||
|
|
90ae85da4d | ||
|
|
f3600fa618 | ||
|
|
2d8befe1cb | ||
|
|
f982ffcaf7 | ||
|
|
255c64551e | ||
|
|
746f28c1b2 | ||
|
|
2b481ba250 | ||
|
|
49622623de | ||
|
|
69f99ade42 | ||
|
|
c236e9711f | ||
|
|
4c2efb6688 | ||
|
|
7eb2d1cf1a | ||
|
|
b4ff83cf9f | ||
|
|
7b20b70c6b | ||
|
|
86207f9d0e | ||
|
|
eda5ece382 | ||
|
|
3404bbc128 | ||
|
|
b5a431aa3c | ||
|
|
49249f6541 | ||
|
|
5614621d6e | ||
|
|
0816562065 | ||
|
|
39618e6862 | ||
|
|
c66e4ba4b3 | ||
|
|
e4b033861f | ||
|
|
85a07a556c | ||
|
|
8dfc48d58b | ||
|
|
82ebdaa0b5 | ||
|
|
ec016f85bc | ||
|
|
c2c812deaa | ||
|
|
cbd4ce6636 | ||
|
|
d4130181e7 | ||
|
|
4e90e03536 | ||
|
|
454193da9b | ||
|
|
38d658abf5 |
No files matched your search
+108
-6
@@ -28,7 +28,7 @@ concurrency:
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
PRELOAD_CACHE_VERSION: v2
|
||||
PRELOAD_CACHE_VERSION: v3
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -57,6 +57,22 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Version bumps are isolated
|
||||
if: github.event_name == 'pull_request'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch --no-tags origin main
|
||||
base=$(git merge-base origin/main HEAD)
|
||||
diff=$(git diff -U0 "$base..HEAD" -- app/build.gradle)
|
||||
grep -Eq '^[+-][[:space:]]*version(Code|Name)[[:space:]]' <<<"$diff" || exit 0
|
||||
other_files=$(git diff --name-only "$base..HEAD" | grep -v '^app/build.gradle$' || true)
|
||||
other_gradle=$(grep -E '^[+-]' <<<"$diff" | grep -Ev '^(---|\+\+\+)' | grep -Ev '^[+-][[:space:]]*version(Code|Name)[[:space:]]' || true)
|
||||
if [[ -n "$other_files" || -n "$other_gradle" ]]; then
|
||||
echo "::error::versionCode/versionName changes must be isolated in their own PR"
|
||||
echo "A version PR may only change the versionCode/versionName lines in app/build.gradle." >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Merge onto main, or say what conflicts
|
||||
if: github.event_name == 'pull_request'
|
||||
run: |
|
||||
@@ -157,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/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/mangoapp/*') }}
|
||||
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'
|
||||
@@ -179,7 +195,14 @@ 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"
|
||||
for script in tools/linuxfs/overlay/usr/local/bin/bannerlator-*; do
|
||||
# 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
|
||||
aarch64-linux-gnu-strip --strip-unneeded "$D/libssbs.so"
|
||||
mkdir -p "$D/usr/local/bin"
|
||||
aarch64-linux-gnu-gcc -O2 -Wall -Wextra -o "$D/usr/local/bin/droiddeck-clipboard" tools/linuxfs/clipboard/clipboard.c -ldl
|
||||
aarch64-linux-gnu-strip --strip-unneeded "$D/usr/local/bin/droiddeck-clipboard"
|
||||
for script in tools/linuxfs/overlay/usr/local/bin/droiddeck-* tools/linuxfs/overlay/usr/local/bin/steam-compatibility; do
|
||||
install -Dm644 "$script" "$D/usr/local/bin/$(basename "$script")"
|
||||
done
|
||||
for f in tools/linuxfs/overlay/usr/bin/* tools/linuxfs/overlay/usr/bin/steamos-polkit-helpers/*; do
|
||||
@@ -201,6 +224,12 @@ jobs:
|
||||
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 -
|
||||
install -Dm644 uruntime "$D/usr/local/lib/droiddeck/uruntime"
|
||||
install -Dm644 tools/linuxfs/licenses/uruntime-LICENSE "$D/usr/local/share/licenses/uruntime/LICENSE"
|
||||
rm uruntime
|
||||
M="$D/usr/local/lib/mangoapp"
|
||||
mkdir -p "$M" mango-pkgs
|
||||
grep -v '^#' tools/mangoapp/packages.txt | while read -r sum url; do
|
||||
@@ -221,6 +250,26 @@ jobs:
|
||||
install -Dm644 tools/mangoapp/mangoapp "$D/usr/local/bin/mangoapp"
|
||||
rm -rf mango-pkgs
|
||||
ls -l "$M"
|
||||
# The Windows components installer's tools (tools/msitools/packages.txt).
|
||||
T="$D/usr/local/lib/droiddeck-msitools"
|
||||
mkdir -p "$T" msi-pkgs
|
||||
grep -v '^#' tools/msitools/packages.txt | while read -r sum url; do
|
||||
[ -n "$url" ] || continue
|
||||
curl -fsSL --retry 3 -o msi-pkgs/pkg.tar.xz "$url"
|
||||
echo "$sum msi-pkgs/pkg.tar.xz" | sha256sum -c -
|
||||
tar -xJf msi-pkgs/pkg.tar.xz -C msi-pkgs
|
||||
rm msi-pkgs/pkg.tar.xz
|
||||
done
|
||||
install -m644 msi-pkgs/usr/bin/msiinfo "$T/msiinfo"
|
||||
install -m644 msi-pkgs/usr/bin/cabextract "$T/cabextract"
|
||||
for so in libmsi-1.0.so.0 libgsf-1.so.114 libgcab-1.0.so.0; do
|
||||
cp -L "msi-pkgs/usr/lib/$so" "$T/$so"
|
||||
done
|
||||
for f in NOTICE GPL-2 GPL-3 LGPL-2.1; do
|
||||
install -Dm644 "tools/msitools/licenses/$f" "$D/usr/local/share/licenses/droiddeck-msitools/$f"
|
||||
done
|
||||
rm -rf msi-pkgs
|
||||
ls -l "$T"
|
||||
install -Dm644 tools/linuxfs/desktop/droiddeck-desktop "$D/usr/local/bin/droiddeck-desktop"
|
||||
install -Dm644 tools/linuxfs/desktop/droiddeck-gpu "$D/usr/local/bin/droiddeck-gpu"
|
||||
install -Dm644 tools/linuxfs/desktop/droiddeck-desktop-gpu "$D/usr/local/bin/droiddeck-desktop-gpu"
|
||||
@@ -230,6 +279,46 @@ jobs:
|
||||
install -Dm644 tools/linuxfs/desktop/firefox-droiddeck.js "$D/usr/lib/firefox/defaults/pref/droiddeck.js"
|
||||
ls -lR "$D"
|
||||
|
||||
- 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 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
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends gcc g++ gcc-multilib g++-multilib binutils >/dev/null
|
||||
tools/linuxfs/build-x86-preloads.sh app/src/main/assets/linuxfs
|
||||
'
|
||||
sudo chown -R "$(id -u):$(id -g)" app/src/main/assets/linuxfs
|
||||
ls -l app/src/main/assets/linuxfs/x86_64 app/src/main/assets/linuxfs/i386
|
||||
|
||||
- name: Bundle the droiddeck-esync packs
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -f tools/droiddeck-esync/release.env ]; then
|
||||
. tools/droiddeck-esync/release.env
|
||||
gh release download "$SYNC_BUNDLE_TAG" -R "$SYNC_BUNDLE_REPO" -p "$SYNC_BUNDLE_ASSET" -O sync-bundle.tzst
|
||||
echo "$SYNC_BUNDLE_SHA256 sync-bundle.tzst" | sha256sum -c -
|
||||
mkdir -p app/src/main/assets/droiddeck-esync
|
||||
tar --use-compress-program=unzstd -xf sync-bundle.tzst -C app/src/main/assets/droiddeck-esync
|
||||
rm sync-bundle.tzst
|
||||
gh release download "$SYNC_BUNDLE_TAG" -R "$SYNC_BUNDLE_REPO" -p index.json -O sync-index.json
|
||||
revoked=$(python3 -c 'import json, sys; print(" ".join(p["id"] for p in json.load(open(sys.argv[1]))["packs"] if p.get("revoked") is True))' sync-index.json)
|
||||
for id in $revoked; do
|
||||
if [ -e "app/src/main/assets/droiddeck-esync/packs/$id.tzst" ]; then
|
||||
echo "::warning::$SYNC_BUNDLE_ASSET carries revoked pack $id; it is left out of the APK (point tools/droiddeck-esync/release.env at a newer bundle)"
|
||||
rm -f "app/src/main/assets/droiddeck-esync/packs/$id.tzst"
|
||||
fi
|
||||
done
|
||||
rm sync-index.json
|
||||
test -f app/src/main/assets/droiddeck-esync/index.json
|
||||
test -f app/src/main/assets/droiddeck-esync/index.json.sig
|
||||
ls -lR app/src/main/assets/droiddeck-esync
|
||||
fi
|
||||
|
||||
- name: Cache PulseAudio 13.0 source
|
||||
id: pa-src
|
||||
uses: actions/cache@v4
|
||||
@@ -294,11 +383,23 @@ jobs:
|
||||
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; do
|
||||
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
|
||||
test -f "$D/usr/local/bin/bannerlator-session"
|
||||
test -f "$D/usr/local/bin/bannerlator-proton-extra"
|
||||
# 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
|
||||
|
||||
- name: Cache the CMake build tree
|
||||
uses: actions/cache@v4
|
||||
@@ -317,6 +418,7 @@ jobs:
|
||||
run: |
|
||||
set -o pipefail
|
||||
./gradlew assembleRelease --console=plain -PndkVersion="$NDK_VERSION" 2>&1 | tee /tmp/build.log
|
||||
python3 tools/release/check_session_assets.py app/build/outputs/apk/release/app-release.apk
|
||||
|
||||
- name: Steam import and game environment tests
|
||||
run: python3 -m unittest discover -s tools/tests -p 'test_*.py'
|
||||
|
||||
@@ -14,6 +14,7 @@ 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/libproot-loader.so
|
||||
app/src/main/assets/droiddeck-esync/
|
||||
|
||||
# Kotlin compiler session files (build-tool state, never source).
|
||||
.kotlin/
|
||||
|
||||
@@ -11,11 +11,13 @@ DroidDeck brings the SteamOS experience to Android: Valve's Steam client in Big
|
||||
|
||||
<p align="center"><img src="docs/releases/media/0.2.0/launch-into-steam.gif" width="80%" alt="Tapping DroidDeck on the Android home screen and landing in Steam Big Picture"></p>
|
||||
|
||||
> Note: DroidDeck does not have a stand-alone website. Do not click on any download links from websites claiming to be the DroidDeck team.
|
||||
|
||||
## Requirements and install
|
||||
|
||||
Use Android 9 or newer on a supported Adreno device (730 or newer, or 8xx). Mali, Xclipse, PowerVR, and Adreno 710 are unsupported. No root is required. Allow about 3 GB for the runtime and 1.1 GB more for the desktop and emulators. Install the APK from [Releases](https://github.com/Droid-Deck/DroidDeck/releases), install the Linux runtime, then press **Play** and sign in. Steam downloads on first launch. Install **Desktop & apps** to use the desktop and emulators. The **Store** installs Linux apps and games from Flathub (ARM64 builds) with Flatpak; see [docs/development/flatpak.md](docs/development/flatpak.md).
|
||||
Use Android 9 or newer on a supported Adreno device (730 or newer, or 8xx). Adreno 6xx is experimental: DirectX 11 uses DXVK 2 and may run, and DirectX 12 games can still crash. Mali, Xclipse, PowerVR, and Adreno 710 are unsupported. No root is required. Allow about 3 GB for the runtime and 1.1 GB more for the desktop and emulators. Install the APK from [Releases](https://github.com/Droid-Deck/DroidDeck/releases), install the Linux runtime, then press **Play** and sign in. Steam downloads on first launch. Install **Desktop & apps** to use the desktop and emulators. The **Store** installs Linux apps and games from Flathub (ARM64 builds) with Flatpak; with additional options to install Appimages and set up scripts.
|
||||
|
||||
On Android 12+, if Steam exits without a log, turn off **Restrict child processes** in Developer options.
|
||||
Before Steam launches, you must turn off **Restrict child processes** in Developer options. If this option is not available in developer settings (Android 12 and 13 devices), first launch of Steam will present a "Fix it for me" button, which will help automate the setup process.
|
||||
|
||||
## Community
|
||||
|
||||
@@ -31,4 +33,4 @@ Compatibility and performance vary by device; hardware validation is limited. De
|
||||
|
||||
## Credits and licence
|
||||
|
||||
GPL-3.0. Runtime, shim, input, and controller work build on WinNative and Bannerlator (maxjivi05). LSFG frame generation is from the work of Camille LaVey and the [Eden](https://eden-emu.dev) emulator project, following [lsfg-vk](https://github.com/PancakeTAS/lsfg-vk), ported to WinNative and DroidDeck by [@maxjivi05](https://github.com/maxjivi05); it needs your own copy of [Lossless Scaling](https://store.steampowered.com/app/993090/) and ships none of its shaders. See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve.
|
||||
GPL-3.0. Runtime, shim, input, and controller work build on WinNative and Bannerlator (maxjivi05). LSFG frame generation is from the work of Camille LaVey and the [Eden](https://eden-emu.dev) emulator project, following [lsfg-vk](https://github.com/PancakeTAS/lsfg-vk), ported to WinNative and DroidDeck by [@maxjivi05](https://github.com/maxjivi05); it needs your own copy of [Lossless Scaling](https://store.steampowered.com/app/993090/) and ships none of its shaders. x86 AppImages, and any whose own runtime cannot unpack them, are unpacked with [uruntime](https://github.com/VHSgunzo/uruntime) by VHSgunzo (MIT), shipped unmodified with its licence. See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve.
|
||||
+14
-2
@@ -79,8 +79,8 @@ android {
|
||||
// file out of its own data directory, and this app's entire job is to exec a rootfs:
|
||||
// proot, gamescope, the Steam client and everything it launches all live under files/.
|
||||
targetSdkVersion 28
|
||||
versionCode 10
|
||||
versionName "0.3.0"
|
||||
versionCode 11
|
||||
versionName "0.3.1"
|
||||
buildConfigField "String", "BUILD_LABEL", "\"${escapedBuildLabel}\""
|
||||
buildConfigField "String", "SOURCE_COMMIT", "\"${sourceCommit}\""
|
||||
buildConfigField "int", "PR_NUMBER", "${prNumber}"
|
||||
@@ -176,10 +176,22 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
// Session scripts must follow this checkout even when the native asset bundle is cached.
|
||||
// A stale local bundle can omit a newly required helper on a fresh runtime install.
|
||||
def stageSessionScripts = tasks.register('stageSessionScripts', Copy) {
|
||||
from(rootProject.file('tools/linuxfs/overlay')) {
|
||||
include 'usr/local/bin/droiddeck-*', 'usr/local/bin/steam-compatibility', 'usr/bin/**'
|
||||
}
|
||||
into(layout.projectDirectory.dir('src/main/assets/linuxfs'))
|
||||
}
|
||||
tasks.named('preBuild').configure { dependsOn(stageSessionScripts) }
|
||||
|
||||
dependencies {
|
||||
// The runtime tarball is a zstd-compressed tar.
|
||||
implementation 'com.github.luben:zstd-jni:1.5.2-3@aar'
|
||||
implementation 'org.apache.commons:commons-compress:1.27.1'
|
||||
// Bannerlator's Windows components come as .tar.xz (WinComponents).
|
||||
implementation 'org.tukaani:xz:1.10'
|
||||
// The UI: every menu, dialog and overlay is Compose. The session's SurfaceView and the
|
||||
// on-screen pad are Views underneath it, which is what they are in every Compose app.
|
||||
implementation platform('androidx.compose:compose-bom:2024.02.00')
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application>
|
||||
<!-- Debug builds only. Android grants DUMP to adb shell; release APKs have no bridge. -->
|
||||
<activity
|
||||
android:name=".agent.AgentStartActivity"
|
||||
android:exported="true"
|
||||
android:excludeFromRecents="true"
|
||||
android:noHistory="true"
|
||||
android:permission="android.permission.DUMP"
|
||||
android:theme="@style/SessionTheme" />
|
||||
<provider
|
||||
android:name=".agent.AgentBridgeProvider"
|
||||
android:authorities="${applicationId}.agent"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.DUMP"
|
||||
android:grantUriPermissions="false" />
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -13,6 +13,10 @@
|
||||
download from being throttled to nothing by the radio's power-save. -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<!-- Optional Wi-Fi discovery: requested only from Steam settings after an explanation. -->
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
<!-- Ends background processes of another app that holds the Steam login. GameHub starts
|
||||
itself with the phone via boot receivers and signs this client out seconds after every
|
||||
@@ -57,6 +61,20 @@
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="droiddeck" android:host="game" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.CREATE_SHORTCUT" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="com.droiddeck.launcher.LAUNCH_GAME" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Android owns the Home key. This small translucent Home handler routes the resulting
|
||||
@@ -109,10 +127,19 @@
|
||||
android:screenOrientation="sensorLandscape"
|
||||
android:theme="@style/AppTheme" />
|
||||
|
||||
<activity
|
||||
android:name=".agent.AgentStartActivity"
|
||||
android:exported="true"
|
||||
android:excludeFromRecents="true"
|
||||
android:noHistory="true"
|
||||
android:permission="android.permission.DUMP"
|
||||
android:theme="@style/SessionTheme" />
|
||||
|
||||
<activity
|
||||
android:name=".SessionActivity"
|
||||
android:exported="false"
|
||||
android:launchMode="singleTop"
|
||||
android:supportsPictureInPicture="true"
|
||||
android:launchMode="singleTask"
|
||||
android:theme="@style/SessionTheme"
|
||||
android:preferMinimalPostProcessing="true"
|
||||
android:screenOrientation="sensorLandscape"
|
||||
@@ -158,5 +185,11 @@
|
||||
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
|
||||
</intent-filter>
|
||||
</provider>
|
||||
<provider
|
||||
android:name=".agent.AgentBridgeProvider"
|
||||
android:authorities="${applicationId}.agent"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.DUMP"
|
||||
android:grantUriPermissions="false" />
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -44,6 +44,11 @@ add_subdirectory(waylandcomp)
|
||||
add_library(sdnative SHARED sdnative/sdnative.c)
|
||||
target_link_libraries(sdnative log)
|
||||
|
||||
# The device info window's Vulkan section: the phone's own driver, asked once through the system
|
||||
# loader (opened with dlopen, so a phone without Vulkan still loads the app).
|
||||
add_library(deviceinfo SHARED deviceinfo/vkinfo.c)
|
||||
target_link_libraries(deviceinfo dl)
|
||||
|
||||
# Rebuild Termux terminal-emulator's PTY JNI helper with the app's NDK instead of carrying its
|
||||
# prebuilt libtermux.so with a redundant libstdc++.so dependency.
|
||||
add_library(termux SHARED termux/termux.c)
|
||||
@@ -0,0 +1,112 @@
|
||||
// What the phone's own Vulkan driver says about its GPU, for the app's device info window: one
|
||||
// instance on the system loader, the first physical device's properties, a few limits and the
|
||||
// extensions DXVK and VKD3D-Proton lean on. Returned as "key=value" lines; nothing is kept open.
|
||||
#include <dlfcn.h>
|
||||
#include <jni.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#define VK_NO_PROTOTYPES
|
||||
#include <vulkan/vulkan.h>
|
||||
|
||||
#define LINE(...) do { if (used < sizeof(out)) used += snprintf(out + used, sizeof(out) - used, __VA_ARGS__); } while (0)
|
||||
|
||||
static const char *const EXTENSIONS[] = {
|
||||
"VK_EXT_robustness2", "VK_EXT_transform_feedback", "VK_EXT_custom_border_color",
|
||||
"VK_KHR_dynamic_rendering", "VK_EXT_extended_dynamic_state3", "VK_EXT_descriptor_indexing",
|
||||
"VK_KHR_timeline_semaphore", "VK_EXT_mesh_shader", "VK_KHR_ray_query",
|
||||
};
|
||||
|
||||
static const char *type_name(VkPhysicalDeviceType type) {
|
||||
switch (type) {
|
||||
case VK_PHYSICAL_DEVICE_TYPE_INTEGRATED_GPU: return "integrated GPU";
|
||||
case VK_PHYSICAL_DEVICE_TYPE_DISCRETE_GPU: return "discrete GPU";
|
||||
case VK_PHYSICAL_DEVICE_TYPE_VIRTUAL_GPU: return "virtual GPU";
|
||||
case VK_PHYSICAL_DEVICE_TYPE_CPU: return "CPU";
|
||||
default: return "other";
|
||||
}
|
||||
}
|
||||
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_com_droiddeck_launcher_gpu_VulkanInfo_nativeQuery(JNIEnv *env, jclass clazz) {
|
||||
static char out[4096];
|
||||
size_t used = 0;
|
||||
out[0] = 0;
|
||||
void *lib = dlopen("libvulkan.so", RTLD_NOW | RTLD_LOCAL);
|
||||
if (!lib) { LINE("error=no Vulkan loader\n"); return (*env)->NewStringUTF(env, out); }
|
||||
PFN_vkGetInstanceProcAddr gipa = (PFN_vkGetInstanceProcAddr) dlsym(lib, "vkGetInstanceProcAddr");
|
||||
PFN_vkCreateInstance create = gipa ? (PFN_vkCreateInstance) gipa(NULL, "vkCreateInstance") : NULL;
|
||||
PFN_vkEnumerateInstanceVersion instanceVersion = gipa ? (PFN_vkEnumerateInstanceVersion) gipa(NULL, "vkEnumerateInstanceVersion") : NULL;
|
||||
if (!create) { LINE("error=no vkCreateInstance\n"); dlclose(lib); return (*env)->NewStringUTF(env, out); }
|
||||
uint32_t loader = VK_API_VERSION_1_0;
|
||||
if (instanceVersion) instanceVersion(&loader);
|
||||
LINE("loader=%u.%u.%u\n", VK_API_VERSION_MAJOR(loader), VK_API_VERSION_MINOR(loader), VK_API_VERSION_PATCH(loader));
|
||||
|
||||
VkApplicationInfo app = { .sType = VK_STRUCTURE_TYPE_APPLICATION_INFO, .pApplicationName = "DroidDeck device info",
|
||||
.apiVersion = loader >= VK_API_VERSION_1_1 ? VK_API_VERSION_1_1 : VK_API_VERSION_1_0 };
|
||||
VkInstanceCreateInfo info = { .sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO, .pApplicationInfo = &app };
|
||||
VkInstance instance = VK_NULL_HANDLE;
|
||||
if (create(&info, NULL, &instance) != VK_SUCCESS) { LINE("error=vkCreateInstance failed\n"); dlclose(lib); return (*env)->NewStringUTF(env, out); }
|
||||
|
||||
#define GET(name) PFN_##name name = (PFN_##name) gipa(instance, #name)
|
||||
GET(vkDestroyInstance); GET(vkEnumeratePhysicalDevices); GET(vkGetPhysicalDeviceProperties);
|
||||
GET(vkGetPhysicalDeviceProperties2); GET(vkGetPhysicalDeviceMemoryProperties);
|
||||
GET(vkEnumerateDeviceExtensionProperties); GET(vkGetPhysicalDeviceFeatures);
|
||||
uint32_t count = 1;
|
||||
VkPhysicalDevice device = VK_NULL_HANDLE;
|
||||
VkResult listed = vkEnumeratePhysicalDevices ? vkEnumeratePhysicalDevices(instance, &count, &device) : VK_ERROR_INITIALIZATION_FAILED;
|
||||
if ((listed != VK_SUCCESS && listed != VK_INCOMPLETE) || count == 0 || !device || !vkGetPhysicalDeviceProperties) {
|
||||
LINE("error=no Vulkan device\n");
|
||||
} else {
|
||||
VkPhysicalDeviceProperties p;
|
||||
vkGetPhysicalDeviceProperties(device, &p);
|
||||
LINE("device=%s\n", p.deviceName);
|
||||
LINE("type=%s\n", type_name(p.deviceType));
|
||||
LINE("api=%u.%u.%u\n", VK_API_VERSION_MAJOR(p.apiVersion), VK_API_VERSION_MINOR(p.apiVersion), VK_API_VERSION_PATCH(p.apiVersion));
|
||||
// Qualcomm packs its driver version as 10.10.12 bits, the same layout as VK_MAKE_VERSION.
|
||||
LINE("driver_version=%u.%u.%u\n", p.driverVersion >> 22, (p.driverVersion >> 12) & 0x3ff, p.driverVersion & 0xfff);
|
||||
LINE("vendor_id=0x%04x\ndevice_id=0x%08x\n", p.vendorID, p.deviceID);
|
||||
LINE("max_image_2d=%u\n", p.limits.maxImageDimension2D);
|
||||
|
||||
// Driver name and build string (Vulkan 1.2 or VK_KHR_driver_properties).
|
||||
uint32_t extCount = 0;
|
||||
if (vkEnumerateDeviceExtensionProperties) vkEnumerateDeviceExtensionProperties(device, NULL, &extCount, NULL);
|
||||
static VkExtensionProperties exts[512];
|
||||
if (extCount > 512) extCount = 512;
|
||||
if (extCount && vkEnumerateDeviceExtensionProperties) vkEnumerateDeviceExtensionProperties(device, NULL, &extCount, exts);
|
||||
int driverProps = p.apiVersion >= VK_API_VERSION_1_2;
|
||||
for (uint32_t i = 0; i < extCount; i++)
|
||||
if (!strcmp(exts[i].extensionName, "VK_KHR_driver_properties")) driverProps = 1;
|
||||
if (driverProps && vkGetPhysicalDeviceProperties2) {
|
||||
VkPhysicalDeviceDriverProperties driver = { .sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_DRIVER_PROPERTIES };
|
||||
VkPhysicalDeviceProperties2 p2 = { .sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PROPERTIES_2, .pNext = &driver };
|
||||
vkGetPhysicalDeviceProperties2(device, &p2);
|
||||
LINE("driver_name=%s\n", driver.driverName);
|
||||
LINE("driver_info=%s\n", driver.driverInfo);
|
||||
LINE("conformance=%u.%u.%u.%u\n", driver.conformanceVersion.major, driver.conformanceVersion.minor,
|
||||
driver.conformanceVersion.subminor, driver.conformanceVersion.patch);
|
||||
}
|
||||
LINE("extension_count=%u\n", extCount);
|
||||
for (size_t e = 0; e < sizeof(EXTENSIONS) / sizeof(*EXTENSIONS); e++) {
|
||||
int has = 0;
|
||||
for (uint32_t i = 0; i < extCount && !has; i++) has = !strcmp(exts[i].extensionName, EXTENSIONS[e]);
|
||||
LINE("ext.%s=%s\n", EXTENSIONS[e], has ? "yes" : "no");
|
||||
}
|
||||
if (vkGetPhysicalDeviceFeatures) {
|
||||
VkPhysicalDeviceFeatures f;
|
||||
vkGetPhysicalDeviceFeatures(device, &f);
|
||||
LINE("geometry_shader=%s\ntessellation_shader=%s\ntexture_bc=%s\n",
|
||||
f.geometryShader ? "yes" : "no", f.tessellationShader ? "yes" : "no", f.textureCompressionBC ? "yes" : "no");
|
||||
}
|
||||
if (vkGetPhysicalDeviceMemoryProperties) {
|
||||
VkPhysicalDeviceMemoryProperties m;
|
||||
vkGetPhysicalDeviceMemoryProperties(device, &m);
|
||||
VkDeviceSize largest = 0;
|
||||
for (uint32_t i = 0; i < m.memoryHeapCount; i++)
|
||||
if ((m.memoryHeaps[i].flags & VK_MEMORY_HEAP_DEVICE_LOCAL_BIT) && m.memoryHeaps[i].size > largest) largest = m.memoryHeaps[i].size;
|
||||
LINE("device_heap_mb=%llu\n", (unsigned long long) (largest >> 20));
|
||||
}
|
||||
}
|
||||
if (vkDestroyInstance) vkDestroyInstance(instance, NULL);
|
||||
dlclose(lib);
|
||||
return (*env)->NewStringUTF(env, out);
|
||||
}
|
||||
@@ -54,7 +54,7 @@
|
||||
#include <sys/un.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#define EXPORT __attribute__((visibility("default"))) extern "C"
|
||||
#define EXPORT extern "C" __attribute__((visibility("default")))
|
||||
|
||||
// glibc declares the request as unsigned long; bionic as int. The same source also serves the
|
||||
// Linux runtime's preload (tools/linuxfs), where Steam and SDL run against glibc.
|
||||
@@ -1717,6 +1717,81 @@ __attribute__((visibility("hidden"))) static std::string deck_listing_for(const
|
||||
return std::string(listing) + "/" + names[which];
|
||||
}
|
||||
|
||||
#ifdef __GLIBC__
|
||||
// sd-device's path chase now requires a mount ID, which statx only supplies from Linux 5.8.
|
||||
// Older handheld kernels still publish the real ID in fdinfo. Scope this compatibility path to
|
||||
// libudev/libsystemd in the Deck-reading Steam client, and never invent a unique mount ID.
|
||||
__attribute__((visibility("hidden"))) static bool deck_device_library(void *caller) {
|
||||
Dl_info info;
|
||||
if (!dladdr(caller, &info) || !info.dli_fname) return false;
|
||||
const char *name = strrchr(info.dli_fname, '/');
|
||||
name = name ? name + 1 : info.dli_fname;
|
||||
return !strncmp(name, "libudev.so", 10) || !strncmp(name, "libsystemd.so", 13);
|
||||
}
|
||||
|
||||
static int real_fstat(int fd, struct stat *buf);
|
||||
|
||||
__attribute__((visibility("hidden"))) static bool deck_mount_id_from_fdinfo(
|
||||
int dirfd, const char *path, int flags, struct statx *buf) {
|
||||
static auto real_openat = reinterpret_cast<decltype(&::openat)>(dlsym(RTLD_NEXT, "openat"));
|
||||
int target;
|
||||
if (!path || !*path) {
|
||||
if (!(flags & AT_EMPTY_PATH)) return false;
|
||||
target = dirfd == AT_FDCWD ? real_openat(AT_FDCWD, ".", O_PATH | O_CLOEXEC) :
|
||||
syscall(SYS_fcntl, dirfd, F_DUPFD_CLOEXEC, 0);
|
||||
} else {
|
||||
target = real_openat(dirfd, path, O_PATH | O_CLOEXEC |
|
||||
((flags & AT_SYMLINK_NOFOLLOW) ? O_NOFOLLOW : 0));
|
||||
}
|
||||
if (target < 0) return false;
|
||||
struct stat st;
|
||||
// Pin the inode and check it still matches the successful statx before adding information.
|
||||
bool same = real_fstat(target, &st) == 0 && (buf->stx_mask & STATX_INO) &&
|
||||
st.st_ino == buf->stx_ino && major(st.st_dev) == buf->stx_dev_major &&
|
||||
minor(st.st_dev) == buf->stx_dev_minor;
|
||||
char name[64];
|
||||
snprintf(name, sizeof(name), "/proc/self/fdinfo/%d", target);
|
||||
int info = same ? real_openat(AT_FDCWD, name, O_RDONLY | O_CLOEXEC) : -1;
|
||||
char value[512];
|
||||
ssize_t length = info >= 0 ? syscall(SYS_read, info, value, sizeof(value) - 1) : -1;
|
||||
if (info >= 0) syscall(SYS_close, info);
|
||||
syscall(SYS_close, target);
|
||||
if (length < 0) return false;
|
||||
value[length] = '\0';
|
||||
for (const char *line = value; line && *line;) {
|
||||
unsigned long long id;
|
||||
if (sscanf(line, "mnt_id: %llu", &id) == 1 && id > 0) {
|
||||
buf->stx_mnt_id = id;
|
||||
buf->stx_mask |= STATX_MNT_ID;
|
||||
return true;
|
||||
}
|
||||
const char *next = strchr(line, '\n');
|
||||
line = next ? next + 1 : nullptr;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
EXPORT int statx(int dirfd, const char *path, int flags, unsigned mask, struct statx *buf) {
|
||||
static auto real = reinterpret_cast<decltype(&::statx)>(dlsym(RTLD_NEXT, "statx"));
|
||||
int result = real(dirfd, path, flags, mask, buf);
|
||||
int saved_errno = errno;
|
||||
constexpr unsigned unique_mount_id = 0x4000; // STATX_MNT_ID_UNIQUE, newer than build headers.
|
||||
if (result == 0 && (mask & STATX_MNT_ID) &&
|
||||
!(buf->stx_mask & (STATX_MNT_ID | unique_mount_id)) && fake_deck_enabled() &&
|
||||
process_is_steam_client() && deck_device_library(__builtin_return_address(0))) {
|
||||
bool recovered = deck_mount_id_from_fdinfo(dirfd, path, flags, buf);
|
||||
static std::atomic<bool> noted_success{false}, noted_failure{false};
|
||||
if (!(recovered ? noted_success : noted_failure).exchange(true))
|
||||
Logger::log("deck discovery: statx mount id %s for %s (returned mask=0x%x id=%llu)\n",
|
||||
recovered ? "recovered from fdinfo" : "unavailable in fdinfo",
|
||||
path && *path ? path : "(fd)", buf->stx_mask,
|
||||
static_cast<unsigned long long>(buf->stx_mnt_id));
|
||||
}
|
||||
errno = saved_errno;
|
||||
return result;
|
||||
}
|
||||
#endif
|
||||
|
||||
EXPORT DIR *opendir(const char *name) {
|
||||
static auto real = reinterpret_cast<DIR *(*)(const char *)>(dlsym(RTLD_NEXT, "opendir"));
|
||||
DIR *dir = real(name);
|
||||
@@ -1876,9 +1951,8 @@ EXPORT int openat(int dirfd, const char *pathname, int flags, ...) {
|
||||
return fd;
|
||||
}
|
||||
|
||||
EXPORT int stat(const char *pathname, struct stat *statbuf) {
|
||||
static auto my_stat = reinterpret_cast<decltype(&::stat)>(dlsym(RTLD_NEXT, "stat"));
|
||||
|
||||
template <typename S, typename Real>
|
||||
static int fake_stat_path(const char *pathname, S *statbuf, Real real) {
|
||||
if (is_withdrawn_pad_path(pathname) || (is_deck_hidraw_path(pathname) && !process_is_steam_client())) {
|
||||
errno = ENOENT;
|
||||
return -1;
|
||||
@@ -1914,7 +1988,7 @@ EXPORT int stat(const char *pathname, struct stat *statbuf) {
|
||||
}
|
||||
}
|
||||
|
||||
int ret = my_stat(pathname, statbuf);
|
||||
int ret = real(pathname, statbuf);
|
||||
|
||||
if (ret == 0 && event && get_event_number(event) >= 0) {
|
||||
statbuf->st_mode = (statbuf->st_mode & ~S_IFMT) | S_IFCHR;
|
||||
@@ -1927,10 +2001,9 @@ EXPORT int stat(const char *pathname, struct stat *statbuf) {
|
||||
return ret;
|
||||
}
|
||||
|
||||
EXPORT int fstat(int fd, struct stat *buf) {
|
||||
static auto my_fstat = reinterpret_cast<decltype(&::fstat)>(dlsym(RTLD_NEXT, "fstat"));
|
||||
|
||||
int ret = my_fstat(fd, buf);
|
||||
template <typename S, typename Real>
|
||||
static int fake_stat_fd(int fd, S *buf, Real real) {
|
||||
int ret = real(fd, buf);
|
||||
|
||||
std::lock_guard<std::recursive_mutex> guard(controller_mutex());
|
||||
if (ret == 0 && deck_map().count(fd)) {
|
||||
@@ -1947,6 +2020,36 @@ EXPORT int fstat(int fd, struct stat *buf) {
|
||||
return ret;
|
||||
}
|
||||
|
||||
static int real_stat(const char *pathname, struct stat *statbuf) {
|
||||
static auto fn = reinterpret_cast<int (*)(const char *, struct stat *)>(dlsym(RTLD_NEXT, "stat"));
|
||||
if (fn) return fn(pathname, statbuf);
|
||||
#ifdef _STAT_VER
|
||||
static auto xfn = reinterpret_cast<int (*)(int, const char *, struct stat *)>(dlsym(RTLD_NEXT, "__xstat"));
|
||||
if (xfn) return xfn(_STAT_VER, pathname, statbuf);
|
||||
#endif
|
||||
errno = ENOSYS;
|
||||
return -1;
|
||||
}
|
||||
|
||||
static int real_fstat(int fd, struct stat *buf) {
|
||||
static auto fn = reinterpret_cast<int (*)(int, struct stat *)>(dlsym(RTLD_NEXT, "fstat"));
|
||||
if (fn) return fn(fd, buf);
|
||||
#ifdef _STAT_VER
|
||||
static auto xfn = reinterpret_cast<int (*)(int, int, struct stat *)>(dlsym(RTLD_NEXT, "__fxstat"));
|
||||
if (xfn) return xfn(_STAT_VER, fd, buf);
|
||||
#endif
|
||||
errno = ENOSYS;
|
||||
return -1;
|
||||
}
|
||||
|
||||
EXPORT int stat(const char *pathname, struct stat *statbuf) {
|
||||
return fake_stat_path(pathname, statbuf, real_stat);
|
||||
}
|
||||
|
||||
EXPORT int fstat(int fd, struct stat *buf) {
|
||||
return fake_stat_fd(fd, buf, real_fstat);
|
||||
}
|
||||
|
||||
EXPORT int access(const char *pathname, int mode) {
|
||||
static auto my_access = reinterpret_cast<decltype(&::access)>(dlsym(RTLD_NEXT, "access"));
|
||||
if (fake_uinput_enabled() && is_uinput_path(pathname)) return 0;
|
||||
@@ -2792,31 +2895,57 @@ EXPORT int openat64(int dirfd, const char *pathname, int flags, ...) {
|
||||
return openat(dirfd, pathname, flags, mode);
|
||||
}
|
||||
|
||||
static int real_stat64(const char *pathname, struct stat64 *statbuf) {
|
||||
static auto fn = reinterpret_cast<int (*)(const char *, struct stat64 *)>(dlsym(RTLD_NEXT, "stat64"));
|
||||
if (fn) return fn(pathname, statbuf);
|
||||
#ifdef _STAT_VER
|
||||
static auto xfn = reinterpret_cast<int (*)(int, const char *, struct stat64 *)>(dlsym(RTLD_NEXT, "__xstat64"));
|
||||
if (xfn) return xfn(_STAT_VER, pathname, statbuf);
|
||||
#endif
|
||||
errno = ENOSYS;
|
||||
return -1;
|
||||
}
|
||||
|
||||
static int real_fstat64(int fd, struct stat64 *buf) {
|
||||
static auto fn = reinterpret_cast<int (*)(int, struct stat64 *)>(dlsym(RTLD_NEXT, "fstat64"));
|
||||
if (fn) return fn(fd, buf);
|
||||
#ifdef _STAT_VER
|
||||
static auto xfn = reinterpret_cast<int (*)(int, int, struct stat64 *)>(dlsym(RTLD_NEXT, "__fxstat64"));
|
||||
if (xfn) return xfn(_STAT_VER, fd, buf);
|
||||
#endif
|
||||
errno = ENOSYS;
|
||||
return -1;
|
||||
}
|
||||
|
||||
EXPORT int stat64(const char *pathname, struct stat64 *statbuf) {
|
||||
return stat(pathname, reinterpret_cast<struct stat *>(statbuf));
|
||||
return fake_stat_path(pathname, statbuf, real_stat64);
|
||||
}
|
||||
|
||||
EXPORT int fstat64(int fd, struct stat64 *buf) {
|
||||
return fstat(fd, reinterpret_cast<struct stat *>(buf));
|
||||
return fake_stat_fd(fd, buf, real_fstat64);
|
||||
}
|
||||
|
||||
EXPORT int __xstat(int version, const char *pathname, struct stat *statbuf) {
|
||||
(void)version;
|
||||
return stat(pathname, statbuf);
|
||||
static auto real = reinterpret_cast<int (*)(int, const char *, struct stat *)>(dlsym(RTLD_NEXT, "__xstat"));
|
||||
if (!real) return stat(pathname, statbuf);
|
||||
return fake_stat_path(pathname, statbuf, [version](const char *path, struct stat *buf) { return real(version, path, buf); });
|
||||
}
|
||||
|
||||
EXPORT int __xstat64(int version, const char *pathname, struct stat64 *statbuf) {
|
||||
(void)version;
|
||||
return stat(pathname, reinterpret_cast<struct stat *>(statbuf));
|
||||
static auto real = reinterpret_cast<int (*)(int, const char *, struct stat64 *)>(dlsym(RTLD_NEXT, "__xstat64"));
|
||||
if (!real) return stat64(pathname, statbuf);
|
||||
return fake_stat_path(pathname, statbuf, [version](const char *path, struct stat64 *buf) { return real(version, path, buf); });
|
||||
}
|
||||
|
||||
EXPORT int __fxstat(int version, int fd, struct stat *buf) {
|
||||
(void)version;
|
||||
return fstat(fd, buf);
|
||||
static auto real = reinterpret_cast<int (*)(int, int, struct stat *)>(dlsym(RTLD_NEXT, "__fxstat"));
|
||||
if (!real) return fstat(fd, buf);
|
||||
return fake_stat_fd(fd, buf, [version](int f, struct stat *b) { return real(version, f, b); });
|
||||
}
|
||||
|
||||
EXPORT int __fxstat64(int version, int fd, struct stat64 *buf) {
|
||||
(void)version;
|
||||
return fstat(fd, reinterpret_cast<struct stat *>(buf));
|
||||
static auto real = reinterpret_cast<int (*)(int, int, struct stat64 *)>(dlsym(RTLD_NEXT, "__fxstat64"));
|
||||
if (!real) return fstat64(fd, buf);
|
||||
return fake_stat_fd(fd, buf, [version](int f, struct stat64 *b) { return real(version, f, b); });
|
||||
}
|
||||
#endif
|
||||
@@ -4,7 +4,7 @@
|
||||
# generated/ (no host wayland-scanner needed at app build time). Built by the app
|
||||
# (add_subdirectory from ../CMakeLists.txt) and on its own by standalone/ for
|
||||
# build-waylandcomp.yml.
|
||||
add_library(bannerwayland SHARED
|
||||
add_library(droiddeckwayland SHARED
|
||||
src/compositor.c
|
||||
src/vk_present.c
|
||||
src/effects_chain.c
|
||||
@@ -12,7 +12,7 @@ add_library(bannerwayland SHARED
|
||||
src/sc_layer.c
|
||||
src/ahb_swapchain.c
|
||||
src/waylandcomp_jni.c
|
||||
src/banner_ext.c
|
||||
src/droiddeck_ext.c
|
||||
src/wl_dmabuf.c
|
||||
src/wl_clipboard.c
|
||||
src/wl_text_input.c
|
||||
@@ -36,9 +36,9 @@ add_library(bannerwayland SHARED
|
||||
src/framegen_jni.c
|
||||
)
|
||||
# Warnings on for the compositor's own C. They do not fail the build; they are there to be read.
|
||||
target_compile_options(bannerwayland PRIVATE
|
||||
target_compile_options(droiddeckwayland PRIVATE
|
||||
$<$<COMPILE_LANGUAGE:C>:-Wall -Wextra -Wno-unused-parameter -Wno-missing-field-initializers>)
|
||||
target_include_directories(bannerwayland PRIVATE
|
||||
target_include_directories(droiddeckwayland PRIVATE
|
||||
generated
|
||||
prebuilt/include
|
||||
src
|
||||
@@ -54,56 +54,56 @@ target_include_directories(bannerwayland PRIVATE
|
||||
# framegen tree and the vendored DXBC translator (thirdparty/dxbc, for the LSFG cache format),
|
||||
# which only the app build has: the standalone build (standalone/) gets the stub, which reports
|
||||
# every engine unavailable and generates nothing.
|
||||
set(BANNER_FRAMEGEN_LSFG_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../framegen/lsfg)
|
||||
set(BANNER_FRAMEGEN_WINFG_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../framegen/winfg)
|
||||
if (TARGET dxbc AND EXISTS ${BANNER_FRAMEGEN_LSFG_DIR}/lsfg_engine.cpp
|
||||
AND EXISTS ${BANNER_FRAMEGEN_WINFG_DIR}/winfg_engine.cpp)
|
||||
message(STATUS "bannerwayland: frame-generation engines built in (LSFG Native, Win-FG Native)")
|
||||
target_sources(bannerwayland PRIVATE
|
||||
set(DROIDDECK_FRAMEGEN_LSFG_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../framegen/lsfg)
|
||||
set(DROIDDECK_FRAMEGEN_WINFG_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../framegen/winfg)
|
||||
if (TARGET dxbc AND EXISTS ${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_engine.cpp
|
||||
AND EXISTS ${DROIDDECK_FRAMEGEN_WINFG_DIR}/winfg_engine.cpp)
|
||||
message(STATUS "droiddeckwayland: frame-generation engines built in (LSFG Native, Win-FG Native)")
|
||||
target_sources(droiddeckwayland PRIVATE
|
||||
src/framegen_engine.cpp
|
||||
${BANNER_FRAMEGEN_LSFG_DIR}/lsfg_probe.cpp
|
||||
${BANNER_FRAMEGEN_LSFG_DIR}/lsfg_dll.cpp
|
||||
${BANNER_FRAMEGEN_LSFG_DIR}/lsfg_dxbc.cpp
|
||||
${BANNER_FRAMEGEN_LSFG_DIR}/lsfg_vkd.cpp
|
||||
${BANNER_FRAMEGEN_LSFG_DIR}/lsfg_common.cpp
|
||||
${BANNER_FRAMEGEN_LSFG_DIR}/lsfg_shaders.cpp
|
||||
${BANNER_FRAMEGEN_LSFG_DIR}/lsfg_mipmaps.cpp
|
||||
${BANNER_FRAMEGEN_LSFG_DIR}/lsfg_alpha.cpp
|
||||
${BANNER_FRAMEGEN_LSFG_DIR}/lsfg_beta.cpp
|
||||
${BANNER_FRAMEGEN_LSFG_DIR}/lsfg_gamma.cpp
|
||||
${BANNER_FRAMEGEN_LSFG_DIR}/lsfg_delta.cpp
|
||||
${BANNER_FRAMEGEN_LSFG_DIR}/lsfg_generate.cpp
|
||||
${BANNER_FRAMEGEN_LSFG_DIR}/lsfg_chain.cpp
|
||||
${BANNER_FRAMEGEN_LSFG_DIR}/lsfg_pacer.cpp
|
||||
${BANNER_FRAMEGEN_LSFG_DIR}/lsfg_engine.cpp
|
||||
${BANNER_FRAMEGEN_LSFG_DIR}/lsfg_governor.cpp
|
||||
${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_probe.cpp
|
||||
${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_dll.cpp
|
||||
${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_dxbc.cpp
|
||||
${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_vkd.cpp
|
||||
${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_common.cpp
|
||||
${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_shaders.cpp
|
||||
${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_mipmaps.cpp
|
||||
${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_alpha.cpp
|
||||
${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_beta.cpp
|
||||
${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_gamma.cpp
|
||||
${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_delta.cpp
|
||||
${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_generate.cpp
|
||||
${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_chain.cpp
|
||||
${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_pacer.cpp
|
||||
${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_engine.cpp
|
||||
${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_governor.cpp
|
||||
# The Lossless.dll import (validate / extract / cache) for the app's LsfgNative.
|
||||
${BANNER_FRAMEGEN_LSFG_DIR}/lsfg_jni.cpp
|
||||
${BANNER_FRAMEGEN_WINFG_DIR}/framegen.cpp
|
||||
${BANNER_FRAMEGEN_WINFG_DIR}/winfg_vkd.cpp
|
||||
${BANNER_FRAMEGEN_WINFG_DIR}/winfg_engine.cpp
|
||||
${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_jni.cpp
|
||||
${DROIDDECK_FRAMEGEN_WINFG_DIR}/framegen.cpp
|
||||
${DROIDDECK_FRAMEGEN_WINFG_DIR}/winfg_vkd.cpp
|
||||
${DROIDDECK_FRAMEGEN_WINFG_DIR}/winfg_engine.cpp
|
||||
)
|
||||
target_include_directories(bannerwayland PRIVATE
|
||||
target_include_directories(droiddeckwayland PRIVATE
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/.. # <thirdparty/...> as the frame-generation engines see it
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/../framegen # VulkanRendererContext.h (VkTable), lsfg/, winfg/
|
||||
)
|
||||
target_compile_options(bannerwayland PRIVATE
|
||||
target_compile_options(droiddeckwayland PRIVATE
|
||||
$<$<COMPILE_LANGUAGE:CXX>:-std=c++17 -fvisibility=hidden>)
|
||||
target_compile_definitions(bannerwayland PRIVATE
|
||||
target_compile_definitions(droiddeckwayland PRIVATE
|
||||
WINFG_UPSTREAM="${WINFG_UPSTREAM}"
|
||||
WINFG_CHAIN_HASH="${WINFG_CHAIN_HASH}"
|
||||
)
|
||||
target_link_libraries(bannerwayland dxbc atomic)
|
||||
target_link_libraries(droiddeckwayland dxbc atomic)
|
||||
else()
|
||||
message(STATUS "bannerwayland: frame-generation engines NOT built in (standalone build): stub")
|
||||
target_sources(bannerwayland PRIVATE src/framegen_engine_stub.c)
|
||||
message(STATUS "droiddeckwayland: frame-generation engines NOT built in (standalone build): stub")
|
||||
target_sources(droiddeckwayland PRIVATE src/framegen_engine_stub.c)
|
||||
endif()
|
||||
add_library(wayland-server SHARED IMPORTED)
|
||||
set_target_properties(wayland-server PROPERTIES IMPORTED_LOCATION
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/prebuilt/lib/libwayland-server.so)
|
||||
# Vulkan is loaded at runtime via adrenotools (Turnip), not linked directly - so the
|
||||
# compositor uses Turnip's driver, which supports dmabuf import (drm_format_modifier).
|
||||
target_link_libraries(bannerwayland
|
||||
target_link_libraries(droiddeckwayland
|
||||
wayland-server
|
||||
adrenotools
|
||||
dl
|
||||
|
||||
@@ -29,7 +29,7 @@ the dma-buf fd through `zwp_linux_dmabuf_v1` as today (so the blit path stays as
|
||||
The host half - one `ASurfaceControl` layer per fullscreen window, geometry from the fullscreen
|
||||
mode, release fences from `ASurfaceTransaction_setOnComplete`, black base surface, HUD/pointer as
|
||||
Android views above - is implemented in this branch as a gated prototype (`sc_layer.c`,
|
||||
`BANNER_WAYLAND_ZERO_COPY=1`) with a compositor-owned AHB pool and one blit, so the
|
||||
`DROIDDECK_WAYLAND_ZERO_COPY=1`) with a compositor-owned AHB pool and one blit, so the
|
||||
SurfaceFlinger side can be measured on the device before the driver work starts.
|
||||
|
||||
---
|
||||
@@ -240,13 +240,13 @@ Prerequisite: a build of this branch, container on Wayland, the AIO Graphics Tes
|
||||
D3D12 tabs) as the workload, session log in `Download/Wayland-logs/`.
|
||||
|
||||
0. **UBWC modifier A/B (no layers).** Done on `feat/wayland-ubwc` (advertised when the compositor's
|
||||
driver imports it, `BANNER_WAYLAND_UBWC=0` = old linear-only list; see `WAYLAND_RUNTIME.md`
|
||||
driver imports it, `DROIDDECK_WAYLAND_UBWC=0` = old linear-only list; see `WAYLAND_RUNTIME.md`
|
||||
"Compressed (UBWC) game buffers"). Run the AIO Vulkan/D3D12 with and without. Expect
|
||||
`vulkan: … is presenting GPU frames through Wayland: WxH, format XB24, tiled (zero-copy)` and
|
||||
no `dmabuf import failed`. Records how much of the 20 % is the linear swapchain alone.
|
||||
1. **Layer mode smoke.** Put `BANNER_WAYLAND_ZERO_COPY=1` in the container's environment
|
||||
1. **Layer mode smoke.** Put `DROIDDECK_WAYLAND_ZERO_COPY=1` in the container's environment
|
||||
variables, launch the AIO fullscreen. Expected log lines (tag `layer`):
|
||||
`SurfaceControl "banner_wayland_game" created as a child of the screen surface`,
|
||||
`SurfaceControl "droiddeck_wayland_game" created as a child of the screen surface`,
|
||||
`pool buffer 1920x1080 UBWC (QCOM_COMPRESSED), stride 1920 px (gralloc handle 2 fds / N ints)`
|
||||
(or `linear` after `import of a UBWC … failed` - also a result: it tells whether the compositor
|
||||
Turnip accepts gralloc's UBWC pitch), `geometry: buffer 0,0-1920,1080 -> screen …`,
|
||||
@@ -254,7 +254,7 @@ D3D12 tabs) as the workload, session log in `Download/Wayland-logs/`.
|
||||
and the probe line `kernel exports sync_file fences from the game's dma-buf …` or
|
||||
`DMA_BUF_IOCTL_EXPORT_SYNC_FILE … failed (…)`. Picture correct, HUD and pointer on top, fullscreen
|
||||
mode changes move the layer (`geometry:` lines).
|
||||
2. **Composition type.** With the AIO running: `dumpsys SurfaceFlinger | grep -A3 banner_wayland_game`
|
||||
2. **Composition type.** With the AIO running: `dumpsys SurfaceFlinger | grep -A3 droiddeck_wayland_game`
|
||||
and the `GPU_TARGET` line of the pipe table. DEVICE/DEVICE on the game layer with an empty GPU
|
||||
target = the DPU does the scaling; CLIENT = no gain on this panel orientation → try the
|
||||
handheld's native orientation / a resolution equal to the panel's.
|
||||
@@ -271,7 +271,7 @@ harden: async fence instead of CPU wait, per-window layers, presentation feedbac
|
||||
`getLatchTime`); option (a) WSI + protocol + wcp = 2–3 days + 1 day device proving; option (c) =
|
||||
3–4 days + the same proving; (b) = not buildable.
|
||||
|
||||
## 6. What the prototype in this branch does (`BANNER_WAYLAND_ZERO_COPY=1`)
|
||||
## 6. What the prototype in this branch does (`DROIDDECK_WAYLAND_ZERO_COPY=1`)
|
||||
|
||||
Files: `src/sc_layer.{c,h}` (new), `src/vk_present.{c,h}` (`vkp_image_import_dmabuf` with a
|
||||
blit-destination role, `vkp_blit_image`, `vkp_update_map`, `vkp_map_draw`, `vkp_window`,
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* See ahb_swapchain.h and ZERO_COPY_SPIKE.md. */
|
||||
#define _GNU_SOURCE
|
||||
#include "ahb_swapchain.h"
|
||||
#include "banner_color.h"
|
||||
#include "banner_ext.h"
|
||||
#include "droiddeck_color.h"
|
||||
#include "droiddeck_ext.h"
|
||||
#include "sc_layer.h"
|
||||
#include "vk_present.h"
|
||||
#include "banner-ahb-v1-server-protocol.h"
|
||||
@@ -22,10 +22,10 @@
|
||||
#define MOD_QCOM_COMPRESSED 0x0500000000000001ULL /* DRM_FORMAT_MOD_QCOM_COMPRESSED (UBWC) */
|
||||
|
||||
/* linux/dma-buf.h (not in the NDK sysroot's older headers): sync_file export / import. */
|
||||
struct banner_dma_buf_sync_file { uint32_t flags; int32_t fd; };
|
||||
#define BANNER_DMA_BUF_SYNC_READ 1u
|
||||
#define BANNER_DMA_BUF_IOCTL_EXPORT_SYNC_FILE _IOWR('b', 2, struct banner_dma_buf_sync_file)
|
||||
#define BANNER_DMA_BUF_IOCTL_IMPORT_SYNC_FILE _IOW('b', 3, struct banner_dma_buf_sync_file)
|
||||
struct droiddeck_dma_buf_sync_file { uint32_t flags; int32_t fd; };
|
||||
#define DROIDDECK_DMA_BUF_SYNC_READ 1u
|
||||
#define DROIDDECK_DMA_BUF_IOCTL_EXPORT_SYNC_FILE _IOWR('b', 2, struct droiddeck_dma_buf_sync_file)
|
||||
#define DROIDDECK_DMA_BUF_IOCTL_IMPORT_SYNC_FILE _IOW('b', 3, struct droiddeck_dma_buf_sync_file)
|
||||
|
||||
extern volatile int g_zero_copy;
|
||||
|
||||
@@ -90,9 +90,9 @@ static void buf_free(struct ahb_buf *ab) {
|
||||
if (ab->fence_src) { wl_event_source_remove(ab->fence_src); ab->fence_src = NULL; }
|
||||
if (ab->fence_fd >= 0) { close(ab->fence_fd); ab->fence_fd = -1; }
|
||||
wl_list_remove(&ab->link);
|
||||
*banner_dmabuf_ahb_slot(ab->b) = NULL;
|
||||
*droiddeck_dmabuf_ahb_slot(ab->b) = NULL;
|
||||
AHardwareBuffer_release(ab->ahb);
|
||||
banner_dmabuf_unref(ab->b);
|
||||
droiddeck_dmabuf_unref(ab->b);
|
||||
free(ab);
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ static void on_buffer_resource_destroyed(struct wl_listener *l, void *data) {
|
||||
|
||||
static void send_deferred_release(struct ahb_buf *ab) {
|
||||
if (ab->release_pending && ab->resource)
|
||||
banner_release_buffer(ab->surface, ab->resource, ab->surface != NULL, ab->deferred_ns);
|
||||
droiddeck_release_buffer(ab->surface, ab->resource, ab->surface != NULL, ab->deferred_ns);
|
||||
ab->release_pending = 0;
|
||||
ab->surface = NULL;
|
||||
}
|
||||
@@ -138,14 +138,14 @@ static void handle_released(uint64_t id, int fd) {
|
||||
if (!ab) { if (fd >= 0) close(fd); return; }
|
||||
ab->on_layer = 0;
|
||||
if (fd >= 0) {
|
||||
struct banner_dma_buf_sync_file imp = {.flags = BANNER_DMA_BUF_SYNC_READ, .fd = fd};
|
||||
int dmabuf_fd = banner_dmabuf_fd(ab->b);
|
||||
if (dmabuf_fd >= 0 && ioctl(dmabuf_fd, BANNER_DMA_BUF_IOCTL_IMPORT_SYNC_FILE, &imp) == 0) {
|
||||
struct droiddeck_dma_buf_sync_file imp = {.flags = DROIDDECK_DMA_BUF_SYNC_READ, .fd = fd};
|
||||
int dmabuf_fd = droiddeck_dmabuf_fd(ab->b);
|
||||
if (dmabuf_fd >= 0 && ioctl(dmabuf_fd, DROIDDECK_DMA_BUF_IOCTL_IMPORT_SYNC_FILE, &imp) == 0) {
|
||||
close(fd);
|
||||
} else {
|
||||
if (!g_import_failed_logged) {
|
||||
g_import_failed_logged = 1;
|
||||
banner_log("layer", "zero-copy: DMA_BUF_IOCTL_IMPORT_SYNC_FILE failed (%s): releases wait for the display's fence here instead",
|
||||
droiddeck_log("layer", "zero-copy: DMA_BUF_IOCTL_IMPORT_SYNC_FILE failed (%s): releases wait for the display's fence here instead",
|
||||
strerror(errno));
|
||||
}
|
||||
/* Wait it out in the event loop (a sync_file is readable once signalled). */
|
||||
@@ -196,23 +196,23 @@ void ahb_swapchain_layer_released(void *token, int release_fd) {
|
||||
/* ---- present */
|
||||
|
||||
int ahb_swapchain_has_ahb(const struct dmabuf_buffer *b) {
|
||||
return b && *banner_dmabuf_ahb_slot((struct dmabuf_buffer *)b) != NULL;
|
||||
return b && *droiddeck_dmabuf_ahb_slot((struct dmabuf_buffer *)b) != NULL;
|
||||
}
|
||||
|
||||
int ahb_swapchain_present(struct dmabuf_buffer *b, struct surface *s, int scene_w, int scene_h) {
|
||||
struct ahb_buf *ab = b ? *banner_dmabuf_ahb_slot(b) : NULL;
|
||||
struct ahb_buf *ab = b ? *droiddeck_dmabuf_ahb_slot(b) : NULL;
|
||||
if (!ab) return -1;
|
||||
int dmabuf_fd = banner_dmabuf_fd(b);
|
||||
int dmabuf_fd = droiddeck_dmabuf_fd(b);
|
||||
/* Acquire fence: the game's render fence, which its driver put into the dma-buf before the
|
||||
* commit (Mesa's implicit sync). The display waits on it, not the CPU. */
|
||||
int acquire = -1;
|
||||
struct banner_dma_buf_sync_file exp = {.flags = BANNER_DMA_BUF_SYNC_READ, .fd = -1};
|
||||
if (dmabuf_fd >= 0 && ioctl(dmabuf_fd, BANNER_DMA_BUF_IOCTL_EXPORT_SYNC_FILE, &exp) == 0 && exp.fd >= 0) {
|
||||
struct droiddeck_dma_buf_sync_file exp = {.flags = DROIDDECK_DMA_BUF_SYNC_READ, .fd = -1};
|
||||
if (dmabuf_fd >= 0 && ioctl(dmabuf_fd, DROIDDECK_DMA_BUF_IOCTL_EXPORT_SYNC_FILE, &exp) == 0 && exp.fd >= 0) {
|
||||
acquire = exp.fd;
|
||||
} else if (dmabuf_fd >= 0) {
|
||||
if (!g_export_failed_logged) {
|
||||
g_export_failed_logged = 1;
|
||||
banner_log("layer", "zero-copy: DMA_BUF_IOCTL_EXPORT_SYNC_FILE failed (%s): waiting for each frame on the CPU instead",
|
||||
droiddeck_log("layer", "zero-copy: DMA_BUF_IOCTL_EXPORT_SYNC_FILE failed (%s): waiting for each frame on the CPU instead",
|
||||
strerror(errno));
|
||||
}
|
||||
struct pollfd p = {.fd = dmabuf_fd, .events = POLLIN}; /* readable = the writers are done */
|
||||
@@ -220,7 +220,7 @@ int ahb_swapchain_present(struct dmabuf_buffer *b, struct surface *s, int scene_
|
||||
do { r = poll(&p, 1, 100); } while (r < 0 && errno == EINTR);
|
||||
}
|
||||
int r = sc_layer_present_ahb(ab->ahb, ab->w, ab->h, acquire, (void *)(uintptr_t)ab->id, scene_w, scene_h,
|
||||
s ? banner_surface_color(s) : NULL, ab->format);
|
||||
s ? droiddeck_surface_color(s) : NULL, ab->format);
|
||||
if (r < 0) return -1;
|
||||
if (r == 1) return 0; /* nothing of it on screen: not on the layer, nothing to release later */
|
||||
if (!ab->on_layer) {
|
||||
@@ -231,8 +231,8 @@ int ahb_swapchain_present(struct dmabuf_buffer *b, struct surface *s, int scene_
|
||||
if (s && g_announced != s) {
|
||||
char name[160];
|
||||
g_announced = s;
|
||||
banner_surface_describe(s, name, sizeof(name));
|
||||
banner_log("layer", "zero-copy: presenting %s without a copy", name);
|
||||
droiddeck_surface_describe(s, name, sizeof(name));
|
||||
droiddeck_log("layer", "zero-copy: presenting %s without a copy", name);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -245,7 +245,7 @@ int ahb_swapchain_last_frame_age_ms(void) {
|
||||
}
|
||||
|
||||
int ahb_swapchain_defer_release(struct dmabuf_buffer *b, struct wl_resource *buffer, struct surface *s, int paced) {
|
||||
struct ahb_buf *ab = b ? *banner_dmabuf_ahb_slot(b) : NULL;
|
||||
struct ahb_buf *ab = b ? *droiddeck_dmabuf_ahb_slot(b) : NULL;
|
||||
if (!ab || !ab->on_layer || !buffer || ab->resource != buffer) return 0;
|
||||
ab->release_pending = 1;
|
||||
ab->deferred_ns = now_ns();
|
||||
@@ -268,7 +268,7 @@ unsigned ahb_swapchain_stats_take(void) {
|
||||
int ahb_swapchain_advertised(void) { return g_advertised; }
|
||||
|
||||
uint32_t ahb_swapchain_ahb_format(const struct dmabuf_buffer *b) {
|
||||
struct ahb_buf *ab = b ? *banner_dmabuf_ahb_slot((struct dmabuf_buffer *)b) : NULL;
|
||||
struct ahb_buf *ab = b ? *droiddeck_dmabuf_ahb_slot((struct dmabuf_buffer *)b) : NULL;
|
||||
return ab ? ab->format : 0;
|
||||
}
|
||||
|
||||
@@ -279,14 +279,14 @@ static void ahb_destroy(struct wl_client *c, struct wl_resource *r) { wl_resourc
|
||||
static void ahb_attach(struct wl_client *c, struct wl_resource *r, struct wl_resource *buffer, int32_t sock,
|
||||
uint32_t width, uint32_t height, uint32_t stride, uint32_t mod_hi, uint32_t mod_lo,
|
||||
uint32_t image_count) {
|
||||
struct dmabuf_buffer *b = banner_dmabuf_from_resource(buffer);
|
||||
struct dmabuf_buffer *b = droiddeck_dmabuf_from_resource(buffer);
|
||||
uint64_t modifier = ((uint64_t)mod_hi << 32) | mod_lo;
|
||||
if (!b) {
|
||||
banner_log("layer", "zero-copy: %s attached an AHardwareBuffer to a non-dma-buf wl_buffer, ignored", banner_client_name(c));
|
||||
droiddeck_log("layer", "zero-copy: %s attached an AHardwareBuffer to a non-dma-buf wl_buffer, ignored", droiddeck_client_name(c));
|
||||
close(sock);
|
||||
return;
|
||||
}
|
||||
void **slot = banner_dmabuf_ahb_slot(b);
|
||||
void **slot = droiddeck_dmabuf_ahb_slot(b);
|
||||
if (*slot) { close(sock); return; } /* once per buffer */
|
||||
|
||||
/* The handle was written into the socket before the request was sent; a short wait is just
|
||||
@@ -298,17 +298,17 @@ static void ahb_attach(struct wl_client *c, struct wl_resource *r, struct wl_res
|
||||
int rc = pr > 0 ? AHardwareBuffer_recvHandleFromUnixSocket(sock, &ahb) : -1;
|
||||
close(sock);
|
||||
if (rc != 0 || !ahb) {
|
||||
banner_log("layer", "zero-copy: receiving %s's AHardwareBuffer failed (%s)", banner_client_name(c),
|
||||
droiddeck_log("layer", "zero-copy: receiving %s's AHardwareBuffer failed (%s)", droiddeck_client_name(c),
|
||||
pr > 0 ? "recvHandleFromUnixSocket" : "nothing on the socket");
|
||||
return;
|
||||
}
|
||||
AHardwareBuffer_Desc d;
|
||||
AHardwareBuffer_describe(ahb, &d);
|
||||
int bw, bh;
|
||||
banner_dmabuf_size(b, &bw, &bh);
|
||||
droiddeck_dmabuf_size(b, &bw, &bh);
|
||||
if ((int)d.width != bw || (int)d.height != bh || (int)width != bw || (int)height != bh) {
|
||||
banner_log("layer", "zero-copy: %s's AHardwareBuffer is %ux%u but its wl_buffer %dx%d, ignored",
|
||||
banner_client_name(c), d.width, d.height, bw, bh);
|
||||
droiddeck_log("layer", "zero-copy: %s's AHardwareBuffer is %ux%u but its wl_buffer %dx%d, ignored",
|
||||
droiddeck_client_name(c), d.width, d.height, bw, bh);
|
||||
AHardwareBuffer_release(ahb);
|
||||
return;
|
||||
}
|
||||
@@ -317,7 +317,7 @@ static void ahb_attach(struct wl_client *c, struct wl_resource *r, struct wl_res
|
||||
if (!ab) { AHardwareBuffer_release(ahb); wl_client_post_no_memory(c); return; }
|
||||
ab->id = g_next_id++;
|
||||
ab->b = b;
|
||||
banner_dmabuf_ref(b);
|
||||
droiddeck_dmabuf_ref(b);
|
||||
ab->ahb = ahb;
|
||||
ab->w = bw; ab->h = bh;
|
||||
ab->stride = d.stride ? d.stride : stride;
|
||||
@@ -335,8 +335,8 @@ static void ahb_attach(struct wl_client *c, struct wl_resource *r, struct wl_res
|
||||
if (g_last_chain.client != c || g_last_chain.w != bw || g_last_chain.h != bh ||
|
||||
g_last_chain.image_count != image_count || g_last_chain.modifier != modifier || g_last_chain.format != d.format) {
|
||||
g_last_chain = (struct chain_seen){c, bw, bh, image_count, modifier, d.format};
|
||||
banner_log("layer", "zero-copy: AHB swapchain from %s (%u images, %dx%d, %s, %s, stride %u px)", banner_client_name(c),
|
||||
image_count, bw, bh, banner_ahb_format_name(d.format), modifier == MOD_QCOM_COMPRESSED ? "UBWC (QCOM_COMPRESSED)"
|
||||
droiddeck_log("layer", "zero-copy: AHB swapchain from %s (%u images, %dx%d, %s, %s, stride %u px)", droiddeck_client_name(c),
|
||||
image_count, bw, bh, droiddeck_ahb_format_name(d.format), modifier == MOD_QCOM_COMPRESSED ? "UBWC (QCOM_COMPRESSED)"
|
||||
: modifier == 0 ? "linear" : "unknown modifier", ab->stride);
|
||||
}
|
||||
}
|
||||
@@ -363,7 +363,7 @@ static void bind_ahb(struct wl_client *c, void *data, uint32_t ver, uint32_t id)
|
||||
/* One line per program, not per surface-format query (each binds its own). */
|
||||
if (last_named != c) {
|
||||
last_named = c;
|
||||
banner_log("layer", "zero-copy: %s bound banner_ahb_v1 version %u (%s)", banner_client_name(c), ver,
|
||||
droiddeck_log("layer", "zero-copy: %s bound banner_ahb_v1 version %u (%s)", droiddeck_client_name(c), ver,
|
||||
ver >= 2 ? "follows the live switch" : "version 1: decides from its launch environment only");
|
||||
}
|
||||
}
|
||||
@@ -372,7 +372,7 @@ void ahb_swapchain_set_mode(int on, int live) {
|
||||
on = on ? 1 : 0;
|
||||
g_zero_copy = on;
|
||||
if (!g_advertised) {
|
||||
if (live) banner_log("layer", "zero-copy switched %s from the drawer, but no display layer is available on this device: no change",
|
||||
if (live) droiddeck_log("layer", "zero-copy switched %s from the drawer, but no display layer is available on this device: no change",
|
||||
on ? "on" : "off");
|
||||
return;
|
||||
}
|
||||
@@ -390,37 +390,37 @@ void ahb_swapchain_set_mode(int on, int live) {
|
||||
memset(&g_last_chain, 0, sizeof(g_last_chain));
|
||||
g_announced = NULL;
|
||||
if (live)
|
||||
banner_log("layer", "zero-copy switched %s from the drawer: %d bound program%s told to rebuild their swapchains%s",
|
||||
droiddeck_log("layer", "zero-copy switched %s from the drawer: %d bound program%s told to rebuild their swapchains%s",
|
||||
on ? "on" : "off", told, told == 1 ? "" : "s",
|
||||
on ? "; frames go on the display layer once the new gralloc swapchain is up"
|
||||
: "; gralloc frames still in flight stay on the layer (or take the copy path) until then");
|
||||
else
|
||||
banner_log("layer", "zero-copy: %s at launch (%s)", on ? "on" : "off",
|
||||
on ? "BANNER_WAYLAND_ZERO_COPY=1: games present their own gralloc buffers on the display layer"
|
||||
droiddeck_log("layer", "zero-copy: %s at launch (%s)", on ? "on" : "off",
|
||||
on ? "DROIDDECK_WAYLAND_ZERO_COPY=1: games present their own gralloc buffers on the display layer"
|
||||
: "the drawer's Zero-copy presentation switch turns it on live");
|
||||
if (live) wl_display_flush_clients(banner_get_display());
|
||||
banner_request_redraw();
|
||||
if (live) wl_display_flush_clients(droiddeck_get_display());
|
||||
droiddeck_request_redraw();
|
||||
}
|
||||
|
||||
void ahb_swapchain_init(struct wl_display *display) {
|
||||
wl_list_init(&g_bufs);
|
||||
wl_list_init(&g_clients);
|
||||
if (!sc_layer_available()) {
|
||||
banner_log("layer", "zero-copy: no display layer on this device (see the line above): banner_ahb_v1 not advertised");
|
||||
droiddeck_log("layer", "zero-copy: no display layer on this device (see the line above): banner_ahb_v1 not advertised");
|
||||
return;
|
||||
}
|
||||
g_loop = wl_display_get_event_loop(display);
|
||||
if (pipe2(g_rel_pipe, O_CLOEXEC | O_NONBLOCK) != 0) {
|
||||
banner_log("error", "zero-copy: release pipe creation failed (%s); zero-copy presents disabled", strerror(errno));
|
||||
droiddeck_log("error", "zero-copy: release pipe creation failed (%s); zero-copy presents disabled", strerror(errno));
|
||||
g_rel_pipe[0] = g_rel_pipe[1] = -1;
|
||||
return;
|
||||
}
|
||||
wl_event_loop_add_fd(g_loop, g_rel_pipe[0], WL_EVENT_READABLE, on_release_pipe, NULL);
|
||||
if (!wl_global_create(display, &banner_ahb_v1_interface, 2, NULL, bind_ahb)) {
|
||||
banner_log("error", "zero-copy: banner_ahb_v1 global creation failed");
|
||||
droiddeck_log("error", "zero-copy: banner_ahb_v1 global creation failed");
|
||||
return;
|
||||
}
|
||||
g_advertised = 1;
|
||||
banner_log("layer", "zero-copy: banner_ahb_v1 version 2 advertised (games built for it present their own gralloc buffers while the switch is on)");
|
||||
droiddeck_log("layer", "zero-copy: banner_ahb_v1 version 2 advertised (games built for it present their own gralloc buffers while the switch is on)");
|
||||
ahb_swapchain_set_mode(g_zero_copy, 0);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
#ifndef AHB_SWAPCHAIN_H
|
||||
#define AHB_SWAPCHAIN_H
|
||||
/*
|
||||
* Zero-copy game frames (layer mode, BANNER_WAYLAND_ZERO_COPY=1 at launch, the drawer's "Zero-copy
|
||||
* Zero-copy game frames (layer mode, DROIDDECK_WAYLAND_ZERO_COPY=1 at launch, the drawer's "Zero-copy
|
||||
* presentation" switch live): the guest half of the design in ZERO_COPY_SPIKE.md. Our Wayland Turnip
|
||||
* (banners-turnip-wayland, patches/wayland/banner_ahb_wsi.py) binds the private global banner_ahb_v1
|
||||
* (version 2), which this compositor advertises on every session where a display layer is possible,
|
||||
@@ -63,21 +63,21 @@ uint32_t ahb_swapchain_ahb_format(const struct dmabuf_buffer *b);
|
||||
void ahb_swapchain_layer_released(void *token, int release_fd);
|
||||
|
||||
/* ---- ahb_swapchain.c -> compositor.c (hooks) */
|
||||
struct dmabuf_buffer *banner_dmabuf_from_resource(struct wl_resource *buffer);
|
||||
int banner_dmabuf_fd(const struct dmabuf_buffer *b); /* plane 0's dma-buf */
|
||||
void banner_dmabuf_size(const struct dmabuf_buffer *b, int *w, int *h);
|
||||
void **banner_dmabuf_ahb_slot(struct dmabuf_buffer *b); /* this module's per-buffer state */
|
||||
void banner_dmabuf_ref(struct dmabuf_buffer *b);
|
||||
void banner_dmabuf_unref(struct dmabuf_buffer *b);
|
||||
struct dmabuf_buffer *droiddeck_dmabuf_from_resource(struct wl_resource *buffer);
|
||||
int droiddeck_dmabuf_fd(const struct dmabuf_buffer *b); /* plane 0's dma-buf */
|
||||
void droiddeck_dmabuf_size(const struct dmabuf_buffer *b, int *w, int *h);
|
||||
void **droiddeck_dmabuf_ahb_slot(struct dmabuf_buffer *b); /* this module's per-buffer state */
|
||||
void droiddeck_dmabuf_ref(struct dmabuf_buffer *b);
|
||||
void droiddeck_dmabuf_unref(struct dmabuf_buffer *b);
|
||||
/* Give a wl_buffer back to its client now (paced = 0) or on the FPS limiter's cadence (s != NULL).
|
||||
* since_ns = when the compositor let go of it (CLOCK_MONOTONIC; the perf line's release latency). */
|
||||
void banner_release_buffer(struct surface *s, struct wl_resource *buffer, int paced, int64_t since_ns);
|
||||
void droiddeck_release_buffer(struct surface *s, struct wl_resource *buffer, int paced, int64_t since_ns);
|
||||
/* Redraw the scene on the next tick (the present path may have changed). */
|
||||
void banner_request_redraw(void);
|
||||
void droiddeck_request_redraw(void);
|
||||
/* "<title>" (program) of the window a surface belongs to, for the log. */
|
||||
void banner_surface_describe(const struct surface *s, char *out, size_t size);
|
||||
/* The surface's current image description (banner_color.h), NULL = none / HDR gate closed. */
|
||||
struct banner_color;
|
||||
const struct banner_color *banner_surface_color(const struct surface *s);
|
||||
void droiddeck_surface_describe(const struct surface *s, char *out, size_t size);
|
||||
/* The surface's current image description (droiddeck_color.h), NULL = none / HDR gate closed. */
|
||||
struct droiddeck_color;
|
||||
const struct droiddeck_color *droiddeck_surface_color(const struct surface *s);
|
||||
|
||||
#endif
|
||||
@@ -1,50 +0,0 @@
|
||||
/*
|
||||
* Interface between compositor.c and the extension modules that live in their own files:
|
||||
* wl_clipboard.c wl_data_device_manager + zwlr_data_control_manager_v1 (selection only)
|
||||
* wl_text_input.c zwp_text_input_manager_v3 (host IME / soft keyboard)
|
||||
* wl_toplevel_icon.c xdg_toplevel_icon_manager_v1 (accepted, not rendered)
|
||||
* banner_ext.c the host→compositor message queue shared by the modules
|
||||
* Everything here runs on the compositor (dispatch) thread unless noted.
|
||||
*/
|
||||
#pragma once
|
||||
#include <stdint.h>
|
||||
#include <wayland-server.h>
|
||||
|
||||
void banner_log(const char *tag, const char *fmt, ...);
|
||||
|
||||
/* ---- compositor.c → modules */
|
||||
void banner_ext_init(struct wl_display *display); /* creates the globals + host queue */
|
||||
void banner_clipboard_keyboard_focus(struct wl_client *client); /* keyboard focus moved; NULL = none */
|
||||
void banner_text_input_refocus(void); /* the text-input target may have changed */
|
||||
void banner_text_input_surface_gone(struct wl_resource *surface);
|
||||
|
||||
/* ---- modules → compositor.c */
|
||||
const char *banner_client_name(struct wl_client *client);
|
||||
struct wl_display *banner_get_display(void);
|
||||
/* The surface text input follows (the last clicked window, else the topmost program window). */
|
||||
struct wl_resource *banner_ime_target(void);
|
||||
/* Scene (virtual desktop) origin of a toplevel surface; 0,0 when unknown. */
|
||||
void banner_surface_scene_origin(struct wl_resource *surface, int *x, int *y);
|
||||
/* Deliver a key press/release through the wl_keyboard path as if typed. */
|
||||
void banner_inject_key(uint32_t evdev, int pressed);
|
||||
|
||||
/* ---- modules → app (JNI upcalls; waylandcomp_jni.c) */
|
||||
void banner_on_clipboard_text(const char *utf8, int len);
|
||||
void banner_on_text_input(int enabled, const char *program, int x, int y, int w, int h);
|
||||
|
||||
/* ---- app → modules (callable from any thread; queued to the compositor thread) */
|
||||
void banner_host_clipboard_text(const char *utf8, int len); /* len 0 = clear */
|
||||
void banner_host_text_commit(const char *utf8, int len);
|
||||
void banner_host_text_preedit(const char *utf8, int len, int cursor_begin, int cursor_end);
|
||||
void banner_host_text_delete(int before, int after);
|
||||
void banner_host_zero_copy(int on, int live); /* the drawer's zero-copy switch (ahb_swapchain_set_mode) */
|
||||
void banner_host_hdr_output(int on); /* the drawer's HDR output switch (banner_color_set_output) */
|
||||
|
||||
/* ---- module internals reached through the queue (banner_ext.c → modules) */
|
||||
void clipboard_init(struct wl_display *display);
|
||||
void clipboard_host_text(char *utf8, size_t len); /* takes ownership of utf8 */
|
||||
void text_input_init(struct wl_display *display);
|
||||
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);
|
||||
@@ -2,7 +2,7 @@
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
#include "blend_pass.h"
|
||||
#include "vk_loader.h"
|
||||
#include "vk_present.h" /* banner_log, vkp_gpu_name */
|
||||
#include "vk_present.h" /* droiddeck_log, vkp_gpu_name */
|
||||
#include <string.h>
|
||||
|
||||
/* SPIR-V, pre-compiled (glslangValidator -V blend.vert --vn blend_vert_code, the same for blend.frag)
|
||||
@@ -168,12 +168,12 @@ static int objects_ensure(void) {
|
||||
if (!(g_vert = shader(blend_vert_code, sizeof(blend_vert_code)))) goto fail;
|
||||
if (!(g_frag = shader(blend_frag_code, sizeof(blend_frag_code)))) goto fail;
|
||||
g_ready = 1;
|
||||
banner_log("gpu", "alpha composition ready on %s: translucent surfaces are blended over what is under them",
|
||||
droiddeck_log("gpu", "alpha composition ready on %s: translucent surfaces are blended over what is under them",
|
||||
vkp_gpu_name());
|
||||
return 0;
|
||||
fail:
|
||||
objects_destroy();
|
||||
banner_log("error", "gpu: the alpha composition pass could not be built on this driver; translucent surfaces "
|
||||
droiddeck_log("error", "gpu: the alpha composition pass could not be built on this driver; translucent surfaces "
|
||||
"are drawn opaque this session");
|
||||
return -1;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* bannerlator-wayland - the embedded compositor of the Wayland display path.
|
||||
* droiddeck-wayland - the embedded compositor of the Wayland display path.
|
||||
*
|
||||
* Clients are the Wine processes of a container (winewayland.drv) and, through them,
|
||||
* Mesa's Vulkan WSI. Each process is its own client, so a Windows virtual desktop is
|
||||
@@ -50,12 +50,12 @@
|
||||
#include "framegen_bridge.h"
|
||||
#include <pthread.h>
|
||||
#include "effects_chain.h"
|
||||
#include "banner_ext.h"
|
||||
#include "banner_color.h"
|
||||
#include "droiddeck_ext.h"
|
||||
#include "droiddeck_color.h"
|
||||
#include "compositor_internal.h"
|
||||
|
||||
#define WLOGI(...) __android_log_print(ANDROID_LOG_INFO, "BannerWayland", __VA_ARGS__)
|
||||
#define WLOGE(...) __android_log_print(ANDROID_LOG_ERROR, "BannerWayland", __VA_ARGS__)
|
||||
#define WLOGI(...) __android_log_print(ANDROID_LOG_INFO, "DroidDeckWayland", __VA_ARGS__)
|
||||
#define WLOGE(...) __android_log_print(ANDROID_LOG_ERROR, "DroidDeckWayland", __VA_ARGS__)
|
||||
|
||||
/* ------------------------------------------------------------------ session log
|
||||
* One readable, time-stamped file per Wayland session in Download/Wayland-logs, also
|
||||
@@ -75,7 +75,7 @@ static pthread_mutex_t g_log_lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
static char *g_prelog[PRELOG_MAX];
|
||||
static int g_prelog_n;
|
||||
|
||||
void banner_log(const char *tag, const char *fmt, ...) {
|
||||
void droiddeck_log(const char *tag, const char *fmt, ...) {
|
||||
char msg[512];
|
||||
va_list ap;
|
||||
va_start(ap, fmt);
|
||||
@@ -121,11 +121,11 @@ static void open_session_log(void) {
|
||||
setvbuf(f, NULL, _IOLBF, 0);
|
||||
strftime(stamp, sizeof(stamp), "%Y-%m-%d %H:%M:%S", &tm);
|
||||
fprintf(f,
|
||||
"Bannerlator Wayland session\n"
|
||||
"===========================\n"
|
||||
"DroidDeck Wayland session\n"
|
||||
"=========================\n"
|
||||
"Started %s\n"
|
||||
"Display Wayland: Windows programs draw through the Bannerlator compositor.\n"
|
||||
" No X server is used for this session.\n"
|
||||
"Display Wayland: programs draw through the DroidDeck compositor.\n"
|
||||
" gamescope and the desktop start their own Xwayland for X11 programs.\n"
|
||||
"Log %s\n\n"
|
||||
"Time Area Event\n"
|
||||
"------------ -------- -----------------------------------------------------\n",
|
||||
@@ -178,8 +178,8 @@ static const char *client_name(struct wl_client *client) {
|
||||
|
||||
static void on_client_destroyed(struct wl_listener *l, void *data) {
|
||||
struct client_info *ci = wl_container_of(l, ci, destroy), **pp;
|
||||
banner_log("program", "disconnected: %s (pid %d)", ci->name, (int)ci->pid);
|
||||
banner_color_client_gone(ci->client); /* HDR summary for a program that presented HDR (gate open only) */
|
||||
droiddeck_log("program", "disconnected: %s (pid %d)", ci->name, (int)ci->pid);
|
||||
droiddeck_color_client_gone(ci->client); /* HDR summary for a program that presented HDR (gate open only) */
|
||||
for (pp = &g_clients; *pp; pp = &(*pp)->next)
|
||||
if (*pp == ci) { *pp = ci->next; break; }
|
||||
free(ci);
|
||||
@@ -211,7 +211,7 @@ static void on_client_created(struct wl_listener *l, void *data) {
|
||||
wl_client_add_destroy_listener(client, &ci->destroy);
|
||||
ci->next = g_clients;
|
||||
g_clients = ci;
|
||||
banner_log("program", "connected over Wayland: %s (pid %d)", ci->name, (int)ci->pid);
|
||||
droiddeck_log("program", "connected over Wayland: %s (pid %d)", ci->name, (int)ci->pid);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ surfaces */
|
||||
@@ -304,7 +304,7 @@ volatile int g_fps_limit;
|
||||
* the X11 renderer's unviewable "explorer.exe". They still exist for input routing. */
|
||||
volatile int g_hide_shell;
|
||||
|
||||
/* Experimental layer mode (BANNER_WAYLAND_ZERO_COPY=1 in the container's environment): a single
|
||||
/* Experimental layer mode (DROIDDECK_WAYLAND_ZERO_COPY=1 in the container's environment): a single
|
||||
* fullscreen window is shown on its own Android layer (sc_layer.c) instead of being blitted into the
|
||||
* screen swapchain. Set from the app before the compositor starts; read per frame. */
|
||||
volatile int g_zero_copy;
|
||||
@@ -314,11 +314,11 @@ static int g_zero_copy_fx_skip_said;
|
||||
/* Compressed (UBWC) game buffers: zwp_linux_dmabuf_v1 advertises DRM_FORMAT_MOD_QCOM_COMPRESSED next
|
||||
* to LINEAR for every format the renderer's own driver can import that way, so Turnip's Wayland WSI
|
||||
* in the game allocates UBWC swapchain images instead of resolving each frame to a linear copy.
|
||||
* BANNER_WAYLAND_UBWC=0 in the container's environment turns the advertisement off (A/B switch). Set
|
||||
* DROIDDECK_WAYLAND_UBWC=0 in the container's environment turns the advertisement off (A/B switch). Set
|
||||
* from the app before the compositor starts; read at every zwp_linux_dmabuf_v1 bind. */
|
||||
volatile int g_ubwc = 1;
|
||||
|
||||
/* Debug (BANNER_WAYLAND_NO_RENDER_NODE=1): name no DRM device in the dma-buf feedback, as a phone
|
||||
/* Debug (DROIDDECK_WAYLAND_NO_RENDER_NODE=1): name no DRM device in the dma-buf feedback, as a phone
|
||||
* that exposes no /dev/dri node to apps does, to reproduce its OpenGL path here. Set from the app
|
||||
* before the compositor starts. */
|
||||
volatile int g_no_render_node;
|
||||
@@ -544,13 +544,13 @@ static void cursor_publish_shm(struct wl_shm_buffer *shm, int hx, int hy) {
|
||||
/* The serial alone, without the lock: the app asks on every pointer move, and the snapshot below
|
||||
* copies up to 256 KB of pixels across JNI and holds the lock the compositor thread takes to
|
||||
* change the cursor. A torn read is only a missed or an extra snapshot. */
|
||||
int banner_cursor_serial(void) {
|
||||
int droiddeck_cursor_serial(void) {
|
||||
return __atomic_load_n(&g_cursor_serial, __ATOMIC_RELAXED);
|
||||
}
|
||||
|
||||
/* Read by the app (UI thread). out = [serial, hidden, w, h, hotspotX, hotspotY, pixels...].
|
||||
* Returns the number of ints written, or 0 if out is too small. */
|
||||
int banner_cursor_snapshot(int *out, int cap) {
|
||||
int droiddeck_cursor_snapshot(int *out, int cap) {
|
||||
int n = 0;
|
||||
pthread_mutex_lock(&g_cursor_lock);
|
||||
int need = 6 + (g_cursor_hidden ? 0 : g_cursor_w * g_cursor_h);
|
||||
@@ -650,7 +650,7 @@ static void map_toplevel(struct surface *s) {
|
||||
{
|
||||
char name[160];
|
||||
describe(s, name, sizeof(name));
|
||||
banner_log("window", "opened %s %dx%d at %d,%d%s", name, w, h, s->x, s->y,
|
||||
droiddeck_log("window", "opened %s %dx%d at %d,%d%s", name, w, h, s->x, s->y,
|
||||
s->placed ? "" : " (no desktop position yet)");
|
||||
}
|
||||
wl_list_insert(g_toplevels.prev, &s->toplevel_link); /* new windows start on top */
|
||||
@@ -667,12 +667,12 @@ static void unmap_toplevel(struct surface *s) {
|
||||
{
|
||||
char name[160];
|
||||
describe(s, name, sizeof(name));
|
||||
banner_log("window", "closed %s", name);
|
||||
droiddeck_log("window", "closed %s", name);
|
||||
}
|
||||
wl_list_remove(&s->toplevel_link);
|
||||
wl_list_init(&s->toplevel_link);
|
||||
if (g_ime_click == s) g_ime_click = NULL;
|
||||
banner_text_input_refocus();
|
||||
droiddeck_text_input_refocus();
|
||||
}
|
||||
|
||||
/* Let go of the surface's dmabuf content. paced = 1: the buffer was replaced, give it back on the
|
||||
@@ -752,11 +752,11 @@ static struct surface *g_hud_surface;
|
||||
static int64_t g_hud_last_ns;
|
||||
/* That window committed a frame the screen has not drawn yet. */
|
||||
static int g_hud_fresh;
|
||||
extern void banner_on_game_surface(const char *window, const char *gpu); /* window NULL = gone */
|
||||
extern void banner_on_game_frame(void);
|
||||
extern void droiddeck_on_game_surface(const char *window, const char *gpu); /* window NULL = gone */
|
||||
extern void droiddeck_on_game_frame(void);
|
||||
/* The program behind that window: its Linux pid (the Wayland client's credentials) and executable name
|
||||
* ("" when /proc gave none) - the app arms its CPU affinity on it (X11 does that from window events). */
|
||||
extern void banner_on_game_program(int pid, const char *program);
|
||||
extern void droiddeck_on_game_program(int pid, const char *program);
|
||||
|
||||
static void take_dmabuf(struct surface *s, struct dmabuf_buffer *b, struct wl_resource *buffer) {
|
||||
if (s->dmabuf != buffer || s->dmabuf_buf != b) {
|
||||
@@ -787,7 +787,7 @@ static void take_dmabuf(struct surface *s, struct dmabuf_buffer *b, struct wl_re
|
||||
(s->buf_w != b->width || s->buf_h != b->height)) {
|
||||
char name[160];
|
||||
describe(s, name, sizeof(name));
|
||||
banner_log("vulkan", "%s changed its frame size: %dx%d -> %dx%d", name,
|
||||
droiddeck_log("vulkan", "%s changed its frame size: %dx%d -> %dx%d", name,
|
||||
s->buf_w, s->buf_h, b->width, b->height);
|
||||
}
|
||||
s->buf_w = b->width;
|
||||
@@ -803,18 +803,18 @@ static void take_dmabuf(struct surface *s, struct dmabuf_buffer *b, struct wl_re
|
||||
if (b->img)
|
||||
/* Imported for the copy path. Whether its frames ALSO go on the display layer without a copy
|
||||
* is decided per frame (fullscreen, zero-copy on) and counted in the 10 s lines. */
|
||||
banner_log("vulkan", "%s is presenting GPU frames through Wayland: %dx%d, format %c%c%c%c, %s (%s)",
|
||||
droiddeck_log("vulkan", "%s is presenting GPU frames through Wayland: %dx%d, format %c%c%c%c, %s (%s)",
|
||||
name, b->width, b->height, b->format & 0xff, (b->format >> 8) & 0xff,
|
||||
(b->format >> 16) & 0xff, (b->format >> 24) & 0xff,
|
||||
vkp_modifier_name(b->modifier),
|
||||
ahb_swapchain_has_ahb(b) ? "gralloc buffers: can go on the display layer without a copy"
|
||||
: "dma-buf: copied into the screen swapchain");
|
||||
else if (ahb_swapchain_has_ahb(b))
|
||||
banner_log("vulkan", "%s is presenting GPU frames through Wayland: %dx%d on its own display layer only "
|
||||
droiddeck_log("vulkan", "%s is presenting GPU frames through Wayland: %dx%d on its own display layer only "
|
||||
"(gralloc buffers the compositor cannot import for the copy path)",
|
||||
name, b->width, b->height);
|
||||
else
|
||||
banner_log("error", "could not import GPU frames from %s (%dx%d, modifier %#llx)",
|
||||
droiddeck_log("error", "could not import GPU frames from %s (%dx%d, modifier %#llx)",
|
||||
name, b->width, b->height, (unsigned long long)b->modifier);
|
||||
}
|
||||
/* The HUD follows the window whether its frames are copied or go straight to the layer: a
|
||||
@@ -830,21 +830,21 @@ static void take_dmabuf(struct surface *s, struct dmabuf_buffer *b, struct wl_re
|
||||
char name[160];
|
||||
describe(s, name, sizeof(name));
|
||||
g_hud_surface = s;
|
||||
banner_on_game_surface(name, vkp_gpu_name());
|
||||
droiddeck_on_game_surface(name, vkp_gpu_name());
|
||||
struct client_info *ci = client_info_of(wl_resource_get_client(s->resource));
|
||||
if (ci) banner_on_game_program((int)ci->pid, strncmp(ci->name, "pid ", 4) ? ci->name : "");
|
||||
if (ci) droiddeck_on_game_program((int)ci->pid, strncmp(ci->name, "pid ", 4) ? ci->name : "");
|
||||
}
|
||||
/* HDR session (gate open): a DXVK game switches to HDR by REBUILDING its swapchain on the same
|
||||
* surface, so the one-shot line above never sees the 10-bit buffers - name every format change. */
|
||||
if (b->format != s->hdr_fmt_logged && banner_color_hdr_open()) {
|
||||
if (b->format != s->hdr_fmt_logged && droiddeck_color_hdr_open()) {
|
||||
char name[160];
|
||||
uint32_t af = ahb_swapchain_ahb_format(b);
|
||||
int ten = b->format == FOURCC('A', 'B', '3', '0') || b->format == FOURCC('X', 'B', '3', '0');
|
||||
describe(s, name, sizeof(name));
|
||||
banner_log("color", "%s presents %dx%d buffers in %c%c%c%c (%s), %s%s%s; %s", name, b->width, b->height,
|
||||
droiddeck_log("color", "%s presents %dx%d buffers in %c%c%c%c (%s), %s%s%s; %s", name, b->width, b->height,
|
||||
b->format & 0xff, (b->format >> 8) & 0xff, (b->format >> 16) & 0xff, (b->format >> 24) & 0xff,
|
||||
ten ? "10-bit A2B10G10R10" : "8-bit", vkp_modifier_name(b->modifier),
|
||||
af ? ", gralloc " : ", no gralloc buffer (copy path only)", af ? banner_ahb_format_name(af) : "",
|
||||
af ? ", gralloc " : ", no gralloc buffer (copy path only)", af ? droiddeck_ahb_format_name(af) : "",
|
||||
b->img ? "the compositor imported it"
|
||||
: af ? "the compositor's driver could NOT import it (display layer only, fullscreen)"
|
||||
: "the compositor's driver could NOT import it (nothing can show it)");
|
||||
@@ -854,23 +854,23 @@ static void take_dmabuf(struct surface *s, struct dmabuf_buffer *b, struct wl_re
|
||||
* Asking whether the compositor could import it, as this used to, is a question about the
|
||||
* copy path and not about whether a frame happened: a game whose buffers go straight to
|
||||
* the display layer draws on screen while the counter sat at 0.0 fps and 1000.0 ms. */
|
||||
if (s == g_hud_surface) { g_hud_last_ns = now_ns(); g_hud_fresh = 1; banner_on_game_frame(); }
|
||||
if (s == g_hud_surface) { g_hud_last_ns = now_ns(); g_hud_fresh = 1; droiddeck_on_game_frame(); }
|
||||
}
|
||||
|
||||
/* ---- hooks for ahb_swapchain.c (zero-copy layers) */
|
||||
struct dmabuf_buffer *banner_dmabuf_from_resource(struct wl_resource *buffer) { return get_dmabuf(buffer); }
|
||||
int banner_dmabuf_fd(const struct dmabuf_buffer *b) { return b && b->n_planes > 0 ? b->fd[0] : -1; }
|
||||
void banner_dmabuf_size(const struct dmabuf_buffer *b, int *w, int *h) { *w = b->width; *h = b->height; }
|
||||
void **banner_dmabuf_ahb_slot(struct dmabuf_buffer *b) { return &b->ahb_state; }
|
||||
void banner_dmabuf_ref(struct dmabuf_buffer *b) { b->refs++; }
|
||||
void banner_dmabuf_unref(struct dmabuf_buffer *b) { dmabuf_buffer_unref(b); }
|
||||
void banner_release_buffer(struct surface *s, struct wl_resource *buffer, int paced, int64_t since_ns) {
|
||||
struct dmabuf_buffer *droiddeck_dmabuf_from_resource(struct wl_resource *buffer) { return get_dmabuf(buffer); }
|
||||
int droiddeck_dmabuf_fd(const struct dmabuf_buffer *b) { return b && b->n_planes > 0 ? b->fd[0] : -1; }
|
||||
void droiddeck_dmabuf_size(const struct dmabuf_buffer *b, int *w, int *h) { *w = b->width; *h = b->height; }
|
||||
void **droiddeck_dmabuf_ahb_slot(struct dmabuf_buffer *b) { return &b->ahb_state; }
|
||||
void droiddeck_dmabuf_ref(struct dmabuf_buffer *b) { b->refs++; }
|
||||
void droiddeck_dmabuf_unref(struct dmabuf_buffer *b) { dmabuf_buffer_unref(b); }
|
||||
void droiddeck_release_buffer(struct surface *s, struct wl_resource *buffer, int paced, int64_t since_ns) {
|
||||
if (paced && s) release_buffer(s, buffer, since_ns);
|
||||
else { wl_buffer_send_release(buffer); perf_note_release(since_ns); }
|
||||
}
|
||||
void banner_surface_describe(const struct surface *s, char *out, size_t size) { describe(s, out, size); }
|
||||
const struct banner_color *banner_surface_color(const struct surface *s) {
|
||||
return s ? banner_color_of(s->resource) : NULL;
|
||||
void droiddeck_surface_describe(const struct surface *s, char *out, size_t size) { describe(s, out, size); }
|
||||
const struct droiddeck_color *droiddeck_surface_color(const struct surface *s) {
|
||||
return s ? droiddeck_color_of(s->resource) : NULL;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ wl_surface */
|
||||
@@ -1134,7 +1134,7 @@ static void surface_commit(struct wl_client *c, struct wl_resource *r) {
|
||||
wl_list_insert_list(s->feedback.prev, &s->pending_feedback);
|
||||
wl_list_init(&s->pending_feedback);
|
||||
constraints_surface_commit(s);
|
||||
banner_color_commit(s->resource); /* wp_color_management_surface_v1 state (returns at once when HDR is off) */
|
||||
droiddeck_color_commit(s->resource); /* wp_color_management_surface_v1 state (returns at once when HDR is off) */
|
||||
|
||||
if (s->role == ROLE_TOPLEVEL && s->xdg_toplevel) {
|
||||
if (s->has_content) map_toplevel(s);
|
||||
@@ -1184,9 +1184,9 @@ static void surface_resource_destroy(struct wl_resource *r) {
|
||||
if (g_grab == s) g_grab = NULL;
|
||||
if (g_key_target == s) g_key_target = NULL;
|
||||
if (g_ime_click == s) g_ime_click = NULL;
|
||||
banner_text_input_surface_gone(r);
|
||||
if (g_desktop == s) { g_desktop = NULL; banner_log("desktop", "the desktop closed"); }
|
||||
if (g_hud_surface == s) { g_hud_surface = NULL; banner_on_game_surface(NULL, NULL); }
|
||||
droiddeck_text_input_surface_gone(r);
|
||||
if (g_desktop == s) { g_desktop = NULL; droiddeck_log("desktop", "the desktop closed"); }
|
||||
if (g_hud_surface == s) { g_hud_surface = NULL; droiddeck_on_game_surface(NULL, NULL); }
|
||||
constraints_surface_gone(s);
|
||||
|
||||
unmap_toplevel(s);
|
||||
@@ -1436,7 +1436,7 @@ static void xdg_toplevel_set_title(struct wl_client *c, struct wl_resource *r, c
|
||||
if (announce) {
|
||||
char after[160];
|
||||
describe(s, after, sizeof(after));
|
||||
banner_log("window", "renamed %s -> %s", before, after);
|
||||
droiddeck_log("window", "renamed %s -> %s", before, after);
|
||||
}
|
||||
}
|
||||
static void xdg_toplevel_set_app_id(struct wl_client *c, struct wl_resource *r, const char *id) {}
|
||||
@@ -1613,7 +1613,7 @@ static void desktop_set_desktop(struct wl_client *c, struct wl_resource *r, stru
|
||||
if (!s || (s->role != ROLE_NONE && s->role != ROLE_DESKTOP)) return;
|
||||
s->role = ROLE_DESKTOP;
|
||||
g_desktop = s;
|
||||
banner_log("desktop", "Windows virtual desktop created by %s", client_name(c));
|
||||
droiddeck_log("desktop", "Windows virtual desktop created by %s", client_name(c));
|
||||
schedule_render();
|
||||
}
|
||||
static void desktop_set_window(struct wl_client *c, struct wl_resource *r, struct wl_resource *surface,
|
||||
@@ -1623,7 +1623,7 @@ static void desktop_set_window(struct wl_client *c, struct wl_resource *r, struc
|
||||
if (s->placed && (s->x != x || s->y != y) && s->mapped && log_budget()) {
|
||||
char name[160];
|
||||
describe(s, name, sizeof(name));
|
||||
banner_log("window", "moved %s to %d,%d", name, x, y);
|
||||
droiddeck_log("window", "moved %s to %d,%d", name, x, y);
|
||||
}
|
||||
s->hwnd = hwnd;
|
||||
s->x = x;
|
||||
@@ -1666,13 +1666,13 @@ static void bind_output(struct wl_client *c, void *data, uint32_t ver, uint32_t
|
||||
if (!r) { wl_client_post_no_memory(c); return; }
|
||||
wl_resource_set_implementation(r, &output_impl, NULL, NULL);
|
||||
wl_output_send_geometry(r, 0, 0, 340, 190, WL_OUTPUT_SUBPIXEL_UNKNOWN,
|
||||
"Bannerlator", "Wayland", WL_OUTPUT_TRANSFORM_NORMAL);
|
||||
"DroidDeck", "Wayland", WL_OUTPUT_TRANSFORM_NORMAL);
|
||||
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, "Bannerlator-1");
|
||||
wl_output_send_description(r, "Bannerlator display");
|
||||
wl_output_send_name(r, "DroidDeck-1");
|
||||
wl_output_send_description(r, "DroidDeck display");
|
||||
}
|
||||
if (ver >= 2) {
|
||||
wl_output_send_scale(r, 1);
|
||||
@@ -1701,7 +1701,7 @@ static int g_hdr_unimported_rect[4], g_hdr_unimported_below; /* scene x,y,w,h; d
|
||||
|
||||
static void note_hdr_unimported(const struct draw_list *dl, struct surface *s, int ox, int oy) {
|
||||
if (!s->dmabuf_buf || s->dmabuf_buf->img || !ahb_swapchain_has_ahb(s->dmabuf_buf)) return;
|
||||
const struct banner_color *c = banner_color_of(s->resource);
|
||||
const struct droiddeck_color *c = droiddeck_color_of(s->resource);
|
||||
if (!c || !c->dataspace) return;
|
||||
if (s->src_set && (s->src[0] != 0 || s->src[1] != 0 || (int)(s->src[2] + 0.5f) != s->buf_w ||
|
||||
(int)(s->src[3] + 0.5f) != s->buf_h)) return; /* cropped: the layer shows whole buffers */
|
||||
@@ -1730,7 +1730,7 @@ static void add_surface(struct draw_list *dl, struct surface *s, int ox, int oy)
|
||||
int dw, dh;
|
||||
|
||||
if (!img) {
|
||||
if (g_zero_copy && banner_color_hdr_open()) note_hdr_unimported(dl, s, ox, oy);
|
||||
if (g_zero_copy && droiddeck_color_hdr_open()) note_hdr_unimported(dl, s, ox, oy);
|
||||
return;
|
||||
}
|
||||
if (s->src_set) { sx = s->src[0]; sy = s->src[1]; sw = s->src[2]; sh = s->src[3]; }
|
||||
@@ -1850,7 +1850,7 @@ static struct surface *ahb_layer_only_candidate(int scene_w, int scene_h) {
|
||||
return s;
|
||||
}
|
||||
|
||||
/* ---- HDR (banner_color.h) in the scene
|
||||
/* ---- HDR (droiddeck_color.h) in the scene
|
||||
* An HDR frame is only right on the game's own display layer: everything the compositor draws itself -
|
||||
* the scene blit, the effects chain, frame generation, its swapchain - is 8-bit sRGB, and a PQ-encoded
|
||||
* frame pushed through it as it stands comes out washed out. So a scene with HDR in it takes one of
|
||||
@@ -1866,7 +1866,7 @@ static struct surface *ahb_layer_only_candidate(int scene_w, int scene_h) {
|
||||
* tone-mapped to SDR (correct colours instead of washed out).
|
||||
* 3 a frame the compositor could not import: only its display layer can show it, so effects and
|
||||
* frame generation are skipped for it (said in the log).
|
||||
* The drawer's HDR output switch (banner_color_output) OFF: the same routes, tone-mapped - route 0
|
||||
* The drawer's HDR output switch (droiddeck_color_output) OFF: the same routes, tone-mapped - route 0
|
||||
* becomes route 1 (the whole scene composed, tone-mapped to sRGB, onto the game layer UNtagged), route 2
|
||||
* tone-maps into an SDR swapchain, route 3 cannot (nothing can read those frames) and stays HDR, said
|
||||
* in the log. The game is told nothing; it keeps rendering HDR.
|
||||
@@ -1876,7 +1876,7 @@ struct hdr_scene {
|
||||
struct vkp_hdr_frame hf;
|
||||
unsigned char *is_hdr; /* per draw (calloc'd; free after the frame) */
|
||||
int n_hdr;
|
||||
const struct banner_color *color; /* the topmost HDR draw's description: what the picture is tagged */
|
||||
const struct droiddeck_color *color; /* the topmost HDR draw's description: what the picture is tagged */
|
||||
struct surface *who; /* its surface, for the log */
|
||||
const char *why; /* route 1: why the game cannot be alone on its layer */
|
||||
};
|
||||
@@ -1891,29 +1891,29 @@ static struct surface *hdr_unimported_fullscreen(const struct draw_list *dl, int
|
||||
}
|
||||
|
||||
static int is_hdr_surface(struct surface *s) {
|
||||
const struct banner_color *c = s ? banner_color_of(s->resource) : NULL;
|
||||
const struct droiddeck_color *c = s ? droiddeck_color_of(s->resource) : NULL;
|
||||
return c && c->dataspace;
|
||||
}
|
||||
|
||||
/* Which route this scene takes (see above); fills hs. 0 whenever the gate is closed or no HDR is drawn. */
|
||||
static int hdr_plan(const struct draw_list *dl, int w, int h, int fx_on, int framegen, struct hdr_scene *hs) {
|
||||
memset(hs, 0, sizeof(*hs));
|
||||
if (!banner_color_hdr_open()) return 0;
|
||||
if (!droiddeck_color_hdr_open()) return 0;
|
||||
/* A fullscreen HDR frame this compositor could not import has no draw: layer only (route 3). */
|
||||
struct surface *un = hdr_unimported_fullscreen(dl, w, h);
|
||||
if (!un) {
|
||||
struct surface *lo = ahb_layer_only_candidate(w, h);
|
||||
if (lo && is_hdr_surface(lo)) un = lo;
|
||||
}
|
||||
if (un && g_zero_copy) { hs->color = banner_color_of(un->resource); hs->who = un; return 3; }
|
||||
hs->hf.tonemap = !banner_color_output();
|
||||
if (un && g_zero_copy) { hs->color = droiddeck_color_of(un->resource); hs->who = un; return 3; }
|
||||
hs->hf.tonemap = !droiddeck_color_output();
|
||||
if (dl->n <= 0 || !(hs->is_hdr = calloc((size_t)dl->n, 1))) return 0;
|
||||
for (int i = 0; i < dl->n; i++) {
|
||||
struct surface *s = surface_for_image(dl->d[i].img);
|
||||
if (!is_hdr_surface(s)) continue;
|
||||
hs->is_hdr[i] = 1;
|
||||
hs->n_hdr++;
|
||||
hs->color = banner_color_of(s->resource); /* later draws are above: the topmost wins */
|
||||
hs->color = droiddeck_color_of(s->resource); /* later draws are above: the topmost wins */
|
||||
hs->who = s;
|
||||
}
|
||||
if (!hs->n_hdr) { free(hs->is_hdr); hs->is_hdr = NULL; return 0; }
|
||||
@@ -1942,7 +1942,7 @@ static int hdr_plan(const struct draw_list *dl, int w, int h, int fx_on, int fra
|
||||
static void hdr_note_route(int route, const struct hdr_scene *hs) {
|
||||
static int said = -1, said_tm = -1;
|
||||
static const char *said_why;
|
||||
const int tm = route != 0 && !banner_color_output(); /* route 0 never runs with the switch off */
|
||||
const int tm = route != 0 && !droiddeck_color_output(); /* route 0 never runs with the switch off */
|
||||
if (route == said && tm == said_tm && (route != 1 || hs->why == said_why)) return;
|
||||
int was = said;
|
||||
said = route; said_why = hs->why; said_tm = tm;
|
||||
@@ -1951,30 +1951,30 @@ static void hdr_note_route(int route, const struct hdr_scene *hs) {
|
||||
switch (route) {
|
||||
case 1:
|
||||
if (tm)
|
||||
banner_log("color", "tone-mapped picture for %s: %s - the whole scene is composed and tone-mapped to SDR "
|
||||
droiddeck_log("color", "tone-mapped picture for %s: %s - the whole scene is composed and tone-mapped to SDR "
|
||||
"(HDR peak %.0f nits rolled off to SDR white, screen effects applied after it) on the game's "
|
||||
"display layer, untagged", name, hs->why ? hs->why : "?", hs->hf.peak_nits);
|
||||
else
|
||||
banner_log("color", "HDR picture for %s: %s - the whole scene is composed into one 10-bit PQ BT.2020 picture "
|
||||
droiddeck_log("color", "HDR picture for %s: %s - the whole scene is composed into one 10-bit PQ BT.2020 picture "
|
||||
"(SDR content at %.0f nits, screen effects applied in 10-bit) on the game's display layer, tagged "
|
||||
"BT2020_PQ", name, hs->why ? hs->why : "?", banner_color_sdr_white());
|
||||
"BT2020_PQ", name, hs->why ? hs->why : "?", droiddeck_color_sdr_white());
|
||||
break;
|
||||
case 2:
|
||||
if (tm)
|
||||
banner_log("color", "HDR with frame generation for %s, HDR output switched off: the scene is tone-mapped to "
|
||||
droiddeck_log("color", "HDR with frame generation for %s, HDR output switched off: the scene is tone-mapped to "
|
||||
"SDR and presented through an SDR screen swapchain", name);
|
||||
else
|
||||
banner_log("color", "HDR with frame generation for %s: the scene is composed into PQ and presented through "
|
||||
droiddeck_log("color", "HDR with frame generation for %s: the scene is composed into PQ and presented through "
|
||||
"the screen swapchain (HDR10 where the surface offers it, else tone-mapped to SDR)", name);
|
||||
break;
|
||||
case 3:
|
||||
if (tm)
|
||||
banner_log("color", "HDR output is switched off, but %s's HDR frames cannot be imported by the compositor, "
|
||||
droiddeck_log("color", "HDR output is switched off, but %s's HDR frames cannot be imported by the compositor, "
|
||||
"so nothing can tone-map them: they stay HDR on its own display layer", name);
|
||||
break; /* otherwise hdr_note_precedence says it */
|
||||
default:
|
||||
if (was == 1 || was == 2)
|
||||
banner_log("color", "%s is back on its own display layer (no composition needed)", name);
|
||||
droiddeck_log("color", "%s is back on its own display layer (no composition needed)", name);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -1987,18 +1987,18 @@ static void hdr_note_precedence(struct surface *hs, int fx_on, int framegen) {
|
||||
if (hs) describe(hs, name, sizeof(name));
|
||||
if (fx_skip != fx_said) {
|
||||
if (fx_skip)
|
||||
banner_log("color", "screen effects are NOT applied to %s: its HDR frames cannot be imported by the "
|
||||
droiddeck_log("color", "screen effects are NOT applied to %s: its HDR frames cannot be imported by the "
|
||||
"compositor, so only its own display layer can show them (as they are)", name);
|
||||
else if (fx_said == 1)
|
||||
banner_log("color", "screen effects apply to the scene again (no HDR game on its layer, or effects off)");
|
||||
droiddeck_log("color", "screen effects apply to the scene again (no HDR game on its layer, or effects off)");
|
||||
fx_said = fx_skip;
|
||||
}
|
||||
if (fg_skip != fg_said) {
|
||||
if (fg_skip)
|
||||
banner_log("color", "frame generation is NOT applied to %s: its HDR frames cannot be imported by the "
|
||||
droiddeck_log("color", "frame generation is NOT applied to %s: its HDR frames cannot be imported by the "
|
||||
"compositor, so only its own display layer can show them (as they are)", name);
|
||||
else if (fg_said == 1)
|
||||
banner_log("color", "frame generation applies again (no HDR game on its layer, or frame generation off)");
|
||||
droiddeck_log("color", "frame generation applies again (no HDR game on its layer, or frame generation off)");
|
||||
fg_said = fg_skip;
|
||||
}
|
||||
}
|
||||
@@ -2007,11 +2007,11 @@ static void hdr_note_precedence(struct surface *hs, int fx_on, int framegen) {
|
||||
static void hdr_count_copied(const struct draw_list *dl, const char *reason) {
|
||||
for (int i = 0; i < dl->n; i++) {
|
||||
struct surface *s = surface_for_image(dl->d[i].img);
|
||||
const struct banner_color *c = s ? banner_color_of(s->resource) : NULL;
|
||||
const struct droiddeck_color *c = s ? droiddeck_color_of(s->resource) : NULL;
|
||||
if (!c || !c->dataspace) continue;
|
||||
char name[160];
|
||||
describe(s, name, sizeof(name));
|
||||
banner_color_frame_copied(name, reason);
|
||||
droiddeck_color_frame_copied(name, reason);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2028,8 +2028,8 @@ static void render_scene(void) {
|
||||
wl_list_for_each(s, &g_surfaces, link) s->drawn = 0;
|
||||
scene_size(&w, &h);
|
||||
if (w != g_scene_w || h != g_scene_h) {
|
||||
if (g_desktop) banner_log("desktop", "size %dx%d", w, h);
|
||||
else banner_log("desktop", "no desktop: showing %dx%d (largest window)", w, h);
|
||||
if (g_desktop) droiddeck_log("desktop", "size %dx%d", w, h);
|
||||
else droiddeck_log("desktop", "no desktop: showing %dx%d (largest window)", w, h);
|
||||
g_scene_w = w;
|
||||
g_scene_h = h;
|
||||
}
|
||||
@@ -2048,7 +2048,7 @@ static void render_scene(void) {
|
||||
struct hdr_scene hs;
|
||||
int hdr_route = hdr_plan(&dl, w, h, fx_on, framegen, &hs);
|
||||
const char *hdr_copy = NULL; /* why this scene's HDR frames (if any) took the copy path */
|
||||
if (banner_color_hdr_open()) {
|
||||
if (droiddeck_color_hdr_open()) {
|
||||
hdr_note_route(hdr_route, &hs);
|
||||
hdr_note_precedence(hdr_route == 3 ? hs.who : NULL, fx_on, framegen);
|
||||
}
|
||||
@@ -2059,12 +2059,12 @@ static void render_scene(void) {
|
||||
sc_layer_hide_overlay();
|
||||
if (sc_layer_present_hdr_scene(dl.d, dl.n, &hs.hf, w, h, hs.color) == 0) {
|
||||
rendered = vkp_base_black(w, h) == 0; /* black under the composed picture, presented only when it is not already */
|
||||
if (fell_back) { fell_back = 0; banner_log("color", "the composed picture is on the game's display layer again"); }
|
||||
if (fell_back) { fell_back = 0; droiddeck_log("color", "the composed picture is on the game's display layer again"); }
|
||||
} else {
|
||||
hdr_route = 2; /* no layer this frame: the picture goes through the swapchain instead */
|
||||
if (!fell_back) {
|
||||
fell_back = 1;
|
||||
banner_log("color", "the composed picture could not go on the game's display layer (no layer, or the "
|
||||
droiddeck_log("color", "the composed picture could not go on the game's display layer (no layer, or the "
|
||||
"10-bit pass failed): it goes through the screen swapchain instead until that changes");
|
||||
}
|
||||
}
|
||||
@@ -2074,8 +2074,8 @@ static void render_scene(void) {
|
||||
sc_layer_hide();
|
||||
rendered = vkp_render_hdr(w, h, dl.d, dl.n, &hs.hf, &how) == 0;
|
||||
copy = 1;
|
||||
if (rendered && how == 1) banner_color_frame_shown(hs.color, BANNER_HDR_SWAPCHAIN, 0);
|
||||
else if (rendered && how == 2) banner_color_frame_shown(hs.color, BANNER_HDR_TONEMAPPED, 0);
|
||||
if (rendered && how == 1) droiddeck_color_frame_shown(hs.color, DROIDDECK_HDR_SWAPCHAIN, 0);
|
||||
else if (rendered && how == 2) droiddeck_color_frame_shown(hs.color, DROIDDECK_HDR_TONEMAPPED, 0);
|
||||
else if (rendered) hdr_copy = "the HDR composition pass is unavailable on this driver";
|
||||
}
|
||||
if (hdr_route == 0 || hdr_route == 3) {
|
||||
@@ -2104,14 +2104,14 @@ static void render_scene(void) {
|
||||
if (g_zero_copy && blocked != g_zero_copy_paused) {
|
||||
g_zero_copy_paused = blocked;
|
||||
if (blocked == 1)
|
||||
banner_log("framegen", "zero-copy paused: frame generation needs the compositor pass");
|
||||
droiddeck_log("framegen", "zero-copy paused: frame generation needs the compositor pass");
|
||||
else if (blocked == 2)
|
||||
banner_log("effects", "zero-copy paused: a window above the game needs the compositor pass for the whole scene");
|
||||
droiddeck_log("effects", "zero-copy paused: a window above the game needs the compositor pass for the whole scene");
|
||||
else if (blocked == 3)
|
||||
banner_log("layer", "zero-copy paused: a window above the game would need a second display layer, "
|
||||
droiddeck_log("layer", "zero-copy paused: a window above the game would need a second display layer, "
|
||||
"which this display cannot compose in hardware - whole scene on the copy path");
|
||||
else
|
||||
banner_log("effects", "zero-copy resumed: the game is back on its own display layer");
|
||||
droiddeck_log("effects", "zero-copy resumed: the game is back on its own display layer");
|
||||
}
|
||||
struct surface *ls = li >= 0 ? surface_for_image(dl.d[li].img) : NULL;
|
||||
/* A frame this renderer could not import can only be shown on the layer, effects or not. */
|
||||
@@ -2121,7 +2121,7 @@ static void render_scene(void) {
|
||||
if (li < 0 && !ls) ls = hdr_unimported_fullscreen(&dl, w, h);
|
||||
if (ls && pass_on && li < 0 && !g_zero_copy_fx_skip_said) {
|
||||
g_zero_copy_fx_skip_said = 1;
|
||||
banner_log(framegen ? "framegen" : "effects",
|
||||
droiddeck_log(framegen ? "framegen" : "effects",
|
||||
"the game's frames cannot be imported by the compositor: shown zero-copy, %s skipped",
|
||||
framegen ? "frame generation" : "effects");
|
||||
}
|
||||
@@ -2148,7 +2148,7 @@ static void render_scene(void) {
|
||||
r = ahb_swapchain_present(ls->dmabuf_buf, ls, w, h);
|
||||
if (r != 0 && li >= 0)
|
||||
r = fx_on ? sc_layer_present_pass(&dl.d[li], 1, w, h)
|
||||
: sc_layer_present(dl.d[li].img, w, h, ls ? banner_surface_color(ls) : NULL);
|
||||
: sc_layer_present(dl.d[li].img, w, h, ls ? droiddeck_surface_color(ls) : NULL);
|
||||
if (r == 0) {
|
||||
if (ls) ls->drawn = 1;
|
||||
/* The one window above the game keeps the game off the copy path entirely: it goes on
|
||||
@@ -2188,7 +2188,7 @@ static void render_scene(void) {
|
||||
}
|
||||
} /* routes 0 and 3 */
|
||||
free(hs.is_hdr);
|
||||
if (hdr_copy && rendered && banner_color_hdr_open()) hdr_count_copied(&dl, hdr_copy);
|
||||
if (hdr_copy && rendered && droiddeck_color_hdr_open()) hdr_count_copied(&dl, hdr_copy);
|
||||
if (rendered) {
|
||||
int64_t t = now_ns();
|
||||
g_stat_frames++;
|
||||
@@ -2370,7 +2370,7 @@ static void bind_seat(struct wl_client *c, void *data, uint32_t ver, uint32_t id
|
||||
struct wl_resource *r = wl_resource_create(c, &wl_seat_interface, ver, id);
|
||||
wl_resource_set_implementation(r, &seat_impl, NULL, NULL);
|
||||
wl_seat_send_capabilities(r, WL_SEAT_CAPABILITY_POINTER | WL_SEAT_CAPABILITY_KEYBOARD | WL_SEAT_CAPABILITY_TOUCH);
|
||||
if (ver >= 2) wl_seat_send_name(r, "bannerlator-seat");
|
||||
if (ver >= 2) wl_seat_send_name(r, "droiddeck-seat");
|
||||
}
|
||||
|
||||
static struct seat_pointer *pointer_for(struct wl_client *client) {
|
||||
@@ -2449,7 +2449,7 @@ static void keyboard_focus(struct wl_resource *target) {
|
||||
g_mods_depressed, 0, g_mods_locked, 0);
|
||||
}
|
||||
wl_array_release(&keys);
|
||||
banner_clipboard_keyboard_focus(client); /* wl_data_device selection follows focus */
|
||||
droiddeck_clipboard_keyboard_focus(client); /* wl_data_device selection follows focus */
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ pointer constraints
|
||||
@@ -2502,14 +2502,14 @@ static int g_raw_valid;
|
||||
|
||||
/* JNI: the app switches its touch/mouse path to deltas while a lock holds and re-syncs its
|
||||
* pointer to x,y when it ends. */
|
||||
extern void banner_on_pointer_lock(int locked, int x, int y);
|
||||
extern void droiddeck_on_pointer_lock(int locked, int x, int y);
|
||||
|
||||
static void sync_lock_notify(void) {
|
||||
static int last;
|
||||
int locked = g_active_constraint && g_active_constraint->is_lock;
|
||||
if (locked == last) return;
|
||||
last = locked;
|
||||
banner_on_pointer_lock(locked, (int)g_ptr_x, (int)g_ptr_y);
|
||||
droiddeck_on_pointer_lock(locked, (int)g_ptr_x, (int)g_ptr_y);
|
||||
}
|
||||
|
||||
static void constraint_describe(const struct constraint *k, char *out, size_t size) {
|
||||
@@ -2570,7 +2570,7 @@ static void constraint_end(struct constraint *k, int tell_client, const char *wh
|
||||
g_raw_valid = 0;
|
||||
if (tell_client) constraint_send_state(k, 0);
|
||||
constraint_describe(k, name, sizeof(name));
|
||||
banner_log("pointer", "%s: %s (%s), pointer at %d,%d", k->is_lock ? "unlocked" : "unconfined",
|
||||
droiddeck_log("pointer", "%s: %s (%s), pointer at %d,%d", k->is_lock ? "unlocked" : "unconfined",
|
||||
name, why, (int)g_ptr_x, (int)g_ptr_y);
|
||||
sync_lock_notify();
|
||||
}
|
||||
@@ -2590,15 +2590,15 @@ static void constraint_activate(struct constraint *k) {
|
||||
constraint_send_state(k, 1);
|
||||
constraint_describe(k, name, sizeof(name));
|
||||
if (k->is_lock)
|
||||
banner_log("pointer", "locked: %s (%s), pointer frozen at %d,%d", name,
|
||||
droiddeck_log("pointer", "locked: %s (%s), pointer frozen at %d,%d", name,
|
||||
k->lifetime == ZWP_POINTER_CONSTRAINTS_V1_LIFETIME_ONESHOT ? "oneshot" : "persistent",
|
||||
(int)g_ptr_x, (int)g_ptr_y);
|
||||
else if (k->region.set)
|
||||
banner_log("pointer", "confined: %s (%s) to %dx%d at %d,%d of the window", name,
|
||||
droiddeck_log("pointer", "confined: %s (%s) to %dx%d at %d,%d of the window", name,
|
||||
k->lifetime == ZWP_POINTER_CONSTRAINTS_V1_LIFETIME_ONESHOT ? "oneshot" : "persistent",
|
||||
k->region.w, k->region.h, k->region.x, k->region.y);
|
||||
else
|
||||
banner_log("pointer", "confined: %s (%s) to the whole window", name,
|
||||
droiddeck_log("pointer", "confined: %s (%s) to the whole window", name,
|
||||
k->lifetime == ZWP_POINTER_CONSTRAINTS_V1_LIFETIME_ONESHOT ? "oneshot" : "persistent");
|
||||
sync_lock_notify();
|
||||
wl_display_flush_clients(g_display);
|
||||
@@ -2652,7 +2652,7 @@ static void constraints_surface_commit(struct surface *s) {
|
||||
g_ptr_x = ox + k->hint_x;
|
||||
g_ptr_y = oy + k->hint_y;
|
||||
if (log_budget())
|
||||
banner_log("pointer", "position hint: pointer moved to %d,%d", (int)g_ptr_x, (int)g_ptr_y);
|
||||
droiddeck_log("pointer", "position hint: pointer moved to %d,%d", (int)g_ptr_x, (int)g_ptr_y);
|
||||
}
|
||||
}
|
||||
if (k->pending_region_set) {
|
||||
@@ -2733,7 +2733,7 @@ static void constraint_create(struct wl_client *c, struct wl_resource *r, uint32
|
||||
wl_list_insert(g_constraints.prev, &k->link);
|
||||
if (!s) { k->defunct = 1; return; }
|
||||
constraint_describe(k, name, sizeof(name));
|
||||
banner_log("pointer", "%s requested by %s for %s", is_lock ? "lock" : "confine", client_name(c), name);
|
||||
droiddeck_log("pointer", "%s requested by %s for %s", is_lock ? "lock" : "confine", client_name(c), name);
|
||||
constraint_activate(k);
|
||||
}
|
||||
|
||||
@@ -2764,7 +2764,7 @@ static void bind_pointer_constraints(struct wl_client *c, void *data, uint32_t v
|
||||
static void relative_pointer_resource_destroy(struct wl_resource *r) {
|
||||
struct relative_pointer *rp = wl_resource_get_user_data(r);
|
||||
if (!rp) return;
|
||||
banner_log("pointer", "relative pointer released by %s", client_name(wl_resource_get_client(r)));
|
||||
droiddeck_log("pointer", "relative pointer released by %s", client_name(wl_resource_get_client(r)));
|
||||
wl_list_remove(&rp->link);
|
||||
free(rp);
|
||||
}
|
||||
@@ -2783,7 +2783,7 @@ static void relative_pointer_manager_get(struct wl_client *c, struct wl_resource
|
||||
rp->pointer = pointer;
|
||||
wl_resource_set_implementation(res, &relative_pointer_impl, rp, relative_pointer_resource_destroy);
|
||||
wl_list_insert(g_relative_pointers.prev, &rp->link);
|
||||
banner_log("pointer", "relative pointer created for %s (motion arrives as deltas)", client_name(c));
|
||||
droiddeck_log("pointer", "relative pointer created for %s (motion arrives as deltas)", client_name(c));
|
||||
}
|
||||
static const struct zwp_relative_pointer_manager_v1_interface relative_pointer_manager_impl = {
|
||||
.destroy = relative_pointer_manager_destroy,
|
||||
@@ -2890,7 +2890,7 @@ static void pointer_input(double x, double y, int relative, uint32_t button, int
|
||||
if (button && pressed && !(k && k->is_lock)) {
|
||||
struct surface *clicked = toplevel_at(g_ptr_x, g_ptr_y);
|
||||
if (clicked) g_ime_click = clicked;
|
||||
banner_text_input_refocus();
|
||||
droiddeck_text_input_refocus();
|
||||
}
|
||||
wl_display_flush_clients(g_display);
|
||||
}
|
||||
@@ -3034,7 +3034,7 @@ static void touch_down(int id, double x, double y) {
|
||||
if (!any_active) {
|
||||
char name[160];
|
||||
describe(target, name, sizeof(name));
|
||||
banner_log("touch", "%s gets %s", name, has_touch ? "touch" : "the pointer (it takes no touch)");
|
||||
droiddeck_log("touch", "%s gets %s", name, has_touch ? "touch" : "the pointer (it takes no touch)");
|
||||
}
|
||||
if (!has_touch) {
|
||||
if (any_active) return; /* one finger drives the pointer; a second one is ignored */
|
||||
@@ -3113,7 +3113,7 @@ static void deliver_touch(const struct input_msg *m) {
|
||||
}
|
||||
|
||||
static void key_event(uint32_t evdev, int pressed);
|
||||
struct wl_resource *banner_ime_target(void);
|
||||
struct wl_resource *droiddeck_ime_target(void);
|
||||
static void deliver_key(const struct input_msg *m) {
|
||||
key_event((uint32_t)m->p1, m->p2);
|
||||
}
|
||||
@@ -3177,7 +3177,7 @@ static void key_event(uint32_t evdev, int pressed) {
|
||||
* thread, not to the foreground program (X11 delivers keys to the focused app window, so
|
||||
* this mirrors it). The desktop itself is the last resort. */
|
||||
struct surface *target = NULL;
|
||||
struct wl_resource *ime = banner_ime_target();
|
||||
struct wl_resource *ime = droiddeck_ime_target();
|
||||
if (ime) target = wl_resource_get_user_data(ime);
|
||||
if (!target && g_key_target && g_key_target->mapped && g_key_target != g_desktop) target = g_key_target;
|
||||
if (!target) target = g_desktop;
|
||||
@@ -3220,14 +3220,14 @@ static int on_input_readable(int fd, uint32_t mask, void *data) {
|
||||
|
||||
/* Called from JNI (Android UI thread). Queues a pointer event; the compositor thread
|
||||
* dispatches it. x/y are in INPUT_SPACE (0..1919, 0..1079) over the whole output. */
|
||||
void banner_wayland_send_pointer(int action, int x, int y) {
|
||||
void droiddeck_wayland_send_pointer(int action, int x, int y) {
|
||||
if (g_input_pipe[1] < 0) return;
|
||||
struct input_msg m = { 0, action, x, y, 0 };
|
||||
ssize_t n = write(g_input_pipe[1], &m, sizeof(m));
|
||||
(void)n;
|
||||
}
|
||||
|
||||
void banner_wayland_send_touch(int action, int pointer_id, int x, int y) {
|
||||
void droiddeck_wayland_send_touch(int action, int pointer_id, int x, int y) {
|
||||
if (g_input_pipe[1] < 0) return;
|
||||
struct input_msg m = { 7, action, pointer_id, x, y };
|
||||
ssize_t n = write(g_input_pipe[1], &m, sizeof(m));
|
||||
@@ -3235,7 +3235,7 @@ void banner_wayland_send_touch(int action, int pointer_id, int x, int y) {
|
||||
}
|
||||
|
||||
/* Called from JNI. Queues a key event. evdev = Linux input keycode (e.g. KEY_A=30); state 1=down 0=up. */
|
||||
void banner_wayland_send_key(int evdev, int state) {
|
||||
void droiddeck_wayland_send_key(int evdev, int state) {
|
||||
if (g_input_pipe[1] < 0) return;
|
||||
struct input_msg m = { 1, evdev, state, 0, 0 };
|
||||
ssize_t n = write(g_input_pipe[1], &m, sizeof(m));
|
||||
@@ -3243,7 +3243,7 @@ void banner_wayland_send_key(int evdev, int state) {
|
||||
}
|
||||
|
||||
/* Called from JNI on every screen refresh (Choreographer). */
|
||||
void banner_wayland_vsync(int64_t frame_time_ns) {
|
||||
void droiddeck_wayland_vsync(int64_t frame_time_ns) {
|
||||
if (g_input_pipe[1] < 0) return;
|
||||
struct input_msg m = { 5, (int)(frame_time_ns >> 32), (int)(uint32_t)frame_time_ns, 0, 0 };
|
||||
ssize_t n = write(g_input_pipe[1], &m, sizeof(m));
|
||||
@@ -3253,7 +3253,7 @@ void banner_wayland_vsync(int64_t frame_time_ns) {
|
||||
/* Called from JNI with the app's X-server input (on-screen controls, mouse): type 2 = motion
|
||||
* to scene x,y; 3 = evdev button a pressed/released (b); 4 = a wheel steps (negative = up);
|
||||
* 6 = relative motion by a,b in 1/256 pixel (the app's Relative Mouse / captured-mouse path). */
|
||||
void banner_wayland_send_scene_input(int type, int a, int b) {
|
||||
void droiddeck_wayland_send_scene_input(int type, int a, int b) {
|
||||
if (g_input_pipe[1] < 0) return;
|
||||
struct input_msg m = { type, a, b, 0, 0 };
|
||||
ssize_t n = write(g_input_pipe[1], &m, sizeof(m));
|
||||
@@ -3261,15 +3261,15 @@ void banner_wayland_send_scene_input(int type, int a, int b) {
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ extension module hooks
|
||||
* What wl_clipboard.c / wl_text_input.c need from the scene (see banner_ext.h). */
|
||||
* What wl_clipboard.c / wl_text_input.c need from the scene (see droiddeck_ext.h). */
|
||||
|
||||
const char *banner_client_name(struct wl_client *client) { return client_name(client); }
|
||||
struct wl_display *banner_get_display(void) { return g_display; }
|
||||
void banner_request_redraw(void) { schedule_render(); }
|
||||
const char *droiddeck_client_name(struct wl_client *client) { return client_name(client); }
|
||||
struct wl_display *droiddeck_get_display(void) { return g_display; }
|
||||
void droiddeck_request_redraw(void) { schedule_render(); }
|
||||
|
||||
/* The surface text input follows: the last clicked mapped program window, else the topmost
|
||||
* mapped window not owned by the desktop's process (explorer). NULL when there is none. */
|
||||
struct wl_resource *banner_ime_target(void) {
|
||||
struct wl_resource *droiddeck_ime_target(void) {
|
||||
struct surface *s;
|
||||
if (g_ime_click && g_ime_click->mapped) return g_ime_click->resource;
|
||||
struct wl_client *shell = g_desktop ? wl_resource_get_client(g_desktop->resource) : NULL;
|
||||
@@ -3278,13 +3278,13 @@ struct wl_resource *banner_ime_target(void) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
void banner_surface_scene_origin(struct wl_resource *surface, int *x, int *y) {
|
||||
void droiddeck_surface_scene_origin(struct wl_resource *surface, int *x, int *y) {
|
||||
struct surface *s = surface ? wl_resource_get_user_data(surface) : NULL;
|
||||
*x = 0; *y = 0;
|
||||
if (s && s->placed) { *x = s->x; *y = s->y; }
|
||||
}
|
||||
|
||||
void banner_inject_key(uint32_t evdev, int pressed) { key_event(evdev, pressed); }
|
||||
void droiddeck_inject_key(uint32_t evdev, int pressed) { key_event(evdev, pressed); }
|
||||
|
||||
/* ------------------------------------------------------------------ 10 s summary */
|
||||
|
||||
@@ -3311,7 +3311,7 @@ static int on_stats_timer(void *data) {
|
||||
* zero-copy, so they are counted apart from the line above. */
|
||||
if (layer_frames) off += snprintf(extra + off, sizeof(extra) - (size_t)off, " | %u layer frames", layer_frames);
|
||||
if (generated) snprintf(extra + off, sizeof(extra) - (size_t)off, " | %u generated frames", generated);
|
||||
banner_log("stats", "last 10 s: %u frames on screen (%.1f fps) | %u GPU frames from games | %u window redraws | %d windows open%s",
|
||||
droiddeck_log("stats", "last 10 s: %u frames on screen (%.1f fps) | %u GPU frames from games | %u window redraws | %d windows open%s",
|
||||
g_stat_frames + generated, (g_stat_frames + generated) / 10.0, g_stat_dmabuf, g_stat_shm, windows, extra);
|
||||
}
|
||||
/* The perf line, next to the stats line and only when something happened: the compositor thread's
|
||||
@@ -3323,7 +3323,7 @@ static int on_stats_timer(void *data) {
|
||||
const unsigned drops = sc_layer_drops_take();
|
||||
#define PERF_MS(sum, n) ((n) ? (double)(sum) / 1e6 / (double)(n) : 0.0)
|
||||
if (g_perf.scenes || vp.presents || g_perf.releases || drops)
|
||||
banner_log("perf", "last 10 s: %u ticks, %u scenes, %u on screen (copy %u, zero-copy %u, layer copy %u) | "
|
||||
droiddeck_log("perf", "last 10 s: %u ticks, %u scenes, %u on screen (copy %u, zero-copy %u, layer copy %u) | "
|
||||
"render_scene %.2f/%.2f ms | base %u black kept, %u presented | acquire %.2f/%.2f ms | "
|
||||
"present %.2f/%.2f ms (%u) | fence wait %.2f/%.2f ms (%u, %u GPU release waits) | "
|
||||
"release %.2f/%.2f ms (%u, %u held) | %u pool drops",
|
||||
@@ -3339,7 +3339,7 @@ static int on_stats_timer(void *data) {
|
||||
memset(&g_perf, 0, sizeof(g_perf));
|
||||
}
|
||||
g_stat_frames = g_stat_dmabuf = g_stat_shm = 0;
|
||||
banner_color_stats_tick(); /* HDR evidence for the same window (nothing when the HDR gate is closed) */
|
||||
droiddeck_color_stats_tick(); /* HDR evidence for the same window (nothing when the HDR gate is closed) */
|
||||
/* A program that asked for dma-buf feedback (EGL's GPU path does, first thing) but has drawn
|
||||
* only wl_shm frames since is an OpenGL program whose EGL gave up on the GPU. The software
|
||||
* path it fell to has no rasteriser in the Wayland layers, so what it commits is black. */
|
||||
@@ -3347,7 +3347,7 @@ static int on_stats_timer(void *data) {
|
||||
if (!ci->asked_feedback || ci->dmabuf_buffers || ci->shm_gl_said || ci->shm_frames < 150)
|
||||
continue;
|
||||
ci->shm_gl_said = 1;
|
||||
banner_log("opengl", "%s asked for GPU buffers but has drawn only software (shared-memory) "
|
||||
droiddeck_log("opengl", "%s asked for GPU buffers but has drawn only software (shared-memory) "
|
||||
"frames: its OpenGL fell back to software rendering, which the Wayland layer cannot "
|
||||
"draw - expect a black picture (main device %u:%u)", ci->name,
|
||||
(unsigned)major(g_main_device), (unsigned)minor(g_main_device));
|
||||
@@ -3373,7 +3373,7 @@ static void test_input_line(const char *line) {
|
||||
int n = sscanf(line, "%15s %lf %lf", cmd, &x, &y);
|
||||
|
||||
if (n < 2) return;
|
||||
banner_log("test", "input: %s", line);
|
||||
droiddeck_log("test", "input: %s", line);
|
||||
if (!strcmp(cmd, "key") || !strcmp(cmd, "keydown") || !strcmp(cmd, "keyup")) {
|
||||
code = (unsigned)x;
|
||||
if (strcmp(cmd, "keyup")) key_event(code, 1);
|
||||
@@ -3436,7 +3436,7 @@ static void start_test_input(struct wl_event_loop *loop) {
|
||||
|
||||
static struct wl_listener g_client_created = { .notify = on_client_created };
|
||||
|
||||
int banner_wayland_run(void) {
|
||||
int droiddeck_wayland_run(void) {
|
||||
wl_list_init(&g_surfaces);
|
||||
wl_list_init(&g_toplevels);
|
||||
wl_list_init(&g_constraints);
|
||||
@@ -3468,7 +3468,7 @@ int banner_wayland_run(void) {
|
||||
rt ? rt : "(null)");
|
||||
return 1;
|
||||
}
|
||||
banner_log("display", "Wayland compositor listening on %s/wayland-0", rt ? rt : "?");
|
||||
droiddeck_log("display", "Wayland compositor listening on %s/wayland-0", rt ? rt : "?");
|
||||
wl_display_add_client_created_listener(display, &g_client_created);
|
||||
|
||||
wl_global_create(display, &wl_compositor_interface, 6, NULL, bind_compositor);
|
||||
@@ -3485,13 +3485,13 @@ int banner_wayland_run(void) {
|
||||
wl_global_create(display, &wp_presentation_interface, 2, NULL, bind_presentation);
|
||||
wl_global_create(display, &zwp_pointer_constraints_v1_interface, 1, NULL, bind_pointer_constraints);
|
||||
wl_global_create(display, &zwp_relative_pointer_manager_v1_interface, 1, NULL, bind_relative_pointer_manager);
|
||||
banner_ext_init(display); /* clipboard, text input, toplevel icons (own files, see banner_ext.h) */
|
||||
droiddeck_ext_init(display); /* clipboard, text input, toplevel icons (own files, see droiddeck_ext.h) */
|
||||
ahb_swapchain_init(display); /* zero-copy layers: banner_ahb_v1, advertised whenever a display
|
||||
* layer is possible; the mode event carries the live switch
|
||||
* (ahb_swapchain.h) */
|
||||
banner_color_init(display); /* HDR10 (opt-in): decides the gate from the app's request + the
|
||||
droiddeck_color_init(display); /* HDR10 (opt-in): decides the gate from the app's request + the
|
||||
* display + the layer path above; creates wp_color_manager_v1
|
||||
* only when it is open (banner_color.h). Before any client can
|
||||
* only when it is open (droiddeck_color.h). Before any client can
|
||||
* bind zwp_linux_dmabuf_v1, whose table it widens. */
|
||||
wl_list_init(&g_pending_releases);
|
||||
g_release_timer_fd = timerfd_create(CLOCK_MONOTONIC, TFD_NONBLOCK | TFD_CLOEXEC);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/*
|
||||
* What compositor.c shares with the protocol modules split out of it (wl_dmabuf.c): the dma-buf
|
||||
* buffer records, the per-client bookkeeping and the few functions and globals both sides use.
|
||||
* Everything declared here is hidden: it is internal to libbannerwayland, not part of its ABI.
|
||||
* Everything declared here is hidden: it is internal to libdroiddeckwayland, not part of its ABI.
|
||||
*/
|
||||
#pragma once
|
||||
#include <stdint.h>
|
||||
@@ -13,7 +13,7 @@
|
||||
#define COMPOSITOR_INTERNAL __attribute__((visibility("hidden")))
|
||||
|
||||
#ifndef WLOGE
|
||||
#define WLOGE(...) __android_log_print(ANDROID_LOG_ERROR, "BannerWayland", __VA_ARGS__)
|
||||
#define WLOGE(...) __android_log_print(ANDROID_LOG_ERROR, "DroidDeckWayland", __VA_ARGS__)
|
||||
#endif
|
||||
|
||||
/* ------------------------------------------------------------------ dmabuf buffers */
|
||||
@@ -71,8 +71,8 @@ struct client_info {
|
||||
COMPOSITOR_INTERNAL struct client_info *client_info_of(struct wl_client *client);
|
||||
COMPOSITOR_INTERNAL void dmabuf_buffer_unref(struct dmabuf_buffer *b);
|
||||
COMPOSITOR_INTERNAL extern const struct wl_buffer_interface dbuf_buffer_impl;
|
||||
extern volatile int g_ubwc; /* BANNER_WAYLAND_UBWC (waylandcomp_jni.c sets it) */
|
||||
extern volatile int g_no_render_node; /* BANNER_WAYLAND_NO_RENDER_NODE */
|
||||
extern volatile int g_ubwc; /* DROIDDECK_WAYLAND_UBWC (waylandcomp_jni.c sets it) */
|
||||
extern volatile int g_no_render_node; /* DROIDDECK_WAYLAND_NO_RENDER_NODE */
|
||||
|
||||
/* ---- wl_dmabuf.c */
|
||||
COMPOSITOR_INTERNAL extern dev_t g_main_device; /* the render node clients are told to allocate on */
|
||||
|
||||
+47
-47
@@ -1,5 +1,5 @@
|
||||
#ifndef BANNER_COLOR_H
|
||||
#define BANNER_COLOR_H
|
||||
#ifndef DROIDDECK_COLOR_H
|
||||
#define DROIDDECK_COLOR_H
|
||||
/*
|
||||
* HDR10 output on the Wayland backend (opt-in) - the compositor half of HDR_RECON.md Phase A (round 1)
|
||||
* plus HDR-aware composition (round 2, hdr_compose.h), implemented in wl_color_mgmt.c. The game half
|
||||
@@ -8,18 +8,18 @@
|
||||
* with DXVK_HDR=1 (the app exports it when the setting is on).
|
||||
*
|
||||
* THE GATE. Nothing here exists for a session unless all of these hold when the compositor starts:
|
||||
* - the container's / game's "HDR output" setting is on, or BANNER_WAYLAND_HDR=1 in their
|
||||
* - the container's / game's "HDR output" setting is on, or DROIDDECK_WAYLAND_HDR=1 in their
|
||||
* environment (which overrides the setting either way);
|
||||
* - the display the game is on lists HDR10 among its supported HDR types (read by the app from
|
||||
* android.view.Display - a property of the connector, not of the device);
|
||||
* - display layers with dataspace control (ASurfaceTransaction_setBufferDataSpace, Android 10+)
|
||||
* and the zero-copy global (banner_ahb_v1): the HDR frame (or the composed HDR picture) is shown
|
||||
* on the game's own display layer, tagged BT2020_PQ.
|
||||
* In game, the drawer's HDR output switch (banner_color_set_output) flips between that and the same
|
||||
* In game, the drawer's HDR output switch (droiddeck_color_set_output) flips between that and the same
|
||||
* frames tone-mapped to SDR, live; the gate itself never changes during a session.
|
||||
* A closed gate advertises nothing - no colour-management global, no 10-bit dma-buf formats - so the
|
||||
* session is byte-for-byte what it was before this file existed, and the session log says why.
|
||||
* BANNER_WAYLAND_HDR=force skips the display check only (testing the negotiation on an SDR panel;
|
||||
* DROIDDECK_WAYLAND_HDR=force skips the display check only (testing the negotiation on an SDR panel;
|
||||
* SurfaceFlinger then tone-maps the layer, and says so in its composition type).
|
||||
*
|
||||
* WHAT AN OPEN GATE OFFERS (exactly the subset Mesa binds, HDR_RECON.md §2.2): wp_color_manager_v1
|
||||
@@ -35,13 +35,13 @@
|
||||
#include <wayland-server.h>
|
||||
|
||||
/* ADataSpace values (NDK <android/data_space.h>) the layer path emits. */
|
||||
#define BANNER_ADATASPACE_UNKNOWN 0
|
||||
#define BANNER_ADATASPACE_BT2020_PQ 163971072 /* STANDARD_BT2020 | TRANSFER_ST2084 | RANGE_FULL = 0x09c60000 */
|
||||
#define BANNER_ADATASPACE_BT2020_HLG 168165376 /* STANDARD_BT2020 | TRANSFER_HLG | RANGE_FULL */
|
||||
#define DROIDDECK_ADATASPACE_UNKNOWN 0
|
||||
#define DROIDDECK_ADATASPACE_BT2020_PQ 163971072 /* STANDARD_BT2020 | TRANSFER_ST2084 | RANGE_FULL = 0x09c60000 */
|
||||
#define DROIDDECK_ADATASPACE_BT2020_HLG 168165376 /* STANDARD_BT2020 | TRANSFER_HLG | RANGE_FULL */
|
||||
|
||||
/* One image description as the program made it, and what it becomes on a display layer. Immutable:
|
||||
* a changed description is a new record with a new identity. */
|
||||
struct banner_color {
|
||||
struct droiddeck_color {
|
||||
uint32_t identity; /* the id the `ready` event carried (never 0) */
|
||||
uint32_t primaries, tf; /* wp_color_manager_v1 named values */
|
||||
int32_t dataspace; /* ADataSpace on a display layer; 0 = none (treated as sRGB) */
|
||||
@@ -54,97 +54,97 @@ struct banner_color {
|
||||
};
|
||||
|
||||
/* ---- app -> compositor (JNI, any thread) */
|
||||
/* mode 0 = off, 1 = BANNER_WAYLAND_HDR=1, 2 = BANNER_WAYLAND_HDR=force (testing). source names where
|
||||
/* mode 0 = off, 1 = DROIDDECK_WAYLAND_HDR=1, 2 = DROIDDECK_WAYLAND_HDR=force (testing). source names where
|
||||
* the switch came from; dxvk_hdr = DXVK_HDR=1 is in the game's environment; zero_copy_forced = the
|
||||
* app turned zero-copy presentation on for this session because HDR needs it. Before the start. */
|
||||
void banner_color_set_request(int mode, const char *source, int dxvk_hdr, int zero_copy_forced);
|
||||
void droiddeck_color_set_request(int mode, const char *source, int dxvk_hdr, int zero_copy_forced);
|
||||
/* The display the game is on, as android.view.Display reports it. Before the start (it feeds the
|
||||
* gate) and again whenever it changes (logged; the gate is decided once per session). */
|
||||
void banner_color_set_display(int id, const char *name, const char *formats, int hdr10, float max_lum,
|
||||
void droiddeck_color_set_display(int id, const char *name, const char *formats, int hdr10, float max_lum,
|
||||
float max_avg, float min_lum, int ratio_available, float ratio, int api);
|
||||
/* One reading of Display.getHdrSdrRatio() (API 34+; < 0 = not available). listener = it came from
|
||||
* the display's ratio listener rather than the app's periodic sampler. */
|
||||
void banner_color_ratio_sample(float ratio, int listener);
|
||||
void droiddeck_color_ratio_sample(float ratio, int listener);
|
||||
/* Milliseconds since an HDR frame last went onto a display layer; -1 = none this session. */
|
||||
int banner_color_last_frame_age_ms(void);
|
||||
int droiddeck_color_last_frame_age_ms(void);
|
||||
/* -1 = the compositor has not decided yet, 0 = closed, 1 = open. */
|
||||
int banner_color_gate_state(void);
|
||||
int droiddeck_color_gate_state(void);
|
||||
/* The session is ending: write the summary line ("HDR on screen: …"). */
|
||||
void banner_color_session_end(void);
|
||||
void droiddeck_color_session_end(void);
|
||||
/* 1 while HDR frames are really on screen: a frame tagged BT2020_PQ reached a display layer or the HDR
|
||||
* swapchain in the last 1.5 s AND, where the display reports an HDR/SDR ratio, its last reading is
|
||||
* above 1.01 (the HUD badge). */
|
||||
int banner_color_hdr_on_screen(void);
|
||||
int droiddeck_color_hdr_on_screen(void);
|
||||
/* The HUD / drawer state: 0 = no HDR frames on screen (or not confirmed yet), 1 = HDR frames on screen
|
||||
* with headroom (as banner_color_hdr_on_screen), 2 = HDR frames on screen but the display has given
|
||||
* with headroom (as droiddeck_color_hdr_on_screen), 2 = HDR frames on screen but the display has given
|
||||
* them NO headroom (ratio <= 1.01) for 5 s or more - the brightness slider at maximum, or a screen
|
||||
* recording (Android turns HDR headroom off while the screen is recorded). */
|
||||
int banner_color_hdr_state(void);
|
||||
int droiddeck_color_hdr_state(void);
|
||||
/* Nits SDR content is placed at inside an HDR picture (default 203, BT.2408); any thread. */
|
||||
void banner_color_set_sdr_white(float nits);
|
||||
float banner_color_sdr_white(void);
|
||||
/* The HDR session was asked for (BANNER_WAYLAND_HDR / the setting resolved on or force) - known before
|
||||
void droiddeck_color_set_sdr_white(float nits);
|
||||
float droiddeck_color_sdr_white(void);
|
||||
/* The HDR session was asked for (DROIDDECK_WAYLAND_HDR / the setting resolved on or force) - known before
|
||||
* the compositor starts, so the Vulkan instance can enable VK_EXT_swapchain_colorspace for it. */
|
||||
int banner_color_requested(void);
|
||||
int droiddeck_color_requested(void);
|
||||
/* The drawer's live "HDR output" switch, per session, starting ON. On = HDR frames go to the display as
|
||||
* HDR; off = the SAME frames are composed tone-mapped to SDR (hdr_compose.h) - the game is told nothing
|
||||
* and keeps rendering HDR (DXVK_HDR and the colour-manager offer were decided at launch). Compositor
|
||||
* thread (the app posts it through banner_host_hdr_output); logs the flip and redraws. */
|
||||
void banner_color_set_output(int on);
|
||||
* thread (the app posts it through droiddeck_host_hdr_output); logs the flip and redraws. */
|
||||
void droiddeck_color_set_output(int on);
|
||||
/* 1 = HDR output on (any thread). */
|
||||
int banner_color_output(void);
|
||||
int droiddeck_color_output(void);
|
||||
/* 1 while an HDR game's frames are being shown tone-mapped to SDR (the last one < 1.5 s ago); any thread. */
|
||||
int banner_color_tonemapped_on_screen(void);
|
||||
int droiddeck_color_tonemapped_on_screen(void);
|
||||
/* Device evidence beside the HDR/SDR headroom (the app, non-root APIs; any thread; HDR sessions only):
|
||||
* PowerManager thermal status (0..6, -1 unknown) + getThermalHeadroom(10) (< 0 = not available),
|
||||
* Settings.System SCREEN_BRIGHTNESS (0..255, -1 unknown) + SCREEN_BRIGHTNESS_MODE (1 auto, 0 manual).
|
||||
* Changes of status and brightness are logged; every no-headroom line, the 10 s line and the verdict
|
||||
* carry the last values. */
|
||||
void banner_color_env_sample(int thermal, float headroom, int brightness, int bmode);
|
||||
void droiddeck_color_env_sample(int thermal, float headroom, int brightness, int bmode);
|
||||
|
||||
/* ---- the explicit HDR headroom request (Android 15+: ASurfaceTransaction_setDesiredHdrHeadroom on the game
|
||||
* layer, SurfaceView.setDesiredHdrHeadroom on the screen surface). Some phones only boost HDR when asked. */
|
||||
/* The ratio to ask for an HDR frame of `c`: content peak (max CLL, else mastering max, else the display's
|
||||
* peak) / SDR white (203), capped at the display's highest ratio when it reports one; 0 = ask nothing
|
||||
* (not HDR, or the display reports it cannot boost). `why` gets the one-line reason. Any thread. */
|
||||
float banner_color_desired_headroom(const struct banner_color *c, char *why, size_t n);
|
||||
float droiddeck_color_desired_headroom(const struct droiddeck_color *c, char *why, size_t n);
|
||||
/* What was last asked for (> 0 the ratio, 0 nothing, -1 the API is missing): for the no-headroom lines. */
|
||||
void banner_color_note_headroom_request(float ratio);
|
||||
void droiddeck_color_note_headroom_request(float ratio);
|
||||
/* Display.getHighestHdrSdrRatio() (Android 16+; <= 0 = not reported). Any thread; logged when it arrives. */
|
||||
void banner_color_set_highest_ratio(float ratio);
|
||||
void droiddeck_color_set_highest_ratio(float ratio);
|
||||
/* The headroom the SCREEN surface should ask for (frames through the HDR10 swapchain in the last 1.5 s),
|
||||
* 0 = none; `why` gets the reason. The app polls it and applies it. Any thread. */
|
||||
float banner_color_screen_headroom(char *why, size_t n);
|
||||
float droiddeck_color_screen_headroom(char *why, size_t n);
|
||||
|
||||
/* ---- compositor.c -> here */
|
||||
/* Decide the gate (call after ahb_swapchain_init) and create the global when it is open. */
|
||||
void banner_color_init(struct wl_display *display);
|
||||
void droiddeck_color_init(struct wl_display *display);
|
||||
/* The gate is open: the 10-bit dma-buf formats may be advertised, descriptions may exist. */
|
||||
int banner_color_hdr_open(void);
|
||||
int droiddeck_color_hdr_open(void);
|
||||
/* wl_surface.commit: the pending image description (if any request touched it) becomes current. */
|
||||
void banner_color_commit(struct wl_resource *surface);
|
||||
void droiddeck_color_commit(struct wl_resource *surface);
|
||||
/* The surface's current image description; NULL = none (sRGB, today's default). */
|
||||
const struct banner_color *banner_color_of(struct wl_resource *surface);
|
||||
const struct droiddeck_color *droiddeck_color_of(struct wl_resource *surface);
|
||||
/* A program disconnected: if it presented HDR, its part of the summary is written now. */
|
||||
void banner_color_client_gone(struct wl_client *client);
|
||||
void droiddeck_color_client_gone(struct wl_client *client);
|
||||
/* The 10 s summary tick (one `color` line when anything HDR happened in the window). */
|
||||
void banner_color_stats_tick(void);
|
||||
void droiddeck_color_stats_tick(void);
|
||||
/* A frame of an HDR-described surface went through the compositor's own 8-bit sRGB pass this scene
|
||||
* (who = the window, reason = why it did not get the display layer). Shown untone-mapped. */
|
||||
void banner_color_frame_copied(const char *who, const char *reason);
|
||||
void droiddeck_color_frame_copied(const char *who, const char *reason);
|
||||
|
||||
/* ---- sc_layer.c / compositor.c -> here: a NEW frame of an HDR-described surface was shown, and how */
|
||||
enum banner_hdr_path {
|
||||
BANNER_HDR_ZERO_COPY = 0, /* the game's own gralloc buffer on its display layer, tagged */
|
||||
BANNER_HDR_LAYER_COPY, /* one 8-bit copy of the game's frame on its layer, tagged */
|
||||
BANNER_HDR_COMPOSED, /* the whole scene composed into one PQ picture on the game layer (hdr_compose.h) */
|
||||
BANNER_HDR_SWAPCHAIN, /* composed into PQ and presented through an HDR10 swapchain (frame generation) */
|
||||
BANNER_HDR_TONEMAPPED, /* composed and tone-mapped to SDR (HDR output switched off, or a present that
|
||||
enum droiddeck_hdr_path {
|
||||
DROIDDECK_HDR_ZERO_COPY = 0, /* the game's own gralloc buffer on its display layer, tagged */
|
||||
DROIDDECK_HDR_LAYER_COPY, /* one 8-bit copy of the game's frame on its layer, tagged */
|
||||
DROIDDECK_HDR_COMPOSED, /* the whole scene composed into one PQ picture on the game layer (hdr_compose.h) */
|
||||
DROIDDECK_HDR_SWAPCHAIN, /* composed into PQ and presented through an HDR10 swapchain (frame generation) */
|
||||
DROIDDECK_HDR_TONEMAPPED, /* composed and tone-mapped to SDR (HDR output switched off, or a present that
|
||||
* cannot carry HDR) - NOT HDR */
|
||||
};
|
||||
/* ahb_format = the buffer's AHARDWAREBUFFER_FORMAT_* where one exists (0 otherwise). */
|
||||
void banner_color_frame_shown(const struct banner_color *c, int path, uint32_t ahb_format);
|
||||
void droiddeck_color_frame_shown(const struct droiddeck_color *c, int path, uint32_t ahb_format);
|
||||
/* "RGBA1010102 (10-bit)" etc. for an AHARDWAREBUFFER_FORMAT_* value (static buffer). */
|
||||
const char *banner_ahb_format_name(uint32_t format);
|
||||
const char *droiddeck_ahb_format_name(uint32_t format);
|
||||
|
||||
#endif
|
||||
+13
-13
@@ -2,7 +2,7 @@
|
||||
* Host → compositor message queue plus the init of the extension modules.
|
||||
*
|
||||
* The app's threads (UI thread, clipboard listener) hand text to the compositor through
|
||||
* banner_host_* ; the messages are queued under a mutex and a pipe byte wakes the wl event
|
||||
* droiddeck_host_* ; the messages are queued under a mutex and a pipe byte wakes the wl event
|
||||
* loop, which drains them on the compositor thread - the only thread that may touch
|
||||
* wl_resources.
|
||||
*/
|
||||
@@ -13,9 +13,9 @@
|
||||
#include <fcntl.h>
|
||||
#include <pthread.h>
|
||||
#include <android/log.h>
|
||||
#include "banner_ext.h"
|
||||
#include "droiddeck_ext.h"
|
||||
#include "ahb_swapchain.h"
|
||||
#include "banner_color.h"
|
||||
#include "droiddeck_color.h"
|
||||
|
||||
enum host_kind { HOST_CLIPBOARD = 1, HOST_TEXT_COMMIT, HOST_TEXT_PREEDIT, HOST_TEXT_DELETE, HOST_ZERO_COPY, HOST_HDR_OUTPUT };
|
||||
|
||||
@@ -53,22 +53,22 @@ static void host_post(enum host_kind kind, const char *text, size_t len, int a,
|
||||
}
|
||||
}
|
||||
|
||||
void banner_host_clipboard_text(const char *utf8, int len) {
|
||||
void droiddeck_host_clipboard_text(const char *utf8, int len) {
|
||||
host_post(HOST_CLIPBOARD, utf8, len > 0 ? (size_t)len : 0, 0, 0);
|
||||
}
|
||||
void banner_host_text_commit(const char *utf8, int len) {
|
||||
void droiddeck_host_text_commit(const char *utf8, int len) {
|
||||
host_post(HOST_TEXT_COMMIT, utf8, len > 0 ? (size_t)len : 0, 0, 0);
|
||||
}
|
||||
void banner_host_text_preedit(const char *utf8, int len, int cursor_begin, int cursor_end) {
|
||||
void droiddeck_host_text_preedit(const char *utf8, int len, int cursor_begin, int cursor_end) {
|
||||
host_post(HOST_TEXT_PREEDIT, utf8, len > 0 ? (size_t)len : 0, cursor_begin, cursor_end);
|
||||
}
|
||||
void banner_host_text_delete(int before, int after) {
|
||||
void droiddeck_host_text_delete(int before, int after) {
|
||||
host_post(HOST_TEXT_DELETE, NULL, 0, before, after);
|
||||
}
|
||||
void banner_host_zero_copy(int on, int live) {
|
||||
void droiddeck_host_zero_copy(int on, int live) {
|
||||
host_post(HOST_ZERO_COPY, NULL, 0, on, live);
|
||||
}
|
||||
void banner_host_hdr_output(int on) {
|
||||
void droiddeck_host_hdr_output(int on) {
|
||||
host_post(HOST_HDR_OUTPUT, NULL, 0, on, 0);
|
||||
}
|
||||
|
||||
@@ -99,17 +99,17 @@ static int on_wake(int fd, uint32_t mask, void *data) {
|
||||
ahb_swapchain_set_mode(m->a, m->b);
|
||||
break;
|
||||
case HOST_HDR_OUTPUT:
|
||||
banner_color_set_output(m->a);
|
||||
droiddeck_color_set_output(m->a);
|
||||
break;
|
||||
}
|
||||
free(m->text);
|
||||
free(m);
|
||||
}
|
||||
wl_display_flush_clients(banner_get_display());
|
||||
wl_display_flush_clients(droiddeck_get_display());
|
||||
return 0;
|
||||
}
|
||||
|
||||
void banner_ext_init(struct wl_display *display) {
|
||||
void droiddeck_ext_init(struct wl_display *display) {
|
||||
struct wl_event_loop *loop = wl_display_get_event_loop(display);
|
||||
if (pipe2(g_wake, O_CLOEXEC | O_NONBLOCK) == 0) {
|
||||
wl_event_loop_add_fd(loop, g_wake[0], WL_EVENT_READABLE, on_wake, NULL);
|
||||
@@ -118,7 +118,7 @@ void banner_ext_init(struct wl_display *display) {
|
||||
pthread_mutex_unlock(&g_mu);
|
||||
if (queued) { char c = 1; ssize_t n = write(g_wake[1], &c, 1); (void)n; }
|
||||
} else
|
||||
__android_log_print(ANDROID_LOG_ERROR, "BannerWayland", "host queue pipe failed");
|
||||
__android_log_print(ANDROID_LOG_ERROR, "DroidDeckWayland", "host queue pipe failed");
|
||||
clipboard_init(display);
|
||||
text_input_init(display);
|
||||
toplevel_icon_init(display);
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Interface between compositor.c and the extension modules that live in their own files:
|
||||
* wl_clipboard.c wl_data_device_manager + zwlr_data_control_manager_v1 (selection only)
|
||||
* wl_text_input.c zwp_text_input_manager_v3 (host IME / soft keyboard)
|
||||
* wl_toplevel_icon.c xdg_toplevel_icon_manager_v1 (accepted, not rendered)
|
||||
* droiddeck_ext.c the host→compositor message queue shared by the modules
|
||||
* Everything here runs on the compositor (dispatch) thread unless noted.
|
||||
*/
|
||||
#pragma once
|
||||
#include <stdint.h>
|
||||
#include <wayland-server.h>
|
||||
|
||||
void droiddeck_log(const char *tag, const char *fmt, ...);
|
||||
|
||||
/* ---- compositor.c → modules */
|
||||
void droiddeck_ext_init(struct wl_display *display); /* creates the globals + host queue */
|
||||
void droiddeck_clipboard_keyboard_focus(struct wl_client *client); /* keyboard focus moved; NULL = none */
|
||||
void droiddeck_text_input_refocus(void); /* the text-input target may have changed */
|
||||
void droiddeck_text_input_surface_gone(struct wl_resource *surface);
|
||||
|
||||
/* ---- modules → compositor.c */
|
||||
const char *droiddeck_client_name(struct wl_client *client);
|
||||
struct wl_display *droiddeck_get_display(void);
|
||||
/* The surface text input follows (the last clicked window, else the topmost program window). */
|
||||
struct wl_resource *droiddeck_ime_target(void);
|
||||
/* Scene (virtual desktop) origin of a toplevel surface; 0,0 when unknown. */
|
||||
void droiddeck_surface_scene_origin(struct wl_resource *surface, int *x, int *y);
|
||||
/* Deliver a key press/release through the wl_keyboard path as if typed. */
|
||||
void droiddeck_inject_key(uint32_t evdev, int pressed);
|
||||
|
||||
/* ---- modules → app (JNI upcalls; waylandcomp_jni.c) */
|
||||
void droiddeck_on_clipboard_text(const char *utf8, int len);
|
||||
void droiddeck_on_text_input(int enabled, const char *program, int x, int y, int w, int h);
|
||||
|
||||
/* ---- app → modules (callable from any thread; queued to the compositor thread) */
|
||||
void droiddeck_host_clipboard_text(const char *utf8, int len); /* len 0 = clear */
|
||||
void droiddeck_host_text_commit(const char *utf8, int len);
|
||||
void droiddeck_host_text_preedit(const char *utf8, int len, int cursor_begin, int cursor_end);
|
||||
void droiddeck_host_text_delete(int before, int after);
|
||||
void droiddeck_host_zero_copy(int on, int live); /* the drawer's zero-copy switch (ahb_swapchain_set_mode) */
|
||||
void droiddeck_host_hdr_output(int on); /* the drawer's HDR output switch (droiddeck_color_set_output) */
|
||||
|
||||
/* ---- module internals reached through the queue (droiddeck_ext.c → modules) */
|
||||
void clipboard_init(struct wl_display *display);
|
||||
void clipboard_host_text(char *utf8, size_t len); /* takes ownership of utf8 */
|
||||
void text_input_init(struct wl_display *display);
|
||||
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);
|
||||
@@ -2,7 +2,7 @@
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
#include "effects_chain.h"
|
||||
#include "vk_loader.h"
|
||||
#include "vk_present.h" /* banner_log */
|
||||
#include "vk_present.h" /* droiddeck_log */
|
||||
#include <pthread.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
@@ -115,7 +115,7 @@ static void log_settings(const struct fx_settings *s) {
|
||||
char line[400];
|
||||
int n;
|
||||
if (!spatial_mode(s->scaling) && !effect_count(s)) {
|
||||
banner_log("effects", "all off: scaling=%s (plain blit)", scaling_name(s->scaling));
|
||||
droiddeck_log("effects", "all off: scaling=%s (plain blit)", scaling_name(s->scaling));
|
||||
return;
|
||||
}
|
||||
n = snprintf(line, sizeof(line), "scaling=%s", scaling_name(s->scaling));
|
||||
@@ -133,7 +133,7 @@ static void log_settings(const struct fx_settings *s) {
|
||||
if (s->crt) n += snprintf(line + n, sizeof(line) - n, ", CRT on");
|
||||
if (s->deband_on) n += snprintf(line + n, sizeof(line) - n, ", Deband on %d%%", s->deband_pct);
|
||||
(void)n;
|
||||
banner_log("effects", "%s", line);
|
||||
droiddeck_log("effects", "%s", line);
|
||||
}
|
||||
|
||||
int vkp_effects_sync(void) {
|
||||
@@ -262,7 +262,7 @@ static int target_ensure(struct fx_target *t, int w, int h, const char *what) {
|
||||
VK_IMAGE_USAGE_TRANSFER_SRC_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT,
|
||||
.sharingMode = VK_SHARING_MODE_EXCLUSIVE, .initialLayout = VK_IMAGE_LAYOUT_UNDEFINED};
|
||||
VkResult r = g_vk.CreateImage(g_dev, &ii, NULL, &t->img);
|
||||
if (r != VK_SUCCESS) { banner_log("error", "effects: %s image %dx%d: vkCreateImage %d", what, w, h, (int)r); return -1; }
|
||||
if (r != VK_SUCCESS) { droiddeck_log("error", "effects: %s image %dx%d: vkCreateImage %d", what, w, h, (int)r); return -1; }
|
||||
VkMemoryRequirements req;
|
||||
g_vk.GetImageMemoryRequirements(g_dev, t->img, &req);
|
||||
int idx = memtype(req.memoryTypeBits, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT);
|
||||
@@ -271,7 +271,7 @@ static int target_ensure(struct fx_target *t, int w, int h, const char *what) {
|
||||
.memoryTypeIndex = (uint32_t)(idx < 0 ? 0 : idx)};
|
||||
r = g_vk.AllocateMemory(g_dev, &mai, NULL, &t->mem);
|
||||
if (r != VK_SUCCESS) {
|
||||
banner_log("error", "effects: %s image %dx%d (%llu bytes): vkAllocateMemory %d", what, w, h,
|
||||
droiddeck_log("error", "effects: %s image %dx%d (%llu bytes): vkAllocateMemory %d", what, w, h,
|
||||
(unsigned long long)req.size, (int)r);
|
||||
target_destroy(t); return -1;
|
||||
}
|
||||
@@ -312,7 +312,7 @@ static VkShaderModule shader(const uint32_t *code, size_t size) {
|
||||
* viewport/scissor - the X11 renderer's createPostPipeline. */
|
||||
static VkPipeline post_pipeline(VkShaderModule vert, const uint32_t *frag_code, size_t frag_size, const char *name) {
|
||||
VkShaderModule frag = shader(frag_code, frag_size);
|
||||
if (!frag) { banner_log("error", "effects: %s: vkCreateShaderModule failed", name); return VK_NULL_HANDLE; }
|
||||
if (!frag) { droiddeck_log("error", "effects: %s: vkCreateShaderModule failed", name); return VK_NULL_HANDLE; }
|
||||
VkPipelineShaderStageCreateInfo st[2] = {
|
||||
{.sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO, .stage = VK_SHADER_STAGE_VERTEX_BIT, .module = vert, .pName = "main"},
|
||||
{.sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO, .stage = VK_SHADER_STAGE_FRAGMENT_BIT, .module = frag, .pName = "main"}};
|
||||
@@ -340,7 +340,7 @@ static VkPipeline post_pipeline(VkShaderModule vert, const uint32_t *frag_code,
|
||||
VkPipeline p = VK_NULL_HANDLE;
|
||||
VkResult r = g_vk.CreateGraphicsPipelines(g_dev, VK_NULL_HANDLE, 1, &pi, NULL, &p);
|
||||
g_vk.DestroyShaderModule(g_dev, frag, NULL);
|
||||
if (r != VK_SUCCESS) { banner_log("error", "effects: %s: vkCreateGraphicsPipelines %d", name, (int)r); return VK_NULL_HANDLE; }
|
||||
if (r != VK_SUCCESS) { droiddeck_log("error", "effects: %s: vkCreateGraphicsPipelines %d", name, (int)r); return VK_NULL_HANDLE; }
|
||||
return p;
|
||||
}
|
||||
|
||||
@@ -367,7 +367,7 @@ void vkp_effects_set_formats(VkFormat scene_fmt, VkFormat target_fmt) {
|
||||
g_fx_fmt = target_fmt;
|
||||
g_scene_fmt = scene_fmt;
|
||||
if (g_dev)
|
||||
banner_log("effects", "chain now works in %s (%s)",
|
||||
droiddeck_log("effects", "chain now works in %s (%s)",
|
||||
target_fmt == FX_FORMAT ? "8-bit RGBA"
|
||||
: target_fmt == VK_FORMAT_R16G16B16A16_SFLOAT ? "FP16 RGBA" : "10-bit RGB",
|
||||
target_fmt == FX_FORMAT ? "SDR frames"
|
||||
@@ -383,7 +383,7 @@ void vkp_effects_set_formats(VkFormat scene_fmt, VkFormat target_fmt) {
|
||||
* unavailable for this session (logged once; frames fall back to the plain blit). */
|
||||
static int objects_ensure(void) {
|
||||
if (g_ready) return g_ready == 1 ? 0 : -1;
|
||||
if (!g_dev || !g_vk.CreateGraphicsPipelines) { g_ready = -1; banner_log("error", "effects: no device for the chain"); return -1; }
|
||||
if (!g_dev || !g_vk.CreateGraphicsPipelines) { g_ready = -1; droiddeck_log("error", "effects: no device for the chain"); return -1; }
|
||||
/* Render pass: one colour target, overwritten whole (DONT_CARE), left SHADER_READ_ONLY for the
|
||||
* next pass. The dependencies order this pass after whoever read the target last (previous
|
||||
* pass / previous frame's blit) and before whoever samples or blits it next. */
|
||||
@@ -448,12 +448,12 @@ static int objects_ensure(void) {
|
||||
#undef MK
|
||||
g_vk.DestroyShaderModule(g_dev, vert, NULL);
|
||||
g_ready = 1;
|
||||
banner_log("effects", "chain ready: 13 passes (SGSR, SGSR HQ, NIS, FSR EASU+RCAS, CAS, colour, FXAA, Toon, HDR, NTSC, CRT, deband) on %s", vkp_gpu_name());
|
||||
droiddeck_log("effects", "chain ready: 13 passes (SGSR, SGSR HQ, NIS, FSR EASU+RCAS, CAS, colour, FXAA, Toon, HDR, NTSC, CRT, deband) on %s", vkp_gpu_name());
|
||||
return 0;
|
||||
fail:
|
||||
objects_destroy();
|
||||
g_ready = -1;
|
||||
banner_log("error", "effects: the screen-effect chain could not be built on this driver; effects are off for this session");
|
||||
droiddeck_log("error", "effects: the screen-effect chain could not be built on this driver; effects are off for this session");
|
||||
return -1;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,9 +11,9 @@
|
||||
#include <time.h>
|
||||
#include <android/log.h>
|
||||
|
||||
void banner_log(const char *tag, const char *fmt, ...) __attribute__((format(printf, 2, 3)));
|
||||
#define FGLOG(...) banner_log("framegen", __VA_ARGS__)
|
||||
#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, "BannerWayland", __VA_ARGS__)
|
||||
void droiddeck_log(const char *tag, const char *fmt, ...) __attribute__((format(printf, 2, 3)));
|
||||
#define FGLOG(...) droiddeck_log("framegen", __VA_ARGS__)
|
||||
#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, "DroidDeckWayland", __VA_ARGS__)
|
||||
|
||||
/* ---- app controls (written from any thread, read on the compositor thread) ------------- */
|
||||
static _Atomic int g_kind = VKP_FG_ENGINE_LSFG;
|
||||
|
||||
@@ -20,8 +20,8 @@
|
||||
#include "lsfg/lsfg_vkd.h"
|
||||
#include "winfg/winfg_engine.h"
|
||||
|
||||
#define ELOG(...) __android_log_print(ANDROID_LOG_INFO, "BannerWayland", "framegen: " __VA_ARGS__)
|
||||
#define ELOGE(...) __android_log_print(ANDROID_LOG_ERROR, "BannerWayland", "framegen: " __VA_ARGS__)
|
||||
#define ELOG(...) __android_log_print(ANDROID_LOG_INFO, "DroidDeckWayland", "framegen: " __VA_ARGS__)
|
||||
#define ELOGE(...) __android_log_print(ANDROID_LOG_ERROR, "DroidDeckWayland", "framegen: " __VA_ARGS__)
|
||||
|
||||
namespace {
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
#include "hdr_compose.h"
|
||||
#include "vk_loader.h"
|
||||
#include "vk_present.h" /* banner_log, vkp_gpu_name */
|
||||
#include "vk_present.h" /* droiddeck_log, vkp_gpu_name */
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
@@ -190,7 +190,7 @@ static int objects_ensure(void) {
|
||||
if (!(g_out[i].pipe = make_pipe(g_out[i].rp))) goto fail;
|
||||
}
|
||||
g_ready = 1;
|
||||
banner_log("color", "HDR composition ready on %s: scenes that mix HDR and SDR content are put into one "
|
||||
droiddeck_log("color", "HDR composition ready on %s: scenes that mix HDR and SDR content are put into one "
|
||||
"encoding (PQ BT.2020, or tone-mapped sRGB) instead of being shown washed out", vkp_gpu_name());
|
||||
return 0;
|
||||
fail:
|
||||
@@ -200,7 +200,7 @@ fail:
|
||||
g_dev = keep;
|
||||
}
|
||||
g_ready = -1;
|
||||
banner_log("error", "color: the HDR composition pass could not be built on this driver; HDR scenes that cannot "
|
||||
droiddeck_log("error", "color: the HDR composition pass could not be built on this driver; HDR scenes that cannot "
|
||||
"stay on the game's own layer are shown the old way (washed out) this session");
|
||||
return -1;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
#define _GNU_SOURCE
|
||||
#include "sc_layer.h"
|
||||
#include "ahb_swapchain.h"
|
||||
#include "banner_color.h"
|
||||
#include "droiddeck_color.h"
|
||||
#include "vk_present.h"
|
||||
#include <android/hardware_buffer.h>
|
||||
#include <android/native_window.h>
|
||||
@@ -34,7 +34,7 @@
|
||||
|
||||
/* The gralloc buffer handle behind an AHardwareBuffer (AOSP cutils/native_handle.h, which the NDK
|
||||
* does not ship; the layout is a stable ABI: fds first, then ints). */
|
||||
struct banner_native_handle { int version; int numFds; int numInts; int data[]; };
|
||||
struct droiddeck_native_handle { int version; int numFds; int numInts; int data[]; };
|
||||
|
||||
/* ---- libandroid SurfaceControl API (dlsym) ---------------------------------------------------- */
|
||||
|
||||
@@ -44,12 +44,12 @@ typedef struct ASurfaceTransactionStats ASurfaceTransactionStats;
|
||||
typedef void (*sc_complete_fn)(void *context, ASurfaceTransactionStats *stats);
|
||||
|
||||
/* <android/hdr_metadata.h> (NDK, API 29), declared here like the rest of this table: stable ABI. */
|
||||
struct banner_color_xy { float x, y; };
|
||||
struct banner_hdr_smpte2086 {
|
||||
struct banner_color_xy displayPrimaryRed, displayPrimaryGreen, displayPrimaryBlue, whitePoint;
|
||||
struct droiddeck_color_xy { float x, y; };
|
||||
struct droiddeck_hdr_smpte2086 {
|
||||
struct droiddeck_color_xy displayPrimaryRed, displayPrimaryGreen, displayPrimaryBlue, whitePoint;
|
||||
float maxLuminance, minLuminance;
|
||||
};
|
||||
struct banner_hdr_cta861_3 { float maxContentLightLevel, maxFrameAverageLightLevel; };
|
||||
struct droiddeck_hdr_cta861_3 { float maxContentLightLevel, maxFrameAverageLightLevel; };
|
||||
|
||||
static struct {
|
||||
ASurfaceControl *(*createFromWindow)(ANativeWindow *, const char *);
|
||||
@@ -69,11 +69,11 @@ static struct {
|
||||
* the layer simply carries no vote and the app's surface vote is all there is. */
|
||||
void (*setFrameRate)(ASurfaceTransaction *, ASurfaceControl *, float, int8_t);
|
||||
void (*setFrameRateStrategy)(ASurfaceTransaction *, ASurfaceControl *, float, int8_t, int8_t);
|
||||
/* Colour (API 29), optional: only an HDR session ever calls them (banner_color.h's gate needs
|
||||
/* Colour (API 29), optional: only an HDR session ever calls them (droiddeck_color.h's gate needs
|
||||
* setBufferDataSpace; the metadata calls are sent when present). */
|
||||
void (*setBufferDataSpace)(ASurfaceTransaction *, ASurfaceControl *, int32_t);
|
||||
void (*setHdrMetadata_smpte2086)(ASurfaceTransaction *, ASurfaceControl *, const struct banner_hdr_smpte2086 *);
|
||||
void (*setHdrMetadata_cta861_3)(ASurfaceTransaction *, ASurfaceControl *, const struct banner_hdr_cta861_3 *);
|
||||
void (*setHdrMetadata_smpte2086)(ASurfaceTransaction *, ASurfaceControl *, const struct droiddeck_hdr_smpte2086 *);
|
||||
void (*setHdrMetadata_cta861_3)(ASurfaceTransaction *, ASurfaceControl *, const struct droiddeck_hdr_cta861_3 *);
|
||||
/* Not in the public NDK headers (vndk/hardware_buffer.h) but exported by libnativewindow.so on
|
||||
* every device; Mesa's Android WSI calls it for every gralloc buffer it imports. */
|
||||
const void *(*getNativeHandle)(const AHardwareBuffer *);
|
||||
@@ -95,12 +95,12 @@ static int load_api(void) {
|
||||
if (!nw) nw = dlopen("libnativewindow.so", RTLD_NOW);
|
||||
api.getNativeHandle = nw ? dlsym(nw, "AHardwareBuffer_getNativeHandle") : NULL;
|
||||
if (!api.getNativeHandle) {
|
||||
banner_log("layer", "unavailable: libnativewindow.so has no AHardwareBuffer_getNativeHandle");
|
||||
droiddeck_log("layer", "unavailable: libnativewindow.so has no AHardwareBuffer_getNativeHandle");
|
||||
return -1;
|
||||
}
|
||||
void *lib = dlopen("libandroid.so", RTLD_NOW | RTLD_NOLOAD);
|
||||
if (!lib) lib = dlopen("libandroid.so", RTLD_NOW);
|
||||
if (!lib) { banner_log("layer", "unavailable: dlopen(libandroid.so): %s", dlerror()); return -1; }
|
||||
if (!lib) { droiddeck_log("layer", "unavailable: dlopen(libandroid.so): %s", dlerror()); return -1; }
|
||||
#define SYM(field, name) api.field = dlsym(lib, name)
|
||||
SYM(createFromWindow, "ASurfaceControl_createFromWindow");
|
||||
SYM(release, "ASurfaceControl_release");
|
||||
@@ -125,7 +125,7 @@ static int load_api(void) {
|
||||
if (!api.createFromWindow || !api.release || !api.txCreate || !api.txDelete || !api.txApply ||
|
||||
!api.setBuffer || !api.setZOrder || !api.setVisibility || !api.setGeometry ||
|
||||
!api.setBufferTransparency || !api.reparent || !api.setOnComplete || !api.prevReleaseFence) {
|
||||
banner_log("layer", "unavailable: libandroid.so lacks part of the ASurfaceControl API (Android 10+)");
|
||||
droiddeck_log("layer", "unavailable: libandroid.so lacks part of the ASurfaceControl API (Android 10+)");
|
||||
return -1;
|
||||
}
|
||||
api.state = 1;
|
||||
@@ -214,10 +214,10 @@ static AHardwareBuffer *g_blank;
|
||||
static void layers_init(void) {
|
||||
if (g_layers_ready) return;
|
||||
g_layers_ready = 1;
|
||||
g_layers[SC_LAYER_GAME] = (struct layer){.name = "banner_wayland_game", .z = 1, .pool_n = POOL_MAX,
|
||||
g_layers[SC_LAYER_GAME] = (struct layer){.name = "droiddeck_wayland_game", .z = 1, .pool_n = POOL_MAX,
|
||||
.cur_slot = -1, .votes_rate = 1, .fps_applied = -1.0f, .hr_applied = -1.0f,
|
||||
.ds_applied = -1};
|
||||
g_layers[SC_LAYER_OVERLAY] = (struct layer){.name = "banner_wayland_overlay", .z = 2, .pool_n = 3,
|
||||
g_layers[SC_LAYER_OVERLAY] = (struct layer){.name = "droiddeck_wayland_overlay", .z = 2, .pool_n = 3,
|
||||
.cur_slot = -1, .votes_rate = 0, .fps_applied = -1.0f,
|
||||
.hr_applied = -1.0f, .ds_applied = -1};
|
||||
for (int i = 0; i < SC_LAYER_COUNT; i++)
|
||||
@@ -330,7 +330,7 @@ static void retire_sc(struct layer *l) {
|
||||
if (l->cur_token) ahb_swapchain_layer_released(l->cur_token, -1);
|
||||
api.release(l->sc);
|
||||
}
|
||||
banner_log("layer", "%s: SurfaceControl retired (window %p)", l->name, (void *)l->win);
|
||||
droiddeck_log("layer", "%s: SurfaceControl retired (window %p)", l->name, (void *)l->win);
|
||||
l->sc = NULL; l->win = NULL;
|
||||
l->shown = 0; l->cur_slot = -1; l->cur_token = NULL; l->geo_valid = 0;
|
||||
l->fps_applied = -1.0f; /* the next SurfaceControl carries no vote until it is re-applied */
|
||||
@@ -368,7 +368,7 @@ static void drain_and_free_pools(void) {
|
||||
/* Native-handle sniff, the same one Mesa's u_gralloc fallback uses (u_gralloc_fallback.c): a QTI
|
||||
* gralloc private_handle_t carries the magic 'gmsm' as its first int and the UBWC flag
|
||||
* (PRIV_FLAGS_UBWC_ALIGNED, 0x08000000) in the next one. Returns 0 when the layout is unknown. */
|
||||
static int sniff_modifier(const struct banner_native_handle *h, uint64_t *mod) {
|
||||
static int sniff_modifier(const struct droiddeck_native_handle *h, uint64_t *mod) {
|
||||
const uint32_t gmsm = ('g' << 24) | ('m' << 16) | ('s' << 8) | 'm';
|
||||
if (!h || h->numFds < 1 || h->numInts < 2) return 0;
|
||||
if ((uint32_t)h->data[h->numFds] != gmsm) return 0;
|
||||
@@ -387,7 +387,7 @@ static int g_alloc_failed; /* set by alloc_slot when gralloc or the import refus
|
||||
* TIER_PLAIN the same without the vendor bit (a gralloc that refuses unknown bits; what we asked before).
|
||||
* TIER_LINEAR plus a CPU bit, so the layout is known to be linear even when the handle is unreadable. */
|
||||
enum { TIER_UBWC, TIER_PLAIN, TIER_LINEAR };
|
||||
#define BANNER_AHB_USAGE_VENDOR_UBWC (1ULL << 28) /* AHARDWAREBUFFER_USAGE_VENDOR_0 */
|
||||
#define DROIDDECK_AHB_USAGE_VENDOR_UBWC (1ULL << 28) /* AHARDWAREBUFFER_USAGE_VENDOR_0 */
|
||||
|
||||
static const char *tier_name(int tier) {
|
||||
return tier == TIER_UBWC ? "asked for UBWC" : tier == TIER_PLAIN ? "no UBWC request" : "linear by request (CPU bit)";
|
||||
@@ -404,22 +404,22 @@ static int alloc_slot(struct layer *l, struct slot *s, int w, int h, uint32_t fm
|
||||
* composer overlay (the display scans it out). See the tiers above for the rest. */
|
||||
.usage = AHARDWAREBUFFER_USAGE_GPU_COLOR_OUTPUT | AHARDWAREBUFFER_USAGE_GPU_SAMPLED_IMAGE |
|
||||
AHARDWAREBUFFER_USAGE_COMPOSER_OVERLAY |
|
||||
(tier == TIER_UBWC ? BANNER_AHB_USAGE_VENDOR_UBWC : 0) |
|
||||
(tier == TIER_UBWC ? DROIDDECK_AHB_USAGE_VENDOR_UBWC : 0) |
|
||||
(tier == TIER_LINEAR ? AHARDWAREBUFFER_USAGE_CPU_READ_RARELY : 0)};
|
||||
AHardwareBuffer *ahb = NULL;
|
||||
if (AHardwareBuffer_allocate(&d, &ahb) != 0 || !ahb) {
|
||||
if (tier < TIER_LINEAR) {
|
||||
banner_log("layer", "%s: gralloc refused a %dx%d pool buffer (%s): trying %s", l->name, w, h,
|
||||
droiddeck_log("layer", "%s: gralloc refused a %dx%d pool buffer (%s): trying %s", l->name, w, h,
|
||||
tier_name(tier), tier_name(tier + 1));
|
||||
l->alloc_tier = tier + 1;
|
||||
continue;
|
||||
}
|
||||
banner_log("error", "layer: %s: AHardwareBuffer_allocate %dx%d (format %#x) failed", l->name, w, h, fmt);
|
||||
droiddeck_log("error", "layer: %s: AHardwareBuffer_allocate %dx%d (format %#x) failed", l->name, w, h, fmt);
|
||||
g_alloc_failed = 1;
|
||||
return -1;
|
||||
}
|
||||
AHardwareBuffer_Desc got; AHardwareBuffer_describe(ahb, &got);
|
||||
const struct banner_native_handle *nh = api.getNativeHandle(ahb);
|
||||
const struct droiddeck_native_handle *nh = api.getNativeHandle(ahb);
|
||||
uint64_t mod = MOD_LINEAR;
|
||||
if (!sniff_modifier(nh, &mod)) {
|
||||
/* Not a handle we can read: the only layout we can assume is linear, and only if the buffer
|
||||
@@ -427,7 +427,7 @@ static int alloc_slot(struct layer *l, struct slot *s, int w, int h, uint32_t fm
|
||||
if (tier < TIER_LINEAR) {
|
||||
AHardwareBuffer_release(ahb);
|
||||
l->alloc_tier = TIER_LINEAR;
|
||||
banner_log("layer", "%s: gralloc handle layout unknown (%d fds, %d ints): using linear pool buffers",
|
||||
droiddeck_log("layer", "%s: gralloc handle layout unknown (%d fds, %d ints): using linear pool buffers",
|
||||
l->name, nh ? nh->numFds : -1, nh ? nh->numInts : -1);
|
||||
continue;
|
||||
}
|
||||
@@ -439,7 +439,7 @@ static int alloc_slot(struct layer *l, struct slot *s, int w, int h, uint32_t fm
|
||||
struct vkp_image *img = fd >= 0 ? vkp_image_import_dmabuf(fd, fmt == AHB_RGB10A2 ? DRM_ABGR2101010 : DRM_ABGR8888,
|
||||
mod, w, h, got.stride * 4, 0, 1) : NULL;
|
||||
if (!img) {
|
||||
banner_log("layer", "%s: import of a %s %dx%d pool buffer (stride %u px) into the compositor's Turnip failed",
|
||||
droiddeck_log("layer", "%s: import of a %s %dx%d pool buffer (stride %u px) into the compositor's Turnip failed",
|
||||
l->name, mod == MOD_QCOM_COMPRESSED ? "UBWC" : "linear", w, h, got.stride);
|
||||
AHardwareBuffer_release(ahb);
|
||||
if (tier < TIER_LINEAR) { l->alloc_tier = tier + 1; continue; }
|
||||
@@ -451,7 +451,7 @@ static int alloc_slot(struct layer *l, struct slot *s, int w, int h, uint32_t fm
|
||||
if (l->logged_w != w || l->logged_h != h || l->logged_fmt != fmt || l->logged_mod != mod ||
|
||||
l->logged_tier != tier) {
|
||||
l->logged_w = w; l->logged_h = h; l->logged_fmt = fmt; l->logged_mod = mod; l->logged_tier = tier;
|
||||
banner_log("layer", "%s: pool buffers %dx%d%s: %s (%s), stride %u px, up to %d buffers (gralloc handle %d fds / %d ints)",
|
||||
droiddeck_log("layer", "%s: pool buffers %dx%d%s: %s (%s), stride %u px, up to %d buffers (gralloc handle %d fds / %d ints)",
|
||||
l->name, w, h, fmt == AHB_RGB10A2 ? " 10-bit RGBA1010102" : "",
|
||||
mod == MOD_QCOM_COMPRESSED ? "UBWC (QCOM_COMPRESSED)" : "linear", tier_name(tier),
|
||||
got.stride, l->pool_n, nh ? nh->numFds : -1, nh ? nh->numInts : -1);
|
||||
@@ -532,10 +532,10 @@ void sc_layer_probe_dmabuf_fd(int fd) {
|
||||
struct { uint32_t flags; int32_t fd; } exp = {.flags = 1u /* DMA_BUF_SYNC_READ */, .fd = -1};
|
||||
/* DMA_BUF_IOCTL_EXPORT_SYNC_FILE = _IOWR('b', 2, struct dma_buf_export_sync_file) */
|
||||
if (ioctl(fd, _IOWR('b', 2, exp), &exp) == 0) {
|
||||
banner_log("layer", "kernel exports sync_file fences from the game's dma-buf: zero-copy acquire fences can come from the buffer itself");
|
||||
droiddeck_log("layer", "kernel exports sync_file fences from the game's dma-buf: zero-copy acquire fences can come from the buffer itself");
|
||||
if (exp.fd >= 0) close(exp.fd);
|
||||
} else {
|
||||
banner_log("layer", "DMA_BUF_IOCTL_EXPORT_SYNC_FILE on the game's dma-buf failed (%s): acquire fences must be exported by the game's driver (sync_fd) instead",
|
||||
droiddeck_log("layer", "DMA_BUF_IOCTL_EXPORT_SYNC_FILE on the game's dma-buf failed (%s): acquire fences must be exported by the game's driver (sync_fd) instead",
|
||||
strerror(errno));
|
||||
}
|
||||
}
|
||||
@@ -549,7 +549,7 @@ static int ensure_sc(struct layer *l) {
|
||||
if (l->sc && l->win != win) retire_sc(l);
|
||||
if (!l->sc) {
|
||||
l->sc = api.createFromWindow(win, l->name);
|
||||
if (!l->sc) { banner_log("error", "layer: ASurfaceControl_createFromWindow(%s) failed", l->name); return -1; }
|
||||
if (!l->sc) { droiddeck_log("error", "layer: ASurfaceControl_createFromWindow(%s) failed", l->name); return -1; }
|
||||
l->win = win;
|
||||
l->ds_applied = -1; l->md_applied = 0; l->hr_applied = -1.0f;
|
||||
ASurfaceTransaction *tx = api.txCreate();
|
||||
@@ -559,7 +559,7 @@ static int ensure_sc(struct layer *l) {
|
||||
apply_frame_rate(tx, l);
|
||||
api.txApply(tx); api.txDelete(tx);
|
||||
}
|
||||
banner_log("layer", "SurfaceControl \"%s\" created as a child of the screen surface (z=%d)", l->name, (int)l->z);
|
||||
droiddeck_log("layer", "SurfaceControl \"%s\" created as a child of the screen surface (z=%d)", l->name, (int)l->z);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -585,7 +585,7 @@ static void apply_geometry(ASurfaceTransaction *tx, struct layer *l, const int r
|
||||
l->geo_src = srcR; l->geo_dst = dstR; l->geo_valid = 1;
|
||||
char cov[96] = "";
|
||||
if (l->ds_applied > 0) coverage_text(l, cov, sizeof(cov));
|
||||
banner_log("layer", "%s geometry: buffer %d,%d-%d,%d -> screen %d,%d-%d,%d%s%s%s", l->name,
|
||||
droiddeck_log("layer", "%s geometry: buffer %d,%d-%d,%d -> screen %d,%d-%d,%d%s%s%s", l->name,
|
||||
r[0], r[1], r[2], r[3], r[4], r[5], r[6], r[7], cov[0] ? " (HDR layer " : "", cov, cov[0] ? ")" : "");
|
||||
}
|
||||
}
|
||||
@@ -606,11 +606,11 @@ static void apply_frame_rate(ASurfaceTransaction *tx, struct layer *l) {
|
||||
}
|
||||
int first = l->fps_applied < 0.0f;
|
||||
l->fps_applied = want;
|
||||
if (want > 0.0f) banner_log("layer", "display frame-rate vote on %s: %.2f Hz", l->name, want);
|
||||
else if (!first) banner_log("layer", "display frame-rate vote on %s cleared (panel runs free)", l->name);
|
||||
if (want > 0.0f) droiddeck_log("layer", "display frame-rate vote on %s: %.2f Hz", l->name, want);
|
||||
else if (!first) droiddeck_log("layer", "display frame-rate vote on %s cleared (panel runs free)", l->name);
|
||||
}
|
||||
|
||||
/* ---- colour (HDR, banner_color.h) -------------------------------------------------------------
|
||||
/* ---- colour (HDR, droiddeck_color.h) -------------------------------------------------------------
|
||||
* The layer is told what its buffer's pixels MEAN - the dataspace (BT2020_PQ for an HDR10 frame) and
|
||||
* the game's mastering / content-light metadata - in the same transaction as the buffer, so the
|
||||
* display never shows an HDR frame decoded as sRGB or the other way round. A layer that has never been
|
||||
@@ -630,67 +630,67 @@ static void coverage_text(const struct layer *l, char *out, size_t n) {
|
||||
|
||||
/* API 35: ask the display for HDR headroom explicitly while the layer carries an HDR frame, and clear the
|
||||
* request (0 = no preference) when it stops. Only on change; a layer never tagged is never touched. */
|
||||
static void apply_headroom(ASurfaceTransaction *tx, struct layer *l, const struct banner_color *c) {
|
||||
static void apply_headroom(ASurfaceTransaction *tx, struct layer *l, const struct droiddeck_color *c) {
|
||||
static int said_missing;
|
||||
if (!api.setDesiredHdrHeadroom) {
|
||||
if (c && c->dataspace && !said_missing) {
|
||||
said_missing = 1;
|
||||
banner_color_note_headroom_request(-1.0f);
|
||||
banner_log("color", "HDR headroom request not available on %s (Android < 15 has no "
|
||||
droiddeck_color_note_headroom_request(-1.0f);
|
||||
droiddeck_log("color", "HDR headroom request not available on %s (Android < 15 has no "
|
||||
"ASurfaceTransaction_setDesiredHdrHeadroom): the layer relies on Android's default", l->name);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const float want = (c && c->dataspace) ? banner_color_desired_headroom(c, NULL, 0) : 0.0f;
|
||||
const float want = (c && c->dataspace) ? droiddeck_color_desired_headroom(c, NULL, 0) : 0.0f;
|
||||
if (l->hr_applied < 0.0f && want == 0.0f && !(c && c->dataspace)) return; /* never asked: leave it be */
|
||||
if (l->hr_applied >= 0.0f && fabsf(want - l->hr_applied) < 0.005f) return;
|
||||
char why[200] = "";
|
||||
if (c && c->dataspace) banner_color_desired_headroom(c, why, sizeof(why));
|
||||
if (c && c->dataspace) droiddeck_color_desired_headroom(c, why, sizeof(why));
|
||||
api.setDesiredHdrHeadroom(tx, l->sc, want);
|
||||
const int first = l->hr_applied < 0.0f;
|
||||
l->hr_applied = want;
|
||||
banner_color_note_headroom_request(want);
|
||||
droiddeck_color_note_headroom_request(want);
|
||||
if (want > 0.0f)
|
||||
banner_log("color", "requested HDR headroom %.1fx on %s (%s)", want, l->name, why);
|
||||
droiddeck_log("color", "requested HDR headroom %.1fx on %s (%s)", want, l->name, why);
|
||||
else if (c && c->dataspace)
|
||||
banner_log("color", "no HDR headroom requested on %s: %s", l->name, why);
|
||||
droiddeck_log("color", "no HDR headroom requested on %s: %s", l->name, why);
|
||||
else if (!first)
|
||||
banner_log("color", "HDR headroom request on %s cleared (no preference): the frame on the layer is not HDR", l->name);
|
||||
droiddeck_log("color", "HDR headroom request on %s cleared (no preference): the frame on the layer is not HDR", l->name);
|
||||
}
|
||||
|
||||
static void apply_colour(ASurfaceTransaction *tx, struct layer *l, const struct banner_color *c) {
|
||||
int32_t want = c ? c->dataspace : BANNER_ADATASPACE_UNKNOWN;
|
||||
static void apply_colour(ASurfaceTransaction *tx, struct layer *l, const struct droiddeck_color *c) {
|
||||
int32_t want = c ? c->dataspace : DROIDDECK_ADATASPACE_UNKNOWN;
|
||||
uint32_t md = (c && c->dataspace) ? c->identity : 0;
|
||||
if (!api.setBufferDataSpace) return;
|
||||
if (l->ds_applied < 0 && want == BANNER_ADATASPACE_UNKNOWN) return; /* never tagged: leave it be */
|
||||
if (l->ds_applied < 0 && want == DROIDDECK_ADATASPACE_UNKNOWN) return; /* never tagged: leave it be */
|
||||
apply_headroom(tx, l, want ? c : NULL);
|
||||
if (want == l->ds_applied && md == l->md_applied) return;
|
||||
api.setBufferDataSpace(tx, l->sc, want);
|
||||
struct banner_hdr_smpte2086 st;
|
||||
struct banner_hdr_cta861_3 cta;
|
||||
struct droiddeck_hdr_smpte2086 st;
|
||||
struct droiddeck_hdr_cta861_3 cta;
|
||||
int st_on = c && c->dataspace && c->has_st2086, cta_on = c && c->dataspace && c->has_cta861;
|
||||
if (st_on) {
|
||||
st = (struct banner_hdr_smpte2086){
|
||||
st = (struct droiddeck_hdr_smpte2086){
|
||||
.displayPrimaryRed = {c->red[0], c->red[1]}, .displayPrimaryGreen = {c->green[0], c->green[1]},
|
||||
.displayPrimaryBlue = {c->blue[0], c->blue[1]}, .whitePoint = {c->white[0], c->white[1]},
|
||||
.maxLuminance = c->max_lum, .minLuminance = c->min_lum};
|
||||
}
|
||||
if (cta_on) cta = (struct banner_hdr_cta861_3){.maxContentLightLevel = c->max_cll, .maxFrameAverageLightLevel = c->max_fall};
|
||||
if (cta_on) cta = (struct droiddeck_hdr_cta861_3){.maxContentLightLevel = c->max_cll, .maxFrameAverageLightLevel = c->max_fall};
|
||||
/* NULL clears what a previous description set: metadata never outlives the frame it belongs to. */
|
||||
if (api.setHdrMetadata_smpte2086) api.setHdrMetadata_smpte2086(tx, l->sc, st_on ? &st : NULL);
|
||||
if (api.setHdrMetadata_cta861_3) api.setHdrMetadata_cta861_3(tx, l->sc, cta_on ? &cta : NULL);
|
||||
if (want) {
|
||||
char cov[96];
|
||||
coverage_text(l, cov, sizeof(cov));
|
||||
banner_log("color", "%s: dataspace %s (%#x) set on the display layer for image description #%u%s%s; SMPTE 2086 "
|
||||
"%s, CTA-861.3 %s [%s]", l->name, want == BANNER_ADATASPACE_BT2020_PQ ? "BT2020_PQ" : "HDR",
|
||||
droiddeck_log("color", "%s: dataspace %s (%#x) set on the display layer for image description #%u%s%s; SMPTE 2086 "
|
||||
"%s, CTA-861.3 %s [%s]", l->name, want == DROIDDECK_ADATASPACE_BT2020_PQ ? "BT2020_PQ" : "HDR",
|
||||
(unsigned)want, c->identity, cov[0] ? ", " : "", cov,
|
||||
st_on ? (api.setHdrMetadata_smpte2086 ? "sent" : "not supported by this Android") : "none given",
|
||||
cta_on ? (api.setHdrMetadata_cta861_3 ? "sent" : "not supported by this Android") : "none given",
|
||||
c->text);
|
||||
}
|
||||
else
|
||||
banner_log("color", "%s: dataspace back to UNKNOWN (sRGB), HDR metadata cleared - the frame on the layer is "
|
||||
droiddeck_log("color", "%s: dataspace back to UNKNOWN (sRGB), HDR metadata cleared - the frame on the layer is "
|
||||
"not an HDR frame", l->name);
|
||||
l->ds_applied = want;
|
||||
l->md_applied = md;
|
||||
@@ -711,7 +711,7 @@ void sc_layer_hdr_symbols(char *out, size_t size) {
|
||||
static void log_layer_count(void) {
|
||||
if (g_two_logged) return;
|
||||
g_two_logged = 1;
|
||||
banner_log("layer", "%d display layers in use: \"%s\" (z=%d) and \"%s\" (z=%d) above it, both children of the "
|
||||
droiddeck_log("layer", "%d display layers in use: \"%s\" (z=%d) and \"%s\" (z=%d) above it, both children of the "
|
||||
"screen surface; the app's own views (drawer, HUD, pointer) stay above both. %d is the cap - more "
|
||||
"would push SurfaceFlinger into GPU client composition",
|
||||
SC_LAYER_COUNT, g_layers[SC_LAYER_GAME].name, (int)g_layers[SC_LAYER_GAME].z,
|
||||
@@ -748,7 +748,7 @@ static ASurfaceControl *swap_sc_begin(struct layer *l) {
|
||||
if (!win || win != l->win) return NULL; /* the window changed: ensure_sc re-creates it anyway */
|
||||
ASurfaceControl *fresh = api.createFromWindow(win, l->name);
|
||||
if (!fresh) {
|
||||
banner_log("error", "layer: %s: composition recovery could not create a new SurfaceControl", l->name);
|
||||
droiddeck_log("error", "layer: %s: composition recovery could not create a new SurfaceControl", l->name);
|
||||
return NULL;
|
||||
}
|
||||
ASurfaceControl *old = l->sc;
|
||||
@@ -760,14 +760,14 @@ static ASurfaceControl *swap_sc_begin(struct layer *l) {
|
||||
l->geo_valid = 0;
|
||||
l->fps_applied = -1.0f;
|
||||
l->ds_applied = -1; l->md_applied = 0; l->hr_applied = -1.0f;
|
||||
banner_log("layer", "composition recovery: %s got a fresh SurfaceControl now that nothing is above "
|
||||
droiddeck_log("layer", "composition recovery: %s got a fresh SurfaceControl now that nothing is above "
|
||||
"the game (measured on this panel: hardware composition does NOT return from this alone)", l->name);
|
||||
return old;
|
||||
}
|
||||
|
||||
/* The transaction that puts pool slot `idx` of layer `l` on screen at `r`, as `color` (NULL = no
|
||||
* description). 0 = applied. */
|
||||
static int present_slot(struct layer *l, int idx, const int r[8], const struct banner_color *color,
|
||||
static int present_slot(struct layer *l, int idx, const int r[8], const struct droiddeck_color *color,
|
||||
int translucent) {
|
||||
struct slot *s = &l->slots[idx];
|
||||
ASurfaceTransaction *tx = api.txCreate();
|
||||
@@ -792,7 +792,7 @@ static int present_slot(struct layer *l, int idx, const int r[8], const struct b
|
||||
api.txDelete(tx);
|
||||
l->cur_slot = idx;
|
||||
l->cur_token = NULL;
|
||||
if (!l->shown) { l->shown = 1; banner_log("layer", "%s: layer shown", l->name); }
|
||||
if (!l->shown) { l->shown = 1; droiddeck_log("layer", "%s: layer shown", l->name); }
|
||||
g_stat_layer_frames++;
|
||||
return 0;
|
||||
}
|
||||
@@ -805,7 +805,7 @@ static void log_drop(struct layer *l) {
|
||||
l->drops_unlogged++;
|
||||
int64_t t = now_ns();
|
||||
if (!l->drop_logged_ns || t - l->drop_logged_ns > 30000000000LL) {
|
||||
banner_log("layer", "%s: no free layer buffer (display still holds all %d): %u frame%s dropped%s", l->name,
|
||||
droiddeck_log("layer", "%s: no free layer buffer (display still holds all %d): %u frame%s dropped%s", l->name,
|
||||
l->pool_n, l->drops_unlogged, l->drops_unlogged == 1 ? "" : "s",
|
||||
l->drop_logged_ns ? " since the last such line" : "");
|
||||
l->drop_logged_ns = t;
|
||||
@@ -820,7 +820,7 @@ unsigned sc_layer_drops_take(void) {
|
||||
}
|
||||
|
||||
int sc_layer_present_ahb(AHardwareBuffer *ahb, int w, int h, int acquire_fd, void *token, int scene_w, int scene_h,
|
||||
const struct banner_color *color, uint32_t ahb_format) {
|
||||
const struct droiddeck_color *color, uint32_t ahb_format) {
|
||||
struct layer *l = layer_of(SC_LAYER_GAME);
|
||||
int r[8];
|
||||
if (!ahb || !token || ensure_sc(l) != 0) goto unavailable;
|
||||
@@ -836,6 +836,7 @@ int sc_layer_present_ahb(AHardwareBuffer *ahb, int w, int h, int acquire_fd, voi
|
||||
apply_geometry(tx, l, r); apply_frame_rate(tx, l); apply_colour(tx, l, color);
|
||||
api.txApply(tx); api.txDelete(tx);
|
||||
}
|
||||
vkp_signal_first_frame();
|
||||
return 0;
|
||||
}
|
||||
ASurfaceTransaction *tx = api.txCreate();
|
||||
@@ -862,16 +863,19 @@ int sc_layer_present_ahb(AHardwareBuffer *ahb, int w, int h, int acquire_fd, voi
|
||||
api.txDelete(tx);
|
||||
l->cur_slot = -1;
|
||||
l->cur_token = token;
|
||||
if (!l->shown) { l->shown = 1; banner_log("layer", "%s: layer shown", l->name); }
|
||||
if (!l->first_logged) { l->first_logged = 1; vkp_signal_first_frame(); }
|
||||
if (color && color->dataspace) banner_color_frame_shown(color, BANNER_HDR_ZERO_COPY, ahb_format);
|
||||
if (!l->shown) { l->shown = 1; droiddeck_log("layer", "%s: layer shown", l->name); }
|
||||
/* The notification is re-armed for each session; the layer and its logging flag can
|
||||
* outlive it. Let the notification's own one-shot decide whether to fire. */
|
||||
if (!l->first_logged) l->first_logged = 1;
|
||||
vkp_signal_first_frame();
|
||||
if (color && color->dataspace) droiddeck_color_frame_shown(color, DROIDDECK_HDR_ZERO_COPY, ahb_format);
|
||||
return 0;
|
||||
unavailable:
|
||||
if (acquire_fd >= 0) close(acquire_fd);
|
||||
return -1;
|
||||
}
|
||||
|
||||
int sc_layer_present(struct vkp_image *src, int scene_w, int scene_h, const struct banner_color *color) {
|
||||
int sc_layer_present(struct vkp_image *src, int scene_w, int scene_h, const struct droiddeck_color *color) {
|
||||
struct layer *l = layer_of(SC_LAYER_GAME);
|
||||
if (!src || ensure_sc(l) != 0) return -1;
|
||||
int sw = vkp_image_width(src), sh = vkp_image_height(src);
|
||||
@@ -885,14 +889,14 @@ int sc_layer_present(struct vkp_image *src, int scene_w, int scene_h, const stru
|
||||
if (idx < 0) { log_drop(l); return 0; }
|
||||
if (vkp_blit_image(src, l->slots[idx].img, wait_fd) != 0) return -1;
|
||||
if (present_slot(l, idx, r, color, 0) != 0) return -1;
|
||||
if (color && color->dataspace) banner_color_frame_shown(color, BANNER_HDR_LAYER_COPY, AHB_RGBA8);
|
||||
if (color && color->dataspace) droiddeck_color_frame_shown(color, DROIDDECK_HDR_LAYER_COPY, AHB_RGBA8);
|
||||
if (!l->first_logged) {
|
||||
l->first_logged = 1;
|
||||
banner_log("layer", "presenting %dx%d game frames on their own SurfaceControl layer (%s pool, %d buffers); "
|
||||
droiddeck_log("layer", "presenting %dx%d game frames on their own SurfaceControl layer (%s pool, %d buffers); "
|
||||
"HUD and pointer stay Android views above it", sw, sh,
|
||||
l->pool_modifier == MOD_QCOM_COMPRESSED ? "UBWC" : "linear", l->pool_n);
|
||||
vkp_signal_first_frame();
|
||||
}
|
||||
vkp_signal_first_frame();
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -912,11 +916,11 @@ int sc_layer_present_pass(const struct vkp_draw *draws, int n, int scene_w, int
|
||||
if (present_slot(l, idx, r, NULL, 0) != 0) return -1; /* the effects chain's result is 8-bit sRGB */
|
||||
if (!l->first_logged) {
|
||||
l->first_logged = 1;
|
||||
banner_log("layer", "presenting %dx%d frames on their own SurfaceControl layer (%s pool, %d buffers); "
|
||||
droiddeck_log("layer", "presenting %dx%d frames on their own SurfaceControl layer (%s pool, %d buffers); "
|
||||
"HUD and pointer stay Android views above it", rw, rh,
|
||||
l->pool_modifier == MOD_QCOM_COMPRESSED ? "UBWC" : "linear", l->pool_n);
|
||||
vkp_signal_first_frame();
|
||||
}
|
||||
vkp_signal_first_frame();
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -931,7 +935,7 @@ int sc_layer_present_pass(const struct vkp_draw *draws, int n, int scene_w, int
|
||||
static uint32_t g_hdr_pool_fmt = AHB_RGB10A2;
|
||||
|
||||
int sc_layer_present_hdr_scene(const struct vkp_draw *draws, int n, const struct vkp_hdr_frame *hf,
|
||||
int scene_w, int scene_h, const struct banner_color *color) {
|
||||
int scene_w, int scene_h, const struct droiddeck_color *color) {
|
||||
struct layer *l = layer_of(SC_LAYER_GAME);
|
||||
int rw = 0, rh = 0, r[8];
|
||||
if (!draws || n <= 0 || !hf || ensure_sc(l) != 0) return -1;
|
||||
@@ -945,7 +949,7 @@ int sc_layer_present_hdr_scene(const struct vkp_draw *draws, int n, const struct
|
||||
int idx = take_free_slot(l, rw, rh, fmt, &wait_fd);
|
||||
if (idx < 0 && g_alloc_failed && fmt == AHB_RGB10A2) {
|
||||
g_hdr_pool_fmt = AHB_RGBA8;
|
||||
banner_log("color", "%s: this device will not make a 10-bit layer buffer (RGBA1010102): the HDR picture goes "
|
||||
droiddeck_log("color", "%s: this device will not make a 10-bit layer buffer (RGBA1010102): the HDR picture goes "
|
||||
"on 8-bit buffers instead (still tagged BT2020_PQ; less precision)", l->name);
|
||||
idx = take_free_slot(l, rw, rh, g_hdr_pool_fmt, &wait_fd);
|
||||
}
|
||||
@@ -953,25 +957,26 @@ int sc_layer_present_hdr_scene(const struct vkp_draw *draws, int n, const struct
|
||||
if (vkp_pass_copy_to(l->slots[idx].img, wait_fd) != 0) return -1;
|
||||
if (present_slot(l, idx, r, tm ? NULL : color, 0) != 0) return -1; /* tone-mapped = plain sRGB: no tag */
|
||||
if (color && color->dataspace)
|
||||
banner_color_frame_shown(color, tm ? BANNER_HDR_TONEMAPPED : BANNER_HDR_COMPOSED, tm ? AHB_RGBA8 : g_hdr_pool_fmt);
|
||||
droiddeck_color_frame_shown(color, tm ? DROIDDECK_HDR_TONEMAPPED : DROIDDECK_HDR_COMPOSED, tm ? AHB_RGBA8 : g_hdr_pool_fmt);
|
||||
static int said = -1;
|
||||
if (said != tm) {
|
||||
said = tm;
|
||||
if (tm)
|
||||
banner_log("color", "tone-mapped picture on the game's display layer: %dx%d, %s 8-bit buffers, untagged "
|
||||
droiddeck_log("color", "tone-mapped picture on the game's display layer: %dx%d, %s 8-bit buffers, untagged "
|
||||
"(sRGB) - HDR output is switched off", rw, rh,
|
||||
l->pool_modifier == MOD_QCOM_COMPRESSED ? "UBWC" : "linear");
|
||||
else {
|
||||
char cov[96];
|
||||
coverage_text(l, cov, sizeof(cov));
|
||||
banner_log("color", "HDR picture on its own display layer: %dx%d, %s %s buffers, tagged %s%s%s", rw, rh,
|
||||
droiddeck_log("color", "HDR picture on its own display layer: %dx%d, %s %s buffers, tagged %s%s%s", rw, rh,
|
||||
l->pool_modifier == MOD_QCOM_COMPRESSED ? "UBWC" : "linear",
|
||||
g_hdr_pool_fmt == AHB_RGB10A2 ? "10-bit" : "8-bit",
|
||||
color && color->dataspace == BANNER_ADATASPACE_BT2020_PQ ? "BT2020_PQ" : "HDR",
|
||||
color && color->dataspace == DROIDDECK_ADATASPACE_BT2020_PQ ? "BT2020_PQ" : "HDR",
|
||||
cov[0] ? ", " : "", cov);
|
||||
}
|
||||
}
|
||||
if (!l->first_logged) { l->first_logged = 1; vkp_signal_first_frame(); }
|
||||
if (!l->first_logged) l->first_logged = 1;
|
||||
vkp_signal_first_frame();
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -1020,13 +1025,13 @@ int sc_layer_overlay_affordable(void) {
|
||||
int first = g_overlay_declined < 0;
|
||||
g_overlay_declined = want;
|
||||
if (want)
|
||||
banner_log("layer", "overlay layer declined: this display rotates every layer %d° and the game "
|
||||
droiddeck_log("layer", "overlay layer declined: this display rotates every layer %d° and the game "
|
||||
"layer is scaled %dx%d -> %dx%d, and on that combination a second layer drops the "
|
||||
"whole frame to GPU composition for the rest of the session (measured). The window "
|
||||
"above the game goes on the copy path instead - same picture, one blit.",
|
||||
deg, sr[0], sr[1], ds[0], ds[1]);
|
||||
else if (!first)
|
||||
banner_log("layer", "overlay layer allowed again: the game layer is no longer both rotated and "
|
||||
droiddeck_log("layer", "overlay layer allowed again: the game layer is no longer both rotated and "
|
||||
"scaled, so a second display layer costs nothing here");
|
||||
}
|
||||
return ok;
|
||||
@@ -1077,7 +1082,7 @@ void sc_layer_hide(void) {
|
||||
* SurfaceControl, since it goes up and down with every effects/frame-generation toggle. */
|
||||
if (g_layers[SC_LAYER_OVERLAY].sc) sc_layer_hide_overlay();
|
||||
if (g_layers[SC_LAYER_GAME].shown) hide_layer(&g_layers[SC_LAYER_GAME]);
|
||||
if (said) banner_log("layer", "layers hidden (scene is not a single fullscreen window)");
|
||||
if (said) droiddeck_log("layer", "layers hidden (scene is not a single fullscreen window)");
|
||||
}
|
||||
|
||||
void sc_layer_hide_overlay(void) {
|
||||
@@ -1093,7 +1098,7 @@ void sc_layer_hide_overlay(void) {
|
||||
* buffers stay allocated for the next window. */
|
||||
if (l->shown) hide_layer(l);
|
||||
retire_sc(l);
|
||||
banner_log("layer", "%s: gone (nothing is above the game any more)", l->name);
|
||||
droiddeck_log("layer", "%s: gone (nothing is above the game any more)", l->name);
|
||||
/* ...and that is the half the measurement said is not enough: arm the game layer's
|
||||
* SurfaceControl swap, which the next frame performs (swap_sc_begin). */
|
||||
if (g_layers[SC_LAYER_GAME].sc) g_layers[SC_LAYER_GAME].recreate_pending = 1;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#ifndef SC_LAYER_H
|
||||
#define SC_LAYER_H
|
||||
/*
|
||||
* Layer mode (BANNER_WAYLAND_ZERO_COPY=1): the scene is handed to SurfaceFlinger as a small,
|
||||
* Layer mode (DROIDDECK_WAYLAND_ZERO_COPY=1): the scene is handed to SurfaceFlinger as a small,
|
||||
* deliberately ordered SET of Android display layers - ASurfaceControl children of the
|
||||
* compositor's SurfaceView - instead of being blitted into the compositor's own swapchain.
|
||||
*
|
||||
@@ -12,8 +12,8 @@
|
||||
* an ASurfaceControl has no input channel, so touch and mouse keep
|
||||
* reaching the SurfaceView exactly as they did.
|
||||
* +-- SurfaceView ..... the compositor's Vulkan swapchain; black while layer mode is up
|
||||
* +-- z=1 "banner_wayland_game" ...... the one fullscreen game window
|
||||
* +-- z=2 "banner_wayland_overlay" ... at most one window drawn ABOVE the game
|
||||
* +-- z=1 "droiddeck_wayland_game" ...... the one fullscreen game window
|
||||
* +-- z=2 "droiddeck_wayland_overlay" ... at most one window drawn ABOVE the game
|
||||
*
|
||||
* TWO layers is the hard cap (SC_LAYER_COUNT), and deliberately so: HWC composes only a few
|
||||
* layers before SurfaceFlinger falls back to GPU client composition, which would throw away the
|
||||
@@ -71,7 +71,7 @@
|
||||
|
||||
struct vkp_image;
|
||||
struct vkp_draw;
|
||||
struct banner_color;
|
||||
struct droiddeck_color;
|
||||
|
||||
/* The layers, bottom first. The z-order is the array order (z = id + 1). */
|
||||
enum sc_layer_id { SC_LAYER_GAME = 0, SC_LAYER_OVERLAY = 1, SC_LAYER_COUNT = 2 };
|
||||
@@ -84,7 +84,7 @@ int sc_layer_available(void);
|
||||
* Decides where a zero-copy acquire fence can come from; result goes to the session log. */
|
||||
void sc_layer_probe_dmabuf_fd(int fd);
|
||||
|
||||
/* HDR (banner_color.h): can a display layer be told its buffer's colour encoding here
|
||||
/* HDR (droiddeck_color.h): can a display layer be told its buffer's colour encoding here
|
||||
* (ASurfaceTransaction_setBufferDataSpace, Android 10+)? Part of the HDR gate. */
|
||||
int sc_layer_can_tag_hdr(void);
|
||||
/* Which of the colour calls this libandroid has, for the gate's log line. */
|
||||
@@ -100,7 +100,7 @@ void sc_layer_hdr_symbols(char *out, size_t size);
|
||||
* free buffer), -1 = the layer path is unavailable this frame and the caller must draw the old way.
|
||||
* The copy into the layer buffer is 8-bit: an HDR frame keeps its tag (its colours stay right), and
|
||||
* loses precision. */
|
||||
int sc_layer_present(struct vkp_image *src, int scene_w, int scene_h, const struct banner_color *color);
|
||||
int sc_layer_present(struct vkp_image *src, int scene_w, int scene_h, const struct droiddeck_color *color);
|
||||
|
||||
/* GAME layer, screen effects on: run the compositor pass (composite `draws` + the effects chain,
|
||||
* vkp_pass_begin) and put its result on the layer. Same return values as sc_layer_present. */
|
||||
@@ -113,7 +113,7 @@ int sc_layer_present_pass(const struct vkp_draw *draws, int n, int scene_w, int
|
||||
* Same return values as sc_layer_present. */
|
||||
struct vkp_hdr_frame;
|
||||
int sc_layer_present_hdr_scene(const struct vkp_draw *draws, int n, const struct vkp_hdr_frame *hf,
|
||||
int scene_w, int scene_h, const struct banner_color *color);
|
||||
int scene_w, int scene_h, const struct droiddeck_color *color);
|
||||
|
||||
/* GAME layer, zero-copy (ahb_swapchain.c): show the game's own w x h AHardwareBuffer, gated by
|
||||
* acquire_fd (a sync_file the layer waits on before reading; owned by the callee, -1 = none).
|
||||
@@ -124,7 +124,7 @@ int sc_layer_present_hdr_scene(const struct vkp_draw *draws, int n, const struct
|
||||
* frame (the caller draws the old way). `color` / `ahb_format`: the frame's image description (NULL =
|
||||
* none) and the buffer's AHARDWAREBUFFER_FORMAT_*, for the dataspace and the HDR evidence. */
|
||||
int sc_layer_present_ahb(AHardwareBuffer *ahb, int w, int h, int acquire_fd, void *token,
|
||||
int scene_w, int scene_h, const struct banner_color *color, uint32_t ahb_format);
|
||||
int scene_w, int scene_h, const struct droiddeck_color *color, uint32_t ahb_format);
|
||||
|
||||
/* Vote a panel refresh rate for the layer the game presents on (VRR / refresh-rate matching), the
|
||||
* same rate and compatibility the app votes on its own surface with Surface.setFrameRate; 0 = no
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
#include <sys/stat.h>
|
||||
#include <android/log.h>
|
||||
|
||||
#define TAG "BannerWayland"
|
||||
#define TAG "DroidDeckWayland"
|
||||
|
||||
struct vk_api g_vk;
|
||||
static void *g_handle;
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
#include "framegen_bridge.h"
|
||||
#include "hdr_compose.h"
|
||||
#include "blend_pass.h"
|
||||
#include "banner_color.h"
|
||||
#include "droiddeck_color.h"
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
@@ -19,9 +19,9 @@
|
||||
#include <pthread.h>
|
||||
#include <android/log.h>
|
||||
|
||||
#define TAG "BannerWayland"
|
||||
#define TAG "DroidDeckWayland"
|
||||
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__)
|
||||
#define LOGE(...) banner_log("error", __VA_ARGS__)
|
||||
#define LOGE(...) droiddeck_log("error", __VA_ARGS__)
|
||||
#define MOD_INVALID VKP_MOD_INVALID
|
||||
|
||||
struct vkp_image {
|
||||
@@ -73,7 +73,7 @@ static VkImage *g_images;
|
||||
static uint32_t g_nimg;
|
||||
static VkExtent2D g_extent;
|
||||
|
||||
static int g_first_frame_done; /* one-shot: fire banner_on_first_frame() on first present */
|
||||
static int g_first_frame_done; /* one-shot: fire droiddeck_on_first_frame() on first present */
|
||||
|
||||
/* The base surface under the display layers (vkp_base_black): 1 once a plain black frame has been
|
||||
* presented on the CURRENT swapchain and nothing else since. Every other present clears it, and so does
|
||||
@@ -179,7 +179,7 @@ static int64_t g_swap_retry_at_ns;
|
||||
static int g_swap_fail_logged;
|
||||
|
||||
/* Implemented in waylandcomp_jni.c - notifies Java (dismiss launch overlay). */
|
||||
extern void banner_on_first_frame(void);
|
||||
extern void droiddeck_on_first_frame(void);
|
||||
|
||||
static char g_gpu_name[VK_MAX_PHYSICAL_DEVICE_NAME_SIZE];
|
||||
const char *vkp_gpu_name(void) { return g_gpu_name; }
|
||||
@@ -276,7 +276,7 @@ int vkp_apply_window_request(void) {
|
||||
g_window = w;
|
||||
g_swap_retry_at_ns = 0;
|
||||
g_swap_fail_logged = 0;
|
||||
banner_log("gpu", w ? "screen surface attached" : "screen surface gone: presenting paused");
|
||||
droiddeck_log("gpu", w ? "screen surface attached" : "screen surface gone: presenting paused");
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -353,7 +353,7 @@ static void update_map(int scene_w, int scene_h) {
|
||||
g_map.off_y = (float)(g_map.ry + (int)((g_map.rh - scene_h * aspect) * 0.5f));
|
||||
}
|
||||
g_map.valid = 1;
|
||||
banner_log("screen", "%s, %s: %dx%d scene shown %dx%d at %d,%d on the %dx%d output",
|
||||
droiddeck_log("screen", "%s, %s: %dx%d scene shown %dx%d at %d,%d on the %dx%d output",
|
||||
mode_name(mode), align_name(align), scene_w, scene_h,
|
||||
(int)(scene_w * g_map.kx + 0.5f), (int)(scene_h * g_map.ky + 0.5f),
|
||||
(int)g_map.off_x, (int)g_map.off_y, W, H);
|
||||
@@ -391,10 +391,10 @@ static int dev_init(void) {
|
||||
const char *inst_exts[3] = {VK_KHR_SURFACE_EXTENSION_NAME,
|
||||
VK_KHR_ANDROID_SURFACE_EXTENSION_NAME, NULL};
|
||||
uint32_t n_inst_exts = 2;
|
||||
/* HDR sessions only (banner_color.h): the colour-space extension lets the swapchain carry HDR10 for
|
||||
/* HDR sessions only (droiddeck_color.h): the colour-space extension lets the swapchain carry HDR10 for
|
||||
* frame generation. Asked for only when the loader has it, and only when HDR was asked for, so
|
||||
* every other session creates exactly the instance it always did. */
|
||||
if (banner_color_requested() && g_vk.EnumerateInstanceExtensionProperties) {
|
||||
if (droiddeck_color_requested() && g_vk.EnumerateInstanceExtensionProperties) {
|
||||
uint32_t ne = 0;
|
||||
g_vk.EnumerateInstanceExtensionProperties(NULL, &ne, NULL);
|
||||
VkExtensionProperties *ie = ne ? calloc(ne, sizeof(*ie)) : NULL;
|
||||
@@ -407,14 +407,14 @@ static int dev_init(void) {
|
||||
}
|
||||
}
|
||||
free(ie);
|
||||
banner_log("color", "compositor instance %s VK_EXT_swapchain_colorspace (an HDR10 swapchain for frame "
|
||||
droiddeck_log("color", "compositor instance %s VK_EXT_swapchain_colorspace (an HDR10 swapchain for frame "
|
||||
"generation %s)", g_colorspace_ext ? "enables" : "has no",
|
||||
g_colorspace_ext ? "is possible where the screen surface offers one" : "is not possible: tone-mapped instead");
|
||||
}
|
||||
/* 1.3 like the X11 renderer's instance: the frame-generation probe (framegen_engine.cpp)
|
||||
* queries VkPhysicalDeviceVulkan12Features, and a 1.1 instance may be answered as 1.1. */
|
||||
VkApplicationInfo app = {.sType = VK_STRUCTURE_TYPE_APPLICATION_INFO,
|
||||
.pApplicationName = "banner-wayland-present",
|
||||
.pApplicationName = "droiddeck-wayland-present",
|
||||
.apiVersion = VK_API_VERSION_1_3};
|
||||
VkInstanceCreateInfo ici = {.sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO,
|
||||
.pApplicationInfo = &app,
|
||||
@@ -444,9 +444,9 @@ static int dev_init(void) {
|
||||
VkPhysicalDeviceProperties props;
|
||||
g_vk.GetPhysicalDeviceProperties(g_pd, &props);
|
||||
snprintf(g_gpu_name, sizeof(g_gpu_name), "%s", props.deviceName);
|
||||
banner_log("gpu", "compositor renders on %s with %s", props.deviceName,
|
||||
droiddeck_log("gpu", "compositor renders on %s with %s", props.deviceName,
|
||||
g_library_name ? g_library_name : "the system Vulkan driver");
|
||||
if (g_driver_path) banner_log("gpu", "driver folder %s", g_driver_path);
|
||||
if (g_driver_path) droiddeck_log("gpu", "driver folder %s", g_driver_path);
|
||||
}
|
||||
g_vk.GetPhysicalDeviceMemoryProperties(g_pd, &g_memprops);
|
||||
|
||||
@@ -464,7 +464,7 @@ static int dev_init(void) {
|
||||
LOGE("present: driver MISSING %s (dmabuf import will fail)", dev_exts[i]);
|
||||
/* HDR sessions only: the HDR10 swapchain (frame generation) can carry the game's metadata. */
|
||||
int want_hdr_md = 0;
|
||||
if (banner_color_requested()) {
|
||||
if (droiddeck_color_requested()) {
|
||||
want_hdr_md = has_ext(exts, ne, VK_EXT_HDR_METADATA_EXTENSION_NAME);
|
||||
if (want_hdr_md) dev_exts[n_dev_exts++] = VK_EXT_HDR_METADATA_EXTENSION_NAME;
|
||||
}
|
||||
@@ -487,7 +487,7 @@ static int dev_init(void) {
|
||||
.enabledExtensionCount = n_dev_exts, .ppEnabledExtensionNames = dev_exts};
|
||||
VkResult dr = g_vk.CreateDevice(g_pd, &dci, NULL, &g_dev);
|
||||
if (dr != VK_SUCCESS && fg_features) {
|
||||
banner_log("framegen", "the driver refused the LSFG feature set (%s); device created without it",
|
||||
droiddeck_log("framegen", "the driver refused the LSFG feature set (%s); device created without it",
|
||||
vk_result_name(dr));
|
||||
dci.pNext = NULL;
|
||||
fg_features = NULL;
|
||||
@@ -506,13 +506,13 @@ static int dev_init(void) {
|
||||
g_wait_sem = VK_NULL_HANDLE;
|
||||
}
|
||||
}
|
||||
banner_log("perf", "layer buffers: the display's release fences are waited for %s",
|
||||
droiddeck_log("perf", "layer buffers: the display's release fences are waited for %s",
|
||||
g_import_sem_fd ? "on the GPU (VK_KHR_external_semaphore_fd)"
|
||||
: "on the CPU (this driver has no VK_KHR_external_semaphore_fd)");
|
||||
if (want_hdr_md)
|
||||
g_set_hdr_metadata = (PFN_vkSetHdrMetadataEXT)g_vk.GetDeviceProcAddr(g_dev, "vkSetHdrMetadataEXT");
|
||||
if (banner_color_requested())
|
||||
banner_log("color", "compositor device %s VK_EXT_hdr_metadata (the HDR10 swapchain for frame generation %s)",
|
||||
if (droiddeck_color_requested())
|
||||
droiddeck_log("color", "compositor device %s VK_EXT_hdr_metadata (the HDR10 swapchain for frame generation %s)",
|
||||
g_set_hdr_metadata ? "enables" : "has no",
|
||||
g_set_hdr_metadata ? "carries the game's mastering metadata" : "goes without the game's metadata");
|
||||
|
||||
@@ -641,7 +641,7 @@ static void log_surface_formats(const VkSurfaceFormatKHR *f, uint32_t n) {
|
||||
vk_colorspace_short(f[i].colorSpace));
|
||||
else hdr_cut++;
|
||||
}
|
||||
banner_log("color", "screen surface lists %u format/colour-space pairs (%s); HDR-capable: %s%s", n, per,
|
||||
droiddeck_log("color", "screen surface lists %u format/colour-space pairs (%s); HDR-capable: %s%s", n, per,
|
||||
hdr_n ? hdr : "none", hdr_cut ? " (+more)" : "");
|
||||
}
|
||||
|
||||
@@ -678,7 +678,7 @@ static int swap_init_locked(void) {
|
||||
}
|
||||
VkSurfaceFormatKHR chosen = fmts[0];
|
||||
const VkSurfaceFormatKHR sdr_choice = fmts[0];
|
||||
if (banner_color_requested() && (!g_surface_formats_said || g_swap_hdr_want)) {
|
||||
if (droiddeck_color_requested() && (!g_surface_formats_said || g_swap_hdr_want)) {
|
||||
g_surface_formats_said = 1;
|
||||
log_surface_formats(fmts, nfmt);
|
||||
}
|
||||
@@ -702,13 +702,13 @@ static int swap_init_locked(void) {
|
||||
g_swap_is_hdr = 1;
|
||||
char fname[24];
|
||||
vk_format_short(chosen.format, fname, sizeof(fname));
|
||||
banner_log("color", "screen swapchain built as HDR10 (format %d %s, HDR10_ST2084): frame-generated HDR "
|
||||
droiddeck_log("color", "screen swapchain built as HDR10 (format %d %s, HDR10_ST2084): frame-generated HDR "
|
||||
"frames reach the display as PQ BT.2020%s", (int)chosen.format, fname,
|
||||
vk_format_bits(chosen.format) <= 8 ? " - an 8-bit swapchain: banding possible in smooth gradients"
|
||||
: "");
|
||||
} else {
|
||||
g_swap_hdr_unavailable = 1;
|
||||
banner_log("color", "the screen surface lists no HDR10 swapchain format among its %u format/colour-space "
|
||||
droiddeck_log("color", "the screen surface lists no HDR10 swapchain format among its %u format/colour-space "
|
||||
"pairs (see the line above): frames with frame generation are tone-mapped to SDR instead",
|
||||
nfmt);
|
||||
}
|
||||
@@ -748,7 +748,7 @@ static int swap_init_locked(void) {
|
||||
if (cr != VK_SUCCESS && g_swap_is_hdr) {
|
||||
/* The surface listed the HDR10 pair but refused the swapchain: say so, never ask again this
|
||||
* surface, and build the ordinary one now (frames tone-mapped) - never a black screen. */
|
||||
banner_log("color", "the driver refused the HDR10 swapchain (%s): frames with frame generation are "
|
||||
droiddeck_log("color", "the driver refused the HDR10 swapchain (%s): frames with frame generation are "
|
||||
"tone-mapped to SDR instead", vk_result_name(cr));
|
||||
g_swap_is_hdr = 0;
|
||||
g_swap_hdr_unavailable = 1;
|
||||
@@ -767,7 +767,7 @@ static int swap_init_locked(void) {
|
||||
g_images = calloc(g_nimg, sizeof(VkImage));
|
||||
g_vk.GetSwapchainImagesKHR(g_dev, g_swapchain, &g_nimg, g_images);
|
||||
|
||||
banner_log("gpu", "screen output %ux%u, %u buffers, vsync%s", g_extent.width, g_extent.height, g_nimg,
|
||||
droiddeck_log("gpu", "screen output %ux%u, %u buffers, vsync%s", g_extent.width, g_extent.height, g_nimg,
|
||||
g_swap_extra ? " (frame generation: several presents per frame)" : "");
|
||||
return 0;
|
||||
}
|
||||
@@ -833,7 +833,7 @@ static void init_black_image(void) {
|
||||
g_vk.WaitForFences(g_dev, 1, &g_fence, VK_TRUE, 1000000000ULL) == VK_SUCCESS)
|
||||
g_black_state = 1;
|
||||
else
|
||||
banner_log("gpu", "letterbox black image unavailable: the whole output is cleared every copy-path frame");
|
||||
droiddeck_log("gpu", "letterbox black image unavailable: the whole output is cleared every copy-path frame");
|
||||
}
|
||||
|
||||
/* Blit the black image over everything of swapchain image `img` (in TRANSFER_DST_OPTIMAL) OUTSIDE the
|
||||
@@ -971,7 +971,7 @@ struct vkp_image *vkp_image_import_dmabuf(int fd, uint32_t drm_format, uint64_t
|
||||
LOGE("%s: vkCreateImage(%s, %dx%d, pitch %u, offset %u) -> %s%s", as_blit_dst ? "layer" : "dmabuf",
|
||||
vkp_modifier_name(modifier), w, h, stride, offset, vk_result_name(cr),
|
||||
(!as_blit_dst && modifier == VKP_MOD_QCOM_COMPRESSED)
|
||||
? ": the game's UBWC layout was refused; BANNER_WAYLAND_UBWC=0 forces linear buffers" : "");
|
||||
? ": the game's UBWC layout was refused; DROIDDECK_WAYLAND_UBWC=0 forces linear buffers" : "");
|
||||
free(img); return NULL;
|
||||
}
|
||||
|
||||
@@ -1182,7 +1182,7 @@ static int ensure_scene_image(int w, int h) {
|
||||
if (r != VK_SUCCESS) { LOGE("effects: scene image %dx%d: vkAllocateMemory %s", w, h, vk_result_name(r)); destroy_scene_image(); return -1; }
|
||||
g_vk.BindImageMemory(g_dev, g_scene.img, g_scene.mem, 0);
|
||||
g_scene.w = w; g_scene.h = h;
|
||||
banner_log("effects", "scene image %dx%d for the effect chain", w, h);
|
||||
droiddeck_log("effects", "scene image %dx%d for the effect chain", w, h);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -1218,7 +1218,7 @@ static int ensure_img(struct vkp_img_slot *s, int w, int h, VkFormat fmt, VkImag
|
||||
s->w = w; s->h = h; s->fmt = fmt;
|
||||
char fname[24];
|
||||
vk_format_short(fmt, fname, sizeof(fname));
|
||||
banner_log("color", "%s image %dx%d (%s)", what, w, h, fname);
|
||||
droiddeck_log("color", "%s image %dx%d (%s)", what, w, h, fname);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -1249,7 +1249,7 @@ static int compose_hdr(VkCommandBuffer cmd, const struct vkp_draw *draws, int n,
|
||||
.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT};
|
||||
g_vk.CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 1, &mb, 0, NULL, 0, NULL);
|
||||
|
||||
struct hdrc_params p = {.mode = mode, .sdr_white_nits = banner_color_sdr_white(),
|
||||
struct hdrc_params p = {.mode = mode, .sdr_white_nits = droiddeck_color_sdr_white(),
|
||||
.peak_nits = hf->peak_nits > 0.0f ? hf->peak_nits : 1000.0f};
|
||||
int lowest_hdr = -1;
|
||||
for (int i = 0; i < n; i++) {
|
||||
@@ -1272,7 +1272,7 @@ static int compose_hdr(VkCommandBuffer cmd, const struct vkp_draw *draws, int n,
|
||||
}
|
||||
if (i >= lowest_hdr && !truncated_said) {
|
||||
truncated_said = 1;
|
||||
banner_log("color", "HDR composition: more than %d windows are stacked over the HDR game - the lowest "
|
||||
droiddeck_log("color", "HDR composition: more than %d windows are stacked over the HDR game - the lowest "
|
||||
"ones are treated as SDR", HDRC_MAX_RECTS);
|
||||
}
|
||||
}
|
||||
@@ -1287,7 +1287,7 @@ static void device_lost(const char *where) {
|
||||
g_dev_state = -2;
|
||||
g_base_black = 0;
|
||||
vkp_framegen_device_lost();
|
||||
banner_log("error", "GPU device lost (VK_ERROR_DEVICE_LOST in %s): the compositor has stopped presenting; "
|
||||
droiddeck_log("error", "GPU device lost (VK_ERROR_DEVICE_LOST in %s): the compositor has stopped presenting; "
|
||||
"restart the session", where);
|
||||
for (uint32_t i = 0; i < g_nimg; i++) blendp_forget_image(g_images[i]);
|
||||
if (g_swapchain) g_vk.DestroySwapchainKHR(g_dev, g_swapchain, NULL);
|
||||
@@ -1385,12 +1385,12 @@ static int acquire_image(int k, int first, uint32_t *img, VkResult *ar_out) {
|
||||
if (ar == VK_SUCCESS || ar == VK_SUBOPTIMAL_KHR) { *ar_out = ar; return 0; }
|
||||
if (ar == VK_ERROR_DEVICE_LOST) { device_lost("acquire"); return -1; }
|
||||
if (ar == VK_ERROR_OUT_OF_DATE_KHR || ar == VK_ERROR_SURFACE_LOST_KHR) {
|
||||
banner_log("gpu", "screen surface %s on acquire: rebuilding the swapchain", vk_result_name(ar));
|
||||
droiddeck_log("gpu", "screen surface %s on acquire: rebuilding the swapchain", vk_result_name(ar));
|
||||
destroy_swapchain();
|
||||
if (first && attempt == 0) continue;
|
||||
return -1;
|
||||
}
|
||||
banner_log("error", "present: acquire %d failed (%s %d)", k, vk_result_name(ar), (int)ar);
|
||||
droiddeck_log("error", "present: acquire %d failed (%s %d)", k, vk_result_name(ar), (int)ar);
|
||||
if (ar == VK_TIMEOUT || ar == VK_NOT_READY) return -1;
|
||||
destroy_swapchain(); /* anything else: start over next frame */
|
||||
return -1;
|
||||
@@ -1422,7 +1422,7 @@ static void check_surface_changed(void) {
|
||||
if (caps.currentExtent.width == g_caps_extent.width && caps.currentExtent.height == g_caps_extent.height &&
|
||||
caps.currentTransform == g_surface_transform)
|
||||
return;
|
||||
banner_log("gpu", "screen surface changed (%ux%u -> %ux%u, transform %d -> %d): rebuilding the swapchain",
|
||||
droiddeck_log("gpu", "screen surface changed (%ux%u -> %ux%u, transform %d -> %d): rebuilding the swapchain",
|
||||
g_caps_extent.width, g_caps_extent.height, caps.currentExtent.width, caps.currentExtent.height,
|
||||
(int)g_surface_transform, (int)caps.currentTransform);
|
||||
destroy_swapchain();
|
||||
@@ -1464,7 +1464,7 @@ static int take_wait_fd(int fd) {
|
||||
static int said;
|
||||
if (!said) {
|
||||
said = 1;
|
||||
banner_log("perf", "layer buffers: importing a release fence as a GPU wait failed; waiting for it on the CPU");
|
||||
droiddeck_log("perf", "layer buffers: importing a release fence as a GPU wait failed; waiting for it on the CPU");
|
||||
}
|
||||
}
|
||||
struct pollfd p = {.fd = fd, .events = POLLIN};
|
||||
@@ -1476,18 +1476,18 @@ static int take_wait_fd(int fd) {
|
||||
|
||||
/* The HDR10 swapchain's metadata: the game's SMPTE 2086 / CTA-861.3 values (its image description) via
|
||||
* VK_EXT_hdr_metadata, once per swapchain and description. A game that gave none gets none. */
|
||||
static void send_hdr_metadata(const struct banner_color *c) {
|
||||
static void send_hdr_metadata(const struct droiddeck_color *c) {
|
||||
static int said_none, said_nodesc;
|
||||
if (!c) return;
|
||||
if (!g_set_hdr_metadata) {
|
||||
if (!said_none) { said_none = 1; banner_log("color", "HDR10 swapchain without the game's metadata: this device "
|
||||
if (!said_none) { said_none = 1; droiddeck_log("color", "HDR10 swapchain without the game's metadata: this device "
|
||||
"has no VK_EXT_hdr_metadata (the display uses its defaults)"); }
|
||||
return;
|
||||
}
|
||||
if (!c->has_st2086 && !c->has_cta861) {
|
||||
if (said_nodesc != (int)c->identity) {
|
||||
said_nodesc = (int)c->identity;
|
||||
banner_log("color", "HDR10 swapchain: image description #%u carries no HDR metadata (the game sent none), "
|
||||
droiddeck_log("color", "HDR10 swapchain: image description #%u carries no HDR metadata (the game sent none), "
|
||||
"so none is set - the display uses its defaults", c->identity);
|
||||
}
|
||||
return;
|
||||
@@ -1506,7 +1506,7 @@ static void send_hdr_metadata(const struct banner_color *c) {
|
||||
md.maxFrameAverageLightLevel = c->max_fall;
|
||||
}
|
||||
g_set_hdr_metadata(g_dev, 1, &g_swapchain, &md);
|
||||
banner_log("color", "HDR10 swapchain: the game's metadata set via VK_EXT_hdr_metadata (image description #%u: %s)",
|
||||
droiddeck_log("color", "HDR10 swapchain: the game's metadata set via VK_EXT_hdr_metadata (image description #%u: %s)",
|
||||
c->identity, c->text);
|
||||
}
|
||||
|
||||
@@ -1522,7 +1522,7 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i
|
||||
/* Frame generation queues several presents per scene frame and needs swapchain images for
|
||||
* them: rebuild when that changes (armed, disarmed, another multiplier). */
|
||||
if (g_swapchain && vkp_framegen_extra_images() != g_swap_extra) {
|
||||
banner_log("gpu", "frame generation now wants %d present%s per frame: rebuilding the swapchain",
|
||||
droiddeck_log("gpu", "frame generation now wants %d present%s per frame: rebuilding the swapchain",
|
||||
1 + vkp_framegen_extra_images(), vkp_framegen_extra_images() ? "s" : "");
|
||||
destroy_swapchain();
|
||||
}
|
||||
@@ -1531,7 +1531,7 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i
|
||||
* swapchain (only in HDR sessions: g_colorspace_ext). */
|
||||
const int want_hdr = hf && !hf->tonemap && g_colorspace_ext && !g_swap_hdr_unavailable;
|
||||
if (g_swapchain && want_hdr != g_swap_is_hdr) {
|
||||
banner_log("color", want_hdr ? "HDR frames with frame generation: rebuilding the screen swapchain as HDR10"
|
||||
droiddeck_log("color", want_hdr ? "HDR frames with frame generation: rebuilding the screen swapchain as HDR10"
|
||||
: hf ? "HDR output switched off: rebuilding the screen swapchain as SDR (frames tone-mapped)"
|
||||
: "no HDR frames through the screen swapchain any more: rebuilding it as SDR");
|
||||
destroy_swapchain();
|
||||
@@ -1570,10 +1570,10 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i
|
||||
char sw[24];
|
||||
vk_format_short(g_swap_fmt, sw, sizeof(sw));
|
||||
if (deep)
|
||||
banner_log("color", "frame generation runs on the HDR picture in FP16 (RGBA16F): more than 8 bits of PQ "
|
||||
droiddeck_log("color", "frame generation runs on the HDR picture in FP16 (RGBA16F): more than 8 bits of PQ "
|
||||
"through interpolation, into the HDR10 swapchain (%s)", sw);
|
||||
else
|
||||
banner_log("color", "frame generation runs on the HDR picture in 8 bits (the engine cannot take FP16 here), "
|
||||
droiddeck_log("color", "frame generation runs on the HDR picture in 8 bits (the engine cannot take FP16 here), "
|
||||
"into the HDR10 swapchain (%s): banding possible in smooth gradients", sw);
|
||||
}
|
||||
}
|
||||
@@ -1746,7 +1746,7 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i
|
||||
if (k > 0) {
|
||||
VkResult ark;
|
||||
if (acquire_image(k, 0, &img, &ark) != 0) {
|
||||
banner_log("framegen", "present %d/%d: no swapchain image; the frame ends early", k + 1, presents);
|
||||
droiddeck_log("framegen", "present %d/%d: no swapchain image; the frame ends early", k + 1, presents);
|
||||
pr = VK_ERROR_OUT_OF_DATE_KHR;
|
||||
break;
|
||||
}
|
||||
@@ -1791,7 +1791,7 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i
|
||||
if (fr == VK_ERROR_DEVICE_LOST || pr == VK_ERROR_DEVICE_LOST) { device_lost("present"); return -1; }
|
||||
if (pr == VK_ERROR_OUT_OF_DATE_KHR || pr == VK_ERROR_SURFACE_LOST_KHR) {
|
||||
/* The surface changed or went away under this frame: rebuild before the next one. */
|
||||
banner_log("gpu", "screen surface %s on present: rebuilding the swapchain", vk_result_name(pr));
|
||||
droiddeck_log("gpu", "screen surface %s on present: rebuilding the swapchain", vk_result_name(pr));
|
||||
destroy_swapchain();
|
||||
return -1;
|
||||
} else if (pr == VK_SUBOPTIMAL_KHR || ar == VK_SUBOPTIMAL_KHR) {
|
||||
@@ -1799,9 +1799,9 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i
|
||||
* swap_init), so a rotated panel reports SUBOPTIMAL on every frame. Rebuilding would change
|
||||
* nothing (and a rebuild per frame would be far worse), so present as is; say so once. */
|
||||
static int said;
|
||||
if (!said) { said = 1; banner_log("gpu", "surface reports SUBOPTIMAL (panel rotation); presenting as is"); }
|
||||
if (!said) { said = 1; droiddeck_log("gpu", "surface reports SUBOPTIMAL (panel rotation); presenting as is"); }
|
||||
} else if (pr != VK_SUCCESS) {
|
||||
banner_log("error", "present: vkQueuePresentKHR failed (%s %d)", vk_result_name(pr), (int)pr);
|
||||
droiddeck_log("error", "present: vkQueuePresentKHR failed (%s %d)", vk_result_name(pr), (int)pr);
|
||||
destroy_swapchain();
|
||||
return -1;
|
||||
}
|
||||
@@ -1815,7 +1815,7 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i
|
||||
* launch/preloader overlay can dismiss (wayland has no XServer window-content hook). */
|
||||
if (drawn && !g_first_frame_done) {
|
||||
g_first_frame_done = 1;
|
||||
banner_on_first_frame();
|
||||
droiddeck_on_first_frame();
|
||||
}
|
||||
if (how) *how = hdr_how;
|
||||
return 0;
|
||||
@@ -1856,7 +1856,7 @@ void vkp_reset_first_frame(void) {
|
||||
void vkp_signal_first_frame(void) {
|
||||
if (g_first_frame_done) return;
|
||||
g_first_frame_done = 1;
|
||||
banner_on_first_frame();
|
||||
droiddeck_on_first_frame();
|
||||
}
|
||||
|
||||
int vkp_update_map(int scene_w, int scene_h) {
|
||||
|
||||
@@ -122,12 +122,12 @@ int vkp_can_wait_sync_fd(void);
|
||||
|
||||
/* ---- HDR composition (hdr_compose.h) ----
|
||||
* An HDR game that cannot be alone on its display layer: the scene is composed into ONE encoding. */
|
||||
struct banner_color;
|
||||
struct droiddeck_color;
|
||||
struct vkp_hdr_frame {
|
||||
const unsigned char *is_hdr; /* per draw: 1 = its image holds PQ BT.2020 (the surface's description) */
|
||||
float peak_nits; /* the HDR content's peak, for a tone-map (max CLL / mastering max; 0 = 1000) */
|
||||
int tonemap; /* 1 = the drawer's HDR output switch is off: tone-map to SDR, never PQ */
|
||||
const struct banner_color *color; /* the topmost HDR draw's description: an HDR10 swapchain's metadata */
|
||||
const struct droiddeck_color *color; /* the topmost HDR draw's description: an HDR10 swapchain's metadata */
|
||||
};
|
||||
/* vkp_render for a scene with HDR draws (frame generation): composed into PQ and presented through an
|
||||
* HDR10 swapchain where the surface offers one (and hf->tonemap is 0), else tone-mapped into the ordinary
|
||||
@@ -184,6 +184,6 @@ int vkp_pass_copy_to(struct vkp_image *dst, int wait_fd);
|
||||
void vkp_pass_abort(void);
|
||||
|
||||
// Session log (compositor.c): one line to Download/Wayland-logs and logcat.
|
||||
void banner_log(const char *tag, const char *fmt, ...) __attribute__((format(printf, 2, 3)));
|
||||
void droiddeck_log(const char *tag, const char *fmt, ...) __attribute__((format(printf, 2, 3)));
|
||||
|
||||
#endif
|
||||
@@ -16,20 +16,20 @@
|
||||
#include <android/log.h>
|
||||
#include <android/native_window_jni.h>
|
||||
#include "vk_present.h"
|
||||
#include "banner_ext.h"
|
||||
#include "droiddeck_ext.h"
|
||||
#include "ahb_swapchain.h"
|
||||
#include "sc_layer.h"
|
||||
#include "effects_chain.h"
|
||||
#include "banner_color.h"
|
||||
#include "droiddeck_color.h"
|
||||
|
||||
extern int banner_wayland_run(void);
|
||||
extern void banner_wayland_send_pointer(int action, int x, int y);
|
||||
extern void banner_wayland_send_touch(int action, int pointer_id, int x, int y);
|
||||
extern int banner_cursor_snapshot(int *out, int cap);
|
||||
extern int banner_cursor_serial(void);
|
||||
extern void banner_wayland_send_key(int evdev, int state);
|
||||
extern void banner_wayland_send_scene_input(int type, int a, int b);
|
||||
extern void banner_wayland_vsync(int64_t frame_time_ns);
|
||||
extern int droiddeck_wayland_run(void);
|
||||
extern void droiddeck_wayland_send_pointer(int action, int x, int y);
|
||||
extern void droiddeck_wayland_send_touch(int action, int pointer_id, int x, int y);
|
||||
extern int droiddeck_cursor_snapshot(int *out, int cap);
|
||||
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 volatile int g_fps_limit;
|
||||
extern volatile int g_hide_shell;
|
||||
extern volatile int g_zero_copy;
|
||||
@@ -39,7 +39,7 @@ extern volatile int g_no_render_node;
|
||||
extern volatile int g_output_refresh_mhz;
|
||||
extern volatile int g_output_w, g_output_h;
|
||||
|
||||
#define TAG "BannerWayland"
|
||||
#define TAG "DroidDeckWayland"
|
||||
|
||||
static JavaVM *g_jvm;
|
||||
static volatile pid_t g_comp_tid; /* the compositor thread, for the app's ADPF hint session */
|
||||
@@ -80,7 +80,7 @@ JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved) {
|
||||
/* Called from vk_present.c on the compositor thread when the first client frame is
|
||||
* presented. Attaches to the JVM (this thread is a bare pthread) and calls back into
|
||||
* Java so the launch overlay can dismiss. Fires exactly once. */
|
||||
void banner_on_first_frame(void) {
|
||||
void droiddeck_on_first_frame(void) {
|
||||
if (!g_jvm || !g_compositor_cls || !g_on_first_frame) return;
|
||||
JNIEnv *env = NULL;
|
||||
int attached = 0;
|
||||
@@ -111,7 +111,7 @@ static JNIEnv *thread_env(void) {
|
||||
}
|
||||
|
||||
/* A window started presenting GPU frames (window = its description), or NULL when it closed. */
|
||||
void banner_on_game_surface(const char *window, const char *gpu) {
|
||||
void droiddeck_on_game_surface(const char *window, const char *gpu) {
|
||||
JNIEnv *env;
|
||||
if (!g_compositor_cls || !g_on_game_surface || !(env = thread_env())) return;
|
||||
jstring jw = window ? (*env)->NewStringUTF(env, window) : NULL;
|
||||
@@ -124,7 +124,7 @@ void banner_on_game_surface(const char *window, const char *gpu) {
|
||||
|
||||
/* The program behind the game window that just started presenting: its Linux pid and executable name
|
||||
* ("" = unknown). The app arms its launch-time CPU affinity on it. Compositor thread. */
|
||||
void banner_on_game_program(int pid, const char *program) {
|
||||
void droiddeck_on_game_program(int pid, const char *program) {
|
||||
JNIEnv *env;
|
||||
if (!g_compositor_cls || !g_on_game_program || !(env = thread_env())) return;
|
||||
jstring jp = (*env)->NewStringUTF(env, program ? program : "");
|
||||
@@ -134,7 +134,7 @@ void banner_on_game_program(int pid, const char *program) {
|
||||
}
|
||||
|
||||
/* One GPU frame from that window. */
|
||||
void banner_on_game_frame(void) {
|
||||
void droiddeck_on_game_frame(void) {
|
||||
JNIEnv *env;
|
||||
if (!g_compositor_cls || !g_on_game_frame || !(env = thread_env())) return;
|
||||
(*env)->CallStaticVoidMethod(env, g_compositor_cls, g_on_game_frame);
|
||||
@@ -144,7 +144,7 @@ void banner_on_game_frame(void) {
|
||||
/* A program locked the pointer (locked = 1; the app's input path switches to deltas) or the lock
|
||||
* ended (locked = 0; x,y = where the pointer is now, in scene coordinates, for the app to re-sync
|
||||
* its own pointer to). Compositor thread. */
|
||||
void banner_on_pointer_lock(int locked, int x, int y) {
|
||||
void droiddeck_on_pointer_lock(int locked, int x, int y) {
|
||||
JNIEnv *env;
|
||||
if (!g_compositor_cls || !g_on_pointer_lock || !(env = thread_env())) return;
|
||||
(*env)->CallStaticVoidMethod(env, g_compositor_cls, g_on_pointer_lock, (jboolean)(locked != 0), (jint)x, (jint)y);
|
||||
@@ -153,7 +153,7 @@ void banner_on_pointer_lock(int locked, int x, int y) {
|
||||
|
||||
/* A program copied text (UTF-8, not NUL-terminated for the app's sake - a byte[] so emoji and
|
||||
* NULs survive JNI). Compositor thread. */
|
||||
void banner_on_clipboard_text(const char *utf8, int len) {
|
||||
void droiddeck_on_clipboard_text(const char *utf8, int len) {
|
||||
JNIEnv *env;
|
||||
if (!g_compositor_cls || !g_on_clipboard || !(env = thread_env())) return;
|
||||
jbyteArray arr = (*env)->NewByteArray(env, len);
|
||||
@@ -166,7 +166,7 @@ void banner_on_clipboard_text(const char *utf8, int len) {
|
||||
|
||||
/* A program started (enabled) or stopped accepting IME text; x,y,w,h = its caret rectangle in
|
||||
* scene pixels (all 0 = unknown). Compositor thread. */
|
||||
void banner_on_text_input(int enabled, const char *program, int x, int y, int w, int h) {
|
||||
void droiddeck_on_text_input(int enabled, const char *program, int x, int y, int w, int h) {
|
||||
JNIEnv *env;
|
||||
if (!g_compositor_cls || !g_on_text_input || !(env = thread_env())) return;
|
||||
jstring jp = (*env)->NewStringUTF(env, program ? program : "");
|
||||
@@ -195,11 +195,11 @@ static void *comp_thread(void *arg) {
|
||||
if (before > COMPOSITOR_NICE && setpriority(PRIO_PROCESS, tid, COMPOSITOR_NICE) != 0) refused = errno ? errno : -1;
|
||||
const int after = getpriority(PRIO_PROCESS, tid);
|
||||
if (refused)
|
||||
banner_log("perf", "compositor thread %d \"wl-compositor\": stays at nice %d, a higher priority was refused (%s)",
|
||||
droiddeck_log("perf", "compositor thread %d \"wl-compositor\": stays at nice %d, a higher priority was refused (%s)",
|
||||
(int)tid, after, refused > 0 ? strerror(refused) : "?");
|
||||
else
|
||||
banner_log("perf", "compositor thread %d \"wl-compositor\": nice %d -> %d", (int)tid, before, after);
|
||||
banner_wayland_run();
|
||||
droiddeck_log("perf", "compositor thread %d \"wl-compositor\": nice %d -> %d", (int)tid, before, after);
|
||||
droiddeck_wayland_run();
|
||||
__android_log_print(ANDROID_LOG_INFO, TAG, "compositor thread exited");
|
||||
if (t_attached) (*g_jvm)->DetachCurrentThread(g_jvm);
|
||||
t_env = NULL; t_attached = 0;
|
||||
@@ -272,13 +272,13 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeStartWithSurface(
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSendPointer(
|
||||
JNIEnv *env, jclass clazz, jint action, jint x, jint y) {
|
||||
banner_wayland_send_pointer(action, x, y);
|
||||
droiddeck_wayland_send_pointer(action, x, y);
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSendTouch(
|
||||
JNIEnv *env, jclass clazz, jint action, jint pointer_id, jint x, jint y) {
|
||||
banner_wayland_send_touch(action, pointer_id, x, y);
|
||||
droiddeck_wayland_send_touch(action, pointer_id, x, y);
|
||||
}
|
||||
|
||||
/* The client's pointer image and whether it wants one at all (wl_pointer.set_cursor).
|
||||
@@ -291,7 +291,7 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeCursorSnapshot(
|
||||
jint cap = (*env)->GetArrayLength(env, out);
|
||||
jint *buf = (*env)->GetIntArrayElements(env, out, NULL);
|
||||
if (!buf) return 0;
|
||||
int n = banner_cursor_snapshot((int *)buf, (int)cap);
|
||||
int n = droiddeck_cursor_snapshot((int *)buf, (int)cap);
|
||||
(*env)->ReleaseIntArrayElements(env, out, buf, 0);
|
||||
return n;
|
||||
}
|
||||
@@ -300,27 +300,27 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeCursorSnapshot(
|
||||
JNIEXPORT jint JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeCursorSerial(JNIEnv *env, jclass clazz) {
|
||||
(void)env; (void)clazz;
|
||||
return banner_cursor_serial();
|
||||
return droiddeck_cursor_serial();
|
||||
}
|
||||
|
||||
/* Inject a key event. evdev = Linux input keycode (KEY_A=30…); state 1=down 0=up. */
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSendKey(
|
||||
JNIEnv *env, jclass clazz, jint evdev, jint state) {
|
||||
banner_wayland_send_key(evdev, state);
|
||||
droiddeck_wayland_send_key(evdev, state);
|
||||
}
|
||||
|
||||
/* App X-server input in scene (virtual desktop) coordinates; see banner_wayland_send_scene_input. */
|
||||
/* App X-server input in scene (virtual desktop) coordinates; see droiddeck_wayland_send_scene_input. */
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSendSceneInput(
|
||||
JNIEnv *env, jclass clazz, jint type, jint a, jint b) {
|
||||
banner_wayland_send_scene_input(type, a, b);
|
||||
droiddeck_wayland_send_scene_input(type, a, b);
|
||||
}
|
||||
|
||||
/* One screen refresh (Choreographer frame callback, UI thread): the compositor draws once. */
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeVsync(JNIEnv *env, jclass clazz, jlong frameTimeNanos) {
|
||||
banner_wayland_vsync((int64_t)frameTimeNanos);
|
||||
droiddeck_wayland_vsync((int64_t)frameTimeNanos);
|
||||
}
|
||||
|
||||
/* Shortcut launches: don't draw explorer's windows (desktop, taskbar), like X11's unviewable classes. */
|
||||
@@ -329,15 +329,15 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetHideShell(JNIEnv
|
||||
g_hide_shell = hide ? 1 : 0;
|
||||
}
|
||||
|
||||
/* Zero-copy layer mode (BANNER_WAYLAND_ZERO_COPY=1 at launch, the drawer's switch live): one fullscreen
|
||||
/* Zero-copy layer mode (DROIDDECK_WAYLAND_ZERO_COPY=1 at launch, the drawer's switch live): one fullscreen
|
||||
* window on its own Android layer instead of the swapchain blit (sc_layer.c / ahb_swapchain.c). Any
|
||||
* thread, any time: before the compositor starts it is the launch default; afterwards the compositor
|
||||
* thread flips the state, tells the bound games to rebuild their swapchains for it and redraws. */
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetZeroCopy(JNIEnv *env, jclass clazz, jboolean on) {
|
||||
int live = banner_get_display() != NULL;
|
||||
int live = droiddeck_get_display() != NULL;
|
||||
if (!live) g_zero_copy = on ? 1 : 0; /* read by ahb_swapchain_init before the queue drains */
|
||||
banner_host_zero_copy(on ? 1 : 0, live);
|
||||
droiddeck_host_zero_copy(on ? 1 : 0, live);
|
||||
__android_log_print(ANDROID_LOG_INFO, TAG, "zero-copy layer mode %s%s", on ? "on" : "off", live ? " (live)" : "");
|
||||
}
|
||||
|
||||
@@ -355,7 +355,7 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeZeroCopyFrames(JNIEn
|
||||
}
|
||||
|
||||
/* Compressed (UBWC) game buffers: advertise DRM_FORMAT_MOD_QCOM_COMPRESSED on zwp_linux_dmabuf_v1 when
|
||||
* the renderer's driver imports it (default on; BANNER_WAYLAND_UBWC=0 = off). Set before the compositor
|
||||
* the renderer's driver imports it (default on; DROIDDECK_WAYLAND_UBWC=0 = off). Set before the compositor
|
||||
* starts. */
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetUbwc(JNIEnv *env, jclass clazz, jboolean on) {
|
||||
@@ -363,7 +363,7 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetUbwc(JNIEnv *env,
|
||||
__android_log_print(ANDROID_LOG_INFO, TAG, "compressed (UBWC) game buffers %s", on ? "on" : "off");
|
||||
}
|
||||
|
||||
/* Debug: name no DRM device in the dma-buf feedback (BANNER_WAYLAND_NO_RENDER_NODE=1), the way a
|
||||
/* Debug: name no DRM device in the dma-buf feedback (DROIDDECK_WAYLAND_NO_RENDER_NODE=1), the way a
|
||||
* phone that exposes no /dev/dri node to apps does. Set before the compositor starts. */
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetNoRenderNode(JNIEnv *env, jclass clazz, jboolean on) {
|
||||
@@ -452,12 +452,12 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetScreenEffects(JNI
|
||||
}
|
||||
|
||||
/* One "display" line in the session log, written from Java: facts that live in the Android
|
||||
* framework (the panel's HDR capability) rather than in the compositor. banner_log() mirrors to
|
||||
* framework (the panel's HDR capability) rather than in the compositor. droiddeck_log() mirrors to
|
||||
* logcat and appends to the session file when one is open, so this is safe at any point. */
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeLogDisplay(JNIEnv *env, jclass clazz, jstring message) {
|
||||
char *s = dup_jstr(env, message);
|
||||
if (s) banner_log("display", "%s", s);
|
||||
if (s) droiddeck_log("display", "%s", s);
|
||||
free(s);
|
||||
}
|
||||
|
||||
@@ -466,18 +466,18 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeLogDisplay(JNIEnv *e
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeLogPerf(JNIEnv *env, jclass clazz, jstring message) {
|
||||
char *s = dup_jstr(env, message);
|
||||
if (s) banner_log("perf", "%s", s);
|
||||
if (s) droiddeck_log("perf", "%s", s);
|
||||
free(s);
|
||||
}
|
||||
|
||||
/* ---- HDR10 output, round 1 (banner_color.h / wl_color_mgmt.c) ---- */
|
||||
/* ---- HDR10 output, round 1 (droiddeck_color.h / wl_color_mgmt.c) ---- */
|
||||
|
||||
/* The opt-in: mode 0 off, 1 BANNER_WAYLAND_HDR=1, 2 =force (testing). Before the compositor starts. */
|
||||
/* The opt-in: mode 0 off, 1 DROIDDECK_WAYLAND_HDR=1, 2 =force (testing). Before the compositor starts. */
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetHdrRequest(JNIEnv *env, jclass clazz, jint mode,
|
||||
jstring source, jboolean dxvkHdr, jboolean zeroCopyForced) {
|
||||
char *s = dup_jstr(env, source);
|
||||
banner_color_set_request((int)mode, s, dxvkHdr ? 1 : 0, zeroCopyForced ? 1 : 0);
|
||||
droiddeck_color_set_request((int)mode, s, dxvkHdr ? 1 : 0, zeroCopyForced ? 1 : 0);
|
||||
free(s);
|
||||
}
|
||||
|
||||
@@ -487,7 +487,7 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetHdrDisplay(JNIEnv
|
||||
jstring formats, jboolean hdr10, jfloat maxLum, jfloat maxAvg, jfloat minLum, jboolean ratioAvailable,
|
||||
jfloat ratio, jint api) {
|
||||
char *n = dup_jstr(env, name), *f = dup_jstr(env, formats);
|
||||
banner_color_set_display((int)id, n, f, hdr10 ? 1 : 0, (float)maxLum, (float)maxAvg, (float)minLum,
|
||||
droiddeck_color_set_display((int)id, n, f, hdr10 ? 1 : 0, (float)maxLum, (float)maxAvg, (float)minLum,
|
||||
ratioAvailable ? 1 : 0, (float)ratio, (int)api);
|
||||
free(n); free(f);
|
||||
}
|
||||
@@ -496,38 +496,38 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetHdrDisplay(JNIEnv
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeHdrSdrRatioSample(JNIEnv *env, jclass clazz, jfloat ratio,
|
||||
jboolean listener) {
|
||||
banner_color_ratio_sample((float)ratio, listener ? 1 : 0);
|
||||
droiddeck_color_ratio_sample((float)ratio, listener ? 1 : 0);
|
||||
}
|
||||
|
||||
/* ms since an HDR frame last reached a display layer, -1 = never this session. Any thread. */
|
||||
JNIEXPORT jint JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeHdrLastFrameAgeMs(JNIEnv *env, jclass clazz) {
|
||||
return (jint)banner_color_last_frame_age_ms();
|
||||
return (jint)droiddeck_color_last_frame_age_ms();
|
||||
}
|
||||
|
||||
/* -1 = not decided yet, 0 = closed, 1 = open. Any thread. */
|
||||
JNIEXPORT jint JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeHdrGateState(JNIEnv *env, jclass clazz) {
|
||||
return (jint)banner_color_gate_state();
|
||||
return (jint)droiddeck_color_gate_state();
|
||||
}
|
||||
|
||||
/* The session is ending: the "HDR on screen: ..." summary line. Any thread, once. */
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeHdrSessionEnd(JNIEnv *env, jclass clazz) {
|
||||
banner_color_session_end();
|
||||
droiddeck_color_session_end();
|
||||
}
|
||||
|
||||
/* HDR frames really on screen right now (the HUD badge). Any thread. */
|
||||
JNIEXPORT jboolean JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeHdrOnScreen(JNIEnv *env, jclass clazz) {
|
||||
return banner_color_hdr_on_screen() ? JNI_TRUE : JNI_FALSE;
|
||||
return droiddeck_color_hdr_on_screen() ? JNI_TRUE : JNI_FALSE;
|
||||
}
|
||||
|
||||
/* One "color" line in the session log from Java. */
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeLogColor(JNIEnv *env, jclass clazz, jstring message) {
|
||||
char *s = dup_jstr(env, message);
|
||||
if (s) banner_log("color", "%s", s);
|
||||
if (s) droiddeck_log("color", "%s", s);
|
||||
free(s);
|
||||
}
|
||||
|
||||
@@ -536,7 +536,7 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeLogColor(JNIEnv *env
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeLog(JNIEnv *env, jclass clazz, jstring area, jstring message) {
|
||||
char *a = dup_jstr(env, area), *s = dup_jstr(env, message);
|
||||
if (s) banner_log(a && a[0] ? a : "app", "%s", s);
|
||||
if (s) droiddeck_log(a && a[0] ? a : "app", "%s", s);
|
||||
free(a);
|
||||
free(s);
|
||||
}
|
||||
@@ -544,65 +544,65 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeLog(JNIEnv *env, jcl
|
||||
/* SDR content's level inside an HDR picture, in nits (default 203). */
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetHdrSdrWhite(JNIEnv *env, jclass clazz, jfloat nits) {
|
||||
banner_color_set_sdr_white((float)nits);
|
||||
droiddeck_color_set_sdr_white((float)nits);
|
||||
}
|
||||
|
||||
/* The drawer's live HDR output switch: on = HDR frames as HDR, off = the same frames tone-mapped to SDR.
|
||||
* Applied on the compositor thread (logged there, with a redraw). */
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetHdrOutput(JNIEnv *env, jclass clazz, jboolean on) {
|
||||
if (banner_get_display()) banner_host_hdr_output(on ? 1 : 0);
|
||||
else banner_color_set_output(on ? 1 : 0); /* no compositor thread yet: nothing is drawing either */
|
||||
if (droiddeck_get_display()) droiddeck_host_hdr_output(on ? 1 : 0);
|
||||
else droiddeck_color_set_output(on ? 1 : 0); /* no compositor thread yet: nothing is drawing either */
|
||||
}
|
||||
|
||||
JNIEXPORT jboolean JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeHdrOutput(JNIEnv *env, jclass clazz) {
|
||||
return banner_color_output() ? JNI_TRUE : JNI_FALSE;
|
||||
return droiddeck_color_output() ? JNI_TRUE : JNI_FALSE;
|
||||
}
|
||||
|
||||
/* Device evidence for the HDR lines: thermal status + headroom, brightness + mode. */
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeHdrEnvSample(JNIEnv *env, jclass clazz, jint thermal,
|
||||
jfloat headroom, jint brightness, jint mode) {
|
||||
banner_color_env_sample((int)thermal, (float)headroom != (float)headroom ? -1.0f : (float)headroom,
|
||||
droiddeck_color_env_sample((int)thermal, (float)headroom != (float)headroom ? -1.0f : (float)headroom,
|
||||
(int)brightness, (int)mode);
|
||||
}
|
||||
|
||||
/* Display.getHighestHdrSdrRatio() (Android 16+), <= 0 = not reported. */
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetHdrHighestRatio(JNIEnv *env, jclass clazz, jfloat ratio) {
|
||||
banner_color_set_highest_ratio((float)ratio == (float)ratio ? (float)ratio : -1.0f);
|
||||
droiddeck_color_set_highest_ratio((float)ratio == (float)ratio ? (float)ratio : -1.0f);
|
||||
}
|
||||
|
||||
/* The HDR headroom the screen surface should ask for (HDR10 swapchain frames in the last 1.5 s), 0 = none. */
|
||||
JNIEXPORT jfloat JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeHdrScreenHeadroom(JNIEnv *env, jclass clazz) {
|
||||
return (jfloat)banner_color_screen_headroom(NULL, 0);
|
||||
return (jfloat)droiddeck_color_screen_headroom(NULL, 0);
|
||||
}
|
||||
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeHdrScreenHeadroomWhy(JNIEnv *env, jclass clazz) {
|
||||
char why[200];
|
||||
banner_color_screen_headroom(why, sizeof(why));
|
||||
droiddeck_color_screen_headroom(why, sizeof(why));
|
||||
return (*env)->NewStringUTF(env, why);
|
||||
}
|
||||
|
||||
/* The app's screen-surface request, for the no-headroom lines: > 0 asked, 0 cleared, -1 not possible. */
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeHdrNoteHeadroomRequest(JNIEnv *env, jclass clazz, jfloat ratio) {
|
||||
banner_color_note_headroom_request((float)ratio);
|
||||
droiddeck_color_note_headroom_request((float)ratio);
|
||||
}
|
||||
|
||||
/* 0 none, 1 HDR frames on screen with headroom, 2 HDR frames on screen without headroom for 5 s+. */
|
||||
JNIEXPORT jint JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeHdrState(JNIEnv *env, jclass clazz) {
|
||||
return (jint)banner_color_hdr_state();
|
||||
return (jint)droiddeck_color_hdr_state();
|
||||
}
|
||||
|
||||
/* An HDR game's frames were shown tone-mapped to SDR in the last 1.5 s (the drawer's status line). */
|
||||
JNIEXPORT jboolean JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeHdrToneMappedOnScreen(JNIEnv *env, jclass clazz) {
|
||||
return banner_color_tonemapped_on_screen() ? JNI_TRUE : JNI_FALSE;
|
||||
return droiddeck_color_tonemapped_on_screen() ? JNI_TRUE : JNI_FALSE;
|
||||
}
|
||||
|
||||
/* The Look the controls currently match (null = Custom) - only named in the session log. */
|
||||
@@ -651,7 +651,7 @@ JNIEXPORT void JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetClipboardText(JNIEnv *env, jclass clazz, jbyteArray utf8) {
|
||||
int len;
|
||||
char *buf = dup_bytes(env, utf8, &len);
|
||||
banner_host_clipboard_text(buf ? buf : "", len);
|
||||
droiddeck_host_clipboard_text(buf ? buf : "", len);
|
||||
free(buf);
|
||||
}
|
||||
|
||||
@@ -660,7 +660,7 @@ JNIEXPORT void JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeTextInputCommit(JNIEnv *env, jclass clazz, jbyteArray utf8) {
|
||||
int len;
|
||||
char *buf = dup_bytes(env, utf8, &len);
|
||||
if (buf && len) banner_host_text_commit(buf, len);
|
||||
if (buf && len) droiddeck_host_text_commit(buf, len);
|
||||
free(buf);
|
||||
}
|
||||
|
||||
@@ -670,14 +670,14 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeTextInputPreedit(JNI
|
||||
jint cursorBegin, jint cursorEnd) {
|
||||
int len;
|
||||
char *buf = dup_bytes(env, utf8, &len);
|
||||
banner_host_text_preedit(buf ? buf : "", len, cursorBegin, cursorEnd);
|
||||
droiddeck_host_text_preedit(buf ? buf : "", len, cursorBegin, cursorEnd);
|
||||
free(buf);
|
||||
}
|
||||
|
||||
/* The IME deleted characters around the caret. */
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeTextInputDelete(JNIEnv *env, jclass clazz, jint before, jint after) {
|
||||
banner_host_text_delete(before, after);
|
||||
droiddeck_host_text_delete(before, after);
|
||||
}
|
||||
|
||||
/* KGSL's power control, off (the GPU held at its top clock) or back on. The property is the
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
*
|
||||
* Guest → Android: when a program sets a selection with a text mime, the compositor asks for it
|
||||
* once over a pipe on the event loop (non-blocking, 1 MiB cap, 3 s timeout) and hands the text
|
||||
* to the app (banner_on_clipboard_text → ClipboardManager). Android → guest: the app's text
|
||||
* to the app (droiddeck_on_clipboard_text → ClipboardManager). Android → guest: the app's text
|
||||
* becomes the HOST selection; receive requests are answered with a non-blocking writer.
|
||||
*/
|
||||
#define _GNU_SOURCE 1
|
||||
@@ -25,7 +25,7 @@
|
||||
#include <unistd.h>
|
||||
#include <fcntl.h>
|
||||
#include <errno.h>
|
||||
#include "banner_ext.h"
|
||||
#include "droiddeck_ext.h"
|
||||
#include "wlr-data-control-unstable-v1-server-protocol.h"
|
||||
|
||||
#define CLIP_MAX_BYTES (1024 * 1024)
|
||||
@@ -130,7 +130,7 @@ static int on_writer_writable(int fd, uint32_t mask, void *data) {
|
||||
|
||||
static int on_writer_timeout(void *data) {
|
||||
struct writer *w = data;
|
||||
banner_log("clipboard", "a program never read the clipboard text it asked for; giving up");
|
||||
droiddeck_log("clipboard", "a program never read the clipboard text it asked for; giving up");
|
||||
writer_free(w);
|
||||
return 0;
|
||||
}
|
||||
@@ -179,11 +179,11 @@ static void reader_finish(struct reader *r) {
|
||||
free(g_last_guest_text);
|
||||
g_last_guest_text = copy;
|
||||
g_last_guest_len = r->len;
|
||||
banner_log("clipboard", "guest copied %zu bytes (%s)", r->len, r->mime);
|
||||
banner_on_clipboard_text(copy, (int)r->len);
|
||||
droiddeck_log("clipboard", "guest copied %zu bytes (%s)", r->len, r->mime);
|
||||
droiddeck_on_clipboard_text(copy, (int)r->len);
|
||||
}
|
||||
} else {
|
||||
banner_log("clipboard", "guest copied an empty text; Android clipboard left alone");
|
||||
droiddeck_log("clipboard", "guest copied an empty text; Android clipboard left alone");
|
||||
}
|
||||
reader_free(r);
|
||||
}
|
||||
@@ -192,7 +192,7 @@ static int on_reader_readable(int fd, uint32_t mask, void *data) {
|
||||
struct reader *r = data;
|
||||
for (;;) {
|
||||
if (r->len >= CLIP_MAX_BYTES) {
|
||||
banner_log("clipboard", "guest text exceeds %d bytes; dropped", CLIP_MAX_BYTES);
|
||||
droiddeck_log("clipboard", "guest text exceeds %d bytes; dropped", CLIP_MAX_BYTES);
|
||||
reader_free(r);
|
||||
return 0;
|
||||
}
|
||||
@@ -215,7 +215,7 @@ static int on_reader_readable(int fd, uint32_t mask, void *data) {
|
||||
|
||||
static int on_reader_timeout(void *data) {
|
||||
struct reader *r = data;
|
||||
banner_log("clipboard", "timed out reading the guest's clipboard text (%zu bytes so far)", r->len);
|
||||
droiddeck_log("clipboard", "timed out reading the guest's clipboard text (%zu bytes so far)", r->len);
|
||||
reader_free(r);
|
||||
return 0;
|
||||
}
|
||||
@@ -251,7 +251,7 @@ static void offer_receive(struct wl_client *c, struct wl_resource *r, const char
|
||||
if (!o || o->gen != g_gen) { close(fd); return; } /* stale offer */
|
||||
if (g_owner == OWNER_HOST) {
|
||||
if (is_text_mime(mime)) {
|
||||
banner_log("clipboard", "Android clipboard → guest (%zu bytes) for %s", g_host_len, banner_client_name(c));
|
||||
droiddeck_log("clipboard", "Android clipboard → guest (%zu bytes) for %s", g_host_len, droiddeck_client_name(c));
|
||||
writer_start(fd, g_host_text, g_host_len);
|
||||
} else {
|
||||
close(fd);
|
||||
@@ -327,7 +327,7 @@ static void broadcast_selection(void) {
|
||||
wl_display_flush_clients(g_disp);
|
||||
}
|
||||
|
||||
void banner_clipboard_keyboard_focus(struct wl_client *client) {
|
||||
void droiddeck_clipboard_keyboard_focus(struct wl_client *client) {
|
||||
struct device *d;
|
||||
if (client == g_kb_client) return;
|
||||
g_kb_client = client;
|
||||
@@ -345,7 +345,7 @@ static void source_res_destroy(struct wl_resource *r) {
|
||||
g_src = NULL;
|
||||
g_owner = OWNER_NONE;
|
||||
if (g_reader) reader_free(g_reader);
|
||||
banner_log("clipboard", "%s withdrew its clipboard contents", banner_client_name(wl_resource_get_client(r)));
|
||||
droiddeck_log("clipboard", "%s withdrew its clipboard contents", droiddeck_client_name(wl_resource_get_client(r)));
|
||||
broadcast_selection();
|
||||
}
|
||||
for (int i = 0; i < s->nmimes; i++) free(s->mimes[i]);
|
||||
@@ -396,9 +396,9 @@ static void set_selection(struct wl_client *c, struct wl_resource *source_res) {
|
||||
if (*list) strncat(list, ", ", sizeof(list) - strlen(list) - 1);
|
||||
strncat(list, s->mimes[i], sizeof(list) - strlen(list) - 1);
|
||||
}
|
||||
banner_log("clipboard", "selection set by %s (%s)", banner_client_name(c), *list ? list : "no mime types");
|
||||
droiddeck_log("clipboard", "selection set by %s (%s)", droiddeck_client_name(c), *list ? list : "no mime types");
|
||||
} else {
|
||||
banner_log("clipboard", "%s cleared the clipboard", banner_client_name(c));
|
||||
droiddeck_log("clipboard", "%s cleared the clipboard", droiddeck_client_name(c));
|
||||
}
|
||||
broadcast_selection();
|
||||
read_client_text();
|
||||
@@ -415,7 +415,7 @@ static void device_res_destroy(struct wl_resource *r) {
|
||||
|
||||
static void device_start_drag(struct wl_client *c, struct wl_resource *r, struct wl_resource *source,
|
||||
struct wl_resource *origin, struct wl_resource *icon, uint32_t serial) {
|
||||
banner_log("clipboard", "%s started a drag and drop; not supported, cancelled", banner_client_name(c));
|
||||
droiddeck_log("clipboard", "%s started a drag and drop; not supported, cancelled", droiddeck_client_name(c));
|
||||
if (source) {
|
||||
struct source *s = wl_resource_get_user_data(source);
|
||||
if (s && s != g_src) source_send_cancelled(s);
|
||||
@@ -512,7 +512,7 @@ void clipboard_host_text(char *utf8, size_t len) {
|
||||
if (!len) free(utf8);
|
||||
g_host_len = len;
|
||||
g_owner = len ? OWNER_HOST : OWNER_NONE;
|
||||
banner_log("clipboard", len ? "Android clipboard → guest (%zu bytes)" : "Android clipboard cleared (%zu bytes)", len);
|
||||
droiddeck_log("clipboard", len ? "Android clipboard → guest (%zu bytes)" : "Android clipboard cleared (%zu bytes)", len);
|
||||
broadcast_selection();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/*
|
||||
* wp_color_manager_v1 (wayland-protocols staging color-management-v1, version 1) - the subset Mesa's
|
||||
* Wayland WSI binds - plus the HDR gate, the per-surface image descriptions and the session log's
|
||||
* HDR evidence. See banner_color.h for the design and HDR_RECON.md for why it is shaped this way.
|
||||
* HDR evidence. See droiddeck_color.h for the design and HDR_RECON.md for why it is shaped this way.
|
||||
*
|
||||
* Strictness policy. A protocol error disconnects the client, and the client here is a GAME, so
|
||||
* errors are only raised where the protocol leaves no choice and a conforming client can never hit
|
||||
@@ -12,8 +12,8 @@
|
||||
* for the same surface replaces the first, a request on an inert object is ignored.
|
||||
*/
|
||||
#define _GNU_SOURCE
|
||||
#include "banner_color.h"
|
||||
#include "banner_ext.h"
|
||||
#include "droiddeck_color.h"
|
||||
#include "droiddeck_ext.h"
|
||||
#include "ahb_swapchain.h"
|
||||
#include "sc_layer.h"
|
||||
#include "color-management-v1-server-protocol.h"
|
||||
@@ -30,7 +30,7 @@ extern volatile int g_zero_copy;
|
||||
#define TAG "color"
|
||||
|
||||
/* AHARDWAREBUFFER_FORMAT_* (android/hardware_buffer.h), for the log only. */
|
||||
const char *banner_ahb_format_name(uint32_t f) {
|
||||
const char *droiddeck_ahb_format_name(uint32_t f) {
|
||||
static char other[32];
|
||||
switch (f) {
|
||||
case 1: return "RGBA8888 (8-bit)";
|
||||
@@ -73,9 +73,9 @@ static const char *tf_name(uint32_t tf) {
|
||||
|
||||
static const char *dataspace_name(int32_t ds) {
|
||||
switch (ds) {
|
||||
case BANNER_ADATASPACE_BT2020_PQ: return "BT2020_PQ";
|
||||
case BANNER_ADATASPACE_BT2020_HLG: return "BT2020_HLG";
|
||||
case BANNER_ADATASPACE_UNKNOWN: return "UNKNOWN (sRGB)";
|
||||
case DROIDDECK_ADATASPACE_BT2020_PQ: return "BT2020_PQ";
|
||||
case DROIDDECK_ADATASPACE_BT2020_HLG: return "BT2020_HLG";
|
||||
case DROIDDECK_ADATASPACE_UNKNOWN: return "UNKNOWN (sRGB)";
|
||||
default: return "other";
|
||||
}
|
||||
}
|
||||
@@ -99,7 +99,7 @@ static _Atomic int g_gate = -1; /* -1 undecided, 0 closed, 1 open */
|
||||
static char g_gate_why[320]; /* the closed gate's reason (for the summary) */
|
||||
static _Atomic int g_output_on = 1; /* the drawer's HDR output switch (per session, starts on) */
|
||||
|
||||
void banner_color_set_request(int mode, const char *source, int dxvk_hdr, int zero_copy_forced) {
|
||||
void droiddeck_color_set_request(int mode, const char *source, int dxvk_hdr, int zero_copy_forced) {
|
||||
pthread_mutex_lock(&g_mu);
|
||||
g_req.mode = mode < 0 ? 0 : mode > 2 ? 2 : mode;
|
||||
snprintf(g_req.source, sizeof(g_req.source), "%s", source ? source : "environment");
|
||||
@@ -109,7 +109,7 @@ void banner_color_set_request(int mode, const char *source, int dxvk_hdr, int ze
|
||||
atomic_store(&g_output_on, 1); /* the drawer's switch is per session and starts on */
|
||||
}
|
||||
|
||||
void banner_color_set_display(int id, const char *name, const char *formats, int hdr10, float max_lum,
|
||||
void droiddeck_color_set_display(int id, const char *name, const char *formats, int hdr10, float max_lum,
|
||||
float max_avg, float min_lum, int ratio_available, float ratio, int api) {
|
||||
pthread_mutex_lock(&g_mu);
|
||||
int was_known = g_req.display_known, was_hdr10 = g_req.hdr10, was_id = g_req.display_id;
|
||||
@@ -127,17 +127,17 @@ void banner_color_set_display(int id, const char *name, const char *formats, int
|
||||
* session is recorded, and what it means is said out loud. */
|
||||
if (was_known && atomic_load(&g_gate) >= 0 && (was_hdr10 != (hdr10 ? 1 : 0) || was_id != id)) {
|
||||
if (atomic_load(&g_gate) == 1 && !hdr10)
|
||||
banner_log(TAG, "the game is now on \"%s\" (display %d), which reports no HDR10: HDR frames stay tagged "
|
||||
droiddeck_log(TAG, "the game is now on \"%s\" (display %d), which reports no HDR10: HDR frames stay tagged "
|
||||
"BT2020_PQ and SurfaceFlinger tone-maps them for this display (the offer to games is fixed "
|
||||
"for the session)", name ? name : "?", id);
|
||||
else if (atomic_load(&g_gate) == 0 && hdr10)
|
||||
banner_log(TAG, "the game is now on \"%s\" (display %d), which reports HDR10 - HDR stays off for this "
|
||||
droiddeck_log(TAG, "the game is now on \"%s\" (display %d), which reports HDR10 - HDR stays off for this "
|
||||
"session (the gate is decided when the compositor starts); relaunch to use it", name ? name : "?", id);
|
||||
}
|
||||
}
|
||||
|
||||
int banner_color_gate_state(void) { return atomic_load(&g_gate); }
|
||||
int banner_color_hdr_open(void) { return atomic_load(&g_gate) == 1; }
|
||||
int droiddeck_color_gate_state(void) { return atomic_load(&g_gate); }
|
||||
int droiddeck_color_hdr_open(void) { return atomic_load(&g_gate) == 1; }
|
||||
|
||||
/* ---------------------------------------------------------------- HDR evidence (summary) */
|
||||
|
||||
@@ -170,7 +170,7 @@ static struct {
|
||||
static _Atomic int64_t g_last_frame_ns;
|
||||
static _Atomic int64_t g_last_tm_ns; /* the last HDR frame shown tone-mapped to SDR */
|
||||
|
||||
/* What the device says beside the headroom (the app, non-root APIs; banner_color_env_*). The Fold showed
|
||||
/* What the device says beside the headroom (the app, non-root APIs; droiddeck_color_env_*). The Fold showed
|
||||
* three ways to lose HDR headroom with HDR frames on screen - a screen recording, heat under load, and
|
||||
* (plausibly) the brightness slider at maximum - so every no-headroom line carries this. Under g_mu. */
|
||||
static struct {
|
||||
@@ -241,20 +241,20 @@ static _Atomic int g_screen_hr_x1000; /* the screen surface's wanted headr
|
||||
static _Atomic int64_t g_screen_hr_ns; /* the last HDR10-swapchain frame */
|
||||
static char g_screen_hr_why[200]; /* under g_mu */
|
||||
|
||||
static float peak_of(const struct banner_color *c, float disp_max, const char **src) {
|
||||
static float peak_of(const struct droiddeck_color *c, float disp_max, const char **src) {
|
||||
if (c->max_cll > 0.0f) { *src = "max CLL"; return c->max_cll; }
|
||||
if (c->has_st2086 && c->max_lum > 0.0f) { *src = "mastering max"; return c->max_lum; }
|
||||
if (disp_max > 0.0f) { *src = "the display's peak"; return disp_max; }
|
||||
*src = "assumed"; return 1000.0f;
|
||||
}
|
||||
|
||||
float banner_color_desired_headroom(const struct banner_color *c, char *why, size_t n) {
|
||||
float droiddeck_color_desired_headroom(const struct droiddeck_color *c, char *why, size_t n) {
|
||||
if (!c || !c->dataspace) { if (why && n) why[0] = 0; return 0.0f; }
|
||||
pthread_mutex_lock(&g_mu);
|
||||
const float disp_max = g_req.max_lum, highest = g_req.highest_ratio;
|
||||
pthread_mutex_unlock(&g_mu);
|
||||
const char *src;
|
||||
const float peak = peak_of(c, disp_max, &src), white = banner_color_sdr_white();
|
||||
const float peak = peak_of(c, disp_max, &src), white = droiddeck_color_sdr_white();
|
||||
if (highest > 0.0f && highest <= 1.01f) {
|
||||
if (why && n) snprintf(why, n, "the display reports its highest HDR/SDR ratio as %.2f: no app can get a "
|
||||
"boost here, so nothing is asked", highest);
|
||||
@@ -273,24 +273,24 @@ float banner_color_desired_headroom(const struct banner_color *c, char *why, siz
|
||||
return r;
|
||||
}
|
||||
|
||||
void banner_color_note_headroom_request(float ratio) {
|
||||
void droiddeck_color_note_headroom_request(float ratio) {
|
||||
pthread_mutex_lock(&g_mu);
|
||||
g_env.requested = ratio;
|
||||
pthread_mutex_unlock(&g_mu);
|
||||
}
|
||||
|
||||
void banner_color_set_highest_ratio(float ratio) {
|
||||
void droiddeck_color_set_highest_ratio(float ratio) {
|
||||
pthread_mutex_lock(&g_mu);
|
||||
const float was = g_req.highest_ratio;
|
||||
g_req.highest_ratio = ratio > 0.0f ? ratio : -1.0f;
|
||||
pthread_mutex_unlock(&g_mu);
|
||||
if (ratio > 0.0f && (was < 0.0f || was != ratio) && atomic_load(&g_gate) == 1)
|
||||
banner_log(TAG, "the display's highest HDR/SDR ratio is %.2f%s", ratio,
|
||||
droiddeck_log(TAG, "the display's highest HDR/SDR ratio is %.2f%s", ratio,
|
||||
ratio <= 1.01f ? " - it reports no HDR boost at all: no app can raise HDR highlights above SDR white here"
|
||||
: " (the most HDR headroom it can give)");
|
||||
}
|
||||
|
||||
float banner_color_screen_headroom(char *why, size_t n) {
|
||||
float droiddeck_color_screen_headroom(char *why, size_t n) {
|
||||
const int64_t t = atomic_load(&g_screen_hr_ns);
|
||||
if (!t || now_ns() - t > 1500000000LL) { if (why && n) why[0] = 0; return 0.0f; }
|
||||
if (why && n) {
|
||||
@@ -301,7 +301,7 @@ float banner_color_screen_headroom(char *why, size_t n) {
|
||||
return atomic_load(&g_screen_hr_x1000) / 1000.0f;
|
||||
}
|
||||
|
||||
int banner_color_last_frame_age_ms(void) {
|
||||
int droiddeck_color_last_frame_age_ms(void) {
|
||||
int64_t t = atomic_load(&g_last_frame_ns);
|
||||
if (!t) return -1;
|
||||
int64_t age = (now_ns() - t) / 1000000LL;
|
||||
@@ -408,34 +408,34 @@ static void log_verdict(int force) {
|
||||
int changed = strcmp(v, g_hdr.verdict) != 0;
|
||||
if (changed) snprintf(g_hdr.verdict, sizeof(g_hdr.verdict), "%s", v);
|
||||
pthread_mutex_unlock(&g_mu);
|
||||
if (changed || force) banner_log(TAG, "HDR on screen: %s", v);
|
||||
if (changed || force) droiddeck_log(TAG, "HDR on screen: %s", v);
|
||||
}
|
||||
|
||||
void banner_color_frame_shown(const struct banner_color *c, int path, uint32_t ahb_format) {
|
||||
void droiddeck_color_frame_shown(const struct droiddeck_color *c, int path, uint32_t ahb_format) {
|
||||
if (!c || !c->dataspace) return;
|
||||
int first8 = 0;
|
||||
pthread_mutex_lock(&g_mu);
|
||||
switch (path) {
|
||||
case BANNER_HDR_ZERO_COPY:
|
||||
case DROIDDECK_HDR_ZERO_COPY:
|
||||
if (ahb_format == 0x2b) g_hdr.layer_10bit++;
|
||||
g_hdr.win_zc++;
|
||||
snprintf(g_hdr.layer_fmt, sizeof(g_hdr.layer_fmt), "%s", banner_ahb_format_name(ahb_format));
|
||||
snprintf(g_hdr.layer_fmt, sizeof(g_hdr.layer_fmt), "%s", droiddeck_ahb_format_name(ahb_format));
|
||||
break;
|
||||
case BANNER_HDR_LAYER_COPY:
|
||||
case DROIDDECK_HDR_LAYER_COPY:
|
||||
if (!g_hdr.layer_copy8) first8 = 1;
|
||||
g_hdr.layer_copy8++; g_hdr.win_copy8++;
|
||||
snprintf(g_hdr.layer_fmt, sizeof(g_hdr.layer_fmt), "RGBA8888 layer copy");
|
||||
break;
|
||||
case BANNER_HDR_COMPOSED:
|
||||
case DROIDDECK_HDR_COMPOSED:
|
||||
g_hdr.composed++; g_hdr.win_composed++;
|
||||
snprintf(g_hdr.layer_fmt, sizeof(g_hdr.layer_fmt), "composed %s", banner_ahb_format_name(ahb_format));
|
||||
snprintf(g_hdr.layer_fmt, sizeof(g_hdr.layer_fmt), "composed %s", droiddeck_ahb_format_name(ahb_format));
|
||||
break;
|
||||
case BANNER_HDR_SWAPCHAIN:
|
||||
case DROIDDECK_HDR_SWAPCHAIN:
|
||||
g_hdr.swapchain++; g_hdr.win_swapchain++;
|
||||
snprintf(g_hdr.layer_fmt, sizeof(g_hdr.layer_fmt), "HDR10 swapchain");
|
||||
atomic_store(&g_screen_hr_ns, now_ns());
|
||||
break;
|
||||
default: /* BANNER_HDR_TONEMAPPED: shown, but not as HDR */
|
||||
default: /* DROIDDECK_HDR_TONEMAPPED: shown, but not as HDR */
|
||||
g_hdr.tonemapped++; g_hdr.win_tonemapped++;
|
||||
if (!atomic_load(&g_output_on)) g_hdr.tm_off++;
|
||||
pthread_mutex_unlock(&g_mu);
|
||||
@@ -446,7 +446,7 @@ void banner_color_frame_shown(const struct banner_color *c, int path, uint32_t a
|
||||
g_hdr.win_layer++;
|
||||
pthread_mutex_unlock(&g_mu);
|
||||
atomic_store(&g_last_frame_ns, now_ns());
|
||||
if (path == BANNER_HDR_SWAPCHAIN) {
|
||||
if (path == DROIDDECK_HDR_SWAPCHAIN) {
|
||||
/* The screen surface's headroom request (the app applies it): worked out again when the
|
||||
* description changes, else once a second. */
|
||||
static uint32_t ident;
|
||||
@@ -454,7 +454,7 @@ void banner_color_frame_shown(const struct banner_color *c, int path, uint32_t a
|
||||
const int64_t t = now_ns();
|
||||
if (c->identity != ident || t - calc_ns > 1000000000LL) {
|
||||
char why[200];
|
||||
const float r = banner_color_desired_headroom(c, why, sizeof(why));
|
||||
const float r = droiddeck_color_desired_headroom(c, why, sizeof(why));
|
||||
ident = c->identity; calc_ns = t;
|
||||
pthread_mutex_lock(&g_mu);
|
||||
snprintf(g_screen_hr_why, sizeof(g_screen_hr_why), "%s", why);
|
||||
@@ -463,12 +463,12 @@ void banner_color_frame_shown(const struct banner_color *c, int path, uint32_t a
|
||||
}
|
||||
}
|
||||
if (first8)
|
||||
banner_log(TAG, "HDR frames are reaching the display layer through the compositor's 8-bit layer copy (the game's "
|
||||
droiddeck_log(TAG, "HDR frames are reaching the display layer through the compositor's 8-bit layer copy (the game's "
|
||||
"buffer is not a gralloc buffer this frame): colours stay correct (the frame keeps its BT2020_PQ tag), "
|
||||
"precision drops to 8 bits (banding possible)");
|
||||
}
|
||||
|
||||
void banner_color_frame_copied(const char *who, const char *reason) {
|
||||
void droiddeck_color_frame_copied(const char *who, const char *reason) {
|
||||
int first = 0;
|
||||
pthread_mutex_lock(&g_mu);
|
||||
g_hdr.copy_frames++;
|
||||
@@ -479,14 +479,14 @@ void banner_color_frame_copied(const char *who, const char *reason) {
|
||||
}
|
||||
pthread_mutex_unlock(&g_mu);
|
||||
if (first)
|
||||
banner_log(TAG, "HDR frames of %s go through the compositor's 8-bit SDR copy now, because %s: shown WITHOUT "
|
||||
droiddeck_log(TAG, "HDR frames of %s go through the compositor's 8-bit SDR copy now, because %s: shown WITHOUT "
|
||||
"tone mapping (washed out) until that changes", who ? who : "a window", reason ? reason : "?");
|
||||
}
|
||||
|
||||
void banner_color_ratio_sample(float ratio, int listener) {
|
||||
void droiddeck_color_ratio_sample(float ratio, int listener) {
|
||||
if (atomic_load(&g_gate) != 1) return;
|
||||
int64_t t = now_ns();
|
||||
int age = banner_color_last_frame_age_ms();
|
||||
int age = droiddeck_color_last_frame_age_ms();
|
||||
int live = age >= 0 && age < 1500;
|
||||
int log_it = 0, periodic = 0, nohead_now = 0, head_back = 0;
|
||||
float was;
|
||||
@@ -536,28 +536,28 @@ void banner_color_ratio_sample(float ratio, int listener) {
|
||||
}
|
||||
pthread_mutex_unlock(&g_mu);
|
||||
if (nohead_now)
|
||||
banner_log(TAG, "no HDR headroom for 5 s while HDR frames are on screen (display HDR/SDR ratio %.2f): %s - Android "
|
||||
droiddeck_log(TAG, "no HDR headroom for 5 s while HDR frames are on screen (display HDR/SDR ratio %.2f): %s - Android "
|
||||
"drops HDR headroom while the screen is recorded and when the device is hot, and some phones never "
|
||||
"boost HDR for apps; HDR highlights look no brighter until that ends [%s]", ratio, causes, env);
|
||||
if (head_back)
|
||||
banner_log(TAG, "HDR headroom is back: display HDR/SDR ratio %.2f with HDR frames on screen", ratio);
|
||||
droiddeck_log(TAG, "HDR headroom is back: display HDR/SDR ratio %.2f with HDR frames on screen", ratio);
|
||||
if (!log_it) return;
|
||||
char when[64];
|
||||
if (age < 0) snprintf(when, sizeof(when), "none yet this session");
|
||||
else snprintf(when, sizeof(when), "%s, last one %d ms ago", live ? "yes" : "no", age);
|
||||
if (periodic)
|
||||
banner_log(TAG, "display HDR/SDR ratio %.2f (steady; HDR frames on screen: %s)%s%s%s%s%s", ratio, when,
|
||||
droiddeck_log(TAG, "display HDR/SDR ratio %.2f (steady; HDR frames on screen: %s)%s%s%s%s%s", ratio, when,
|
||||
live && ratio <= 1.01f ? " - no HDR headroom: " : "", live && ratio <= 1.01f ? causes : "",
|
||||
env[0] ? " [" : "", env, env[0] ? "]" : "");
|
||||
else if (was > 0.0f)
|
||||
banner_log(TAG, "display HDR/SDR ratio %.2f (was %.2f; HDR frames on screen: %s) [%s]", ratio, was, when,
|
||||
droiddeck_log(TAG, "display HDR/SDR ratio %.2f (was %.2f; HDR frames on screen: %s) [%s]", ratio, was, when,
|
||||
listener ? "display listener" : "sampler");
|
||||
else
|
||||
banner_log(TAG, "display HDR/SDR ratio %.2f (first reading; HDR frames on screen: %s) [%s]", ratio, when,
|
||||
droiddeck_log(TAG, "display HDR/SDR ratio %.2f (first reading; HDR frames on screen: %s) [%s]", ratio, when,
|
||||
listener ? "display listener" : "sampler");
|
||||
}
|
||||
|
||||
void banner_color_stats_tick(void) {
|
||||
void droiddeck_color_stats_tick(void) {
|
||||
if (atomic_load(&g_gate) != 1) return;
|
||||
char line[512];
|
||||
int any;
|
||||
@@ -582,13 +582,13 @@ void banner_color_stats_tick(void) {
|
||||
g_hdr.win_zc = g_hdr.win_composed = g_hdr.win_swapchain = g_hdr.win_tonemapped = 0;
|
||||
g_hdr.win_ratio_n = 0;
|
||||
pthread_mutex_unlock(&g_mu);
|
||||
if (any) banner_log(TAG, "%s", line);
|
||||
if (any) droiddeck_log(TAG, "%s", line);
|
||||
log_verdict(0);
|
||||
}
|
||||
|
||||
int banner_color_hdr_state(void) {
|
||||
int droiddeck_color_hdr_state(void) {
|
||||
if (atomic_load(&g_gate) != 1) return 0;
|
||||
int age = banner_color_last_frame_age_ms();
|
||||
int age = droiddeck_color_last_frame_age_ms();
|
||||
if (age < 0 || age >= 1500) return 0;
|
||||
pthread_mutex_lock(&g_mu);
|
||||
int confirmed = !g_hdr.ratio_n || g_hdr.ratio_last > 1.01f; /* no ratio on this display: the tag is all there is */
|
||||
@@ -597,26 +597,26 @@ int banner_color_hdr_state(void) {
|
||||
return confirmed ? 1 : nohead ? 2 : 0;
|
||||
}
|
||||
|
||||
int banner_color_hdr_on_screen(void) { return banner_color_hdr_state() == 1; }
|
||||
int droiddeck_color_hdr_on_screen(void) { return droiddeck_color_hdr_state() == 1; }
|
||||
|
||||
static _Atomic int g_sdr_white_x100 = 20300;
|
||||
void banner_color_set_sdr_white(float nits) {
|
||||
void droiddeck_color_set_sdr_white(float nits) {
|
||||
if (!(nits >= 10.0f && nits <= 2000.0f)) return;
|
||||
atomic_store(&g_sdr_white_x100, (int)(nits * 100.0f + 0.5f));
|
||||
banner_log(TAG, "SDR content inside an HDR picture is placed at %.0f nits (BANNER_WAYLAND_HDR_SDR_NITS)", nits);
|
||||
droiddeck_log(TAG, "SDR content inside an HDR picture is placed at %.0f nits (DROIDDECK_WAYLAND_HDR_SDR_NITS)", nits);
|
||||
}
|
||||
float banner_color_sdr_white(void) { return atomic_load(&g_sdr_white_x100) / 100.0f; }
|
||||
float droiddeck_color_sdr_white(void) { return atomic_load(&g_sdr_white_x100) / 100.0f; }
|
||||
|
||||
int banner_color_requested(void) {
|
||||
int droiddeck_color_requested(void) {
|
||||
pthread_mutex_lock(&g_mu);
|
||||
int m = g_req.mode;
|
||||
pthread_mutex_unlock(&g_mu);
|
||||
return m != 0;
|
||||
}
|
||||
|
||||
int banner_color_output(void) { return atomic_load(&g_output_on); }
|
||||
int droiddeck_color_output(void) { return atomic_load(&g_output_on); }
|
||||
|
||||
void banner_color_env_sample(int thermal, float headroom, int brightness, int bmode) {
|
||||
void droiddeck_color_env_sample(int thermal, float headroom, int brightness, int bmode) {
|
||||
if (atomic_load(&g_gate) != 1) return; /* evidence for the HDR lines only */
|
||||
char msg[2][200];
|
||||
int nmsg = 0;
|
||||
@@ -644,42 +644,42 @@ void banner_color_env_sample(int thermal, float headroom, int brightness, int bm
|
||||
"brightness changes are logged as they happen)", e);
|
||||
}
|
||||
pthread_mutex_unlock(&g_mu);
|
||||
for (int i = 0; i < nmsg; i++) banner_log(TAG, "%s", msg[i]);
|
||||
for (int i = 0; i < nmsg; i++) droiddeck_log(TAG, "%s", msg[i]);
|
||||
}
|
||||
|
||||
int banner_color_tonemapped_on_screen(void) {
|
||||
int droiddeck_color_tonemapped_on_screen(void) {
|
||||
if (atomic_load(&g_gate) != 1) return 0;
|
||||
int64_t t = atomic_load(&g_last_tm_ns);
|
||||
return t && now_ns() - t < 1500000000LL;
|
||||
}
|
||||
|
||||
void banner_color_set_output(int on) {
|
||||
void droiddeck_color_set_output(int on) {
|
||||
on = on ? 1 : 0;
|
||||
if (atomic_load(&g_gate) != 1) {
|
||||
/* The drawer only shows the switch in sessions whose gate is open; say so if it ever gets here. */
|
||||
banner_log(TAG, "HDR output switch (%s) ignored: HDR is not open in this session", on ? "on" : "off");
|
||||
droiddeck_log(TAG, "HDR output switch (%s) ignored: HDR is not open in this session", on ? "on" : "off");
|
||||
return;
|
||||
}
|
||||
if (atomic_exchange(&g_output_on, on) == on) return;
|
||||
char who[160];
|
||||
int age = banner_color_last_frame_age_ms(), tm = banner_color_tonemapped_on_screen();
|
||||
int age = droiddeck_color_last_frame_age_ms(), tm = droiddeck_color_tonemapped_on_screen();
|
||||
pthread_mutex_lock(&g_mu);
|
||||
if (!on) g_hdr.output_offs++;
|
||||
snprintf(who, sizeof(who), "%s", g_hdr.applied_who[0] ? g_hdr.applied_who : "no HDR program yet");
|
||||
pthread_mutex_unlock(&g_mu);
|
||||
if (on)
|
||||
banner_log(TAG, "HDR output switched ON in the drawer: HDR frames go to the display as HDR again from the next "
|
||||
droiddeck_log(TAG, "HDR output switched ON in the drawer: HDR frames go to the display as HDR again from the next "
|
||||
"frame (%s; %s)", who, tm ? "they were being tone-mapped to SDR until now" : "none were on screen just now");
|
||||
else
|
||||
banner_log(TAG, "HDR output switched OFF in the drawer: HDR frames are tone-mapped to SDR from the next frame "
|
||||
droiddeck_log(TAG, "HDR output switched OFF in the drawer: HDR frames are tone-mapped to SDR from the next frame "
|
||||
"(%s; %s). The game is not told - it keeps rendering HDR; its own HDR setting and DXVK_HDR are "
|
||||
"untouched", who, age >= 0 && age < 1500 ? "HDR frames were on screen" : "no HDR frames on screen just now");
|
||||
banner_request_redraw(); /* a paused game commits nothing: show the change now */
|
||||
droiddeck_request_redraw(); /* a paused game commits nothing: show the change now */
|
||||
}
|
||||
|
||||
static _Atomic int g_session_ended;
|
||||
|
||||
void banner_color_session_end(void) {
|
||||
void droiddeck_color_session_end(void) {
|
||||
if (atomic_exchange(&g_session_ended, 1)) return;
|
||||
if (atomic_load(&g_gate) < 0) return;
|
||||
pthread_mutex_lock(&g_mu);
|
||||
@@ -692,7 +692,7 @@ void banner_color_session_end(void) {
|
||||
|
||||
struct cm_desc {
|
||||
int refs; /* protocol objects + surfaces (pending / current) */
|
||||
struct banner_color c;
|
||||
struct droiddeck_color c;
|
||||
/* Set on the output / preferred descriptions the compositor serves (never on one a game built
|
||||
* from parameters): get_information is answered rather than refused, with these as the target. */
|
||||
int informative;
|
||||
@@ -727,7 +727,7 @@ static void image_description_get_information(struct wl_client *c, struct wl_res
|
||||
wl_resource_get_version(r), id);
|
||||
if (!info) { wl_client_post_no_memory(c); return; }
|
||||
wl_resource_set_implementation(info, NULL, NULL, NULL); /* events only */
|
||||
const struct banner_color *col = &d->c;
|
||||
const struct droiddeck_color *col = &d->c;
|
||||
int32_t xy[8] = { (int32_t)(col->red[0] * 1e6f + 0.5f), (int32_t)(col->red[1] * 1e6f + 0.5f),
|
||||
(int32_t)(col->green[0] * 1e6f + 0.5f), (int32_t)(col->green[1] * 1e6f + 0.5f),
|
||||
(int32_t)(col->blue[0] * 1e6f + 0.5f), (int32_t)(col->blue[1] * 1e6f + 0.5f),
|
||||
@@ -765,12 +765,12 @@ static void image_description_resource_destroy(struct wl_resource *r) {
|
||||
static void make_output_description(struct wl_client *c, struct wl_resource *parent, uint32_t id, const char *what) {
|
||||
struct cm_desc *d = calloc(1, sizeof(*d));
|
||||
if (!d) { wl_client_post_no_memory(c); return; }
|
||||
struct banner_color *col = &d->c;
|
||||
struct droiddeck_color *col = &d->c;
|
||||
col->identity = g_next_identity++;
|
||||
if (!g_next_identity) g_next_identity = 1;
|
||||
col->primaries = WP_COLOR_MANAGER_V1_PRIMARIES_BT2020;
|
||||
col->tf = WP_COLOR_MANAGER_V1_TRANSFER_FUNCTION_ST2084_PQ;
|
||||
col->dataspace = BANNER_ADATASPACE_BT2020_PQ;
|
||||
col->dataspace = DROIDDECK_ADATASPACE_BT2020_PQ;
|
||||
col->has_st2086 = 1;
|
||||
col->red[0] = 0.708f; col->red[1] = 0.292f; col->green[0] = 0.170f; col->green[1] = 0.797f;
|
||||
col->blue[0] = 0.131f; col->blue[1] = 0.046f; col->white[0] = 0.3127f; col->white[1] = 0.3290f;
|
||||
@@ -788,8 +788,8 @@ static void make_output_description(struct wl_client *c, struct wl_resource *par
|
||||
d->refs = 1;
|
||||
wl_resource_set_implementation(r, &image_description_impl, d, image_description_resource_destroy);
|
||||
wp_image_description_v1_send_ready(r, col->identity);
|
||||
banner_log(TAG, "%s asked for %s: answered the display's HDR10 volume (%s), ready and open to get_information",
|
||||
banner_client_name(c), what, col->text);
|
||||
droiddeck_log(TAG, "%s asked for %s: answered the display's HDR10 volume (%s), ready and open to get_information",
|
||||
droiddeck_client_name(c), what, col->text);
|
||||
}
|
||||
|
||||
static void params_create(struct wl_client *client, struct wl_resource *r, uint32_t id) {
|
||||
@@ -801,15 +801,15 @@ static void params_create(struct wl_client *client, struct wl_resource *r, uint3
|
||||
}
|
||||
struct cm_desc *d = calloc(1, sizeof(*d));
|
||||
if (!d) { wl_client_post_no_memory(client); return; }
|
||||
struct banner_color *c = &d->c;
|
||||
struct droiddeck_color *c = &d->c;
|
||||
c->identity = g_next_identity++;
|
||||
if (!g_next_identity) g_next_identity = 1;
|
||||
c->primaries = p->primaries;
|
||||
c->tf = p->tf;
|
||||
if (p->primaries == WP_COLOR_MANAGER_V1_PRIMARIES_BT2020 && p->tf == WP_COLOR_MANAGER_V1_TRANSFER_FUNCTION_ST2084_PQ)
|
||||
c->dataspace = BANNER_ADATASPACE_BT2020_PQ;
|
||||
c->dataspace = DROIDDECK_ADATASPACE_BT2020_PQ;
|
||||
else if (p->primaries == WP_COLOR_MANAGER_V1_PRIMARIES_BT2020 && p->tf == WP_COLOR_MANAGER_V1_TRANSFER_FUNCTION_HLG)
|
||||
c->dataspace = BANNER_ADATASPACE_BT2020_HLG;
|
||||
c->dataspace = DROIDDECK_ADATASPACE_BT2020_HLG;
|
||||
|
||||
/* HDR metadata, kept only when it makes sense. The PQ defaults stand in for what was not given
|
||||
* (0.005 - 10000 cd/m², the target volume = the primary one); CTA-861.3 treats 0 as unknown. */
|
||||
@@ -870,12 +870,12 @@ static void params_create(struct wl_client *client, struct wl_resource *r, uint3
|
||||
g_hdr.descs++;
|
||||
pthread_mutex_unlock(&g_mu);
|
||||
}
|
||||
banner_log(TAG, "image description #%u from %s: %s -> ready (on a display layer: dataspace %s)", c->identity,
|
||||
banner_client_name(client), c->text, dataspace_name(c->dataspace));
|
||||
droiddeck_log(TAG, "image description #%u from %s: %s -> ready (on a display layer: dataspace %s)", c->identity,
|
||||
droiddeck_client_name(client), c->text, dataspace_name(c->dataspace));
|
||||
if (dropped[0]) {
|
||||
size_t l = strlen(dropped);
|
||||
if (l && dropped[l - 1] == ',') dropped[l - 1] = 0;
|
||||
banner_log(TAG, "image description #%u: dropped HDR metadata that breaks the protocol's rules:%s "
|
||||
droiddeck_log(TAG, "image description #%u: dropped HDR metadata that breaks the protocol's rules:%s "
|
||||
"(Android gets the rest)", c->identity, dropped);
|
||||
}
|
||||
wl_resource_destroy(r); /* create is the creator's destructor */
|
||||
@@ -982,7 +982,7 @@ static struct cm_surf *surf_of(struct wl_resource *surface) {
|
||||
|
||||
static void describe_surface(struct wl_resource *surface, char *out, size_t size) {
|
||||
struct surface *s = surface ? wl_resource_get_user_data(surface) : NULL;
|
||||
if (s) banner_surface_describe(s, out, size);
|
||||
if (s) droiddeck_surface_describe(s, out, size);
|
||||
else snprintf(out, size, "a closed window");
|
||||
}
|
||||
|
||||
@@ -1002,8 +1002,8 @@ static void cm_surface_set_image_description(struct wl_client *c, struct wl_reso
|
||||
struct cm_surf *cs = wl_resource_get_user_data(r);
|
||||
struct cm_desc *d = desc_res ? wl_resource_get_user_data(desc_res) : NULL;
|
||||
if (!cs) {
|
||||
banner_log(TAG, "%s set an image description on a colour-management object whose surface is gone: ignored",
|
||||
banner_client_name(c));
|
||||
droiddeck_log(TAG, "%s set an image description on a colour-management object whose surface is gone: ignored",
|
||||
droiddeck_client_name(c));
|
||||
return;
|
||||
}
|
||||
if (!d) {
|
||||
@@ -1045,7 +1045,7 @@ static void cm_surface_resource_destroy(struct wl_resource *r) {
|
||||
cs->pending_dirty = 1;
|
||||
}
|
||||
|
||||
void banner_color_commit(struct wl_resource *surface) {
|
||||
void droiddeck_color_commit(struct wl_resource *surface) {
|
||||
if (atomic_load(&g_gate) != 1) return;
|
||||
struct cm_surf *cs = surf_of(surface);
|
||||
if (!cs || !cs->pending_dirty) return;
|
||||
@@ -1056,7 +1056,7 @@ void banner_color_commit(struct wl_resource *surface) {
|
||||
char who[160];
|
||||
describe_surface(surface, who, sizeof(who));
|
||||
if (cs->current) {
|
||||
banner_log(TAG, "%s: image description #%u now applies to its frames (%s; perceptual intent)", who,
|
||||
droiddeck_log(TAG, "%s: image description #%u now applies to its frames (%s; perceptual intent)", who,
|
||||
cs->current->c.identity, cs->current->c.text);
|
||||
if (cs->current->c.dataspace) {
|
||||
pthread_mutex_lock(&g_mu);
|
||||
@@ -1064,16 +1064,16 @@ void banner_color_commit(struct wl_resource *surface) {
|
||||
snprintf(g_hdr.applied_who, sizeof(g_hdr.applied_who), "%s", who);
|
||||
pthread_mutex_unlock(&g_mu);
|
||||
if (!g_zero_copy)
|
||||
banner_log(TAG, "%s: zero-copy presentation is OFF, so its HDR frames are composed into the HDR picture "
|
||||
droiddeck_log(TAG, "%s: zero-copy presentation is OFF, so its HDR frames are composed into the HDR picture "
|
||||
"(one extra pass per frame) - switch Zero-copy presentation on for the direct path", who);
|
||||
}
|
||||
} else if (old) {
|
||||
banner_log(TAG, "%s: image description #%u removed - its frames are sRGB again", who, old->c.identity);
|
||||
droiddeck_log(TAG, "%s: image description #%u removed - its frames are sRGB again", who, old->c.identity);
|
||||
}
|
||||
desc_unref(old);
|
||||
}
|
||||
|
||||
const struct banner_color *banner_color_of(struct wl_resource *surface) {
|
||||
const struct droiddeck_color *droiddeck_color_of(struct wl_resource *surface) {
|
||||
if (atomic_load(&g_gate) != 1) return NULL;
|
||||
struct cm_surf *cs = surf_of(surface);
|
||||
return cs && cs->current ? &cs->current->c : NULL;
|
||||
@@ -1125,14 +1125,14 @@ static void cm_get_surface(struct wl_client *c, struct wl_resource *r, uint32_t
|
||||
describe_surface(surface, who, sizeof(who));
|
||||
if (cs->owner) {
|
||||
/* The protocol says surface_exists; a game would be disconnected for it. The newer object wins. */
|
||||
banner_log(TAG, "%s: a second colour-management object for the same surface - the newer one takes over "
|
||||
droiddeck_log(TAG, "%s: a second colour-management object for the same surface - the newer one takes over "
|
||||
"(protocol error surface_exists not raised)", who);
|
||||
wl_resource_set_user_data(cs->owner, NULL);
|
||||
}
|
||||
cs->owner = o;
|
||||
wl_resource_set_implementation(o, &cm_surface_impl, cs, cm_surface_resource_destroy);
|
||||
banner_log(TAG, "%s: colour-management surface created by %s (its Vulkan swapchain asks for a colour space "
|
||||
"other than sRGB)", who, banner_client_name(c));
|
||||
droiddeck_log(TAG, "%s: colour-management surface created by %s (its Vulkan swapchain asks for a colour space "
|
||||
"other than sRGB)", who, droiddeck_client_name(c));
|
||||
}
|
||||
|
||||
static void cm_get_surface_feedback(struct wl_client *c, struct wl_resource *r, uint32_t id, struct wl_resource *surface) {
|
||||
@@ -1186,16 +1186,16 @@ static void bind_cm(struct wl_client *c, void *data, uint32_t ver, uint32_t id)
|
||||
/* One line per program: Mesa binds again for every surface-format query. */
|
||||
if (last_named != c) {
|
||||
last_named = c;
|
||||
banner_log(TAG, "%s bound wp_color_manager_v1 version %u (offered: perceptual intent; parametric descriptions "
|
||||
droiddeck_log(TAG, "%s bound wp_color_manager_v1 version %u (offered: perceptual intent; parametric descriptions "
|
||||
"with mastering metadata; BT.2020 primaries; ST 2084 PQ) - its Vulkan driver can now list "
|
||||
"VK_COLOR_SPACE_HDR10_ST2084_EXT", banner_client_name(c), ver > 1 ? 1u : ver);
|
||||
"VK_COLOR_SPACE_HDR10_ST2084_EXT", droiddeck_client_name(c), ver > 1 ? 1u : ver);
|
||||
}
|
||||
}
|
||||
|
||||
void banner_color_client_gone(struct wl_client *client) {
|
||||
void droiddeck_color_client_gone(struct wl_client *client) {
|
||||
if (atomic_load(&g_gate) != 1 || atomic_load(&g_session_ended)) return;
|
||||
/* A program that presented HDR has left: say where the session stands now, while it is fresh. */
|
||||
const char *name = banner_client_name(client);
|
||||
const char *name = droiddeck_client_name(client);
|
||||
int mine;
|
||||
pthread_mutex_lock(&g_mu);
|
||||
mine = g_hdr.applied_who[0] && name && strstr(g_hdr.applied_who, name) != NULL;
|
||||
@@ -1205,7 +1205,7 @@ void banner_color_client_gone(struct wl_client *client) {
|
||||
|
||||
/* ---------------------------------------------------------------- the gate */
|
||||
|
||||
void banner_color_init(struct wl_display *display) {
|
||||
void droiddeck_color_init(struct wl_display *display) {
|
||||
char why[320] = "";
|
||||
int mode, dxvk_hdr, zc_forced, known, hdr10, id, ratio_avail, api;
|
||||
char name[96], formats[96], source[64];
|
||||
@@ -1229,14 +1229,14 @@ void banner_color_init(struct wl_display *display) {
|
||||
|
||||
/* What asked for HDR, in the words the user knows it by: the editors' setting, or the env override. */
|
||||
char asked[128];
|
||||
if (mode == 2) snprintf(asked, sizeof(asked), "BANNER_WAYLAND_HDR=force (%s)", source);
|
||||
else if (strstr(source, "env")) snprintf(asked, sizeof(asked), "BANNER_WAYLAND_HDR=1 (%s)", source);
|
||||
if (mode == 2) snprintf(asked, sizeof(asked), "DROIDDECK_WAYLAND_HDR=force (%s)", source);
|
||||
else if (strstr(source, "env")) snprintf(asked, sizeof(asked), "DROIDDECK_WAYLAND_HDR=1 (%s)", source);
|
||||
else snprintf(asked, sizeof(asked), "HDR output is on (%s)", source);
|
||||
|
||||
int layer_ok = sc_layer_can_tag_hdr();
|
||||
int ahb_ok = ahb_swapchain_advertised();
|
||||
char off_why[128];
|
||||
if (strstr(source, "env")) snprintf(off_why, sizeof(off_why), "BANNER_WAYLAND_HDR=0 in the %s overrides the setting", source);
|
||||
if (strstr(source, "env")) snprintf(off_why, sizeof(off_why), "DROIDDECK_WAYLAND_HDR=0 in the %s overrides the setting", source);
|
||||
else snprintf(off_why, sizeof(off_why), "the HDR output setting is off");
|
||||
if (mode == 0)
|
||||
snprintf(why, sizeof(why), "HDR output is off (%s)", off_why);
|
||||
@@ -1255,16 +1255,16 @@ void banner_color_init(struct wl_display *display) {
|
||||
snprintf(g_gate_why, sizeof(g_gate_why), "%s", why);
|
||||
atomic_store(&g_gate, 0);
|
||||
if (mode) {
|
||||
banner_log(TAG, "HDR gate CLOSED: %s. Nothing is advertised (no wp_color_manager_v1, no 10-bit dma-buf "
|
||||
droiddeck_log(TAG, "HDR gate CLOSED: %s. Nothing is advertised (no wp_color_manager_v1, no 10-bit dma-buf "
|
||||
"formats): games see an SDR display, exactly as without the switch", why);
|
||||
if (dxvk_hdr)
|
||||
banner_log(TAG, "DXVK_HDR=1 is set: DXGI will still claim an HDR display that no swapchain can get here "
|
||||
droiddeck_log(TAG, "DXVK_HDR=1 is set: DXGI will still claim an HDR display that no swapchain can get here "
|
||||
"(games that check fall back to SDR; some show washed-out colours) - remove it on this display");
|
||||
log_verdict(1);
|
||||
} else if (dxvk_hdr || (known && hdr10)) {
|
||||
/* Off, and silent unless it is worth a line: a display that could show HDR10, or a DXVK
|
||||
* switch that promises games an HDR display they cannot get. */
|
||||
banner_log(TAG, "HDR output off for this session (%s)%s%s", off_why,
|
||||
droiddeck_log(TAG, "HDR output off for this session (%s)%s%s", off_why,
|
||||
(known && hdr10) ? " - this display lists HDR10, so switching HDR output on for this game would "
|
||||
"offer it to games" : "",
|
||||
dxvk_hdr ? " - but DXVK_HDR=1 is set, so DXGI claims an HDR display the game cannot get a "
|
||||
@@ -1276,25 +1276,25 @@ void banner_color_init(struct wl_display *display) {
|
||||
if (!wl_global_create(display, &wp_color_manager_v1_interface, 1, NULL, bind_cm)) {
|
||||
snprintf(g_gate_why, sizeof(g_gate_why), "creating the wp_color_manager_v1 global failed");
|
||||
atomic_store(&g_gate, 0);
|
||||
banner_log("error", "color: wp_color_manager_v1 global creation failed - HDR gate closed");
|
||||
droiddeck_log("error", "color: wp_color_manager_v1 global creation failed - HDR gate closed");
|
||||
log_verdict(1);
|
||||
return;
|
||||
}
|
||||
atomic_store(&g_gate, 1);
|
||||
char syms[160];
|
||||
sc_layer_hdr_symbols(syms, sizeof(syms));
|
||||
banner_log(TAG, "HDR gate %s: %s and %s. Offering games HDR10: wp_color_manager_v1 version 1 (BT.2020 primaries "
|
||||
droiddeck_log(TAG, "HDR gate %s: %s and %s. Offering games HDR10: wp_color_manager_v1 version 1 (BT.2020 primaries "
|
||||
"+ ST 2084 PQ, parametric descriptions with mastering metadata) and 10-bit AB30/XB30 dma-buf formats; HDR "
|
||||
"frames go on the game's own display layer as BT2020_PQ (%s)",
|
||||
mode == 2 ? "FORCED OPEN (testing)" : "OPEN", asked, disp, syms);
|
||||
if (mode == 2 && !hdr10)
|
||||
banner_log(TAG, "BANNER_WAYLAND_HDR=force on a display without HDR10 - testing only: SurfaceFlinger will tone-map "
|
||||
droiddeck_log(TAG, "DROIDDECK_WAYLAND_HDR=force on a display without HDR10 - testing only: SurfaceFlinger will tone-map "
|
||||
"the game layer for this panel (expect GPU/CLIENT composition for it), nothing looks HDR");
|
||||
if (zc_forced)
|
||||
banner_log(TAG, "zero-copy presentation turned on for this session: the HDR game's own 10-bit frames go straight "
|
||||
droiddeck_log(TAG, "zero-copy presentation turned on for this session: the HDR game's own 10-bit frames go straight "
|
||||
"to its display layer (the best path; anything that needs the compositor gets the composed HDR picture)");
|
||||
if (!dxvk_hdr)
|
||||
banner_log(TAG, "DXVK_HDR=1 is not in the game's environment: DXVK games will not see an HDR display in DXGI "
|
||||
droiddeck_log(TAG, "DXVK_HDR=1 is not in the game's environment: DXVK games will not see an HDR display in DXGI "
|
||||
"(dxgi.enableHDR in a dxvk.conf does the same) - add DXVK_HDR=1 to the container's or shortcut's "
|
||||
"environment variables");
|
||||
(void)max_avg; (void)min_lum;
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
|
||||
#include "linux-dmabuf-v1-server-protocol.h"
|
||||
#include "vk_present.h"
|
||||
#include "banner_ext.h"
|
||||
#include "banner_color.h"
|
||||
#include "droiddeck_ext.h"
|
||||
#include "droiddeck_color.h"
|
||||
#include "compositor_internal.h"
|
||||
|
||||
/* ------------------------------------------------------------ zwp_linux_dmabuf_v1 */
|
||||
@@ -112,7 +112,7 @@ static const struct zwp_linux_dmabuf_v1_interface dmabuf_impl = {
|
||||
/* The advertised format/modifier table, built at the first bind from what the renderer's driver
|
||||
* can import (vkp_dmabuf_modifiers). INVALID is always offered too (Mesa drops it; other clients
|
||||
* may use it for the implicit path). Without a renderer the list is LINEAR + INVALID, as before. */
|
||||
/* The last two rows are HDR10's (banner_color.h): A2B10G10R10 as AB30 (alpha) + XB30 (opaque) - Mesa
|
||||
/* The last two rows are HDR10's (droiddeck_color.h): A2B10G10R10 as AB30 (alpha) + XB30 (opaque) - Mesa
|
||||
* lists a VkFormat only when both are advertised, and it is the one 10-bit layout our zero-copy WSI
|
||||
* can put in a gralloc buffer (AHARDWAREBUFFER_FORMAT_R10G10B10A2_UNORM). They are advertised ONLY
|
||||
* while the HDR gate is open, and after the 8-bit rows, so every other session - and every client that
|
||||
@@ -132,7 +132,7 @@ static void dmabuf_build_formats(void) {
|
||||
char line[320];
|
||||
int pos = 0, compressed = 0;
|
||||
g_dmabuf_fmts_ready = 1;
|
||||
g_dmabuf_nfmt = banner_color_hdr_open() ? DMABUF_NFMT_MAX : DMABUF_NFMT_SDR;
|
||||
g_dmabuf_nfmt = droiddeck_color_hdr_open() ? DMABUF_NFMT_MAX : DMABUF_NFMT_SDR;
|
||||
for (int f = 0; f < g_dmabuf_nfmt; f++) {
|
||||
uint64_t got[DMABUF_NMOD];
|
||||
int n = vkp_dmabuf_modifiers(g_dmabuf_fmts[f].fmt, got, DMABUF_NMOD), k = 0;
|
||||
@@ -155,10 +155,10 @@ static void dmabuf_build_formats(void) {
|
||||
vkp_modifier_name(g_dmabuf_fmts[f].mods[i]));
|
||||
if (pos >= (int)sizeof(line)) pos = (int)sizeof(line) - 1;
|
||||
}
|
||||
banner_log("dmabuf", "formats: %s%s", line,
|
||||
!g_ubwc ? " (BANNER_WAYLAND_UBWC=0: qcom_compressed not advertised)" : "");
|
||||
droiddeck_log("dmabuf", "formats: %s%s", line,
|
||||
!g_ubwc ? " (DROIDDECK_WAYLAND_UBWC=0: qcom_compressed not advertised)" : "");
|
||||
if (g_ubwc && !compressed)
|
||||
banner_log("dmabuf", "the compositor's driver (%s) reports no importable qcom_compressed layout: "
|
||||
droiddeck_log("dmabuf", "the compositor's driver (%s) reports no importable qcom_compressed layout: "
|
||||
"game swapchains stay linear", vkp_gpu_name());
|
||||
if (g_dmabuf_nfmt > DMABUF_NFMT_SDR) {
|
||||
/* The rows above always carry LINEAR; say what the compositor's own driver can really import
|
||||
@@ -166,7 +166,7 @@ static void dmabuf_build_formats(void) {
|
||||
uint64_t got[DMABUF_NMOD];
|
||||
int n = vkp_dmabuf_modifiers(DRM_XBGR2101010, got, DMABUF_NMOD), ubwc10 = 0;
|
||||
for (int i = 0; i < n; i++) if (got[i] == VKP_MOD_QCOM_COMPRESSED) ubwc10 = 1;
|
||||
banner_log("color", "10-bit dma-buf formats AB30/XB30 advertised for HDR10; the compositor's driver (%s) "
|
||||
droiddeck_log("color", "10-bit dma-buf formats AB30/XB30 advertised for HDR10; the compositor's driver (%s) "
|
||||
"imports XB30 %s", vkp_gpu_name(),
|
||||
n == 0 ? "with no layout it reports (copy path unlikely; display layer only)"
|
||||
: ubwc10 ? "linear and UBWC" : "linear only (UBWC 10-bit frames: display layer only)");
|
||||
@@ -237,7 +237,7 @@ static void dmabuf_build_feedback(void) {
|
||||
}
|
||||
if (!n) return;
|
||||
|
||||
int fd = (int)syscall(__NR_memfd_create, "banner-dmabuf-formats",
|
||||
int fd = (int)syscall(__NR_memfd_create, "droiddeck-dmabuf-formats",
|
||||
MFD_CLOEXEC | MFD_ALLOW_SEALING);
|
||||
if (fd < 0) { WLOGE("dmabuf feedback: memfd_create failed (%s)", strerror(errno)); return; }
|
||||
size_t size = (size_t)n * sizeof(entries[0]);
|
||||
@@ -251,14 +251,14 @@ static void dmabuf_build_feedback(void) {
|
||||
g_fmt_table_fd = fd;
|
||||
g_fmt_table_size = size;
|
||||
g_fmt_table_n = (uint16_t)n;
|
||||
banner_log("dmabuf", "feedback ready: %d format/modifier pairs, main device %u:%u",
|
||||
droiddeck_log("dmabuf", "feedback ready: %d format/modifier pairs, main device %u:%u",
|
||||
n, (unsigned)major(g_main_device), (unsigned)minor(g_main_device));
|
||||
/* Vulkan games never need the node. Mesa's EGL did until Wayland layer versionCode 9: without
|
||||
* one it fell back to a software path that draws nothing here (black window, sound plays). */
|
||||
if (!g_main_device)
|
||||
banner_log("dmabuf", "%s: OpenGL games need Wayland layer versionCode 9 or newer, "
|
||||
droiddeck_log("dmabuf", "%s: OpenGL games need Wayland layer versionCode 9 or newer, "
|
||||
"which runs OpenGL on the GPU without a DRM node; older layers show a black window",
|
||||
g_no_render_node ? "no DRM device named (forced by BANNER_WAYLAND_NO_RENDER_NODE=1)"
|
||||
g_no_render_node ? "no DRM device named (forced by DROIDDECK_WAYLAND_NO_RENDER_NODE=1)"
|
||||
: "this device gives apps no display (DRM) device (/dev/dri)");
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* Focus model. winewayland enables its text input as soon as our `enter` names a surface and
|
||||
* routes every IME update to that surface's window - inside that window's process (the update
|
||||
* list is per process). Keyboard focus in this compositor sits on the desktop surface (the
|
||||
* desktop owner's process), so text input can't follow it; it follows banner_ime_target():
|
||||
* desktop owner's process), so text input can't follow it; it follows droiddeck_ime_target():
|
||||
* the program window last clicked, else the topmost program window. Clicking into notepad
|
||||
* moves the text-input focus to notepad's process, which is where the edit control lives.
|
||||
*
|
||||
@@ -13,7 +13,7 @@
|
||||
* them (enable resets the state as the protocol says). We answer with `done(serial)` only when
|
||||
* we sent events (preedit/commit strings), never for every commit.
|
||||
*
|
||||
* Host side. banner_on_text_input(enabled, program, cursor rect in scene pixels) tells the app
|
||||
* Host side. droiddeck_on_text_input(enabled, program, cursor rect in scene pixels) tells the app
|
||||
* when a program starts/stops accepting IME text or moves its caret (Wine reports the rect only
|
||||
* when an edit control positions its IME composition window, via ImmSetCompositionWindow).
|
||||
* Typed text arrives through text_input_host_*: preedit_string / commit_string + done.
|
||||
@@ -23,7 +23,7 @@
|
||||
#define _GNU_SOURCE 1
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include "banner_ext.h"
|
||||
#include "droiddeck_ext.h"
|
||||
#include "text-input-unstable-v3-server-protocol.h"
|
||||
|
||||
#ifndef KEY_BACKSPACE
|
||||
@@ -58,7 +58,7 @@ static struct wl_resource *g_target; /* surface text input is focused
|
||||
static int g_told_enabled, g_told_rect[4];
|
||||
static struct text_input *g_told_ti;
|
||||
|
||||
static const char *ti_program(struct text_input *ti) { return banner_client_name(ti->client); }
|
||||
static const char *ti_program(struct text_input *ti) { return droiddeck_client_name(ti->client); }
|
||||
|
||||
/* The enabled text input focused on the current target, if any. */
|
||||
static struct text_input *active_input(void) {
|
||||
@@ -74,22 +74,22 @@ static void tell_app(void) {
|
||||
int enabled = ti != NULL, rect[4] = {0, 0, 0, 0};
|
||||
if (ti) {
|
||||
int ox = 0, oy = 0;
|
||||
banner_surface_scene_origin(ti->entered, &ox, &oy);
|
||||
droiddeck_surface_scene_origin(ti->entered, &ox, &oy);
|
||||
rect[0] = ti->rect[0] + ox; rect[1] = ti->rect[1] + oy;
|
||||
rect[2] = ti->rect[2]; rect[3] = ti->rect[3];
|
||||
}
|
||||
if (enabled == g_told_enabled && ti == g_told_ti && !memcmp(rect, g_told_rect, sizeof(rect))) return;
|
||||
if (enabled && (!g_told_enabled || ti != g_told_ti))
|
||||
banner_log("text-input", "text input enabled by %s (cursor rect %d,%d %dx%d)", ti_program(ti),
|
||||
droiddeck_log("text-input", "text input enabled by %s (cursor rect %d,%d %dx%d)", ti_program(ti),
|
||||
rect[0], rect[1], rect[2], rect[3]);
|
||||
else if (enabled)
|
||||
banner_log("text-input", "cursor rect from %s: %d,%d %dx%d", ti_program(ti), rect[0], rect[1], rect[2], rect[3]);
|
||||
droiddeck_log("text-input", "cursor rect from %s: %d,%d %dx%d", ti_program(ti), rect[0], rect[1], rect[2], rect[3]);
|
||||
else
|
||||
banner_log("text-input", "text input disabled%s%s", g_told_ti ? " by " : "", g_told_ti ? ti_program(g_told_ti) : "");
|
||||
droiddeck_log("text-input", "text input disabled%s%s", g_told_ti ? " by " : "", g_told_ti ? ti_program(g_told_ti) : "");
|
||||
g_told_enabled = enabled;
|
||||
g_told_ti = ti;
|
||||
memcpy(g_told_rect, rect, sizeof(rect));
|
||||
banner_on_text_input(enabled, enabled ? ti_program(ti) : "", rect[0], rect[1], rect[2], rect[3]);
|
||||
droiddeck_on_text_input(enabled, enabled ? ti_program(ti) : "", rect[0], rect[1], rect[2], rect[3]);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ focus */
|
||||
@@ -102,8 +102,8 @@ static void send_leave(struct text_input *ti) {
|
||||
ti->has_preedit = 0;
|
||||
}
|
||||
|
||||
void banner_text_input_refocus(void) {
|
||||
struct wl_resource *t = banner_ime_target();
|
||||
void droiddeck_text_input_refocus(void) {
|
||||
struct wl_resource *t = droiddeck_ime_target();
|
||||
struct text_input *ti;
|
||||
if (t == g_target) return;
|
||||
wl_list_for_each(ti, &g_inputs, link)
|
||||
@@ -121,7 +121,7 @@ void banner_text_input_refocus(void) {
|
||||
wl_display_flush_clients(g_disp);
|
||||
}
|
||||
|
||||
void banner_text_input_surface_gone(struct wl_resource *surface) {
|
||||
void droiddeck_text_input_surface_gone(struct wl_resource *surface) {
|
||||
struct text_input *ti;
|
||||
wl_list_for_each(ti, &g_inputs, link)
|
||||
if (ti->entered == surface) { ti->entered = NULL; ti->enabled = 0; ti->has_preedit = 0; }
|
||||
@@ -136,7 +136,7 @@ static void ti_res_destroy(struct wl_resource *r) {
|
||||
if (!ti) return;
|
||||
wl_list_remove(&ti->link);
|
||||
if (g_told_ti == ti) { g_told_ti = NULL; g_told_enabled = 0; memset(g_told_rect, 0, sizeof(g_told_rect));
|
||||
banner_on_text_input(0, "", 0, 0, 0, 0); }
|
||||
droiddeck_on_text_input(0, "", 0, 0, 0, 0); }
|
||||
free(ti);
|
||||
}
|
||||
|
||||
@@ -235,13 +235,13 @@ static int32_t utf8_offset(const char *s, size_t len, int chars) {
|
||||
void text_input_host_commit(const char *utf8, size_t len) {
|
||||
struct text_input *ti = active_input();
|
||||
if (!ti) {
|
||||
banner_log("text-input", "typed text (%zu chars) dropped: no program accepts text input", utf8_chars(utf8, len));
|
||||
droiddeck_log("text-input", "typed text (%zu chars) dropped: no program accepts text input", utf8_chars(utf8, len));
|
||||
return;
|
||||
}
|
||||
if (ti->has_preedit) { zwp_text_input_v3_send_preedit_string(ti->res, "", 0, 0); ti->has_preedit = 0; }
|
||||
zwp_text_input_v3_send_commit_string(ti->res, utf8);
|
||||
zwp_text_input_v3_send_done(ti->res, ti->serial);
|
||||
banner_log("text-input", "committed %zu chars to %s", utf8_chars(utf8, len), ti_program(ti));
|
||||
droiddeck_log("text-input", "committed %zu chars to %s", utf8_chars(utf8, len), ti_program(ti));
|
||||
}
|
||||
|
||||
void text_input_host_preedit(const char *utf8, size_t len, int cursor_begin, int cursor_end) {
|
||||
@@ -264,9 +264,9 @@ void text_input_host_delete(int before, int after) {
|
||||
zwp_text_input_v3_send_done(ti->res, ti->serial);
|
||||
ti->has_preedit = 0;
|
||||
}
|
||||
for (int i = 0; i < before; i++) { banner_inject_key(KEY_BACKSPACE, 1); banner_inject_key(KEY_BACKSPACE, 0); }
|
||||
for (int i = 0; i < after; i++) { banner_inject_key(KEY_DELETE, 1); banner_inject_key(KEY_DELETE, 0); }
|
||||
banner_log("text-input", "deleted %d before / %d after the caret (as key presses)", before, after);
|
||||
for (int i = 0; i < before; i++) { droiddeck_inject_key(KEY_BACKSPACE, 1); droiddeck_inject_key(KEY_BACKSPACE, 0); }
|
||||
for (int i = 0; i < after; i++) { droiddeck_inject_key(KEY_DELETE, 1); droiddeck_inject_key(KEY_DELETE, 0); }
|
||||
droiddeck_log("text-input", "deleted %d before / %d after the caret (as key presses)", before, after);
|
||||
}
|
||||
|
||||
void text_input_init(struct wl_display *display) {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* missing. We send no icon_size (any size is fine) and `done` at bind, as the protocol asks.
|
||||
*/
|
||||
#include <stdlib.h>
|
||||
#include "banner_ext.h"
|
||||
#include "droiddeck_ext.h"
|
||||
#include "xdg-toplevel-icon-v1-server-protocol.h"
|
||||
|
||||
static void icon_destroy(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); }
|
||||
|
||||
@@ -20,5 +20,6 @@ class App : Application() {
|
||||
com.droiddeck.launcher.update.AppUpdates.init(this)
|
||||
GpuClockPin.clearLeftover(this)
|
||||
BwrapSpawner.start(this)
|
||||
com.droiddeck.launcher.frontend.GameFileSync.start(this)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.droiddeck.launcher.session.ComponentsManager
|
||||
import com.droiddeck.launcher.session.ProtonDefault
|
||||
|
||||
/**
|
||||
* The Components page's state and work: the Protons and their DXVK/VKD3D sets, the Nightlies
|
||||
@@ -20,6 +21,7 @@ internal class ComponentsMenu(private val activity: android.app.Activity, privat
|
||||
var compChecking by mutableStateOf(false)
|
||||
var compBusy by mutableStateOf<String?>(null)
|
||||
var compDownloads by mutableStateOf<Map<String, Int>>(emptyMap())
|
||||
private var shownDefault: String? = null
|
||||
|
||||
/** Reads the Protons (and on first open saves their originals) off the UI thread; the Nightlies list comes from its cache. */
|
||||
fun refreshComponents(snapshotFirst: Boolean = false) {
|
||||
@@ -28,14 +30,35 @@ internal class ComponentsMenu(private val activity: android.app.Activity, privat
|
||||
runCatching { ComponentsManager.applyQueued(activity) }
|
||||
val snap = runCatching { ComponentsManager.snapshot(activity) }.onFailure { Log.w(TAG, "components", it) }.getOrNull()
|
||||
val cat = runCatching { ComponentsManager.catalog(activity, false) }.getOrNull()
|
||||
val chosen = snap?.let { s -> runCatching { ProtonDefault.selectedId(activity, s.protons.map { it.proton }) }.getOrNull() }
|
||||
ui.post {
|
||||
compSnapshot = snap ?: ComponentsManager.Snapshot(emptyList(), emptyList())
|
||||
if (cat != null && cat.fetchedAt > 0) { compCatalog = cat.items; compCatalogAt = cat.fetchedAt }
|
||||
if (compProton == null || snap?.protons?.none { it.proton.id == compProton } == true) compProton = snap?.protons?.firstOrNull()?.proton?.id
|
||||
if (chosen != null && chosen != shownDefault) {
|
||||
shownDefault = chosen
|
||||
compProton = chosen
|
||||
} else if (compProton == null || snap?.protons?.none { it.proton.id == compProton } == true) {
|
||||
compProton = chosen ?: snap?.protons?.firstOrNull()?.proton?.id
|
||||
}
|
||||
}
|
||||
}, "components").start()
|
||||
}
|
||||
|
||||
fun chooseProton(id: String) {
|
||||
val proton = compSnapshot?.protons?.firstOrNull { it.proton.id == id }?.proton ?: return
|
||||
compProton = id
|
||||
Thread({
|
||||
val message = runCatching {
|
||||
when (ProtonDefault.request(activity, proton)) {
|
||||
ProtonDefault.Outcome.LIVE -> activity.getString(R.string.comp_default_live, proton.name)
|
||||
ProtonDefault.Outcome.NEXT_START -> activity.getString(R.string.comp_default_next, proton.name)
|
||||
ProtonDefault.Outcome.NOT_RUNNABLE -> activity.getString(R.string.comp_default_not_runnable, proton.name)
|
||||
}
|
||||
}.getOrElse { e -> activity.getString(R.string.comp_default_failed, e.message ?: e.javaClass.simpleName) }
|
||||
ui.post { android.widget.Toast.makeText(activity, message, android.widget.Toast.LENGTH_LONG).show() }
|
||||
}, "components-default").start()
|
||||
}
|
||||
|
||||
fun componentAction(label: String, work: () -> String) {
|
||||
if (compBusy != null) return
|
||||
compBusy = label
|
||||
|
||||
@@ -7,6 +7,7 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.droiddeck.launcher.runtime.DeckyManager
|
||||
import com.droiddeck.launcher.session.SessionState
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Decky Loader's state in the launcher: what is installed, the releases on offer and an install
|
||||
@@ -51,4 +52,21 @@ internal class DeckyMenu(private val activity: android.app.Activity, private val
|
||||
}
|
||||
}, "install-decky").start()
|
||||
}
|
||||
|
||||
fun importPluginZip(archive: File) {
|
||||
if (deckyStage != null || SessionState.running || deckyInstalled == null) return
|
||||
deckyStage = "Starting plugin import…"; deckyPercent = -1
|
||||
Thread({
|
||||
val problem = runCatching {
|
||||
DeckyManager.installPluginZip(activity, archive) { label, value ->
|
||||
ui.post { deckyStage = label; deckyPercent = value }
|
||||
}
|
||||
}.getOrElse { error -> "Plugin import failed: ${error.message ?: error.javaClass.simpleName}" }
|
||||
ui.post {
|
||||
deckyStage = null; deckyPercent = -1
|
||||
val message = problem ?: "Plugin installed. Restart Steam to load it."
|
||||
android.widget.Toast.makeText(activity, message, android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}, "import-decky-plugin").start()
|
||||
}
|
||||
}
|
||||
@@ -42,11 +42,13 @@ import com.droiddeck.launcher.ui.ComponentsPage
|
||||
import com.droiddeck.launcher.session.GameSaves
|
||||
import com.droiddeck.launcher.session.SessionLogShare
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import com.droiddeck.launcher.session.SteamRepair
|
||||
import com.droiddeck.launcher.ui.ProtonPage
|
||||
import com.droiddeck.launcher.core.CpuCores
|
||||
import com.droiddeck.launcher.core.PhantomProcessLimit
|
||||
import com.droiddeck.launcher.core.PhantomProcessStatus
|
||||
import com.droiddeck.launcher.core.WirelessAdbFix
|
||||
import com.droiddeck.launcher.core.WifiDiscovery
|
||||
import com.droiddeck.launcher.core.WirelessAdbPairingService
|
||||
import com.droiddeck.launcher.ui.CoreRow
|
||||
import com.droiddeck.launcher.ui.PerformancePage
|
||||
@@ -63,10 +65,14 @@ import com.droiddeck.launcher.ui.FrontEndState
|
||||
import com.droiddeck.launcher.ui.FrontEndActions
|
||||
import com.droiddeck.launcher.frontend.CoverArt
|
||||
import com.droiddeck.launcher.frontend.Library
|
||||
import com.droiddeck.launcher.frontend.GameFiles
|
||||
import com.droiddeck.launcher.frontend.GameFileSync
|
||||
import com.droiddeck.launcher.frontend.GameLaunchIntent
|
||||
import com.droiddeck.launcher.ui.DroidDeckTheme
|
||||
import com.droiddeck.launcher.ui.RomsDialog
|
||||
import com.droiddeck.launcher.files.InAppFilePicker
|
||||
import com.droiddeck.launcher.session.SessionArtifacts
|
||||
import com.droiddeck.launcher.session.SessionPhase
|
||||
import com.droiddeck.launcher.session.SessionState
|
||||
import com.droiddeck.launcher.session.GameStorage
|
||||
import com.droiddeck.launcher.input.SecondScreenDisplay
|
||||
@@ -95,6 +101,7 @@ class MainActivity : ComponentActivity() {
|
||||
private var returning by mutableStateOf<ReturningFlood?>(null)
|
||||
private class ReturningFlood(val color: Int, val to: androidx.compose.ui.geometry.Rect?)
|
||||
private var ready by mutableStateOf(false)
|
||||
private var removalPending by mutableStateOf(false)
|
||||
private var available by mutableStateOf<LinuxRuntimeInstaller.Release?>(null)
|
||||
private var busy by mutableStateOf(false)
|
||||
private var stage by mutableStateOf("")
|
||||
@@ -107,6 +114,7 @@ class MainActivity : ComponentActivity() {
|
||||
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)
|
||||
private var showProtons by mutableStateOf(false)
|
||||
@@ -134,6 +142,9 @@ class MainActivity : ComponentActivity() {
|
||||
private var tuSysmem by mutableStateOf(false)
|
||||
private var zinkLazy by mutableStateOf(false)
|
||||
private var noXalia by mutableStateOf(true)
|
||||
private var fastSync by mutableStateOf(false)
|
||||
private var syncFallback by mutableStateOf(true)
|
||||
private var fsyncFirst by mutableStateOf(false)
|
||||
private var gamescopeRealtime by mutableStateOf(false)
|
||||
private var gpuClockPin by mutableStateOf(false)
|
||||
private var prootNoSeccomp by mutableStateOf(false)
|
||||
@@ -144,11 +155,29 @@ class MainActivity : ComponentActivity() {
|
||||
private var showPhantomGate by mutableStateOf(false)
|
||||
private var directAudio by mutableStateOf(false)
|
||||
private var clientDirectAudio by mutableStateOf(false)
|
||||
private var forceFullscreen by mutableStateOf(true)
|
||||
private var stretch16x9 by mutableStateOf(false)
|
||||
private var launcherFullscreen by mutableStateOf(true)
|
||||
private var animationsEnabled by mutableStateOf(true)
|
||||
private var storeEnabled by mutableStateOf(false)
|
||||
private var mic by mutableStateOf(false)
|
||||
private var wifiDiscovery by mutableStateOf(false)
|
||||
private var wifiDiscoveryPermission by mutableStateOf(false)
|
||||
private var wifiDiscoveryLocation by mutableStateOf(false)
|
||||
private var wifiDiscoveryAsked by mutableStateOf(false)
|
||||
private var wifiDiscoveryBlocked by mutableStateOf(false)
|
||||
private val wifiLocationReceiver = object : android.content.BroadcastReceiver() {
|
||||
override fun onReceive(context: android.content.Context?, intent: Intent?) {
|
||||
refreshWifiDiscovery()
|
||||
}
|
||||
}
|
||||
private val wifiLocationPermission = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {
|
||||
SessionPrefs.setWifiDiscoveryEnabled(this, WifiDiscovery.permissionGranted(this))
|
||||
refreshWifiDiscovery()
|
||||
}
|
||||
private val wifiPermissionSettings = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||
SessionPrefs.setWifiDiscoveryEnabled(this, WifiDiscovery.permissionGranted(this))
|
||||
refreshWifiDiscovery()
|
||||
}
|
||||
|
||||
// The app's own picker (files/), once per kind of pick: the two driver lists validate
|
||||
// differently, and the reason a zip is refused names the list it belongs in.
|
||||
@@ -161,6 +190,9 @@ class MainActivity : ComponentActivity() {
|
||||
private val pickAndroidDriver = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
|
||||
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedUri(r.data)?.let { drivers.importDriver(it, linux = false) }
|
||||
}
|
||||
private val pickDeckyPluginZip = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
|
||||
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { decky.importPluginZip(it) }
|
||||
}
|
||||
private val pickAnyDriver = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
|
||||
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedUri(r.data)?.let { drivers.importDriver(it, linux = null) }
|
||||
}
|
||||
@@ -176,6 +208,28 @@ class MainActivity : ComponentActivity() {
|
||||
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let(then)
|
||||
}
|
||||
|
||||
private fun refreshWifiDiscovery() {
|
||||
wifiDiscoveryPermission = WifiDiscovery.permissionGranted(this)
|
||||
wifiDiscoveryLocation = WifiDiscovery.locationEnabled(this)
|
||||
wifiDiscoveryAsked = SessionPrefs.wifiDiscoveryAsked(this)
|
||||
wifiDiscoveryBlocked = !wifiDiscoveryPermission && wifiDiscoveryAsked &&
|
||||
WifiDiscovery.permissions.none { shouldShowRequestPermissionRationale(it) }
|
||||
// A revoked grant must not leave an enabled switch behind.
|
||||
if (!wifiDiscoveryPermission && SessionPrefs.wifiDiscoveryEnabled(this)) {
|
||||
SessionPrefs.setWifiDiscoveryEnabled(this, false)
|
||||
}
|
||||
wifiDiscovery = SessionPrefs.wifiDiscoveryEnabled(this)
|
||||
}
|
||||
|
||||
private fun openWifiDiscoverySettings() {
|
||||
if (!WifiDiscovery.permissionGranted(this)) {
|
||||
wifiPermissionSettings.launch(Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
||||
Uri.parse("package:$packageName")))
|
||||
} else {
|
||||
startActivity(Intent(android.provider.Settings.ACTION_LOCATION_SOURCE_SETTINGS))
|
||||
}
|
||||
}
|
||||
|
||||
/** Takes a new or updated Lossless Scaling from Steam and shows what LSFG can use. */
|
||||
private fun syncLossless() {
|
||||
Thread({
|
||||
@@ -254,15 +308,17 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
// The mode whose settings dialog is open, with what it shows; refreshed by openModeSettings().
|
||||
private var settingsMode by mutableStateOf<String?>(null)
|
||||
private var resolutionCap by mutableStateOf(1080)
|
||||
private var customResolution by mutableStateOf<Pair<Int, Int>?>(null)
|
||||
private var resolution by mutableStateOf(com.droiddeck.launcher.session.SessionDisplay.DEFAULT_RESOLUTION)
|
||||
private var fexPreset by mutableStateOf("")
|
||||
private var steamChannel by mutableStateOf("publicbeta")
|
||||
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 shapeMode by mutableStateOf(SessionPrefs.SHAPE_AUTO)
|
||||
private var appScale by mutableStateOf(com.droiddeck.launcher.core.AppUiPrefs.DEFAULT_SCALE)
|
||||
private var hdrOn by mutableStateOf(false)
|
||||
private var fpsLimit by mutableStateOf(0)
|
||||
private var upscaler by mutableStateOf(0)
|
||||
private var upscaleSharpness by mutableStateOf(75)
|
||||
private var hdrReason by mutableStateOf<String?>(null)
|
||||
private var touchMode by mutableStateOf(SessionPrefs.TOUCH_AUTO)
|
||||
private var suspendPolicy by mutableStateOf(SessionPrefs.SUSPEND_MANUAL)
|
||||
@@ -270,6 +326,8 @@ class MainActivity : ComponentActivity() {
|
||||
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 skipPreallocation by mutableStateOf(false)
|
||||
private var storageOptions by mutableStateOf<List<Pair<String, String>>>(emptyList())
|
||||
private val pickGameStorage = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
|
||||
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedPath(r.data)?.let { path -> setGameStorage(path, GameStorage.labelFor(this, path)) }
|
||||
@@ -300,6 +358,10 @@ class MainActivity : ComponentActivity() {
|
||||
override fun startActivity(intent: Intent?) {
|
||||
if (intent?.component?.className == SessionActivity::class.java.name) {
|
||||
when {
|
||||
busy || LinuxRuntimeInstaller.isBusy() -> {
|
||||
android.widget.Toast.makeText(this, "Wait for the runtime operation to finish", android.widget.Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
protons.protonBusyId != null || ProtonExtras.installInProgress -> {
|
||||
android.widget.Toast.makeText(this, "Wait for the compatibility tool install to finish", android.widget.Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
@@ -327,25 +389,124 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
/** The page is all blue: the session opens on the same blue, with no animation of its own. */
|
||||
private fun launchFlooded(f: PendingFlood) {
|
||||
if (busy || LinuxRuntimeInstaller.isBusy()) { flood = null; floodProgress = 0f; return }
|
||||
val signal = com.droiddeck.launcher.ui.Themes.byId(theme).signal
|
||||
super.startActivity(f.intent.putExtra(com.droiddeck.launcher.ui.EXTRA_FLOOD, signal.toArgb()))
|
||||
overridePendingTransition(0, 0)
|
||||
}
|
||||
|
||||
private var shortcutPicker by mutableStateOf(false)
|
||||
private var shortcutLibraryScanning by mutableStateOf(false)
|
||||
@Volatile private var libraryScanGeneration = 0
|
||||
private var pendingGameLink: String? = null
|
||||
private var gameSyncFolder by mutableStateOf<String?>(null)
|
||||
private fun pickGameExport(game: Library.SteamGame?) {
|
||||
onSavePicked = { folder ->
|
||||
saveAction(getString(R.string.game_frontend_files)) {
|
||||
val saved = if (game != null) GameFiles.export(folder, game)
|
||||
else {
|
||||
GameFileSync.enable(this, folder)
|
||||
folder
|
||||
}
|
||||
ui.post {
|
||||
gameSyncFolder = GameFileSync.folder(this)
|
||||
}
|
||||
getString(R.string.game_file_exported, saved.path)
|
||||
}
|
||||
}
|
||||
pickSaveDir.launch(InAppFilePicker.buildDirIntent(this, getString(R.string.game_file_folder), gameSyncFolder))
|
||||
}
|
||||
|
||||
private fun readGameIntent(request: Intent) {
|
||||
shortcutPicker = request.action == Intent.ACTION_CREATE_SHORTCUT
|
||||
if (shortcutPicker) {
|
||||
// The shortcut picker must only offer games from the scan started for this request.
|
||||
steamGames = emptyList()
|
||||
shortcutLibraryScanning = true
|
||||
} else shortcutLibraryScanning = false
|
||||
pendingGameLink = null
|
||||
if (GameLaunchIntent.accepts(request.action)) {
|
||||
val copy = Intent(request)
|
||||
Thread({
|
||||
val id = GameLaunchIntent.read(this, copy)
|
||||
ui.post {
|
||||
if (intent === request && !isDestroyed) {
|
||||
pendingGameLink = id
|
||||
if (id == null) {
|
||||
android.widget.Toast.makeText(this, R.string.game_link_invalid, android.widget.Toast.LENGTH_LONG).show()
|
||||
} else refresh()
|
||||
}
|
||||
}
|
||||
}, "game-launch-file").start()
|
||||
}
|
||||
}
|
||||
|
||||
private fun launchGame(game: Library.SteamGame, returnHome: Boolean = false): Boolean {
|
||||
if (protons.protonBusyId != null || ProtonExtras.installInProgress || pkgStage != null) {
|
||||
android.widget.Toast.makeText(this, "Wait for the install to finish before launching a game", android.widget.Toast.LENGTH_SHORT).show()
|
||||
return false
|
||||
}
|
||||
if (SessionState.running) {
|
||||
if (SessionState.mode != SessionService.MODE_STEAM || SessionState.stopRequested) {
|
||||
android.widget.Toast.makeText(this, R.string.game_link_session_busy, android.widget.Toast.LENGTH_LONG).show()
|
||||
return true
|
||||
}
|
||||
if (!SessionService.launchGame(this, game.gameIdString)) {
|
||||
android.widget.Toast.makeText(this, R.string.game_link_failed, android.widget.Toast.LENGTH_LONG).show()
|
||||
return false
|
||||
}
|
||||
startActivity(Intent(this, SessionActivity::class.java).setAction(SessionService.ACTION_RESUME)
|
||||
.putExtra(SessionActivity.EXTRA_RETURN_HOME, returnHome)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP))
|
||||
return true
|
||||
}
|
||||
return startSession(Intent(this, SessionActivity::class.java)
|
||||
.putExtra(SessionActivity.EXTRA_RETURN_HOME, returnHome)
|
||||
.putExtra(SessionService.EXTRA_STEAM_URL,
|
||||
com.droiddeck.launcher.frontend.GameLaunchLink.steamUrl(game.gameIdString)), steamSession = true)
|
||||
}
|
||||
|
||||
private fun handleGameLink(games: List<Library.SteamGame>) {
|
||||
if (!lifecycle.currentState.isAtLeast(androidx.lifecycle.Lifecycle.State.RESUMED)) return
|
||||
val id = pendingGameLink ?: return
|
||||
val game = games.firstOrNull { it.gameIdString == id }
|
||||
if (game == null) {
|
||||
pendingGameLink = null
|
||||
intent.data = null
|
||||
intent.action = Intent.ACTION_MAIN
|
||||
intent.removeExtra(GameLaunchIntent.EXTRA_APP_ID)
|
||||
android.widget.Toast.makeText(this, R.string.game_link_missing, android.widget.Toast.LENGTH_LONG).show()
|
||||
} else if (launchGame(game, returnHome = true)) {
|
||||
pendingGameLink = null
|
||||
intent.data = null
|
||||
intent.action = Intent.ACTION_MAIN
|
||||
intent.removeExtra(GameLaunchIntent.EXTRA_APP_ID)
|
||||
}
|
||||
}
|
||||
|
||||
private fun chooseGameShortcut(game: Library.SteamGame) {
|
||||
setResult(RESULT_OK, com.droiddeck.launcher.frontend.GameShortcuts.result(this, game))
|
||||
finish()
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
readGameIntent(intent)
|
||||
gameSyncFolder = GameFileSync.folder(this)
|
||||
displayManager = getSystemService(DISPLAY_SERVICE) as DisplayManager
|
||||
refreshPhantomStatus()
|
||||
theme = SessionPrefs.theme(this)
|
||||
appScale = com.droiddeck.launcher.core.AppUiPrefs.scale(this)
|
||||
// Last start's game list, so the Steam wall is up on the first frame; refresh() replaces it.
|
||||
steamGames = com.droiddeck.launcher.frontend.LibraryCache.load(this)
|
||||
steamGames = if (shortcutPicker) emptyList() else com.droiddeck.launcher.frontend.LibraryCache.load(this)
|
||||
backActionsInverted = SessionPrefs.backActionsInverted(this)
|
||||
launcherFullscreen = SessionPrefs.launcherFullscreen(this)
|
||||
animationsEnabled = SessionPrefs.animationsEnabled(this)
|
||||
storeEnabled = SessionPrefs.storeEnabled(this)
|
||||
applyLauncherFullscreen()
|
||||
updates.start()
|
||||
setContent {
|
||||
DroidDeckTheme(theme) {
|
||||
DroidDeckTheme(theme, appScale = appScale) {
|
||||
com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize()) {
|
||||
val sm = settingsMode
|
||||
val page: (@Composable () -> Unit)? = when {
|
||||
@@ -359,7 +520,10 @@ class MainActivity : ComponentActivity() {
|
||||
com.droiddeck.launcher.ui.FloodBehind({ floodProgress }) {
|
||||
FrontEndScreen(
|
||||
FrontEndState(
|
||||
installed = installed, ready = ready, available = available?.version,
|
||||
installed = installed, ready = ready, available = available?.version, removalPending = removalPending, runtimeActionsBlocked = runtimeChangesBlocked(),
|
||||
shortcutPicker = shortcutPicker,
|
||||
shortcutLibraryScanning = shortcutLibraryScanning,
|
||||
gameSyncFolder = gameSyncFolder,
|
||||
busy = busy, stage = stage, percent = percent,
|
||||
desktopInstalled = desktopInstalled,
|
||||
offlineAccount = offlineAccount, offline = offline,
|
||||
@@ -369,6 +533,7 @@ class MainActivity : ComponentActivity() {
|
||||
lossless = lossless,
|
||||
pageKey = sm?.let { "settings:$it" } ?: if (showPerformance) "performance" else if (showProtons) "protons" else if (showComponents) "components" else if (showMapping) "controller-mapping" else null,
|
||||
theme = theme,
|
||||
appScale = appScale,
|
||||
isHomeApp = homeAppSelected,
|
||||
homeScreenEnabled = homeScreenEnabled,
|
||||
defaultHomeLabel = defaultHomeLabel,
|
||||
@@ -388,6 +553,7 @@ class MainActivity : ComponentActivity() {
|
||||
phantomProcessStatus = phantomProcessStatus,
|
||||
showPhantomGate = showPhantomGate,
|
||||
launcherFullscreen = launcherFullscreen,
|
||||
animationsEnabled = animationsEnabled,
|
||||
storeEnabled = storeEnabled,
|
||||
),
|
||||
FrontEndActions(
|
||||
@@ -399,9 +565,20 @@ class MainActivity : ComponentActivity() {
|
||||
.putExtra(SessionService.EXTRA_MODE, SessionService.MODE_DESKTOP)
|
||||
.putExtra(SessionService.EXTRA_STEAM_UI, "desktop"), steamSession = true)
|
||||
},
|
||||
onSteamGame = { g ->
|
||||
startSession(Intent(this, SessionActivity::class.java)
|
||||
.putExtra(SessionService.EXTRA_STEAM_URL, "steam://rungameid/${g.gameId}"), steamSession = true)
|
||||
onSteamGame = { g -> if (shortcutPicker) chooseGameShortcut(g) else launchGame(g) },
|
||||
onGameShortcut = { g -> com.droiddeck.launcher.frontend.GameShortcuts.pin(this, g) },
|
||||
onExportGameFile = { g -> pickGameExport(g) },
|
||||
onSyncGameFiles = { pickGameExport(null) },
|
||||
onStopGameFileSync = {
|
||||
Thread({
|
||||
GameFileSync.disable(this)
|
||||
ui.post { gameSyncFolder = null }
|
||||
}, "game-file-stop-sync").start()
|
||||
},
|
||||
onCopyGameLink = { g ->
|
||||
(getSystemService(CLIPBOARD_SERVICE) as ClipboardManager).setPrimaryClip(
|
||||
ClipData.newPlainText(g.name, com.droiddeck.launcher.frontend.GameLaunchLink.uri(g.gameIdString)))
|
||||
android.widget.Toast.makeText(this, R.string.game_link_copied, android.widget.Toast.LENGTH_SHORT).show()
|
||||
},
|
||||
onDesktop = {
|
||||
startSession(Intent(this, SessionActivity::class.java)
|
||||
@@ -462,13 +639,10 @@ class MainActivity : ComponentActivity() {
|
||||
logsEnabled = SessionPrefs.logsEnabled(this)
|
||||
},
|
||||
onShareLogs = {
|
||||
Thread({
|
||||
val zip = runCatching { SessionLogShare.zipLatest(this) }.getOrNull()
|
||||
ui.post {
|
||||
if (zip == null) android.widget.Toast.makeText(this, "No session logs yet: run a session first.", android.widget.Toast.LENGTH_LONG).show()
|
||||
else startActivity(SessionLogShare.shareIntent(this, zip))
|
||||
}
|
||||
}, "share-logs").start()
|
||||
SessionLogShare.prepare(this, { SessionLogShare.latest(this) }) { zip ->
|
||||
if (zip == null) android.widget.Toast.makeText(this, "No session logs yet: run a session first.", android.widget.Toast.LENGTH_LONG).show()
|
||||
else startActivity(SessionLogShare.shareIntent(this, zip))
|
||||
}
|
||||
},
|
||||
onClearLogs = {
|
||||
Thread({
|
||||
@@ -486,12 +660,21 @@ class MainActivity : ComponentActivity() {
|
||||
},
|
||||
onPageBack = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showMapping = false },
|
||||
onTheme = { id -> SessionPrefs.setTheme(this, id); theme = id },
|
||||
onAppScale = { percent ->
|
||||
com.droiddeck.launcher.core.AppUiPrefs.setScale(this, percent)
|
||||
appScale = com.droiddeck.launcher.core.AppUiPrefs.scale(this)
|
||||
},
|
||||
onLauncherFullscreen = { on ->
|
||||
SessionPrefs.setLauncherFullscreen(this, on)
|
||||
launcherFullscreen = on
|
||||
applyLauncherFullscreen()
|
||||
},
|
||||
onStoreEnabled = { on -> SessionPrefs.setStoreEnabled(this, on); storeEnabled = on },
|
||||
onAnimationsEnabled = { on ->
|
||||
SessionPrefs.setAnimationsEnabled(this, on)
|
||||
animationsEnabled = on
|
||||
com.droiddeck.launcher.ui.Motion.refresh(this)
|
||||
},
|
||||
onHomeApp = { manageHomeApp() },
|
||||
onHomeScreen = { on ->
|
||||
HomeApp.setHomeScreenEnabled(this, on)
|
||||
@@ -560,6 +743,10 @@ class MainActivity : ComponentActivity() {
|
||||
onSize = { v -> ControllerPrefs.setSize(this, v); refreshController() },
|
||||
onStickClick = { on -> ControllerPrefs.setStickClick(this, on); refreshController() },
|
||||
onAdaptiveSticks = { on -> ControllerPrefs.setAdaptiveSticks(this, on); refreshController() },
|
||||
onRumble = { on -> ControllerPrefs.setRumble(this, on); refreshController() },
|
||||
onSteamButton = { on -> ControllerPrefs.setSteamButton(this, on); refreshController() },
|
||||
onQamButton = { on -> ControllerPrefs.setQamButton(this, on); refreshController() },
|
||||
onKeyboardButton = { on -> ControllerPrefs.setKeyboardButton(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 },
|
||||
@@ -591,7 +778,7 @@ class MainActivity : ComponentActivity() {
|
||||
title = "Remove Linux runtime",
|
||||
text = "This deletes the runtime, the Steam client inside it, and every game installed there.",
|
||||
confirm = "Remove",
|
||||
onConfirm = { Thread({ LinuxRuntimeInstaller.uninstall(this); ui.post { refresh() } }, "uninstall").start() },
|
||||
onConfirm = { showRemove = false; removeRuntime() },
|
||||
onDismiss = { showRemove = false },
|
||||
)
|
||||
flood?.let { f -> com.droiddeck.launcher.ui.LaunchFlood(f.from, onProgress = { floodProgress = it }) { launchFlooded(f) } }
|
||||
@@ -635,10 +822,13 @@ class MainActivity : ComponentActivity() {
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
readGameIntent(intent)
|
||||
refresh()
|
||||
startSteamAtStartupIfEnabled()
|
||||
}
|
||||
|
||||
private fun startSteamAtStartupIfEnabled() {
|
||||
if (intent.action != Intent.ACTION_MAIN) return
|
||||
if (SessionPrefs.runSteamAtStartup(this) && !isFinishing && !isDestroyed) startSteamSession()
|
||||
}
|
||||
|
||||
@@ -680,6 +870,7 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
refreshWifiDiscovery()
|
||||
com.droiddeck.launcher.ui.Motion.refresh(this)
|
||||
// Back from a session stopped behind a flood: open on its blue, before the first frame.
|
||||
com.droiddeck.launcher.ui.QuitFlood.take()?.let { c ->
|
||||
@@ -697,9 +888,10 @@ class MainActivity : ComponentActivity() {
|
||||
val applied = runCatching { ComponentsManager.applyQueued(this) }.getOrDefault(emptyList())
|
||||
if (applied.isNotEmpty()) ui.post {
|
||||
android.widget.Toast.makeText(this, "Applied: " + applied.joinToString(", "), android.widget.Toast.LENGTH_LONG).show()
|
||||
if (showComponents) components.refreshComponents()
|
||||
}
|
||||
ui.post { if (showComponents) components.refreshComponents() }
|
||||
}, "components-queue").start()
|
||||
else if (showComponents) components.refreshComponents()
|
||||
oscMode = SessionPrefs.oscMode(this)
|
||||
refreshController()
|
||||
refreshHomeAppState()
|
||||
@@ -717,6 +909,7 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
registerReceiver(wifiLocationReceiver, android.content.IntentFilter(android.location.LocationManager.MODE_CHANGED_ACTION))
|
||||
displayManager.registerDisplayListener(secondScreenDisplayListener, ui)
|
||||
refreshSecondScreenDisplays()
|
||||
}
|
||||
@@ -727,6 +920,7 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
unregisterReceiver(wifiLocationReceiver)
|
||||
displayManager.unregisterDisplayListener(secondScreenDisplayListener)
|
||||
// The session covers the page by now; coming back finds it as it was.
|
||||
flood = null
|
||||
@@ -782,7 +976,7 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
/** Runs a save import or export off the main thread, one at a time, and says how it went. */
|
||||
private fun saveAction(label: String, work: () -> String) {
|
||||
if (saveBusy != null) return
|
||||
if (busy || LinuxRuntimeInstaller.isBusy() || saveBusy != null) return
|
||||
saveBusy = label
|
||||
Thread({
|
||||
val message = runCatching(work).getOrElse { e -> "$label failed: ${e.message ?: e.javaClass.simpleName}" }
|
||||
@@ -847,10 +1041,10 @@ class MainActivity : ComponentActivity() {
|
||||
protonId = components.compProton,
|
||||
comp = components.compComp,
|
||||
checking = components.compChecking,
|
||||
busy = components.compBusy,
|
||||
busy = if (busy) stage else components.compBusy,
|
||||
downloads = components.compDownloads,
|
||||
requestInitialFocus = focusComponentsContent,
|
||||
onProton = { components.compProton = it },
|
||||
onProton = { components.chooseProton(it) },
|
||||
onComp = { components.compComp = it },
|
||||
onSwap = { file -> components.compProton?.let { pid -> components.componentAction("Swapping") { ComponentsManager.swap(this, pid, file) } } },
|
||||
onRestore = { version -> components.compProton?.let { pid -> components.componentAction("Restoring") { ComponentsManager.restore(this, pid, components.compComp, version) } } },
|
||||
@@ -887,7 +1081,7 @@ class MainActivity : ComponentActivity() {
|
||||
busyId = protons.protonBusyId,
|
||||
stage = protons.protonStage,
|
||||
percent = protons.protonPercent,
|
||||
runtimeReady = ready,
|
||||
runtimeReady = ready && !busy,
|
||||
sessionRunning = SessionState.running,
|
||||
onInstall = { id -> protons.installProton(id) },
|
||||
onCancel = { id -> ProtonExtras.tools.firstOrNull { it.id == id }?.let { ProtonExtras.unqueue(this, it) }; protons.refreshProtons() },
|
||||
@@ -909,7 +1103,7 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
private fun installPackage(id: String) {
|
||||
val entry = catalog?.firstOrNull { it.id == id } ?: return
|
||||
if (pkgStage != null || SessionState.running) return
|
||||
if (busy || LinuxRuntimeInstaller.isBusy() || pkgStage != null || SessionState.running) return
|
||||
pkgId = id; pkgStage = "Starting…"; pkgPercent = -1
|
||||
Thread({
|
||||
val problem = DesktopCatalog.install(this, entry) { stage, percent ->
|
||||
@@ -926,7 +1120,7 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
private fun removePackage(id: String) {
|
||||
val entry = catalog?.firstOrNull { it.id == id } ?: return
|
||||
if (pkgStage != null || SessionState.running) return
|
||||
if (busy || LinuxRuntimeInstaller.isBusy() || pkgStage != null || SessionState.running) return
|
||||
pkgId = id; pkgStage = if (entry.kind == "appimage") "Removing ${entry.name}…" else "Forgetting ${entry.name}…"; pkgPercent = -1
|
||||
Thread({
|
||||
DesktopCatalog.remove(this, entry)
|
||||
@@ -942,27 +1136,40 @@ class MainActivity : ComponentActivity() {
|
||||
private fun ModeSettingsHost(mode: String) {
|
||||
ModeSettingsPage(
|
||||
ModeSettings(
|
||||
mode = mode, resolutionCap = resolutionCap, customResolution = customResolution, shapeMode = shapeMode,
|
||||
mode = mode, resolution = resolution,
|
||||
panelSize = com.droiddeck.launcher.session.SessionDisplay.panelSize(this),
|
||||
hdr = hdrOn, hdrReason = hdrReason, fpsLimit = fpsLimit,
|
||||
upscaler = upscaler, upscaleSharpness = upscaleSharpness,
|
||||
gpuDrivers = drivers.summary(),
|
||||
touchMode = touchMode,
|
||||
suspendPolicy = suspendPolicy,
|
||||
pipSupported = com.droiddeck.launcher.session.SessionPipController.supported(this),
|
||||
pipAutoEnter = pipAutoEnter,
|
||||
oscMode = if (mode == SessionService.MODE_STEAM) oscMode else null,
|
||||
backActionsInverted = backActionsInverted,
|
||||
directAudio = if (mode == SessionService.MODE_STEAM) directAudio else null,
|
||||
clientDirectAudio = clientDirectAudio,
|
||||
forceFullscreen = if (mode == SessionService.MODE_STEAM) forceFullscreen 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,
|
||||
skipPreallocation = mode == SessionService.MODE_STEAM && skipPreallocation,
|
||||
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,
|
||||
runSteamAtStartup = mode == SessionService.MODE_STEAM && runSteamAtStartup,
|
||||
wifiDiscovery = if (mode == SessionService.MODE_STEAM) wifiDiscovery else null,
|
||||
wifiDiscoveryPermission = wifiDiscoveryPermission,
|
||||
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,
|
||||
@@ -972,14 +1179,30 @@ class MainActivity : ComponentActivity() {
|
||||
deckyEnabled = decky.deckySupervisor, deckySessionRunning = SessionState.running,
|
||||
),
|
||||
ModeSettingsActions(
|
||||
onResolution = { cap -> SessionPrefs.setResolutionCap(this, mode, cap); resolutionCap = cap },
|
||||
onCustomResolution = { size -> SessionPrefs.setCustomResolution(this, mode, size); customResolution = size },
|
||||
onShape = { shape -> SessionPrefs.setShapeMode(this, shape); shapeMode = shape },
|
||||
onWifiDiscovery = { on ->
|
||||
if (!on) {
|
||||
SessionPrefs.setWifiDiscoveryEnabled(this, false)
|
||||
refreshWifiDiscovery()
|
||||
} else if (WifiDiscovery.permissionGranted(this)) {
|
||||
SessionPrefs.setWifiDiscoveryEnabled(this, true)
|
||||
refreshWifiDiscovery()
|
||||
} else if (wifiDiscoveryBlocked) {
|
||||
openWifiDiscoverySettings()
|
||||
} else {
|
||||
SessionPrefs.setWifiDiscoveryAsked(this)
|
||||
wifiLocationPermission.launch(WifiDiscovery.permissions)
|
||||
}
|
||||
},
|
||||
onWifiDiscoverySettings = { openWifiDiscoverySettings() },
|
||||
onResolution = { value -> SessionPrefs.setResolutionChoice(this, mode, value); resolution = value },
|
||||
onHdr = { on -> SessionPrefs.setHdr(this, mode, on); hdrOn = on },
|
||||
onGpuDrivers = { openComponents(focusContent = true, tab = com.droiddeck.launcher.ui.GPU_TAB) },
|
||||
onFpsLimit = { fps -> SessionPrefs.setFpsLimit(this, mode, fps); fpsLimit = fps },
|
||||
onUpscaler = { m -> SessionPrefs.setUpscaler(this, m); upscaler = m },
|
||||
onUpscaleSharpness = { pct -> SessionPrefs.setUpscaleSharpness(this, pct); upscaleSharpness = pct },
|
||||
onTouch = { t -> SessionPrefs.setTouchMode(this, t); touchMode = t },
|
||||
onSuspendPolicy = { policy -> SessionPrefs.setSuspendPolicy(this, mode, policy); suspendPolicy = policy },
|
||||
onPipAutoEnter = { on -> SessionPrefs.setPipAutoEnter(this, on); pipAutoEnter = on },
|
||||
onOsc = { o -> SessionPrefs.setOscMode(this, o); oscMode = o },
|
||||
onBackActionsInverted = { inverted ->
|
||||
SessionPrefs.setBackActionsInverted(this, inverted)
|
||||
@@ -987,7 +1210,6 @@ class MainActivity : ComponentActivity() {
|
||||
},
|
||||
onDirectAudio = { on -> SessionPrefs.setDirectAudio(this, on); directAudio = on },
|
||||
onClientDirectAudio = { on -> SessionPrefs.setClientDirectAudio(this, on); clientDirectAudio = on },
|
||||
onForceFullscreen = { on -> SessionPrefs.setForceFullscreen(this, on); forceFullscreen = on },
|
||||
onStretch16x9 = { on -> SessionPrefs.setStretch16x9(this, on); stretch16x9 = on },
|
||||
onMic = { on ->
|
||||
SessionPrefs.setMicEnabled(this, on)
|
||||
@@ -1003,8 +1225,24 @@ class MainActivity : ComponentActivity() {
|
||||
onPickGameStorageFolder = {
|
||||
pickGameStorage.launch(InAppFilePicker.buildDirIntent(this, "Choose the game storage folder", gameStorage.ifEmpty { null }))
|
||||
},
|
||||
onStorageDiagnostics = { on ->
|
||||
SessionPrefs.setStorageDiagnosticsEnabled(this, on)
|
||||
storageDiagnostics = on
|
||||
},
|
||||
onSkipPreallocation = { on ->
|
||||
SessionPrefs.setSkipLibraryPreallocation(this, on)
|
||||
skipPreallocation = on
|
||||
},
|
||||
onFexPreset = { id -> SessionPrefs.setFexPreset(this, id); fexPreset = id },
|
||||
onForceSsbs = { on -> SessionPrefs.setForceSsbs(this, on); forceSsbs = on },
|
||||
onSyncBackend = { id ->
|
||||
SessionPrefs.setSyncBackend(this, id)
|
||||
fastSync = SessionPrefs.fastSync(this)
|
||||
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
|
||||
@@ -1032,6 +1270,9 @@ class MainActivity : ComponentActivity() {
|
||||
decky.deckyInstalled = null
|
||||
decky.deckySupervisor = false
|
||||
},
|
||||
onPickDeckyPluginZip = {
|
||||
pickDeckyPluginZip.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a Decky plugin ZIP"))
|
||||
},
|
||||
onDismiss = { settingsMode = null },
|
||||
),
|
||||
)
|
||||
@@ -1043,6 +1284,9 @@ class MainActivity : ComponentActivity() {
|
||||
cores = CpuCores.all.map { c -> CoreRow(c, "cpu$c" + (CpuCores.maxGhz(c)?.let { String.format(java.util.Locale.US, " · %.1f GHz", it) } ?: "")) },
|
||||
clientOverride = clientOverride, clientCores = clientCores, gameCores = gameCores,
|
||||
tuSysmem = tuSysmem, zinkLazy = zinkLazy, glThread = glThread, noGlError = noGlError, noXalia = noXalia,
|
||||
fastSync = fastSync,
|
||||
syncFallback = syncFallback,
|
||||
fsyncFirst = fsyncFirst,
|
||||
gamescopeRealtime = gamescopeRealtime,
|
||||
gpuClockPin = gpuClockPin,
|
||||
prootNoSeccomp = prootNoSeccomp, prootFastPath = prootFastPath, guestHostname = guestHostname, phantomWarning = phantomWarning,
|
||||
@@ -1052,6 +1296,9 @@ class MainActivity : ComponentActivity() {
|
||||
onGlThread = { on -> SessionPrefs.setGlThread(this, on); glThread = on },
|
||||
onNoGlError = { on -> SessionPrefs.setNoGlError(this, on); noGlError = on },
|
||||
onNoXalia = { on -> SessionPrefs.setNoXalia(this, on); noXalia = on },
|
||||
onFastSync = { on -> SessionPrefs.setFastSync(this, on); fastSync = on },
|
||||
onSyncFallback = { on -> SessionPrefs.setSyncFallback(this, on); syncFallback = on },
|
||||
onFsyncFirst = { on -> SessionPrefs.setFsyncFirst(this, on); fsyncFirst = on },
|
||||
onGamescopeRealtime = { on -> SessionPrefs.setGamescopeRealtime(this, on); gamescopeRealtime = on },
|
||||
onGpuClockPin = { on -> SessionPrefs.setGpuClockPin(this, on); gpuClockPin = on },
|
||||
onProotNoSeccomp = { on -> SessionPrefs.setProotNoSeccomp(this, on); prootNoSeccomp = on },
|
||||
@@ -1099,23 +1346,31 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private var pipAutoEnter by mutableStateOf(false)
|
||||
|
||||
private fun openModeSettings(mode: String) {
|
||||
pipAutoEnter = SessionPrefs.pipAutoEnter(this)
|
||||
showPerformance = false
|
||||
showProtons = false
|
||||
showComponents = false
|
||||
showMapping = false
|
||||
resolutionCap = SessionPrefs.resolutionCap(this, mode)
|
||||
customResolution = SessionPrefs.customResolution(this, mode)
|
||||
resolution = SessionPrefs.resolutionChoice(this, mode, com.droiddeck.launcher.session.SessionDisplay.panelSize(this))
|
||||
fexPreset = SessionPrefs.fexPreset(this)
|
||||
forceSsbs = SessionPrefs.forceSsbs(this)
|
||||
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)
|
||||
shapeMode = SessionPrefs.shapeMode(this)
|
||||
hdrOn = SessionPrefs.hdr(this, mode)
|
||||
fpsLimit = SessionPrefs.fpsLimit(this, mode)
|
||||
upscaler = SessionPrefs.upscaler(this)
|
||||
upscaleSharpness = SessionPrefs.upscaleSharpness(this)
|
||||
hdrReason = com.droiddeck.launcher.wayland.HdrSupport.probe(this).reason
|
||||
touchMode = SessionPrefs.touchMode(this)
|
||||
suspendPolicy = SessionPrefs.suspendPolicy(this, mode)
|
||||
@@ -1123,11 +1378,13 @@ class MainActivity : ComponentActivity() {
|
||||
backActionsInverted = SessionPrefs.backActionsInverted(this)
|
||||
directAudio = SessionPrefs.directAudio(this)
|
||||
clientDirectAudio = SessionPrefs.clientDirectAudio(this)
|
||||
forceFullscreen = SessionPrefs.forceFullscreen(this)
|
||||
stretch16x9 = SessionPrefs.stretch16x9(this)
|
||||
mic = SessionPrefs.micEnabled(this)
|
||||
refreshWifiDiscovery()
|
||||
renderer = SessionPrefs.desktopRenderer(this)
|
||||
gameStorage = SessionPrefs.gameStorage(this)
|
||||
storageDiagnostics = SessionPrefs.storageDiagnosticsEnabled(this)
|
||||
skipPreallocation = SessionPrefs.skipLibraryPreallocation(this)
|
||||
settingsMode = mode
|
||||
// The page opens at once, on what was last read; the slow part (driver files, a walk of the
|
||||
// added-games folders, the storage volumes) lands while it animates in.
|
||||
@@ -1174,6 +1431,9 @@ class MainActivity : ComponentActivity() {
|
||||
tuSysmem = SessionPrefs.tuSysmem(this)
|
||||
zinkLazy = SessionPrefs.zinkLazy(this)
|
||||
noXalia = SessionPrefs.noXalia(this)
|
||||
fastSync = SessionPrefs.fastSync(this)
|
||||
syncFallback = SessionPrefs.syncFallback(this)
|
||||
fsyncFirst = SessionPrefs.fsyncFirst(this)
|
||||
gamescopeRealtime = SessionPrefs.gamescopeRealtime(this)
|
||||
gpuClockPin = SessionPrefs.gpuClockPin(this)
|
||||
prootNoSeccomp = SessionPrefs.prootNoSeccomp(this)
|
||||
@@ -1183,12 +1443,13 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
if (!busy && LinuxRuntimeInstaller.isInstalling()) followInstall { LinuxRuntimeInstaller.attach(it) }
|
||||
if (!busy && LinuxRuntimeInstaller.isBusy()) followRuntimeOperation { LinuxRuntimeInstaller.attach(it) }
|
||||
desktopInstalled = DesktopCatalog.desktopInstalled(this)
|
||||
offlineAccount = OfflineMode.account(this)
|
||||
offline = OfflineMode.enabled(this)
|
||||
installed = LinuxRuntimeInstaller.installedVersion(this)
|
||||
ready = LinuxRuntime.isInstalled(this)
|
||||
removalPending = LinuxRuntimeInstaller.hasRemovalPending(this)
|
||||
frameGenLabel = FrameGen.label(this)
|
||||
romsDir = SessionPrefs.romsDir(this).takeIf { it.isNotEmpty() }
|
||||
logsEnabled = SessionPrefs.logsEnabled(this)
|
||||
@@ -1199,23 +1460,24 @@ class MainActivity : ComponentActivity() {
|
||||
else -> "Steam"
|
||||
} else null
|
||||
// The libraries, off the main thread: manifests and a folder scan.
|
||||
val scanGeneration = ++libraryScanGeneration
|
||||
if (shortcutPicker) shortcutLibraryScanning = true
|
||||
Thread({
|
||||
// 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.steamGames(this) + com.droiddeck.launcher.frontend.AddedGames.scan(this).map { g ->
|
||||
com.droiddeck.launcher.frontend.AddedGameArt.resolve(this, g).let { art ->
|
||||
Library.SteamGame(
|
||||
g.steamAppId ?: g.appId.toInt(), g.name, art.portrait ?: art.header, Library.ADDED, g.gameId,
|
||||
hero = art.hero ?: art.header, gameFiles = g.folder,
|
||||
protonPrefix = Library.protonPrefix(this, g.steamAppId?.toLong() ?: g.appId),
|
||||
)
|
||||
}
|
||||
} else emptyList()
|
||||
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)
|
||||
ui.post { steamGames = all; emulatorList = emus }
|
||||
ui.post {
|
||||
if (scanGeneration == libraryScanGeneration) {
|
||||
steamGames = all
|
||||
emulatorList = emus
|
||||
shortcutLibraryScanning = false
|
||||
handleGameLink(all)
|
||||
}
|
||||
}
|
||||
// Box art for the games that have none, fetched after the list is up; the list is
|
||||
// rebuilt once if any was found.
|
||||
if (!OfflineMode.enabled(this) && CoverArt.fetchMissing(this, emus.flatMap { it.games })) {
|
||||
@@ -1226,9 +1488,9 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
private fun onRuntimeButton() {
|
||||
if (busy) return
|
||||
if (runtimeChangesBlocked()) return
|
||||
val release = available
|
||||
if (installed != null && release?.version == installed) {
|
||||
if (removalPending || installed != null && release?.version == installed) {
|
||||
// Nothing to install: offer the one destructive thing this screen can do.
|
||||
showRemove = true
|
||||
return
|
||||
@@ -1245,15 +1507,17 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
/** Starts a session; with no runtime on a non-Adreno, the same warning Setup gives comes first, before any download. */
|
||||
private fun startSession(intent: Intent, steamSession: Boolean = false) {
|
||||
private fun startSession(intent: Intent, steamSession: Boolean = false): Boolean {
|
||||
if (busy || LinuxRuntimeInstaller.isBusy()) return false
|
||||
refreshPhantomStatus()
|
||||
if (steamSession && PhantomProcessLimit.blocksSteam(phantomProcessStatus)) {
|
||||
showPhantomGate = true
|
||||
return
|
||||
return false
|
||||
}
|
||||
val warn = installed == null && !com.droiddeck.launcher.core.DeviceSupport.adreno()
|
||||
if (warn && available != null) showNonAdreno = available
|
||||
else startActivity(intent)
|
||||
if (warn && available != null) { showNonAdreno = available; return false }
|
||||
startActivity(intent)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun refreshPhantomStatus() {
|
||||
@@ -1283,21 +1547,35 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun runtimeChangesBlocked(): Boolean =
|
||||
busy || LinuxRuntimeInstaller.isBusy() || SessionState.running ||
|
||||
SessionState.phase !in setOf(SessionPhase.IDLE, SessionPhase.FAILED) ||
|
||||
protons.protonBusyId != null || ProtonExtras.installInProgress || pkgStage != null ||
|
||||
components.compBusy != null || saveBusy != null || com.droiddeck.launcher.store.UserAppsState.working != null
|
||||
|
||||
private fun removeRuntime() {
|
||||
if (runtimeChangesBlocked()) return
|
||||
val removal = LinuxRuntimeInstaller.beginUninstall(this) ?: return
|
||||
ready = false
|
||||
com.droiddeck.launcher.runtime.RuntimeInstallService.keepRemovalAlive(this)
|
||||
followRuntimeOperation { removal.run(it) }
|
||||
}
|
||||
|
||||
private fun install(release: LinuxRuntimeInstaller.Release) {
|
||||
// The service keeps the process alive if the user switches away; this screen joins the
|
||||
// same install (or starts it, if it gets there first) to show the progress.
|
||||
com.droiddeck.launcher.runtime.RuntimeInstallService.start(this, release)
|
||||
followInstall { listener -> LinuxRuntimeInstaller.install(this, release, listener) }
|
||||
followRuntimeOperation { listener -> LinuxRuntimeInstaller.install(this, release, listener) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows an install's progress until it ends. [run] either starts one or joins the one already
|
||||
* Shows a runtime operation's progress until it ends. [run] either starts one or joins the one already
|
||||
* running (null: nothing was), which is how a launcher rebuilt mid-install picks it back up.
|
||||
*/
|
||||
private fun followInstall(run: (LinuxRuntimeInstaller.ProgressListener) -> Boolean?) {
|
||||
private fun followRuntimeOperation(run: (LinuxRuntimeInstaller.ProgressListener) -> Boolean?) {
|
||||
busy = true
|
||||
failed = false
|
||||
stage = "Starting…"
|
||||
stage = if (LinuxRuntimeInstaller.isRemoving()) "Removing Linux runtime" else "Starting…"
|
||||
percent = -1
|
||||
Thread({
|
||||
val ok = run(LinuxRuntimeInstaller.ProgressListener { s, p ->
|
||||
@@ -1306,9 +1584,12 @@ class MainActivity : ComponentActivity() {
|
||||
ui.post {
|
||||
busy = false
|
||||
if (ok != null) failed = !ok
|
||||
if (ok == false) LinuxRuntimeInstaller.removalError()?.let {
|
||||
android.widget.Toast.makeText(this, it, android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
refresh()
|
||||
}
|
||||
}, "install").start()
|
||||
}, "runtime-operation").start()
|
||||
}
|
||||
|
||||
private fun checkCatalog() {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.droiddeck.launcher
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import android.hardware.input.InputManager
|
||||
import android.hardware.display.DisplayManager
|
||||
@@ -34,13 +36,18 @@ import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import com.droiddeck.launcher.core.TextureFiltering
|
||||
import com.droiddeck.launcher.files.InAppFilePicker
|
||||
import com.droiddeck.launcher.frontend.Library
|
||||
import com.droiddeck.launcher.gpu.FrameGen
|
||||
import com.droiddeck.launcher.gpu.ScreenEffectLooks
|
||||
import com.droiddeck.launcher.gpu.ScreenEffects
|
||||
import com.droiddeck.launcher.gpu.Lossless
|
||||
import com.droiddeck.launcher.gpu.TurnipDriver
|
||||
import com.droiddeck.launcher.input.EvdevKeys
|
||||
import com.droiddeck.launcher.input.KeyboardHost
|
||||
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.PointerGestures
|
||||
@@ -52,6 +59,8 @@ import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.session.LoadingState
|
||||
import com.droiddeck.launcher.session.PerfHud
|
||||
import com.droiddeck.launcher.session.PerfMode
|
||||
import com.droiddeck.launcher.session.SteamRepair
|
||||
import com.droiddeck.launcher.session.GameEnvironmentStore
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import com.droiddeck.launcher.session.SessionEvents
|
||||
import com.droiddeck.launcher.session.SessionArtifacts
|
||||
@@ -74,7 +83,9 @@ import com.droiddeck.launcher.wayland.CompositorHost
|
||||
import com.droiddeck.launcher.wayland.WaylandCompositor
|
||||
import java.io.File
|
||||
import com.droiddeck.launcher.session.ComponentsManager
|
||||
import com.droiddeck.launcher.ui.DRAWER_PAGES
|
||||
import com.droiddeck.launcher.ui.SessionDrawerPage
|
||||
import com.droiddeck.launcher.ui.sessionDrawerPages
|
||||
import com.droiddeck.launcher.ui.step
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
@@ -89,6 +100,10 @@ import kotlin.math.abs
|
||||
* the HUD line, the loading overlay, the drawer and its dialogs.
|
||||
*/
|
||||
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)
|
||||
private val guestKeysDown = mutableSetOf<Int>()
|
||||
private lateinit var surfaceView: SurfaceView
|
||||
private lateinit var sessionOverlay: ComposeView
|
||||
private lateinit var loading: LoadingState
|
||||
@@ -121,10 +136,16 @@ 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() {
|
||||
if (resumed && SessionState.deckPad) padMotion?.start() else padMotion?.stop()
|
||||
if (resumed && !pipUi && SessionState.deckPad) padMotion?.start() else padMotion?.stop()
|
||||
}
|
||||
private var onScreenControls: OnScreenControls? = null
|
||||
private var keyboard: KeyboardHost? = null
|
||||
private var controllerSettings by mutableStateOf<ControllerPrefs.Settings?>(null)
|
||||
private val sessionClipboard by lazy {
|
||||
SessionClipboard(this) {
|
||||
window.decorView.hasWindowFocus() || secondScreenPresentation?.window?.decorView?.hasWindowFocus() == true
|
||||
}
|
||||
}
|
||||
private var watching = true
|
||||
private lateinit var touchpad: TouchpadGestures
|
||||
private var touchMode by mutableStateOf(SessionPrefs.TOUCH_AUTO)
|
||||
@@ -140,6 +161,21 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private val cursorBuf = IntArray(WaylandCompositor.CURSOR_BUF_INTS)
|
||||
private var cursorSerial = 0
|
||||
private var clientHidesCursor = false
|
||||
/**
|
||||
* A program holds a pointer lock (zwp_locked_pointer_v1: a mouse-look game, through gamescope).
|
||||
* While it does, a physical mouse is captured and its raw deltas go to the program as relative
|
||||
* motion. Without capture the deltas are worked out from Android's pointer position, which stops
|
||||
* at the screen edge, and so did the camera (#153).
|
||||
*/
|
||||
private var pointerLocked = false
|
||||
private val pointerLockListener = WaylandCompositor.PointerLockListener { locked, _, _ ->
|
||||
uiHandler.post {
|
||||
if (pointerLocked == locked) return@post
|
||||
pointerLocked = locked
|
||||
Log.i(TAG, "pointer lock " + if (locked) "taken by a program" else "released")
|
||||
updatePointerCapture()
|
||||
}
|
||||
}
|
||||
private var cursorImage by mutableStateOf<androidx.compose.ui.graphics.ImageBitmap?>(null)
|
||||
private var cursorHotX by mutableStateOf(0)
|
||||
private var cursorHotY by mutableStateOf(0)
|
||||
@@ -187,13 +223,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private var closeAtOnce = false
|
||||
/** The drawer's Components tab: the Protons as last read (ComponentsManager). */
|
||||
private var drawerComponents by mutableStateOf<ComponentsManager.Snapshot?>(null)
|
||||
private var drawerPage by mutableIntStateOf(0)
|
||||
private var drawerPage by mutableStateOf(SessionDrawerPage.CONTROLLER)
|
||||
private var drawerControllerActive by mutableStateOf(false)
|
||||
private var backActionsInverted by mutableStateOf(false)
|
||||
/** 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)
|
||||
private var fillScreen by mutableStateOf(true)
|
||||
private var upscaler by mutableStateOf(0)
|
||||
private var upscaleSharpness by mutableStateOf(75)
|
||||
private var effects by mutableStateOf(ScreenEffects.OFF)
|
||||
private var textureAnisotropy by mutableStateOf(0)
|
||||
private var textureLodBias by mutableStateOf(TextureFiltering.LOD_BIAS_OFF)
|
||||
private var lossless by mutableStateOf(Lossless.State.NONE)
|
||||
private val pickLossless = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
|
||||
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { importLossless(it) }
|
||||
@@ -202,7 +242,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private var fexPreset by mutableStateOf("")
|
||||
private var suspendPolicy by mutableStateOf(SessionPrefs.SUSPEND_MANUAL)
|
||||
private var oscMode by mutableStateOf(SessionPrefs.OSC_AUTO)
|
||||
private var shapeMode by mutableStateOf(SessionPrefs.SHAPE_AUTO)
|
||||
private var onScreenButtonsVisible by mutableStateOf(false)
|
||||
private var secondScreenMode by mutableStateOf(SessionState.secondScreenMode)
|
||||
private var secondScreenDisplays by mutableStateOf<List<SecondScreenDisplay>>(emptyList())
|
||||
private var selectedSecondScreenDisplay by mutableStateOf(SessionState.secondScreenDisplay)
|
||||
@@ -244,26 +284,38 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
override fun onInputDeviceAdded(deviceId: Int) {
|
||||
logInputDevice("connected", deviceId)
|
||||
updateOnScreenControls()
|
||||
updatePointerCapture()
|
||||
}
|
||||
override fun onInputDeviceRemoved(deviceId: Int) {
|
||||
Log.i(TAG, "input device $deviceId disconnected")
|
||||
updateOnScreenControls()
|
||||
updatePointerCapture()
|
||||
}
|
||||
override fun onInputDeviceChanged(deviceId: Int) = updateOnScreenControls()
|
||||
}
|
||||
|
||||
private var runtimeRemovalBlocked = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
if (com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.isRemoving()) {
|
||||
runtimeRemovalBlocked = true
|
||||
android.widget.Toast.makeText(this, "Wait for runtime removal to finish", android.widget.Toast.LENGTH_SHORT).show()
|
||||
finish()
|
||||
return
|
||||
}
|
||||
if (!SessionState.running && SessionState.phase in setOf(SessionPhase.IDLE, SessionPhase.FAILED)) {
|
||||
val mode = intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM
|
||||
// A Flatpak app or AppImage is named by its arguments, which the name lookup reads here.
|
||||
SessionState.programArgs = intent.getStringArrayExtra(SessionService.EXTRA_PROGRAM_ARGS)?.toList().orEmpty()
|
||||
SessionEvents.begin(this, mode, SessionPaths.label(mode, intent.getStringExtra(SessionService.EXTRA_PROGRAM)))
|
||||
}
|
||||
SessionState.program = intent.getStringExtra(SessionService.EXTRA_PROGRAM)
|
||||
SessionState.programArgs = intent.getStringArrayExtra(SessionService.EXTRA_PROGRAM_ARGS)?.toList().orEmpty()
|
||||
SessionState.steamUi = intent.getStringExtra(SessionService.EXTRA_STEAM_UI)
|
||||
SessionState.steamUrl = intent.getStringExtra(SessionService.EXTRA_STEAM_URL)
|
||||
if (!SessionState.running && SessionState.phase in setOf(SessionPhase.IDLE, SessionPhase.FAILED)) {
|
||||
SessionState.program = intent.getStringExtra(SessionService.EXTRA_PROGRAM)
|
||||
SessionState.programArgs = intent.getStringArrayExtra(SessionService.EXTRA_PROGRAM_ARGS)?.toList().orEmpty()
|
||||
SessionState.steamUi = intent.getStringExtra(SessionService.EXTRA_STEAM_UI)
|
||||
SessionState.steamUrl = intent.getStringExtra(SessionService.EXTRA_STEAM_URL)
|
||||
}
|
||||
if (intent.action == SessionService.ACTION_AGENT_START) {
|
||||
SessionEvents.record("agent.start_requested", mapOf("mode" to SessionState.mode))
|
||||
}
|
||||
@@ -288,7 +340,16 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
// below) and the session starts when it is in.
|
||||
if (!LinuxRuntime.isInstalled(this)) Log.i(TAG, "the Linux runtime is not installed; the loading screen installs it")
|
||||
|
||||
val root = FrameLayout(this)
|
||||
// Captured mouse events go down the focus chain, and focus sits somewhere in the overlay:
|
||||
// the root sees every one of them whichever view that is.
|
||||
val root = object : FrameLayout(this) {
|
||||
override fun dispatchCapturedPointerEvent(event: MotionEvent): Boolean =
|
||||
onCapturedPointer(event) || super.dispatchCapturedPointerEvent(event)
|
||||
override fun dispatchPointerCaptureChanged(hasCapture: Boolean) {
|
||||
super.dispatchPointerCaptureChanged(hasCapture)
|
||||
Log.i(TAG, "pointer capture " + if (hasCapture) "on" else "off")
|
||||
}
|
||||
}
|
||||
surfaceView = SurfaceView(this)
|
||||
surfaceView.holder.addCallback(this)
|
||||
root.addView(surfaceView)
|
||||
@@ -308,7 +369,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
uiHandler.removeCallbacks(cursorHide)
|
||||
cursorVisible = false
|
||||
}
|
||||
onScreenControls = OnScreenControls(this, bridge).also { root.addView(it) }
|
||||
onScreenControls = OnScreenControls(this, bridge, onKeyboard = ::togglePcKeyboard).also { root.addView(it) }
|
||||
keyboard = KeyboardHost(this).also { root.addView(it) }
|
||||
touchpad = TouchpadGestures(PointerGestures.slop(this), pointerListener)
|
||||
// One arrow, ours: Android draws a system pointer for a mouse over any window, and the
|
||||
@@ -344,16 +405,21 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
sessionOverlay = createSessionOverlay()
|
||||
root.addView(sessionOverlay)
|
||||
setContentView(root)
|
||||
pip = com.droiddeck.launcher.session.SessionPipController(this, ::pipBounds, ::showPipUi)
|
||||
surfaceView.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> pip.refresh() }
|
||||
pip.refresh()
|
||||
handleHomeGuideIntent(intent)
|
||||
|
||||
updateOnScreenControls()
|
||||
WaylandCompositor.setFirstFrameListener(firstFrameListener)
|
||||
WaylandCompositor.setPointerLockListener(pointerLockListener)
|
||||
SessionEvents.markReadyIfPossible()
|
||||
SessionState.endListener = endListener
|
||||
// A single Back opens the session menu; two quick presses/swipes send the Steam QAM chord.
|
||||
// The single action waits out the double-press window so the two actions stay distinct.
|
||||
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
if (pipUi) return
|
||||
// The PC keyboard closes first (B on a controller), then Back is the drawer again.
|
||||
when {
|
||||
::loading.isInitialized && loading.ended -> finish()
|
||||
@@ -377,7 +443,18 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
// this full-screen host and remain as a white veil after the drawer closes.
|
||||
defaultFocusHighlightEnabled = false
|
||||
setContent {
|
||||
// The drawer, the PC keyboard and picture in picture take the mouse back.
|
||||
androidx.compose.runtime.LaunchedEffect(drawerOpen, pcKeyboardOpen, pipUi) { updatePointerCapture() }
|
||||
DroidDeckTheme {
|
||||
if (pipUi) {
|
||||
if (SessionState.suspended) androidx.compose.foundation.layout.Box(
|
||||
Modifier.fillMaxSize(), contentAlignment = androidx.compose.ui.Alignment.Center,
|
||||
) {
|
||||
androidx.compose.material3.Text(getString(R.string.pip_suspended),
|
||||
color = androidx.compose.ui.graphics.Color.White,
|
||||
modifier = Modifier.background(androidx.compose.ui.graphics.Color.Black.copy(alpha = 0.7f)).padding(6.dp))
|
||||
}
|
||||
} else {
|
||||
com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize()) {
|
||||
CursorOverlay(cursorPos, cursorVisible, resources.displayMetrics.density,
|
||||
cursorImage, cursorHotX, cursorHotY, cursorImageScale)
|
||||
@@ -412,7 +489,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
// The PC keyboard takes it too, for moving over the keys; the drawer opens over it.
|
||||
androidx.compose.runtime.LaunchedEffect(pcKeyboardOpen) { if (pcKeyboardOpen) padBridge?.releaseAll() }
|
||||
if (pcKeyboardOpen) com.droiddeck.launcher.ui.PcKeyboard(
|
||||
sendKey = { code, down -> if (CompositorHost.isStarted) WaylandCompositor.nativeSendKey(code, if (down) 1 else 0) },
|
||||
sendKey = { code, down -> if (CompositorHost.isStarted) sendGuestKey(code, down) },
|
||||
onAndroidKeyboard = { pcKeyboardOpen = false; keyboard?.toggle() },
|
||||
onClose = { pcKeyboardOpen = false },
|
||||
)
|
||||
@@ -423,17 +500,33 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
isHomeApp = isHomeApp,
|
||||
androidApps = androidApps,
|
||||
hudOn = hudOn,
|
||||
fillScreen = if (SessionState.mode == SessionService.MODE_STEAM) fillScreen else null,
|
||||
upscaler = upscaler, upscaleSharpness = upscaleSharpness,
|
||||
effects = effects, textureAnisotropy = textureAnisotropy, textureLodBias = textureLodBias,
|
||||
frameGen = frameGen,
|
||||
lossless = lossless,
|
||||
oscMode = oscMode, suspendPolicy = suspendPolicy, touchMode = touchMode,
|
||||
oscMode = oscMode, onScreenButtonsVisible = onScreenButtonsVisible, suspendPolicy = suspendPolicy, touchMode = touchMode,
|
||||
touchAuto = if (usingTouchpad()) "touchpad" else "direct",
|
||||
shapeMode = shapeMode, fexPreset = fexPreset,
|
||||
fexPreset = fexPreset,
|
||||
secondScreenMode = secondScreenMode,
|
||||
secondScreenDisplays = secondScreenDisplays,
|
||||
selectedSecondScreenDisplay = selectedSecondScreenDisplay,
|
||||
onHud = { on -> SessionPrefs.setHudEnabled(this@SessionActivity, on); hudOn = on; hud.refresh() },
|
||||
onFillScreen = { on -> SessionPrefs.setForceFullscreen(this@SessionActivity, on); fillScreen = on },
|
||||
onUpscaler = { m ->
|
||||
SessionPrefs.setUpscaler(this@SessionActivity, m); upscaler = m
|
||||
WaylandCompositor.nativeSetUpscaler(m)
|
||||
// Two Looks carry a scaling mode of their own, so the mode decides which one the log names.
|
||||
WaylandCompositor.nativeSetLookName(ScreenEffectLooks.match(effects, m)?.name)
|
||||
},
|
||||
onUpscaleSharpness = { pct ->
|
||||
SessionPrefs.setUpscaleSharpness(this@SessionActivity, pct); upscaleSharpness = pct
|
||||
WaylandCompositor.nativeSetUpscaleSharpness(pct)
|
||||
},
|
||||
onEffects = { e ->
|
||||
SessionPrefs.setScreenEffects(this@SessionActivity, e); effects = e
|
||||
e.push(upscaler)
|
||||
},
|
||||
onTextureAnisotropy = { v -> SessionPrefs.setTextureAnisotropy(this@SessionActivity, v); textureAnisotropy = v },
|
||||
onTextureLodBias = { v -> SessionPrefs.setTextureLodBias(this@SessionActivity, v); textureLodBias = v },
|
||||
onFrameGenPick = { mode ->
|
||||
FrameGen.set(this@SessionActivity, mode)
|
||||
readPrefs()
|
||||
@@ -444,19 +537,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
this@SessionActivity, listOf("dll"), getString(R.string.lsfg_pick_title)))
|
||||
},
|
||||
onKeyboard = { drawerOpen = false; pcKeyboardOpen = false; keyboard?.toggle() },
|
||||
onHardwareKeyboard = { drawerOpen = false; pcKeyboardOpen = !pcKeyboardOpen },
|
||||
onHardwareKeyboard = ::togglePcKeyboard,
|
||||
controller = controllerSettings,
|
||||
onRumble = { on -> updateControllerPrefs { ControllerPrefs.setRumble(this@SessionActivity, on) } },
|
||||
onSteamButton = { on -> updateControllerPrefs { ControllerPrefs.setSteamButton(this@SessionActivity, on) } },
|
||||
onQamButton = { on -> updateControllerPrefs { ControllerPrefs.setQamButton(this@SessionActivity, on) } },
|
||||
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,
|
||||
// The desktop with Steam's desktop client in it, in this session's place: the
|
||||
// session ends with status 0 and onSessionEnded starts the relaunch (Stop's
|
||||
// finish() would skip it).
|
||||
onSwitchToDesktop = if (SessionState.mode == SessionService.MODE_STEAM) ({
|
||||
drawerOpen = false
|
||||
SessionState.relaunch = Intent(this@SessionActivity, SessionActivity::class.java)
|
||||
.putExtra(SessionService.EXTRA_MODE, SessionService.MODE_DESKTOP)
|
||||
.putExtra(SessionService.EXTRA_STEAM_UI, "desktop")
|
||||
SessionService.stop(this@SessionActivity)
|
||||
}) else null,
|
||||
backActionsInverted = backActionsInverted,
|
||||
onBackActionsInverted = { inverted ->
|
||||
SessionPrefs.setBackActionsInverted(this@SessionActivity, inverted)
|
||||
@@ -469,7 +557,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
SessionService.suspendPolicyChanged(this@SessionActivity)
|
||||
},
|
||||
onTouch = { v -> SessionPrefs.setTouchMode(this@SessionActivity, v); readPrefs() },
|
||||
onShape = { v -> SessionPrefs.setShapeMode(this@SessionActivity, v); readPrefs() },
|
||||
onFexPreset = { v -> SessionPrefs.setFexPreset(this@SessionActivity, v); readPrefs() },
|
||||
onSecondScreenMode = ::selectSecondScreenMode,
|
||||
onSecondScreenDisplay = ::selectSecondScreenDisplay,
|
||||
@@ -483,7 +570,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
Toast.makeText(this@SessionActivity, "Could not open ${app.label} on $target", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
onBackground = { drawerOpen = false; moveTaskToBack(true) },
|
||||
pipSupported = com.droiddeck.launcher.session.SessionPipController.supported(this@SessionActivity),
|
||||
pipAutoEnter = pipAutoEnter,
|
||||
onPip = { pip.enter() },
|
||||
onPipAutoEnter = { on ->
|
||||
SessionPrefs.setPipAutoEnter(this@SessionActivity, on)
|
||||
pipAutoEnter = on
|
||||
pip.refresh(true)
|
||||
},
|
||||
onBackground = { drawerOpen = false; pip.background(); moveTaskToBack(true) },
|
||||
onShareLogs = { drawerOpen = false; shareCurrentSessionLogs() },
|
||||
onStop = { drawerOpen = false; SessionService.stop(this@SessionActivity); finish() },
|
||||
onStopFrom = { onScreen ->
|
||||
@@ -519,6 +614,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshHomeApp() {
|
||||
@@ -539,6 +635,19 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
padBridge?.triggerQam()
|
||||
}
|
||||
|
||||
private fun togglePcKeyboard() {
|
||||
drawerOpen = false
|
||||
keyboard?.hide()
|
||||
onScreenControls?.releaseAll()
|
||||
pcKeyboardOpen = !pcKeyboardOpen
|
||||
}
|
||||
|
||||
private fun updateControllerPrefs(change: () -> Unit) {
|
||||
change()
|
||||
controllerSettings = ControllerPrefs.read(this)
|
||||
onScreenControls?.reload()
|
||||
}
|
||||
|
||||
private fun routeBackAction() {
|
||||
pendingBackAction?.let { pending ->
|
||||
uiHandler.removeCallbacks(pending)
|
||||
@@ -551,8 +660,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
performBackAction(double = false)
|
||||
}
|
||||
pendingBackAction = pending
|
||||
// Android's own double-tap window (300 ms): the single action waits it out, so it is felt.
|
||||
uiHandler.postDelayed(pending, android.view.ViewConfiguration.getDoubleTapTimeout().toLong())
|
||||
// Wait 500 ms for a second Back press before performing the single-press action.
|
||||
uiHandler.postDelayed(pending, 500L)
|
||||
}
|
||||
|
||||
private fun performBackAction(double: Boolean) {
|
||||
@@ -632,33 +741,43 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
Toast.makeText(this, "No logs for this session.", Toast.LENGTH_LONG).show()
|
||||
return
|
||||
}
|
||||
Thread({
|
||||
val zip = runCatching { SessionLogShare.zipFolder(this, folder) }
|
||||
.onFailure { Log.w(TAG, "could not package current session logs", it) }
|
||||
.getOrNull()
|
||||
uiHandler.post {
|
||||
if (zip == null) {
|
||||
Toast.makeText(this, "Could not create the session log archive.", Toast.LENGTH_LONG).show()
|
||||
} else {
|
||||
runCatching { startActivity(SessionLogShare.shareIntent(this, zip)) }
|
||||
.onFailure {
|
||||
Log.w(TAG, "could not share current session logs", it)
|
||||
Toast.makeText(this, "Could not share the session logs.", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
SessionLogShare.prepare(this, { folder }) { zip ->
|
||||
if (zip == null) {
|
||||
Toast.makeText(this, "Could not create the session log archive.", Toast.LENGTH_LONG).show()
|
||||
} else {
|
||||
runCatching { startActivity(SessionLogShare.shareIntent(this, zip)) }
|
||||
.onFailure {
|
||||
Log.w(TAG, "could not share current session logs", it)
|
||||
Toast.makeText(this, "Could not share the session logs.", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}, "share-session-logs").start()
|
||||
}
|
||||
}
|
||||
|
||||
private fun readPrefs() {
|
||||
pipAutoEnter = SessionPrefs.pipAutoEnter(this)
|
||||
hudOn = SessionPrefs.hudEnabled(this)
|
||||
fillScreen = SessionPrefs.forceFullscreen(this)
|
||||
touchMode = SessionPrefs.touchMode(this)
|
||||
upscaler = SessionPrefs.upscaler(this)
|
||||
upscaleSharpness = SessionPrefs.upscaleSharpness(this)
|
||||
effects = SessionPrefs.screenEffects(this)
|
||||
textureAnisotropy = SessionPrefs.textureAnisotropy(this)
|
||||
textureLodBias = SessionPrefs.textureLodBias(this)
|
||||
val nextTouchMode = SessionPrefs.touchMode(this)
|
||||
if (touchMode != nextTouchMode) {
|
||||
if (::touchpad.isInitialized) touchpad.cancel()
|
||||
if (CompositorHost.isStarted) {
|
||||
WaylandCompositor.nativeSendTouch(3, -1, 0, 0)
|
||||
listOf(PointerGestures.BTN_LEFT, PointerGestures.BTN_RIGHT, PointerGestures.BTN_MIDDLE).forEach {
|
||||
WaylandCompositor.nativeSendSceneInput(3, it, 0)
|
||||
}
|
||||
}
|
||||
if (nextTouchMode == SessionPrefs.TOUCH_OFF) cursorVisible = false
|
||||
}
|
||||
touchMode = nextTouchMode
|
||||
frameGen = FrameGen.mode(this)
|
||||
fexPreset = SessionPrefs.fexPreset(this)
|
||||
suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode)
|
||||
oscMode = SessionPrefs.oscMode(this)
|
||||
shapeMode = SessionPrefs.shapeMode(this)
|
||||
backActionsInverted = SessionPrefs.backActionsInverted(this)
|
||||
}
|
||||
|
||||
@@ -762,7 +881,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
?: return "Could not reach the runtime catalog. Check the connection and press Play again."
|
||||
val ok = com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.install(this, release,
|
||||
progressFor("the Linux runtime", release.size / 1_000_000))
|
||||
return if (ok) null else "The Linux runtime did not install. Check the connection and press Play again."
|
||||
return if (ok) null else "The Linux runtime did not install. Check the runtime in Setup and press Play again."
|
||||
}
|
||||
|
||||
/** Null when the desktop package is in, else the loading screen's closing line. */
|
||||
@@ -806,9 +925,20 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
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):
|
||||
// the game environment is published again so the next launch derives it from this session.
|
||||
val panel = panelBounds()
|
||||
SessionState.upscaleRatio = maxOf(panel.width(), panel.height()).toFloat() / maxOf(size.first, size.second).coerceAtLeast(1)
|
||||
Thread({
|
||||
runCatching { GameEnvironmentStore.publish(this) }
|
||||
.onFailure { Log.e(TAG, "Could not update game environment", it) }
|
||||
}, "game-env-publish").start()
|
||||
// Letterbox, never stretch or crop: the output can be a different shape from the panel,
|
||||
// and a game's picture must keep its proportions with bars, not lose its edges.
|
||||
WaylandCompositor.nativeSetScaleMode(SCALE_FIT, ALIGN_CENTER)
|
||||
WaylandCompositor.nativeSetUpscaler(SessionPrefs.upscaler(this))
|
||||
WaylandCompositor.nativeSetUpscaleSharpness(SessionPrefs.upscaleSharpness(this))
|
||||
SessionPrefs.screenEffects(this).push(SessionPrefs.upscaler(this))
|
||||
// The session's folder, claimed here because the compositor starts before the service and
|
||||
// opens its log once. The compositor reads the path from its environment; setting it after
|
||||
// it has started changes nothing, which is why the service copies the file in at teardown.
|
||||
@@ -836,7 +966,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
val on = wanted && probe.reason == null
|
||||
SessionState.hdr = on
|
||||
if (on) {
|
||||
try { android.system.Os.setenv("BANNER_WAYLAND_HDR", "1", true) } catch (e: Exception) { Log.w(TAG, "BANNER_WAYLAND_HDR", e) }
|
||||
try { android.system.Os.setenv("DROIDDECK_WAYLAND_HDR", "1", true) } catch (e: Exception) { Log.w(TAG, "DROIDDECK_WAYLAND_HDR", e) }
|
||||
WaylandCompositor.nativeSetZeroCopy(true)
|
||||
}
|
||||
WaylandCompositor.nativeSetHdrRequest(
|
||||
@@ -857,7 +987,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
// whole process, so the second Play after a session ended used to re-attach the Surface,
|
||||
// start nothing, and leave the loading panel counting up over a dead session.
|
||||
if (!SessionState.running) {
|
||||
CompositorHost.newSession()
|
||||
SessionEvents.transition(SessionPhase.STARTING_GUEST, "guest.starting")
|
||||
SessionService.start(
|
||||
this, intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM,
|
||||
@@ -893,48 +1022,26 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
* The size the session renders at. gamescope is told this and scales its output onto whatever
|
||||
* the panel is, so a 1440p phone can run the client at 1080p without the client knowing.
|
||||
*/
|
||||
/** The panel, not the window: resources.displayMetrics is what is left after the system bars and the cutout are taken out. */
|
||||
private fun panelBounds(): android.graphics.Rect = if (Build.VERSION.SDK_INT >= 30) {
|
||||
windowManager.maximumWindowMetrics.bounds
|
||||
} else {
|
||||
val metrics = android.util.DisplayMetrics()
|
||||
@Suppress("DEPRECATION")
|
||||
windowManager.defaultDisplay.getRealMetrics(metrics)
|
||||
android.graphics.Rect(0, 0, metrics.widthPixels, metrics.heightPixels)
|
||||
}
|
||||
|
||||
private fun outputSize(): Pair<Int, Int> {
|
||||
// The panel, not the window: resources.displayMetrics is what is left after the system
|
||||
// bars and the cutout are taken out.
|
||||
val bounds = if (Build.VERSION.SDK_INT >= 30) {
|
||||
windowManager.maximumWindowMetrics.bounds
|
||||
} else {
|
||||
val metrics = android.util.DisplayMetrics()
|
||||
@Suppress("DEPRECATION")
|
||||
windowManager.defaultDisplay.getRealMetrics(metrics)
|
||||
android.graphics.Rect(0, 0, metrics.widthPixels, metrics.heightPixels)
|
||||
}
|
||||
val panelW = maxOf(bounds.width(), bounds.height()).toFloat()
|
||||
val panelH = minOf(bounds.width(), bounds.height()).toFloat()
|
||||
// Never narrower than 16:9. A foldable's inner panel is nearly square, and a game handed a
|
||||
// square display draws for the frame it was made for and cuts the sides off itself.
|
||||
// Wider than 16:9 is fine - games and the client cope with a phone's 20:9 - so the
|
||||
// panel's aspect is kept above that, unless the user pinned 16:9 for a foldable, and the
|
||||
// compositor letterboxes onto a squarer panel.
|
||||
// "Match screen" drops that floor, for a 4:3 or 3:2 handheld whose games should
|
||||
// fill it.
|
||||
val aspect = when (SessionPrefs.shapeMode(this)) {
|
||||
SessionPrefs.SHAPE_WIDE -> 16f / 9f
|
||||
SessionPrefs.SHAPE_EXACT -> panelW / panelH
|
||||
else -> maxOf(panelW / panelH, 16f / 9f)
|
||||
}
|
||||
// 720 tall at most by default, client and desktop alike: the client's CEF is the heaviest
|
||||
// thing in the session, and pixels above that cost frames for nothing anyone can see on a
|
||||
// handheld panel. The mode's settings (the cog beside Play / Desktop) can change
|
||||
// the cap or lift it to the panel.
|
||||
val mode = SessionPrefs.prefMode(intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM)
|
||||
// A custom resolution is taken as given; the compositor fits it to the panel.
|
||||
SessionPrefs.customResolution(this, mode)?.let { return it }
|
||||
// An emulator whose frames cost next to nothing (Library.drawsAtPanel) gets the panel,
|
||||
// unless the user chose a resolution for the mode.
|
||||
val panel = com.droiddeck.launcher.session.SessionDisplay.panelSize(this)
|
||||
val program = intent.getStringExtra(SessionService.EXTRA_PROGRAM)
|
||||
val cap = if (intent.getStringExtra(SessionService.EXTRA_MODE) == SessionService.MODE_RUN &&
|
||||
Library.drawsAtPanel(program) && !SessionPrefs.resolutionChosen(this, mode)) 0
|
||||
else SessionPrefs.resolutionCap(this, mode)
|
||||
val height = (if (cap <= 0) panelH else minOf(panelH, cap.toFloat())).toInt()
|
||||
val width = (height * aspect).toInt()
|
||||
// Odd sizes upset the scaler; both dimensions even is what every mode here would be.
|
||||
return Pair(width and 1.inv(), height and 1.inv())
|
||||
// Lightweight emulators keep their panel-sized default until a resolution is chosen.
|
||||
val choice = if (intent.getStringExtra(SessionService.EXTRA_MODE) == SessionService.MODE_RUN &&
|
||||
Library.drawsAtPanel(program) && !SessionPrefs.resolutionChosen(this, mode))
|
||||
com.droiddeck.launcher.session.SessionDisplay.MATCH_SCREEN
|
||||
else SessionPrefs.resolutionChoice(this, mode, panel)
|
||||
return com.droiddeck.launcher.session.SessionDisplay.resolveChoice(panel, choice)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -996,6 +1103,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
val poll = object : Runnable {
|
||||
override fun run() {
|
||||
if (!watching) return
|
||||
if (::pip.isInitialized) pip.refresh()
|
||||
if (SessionState.stopRequested && !SessionState.running && !installingRuntime) {
|
||||
SessionState.stopRequested = false
|
||||
SessionEvents.transition(SessionPhase.IDLE, "session.cancelled")
|
||||
@@ -1014,16 +1122,21 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
|
||||
private fun onSessionEnded(status: Int) {
|
||||
if (isInPictureInPictureMode) {
|
||||
runOnUiThread { finish() }
|
||||
return
|
||||
}
|
||||
closeSecondScreen(reset = true)
|
||||
if (status == 0) {
|
||||
runOnUiThread {
|
||||
if (isFinishing || isDestroyed) return@runOnUiThread
|
||||
// A session asked for in this one's place (the desktop's Steam launchers, the
|
||||
// drawer's DESKTOP) starts here: this activity restarts on its intent, as an agent
|
||||
// A session asked for in this one's place (the desktop's Steam launchers or
|
||||
// Steam's Switch to Desktop) starts here: this activity restarts on its intent, as an agent
|
||||
// start does. startActivity(next) could not do it - the activity is singleTop, so
|
||||
// the request landed in this instance's onNewIntent and went with its finish().
|
||||
SessionState.relaunch?.let { next ->
|
||||
SessionState.relaunch = null
|
||||
next.putExtra(EXTRA_RETURN_HOME, intent.getBooleanExtra(EXTRA_RETURN_HOME, false))
|
||||
setIntent(next)
|
||||
recreate()
|
||||
return@runOnUiThread
|
||||
@@ -1042,8 +1155,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}, "session-end-hint").start()
|
||||
}
|
||||
|
||||
private fun showEnded(status: Int, hint: String?) {
|
||||
if (status == 0) {
|
||||
private fun showEnded(status: Int, hint: EndHint?) {
|
||||
if (status == 0 && hint?.evenOnSuccess != true) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
@@ -1054,7 +1167,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
else -> "Steam"
|
||||
}
|
||||
loading.showEnded(
|
||||
hint ?: "$what stopped unexpectedly. Share the logs with a bug report, or try again.",
|
||||
hint?.text ?: "$what stopped unexpectedly. Share the logs with a bug report, or try again.",
|
||||
"Exit status $status · ${SessionState.logFile?.path ?: "no log"}",
|
||||
)
|
||||
focusEndedScreen()
|
||||
@@ -1082,10 +1195,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
* FEX) on some devices (a Fold 5, twice). The switch that answers it is in Performance, and a
|
||||
* user who never opens the log would not know.
|
||||
*/
|
||||
private fun sessionEndHint(log: File?): String? {
|
||||
private class EndHint(val text: String, val evenOnSuccess: Boolean = false)
|
||||
|
||||
private fun sessionEndHint(log: File?): EndHint? {
|
||||
if (log == null || !log.isFile) return null
|
||||
return try {
|
||||
var enosys = 0
|
||||
var fexMissing = false
|
||||
// The tail is where a dying session says why; 512 KB covers the storm without reading a 1 GB log.
|
||||
val size = log.length()
|
||||
java.io.RandomAccessFile(log, "r").use { f ->
|
||||
@@ -1095,11 +1211,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
f.readFully(bytes)
|
||||
String(bytes, Charsets.ISO_8859_1).lineSequence().forEach { line ->
|
||||
if (line.contains("Function not implemented")) enosys++
|
||||
if (line.contains(FEX_MISSING)) fexMissing = true
|
||||
}
|
||||
}
|
||||
if (enosys >= 8) {
|
||||
"The log shows $enosys \"Function not implemented\" errors: proot's seccomp acceleration is failing a helper on this device. " +
|
||||
"Try Performance \u2192 \"Run proot without seccomp\" (or \"Skip Steam's xalia helper\") and start again."
|
||||
if (fexMissing) {
|
||||
val what = com.droiddeck.launcher.frontend.Library.nameForProgram(SessionState.program) ?: getString(R.string.session_end_program)
|
||||
EndHint(getString(R.string.session_end_fex_missing, what), evenOnSuccess = true)
|
||||
} else if (enosys >= 8) {
|
||||
EndHint("The log shows $enosys \"Function not implemented\" errors: proot's seccomp acceleration is failing a helper on this device. " +
|
||||
"Try Performance \u2192 \"Run proot without seccomp\" (or \"Skip Steam's xalia helper\") and start again.")
|
||||
} else null
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
@@ -1115,6 +1235,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
KeyEvent.KEYCODE_VOLUME_UP, KeyEvent.KEYCODE_VOLUME_DOWN, KeyEvent.KEYCODE_VOLUME_MUTE ->
|
||||
return super.dispatchKeyEvent(event)
|
||||
}
|
||||
if (pipUi) return true
|
||||
val fromController = event.device != null && PadBridge.isFromController(event.device)
|
||||
if (fromController && event.action == KeyEvent.ACTION_DOWN) {
|
||||
if (drawerOpen && !drawerControllerActive) sessionOverlay.requestFocus()
|
||||
@@ -1139,7 +1260,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
if (drawerOpen) {
|
||||
if (fromController && (event.keyCode == KeyEvent.KEYCODE_BUTTON_L1 || event.keyCode == KeyEvent.KEYCODE_BUTTON_R1)) {
|
||||
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
|
||||
drawerPage = (drawerPage + if (event.keyCode == KeyEvent.KEYCODE_BUTTON_R1) 1 else DRAWER_PAGES - 1) % DRAWER_PAGES
|
||||
drawerPage = sessionDrawerPages(SessionState.mode == SessionService.MODE_STEAM && secondScreenDisplays.isNotEmpty())
|
||||
.step(drawerPage, if (event.keyCode == KeyEvent.KEYCODE_BUTTON_R1) 1 else -1)
|
||||
releaseDrawerDirection()
|
||||
}
|
||||
return true
|
||||
@@ -1154,7 +1276,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
return true
|
||||
}
|
||||
val resumeKey = event.keyCode == KeyEvent.KEYCODE_BUTTON_A || event.keyCode == KeyEvent.KEYCODE_BUTTON_START
|
||||
val resumeKey = event.keyCode == KeyEvent.KEYCODE_BUTTON_A || event.keyCode == KeyEvent.KEYCODE_BUTTON_START ||
|
||||
event.keyCode == KeyEvent.KEYCODE_BUTTON_MODE || event.keyCode == KeyEvent.KEYCODE_HOME
|
||||
val resumeKeyId = event.deviceId to event.keyCode
|
||||
if (fromController && resumeKey && (SessionState.suspended || resumeKeyId in resumeKeysDown)) {
|
||||
if (event.action == KeyEvent.ACTION_DOWN) {
|
||||
@@ -1193,11 +1316,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
if (evdev <= 0 && event.scanCode > 0) evdev = event.scanCode
|
||||
if (evdev > 0) {
|
||||
if (down) {
|
||||
if (shiftEvdev != 0) WaylandCompositor.nativeSendKey(shiftEvdev, 1)
|
||||
WaylandCompositor.nativeSendKey(evdev, 1)
|
||||
if (shiftEvdev != 0) sendGuestKey(shiftEvdev, true)
|
||||
sendGuestKey(evdev, true)
|
||||
} else {
|
||||
WaylandCompositor.nativeSendKey(evdev, 0)
|
||||
if (shiftEvdev != 0) WaylandCompositor.nativeSendKey(shiftEvdev, 0)
|
||||
sendGuestKey(evdev, false)
|
||||
if (shiftEvdev != 0) sendGuestKey(shiftEvdev, false)
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -1207,6 +1330,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
|
||||
override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
|
||||
if (pipUi) return true
|
||||
if (drawerOpen && event.device != null && PadBridge.isFromController(event.device)) {
|
||||
if (!drawerControllerActive) sessionOverlay.requestFocus()
|
||||
drawerControllerActive = true
|
||||
@@ -1224,7 +1348,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
return super.dispatchGenericMotionEvent(event)
|
||||
}
|
||||
|
||||
/** With no view focused, captured mouse events skip the root and arrive here as trackball events. */
|
||||
override fun dispatchTrackballEvent(event: MotionEvent): Boolean =
|
||||
onCapturedPointer(event) || super.dispatchTrackballEvent(event)
|
||||
|
||||
override fun dispatchTouchEvent(event: MotionEvent): Boolean {
|
||||
if (pipUi) return true
|
||||
if (event.actionMasked == MotionEvent.ACTION_DOWN && event.isFromSource(InputDevice.SOURCE_TOUCHSCREEN)) {
|
||||
drawerControllerActive = false
|
||||
}
|
||||
@@ -1426,6 +1555,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
|
||||
private fun showSecondScreen(mode: SecondScreenMode) {
|
||||
if (pipUi || !started) return
|
||||
val target = displayManager.getDisplay(selectedSecondScreenDisplay)
|
||||
if (target == null || !target.isValid || (target.flags and Display.FLAG_PRESENTATION) == 0) {
|
||||
// Not there right now - a panel still waking, say. The choice is kept; the display
|
||||
@@ -1511,6 +1641,8 @@ 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 (touchMode == SessionPrefs.TOUCH_OFF) return true
|
||||
if (usingTouchpad()) {
|
||||
val rect = drawnRect() ?: return false
|
||||
if (touchpad.bounds != rect) {
|
||||
@@ -1531,6 +1663,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
// the compositor still holds (an up lost to the drawer opening mid-touch) goes first.
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
WaylandCompositor.nativeSendTouch(3, -1, 0, 0)
|
||||
listOf(PointerGestures.BTN_LEFT, PointerGestures.BTN_RIGHT, PointerGestures.BTN_MIDDLE).forEach {
|
||||
WaylandCompositor.nativeSendSceneInput(3, it, 0)
|
||||
}
|
||||
sendTouch(0, event.actionIndex)
|
||||
}
|
||||
MotionEvent.ACTION_POINTER_DOWN -> sendTouch(0, event.actionIndex)
|
||||
@@ -1567,6 +1702,55 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
return true
|
||||
}
|
||||
|
||||
/** A physical mouse, not a touchscreen, stylus or the system's own virtual devices. */
|
||||
private fun mouseAttached(): Boolean = InputDevice.getDeviceIds().any { id ->
|
||||
val d = InputDevice.getDevice(id)
|
||||
d != null && !d.isVirtual && d.supportsSource(InputDevice.SOURCE_MOUSE)
|
||||
}
|
||||
|
||||
/** Capture the mouse exactly while a program holds a lock and the session has the mouse. */
|
||||
private fun updatePointerCapture() {
|
||||
if (!::surfaceView.isInitialized) return
|
||||
val view = window.decorView
|
||||
val want = pointerLocked && resumed && hasWindowFocus() && !drawerOpen && !pcKeyboardOpen && !pipUi && mouseAttached()
|
||||
if (want == view.hasPointerCapture()) return
|
||||
if (want) view.requestPointerCapture() else view.releasePointerCapture()
|
||||
}
|
||||
|
||||
/**
|
||||
* A captured mouse: raw motion as relative pointer deltas, buttons and the wheel as on the
|
||||
* uncaptured path. Anything else (a captured touchpad's absolute fingers) is left alone.
|
||||
*/
|
||||
private fun onCapturedPointer(event: MotionEvent): Boolean {
|
||||
if (!pointerLocked || !event.isFromSource(InputDevice.SOURCE_MOUSE_RELATIVE)) return false
|
||||
when (event.actionMasked) {
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
var dx = event.x
|
||||
var dy = event.y
|
||||
for (i in 0 until event.historySize) {
|
||||
dx += event.getHistoricalX(i)
|
||||
dy += event.getHistoricalY(i)
|
||||
}
|
||||
WaylandCompositor.sendPointerDelta(dx, dy)
|
||||
}
|
||||
MotionEvent.ACTION_BUTTON_PRESS, MotionEvent.ACTION_BUTTON_RELEASE -> {
|
||||
val button = when (event.actionButton) {
|
||||
MotionEvent.BUTTON_SECONDARY -> PointerGestures.BTN_RIGHT
|
||||
MotionEvent.BUTTON_TERTIARY -> PointerGestures.BTN_MIDDLE
|
||||
else -> PointerGestures.BTN_LEFT
|
||||
}
|
||||
WaylandCompositor.nativeSendSceneInput(3, button, if (event.actionMasked == MotionEvent.ACTION_BUTTON_PRESS) 1 else 0)
|
||||
}
|
||||
MotionEvent.ACTION_SCROLL -> {
|
||||
val v = event.getAxisValue(MotionEvent.AXIS_VSCROLL)
|
||||
if (v != 0f) WaylandCompositor.nativeSendSceneInput(4, -Math.round(v), 0)
|
||||
}
|
||||
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_UP -> {} // the BUTTON_* events carry these
|
||||
else -> return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** The mode decides which controls appear; `droiddeck-osc` in Downloads still overrides. */
|
||||
/** Which controller the session has, so a pad report says what was plugged in and when. */
|
||||
private fun logInputDevice(what: String, deviceId: Int) {
|
||||
@@ -1582,6 +1766,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
?.let { FileUtils.readString(it)?.trim()?.lowercase() }
|
||||
?: SessionPrefs.oscMode(this)
|
||||
val controls = onScreenControls ?: return
|
||||
if (pipUi) {
|
||||
onScreenButtonsVisible = false
|
||||
controls.releaseAll()
|
||||
controls.visibility = View.GONE
|
||||
return
|
||||
}
|
||||
val buttonsOnly = forced == SessionPrefs.OSC_STEAM_QAM
|
||||
controls.setButtonsOnly(buttonsOnly)
|
||||
val show = when (forced) {
|
||||
@@ -1593,6 +1783,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
// started from the rail, or Steam, gets it; the drawer turns it on anywhere.
|
||||
else -> !PadBridge.anyControllerConnected() && SessionState.mode != SessionService.MODE_DESKTOP
|
||||
}
|
||||
onScreenButtonsVisible = show
|
||||
if (show == (controls.visibility == View.VISIBLE)) return
|
||||
if (!show) controls.releaseAll()
|
||||
controls.visibility = if (show) View.VISIBLE else View.GONE
|
||||
@@ -1603,10 +1794,81 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
})
|
||||
}
|
||||
|
||||
private fun sendGuestKey(code: Int, down: Boolean) {
|
||||
if (down && pipUi) return
|
||||
if (down) guestKeysDown.add(code) else guestKeysDown.remove(code)
|
||||
WaylandCompositor.nativeSendKey(code, if (down) 1 else 0)
|
||||
}
|
||||
|
||||
private fun pipBounds(): android.graphics.Rect {
|
||||
val rect = android.graphics.Rect()
|
||||
if (!::surfaceView.isInitialized || !surfaceView.getGlobalVisibleRect(rect)) return rect
|
||||
val (w, h) = SessionState.outputSize
|
||||
if (w <= 0 || h <= 0) return rect
|
||||
val scale = minOf(rect.width().toFloat() / w, rect.height().toFloat() / h)
|
||||
val fittedW = (w * scale).toInt()
|
||||
val fittedH = (h * scale).toInt()
|
||||
rect.inset((rect.width() - fittedW) / 2, (rect.height() - fittedH) / 2)
|
||||
return rect
|
||||
}
|
||||
|
||||
private fun showPipUi(show: Boolean) {
|
||||
pipUi = show
|
||||
if (show) {
|
||||
drawerOpen = false
|
||||
pcKeyboardOpen = false
|
||||
pendingBackAction?.let(uiHandler::removeCallbacks)
|
||||
pendingBackAction = null
|
||||
releaseDrawerDirection()
|
||||
onScreenControls?.releaseAll()
|
||||
padBridge?.releaseAll()
|
||||
touchpad.cancel()
|
||||
guestKeysDown.toList().forEach { sendGuestKey(it, false) }
|
||||
keyboard?.hide()
|
||||
sessionClipboard.stop()
|
||||
padMotion?.stop()
|
||||
cursorVisible = false
|
||||
closeSecondScreen(reset = false)
|
||||
onScreenControls?.visibility = View.GONE
|
||||
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
if (CompositorHost.isStarted) {
|
||||
WaylandCompositor.nativeSendTouch(3, -1, 0, 0)
|
||||
listOf(PointerGestures.BTN_LEFT, PointerGestures.BTN_RIGHT, PointerGestures.BTN_MIDDLE).forEach {
|
||||
WaylandCompositor.nativeSendSceneInput(3, it, 0)
|
||||
}
|
||||
WaylandCompositor.nativeSetScaleMode(SCALE_FIT, ALIGN_CENTER)
|
||||
}
|
||||
} else if (!isFinishing && !isDestroyed) {
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
updateOnScreenControls()
|
||||
if (resumed) { sessionClipboard.start(); updatePadMotion() }
|
||||
if (started) refreshSecondScreenDisplays()
|
||||
if (CompositorHost.isStarted) applyFrameGen()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onUserLeaveHint() {
|
||||
if (::pip.isInitialized) pip.leaving()
|
||||
super.onUserLeaveHint()
|
||||
}
|
||||
|
||||
override fun onPictureInPictureModeChanged(inPip: Boolean, newConfig: android.content.res.Configuration) {
|
||||
super.onPictureInPictureModeChanged(inPip, newConfig)
|
||||
if (::pip.isInitialized) pip.changed(inPip)
|
||||
}
|
||||
|
||||
override fun startActivityForResult(intent: Intent, requestCode: Int, options: Bundle?) {
|
||||
if (::pip.isInitialized && (requestCode >= 0 || intent.component?.packageName == packageName ||
|
||||
intent.action in setOf(Intent.ACTION_CHOOSER, Intent.ACTION_SEND, Intent.ACTION_OPEN_DOCUMENT,
|
||||
Intent.ACTION_GET_CONTENT, "android.settings.PICTURE_IN_PICTURE_SETTINGS"))) pip.background()
|
||||
super.startActivityForResult(intent, requestCode, options)
|
||||
}
|
||||
|
||||
// ── Lifecycle ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
if (runtimeRemovalBlocked) return
|
||||
started = true
|
||||
displayManager.registerDisplayListener(displayListener, Handler(Looper.getMainLooper()))
|
||||
refreshSecondScreenDisplays()
|
||||
@@ -1615,6 +1877,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
if (runtimeRemovalBlocked) { super.onStop(); return }
|
||||
started = false
|
||||
displayManager.unregisterDisplayListener(displayListener)
|
||||
// Closed while the session is out of sight (sleep, a closed lid, another app), and kept:
|
||||
@@ -1635,6 +1898,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
recreate()
|
||||
return
|
||||
}
|
||||
// Home/notification resumes must retain the external frontend's return destination.
|
||||
if (this.intent.getBooleanExtra(EXTRA_RETURN_HOME, false)) intent.putExtra(EXTRA_RETURN_HOME, true)
|
||||
setIntent(intent)
|
||||
if (intent.action == SessionService.ACTION_HOME_GUIDE) {
|
||||
handleHomeGuideIntent(intent)
|
||||
@@ -1646,6 +1911,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
if (runtimeRemovalBlocked) return
|
||||
com.droiddeck.launcher.ui.Motion.refresh(this)
|
||||
refreshHomeApp()
|
||||
if (intent?.action == SessionService.ACTION_RESUME) {
|
||||
@@ -1655,14 +1921,20 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
(getSystemService(INPUT_SERVICE) as? InputManager)
|
||||
?.registerInputDeviceListener(deviceListener, Handler(Looper.getMainLooper()))
|
||||
onScreenControls?.reload()
|
||||
controllerSettings = ControllerPrefs.read(this)
|
||||
updateOnScreenControls()
|
||||
resumed = true
|
||||
updatePointerCapture()
|
||||
if (!pipUi) sessionClipboard.start()
|
||||
updatePadMotion()
|
||||
readPrefs()
|
||||
if (::pip.isInitialized) pip.resumed()
|
||||
if (CompositorHost.isStarted) applyFrameGen()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
if (runtimeRemovalBlocked) { super.onPause(); return }
|
||||
sessionClipboard.stop()
|
||||
// Do not carry transient session UI across an app/display transition. In particular, the
|
||||
// drawer's dim layer can otherwise remain over Steam when this activity returns.
|
||||
drawerOpen = false
|
||||
@@ -1672,6 +1944,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
// A button held when the app goes away would stay held in the ring for the whole session.
|
||||
onScreenControls?.releaseAll()
|
||||
resumed = false
|
||||
updatePointerCapture()
|
||||
updatePadMotion()
|
||||
keyboard?.takeIf { it.shown }?.hide()
|
||||
super.onPause()
|
||||
@@ -1687,7 +1960,18 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
|
||||
override fun onWindowFocusChanged(hasFocus: Boolean) {
|
||||
super.onWindowFocusChanged(hasFocus)
|
||||
if (hasFocus) goFullscreen()
|
||||
if (runtimeRemovalBlocked) return
|
||||
if (hasFocus && !pipUi) {
|
||||
goFullscreen()
|
||||
refreshClipboard()
|
||||
}
|
||||
// Android drops a capture with the focus; a lock still held takes it again on return.
|
||||
updatePointerCapture()
|
||||
}
|
||||
|
||||
/** The keyboard/trackpad presentation can own focus instead of the main display. */
|
||||
fun refreshClipboard() {
|
||||
if (resumed) sessionClipboard.refresh()
|
||||
}
|
||||
|
||||
private fun goFullscreen() {
|
||||
@@ -1710,6 +1994,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private val endListener: (Int) -> Unit = { status -> onSessionEnded(status) }
|
||||
private val firstFrameListener = Runnable {
|
||||
SessionEvents.firstFrame()
|
||||
if (SessionState.mode == SessionService.MODE_STEAM) SteamRepair.clientShown(this)
|
||||
runOnUiThread {
|
||||
loading.visible = false
|
||||
hud.start()
|
||||
@@ -1717,19 +2002,22 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
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.
|
||||
watching = false
|
||||
releaseDrawerDirection()
|
||||
pendingBackAction?.let(uiHandler::removeCallbacks)
|
||||
pendingBackAction = null
|
||||
closeSecondScreen(reset = true)
|
||||
if (::pip.isInitialized) pip.destroyed()
|
||||
closeSecondScreen(reset = false)
|
||||
if (::hud.isInitialized) hud.stop()
|
||||
padBridge?.stop()
|
||||
padMotion?.stop()
|
||||
if (SessionState.deckPadListener === deckPadListener) SessionState.deckPadListener = null
|
||||
if (SessionState.endListener === endListener) SessionState.endListener = null
|
||||
WaylandCompositor.clearFirstFrameListener(firstFrameListener)
|
||||
WaylandCompositor.clearPointerLockListener(pointerLockListener)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
@@ -1752,6 +2040,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
* off, it just closes.
|
||||
*/
|
||||
override fun finish() {
|
||||
if (isFinishing) return
|
||||
if (!quitFlooded && !closeAtOnce && com.droiddeck.launcher.ui.Motion.scale != 0f &&
|
||||
lifecycle.currentState.isAtLeast(androidx.lifecycle.Lifecycle.State.RESUMED)) {
|
||||
if (!leaving) {
|
||||
@@ -1762,6 +2051,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
return
|
||||
}
|
||||
super.finish()
|
||||
if (!closeAtOnce && intent.getBooleanExtra(EXTRA_RETURN_HOME, false)) {
|
||||
com.droiddeck.launcher.ui.QuitFlood.take()
|
||||
com.droiddeck.launcher.ui.LaunchOrigin.flooding = null
|
||||
startActivity(Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
}
|
||||
if (quitFlooded) overridePendingTransition(0, 0)
|
||||
else overridePendingTransition(R.anim.session_hold, R.anim.session_sink)
|
||||
}
|
||||
@@ -1777,9 +2072,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val EXTRA_RETURN_HOME = "returnHome"
|
||||
/** How long the leaving flood may take before the session closes without it. */
|
||||
private const val LEAVE_TIMEOUT_MS = 1_500L
|
||||
private const val TAG = "SessionActivity"
|
||||
private const val FEX_MISSING = "droiddeck-fex: x86 Linux programs need"
|
||||
private const val UNBUFFERED_SOURCES = InputDevice.SOURCE_CLASS_JOYSTICK or InputDevice.SOURCE_CLASS_TRACKBALL or InputDevice.SOURCE_CLASS_POSITION
|
||||
private const val CURSOR_PAD_HOLD_MS = 1200L
|
||||
private const val DRAWER_HAT_THRESHOLD = 0.5f
|
||||
|
||||
+1
@@ -73,6 +73,7 @@ class AgentBridgeProvider : ContentProvider() {
|
||||
.put("steamUi", SessionState.steamUi ?: JSONObject.NULL)
|
||||
.put("steamUrl", SessionState.steamUrl ?: JSONObject.NULL)
|
||||
.put("suspended", SessionState.suspended)
|
||||
.put("pip", SessionState.pipActive)
|
||||
.put("firstFrame", SessionState.firstFrameSeen)
|
||||
.put("output", JSONArray().put(SessionState.outputSize.first).put(SessionState.outputSize.second))
|
||||
.put("refreshHz", SessionState.refreshHz.toDouble())
|
||||
File renamed without changes.
@@ -0,0 +1,19 @@
|
||||
package com.droiddeck.launcher.core
|
||||
|
||||
import android.content.Context
|
||||
|
||||
object AppUiPrefs {
|
||||
const val DEFAULT_SCALE = 100
|
||||
val scales = listOf(75, 85, 100, 115, 125, 150)
|
||||
|
||||
internal fun normalizeScale(percent: Int): Int = percent.takeIf { it in scales } ?: DEFAULT_SCALE
|
||||
|
||||
fun scale(context: Context): Int = normalizeScale(
|
||||
context.getSharedPreferences("app-ui", Context.MODE_PRIVATE).getInt("scale", DEFAULT_SCALE),
|
||||
)
|
||||
|
||||
fun setScale(context: Context, percent: Int) {
|
||||
context.getSharedPreferences("app-ui", Context.MODE_PRIVATE).edit()
|
||||
.putInt("scale", normalizeScale(percent)).apply()
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import com.droiddeck.launcher.gpu.TurnipDriver
|
||||
import com.droiddeck.launcher.runtime.DesktopCatalog
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntimeInstaller
|
||||
import com.droiddeck.launcher.session.EsyncPacks
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import com.droiddeck.launcher.session.SessionService
|
||||
import com.droiddeck.launcher.session.SessionState
|
||||
@@ -107,9 +108,12 @@ object DeviceReport {
|
||||
h("Display")
|
||||
k("Session output", SessionState.outputSize?.let { "${it.first}x${it.second}" })
|
||||
k("Session refresh", String.format(Locale.US, "%.2f Hz", SessionState.refreshHz))
|
||||
k("Shape setting", SessionPrefs.shapeMode(context))
|
||||
k("Custom resolution (Steam)", SessionPrefs.customResolution(context, SessionService.MODE_STEAM)?.let { "${it.first}x${it.second}" } ?: "off")
|
||||
k("Custom resolution (Desktop)", SessionPrefs.customResolution(context, SessionService.MODE_DESKTOP)?.let { "${it.first}x${it.second}" } ?: "off")
|
||||
val panelSize = com.droiddeck.launcher.session.SessionDisplay.panelSize(context)
|
||||
for ((label, mode) in listOf("Resolution (Steam)" to SessionService.MODE_STEAM, "Resolution (Desktop)" to SessionService.MODE_DESKTOP)) {
|
||||
val choice = SessionPrefs.resolutionChoice(context, mode, panelSize)
|
||||
val size = com.droiddeck.launcher.session.SessionDisplay.resolveChoice(panelSize, choice)
|
||||
k(label, "$choice (${size.first}x${size.second})")
|
||||
}
|
||||
k("Foldable", context.packageManager.hasSystemFeature("android.hardware.sensor.hinge_angle"))
|
||||
|
||||
h("Drivers")
|
||||
@@ -150,14 +154,18 @@ object DeviceReport {
|
||||
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)}%)")
|
||||
k("FEX preset", SessionPrefs.fexPreset(context).ifEmpty { "FEX defaults" })
|
||||
k("Skip xalia", SessionPrefs.noXalia(context))
|
||||
k("Wine sync", SessionPrefs.syncBackend(context))
|
||||
EsyncPacks.status(LinuxRuntime.rootDir(context)).let { k("droiddeck-esync packs", "${it.installed} installed, ${it.wanted} wanted") }
|
||||
k("Linux x86 (FEX)", if (com.droiddeck.launcher.runtime.LinuxFex.ready(context)) "ready" else "not set up")
|
||||
k("gamescope realtime", SessionPrefs.gamescopeRealtime(context))
|
||||
k("proot without seccomp", SessionPrefs.prootNoSeccomp(context))
|
||||
k("proot fast path", SessionPrefs.prootFastPath(context))
|
||||
k("Guest host name", SessionPrefs.guestHostname(context))
|
||||
k("DirectAudio for games", SessionPrefs.directAudio(context))
|
||||
k("Stretch games to fill", SessionPrefs.forceFullscreen(context))
|
||||
k("Stretch 16:9 to panel", SessionPrefs.stretch16x9(context))
|
||||
k("Client audio", if (SessionPrefs.clientDirectAudio(context)) "DirectAudio" else "classic")
|
||||
k("Microphone", SessionPrefs.micEnabled(context))
|
||||
@@ -169,6 +177,7 @@ object DeviceReport {
|
||||
SessionPrefs.GAME_STORAGE_OFF -> "internal only"
|
||||
else -> g
|
||||
})
|
||||
k("Skip space reservation", SessionPrefs.skipLibraryPreallocation(context))
|
||||
|
||||
h("Android process limits")
|
||||
k("Phantom proc monitor", PhantomProcessLimit.reportValue(PhantomProcessLimit.read(context)))
|
||||
|
||||
@@ -14,7 +14,7 @@ object GameEnvironment {
|
||||
|
||||
private val namePattern = Regex("[A-Za-z_][A-Za-z0-9_]*")
|
||||
private val unused = setOf(
|
||||
"WINEESYNC", "WINENTSYNC", "WINE_FAST_YIELD", "WINE_DO_NOT_CREATE_DXGI_DEVICE_MANAGER",
|
||||
"WINENTSYNC", "WINE_FAST_YIELD", "WINE_DO_NOT_CREATE_DXGI_DEVICE_MANAGER",
|
||||
"WINE_NEW_MEDIASOURCE", "WRAPPER_MAX_IMAGE_COUNT", "WRAPPER_DMAHEAP_CACHED",
|
||||
"ALSA_LATENCY_MS", "ALSA_VOLUME", "ALSA_BASS_BOOST", "ALSA_PERFORMANCE_MODE",
|
||||
"DXVK_ASYNC", "DXVK_GPLASYNCCACHE",
|
||||
@@ -25,14 +25,24 @@ object GameEnvironment {
|
||||
fun validValue(value: String) = '\u0000' !in value && value.length <= 8192
|
||||
fun validScope(scope: String) = scope.isEmpty() || (Regex("[1-9][0-9]*").matches(scope) && scope.toLongOrNull()?.let { it in 1..4294967295L } == true)
|
||||
|
||||
fun defaults(preset: String): Map<String, String?> = linkedMapOf<String, String?>(
|
||||
fun defaults(preset: String, forceSsbs: Boolean = false): Map<String, String?> = linkedMapOf<String, String?>(
|
||||
"MESA_SHADER_CACHE_DISABLE" to "false",
|
||||
"VKD3D_FEATURE_LEVEL" to "12_2",
|
||||
"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.
|
||||
"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",
|
||||
).apply {
|
||||
FexPreset.env(preset).forEach { put(it.substringBefore('='), it.substringAfter('=')) }
|
||||
// Steam settings' Force SSBS: droiddeck-game-env preloads libssbs.so into the game's Wine.
|
||||
if (forceSsbs) put(FORCE_SSBS, "1")
|
||||
}
|
||||
|
||||
fun effective(config: Config, preset: String, scope: String): Map<String, String?> =
|
||||
defaults(preset) + config.shared + if (scope.isEmpty()) emptyMap() else config.games[scope].orEmpty()
|
||||
const val FORCE_SSBS = "DROIDDECK_FORCE_SSBS"
|
||||
|
||||
fun effective(config: Config, preset: String, scope: String, forceSsbs: Boolean = false): Map<String, String?> =
|
||||
defaults(preset, forceSsbs) + config.shared + if (scope.isEmpty()) emptyMap() else config.games[scope].orEmpty()
|
||||
}
|
||||
@@ -15,6 +15,10 @@ object GameEnvironmentOptions {
|
||||
Option("PROTON_LOG", "1", R.string.game_env_proton_log, Type.TOGGLE, listOf("0", "1")),
|
||||
Option("PROTON_USE_WINED3D", "1", R.string.game_env_wined3d, Type.TOGGLE, listOf("0", "1")),
|
||||
Option("PROTON_USE_XALIA", "0", R.string.game_env_xalia, Type.TOGGLE, listOf("0", "1")),
|
||||
Option("PROTON_NO_NTSYNC", "1", R.string.game_env_no_ntsync, Type.TOGGLE, listOf("0", "1")),
|
||||
Option("PROTON_NO_ESYNC", "1", R.string.game_env_no_esync, Type.TOGGLE, listOf("0", "1")),
|
||||
Option("PROTON_NO_FSYNC", "1", R.string.game_env_no_fsync, Type.TOGGLE, listOf("0", "1")),
|
||||
Option("BL_FSYNC_FIRST", "1", R.string.game_env_fsync_first, Type.TOGGLE, listOf("0", "1")),
|
||||
Option("DXVK_HUD", "fps", R.string.game_env_hud, Type.MULTIPLE, listOf("scale=0.5", "scale=0.7", "opacity=0.5", "opacity=0.7", "devinfo", "fps", "frametimes", "submissions", "drawcalls", "pipelines", "descriptors", "memory", "gpuload", "version", "api", "cs", "compiler", "samplers")),
|
||||
Option("DXVK_CONFIG", "dxvk.maxFrameRate = 60", R.string.game_env_dxvk_config, Type.TEXT),
|
||||
Option("VKD3D_FRAME_RATE", "60", R.string.game_env_vkd3d_limit, Type.NUMBER),
|
||||
|
||||
@@ -18,7 +18,7 @@ package com.droiddeck.launcher.core
|
||||
*
|
||||
* The device's OWN addresses are not kept: the client's IPv6 check logs "external address <ours>"
|
||||
* about twenty times a session, and a public IPv6 address is the user's. [learnOwnAddresses] reads
|
||||
* the link the session was given (etc/bannerlator-net) and every address on the same /64 - privacy
|
||||
* the link the session was given (etc/droiddeck-net) and every address on the same /64 - privacy
|
||||
* addresses rotate inside it - or an own public IPv4 is replaced wherever it appears. Likewise every
|
||||
* Steam account and persona name on the device ([learnAccounts]), for users who sign in with an
|
||||
* account name rather than an email.
|
||||
@@ -95,7 +95,7 @@ object LogRedactor {
|
||||
|
||||
/** Everything the session's runtime can tell about whose logs these are: addresses and accounts. */
|
||||
fun learnFromRuntime(root: java.io.File) {
|
||||
learnOwnAddresses(java.io.File(root, "etc/bannerlator-net"))
|
||||
learnOwnAddresses(java.io.File(root, "etc/droiddeck-net"))
|
||||
learnAccounts(java.io.File(root, "root/.local/share/Steam/config/loginusers.vdf"))
|
||||
}
|
||||
|
||||
@@ -190,6 +190,14 @@ object LogRedactor {
|
||||
false
|
||||
}
|
||||
|
||||
/**
|
||||
* Bump whenever a pattern is added or tightened. A session folder records the version it was
|
||||
* scrubbed under (SessionArtifacts.SCRUBBED_TREE_MARKER), and a share copies a file as it is
|
||||
* only when that matches, so a folder scrubbed under older rules is scrubbed again on the way
|
||||
* out.
|
||||
*/
|
||||
const val RULES_VERSION = 1
|
||||
|
||||
/** [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") }
|
||||
|
||||
@@ -14,10 +14,8 @@ import java.net.Inet6Address
|
||||
* `network.txt`: what the session was given to work with, for the reports that say "the client
|
||||
* thinks WiFi is off" or "Firefox cannot resolve anything".
|
||||
*
|
||||
* The runtime has no NetworkManager, so the client's own network panel is blind by design and says
|
||||
* so in ways that read like a fault. What actually matters is further down: whether the phone had
|
||||
* a validated link, which DNS servers the app wrote into the runtime's `resolv.conf`, and whether
|
||||
* the session can reach a name and an address at all.
|
||||
* The runtime's NetworkManager bridge reports Android's active link. Include whether the phone
|
||||
* had a validated link and which DNS servers the app wrote into the runtime's `resolv.conf`.
|
||||
*
|
||||
* **No network names.** The SSID is identifying and is deliberately not collected; the transport
|
||||
* type, the addresses' families and the DNS servers are what a diagnosis needs.
|
||||
@@ -93,14 +91,14 @@ object NetworkReport {
|
||||
} else {
|
||||
append("etc/resolv.conf does not exist yet - written when the session's link is published.\n")
|
||||
}
|
||||
val netdev = File(LinuxRuntime.rootDir(context), "etc/bannerlator-net")
|
||||
val netdev = File(LinuxRuntime.rootDir(context), "etc/droiddeck-net")
|
||||
if (netdev.isFile) {
|
||||
append("\netc/bannerlator-net (the link as the session sees it):\n")
|
||||
append("\netc/droiddeck-net (the link as the session sees it):\n")
|
||||
FileUtils.readString(netdev)?.lines()?.forEach { append(" ").append(it).append('\n') }
|
||||
}
|
||||
append("\nproot makes no network namespace, so the session uses the phone's connection\n")
|
||||
append("directly: IPv4 and IPv6 both pass through, and there is nothing to forward.\n")
|
||||
append("The Steam client's own network panel drives NetworkManager over D-Bus, which this\n")
|
||||
append("runtime does not have - \"wifi is off\" there is cosmetic and not evidence.\n")
|
||||
append("Steam reads Android's transport, connectivity and metering through the runtime's\n")
|
||||
append("NetworkManager D-Bus bridge. Android settings manage Wi-Fi and cellular connections.\n")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.droiddeck.launcher.core
|
||||
|
||||
import java.util.Locale
|
||||
import kotlin.math.ln
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* Texture filtering for DirectX 9-11 games, as DXVK configuration options
|
||||
* (d3d9/d3d11.samplerAnisotropy and samplerLodBias). VKD3D-Proton and WineD3D do not read them.
|
||||
* Published into the game environment and read by DXVK when a game starts, so a change lands on
|
||||
* the next launch, not the running game.
|
||||
*/
|
||||
object TextureFiltering {
|
||||
/** 0 = the game's own filtering; otherwise that anisotropy on every linearly filtered sampler. */
|
||||
val ANISOTROPY = listOf(0, 2, 4, 8, 16)
|
||||
|
||||
/** Negative mip LOD bias added to the game's own: sharper textures at the cost of some shimmer. */
|
||||
const val LOD_BIAS_OFF = "0"
|
||||
/** Derived from how far the session is enlarged to the panel (see [autoLodBias]). */
|
||||
const val LOD_BIAS_AUTO = "auto"
|
||||
val LOD_BIAS = listOf(LOD_BIAS_OFF, LOD_BIAS_AUTO, "-0.25", "-0.5", "-0.75", "-1.0")
|
||||
|
||||
private const val LOD_BIAS_MIN = -2f
|
||||
|
||||
/**
|
||||
* The mip LOD bias for a picture enlarged by [scale] (panel size over session size, long side
|
||||
* to long side): -log2(scale), AMD's FSR 1 guidance (a 1.5x enlargement - 720p on a 1080p
|
||||
* panel - gives -0.58). 0 when the session is not enlarged or the scale is unknown.
|
||||
*/
|
||||
fun autoLodBias(scale: Float): Float {
|
||||
if (scale.isNaN() || scale <= 1f) return 0f
|
||||
return max((-ln(scale.toDouble()) / ln(2.0)).toFloat(), LOD_BIAS_MIN)
|
||||
}
|
||||
|
||||
fun lodBias(choice: String, autoBias: Float): Float = when (choice) {
|
||||
LOD_BIAS_OFF, "" -> 0f
|
||||
LOD_BIAS_AUTO -> autoBias
|
||||
else -> choice.toFloatOrNull()?.let { max(min(it, 0f), LOD_BIAS_MIN) } ?: 0f
|
||||
}
|
||||
|
||||
/** The DXVK_CONFIG options for the two settings, "" when both are left to the game. */
|
||||
fun dxvkOptions(anisotropy: Int, lodBiasChoice: String, autoBias: Float): String {
|
||||
val parts = ArrayList<String>(4)
|
||||
val aniso = if (anisotropy in ANISOTROPY) anisotropy else 0
|
||||
if (aniso > 0) {
|
||||
parts += "d3d9.samplerAnisotropy = $aniso"
|
||||
parts += "d3d11.samplerAnisotropy = $aniso"
|
||||
}
|
||||
val bias = lodBias(lodBiasChoice, autoBias)
|
||||
if (bias < 0f) {
|
||||
// Locale.US: DXVK parses "-0.58", never "-0,58".
|
||||
val b = String.format(Locale.US, "%.2f", bias)
|
||||
parts += "d3d9.samplerLodBias = $b"
|
||||
parts += "d3d11.samplerLodBias = $b"
|
||||
}
|
||||
return parts.joinToString("; ")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.droiddeck.launcher.core
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.location.LocationManager
|
||||
import android.os.Build
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
|
||||
/** Location is used only for Android's Wi-Fi names and scan results, never coordinates. */
|
||||
object WifiDiscovery {
|
||||
val permissions = arrayOf(Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
|
||||
// targetSdk 28 accepts either grant for Wi-Fi scanning.
|
||||
fun permissionGranted(context: Context): Boolean = permissions.any {
|
||||
context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
fun locationEnabled(context: Context): Boolean = Build.VERSION.SDK_INT < Build.VERSION_CODES.P ||
|
||||
(context.getSystemService(Context.LOCATION_SERVICE) as? LocationManager)?.isLocationEnabled == true
|
||||
|
||||
fun available(context: Context): Boolean = SessionPrefs.wifiDiscoveryEnabled(context) &&
|
||||
permissionGranted(context) && locationEnabled(context)
|
||||
}
|
||||
@@ -22,7 +22,7 @@ class FileManagerActivity : ComponentActivity() {
|
||||
}
|
||||
val initialDir = intent.getStringExtra(EXTRA_START_DIR)?.let(::File)
|
||||
setContent {
|
||||
DroidDeckTheme {
|
||||
DroidDeckTheme(appScale = com.droiddeck.launcher.core.AppUiPrefs.scale(this)) {
|
||||
Surface(modifier = Modifier.fillMaxSize().systemBarsPadding(), color = MaterialTheme.colorScheme.background) {
|
||||
FileManagerScreen(initialDir = initialDir)
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ class FilePickerActivity : ComponentActivity() {
|
||||
?.let { File(it) }?.takeIf { it.isDirectory }
|
||||
|
||||
setContent {
|
||||
DroidDeckTheme {
|
||||
DroidDeckTheme(appScale = com.droiddeck.launcher.core.AppUiPrefs.scale(this)) {
|
||||
Surface(modifier = Modifier.fillMaxSize().systemBarsPadding(), color = MaterialTheme.colorScheme.background) {
|
||||
FileManagerScreen(
|
||||
pickMode = true,
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Best-effort detection of the Windows redistributables a game bundles, by scanning its install
|
||||
* folder for the redist installers Steam/Epic/GOG ship (`_CommonRedist/`, `_Redist/`,
|
||||
* `Redistributables/`, loose `vcredist_x64.exe`, …) and mapping them to entries in our component
|
||||
* catalog (WinComponents, Bannerlator's components.json `name` keys). The result drives the
|
||||
* Recommended part of a game's Windows components; the user still switches each one on.
|
||||
* Bannerlator's detector, unchanged but for this note and its package.
|
||||
*
|
||||
* Wine-aware: keeps the redists that actually matter under Wine/DXVK (VC++ runtimes, .NET, PhysX,
|
||||
* OpenAL, XACT, XNA, GFWL) and SKIPS the ones DXVK/VKD3D already provide (DX12 Agility SDK) or that
|
||||
* aren't our components (anti-cheat, Rockstar/Social-Club/launcher setups). Never throws; returns an
|
||||
* empty list on anything unexpected. Pure filesystem — no network, no Android deps (unit-testable).
|
||||
*/
|
||||
object DependencyDetector {
|
||||
|
||||
/** How a recommendation was found — drives the UI's "recommended vs optional" split. */
|
||||
enum class Kind {
|
||||
/** The game bundles the redist installer (in _CommonRedist etc.) — meant to be installed. */
|
||||
BUNDLED,
|
||||
/** A runtime DLL the game ships loose — usually already works from the game folder; install
|
||||
* only if the game misbehaves. */
|
||||
SHIPPED,
|
||||
/** Steam installs it with the game from Steamworks Shared ([SteamRedists]). */
|
||||
STEAM,
|
||||
}
|
||||
|
||||
/** A recommended component: the catalog [componentName] to install, a display [label], the on-disk
|
||||
* redist that triggered it ([reason]), and [kind] (bundled installer vs shipped DLL). */
|
||||
data class Recommendation(val componentName: String, val label: String, val reason: String, val kind: Kind = Kind.BUNDLED)
|
||||
|
||||
private const val MAX_DEPTH = 5
|
||||
private const val MAX_ENTRIES = 8000 // cost guard against giant game trees
|
||||
|
||||
/** A loose VC++ installer with no year in its path — resolved to "latest" only if no specific
|
||||
* vcredistYYYY was found anywhere (else it's just a duplicate of the versioned one). */
|
||||
private const val VCREDIST_LOOSE = "__vcredist_loose"
|
||||
|
||||
/** Directory names that are redist containers worth descending into. */
|
||||
private val REDIST_DIR_RE = Regex(
|
||||
"""(?i)^(_?commonredist|_?redist(ributables)?|__?installer|vcredist|vc_redist|directx|direct_x|physx|openal|dotnet(fx)?|\.net|xna|xact|redist|support)$"""
|
||||
)
|
||||
|
||||
/** Skip whole subtrees that are never our components. */
|
||||
private val SKIP_DIR_RE = Regex("""(?i)^(d3d12.*|.*agility.*|dxvk|vkd3d|battleye|easyanticheat|eac|anticheat)$""")
|
||||
|
||||
private val YEAR_TO_VCREDIST = 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 LABELS = mapOf(
|
||||
"vcredist2005" to "Visual C++ 2005", "vcredist2008" to "Visual C++ 2008",
|
||||
"vcredist2010" to "Visual C++ 2010", "vcredist2012" to "Visual C++ 2012",
|
||||
"vcredist2013" to "Visual C++ 2013", "vcredist2015" to "Visual C++ 2015-2017",
|
||||
"vcredist2019" to "Visual C++ 2019", "vcredist2022" to "Visual C++ 2015-2022",
|
||||
"physx" to "NVIDIA PhysX", "oalinst" to "OpenAL", "xact" to "DirectX XACT audio",
|
||||
"xact_x64" to "DirectX XACT audio (x64)", "xna40" to "XNA Framework 4.0",
|
||||
"xna31" to "XNA Framework 3.1", "d3dx9" to "DirectX 9 (d3dx9)", "d3dx11" to "DirectX 11 (d3dx11)",
|
||||
"XLiveRedist" to "Games for Windows Live", "dotnet40" to ".NET Framework 4.0",
|
||||
"dotnet45" to ".NET Framework 4.5", "dotnet452" to ".NET Framework 4.5.2",
|
||||
"dotnet46" to ".NET Framework 4.6", "dotnet472" to ".NET Framework 4.7.2",
|
||||
"dotnet48" to ".NET Framework 4.8", "dotnet35" to ".NET Framework 3.5",
|
||||
"dotnetcoredesktop6" to ".NET Desktop 6", "dotnetcoredesktop7" to ".NET Desktop 7",
|
||||
"dotnetcoredesktop8" to ".NET Desktop 8",
|
||||
)
|
||||
|
||||
/** Preferred install order (runtimes others may depend on first). */
|
||||
private val INSTALL_ORDER = listOf(
|
||||
"vcredist2005", "vcredist2008", "vcredist2010", "vcredist2012", "vcredist2013",
|
||||
"vcredist2015", "vcredist2019", "vcredist2022",
|
||||
"dotnet35", "dotnet40", "dotnet45", "dotnet452", "dotnet46", "dotnet472", "dotnet48",
|
||||
"dotnetcoredesktop6", "dotnetcoredesktop7", "dotnetcoredesktop8",
|
||||
"d3dx9", "d3dx11", "xact", "xact_x64", "xna31", "xna40", "physx", "oalinst", "XLiveRedist",
|
||||
)
|
||||
|
||||
/**
|
||||
* Detect from the .exe the user picked, resolving the game root first: walk up from the exe's
|
||||
* folder (bounded) to the nearest ancestor that holds a redist container (`_CommonRedist`, …) at
|
||||
* its top level, and scan from there — so a nested exe (`…/Game/bin/x.exe`) still finds a root-level
|
||||
* `_CommonRedist`, without over-reaching into a shared `steamapps/common` (which has no redist
|
||||
* container of its own). Falls back to scanning the exe's own folder.
|
||||
*/
|
||||
fun detectForExe(exeFile: File): List<Recommendation> {
|
||||
val start = exeFile.absoluteFile.parentFile ?: return emptyList()
|
||||
var d: File? = start
|
||||
var depth = 0
|
||||
while (d != null && depth < 4) {
|
||||
val hasRedistContainer = try {
|
||||
d.listFiles()?.any { it.isDirectory && REDIST_DIR_RE.matches(it.name.lowercase(Locale.US)) } == true
|
||||
} catch (_: Throwable) { false }
|
||||
if (hasRedistContainer) return detect(d)
|
||||
d = d.parentFile
|
||||
depth++
|
||||
}
|
||||
return detect(start)
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan [gameDir] and return the recommended components, de-duplicated (first reason wins), in a
|
||||
* stable install-friendly order. Empty when nothing is detected or [gameDir] isn't a directory.
|
||||
*/
|
||||
fun detect(gameDir: File): List<Recommendation> {
|
||||
if (!gameDir.isDirectory) return emptyList()
|
||||
val hits = LinkedHashMap<String, Recommendation>() // componentName -> first recommendation
|
||||
var visited = 0
|
||||
|
||||
fun consider(f: File, isDir: Boolean, insideRedist: Boolean) {
|
||||
val name = f.name.lowercase(Locale.US)
|
||||
val path = f.path.replace('\\', '/').lowercase(Locale.US)
|
||||
// Bundled = in a redist container or an installer (exe/msi/cab); shipped = a loose runtime DLL.
|
||||
val kind = if (insideRedist || isDir || name.endsWith(".exe") || name.endsWith(".msi") || name.endsWith(".cab"))
|
||||
Kind.BUNDLED else Kind.SHIPPED
|
||||
for (comp in classify(path, name, isDir)) {
|
||||
val existing = hits[comp]
|
||||
if (existing == null) {
|
||||
hits[comp] = Recommendation(comp, LABELS[comp] ?: comp, relativeReason(gameDir, f), kind)
|
||||
} else if (existing.kind == Kind.SHIPPED && kind == Kind.BUNDLED) {
|
||||
hits[comp] = existing.copy(kind = Kind.BUNDLED) // a bundled installer outranks a shipped DLL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun walk(dir: File, depth: Int, insideRedist: Boolean) {
|
||||
if (depth > MAX_DEPTH || visited > MAX_ENTRIES) return
|
||||
val entries = dir.listFiles() ?: return
|
||||
for (f in entries) {
|
||||
if (visited++ > MAX_ENTRIES) return
|
||||
val lower = f.name.lowercase(Locale.US)
|
||||
if (f.isDirectory) {
|
||||
if (SKIP_DIR_RE.matches(lower)) 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
|
||||
// tree a few levels deep so runtime libraries the game ships (in bin/, etc.) are seen.
|
||||
if (isRedistDir || depth < 3) walk(f, depth + 1, isRedistDir)
|
||||
} else {
|
||||
// Classify installers + runtime libraries (.dll/.exe/.msi) anywhere in the scanned
|
||||
// tree; other files are ignored. Recommendations are advisory, so cast a wide net.
|
||||
val ext = lower.substringAfterLast('.', "")
|
||||
if (insideRedist || ext == "dll" || ext == "exe" || ext == "msi") consider(f, isDir = false, insideRedist = insideRedist)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return try {
|
||||
walk(gameDir, 0, false)
|
||||
resolveLooseVcredist(hits)
|
||||
hits.values.sortedBy { INSTALL_ORDER.indexOf(it.componentName).let { i -> if (i < 0) 999 else i } }
|
||||
} catch (_: Throwable) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/** 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
|
||||
val hasSpecific = hits.keys.any { it in YEAR_TO_VCREDIST.values }
|
||||
if (!hasSpecific) hits["vcredist2022"] = loose.copy(componentName = "vcredist2022", label = LABELS["vcredist2022"]!!)
|
||||
}
|
||||
|
||||
private fun relativeReason(gameDir: File, f: File): String {
|
||||
val g = gameDir.path.replace('\\', '/')
|
||||
val p = f.path.replace('\\', '/')
|
||||
return if (p.startsWith("$g/")) p.substring(g.length + 1) else f.name
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a redist file/dir (its full lowercased [path], leaf [name], and whether it [isDir]) to
|
||||
* catalog component name(s). Empty for things we deliberately skip.
|
||||
*/
|
||||
private fun classify(path: String, name: String, isDir: Boolean): List<String> {
|
||||
// Hard skips first — DX12 is provided by DXVK/VKD3D; these aren't our components.
|
||||
if (path.contains("d3d12") || path.contains("agility") ||
|
||||
path.contains("battleye") || path.contains("anticheat") || path.contains("easyanticheat") ||
|
||||
name.contains("socialclub") || name.contains("social-club") ||
|
||||
name.contains("rockstar") || name == "setup.exe"
|
||||
) return emptyList()
|
||||
|
||||
// Visual C++ runtimes — most important. Year comes from the path context.
|
||||
if (path.contains("vcredist") || path.contains("vc_redist") || name.startsWith("vcredist") || name.startsWith("vc_redist")) {
|
||||
val year = YEAR_TO_VCREDIST.keys.firstOrNull { path.contains(it) }
|
||||
return when {
|
||||
year != null -> listOf(YEAR_TO_VCREDIST.getValue(year))
|
||||
isDir -> emptyList() // bare "vcredist" container — wait for the year subfolder / installer
|
||||
else -> listOf(VCREDIST_LOOSE) // loose installer, no year → resolved post-walk
|
||||
}
|
||||
}
|
||||
// VC++ runtime DLL the game ships (msvcp140.dll → 2015-2022, msvcp120.dll → 2013, …).
|
||||
VCRUNTIME_DLL.entries.firstOrNull { name.startsWith(it.key) }?.let { return listOf(it.value) }
|
||||
if (path.contains("openal") || name.startsWith("oalinst") || name == "openal32.dll" || name == "wrap_oal.dll") return listOf("oalinst")
|
||||
if (path.contains("physx") || name.startsWith("physx")) return listOf("physx")
|
||||
if (path.contains("xact") || name.startsWith("xactengine") || name.startsWith("x3daudio")) return listOf("xact", "xact_x64")
|
||||
// Games for Windows Live — the redist folder, OR a GFWL library the game ships (xlive.dll,
|
||||
// Codemasters' dbxLive32/64, gfwl*): GFWL titles need XLiveRedist to run even when they don't
|
||||
// bundle the redist itself.
|
||||
if (path.contains("xliveredist") || name.contains("xlive") || name.startsWith("gfwl") || name.startsWith("dbxlive")) {
|
||||
return listOf("XLiveRedist")
|
||||
}
|
||||
if (path.contains("xna")) return listOf(if (path.contains("3.1") || path.contains("3_1") || path.contains("v31")) "xna31" else "xna40")
|
||||
// .NET — Framework and Core/Desktop runtimes.
|
||||
if (path.contains("dotnet") || path.contains(".net") || name.startsWith("ndp") || name.startsWith("dotnetfx") || name.contains("windowsdesktop-runtime")) {
|
||||
val comp = dotnetComponent("$path $name")
|
||||
return when {
|
||||
comp != null -> listOf(comp)
|
||||
isDir -> emptyList() // bare ".NET" container — wait for the versioned installer
|
||||
else -> listOf("dotnet48") // unlabeled .NET Framework installer file → sensible default
|
||||
}
|
||||
}
|
||||
// DirectX 11 helper DLLs the game ships (d3dx11_43.dll).
|
||||
if (D3DX11_RE.matches(name)) return listOf("d3dx11")
|
||||
// DirectX 9 helper DLLs / d3dcompiler / June-2010 DX redist (DXVK provides core d3d9 but NOT
|
||||
// these helper DLLs).
|
||||
if (name.startsWith("dxsetup") || name.startsWith("dxwebsetup") ||
|
||||
D3DX9_RE.matches(name) || D3DCOMPILER_RE.matches(name) ||
|
||||
(path.contains("directx") && name.endsWith(".exe"))
|
||||
) return listOf("d3dx9")
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
/** Runtime DLL prefix → the VC++ redistributable that provides it (checked with startsWith). */
|
||||
private val VCRUNTIME_DLL = linkedMapOf(
|
||||
"vcruntime140" to "vcredist2022", "msvcp140" to "vcredist2022", "msvcr140" to "vcredist2022", "concrt140" to "vcredist2022",
|
||||
"msvcp120" to "vcredist2013", "msvcr120" to "vcredist2013",
|
||||
"msvcp110" to "vcredist2012", "msvcr110" to "vcredist2012",
|
||||
"msvcp100" to "vcredist2010", "msvcr100" to "vcredist2010",
|
||||
"msvcp90" to "vcredist2008", "msvcr90" to "vcredist2008",
|
||||
"msvcp80" to "vcredist2005", "msvcr80" to "vcredist2005",
|
||||
)
|
||||
private val D3DX9_RE = Regex("""d3dx9_\d+\.dll""")
|
||||
private val D3DX11_RE = Regex("""d3dx11_\d+\.dll""")
|
||||
private val D3DCOMPILER_RE = Regex("""d3dcompiler_\d+\.dll""")
|
||||
|
||||
/** Resolve a .NET component from installer/path text, or null when no version is discernible. */
|
||||
private fun dotnetComponent(s: String): String? = when {
|
||||
s.contains("desktop-runtime-8") || Regex("""\b8\.\d""").containsMatchIn(s) -> "dotnetcoredesktop8"
|
||||
s.contains("desktop-runtime-7") || Regex("""\b7\.\d""").containsMatchIn(s) -> "dotnetcoredesktop7"
|
||||
s.contains("desktop-runtime-6") || Regex("""\b6\.\d""").containsMatchIn(s) -> "dotnetcoredesktop6"
|
||||
s.contains("ndp48") || s.contains("fx48") || s.contains("4.8") -> "dotnet48"
|
||||
s.contains("ndp472") || s.contains("4.7.2") -> "dotnet472"
|
||||
s.contains("ndp46") || s.contains("4.6") -> "dotnet46"
|
||||
s.contains("ndp452") || s.contains("4.5.2") -> "dotnet452"
|
||||
s.contains("dotnetfx45") || s.contains("ndp45") || s.contains("4.5") -> "dotnet45"
|
||||
s.contains("dotnetfx40") || s.contains("ndp40") || s.contains("4.0") -> "dotnet40"
|
||||
s.contains("dotnetfx35") || s.contains("3.5") -> "dotnet35"
|
||||
else -> null // no version signal (e.g. a bare "DotNet" container dir)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
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.SessionPrefs
|
||||
import java.io.File
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/** Runs while DroidDeck's process is alive, including downloads in a background Steam session. */
|
||||
object GameFileSync {
|
||||
private val worker = Executors.newSingleThreadScheduledExecutor { task -> Thread(task, "game-file-sync").apply { isDaemon = true } }
|
||||
private fun prefs(context: Context) = context.getSharedPreferences("game_files", Context.MODE_PRIVATE)
|
||||
fun folder(context: Context): String? = prefs(context).getString("folder", null)
|
||||
|
||||
fun start(context: Context) {
|
||||
val app = context.applicationContext
|
||||
worker.scheduleWithFixedDelay({
|
||||
runCatching { syncConfigured(app) }.onFailure { Log.w("GameFileSync", "Sync failed: ${it.message}") }
|
||||
}, 0, 30, TimeUnit.SECONDS)
|
||||
}
|
||||
|
||||
@Synchronized internal fun syncConfigured(context: Context) {
|
||||
val path = folder(context) ?: return
|
||||
sync(context, File(path))
|
||||
}
|
||||
|
||||
@Synchronized fun enable(context: Context, folder: File) {
|
||||
sync(context, folder)
|
||||
prefs(context).edit().putString("folder", folder.absolutePath).apply()
|
||||
}
|
||||
|
||||
@Synchronized fun disable(context: Context) {
|
||||
prefs(context).edit().remove("folder").apply()
|
||||
}
|
||||
|
||||
@Synchronized private fun sync(context: Context, folder: File) {
|
||||
check(LinuxRuntime.isInstalled(context)) { "Install the runtime first" }
|
||||
// A disconnected library must not be interpreted as a mass uninstall.
|
||||
check(File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/steamapps").isDirectory) { "Steam library is unavailable" }
|
||||
val storage = GameStorage.effective(context)
|
||||
val storageSetting = SessionPrefs.gameStorage(context)
|
||||
val previousStorage = prefs(context).getString("storagePath", null)
|
||||
if (storageSetting.isEmpty() && prefs(context).getString("storageSetting", null) == storageSetting && previousStorage != null) {
|
||||
check(File(previousStorage).isDirectory) { "Previous SD library is unavailable" }
|
||||
}
|
||||
storage?.let { check(File(it.path).listFiles() != null) { "Game storage is unavailable" } }
|
||||
SessionPrefs.addedGamesDirs(context).forEach { check(File(it).listFiles() != null) { "Added games folder is unavailable" } }
|
||||
GameFiles.sync(folder, Library.launchableGames(context, strictRead = true))
|
||||
prefs(context).edit().putString("storageSetting", storageSetting).putString("storagePath", storage?.path).apply()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.AtomicFile
|
||||
import androidx.core.util.readText
|
||||
import androidx.core.util.writeBytes
|
||||
import androidx.core.util.writeText
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
|
||||
/** Small, portable launch files for frontends that scan ROM folders. No guest paths or commands. */
|
||||
object GameFiles {
|
||||
const val EXTENSION = "steam"
|
||||
private const val MAX_BYTES = 128
|
||||
private const val INVENTORY = ".droiddeck-sync.json"
|
||||
private const val FORMAT = "droiddeck-game-files-v1"
|
||||
|
||||
/** Called on a worker: content providers and shared storage may block. */
|
||||
fun readIntent(context: Context, request: Intent): String? = runCatching {
|
||||
if (request.action != Intent.ACTION_VIEW) return null
|
||||
val uri = request.data ?: return null
|
||||
GameLaunchLink.parse(uri.toString())?.let { return it }
|
||||
val stream = when (uri.scheme) {
|
||||
"content" -> context.contentResolver.openInputStream(uri)
|
||||
"file" -> {
|
||||
if (!uri.authority.isNullOrEmpty() || uri.query != null || uri.fragment != null) return null
|
||||
File(uri.path ?: return null).inputStream()
|
||||
}
|
||||
null -> File(uri.toString()).takeIf { it.isAbsolute }?.inputStream()
|
||||
else -> null
|
||||
} ?: return null
|
||||
stream.use(::read)
|
||||
}.getOrNull()
|
||||
|
||||
fun read(stream: InputStream): String? {
|
||||
// An untrusted URI must not make us buffer an arbitrary ROM or parse a command.
|
||||
val bytes = ByteArray(MAX_BYTES + 1)
|
||||
var count = 0
|
||||
while (count < bytes.size) {
|
||||
val n = stream.read(bytes, count, bytes.size - count)
|
||||
if (n < 0) break
|
||||
if (n == 0) return null
|
||||
count += n
|
||||
}
|
||||
if (count > MAX_BYTES) return null
|
||||
val text = String(bytes, 0, count, Charsets.UTF_8).trim()
|
||||
return text.takeIf(GameLaunchLink::validId)
|
||||
}
|
||||
|
||||
fun filename(game: Library.SteamGame, disambiguate: Boolean = false): String {
|
||||
val sanitized = game.name.replace(Regex("[\\p{Cntrl}\\\\/:*?\"<>|]"), "_")
|
||||
.trim().trim('.').ifBlank { "Game" }
|
||||
val points = sanitized.codePoints().limit(40).toArray()
|
||||
val title = String(points, 0, points.size)
|
||||
val suffix = if (disambiguate) " (${game.gameIdString})" else ""
|
||||
return "$title$suffix.$EXTENSION"
|
||||
}
|
||||
|
||||
private fun contents(id: String): String {
|
||||
require(GameLaunchLink.validId(id))
|
||||
return id
|
||||
}
|
||||
|
||||
@Synchronized fun export(folder: File, game: Library.SteamGame): File =
|
||||
exportNamed(folder, game, filename(game))
|
||||
|
||||
private fun exportNamed(folder: File, game: Library.SteamGame, name: String): File {
|
||||
require(folder.isDirectory) { "Folder is unavailable" }
|
||||
val text = contents(game.gameIdString)
|
||||
val target = exportTarget(folder, game, name)
|
||||
require(!target.exists() || matches(target, text)) { "A different file already uses ${target.name}" }
|
||||
if (!target.exists()) write(target, text)
|
||||
exportArt(folder, game)
|
||||
return target
|
||||
}
|
||||
|
||||
private fun exportTarget(folder: File, game: Library.SteamGame, name: String): File {
|
||||
val target = child(folder, name)
|
||||
return if (target.exists() && !matches(target, contents(game.gameIdString))) {
|
||||
child(folder, filename(game, disambiguate = true))
|
||||
} else target
|
||||
}
|
||||
|
||||
/** Cocoon matches icons in its data folder's images directory. Never replace custom art. */
|
||||
private fun exportArt(folder: File, game: Library.SteamGame) {
|
||||
val art = listOfNotNull(game.icon, game.art).firstOrNull {
|
||||
it.isFile && it.extension.lowercase() in listOf("jpg", "jpeg", "png", "webp")
|
||||
} ?: return
|
||||
val title = filename(game).substringBeforeLast(".")
|
||||
.replace(Regex("\\[[^]]*]|\\([^)]*\\)"), "")
|
||||
.replace(Regex("[^a-zA-Z0-9\\s]"), "").trim()
|
||||
.replace(Regex("\\s+"), "-").lowercase(java.util.Locale.ROOT).take(50).trimEnd('-')
|
||||
if (title.isEmpty()) return
|
||||
val images = child(folder, "images")
|
||||
check(images.isDirectory || images.mkdir()) { "Artwork folder is unavailable" }
|
||||
val target = child(images, "$title-icon.${art.extension.lowercase()}")
|
||||
if (!target.exists()) AtomicFile(target).writeBytes(art.readBytes())
|
||||
}
|
||||
|
||||
/** Only remove files in our inventory whose content is still exactly what we wrote. */
|
||||
@Synchronized fun sync(folder: File, games: List<Library.SteamGame>) {
|
||||
require(folder.isDirectory) { "Folder is unavailable" }
|
||||
val inventory = child(folder, INVENTORY)
|
||||
val previous = if (inventory.exists()) {
|
||||
val record = JSONObject(AtomicFile(inventory).readText())
|
||||
require(record.optString("format") == FORMAT) { "Folder has an unrelated sync inventory" }
|
||||
record.getJSONObject("games")
|
||||
} else JSONObject()
|
||||
val next = JSONObject()
|
||||
val unique = games.distinctBy { it.gameId }
|
||||
val names = unique.groupingBy { filename(it).lowercase(java.util.Locale.ROOT) }.eachCount()
|
||||
val exports = unique.map { game ->
|
||||
val name = filename(game, disambiguate = names.getValue(filename(game).lowercase(java.util.Locale.ROOT)) > 1)
|
||||
val preferred = child(folder, name)
|
||||
val target = if (inventoryOwns(folder, previous, preferred, game.gameIdString)) preferred
|
||||
else exportTarget(folder, game, name)
|
||||
val text = contents(game.gameIdString)
|
||||
val existed = target.exists()
|
||||
// Keep an edited export without letting it block the rest of the library sync.
|
||||
val owned = inventoryOwns(folder, previous, target, game.gameIdString)
|
||||
val file = if (owned && target.exists() &&
|
||||
!matches(target, text)) target else exportNamed(folder, game, target.name)
|
||||
if (!existed || owned) next.put(file.name, game.gameIdString)
|
||||
file
|
||||
}
|
||||
// Write new exports before pruning; a failed write must not remove old launch files.
|
||||
for (name in previous.keys()) {
|
||||
val id = previous.getString(name)
|
||||
if (!next.has(name) && name.endsWith(".$EXTENSION") && GameLaunchLink.validId(id)) {
|
||||
val file = child(folder, name)
|
||||
if (matches(file, contents(id))) {
|
||||
// Android shared storage is often case insensitive. A case-only rename may
|
||||
// still resolve to the live export, which must not be pruned as an old file.
|
||||
val retained = exports.any { java.nio.file.Files.isSameFile(it.toPath(), file.toPath()) }
|
||||
if (!retained) check(file.delete()) { "Could not remove $name" }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (previous.toString() != next.toString()) write(inventory, JSONObject().put("format", FORMAT).put("games", next).toString())
|
||||
}
|
||||
|
||||
private fun inventoryOwns(folder: File, record: JSONObject, file: File, id: String): Boolean {
|
||||
if (record.optString(file.name) == id) return true
|
||||
if (!file.exists()) return false
|
||||
return record.keys().asSequence().any { name ->
|
||||
record.optString(name) == id && child(folder, name).let { old ->
|
||||
old.exists() && java.nio.file.Files.isSameFile(old.toPath(), file.toPath())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun child(folder: File, name: String): File {
|
||||
require(name.isNotEmpty() && '/' !in name && '\\' !in name && name != "." && name != "..")
|
||||
val file = File(folder, name)
|
||||
require(file.canonicalFile.parentFile == folder.canonicalFile) { "File leaves the export folder" }
|
||||
return file
|
||||
}
|
||||
|
||||
private fun matches(file: File, text: String) = file.isFile && file.length() == text.toByteArray().size.toLong() &&
|
||||
file.readText() == text
|
||||
|
||||
private fun write(file: File, text: String) = AtomicFile(file).writeText(text)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
|
||||
object GameLaunchIntent {
|
||||
const val ACTION = "com.droiddeck.launcher.LAUNCH_GAME"
|
||||
const val EXTRA_APP_ID = "app_id"
|
||||
const val EXTRA_GAME_SOURCE = "game_source"
|
||||
private const val GAMENATIVE_ACTION = "app.gamenative.LAUNCH_GAME"
|
||||
|
||||
fun accepts(action: String?): Boolean = action == Intent.ACTION_VIEW || action == ACTION || action == GAMENATIVE_ACTION
|
||||
|
||||
fun read(context: Context, intent: Intent): String? = when (intent.action) {
|
||||
Intent.ACTION_VIEW -> GameFiles.readIntent(context, intent)
|
||||
ACTION, GAMENATIVE_ACTION -> readId(intent)
|
||||
else -> null
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
internal fun readId(intent: Intent): String? = runCatching {
|
||||
if (intent.action != ACTION && intent.action != GAMENATIVE_ACTION) return null
|
||||
val source = intent.extras?.get(EXTRA_GAME_SOURCE)
|
||||
if (source != null && (source !is String || !source.equals("STEAM", ignoreCase = true))) return null
|
||||
val id = when (val value = intent.extras?.get(EXTRA_APP_ID)) {
|
||||
is Int -> value.takeIf { it > 0 }?.toString()
|
||||
is String -> value.takeIf(GameLaunchLink::validId)
|
||||
else -> null
|
||||
}
|
||||
id
|
||||
}.getOrNull()
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
/** External callers can select a game, never a guest path or arbitrary Steam command. */
|
||||
object GameLaunchLink {
|
||||
private val idPattern = Regex("[1-9][0-9]{0,19}")
|
||||
private val linkPattern = Regex("droiddeck://game/([1-9][0-9]{0,19})")
|
||||
|
||||
fun validId(id: String): Boolean = idPattern.matches(id) && id.toULongOrNull() != null
|
||||
fun parse(link: String?): String? = link?.let { linkPattern.matchEntire(it)?.groupValues?.get(1) }
|
||||
?.takeIf(::validId)
|
||||
fun uri(id: String): String { require(validId(id)); return "droiddeck://game/$id" }
|
||||
fun steamUrl(id: String): String { require(validId(id)); return "steam://rungameid/$id" }
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ShortcutInfo
|
||||
import android.content.pm.ShortcutManager
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.drawable.Icon
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import com.droiddeck.launcher.MainActivity
|
||||
import com.droiddeck.launcher.R
|
||||
|
||||
object GameShortcuts {
|
||||
fun launchIntent(context: Context, gameId: String) = Intent(Intent.ACTION_VIEW,
|
||||
Uri.parse(GameLaunchLink.uri(gameId)), context, MainActivity::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
|
||||
private fun info(context: Context, game: Library.SteamGame): ShortcutInfo {
|
||||
val name = game.name.ifBlank { context.getString(R.string.app_name) }
|
||||
val icon = listOfNotNull(game.icon, game.art).firstNotNullOfOrNull { file -> runCatching {
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeFile(file.path, bounds)
|
||||
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return@runCatching null
|
||||
val options = BitmapFactory.Options().apply {
|
||||
inSampleSize = 1
|
||||
while (maxOf(bounds.outWidth, bounds.outHeight) / inSampleSize > 256) inSampleSize *= 2
|
||||
}
|
||||
BitmapFactory.decodeFile(file.path, options)?.let(Icon::createWithBitmap)
|
||||
}.getOrNull() } ?: Icon.createWithResource(context, R.mipmap.ic_launcher)
|
||||
return ShortcutInfo.Builder(context, "game:${game.gameIdString}")
|
||||
.setActivity(android.content.ComponentName(context, MainActivity::class.java))
|
||||
.setShortLabel(name.take(40)).setLongLabel(name).setIcon(icon)
|
||||
.setIntent(launchIntent(context, game.gameIdString)).build()
|
||||
}
|
||||
|
||||
fun pin(activity: Activity, game: Library.SteamGame) {
|
||||
val manager = activity.getSystemService(ShortcutManager::class.java)
|
||||
val requested = runCatching { manager.isRequestPinShortcutSupported &&
|
||||
manager.requestPinShortcut(info(activity, game), null) }.getOrDefault(false)
|
||||
if (!requested) Toast.makeText(activity, R.string.game_shortcut_unsupported, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
/** Standard shortcut picker result, understood by launchers that import Android shortcuts. */
|
||||
fun result(activity: Activity, game: Library.SteamGame): Intent =
|
||||
activity.getSystemService(ShortcutManager::class.java).createShortcutResultIntent(info(activity, game))
|
||||
}
|
||||
@@ -23,7 +23,11 @@ object Library {
|
||||
val appId: Int, val name: String, val art: File?, val library: String, val gameId: Long = appId.toLong(),
|
||||
val hero: File? = null, val lastPlayed: Long = 0L,
|
||||
val gameFiles: File? = null, val protonPrefix: File? = null,
|
||||
)
|
||||
val icon: File? = null,
|
||||
) {
|
||||
/** Decimal form used by Steam links and Android shortcuts, including unsigned shortcut ids. */
|
||||
val gameIdString: String get() = java.lang.Long.toUnsignedString(gameId)
|
||||
}
|
||||
/** The [SteamGame.library] of a game added to the library rather than installed by Steam. */
|
||||
const val ADDED = "added"
|
||||
|
||||
@@ -68,6 +72,7 @@ object Library {
|
||||
)
|
||||
private val STEAM_CAPSULES = listOf("library_capsule.jpg", "library_600x900.jpg")
|
||||
private val STEAM_HEROES = listOf("library_hero.jpg")
|
||||
private val STEAM_ICON = Regex("[a-fA-F0-9]{40}\\.(jpg|png)")
|
||||
private val NAME = Regex("^\\s*\"name\"\\s*\"([^\"]*)\"", RegexOption.MULTILINE)
|
||||
private val STATE = Regex("^\\s*\"StateFlags\"\\s*\"(\\d+)\"", RegexOption.MULTILINE)
|
||||
private val LAST_PLAYED = Regex("^\\s*\"LastPlayed\"\\s*\"(\\d+)\"", RegexOption.MULTILINE)
|
||||
@@ -96,18 +101,19 @@ object Library {
|
||||
.firstOrNull { it.isDirectory }
|
||||
}
|
||||
|
||||
fun steamGames(context: Context): List<SteamGame> {
|
||||
fun steamGames(context: Context, strictRead: Boolean = false): List<SteamGame> {
|
||||
val root = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
|
||||
val cache = File(root, "appcache/librarycache")
|
||||
val libraries = steamLibraries(context)
|
||||
val out = LinkedHashMap<Int, SteamGame>()
|
||||
for ((library, label) in libraries) {
|
||||
val steamapps = File(library, "steamapps")
|
||||
steamapps.listFiles { f -> f.isFile && f.name.startsWith("appmanifest_") && f.name.endsWith(".acf") }
|
||||
?.sortedBy { it.name }?.forEach { manifest ->
|
||||
val manifests = steamapps.listFiles { f -> f.isFile && f.name.startsWith("appmanifest_") && f.name.endsWith(".acf") }
|
||||
if (strictRead && steamapps.isDirectory) check(manifests != null) { "Steam manifests are unavailable" }
|
||||
manifests?.sortedBy { it.name }?.forEach { manifest ->
|
||||
val appId = manifest.name.removePrefix("appmanifest_").removeSuffix(".acf").toIntOrNull() ?: return@forEach
|
||||
if (appId in NOT_GAMES || out.containsKey(appId)) return@forEach
|
||||
val text = try { manifest.readText() } catch (e: Exception) { return@forEach }
|
||||
val text = try { manifest.readText() } catch (e: Exception) { if (strictRead) throw e; return@forEach }
|
||||
val name = NAME.find(text)?.groupValues?.get(1)?.trim().orEmpty()
|
||||
val flags = STATE.find(text)?.groupValues?.get(1)?.toIntOrNull() ?: 0
|
||||
// StateFlags 4 = fully installed; anything else is downloading, updating or broken.
|
||||
@@ -125,12 +131,26 @@ object Library {
|
||||
appId, name, art, label, hero = hero, lastPlayed = lastPlayed,
|
||||
gameFiles = gameFiles,
|
||||
protonPrefix = protonPrefix(context, appId.toLong(), library),
|
||||
icon = steamCacheImage(cache, appId, listOf("icon.jpg", "icon.png"))
|
||||
?: File(cache, appId.toString()).listFiles()?.firstOrNull { it.isFile && STEAM_ICON.matches(it.name) },
|
||||
)
|
||||
}
|
||||
}
|
||||
return out.values.toList()
|
||||
}
|
||||
|
||||
/** The same installed-game inventory used for links, shortcuts and file exports. */
|
||||
fun launchableGames(context: Context, strictRead: Boolean = false): List<SteamGame> = (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),
|
||||
icon = art.icon?.takeIf { it.extension.lowercase() != "ico" },
|
||||
)
|
||||
}
|
||||
}).distinctBy { it.gameId }
|
||||
|
||||
/** 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())
|
||||
@@ -184,7 +204,7 @@ object Library {
|
||||
* Whether a program from the rail runs at the panel's own resolution rather than the session's
|
||||
* 720p default. melonDS draws two 256x192 screens on the CPU: the panel's size costs it
|
||||
* nothing, and 720p scaled up to the panel blurs the sharp pixels its screen layout is set
|
||||
* up for (bannerlator-pad-defaults).
|
||||
* up for (droiddeck-pad-defaults).
|
||||
*/
|
||||
fun drawsAtPanel(program: String?): Boolean = specs.any { it.program == program && it.atPanel }
|
||||
|
||||
@@ -321,7 +341,7 @@ object Library {
|
||||
// Dolphin: full screen, drawn inside its own main window, without its "stop the emulation?"
|
||||
// question; no warning boxes either, which wait for a click a controller cannot give
|
||||
// (they still go to Dolphin's log). The guide button is Dolphin's Toggle Fullscreen hotkey
|
||||
// (bannerlator-pad-defaults), which shows Dolphin's window and its settings - so no -b, which
|
||||
// (droiddeck-pad-defaults), which shows Dolphin's window and its settings - so no -b, which
|
||||
// hides that window; closing Dolphin ends the session. Under gamescope Dolphin does not
|
||||
// always see its window as focused, and by default both its hotkeys and the game's
|
||||
// controller then stop: HotkeysRequireFocus off, BackgroundInput on. -C sets a Dolphin.ini
|
||||
@@ -333,10 +353,10 @@ object Library {
|
||||
"-e", guestPath,
|
||||
)
|
||||
// Full screen (LaunchSettings.cpp -f); the settings seed keeps the Getting Started
|
||||
// dialog away (bannerlator-pad-defaults).
|
||||
// dialog away (droiddeck-pad-defaults).
|
||||
"cemu" -> listOf("-f", "-g", guestPath)
|
||||
// Full screen (CLI.cpp --fullscreen); the guide button leaves it for melonDS's menus and
|
||||
// comes back (HK_FullscreenToggle, bannerlator-pad-defaults).
|
||||
// comes back (HK_FullscreenToggle, droiddeck-pad-defaults).
|
||||
"melonds" -> listOf("-f", guestPath)
|
||||
else -> listOf(guestPath)
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ object LibraryCache {
|
||||
appId = o.getInt("appId"), name = o.getString("name"), art = o.file("art"),
|
||||
library = o.getString("library"), gameId = o.getLong("gameId"), hero = o.file("hero"),
|
||||
lastPlayed = o.optLong("lastPlayed"), gameFiles = o.file("gameFiles"), protonPrefix = o.file("protonPrefix"),
|
||||
icon = o.file("icon"),
|
||||
)
|
||||
}
|
||||
}.getOrElse { e -> Log.w(TAG, "unreadable, ignored: ${e.message}"); emptyList() }
|
||||
@@ -35,7 +36,7 @@ object LibraryCache {
|
||||
for (g in games) array.put(
|
||||
JSONObject().put("appId", g.appId).put("name", g.name).put("library", g.library).put("gameId", g.gameId)
|
||||
.put("lastPlayed", g.lastPlayed).putFile("art", g.art).putFile("hero", g.hero)
|
||||
.putFile("gameFiles", g.gameFiles).putFile("protonPrefix", g.protonPrefix),
|
||||
.putFile("gameFiles", g.gameFiles).putFile("protonPrefix", g.protonPrefix).putFile("icon", g.icon),
|
||||
)
|
||||
val f = file(context)
|
||||
val tmp = File(f.path + ".tmp")
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Which catalog components a game's prefix already has from elsewhere - a real installer, the game,
|
||||
* the user - so the list can say so instead of recommending them again. Bannerlator's detector,
|
||||
* on DroidDeck's compatdata/<id>: only markers absent from a fresh Proton prefix count (OpenAL32,
|
||||
* Mono, PhysX, Games for Windows Live); msvcp140, d3dx9 and the like ship with Proton and cannot
|
||||
* tell. Files DroidDeck put there itself (droiddeck-wincomponents' state) are not counted.
|
||||
* Pure filesystem reads; never throws.
|
||||
*/
|
||||
object PrefixInstalledDetector {
|
||||
private const val STATE = ".droiddeck-wincomponents.json"
|
||||
|
||||
/** Detector names (oalinst, mono, physx, XLiveRedist) present in [compat]/pfx. */
|
||||
fun detect(compat: File?): Set<String> = try {
|
||||
val pfx = compat?.let { File(it, "pfx") }
|
||||
if (pfx == null || !pfx.isDirectory) emptySet() else {
|
||||
val ours = ours(compat)
|
||||
val out = HashSet<String>()
|
||||
val openal = listOf("system32", "syswow64").map { "drive_c/windows/$it/openal32.dll" }
|
||||
if (openal.any { it !in ours && fileIgnoringCase(pfx, it) }) out += "oalinst"
|
||||
if (File(pfx, "drive_c/windows/mono").isDirectory) out += "mono"
|
||||
if (hasPhysX(pfx)) out += "physx"
|
||||
if (registryContains(pfx, "Games for Windows")) out += "XLiveRedist"
|
||||
out
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
emptySet()
|
||||
}
|
||||
|
||||
private fun ours(compat: File): Set<String> = try {
|
||||
val files = JSONObject(File(compat, STATE).readText()).optJSONObject("files")
|
||||
files?.keys()?.asSequence()?.map { it.lowercase() }?.toSet().orEmpty()
|
||||
} catch (_: Exception) {
|
||||
emptySet()
|
||||
}
|
||||
|
||||
/** Windows names ignore case; the prefix's folder may spell them either way. */
|
||||
private fun fileIgnoringCase(pfx: File, rel: String): Boolean {
|
||||
val target = File(pfx, rel)
|
||||
return target.parentFile?.listFiles()?.any { it.isFile && it.name.equals(target.name, ignoreCase = true) } == true
|
||||
}
|
||||
|
||||
private fun hasPhysX(pfx: File): Boolean {
|
||||
for (pf in arrayOf("drive_c/Program Files", "drive_c/Program Files (x86)")) {
|
||||
val base = File(pfx, pf)
|
||||
if (File(base, "NVIDIA Corporation/PhysX").isDirectory) return true
|
||||
if (base.listFiles()?.any { it.isDirectory && it.name.contains("PhysX", ignoreCase = true) } == true) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun registryContains(pfx: File, needle: String): Boolean =
|
||||
arrayOf("system.reg", "user.reg").any { name ->
|
||||
val f = File(pfx, name)
|
||||
try {
|
||||
f.isFile && f.length() < 32L * 1024 * 1024 &&
|
||||
f.bufferedReader().use { r -> r.lineSequence().any { it.contains(needle, ignoreCase = true) } }
|
||||
} catch (_: Throwable) {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* What Steam itself installs alongside a game: the Steamworks Shared redistributables (app 228980)
|
||||
* listed under "SharedDepots" in the game's appmanifest. Those live in Steamworks Shared rather than
|
||||
* the game's folder, so the folder scan ([DependencyDetector]) cannot see them; this reads Steam's
|
||||
* list instead. Depots are matched to Bannerlator's catalog names; unknown ones are left out.
|
||||
*/
|
||||
object SteamRedists {
|
||||
/** Steamworks Shared depot -> (catalog component, what Steam calls it). 2015-2019 runtimes are one family. */
|
||||
val DEPOTS = linkedMapOf(
|
||||
"228981" to ("vcredist2005_dll" to "VC++ 2005"),
|
||||
"228982" to ("vcredist2008_dll" to "VC++ 2008"),
|
||||
"228983" to ("vcredist2010_dll" to "VC++ 2010"),
|
||||
"228984" to ("vcredist2012_dll" to "VC++ 2012"),
|
||||
"228985" to ("vcredist2013_dll" to "VC++ 2013"),
|
||||
"228986" to ("vcredist2015_dll" to "VC++ 2015"),
|
||||
"228987" to ("vcredist2019_dll" to "VC++ 2017"),
|
||||
"228988" to ("vcredist2019_dll" to "VC++ 2019"),
|
||||
"228989" to ("vcredist2022_dll" to "VC++ 2022"),
|
||||
"228990" to ("d3dx9" to "DirectX (June 2010)"),
|
||||
)
|
||||
private val BLOCK = Regex("\"SharedDepots\"\\s*\\{([^}]*)\\}", RegexOption.IGNORE_CASE)
|
||||
private val ENTRY = Regex("\"(\\d+)\"\\s+\"\\d+\"")
|
||||
|
||||
/** The game's appmanifest beside its install folder (steamapps/common/<dir> -> steamapps/). */
|
||||
fun manifest(gameDir: File?, appId: Int): File? {
|
||||
val steamapps = gameDir?.parentFile?.parentFile ?: return null
|
||||
return File(steamapps, "appmanifest_$appId.acf").takeIf { it.isFile }
|
||||
}
|
||||
|
||||
fun detect(gameDir: File?, appId: Int): List<DependencyDetector.Recommendation> {
|
||||
val text = try {
|
||||
manifest(gameDir, appId)?.takeIf { it.length() < 1L shl 20 }?.readText() ?: return emptyList()
|
||||
} catch (_: Exception) {
|
||||
return emptyList()
|
||||
}
|
||||
val block = BLOCK.find(text)?.groupValues?.get(1) ?: return emptyList()
|
||||
return ENTRY.findAll(block).mapNotNull { DEPOTS[it.groupValues[1]] }
|
||||
.distinctBy { it.first }
|
||||
.map { (component, label) -> DependencyDetector.Recommendation(component, label, label, DependencyDetector.Kind.STEAM) }
|
||||
.toList()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package com.droiddeck.launcher.gpu
|
||||
|
||||
import android.app.ActivityManager
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Everything the device info window shows about the phone's chip: SoC, GPU, CPU, memory, system,
|
||||
* the phone's own Vulkan driver and the drivers DroidDeck uses. Reads files and asks Vulkan once
|
||||
* ([VulkanInfo]), so [collect] belongs off the main thread. Values the phone does not give are
|
||||
* left out rather than guessed.
|
||||
*/
|
||||
object DeviceInfo {
|
||||
class Section(val title: String, val rows: List<Pair<String, String>>)
|
||||
|
||||
fun collect(context: Context): List<Section> {
|
||||
val vk = VulkanInfo.query()
|
||||
// After Vulkan has been asked, detection can use its name too.
|
||||
val gpu = GpuInfo.detect()
|
||||
return listOf(
|
||||
chip(), gpu(gpu), cpu(), memory(context), system(), vulkan(context, vk), drivers(context, gpu),
|
||||
).filter { it.rows.isNotEmpty() }
|
||||
}
|
||||
|
||||
/** The whole window as plain text, for a bug report. */
|
||||
fun text(sections: List<Section>): String = buildString {
|
||||
append("DroidDeck device info\n")
|
||||
for (s in sections) {
|
||||
append('\n').append(s.title).append('\n')
|
||||
for ((k, v) in s.rows) append(" ").append(k).append(": ").append(v).append('\n')
|
||||
}
|
||||
}
|
||||
|
||||
private fun MutableList<Pair<String, String>>.add(key: String, value: String?) {
|
||||
if (!value.isNullOrBlank() && value != Build.UNKNOWN) add(key to value.trim())
|
||||
}
|
||||
|
||||
private fun read(path: String): String? = FileUtils.readString(File(path))?.trim()?.ifEmpty { null }
|
||||
|
||||
private fun prop(name: String): String = GpuInfo.systemProperty(name)
|
||||
|
||||
private fun chip() = Section("Chip (SoC)", buildList<Pair<String, String>> {
|
||||
val model = SocNames.model()
|
||||
val platform = prop("ro.board.platform")
|
||||
add("Name", SocNames.name(model, platform))
|
||||
add("Model", model)
|
||||
if (Build.VERSION.SDK_INT >= 31) add("Maker", Build.SOC_MANUFACTURER)
|
||||
add("Platform", platform)
|
||||
add("Hardware", Build.HARDWARE)
|
||||
add("Board", Build.BOARD)
|
||||
val id = read("/sys/devices/soc0/soc_id")
|
||||
val machine = read("/sys/devices/soc0/machine")
|
||||
add("SoC id", listOfNotNull(id, machine).joinToString(" · "))
|
||||
add("Family", read("/sys/devices/soc0/family"))
|
||||
})
|
||||
|
||||
private fun gpu(gpu: GpuInfo) = Section("GPU", buildList<Pair<String, String>> {
|
||||
add("GPU", gpu.name)
|
||||
add("Family", gpu.family.label)
|
||||
add("Model found from", when (gpu.modelSource) {
|
||||
"kernel" -> "the kernel's GPU name"
|
||||
"vulkan" -> "the Vulkan driver (the kernel's name has no model)"
|
||||
"platform" -> "the platform (the kernel's name has no model)"
|
||||
else -> "unknown"
|
||||
})
|
||||
add("Kernel name", gpu.kgslName)
|
||||
val kgsl = "/sys/class/kgsl/kgsl-3d0"
|
||||
add("Clock range", gpuRange())
|
||||
add("Load", read("$kgsl/gpu_busy_percentage")?.let { if (it.endsWith("%")) it else "$it %" })
|
||||
add("Temperature", read("$kgsl/temp")?.toLongOrNull()?.let { if (it > 1000) "${it / 1000} °C" else "$it °C" })
|
||||
add("Support", gpu.supportText)
|
||||
})
|
||||
|
||||
private fun cpu() = Section("CPU", buildList<Pair<String, String>> {
|
||||
add("Cores", Runtime.getRuntime().availableProcessors().toString())
|
||||
// Each cpufreq policy is one cluster: the cores it covers and their speed range.
|
||||
val policies = File("/sys/devices/system/cpu/cpufreq").listFiles { f -> f.name.matches(Regex("policy\\d+")) }
|
||||
?.sortedBy { it.name.removePrefix("policy").toInt() }.orEmpty()
|
||||
for (p in policies) {
|
||||
val cpus = (read("${p.path}/related_cpus") ?: read("${p.path}/affected_cpus"))?.split(Regex("\\s+"))?.filter { it.isNotEmpty() }.orEmpty()
|
||||
val min = read("${p.path}/cpuinfo_min_freq")?.toLongOrNull()
|
||||
val max = read("${p.path}/cpuinfo_max_freq")?.toLongOrNull() ?: continue
|
||||
val cores = if (cpus.size > 1) "cores ${cpus.first()}-${cpus.last()}" else "core ${cpus.firstOrNull() ?: p.name.removePrefix("policy")}"
|
||||
add("Cluster ${p.name.removePrefix("policy")}", "${cpus.size.coerceAtLeast(1)} × $cores · " +
|
||||
(if (min != null) ghz(min) + "–" else "") + ghz(max))
|
||||
}
|
||||
add("ABI", Build.SUPPORTED_ABIS.joinToString(", "))
|
||||
})
|
||||
|
||||
private fun ghz(khz: Long) = String.format(Locale.US, "%.2f GHz", khz / 1e6)
|
||||
|
||||
/** The GPU's slowest and fastest step: KGSL's list, else a devfreq GPU node (Mali and others). */
|
||||
private fun gpuRange(): String? {
|
||||
fun mhz(hz: Long) = "${hz / 1_000_000} MHz"
|
||||
val kgsl = read("/sys/class/kgsl/kgsl-3d0/gpu_available_frequencies")
|
||||
?.split(Regex("\\s+"))?.mapNotNull { it.toLongOrNull() }?.filter { it > 0 }.orEmpty()
|
||||
if (kgsl.isNotEmpty()) return "${mhz(kgsl.min())}–${mhz(kgsl.max())} (${kgsl.size} steps)"
|
||||
read("/sys/class/kgsl/kgsl-3d0/max_gpuclk")?.toLongOrNull()?.let { return "up to ${mhz(it)}" }
|
||||
val node = File("/sys/class/devfreq").listFiles()?.firstOrNull { f ->
|
||||
val n = f.name.lowercase()
|
||||
(n.contains("gpu") || n.contains("mali") || n.contains("kgsl-3d")) && listOf("bus", "bw", "memlat").none { n.contains(it) }
|
||||
} ?: return null
|
||||
val steps = read("${node.path}/available_frequencies")?.split(Regex("\\s+"))?.mapNotNull { it.toLongOrNull() }?.filter { it > 0 }.orEmpty()
|
||||
if (steps.isNotEmpty()) return "${mhz(steps.min())}–${mhz(steps.max())} (${steps.size} steps)"
|
||||
val min = read("${node.path}/min_freq")?.toLongOrNull()
|
||||
val max = read("${node.path}/max_freq")?.toLongOrNull() ?: return null
|
||||
return (if (min != null) "${mhz(min)}–" else "up to ") + mhz(max)
|
||||
}
|
||||
|
||||
private fun memory(context: Context) = Section("Memory", buildList<Pair<String, String>> {
|
||||
val am = context.getSystemService(ActivityManager::class.java) ?: return@buildList
|
||||
val mi = ActivityManager.MemoryInfo().also(am::getMemoryInfo)
|
||||
add("Total", FileUtils.sizeToString(mi.totalMem))
|
||||
add("Available now", FileUtils.sizeToString(mi.availMem))
|
||||
})
|
||||
|
||||
private fun system() = Section("System", buildList<Pair<String, String>> {
|
||||
add("Device", "${Build.MANUFACTURER} ${Build.MODEL}")
|
||||
add("Android", "${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})")
|
||||
add("Security patch", Build.VERSION.SECURITY_PATCH)
|
||||
add("Kernel", System.getProperty("os.version"))
|
||||
add("Build", Build.DISPLAY)
|
||||
})
|
||||
|
||||
private fun vulkan(context: Context, vk: Map<String, String>) = Section("Vulkan (the phone's own driver)", buildList<Pair<String, String>> {
|
||||
vk["error"]?.let { add("Error", it) }
|
||||
add("Device", vk["device"])
|
||||
add("Type", vk["type"])
|
||||
add("API version", vk["api"])
|
||||
add("Loader version", vk["loader"])
|
||||
add("Driver", listOfNotNull(vk["driver_name"], vk["driver_version"]).joinToString(" "))
|
||||
// Qualcomm's build string starts with its own label ("Driver Build: 43c70540de, ...").
|
||||
add("Driver build", vk["driver_info"]?.replace(Regex("(?i)^driver build:\\s*"), ""))
|
||||
add("Conformance", vk["conformance"])
|
||||
add("Vendor / device id", listOfNotNull(vk["vendor_id"], vk["device_id"]).joinToString(" / "))
|
||||
add("Max texture size", vk["max_image_2d"])
|
||||
add("GPU memory heap", vk["device_heap_mb"]?.let { "$it MB" })
|
||||
add("Extensions", vk["extension_count"])
|
||||
val features = listOf("geometry_shader" to "Geometry shaders", "tessellation_shader" to "Tessellation", "texture_bc" to "BC textures")
|
||||
for ((key, label) in features) add(label, vk[key])
|
||||
for ((key, value) in vk) if (key.startsWith("ext.")) add(key.removePrefix("ext."), value)
|
||||
// What Android certifies the phone for.
|
||||
val pm = context.packageManager
|
||||
val hw = pm.systemAvailableFeatures.associate { it.name to it.version }
|
||||
hw[PackageManager.FEATURE_VULKAN_HARDWARE_VERSION]?.let {
|
||||
add("Android-certified version", "${it shr 22}.${(it shr 12) and 0x3ff}.${it and 0xfff}")
|
||||
}
|
||||
hw[PackageManager.FEATURE_VULKAN_HARDWARE_LEVEL]?.let { add("Android hardware level", it.toString()) }
|
||||
})
|
||||
|
||||
private fun drivers(context: Context, gpu: GpuInfo) = Section("Drivers DroidDeck uses", buildList<Pair<String, String>> {
|
||||
add("Driver mode", SessionPrefs.gpuDriverMode(context))
|
||||
val turnip = TurnipDriver(context)
|
||||
val choice = SessionPrefs.androidDriver(context).ifEmpty { turnip.autoId() }
|
||||
add("Display driver", "${turnip.displayName(choice)} ${turnip.driverVersion(choice)}".trim())
|
||||
val linux = LinuxVulkanDriverManager(context)
|
||||
val id = SessionPrefs.linuxDriver(context)
|
||||
add("Game driver", if (id.isEmpty()) "the runtime's own Turnip" else "${linux.getDriverName(id)} ${linux.getDriverVersion(id)}".trim())
|
||||
if (gpu.oneUi8Gen2) add("Note", "One UI on an 8 Gen 2: needs the OneUI Turnip build")
|
||||
})
|
||||
}
|
||||
@@ -16,10 +16,14 @@ data class GpuInfo(
|
||||
/** The three-digit Adreno model (740, 825), or 0 when KGSL does not say. */
|
||||
val model: Int,
|
||||
val family: Family,
|
||||
/** The SoC as the device reports it ("SM8550", "QCS8550"), or "" when it does not. */
|
||||
/** The SoC as a person would say it ("Snapdragon 8 Gen 2 (QCS8550)"), or "" when the phone does not say. */
|
||||
val soc: String,
|
||||
/** Samsung's One UI on an 8 Gen 2: its Turnip needs the OneUI build, or frames tear and flicker. */
|
||||
val oneUi8Gen2: Boolean,
|
||||
/** KGSL's own name for the GPU, as the kernel spells it ("Adreno740v2", "Adreno33v2"). */
|
||||
val kgslName: String = "",
|
||||
/** Where [model] came from: "kernel", "vulkan" or "platform", "" when unknown. */
|
||||
val modelSource: String = "",
|
||||
) {
|
||||
enum class Family(val label: String) {
|
||||
A8XX("Adreno 8xx"),
|
||||
@@ -56,17 +60,49 @@ data class GpuInfo(
|
||||
val adreno = File("/sys/class/kgsl/kgsl-3d0").exists() || File("/vendor/lib64/hw/vulkan.adreno.so").exists()
|
||||
val raw = listOf("/sys/class/kgsl/kgsl-3d0/gpu_model", "/sys/class/kgsl/kgsl-3d0/gpu_chipid")
|
||||
.firstNotNullOfOrNull { FileUtils.readString(File(it))?.trim()?.takeIf(String::isNotEmpty) }
|
||||
val soc = if (Build.VERSION.SDK_INT >= 31) Build.SOC_MODEL.takeIf { it.isNotBlank() && it != Build.UNKNOWN }.orEmpty() else ""
|
||||
val model = raw?.let { Regex("""(\d{3})""").find(it)?.groupValues?.get(1)?.toIntOrNull() } ?: 0
|
||||
// Where vendors put the chip's model, named when it is known: "Snapdragon 8 Gen 2 (QCS8550)".
|
||||
val soc = if (adreno) SocNames.label() else ""
|
||||
val fromKernel = raw?.let { threeDigits(it) } ?: 0
|
||||
// Some kernels name the GPU without its model (AYANEO's Pocket FIT: "Adreno33v2"). The
|
||||
// Vulkan driver's own name, when this process has asked it, then the platform's code
|
||||
// name stand in - the GPU is the same on every phone of a platform.
|
||||
val fromVulkan = if (fromKernel > 0 || !adreno) 0 else VulkanInfo.cachedOrNull()?.get("device")?.let { threeDigits(it) } ?: 0
|
||||
val fromPlatform = if (fromKernel > 0 || fromVulkan > 0 || !adreno) 0 else platformModel()
|
||||
val model = maxOf(fromKernel, fromVulkan, fromPlatform)
|
||||
val source = when {
|
||||
fromKernel > 0 -> "kernel"
|
||||
fromVulkan > 0 -> "vulkan"
|
||||
fromPlatform > 0 -> "platform"
|
||||
else -> ""
|
||||
}
|
||||
val family = familyOf(adreno, model)
|
||||
val samsung = Build.MANUFACTURER.equals("samsung", ignoreCase = true)
|
||||
return GpuInfo(
|
||||
name = if (!adreno) Build.HARDWARE.ifBlank { "this GPU" } else if (model > 0) "Adreno $model" else "Adreno",
|
||||
model = model, family = family, soc = soc,
|
||||
oneUi8Gen2 = samsung && model == 740,
|
||||
kgslName = raw.orEmpty(), modelSource = source,
|
||||
)
|
||||
}
|
||||
|
||||
private fun threeDigits(text: String): Int = Regex("""(\d{3})""").find(text)?.groupValues?.get(1)?.toIntOrNull() ?: 0
|
||||
|
||||
/** Qualcomm platform code names whose GPU is known for certain. */
|
||||
private val PLATFORMS = mapOf(
|
||||
"msmnile" to 640, "kona" to 650, "lahaina" to 660, "taro" to 730, "cape" to 730,
|
||||
"kalama" to 740, "pineapple" to 750, "sun" to 830,
|
||||
)
|
||||
|
||||
internal fun platformModel(platform: String = systemProperty("ro.board.platform")): Int =
|
||||
PLATFORMS[platform.lowercase()] ?: PLATFORMS[systemProperty("ro.vendor.qti.soc_name").lowercase()] ?: 0
|
||||
|
||||
/** android.os.SystemProperties.get, which apps may read but not call directly. */
|
||||
fun systemProperty(name: String): String = try {
|
||||
Class.forName("android.os.SystemProperties").getMethod("get", String::class.java).invoke(null, name) as? String ?: ""
|
||||
} catch (_: Exception) {
|
||||
""
|
||||
}
|
||||
|
||||
internal fun familyOf(adreno: Boolean, model: Int): Family = when {
|
||||
!adreno -> Family.NOT_ADRENO
|
||||
model == 0 -> Family.ADRENO_UNKNOWN
|
||||
|
||||
@@ -6,7 +6,7 @@ package com.droiddeck.launcher.gpu;
|
||||
* DXBC chain translated to SPIR-V. {@link Lossless} decides when; these calls block.
|
||||
*/
|
||||
final class LsfgNative {
|
||||
static { System.loadLibrary("bannerwayland"); }
|
||||
static { System.loadLibrary("droiddeckwayland"); }
|
||||
|
||||
private LsfgNative() {}
|
||||
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package com.droiddeck.launcher.gpu
|
||||
|
||||
import com.droiddeck.launcher.wayland.WaylandCompositor
|
||||
import org.json.JSONObject
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* The compositor's post chain (waylandcomp/src/effects_chain.c) as the session menu drives it:
|
||||
* the colour grade, the sharpening, the shader toggles and terminal debanding. Units are the
|
||||
* menu's own, so a value goes straight from a row into the setter it drives:
|
||||
*
|
||||
* - [brightness] / [contrast] -100..100, 0 = neutral
|
||||
* - [gamma] 0.5..3.0, 1.0 = neutral
|
||||
* - [saturation] 0..200 percent, 100 = neutral
|
||||
* - [casLevel] 0..100; the pass runs only while [cas] is on
|
||||
* - [debandStrength] 0..200, 100 = one LSB
|
||||
*
|
||||
* Applies live: the compositor picks the change up on its next frame. While any pass is on, a
|
||||
* zero-copy game goes through the compositor pass instead, which costs a little GPU time.
|
||||
*/
|
||||
data class ScreenEffects(
|
||||
val brightness: Int = 0,
|
||||
val contrast: Int = 0,
|
||||
val gamma: Float = 1f,
|
||||
val saturation: Int = 100,
|
||||
val cas: Boolean = false,
|
||||
val casLevel: Int = 50,
|
||||
val hdr: Boolean = false,
|
||||
val deband: Boolean = false,
|
||||
val debandStrength: Int = 100,
|
||||
val fxaa: Boolean = false,
|
||||
val toon: Boolean = false,
|
||||
val crt: Boolean = false,
|
||||
val ntsc: Boolean = false,
|
||||
) {
|
||||
/** The sharpening level the picture gets: 0 while the pass is off, whatever its level is parked at. */
|
||||
val effectiveCas: Int get() = if (cas) casLevel else 0
|
||||
|
||||
/** Whether any pass runs, so the menu can say when the picture is untouched. */
|
||||
val any: Boolean get() = brightness != 0 || contrast != 0 || abs(gamma - 1f) > 0.005f || saturation != 100 ||
|
||||
effectiveCas > 0 || hdr || deband || fxaa || toon || crt || ntsc
|
||||
|
||||
/** Hands every pass to the compositor, with the Look's name for its log line. */
|
||||
fun push(upscaler: Int) {
|
||||
WaylandCompositor.nativeSetCas(cas, casLevel)
|
||||
WaylandCompositor.nativeSetHdr(hdr)
|
||||
WaylandCompositor.nativeSetDeband(deband, debandStrength)
|
||||
WaylandCompositor.nativeSetScreenEffects(brightness.toFloat(), contrast.toFloat(), gamma, saturation.toFloat(), fxaa, toon, crt, ntsc)
|
||||
WaylandCompositor.nativeSetLookName(ScreenEffectLooks.match(this, upscaler)?.name)
|
||||
}
|
||||
|
||||
fun encode(): JSONObject = JSONObject()
|
||||
.put("brightness", brightness).put("contrast", contrast).put("gamma", gamma.toDouble()).put("saturation", saturation)
|
||||
.put("cas", cas).put("casLevel", casLevel).put("hdr", hdr).put("deband", deband).put("debandStrength", debandStrength)
|
||||
.put("fxaa", fxaa).put("toon", toon).put("crt", crt).put("ntsc", ntsc)
|
||||
|
||||
companion object {
|
||||
val OFF = ScreenEffects()
|
||||
|
||||
fun decode(json: JSONObject): ScreenEffects = ScreenEffects(
|
||||
brightness = json.optInt("brightness", 0).coerceIn(-100, 100),
|
||||
contrast = json.optInt("contrast", 0).coerceIn(-100, 100),
|
||||
gamma = json.optDouble("gamma", 1.0).toFloat().coerceIn(0.5f, 3f),
|
||||
saturation = json.optInt("saturation", 100).coerceIn(0, 200),
|
||||
cas = json.optBoolean("cas", false),
|
||||
casLevel = json.optInt("casLevel", 50).coerceIn(0, 100),
|
||||
hdr = json.optBoolean("hdr", false),
|
||||
deband = json.optBoolean("deband", false),
|
||||
debandStrength = json.optInt("debandStrength", 100).coerceIn(0, 200),
|
||||
fxaa = json.optBoolean("fxaa", false),
|
||||
toon = json.optBoolean("toon", false),
|
||||
crt = json.optBoolean("crt", false),
|
||||
ntsc = json.optBoolean("ntsc", false),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One Look: a named set of values for the rows that already exist on the Display page. Picking
|
||||
* a Look moves those rows; each stays adjustable afterwards, and the moment one moves the Look
|
||||
* row reads Custom. [scalingMode] null leaves the scaling mode alone; otherwise it is a
|
||||
* SessionPrefs.upscalerChoices mode the Look switches to.
|
||||
*/
|
||||
data class Look(
|
||||
val name: String,
|
||||
val effects: ScreenEffects,
|
||||
val scalingMode: Int? = null,
|
||||
/** One line under the row while this Look is picked. */
|
||||
val desc: String,
|
||||
)
|
||||
|
||||
object ScreenEffectLooks {
|
||||
// Scaling modes as the compositor numbers them (WaylandCompositor.nativeSetUpscaler).
|
||||
private const val SCALING_NEAREST = 2
|
||||
private const val SCALING_FSR = 4
|
||||
|
||||
/** Index 0 is the neutral state a fresh session matches, so the row always names something. */
|
||||
val LOOKS: List<Look> = listOf(
|
||||
Look("Off", ScreenEffects.OFF, desc = "Nothing applied."),
|
||||
Look("Game Clarity", ScreenEffects(brightness = 2, contrast = 12, saturation = 108, cas = true, casLevel = 55),
|
||||
desc = "Sharpening with a touch of contrast - the everyday one."),
|
||||
Look("Vivid", ScreenEffects(brightness = 3, contrast = 10, gamma = 0.98f, saturation = 145, cas = true, casLevel = 30, deband = true),
|
||||
desc = "Colour pushed hard. Deband on so skies don't stripe."),
|
||||
Look("Cinematic", ScreenEffects(brightness = -4, contrast = 22, gamma = 1.10f, saturation = 88, cas = true, casLevel = 20, fxaa = true, deband = true),
|
||||
desc = "Deeper contrast, cooler colour, edges smoothed."),
|
||||
Look("Competitive", ScreenEffects(brightness = 16, contrast = 18, gamma = 0.92f, saturation = 82, cas = true, casLevel = 75),
|
||||
desc = "Shadows lifted and heavy sharpening - spot people first."),
|
||||
Look("Adaptive Sharpen", ScreenEffects(cas = true, casLevel = 85),
|
||||
desc = "Sharpening only, colour untouched."),
|
||||
Look("Filmic", ScreenEffects(brightness = -2, contrast = 16, gamma = 1.14f, saturation = 92, cas = true, casLevel = 15, fxaa = true, deband = true),
|
||||
desc = "Film-like curve, slightly muted."),
|
||||
Look("Arcade", ScreenEffects(brightness = 6, contrast = 20, gamma = 0.95f, saturation = 170, cas = true, casLevel = 45, deband = true),
|
||||
desc = "Loud and punchy."),
|
||||
Look("Retro CRT", ScreenEffects(brightness = 6, contrast = 14, gamma = 1.05f, saturation = 115, crt = true, ntsc = true),
|
||||
desc = "The CRT tube and analogue signal passes together."),
|
||||
Look("Upscale Sharp", ScreenEffects(contrast = 6, saturation = 104, cas = true, casLevel = 70), scalingMode = SCALING_FSR,
|
||||
desc = "For running below panel resolution."),
|
||||
Look("Pixel Clean", ScreenEffects(contrast = 8, deband = true), scalingMode = SCALING_NEAREST,
|
||||
desc = "Crisp pixels for 2D and old titles."),
|
||||
Look("Anime Edge", ScreenEffects(brightness = 2, contrast = 10, saturation = 125, cas = true, casLevel = 50, toon = true, deband = true),
|
||||
desc = "Toon outlines plus sharpening."),
|
||||
)
|
||||
|
||||
/**
|
||||
* The Look the live values are, or null for Custom. A Look's sharpening is compared as the
|
||||
* effective level, so a pass switched off matches a Look without one whatever its level is
|
||||
* parked at; a Look with no scaling mode of its own matches under any.
|
||||
*/
|
||||
fun match(e: ScreenEffects, upscaler: Int): Look? = LOOKS.firstOrNull { l ->
|
||||
val t = l.effects
|
||||
t.brightness == e.brightness && t.contrast == e.contrast && abs(t.gamma - e.gamma) < 0.005f &&
|
||||
t.saturation == e.saturation && t.effectiveCas == e.effectiveCas &&
|
||||
t.fxaa == e.fxaa && t.crt == e.crt && t.toon == e.toon && t.ntsc == e.ntsc && t.deband == e.deband &&
|
||||
(l.scalingMode == null || l.scalingMode == upscaler)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.droiddeck.launcher.gpu
|
||||
|
||||
import android.os.Build
|
||||
|
||||
/**
|
||||
* Which chip this is, as a person would say it. Handhelds often leave Android's SoC model blank
|
||||
* (the AYANEO Pocket FIT does), so the model is looked for where vendors put it, the same order
|
||||
* ClusterTune uses; then it is named from a table of models we are sure of, or from the platform
|
||||
* code name as a family ("Snapdragon 8 Gen 3 family") when the model itself is a vendor variant.
|
||||
*/
|
||||
object SocNames {
|
||||
/** Model codes whose product name is certain. */
|
||||
private val MODELS = mapOf(
|
||||
"SM8150" to "Snapdragon 855", "SM8250" to "Snapdragon 865", "SM8250-AC" to "Snapdragon 870",
|
||||
"SM8350" to "Snapdragon 888", "SM8450" to "Snapdragon 8 Gen 1", "SM8475" to "Snapdragon 8+ Gen 1",
|
||||
"SM8550" to "Snapdragon 8 Gen 2", "QCS8550" to "Snapdragon 8 Gen 2", "SM8650" to "Snapdragon 8 Gen 3",
|
||||
"SM8750" to "Snapdragon 8 Elite", "SM8850" to "Snapdragon 8 Elite Gen 5",
|
||||
"SM7325" to "Snapdragon 778G", "SM7450" to "Snapdragon 7 Gen 1", "SM7550" to "Snapdragon 7 Gen 3",
|
||||
"SM6375" to "Snapdragon 695",
|
||||
)
|
||||
|
||||
/** Platform code names: every chip of one platform shares its CPU and GPU design. */
|
||||
private val PLATFORMS = mapOf(
|
||||
"msmnile" to "Snapdragon 855/860", "kona" to "Snapdragon 865/870", "lahaina" to "Snapdragon 888",
|
||||
"taro" to "Snapdragon 8 Gen 1", "cape" to "Snapdragon 8+ Gen 1", "kalama" to "Snapdragon 8 Gen 2",
|
||||
"pineapple" to "Snapdragon 8 Gen 3", "sun" to "Snapdragon 8 Elite",
|
||||
)
|
||||
|
||||
/** The chip's model code ("QCS8550", "SG8350P"), or "" when the phone does not say. */
|
||||
fun model(prop: (String) -> String = GpuInfo::systemProperty): String {
|
||||
val sdk = if (Build.VERSION.SDK_INT >= 31) Build.SOC_MODEL.takeIf { it != Build.UNKNOWN }.orEmpty() else ""
|
||||
return listOf(prop("ro.soc.model"), prop("ro.vendor.qti.soc_model"), sdk, prop("ro.fota.platform"))
|
||||
.map { it.trim() }
|
||||
// ro.fota.platform is often a branch name ("MSM_13.0"), not a chip.
|
||||
.firstOrNull { it.isNotEmpty() && !Regex("(?i)^msm_\\d").containsMatchIn(it) }.orEmpty()
|
||||
}
|
||||
|
||||
/** "Snapdragon 8 Gen 2", "Snapdragon 8 Gen 3 family", or null when neither the model nor the platform is known. */
|
||||
fun name(model: String, platform: String = GpuInfo.systemProperty("ro.board.platform")): String? =
|
||||
MODELS[model.uppercase()] ?: PLATFORMS[platform.lowercase()]?.let { "$it family" }
|
||||
|
||||
/** For the GPU card: "Snapdragon 8 Gen 2 (QCS8550)", the bare model, or "". */
|
||||
fun label(model: String = model(), platform: String = GpuInfo.systemProperty("ro.board.platform")): String {
|
||||
val name = name(model, platform)
|
||||
return when {
|
||||
name != null && model.isNotEmpty() -> "$name ($model)"
|
||||
name != null -> name
|
||||
else -> model
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.droiddeck.launcher.gpu
|
||||
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* The phone's own Vulkan driver, as it describes itself (native/deviceinfo): device name, API and
|
||||
* driver versions, a few limits and the extensions DXVK/VKD3D lean on. Asked once per process and
|
||||
* kept; the first [query] creates a Vulkan instance, so it belongs off the main thread.
|
||||
*/
|
||||
object VulkanInfo {
|
||||
@Volatile private var cached: Map<String, String>? = null
|
||||
|
||||
@JvmStatic private external fun nativeQuery(): String
|
||||
|
||||
/** Blocking on first use. Empty-ish map with an "error" key when Vulkan cannot be asked. */
|
||||
@Synchronized
|
||||
fun query(): Map<String, String> {
|
||||
cached?.let { return it }
|
||||
val text = try {
|
||||
System.loadLibrary("deviceinfo")
|
||||
nativeQuery()
|
||||
} catch (t: Throwable) {
|
||||
Log.w("VulkanInfo", "query failed", t)
|
||||
"error=" + (t.message ?: t.javaClass.simpleName)
|
||||
}
|
||||
val map = text.lineSequence().mapNotNull { line ->
|
||||
val eq = line.indexOf('=')
|
||||
if (eq <= 0) null else line.substring(0, eq) to line.substring(eq + 1)
|
||||
}.toMap(LinkedHashMap())
|
||||
cached = map
|
||||
return map
|
||||
}
|
||||
|
||||
/** What [query] found, if it has run in this process; never blocks. */
|
||||
fun cachedOrNull(): Map<String, String>? = cached
|
||||
}
|
||||
@@ -41,11 +41,15 @@ object ControllerPrefs {
|
||||
val size: Int,
|
||||
val stickClick: Boolean,
|
||||
val adaptiveSticks: Boolean,
|
||||
val rumble: Boolean,
|
||||
val steamButton: Boolean,
|
||||
val qamButton: Boolean,
|
||||
val keyboardButton: Boolean,
|
||||
val customLayout: Boolean,
|
||||
val mapping: Map<String, String>,
|
||||
)
|
||||
|
||||
private fun prefs(context: Context) = context.getSharedPreferences("controller", Context.MODE_PRIVATE)
|
||||
internal fun prefs(context: Context) = context.getSharedPreferences("controller", Context.MODE_PRIVATE)
|
||||
|
||||
fun read(context: Context): Settings {
|
||||
val p = prefs(context)
|
||||
@@ -55,6 +59,10 @@ object ControllerPrefs {
|
||||
size = p.getInt("size", 100).takeIf { it in sizes } ?: 100,
|
||||
stickClick = p.getBoolean("stickClick", true),
|
||||
adaptiveSticks = p.getBoolean("adaptiveSticks", true),
|
||||
rumble = rumbleEnabled(context),
|
||||
steamButton = p.getBoolean("steamButton", true),
|
||||
qamButton = p.getBoolean("qamButton", true),
|
||||
keyboardButton = p.getBoolean("keyboardButton", true),
|
||||
customLayout = p.all.keys.any { it.startsWith("layout.") },
|
||||
mapping = mappable.associate { (id, _) -> id to target(context, id) },
|
||||
)
|
||||
@@ -92,6 +100,24 @@ object ControllerPrefs {
|
||||
prefs(context).edit().putBoolean("adaptiveSticks", on).apply()
|
||||
}
|
||||
|
||||
fun rumbleEnabled(context: Context): Boolean = prefs(context).getBoolean("rumble", true)
|
||||
|
||||
fun setRumble(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("rumble", on).apply()
|
||||
}
|
||||
|
||||
fun setSteamButton(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("steamButton", on).apply()
|
||||
}
|
||||
|
||||
fun setQamButton(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("qamButton", on).apply()
|
||||
}
|
||||
|
||||
fun setKeyboardButton(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("keyboardButton", 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 ->
|
||||
@@ -111,7 +137,8 @@ object ControllerPrefs {
|
||||
}
|
||||
|
||||
fun resetAll(context: Context) {
|
||||
prefs(context).edit().clear().apply()
|
||||
// On older Android versions clear() does not notify listeners; restore rumble explicitly.
|
||||
prefs(context).edit().clear().putBoolean("rumble", true).apply()
|
||||
}
|
||||
|
||||
fun resetAllLayouts(context: Context) {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.droiddeck.launcher.input;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
/**
|
||||
* Where the fake input rings live and how the guest is told about them. Kept apart from
|
||||
* {@link FakeInputWriter}, which loads native code, so it can be tested on the JVM.
|
||||
*/
|
||||
final class FakeInputRingPaths {
|
||||
static final String RING_DIR_NAME = "fakeinput-rings";
|
||||
|
||||
private FakeInputRingPaths() {}
|
||||
|
||||
/** The rings sit beside the fake evdev nodes: session/dev/input -> session/dev/fakeinput-rings. */
|
||||
static File ringDir(File fakeInputDir) {
|
||||
if (fakeInputDir == null) {
|
||||
return null;
|
||||
}
|
||||
File inputDir = fakeInputDir.getAbsoluteFile();
|
||||
File parent = inputDir.getParentFile();
|
||||
return new File(parent != null ? parent : inputDir, RING_DIR_NAME);
|
||||
}
|
||||
|
||||
static File ringFile(File fakeInputDir, int slot) {
|
||||
File ringDir = ringDir(fakeInputDir);
|
||||
return ringDir != null ? new File(ringDir, "ring" + slot) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ring's path as the guest opens it. The session tree is bound into the guest at its own
|
||||
* host path, spelled the way {@code Context.getFilesDir()} spells it (LinuxRuntime.binds), so
|
||||
* the export has to use that same spelling. A canonical path is not that: where
|
||||
* /data/user/0 is a link, or a separate mount, onto /data/data (ColorOS 16, #166) it resolves
|
||||
* to a prefix nothing is bound at, and every pad, physical and on-screen, goes dead.
|
||||
*/
|
||||
static String exportPath(File ringFile) {
|
||||
return ringFile.getAbsolutePath();
|
||||
}
|
||||
}
|
||||
@@ -94,7 +94,6 @@ 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]
|
||||
private static final String RING_DIR_NAME = "fakeinput-rings";
|
||||
// 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.
|
||||
@@ -198,26 +197,8 @@ public class FakeInputWriter {
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private static File getRingDir(File fakeInputDir) {
|
||||
if (fakeInputDir == null) {
|
||||
return null;
|
||||
}
|
||||
File inputDir = fakeInputDir.getAbsoluteFile();
|
||||
File parent = inputDir.getParentFile();
|
||||
return new File(parent != null ? parent : inputDir, RING_DIR_NAME);
|
||||
}
|
||||
|
||||
private static File getRingFile(File fakeInputDir, int slot) {
|
||||
File ringDir = getRingDir(fakeInputDir);
|
||||
return ringDir != null ? new File(ringDir, "ring" + slot) : null;
|
||||
}
|
||||
|
||||
private static String getCanonicalOrAbsolutePath(File file) {
|
||||
try {
|
||||
return file.getCanonicalPath();
|
||||
} catch (IOException e) {
|
||||
return file.getAbsolutePath();
|
||||
}
|
||||
return FakeInputRingPaths.ringFile(fakeInputDir, slot);
|
||||
}
|
||||
|
||||
private static void initializeRingHeader(ByteBuffer data) {
|
||||
@@ -325,7 +306,7 @@ public class FakeInputWriter {
|
||||
ringSlot.ringFile = ringFile.getAbsoluteFile();
|
||||
ringSlot.ringRaf = raf;
|
||||
ringSlot.ringChannel = channel;
|
||||
ringSlot.exportPath = getCanonicalOrAbsolutePath(ringFile);
|
||||
ringSlot.exportPath = FakeInputRingPaths.exportPath(ringFile);
|
||||
Log.i(TAG, "Created fake input file ring for slot " + slot + ": " + ringSlot.exportPath);
|
||||
return ringSlot;
|
||||
} catch (IOException e) {
|
||||
|
||||
@@ -19,7 +19,12 @@ import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
|
||||
@SuppressLint("ViewConstructor")
|
||||
class OnScreenControls(context: Context, private val pad: PadBridge?, private val editing: Boolean = false) : View(context) {
|
||||
class OnScreenControls(
|
||||
context: Context,
|
||||
private val pad: PadBridge?,
|
||||
private val editing: Boolean = false,
|
||||
private val onKeyboard: (() -> Unit)? = null,
|
||||
) : View(context) {
|
||||
|
||||
private class Control(
|
||||
val id: String,
|
||||
@@ -86,6 +91,9 @@ class OnScreenControls(context: Context, private val pad: PadBridge?, private va
|
||||
private val groups = controls.map { it.group }.distinct()
|
||||
|
||||
private var buttonsOnly = false
|
||||
private var quickHidden = false
|
||||
private var quickPressedBy = -1
|
||||
private var keyboardPressedBy = -1
|
||||
private var settings = ControllerPrefs.read(context)
|
||||
private var safe = Rect()
|
||||
private var selected: String? = null
|
||||
@@ -159,9 +167,18 @@ class OnScreenControls(context: Context, private val pad: PadBridge?, private va
|
||||
if (buttonsOnly == enabled) return
|
||||
releaseAll()
|
||||
buttonsOnly = enabled
|
||||
quickHidden = false
|
||||
relayout()
|
||||
}
|
||||
|
||||
/** Hide the full touch pad for an unobstructed screen while keeping its quick toggle available. */
|
||||
fun setQuickHidden(hidden: Boolean) {
|
||||
if (buttonsOnly || editing || quickHidden == hidden) return
|
||||
if (hidden) releaseAll()
|
||||
quickHidden = hidden
|
||||
invalidate()
|
||||
}
|
||||
|
||||
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
||||
super.onSizeChanged(w, h, oldw, oldh)
|
||||
relayout()
|
||||
@@ -348,7 +365,7 @@ class OnScreenControls(context: Context, private val pad: PadBridge?, private va
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
for (control in controls) {
|
||||
if (!quickHidden) for (control in controls) {
|
||||
if (!isVisible(control)) continue
|
||||
if (control.stick >= 0 && settings.adaptiveSticks && !editing && control.pressedBy == -1) continue
|
||||
val held = control.pressedBy != -1
|
||||
@@ -394,15 +411,64 @@ class OnScreenControls(context: Context, private val pad: PadBridge?, private va
|
||||
canvas.drawText(name, control.cx, control.cy + text.textSize * 0.35f, text)
|
||||
}
|
||||
}
|
||||
val group = selected ?: return
|
||||
val (x, y) = centre(group)
|
||||
stroke.color = heldStroke
|
||||
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)
|
||||
if (editing) {
|
||||
val group = selected ?: return
|
||||
val (x, y) = centre(group)
|
||||
stroke.color = heldStroke
|
||||
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)
|
||||
fill.color = Color.argb(190, Color.red(idleFill), Color.green(idleFill), Color.blue(idleFill))
|
||||
canvas.drawCircle(x, y, radius, fill)
|
||||
stroke.color = idleStroke
|
||||
canvas.drawCircle(x, y, radius, stroke)
|
||||
text.color = idleText
|
||||
text.textSize = dp(22f)
|
||||
canvas.drawText(if (quickHidden) "+" else "−", x, y + text.textSize * 0.35f, text)
|
||||
}
|
||||
if (!editing && settings.keyboardButton && onKeyboard != null) {
|
||||
val (x, y) = keyboardCenter()
|
||||
fill.color = Color.argb(190, Color.red(idleFill), Color.green(idleFill), Color.blue(idleFill))
|
||||
canvas.drawCircle(x, y, dp(18f), fill)
|
||||
stroke.color = if (keyboardPressedBy == -1) idleStroke else heldStroke
|
||||
canvas.drawCircle(x, y, dp(18f), stroke)
|
||||
box.set(x - dp(10f), y - dp(7f), x + dp(10f), y + dp(7f))
|
||||
canvas.drawRoundRect(box, dp(2f), dp(2f), stroke)
|
||||
for (row in 0..1) for (col in 0..2) {
|
||||
val kx = x + dp(-6f + col * 5f)
|
||||
val ky = y + dp(-3f + row * 4f)
|
||||
canvas.drawLine(kx, ky, kx + dp(2f), ky, stroke)
|
||||
}
|
||||
canvas.drawLine(x - dp(5f), y + dp(4f), x + dp(5f), y + dp(4f), stroke)
|
||||
}
|
||||
}
|
||||
|
||||
private fun quickCenter() = (safe.left + dp(34f)) to (height - safe.bottom - dp(34f))
|
||||
|
||||
private fun keyboardCenter() = if (buttonsOnly)
|
||||
width / 2f to (height - safe.bottom - dp(44f))
|
||||
else (safe.left + dp(90f)) to (height - safe.bottom - dp(34f))
|
||||
|
||||
private fun keyboardContains(x: Float, y: Float): Boolean {
|
||||
if (!settings.keyboardButton || onKeyboard == null) return false
|
||||
val (cx, cy) = keyboardCenter()
|
||||
val dx = x - cx
|
||||
val dy = y - cy
|
||||
return dx * dx + dy * dy <= dp(26f) * dp(26f)
|
||||
}
|
||||
|
||||
private fun quickContains(x: Float, y: Float): Boolean {
|
||||
val (cx, cy) = quickCenter()
|
||||
val radius = dp(26f)
|
||||
val dx = x - cx
|
||||
val dy = y - cy
|
||||
return dx * dx + dy * dy <= radius * radius
|
||||
}
|
||||
|
||||
private fun drawArrow(canvas: Canvas, x: Float, y: Float, size: Float, direction: Int) {
|
||||
@@ -429,6 +495,18 @@ class OnScreenControls(context: Context, private val pad: PadBridge?, private va
|
||||
val index = event.actionIndex
|
||||
val x = event.getX(index)
|
||||
val y = event.getY(index)
|
||||
if (event.actionMasked == MotionEvent.ACTION_DOWN && keyboardContains(x, y)) {
|
||||
keyboardPressedBy = event.getPointerId(index)
|
||||
invalidate()
|
||||
return true
|
||||
}
|
||||
if (keyboardPressedBy != -1) return true
|
||||
if (!buttonsOnly && event.actionMasked == MotionEvent.ACTION_DOWN && quickContains(x, y)) {
|
||||
quickPressedBy = event.getPointerId(index)
|
||||
return true
|
||||
}
|
||||
if (quickPressedBy != -1) return true
|
||||
if (quickHidden) return false
|
||||
val control = controlAt(x, y) ?: adaptiveStickAt(x, y) ?: return false
|
||||
if (control.pressedBy != -1) return true
|
||||
control.pressedBy = event.getPointerId(index)
|
||||
@@ -444,6 +522,8 @@ class OnScreenControls(context: Context, private val pad: PadBridge?, private va
|
||||
return true
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
if (keyboardPressedBy != -1) return true
|
||||
if (quickPressedBy != -1) return true
|
||||
var changed = false
|
||||
for (index in 0 until event.pointerCount) {
|
||||
val pointer = event.getPointerId(index)
|
||||
@@ -468,6 +548,24 @@ class OnScreenControls(context: Context, private val pad: PadBridge?, private va
|
||||
}
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_POINTER_UP, MotionEvent.ACTION_CANCEL -> {
|
||||
val pointer = event.getPointerId(event.actionIndex)
|
||||
if (event.actionMasked == MotionEvent.ACTION_CANCEL) {
|
||||
releaseAll()
|
||||
return true
|
||||
}
|
||||
if (keyboardPressedBy == pointer) {
|
||||
val tapped = event.actionMasked == MotionEvent.ACTION_UP && keyboardContains(event.x, event.y)
|
||||
keyboardPressedBy = -1
|
||||
invalidate()
|
||||
if (tapped) { releaseAll(); onKeyboard?.invoke() }
|
||||
return true
|
||||
}
|
||||
if (quickPressedBy == pointer) {
|
||||
val toggled = event.actionMasked == MotionEvent.ACTION_UP && quickContains(event.x, event.y)
|
||||
quickPressedBy = -1
|
||||
if (toggled) setQuickHidden(!quickHidden)
|
||||
return true
|
||||
}
|
||||
if (quickHidden) return false
|
||||
var changed = false
|
||||
for (control in controls) {
|
||||
if (control.pressedBy == pointer || event.actionMasked == MotionEvent.ACTION_CANCEL) {
|
||||
@@ -510,11 +608,11 @@ class OnScreenControls(context: Context, private val pad: PadBridge?, private va
|
||||
|
||||
private fun controlAt(x: Float, y: Float): Control? =
|
||||
controls.firstOrNull {
|
||||
isVisible(it) && (editing || !settings.adaptiveSticks || it.stick < 0) && it.contains(x, y, it.radius)
|
||||
!quickHidden && isVisible(it) && (editing || !settings.adaptiveSticks || it.stick < 0) && it.contains(x, y, it.radius)
|
||||
}
|
||||
|
||||
private fun adaptiveStickAt(x: Float, y: Float): Control? {
|
||||
if (!settings.adaptiveSticks || editing || buttonsOnly) return null
|
||||
if (!settings.adaptiveSticks || editing || buttonsOnly || quickHidden) return null
|
||||
if (x < safe.left || x >= width - safe.right || y < safe.top || y >= height - safe.bottom) return null
|
||||
if (controls.any { it.stick < 0 && isVisible(it) && it.contains(x, y, it.radius, 5f) }) return null
|
||||
return controls.filter {
|
||||
@@ -530,6 +628,8 @@ class OnScreenControls(context: Context, private val pad: PadBridge?, private va
|
||||
}
|
||||
|
||||
private fun isVisible(control: Control): Boolean = when {
|
||||
!editing && control.id == "guide" && !settings.steamButton -> false
|
||||
!editing && control.id == "qam" && !settings.qamButton -> false
|
||||
buttonsOnly && !editing -> control.id == "guide" || control.id == "qam"
|
||||
else -> control.target != ControllerPrefs.OFF
|
||||
}
|
||||
@@ -592,6 +692,9 @@ class OnScreenControls(context: Context, private val pad: PadBridge?, private va
|
||||
}
|
||||
|
||||
fun releaseAll() {
|
||||
quickPressedBy = -1
|
||||
keyboardPressedBy = -1
|
||||
invalidate()
|
||||
if (controls.none { it.pressedBy != -1 || it.clicked } && !buttonsOnly) return
|
||||
controls.forEach {
|
||||
it.pressedBy = -1
|
||||
|
||||
@@ -76,6 +76,17 @@ public final class PadBridge {
|
||||
private final java.util.Set<Integer> seenDevices = new java.util.HashSet<>();
|
||||
private int lastDeviceId = Integer.MIN_VALUE;
|
||||
|
||||
/** No physical controller is driving the pad: nothing yet, or the on-screen controls were last. */
|
||||
public static final int NO_CONTROLLER = -1;
|
||||
/**
|
||||
* 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;
|
||||
|
||||
public static int activeControllerId() { return activeControllerId; }
|
||||
|
||||
public PadBridge(File fakeInputDir) {
|
||||
writer = new FakeInputWriter(fakeInputDir.getAbsolutePath(), SLOT);
|
||||
}
|
||||
@@ -90,6 +101,7 @@ public final class PadBridge {
|
||||
}
|
||||
|
||||
public synchronized void stop() {
|
||||
activeControllerId = NO_CONTROLLER;
|
||||
systemGuidePressed = false;
|
||||
systemQamPressed = false;
|
||||
qamChordActive = false;
|
||||
@@ -218,6 +230,7 @@ public final class PadBridge {
|
||||
*/
|
||||
public synchronized void applyTouch(java.util.function.Consumer<PadState> mutation) {
|
||||
mutation.accept(state);
|
||||
activeControllerId = NO_CONTROLLER;
|
||||
notePlayerInput();
|
||||
statOnScreen++;
|
||||
scheduleStats();
|
||||
@@ -299,6 +312,7 @@ public final class PadBridge {
|
||||
|
||||
/** Per event, so the common case - the same pad as last time - is a single compare. */
|
||||
private void noteDevice(InputDevice device) {
|
||||
activeControllerId = device.getId();
|
||||
if (device.getId() == lastDeviceId) return;
|
||||
lastDeviceId = device.getId();
|
||||
statDevice = device.getName();
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.system.Os
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.wayland.WaylandCompositor
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
|
||||
/** Text selections shared by Android and the guest while the session is visible. */
|
||||
class SessionClipboard(context: Context, private val hasFocus: () -> Boolean) {
|
||||
private val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private val hostFile = File(context.filesDir, "session/android-clipboard")
|
||||
private var active = false
|
||||
private val androidListener = ClipboardManager.OnPrimaryClipChangedListener { refresh() }
|
||||
private val guestListener = object : WaylandCompositor.ClipboardListener {
|
||||
override fun onGuestClipboardText(text: String) {
|
||||
// Native callbacks arrive on the compositor thread. Do not publish a delayed callback
|
||||
// after this activity has paused or been replaced.
|
||||
handler.post {
|
||||
if (active && hasFocus() && WaylandCompositor.isClipboardListener(this)) {
|
||||
val current = clipboard.primaryClip?.takeIf { it.itemCount > 0 }?.getItemAt(0)?.text
|
||||
if (current?.toString() != text) {
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText("DroidDeck", text))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun start() {
|
||||
if (active) return
|
||||
active = true
|
||||
clipboard.addPrimaryClipChangedListener(androidListener)
|
||||
WaylandCompositor.setClipboardListener(guestListener)
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
if (!active) return
|
||||
active = false
|
||||
clipboard.removePrimaryClipChangedListener(androidListener)
|
||||
WaylandCompositor.clearClipboardListener(guestListener)
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
}
|
||||
|
||||
/** Android 10+ permits clipboard reads only with window focus. Resume can precede focus. */
|
||||
fun refresh() {
|
||||
if (!active || !hasFocus()) return
|
||||
val clip = clipboard.primaryClip
|
||||
// Only share literal text, never resolve content URIs or load copied files.
|
||||
if (clip == null) {
|
||||
publish(null)
|
||||
} else if (clip.itemCount > 0) {
|
||||
publish(clip.getItemAt(0).text?.toString())
|
||||
}
|
||||
}
|
||||
|
||||
private fun publish(text: String?) {
|
||||
WaylandCompositor.setClipboardText(text)
|
||||
// Gamescope needs the same selection in its own Xwayland. Rename complete UTF-8 text
|
||||
// into place so its helper never sees half a launch command. Kept in private app data.
|
||||
val pending = File(hostFile.parentFile, "android-clipboard.pending")
|
||||
try {
|
||||
hostFile.parentFile?.mkdirs()
|
||||
pending.writeText(text.orEmpty(), Charsets.UTF_8)
|
||||
Os.rename(pending.path, hostFile.path)
|
||||
} catch (e: IOException) {
|
||||
Log.w("SessionClipboard", "Could not share text with Xwayland", e)
|
||||
} catch (e: android.system.ErrnoException) {
|
||||
Log.w("SessionClipboard", "Could not publish text for Xwayland", e)
|
||||
} finally {
|
||||
pending.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,17 @@ class TouchpadGestures(
|
||||
listener.onButton(PointerGestures.BTN_LEFT, true, x, y)
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
handler.removeCallbacks(longPress)
|
||||
if (dragging) listener.onButton(PointerGestures.BTN_LEFT, false, x, y)
|
||||
dragging = false
|
||||
held = false
|
||||
holdDrag = false
|
||||
anchorId = -1
|
||||
moverId = -1
|
||||
lastTapAt = 0L
|
||||
}
|
||||
|
||||
fun place(px: Float, py: Float) {
|
||||
x = px.coerceIn(bounds.left, bounds.right)
|
||||
y = py.coerceIn(bounds.top, bounds.bottom)
|
||||
|
||||
@@ -13,16 +13,14 @@ import java.io.RandomAccessFile
|
||||
* AppImages the user brings: picked from storage, checked, and extracted once into the runtime at
|
||||
* /opt/appimages/user/<id>/app. proot has no FUSE to mount an image, and extracting it at every
|
||||
* start would cost its whole size in /tmp each time. Each one gets a menu entry on the Linux
|
||||
* desktop, and the front end starts it under gamescope through bannerlator-appimage-run.
|
||||
* desktop, and the front end starts it under gamescope through droiddeck-appimage-run.
|
||||
*
|
||||
* <id>/name holds the name to show, <id>/icon.png the icon when the image has a PNG one.
|
||||
*/
|
||||
object AppImageManager {
|
||||
private const val TAG = "AppImageManager"
|
||||
const val GUEST_DIR = "/opt/appimages/user"
|
||||
const val LAUNCHER = "/usr/local/bin/bannerlator-appimage-run"
|
||||
private const val ELF_AARCH64 = 183
|
||||
private const val ELF_X86_64 = 62
|
||||
const val LAUNCHER = "/usr/local/bin/droiddeck-appimage-run"
|
||||
private const val SHARP_ICON = 128
|
||||
|
||||
class Item(val id: String, val name: String, val icon: File?, val comment: String?) {
|
||||
@@ -59,9 +57,8 @@ object AppImageManager {
|
||||
}
|
||||
val machine = (head[18].toInt() and 0xff) or ((head[19].toInt() and 0xff) shl 8)
|
||||
return when {
|
||||
machine == ELF_X86_64 -> "This AppImage is built for x86_64 PCs. DroidDeck runs ARM64 (aarch64) AppImages - look for an \"aarch64\" or \"arm64\" download."
|
||||
machine != ELF_AARCH64 -> "This AppImage is not built for ARM64 (aarch64)"
|
||||
head[8] != 'A'.code.toByte() || head[9] != 'I'.code.toByte() -> "This is an ARM64 program but not an AppImage"
|
||||
machine != LinuxFex.ELF_AARCH64 && !LinuxFex.isX86(machine) -> "This AppImage is built for neither ARM64 (aarch64) nor x86 PCs"
|
||||
head[8] != 'A'.code.toByte() || head[9] != 'I'.code.toByte() -> "This is a Linux program but not an AppImage"
|
||||
head[10].toInt() != 2 -> "Only type 2 AppImages can be imported (this one is type ${head[10].toInt()})"
|
||||
else -> null
|
||||
}
|
||||
@@ -96,6 +93,7 @@ object AppImageManager {
|
||||
val dir = File(base, idFor(name ?: file.name, base.list()?.toSet() ?: emptySet()))
|
||||
try {
|
||||
extract(context, dir, file, onProgress)?.let { FileUtils.delete(dir); return it }
|
||||
LinuxFex.writeArch(dir, LinuxFex.elfMachine(file))
|
||||
describe(dir, file)
|
||||
if (name != null) FileUtils.writeString(File(dir, "name"), name)
|
||||
if (icon != null && !UserApps.saveIcon(icon, File(dir, "icon.png"))) Log.w(TAG, "icon ${icon.path} could not be read")
|
||||
@@ -119,6 +117,7 @@ object AppImageManager {
|
||||
}
|
||||
return try {
|
||||
extract(context, dir, file, onProgress) ?: run {
|
||||
LinuxFex.writeArch(dir, LinuxFex.elfMachine(file))
|
||||
meta.forEach { (k, v) -> FileUtils.writeString(File(dir, k), v) }
|
||||
null
|
||||
}
|
||||
@@ -140,12 +139,14 @@ object AppImageManager {
|
||||
file.inputStream().use { input -> image.outputStream().use { FileUtils.copy(input, it) } }
|
||||
image.setExecutable(true, false)
|
||||
onProgress("Extracting ${file.name}")
|
||||
val unpack = if (LinuxFex.isX86(LinuxFex.elfMachine(image))) "${LinuxFex.TOOL} extract ./image.AppImage"
|
||||
else "./image.AppImage --appimage-extract || { rm -rf squashfs-root AppDir; ${LinuxFex.TOOL} extract ./image.AppImage; }"
|
||||
val out = StringBuilder()
|
||||
// uruntime's DwarFS images unpack into AppDir with squashfs-root a link to it, and keep
|
||||
// one program under several names as hard links (sharun), which Android denies apps.
|
||||
val status = GuestCommand.run(context, listOf(
|
||||
"/bin/bash", "-c",
|
||||
"cd \"$1\" && rm -rf squashfs-root AppDir && ./image.AppImage --appimage-extract >/dev/null && " +
|
||||
"cd \"$1\" && rm -rf squashfs-root AppDir && { $unpack; } >/dev/null && " +
|
||||
"rm -rf app && mv \"$(readlink -f squashfs-root)\" app && rm -f squashfs-root",
|
||||
"extract", "$GUEST_DIR/${dir.name}",
|
||||
), logName = "appimage-import", linkDir = links) { line -> if (out.length < 2000) out.appendLine(line) }
|
||||
|
||||
@@ -19,7 +19,7 @@ import java.nio.file.Files
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
/**
|
||||
* Flatpak's sandboxes, started for the runtime's bwrap stand-in (bannerlator-bwrap).
|
||||
* Flatpak's sandboxes, started for the runtime's bwrap stand-in (droiddeck-bwrap).
|
||||
*
|
||||
* A Flatpak app runs with its runtime as /usr and itself as /app, which bwrap arranges with mount
|
||||
* namespaces Android gives no app. proot can show the same picture, and started here - beside the
|
||||
@@ -300,7 +300,10 @@ object BwrapSpawner {
|
||||
// session has Xwayland) it stays on X11, which works. GTK and Qt go by their own settings.
|
||||
if (env.containsKey("DISPLAY")) env["XDG_SESSION_TYPE"] = "x11"
|
||||
for (name in listOf("MOZ_DISABLE_CONTENT_SANDBOX", "MOZ_DISABLE_GMP_SANDBOX", "MOZ_DISABLE_RDD_SANDBOX",
|
||||
"MOZ_DISABLE_SOCKET_PROCESS_SANDBOX", "MOZ_DISABLE_UTILITY_SANDBOX")) {
|
||||
"MOZ_DISABLE_SOCKET_PROCESS_SANDBOX", "MOZ_DISABLE_UTILITY_SANDBOX",
|
||||
// Android 16 refuses the size seal Firefox puts on a tab's shared
|
||||
// memory, and the tab then dies on every page (droiddeck-desktop).
|
||||
"MOZ_SHM_NO_SEALS")) {
|
||||
env.putIfAbsent(name, "1")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,15 @@ import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.core.Downloader
|
||||
import com.droiddeck.launcher.session.SessionState
|
||||
import com.droiddeck.launcher.core.ArchivePaths
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.FileOutputStream
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.util.UUID
|
||||
import java.util.zip.ZipInputStream
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/** Optional Decky Loader installation for the ARM64 Linux guest. */
|
||||
object DeckyManager {
|
||||
@@ -74,8 +81,7 @@ object DeckyManager {
|
||||
val assets = release.optJSONArray("assets") ?: continue
|
||||
val rows = (0 until assets.length()).map { assets.getJSONObject(it) }
|
||||
val arm = rows.firstOrNull { it.optString("name") == "PluginLoader-arm64" }
|
||||
val guest = LinuxRuntime.rootDir(context)
|
||||
val fexReady = File(guest, "usr/share/guestos/fex-mesa").isDirectory && File(guest, "usr/bin/FEX").canExecute()
|
||||
val fexReady = LinuxFex.ready(context)
|
||||
val binary = arm ?: if (fexReady) rows.firstOrNull { it.optString("name") == "PluginLoader" } else null
|
||||
val name = binary?.optString("name") ?: continue
|
||||
val url = binary.optString("browser_download_url").takeIf { it.startsWith("https://") } ?: continue
|
||||
@@ -137,6 +143,123 @@ object DeckyManager {
|
||||
return null
|
||||
}
|
||||
|
||||
/** Imports a Decky distribution ZIP into homebrew/plugins for the next Steam session. */
|
||||
fun installPluginZip(context: Context, archive: File, progress: (String, Int) -> Unit): String? {
|
||||
if (SessionState.running) return "Stop the active session before installing a plugin"
|
||||
if (installed(context) == null) return "Install Decky Loader before importing a plugin"
|
||||
if (!archive.isFile || !archive.canRead()) return "Could not read the selected ZIP file"
|
||||
|
||||
val plugins = File(LinuxRuntime.rootDir(context), "root/homebrew/plugins")
|
||||
if (!plugins.exists() && !plugins.mkdirs()) return "Could not create the Decky plugins directory"
|
||||
val staging = File(plugins, ".droiddeck-import-${UUID.randomUUID()}")
|
||||
val backupSuffix = ".droiddeck-backup-${UUID.randomUUID()}"
|
||||
try {
|
||||
staging.mkdirs()
|
||||
progress("Extracting plugin ZIP", -1)
|
||||
extractPluginZip(archive, staging)
|
||||
val roots = staging.listFiles()?.filter { it.isDirectory } ?: emptyList()
|
||||
if (roots.size != 1 || staging.listFiles()?.size != 1) return "Plugin ZIP must contain one top-level plugin folder"
|
||||
val plugin = roots.single()
|
||||
if (!File(plugin, "plugin.json").isFile || !File(plugin, "package.json").isFile || !File(plugin, "dist/index.js").isFile) {
|
||||
return "Plugin ZIP is missing plugin.json, package.json, or dist/index.js"
|
||||
}
|
||||
val name = JSONObject(File(plugin, "plugin.json").readText()).getString("name")
|
||||
require(name.isNotBlank()) { "Plugin name is missing" }
|
||||
val metadata = JSONObject(File(plugin, "package.json").readText())
|
||||
downloadRemoteBinaries(metadata, plugin, progress)
|
||||
if (SessionState.running) return "A session started during installation; stop it before installing a plugin"
|
||||
|
||||
DeckyPluginInstall.activate(plugin, plugins, name,
|
||||
File(plugins.parentFile, "settings/loader.json"), backupSuffix)
|
||||
progress("Plugin installed · restart Steam to load it", 100)
|
||||
return null
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "plugin ZIP import", e)
|
||||
return e.message?.takeIf { it.isNotBlank() } ?: "Plugin ZIP installation failed"
|
||||
} finally {
|
||||
staging.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractPluginZip(archive: File, destination: File) {
|
||||
var totalBytes = 0L
|
||||
var entries = 0
|
||||
val seen = HashSet<String>()
|
||||
ZipInputStream(BufferedInputStream(FileInputStream(archive))).use { zip ->
|
||||
while (true) {
|
||||
val entry = zip.nextEntry ?: break
|
||||
entries++
|
||||
require(entries <= 10_000) { "Plugin ZIP contains too many entries" }
|
||||
val relative = entry.name.replace('\\', '/')
|
||||
require(relative.isNotBlank() && !relative.startsWith('/')) { "Plugin ZIP contains an invalid path" }
|
||||
require(seen.add(relative)) { "Plugin ZIP contains duplicate paths" }
|
||||
val output = ArchivePaths.inside(destination, relative)
|
||||
?: throw IllegalArgumentException("Plugin ZIP contains an unsafe path")
|
||||
if (entry.isDirectory) {
|
||||
require(output.mkdirs() || output.isDirectory) { "Could not create plugin folder" }
|
||||
} else {
|
||||
require(output.parentFile?.let { it.mkdirs() || it.isDirectory } == true) { "Could not create plugin folder" }
|
||||
FileOutputStream(output).use { out ->
|
||||
val buffer = ByteArray(64 * 1024)
|
||||
while (true) {
|
||||
val read = zip.read(buffer)
|
||||
if (read < 0) break
|
||||
totalBytes += read
|
||||
require(totalBytes <= MAX_PLUGIN_ZIP_BYTES) { "Plugin ZIP expands beyond the 1 GiB limit" }
|
||||
out.write(buffer, 0, read)
|
||||
}
|
||||
}
|
||||
// Decky distributions place plugin executables under bin/. Java's ZIP reader
|
||||
// does not expose Unix mode bits, so restore execution on those payload files.
|
||||
if (relative.split('/').dropLast(1).any { it == "bin" } && !output.setExecutable(true, false)) {
|
||||
throw IllegalStateException("Could not mark bundled plugin binary executable")
|
||||
}
|
||||
}
|
||||
zip.closeEntry()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun downloadRemoteBinaries(metadata: JSONObject, plugin: File, progress: (String, Int) -> Unit) {
|
||||
val binaries = metadata.optJSONArray("remote_binary") ?: return
|
||||
val bin = File(plugin, "bin")
|
||||
for (i in 0 until binaries.length()) {
|
||||
val binary = binaries.optJSONObject(i) ?: throw IllegalArgumentException("Invalid remote_binary entry")
|
||||
val name = binary.optString("name")
|
||||
val url = binary.optString("url")
|
||||
val hash = binary.optString("sha256hash")
|
||||
require(name.isNotBlank() && name != "." && name != ".." && !name.contains('/') && !name.contains('\\')) { "Invalid remote binary name" }
|
||||
require(url.startsWith("https://")) { "Remote binary URL must use HTTPS" }
|
||||
require(hash.matches(Regex("(?i)[0-9a-f]{64}"))) { "Remote binary $name has an invalid SHA-256 hash" }
|
||||
require(bin.mkdirs() || bin.isDirectory) { "Could not create plugin bin directory" }
|
||||
val temp = File(bin, ".$name.download")
|
||||
progress("Downloading plugin binary · $name", -1)
|
||||
if (!Downloader.downloadFile(url, temp, false) { fraction ->
|
||||
progress("Downloading plugin binary · $name", if (fraction < 0) -1 else (fraction * 100).toInt().coerceIn(0, 100))
|
||||
}) {
|
||||
temp.delete()
|
||||
throw IllegalStateException("Could not download plugin binary $name")
|
||||
}
|
||||
if (!hash.equals(Hashes.sha256(temp), ignoreCase = true)) {
|
||||
temp.delete()
|
||||
throw IllegalStateException("Plugin binary checksum mismatch · $name")
|
||||
}
|
||||
if (!temp.setExecutable(true, false)) {
|
||||
temp.delete()
|
||||
throw IllegalStateException("Could not mark plugin binary executable · $name")
|
||||
}
|
||||
val target = File(bin, name)
|
||||
if (target.exists() && !target.delete()) {
|
||||
temp.delete()
|
||||
throw IllegalStateException("Could not replace plugin binary · $name")
|
||||
}
|
||||
if (!temp.renameTo(target)) {
|
||||
temp.delete()
|
||||
throw IllegalStateException("Could not install plugin binary · $name")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun uninstall(context: Context, wipeData: Boolean) {
|
||||
val root = LinuxRuntime.rootDir(context)
|
||||
setSupervisorEnabled(context, false)
|
||||
@@ -155,4 +278,6 @@ object DeckyManager {
|
||||
h[4] == 2.toByte() && h[5] == 1.toByte() && (h[18].toInt() and 255) + ((h[19].toInt() and 255) shl 8) == machine
|
||||
}
|
||||
}.getOrDefault(false)
|
||||
|
||||
private const val MAX_PLUGIN_ZIP_BYTES = 1024L * 1024L * 1024L
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.droiddeck.launcher.runtime
|
||||
|
||||
import java.io.File
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/** Offline equivalent of Decky's identity-based replacement and loader settings update. */
|
||||
internal object DeckyPluginInstall {
|
||||
fun activate(staged: File, plugins: File, name: String, settingsFile: File, backupSuffix: String) {
|
||||
val settings = if (settingsFile.exists()) JSONObject(settingsFile.readText()) else JSONObject()
|
||||
val existing = plugins.listFiles().orEmpty().filter { folder ->
|
||||
folder.isDirectory && !folder.name.startsWith(".droiddeck-") && runCatching {
|
||||
JSONObject(File(folder, "plugin.json").readText()).getString("name") == name
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
val target = File(plugins, staged.name)
|
||||
require(!target.exists() || target in existing) { "Plugin folder belongs to another plugin" }
|
||||
val order = settings.optJSONArray("pluginOrder") ?: JSONArray(plugins.listFiles().orEmpty()
|
||||
.filter { it.isDirectory && !it.name.startsWith(".droiddeck-") }
|
||||
.mapNotNull { runCatching { JSONObject(File(it, "plugin.json").readText()).getString("name") }.getOrNull() }
|
||||
.distinct())
|
||||
if ((0 until order.length()).none { order.optString(it) == name }) order.put(name)
|
||||
settings.put("pluginOrder", order)
|
||||
// Decky's reinstall removes these flags while retaining the plugin's order and data.
|
||||
for (key in listOf("frozenPlugins", "hiddenPlugins", "disabled_plugins")) {
|
||||
val values = settings.optJSONArray(key) ?: continue
|
||||
settings.put(key, JSONArray((0 until values.length()).map { values.get(it) }.filter { it != name }))
|
||||
}
|
||||
settingsFile.parentFile?.mkdirs()
|
||||
val pending = File(settingsFile.parentFile, "loader.json$backupSuffix")
|
||||
val backups = mutableListOf<Pair<File, File>>()
|
||||
var activated = false
|
||||
try {
|
||||
pending.writeText(settings.toString(4))
|
||||
for (old in existing) {
|
||||
val backup = File(plugins, ".droiddeck-backup-${old.name}$backupSuffix")
|
||||
check(old.renameTo(backup)) { "Could not preserve the existing plugin" }
|
||||
backups.add(old to backup)
|
||||
}
|
||||
check(staged.renameTo(target)) { "Could not activate the imported plugin" }
|
||||
activated = true
|
||||
check(pending.renameTo(settingsFile)) { "Could not update Decky plugin settings" }
|
||||
} catch (error: Exception) {
|
||||
if (activated) target.deleteRecursively()
|
||||
for ((old, backup) in backups.asReversed()) check(backup.renameTo(old)) { "Could not restore the existing plugin" }
|
||||
throw error
|
||||
} finally {
|
||||
pending.delete()
|
||||
}
|
||||
backups.forEach { (_, backup) -> backup.deleteRecursively() }
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,8 @@ import org.json.JSONObject
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Flatpak in the Linux runtime: putting it there (bannerlator-flatpak-setup), the apps installed
|
||||
* from Flathub, and installing, removing and updating them (bannerlator-flatpak). One per-user
|
||||
* Flatpak in the Linux runtime: putting it there (droiddeck-flatpak-setup), the apps installed
|
||||
* from Flathub, and installing, removing and updating them (droiddeck-flatpak). One per-user
|
||||
* installation at /root/.local/share/flatpak, so nothing needs the system helper or polkit.
|
||||
*
|
||||
* Every command runs in a proot of its own, outside any session: the store works whether or not
|
||||
@@ -17,11 +17,11 @@ import java.io.File
|
||||
object FlatpakManager {
|
||||
private const val TAG = "FlatpakManager"
|
||||
const val FLATPAK = "/usr/bin/flatpak"
|
||||
const val BWRAP = "/usr/local/bin/bannerlator-bwrap"
|
||||
const val BWRAP = "/usr/local/bin/droiddeck-bwrap"
|
||||
/** The program the front end starts a Flatpak app through, in a session of its own. */
|
||||
const val LAUNCHER = "/usr/local/bin/bannerlator-flatpak-run"
|
||||
private const val HELPER = "/usr/local/bin/bannerlator-flatpak"
|
||||
private const val SETUP = "/usr/local/bin/bannerlator-flatpak-setup"
|
||||
const val LAUNCHER = "/usr/local/bin/droiddeck-flatpak-run"
|
||||
private const val HELPER = "/usr/local/bin/droiddeck-flatpak"
|
||||
private const val SETUP = "/usr/local/bin/droiddeck-flatpak-setup"
|
||||
private const val USER_DIR = "root/.local/share/flatpak"
|
||||
|
||||
/** One store operation at a time: Flatpak locks its installation per transaction anyway. */
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package com.droiddeck.launcher.runtime
|
||||
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Paths
|
||||
|
||||
object LegacyNames {
|
||||
private const val TAG = "LegacyNames"
|
||||
const val SESSION_SCRIPT = "/usr/local/bin/bannerlator-session"
|
||||
val SCRIPTS = listOf(
|
||||
"appimage-run", "bwrap", "desktop-games", "flatpak", "flatpak-run", "flatpak-setup",
|
||||
"game-env", "login1", "netmanager", "pad-defaults", "proton-extra", "script-run",
|
||||
"seed-redists", "session", "steam-games", "steam-install", "steam-launch",
|
||||
"steam-library", "steam-shim", "steam-shortcuts",
|
||||
)
|
||||
private val LINKED_DIRS = listOf("etc/bannerlator" to "etc/droiddeck", "usr/local/share/bannerlator" to "usr/local/share/droiddeck")
|
||||
private val MOVED_FILES = listOf(
|
||||
"etc/bannerlator-net" to "etc/droiddeck-net",
|
||||
"etc/bannerlator-network.json" to "etc/droiddeck-network.json",
|
||||
"etc/bannerlator-wifi-scan-request" to "etc/droiddeck-wifi-scan-request",
|
||||
)
|
||||
private const val MENU_DIR = "usr/local/share/applications"
|
||||
private const val OLD_BIN = "/usr/local/bin/bannerlator-"
|
||||
private const val NEW_BIN = "/usr/local/bin/droiddeck-"
|
||||
|
||||
@JvmStatic
|
||||
@Synchronized
|
||||
fun migrateEtc(root: File) {
|
||||
for ((old, new) in LINKED_DIRS) moveAndLink(File(root, old), File(root, new))
|
||||
File(root, "etc/droiddeck/empty").mkdirs()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun migrateRootfs(root: File) {
|
||||
migrateEtc(root)
|
||||
for ((old, new) in MOVED_FILES) move(File(root, old), File(root, new))
|
||||
linkScripts(root)
|
||||
rewriteMenuEntries(root)
|
||||
}
|
||||
|
||||
private fun isLink(f: File) = Files.isSymbolicLink(f.toPath())
|
||||
|
||||
private fun move(legacy: File, target: File) {
|
||||
if (isLink(legacy) || !legacy.exists()) return
|
||||
if (legacy.isDirectory && !isLink(target) && (target.isDirectory || !target.exists())) {
|
||||
target.mkdirs()
|
||||
legacy.listFiles()?.forEach { move(it, File(target, it.name)) }
|
||||
if (!legacy.delete()) Log.w(TAG, "could not empty $legacy")
|
||||
return
|
||||
}
|
||||
if (target.exists() || isLink(target)) {
|
||||
FileUtils.delete(legacy)
|
||||
} else if (!legacy.renameTo(target)) {
|
||||
Log.w(TAG, "could not move $legacy to $target")
|
||||
}
|
||||
}
|
||||
|
||||
private fun moveAndLink(legacy: File, target: File) {
|
||||
if (isLink(legacy) || !legacy.exists()) return
|
||||
move(legacy, target)
|
||||
if (!legacy.exists()) replaceWithLink(legacy, target)
|
||||
}
|
||||
|
||||
private fun replaceWithLink(legacy: File, target: File) {
|
||||
val link = File(legacy.parentFile, legacy.name + ".link")
|
||||
FileUtils.delete(link)
|
||||
try {
|
||||
Files.createSymbolicLink(link.toPath(), Paths.get(target.name))
|
||||
if (!link.renameTo(legacy)) {
|
||||
FileUtils.delete(link)
|
||||
Log.w(TAG, "could not link $legacy to ${target.name}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
FileUtils.delete(link)
|
||||
Log.w(TAG, "could not link $legacy to ${target.name}", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun linkScripts(root: File) {
|
||||
val bin = File(root, "usr/local/bin")
|
||||
for (name in SCRIPTS) {
|
||||
val target = File(bin, "droiddeck-$name")
|
||||
val legacy = File(bin, "bannerlator-$name")
|
||||
if (!target.isFile || isLink(target)) continue
|
||||
if (isLink(legacy)) {
|
||||
val points = runCatching { Files.readSymbolicLink(legacy.toPath()).toString() }.getOrNull()
|
||||
if (points == target.name) continue
|
||||
} else if (!legacy.exists()) {
|
||||
continue
|
||||
}
|
||||
replaceWithLink(legacy, target)
|
||||
}
|
||||
}
|
||||
|
||||
private fun rewriteMenuEntries(root: File) {
|
||||
val entries = File(root, MENU_DIR).listFiles { f -> f.isFile && f.name.startsWith("droiddeck-") && f.name.endsWith(".desktop") } ?: return
|
||||
for (entry in entries) {
|
||||
val text = FileUtils.readString(entry) ?: continue
|
||||
if (!text.contains(OLD_BIN)) continue
|
||||
val staged = File(entry.parentFile, entry.name + ".staged")
|
||||
if (!FileUtils.writeString(staged, text.replace(OLD_BIN, NEW_BIN)) || !staged.renameTo(entry)) {
|
||||
staged.delete()
|
||||
Log.w(TAG, "could not rewrite $entry")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.droiddeck.launcher.runtime
|
||||
|
||||
import android.content.Context
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
|
||||
object LinuxFex {
|
||||
const val TOOL = "/usr/local/bin/droiddeck-fex"
|
||||
const val AUTO = "auto"
|
||||
const val ON = "on"
|
||||
const val OFF = "off"
|
||||
val MODES = listOf(AUTO, ON, OFF)
|
||||
const val ELF_I386 = 3
|
||||
const val ELF_X86_64 = 62
|
||||
const val ELF_AARCH64 = 183
|
||||
private const val MODE_FILE = "fex"
|
||||
private const val ARCH_FILE = "arch"
|
||||
private const val STATUS = "root/.local/share/droiddeck-fex/status.json"
|
||||
|
||||
fun ready(context: Context): Boolean =
|
||||
FileUtils.readString(File(LinuxRuntime.rootDir(context), STATUS))
|
||||
?.let { runCatching { JSONObject(it).optBoolean("ready") }.getOrNull() } == true
|
||||
|
||||
fun mode(dir: File): String = FileUtils.readString(File(dir, MODE_FILE))?.trim()?.takeIf { it in MODES } ?: AUTO
|
||||
|
||||
fun setMode(dir: File, mode: String): Boolean {
|
||||
val file = File(dir, MODE_FILE)
|
||||
return when (mode) {
|
||||
AUTO -> !file.exists() || file.delete()
|
||||
ON, OFF -> FileUtils.writeString(file, mode)
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
fun elfMachine(file: File): Int? = try {
|
||||
RandomAccessFile(file, "r").use { f ->
|
||||
val head = ByteArray(20)
|
||||
if (f.read(head) < head.size) return null
|
||||
if (head[0] != 0x7f.toByte() || head[1] != 'E'.code.toByte() || head[2] != 'L'.code.toByte() || head[3] != 'F'.code.toByte()) return null
|
||||
if (head[5].toInt() == 2) ((head[18].toInt() and 0xff) shl 8) or (head[19].toInt() and 0xff)
|
||||
else (head[18].toInt() and 0xff) or ((head[19].toInt() and 0xff) shl 8)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
fun isX86(machine: Int?): Boolean = machine == ELF_X86_64 || machine == ELF_I386
|
||||
|
||||
fun archName(machine: Int?): String? = when (machine) {
|
||||
ELF_X86_64 -> "x86_64"
|
||||
ELF_I386 -> "i386"
|
||||
ELF_AARCH64 -> "arm64"
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun writeArch(dir: File, machine: Int?) {
|
||||
val name = archName(machine)
|
||||
if (name == null) File(dir, ARCH_FILE).delete() else FileUtils.writeString(File(dir, ARCH_FILE), name)
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,30 @@
|
||||
package com.droiddeck.launcher.runtime
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.location.LocationManager
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.LinkProperties
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.wifi.WifiInfo
|
||||
import android.net.wifi.WifiManager
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.SystemClock
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.core.SessionPart
|
||||
import com.droiddeck.launcher.core.WifiDiscovery
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.net.Inet4Address
|
||||
import java.net.Inet6Address
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* The device's network link, published for the Linux runtime's processes.
|
||||
@@ -34,20 +47,94 @@ class LinuxNetworkLinkComponent(
|
||||
appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
private val lock = Any()
|
||||
private var callback: ConnectivityManager.NetworkCallback? = null
|
||||
private var currentNetwork: Network? = null
|
||||
private var currentProperties: LinkProperties? = null
|
||||
private var currentCapabilities: NetworkCapabilities? = null
|
||||
private val wifiManager = appContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager
|
||||
private val scanHandler = Handler(Looper.getMainLooper())
|
||||
private var wifiReceiver: BroadcastReceiver? = null
|
||||
private var scanRequestStamp = 0L
|
||||
private var lastScanRequest = -SCAN_INTERVAL_MS
|
||||
@Volatile private var lastNamesAllowed = false
|
||||
private val scanRequests = object : Runnable {
|
||||
override fun run() {
|
||||
if (canReadWifiNames() != lastNamesAllowed) {
|
||||
synchronized(lock) { writeNetworkState(currentProperties, currentCapabilities) }
|
||||
requestWifiScan()
|
||||
}
|
||||
val stamp = File(rootDir, "etc/droiddeck-wifi-scan-request").lastModified()
|
||||
if (stamp != 0L && stamp != scanRequestStamp) {
|
||||
scanRequestStamp = stamp
|
||||
requestWifiScan()
|
||||
}
|
||||
if (wifiReceiver != null) scanHandler.postDelayed(this, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
/** Called before the session starts, so its first process already sees the link. */
|
||||
fun publish() = write(connectivity.activeNetwork?.let(connectivity::getLinkProperties))
|
||||
fun publish() = synchronized(lock) {
|
||||
currentNetwork = connectivity.activeNetwork
|
||||
currentProperties = currentNetwork?.let(connectivity::getLinkProperties)
|
||||
currentCapabilities = currentNetwork?.let(connectivity::getNetworkCapabilities)
|
||||
write(currentProperties, currentCapabilities)
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
val registered = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onLinkPropertiesChanged(network: Network, properties: LinkProperties) = write(properties)
|
||||
override fun onLost(network: Network) = write(null)
|
||||
override fun onAvailable(network: Network) = synchronized(lock) {
|
||||
currentNetwork = network
|
||||
currentProperties = null
|
||||
currentCapabilities = null
|
||||
}
|
||||
|
||||
override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) = synchronized(lock) {
|
||||
if (network == currentNetwork) {
|
||||
currentCapabilities = capabilities
|
||||
if (currentProperties != null) write(currentProperties, capabilities)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLinkPropertiesChanged(network: Network, properties: LinkProperties) = synchronized(lock) {
|
||||
if (network == currentNetwork) {
|
||||
currentProperties = properties
|
||||
write(properties, currentCapabilities)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) = synchronized(lock) {
|
||||
// A handover can report the old link lost after the new default arrived.
|
||||
if (network == currentNetwork) {
|
||||
currentNetwork = null
|
||||
currentProperties = null
|
||||
currentCapabilities = null
|
||||
write(null, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
synchronized(lock) { callback = registered }
|
||||
connectivity.registerDefaultNetworkCallback(registered)
|
||||
val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
synchronized(lock) { writeNetworkState(currentProperties, currentCapabilities) }
|
||||
}
|
||||
}
|
||||
wifiReceiver = receiver
|
||||
appContext.registerReceiver(receiver, IntentFilter().apply {
|
||||
addAction(WifiManager.SCAN_RESULTS_AVAILABLE_ACTION)
|
||||
addAction(WifiManager.RSSI_CHANGED_ACTION)
|
||||
addAction(WifiManager.WIFI_STATE_CHANGED_ACTION)
|
||||
addAction(WifiManager.NETWORK_STATE_CHANGED_ACTION)
|
||||
addAction(LocationManager.MODE_CHANGED_ACTION)
|
||||
})
|
||||
scanRequestStamp = File(rootDir, "etc/droiddeck-wifi-scan-request").lastModified()
|
||||
scanHandler.post(scanRequests)
|
||||
requestWifiScan()
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
scanHandler.removeCallbacks(scanRequests)
|
||||
wifiReceiver?.let { appContext.unregisterReceiver(it) }
|
||||
wifiReceiver = null
|
||||
val registered = synchronized(lock) { callback.also { callback = null } } ?: return
|
||||
try {
|
||||
connectivity.unregisterNetworkCallback(registered)
|
||||
@@ -56,7 +143,22 @@ class LinuxNetworkLinkComponent(
|
||||
}
|
||||
}
|
||||
|
||||
private fun write(properties: LinkProperties?) {
|
||||
private fun canReadWifiNames(): Boolean = WifiDiscovery.available(appContext)
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun requestWifiScan() {
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
if (!canReadWifiNames() || wifiManager?.isWifiEnabled != true || now - lastScanRequest < SCAN_INTERVAL_MS) return
|
||||
lastScanRequest = now
|
||||
try {
|
||||
Log.i(TAG, "Wi-Fi scan requested: accepted=${wifiManager.startScan()}")
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "Wi-Fi scan permission unavailable")
|
||||
}
|
||||
synchronized(lock) { writeNetworkState(currentProperties, currentCapabilities) }
|
||||
}
|
||||
|
||||
private fun write(properties: LinkProperties?, capabilities: NetworkCapabilities?) {
|
||||
val file = File(rootDir, LINK_FILE)
|
||||
synchronized(lock) {
|
||||
try {
|
||||
@@ -67,10 +169,99 @@ class LinuxNetworkLinkComponent(
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Could not publish the network link", e)
|
||||
}
|
||||
writeNetworkState(properties, capabilities)
|
||||
writeResolver(properties)
|
||||
}
|
||||
}
|
||||
|
||||
/** NetworkManager's view of Android. Kept separate from netif.c's legacy adapter format. */
|
||||
@Suppress("DEPRECATION")
|
||||
private fun writeNetworkState(properties: LinkProperties?, capabilities: NetworkCapabilities?) {
|
||||
val namesAllowed = canReadWifiNames()
|
||||
lastNamesAllowed = namesAllowed
|
||||
val scans = if (namesAllowed && wifiManager?.isWifiEnabled == true) {
|
||||
try { wifiManager.scanResults.orEmpty() } catch (e: SecurityException) { emptyList() }
|
||||
} else emptyList()
|
||||
val transport = when {
|
||||
capabilities == null -> "none"
|
||||
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "wifi"
|
||||
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "cellular"
|
||||
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "ethernet"
|
||||
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) -> "vpn"
|
||||
else -> "other"
|
||||
}
|
||||
val state = JSONObject().apply {
|
||||
put("transport", transport)
|
||||
put("interface", properties?.interfaceName ?: "android")
|
||||
put("mtu", properties?.mtu?.takeIf { it > 0 } ?: DEFAULT_MTU)
|
||||
put("connected", properties != null && capabilities != null)
|
||||
put("validated", capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) == true)
|
||||
put("captivePortal", capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_CAPTIVE_PORTAL) == true)
|
||||
put("metered", capabilities?.let { !it.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED) })
|
||||
put("wifiEnabled", wifiManager?.isWifiEnabled == true)
|
||||
put("scanAllowed", namesAllowed)
|
||||
put("lastScan", scans.maxOfOrNull { it.timestamp / 1000 } ?: -1L)
|
||||
put("accessPoints", JSONArray().apply {
|
||||
scans.filter { it.SSID.isNotEmpty() }.forEach {
|
||||
put(JSONObject().apply {
|
||||
put("ssid", it.SSID)
|
||||
put("bssid", it.BSSID)
|
||||
put("strength", WifiManager.calculateSignalLevel(it.level, 101))
|
||||
put("frequency", it.frequency)
|
||||
put("capabilities", it.capabilities)
|
||||
put("lastSeen", it.timestamp / 1_000_000)
|
||||
})
|
||||
}
|
||||
})
|
||||
put("addresses", JSONArray().apply {
|
||||
properties?.linkAddresses.orEmpty().forEach {
|
||||
put(JSONObject().put("address", it.address.hostAddress?.substringBefore('%')).put("prefix", it.prefixLength))
|
||||
}
|
||||
})
|
||||
put("gateways", JSONArray().apply {
|
||||
properties?.routes.orEmpty().filter { it.isDefaultRoute }.forEach {
|
||||
it.gateway?.takeUnless { address -> address.isAnyLocalAddress }?.let { address ->
|
||||
put(address.hostAddress?.substringBefore('%'))
|
||||
}
|
||||
}
|
||||
})
|
||||
put("dns", JSONArray().apply {
|
||||
properties?.dnsServers.orEmpty().filter { !it.isLinkLocalAddress }.forEach {
|
||||
put(it.hostAddress?.substringBefore('%'))
|
||||
}
|
||||
})
|
||||
if (transport == "wifi") {
|
||||
val connectionInfo = try { wifiManager?.connectionInfo } catch (e: SecurityException) { null }
|
||||
val info = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
(capabilities?.transportInfo as? WifiInfo) ?: connectionInfo
|
||||
} else {
|
||||
connectionInfo
|
||||
}
|
||||
if (info != null) {
|
||||
// SSIDs can be redacted by Android. No location permission is needed to
|
||||
// report the transport; a hidden name simply displays as Wi-Fi in Steam.
|
||||
if (namesAllowed) {
|
||||
val namedInfo = connectionInfo ?: info
|
||||
namedInfo.ssid?.takeUnless { it == WifiManager.UNKNOWN_SSID || it.isEmpty() }
|
||||
?.removeSurrounding("\"")?.let { put("ssid", it) }
|
||||
namedInfo.bssid?.takeUnless { it == "02:00:00:00:00:00" }?.let { put("bssid", it) }
|
||||
}
|
||||
put("strength", WifiManager.calculateSignalLevel(info.rssi, 101))
|
||||
put("frequency", info.frequency.coerceAtLeast(0))
|
||||
put("bitrate", info.linkSpeed.coerceAtLeast(0) * 1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
val file = File(rootDir, "etc/droiddeck-network.json")
|
||||
val staged = File(file.path + ".staged")
|
||||
staged.writeText(state.toString())
|
||||
if (!staged.renameTo(file)) throw IOException("Could not replace network state")
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Could not publish the network state", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The guest's resolver, from the network it is actually on. glibc reads /etc/resolv.conf,
|
||||
* and the image ships two public servers there, which is why Steam resolves at all - but a
|
||||
@@ -144,10 +335,11 @@ class LinuxNetworkLinkComponent(
|
||||
|
||||
companion object {
|
||||
private const val TAG = "LinuxNetworkLink"
|
||||
private const val LINK_FILE = "etc/bannerlator-net"
|
||||
private const val LINK_FILE = "etc/droiddeck-net"
|
||||
private const val LINK_INDEX = 2
|
||||
private const val DEFAULT_MTU = 1500
|
||||
private const val OFFLINE_NAME = "eth0"
|
||||
private const val OFFLINE_ADDRESS = "10.0.0.2 24"
|
||||
private const val SCAN_INTERVAL_MS = 30_000L
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ import java.util.List;
|
||||
*/
|
||||
public final class LinuxRuntime {
|
||||
public static final String DIR = "linuxfs";
|
||||
public static final String SESSION_SCRIPT = "/usr/local/bin/bannerlator-session";
|
||||
public static final String SESSION_SCRIPT = "/usr/local/bin/droiddeck-session";
|
||||
public static final String MODE_DESKTOP = "desktop";
|
||||
public static final String MODE_STEAM = "steam";
|
||||
public static final String MODE_RUN = "run";
|
||||
@@ -88,9 +88,11 @@ public final class LinuxRuntime {
|
||||
|
||||
/** The rootfs is present with gamescope and the session script the launcher hands control to. */
|
||||
public static boolean isInstalled(Context context) {
|
||||
if (LinuxRuntimeInstaller.isRemoving() || LinuxRuntimeInstaller.hasRemovalPending(context)) return false;
|
||||
File root = rootDir(context);
|
||||
return new File(root, "usr/bin/gamescope").isFile()
|
||||
&& new File(root, SESSION_SCRIPT.substring(1)).isFile()
|
||||
&& (new File(root, SESSION_SCRIPT.substring(1)).isFile()
|
||||
|| new File(root, LegacyNames.SESSION_SCRIPT.substring(1)).isFile())
|
||||
&& prootBinary(context).isFile()
|
||||
&& prootLoader(context).isFile();
|
||||
}
|
||||
@@ -181,6 +183,7 @@ public final class LinuxRuntime {
|
||||
public static List<String> binds(Context context, File sessionRoot, File runtimeDir,
|
||||
File externalStorage, List<String> extraBinds) {
|
||||
File root = rootDir(context);
|
||||
LegacyNames.migrateEtc(root);
|
||||
List<String> cmd = new ArrayList<>();
|
||||
bind(cmd, "/dev");
|
||||
bind(cmd, "/proc");
|
||||
@@ -190,7 +193,7 @@ public final class LinuxRuntime {
|
||||
bind(cmd, "/proc/self/fd/0:/dev/stdin");
|
||||
bind(cmd, "/proc/self/fd/1:/dev/stdout");
|
||||
bind(cmd, "/proc/self/fd/2:/dev/stderr");
|
||||
bind(cmd, new File(root, "etc/bannerlator/empty").getPath() + ":/sys/fs/selinux");
|
||||
bind(cmd, new File(root, "etc/droiddeck/empty").getPath() + ":/sys/fs/selinux");
|
||||
bind(cmd, context.getFilesDir().getPath());
|
||||
bind(cmd, context.getCacheDir().getPath());
|
||||
if (runtimeDir != null) {
|
||||
@@ -206,7 +209,7 @@ public final class LinuxRuntime {
|
||||
bind(cmd, shm.getPath() + ":/dev/shm");
|
||||
|
||||
// Android denies apps these; glibc, Steam and libcap read them at startup.
|
||||
File fakeProc = new File(root, "etc/bannerlator/proc");
|
||||
File fakeProc = new File(root, "etc/droiddeck/proc");
|
||||
// libpci picks its procfs backend on whether it can read the /proc/bus/pci directory, which
|
||||
// the app can, then die()s - exit(1) on the calling process - on the devices file inside
|
||||
// it, which the app cannot. Chromium loads libpci in its GPU process to name the video
|
||||
@@ -229,6 +232,17 @@ public final class LinuxRuntime {
|
||||
// It then fails the isFile() test below and the session runs as it did before.
|
||||
}
|
||||
}
|
||||
// protonfixes, which GE-Proton and proton-cachyos run before every game, opens this to
|
||||
// check the esync file limit and dies on the PermissionError, so no game starts with them.
|
||||
// The value is the 64-bit kernel default.
|
||||
File fileMax = new File(fakeProc, "file-max");
|
||||
if (!fileMax.isFile()) {
|
||||
try {
|
||||
Files.write(fileMax.toPath(), "9223372036854775807\n".getBytes(StandardCharsets.US_ASCII));
|
||||
} catch (IOException e) {
|
||||
// Left unbound, as above.
|
||||
}
|
||||
}
|
||||
String[][] procFiles = {
|
||||
{"pci_devices", "/proc/bus/pci/devices"},
|
||||
{"stat", "/proc/stat"},
|
||||
@@ -239,6 +253,7 @@ public final class LinuxRuntime {
|
||||
{"cap_last_cap", "/proc/sys/kernel/cap_last_cap"},
|
||||
{"overflowuid", "/proc/sys/kernel/overflowuid"},
|
||||
{"overflowgid", "/proc/sys/kernel/overflowgid"},
|
||||
{"file-max", "/proc/sys/fs/file-max"},
|
||||
};
|
||||
for (String[] entry : procFiles) {
|
||||
File fake = new File(fakeProc, entry[0]);
|
||||
@@ -405,7 +420,7 @@ public final class LinuxRuntime {
|
||||
* under the mainline name.
|
||||
*/
|
||||
private static void bindCpuTemps(List<String> cmd, File root) {
|
||||
File name = new File(root, "etc/bannerlator/cpu-thermal-type");
|
||||
File name = new File(root, "etc/droiddeck/cpu-thermal-type");
|
||||
try {
|
||||
if (!name.isFile()) Files.write(name.toPath(), "cpu0-thermal\n".getBytes(StandardCharsets.US_ASCII));
|
||||
} catch (IOException e) {
|
||||
|
||||
@@ -64,7 +64,7 @@ public final class LinuxRuntimeInstaller {
|
||||
}
|
||||
|
||||
/**
|
||||
* One install per process. The launcher and the session screen can both start one, and a
|
||||
* One runtime operation per process. The launcher and session can both install, and a
|
||||
* launcher rebuilt mid-install (a pad plugged in, dark mode switched) forgets it had: a second
|
||||
* install would share the first one's archive and staging directory and wreck both.
|
||||
*/
|
||||
@@ -74,11 +74,13 @@ public final class LinuxRuntimeInstaller {
|
||||
volatile String stage = "Starting\u2026";
|
||||
volatile int percent = -1;
|
||||
volatile boolean ok;
|
||||
final boolean removal;
|
||||
Job(boolean removal) { this.removal = removal; }
|
||||
}
|
||||
|
||||
private static final Object JOB_LOCK = new Object();
|
||||
private static Job running;
|
||||
private static boolean removing;
|
||||
private static volatile String removalError;
|
||||
|
||||
private LinuxRuntimeInstaller() {}
|
||||
|
||||
@@ -127,9 +129,9 @@ public final class LinuxRuntimeInstaller {
|
||||
Job job;
|
||||
boolean owner;
|
||||
synchronized (JOB_LOCK) {
|
||||
if (removing) return false;
|
||||
if (running != null && running.removal) return false;
|
||||
owner = running == null;
|
||||
if (owner) running = new Job();
|
||||
if (owner) { running = new Job(false); removalError = null; }
|
||||
job = running;
|
||||
if (listener != null) job.listeners.add(listener);
|
||||
}
|
||||
@@ -152,13 +154,31 @@ 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;
|
||||
return running != null && !running.removal;
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean isBusy() {
|
||||
synchronized (JOB_LOCK) { return running != null; }
|
||||
}
|
||||
|
||||
public static boolean isRemoving() {
|
||||
synchronized (JOB_LOCK) { return running != null && running.removal; }
|
||||
}
|
||||
|
||||
public static String removalError() { return removalError; }
|
||||
|
||||
public static boolean hasRemovalPending(Context context) {
|
||||
return removalDirectory(LinuxRuntime.rootDir(context)).exists();
|
||||
}
|
||||
|
||||
private static File removalDirectory(File root) {
|
||||
return new File(root.getParentFile(), root.getName() + ".removing");
|
||||
}
|
||||
|
||||
/**
|
||||
* Follows the install already running, for a screen that was rebuilt mid-install. Blocks until
|
||||
* it ends and returns its result, or returns null at once when nothing is installing.
|
||||
* Follows the runtime operation already running, including removal, after a screen rebuild.
|
||||
* Blocks until it ends and returns its result, or null when there is no current operation.
|
||||
*/
|
||||
public static Boolean attach(ProgressListener listener) {
|
||||
Job job;
|
||||
@@ -186,6 +206,11 @@ public final class LinuxRuntimeInstaller {
|
||||
private static boolean installOnce(Context context, Release release, ProgressListener listener) {
|
||||
File archive = new File(context.getCacheDir(), "linuxfs.tar.zst");
|
||||
try {
|
||||
// A failed/interrupted explicit removal must finish before a fresh installation.
|
||||
// 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("Removing runtime leftovers", -1);
|
||||
RuntimeFileTree.delete(pendingRemoval, null);
|
||||
if (listener != null) listener.onProgress("Downloading", 0);
|
||||
// Downloader reports a 0..1 fraction, or -1 while the total size is unknown.
|
||||
boolean ok = Downloader.downloadFile(release.url, archive, true, (fraction) -> {
|
||||
@@ -212,18 +237,18 @@ 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);
|
||||
FileUtils.delete(staging);
|
||||
RuntimeFileTree.delete(staging, null);
|
||||
if (!staging.mkdirs()) return false;
|
||||
if (listener != null) listener.onProgress("Extracting", -1);
|
||||
if (!extract(archive, staging, listener)) {
|
||||
FileUtils.delete(staging);
|
||||
RuntimeFileTree.delete(staging, null);
|
||||
return false;
|
||||
}
|
||||
FileUtils.writeString(new File(staging, VERSION_FILE), release.version);
|
||||
|
||||
FileUtils.delete(old);
|
||||
RuntimeFileTree.delete(old, null);
|
||||
if (root.isDirectory() && !root.renameTo(old)) {
|
||||
FileUtils.delete(staging);
|
||||
RuntimeFileTree.delete(staging, null);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -232,16 +257,15 @@ public final class LinuxRuntimeInstaller {
|
||||
// own empty /root is dropped first so the rename has somewhere to land. If this fails
|
||||
// the update is abandoned and the previous runtime is put back untouched - shipping a
|
||||
// working system with the user's games gone is the worse outcome.
|
||||
File keptFrom = new File(old, USER_DATA);
|
||||
if (keptFrom.isDirectory()) {
|
||||
File keptTo = new File(staging, USER_DATA);
|
||||
FileUtils.delete(keptTo);
|
||||
if (!keptFrom.renameTo(keptTo)) {
|
||||
Log.w(TAG, "could not carry " + USER_DATA + " across the update; rolling back");
|
||||
FileUtils.delete(staging);
|
||||
old.renameTo(root);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
RuntimeFileTree.carryHome(old, staging);
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "could not carry root across the update; rolling back", e);
|
||||
// Restore the live name even if cleaning the failed staging tree is denied.
|
||||
old.renameTo(root);
|
||||
try { RuntimeFileTree.delete(staging, null); }
|
||||
catch (IOException cleanup) { Log.w(TAG, "could not clear the failed staging tree", cleanup); }
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!staging.renameTo(root)) {
|
||||
@@ -250,7 +274,7 @@ public final class LinuxRuntimeInstaller {
|
||||
if (old.isDirectory()) old.renameTo(root);
|
||||
return false;
|
||||
}
|
||||
FileUtils.delete(old);
|
||||
RuntimeFileTree.delete(old, null);
|
||||
return LinuxRuntime.isInstalled(context);
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "install", e);
|
||||
@@ -260,13 +284,13 @@ public final class LinuxRuntimeInstaller {
|
||||
}
|
||||
}
|
||||
|
||||
private static void recoverInterruptedSwap(File root, File staging, File old) {
|
||||
static void recoverInterruptedSwap(File root, File staging, File old) throws IOException {
|
||||
File target = root.isDirectory() ? root : old;
|
||||
File stagedHome = new File(staging, USER_DATA);
|
||||
File home = new File(target, USER_DATA);
|
||||
if (target.isDirectory() && stagedHome.isDirectory() && (!home.exists() || isEmptyDir(home))) {
|
||||
FileUtils.delete(home);
|
||||
if (stagedHome.renameTo(home)) Log.w(TAG, "recovered " + USER_DATA + " from an interrupted update");
|
||||
RuntimeFileTree.carryHome(staging, target);
|
||||
Log.w(TAG, "recovered " + USER_DATA + " from an interrupted update");
|
||||
}
|
||||
if (!root.isDirectory() && old.isDirectory() && old.renameTo(root)) {
|
||||
Log.w(TAG, "restored the previous runtime after an interrupted update");
|
||||
@@ -278,20 +302,76 @@ public final class LinuxRuntimeInstaller {
|
||||
return dir.isDirectory() && names != null && names.length == 0;
|
||||
}
|
||||
|
||||
/** Removes the runtime; returns false without touching it while an install is running. */
|
||||
public static boolean uninstall(Context context) {
|
||||
/** Reserve removal before returning to the UI, so Play cannot race the worker. */
|
||||
public static Removal beginUninstall(Context context) {
|
||||
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;
|
||||
return beginUninstall(LinuxRuntime.rootDir(context));
|
||||
}
|
||||
|
||||
static Removal beginUninstall(File root) {
|
||||
synchronized (JOB_LOCK) {
|
||||
if (running != null || removing) return false;
|
||||
removing = true;
|
||||
if (running != null) return null;
|
||||
removalError = null;
|
||||
running = new Job(true);
|
||||
running.stage = "Removing Linux runtime";
|
||||
return new Removal(root, running);
|
||||
}
|
||||
try {
|
||||
FileUtils.delete(LinuxRuntime.rootDir(context));
|
||||
return true;
|
||||
} finally {
|
||||
synchronized (JOB_LOCK) {
|
||||
removing = false;
|
||||
}
|
||||
|
||||
public static final class Removal {
|
||||
private final File root;
|
||||
private final Job job;
|
||||
private final java.util.concurrent.atomic.AtomicBoolean started = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
private Removal(File root, Job job) { this.root = root; this.job = job; }
|
||||
|
||||
public boolean run(ProgressListener listener) {
|
||||
if (!started.compareAndSet(false, true)) return join(job, listener);
|
||||
if (listener != null) job.listeners.add(listener);
|
||||
try {
|
||||
report("Removing Linux runtime", -1);
|
||||
final long[] removed = {0};
|
||||
final long[] lastShown = {0};
|
||||
java.util.function.LongConsumer progress = ignored -> {
|
||||
removed[0]++;
|
||||
long now = System.nanoTime();
|
||||
if (removed[0] == 1 || now - lastShown[0] >= 200_000_000L) {
|
||||
lastShown[0] = now;
|
||||
report("Removing Linux runtime · " + removed[0] + " entries", -1);
|
||||
}
|
||||
};
|
||||
File pending = removalDirectory(root);
|
||||
// Invalidate the installation before traversing it. A process killed halfway
|
||||
// through leaves a named removal to resume, never a launchable partial runtime.
|
||||
if (root.exists()) {
|
||||
RuntimeFileTree.delete(pending, progress);
|
||||
if (!root.renameTo(pending)) throw new IOException("Cannot prepare the runtime for removal");
|
||||
} else if (!pending.isDirectory() && !pending.mkdirs()) {
|
||||
throw new IOException("Cannot prepare the runtime for removal");
|
||||
}
|
||||
// Keep the quarantine until all leftovers are gone, so a failure stays retryable.
|
||||
RuntimeFileTree.delete(new File(root.getParentFile(), root.getName() + ".new"), progress);
|
||||
RuntimeFileTree.delete(new File(root.getParentFile(), root.getName() + ".old"), progress);
|
||||
RuntimeFileTree.delete(pending, progress);
|
||||
job.ok = true;
|
||||
report("Linux runtime removed", 100);
|
||||
return true;
|
||||
} catch (IOException e) {
|
||||
removalError = "Could not finish removing the Linux runtime. Retry removal in Setup.";
|
||||
Log.w(TAG, "runtime removal incomplete", e);
|
||||
report(removalError, -1);
|
||||
return false;
|
||||
} finally {
|
||||
synchronized (JOB_LOCK) { running = null; }
|
||||
job.done.countDown();
|
||||
}
|
||||
}
|
||||
private void report(String stage, int percent) {
|
||||
job.stage = stage;
|
||||
job.percent = percent;
|
||||
for (ProgressListener listener : job.listeners) listener.onProgress(stage, percent);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,13 +21,15 @@ import java.security.MessageDigest
|
||||
*/
|
||||
object ProotFastPath {
|
||||
const val LIBRARY = "/usr/local/lib/libblfastpath.so"
|
||||
/** MAX_BINDS in fastpath.c: past it the library turns itself off, so the session keeps it off. */
|
||||
const val MAX_BINDS = 256
|
||||
|
||||
fun enabled(context: Context): Boolean =
|
||||
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? {
|
||||
if (binds.any { it.contains('|') } || root.path.contains('|')) return null
|
||||
if (binds.size > MAX_BINDS || binds.any { it.contains('|') } || root.path.contains('|')) return null
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
.digest((root.path + "\n" + binds.joinToString("\n")).toByteArray())
|
||||
return digest.take(12).joinToString("") { "%02x".format(it) }
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.droiddeck.launcher.runtime;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.DirectoryStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.NoSuchFileException;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.util.function.LongConsumer;
|
||||
|
||||
/** Checked removal of an app-owned runtime tree, including read-only directories. */
|
||||
final class RuntimeFileTree {
|
||||
private RuntimeFileTree() {}
|
||||
|
||||
static void delete(File root, LongConsumer progress) throws IOException {
|
||||
delete(root.toPath(), progress, new long[]{0});
|
||||
}
|
||||
|
||||
private static void delete(Path path, LongConsumer progress, long[] removed) throws IOException {
|
||||
BasicFileAttributes attrs;
|
||||
try { attrs = Files.readAttributes(path, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); }
|
||||
catch (NoSuchFileException e) { return; }
|
||||
if (attrs.isDirectory()) {
|
||||
// Change directory permissions before opening it, so mode 000 also works. Links
|
||||
// are inspected with lstat and unlinked directly; never chmod or visit their target.
|
||||
makeWritable(path.toFile());
|
||||
try (DirectoryStream<Path> children = Files.newDirectoryStream(path)) {
|
||||
for (Path child : children) delete(child, progress, removed);
|
||||
}
|
||||
}
|
||||
Files.delete(path);
|
||||
if (progress != null) progress.accept(++removed[0]);
|
||||
}
|
||||
|
||||
private static void makeWritable(File dir) throws IOException {
|
||||
if (!dir.setReadable(true, true) || !dir.setWritable(true, true) || !dir.setExecutable(true, true))
|
||||
throw new IOException("Cannot make directory removable: " + dir);
|
||||
}
|
||||
|
||||
static void carryHome(File old, File staging) throws IOException {
|
||||
File home = new File(old, "root");
|
||||
if (!home.isDirectory()) return;
|
||||
makeWritable(old);
|
||||
makeWritable(staging);
|
||||
File target = new File(staging, "root");
|
||||
delete(target, null);
|
||||
if (!home.renameTo(target)) throw new IOException("Cannot carry root across the update");
|
||||
}
|
||||
}
|
||||
@@ -25,22 +25,25 @@ class RuntimeInstallService : Service() {
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
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("Starting…", -1))
|
||||
if (release == null) {
|
||||
startForeground(NOTIFICATION_ID, notification(if (removal) "Removing Linux runtime" else "Starting…", -1))
|
||||
if (release == null && !removal) {
|
||||
stopSelf(startId)
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
Thread({
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
var shown = Int.MIN_VALUE
|
||||
LinuxRuntimeInstaller.install(applicationContext, release) { stage, percent ->
|
||||
// Every percent at most once: a notification per read would flood the system.
|
||||
if (percent != shown) {
|
||||
shown = percent
|
||||
var shown = ""
|
||||
val progress = LinuxRuntimeInstaller.ProgressListener { stage, percent ->
|
||||
// Install percentages and throttled removal counts, including stage transitions.
|
||||
if ("$stage:$percent" != shown) {
|
||||
shown = "$stage:$percent"
|
||||
manager?.notify(NOTIFICATION_ID, notification(stage, percent))
|
||||
}
|
||||
}
|
||||
if (removal) LinuxRuntimeInstaller.attach(progress)
|
||||
else LinuxRuntimeInstaller.install(applicationContext, release!!, progress)
|
||||
@Suppress("DEPRECATION")
|
||||
stopForeground(true)
|
||||
stopSelf(startId)
|
||||
@@ -50,9 +53,9 @@ class RuntimeInstallService : Service() {
|
||||
|
||||
private fun notification(stage: String, percent: Int): Notification {
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
manager?.createNotificationChannel(NotificationChannel(CHANNEL_ID, "Runtime install",
|
||||
manager?.createNotificationChannel(NotificationChannel(CHANNEL_ID, "Linux runtime",
|
||||
NotificationManager.IMPORTANCE_LOW).apply {
|
||||
description = "Shows while the Linux runtime downloads and installs"
|
||||
description = "Shows while the Linux runtime is installed or removed"
|
||||
setShowBadge(false)
|
||||
setSound(null, null)
|
||||
enableVibration(false)
|
||||
@@ -62,7 +65,7 @@ class RuntimeInstallService : Service() {
|
||||
PendingIntent.FLAG_IMMUTABLE)
|
||||
return Notification.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_stat_session)
|
||||
.setContentTitle("Installing the Linux runtime")
|
||||
.setContentTitle(if (LinuxRuntimeInstaller.isRemoving()) "Removing the Linux runtime" else "Installing the Linux runtime")
|
||||
.setContentText(stage)
|
||||
.setProgress(100, percent.coerceIn(0, 100), percent < 0)
|
||||
.setContentIntent(open)
|
||||
@@ -75,6 +78,7 @@ class RuntimeInstallService : Service() {
|
||||
companion object {
|
||||
private const val CHANNEL_ID = "runtime-install"
|
||||
private const val NOTIFICATION_ID = 2
|
||||
private const val ACTION_REMOVE = "com.droiddeck.launcher.REMOVE_RUNTIME"
|
||||
private const val EXTRA_VERSION = "version"
|
||||
private const val EXTRA_URL = "url"
|
||||
private const val EXTRA_SHA256 = "sha256"
|
||||
@@ -90,6 +94,11 @@ class RuntimeInstallService : Service() {
|
||||
if (Build.VERSION.SDK_INT >= 26) context.startForegroundService(intent) else context.startService(intent)
|
||||
}
|
||||
|
||||
fun keepRemovalAlive(context: Context) {
|
||||
val intent = Intent(context, RuntimeInstallService::class.java).setAction(ACTION_REMOVE)
|
||||
if (Build.VERSION.SDK_INT >= 26) context.startForegroundService(intent) else context.startService(intent)
|
||||
}
|
||||
|
||||
private fun releaseFrom(intent: Intent): LinuxRuntimeInstaller.Release? {
|
||||
val version = intent.getStringExtra(EXTRA_VERSION) ?: return null
|
||||
val url = intent.getStringExtra(EXTRA_URL) ?: return null
|
||||
|
||||
@@ -31,7 +31,7 @@ import java.io.RandomAccessFile
|
||||
object UserApps {
|
||||
private const val TAG = "UserApps"
|
||||
const val GUEST_DIR = "/opt/droiddeck-apps"
|
||||
const val SCRIPT_LAUNCHER = "/usr/local/bin/bannerlator-script-run"
|
||||
const val SCRIPT_LAUNCHER = "/usr/local/bin/droiddeck-script-run"
|
||||
private const val FLATPAK_OVERRIDES = ".flatpak"
|
||||
private const val ICON_SIZE = 256
|
||||
private const val ELF_SCAN_LIMIT = 4000
|
||||
@@ -53,6 +53,7 @@ object UserApps {
|
||||
val key: String, val kind: Kind, val name: String, val icon: Any?, val detail: String?,
|
||||
val program: String, val args: List<String>, val copied: Boolean = false,
|
||||
val repo: String? = null, val version: String? = null,
|
||||
val fex: String = LinuxFex.AUTO, val arch: String? = null,
|
||||
)
|
||||
|
||||
class Release(val tag: String, val published: String, val asset: String, val url: String)
|
||||
@@ -89,6 +90,7 @@ object UserApps {
|
||||
"script:${dir.name}", Kind.SCRIPT, readName(dir) ?: dir.name, File(dir, "icon.png").takeIf { it.isFile },
|
||||
FileUtils.readString(File(dir, "source"))?.trim(), SCRIPT_LAUNCHER, listOf("$GUEST_DIR/${dir.name}"),
|
||||
copied = !java.nio.file.Files.isSymbolicLink(File(dir, "files").toPath()),
|
||||
fex = LinuxFex.mode(dir),
|
||||
)
|
||||
}.orEmpty()
|
||||
val images = AppImageManager.list(context).map { item ->
|
||||
@@ -96,6 +98,7 @@ object UserApps {
|
||||
App(
|
||||
"appimage:${item.id}", Kind.APPIMAGE, item.name, item.icon, item.comment, AppImageManager.LAUNCHER, listOf(item.guestDir),
|
||||
repo = FileUtils.readString(File(dir, GITHUB_REPO))?.trim(), version = FileUtils.readString(File(dir, GITHUB_TAG))?.trim(),
|
||||
fex = LinuxFex.mode(dir), arch = FileUtils.readString(File(dir, "arch"))?.trim()?.takeIf { it.isNotEmpty() },
|
||||
)
|
||||
}
|
||||
val flatpaks = FlatpakManager.installedApps(context).map { app ->
|
||||
@@ -140,7 +143,7 @@ object UserApps {
|
||||
* Renames [app] and sets its icon: [icon] a path or URL, "" for its default, null to keep it.
|
||||
* The Linux desktop's menu entry follows.
|
||||
*/
|
||||
fun edit(context: Context, app: App, name: String, icon: String?): String? {
|
||||
fun edit(context: Context, app: App, name: String, icon: String?, fex: String? = null): String? {
|
||||
val id = app.key.substringAfter(':')
|
||||
if (id.isEmpty() || '/' in id || id.startsWith(".")) return null
|
||||
val dir = when (app.kind) {
|
||||
@@ -155,6 +158,7 @@ object UserApps {
|
||||
icon.isEmpty() -> if (app.kind == Kind.APPIMAGE) AppImageManager.restoreIcon(dir) else target.delete()
|
||||
else -> withIcon(context, icon) { file -> if (file == null || !saveIcon(file, target)) return context.getString(R.string.user_apps_icon_failed) }
|
||||
}
|
||||
if (fex != null && app.kind != Kind.FLATPAK && !LinuxFex.setMode(dir, fex)) return context.getString(R.string.user_apps_failed)
|
||||
when (app.kind) {
|
||||
Kind.SCRIPT -> writeScriptEntry(context, dir)
|
||||
Kind.APPIMAGE -> AppImageManager.writeMenuEntry(context, dir)
|
||||
@@ -310,14 +314,18 @@ object UserApps {
|
||||
return if (repo.isEmpty() || repo == "." || repo == "..") null else "${m.groupValues[1]}/$repo"
|
||||
}
|
||||
|
||||
/** The ARM64 AppImage among a release's files: named for aarch64/arm64, else for no other CPU. */
|
||||
/**
|
||||
* The AppImage among a release's files: named for aarch64/arm64, else for no other CPU, else the
|
||||
* x86_64 one, which runs through FEX.
|
||||
*/
|
||||
internal fun pickAsset(names: List<String>): String? {
|
||||
val images = names.filter { it.endsWith(".appimage", ignoreCase = true) }
|
||||
return images.firstOrNull { Regex("aarch64|arm64", RegexOption.IGNORE_CASE).containsMatchIn(it) }
|
||||
?: images.firstOrNull { !Regex("x86_64|x86-64|amd64|x64|i[36]86|armhf|armv7", RegexOption.IGNORE_CASE).containsMatchIn(it) }
|
||||
?: images.firstOrNull { Regex("x86_64|x86-64|amd64|x64", RegexOption.IGNORE_CASE).containsMatchIn(it) }
|
||||
}
|
||||
|
||||
/** The newest release of [repo] with an ARM64 AppImage, preferring full releases; else what went wrong. */
|
||||
/** The newest release of [repo] with an AppImage this device runs, preferring full releases; else what went wrong. */
|
||||
private fun latestRelease(context: Context, repo: String): Pair<Release?, String?> {
|
||||
val body = Downloader.downloadString("https://api.github.com/repos/$repo/releases?per_page=15")
|
||||
val releases = body?.let { runCatching { JSONArray(it) }.getOrNull() }
|
||||
|
||||
@@ -14,7 +14,7 @@ import kotlin.math.abs
|
||||
* The client reads `/sys/class/power_supply/BAT<n>/...` - a laptop's or a Deck's naming and files
|
||||
* (`energy_now`, `power_now`, `time_to_empty_now`, a `uevent` of `POWER_SUPPLY_*` lines). Android
|
||||
* names its supply `battery` and lays it out differently, and on many phones an app may not read
|
||||
* it at all, so the client showed no battery. This writes a `BAT0` (and a `BAT1`, the Deck's name)
|
||||
* it at all, so the client showed no battery. This writes one `BAT1` (the Deck's name)
|
||||
* from Android's own battery API - the same on every device - into a directory the session binds
|
||||
* over `/sys/class/power_supply`, refreshed every few seconds while the session runs.
|
||||
*
|
||||
@@ -31,6 +31,8 @@ class BatteryComponent(val dir: File, private val vpowerDir: File) : SessionPart
|
||||
private var thread: Thread? = null
|
||||
|
||||
override fun start() {
|
||||
// Older builds exported the same aggregate battery twice; MangoHud counts both supplies.
|
||||
File(dir, "BAT0").deleteRecursively()
|
||||
running = true
|
||||
write()
|
||||
thread = Thread({
|
||||
@@ -39,7 +41,7 @@ class BatteryComponent(val dir: File, private val vpowerDir: File) : SessionPart
|
||||
if (running) write()
|
||||
}
|
||||
}, "battery-sysfs").apply { isDaemon = true; start() }
|
||||
Log.i(TAG, "battery: BAT0/BAT1 written to ${dir.path} every ${PERIOD_MS / 1000} s")
|
||||
Log.i(TAG, "battery: BAT1 written to ${dir.path} every ${PERIOD_MS / 1000} s")
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
@@ -87,7 +89,7 @@ class BatteryComponent(val dir: File, private val vpowerDir: File) : SessionPart
|
||||
pct <= 15 -> "Low"
|
||||
else -> "Normal"
|
||||
}
|
||||
for (name in listOf("BAT0", "BAT1")) {
|
||||
for (name in listOf("BAT1")) {
|
||||
val bat = File(dir, name).apply { mkdirs() }
|
||||
val attrs = linkedMapOf(
|
||||
"type" to "Battery",
|
||||
|
||||
@@ -4,6 +4,8 @@ import com.droiddeck.launcher.core.Hashes
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.core.Downloader
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import com.droiddeck.launcher.gpu.GpuInfo
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.github.luben.zstd.ZstdInputStream
|
||||
import com.github.luben.zstd.ZstdOutputStream
|
||||
@@ -84,6 +86,7 @@ object ComponentsManager {
|
||||
val proton: Proton, val components: Map<String, Component>, val originals: List<Original>, val inUseByGame: Boolean,
|
||||
/** When a launch last had to put this Proton's chosen files back (epoch seconds), or 0. */
|
||||
val reappliedAt: Long = 0,
|
||||
val sync: EsyncPacks.ToolState? = null,
|
||||
)
|
||||
data class Snapshot(val protons: List<ProtonView>, val packages: List<Package>)
|
||||
data class Catalog(val items: List<CatalogItem>, val fetchedAt: Long)
|
||||
@@ -101,11 +104,25 @@ object ComponentsManager {
|
||||
private fun stateFile(context: Context) = File(dataDir(context), "state.json")
|
||||
private fun catalogFile(context: Context) = File(dataDir(context), "catalog.json")
|
||||
/**
|
||||
* Inside the Linux runtime, read by the Proton launch wrappers (bannerlator-steam-compat's
|
||||
* Inside the Linux runtime, read by the Proton launch wrappers (steam-compatibility's
|
||||
* bl_components) before every game launch: desired.tsv + an unpacked copy of each package in use.
|
||||
*/
|
||||
private const val LAUNCH_DIR = "root/.local/share/bannerlator-components"
|
||||
private fun launchDir(context: Context) = File(root(context), LAUNCH_DIR)
|
||||
private const val LAUNCH_DIR = "root/.local/share/droiddeck-components"
|
||||
private const val LEGACY_LAUNCH_DIR = "root/.local/share/bannerlator-components"
|
||||
private fun launchDir(context: Context): File {
|
||||
migrateLaunchDir(context)
|
||||
return File(root(context), LAUNCH_DIR)
|
||||
}
|
||||
|
||||
fun migrateLaunchDir(context: Context) = synchronized(lock) {
|
||||
val legacy = File(root(context), LEGACY_LAUNCH_DIR)
|
||||
val dir = File(root(context), LAUNCH_DIR)
|
||||
if (!legacy.isDirectory || dir.exists() || !legacy.renameTo(dir)) return@synchronized
|
||||
val desired = File(dir, "desired.tsv")
|
||||
val text = FileUtils.readString(desired) ?: return@synchronized
|
||||
val staged = File(dir, "desired.tsv.staged")
|
||||
if (!FileUtils.writeString(staged, text.replace("/$LEGACY_LAUNCH_DIR/", "/$LAUNCH_DIR/")) || !staged.renameTo(desired)) staged.delete()
|
||||
}
|
||||
|
||||
fun safeName(s: String): String = s.replace(Regex("[^A-Za-z0-9._+-]+"), "_").trim('_').ifEmpty { "unnamed" }
|
||||
|
||||
@@ -202,22 +219,24 @@ object ComponentsManager {
|
||||
* Is anything running on this Proton right now - a game, or the client's own start-up runs,
|
||||
* whose Wine processes can outlive them? Swaps wait while it is. Read from the command lines.
|
||||
*/
|
||||
fun inUse(proton: Proton): Boolean = anyProcess(proton) { needle, text -> text.contains(needle + "proton ") || text.contains(needle + "files/bin") }
|
||||
fun inUse(context: Context, proton: Proton): Boolean = anyProcess(context, proton) { needle, text -> text.contains(needle + "proton ") || text.contains(needle + "files/bin") }
|
||||
|
||||
/**
|
||||
* Is a game running on this Proton? Only a launch runs proton with the verb waitforexitandrun
|
||||
* (Proton's own script stays up until the game exits); the client's start-up runs do not.
|
||||
*/
|
||||
fun gameRunning(proton: Proton): Boolean = anyProcess(proton) { needle, text -> text.contains(needle + "proton waitforexitandrun") }
|
||||
fun gameRunning(context: Context, proton: Proton): Boolean = anyProcess(context, proton) { needle, text -> text.contains(needle + "proton waitforexitandrun") }
|
||||
|
||||
private fun anyProcess(proton: Proton, match: (String, String) -> Boolean): Boolean {
|
||||
val needle = proton.guestPath.trimEnd('/') + "/"
|
||||
private fun anyProcess(context: Context, proton: Proton, match: (String, String) -> Boolean): Boolean {
|
||||
val needles = (listOf(proton.guestPath) + EsyncPacks.distPathsFor(root(context), proton.guestPath))
|
||||
.map { it.trimEnd('/') + "/" }
|
||||
val procs = File("/proc").listFiles() ?: return false
|
||||
for (p in procs) {
|
||||
if (!p.name.all(Char::isDigit)) continue
|
||||
val cmd = runCatching { File(p, "cmdline").readBytes() }.getOrNull() ?: continue
|
||||
if (cmd.isEmpty()) continue
|
||||
if (match(needle, String(cmd).replace('\u0000', ' '))) return true
|
||||
val text = String(cmd).replace('\u0000', ' ')
|
||||
if (needles.any { match(it, text) }) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -290,6 +309,7 @@ object ComponentsManager {
|
||||
}
|
||||
}
|
||||
require(File(staged, "files").isDirectory) { "${wcp.name} has no files/" }
|
||||
if (comp == "fex") FEX_FILES.filter { it.endsWith(".so") && !File(staged, it).isFile }.forEach { File(dir, it).delete() }
|
||||
COMP_DIR[comp]?.let { compDir ->
|
||||
for (arch in PE_ARCHES) {
|
||||
val target = File(dir, "$compDir/$arch")
|
||||
@@ -392,6 +412,7 @@ object ComponentsManager {
|
||||
if (parts.size >= 2) parts[0].toLongOrNull()?.let { reapplied[parts[1]] = maxOf(reapplied[parts[1]] ?: 0, it) }
|
||||
}
|
||||
}
|
||||
val syncPacks = EsyncPacks.status(root(context))
|
||||
val views = protons(context).map { p ->
|
||||
val comps = COMPONENTS.associateWith { comp ->
|
||||
val detected = if (comp == "fex") fexVersion(p.dir) else readVersionFile(File(p.dir, "${COMP_DIR.getValue(comp)}/version"))
|
||||
@@ -414,7 +435,7 @@ object ComponentsManager {
|
||||
q?.optString("label"),
|
||||
)
|
||||
}
|
||||
ProtonView(p, comps, originals(context, p.id), gameRunning(p), reapplied[p.dir.name] ?: 0)
|
||||
ProtonView(p, comps, originals(context, p.id), gameRunning(context, p), reapplied[p.dir.name] ?: 0, syncPacks.tool(p.guestPath))
|
||||
}
|
||||
Snapshot(views, packages(context))
|
||||
}
|
||||
@@ -489,7 +510,7 @@ object ComponentsManager {
|
||||
val wcp = File(packagesDir(context), safeName(file))
|
||||
val info = packageInfo(wcp)
|
||||
val state = loadState(context)
|
||||
if (inUse(p)) {
|
||||
if (inUse(context, p)) {
|
||||
state.sub("queued").sub(p.id).put(info.comp, JSONObject().put("kind", "package").put("file", wcp.name).put("label", info.version))
|
||||
saveState(context, state)
|
||||
return "${LABEL[info.comp]} ${info.version} goes into ${p.name} when the game running on it closes."
|
||||
@@ -515,7 +536,7 @@ object ComponentsManager {
|
||||
val wcp = File(originalsDir(context), "$protonId/${safeName(protonVersion)}/$comp.wcp")
|
||||
check(wcp.isFile) { "That original bundle is not stored" }
|
||||
val state = loadState(context)
|
||||
if (inUse(p)) {
|
||||
if (inUse(context, p)) {
|
||||
state.sub("queued").sub(p.id).put(comp, JSONObject().put("kind", "original").put("protonVersion", protonVersion).put("label", "Original $protonVersion"))
|
||||
saveState(context, state)
|
||||
return "${LABEL[comp]} of ${p.name} goes back to its original when the game running on it closes."
|
||||
@@ -545,7 +566,7 @@ object ComponentsManager {
|
||||
val all = protons(context)
|
||||
for (pid in queued.keys().asSequence().toList()) {
|
||||
val p = all.firstOrNull { it.id == pid } ?: run { queued.remove(pid); null } ?: continue
|
||||
if (inUse(p)) continue
|
||||
if (inUse(context, p)) continue
|
||||
val comps = queued.optJSONObject(pid) ?: continue
|
||||
for (comp in comps.keys().asSequence().toList()) {
|
||||
val q = comps.optJSONObject(comp) ?: continue
|
||||
@@ -640,6 +661,107 @@ object ComponentsManager {
|
||||
return Catalog(items, o.optLong("fetchedAt"))
|
||||
}
|
||||
|
||||
/**
|
||||
* A package Proton does not ship, required on the GPUs listed here. An empty [models] set
|
||||
* means every model in [families]. A row the user already chose in Components is left alone;
|
||||
* a Steam update that puts the Proton's own files back is replaced on the next session.
|
||||
*/
|
||||
internal data class ForcedPackage(
|
||||
val families: Set<GpuInfo.Family>,
|
||||
val comp: String,
|
||||
val file: String,
|
||||
val release: String,
|
||||
val models: Set<Int> = emptySet(),
|
||||
) {
|
||||
fun matches(gpu: GpuInfo) = gpu.family in families && (models.isEmpty() || gpu.model in models)
|
||||
}
|
||||
|
||||
internal val FORCED_PACKAGES = listOf(
|
||||
// DXVK 3 refuses to start unless the device exposes storageBuffer8BitAccess. Turnip on
|
||||
// Adreno 6xx does not. 2.7.1 is the last DXVK 2 package on the Dxvk-Linux tag.
|
||||
ForcedPackage(setOf(GpuInfo.Family.A6XX), "dxvk", "dxvk-2.7.1-linux.wcp", "Dxvk-Linux"),
|
||||
)
|
||||
|
||||
/**
|
||||
* True when this Proton should receive the forced package. No active swap means the Proton's
|
||||
* own copy. A record for a different Proton build means a Steam update put that copy back,
|
||||
* whichever package was recorded. A record for this build is a Components choice and stays.
|
||||
*/
|
||||
internal fun forcedPackageWanted(activeFile: String?, protonVersionMatches: Boolean): Boolean =
|
||||
activeFile.isNullOrEmpty() || !protonVersionMatches
|
||||
|
||||
/** The catalog row for this file and release, or null. */
|
||||
internal fun catalogMatch(items: List<CatalogItem>, file: String, release: String): CatalogItem? =
|
||||
items.firstOrNull { it.file == file && it.release == release }
|
||||
|
||||
/**
|
||||
* Install every [FORCED_PACKAGES] row this GPU matches into each Proton that
|
||||
* [forcedPackageWanted] selects. Returns a line for the log, or null when this GPU or these
|
||||
* Protons need nothing. A failed fetch throws and the session logs it without stopping.
|
||||
*/
|
||||
fun ensureForcedPackages(context: Context): String? = ensureForcedPackages(
|
||||
context, GpuInfo.detect(),
|
||||
{ file, release -> catalogItem(context, file, release) },
|
||||
{ download(context, it) {} },
|
||||
{ id, file -> swap(context, id, file) },
|
||||
)
|
||||
|
||||
internal fun ensureForcedPackages(
|
||||
context: Context,
|
||||
gpu: GpuInfo,
|
||||
lookup: (file: String, release: String) -> CatalogItem?,
|
||||
fetch: (CatalogItem) -> Unit,
|
||||
apply: (protonId: String, file: String) -> String,
|
||||
): String? {
|
||||
val rules = FORCED_PACKAGES.filter { it.matches(gpu) }
|
||||
if (rules.isEmpty()) return null
|
||||
val installed = protons(context)
|
||||
val lines = mutableListOf<String>()
|
||||
for (rule in rules) {
|
||||
val state = loadState(context)
|
||||
val targets = installed.filter { p ->
|
||||
val active = state.optJSONObject("active")?.optJSONObject(p.id)?.optJSONObject(rule.comp)
|
||||
forcedPackageWanted(active?.optString("file"), active?.optString("protonVersion") == p.version)
|
||||
}
|
||||
if (targets.isEmpty()) continue
|
||||
val missing = missingPackage(context, rule, lookup, fetch)
|
||||
if (missing != null) {
|
||||
lines.add(missing)
|
||||
continue
|
||||
}
|
||||
lines.add(targets.joinToString("; ") { apply(it.id, rule.file) })
|
||||
}
|
||||
return lines.joinToString("; ").ifEmpty { null }
|
||||
}
|
||||
|
||||
/** Null when [rule]'s package is stored and readable. Otherwise the log line, after a fetch. */
|
||||
private fun missingPackage(
|
||||
context: Context,
|
||||
rule: ForcedPackage,
|
||||
lookup: (file: String, release: String) -> CatalogItem?,
|
||||
fetch: (CatalogItem) -> Unit,
|
||||
): String? {
|
||||
val wcp = File(packagesDir(context), safeName(rule.file))
|
||||
if (wcp.isFile && runCatching { packageInfo(wcp) }.isSuccess) return null
|
||||
wcp.delete()
|
||||
val item = lookup(rule.file, rule.release)
|
||||
?: return "${rule.file} is not in the Nightlies listing; ${LABEL.getValue(rule.comp)} stays on the Proton's copy"
|
||||
fetch(item)
|
||||
return null
|
||||
}
|
||||
|
||||
/** The cached Nightlies row for this file, fetching the listing when the cache does not have it. */
|
||||
internal fun catalogItem(context: Context, file: String, release: String): CatalogItem? =
|
||||
resolveCatalogItem(file, release, { catalog(context, false).items }, { catalog(context, true).items })
|
||||
|
||||
/** [cached] when it has the row, otherwise [refresh]. [refresh] is not called on a hit. */
|
||||
internal fun resolveCatalogItem(
|
||||
file: String,
|
||||
release: String,
|
||||
cached: () -> List<CatalogItem>,
|
||||
refresh: () -> List<CatalogItem>,
|
||||
): CatalogItem? = catalogMatch(cached(), file, release) ?: catalogMatch(refresh(), file, release)
|
||||
|
||||
/** Downloads a catalog item into storage, verifying the release's sha256 and the package type. */
|
||||
fun download(context: Context, item: CatalogItem, progress: (Int) -> Unit): Package {
|
||||
require(item.url.startsWith("https://github.com/$NIGHTLIES/releases/download/")) { "Downloads come only from the Nightlies releases" }
|
||||
|
||||
@@ -0,0 +1,941 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import com.droiddeck.launcher.core.Hashes
|
||||
import com.github.luben.zstd.ZstdInputStream
|
||||
import org.apache.commons.compress.archivers.tar.TarArchiveEntry
|
||||
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream
|
||||
import org.apache.commons.compress.archivers.tar.TarConstants
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.nio.file.Files
|
||||
import java.security.KeyFactory
|
||||
import java.security.PublicKey
|
||||
import java.security.SecureRandom
|
||||
import java.security.Signature
|
||||
import java.security.spec.X509EncodedKeySpec
|
||||
import java.util.Base64
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
object EsyncPacks {
|
||||
private const val TAG = "DroidDeckSync"
|
||||
const val REPO = "Droid-Deck/DroidDeck-Components"
|
||||
const val INDEX_URL = "https://github.com/$REPO/releases/download/droiddeck-esync-index/index.json"
|
||||
const val SIG_URL = "$INDEX_URL.sig"
|
||||
const val ASSET_PREFIX = "https://github.com/$REPO/releases/download/droiddeck-esync-"
|
||||
const val PUBLIC_KEY = "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEFyqZQAMh3g3nsEGLU+kBGVPBEkXrDv5F3kPtuQ+FV1DkTCGmYUQ1LS9wJtHGt0Ht4wToHGGEp9yzDibYZ4/zhg=="
|
||||
const val NTDLL = "files/lib/wine/aarch64-unix/ntdll.so"
|
||||
const val WINESERVER = "files/bin-arm64/wineserver"
|
||||
const val GUEST_STORE = "/root/.local/share/droiddeck-esync"
|
||||
private const val STORE = "root/.local/share/droiddeck-esync"
|
||||
private const val PACK_JSON = "pack.json"
|
||||
private const val COMPLETE = ".complete"
|
||||
private const val IGNORED = ".ignored"
|
||||
private const val DIST_JSON = ".droiddeck-esync.json"
|
||||
private const val WANTED = "wanted.tsv"
|
||||
private const val TOOLS = "tools.tsv"
|
||||
private const val FLOOR = "floor"
|
||||
private const val PACK_FAILURES = "packFailures"
|
||||
private const val PROGRESS_LABEL = "Fetching droiddeck-esync pack"
|
||||
private const val INDEX_LIMIT = 4L shl 20
|
||||
private const val SIG_LIMIT = 4096L
|
||||
private const val ASSET_LIMIT = 256L shl 20
|
||||
private const val PACK_JSON_LIMIT = 1L shl 20
|
||||
private const val MAX_AGE_MS = 12L * 60 * 60 * 1000
|
||||
private const val WANTED_AGE_MS = 30L * 60 * 1000
|
||||
private const val FAILURE_BACKOFF_MS = 10L * 60 * 1000
|
||||
private const val STALE_MS = 60L * 60 * 1000
|
||||
private const val PRUNE_AGE_MS = 30L * 24 * 60 * 60 * 1000
|
||||
private const val TIMEOUT_MS = 30_000
|
||||
private val ID = Regex("[A-Za-z0-9][A-Za-z0-9._-]{0,127}")
|
||||
private val SHA256 = Regex("[0-9a-f]{64}")
|
||||
private val ASSET_PATH = Regex("[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*")
|
||||
private val TEMP = Regex("\\..+\\.tmp-[0-9a-f]{16}")
|
||||
private val RETIRED = Regex("\\..+\\.del-[0-9a-f]{16}")
|
||||
private val BINARIES = listOf(NTDLL, WINESERVER)
|
||||
private val EXPECTED = setOf(PACK_JSON, NTDLL, WINESERVER)
|
||||
private val REAL_DIRS = setOf(".", "files", "files/bin-arm64", "files/lib", "files/lib/wine", "files/lib/wine/aarch64-unix")
|
||||
private val DIRECTORIES = REAL_DIRS - "." + ""
|
||||
private val STOCK_LINKS = setOf("dist.lock", "files/steampipe_fixups_mtime")
|
||||
|
||||
data class Asset(val url: String, val sha256: String, val size: Long)
|
||||
|
||||
data class Entry(
|
||||
val id: String,
|
||||
val flavor: String,
|
||||
val rev: Int,
|
||||
val version: String,
|
||||
val versionLine: String,
|
||||
val stock: Map<String, String>,
|
||||
val exports: String,
|
||||
val sourceMatch: Boolean,
|
||||
val files: Map<String, String>,
|
||||
val asset: Asset,
|
||||
val revoked: Boolean,
|
||||
)
|
||||
|
||||
data class Index(val schema: Int, val generated: Long, val packs: List<Entry>)
|
||||
|
||||
private class Signed(val bytes: ByteArray, val sig: ByteArray, val index: Index)
|
||||
|
||||
data class Wanted(
|
||||
val ntdll: String,
|
||||
val wineserver: String,
|
||||
val version: String,
|
||||
val exports: String,
|
||||
val toolDir: String,
|
||||
val firstSeen: Long,
|
||||
val inUse: Rank? = null,
|
||||
val epoch: Long = 0L,
|
||||
) {
|
||||
val key: String get() = "$ntdll:$wineserver"
|
||||
}
|
||||
|
||||
data class Rank(val kind: Int, val sameVersion: Int, val rev: Int) : Comparable<Rank> {
|
||||
override fun compareTo(other: Rank): Int = compareValuesBy(this, other, { it.kind }, { it.sameVersion }, { it.rev })
|
||||
}
|
||||
|
||||
data class ToolState(
|
||||
val toolDir: String,
|
||||
val versionLine: String,
|
||||
val ntdll: String,
|
||||
val wineserver: String,
|
||||
val state: String,
|
||||
val packId: String?,
|
||||
)
|
||||
|
||||
data class Status(val installed: Int, val wanted: Int, val tools: List<ToolState>) {
|
||||
fun tool(guestPath: String): ToolState? {
|
||||
val path = guestPath.trimEnd('/')
|
||||
return tools.firstOrNull { it.toolDir.trimEnd('/') == path }
|
||||
}
|
||||
}
|
||||
|
||||
private val fetchLock = Any()
|
||||
private val cacheLock = Any()
|
||||
private val installLock = Any()
|
||||
private val pending = AtomicBoolean(false)
|
||||
private val executor = Executors.newSingleThreadExecutor { runnable ->
|
||||
Thread(runnable, "droiddeck-esync-fetch").apply { isDaemon = true }
|
||||
}
|
||||
private val random = SecureRandom()
|
||||
private val shippedKey: PublicKey by lazy { key(PUBLIC_KEY) }
|
||||
internal var keyOverride: PublicKey? = null
|
||||
private val publicKey: PublicKey get() = keyOverride ?: shippedKey
|
||||
internal var fetcher: (String, Long, OutputStream, ((Long) -> Unit)?) -> Boolean = { url, limit, out, progress ->
|
||||
httpFetch(url, limit, out, progress)
|
||||
}
|
||||
|
||||
fun store(root: File): File = File(root, STORE)
|
||||
|
||||
private fun cacheDir(context: Context) = File(context.filesDir, "droiddeck-esync")
|
||||
|
||||
internal fun key(base64: String): PublicKey =
|
||||
KeyFactory.getInstance("EC").generatePublic(X509EncodedKeySpec(Base64.getDecoder().decode(base64)))
|
||||
|
||||
fun allowedUrl(url: String): Boolean =
|
||||
url.startsWith(ASSET_PREFIX) && ASSET_PATH.matches(url.substring(ASSET_PREFIX.length))
|
||||
|
||||
fun parseIndex(bytes: ByteArray): Index {
|
||||
val root = JSONObject(String(bytes, Charsets.UTF_8))
|
||||
val schema = root.getInt("schema")
|
||||
require(schema == 1) { "sync index schema $schema is not supported" }
|
||||
val generated = root.getLong("generated")
|
||||
val array = root.getJSONArray("packs")
|
||||
val packs = ArrayList<Entry>()
|
||||
val seen = HashSet<String>()
|
||||
for (i in 0 until array.length()) {
|
||||
val entry = try {
|
||||
parseEntry(array.getJSONObject(i))
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "sync index entry $i skipped: ${e.message}")
|
||||
continue
|
||||
}
|
||||
if (seen.add(entry.id)) packs.add(entry)
|
||||
}
|
||||
return Index(schema, generated, packs)
|
||||
}
|
||||
|
||||
private fun parseEntry(o: JSONObject): Entry {
|
||||
val id = o.getString("id")
|
||||
require(ID.matches(id)) { "invalid id $id" }
|
||||
val asset = o.getJSONObject("asset")
|
||||
val url = asset.getString("url")
|
||||
require(allowedUrl(url)) { "$id: asset $url is outside $ASSET_PREFIX" }
|
||||
val sha256 = asset.getString("sha256").lowercase()
|
||||
require(SHA256.matches(sha256)) { "$id: invalid asset sha256" }
|
||||
val size = asset.getLong("size")
|
||||
require(size in 1..ASSET_LIMIT) { "$id: invalid asset size $size" }
|
||||
val flavor = o.getString("flavor")
|
||||
require(flavor.isNotEmpty()) { "$id: no flavor" }
|
||||
return Entry(
|
||||
id, flavor, o.getInt("rev"), o.optString("version"), o.optString("version_line"),
|
||||
hashes(o.getJSONObject("stock")), o.optString("exports").lowercase(), o.opt("source_match") == true,
|
||||
hashes(o.getJSONObject("files")), Asset(url, sha256, size), o.optBoolean("revoked", false),
|
||||
)
|
||||
}
|
||||
|
||||
private fun hashes(o: JSONObject): Map<String, String> = BINARIES.associateWith { rel ->
|
||||
o.getString(rel).lowercase().also { require(SHA256.matches(it)) { "invalid sha256 for $rel" } }
|
||||
}
|
||||
|
||||
fun verify(indexBytes: ByteArray, sigBytes: ByteArray): Boolean = try {
|
||||
verify(indexBytes, sigBytes, publicKey)
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
internal fun verify(indexBytes: ByteArray, sigBytes: ByteArray, key: PublicKey): Boolean = try {
|
||||
Signature.getInstance("SHA256withECDSA").run {
|
||||
initVerify(key)
|
||||
update(indexBytes)
|
||||
verify(sigBytes)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
internal fun accept(indexBytes: ByteArray, sigBytes: ByteArray, cached: Index?, key: PublicKey = publicKey, floor: Long = 0L): Index? {
|
||||
if (!verify(indexBytes, sigBytes, key)) {
|
||||
Log.w(TAG, "sync index signature does not verify")
|
||||
return null
|
||||
}
|
||||
val index = try {
|
||||
parseIndex(indexBytes)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "sync index unreadable: ${e.message}")
|
||||
return null
|
||||
}
|
||||
val minimum = maxOf(cached?.generated ?: Long.MIN_VALUE, floor)
|
||||
if (index.generated < minimum) {
|
||||
Log.w(TAG, "sync index ${index.generated} is older than $minimum, one already accepted; kept the cached one")
|
||||
return null
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
internal fun kind(entry: Entry, row: Wanted): Int = when {
|
||||
entry.stock[NTDLL] == row.ntdll && entry.stock[WINESERVER] == row.wineserver ->
|
||||
if (entry.version != row.version && row.epoch > 0 && epochOf(entry.versionLine) > row.epoch) 0 else 2
|
||||
entry.sourceMatch && entry.version.isNotEmpty() && entry.version == row.version &&
|
||||
entry.exports.isNotEmpty() && entry.exports == row.exports -> 1
|
||||
else -> 0
|
||||
}
|
||||
|
||||
fun matches(entry: Entry, row: Wanted): Boolean = !entry.revoked && kind(entry, row) > 0
|
||||
|
||||
internal fun epochOf(versionLine: String): Long =
|
||||
versionLine.trim().split(Regex("\\s+")).firstOrNull()?.toLongOrNull()?.takeIf { it > 0 } ?: 0L
|
||||
|
||||
internal fun withEpochs(wanted: List<Wanted>, tools: List<ToolState>): List<Wanted> = wanted.map { row ->
|
||||
tools.firstOrNull {
|
||||
it.toolDir == row.toolDir && it.ntdll.trim().lowercase() == row.ntdll && it.wineserver.trim().lowercase() == row.wineserver
|
||||
}?.let { row.copy(epoch = epochOf(it.versionLine)) } ?: row
|
||||
}
|
||||
|
||||
internal fun rank(entry: Entry, row: Wanted): Rank =
|
||||
Rank(kind(entry, row), if (entry.version.isNotEmpty() && entry.version == row.version) 1 else 0, entry.rev)
|
||||
|
||||
fun best(entries: List<Entry>, row: Wanted): Entry? = entries.filter { matches(it, row) }
|
||||
.maxWithOrNull(compareBy<Entry>({ rank(it, row) }, { it.id }))
|
||||
|
||||
internal fun plan(index: Index, wanted: List<Wanted>, installed: Set<String>): List<Entry> =
|
||||
wanted.mapNotNull { row -> best(index.packs, row)?.takeIf { row.inUse == null || rank(it, row) > row.inUse } }
|
||||
.distinctBy { it.id }.filter { it.id !in installed }
|
||||
|
||||
internal fun upgradeRows(store: File, tools: List<ToolState>): List<Wanted> = tools.mapNotNull { tool ->
|
||||
val ntdll = tool.ntdll.trim().lowercase()
|
||||
val wineserver = tool.wineserver.trim().lowercase()
|
||||
if (tool.state != "pack" || !SHA256.matches(ntdll) || !SHA256.matches(wineserver)) return@mapNotNull null
|
||||
val token = tool.versionLine.trim().split(Regex("\\s+")).getOrNull(1).orEmpty()
|
||||
val pack = tool.packId?.takeIf { ID.matches(it) }?.let { installedPack(store, it) }
|
||||
val exports = if (pack != null && pack.opt("source_match") == true && token.isNotEmpty() && pack.opt("version") == token)
|
||||
(pack.opt("exports") as? String).orEmpty() else ""
|
||||
val epoch = epochOf(tool.versionLine)
|
||||
val inUse = pack?.let { packRank(it, ntdll, wineserver, token, exports, epoch) }?.takeIf { it.kind > 0 }
|
||||
Wanted(ntdll, wineserver, token, exports, tool.toolDir, 0L, inUse, epoch)
|
||||
}
|
||||
|
||||
private fun packRank(pack: JSONObject, ntdll: String, wineserver: String, token: String, exports: String, epoch: Long): Rank =
|
||||
Rank(covers(pack, ntdll, wineserver, token, exports, epoch), if (token.isNotEmpty() && pack.opt("version") == token) 1 else 0,
|
||||
pack.optInt("rev"))
|
||||
|
||||
private fun installedPack(store: File, id: String): JSONObject? {
|
||||
val dir = File(store, "packs/$id")
|
||||
if (!File(dir, COMPLETE).isFile) return null
|
||||
return readFile(File(dir, PACK_JSON), PACK_JSON_LIMIT)
|
||||
?.let { runCatching { JSONObject(String(it, Charsets.UTF_8)) }.getOrNull() }
|
||||
}
|
||||
|
||||
fun parseWanted(text: String): List<Wanted> = text.lineSequence().mapNotNull { line ->
|
||||
val fields = line.trimEnd('\r').split('\t')
|
||||
if (fields.size < 5) return@mapNotNull null
|
||||
val ntdll = fields[0].trim().lowercase()
|
||||
val wineserver = fields[1].trim().lowercase()
|
||||
if (!SHA256.matches(ntdll) || !SHA256.matches(wineserver)) return@mapNotNull null
|
||||
Wanted(ntdll, wineserver, field(fields[2]), field(fields[3]).lowercase(), fields[4],
|
||||
fields.getOrNull(5)?.trim()?.toLongOrNull() ?: 0L)
|
||||
}.distinctBy { it.key }.toList()
|
||||
|
||||
private fun field(value: String): String = value.trim().takeIf { it != "-" }.orEmpty()
|
||||
|
||||
fun parseTools(text: String): List<ToolState> = text.lineSequence().mapNotNull { line ->
|
||||
val fields = line.trimEnd('\r').split('\t')
|
||||
if (fields.size < 6 || fields[0].isEmpty()) return@mapNotNull null
|
||||
ToolState(fields[0], fields[1], fields[2], fields[3], fields[4], fields[5].takeIf { it.isNotEmpty() && it != "-" })
|
||||
}.toList()
|
||||
|
||||
fun status(root: File): Status = try {
|
||||
val store = store(root)
|
||||
if (!store.isDirectory) Status(0, 0, emptyList())
|
||||
else {
|
||||
val wanted = readText(File(store, WANTED))?.let { parseWanted(it) }.orEmpty()
|
||||
val tools = readText(File(store, TOOLS))?.let { parseTools(it) }.orEmpty()
|
||||
Status(installedIds(store).size, wanted.size, settled(store, tools, wanted))
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "sync status", t)
|
||||
Status(0, 0, emptyList())
|
||||
}
|
||||
|
||||
internal fun settled(store: File, tools: List<ToolState>, wanted: List<Wanted>): List<ToolState> {
|
||||
if (tools.none { it.state == "wanted" }) return tools
|
||||
val packs = installedIds(store).sorted()
|
||||
.filter { !File(store, "packs/$it/$IGNORED").exists() }
|
||||
.mapNotNull { id -> installedPack(store, id)?.let { id to it } }
|
||||
val rows = wanted.associateBy { it.key }
|
||||
return tools.map { tool ->
|
||||
if (tool.state != "wanted") return@map tool
|
||||
val ntdll = tool.ntdll.trim().lowercase()
|
||||
val wineserver = tool.wineserver.trim().lowercase()
|
||||
val token = tool.versionLine.trim().split(Regex("\\s+")).getOrNull(1).orEmpty()
|
||||
val exports = rows["$ntdll:$wineserver"]?.exports.orEmpty()
|
||||
val epoch = epochOf(tool.versionLine)
|
||||
val match = packs.map { (id, pack) -> Triple(id, pack, packRank(pack, ntdll, wineserver, token, exports, epoch)) }
|
||||
.filter { it.third.kind > 0 }
|
||||
.maxWithOrNull(compareBy({ it.third }, { it.first }))
|
||||
if (match == null) tool else tool.copy(state = "pack", packId = match.first)
|
||||
}
|
||||
}
|
||||
|
||||
private fun covers(pack: JSONObject, ntdll: String, wineserver: String, token: String, exports: String, epoch: Long): Int {
|
||||
val stock = pack.optJSONObject("stock")
|
||||
val version = pack.opt("version") as? String
|
||||
if (stock != null && stock.optString(NTDLL) == ntdll && stock.optString(WINESERVER) == wineserver) {
|
||||
return if (version != token && epoch > 0 && epochOf(pack.optString("version_line")) > epoch) 0 else 2
|
||||
}
|
||||
val packExports = pack.opt("exports") as? String
|
||||
return if (pack.opt("source_match") == true && token.isNotEmpty() && version == token &&
|
||||
exports.isNotEmpty() && packExports == exports) 1 else 0
|
||||
}
|
||||
|
||||
fun distPathsFor(root: File, stockGuestPath: String): List<String> = try {
|
||||
val wanted = stockGuestPath.trimEnd('/')
|
||||
if (wanted.isEmpty()) emptyList()
|
||||
else File(store(root), "dist").listFiles().orEmpty()
|
||||
.filter { !it.name.startsWith(".") && it.isDirectory }
|
||||
.mapNotNull { dir ->
|
||||
val record = readText(File(dir, DIST_JSON))?.let { runCatching { JSONObject(it) }.getOrNull() }
|
||||
?: return@mapNotNull null
|
||||
val stock = record.optString("stock").trimEnd('/')
|
||||
val tool = record.optString("tool").trimEnd('/')
|
||||
if (stock == wanted || tool == wanted) "$GUEST_STORE/dist/${dir.name}" else null
|
||||
}
|
||||
.sorted()
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "sync dists", t)
|
||||
emptyList()
|
||||
}
|
||||
|
||||
fun stageBundled(context: Context, root: File) {
|
||||
try {
|
||||
val store = store(root)
|
||||
if (!store.isDirectory && !store.mkdirs()) {
|
||||
Log.w(TAG, "could not create $store")
|
||||
return
|
||||
}
|
||||
val bundled = bundledIndex(context)
|
||||
val trusted = if (bundled == null) cachedIndex(context)
|
||||
else storeIndex(context, bundled.bytes, bundled.sig, bundled.index, newerOnly = true)
|
||||
if (trusted != null) removeRevoked(store, trusted)
|
||||
if (bundled == null) return
|
||||
if (trusted == null) {
|
||||
Log.w(TAG, "the bundled sync index is older than one already accepted; bundled packs skipped")
|
||||
return
|
||||
}
|
||||
val revoked = (bundled.index.packs + trusted.packs).filter { it.revoked }.map { it.id }.toSet()
|
||||
val shipped = context.assets.list("droiddeck-esync/packs")?.toSet().orEmpty()
|
||||
val missing = bundled.index.packs.filter {
|
||||
it.id !in revoked && "${it.id}.tzst" in shipped && !File(store, "packs/${it.id}/$COMPLETE").isFile
|
||||
}
|
||||
if (missing.isEmpty()) return
|
||||
sweep(context, store)
|
||||
for (entry in missing) installBundled(context, store, entry)
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "bundled sync packs", t)
|
||||
}
|
||||
}
|
||||
|
||||
private fun bundledIndex(context: Context): Signed? {
|
||||
val bundle = context.assets.list("droiddeck-esync")?.toSet().orEmpty()
|
||||
if ("index.json" !in bundle || "index.json.sig" !in bundle) return null
|
||||
val bytes = context.assets.open("droiddeck-esync/index.json").use { readBounded(it, INDEX_LIMIT) }
|
||||
val sig = context.assets.open("droiddeck-esync/index.json.sig").use { readBounded(it, SIG_LIMIT) }
|
||||
if (!verify(bytes, sig)) {
|
||||
Log.w(TAG, "the bundled sync index does not verify; bundled packs skipped")
|
||||
return null
|
||||
}
|
||||
return Signed(bytes, sig, parseIndex(bytes))
|
||||
}
|
||||
|
||||
private fun installBundled(context: Context, store: File, entry: Entry) {
|
||||
val copy = try {
|
||||
tempFile(context, entry.id)
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "bundled sync pack ${entry.id}", e)
|
||||
return
|
||||
}
|
||||
try {
|
||||
context.assets.open("droiddeck-esync/packs/${entry.id}.tzst").use { input ->
|
||||
FileOutputStream(copy).use { output -> copyBounded(input, output, entry.asset.size) }
|
||||
}
|
||||
if (install(store, entry, copy)) {
|
||||
Log.i(TAG, "sync pack ${entry.id} installed from the app")
|
||||
SessionEvents.record("sync.pack", mapOf("id" to entry.id, "source" to "bundled", "result" to "installed"))
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "bundled sync pack ${entry.id}", t)
|
||||
SessionEvents.record("sync.pack", mapOf("id" to entry.id, "source" to "bundled", "result" to "failed", "reason" to t.message))
|
||||
} finally {
|
||||
copy.delete()
|
||||
}
|
||||
}
|
||||
|
||||
fun fetchInBackground(context: Context, root: File) {
|
||||
val app = context.applicationContext ?: context
|
||||
if (!pending.compareAndSet(false, true)) return
|
||||
try {
|
||||
executor.execute {
|
||||
pending.set(false)
|
||||
fetchWanted(app, root)
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
pending.set(false)
|
||||
Log.w(TAG, "could not schedule a sync fetch", t)
|
||||
}
|
||||
}
|
||||
|
||||
fun enabled(context: Context): Boolean = SessionPrefs.syncFallback(context) || try {
|
||||
GameEnvironmentStore.read(context).let { config ->
|
||||
(listOf(config.shared) + config.games.values).any { it["BL_SYNC_FALLBACK"] == "1" }
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
false
|
||||
}
|
||||
|
||||
fun fetchWanted(context: Context, root: File, onProgress: ((String, Int) -> Unit)? = null): Int = synchronized(fetchLock) {
|
||||
try {
|
||||
val store = store(root)
|
||||
if (!store.isDirectory) return 0
|
||||
cachedIndex(context)?.let { removeRevoked(store, it) }
|
||||
if (!enabled(context)) return 0
|
||||
sweep(context, store)
|
||||
val toolsText = readText(File(store, TOOLS))
|
||||
val tools = toolsText?.let { parseTools(it) }.orEmpty()
|
||||
val wanted = withEpochs(readText(File(store, WANTED))?.let { parseWanted(it) }.orEmpty(), tools)
|
||||
val index = refreshIndex(context, wanted) ?: return 0
|
||||
removeRevoked(store, index)
|
||||
val rows = wanted + upgradeRows(store, tools)
|
||||
if (toolsText != null) prune(store, keep(store, index, rows, tools, bundledIds(context)), System.currentTimeMillis())
|
||||
val planned = plan(index, rows, installedIds(store))
|
||||
val state = loadState(context)
|
||||
val previous = state.optJSONObject(PACK_FAILURES)
|
||||
val failures = pruneFailures(previous, index)
|
||||
val missing = planned.filter { !failedRecently(failures, it, System.currentTimeMillis()) }
|
||||
var added = 0
|
||||
for (entry in missing) {
|
||||
if (download(context, store, entry, onProgress)) {
|
||||
added++
|
||||
failures.remove(entry.id)
|
||||
} else {
|
||||
failures.put(entry.id, failure(failures, entry, System.currentTimeMillis()))
|
||||
}
|
||||
}
|
||||
val kept = failures.takeIf { it.length() > 0 }
|
||||
if (kept?.toString() != previous?.toString()) {
|
||||
if (kept != null) state.put(PACK_FAILURES, kept) else state.remove(PACK_FAILURES)
|
||||
saveState(context, state)
|
||||
}
|
||||
if (planned.size > missing.size) Log.i(TAG, "sync: ${planned.size - missing.size} pack(s) wait after failed downloads")
|
||||
if (missing.isNotEmpty()) Log.i(TAG, "sync: $added of ${missing.size} wanted pack(s) installed")
|
||||
added
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "fetching sync packs", t)
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshIndex(context: Context, wanted: List<Wanted> = emptyList()): Index? = synchronized(fetchLock) {
|
||||
val cached = cachedIndex(context)
|
||||
val state = loadState(context)
|
||||
val now = System.currentTimeMillis()
|
||||
if (!due(cached, state, wanted, now)) return cached
|
||||
val bytes = fetchBytes(INDEX_URL, INDEX_LIMIT)
|
||||
val sig = if (bytes != null) fetchBytes(SIG_URL, SIG_LIMIT) else null
|
||||
val fresh = if (bytes != null && sig != null) accept(bytes, sig, cached, floor = floor(context)) else null
|
||||
if (bytes == null || sig == null || fresh == null) {
|
||||
state.put("failedAt", now)
|
||||
saveState(context, state)
|
||||
return cached
|
||||
}
|
||||
val trusted = storeIndex(context, bytes, sig, fresh, newerOnly = false) ?: return cached
|
||||
state.remove("failedAt")
|
||||
state.put("checkedAt", now)
|
||||
state.put("misses", misses(state.optJSONObject("misses"), trusted, wanted, now))
|
||||
saveState(context, state)
|
||||
Log.i(TAG, "sync index ${trusted.generated}: ${trusted.packs.size} pack(s)")
|
||||
trusted
|
||||
}
|
||||
|
||||
internal fun due(cached: Index?, state: JSONObject, wanted: List<Wanted>, now: Long): Boolean {
|
||||
val failed = state.optLong("failedAt", 0L)
|
||||
if (failed > 0 && now - failed in 0 until FAILURE_BACKOFF_MS) return false
|
||||
val checked = state.optLong("checkedAt", 0L)
|
||||
if (cached == null || checked <= 0 || now < checked || now - checked >= MAX_AGE_MS) return true
|
||||
val misses = state.optJSONObject("misses")
|
||||
return wanted.any { row ->
|
||||
if (cached.packs.any { matches(it, row) }) return@any false
|
||||
val miss = misses?.optJSONObject(row.key)
|
||||
val count = miss?.optInt("n", 0) ?: 0
|
||||
if (count <= 0) now - checked >= WANTED_AGE_MS
|
||||
else now - (miss?.optLong("at", 0L) ?: 0L) >= backoff(count)
|
||||
}
|
||||
}
|
||||
|
||||
private fun backoff(misses: Int): Long = minOf(WANTED_AGE_MS shl minOf(misses - 1, 5), MAX_AGE_MS)
|
||||
|
||||
private fun packBackoff(failures: Int): Long = minOf(FAILURE_BACKOFF_MS shl minOf(failures - 1, 7), MAX_AGE_MS)
|
||||
|
||||
internal fun pruneFailures(previous: JSONObject?, index: Index): JSONObject {
|
||||
val out = JSONObject()
|
||||
if (previous == null) return out
|
||||
val assets = index.packs.associate { it.id to it.asset.sha256 }
|
||||
for (id in previous.keys()) {
|
||||
val record = previous.optJSONObject(id) ?: continue
|
||||
if (assets[id] == record.optString("sha")) out.put(id, record)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
internal fun failedRecently(failures: JSONObject?, entry: Entry, now: Long): Boolean {
|
||||
val record = failures?.optJSONObject(entry.id) ?: return false
|
||||
if (record.optString("sha") != entry.asset.sha256) return false
|
||||
val count = record.optInt("n", 0)
|
||||
return count > 0 && now - record.optLong("at", 0L) in 0 until packBackoff(count)
|
||||
}
|
||||
|
||||
internal fun failure(failures: JSONObject, entry: Entry, now: Long): JSONObject {
|
||||
val record = failures.optJSONObject(entry.id)?.takeIf { it.optString("sha") == entry.asset.sha256 }
|
||||
return JSONObject().put("sha", entry.asset.sha256).put("n", (record?.optInt("n", 0) ?: 0) + 1).put("at", now)
|
||||
}
|
||||
|
||||
private fun misses(previous: JSONObject?, index: Index, wanted: List<Wanted>, now: Long): JSONObject {
|
||||
val out = JSONObject()
|
||||
for (row in wanted) {
|
||||
if (index.packs.any { matches(it, row) }) continue
|
||||
val count = previous?.optJSONObject(row.key)?.optInt("n", 0) ?: 0
|
||||
out.put(row.key, JSONObject().put("n", count + 1).put("at", now))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun loadState(context: Context): JSONObject = synchronized(cacheLock) {
|
||||
readFile(File(cacheDir(context), "state.json"), PACK_JSON_LIMIT)
|
||||
?.let { runCatching { JSONObject(String(it, Charsets.UTF_8)) }.getOrNull() } ?: JSONObject()
|
||||
}
|
||||
|
||||
private fun saveState(context: Context, state: JSONObject) = synchronized(cacheLock) {
|
||||
try {
|
||||
writeAtomic(File(cacheDir(context).apply { mkdirs() }, "state.json"), state.toString().toByteArray(Charsets.UTF_8))
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "sync state", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun cachedIndex(context: Context): Index? = synchronized(cacheLock) {
|
||||
val dir = cacheDir(context)
|
||||
val bytes = readFile(File(dir, "index.json"), INDEX_LIMIT) ?: return null
|
||||
val sig = readFile(File(dir, "index.json.sig"), SIG_LIMIT) ?: return null
|
||||
if (!verify(bytes, sig)) {
|
||||
Log.w(TAG, "the cached sync index does not verify; ignoring it")
|
||||
return null
|
||||
}
|
||||
runCatching { parseIndex(bytes) }.getOrNull()
|
||||
}
|
||||
|
||||
private fun floor(context: Context): Long = synchronized(cacheLock) {
|
||||
readFile(File(cacheDir(context), FLOOR), 64)?.toString(Charsets.UTF_8)?.trim()?.toLongOrNull() ?: 0L
|
||||
}
|
||||
|
||||
private fun storeIndex(context: Context, bytes: ByteArray, sig: ByteArray, index: Index, newerOnly: Boolean): Index? = synchronized(cacheLock) {
|
||||
val cached = cachedIndex(context)
|
||||
val floor = floor(context)
|
||||
if (index.generated < floor) return cached
|
||||
if (cached != null && (cached.generated > index.generated || newerOnly && cached.generated >= index.generated)) return cached
|
||||
try {
|
||||
val dir = cacheDir(context).apply { mkdirs() }
|
||||
if (index.generated > floor) writeAtomic(File(dir, FLOOR), index.generated.toString().toByteArray(Charsets.UTF_8))
|
||||
writeAtomic(File(dir, "index.json.sig"), sig)
|
||||
writeAtomic(File(dir, "index.json"), bytes)
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "could not cache the sync index", e)
|
||||
}
|
||||
index
|
||||
}
|
||||
|
||||
private fun download(context: Context, store: File, entry: Entry, onProgress: ((String, Int) -> Unit)?): Boolean {
|
||||
if (!allowedUrl(entry.asset.url)) {
|
||||
Log.w(TAG, "sync pack ${entry.id}: ${entry.asset.url} is outside $ASSET_PREFIX")
|
||||
return false
|
||||
}
|
||||
val part = try {
|
||||
tempFile(context, entry.id)
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "sync pack ${entry.id}", e)
|
||||
return false
|
||||
}
|
||||
try {
|
||||
onProgress?.invoke(PROGRESS_LABEL, -1)
|
||||
var reported = -1
|
||||
val fetched = FileOutputStream(part).use { output ->
|
||||
fetch(entry.asset.url, entry.asset.size, output) { done ->
|
||||
val percent = (done * 100 / entry.asset.size).toInt().coerceIn(0, 100)
|
||||
if (percent != reported) {
|
||||
reported = percent
|
||||
onProgress?.invoke(PROGRESS_LABEL, percent)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!fetched) {
|
||||
SessionEvents.record("sync.pack", mapOf("id" to entry.id, "source" to "download", "result" to "failed", "reason" to "download"))
|
||||
return false
|
||||
}
|
||||
if (install(store, entry, part)) {
|
||||
Log.i(TAG, "sync pack ${entry.id} downloaded and installed")
|
||||
SessionEvents.record("sync.pack", mapOf("id" to entry.id, "source" to "download", "result" to "installed"))
|
||||
}
|
||||
return true
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "sync pack ${entry.id}", t)
|
||||
SessionEvents.record("sync.pack", mapOf("id" to entry.id, "source" to "download", "result" to "failed", "reason" to t.message))
|
||||
return false
|
||||
} finally {
|
||||
part.delete()
|
||||
}
|
||||
}
|
||||
|
||||
internal fun install(store: File, entry: Entry, archive: File): Boolean {
|
||||
require(archive.length() == entry.asset.size) { "${entry.id}: ${archive.length()} bytes, the index says ${entry.asset.size}" }
|
||||
require(Hashes.sha256(archive) == entry.asset.sha256) { "${entry.id}: the archive does not match the index checksum" }
|
||||
return TarArchiveInputStream(ZstdInputStream(BufferedInputStream(FileInputStream(archive), 1 shl 16))).use {
|
||||
place(store, entry, it)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun place(store: File, entry: Entry, tar: TarArchiveInputStream): Boolean {
|
||||
val packs = File(store, "packs")
|
||||
if (!packs.isDirectory && !packs.mkdirs()) throw IOException("could not create $packs")
|
||||
val temp = File(packs, ".${entry.id}.tmp-${token()}")
|
||||
try {
|
||||
if (!temp.mkdir()) throw IOException("could not create $temp")
|
||||
unpack(tar, temp)
|
||||
val manifest = File(temp, PACK_JSON)
|
||||
checkPack(JSONObject(manifest.readText()), entry)
|
||||
for (rel in BINARIES) {
|
||||
val file = File(temp, rel)
|
||||
require(Hashes.sha256(file) == entry.files[rel]) { "${entry.id}: $rel does not match the index" }
|
||||
if (!file.setReadable(true, false) || !file.setExecutable(true, false)) throw IOException("could not mark $rel executable")
|
||||
}
|
||||
manifest.setReadable(true, false)
|
||||
if (!File(temp, COMPLETE).createNewFile()) throw IOException("could not mark ${entry.id} complete")
|
||||
return commit(packs, temp, entry.id)
|
||||
} finally {
|
||||
if (temp.exists()) FileUtils.delete(temp)
|
||||
}
|
||||
}
|
||||
|
||||
private fun commit(packs: File, temp: File, id: String): Boolean = synchronized(installLock) {
|
||||
val target = File(packs, id)
|
||||
if (File(target, COMPLETE).isFile) return false
|
||||
if (target.exists() || isLink(target)) retire(packs, target)
|
||||
if (!temp.renameTo(target)) throw IOException("could not move sync pack $id into place")
|
||||
true
|
||||
}
|
||||
|
||||
internal fun checkPack(pack: JSONObject, entry: Entry) {
|
||||
require(pack.opt("format") == 1) { "${entry.id}: pack.json format ${pack.opt("format")}" }
|
||||
require(pack.opt("id") == entry.id) { "pack.json names ${pack.opt("id")}, the index ${entry.id}" }
|
||||
require(pack.opt("flavor") == entry.flavor) { "${entry.id}: flavor differs from the index" }
|
||||
require(pack.opt("rev") == entry.rev) { "${entry.id}: rev differs from the index" }
|
||||
require(pack.opt("version") == entry.version) { "${entry.id}: version differs from the index" }
|
||||
require(!pack.has("version_line") || pack.opt("version_line") is String) { "${entry.id}: version_line is not a string" }
|
||||
require(strictHashes(pack.opt("stock")) == entry.stock) { "${entry.id}: stock hashes differ from the index" }
|
||||
require(pack.opt("exports") == entry.exports) { "${entry.id}: exports differ from the index" }
|
||||
require(pack.opt("source_match") == entry.sourceMatch) { "${entry.id}: source_match differs from the index" }
|
||||
require(strictHashes(pack.opt("files")) == entry.files) { "${entry.id}: file hashes differ from the index" }
|
||||
require(!pack.has("copy") || validCopy(pack.opt("copy"))) { "${entry.id}: invalid copy list" }
|
||||
require(!truthy(pack.opt("revoked")) && !truthy(pack.opt("ignored"))) { "${entry.id}: pack.json is revoked" }
|
||||
}
|
||||
|
||||
private fun strictHashes(value: Any?): Map<String, String>? {
|
||||
if (value !is JSONObject) return null
|
||||
return BINARIES.associateWith { rel -> (value.opt(rel) as? String)?.takeIf { SHA256.matches(it) } ?: return null }
|
||||
}
|
||||
|
||||
private fun validCopy(value: Any?): Boolean =
|
||||
value is JSONArray && (0 until value.length()).all { copyPath(value.opt(it)) }
|
||||
|
||||
internal fun copyPath(value: Any?): Boolean {
|
||||
if (value !is String || value.isEmpty() || value.startsWith("/") || '\u0000' in value) return false
|
||||
if (value.split('/').any { it.isEmpty() || it == "." || it == ".." }) return false
|
||||
return value.substringBeforeLast('/', ".") in REAL_DIRS && value !in BINARIES && value !in REAL_DIRS &&
|
||||
value !in STOCK_LINKS && value != DIST_JSON
|
||||
}
|
||||
|
||||
private fun truthy(value: Any?): Boolean = when (value) {
|
||||
null, JSONObject.NULL -> false
|
||||
is Boolean -> value
|
||||
is Number -> value.toDouble() != 0.0
|
||||
is String -> value.isNotEmpty()
|
||||
is JSONArray -> value.length() > 0
|
||||
is JSONObject -> value.length() > 0
|
||||
else -> true
|
||||
}
|
||||
|
||||
internal fun unpack(tar: TarArchiveInputStream, dest: File) {
|
||||
val seen = HashSet<String>()
|
||||
while (true) {
|
||||
val entry = tar.nextEntry ?: break
|
||||
val rel = entryPath(entry) ?: continue
|
||||
require(seen.add(rel)) { "$rel appears twice" }
|
||||
val limit = if (rel == PACK_JSON) PACK_JSON_LIMIT else ASSET_LIMIT
|
||||
require(entry.size in 0..limit) { "$rel is too large" }
|
||||
val out = File(dest, rel)
|
||||
out.parentFile?.let { if (!it.isDirectory && !it.mkdirs()) throw IOException("could not create $it") }
|
||||
FileOutputStream(out).use {
|
||||
copyBounded(tar, it, limit)
|
||||
it.fd.sync()
|
||||
}
|
||||
}
|
||||
require(seen == EXPECTED) { "the pack lacks ${(EXPECTED - seen).joinToString()}" }
|
||||
}
|
||||
|
||||
internal fun entryPath(entry: TarArchiveEntry): String? {
|
||||
val name = entry.name
|
||||
require(!name.startsWith("/")) { "absolute path $name" }
|
||||
val parts = name.split('/').filter { it.isNotEmpty() }.let { if (it.firstOrNull() == ".") it.drop(1) else it }
|
||||
require(parts.none { it == "." || it == ".." }) { "unsafe path $name" }
|
||||
require(!entry.isSymbolicLink && !entry.isLink) { "link entry $name" }
|
||||
require(!entry.isCharacterDevice && !entry.isBlockDevice && !entry.isFIFO) { "device entry $name" }
|
||||
val rel = parts.joinToString("/")
|
||||
if (entry.isDirectory) {
|
||||
require(rel in DIRECTORIES) { "unexpected directory $name" }
|
||||
return null
|
||||
}
|
||||
require(entry.linkFlag == TarConstants.LF_NORMAL || entry.linkFlag == TarConstants.LF_OLDNORM) { "unsupported entry type for $name" }
|
||||
require(!entry.isSparse) { "sparse entry $name" }
|
||||
require(rel in EXPECTED) { "unexpected entry $name" }
|
||||
return rel
|
||||
}
|
||||
|
||||
internal fun installedIds(store: File): Set<String> = File(store, "packs").listFiles().orEmpty()
|
||||
.filter { ID.matches(it.name) && File(it, COMPLETE).isFile }
|
||||
.map { it.name }
|
||||
.toSet()
|
||||
|
||||
internal fun removeRevoked(store: File, index: Index): List<String> {
|
||||
val revoked = index.packs.filter { it.revoked }.map { it.id }.toSet()
|
||||
if (revoked.isEmpty()) return emptyList()
|
||||
val packs = File(store, "packs")
|
||||
val removed = ArrayList<String>()
|
||||
synchronized(installLock) {
|
||||
for (id in revoked) {
|
||||
val dir = File(packs, id)
|
||||
if (!dir.exists() && !isLink(dir)) continue
|
||||
retire(packs, dir)
|
||||
removed.add(id)
|
||||
}
|
||||
}
|
||||
for (id in removed) {
|
||||
Log.i(TAG, "sync pack $id was revoked; removed")
|
||||
SessionEvents.record("sync.pack", mapOf("id" to id, "result" to "revoked"))
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
private fun bundledIds(context: Context): Set<String> =
|
||||
context.assets.list("droiddeck-esync/packs").orEmpty().filter { it.endsWith(".tzst") }.map { it.removeSuffix(".tzst") }.toSet()
|
||||
|
||||
internal fun keep(store: File, index: Index, rows: List<Wanted>, tools: List<ToolState>, bundled: Set<String>): Set<String> {
|
||||
val ids = HashSet(bundled)
|
||||
tools.mapNotNullTo(ids) { it.packId }
|
||||
rows.mapNotNullTo(ids) { best(index.packs, it)?.id }
|
||||
File(store, "dist").listFiles().orEmpty().filter { it.isDirectory }.forEach { dir ->
|
||||
val record = readText(File(dir, DIST_JSON))?.let { runCatching { JSONObject(it) }.getOrNull() }
|
||||
(record?.opt("pack") as? String)?.let { ids.add(it) }
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
internal fun prune(store: File, keep: Set<String>, now: Long): List<String> {
|
||||
val packs = File(store, "packs")
|
||||
val removed = ArrayList<String>()
|
||||
synchronized(installLock) {
|
||||
for (id in installedIds(store).sorted()) {
|
||||
val dir = File(packs, id)
|
||||
if (id in keep || now - dir.lastModified() < PRUNE_AGE_MS) continue
|
||||
retire(packs, dir)
|
||||
removed.add(id)
|
||||
}
|
||||
}
|
||||
for (id in removed) {
|
||||
Log.i(TAG, "sync pack $id is no longer used; removed")
|
||||
SessionEvents.record("sync.pack", mapOf("id" to id, "result" to "pruned"))
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
private fun retire(packs: File, dir: File) {
|
||||
val gone = File(packs, ".${dir.name}.del-${token()}")
|
||||
if (dir.renameTo(gone)) FileUtils.delete(gone) else FileUtils.delete(dir)
|
||||
}
|
||||
|
||||
private fun sweep(context: Context, store: File) {
|
||||
val now = System.currentTimeMillis()
|
||||
synchronized(installLock) {
|
||||
File(store, "packs").listFiles()?.forEach { file ->
|
||||
if (RETIRED.matches(file.name) || TEMP.matches(file.name) && now - file.lastModified() > STALE_MS) FileUtils.delete(file)
|
||||
}
|
||||
}
|
||||
File(cacheDir(context), "tmp").listFiles()?.forEach { file ->
|
||||
if (now - file.lastModified() > STALE_MS) FileUtils.delete(file)
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchBytes(url: String, limit: Long): ByteArray? {
|
||||
val out = ByteArrayOutputStream()
|
||||
return if (fetch(url, limit, out, null)) out.toByteArray() else null
|
||||
}
|
||||
|
||||
private fun fetch(url: String, limit: Long, out: OutputStream, progress: ((Long) -> Unit)?): Boolean =
|
||||
fetcher(url, limit, out, progress)
|
||||
|
||||
private fun httpFetch(url: String, limit: Long, out: OutputStream, progress: ((Long) -> Unit)?): Boolean {
|
||||
var connection: HttpURLConnection? = null
|
||||
try {
|
||||
connection = (URL(url).openConnection() as HttpURLConnection).apply {
|
||||
connectTimeout = TIMEOUT_MS
|
||||
readTimeout = TIMEOUT_MS
|
||||
instanceFollowRedirects = true
|
||||
setRequestProperty("User-Agent", "DroidDeck-Android")
|
||||
}
|
||||
val code = connection.responseCode
|
||||
if (code / 100 != 2) {
|
||||
Log.w(TAG, "$url -> HTTP $code")
|
||||
return false
|
||||
}
|
||||
if (connection.contentLengthLong > limit) {
|
||||
Log.w(TAG, "$url is ${connection.contentLengthLong} bytes, more than $limit")
|
||||
return false
|
||||
}
|
||||
connection.inputStream.use { input ->
|
||||
val buffer = ByteArray(1 shl 16)
|
||||
var total = 0L
|
||||
while (true) {
|
||||
val n = input.read(buffer)
|
||||
if (n < 0) break
|
||||
total += n
|
||||
if (total > limit) {
|
||||
Log.w(TAG, "$url sent more than $limit bytes")
|
||||
return false
|
||||
}
|
||||
out.write(buffer, 0, n)
|
||||
progress?.invoke(total)
|
||||
}
|
||||
}
|
||||
return true
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "GET $url: ${e.message}")
|
||||
return false
|
||||
} finally {
|
||||
connection?.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyBounded(input: InputStream, out: OutputStream, limit: Long): Long {
|
||||
val buffer = ByteArray(1 shl 16)
|
||||
var total = 0L
|
||||
while (true) {
|
||||
val n = input.read(buffer)
|
||||
if (n < 0) return total
|
||||
total += n
|
||||
if (total > limit) throw IOException("more than $limit bytes")
|
||||
out.write(buffer, 0, n)
|
||||
}
|
||||
}
|
||||
|
||||
private fun readBounded(input: InputStream, limit: Long): ByteArray {
|
||||
val out = ByteArrayOutputStream()
|
||||
copyBounded(input, out, limit)
|
||||
return out.toByteArray()
|
||||
}
|
||||
|
||||
private fun readFile(file: File, limit: Long): ByteArray? = try {
|
||||
if (!file.isFile || file.length() > limit) null else file.readBytes()
|
||||
} catch (e: IOException) {
|
||||
null
|
||||
}
|
||||
|
||||
private fun readText(file: File): String? = readFile(file, INDEX_LIMIT)?.toString(Charsets.UTF_8)
|
||||
|
||||
private fun writeAtomic(file: File, bytes: ByteArray) {
|
||||
val temp = File(file.parentFile, ".${file.name}.tmp-${token()}")
|
||||
try {
|
||||
temp.writeBytes(bytes)
|
||||
if (!temp.renameTo(file)) throw IOException("could not replace $file")
|
||||
} finally {
|
||||
if (temp.exists()) temp.delete()
|
||||
}
|
||||
}
|
||||
|
||||
private fun tempFile(context: Context, id: String): File {
|
||||
val dir = File(cacheDir(context), "tmp")
|
||||
if (!dir.isDirectory && !dir.mkdirs()) throw IOException("could not create $dir")
|
||||
return File(dir, "$id.${token()}.tzst")
|
||||
}
|
||||
|
||||
private fun isLink(file: File): Boolean = try {
|
||||
Files.isSymbolicLink(file.toPath())
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
private fun token(): String {
|
||||
val bytes = ByteArray(8)
|
||||
synchronized(random) { random.nextBytes(bytes) }
|
||||
return bytes.joinToString("") { "%02x".format(it.toInt() and 0xff) }
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.droiddeck.launcher.session
|
||||
import android.content.Context
|
||||
import android.util.AtomicFile
|
||||
import com.droiddeck.launcher.core.GameEnvironment
|
||||
import com.droiddeck.launcher.core.TextureFiltering
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
@@ -10,6 +11,8 @@ import java.io.File
|
||||
object GameEnvironmentStore {
|
||||
private const val SETTINGS = "game-environment.json"
|
||||
private const val GUEST_FILE = "root/.config/droiddeck/game-environment.json"
|
||||
/** The published file's extra DXVK options (droiddeck-game-env reads it; the app's own file has none). */
|
||||
const val DXVK_CONFIG = "dxvkConfig"
|
||||
|
||||
@Synchronized
|
||||
fun read(context: Context): GameEnvironment.Config {
|
||||
@@ -56,8 +59,16 @@ object GameEnvironmentStore {
|
||||
|
||||
@Synchronized
|
||||
fun publish(context: Context, config: GameEnvironment.Config = read(context)) {
|
||||
val resolved = config.copy(shared = GameEnvironment.defaults(SessionPrefs.fexPreset(context)) + config.shared)
|
||||
write(File(LinuxRuntime.rootDir(context), GUEST_FILE), encode(resolved).toString())
|
||||
val resolved = config.copy(shared = GameEnvironment.defaults(SessionPrefs.fexPreset(context), SessionPrefs.forceSsbs(context)) + config.shared)
|
||||
val json = encode(resolved)
|
||||
// The Display page's texture filtering, as DXVK options the launcher appends to
|
||||
// DXVK_CONFIG after the profiles - a user's own DXVK_CONFIG entry keeps its options.
|
||||
val dxvk = TextureFiltering.dxvkOptions(
|
||||
SessionPrefs.textureAnisotropy(context), SessionPrefs.textureLodBias(context),
|
||||
TextureFiltering.autoLodBias(SessionState.upscaleRatio),
|
||||
)
|
||||
if (dxvk.isNotEmpty()) json.put(DXVK_CONFIG, dxvk)
|
||||
write(File(LinuxRuntime.rootDir(context), GUEST_FILE), json.toString())
|
||||
}
|
||||
|
||||
private fun write(file: File, text: String) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import java.io.File
|
||||
|
||||
/**
|
||||
* Whether Deck mode's performance overlay is on screen. The client sets its level by rewriting
|
||||
* mangoapp's config (bannerlator-session makes it, `/tmp/mangohud.*`), and `no_display` is the
|
||||
* mangoapp's config (droiddeck-session makes it, `/tmp/mangohud.*`), and `no_display` is the
|
||||
* level that hides it. The feeds whose reads cost something - the fan's tachometer busy-waits in
|
||||
* the kernel, GPU memory walks every process's mappings - only run while it shows.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
/** Platform-independent eligibility; a service existing alone does not mean it has a picture. */
|
||||
object PipPolicy {
|
||||
fun eligible(running: Boolean, frameSeen: Boolean, phase: SessionPhase, suspended: Boolean, automatic: Boolean): Boolean =
|
||||
running && frameSeen && phase in setOf(SessionPhase.READY, SessionPhase.SUSPENDED) &&
|
||||
(!automatic || !suspended)
|
||||
|
||||
fun aspect(width: Int, height: Int): Pair<Int, Int> = when {
|
||||
width <= 0 || height <= 0 -> 16 to 9
|
||||
width.toDouble() / height > 2.39 -> 239 to 100
|
||||
height.toDouble() / width > 2.39 -> 100 to 239
|
||||
else -> width to height
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
|
||||
object ProtonDefault {
|
||||
private const val TAG = "ProtonDefault"
|
||||
private const val DIR = "root/.local/share/droiddeck-compat"
|
||||
|
||||
data class Tool(val name: String, val display: String, val dir: String, val valve: Boolean)
|
||||
data class Request(val seq: Long, val valve: Boolean, val dir: String)
|
||||
data class State(val default: String?, val applied: Long, val live: Boolean, val pending: String?, val tools: List<Tool>)
|
||||
|
||||
enum class Outcome { LIVE, NEXT_START, NOT_RUNNABLE }
|
||||
|
||||
private fun dir(context: Context) = File(LinuxRuntime.rootDir(context), DIR)
|
||||
|
||||
internal fun parseState(text: String?): State {
|
||||
val o = runCatching { JSONObject(text ?: "") }.getOrNull() ?: return State(null, 0, false, null, emptyList())
|
||||
val tools = o.optJSONArray("tools")
|
||||
return State(
|
||||
default = o.optString("default").ifEmpty { null },
|
||||
applied = o.optLong("applied", 0),
|
||||
live = o.optBoolean("live", false),
|
||||
pending = o.optString("pending").ifEmpty { null },
|
||||
tools = (0 until (tools?.length() ?: 0)).mapNotNull { i ->
|
||||
tools?.optJSONObject(i)?.let { t ->
|
||||
Tool(t.optString("name"), t.optString("display"), t.optString("dir"), t.optBoolean("valve"))
|
||||
}?.takeIf { it.name.isNotEmpty() }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
internal fun parseRequest(text: String?): Request? {
|
||||
val o = runCatching { JSONObject(text ?: "") }.getOrNull() ?: return null
|
||||
val seq = o.optLong("seq", 0)
|
||||
val dir = o.optString("dir")
|
||||
return if (seq > 0 && dir.isNotEmpty()) Request(seq, o.optBoolean("valve"), dir) else null
|
||||
}
|
||||
|
||||
internal fun chosen(state: State, request: Request?): Pair<Boolean, String>? {
|
||||
if (request != null && request.seq > state.applied) return request.valve to request.dir
|
||||
val tool = state.tools.firstOrNull { it.name == state.default } ?: return null
|
||||
return tool.valve to tool.dir
|
||||
}
|
||||
|
||||
internal fun runnable(state: State, proton: ComponentsManager.Proton): Boolean =
|
||||
state.tools.isEmpty() || state.tools.any { it.valve == proton.valve && it.dir == proton.dir.name }
|
||||
|
||||
private fun read(file: File): String? = runCatching { file.readText() }.getOrNull()
|
||||
|
||||
fun state(context: Context): State = parseState(read(File(dir(context), "state.json")))
|
||||
|
||||
fun selectedId(context: Context, protons: List<ComponentsManager.Proton>): String? {
|
||||
val d = dir(context)
|
||||
val pick = chosen(parseState(read(File(d, "state.json"))), parseRequest(read(File(d, "request.json")))) ?: return null
|
||||
return protons.firstOrNull { it.valve == pick.first && it.dir.name == pick.second }?.id
|
||||
}
|
||||
|
||||
internal fun nextSeq(now: Long, state: State, previous: Request?): Long =
|
||||
maxOf(now, state.applied + 1, (previous?.seq ?: 0) + 1)
|
||||
|
||||
@Synchronized
|
||||
fun request(context: Context, proton: ComponentsManager.Proton): Outcome {
|
||||
val state = state(context)
|
||||
if (!runnable(state, proton)) return Outcome.NOT_RUNNABLE
|
||||
val d = dir(context)
|
||||
val seq = nextSeq(System.currentTimeMillis(), state, parseRequest(read(File(d, "request.json"))))
|
||||
val body = JSONObject().put("seq", seq).put("valve", proton.valve).put("dir", proton.dir.name).toString(1) + "\n"
|
||||
val staged = File(d, "request.json.staged")
|
||||
try {
|
||||
d.mkdirs()
|
||||
staged.writeText(body)
|
||||
if (!staged.renameTo(File(d, "request.json"))) throw java.io.IOException("rename failed")
|
||||
} catch (e: Exception) {
|
||||
staged.delete()
|
||||
Log.w(TAG, "could not write the Proton request", e)
|
||||
throw e
|
||||
}
|
||||
val tool = state.tools.firstOrNull { it.valve == proton.valve && it.dir == proton.dir.name }
|
||||
return if (SessionState.running && state.live && (tool == null || state.pending != tool.name)) Outcome.LIVE else Outcome.NEXT_START
|
||||
}
|
||||
}
|
||||
@@ -131,7 +131,7 @@ object ProtonExtras {
|
||||
val guest = listOf(
|
||||
"/usr/bin/env", "-i", "HOME=/root", "USER=root", "PATH=/usr/local/bin:/usr/bin:/bin",
|
||||
"LANG=C.UTF-8", "XDG_DATA_HOME=/root/.local/share",
|
||||
"/usr/local/bin/bannerlator-proton-extra", "/root/.local/share/Steam", archive.absolutePath,
|
||||
"/usr/local/bin/droiddeck-proton-extra", "/root/.local/share/Steam", archive.absolutePath,
|
||||
)
|
||||
val command = LinuxRuntime.command(context, null, runtimeDir, null, guest)
|
||||
val process = ProcessBuilder(command)
|
||||
@@ -155,6 +155,14 @@ object ProtonExtras {
|
||||
else {
|
||||
archive.delete()
|
||||
unqueue(context, tool)
|
||||
if (EsyncPacks.enabled(context)) {
|
||||
onProgress("Fetching droiddeck-esync pack", -1)
|
||||
try {
|
||||
EsyncPacks.fetchWanted(context, root, onProgress)
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "sync pack for ${tool.name}", t)
|
||||
}
|
||||
}
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -1,29 +1,62 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.net.LocalServerSocket
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Build
|
||||
import android.os.CombinedVibration
|
||||
import android.os.VibrationEffect
|
||||
import android.os.Vibrator
|
||||
import android.os.VibratorManager
|
||||
import android.util.Log
|
||||
import android.view.InputDevice
|
||||
import androidx.annotation.RequiresApi
|
||||
import com.droiddeck.launcher.core.SessionPart
|
||||
import com.droiddeck.launcher.input.ControllerPrefs
|
||||
import com.droiddeck.launcher.input.PadBridge
|
||||
import java.io.DataInputStream
|
||||
import kotlin.math.max
|
||||
|
||||
/**
|
||||
* Rumble for the on-screen pad, on the phone itself.
|
||||
* Rumble for the pad, on the motors in the player's hands.
|
||||
*
|
||||
* When a game plays a force-feedback effect on the fake pad, 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 drives the device's
|
||||
* vibrator with the stronger motor's strength for the effect's duration. Best effort both ways:
|
||||
* a missing listener costs the guest nothing, and a bad packet is dropped.
|
||||
* 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 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
|
||||
/** 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 ->
|
||||
if (key == null || key == "rumble") refreshEnabled()
|
||||
}
|
||||
|
||||
/** The motors of the controller with this input device id, or null when it has none. Tests swap it. */
|
||||
internal var controllerMotors: (Int) -> Motors? = ::lookupController
|
||||
|
||||
@Synchronized private fun refreshEnabled() {
|
||||
enabled = app()?.let { ControllerPrefs.rumbleEnabled(it) } == true
|
||||
if (!enabled) { playing?.cancel(); playing = null }
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
val ctx = app() ?: return
|
||||
val s = try { LocalServerSocket(NAME) } catch (e: Exception) { Log.w(TAG, "rumble: no listener ($e)"); return }
|
||||
val vibrator = if (Build.VERSION.SDK_INT >= 31) ctx.getSystemService(VibratorManager::class.java)?.defaultVibrator
|
||||
else @Suppress("DEPRECATION") ctx.getSystemService(Vibrator::class.java)
|
||||
phone = vibrator?.takeIf { it.hasVibrator() }?.let { PhoneMotors(it) }
|
||||
preferences = ControllerPrefs.prefs(ctx).also { it.registerOnSharedPreferenceChangeListener(preferenceListener) }
|
||||
refreshEnabled()
|
||||
server = s
|
||||
Thread({
|
||||
while (server === s) {
|
||||
@@ -31,8 +64,7 @@ class RumbleComponent : SessionPart() {
|
||||
try {
|
||||
val bytes = ByteArray(8)
|
||||
DataInputStream(client.inputStream).readFully(bytes)
|
||||
val strong = u16(bytes, 0); val weak = u16(bytes, 2); val ms = u16(bytes, 4)
|
||||
buzz(max(strong, weak), ms)
|
||||
buzz(u16(bytes, 0), u16(bytes, 2), u16(bytes, 4))
|
||||
} catch (e: Exception) {
|
||||
// A partial packet or a closed peer: nothing to play.
|
||||
} finally {
|
||||
@@ -43,33 +75,107 @@ class RumbleComponent : SessionPart() {
|
||||
Log.i(TAG, "rumble: listening on @$NAME")
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
val s = server ?: return
|
||||
@Synchronized override fun stop() {
|
||||
preferences?.unregisterOnSharedPreferenceChangeListener(preferenceListener)
|
||||
preferences = null
|
||||
enabled = false
|
||||
playing?.cancel()
|
||||
playing = null
|
||||
phone = null
|
||||
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 */ }
|
||||
try { s?.close() } catch (e: Exception) { /* the accept loop ends on the next wake */ }
|
||||
}
|
||||
|
||||
private fun u16(b: ByteArray, at: Int): Int = (b[at].toInt() and 0xFF) or ((b[at + 1].toInt() and 0xFF) shl 8)
|
||||
|
||||
private fun buzz(strength: Int, ms: Int) {
|
||||
val ctx = app() ?: return
|
||||
val vibrator: Vibrator = if (Build.VERSION.SDK_INT >= 31) ctx.getSystemService(VibratorManager::class.java)?.defaultVibrator ?: return
|
||||
else @Suppress("DEPRECATION") (ctx.getSystemService(Vibrator::class.java) ?: return)
|
||||
if (!vibrator.hasVibrator()) return
|
||||
if (strength == 0 || ms == 0) { vibrator.cancel(); return }
|
||||
val amplitude = (strength * 255L / 65535L).toInt().coerceIn(1, 255)
|
||||
val duration = ms.toLong().coerceIn(1L, 5000L)
|
||||
try {
|
||||
if (vibrator.hasAmplitudeControl()) vibrator.vibrate(VibrationEffect.createOneShot(duration, amplitude))
|
||||
else vibrator.vibrate(VibrationEffect.createOneShot(duration, VibrationEffect.DEFAULT_AMPLITUDE))
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "rumble: $e")
|
||||
@Synchronized private fun buzz(strong: Int, weak: Int, ms: Int) {
|
||||
if (!enabled) return
|
||||
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(); return }
|
||||
try {
|
||||
target.play(strong, weak, ms.toLong().coerceIn(1L, 5000L))
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "rumble: ${target.name}: $e")
|
||||
}
|
||||
}
|
||||
|
||||
/** 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
|
||||
}
|
||||
|
||||
/** One place an effect can play. Strengths are the evdev 0..65535 magnitudes. */
|
||||
internal interface Motors {
|
||||
val name: String
|
||||
fun play(strong: Int, weak: Int, ms: Long)
|
||||
fun cancel()
|
||||
}
|
||||
|
||||
private class PhoneMotors(private val vibrator: Vibrator) : Motors {
|
||||
override val name = "the device's vibrator"
|
||||
override fun play(strong: Int, weak: Int, ms: Long) = vibrator.vibrate(oneShot(vibrator, max(strong, weak), ms))
|
||||
override fun cancel() = vibrator.cancel()
|
||||
}
|
||||
|
||||
/** Android 12+: each of the pad's motors on its own, the lower id being the strong one. */
|
||||
@RequiresApi(31)
|
||||
private class PadMotors(override val name: String, private val manager: VibratorManager, private val ids: IntArray) : Motors {
|
||||
override fun play(strong: Int, weak: Int, ms: Long) {
|
||||
val combined = CombinedVibration.startParallel()
|
||||
var any = false
|
||||
fun add(id: Int, strength: Int) {
|
||||
if (strength == 0) return
|
||||
combined.addVibrator(id, oneShot(manager.getVibrator(id), strength, ms))
|
||||
any = true
|
||||
}
|
||||
if (ids.size >= 2) { add(ids[0], strong); add(ids[1], weak) } else add(ids[0], max(strong, weak))
|
||||
if (any) manager.vibrate(combined.combine()) else manager.cancel()
|
||||
}
|
||||
override fun cancel() = manager.cancel()
|
||||
}
|
||||
|
||||
/** Before Android 12 a pad has one vibrator. */
|
||||
private class LegacyPadMotors(override val name: String, private val vibrator: Vibrator) : Motors {
|
||||
override fun play(strong: Int, weak: Int, ms: Long) = vibrator.vibrate(oneShot(vibrator, max(strong, weak), ms))
|
||||
override fun cancel() = vibrator.cancel()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SessionService"
|
||||
/** Must match the fake evdev layer (fakeinput_steam.cpp). */
|
||||
const val NAME = "droiddeck-rumble"
|
||||
|
||||
private fun oneShot(vibrator: Vibrator, strength: Int, ms: Long): VibrationEffect {
|
||||
val amplitude = if (vibrator.hasAmplitudeControl()) (strength * 255L / 65535L).toInt().coerceIn(1, 255)
|
||||
else VibrationEffect.DEFAULT_AMPLITUDE
|
||||
return VibrationEffect.createOneShot(ms, amplitude)
|
||||
}
|
||||
|
||||
private fun lookupController(id: Int): Motors? {
|
||||
val device = InputDevice.getDevice(id) ?: return null
|
||||
val name = "\"${device.name}\""
|
||||
if (Build.VERSION.SDK_INT >= 31) {
|
||||
val manager = device.vibratorManager
|
||||
val ids = manager.vibratorIds.sortedArray()
|
||||
return if (ids.isEmpty()) null else PadMotors(name, manager, ids)
|
||||
}
|
||||
@Suppress("DEPRECATION")
|
||||
val vibrator = device.vibrator
|
||||
return if (vibrator.hasVibrator()) LegacyPadMotors(name, vibrator) else null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@ object SecondaryLibrary {
|
||||
"4185400" to "SteamLinuxRuntime_4-arm64",
|
||||
"4427310" to "Proton Experimental (ARM64)",
|
||||
"4628740" to "Proton 11.0 (ARM64)",
|
||||
"1628350" to "SteamLinuxRuntime_sniper",
|
||||
"3127680" to "FEX-Emu",
|
||||
)
|
||||
|
||||
fun privateRoot(files: File, library: File): File {
|
||||
@@ -32,7 +34,7 @@ object SecondaryLibrary {
|
||||
val id = Regex("\"appid\"\\s+\"(\\d+)\"").find(text)?.groupValues?.get(1)
|
||||
val dir = Regex("\"installdir\"\\s+\"([^\"]+)\"").find(text)?.groupValues?.get(1)
|
||||
if (id != null && dir != null && File(dir).name == dir &&
|
||||
(dir.startsWith("Proton ") || dir.startsWith("SteamLinuxRuntime"))) tools[id] = dir
|
||||
(dir.startsWith("Proton ") || dir.startsWith("SteamLinuxRuntime") || id in bootstrapTools)) tools[id] = dir
|
||||
}
|
||||
val overrides = ArrayList<Pair<File, String>>()
|
||||
for (name in listOf("compatdata", "shadercache")) {
|
||||
|
||||
@@ -34,6 +34,16 @@ object SessionArtifacts {
|
||||
/** 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: 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"
|
||||
|
||||
/** Everything the end of a session gathers, into [dir]. Safe to call for a dead session. */
|
||||
fun collect(context: Context, dir: File, reason: String) {
|
||||
try {
|
||||
@@ -44,11 +54,11 @@ object SessionArtifacts {
|
||||
}
|
||||
}
|
||||
LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context))
|
||||
copySteamLogs(context, dir)
|
||||
val record = Record(dir)
|
||||
copySteamLogs(context, dir, record)
|
||||
// A session the system killed leaves its trace here and nowhere else.
|
||||
SessionLogCapture.dumpCrashBuffer(File(dir, "crash.log"))
|
||||
scrubFolder(dir)
|
||||
try { File(dir, SCRUBBED_MARKER).writeText("scrubbed ${now()}\n") } catch (e: Exception) {}
|
||||
markScrubbed(scrubTree(record))
|
||||
SessionEvents.record("session.artifacts_collected", mapOf("reason" to reason), dir)
|
||||
File(dir, COMPLETE_MARKER).writeText("collected: $reason at ${now()}\n")
|
||||
} catch (e: Exception) {
|
||||
@@ -71,8 +81,7 @@ object SessionArtifacts {
|
||||
if (dirs.isEmpty()) return
|
||||
LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context))
|
||||
dirs.forEach { dir ->
|
||||
scrubFolder(dir)
|
||||
File(dir, "steam").takeIf { it.isDirectory }?.let { scrubFolder(it) }
|
||||
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)")
|
||||
@@ -84,29 +93,121 @@ object SessionArtifacts {
|
||||
* through the redactor before the folder can be shared. A file is rewritten only if a line
|
||||
* changed. steam/ was scrubbed on the way in.
|
||||
*/
|
||||
private fun scrubFolder(dir: File) {
|
||||
dir.listFiles { f -> f.isFile && !f.name.startsWith(".") }?.forEach { f ->
|
||||
/** A file's size and modification time as they stood right after it was scrubbed. */
|
||||
private class Scrubbed(val length: Long, val modified: Long)
|
||||
|
||||
/** Files of a folder as they stood right after scrubbing, filled by [scrubTree] and [copySteamLogs]. */
|
||||
private class Record(val dir: File) {
|
||||
val files = HashMap<File, Scrubbed>()
|
||||
var failed = false
|
||||
}
|
||||
|
||||
private fun scrubFolder(dir: File, record: Record) {
|
||||
dir.listFiles { f -> f.isFile && !f.name.startsWith(".") && f !in record.files }?.forEach { f ->
|
||||
if (!LogRedactor.isText(f)) return@forEach
|
||||
try {
|
||||
val tmp = File(dir, ".${f.name}.scrub")
|
||||
tmp.bufferedWriter().use { w -> LogRedactor.scrubTo(f, w) }
|
||||
if (tmp.length() != f.length() || tmp.readBytes().contentEquals(f.readBytes()).not()) tmp.renameTo(f) else tmp.delete()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "could not scrub ${f.name}", e)
|
||||
}
|
||||
val done = scrubFile(dir, f)
|
||||
if (done == null) record.failed = true else record.files[f] = done
|
||||
}
|
||||
}
|
||||
|
||||
/** Steam's logs: redacted into steam/, never copied verbatim. */
|
||||
private fun copySteamLogs(context: Context, dir: File) {
|
||||
/**
|
||||
* Puts [f] through the redactor in place. Returns its size and modification time as it then
|
||||
* stands, or null if it could not, and [f] may still hold the original. A writer still
|
||||
* appending to [f] (the app's own log capture) changes its size afterwards, and a share then
|
||||
* scrubs it again.
|
||||
*/
|
||||
private fun scrubFile(dir: File, f: File): Scrubbed? {
|
||||
val tmp = File(dir, ".${f.name}.scrub")
|
||||
return try {
|
||||
val before = Scrubbed(f.length(), f.lastModified())
|
||||
tmp.bufferedWriter().use { w -> LogRedactor.scrubTo(f, w) }
|
||||
val scrubbed = tmp.readBytes()
|
||||
if (scrubbed.contentEquals(f.readBytes())) {
|
||||
tmp.delete()
|
||||
// Unchanged by the redactor, and by anything else while it read.
|
||||
before.takeIf { f.length() == it.length && f.lastModified() == it.modified }
|
||||
} else {
|
||||
// Shared storage can refuse a rename; then the scrubbed bytes are written over the
|
||||
// original instead, so it never stays behind unscrubbed.
|
||||
if (!tmp.renameTo(f)) { f.writeBytes(scrubbed); tmp.delete() }
|
||||
Scrubbed(scrubbed.size.toLong(), f.lastModified()).takeIf { f.length() == it.length }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "could not scrub ${f.name}", e)
|
||||
tmp.delete()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
val dir = record.dir
|
||||
val tree = File(dir, SCRUBBED_TREE_MARKER)
|
||||
if (record.failed) {
|
||||
tree.delete()
|
||||
Log.w(TAG, "$dir is not fully scrubbed; it is scrubbed again when shared")
|
||||
return
|
||||
}
|
||||
try {
|
||||
File(dir, SCRUBBED_MARKER).writeText("scrubbed ${now()}\n")
|
||||
val lines = StringBuilder("rules ${LogRedactor.RULES_VERSION}\n")
|
||||
record.files.forEach { (f, s) ->
|
||||
lines.append(s.length).append('\t').append(s.modified).append('\t')
|
||||
.append(f.relativeTo(dir).path).append('\n')
|
||||
}
|
||||
tree.writeText(lines.toString())
|
||||
} catch (e: Exception) {
|
||||
tree.delete()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The files of [dir] that its own ending scrubbed and that are unchanged since (same size and
|
||||
* modification time), by path relative to [dir]. Empty when the folder has no such record, or
|
||||
* one made under other redactor rules.
|
||||
*/
|
||||
fun scrubbedFiles(dir: File): Set<String> {
|
||||
val lines = try { File(dir, SCRUBBED_TREE_MARKER).takeIf { it.isFile }?.readLines() } catch (e: Exception) { null }
|
||||
?: return emptySet()
|
||||
if (lines.firstOrNull() != "rules ${LogRedactor.RULES_VERSION}") return emptySet()
|
||||
return lines.drop(1).mapNotNull { line ->
|
||||
val parts = line.split('\t', limit = 3)
|
||||
if (parts.size != 3) return@mapNotNull null
|
||||
val f = File(dir, parts[2])
|
||||
parts[2].takeIf { f.isFile && f.length().toString() == parts[0] && f.lastModified().toString() == parts[1] }
|
||||
}.toSet()
|
||||
}
|
||||
|
||||
/** The end-of-session scrub of [dir] on its own (steam/ already in place), for tests. */
|
||||
@androidx.annotation.VisibleForTesting
|
||||
internal fun scrubAndMark(dir: File) = markScrubbed(scrubTree(Record(dir)))
|
||||
|
||||
/**
|
||||
* [record]'s folder and every folder under it (steam/, droiddeck-esync/): the session script
|
||||
* copied files there verbatim, and a Steam log the app did not copy over again (gone from the
|
||||
* runtime, or past its size limit) stayed as the script left it. Files [copySteamLogs] already
|
||||
* redacted on the way in are in [record] and left alone.
|
||||
*/
|
||||
private fun scrubTree(record: Record): Record {
|
||||
record.dir.walkTopDown().filter { it.isDirectory }.forEach { scrubFolder(it, record) }
|
||||
return record
|
||||
}
|
||||
|
||||
/** Steam's logs: redacted into steam/, never copied verbatim. What it wrote goes in [record]. */
|
||||
private fun copySteamLogs(context: Context, dir: File, record: Record) {
|
||||
val logs = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/logs")
|
||||
if (!logs.isDirectory) return
|
||||
val out = File(dir, "steam").apply { mkdirs() }
|
||||
logs.listFiles { f -> f.isFile && f.length() < 8L * 1024 * 1024 }?.forEach { src ->
|
||||
try {
|
||||
File(out, src.name).bufferedWriter().use { w ->
|
||||
val dst = File(out, src.name)
|
||||
dst.bufferedWriter().use { w ->
|
||||
src.forEachLine { line -> w.write(LogRedactor.redact(line)); w.newLine() }
|
||||
}
|
||||
record.files[dst] = Scrubbed(dst.length(), dst.lastModified())
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "could not scrub ${src.name}", e)
|
||||
}
|
||||
@@ -143,12 +244,11 @@ object SessionArtifacts {
|
||||
"crash.log holds Android's crash buffer as of the next app start - if this\n" +
|
||||
"session died of a crash, the entry is in there unless the device rebooted.\n"
|
||||
)
|
||||
if (newest) {
|
||||
copySteamLogs(context, dir)
|
||||
}
|
||||
val record = Record(dir)
|
||||
if (newest) copySteamLogs(context, dir, record)
|
||||
SessionLogCapture.dumpCrashBuffer(File(dir, "crash.log"))
|
||||
SessionEvents.record("session.artifacts_recovered", mapOf("newest" to newest), dir)
|
||||
scrubFolder(dir)
|
||||
markScrubbed(scrubTree(record))
|
||||
File(dir, COMPLETE_MARKER).writeText("collected: late, at next app start, ${now()}\n")
|
||||
Log.i(TAG, "finished the abandoned session folder $dir")
|
||||
} catch (e: Exception) {
|
||||
|
||||
Loaded 100 of 295 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user