Merge pull request #16 from The412Banner/feat/qcom-driver-and-deck-fixes

Deck mode performance overlay, killed-session cleanup, NIS
This commit is contained in:
The412Banner authored and GitHub committed 2026-09-23 16:44:57 -04:00
commit a19dfb88a0
11 files changed
+798 -56

No files matched your search

+20 -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/overlay/usr/bin/**', 'tools/linuxfs/desktop/*', 'tools/gamescope/release.env') }}
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/*', 'tools/gamescope/release.env', 'tools/mangoapp/*') }}
- name: Build libfakeinput.so and libblsession.so (glibc aarch64)
if: steps.preloads.outputs.cache-hit != 'true'
@@ -191,6 +191,25 @@ jobs:
test -f "$D/usr/local/bin/gamescope"
rm gamescope.tzst
fi
# Valve's aarch64 mangoapp for Deck mode's performance overlay, plus the five libraries the
# runtime lacks, pinned by sha256 in tools/mangoapp/packages.txt. Stored under the SONAME
# each is loaded by, as real files: assets cannot carry symlinks.
M="$D/usr/local/lib/mangoapp"
mkdir -p "$M" mango-pkgs
grep -v '^#' tools/mangoapp/packages.txt | while read -r sum url; do
[ -n "$url" ] || continue
curl -fsSL --retry 3 -o mango-pkgs/pkg.tar.zst "$url"
echo "$sum mango-pkgs/pkg.tar.zst" | sha256sum -c -
tar --use-compress-program=unzstd -xf mango-pkgs/pkg.tar.zst -C mango-pkgs
rm mango-pkgs/pkg.tar.zst
done
install -m644 mango-pkgs/usr/bin/mangoapp "$M/mangoapp"
for so in libfmt.so.10 libspdlog.so.1.13 libglfw.so.3 libtraceevent.so.1 libtracefs.so.1; do
cp -L "mango-pkgs/usr/lib/$so" "$M/$so"
done
install -Dm644 tools/mangoapp/mangoapp "$D/usr/local/bin/mangoapp"
rm -rf mango-pkgs
ls -l "$M"
# 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"
@@ -22,6 +22,7 @@ import com.steamdeck.launcher.gpu.FrameGen
import com.steamdeck.launcher.gpu.LinuxVulkanDriver
import com.steamdeck.launcher.gpu.LinuxVulkanDriverManager
import com.steamdeck.launcher.gpu.TurnipDriver
import com.steamdeck.launcher.gpu.TurnipReleases
import com.steamdeck.launcher.gpu.LsfgNative
import com.steamdeck.launcher.runtime.LinuxRuntime
import com.steamdeck.launcher.runtime.DesktopCatalog
@@ -105,6 +106,14 @@ class MainActivity : ComponentActivity() {
private var clientDirectAudio by mutableStateOf(false)
private var mic by mutableStateOf(false)
private var linuxRows by mutableStateOf<List<DriverRow>>(emptyList())
/** The latest Banners-Turnip release as each driver menu offers it (see [refreshReleaseRows]). */
private var linuxDownloads by mutableStateOf<List<com.steamdeck.launcher.ui.DownloadRow>>(emptyList())
private var androidDownloads by mutableStateOf<List<com.steamdeck.launcher.ui.DownloadRow>>(emptyList())
private var releaseStatus by mutableStateOf("Not checked yet - tap refresh to look for new drivers")
private var releaseChecking by mutableStateOf(false)
private var canRestoreBundled by mutableStateOf(false)
/** Asset name -> download percent, while it downloads. */
private val releaseProgress = HashMap<String, Int>()
private var linuxSteam by mutableStateOf("")
private var linuxDesktop by mutableStateOf("")
private var androidRows by mutableStateOf<List<DriverRow>>(emptyList())
@@ -517,6 +526,8 @@ class MainActivity : ComponentActivity() {
addedGamesDirs = if (mode == SessionService.MODE_STEAM) addedGamesDirs else null,
addedGames = if (mode == SessionService.MODE_STEAM) addedGames else emptyList(),
addedGamesArt = addedGamesArt,
linuxDownloads = linuxDownloads, androidDownloads = androidDownloads, releaseStatus = releaseStatus,
releaseChecking = releaseChecking, canRestoreBundled = canRestoreBundled,
),
ModeSettingsActions(
onResolution = { cap -> SessionPrefs.setResolutionCap(this, mode, cap); resolutionCap = cap },
@@ -525,10 +536,13 @@ class MainActivity : ComponentActivity() {
onHdr = { on -> SessionPrefs.setHdr(this, mode, on); hdrOn = on },
onSelectLinux = { id -> SessionPrefs.setLinuxDriver(this, mode, id); refreshDrivers() },
onImportLinux = { pickLinuxDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a Linux runtime driver (-Linux zip)")) },
onRemoveLinux = { id -> LinuxVulkanDriverManager(this).removeDriver(id); refreshDrivers() },
onRemoveLinux = { id -> deleteDriver(id, linux = true) },
onRefreshReleases = { checkLatestTurnip() },
onDownloadDriver = { name -> downloadReleaseDriver(name) },
onRestoreBundled = { TurnipDriver(this).restoreBundled(); refreshDrivers() },
onSelectAndroid = { id -> SessionPrefs.setAndroidDriver(this, id); refreshDrivers() },
onImportAndroid = { pickAndroidDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a display driver (AdrenoTools zip)")) },
onRemoveAndroid = { id -> TurnipDriver(this).remove(id); refreshDrivers() },
onRemoveAndroid = { id -> deleteDriver(id, linux = false) },
onTouch = { t -> SessionPrefs.setTouchMode(this, t); touchMode = t },
onSuspendPolicy = { policy -> SessionPrefs.setSuspendPolicy(this, mode, policy); suspendPolicy = policy },
onOsc = { o -> SessionPrefs.setOscMode(this, o); oscMode = o },
@@ -651,6 +665,7 @@ class MainActivity : ComponentActivity() {
private fun refreshDrivers() {
val lm = LinuxVulkanDriverManager(this)
fun origin(id: String) = if (TurnipReleases.isDownloaded(this, id)) DriverRow.DOWNLOADED else DriverRow.IMPORTED
linuxRows = LinuxVulkanDriver.optionValues(this).map { id ->
if (id.isEmpty()) DriverRow("", "Runtime default", "the Turnip built into the runtime", false)
else DriverRow(
@@ -658,8 +673,8 @@ class MainActivity : ComponentActivity() {
listOfNotNull(
lm.getDriverVersion(id).takeIf { it.isNotEmpty() },
lm.getMinGlibc(id).takeIf { it.isNotEmpty() }?.let { "glibc $it+" },
).joinToString(" · ").ifEmpty { "imported" },
true,
).joinToString(" · "),
true, origin(id),
)
}
linuxSteam = SessionPrefs.linuxDriver(this, SessionService.MODE_STEAM)
@@ -672,10 +687,12 @@ class MainActivity : ComponentActivity() {
if (auto == "system") "system Vulkan: no bundled build for this GPU" else "${td.displayName(auto)} (bundled)",
false,
))
for (id in TurnipDriver.BUNDLED) add(DriverRow(id, td.displayName(id), "bundled" + td.driverVersion(id).let { if (it.isEmpty()) "" else " · $it" }, false))
for (id in td.enumerateImported()) add(DriverRow(id, td.displayName(id), "imported" + td.driverVersion(id).let { if (it.isEmpty()) "" else " · $it" }, true))
for (id in td.visibleBundled()) add(DriverRow(id, td.displayName(id), td.driverVersion(id), true, DriverRow.BUNDLED))
for (id in td.enumerateImported()) add(DriverRow(id, td.displayName(id), td.driverVersion(id), true, origin(id)))
}
canRestoreBundled = td.hiddenBundled().isNotEmpty()
androidSelected = SessionPrefs.androidDriver(this)
refreshReleaseRows()
}
/**
@@ -705,6 +722,112 @@ class MainActivity : ComponentActivity() {
}, "import-driver").start()
}
/**
* Delete an imported or downloaded driver. A mode still set to it goes back to its default, so a
* session never starts on a driver that is gone; a release download is forgotten, so the menu
* offers it again.
*/
private fun deleteDriver(id: String, linux: Boolean) {
if (linux) {
LinuxVulkanDriverManager(this).removeDriver(id)
for (mode in listOf(SessionService.MODE_STEAM, SessionService.MODE_DESKTOP)) {
if (SessionPrefs.linuxDriver(this, mode) == id) SessionPrefs.setLinuxDriver(this, mode, "")
}
} else {
val td = TurnipDriver(this)
if (id in TurnipDriver.BUNDLED) td.hideBundled(id) else td.remove(id)
if (SessionPrefs.androidDriver(this) == id) SessionPrefs.setAndroidDriver(this, TurnipDriver.AUTO)
}
TurnipReleases.forget(this, id)
android.widget.Toast.makeText(this, "Deleted ${id}", android.widget.Toast.LENGTH_SHORT).show()
refreshDrivers()
}
/** The download entries and the refresh line, from what the last check found. */
private fun refreshReleaseRows() {
val check = TurnipReleases.cached(this)
val lm = LinuxVulkanDriverManager(this)
val td = TurnipDriver(this)
fun rows(linux: Boolean) = check?.assets.orEmpty()
.filter { it.linux == linux }
.filter { a -> TurnipReleases.installedId(this, a) { id -> if (linux) lm.isInstalled(id) else td.isInstalled(id) } == null }
.map { a ->
val mb = "%.1f MB".format(a.size / 1_048_576.0)
com.steamdeck.launcher.ui.DownloadRow(a.name, "${a.source} ${a.tag}", "${a.label} · $mb", releaseProgress[a.name])
}
linuxDownloads = rows(linux = true)
androidDownloads = rows(linux = false)
if (!releaseChecking) releaseStatus = when (check) {
null -> "Not checked yet - tap refresh to look for new drivers"
else -> "Latest: " + check.latest.joinToString(" · ") { "${it.first} ${it.second}" } +
(if (check.failed.isEmpty()) "" else " · ${check.failed.joinToString()} unreachable") +
" · checked ${ago(check.checkedAt)}"
}
}
private fun ago(t: Long): String {
val m = ((System.currentTimeMillis() - t) / 60_000).coerceAtLeast(0)
return when {
m < 1 -> "just now"
m < 60 -> "$m min ago"
m < 48 * 60 -> "${m / 60} h ago"
else -> "${m / (24 * 60)} days ago"
}
}
/** Only when the user taps refresh: nothing goes online on its own. */
private fun checkLatestTurnip() {
if (releaseChecking) return
releaseChecking = true
releaseStatus = "Checking Banners-Turnip and WinNative…"
Thread({
val problem = try { TurnipReleases.refresh(this); null } catch (e: Exception) {
Log.w(TAG, "latest Turnip check", e); e.message ?: "check failed"
}
ui.post {
releaseChecking = false
refreshReleaseRows()
if (problem != null) releaseStatus = "Couldn't check: $problem"
}
}, "turnip-release-check").start()
}
/** Download one release driver and import it through the same importer a picked zip uses. */
private fun downloadReleaseDriver(assetName: String) {
val asset = TurnipReleases.cached(this)?.assets?.firstOrNull { it.name == assetName } ?: return
if (releaseProgress.containsKey(assetName)) return
releaseProgress[assetName] = 0
refreshReleaseRows()
Thread({
var file: java.io.File? = null
val problem = try {
file = TurnipReleases.download(this, asset) { pct ->
ui.post { releaseProgress[assetName] = pct; refreshReleaseRows() }
}
val uri = Uri.fromFile(file)
val id = if (asset.linux) LinuxVulkanDriverManager(this).installDriver(uri, asset.name)
else TurnipDriver(this).installFromZip(uri, asset.name)
TurnipReleases.recordDownload(this, asset, id)
null
} catch (e: IllegalArgumentException) {
e.message
} catch (e: Exception) {
Log.w(TAG, "release driver download", e)
"Download failed: ${e.message}"
} finally {
file?.let { com.steamdeck.launcher.core.FileUtils.delete(it) }
}
ui.post {
releaseProgress.remove(assetName)
android.widget.Toast.makeText(
this, problem ?: "Installed ${asset.name.removeSuffix(".zip")} - pick it in the menu",
android.widget.Toast.LENGTH_LONG,
).show()
refreshDrivers()
}
}, "download-turnip").start()
}
private fun displayNameOf(uri: Uri): String? = if (uri.scheme == "file") uri.lastPathSegment else try {
contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)
?.use { c -> if (c.moveToFirst()) c.getString(0) else null }
@@ -52,6 +52,9 @@ public final class TurnipDriver {
public static final List<String> BUNDLED = Collections.unmodifiableList(Arrays.asList(DRIVER_A7XX, DRIVER_A8XX));
/** Overrides the pick, for a device we cannot reach: a7xx, a8xx or system. */
private static final String OVERRIDE_FILE = "Download/steamdeck-driver";
/** Bundled drivers the user deleted: hidden from the list, their unpacked copy removed. */
private static final String PREFS = "graphics_driver";
private static final String KEY_HIDDEN = "hiddenBundled";
/** Stored choice meaning "pick by GPU" - the default, and what a removed import falls back to. */
public static final String AUTO = "";
@@ -111,6 +114,37 @@ public final class TurnipDriver {
return library != null && !library.isEmpty() && new File(new File(contentDir, driverId), library).isFile();
}
/**
* The bundled drivers the list shows: {@link #BUNDLED} minus the ones the user deleted. A deleted
* one still lives in the apk and Auto still unpacks it on a GPU that needs it, so deleting can
* never leave a device without a driver; it only leaves the list.
*/
public List<String> visibleBundled() {
java.util.Set<String> hidden = hiddenBundled();
ArrayList<String> out = new ArrayList<>();
for (String id : BUNDLED) if (!hidden.contains(id)) out.add(id);
return out;
}
public java.util.Set<String> hiddenBundled() {
return new java.util.HashSet<>(context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getStringSet(KEY_HIDDEN, java.util.Collections.<String>emptySet()));
}
/** "Delete" for a bundled driver: remove its unpacked copy and hide it from the list. */
public void hideBundled(String driverId) {
if (!BUNDLED.contains(driverId)) return;
FileUtils.delete(new File(contentDir, driverId));
java.util.Set<String> hidden = hiddenBundled();
hidden.add(driverId);
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putStringSet(KEY_HIDDEN, hidden).apply();
Log.i(TAG, "bundled driver " + driverId + " hidden by the user");
}
public void restoreBundled() {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().remove(KEY_HIDDEN).apply();
}
/** Imported drivers only - the bundled two are listed by {@link #BUNDLED}. */
public List<String> enumerateImported() {
ArrayList<String> ids = new ArrayList<>();
@@ -0,0 +1,207 @@
package com.steamdeck.launcher.gpu
import android.content.Context
import com.steamdeck.launcher.core.FileUtils
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
/**
* Driver downloads from the Turnip release repos, offered in both driver menus. Checked only when
* the user taps refresh - the result is remembered, so the menu shows the last check without going
* online. A download goes through the same importer as a zip picked by hand, so it gets the same
* checks and lands as an ordinary imported driver.
*
* Each repo's recent releases are read newest first and every driver variant is offered from the
* newest release that carries it: a repo that stops publishing one variant (Banners-Turnip's A8xx
* Linux build after v26.3.0-20260923) still offers its last one, and a repo with two release lines
* (WinNative's Android `v1.x` and Linux `linux-v0.x`) offers the newest of each.
*/
object TurnipReleases {
private const val PREFS = "turnip_releases"
private const val KEY_RELEASE = "latest"
private const val KEY_DOWNLOADS = "downloads"
/** [label] names the variant for the menu: the GPUs it is for, and a build flavour. */
class Asset(val source: String, val tag: String, val name: String, val url: String, val size: Long, val linux: Boolean, val label: String)
/** [latest] = each source's newest release tag, for the refresh line. */
class Check(val assets: List<Asset>, val latest: List<Pair<String, String>>, val failed: List<String>, val checkedAt: Long)
private class Source(val label: String, val repo: String, val classify: (name: String, tag: String) -> Pair<Boolean, String>?)
private val SOURCES = listOf(
// Turnip-<tag>[-variant][-Linux|-Wayland].zip; -Wayland is for a path this app does not have.
Source("Banners-Turnip", "The412Banner/Banners-Turnip") { name, tag ->
val prefix = "Turnip-$tag"
if (!name.startsWith(prefix) || !name.endsWith(".zip")) return@Source null
var variant = name.removePrefix(prefix).removeSuffix(".zip")
if (variant.endsWith("-Wayland")) return@Source null
val linux = variant.endsWith("-Linux")
variant = variant.removeSuffix("-Linux")
linux to when {
variant.isEmpty() -> "Adreno 6xx/7xx"
variant == "-A8xx" -> "Adreno 8xx"
variant.startsWith("-710-720") -> "Adreno 710/720" + if (variant.contains("Test")) " (test)" else ""
else -> variant.trimStart('-')
}
},
// WN-Turnip-<ver>-<b|p>_Axxx.zip (Android) and WN-Linux-Turnip-<ver>-<b|p>_Axxx.zip (Linux):
// b = Balanced, p = Performance, Axxx = every Adreno.
Source("WinNative", "WinNative-Emu/Drivers") { name, _ ->
val m = Regex("""^WN-(Linux-)?Turnip-[^-]+-([a-z]+)_(\w+)\.zip$""").find(name) ?: return@Source null
val flavour = when (m.groupValues[2]) { "b" -> "Balanced"; "p" -> "Performance"; else -> m.groupValues[2] }
val gpus = if (m.groupValues[3] == "Axxx") "all Adreno" else m.groupValues[3]
(m.groupValues[1].isNotEmpty()) to "$gpus · $flavour"
},
)
/** The result of the last check, or null when the user has never checked. */
fun cached(context: Context): Check? {
val raw = prefs(context).getString(KEY_RELEASE, null) ?: return null
return runCatching { parse(JSONObject(raw)) }.getOrNull()
}
/**
* Read every source now and remember the result. A source that fails is named in [Check.failed]
* and the others still count; throws only when every source failed.
*/
fun refresh(context: Context): Check {
val assets = JSONArray()
val latest = JSONArray()
val failed = ArrayList<String>()
var lastError: Exception? = null
for (src in SOURCES) {
try {
val releases = JSONArray(get("https://api.github.com/repos/${src.repo}/releases?per_page=15"))
val seen = HashSet<String>()
var newest: String? = null
for (i in 0 until releases.length()) {
val r = releases.getJSONObject(i)
if (r.optBoolean("draft")) continue
val tag = r.getString("tag_name")
if (newest == null) newest = tag
val list = r.optJSONArray("assets") ?: continue
for (j in 0 until list.length()) {
val a = list.getJSONObject(j)
val name = a.getString("name")
val (linux, label) = src.classify(name, tag) ?: continue
// Newest first: the first release carrying a variant is the one offered.
if (!seen.add("$linux|$label")) continue
assets.put(JSONObject().put("source", src.label).put("tag", tag).put("name", name)
.put("url", a.getString("browser_download_url")).put("size", a.optLong("size"))
.put("linux", linux).put("label", label))
}
}
if (newest != null) latest.put(JSONObject().put("source", src.label).put("tag", newest))
} catch (e: Exception) {
failed.add(src.label)
lastError = e
}
}
if (failed.size == SOURCES.size) throw lastError ?: IOException("no source answered")
val stored = JSONObject().put("assets", assets).put("latest", latest)
.put("failed", JSONArray(failed)).put("checkedAt", System.currentTimeMillis())
prefs(context).edit().putString(KEY_RELEASE, stored.toString()).apply()
return parse(stored)
}
private fun get(url: String): String {
val c = URL(url).openConnection() as HttpURLConnection
c.connectTimeout = 15_000
c.readTimeout = 20_000
c.setRequestProperty("Accept", "application/vnd.github+json")
c.setRequestProperty("User-Agent", "SteamDeck-app")
try {
val code = c.responseCode
if (code == 403 || code == 429) throw IOException("GitHub's rate limit was hit - try again later")
if (code != 200) throw IOException("GitHub answered HTTP $code")
return c.inputStream.bufferedReader().use { it.readText() }
} finally {
c.disconnect()
}
}
private fun parse(json: JSONObject): Check {
val out = ArrayList<Asset>()
val list = json.getJSONArray("assets")
for (i in 0 until list.length()) {
val a = list.getJSONObject(i)
out.add(Asset(a.getString("source"), a.getString("tag"), a.getString("name"), a.getString("url"),
a.optLong("size"), a.getBoolean("linux"), a.getString("label")))
}
val latest = ArrayList<Pair<String, String>>()
val l = json.optJSONArray("latest") ?: JSONArray()
for (i in 0 until l.length()) l.getJSONObject(i).let { latest.add(it.getString("source") to it.getString("tag")) }
val failed = ArrayList<String>()
val f = json.optJSONArray("failed") ?: JSONArray()
for (i in 0 until f.length()) failed.add(f.getString(i))
return Check(out, latest, failed, json.optLong("checkedAt"))
}
/** The id an asset was installed as, when it was downloaded before and is still installed. */
fun installedId(context: Context, asset: Asset, isInstalled: (String) -> Boolean): String? =
downloads(context).optString(asset.name, "").takeIf { it.isNotEmpty() && isInstalled(it) }
/** True when [id] came from a release download rather than a zip picked by hand. */
fun isDownloaded(context: Context, id: String): Boolean {
val d = downloads(context)
return d.keys().asSequence().any { d.optString(it) == id }
}
fun recordDownload(context: Context, asset: Asset, id: String) {
prefs(context).edit().putString(KEY_DOWNLOADS, downloads(context).put(asset.name, id).toString()).apply()
}
/** Forget a deleted driver, so its release offers the download again. */
fun forget(context: Context, id: String) {
val d = downloads(context)
val keys = d.keys().asSequence().filter { d.optString(it) == id }.toList()
if (keys.isEmpty()) return
keys.forEach { d.remove(it) }
prefs(context).edit().putString(KEY_DOWNLOADS, d.toString()).apply()
}
/** Download an asset into the cache; the caller imports it and deletes the file. */
fun download(context: Context, asset: Asset, progress: (Int) -> Unit): File {
val target = File(context.cacheDir, asset.name)
val c = URL(asset.url).openConnection() as HttpURLConnection
c.connectTimeout = 15_000
c.readTimeout = 60_000
c.setRequestProperty("User-Agent", "SteamDeck-app")
c.instanceFollowRedirects = true
try {
if (c.responseCode != 200) throw IOException("download answered HTTP ${c.responseCode}")
val total = c.contentLengthLong.takeIf { it > 0 } ?: asset.size
c.inputStream.use { input ->
FileOutputStream(target).use { out ->
val buf = ByteArray(1 shl 16)
var done = 0L
var last = -1
while (true) {
val r = input.read(buf)
if (r <= 0) break
out.write(buf, 0, r)
done += r
val pct = if (total > 0) (done * 100 / total).toInt() else 0
if (pct != last) { last = pct; progress(pct) }
}
}
}
return target
} catch (e: Exception) {
FileUtils.delete(target)
throw e
} finally {
c.disconnect()
}
}
private fun downloads(context: Context): JSONObject =
runCatching { JSONObject(prefs(context).getString(KEY_DOWNLOADS, "{}")!!) }.getOrDefault(JSONObject())
private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
}
@@ -64,9 +64,20 @@ object SessionFiles {
// The patched gamescope (tools/gamescope): the runtime's own version rebuilt with the ARM64
// client fixes, over /usr/local/bin so it comes first in the session's PATH. Only when the
// apk carries it - a build without the asset leaves the runtime's copy alone.
val optional = arrayOf(
// Valve's mangoapp (tools/mangoapp) - Deck mode's performance overlay - with the five
// libraries the runtime lacks beside it, and the wrapper on PATH that points it at them.
val mangoapp = listOf(
"usr/local/bin/mangoapp",
"usr/local/lib/mangoapp/mangoapp",
"usr/local/lib/mangoapp/libfmt.so.10",
"usr/local/lib/mangoapp/libspdlog.so.1.13",
"usr/local/lib/mangoapp/libglfw.so.3",
"usr/local/lib/mangoapp/libtraceevent.so.1",
"usr/local/lib/mangoapp/libtracefs.so.1",
).map { it to it }
val optional = (arrayOf(
"usr/local/bin/gamescope" to "usr/local/bin/gamescope",
).filter { (asset, _) ->
) + mangoapp).filter { (asset, _) ->
val dir = asset.substringBeforeLast('/')
runCatching { context.assets.list("linuxfs/$dir")?.contains(asset.substringAfterLast('/')) == true }.getOrDefault(false)
}
@@ -0,0 +1,236 @@
package com.steamdeck.launcher.ui
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Refresh
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/**
* One driver list as a full page - the Runtime driver or the Display driver - opened from the
* session's settings page. Installed drivers are picked by tapping the row and each carries a trash
* can (the "Auto"/"Runtime default" row is a setting, not a driver, so it has none); what the last
* check of the release repos found is offered below; the refresh button beside the title is the
* only thing that goes online.
*/
@Composable
fun DriverPage(
title: String,
hint: String,
rows: List<DriverRow>,
selected: String,
downloads: List<DownloadRow>,
status: String,
checking: Boolean,
importLabel: String,
canRestore: Boolean,
onSelect: (String) -> Unit,
onDelete: (String) -> Unit,
onRefresh: () -> Unit,
onDownload: (String) -> Unit,
onImport: () -> Unit,
onRestore: () -> Unit,
onBack: () -> Unit,
) {
val host = rememberMenuHost()
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
var confirm by remember { mutableStateOf<DriverRow?>(null) }
// Back (the controller's B too) closes this page only, back to the session's settings.
BackHandler(onBack = onBack)
// D-pad: up from the first driver lands on refresh; the page opens with the driver in use focused.
val refreshFocus = remember { FocusRequester() }
val selectedFocus = remember { FocusRequester() }
LaunchedEffect(Unit) { runCatching { selectedFocus.requestFocus() } }
val refreshSrc = remember { MutableInteractionSource() }
val refreshHot = refreshSrc.collectIsFocusedAsState().value || refreshSrc.collectIsHoveredAsState().value
SettingsPage(
host, title = title, onBack = onBack, lede = "$hint\n$status",
action = {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(40.dp).clip(RoundedCornerShape(10.dp))
.background(if (refreshHot) pal.signal.copy(alpha = 0.16f) else Color.Transparent)
.border(if (refreshHot) 2.dp else 1.dp, if (refreshHot) pal.signal else pal.line, RoundedCornerShape(10.dp))
.focusRequester(refreshFocus)
.hoverable(refreshSrc)
.clickable(interactionSource = refreshSrc, indication = null, enabled = !checking, onClick = onRefresh),
) {
if (checking) CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(18.dp))
else Icon(Icons.Outlined.Refresh, contentDescription = "Check for new drivers", tint = colors.onBackground, modifier = Modifier.size(20.dp))
}
},
) {
SettingsGroup("Installed") {
val focusRow = rows.firstOrNull { it.id == selected } ?: rows.firstOrNull()
rows.forEachIndexed { i, row ->
InstalledRow(
row, row.id == selected, onSelect = { onSelect(row.id) }, onDelete = { confirm = row },
modifier = Modifier
.then(if (row === focusRow) Modifier.focusRequester(selectedFocus) else Modifier)
.then(if (i == 0) Modifier.focusProperties { up = refreshFocus } else Modifier),
)
}
}
SettingsGroup("Available to download") {
if (downloads.isEmpty()) Text(
"Nothing new from the last check. Tap the refresh button to look for newer releases.",
fontSize = 12.5.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 14.dp, vertical = 14.dp),
) else for (d in downloads) {
SettingsRow(d.label, d.detail) {
val p = d.progress
if (p == null) SecondaryButton("Download") { onDownload(d.key) }
else Column(horizontalAlignment = Alignment.End, modifier = Modifier.width(120.dp)) {
Text("$p%", fontSize = 12.sp, color = colors.onSurfaceVariant)
Spacer(Modifier.height(4.dp))
LinearProgressIndicator(progress = { p / 100f }, modifier = Modifier.fillMaxWidth())
}
}
}
}
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 14.dp)) {
SecondaryButton(importLabel, onClick = onImport)
if (canRestore) FocusText("Restore built-in drivers", pal.signal, onClick = onRestore)
}
}
confirm?.let { row ->
AlertDialog(
onDismissRequest = { confirm = null },
title = { Text("Delete ${row.name}?") },
text = {
Text(
if (row.tag == DriverRow.BUNDLED) "It is built into the app, so this removes its unpacked files and hides it. \"Restore built-in drivers\" brings it back, and Auto still uses it on a GPU that needs it."
else "Its files are removed from the app." + if (row.id == selected) " It is the driver in use, so the default takes its place." else "",
fontSize = 13.sp,
)
},
// Opens on Cancel, so a stray A press on a controller never deletes anything.
confirmButton = { FocusText("Delete", colors.error) { confirm = null; onDelete(row.id) } },
dismissButton = {
val cancelFocus = remember { FocusRequester() }
LaunchedEffect(Unit) { runCatching { cancelFocus.requestFocus() } }
FocusText("Cancel", colors.onBackground, modifier = Modifier.focusRequester(cancelFocus)) { confirm = null }
},
)
}
}
@Composable
private fun InstalledRow(row: DriverRow, selected: Boolean, onSelect: () -> Unit, onDelete: () -> Unit, modifier: Modifier = Modifier) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value
val shape = RoundedCornerShape(10.dp)
// Two focus targets side by side - the driver itself, and its trash can - so a controller's
// d-pad right reaches the trash can and left comes back. (Inside one clickable row the trash
// can was a child of the focused area and "right" had nowhere to go.) The row's outline still
// follows the driver part, so it looks the same.
val mainFocus = remember { FocusRequester() }
val delFocus = remember { FocusRequester() }
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(3.dp).clip(shape)
.background(if (hot) pal.signal.copy(alpha = 0.14f) else if (selected) pal.signal.copy(alpha = 0.08f) else Color.Transparent)
.border(2.dp, if (hot) pal.signal else Color.Transparent, shape),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1f).then(modifier).focusRequester(mainFocus)
.then(if (row.removable) Modifier.focusProperties { right = delFocus } else Modifier)
.hoverable(src).clickable(interactionSource = src, indication = null, onClick = onSelect)
.padding(horizontal = 11.dp, vertical = 8.dp),
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(20.dp).border(2.dp, if (selected) pal.signal else colors.onSurfaceVariant, CircleShape),
) { if (selected) Box(Modifier.size(10.dp).background(pal.signal, CircleShape)) }
Spacer(Modifier.width(14.dp))
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
row.name, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis,
color = if (selected) pal.signal else colors.onBackground, modifier = Modifier.weight(1f, fill = false),
)
if (row.tag.isNotEmpty()) Text(
row.tag, fontSize = 9.5.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp, color = colors.onSurfaceVariant,
modifier = Modifier.padding(start = 8.dp).clip(RoundedCornerShape(5.dp)).background(Color.White.copy(alpha = 0.07f))
.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
if (row.detail.isNotEmpty()) Text(row.detail, fontSize = 11.5.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 2.dp))
}
}
if (row.removable) {
val delSrc = remember { MutableInteractionSource() }
val delHot = delSrc.collectIsFocusedAsState().value || delSrc.collectIsHoveredAsState().value
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.padding(end = 8.dp).size(40.dp).clip(RoundedCornerShape(10.dp))
.background(if (delHot) colors.error.copy(alpha = 0.16f) else Color.Transparent)
.border(2.dp, if (delHot) colors.error else Color.Transparent, RoundedCornerShape(10.dp))
.focusRequester(delFocus)
.focusProperties { left = mainFocus }
.hoverable(delSrc).clickable(interactionSource = delSrc, indication = null, onClick = onDelete),
) { Icon(Icons.Outlined.Delete, contentDescription = "Delete ${row.name}", tint = if (delHot) colors.error else colors.onSurfaceVariant, modifier = Modifier.size(20.dp)) }
}
}
Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
}
/** A text button that shows where controller focus is: an outline in its own colour. */
@Composable
private fun FocusText(text: String, color: Color, modifier: Modifier = Modifier, onClick: () -> Unit) {
val src = remember { MutableInteractionSource() }
val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value
val shape = RoundedCornerShape(8.dp)
Text(
text, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = color,
modifier = modifier.clip(shape)
.background(if (hot) color.copy(alpha = 0.14f) else Color.Transparent)
.border(2.dp, if (hot) color else Color.Transparent, shape)
.hoverable(src).clickable(interactionSource = src, indication = null, onClick = onClick)
.padding(horizontal = 12.dp, vertical = 8.dp),
)
}
@@ -11,13 +11,24 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.steamdeck.launcher.core.FexPreset
import com.steamdeck.launcher.session.SessionPrefs
import com.steamdeck.launcher.session.SessionService
class DriverRow(val id: String, val name: String, val detail: String, val removable: Boolean)
/** [tag] is shown beside the name: [BUNDLED], [DOWNLOADED], [IMPORTED], or "" for Auto / Runtime default. */
class DriverRow(val id: String, val name: String, val detail: String, val removable: Boolean, val tag: String = "") {
companion object {
const val BUNDLED = "BUNDLED"
const val DOWNLOADED = "DOWNLOADED"
const val IMPORTED = "IMPORTED"
}
}
/** A release driver (Banners-Turnip, WinNative) that is not installed yet; [key] is its asset name. */
class DownloadRow(val key: String, val label: String, val detail: String, val progress: Int? = null)
class ModeSettings(
val mode: String,
@@ -49,6 +60,13 @@ class ModeSettings(
val addedGamesDirs: List<String>? = null,
val addedGames: List<AddedGameRow> = emptyList(),
val addedGamesArt: Boolean = true,
/** Latest Banners-Turnip release: what each driver menu offers to download, and the refresh line. */
val linuxDownloads: List<DownloadRow> = emptyList(),
val androidDownloads: List<DownloadRow> = emptyList(),
val releaseStatus: String = "Not checked yet - tap refresh to look for new drivers",
val releaseChecking: Boolean = false,
/** A bundled display driver was deleted: the page offers to restore it. */
val canRestoreBundled: Boolean = false,
)
/** One added game as the settings page shows it: its folder, the chosen .exe, the other .exe files it could be. */
@@ -63,6 +81,10 @@ class ModeSettingsActions(
val onSelectLinux: (String) -> Unit,
val onImportLinux: () -> Unit,
val onRemoveLinux: (String) -> Unit,
val onRefreshReleases: () -> Unit = {},
/** Asset name of the release driver to download. */
val onDownloadDriver: (String) -> Unit = {},
val onRestoreBundled: () -> Unit = {},
val onSelectAndroid: (String) -> Unit,
val onImportAndroid: () -> Unit,
val onRemoveAndroid: (String) -> Unit,
@@ -89,10 +111,57 @@ class ModeSettingsActions(
fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
val steam = s.mode == SessionService.MODE_STEAM
val host = rememberMenuHost()
// The two driver lists open as full pages over this one ("rt" = runtime, "panel" = display).
// Coming back restores this page as it was left: the same scroll position, and controller focus
// on the driver box that opened the page.
var driverPage by remember { mutableStateOf<String?>(null) }
var returnTo by remember { mutableStateOf<String?>(null) }
val pageScroll = androidx.compose.foundation.rememberScrollState()
val runtimeChip = remember { androidx.compose.ui.focus.FocusRequester() }
val displayChip = remember { androidx.compose.ui.focus.FocusRequester() }
val firstChip = remember { androidx.compose.ui.focus.FocusRequester() }
fun openDriverPage(key: String) { returnTo = key; driverPage = key }
androidx.compose.runtime.LaunchedEffect(driverPage) {
if (driverPage == null) {
// One frame first: the box has to be laid out before it can take focus. Opened from the
// cog, focus starts on the first control (Resolution) so the d-pad works at once; back
// from a driver page, it returns to the driver box that opened it.
androidx.compose.runtime.withFrameNanos { }
val target = when (returnTo) { "rt" -> runtimeChip; "panel" -> displayChip; else -> firstChip }
runCatching { target.requestFocus() }
}
}
when (driverPage) {
"rt" -> {
DriverPage(
title = "Runtime driver",
hint = (if (steam) "Used by Steam and games." else "Used by desktop apps.") + " Applies next session.",
rows = s.linuxRows, selected = s.linuxSelected, downloads = s.linuxDownloads,
status = s.releaseStatus, checking = s.releaseChecking, importLabel = "Import Turnip zip…", canRestore = false,
onSelect = a.onSelectLinux, onDelete = a.onRemoveLinux, onRefresh = a.onRefreshReleases,
onDownload = a.onDownloadDriver, onImport = a.onImportLinux, onRestore = {}, onBack = { driverPage = null },
)
return
}
"panel" -> {
DriverPage(
title = "Display driver",
hint = "Used by the compositor in both modes. Restart the app to apply.",
rows = s.androidRows, selected = s.androidSelected, downloads = s.androidDownloads,
status = s.releaseStatus, checking = s.releaseChecking, importLabel = "Import an AdrenoTools zip…",
canRestore = s.canRestoreBundled,
onSelect = a.onSelectAndroid, onDelete = a.onRemoveAndroid, onRefresh = a.onRefreshReleases,
onDownload = a.onDownloadDriver, onImport = a.onImportAndroid, onRestore = a.onRestoreBundled,
onBack = { driverPage = null },
)
return
}
}
SettingsPage(
host,
title = if (steam) "Steam session" else "Desktop session",
onBack = a.onDismiss,
scroll = pageScroll,
) {
SettingsGroup("Display") {
val default = SessionPrefs.defaultResolutionCap(s.mode)
@@ -104,6 +173,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
.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.",
chipModifier = androidx.compose.ui.Modifier.focusRequester(firstChip),
onPick = { v -> if (v == CUSTOM) editCustom = true else { a.onCustomResolution(null); a.onResolution(v) } },
)
ChoiceRow(
@@ -126,18 +196,18 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
)
}
SettingsGroup("Drivers") {
DriverRowMenu(
host, "rt", "Runtime driver",
(if (steam) "Used by Steam and games." else "Used by desktop apps.") + " Applies next session.",
s.linuxRows, s.linuxSelected, importLabel = "Import Turnip zip…",
onSelect = a.onSelectLinux, onRemove = a.onRemoveLinux, onImport = a.onImportLinux,
)
DriverRowMenu(
host, "panel", "Display driver",
"Used by the compositor in both modes. Restart the app to apply.",
s.androidRows, s.androidSelected, importLabel = "Import an AdrenoTools zip…",
onSelect = a.onSelectAndroid, onRemove = a.onRemoveAndroid, onImport = a.onImportAndroid,
)
SettingsRow("Runtime driver", (if (steam) "Used by Steam and games." else "Used by desktop apps.") + " Applies next session.") {
ValueChip(
s.linuxRows.firstOrNull { it.id == s.linuxSelected }?.name ?: "Runtime default", open = false,
modifier = androidx.compose.ui.Modifier.focusRequester(runtimeChip),
) { openDriverPage("rt") }
}
SettingsRow("Display driver", "Used by the compositor in both modes. Restart the app to apply.") {
ValueChip(
s.androidRows.firstOrNull { it.id == s.androidSelected }?.name ?: "Auto - picked by GPU", open = false,
modifier = androidx.compose.ui.Modifier.focusRequester(displayChip),
) { openDriverPage("panel") }
}
}
SettingsGroup(if (steam) "Touch & controls" else "Touch") {
ChoiceRow(
@@ -259,32 +329,6 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
}
}
@Composable
private fun DriverRowMenu(
host: MenuHost, key: String, label: String, hint: String, rows: List<DriverRow>, selected: String, importLabel: String,
onSelect: (String) -> Unit, onRemove: (String) -> Unit, onImport: () -> Unit,
) {
val open = host.open == key
val colors = MaterialTheme.colorScheme
SettingsRow(label, hint, highlighted = open) {
androidx.compose.foundation.layout.Box {
ValueChip(rows.firstOrNull { it.id == selected }?.name ?: rows.firstOrNull()?.name ?: "-", open) { host.open = if (open) null else key }
AnchoredMenu(open, onDismiss = { if (host.open == key) host.open = null }, title = label) {
for (row in rows) MenuItem(
row.name, checked = row.id == selected, detail = row.detail.ifEmpty { null },
trailing = if (row.removable) ({
Text(
"✕", fontSize = 12.sp, color = colors.onSurfaceVariant,
modifier = Modifier.size(24.dp).padding(4.dp).clickable { onRemove(row.id); host.open = null },
)
}) else null,
) { onSelect(row.id); host.open = null }
MenuItem(importLabel, checked = false) { host.open = null; onImport() }
}
}
}
}
/** The Resolution menu's "Custom…" entry. */
private const val CUSTOM = -1
@@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
@@ -102,9 +103,13 @@ fun AnchoredMenu(open: Boolean, onDismiss: () -> Unit, title: String? = null, no
exit = fadeOut(Motion.tw(140)) + scaleOut(Motion.tw(140), targetScale = 0.96f, transformOrigin = TransformOrigin(1f, 0f)),
label = "menu",
) {
// A long list (a driver menu with its downloads) must not run off the screen: the menu is
// capped at most of the window's height and its entries scroll under the fixed title.
val maxHeight = (androidx.compose.ui.platform.LocalConfiguration.current.screenHeightDp * 0.86f).dp
Column(
modifier = Modifier
.widthIn(min = 220.dp, max = 340.dp)
.heightIn(max = maxHeight)
.shadow(24.dp, RowShape, ambientColor = Color.Black, spotColor = Color.Black)
.clip(RowShape)
.background(pal.surfaceVariant.copy(alpha = 0.95f))
@@ -115,7 +120,7 @@ fun AnchoredMenu(open: Boolean, onDismiss: () -> Unit, title: String? = null, no
title.uppercase(), fontSize = 10.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 2.sp, color = colors.onSurfaceVariant,
modifier = Modifier.padding(start = 10.dp, top = 6.dp, bottom = 6.dp),
)
content()
Column(Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState())) { content() }
if (note != null) {
Spacer(Modifier.height(4.dp))
Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
@@ -160,7 +165,7 @@ fun MenuItem(
}
@Composable
fun ValueChip(text: String, open: Boolean, enabled: Boolean = true, onClick: () -> Unit) {
fun ValueChip(text: String, open: Boolean, enabled: Boolean = true, modifier: Modifier = Modifier, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val src = remember { MutableInteractionSource() }
val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value
@@ -169,7 +174,7 @@ fun ValueChip(text: String, open: Boolean, enabled: Boolean = true, onClick: ()
val rot by animateFloatAsState(if (open) 180f else 0f, Motion.sp(0.6f), label = "chipCaret")
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
modifier = modifier
.widthIn(min = 150.dp)
.clip(RoundedCornerShape(10.dp))
.background(colors.surfaceVariant)
@@ -221,12 +226,14 @@ fun SettingsRow(label: String, hint: String?, highlighted: Boolean = false, cont
fun <T> ChoiceRow(
host: MenuHost, key: String, label: String, hint: String?,
options: List<Pair<T, String>>, selected: T, enabled: Boolean = true, note: String? = null,
/** For the box itself - a page's FocusRequester for its first control. */
chipModifier: Modifier = Modifier,
onPick: (T) -> Unit,
) {
val open = host.open == key
SettingsRow(label, hint, highlighted = open) {
Box {
ValueChip(options.firstOrNull { it.first == selected }?.second ?: "-", open, enabled) { host.open = if (open) null else key }
ValueChip(options.firstOrNull { it.first == selected }?.second ?: "-", open, enabled, modifier = chipModifier) { host.open = if (open) null else key }
AnchoredMenu(open, onDismiss = { if (host.open == key) host.open = null }, title = label, note = note) {
for ((value, text) in options) MenuItem(text, checked = value == selected) { onPick(value); host.open = null }
}
@@ -275,16 +282,28 @@ fun SettingsPage(
onBack: () -> Unit,
eyebrow: String? = null,
lede: String? = null,
/** A small control at the right of the title (the driver pages' refresh button). */
action: (@Composable () -> Unit)? = null,
/** Pass one held above the page to keep its scroll position across a page opened over it. */
scroll: androidx.compose.foundation.ScrollState? = null,
content: @Composable ColumnScope.() -> Unit,
) {
val scrollState = scroll ?: rememberScrollState()
val colors = MaterialTheme.colorScheme
val dim by animateFloatAsState(if (host.open != null) 0.6f else 1f, Motion.tw(220), label = "pageDim")
Column(modifier = Modifier.fillMaxSize().padding(horizontal = 22.dp, vertical = 18.dp)) {
Rise(0) {
Row(verticalAlignment = Alignment.CenterVertically) {
// Outlined when a controller's focus is on it, like every other control here.
val backSrc = remember { MutableInteractionSource() }
val backHot = backSrc.collectIsFocusedAsState().value || backSrc.collectIsHoveredAsState().value
Text(
"‹ Back", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = colors.onSurfaceVariant,
modifier = Modifier.clip(RoundedCornerShape(8.dp)).clickable(onClick = onBack).padding(horizontal = 6.dp, vertical = 4.dp),
"‹ Back", fontSize = 12.sp, fontWeight = FontWeight.SemiBold,
color = if (backHot) LocalPalette.current.signal else colors.onSurfaceVariant,
modifier = Modifier.clip(RoundedCornerShape(8.dp))
.border(2.dp, if (backHot) LocalPalette.current.signal else Color.Transparent, RoundedCornerShape(8.dp))
.hoverable(backSrc).clickable(interactionSource = backSrc, indication = null, onClick = onBack)
.padding(horizontal = 6.dp, vertical = 4.dp),
)
if (eyebrow != null) {
Spacer(Modifier.width(10.dp))
@@ -292,10 +311,15 @@ fun SettingsPage(
}
}
}
Rise(1) { Title(title) }
Rise(1) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.weight(1f)) { Title(title) }
if (action != null) action()
}
}
if (lede != null) Rise(2) { Lede(lede) }
Rise(3, Modifier.weight(1f).fillMaxWidth()) {
Column(modifier = Modifier.fillMaxSize().graphicsLayer { alpha = dim }.verticalScroll(rememberScrollState()).padding(bottom = 24.dp)) { content() }
Column(modifier = Modifier.fillMaxSize().graphicsLayer { alpha = dim }.verticalScroll(scrollState).padding(bottom = 24.dp)) { content() }
}
}
}
@@ -113,6 +113,19 @@ if [ -z "${BL_INSIDE:-}" ]; then
# HDR10: the app opened its compositor's colour-management gate for this session (BL_HDR=1);
# gamescope then offers HDR to its clients through the same wp_color_manager_v1 it binds on ours.
[ "${BL_HDR:-0}" = 1 ] && args+=(--hdr-enabled)
# Deck mode's performance overlay (Quick Access Menu → Performance → Overlay Level) is drawn by
# mangoapp, which gamescope starts beside the client; the client sets the level by rewriting
# MANGOHUD_CONFIGFILE. The apk stages Valve's aarch64 build (tools/mangoapp) - without it the menu
# still offers the levels and nothing appears. Starts hidden until the client writes a level.
# BL_MANGOAPP=0 turns it off (one community build found Valve's mangoapp crashing on Turnip).
if [ "$mode" = steam ] && [ "${BL_STEAMDECK:-0}" = 1 ] && [ "${BL_MANGOAPP:-1}" = 1 ] \
&& [ "${BL_STEAM_UI:-}" != desktop ] && command -v mangoapp >/dev/null 2>&1; then
MANGOHUD_CONFIGFILE=$(mktemp /tmp/mangohud.XXXXXXXX)
echo no_display > "$MANGOHUD_CONFIGFILE"
export MANGOHUD_CONFIGFILE STEAM_USE_MANGOAPP=1 STEAM_MANGOAPP_PRESETS_SUPPORTED=1 STEAM_MANGOAPP_HORIZONTAL_SUPPORTED=1
args+=(--mangoapp)
echo "== gamescope: mangoapp on ($(command -v mangoapp), config $MANGOHUD_CONFIGFILE)"
fi
export BL_INSIDE=1
exec gamescope "${args[@]}" -- "$0" "$mode" "$@"
fi
@@ -349,8 +362,24 @@ BUSCONF
# 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)
# The QAM's Scaling Filter lists NVIDIA Image Scaling only when told gamescope has it (it does).
export STEAM_GAMESCOPE_NIS_SUPPORTED=1
echo "== steam: Deck mode on (-steamdeck)"
fi
# What a killed session leaves behind. Android ends sessions without warning (backgrounding, the
# low-memory killer), and the client then finds .crash - opening its update UI on the spinner -
# and the web helper's Singleton* locks in htmlcache, owned by a process that is gone, which can
# leave the Gamepad UI blank. No client or helper is running at this point in a session.
if ! pgrep -f 'steamwebhelper|steamrtarm64/steam' >/dev/null 2>&1; then
_stale=""
for _f in "$steam_root/.crash" "$steam_root/steam.pid" \
"$steam_root/config/htmlcache/SingletonLock" "$steam_root/config/htmlcache/SingletonCookie" \
"$steam_root/config/htmlcache/SingletonSocket"; do
if [ -e "$_f" ] || [ -L "$_f" ]; then rm -f "$_f" && _stale="$_stale ${_f##*/}"; fi
done
[ -n "$_stale" ] && echo "== steam: cleared what an earlier session left:$_stale"
unset _stale _f
fi
# 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
+4
View File
@@ -0,0 +1,4 @@
#!/bin/sh
# gamescope --mangoapp runs `mangoapp` from PATH; this puts Valve's build beside the libraries the
# runtime lacks (fmt 10, spdlog 1.13, glfw, libtracefs, libtraceevent), staged by the app.
LD_LIBRARY_PATH=/usr/local/lib/mangoapp${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH} exec /usr/local/lib/mangoapp/mangoapp "$@"
+11
View File
@@ -0,0 +1,11 @@
# Valve's aarch64 mangoapp (the Deck performance overlay) and the five libraries it needs that the
# runtime does not have, at the versions it was built against (its .BUILDINFO: fmt 10.2.0,
# spdlog 1.13.0). CI fetches each, checks the sha256 and stages the files named below into the apk
# under usr/local/lib/mangoapp; SessionFiles copies them into the runtime at every launch.
# sha256 url
5b203a711e4e48f01fdffaa4f5a330f8fef22f1eab865f067a3e60bd99a28a5c https://holo-packages.steamos.cloud/archlinux-deckard-hotfixes/release/0.4.x/mangohud-20260902.1-1-aarch64.pkg.tar.zst
ef7411229ea4e9b3ab4e36ac356a85b5072f362ee1de68b28d09c43f0b0ecc44 https://steamdeck-packages.steamos.cloud/archlinux-deckard/archlinux/mash-20240705.2/extra/os/aarch64/fmt-10.2.0-1-aarch64.pkg.tar.zst
c9bfd2c3f15071329d381b9c2061d07503c4787ceb7001b9dfbf72a23c85cb04 https://steamdeck-packages.steamos.cloud/archlinux-deckard/archlinux/mash-20240705.2/extra/os/aarch64/spdlog-1.13.0-1-aarch64.pkg.tar.zst
f9fae6c84a0382188f1cb20761424051593fd09b1456191cc250bd16e0a1d7a5 https://steamdeck-packages.steamos.cloud/archlinux-deckard/archlinux/mash-20240705.2/extra/os/aarch64/glfw-3.4-2-aarch64.pkg.tar.zst
0cccdc9c92cb3cae1e37ebeb0ed20b93bb3b5000c8f955b63d0e07bd878c7018 https://steamdeck-packages.steamos.cloud/archlinux-deckard/archlinux/mash-20240705.2/extra/os/aarch64/libtraceevent-1%3A1.8.2-2-aarch64.pkg.tar.zst
47ffd6a953959d6a0df44539bef30a67d0b8c95cce0053273908d9d70ef7abc4 https://steamdeck-packages.steamos.cloud/archlinux-deckard/archlinux/mash-20240705.2/extra/os/aarch64/libtracefs-1.8.0-1-aarch64.pkg.tar.zst