Files
DroidDeck/.github/workflows/build.yml
T
The412Banner 65fc2a38a3 Cemu (Wii U) from the front end and the desktop, and two session fixes
Cemu never showed a frame on DroidDeck: its Getting Started dialog blocked
every first launch (the folder picker cannot reach ROMs), the session's
controller helper aborted it, and the desktop's Vulkan renderer drew no
cursor. With the Turnip KGSL fix in the driver and the haptic-optional
Cemu in the catalog, this makes the app side complete:

- bannerlator-pad-defaults seeds Cemu's settings.xml once: the Wii U games
  folder, Vulkan, and Cubeb audio on the default device for both the TV
  and the GamePad speaker at full volume (Wipeout 3 plays through the
  GamePad; Cemu's own default is 20 %).
- The Cemu tile launches full screen (-f).
- libfakeinput.so is preloaded into every session process and exported its
  static C++ runtime (~7000 symbols); programs unwound through it and
  aborted (shadPS4 in pthread_exit, Cemu's controller thread). Link with
  --exclude-libs,ALL, and fail the build if the runtime leaks out again.
- On the Vulkan and GLES desktops the pointer worked but no cursor was
  drawn: wlroots hands the cursor to the app's compositor, which draws
  none. WLR_NO_HARDWARE_CURSORS=1 makes labwc composite it itself.

Device-tested on the FIT: Super Mario 3D World in-game at 35-37 fps with
sound, Deus Ex HR DC and Wipeout 3 launch with sound; RPCS3, Dolphin and a
Steam session (FlatOut) unchanged on the same driver.
2026-09-27 16:30:49 -04:00

432 lines
19 KiB
YAML

name: Build APK
run-name: ${{ github.event_name == 'pull_request' && format('building pr {0} on {1}', github.event.pull_request.number, github.event.pull_request.head.sha) || format('building {0} on {1}', github.ref_name, github.sha) }}
on:
push:
branches: [ main ]
paths-ignore:
- '**/*.md'
- 'LICENSE'
- '.gitignore'
pull_request:
branches: [ main ]
types: [ opened, synchronize, reopened, ready_for_review ]
paths-ignore:
- '**/*.md'
- 'LICENSE'
- '.gitignore'
workflow_dispatch:
permissions:
contents: read
pull-requests: write
concurrency:
group: build-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
PRELOAD_CACHE_VERSION: v2
jobs:
build:
name: Build APK
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
runs-on: ubuntu-24.04
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
- name: No private key in the tree
run: |
set -euo pipefail
keys=$(git ls-files | grep -iE '\.(jks|keystore|p12|pfx|pk8|pem|key)$' | grep -vx 'keystore/testkey.p12' || true)
if [ -n "$keys" ]; then
echo "::error::a private key is committed - remove it from the history and make a new one: $keys"
exit 1
fi
- name: Merge onto main, or say what conflicts
if: github.event_name == 'pull_request'
run: |
set -uo pipefail
git config user.name "ci"
git config user.email "ci@users.noreply.github.com"
git fetch --no-tags origin main
BASE=$(git rev-parse --short origin/main)
HEAD=$(git rev-parse --short HEAD)
if git merge --no-ff --no-edit origin/main >/tmp/merge.out 2>&1; then
{
echo "### Mergeable"
echo
echo "\`$HEAD\` merges cleanly onto main (\`$BASE\`). What follows was built from the merged result."
} > /tmp/pr-merge.md
cat /tmp/pr-merge.md >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
FILES=$(git diff --name-only --diff-filter=U)
if [ -z "$FILES" ]; then
{
echo "### ERROR: Could not merge onto main (\`$BASE\`)"
echo
echo '```'
cat /tmp/merge.out
echo '```'
} > /tmp/pr-merge.md
cat /tmp/pr-merge.md >> "$GITHUB_STEP_SUMMARY"
echo "::error::git could not merge main: $(head -3 /tmp/merge.out | tr '\n' ' ')"
exit 1
fi
{
echo "### ERROR: Not mergeable - conflicts with main (\`$BASE\`)"
echo
echo "Files that conflict:"
echo
for f in $FILES; do echo "- \`$f\`"; done
echo
echo "Each conflicting hunk, as git leaves it: \`<<<<<<< HEAD\` is this pull request, \`>>>>>>> origin/main\` is main."
echo
for f in $FILES; do
echo "<details><summary><code>$f</code></summary>"
echo
echo '```'
awk '/^<<<<<<< /{p=1} p{print} /^>>>>>>> /{p=0}' "$f" | head -300
echo '```'
echo "</details>"
echo
done
echo "Not built: rebase or merge main into the branch and push again."
} > /tmp/pr-merge.md
cat /tmp/pr-merge.md >> "$GITHUB_STEP_SUMMARY"
git merge --abort || true
echo "::error::the pull request conflicts with main: $(echo $FILES | tr '\n' ' ')"
exit 1
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- uses: gradle/actions/setup-gradle@v4
with:
cache-cleanup: on-success
- name: Resolve the runner's NDK
run: |
NDK="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"
VERSION=$(sed -n 's/^Pkg.Revision *= *//p' "$NDK/source.properties")
echo "NDK_VERSION=$VERSION" >> "$GITHUB_ENV"
- name: Set the APK build label
id: build-label
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
BUILD_REF: ${{ github.ref_name }}
BUILD_SHA: ${{ github.sha }}
run: |
if [[ -n "$(git status --porcelain)" ]]; then
tree_state=dirty
else
tree_state=clean
fi
echo "DROIDDECK_BUILD_TREE_STATE=$tree_state" >> "$GITHUB_ENV"
if [[ -n "$PR_NUMBER" ]]; then
label="pr${PR_NUMBER} at ${PR_HEAD_SHA:0:7}"
else
label="${BUILD_REF} at ${BUILD_SHA:0:7}"
fi
echo "label=$label" >> "$GITHUB_OUTPUT"
echo "Build: \`$label\`" >> "$GITHUB_STEP_SUMMARY"
- name: Cache the preloads
id: preloads
uses: actions/cache@v4
with:
path: app/src/main/assets/linuxfs
key: preloads-${{ env.PRELOAD_CACHE_VERSION }}-${{ hashFiles('app/src/main/cpp/fakeinput_steam.cpp', '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'
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
sudo apt-get update -qq
sudo apt-get install -y -qq gcc-aarch64-linux-gnu g++-aarch64-linux-gnu >/dev/null
D=app/src/main/assets/linuxfs
mkdir -p "$D"
aarch64-linux-gnu-g++ -shared -fPIC -O2 -Wall -Wno-attributes -Wno-nonnull-compare \
-pthread -std=c++17 -static-libstdc++ -static-libgcc -Wl,--exclude-libs,ALL \
-o "$D/libfakeinput.so" app/src/main/cpp/fakeinput_steam.cpp -ldl
aarch64-linux-gnu-strip --strip-unneeded "$D/libfakeinput.so"
aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -pthread \
-o "$D/libblsession.so" tools/linuxfs/preload/*.c -ldl
aarch64-linux-gnu-strip --strip-unneeded "$D/libblsession.so"
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
[ -f "$f" ] && install -Dm644 "$f" "$D/${f#tools/linuxfs/overlay/}"
done
if [ -f tools/gamescope/release.env ]; then
. tools/gamescope/release.env
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
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
[ -n "$url" ] || continue
curl -fsSL --retry 3 -o mango-pkgs/pkg.tar.zst "$url"
echo "$sum mango-pkgs/pkg.tar.zst" | sha256sum -c -
tar --use-compress-program=unzstd -xf mango-pkgs/pkg.tar.zst -C mango-pkgs
rm mango-pkgs/pkg.tar.zst
done
install -m644 mango-pkgs/usr/bin/mangoapp "$M/mangoapp"
for so in libfmt.so.10 libspdlog.so.1.13 libglfw.so.3 libtraceevent.so.1 libtracefs.so.1; do
cp -L "mango-pkgs/usr/lib/$so" "$M/$so"
done
install -Dm644 tools/mangoapp/mangoapp "$D/usr/local/bin/mangoapp"
rm -rf mango-pkgs
ls -l "$M"
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/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"
- name: Cache PulseAudio 13.0 source
id: pa-src
uses: actions/cache@v4
with:
path: pa13
key: pulseaudio-src-13.0
- name: Fetch PulseAudio 13.0 source
if: steps.pa-src.outputs.cache-hit != 'true'
run: |
set -euo pipefail
curl -fsSL -o pa.tar.gz https://github.com/pulseaudio/pulseaudio/archive/refs/tags/v13.0.tar.gz
mkdir -p pa13 && tar -xzf pa.tar.gz -C pa13 --strip-components=1 && rm pa.tar.gz
- name: Build the AAudio sink into the audio bundle
run: |
set -euo pipefail
export NDK="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"
tools/aaudio-sink/build.sh "$PWD/pa13" "$PWD/sink-out"
rm -rf pa-bundle && mkdir -p pa-bundle
tar --use-compress-program=unzstd -xf app/src/main/assets/pulseaudio.tzst -C pa-bundle
test ! -f pa-bundle/modules/arm64/module-aaudio-sink.so
install -m755 sink-out/module-aaudio-sink.so pa-bundle/modules/arm64/module-aaudio-sink.so
install -m755 sink-out/module-directaudio-sink.so pa-bundle/modules/arm64/module-directaudio-sink.so
(cd pa-bundle && tar --use-compress-program='zstd -19' -cf ../pulseaudio.tzst .)
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
with:
path: |
app/src/main/jniLibs/arm64-v8a/libproot.so
app/src/main/jniLibs/arm64-v8a/libproot-loader.so
key: proot-${{ env.NDK_VERSION }}-${{ hashFiles('tools/proot/**') }}
- name: Build proot
if: steps.proot.outputs.cache-hit != 'true'
run: |
set -euo pipefail
export NDK="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"
tools/proot/build.sh app/src/main/jniLibs/arm64-v8a
- name: Check the preloads
run: |
set -euo pipefail
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
with:
path: |
app/.cxx
app/build/intermediates/cxx
key: cxx-${{ env.NDK_VERSION }}-${{ hashFiles('app/src/main/cpp/**') }}
restore-keys: |
cxx-${{ env.NDK_VERSION }}-
- name: Build
id: build
env:
DROIDDECK_BUILD_LABEL: ${{ steps.build-label.outputs.label }}
run: |
set -o pipefail
./gradlew assembleRelease --console=plain -PndkVersion="$NDK_VERSION" 2>&1 | tee /tmp/build.log
- 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
APK=$(find app/build/outputs/apk -name '*.apk' | head -1)
TOOLS=$(find "$ANDROID_HOME/build-tools" -maxdepth 1 -type d | sort -V | tail -1)
"$TOOLS/zipalign" -p -f 4 "$APK" "$APK.aligned"
"$TOOLS/apksigner" sign \
--ks keystore/testkey.p12 --ks-type PKCS12 --ks-pass pass:android \
--ks-key-alias testkey --key-pass pass:android \
--v1-signing-enabled true --v2-signing-enabled true --v3-signing-enabled true \
--out "$APK" "$APK.aligned"
rm -f "$APK.aligned" "$APK.idsig"
- name: Check the signature
run: |
set -euo pipefail
APK=$(find app/build/outputs/apk -name '*.apk' | head -1)
APKSIGNER=$(find "$ANDROID_HOME/build-tools" -name apksigner | sort -V | tail -1)
"$APKSIGNER" verify --min-sdk-version 21 --verbose --print-certs "$APK" | tee /tmp/sig.txt
unzip -l "$APK" | grep -qE 'META-INF/.*\.(SF|RSA|DSA)$' \
|| { echo "::error::apk carries no JAR signature files"; exit 1; }
for scheme in "v1 scheme (JAR signing): true" "v2 scheme (APK Signature Scheme v2): true" \
"v3 scheme (APK Signature Scheme v3): true"; do
grep -qF "$scheme" /tmp/sig.txt || { echo "::error::apk is not signed with $scheme"; exit 1; }
done
grep -qi "CN=Android, OU=Android, O=Android" /tmp/sig.txt \
|| { echo "::error::apk is not signed with the AOSP testkey"; exit 1; }
- name: Report on the pull request
if: always() && github.event_name == 'pull_request'
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
PR: ${{ github.event.pull_request.number }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
BUILD_OUTCOME: ${{ steps.build.outcome }}
run: |
set -uo pipefail
MARK="<!-- droiddeck-pr-build -->"
{
echo "$MARK"
echo "## Build of this pull request"
echo
[ -f /tmp/pr-merge.md ] && cat /tmp/pr-merge.md
echo
case "$BUILD_OUTCOME" in
success)
echo "### Build passed"
echo
echo "The \`droiddeck-apk\` artifact is [on this run]($RUN_URL), signed with the public test key. After approval, a maintainer can run **PR Release Publish** from \`main\` to create an APK that installs over the release."
;;
failure)
echo "### ERROR: Build failed"
echo
echo "The compiler's errors ([full log]($RUN_URL)):"
echo
echo '```'
{ grep -E '^e: |^w: .*error|error:|CMake Error|fatal error|ninja: error|FAILED|What went wrong|Execution failed for task|Unresolved reference|Type mismatch|Unresolved' /tmp/build.log 2>/dev/null \
| sed -e 's#file:///home/runner/work/[^/]*/[^/]*/##' -e 's#/home/runner/work/[^/]*/[^/]*/##' \
| grep -v '^w: ' | awk '!seen[$0]++' | head -80; } || true
echo '```'
echo
echo "<details><summary>What went wrong (Gradle)</summary>"
echo
echo '```'
awk '/^\* What went wrong/{p=1} /^\* Try:/{p=0} p{print}' /tmp/build.log 2>/dev/null | head -60
echo '```'
echo "</details>"
;;
skipped|cancelled)
echo "### Not built"
echo
echo "The build did not run (see above, or [the run]($RUN_URL))."
;;
esac
echo
} > /tmp/pr-report.md
cat /tmp/pr-report.md >> "$GITHUB_STEP_SUMMARY"
ID=$(gh api "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" --paginate --jq ".[] | select(.body | startswith(\"$MARK\")) | .id" | head -1 || true)
if [ -n "$ID" ]; then
gh api -X PATCH "repos/${GITHUB_REPOSITORY}/issues/comments/${ID}" -F body=@/tmp/pr-report.md >/dev/null
else
gh api -X POST "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" -F body=@/tmp/pr-report.md >/dev/null
fi
- uses: actions/upload-artifact@v4
with:
name: droiddeck-apk
path: app/build/outputs/apk/release/*.apk
if-no-files-found: error
retention-days: 30
compression-level: 0
sign:
needs: build
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
permissions:
contents: read
actions: read
uses: ./.github/workflows/sign.yml
with:
environment: signing-main
variants: '["standard"]'
secrets: inherit