feat(plugin): implement automatic plugin updates and enhance JSON parsing

This commit is contained in:
Kingkor Roy Tirtho 2026-10-26 12:44:04 +06:00
parent 2edbfefe93
commit 0acc457890
5 changed files with 75 additions and 7 deletions

View File

@ -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,

View File

@ -115,6 +115,9 @@ class PluginManager(
val database: Database,
val paths: Paths) : KoinComponent, PluginProvider, AudioPluginSource, ScrobblePluginSource {
private val logger by injectLogger<PluginManager>()
// 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<PluginEntry>(pluginJson)
pluginJsonParser.decodeFromString<PluginEntry>(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<PluginEntry>(pluginJson)
pluginJsonParser.decodeFromString<PluginEntry>(pluginJson)
} catch (e: Exception) {
throw IllegalArgumentException(getString(Res.string.plugin_error_invalid_plugin_json, e.message.orEmpty()))
}

View File

@ -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<T : Enum<T>>(
private val elementSerializer: KSerializer<T>) : KSerializer<List<T>> {
private val listSerializer = ListSerializer(elementSerializer)
override val descriptor: SerialDescriptor = listSerializer.descriptor
override fun serialize(encoder: Encoder, value: List<T>) =
listSerializer.serialize(encoder, value)
override fun deserialize(decoder: Decoder): List<T> {
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>(PluginCapability.serializer())
object LenientPluginAbilityListSerializer :
LenientEnumListSerializer<PluginAbility>(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<PluginCapability>,
@Serializable(with = LenientPluginAbilityListSerializer::class)
val abilities: List<PluginAbility>,
val contact: String,
val repository: String,

View File

@ -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(

View File

@ -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
}