feat: Decky Loader update channel selector (#1264)

This commit is contained in:
Kurt Himebauch authored and GitHub committed 2026-10-21 17:08:46 +00:00
1 parent 003064769d
commit f67398e6bc
6 files changed
+108 -11

No files matched your search

@@ -11,7 +11,9 @@ import java.io.File
internal class DeckyMenu(private val activity: android.app.Activity, private val ui: android.os.Handler) {
var deckyInstalled by mutableStateOf<String?>(null)
var deckyReleases by mutableStateOf<List<DeckyManager.Release>>(emptyList())
var deckyLatest by mutableStateOf<DeckyManager.Release?>(null)
var deckyBranch by mutableIntStateOf(DeckyManager.BRANCH_STABLE)
private var deckyChannels = DeckyManager.ReleaseChannels(emptyList(), emptyList())
var deckyChecking by mutableStateOf(false)
var deckyStage by mutableStateOf<String?>(null)
var deckyPercent by mutableIntStateOf(-1)
@@ -22,19 +24,33 @@ internal class DeckyMenu(private val activity: android.app.Activity, private val
fun refreshDecky() {
val request = ++deckyReleaseRequest
deckyBranch = DeckyManager.branch(activity)
deckyChecking = true
Thread({
val channels = runCatching { DeckyManager.releaseChannels(activity) }
.getOrElse { DeckyManager.ReleaseChannels(emptyList(), emptyList()) }
ui.post {
if (request == deckyReleaseRequest) {
deckyReleases = channels.stable.ifEmpty { channels.prerelease }
deckyChannels = channels
deckyLatest = DeckyManager.latest(channels, deckyBranch)
deckyChecking = false
}
}
}, "decky-releases").start()
}
fun switchDeckyBranch(branch: Int) {
if (branch == deckyBranch || deckyStage != null || SessionState.running) return
val saved = runCatching { DeckyManager.setBranch(activity, branch) }
if (saved.isFailure) {
android.widget.Toast.makeText(activity, R.string.decky_channel_failed, android.widget.Toast.LENGTH_LONG).show()
return
}
deckyBranch = branch
deckyLatest = DeckyManager.latest(deckyChannels, branch)
refreshDecky()
}
fun installDecky(release: DeckyManager.Release) {
if (deckyStage != null || SessionState.running) return
deckyStage = activity.getString(R.string.store_starting); deckyPercent = -1
@@ -1649,7 +1649,8 @@ class MainActivity : ComponentActivity() {
wifiDiscoveryAsked = wifiDiscoveryAsked,
wifiDiscoveryBlocked = wifiDiscoveryBlocked,
deckyInstalled = if (mode == SessionService.MODE_STEAM) decky.deckyInstalled else null,
deckyLatestRelease = if (mode == SessionService.MODE_STEAM) decky.deckyReleases.firstOrNull() else null,
deckyLatestRelease = if (mode == SessionService.MODE_STEAM) decky.deckyLatest else null,
deckyBranch = decky.deckyBranch,
deckyChecking = decky.deckyChecking, deckyStage = decky.deckyStage, deckyPercent = decky.deckyPercent,
deckyDownloadingBinary = decky.deckyDownloadingBinary,
deckyEnabled = decky.deckySupervisor, deckySessionRunning = SessionState.running,
@@ -1732,6 +1733,7 @@ class MainActivity : ComponentActivity() {
onBootMovieChanged = { loadBootMovie() },
onDeckyInstall = { release -> decky.installDecky(release) },
onDeckyCheck = { decky.refreshDecky() },
onDeckyBranch = { branch -> decky.switchDeckyBranch(branch) },
onDeckyEnabled = { enabled -> DeckyManager.setSupervisorEnabled(this, enabled); decky.deckySupervisor = enabled },
onDeckyUninstall = {
DeckyManager.uninstall(this, wipeData = false)
@@ -22,6 +22,7 @@ object DeckyManager {
private const val RELEASES = "https://api.github.com/repos/$REPOSITORY/releases?per_page=30"
private const val PATH = "root/homebrew/services/PluginLoader"
private const val VERSION = "root/homebrew/services/.droiddeck-decky-version"
private const val SETTINGS = "root/homebrew/settings/loader.json"
// droiddeck-session reads the marker from the root of the client it runs.
private val CEF_REMOTE_DEBUG_MARKERS = listOf(
"root/.local/share/Steam/.cef-enable-remote-debugging",
@@ -32,7 +33,34 @@ object DeckyManager {
private set
data class Release(val tag: String, val prerelease: Boolean, val asset: String, val url: String, val digest: String?, val shaUrl: String?, val size: Long, val machine: Int)
data class ReleaseChannels(val stable: List<Release>, val prerelease: List<Release>)
data class ReleaseChannels(val stable: List<Release>, val prerelease: List<Release>, val all: List<Release> = stable + prerelease)
const val BRANCH_STABLE = 0
const val BRANCH_PRERELEASE = 1
const val BRANCH_TESTING = 2
private fun settingsFile(context: Context) = File(LinuxRuntime.rootDir(context), SETTINGS)
fun branch(context: Context): Int {
val saved = runCatching { JSONObject(settingsFile(context).readText()).optInt("branch", -1) }.getOrDefault(-1)
if (saved >= 0) return saved
val version = File(LinuxRuntime.rootDir(context), VERSION).takeIf { it.isFile }?.readText()?.trim().orEmpty()
return if (version.startsWith("v") && "-pre" in version) BRANCH_PRERELEASE else BRANCH_STABLE
}
fun setBranch(context: Context, branch: Int) {
val file = settingsFile(context)
val settings = runCatching { JSONObject(file.readText()) }.getOrDefault(JSONObject())
settings.put("branch", branch)
file.parentFile?.mkdirs()
val staged = File(file.parentFile, "${file.name}.tmp")
staged.writeText(settings.toString(4))
check(staged.renameTo(file)) { "cannot write ${file.path}" }
}
// Mirrors Decky's updater: Stable takes the newest non-prerelease tag, Prerelease the newest tag of either kind.
fun latest(channels: ReleaseChannels, branch: Int): Release? =
if (branch == BRANCH_STABLE) channels.stable.firstOrNull() else channels.all.firstOrNull()
fun installed(context: Context): String? {
val loader = loader(context)
@@ -74,17 +102,13 @@ object DeckyManager {
}
}
fun releases(context: Context, prerelease: Boolean): List<Release> {
val channels = releaseChannels(context)
return if (prerelease) channels.prerelease else channels.stable
}
fun releaseChannels(context: Context): ReleaseChannels {
val body = Downloader.downloadString(RELEASES) ?: return ReleaseChannels(emptyList(), emptyList())
return try {
val array = JSONArray(body)
val stable = mutableListOf<Release>()
val prerelease = mutableListOf<Release>()
val all = mutableListOf<Release>()
for (i in 0 until array.length()) {
val release = array.getJSONObject(i)
if (release.optBoolean("draft", false)) continue
@@ -105,9 +129,11 @@ object DeckyManager {
val size = binary.optLong("size", 0)
if (size <= 0) continue
val row = Release(release.optString("tag_name", "unknown"), isPrerelease, name, url, digest, checksum, size, if (name == "PluginLoader-arm64") 183 else 62)
(if (isPrerelease) prerelease else stable).add(row)
if (!row.tag.startsWith("v")) continue
all.add(row)
(if (isPrerelease || "-pre" in row.tag) prerelease else stable).add(row)
}
ReleaseChannels(stable, prerelease)
ReleaseChannels(stable, prerelease, all)
} catch (e: Exception) { Log.w(TAG, "release metadata", e); ReleaseChannels(emptyList(), emptyList()) }
}
@@ -99,6 +99,7 @@ class ModeSettings(
val wifiDiscoveryBlocked: Boolean = false,
val deckyInstalled: String? = null,
val deckyLatestRelease: DeckyManager.Release? = null,
val deckyBranch: Int = DeckyManager.BRANCH_STABLE,
val deckyChecking: Boolean = false,
val deckyStage: String? = null,
val deckyPercent: Int = -1,
@@ -145,6 +146,7 @@ class ModeSettingsActions(
val onWifiDiscoverySettings: () -> Unit = {},
val onDeckyInstall: (DeckyManager.Release) -> Unit = {},
val onDeckyCheck: () -> Unit = {},
val onDeckyBranch: (Int) -> Unit = {},
val onDeckyEnabled: (Boolean) -> Unit = {},
val onDeckyUninstall: () -> Unit = {},
val onPickDeckyPluginZip: () -> Unit = {},
@@ -424,6 +426,19 @@ fun ModeSheetRows(s: ModeSettings, a: ModeSettingsActions, tab: String, extras:
if (s.deckyInstalled != null) DeckyUninstall(enabled = s.deckyStage == null && !s.deckySessionRunning, onUninstall = a.onDeckyUninstall)
}
}
ChoiceRow(
host, "decky-channel", stringResource(R.string.decky_channel),
stringResource(R.string.decky_channel_hint),
listOf(
DeckyManager.BRANCH_STABLE to stringResource(R.string.decky_channel_stable),
DeckyManager.BRANCH_PRERELEASE to stringResource(R.string.decky_channel_prerelease),
) + if (s.deckyBranch == DeckyManager.BRANCH_TESTING) listOf(
DeckyManager.BRANCH_TESTING to stringResource(R.string.decky_channel_testing),
) else emptyList(),
s.deckyBranch,
enabled = s.deckyStage == null && !s.deckySessionRunning,
onPick = a.onDeckyBranch,
)
if (s.deckyInstalled != null) ToggleRow(
host, "decky-enabled", stringResource(R.string.mode_decky),
if (s.deckyEnabled) stringResource(R.string.mode_decky_on)
+6
View File
@@ -308,6 +308,12 @@
<string name="mode_decky_current">Up to date · %1$s</string>
<string name="mode_decky_loader">Loader</string>
<string name="mode_decky_install_latest">Install latest</string>
<string name="decky_channel">Update Channel</string>
<string name="decky_channel_hint">Which Decky Loader releases to install. Decky\'s own updater uses the same channel.</string>
<string name="decky_channel_stable">Stable</string>
<string name="decky_channel_prerelease">Prerelease</string>
<string name="decky_channel_testing">Testing</string>
<string name="decky_channel_failed">Could not save the Decky update channel</string>
<string name="mode_decky_on">Starts with Steam. While on, other apps on this device may be able to control Steam.</string>
<string name="mode_decky_off">Off. Steam\'s remote debugging port stays closed.</string>
<string name="mode_decky_remove_title">Uninstall Decky Loader?</string>
@@ -0,0 +1,32 @@
package com.droiddeck.launcher.runtime
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class DeckyChannelTest {
private fun release(tag: String, prerelease: Boolean) =
DeckyManager.Release(tag, prerelease, "PluginLoader-arm64", "https://example.invalid/$tag", null, null, 1, 183)
@Test fun stableSkipsPrereleasesAndPrereleaseTakesTheNewestTag() {
val pre = release("v3.4.0-pre1", true)
val stable = release("v3.3.0", false)
val channels = DeckyManager.ReleaseChannels(listOf(stable), listOf(pre), listOf(pre, stable))
assertEquals("v3.3.0", DeckyManager.latest(channels, DeckyManager.BRANCH_STABLE)?.tag)
assertEquals("v3.4.0-pre1", DeckyManager.latest(channels, DeckyManager.BRANCH_PRERELEASE)?.tag)
}
@Test fun prereleaseFollowsAStableThatShippedAfterTheLastPrerelease() {
val stable = release("v3.3.0", false)
val pre = release("v3.3.0-pre1", true)
val channels = DeckyManager.ReleaseChannels(listOf(stable), listOf(pre), listOf(stable, pre))
assertEquals("v3.3.0", DeckyManager.latest(channels, DeckyManager.BRANCH_PRERELEASE)?.tag)
}
@Test fun stableHasNothingWhenOnlyPrereleasesExist() {
val pre = release("v3.4.0-pre1", true)
assertNull(DeckyManager.latest(DeckyManager.ReleaseChannels(emptyList(), listOf(pre), listOf(pre)), DeckyManager.BRANCH_STABLE))
}
}