fix(components): auto updates follow DroidDeck-Proton only (#2190)

This commit is contained in:
Kurt Himebauch authored and GitHub committed 2026-11-06 18:41:37 -05:00
1 parent 250f80fbb5
commit f33d5b9e09
5 files changed
+227 -9

No files matched your search

@@ -13,6 +13,8 @@ object ComponentUpdater {
private const val TAG = "ComponentUpdater"
private const val CHECK_EVERY_MS = 3_600_000L
private const val BOOTED_KEPT = 200
private const val HANDED_BACK = "handedBack"
internal const val HAND_BACK_FILES = "handBackFiles"
private val lock = Any()
private val io = Any()
@@ -57,7 +59,8 @@ object ComponentUpdater {
fun settle(context: Context) {
val owned = load(context).optJSONObject("lines")?.let { lines -> lines.keys().asSequence().flatMap { lines.optJSONObject(it).strings("owned") }.toSet() } ?: emptySet()
val choices = ComponentsManager.activeChoices(context).values.flatMap { it.entries }
val own = (runCatching { ComponentsManager.protons(context) }.getOrNull() ?: return).filter(ComponentUpdates::follows).mapTo(HashSet()) { it.id }
val choices = ComponentsManager.activeChoices(context).filterKeys { it in own }.values.flatMap { it.entries }
for (comp in ComponentsManager.COMPONENTS) {
if (SessionPrefs.componentModeChosen(context, comp)) continue
val manual = choices.any { (c, choice) -> c == comp && !choice.auto && choice.file !in owned && !choice.file.startsWith("original ") }
@@ -80,7 +83,7 @@ object ComponentUpdater {
logLines(context).mapNotNull { STAMP.find(it)?.groupValues?.get(1) }.maxOrNull() ?: "0"
private fun lineOf(context: Context, state: JSONObject, p: ComponentsManager.Proton, comp: String): ComponentUpdates.Line? {
if (!ComponentsManager.supports(p, comp)) return null
if (!ComponentUpdates.follows(p) || !ComponentsManager.supports(p, comp)) return null
if (comp == "fex") return ComponentUpdates.line("fex", false)
val key = "${p.id}|${p.version}"
val flavors = state.obj("flavors").obj(key)
@@ -93,7 +96,9 @@ object ComponentUpdater {
settle(context)
val state = load(context)
val notes = ArrayList<String>()
val protons = runCatching { ComponentsManager.protons(context) }.getOrDefault(emptyList())
val found = runCatching { ComponentsManager.protons(context) }.getOrNull()
val protons = found ?: emptyList()
if (found != null) handBack(context, state, found)
judge(context, state, protons, notes)
val due = force || System.currentTimeMillis() - state.optLong("checkedAt") >= CHECK_EVERY_MS
val wanted = ComponentsManager.COMPONENTS.filter { managed(context, it) }
@@ -137,8 +142,26 @@ object ComponentUpdater {
complete = false
continue
}
val current = ls.optString("current")
val missing = current.isNotEmpty() && !ComponentsManager.isStored(context, current)
val relisted = if (missing) catalog.firstOrNull { it.file == current } else null
if (relisted != null) {
if (holdBack) {
state.put("note", context.getString(R.string.comp_auto_waits_unmetered))
complete = false
continue
}
val got = runCatching { ComponentsManager.download(context, relisted) {} }
if (got.isFailure) {
Log.w(TAG, "download ${relisted.file} again", got.exceptionOrNull())
state.put("note", context.getString(R.string.comp_auto_check_failed))
complete = false
continue
}
ls.put("owned", JSONArray(ls.strings("owned") + current))
}
val target = ComponentUpdates.pick(catalog, line, ls.strings("bad"))
if (target == null || ls.optString("current") == target.file) continue
if (target == null || current == target.file) continue
val stored = ComponentsManager.isStored(context, target.file)
if (!stored) {
if (holdBack) {
@@ -161,7 +184,7 @@ object ComponentUpdater {
if (!stored) runCatching { ComponentsManager.deletePackage(context, target.file) }
continue
}
ls.put("previous", ls.optString("current").ifEmpty { ComponentUpdates.ORIGINAL })
ls.put("previous", if (missing && relisted == null) ComponentUpdates.ORIGINAL else current.ifEmpty { ComponentUpdates.ORIGINAL })
.put("current", target.file).put("since", latestStamp(context)).put("failed", JSONObject())
if (!stored) ls.put("owned", JSONArray(ls.strings("owned") + target.file))
ls.remove("note")
@@ -210,6 +233,33 @@ object ComponentUpdater {
}
}
internal fun placed(state: JSONObject): Set<String> {
val remembered = state.strings(HAND_BACK_FILES)
val lines = state.optJSONObject("lines") ?: return remembered
return remembered + lines.keys().asSequence().flatMap { k ->
val ls = lines.optJSONObject(k) ?: return@flatMap emptySequence()
(ls.strings("owned") + ls.strings("bad") + ls.optString("current") + ls.optString("previous")).asSequence()
}.filter { it.isNotEmpty() }.toSet()
}
private fun handBack(context: Context, state: JSONObject, protons: List<ComponentsManager.Proton>) {
if (state.optBoolean(HANDED_BACK)) return
val placed = placed(state)
state.put(HAND_BACK_FILES, JSONArray(placed.sorted()))
val own = protons.filter(ComponentUpdates::follows).mapTo(HashSet()) { it.id }
runCatching { ComponentsManager.dropQueuedPackages(context, placed) { it in own || ComponentUpdates.ownId(it) } }.onFailure { Log.w(TAG, "drop queued Auto builds", it) }.getOrNull() ?: return
val strays = ComponentUpdates.strays(protons, ComponentsManager.activeChoices(context), placed)
for ((p, comp) in strays) {
runCatching { ComponentsManager.restoreUnlessQueued(context, p.id, comp, ComponentsManager.safeName(p.version)) }
.onFailure { Log.w(TAG, "hand back ${p.name} $comp", it) }
}
val after = ComponentsManager.activeChoices(context)
if (ComponentUpdates.strays(protons, after, placed).isEmpty() && !ComponentUpdates.unseen(protons, after, placed)) {
state.put(HANDED_BACK, true)
state.remove(HAND_BACK_FILES)
}
}
internal fun dropRemoved(state: JSONObject, protons: List<ComponentsManager.Proton>) {
val installed = protons.mapTo(HashSet()) { it.id }
val lines = state.optJSONObject("lines") ?: return
@@ -23,6 +23,30 @@ object ComponentUpdates {
Line("fex", "fex", "DD-FEXCore-"),
)
private val OWN_PREFIX = ProtonExtras.tools.first { it.id == ProtonExtras.OWN }.prefix
fun follows(p: ComponentsManager.Proton): Boolean = !p.valve && p.dir.name.startsWith(OWN_PREFIX)
fun ownId(protonId: String): Boolean = protonId.startsWith(ComponentsManager.safeName(OWN_PREFIX))
fun strays(
protons: List<ComponentsManager.Proton>,
choices: Map<String, Map<String, ComponentsManager.Choice>>,
placed: Set<String>,
): List<Pair<ComponentsManager.Proton, String>> =
protons.filterNot(::follows).flatMap { p ->
choices[p.id].orEmpty().filter { (_, c) -> !c.auto && c.protonVersion == p.version && c.file in placed }.keys.map { p to it }
}
fun unseen(
protons: List<ComponentsManager.Proton>,
choices: Map<String, Map<String, ComponentsManager.Choice>>,
placed: Set<String>,
): Boolean {
val found = protons.mapTo(HashSet()) { it.id }
return choices.any { (id, comps) -> id !in found && comps.values.any { !it.auto && it.file in placed } }
}
fun line(comp: String, arm64ec: Boolean): Line = LINES.first {
it.comp == comp && (comp == "fex" || it.key.endsWith("-arm64ec") == arm64ec)
}
@@ -700,6 +700,12 @@ object ComponentsManager {
context.getString(R.string.cmgr_restore_done, LABEL[comp], p.name, protonVersion)
}
fun restoreUnlessQueued(context: Context, protonId: String, comp: String, protonVersion: String): Boolean = synchronized(lock) {
if (loadState(context).optJSONObject("queued")?.optJSONObject(protonId)?.has(comp) == true) return false
restore(context, protonId, comp, protonVersion)
true
}
private fun applyOriginal(context: Context, p: Proton, comp: String, protonVersion: String, wcp: File, state: JSONObject) {
val current = protonVersion == safeName(p.version)
if (!current) ensureOriginal(context, p, comp, state)
@@ -750,6 +756,25 @@ object ComponentsManager {
saveState(context, state)
}
fun dropQueuedPackages(context: Context, files: Set<String>, keep: (String) -> Boolean): Int = synchronized(lock) {
val state = loadState(context)
val queued = state.sub("queued")
var dropped = 0
for (pid in queued.keys().asSequence().toList()) {
if (keep(pid)) continue
val comps = queued.optJSONObject(pid) ?: continue
for (comp in comps.keys().asSequence().toList()) {
val entry = comps.optJSONObject(comp) ?: continue
if (entry.optString("kind") != "package" || entry.optBoolean("auto") || entry.optString("file") !in files) continue
comps.remove(comp)
dropped++
}
if (comps.length() == 0) queued.remove(pid)
}
if (dropped > 0) saveState(context, state)
dropped
}
class StoredPackage(val pkg: Package, val inUse: Boolean)
class StoredOriginal(val protonId: String, val protonName: String?, val comp: String, val protonVersion: String, val size: Long, val kept: Boolean)
class Stored(val packages: List<StoredPackage>, val originals: List<StoredOriginal>)
@@ -968,9 +993,16 @@ object ComponentsManager {
if (!force && cache.isFile) runCatching { return parseCatalog(JSONObject(cache.readText())) }
if (!force) return Catalog(emptyList(), 0)
val items = JSONArray()
val previous = if (cache.isFile) runCatching { JSONObject(cache.readText()).optJSONArray("items") }.getOrNull() else null
var fetched = 0
for ((repo, tag) in SOURCES) {
val body = Downloader.downloadString("https://api.github.com/repos/$repo/releases/tags/$tag") ?: continue
val assets = runCatching { JSONObject(body).optJSONArray("assets") }.getOrNull() ?: continue
val body = Downloader.downloadString("https://api.github.com/repos/$repo/releases/tags/$tag")
val assets = body?.let { runCatching { JSONObject(it).optJSONArray("assets") }.getOrNull() }
if (assets == null) {
keepSource(previous, tag, items)
continue
}
fetched++
for (i in 0 until assets.length()) {
val a = assets.getJSONObject(i)
val name = a.optString("name")
@@ -981,13 +1013,18 @@ object ComponentsManager {
.put("url", a.optString("browser_download_url")).put("size", a.optLong("size")).put("digest", a.optString("digest")))
}
}
if (items.length() == 0 && cache.isFile) runCatching { return parseCatalog(JSONObject(cache.readText())) }
if (fetched == 0 && cache.isFile) runCatching { return parseCatalog(JSONObject(cache.readText())) }
val out = JSONObject().put("items", items).put("fetchedAt", System.currentTimeMillis() / 1000)
val tmp = File(cache.parentFile, cache.name + ".tmp")
tmp.writeText(out.toString()); tmp.renameTo(cache)
return parseCatalog(out)
}
internal fun keepSource(previous: JSONArray?, tag: String, into: JSONArray) {
previous ?: return
for (i in 0 until previous.length()) previous.optJSONObject(i)?.takeIf { it.optString("release") == tag }?.let(into::put)
}
private fun parseCatalog(o: JSONObject): Catalog {
val arr = o.optJSONArray("items") ?: JSONArray()
val items = (0 until arr.length()).map { arr.getJSONObject(it) }.map {
@@ -528,7 +528,8 @@ fun ComponentsPage(
else -> stringResource(R.string.comp_nightlies_count, availableRows(l, catalog, snapshot).size)
}
}
val moded: (String) -> Boolean = { it in compModes && it !in compAutoHeld && !ComponentsManager.runtimeManaged(it, gpu.systemPowerVr) }
val followed = view?.proton?.let(ComponentUpdates::follows) == true
val moded: (String) -> Boolean = { followed && it in compModes && it !in compAutoHeld && !ComponentsManager.runtimeManaged(it, gpu.systemPowerVr) }
val automatic: (String) -> Boolean = { moded(it) && compModes[it] == ComponentUpdates.AUTO }
val installedRows: @Composable ColumnScope.(String, Boolean) -> Unit = { l, right ->
when (l) {
@@ -236,6 +236,112 @@ class ComponentUpdatesTest {
assertEquals("DD-FEXCore-2611+2-nightly.wcp", fex.getJSONArray("bad").getString(0))
}
private fun proton(dir: String, valve: Boolean = false, version: String = "$dir-1") =
ComponentsManager.Proton(dir, dir, File("/steam/compatibilitytools.d/$dir"), "/p/$dir", version, valve)
@Test fun autoFollowsOnlyDroidDeckProton() {
assertTrue(ComponentUpdates.follows(proton("DroidDeck-Proton-10.0-7")))
assertFalse(ComponentUpdates.follows(proton("DroidDeck-Proton-10.0-7", valve = true)))
assertFalse(ComponentUpdates.follows(proton("droiddeck-proton-arm64")))
assertFalse(ComponentUpdates.follows(proton("droiddeck-proton-11-arm64")))
assertFalse(ComponentUpdates.follows(proton("GE-Proton10-25")))
assertFalse(ComponentUpdates.follows(proton("proton-cachyos-10.0-20251101-slr-arm64")))
}
@Test fun onlyAutosOwnBuildsInOtherProtonsAreHandedBack() {
val dd = proton("DroidDeck-Proton-10.0-7")
val ge = proton("GE-Proton10-25")
val valve = proton("Proton - Experimental", valve = true)
val choices = mapOf(
dd.id to mapOf("dxvk" to ComponentsManager.Choice("DD-dxvk-3.1.1+190-nightly.wcp", false, dd.version)),
ge.id to mapOf(
"dxvk" to ComponentsManager.Choice("DD-dxvk-3.1.1+190-nightly.wcp", false, ge.version),
"vkd3d" to ComponentsManager.Choice("vkd3d-proton-3.0-mine.wcp", false, ge.version),
"fex" to ComponentsManager.Choice("DD-FEXCore-2611+3-nightly.wcp", false, "GE-Proton10-24"),
),
valve.id to mapOf(
"fex" to ComponentsManager.Choice("DD-FEXCore-2611+3-nightly.wcp", false, valve.version),
"dxvk" to ComponentsManager.Choice("DD-dxvk-3.1.1+190-nightly.wcp", true, valve.version),
),
)
val placed = setOf("DD-dxvk-3.1.1+190-nightly.wcp", "DD-FEXCore-2611+3-nightly.wcp")
assertEquals(
setOf(ge.id to "dxvk", valve.id to "fex"),
ComponentUpdates.strays(listOf(dd, ge, valve), choices, placed).map { (p, c) -> p.id to c }.toSet(),
)
}
@Test fun aProtonMissingFromDiscoveryKeepsTheHandBackPending() {
val dd = proton("DroidDeck-Proton-10.0-7")
val ge = proton("GE-Proton10-25")
val placed = setOf("DD-dxvk-3.1.1+190-nightly.wcp")
val choices = mapOf(ge.id to mapOf("dxvk" to ComponentsManager.Choice("DD-dxvk-3.1.1+190-nightly.wcp", false, ge.version)))
assertTrue(ComponentUpdates.unseen(listOf(dd), choices, placed))
assertFalse(ComponentUpdates.unseen(listOf(dd, ge), choices, placed))
assertFalse(ComponentUpdates.unseen(listOf(dd), choices, emptySet()))
}
@Test fun queuedAutoBuildsInOtherProtonsAreDroppedAndEverythingElseStays() {
org.junit.Assume.assumeFalse(System.getProperty("os.name").startsWith("Windows"))
val context = ApplicationProvider.getApplicationContext<Context>()
val state = File(context.filesDir, "components/state.json").apply { parentFile?.mkdirs() }
fun pkg(file: String, auto: Boolean = false) = JSONObject().put("kind", "package").put("file", file).put("label", file).apply { if (auto) put("auto", true) }
state.writeText(JSONObject().put("queued", JSONObject()
.put("GE-Proton10-25", JSONObject().put("dxvk", pkg("DD-dxvk-3.1.1+190-nightly.wcp")).put("vkd3d", pkg("vkd3d-proton-3.0-mine.wcp"))
.put("fex", pkg("DD-FEXCore-2611+3-nightly.wcp", auto = true)))
.put("proton-cachyos-10.0-arm64", JSONObject().put("dxvk", pkg("DD-dxvk-3.1.1+190-nightly.wcp")))
.put("DroidDeck-Proton-10.0-7", JSONObject().put("dxvk", pkg("DD-dxvk-3.1.1+190-nightly.wcp")))).toString())
val placed = setOf("DD-dxvk-3.1.1+190-nightly.wcp", "DD-FEXCore-2611+3-nightly.wcp")
assertEquals(2, ComponentsManager.dropQueuedPackages(context, placed, ComponentUpdates::ownId))
val queued = JSONObject(state.readText()).getJSONObject("queued")
assertEquals(setOf("vkd3d", "fex"), queued.getJSONObject("GE-Proton10-25").keys().asSequence().toSet())
assertFalse(queued.has("proton-cachyos-10.0-arm64"))
assertTrue(queued.getJSONObject("DroidDeck-Proton-10.0-7").has("dxvk"))
}
@Test fun aQueuedSwapIsNotReplacedByTheHandBack() {
val context = ApplicationProvider.getApplicationContext<Context>()
val state = File(context.filesDir, "components/state.json").apply { parentFile?.mkdirs() }
val queued = JSONObject().put("kind", "package").put("file", "vkd3d-proton-3.0-mine.wcp").put("label", "3.0")
state.writeText(JSONObject().put("queued", JSONObject().put("GE-Proton10-25", JSONObject().put("vkd3d", queued))).toString())
assertFalse(ComponentsManager.restoreUnlessQueued(context, "GE-Proton10-25", "vkd3d", "GE-Proton10-25"))
val after = JSONObject(state.readText()).getJSONObject("queued").getJSONObject("GE-Proton10-25").getJSONObject("vkd3d")
assertEquals("vkd3d-proton-3.0-mine.wcp", after.getString("file"))
}
@Test fun aSourceThatFailsToListKeepsItsPreviousPackages() {
val previous = JSONArray()
.put(JSONObject().put("file", "DD-dxvk-arm64ec-3.1.1+198-nightly.wcp").put("release", ComponentsManager.DD_NIGHTLY))
.put(JSONObject().put("file", "dxvk-3.1.1-linux.wcp").put("release", "Dxvk-Linux"))
val into = JSONArray()
ComponentsManager.keepSource(previous, ComponentsManager.DD_NIGHTLY, into)
assertEquals(1, into.length())
assertEquals("DD-dxvk-arm64ec-3.1.1+198-nightly.wcp", into.getJSONObject(0).getString("file"))
ComponentsManager.keepSource(null, ComponentsManager.DD_NIGHTLY, into)
assertEquals(1, into.length())
}
@Test fun everyBuildAutoEverPlacedCountsAsItsOwn() {
val state = JSONObject().put("lines", JSONObject()
.put("dxvk", JSONObject().put("current", "b.wcp").put("previous", ComponentUpdates.ORIGINAL)
.put("owned", JSONArray().put("a.wcp").put("b.wcp")).put("bad", JSONArray().put("c.wcp")))
.put("fex", JSONObject().put("current", "f2.wcp").put("previous", "f1.wcp")))
assertEquals(setOf("a.wcp", "b.wcp", "c.wcp", "f1.wcp", "f2.wcp"), ComponentUpdater.placed(state))
assertEquals(emptySet<String>(), ComponentUpdater.placed(JSONObject()))
}
@Test fun aBuildAutoMovedPastStillCountsUntilTheHandBackCompletes() {
val first = JSONObject().put("lines", JSONObject().put("dxvk", JSONObject().put("current", "a.wcp")))
val remembered = ComponentUpdater.placed(first)
val later = JSONObject().put(ComponentUpdater.HAND_BACK_FILES, JSONArray(remembered.sorted()))
.put("lines", JSONObject().put("dxvk", JSONObject().put("current", "c.wcp").put("previous", "b.wcp")))
val placed = ComponentUpdater.placed(later)
assertEquals(setOf("a.wcp", "b.wcp", "c.wcp"), placed)
val proton = proton("GE-Proton10-25")
val choices = mapOf(proton.id to mapOf("dxvk" to ComponentsManager.Choice("a.wcp", false, proton.version)))
assertTrue(ComponentUpdates.unseen(emptyList(), choices, placed))
}
@Test fun onlyTheCurrentAndThePreviousBuildAndUserSelectionsAreKept() {
assertEquals(setOf("b.wcp", "a.wcp", "mine.wcp"), ComponentUpdates.keep("b.wcp", "a.wcp", setOf("mine.wcp")))
assertEquals(setOf("b.wcp"), ComponentUpdates.keep("b.wcp", ComponentUpdates.ORIGINAL, emptySet()))