mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-12 11:31:08 +00:00
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:
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))
|
||||
|
||||
Reference in new issue
Block a user