From 0acc457890edfeee3207651cd65cd752b508940e Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Mon, 26 Oct 2026 12:44:04 +0600 Subject: [PATCH] feat(plugin): implement automatic plugin updates and enhance JSON parsing --- .../kotlin/org/spotube/spotube/App.kt | 5 +-- .../spotube/modules/plugin/PluginManager.kt | 16 ++++++- .../spotube/modules/plugin/PluginModels.kt | 43 +++++++++++++++++++ .../modules/update/PluginUpdateDialog.kt | 6 ++- .../modules/update/PluginUpdateService.kt | 12 ++++++ 5 files changed, 75 insertions(+), 7 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/App.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/App.kt index 9655a6d3..37a15a6f 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/App.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/App.kt @@ -262,10 +262,7 @@ fun App( update = update, onDismiss = pluginUpdateService::dismissUpdate, onIgnore = pluginUpdateService::ignoreUpdate, - onUpdate = { - runCatching { openUrlInBrowser(update.info.directDownloadUrl) } - pluginUpdateService.dismissUpdate() - }) + onUpdate = pluginUpdateService::installUpdate) } SupportDialog( visible = showSupportDialog, diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginManager.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginManager.kt index 71871b23..c9d673e2 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginManager.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginManager.kt @@ -115,6 +115,9 @@ class PluginManager( val database: Database, val paths: Paths) : KoinComponent, PluginProvider, AudioPluginSource, ScrobblePluginSource { private val logger by injectLogger() + + // plugin.json may carry fields newer than this app knows about (e.g. `entryPoint`). + private val pluginJsonParser = Json { ignoreUnknownKeys = true } private val pluginExceptionHandler = CoroutineExceptionHandler { _, exception -> logger.e(exception) { "Plugin runtime threw an unhandled exception. Intercepted safely." } } @@ -324,6 +327,15 @@ class PluginManager( } } + /** Downloads and installs a newer plugin build over the installed one, without prompting. */ + suspend fun updatePluginFromURL(url: String) { + withContext(Dispatchers.IO) { + val response = httpClient.get(url) + val bytes = response.bodyAsChannel().readRemaining().readByteArray() + addPluginFromByteArray(bytes = bytes, allowReplacingInstalled = true) + } + } + /** Parses the zip, reads plugin.json, then surfaces a PendingPlugin for the UI to confirm. */ suspend fun preparePlugin(bytes: ByteArray) { withContext(Dispatchers.IO) { @@ -362,7 +374,7 @@ class PluginManager( val pluginJson = FileSystem.SYSTEM.source(pluginJsonPath).buffer().use { it.readUtf8() } val pluginEntry = try { - Json.decodeFromString(pluginJson) + pluginJsonParser.decodeFromString(pluginJson) } catch (e: Exception) { throw IllegalArgumentException(getString(Res.string.plugin_error_invalid_plugin_json, e.message.orEmpty())) } @@ -484,7 +496,7 @@ class PluginManager( } val pluginEntry = try { - Json.decodeFromString(pluginJson) + pluginJsonParser.decodeFromString(pluginJson) } catch (e: Exception) { throw IllegalArgumentException(getString(Res.string.plugin_error_invalid_plugin_json, e.message.orEmpty())) } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginModels.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginModels.kt index 2048248f..050dd859 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginModels.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginModels.kt @@ -18,9 +18,18 @@ package org.spotube.spotube.modules.plugin import com.goncalossilva.murmurhash.MurmurHash3 +import kotlinx.serialization.KSerializer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonDecoder +@Serializable enum class PluginCapability { @SerialName("persistent_storage") PERSISTENT_STORAGE, @@ -43,6 +52,38 @@ enum class PluginAbility { SCROBBLE, } +/** + * Decodes a list of enums while dropping values this app version doesn't know about, so a + * plugin.json written for another plugin API revision still parses. + */ +abstract class LenientEnumListSerializer>( + private val elementSerializer: KSerializer) : KSerializer> { + private val listSerializer = ListSerializer(elementSerializer) + override val descriptor: SerialDescriptor = listSerializer.descriptor + + override fun serialize(encoder: Encoder, value: List) = + listSerializer.serialize(encoder, value) + + override fun deserialize(decoder: Decoder): List { + val jsonDecoder = decoder as? JsonDecoder ?: return listSerializer.deserialize(decoder) + val array = jsonDecoder.decodeJsonElement() as? JsonArray + ?: throw SerializationException("Expected a JSON array") + return array.mapNotNull { element -> + try { + jsonDecoder.json.decodeFromJsonElement(elementSerializer, element) + } catch (_: SerializationException) { + null + } + } + } +} + +object LenientPluginCapabilityListSerializer : + LenientEnumListSerializer(PluginCapability.serializer()) + +object LenientPluginAbilityListSerializer : + LenientEnumListSerializer(PluginAbility.serializer()) + @Serializable data class PluginEntry( val name: String, @@ -50,7 +91,9 @@ data class PluginEntry( val apiVersion: String, val description: String, val author: String, + @Serializable(with = LenientPluginCapabilityListSerializer::class) val capabilities: List, + @Serializable(with = LenientPluginAbilityListSerializer::class) val abilities: List, val contact: String, val repository: String, diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/PluginUpdateDialog.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/PluginUpdateDialog.kt index dc9d3388..dbfd236f 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/PluginUpdateDialog.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/PluginUpdateDialog.kt @@ -20,7 +20,10 @@ package org.spotube.spotube.modules.update import org.spotube.millennium.composables.MillenniumAlertDialog import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.text.selection.SelectionContainer import org.spotube.spotube.core.ui.theming.AppTheme import com.composeunstyled.Text @@ -40,7 +43,7 @@ import spotube.composeapp.generated.resources.update_action_update /** * Prompts the user that a newer version of one of their installed plugins is - * available. [onUpdate] should open the plugin's direct download URL; [onIgnore] + * available. [onUpdate] should download and install the update; [onIgnore] * persistently silences this specific plugin version so the check moves on to the * next newer one (or another plugin). */ @@ -53,6 +56,7 @@ fun PluginUpdateDialog( MillenniumAlertDialog( visible = true, onDismissRequest = onDismiss, + modifier = Modifier.windowInsetsPadding(WindowInsets.safeDrawing), title = { Text( stringResource( diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/PluginUpdateService.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/PluginUpdateService.kt index 0b941cde..6c09dbeb 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/PluginUpdateService.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/PluginUpdateService.kt @@ -124,6 +124,18 @@ class PluginUpdateService( } } + fun installUpdate() { + val update = _availableUpdate.value ?: return + _availableUpdate.value = null + scope.launch { + runCatching { + pluginManager.updatePluginFromURL(update.info.directDownloadUrl) + }.onFailure { e -> + logger.e(e) { "Failed to install update for plugin ${update.pluginName}" } + } + } + } + fun dismissUpdate() { _availableUpdate.value = null }