diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt index 0dab068d8..47ffb2c93 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt @@ -78,6 +78,7 @@ object SessionFiles { "usr/local/bin/droiddeck-steam-games" to "usr/local/bin/droiddeck-steam-games", "usr/local/bin/droiddeck-pad-defaults" to "usr/local/bin/droiddeck-pad-defaults", "usr/local/bin/droiddeck-steam-x64" to "usr/local/bin/droiddeck-steam-x64", + "usr/local/bin/droiddeck-steamrt-shim" to "usr/local/bin/droiddeck-steamrt-shim", "usr/local/bin/droiddeck-x64-arm-proton" to "usr/local/bin/droiddeck-x64-arm-proton", "usr/local/bin/droiddeck-x64-arm-webhelper" to "usr/local/bin/droiddeck-x64-arm-webhelper", "usr/local/bin/droiddeck-x64-webhelper" to "usr/local/bin/droiddeck-x64-webhelper", diff --git a/docs/development/arm64-steam-client.md b/docs/development/arm64-steam-client.md new file mode 100644 index 000000000..bb1efc04f --- /dev/null +++ b/docs/development/arm64-steam-client.md @@ -0,0 +1,42 @@ +# Native arm64 Steam client: channel and runtime + +The default client is Valve's arm64 Linux build (`steamrtarm64/steam`). Valve does not support it, but +publishes it on the Steam Deck client channels. `droiddeck-steam-install` installs it and +`droiddeck-session` starts it with `STEAM_RUNTIME=1`. + +## Channel + +- The default is `steamdeck_publicbeta` (manifest `steam_client_steamdeck_publicbeta_linuxarm64`). + Valve also publishes `steam_client_steamdeck_stable_linuxarm64`. +- `droiddeck-steam-install --channel` prints the channel in use. The session passes that value as + `-clientbeta` and writes it to `package/beta`. The two must match, or the client loops on rc 42. +- Setting: `BL_STEAM_CHANNEL`, otherwise `~/.config/droiddeck/steam-channel`, set to `beta` or + `stable`. A set channel never changes on its own. +- With no setting, the client falls back to `steamdeck_stable` automatically. It stays on stable + (`package/droiddeck-channel`) until a setting says otherwise. The fallback happens: + - when the client was started two more times without being shown after the start-failure repair + (`package/droiddeck-unconfirmed`: two unconfirmed starts trigger a repair, which writes + `repaired`; two more unconfirmed starts trigger the fallback); + - when the beta manifest has no `*_linuxarm64_linuxarm64` component. The install log then lists the + manifest's block names, so a renamed or removed arm64 build can be seen. +- The x86-64 client keeps `steamdeck_publicbeta` (see [x86-64-steam-client.md](x86-64-steam-client.md)). + +## Steam Runtime container + +Valve's 2026-10-15 beta made the x86 Linux client 64-bit and put it in a Steam Runtime container +(pressure-vessel). The container needs unprivileged user and mount namespaces and a D-Bus session bus, +which an Android app does not have. The x86-64 path already works around this. The arm64 client gets the +same treatment if Valve containerizes it too: + +- `STEAM_STEAMRT_RECURSION_GUARD=1` is exported for both clients. It tells the client that it is + already inside its container, so after an update it does not re-execute itself through + `pressure-vessel-wrap` (bwrap). +- `droiddeck-steamrt-shim ` builds the shim when the client carries + `pv-runtime/steam-runtime-steamrt` or a `steam-runtime-check-requirements` probe: + - Valve's runtime is linked file by file; + - the probe is replaced with `exit 0`; + - its own `steam-runtime-launch-client` runs commands directly. + + For the arm64 client the session then starts it with `STEAM_RUNTIME_STEAMRT` and `PATH` pointing + at `~/.droiddeck-steamrt-shim` and logs "Valve containerized the arm64 Steam client". Without those + files the shim is not built and the start is unchanged. diff --git a/docs/development/x86-64-steam-client.md b/docs/development/x86-64-steam-client.md index f11923fe7..3e0d867a2 100644 --- a/docs/development/x86-64-steam-client.md +++ b/docs/development/x86-64-steam-client.md @@ -53,7 +53,8 @@ arm64 client down first. ("Steam now requires user namespaces") when `steam-runtime-check-requirements`' bwrap probe cannot create a user namespace, which no Android app can. `STEAM_RUNTIME_STEAMRT` points at `steamrt-shim`: Valve's runtime linked file by file with only the probe replaced (`exit 0`). proot - is the container, as for the arm64 client. + is the container, as for the arm64 client. `droiddeck-steamrt-shim` builds it, and builds the same + shim for the arm64 client if that one is containerized too ([arm64-steam-client.md](arm64-steam-client.md)). - **Re-exec after an update.** The client re-executes itself inside the SteamRT container through `pressure-vessel-wrap` (bwrap). `STEAM_STEAMRT_RECURSION_GUARD=1` says it is already inside one; the FEX rootfs with the client's `steamrt64` libraries is that container. diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session index 688d0c19b..c33b78552 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session @@ -403,6 +403,11 @@ case $mode in droiddeck-steam-x64 --sync-login || true fi steam_client=("$steam_root/steamrtarm64/steam") + _bl_srt_shim=$HOME/.droiddeck-steamrt-shim + if droiddeck-steamrt-shim "$steam_root/steamrtarm64" "$_bl_srt_shim"; then + echo "== steam: Valve containerized the arm64 Steam client (it carries a Steam Runtime container); starting it through $_bl_srt_shim" + steam_client=(env STEAM_RUNTIME_STEAMRT="$_bl_srt_shim" PATH="$_bl_srt_shim/bin:$PATH" "${steam_client[@]}") + fi fi _bl_proton_root=$steam_root if [ "$steam_x64" = 1 ]; then @@ -746,10 +751,13 @@ BUSCONF fi # The UI bridge launches SteamVR, which needs the guest agent's library environment. - _bl_compat_env=(env -u LD_LIBRARY_PATH -u STEAM_RUNTIME) + _bl_compat_env=(env -u LD_LIBRARY_PATH -u STEAM_RUNTIME -u STEAM_STEAMRT_RECURSION_GUARD) [ "${LD_LIBRARY_PATH+x}" ] && _bl_compat_env+=("LD_LIBRARY_PATH=$LD_LIBRARY_PATH") [ "${STEAM_RUNTIME+x}" ] && _bl_compat_env+=("STEAM_RUNTIME=$STEAM_RUNTIME") + [ "${STEAM_STEAMRT_RECURSION_GUARD+x}" ] && _bl_compat_env+=("STEAM_STEAMRT_RECURSION_GUARD=$STEAM_STEAMRT_RECURSION_GUARD") export STEAM_RUNTIME=1 + # A containerized client re-executes itself through pressure-vessel-wrap (bwrap) unless told it is already inside. + export STEAM_STEAMRT_RECURSION_GUARD=1 export LD_LIBRARY_PATH=$steam_root/steamrtarm64:$steam_root/steamrtarm64/panorama${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH} export SDL_VIDEO_X11_DGAMOUSE=0 decky_loader=$HOME/homebrew/services/PluginLoader @@ -829,6 +837,9 @@ BUSCONF unset _stale _f fi steam_channel=steamdeck_publicbeta + if [ "$steam_x64" = 0 ]; then + steam_channel=$(droiddeck-steam-install --channel 2>/dev/null || echo steamdeck_publicbeta) + fi _bl_lang=() [ -n "${BL_STEAM_LANGUAGE:-}" ] && _bl_lang=(-language "$BL_STEAM_LANGUAGE") steam_beta=$steam_root/package/beta diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-install b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-install index 3ce263c0d..8ebef7ea5 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-install +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-install @@ -1,9 +1,26 @@ #!/bin/bash set -eu cdn_list="https://client-update.steamstatic.com https://client-update.akamai.steamstatic.com https://client-update.fastly.steamstatic.com" -steam_channel=steamdeck_publicbeta -manifest=steam_client_${steam_channel}_linuxarm64 +beta_channel=steamdeck_publicbeta +stable_channel=steamdeck_stable steam_root=${XDG_DATA_HOME:-$HOME/.local/share}/Steam +channel_state=$steam_root/package/droiddeck-channel +pinned=1 +wanted=${BL_STEAM_CHANNEL:-$(cat "$HOME/.config/droiddeck/steam-channel" 2>/dev/null || true)} +case $wanted in + stable|"$stable_channel") steam_channel=$stable_channel ;; + beta|"$beta_channel") steam_channel=$beta_channel ;; + *) + pinned= + steam_channel=$beta_channel + [ "$(cat "$channel_state" 2>/dev/null)" = "$stable_channel" ] && steam_channel=$stable_channel + ;; +esac +if [ "${1:-}" = --channel ]; then + echo "$steam_channel" + exit 0 +fi +manifest=steam_client_${steam_channel}_linuxarm64 stamp=$steam_root/package/droiddeck-installed legacy_stamp=$steam_root/package/bannerlator-installed unconfirmed=$steam_root/package/droiddeck-unconfirmed @@ -12,11 +29,21 @@ if [ -f "$legacy_stamp" ] && [ ! -f "$stamp" ]; then mv -f "$legacy_stamp" "$stamp" || true fi repair="" +fallback="" +starts=0 +[ -f "$unconfirmed" ] && starts=$(grep -cx start "$unconfirmed" || true) if [ -e "$repair_request" ]; then repair="requested" -elif [ -f "$unconfirmed" ] && ! grep -qx repaired "$unconfirmed" \ - && [ "$(grep -cx start "$unconfirmed")" -ge 2 ]; then - repair="the client was started $(grep -cx start "$unconfirmed") times without showing anything" +elif [ "$starts" -ge 2 ] && ! grep -qx repaired "$unconfirmed"; then + repair="the client was started $starts times without showing anything" +elif [ "$starts" -ge 2 ] && [ -z "$pinned" ] && [ "$steam_channel" = "$beta_channel" ] && [ "${1:-}" != --sync ]; then + repair="the repaired $beta_channel client was started $starts more times without showing anything" + fallback=1 +fi +if [ -n "$fallback" ]; then + echo "== STEP $(date -u +%H:%M:%S) falling back to Valve's $stable_channel Steam client: $repair" + steam_channel=$stable_channel + manifest=steam_client_${steam_channel}_linuxarm64 fi if [ -n "$repair" ]; then echo "== STEP $(date -u +%H:%M:%S) repairing the Steam client ($repair)" @@ -42,20 +69,42 @@ fi work=$(mktemp -d) trap 'rm -rf "$work"' EXIT -cdn="" -for candidate in $cdn_list; do - if curl -fsSL --connect-timeout 10 --max-time 30 --retry 2 --retry-connrefused --retry-delay 3 \ - "$candidate/$manifest" -o "$work/manifest"; then - cdn=$candidate - break +fetch_manifest() { + cdn="" + for candidate in $cdn_list; do + if curl -fsSL --connect-timeout 10 --max-time 30 --retry 2 --retry-connrefused --retry-delay 3 \ + "$candidate/$manifest" -o "$work/manifest"; then + cdn=$candidate + break + fi + done + [ -n "$cdn" ] || { echo "droiddeck-steam-install: no Steam client CDN answered for $manifest" >&2; exit 1; } + cdn_order=("$cdn") + for candidate in $cdn_list; do + [ "$candidate" = "$cdn" ] || cdn_order+=("$candidate") + done + version=$(awk -F'"' '$2 == "version" { print $4; exit }' "$work/manifest") + awk -F'"' ' + /^\t"/ && NF >= 3 && $4 == "" { block = $2 } + $2 == "file" { file[block] = $4 } + $2 == "sha2" { sha[block] = $4 } + END { for (b in file) if (b ~ /_all$/ || b ~ /_linuxarm64_linuxarm64$/) print b, file[b], sha[b] } + ' "$work/manifest" | sort > "$work/parts" + [ -s "$work/parts" ] && return 0 + echo "droiddeck-steam-install: no client components in $manifest; its blocks are:" \ + "$(awk -F'"' '/^\t"/ && NF >= 3 && $4 == "" { printf "%s ", $2 }' "$work/manifest")" >&2 + return 1 +} +if ! fetch_manifest; then + if [ -n "$sync" ] || [ -n "$pinned" ] || [ "$steam_channel" = "$stable_channel" ]; then + exit 1 fi -done -[ -n "$cdn" ] || { echo "droiddeck-steam-install: no Steam client CDN answered for $manifest" >&2; exit 1; } -cdn_order=("$cdn") -for candidate in $cdn_list; do - [ "$candidate" = "$cdn" ] || cdn_order+=("$candidate") -done -version=$(awk -F'"' '$2 == "version" { print $4; exit }' "$work/manifest") + echo "== STEP $(date -u +%H:%M:%S) falling back to Valve's $stable_channel Steam client: $manifest has no arm64 client" + steam_channel=$stable_channel + manifest=steam_client_${steam_channel}_linuxarm64 + fallback=1 + fetch_manifest || exit 1 +fi if [ -n "$sync" ]; then installed=$(awk -F'"' '$2 == "version" { print $4; exit }' "$steam_root/package/$manifest.manifest" 2>/dev/null || true) if [ -x "$steam_root/steamrtarm64/steam" ] && [ "$installed" = "$version" ]; then @@ -64,13 +113,6 @@ if [ -n "$sync" ]; then fi echo "== STEP $(date -u +%H:%M:%S) updating the arm64 Steam client to $version (was ${installed:-none}) for the x86-64 client" fi -awk -F'"' ' - /^\t"/ && NF >= 3 && $4 == "" { block = $2 } - $2 == "file" { file[block] = $4 } - $2 == "sha2" { sha[block] = $4 } - END { for (b in file) if (b ~ /_all$/ || b ~ /_linuxarm64_linuxarm64$/) print b, file[b], sha[b] } -' "$work/manifest" | sort > "$work/parts" -[ -s "$work/parts" ] || { echo "droiddeck-steam-install: no client components in $manifest" >&2; exit 1; } total=$(grep -c . "$work/parts" 2>/dev/null || echo 0) mkdir -p "$steam_root/package" @@ -117,6 +159,10 @@ ln -sfn "$steam_root/linuxarm64" "$HOME/.steam/sdkarm64" ln -sfn "$steam_root/steamrtarm64" "$HOME/.steam/binarm64" ln -sfn "$steam_root/linux32" "$HOME/.steam/sdk32" echo "$version" > "$stamp" +if [ -n "$fallback" ]; then + echo "$stable_channel" > "$channel_state" + echo "== STEP $(date -u +%H:%M:%S) the Steam client now follows $stable_channel (beta in ~/.config/droiddeck/steam-channel returns to $beta_channel)" +fi if [ -n "$repair" ]; then echo repaired > "$unconfirmed" rm -f "$repair_request" diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-x64 b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-x64 index e52a4e985..df9ec941b 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-x64 +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-x64 @@ -520,39 +520,8 @@ ln -sfn "$STEAM_X64/ubuntu12_32" "$X64_HOME/.steam/bin32" ln -sfn "$STEAM_X64/ubuntu12_64" "$X64_HOME/.steam/bin64" ln -sfn "$STEAM_X64/linux32" "$X64_HOME/.steam/sdk32" ln -sfn "$STEAM_X64/linux64" "$X64_HOME/.steam/sdk64" -# The client's bwrap probe (steam-runtime-check-requirements) needs user namespaces, which no Android app has. -srt_real=$STEAM_X64/steamrt64/pv-runtime/steam-runtime-steamrt -srt_shim=$X64_HOME/steamrt-shim -if [ -d "$srt_real" ]; then - rm -rf "$srt_shim" - mkdir -p "$srt_shim/bin" - for e in "$srt_real"/* "$srt_real"/.[!.]*; do - [ -e "$e" ] || continue - [ "$(basename "$e")" = bin ] || ln -s "$e" "$srt_shim/$(basename "$e")" - done - for e in "$srt_real"/bin/*; do ln -s "$e" "$srt_shim/bin/$(basename "$e")"; done - rm -f "$srt_shim/bin/steam-runtime-check-requirements" - printf '#!/bin/sh -exit 0 -' > "$srt_shim/bin/steam-runtime-check-requirements" - chmod 755 "$srt_shim/bin/steam-runtime-check-requirements" - rm -f "$srt_shim/bin/steam-runtime-launch-client" - cat > "$srt_shim/bin/steam-runtime-launch-client" <<'LAUNCH' -#!/bin/sh -while [ $# -gt 0 ]; do - case $1 in - --) shift; break ;; - --directory=*) cd "${1#--directory=}" || exit 127 ;; - --env=*) export "${1#--env=}" ;; - --unset-env=*) unset "${1#--unset-env=}" ;; - esac - shift -done -[ $# -gt 0 ] || exit 0 -exec "$@" -LAUNCH - chmod 755 "$srt_shim/bin/steam-runtime-launch-client" -fi +# Valve's Steam Runtime container needs user namespaces and a session-bus launcher; steamrt-shim stands in for both. +droiddeck-steamrt-shim "$STEAM_X64/steamrt64" "$X64_HOME/steamrt-shim" || true # Valve's platform libraries ship without soname links (pressure-vessel makes them); ldconfig adds them here. srt_libs=$X64_HOME/steamrt-libs rm -rf "$srt_libs" diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steamrt-shim b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steamrt-shim new file mode 100755 index 000000000..96e851bc9 --- /dev/null +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steamrt-shim @@ -0,0 +1,49 @@ +#!/bin/bash +# droiddeck-steamrt-shim +# Builds from the Steam Runtime container the client carries and exits 0, or removes it and exits 1 +# when the client carries none. +set -eu +client=$1 +shim=$2 +srt_real=$client/pv-runtime/steam-runtime-steamrt +if [ ! -d "$srt_real" ]; then + probe=$(find "$client" -maxdepth 4 -name steam-runtime-check-requirements -path '*/bin/*' -print -quit 2>/dev/null || true) + srt_real=${probe:+${probe%/bin/*}} +fi +if [ -z "$srt_real" ] || [ ! -d "$srt_real" ]; then + rm -rf "$shim" + exit 1 +fi +rm -rf "$shim" +mkdir -p "$shim/bin" +for e in "$srt_real"/* "$srt_real"/.[!.]*; do + [ -e "$e" ] || continue + [ "$(basename "$e")" = bin ] || ln -s "$e" "$shim/$(basename "$e")" +done +for e in "$srt_real"/bin/*; do + [ -e "$e" ] || continue + ln -s "$e" "$shim/bin/$(basename "$e")" +done +# The client's bwrap probe (steam-runtime-check-requirements) needs user namespaces, which no Android app has. +rm -f "$shim/bin/steam-runtime-check-requirements" +printf '#!/bin/sh +exit 0 +' > "$shim/bin/steam-runtime-check-requirements" +chmod 755 "$shim/bin/steam-runtime-check-requirements" +# Valve's launcher asks a service on the session bus; proot is already the container, so the command runs here. +rm -f "$shim/bin/steam-runtime-launch-client" +cat > "$shim/bin/steam-runtime-launch-client" <<'LAUNCH' +#!/bin/sh +while [ $# -gt 0 ]; do + case $1 in + --) shift; break ;; + --directory=*) cd "${1#--directory=}" || exit 127 ;; + --env=*) export "${1#--env=}" ;; + --unset-env=*) unset "${1#--unset-env=}" ;; + esac + shift +done +[ $# -gt 0 ] || exit 0 +exec "$@" +LAUNCH +chmod 755 "$shim/bin/steam-runtime-launch-client" diff --git a/tools/tests/test_steam_install.py b/tools/tests/test_steam_install.py index 8692d5649..256e71133 100644 --- a/tools/tests/test_steam_install.py +++ b/tools/tests/test_steam_install.py @@ -56,7 +56,7 @@ cp "$TEST_CDN/${url##*/}" "$out" self.env = dict(os.environ, HOME=str(self.home), XDG_DATA_HOME=str(self.data), PATH=str(self.bin) + os.pathsep + os.environ['PATH'], TEST_CDN=str(self.cdn), TEST_REQUESTS=str(self.requests)) - for channel in ('steamdeck_publicbeta',): + for channel in ('steamdeck_publicbeta', 'steamdeck_stable'): archive = self.cdn / (channel + '.zip') with zipfile.ZipFile(archive, 'w') as package: package.writestr('steamrtarm64/steam', channel) @@ -68,12 +68,17 @@ cp "$TEST_CDN/${url##*/}" "$out" f'\t\t"file" "{archive.name}"\n\t\t"sha2" "{digest}"\n' '\t}\n}\n') - def install(self, fail_host=None): + def install(self, fail_host=None, *args, **extra): env = self.env if fail_host is None else dict(self.env, TEST_FAIL_HOST=fail_host) - return subprocess.run(['bash', str(SCRIPT)], env=env, text=True, capture_output=True) + return subprocess.run(['bash', str(SCRIPT), *args], env=dict(env, **extra), text=True, capture_output=True) - def assert_installed(self, channel='steamdeck_publicbeta', fail_host=None, expected=None): - result = self.install(fail_host) + def channel(self, **extra): + result = self.install(None, '--channel', **extra) + self.assertEqual(0, result.returncode, result.stderr) + return result.stdout.strip() + + def assert_installed(self, channel='steamdeck_publicbeta', fail_host=None, expected=None, **extra): + result = self.install(fail_host, **extra) self.assertEqual(0, result.returncode, result.stderr) self.assertEqual(channel + '\n', (self.steam / 'package/beta').read_text()) self.assertEqual('123456\n', (self.steam / 'package/droiddeck-installed').read_text()) @@ -87,6 +92,7 @@ cp "$TEST_CDN/${url##*/}" "$out" 'https://client-update.steamstatic.com/' + channel + '.zip', ] self.assertEqual(expected, self.requests.read_text().splitlines()) + return result def test_downloads_the_deck_beta_client(self): self.assert_installed() @@ -155,8 +161,80 @@ cp "$TEST_CDN/${url##*/}" "$out" self.requests.unlink() with (package / 'droiddeck-unconfirmed').open('a') as marker: marker.write('start\nstart\nstart\n') + self.assertEqual(0, self.install(BL_STEAM_CHANNEL='beta').returncode) + self.assertFalse(self.requests.exists()) + + def test_the_default_channel_is_the_deck_beta(self): + self.assertEqual('steamdeck_publicbeta', self.channel()) + + def stable_requests(self, beta_manifest=True): + stable = [ + 'https://client-update.steamstatic.com/steam_client_steamdeck_stable_linuxarm64', + 'https://client-update.steamstatic.com/steamdeck_stable.zip', + ] + if beta_manifest: + stable.insert(0, 'https://client-update.steamstatic.com/steam_client_steamdeck_publicbeta_linuxarm64') + return stable + + def test_a_repaired_beta_client_that_still_shows_nothing_falls_back_to_stable(self): + package = self.break_client(2) + self.assertEqual(0, self.install().returncode) + self.requests.unlink() + with (package / 'droiddeck-unconfirmed').open('a') as marker: + marker.write('start\nstart\n') + result = self.assert_installed('steamdeck_stable', expected=self.stable_requests(False)) + self.assertIn("falling back to Valve's steamdeck_stable Steam client", result.stdout) + self.assertEqual('steamdeck_stable\n', (package / 'droiddeck-channel').read_text()) + self.assertEqual('repaired\n', (package / 'droiddeck-unconfirmed').read_text()) + self.assertEqual('steamdeck_stable', self.channel()) + + def test_the_stable_fallback_is_not_repeated(self): + self.test_a_repaired_beta_client_that_still_shows_nothing_falls_back_to_stable() + self.requests.unlink() + with (self.steam / 'package/droiddeck-unconfirmed').open('a') as marker: + marker.write('start\nstart\nstart\n') self.assertEqual(0, self.install().returncode) self.assertFalse(self.requests.exists()) + self.assertEqual('steamdeck_stable\n', (self.steam / 'package/beta').read_text()) + + def test_a_pinned_beta_channel_never_falls_back(self): + package = self.break_client(2) + self.assertEqual(0, self.install().returncode) + with (package / 'droiddeck-unconfirmed').open('a') as marker: + marker.write('start\nstart\n') + self.assertEqual('steamdeck_publicbeta', self.channel(BL_STEAM_CHANNEL='beta')) + self.assertEqual(0, self.install(BL_STEAM_CHANNEL='beta').returncode) + self.assertFalse((package / 'droiddeck-channel').exists()) + + def test_the_channel_setting_selects_stable(self): + setting = self.home / '.config/droiddeck/steam-channel' + setting.parent.mkdir(parents=True) + setting.write_text('stable\n') + self.assertEqual('steamdeck_stable', self.channel()) + self.assert_installed('steamdeck_stable', expected=self.stable_requests(False)) + self.assertFalse((self.steam / 'package/droiddeck-channel').exists()) + + def empty_beta_manifest(self): + (self.cdn / 'steam_client_steamdeck_publicbeta_linuxarm64').write_text( + '"linuxarm64"\n{\n\t"version" "123457"\n' + '\t"client_steamrtarm64_linuxarm64"\n\t{\n' + '\t\t"file" "client.zip"\n\t\t"sha2" "00"\n' + '\t}\n}\n') + + def test_a_beta_manifest_without_an_arm64_client_falls_back_to_stable(self): + self.empty_beta_manifest() + result = self.assert_installed('steamdeck_stable', expected=self.stable_requests()) + self.assertIn('no client components in steam_client_steamdeck_publicbeta_linuxarm64', result.stderr) + self.assertIn('client_steamrtarm64_linuxarm64', result.stderr) + self.assertEqual('steamdeck_stable\n', (self.steam / 'package/droiddeck-channel').read_text()) + self.assertEqual('steamdeck_stable', self.channel()) + + def test_a_pinned_beta_manifest_without_an_arm64_client_fails_and_names_its_blocks(self): + self.empty_beta_manifest() + result = self.install(BL_STEAM_CHANNEL='beta') + self.assertNotEqual(0, result.returncode) + self.assertIn('client_steamrtarm64_linuxarm64', result.stderr) + self.assertFalse((self.steam / 'package/droiddeck-installed').exists()) def test_requested_repair_runs_once_and_is_retried_until_it_completes(self): package = self.break_client(0) diff --git a/tools/tests/test_steamrt_shim.py b/tools/tests/test_steamrt_shim.py new file mode 100644 index 000000000..54f105eeb --- /dev/null +++ b/tools/tests/test_steamrt_shim.py @@ -0,0 +1,117 @@ +import os +from pathlib import Path +import re +import subprocess +import tempfile +import unittest + + +BIN = Path(__file__).resolve().parents[1] / 'linuxfs/overlay/usr/local/bin' +SHIM = BIN / 'droiddeck-steamrt-shim' +SESSION = (BIN / 'droiddeck-session').read_text() +X64 = (BIN / 'droiddeck-steam-x64').read_text() + + +class SteamrtShimTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.client = self.root / 'steamrtarm64' + self.client.mkdir() + self.shim = self.root / 'shim' + + def runtime(self, path): + runtime = self.client / path + (runtime / 'bin').mkdir(parents=True) + (runtime / 'bin/steam-runtime-check-requirements').write_text('#!/bin/sh\nexit 1\n') + (runtime / 'bin/steam-runtime-system-info').write_text('#!/bin/sh\necho info\n') + (runtime / 'files').mkdir() + (runtime / 'files/marker').write_text('runtime files') + return runtime + + def build(self): + return subprocess.run(['bash', str(SHIM), str(self.client), str(self.shim)], + capture_output=True, text=True, timeout=30) + + def sh(self, *args): + return subprocess.run(['sh', *map(str, args)], capture_output=True, text=True, timeout=30) + + def assert_shim(self): + result = self.build() + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual(0, self.sh(self.shim / 'bin/steam-runtime-check-requirements').returncode) + self.assertEqual('info\n', self.sh(self.shim / 'bin/steam-runtime-system-info').stdout) + self.assertEqual('runtime files', (self.shim / 'files/marker').read_text()) + launched = self.sh(self.shim / 'bin/steam-runtime-launch-client', '--env=BL_SHIM_TEST=yes', + f'--directory={self.root}', '--', 'sh', '-c', 'echo "$BL_SHIM_TEST $(basename "$PWD")"') + self.assertEqual(f'yes {self.root.name}\n', launched.stdout) + + def test_a_client_without_a_container_gets_no_shim(self): + self.shim.mkdir() + self.assertEqual(1, self.build().returncode) + self.assertFalse(self.shim.exists()) + + def test_the_pressure_vessel_runtime_is_shimmed(self): + self.runtime('pv-runtime/steam-runtime-steamrt') + self.assert_shim() + + def test_a_requirements_probe_elsewhere_is_shimmed(self): + self.runtime('steam-runtime') + self.assert_shim() + + def test_the_arm64_session_starts_a_containerized_client_through_the_shim(self): + block = re.search(r'\n( +steam_client=\("\$steam_root/steamrtarm64/steam"\)\n.*?\n fi\n)', SESSION, re.S).group(1) + stub = self.root / 'droiddeck-steamrt-shim' + for status, shimmed in ((1, False), (0, True)): + stub.write_text(f'#!/bin/bash\nexit {status}\n') + stub.chmod(0o755) + script = 'if :; then\n' + block + 'printf "%s\\n" "${steam_client[@]}"\n' + env = dict(os.environ, HOME=str(self.root), steam_root='/steam', + PATH=str(self.root) + os.pathsep + os.environ['PATH']) + result = subprocess.run(['bash', '-c', script], env=env, capture_output=True, text=True, timeout=30) + self.assertEqual(0, result.returncode, result.stderr) + lines = result.stdout.splitlines() + self.assertEqual('/steam/steamrtarm64/steam', lines[-1]) + self.assertEqual(shimmed, 'Valve containerized the arm64 Steam client' in result.stdout) + self.assertEqual(shimmed, any(line.startswith('STEAM_RUNTIME_STEAMRT=') and + line.endswith('/.droiddeck-steamrt-shim') for line in lines)) + + def test_both_clients_are_told_they_already_run_inside_the_container(self): + guard = SESSION.index('export STEAM_STEAMRT_RECURSION_GUARD=1') + self.assertLess(guard, SESSION.index('"${steam_client[@]}" "${_bl_ui[@]}"')) + self.assertIn('-u STEAM_STEAMRT_RECURSION_GUARD', SESSION[:guard]) + self.assertIn('export STEAM_STEAMRT_RECURSION_GUARD=1', X64) + self.assertIn('droiddeck-steamrt-shim "$STEAM_X64/steamrt64" "$X64_HOME/steamrt-shim"', X64) + + +class SessionChannelTest(unittest.TestCase): + BLOCK = re.search(r'\n( +steam_channel=steamdeck_publicbeta\n.*?\n +fi\n)', SESSION, re.S).group(1) + + def channel(self, x64, installer): + with tempfile.TemporaryDirectory() as temp: + stub = Path(temp) / 'droiddeck-steam-install' + stub.write_text('#!/bin/bash\n' + installer + '\n') + stub.chmod(0o755) + env = dict(os.environ, steam_x64=x64, PATH=temp + os.pathsep + os.environ['PATH']) + result = subprocess.run(['bash', '-c', self.BLOCK + 'echo "$steam_channel"\n'], env=env, + capture_output=True, text=True, timeout=30) + self.assertEqual(0, result.returncode, result.stderr) + return result.stdout.strip() + + def test_the_arm64_client_follows_the_installed_channel(self): + self.assertEqual('steamdeck_stable', self.channel('0', '[ "$1" = --channel ] && echo steamdeck_stable')) + + def test_an_installer_without_an_answer_keeps_the_deck_beta(self): + self.assertEqual('steamdeck_publicbeta', self.channel('0', 'exit 1')) + + def test_the_x86_client_keeps_its_own_channel(self): + self.assertEqual('steamdeck_publicbeta', self.channel('1', 'echo steamdeck_stable')) + + def test_the_client_branch_on_disk_and_clientbeta_use_the_same_channel(self): + self.assertIn('-clientbeta "$steam_channel"', SESSION) + self.assertIn('echo "$steam_channel" > "$steam_beta"', SESSION) + + +if __name__ == '__main__': + unittest.main()