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