feat: automatically update installed Proton builds on app open

This commit is contained in:
Kurt Himebauch committed 2026-10-24 19:50:10 -04:00
1 parent 74a6a88df8
commit 8a948fbe45
3 files changed
+187 -9

No files matched your search

@@ -91,7 +91,7 @@ class FrontEndActivity : ComponentActivity() {
private val components = ComponentsMenu(this, ui)
private val decky = DeckyMenu(this, ui)
private val updates = UpdatesMenu(this, ui)
private val protons = ProtonMenu(this, ui)
private val protons = ProtonMenu(this, ui) { !busy && pkgStage == null && components.compBusy == null }
private var installed by mutableStateOf<String?>(null)
private var flood by mutableStateOf<PendingFlood?>(null)
@@ -1,6 +1,8 @@
package com.droiddeck.launcher
import android.os.Handler
import android.util.Log
import androidx.annotation.VisibleForTesting
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
@@ -8,31 +10,47 @@ import androidx.compose.runtime.setValue
import com.droiddeck.launcher.session.ProtonExtras
import com.droiddeck.launcher.ui.ProtonRow
import com.droiddeck.launcher.session.SessionState
import com.droiddeck.launcher.frontend.AutoComponents
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.runtime.LinuxRuntimeInstaller
internal class ProtonMenu(private val activity: android.app.Activity, private val ui: android.os.Handler) {
internal class ProtonMenu(
private val activity: android.app.Activity,
private val ui: Handler,
private val idle: () -> Boolean = { true },
) {
var protonRows by mutableStateOf<List<ProtonRow>>(emptyList())
var protonBusyId by mutableStateOf<String?>(null)
var protonStage by mutableStateOf<String?>(null)
var protonPercent by mutableIntStateOf(-1)
private val checking = java.util.concurrent.atomic.AtomicBoolean(false)
private val autoAttempts = HashMap<String, Pair<String, Long>>()
@VisibleForTesting internal var installer: (ProtonExtras.Tool, (String, Int) -> Unit) -> String? =
{ tool, progress -> ProtonExtras.install(activity, tool, progress) }
fun installProton(id: String) {
fun installProton(id: String, automatic: Boolean = false) {
val tool = ProtonExtras.tools.firstOrNull { it.id == id } ?: return
if (protonBusyId != null || SessionState.running) return
if (protonBusyId != null || ProtonExtras.installInProgress || SessionState.running) return
ProtonExtras.unqueue(activity, tool)
protonBusyId = id
protonStage = activity.getString(R.string.store_starting)
protonPercent = -1
Thread({
val problem = ProtonExtras.install(activity, tool) { label, value ->
ui.post { protonStage = label; protonPercent = value }
}
val problem = runCatching {
installer(tool) { label, value ->
ui.post { protonStage = label; protonPercent = value }
}
}.getOrElse { it.message ?: it.javaClass.simpleName }
ui.post {
protonBusyId = null
protonStage = null
protonPercent = -1
refreshProtons()
if (problem != null) android.widget.Toast.makeText(activity, problem, android.widget.Toast.LENGTH_LONG).show()
if (problem != null) {
if (automatic) Log.w("ProtonMenu", "auto-update ${tool.id}: $problem")
else android.widget.Toast.makeText(activity, problem, android.widget.Toast.LENGTH_LONG).show()
}
if (automatic) ensureAutoUpdates()
}
}, "install-proton-$id").start()
}
@@ -64,7 +82,23 @@ internal class ProtonMenu(private val activity: android.app.Activity, private va
if (!checking.compareAndSet(false, true)) return
Thread({
val asked = try { ProtonExtras.checkLatest(activity, force) } catch (_: Throwable) { false } finally { checking.set(false) }
if (asked) ui.post { refreshProtons() }
ui.post {
if (asked) refreshProtons()
ensureAutoUpdates()
}
}, "proton-updates").start()
}
internal fun ensureAutoUpdates(now: Long = System.currentTimeMillis()) {
if (activity.isFinishing || activity.isDestroyed || !idle() || protonBusyId != null ||
ProtonExtras.installInProgress || SessionState.running || LinuxRuntimeInstaller.isBusy() ||
!LinuxRuntime.isInstalled(activity) || !AutoComponents.online(activity) || AutoComponents.unfavourable(activity)) return
val (tool, update) = ProtonExtras.tools.firstNotNullOfOrNull { tool ->
val update = ProtonExtras.update(activity, tool) ?: return@firstNotNullOfOrNull null
val previous = autoAttempts[tool.id]
if (previous == null || previous.first != update || now - previous.second !in 0 until 15L * 60 * 1000) tool to update else null
} ?: return
autoAttempts[tool.id] = update to now
installProton(tool.id, automatic = true)
}
}
@@ -0,0 +1,144 @@
package com.droiddeck.launcher
import android.app.Activity
import android.os.Handler
import android.os.Looper
import com.droiddeck.launcher.frontend.AutoComponents
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.session.ProtonExtras
import com.droiddeck.launcher.session.SessionState
import org.json.JSONObject
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
import java.io.File
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], manifest = Config.NONE)
class ProtonMenuAutoTest {
private val online = AutoComponents.online
private val constrained = AutoComponents.unfavourable
private lateinit var activity: Activity
private lateinit var menu: ProtonMenu
private lateinit var root: File
private val attempts = mutableListOf<String>()
private var idle = true
@Before fun setup() {
activity = Robolectric.buildActivity(Activity::class.java).setup().get()
root = LinuxRuntime.rootDir(activity)
for (path in listOf("usr/bin/gamescope", "usr/local/bin/droiddeck-session", "opt/android-host/proot", "opt/android-host/loader")) {
File(root, path).apply { parentFile!!.mkdirs(); writeText("") }
}
AutoComponents.online = { true }
AutoComponents.unfavourable = { false }
SessionState.running = false
val latest = JSONObject()
for (tool in ProtonExtras.tools) latest.put(tool.id, JSONObject()
.put("tag", "${tool.prefix}-11-12").put("at", System.currentTimeMillis()))
File(activity.filesDir, "proton-latest.json").writeText(latest.toString())
menu = ProtonMenu(activity, Handler(Looper.getMainLooper())) { idle }
menu.installer = { tool, progress ->
progress("Installing ${tool.name}", 50)
install("${tool.prefix}-11-12")
null
}
}
@After fun reset() {
AutoComponents.online = online
AutoComponents.unfavourable = constrained
SessionState.running = false
}
private fun install(name: String): File = File(root, "root/.local/share/Steam/compatibilitytools.d/$name").apply {
mkdirs()
File(this, "toolmanifest.vdf").writeText("manifest")
}
private fun waitUntil(done: () -> Boolean) {
val deadline = System.nanoTime() + 5_000_000_000L
while (System.nanoTime() < deadline) {
shadowOf(Looper.getMainLooper()).idle()
if (done()) return
Thread.sleep(10)
}
fail("Proton work did not finish")
}
@Test fun aCachedCheckUpdatesOnlyInstalledFamiliesAndKeepsOlderBuildsAndSelections() {
val older = install("DroidDeck-Proton-11-11.1")
val request = File(root, "root/.local/share/droiddeck-compat/request.json").apply {
parentFile!!.mkdirs()
writeText("{\"seq\":1,\"dir\":\"DroidDeck-Proton-11-11.1\",\"valve\":false}")
}
val before = request.readText()
assertFalse(ProtonExtras.checkLatest(activity))
menu.checkUpdates()
waitUntil { menu.protonRows.singleOrNull { it.id == "droiddeck" }?.installed == "DroidDeck-Proton-11-12" && menu.protonBusyId == null }
assertTrue(older.isDirectory)
assertEquals(before, request.readText())
assertTrue(ProtonExtras.installedAll(activity, ProtonExtras.tools.first { it.id == "ge" }).isEmpty())
assertTrue(ProtonExtras.installedAll(activity, ProtonExtras.tools.first { it.id == "cachyos" }).isEmpty())
}
@Test fun downloadsWaitForTheSessionNetworkPowerAndOtherInstallWork() {
install("DroidDeck-Proton-11-11.1")
menu.installer = { tool, _ -> attempts.add(tool.id); "failed" }
SessionState.running = true
menu.ensureAutoUpdates()
SessionState.running = false
AutoComponents.online = { false }
menu.ensureAutoUpdates()
AutoComponents.online = { true }
AutoComponents.unfavourable = { true }
menu.ensureAutoUpdates()
AutoComponents.unfavourable = { false }
idle = false
menu.ensureAutoUpdates()
assertNull(menu.protonBusyId)
assertTrue(attempts.isEmpty())
idle = true
menu.ensureAutoUpdates()
waitUntil { menu.protonBusyId == null }
assertEquals(listOf("droiddeck"), attempts)
}
@Test fun aFailedUpdateDoesNotLoopOrBlockOtherInstalledFamilies() {
val ge = install("GE-Proton11-11")
val dd = install("DroidDeck-Proton-11-11.1")
menu.installer = { tool, _ ->
attempts.add(tool.id)
if (tool.id == "ge") throw IllegalStateException("download failed")
install("${tool.prefix}-11-12")
null
}
menu.ensureAutoUpdates()
waitUntil { menu.protonBusyId == null }
menu.ensureAutoUpdates()
shadowOf(Looper.getMainLooper()).idle()
assertEquals(listOf("ge", "droiddeck"), attempts)
assertTrue(ge.isDirectory)
assertTrue(dd.isDirectory)
assertNull(menu.protonStage)
assertEquals(-1, menu.protonPercent)
}
@Test fun anUninstalledRuntimeOrCurrentProtonDoesNotStartADownload() {
install("DroidDeck-Proton-11-12")
menu.installer = { tool, _ -> attempts.add(tool.id); null }
menu.ensureAutoUpdates()
assertTrue(attempts.isEmpty())
install("GE-Proton11-11")
File(root, "usr/bin/gamescope").delete()
menu.ensureAutoUpdates()
assertTrue(attempts.isEmpty())
assertNull(menu.protonBusyId)
}
}