feat: plugin support for desktop

This commit is contained in:
tapframe 2026-06-10 15:16:18 +05:30
parent 3c6bb75fd0
commit 3629141365
10 changed files with 150 additions and 47 deletions

View file

@ -241,6 +241,7 @@ val iosDistributionSourceDir = if (iosDistribution == "full") {
}
val iosFrameworkBundleId = "com.nuvio.media"
val fullCommonSourceDir = project.file("src/fullCommonMain/kotlin")
val fullPluginSourceDir = fullCommonSourceDir.resolve("com/nuvio/app/features/plugins")
val generatedRuntimeConfigDir = layout.buildDirectory.dir("generated/runtime-config/kotlin")
val requestedGradleTasks = gradle.startParameter.taskNames.map { taskName ->
taskName.substringAfterLast(':').lowercase()
@ -692,10 +693,13 @@ kotlin {
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("lib-*.aar"))))
}
val desktopMain by getting {
kotlin.srcDir(fullPluginSourceDir)
dependencies {
implementation(compose.desktop.currentOs)
implementation(libs.kotlinx.coroutines.swing)
implementation(libs.ktor.client.cio)
implementation(libs.quickjs.kt)
implementation(libs.ksoup)
}
}
commonMain.dependencies {

View file

@ -26,4 +26,6 @@ internal object PluginStorage {
internal fun currentPluginPlatform(): String = "android"
internal fun currentPluginPlatformTags(): Set<String> = setOf(currentPluginPlatform())
internal fun currentEpochMillis(): Long = System.currentTimeMillis()

View file

@ -1,7 +1,7 @@
package com.nuvio.app.core.build
actual object AppFeaturePolicy {
actual val pluginsEnabled: Boolean = false
actual val pluginsEnabled: Boolean = true
actual val downloadsEnabled: Boolean = false
actual val notificationsEnabled: Boolean = false
actual val p2pEnabled: Boolean = false

View file

@ -0,0 +1,59 @@
package com.nuvio.app.features.plugins
import java.security.MessageDigest
import java.util.Base64
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
internal fun pluginDigestHex(algorithm: String, data: String): String {
val normalized = algorithm.uppercase()
val digest = MessageDigest.getInstance(normalized).digest(data.encodeToByteArray())
return digest.joinToString(separator = "") { byte ->
byte.toUByte().toString(16).padStart(2, '0')
}
}
internal fun pluginHmacHex(algorithm: String, key: String, data: String): String {
val normalized = when (algorithm.uppercase()) {
"SHA1" -> "HmacSHA1"
"SHA256" -> "HmacSHA256"
"SHA512" -> "HmacSHA512"
"MD5" -> "HmacMD5"
else -> error("Unsupported HMAC algorithm: $algorithm")
}
val mac = Mac.getInstance(normalized)
mac.init(SecretKeySpec(key.encodeToByteArray(), normalized))
val digest = mac.doFinal(data.encodeToByteArray())
return digest.joinToString(separator = "") { byte ->
byte.toUByte().toString(16).padStart(2, '0')
}
}
internal fun pluginBase64Encode(data: String): String =
Base64.getEncoder().encodeToString(data.encodeToByteArray())
internal fun pluginBase64Decode(data: String): String {
val normalized = data.trim().replace("\n", "").replace("\r", "").replace(" ", "")
val decoded = Base64.getDecoder().decode(normalized)
return decoded.decodeToString()
}
internal fun pluginUtf8ToHex(value: String): String =
value.encodeToByteArray().joinToString(separator = "") { byte ->
byte.toUByte().toString(16).padStart(2, '0')
}
internal fun pluginHexToUtf8(hex: String): String {
val normalized = hex.trim().lowercase()
.replace(" ", "")
.removePrefix("0x")
if (normalized.isEmpty()) return ""
val evenHex = if (normalized.length % 2 == 0) normalized else "0$normalized"
val out = ByteArray(evenHex.length / 2)
for (index in out.indices) {
val part = evenHex.substring(index * 2, index * 2 + 2)
out[index] = part.toInt(16).toByte()
}
return out.decodeToString()
}

View file

@ -0,0 +1,35 @@
package com.nuvio.app.features.plugins
import com.nuvio.app.core.storage.DesktopStorage
import java.util.Locale
internal object PluginStorage {
private const val pluginsStateKey = "plugins_state"
private val store = DesktopStorage.store("nuvio_plugins")
fun loadState(profileId: Int): String? =
store.getString("${pluginsStateKey}_$profileId")
fun saveState(profileId: Int, payload: String) {
store.putString("${pluginsStateKey}_$profileId", payload)
}
}
internal fun currentPluginPlatform(): String = "desktop"
internal fun currentPluginPlatformTags(): Set<String> {
val osName = System.getProperty("os.name").orEmpty().lowercase(Locale.ROOT)
val osTag = when {
osName.contains("mac") || osName.contains("darwin") -> "macos"
osName.contains("win") -> "windows"
osName.contains("linux") -> "linux"
else -> null
}
return buildSet {
add(currentPluginPlatform())
add("jvm")
osTag?.let(::add)
}
}
internal fun currentEpochMillis(): Long = System.currentTimeMillis()

View file

@ -1,42 +0,0 @@
package com.nuvio.app.features.plugins
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.plugins_error_unavailable_build
import org.jetbrains.compose.resources.getString
actual object PluginRepository {
private val disabledState = MutableStateFlow(PluginsUiState(pluginsEnabled = false))
actual val uiState: StateFlow<PluginsUiState> = disabledState.asStateFlow()
actual fun initialize() = Unit
actual fun onProfileChanged(profileId: Int) = Unit
actual fun clearLocalState() = Unit
actual suspend fun pullFromServer(profileId: Int) = Unit
actual suspend fun addRepository(rawUrl: String): AddPluginRepositoryResult =
AddPluginRepositoryResult.Error(getString(Res.string.plugins_error_unavailable_build))
actual fun removeRepository(manifestUrl: String) = Unit
actual fun refreshAll() = Unit
actual fun refreshRepository(manifestUrl: String, pushAfterRefresh: Boolean) = Unit
actual fun toggleScraper(scraperId: String, enabled: Boolean) = Unit
actual fun setPluginsEnabled(enabled: Boolean) = Unit
actual fun setGroupStreamsByRepository(enabled: Boolean) = Unit
actual fun getEnabledScrapersForType(type: String): List<PluginScraper> = emptyList()
actual suspend fun testScraper(scraperId: String): Result<List<PluginRuntimeResult>> =
Result.failure(UnsupportedOperationException(getString(Res.string.plugins_error_unavailable_build)))
actual suspend fun executeScraper(
scraper: PluginScraper,
tmdbId: String,
mediaType: String,
season: Int?,
episode: Int?,
): Result<List<PluginRuntimeResult>> =
Result.failure(UnsupportedOperationException(getString(Res.string.plugins_error_unavailable_build)))
}

View file

@ -1,5 +1,14 @@
package com.nuvio.app.features.settings
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.ui.Modifier
import com.nuvio.app.features.plugins.PluginsSettingsPageContent
internal actual fun LazyListScope.pluginsSettingsContent() = Unit
internal actual fun LazyListScope.pluginsSettingsContent() {
item {
PluginsSettingsPageContent(
modifier = Modifier.fillMaxWidth(),
)
}
}

View file

@ -0,0 +1,34 @@
package com.nuvio.app.features.plugins
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
class PluginRuntimeDesktopTest {
@Test
fun `desktop runtime executes scraper code`() = runBlocking {
val results = PluginRuntime.executePlugin(
code = """
module.exports.getStreams = async function(tmdbId, mediaType) {
return [{
title: "Desktop stream " + tmdbId + " " + mediaType,
url: "https://example.test/movie.mp4",
quality: "1080p",
provider: "Desktop Test"
}];
};
""".trimIndent(),
tmdbId = "603",
mediaType = "movie",
season = null,
episode = null,
scraperId = "desktop-runtime-test",
)
assertEquals(1, results.size)
assertEquals("Desktop stream 603 movie", results.single().title)
assertEquals("https://example.test/movie.mp4", results.single().url)
assertEquals("1080p", results.single().quality)
assertEquals("Desktop Test", results.single().provider)
}
}

View file

@ -413,11 +413,11 @@ actual object PluginRepository {
}
private fun PluginManifestScraper.isSupportedOnCurrentPlatform(): Boolean {
val platform = currentPluginPlatform().lowercase()
val platformTags = currentPluginPlatformTags().map { it.lowercase() }.toSet()
val supported = supportedPlatforms?.map { it.lowercase() }?.toSet().orEmpty()
val disabled = disabledPlatforms?.map { it.lowercase() }?.toSet().orEmpty()
if (supported.isNotEmpty() && platform !in supported) return false
if (platform in disabled) return false
if (supported.isNotEmpty() && platformTags.none { it in supported }) return false
if (platformTags.any { it in disabled }) return false
return true
}

View file

@ -19,5 +19,7 @@ internal object PluginStorage {
internal fun currentPluginPlatform(): String = "ios"
internal fun currentPluginPlatformTags(): Set<String> = setOf(currentPluginPlatform())
internal fun currentEpochMillis(): Long =
(platform.Foundation.NSDate().timeIntervalSince1970 * 1000.0).toLong()