Driver menus: WinNative drivers, newest release per variant, scrolling menus

- WinNative-Emu/Drivers joins Banners-Turnip as a source: WN-Turnip
  (Android, v1.x line) and WN-Linux-Turnip (Linux, linux-v0.x line), each
  in its Balanced and Performance builds for every Adreno.
- Each source's recent releases are read newest first and every variant
  is offered from the newest release carrying it. Banners-Turnip stopped
  publishing its A8xx Linux build after v26.3.0-20260923, so the runtime
  menu listed no 8xx driver at all; it now offers that last one. The
  refresh line names each source's newest release and any that could not
  be reached.
- Drop-down menus are capped at the window's height and their entries
  scroll under the title: a driver menu with its downloads ran off the
  bottom of the screen, hiding the refresh line and Import.
This commit is contained in:
The412Banner committed 2026-09-23 16:33:16 -04:00
1 parent d0b1686a84
commit 39f5478ea9
4 files changed
+119 -64

No files matched your search

@@ -109,7 +109,7 @@ class MainActivity : ComponentActivity() {
/** 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("Check for the latest Turnip")
private var releaseStatus by mutableStateOf("Check for new drivers (Banners-Turnip, WinNative)")
private var releaseChecking = false
/** Asset name -> download percent, while it downloads. */
private val releaseProgress = HashMap<String, Int>()
@@ -737,26 +737,28 @@ class MainActivity : ComponentActivity() {
refreshDrivers()
}
/** The download entries and the refresh line, from the release the last check found. */
/** The download entries and the refresh line, from what the last check found. */
private fun refreshReleaseRows() {
val release = TurnipReleases.cached(this)
val check = TurnipReleases.cached(this)
val lm = LinuxVulkanDriverManager(this)
val td = TurnipDriver(this)
fun rows(linux: Boolean) = release?.assets.orEmpty()
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, "Download Turnip ${release!!.tag}",
releaseProgress[a.name]?.let { "${a.gpus} · downloading $it%" } ?: "${a.gpus} · $mb",
a.name, "Download ${a.source} ${a.tag}",
releaseProgress[a.name]?.let { "${a.label} · downloading $it%" } ?: "${a.label} · $mb",
)
}
linuxDownloads = rows(linux = true)
androidDownloads = rows(linux = false)
if (!releaseChecking) releaseStatus = when (release) {
null -> "Check for the latest Turnip"
else -> "Latest: ${release.tag} · checked ${ago(release.checkedAt)} · tap to refresh"
if (!releaseChecking) releaseStatus = when (check) {
null -> "Check for new drivers (Banners-Turnip, WinNative)"
else -> "Latest: " + check.latest.joinToString(" · ") { "${it.first} ${it.second}" } +
(if (check.failed.isEmpty()) "" else " · ${check.failed.joinToString()} unreachable") +
" · checked ${ago(check.checkedAt)} · tap to refresh"
}
}
@@ -774,7 +776,7 @@ class MainActivity : ComponentActivity() {
private fun checkLatestTurnip() {
if (releaseChecking) return
releaseChecking = true
releaseStatus = "Checking Banners-Turnip…"
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"
@@ -11,33 +11,106 @@ import java.net.HttpURLConnection
import java.net.URL
/**
* The latest Banners-Turnip release, offered in both driver menus as downloads. Checked only when
* the user taps refresh - the result is remembered, so the menu shows the last release found
* 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.
* 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.
*
* Asset names are `Turnip-<tag>[-variant][-Linux|-Wayland].zip`: the plain ones are AdrenoTools
* drivers for the display driver menu, `-Linux` ones are glibc drivers for the runtime driver menu,
* and `-Wayland` ones are for a path this app does not have, so they are never offered.
* 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 API = "https://api.github.com/repos/The412Banner/Banners-Turnip/releases/latest"
private const val PREFS = "turnip_releases"
private const val KEY_RELEASE = "latest"
private const val KEY_DOWNLOADS = "downloads"
class Asset(val name: String, val url: String, val size: Long, val linux: Boolean, val gpus: String)
class Release(val tag: String, val assets: List<Asset>, val checkedAt: Long)
/** [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)
/** The release found by the last check, or null when the user has never checked. */
fun cached(context: Context): Release? {
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()
}
/** Ask GitHub for the latest release now and remember it. Throws on network or API errors. */
fun refresh(context: Context): Release {
val c = URL(API).openConnection() as HttpURLConnection
/**
* 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")
@@ -46,52 +119,27 @@ object TurnipReleases {
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")
val body = c.inputStream.bufferedReader().use { it.readText() }
val json = JSONObject(body)
val tag = json.getString("tag_name")
val assets = JSONArray()
val list = json.optJSONArray("assets") ?: JSONArray()
for (i in 0 until list.length()) {
val a = list.getJSONObject(i)
assets.put(JSONObject().put("name", a.getString("name"))
.put("url", a.getString("browser_download_url")).put("size", a.optLong("size")))
}
val stored = JSONObject().put("tag", tag).put("assets", assets).put("checkedAt", System.currentTimeMillis())
prefs(context).edit().putString(KEY_RELEASE, stored.toString()).apply()
return parse(stored)
return c.inputStream.bufferedReader().use { it.readText() }
} finally {
c.disconnect()
}
}
private fun parse(json: JSONObject): Release {
val tag = json.getString("tag")
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)
classify(a.getString("name"), tag)?.let { (linux, gpus) ->
out.add(Asset(a.getString("name"), a.getString("url"), a.optLong("size"), linux, gpus))
}
out.add(Asset(a.getString("source"), a.getString("tag"), a.getString("name"), a.getString("url"),
a.optLong("size"), a.getBoolean("linux"), a.getString("label")))
}
return Release(tag, out, json.optLong("checkedAt"))
}
/** (is it a Linux driver, which GPUs it is for), or null for anything this app cannot use. */
internal fun classify(name: String, tag: String): Pair<Boolean, String>? {
val prefix = "Turnip-$tag"
if (!name.startsWith(prefix) || !name.endsWith(".zip")) return null
var variant = name.removePrefix(prefix).removeSuffix(".zip")
if (variant.endsWith("-Wayland")) return null
val linux = variant.endsWith("-Linux")
variant = variant.removeSuffix("-Linux")
val gpus = 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('-')
}
return linux to gpus
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. */
@@ -23,7 +23,7 @@ import com.steamdeck.launcher.session.SessionService
class DriverRow(val id: String, val name: String, val detail: String, val removable: Boolean)
/** A driver from the latest Banners-Turnip release that is not installed yet; [key] is its asset name. */
/** 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)
class ModeSettings(
@@ -59,7 +59,7 @@ class ModeSettings(
/** 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 = "Check for the latest Turnip",
val releaseStatus: String = "Check for new drivers (Banners-Turnip, WinNative)",
)
/** One added game as the settings page shows it: its folder, the chosen .exe, the other .exe files it could be. */
@@ -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))