diff --git a/app/src/main/java/com/droiddeck/launcher/session/ComponentUpdater.kt b/app/src/main/java/com/droiddeck/launcher/session/ComponentUpdater.kt index e428ede2a..4c3c43148 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/ComponentUpdater.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/ComponentUpdater.kt @@ -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() - 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 { + 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) { + 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) { val installed = protons.mapTo(HashSet()) { it.id } val lines = state.optJSONObject("lines") ?: return diff --git a/app/src/main/java/com/droiddeck/launcher/session/ComponentUpdates.kt b/app/src/main/java/com/droiddeck/launcher/session/ComponentUpdates.kt index 7acf29a4e..cd22696ed 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/ComponentUpdates.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/ComponentUpdates.kt @@ -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, + choices: Map>, + placed: Set, + ): List> = + 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, + choices: Map>, + placed: Set, + ): 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) } diff --git a/app/src/main/java/com/droiddeck/launcher/session/ComponentsManager.kt b/app/src/main/java/com/droiddeck/launcher/session/ComponentsManager.kt index edc68aea3..2e912bae5 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/ComponentsManager.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/ComponentsManager.kt @@ -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, 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, val originals: List) @@ -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 { diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt index 3746a32de..2b93f2ad9 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt @@ -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) { diff --git a/app/src/test/java/com/droiddeck/launcher/session/ComponentUpdatesTest.kt b/app/src/test/java/com/droiddeck/launcher/session/ComponentUpdatesTest.kt index 4711ad316..0941bd888 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/ComponentUpdatesTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/ComponentUpdatesTest.kt @@ -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() + 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() + 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(), 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()))