From 39f5478ea94bd3bbb47be27d95e0a89fa5ed4318 Mon Sep 17 00:00:00 2001 From: The412Banner Date: Wed, 23 Sep 2026 15:25:33 -0400 Subject: [PATCH] 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. --- .../com/steamdeck/launcher/MainActivity.kt | 22 +-- .../steamdeck/launcher/gpu/TurnipReleases.kt | 150 ++++++++++++------ .../launcher/ui/ModeSettingsDialog.kt | 4 +- .../steamdeck/launcher/ui/SettingsWidgets.kt | 7 +- 4 files changed, 119 insertions(+), 64 deletions(-) diff --git a/app/src/main/java/com/steamdeck/launcher/MainActivity.kt b/app/src/main/java/com/steamdeck/launcher/MainActivity.kt index 2e3b8cfaa..6b716a17b 100644 --- a/app/src/main/java/com/steamdeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/steamdeck/launcher/MainActivity.kt @@ -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>(emptyList()) private var androidDownloads by mutableStateOf>(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() @@ -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" diff --git a/app/src/main/java/com/steamdeck/launcher/gpu/TurnipReleases.kt b/app/src/main/java/com/steamdeck/launcher/gpu/TurnipReleases.kt index 90eb2bc89..5482d2883 100644 --- a/app/src/main/java/com/steamdeck/launcher/gpu/TurnipReleases.kt +++ b/app/src/main/java/com/steamdeck/launcher/gpu/TurnipReleases.kt @@ -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-[-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, 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, val latest: List>, val failed: List, 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?) + + private val SOURCES = listOf( + // Turnip-[-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--_Axxx.zip (Android) and WN-Linux-Turnip--_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() + 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() + 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() 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? { - 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>() + 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() + 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. */ diff --git a/app/src/main/java/com/steamdeck/launcher/ui/ModeSettingsDialog.kt b/app/src/main/java/com/steamdeck/launcher/ui/ModeSettingsDialog.kt index 3b7a03350..839b497c6 100644 --- a/app/src/main/java/com/steamdeck/launcher/ui/ModeSettingsDialog.kt +++ b/app/src/main/java/com/steamdeck/launcher/ui/ModeSettingsDialog.kt @@ -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 = emptyList(), val androidDownloads: List = 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. */ diff --git a/app/src/main/java/com/steamdeck/launcher/ui/SettingsWidgets.kt b/app/src/main/java/com/steamdeck/launcher/ui/SettingsWidgets.kt index 2e09102cc..e151f249c 100644 --- a/app/src/main/java/com/steamdeck/launcher/ui/SettingsWidgets.kt +++ b/app/src/main/java/com/steamdeck/launcher/ui/SettingsWidgets.kt @@ -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))