From ebf5da98404350d3956de1e2f191746d4a7ea519 Mon Sep 17 00:00:00 2001 From: The412Banner Date: Wed, 23 Sep 2026 18:48:19 -0400 Subject: [PATCH 1/3] First sign-in fetches the ARM64 Proton and restarts the client once, as Bannerlator does On a fresh install the client starts signed out and drops the steam://install/4427310 the session hands it, so the Proton Experimental (ARM64) dialog only appeared after the user restarted the client by hand. Ported from Bannerlator's session (device-proven there 2026-09-22): - With no remembered sign-in, a watcher on logs/connection_log.txt waits for the sign-in to complete and asks the running client again. - A watcher on appmanifest_4427310.acf waits for StateFlags 4, registers the compatibility tool again, leaves ~/.bl-restart-once and asks the client to -shutdown; the start loop sees the marker and starts it once more (not counted against the update cap), so the tool lists and defaults are current before the user installs a game. - The client-branch check runs before every start, not once, since the client rewrites package/beta during its run. The loading screen no longer says Steam keeps running in the background: sessions pause when the app leaves the screen (Background behavior). --- app/src/main/res/values/strings.xml | 4 +- .../overlay/usr/local/bin/bannerlator-session | 62 +++++++++++++++++-- 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6e5fae82f..f83701d7a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3,11 +3,11 @@ DroidDeck First run downloads Steam. This may take a few minutes. - Steam keeps running in the background. + By default, leaving the app pauses Steam; it picks up where it left off when you come back. Your controller appears as an Xbox 360 pad. Otherwise, use the on-screen controls. Session - Keeps Steam running in the background + Shows while a Steam or desktop session is open Steam is running Session paused - tap Resume when ready Resume diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session index 94a2396c6..d4e609453 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session @@ -252,6 +252,49 @@ case $mode in if [ ! -f "$steam_root/steamapps/appmanifest_4427310.acf" ]; then step "asking the client to fetch the ARM64 Proton" set -- "$@" steam://install/4427310 + # Ported from Bannerlator (bannerlator-session, device-proven 2026-09-22). The client decides + # a title's compatibility tool when the title is installed and keeps that decision for the + # rest of its run, and the tool is set up before the client comes up. So once the depot has + # fully landed, restart the client once - its own -shutdown, then the loop below, which knows + # the marker - and every title installed from then on runs through the tool, with the + # refreshed tool list and defaults, and no second launch by the user. A first run only: with + # the depot present at session start nothing here is armed. + ( + _bl_acf="$steam_root/steamapps/appmanifest_4427310.acf" + for _ in $(seq 1 360); do + sleep 10 + # StateFlags 4 = fully installed; a moment more for the client to finish its bookkeeping. + if grep -q '"StateFlags"[[:space:]]*"4"' "$_bl_acf" 2>/dev/null; then + sleep 10 + bannerlator-steam-compat "$steam_root" >/dev/null 2>&1 || true + step "compatibility layer installed: restarting the Steam client once" + : > "$HOME/.bl-restart-once" + "$steam_root/steamrtarm64/steam" -shutdown >/dev/null 2>&1 || true + exit 0 + fi + done + ) & + # A client that starts signed out drops that request - on a fresh install nobody has signed + # in yet - so the install dialog only appeared after the user restarted the client by hand. + # With no remembered sign-in, ask again once the sign-in has happened: the client binary + # started a second time with a URL hands it to the running instance and exits. + if ! grep -q '"RememberPassword"[[:space:]]*"1"' "$steam_root/config/loginusers.vdf" 2>/dev/null; then + ( + _bl_conn="$steam_root/logs/connection_log.txt" + _bl_from=$(stat -c %s "$_bl_conn" 2>/dev/null || echo 0) + for _ in $(seq 1 180); do + sleep 10 + [ -f "$steam_root/steamapps/appmanifest_4427310.acf" ] && exit 0 + if tail -c +$((_bl_from + 1)) "$_bl_conn" 2>/dev/null | grep -q "RecvMsgClientLogOnResponse() : processing complete"; then + sleep 15 + [ -f "$steam_root/steamapps/appmanifest_4427310.acf" ] && exit 0 + step "signed in: asking the client again for the ARM64 Proton" + "$steam_root/steamrtarm64/steam" steam://install/4427310 >/dev/null 2>&1 || true + exit 0 + fi + done + ) & + fi fi # The tool is registered before the client starts and finds the depot at launch time, so # nothing here waits for a download. What can still change under a running client is which @@ -384,17 +427,21 @@ BUSCONF # 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. + # Checked before every start, not once: the client rewrites package/beta during its own run, + # and a restart below (rc=42, or the one after the ARM64 Proton lands) would otherwise start it + # again on the branch it just wrote. 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 + rm -f "$HOME/.bl-restart-once" + 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 step "starting the Steam client${BL_STEAM_UI:+ ($BL_STEAM_UI UI)}" "$steam_root/steamrtarm64/steam" "${_bl_ui[@]}" -clientbeta "$steam_channel" \ -no-cef-sandbox -cef-force-gpu -cef-ozone-platform=x11 \ @@ -402,6 +449,13 @@ BUSCONF rc=$? echo "== steam exited rc=$rc" collect_steam_logs + # The session asked for this exit (the ARM64 Proton landed): start again, without the URL + # arguments, which were acted on. Not counted against the update cap. + if [ -f "$HOME/.bl-restart-once" ]; then + echo "== steam: restarting once so the new compatibility layer is in the client's lists" + set -- + continue + fi [ "$rc" -eq 42 ] || break steam_updates=$((steam_updates + 1)) if [ "$steam_updates" -ge 3 ]; then From 034d484271efac9a3e95951298464b098f4915f1 Mon Sep 17 00:00:00 2001 From: The412Banner Date: Wed, 23 Sep 2026 18:52:50 -0400 Subject: [PATCH 2/3] Session drawer: opens focused on its first control, and the controller drives it With the drawer open the controller still fed the game - PadBridge consumed every pad key and axis - and nothing in the drawer had focus. Now, while it is open, pad events go to Android's own handling instead: the d-pad and stick move focus, and the framework's fallbacks make A select and B Back (which closes the drawer), as on the app's main screen. Opening it releases the game's pad (PadBridge.releaseAll), so a button or stick held at that moment is not left down in the game. It opens with focus on the STEAM button (in a desktop session, the first setting), and the STEAM and ... buttons show a focus outline like the rest of the app's controls. ToggleRow gains a modifier for its box. --- .../com/droiddeck/launcher/SessionActivity.kt | 7 +++++ .../droiddeck/launcher/input/PadBridge.java | 9 ++++++ .../droiddeck/launcher/ui/SessionOverlay.kt | 28 +++++++++++++++++-- .../droiddeck/launcher/ui/SettingsWidgets.kt | 4 +-- 4 files changed, 44 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 83d940a16..a3ed30bff 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -179,6 +179,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { CursorOverlay(cursorPos, cursorVisible, resources.displayMetrics.density) if (hud.text.isNotEmpty()) HudText(hud.text) if (loading.visible) LoadingOverlay(loading.step, loading.percent, loading.elapsed, loading.hint, loading.ended) + // Opening the drawer takes the controller away from the game: release its pad. + androidx.compose.runtime.LaunchedEffect(drawerOpen) { if (drawerOpen) padBridge?.releaseAll() } SessionDrawer(drawerOpen, DrawerActions( steam = SessionState.mode == SessionService.MODE_STEAM, isHomeApp = isHomeApp, @@ -607,6 +609,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { KeyEvent.KEYCODE_VOLUME_UP, KeyEvent.KEYCODE_VOLUME_DOWN, KeyEvent.KEYCODE_VOLUME_MUTE -> return super.dispatchKeyEvent(event) } + // With the drawer open the controller drives the drawer, not the game: Android's own + // handling moves focus with the d-pad and stick, and its fallbacks make A select and B Back + // (which closes the drawer) - the same as on the app's main screen. + if (drawerOpen && event.device != null && PadBridge.isFromController(event.device)) return super.dispatchKeyEvent(event) if (event.keyCode != KeyEvent.KEYCODE_BACK && padBridge?.onKeyEvent(event) == true) return true // A hardware keyboard, forwarded to the compositor's wl_keyboard. Back is left to the // activity, which opens the drawer. @@ -648,6 +654,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean { + if (drawerOpen && event.device != null && PadBridge.isFromController(event.device)) return super.dispatchGenericMotionEvent(event) if (padBridge?.onMotionEvent(event) == true) return true if (event.isFromSource(android.view.InputDevice.SOURCE_MOUSE) && !drawerOpen && onMouse(event)) return true return super.dispatchGenericMotionEvent(event) diff --git a/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java b/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java index fc1224b0d..d07ab82a0 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java +++ b/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java @@ -166,6 +166,15 @@ public final class PadBridge { else publish(); } + /** + * Everything released and centred - for when the controller stops feeding the game (the + * session drawer opened), so a button or stick held at that moment is not left down in it. + */ + public synchronized void releaseAll() { + state.clear(); + publish(); + } + /** Starts the same Guide-then-A sequence used by the touch QAM button. */ public synchronized void triggerQam() { startQamChord(); diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt index 83958d9c8..0a568a140 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt @@ -4,6 +4,9 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.ui.focus.focusRequester +import androidx.compose.material3.ButtonDefaults +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.interaction.PressInteraction import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -177,6 +180,16 @@ fun SessionDrawer(open: Boolean, a: DrawerActions) { val host = rememberMenuHost() var androidAppsExpanded by rememberSaveable { mutableStateOf(false) } val veil by animateFloatAsState(if (open) 1f else 0f, Motion.tw(260), label = "veil") + // A controller starts on the drawer's first control, highlighted, as on the app's main screen: + // the STEAM button, or in a desktop session the first setting. + val firstFocus = remember { androidx.compose.ui.focus.FocusRequester() } + val steamButtons = a.onSteamMenu != null && a.onQam != null + LaunchedEffect(open) { + if (open) { + androidx.compose.runtime.withFrameNanos { } + runCatching { firstFocus.requestFocus() } + } + } if (open || veil > 0.01f) Box( modifier = Modifier.fillMaxSize().graphicsLayer { alpha = veil }.background(Color(0x8A000000)) .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) { host.open = null; a.onClose() }, @@ -203,6 +216,7 @@ fun SessionDrawer(open: Boolean, a: DrawerActions) { } if (a.onSteamMenu != null && a.onQam != null) { val qamInteraction = remember { MutableInteractionSource() } + val qamHot = qamInteraction.collectIsFocusedAsState().value || qamInteraction.collectIsHoveredAsState().value var qamStartedOnPress by remember { mutableStateOf(false) } LaunchedEffect(qamInteraction) { qamInteraction.interactions.collect { interaction -> @@ -217,10 +231,15 @@ fun SessionDrawer(open: Boolean, a: DrawerActions) { horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp), ) { + val steamSrc = remember { MutableInteractionSource() } + val steamHot = steamSrc.collectIsFocusedAsState().value || steamSrc.collectIsHoveredAsState().value OutlinedButton( onClick = { host.open = null; a.onSteamMenu.invoke() }, - modifier = Modifier.weight(1f).height(48.dp), + interactionSource = steamSrc, + modifier = Modifier.weight(1f).height(48.dp).focusRequester(firstFocus), shape = RoundedCornerShape(12.dp), + border = BorderStroke(if (steamHot) 2.dp else 1.dp, if (steamHot) pal.signal else colors.outline), + colors = ButtonDefaults.outlinedButtonColors(containerColor = if (steamHot) pal.signal.copy(alpha = 0.16f) else Color.Transparent), ) { Text("STEAM", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.5.sp) } @@ -233,6 +252,8 @@ fun SessionDrawer(open: Boolean, a: DrawerActions) { interactionSource = qamInteraction, modifier = Modifier.weight(1f).height(48.dp).semantics { contentDescription = "Open Quick Access Menu" }, shape = RoundedCornerShape(12.dp), + border = BorderStroke(if (qamHot) 2.dp else 1.dp, if (qamHot) pal.signal else colors.outline), + colors = ButtonDefaults.outlinedButtonColors(containerColor = if (qamHot) pal.signal.copy(alpha = 0.16f) else Color.Transparent), ) { Text("…", fontSize = 22.sp, fontWeight = FontWeight.SemiBold) } @@ -268,7 +289,10 @@ fun SessionDrawer(open: Boolean, a: DrawerActions) { } SettingsGroup("Now") { - ToggleRow(host, "hud", "Performance HUD", null, a.hudOn, onChange = a.onHud) + ToggleRow( + host, "hud", "Performance HUD", null, a.hudOn, + chipModifier = if (steamButtons) Modifier else Modifier.focusRequester(firstFocus), onChange = a.onHud, + ) val fgOpen = host.open == "fg" val fgLabel = when (a.frameGenEngine) { FrameGen.ENGINE_WINFG -> "Win-FG ${a.frameGenMultiplier}×" diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt b/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt index eaae923f3..da7fd678d 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt @@ -242,8 +242,8 @@ fun ChoiceRow( } @Composable -fun ToggleRow(host: MenuHost, key: String, label: String, hint: String?, checked: Boolean, enabled: Boolean = true, onChange: (Boolean) -> Unit) = - ChoiceRow(host, key, label, hint, listOf(true to "On", false to "Off"), checked, enabled, onPick = onChange) +fun ToggleRow(host: MenuHost, key: String, label: String, hint: String?, checked: Boolean, enabled: Boolean = true, chipModifier: Modifier = Modifier, onChange: (Boolean) -> Unit) = + ChoiceRow(host, key, label, hint, listOf(true to "On", false to "Off"), checked, enabled, chipModifier = chipModifier, onPick = onChange) @Composable fun MultiRow( From edeb388233976125663d7ac0ac836f2e42a17607 Mon Sep 17 00:00:00 2001 From: The412Banner Date: Wed, 23 Sep 2026 19:00:52 -0400 Subject: [PATCH 3/3] Front end: credits and the primary button show a focus outline With a controller, credits only showed the ripple's faint box on the dark rail, and the primary button (Play) only grew slightly with a shine that barely shows on its light fill. Both now get the 2 dp signal-blue outline the other controls use; credits' text turns blue as well. --- .../com/droiddeck/launcher/ui/FrontEndScreen.kt | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt index e4acabf7f..052630cc7 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt @@ -337,9 +337,16 @@ private fun Rail( } } + // Outlined when a controller is on it, like every other control; the ripple alone was + // a faint box on the dark rail. + val creditsSrc = remember { MutableInteractionSource() } + val creditsHot = rememberHot(creditsSrc) Text( - "credits", fontSize = 11.sp, color = colors.onSurfaceVariant, - modifier = Modifier.clip(Shape10).clickable(onClick = a.onCredits).padding(horizontal = 10.dp, vertical = 8.dp), + "credits", fontSize = 11.sp, color = if (creditsHot) LocalPalette.current.signal else colors.onSurfaceVariant, + modifier = Modifier.clip(Shape10) + .border(2.dp, if (creditsHot) LocalPalette.current.signal else Color.Transparent, Shape10) + .hoverable(creditsSrc).clickable(interactionSource = creditsSrc, indication = null, onClick = a.onCredits) + .padding(horizontal = 10.dp, vertical = 8.dp), ) } } @@ -789,6 +796,9 @@ private fun PrimaryButton(text: String, enabled: Boolean = true, onClick: () -> .clip(Shape12) .background(if (enabled) Brush.linearGradient(listOf(colors.primary, pal.primary2)) else Brush.linearGradient(listOf(colors.surfaceVariant, colors.surfaceVariant))) .shine(hot, 0.45f) + // The grow and shine alone barely show on the light fill: outline it when a + // controller is on it, as the other controls are. + .border(2.dp, if (hot) pal.signal else Color.Transparent, Shape12) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, enabled = enabled, onClick = onClick) .padding(horizontal = 18.dp, vertical = 11.dp), ) {