From 4031f512fc227adb4f4e5a6bb45dc88933105d61 Mon Sep 17 00:00:00 2001 From: The412Banner Date: Sat, 19 Sep 2026 14:07:06 -0400 Subject: [PATCH] Cache everything CI can, and credit both authors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repo is private, so each run is billed. Three caches now carry a rebuild: Gradle's own through setup-gradle (dependencies, build cache, configuration cache), the CMake/ninja tree keyed on the C sources and the NDK, and the two cross-compiled preloads — the last of which also skips installing the cross toolchain, since a run that changes only Kotlin has no reason to touch a C compiler. Superseded pushes are cancelled, documentation pushes do not build, and the apk is stored rather than recompressed. The preload assertions moved from the cross binutils to readelf, which reads any ELF whatever the host, so they keep running on a cache hit. Credits name The412Banner and maxjivi05: the runtime under all of this is Max's gamescope work from WinNative. --- .github/workflows/build.yml | 121 +++++++++++++----- README.md | 6 + .../the412banner/steamdeck/MainActivity.kt | 7 + app/src/main/res/layout/activity_main.xml | 10 ++ app/src/main/res/values/strings.xml | 3 + gradle.properties | 7 + 6 files changed, 122 insertions(+), 32 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3dd7c09e7..1dd6b5517 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,15 +2,36 @@ name: Build APK # One job: the two glibc preload libraries the session needs, then the app around them. # Nothing is built on a developer machine — this workflow is the only thing that produces an apk. +# +# This repo is private, so every minute here is billed. Three caches carry the cost of a rebuild +# down to what actually changed: +# • Gradle's own (dependencies, wrapper, build cache, configuration cache) via setup-gradle +# • the CMake/ninja tree, so a Kotlin-only change does not recompile the compositor +# • the two cross-compiled preloads, which also skips installing the cross toolchain +# A run that changes only Kotlin should not touch the C compiler at all. on: push: branches: [ main ] + # Documentation changes cannot alter the apk, so they do not buy a build. + paths-ignore: + - '**/*.md' + - 'LICENSE' + - '.gitignore' workflow_dispatch: permissions: contents: read +# A newer push makes the running build pointless; stop paying for it. +concurrency: + group: build-${{ github.ref }} + cancel-in-progress: true + +env: + # Bump to force the preload cache to miss (e.g. after changing a compiler flag below). + PRELOAD_CACHE_VERSION: v1 + jobs: build: runs-on: ubuntu-24.04 @@ -23,14 +44,33 @@ jobs: distribution: temurin java-version: '17' + # Caches ~/.gradle (dependencies, wrapper distribution, build cache, configuration cache) + # and prunes what the run did not use, so the entry cannot grow without bound. - 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 at $NDK" + echo "NDK_VERSION=$VERSION" >> "$GITHUB_ENV" # ── The session's preloads ─────────────────────────────────────────────────────────── # Both are glibc aarch64 libraries that live inside the rootfs, so they are cross-compiled # here rather than by the NDK. They ship in assets (not in the runtime tarball) so a fix in # either reaches an already-installed runtime on the next launch instead of waiting on a - # ~790 MB runtime re-host. + # ~790 MB runtime re-host. Their inputs change rarely, so most runs restore them. + - 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.cpp', 'tools/linuxfs/preload/*.c', 'tools/linuxfs/overlay/usr/local/bin/*') }} + - name: Build libfakeinput.so and libblsession.so (glibc aarch64) + if: steps.preloads.outputs.cache-hit != 'true' run: | set -euo pipefail sudo apt-get update -qq @@ -44,42 +84,57 @@ jobs: -pthread -std=c++17 -static-libstdc++ -static-libgcc \ -o "$D/libfakeinput.so" app/src/main/cpp/fakeinput.cpp -ldl aarch64-linux-gnu-strip --strip-unneeded "$D/libfakeinput.so" - NEED=$(aarch64-linux-gnu-readelf -d "$D/libfakeinput.so" | sed -n 's/.*NEEDED.*\[\(.*\)\]/\1/p') - echo "libfakeinput NEEDED: $NEED" - 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 - # The interposers are the whole point: if one is missing the client sees no controller. - SYMS=$(aarch64-linux-gnu-nm -D --defined-only "$D/libfakeinput.so" | awk '{print $NF}') - for sym in open openat ioctl read close poll ppoll select stat fstat access scandir; do - grep -qx "$sym" <<<"$SYMS" || { echo "::error::libfakeinput.so does not export $sym"; exit 1; } - done - 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 sym in socket bind getsockname setsockopt; do - aarch64-linux-gnu-nm -D --defined-only "$D/libblsession.so" | awk '{print $NF}' \ - | grep -qx "$sym" || { echo "::error::libblsession.so does not export $sym"; exit 1; } - done - # The session's scripts travel the same way, for the same reason. for script in tools/linuxfs/overlay/usr/local/bin/bannerlator-*; do install -Dm644 "$script" "$D/usr/local/bin/$(basename "$script")" done ls -lR "$D" - - name: Resolve the runner's NDK + # Runs whether the preloads were built or restored: readelf reads any ELF whatever the host, + # so these assertions cost nothing and need no cross toolchain. + - name: Check the preloads 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 at $NDK" - echo "NDK_VERSION=$VERSION" >> "$GITHUB_ENV" + set -euo pipefail + D=app/src/main/assets/linuxfs + NEED=$(readelf -d "$D/libfakeinput.so" | sed -n 's/.*NEEDED.*\[\(.*\)\]/\1/p') + echo "libfakeinput NEEDED: $NEED" + 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}'; } + # The interposers are the whole point: if one is missing the client sees no controller. + 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 + SESSION=$(syms "$D/libblsession.so") + for sym in socket bind getsockname setsockopt; do + grep -qx "$sym" <<<"$SESSION" || { echo "::error::libblsession.so does not export $sym"; exit 1; } + done + test -f "$D/usr/local/bin/bannerlator-session" + echo "preloads check out" + + # ── The native build ───────────────────────────────────────────────────────────────── + # The compositor and adrenotools are the slowest part of a build and change least often. + # The key pins the C sources and the NDK; the restore-key still hands an older tree to + # ninja, which then rebuilds only the files that differ. + - 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 - run: ./gradlew --no-daemon assembleDebug -PndkVersion="$NDK_VERSION" + run: ./gradlew assembleDebug -PndkVersion="$NDK_VERSION" - name: Describe the apk run: | @@ -90,13 +145,6 @@ jobs: sha256sum "$APK" unzip -l "$APK" | grep -E 'lib/arm64-v8a|assets/linuxfs' | head -30 - - uses: actions/upload-artifact@v4 - with: - name: steamdeck-apk - path: app/build/outputs/apk/debug/*.apk - if-no-files-found: error - retention-days: 30 - # Every NEEDED of every library we ship must resolve inside the apk or on the device. The # first build shipped libwayland-server.so without libffi.so, and the app died on # System.loadLibrary the moment a session started — a check this step would have caught. @@ -122,3 +170,12 @@ jobs: done [ "$fail" = 0 ] && echo "every NEEDED resolves" exit $fail + + - uses: actions/upload-artifact@v4 + with: + name: steamdeck-apk + path: app/build/outputs/apk/debug/*.apk + if-no-files-found: error + retention-days: 30 + # An apk is a zip already; compressing it again buys nothing and costs seconds. + compression-level: 0 diff --git a/README.md b/README.md index 108f0ab98..c76ba3c33 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,12 @@ Files in `/sdcard/Download`, for a device that cannot be reached with a debugger Session logs land in `/sdcard/Download/SteamDeck/`. +## Authors + +- **The412Banner** — the app, the Wayland compositor, the runtime plumbing and the Steam session. +- **maxjivi05 (Max)** — the gamescope runtime this is built on: the proot session, the session + shim, the fake-evdev interposer and the controller work, from WinNative. + ## Lineage and licence GPL-3.0. The gamescope runtime, the session shim and the fake-evdev interposer come from diff --git a/app/src/main/java/com/the412banner/steamdeck/MainActivity.kt b/app/src/main/java/com/the412banner/steamdeck/MainActivity.kt index 0e56ee8fc..55948ade4 100644 --- a/app/src/main/java/com/the412banner/steamdeck/MainActivity.kt +++ b/app/src/main/java/com/the412banner/steamdeck/MainActivity.kt @@ -44,6 +44,13 @@ class MainActivity : Activity() { playButton.setOnClickListener { startActivity(Intent(this, SessionActivity::class.java)) } runtimeButton.setOnClickListener { onRuntimeButton() } + findViewById(R.id.credits).setOnClickListener { + AlertDialog.Builder(this) + .setTitle(R.string.credits_title) + .setMessage(R.string.credits_body) + .setPositiveButton(android.R.string.ok, null) + .show() + } // The session's logs land in Downloads so a failed run can be handed over as a folder // rather than dug out of app-private storage. targetSdk 28 means the old permission still diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index a285a544f..e8e370a5f 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -63,4 +63,14 @@ android:layout_height="wrap_content" android:layout_marginTop="8dp" android:text="@string/install_runtime" /> + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 538f28011..f73e844dd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -18,4 +18,7 @@ Install failed — nothing was changed Starting the session… The session ended (%1$d)\n%2$s + by The412Banner and maxjivi05 · credits + Credits + SteamDeck\n\nThe412Banner — the app, the Wayland compositor, the runtime and the Steam session.\n\nmaxjivi05 (Max) — the gamescope runtime this is built on: the proot session, the session shim, the fake-evdev interposer and the controller work, from WinNative.\n\nGPL-3.0. Valve, Steam, Steam Deck and Proton are trademarks of Valve Corporation; this project is not affiliated with Valve. gamescope, Mesa, Turnip, Xwayland, PulseAudio, proot and Arch Linux ARM are their authors\' own. diff --git a/gradle.properties b/gradle.properties index 2cbf5f77c..c9aa3abf6 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,11 @@ org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8 +# CI is the only thing that builds this repo and its minutes are billed, so every cache Gradle +# offers is on: task outputs are reused across runs from the cache the CI action restores, the +# configuration phase is skipped when nothing about the build scripts changed, and independent +# tasks run together. +org.gradle.caching=true +org.gradle.configuration-cache=true +org.gradle.parallel=true android.useAndroidX=true android.nonTransitiveRClass=true # The NDK the compositor is built with. CI passes the one the runner has installed