mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 10:13:59 +00:00
feat(plugin): implement automatic plugin updates and enhance JSON parsing
This commit is contained in:
parent
2edbfefe93
commit
0acc457890
@ -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,
|
||||
|
||||
@ -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()))
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user