diff --git a/app/src/main/java/com/droiddeck/launcher/DeckyMenu.kt b/app/src/main/java/com/droiddeck/launcher/DeckyMenu.kt index 03e781247..240202dae 100644 --- a/app/src/main/java/com/droiddeck/launcher/DeckyMenu.kt +++ b/app/src/main/java/com/droiddeck/launcher/DeckyMenu.kt @@ -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(null) - var deckyReleases by mutableStateOf>(emptyList()) + var deckyLatest by mutableStateOf(null) + var deckyBranch by mutableIntStateOf(DeckyManager.BRANCH_STABLE) + private var deckyChannels = DeckyManager.ReleaseChannels(emptyList(), emptyList()) var deckyChecking by mutableStateOf(false) var deckyStage by mutableStateOf(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 diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index 4735d19dc..16893e62c 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -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) diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/DeckyManager.kt b/app/src/main/java/com/droiddeck/launcher/runtime/DeckyManager.kt index ba454af7a..5c5687c96 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/DeckyManager.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/DeckyManager.kt @@ -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, val prerelease: List) + data class ReleaseChannels(val stable: List, val prerelease: List, val all: List = 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 { - 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() val prerelease = mutableListOf() + val all = mutableListOf() 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()) } } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt index e09605819..f5d649025 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt @@ -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) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 026229b8d..a11bc727e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -308,6 +308,12 @@ Up to date ยท %1$s Loader Install latest + Update Channel + Which Decky Loader releases to install. Decky\'s own updater uses the same channel. + Stable + Prerelease + Testing + Could not save the Decky update channel Starts with Steam. While on, other apps on this device may be able to control Steam. Off. Steam\'s remote debugging port stays closed. Uninstall Decky Loader? diff --git a/app/src/test/java/com/droiddeck/launcher/runtime/DeckyChannelTest.kt b/app/src/test/java/com/droiddeck/launcher/runtime/DeckyChannelTest.kt new file mode 100644 index 000000000..025fc6379 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/runtime/DeckyChannelTest.kt @@ -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)) + } +}