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,
|
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,
|
||||||
|
|||||||
@ -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()))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user