From 3629141365a365b86f51be21ca710c518571a366 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 10 Jun 2026 15:16:18 +0530 Subject: [PATCH] feat: plugin support for desktop --- composeApp/build.gradle.kts | 4 ++ .../plugins/PluginPlatform.android.kt | 2 + .../core/build/AppFeaturePolicy.desktop.kt | 2 +- .../features/plugins/PluginCrypto.desktop.kt | 59 +++++++++++++++++++ .../plugins/PluginPlatform.desktop.kt | 35 +++++++++++ .../plugins/PluginRepository.desktop.kt | 42 ------------- .../settings/PluginsSettingsPage.desktop.kt | 11 +++- .../plugins/PluginRuntimeDesktopTest.kt | 34 +++++++++++ .../app/features/plugins/PluginRepository.kt | 6 +- .../features/plugins/PluginPlatform.ios.kt | 2 + 10 files changed, 150 insertions(+), 47 deletions(-) create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/plugins/PluginCrypto.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/plugins/PluginPlatform.desktop.kt delete mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.desktop.kt create mode 100644 composeApp/src/desktopTest/kotlin/com/nuvio/app/features/plugins/PluginRuntimeDesktopTest.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 49577e6f..fdeaa9d5 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -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 { diff --git a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginPlatform.android.kt b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginPlatform.android.kt index 6e77db32..f44e40cd 100644 --- a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginPlatform.android.kt +++ b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginPlatform.android.kt @@ -26,4 +26,6 @@ internal object PluginStorage { internal fun currentPluginPlatform(): String = "android" +internal fun currentPluginPlatformTags(): Set = setOf(currentPluginPlatform()) + internal fun currentEpochMillis(): Long = System.currentTimeMillis() diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.desktop.kt index e1c3f19c..a989c77d 100644 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.desktop.kt +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.desktop.kt @@ -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 diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/plugins/PluginCrypto.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/plugins/PluginCrypto.desktop.kt new file mode 100644 index 00000000..0b73ade0 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/plugins/PluginCrypto.desktop.kt @@ -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() +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/plugins/PluginPlatform.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/plugins/PluginPlatform.desktop.kt new file mode 100644 index 00000000..1217b448 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/plugins/PluginPlatform.desktop.kt @@ -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 { + 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() diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.desktop.kt deleted file mode 100644 index c6af9b68..00000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.desktop.kt +++ /dev/null @@ -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 = 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 = emptyList() - - actual suspend fun testScraper(scraperId: String): Result> = - 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> = - Result.failure(UnsupportedOperationException(getString(Res.string.plugins_error_unavailable_build))) -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/PluginsSettingsPage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/PluginsSettingsPage.desktop.kt index 99a36f15..31fed517 100644 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/PluginsSettingsPage.desktop.kt +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/PluginsSettingsPage.desktop.kt @@ -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(), + ) + } +} diff --git a/composeApp/src/desktopTest/kotlin/com/nuvio/app/features/plugins/PluginRuntimeDesktopTest.kt b/composeApp/src/desktopTest/kotlin/com/nuvio/app/features/plugins/PluginRuntimeDesktopTest.kt new file mode 100644 index 00000000..ecf9739c --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/com/nuvio/app/features/plugins/PluginRuntimeDesktopTest.kt @@ -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) + } +} diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.kt index 88439c51..da5a0e8f 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.kt @@ -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 } diff --git a/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginPlatform.ios.kt b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginPlatform.ios.kt index a30ad428..0d304e96 100644 --- a/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginPlatform.ios.kt +++ b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginPlatform.ios.kt @@ -19,5 +19,7 @@ internal object PluginStorage { internal fun currentPluginPlatform(): String = "ios" +internal fun currentPluginPlatformTags(): Set = setOf(currentPluginPlatform()) + internal fun currentEpochMillis(): Long = (platform.Foundation.NSDate().timeIntervalSince1970 * 1000.0).toLong()