Deck mode made safe, a client-branch choice, and a battery the client can see

Deck mode passes -steamdeck alone: -steamos3 opted the install into the steamdeck_stable
branch and started the restart loop Bannerlator hit on device (38996ea1). The SteamOS helpers
the client calls in that mode - the two Armada found it needs under steamos-polkit-helpers and
the three under /usr/bin - are staged from the apk as no-op stubs. The client branch on disk is
kept in step with the one forced on the command line, and an update that cannot install stops
the session after three restarts instead of looping for ever.

The Steam cog gets a Client branch row: public beta (every session so far) or Steam Deck
public beta, the channel Armada bootstraps from.

The client reads /sys/class/power_supply/BAT<n>/; Android's supply is called battery and laid
out differently, so the Quick Access Menu had no battery. BatteryComponent writes BAT0 and BAT1
from Android's battery API every five seconds - status, percentage, charge, energy, power and
time to empty - into a directory bound over /sys/class/power_supply.
This commit is contained in:
The412Banner committed 2026-09-23 09:11:54 -04:00
1 parent a4e59986a8
commit 2fb79735c3
14 files changed
+238 -10

No files matched your search

+5 -1
View File
@@ -150,7 +150,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/overlay/usr/local/bin/*', 'tools/linuxfs/desktop/*') }}
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/*') }}
- name: Build libfakeinput.so and libblsession.so (glibc aarch64)
if: steps.preloads.outputs.cache-hit != 'true'
@@ -174,6 +174,10 @@ jobs:
for script in tools/linuxfs/overlay/usr/local/bin/bannerlator-*; do
install -Dm644 "$script" "$D/usr/local/bin/$(basename "$script")"
done
# The SteamOS helpers the client calls in Deck mode, as no-op stubs (see each file).
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
# And the desktop's launcher and labwc defaults, staged over the desktop package's copies.
install -Dm644 tools/linuxfs/desktop/steamdeck-desktop "$D/usr/local/bin/steamdeck-desktop"
install -Dm644 tools/linuxfs/desktop/autostart "$D/etc/xdg/labwc/autostart"
@@ -123,6 +123,7 @@ class MainActivity : ComponentActivity() {
private var settingsMode by mutableStateOf<String?>(null)
private var resolutionCap by mutableStateOf(1080)
private var fexPreset by mutableStateOf("")
private var steamChannel by mutableStateOf("publicbeta")
private var theme by mutableStateOf("paper")
private var shapeMode by mutableStateOf(SessionPrefs.SHAPE_AUTO)
private var hdrOn by mutableStateOf(false)
@@ -369,6 +370,7 @@ class MainActivity : ComponentActivity() {
gameStorage = if (mode == SessionService.MODE_STEAM) gameStorage else null,
storageOptions = storageOptions,
fexPreset = if (mode == SessionService.MODE_STEAM) fexPreset else null,
steamChannel = if (mode == SessionService.MODE_STEAM) steamChannel else null,
),
ModeSettingsActions(
onResolution = { cap -> SessionPrefs.setResolutionCap(this, mode, cap); resolutionCap = cap },
@@ -398,6 +400,7 @@ class MainActivity : ComponentActivity() {
pickGameStorage.launch(InAppFilePicker.buildDirIntent(this, "Choose the game storage folder", gameStorage.ifEmpty { null }))
},
onFexPreset = { id -> SessionPrefs.setFexPreset(this, id); fexPreset = id },
onSteamChannel = { id -> SessionPrefs.setSteamChannel(this, id); steamChannel = id },
onDismiss = { settingsMode = null },
),
)
@@ -435,6 +438,7 @@ class MainActivity : ComponentActivity() {
refreshDrivers()
resolutionCap = SessionPrefs.resolutionCap(this, mode)
fexPreset = SessionPrefs.fexPreset(this)
steamChannel = SessionPrefs.steamChannel(this)
shapeMode = SessionPrefs.shapeMode(this)
hdrOn = SessionPrefs.hdr(this, mode)
hdrReason = com.steamdeck.launcher.wayland.HdrSupport.probe(this).reason
@@ -0,0 +1,130 @@
package com.steamdeck.launcher.session
import android.content.Intent
import android.content.IntentFilter
import android.os.BatteryManager
import android.util.Log
import com.steamdeck.launcher.core.EnvironmentComponent
import java.io.File
import kotlin.math.abs
/**
* The battery the Steam client can see.
*
* The client reads `/sys/class/power_supply/BAT<n>/...` - a laptop's or a Deck's naming and files
* (`energy_now`, `power_now`, `time_to_empty_now`, a `uevent` of `POWER_SUPPLY_*` lines). Android
* names its supply `battery` and lays it out differently, and on many phones an app may not read
* it at all, so the client showed no battery. This writes a `BAT0` (and a `BAT1`, the Deck's name)
* from Android's own battery API - the same on every device - into a directory the session binds
* over `/sys/class/power_supply`, refreshed every few seconds while the session runs.
*
* Values: charge in µAh from the charge counter; energy in µWh from charge × voltage; power in µW
* from |current| × voltage; time to empty from charge ÷ |average current| while discharging. Where
* a device reports no current, the client still gets status and percentage.
*/
class BatteryComponent(val dir: File) : EnvironmentComponent() {
@Volatile private var running = false
private var thread: Thread? = null
override fun start() {
running = true
write()
thread = Thread({
while (running) {
try { Thread.sleep(PERIOD_MS) } catch (e: InterruptedException) { break }
if (running) write()
}
}, "battery-sysfs").apply { isDaemon = true; start() }
Log.i(TAG, "battery: BAT0/BAT1 written to ${dir.path} every ${PERIOD_MS / 1000} s")
}
override fun stop() {
running = false
thread?.interrupt()
thread = null
}
private fun write() {
val ctx = context ?: return
try {
val bm = ctx.getSystemService(BatteryManager::class.java)
val sticky: Intent? = ctx.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
val status = sticky?.getIntExtra(BatteryManager.EXTRA_STATUS, BatteryManager.BATTERY_STATUS_UNKNOWN) ?: BatteryManager.BATTERY_STATUS_UNKNOWN
val level = sticky?.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) ?: -1
val scale = sticky?.getIntExtra(BatteryManager.EXTRA_SCALE, 100)?.takeIf { it > 0 } ?: 100
val pct = if (level >= 0) level * 100 / scale else (bm?.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY) ?: 50)
val voltageUv = ((sticky?.getIntExtra(BatteryManager.EXTRA_VOLTAGE, 0) ?: 0).toLong() * 1000L).takeIf { it > 0 } ?: 3_800_000L
val tempDeci = sticky?.getIntExtra(BatteryManager.EXTRA_TEMPERATURE, 0) ?: 0
val tech = sticky?.getStringExtra(BatteryManager.EXTRA_TECHNOLOGY)?.takeIf { it.isNotBlank() } ?: "Li-ion"
val chargeNowUah = bm?.getLongProperty(BatteryManager.BATTERY_PROPERTY_CHARGE_COUNTER)?.takeIf { it > 0 && it != Long.MIN_VALUE } ?: 0L
val currentNowUa = bm?.getLongProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_NOW)?.takeIf { it != Long.MIN_VALUE } ?: 0L
val currentAvgUa = bm?.getLongProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_AVERAGE)?.takeIf { it != Long.MIN_VALUE && it != 0L } ?: currentNowUa
// Android's sign convention varies by vendor; the status says which way the current flows.
val discharging = status == BatteryManager.BATTERY_STATUS_DISCHARGING || status == BatteryManager.BATTERY_STATUS_NOT_CHARGING
val chargeFullUah = if (chargeNowUah > 0 && pct > 0) chargeNowUah * 100 / pct else 0L
val energyNowUwh = chargeNowUah * voltageUv / 1_000_000L
val energyFullUwh = chargeFullUah * voltageUv / 1_000_000L
val powerNowUw = abs(currentNowUa) * voltageUv / 1_000_000L
val timeToEmptyS = if (discharging && chargeNowUah > 0 && abs(currentAvgUa) > 0) chargeNowUah * 3600L / abs(currentAvgUa) else 0L
val statusText = when (status) {
BatteryManager.BATTERY_STATUS_CHARGING -> "Charging"
BatteryManager.BATTERY_STATUS_DISCHARGING -> "Discharging"
BatteryManager.BATTERY_STATUS_FULL -> "Full"
BatteryManager.BATTERY_STATUS_NOT_CHARGING -> "Not charging"
else -> "Unknown"
}
val capacityLevel = when {
status == BatteryManager.BATTERY_STATUS_FULL || pct >= 100 -> "Full"
pct <= 5 -> "Critical"
pct <= 15 -> "Low"
else -> "Normal"
}
for (name in listOf("BAT0", "BAT1")) {
val bat = File(dir, name).apply { mkdirs() }
val attrs = linkedMapOf(
"type" to "Battery",
"present" to "1",
"status" to statusText,
"capacity" to pct.toString(),
"capacity_level" to capacityLevel,
"technology" to tech,
"voltage_now" to voltageUv.toString(),
"voltage_min_design" to voltageUv.toString(),
"current_now" to currentNowUa.toString(),
"current_avg" to currentAvgUa.toString(),
"charge_now" to chargeNowUah.toString(),
"charge_full" to chargeFullUah.toString(),
"charge_full_design" to chargeFullUah.toString(),
"energy_now" to energyNowUwh.toString(),
"energy_full" to energyFullUwh.toString(),
"energy_full_design" to energyFullUwh.toString(),
"power_now" to powerNowUw.toString(),
"time_to_empty_now" to timeToEmptyS.toString(),
"time_to_empty_avg" to timeToEmptyS.toString(),
"temp" to tempDeci.toString(),
"model_name" to android.os.Build.MODEL.replace(' ', '_'),
"manufacturer" to android.os.Build.MANUFACTURER.replace(' ', '_'),
"scope" to "System",
)
for ((k, v) in attrs) put(File(bat, k), v + "\n")
val uevent = StringBuilder("POWER_SUPPLY_NAME=$name\n")
for ((k, v) in attrs) uevent.append("POWER_SUPPLY_").append(k.uppercase()).append('=').append(v).append('\n')
put(File(bat, "uevent"), uevent.toString())
}
} catch (t: Throwable) {
Log.w(TAG, "battery: could not write", t)
}
}
/** Atomic per file: a reader in the guest never sees a half-written value. */
private fun put(target: File, text: String) {
val tmp = File(target.parentFile, "." + target.name + ".tmp")
tmp.writeText(text)
if (!tmp.renameTo(target)) { target.writeText(text); tmp.delete() }
}
companion object {
private const val TAG = "SessionService"
private const val PERIOD_MS = 5_000L
}
}
@@ -37,6 +37,13 @@ object SessionFiles {
"usr/local/bin/bannerlator-steam-launch" to "usr/local/bin/bannerlator-steam-launch",
"usr/local/bin/bannerlator-desktop-games" to "usr/local/bin/bannerlator-desktop-games",
"usr/local/bin/bannerlator-steam-shim" to "usr/local/bin/bannerlator-steam-shim",
// The SteamOS helpers the client calls in Deck mode: the two Armada found it needs, plus
// the three under /usr/bin, all no-ops that answer "nothing to do" (see each file).
"usr/bin/steamos-update" to "usr/bin/steamos-update",
"usr/bin/steamos-select-branch" to "usr/bin/steamos-select-branch",
"usr/bin/jupiter-biosupdate" to "usr/bin/jupiter-biosupdate",
"usr/bin/steamos-polkit-helpers/steamos-priv-write" to "usr/bin/steamos-polkit-helpers/steamos-priv-write",
"usr/bin/steamos-polkit-helpers/steamos-set-timezone" to "usr/bin/steamos-polkit-helpers/steamos-set-timezone",
)
// The desktop's launcher and labwc defaults, only where the desktop package is installed:
// staging them into a runtime without it would make the desktop look present when it is not.
@@ -220,6 +220,13 @@ object SessionPrefs {
prefs(context).edit().putString("fexPreset", id).apply()
}
/** The Steam client branch forced on the command line: "publicbeta" (every session so far) or "steamdeck_publicbeta" (Armada's). */
fun steamChannel(context: Context): String = prefs(context).getString("steamChannel", "publicbeta") ?: "publicbeta"
fun setSteamChannel(context: Context, id: String) {
prefs(context).edit().putString("steamChannel", id).apply()
}
/** The app's colour theme (ui/Themes ids); Paper on black unless chosen otherwise. */
fun theme(context: Context): String = prefs(context).getString("theme", "paper") ?: "paper"
@@ -201,6 +201,7 @@ class SessionService : Service() {
if (SessionPrefs.glThread(this)) guest.add("mesa_glthread=true")
if (SessionPrefs.noGlError(this)) guest.add("MESA_NO_ERROR=1")
if (SessionState.mode == MODE_STEAM) guest.add("BL_STEAMDECK=" + (if (SessionPrefs.steamDeckMode(this)) "1" else "0"))
if (SessionState.mode == MODE_STEAM) guest.add("BL_STEAM_CHANNEL=" + SessionPrefs.steamChannel(this))
// Proton's own gate for its xalia helper (its `proton` script reads this, and sets
// XALIA_SUPPORTED_ONLY itself otherwise). Off by default: xalia is Valve's, and on a device
// whose seccomp answers its syscalls normally there is no reason to take it away.
@@ -349,6 +350,14 @@ class SessionService : Service() {
val binds = ArrayList<String>()
if (controllersOn) binds.add(fakeInputDir.path + ":/dev/input")
// The client's battery readout (the Quick Access Menu, the top bar) reads
// /sys/class/power_supply/BAT<n>/..., a laptop's or a Deck's naming; Android's supply is
// called "battery" and its files differ, so the client sees no battery at all. A directory
// of our own, written from Android's battery API every few seconds, is bound over it.
val battery = BatteryComponent(File(filesDir, "session/sys/power_supply"))
battery.setContext(this)
components.add(battery)
binds.add(battery.dir.path + ":/sys/class/power_supply")
// Where the device's files appear inside the session. Internal storage is bound at its own
// path already, and every program's file dialog opens at home and lists "Computer" from
// /proc/mounts, where a proot bind never shows - so a user saw only the runtime's own
@@ -35,6 +35,8 @@ class ModeSettings(
val gameStorage: String? = null,
val storageOptions: List<Pair<String, String>> = emptyList(),
val fexPreset: String? = null,
/** Steam only: the client branch forced on the command line. */
val steamChannel: String? = null,
)
class ModeSettingsActions(
@@ -55,6 +57,7 @@ class ModeSettingsActions(
val onGameStorage: (path: String, label: String) -> Unit = { _, _ -> },
val onPickGameStorageFolder: () -> Unit = {},
val onFexPreset: (String) -> Unit = {},
val onSteamChannel: (String) -> Unit = {},
val onDismiss: () -> Unit,
)
@@ -124,6 +127,14 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
note = "Auto shows the full pad when no controller is attached. Steam + QAM shows only those two buttons.", onPick = a.onOsc,
)
}
if (steam && s.steamChannel != null) SettingsGroup("Client") {
ChoiceRow(
host, "channel", "Client branch", "The Steam client build the session forces. Applies at the next session start; the client may update itself once.",
listOf("publicbeta" to "Public beta", "steamdeck_publicbeta" to "Steam Deck public beta"), s.steamChannel,
note = "Public beta is what every session has run on. Steam Deck public beta is the channel Armada bootstraps its ARM64 client from.",
onPick = a.onSteamChannel,
)
}
if (steam && s.fexPreset != null) SettingsGroup("Games") {
ChoiceRow(
host, "fex", "FEX preset", "How FEX translates the x86 games the client launches. Applies to the next game launch. Also in the session's drawer.",
@@ -89,7 +89,7 @@ fun PerformancePage(
)
ToggleRow(
host, "deck", "Steam Deck mode",
"Runs the client as SteamOS runs its own session (-steamdeck -steamos3), the shape Valve tunes Big Picture for. Off by default: the client then expects Deck hardware that is not here, and that cost is untested.",
"Runs the client with -steamdeck: the Quick Access Menu, the performance overlay and the Deck settings pages. The SteamOS helpers it calls are stubbed and the battery it reads is provided, so the mode is safe to try; off by default until it is proven on more devices.",
steamDeckMode, onChange = onSteamDeckMode,
)
}
+3
View File
@@ -0,0 +1,3 @@
#!/bin/sh
# No Deck BIOS here. The client calls this in Deck mode; a clean exit is "nothing to do".
exit 0
@@ -0,0 +1,6 @@
#!/bin/sh
# steamos-priv-write <path> <value>: how the client in Deck mode sets brightness, TDP and fan
# controls through sysfs. None of those paths is writable from an app on Android, and the app
# already sets the panel's mode and the game-tier power policy from the Android side. Accept
# and do nothing, so the client never logs a failed helper.
exit 0
@@ -0,0 +1,3 @@
#!/bin/sh
# The session carries the device's time zone in TZ already; the client's own setter is a no-op.
exit 0
+11
View File
@@ -0,0 +1,11 @@
#!/bin/sh
# The SteamOS update-branch selector the client calls in Deck mode. The session forces the client
# branch on the command line and keeps package/beta in step with it, so a selection here would
# only start the restart loop the session script describes. List and report the stable branch;
# accept and ignore a choice.
case "${1:-}" in
-l) printf 'rel\n' ;;
-c) printf 'rel\n' ;;
*) : ;;
esac
exit 0
+8
View File
@@ -0,0 +1,8 @@
#!/bin/sh
# What the Steam client calls in Deck mode to look for a SteamOS update. There is no SteamOS
# here: the answer is "no update", which is exit 7 in SteamOS's convention, and the client moves
# on. --supports-duplicate-detection is a capability probe and answers yes. Staged from the apk.
case "${1:-}" in
--supports-duplicate-detection) exit 0 ;;
esac
exit 7
@@ -323,24 +323,49 @@ BUSCONF
# desktop's launchers ask for; gamescope shows one window at a time and the game takes over.
_bl_ui=(-gamepadui)
[ "${BL_STEAM_UI:-}" = desktop ] && _bl_ui=()
# SteamOS runs its own session as `steam -gamepadui -steamos3 -steamdeck`, which is the shape
# Valve tunes Big Picture for. BL_STEAMDECK=1 (the Performance page) tries it. Off by default:
# it also makes the client expect Deck hardware (battery, TDP controls, a Deck input device)
# that is not here, and that cost is untested.
# Deck mode (BL_STEAMDECK=1, the Performance page): -steamdeck is the flag that puts the Quick
# Access Menu, the performance overlay and the Deck settings pages on screen. Only -steamdeck,
# never -steamos3: that one makes the client manage itself as SteamOS does - it calls
# steamos-select-branch and opts the install into the steamdeck_stable client branch, writes
# that to package/beta, and from then on the branch on disk loses to the -clientbeta below on
# every launch; the updater finds "installed version 0", marks an update pending and exits 42
# to apply it, which never happens, so the client restarts for ever - under a running game,
# taking Steam Input with it each time. (Bannerlator 38996ea1, on device.) The SteamOS helpers
# the client calls in this mode are staged as no-op stubs under /usr/bin.
if [ "${BL_STEAMDECK:-0}" = 1 ] && [ "${BL_STEAM_UI:-}" != desktop ]; then
_bl_ui+=(-steamdeck -steamos3)
echo "== steam: Deck mode on (-steamdeck -steamos3)"
_bl_ui+=(-steamdeck)
echo "== steam: Deck mode on (-steamdeck)"
fi
echo "== steam args: ${_bl_ui[*]} -clientbeta publicbeta -no-cef-sandbox -cef-force-gpu -cef-ozone-platform=x11 -cef-use-gl=angle -cef-use-angle=vulkan $*"
# The client branch. publicbeta is what every session has run on; steamdeck_publicbeta is the
# channel Armada bootstraps its ARM64 client from. Whatever wrote package/beta, the branch on
# disk has to agree with the one forced here - a disagreement is the restart loop above, and
# it is silent apart from rc=42 in this log.
steam_channel=${BL_STEAM_CHANNEL:-publicbeta}
steam_beta=$steam_root/package/beta
if [ -f "$steam_beta" ] && [ "$(cat "$steam_beta" 2>/dev/null)" != "$steam_channel" ]; then
echo "== steam: client branch on disk was '$(cat "$steam_beta" 2>/dev/null)', set to '$steam_channel' to match -clientbeta"
echo "$steam_channel" > "$steam_beta"
fi
echo "== steam args: ${_bl_ui[*]} -clientbeta $steam_channel -no-cef-sandbox -cef-force-gpu -cef-ozone-platform=x11 -cef-use-gl=angle -cef-use-angle=vulkan $*"
# An update that cannot install asks for a restart (rc=42) every time; the count is capped so a
# session can never again loop in silence, which reads as a menu opening and closing by itself.
steam_updates=0
while :; do
step "starting the Steam client${BL_STEAM_UI:+ ($BL_STEAM_UI UI)}"
"$steam_root/steamrtarm64/steam" "${_bl_ui[@]}" -clientbeta publicbeta \
"$steam_root/steamrtarm64/steam" "${_bl_ui[@]}" -clientbeta "$steam_channel" \
-no-cef-sandbox -cef-force-gpu -cef-ozone-platform=x11 \
-cef-use-gl=angle -cef-use-angle=vulkan "$@"
rc=$?
echo "== steam exited rc=$rc"
collect_steam_logs
[ "$rc" -eq 42 ] || break
steam_updates=$((steam_updates + 1))
if [ "$steam_updates" -ge 3 ]; then
echo "== steam: asked to restart for an update $steam_updates times and it is not installing; the session stops here rather than loop"
break
fi
# The URL arguments were acted on the first time; a restart must not repeat them.
set --
done
reap_orphans
exit "$rc"