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, update = update,
onDismiss = pluginUpdateService::dismissUpdate, onDismiss = pluginUpdateService::dismissUpdate,
onIgnore = pluginUpdateService::ignoreUpdate, onIgnore = pluginUpdateService::ignoreUpdate,
onUpdate = { onUpdate = pluginUpdateService::installUpdate)
runCatching { openUrlInBrowser(update.info.directDownloadUrl) }
pluginUpdateService.dismissUpdate()
})
} }
SupportDialog( SupportDialog(
visible = showSupportDialog, visible = showSupportDialog,

View File

@ -115,6 +115,9 @@ class PluginManager(
val database: Database, val database: Database,
val paths: Paths) : KoinComponent, PluginProvider, AudioPluginSource, ScrobblePluginSource { val paths: Paths) : KoinComponent, PluginProvider, AudioPluginSource, ScrobblePluginSource {
private val logger by injectLogger<PluginManager>() 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 -> private val pluginExceptionHandler = CoroutineExceptionHandler { _, exception ->
logger.e(exception) { "Plugin runtime threw an unhandled exception. Intercepted safely." } 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. */ /** Parses the zip, reads plugin.json, then surfaces a PendingPlugin for the UI to confirm. */
suspend fun preparePlugin(bytes: ByteArray) { suspend fun preparePlugin(bytes: ByteArray) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
@ -362,7 +374,7 @@ class PluginManager(
val pluginJson = val pluginJson =
FileSystem.SYSTEM.source(pluginJsonPath).buffer().use { it.readUtf8() } FileSystem.SYSTEM.source(pluginJsonPath).buffer().use { it.readUtf8() }
val pluginEntry = try { val pluginEntry = try {
Json.decodeFromString<PluginEntry>(pluginJson) pluginJsonParser.decodeFromString<PluginEntry>(pluginJson)
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalArgumentException(getString(Res.string.plugin_error_invalid_plugin_json, e.message.orEmpty())) throw IllegalArgumentException(getString(Res.string.plugin_error_invalid_plugin_json, e.message.orEmpty()))
} }
@ -484,7 +496,7 @@ class PluginManager(
} }
val pluginEntry = try { val pluginEntry = try {
Json.decodeFromString<PluginEntry>(pluginJson) pluginJsonParser.decodeFromString<PluginEntry>(pluginJson)
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalArgumentException(getString(Res.string.plugin_error_invalid_plugin_json, e.message.orEmpty())) 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 package org.spotube.spotube.modules.plugin
import com.goncalossilva.murmurhash.MurmurHash3 import com.goncalossilva.murmurhash.MurmurHash3
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable 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 { enum class PluginCapability {
@SerialName("persistent_storage") @SerialName("persistent_storage")
PERSISTENT_STORAGE, PERSISTENT_STORAGE,
@ -43,6 +52,38 @@ enum class PluginAbility {
SCROBBLE, 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 @Serializable
data class PluginEntry( data class PluginEntry(
val name: String, val name: String,
@ -50,7 +91,9 @@ data class PluginEntry(
val apiVersion: String, val apiVersion: String,
val description: String, val description: String,
val author: String, val author: String,
@Serializable(with = LenientPluginCapabilityListSerializer::class)
val capabilities: List<PluginCapability>, val capabilities: List<PluginCapability>,
@Serializable(with = LenientPluginAbilityListSerializer::class)
val abilities: List<PluginAbility>, val abilities: List<PluginAbility>,
val contact: String, val contact: String,
val repository: String, val repository: String,

View File

@ -20,7 +20,10 @@ package org.spotube.spotube.modules.update
import org.spotube.millennium.composables.MillenniumAlertDialog import org.spotube.millennium.composables.MillenniumAlertDialog
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.padding 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 androidx.compose.foundation.text.selection.SelectionContainer
import org.spotube.spotube.core.ui.theming.AppTheme import org.spotube.spotube.core.ui.theming.AppTheme
import com.composeunstyled.Text 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 * 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 * persistently silences this specific plugin version so the check moves on to the
* next newer one (or another plugin). * next newer one (or another plugin).
*/ */
@ -53,6 +56,7 @@ fun PluginUpdateDialog(
MillenniumAlertDialog( MillenniumAlertDialog(
visible = true, visible = true,
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
modifier = Modifier.windowInsetsPadding(WindowInsets.safeDrawing),
title = { title = {
Text( Text(
stringResource( 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() { fun dismissUpdate() {
_availableUpdate.value = null _availableUpdate.value = null
} }