Merge remote-tracking branch 'origin/main' into fix/termux-system-runtime

# Conflicts:
#	app/src/main/java/com/steamdeck/launcher/SessionActivity.kt
#	app/src/main/java/com/steamdeck/launcher/ui/SessionOverlay.kt
This commit is contained in:
xXJsonDeruloXx committed 2026-09-23 14:22:49 -04:00
commit 964c876a3e
13 files changed
+475 -38

No files matched your search

+18
View File
@@ -54,6 +54,23 @@
</intent-filter>
</activity>
<!-- Android owns the Home key. This small translucent Home handler routes the resulting
Home launch either back into a running Steam session or to the regular front end. -->
<activity
android:name=".HomeActivity"
android:exported="true"
android:excludeFromRecents="true"
android:noHistory="true"
android:taskAffinity="${applicationId}.home"
android:screenOrientation="sensorLandscape"
android:theme="@android:style/Theme.Translucent.NoTitleBar">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.HOME" />
</intent-filter>
</activity>
<!--
A session keeps a Surface, a compositor thread and a proot tree alive; a configuration
change must not tear that down and rebuild it, so the activity handles them itself.
@@ -79,6 +96,7 @@
<activity
android:name=".SessionActivity"
android:exported="false"
android:launchMode="singleTop"
android:theme="@style/SessionTheme"
android:preferMinimalPostProcessing="true"
android:screenOrientation="sensorLandscape"
@@ -0,0 +1,27 @@
package com.steamdeck.launcher
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import com.steamdeck.launcher.session.SessionService
/** Receives the system Home launch and routes it without consuming KEYCODE_HOME. */
class HomeActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
routeHome()
}
private fun routeHome() {
val destination = if (HomeApp.isActiveSteamSession()) {
Intent(this, SessionActivity::class.java)
.setAction(SessionService.ACTION_HOME_GUIDE)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
} else {
Intent(this, MainActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
}
startActivity(destination)
finish()
}
}
@@ -0,0 +1,95 @@
package com.steamdeck.launcher
import android.app.role.RoleManager
import android.graphics.Bitmap
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.provider.Settings
import androidx.core.graphics.drawable.toBitmap
import com.steamdeck.launcher.session.SessionService
import com.steamdeck.launcher.session.SessionState
/** Android Home role and the launchable Android apps shown by the in-session app drawer. */
object HomeApp {
data class LaunchableApp(
val label: String,
val packageName: String,
val className: String,
val icon: Bitmap?,
)
fun isDefault(context: Context): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val roles = context.getSystemService(RoleManager::class.java)
if (roles?.isRoleAvailable(RoleManager.ROLE_HOME) == true) {
return roles.isRoleHeld(RoleManager.ROLE_HOME)
}
}
val home = Intent(Intent.ACTION_MAIN)
.addCategory(Intent.CATEGORY_HOME)
.addCategory(Intent.CATEGORY_DEFAULT)
return context.packageManager.resolveActivity(home, PackageManager.MATCH_DEFAULT_ONLY)
?.activityInfo?.packageName == context.packageName
}
fun defaultLabel(context: Context): String? {
val pm = context.packageManager
val home = Intent(Intent.ACTION_MAIN)
.addCategory(Intent.CATEGORY_HOME)
.addCategory(Intent.CATEGORY_DEFAULT)
val resolved = pm.resolveActivity(home, PackageManager.MATCH_DEFAULT_ONLY) ?: return null
val activity = resolved.activityInfo ?: return null
val label = runCatching { pm.getApplicationLabel(activity.applicationInfo).toString() }.getOrNull()
return label?.trim()?.takeIf { it.isNotEmpty() }
?: resolved.loadLabel(pm)?.toString()?.trim()?.takeIf { it.isNotEmpty() }
}
fun roleRequestIntent(context: Context): Intent? {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return null
val roles = context.getSystemService(RoleManager::class.java) ?: return null
if (!roles.isRoleAvailable(RoleManager.ROLE_HOME) || roles.isRoleHeld(RoleManager.ROLE_HOME)) return null
return roles.createRequestRoleIntent(RoleManager.ROLE_HOME)
}
fun settingsIntent(): Intent = Intent(Settings.ACTION_HOME_SETTINGS)
fun launchableApps(context: Context): List<LaunchableApp> {
val pm = context.packageManager
val launch = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
val apps = pm.queryIntentActivities(launch, 0)
.asSequence()
.mapNotNull { result ->
val activity = result.activityInfo ?: return@mapNotNull null
if (!activity.exported || activity.packageName == context.packageName) return@mapNotNull null
val label = result.loadLabel(pm)?.toString()?.trim().orEmpty().ifEmpty { activity.packageName }
val icon = runCatching { result.loadIcon(pm).toBitmap(48, 48) }.getOrNull()
LaunchableApp(label, activity.packageName, activity.name, icon)
}
.distinctBy { it.packageName }
.toList()
return apps.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.label })
}
fun launch(context: Context, app: LaunchableApp) {
val intent = Intent(Intent.ACTION_MAIN)
.addCategory(Intent.CATEGORY_LAUNCHER)
.setComponent(ComponentName(app.packageName, app.className))
context.startActivity(intent)
}
fun isActiveSteamSession(): Boolean =
SessionState.running && SessionState.mode == SessionService.MODE_STEAM
fun openSystemHomeSettings(context: Context) {
try {
context.startActivity(settingsIntent())
} catch (_: Exception) {
// A role request remains available on Android 10+ if the settings page is missing.
val request = roleRequestIntent(context)
if (request != null) context.startActivity(request)
}
}
}
@@ -149,6 +149,7 @@ class MainActivity : ComponentActivity() {
// The mode whose settings dialog is open, with what it shows; refreshed by openModeSettings().
private var settingsMode by mutableStateOf<String?>(null)
private var resolutionCap by mutableStateOf(1080)
private var customResolution by mutableStateOf<Pair<Int, Int>?>(null)
private var fexPreset by mutableStateOf("")
private var steamChannel by mutableStateOf("publicbeta")
private var theme by mutableStateOf("paper")
@@ -172,6 +173,13 @@ class MainActivity : ComponentActivity() {
private var runningLabel by mutableStateOf<String?>(null)
private var logsEnabled by mutableStateOf(true)
private var showRoms by mutableStateOf(false)
private var homeAppSelected by mutableStateOf(false)
private var defaultHomeLabel by mutableStateOf<String?>(null)
private var androidApps by mutableStateOf<List<HomeApp.LaunchableApp>>(emptyList())
private val homeRoleRequest = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
refreshHomeAppState()
}
/** The session surface rises over the front end instead of cutting to it. */
override fun startActivity(intent: Intent?) {
@@ -203,6 +211,9 @@ class MainActivity : ComponentActivity() {
lsfgReady = LsfgNative.isInstalled(this),
pageKey = sm?.let { "settings:$it" } ?: if (showPerformance) "performance" else if (showApps) "apps" else null,
theme = theme,
isHomeApp = homeAppSelected,
defaultHomeLabel = defaultHomeLabel,
androidApps = androidApps,
),
FrontEndActions(
onPlay = { startSession(Intent(this, SessionActivity::class.java)) },
@@ -261,6 +272,8 @@ class MainActivity : ComponentActivity() {
onCredits = { showCredits = true },
onPageBack = { settingsMode = null; showPerformance = false; showApps = false },
onTheme = { id -> SessionPrefs.setTheme(this, id); theme = id },
onHomeApp = { manageHomeApp() },
onAndroidApp = { app -> launchAndroidApp(app) },
),
page = page,
)
@@ -322,6 +335,7 @@ class MainActivity : ComponentActivity() {
override fun onResume() {
super.onResume()
refreshHomeAppState()
refresh()
// Added games' art (a store lookup for what the folders lack) starts here, not only when
// the cog opens.
@@ -329,6 +343,29 @@ class MainActivity : ComponentActivity() {
if (!busy) Thread({ checkCatalog() }, "catalog").start()
}
private fun refreshHomeAppState() {
homeAppSelected = HomeApp.isDefault(this)
defaultHomeLabel = HomeApp.defaultLabel(this)
androidApps = if (homeAppSelected) HomeApp.launchableApps(this) else emptyList()
}
private fun launchAndroidApp(app: HomeApp.LaunchableApp) {
try {
HomeApp.launch(this, app)
} catch (_: Exception) {
android.widget.Toast.makeText(this, "Could not open ${app.label}", android.widget.Toast.LENGTH_SHORT).show()
}
}
private fun manageHomeApp() {
val request = HomeApp.roleRequestIntent(this)
if (request != null) {
homeRoleRequest.launch(request)
} else {
HomeApp.openSystemHomeSettings(this)
}
}
/** Desktop & apps, in the front end's pane. */
@Composable
private fun AppsHost() {
@@ -414,7 +451,7 @@ class MainActivity : ComponentActivity() {
private fun ModeSettingsHost(mode: String) {
ModeSettingsPage(
ModeSettings(
mode = mode, resolutionCap = resolutionCap, shapeMode = shapeMode,
mode = mode, resolutionCap = resolutionCap, customResolution = customResolution, shapeMode = shapeMode,
hdr = hdrOn, hdrReason = hdrReason,
linuxRows = linuxRows,
linuxSelected = if (mode == SessionService.MODE_STEAM) linuxSteam else linuxDesktop,
@@ -436,6 +473,7 @@ class MainActivity : ComponentActivity() {
),
ModeSettingsActions(
onResolution = { cap -> SessionPrefs.setResolutionCap(this, mode, cap); resolutionCap = cap },
onCustomResolution = { size -> SessionPrefs.setCustomResolution(this, mode, size); customResolution = size },
onShape = { shape -> SessionPrefs.setShapeMode(this, shape); shapeMode = shape },
onHdr = { on -> SessionPrefs.setHdr(this, mode, on); hdrOn = on },
onSelectLinux = { id -> SessionPrefs.setLinuxDriver(this, mode, id); refreshDrivers() },
@@ -530,6 +568,7 @@ class MainActivity : ComponentActivity() {
showApps = false
refreshDrivers()
resolutionCap = SessionPrefs.resolutionCap(this, mode)
customResolution = SessionPrefs.customResolution(this, mode)
fexPreset = SessionPrefs.fexPreset(this)
steamChannel = SessionPrefs.steamChannel(this)
addedGamesDirs = SessionPrefs.addedGamesDirs(this)
@@ -2,6 +2,7 @@ package com.steamdeck.launcher
import android.hardware.input.InputManager
import android.hardware.display.DisplayManager
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.os.Environment
@@ -19,6 +20,7 @@ import android.widget.FrameLayout
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
@@ -108,6 +110,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun onDisplayRemoved(displayId: Int) = refreshSecondScreenDisplays()
override fun onDisplayChanged(displayId: Int) = refreshSecondScreenDisplays()
}
private var isHomeApp by mutableStateOf(false)
private var defaultHomeLabel by mutableStateOf<String?>(null)
private var androidApps by mutableStateOf<List<HomeApp.LaunchableApp>>(emptyList())
private val homeRoleRequest = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
refreshHomeApp()
}
/**
* Shows the on-screen pad when nothing is plugged in and takes it away the moment something
@@ -133,11 +141,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
displayManager = getSystemService(DISPLAY_SERVICE) as DisplayManager
refreshSecondScreenDisplays()
if (!LinuxRuntime.isInstalled(this)) {
Log.e(TAG, "the Linux runtime is not installed")
finish()
return
}
// No runtime is not a reason to leave: the loading screen installs it (installThenStart,
// below) and the session starts when it is in.
if (!LinuxRuntime.isInstalled(this)) Log.i(TAG, "the Linux runtime is not installed; the loading screen installs it")
val root = FrameLayout(this)
surfaceView = SurfaceView(this)
@@ -184,6 +190,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (loading.visible) LoadingOverlay(loading.step, loading.percent, loading.elapsed, loading.hint, loading.ended)
SessionDrawer(drawerOpen, DrawerActions(
steam = SessionState.mode == SessionService.MODE_STEAM,
isHomeApp = isHomeApp,
defaultHomeLabel = defaultHomeLabel,
androidApps = androidApps,
hudOn = hudOn,
frameGenEngine = frameGenEngine, frameGenMultiplier = frameGenMultiplier,
lsfgReady = LsfgNative.isInstalled(this@SessionActivity),
@@ -200,16 +209,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
applyFrameGen()
},
onKeyboard = { drawerOpen = false; keyboard?.toggle() },
onSteamMenu = if (SessionState.mode == SessionService.MODE_STEAM) ({
// The Guide button, the way the on-screen ◉ sends it: a device with no
// Xbox button, or a pad the client hides the controls for, has no other
// way to open the client's menu in a game.
drawerOpen = false
padBridge?.applyTouch { st -> st.press(com.steamdeck.launcher.input.PadState.GUIDE, true) }
Handler(Looper.getMainLooper()).postDelayed({
padBridge?.applyTouch { st -> st.press(com.steamdeck.launcher.input.PadState.GUIDE, false) }
}, 90)
}) else null,
onSteamMenu = if (SessionState.mode == SessionService.MODE_STEAM) ({ sendSteamGuide() }) else null,
onQam = if (SessionState.mode == SessionService.MODE_STEAM) ({
drawerOpen = false
padBridge?.triggerQam()
@@ -221,6 +221,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
onFexPreset = { v -> SessionPrefs.setFexPreset(this@SessionActivity, v); readPrefs() },
onSecondScreenMode = ::selectSecondScreenMode,
onSecondScreenDisplay = ::selectSecondScreenDisplay,
onHomeApp = ::manageHomeApp,
onLaunchAndroidApp = { app ->
drawerOpen = false
try {
HomeApp.launch(this@SessionActivity, app)
} catch (_: Exception) {
Toast.makeText(this@SessionActivity, "Could not open ${app.label}", Toast.LENGTH_SHORT).show()
}
},
onBackground = { drawerOpen = false; moveTaskToBack(true) },
onStop = { drawerOpen = false; SessionService.stop(this@SessionActivity); finish() },
onClose = { drawerOpen = false },
@@ -239,6 +248,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
})
setContentView(root)
handleHomeGuideIntent(intent)
updateOnScreenControls()
WaylandCompositor.setFirstFrameListener {
@@ -269,6 +279,33 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
}
private fun refreshHomeApp() {
isHomeApp = HomeApp.isDefault(this)
defaultHomeLabel = HomeApp.defaultLabel(this)
androidApps = if (isHomeApp) HomeApp.launchableApps(this) else emptyList()
}
private fun manageHomeApp() {
val request = HomeApp.roleRequestIntent(this)
if (request != null) homeRoleRequest.launch(request)
else HomeApp.openSystemHomeSettings(this)
}
private fun sendSteamGuide() {
drawerOpen = false
padBridge?.applyTouch { state -> state.press(com.steamdeck.launcher.input.PadState.GUIDE, true) }
uiHandler.postDelayed({
padBridge?.applyTouch { state -> state.press(com.steamdeck.launcher.input.PadState.GUIDE, false) }
}, 90)
}
private fun handleHomeGuideIntent(incoming: Intent?) {
if (incoming?.action != SessionService.ACTION_HOME_GUIDE) return
incoming.action = null
setIntent(incoming)
if (SessionState.running && SessionState.mode == SessionService.MODE_STEAM) sendSteamGuide()
}
private fun readPrefs() {
hudOn = SessionPrefs.hudEnabled(this)
touchMode = SessionPrefs.touchMode(this)
@@ -466,13 +503,20 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
// Wider than 16:9 is fine - games and the client cope with a phone's 20:9 - so the
// panel's aspect is kept above that, unless the user pinned 16:9 for a foldable, and the
// compositor letterboxes onto a squarer panel.
val aspect = if (SessionPrefs.shapeMode(this) == SessionPrefs.SHAPE_WIDE) 16f / 9f
else maxOf(panelW / panelH, 16f / 9f)
// "Exactly this panel" drops that floor, for a 4:3 or 3:2 handheld whose games should
// fill it.
val aspect = when (SessionPrefs.shapeMode(this)) {
SessionPrefs.SHAPE_WIDE -> 16f / 9f
SessionPrefs.SHAPE_EXACT -> panelW / panelH
else -> maxOf(panelW / panelH, 16f / 9f)
}
// 720 tall at most by default, client and desktop alike: the client's CEF is the heaviest
// thing in the session, and pixels above that cost frames for nothing anyone can see on a
// handheld panel. The mode's settings (the cog beside Play / Desktop) can change
// the cap or lift it to the panel.
val mode = SessionPrefs.prefMode(intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM)
// A custom resolution is taken as given; the compositor fits it to the panel.
SessionPrefs.customResolution(this, mode)?.let { return it }
val cap = SessionPrefs.resolutionCap(this, mode)
val height = (if (cap <= 0) panelH else minOf(panelH, cap.toFloat())).toInt()
val width = (height * aspect).toInt()
@@ -890,7 +934,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun onNewIntent(intent: android.content.Intent) {
super.onNewIntent(intent)
setIntent(intent)
if (intent.action == SessionService.ACTION_RESUME) {
if (intent.action == SessionService.ACTION_HOME_GUIDE) {
handleHomeGuideIntent(intent)
} else if (intent.action == SessionService.ACTION_RESUME) {
intent.action = null
SessionService.resume(this)
}
@@ -898,6 +944,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun onResume() {
super.onResume()
refreshHomeApp()
if (intent?.action == SessionService.ACTION_RESUME) {
intent.action = null
SessionService.resume(this)
@@ -109,6 +109,8 @@ object DeviceReport {
k("Session output", SessionState.outputSize?.let { "${it.first}x${it.second}" })
k("Session refresh", String.format(Locale.US, "%.2f Hz", SessionState.refreshHz))
k("Shape setting", SessionPrefs.shapeMode(context))
k("Custom resolution (Steam)", SessionPrefs.customResolution(context, SessionService.MODE_STEAM)?.let { "${it.first}x${it.second}" } ?: "off")
k("Custom resolution (Desktop)", SessionPrefs.customResolution(context, SessionService.MODE_DESKTOP)?.let { "${it.first}x${it.second}" } ?: "off")
k("Foldable", context.packageManager.hasSystemFeature("android.hardware.sensor.hinge_angle"))
h("Drivers")
@@ -35,10 +35,18 @@ object SessionPrefs {
const val SHAPE_AUTO = "auto"
const val SHAPE_WIDE = "16:9"
const val SHAPE_EXACT = "exact"
/** The choices the settings offer, in order. */
val shapeChoices = listOf(
SHAPE_AUTO to "The panel's shape (16:9 or wider)",
SHAPE_EXACT to "Exactly this panel (4:3, 3:2…)",
SHAPE_WIDE to "16:9 with bars",
)
/**
* The shape of the display the session presents: the panel's own (never narrower than 16:9)
* or a fixed 16:9. A foldable defaults to 16:9, which sits with modest bars on either of its
* The shape of the display the session presents: the panel's own (never narrower than 16:9),
* exactly the panel's (a 4:3 or 3:2 handheld, drawn edge to edge), or a fixed 16:9. A foldable defaults to 16:9, which sits with modest bars on either of its
* panels; the panel's own shape would fit one and leave a strip on the other, and gamescope's
* display cannot change size once the session is up.
*/
@@ -300,6 +308,27 @@ object SessionPrefs {
prefs(context).edit().putInt("resolutionCap.$mode", cap).apply()
}
/**
* A fixed size for the session's display, per mode, or null. When set it replaces both the
* cap and the shape: the compositor fits it to the panel with bars where the shapes differ.
*/
fun customResolution(context: Context, mode: String): Pair<Int, Int>? =
parseResolution(prefs(context).getString("customRes.$mode", null))
fun setCustomResolution(context: Context, mode: String, size: Pair<Int, Int>?) {
prefs(context).edit().putString("customRes.$mode", size?.let { "${it.first}x${it.second}" }).apply()
}
/** "1024x768" (or ×, or *) to an even size inside 320x240..3840x2160; anything else is null. */
fun parseResolution(text: String?): Pair<Int, Int>? {
val parts = text?.trim()?.split('x', 'X', '×', '*')?.map { it.trim() } ?: return null
if (parts.size != 2) return null
val w = parts[0].toIntOrNull() ?: return null
val h = parts[1].toIntOrNull() ?: return null
if (w !in 320..3840 || h !in 240..2160) return null
return Pair(w and 1.inv(), h and 1.inv())
}
/**
* What the desktop shell composites with: pixman (software, the default - the Adreno stand-in
* is not a DRM render node, so labwc's gbm allocator cannot use it), or gles2 / vulkan for a
@@ -961,6 +961,7 @@ class SessionService : Service() {
private const val NOTIFICATION_ID = 1001
const val ACTION_STOP = "com.steamdeck.launcher.STOP_SESSION"
const val ACTION_RESUME = "com.steamdeck.launcher.RESUME_SESSION"
const val ACTION_HOME_GUIDE = "com.steamdeck.launcher.HOME_GUIDE"
private const val ACTION_ACTIVITY_VISIBLE = "com.steamdeck.launcher.ACTIVITY_VISIBLE"
private const val ACTION_ACTIVITY_HIDDEN = "com.steamdeck.launcher.ACTIVITY_HIDDEN"
private const val ACTION_TRACK_AUXILIARY = "com.steamdeck.launcher.TRACK_AUXILIARY"
@@ -1,5 +1,6 @@
package com.steamdeck.launcher.ui
import android.graphics.Bitmap
import android.os.Build
import android.provider.Settings
import androidx.activity.compose.BackHandler
@@ -88,6 +89,7 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.LayoutCoordinates
@@ -130,6 +132,9 @@ class FrontEndState(
val lsfgReady: Boolean = false,
val pageKey: String? = null,
val theme: String = Themes.PAPER,
val isHomeApp: Boolean = false,
val defaultHomeLabel: String? = null,
val androidApps: List<com.steamdeck.launcher.HomeApp.LaunchableApp> = emptyList(),
)
class FrontEndActions(
@@ -156,6 +161,8 @@ class FrontEndActions(
val onCredits: () -> Unit,
val onPageBack: () -> Unit = {},
val onTheme: (String) -> Unit = {},
val onHomeApp: () -> Unit = {},
val onAndroidApp: (com.steamdeck.launcher.HomeApp.LaunchableApp) -> Unit = {},
)
@@ -224,6 +231,7 @@ fun FrontEndScreen(s: FrontEndState, a: FrontEndActions, page: (@Composable () -
val colors = MaterialTheme.colorScheme
val ctx = LocalContext.current
BackHandler(enabled = s.pageKey != null && page != null) { a.onPageBack() }
LaunchedEffect(s.isHomeApp) { if (!s.isHomeApp && selected == "android-apps") selected = "steam" }
remember { Motion.scale = Settings.Global.getFloat(ctx.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f); true }
BoxWithConstraints(modifier = Modifier.fillMaxSize().background(colors.background).systemBarsPadding()) {
@@ -312,6 +320,10 @@ private fun Rail(
}
val unregister: (String) -> Unit = { positions.remove(it) }
Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(2.dp)) {
if (s.isHomeApp) {
NavItem("Android apps", "android-apps", selected == "android-apps", count = s.androidApps.size,
register = register, unregister = unregister) { onSelect("android-apps") }
}
NavItem("Steam", "steam", selected == "steam", caret = openSteam, count = s.steamGames.size, register = register, unregister = unregister) { onSelect("steam") }
Sub(openSteam) {
for ((i, g) in s.steamGames.withIndex()) NavItem(g.name, "app:${g.appId}", selected == "app:${g.appId}", small = true, i = i, register = register, unregister = unregister) { onSelect("app:${g.appId}") }
@@ -334,12 +346,12 @@ private fun Rail(
if (s.emulators.none { it.installed }) NavItem("Install emulators", "apps", selected == "apps", small = true, muted = true, register = register, unregister = unregister) { a.onApps() }
NavItem("Settings", "settings:lxqt", selected == "settings:lxqt", small = true, tiny = true, muted = true, i = s.emulators.count { it.installed }, register = register, unregister = unregister) { a.onDesktopSettings() }
}
Spacer(Modifier.height(6.dp))
Spacer(Modifier.height(6.dp))
val menus = remember { MenuHost() }
val item: @Composable (String, String?, Int, String?, () -> Unit) -> Unit = { label, value, i, key, act ->
NavItem(label, key ?: "x", key != null && selected == key, small = true, tiny = true, muted = true, value = value, i = i, register = register, unregister = unregister) { act() }
}
NavItem("Setup", "x", false, caret = openSetup, count = 10, onClick = onToggleSetup)
NavItem("Setup", "x", false, caret = openSetup, count = 11, onClick = onToggleSetup)
Sub(openSetup) {
item("Files", null, 0, null, a.onFiles)
item("Desktop & apps", null, 1, "apps", a.onApps)
@@ -388,6 +400,7 @@ private fun Rail(
for (t in Themes.all) MenuItem(t.label, checked = s.theme == t.id) { a.onTheme(t.id); menus.open = null }
}
}
item("Default Home app", s.defaultHomeLabel ?: "Choose a Home app", 10, null, a.onHomeApp)
}
}
}
@@ -503,6 +516,7 @@ private fun Pane(s: FrontEndState, selected: String, a: FrontEndActions, page: (
selected.startsWith("app:") -> s.steamGames.firstOrNull { "app:${it.appId}" == selected }.let { it?.art to hueOf(it?.name ?: "") }
selected.startsWith("emu:") -> null to hueOf(selected)
selected.startsWith("rom:") -> romFor(s, selected).let { it?.second?.art to hueOf(it?.second?.name ?: "") }
selected == "android-apps" -> null to hueOf("android-apps")
else -> null to 268f
}
Backdrop(wash)
@@ -547,6 +561,24 @@ private fun Content(s: FrontEndState, selected: String, a: FrontEndActions, modi
val colors = MaterialTheme.colorScheme
Column(modifier = modifier.padding(horizontal = 22.dp, vertical = 18.dp)) {
when {
selected == "android-apps" && s.isHomeApp -> {
Rise(0) { Eyebrow("Android apps") }
Rise(1) { Title("Installed apps") }
Rise(2) { SectionTitle("Apps", s.androidApps.size.toString()) }
if (s.androidApps.isEmpty()) Rise(3) { Note("No launchable Android apps found.") }
else Rise(3, Modifier.weight(1f).fillMaxWidth()) {
ArtGrid(s.androidApps.map { app ->
Tile(
app.label,
null,
null,
"android:${app.packageName}",
onClick = { a.onAndroidApp(app) },
iconBitmap = app.icon,
)
})
}
}
selected == "steam" -> {
Rise(0) { Eyebrow("Steam") }
Rise(3) {
@@ -662,7 +694,11 @@ private fun Content(s: FrontEndState, selected: String, a: FrontEndActions, modi
}
}
private class Tile(val title: String, val sub: String?, val art: File?, val key: String, val iconRes: Int? = null, val dim: Boolean = false, val onClick: () -> Unit)
private class Tile(
val title: String, val sub: String?, val art: File?, val key: String,
val iconRes: Int? = null, val dim: Boolean = false, val iconBitmap: Bitmap? = null,
val onClick: () -> Unit,
)
@Composable
@@ -777,7 +813,7 @@ private fun Poster(art: File?, name: String, modifier: Modifier) {
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun ArtGrid(tiles: List<Tile>, wide: Boolean = false) {
val square = tiles.isNotEmpty() && tiles.all { it.art == null && it.iconRes != null }
val square = tiles.isNotEmpty() && tiles.all { it.art == null && (it.iconRes != null || it.iconBitmap != null) }
// Thumbnails to recognise a game by, not posters; icon tiles are squares.
val minSize = if (square) 64.dp else if (wide) 92.dp else 70.dp
val gap = 8.dp
@@ -821,7 +857,7 @@ private fun GameTile(t: Tile, wide: Boolean, square: Boolean, src: MutableIntera
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = t.onClick),
) {
Box(modifier = Modifier.fillMaxWidth().shine(hot)) {
Art(t.art, t.iconRes, t.title, Modifier.fillMaxWidth(), wide)
Art(t.art, t.iconRes, t.title, Modifier.fillMaxWidth(), wide, t.iconBitmap)
androidx.compose.animation.AnimatedVisibility(
visible = hot, modifier = Modifier.align(Alignment.Center),
enter = scaleIn(Motion.sp(0.5f), initialScale = 0.5f) + fadeIn(Motion.tw(200)),
@@ -841,13 +877,14 @@ private fun GameTile(t: Tile, wide: Boolean, square: Boolean, src: MutableIntera
}
@Composable
private fun Art(art: File?, iconRes: Int?, label: String, modifier: Modifier, wide: Boolean = false) {
private fun Art(art: File?, iconRes: Int?, label: String, modifier: Modifier, wide: Boolean = false, iconBitmap: Bitmap? = null) {
val colors = MaterialTheme.colorScheme
val ratio = if (art == null && iconRes != null) 1f else if (wide) 16f / 9f else 2f / 3f
Box(modifier = modifier.aspectRatio(ratio).background(if (art == null && iconRes == null) artBrush(hueOf(label)) else Brush.linearGradient(listOf(colors.surfaceVariant, colors.surface)))) {
val ratio = if (art == null && (iconRes != null || iconBitmap != null)) 1f else if (wide) 16f / 9f else 2f / 3f
Box(modifier = modifier.aspectRatio(ratio).background(if (art == null && iconRes == null && iconBitmap == null) artBrush(hueOf(label)) else Brush.linearGradient(listOf(colors.surfaceVariant, colors.surface)))) {
when {
art != null -> AsyncImage(model = art, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize())
iconRes != null -> Image(painterResource(iconRes), null, contentScale = ContentScale.Fit, modifier = Modifier.fillMaxSize().padding(if (wide) 10.dp else 8.dp))
iconBitmap != null -> Image(bitmap = iconBitmap.asImageBitmap(), contentDescription = null, contentScale = ContentScale.Fit, modifier = Modifier.fillMaxSize().padding(8.dp))
else -> {
Spacer(Modifier.fillMaxSize().background(Brush.verticalGradient(0f to Color.Transparent, 0.45f to Color.Transparent, 1f to Color.Black.copy(alpha = 0.55f))))
Text(label, fontSize = 9.sp, fontWeight = FontWeight.Bold, color = Color.White.copy(alpha = 0.92f), modifier = Modifier.align(Alignment.BottomStart).padding(5.dp), maxLines = 3, overflow = TextOverflow.Ellipsis)
@@ -6,6 +6,10 @@ import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -18,6 +22,8 @@ class DriverRow(val id: String, val name: String, val detail: String, val remova
class ModeSettings(
val mode: String,
val resolutionCap: Int,
/** A fixed session size, or null for the cap and shape. */
val customResolution: Pair<Int, Int>? = null,
val shapeMode: String,
val hdr: Boolean,
val hdrReason: String?,
@@ -50,6 +56,8 @@ class AddedGameRow(val folderPath: String, val folderName: String, val exePath:
class ModeSettingsActions(
val onResolution: (Int) -> Unit,
/** Null clears it. */
val onCustomResolution: (Pair<Int, Int>?) -> Unit = {},
val onShape: (String) -> Unit,
val onHdr: (Boolean) -> Unit,
val onSelectLinux: (String) -> Unit,
@@ -88,16 +96,25 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
) {
SettingsGroup("Display") {
val default = SessionPrefs.defaultResolutionCap(s.mode)
var editCustom by remember { mutableStateOf(false) }
val custom = s.customResolution
ChoiceRow(
host, "res", "Resolution", "Applies next session.",
listOf(720 to "Up to 720p", 900 to "Up to 900p", 1080 to "Up to 1080p", 0 to "The panel's own")
.map { (cap, label) -> cap to (if (cap == default) "$label - the default" else label) },
s.resolutionCap, note = "720p can improve menu responsiveness.",
onPick = a.onResolution,
.map { (cap, label) -> cap to (if (cap == default) "$label - the default" else label) } +
(CUSTOM to (custom?.let { "Custom · ${it.first}×${it.second}" } ?: "Custom…")),
if (custom != null) CUSTOM else s.resolutionCap, note = "720p can improve menu responsiveness.",
onPick = { v -> if (v == CUSTOM) editCustom = true else { a.onCustomResolution(null); a.onResolution(v) } },
)
ChoiceRow(
host, "shape", "Shape", "Adds bars to preserve 16:9.",
listOf("auto" to "The panel's shape", "16:9" to "16:9 with bars"), s.shapeMode, onPick = a.onShape,
host, "shape", "Shape",
if (custom != null) "Set by the custom resolution." else "The panel's shape stays at 16:9 or wider; pick Exactly this panel for a 4:3 or 3:2 screen.",
com.steamdeck.launcher.session.SessionPrefs.shapeChoices, s.shapeMode, enabled = custom == null, onPick = a.onShape,
)
if (editCustom) CustomResolutionDialog(
initial = custom,
onSave = { size -> editCustom = false; a.onCustomResolution(size) },
onDismiss = { editCustom = false },
)
}
SettingsGroup("HDR") {
@@ -267,3 +284,59 @@ private fun DriverRowMenu(
}
}
}
/** The Resolution menu's "Custom…" entry. */
private const val CUSTOM = -1
/** Width × height for the session, with the common handheld shapes one tap away. */
@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class)
@Composable
private fun CustomResolutionDialog(initial: Pair<Int, Int>?, onSave: (Pair<Int, Int>) -> Unit, onDismiss: () -> Unit) {
var w by remember { mutableStateOf(initial?.first?.toString() ?: "") }
var h by remember { mutableStateOf(initial?.second?.toString() ?: "") }
val parsed = SessionPrefs.parseResolution("${w}x$h")
androidx.compose.material3.AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Custom resolution") },
text = {
androidx.compose.foundation.layout.Column {
Text(
"The session's display size. It replaces the cap and the shape; a size that does not match the panel's shape gets bars.",
fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant,
)
androidx.compose.foundation.layout.Row(
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
modifier = Modifier.padding(top = 12.dp),
) {
val numbers = androidx.compose.foundation.text.KeyboardOptions(keyboardType = androidx.compose.ui.text.input.KeyboardType.Number)
androidx.compose.material3.OutlinedTextField(
w, { v -> w = v.filter(Char::isDigit).take(4) }, label = { Text("Width") },
singleLine = true, keyboardOptions = numbers, modifier = Modifier.weight(1f),
)
Text("×", fontSize = 18.sp, modifier = Modifier.padding(horizontal = 10.dp))
androidx.compose.material3.OutlinedTextField(
h, { v -> h = v.filter(Char::isDigit).take(4) }, label = { Text("Height") },
singleLine = true, keyboardOptions = numbers, modifier = Modifier.weight(1f),
)
}
androidx.compose.foundation.layout.FlowRow(
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(6.dp),
modifier = Modifier.padding(top = 10.dp),
) {
for ((pw, ph, tag) in listOf(Triple(960, 720, "4:3"), Triple(1024, 768, "4:3"), Triple(1280, 960, "4:3"), Triple(1280, 800, "16:10"), Triple(1152, 648, "16:9"), Triple(1280, 720, "16:9"))) {
androidx.compose.material3.AssistChip(
onClick = { w = pw.toString(); h = ph.toString() },
label = { Text("$pw×$ph · $tag", fontSize = 12.sp) },
)
}
}
if (parsed == null && (w.isNotEmpty() || h.isNotEmpty())) Text(
"Between 320×240 and 3840×2160.", fontSize = 12.sp, color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 6.dp),
)
}
},
confirmButton = { androidx.compose.material3.TextButton(enabled = parsed != null, onClick = { parsed?.let(onSave) }) { Text("Use") } },
dismissButton = { androidx.compose.material3.TextButton(onClick = onDismiss) { Text("Cancel") } },
)
}
@@ -29,12 +29,14 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.steamdeck.launcher.R
import com.steamdeck.launcher.HomeApp
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Spring
@@ -54,6 +56,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
@@ -136,6 +139,9 @@ fun SessionPausedOverlay(onResume: () -> Unit) {
/** Everything the drawer shows and does. */
class DrawerActions(
val steam: Boolean,
val isHomeApp: Boolean,
val defaultHomeLabel: String?,
val androidApps: List<HomeApp.LaunchableApp>,
val hudOn: Boolean,
val frameGenEngine: String,
val frameGenMultiplier: Int,
@@ -160,6 +166,8 @@ class DrawerActions(
val onFexPreset: (String) -> Unit,
val onSecondScreenMode: (SecondScreenMode) -> Unit,
val onSecondScreenDisplay: (Int) -> Unit,
val onHomeApp: () -> Unit,
val onLaunchAndroidApp: (HomeApp.LaunchableApp) -> Unit,
val onBackground: () -> Unit,
val onStop: () -> Unit,
val onClose: () -> Unit,
@@ -170,6 +178,7 @@ fun SessionDrawer(open: Boolean, a: DrawerActions) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val host = rememberMenuHost()
var androidAppsExpanded by rememberSaveable { mutableStateOf(false) }
val veil by animateFloatAsState(if (open) 1f else 0f, Motion.tw(260), label = "veil")
if (open || veil > 0.01f) Box(
modifier = Modifier.fillMaxSize().graphicsLayer { alpha = veil }.background(Color(0x8A000000))
@@ -233,6 +242,40 @@ fun SessionDrawer(open: Boolean, a: DrawerActions) {
}
}
if (a.isHomeApp) SettingsGroup("Android apps") {
ActionRow("Launch an app", null, if (androidAppsExpanded) "Hide" else "Show") {
androidAppsExpanded = !androidAppsExpanded
}
if (androidAppsExpanded) {
if (a.androidApps.isEmpty()) {
Text("No launchable apps found", fontSize = 12.sp, color = colors.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp))
} else {
for (app in a.androidApps) {
MenuItem(
app.label,
checked = false,
leading = {
app.icon?.let { icon ->
Image(
bitmap = icon.asImageBitmap(),
contentDescription = null,
modifier = Modifier.size(26.dp).clip(RoundedCornerShape(6.dp)),
)
}
},
) { a.onLaunchAndroidApp(app) }
}
}
}
}
if (a.isHomeApp) {
SettingsGroup("Home screen") {
ActionRow("Default Home app", a.defaultHomeLabel?.let { "Currently using $it" }, "Change") { a.onHomeApp() }
}
}
SettingsGroup("Now") {
ToggleRow(host, "hud", "Performance HUD", null, a.hudOn, onChange = a.onHud)
val fgOpen = host.open == "fg"
@@ -282,7 +325,7 @@ fun SessionDrawer(open: Boolean, a: DrawerActions) {
SettingsGroup("Next session") {
ChoiceRow(
host, "shape", "Display shape", null,
listOf(SessionPrefs.SHAPE_AUTO to "The panel's shape", SessionPrefs.SHAPE_WIDE to "16:9 with bars"), a.shapeMode, onPick = a.onShape,
SessionPrefs.shapeChoices, a.shapeMode, onPick = a.onShape,
)
if (a.steam) ChoiceRow(
host, "fex", "FEX preset", null,
@@ -292,8 +335,11 @@ fun SessionDrawer(open: Boolean, a: DrawerActions) {
}
Spacer(Modifier.height(18.dp))
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
SecondaryButton("Send to background") { host.open = null; a.onBackground() }
Row(
horizontalArrangement = if (a.isHomeApp) Arrangement.End else Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxWidth(),
) {
if (!a.isHomeApp) SecondaryButton("Send to background") { host.open = null; a.onBackground() }
DangerButton("Stop session") { host.open = null; a.onStop() }
}
Spacer(Modifier.height(12.dp))
@@ -129,7 +129,7 @@ fun AnchoredMenu(open: Boolean, onDismiss: () -> Unit, title: String? = null, no
@Composable
fun MenuItem(
label: String, checked: Boolean, enabled: Boolean = true, detail: String? = null,
trailing: (@Composable () -> Unit)? = null, onClick: () -> Unit,
trailing: (@Composable () -> Unit)? = null, leading: (@Composable () -> Unit)? = null, onClick: () -> Unit,
) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
@@ -147,6 +147,10 @@ fun MenuItem(
.padding(horizontal = 10.dp, vertical = 9.dp),
) {
Text("✓", fontSize = 12.sp, color = pal.signal, modifier = Modifier.width(18.dp).alpha(if (checked) 1f else 0f))
if (leading != null) {
leading()
Spacer(Modifier.width(8.dp))
}
Column(modifier = Modifier.weight(1f)) {
Text(label, fontSize = 13.5.sp, color = if (checked) pal.signal else colors.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis)
if (detail != null) Text(detail, fontSize = 11.sp, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis)
+19
View File
@@ -6,6 +6,25 @@ the timeline, then lessons and backlog. Companion to the README (what the app *d
---
## 2026-09-23 (afternoon) - main `90c7574`: first-run onboarding, Desktop & apps page, 4:3 display
State: **main = `90c7574`** (PR #14 merge), main build run 35895701784. No tag, no release; next version is still open. **Nothing below is device-tested** except the audio default (Kurt, AYN Thor).
- **PR #11 → `f79dc2d`** (`fix/directaudio-default`): the Steam client's audio is back on the 0.1.5 classic sink by default (`module-aaudio-classic-sink.so`, byte-identical to 0.1.5's arm64 module); DirectAudio is a choice in the Steam cog ("Steam client audio", pref `clientDirectAudio`, default off). Games keep DirectAudio on by default; mic on by default, RECORD_AUDIO asked once. Old armhf sink out of the bundle; the bundle re-unpacks when its contents change (stamp = BUNDLE_STAMP + CRC32 of pulseaudio.tzst). Relay logs a heartbeat line; Setup › Session logs › Share latest logs. Kurt on the Thor: classic "works perfectly".
- **PR #12 → `0ddb0be`** (`feat/play-installs-runtime`):
- `586c9c2` Play and Desktop UI are enabled with no runtime; the session's loading screen downloads, checks and unpacks it ("downloading the Linux runtime · 332 of 791 MB"), then starts the session. A failure ends on the loading screen with the reason.
- `f437957` Desktop does the same: the runtime if missing, then the `desktop` package from `desktop.json` (426 MB), then LXQt. Play, Desktop UI and Desktop all give the non-Adreno warning before any download (`MainActivity.startSession`).
- `cbd3bc9` Desktop & apps is a page in the pane (pageKey `apps`), not a dialog: a rail-style dropdown per tier with an installed/total pill (Native ARM64 open), per-package Install/Remove, and the installing package's step line and bar in its own row. Leaving the page does not stop an install.
- **PR #14 → `90c7574`** (`fix/exact-panel-shape`), from a user report on a 4:3 handheld (RP Nova) - "the panel's shape" still gave 16:9:
- Cause: `SessionActivity` sized the display at `maxOf(panel aspect, 16:9)` (the foldable guard), so on a 4:3 panel both choices were 16:9 with bars.
- `9c47095` Shape › **Exactly this panel (4:3, 3:2…)** - the panel's own aspect, no floor. The other two unchanged; foldables still default to 16:9. In the cog and the in-session menu (`SessionPrefs.shapeChoices`).
- `ad2f9f8` Resolution › **Custom…** per mode (`customRes.<mode>`, 320×240-3840×2160, evened) with 4:3 / 16:10 / 16:9 presets. It replaces the cap and the shape (Shape greys out); picking a cap clears it; the device report lists it. Not in the in-session menu.
- Note: the branch was cut from `cbd3bc9`, not `0ddb0be` - same tree.
- Staged: `SteamDeck-play-installs-runtime-586c9c2.apk` (`21460acd…`), `…-f437957.apk` (`174e74a6…`), `SteamDeck-apps-page-cbd3bc9.apk` (`c2cfbebe…`), `SteamDeck-custom-res-ad2f9f8.apk` (`d02ee5ce…`, = main's content). HTML preview of the onboarding flow: `/sdcard/Download/SteamDeck-onboarding-preview.html`.
- CI note: a branch push does not build (`build.yml` = main + PRs); dispatch it with `gh workflow run build.yml --ref <branch>`. A newer dispatch on the same branch cancels the older run.
- Open: Kurt's PR #13 "Add Steam second-screen controls" (`steamdeck-second-screen`) - not reviewed; now needs to merge onto `90c7574`.
- **Next:** (1) fresh-install pass: uninstall, install, press Desktop first (runtime + desktop, ~1.2 GB), then Play with no download; (2) the Desktop & apps page on device (dropdowns, in-row bar, Back mid-install); (3) the Nova reporter tests Exactly this panel / Custom 960×720; (4) PR #13.
## 2026-09-23 - branch `feat/armada-and-lineage`
- Steam Deck mode: `-steamdeck` only; SteamOS helper stubs staged from the apk under `/usr/bin` and `/usr/bin/steamos-polkit-helpers` (the missing `steamos-update` there was the "Update Error" dialog; `jupiter-dock-updater --check` answers 7 so no dock firmware row); Deck mode defaults the `steamdeck_publicbeta` channel (on `publicbeta` every start reinstalled the client and lost the launch URL); client branch row in the Steam cog.