mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-15 11:47:14 -04:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c74bac96e | ||
|
|
66b71b486f | ||
|
|
ca1ceb5e28 | ||
|
|
05284b0dd6 | ||
|
|
8f36c4e37f |
No files matched your search
@@ -1,101 +0,0 @@
|
||||
name: Build desktop package (KDE Plasma)
|
||||
|
||||
# The desktop the app installs into its Linux runtime on first use: KDE Plasma as SteamOS's desktop
|
||||
# mode has it, the closure of tools/desktop-kde/seeds.txt over Arch Linux ARM minus what the runtime
|
||||
# already carries, plus tools/desktop-kde/overlay (tools/desktop-kde/build.sh), every package
|
||||
# signature-checked. Published as a release in Droid-Deck/DroidDeck-Components with DroidDeck's
|
||||
# other components; the apk pins it in tools/desktop-kde/release.env (tag, sha256, size). A
|
||||
# published tag is never rebuilt: the packages are Arch Linux ARM's current ones, so a rebuild
|
||||
# would not match the pinned sha256. A changed package gets a new tag (desktop-kde-r2, ...) and a
|
||||
# release.env update.
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'tools/desktop-kde/**'
|
||||
- '.github/workflows/build-desktop-kde.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag to publish as (e.g. desktop-kde-r1); empty = artifact only'
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
kwin:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build KWin with nested desktop scaling
|
||||
run: |
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq zstd xz-utils gpgv patch binutils >/dev/null
|
||||
bash tools/desktop-kde/build-kwin.sh out/kwin
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop-kde-kwin
|
||||
retention-days: 7
|
||||
path: out/kwin/
|
||||
build:
|
||||
needs: kwin
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: desktop-kde-kwin
|
||||
path: out/kwin
|
||||
- uses: actions/create-github-app-token@v2
|
||||
id: components-token
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.tag != ''
|
||||
with:
|
||||
app-id: ${{ vars.DROIDDECK_CI_APP_ID }}
|
||||
private-key: ${{ secrets.DROIDDECK_CI_APP_KEY }}
|
||||
owner: Droid-Deck
|
||||
repositories: DroidDeck-Components
|
||||
- name: Refuse a published tag
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.tag != ''
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.components-token.outputs.token }}
|
||||
run: |
|
||||
if gh release view '${{ inputs.tag }}' -R Droid-Deck/DroidDeck-Components >/dev/null 2>&1; then
|
||||
echo "::error::${{ inputs.tag }} is already published, and published tags are never replaced: use a new tag"
|
||||
exit 1
|
||||
fi
|
||||
- name: Build
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq zstd xz-utils gpgv >/dev/null
|
||||
KDE_KWIN_OVERRIDE="$PWD/out/kwin" tools/desktop-kde/build.sh out
|
||||
cp out/kwin/desktop-kde.kwin.txt out/
|
||||
echo "size $(stat -c %s out/desktop-kde.tar.zst) sha256 $(cat out/desktop-kde.sha256)"
|
||||
# The package itself too, briefly: enough to try a build on a device before publishing it.
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop-kde
|
||||
retention-days: 7
|
||||
compression-level: 0
|
||||
path: |
|
||||
out/desktop-kde.tar.zst
|
||||
out/desktop-kde.packages.txt
|
||||
out/desktop-kde.kwin.txt
|
||||
out/desktop-kde.sha256
|
||||
- name: Publish
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.tag != ''
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.components-token.outputs.token }}
|
||||
GH_REPO: Droid-Deck/DroidDeck-Components
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG='${{ inputs.tag }}'
|
||||
if gh release view "$TAG" >/dev/null 2>&1; then
|
||||
echo "::error::$TAG is already published in DroidDeck-Components, and published tags are never replaced: use a new tag"
|
||||
exit 1
|
||||
fi
|
||||
gh release create "$TAG" --latest=false --prerelease=false --title "$TAG" \
|
||||
--notes "KDE Plasma desktop package for DroidDeck's Linux runtime ($GITHUB_REPOSITORY tools/desktop-kde, $GITHUB_SHA). size $(stat -c %s out/desktop-kde.tar.zst), sha256 $(cat out/desktop-kde.sha256). desktop-kde.packages.txt lists the signature-checked Arch Linux ARM packages it was built from." \
|
||||
out/desktop-kde.tar.zst out/desktop-kde.sha256 out/desktop-kde.packages.txt out/desktop-kde.kwin.txt
|
||||
gh release edit "$TAG" --latest=false --prerelease=false
|
||||
@@ -42,7 +42,4 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh release create "${{ inputs.tag }}" --latest=false --prerelease=false --title "${{ inputs.tag }}" --notes "gamescope 3.16.29 with tools/gamescope/patches; staged by the apk over /usr/local/bin/gamescope. sha256 $(cut -d' ' -f1 gamescope.tzst.sha256)" gamescope.tzst gamescope.tzst.sha256 || gh release upload "${{ inputs.tag }}" gamescope.tzst gamescope.tzst.sha256 --clobber
|
||||
# A component the apk fetches by tag, never something users install: it must not
|
||||
# become the repo's Latest release or appear as a pre-release, also when re-uploaded.
|
||||
gh release edit "${{ inputs.tag }}" --latest=false --prerelease=false
|
||||
gh release create "${{ inputs.tag }}" --title "${{ inputs.tag }}" --notes "gamescope 3.16.29 with tools/gamescope/patches; staged by the apk over /usr/local/bin/gamescope. sha256 $(cut -d' ' -f1 gamescope.tzst.sha256)" gamescope.tzst gamescope.tzst.sha256 || gh release upload "${{ inputs.tag }}" gamescope.tzst gamescope.tzst.sha256 --clobber
|
||||
@@ -0,0 +1,46 @@
|
||||
name: Build wlroots (patched)
|
||||
|
||||
# The desktop's wlroots (Arch Linux ARM's wlroots0.20, what the desktop package's labwc links)
|
||||
# rebuilt with tools/wlroots/patches: a Vulkan DMA-BUF allocator for the Adreno node, which is not
|
||||
# a DRM device, so labwc's vulkan and gles2 renderers can start. Built in an Arch Linux ARM
|
||||
# container on an arm64 runner and published as a release asset the apk build fetches
|
||||
# (tools/wlroots/release.env names the tag and sha). The hosted desktop package is never changed.
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'tools/wlroots/**'
|
||||
- '.github/workflows/build-wlroots.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag to publish as (e.g. wlroots-0.20.2-p1); empty = artifact only'
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build in Arch Linux ARM
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker run --rm -v "$PWD:/work" -w /work menci/archlinuxarm:base-devel bash tools/wlroots/build-in-arch.sh
|
||||
sudo chown -R "$(id -u):$(id -g)" .
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: wlroots-patched
|
||||
path: |
|
||||
wlroots.tzst
|
||||
wlroots.tzst.sha256
|
||||
- name: Publish
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.tag != ''
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh release create "${{ inputs.tag }}" --title "${{ inputs.tag }}" --notes "wlroots 0.20.2 with tools/wlroots/patches (Vulkan DMA-BUF allocator for non-DRM GPU nodes); staged by the apk at /usr/local/lib/droiddeck-wlroots for the desktop's GPU renderers. sha256 $(cut -d' ' -f1 wlroots.tzst.sha256)" wlroots.tzst wlroots.tzst.sha256 || gh release upload "${{ inputs.tag }}" wlroots.tzst wlroots.tzst.sha256 --clobber
|
||||
+65
-228
@@ -28,7 +28,7 @@ concurrency:
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
PRELOAD_CACHE_VERSION: v3
|
||||
PRELOAD_CACHE_VERSION: v2
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -44,19 +44,6 @@ jobs:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check co-authors
|
||||
if: github.event_name == 'pull_request'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch --no-tags origin main
|
||||
base=$(git merge-base origin/main HEAD)
|
||||
messages=$(git log --format=%B "$base..HEAD")
|
||||
messages+=$'\n'"$(jq -r '.pull_request.title, (.pull_request.body // "")' "$GITHUB_EVENT_PATH")"
|
||||
if grep -Eiq '^[[:space:]]*Co-authored-by:.*(Claude|Anthropic|OpenAI|ChatGPT|GPT|Copilot|Cursor|Gemini|Codex|Devin|Windsurf|Codeium|Amazon Q|Tabnine|Cody|\[bot\])' <<<"$messages"; then
|
||||
echo "::error::AI and bot identities cannot be co-authors; remove the Co-authored-by trailer."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: The packages a signed build ships as
|
||||
id: variants
|
||||
run: echo "list=$(awk '!/^#/ && NF { print $1 }' tools/release/variants.txt | jq -R . | jq -sc .)" >> "$GITHUB_OUTPUT"
|
||||
@@ -70,22 +57,6 @@ 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: |
|
||||
@@ -186,7 +157,7 @@ jobs:
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: app/src/main/assets/linuxfs
|
||||
key: preloads-${{ env.PRELOAD_CACHE_VERSION }}-${{ hashFiles('app/src/main/cpp/fakeinput_steam.cpp', 'tools/linuxfs/preload/*.c', 'tools/linuxfs/build-x86-preloads.sh', 'tools/linuxfs/fex/*', 'tools/linuxfs/clipboard/*.c', 'tools/linuxfs/ssbs/*.c', 'tools/proot/fastpath/*.c', 'tools/linuxfs/overlay/usr/local/bin/*', 'tools/linuxfs/overlay/usr/bin/**', 'tools/linuxfs/desktop/*', 'tools/gamescope/release.env', 'tools/wlroots/release.env', 'tools/linuxfs/uruntime.env', 'tools/linuxfs/licenses/*', 'tools/mangoapp/*', 'tools/msitools/**', 'tools/linuxfs/debian-image.env') }}
|
||||
key: preloads-${{ env.PRELOAD_CACHE_VERSION }}-${{ hashFiles('app/src/main/cpp/fakeinput_steam.cpp', 'tools/linuxfs/preload/*.c', 'tools/linuxfs/overlay/usr/local/bin/*', 'tools/linuxfs/overlay/usr/bin/**', 'tools/linuxfs/desktop/*', 'tools/gamescope/release.env', 'tools/wlroots/release.env', 'tools/mangoapp/*') }}
|
||||
|
||||
- name: Build libfakeinput.so and libblsession.so (glibc aarch64)
|
||||
if: steps.preloads.outputs.cache-hit != 'true'
|
||||
@@ -205,20 +176,7 @@ jobs:
|
||||
aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -pthread \
|
||||
-o "$D/libblsession.so" tools/linuxfs/preload/*.c -ldl
|
||||
aarch64-linux-gnu-strip --strip-unneeded "$D/libblsession.so"
|
||||
aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -pthread \
|
||||
-o "$D/libblfastpath.so" tools/proot/fastpath/fastpath.c -ldl
|
||||
aarch64-linux-gnu-strip --strip-unneeded "$D/libblfastpath.so"
|
||||
aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -nostdlib -ffreestanding -fno-stack-protector \
|
||||
-fno-tree-loop-distribute-patterns -Wl,-z,defs -o "$D/libblaudit.so" tools/proot/fastpath/audit.c
|
||||
aarch64-linux-gnu-strip --strip-unneeded "$D/libblaudit.so"
|
||||
# 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
|
||||
for script in tools/linuxfs/overlay/usr/local/bin/bannerlator-*; 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
|
||||
@@ -226,18 +184,20 @@ jobs:
|
||||
done
|
||||
if [ -f tools/gamescope/release.env ]; then
|
||||
. tools/gamescope/release.env
|
||||
gh release download "${GAMESCOPE_TAG}" -R "${GAMESCOPE_REPO:-${GITHUB_REPOSITORY}}" -p gamescope.tzst -O gamescope.tzst
|
||||
gh release download "${GAMESCOPE_TAG}" -R "${GITHUB_REPOSITORY}" -p gamescope.tzst -O gamescope.tzst
|
||||
echo "${GAMESCOPE_SHA256} gamescope.tzst" | sha256sum -c -
|
||||
tar --use-compress-program=unzstd -xf gamescope.tzst -C "$D"
|
||||
test -f "$D/usr/local/bin/gamescope"
|
||||
rm gamescope.tzst
|
||||
fi
|
||||
. tools/linuxfs/uruntime.env
|
||||
curl -fsSL --retry 3 -o uruntime "https://github.com/VHSgunzo/uruntime/releases/download/${URUNTIME_VERSION}/${URUNTIME_ASSET}"
|
||||
echo "${URUNTIME_SHA256} uruntime" | sha256sum -c -
|
||||
install -Dm644 uruntime "$D/usr/local/lib/droiddeck/uruntime"
|
||||
install -Dm644 tools/linuxfs/licenses/uruntime-LICENSE "$D/usr/local/share/licenses/uruntime/LICENSE"
|
||||
rm uruntime
|
||||
if [ -f tools/wlroots/release.env ]; then
|
||||
. tools/wlroots/release.env
|
||||
gh release download "${WLROOTS_TAG}" -R "${GITHUB_REPOSITORY}" -p wlroots.tzst -O wlroots.tzst
|
||||
echo "${WLROOTS_SHA256} wlroots.tzst" | sha256sum -c -
|
||||
tar --use-compress-program=unzstd -xf wlroots.tzst -C "$D"
|
||||
test -f "$D/usr/local/lib/droiddeck-wlroots/libwlroots-0.20.so"
|
||||
rm wlroots.tzst
|
||||
fi
|
||||
M="$D/usr/local/lib/mangoapp"
|
||||
mkdir -p "$M" mango-pkgs
|
||||
grep -v '^#' tools/mangoapp/packages.txt | while read -r sum url; do
|
||||
@@ -248,113 +208,21 @@ jobs:
|
||||
rm mango-pkgs/pkg.tar.zst
|
||||
done
|
||||
install -m644 mango-pkgs/usr/bin/mangoapp "$M/mangoapp"
|
||||
for so in libfmt.so.10 libspdlog.so.1.13 libglfw.so.3 libtraceevent.so.1; do
|
||||
for so in libfmt.so.10 libspdlog.so.1.13 libglfw.so.3 libtraceevent.so.1 libtracefs.so.1; do
|
||||
cp -L "mango-pkgs/usr/lib/$so" "$M/$so"
|
||||
done
|
||||
# Ours, not the package's: GPU memory without tracefs (tools/mangoapp/libtracefs-shim.c).
|
||||
aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -Wl,-soname,libtracefs.so.1 \
|
||||
-o "$M/libtracefs.so.1" tools/mangoapp/libtracefs-shim.c
|
||||
aarch64-linux-gnu-strip --strip-unneeded "$M/libtracefs.so.1"
|
||||
install -Dm644 tools/mangoapp/mangoapp "$D/usr/local/bin/mangoapp"
|
||||
rm -rf mango-pkgs
|
||||
ls -l "$M"
|
||||
# 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"
|
||||
# 7-Zip (the full build with its format plugin) opens installer .exe wrappers.
|
||||
install -m644 msi-pkgs/usr/lib/7zip/7z "$T/7z"
|
||||
install -m644 msi-pkgs/usr/lib/7zip/7z.so "$T/7z.so"
|
||||
for so in libmsi-1.0.so.0 libgsf-1.so.114 libgcab-1.0.so.0; do
|
||||
cp -L "msi-pkgs/usr/lib/$so" "$T/$so"
|
||||
done
|
||||
for f in NOTICE GPL-2 GPL-3 LGPL-2.1 7zip-License 7zip-unRarLicense; do
|
||||
install -Dm644 "tools/msitools/licenses/$f" "$D/usr/local/share/licenses/droiddeck-msitools/$f"
|
||||
done
|
||||
rm -rf msi-pkgs
|
||||
ls -l "$T"
|
||||
install -Dm644 tools/linuxfs/desktop/droiddeck-desktop "$D/usr/local/bin/droiddeck-desktop"
|
||||
install -Dm644 tools/linuxfs/desktop/droiddeck-gpu "$D/usr/local/bin/droiddeck-gpu"
|
||||
install -Dm644 tools/linuxfs/desktop/droiddeck-desktop-gpu "$D/usr/local/bin/droiddeck-desktop-gpu"
|
||||
install -Dm644 tools/linuxfs/desktop/kwin_wayland_wrapper "$D/usr/local/bin/kwin_wayland_wrapper"
|
||||
install -Dm644 tools/linuxfs/desktop/autostart "$D/etc/xdg/labwc/autostart"
|
||||
install -Dm644 tools/linuxfs/desktop/rc.xml "$D/etc/xdg/labwc/rc.xml"
|
||||
install -Dm644 tools/linuxfs/desktop/panel.conf "$D/etc/xdg/lxqt/panel.conf"
|
||||
install -Dm644 tools/linuxfs/desktop/firefox-droiddeck.js "$D/usr/lib/firefox/defaults/pref/droiddeck.js"
|
||||
ls -lR "$D"
|
||||
|
||||
# The Debian 11 image the preloads are compiled in comes from a release on this repo
|
||||
# (published once by ci-debian-image.yml, pinned in tools/linuxfs/debian-image.env): the
|
||||
# public registry mirrors rate-limit the shared runner addresses and failed builds on a
|
||||
# busy evening. Only if that download fails is the image pulled, with retries.
|
||||
- name: Fetch the Debian image the preloads build in
|
||||
if: steps.preloads.outputs.cache-hit != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
. tools/linuxfs/debian-image.env
|
||||
image=public.ecr.aws/docker/library/debian:bullseye
|
||||
if gh release download "$DEBIAN_IMAGE_TAG" -R "$GITHUB_REPOSITORY" -p "$DEBIAN_IMAGE_ASSET" -O /tmp/debian-image.tar.zst \
|
||||
&& { [ -z "$DEBIAN_IMAGE_SHA256" ] || echo "$DEBIAN_IMAGE_SHA256 /tmp/debian-image.tar.zst" | sha256sum -c -; }; then
|
||||
zstd -dc /tmp/debian-image.tar.zst | docker load
|
||||
rm -f /tmp/debian-image.tar.zst
|
||||
else
|
||||
echo "::warning::the hosted Debian image could not be fetched; pulling from the registry instead"
|
||||
for attempt in 1 2 3 4 5; do
|
||||
docker pull "$image" && break
|
||||
[ "$attempt" -lt 5 ] || { echo "::error::could not pull $image after $attempt attempts"; exit 1; }
|
||||
echo "pull attempt $attempt failed (registry busy?); retrying in $((attempt * 30)) s"
|
||||
sleep $((attempt * 30))
|
||||
done
|
||||
fi
|
||||
docker image ls "$image"
|
||||
|
||||
- name: Build the x86 preloads FEX guests load (glibc 2.31)
|
||||
if: steps.preloads.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker run --rm -v "$PWD:/src" -w /src public.ecr.aws/docker/library/debian:bullseye bash -c '
|
||||
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
|
||||
@@ -367,11 +235,6 @@ jobs:
|
||||
set -euo pipefail
|
||||
curl -fsSL -o pa.tar.gz https://github.com/pulseaudio/pulseaudio/archive/refs/tags/v13.0.tar.gz
|
||||
mkdir -p pa13 && tar -xzf pa.tar.gz -C pa13 --strip-components=1 && rm pa.tar.gz
|
||||
# DirectAudio - the game driver (one set per Wine audio interface), the Android-side helper and
|
||||
# the Steam client's sinks - comes from a pinned release of The412Banner/directaudio, checksummed,
|
||||
# exactly like gamescope; nothing compiled for audio is kept in this repo.
|
||||
- name: Fetch DirectAudio (tools/directaudio/release.env)
|
||||
run: tools/directaudio/fetch.sh "$PWD" "$PWD/sink-out"
|
||||
- name: Build the AAudio sink into the audio bundle
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -381,12 +244,12 @@ jobs:
|
||||
tar --use-compress-program=unzstd -xf app/src/main/assets/pulseaudio.tzst -C pa-bundle
|
||||
test ! -f pa-bundle/modules/arm64/module-aaudio-sink.so
|
||||
install -m755 sink-out/module-aaudio-sink.so pa-bundle/modules/arm64/module-aaudio-sink.so
|
||||
install -m755 sink-out/module-directaudio-native-sink.so pa-bundle/modules/arm64/module-directaudio-native-sink.so
|
||||
install -m755 sink-out/module-directaudio-sink.so pa-bundle/modules/arm64/module-directaudio-sink.so
|
||||
(cd pa-bundle && tar --use-compress-program='zstd -19' -cf ../pulseaudio.tzst .)
|
||||
mkdir -p app/build/prepared-assets
|
||||
mv pulseaudio.tzst app/build/prepared-assets/pulseaudio.tzst
|
||||
tar --use-compress-program=unzstd -tf app/build/prepared-assets/pulseaudio.tzst | grep -c '\.so$'
|
||||
mv pulseaudio.tzst app/src/main/assets/pulseaudio.tzst
|
||||
tar --use-compress-program=unzstd -tf app/src/main/assets/pulseaudio.tzst | grep -c '\.so$'
|
||||
rm -rf pa-bundle sink-out
|
||||
|
||||
- name: Cache proot
|
||||
id: proot
|
||||
uses: actions/cache@v4
|
||||
@@ -405,40 +268,30 @@ jobs:
|
||||
- name: Check the preloads
|
||||
run: |
|
||||
set -euo pipefail
|
||||
bash tools/linuxfs/check-preloads.sh app/src/main/assets/linuxfs
|
||||
|
||||
# The store download engines (app/src/main/rust/stores) are Rust, built by Gradle's
|
||||
# buildRustStores task through cargo-ndk. rustup and cargo are on the runner image; the Android
|
||||
# target and cargo-ndk are added here. Cache = cargo registry + cargo-ndk + the crate's target
|
||||
# tree, keyed on the rustc version and Cargo.lock, so a source-only change rebuilds this crate
|
||||
# on top of the cached dependency objects, and a lockfile change still starts from the
|
||||
# previous objects (restore-keys).
|
||||
- name: Rust toolchain for the store engines (aarch64-linux-android)
|
||||
id: rust
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rustup target add aarch64-linux-android
|
||||
echo "rustc=$(rustc -V | awk '{print $2}')" >> "$GITHUB_OUTPUT"
|
||||
rustc -V; cargo -V
|
||||
|
||||
- name: Cache cargo registry and the store engines' target
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry/index
|
||||
~/.cargo/registry/cache
|
||||
~/.cargo/git/db
|
||||
~/.cargo/bin/cargo-ndk
|
||||
app/src/main/rust/stores/target
|
||||
key: stores-${{ steps.rust.outputs.rustc }}-${{ hashFiles('app/src/main/rust/stores/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
stores-${{ steps.rust.outputs.rustc }}-
|
||||
|
||||
- name: cargo-ndk
|
||||
run: |
|
||||
set -euo pipefail
|
||||
command -v cargo-ndk >/dev/null || cargo install cargo-ndk --locked
|
||||
cargo ndk --version
|
||||
D=app/src/main/assets/linuxfs
|
||||
NEED=$(readelf -d "$D/libfakeinput.so" | sed -n 's/.*NEEDED.*\[\(.*\)\]/\1/p')
|
||||
for bad in libstdc++.so.6 libgcc_s.so.1; do
|
||||
if printf '%s\n' "$NEED" | grep -qx "$bad"; then
|
||||
echo "::error::libfakeinput.so links $bad - the C++ runtime must stay static"; exit 1
|
||||
fi
|
||||
done
|
||||
syms() { readelf -Ws "$1" | awk '$4 == "FUNC" && $5 == "GLOBAL" {sub(/@.*/, "", $8); print $8}'; }
|
||||
FAKE=$(syms "$D/libfakeinput.so")
|
||||
for sym in open openat ioctl read close poll ppoll select stat fstat access scandir; do
|
||||
grep -qx "$sym" <<<"$FAKE" || { echo "::error::libfakeinput.so does not export $sym"; exit 1; }
|
||||
done
|
||||
# The static C++ runtime stays private: every session process preloads this library,
|
||||
# and an exported personality routine takes over other programs' unwinding (shadPS4
|
||||
# aborted in pthread_exit).
|
||||
if grep -qxE '__gxx_personality_v0|__cxa_throw|_Unwind_Resume' <<<"$FAKE"; then
|
||||
echo "::error::libfakeinput.so exports the C++ runtime"; exit 1
|
||||
fi
|
||||
SESSION=$(syms "$D/libblsession.so")
|
||||
for sym in socket bind getsockname setsockopt statfs statvfs; 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"
|
||||
|
||||
- name: Cache the CMake build tree
|
||||
uses: actions/cache@v4
|
||||
@@ -454,46 +307,9 @@ jobs:
|
||||
id: build
|
||||
env:
|
||||
DROIDDECK_BUILD_LABEL: ${{ steps.build-label.outputs.label }}
|
||||
SGDB_API_KEY: ${{ secrets.SGDB_API_KEY }}
|
||||
run: |
|
||||
set -o pipefail
|
||||
python3 tools/release/runtime_inputs.py record
|
||||
./gradlew assembleRelease --console=plain -PndkVersion="$NDK_VERSION" 2>&1 | tee /tmp/build.log
|
||||
python3 tools/release/check_session_assets.py app/build/outputs/apk/release/app-release.apk
|
||||
python3 tools/release/runtime_inputs.py apk app/build/outputs/apk/release/app-release.apk
|
||||
|
||||
- name: Check the store engines' library
|
||||
# The exports Kotlin binds must be in the apk's libdroiddeckstores.so, or the stores fail on
|
||||
# the device with an UnsatisfiedLinkError instead of here. The lockfile check is the
|
||||
# reproducibility guard: cargo must not have had to re-resolve anything.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
NDK="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"
|
||||
python3 tools/release/check_android_libraries.py app/build/outputs/apk/release/app-release.apk \
|
||||
--readelf "$NDK/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-readelf"
|
||||
git diff --stat -- app/src/main/rust/stores/Cargo.lock
|
||||
git diff --exit-code -- app/src/main/rust/stores/Cargo.lock \
|
||||
|| { echo "::error::Cargo.lock changed during the build; commit the regenerated lockfile (uploaded as stores-cargo-lock)"; exit 1; }
|
||||
|
||||
- name: Store engines host unit tests
|
||||
# The crate's #[test] functions are pure host-side logic (manifest parsing, chunk plans,
|
||||
# assembly from a cache, hashes), so a plain `cargo test` on the runner's own architecture
|
||||
# runs them; the Android build above compiles none of them. Informational on this first
|
||||
# run - the point is to learn whether they pass - and bounded so a hang can never hold
|
||||
# the apk. Make it blocking once it is known green.
|
||||
timeout-minutes: 10
|
||||
continue-on-error: true
|
||||
run: |
|
||||
cd app/src/main/rust/stores
|
||||
cargo test --locked 2>&1 | tail -60
|
||||
|
||||
- name: Upload the regenerated Cargo.lock
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: stores-cargo-lock
|
||||
path: app/src/main/rust/stores/Cargo.lock
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: Steam import and game environment tests
|
||||
run: python3 -m unittest discover -s tools/tests -p 'test_*.py'
|
||||
@@ -501,6 +317,27 @@ jobs:
|
||||
- name: Unit tests
|
||||
run: ./gradlew testReleaseUnitTest --console=plain -PndkVersion="$NDK_VERSION"
|
||||
|
||||
- name: Check the native dependency closure
|
||||
run: |
|
||||
set -euo pipefail
|
||||
APK=$(find app/build/outputs/apk -name '*.apk' | head -1)
|
||||
WORK=$(mktemp -d)
|
||||
unzip -q "$APK" 'lib/arm64-v8a/*' -d "$WORK"
|
||||
cd "$WORK/lib/arm64-v8a"
|
||||
SYSTEM="libc.so libm.so libdl.so liblog.so libandroid.so libz.so libvulkan.so libstdc++.so
|
||||
libGLESv2.so libEGL.so libnativewindow.so libjnigraphics.so libaaudio.so
|
||||
libOpenSLES.so libmediandk.so libcamera2ndk.so libsync.so libneuralnetworks.so"
|
||||
fail=0
|
||||
for so in *.so; do
|
||||
for need in $(readelf -d "$so" | sed -n 's/.*NEEDED.*\[\(.*\)\]/\1/p'); do
|
||||
[ -f "$need" ] && continue
|
||||
case " $(echo $SYSTEM) " in *" $need "*) continue ;; esac
|
||||
echo "::error::$so needs $need, which is neither in the apk nor a platform library"
|
||||
fail=1
|
||||
done
|
||||
done
|
||||
exit $fail
|
||||
|
||||
- name: Sign with the testkey (v1 + v2 + v3)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
name: Publish the CI Debian image
|
||||
|
||||
# Pulls the Debian 11 image the x86 preloads build in, once, and publishes it as a release asset
|
||||
# on this repo so build.yml never depends on a registry mirror's rate limit. Run it again (with a
|
||||
# new tag) only to refresh the image; tools/linuxfs/debian-image.env names the one builds use.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag to publish to (a new -rN tag for a refresh; builds pin the one in tools/linuxfs/debian-image.env)'
|
||||
required: true
|
||||
default: 'ci-debian-bullseye-r2'
|
||||
replace:
|
||||
description: 'Overwrite the asset if the release already has one (changes its checksum: re-pin it in debian-image.env)'
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Pull, save and publish
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
REPLACE: ${{ inputs.replace }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
image=public.ecr.aws/docker/library/debian:bullseye
|
||||
# A published image is pinned by checksum in the build; never replace one by accident.
|
||||
if [ "$REPLACE" != true ] && gh release view "$TAG" -R "$GITHUB_REPOSITORY" --json assets -q '.assets[].name' 2>/dev/null | grep -qx debian-bullseye.tar.zst; then
|
||||
echo "::error::$TAG already carries debian-bullseye.tar.zst; publish to a new tag, or set replace and re-pin tools/linuxfs/debian-image.env"
|
||||
exit 1
|
||||
fi
|
||||
for attempt in 1 2 3 4 5 6; do
|
||||
docker pull "$image" && break
|
||||
[ "$attempt" -lt 6 ] || { echo "::error::could not pull $image"; exit 1; }
|
||||
echo "pull attempt $attempt failed (registry busy?); retrying in $((attempt * 30)) s"
|
||||
sleep $((attempt * 30))
|
||||
done
|
||||
docker image inspect "$image" --format 'image id {{.Id}} created {{.Created}}'
|
||||
docker save "$image" | zstd -19 -T0 -o debian-bullseye.tar.zst
|
||||
sha256sum debian-bullseye.tar.zst | tee SHA256SUMS
|
||||
ls -l debian-bullseye.tar.zst
|
||||
if ! gh release view "$TAG" -R "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||
gh release create "$TAG" -R "$GITHUB_REPOSITORY" --title "CI Debian 11 image ($TAG)" --latest=false \
|
||||
--notes "The debian:bullseye image build.yml compiles the x86 preloads in (glibc 2.31), saved with docker save and compressed with zstd. Pulled from public.ecr.aws/docker/library/debian:bullseye on $(date -u +%Y-%m-%d); image id $(docker image inspect "$image" --format '{{.Id}}'). Pinned in tools/linuxfs/debian-image.env."
|
||||
fi
|
||||
gh release upload "$TAG" -R "$GITHUB_REPOSITORY" debian-bullseye.tar.zst SHA256SUMS --clobber
|
||||
{ echo "Published \`$TAG\`:"; echo; echo '```'; cat SHA256SUMS; echo '```'; } >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -13,28 +13,13 @@ permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
concurrency:
|
||||
group: pr-release-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: false
|
||||
queue: max
|
||||
|
||||
jobs:
|
||||
cleanup:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check the current PR state
|
||||
id: state
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
state=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" --jq .state)
|
||||
echo "closed=$([ "$state" = closed ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
|
||||
# A token of the org's "DroidDeck CI Publisher" app, as build.yml's announce job uses.
|
||||
- uses: actions/create-github-app-token@v2
|
||||
if: ${{ steps.state.outputs.closed == 'true' }}
|
||||
id: ci-token
|
||||
with:
|
||||
app-id: ${{ vars.DROIDDECK_CI_APP_ID }}
|
||||
@@ -42,7 +27,6 @@ jobs:
|
||||
owner: Droid-Deck
|
||||
repositories: DroidDeck-CI
|
||||
- name: Delete the test build
|
||||
if: ${{ steps.state.outputs.closed == 'true' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.ci-token.outputs.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
@@ -58,19 +42,6 @@ jobs:
|
||||
done
|
||||
[ "$found" = 1 ] || echo "No test build for PR #$PR_NUMBER"
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
if: ${{ steps.state.outputs.closed == 'true' }}
|
||||
with:
|
||||
ref: main
|
||||
persist-credentials: false
|
||||
- name: Retire the Discord PR announcement
|
||||
if: ${{ steps.state.outputs.closed == 'true' }}
|
||||
env:
|
||||
WEBHOOK: ${{ secrets.DISCORD_PR_BUILDS_WEBHOOK }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: python3 tools/release/pr_discord.py retire
|
||||
|
||||
catalog:
|
||||
needs: cleanup
|
||||
if: ${{ always() }}
|
||||
|
||||
@@ -26,7 +26,6 @@ permissions:
|
||||
concurrency:
|
||||
group: pr-release-${{ github.event_name == 'pull_request_target' && github.event.pull_request.number || github.event_name == 'workflow_run' && github.event.workflow_run.pull_requests[0].number || inputs.pr_number }}
|
||||
cancel-in-progress: false
|
||||
queue: max
|
||||
|
||||
jobs:
|
||||
authorize:
|
||||
@@ -89,8 +88,7 @@ jobs:
|
||||
esac
|
||||
fi
|
||||
pr=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER")
|
||||
jq -e '.state == "open" and .draft == false and .base.ref == "main"' <<<"$pr" >/dev/null ||
|
||||
{ echo "::error::PR #$PR_NUMBER must be open, ready for review, and target main"; exit 1; }
|
||||
jq -e '.state == "open" and .draft == false and .base.ref == "main"' <<<"$pr" >/dev/null
|
||||
head=$(jq -r '.head.sha' <<<"$pr")
|
||||
base=$(jq -r '.base.sha' <<<"$pr")
|
||||
|
||||
@@ -116,39 +114,36 @@ jobs:
|
||||
sleep 30
|
||||
done
|
||||
[[ -n "$run_id" ]] || { echo '::error::The PR build did not finish within 30 minutes'; exit 1; }
|
||||
# GitHub leaves a run's pull_requests empty when its head is in a fork, so the build is
|
||||
# tied to this PR by its head repository, branch and commit instead.
|
||||
run=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$run_id")
|
||||
jq -e --arg head "$head" --arg repo "$(jq -r .head.repo.full_name <<<"$pr")" --arg ref "$(jq -r .head.ref <<<"$pr")" '
|
||||
jq -e --arg head "$head" --arg base "$base" --argjson number "$PR_NUMBER" '
|
||||
.event == "pull_request" and .head_sha == $head and .conclusion == "success" and
|
||||
.head_repository.full_name == $repo and .head_branch == $ref
|
||||
' <<<"$run" >/dev/null ||
|
||||
{ echo "::error::Build run $run_id is not a green build of PR #$PR_NUMBER at ${head:0:7}"; exit 1; }
|
||||
any(.pull_requests[]?; .number == $number and .head.sha == $head and .base.sha == $base)
|
||||
' <<<"$run" >/dev/null
|
||||
|
||||
sdk=${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}
|
||||
tools=$(find "$sdk/build-tools" -mindepth 1 -maxdepth 1 -type d | sort -V | tail -1)
|
||||
# A PR build is made from the PR merged onto main, so it must install over main's own
|
||||
# build. main is what this job checked out; no release has to exist or be marked latest.
|
||||
main_code=$(sed -n 's/^[[:space:]]*versionCode[[:space:]][[:space:]]*\([0-9][0-9]*\)[[:space:]]*$/\1/p' app/build.gradle)
|
||||
[[ "$main_code" =~ ^[0-9]+$ ]] || { echo "::error::No numeric versionCode in main's app/build.gradle"; exit 1; }
|
||||
mkdir -p "$RUNNER_TEMP/pr"
|
||||
stable_tag=$(gh api "repos/$GITHUB_REPOSITORY/releases/latest" --jq .tag_name)
|
||||
mkdir -p "$RUNNER_TEMP/stable" "$RUNNER_TEMP/pr"
|
||||
gh release download "$stable_tag" -R "$GITHUB_REPOSITORY" \
|
||||
-p "DroidDeck-$stable_tag.apk" -D "$RUNNER_TEMP/stable"
|
||||
gh run download "$run_id" -R "$GITHUB_REPOSITORY" -n droiddeck-apk -D "$RUNNER_TEMP/pr"
|
||||
mapfile -t apks < <(find "$RUNNER_TEMP/pr" -type f -name '*.apk')
|
||||
[[ ${#apks[@]} == 1 ]] || { echo '::error::Expected one PR APK'; exit 1; }
|
||||
package_line() {
|
||||
"$tools/aapt" dump badging "$1" | sed -n "s/^package: name='\\([^']*\\)' versionCode='\\([^']*\\)'.*/\\1 \\2/p"
|
||||
}
|
||||
stable=$(package_line "$RUNNER_TEMP/stable/DroidDeck-$stable_tag.apk")
|
||||
candidate=$(package_line "${apks[0]}")
|
||||
[[ "$candidate" =~ ^com\.droiddeck\.launcher\ [0-9]+$ && ${candidate##* } -ge $main_code ]] || {
|
||||
echo "::error::PR package/versionCode '$candidate' cannot install over main (versionCode $main_code)"; exit 1;
|
||||
[[ "$stable" =~ ^com\.droiddeck\.launcher\ [0-9]+$ && "$candidate" =~ ^com\.droiddeck\.launcher\ [0-9]+$ &&
|
||||
${candidate##* } -ge ${stable##* } ]] || {
|
||||
echo "::error::PR package/versionCode '$candidate' cannot install over stable '$stable'"; exit 1;
|
||||
}
|
||||
|
||||
current=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER")
|
||||
jq -e --arg head "$head" --arg base "$base" '
|
||||
.state == "open" and .draft == false and .base.ref == "main" and
|
||||
.head.sha == $head and .base.sha == $base
|
||||
' <<<"$current" >/dev/null ||
|
||||
{ echo "::error::PR #$PR_NUMBER moved on while its build was checked; publish again"; exit 1; }
|
||||
' <<<"$current" >/dev/null
|
||||
{
|
||||
echo "pr_number=$PR_NUMBER"
|
||||
echo "run_id=$run_id"
|
||||
@@ -179,10 +174,6 @@ jobs:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
persist-credentials: false
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: droiddeck-pr-signed-*
|
||||
@@ -312,27 +303,39 @@ jobs:
|
||||
$NEXT
|
||||
EOF
|
||||
)
|
||||
comment=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" --paginate --slurp | \
|
||||
jq "[.[][] | select(.user.login == \"github-actions[bot]\") | select(.body | startswith(\"$mark\"))] | first // {}")
|
||||
id=$(jq -r '.id // empty' <<<"$comment")
|
||||
id=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" --paginate \
|
||||
--jq ".[] | select(.body | startswith(\"$mark\")) | .id" | head -1 || true)
|
||||
if [ -n "$id" ]; then
|
||||
state=$(jq -r '.body | split("\n")[] | select(startswith("<!-- droiddeck-discord: ") and endswith(" -->"))' <<<"$comment")
|
||||
if [ -n "$state" ]; then body=$(printf '%s\n%s' "$body" "$state"); fi
|
||||
gh api -X PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$id" -f body="$body" >/dev/null
|
||||
else
|
||||
gh api -X POST "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" -f body="$body" >/dev/null
|
||||
fi
|
||||
|
||||
- name: Update the Discord PR announcement
|
||||
if: ${{ always() && steps.publish.outcome == 'success' }}
|
||||
# Asked for (the label, a manual run): always announced. Signed on its own after a push:
|
||||
# only a PR's first test build is, and later ones update its release quietly.
|
||||
- name: Post to Discord
|
||||
if: ${{ always() && steps.publish.outcome == 'success' && (needs.authorize.outputs.automatic != 'true' || steps.publish.outputs.first == 'true') }}
|
||||
env:
|
||||
WEBHOOK: ${{ secrets.DISCORD_PR_BUILDS_WEBHOOK }}
|
||||
WEBHOOK: ${{ secrets.DISCORD_BUILDS_WEBHOOK }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ needs.authorize.outputs.pr_number }}
|
||||
HEAD_SHA: ${{ needs.authorize.outputs.head_sha }}
|
||||
RELEASE: ${{ steps.publish.outputs.release }}
|
||||
APK: ${{ steps.publish.outputs.apk }}
|
||||
run: python3 tools/release/pr_discord.py publish
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "$WEBHOOK" ]; then
|
||||
echo "::notice::No DISCORD_BUILDS_WEBHOOK secret; nothing posted"
|
||||
exit 0
|
||||
fi
|
||||
short=${HEAD_SHA:0:7}
|
||||
title=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" --jq .title)
|
||||
what="[#$PR_NUMBER]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/pull/$PR_NUMBER): $title"
|
||||
get=$(printf 'Download: [standard APK](<%s>) · [all variants](<%s>)' "$APK" "$RELEASE")
|
||||
content=$(printf '**New signed PR test build** \`pr-%s\` · \`%s\`\n%s\n_Not merged — for testing._\n%s' "$PR_NUMBER" "$short" "$what" "$get")
|
||||
jq -n --arg content "$content" '{content: $content, allowed_mentions: {parse: []}}' |
|
||||
curl -fsS -H 'Content-Type: application/json' -d @- "$WEBHOOK" -o /dev/null
|
||||
echo "Posted: $content"
|
||||
|
||||
catalog:
|
||||
needs: publish
|
||||
|
||||
@@ -62,59 +62,3 @@ jobs:
|
||||
label: ${{ inputs.version }}
|
||||
artifact: droiddeck-release
|
||||
secrets: inherit
|
||||
|
||||
# The release is made here, from the apks this run signed, so it traces back to its Build APK run.
|
||||
publish:
|
||||
name: publish the release
|
||||
needs: [find, sign]
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: droiddeck-release-*
|
||||
merge-multiple: true
|
||||
path: out
|
||||
|
||||
- name: Publish
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
BUILD_RUN: ${{ needs.find.outputs.run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
notes="docs/releases/$VERSION.md"
|
||||
[ -f "$notes" ] || { echo "::error::$notes is missing: add the release notes first"; exit 1; }
|
||||
if gh release view "$VERSION" -R "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||
echo "::error::release $VERSION already exists"; exit 1
|
||||
fi
|
||||
# The notes' first line is the title; media paths become raw URLs at this release's tag.
|
||||
title=$(sed -n '1s/^# *//p' "$notes")
|
||||
[ -n "$title" ] || title="DroidDeck $VERSION"
|
||||
raw="https://raw.githubusercontent.com/$GITHUB_REPOSITORY/$VERSION/docs/releases/media/"
|
||||
{
|
||||
sed '1{/^# /d;}' "$notes" | sed -E "s#(\]\(|src=\")media/#\1$raw#g"
|
||||
echo
|
||||
echo "---"
|
||||
echo
|
||||
echo "Built by [Build APK run $BUILD_RUN]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$BUILD_RUN) from \`$GITHUB_SHA\`, signed and published by [Release APKs run $GITHUB_RUN_ID]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID)."
|
||||
} > "$RUNNER_TEMP/body.md"
|
||||
( cd out && cat SHA256SUMS-*.txt | sort -k2 > SHA256SUMS.txt && rm SHA256SUMS-*.txt && cat SHA256SUMS.txt )
|
||||
gh release create "$VERSION" -R "$GITHUB_REPOSITORY" out/*.apk out/SHA256SUMS.txt \
|
||||
--target "$GITHUB_SHA" --title "$title" --notes-file "$RUNNER_TEMP/body.md" --latest
|
||||
echo "Published [$title]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/releases/tag/$VERSION)" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# A release made with this run's token fires no release event, so the in-app catalog is refreshed here.
|
||||
catalog:
|
||||
needs: publish
|
||||
uses: ./.github/workflows/update-catalog.yml
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
secrets: inherit
|
||||
@@ -13,7 +13,6 @@ permissions:
|
||||
concurrency:
|
||||
group: update-app-catalog
|
||||
cancel-in-progress: false
|
||||
queue: max
|
||||
|
||||
jobs:
|
||||
catalog:
|
||||
|
||||
@@ -13,13 +13,7 @@ captures/
|
||||
app/src/main/assets/linuxfs/
|
||||
app/src/main/jniLibs/arm64-v8a/libproot.so
|
||||
app/src/main/jniLibs/arm64-v8a/.proot-inputs
|
||||
app/src/main/jniLibs/arm64-v8a/.proot-outputs
|
||||
app/src/main/jniLibs/arm64-v8a/libproot-loader.so
|
||||
app/src/main/jniLibs/arm64-v8a/libdirectaudiorelay.so
|
||||
# The store download engines (app/src/main/rust/stores) are cross-compiled by Gradle into
|
||||
# app/build/rustJniLibs; cargo's own build tree stays next to the crate. Neither is source.
|
||||
app/src/main/rust/stores/target/
|
||||
app/src/main/assets/droiddeck-esync/
|
||||
|
||||
# Kotlin compiler session files (build-tool state, never source).
|
||||
.kotlin/
|
||||
@@ -39,4 +33,3 @@ __pycache__/
|
||||
|
||||
# Local signing with the release key (tools/build_local.sh); holds a password.
|
||||
.signing.env
|
||||
app/src/main/assets/directaudio/
|
||||
@@ -11,30 +11,24 @@ 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). Adreno 6xx is experimental: DirectX 11 uses DXVK 2 and may run, and DirectX 12 games can still crash. Mali, Xclipse, PowerVR, and Adreno 710 are unsupported. No root is required. Allow about 3 GB for the runtime and 1.1 GB more for the desktop and emulators. Install the APK from [Releases](https://github.com/Droid-Deck/DroidDeck/releases), install the Linux runtime, then press **Play** and sign in. Steam downloads on first launch. Install **Desktop & apps** to use the desktop and emulators. The **Store** installs Linux apps and games from Flathub (ARM64 builds) with Flatpak; with additional options to install Appimages and set up scripts. **Stores** (Setup › Stores › "Show Stores in the rail") signs in to GOG, Epic Games and Amazon Games, browses their catalogs and libraries, downloads games into the Games storage and adds each one to the Steam client as a non-Steam game, so it launches through Proton like everything else; the cog on its chip row sets which tab a store opens on and the download speed tier.
|
||||
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).
|
||||
|
||||
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.
|
||||
On Android 12+, if Steam exits without a log, turn off **Restrict child processes** in Developer options.
|
||||
|
||||
## Community
|
||||
|
||||
Join the [DroidDeck Discord](https://discord.gg/JRGAvawjsm) for help, Preview builds, and device reports. Bug reports go in its **#bug-reports** forum; attach the zip from **Share logs** (Setup, or the session drawer) so the logs come with it.
|
||||
Join the [DroidDeck Discord](https://discord.gg/JRGAvawjsm) for help, Preview builds, and device reports. Bug reports go in its **#bug-reports** forum; attach the session folder from `Download/DroidDeck/` so the logs come with it.
|
||||
|
||||
## Build
|
||||
|
||||
Run `tools/build_local.sh` with Docker, Java 17, the Android SDK/NDK, Rust through rustup, GitHub CLI, Python 3, and `zstd` installed. It prepares the same native libraries, pinned components and audio sinks as CI, then checks the APK's assets, JNI exports and dependencies. The APK is at `app/build/outputs/apk/release/app-release.apk`. Set `DROIDDECK_PA13_SOURCE_DIR` to an existing PulseAudio 13.0 source directory to skip downloading it. Set `DROIDDECK_SIGNING_ENV` to a signing environment file to install over a release build; relative keystore paths resolve beside that file. To install on an attached device, run `tools/deploy_local.sh`.
|
||||
|
||||
After preparation, ordinary `./gradlew :app:assembleRelease` builds can reuse the verified inputs. If native sources, component pins or staged files change, packaging stops with instructions to rerun the helper. Generated audio stays under `app/build/`; the tracked base bundle is never modified. `./gradlew -PskipRust=true :app:testDebugUnitTest` needs no native preparation. For an APK without store engines, use `DROIDDECK_SKIP_RUST=1 tools/build_local.sh`; it omits cached engine libraries too.
|
||||
|
||||
Run the helper and preload regression suite with `bash tools/test_local.sh`. On macOS it runs in Linux through Docker, matching the helpers' runtime and CI instead of compiling Linux preloads against macOS headers.
|
||||
Run `tools/build_local.sh` with Docker, Java 17, the Android SDK/NDK, and `zstd` installed. It builds the ARM64 audio sinks from PulseAudio 13.0 and packages them into the APK at `app/build/outputs/apk/release/app-release.apk`. Set `DROIDDECK_PA13_SOURCE_DIR` to an existing PulseAudio 13.0 source directory to skip downloading it. To install the APK on an attached device, run `tools/deploy_local.sh`.
|
||||
|
||||
## Limits
|
||||
|
||||
Compatibility and performance vary by device; hardware validation is limited. Desktop compositing uses software rendering. Firefox sandboxing is reduced under proot. Logs are kept app-private (`files/logs/`, the last 30 sessions); **Share logs** packs the newest session, with the stores and tool logs, into one scrubbed zip.
|
||||
Compatibility and performance vary by device; hardware validation is limited. Desktop compositing uses software rendering. Firefox sandboxing is reduced under proot. See the session logs in `Download/DroidDeck/` when diagnosing problems.
|
||||
|
||||
## Credits and licence
|
||||
|
||||
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.
|
||||
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.
|
||||
+2
-105
@@ -59,19 +59,6 @@ def ciBuild = providers.environmentVariable('GITHUB_ACTIONS').getOrNull() == 'tr
|
||||
def releaseSigner = rootProject.file('keystore/release-signer.sha256').text.trim()
|
||||
// When the source was committed, for "built 3 hours ago": fixed per commit, so it costs no rebuilds.
|
||||
def commitTime = (gitValue(['show', '-s', '--format=%ct', 'HEAD']) ?: '0').with { it.isLong() ? it : '0' }
|
||||
// The desktop package this apk installs (tools/desktop-kde/release.env): published once per tag in
|
||||
// DroidDeck-Components, pinned here with its checksum like gamescope and the esync packs, so the
|
||||
// desktop scripts in the apk always get the package they were made for.
|
||||
def desktopKde = rootProject.file('tools/desktop-kde/release.env').readLines()
|
||||
.findAll { it.contains('=') && !it.startsWith('#') }
|
||||
.collectEntries { def kv = it.split('=', 2); [(kv[0].trim()): kv[1].trim()] }
|
||||
def desktopRemovalSources = new groovy.json.JsonSlurper().parse(file('src/main/assets/desktop-removal/sources.json'))
|
||||
if (desktopRemovalSources.archives[desktopKde.DESKTOP_KDE_TAG] != desktopKde.DESKTOP_KDE_SHA256) {
|
||||
throw new GradleException('Desktop removal inventories do not match the pinned archive')
|
||||
}
|
||||
if (!file('src/main/assets/desktop-removal/versions.txt').readLines().contains(desktopKde.DESKTOP_KDE_TAG)) {
|
||||
throw new GradleException('Regenerate desktop removal inventories for ' + desktopKde.DESKTOP_KDE_TAG)
|
||||
}
|
||||
// tools/release/variants.txt as "package=suffix;...", so the updater picks the apk of the package it runs as.
|
||||
def variantSuffixes = rootProject.file('tools/release/variants.txt').readLines()
|
||||
.findAll { it.trim() && !it.startsWith('#') }
|
||||
@@ -92,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 12
|
||||
versionName "0.3.2"
|
||||
versionCode 9
|
||||
versionName "0.2.0"
|
||||
buildConfigField "String", "BUILD_LABEL", "\"${escapedBuildLabel}\""
|
||||
buildConfigField "String", "SOURCE_COMMIT", "\"${sourceCommit}\""
|
||||
buildConfigField "int", "PR_NUMBER", "${prNumber}"
|
||||
@@ -101,11 +88,6 @@ android {
|
||||
buildConfigField "String", "RELEASE_SIGNER", "\"${releaseSigner}\""
|
||||
buildConfigField "long", "COMMIT_TIME", "${commitTime}L * 1000L"
|
||||
buildConfigField "String", "VARIANT_SUFFIXES", "\"${variantSuffixes}\""
|
||||
buildConfigField "String", "SGDB_API_KEY", "\"${System.getenv("SGDB_API_KEY") ?: project.findProperty("sgdbApiKey") ?: ""}\""
|
||||
buildConfigField "String", "DESKTOP_KDE_TAG", "\"${desktopKde.DESKTOP_KDE_TAG}\""
|
||||
buildConfigField "String", "DESKTOP_KDE_URL", "\"https://github.com/${desktopKde.DESKTOP_KDE_REPO}/releases/download/${desktopKde.DESKTOP_KDE_TAG}/desktop-kde.tar.zst\""
|
||||
buildConfigField "String", "DESKTOP_KDE_SHA256", "\"${desktopKde.DESKTOP_KDE_SHA256}\""
|
||||
buildConfigField "long", "DESKTOP_KDE_SIZE", "${desktopKde.DESKTOP_KDE_SIZE}L"
|
||||
|
||||
ndk {
|
||||
abiFilters 'arm64-v8a'
|
||||
@@ -183,8 +165,6 @@ android {
|
||||
testOptions {
|
||||
// Plain JVM tests: a stray android.util.Log call returns a default instead of throwing.
|
||||
unitTests.returnDefaultValues = true
|
||||
// Robolectric tests start activities whose text is now all string resources.
|
||||
unitTests.includeAndroidResources = true
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
@@ -196,92 +176,10 @@ 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) }
|
||||
|
||||
// Prepared audio lives under build/, so native builds never rewrite the tracked base bundle.
|
||||
// Copying into one asset root also prevents an old base bundle winning a duplicate asset merge.
|
||||
def runtimeAssetsDir = layout.buildDirectory.dir('runtimeAssets')
|
||||
def stageRuntimeAssets = tasks.register('stageRuntimeAssets', Sync) {
|
||||
dependsOn(stageSessionScripts)
|
||||
from(layout.projectDirectory.dir('src/main/assets')) { exclude 'pulseaudio.tzst' }
|
||||
from(layout.buildDirectory.dir('prepared-assets'))
|
||||
into(runtimeAssetsDir)
|
||||
}
|
||||
android.sourceSets.main.assets.setSrcDirs([runtimeAssetsDir])
|
||||
tasks.named('preBuild').configure { dependsOn(stageRuntimeAssets) }
|
||||
|
||||
// Unit tests and previews remain usable without Docker. Packaging an APK requires the complete
|
||||
// runtime payload prepared by the local helper or CI, with matching sources and output hashes.
|
||||
def verifyRuntimeInputs = tasks.register('verifyRuntimeInputs', Exec) {
|
||||
// The session scripts are re-copied into the asset tree by stageSessionScripts; with parallel
|
||||
// task execution this check once hashed a file mid-copy and failed a main build. Hash only
|
||||
// once the staging has finished.
|
||||
mustRunAfter(stageSessionScripts, stageRuntimeAssets)
|
||||
workingDir rootProject.projectDir
|
||||
commandLine 'python3', 'tools/release/runtime_inputs.py', 'verify'
|
||||
}
|
||||
tasks.configureEach { task ->
|
||||
if (task.name in ['packageDebug', 'packageRelease']) task.dependsOn(verifyRuntimeInputs)
|
||||
}
|
||||
|
||||
// The GOG / Epic / Amazon download engines are Rust (app/src/main/rust/stores, ported from
|
||||
// Bannerlator's bl-steam-client crate). cargo-ndk cross-compiles them for arm64-v8a into the build
|
||||
// directory, which is one more jniLibs source below: nothing compiled is committed, the same rule
|
||||
// the preloads, proot and the DirectAudio relay follow. cargo-ndk exports the NDK clang/llvm-ar as
|
||||
// the C compiler for the one C-compiling crate (ring, under rustls) - the step Bannerlator's CI
|
||||
// does by hand - and reads the NDK from ANDROID_NDK_HOME, which is set to the NDK this build pins.
|
||||
// -PskipRust=true builds without the library, for a checkout with no Rust toolchain: the stores
|
||||
// then report themselves unavailable instead of the build failing.
|
||||
def rustCrateDir = layout.projectDirectory.dir('src/main/rust/stores')
|
||||
def rustJniLibsDir = layout.buildDirectory.dir('rustJniLibs')
|
||||
def skipRust = (project.findProperty('skipRust') ?: 'false').toString().toBoolean()
|
||||
def buildRustStores = tasks.register('buildRustStores', Exec) {
|
||||
description = 'Cross-compiles the store download engines (libdroiddeckstores.so) with cargo-ndk.'
|
||||
onlyIf { !skipRust }
|
||||
inputs.dir(rustCrateDir.dir('src'))
|
||||
inputs.files(rustCrateDir.file('Cargo.toml'), rustCrateDir.file('Cargo.lock'))
|
||||
inputs.property('ndkVersion', android.ndkVersion)
|
||||
if (!skipRust) inputs.property('rustToolchain', providers.exec {
|
||||
environment 'PATH', "${System.getProperty('user.home')}/.cargo/bin${File.pathSeparator}${System.getenv('PATH')}"
|
||||
commandLine 'rustup', 'run', 'stable', 'rustc', '-Vv'
|
||||
}.standardOutput.asText)
|
||||
outputs.dir(rustJniLibsDir)
|
||||
workingDir rustCrateDir
|
||||
// The configuration cache serialises the task, so everything it needs is resolved here, not
|
||||
// in doFirst. ~/.cargo/bin is where rustup puts cargo and cargo-ndk; an IDE-launched Gradle
|
||||
// does not always have it on PATH.
|
||||
def cargoBin = "${System.getProperty('user.home')}/.cargo/bin"
|
||||
environment 'PATH', "${cargoBin}${File.pathSeparator}${System.getenv('PATH')}"
|
||||
environment 'ANDROID_NDK_HOME', android.ndkDirectory.absolutePath
|
||||
environment 'CARGO_TERM_COLOR', 'never'
|
||||
if (!skipRust) environment 'RUSTC', providers.exec {
|
||||
commandLine 'rustup', 'which', '--toolchain', 'stable', 'rustc'
|
||||
}.standardOutput.asText.get().trim()
|
||||
// --platform 26 = minSdkVersion: the engines link against the API 26 libc, like the C++ code.
|
||||
// Homebrew's cargo/rustc can precede rustup on macOS and use a different sysroot, despite
|
||||
// rustup reporting the Android target installed. Run both through the selected toolchain.
|
||||
commandLine 'rustup', 'run', 'stable', 'cargo', 'ndk', '--target', 'arm64-v8a', '--platform', '26',
|
||||
'--output-dir', rustJniLibsDir.get().asFile.absolutePath,
|
||||
'build', '--release', '--locked'
|
||||
}
|
||||
// Skipping Rust must omit its library even if another build left a cached .so behind.
|
||||
if (!skipRust) android.sourceSets.main.jniLibs.srcDir(rustJniLibsDir)
|
||||
tasks.named('preBuild').configure { dependsOn(buildRustStores) }
|
||||
|
||||
dependencies {
|
||||
// The runtime tarball is a zstd-compressed tar.
|
||||
implementation 'com.github.luben:zstd-jni:1.5.2-3@aar'
|
||||
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')
|
||||
@@ -292,7 +190,6 @@ dependencies {
|
||||
// copies on. Same versions as Bannerlator so the screen stays a straight copy.
|
||||
implementation 'androidx.compose.material:material-icons-extended'
|
||||
implementation 'io.coil-kt:coil-compose:2.6.0'
|
||||
implementation 'com.caverock:androidsvg-aar:1.4'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
|
||||
// ADB-over-Wi-Fi pairing and authenticated shell client. Users still approve pairing from
|
||||
// Android's Wireless debugging screen; DroidDeck only runs its one fixed setting command.
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,166 @@
|
||||
package com.droiddeck.launcher.agent
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ContentProvider
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.os.Binder
|
||||
import android.os.Bundle
|
||||
import android.os.Process
|
||||
import com.droiddeck.launcher.BuildConfig
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntimeInstaller
|
||||
import com.droiddeck.launcher.session.SessionArtifacts
|
||||
import com.droiddeck.launcher.session.SessionEvents
|
||||
import com.droiddeck.launcher.session.SessionPhase
|
||||
import com.droiddeck.launcher.session.SessionPaths
|
||||
import com.droiddeck.launcher.session.SessionService
|
||||
import com.droiddeck.launcher.session.SessionState
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/** A shell-only control surface, installed only by debug builds. */
|
||||
class AgentBridgeProvider : ContentProvider() {
|
||||
override fun onCreate(): Boolean {
|
||||
val appContext = context?.applicationContext ?: return false
|
||||
Thread({
|
||||
try {
|
||||
SessionArtifacts.finishAbandoned(appContext)
|
||||
} finally {
|
||||
recoveryComplete.countDown()
|
||||
}
|
||||
}, "agent-recover-artifacts").start()
|
||||
return true
|
||||
}
|
||||
|
||||
override fun call(method: String, arg: String?, extras: Bundle?): Bundle {
|
||||
val context = requireNotNull(context)
|
||||
if (Binder.getCallingUid() != Process.myUid() &&
|
||||
context.checkCallingPermission(Manifest.permission.DUMP) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
throw SecurityException("android.permission.DUMP is required")
|
||||
}
|
||||
|
||||
val response = try {
|
||||
when (method) {
|
||||
"state" -> state(context).put("ok", true)
|
||||
"start" -> error("USE_DROIDDECKCTL", "Start sessions with tools/droiddeckctl so Android launches a visible Activity")
|
||||
"stop" -> stop(context)
|
||||
"resume" -> resume(context)
|
||||
else -> error("UNKNOWN_COMMAND", "Unknown agent command '$method'")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
error("COMMAND_FAILED", e.message ?: e.javaClass.simpleName)
|
||||
}
|
||||
return Bundle().apply { putString(RESULT_JSON, response.toString()) }
|
||||
}
|
||||
|
||||
private fun state(context: Context): JSONObject {
|
||||
val runtimeVersion = LinuxRuntimeInstaller.installedVersion(context)
|
||||
val dir = SessionState.logDirectory ?: SessionState.logFile?.parentFile ?: latestSessionDirectory()
|
||||
val session = JSONObject()
|
||||
.put("id", SessionState.sessionId ?: dir?.name ?: JSONObject.NULL)
|
||||
.put("phase", SessionState.phase.name)
|
||||
.put("running", SessionState.running)
|
||||
.put("mode", SessionState.mode)
|
||||
.put("program", SessionState.program ?: JSONObject.NULL)
|
||||
.put("steamUi", SessionState.steamUi ?: JSONObject.NULL)
|
||||
.put("steamUrl", SessionState.steamUrl ?: JSONObject.NULL)
|
||||
.put("suspended", SessionState.suspended)
|
||||
.put("firstFrame", SessionState.firstFrameSeen)
|
||||
.put("output", JSONArray().put(SessionState.outputSize.first).put(SessionState.outputSize.second))
|
||||
.put("refreshHz", SessionState.refreshHz.toDouble())
|
||||
.put("lastTransitionAt", SessionState.lastTransitionAt)
|
||||
.put("guestPid", SessionState.guestPid.takeIf { it > 1 } ?: JSONObject.NULL)
|
||||
.put("installing", SessionState.installing ?: JSONObject.NULL)
|
||||
.put("logDir", dir?.absolutePath ?: JSONObject.NULL)
|
||||
.put("eventsFile", dir?.let { File(it, "events.jsonl").absolutePath } ?: JSONObject.NULL)
|
||||
.put("artifactsAvailable", dir?.isDirectory ?: false)
|
||||
.put("artifactsComplete", dir?.let { File(it, ".complete").isFile } ?: false)
|
||||
|
||||
val failure = if (SessionState.failureCode == null) JSONObject.NULL else JSONObject()
|
||||
.put("code", SessionState.failureCode)
|
||||
.put("message", SessionState.failureMessage ?: JSONObject.NULL)
|
||||
.put("status", SessionState.failureStatus ?: JSONObject.NULL)
|
||||
session.put("failure", failure)
|
||||
|
||||
return JSONObject()
|
||||
.put("schema", 1)
|
||||
.put("build", BuildConfig.BUILD_LABEL)
|
||||
.put("appVersion", context.packageManager.getPackageInfo(context.packageName, 0).versionName)
|
||||
.put("runtime", JSONObject()
|
||||
.put("installed", LinuxRuntime.isInstalled(context))
|
||||
.put("version", runtimeVersion ?: JSONObject.NULL))
|
||||
.put("session", session)
|
||||
}
|
||||
|
||||
private fun latestSessionDirectory(): File? {
|
||||
val parent = LinuxRuntime.debugLogDir()
|
||||
return parent.listFiles { file -> file.isDirectory && file.name.startsWith("session-") }
|
||||
?.maxWithOrNull(compareBy<File> { it.lastModified() }.thenBy { it.name })
|
||||
}
|
||||
|
||||
private fun stop(context: Context): JSONObject {
|
||||
if (SessionState.running) {
|
||||
SessionService.stop(context)
|
||||
return JSONObject().put("ok", true).put("command", "stop")
|
||||
}
|
||||
if (SessionState.phase == SessionPhase.STOPPING) {
|
||||
return JSONObject().put("ok", true).put("command", "stop")
|
||||
}
|
||||
if (SessionState.phase in STARTING_PHASES) {
|
||||
SessionState.stopRequested = true
|
||||
SessionEvents.transition(SessionPhase.STOPPING, "session.stop_requested")
|
||||
return JSONObject().put("ok", true).put("command", "stop")
|
||||
}
|
||||
return error("NO_ACTIVE_SESSION", "There is no session to stop")
|
||||
}
|
||||
|
||||
private fun resume(context: Context): JSONObject {
|
||||
if (!SessionState.running || !SessionState.suspended) {
|
||||
return error("SESSION_NOT_SUSPENDED", "There is no suspended session to resume")
|
||||
}
|
||||
SessionService.resume(context)
|
||||
return JSONObject().put("ok", true).put("command", "resume")
|
||||
}
|
||||
|
||||
private fun error(code: String, message: String) = JSONObject()
|
||||
.put("ok", false)
|
||||
.put("error", JSONObject().put("code", code).put("message", message))
|
||||
|
||||
override fun query(uri: Uri, projection: Array<out String>?, selection: String?, selectionArgs: Array<out String>?, sortOrder: String?): Cursor? = null
|
||||
override fun getType(uri: Uri): String? = null
|
||||
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
|
||||
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = 0
|
||||
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?): Int = 0
|
||||
|
||||
companion object {
|
||||
const val RESULT_JSON = "json"
|
||||
private val recoveryComplete = CountDownLatch(1)
|
||||
|
||||
fun awaitRecovery() {
|
||||
try {
|
||||
if (!recoveryComplete.await(90, TimeUnit.SECONDS)) {
|
||||
throw IllegalStateException("Session artifact recovery did not finish")
|
||||
}
|
||||
} catch (e: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
throw IllegalStateException("Interrupted while recovering session artifacts", e)
|
||||
}
|
||||
}
|
||||
|
||||
private val STARTING_PHASES = setOf(
|
||||
SessionPhase.PREPARING,
|
||||
SessionPhase.INSTALLING_RUNTIME,
|
||||
SessionPhase.STARTING_COMPOSITOR,
|
||||
SessionPhase.STARTING_GUEST,
|
||||
SessionPhase.STARTING_STEAM,
|
||||
)
|
||||
}
|
||||
}
|
||||
-12
@@ -14,7 +14,6 @@ import org.json.JSONObject
|
||||
class AgentStartActivity : Activity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
requestedOrientation = com.droiddeck.launcher.session.SessionPrefs.orientation(this)
|
||||
val request = decodeRequest(intent.getStringExtra(EXTRA_REQUEST))
|
||||
if (request == null) {
|
||||
finish()
|
||||
@@ -39,10 +38,6 @@ class AgentStartActivity : Activity() {
|
||||
Log.e(TAG, "missing agent start request")
|
||||
return null
|
||||
}
|
||||
if (encoded.length > AgentAccess.MAX_REQUEST_CHARS) {
|
||||
Log.w(TAG, "agent start request is too large")
|
||||
return null
|
||||
}
|
||||
return try {
|
||||
JSONObject(String(Base64.decode(encoded, Base64.DEFAULT), Charsets.UTF_8))
|
||||
} catch (e: Exception) {
|
||||
@@ -52,13 +47,6 @@ class AgentStartActivity : Activity() {
|
||||
}
|
||||
|
||||
private fun startSession(request: JSONObject) {
|
||||
try {
|
||||
AgentAccess.requireStart(this, request)
|
||||
} catch (e: AgentException) {
|
||||
Log.w(TAG, e.code)
|
||||
finish()
|
||||
return
|
||||
}
|
||||
val requestedMode = request.optString("mode")
|
||||
val sessionMode = when (requestedMode) {
|
||||
"steam" -> SessionService.MODE_STEAM
|
||||
@@ -8,17 +8,11 @@
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<!-- Store download progress and "installed" / "failed" notices (Android 13+). -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<!-- The session keeps running while the app is not on screen: a foreground service holds the
|
||||
process at perceptible priority, and a high-performance WiFi lock keeps a backgrounded
|
||||
download from being throttled to nothing by the radio's power-save. -->
|
||||
<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
|
||||
@@ -30,16 +24,12 @@
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<!-- Installs DroidDeck's own updates (Updates page) over itself, after the user allows it. -->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<!-- A paired Steam Controller, read over Bluetooth LE out of lizard mode (SteamControllerBle).
|
||||
Granted at install below targetSdk 31; no scan, so no location. -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" />
|
||||
|
||||
<uses-feature android:name="android.hardware.vulkan.version" android:version="0x401000" android:required="true" />
|
||||
<uses-feature android:name="android.hardware.gamepad" android:required="false" />
|
||||
|
||||
<application
|
||||
android:name=".App"
|
||||
android:localeConfig="@xml/locales_config"
|
||||
android:allowBackup="false"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
@@ -66,20 +56,6 @@
|
||||
<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
|
||||
@@ -132,26 +108,10 @@
|
||||
android:screenOrientation="sensorLandscape"
|
||||
android:theme="@style/AppTheme" />
|
||||
|
||||
<!-- A store's own sign-in page (GOG, Epic Games, Amazon Games) in a WebView. -->
|
||||
<activity
|
||||
android:name=".stores.StoreLoginActivity"
|
||||
android:exported="false"
|
||||
android:screenOrientation="sensorLandscape"
|
||||
android:theme="@style/AppTheme" />
|
||||
|
||||
<activity
|
||||
android:name=".agent.AgentStartActivity"
|
||||
android:exported="true"
|
||||
android:excludeFromRecents="true"
|
||||
android:noHistory="true"
|
||||
android:permission="android.permission.DUMP"
|
||||
android:theme="@style/SessionTheme" />
|
||||
|
||||
<activity
|
||||
android:name=".SessionActivity"
|
||||
android:exported="false"
|
||||
android:supportsPictureInPicture="true"
|
||||
android:launchMode="singleTask"
|
||||
android:launchMode="singleTop"
|
||||
android:theme="@style/SessionTheme"
|
||||
android:preferMinimalPostProcessing="true"
|
||||
android:screenOrientation="sensorLandscape"
|
||||
@@ -175,11 +135,6 @@
|
||||
<service
|
||||
android:name=".core.WirelessAdbPairingService"
|
||||
android:exported="false" />
|
||||
<!-- Keeps the process alive while a store (GOG, Epic, Amazon) game downloads. -->
|
||||
<service
|
||||
android:name=".stores.download.StoreDownloadService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
<!-- Hands a zipped session folder to the share sheet; nothing else is exposed. -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
@@ -202,11 +157,5 @@
|
||||
<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>
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,49 +0,0 @@
|
||||
{
|
||||
"archives": {
|
||||
"lxqt-r1": "c2bb860b3b1688287451e2495be5db313ec07f8c8af501dfac2768a9f417bc86",
|
||||
"desktop-kde-r1": "55747e75d0ec055ab7ea1e92a757f3be227cc3205bb9101f5b53cadef6318579",
|
||||
"desktop-kde-r2": "9ba832677b498654136c69d8c8330434d17e4d53cd099acac9bde3ba429b35bf",
|
||||
"emulators-r1": "a1f4806a9663d28f424b4cab1277e0ede80a92e69de5bc063cdc8b45af220164"
|
||||
},
|
||||
"desktopApplications": {
|
||||
"ark-26.08.1-1-aarch64.pkg.tar.xz": "93e2c2b75ff20cd29b4f312ed1979ef6f27255b93232b93bfcdb31239903c1d4",
|
||||
"breeze-6.7.5-1-aarch64.pkg.tar.xz": "1d7f4eb9d8d234caaea23621909f5233d19462c2f058afeeb54bed7b81bd03bd",
|
||||
"breeze-gtk-6.7.5-1-any.pkg.tar.xz": "a0f59bc4fc426879c8f8a97f6dcc707fb7d1b52913da5eb7e9db83777b6957b7",
|
||||
"breeze-icons-6.30.0-1-aarch64.pkg.tar.xz": "4bae4689032501156687a227024e56554820747644953c67fc8d2e2b6b0b045a",
|
||||
"dolphin-26.08.1-1-aarch64.pkg.tar.xz": "fef738ab8132d3eba1156b98c116699ce0a5efcb4cb462efbacdcf4c47f79a23",
|
||||
"firefox-157.0.1-1-aarch64.pkg.tar.xz": "d142fdb8a339a65eca7cea6a2bba5b29202386166abd9d59851d6757c9eb2163",
|
||||
"gwenview-26.08.1-1-aarch64.pkg.tar.xz": "a9dc7d5ab73fa677905d5a57248b1b9b404b6b16e862958cc7443d387f684a47",
|
||||
"kate-26.08.1-1-aarch64.pkg.tar.xz": "acf50710042746f243a3b219a1ae0f788e338f3dd9d75093e8bb671a618b83b6",
|
||||
"kde-cli-tools-6.7.5-1-aarch64.pkg.tar.xz": "af466398a912a994a97fadfe46aff401d88a90d7fdce7e843dc4218098fa38ac",
|
||||
"kde-gtk-config-6.7.5-1-aarch64.pkg.tar.xz": "ba6cb4cb01dbb3f2c247f2f6973ede3b64c0849f89340217b51dfbf11cb8cb5c",
|
||||
"kinfocenter-6.7.5-1-aarch64.pkg.tar.xz": "f3d5416db96b50f9a5b8a284d82ae21d834a8cce646010abdd7087491c399b0b",
|
||||
"konsole-26.08.1-1-aarch64.pkg.tar.xz": "1ecf87c0d11d620ebe4aeafb02788e0d5f4fe9019ad9c5d9f1011c43237dd5ec",
|
||||
"kscreen-6.7.5-1-aarch64.pkg.tar.xz": "8fbaa6fedf93edeba021a5416b551663654919b27c2ee237d696ec0b0ce7d5b7",
|
||||
"kwin-6.7.5-1-aarch64.pkg.tar.xz": "a7535710c74b90359f80306134b2b7581132cbac508dc84b7e07ef275cc8c3fe",
|
||||
"mpv-1:0.41.0-6-aarch64.pkg.tar.xz": "6c89d00736820210a3c7f32af4cf95d155191d463f9df8bd22137c45f30a554a",
|
||||
"plasma-desktop-6.7.5-1-aarch64.pkg.tar.xz": "b266dd42cea965b5ab1cc1ae600877e9d3bc83ec642c61fe1669c4457e641a55",
|
||||
"plasma-integration-6.7.5-1-aarch64.pkg.tar.xz": "daa96da14d9a4d12c0d9c0341e1be6836a9a437c469d61da5e9844d51e2606cf",
|
||||
"plasma-pa-6.7.5-1-aarch64.pkg.tar.xz": "02dbefe9d3e0069b2bb37cac2a6be18b8f0a1887c87704a0716e7a9268eadcd8",
|
||||
"plasma-workspace-6.7.5-1-aarch64.pkg.tar.xz": "3a1455857afaa9ccf342ab6167f0f15794ee9d248b0925398ea6f62f12730a86",
|
||||
"spectacle-1:6.7.5-1-aarch64.pkg.tar.xz": "0c9837412ea6119edef18bcb0a8fceb74634cb35be0ca78b3e1dd1ff2443866e",
|
||||
"systemsettings-6.7.5-1-aarch64.pkg.tar.xz": "b02b6a726857d5125c92d1ec95379216c7a3bc1f187a3b46e88c0d056b29ce70",
|
||||
"xdg-desktop-portal-kde-6.7.5-1-aarch64.pkg.tar.xz": "2c57d17d34d577023eb9cd7b0fca1ba9163549afe60450f6271aa148af099ef2"
|
||||
},
|
||||
"legacyApplications": {
|
||||
"featherpad-1.6.3-1-aarch64.pkg.tar.xz": "fcac55215815c0b712453f9bcc09abfe4b518e36025a111a7a7598ef04913f3d",
|
||||
"labwc-0.20.2-1-aarch64.pkg.tar.xz": "bcd4cc3945165b0fdd33c0117cd7b9dfd05cf8c327326c37f3c3a27ef23ee5ca",
|
||||
"lximage-qt-2.4.0-1-aarch64.pkg.tar.xz": "5d6bdd5472ea6bcb4b993b36df3ab7010bb2e62b36a78a649708fc9a52cb0928",
|
||||
"lxqt-config-2.4.0-1-aarch64.pkg.tar.xz": "4b601d798fc7689ee76787dbef8c8958c02429ee60248cd99d950c919dbefba5",
|
||||
"lxqt-panel-2.4.1-1-aarch64.pkg.tar.xz": "a141e0f8049e73f859d843510c6f45d6ed9087c990bcebf44f5f2430621024c4",
|
||||
"lxqt-qtplugin-2.4.0-3-aarch64.pkg.tar.xz": "61c8f828be73111ee0bd5e3163b0d64c2098dd0189710a190f42be11137695cf",
|
||||
"lxqt-runner-2.4.0-1-aarch64.pkg.tar.xz": "5afa74652a37366ea4e7774cf33379977582fbdca599cef1005ebf637cbcae4b",
|
||||
"lxqt-session-2.4.0-2-aarch64.pkg.tar.xz": "e9c7c8a0011859029622ad202e0d3826e4e5bc8d64659031f6efa211456ff6eb",
|
||||
"lxqt-themes-2.4.0-1-any.pkg.tar.xz": "7ea16c62a1fa70cdac13cb3f8fdcdaad299ae1f5bd514acfda671484755787bf",
|
||||
"lxqt-wayland-session-0.4.1-1-any.pkg.tar.xz": "cd8dc11f5035469d23b6bbd937a90593402428c43de37517412aa43fe1fc6b10",
|
||||
"papirus-icon-theme-20260801-1-any.pkg.tar.xz": "4aae3f47b800a60d6fd360e55adc581408ceaafc481c5a420a681741a3966510",
|
||||
"pcmanfm-qt-2.4.1-1-aarch64.pkg.tar.xz": "7ec1a6b39dd0e4055b39f0e2c0549ea263d83004e1b821fb9e42f1d7f5fff0f9",
|
||||
"qterminal-2.4.0-1-aarch64.pkg.tar.xz": "aa4adc9c9d47641b9452ec8d879038a9cb4762c3b6b5299045fac968fd0ed22d",
|
||||
"swaybg-1.2.2-1-aarch64.pkg.tar.xz": "a26caa16ca7d581b5ca05758677058b4b60af10dc8d83e447ad6824b762b48b8",
|
||||
"wlr-randr-0.5.0-1-aarch64.pkg.tar.xz": "58c3651c6a15dc8f95a310c7e225f759aa766be2fb3c0981a9a8b567854675b3"
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
desktop-kde-r1
|
||||
desktop-kde-r2
|
||||
@@ -0,0 +1,115 @@
|
||||
# DirectAudio for the Linux Steam client (relay build)
|
||||
|
||||
This is the driver for games that run under **Valve's ARM64 Proton inside a Linux
|
||||
runtime on Android** - Bannerlator's linuxfs session with the native Steam client -
|
||||
where the game process is a glibc process and cannot touch Android's audio
|
||||
libraries itself.
|
||||
|
||||
Two pieces, both required:
|
||||
|
||||
| piece | what it is | where it runs |
|
||||
|---|---|---|
|
||||
| `aarch64-unix/winedirectaudio.so` + the two PE shells | the Wine `mmdevapi` driver, glibc build, **no AAudio inside** | in the game, inside the Linux rootfs |
|
||||
| `directaudio-relay` | a small bionic program that owns the AAudio output and microphone streams | on the Android side, under the app's uid |
|
||||
|
||||
They meet over a unix socket. The driver connects, sends its launch config, and
|
||||
gets back two shared-memory rings: one it fills with mixed game audio, one the
|
||||
helper fills with microphone audio. The helper plays and records; the driver
|
||||
keeps doing everything else it always did.
|
||||
|
||||
## Which Proton
|
||||
|
||||
Built inside `ValveSoftware/wine` at `proton_11.0`. The private `mmdevapi`
|
||||
interface it implements is byte-identical across **Proton 11.0 (ARM64)**,
|
||||
**Proton Experimental (ARM64)** and our own Wine-11 layers (checked 2026-09-19),
|
||||
so this one build serves any of them. A future Proton on a Wine 12 base will
|
||||
need a rebuild - the interface is not versioned, and a mismatch is silence, not
|
||||
an error.
|
||||
|
||||
## Installing the driver next to Proton (not into it)
|
||||
|
||||
Steam verifies and updates its Proton depots, so files added under
|
||||
`steamapps/common/Proton .../files/lib/wine/` will not survive. Put the driver
|
||||
in a directory of its own and point Wine at it:
|
||||
|
||||
```
|
||||
<somewhere>/directaudio/lib/wine/aarch64-unix/winedirectaudio.so
|
||||
<somewhere>/directaudio/lib/wine/aarch64-windows/winedirectaudio.drv
|
||||
<somewhere>/directaudio/lib/wine/i386-windows/winedirectaudio.drv
|
||||
```
|
||||
|
||||
and set, for the game process:
|
||||
|
||||
```
|
||||
WINEDLLPATH=<somewhere>/directaudio/lib/wine
|
||||
```
|
||||
|
||||
Wine searches `WINEDLLPATH` entries with the per-arch subdirectories appended,
|
||||
so both the PE shell and the unixlib are found there. In Bannerlator's runtime
|
||||
the natural place to export it is the `bannerlator-proton` compatibility-tool
|
||||
wrapper, which every Proton launch goes through.
|
||||
|
||||
## Selecting the driver
|
||||
|
||||
`mmdevapi` only tries the drivers named in the registry (or its built-in
|
||||
`pulse,alsa,oss,coreaudio` list, which does not know about this one). Set, in the
|
||||
game's prefix:
|
||||
|
||||
```
|
||||
wine reg add "HKCU\Software\Wine\Drivers" /v Audio /d directaudio /f
|
||||
```
|
||||
|
||||
Under Proton the prefix is `compatdata/<appid>/pfx`; run the command with that
|
||||
prefix as `WINEPREFIX` using Proton's own `bin-arm64/wine`, or write the key from
|
||||
the wrapper before it hands off to Proton.
|
||||
|
||||
## Running the helper
|
||||
|
||||
```
|
||||
directaudio-relay --socket <path> [--log]
|
||||
```
|
||||
|
||||
- Run it **under the app's uid**, the way the app already runs its PulseAudio
|
||||
daemon. Android checks `RECORD_AUDIO` against the calling uid, so a helper
|
||||
started any other way can play but cannot record.
|
||||
- Start it **before** the game: the driver asks "is a helper there?" once, when
|
||||
Wine picks an audio driver, and falls back to unavailable if not.
|
||||
- The socket path must be reachable from inside the Linux rootfs. Bannerlator
|
||||
binds the app's files directory and its runtime directory into the session at
|
||||
their own paths, so any socket under either works unchanged on both sides.
|
||||
- The helper serves any number of game processes; each gets its own streams.
|
||||
It exits on `SIGTERM`; a game exiting tears down that game's streams only.
|
||||
|
||||
## Telling the driver where the helper is
|
||||
|
||||
```
|
||||
BANNER_AUDIO_DIRECT_RELAY=<the same socket path>
|
||||
```
|
||||
|
||||
Unset, the driver looks for `$XDG_RUNTIME_DIR/directaudio-relay`.
|
||||
|
||||
## Everything else is unchanged
|
||||
|
||||
All `BANNER_AUDIO_DIRECT_*` knobs mean what they mean in the in-process build:
|
||||
`_PERF`, `_ADAPTIVE`, `_DECAY`, `_MS`, `_MAXMS`, `_BF`, `_MBF`, `_EXCLUSIVE`,
|
||||
`_WATCHDOG`, `_STALL_MS`, `_DECAY_*`, `_LOG`, `_PERIOD_MS`, `_MINPERIOD_MS`, and
|
||||
the live-config mailbox `_RUNTIME` (the helper watches the file; it must be a
|
||||
path the helper can read on the Android side). **`BANNER_AUDIO_DIRECT_MIC=1`**
|
||||
exposes the capture endpoint exactly as before: the helper opens the input
|
||||
stream (`VOICE_COMMUNICATION` preset) when the game connects and makes it hot on
|
||||
the game's first capture start.
|
||||
|
||||
One thing is different: there is one extra hand-off, the ring. The helper's
|
||||
callback pulls a burst from it, so the driver keeps two bursts queued (about 8 ms
|
||||
on a 192-frame device) on top of the AAudio buffer the presets control, and the
|
||||
helper raises that if a callback ever finds the ring short. `get_latency` reports
|
||||
the sum, so a game that sizes its own buffers from it still sees the truth.
|
||||
|
||||
## Proving it is running
|
||||
|
||||
- Helper side: `logcat -s DA-Relay:I` shows `hello from <exe>`, then `open: buffer
|
||||
... ring target ...`, then `capture open` / `capture start` for the mic.
|
||||
- Game side: the session log carries `DirectAudio: relay: connected - ...`.
|
||||
- The game process's `/proc/<pid>/maps` shows `winedirectaudio.so` and **no**
|
||||
`libaaudio.so` (it is in the helper's maps instead). The AudioFlinger track is
|
||||
owned by the helper's pid.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,4 @@
|
||||
directaudio linux-wine11 relay build
|
||||
source 3548a4a730e8d01cb0cb0268d51a2fb8f23dcd33
|
||||
wine ValveSoftware/wine@proton_11.0
|
||||
unixlib.h sha256 fdbed263338d0719c4d12e02b045c23721de33219ec8878e4c2d7ebc9efcbe09
|
||||
@@ -44,11 +44,6 @@ 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)
|
||||
@@ -1,112 +0,0 @@
|
||||
// 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);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -136,7 +136,6 @@ JNIEXPORT jint JNICALL Java_com_termux_terminal_JNI_createSubprocess(
|
||||
if (!arg_utf8) return throw_runtime_exception(env, "GetStringUTFChars() failed for argv");
|
||||
argv[i] = strdup(arg_utf8);
|
||||
(*env)->ReleaseStringUTFChars(env, arg_java_string, arg_utf8);
|
||||
(*env)->DeleteLocalRef(env, arg_java_string);
|
||||
}
|
||||
argv[size] = NULL;
|
||||
}
|
||||
@@ -152,7 +151,6 @@ JNIEXPORT jint JNICALL Java_com_termux_terminal_JNI_createSubprocess(
|
||||
if (!env_utf8) return throw_runtime_exception(env, "GetStringUTFChars() failed for env");
|
||||
envp[i] = strdup(env_utf8);
|
||||
(*env)->ReleaseStringUTFChars(env, env_java_string, env_utf8);
|
||||
(*env)->DeleteLocalRef(env, env_java_string);
|
||||
}
|
||||
envp[size] = NULL;
|
||||
}
|
||||
@@ -162,7 +160,7 @@ JNIEXPORT jint JNICALL Java_com_termux_terminal_JNI_createSubprocess(
|
||||
char const* cmd_utf8 = (*env)->GetStringUTFChars(env, cmd, NULL);
|
||||
int ptm = create_subprocess(env, cmd_utf8, cmd_cwd, argv, envp, &procId, rows, columns);
|
||||
(*env)->ReleaseStringUTFChars(env, cmd, cmd_utf8);
|
||||
(*env)->ReleaseStringUTFChars(env, cwd, cmd_cwd);
|
||||
(*env)->ReleaseStringUTFChars(env, cmd, cmd_cwd);
|
||||
|
||||
if (argv) {
|
||||
for (char** tmp = argv; *tmp; ++tmp) free(*tmp);
|
||||
|
||||
@@ -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(droiddeckwayland SHARED
|
||||
add_library(bannerwayland SHARED
|
||||
src/compositor.c
|
||||
src/vk_present.c
|
||||
src/effects_chain.c
|
||||
@@ -12,12 +12,11 @@ add_library(droiddeckwayland SHARED
|
||||
src/sc_layer.c
|
||||
src/ahb_swapchain.c
|
||||
src/waylandcomp_jni.c
|
||||
src/droiddeck_ext.c
|
||||
src/banner_ext.c
|
||||
src/wl_dmabuf.c
|
||||
src/wl_clipboard.c
|
||||
src/wl_text_input.c
|
||||
src/wl_toplevel_icon.c
|
||||
src/wl_single_pixel.c
|
||||
src/wl_color_mgmt.c
|
||||
src/hdr_compose.c
|
||||
src/blend_pass.c
|
||||
@@ -32,15 +31,14 @@ add_library(droiddeckwayland SHARED
|
||||
generated/wlr-data-control-unstable-v1-protocol.c
|
||||
generated/text-input-unstable-v3-protocol.c
|
||||
generated/xdg-toplevel-icon-v1-protocol.c
|
||||
generated/single-pixel-buffer-v1-protocol.c
|
||||
generated/banner-ahb-v1-protocol.c
|
||||
src/framegen_bridge.c
|
||||
src/framegen_jni.c
|
||||
)
|
||||
# Warnings on for the compositor's own C. They do not fail the build; they are there to be read.
|
||||
target_compile_options(droiddeckwayland PRIVATE
|
||||
target_compile_options(bannerwayland PRIVATE
|
||||
$<$<COMPILE_LANGUAGE:C>:-Wall -Wextra -Wno-unused-parameter -Wno-missing-field-initializers>)
|
||||
target_include_directories(droiddeckwayland PRIVATE
|
||||
target_include_directories(bannerwayland PRIVATE
|
||||
generated
|
||||
prebuilt/include
|
||||
src
|
||||
@@ -56,56 +54,56 @@ target_include_directories(droiddeckwayland 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(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
|
||||
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
|
||||
src/framegen_engine.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
|
||||
${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
|
||||
# The Lossless.dll import (validate / extract / cache) for the app's LsfgNative.
|
||||
${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
|
||||
${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
|
||||
)
|
||||
target_include_directories(droiddeckwayland PRIVATE
|
||||
target_include_directories(bannerwayland 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(droiddeckwayland PRIVATE
|
||||
target_compile_options(bannerwayland PRIVATE
|
||||
$<$<COMPILE_LANGUAGE:CXX>:-std=c++17 -fvisibility=hidden>)
|
||||
target_compile_definitions(droiddeckwayland PRIVATE
|
||||
target_compile_definitions(bannerwayland PRIVATE
|
||||
WINFG_UPSTREAM="${WINFG_UPSTREAM}"
|
||||
WINFG_CHAIN_HASH="${WINFG_CHAIN_HASH}"
|
||||
)
|
||||
target_link_libraries(droiddeckwayland dxbc atomic)
|
||||
target_link_libraries(bannerwayland dxbc atomic)
|
||||
else()
|
||||
message(STATUS "droiddeckwayland: frame-generation engines NOT built in (standalone build): stub")
|
||||
target_sources(droiddeckwayland PRIVATE src/framegen_engine_stub.c)
|
||||
message(STATUS "bannerwayland: frame-generation engines NOT built in (standalone build): stub")
|
||||
target_sources(bannerwayland 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(droiddeckwayland
|
||||
target_link_libraries(bannerwayland
|
||||
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`,
|
||||
`DROIDDECK_WAYLAND_ZERO_COPY=1`) with a compositor-owned AHB pool and one blit, so the
|
||||
`BANNER_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, `DROIDDECK_WAYLAND_UBWC=0` = old linear-only list; see `WAYLAND_RUNTIME.md`
|
||||
driver imports it, `BANNER_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 `DROIDDECK_WAYLAND_ZERO_COPY=1` in the container's environment
|
||||
1. **Layer mode smoke.** Put `BANNER_WAYLAND_ZERO_COPY=1` in the container's environment
|
||||
variables, launch the AIO fullscreen. Expected log lines (tag `layer`):
|
||||
`SurfaceControl "droiddeck_wayland_game" created as a child of the screen surface`,
|
||||
`SurfaceControl "banner_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 droiddeck_wayland_game`
|
||||
2. **Composition type.** With the AIO running: `dumpsys SurfaceFlinger | grep -A3 banner_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 (`DROIDDECK_WAYLAND_ZERO_COPY=1`)
|
||||
## 6. What the prototype in this branch does (`BANNER_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`,
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
/* Generated by wayland-scanner 1.26.0 */
|
||||
|
||||
/*
|
||||
* Copyright © 2022 Simon Ser
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a
|
||||
* copy of this software and associated documentation files (the "Software"),
|
||||
* to deal in the Software without restriction, including without limitation
|
||||
* the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
||||
* and/or sell copies of the Software, and to permit persons to whom the
|
||||
* Software is furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice (including the next
|
||||
* paragraph) shall be included in all copies or substantial portions of the
|
||||
* Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
|
||||
* THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
* DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdint.h>
|
||||
#include "wayland-util.h"
|
||||
|
||||
#ifndef __has_attribute
|
||||
# define __has_attribute(x) 0 /* Compatibility with non-clang compilers. */
|
||||
#endif
|
||||
|
||||
#if (__has_attribute(visibility) || defined(__GNUC__) && __GNUC__ >= 4)
|
||||
#define WL_PRIVATE __attribute__ ((visibility("hidden")))
|
||||
#else
|
||||
#define WL_PRIVATE
|
||||
#endif
|
||||
|
||||
extern const struct wl_interface wl_buffer_interface;
|
||||
|
||||
static const struct wl_interface *single_pixel_buffer_v1_types[] = {
|
||||
&wl_buffer_interface,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
};
|
||||
|
||||
static const struct wl_message wp_single_pixel_buffer_manager_v1_requests[] = {
|
||||
{ "destroy", "", single_pixel_buffer_v1_types + 0 },
|
||||
{ "create_u32_rgba_buffer", "nuuuu", single_pixel_buffer_v1_types + 0 },
|
||||
};
|
||||
|
||||
WL_PRIVATE const struct wl_interface wp_single_pixel_buffer_manager_v1_interface = {
|
||||
"wp_single_pixel_buffer_manager_v1", 1,
|
||||
2, wp_single_pixel_buffer_manager_v1_requests,
|
||||
0, NULL,
|
||||
};
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
/* Generated by wayland-scanner 1.26.0 */
|
||||
|
||||
#ifndef SINGLE_PIXEL_BUFFER_V1_SERVER_PROTOCOL_H
|
||||
#define SINGLE_PIXEL_BUFFER_V1_SERVER_PROTOCOL_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
#include "wayland-server.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
struct wl_client;
|
||||
struct wl_resource;
|
||||
|
||||
/**
|
||||
* @page page_single_pixel_buffer_v1 The single_pixel_buffer_v1 protocol
|
||||
* single pixel buffer factory
|
||||
*
|
||||
* @section page_desc_single_pixel_buffer_v1 Description
|
||||
*
|
||||
* This protocol extension allows clients to create single-pixel buffers.
|
||||
*
|
||||
* Compositors supporting this protocol extension should also support the
|
||||
* viewporter protocol extension. Clients may use viewporter to scale a
|
||||
* single-pixel buffer to a desired size.
|
||||
*
|
||||
* Warning! The protocol described in this file is currently in the testing
|
||||
* phase. Backward compatible changes may be added together with the
|
||||
* corresponding interface version bump. Backward incompatible changes can
|
||||
* only be done by creating a new major version of the extension.
|
||||
*
|
||||
* @section page_ifaces_single_pixel_buffer_v1 Interfaces
|
||||
* - @subpage page_iface_wp_single_pixel_buffer_manager_v1 - global factory for single-pixel buffers
|
||||
* @section page_copyright_single_pixel_buffer_v1 Copyright
|
||||
* <pre>
|
||||
*
|
||||
* Copyright © 2022 Simon Ser
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a
|
||||
* copy of this software and associated documentation files (the "Software"),
|
||||
* to deal in the Software without restriction, including without limitation
|
||||
* the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
||||
* and/or sell copies of the Software, and to permit persons to whom the
|
||||
* Software is furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice (including the next
|
||||
* paragraph) shall be included in all copies or substantial portions of the
|
||||
* Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
|
||||
* THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
* DEALINGS IN THE SOFTWARE.
|
||||
* </pre>
|
||||
*/
|
||||
struct wl_buffer;
|
||||
struct wp_single_pixel_buffer_manager_v1;
|
||||
|
||||
#ifndef WP_SINGLE_PIXEL_BUFFER_MANAGER_V1_INTERFACE
|
||||
#define WP_SINGLE_PIXEL_BUFFER_MANAGER_V1_INTERFACE
|
||||
/**
|
||||
* @page page_iface_wp_single_pixel_buffer_manager_v1 wp_single_pixel_buffer_manager_v1
|
||||
* @section page_iface_wp_single_pixel_buffer_manager_v1_desc Description
|
||||
*
|
||||
* The wp_single_pixel_buffer_manager_v1 interface is a factory for
|
||||
* single-pixel buffers.
|
||||
* @section page_iface_wp_single_pixel_buffer_manager_v1_api API
|
||||
* See @ref iface_wp_single_pixel_buffer_manager_v1.
|
||||
*/
|
||||
/**
|
||||
* @defgroup iface_wp_single_pixel_buffer_manager_v1 The wp_single_pixel_buffer_manager_v1 interface
|
||||
*
|
||||
* The wp_single_pixel_buffer_manager_v1 interface is a factory for
|
||||
* single-pixel buffers.
|
||||
*/
|
||||
extern const struct wl_interface wp_single_pixel_buffer_manager_v1_interface;
|
||||
#endif
|
||||
|
||||
/**
|
||||
* @ingroup iface_wp_single_pixel_buffer_manager_v1
|
||||
* @struct wp_single_pixel_buffer_manager_v1_interface
|
||||
*/
|
||||
struct wp_single_pixel_buffer_manager_v1_interface {
|
||||
/**
|
||||
* destroy the manager
|
||||
*
|
||||
* Destroy the wp_single_pixel_buffer_manager_v1 object.
|
||||
*
|
||||
* The child objects created via this interface are unaffected.
|
||||
*/
|
||||
void (*destroy)(struct wl_client *client,
|
||||
struct wl_resource *resource);
|
||||
/**
|
||||
* create a 1×1 buffer from 32-bit RGBA values
|
||||
*
|
||||
* Create a single-pixel buffer from four 32-bit RGBA values.
|
||||
*
|
||||
* Unless specified in another protocol extension, the RGBA values
|
||||
* use pre-multiplied alpha.
|
||||
*
|
||||
* The width and height of the buffer are 1.
|
||||
*
|
||||
* The r, g, b and a arguments valid range is from UINT32_MIN (0)
|
||||
* to UINT32_MAX (0xffffffff).
|
||||
*
|
||||
* These arguments should be interpreted as a percentage, i.e. -
|
||||
* UINT32_MIN = 0% of the given color component - UINT32_MAX = 100%
|
||||
* of the given color component
|
||||
* @param r value of the buffer's red channel
|
||||
* @param g value of the buffer's green channel
|
||||
* @param b value of the buffer's blue channel
|
||||
* @param a value of the buffer's alpha channel
|
||||
*/
|
||||
void (*create_u32_rgba_buffer)(struct wl_client *client,
|
||||
struct wl_resource *resource,
|
||||
uint32_t id,
|
||||
uint32_t r,
|
||||
uint32_t g,
|
||||
uint32_t b,
|
||||
uint32_t a);
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* @ingroup iface_wp_single_pixel_buffer_manager_v1
|
||||
*/
|
||||
#define WP_SINGLE_PIXEL_BUFFER_MANAGER_V1_DESTROY_SINCE_VERSION 1
|
||||
/**
|
||||
* @ingroup iface_wp_single_pixel_buffer_manager_v1
|
||||
*/
|
||||
#define WP_SINGLE_PIXEL_BUFFER_MANAGER_V1_CREATE_U32_RGBA_BUFFER_SINCE_VERSION 1
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
@@ -1,76 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<protocol name="single_pixel_buffer_v1">
|
||||
<copyright>
|
||||
Copyright © 2022 Simon Ser
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a
|
||||
copy of this software and associated documentation files (the "Software"),
|
||||
to deal in the Software without restriction, including without limitation
|
||||
the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
||||
and/or sell copies of the Software, and to permit persons to whom the
|
||||
Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice (including the next
|
||||
paragraph) shall be included in all copies or substantial portions of the
|
||||
Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
|
||||
THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
</copyright>
|
||||
|
||||
<description summary="single pixel buffer factory">
|
||||
This protocol extension allows clients to create single-pixel buffers.
|
||||
|
||||
Compositors supporting this protocol extension should also support the
|
||||
viewporter protocol extension. Clients may use viewporter to scale a
|
||||
single-pixel buffer to a desired size.
|
||||
|
||||
Warning! The protocol described in this file is currently in the testing
|
||||
phase. Backward compatible changes may be added together with the
|
||||
corresponding interface version bump. Backward incompatible changes can
|
||||
only be done by creating a new major version of the extension.
|
||||
</description>
|
||||
|
||||
<interface name="wp_single_pixel_buffer_manager_v1" version="1">
|
||||
<description summary="global factory for single-pixel buffers">
|
||||
The wp_single_pixel_buffer_manager_v1 interface is a factory for
|
||||
single-pixel buffers.
|
||||
</description>
|
||||
|
||||
<request name="destroy" type="destructor">
|
||||
<description summary="destroy the manager">
|
||||
Destroy the wp_single_pixel_buffer_manager_v1 object.
|
||||
|
||||
The child objects created via this interface are unaffected.
|
||||
</description>
|
||||
</request>
|
||||
|
||||
<request name="create_u32_rgba_buffer">
|
||||
<description summary="create a 1×1 buffer from 32-bit RGBA values">
|
||||
Create a single-pixel buffer from four 32-bit RGBA values.
|
||||
|
||||
Unless specified in another protocol extension, the RGBA values use
|
||||
pre-multiplied alpha.
|
||||
|
||||
The width and height of the buffer are 1.
|
||||
|
||||
The r, g, b and a arguments valid range is from UINT32_MIN (0)
|
||||
to UINT32_MAX (0xffffffff).
|
||||
|
||||
These arguments should be interpreted as a percentage, i.e.
|
||||
- UINT32_MIN = 0% of the given color component
|
||||
- UINT32_MAX = 100% of the given color component
|
||||
</description>
|
||||
<arg name="id" type="new_id" interface="wl_buffer"/>
|
||||
<arg name="r" type="uint" summary="value of the buffer's red channel"/>
|
||||
<arg name="g" type="uint" summary="value of the buffer's green channel"/>
|
||||
<arg name="b" type="uint" summary="value of the buffer's blue channel"/>
|
||||
<arg name="a" type="uint" summary="value of the buffer's alpha channel"/>
|
||||
</request>
|
||||
</interface>
|
||||
</protocol>
|
||||
@@ -2,8 +2,8 @@
|
||||
* See ahb_swapchain.h and ZERO_COPY_SPIKE.md. */
|
||||
#define _GNU_SOURCE
|
||||
#include "ahb_swapchain.h"
|
||||
#include "droiddeck_color.h"
|
||||
#include "droiddeck_ext.h"
|
||||
#include "banner_color.h"
|
||||
#include "banner_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 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)
|
||||
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)
|
||||
|
||||
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);
|
||||
*droiddeck_dmabuf_ahb_slot(ab->b) = NULL;
|
||||
*banner_dmabuf_ahb_slot(ab->b) = NULL;
|
||||
AHardwareBuffer_release(ab->ahb);
|
||||
droiddeck_dmabuf_unref(ab->b);
|
||||
banner_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)
|
||||
droiddeck_release_buffer(ab->surface, ab->resource, ab->surface != NULL, ab->deferred_ns);
|
||||
banner_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 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) {
|
||||
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) {
|
||||
close(fd);
|
||||
} else {
|
||||
if (!g_import_failed_logged) {
|
||||
g_import_failed_logged = 1;
|
||||
droiddeck_log("layer", "zero-copy: DMA_BUF_IOCTL_IMPORT_SYNC_FILE failed (%s): releases wait for the display's fence here instead",
|
||||
banner_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 && *droiddeck_dmabuf_ahb_slot((struct dmabuf_buffer *)b) != NULL;
|
||||
return b && *banner_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 ? *droiddeck_dmabuf_ahb_slot(b) : NULL;
|
||||
struct ahb_buf *ab = b ? *banner_dmabuf_ahb_slot(b) : NULL;
|
||||
if (!ab) return -1;
|
||||
int dmabuf_fd = droiddeck_dmabuf_fd(b);
|
||||
int dmabuf_fd = banner_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 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) {
|
||||
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) {
|
||||
acquire = exp.fd;
|
||||
} else if (dmabuf_fd >= 0) {
|
||||
if (!g_export_failed_logged) {
|
||||
g_export_failed_logged = 1;
|
||||
droiddeck_log("layer", "zero-copy: DMA_BUF_IOCTL_EXPORT_SYNC_FILE failed (%s): waiting for each frame on the CPU instead",
|
||||
banner_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 ? droiddeck_surface_color(s) : NULL, ab->format);
|
||||
s ? banner_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;
|
||||
droiddeck_surface_describe(s, name, sizeof(name));
|
||||
droiddeck_log("layer", "zero-copy: presenting %s without a copy", name);
|
||||
banner_surface_describe(s, name, sizeof(name));
|
||||
banner_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 ? *droiddeck_dmabuf_ahb_slot(b) : NULL;
|
||||
struct ahb_buf *ab = b ? *banner_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 ? *droiddeck_dmabuf_ahb_slot((struct dmabuf_buffer *)b) : NULL;
|
||||
struct ahb_buf *ab = b ? *banner_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 = droiddeck_dmabuf_from_resource(buffer);
|
||||
struct dmabuf_buffer *b = banner_dmabuf_from_resource(buffer);
|
||||
uint64_t modifier = ((uint64_t)mod_hi << 32) | mod_lo;
|
||||
if (!b) {
|
||||
droiddeck_log("layer", "zero-copy: %s attached an AHardwareBuffer to a non-dma-buf wl_buffer, ignored", droiddeck_client_name(c));
|
||||
banner_log("layer", "zero-copy: %s attached an AHardwareBuffer to a non-dma-buf wl_buffer, ignored", banner_client_name(c));
|
||||
close(sock);
|
||||
return;
|
||||
}
|
||||
void **slot = droiddeck_dmabuf_ahb_slot(b);
|
||||
void **slot = banner_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) {
|
||||
droiddeck_log("layer", "zero-copy: receiving %s's AHardwareBuffer failed (%s)", droiddeck_client_name(c),
|
||||
banner_log("layer", "zero-copy: receiving %s's AHardwareBuffer failed (%s)", banner_client_name(c),
|
||||
pr > 0 ? "recvHandleFromUnixSocket" : "nothing on the socket");
|
||||
return;
|
||||
}
|
||||
AHardwareBuffer_Desc d;
|
||||
AHardwareBuffer_describe(ahb, &d);
|
||||
int bw, bh;
|
||||
droiddeck_dmabuf_size(b, &bw, &bh);
|
||||
banner_dmabuf_size(b, &bw, &bh);
|
||||
if ((int)d.width != bw || (int)d.height != bh || (int)width != bw || (int)height != 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);
|
||||
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);
|
||||
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;
|
||||
droiddeck_dmabuf_ref(b);
|
||||
banner_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};
|
||||
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)"
|
||||
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)"
|
||||
: 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;
|
||||
droiddeck_log("layer", "zero-copy: %s bound banner_ahb_v1 version %u (%s)", droiddeck_client_name(c), ver,
|
||||
banner_log("layer", "zero-copy: %s bound banner_ahb_v1 version %u (%s)", banner_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) droiddeck_log("layer", "zero-copy switched %s from the drawer, but no display layer is available on this device: no change",
|
||||
if (live) banner_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)
|
||||
droiddeck_log("layer", "zero-copy switched %s from the drawer: %d bound program%s told to rebuild their swapchains%s",
|
||||
banner_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
|
||||
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"
|
||||
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"
|
||||
: "the drawer's Zero-copy presentation switch turns it on live");
|
||||
if (live) wl_display_flush_clients(droiddeck_get_display());
|
||||
droiddeck_request_redraw();
|
||||
if (live) wl_display_flush_clients(banner_get_display());
|
||||
banner_request_redraw();
|
||||
}
|
||||
|
||||
void ahb_swapchain_init(struct wl_display *display) {
|
||||
wl_list_init(&g_bufs);
|
||||
wl_list_init(&g_clients);
|
||||
if (!sc_layer_available()) {
|
||||
droiddeck_log("layer", "zero-copy: no display layer on this device (see the line above): banner_ahb_v1 not advertised");
|
||||
banner_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) {
|
||||
droiddeck_log("error", "zero-copy: release pipe creation failed (%s); zero-copy presents disabled", strerror(errno));
|
||||
banner_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)) {
|
||||
droiddeck_log("error", "zero-copy: banner_ahb_v1 global creation failed");
|
||||
banner_log("error", "zero-copy: banner_ahb_v1 global creation failed");
|
||||
return;
|
||||
}
|
||||
g_advertised = 1;
|
||||
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)");
|
||||
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)");
|
||||
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, DROIDDECK_WAYLAND_ZERO_COPY=1 at launch, the drawer's "Zero-copy
|
||||
* Zero-copy game frames (layer mode, BANNER_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 *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);
|
||||
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);
|
||||
/* 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 droiddeck_release_buffer(struct surface *s, struct wl_resource *buffer, int paced, int64_t since_ns);
|
||||
void banner_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 droiddeck_request_redraw(void);
|
||||
void banner_request_redraw(void);
|
||||
/* "<title>" (program) of the window a surface belongs to, for the log. */
|
||||
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);
|
||||
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);
|
||||
|
||||
#endif
|
||||
+47
-47
@@ -1,5 +1,5 @@
|
||||
#ifndef DROIDDECK_COLOR_H
|
||||
#define DROIDDECK_COLOR_H
|
||||
#ifndef BANNER_COLOR_H
|
||||
#define BANNER_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 DROIDDECK_WAYLAND_HDR=1 in their
|
||||
* - the container's / game's "HDR output" setting is on, or BANNER_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 (droiddeck_color_set_output) flips between that and the same
|
||||
* In game, the drawer's HDR output switch (banner_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.
|
||||
* DROIDDECK_WAYLAND_HDR=force skips the display check only (testing the negotiation on an SDR panel;
|
||||
* BANNER_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 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 */
|
||||
#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 */
|
||||
|
||||
/* 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 droiddeck_color {
|
||||
struct banner_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 droiddeck_color {
|
||||
};
|
||||
|
||||
/* ---- app -> compositor (JNI, any thread) */
|
||||
/* mode 0 = off, 1 = DROIDDECK_WAYLAND_HDR=1, 2 = DROIDDECK_WAYLAND_HDR=force (testing). source names where
|
||||
/* mode 0 = off, 1 = BANNER_WAYLAND_HDR=1, 2 = BANNER_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 droiddeck_color_set_request(int mode, const char *source, int dxvk_hdr, int zero_copy_forced);
|
||||
void banner_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 droiddeck_color_set_display(int id, const char *name, const char *formats, int hdr10, float max_lum,
|
||||
void banner_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 droiddeck_color_ratio_sample(float ratio, int listener);
|
||||
void banner_color_ratio_sample(float ratio, int listener);
|
||||
/* Milliseconds since an HDR frame last went onto a display layer; -1 = none this session. */
|
||||
int droiddeck_color_last_frame_age_ms(void);
|
||||
int banner_color_last_frame_age_ms(void);
|
||||
/* -1 = the compositor has not decided yet, 0 = closed, 1 = open. */
|
||||
int droiddeck_color_gate_state(void);
|
||||
int banner_color_gate_state(void);
|
||||
/* The session is ending: write the summary line ("HDR on screen: …"). */
|
||||
void droiddeck_color_session_end(void);
|
||||
void banner_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 droiddeck_color_hdr_on_screen(void);
|
||||
int banner_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 droiddeck_color_hdr_on_screen), 2 = HDR frames on screen but the display has given
|
||||
* with headroom (as banner_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 droiddeck_color_hdr_state(void);
|
||||
int banner_color_hdr_state(void);
|
||||
/* Nits SDR content is placed at inside an HDR picture (default 203, BT.2408); any thread. */
|
||||
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
|
||||
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
|
||||
* the compositor starts, so the Vulkan instance can enable VK_EXT_swapchain_colorspace for it. */
|
||||
int droiddeck_color_requested(void);
|
||||
int banner_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 droiddeck_host_hdr_output); logs the flip and redraws. */
|
||||
void droiddeck_color_set_output(int on);
|
||||
* thread (the app posts it through banner_host_hdr_output); logs the flip and redraws. */
|
||||
void banner_color_set_output(int on);
|
||||
/* 1 = HDR output on (any thread). */
|
||||
int droiddeck_color_output(void);
|
||||
int banner_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 droiddeck_color_tonemapped_on_screen(void);
|
||||
int banner_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 droiddeck_color_env_sample(int thermal, float headroom, int brightness, int bmode);
|
||||
void banner_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 droiddeck_color_desired_headroom(const struct droiddeck_color *c, char *why, size_t n);
|
||||
float banner_color_desired_headroom(const struct banner_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 droiddeck_color_note_headroom_request(float ratio);
|
||||
void banner_color_note_headroom_request(float ratio);
|
||||
/* Display.getHighestHdrSdrRatio() (Android 16+; <= 0 = not reported). Any thread; logged when it arrives. */
|
||||
void droiddeck_color_set_highest_ratio(float ratio);
|
||||
void banner_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 droiddeck_color_screen_headroom(char *why, size_t n);
|
||||
float banner_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 droiddeck_color_init(struct wl_display *display);
|
||||
void banner_color_init(struct wl_display *display);
|
||||
/* The gate is open: the 10-bit dma-buf formats may be advertised, descriptions may exist. */
|
||||
int droiddeck_color_hdr_open(void);
|
||||
int banner_color_hdr_open(void);
|
||||
/* wl_surface.commit: the pending image description (if any request touched it) becomes current. */
|
||||
void droiddeck_color_commit(struct wl_resource *surface);
|
||||
void banner_color_commit(struct wl_resource *surface);
|
||||
/* The surface's current image description; NULL = none (sRGB, today's default). */
|
||||
const struct droiddeck_color *droiddeck_color_of(struct wl_resource *surface);
|
||||
const struct banner_color *banner_color_of(struct wl_resource *surface);
|
||||
/* A program disconnected: if it presented HDR, its part of the summary is written now. */
|
||||
void droiddeck_color_client_gone(struct wl_client *client);
|
||||
void banner_color_client_gone(struct wl_client *client);
|
||||
/* The 10 s summary tick (one `color` line when anything HDR happened in the window). */
|
||||
void droiddeck_color_stats_tick(void);
|
||||
void banner_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 droiddeck_color_frame_copied(const char *who, const char *reason);
|
||||
void banner_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 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
|
||||
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
|
||||
* cannot carry HDR) - NOT HDR */
|
||||
};
|
||||
/* ahb_format = the buffer's AHARDWAREBUFFER_FORMAT_* where one exists (0 otherwise). */
|
||||
void droiddeck_color_frame_shown(const struct droiddeck_color *c, int path, uint32_t ahb_format);
|
||||
void banner_color_frame_shown(const struct banner_color *c, int path, uint32_t ahb_format);
|
||||
/* "RGBA1010102 (10-bit)" etc. for an AHARDWAREBUFFER_FORMAT_* value (static buffer). */
|
||||
const char *droiddeck_ahb_format_name(uint32_t format);
|
||||
const char *banner_ahb_format_name(uint32_t format);
|
||||
|
||||
#endif
|
||||
+13
-14
@@ -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
|
||||
* droiddeck_host_* ; the messages are queued under a mutex and a pipe byte wakes the wl event
|
||||
* banner_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 "droiddeck_ext.h"
|
||||
#include "banner_ext.h"
|
||||
#include "ahb_swapchain.h"
|
||||
#include "droiddeck_color.h"
|
||||
#include "banner_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 droiddeck_host_clipboard_text(const char *utf8, int len) {
|
||||
void banner_host_clipboard_text(const char *utf8, int len) {
|
||||
host_post(HOST_CLIPBOARD, utf8, len > 0 ? (size_t)len : 0, 0, 0);
|
||||
}
|
||||
void droiddeck_host_text_commit(const char *utf8, int len) {
|
||||
void banner_host_text_commit(const char *utf8, int len) {
|
||||
host_post(HOST_TEXT_COMMIT, utf8, len > 0 ? (size_t)len : 0, 0, 0);
|
||||
}
|
||||
void droiddeck_host_text_preedit(const char *utf8, int len, int cursor_begin, int cursor_end) {
|
||||
void banner_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 droiddeck_host_text_delete(int before, int after) {
|
||||
void banner_host_text_delete(int before, int after) {
|
||||
host_post(HOST_TEXT_DELETE, NULL, 0, before, after);
|
||||
}
|
||||
void droiddeck_host_zero_copy(int on, int live) {
|
||||
void banner_host_zero_copy(int on, int live) {
|
||||
host_post(HOST_ZERO_COPY, NULL, 0, on, live);
|
||||
}
|
||||
void droiddeck_host_hdr_output(int on) {
|
||||
void banner_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:
|
||||
droiddeck_color_set_output(m->a);
|
||||
banner_color_set_output(m->a);
|
||||
break;
|
||||
}
|
||||
free(m->text);
|
||||
free(m);
|
||||
}
|
||||
wl_display_flush_clients(droiddeck_get_display());
|
||||
wl_display_flush_clients(banner_get_display());
|
||||
return 0;
|
||||
}
|
||||
|
||||
void droiddeck_ext_init(struct wl_display *display) {
|
||||
void banner_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,9 +118,8 @@ void droiddeck_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, "DroidDeckWayland", "host queue pipe failed");
|
||||
__android_log_print(ANDROID_LOG_ERROR, "BannerWayland", "host queue pipe failed");
|
||||
clipboard_init(display);
|
||||
text_input_init(display);
|
||||
toplevel_icon_init(display);
|
||||
single_pixel_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)
|
||||
* 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" /* droiddeck_log, vkp_gpu_name */
|
||||
#include "vk_present.h" /* banner_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;
|
||||
droiddeck_log("gpu", "alpha composition ready on %s: translucent surfaces are blended over what is under them",
|
||||
banner_log("gpu", "alpha composition ready on %s: translucent surfaces are blended over what is under them",
|
||||
vkp_gpu_name());
|
||||
return 0;
|
||||
fail:
|
||||
objects_destroy();
|
||||
droiddeck_log("error", "gpu: the alpha composition pass could not be built on this driver; translucent surfaces "
|
||||
banner_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 @@
|
||||
/*
|
||||
* droiddeck-wayland - the embedded compositor of the Wayland display path.
|
||||
* bannerlator-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 "droiddeck_ext.h"
|
||||
#include "droiddeck_color.h"
|
||||
#include "banner_ext.h"
|
||||
#include "banner_color.h"
|
||||
#include "compositor_internal.h"
|
||||
|
||||
#define WLOGI(...) __android_log_print(ANDROID_LOG_INFO, "DroidDeckWayland", __VA_ARGS__)
|
||||
#define WLOGE(...) __android_log_print(ANDROID_LOG_ERROR, "DroidDeckWayland", __VA_ARGS__)
|
||||
#define WLOGI(...) __android_log_print(ANDROID_LOG_INFO, "BannerWayland", __VA_ARGS__)
|
||||
#define WLOGE(...) __android_log_print(ANDROID_LOG_ERROR, "BannerWayland", __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 droiddeck_log(const char *tag, const char *fmt, ...) {
|
||||
void banner_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,
|
||||
"DroidDeck Wayland session\n"
|
||||
"=========================\n"
|
||||
"Bannerlator Wayland session\n"
|
||||
"===========================\n"
|
||||
"Started %s\n"
|
||||
"Display Wayland: programs draw through the DroidDeck compositor.\n"
|
||||
" gamescope and the desktop start their own Xwayland for X11 programs.\n"
|
||||
"Display Wayland: Windows programs draw through the Bannerlator compositor.\n"
|
||||
" No X server is used for this session.\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;
|
||||
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) */
|
||||
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) */
|
||||
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;
|
||||
droiddeck_log("program", "connected over Wayland: %s (pid %d)", ci->name, (int)ci->pid);
|
||||
banner_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 (DROIDDECK_WAYLAND_ZERO_COPY=1 in the container's environment): a single
|
||||
/* Experimental layer mode (BANNER_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.
|
||||
* DROIDDECK_WAYLAND_UBWC=0 in the container's environment turns the advertisement off (A/B switch). Set
|
||||
* BANNER_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 (DROIDDECK_WAYLAND_NO_RENDER_NODE=1): name no DRM device in the dma-buf feedback, as a phone
|
||||
/* Debug (BANNER_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 droiddeck_cursor_serial(void) {
|
||||
int banner_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 droiddeck_cursor_snapshot(int *out, int cap) {
|
||||
int banner_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));
|
||||
droiddeck_log("window", "opened %s %dx%d at %d,%d%s", name, w, h, s->x, s->y,
|
||||
banner_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));
|
||||
droiddeck_log("window", "closed %s", name);
|
||||
banner_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;
|
||||
droiddeck_text_input_refocus();
|
||||
banner_text_input_refocus();
|
||||
}
|
||||
|
||||
/* Let go of the surface's dmabuf content. paced = 1: the buffer was replaced, give it back on the
|
||||
@@ -745,33 +745,16 @@ static void take_shm(struct surface *s, struct wl_shm_buffer *shm, struct wl_res
|
||||
if (ci && ci->asked_feedback) ci->shm_frames++; /* since it asked for GPU buffers */
|
||||
}
|
||||
|
||||
/* A single-pixel buffer (wl_single_pixel.c) as a 1x1 image, which the surface's viewport stretches. */
|
||||
static void take_pixel(struct surface *s, const uint8_t bgra[4], struct wl_resource *buffer) {
|
||||
if (s->shm_img && (vkp_image_width(s->shm_img) != 1 || vkp_image_height(s->shm_img) != 1)) {
|
||||
vkp_image_destroy(s->shm_img);
|
||||
s->shm_img = NULL;
|
||||
}
|
||||
if (!s->shm_img) s->shm_img = vkp_image_create_shm(1, 1);
|
||||
if (s->shm_img) vkp_image_upload_shm(s->shm_img, bgra, 4);
|
||||
wl_buffer_send_release(buffer);
|
||||
s->buf_w = 1;
|
||||
s->buf_h = 1;
|
||||
s->buf_alpha = bgra[3] != 0xff;
|
||||
s->has_content = s->shm_img != NULL;
|
||||
}
|
||||
|
||||
/* The window the app's performance HUD follows: the latest one at least as big as the last to
|
||||
* start presenting GPU frames (take_dmabuf) (X11 binds the HUD to the _MESA_DRV window and counts X presents instead). JNI upcalls. */
|
||||
static struct surface *g_hud_surface;
|
||||
/* When the HUD's window last presented a frame (now_ns), to let a replacement take over. */
|
||||
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 droiddeck_on_game_surface(const char *window, const char *gpu); /* window NULL = gone */
|
||||
extern void droiddeck_on_game_frame(void);
|
||||
extern void banner_on_game_surface(const char *window, const char *gpu); /* window NULL = gone */
|
||||
extern void banner_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 droiddeck_on_game_program(int pid, const char *program);
|
||||
extern void banner_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) {
|
||||
@@ -802,7 +785,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));
|
||||
droiddeck_log("vulkan", "%s changed its frame size: %dx%d -> %dx%d", name,
|
||||
banner_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;
|
||||
@@ -818,18 +801,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. */
|
||||
droiddeck_log("vulkan", "%s is presenting GPU frames through Wayland: %dx%d, format %c%c%c%c, %s (%s)",
|
||||
banner_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))
|
||||
droiddeck_log("vulkan", "%s is presenting GPU frames through Wayland: %dx%d on its own display layer only "
|
||||
banner_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
|
||||
droiddeck_log("error", "could not import GPU frames from %s (%dx%d, modifier %#llx)",
|
||||
banner_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
|
||||
@@ -845,21 +828,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;
|
||||
droiddeck_on_game_surface(name, vkp_gpu_name());
|
||||
banner_on_game_surface(name, vkp_gpu_name());
|
||||
struct client_info *ci = client_info_of(wl_resource_get_client(s->resource));
|
||||
if (ci) droiddeck_on_game_program((int)ci->pid, strncmp(ci->name, "pid ", 4) ? ci->name : "");
|
||||
if (ci) banner_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 && droiddeck_color_hdr_open()) {
|
||||
if (b->format != s->hdr_fmt_logged && banner_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));
|
||||
droiddeck_log("color", "%s presents %dx%d buffers in %c%c%c%c (%s), %s%s%s; %s", name, b->width, b->height,
|
||||
banner_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 ? droiddeck_ahb_format_name(af) : "",
|
||||
af ? ", gralloc " : ", no gralloc buffer (copy path only)", af ? banner_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)");
|
||||
@@ -869,23 +852,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; droiddeck_on_game_frame(); }
|
||||
if (s == g_hud_surface) { g_hud_last_ns = now_ns(); banner_on_game_frame(); }
|
||||
}
|
||||
|
||||
/* ---- hooks for ahb_swapchain.c (zero-copy layers) */
|
||||
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) {
|
||||
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) {
|
||||
if (paced && s) release_buffer(s, buffer, since_ns);
|
||||
else { wl_buffer_send_release(buffer); perf_note_release(since_ns); }
|
||||
}
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ wl_surface */
|
||||
@@ -1099,7 +1082,6 @@ static void surface_commit(struct wl_client *c, struct wl_resource *r) {
|
||||
struct wl_resource *buffer = s->pending_buffer;
|
||||
struct dmabuf_buffer *db = get_dmabuf(buffer);
|
||||
struct wl_shm_buffer *shm = buffer && !db ? wl_shm_buffer_get(buffer) : NULL;
|
||||
uint8_t pixel[4];
|
||||
|
||||
/* The previous content is replaced before reaching the screen. */
|
||||
feedback_discard_all(&s->feedback);
|
||||
@@ -1129,9 +1111,6 @@ static void surface_commit(struct wl_client *c, struct wl_resource *r) {
|
||||
} else if (shm) {
|
||||
drop_dmabuf(s, 1);
|
||||
take_shm(s, shm, buffer);
|
||||
} else if (single_pixel_buffer_get(buffer, pixel)) {
|
||||
drop_dmabuf(s, 1);
|
||||
take_pixel(s, pixel, buffer);
|
||||
} else {
|
||||
drop_dmabuf(s, 1);
|
||||
s->has_content = 0;
|
||||
@@ -1153,7 +1132,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);
|
||||
droiddeck_color_commit(s->resource); /* wp_color_management_surface_v1 state (returns at once when HDR is off) */
|
||||
banner_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);
|
||||
@@ -1203,9 +1182,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;
|
||||
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); }
|
||||
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); }
|
||||
constraints_surface_gone(s);
|
||||
|
||||
unmap_toplevel(s);
|
||||
@@ -1455,7 +1434,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));
|
||||
droiddeck_log("window", "renamed %s -> %s", before, after);
|
||||
banner_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) {}
|
||||
@@ -1632,7 +1611,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;
|
||||
droiddeck_log("desktop", "Windows virtual desktop created by %s", client_name(c));
|
||||
banner_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,
|
||||
@@ -1642,7 +1621,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));
|
||||
droiddeck_log("window", "moved %s to %d,%d", name, x, y);
|
||||
banner_log("window", "moved %s to %d,%d", name, x, y);
|
||||
}
|
||||
s->hwnd = hwnd;
|
||||
s->x = x;
|
||||
@@ -1680,27 +1659,18 @@ static void bind_desktop(struct wl_client *c, void *data, uint32_t ver, uint32_t
|
||||
static void output_release(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); }
|
||||
static const struct wl_output_interface output_impl = { .release = output_release };
|
||||
|
||||
/* Every bound wl_output, so a resized output (a foldable opening) can send its new mode. */
|
||||
static struct wl_list g_outputs;
|
||||
static void output_resource_destroy(struct wl_resource *r) { wl_list_remove(wl_resource_get_link(r)); }
|
||||
|
||||
static void output_send_mode(struct wl_resource *r) {
|
||||
wl_output_send_mode(r, WL_OUTPUT_MODE_CURRENT | WL_OUTPUT_MODE_PREFERRED,
|
||||
g_output_w > 0 ? g_output_w : 1920, g_output_h > 0 ? g_output_h : 1080,
|
||||
g_output_refresh_mhz > 0 ? g_output_refresh_mhz : 60000);
|
||||
}
|
||||
|
||||
static void bind_output(struct wl_client *c, void *data, uint32_t ver, uint32_t id) {
|
||||
struct wl_resource *r = wl_resource_create(c, &wl_output_interface, ver, id);
|
||||
if (!r) { wl_client_post_no_memory(c); return; }
|
||||
wl_resource_set_implementation(r, &output_impl, NULL, output_resource_destroy);
|
||||
wl_list_insert(&g_outputs, wl_resource_get_link(r));
|
||||
wl_resource_set_implementation(r, &output_impl, NULL, NULL);
|
||||
wl_output_send_geometry(r, 0, 0, 340, 190, WL_OUTPUT_SUBPIXEL_UNKNOWN,
|
||||
"DroidDeck", "Wayland", WL_OUTPUT_TRANSFORM_NORMAL);
|
||||
output_send_mode(r);
|
||||
"Bannerlator", "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, "DroidDeck-1");
|
||||
wl_output_send_description(r, "DroidDeck display");
|
||||
wl_output_send_name(r, "Bannerlator-1");
|
||||
wl_output_send_description(r, "Bannerlator display");
|
||||
}
|
||||
if (ver >= 2) {
|
||||
wl_output_send_scale(r, 1);
|
||||
@@ -1729,7 +1699,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 droiddeck_color *c = droiddeck_color_of(s->resource);
|
||||
const struct banner_color *c = banner_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 */
|
||||
@@ -1758,7 +1728,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 && droiddeck_color_hdr_open()) note_hdr_unimported(dl, s, ox, oy);
|
||||
if (g_zero_copy && banner_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]; }
|
||||
@@ -1878,7 +1848,7 @@ static struct surface *ahb_layer_only_candidate(int scene_w, int scene_h) {
|
||||
return s;
|
||||
}
|
||||
|
||||
/* ---- HDR (droiddeck_color.h) in the scene
|
||||
/* ---- HDR (banner_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
|
||||
@@ -1894,7 +1864,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 (droiddeck_color_output) OFF: the same routes, tone-mapped - route 0
|
||||
* The drawer's HDR output switch (banner_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.
|
||||
@@ -1904,7 +1874,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 droiddeck_color *color; /* the topmost HDR draw's description: what the picture is tagged */
|
||||
const struct banner_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 */
|
||||
};
|
||||
@@ -1919,29 +1889,29 @@ static struct surface *hdr_unimported_fullscreen(const struct draw_list *dl, int
|
||||
}
|
||||
|
||||
static int is_hdr_surface(struct surface *s) {
|
||||
const struct droiddeck_color *c = s ? droiddeck_color_of(s->resource) : NULL;
|
||||
const struct banner_color *c = s ? banner_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 (!droiddeck_color_hdr_open()) return 0;
|
||||
if (!banner_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 = droiddeck_color_of(un->resource); hs->who = un; return 3; }
|
||||
hs->hf.tonemap = !droiddeck_color_output();
|
||||
if (un && g_zero_copy) { hs->color = banner_color_of(un->resource); hs->who = un; return 3; }
|
||||
hs->hf.tonemap = !banner_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 = droiddeck_color_of(s->resource); /* later draws are above: the topmost wins */
|
||||
hs->color = banner_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; }
|
||||
@@ -1970,7 +1940,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 && !droiddeck_color_output(); /* route 0 never runs with the switch off */
|
||||
const int tm = route != 0 && !banner_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;
|
||||
@@ -1979,30 +1949,30 @@ static void hdr_note_route(int route, const struct hdr_scene *hs) {
|
||||
switch (route) {
|
||||
case 1:
|
||||
if (tm)
|
||||
droiddeck_log("color", "tone-mapped picture for %s: %s - the whole scene is composed and tone-mapped to SDR "
|
||||
banner_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
|
||||
droiddeck_log("color", "HDR picture for %s: %s - the whole scene is composed into one 10-bit PQ BT.2020 picture "
|
||||
banner_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 : "?", droiddeck_color_sdr_white());
|
||||
"BT2020_PQ", name, hs->why ? hs->why : "?", banner_color_sdr_white());
|
||||
break;
|
||||
case 2:
|
||||
if (tm)
|
||||
droiddeck_log("color", "HDR with frame generation for %s, HDR output switched off: the scene is tone-mapped to "
|
||||
banner_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
|
||||
droiddeck_log("color", "HDR with frame generation for %s: the scene is composed into PQ and presented through "
|
||||
banner_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)
|
||||
droiddeck_log("color", "HDR output is switched off, but %s's HDR frames cannot be imported by the compositor, "
|
||||
banner_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)
|
||||
droiddeck_log("color", "%s is back on its own display layer (no composition needed)", name);
|
||||
banner_log("color", "%s is back on its own display layer (no composition needed)", name);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -2015,18 +1985,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)
|
||||
droiddeck_log("color", "screen effects are NOT applied to %s: its HDR frames cannot be imported by the "
|
||||
banner_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)
|
||||
droiddeck_log("color", "screen effects apply to the scene again (no HDR game on its layer, or effects off)");
|
||||
banner_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)
|
||||
droiddeck_log("color", "frame generation is NOT applied to %s: its HDR frames cannot be imported by the "
|
||||
banner_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)
|
||||
droiddeck_log("color", "frame generation applies again (no HDR game on its layer, or frame generation off)");
|
||||
banner_log("color", "frame generation applies again (no HDR game on its layer, or frame generation off)");
|
||||
fg_said = fg_skip;
|
||||
}
|
||||
}
|
||||
@@ -2035,11 +2005,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 droiddeck_color *c = s ? droiddeck_color_of(s->resource) : NULL;
|
||||
const struct banner_color *c = s ? banner_color_of(s->resource) : NULL;
|
||||
if (!c || !c->dataspace) continue;
|
||||
char name[160];
|
||||
describe(s, name, sizeof(name));
|
||||
droiddeck_color_frame_copied(name, reason);
|
||||
banner_color_frame_copied(name, reason);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2051,13 +2021,12 @@ static void render_scene(void) {
|
||||
int copy = 0; /* this scene went through the screen swapchain (the perf line's "copy") */
|
||||
|
||||
g_dirty = 0;
|
||||
g_hud_fresh = 0;
|
||||
g_hdr_unimported = NULL; /* found again while the scene is built (HDR gate open only) */
|
||||
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) droiddeck_log("desktop", "size %dx%d", w, h);
|
||||
else droiddeck_log("desktop", "no desktop: showing %dx%d (largest window)", w, 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);
|
||||
g_scene_w = w;
|
||||
g_scene_h = h;
|
||||
}
|
||||
@@ -2076,7 +2045,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 (droiddeck_color_hdr_open()) {
|
||||
if (banner_color_hdr_open()) {
|
||||
hdr_note_route(hdr_route, &hs);
|
||||
hdr_note_precedence(hdr_route == 3 ? hs.who : NULL, fx_on, framegen);
|
||||
}
|
||||
@@ -2087,12 +2056,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; droiddeck_log("color", "the composed picture is on the game's display layer again"); }
|
||||
if (fell_back) { fell_back = 0; banner_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;
|
||||
droiddeck_log("color", "the composed picture could not go on the game's display layer (no layer, or the "
|
||||
banner_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");
|
||||
}
|
||||
}
|
||||
@@ -2102,8 +2071,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) 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);
|
||||
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);
|
||||
else if (rendered) hdr_copy = "the HDR composition pass is unavailable on this driver";
|
||||
}
|
||||
if (hdr_route == 0 || hdr_route == 3) {
|
||||
@@ -2132,14 +2101,14 @@ static void render_scene(void) {
|
||||
if (g_zero_copy && blocked != g_zero_copy_paused) {
|
||||
g_zero_copy_paused = blocked;
|
||||
if (blocked == 1)
|
||||
droiddeck_log("framegen", "zero-copy paused: frame generation needs the compositor pass");
|
||||
banner_log("framegen", "zero-copy paused: frame generation needs the compositor pass");
|
||||
else if (blocked == 2)
|
||||
droiddeck_log("effects", "zero-copy paused: a window above the game needs the compositor pass for the whole scene");
|
||||
banner_log("effects", "zero-copy paused: a window above the game needs the compositor pass for the whole scene");
|
||||
else if (blocked == 3)
|
||||
droiddeck_log("layer", "zero-copy paused: a window above the game would need a second display layer, "
|
||||
banner_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
|
||||
droiddeck_log("effects", "zero-copy resumed: the game is back on its own display layer");
|
||||
banner_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. */
|
||||
@@ -2149,7 +2118,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;
|
||||
droiddeck_log(framegen ? "framegen" : "effects",
|
||||
banner_log(framegen ? "framegen" : "effects",
|
||||
"the game's frames cannot be imported by the compositor: shown zero-copy, %s skipped",
|
||||
framegen ? "frame generation" : "effects");
|
||||
}
|
||||
@@ -2176,7 +2145,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 ? droiddeck_surface_color(ls) : NULL);
|
||||
: sc_layer_present(dl.d[li].img, w, h, ls ? banner_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
|
||||
@@ -2216,7 +2185,7 @@ static void render_scene(void) {
|
||||
}
|
||||
} /* routes 0 and 3 */
|
||||
free(hs.is_hdr);
|
||||
if (hdr_copy && rendered && droiddeck_color_hdr_open()) hdr_count_copied(&dl, hdr_copy);
|
||||
if (hdr_copy && rendered && banner_color_hdr_open()) hdr_count_copied(&dl, hdr_copy);
|
||||
if (rendered) {
|
||||
int64_t t = now_ns();
|
||||
g_stat_frames++;
|
||||
@@ -2267,13 +2236,6 @@ static void schedule_render(void) {
|
||||
wl_event_source_timer_update(g_fallback_timer, 8);
|
||||
}
|
||||
|
||||
/* With frame generation on, every scene is the engine's next source frame, so one drawn for
|
||||
* another window (Steam's overlay) counted as a game frame and took its present slots. Such a
|
||||
* change waits for the game's next frame, unless the game has been still for 100 ms. */
|
||||
static int scene_waits_for_game(int64_t now) {
|
||||
return g_hud_surface && !g_hud_fresh && now - g_hud_last_ns < 100000000LL && vkp_framegen_active();
|
||||
}
|
||||
|
||||
/* A screen refresh (Choreographer tick): draw the newest state once. */
|
||||
static void on_vsync(int64_t frame_time_ns) {
|
||||
int64_t now = now_ns();
|
||||
@@ -2289,7 +2251,7 @@ static void on_vsync(int64_t frame_time_ns) {
|
||||
if (vkp_apply_window_request()) g_dirty = 1;
|
||||
/* A screen-effect setting changed (JNI, any thread): redraw so it shows on a static scene too. */
|
||||
if (vkp_effects_sync()) g_dirty = 1;
|
||||
if (g_dirty && !scene_waits_for_game(now)) render_scene();
|
||||
if (g_dirty) render_scene();
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ wl_seat */
|
||||
@@ -2398,7 +2360,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, "droiddeck-seat");
|
||||
if (ver >= 2) wl_seat_send_name(r, "bannerlator-seat");
|
||||
}
|
||||
|
||||
static struct seat_pointer *pointer_for(struct wl_client *client) {
|
||||
@@ -2477,7 +2439,7 @@ static void keyboard_focus(struct wl_resource *target) {
|
||||
g_mods_depressed, 0, g_mods_locked, 0);
|
||||
}
|
||||
wl_array_release(&keys);
|
||||
droiddeck_clipboard_keyboard_focus(client); /* wl_data_device selection follows focus */
|
||||
banner_clipboard_keyboard_focus(client); /* wl_data_device selection follows focus */
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ pointer constraints
|
||||
@@ -2530,14 +2492,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 droiddeck_on_pointer_lock(int locked, int x, int y);
|
||||
extern void banner_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;
|
||||
droiddeck_on_pointer_lock(locked, (int)g_ptr_x, (int)g_ptr_y);
|
||||
banner_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) {
|
||||
@@ -2598,7 +2560,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));
|
||||
droiddeck_log("pointer", "%s: %s (%s), pointer at %d,%d", k->is_lock ? "unlocked" : "unconfined",
|
||||
banner_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();
|
||||
}
|
||||
@@ -2618,15 +2580,15 @@ static void constraint_activate(struct constraint *k) {
|
||||
constraint_send_state(k, 1);
|
||||
constraint_describe(k, name, sizeof(name));
|
||||
if (k->is_lock)
|
||||
droiddeck_log("pointer", "locked: %s (%s), pointer frozen at %d,%d", name,
|
||||
banner_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)
|
||||
droiddeck_log("pointer", "confined: %s (%s) to %dx%d at %d,%d of the window", name,
|
||||
banner_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
|
||||
droiddeck_log("pointer", "confined: %s (%s) to the whole window", name,
|
||||
banner_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);
|
||||
@@ -2680,7 +2642,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())
|
||||
droiddeck_log("pointer", "position hint: pointer moved to %d,%d", (int)g_ptr_x, (int)g_ptr_y);
|
||||
banner_log("pointer", "position hint: pointer moved to %d,%d", (int)g_ptr_x, (int)g_ptr_y);
|
||||
}
|
||||
}
|
||||
if (k->pending_region_set) {
|
||||
@@ -2761,7 +2723,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));
|
||||
droiddeck_log("pointer", "%s requested by %s for %s", is_lock ? "lock" : "confine", client_name(c), name);
|
||||
banner_log("pointer", "%s requested by %s for %s", is_lock ? "lock" : "confine", client_name(c), name);
|
||||
constraint_activate(k);
|
||||
}
|
||||
|
||||
@@ -2792,7 +2754,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;
|
||||
droiddeck_log("pointer", "relative pointer released by %s", client_name(wl_resource_get_client(r)));
|
||||
banner_log("pointer", "relative pointer released by %s", client_name(wl_resource_get_client(r)));
|
||||
wl_list_remove(&rp->link);
|
||||
free(rp);
|
||||
}
|
||||
@@ -2811,7 +2773,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);
|
||||
droiddeck_log("pointer", "relative pointer created for %s (motion arrives as deltas)", client_name(c));
|
||||
banner_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,
|
||||
@@ -2918,7 +2880,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;
|
||||
droiddeck_text_input_refocus();
|
||||
banner_text_input_refocus();
|
||||
}
|
||||
wl_display_flush_clients(g_display);
|
||||
}
|
||||
@@ -3062,7 +3024,7 @@ static void touch_down(int id, double x, double y) {
|
||||
if (!any_active) {
|
||||
char name[160];
|
||||
describe(target, name, sizeof(name));
|
||||
droiddeck_log("touch", "%s gets %s", name, has_touch ? "touch" : "the pointer (it takes no touch)");
|
||||
banner_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 */
|
||||
@@ -3141,7 +3103,7 @@ static void deliver_touch(const struct input_msg *m) {
|
||||
}
|
||||
|
||||
static void key_event(uint32_t evdev, int pressed);
|
||||
struct wl_resource *droiddeck_ime_target(void);
|
||||
struct wl_resource *banner_ime_target(void);
|
||||
static void deliver_key(const struct input_msg *m) {
|
||||
key_event((uint32_t)m->p1, m->p2);
|
||||
}
|
||||
@@ -3205,7 +3167,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 = droiddeck_ime_target();
|
||||
struct wl_resource *ime = banner_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;
|
||||
@@ -3228,23 +3190,6 @@ static void key_event(uint32_t evdev, int pressed) {
|
||||
wl_display_flush_clients(g_display);
|
||||
}
|
||||
|
||||
/* The output changed size under a running session (a foldable opened or closed, with the
|
||||
* session following the screen). Every client that has had its first configure is told the new
|
||||
* size: gamescope's Wayland backend and the desktop's KWin both resize their output to it, and
|
||||
* the next buffers they commit fill the new panel instead of being letterboxed onto it. */
|
||||
static void output_resized(void) {
|
||||
struct surface *s;
|
||||
struct wl_resource *r;
|
||||
wl_resource_for_each(r, &g_outputs) {
|
||||
output_send_mode(r);
|
||||
if (wl_resource_get_version(r) >= 2) wl_output_send_done(r);
|
||||
}
|
||||
wl_list_for_each(s, &g_surfaces, link)
|
||||
if (s->xdg_toplevel && s->toplevel_committed) send_toplevel_configure(s);
|
||||
droiddeck_log("display", "output resized to %dx%d", g_output_w, g_output_h);
|
||||
wl_display_flush_clients(g_display);
|
||||
}
|
||||
|
||||
/* wl event-loop callback: drain queued input events written by the Android UI thread. */
|
||||
static int on_input_readable(int fd, uint32_t mask, void *data) {
|
||||
struct input_msg m;
|
||||
@@ -3257,7 +3202,6 @@ static int on_input_readable(int fd, uint32_t mask, void *data) {
|
||||
case 5: on_vsync(((int64_t)m.p1 << 32) | (uint32_t)m.p2); break;
|
||||
case 6: pointer_delta(m.p1 / 256.0, m.p2 / 256.0); break; /* relative motion, 1/256 px */
|
||||
case 7: deliver_touch(&m); break;
|
||||
case 8: output_resized(); break;
|
||||
default: deliver_pointer(&m); break;
|
||||
}
|
||||
}
|
||||
@@ -3266,14 +3210,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 droiddeck_wayland_send_pointer(int action, int x, int y) {
|
||||
void banner_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 droiddeck_wayland_send_touch(int action, int pointer_id, int x, int y) {
|
||||
void banner_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));
|
||||
@@ -3281,23 +3225,15 @@ void droiddeck_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 droiddeck_wayland_send_key(int evdev, int state) {
|
||||
void banner_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));
|
||||
(void)n;
|
||||
}
|
||||
|
||||
/* Called from JNI after g_output_w/h changed: the compositor thread re-sends the size. */
|
||||
void droiddeck_wayland_output_resized(void) {
|
||||
if (g_input_pipe[1] < 0) return;
|
||||
struct input_msg m = { 8, 0, 0, 0, 0 };
|
||||
ssize_t n = write(g_input_pipe[1], &m, sizeof(m));
|
||||
(void)n;
|
||||
}
|
||||
|
||||
/* Called from JNI on every screen refresh (Choreographer). */
|
||||
void droiddeck_wayland_vsync(int64_t frame_time_ns) {
|
||||
void banner_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));
|
||||
@@ -3307,7 +3243,7 @@ void droiddeck_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 droiddeck_wayland_send_scene_input(int type, int a, int b) {
|
||||
void banner_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));
|
||||
@@ -3315,15 +3251,15 @@ void droiddeck_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 droiddeck_ext.h). */
|
||||
* What wl_clipboard.c / wl_text_input.c need from the scene (see banner_ext.h). */
|
||||
|
||||
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(); }
|
||||
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(); }
|
||||
|
||||
/* 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 *droiddeck_ime_target(void) {
|
||||
struct wl_resource *banner_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;
|
||||
@@ -3332,13 +3268,13 @@ struct wl_resource *droiddeck_ime_target(void) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
void droiddeck_surface_scene_origin(struct wl_resource *surface, int *x, int *y) {
|
||||
void banner_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 droiddeck_inject_key(uint32_t evdev, int pressed) { key_event(evdev, pressed); }
|
||||
void banner_inject_key(uint32_t evdev, int pressed) { key_event(evdev, pressed); }
|
||||
|
||||
/* ------------------------------------------------------------------ 10 s summary */
|
||||
|
||||
@@ -3365,7 +3301,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);
|
||||
droiddeck_log("stats", "last 10 s: %u frames on screen (%.1f fps) | %u GPU frames from games | %u window redraws | %d windows open%s",
|
||||
banner_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
|
||||
@@ -3377,7 +3313,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)
|
||||
droiddeck_log("perf", "last 10 s: %u ticks, %u scenes, %u on screen (copy %u, zero-copy %u, layer copy %u) | "
|
||||
banner_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",
|
||||
@@ -3393,7 +3329,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;
|
||||
droiddeck_color_stats_tick(); /* HDR evidence for the same window (nothing when the HDR gate is closed) */
|
||||
banner_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. */
|
||||
@@ -3401,7 +3337,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;
|
||||
droiddeck_log("opengl", "%s asked for GPU buffers but has drawn only software (shared-memory) "
|
||||
banner_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));
|
||||
@@ -3427,7 +3363,7 @@ static void test_input_line(const char *line) {
|
||||
int n = sscanf(line, "%15s %lf %lf", cmd, &x, &y);
|
||||
|
||||
if (n < 2) return;
|
||||
droiddeck_log("test", "input: %s", line);
|
||||
banner_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);
|
||||
@@ -3490,10 +3426,9 @@ static void start_test_input(struct wl_event_loop *loop) {
|
||||
|
||||
static struct wl_listener g_client_created = { .notify = on_client_created };
|
||||
|
||||
int droiddeck_wayland_run(void) {
|
||||
int banner_wayland_run(void) {
|
||||
wl_list_init(&g_surfaces);
|
||||
wl_list_init(&g_toplevels);
|
||||
wl_list_init(&g_outputs);
|
||||
wl_list_init(&g_constraints);
|
||||
wl_list_init(&g_relative_pointers);
|
||||
open_session_log();
|
||||
@@ -3523,7 +3458,7 @@ int droiddeck_wayland_run(void) {
|
||||
rt ? rt : "(null)");
|
||||
return 1;
|
||||
}
|
||||
droiddeck_log("display", "Wayland compositor listening on %s/wayland-0", rt ? rt : "?");
|
||||
banner_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);
|
||||
@@ -3540,13 +3475,13 @@ int droiddeck_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);
|
||||
droiddeck_ext_init(display); /* clipboard, text input, toplevel icons (own files, see droiddeck_ext.h) */
|
||||
banner_ext_init(display); /* clipboard, text input, toplevel icons (own files, see banner_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) */
|
||||
droiddeck_color_init(display); /* HDR10 (opt-in): decides the gate from the app's request + the
|
||||
banner_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 (droiddeck_color.h). Before any client can
|
||||
* only when it is open (banner_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 libdroiddeckwayland, not part of its ABI.
|
||||
* Everything declared here is hidden: it is internal to libbannerwayland, 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, "DroidDeckWayland", __VA_ARGS__)
|
||||
#define WLOGE(...) __android_log_print(ANDROID_LOG_ERROR, "BannerWayland", __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; /* DROIDDECK_WAYLAND_UBWC (waylandcomp_jni.c sets it) */
|
||||
extern volatile int g_no_render_node; /* DROIDDECK_WAYLAND_NO_RENDER_NODE */
|
||||
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 */
|
||||
|
||||
/* ---- wl_dmabuf.c */
|
||||
COMPOSITOR_INTERNAL extern dev_t g_main_device; /* the render node clients are told to allocate on */
|
||||
|
||||
@@ -1,53 +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)
|
||||
* 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);
|
||||
void single_pixel_init(struct wl_display *display);
|
||||
/* A wp_single_pixel_buffer_manager_v1 buffer: 1 and its pixel (B, G, R, A, premultiplied), else 0. */
|
||||
int single_pixel_buffer_get(struct wl_resource *buffer, uint8_t bgra[4]);
|
||||
@@ -2,7 +2,7 @@
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
#include "effects_chain.h"
|
||||
#include "vk_loader.h"
|
||||
#include "vk_present.h" /* droiddeck_log */
|
||||
#include "vk_present.h" /* banner_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)) {
|
||||
droiddeck_log("effects", "all off: scaling=%s (plain blit)", scaling_name(s->scaling));
|
||||
banner_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;
|
||||
droiddeck_log("effects", "%s", line);
|
||||
banner_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) { droiddeck_log("error", "effects: %s image %dx%d: vkCreateImage %d", what, w, h, (int)r); return -1; }
|
||||
if (r != VK_SUCCESS) { banner_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) {
|
||||
droiddeck_log("error", "effects: %s image %dx%d (%llu bytes): vkAllocateMemory %d", what, w, h,
|
||||
banner_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) { droiddeck_log("error", "effects: %s: vkCreateShaderModule failed", name); return VK_NULL_HANDLE; }
|
||||
if (!frag) { banner_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) { droiddeck_log("error", "effects: %s: vkCreateGraphicsPipelines %d", name, (int)r); return VK_NULL_HANDLE; }
|
||||
if (r != VK_SUCCESS) { banner_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)
|
||||
droiddeck_log("effects", "chain now works in %s (%s)",
|
||||
banner_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; droiddeck_log("error", "effects: no device for the chain"); return -1; }
|
||||
if (!g_dev || !g_vk.CreateGraphicsPipelines) { g_ready = -1; banner_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;
|
||||
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());
|
||||
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());
|
||||
return 0;
|
||||
fail:
|
||||
objects_destroy();
|
||||
g_ready = -1;
|
||||
droiddeck_log("error", "effects: the screen-effect chain could not be built on this driver; effects are off for this session");
|
||||
banner_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 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__)
|
||||
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__)
|
||||
|
||||
/* ---- 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, "DroidDeckWayland", "framegen: " __VA_ARGS__)
|
||||
#define ELOGE(...) __android_log_print(ANDROID_LOG_ERROR, "DroidDeckWayland", "framegen: " __VA_ARGS__)
|
||||
#define ELOG(...) __android_log_print(ANDROID_LOG_INFO, "BannerWayland", "framegen: " __VA_ARGS__)
|
||||
#define ELOGE(...) __android_log_print(ANDROID_LOG_ERROR, "BannerWayland", "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" /* droiddeck_log, vkp_gpu_name */
|
||||
#include "vk_present.h" /* banner_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;
|
||||
droiddeck_log("color", "HDR composition ready on %s: scenes that mix HDR and SDR content are put into one "
|
||||
banner_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;
|
||||
droiddeck_log("error", "color: the HDR composition pass could not be built on this driver; HDR scenes that cannot "
|
||||
banner_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 "droiddeck_color.h"
|
||||
#include "banner_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 droiddeck_native_handle { int version; int numFds; int numInts; int data[]; };
|
||||
struct banner_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 droiddeck_color_xy { float x, y; };
|
||||
struct droiddeck_hdr_smpte2086 {
|
||||
struct droiddeck_color_xy displayPrimaryRed, displayPrimaryGreen, displayPrimaryBlue, whitePoint;
|
||||
struct banner_color_xy { float x, y; };
|
||||
struct banner_hdr_smpte2086 {
|
||||
struct banner_color_xy displayPrimaryRed, displayPrimaryGreen, displayPrimaryBlue, whitePoint;
|
||||
float maxLuminance, minLuminance;
|
||||
};
|
||||
struct droiddeck_hdr_cta861_3 { float maxContentLightLevel, maxFrameAverageLightLevel; };
|
||||
struct banner_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 (droiddeck_color.h's gate needs
|
||||
/* Colour (API 29), optional: only an HDR session ever calls them (banner_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 droiddeck_hdr_smpte2086 *);
|
||||
void (*setHdrMetadata_cta861_3)(ASurfaceTransaction *, ASurfaceControl *, const struct droiddeck_hdr_cta861_3 *);
|
||||
void (*setHdrMetadata_smpte2086)(ASurfaceTransaction *, ASurfaceControl *, const struct banner_hdr_smpte2086 *);
|
||||
void (*setHdrMetadata_cta861_3)(ASurfaceTransaction *, ASurfaceControl *, const struct banner_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) {
|
||||
droiddeck_log("layer", "unavailable: libnativewindow.so has no AHardwareBuffer_getNativeHandle");
|
||||
banner_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) { droiddeck_log("layer", "unavailable: dlopen(libandroid.so): %s", dlerror()); return -1; }
|
||||
if (!lib) { banner_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) {
|
||||
droiddeck_log("layer", "unavailable: libandroid.so lacks part of the ASurfaceControl API (Android 10+)");
|
||||
banner_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 = "droiddeck_wayland_game", .z = 1, .pool_n = POOL_MAX,
|
||||
g_layers[SC_LAYER_GAME] = (struct layer){.name = "banner_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 = "droiddeck_wayland_overlay", .z = 2, .pool_n = 3,
|
||||
g_layers[SC_LAYER_OVERLAY] = (struct layer){.name = "banner_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);
|
||||
}
|
||||
droiddeck_log("layer", "%s: SurfaceControl retired (window %p)", l->name, (void *)l->win);
|
||||
banner_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 droiddeck_native_handle *h, uint64_t *mod) {
|
||||
static int sniff_modifier(const struct banner_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 DROIDDECK_AHB_USAGE_VENDOR_UBWC (1ULL << 28) /* AHARDWAREBUFFER_USAGE_VENDOR_0 */
|
||||
#define BANNER_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 ? DROIDDECK_AHB_USAGE_VENDOR_UBWC : 0) |
|
||||
(tier == TIER_UBWC ? BANNER_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) {
|
||||
droiddeck_log("layer", "%s: gralloc refused a %dx%d pool buffer (%s): trying %s", l->name, w, h,
|
||||
banner_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;
|
||||
}
|
||||
droiddeck_log("error", "layer: %s: AHardwareBuffer_allocate %dx%d (format %#x) failed", l->name, w, h, fmt);
|
||||
banner_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 droiddeck_native_handle *nh = api.getNativeHandle(ahb);
|
||||
const struct banner_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;
|
||||
droiddeck_log("layer", "%s: gralloc handle layout unknown (%d fds, %d ints): using linear pool buffers",
|
||||
banner_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) {
|
||||
droiddeck_log("layer", "%s: import of a %s %dx%d pool buffer (stride %u px) into the compositor's Turnip failed",
|
||||
banner_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;
|
||||
droiddeck_log("layer", "%s: pool buffers %dx%d%s: %s (%s), stride %u px, up to %d buffers (gralloc handle %d fds / %d ints)",
|
||||
banner_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) {
|
||||
droiddeck_log("layer", "kernel exports sync_file fences from the game's dma-buf: zero-copy acquire fences can come from the buffer itself");
|
||||
banner_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 {
|
||||
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",
|
||||
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",
|
||||
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) { droiddeck_log("error", "layer: ASurfaceControl_createFromWindow(%s) failed", l->name); return -1; }
|
||||
if (!l->sc) { banner_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);
|
||||
}
|
||||
droiddeck_log("layer", "SurfaceControl \"%s\" created as a child of the screen surface (z=%d)", l->name, (int)l->z);
|
||||
banner_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));
|
||||
droiddeck_log("layer", "%s geometry: buffer %d,%d-%d,%d -> screen %d,%d-%d,%d%s%s%s", l->name,
|
||||
banner_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) 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);
|
||||
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);
|
||||
}
|
||||
|
||||
/* ---- colour (HDR, droiddeck_color.h) -------------------------------------------------------------
|
||||
/* ---- colour (HDR, banner_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 droiddeck_color *c) {
|
||||
static void apply_headroom(ASurfaceTransaction *tx, struct layer *l, const struct banner_color *c) {
|
||||
static int said_missing;
|
||||
if (!api.setDesiredHdrHeadroom) {
|
||||
if (c && c->dataspace && !said_missing) {
|
||||
said_missing = 1;
|
||||
droiddeck_color_note_headroom_request(-1.0f);
|
||||
droiddeck_log("color", "HDR headroom request not available on %s (Android < 15 has no "
|
||||
banner_color_note_headroom_request(-1.0f);
|
||||
banner_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) ? droiddeck_color_desired_headroom(c, NULL, 0) : 0.0f;
|
||||
const float want = (c && c->dataspace) ? banner_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) droiddeck_color_desired_headroom(c, why, sizeof(why));
|
||||
if (c && c->dataspace) banner_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;
|
||||
droiddeck_color_note_headroom_request(want);
|
||||
banner_color_note_headroom_request(want);
|
||||
if (want > 0.0f)
|
||||
droiddeck_log("color", "requested HDR headroom %.1fx on %s (%s)", want, l->name, why);
|
||||
banner_log("color", "requested HDR headroom %.1fx on %s (%s)", want, l->name, why);
|
||||
else if (c && c->dataspace)
|
||||
droiddeck_log("color", "no HDR headroom requested on %s: %s", l->name, why);
|
||||
banner_log("color", "no HDR headroom requested on %s: %s", l->name, why);
|
||||
else if (!first)
|
||||
droiddeck_log("color", "HDR headroom request on %s cleared (no preference): the frame on the layer is not HDR", l->name);
|
||||
banner_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 droiddeck_color *c) {
|
||||
int32_t want = c ? c->dataspace : DROIDDECK_ADATASPACE_UNKNOWN;
|
||||
static void apply_colour(ASurfaceTransaction *tx, struct layer *l, const struct banner_color *c) {
|
||||
int32_t want = c ? c->dataspace : BANNER_ADATASPACE_UNKNOWN;
|
||||
uint32_t md = (c && c->dataspace) ? c->identity : 0;
|
||||
if (!api.setBufferDataSpace) return;
|
||||
if (l->ds_applied < 0 && want == DROIDDECK_ADATASPACE_UNKNOWN) return; /* never tagged: leave it be */
|
||||
if (l->ds_applied < 0 && want == BANNER_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 droiddeck_hdr_smpte2086 st;
|
||||
struct droiddeck_hdr_cta861_3 cta;
|
||||
struct banner_hdr_smpte2086 st;
|
||||
struct banner_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 droiddeck_hdr_smpte2086){
|
||||
st = (struct banner_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 droiddeck_hdr_cta861_3){.maxContentLightLevel = c->max_cll, .maxFrameAverageLightLevel = c->max_fall};
|
||||
if (cta_on) cta = (struct banner_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));
|
||||
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",
|
||||
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",
|
||||
(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
|
||||
droiddeck_log("color", "%s: dataspace back to UNKNOWN (sRGB), HDR metadata cleared - the frame on the layer is "
|
||||
banner_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;
|
||||
droiddeck_log("layer", "%d display layers in use: \"%s\" (z=%d) and \"%s\" (z=%d) above it, both children of the "
|
||||
banner_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) {
|
||||
droiddeck_log("error", "layer: %s: composition recovery could not create a new SurfaceControl", l->name);
|
||||
banner_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;
|
||||
droiddeck_log("layer", "composition recovery: %s got a fresh SurfaceControl now that nothing is above "
|
||||
banner_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 droiddeck_color *color,
|
||||
static int present_slot(struct layer *l, int idx, const int r[8], const struct banner_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 d
|
||||
api.txDelete(tx);
|
||||
l->cur_slot = idx;
|
||||
l->cur_token = NULL;
|
||||
if (!l->shown) { l->shown = 1; droiddeck_log("layer", "%s: layer shown", l->name); }
|
||||
if (!l->shown) { l->shown = 1; banner_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) {
|
||||
droiddeck_log("layer", "%s: no free layer buffer (display still holds all %d): %u frame%s dropped%s", l->name,
|
||||
banner_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 droiddeck_color *color, uint32_t ahb_format) {
|
||||
const struct banner_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,7 +836,6 @@ 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();
|
||||
@@ -863,19 +862,16 @@ 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; 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);
|
||||
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);
|
||||
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 droiddeck_color *color) {
|
||||
int sc_layer_present(struct vkp_image *src, int scene_w, int scene_h, const struct banner_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);
|
||||
@@ -889,14 +885,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) droiddeck_color_frame_shown(color, DROIDDECK_HDR_LAYER_COPY, AHB_RGBA8);
|
||||
if (color && color->dataspace) banner_color_frame_shown(color, BANNER_HDR_LAYER_COPY, AHB_RGBA8);
|
||||
if (!l->first_logged) {
|
||||
l->first_logged = 1;
|
||||
droiddeck_log("layer", "presenting %dx%d game frames on their own SurfaceControl layer (%s pool, %d buffers); "
|
||||
banner_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;
|
||||
}
|
||||
|
||||
@@ -916,11 +912,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;
|
||||
droiddeck_log("layer", "presenting %dx%d frames on their own SurfaceControl layer (%s pool, %d buffers); "
|
||||
banner_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;
|
||||
}
|
||||
|
||||
@@ -935,7 +931,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 droiddeck_color *color) {
|
||||
int scene_w, int scene_h, const struct banner_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;
|
||||
@@ -949,7 +945,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;
|
||||
droiddeck_log("color", "%s: this device will not make a 10-bit layer buffer (RGBA1010102): the HDR picture goes "
|
||||
banner_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);
|
||||
}
|
||||
@@ -957,26 +953,25 @@ 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)
|
||||
droiddeck_color_frame_shown(color, tm ? DROIDDECK_HDR_TONEMAPPED : DROIDDECK_HDR_COMPOSED, tm ? AHB_RGBA8 : g_hdr_pool_fmt);
|
||||
banner_color_frame_shown(color, tm ? BANNER_HDR_TONEMAPPED : BANNER_HDR_COMPOSED, tm ? AHB_RGBA8 : g_hdr_pool_fmt);
|
||||
static int said = -1;
|
||||
if (said != tm) {
|
||||
said = tm;
|
||||
if (tm)
|
||||
droiddeck_log("color", "tone-mapped picture on the game's display layer: %dx%d, %s 8-bit buffers, untagged "
|
||||
banner_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));
|
||||
droiddeck_log("color", "HDR picture on its own display layer: %dx%d, %s %s buffers, tagged %s%s%s", rw, rh,
|
||||
banner_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 == DROIDDECK_ADATASPACE_BT2020_PQ ? "BT2020_PQ" : "HDR",
|
||||
color && color->dataspace == BANNER_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;
|
||||
}
|
||||
|
||||
@@ -1025,13 +1020,13 @@ int sc_layer_overlay_affordable(void) {
|
||||
int first = g_overlay_declined < 0;
|
||||
g_overlay_declined = want;
|
||||
if (want)
|
||||
droiddeck_log("layer", "overlay layer declined: this display rotates every layer %d° and the game "
|
||||
banner_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)
|
||||
droiddeck_log("layer", "overlay layer allowed again: the game layer is no longer both rotated and "
|
||||
banner_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;
|
||||
@@ -1082,7 +1077,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) droiddeck_log("layer", "layers hidden (scene is not a single fullscreen window)");
|
||||
if (said) banner_log("layer", "layers hidden (scene is not a single fullscreen window)");
|
||||
}
|
||||
|
||||
void sc_layer_hide_overlay(void) {
|
||||
@@ -1098,7 +1093,7 @@ void sc_layer_hide_overlay(void) {
|
||||
* buffers stay allocated for the next window. */
|
||||
if (l->shown) hide_layer(l);
|
||||
retire_sc(l);
|
||||
droiddeck_log("layer", "%s: gone (nothing is above the game any more)", l->name);
|
||||
banner_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 (DROIDDECK_WAYLAND_ZERO_COPY=1): the scene is handed to SurfaceFlinger as a small,
|
||||
* Layer mode (BANNER_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 "droiddeck_wayland_game" ...... the one fullscreen game window
|
||||
* +-- z=2 "droiddeck_wayland_overlay" ... at most one window drawn ABOVE the game
|
||||
* +-- z=1 "banner_wayland_game" ...... the one fullscreen game window
|
||||
* +-- z=2 "banner_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 droiddeck_color;
|
||||
struct banner_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 (droiddeck_color.h): can a display layer be told its buffer's colour encoding here
|
||||
/* HDR (banner_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 droiddeck_color *color);
|
||||
int sc_layer_present(struct vkp_image *src, int scene_w, int scene_h, const struct banner_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 droiddeck_color *color);
|
||||
int scene_w, int scene_h, const struct banner_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 droiddeck_color *color, uint32_t ahb_format);
|
||||
int scene_w, int scene_h, const struct banner_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 "DroidDeckWayland"
|
||||
#define TAG "BannerWayland"
|
||||
|
||||
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 "droiddeck_color.h"
|
||||
#include "banner_color.h"
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
@@ -19,9 +19,9 @@
|
||||
#include <pthread.h>
|
||||
#include <android/log.h>
|
||||
|
||||
#define TAG "DroidDeckWayland"
|
||||
#define TAG "BannerWayland"
|
||||
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__)
|
||||
#define LOGE(...) droiddeck_log("error", __VA_ARGS__)
|
||||
#define LOGE(...) banner_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 droiddeck_on_first_frame() on first present */
|
||||
static int g_first_frame_done; /* one-shot: fire banner_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
|
||||
@@ -160,11 +160,6 @@ static const char *vk_result_name(VkResult r);
|
||||
static pthread_mutex_t g_req_lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
static ANativeWindow *g_req_window;
|
||||
static int g_req_pending;
|
||||
/* A detach was asked for since the compositor thread last looked. A rebind (null, then the same
|
||||
* window) to rebuild the swapchain at a new size can land in full before the thread gets to it,
|
||||
* and would otherwise read as the same window handed over again - nothing rebuilt, and the old
|
||||
* size's buffers scaled onto the rotated or refolded panel. */
|
||||
static int g_req_detached;
|
||||
|
||||
/* Scale mode + alignment (app values, see vk_present.h); written from any thread, read per frame. */
|
||||
static volatile int g_mode = VKP_MODE_STRETCH, g_align = VKP_ALIGN_CENTER;
|
||||
@@ -184,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 droiddeck_on_first_frame(void);
|
||||
extern void banner_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; }
|
||||
@@ -257,7 +252,6 @@ void vk_present_set_window(ANativeWindow *window) {
|
||||
if (g_req_pending && g_req_window && g_req_window != window) superseded = g_req_window;
|
||||
g_req_window = window;
|
||||
g_req_pending = 1;
|
||||
if (!window) g_req_detached = 1;
|
||||
pthread_mutex_unlock(&g_req_lock);
|
||||
if (superseded) ANativeWindow_release(superseded);
|
||||
}
|
||||
@@ -267,20 +261,13 @@ int vkp_apply_window_request(void) {
|
||||
pthread_mutex_lock(&g_req_lock);
|
||||
if (!g_req_pending) { pthread_mutex_unlock(&g_req_lock); return 0; }
|
||||
w = g_req_window;
|
||||
const int detached = g_req_detached;
|
||||
g_req_window = NULL;
|
||||
g_req_pending = 0;
|
||||
g_req_detached = 0;
|
||||
pthread_mutex_unlock(&g_req_lock);
|
||||
if (w == g_window) {
|
||||
/* The same window handed over again (ANativeWindow_fromSurface adds a reference each time). */
|
||||
if (w) ANativeWindow_release(w);
|
||||
if (!w || !detached) return 0;
|
||||
/* ... after a detach: a rebind for a resized surface. Rebuilt at its size on the next frame. */
|
||||
sc_layer_window_gone();
|
||||
destroy_swapchain();
|
||||
droiddeck_log("gpu", "screen surface rebound: rebuilding the swapchain at its new size");
|
||||
return 1;
|
||||
return 0;
|
||||
}
|
||||
sc_layer_window_gone(); /* the layer (if any) belongs to the old window */
|
||||
destroy_swapchain(); /* recreated against the new window on the next frame */
|
||||
@@ -289,7 +276,7 @@ int vkp_apply_window_request(void) {
|
||||
g_window = w;
|
||||
g_swap_retry_at_ns = 0;
|
||||
g_swap_fail_logged = 0;
|
||||
droiddeck_log("gpu", w ? "screen surface attached" : "screen surface gone: presenting paused");
|
||||
banner_log("gpu", w ? "screen surface attached" : "screen surface gone: presenting paused");
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -366,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;
|
||||
droiddeck_log("screen", "%s, %s: %dx%d scene shown %dx%d at %d,%d on the %dx%d output",
|
||||
banner_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);
|
||||
@@ -404,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 (droiddeck_color.h): the colour-space extension lets the swapchain carry HDR10 for
|
||||
/* HDR sessions only (banner_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 (droiddeck_color_requested() && g_vk.EnumerateInstanceExtensionProperties) {
|
||||
if (banner_color_requested() && g_vk.EnumerateInstanceExtensionProperties) {
|
||||
uint32_t ne = 0;
|
||||
g_vk.EnumerateInstanceExtensionProperties(NULL, &ne, NULL);
|
||||
VkExtensionProperties *ie = ne ? calloc(ne, sizeof(*ie)) : NULL;
|
||||
@@ -420,14 +407,14 @@ static int dev_init(void) {
|
||||
}
|
||||
}
|
||||
free(ie);
|
||||
droiddeck_log("color", "compositor instance %s VK_EXT_swapchain_colorspace (an HDR10 swapchain for frame "
|
||||
banner_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 = "droiddeck-wayland-present",
|
||||
.pApplicationName = "banner-wayland-present",
|
||||
.apiVersion = VK_API_VERSION_1_3};
|
||||
VkInstanceCreateInfo ici = {.sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO,
|
||||
.pApplicationInfo = &app,
|
||||
@@ -457,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);
|
||||
droiddeck_log("gpu", "compositor renders on %s with %s", props.deviceName,
|
||||
banner_log("gpu", "compositor renders on %s with %s", props.deviceName,
|
||||
g_library_name ? g_library_name : "the system Vulkan driver");
|
||||
if (g_driver_path) droiddeck_log("gpu", "driver folder %s", g_driver_path);
|
||||
if (g_driver_path) banner_log("gpu", "driver folder %s", g_driver_path);
|
||||
}
|
||||
g_vk.GetPhysicalDeviceMemoryProperties(g_pd, &g_memprops);
|
||||
|
||||
@@ -477,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 (droiddeck_color_requested()) {
|
||||
if (banner_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;
|
||||
}
|
||||
@@ -500,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) {
|
||||
droiddeck_log("framegen", "the driver refused the LSFG feature set (%s); device created without it",
|
||||
banner_log("framegen", "the driver refused the LSFG feature set (%s); device created without it",
|
||||
vk_result_name(dr));
|
||||
dci.pNext = NULL;
|
||||
fg_features = NULL;
|
||||
@@ -519,13 +506,13 @@ static int dev_init(void) {
|
||||
g_wait_sem = VK_NULL_HANDLE;
|
||||
}
|
||||
}
|
||||
droiddeck_log("perf", "layer buffers: the display's release fences are waited for %s",
|
||||
banner_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 (droiddeck_color_requested())
|
||||
droiddeck_log("color", "compositor device %s VK_EXT_hdr_metadata (the HDR10 swapchain for frame generation %s)",
|
||||
if (banner_color_requested())
|
||||
banner_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");
|
||||
|
||||
@@ -654,7 +641,7 @@ static void log_surface_formats(const VkSurfaceFormatKHR *f, uint32_t n) {
|
||||
vk_colorspace_short(f[i].colorSpace));
|
||||
else hdr_cut++;
|
||||
}
|
||||
droiddeck_log("color", "screen surface lists %u format/colour-space pairs (%s); HDR-capable: %s%s", n, per,
|
||||
banner_log("color", "screen surface lists %u format/colour-space pairs (%s); HDR-capable: %s%s", n, per,
|
||||
hdr_n ? hdr : "none", hdr_cut ? " (+more)" : "");
|
||||
}
|
||||
|
||||
@@ -691,7 +678,7 @@ static int swap_init_locked(void) {
|
||||
}
|
||||
VkSurfaceFormatKHR chosen = fmts[0];
|
||||
const VkSurfaceFormatKHR sdr_choice = fmts[0];
|
||||
if (droiddeck_color_requested() && (!g_surface_formats_said || g_swap_hdr_want)) {
|
||||
if (banner_color_requested() && (!g_surface_formats_said || g_swap_hdr_want)) {
|
||||
g_surface_formats_said = 1;
|
||||
log_surface_formats(fmts, nfmt);
|
||||
}
|
||||
@@ -715,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));
|
||||
droiddeck_log("color", "screen swapchain built as HDR10 (format %d %s, HDR10_ST2084): frame-generated HDR "
|
||||
banner_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;
|
||||
droiddeck_log("color", "the screen surface lists no HDR10 swapchain format among its %u format/colour-space "
|
||||
banner_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);
|
||||
}
|
||||
@@ -761,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. */
|
||||
droiddeck_log("color", "the driver refused the HDR10 swapchain (%s): frames with frame generation are "
|
||||
banner_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;
|
||||
@@ -780,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);
|
||||
|
||||
droiddeck_log("gpu", "screen output %ux%u, %u buffers, vsync%s", g_extent.width, g_extent.height, g_nimg,
|
||||
banner_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;
|
||||
}
|
||||
@@ -846,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
|
||||
droiddeck_log("gpu", "letterbox black image unavailable: the whole output is cleared every copy-path frame");
|
||||
banner_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
|
||||
@@ -984,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; DROIDDECK_WAYLAND_UBWC=0 forces linear buffers" : "");
|
||||
? ": the game's UBWC layout was refused; BANNER_WAYLAND_UBWC=0 forces linear buffers" : "");
|
||||
free(img); return NULL;
|
||||
}
|
||||
|
||||
@@ -1195,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;
|
||||
droiddeck_log("effects", "scene image %dx%d for the effect chain", w, h);
|
||||
banner_log("effects", "scene image %dx%d for the effect chain", w, h);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -1231,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));
|
||||
droiddeck_log("color", "%s image %dx%d (%s)", what, w, h, fname);
|
||||
banner_log("color", "%s image %dx%d (%s)", what, w, h, fname);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -1262,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 = droiddeck_color_sdr_white(),
|
||||
struct hdrc_params p = {.mode = mode, .sdr_white_nits = banner_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++) {
|
||||
@@ -1285,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;
|
||||
droiddeck_log("color", "HDR composition: more than %d windows are stacked over the HDR game - the lowest "
|
||||
banner_log("color", "HDR composition: more than %d windows are stacked over the HDR game - the lowest "
|
||||
"ones are treated as SDR", HDRC_MAX_RECTS);
|
||||
}
|
||||
}
|
||||
@@ -1300,7 +1287,7 @@ static void device_lost(const char *where) {
|
||||
g_dev_state = -2;
|
||||
g_base_black = 0;
|
||||
vkp_framegen_device_lost();
|
||||
droiddeck_log("error", "GPU device lost (VK_ERROR_DEVICE_LOST in %s): the compositor has stopped presenting; "
|
||||
banner_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);
|
||||
@@ -1398,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) {
|
||||
droiddeck_log("gpu", "screen surface %s on acquire: rebuilding the swapchain", vk_result_name(ar));
|
||||
banner_log("gpu", "screen surface %s on acquire: rebuilding the swapchain", vk_result_name(ar));
|
||||
destroy_swapchain();
|
||||
if (first && attempt == 0) continue;
|
||||
return -1;
|
||||
}
|
||||
droiddeck_log("error", "present: acquire %d failed (%s %d)", k, vk_result_name(ar), (int)ar);
|
||||
banner_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;
|
||||
@@ -1435,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;
|
||||
droiddeck_log("gpu", "screen surface changed (%ux%u -> %ux%u, transform %d -> %d): rebuilding the swapchain",
|
||||
banner_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();
|
||||
@@ -1477,7 +1464,7 @@ static int take_wait_fd(int fd) {
|
||||
static int said;
|
||||
if (!said) {
|
||||
said = 1;
|
||||
droiddeck_log("perf", "layer buffers: importing a release fence as a GPU wait failed; waiting for it on the CPU");
|
||||
banner_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};
|
||||
@@ -1489,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 droiddeck_color *c) {
|
||||
static void send_hdr_metadata(const struct banner_color *c) {
|
||||
static int said_none, said_nodesc;
|
||||
if (!c) return;
|
||||
if (!g_set_hdr_metadata) {
|
||||
if (!said_none) { said_none = 1; droiddeck_log("color", "HDR10 swapchain without the game's metadata: this device "
|
||||
if (!said_none) { said_none = 1; banner_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;
|
||||
droiddeck_log("color", "HDR10 swapchain: image description #%u carries no HDR metadata (the game sent none), "
|
||||
banner_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;
|
||||
@@ -1519,7 +1506,7 @@ static void send_hdr_metadata(const struct droiddeck_color *c) {
|
||||
md.maxFrameAverageLightLevel = c->max_fall;
|
||||
}
|
||||
g_set_hdr_metadata(g_dev, 1, &g_swapchain, &md);
|
||||
droiddeck_log("color", "HDR10 swapchain: the game's metadata set via VK_EXT_hdr_metadata (image description #%u: %s)",
|
||||
banner_log("color", "HDR10 swapchain: the game's metadata set via VK_EXT_hdr_metadata (image description #%u: %s)",
|
||||
c->identity, c->text);
|
||||
}
|
||||
|
||||
@@ -1529,14 +1516,13 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i
|
||||
const struct vkp_hdr_frame *hf, int *how) {
|
||||
if (how) *how = 0;
|
||||
vkp_apply_window_request();
|
||||
check_surface_changed(); /* a rotation or a fold the rebind did not cover: rebuild at the new size */
|
||||
if (g_dev_state == -2) return -1;
|
||||
if (dev_init() != 0 || !g_window || scene_w <= 0 || scene_h <= 0) return -1;
|
||||
|
||||
/* 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) {
|
||||
droiddeck_log("gpu", "frame generation now wants %d present%s per frame: rebuilding the swapchain",
|
||||
banner_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();
|
||||
}
|
||||
@@ -1545,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) {
|
||||
droiddeck_log("color", want_hdr ? "HDR frames with frame generation: rebuilding the screen swapchain as HDR10"
|
||||
banner_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();
|
||||
@@ -1584,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)
|
||||
droiddeck_log("color", "frame generation runs on the HDR picture in FP16 (RGBA16F): more than 8 bits of PQ "
|
||||
banner_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
|
||||
droiddeck_log("color", "frame generation runs on the HDR picture in 8 bits (the engine cannot take FP16 here), "
|
||||
banner_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);
|
||||
}
|
||||
}
|
||||
@@ -1760,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) {
|
||||
droiddeck_log("framegen", "present %d/%d: no swapchain image; the frame ends early", k + 1, presents);
|
||||
banner_log("framegen", "present %d/%d: no swapchain image; the frame ends early", k + 1, presents);
|
||||
pr = VK_ERROR_OUT_OF_DATE_KHR;
|
||||
break;
|
||||
}
|
||||
@@ -1805,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. */
|
||||
droiddeck_log("gpu", "screen surface %s on present: rebuilding the swapchain", vk_result_name(pr));
|
||||
banner_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) {
|
||||
@@ -1813,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; droiddeck_log("gpu", "surface reports SUBOPTIMAL (panel rotation); presenting as is"); }
|
||||
if (!said) { said = 1; banner_log("gpu", "surface reports SUBOPTIMAL (panel rotation); presenting as is"); }
|
||||
} else if (pr != VK_SUCCESS) {
|
||||
droiddeck_log("error", "present: vkQueuePresentKHR failed (%s %d)", vk_result_name(pr), (int)pr);
|
||||
banner_log("error", "present: vkQueuePresentKHR failed (%s %d)", vk_result_name(pr), (int)pr);
|
||||
destroy_swapchain();
|
||||
return -1;
|
||||
}
|
||||
@@ -1829,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;
|
||||
droiddeck_on_first_frame();
|
||||
banner_on_first_frame();
|
||||
}
|
||||
if (how) *how = hdr_how;
|
||||
return 0;
|
||||
@@ -1870,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;
|
||||
droiddeck_on_first_frame();
|
||||
banner_on_first_frame();
|
||||
}
|
||||
|
||||
int vkp_update_map(int scene_w, int scene_h) {
|
||||
@@ -2113,7 +2099,6 @@ static int pass_begin_impl(int scene_w, int scene_h, const struct vkp_draw *draw
|
||||
const struct vkp_hdr_frame *hf, int *rw, int *rh) {
|
||||
if (g_pass.active) vkp_pass_abort();
|
||||
if (g_dev_state == -2 || dev_init() != 0 || !g_window || scene_w <= 0 || scene_h <= 0) return -1;
|
||||
check_surface_changed(); /* as in render_impl: the effects pass maps onto the output's size */
|
||||
if (!g_swapchain && swap_init() != 0) return -1; /* the mapping is in output pixels */
|
||||
update_map(scene_w, scene_h);
|
||||
if (!g_map.valid) return -1;
|
||||
|
||||
@@ -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 droiddeck_color;
|
||||
struct banner_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 droiddeck_color *color; /* the topmost HDR draw's description: an HDR10 swapchain's metadata */
|
||||
const struct banner_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 droiddeck_log(const char *tag, const char *fmt, ...) __attribute__((format(printf, 2, 3)));
|
||||
void banner_log(const char *tag, const char *fmt, ...) __attribute__((format(printf, 2, 3)));
|
||||
|
||||
#endif
|
||||
@@ -16,21 +16,20 @@
|
||||
#include <android/log.h>
|
||||
#include <android/native_window_jni.h>
|
||||
#include "vk_present.h"
|
||||
#include "droiddeck_ext.h"
|
||||
#include "banner_ext.h"
|
||||
#include "ahb_swapchain.h"
|
||||
#include "sc_layer.h"
|
||||
#include "effects_chain.h"
|
||||
#include "droiddeck_color.h"
|
||||
#include "banner_color.h"
|
||||
|
||||
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 void droiddeck_wayland_output_resized(void);
|
||||
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 volatile int g_fps_limit;
|
||||
extern volatile int g_hide_shell;
|
||||
extern volatile int g_zero_copy;
|
||||
@@ -40,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 "DroidDeckWayland"
|
||||
#define TAG "BannerWayland"
|
||||
|
||||
static JavaVM *g_jvm;
|
||||
static volatile pid_t g_comp_tid; /* the compositor thread, for the app's ADPF hint session */
|
||||
@@ -81,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 droiddeck_on_first_frame(void) {
|
||||
void banner_on_first_frame(void) {
|
||||
if (!g_jvm || !g_compositor_cls || !g_on_first_frame) return;
|
||||
JNIEnv *env = NULL;
|
||||
int attached = 0;
|
||||
@@ -112,7 +111,7 @@ static JNIEnv *thread_env(void) {
|
||||
}
|
||||
|
||||
/* A window started presenting GPU frames (window = its description), or NULL when it closed. */
|
||||
void droiddeck_on_game_surface(const char *window, const char *gpu) {
|
||||
void banner_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;
|
||||
@@ -125,7 +124,7 @@ void droiddeck_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 droiddeck_on_game_program(int pid, const char *program) {
|
||||
void banner_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 : "");
|
||||
@@ -135,7 +134,7 @@ void droiddeck_on_game_program(int pid, const char *program) {
|
||||
}
|
||||
|
||||
/* One GPU frame from that window. */
|
||||
void droiddeck_on_game_frame(void) {
|
||||
void banner_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);
|
||||
@@ -145,7 +144,7 @@ void droiddeck_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 droiddeck_on_pointer_lock(int locked, int x, int y) {
|
||||
void banner_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);
|
||||
@@ -154,7 +153,7 @@ void droiddeck_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 droiddeck_on_clipboard_text(const char *utf8, int len) {
|
||||
void banner_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);
|
||||
@@ -167,7 +166,7 @@ void droiddeck_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 droiddeck_on_text_input(int enabled, const char *program, int x, int y, int w, int h) {
|
||||
void banner_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 : "");
|
||||
@@ -196,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)
|
||||
droiddeck_log("perf", "compositor thread %d \"wl-compositor\": stays at nice %d, a higher priority was refused (%s)",
|
||||
banner_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
|
||||
droiddeck_log("perf", "compositor thread %d \"wl-compositor\": nice %d -> %d", (int)tid, before, after);
|
||||
droiddeck_wayland_run();
|
||||
banner_log("perf", "compositor thread %d \"wl-compositor\": nice %d -> %d", (int)tid, before, after);
|
||||
banner_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;
|
||||
@@ -273,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) {
|
||||
droiddeck_wayland_send_pointer(action, x, y);
|
||||
banner_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) {
|
||||
droiddeck_wayland_send_touch(action, pointer_id, x, y);
|
||||
banner_wayland_send_touch(action, pointer_id, x, y);
|
||||
}
|
||||
|
||||
/* The client's pointer image and whether it wants one at all (wl_pointer.set_cursor).
|
||||
@@ -292,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 = droiddeck_cursor_snapshot((int *)buf, (int)cap);
|
||||
int n = banner_cursor_snapshot((int *)buf, (int)cap);
|
||||
(*env)->ReleaseIntArrayElements(env, out, buf, 0);
|
||||
return n;
|
||||
}
|
||||
@@ -301,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 droiddeck_cursor_serial();
|
||||
return banner_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) {
|
||||
droiddeck_wayland_send_key(evdev, state);
|
||||
banner_wayland_send_key(evdev, state);
|
||||
}
|
||||
|
||||
/* App X-server input in scene (virtual desktop) coordinates; see droiddeck_wayland_send_scene_input. */
|
||||
/* App X-server input in scene (virtual desktop) coordinates; see banner_wayland_send_scene_input. */
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSendSceneInput(
|
||||
JNIEnv *env, jclass clazz, jint type, jint a, jint b) {
|
||||
droiddeck_wayland_send_scene_input(type, a, b);
|
||||
banner_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) {
|
||||
droiddeck_wayland_vsync((int64_t)frameTimeNanos);
|
||||
banner_wayland_vsync((int64_t)frameTimeNanos);
|
||||
}
|
||||
|
||||
/* Shortcut launches: don't draw explorer's windows (desktop, taskbar), like X11's unviewable classes. */
|
||||
@@ -330,15 +329,15 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetHideShell(JNIEnv
|
||||
g_hide_shell = hide ? 1 : 0;
|
||||
}
|
||||
|
||||
/* Zero-copy layer mode (DROIDDECK_WAYLAND_ZERO_COPY=1 at launch, the drawer's switch live): one fullscreen
|
||||
/* Zero-copy layer mode (BANNER_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 = droiddeck_get_display() != NULL;
|
||||
int live = banner_get_display() != NULL;
|
||||
if (!live) g_zero_copy = on ? 1 : 0; /* read by ahb_swapchain_init before the queue drains */
|
||||
droiddeck_host_zero_copy(on ? 1 : 0, live);
|
||||
banner_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)" : "");
|
||||
}
|
||||
|
||||
@@ -356,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; DROIDDECK_WAYLAND_UBWC=0 = off). Set before the compositor
|
||||
* the renderer's driver imports it (default on; BANNER_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) {
|
||||
@@ -364,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 (DROIDDECK_WAYLAND_NO_RENDER_NODE=1), the way a
|
||||
/* Debug: name no DRM device in the dma-buf feedback (BANNER_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) {
|
||||
@@ -402,17 +401,6 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetOutputSize(JNIEnv
|
||||
g_output_h = h > 0 ? h : 0;
|
||||
}
|
||||
|
||||
/* The output's size changed while the compositor runs (a foldable opening or closing with the
|
||||
* session following the screen): store it and have the compositor thread tell the clients. */
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeResizeOutput(JNIEnv *env, jclass clazz, jint w, jint h) {
|
||||
(void)env; (void)clazz;
|
||||
if (w <= 0 || h <= 0) return;
|
||||
g_output_w = w;
|
||||
g_output_h = h;
|
||||
droiddeck_wayland_output_resized();
|
||||
}
|
||||
|
||||
/* Fullscreen mode + screen alignment (Container.FULLSCREEN_* / ALIGN_* values): how the scene is
|
||||
* fitted onto the output. Any thread, any time; the next frame uses it. */
|
||||
JNIEXPORT void JNICALL
|
||||
@@ -464,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. droiddeck_log() mirrors to
|
||||
* framework (the panel's HDR capability) rather than in the compositor. banner_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) droiddeck_log("display", "%s", s);
|
||||
if (s) banner_log("display", "%s", s);
|
||||
free(s);
|
||||
}
|
||||
|
||||
@@ -478,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) droiddeck_log("perf", "%s", s);
|
||||
if (s) banner_log("perf", "%s", s);
|
||||
free(s);
|
||||
}
|
||||
|
||||
/* ---- HDR10 output, round 1 (droiddeck_color.h / wl_color_mgmt.c) ---- */
|
||||
/* ---- HDR10 output, round 1 (banner_color.h / wl_color_mgmt.c) ---- */
|
||||
|
||||
/* The opt-in: mode 0 off, 1 DROIDDECK_WAYLAND_HDR=1, 2 =force (testing). Before the compositor starts. */
|
||||
/* The opt-in: mode 0 off, 1 BANNER_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);
|
||||
droiddeck_color_set_request((int)mode, s, dxvkHdr ? 1 : 0, zeroCopyForced ? 1 : 0);
|
||||
banner_color_set_request((int)mode, s, dxvkHdr ? 1 : 0, zeroCopyForced ? 1 : 0);
|
||||
free(s);
|
||||
}
|
||||
|
||||
@@ -499,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);
|
||||
droiddeck_color_set_display((int)id, n, f, hdr10 ? 1 : 0, (float)maxLum, (float)maxAvg, (float)minLum,
|
||||
banner_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);
|
||||
}
|
||||
@@ -508,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) {
|
||||
droiddeck_color_ratio_sample((float)ratio, listener ? 1 : 0);
|
||||
banner_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)droiddeck_color_last_frame_age_ms();
|
||||
return (jint)banner_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)droiddeck_color_gate_state();
|
||||
return (jint)banner_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) {
|
||||
droiddeck_color_session_end();
|
||||
banner_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 droiddeck_color_hdr_on_screen() ? JNI_TRUE : JNI_FALSE;
|
||||
return banner_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) droiddeck_log("color", "%s", s);
|
||||
if (s) banner_log("color", "%s", s);
|
||||
free(s);
|
||||
}
|
||||
|
||||
@@ -548,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) droiddeck_log(a && a[0] ? a : "app", "%s", s);
|
||||
if (s) banner_log(a && a[0] ? a : "app", "%s", s);
|
||||
free(a);
|
||||
free(s);
|
||||
}
|
||||
@@ -556,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) {
|
||||
droiddeck_color_set_sdr_white((float)nits);
|
||||
banner_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 (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 */
|
||||
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 */
|
||||
}
|
||||
|
||||
JNIEXPORT jboolean JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeHdrOutput(JNIEnv *env, jclass clazz) {
|
||||
return droiddeck_color_output() ? JNI_TRUE : JNI_FALSE;
|
||||
return banner_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) {
|
||||
droiddeck_color_env_sample((int)thermal, (float)headroom != (float)headroom ? -1.0f : (float)headroom,
|
||||
banner_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) {
|
||||
droiddeck_color_set_highest_ratio((float)ratio == (float)ratio ? (float)ratio : -1.0f);
|
||||
banner_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)droiddeck_color_screen_headroom(NULL, 0);
|
||||
return (jfloat)banner_color_screen_headroom(NULL, 0);
|
||||
}
|
||||
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeHdrScreenHeadroomWhy(JNIEnv *env, jclass clazz) {
|
||||
char why[200];
|
||||
droiddeck_color_screen_headroom(why, sizeof(why));
|
||||
banner_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) {
|
||||
droiddeck_color_note_headroom_request((float)ratio);
|
||||
banner_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)droiddeck_color_hdr_state();
|
||||
return (jint)banner_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 droiddeck_color_tonemapped_on_screen() ? JNI_TRUE : JNI_FALSE;
|
||||
return banner_color_tonemapped_on_screen() ? JNI_TRUE : JNI_FALSE;
|
||||
}
|
||||
|
||||
/* The Look the controls currently match (null = Custom) - only named in the session log. */
|
||||
@@ -663,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);
|
||||
droiddeck_host_clipboard_text(buf ? buf : "", len);
|
||||
banner_host_clipboard_text(buf ? buf : "", len);
|
||||
free(buf);
|
||||
}
|
||||
|
||||
@@ -672,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) droiddeck_host_text_commit(buf, len);
|
||||
if (buf && len) banner_host_text_commit(buf, len);
|
||||
free(buf);
|
||||
}
|
||||
|
||||
@@ -682,14 +670,14 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeTextInputPreedit(JNI
|
||||
jint cursorBegin, jint cursorEnd) {
|
||||
int len;
|
||||
char *buf = dup_bytes(env, utf8, &len);
|
||||
droiddeck_host_text_preedit(buf ? buf : "", len, cursorBegin, cursorEnd);
|
||||
banner_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) {
|
||||
droiddeck_host_text_delete(before, after);
|
||||
banner_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 (droiddeck_on_clipboard_text → ClipboardManager). Android → guest: the app's text
|
||||
* to the app (banner_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 "droiddeck_ext.h"
|
||||
#include "banner_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;
|
||||
droiddeck_log("clipboard", "a program never read the clipboard text it asked for; giving up");
|
||||
banner_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;
|
||||
droiddeck_log("clipboard", "guest copied %zu bytes (%s)", r->len, r->mime);
|
||||
droiddeck_on_clipboard_text(copy, (int)r->len);
|
||||
banner_log("clipboard", "guest copied %zu bytes (%s)", r->len, r->mime);
|
||||
banner_on_clipboard_text(copy, (int)r->len);
|
||||
}
|
||||
} else {
|
||||
droiddeck_log("clipboard", "guest copied an empty text; Android clipboard left alone");
|
||||
banner_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) {
|
||||
droiddeck_log("clipboard", "guest text exceeds %d bytes; dropped", CLIP_MAX_BYTES);
|
||||
banner_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;
|
||||
droiddeck_log("clipboard", "timed out reading the guest's clipboard text (%zu bytes so far)", r->len);
|
||||
banner_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)) {
|
||||
droiddeck_log("clipboard", "Android clipboard → guest (%zu bytes) for %s", g_host_len, droiddeck_client_name(c));
|
||||
banner_log("clipboard", "Android clipboard → guest (%zu bytes) for %s", g_host_len, banner_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 droiddeck_clipboard_keyboard_focus(struct wl_client *client) {
|
||||
void banner_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);
|
||||
droiddeck_log("clipboard", "%s withdrew its clipboard contents", droiddeck_client_name(wl_resource_get_client(r)));
|
||||
banner_log("clipboard", "%s withdrew its clipboard contents", banner_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);
|
||||
}
|
||||
droiddeck_log("clipboard", "selection set by %s (%s)", droiddeck_client_name(c), *list ? list : "no mime types");
|
||||
banner_log("clipboard", "selection set by %s (%s)", banner_client_name(c), *list ? list : "no mime types");
|
||||
} else {
|
||||
droiddeck_log("clipboard", "%s cleared the clipboard", droiddeck_client_name(c));
|
||||
banner_log("clipboard", "%s cleared the clipboard", banner_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) {
|
||||
droiddeck_log("clipboard", "%s started a drag and drop; not supported, cancelled", droiddeck_client_name(c));
|
||||
banner_log("clipboard", "%s started a drag and drop; not supported, cancelled", banner_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;
|
||||
droiddeck_log("clipboard", len ? "Android clipboard → guest (%zu bytes)" : "Android clipboard cleared (%zu bytes)", len);
|
||||
banner_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 droiddeck_color.h for the design and HDR_RECON.md for why it is shaped this way.
|
||||
* HDR evidence. See banner_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 "droiddeck_color.h"
|
||||
#include "droiddeck_ext.h"
|
||||
#include "banner_color.h"
|
||||
#include "banner_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 *droiddeck_ahb_format_name(uint32_t f) {
|
||||
const char *banner_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 DROIDDECK_ADATASPACE_BT2020_PQ: return "BT2020_PQ";
|
||||
case DROIDDECK_ADATASPACE_BT2020_HLG: return "BT2020_HLG";
|
||||
case DROIDDECK_ADATASPACE_UNKNOWN: return "UNKNOWN (sRGB)";
|
||||
case BANNER_ADATASPACE_BT2020_PQ: return "BT2020_PQ";
|
||||
case BANNER_ADATASPACE_BT2020_HLG: return "BT2020_HLG";
|
||||
case BANNER_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 droiddeck_color_set_request(int mode, const char *source, int dxvk_hdr, int zero_copy_forced) {
|
||||
void banner_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 droiddeck_color_set_request(int mode, const char *source, int dxvk_hdr, int
|
||||
atomic_store(&g_output_on, 1); /* the drawer's switch is per session and starts on */
|
||||
}
|
||||
|
||||
void droiddeck_color_set_display(int id, const char *name, const char *formats, int hdr10, float max_lum,
|
||||
void banner_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 droiddeck_color_set_display(int id, const char *name, const char *formats,
|
||||
* 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)
|
||||
droiddeck_log(TAG, "the game is now on \"%s\" (display %d), which reports no HDR10: HDR frames stay tagged "
|
||||
banner_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)
|
||||
droiddeck_log(TAG, "the game is now on \"%s\" (display %d), which reports HDR10 - HDR stays off for this "
|
||||
banner_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 droiddeck_color_gate_state(void) { return atomic_load(&g_gate); }
|
||||
int droiddeck_color_hdr_open(void) { return atomic_load(&g_gate) == 1; }
|
||||
int banner_color_gate_state(void) { return atomic_load(&g_gate); }
|
||||
int banner_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; droiddeck_color_env_*). The Fold showed
|
||||
/* What the device says beside the headroom (the app, non-root APIs; banner_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 droiddeck_color *c, float disp_max, const char **src) {
|
||||
static float peak_of(const struct banner_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 droiddeck_color_desired_headroom(const struct droiddeck_color *c, char *why, size_t n) {
|
||||
float banner_color_desired_headroom(const struct banner_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 = droiddeck_color_sdr_white();
|
||||
const float peak = peak_of(c, disp_max, &src), white = banner_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 droiddeck_color_desired_headroom(const struct droiddeck_color *c, char *wh
|
||||
return r;
|
||||
}
|
||||
|
||||
void droiddeck_color_note_headroom_request(float ratio) {
|
||||
void banner_color_note_headroom_request(float ratio) {
|
||||
pthread_mutex_lock(&g_mu);
|
||||
g_env.requested = ratio;
|
||||
pthread_mutex_unlock(&g_mu);
|
||||
}
|
||||
|
||||
void droiddeck_color_set_highest_ratio(float ratio) {
|
||||
void banner_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)
|
||||
droiddeck_log(TAG, "the display's highest HDR/SDR ratio is %.2f%s", ratio,
|
||||
banner_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 droiddeck_color_screen_headroom(char *why, size_t n) {
|
||||
float banner_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 droiddeck_color_screen_headroom(char *why, size_t n) {
|
||||
return atomic_load(&g_screen_hr_x1000) / 1000.0f;
|
||||
}
|
||||
|
||||
int droiddeck_color_last_frame_age_ms(void) {
|
||||
int banner_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) droiddeck_log(TAG, "HDR on screen: %s", v);
|
||||
if (changed || force) banner_log(TAG, "HDR on screen: %s", v);
|
||||
}
|
||||
|
||||
void droiddeck_color_frame_shown(const struct droiddeck_color *c, int path, uint32_t ahb_format) {
|
||||
void banner_color_frame_shown(const struct banner_color *c, int path, uint32_t ahb_format) {
|
||||
if (!c || !c->dataspace) return;
|
||||
int first8 = 0;
|
||||
pthread_mutex_lock(&g_mu);
|
||||
switch (path) {
|
||||
case DROIDDECK_HDR_ZERO_COPY:
|
||||
case BANNER_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", droiddeck_ahb_format_name(ahb_format));
|
||||
snprintf(g_hdr.layer_fmt, sizeof(g_hdr.layer_fmt), "%s", banner_ahb_format_name(ahb_format));
|
||||
break;
|
||||
case DROIDDECK_HDR_LAYER_COPY:
|
||||
case BANNER_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 DROIDDECK_HDR_COMPOSED:
|
||||
case BANNER_HDR_COMPOSED:
|
||||
g_hdr.composed++; g_hdr.win_composed++;
|
||||
snprintf(g_hdr.layer_fmt, sizeof(g_hdr.layer_fmt), "composed %s", droiddeck_ahb_format_name(ahb_format));
|
||||
snprintf(g_hdr.layer_fmt, sizeof(g_hdr.layer_fmt), "composed %s", banner_ahb_format_name(ahb_format));
|
||||
break;
|
||||
case DROIDDECK_HDR_SWAPCHAIN:
|
||||
case BANNER_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: /* DROIDDECK_HDR_TONEMAPPED: shown, but not as HDR */
|
||||
default: /* BANNER_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 droiddeck_color_frame_shown(const struct droiddeck_color *c, int path, uint
|
||||
g_hdr.win_layer++;
|
||||
pthread_mutex_unlock(&g_mu);
|
||||
atomic_store(&g_last_frame_ns, now_ns());
|
||||
if (path == DROIDDECK_HDR_SWAPCHAIN) {
|
||||
if (path == BANNER_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 droiddeck_color_frame_shown(const struct droiddeck_color *c, int path, uint
|
||||
const int64_t t = now_ns();
|
||||
if (c->identity != ident || t - calc_ns > 1000000000LL) {
|
||||
char why[200];
|
||||
const float r = droiddeck_color_desired_headroom(c, why, sizeof(why));
|
||||
const float r = banner_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 droiddeck_color_frame_shown(const struct droiddeck_color *c, int path, uint
|
||||
}
|
||||
}
|
||||
if (first8)
|
||||
droiddeck_log(TAG, "HDR frames are reaching the display layer through the compositor's 8-bit layer copy (the game's "
|
||||
banner_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 droiddeck_color_frame_copied(const char *who, const char *reason) {
|
||||
void banner_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 droiddeck_color_frame_copied(const char *who, const char *reason) {
|
||||
}
|
||||
pthread_mutex_unlock(&g_mu);
|
||||
if (first)
|
||||
droiddeck_log(TAG, "HDR frames of %s go through the compositor's 8-bit SDR copy now, because %s: shown WITHOUT "
|
||||
banner_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 droiddeck_color_ratio_sample(float ratio, int listener) {
|
||||
void banner_color_ratio_sample(float ratio, int listener) {
|
||||
if (atomic_load(&g_gate) != 1) return;
|
||||
int64_t t = now_ns();
|
||||
int age = droiddeck_color_last_frame_age_ms();
|
||||
int age = banner_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 droiddeck_color_ratio_sample(float ratio, int listener) {
|
||||
}
|
||||
pthread_mutex_unlock(&g_mu);
|
||||
if (nohead_now)
|
||||
droiddeck_log(TAG, "no HDR headroom for 5 s while HDR frames are on screen (display HDR/SDR ratio %.2f): %s - Android "
|
||||
banner_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)
|
||||
droiddeck_log(TAG, "HDR headroom is back: display HDR/SDR ratio %.2f with HDR frames on screen", ratio);
|
||||
banner_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)
|
||||
droiddeck_log(TAG, "display HDR/SDR ratio %.2f (steady; HDR frames on screen: %s)%s%s%s%s%s", ratio, when,
|
||||
banner_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)
|
||||
droiddeck_log(TAG, "display HDR/SDR ratio %.2f (was %.2f; HDR frames on screen: %s) [%s]", ratio, was, when,
|
||||
banner_log(TAG, "display HDR/SDR ratio %.2f (was %.2f; HDR frames on screen: %s) [%s]", ratio, was, when,
|
||||
listener ? "display listener" : "sampler");
|
||||
else
|
||||
droiddeck_log(TAG, "display HDR/SDR ratio %.2f (first reading; HDR frames on screen: %s) [%s]", ratio, when,
|
||||
banner_log(TAG, "display HDR/SDR ratio %.2f (first reading; HDR frames on screen: %s) [%s]", ratio, when,
|
||||
listener ? "display listener" : "sampler");
|
||||
}
|
||||
|
||||
void droiddeck_color_stats_tick(void) {
|
||||
void banner_color_stats_tick(void) {
|
||||
if (atomic_load(&g_gate) != 1) return;
|
||||
char line[512];
|
||||
int any;
|
||||
@@ -582,13 +582,13 @@ void droiddeck_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) droiddeck_log(TAG, "%s", line);
|
||||
if (any) banner_log(TAG, "%s", line);
|
||||
log_verdict(0);
|
||||
}
|
||||
|
||||
int droiddeck_color_hdr_state(void) {
|
||||
int banner_color_hdr_state(void) {
|
||||
if (atomic_load(&g_gate) != 1) return 0;
|
||||
int age = droiddeck_color_last_frame_age_ms();
|
||||
int age = banner_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 droiddeck_color_hdr_state(void) {
|
||||
return confirmed ? 1 : nohead ? 2 : 0;
|
||||
}
|
||||
|
||||
int droiddeck_color_hdr_on_screen(void) { return droiddeck_color_hdr_state() == 1; }
|
||||
int banner_color_hdr_on_screen(void) { return banner_color_hdr_state() == 1; }
|
||||
|
||||
static _Atomic int g_sdr_white_x100 = 20300;
|
||||
void droiddeck_color_set_sdr_white(float nits) {
|
||||
void banner_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));
|
||||
droiddeck_log(TAG, "SDR content inside an HDR picture is placed at %.0f nits (DROIDDECK_WAYLAND_HDR_SDR_NITS)", nits);
|
||||
banner_log(TAG, "SDR content inside an HDR picture is placed at %.0f nits (BANNER_WAYLAND_HDR_SDR_NITS)", nits);
|
||||
}
|
||||
float droiddeck_color_sdr_white(void) { return atomic_load(&g_sdr_white_x100) / 100.0f; }
|
||||
float banner_color_sdr_white(void) { return atomic_load(&g_sdr_white_x100) / 100.0f; }
|
||||
|
||||
int droiddeck_color_requested(void) {
|
||||
int banner_color_requested(void) {
|
||||
pthread_mutex_lock(&g_mu);
|
||||
int m = g_req.mode;
|
||||
pthread_mutex_unlock(&g_mu);
|
||||
return m != 0;
|
||||
}
|
||||
|
||||
int droiddeck_color_output(void) { return atomic_load(&g_output_on); }
|
||||
int banner_color_output(void) { return atomic_load(&g_output_on); }
|
||||
|
||||
void droiddeck_color_env_sample(int thermal, float headroom, int brightness, int bmode) {
|
||||
void banner_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 droiddeck_color_env_sample(int thermal, float headroom, int brightness, int
|
||||
"brightness changes are logged as they happen)", e);
|
||||
}
|
||||
pthread_mutex_unlock(&g_mu);
|
||||
for (int i = 0; i < nmsg; i++) droiddeck_log(TAG, "%s", msg[i]);
|
||||
for (int i = 0; i < nmsg; i++) banner_log(TAG, "%s", msg[i]);
|
||||
}
|
||||
|
||||
int droiddeck_color_tonemapped_on_screen(void) {
|
||||
int banner_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 droiddeck_color_set_output(int on) {
|
||||
void banner_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. */
|
||||
droiddeck_log(TAG, "HDR output switch (%s) ignored: HDR is not open in this session", on ? "on" : "off");
|
||||
banner_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 = droiddeck_color_last_frame_age_ms(), tm = droiddeck_color_tonemapped_on_screen();
|
||||
int age = banner_color_last_frame_age_ms(), tm = banner_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)
|
||||
droiddeck_log(TAG, "HDR output switched ON in the drawer: HDR frames go to the display as HDR again from the next "
|
||||
banner_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
|
||||
droiddeck_log(TAG, "HDR output switched OFF in the drawer: HDR frames are tone-mapped to SDR from the next frame "
|
||||
banner_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");
|
||||
droiddeck_request_redraw(); /* a paused game commits nothing: show the change now */
|
||||
banner_request_redraw(); /* a paused game commits nothing: show the change now */
|
||||
}
|
||||
|
||||
static _Atomic int g_session_ended;
|
||||
|
||||
void droiddeck_color_session_end(void) {
|
||||
void banner_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 droiddeck_color_session_end(void) {
|
||||
|
||||
struct cm_desc {
|
||||
int refs; /* protocol objects + surfaces (pending / current) */
|
||||
struct droiddeck_color c;
|
||||
struct banner_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 droiddeck_color *col = &d->c;
|
||||
const struct banner_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 droiddeck_color *col = &d->c;
|
||||
struct banner_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 = DROIDDECK_ADATASPACE_BT2020_PQ;
|
||||
col->dataspace = BANNER_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);
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
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 droiddeck_color *c = &d->c;
|
||||
struct banner_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 = DROIDDECK_ADATASPACE_BT2020_PQ;
|
||||
c->dataspace = BANNER_ADATASPACE_BT2020_PQ;
|
||||
else if (p->primaries == WP_COLOR_MANAGER_V1_PRIMARIES_BT2020 && p->tf == WP_COLOR_MANAGER_V1_TRANSFER_FUNCTION_HLG)
|
||||
c->dataspace = DROIDDECK_ADATASPACE_BT2020_HLG;
|
||||
c->dataspace = BANNER_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);
|
||||
}
|
||||
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));
|
||||
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));
|
||||
if (dropped[0]) {
|
||||
size_t l = strlen(dropped);
|
||||
if (l && dropped[l - 1] == ',') dropped[l - 1] = 0;
|
||||
droiddeck_log(TAG, "image description #%u: dropped HDR metadata that breaks the protocol's rules:%s "
|
||||
banner_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) droiddeck_surface_describe(s, out, size);
|
||||
if (s) banner_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) {
|
||||
droiddeck_log(TAG, "%s set an image description on a colour-management object whose surface is gone: ignored",
|
||||
droiddeck_client_name(c));
|
||||
banner_log(TAG, "%s set an image description on a colour-management object whose surface is gone: ignored",
|
||||
banner_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 droiddeck_color_commit(struct wl_resource *surface) {
|
||||
void banner_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 droiddeck_color_commit(struct wl_resource *surface) {
|
||||
char who[160];
|
||||
describe_surface(surface, who, sizeof(who));
|
||||
if (cs->current) {
|
||||
droiddeck_log(TAG, "%s: image description #%u now applies to its frames (%s; perceptual intent)", who,
|
||||
banner_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 droiddeck_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)
|
||||
droiddeck_log(TAG, "%s: zero-copy presentation is OFF, so its HDR frames are composed into the HDR picture "
|
||||
banner_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) {
|
||||
droiddeck_log(TAG, "%s: image description #%u removed - its frames are sRGB again", who, old->c.identity);
|
||||
banner_log(TAG, "%s: image description #%u removed - its frames are sRGB again", who, old->c.identity);
|
||||
}
|
||||
desc_unref(old);
|
||||
}
|
||||
|
||||
const struct droiddeck_color *droiddeck_color_of(struct wl_resource *surface) {
|
||||
const struct banner_color *banner_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. */
|
||||
droiddeck_log(TAG, "%s: a second colour-management object for the same surface - the newer one takes over "
|
||||
banner_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);
|
||||
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));
|
||||
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));
|
||||
}
|
||||
|
||||
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;
|
||||
droiddeck_log(TAG, "%s bound wp_color_manager_v1 version %u (offered: perceptual intent; parametric descriptions "
|
||||
banner_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", droiddeck_client_name(c), ver > 1 ? 1u : ver);
|
||||
"VK_COLOR_SPACE_HDR10_ST2084_EXT", banner_client_name(c), ver > 1 ? 1u : ver);
|
||||
}
|
||||
}
|
||||
|
||||
void droiddeck_color_client_gone(struct wl_client *client) {
|
||||
void banner_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 = droiddeck_client_name(client);
|
||||
const char *name = banner_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 droiddeck_color_client_gone(struct wl_client *client) {
|
||||
|
||||
/* ---------------------------------------------------------------- the gate */
|
||||
|
||||
void droiddeck_color_init(struct wl_display *display) {
|
||||
void banner_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 droiddeck_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), "DROIDDECK_WAYLAND_HDR=force (%s)", source);
|
||||
else if (strstr(source, "env")) snprintf(asked, sizeof(asked), "DROIDDECK_WAYLAND_HDR=1 (%s)", source);
|
||||
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);
|
||||
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), "DROIDDECK_WAYLAND_HDR=0 in the %s overrides the setting", source);
|
||||
if (strstr(source, "env")) snprintf(off_why, sizeof(off_why), "BANNER_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 droiddeck_color_init(struct wl_display *display) {
|
||||
snprintf(g_gate_why, sizeof(g_gate_why), "%s", why);
|
||||
atomic_store(&g_gate, 0);
|
||||
if (mode) {
|
||||
droiddeck_log(TAG, "HDR gate CLOSED: %s. Nothing is advertised (no wp_color_manager_v1, no 10-bit dma-buf "
|
||||
banner_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)
|
||||
droiddeck_log(TAG, "DXVK_HDR=1 is set: DXGI will still claim an HDR display that no swapchain can get here "
|
||||
banner_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. */
|
||||
droiddeck_log(TAG, "HDR output off for this session (%s)%s%s", off_why,
|
||||
banner_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 droiddeck_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);
|
||||
droiddeck_log("error", "color: wp_color_manager_v1 global creation failed - HDR gate closed");
|
||||
banner_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));
|
||||
droiddeck_log(TAG, "HDR gate %s: %s and %s. Offering games HDR10: wp_color_manager_v1 version 1 (BT.2020 primaries "
|
||||
banner_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)
|
||||
droiddeck_log(TAG, "DROIDDECK_WAYLAND_HDR=force on a display without HDR10 - testing only: SurfaceFlinger will tone-map "
|
||||
banner_log(TAG, "BANNER_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)
|
||||
droiddeck_log(TAG, "zero-copy presentation turned on for this session: the HDR game's own 10-bit frames go straight "
|
||||
banner_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)
|
||||
droiddeck_log(TAG, "DXVK_HDR=1 is not in the game's environment: DXVK games will not see an HDR display in DXGI "
|
||||
banner_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 "droiddeck_ext.h"
|
||||
#include "droiddeck_color.h"
|
||||
#include "banner_ext.h"
|
||||
#include "banner_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 (droiddeck_color.h): A2B10G10R10 as AB30 (alpha) + XB30 (opaque) - Mesa
|
||||
/* The last two rows are HDR10's (banner_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 = droiddeck_color_hdr_open() ? DMABUF_NFMT_MAX : DMABUF_NFMT_SDR;
|
||||
g_dmabuf_nfmt = banner_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;
|
||||
}
|
||||
droiddeck_log("dmabuf", "formats: %s%s", line,
|
||||
!g_ubwc ? " (DROIDDECK_WAYLAND_UBWC=0: qcom_compressed not advertised)" : "");
|
||||
banner_log("dmabuf", "formats: %s%s", line,
|
||||
!g_ubwc ? " (BANNER_WAYLAND_UBWC=0: qcom_compressed not advertised)" : "");
|
||||
if (g_ubwc && !compressed)
|
||||
droiddeck_log("dmabuf", "the compositor's driver (%s) reports no importable qcom_compressed layout: "
|
||||
banner_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;
|
||||
droiddeck_log("color", "10-bit dma-buf formats AB30/XB30 advertised for HDR10; the compositor's driver (%s) "
|
||||
banner_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, "droiddeck-dmabuf-formats",
|
||||
int fd = (int)syscall(__NR_memfd_create, "banner-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;
|
||||
droiddeck_log("dmabuf", "feedback ready: %d format/modifier pairs, main device %u:%u",
|
||||
banner_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)
|
||||
droiddeck_log("dmabuf", "%s: OpenGL games need Wayland layer versionCode 9 or newer, "
|
||||
banner_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 DROIDDECK_WAYLAND_NO_RENDER_NODE=1)"
|
||||
g_no_render_node ? "no DRM device named (forced by BANNER_WAYLAND_NO_RENDER_NODE=1)"
|
||||
: "this device gives apps no display (DRM) device (/dev/dri)");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
/*
|
||||
* wp_single_pixel_buffer_manager_v1 (version 1): a wl_buffer of one pixel of one colour, which a
|
||||
* client stretches over a surface with wp_viewporter. KWin's Wayland backend - the desktop's KWin
|
||||
* nested in this compositor - refuses to start without it. The surface takes the pixel as a 1x1
|
||||
* image, the way it takes a wl_shm buffer (compositor.c, take_pixel).
|
||||
*/
|
||||
#include <stdlib.h>
|
||||
#include "droiddeck_ext.h"
|
||||
#include "single-pixel-buffer-v1-server-protocol.h"
|
||||
|
||||
struct single_pixel {
|
||||
uint8_t bgra[4]; /* premultiplied, as wl_shm's ARGB8888 lies in memory */
|
||||
};
|
||||
|
||||
static void buffer_destroy(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); }
|
||||
static const struct wl_buffer_interface buffer_impl = { .destroy = buffer_destroy };
|
||||
|
||||
static void buffer_free(struct wl_resource *r) { free(wl_resource_get_user_data(r)); }
|
||||
|
||||
int single_pixel_buffer_get(struct wl_resource *buffer, uint8_t bgra[4]) {
|
||||
if (!buffer || !wl_resource_instance_of(buffer, &wl_buffer_interface, &buffer_impl)) return 0;
|
||||
struct single_pixel *p = wl_resource_get_user_data(buffer);
|
||||
for (int i = 0; i < 4; i++) bgra[i] = p->bgra[i];
|
||||
return 1;
|
||||
}
|
||||
|
||||
static void mgr_destroy(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); }
|
||||
static void mgr_create_buffer(struct wl_client *c, struct wl_resource *r, uint32_t id,
|
||||
uint32_t red, uint32_t green, uint32_t blue, uint32_t alpha) {
|
||||
struct single_pixel *p = calloc(1, sizeof(*p));
|
||||
struct wl_resource *buffer = p ? wl_resource_create(c, &wl_buffer_interface, 1, id) : NULL;
|
||||
if (!buffer) { free(p); wl_client_post_no_memory(c); return; }
|
||||
/* The protocol's values span the whole u32 range; the top byte is the 8-bit channel. */
|
||||
p->bgra[0] = blue >> 24;
|
||||
p->bgra[1] = green >> 24;
|
||||
p->bgra[2] = red >> 24;
|
||||
p->bgra[3] = alpha >> 24;
|
||||
wl_resource_set_implementation(buffer, &buffer_impl, p, buffer_free);
|
||||
}
|
||||
static const struct wp_single_pixel_buffer_manager_v1_interface mgr_impl = {
|
||||
.destroy = mgr_destroy, .create_u32_rgba_buffer = mgr_create_buffer,
|
||||
};
|
||||
static void bind_manager(struct wl_client *c, void *data, uint32_t ver, uint32_t id) {
|
||||
struct wl_resource *r = wl_resource_create(c, &wp_single_pixel_buffer_manager_v1_interface, ver, id);
|
||||
if (!r) { wl_client_post_no_memory(c); return; }
|
||||
wl_resource_set_implementation(r, &mgr_impl, NULL, NULL);
|
||||
}
|
||||
|
||||
void single_pixel_init(struct wl_display *display) {
|
||||
wl_global_create(display, &wp_single_pixel_buffer_manager_v1_interface, 1, NULL, bind_manager);
|
||||
}
|
||||
@@ -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 droiddeck_ime_target():
|
||||
* desktop owner's process), so text input can't follow it; it follows banner_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. droiddeck_on_text_input(enabled, program, cursor rect in scene pixels) tells the app
|
||||
* Host side. banner_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 "droiddeck_ext.h"
|
||||
#include "banner_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 droiddeck_client_name(ti->client); }
|
||||
static const char *ti_program(struct text_input *ti) { return banner_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;
|
||||
droiddeck_surface_scene_origin(ti->entered, &ox, &oy);
|
||||
banner_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))
|
||||
droiddeck_log("text-input", "text input enabled by %s (cursor rect %d,%d %dx%d)", ti_program(ti),
|
||||
banner_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)
|
||||
droiddeck_log("text-input", "cursor rect from %s: %d,%d %dx%d", ti_program(ti), rect[0], rect[1], rect[2], rect[3]);
|
||||
banner_log("text-input", "cursor rect from %s: %d,%d %dx%d", ti_program(ti), rect[0], rect[1], rect[2], rect[3]);
|
||||
else
|
||||
droiddeck_log("text-input", "text input disabled%s%s", g_told_ti ? " by " : "", g_told_ti ? ti_program(g_told_ti) : "");
|
||||
banner_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));
|
||||
droiddeck_on_text_input(enabled, enabled ? ti_program(ti) : "", rect[0], rect[1], rect[2], rect[3]);
|
||||
banner_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 droiddeck_text_input_refocus(void) {
|
||||
struct wl_resource *t = droiddeck_ime_target();
|
||||
void banner_text_input_refocus(void) {
|
||||
struct wl_resource *t = banner_ime_target();
|
||||
struct text_input *ti;
|
||||
if (t == g_target) return;
|
||||
wl_list_for_each(ti, &g_inputs, link)
|
||||
@@ -121,7 +121,7 @@ void droiddeck_text_input_refocus(void) {
|
||||
wl_display_flush_clients(g_disp);
|
||||
}
|
||||
|
||||
void droiddeck_text_input_surface_gone(struct wl_resource *surface) {
|
||||
void banner_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));
|
||||
droiddeck_on_text_input(0, "", 0, 0, 0, 0); }
|
||||
banner_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) {
|
||||
droiddeck_log("text-input", "typed text (%zu chars) dropped: no program accepts text input", utf8_chars(utf8, len));
|
||||
banner_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);
|
||||
droiddeck_log("text-input", "committed %zu chars to %s", utf8_chars(utf8, len), ti_program(ti));
|
||||
banner_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++) { 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);
|
||||
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);
|
||||
}
|
||||
|
||||
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 "droiddeck_ext.h"
|
||||
#include "banner_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); }
|
||||
|
||||
@@ -11,26 +11,14 @@ import com.droiddeck.launcher.session.GpuClockPin
|
||||
* sandboxes are started through (BwrapSpawner).
|
||||
*/
|
||||
class App : Application() {
|
||||
override fun attachBaseContext(base: android.content.Context) =
|
||||
super.attachBaseContext(com.droiddeck.launcher.core.AppLanguage.wrap(base))
|
||||
|
||||
override fun onConfigurationChanged(newConfig: android.content.res.Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
com.droiddeck.launcher.core.AppLanguage.systemChanged(newConfig)
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
CrashHandler.install(this)
|
||||
// Before anything else touches the session prefs: a new install's first read decides it.
|
||||
com.droiddeck.launcher.session.SessionPrefs.settleDeckModeDefault(this)
|
||||
com.droiddeck.launcher.session.SessionPrefs.settleGpuDriverMode(this)
|
||||
com.droiddeck.launcher.update.AppUpdates.init(this)
|
||||
GpuClockPin.clearLeftover(this)
|
||||
BwrapSpawner.start(this)
|
||||
com.droiddeck.launcher.frontend.GameFileSync.start(this)
|
||||
// The game stores the Stores section can talk to; the native engine loads only when one needs it.
|
||||
com.droiddeck.launcher.stores.StoresState.init(this)
|
||||
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.gog.GogBackend)
|
||||
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.epic.EpicBackend)
|
||||
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.amazon.AmazonBackend)
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ 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
|
||||
@@ -21,7 +20,6 @@ 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) {
|
||||
@@ -30,40 +28,19 @@ 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 (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
|
||||
}
|
||||
if (compProton == null || snap?.protons?.none { it.proton.id == compProton } == true) compProton = 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
|
||||
Thread({
|
||||
val message = runCatching(work).getOrElse { e -> activity.getString(R.string.main_action_failed, label, e.message ?: e.javaClass.simpleName) }
|
||||
val message = runCatching(work).getOrElse { e -> "$label failed: ${e.message ?: e.javaClass.simpleName}" }
|
||||
ui.post {
|
||||
compBusy = null
|
||||
android.widget.Toast.makeText(activity, message, android.widget.Toast.LENGTH_LONG).show()
|
||||
@@ -79,7 +56,7 @@ internal class ComponentsMenu(private val activity: android.app.Activity, privat
|
||||
val cat = runCatching { ComponentsManager.catalog(activity, true) }.getOrNull()
|
||||
ui.post {
|
||||
compChecking = false
|
||||
if (cat == null || cat.items.isEmpty()) android.widget.Toast.makeText(activity, activity.getString(R.string.comp_nightlies_unreachable), android.widget.Toast.LENGTH_LONG).show()
|
||||
if (cat == null || cat.items.isEmpty()) android.widget.Toast.makeText(activity, "The Nightlies could not be reached", android.widget.Toast.LENGTH_LONG).show()
|
||||
else { compCatalog = cat.items; compCatalogAt = cat.fetchedAt }
|
||||
}
|
||||
}, "components-catalog").start()
|
||||
@@ -91,8 +68,8 @@ internal class ComponentsMenu(private val activity: android.app.Activity, privat
|
||||
Thread({
|
||||
val message = runCatching {
|
||||
val pkg = ComponentsManager.download(activity, item) { pc -> ui.post { if (compDownloads.containsKey(item.file)) compDownloads = compDownloads + (item.file to pc) } }
|
||||
activity.getString(R.string.comp_stored, pkg.version)
|
||||
}.getOrElse { e -> activity.getString(R.string.comp_download_failed, e.message ?: e.javaClass.simpleName) }
|
||||
"Stored ${pkg.version}"
|
||||
}.getOrElse { e -> "Download failed: ${e.message ?: e.javaClass.simpleName}" }
|
||||
ui.post {
|
||||
compDownloads = compDownloads - item.file
|
||||
android.widget.Toast.makeText(activity, message, android.widget.Toast.LENGTH_SHORT).show()
|
||||
|
||||
@@ -7,7 +7,6 @@ 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
|
||||
@@ -19,8 +18,6 @@ internal class DeckyMenu(private val activity: android.app.Activity, private val
|
||||
var deckyChecking by mutableStateOf(false)
|
||||
var deckyStage by mutableStateOf<String?>(null)
|
||||
var deckyPercent by mutableIntStateOf(-1)
|
||||
/** True while the stage is the plugin's binary download (DeckyManager.downloadingBinary). */
|
||||
var deckyDownloadingBinary by mutableStateOf(false)
|
||||
var deckySupervisor by mutableStateOf(false)
|
||||
var deckyReleaseRequest = 0
|
||||
|
||||
@@ -43,33 +40,15 @@ internal class DeckyMenu(private val activity: android.app.Activity, private val
|
||||
|
||||
fun installDecky(release: DeckyManager.Release) {
|
||||
if (deckyStage != null || SessionState.running) return
|
||||
deckyStage = activity.getString(R.string.store_starting); deckyPercent = -1
|
||||
deckyStage = "Starting…"; deckyPercent = -1
|
||||
Thread({
|
||||
val problem = runCatching {
|
||||
DeckyManager.install(activity, release) { label, value -> ui.post { deckyStage = label; deckyPercent = value } }
|
||||
}.getOrElse { error -> activity.getString(R.string.decky_install_failed, error.message ?: error.javaClass.simpleName) }
|
||||
}.getOrElse { error -> "Decky install failed: ${error.message ?: error.javaClass.simpleName}" }
|
||||
ui.post {
|
||||
deckyStage = null; deckyPercent = -1; deckyInstalled = DeckyManager.installed(activity)
|
||||
if (problem != null) android.widget.Toast.makeText(activity, problem, android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}, "install-decky").start()
|
||||
}
|
||||
|
||||
fun importPluginZip(archive: File) {
|
||||
if (deckyStage != null || SessionState.running || deckyInstalled == null) return
|
||||
deckyStage = activity.getString(R.string.decky_plugin_starting); deckyPercent = -1
|
||||
Thread({
|
||||
val problem = runCatching {
|
||||
DeckyManager.installPluginZip(activity, archive) { label, value ->
|
||||
val binary = DeckyManager.downloadingBinary
|
||||
ui.post { deckyStage = label; deckyPercent = value; deckyDownloadingBinary = binary }
|
||||
}
|
||||
}.getOrElse { error -> activity.getString(R.string.decky_plugin_failed, error.message ?: error.javaClass.simpleName) }
|
||||
ui.post {
|
||||
deckyStage = null; deckyPercent = -1; deckyDownloadingBinary = false
|
||||
val message = problem ?: activity.getString(R.string.decky_plugin_installed)
|
||||
android.widget.Toast.makeText(activity, message, android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}, "import-decky-plugin").start()
|
||||
}
|
||||
}
|
||||
@@ -37,8 +37,7 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
/** The latest Banners-Turnip release as each driver menu offers it (see [refreshReleaseRows]). */
|
||||
var linuxDownloads by mutableStateOf<List<com.droiddeck.launcher.ui.DownloadRow>>(emptyList())
|
||||
var androidDownloads by mutableStateOf<List<com.droiddeck.launcher.ui.DownloadRow>>(emptyList())
|
||||
// Empty until the first refresh: the activity has no context yet while its fields are made.
|
||||
var releaseStatus by mutableStateOf("")
|
||||
var releaseStatus by mutableStateOf("Not checked yet - tap refresh to look for new drivers")
|
||||
var releaseChecking by mutableStateOf(false)
|
||||
var canRestoreBundled by mutableStateOf(false)
|
||||
/** Asset name -> download percent, while it downloads. */
|
||||
@@ -58,10 +57,10 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
private var autoCheckedThisProcess = false
|
||||
|
||||
fun state() = GpuDriversState(
|
||||
gpuName = gpu.displayName(activity), gpuFamily = gpu.family.label(activity), soc = gpu.soc, supportText = gpu.supportText(activity),
|
||||
gpuName = gpu.name, gpuFamily = gpu.family.label, soc = gpu.soc, supportText = gpu.supportText,
|
||||
supported = gpu.support == GpuInfo.Support.TESTED, unsupported = gpu.support == GpuInfo.Support.UNSUPPORTED,
|
||||
auto = mode == SessionPrefs.GPU_DRIVERS_AUTO, pairs = pairRows, busy = pairBusy, percent = pairPercent,
|
||||
autoStatus = autoStatus, releaseStatus = releaseStatus.ifEmpty { activity.getString(R.string.drivers_not_checked) }, checking = releaseChecking,
|
||||
autoStatus = autoStatus, releaseStatus = releaseStatus, checking = releaseChecking,
|
||||
linuxRows = linuxRows, linuxSelected = linuxSelected, androidRows = androidRows, androidSelected = androidSelected,
|
||||
linuxDownloads = linuxDownloads, androidDownloads = androidDownloads, canRestoreBundled = canRestoreBundled,
|
||||
activeBundle = activeBundle,
|
||||
@@ -74,10 +73,10 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
fun origin(id: String) = if (TurnipReleases.isDownloaded(activity, id)) DriverRow.DOWNLOADED else DriverRow.IMPORTED
|
||||
val bundles = DriverBundle.all(activity)
|
||||
fun bundleRow(id: String, linux: Boolean) = bundles.firstOrNull { (if (linux) it.linuxId else it.androidId) == id }?.let {
|
||||
DriverRow(id, it.label, activity.getString(if (linux) R.string.drivers_bundle_sets_display else R.string.drivers_bundle_sets_runtime), true, DriverRow.BUNDLE)
|
||||
DriverRow(id, it.label, "Android + Linux: sets the ${if (linux) "display" else "runtime"} driver with it", true, DriverRow.BUNDLE)
|
||||
}
|
||||
linuxRows = LinuxVulkanDriver.optionValues(activity).map { id ->
|
||||
if (id.isEmpty()) DriverRow("", activity.getString(R.string.gpu_runtime_default), activity.getString(R.string.drivers_runtime_builtin), false)
|
||||
if (id.isEmpty()) DriverRow("", "Runtime default", "the Turnip built into the runtime", false)
|
||||
else bundleRow(id, linux = true) ?: DriverRow(
|
||||
id, lm.getDriverName(id),
|
||||
listOfNotNull(
|
||||
@@ -93,8 +92,8 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
val auto = td.autoId()
|
||||
androidRows = buildList {
|
||||
add(DriverRow(
|
||||
TurnipDriver.AUTO, activity.getString(R.string.gpu_auto_by_gpu),
|
||||
if (auto == "system") activity.getString(R.string.drivers_system_vulkan) else activity.getString(R.string.drivers_bundled, td.displayName(auto)),
|
||||
TurnipDriver.AUTO, "Auto - picked by GPU",
|
||||
if (auto == "system") "system Vulkan: no bundled build for this GPU" else "${td.displayName(auto)} (bundled)",
|
||||
false,
|
||||
))
|
||||
for (id in td.visibleBundled()) add(DriverRow(id, td.displayName(id), td.driverVersion(id), true, DriverRow.BUNDLED))
|
||||
@@ -110,8 +109,7 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
/** One line for the settings row that opens this: "Auto · WinNative · Balanced". */
|
||||
fun summary(): String {
|
||||
val active = pairRows.firstOrNull { it.active }?.name ?: activeBundle
|
||||
val label = activity.getString(if (mode == SessionPrefs.GPU_DRIVERS_AUTO) R.string.common_auto else R.string.drivers_manual)
|
||||
return active?.let { activity.getString(R.string.drivers_summary, label, it) } ?: label
|
||||
return (if (mode == SessionPrefs.GPU_DRIVERS_AUTO) "Auto" else "Manual") + (active?.let { " · $it" } ?: "")
|
||||
}
|
||||
|
||||
/** The pairs the last check found, with what is installed and in use; this GPU's first. */
|
||||
@@ -127,7 +125,7 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
val mb = p.assets.sumOf { it.size } / 1_048_576.0
|
||||
PairRow(
|
||||
key = p.key, name = p.name, version = p.version,
|
||||
detail = if (!p.complete) activity.getString(R.string.drivers_half_published) else activity.getString(R.string.drivers_pair_size, mb),
|
||||
detail = if (!p.complete) "Only one half is published right now" else "%.0f MB for both".format(mb),
|
||||
recommended = p.key == recommended, suits = p.suits(gpu), complete = p.complete,
|
||||
installed = displayId != null && linuxId != null,
|
||||
active = displayId != null && linuxId != null &&
|
||||
@@ -155,11 +153,11 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
if (!force && autoCheckedThisProcess) return
|
||||
autoCheckedThisProcess = true
|
||||
if (DriverPairs.recommendedKey(gpu, emptyList()) == null) {
|
||||
autoStatus = activity.getString(R.string.drivers_none_to_set, gpu.supportText(activity).lowercase())
|
||||
autoStatus = "No drivers to set: ${gpu.supportText.lowercase()}"
|
||||
return
|
||||
}
|
||||
releaseChecking = true
|
||||
autoStatus = activity.getString(R.string.drivers_checking_latest)
|
||||
autoStatus = "Checking for the latest drivers…"
|
||||
Thread({
|
||||
val cached = TurnipReleases.cached(activity)
|
||||
val stale = cached == null || System.currentTimeMillis() - cached.checkedAt > 24 * 3_600_000L ||
|
||||
@@ -174,10 +172,9 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
val pair = pairs.firstOrNull { it.key == key }
|
||||
val row = pairRows.firstOrNull { it.key == key }
|
||||
when {
|
||||
row?.active == true -> autoStatus = problem?.let { activity.getString(R.string.drivers_up_to_date_check_failed, it) }
|
||||
?: activity.getString(R.string.upd_current_status)
|
||||
row?.active == true -> autoStatus = "Up to date" + (problem?.let { " (couldn't check: $it)" } ?: "")
|
||||
pair == null || !pair.complete ->
|
||||
autoStatus = problem?.let { activity.getString(R.string.upd_couldnt_check, it) } ?: activity.getString(R.string.drivers_recommended_unpublished)
|
||||
autoStatus = problem?.let { "Couldn't check: $it" } ?: "The recommended drivers aren't published right now"
|
||||
else -> installPair(pair, auto = true)
|
||||
}
|
||||
}
|
||||
@@ -200,7 +197,7 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
if (!pair.complete) return
|
||||
pairBusy = pair.key
|
||||
pairPercent = 0
|
||||
if (auto) autoStatus = activity.getString(R.string.drivers_downloading_pair, pair.name, pair.version)
|
||||
if (auto) autoStatus = "Downloading ${pair.name} ${pair.version}…"
|
||||
Thread({
|
||||
val lm = LinuxVulkanDriverManager(activity)
|
||||
val td = TurnipDriver(activity)
|
||||
@@ -220,7 +217,7 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
val displayChanged = SessionPrefs.androidDriver(activity) != displayId
|
||||
SessionPrefs.setAndroidDriver(activity, displayId)
|
||||
SessionPrefs.setLinuxDriver(activity, linuxId)
|
||||
val restart = displayChanged && CompositorHost.isStarted
|
||||
val restart = if (displayChanged && CompositorHost.isStarted) " The display driver applies after DroidDeck restarts." else ""
|
||||
if (auto) {
|
||||
val previous = SessionPrefs.gpuAutoInstalled(activity)
|
||||
for (id in previous - setOf(displayId, linuxId)) {
|
||||
@@ -233,18 +230,14 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
TurnipReleases.forget(activity, id)
|
||||
}
|
||||
SessionPrefs.setGpuAutoInstalled(activity, setOf(displayId, linuxId))
|
||||
autoStatus = activity.getString(
|
||||
if (downloaded) { if (restart) R.string.drivers_updated_to_restart else R.string.drivers_updated_to }
|
||||
else { if (restart) R.string.drivers_switched_to_restart else R.string.drivers_switched_to },
|
||||
pair.name, pair.version,
|
||||
)
|
||||
autoStatus = (if (downloaded) "Updated to" else "Switched to") + " ${pair.name} ${pair.version}.$restart"
|
||||
} else {
|
||||
android.widget.Toast.makeText(activity, activity.getString(if (restart) R.string.drivers_using_restart else R.string.drivers_using, pair.name, pair.version), android.widget.Toast.LENGTH_LONG).show()
|
||||
android.widget.Toast.makeText(activity, "Using ${pair.name} ${pair.version}.$restart", android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}.onFailure { e ->
|
||||
Log.w(TAG, "driver pair ${pair.key}", e)
|
||||
val why = if (e is IllegalArgumentException) e.message else activity.getString(R.string.comp_download_failed, e.message)
|
||||
if (auto) autoStatus = why ?: activity.getString(R.string.user_apps_download_failed)
|
||||
val why = if (e is IllegalArgumentException) e.message else "Download failed: ${e.message}"
|
||||
if (auto) autoStatus = why ?: "Download failed"
|
||||
else android.widget.Toast.makeText(activity, why, android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
refreshDrivers()
|
||||
@@ -305,7 +298,7 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
try {
|
||||
LinuxVulkanDriverManager(activity).installDriver(uri, name)
|
||||
} catch (runtime: IllegalArgumentException) {
|
||||
throw IllegalArgumentException(activity.getString(R.string.drivers_not_a_driver))
|
||||
throw IllegalArgumentException("Not a driver zip: neither an AdrenoTools driver, a -Linux Turnip nor an Android + Linux bundle")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -314,11 +307,10 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
e.message
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "driver import", e)
|
||||
activity.getString(R.string.drivers_import_failed, e.message)
|
||||
"Import failed: ${e.message}"
|
||||
}
|
||||
ui.post {
|
||||
val done = bundle?.let { useBundle(it) }
|
||||
?: name?.let { activity.getString(R.string.comp_imported, it) } ?: activity.getString(R.string.drivers_imported_unnamed)
|
||||
val done = bundle?.let { useBundle(it) } ?: "Imported ${name ?: "driver"}"
|
||||
android.widget.Toast.makeText(
|
||||
activity, problem ?: done,
|
||||
if (problem != null || bundle != null) android.widget.Toast.LENGTH_LONG else android.widget.Toast.LENGTH_SHORT,
|
||||
@@ -348,10 +340,8 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
val displayChanged = SessionPrefs.androidDriver(activity) != bundle.androidId
|
||||
DriverBundle.select(activity, bundle)
|
||||
if (mode == SessionPrefs.GPU_DRIVERS_AUTO) setMode(false)
|
||||
return activity.getString(
|
||||
if (displayChanged && CompositorHost.isStarted) R.string.drivers_using_bundle_restart else R.string.drivers_using_bundle,
|
||||
bundle.label,
|
||||
)
|
||||
return "Using ${bundle.label} as both the runtime and display driver." +
|
||||
if (displayChanged && CompositorHost.isStarted) " The display driver applies after DroidDeck restarts." else ""
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -364,7 +354,7 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
if (bundle != null) {
|
||||
DriverBundle.remove(activity, bundle)
|
||||
TurnipReleases.forget(activity, bundle.id)
|
||||
android.widget.Toast.makeText(activity, activity.getString(R.string.drivers_deleted_bundle, bundle.label), android.widget.Toast.LENGTH_SHORT).show()
|
||||
android.widget.Toast.makeText(activity, "Deleted ${bundle.label} (both drivers)", android.widget.Toast.LENGTH_SHORT).show()
|
||||
refreshDrivers()
|
||||
return
|
||||
}
|
||||
@@ -377,7 +367,7 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
if (SessionPrefs.androidDriver(activity) == id) SessionPrefs.setAndroidDriver(activity, TurnipDriver.AUTO)
|
||||
}
|
||||
TurnipReleases.forget(activity, id)
|
||||
android.widget.Toast.makeText(activity, activity.getString(R.string.drivers_deleted, id), android.widget.Toast.LENGTH_SHORT).show()
|
||||
android.widget.Toast.makeText(activity, "Deleted ${id}", android.widget.Toast.LENGTH_SHORT).show()
|
||||
refreshDrivers()
|
||||
}
|
||||
|
||||
@@ -388,28 +378,26 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
.filter { it.bundle || it.linux == linux }
|
||||
.filter { a -> installedIdOf(a) == null }
|
||||
.map { a ->
|
||||
val mb = activity.getString(R.string.common_size_mb, a.size / 1_048_576.0)
|
||||
val mb = "%.1f MB".format(a.size / 1_048_576.0)
|
||||
com.droiddeck.launcher.ui.DownloadRow(a.name, "${a.source} ${a.tag}", "${a.label} · $mb", releaseProgress[a.name])
|
||||
}
|
||||
linuxDownloads = rows(linux = true)
|
||||
androidDownloads = rows(linux = false)
|
||||
if (!releaseChecking) releaseStatus = when (check) {
|
||||
null -> activity.getString(R.string.drivers_not_checked)
|
||||
else -> {
|
||||
val latest = check.latest.joinToString(" · ") { "${it.first} ${it.second}" }
|
||||
if (check.failed.isEmpty()) activity.getString(R.string.drivers_release_status, latest, ago(check.checkedAt))
|
||||
else activity.getString(R.string.drivers_release_status_failed, latest, check.failed.joinToString(), ago(check.checkedAt))
|
||||
}
|
||||
null -> "Not checked yet - tap refresh to look for new drivers"
|
||||
else -> "Latest: " + check.latest.joinToString(" · ") { "${it.first} ${it.second}" } +
|
||||
(if (check.failed.isEmpty()) "" else " · ${check.failed.joinToString()} unreachable") +
|
||||
" · checked ${ago(check.checkedAt)}"
|
||||
}
|
||||
}
|
||||
|
||||
private fun ago(t: Long): String {
|
||||
val m = ((System.currentTimeMillis() - t) / 60_000).coerceAtLeast(0)
|
||||
return when {
|
||||
m < 1 -> activity.getString(R.string.upd_just_now)
|
||||
m < 60 -> activity.resources.getQuantityString(R.plurals.drivers_ago_minutes, m.toInt(), m)
|
||||
m < 48 * 60 -> activity.resources.getQuantityString(R.plurals.drivers_ago_hours, (m / 60).toInt(), m / 60)
|
||||
else -> activity.resources.getQuantityString(R.plurals.drivers_ago_days, (m / (24 * 60)).toInt(), m / (24 * 60))
|
||||
m < 1 -> "just now"
|
||||
m < 60 -> "$m min ago"
|
||||
m < 48 * 60 -> "${m / 60} h ago"
|
||||
else -> "${m / (24 * 60)} days ago"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -417,16 +405,16 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
fun checkLatestTurnip() {
|
||||
if (releaseChecking) return
|
||||
releaseChecking = true
|
||||
releaseStatus = activity.getString(R.string.drivers_checking_sources)
|
||||
releaseStatus = "Checking Banners-Turnip, WinNative and DroidDeck…"
|
||||
Thread({
|
||||
val problem = try { TurnipReleases.refresh(activity); null } catch (e: Exception) {
|
||||
Log.w(TAG, "latest Turnip check", e); e.message ?: activity.getString(R.string.drivers_check_failed)
|
||||
Log.w(TAG, "latest Turnip check", e); e.message ?: "check failed"
|
||||
}
|
||||
ui.post {
|
||||
releaseChecking = false
|
||||
refreshReleaseRows()
|
||||
refreshPairs()
|
||||
if (problem != null) releaseStatus = activity.getString(R.string.upd_couldnt_check, problem)
|
||||
if (problem != null) releaseStatus = "Couldn't check: $problem"
|
||||
}
|
||||
}, "turnip-release-check").start()
|
||||
}
|
||||
@@ -445,16 +433,13 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
e.message
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "release driver download", e)
|
||||
activity.getString(R.string.comp_download_failed, e.message)
|
||||
"Download failed: ${e.message}"
|
||||
}
|
||||
ui.post {
|
||||
releaseProgress.remove(assetName)
|
||||
val installed = activity.getString(
|
||||
if (asset.bundle) R.string.drivers_installed_pick_either else R.string.drivers_installed_pick,
|
||||
asset.name.removeSuffix(".zip"),
|
||||
)
|
||||
val pick = if (asset.bundle) "pick it in either list to set both drivers" else "pick it in the menu"
|
||||
android.widget.Toast.makeText(
|
||||
activity, problem ?: installed,
|
||||
activity, problem ?: "Installed ${asset.name.removeSuffix(".zip")} - $pick",
|
||||
android.widget.Toast.LENGTH_LONG,
|
||||
).show()
|
||||
refreshDrivers()
|
||||
|
||||
@@ -7,14 +7,8 @@ import com.droiddeck.launcher.session.SessionService
|
||||
|
||||
/** Receives the system Home launch and routes it without consuming KEYCODE_HOME. */
|
||||
class HomeActivity : Activity() {
|
||||
override fun attachBaseContext(newBase: android.content.Context) {
|
||||
super.attachBaseContext(newBase)
|
||||
com.droiddeck.launcher.core.AppLanguage.applyTo(this, newBase)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
requestedOrientation = com.droiddeck.launcher.session.SessionPrefs.orientation(this)
|
||||
routeHome()
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -24,7 +24,7 @@ internal class ProtonMenu(private val activity: android.app.Activity, private va
|
||||
if (protonBusyId != null || SessionState.running) return
|
||||
ProtonExtras.unqueue(activity, tool)
|
||||
protonBusyId = id
|
||||
protonStage = activity.getString(R.string.store_starting)
|
||||
protonStage = "Starting…"
|
||||
protonPercent = -1
|
||||
Thread({
|
||||
val problem = ProtonExtras.install(activity, tool) { label, value ->
|
||||
@@ -44,7 +44,7 @@ internal class ProtonMenu(private val activity: android.app.Activity, private va
|
||||
val tool = ProtonExtras.tools.firstOrNull { it.id == id } ?: return
|
||||
if (protonBusyId != null || SessionState.running) return
|
||||
protonBusyId = id
|
||||
protonStage = activity.getString(R.string.main_removing_named, tool.name)
|
||||
protonStage = "Removing ${tool.name}…"
|
||||
protonPercent = -1
|
||||
Thread({
|
||||
ProtonExtras.remove(activity, tool)
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -46,7 +46,7 @@ internal class UpdatesMenu(private val activity: Activity, private val ui: Handl
|
||||
onAllowInstalls = {
|
||||
askPermission = false
|
||||
runCatching { activity.startActivity(SelfInstaller.permissionIntent(activity)) }
|
||||
.onFailure { error = activity.getString(R.string.upd_unknown_apps_failed); pending = null }
|
||||
.onFailure { error = "Couldn't open Android's \"Install unknown apps\" setting"; pending = null }
|
||||
},
|
||||
onDismissPermission = { askPermission = false; pending = null },
|
||||
)
|
||||
@@ -77,7 +77,7 @@ internal class UpdatesMenu(private val activity: Activity, private val ui: Handl
|
||||
catalog = it
|
||||
follow = AppUpdates.follow(activity, it)
|
||||
error = null
|
||||
}.onFailure { if (force) error = activity.getString(R.string.upd_couldnt_check, it.message ?: it.javaClass.simpleName) }
|
||||
}.onFailure { if (force) error = "Couldn't check: ${it.message ?: it.javaClass.simpleName}" }
|
||||
}
|
||||
}, "app-updates").start()
|
||||
}
|
||||
@@ -90,20 +90,20 @@ internal class UpdatesMenu(private val activity: Activity, private val ui: Handl
|
||||
|
||||
private fun install(r: AppUpdates.Release) {
|
||||
if (stage != null) return
|
||||
val apk = r.apk ?: run { error = activity.getString(R.string.upd_no_download); return }
|
||||
AppUpdates.installBlock(r)?.let { error = it.message(activity); return }
|
||||
if (SessionState.running) { error = activity.getString(R.string.upd_stop_first); return }
|
||||
val apk = r.apk ?: run { error = "This build has no download for this copy of DroidDeck"; return }
|
||||
AppUpdates.installBlock(r)?.let { error = it; return }
|
||||
if (SessionState.running) { error = "Stop the running session first, then update"; return }
|
||||
if (!SelfInstaller.canInstall(activity)) { pending = r; askPermission = true; return }
|
||||
error = null
|
||||
stage = activity.getString(R.string.upd_stage_downloading)
|
||||
stage = "Downloading"
|
||||
percent = 0
|
||||
Thread({
|
||||
val result = runCatching {
|
||||
val file = SelfInstaller.download(activity, apk) { pct -> ui.post { percent = pct } }
|
||||
ui.post { stage = activity.getString(R.string.upd_stage_installing); percent = -1 }
|
||||
ui.post { stage = "Installing"; percent = -1 }
|
||||
SelfInstaller.install(activity, file)
|
||||
}
|
||||
result.onFailure { e -> ui.post { stage = null; error = e.message ?: activity.getString(R.string.upd_failed) } }
|
||||
result.onFailure { e -> ui.post { stage = null; error = e.message ?: "The update failed" } }
|
||||
}, "app-update-install").start()
|
||||
}
|
||||
|
||||
@@ -114,14 +114,14 @@ internal class UpdatesMenu(private val activity: Activity, private val ui: Handl
|
||||
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
|
||||
val confirm = if (Build.VERSION.SDK_INT >= 33) intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
|
||||
else @Suppress("DEPRECATION") intent.getParcelableExtra(Intent.EXTRA_INTENT)
|
||||
if (confirm == null) { stage = null; error = activity.getString(R.string.upd_not_offered); return }
|
||||
stage = activity.getString(R.string.upd_stage_confirm)
|
||||
if (confirm == null) { stage = null; error = "Android didn't offer to install the update"; return }
|
||||
stage = "Waiting for you to confirm"
|
||||
activity.startActivity(confirm.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
}
|
||||
PackageInstaller.STATUS_SUCCESS -> stage = null
|
||||
else -> {
|
||||
stage = null
|
||||
error = SelfInstaller.describeFailure(activity, status, intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE))
|
||||
error = SelfInstaller.describeFailure(status, intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
package com.droiddeck.launcher.agent
|
||||
|
||||
import android.content.Context
|
||||
|
||||
/**
|
||||
* Who may run code through the agent bridge. Reading state, starting and stopping sessions,
|
||||
* launching and quitting games and sending input only need the shell (the provider's DUMP
|
||||
* permission). Running commands in the guest, evaluating JavaScript in the Steam client, session
|
||||
* environment, binary overrides and preference writes reach inside the app's sandbox - the guest
|
||||
* holds the Steam login - which the shell of a stock device otherwise cannot. They require
|
||||
* Setup > Session > Debugging tools to be on, in every build. The bridge itself cannot turn it on.
|
||||
*/
|
||||
object AgentAccess {
|
||||
const val MAX_REQUEST_CHARS = 256 * 1024
|
||||
private const val PREFS = "agent"
|
||||
private const val KEY_COMMANDS = "commands"
|
||||
|
||||
/** The preferences file the bridge must never write: set-pref refuses it. */
|
||||
const val PROTECTED_PREFS = PREFS
|
||||
|
||||
fun commandsAllowed(context: Context): Boolean =
|
||||
prefs(context).getBoolean(KEY_COMMANDS, false)
|
||||
|
||||
/** The Setup toggle's own state. */
|
||||
fun commandsEnabled(context: Context): Boolean = prefs(context).getBoolean(KEY_COMMANDS, false)
|
||||
|
||||
fun setCommandsEnabled(context: Context, enabled: Boolean) {
|
||||
prefs(context).edit().putBoolean(KEY_COMMANDS, enabled).apply()
|
||||
if (!enabled) runCatching { AgentEnv.revoke(context) }
|
||||
.onFailure { android.util.Log.w("AgentAccess", "Could not finish clearing debugging experiments") }
|
||||
}
|
||||
|
||||
fun requireCommands(context: Context) {
|
||||
if (!commandsAllowed(context)) throw AgentException(
|
||||
"AGENT_COMMANDS_DISABLED",
|
||||
"Debugging tools are off: turn on Setup > Session > Debugging tools on the device",
|
||||
)
|
||||
}
|
||||
|
||||
fun requireStart(context: Context, request: org.json.JSONObject) {
|
||||
if (request.optString("mode") == "run") requireCommands(context)
|
||||
}
|
||||
|
||||
private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
/** A refusal with a stable code, answered as {"ok": false, "error": {code, message}}. */
|
||||
class AgentException(val code: String, message: String) : RuntimeException(message)
|
||||
@@ -1,288 +0,0 @@
|
||||
package com.droiddeck.launcher.agent
|
||||
|
||||
import com.droiddeck.launcher.session.SessionPaths
|
||||
import android.Manifest
|
||||
import android.content.ContentProvider
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.os.Binder
|
||||
import android.os.Bundle
|
||||
import android.os.Process
|
||||
import android.util.Base64
|
||||
import com.droiddeck.launcher.BuildConfig
|
||||
import com.droiddeck.launcher.frontend.GameLaunchLink
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntimeInstaller
|
||||
import com.droiddeck.launcher.session.SessionArtifacts
|
||||
import com.droiddeck.launcher.session.SessionEvents
|
||||
import com.droiddeck.launcher.session.SessionPhase
|
||||
import com.droiddeck.launcher.session.SessionService
|
||||
import com.droiddeck.launcher.session.SessionState
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* The shell's control surface (docs/agent-control.md), in every build: the provider and its start
|
||||
* Activity need android.permission.DUMP, held by the shell and privileged or explicitly granted callers. Commands that
|
||||
* reach into the app's sandbox are further gated by [AgentAccess].
|
||||
*
|
||||
* A method's request is JSON, base64-encoded in the "request" extra; every answer is JSON in the
|
||||
* "json" key of the returned Bundle, {"ok": true, ...} or {"ok": false, "error": {code, message}}.
|
||||
*/
|
||||
class AgentBridgeProvider : ContentProvider() {
|
||||
override fun onCreate(): Boolean {
|
||||
val appContext = context?.applicationContext ?: return false
|
||||
Thread({
|
||||
try {
|
||||
SessionArtifacts.finishAbandoned(appContext)
|
||||
} finally {
|
||||
recoveryComplete.countDown()
|
||||
}
|
||||
}, "agent-recover-artifacts").start()
|
||||
return true
|
||||
}
|
||||
|
||||
override fun call(method: String, arg: String?, extras: Bundle?): Bundle {
|
||||
val context = requireNotNull(context)
|
||||
if (Binder.getCallingUid() != Process.myUid() &&
|
||||
context.checkCallingPermission(Manifest.permission.DUMP) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
throw SecurityException("android.permission.DUMP is required")
|
||||
}
|
||||
|
||||
val response = try {
|
||||
val request = decode(extras?.getString(EXTRA_REQUEST))
|
||||
// Binder identity is the shell's; everything below acts as the app.
|
||||
val token = Binder.clearCallingIdentity()
|
||||
try {
|
||||
dispatch(context, method, request)
|
||||
} finally {
|
||||
Binder.restoreCallingIdentity(token)
|
||||
}
|
||||
} catch (e: AgentException) {
|
||||
error(e.code, e.message ?: e.code)
|
||||
} catch (e: Exception) {
|
||||
error("COMMAND_FAILED", e.message ?: e.javaClass.simpleName)
|
||||
}
|
||||
val json = response.toString().let {
|
||||
if (it.length <= 384 * 1024) it else error("RESPONSE_TOO_LARGE", "Reduce the command output or select fewer fields").toString()
|
||||
}
|
||||
return Bundle().apply { putString(RESULT_JSON, json) }
|
||||
}
|
||||
|
||||
private fun dispatch(context: Context, method: String, request: JSONObject): JSONObject = when (method) {
|
||||
"state" -> state(context).put("ok", true)
|
||||
"start" -> error("USE_DROIDDECKCTL", "Start sessions with tools/droiddeckctl so Android launches a visible Activity")
|
||||
"stop" -> stop(context)
|
||||
"resume" -> resume(context)
|
||||
"launch" -> launch(context, request)
|
||||
"focus" -> guest(context, JSONObject().put("kind", "focus"), 15_000)
|
||||
"quit" -> guest(context, JSONObject().put("kind", "quit").put("appId", request.opt("appId"))
|
||||
.put("timeout", request.optDouble("timeout", 15.0)), timeoutMs(request, 15.0, 30_000))
|
||||
"input" -> AgentInput.handle(request)
|
||||
"ui" -> AgentUi.handle(request)
|
||||
"guest" -> {
|
||||
AgentAccess.requireCommands(context)
|
||||
guest(context, JSONObject(request.toString()).put("kind", "exec"), timeoutMs(request, 30.0, 10_000))
|
||||
}
|
||||
"cdp" -> {
|
||||
AgentAccess.requireCommands(context)
|
||||
guest(context, JSONObject(request.toString()).put("kind", "cdp"), timeoutMs(request, 15.0, 5_000))
|
||||
}
|
||||
"env" -> env(context, request)
|
||||
"override" -> {
|
||||
AgentAccess.requireCommands(context)
|
||||
AgentEnv.override(context, request.optString("name")).put("ok", true).put("command", "override")
|
||||
}
|
||||
"prefs" -> prefs(context, request)
|
||||
"access" -> JSONObject().put("ok", true).put("commands", AgentAccess.commandsAllowed(context))
|
||||
.put("debugBuild", BuildConfig.DEBUG).put("inbox", AgentEnv.inbox(context).absolutePath)
|
||||
else -> error("UNKNOWN_COMMAND", "Unknown agent command '$method'")
|
||||
}
|
||||
|
||||
private fun state(context: Context): JSONObject {
|
||||
val runtimeVersion = LinuxRuntimeInstaller.installedVersion(context)
|
||||
val dir = SessionState.logDirectory ?: SessionState.logFile?.parentFile ?: SessionPaths.sessionFolders(context).lastOrNull()
|
||||
val session = JSONObject()
|
||||
.put("id", SessionState.sessionId ?: dir?.name ?: JSONObject.NULL)
|
||||
.put("phase", SessionState.phase.name)
|
||||
.put("running", SessionState.running)
|
||||
.put("mode", SessionState.mode)
|
||||
.put("program", SessionState.program ?: JSONObject.NULL)
|
||||
.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())
|
||||
.put("lastTransitionAt", SessionState.lastTransitionAt)
|
||||
.put("guestPid", SessionState.guestPid.takeIf { it > 1 } ?: JSONObject.NULL)
|
||||
.put("installing", SessionState.installing ?: JSONObject.NULL)
|
||||
.put("logDir", dir?.absolutePath ?: JSONObject.NULL)
|
||||
.put("eventsFile", dir?.let { File(it, "events.jsonl").absolutePath } ?: JSONObject.NULL)
|
||||
.put("artifactsAvailable", dir?.isDirectory ?: false)
|
||||
.put("artifactsComplete", dir?.let { File(it, ".complete").isFile } ?: false)
|
||||
|
||||
val failure = if (SessionState.failureCode == null) JSONObject.NULL else JSONObject()
|
||||
.put("code", SessionState.failureCode)
|
||||
.put("message", SessionState.failureMessage ?: JSONObject.NULL)
|
||||
.put("status", SessionState.failureStatus ?: JSONObject.NULL)
|
||||
session.put("failure", failure)
|
||||
|
||||
// gamescope's focus as last published by the guest agent, without the window lists
|
||||
// (the focus command returns those, fresh).
|
||||
val focus = AgentGuest.focus(context)?.let { snap ->
|
||||
JSONObject()
|
||||
.put("focusedApp", snap.opt("focusedApp") ?: JSONObject.NULL)
|
||||
.put("focusedWindow", snap.opt("focusedWindow") ?: JSONObject.NULL)
|
||||
.put("focusableApps", snap.optJSONArray("focusableApps") ?: JSONArray())
|
||||
.put("baselayerAppIds", snap.optJSONArray("baselayerAppIds") ?: JSONArray())
|
||||
.put("seq", snap.optInt("seq"))
|
||||
.put("t", snap.optLong("t"))
|
||||
}
|
||||
session.put("focus", focus ?: JSONObject.NULL)
|
||||
|
||||
val hello = AgentGuest.hello(context)
|
||||
return JSONObject()
|
||||
.put("schema", SCHEMA)
|
||||
.put("build", BuildConfig.BUILD_LABEL)
|
||||
.put("appVersion", context.packageManager.getPackageInfo(context.packageName, 0).versionName)
|
||||
.put("runtime", JSONObject()
|
||||
.put("installed", LinuxRuntime.isInstalled(context))
|
||||
.put("version", runtimeVersion ?: JSONObject.NULL))
|
||||
.put("agent", JSONObject()
|
||||
.put("guest", hello != null)
|
||||
.put("guestFocus", hello?.optBoolean("focus") ?: false)
|
||||
.put("commands", AgentAccess.commandsAllowed(context)))
|
||||
.put("session", session)
|
||||
}
|
||||
|
||||
private fun stop(context: Context): JSONObject {
|
||||
if (SessionState.running) {
|
||||
SessionService.stop(context)
|
||||
return JSONObject().put("ok", true).put("command", "stop")
|
||||
}
|
||||
if (SessionState.phase == SessionPhase.STOPPING) {
|
||||
return JSONObject().put("ok", true).put("command", "stop")
|
||||
}
|
||||
if (SessionState.phase in STARTING_PHASES) {
|
||||
SessionState.stopRequested = true
|
||||
SessionEvents.transition(SessionPhase.STOPPING, "session.stop_requested")
|
||||
return JSONObject().put("ok", true).put("command", "stop")
|
||||
}
|
||||
return error("NO_ACTIVE_SESSION", "There is no session to stop")
|
||||
}
|
||||
|
||||
private fun resume(context: Context): JSONObject {
|
||||
if (!SessionState.running || !SessionState.suspended) {
|
||||
return error("SESSION_NOT_SUSPENDED", "There is no suspended session to resume")
|
||||
}
|
||||
SessionService.resume(context)
|
||||
return JSONObject().put("ok", true).put("command", "resume")
|
||||
}
|
||||
|
||||
private fun launch(context: Context, request: JSONObject): JSONObject {
|
||||
val appId = request.optString("appId")
|
||||
if (!GameLaunchLink.validId(appId)) throw AgentException("INVALID_APP_ID", "appId must be a decimal Steam app id")
|
||||
if (!SessionState.running || SessionState.mode != SessionService.MODE_STEAM) {
|
||||
throw AgentException("NO_STEAM_SESSION", "Games launch inside a running Steam session")
|
||||
}
|
||||
if (!SessionService.launchGame(context, appId)) throw AgentException("LAUNCH_REJECTED", "The session did not accept the launch")
|
||||
SessionEvents.record("agent.launch_requested", mapOf("appId" to appId))
|
||||
return JSONObject().put("ok", true).put("command", "launch").put("appId", appId)
|
||||
}
|
||||
|
||||
private fun env(context: Context, request: JSONObject): JSONObject {
|
||||
AgentAccess.requireCommands(context)
|
||||
val result = when (val op = request.optString("op", "list")) {
|
||||
"list" -> AgentEnv.list(context)
|
||||
"set" -> {
|
||||
val lines = request.optJSONArray("lines") ?: JSONArray()
|
||||
AgentEnv.set(context, List(lines.length()) { lines.optString(it) }, request.optBoolean("persistent"))
|
||||
}
|
||||
"clear" -> AgentEnv.clear(context, request.optString("scope", "next"))
|
||||
else -> throw AgentException("INVALID_REQUEST", "env op '$op' is not list, set or clear")
|
||||
}
|
||||
return result.put("ok", true).put("command", "env")
|
||||
}
|
||||
|
||||
private fun prefs(context: Context, request: JSONObject): JSONObject {
|
||||
AgentAccess.requireCommands(context)
|
||||
val file = request.optString("file")
|
||||
val result = when (val op = request.optString("op", "get")) {
|
||||
"get" -> AgentPrefs.get(context, file)
|
||||
"set" -> AgentPrefs.set(context, file, request.optString("key"), request.optString("type", "string"),
|
||||
if (request.isNull("value")) null else request.optString("value"))
|
||||
else -> throw AgentException("INVALID_REQUEST", "prefs op '$op' is not get or set")
|
||||
}
|
||||
return result.put("ok", true).put("command", "prefs")
|
||||
}
|
||||
|
||||
/** A guest agent request; its own answer already carries ok or error. */
|
||||
private fun guest(context: Context, request: JSONObject, timeoutMs: Long): JSONObject =
|
||||
AgentGuest.call(context, request, timeoutMs)
|
||||
|
||||
/** The guest's own timeout plus slack for the round trip. */
|
||||
private fun timeoutMs(request: JSONObject, defaultSeconds: Double, slackMs: Long): Long {
|
||||
val seconds = if (request.has("timeout")) request.opt("timeout")?.toString()?.toDoubleOrNull()
|
||||
?: throw AgentException("INVALID_REQUEST", "timeout must be a number") else defaultSeconds
|
||||
if (!seconds.isFinite() || seconds <= 0 || seconds > 600) {
|
||||
throw AgentException("INVALID_REQUEST", "timeout must be a positive number no greater than 600 seconds")
|
||||
}
|
||||
return (seconds * 1000).toLong() + slackMs
|
||||
}
|
||||
|
||||
private fun decode(encoded: String?): JSONObject {
|
||||
if (encoded.isNullOrBlank()) return JSONObject()
|
||||
if (encoded.length > AgentAccess.MAX_REQUEST_CHARS) throw AgentException("INVALID_REQUEST", "The encoded request exceeds 256 KiB")
|
||||
return try {
|
||||
JSONObject(String(Base64.decode(encoded, Base64.DEFAULT), Charsets.UTF_8))
|
||||
} catch (e: Exception) {
|
||||
throw AgentException("INVALID_REQUEST", "The request extra is not base64 JSON")
|
||||
}
|
||||
}
|
||||
|
||||
private fun error(code: String, message: String) = JSONObject()
|
||||
.put("ok", false)
|
||||
.put("error", JSONObject().put("code", code).put("message", message))
|
||||
|
||||
override fun query(uri: Uri, projection: Array<out String>?, selection: String?, selectionArgs: Array<out String>?, sortOrder: String?): Cursor? = null
|
||||
override fun getType(uri: Uri): String? = null
|
||||
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
|
||||
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = 0
|
||||
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?): Int = 0
|
||||
|
||||
companion object {
|
||||
const val RESULT_JSON = "json"
|
||||
const val EXTRA_REQUEST = "request"
|
||||
/** 2 added agent and session.focus; everything in 1 is unchanged. */
|
||||
const val SCHEMA = 2
|
||||
private val recoveryComplete = CountDownLatch(1)
|
||||
|
||||
fun awaitRecovery() {
|
||||
try {
|
||||
if (!recoveryComplete.await(90, TimeUnit.SECONDS)) {
|
||||
throw IllegalStateException("Session artifact recovery did not finish")
|
||||
}
|
||||
} catch (e: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
throw IllegalStateException("Interrupted while recovering session artifacts", e)
|
||||
}
|
||||
}
|
||||
|
||||
private val STARTING_PHASES = setOf(
|
||||
SessionPhase.PREPARING,
|
||||
SessionPhase.INSTALLING_RUNTIME,
|
||||
SessionPhase.STARTING_COMPOSITOR,
|
||||
SessionPhase.STARTING_GUEST,
|
||||
SessionPhase.STARTING_STEAM,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
package com.droiddeck.launcher.agent
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.LinkOption.NOFOLLOW_LINKS
|
||||
import java.nio.file.StandardOpenOption.READ
|
||||
|
||||
/**
|
||||
* Experiment switches an agent sets without touching Download/droiddeck-env or rebuilding the apk:
|
||||
*
|
||||
* - environment lines (KEY=VALUE) for the session, applied after the user's own droiddeck-env, so
|
||||
* they win. "next" lines are taken by the next session that starts and dropped when it ends;
|
||||
* "persistent" ones stay until cleared.
|
||||
* - binary overrides: a file pushed to the app's external files (agent-inbox/<name>) is installed
|
||||
* as /opt/droiddeck-agent/bin/<name> in the rootfs and put first on the next session's PATH -
|
||||
* a gamescope build under test, say. Removed when that session ends.
|
||||
*
|
||||
* Kept in the app's own files (agent-env.json), never in a place the shell can write.
|
||||
*/
|
||||
object AgentEnv {
|
||||
private const val TAG = "AgentEnv"
|
||||
private const val FILE = "agent-env.json"
|
||||
private const val GUEST_BIN = "/opt/droiddeck-agent/bin"
|
||||
private const val BASE_PATH = "/usr/local/bin:/usr/bin:/bin"
|
||||
private val NAME = Regex("[A-Za-z0-9._-]{1,64}")
|
||||
private val KEY = Regex("[A-Za-z_][A-Za-z0-9_]*")
|
||||
private val lock = Any()
|
||||
|
||||
fun inbox(context: Context): File = File(context.getExternalFilesDir(null), "agent-inbox")
|
||||
|
||||
fun list(context: Context): JSONObject = synchronized(lock) {
|
||||
AgentAccess.requireCommands(context)
|
||||
load(context)
|
||||
}
|
||||
|
||||
/** Add [lines] for the next session ([persistent] = every session until cleared). */
|
||||
fun set(context: Context, lines: List<String>, persistent: Boolean): JSONObject = synchronized(lock) {
|
||||
AgentAccess.requireCommands(context)
|
||||
if (lines.any { line -> KEY.matchEntire(line.substringBefore('=', "")) == null || !line.contains('=') || line.any { it == '\n' || it == '\r' || it == '\u0000' } }) {
|
||||
throw AgentException("INVALID_ENV", "Environment entries must be single KEY=VALUE lines without NUL bytes")
|
||||
}
|
||||
val state = load(context)
|
||||
val key = if (persistent) "persistent" else "pending"
|
||||
val merged = merge(strings(state.optJSONArray(key)), lines)
|
||||
state.put(key, JSONArray(merged))
|
||||
save(context, state)
|
||||
state
|
||||
}
|
||||
|
||||
/** Drop lines: [scope] is "next", "persistent" or "all". */
|
||||
fun clear(context: Context, scope: String): JSONObject = synchronized(lock) {
|
||||
AgentAccess.requireCommands(context)
|
||||
val state = load(context)
|
||||
when (scope) {
|
||||
"next" -> state.put("pending", JSONArray())
|
||||
"persistent" -> state.put("persistent", JSONArray())
|
||||
"all" -> { state.put("pending", JSONArray()); state.put("persistent", JSONArray()); state.put("pendingOverrides", JSONArray()) }
|
||||
else -> throw AgentException("INVALID_SCOPE", "Scope must be next, persistent or all")
|
||||
}
|
||||
save(context, state)
|
||||
state
|
||||
}
|
||||
|
||||
/** Install agent-inbox/[name] as an override for the next session. */
|
||||
fun override(context: Context, name: String): JSONObject = synchronized(lock) {
|
||||
AgentAccess.requireCommands(context)
|
||||
if (NAME.matchEntire(name) == null || name in setOf(".", "..")) throw AgentException("INVALID_NAME", "Override names are letters, digits, '.', '_' and '-'")
|
||||
val source = File(inbox(context), name)
|
||||
if (Files.isSymbolicLink(source.toPath())) throw AgentException("INVALID_NAME", "The override must be a regular file")
|
||||
if (!source.isFile) throw AgentException("NO_SUCH_FILE", "Push the binary to ${source.path} first")
|
||||
val target = File(bin(context), name)
|
||||
target.parentFile?.mkdirs()
|
||||
val staged = File.createTempFile("override-", ".tmp", target.parentFile)
|
||||
try {
|
||||
Files.newInputStream(source.toPath(), READ, NOFOLLOW_LINKS).use { input -> staged.outputStream().use { input.copyTo(it) } }
|
||||
} catch (e: Exception) {
|
||||
staged.delete()
|
||||
throw AgentException("OVERRIDE_FAILED", "Could not stage the override")
|
||||
}
|
||||
if (!staged.setExecutable(true, false) || !staged.renameTo(target)) {
|
||||
staged.delete()
|
||||
throw AgentException("OVERRIDE_FAILED", "Could not install $name in the rootfs")
|
||||
}
|
||||
source.delete()
|
||||
val state = load(context)
|
||||
state.put("pendingOverrides", JSONArray(merge(strings(state.optJSONArray("pendingOverrides")), listOf(name))))
|
||||
save(context, state)
|
||||
state.put("installed", "$GUEST_BIN/$name")
|
||||
}
|
||||
|
||||
/**
|
||||
* At session start: the lines this session gets, after the user's droiddeck-env. Pending lines
|
||||
* and overrides become this session's own, to be dropped by [endSession].
|
||||
*/
|
||||
fun beginSession(context: Context): List<String> = synchronized(lock) {
|
||||
if (!AgentAccess.commandsAllowed(context)) {
|
||||
revoke(context)
|
||||
return@synchronized emptyList()
|
||||
}
|
||||
val state = load(context)
|
||||
val active = strings(state.optJSONArray("pending"))
|
||||
val overrides = strings(state.optJSONArray("pendingOverrides"))
|
||||
state.put("active", JSONArray(active)).put("pending", JSONArray())
|
||||
state.put("activeOverrides", JSONArray(overrides)).put("pendingOverrides", JSONArray())
|
||||
save(context, state)
|
||||
// Anything else there was left by a session that never reached endSession (the app died).
|
||||
bin(context).listFiles()
|
||||
?.filter { it.name !in overrides }?.forEach { it.delete() }
|
||||
val lines = strings(state.optJSONArray("persistent")) + active
|
||||
val withPath = if (overrides.isEmpty()) lines else lines + "PATH=$GUEST_BIN:${pathOf(lines)}"
|
||||
if (withPath.isNotEmpty()) Log.i(TAG, "agent environment keys for this session: ${withPath.map { it.substringBefore('=') }}")
|
||||
withPath
|
||||
}
|
||||
|
||||
fun revoke(context: Context) = synchronized(lock) {
|
||||
val state = load(context)
|
||||
for (key in listOf("pending", "active", "persistent", "pendingOverrides", "activeOverrides")) state.put(key, JSONArray())
|
||||
save(context, state)
|
||||
bin(context).listFiles()?.forEach { it.delete() }
|
||||
}
|
||||
|
||||
/** At session end: this session's lines and override binaries go. */
|
||||
fun endSession(context: Context) = synchronized(lock) {
|
||||
val state = load(context)
|
||||
val overrides = strings(state.optJSONArray("activeOverrides"))
|
||||
val pending = strings(state.optJSONArray("pendingOverrides"))
|
||||
val bin = bin(context)
|
||||
overrides.filter { it !in pending }.forEach { File(bin, it).delete() }
|
||||
state.put("active", JSONArray()).put("activeOverrides", JSONArray())
|
||||
save(context, state)
|
||||
}
|
||||
|
||||
/** A PATH= line among [lines] (the user's or an agent's) is extended rather than replaced. */
|
||||
private fun pathOf(lines: List<String>) =
|
||||
lines.lastOrNull { it.startsWith("PATH=") }?.substringAfter('=')?.takeIf { it.isNotBlank() } ?: BASE_PATH
|
||||
|
||||
private fun bin(context: Context): File {
|
||||
val root = LinuxRuntime.rootDir(context).canonicalFile.toPath()
|
||||
val directory = root.resolve(GUEST_BIN.removePrefix("/"))
|
||||
var path = directory
|
||||
while (path != root) {
|
||||
if (Files.isSymbolicLink(path)) throw AgentException("OVERRIDE_FAILED", "The override directory must not contain symbolic links")
|
||||
path = path.parent
|
||||
}
|
||||
return directory.toFile()
|
||||
}
|
||||
|
||||
private fun merge(existing: List<String>, added: List<String>): List<String> {
|
||||
val keys = added.map { it.substringBefore('=') }.toSet()
|
||||
return existing.filter { it.substringBefore('=') !in keys } + added
|
||||
}
|
||||
|
||||
private fun strings(array: JSONArray?): List<String> =
|
||||
if (array == null) emptyList() else List(array.length()) { array.optString(it) }.filter { it.isNotEmpty() }
|
||||
|
||||
private fun load(context: Context): JSONObject {
|
||||
val file = File(context.filesDir, FILE)
|
||||
val state = try { if (file.isFile) JSONObject(file.readText()) else JSONObject() } catch (e: Exception) { JSONObject() }
|
||||
for (key in listOf("pending", "active", "persistent", "pendingOverrides", "activeOverrides")) {
|
||||
if (!state.has(key)) state.put(key, JSONArray())
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
private fun save(context: Context, state: JSONObject) {
|
||||
val file = File(context.filesDir, FILE)
|
||||
val staged = File(context.filesDir, "$FILE.tmp")
|
||||
staged.writeText(state.toString())
|
||||
if (!staged.renameTo(file)) {
|
||||
staged.delete()
|
||||
throw AgentException("ENV_WRITE_FAILED", "Could not save the debugging environment")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Typed writes to the app's SharedPreferences, for driving a settings matrix without the UI. */
|
||||
object AgentPrefs {
|
||||
fun set(context: Context, file: String, key: String, type: String, value: String?): JSONObject {
|
||||
AgentAccess.requireCommands(context)
|
||||
validateFile(file)
|
||||
if (file == AgentAccess.PROTECTED_PREFS) throw AgentException("PROTECTED_PREFS", "The agent preferences are set on the device only")
|
||||
if (file.isBlank() || key.isBlank()) throw AgentException("INVALID_PREF", "file and key are required")
|
||||
val prefs = context.getSharedPreferences(file, Context.MODE_PRIVATE)
|
||||
val previous = prefs.all[key]
|
||||
val editor = prefs.edit()
|
||||
try {
|
||||
when (type) {
|
||||
"remove" -> editor.remove(key)
|
||||
"bool" -> editor.putBoolean(key, value?.toBooleanStrictOrNull() ?: throw NumberFormatException("not true/false"))
|
||||
"int" -> editor.putInt(key, value!!.toInt())
|
||||
"long" -> editor.putLong(key, value!!.toLong())
|
||||
"float" -> editor.putFloat(key, value!!.toFloat())
|
||||
"string" -> editor.putString(key, value ?: "")
|
||||
else -> throw AgentException("INVALID_PREF", "type must be bool, int, long, float, string or remove")
|
||||
}
|
||||
} catch (e: RuntimeException) {
|
||||
if (e is AgentException) throw e
|
||||
throw AgentException("INVALID_PREF", "The value is not a $type")
|
||||
}
|
||||
if (!editor.commit()) throw AgentException("PREF_WRITE_FAILED", "Could not write $file/$key")
|
||||
return JSONObject().put("file", file).put("key", key).put("type", type)
|
||||
.put("previous", previous ?: JSONObject.NULL).put("value", prefs.all[key] ?: JSONObject.NULL)
|
||||
}
|
||||
|
||||
fun get(context: Context, file: String): JSONObject {
|
||||
AgentAccess.requireCommands(context)
|
||||
validateFile(file)
|
||||
val all = context.getSharedPreferences(file, Context.MODE_PRIVATE).all
|
||||
return JSONObject().put("file", file).put("values", JSONObject(all.mapValues { it.value ?: JSONObject.NULL }))
|
||||
}
|
||||
|
||||
private fun validateFile(file: String) {
|
||||
if (!Regex("[A-Za-z0-9_-][A-Za-z0-9._-]{0,127}").matches(file)) {
|
||||
throw AgentException("INVALID_PREF", "Preference files must be simple names")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
package com.droiddeck.launcher.agent
|
||||
|
||||
import android.content.Context
|
||||
import android.os.FileObserver
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.session.SessionEvents
|
||||
import com.droiddeck.launcher.session.SessionState
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* The app's side of the guest agent (tools/linuxfs/overlay/usr/local/bin/droiddeck-agent): requests
|
||||
* go in as files under the session root's agent directory and answers come back the same way, so
|
||||
* whatever runs does so inside the live session with its own environment.
|
||||
*/
|
||||
object AgentGuest {
|
||||
private const val TAG = "AgentGuest"
|
||||
private const val POLL_MS = 50L
|
||||
private val lock = Any()
|
||||
private var observer: FileObserver? = null
|
||||
private var lastFocusKey: String? = null
|
||||
|
||||
fun dir(context: Context) = File(LinuxRuntime.sessionRoot(context), "agent")
|
||||
|
||||
/**
|
||||
* Before each session: nothing of the last one's daemon, answers or focus may be read as
|
||||
* current. Then watch for the new daemon's hello and focus snapshots, which go into the
|
||||
* session's events.jsonl.
|
||||
*/
|
||||
fun reset(context: Context) {
|
||||
synchronized(lock) {
|
||||
observer?.stopWatching()
|
||||
observer = null
|
||||
lastFocusKey = null
|
||||
val dir = dir(context)
|
||||
dir.deleteRecursively()
|
||||
dir.mkdirs()
|
||||
@Suppress("DEPRECATION")
|
||||
observer = object : FileObserver(dir.path, MOVED_TO or CLOSE_WRITE) {
|
||||
override fun onEvent(event: Int, path: String?) {
|
||||
when (path) {
|
||||
"hello.json" -> readJson(File(dir, path))?.let {
|
||||
SessionEvents.record("agent.ready", mapOf("pid" to it.optInt("pid"), "focus" to it.optBoolean("focus")))
|
||||
}
|
||||
"focus.json" -> readJson(File(dir, path))?.let(::recordFocus)
|
||||
}
|
||||
}
|
||||
}.also { it.startWatching() }
|
||||
}
|
||||
}
|
||||
|
||||
/** At the end of a session: no more events for a folder that is being closed. */
|
||||
fun stop() {
|
||||
synchronized(lock) {
|
||||
observer?.stopWatching()
|
||||
observer = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun recordFocus(snapshot: JSONObject) {
|
||||
val fields = linkedMapOf<String, Any?>(
|
||||
"focusedApp" to snapshot.opt("focusedApp"),
|
||||
"focusableApps" to snapshot.optJSONArray("focusableApps")?.toString(),
|
||||
"baselayerAppIds" to snapshot.optJSONArray("baselayerAppIds")?.toString(),
|
||||
)
|
||||
val key = fields.values.joinToString("|")
|
||||
synchronized(lock) {
|
||||
if (key == lastFocusKey) return
|
||||
lastFocusKey = key
|
||||
}
|
||||
SessionEvents.record("focus.changed", fields + ("seq" to snapshot.optInt("seq")))
|
||||
}
|
||||
|
||||
/** The daemon's hello, when it is running in the current session. */
|
||||
fun hello(context: Context): JSONObject? {
|
||||
if (!SessionState.running) return null
|
||||
val hello = readJson(File(dir(context), "hello.json")) ?: return null
|
||||
val pid = hello.optInt("pid", -1)
|
||||
return hello.takeIf { pid > 1 && File("/proc/$pid").exists() }
|
||||
}
|
||||
|
||||
/** The newest focus snapshot the daemon published, when a session is running. */
|
||||
fun focus(context: Context): JSONObject? =
|
||||
if (SessionState.running) readJson(File(dir(context), "focus.json")) else null
|
||||
|
||||
/** Send one request and wait for its answer. Throws [AgentException] with a stable code. */
|
||||
fun call(context: Context, request: JSONObject, timeoutMs: Long): JSONObject {
|
||||
if (request.optString("kind") in setOf("exec", "cdp")) AgentAccess.requireCommands(context)
|
||||
if (!SessionState.running) throw AgentException("NO_ACTIVE_SESSION", "There is no running session")
|
||||
hello(context) ?: throw AgentException("AGENT_UNAVAILABLE", "The guest agent is not running in this session (yet)")
|
||||
val dir = dir(context)
|
||||
val requests = File(dir, "req").apply { mkdirs() }
|
||||
val answers = File(dir, "resp").apply { mkdirs() }
|
||||
val id = UUID.randomUUID().toString()
|
||||
val staged = File(requests, "$id.json.tmp")
|
||||
val published = File(requests, "$id.json")
|
||||
val answer = File(answers, "$id.json")
|
||||
staged.writeText(request.toString())
|
||||
if (!staged.renameTo(published)) {
|
||||
staged.delete()
|
||||
throw AgentException("AGENT_UNAVAILABLE", "Could not hand the request to the guest agent")
|
||||
}
|
||||
val deadline = System.currentTimeMillis() + timeoutMs
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
if (answer.isFile) {
|
||||
if (answer.length() > 1024 * 1024) {
|
||||
answer.delete()
|
||||
throw AgentException("RESPONSE_TOO_LARGE", "The guest response exceeds 1 MiB")
|
||||
}
|
||||
val result = readJson(answer)
|
||||
answer.delete()
|
||||
return result ?: throw AgentException("INVALID_AGENT_RESPONSE", "The guest agent's answer was not JSON")
|
||||
}
|
||||
Thread.sleep(POLL_MS)
|
||||
}
|
||||
published.delete()
|
||||
Log.w(TAG, "no answer to ${request.optString("kind")} in ${timeoutMs}ms")
|
||||
throw AgentException("TIMEOUT", "The guest agent did not answer within ${timeoutMs / 1000}s")
|
||||
}
|
||||
|
||||
private fun readJson(file: File): JSONObject? = try {
|
||||
if (file.isFile && file.length() <= 1024 * 1024) JSONObject(file.readText()) else null
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
package com.droiddeck.launcher.agent
|
||||
|
||||
import com.droiddeck.launcher.input.PadState
|
||||
import com.droiddeck.launcher.session.SessionState
|
||||
import com.droiddeck.launcher.wayland.WaylandCompositor
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Input into the running session the way a player gives it: keys and touches through the
|
||||
* compositor (what the session screen sends), and the virtual pad through the same PadBridge the
|
||||
* on-screen controls drive, so games that ignore touch still see a controller.
|
||||
*/
|
||||
object AgentInput {
|
||||
/** Out of the way of real fingers, which Android numbers from 0. */
|
||||
private const val TOUCH_ID = 9
|
||||
private const val TOUCH_SCALE_X = 1919f
|
||||
private const val TOUCH_SCALE_Y = 1079f
|
||||
|
||||
fun handle(request: JSONObject): JSONObject {
|
||||
if (!SessionState.running || !SessionState.firstFrameSeen) {
|
||||
throw AgentException("SESSION_NOT_READY", "Input needs a running session that has drawn its first frame")
|
||||
}
|
||||
return when (val type = request.optString("type")) {
|
||||
"key" -> key(request)
|
||||
"text" -> text(request)
|
||||
"tap" -> tap(request)
|
||||
"swipe" -> swipe(request)
|
||||
"pad" -> pad(request)
|
||||
"stick" -> stick(request)
|
||||
"trigger" -> trigger(request)
|
||||
else -> throw AgentException("INVALID_INPUT", "Unknown input type '$type'")
|
||||
}.put("ok", true).put("command", "input").put("type", request.optString("type"))
|
||||
}
|
||||
|
||||
private fun key(request: JSONObject): JSONObject {
|
||||
val name = request.optString("key")
|
||||
val code = KEYS[name.uppercase()] ?: name.toIntOrNull()
|
||||
?: throw AgentException("INVALID_INPUT", "Unknown key '$name' (a name such as ENTER or an evdev code)")
|
||||
val modifiers = request.optJSONArray("modifiers")?.let { array ->
|
||||
List(array.length()) { i ->
|
||||
KEYS[array.optString(i).uppercase()] ?: throw AgentException("INVALID_INPUT", "Unknown modifier '${array.optString(i)}'")
|
||||
}
|
||||
}.orEmpty()
|
||||
when (val action = request.optString("action", "tap")) {
|
||||
"down" -> WaylandCompositor.nativeSendKey(code, 1)
|
||||
"up" -> WaylandCompositor.nativeSendKey(code, 0)
|
||||
"tap" -> {
|
||||
modifiers.forEach { WaylandCompositor.nativeSendKey(it, 1) }
|
||||
WaylandCompositor.nativeSendKey(code, 1)
|
||||
hold(request, 40)
|
||||
WaylandCompositor.nativeSendKey(code, 0)
|
||||
modifiers.asReversed().forEach { WaylandCompositor.nativeSendKey(it, 0) }
|
||||
}
|
||||
else -> throw AgentException("INVALID_INPUT", "Key action '$action' is not tap, down or up")
|
||||
}
|
||||
return JSONObject().put("code", code)
|
||||
}
|
||||
|
||||
/** Typed as key presses (US layout), which reaches X programs that have no text-input support. */
|
||||
private fun text(request: JSONObject): JSONObject {
|
||||
val text = request.optString("text")
|
||||
if (request.optBoolean("commit")) {
|
||||
WaylandCompositor.textInputCommit(text)
|
||||
return JSONObject().put("length", text.length).put("via", "text-input")
|
||||
}
|
||||
val missing = text.filter { TYPED[it] == null }
|
||||
if (missing.isNotEmpty()) throw AgentException("INVALID_INPUT", "Cannot type '${missing.toSet().joinToString("")}' as keys; use commit")
|
||||
val delay = request.optLong("delayMs", 15)
|
||||
for (ch in text) {
|
||||
val (code, shift) = TYPED.getValue(ch)
|
||||
if (shift) WaylandCompositor.nativeSendKey(KEYS.getValue("LEFTSHIFT"), 1)
|
||||
WaylandCompositor.nativeSendKey(code, 1)
|
||||
WaylandCompositor.nativeSendKey(code, 0)
|
||||
if (shift) WaylandCompositor.nativeSendKey(KEYS.getValue("LEFTSHIFT"), 0)
|
||||
Thread.sleep(delay)
|
||||
}
|
||||
return JSONObject().put("length", text.length).put("via", "keys")
|
||||
}
|
||||
|
||||
private fun tap(request: JSONObject): JSONObject {
|
||||
val (x, y) = point(request, "x", "y")
|
||||
WaylandCompositor.nativeSendTouch(0, TOUCH_ID, x, y)
|
||||
hold(request, 60)
|
||||
WaylandCompositor.nativeSendTouch(2, TOUCH_ID, x, y)
|
||||
return JSONObject().put("touch", org.json.JSONArray().put(x).put(y))
|
||||
}
|
||||
|
||||
private fun swipe(request: JSONObject): JSONObject {
|
||||
val (x1, y1) = point(request, "x", "y")
|
||||
val (x2, y2) = point(request, "x2", "y2")
|
||||
val duration = request.optLong("durationMs", 300).coerceIn(16, 10_000)
|
||||
val steps = (duration / 16).toInt().coerceAtLeast(2)
|
||||
WaylandCompositor.nativeSendTouch(0, TOUCH_ID, x1, y1)
|
||||
for (i in 1..steps) {
|
||||
Thread.sleep(duration / steps)
|
||||
WaylandCompositor.nativeSendTouch(1, TOUCH_ID, x1 + (x2 - x1) * i / steps, y1 + (y2 - y1) * i / steps)
|
||||
}
|
||||
WaylandCompositor.nativeSendTouch(2, TOUCH_ID, x2, y2)
|
||||
return JSONObject().put("from", org.json.JSONArray().put(x1).put(y1)).put("to", org.json.JSONArray().put(x2).put(y2))
|
||||
}
|
||||
|
||||
/**
|
||||
* A point in the compositor's touch space (0..1919 x 0..1079 whatever the session's size), from
|
||||
* the session's output pixels (space "output", the default: what the guest draws at) or 0..1
|
||||
* fractions (space "fraction").
|
||||
*/
|
||||
private fun point(request: JSONObject, xKey: String, yKey: String): Pair<Int, Int> {
|
||||
if (!request.has(xKey) || !request.has(yKey)) throw AgentException("INVALID_INPUT", "$xKey and $yKey are required")
|
||||
val x = request.getDouble(xKey)
|
||||
val y = request.getDouble(yKey)
|
||||
val (fx, fy) = when (val space = request.optString("space", "output")) {
|
||||
"fraction" -> x to y
|
||||
"output" -> {
|
||||
val (w, h) = SessionState.outputSize
|
||||
x / (w - 1).coerceAtLeast(1) to y / (h - 1).coerceAtLeast(1)
|
||||
}
|
||||
else -> throw AgentException("INVALID_INPUT", "space '$space' is not output or fraction")
|
||||
}
|
||||
return (fx.coerceIn(0.0, 1.0) * TOUCH_SCALE_X).toInt() to (fy.coerceIn(0.0, 1.0) * TOUCH_SCALE_Y).toInt()
|
||||
}
|
||||
|
||||
private fun pad(request: JSONObject): JSONObject {
|
||||
val bridge = SessionState.padBridge ?: throw AgentException("NO_PAD", "The session has no virtual pad")
|
||||
val name = request.optString("button").lowercase()
|
||||
if (name == "qam") {
|
||||
bridge.triggerQam()
|
||||
return JSONObject().put("button", name)
|
||||
}
|
||||
val button = BUTTONS[name]
|
||||
if (button == null && name !in DPAD) throw AgentException("INVALID_INPUT", "Unknown pad button '$name'")
|
||||
val apply = { s: PadState, down: Boolean ->
|
||||
when (name) {
|
||||
"up" -> s.up = down
|
||||
"down" -> s.down = down
|
||||
"left" -> s.left = down
|
||||
"right" -> s.right = down
|
||||
else -> s.press(button!!, down)
|
||||
}
|
||||
}
|
||||
when (val action = request.optString("action", "tap")) {
|
||||
"down" -> bridge.applyTouch { apply(it, true) }
|
||||
"up" -> bridge.applyTouch { apply(it, false) }
|
||||
"tap" -> {
|
||||
bridge.applyTouch { apply(it, true) }
|
||||
hold(request, 120)
|
||||
bridge.applyTouch { apply(it, false) }
|
||||
}
|
||||
else -> throw AgentException("INVALID_INPUT", "Pad action '$action' is not tap, down or up")
|
||||
}
|
||||
return JSONObject().put("button", name)
|
||||
}
|
||||
|
||||
private fun stick(request: JSONObject): JSONObject {
|
||||
val bridge = SessionState.padBridge ?: throw AgentException("NO_PAD", "The session has no virtual pad")
|
||||
val right = request.optString("stick", "left") == "right"
|
||||
val x = request.optDouble("x", 0.0).toFloat().coerceIn(-1f, 1f)
|
||||
val y = request.optDouble("y", 0.0).toFloat().coerceIn(-1f, 1f)
|
||||
bridge.applyTouch { if (right) { it.rightX = x; it.rightY = y } else { it.leftX = x; it.leftY = y } }
|
||||
hold(request, 300)
|
||||
bridge.applyTouch { if (right) { it.rightX = 0f; it.rightY = 0f } else { it.leftX = 0f; it.leftY = 0f } }
|
||||
return JSONObject().put("stick", if (right) "right" else "left")
|
||||
}
|
||||
|
||||
private fun trigger(request: JSONObject): JSONObject {
|
||||
val bridge = SessionState.padBridge ?: throw AgentException("NO_PAD", "The session has no virtual pad")
|
||||
val right = request.optString("trigger", "right") == "right"
|
||||
val value = request.optDouble("value", 1.0).toFloat().coerceIn(0f, 1f)
|
||||
bridge.applyTouch { if (right) it.rightTrigger = value else it.leftTrigger = value }
|
||||
hold(request, 200)
|
||||
bridge.applyTouch { if (right) it.rightTrigger = 0f else it.leftTrigger = 0f }
|
||||
return JSONObject().put("trigger", if (right) "right" else "left")
|
||||
}
|
||||
|
||||
private fun hold(request: JSONObject, defaultMs: Long) =
|
||||
Thread.sleep(request.optLong("holdMs", defaultMs).coerceIn(0, 10_000))
|
||||
|
||||
private val DPAD = setOf("up", "down", "left", "right")
|
||||
|
||||
private val BUTTONS = mapOf(
|
||||
"a" to PadState.A, "b" to PadState.B, "x" to PadState.X, "y" to PadState.Y,
|
||||
"lb" to PadState.LB, "rb" to PadState.RB, "select" to PadState.SELECT, "back" to PadState.SELECT,
|
||||
"start" to PadState.START, "menu" to PadState.START, "l3" to PadState.L3, "r3" to PadState.R3,
|
||||
"guide" to PadState.GUIDE, "steam" to PadState.GUIDE,
|
||||
)
|
||||
|
||||
/** Linux evdev codes (input-event-codes.h) by name. */
|
||||
private val KEYS: Map<String, Int> = buildMap {
|
||||
"1234567890".forEachIndexed { i, c -> put(c.toString(), 2 + i) }
|
||||
"QWERTYUIOP".forEachIndexed { i, c -> put(c.toString(), 16 + i) }
|
||||
"ASDFGHJKL".forEachIndexed { i, c -> put(c.toString(), 30 + i) }
|
||||
"ZXCVBNM".forEachIndexed { i, c -> put(c.toString(), 44 + i) }
|
||||
(1..10).forEach { put("F$it", 58 + it) }
|
||||
put("F11", 87); put("F12", 88)
|
||||
putAll(mapOf(
|
||||
"ESC" to 1, "MINUS" to 12, "EQUAL" to 13, "BACKSPACE" to 14, "TAB" to 15, "LEFTBRACE" to 26,
|
||||
"RIGHTBRACE" to 27, "ENTER" to 28, "LEFTCTRL" to 29, "SEMICOLON" to 39, "APOSTROPHE" to 40,
|
||||
"GRAVE" to 41, "LEFTSHIFT" to 42, "BACKSLASH" to 43, "COMMA" to 51, "DOT" to 52, "SLASH" to 53,
|
||||
"RIGHTSHIFT" to 54, "LEFTALT" to 56, "SPACE" to 57, "CAPSLOCK" to 58, "RIGHTCTRL" to 97,
|
||||
"RIGHTALT" to 100, "HOME" to 102, "UP" to 103, "PAGEUP" to 104, "LEFT" to 105, "RIGHT" to 106,
|
||||
"END" to 107, "DOWN" to 108, "PAGEDOWN" to 109, "INSERT" to 110, "DELETE" to 111, "LEFTMETA" to 125,
|
||||
"CTRL" to 29, "SHIFT" to 42, "ALT" to 56, "META" to 125, "SUPER" to 125, "RETURN" to 28, "ESCAPE" to 1,
|
||||
))
|
||||
}
|
||||
|
||||
/** US-layout characters as (evdev code, shift). */
|
||||
private val TYPED: Map<Char, Pair<Int, Boolean>> = buildMap {
|
||||
for (c in 'a'..'z') put(c, KEYS.getValue(c.uppercase()) to false)
|
||||
for (c in 'A'..'Z') put(c, KEYS.getValue(c.toString()) to true)
|
||||
for (c in '0'..'9') put(c, KEYS.getValue(c.toString()) to false)
|
||||
"!@#$%^&*()".forEachIndexed { i, c -> put(c, KEYS.getValue(((i + 1) % 10).toString()) to true) }
|
||||
putAll(mapOf(
|
||||
' ' to (57 to false), '\n' to (28 to false), '\t' to (15 to false),
|
||||
'-' to (12 to false), '_' to (12 to true), '=' to (13 to false), '+' to (13 to true),
|
||||
'[' to (26 to false), '{' to (26 to true), ']' to (27 to false), '}' to (27 to true),
|
||||
';' to (39 to false), ':' to (39 to true), '\'' to (40 to false), '"' to (40 to true),
|
||||
'`' to (41 to false), '~' to (41 to true), '\\' to (43 to false), '|' to (43 to true),
|
||||
',' to (51 to false), '<' to (51 to true), '.' to (52 to false), '>' to (52 to true),
|
||||
'/' to (53 to false), '?' to (53 to true),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
package com.droiddeck.launcher.agent
|
||||
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.inspector.WindowInspector
|
||||
import androidx.compose.ui.platform.ViewRootForTest
|
||||
import androidx.compose.ui.semantics.SemanticsActions
|
||||
import androidx.compose.ui.semantics.SemanticsConfiguration
|
||||
import androidx.compose.ui.semantics.SemanticsNode
|
||||
import androidx.compose.ui.semantics.SemanticsProperties
|
||||
import androidx.compose.ui.semantics.getOrNull
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
/**
|
||||
* The app's own UI as an agent sees it: every Compose node in every window of the process (popups
|
||||
* and menus included), read from Compose's semantics rather than through UI Automator, which on a
|
||||
* dual-screen device dumps whichever window has focus - often not this app's. Nodes carry their
|
||||
* test tag (ui/TestTags.kt), text, content description, role, state and on-screen bounds; click
|
||||
* and set-text act on a node by tag or text, so a test needs no coordinates.
|
||||
*/
|
||||
object AgentUi {
|
||||
private const val TIMEOUT_MS = 5_000L
|
||||
/** Setup's Debugging tools toggle: the bridge never acts on its own gate (AgentAccess). */
|
||||
private const val GATE_TAG = "setting-agent-commands"
|
||||
|
||||
fun handle(request: JSONObject): JSONObject = when (val op = request.optString("op", "dump")) {
|
||||
"dump" -> onMain { dump(request.optBoolean("all")) }
|
||||
"click" -> onMain { act(request) { node -> perform(node, SemanticsActions.OnClick) } }
|
||||
"set-text" -> onMain {
|
||||
val text = request.optString("text")
|
||||
act(request) { node ->
|
||||
node.config.getOrNull(SemanticsActions.SetText)?.action?.invoke(AnnotatedString(text))
|
||||
?: throw AgentException("NOT_EDITABLE", "That node takes no text")
|
||||
}
|
||||
}
|
||||
else -> throw AgentException("INVALID_REQUEST", "ui op '$op' is not dump, click or set-text")
|
||||
}.put("ok", true).put("command", "ui")
|
||||
|
||||
private fun roots(): List<Pair<View, ViewRootForTest>> {
|
||||
if (Build.VERSION.SDK_INT < 29) throw AgentException("UNSUPPORTED", "The UI dump needs Android 10 or later")
|
||||
val found = mutableListOf<Pair<View, ViewRootForTest>>()
|
||||
fun walk(view: View) {
|
||||
if (view is ViewRootForTest) found += view to view
|
||||
if (view is ViewGroup) for (i in 0 until view.childCount) walk(view.getChildAt(i))
|
||||
}
|
||||
WindowInspector.getGlobalWindowViews().filter { it.isAttachedToWindow && it.isShown }.forEach(::walk)
|
||||
return found
|
||||
}
|
||||
|
||||
private fun dump(all: Boolean): JSONObject {
|
||||
val windows = JSONArray()
|
||||
for ((view, root) in roots()) {
|
||||
val nodes = JSONArray()
|
||||
val location = IntArray(2).also { view.getLocationOnScreen(it) }
|
||||
fun visit(node: SemanticsNode) {
|
||||
val json = describe(node, location)
|
||||
if (all || json.has("tag") || json.has("text") || json.has("description") || json.optBoolean("clickable")) nodes.put(json)
|
||||
node.children.forEach(::visit)
|
||||
}
|
||||
visit(root.semanticsOwner.rootSemanticsNode)
|
||||
windows.put(JSONObject()
|
||||
.put("display", view.display?.displayId ?: JSONObject.NULL)
|
||||
.put("window", view.rootView.javaClass.simpleName)
|
||||
.put("nodes", nodes))
|
||||
}
|
||||
return JSONObject().put("windows", windows)
|
||||
}
|
||||
|
||||
private fun describe(node: SemanticsNode, origin: IntArray): JSONObject {
|
||||
val config = node.config
|
||||
val json = JSONObject().put("id", node.id)
|
||||
config.getOrNull(SemanticsProperties.TestTag)?.let { json.put("tag", it) }
|
||||
val text = textFields(config)
|
||||
text.keys().forEach { json.put(it, text.get(it)) }
|
||||
config.getOrNull(SemanticsProperties.Role)?.let { json.put("role", it.toString()) }
|
||||
config.getOrNull(SemanticsProperties.ToggleableState)?.let { json.put("toggle", it.name) }
|
||||
config.getOrNull(SemanticsProperties.Selected)?.let { json.put("selected", it) }
|
||||
config.getOrNull(SemanticsProperties.Focused)?.let { json.put("focused", it) }
|
||||
if (config.contains(SemanticsProperties.Disabled)) json.put("disabled", true)
|
||||
if (config.contains(SemanticsActions.OnClick)) json.put("clickable", true)
|
||||
if (config.contains(SemanticsActions.SetText)) json.put("editable", true)
|
||||
val b = node.boundsInWindow
|
||||
json.put("bounds", JSONArray()
|
||||
.put(origin[0] + b.left.toInt()).put(origin[1] + b.top.toInt())
|
||||
.put(origin[0] + b.right.toInt()).put(origin[1] + b.bottom.toInt()))
|
||||
return json
|
||||
}
|
||||
|
||||
internal fun textFields(config: SemanticsConfiguration): JSONObject {
|
||||
val password = config.contains(SemanticsProperties.Password)
|
||||
val json = JSONObject()
|
||||
for ((key, value) in listOf(
|
||||
"text" to config.getOrNull(SemanticsProperties.Text)?.joinToString(" ") { it.text },
|
||||
"editableText" to config.getOrNull(SemanticsProperties.EditableText)?.text,
|
||||
"description" to config.getOrNull(SemanticsProperties.ContentDescription)?.joinToString(" "),
|
||||
"state" to config.getOrNull(SemanticsProperties.StateDescription),
|
||||
)) value?.let { json.put(key, if (password) "<redacted>" else it) }
|
||||
return json
|
||||
}
|
||||
|
||||
/**
|
||||
* The node a request names by "tag" (exact) or "match" (text or description containing it,
|
||||
* ignoring case); "index" picks among several, in tree order.
|
||||
*/
|
||||
private fun act(request: JSONObject, action: (SemanticsNode) -> Unit): JSONObject {
|
||||
val tag = request.optString("tag").takeIf { it.isNotEmpty() }
|
||||
val text = if (tag == null) request.optString("match").takeIf { it.isNotEmpty() } else null
|
||||
if (tag == null && text == null) throw AgentException("INVALID_REQUEST", "Name the node by tag or match")
|
||||
val matches = mutableListOf<Pair<SemanticsNode, IntArray>>()
|
||||
for ((view, root) in roots()) {
|
||||
val origin = IntArray(2).also { view.getLocationOnScreen(it) }
|
||||
fun visit(node: SemanticsNode) {
|
||||
val config = node.config
|
||||
val hit = if (tag != null) config.getOrNull(SemanticsProperties.TestTag) == tag
|
||||
else (config.getOrNull(SemanticsProperties.Text)?.joinToString(" ") { it.text }
|
||||
?: config.getOrNull(SemanticsProperties.ContentDescription)?.joinToString(" ") ?: "")
|
||||
.contains(text!!, ignoreCase = true)
|
||||
if (hit) matches += node to origin
|
||||
node.children.forEach(::visit)
|
||||
}
|
||||
visit(root.semanticsOwner.rootSemanticsNode)
|
||||
}
|
||||
val index = request.optInt("index", 0)
|
||||
val (node, origin) = matches.getOrNull(index)
|
||||
?: throw AgentException("NO_SUCH_NODE", "No node with ${if (tag != null) "tag '$tag'" else "text '$text'"} (${matches.size} found)")
|
||||
if (generateSequence(node) { it.parent }.any { it.config.getOrNull(SemanticsProperties.TestTag) == GATE_TAG }) {
|
||||
throw AgentException("PROTECTED_CONTROL", "Debugging tools are switched on the device only")
|
||||
}
|
||||
action(node)
|
||||
return JSONObject().put("matches", matches.size).put("node", describe(node, origin))
|
||||
}
|
||||
|
||||
private fun perform(node: SemanticsNode, key: androidx.compose.ui.semantics.SemanticsPropertyKey<androidx.compose.ui.semantics.AccessibilityAction<() -> Boolean>>) {
|
||||
val action = node.config.getOrNull(key)?.action ?: throw AgentException("NOT_CLICKABLE", "That node has no click action")
|
||||
action()
|
||||
}
|
||||
|
||||
private fun <T> onMain(block: () -> T): T {
|
||||
if (Looper.myLooper() == Looper.getMainLooper()) return block()
|
||||
val result = AtomicReference<Result<T>>()
|
||||
val done = CountDownLatch(1)
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
result.set(runCatching(block))
|
||||
done.countDown()
|
||||
}
|
||||
if (!done.await(TIMEOUT_MS, TimeUnit.MILLISECONDS)) throw AgentException("TIMEOUT", "The UI thread did not answer")
|
||||
return result.get().getOrThrow()
|
||||
}
|
||||
}
|
||||
@@ -28,9 +28,7 @@ public class PulseAudioComponent extends SessionPart {
|
||||
/** Where the guest reaches the daemon; the session exports PULSE_SERVER=unix:<this>. */
|
||||
public static final String SOCKET_NAME = "PS0";
|
||||
/** Identifies the bundled pulseaudio.tzst; a change here re-unpacks it over what a device has. */
|
||||
private static final String BUNDLE_STAMP = "2026-10-08-pa13-directaudio-native";
|
||||
|
||||
|
||||
private static final String BUNDLE_STAMP = "2026-09-23-pa13-suspend-r5";
|
||||
|
||||
private final File workingDir;
|
||||
/** Where the daemon's own output is kept for this session, or null for logcat only. */
|
||||
@@ -42,6 +40,8 @@ public class PulseAudioComponent extends SessionPart {
|
||||
* turns that one stream into a source the client can see, named DirectAudioMic.
|
||||
*/
|
||||
private final String micFifoPath;
|
||||
/** The DirectAudio relay's socket when the client's output should go through it, else null. */
|
||||
private String relaySocketPath;
|
||||
private volatile int pid = -1;
|
||||
|
||||
public PulseAudioComponent(Context context) {
|
||||
@@ -54,6 +54,15 @@ public class PulseAudioComponent extends SessionPart {
|
||||
this.micFifoPath = micFifoPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route the daemon's output through the DirectAudio relay at this socket instead of an AAudio
|
||||
* stream of its own. The relay owns the stream outside proot, with its adaptive buffer; the
|
||||
* daemon only fills a shared ring. Set before {@link #start()}; the relay may start later.
|
||||
*/
|
||||
public void setRelaySocket(String path) {
|
||||
this.relaySocketPath = path;
|
||||
}
|
||||
|
||||
/** CRC-32 of the bundled pulseaudio.tzst, or "?" if it cannot be read. About 600 KB; cheap. */
|
||||
private String bundleChecksum() {
|
||||
java.util.zip.CRC32 crc = new java.util.zip.CRC32();
|
||||
@@ -97,7 +106,6 @@ public class PulseAudioComponent extends SessionPart {
|
||||
String have = FileUtils.readString(stamp);
|
||||
String want = BUNDLE_STAMP + "-" + bundleChecksum();
|
||||
if (!new File(modulesDir, "arm64/module-aaudio-sink.so").isFile()
|
||||
|| !new File(modulesDir, "arm64/module-directaudio-native-sink.so").isFile()
|
||||
|| !new File(workingDir, "pactl").isFile()
|
||||
|| have == null || !want.equals(have.trim())) {
|
||||
Log.i(TAG, "unpacking pulseaudio.tzst (" + want + "; had " + have + ")");
|
||||
@@ -131,15 +139,14 @@ public class PulseAudioComponent extends SessionPart {
|
||||
// the sink to 0% and the session plays silence.
|
||||
// The sink's name is what the client's Audio settings show as the output device, so it
|
||||
// says which road the sound takes.
|
||||
// The client's sound: DirectAudio's own engine, in this process, one step from Android.
|
||||
// decay=0: shrinking the device buffer after a quiet spell probes below what works and that
|
||||
// probe is a click; the client plays menus. Between .nofail and .fail a module that cannot
|
||||
// open its stream does not take the daemon down; ensureClientSink() then loads the plain
|
||||
// AAudio sink in its place.
|
||||
config.add(".nofail");
|
||||
config.add("load-module module-directaudio-native-sink sink_name=DirectAudio performance_mode=1 adaptive=1 decay=0 limiter=1 watchdog=1 volume=1.0");
|
||||
config.add("set-default-sink DirectAudio");
|
||||
config.add(".fail");
|
||||
if (relaySocketPath != null && !relaySocketPath.isEmpty()) {
|
||||
config.add("load-module module-directaudio-sink sink_name=DirectAudio socket=\"" + relaySocketPath + "\" performance_mode=1 adaptive=1 volume=1.0");
|
||||
config.add("set-default-sink DirectAudio");
|
||||
} else {
|
||||
// The classic sink: the adaptive AAudio module 0.1.5 shipped, unchanged.
|
||||
config.add("load-module module-aaudio-classic-sink sink_name=AAudioSink performance_mode=1 adaptive=1 volume=1.0");
|
||||
config.add("set-default-sink AAudioSink");
|
||||
}
|
||||
if (micFifoPath != null && !micFifoPath.isEmpty()) {
|
||||
// The format is the helper's, fixed at s16le/48000/mono: it resamples when the device
|
||||
// grants another input rate, so the daemon is never told a rate the bytes are not.
|
||||
@@ -171,84 +178,6 @@ public class PulseAudioComponent extends SessionPart {
|
||||
Log.i(TAG, line);
|
||||
if (out != null) synchronized (out) { out.println(line); out.flush(); }
|
||||
});
|
||||
Thread guard = new Thread(() -> ensureClientSink(out), "pulse-sink-guard");
|
||||
guard.setDaemon(true);
|
||||
guard.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* The fallback: once the daemon answers, if the DirectAudio sink is not
|
||||
* there (the module failed to load or to open its stream), load the plain AAudio sink so the
|
||||
* client is never left without an output. Says so in the audio log either way.
|
||||
*/
|
||||
private void ensureClientSink(java.io.PrintWriter out) {
|
||||
try {
|
||||
long deadline = System.currentTimeMillis() + 15000L;
|
||||
String sinks = null;
|
||||
while (System.currentTimeMillis() < deadline && pid > 1) {
|
||||
if (socket().exists() && (sinks = pactl("list", "short", "sinks")) != null) break;
|
||||
try { Thread.sleep(250L); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; }
|
||||
}
|
||||
String line;
|
||||
if (sinks == null) {
|
||||
line = "== client sink: the daemon did not answer in 15 s; no fallback attempted";
|
||||
} else if (sinks.contains("\tDirectAudio\t") || sinks.startsWith("DirectAudio\t") || sinks.contains("\tDirectAudio\n") || sinks.matches("(?s).*\\bDirectAudio\\b.*")) {
|
||||
line = "== client sink: DirectAudio (module-directaudio-native-sink)";
|
||||
} else {
|
||||
boolean loaded = pactl("load-module", "module-aaudio-sink", "sink_name=AAudioSink", "performance_mode=1", "adaptive=1", "volume=1.0") != null
|
||||
&& pactl("set-default-sink", "AAudioSink") != null;
|
||||
line = loaded ? "== client sink: DirectAudio did not come up; FALLBACK to the plain AAudio sink (AAudioSink)"
|
||||
: "== client sink: DirectAudio did not come up and the AAudio fallback failed too - no client sound";
|
||||
Log.w(TAG, line);
|
||||
}
|
||||
Log.i(TAG, line);
|
||||
if (out != null) synchronized (out) { out.println(line); out.flush(); }
|
||||
} catch (Throwable t) {
|
||||
Log.w(TAG, "ensureClientSink failed", t);
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs pactl against this daemon; its stdout, or null when it failed or timed out. */
|
||||
private String pactl(String... args) {
|
||||
File pactl = new File(workingDir, "pactl");
|
||||
if (!pactl.isFile() || pid <= 1) return null;
|
||||
java.lang.Process process = null;
|
||||
try {
|
||||
File modules = new File(workingDir, "modules/arm64");
|
||||
ArrayList<String> cmd = new ArrayList<>();
|
||||
cmd.add(pactl.getAbsolutePath());
|
||||
java.util.Collections.addAll(cmd, args);
|
||||
ProcessBuilder builder = new ProcessBuilder(cmd);
|
||||
builder.directory(workingDir);
|
||||
builder.redirectErrorStream(true);
|
||||
builder.environment().put("LD_LIBRARY_PATH", "/system/lib64:" + modules + ":" + workingDir);
|
||||
builder.environment().put("HOME", workingDir.getAbsolutePath());
|
||||
builder.environment().put("TMPDIR", workingDir.getAbsolutePath());
|
||||
builder.environment().put("PULSE_SERVER", "unix:" + socket().getAbsolutePath());
|
||||
process = builder.start();
|
||||
String output;
|
||||
try (InputStream in = process.getInputStream();
|
||||
java.io.ByteArrayOutputStream outStream = new java.io.ByteArrayOutputStream()) {
|
||||
byte[] buf = new byte[4096];
|
||||
int n;
|
||||
while ((n = in.read(buf)) != -1) {
|
||||
outStream.write(buf, 0, n);
|
||||
}
|
||||
output = outStream.toString(java.nio.charset.StandardCharsets.UTF_8.name());
|
||||
}
|
||||
if (!process.waitFor(5, TimeUnit.SECONDS)) {
|
||||
process.destroyForcibly();
|
||||
return null;
|
||||
}
|
||||
return process.exitValue() == 0 ? output : null;
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return null;
|
||||
} catch (Throwable t) {
|
||||
return null;
|
||||
} finally {
|
||||
if (process != null && process.isAlive()) process.destroyForcibly();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
package com.droiddeck.launcher.core
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.os.LocaleList
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* The language the app shows itself in.
|
||||
*
|
||||
* Nothing chosen (the default) means the system's: Android's own language, or the per-app language
|
||||
* Android 13+ lets a user set for DroidDeck in its settings, exactly as Android resolves it. Picking
|
||||
* one in Setup overrides that until the user picks "System default" again. Each activity and service
|
||||
* applies the choice to its own context ([wrap], [applyTo]), so it works on every Android version
|
||||
* the app runs on rather than only where the platform's per-app language API exists.
|
||||
*
|
||||
* Steam follows the same choice: [SteamLanguage] turns [effective] into the client's language.
|
||||
*/
|
||||
object AppLanguage {
|
||||
/** The stored value meaning "follow the system". */
|
||||
const val SYSTEM = ""
|
||||
|
||||
/** The languages the app ships, as BCP 47 tags, in the order the picker lists them. */
|
||||
val supported: List<String> = listOf("en", "es", "fr", "ru", "ja", "ko", "zh-CN", "zh-TW", "zh-HK")
|
||||
|
||||
private const val PREFS = "language"
|
||||
private const val KEY = "app_language"
|
||||
|
||||
/** The locale Android gave the process before any choice here was applied. */
|
||||
@Volatile private var systemLocale: Locale? = null
|
||||
|
||||
/** The stored choice: one of [supported], or [SYSTEM]. */
|
||||
fun chosen(context: Context): String =
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY, SYSTEM)
|
||||
?.takeIf { it in supported } ?: SYSTEM
|
||||
|
||||
fun setChosen(context: Context, tag: String) {
|
||||
val value = if (tag in supported) tag else SYSTEM
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putString(KEY, value).commit()
|
||||
val locale = value.takeIf { it.isNotEmpty() }?.let(Locale::forLanguageTag) ?: system(context)
|
||||
Locale.setDefault(locale)
|
||||
// Code that reads strings through the application context sees the new language too.
|
||||
val app = context.applicationContext
|
||||
@Suppress("DEPRECATION")
|
||||
app.resources.updateConfiguration(
|
||||
Configuration(app.resources.configuration).apply { setLocales(LocaleList(locale)) },
|
||||
app.resources.displayMetrics,
|
||||
)
|
||||
}
|
||||
|
||||
/** The locale Android chose for the app, ignoring the choice made here. */
|
||||
fun system(context: Context): Locale =
|
||||
systemLocale ?: context.resources.configuration.locales[0] ?: Locale.getDefault()
|
||||
|
||||
/** The locale the app is shown in: the chosen language, or else the system's. */
|
||||
fun effective(context: Context): Locale =
|
||||
chosen(context).takeIf { it.isNotEmpty() }?.let(Locale::forLanguageTag) ?: system(context)
|
||||
|
||||
/** Remembers the system's locale; call with the untouched base context, before [wrap]. */
|
||||
fun noteSystem(base: Context) {
|
||||
if (systemLocale == null) systemLocale = base.resources.configuration.locales[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* [base], showing the chosen language: for an Application's or a Service's attachBaseContext.
|
||||
* Only the locale is overridden, so screen size, density and night mode keep following the
|
||||
* device as they change.
|
||||
*/
|
||||
fun wrap(base: Context): Context {
|
||||
noteSystem(base)
|
||||
val tag = chosen(base).ifEmpty { return base }
|
||||
val locale = Locale.forLanguageTag(tag)
|
||||
Locale.setDefault(locale)
|
||||
return base.createConfigurationContext(override(locale))
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the chosen language to an activity; call from its attachBaseContext, after super.
|
||||
* applyOverrideConfiguration keeps the locale through rotation and window resizes, which an
|
||||
* activity here handles itself rather than being recreated.
|
||||
*/
|
||||
fun applyTo(activity: Activity, base: Context) {
|
||||
noteSystem(base)
|
||||
val tag = chosen(base).ifEmpty { return }
|
||||
val locale = Locale.forLanguageTag(tag)
|
||||
Locale.setDefault(locale)
|
||||
activity.applyOverrideConfiguration(override(locale))
|
||||
}
|
||||
|
||||
/** Follows a change of the system's language while the process lives (Application.onConfigurationChanged). */
|
||||
fun systemChanged(config: Configuration) {
|
||||
config.locales[0]?.let { systemLocale = it }
|
||||
}
|
||||
|
||||
// A new Configuration's fontScale is 1, which as an override would undo the user's font size;
|
||||
// 0 leaves it undefined, so only the locale is changed.
|
||||
private fun override(locale: Locale) = Configuration().apply { fontScale = 0f; setLocales(LocaleList(locale)) }
|
||||
|
||||
/** Which of the app's languages [locale] reads as, or null when the app does not ship it. */
|
||||
fun supportedTag(locale: Locale): String? = when (locale.language) {
|
||||
"en", "es", "fr", "ru", "ja", "ko" -> locale.language
|
||||
"zh" -> when {
|
||||
locale.script == "Hans" -> "zh-CN"
|
||||
locale.country == "HK" || locale.country == "MO" -> "zh-HK"
|
||||
locale.script == "Hant" || locale.country == "TW" -> "zh-TW"
|
||||
else -> "zh-CN"
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** [locale]'s language named in itself, without its region unless the app tells them apart. */
|
||||
fun displayName(locale: Locale): String =
|
||||
supportedTag(locale)?.let(::nativeName) ?: locale.getDisplayLanguage(locale).replaceFirstChar { it.titlecase(locale) }
|
||||
|
||||
/** A language's name in that language, for the picker: what a reader of it looks for. */
|
||||
fun nativeName(tag: String): String = when (tag) {
|
||||
"en" -> "English"
|
||||
"es" -> "Español"
|
||||
"fr" -> "Français"
|
||||
"ru" -> "Русский"
|
||||
"ja" -> "日本語"
|
||||
"ko" -> "한국어"
|
||||
"zh-CN" -> "简体中文"
|
||||
"zh-TW" -> "繁體中文(台灣)"
|
||||
"zh-HK" -> "繁體中文(香港)"
|
||||
else -> Locale.forLanguageTag(tag).let { it.getDisplayName(it) }
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
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,7 +12,6 @@ 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
|
||||
@@ -53,7 +52,7 @@ object DeviceReport {
|
||||
b.append("DroidDeck session report\n")
|
||||
b.append("========================\n")
|
||||
k("Written", SimpleDateFormat("yyyy-MM-dd HH:mm:ss zzz", Locale.US).format(Date()))
|
||||
k("Session mode", when (mode) { SessionService.MODE_DESKTOP -> "desktop (KDE Plasma)"; SessionService.MODE_RUN -> "a program under gamescope"; else -> "Steam client (gamescope)" })
|
||||
k("Session mode", when (mode) { SessionService.MODE_DESKTOP -> "desktop (labwc/LXQt)"; SessionService.MODE_RUN -> "a program under gamescope"; else -> "Steam client (gamescope)" })
|
||||
|
||||
h("App")
|
||||
runCatching {
|
||||
@@ -108,12 +107,9 @@ 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))
|
||||
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("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")
|
||||
k("Foldable", context.packageManager.hasSystemFeature("android.hardware.sensor.hinge_angle"))
|
||||
|
||||
h("Drivers")
|
||||
@@ -152,20 +148,17 @@ object DeviceReport {
|
||||
k("Zink lazy descriptors", SessionPrefs.zinkLazy(context))
|
||||
k("Threaded GL (glthread)", SessionPrefs.glThread(context))
|
||||
k("No GL error checks", SessionPrefs.noGlError(context))
|
||||
k("Steam Deck mode", SessionPrefs.steamDeckMode(context))
|
||||
k("Steam controller", SessionPrefs.steamController(context))
|
||||
k("Upscaler", (SessionPrefs.upscalerChoices.firstOrNull { it.first == SessionPrefs.upscaler(context) }?.second ?: "Off") +
|
||||
" (sharpness ${SessionPrefs.upscaleSharpness(context)}%)")
|
||||
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))
|
||||
k("On-screen controls", SessionPrefs.oscMode(context))
|
||||
k("Touch mode", SessionPrefs.touchMode(context))
|
||||
@@ -179,16 +172,10 @@ object DeviceReport {
|
||||
h("Android process limits")
|
||||
k("Phantom proc monitor", PhantomProcessLimit.reportValue(PhantomProcessLimit.read(context)))
|
||||
|
||||
h("Device switch files")
|
||||
SessionService.envSwitchFile(context)?.takeIf { it.isFile }?.let { f ->
|
||||
k("droiddeck-env", FileUtils.readString(f)?.trim()?.replace('\n', ' ')?.ifEmpty { "(present, empty)" } ?: "(present)")
|
||||
}
|
||||
if (File(Environment.getExternalStorageDirectory(), "Download/droiddeck-env").isFile) {
|
||||
k("Download/droiddeck-env", "(ignored - it moved to Android/data/${context.packageName}/files)")
|
||||
}
|
||||
for (name in listOf("droiddeck-tu-debug", "droiddeck-driver",
|
||||
h("Device switch files in Download")
|
||||
for (name in listOf("droiddeck-env", "droiddeck-tu-debug", "droiddeck-driver",
|
||||
"droiddeck-osc", "droiddeck-no-pad",
|
||||
"droiddeck-no-hud")) {
|
||||
"droiddeck-no-hud", "droiddeck-wlr-renderer")) {
|
||||
val f = File(Environment.getExternalStorageDirectory(), "Download/$name")
|
||||
if (f.isFile) k(name, FileUtils.readString(f)?.trim()?.replace('\n', ' ')?.ifEmpty { "(present, empty)" } ?: "(present)")
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package com.droiddeck.launcher.core
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import com.droiddeck.launcher.R
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
@@ -19,12 +17,8 @@ object DeviceSupport {
|
||||
File("/sys/class/kgsl/kgsl-3d0").exists() || File("/vendor/lib64/hw/vulkan.adreno.so").exists()
|
||||
|
||||
/** The chip as the device names it, for the card that explains the refusal. */
|
||||
fun gpuName(context: Context): String {
|
||||
fun gpuName(): String {
|
||||
val soc = if (Build.VERSION.SDK_INT >= 31) Build.SOC_MODEL.takeIf { it.isNotBlank() && it != Build.UNKNOWN } else null
|
||||
return soc?.let { "$it (${Build.HARDWARE})" } ?: Build.HARDWARE.ifBlank { context.getString(R.string.gpu_this_gpu) }
|
||||
return soc?.let { "$it (${Build.HARDWARE})" } ?: Build.HARDWARE.ifBlank { "this GPU" }
|
||||
}
|
||||
|
||||
/** False on the Odin 3 (CQ8725S): a low-latency Wi-Fi lock makes its WLAN firmware hang, and
|
||||
* that firmware reboots the whole device instead of restarting Wi-Fi. */
|
||||
fun lowLatencyWifiSafe(): Boolean = Build.VERSION.SDK_INT < 31 || Build.SOC_MODEL != "CQ8725S"
|
||||
}
|
||||
@@ -14,7 +14,7 @@ object GameEnvironment {
|
||||
|
||||
private val namePattern = Regex("[A-Za-z_][A-Za-z0-9_]*")
|
||||
private val unused = setOf(
|
||||
"WINENTSYNC", "WINE_FAST_YIELD", "WINE_DO_NOT_CREATE_DXGI_DEVICE_MANAGER",
|
||||
"WINEESYNC", "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,25 +25,14 @@ 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, forceSsbs: Boolean = false): Map<String, String?> = linkedMapOf<String, String?>(
|
||||
fun defaults(preset: String): 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.
|
||||
// Older NativeAOT runtimes require bare hex digits. CoreCLR accepts this form too.
|
||||
"DOTNET_GCRegionRange" to "800000000",
|
||||
// .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")
|
||||
}
|
||||
|
||||
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()
|
||||
fun effective(config: Config, preset: String, scope: String): Map<String, String?> =
|
||||
defaults(preset) + config.shared + if (scope.isEmpty()) emptyMap() else config.games[scope].orEmpty()
|
||||
}
|
||||
@@ -15,10 +15,6 @@ 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),
|
||||
|
||||
@@ -40,7 +40,7 @@ object HostProcess {
|
||||
if (onLine == null) builder.redirectOutput(File("/dev/null"))
|
||||
val process = builder.start()
|
||||
val pid = pidOf(process)
|
||||
Log.i(TAG, "started pid $pid: ${argv.first()} (${argv.size - 1} arguments)")
|
||||
Log.i(TAG, "started pid $pid: $command")
|
||||
if (onLine != null) {
|
||||
Thread({
|
||||
try {
|
||||
@@ -59,7 +59,7 @@ object HostProcess {
|
||||
}
|
||||
pid
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "could not start: ${argv.first()}", e)
|
||||
Log.e(TAG, "could not start: $command", e)
|
||||
-1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/droiddeck-net) and every address on the same /64 - privacy
|
||||
* the link the session was given (etc/bannerlator-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.
|
||||
@@ -60,45 +60,18 @@ object LogRedactor {
|
||||
private val IPV4 = Regex("(?<![0-9.])(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})(?![0-9.])")
|
||||
/** An IPv6 literal: "::" somewhere, or all eight groups (a clock's 10:03:05 is neither). */
|
||||
private val IPV6 = Regex("(?<![0-9A-Za-z:])[0-9A-Fa-f]{0,4}(?::[0-9A-Fa-f]{0,4}){2,7}(?:%[A-Za-z0-9_.]+)?(?![0-9A-Za-z:])")
|
||||
private val MAC = Regex("(?i)(?<![0-9a-z:-])[0-9a-f]{2}([:-])(?:[0-9a-f]{2}\\1){4}[0-9a-f]{2}(?![0-9a-z:-])")
|
||||
private val MAC_FIELD = Regex(
|
||||
"(?i)(\\bmac(?:[_ ]?address)?[\"']?\\s*[:=]\\s*[\"']?)[0-9a-f]{2}([:-])" +
|
||||
"(?:[0-9a-f]{2}\\2){4}[0-9a-f]{2}(?![0-9a-z:-])"
|
||||
)
|
||||
private val DEVICE_IDENTIFIER = Regex(
|
||||
"(?i)(\\b(?:android[_ ]?id|(?:device|controller)[_ ]?serial|serial(?:[_ ]?number)?|build\\.serial)" +
|
||||
"[\"']?\\s*[:=]\\s*)(\"[^\"\\r\\n<>]+\"|'[^'\\r\\n<>]+'|[^\\s\"'<>&;,\\[\\]{}]+)"
|
||||
)
|
||||
|
||||
/**
|
||||
* The account a Steam UI login line names ("Login: OnLoginStateChange <account> 2 1 0 0"): a
|
||||
* user who signs in with a plain account name, not an email, had it in every log. The single
|
||||
* space then a non-space keeps "OnLoginStateChange 0 1 0 0" (no account yet) as it is.
|
||||
*/
|
||||
private val LOGIN_STATE = Regex("(?i)(OnLoginStateChange:? )([^\\s<]\\S*)")
|
||||
private val LOGIN_STATE = Regex("(OnLoginStateChange )(\\S+)")
|
||||
private val LOGIN_USERS = Regex("(OnLoginUsersChanged )(\\S.*)$")
|
||||
|
||||
/**
|
||||
* An account name as a field: `AccountName`, `account_name`, `username` (`"AccountName"
|
||||
* "someone"` in a VDF, `account_name=...`, `"username": "..."`) and `login` - the last only
|
||||
* as `login=`, a quoted key or a VDF pair, so "Login: OnLoginStateChange ..." keeps its label.
|
||||
* A quoted value is blanked whole; one already blanked is left as it is.
|
||||
*/
|
||||
private const val ACCOUNT_VALUE = "(\"[^\"<\\r\\n]+\"|'[^'<\\r\\n]+'|[^\\s\"'<>&;,}]+)"
|
||||
private val ACCOUNT_KV = Regex(
|
||||
"(?i)(?<![A-Za-z0-9_])(account[_-]?name|user[_-]?name|epicusername)" +
|
||||
"([\"']?\\s*[=:]\\s*|[\"']\\s+)" + ACCOUNT_VALUE
|
||||
)
|
||||
private val LOGIN_KV = Regex(
|
||||
"(?i)(?<![A-Za-z0-9_])(login)(\\s*=\\s*|[\"']\\s*[=:]\\s*|[\"']\\s+)" + ACCOUNT_VALUE
|
||||
)
|
||||
|
||||
/** A learned pattern and the text (lowercase) a line must hold for it to match; null when none can be said. */
|
||||
private class Learned(val regex: Regex, val needs: String?)
|
||||
|
||||
/** The device's Steam accounts and persona names as patterns (see [learnAccounts]). */
|
||||
@Volatile
|
||||
private var accounts: List<Learned> = emptyList()
|
||||
private var accounts: List<Regex> = emptyList()
|
||||
|
||||
/**
|
||||
* Learns every Steam account on the device from the client's loginusers.vdf - its AccountName
|
||||
@@ -106,13 +79,12 @@ object LogRedactor {
|
||||
* Names under three characters are skipped: blanking every "a" would ruin a log.
|
||||
*/
|
||||
fun learnAccounts(loginUsers: java.io.File) {
|
||||
val found = ArrayList<Learned>()
|
||||
val found = ArrayList<Regex>()
|
||||
try {
|
||||
if (loginUsers.isFile) {
|
||||
val kv = Regex("\"(AccountName|PersonaName)\"\\s+\"([^\"]*)\"")
|
||||
kv.findAll(loginUsers.readText()).map { it.groupValues[2].trim() }.filter { it.length >= 3 }.distinct().forEach { name ->
|
||||
val needs = name.lowercase().takeIf { name.all { it.code < 0x80 } }
|
||||
found += Learned(Regex("(?i)(?<![A-Za-z0-9_])" + Regex.escape(name) + "(?![A-Za-z0-9_])"), needs)
|
||||
found += Regex("(?i)(?<![A-Za-z0-9_])" + Regex.escape(name) + "(?![A-Za-z0-9_])")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
@@ -123,13 +95,13 @@ 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/droiddeck-net"))
|
||||
learnOwnAddresses(java.io.File(root, "etc/bannerlator-net"))
|
||||
learnAccounts(java.io.File(root, "root/.local/share/Steam/config/loginusers.vdf"))
|
||||
}
|
||||
|
||||
/** This device's public addresses as patterns (see [learnOwnAddresses]); empty until learned. */
|
||||
@Volatile
|
||||
private var own: List<Learned> = emptyList()
|
||||
private var own: List<Regex> = emptyList()
|
||||
|
||||
/**
|
||||
* Learns the device's addresses from the link file the app writes for the session
|
||||
@@ -137,7 +109,7 @@ object LogRedactor {
|
||||
* private, link-local and loopback addresses identify nobody and are left out.
|
||||
*/
|
||||
fun learnOwnAddresses(linkFile: java.io.File) {
|
||||
val found = ArrayList<Learned>()
|
||||
val found = ArrayList<Regex>()
|
||||
try {
|
||||
if (linkFile.isFile) linkFile.forEachLine { line ->
|
||||
val addr = line.trim().takeIf { it.startsWith("addr ") }?.split(Regex("\\s+"))?.getOrNull(1) ?: return@forEachLine
|
||||
@@ -146,12 +118,9 @@ object LogRedactor {
|
||||
val groups = expand6(addr)?.take(4) ?: return@forEachLine
|
||||
// Any spelling of an address in this /64: leading zeros dropped, :: anywhere after.
|
||||
val prefix = groups.joinToString(":") { g -> "0{0,3}" + Regex.escape(g.trimStart('0').ifEmpty { "0" }) }
|
||||
found += Learned(
|
||||
Regex("(?i)(?<![0-9A-Fa-f:])$prefix(?::[0-9A-Fa-f]{0,4}){1,4}(?:%[A-Za-z0-9_.]+)?"),
|
||||
groups[0].trimStart('0').ifEmpty { "0" },
|
||||
)
|
||||
found += Regex("(?i)(?<![0-9A-Fa-f:])$prefix(?::[0-9A-Fa-f]{0,4}){1,4}(?:%[A-Za-z0-9_.]+)?")
|
||||
}
|
||||
"public IPv4" -> found += Learned(Regex("(?<![0-9.])" + Regex.escape(addr) + "(?![0-9.])"), addr)
|
||||
"public IPv4" -> found += Regex("(?<![0-9.])" + Regex.escape(addr) + "(?![0-9.])")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
@@ -221,136 +190,37 @@ 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 = 3
|
||||
|
||||
/**
|
||||
* A private IPv4 address (10/8, 172.16/12, 192.168/16, link-local 169.254/16): the home
|
||||
* network's layout, e.g. its router as the resolver. Blanked in a shared zip only; public
|
||||
* server addresses stay. Not part of a longer dotted number (a version like 10.0.19041.1).
|
||||
*/
|
||||
private val LAN_IPV4 = Regex(
|
||||
"(?<![\\d.])(?:10\\.\\d{1,3}|192\\.168|172\\.(?:1[6-9]|2\\d|3[01])|169\\.254)\\.\\d{1,3}\\.\\d{1,3}(?!\\.?\\d)"
|
||||
)
|
||||
|
||||
/** [src]'s lines, scrubbed, to [out]. */
|
||||
fun scrubTo(src: java.io.File, out: java.io.Writer) {
|
||||
src.forEachLine { line -> out.write(redact(line)); out.write("\n") }
|
||||
}
|
||||
|
||||
/**
|
||||
* The last pass for a file going into a shared zip: [redact], then the rules the stores' log
|
||||
* uses too ([SecretScrub]) - URLs without query, fragment and user:password and with any
|
||||
* token-bearing path segment blanked, token values and Authorization / Cookie headers blanked
|
||||
* - and private IPv4 addresses as `<lan-address>`. A line already clean comes out unchanged.
|
||||
*/
|
||||
fun redactForShare(line: String): String {
|
||||
if (line.isEmpty()) return line
|
||||
return try {
|
||||
val out = SecretScrub.scrub(redact(line), SecretScrub.Urls.KEEP_PATH, "<redacted:token>")
|
||||
LAN_IPV4.replace(out, "<lan-address>")
|
||||
} catch (t: Throwable) {
|
||||
"<redaction failed; line withheld>"
|
||||
}
|
||||
}
|
||||
|
||||
/** [src]'s lines through [redactForShare], to [out]: the pass every text file in a shared zip gets. */
|
||||
fun scrubForShare(src: java.io.File, out: java.io.Writer) {
|
||||
src.forEachLine { line -> out.write(redactForShare(line)); out.write("\n") }
|
||||
}
|
||||
|
||||
/** `<redacted:account>`, inside [value]'s quotes when it had them. */
|
||||
private fun quoted(value: String): String {
|
||||
val q = value.first().takeIf { it == '"' || it == '\'' }?.toString().orEmpty()
|
||||
return "$q<redacted:account>$q"
|
||||
}
|
||||
|
||||
/**
|
||||
* A line on its way through [redact]. Each rule runs only on a line holding what it cannot match
|
||||
* without - a word, enough separators, a long enough run: most of a Steam log is lines no rule
|
||||
* touches, and a session's logs went through every rule at about a megabyte a second. A line with anything but ASCII in it goes through
|
||||
* every case-insensitive rule, since Android's regex folds case more widely than lowercase().
|
||||
*/
|
||||
private class Line(var text: String) {
|
||||
private val unfolded = text.any { it.code >= 0x80 }
|
||||
private var lower: String? = null
|
||||
|
||||
fun has(vararg words: String): Boolean {
|
||||
if (unfolded) return true
|
||||
val folded = lower ?: text.lowercase().also { lower = it }
|
||||
return words.any { folded.contains(it) }
|
||||
}
|
||||
|
||||
fun set(next: String) {
|
||||
if (next != text) {
|
||||
text = next
|
||||
lower = null
|
||||
}
|
||||
}
|
||||
|
||||
fun holds(c: Char, times: Int): Boolean {
|
||||
var n = 0
|
||||
for (ch in text) if (ch == c && ++n >= times) return true
|
||||
return false
|
||||
}
|
||||
|
||||
inline fun hasRun(length: Int, inRun: (Char) -> Boolean): Boolean {
|
||||
var n = 0
|
||||
for (ch in text) if (!inRun(ch)) n = 0 else if (++n >= length) return true
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private fun isHex(c: Char) = c in '0'..'9' || c in 'a'..'f' || c in 'A'..'F'
|
||||
|
||||
/** [line] with every credential shape replaced. Null- and exception-safe by construction. */
|
||||
fun redact(line: String): String {
|
||||
if (line.isEmpty()) return line
|
||||
return try {
|
||||
val out = Line(line)
|
||||
if (out.has("mac")) out.set(MAC_FIELD.replace(out.text) { "${it.groupValues[1]}<redacted:mac>" })
|
||||
if (out.holds(':', 5) || out.holds('-', 5)) out.set(MAC.replace(out.text, "<redacted:mac>"))
|
||||
if (out.has("serial", "android")) out.set(DEVICE_IDENTIFIER.replace(out.text) {
|
||||
val value = it.groupValues[2]
|
||||
val quote = value.first().takeIf { char -> char == '\"' || char == '\'' }?.toString().orEmpty()
|
||||
"${it.groupValues[1]}${quote}<redacted:serial>${quote}"
|
||||
})
|
||||
if (out.holds('-', 4)) out.set(GUID.replace(out.text, "<redacted:guid>"))
|
||||
if (out.has("jwt")) out.set(JWT_LABELLED.replace(out.text) { "${it.groupValues[1]}<redacted:jwt>" })
|
||||
if ("ey" in out.text) out.set(JWT_BASE64.replace(out.text, "<redacted:jwt>"))
|
||||
if (out.has("token", "auth", "ticket", "sessionid", "steamloginsecure", "api", "machine", "pass", "pwd", "secret")) {
|
||||
out.set(SECRET_KV.replace(out.text) { "${it.groupValues[1]}${it.groupValues[2]}<redacted:token>" })
|
||||
}
|
||||
if (out.has("guard", "factor", "2fa")) out.set(GUARD_CODE.replace(out.text) { "${it.groupValues[1]}<redacted:code>" })
|
||||
if (out.hasRun(32, ::isHex)) out.set(WEBAPI_KEY.replace(out.text, "<redacted:key>"))
|
||||
var out = line
|
||||
out = GUID.replace(out, "<redacted:guid>")
|
||||
out = JWT_LABELLED.replace(out) { "${it.groupValues[1]}<redacted:jwt>" }
|
||||
out = JWT_BASE64.replace(out, "<redacted:jwt>")
|
||||
out = SECRET_KV.replace(out) { "${it.groupValues[1]}${it.groupValues[2]}<redacted:token>" }
|
||||
out = GUARD_CODE.replace(out) { "${it.groupValues[1]}<redacted:code>" }
|
||||
out = WEBAPI_KEY.replace(out, "<redacted:key>")
|
||||
// Mask, not delete: the last four digits let a reader correlate lines to one account.
|
||||
if ("76561" in out.text) out.set(STEAMID64.replace(out.text) { "${it.groupValues[1]}********${it.groupValues[3]}" })
|
||||
if ("[U:1:" in out.text) out.set(STEAMID3.replace(out.text) { m ->
|
||||
out = STEAMID64.replace(out) { "${it.groupValues[1]}********${it.groupValues[3]}" }
|
||||
out = STEAMID3.replace(out) { m ->
|
||||
val id = m.groupValues[1]
|
||||
"[U:1:${if (id.length > 4) "*".repeat(id.length - 4) + id.takeLast(4) else id}]"
|
||||
})
|
||||
if (out.has("external")) out.set(EXTERNAL_ADDR.replace(out.text) { "${it.groupValues[1]}<redacted:ip>" })
|
||||
for (r in own) if (r.needs == null || out.has(r.needs)) out.set(r.regex.replace(out.text, "<redacted:ip>"))
|
||||
if (out.has("onloginstatechange")) out.set(LOGIN_STATE.replace(out.text) { "${it.groupValues[1]}<redacted:account>" })
|
||||
if ("OnLoginUsersChanged " in out.text) out.set(LOGIN_USERS.replace(out.text) { "${it.groupValues[1]}<redacted:account>" })
|
||||
if (out.has("account", "user")) {
|
||||
out.set(ACCOUNT_KV.replace(out.text) { "${it.groupValues[1]}${it.groupValues[2]}${quoted(it.groupValues[3])}" })
|
||||
}
|
||||
if (out.has("login")) out.set(LOGIN_KV.replace(out.text) { "${it.groupValues[1]}${it.groupValues[2]}${quoted(it.groupValues[3])}" })
|
||||
for (r in accounts) if (r.needs == null || out.has(r.needs)) out.set(r.regex.replace(out.text, "<redacted:account>"))
|
||||
if ('@' in out.text) out.set(EMAIL.replace(out.text, "<redacted:email>"))
|
||||
if (out.has("jwt", "token", "ticket", "sessionid", "steamloginsecure", "machineauth")) {
|
||||
out.set(RESIDUAL.replace(out.text) { "${it.groupValues[1]}=<redacted:token>" })
|
||||
}
|
||||
if (out.hasRun(88) { it in 'a'..'z' || it in 'A'..'Z' || it in '0'..'9' || it == '_' || it == '-' }) {
|
||||
out.set(LONG_TOKEN.replace(out.text, "<redacted:token>"))
|
||||
}
|
||||
out.text
|
||||
out = EXTERNAL_ADDR.replace(out) { "${it.groupValues[1]}<redacted:ip>" }
|
||||
for (r in own) out = r.replace(out, "<redacted:ip>")
|
||||
out = LOGIN_STATE.replace(out) { "${it.groupValues[1]}<redacted:account>" }
|
||||
out = LOGIN_USERS.replace(out) { "${it.groupValues[1]}<redacted:account>" }
|
||||
for (r in accounts) out = r.replace(out, "<redacted:account>")
|
||||
out = EMAIL.replace(out, "<redacted:email>")
|
||||
out = RESIDUAL.replace(out) { "${it.groupValues[1]}=<redacted:token>" }
|
||||
out = LONG_TOKEN.replace(out, "<redacted:token>")
|
||||
out
|
||||
} catch (t: Throwable) {
|
||||
// A log line is never worth crashing a session over, but an unscrubbed one must not
|
||||
// reach the file either.
|
||||
|
||||
@@ -14,8 +14,10 @@ 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'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`.
|
||||
* 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.
|
||||
*
|
||||
* **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.
|
||||
@@ -91,14 +93,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/droiddeck-net")
|
||||
val netdev = File(LinuxRuntime.rootDir(context), "etc/bannerlator-net")
|
||||
if (netdev.isFile) {
|
||||
append("\netc/droiddeck-net (the link as the session sees it):\n")
|
||||
append("\netc/bannerlator-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("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")
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package com.droiddeck.launcher.core
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import com.droiddeck.launcher.R
|
||||
|
||||
/** Android's phantom process monitor can kill the many child processes used by a Steam session. */
|
||||
enum class PhantomProcessStatus {
|
||||
@@ -99,30 +98,29 @@ object PhantomProcessLimit {
|
||||
fun blocksSteam(status: PhantomProcessStatus): Boolean =
|
||||
status != PhantomProcessStatus.NOT_APPLICABLE && status != PhantomProcessStatus.DISABLED
|
||||
|
||||
fun title(context: Context, status: PhantomProcessStatus): String = context.getString(when (status) {
|
||||
PhantomProcessStatus.ENABLED -> R.string.phantom_title_on
|
||||
PhantomProcessStatus.UNSET -> R.string.phantom_title_unset
|
||||
PhantomProcessStatus.UNREADABLE -> R.string.phantom_title_unreadable
|
||||
PhantomProcessStatus.DISABLED -> R.string.phantom_title_off
|
||||
PhantomProcessStatus.NOT_APPLICABLE -> R.string.phantom_title_not_required
|
||||
})
|
||||
|
||||
fun instructions(context: Context, status: PhantomProcessStatus): String = when (status) {
|
||||
PhantomProcessStatus.ENABLED, PhantomProcessStatus.UNSET, PhantomProcessStatus.UNREADABLE ->
|
||||
context.getString(R.string.phantom_instructions_fix, fixSentence(context))
|
||||
PhantomProcessStatus.DISABLED -> context.getString(R.string.phantom_instructions_off)
|
||||
PhantomProcessStatus.NOT_APPLICABLE -> context.getString(R.string.phantom_instructions_not_applicable)
|
||||
fun title(status: PhantomProcessStatus): String = when (status) {
|
||||
PhantomProcessStatus.ENABLED -> "Child-process limit is on"
|
||||
PhantomProcessStatus.UNSET -> "Child-process limit is unset"
|
||||
PhantomProcessStatus.UNREADABLE -> "Child-process limit could not be checked"
|
||||
PhantomProcessStatus.DISABLED -> "Child-process limit is off"
|
||||
PhantomProcessStatus.NOT_APPLICABLE -> "Child-process limit not required"
|
||||
}
|
||||
|
||||
fun gateInstructions(context: Context, status: PhantomProcessStatus): String = when (status) {
|
||||
PhantomProcessStatus.ENABLED, PhantomProcessStatus.UNSET, PhantomProcessStatus.UNREADABLE -> fixSentence(context)
|
||||
else -> instructions(context, status)
|
||||
fun instructions(status: PhantomProcessStatus): String = when (status) {
|
||||
PhantomProcessStatus.ENABLED, PhantomProcessStatus.UNSET, PhantomProcessStatus.UNREADABLE -> fixSentence() + " If that is not possible, connect this device to a computer with ADB and run the command below. Return here and check again."
|
||||
PhantomProcessStatus.DISABLED -> "Steam sessions can start."
|
||||
PhantomProcessStatus.NOT_APPLICABLE -> "This Android version does not use this limit."
|
||||
}
|
||||
|
||||
fun fixSentence(context: Context, sdk: Int = Build.VERSION.SDK_INT): String =
|
||||
context.getString(if (hasDeveloperToggle(sdk)) R.string.phantom_fix_toggle else R.string.phantom_fix_no_toggle)
|
||||
fun gateInstructions(status: PhantomProcessStatus): String = when (status) {
|
||||
PhantomProcessStatus.ENABLED, PhantomProcessStatus.UNSET, PhantomProcessStatus.UNREADABLE -> fixSentence()
|
||||
else -> instructions(status)
|
||||
}
|
||||
|
||||
fun fixSentence(sdk: Int = Build.VERSION.SDK_INT): String =
|
||||
if (hasDeveloperToggle(sdk)) "Turn on “Disable child process restrictions” in Developer options, and keep Developer options on afterwards."
|
||||
else "This Android version has no switch for it. Use Wireless debugging to change it from this device."
|
||||
|
||||
/** English: for the device report. */
|
||||
fun reportValue(status: PhantomProcessStatus): String = when (status) {
|
||||
PhantomProcessStatus.DISABLED -> "disabled (good - the OS will not kill the session's children)"
|
||||
PhantomProcessStatus.ENABLED -> "ENABLED (the OS may kill the session with no log; turn off Restrict child processes)"
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
package com.droiddeck.launcher.core
|
||||
|
||||
/**
|
||||
* The one set of rules that keeps URLs and tokens out of anything the app writes or shares: the
|
||||
* stores' log ([com.droiddeck.launcher.stores.StoreLog.redactLine]) and the last pass over every
|
||||
* text file in a shared log zip ([LogRedactor.redactForShare]).
|
||||
*
|
||||
* - Every URL loses its query, fragment and `user:password@`. [Urls.SHORT] then keeps only the host
|
||||
* and a plain first path segment (the stores' lines, where a GOG secure link carries its token in
|
||||
* the path); [Urls.KEEP_PATH] keeps the path but blanks any segment that carries a token.
|
||||
* - Outside URLs, the values of token-like keys (`token`, `__token__`, `f_token`, `hdnts`, access /
|
||||
* refresh / id tokens; exchange and authorization codes of four characters or more, and a bare
|
||||
* `code=` where it is one: in a query string or on a line with OAuth keys such as `client_id`,
|
||||
* `redirect_uri` or `state`, never an 8-hex exception code like Proton's `code=c0000005`) and of
|
||||
* Authorization / Cookie headers are blanked with [mark].
|
||||
*
|
||||
* A line already scrubbed comes out unchanged.
|
||||
*/
|
||||
object SecretScrub {
|
||||
enum class Urls { SHORT, KEEP_PATH }
|
||||
|
||||
private val URL = Regex("""[A-Za-z][A-Za-z0-9+.\-]*://[^\s"'<>]+""")
|
||||
private val SAFE_SEGMENT = Regex("""[A-Za-z0-9._\-]{1,40}""")
|
||||
private val TOKEN_SEGMENT = Regex("""(?i)(token|hdnts|hmac|signature|sig|exp)=""")
|
||||
private val TOKEN = Regex("""(?i)(?<![A-Za-z0-9_])(__token__|f_token|hdnts|access_token|refresh_token|id_token|token)(["']?\s*[=:]\s*["']?)[^\s&;,"'<>}]+""")
|
||||
/** Codes that are only ever OAuth grants. */
|
||||
private val GRANT_CODE = Regex("""(?i)(?<![A-Za-z0-9_])(exchange_code|authorization_?code)(["']?\s*=\s*["']?)[^\s&;,"'<>}]{4,}""")
|
||||
/** A bare `code=`: a grant only right after `?` / `&` (a query string) or beside OAuth keys ([OAUTH_CONTEXT]). */
|
||||
private val CODE = Regex("""(?i)(?<![A-Za-z0-9_])(code)(["']?\s*=\s*["']?)([^\s&;,"'<>}]{4,})""")
|
||||
private val OAUTH_CONTEXT = Regex("""(?i)(?<![A-Za-z0-9_])(client_id|client_secret|redirect_uri|state|grant_type|response_type|access_token|refresh_token|id_token)["']?\s*[=:]""")
|
||||
/** A Windows exception or status code (`code=c0000005`, `code=0x80000003`): never a grant. */
|
||||
private val STATUS_CODE = Regex("""(?i)(0x)?[0-9a-f]{8}""")
|
||||
private val HEADER = Regex("""(?i)\b(authorization|cookie|set-cookie)(\s*[:=]\s*).+""")
|
||||
|
||||
fun scrub(text: String, urls: Urls, mark: String): String {
|
||||
var out = URL.replace(text) { m -> if (urls == Urls.SHORT) short(m.value) else keepPath(m.value, mark) }
|
||||
out = TOKEN.replace(out) { m -> m.groupValues[1] + m.groupValues[2] + mark }
|
||||
out = GRANT_CODE.replace(out) { m -> m.groupValues[1] + m.groupValues[2] + mark }
|
||||
val oauth = OAUTH_CONTEXT.containsMatchIn(text)
|
||||
val src = out
|
||||
out = CODE.replace(src) { m ->
|
||||
val inQuery = m.range.first > 0 && src[m.range.first - 1] in "?&"
|
||||
val grant = !STATUS_CODE.matches(m.groupValues[3]) && (oauth || inQuery)
|
||||
if (grant) m.groupValues[1] + m.groupValues[2] + mark else m.value
|
||||
}
|
||||
out = HEADER.replace(out) { m -> m.groupValues[1] + m.groupValues[2] + mark }
|
||||
return out
|
||||
}
|
||||
|
||||
private fun split(url: String): Triple<String, String, String?> {
|
||||
val schemeEnd = url.indexOf("://")
|
||||
val rest = url.substring(schemeEnd + 3).substringBefore('#').substringBefore('?')
|
||||
val slash = rest.indexOf('/')
|
||||
val authority = (if (slash >= 0) rest.substring(0, slash) else rest).substringAfterLast('@')
|
||||
return Triple(url.substring(0, schemeEnd), authority, if (slash >= 0) rest.substring(slash + 1) else null)
|
||||
}
|
||||
|
||||
private fun short(url: String): String {
|
||||
val (scheme, authority, path) = split(url)
|
||||
if (path == null) return "$scheme://$authority"
|
||||
val first = path.substringBefore('/')
|
||||
if (first.isEmpty()) return "$scheme://$authority/"
|
||||
val shown = if (SAFE_SEGMENT.matches(first)) first else "…"
|
||||
return "$scheme://$authority/$shown" + if (path.length > first.length && shown != "…") "/…" else ""
|
||||
}
|
||||
|
||||
private fun keepPath(url: String, mark: String): String {
|
||||
val (scheme, authority, path) = split(url)
|
||||
val clean = path?.split('/')?.joinToString("/") { seg -> if (TOKEN_SEGMENT.containsMatchIn(seg)) mark else seg }
|
||||
return "$scheme://$authority" + (clean?.let { "/$it" } ?: "")
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
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("; ")
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package com.droiddeck.launcher.core
|
||||
import android.content.Context
|
||||
import android.net.nsd.NsdManager
|
||||
import android.net.nsd.NsdServiceInfo
|
||||
import com.droiddeck.launcher.R
|
||||
import com.flyfishxu.kadb.Kadb
|
||||
import com.flyfishxu.kadb.cert.KadbCert
|
||||
import java.io.File
|
||||
@@ -38,21 +37,21 @@ object WirelessAdbFix {
|
||||
|
||||
val identity = KadbCert.get(notAfter = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(3650))
|
||||
KadbCert.set(identity.first, identity.second)
|
||||
writeAtomically(context, cert, identity.first)
|
||||
writeAtomically(context, key, identity.second)
|
||||
writeAtomically(cert, identity.first)
|
||||
writeAtomically(key, identity.second)
|
||||
}
|
||||
|
||||
private fun writeAtomically(context: Context, destination: File, contents: ByteArray) {
|
||||
private fun writeAtomically(destination: File, contents: ByteArray) {
|
||||
val temporary = File(destination.parentFile, "${destination.name}.tmp")
|
||||
temporary.writeBytes(contents)
|
||||
check(temporary.renameTo(destination)) { context.getString(R.string.adbfix_save_failed) }
|
||||
check(temporary.renameTo(destination)) { "Could not save Wireless debugging identity" }
|
||||
}
|
||||
|
||||
suspend fun pair(context: Context, host: String, port: Int, pairingCode: String) {
|
||||
loadOrCreateIdentity(context.applicationContext)
|
||||
Kadb.pair(host, port, pairingCode, "DroidDeck")
|
||||
writeAtomically(context, File(context.noBackupFilesDir, HOST_FILE), host.toByteArray())
|
||||
writeAtomically(context, File(context.noBackupFilesDir, CONNECTION_FILE), ByteArray(0))
|
||||
writeAtomically(File(context.noBackupFilesDir, HOST_FILE), host.toByteArray())
|
||||
writeAtomically(File(context.noBackupFilesDir, CONNECTION_FILE), ByteArray(0))
|
||||
}
|
||||
|
||||
private fun savedConnection(context: Context): SavedConnection? {
|
||||
@@ -74,7 +73,6 @@ object WirelessAdbFix {
|
||||
|
||||
private fun saveConnection(context: Context, host: String, port: Int) {
|
||||
writeAtomically(
|
||||
context,
|
||||
File(context.applicationContext.noBackupFilesDir, CONNECTION_FILE),
|
||||
"$host\n$port".toByteArray(),
|
||||
)
|
||||
@@ -173,11 +171,11 @@ object WirelessAdbFix {
|
||||
Kadb.create(host, port).use { adb ->
|
||||
PhantomProcessLimit.shellCommands(enabled).forEach { command ->
|
||||
val change = adb.shell(command)
|
||||
check(change.exitCode == 0) { change.allOutput.ifBlank { context.getString(R.string.adbfix_command_failed, change.exitCode) } }
|
||||
check(change.exitCode == 0) { change.allOutput.ifBlank { "ADB command failed (${change.exitCode})" } }
|
||||
}
|
||||
val result = adb.shell(PhantomProcessLimit.verifyCommand())
|
||||
check(result.exitCode == 0 && PhantomProcessLimit.verified(result.output, enabled)) {
|
||||
context.getString(R.string.adbfix_not_confirmed, result.allOutput.trim())
|
||||
"Android did not confirm the child-process limit was changed: ${result.allOutput.trim()}"
|
||||
}
|
||||
}
|
||||
if (PhantomProcessLimit.usesDeviceConfig()) PhantomProcessLimit.rememberAndroid12(context, !enabled)
|
||||
@@ -188,7 +186,7 @@ object WirelessAdbFix {
|
||||
fun setUsingSavedPairing(context: Context, enabled: Boolean) {
|
||||
val appContext = context.applicationContext
|
||||
val saved = savedConnection(appContext)
|
||||
?: error(appContext.getString(R.string.adbfix_no_pairing))
|
||||
?: error("No Wireless debugging pairing is saved")
|
||||
val cachedPort = saved.port
|
||||
if (cachedPort != null) {
|
||||
val cachedResult = runCatching {
|
||||
@@ -203,7 +201,7 @@ object WirelessAdbFix {
|
||||
}
|
||||
} else {
|
||||
val discoveredPort = findConnectPort(appContext, saved.host)
|
||||
?: error(appContext.getString(R.string.adbfix_enter_address))
|
||||
?: error("Enter the current Wireless debugging IP address & Port")
|
||||
setChildProcessLimit(appContext, saved.host, discoveredPort, enabled)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,9 +20,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
class WirelessAdbPairingService : Service() {
|
||||
override fun attachBaseContext(newBase: android.content.Context) =
|
||||
super.attachBaseContext(com.droiddeck.launcher.core.AppLanguage.wrap(newBase))
|
||||
|
||||
sealed interface Stage {
|
||||
data object Idle : Stage
|
||||
data object Waiting : Stage
|
||||
@@ -38,7 +35,7 @@ class WirelessAdbPairingService : Service() {
|
||||
private var pairingHost: String? = null
|
||||
private var pairingPort: Int? = null
|
||||
@Volatile private var working = false
|
||||
private val timeout = Runnable { finish(Stage.Failed(getString(R.string.adbpair_timed_out))) }
|
||||
private val timeout = Runnable { finish(Stage.Failed("Timed out waiting for the pairing pop-up. Try again from DroidDeck.")) }
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
@@ -78,13 +75,13 @@ class WirelessAdbPairingService : Service() {
|
||||
override fun onServiceLost(serviceInfo: NsdServiceInfo) = Unit
|
||||
override fun onDiscoveryStopped(serviceType: String) = Unit
|
||||
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
|
||||
main.post { finish(Stage.Failed(getString(R.string.adbpair_discovery_failed_code, errorCode))) }
|
||||
main.post { finish(Stage.Failed("Could not look for the pairing pop-up ($errorCode). Check that Wi-Fi is on.")) }
|
||||
}
|
||||
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) = Unit
|
||||
}
|
||||
runCatching { manager.discoverServices("$PAIRING_TYPE.", NsdManager.PROTOCOL_DNS_SD, listener) }
|
||||
.onSuccess { nsd = manager; discovery = listener }
|
||||
.onFailure { finish(Stage.Failed(getString(R.string.adbpair_discovery_failed, it.localizedMessage.toString()))) }
|
||||
.onFailure { finish(Stage.Failed("Could not look for the pairing pop-up: ${it.localizedMessage}")) }
|
||||
}
|
||||
|
||||
private fun resolveListener() = object : NsdManager.ResolveListener {
|
||||
@@ -110,34 +107,34 @@ class WirelessAdbPairingService : Service() {
|
||||
return
|
||||
}
|
||||
if (code.length != 6) {
|
||||
show(Stage.CodeNeeded(getString(R.string.adbpair_six_digits)))
|
||||
show(Stage.CodeNeeded("Enter all 6 digits of the code"))
|
||||
return
|
||||
}
|
||||
working = true
|
||||
show(Stage.Working(getString(R.string.adbpair_pairing)))
|
||||
show(Stage.Working("Pairing…"))
|
||||
Thread({
|
||||
val paired = runCatching { kotlinx.coroutines.runBlocking { WirelessAdbFix.pair(this@WirelessAdbPairingService, WirelessAdbFix.LOOPBACK, port, code) } }
|
||||
if (paired.isFailure) {
|
||||
main.post {
|
||||
working = false
|
||||
pairingPort = null
|
||||
show(Stage.CodeNeeded(getString(R.string.adbpair_code_rejected)))
|
||||
show(Stage.CodeNeeded("That code didn't work. Open the pairing pop-up again and enter its new code."))
|
||||
}
|
||||
return@Thread
|
||||
}
|
||||
main.post {
|
||||
stopDiscovery()
|
||||
show(Stage.Working(getString(R.string.adbpair_connecting)))
|
||||
show(Stage.Working("Paired. Connecting…"))
|
||||
}
|
||||
val result = runCatching {
|
||||
val connectPort = WirelessAdbFix.localConnectPort(this)
|
||||
?: error(getString(R.string.adbpair_port_not_found))
|
||||
main.post { show(Stage.Working(getString(R.string.adbpair_applying))) }
|
||||
?: error("Paired, but the Wireless debugging port was not found. Keep Wireless debugging on and try again.")
|
||||
main.post { show(Stage.Working("Applying the setting…")) }
|
||||
WirelessAdbFix.setChildProcessLimit(this, WirelessAdbFix.LOOPBACK, connectPort, false)
|
||||
}
|
||||
main.post {
|
||||
working = false
|
||||
finish(result.exceptionOrNull()?.let { Stage.Failed(it.localizedMessage ?: getString(R.string.adbpair_command_failed)) } ?: Stage.Done)
|
||||
finish(result.exceptionOrNull()?.let { Stage.Failed(it.localizedMessage ?: "Wireless debugging command failed") } ?: Stage.Done)
|
||||
}
|
||||
}, "wireless-adb-pairing").start()
|
||||
}
|
||||
@@ -177,9 +174,9 @@ class WirelessAdbPairingService : Service() {
|
||||
|
||||
private fun notification(stage: Stage): Notification {
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
manager?.createNotificationChannel(NotificationChannel(CHANNEL_ID, getString(R.string.adbpair_channel_name),
|
||||
manager?.createNotificationChannel(NotificationChannel(CHANNEL_ID, "Wireless debugging setup",
|
||||
NotificationManager.IMPORTANCE_HIGH).apply {
|
||||
description = getString(R.string.adbpair_channel_description)
|
||||
description = "Asks for the Wireless debugging pairing code while Settings is open"
|
||||
setShowBadge(false)
|
||||
setSound(null, null)
|
||||
enableVibration(false)
|
||||
@@ -196,40 +193,41 @@ class WirelessAdbPairingService : Service() {
|
||||
.setCategory(Notification.CATEGORY_STATUS)
|
||||
when (stage) {
|
||||
Stage.Waiting, Stage.Idle -> builder
|
||||
.setContentTitle(getString(R.string.adbpair_title_pair))
|
||||
.setContentText(getString(R.string.adbpair_waiting_text))
|
||||
.setStyle(Notification.BigTextStyle().bigText(getString(R.string.adbpair_waiting_long)))
|
||||
.setContentTitle("Pair Wireless debugging")
|
||||
.setContentText("Turn on Wireless debugging, then tap “Pair device with pairing code”.")
|
||||
.setStyle(Notification.BigTextStyle().bigText(
|
||||
"Turn on Wireless debugging, then tap “Pair device with pairing code”. Stay in Settings: the code goes in this notification."))
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.addAction(Notification.Action.Builder(null, getString(R.string.common_cancel), cancel).build())
|
||||
.addAction(Notification.Action.Builder(null, "Cancel", cancel).build())
|
||||
is Stage.CodeNeeded -> {
|
||||
val reply = PendingIntent.getService(this, 2,
|
||||
Intent(this, WirelessAdbPairingService::class.java).setAction(ACTION_CODE),
|
||||
if (Build.VERSION.SDK_INT >= 31) PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
else PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
val input = RemoteInput.Builder(KEY_CODE).setLabel(getString(R.string.adbpair_code_label)).build()
|
||||
val text = stage.error ?: getString(R.string.adbpair_code_hint)
|
||||
val input = RemoteInput.Builder(KEY_CODE).setLabel("6-digit pairing code").build()
|
||||
val text = stage.error ?: "Tap Enter code and type the 6 digits shown in Settings."
|
||||
builder
|
||||
.setContentTitle(getString(if (stage.error == null) R.string.adbpair_title_code else R.string.adbpair_title_retry))
|
||||
.setContentTitle(if (stage.error == null) "Enter the Wi-Fi pairing code" else "Try the code again")
|
||||
.setContentText(text)
|
||||
.setStyle(Notification.BigTextStyle().bigText(text))
|
||||
.setOngoing(true)
|
||||
.addAction(Notification.Action.Builder(null, getString(R.string.adbpair_enter_code), reply).addRemoteInput(input).build())
|
||||
.addAction(Notification.Action.Builder(null, getString(R.string.common_cancel), cancel).build())
|
||||
.addAction(Notification.Action.Builder(null, "Enter code", reply).addRemoteInput(input).build())
|
||||
.addAction(Notification.Action.Builder(null, "Cancel", cancel).build())
|
||||
}
|
||||
is Stage.Working -> builder
|
||||
.setContentTitle(getString(R.string.adbpair_title_working))
|
||||
.setContentTitle("Setting up Steam")
|
||||
.setContentText(stage.step)
|
||||
.setProgress(0, 0, true)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
Stage.Done -> builder
|
||||
.setContentTitle(getString(R.string.adbpair_title_done))
|
||||
.setContentText(getString(R.string.adbpair_done_text))
|
||||
.setStyle(Notification.BigTextStyle().bigText(getString(R.string.adbpair_done_text)))
|
||||
.setContentTitle("Steam is ready")
|
||||
.setContentText("Tap to go back to DroidDeck. You can turn Wireless debugging off now.")
|
||||
.setStyle(Notification.BigTextStyle().bigText("Tap to go back to DroidDeck. You can turn Wireless debugging off now."))
|
||||
.setAutoCancel(true)
|
||||
is Stage.Failed -> builder
|
||||
.setContentTitle(getString(R.string.adbpair_title_failed))
|
||||
.setContentTitle("Wireless debugging setup stopped")
|
||||
.setContentText(stage.error)
|
||||
.setStyle(Notification.BigTextStyle().bigText(stage.error))
|
||||
.setAutoCancel(true)
|
||||
|
||||
@@ -56,8 +56,6 @@ data class RailItem(
|
||||
val selected: Boolean,
|
||||
/** >0 shows a small accent count badge on the item's icon (expanded and collapsed). */
|
||||
val badge: Int = 0,
|
||||
/** The under-icon label when the rail is collapsed, for names too long to fit; null derives one. */
|
||||
val shortLabel: String? = null,
|
||||
// onClick stays LAST so existing call sites can pass it as a trailing lambda; badge is an
|
||||
// optional named param before it (default 0).
|
||||
val onClick: () -> Unit,
|
||||
@@ -75,6 +73,7 @@ private fun collapsedLabel(label: String): String {
|
||||
when (label) {
|
||||
"ENVIROMENT" -> return "ENVIRON" // the app's existing (mis)spelling, kept uppercase
|
||||
"WIN COMPONENTS" -> return "WIN COMP"
|
||||
"Downloads" -> return "Downlds"
|
||||
}
|
||||
if (label.contains(' ')) return label // multi-word → wraps to 2 lines
|
||||
return if (label.length > 9) label.take(8) + "…" else label
|
||||
@@ -318,7 +317,7 @@ fun CollapsibleRail(
|
||||
RailItemIcon(item, iconTint)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
item.shortLabel ?: collapsedLabel(item.label),
|
||||
collapsedLabel(item.label),
|
||||
color = labelColor,
|
||||
fontSize = 7.5.sp,
|
||||
lineHeight = 8.5.sp,
|
||||
|
||||
@@ -15,20 +15,14 @@ import java.io.File
|
||||
|
||||
/** The File Manager as a screen of its own, from the Files tile on the main screen. */
|
||||
class FileManagerActivity : ComponentActivity() {
|
||||
override fun attachBaseContext(newBase: android.content.Context) {
|
||||
super.attachBaseContext(newBase)
|
||||
com.droiddeck.launcher.core.AppLanguage.applyTo(this, newBase)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
requestedOrientation = com.droiddeck.launcher.session.SessionPrefs.orientation(this)
|
||||
if (checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
|
||||
requestPermissions(arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), 1)
|
||||
}
|
||||
val initialDir = intent.getStringExtra(EXTRA_START_DIR)?.let(::File)
|
||||
setContent {
|
||||
DroidDeckTheme(appScale = com.droiddeck.launcher.core.AppUiPrefs.scale(this)) {
|
||||
DroidDeckTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize().systemBarsPadding(), color = MaterialTheme.colorScheme.background) {
|
||||
FileManagerScreen(initialDir = initialDir)
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.ArrowUpward
|
||||
import androidx.compose.material.icons.filled.Checklist
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.FileCopy
|
||||
@@ -62,7 +61,6 @@ import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.graphics.vector.rememberVectorPainter
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import coil.compose.AsyncImage
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -109,7 +107,7 @@ private fun FileContextMenuItems(
|
||||
) {
|
||||
val isDir = file.isDirectory
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.fm_select)) },
|
||||
text = { Text("Select") },
|
||||
leadingIcon = { Icon(Icons.Filled.Checklist, null, tint = MaterialTheme.colorScheme.primary) },
|
||||
onClick = { onDismissMenu(); onSelect() },
|
||||
)
|
||||
@@ -117,7 +115,7 @@ private fun FileContextMenuItems(
|
||||
// Properties: basic info + Read-only / Hidden toggles, for ANY file or folder (handy for config
|
||||
// files like .txt/.cfg/.ini). Kept near the top since it's a common reason to open this menu.
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.fm_properties)) },
|
||||
text = { Text("Properties") },
|
||||
leadingIcon = { Icon(Icons.Filled.Info, null, tint = MaterialTheme.colorScheme.primary) },
|
||||
onClick = { onDismissMenu(); onProperties() },
|
||||
)
|
||||
@@ -125,7 +123,7 @@ private fun FileContextMenuItems(
|
||||
// Favorites are directories - only folders get the pin toggle.
|
||||
if (isDir) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(if (isFavorite) stringResource(R.string.fm_favorite_remove) else stringResource(R.string.fm_favorite_add)) },
|
||||
text = { Text(if (isFavorite) "Remove from Favorites" else "Add to Favorites") },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
if (isFavorite) Icons.Filled.Star else Icons.Filled.StarBorder,
|
||||
@@ -138,25 +136,25 @@ private fun FileContextMenuItems(
|
||||
MenuItemDivider()
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.fm_rename)) },
|
||||
text = { Text("Rename") },
|
||||
leadingIcon = { Icon(Icons.Filled.Edit, null, tint = MaterialTheme.colorScheme.primary) },
|
||||
onClick = { onDismissMenu(); onRename() },
|
||||
)
|
||||
MenuItemDivider()
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.fm_copy)) },
|
||||
text = { Text("Copy") },
|
||||
leadingIcon = { Icon(Icons.Filled.FileCopy, null, tint = MaterialTheme.colorScheme.primary) },
|
||||
onClick = { onDismissMenu(); onCopy() },
|
||||
)
|
||||
MenuItemDivider()
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.fm_cut)) },
|
||||
text = { Text("Cut") },
|
||||
leadingIcon = { Icon(Icons.Filled.ContentCut, null, tint = MaterialTheme.colorScheme.primary) },
|
||||
onClick = { onDismissMenu(); onCut() },
|
||||
)
|
||||
MenuItemDivider()
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.common_delete)) },
|
||||
text = { Text("Delete") },
|
||||
leadingIcon = { Icon(Icons.Filled.Delete, null, tint = MaterialTheme.colorScheme.primary) },
|
||||
onClick = { onDismissMenu(); onDelete() },
|
||||
)
|
||||
@@ -186,7 +184,6 @@ internal fun FileItemRow(
|
||||
onProperties: () -> Unit = {},
|
||||
) {
|
||||
val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) }
|
||||
val context = LocalContext.current
|
||||
val isDir = file.isDirectory
|
||||
val isExe = !isDir && file.name.lowercase().let { it.endsWith(".exe") || it.endsWith(".bat") || it.endsWith(".msi") || it.endsWith(".sh") }
|
||||
// Image files show a real thumbnail instead of the generic file icon (handy when picking a
|
||||
@@ -279,7 +276,7 @@ internal fun FileItemRow(
|
||||
)
|
||||
Text(
|
||||
text = buildString {
|
||||
if (!isDir) append(FileOps.formatBytes(context, file.length())).append(" \u2022 ")
|
||||
if (!isDir) append(FileOps.formatBytes(file.length())).append(" \u2022 ")
|
||||
append(dateFormat.format(Date(file.lastModified())))
|
||||
},
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
@@ -288,7 +285,7 @@ internal fun FileItemRow(
|
||||
}
|
||||
if (showActions) Box {
|
||||
IconButton(onClick = onMenu) {
|
||||
Icon(Icons.Filled.MoreVert, stringResource(R.string.fm_actions), tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp))
|
||||
Icon(Icons.Filled.MoreVert, "Actions", tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = menuExpanded,
|
||||
@@ -313,47 +310,6 @@ internal fun FileItemRow(
|
||||
}
|
||||
}
|
||||
|
||||
// The ".." row at the top of a folder: one level up, styled like a folder row so it reads as part
|
||||
// of the list and takes controller focus the same way.
|
||||
@Composable
|
||||
internal fun ParentFolderRow(compact: Boolean = false, onTap: () -> Unit) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 3.dp)
|
||||
.clickable(onClick = onTap),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = if (compact) 3.dp else 8.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ArrowUpward,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(if (compact) 24.dp else 36.dp),
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "..",
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.fm_up_one_level),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dedicated Favorites list that replaces the file list while the star toggle is on.
|
||||
// Reads the store keyed on [favTick] so it recomposes after any pin/unpin.
|
||||
@Composable
|
||||
@@ -382,7 +338,7 @@ internal fun FavoritesList(
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.fm_favorites),
|
||||
text = "Favorites",
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
@@ -395,7 +351,7 @@ internal fun FavoritesList(
|
||||
Icon(Icons.Filled.PushPin, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(16.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.fm_pin_current),
|
||||
text = "Pin current folder",
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontSize = 13.sp,
|
||||
)
|
||||
@@ -410,7 +366,7 @@ internal fun FavoritesList(
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.fm_no_favorites),
|
||||
text = "No favorites yet - pin a folder with its ⋮ menu to jump back here fast.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 13.sp,
|
||||
modifier = Modifier.padding(horizontal = 32.dp),
|
||||
@@ -475,7 +431,7 @@ private fun FavoriteCard(
|
||||
// Origin line: coloured drive badge + source description.
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = stringResource(loc.driveLabel),
|
||||
text = loc.driveLabel,
|
||||
color = badgeFg,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
@@ -487,9 +443,9 @@ private fun FavoriteCard(
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
text = when (loc.storage) {
|
||||
FavStorage.INTERNAL -> stringResource(R.string.games_internal)
|
||||
FavStorage.SD -> stringResource(R.string.fm_drive_sd)
|
||||
FavStorage.OTHER -> stringResource(R.string.fm_drive_storage)
|
||||
FavStorage.INTERNAL -> "Internal storage"
|
||||
FavStorage.SD -> "SD card"
|
||||
FavStorage.OTHER -> "Storage"
|
||||
},
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 12.sp,
|
||||
@@ -508,7 +464,7 @@ private fun FavoriteCard(
|
||||
IconButton(onClick = onUnpin) {
|
||||
Icon(
|
||||
Icons.Filled.Star,
|
||||
contentDescription = stringResource(R.string.fm_unpin_favorite),
|
||||
contentDescription = "Remove from favorites",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
|
||||
@@ -8,8 +8,6 @@ import androidx.compose.material.icons.filled.Image
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.annotation.StringRes
|
||||
import com.droiddeck.launcher.R
|
||||
import java.io.File
|
||||
|
||||
// The File Manager's non-UI helpers: sorting, paths, conflict choices and favourite locations.
|
||||
@@ -79,7 +77,7 @@ enum class FavStorage { INTERNAL, SD, OTHER }
|
||||
|
||||
data class FavLocation(
|
||||
val storage: FavStorage,
|
||||
@StringRes val driveLabel: Int, // Internal, SD card, or Storage
|
||||
val driveLabel: String, // "Internal", "SD card", or "Storage"
|
||||
val displayPath: String // the unix absolute path
|
||||
)
|
||||
|
||||
@@ -89,20 +87,15 @@ fun describeLocation(file: File): FavLocation {
|
||||
|
||||
val internal = "/storage/emulated/0"
|
||||
if (abs == internal || abs.startsWith("$internal/")) {
|
||||
return FavLocation(FavStorage.INTERNAL, R.string.fm_drive_internal, abs)
|
||||
return FavLocation(FavStorage.INTERNAL, "Internal", abs)
|
||||
}
|
||||
|
||||
if (abs.startsWith("/storage/")) {
|
||||
val name = abs.removePrefix("/storage/").substringBefore('/')
|
||||
if (name.isNotEmpty() && name != "emulated" && name != "self") {
|
||||
return FavLocation(FavStorage.SD, R.string.fm_drive_sd, abs)
|
||||
return FavLocation(FavStorage.SD, "SD card", abs)
|
||||
}
|
||||
}
|
||||
|
||||
// The Linux system's root (the app's files/linuxfs, the sessions' /) and anything under it.
|
||||
if (Regex("/files/linuxfs(/|$)").containsMatchIn(abs)) {
|
||||
return FavLocation(FavStorage.OTHER, R.string.fm_drive_linux, abs)
|
||||
}
|
||||
|
||||
return FavLocation(FavStorage.OTHER, R.string.fm_drive_storage, abs)
|
||||
return FavLocation(FavStorage.OTHER, "Storage", abs)
|
||||
}
|
||||
@@ -22,7 +22,6 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
@@ -42,7 +41,6 @@ import androidx.compose.material.icons.filled.ViewList
|
||||
import androidx.compose.material.icons.filled.StarBorder
|
||||
import androidx.compose.material.icons.filled.Storage
|
||||
import androidx.compose.material.icons.filled.Smartphone
|
||||
import androidx.compose.material.icons.filled.Terminal
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.Description
|
||||
import androidx.compose.material.icons.filled.Image
|
||||
@@ -78,9 +76,6 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.droiddeck.launcher.R
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
@@ -145,14 +140,7 @@ fun FileManagerScreen(
|
||||
val name = abs.removePrefix("/storage/").substringBefore('/')
|
||||
if (name.isNotEmpty() && name != "emulated" && name != "self") File("/storage/$name") else rootDir
|
||||
}
|
||||
// A game's own folder or Proton prefix lives in the app's private storage, which has no
|
||||
// volume above it: floor at the app's files dir so up/back still reach the library roots.
|
||||
else -> {
|
||||
val appFiles = runCatching { context.filesDir.canonicalPath }.getOrNull()
|
||||
generateSequence(rootDir) { it.parentFile }
|
||||
.firstOrNull { appFiles != null && runCatching { it.canonicalPath }.getOrNull() == appFiles }
|
||||
?: rootDir
|
||||
}
|
||||
else -> rootDir
|
||||
}
|
||||
mutableStateOf(vol)
|
||||
}
|
||||
@@ -241,9 +229,7 @@ fun FileManagerScreen(
|
||||
loadDirectory(dir)
|
||||
}
|
||||
|
||||
// Open the start dir without openDrive(): that pins the floor to the start dir itself, which
|
||||
// greyed out up/back whenever the manager was opened at a game's folder.
|
||||
LaunchedEffect(Unit) { loadDirectory(rootDir) }
|
||||
LaunchedEffect(Unit) { openDrive(rootDir) }
|
||||
|
||||
// System/gesture Back: while the Favorites view is open it closes that first; otherwise
|
||||
// it goes up one directory. Only at the current drive's root with Favorites closed is it
|
||||
@@ -257,14 +243,6 @@ fun FileManagerScreen(
|
||||
if (parent != null && parent.exists()) loadDirectory(parent)
|
||||
}
|
||||
|
||||
// One level up, never above the current drive's root: shared by the toolbar arrow and the
|
||||
// ".." row at the top of the list.
|
||||
val canGoUp = currentDir != currentRoot && currentDir.parentFile?.exists() == true
|
||||
fun goUp() {
|
||||
val parent = currentDir.parentFile
|
||||
if (currentDir != currentRoot && parent != null && parent.exists()) loadDirectory(parent)
|
||||
}
|
||||
|
||||
// Resolve a non-colliding destination in [dir] for [name] (foo.txt -> "foo (1).txt").
|
||||
fun uniqueDestination(dir: File, name: String): File {
|
||||
var candidate = File(dir, name)
|
||||
@@ -283,11 +261,11 @@ fun FileManagerScreen(
|
||||
fun performDelete(file: File) {
|
||||
scope.launch {
|
||||
isOperationRunning = true
|
||||
operationLabel = context.getString(R.string.fm_op_deleting)
|
||||
operationLabel = "Deleting..."
|
||||
val ok = withContext(Dispatchers.IO) { FileOps.delete(file) }
|
||||
isOperationRunning = false
|
||||
loadDirectory(currentDir, resetScroll = false)
|
||||
if (!ok) Toast.makeText(context, R.string.fm_delete_failed, Toast.LENGTH_SHORT).show()
|
||||
if (!ok) Toast.makeText(context, "Delete failed", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,7 +288,7 @@ fun FileManagerScreen(
|
||||
operationJob = scope.launch {
|
||||
operationProgress = 0f
|
||||
operationDeterminate = true
|
||||
operationLabel = context.getString(if (cut) R.string.fm_op_moving else R.string.fm_op_copying)
|
||||
operationLabel = if (cut) "Moving..." else "Copying..."
|
||||
isOperationRunning = true
|
||||
|
||||
var applyToAll: ConflictChoice? = null
|
||||
@@ -346,10 +324,10 @@ fun FileManagerScreen(
|
||||
}
|
||||
|
||||
// Progress is per item; with a batch the label carries the overall position.
|
||||
operationLabel = if (sources.size > 1) {
|
||||
context.getString(if (cut) R.string.fm_op_moving_batch else R.string.fm_op_copying_batch, done + 1, sources.size, src.name)
|
||||
} else {
|
||||
context.getString(if (cut) R.string.fm_op_moving_item else R.string.fm_op_copying_item, src.name)
|
||||
operationLabel = buildString {
|
||||
append(if (cut) "Moving" else "Copying")
|
||||
if (sources.size > 1) append(" ${done + 1}/${sources.size}")
|
||||
append(" - ").append(src.name)
|
||||
}
|
||||
var lastPct = -1
|
||||
val onProgress = FileOps.ProgressCallback { copied, total ->
|
||||
@@ -377,9 +355,9 @@ fun FileManagerScreen(
|
||||
loadDirectory(currentDir, resetScroll = false)
|
||||
|
||||
val message = when {
|
||||
failed > 0 -> context.getString(R.string.fm_paste_failed, done, failed)
|
||||
skipped > 0 -> context.getString(R.string.fm_paste_skipped, done, skipped)
|
||||
sources.size > 1 -> context.resources.getQuantityString(if (cut) R.plurals.fm_paste_moved else R.plurals.fm_paste_copied, done, done)
|
||||
failed > 0 -> "$done done, $failed failed"
|
||||
skipped > 0 -> "$done done, $skipped skipped"
|
||||
sources.size > 1 -> "$done items ${if (cut) "moved" else "copied"}"
|
||||
else -> null
|
||||
}
|
||||
if (message != null) Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||
@@ -389,32 +367,32 @@ fun FileManagerScreen(
|
||||
fun performRename(file: File, newName: String) {
|
||||
val target = File(file.parentFile, newName)
|
||||
if (target.exists()) {
|
||||
Toast.makeText(context, context.getString(R.string.fm_already_exists, newName), Toast.LENGTH_SHORT).show()
|
||||
Toast.makeText(context, "\"$newName\" already exists", Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
scope.launch {
|
||||
isOperationRunning = true
|
||||
operationLabel = context.getString(R.string.fm_op_renaming)
|
||||
operationLabel = "Renaming..."
|
||||
val ok = withContext(Dispatchers.IO) { file.renameTo(target) }
|
||||
isOperationRunning = false
|
||||
loadDirectory(currentDir, resetScroll = false)
|
||||
if (!ok) Toast.makeText(context, R.string.fm_rename_failed, Toast.LENGTH_SHORT).show()
|
||||
if (!ok) Toast.makeText(context, "Rename failed", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
fun createFolder(parent: File, name: String) {
|
||||
val target = File(parent, name)
|
||||
if (target.exists()) {
|
||||
Toast.makeText(context, context.getString(R.string.fm_already_exists, name), Toast.LENGTH_SHORT).show()
|
||||
Toast.makeText(context, "\"$name\" already exists", Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
scope.launch {
|
||||
isOperationRunning = true
|
||||
operationLabel = context.getString(R.string.fm_op_creating_folder)
|
||||
operationLabel = "Creating folder..."
|
||||
val ok = withContext(Dispatchers.IO) { target.mkdirs() }
|
||||
isOperationRunning = false
|
||||
loadDirectory(currentDir, resetScroll = false)
|
||||
if (!ok) Toast.makeText(context, R.string.fm_create_folder_failed, Toast.LENGTH_SHORT).show()
|
||||
if (!ok) Toast.makeText(context, "Could not create folder", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -431,9 +409,6 @@ fun FileManagerScreen(
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
val drives = remember(storageTick) { StorageRoots.list(context) }
|
||||
val linuxRoot = remember(storageTick) {
|
||||
com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(context).takeIf { File(it, "usr").isDirectory }
|
||||
}
|
||||
|
||||
// ── Dialogs ──
|
||||
|
||||
@@ -441,12 +416,12 @@ fun FileManagerScreen(
|
||||
var folderName by remember { mutableStateOf("") }
|
||||
OutlinedAlertDialog(
|
||||
onDismissRequest = { showNewFolderDialog = false },
|
||||
title = { Text(stringResource(R.string.fm_new_folder)) },
|
||||
title = { Text("New Folder") },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = folderName,
|
||||
onValueChange = { folderName = it },
|
||||
label = { Text(stringResource(R.string.fm_folder_name)) },
|
||||
label = { Text("Folder name") },
|
||||
singleLine = true,
|
||||
)
|
||||
},
|
||||
@@ -454,9 +429,9 @@ fun FileManagerScreen(
|
||||
TextButton(onClick = {
|
||||
showNewFolderDialog = false
|
||||
if (folderName.isNotBlank()) createFolder(currentDir, folderName)
|
||||
}) { Text(stringResource(R.string.fm_create)) }
|
||||
}) { Text("Create") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { showNewFolderDialog = false }) { Text(stringResource(R.string.common_cancel)) } },
|
||||
dismissButton = { TextButton(onClick = { showNewFolderDialog = false }) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -464,12 +439,12 @@ fun FileManagerScreen(
|
||||
var newName by remember(renameTarget) { mutableStateOf(renameTarget?.name ?: "") }
|
||||
OutlinedAlertDialog(
|
||||
onDismissRequest = { renameTarget = null },
|
||||
title = { Text(stringResource(R.string.fm_rename)) },
|
||||
title = { Text("Rename") },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = newName,
|
||||
onValueChange = { newName = it },
|
||||
label = { Text(stringResource(R.string.fm_new_name)) },
|
||||
label = { Text("New name") },
|
||||
singleLine = true,
|
||||
)
|
||||
},
|
||||
@@ -478,9 +453,9 @@ fun FileManagerScreen(
|
||||
val file = renameTarget
|
||||
renameTarget = null
|
||||
if (file != null && newName.isNotBlank()) performRename(file, newName)
|
||||
}) { Text(stringResource(R.string.fm_rename)) }
|
||||
}) { Text("Rename") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { renameTarget = null }) { Text(stringResource(R.string.common_cancel)) } },
|
||||
dismissButton = { TextButton(onClick = { renameTarget = null }) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -498,15 +473,15 @@ fun FileManagerScreen(
|
||||
val file = selectedEntry ?: return
|
||||
OutlinedAlertDialog(
|
||||
onDismissRequest = { selectedEntry = null },
|
||||
title = { Text(stringResource(R.string.fm_delete_title)) },
|
||||
text = { Text(stringResource(R.string.fm_delete_text, file.name)) },
|
||||
title = { Text("Delete?") },
|
||||
text = { Text("Delete \"${file.name}\" permanently?") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
selectedEntry = null
|
||||
performDelete(file)
|
||||
}) { Text(stringResource(R.string.common_delete)) }
|
||||
}) { Text("Delete") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { selectedEntry = null }) { Text(stringResource(R.string.common_cancel)) } },
|
||||
dismissButton = { TextButton(onClick = { selectedEntry = null }) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -514,10 +489,10 @@ fun FileManagerScreen(
|
||||
val victims = pendingBulkDelete
|
||||
OutlinedAlertDialog(
|
||||
onDismissRequest = { pendingBulkDelete = emptyList() },
|
||||
title = { Text(pluralStringResource(R.plurals.fm_delete_items_title, victims.size, victims.size)) },
|
||||
title = { Text("Delete ${victims.size} item${if (victims.size == 1) "" else "s"}?") },
|
||||
text = {
|
||||
Column {
|
||||
Text(stringResource(R.string.fm_cannot_undo))
|
||||
Text("This can't be undone.")
|
||||
Spacer(Modifier.height(6.dp))
|
||||
// Name a few so an accidental Select-All is obvious before it's too late.
|
||||
victims.take(5).forEach {
|
||||
@@ -525,7 +500,7 @@ fun FileManagerScreen(
|
||||
}
|
||||
if (victims.size > 5) {
|
||||
Text(
|
||||
pluralStringResource(R.plurals.fm_and_more, victims.size - 5, victims.size - 5),
|
||||
"…and ${victims.size - 5} more",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
@@ -541,19 +516,19 @@ fun FileManagerScreen(
|
||||
isOperationRunning = true
|
||||
var failed = 0
|
||||
victims.forEachIndexed { i, f ->
|
||||
operationLabel = context.getString(R.string.fm_op_deleting_batch, i + 1, victims.size, f.name)
|
||||
operationLabel = "Deleting ${i + 1}/${victims.size} - ${f.name}"
|
||||
if (!withContext(Dispatchers.IO) { FileOps.delete(f) }) failed++
|
||||
}
|
||||
isOperationRunning = false
|
||||
operationJob = null
|
||||
loadDirectory(currentDir, resetScroll = false)
|
||||
if (failed > 0) {
|
||||
Toast.makeText(context, context.resources.getQuantityString(R.plurals.fm_delete_some_failed, failed, failed), Toast.LENGTH_SHORT).show()
|
||||
Toast.makeText(context, "$failed couldn't be deleted", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}) { Text(stringResource(R.string.common_delete), color = MaterialTheme.colorScheme.error) }
|
||||
}) { Text("Delete", color = MaterialTheme.colorScheme.error) }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { pendingBulkDelete = emptyList() }) { Text(stringResource(R.string.common_cancel)) } },
|
||||
dismissButton = { TextButton(onClick = { pendingBulkDelete = emptyList() }) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -562,12 +537,12 @@ fun FileManagerScreen(
|
||||
val isDir = conflict.isDirectory
|
||||
OutlinedAlertDialog(
|
||||
onDismissRequest = { pendingConflict = null },
|
||||
title = { Text(stringResource(R.string.fm_already_exists, conflict.name)) },
|
||||
title = { Text("\"${conflict.name}\" already exists") },
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
if (isDir) stringResource(R.string.fm_conflict_merge_hint)
|
||||
else stringResource(R.string.fm_conflict_overwrite_hint),
|
||||
if (isDir) "Merge adds and replaces files inside the existing folder."
|
||||
else "Overwrite replaces the existing file.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
@@ -578,15 +553,15 @@ fun FileManagerScreen(
|
||||
checked = conflictApplyToAll,
|
||||
onCheckedChange = { conflictApplyToAll = it },
|
||||
)
|
||||
Text(stringResource(R.string.fm_conflict_apply_all), fontSize = 12.sp)
|
||||
Text("Apply to all conflicts", fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
listOf(
|
||||
(if (isDir) ConflictChoice.MERGE else ConflictChoice.OVERWRITE) to
|
||||
(if (isDir) stringResource(R.string.fm_conflict_merge) else stringResource(R.string.fm_conflict_overwrite)),
|
||||
ConflictChoice.KEEP_BOTH to stringResource(R.string.fm_conflict_keep_both),
|
||||
ConflictChoice.SKIP to stringResource(R.string.fm_conflict_skip),
|
||||
(if (isDir) "Merge" else "Overwrite"),
|
||||
ConflictChoice.KEEP_BOTH to "Keep both",
|
||||
ConflictChoice.SKIP to "Skip",
|
||||
).forEach { (choice, label) ->
|
||||
TextButton(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -598,7 +573,7 @@ fun FileManagerScreen(
|
||||
confirmButton = {},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { conflictChoice = ConflictChoice.SKIP; pendingConflict = null }) {
|
||||
Text(stringResource(R.string.common_cancel))
|
||||
Text("Cancel")
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -628,7 +603,7 @@ fun FileManagerScreen(
|
||||
) {
|
||||
Icon(Icons.Filled.Folder, null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(stringResource(R.string.fm_select_this_folder))
|
||||
Text("Select this folder")
|
||||
}
|
||||
}
|
||||
// ── Path bar ──
|
||||
@@ -639,11 +614,15 @@ fun FileManagerScreen(
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
) {
|
||||
IconButton(onClick = { goUp() }, enabled = canGoUp) {
|
||||
Icon(Icons.Filled.ArrowBack, stringResource(R.string.fm_back), tint = MaterialTheme.colorScheme.primary)
|
||||
IconButton(onClick = {
|
||||
val parent = currentDir.parentFile
|
||||
// Don't climb above the current drive's root.
|
||||
if (currentDir != currentRoot && parent != null && parent.exists()) loadDirectory(parent)
|
||||
}, enabled = currentDir != currentRoot) {
|
||||
Icon(Icons.Filled.ArrowBack, "Back", tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
|
||||
val currentDriveLabel = stringResource(describeLocation(currentDir).driveLabel)
|
||||
val currentDriveLabel = describeLocation(currentDir).driveLabel
|
||||
// Dim the drive chip while the Favorites list is open (it's not the active context).
|
||||
val driveChipAlpha = if (showFavorites) 0.45f else 1f
|
||||
Box {
|
||||
@@ -687,23 +666,13 @@ fun FileManagerScreen(
|
||||
} else {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.fm_drive_not_readable, drive.label),
|
||||
"${drive.label} is mounted but not readable right now",
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
linuxRoot?.let { root ->
|
||||
MenuItemDivider()
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.fm_drive_linux)) },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Filled.Terminal, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(18.dp))
|
||||
},
|
||||
onClick = { showDriveMenu = false; openDrive(root) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -711,7 +680,7 @@ fun FileManagerScreen(
|
||||
|
||||
if (showFavorites) {
|
||||
Text(
|
||||
text = stringResource(R.string.fm_favorites),
|
||||
text = "Favorites",
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
@@ -749,7 +718,7 @@ fun FileManagerScreen(
|
||||
) {
|
||||
Icon(Icons.Filled.CreateNewFolder, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(16.dp))
|
||||
Spacer(Modifier.width(5.dp))
|
||||
Text(stringResource(R.string.fm_new_folder), color = MaterialTheme.colorScheme.onBackground, fontSize = 12.sp)
|
||||
Text("New Folder", color = MaterialTheme.colorScheme.onBackground, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = {
|
||||
@@ -758,19 +727,19 @@ fun FileManagerScreen(
|
||||
}) {
|
||||
Icon(
|
||||
if (gridView) Icons.Filled.ViewList else Icons.Filled.GridView,
|
||||
if (gridView) stringResource(R.string.fm_list_view) else stringResource(R.string.fm_grid_view),
|
||||
if (gridView) "List view" else "Grid view",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { showSearch = !showSearch; if (!showSearch) searchQuery = "" }) {
|
||||
Icon(Icons.Filled.Search, stringResource(R.string.fm_search), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Icon(Icons.Filled.Search, "Search", tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
Box {
|
||||
IconButton(onClick = { showSortMenu = true }) {
|
||||
Icon(Icons.Filled.Sort, stringResource(R.string.fm_sort), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Icon(Icons.Filled.Sort, "Sort", tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
DropdownMenu(expanded = showSortMenu, onDismissRequest = { showSortMenu = false }) {
|
||||
listOf("name" to stringResource(R.string.fm_sort_name), "date" to stringResource(R.string.fm_sort_date), "size" to stringResource(R.string.fm_prop_size))
|
||||
listOf("name" to "Name", "date" to "Date modified", "size" to "Size")
|
||||
.forEach { (key, label) ->
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
@@ -789,7 +758,7 @@ fun FileManagerScreen(
|
||||
}
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
|
||||
DropdownMenuItem(
|
||||
text = { Text(if (compactRows) stringResource(R.string.fm_rows_comfortable) else stringResource(R.string.fm_rows_compact)) },
|
||||
text = { Text(if (compactRows) "Comfortable rows" else "Compact rows") },
|
||||
onClick = {
|
||||
compactRows = !compactRows
|
||||
browsePrefs.edit().putBoolean("fmCompactRows", compactRows).apply()
|
||||
@@ -797,7 +766,7 @@ fun FileManagerScreen(
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(if (showHidden) stringResource(R.string.fm_hide_hidden) else stringResource(R.string.fm_show_hidden)) },
|
||||
text = { Text(if (showHidden) "Hide hidden files" else "Show hidden files") },
|
||||
onClick = {
|
||||
showHidden = !showHidden
|
||||
browsePrefs.edit().putBoolean("fmShowHidden", showHidden).apply()
|
||||
@@ -812,9 +781,9 @@ fun FileManagerScreen(
|
||||
// Star toggle: open/close the dedicated Favorites list.
|
||||
IconButton(onClick = { showFavorites = !showFavorites }) {
|
||||
if (showFavorites) {
|
||||
Icon(Icons.Filled.Star, stringResource(R.string.fm_hide_favorites), tint = MaterialTheme.colorScheme.primary)
|
||||
Icon(Icons.Filled.Star, "Hide favorites", tint = MaterialTheme.colorScheme.primary)
|
||||
} else {
|
||||
Icon(Icons.Filled.StarBorder, stringResource(R.string.fm_show_favorites), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Icon(Icons.Filled.StarBorder, "Show favorites", tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -825,7 +794,7 @@ fun FileManagerScreen(
|
||||
value = searchQuery,
|
||||
onValueChange = { searchQuery = it },
|
||||
singleLine = true,
|
||||
placeholder = { Text(stringResource(R.string.fm_filter_folder), fontSize = 13.sp) },
|
||||
placeholder = { Text("Filter this folder", fontSize = 13.sp) },
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
@@ -852,7 +821,7 @@ fun FileManagerScreen(
|
||||
)
|
||||
if (freeSpace > 0) {
|
||||
Text(
|
||||
stringResource(R.string.fm_free_space, FileOps.formatBytes(context, freeSpace)),
|
||||
"${FileOps.formatBytes(freeSpace)} free",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 1,
|
||||
@@ -873,7 +842,7 @@ fun FileManagerScreen(
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.fm_selected_count, selectedPaths.size),
|
||||
"${selectedPaths.size} selected",
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
fontSize = 13.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
@@ -886,7 +855,7 @@ fun FileManagerScreen(
|
||||
else entries.map { it.absolutePath }.toSet()
|
||||
},
|
||||
contentPadding = selBarPadding,
|
||||
) { Text(if (selectedPaths.size == entries.size) stringResource(R.string.fm_select_none) else stringResource(R.string.fm_select_all), fontSize = 12.sp) }
|
||||
) { Text(if (selectedPaths.size == entries.size) "None" else "All", fontSize = 12.sp) }
|
||||
Spacer(Modifier.width(4.dp))
|
||||
OutlinedButton(
|
||||
enabled = selectedPaths.isNotEmpty(),
|
||||
@@ -897,7 +866,7 @@ fun FileManagerScreen(
|
||||
selectedPaths = emptySet()
|
||||
},
|
||||
contentPadding = selBarPadding,
|
||||
) { Text(stringResource(R.string.fm_copy), fontSize = 12.sp) }
|
||||
) { Text("Copy", fontSize = 12.sp) }
|
||||
Spacer(Modifier.width(4.dp))
|
||||
OutlinedButton(
|
||||
enabled = selectedPaths.isNotEmpty(),
|
||||
@@ -908,19 +877,19 @@ fun FileManagerScreen(
|
||||
selectedPaths = emptySet()
|
||||
},
|
||||
contentPadding = selBarPadding,
|
||||
) { Text(stringResource(R.string.fm_cut), fontSize = 12.sp) }
|
||||
) { Text("Cut", fontSize = 12.sp) }
|
||||
Spacer(Modifier.width(4.dp))
|
||||
OutlinedButton(
|
||||
enabled = selectedPaths.isNotEmpty(),
|
||||
onClick = { pendingBulkDelete = entries.filter { it.absolutePath in selectedPaths } },
|
||||
contentPadding = selBarPadding,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.error),
|
||||
) { Text(stringResource(R.string.common_delete), color = MaterialTheme.colorScheme.error, fontSize = 12.sp) }
|
||||
) { Text("Delete", color = MaterialTheme.colorScheme.error, fontSize = 12.sp) }
|
||||
Spacer(Modifier.width(4.dp))
|
||||
OutlinedButton(
|
||||
onClick = { selectionMode = false; selectedPaths = emptySet() },
|
||||
contentPadding = selBarPadding,
|
||||
) { Text(stringResource(R.string.fm_done), fontSize = 12.sp) }
|
||||
) { Text("Done", fontSize = 12.sp) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -934,16 +903,16 @@ fun FileManagerScreen(
|
||||
.clickable { performPaste() }
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
) {
|
||||
Icon(Icons.Filled.ContentPaste, stringResource(R.string.fm_paste), tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(18.dp))
|
||||
Icon(Icons.Filled.ContentPaste, "Paste", tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
val what = if (clipboardFiles.size == 1) clipboardFiles.first().name
|
||||
else pluralStringResource(R.plurals.fm_items, clipboardFiles.size, clipboardFiles.size)
|
||||
else "${clipboardFiles.size} items"
|
||||
Text(
|
||||
if (isCutOperation) stringResource(R.string.fm_paste_move_here, what) else stringResource(R.string.fm_paste_here, what),
|
||||
"Paste $what${if (isCutOperation) " (move)" else ""} here",
|
||||
color = MaterialTheme.colorScheme.onBackground, fontSize = 13.sp, modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(onClick = { clipboardFiles = emptyList(); isCutOperation = false }) {
|
||||
Text(stringResource(R.string.common_cancel), color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 12.sp)
|
||||
Text("Cancel", color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -975,7 +944,7 @@ fun FileManagerScreen(
|
||||
isOperationRunning = false
|
||||
operationDeterminate = false
|
||||
loadDirectory(currentDir, resetScroll = false)
|
||||
}) { Text(stringResource(R.string.common_cancel), fontSize = 12.sp) }
|
||||
}) { Text("Cancel", fontSize = 12.sp) }
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
@@ -1001,41 +970,38 @@ fun FileManagerScreen(
|
||||
// shown in pick mode (the themed picker keeps its slim layout). Built each recompose (cheap)
|
||||
// so it tracks the current drive/favourites without stale click lambdas.
|
||||
val fmRailState = rememberRailState("filemanager")
|
||||
fun locItem(label: String, icon: androidx.compose.ui.graphics.vector.ImageVector, dir: File, shortLabel: String? = null) =
|
||||
RailItem(label, icon, !showFavorites && currentRoot.absolutePath == dir.absolutePath, shortLabel = shortLabel) {
|
||||
fun locItem(label: String, icon: androidx.compose.ui.graphics.vector.ImageVector, dir: File) =
|
||||
RailItem(label, icon, !showFavorites && currentRoot.absolutePath == dir.absolutePath) {
|
||||
showFavorites = false; openDrive(dir)
|
||||
}
|
||||
val storageItems = buildList {
|
||||
add(locItem(stringResource(R.string.fm_drive_internal), Icons.Filled.Smartphone, File("/storage/emulated/0")))
|
||||
add(locItem("Internal", Icons.Filled.Smartphone, File("/storage/emulated/0")))
|
||||
drives.filter { it.removable }.forEach { d ->
|
||||
add(RailItem(d.label, Icons.Filled.SdStorage, !showFavorites && currentRoot.absolutePath == d.dir.absolutePath) {
|
||||
showFavorites = false; if (d.readable) openDrive(d.dir)
|
||||
})
|
||||
}
|
||||
// The Linux system the sessions run in: its own / (the desktop's root), once installed.
|
||||
linuxRoot?.let { add(locItem(stringResource(R.string.fm_drive_linux), Icons.Filled.Terminal, it)) }
|
||||
}
|
||||
val quickItems = buildList {
|
||||
File("/storage/emulated/0/Download").takeIf { it.isDirectory }?.let { add(locItem(stringResource(R.string.fm_rail_downloads), Icons.Filled.Download, it, stringResource(R.string.fm_rail_downloads_short))) }
|
||||
File("/storage/emulated/0/Download").takeIf { it.isDirectory }?.let { add(locItem("Downloads", Icons.Filled.Download, it)) }
|
||||
// The ROMs folder chosen on the main screen: what the session shows as /root/ROMs.
|
||||
SessionPrefs.romsDir(context).takeIf { it.isNotEmpty() }?.let(::File)?.takeIf { it.isDirectory }
|
||||
?.let { add(locItem(stringResource(R.string.fm_rail_roms), Icons.Filled.SportsEsports, it)) }
|
||||
// The app's own logs folder: private, which this file manager (the app itself) can read.
|
||||
com.droiddeck.launcher.runtime.LinuxRuntime.logDir(context).takeIf { it.isDirectory }?.let { add(locItem(stringResource(R.string.fm_rail_session_logs), Icons.Filled.Description, it)) }
|
||||
File("/storage/emulated/0/Pictures").takeIf { it.isDirectory }?.let { add(locItem(stringResource(R.string.fm_rail_pictures), Icons.Filled.Image, it)) }
|
||||
?.let { add(locItem("ROMs", Icons.Filled.SportsEsports, it)) }
|
||||
File("/storage/emulated/0/Download/DroidDeck").takeIf { it.isDirectory }?.let { add(locItem("Session logs", Icons.Filled.Description, it)) }
|
||||
File("/storage/emulated/0/Pictures").takeIf { it.isDirectory }?.let { add(locItem("Pictures", Icons.Filled.Image, it)) }
|
||||
}
|
||||
val favItems = remember(favTick) { FavoritesStore.list(context).map(::File).filter { it.exists() } }
|
||||
.map { d -> RailItem(d.name, Icons.Filled.Star, false) { showFavorites = false; openDrive(d) } }
|
||||
val locationSections = buildList {
|
||||
add(RailSection(stringResource(R.string.fm_rail_storage), storageItems))
|
||||
if (quickItems.isNotEmpty()) add(RailSection(stringResource(R.string.fm_rail_quick), quickItems))
|
||||
if (favItems.isNotEmpty()) add(RailSection(stringResource(R.string.fm_rail_favorites), favItems))
|
||||
add(RailSection("STORAGE", storageItems))
|
||||
if (quickItems.isNotEmpty()) add(RailSection("QUICK", quickItems))
|
||||
if (favItems.isNotEmpty()) add(RailSection("FAVORITES", favItems))
|
||||
}
|
||||
|
||||
Row(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||
// The slim picker keeps its layout: no rail.
|
||||
if (!pickMode) {
|
||||
CollapsibleRail(state = fmRailState, title = stringResource(R.string.fm_rail_title), sections = locationSections, outlinedItems = true)
|
||||
CollapsibleRail(state = fmRailState, title = "Files", sections = locationSections, outlinedItems = true)
|
||||
}
|
||||
Box(modifier = Modifier.weight(1f).fillMaxSize()) {
|
||||
// ── Favorites list OR file list ──
|
||||
@@ -1046,7 +1012,7 @@ fun FileManagerScreen(
|
||||
onPinCurrent = {
|
||||
FavoritesStore.add(context, currentDir.absolutePath)
|
||||
favTick++
|
||||
Toast.makeText(context, context.getString(R.string.fm_favorite_added, currentDir.name), Toast.LENGTH_SHORT).show()
|
||||
Toast.makeText(context, "Added \"${currentDir.name}\" to Favorites", Toast.LENGTH_SHORT).show()
|
||||
},
|
||||
onJump = { dir ->
|
||||
showFavorites = false
|
||||
@@ -1055,7 +1021,7 @@ fun FileManagerScreen(
|
||||
onUnpin = { dir ->
|
||||
FavoritesStore.remove(context, dir.absolutePath)
|
||||
favTick++
|
||||
Toast.makeText(context, context.getString(R.string.fm_favorite_removed, dir.name), Toast.LENGTH_SHORT).show()
|
||||
Toast.makeText(context, "Removed \"${dir.name}\" from Favorites", Toast.LENGTH_SHORT).show()
|
||||
},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
@@ -1075,9 +1041,6 @@ fun FileManagerScreen(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(8.dp),
|
||||
) {
|
||||
if (canGoUp) item(key = "..", span = { GridItemSpan(maxLineSpan) }) {
|
||||
ParentFolderRow(compact = compactRows, onTap = { goUp() })
|
||||
}
|
||||
items(shownEntries, key = { it.absolutePath }) { file ->
|
||||
val isFav = remember(file.absolutePath, favTick) {
|
||||
FavoritesStore.isFavorite(context, file.absolutePath)
|
||||
@@ -1132,8 +1095,8 @@ fun FileManagerScreen(
|
||||
showMenuFor = null
|
||||
Toast.makeText(
|
||||
context,
|
||||
if (nowFav) context.getString(R.string.fm_favorite_added, file.name)
|
||||
else context.getString(R.string.fm_favorite_removed, file.name),
|
||||
if (nowFav) "Added \"${file.name}\" to Favorites"
|
||||
else "Removed \"${file.name}\" from Favorites",
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
},
|
||||
@@ -1143,16 +1106,13 @@ fun FileManagerScreen(
|
||||
}
|
||||
} else
|
||||
LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
|
||||
if (canGoUp) item(key = "..") {
|
||||
ParentFolderRow(compact = compactRows, onTap = { goUp() })
|
||||
}
|
||||
if (entries.isEmpty()) {
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier.fillParentMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(stringResource(R.string.fm_empty_directory), color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text("Empty directory", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -1213,8 +1173,8 @@ fun FileManagerScreen(
|
||||
showMenuFor = null
|
||||
Toast.makeText(
|
||||
context,
|
||||
if (nowFav) context.getString(R.string.fm_favorite_added, file.name)
|
||||
else context.getString(R.string.fm_favorite_removed, file.name),
|
||||
if (nowFav) "Added \"${file.name}\" to Favorites"
|
||||
else "Removed \"${file.name}\" from Favorites",
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
},
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package com.droiddeck.launcher.files
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.R
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
@@ -21,9 +19,9 @@ object FileOps {
|
||||
/** Byte-accurate copy progress (copied/total bytes), reported as the copy proceeds. */
|
||||
fun interface ProgressCallback { fun onProgress(copiedBytes: Long, totalBytes: Long) }
|
||||
|
||||
fun formatBytes(context: Context, bytes: Long): String {
|
||||
if (bytes <= 0) return context.getString(R.string.fm_size_zero)
|
||||
val units = arrayOf(context.getString(R.string.fm_unit_bytes), "KB", "MB", "GB", "TB")
|
||||
fun formatBytes(bytes: Long): String {
|
||||
if (bytes <= 0) return "0 bytes"
|
||||
val units = arrayOf("bytes", "KB", "MB", "GB", "TB")
|
||||
val group = (Math.log10(bytes.toDouble()) / Math.log10(1024.0)).toInt().coerceIn(0, units.lastIndex)
|
||||
return String.format(Locale.ENGLISH, "%.2f", bytes / Math.pow(1024.0, group.toDouble())) + " " + units[group]
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.ui.DroidDeckTheme
|
||||
import java.io.File
|
||||
|
||||
@@ -27,15 +26,9 @@ import java.io.File
|
||||
* - [EXTRA_PICKER_TITLE] the header
|
||||
*/
|
||||
class FilePickerActivity : ComponentActivity() {
|
||||
override fun attachBaseContext(newBase: android.content.Context) {
|
||||
super.attachBaseContext(newBase)
|
||||
com.droiddeck.launcher.core.AppLanguage.applyTo(this, newBase)
|
||||
}
|
||||
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
requestedOrientation = com.droiddeck.launcher.session.SessionPrefs.orientation(this)
|
||||
// targetSdk 28: the old storage permission is exactly what lets us list the card.
|
||||
if (checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
|
||||
requestPermissions(arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), 1)
|
||||
@@ -50,14 +43,14 @@ class FilePickerActivity : ComponentActivity() {
|
||||
?.let { File(it) }?.takeIf { it.isDirectory }
|
||||
|
||||
setContent {
|
||||
DroidDeckTheme(appScale = com.droiddeck.launcher.core.AppUiPrefs.scale(this)) {
|
||||
DroidDeckTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize().systemBarsPadding(), color = MaterialTheme.colorScheme.background) {
|
||||
FileManagerScreen(
|
||||
pickMode = true,
|
||||
pickDirMode = pickDir,
|
||||
pickExtensions = extensions,
|
||||
initialDir = initialDir,
|
||||
pickerTitle = title ?: getString(if (pickDir) R.string.fm_choose_folder else R.string.fm_choose_file),
|
||||
pickerTitle = title ?: if (pickDir) "Choose a folder" else "Choose a file",
|
||||
onPick = { file ->
|
||||
setResult(Activity.RESULT_OK, Intent().putExtra(EXTRA_SELECTED_FILE, file.absolutePath))
|
||||
finish()
|
||||
|
||||
@@ -25,8 +25,6 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.droiddeck.launcher.R
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -68,7 +66,7 @@ internal fun FilePropertiesDialog(
|
||||
|
||||
OutlinedAlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.fm_properties)) },
|
||||
title = { Text("Properties") },
|
||||
text = {
|
||||
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
|
||||
// ── Basic info ──
|
||||
@@ -81,14 +79,14 @@ internal fun FilePropertiesDialog(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
PropertyLine(stringResource(R.string.fm_prop_location), file.parent ?: "-")
|
||||
PropertyLine("Location", file.parent ?: "-")
|
||||
PropertyLine(
|
||||
stringResource(R.string.fm_prop_type),
|
||||
if (file.isDirectory) stringResource(R.string.fm_type_folder)
|
||||
else file.extension.uppercase().let { if (it.isBlank()) stringResource(R.string.fm_type_file) else stringResource(R.string.fm_type_ext_file, it) },
|
||||
"Type",
|
||||
if (file.isDirectory) "Folder"
|
||||
else file.extension.uppercase().let { if (it.isBlank()) "File" else "$it file" },
|
||||
)
|
||||
if (!file.isDirectory) PropertyLine(stringResource(R.string.fm_prop_size), FileOps.formatBytes(context, file.length()))
|
||||
PropertyLine(stringResource(R.string.fm_prop_modified), dateFormat.format(Date(file.lastModified())))
|
||||
if (!file.isDirectory) PropertyLine("Size", FileOps.formatBytes(file.length()))
|
||||
PropertyLine("Modified", dateFormat.format(Date(file.lastModified())))
|
||||
|
||||
Spacer(Modifier.height(10.dp))
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
|
||||
@@ -97,8 +95,8 @@ internal fun FilePropertiesDialog(
|
||||
val state = attrs
|
||||
// ── Read-only ── checked when the owner can't write (Wine's FILE_ATTRIBUTE_READONLY).
|
||||
AttributeToggleRow(
|
||||
label = stringResource(R.string.fm_read_only),
|
||||
description = stringResource(R.string.fm_read_only_hint),
|
||||
label = "Read-only",
|
||||
description = "Stops games (and Wine) from overwriting or deleting this file.",
|
||||
checked = state?.readOnly == true,
|
||||
enabled = state != null && !busy,
|
||||
onToggle = { want ->
|
||||
@@ -110,7 +108,7 @@ internal fun FilePropertiesDialog(
|
||||
attrs = attrs?.copy(readOnly = want)
|
||||
onChanged()
|
||||
} else {
|
||||
Toast.makeText(context, R.string.fm_read_only_failed, Toast.LENGTH_SHORT).show()
|
||||
Toast.makeText(context, "Couldn't change Read-only", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -119,10 +117,10 @@ internal fun FilePropertiesDialog(
|
||||
// ── Hidden ── Wine's DOS hidden bit in the user.DOSATTRIB xattr.
|
||||
val hiddenSupported = state?.hiddenSupported == true
|
||||
AttributeToggleRow(
|
||||
label = stringResource(R.string.fm_hidden),
|
||||
label = "Hidden",
|
||||
description = if (state != null && !hiddenSupported)
|
||||
stringResource(R.string.fm_hidden_unsupported)
|
||||
else stringResource(R.string.fm_hidden_hint),
|
||||
"This storage can't store the hidden flag."
|
||||
else "Marks the file hidden in Windows (Wine's hidden attribute).",
|
||||
checked = state?.hidden == true,
|
||||
enabled = state != null && hiddenSupported && !busy,
|
||||
onToggle = { want ->
|
||||
@@ -136,14 +134,14 @@ internal fun FilePropertiesDialog(
|
||||
} else {
|
||||
// The write failed after all - disable the toggle rather than lie.
|
||||
attrs = attrs?.copy(hiddenSupported = false)
|
||||
Toast.makeText(context, R.string.fm_hidden_failed, Toast.LENGTH_SHORT).show()
|
||||
Toast.makeText(context, "Couldn't change Hidden on this storage", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.fm_done)) } },
|
||||
confirmButton = { TextButton(onClick = onDismiss) { Text("Done") } },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Loaded 100 of 707 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user