From 4c59ab90a33f51e675c11a507daf620f28188077 Mon Sep 17 00:00:00 2001 From: paregi12 Date: Sat, 16 May 2026 15:22:07 +0530 Subject: [PATCH 01/24] feat(plugin-runtime): implement robust, future-proof native crypto engine (AES, PBKDF2, Web Crypto) --- .../features/plugins/PluginCrypto.android.kt | 135 +++- .../features/plugins/runtime/PluginRuntime.kt | 175 ++++++ .../plugins/runtime/crypto/CryptoBridge.kt | 136 ++++ .../features/plugins/runtime/dom/DomBridge.kt | 118 ++++ .../plugins/runtime/host/HostApiRegistry.kt | 19 + .../plugins/runtime/host/HostFunctions.kt | 43 ++ .../features/plugins/runtime/js/JsBindings.kt | 595 ++++++++++++++++++ .../features/plugins/runtime/js/JsRuntime.kt | 16 + .../plugins/runtime/network/FetchBridge.kt | 103 +++ .../plugins/runtime/wasm/WasmBridge.kt | 16 + .../app/features/plugins/PluginCrypto.ios.kt | 273 +++++++- .../cinterop/commoncrypto_shim.h | 81 +++ 12 files changed, 1692 insertions(+), 18 deletions(-) create mode 100644 composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt create mode 100644 composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/crypto/CryptoBridge.kt create mode 100644 composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/dom/DomBridge.kt create mode 100644 composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/host/HostApiRegistry.kt create mode 100644 composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/host/HostFunctions.kt create mode 100644 composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt create mode 100644 composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsRuntime.kt create mode 100644 composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/network/FetchBridge.kt create mode 100644 composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/wasm/WasmBridge.kt diff --git a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.android.kt b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.android.kt index 8a4c66e9..475fd8b4 100644 --- a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.android.kt +++ b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.android.kt @@ -1,14 +1,139 @@ package com.nuvio.app.features.plugins +import java.security.KeyFactory +import java.security.MessageDigest +import java.security.SecureRandom +import java.security.Signature +import java.security.spec.PKCS8EncodedKeySpec +import java.security.spec.X509EncodedKeySpec +import javax.crypto.Cipher +import javax.crypto.Mac +import javax.crypto.SecretKeyFactory +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.PBEKeySpec +import javax.crypto.spec.SecretKeySpec import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi -import java.security.MessageDigest -import javax.crypto.Mac -import javax.crypto.spec.SecretKeySpec + +private val secureRandom = SecureRandom() + +internal fun pluginGetRandomValues(length: Int): ByteArray { + val bytes = ByteArray(length) + secureRandom.nextBytes(bytes) + return bytes +} + +internal fun pluginDigest(algorithm: String, data: ByteArray): ByteArray { + return MessageDigest.getInstance(algorithm.uppercase()).digest(data) +} + +internal fun pluginPbkdf2( + password: ByteArray, + salt: ByteArray, + iterations: Int, + keySizeBits: Int, + algorithm: String, +): ByteArray { + val normalizedAlgo = when (algorithm.uppercase()) { + "SHA256" -> "PBKDF2WithHmacSHA256" + "SHA1" -> "PBKDF2WithHmacSHA1" + else -> "PBKDF2WithHmacSHA256" + } + val factory = SecretKeyFactory.getInstance(normalizedAlgo) + val passChars = password.map { (it.toInt() and 0xFF).toChar() }.toCharArray() + val spec = PBEKeySpec(passChars, salt, iterations, keySizeBits) + return factory.generateSecret(spec).encoded +} + +internal fun pluginAesEncrypt( + mode: String, + key: ByteArray, + iv: ByteArray, + data: ByteArray, +): ByteArray { + val normalizedMode = when (mode.uppercase()) { + "AES-CBC", "CBC" -> "AES/CBC/PKCS5Padding" + "AES-GCM", "GCM" -> "AES/GCM/NoPadding" + "AES-ECB", "ECB" -> "AES/ECB/PKCS5Padding" + else -> "AES/CBC/PKCS5Padding" + } + + val cipher = Cipher.getInstance(normalizedMode) + val keySpec = SecretKeySpec(key, "AES") + + if (normalizedMode.contains("ECB")) { + cipher.init(Cipher.ENCRYPT_MODE, keySpec) + } else if (normalizedMode.contains("GCM")) { + val gcmSpec = GCMParameterSpec(128, iv) + cipher.init(Cipher.ENCRYPT_MODE, keySpec, gcmSpec) + } else { + val ivSpec = IvParameterSpec(iv) + cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec) + } + + return cipher.doFinal(data) +} + +internal fun pluginAesDecrypt( + mode: String, + key: ByteArray, + iv: ByteArray, + data: ByteArray, +): ByteArray { + val normalizedMode = when (mode.uppercase()) { + "AES-CBC", "CBC" -> "AES/CBC/PKCS5Padding" + "AES-GCM", "GCM" -> "AES/GCM/NoPadding" + "AES-ECB", "ECB" -> "AES/ECB/PKCS5Padding" + else -> "AES/CBC/PKCS5Padding" + } + + val cipher = Cipher.getInstance(normalizedMode) + val keySpec = SecretKeySpec(key, "AES") + + if (normalizedMode.contains("ECB")) { + cipher.init(Cipher.DECRYPT_MODE, keySpec) + } else if (normalizedMode.contains("GCM")) { + val gcmSpec = GCMParameterSpec(128, iv) + cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmSpec) + } else { + val ivSpec = IvParameterSpec(iv) + cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec) + } + + return cipher.doFinal(data) +} + +internal fun pluginSign(algorithm: String, privateKey: ByteArray, data: ByteArray): ByteArray { + val (keyAlgo, sigAlgo) = when (algorithm.uppercase()) { + "RSASSA-PKCS1-V1_5-SHA256", "RSASSA-PKCS1-V1_5" -> "RSA" to "SHA256withRSA" + "ECDSA-SHA256", "ECDSA" -> "EC" to "SHA256withECDSA" + else -> "RSA" to "SHA256withRSA" + } + val factory = KeyFactory.getInstance(keyAlgo) + val privKey = factory.generatePrivate(PKCS8EncodedKeySpec(privateKey)) + val sig = Signature.getInstance(sigAlgo) + sig.initSign(privKey) + sig.update(data) + return sig.sign() +} + +internal fun pluginVerify(algorithm: String, publicKey: ByteArray, signature: ByteArray, data: ByteArray): Boolean { + val (keyAlgo, sigAlgo) = when (algorithm.uppercase()) { + "RSASSA-PKCS1-V1_5-SHA256", "RSASSA-PKCS1-V1_5" -> "RSA" to "SHA256withRSA" + "ECDSA-SHA256", "ECDSA" -> "EC" to "SHA256withECDSA" + else -> "RSA" to "SHA256withRSA" + } + val factory = KeyFactory.getInstance(keyAlgo) + val pubKey = factory.generatePublic(X509EncodedKeySpec(publicKey)) + val sig = Signature.getInstance(sigAlgo) + sig.initVerify(pubKey) + sig.update(data) + return sig.verify(signature) +} internal fun pluginDigestHex(algorithm: String, data: String): String { - val normalized = algorithm.uppercase() - val digest = MessageDigest.getInstance(normalized).digest(data.encodeToByteArray()) + val digest = pluginDigest(algorithm, data.encodeToByteArray()) return digest.joinToString(separator = "") { byte -> byte.toUByte().toString(16).padStart(2, '0') } diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt new file mode 100644 index 00000000..7349e11d --- /dev/null +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt @@ -0,0 +1,175 @@ +package com.nuvio.app.features.plugins.runtime + +import com.nuvio.app.features.plugins.PluginRuntimeResult +import com.nuvio.app.features.plugins.runtime.crypto.CryptoBridge +import com.nuvio.app.features.plugins.runtime.dom.DomBridge +import com.nuvio.app.features.plugins.runtime.host.HostApiRegistry +import com.nuvio.app.features.plugins.runtime.host.HostFunctions +import com.nuvio.app.features.plugins.runtime.js.JsBindings +import com.nuvio.app.features.plugins.runtime.js.JsRuntime +import com.nuvio.app.features.plugins.runtime.network.FetchBridge +import com.nuvio.app.features.plugins.runtime.wasm.WasmBridge +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonPrimitive + +private const val PLUGIN_TIMEOUT_MS = 60_000L + +internal object PluginRuntime { + private val json = Json { ignoreUnknownKeys = true } + + suspend fun executePlugin( + code: String, + tmdbId: String, + mediaType: String, + season: Int?, + episode: Int?, + scraperId: String, + scraperSettings: Map = emptyMap(), + ): List = withContext(Dispatchers.Default) { + withTimeout(PLUGIN_TIMEOUT_MS) { + executePluginInternal( + code = code, + tmdbId = tmdbId, + mediaType = mediaType, + season = season, + episode = episode, + scraperId = scraperId, + scraperSettings = scraperSettings, + ) + } + } + + private suspend fun executePluginInternal( + code: String, + tmdbId: String, + mediaType: String, + season: Int?, + episode: Int?, + scraperId: String, + scraperSettings: Map, + ): List { + val jsRuntime = JsRuntime() + var resultJson = "[]" + + val domBridge = DomBridge() + val hostRegistry = HostApiRegistry().apply { + addModule(HostFunctions(scraperId) { resultJson = it }) + addModule(FetchBridge()) + addModule(CryptoBridge()) + addModule(WasmBridge()) + addModule(domBridge) + } + + try { + jsRuntime.use { + hostRegistry.registerAll(this) + + val settingsJson = toJsonElement(scraperSettings).toString() + val polyfillCode = JsBindings.buildPolyfillCode(scraperId, settingsJson) + evaluate(polyfillCode) + + val wrappedCode = """ + var module = { exports: {} }; + var exports = module.exports; + (function() { + $code + })(); + """.trimIndent() + evaluate(wrappedCode) + + val seasonArg = season?.toString() ?: "undefined" + val episodeArg = episode?.toString() ?: "undefined" + val callCode = """ + (async function() { + try { + var getStreams = module.exports.getStreams || globalThis.getStreams; + if (!getStreams) { + console.error("getStreams function not found on module.exports or globalThis"); + __capture_result(JSON.stringify([])); + return; + } + var result = await getStreams("$tmdbId", "$mediaType", $seasonArg, $episodeArg); + __capture_result(JSON.stringify(result || [])); + } catch (e) { + console.error("getStreams error:", e && e.message ? e.message : e, e && e.stack ? e.stack : ""); + __capture_result(JSON.stringify([])); + } + })(); + """.trimIndent() + evaluate(callCode) + } + + return parseJsonResults(resultJson) + } finally { + domBridge.clear() + } + } + + private fun parseJsonResults(rawJson: String): List { + return runCatching { + val array = json.parseToJsonElement(rawJson) as? JsonArray ?: return emptyList() + array.mapNotNull { element -> + val item = element as? JsonObject ?: return@mapNotNull null + val url = when (val urlValue = item["url"]) { + is JsonPrimitive -> urlValue.contentOrNull?.takeIf { it.isNotBlank() } + is JsonObject -> urlValue["url"]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() } + else -> null + } ?: return@mapNotNull null + + val headers = (item["headers"] as? JsonObject) + ?.mapNotNull { (key, value) -> + value.jsonPrimitive.contentOrNull?.let { key to it } + } + ?.toMap() + ?.takeIf { it.isNotEmpty() } + + PluginRuntimeResult( + title = item.stringOrNull("title") ?: item.stringOrNull("name") ?: "Unknown", + name = item.stringOrNull("name"), + url = url, + quality = item.stringOrNull("quality"), + size = item.stringOrNull("size"), + language = item.stringOrNull("language"), + provider = item.stringOrNull("provider"), + type = item.stringOrNull("type"), + seeders = item["seeders"]?.jsonPrimitive?.intOrNull, + peers = item["peers"]?.jsonPrimitive?.intOrNull, + infoHash = item.stringOrNull("infoHash"), + headers = headers, + ) + }.filter { it.url.isNotBlank() } + }.getOrElse { emptyList() } + } + + private fun JsonObject.stringOrNull(key: String): String? = + this[key]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() && !it.contains("[object") } + + private fun toJsonElement(value: Any?): JsonElement = when (value) { + null -> JsonNull + is JsonElement -> value + is String -> JsonPrimitive(value) + is Boolean -> JsonPrimitive(value) + is Int -> JsonPrimitive(value) + is Long -> JsonPrimitive(value) + is Float -> JsonPrimitive(value) + is Double -> JsonPrimitive(value) + is Number -> JsonPrimitive(value.toDouble()) + is Map<*, *> -> JsonObject( + value.entries + .filter { it.key is String } + .associate { (it.key as String) to toJsonElement(it.value) }, + ) + is Iterable<*> -> JsonArray(value.map(::toJsonElement)) + else -> JsonPrimitive(value.toString()) + } +} diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/crypto/CryptoBridge.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/crypto/CryptoBridge.kt new file mode 100644 index 00000000..0cbe87ce --- /dev/null +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/crypto/CryptoBridge.kt @@ -0,0 +1,136 @@ +package com.nuvio.app.features.plugins.runtime.crypto + +import com.dokar.quickjs.QuickJs +import com.dokar.quickjs.binding.function +import com.nuvio.app.features.plugins.runtime.host.HostModule +import com.nuvio.app.features.plugins.pluginDigestHex +import com.nuvio.app.features.plugins.pluginHmacHex +import com.nuvio.app.features.plugins.pluginBase64Encode +import com.nuvio.app.features.plugins.pluginBase64Decode +import com.nuvio.app.features.plugins.pluginUtf8ToHex +import com.nuvio.app.features.plugins.pluginHexToUtf8 +import com.nuvio.app.features.plugins.pluginGetRandomValues +import com.nuvio.app.features.plugins.pluginDigest +import com.nuvio.app.features.plugins.pluginPbkdf2 +import com.nuvio.app.features.plugins.pluginAesDecrypt +import com.nuvio.app.features.plugins.pluginAesEncrypt +import com.nuvio.app.features.plugins.pluginSign +import com.nuvio.app.features.plugins.pluginVerify + +internal class CryptoBridge : HostModule { + override fun register(runtime: QuickJs) { + // --- Binary-Safe Bridges (New) --- + + runtime.function("__crypto_get_random_values") { args -> + val length = (args.getOrNull(0) as? Number)?.toInt() ?: 0 + runCatching { + pluginGetRandomValues(length) + }.getOrElse { ByteArray(0) } + } + + runtime.function("__crypto_digest_raw") { args -> + val algorithm = args.getOrNull(0)?.toString() ?: "SHA256" + val data = args.getOrNull(1) as? ByteArray ?: ByteArray(0) + runCatching { + pluginDigest(algorithm, data) + }.getOrElse { ByteArray(0) } + } + + runtime.function("__crypto_pbkdf2_raw") { args -> + val password = args.getOrNull(0) as? ByteArray ?: ByteArray(0) + val salt = args.getOrNull(1) as? ByteArray ?: ByteArray(0) + val iterations = (args.getOrNull(2) as? Number)?.toInt() ?: 1000 + val keySizeBits = (args.getOrNull(3) as? Number)?.toInt() ?: 256 + val algorithm = args.getOrNull(4)?.toString() ?: "SHA256" + runCatching { + pluginPbkdf2(password, salt, iterations, keySizeBits, algorithm) + }.getOrElse { ByteArray(0) } + } + + runtime.function("__crypto_aes_encrypt_raw") { args -> + val mode = args.getOrNull(0)?.toString() ?: "AES-CBC" + val key = args.getOrNull(1) as? ByteArray ?: ByteArray(0) + val iv = args.getOrNull(2) as? ByteArray ?: ByteArray(0) + val data = args.getOrNull(3) as? ByteArray ?: ByteArray(0) + runCatching { + pluginAesEncrypt(mode, key, iv, data) + }.getOrElse { ByteArray(0) } + } + + runtime.function("__crypto_aes_decrypt_raw") { args -> + val mode = args.getOrNull(0)?.toString() ?: "AES-CBC" + val key = args.getOrNull(1) as? ByteArray ?: ByteArray(0) + val iv = args.getOrNull(2) as? ByteArray ?: ByteArray(0) + val data = args.getOrNull(3) as? ByteArray ?: ByteArray(0) + runCatching { + pluginAesDecrypt(mode, key, iv, data) + }.getOrElse { ByteArray(0) } + } + + runtime.function("__crypto_sign_raw") { args -> + val algorithm = args.getOrNull(0)?.toString() ?: "" + val privateKey = args.getOrNull(1) as? ByteArray ?: ByteArray(0) + val data = args.getOrNull(2) as? ByteArray ?: ByteArray(0) + runCatching { + pluginSign(algorithm, privateKey, data) + }.getOrElse { ByteArray(0) } + } + + runtime.function("__crypto_verify_raw") { args -> + val algorithm = args.getOrNull(0)?.toString() ?: "" + val publicKey = args.getOrNull(1) as? ByteArray ?: ByteArray(0) + val signature = args.getOrNull(2) as? ByteArray ?: ByteArray(0) + val data = args.getOrNull(3) as? ByteArray ?: ByteArray(0) + runCatching { + pluginVerify(algorithm, publicKey, signature, data) + }.getOrDefault(false) + } + + // --- Legacy Hex/String Bridges (Backward Compatibility) --- + + runtime.function("__crypto_digest_hex") { args -> + val algorithm = args.getOrNull(0)?.toString() ?: "SHA256" + val data = args.getOrNull(1)?.toString() ?: "" + runCatching { + pluginDigestHex(algorithm, data) + }.getOrDefault("") + } + + runtime.function("__crypto_hmac_hex") { args -> + val algorithm = args.getOrNull(0)?.toString() ?: "SHA256" + val key = args.getOrNull(1)?.toString() ?: "" + val data = args.getOrNull(2)?.toString() ?: "" + runCatching { + pluginHmacHex(algorithm, key, data) + }.getOrDefault("") + } + + runtime.function("__crypto_base64_encode") { args -> + val data = args.getOrNull(0)?.toString() ?: "" + runCatching { + pluginBase64Encode(data) + }.getOrDefault("") + } + + runtime.function("__crypto_base64_decode") { args -> + val data = args.getOrNull(0)?.toString() ?: "" + runCatching { + pluginBase64Decode(data) + }.getOrDefault("") + } + + runtime.function("__crypto_utf8_to_hex") { args -> + val data = args.getOrNull(0)?.toString() ?: "" + runCatching { + pluginUtf8ToHex(data) + }.getOrDefault("") + } + + runtime.function("__crypto_hex_to_utf8") { args -> + val data = args.getOrNull(0)?.toString() ?: "" + runCatching { + pluginHexToUtf8(data) + }.getOrDefault("") + } + } +} diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/dom/DomBridge.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/dom/DomBridge.kt new file mode 100644 index 00000000..0ada370e --- /dev/null +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/dom/DomBridge.kt @@ -0,0 +1,118 @@ +package com.nuvio.app.features.plugins.runtime.dom + +import com.dokar.quickjs.QuickJs +import com.dokar.quickjs.binding.function +import com.fleeksoft.ksoup.Ksoup +import com.fleeksoft.ksoup.nodes.Document +import com.fleeksoft.ksoup.nodes.Element +import com.fleeksoft.ksoup.select.Elements +import com.nuvio.app.features.plugins.runtime.host.HostModule +import kotlin.random.Random + +internal class DomBridge : HostModule { + private val documentCache = mutableMapOf() + private val elementCache = mutableMapOf() + private var idCounter = 0 + private val containsRegex = Regex(""":contains\([\"']([^\"']+)[\"']\)""") + + override fun register(runtime: QuickJs) { + runtime.function("__cheerio_load") { args -> + val html = args.getOrNull(0)?.toString() ?: "" + val docId = "doc_${idCounter++}_${Random.nextInt(0, Int.MAX_VALUE)}" + documentCache[docId] = Ksoup.parse(html) + docId + } + + runtime.function("__cheerio_select") { args -> + val docId = args.getOrNull(0)?.toString() ?: "" + var selector = args.getOrNull(1)?.toString() ?: "" + val doc = documentCache[docId] ?: return@function "[]" + try { + selector = selector.replace(containsRegex, ":contains($1)") + val elements = if (selector.isEmpty()) Elements() else doc.select(selector) + val ids = elements.mapIndexed { index, el -> + val id = "$docId:$index:${el.hashCode()}" + elementCache[id] = el + id + } + "[" + ids.joinToString(",") { "\"${it.replace("\"", "\\\"")}\"" } + "]" + } catch (_: Exception) { + "[]" + } + } + + runtime.function("__cheerio_find") { args -> + val docId = args.getOrNull(0)?.toString() ?: "" + val elementId = args.getOrNull(1)?.toString() ?: "" + var selector = args.getOrNull(2)?.toString() ?: "" + val element = elementCache[elementId] ?: return@function "[]" + try { + selector = selector.replace(containsRegex, ":contains($1)") + val elements = element.select(selector) + val ids = elements.mapIndexed { index, el -> + val id = "$docId:find:$index:${el.hashCode()}" + elementCache[id] = el + id + } + "[" + ids.joinToString(",") { "\"${it.replace("\"", "\\\"")}\"" } + "]" + } catch (_: Exception) { + "[]" + } + } + + runtime.function("__cheerio_text") { args -> + val elementIds = args.getOrNull(1)?.toString() ?: "" + elementIds.split(",") + .filter { it.isNotEmpty() } + .mapNotNull { elementCache[it]?.text() } + .joinToString(" ") + } + + runtime.function("__cheerio_html") { args -> + val docId = args.getOrNull(0)?.toString() ?: "" + val elementId = args.getOrNull(1)?.toString() ?: "" + if (elementId.isEmpty()) { + documentCache[docId]?.html() ?: "" + } else { + elementCache[elementId]?.html() ?: "" + } + } + + runtime.function("__cheerio_inner_html") { args -> + val elementId = args.getOrNull(1)?.toString() ?: "" + elementCache[elementId]?.html() ?: "" + } + + runtime.function("__cheerio_attr") { args -> + val elementId = args.getOrNull(1)?.toString() ?: "" + val attrName = args.getOrNull(2)?.toString() ?: "" + val value = elementCache[elementId]?.attr(attrName) + if (value.isNullOrEmpty()) "__UNDEFINED__" else value + } + + runtime.function("__cheerio_next") { args -> + val docId = args.getOrNull(0)?.toString() ?: "" + val elementId = args.getOrNull(1)?.toString() ?: "" + val element = elementCache[elementId] ?: return@function "__NONE__" + val next = element.nextElementSibling() ?: return@function "__NONE__" + val nextId = "$docId:next:${next.hashCode()}" + elementCache[nextId] = next + nextId + } + + runtime.function("__cheerio_prev") { args -> + val docId = args.getOrNull(0)?.toString() ?: "" + val elementId = args.getOrNull(1)?.toString() ?: "" + val element = elementCache[elementId] ?: return@function "__NONE__" + val prev = element.previousElementSibling() ?: return@function "__NONE__" + val prevId = "$docId:prev:${prev.hashCode()}" + elementCache[prevId] = prev + prevId + } + } + + fun clear() { + documentCache.clear() + elementCache.clear() + } +} diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/host/HostApiRegistry.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/host/HostApiRegistry.kt new file mode 100644 index 00000000..c002258a --- /dev/null +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/host/HostApiRegistry.kt @@ -0,0 +1,19 @@ +package com.nuvio.app.features.plugins.runtime.host + +import com.dokar.quickjs.QuickJs + +internal interface HostModule { + fun register(runtime: QuickJs) +} + +internal class HostApiRegistry { + private val modules = mutableListOf() + + fun addModule(module: HostModule) { + modules.add(module) + } + + fun registerAll(runtime: QuickJs) { + modules.forEach { it.register(runtime) } + } +} diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/host/HostFunctions.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/host/HostFunctions.kt new file mode 100644 index 00000000..552fbac6 --- /dev/null +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/host/HostFunctions.kt @@ -0,0 +1,43 @@ +package com.nuvio.app.features.plugins.runtime.host + +import co.touchlab.kermit.Logger +import com.dokar.quickjs.QuickJs +import com.dokar.quickjs.binding.define +import com.dokar.quickjs.binding.function + +internal class HostFunctions( + private val scraperId: String, + private val onResult: (String) -> Unit +) : HostModule { + private val log = Logger.withTag("PluginRuntime") + + override fun register(runtime: QuickJs) { + runtime.define("console") { + function("log") { args -> + log.d { "Plugin:$scraperId ${args.joinToString(" ") { it?.toString() ?: "null" }}" } + null + } + function("error") { args -> + log.e { "Plugin:$scraperId ${args.joinToString(" ") { it?.toString() ?: "null" }}" } + null + } + function("warn") { args -> + log.w { "Plugin:$scraperId ${args.joinToString(" ") { it?.toString() ?: "null" }}" } + null + } + function("info") { args -> + log.i { "Plugin:$scraperId ${args.joinToString(" ") { it?.toString() ?: "null" }}" } + null + } + function("debug") { args -> + log.d { "Plugin:$scraperId ${args.joinToString(" ") { it?.toString() ?: "null" }}" } + null + } + } + + runtime.function("__capture_result") { args -> + onResult(args.getOrNull(0)?.toString() ?: "[]") + null + } + } +} diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt new file mode 100644 index 00000000..0969bc70 --- /dev/null +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt @@ -0,0 +1,595 @@ +package com.nuvio.app.features.plugins.runtime.js + +internal object JsBindings { + fun buildPolyfillCode(scraperId: String, settingsJson: String): String { + return """ + globalThis.SCRAPER_ID = "$scraperId"; + globalThis.SCRAPER_SETTINGS = $settingsJson; + if (typeof globalThis.global === 'undefined') globalThis.global = globalThis; + if (typeof globalThis.window === 'undefined') globalThis.window = globalThis; + if (typeof globalThis.self === 'undefined') globalThis.self = globalThis; + + ${fetchPolyfill()} + ${abortControllerPolyfill()} + ${base64Polyfill()} + ${urlPolyfill()} + ${cryptoPolyfill()} + ${cheerioPolyfill()} + ${requirePolyfill()} + ${arrayPolyfill()} + ${objectPolyfill()} + ${stringPolyfill()} + """.trimIndent() + } + + private fun fetchPolyfill() = """ + var fetch = async function(url, options) { + options = options || {}; + var method = (options.method || 'GET').toUpperCase(); + var headers = options.headers || {}; + var body = options.body || ''; + var followRedirects = options.redirect !== 'manual'; + var result = __native_fetch(url, method, JSON.stringify(headers), body, followRedirects); + var parsed = JSON.parse(result); + return { + ok: parsed.ok, + status: parsed.status, + statusText: parsed.statusText, + url: parsed.url, + headers: { + get: function(name) { + return parsed.headers[name.toLowerCase()] || null; + } + }, + text: function() { return Promise.resolve(parsed.body); }, + json: function() { + try { + if (parsed.body === null || parsed.body === undefined || parsed.body === '') { + return Promise.resolve(null); + } + return Promise.resolve(JSON.parse(parsed.body)); + } catch (e) { + return Promise.resolve(null); + } + } + }; + }; + """.trimIndent() + + private fun abortControllerPolyfill() = """ + if (typeof AbortSignal === 'undefined') { + var AbortSignal = function() { this.aborted = false; this.reason = undefined; this._listeners = []; }; + AbortSignal.prototype.addEventListener = function(type, listener) { + if (type !== 'abort' || typeof listener !== 'function') return; + this._listeners.push(listener); + }; + AbortSignal.prototype.removeEventListener = function(type, listener) { + if (type !== 'abort') return; + this._listeners = this._listeners.filter(function(l) { return l !== listener; }); + }; + AbortSignal.prototype.dispatchEvent = function(event) { + if (!event || event.type !== 'abort') return true; + for (var i = 0; i < this._listeners.length; i++) { + try { this._listeners[i].call(this, event); } catch (e) {} + } + return true; + }; + globalThis.AbortSignal = AbortSignal; + } + + if (typeof AbortController === 'undefined') { + var AbortController = function() { this.signal = new AbortSignal(); }; + AbortController.prototype.abort = function(reason) { + if (this.signal.aborted) return; + this.signal.aborted = true; + this.signal.reason = reason; + this.signal.dispatchEvent({ type: 'abort' }); + }; + globalThis.AbortController = AbortController; + } + """.trimIndent() + + private fun base64Polyfill() = """ + if (typeof atob === 'undefined') { + globalThis.atob = function(input) { + var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='; + var str = String(input).replace(/=+$/, ''); + if (str.length % 4 === 1) throw new Error('InvalidCharacterError'); + var output = ''; + var bc = 0, bs, buffer, idx = 0; + while ((buffer = str.charAt(idx++))) { + buffer = chars.indexOf(buffer); + if (buffer === -1) continue; + bs = bc % 4 ? bs * 64 + buffer : buffer; + if (bc++ % 4) output += String.fromCharCode(255 & (bs >> ((-2 * bc) & 6))); + } + return output; + }; + } + + if (typeof btoa === 'undefined') { + globalThis.btoa = function(input) { + var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='; + var str = String(input); + var output = ''; + for (var block, charCode, idx = 0, map = chars; + str.charAt(idx | 0) || (map = '=', idx % 1); + output += map.charAt(63 & (block >> (8 - (idx % 1) * 8)))) { + charCode = str.charCodeAt(idx += 3 / 4); + if (charCode > 0xFF) throw new Error('InvalidCharacterError'); + block = (block << 8) | charCode; + } + return output; + }; + } + """.trimIndent() + + private fun urlPolyfill() = """ + var __native_parse_url = typeof __parse_url !== 'undefined' ? __parse_url : function(u) { return JSON.stringify({ protocol: '', host: '', hostname: '', port: '', pathname: '/', search: '', hash: '' }); }; + var URL = function(urlString, base) { + var fullUrl = urlString; + if (base && !/^https?:\/\//i.test(urlString)) { + var b = typeof base === 'string' ? base : base.href; + if (urlString.charAt(0) === '/') { + var m = b.match(/^(https?:\/\/[^\/]+)/); + fullUrl = m ? m[1] + urlString : urlString; + } else { + fullUrl = b.replace(/\/[^\/]*$/, '/') + urlString; + } + } + var parsed = __native_parse_url(fullUrl); + var data = JSON.parse(parsed); + this.href = fullUrl; + this.protocol = data.protocol; + this.host = data.host; + this.hostname = data.hostname; + this.port = data.port; + this.pathname = data.pathname; + this.search = data.search; + this.hash = data.hash; + this.origin = data.protocol + '//' + data.host; + this.searchParams = new URLSearchParams(data.search || ''); + }; + URL.prototype.toString = function() { return this.href; }; + + var URLSearchParams = function(init) { + this._params = {}; + var self = this; + if (init && typeof init === 'object' && !Array.isArray(init)) { + Object.keys(init).forEach(function(key) { self._params[key] = String(init[key]); }); + } else if (typeof init === 'string') { + init.replace(/^\?/, '').split('&').forEach(function(pair) { + var parts = pair.split('='); + if (parts[0]) self._params[decodeURIComponent(parts[0])] = decodeURIComponent(parts[1] || ''); + }); + } + }; + URLSearchParams.prototype.toString = function() { + var self = this; + return Object.keys(this._params).map(function(key) { + return encodeURIComponent(key) + '=' + encodeURIComponent(self._params[key]); + }).join('&'); + }; + URLSearchParams.prototype.get = function(key) { return this._params.hasOwnProperty(key) ? this._params[key] : null; }; + URLSearchParams.prototype.set = function(key, value) { this._params[key] = String(value); }; + URLSearchParams.prototype.append = function(key, value) { this._params[key] = String(value); }; + URLSearchParams.prototype.has = function(key) { return this._params.hasOwnProperty(key); }; + URLSearchParams.prototype.delete = function(key) { delete this._params[key]; }; + URLSearchParams.prototype.keys = function() { return Object.keys(this._params); }; + URLSearchParams.prototype.values = function() { + var self = this; + return Object.keys(this._params).map(function(k) { return self._params[k]; }); + }; + URLSearchParams.prototype.entries = function() { + var self = this; + return Object.keys(this._params).map(function(k) { return [k, self._params[k]]; }); + }; + URLSearchParams.prototype.forEach = function(callback) { + var self = this; + Object.keys(this._params).forEach(function(key) { callback(self._params[key], key, self); }); + }; + URLSearchParams.prototype.getAll = function(key) { + return this._params.hasOwnProperty(key) ? [this._params[key]] : []; + }; + URLSearchParams.prototype.sort = function() { + var sorted = {}; + var self = this; + Object.keys(this._params).sort().forEach(function(k) { sorted[k] = self._params[k]; }); + this._params = sorted; + }; + """.trimIndent() + + private fun cryptoPolyfill() = """ + function __hexToWords(hex) { + var words = []; + for (var i = 0; i < hex.length; i += 8) { + var chunk = hex.substring(i, i + 8); + while (chunk.length < 8) chunk += '0'; + words.push(parseInt(chunk, 16) | 0); + } + return words; + } + + function __wordsToHex(words, sigBytes) { + var hex = ''; + for (var i = 0; i < sigBytes; i++) { + var word = words[i >>> 2] || 0; + var byte = (word >>> (24 - (i % 4) * 8)) & 0xff; + var part = byte.toString(16); + if (part.length < 2) part = '0' + part; + hex += part; + } + return hex; + } + + function __wordArrayToHex(value) { + if (!value) return ''; + if (typeof value.__hex === 'string') return value.__hex.toLowerCase(); + if (Array.isArray(value.words) && typeof value.sigBytes === 'number') { + return __wordsToHex(value.words, value.sigBytes); + } + return typeof __crypto_utf8_to_hex !== 'undefined' ? __crypto_utf8_to_hex(String(value)) : ''; + } + + function __buildWordArray(hex, utf8Override) { + var normalizedHex = (hex || '').toLowerCase(); + if (normalizedHex.length % 2 !== 0) normalizedHex = '0' + normalizedHex; + var wordArray = { + __hex: normalizedHex, + __utf8: utf8Override !== undefined ? utf8Override : (typeof __crypto_hex_to_utf8 !== 'undefined' ? __crypto_hex_to_utf8(normalizedHex) : ''), + sigBytes: normalizedHex.length / 2, + words: __hexToWords(normalizedHex), + toString: function(encoder) { + if (!encoder || encoder === CryptoJS.enc.Hex) return this.__hex; + if (encoder === CryptoJS.enc.Utf8) return this.__utf8; + if (encoder === CryptoJS.enc.Base64) return typeof __crypto_base64_encode !== 'undefined' ? __crypto_base64_encode(this.__utf8) : ''; + return this.__hex; + }, + clamp: function() { return this; }, + concat: function(other) { + var otherHex = __wordArrayToHex(other); + this.__hex += otherHex; + this.__utf8 = typeof __crypto_hex_to_utf8 !== 'undefined' ? __crypto_hex_to_utf8(this.__hex) : ''; + this.sigBytes = this.__hex.length / 2; + this.words = __hexToWords(this.__hex); + return this; + } + }; + return wordArray; + } + + function __wordArrayFromHex(hex) { return __buildWordArray(hex, undefined); } + function __wordArrayFromUtf8(text) { + var utf8 = text == null ? '' : String(text); + var hex = typeof __crypto_utf8_to_hex !== 'undefined' ? __crypto_utf8_to_hex(utf8) : ''; + return __buildWordArray(hex, utf8); + } + function __wordArrayFromBase64(base64) { + var utf8 = typeof __crypto_base64_decode !== 'undefined' ? __crypto_base64_decode(base64 || '') : ''; + return __wordArrayFromUtf8(utf8); + } + + function __normalizeWordArrayInput(value) { + if (value && typeof value === 'object' && typeof value.__utf8 === 'string') return value.__utf8; + if (value && typeof value === 'object' && typeof value.__hex === 'string') return typeof __crypto_hex_to_utf8 !== 'undefined' ? __crypto_hex_to_utf8(value.__hex) : ''; + if (value && typeof value === 'object' && Array.isArray(value.words) && typeof value.sigBytes === 'number') { + return typeof __crypto_hex_to_utf8 !== 'undefined' ? __crypto_hex_to_utf8(__wordsToHex(value.words, value.sigBytes)) : ''; + } + if (value == null) return ''; + return String(value); + } + + function __bufferToUint8(data) { + if (data instanceof Uint8Array) return data; + if (data instanceof ArrayBuffer) return new Uint8Array(data); + if (typeof data === 'string') return new TextEncoder().encode(data); + return new Uint8Array(0); + } + + var CryptoJS = { + enc: { + Hex: { stringify: function(wa) { return __wordArrayToHex(wa); }, parse: function(s) { return __wordArrayFromHex(s); } }, + Utf8: { stringify: function(wa) { return wa.toString(CryptoJS.enc.Utf8); }, parse: function(s) { return __wordArrayFromUtf8(s); } }, + Base64: { stringify: function(wa) { return wa.toString(CryptoJS.enc.Base64); }, parse: function(s) { return __wordArrayFromBase64(s); } } + }, + lib: { WordArray: { create: function(words, sigBytes) { return __buildWordArray(__wordsToHex(words, sigBytes), undefined); } } }, + mode: { CBC: 'AES-CBC', GCM: 'AES-GCM', ECB: 'AES-ECB' }, + pad: { Pkcs7: 'Pkcs7', NoPadding: 'NoPadding' }, + algo: { SHA256: 'SHA256' }, + MD5: function(m) { return __wordArrayFromHex(__crypto_digest_hex('MD5', __normalizeWordArrayInput(m))); }, + SHA1: function(m) { return __wordArrayFromHex(__crypto_digest_hex('SHA1', __normalizeWordArrayInput(m))); }, + SHA256: function(m) { return __wordArrayFromHex(__crypto_digest_hex('SHA256', __normalizeWordArrayInput(m))); }, + SHA512: function(m) { return __wordArrayFromHex(__crypto_digest_hex('SHA512', __normalizeWordArrayInput(m))); }, + PBKDF2: function(pass, salt, options) { + var pBytes = __bufferToUint8(__normalizeWordArrayInput(pass)); + var sBytes = __bufferToUint8(__normalizeWordArrayInput(salt)); + var iter = options.iterations || 1000; + var kSize = options.keySize || (256/32); + var algo = options.hasher === CryptoJS.algo.SHA256 ? 'SHA256' : 'SHA1'; + var resBytes = typeof __crypto_pbkdf2_raw !== 'undefined' ? __crypto_pbkdf2_raw(pBytes, sBytes, iter, kSize * 32, algo) : new Uint8Array(0); + return __wordArrayFromHex(__wordsToHex(Array.from(resBytes), resBytes.length)); + }, + AES: { + encrypt: function(message, key, options) { + var data = __bufferToUint8(__normalizeWordArrayInput(message)); + var kBytes = __bufferToUint8(__wordArrayToHex(key)); + var ivBytes = __bufferToUint8(__wordArrayToHex(options.iv || '')); + var mode = options.mode || 'AES-CBC'; + var resBytes = typeof __crypto_aes_encrypt_raw !== 'undefined' ? __crypto_aes_encrypt_raw(mode, kBytes, ivBytes, data) : new Uint8Array(0); + var wa = __wordArrayFromHex(__wordsToHex(Array.from(resBytes), resBytes.length)); + return { + ciphertext: wa, + toString: function() { return wa.toString(CryptoJS.enc.Base64); } + }; + }, + decrypt: function(cipher, key, options) { + var data = typeof cipher === 'string' ? __bufferToUint8(typeof __crypto_base64_decode !== 'undefined' ? __crypto_base64_decode(cipher) : '') : (cipher.ciphertext ? __bufferToUint8(typeof __crypto_base64_decode !== 'undefined' ? __crypto_base64_decode(cipher.ciphertext.toString(CryptoJS.enc.Base64)) : '') : __bufferToUint8(cipher)); + var kBytes = __bufferToUint8(__wordArrayToHex(key)); + var ivBytes = __bufferToUint8(__wordArrayToHex(options.iv || '')); + var mode = options.mode || 'AES-CBC'; + var resBytes = typeof __crypto_aes_decrypt_raw !== 'undefined' ? __crypto_aes_decrypt_raw(mode, kBytes, ivBytes, data) : new Uint8Array(0); + var plain = new TextDecoder().decode(resBytes); + return { toString: function(enc) { return plain; } }; + } + } + }; + globalThis.CryptoJS = CryptoJS; + + globalThis.crypto = { + subtle: { + digest: async function(algo, data) { + var bytes = __bufferToUint8(data); + var res = typeof __crypto_digest_raw !== 'undefined' ? __crypto_digest_raw(algo.name || algo, bytes) : new Uint8Array(0); + return res.buffer; + }, + importKey: async function(fmt, data, algo, ext, use) { return { _raw: data, _algo: algo }; }, + deriveBits: async function(params, key, len) { + var pBytes = __bufferToUint8(key._raw); + var sBytes = __bufferToUint8(params.salt); + var res = typeof __crypto_pbkdf2_raw !== 'undefined' ? __crypto_pbkdf2_raw(pBytes, sBytes, params.iterations, len, params.hash) : new Uint8Array(0); + return res.buffer; + }, + encrypt: async function(params, key, data) { + var kBytes = __bufferToUint8(key._raw); + var ivBytes = __bufferToUint8(params.iv || ''); + var dBytes = __bufferToUint8(data); + var res = typeof __crypto_aes_encrypt_raw !== 'undefined' ? __crypto_aes_encrypt_raw(params.name, kBytes, ivBytes, dBytes) : new Uint8Array(0); + return res.buffer; + }, + decrypt: async function(params, key, data) { + var kBytes = __bufferToUint8(key._raw); + var ivBytes = __bufferToUint8(params.iv || ''); + var dBytes = __bufferToUint8(data); + var res = typeof __crypto_aes_decrypt_raw !== 'undefined' ? __crypto_aes_decrypt_raw(params.name, kBytes, ivBytes, dBytes) : new Uint8Array(0); + return res.buffer; + }, + sign: async function(algo, key, data) { + var algoName = typeof algo === 'string' ? algo : (algo.name || ''); + var kBytes = __bufferToUint8(key._raw); + var dBytes = __bufferToUint8(data); + var res = typeof __crypto_sign_raw !== 'undefined' ? __crypto_sign_raw(algoName, kBytes, dBytes) : new Uint8Array(0); + return res.buffer; + }, + verify: async function(algo, key, sig, data) { + var algoName = typeof algo === 'string' ? algo : (algo.name || ''); + var kBytes = __bufferToUint8(key._raw); + var sBytes = __bufferToUint8(sig); + var dBytes = __bufferToUint8(data); + return typeof __crypto_verify_raw !== 'undefined' ? __crypto_verify_raw(algoName, kBytes, sBytes, dBytes) : false; + } + }, + getRandomValues: function(arr) { + + var bytes = typeof __crypto_get_random_values !== 'undefined' ? __crypto_get_random_values(arr.length) : new Uint8Array(arr.length); + for (var i = 0; i < arr.length; i++) arr[i] = bytes[i]; + return arr; + } + }; + + // WebAssembly placeholder + globalThis.WebAssembly = { + instantiate: async function(bufferSource, importObject) { + console.warn("WebAssembly.instantiate called (placeholder)"); + return { instance: { exports: {} }, module: {} }; + } + }; + """.trimIndent() + + private fun cheerioPolyfill() = """ + var cheerio = { + load: function(html) { + var docId = __cheerio_load(html); + var $ = function(selector, context) { + if (selector && selector._elementIds) return selector; + if (context && context._elementIds && context._elementIds.length > 0) { + var allIds = []; + for (var i = 0; i < context._elementIds.length; i++) { + var childIdsJson = __cheerio_find(docId, context._elementIds[i], selector); + var childIds = JSON.parse(childIdsJson); + allIds = allIds.concat(childIds); + } + return createCheerioWrapperFromIds(docId, allIds); + } + return createCheerioWrapper(docId, selector); + }; + $.html = function(el) { + if (el && el._elementIds && el._elementIds.length > 0) { + return __cheerio_html(docId, el._elementIds[0]); + } + return __cheerio_html(docId, ''); + }; + return $; + } + }; + + function createCheerioWrapper(docId, selector) { + var elementIds; + if (typeof selector === 'string') { + var idsJson = __cheerio_select(docId, selector); + elementIds = JSON.parse(idsJson); + } else { + elementIds = []; + } + return createCheerioWrapperFromIds(docId, elementIds); + } + + function createCheerioWrapperFromIds(docId, ids) { + var wrapper = { + _docId: docId, + _elementIds: ids, + length: ids.length, + each: function(callback) { + for (var i = 0; i < ids.length; i++) { + var elWrapper = createCheerioWrapperFromIds(docId, [ids[i]]); + callback.call(elWrapper, i, elWrapper); + } + return wrapper; + }, + find: function(sel) { + var allIds = []; + for (var i = 0; i < ids.length; i++) { + var childIdsJson = __cheerio_find(docId, ids[i], sel); + var childIds = JSON.parse(childIdsJson); + allIds = allIds.concat(childIds); + } + return createCheerioWrapperFromIds(docId, allIds); + }, + text: function() { + if (ids.length === 0) return ''; + return __cheerio_text(docId, ids.join(',')); + }, + html: function() { + if (ids.length === 0) return ''; + return __cheerio_inner_html(docId, ids[0]); + }, + attr: function(name) { + if (ids.length === 0) return undefined; + var val = __cheerio_attr(docId, ids[0], name); + return val === '__UNDEFINED__' ? undefined : val; + }, + first: function() { return createCheerioWrapperFromIds(docId, ids.length > 0 ? [ids[0]] : []); }, + last: function() { return createCheerioWrapperFromIds(docId, ids.length > 0 ? [ids[ids.length - 1]] : []); }, + next: function() { + var nextIds = []; + for (var i = 0; i < ids.length; i++) { + var nextId = __cheerio_next(docId, ids[i]); + if (nextId && nextId !== '__NONE__') nextIds.push(nextId); + } + return createCheerioWrapperFromIds(docId, nextIds); + }, + prev: function() { + var prevIds = []; + for (var i = 0; i < ids.length; i++) { + var prevId = __cheerio_prev(docId, ids[i]); + if (prevId && prevId !== '__NONE__') prevIds.push(prevId); + } + return createCheerioWrapperFromIds(docId, prevIds); + }, + eq: function(index) { + if (index >= 0 && index < ids.length) return createCheerioWrapperFromIds(docId, [ids[index]]); + return createCheerioWrapperFromIds(docId, []); + }, + get: function(index) { + if (typeof index === 'number') { + if (index >= 0 && index < ids.length) return createCheerioWrapperFromIds(docId, [ids[index]]); + return undefined; + } + return ids.map(function(id) { return createCheerioWrapperFromIds(docId, [id]); }); + }, + map: function(callback) { + var results = []; + for (var i = 0; i < ids.length; i++) { + var elWrapper = createCheerioWrapperFromIds(docId, [ids[i]]); + var result = callback.call(elWrapper, i, elWrapper); + if (result !== undefined && result !== null) results.push(result); + } + return { + length: results.length, + get: function(index) { return typeof index === 'number' ? results[index] : results; }, + toArray: function() { return results; } + }; + }, + filter: function(selectorOrCallback) { + if (typeof selectorOrCallback === 'function') { + var filteredIds = []; + for (var i = 0; i < ids.length; i++) { + var elWrapper = createCheerioWrapperFromIds(docId, [ids[i]]); + var result = selectorOrCallback.call(elWrapper, i, elWrapper); + if (result) filteredIds.push(ids[i]); + } + return createCheerioWrapperFromIds(docId, filteredIds); + } + return wrapper; + }, + children: function(sel) { return this.find(sel || '*'); }, + parent: function() { return createCheerioWrapperFromIds(docId, []); }, + toArray: function() { return ids.map(function(id) { return createCheerioWrapperFromIds(docId, [id]); }); } + }; + return wrapper; + } + """.trimIndent() + + private fun requirePolyfill() = """ + var require = function(moduleName) { + if (moduleName === 'cheerio' || moduleName === 'cheerio-without-node-native' || moduleName === 'react-native-cheerio') { + return cheerio; + } + if (moduleName === 'crypto-js') { + return CryptoJS; + } + throw new Error("Module '" + moduleName + "' is not available"); + }; + """.trimIndent() + + private fun arrayPolyfill() = """ + if (!Array.prototype.flat) { + Array.prototype.flat = function(depth) { + depth = depth === undefined ? 1 : Math.floor(depth); + if (depth < 1) return Array.prototype.slice.call(this); + return (function flatten(arr, d) { + return d > 0 + ? arr.reduce(function(acc, val) { return acc.concat(Array.isArray(val) ? flatten(val, d - 1) : val); }, []) + : arr.slice(); + })(this, depth); + }; + } + + if (!Array.prototype.flatMap) { + Array.prototype.flatMap = function(callback, thisArg) { return this.map(callback, thisArg).flat(); }; + } + """.trimIndent() + + private fun objectPolyfill() = """ + if (!Object.entries) { + Object.entries = function(obj) { + var result = []; + for (var key in obj) { + if (obj.hasOwnProperty(key)) result.push([key, obj[key]]); + } + return result; + }; + } + + if (!Object.fromEntries) { + Object.fromEntries = function(entries) { + var result = {}; + for (var i = 0; i < entries.length; i++) { + result[entries[i][0]] = entries[i][1]; + } + return result; + }; + } + """.trimIndent() + + private fun stringPolyfill() = """ + if (!String.prototype.replaceAll) { + String.prototype.replaceAll = function(search, replace) { + if (search instanceof RegExp) { + if (!search.global) throw new TypeError('replaceAll must be called with a global RegExp'); + return this.replace(search, replace); + } + return this.split(search).join(replace); + }; + } + """.trimIndent() +} diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsRuntime.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsRuntime.kt new file mode 100644 index 00000000..704da56c --- /dev/null +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsRuntime.kt @@ -0,0 +1,16 @@ +package com.nuvio.app.features.plugins.runtime.js + +import com.dokar.quickjs.QuickJs +import com.dokar.quickjs.quickJs +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers + +internal class JsRuntime( + private val dispatcher: CoroutineDispatcher = Dispatchers.Default +) { + suspend fun use(block: suspend QuickJs.() -> T): T { + return quickJs(dispatcher) { + block() + } + } +} diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/network/FetchBridge.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/network/FetchBridge.kt new file mode 100644 index 00000000..5dd6d78b --- /dev/null +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/network/FetchBridge.kt @@ -0,0 +1,103 @@ +package com.nuvio.app.features.plugins.runtime.network + +import co.touchlab.kermit.Logger +import com.dokar.quickjs.QuickJs +import com.dokar.quickjs.binding.function +import com.nuvio.app.features.addons.httpRequestRaw +import com.nuvio.app.features.plugins.runtime.host.HostModule +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonPrimitive + +private const val MAX_FETCH_BODY_CHARS = 256 * 1024 +private const val MAX_FETCH_HEADER_VALUE_CHARS = 8 * 1024 +private const val FETCH_TRUNCATION_SUFFIX = "\n...[truncated]" + +internal class FetchBridge : HostModule { + private val log = Logger.withTag("PluginRuntime") + private val json = Json { ignoreUnknownKeys = true } + + override fun register(runtime: QuickJs) { + runtime.function("__native_fetch") { args -> + val url = args.getOrNull(0)?.toString() ?: "" + val method = args.getOrNull(1)?.toString() ?: "GET" + val headersJson = args.getOrNull(2)?.toString() ?: "{}" + val body = args.getOrNull(3)?.toString() ?: "" + val followRedirects = args.getOrNull(4) as? Boolean ?: true + try { + performNativeFetch(url, method, headersJson, body, followRedirects) + } catch (t: Throwable) { + log.e(t) { "Fetch bridge error for $method $url" } + JsonObject( + mapOf( + "ok" to JsonPrimitive(false), + "status" to JsonPrimitive(0), + "statusText" to JsonPrimitive(t.message ?: "Fetch failed"), + "url" to JsonPrimitive(url), + "body" to JsonPrimitive(""), + "headers" to JsonObject(emptyMap()), + ), + ).toString() + } + } + } + + private fun performNativeFetch( + url: String, + method: String, + headersJson: String, + body: String, + followRedirects: Boolean, + ): String { + val headers = parseHeaders(headersJson).toMutableMap() + if (!headers.containsKey("User-Agent")) { + headers["User-Agent"] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" + } + + val response = runBlocking { + httpRequestRaw( + method = method, + url = url, + headers = headers, + body = body, + followRedirects = followRedirects, + ) + } + + val responseHeaders = response.headers.mapValues { (_, value) -> + truncateString(value, MAX_FETCH_HEADER_VALUE_CHARS) + } + val result = JsonObject( + mapOf( + "ok" to JsonPrimitive(response.status in 200..299), + "status" to JsonPrimitive(response.status), + "statusText" to JsonPrimitive(response.statusText), + "url" to JsonPrimitive(response.url), + "body" to JsonPrimitive(truncateString(response.body, MAX_FETCH_BODY_CHARS)), + "headers" to JsonObject(responseHeaders.mapValues { JsonPrimitive(it.value) }), + ), + ) + return result.toString() + } + + private fun parseHeaders(headersJson: String): Map { + return runCatching { + val obj = json.parseToJsonElement(headersJson) as? JsonObject ?: JsonObject(emptyMap()) + obj.entries + .mapNotNull { (key, value) -> + value.jsonPrimitive.contentOrNull?.let { key to it } + } + .toMap() + }.getOrDefault(emptyMap()) + } + + private fun truncateString(value: String, maxChars: Int): String { + if (value.length <= maxChars) return value + val end = maxChars - FETCH_TRUNCATION_SUFFIX.length + if (end <= 0) return FETCH_TRUNCATION_SUFFIX.take(maxChars) + return value.substring(0, end) + FETCH_TRUNCATION_SUFFIX + } +} diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/wasm/WasmBridge.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/wasm/WasmBridge.kt new file mode 100644 index 00000000..bc1e8d16 --- /dev/null +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/wasm/WasmBridge.kt @@ -0,0 +1,16 @@ +package com.nuvio.app.features.plugins.runtime.wasm + +import com.dokar.quickjs.QuickJs +import com.nuvio.app.features.plugins.runtime.host.HostModule + +/** + * Lightweight WASM Helpers bridge. + * For now, this is a placeholder for running small WASM modules. + * In the future, this could integrate a lightweight WASM interpreter like Chasm or wasm-interp.js. + */ +internal class WasmBridge : HostModule { + override fun register(runtime: QuickJs) { + // Placeholder for WASM instantiation bridge + // runtime.function("__native_wasm_instantiate") { ... } + } +} diff --git a/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.ios.kt b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.ios.kt index f581b27c..dc118695 100644 --- a/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.ios.kt +++ b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.ios.kt @@ -2,6 +2,15 @@ package com.nuvio.app.features.plugins import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.refTo +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.usePinned +import kotlinx.cinterop.reinterpret +import kotlinx.cinterop.UByteVar +import kotlinx.cinterop.ByteVar +import kotlinx.cinterop.alloc +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr +import kotlinx.cinterop.value import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi import com.nuvio.app.features.plugins.cryptointerop.CC_MD5 @@ -17,6 +26,226 @@ import com.nuvio.app.features.plugins.cryptointerop.kCCHmacAlgMD5 import com.nuvio.app.features.plugins.cryptointerop.kCCHmacAlgSHA1 import com.nuvio.app.features.plugins.cryptointerop.kCCHmacAlgSHA256 import com.nuvio.app.features.plugins.cryptointerop.kCCHmacAlgSHA512 +import com.nuvio.app.features.plugins.cryptointerop.CCKeyDerivationPBKDF +import com.nuvio.app.features.plugins.cryptointerop.kCCPBKDF2 +import com.nuvio.app.features.plugins.cryptointerop.kCCPRFHmacAlgSHA1 +import com.nuvio.app.features.plugins.cryptointerop.kCCPRFHmacAlgSHA256 +import com.nuvio.app.features.plugins.cryptointerop.CCCrypt +import com.nuvio.app.features.plugins.cryptointerop.kCCDecrypt +import com.nuvio.app.features.plugins.cryptointerop.kCCAlgorithmAES +import com.nuvio.app.features.plugins.cryptointerop.kCCOptionECBMode +import com.nuvio.app.features.plugins.cryptointerop.kCCEncrypt +import com.nuvio.app.features.plugins.cryptointerop.kCCOptionPKCS7Padding +import com.nuvio.app.features.plugins.cryptointerop.kCCSuccess +import platform.Security.SecRandomCopyBytes +import platform.Security.kSecRandomDefault + +internal fun pluginGetRandomValues(length: Int): ByteArray { + val bytes = ByteArray(length) + @OptIn(ExperimentalForeignApi::class) + SecRandomCopyBytes(kSecRandomDefault, length.toULong(), bytes.refTo(0)) + return bytes +} + +@OptIn(ExperimentalForeignApi::class) +internal fun pluginDigest(algorithm: String, data: ByteArray): ByteArray { + val normalized = algorithm.uppercase() + val output = ByteArray( + when (normalized) { + "MD5" -> CC_MD5_DIGEST_LENGTH.toInt() + "SHA1" -> CC_SHA1_DIGEST_LENGTH.toInt() + "SHA256" -> CC_SHA256_DIGEST_LENGTH.toInt() + "SHA512" -> CC_SHA512_DIGEST_LENGTH.toInt() + else -> error("Unsupported digest algorithm: $algorithm") + }, + ) + + data.usePinned { pinnedData -> + output.usePinned { pinnedOutput -> + val dataPtr = if (data.isNotEmpty()) pinnedData.addressOf(0) else null + val outputPtr = pinnedOutput.addressOf(0).reinterpret() + + when (normalized) { + "MD5" -> CC_MD5(dataPtr, data.size.toUInt(), outputPtr) + "SHA1" -> CC_SHA1(dataPtr, data.size.toUInt(), outputPtr) + "SHA256" -> CC_SHA256(dataPtr, data.size.toUInt(), outputPtr) + "SHA512" -> CC_SHA512(dataPtr, data.size.toUInt(), outputPtr) + } + } + } + + return output +} + +@OptIn(ExperimentalForeignApi::class) +internal fun pluginPbkdf2( + password: ByteArray, + salt: ByteArray, + iterations: Int, + keySizeBits: Int, + algorithm: String, +): ByteArray { + val prf = when (algorithm.uppercase()) { + "SHA256" -> kCCPRFHmacAlgSHA256 + "SHA1" -> kCCPRFHmacAlgSHA1 + else -> kCCPRFHmacAlgSHA256 + } + + val derivedKeyLen = keySizeBits / 8 + val derivedKey = ByteArray(derivedKeyLen) + + password.usePinned { pinnedPassword -> + salt.usePinned { pinnedSalt -> + derivedKey.usePinned { pinnedDerivedKey -> + val passwordPtr = if (password.isNotEmpty()) pinnedPassword.addressOf(0).reinterpret() else null + val saltPtr = if (salt.isNotEmpty()) pinnedSalt.addressOf(0).reinterpret() else null + val derivedKeyPtr = pinnedDerivedKey.addressOf(0).reinterpret() + + val status = CCKeyDerivationPBKDF( + algorithm = kCCPBKDF2, + password = passwordPtr, + passwordLen = password.size.toULong(), + salt = saltPtr, + saltLen = salt.size.toULong(), + prf = prf, + rounds = iterations.toUInt(), + derivedKey = derivedKeyPtr, + derivedKeyLen = derivedKeyLen.toULong() + ) + + require(status == kCCSuccess) { "PBKDF2 failed with status: $status" } + } + } + } + + return derivedKey +} + +@OptIn(ExperimentalForeignApi::class) +internal fun pluginAesEncrypt( + mode: String, + key: ByteArray, + iv: ByteArray, + data: ByteArray, +): ByteArray { + val isGcm = mode.uppercase().contains("GCM") + if (isGcm) { + throw UnsupportedOperationException("AES-GCM Encrypt is not yet implemented on iOS") + } + val isEcb = mode.uppercase().contains("ECB") + + val dataOutAvailable = data.size + 16 // AES block size + val dataOut = ByteArray(dataOutAvailable) + + var finalData: ByteArray? = null + + memScoped { + val dataOutMoved = alloc() + + val options = if (isEcb) { + kCCOptionPKCS7Padding or kCCOptionECBMode + } else { + kCCOptionPKCS7Padding + } + + key.usePinned { pinnedKey -> + iv.usePinned { pinnedIv -> + data.usePinned { pinnedData -> + dataOut.usePinned { pinnedDataOut -> + val status = CCCrypt( + op = kCCEncrypt, + alg = kCCAlgorithmAES, + options = options, + key = if (key.isNotEmpty()) pinnedKey.addressOf(0) else null, + keyLength = key.size.toULong(), + iv = if (!isEcb && iv.isNotEmpty()) pinnedIv.addressOf(0) else null, + dataIn = if (data.isNotEmpty()) pinnedData.addressOf(0) else null, + dataInLength = data.size.toULong(), + dataOut = pinnedDataOut.addressOf(0), + dataOutAvailable = dataOutAvailable.toULong(), + dataOutMoved = dataOutMoved.ptr + ) + + if (status == kCCSuccess) { + finalData = dataOut.copyOf(dataOutMoved.value.toInt()) + } else { + error("CCCrypt Encrypt failed with status: $status") + } + } + } + } + } + } + + return finalData ?: ByteArray(0) +} + +@OptIn(ExperimentalForeignApi::class) +internal fun pluginAesDecrypt( + mode: String, + key: ByteArray, + iv: ByteArray, + data: ByteArray, +): ByteArray { + val isGcm = mode.uppercase().contains("GCM") + if (isGcm) { + throw UnsupportedOperationException("AES-GCM Decrypt is not yet implemented on iOS") + } + val isEcb = mode.uppercase().contains("ECB") + + val dataOutAvailable = data.size + 16 // AES block size + val dataOut = ByteArray(dataOutAvailable) + + var finalData: ByteArray? = null + + memScoped { + val dataOutMoved = alloc() + + val options = if (isEcb) { + kCCOptionPKCS7Padding or kCCOptionECBMode + } else { + kCCOptionPKCS7Padding + } + + key.usePinned { pinnedKey -> + iv.usePinned { pinnedIv -> + data.usePinned { pinnedData -> + dataOut.usePinned { pinnedDataOut -> + val status = CCCrypt( + op = kCCDecrypt, + alg = kCCAlgorithmAES, + options = options, + key = if (key.isNotEmpty()) pinnedKey.addressOf(0) else null, + keyLength = key.size.toULong(), + iv = if (!isEcb && iv.isNotEmpty()) pinnedIv.addressOf(0) else null, + dataIn = if (data.isNotEmpty()) pinnedData.addressOf(0) else null, + dataInLength = data.size.toULong(), + dataOut = pinnedDataOut.addressOf(0), + dataOutAvailable = dataOutAvailable.toULong(), + dataOutMoved = dataOutMoved.ptr + ) + + if (status == kCCSuccess) { + finalData = dataOut.copyOf(dataOutMoved.value.toInt()) + } else { + error("CCCrypt failed with status: $status") + } + } + } + } + } + } + + return finalData ?: ByteArray(0) +} + +internal fun pluginSign(algorithm: String, privateKey: ByteArray, data: ByteArray): ByteArray { + throw UnsupportedOperationException("Asymmetric signing is currently implemented natively only on Android") +} + +internal fun pluginVerify(algorithm: String, publicKey: ByteArray, signature: ByteArray, data: ByteArray): Boolean { + throw UnsupportedOperationException("Asymmetric verification is currently implemented natively only on Android") +} private fun UByteArray.toHex(): String = joinToString(separator = "") { byte -> byte.toString(16).padStart(2, '0') @@ -36,11 +265,18 @@ internal fun pluginDigestHex(algorithm: String, data: String): String { }, ) - when (normalized) { - "MD5" -> CC_MD5(input.refTo(0), input.size.toUInt(), output.refTo(0)) - "SHA1" -> CC_SHA1(input.refTo(0), input.size.toUInt(), output.refTo(0)) - "SHA256" -> CC_SHA256(input.refTo(0), input.size.toUInt(), output.refTo(0)) - "SHA512" -> CC_SHA512(input.refTo(0), input.size.toUInt(), output.refTo(0)) + input.usePinned { pinnedInput -> + output.usePinned { pinnedOutput -> + val dataPtr = if (input.isNotEmpty()) pinnedInput.addressOf(0) else null + val outputPtr = pinnedOutput.addressOf(0) + + when (normalized) { + "MD5" -> CC_MD5(dataPtr, input.size.toUInt(), outputPtr) + "SHA1" -> CC_SHA1(dataPtr, input.size.toUInt(), outputPtr) + "SHA256" -> CC_SHA256(dataPtr, input.size.toUInt(), outputPtr) + "SHA512" -> CC_SHA512(dataPtr, input.size.toUInt(), outputPtr) + } + } } return output.toHex() @@ -61,14 +297,25 @@ internal fun pluginHmacHex(algorithm: String, key: String, data: String): String } val output = UByteArray(outputSize) - CCHmac( - alg, - keyBytes.refTo(0), - keyBytes.size.toULong(), - input.refTo(0), - input.size.toULong(), - output.refTo(0), - ) + + keyBytes.usePinned { pinnedKey -> + input.usePinned { pinnedInput -> + output.usePinned { pinnedOutput -> + val keyPtr = if (keyBytes.isNotEmpty()) pinnedKey.addressOf(0) else null + val inputPtr = if (input.isNotEmpty()) pinnedInput.addressOf(0) else null + val outputPtr = pinnedOutput.addressOf(0) + + CCHmac( + alg, + keyPtr, + keyBytes.size.toULong(), + inputPtr, + input.size.toULong(), + outputPtr, + ) + } + } + } return output.toHex() } diff --git a/composeApp/src/nativeInterop/cinterop/commoncrypto_shim.h b/composeApp/src/nativeInterop/cinterop/commoncrypto_shim.h index cc394555..b2620dc1 100644 --- a/composeApp/src/nativeInterop/cinterop/commoncrypto_shim.h +++ b/composeApp/src/nativeInterop/cinterop/commoncrypto_shim.h @@ -31,3 +31,84 @@ void CCHmac( size_t dataLength, void *macOut ); + +typedef uint32_t CCPBKDFAlgorithm; +enum { + kCCPBKDF2 = 2, +}; + +typedef uint32_t CCPseudoRandomAlgorithm; +enum { + kCCPRFHmacAlgSHA1 = 1, + kCCPRFHmacAlgSHA224 = 2, + kCCPRFHmacAlgSHA256 = 3, + kCCPRFHmacAlgSHA384 = 4, + kCCPRFHmacAlgSHA512 = 5, +}; + +int CCKeyDerivationPBKDF( + CCPBKDFAlgorithm algorithm, + const char *password, + size_t passwordLen, + const uint8_t *salt, + size_t saltLen, + CCPseudoRandomAlgorithm prf, + uint32_t rounds, + uint8_t *derivedKey, + size_t derivedKeyLen +); + +typedef int32_t CCCryptorStatus; +enum { + kCCSuccess = 0, + kCCParamError = -4300, + kCCBufferTooSmall = -4301, + kCCMemoryFailure = -4302, + kCCAlignmentError = -4303, + kCCDecodeError = -4304, + kCCUnimplemented = -4305, + kCCOverflow = -4306, + kCCRNGFailure = -4307, + kCCUnspecifiedError = -4308, + kCCCallSequenceError = -4309, + kCCKeySizeError = -4310, + kCCInvalidKey = -4311, +}; + +typedef uint32_t CCOperation; +enum { + kCCEncrypt = 0, + kCCDecrypt = 1, +}; + +typedef uint32_t CCAlgorithm; +enum { + kCCAlgorithmAES128 = 0, + kCCAlgorithmAES = 0, + kCCAlgorithmDES = 1, + kCCAlgorithm3DES = 2, + kCCAlgorithmCAST = 3, + kCCAlgorithmRC4 = 4, + kCCAlgorithmRC2 = 5, + kCCAlgorithmBlowfish = 6, +}; + +typedef uint32_t CCOptions; +enum { + kCCOptionPKCS7Padding = 1, + kCCOptionECBMode = 2, +}; + +CCCryptorStatus CCCrypt( + CCOperation op, + CCAlgorithm alg, + CCOptions options, + const void *key, + size_t keyLength, + const void *iv, + const void *dataIn, + size_t dataInLength, + void *dataOut, + size_t dataOutAvailable, + size_t *dataOutMoved +); From e76119ee44ef5c6062a05c3db89cd297a345b66b Mon Sep 17 00:00:00 2001 From: paregi12 Date: Sat, 16 May 2026 18:25:49 +0530 Subject: [PATCH 02/24] feat: complete modular plugin runtime transition with url bridge and enhanced polyfills --- .../features/plugins/PluginCrypto.android.kt | 10 +- .../app/features/plugins/PluginRepository.kt | 1 + .../app/features/plugins/PluginRuntime.kt | 991 ------------------ .../features/plugins/runtime/PluginRuntime.kt | 2 + .../features/plugins/runtime/js/JsBindings.kt | 27 + .../plugins/runtime/network/UrlBridge.kt | 53 + .../plugins/runtime/wasm/WasmBridge.kt | 4 +- .../app/features/plugins/PluginCrypto.ios.kt | 10 +- 8 files changed, 99 insertions(+), 999 deletions(-) delete mode 100644 composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRuntime.kt create mode 100644 composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/network/UrlBridge.kt diff --git a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.android.kt b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.android.kt index 475fd8b4..b1d4fedc 100644 --- a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.android.kt +++ b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.android.kt @@ -171,11 +171,11 @@ internal fun pluginUtf8ToHex(value: String): String = byte.toUByte().toString(16).padStart(2, '0') } -internal fun pluginHexToUtf8(hex: String): String { +internal fun pluginHexToByteArray(hex: String): ByteArray { val normalized = hex.trim().lowercase() .replace(" ", "") .removePrefix("0x") - if (normalized.isEmpty()) return "" + if (normalized.isEmpty()) return ByteArray(0) val evenHex = if (normalized.length % 2 == 0) normalized else "0$normalized" val out = ByteArray(evenHex.length / 2) @@ -183,5 +183,9 @@ internal fun pluginHexToUtf8(hex: String): String { val part = evenHex.substring(index * 2, index * 2 + 2) out[index] = part.toInt(16).toByte() } - return out.decodeToString() + return out +} + +internal fun pluginHexToUtf8(hex: String): String { + return pluginHexToByteArray(hex).decodeToString() } 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 32e0562f..ec2741e7 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 @@ -5,6 +5,7 @@ import com.nuvio.app.core.network.SupabaseProvider import com.nuvio.app.features.addons.httpGetText import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.tmdb.TmdbService +import com.nuvio.app.features.plugins.runtime.PluginRuntime import io.github.jan.supabase.postgrest.postgrest import io.github.jan.supabase.postgrest.query.Order import io.github.jan.supabase.postgrest.rpc diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRuntime.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRuntime.kt deleted file mode 100644 index 8d792b5e..00000000 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRuntime.kt +++ /dev/null @@ -1,991 +0,0 @@ -package com.nuvio.app.features.plugins - -import co.touchlab.kermit.Logger -import com.dokar.quickjs.binding.define -import com.dokar.quickjs.binding.function -import com.dokar.quickjs.quickJs -import com.fleeksoft.ksoup.Ksoup -import com.fleeksoft.ksoup.nodes.Document -import com.fleeksoft.ksoup.nodes.Element -import com.fleeksoft.ksoup.select.Elements -import com.nuvio.app.features.addons.httpRequestRaw -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeout -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonNull -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.contentOrNull -import kotlinx.serialization.json.intOrNull -import kotlinx.serialization.json.jsonPrimitive -import kotlin.random.Random - -private const val PLUGIN_TIMEOUT_MS = 60_000L -private const val MAX_FETCH_BODY_CHARS = 256 * 1024 -private const val MAX_FETCH_HEADER_VALUE_CHARS = 8 * 1024 -private const val FETCH_TRUNCATION_SUFFIX = "\n...[truncated]" - -internal object PluginRuntime { - private val log = Logger.withTag("PluginRuntime") - private val json = Json { - ignoreUnknownKeys = true - } - - private val containsRegex = Regex(""":contains\([\"']([^\"']+)[\"']\)""") - - suspend fun executePlugin( - code: String, - tmdbId: String, - mediaType: String, - season: Int?, - episode: Int?, - scraperId: String, - scraperSettings: Map = emptyMap(), - ): List = withContext(Dispatchers.Default) { - withTimeout(PLUGIN_TIMEOUT_MS) { - executePluginInternal( - code = code, - tmdbId = tmdbId, - mediaType = mediaType, - season = season, - episode = episode, - scraperId = scraperId, - scraperSettings = scraperSettings, - ) - } - } - - private suspend fun executePluginInternal( - code: String, - tmdbId: String, - mediaType: String, - season: Int?, - episode: Int?, - scraperId: String, - scraperSettings: Map, - ): List { - val documentCache = mutableMapOf() - val elementCache = mutableMapOf() - var idCounter = 0 - var resultJson = "[]" - - try { - quickJs(Dispatchers.Default) { - define("console") { - function("log") { args -> - log.d { "Plugin:$scraperId ${args.joinToString(" ") { it?.toString() ?: "null" }}" } - null - } - function("error") { args -> - log.e { "Plugin:$scraperId ${args.joinToString(" ") { it?.toString() ?: "null" }}" } - null - } - function("warn") { args -> - log.w { "Plugin:$scraperId ${args.joinToString(" ") { it?.toString() ?: "null" }}" } - null - } - function("info") { args -> - log.i { "Plugin:$scraperId ${args.joinToString(" ") { it?.toString() ?: "null" }}" } - null - } - function("debug") { args -> - log.d { "Plugin:$scraperId ${args.joinToString(" ") { it?.toString() ?: "null" }}" } - null - } - } - - function("__native_fetch") { args -> - val url = args.getOrNull(0)?.toString() ?: "" - val method = args.getOrNull(1)?.toString() ?: "GET" - val headersJson = args.getOrNull(2)?.toString() ?: "{}" - val body = args.getOrNull(3)?.toString() ?: "" - val followRedirects = args.getOrNull(4) as? Boolean ?: true - try { - performNativeFetch(url, method, headersJson, body, followRedirects) - } catch (t: Throwable) { - log.e(t) { "Fetch bridge error for $method $url" } - JsonObject( - mapOf( - "ok" to JsonPrimitive(false), - "status" to JsonPrimitive(0), - "statusText" to JsonPrimitive(t.message ?: "Fetch failed"), - "url" to JsonPrimitive(url), - "body" to JsonPrimitive(""), - "headers" to JsonObject(emptyMap()), - ), - ).toString() - } - } - - function("__crypto_digest_hex") { args -> - val algorithm = args.getOrNull(0)?.toString() ?: "SHA256" - val data = args.getOrNull(1)?.toString() ?: "" - runCatching { - pluginDigestHex(algorithm, data) - }.getOrDefault("") - } - - function("__crypto_hmac_hex") { args -> - val algorithm = args.getOrNull(0)?.toString() ?: "SHA256" - val key = args.getOrNull(1)?.toString() ?: "" - val data = args.getOrNull(2)?.toString() ?: "" - runCatching { - pluginHmacHex(algorithm, key, data) - }.getOrDefault("") - } - - function("__crypto_base64_encode") { args -> - val data = args.getOrNull(0)?.toString() ?: "" - runCatching { - pluginBase64Encode(data) - }.getOrDefault("") - } - - function("__crypto_base64_decode") { args -> - val data = args.getOrNull(0)?.toString() ?: "" - runCatching { - pluginBase64Decode(data) - }.getOrDefault("") - } - - function("__crypto_utf8_to_hex") { args -> - val data = args.getOrNull(0)?.toString() ?: "" - runCatching { - pluginUtf8ToHex(data) - }.getOrDefault("") - } - - function("__crypto_hex_to_utf8") { args -> - val data = args.getOrNull(0)?.toString() ?: "" - runCatching { - pluginHexToUtf8(data) - }.getOrDefault("") - } - - function("__parse_url") { args -> - parseUrl(args.getOrNull(0)?.toString() ?: "") - } - - function("__cheerio_load") { args -> - val html = args.getOrNull(0)?.toString() ?: "" - val docId = "doc_${idCounter++}_${Random.nextInt(0, Int.MAX_VALUE)}" - documentCache[docId] = Ksoup.parse(html) - docId - } - - function("__cheerio_select") { args -> - val docId = args.getOrNull(0)?.toString() ?: "" - var selector = args.getOrNull(1)?.toString() ?: "" - val doc = documentCache[docId] ?: return@function "[]" - try { - selector = selector.replace(containsRegex, ":contains($1)") - val elements = if (selector.isEmpty()) Elements() else doc.select(selector) - val ids = elements.mapIndexed { index, el -> - val id = "$docId:$index:${el.hashCode()}" - elementCache[id] = el - id - } - "[" + ids.joinToString(",") { "\"${it.replace("\"", "\\\"")}\"" } + "]" - } catch (_: Exception) { - "[]" - } - } - - function("__cheerio_find") { args -> - val docId = args.getOrNull(0)?.toString() ?: "" - val elementId = args.getOrNull(1)?.toString() ?: "" - var selector = args.getOrNull(2)?.toString() ?: "" - val element = elementCache[elementId] ?: return@function "[]" - try { - selector = selector.replace(containsRegex, ":contains($1)") - val elements = element.select(selector) - val ids = elements.mapIndexed { index, el -> - val id = "$docId:find:$index:${el.hashCode()}" - elementCache[id] = el - id - } - "[" + ids.joinToString(",") { "\"${it.replace("\"", "\\\"")}\"" } + "]" - } catch (_: Exception) { - "[]" - } - } - - function("__cheerio_text") { args -> - val elementIds = args.getOrNull(1)?.toString() ?: "" - elementIds.split(",") - .filter { it.isNotEmpty() } - .mapNotNull { elementCache[it]?.text() } - .joinToString(" ") - } - - function("__cheerio_html") { args -> - val docId = args.getOrNull(0)?.toString() ?: "" - val elementId = args.getOrNull(1)?.toString() ?: "" - if (elementId.isEmpty()) { - documentCache[docId]?.html() ?: "" - } else { - elementCache[elementId]?.html() ?: "" - } - } - - function("__cheerio_inner_html") { args -> - val elementId = args.getOrNull(1)?.toString() ?: "" - elementCache[elementId]?.html() ?: "" - } - - function("__cheerio_attr") { args -> - val elementId = args.getOrNull(1)?.toString() ?: "" - val attrName = args.getOrNull(2)?.toString() ?: "" - val value = elementCache[elementId]?.attr(attrName) - if (value.isNullOrEmpty()) "__UNDEFINED__" else value - } - - function("__cheerio_next") { args -> - val docId = args.getOrNull(0)?.toString() ?: "" - val elementId = args.getOrNull(1)?.toString() ?: "" - val element = elementCache[elementId] ?: return@function "__NONE__" - val next = element.nextElementSibling() ?: return@function "__NONE__" - val nextId = "$docId:next:${next.hashCode()}" - elementCache[nextId] = next - nextId - } - - function("__cheerio_prev") { args -> - val docId = args.getOrNull(0)?.toString() ?: "" - val elementId = args.getOrNull(1)?.toString() ?: "" - val element = elementCache[elementId] ?: return@function "__NONE__" - val prev = element.previousElementSibling() ?: return@function "__NONE__" - val prevId = "$docId:prev:${prev.hashCode()}" - elementCache[prevId] = prev - prevId - } - - function("__capture_result") { args -> - resultJson = args.getOrNull(0)?.toString() ?: "[]" - null - } - - val settingsJson = toJsonElement(scraperSettings).toString() - val polyfillCode = buildPolyfillCode(scraperId, settingsJson) - evaluate(polyfillCode) - - val wrappedCode = """ - var module = { exports: {} }; - var exports = module.exports; - (function() { - $code - })(); - """.trimIndent() - evaluate(wrappedCode) - - val seasonArg = season?.toString() ?: "undefined" - val episodeArg = episode?.toString() ?: "undefined" - val callCode = """ - (async function() { - try { - var getStreams = module.exports.getStreams || globalThis.getStreams; - if (!getStreams) { - console.error("getStreams function not found on module.exports or globalThis"); - __capture_result(JSON.stringify([])); - return; - } - var result = await getStreams("$tmdbId", "$mediaType", $seasonArg, $episodeArg); - __capture_result(JSON.stringify(result || [])); - } catch (e) { - console.error("getStreams error:", e && e.message ? e.message : e, e && e.stack ? e.stack : ""); - __capture_result(JSON.stringify([])); - } - })(); - """.trimIndent() - evaluate(callCode) - } - - return parseJsonResults(resultJson) - } finally { - documentCache.clear() - elementCache.clear() - } - } - - private fun performNativeFetch( - url: String, - method: String, - headersJson: String, - body: String, - followRedirects: Boolean, - ): String { - return try { - val headers = parseHeaders(headersJson).toMutableMap() - if (!headers.containsKey("User-Agent")) { - headers["User-Agent"] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" - } - - val response = runBlocking { - httpRequestRaw( - method = method, - url = url, - headers = headers, - body = body, - followRedirects = followRedirects, - ) - } - - val responseHeaders = response.headers.mapValues { (_, value) -> - truncateString(value, MAX_FETCH_HEADER_VALUE_CHARS) - } - val result = JsonObject( - mapOf( - "ok" to JsonPrimitive(response.status in 200..299), - "status" to JsonPrimitive(response.status), - "statusText" to JsonPrimitive(response.statusText), - "url" to JsonPrimitive(response.url), - "body" to JsonPrimitive(truncateString(response.body, MAX_FETCH_BODY_CHARS)), - "headers" to JsonObject(responseHeaders.mapValues { JsonPrimitive(it.value) }), - ), - ) - result.toString() - } catch (error: Throwable) { - log.e(error) { "Fetch error for $method $url" } - JsonObject( - mapOf( - "ok" to JsonPrimitive(false), - "status" to JsonPrimitive(0), - "statusText" to JsonPrimitive(error.message ?: "Fetch failed"), - "url" to JsonPrimitive(url), - "body" to JsonPrimitive(""), - "headers" to JsonObject(emptyMap()), - ), - ) - .toString() - } - } - - private fun parseHeaders(headersJson: String): Map { - return runCatching { - val obj = json.parseToJsonElement(headersJson) as? JsonObject ?: JsonObject(emptyMap()) - obj.entries - .mapNotNull { (key, value) -> - value.jsonPrimitive.contentOrNull?.let { key to it } - } - .toMap() - }.getOrDefault(emptyMap()) - } - - private fun parseUrl(urlString: String): String { - return try { - val parsed = io.ktor.http.Url(urlString) - JsonObject( - mapOf( - "protocol" to JsonPrimitive("${parsed.protocol.name}:"), - "host" to JsonPrimitive( - if (parsed.port != parsed.protocol.defaultPort) { - "${parsed.host}:${parsed.port}" - } else { - parsed.host - }, - ), - "hostname" to JsonPrimitive(parsed.host), - "port" to JsonPrimitive( - if (parsed.port != parsed.protocol.defaultPort) parsed.port.toString() else "", - ), - "pathname" to JsonPrimitive(parsed.encodedPath.ifBlank { "/" }), - "search" to JsonPrimitive(parsed.encodedQuery?.let { "?$it" } ?: ""), - "hash" to JsonPrimitive(parsed.encodedFragment?.let { "#$it" } ?: ""), - ), - ).toString() - } catch (_: Exception) { - JsonObject( - mapOf( - "protocol" to JsonPrimitive(""), - "host" to JsonPrimitive(""), - "hostname" to JsonPrimitive(""), - "port" to JsonPrimitive(""), - "pathname" to JsonPrimitive("/"), - "search" to JsonPrimitive(""), - "hash" to JsonPrimitive(""), - ), - ).toString() - } - } - - private fun truncateString(value: String, maxChars: Int): String { - if (value.length <= maxChars) return value - val end = maxChars - FETCH_TRUNCATION_SUFFIX.length - if (end <= 0) return FETCH_TRUNCATION_SUFFIX.take(maxChars) - return value.substring(0, end) + FETCH_TRUNCATION_SUFFIX - } - - private fun parseJsonResults(rawJson: String): List { - return runCatching { - val array = json.parseToJsonElement(rawJson) as? JsonArray ?: return emptyList() - array.mapNotNull { element -> - val item = element as? JsonObject ?: return@mapNotNull null - val url = when (val urlValue = item["url"]) { - is JsonPrimitive -> urlValue.contentOrNull?.takeIf { it.isNotBlank() } - is JsonObject -> urlValue["url"]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() } - else -> null - } ?: return@mapNotNull null - - val headers = (item["headers"] as? JsonObject) - ?.mapNotNull { (key, value) -> - value.jsonPrimitive.contentOrNull?.let { key to it } - } - ?.toMap() - ?.takeIf { it.isNotEmpty() } - - PluginRuntimeResult( - title = item.stringOrNull("title") ?: item.stringOrNull("name") ?: "Unknown", - name = item.stringOrNull("name"), - url = url, - quality = item.stringOrNull("quality"), - size = item.stringOrNull("size"), - language = item.stringOrNull("language"), - provider = item.stringOrNull("provider"), - type = item.stringOrNull("type"), - seeders = item["seeders"]?.jsonPrimitive?.intOrNull, - peers = item["peers"]?.jsonPrimitive?.intOrNull, - infoHash = item.stringOrNull("infoHash"), - headers = headers, - ) - }.filter { it.url.isNotBlank() } - }.getOrElse { error -> - log.e(error) { "Failed to parse plugin result json" } - emptyList() - } - } - - private fun JsonObject.stringOrNull(key: String): String? = - this[key]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() && !it.contains("[object") } - - private fun toJsonElement(value: Any?): JsonElement = when (value) { - null -> JsonNull - is JsonElement -> value - is String -> JsonPrimitive(value) - is Boolean -> JsonPrimitive(value) - is Int -> JsonPrimitive(value) - is Long -> JsonPrimitive(value) - is Float -> JsonPrimitive(value) - is Double -> JsonPrimitive(value) - is Number -> JsonPrimitive(value.toDouble()) - is Map<*, *> -> JsonObject( - value.entries - .filter { it.key is String } - .associate { (it.key as String) to toJsonElement(it.value) }, - ) - is Iterable<*> -> JsonArray(value.map(::toJsonElement)) - else -> JsonPrimitive(value.toString()) - } - - private fun buildPolyfillCode(scraperId: String, settingsJson: String): String { - return """ - globalThis.SCRAPER_ID = "$scraperId"; - globalThis.SCRAPER_SETTINGS = $settingsJson; - if (typeof globalThis.global === 'undefined') globalThis.global = globalThis; - if (typeof globalThis.window === 'undefined') globalThis.window = globalThis; - if (typeof globalThis.self === 'undefined') globalThis.self = globalThis; - - var fetch = async function(url, options) { - options = options || {}; - var method = (options.method || 'GET').toUpperCase(); - var headers = options.headers || {}; - var body = options.body || ''; - var followRedirects = options.redirect !== 'manual'; - var result = __native_fetch(url, method, JSON.stringify(headers), body, followRedirects); - var parsed = JSON.parse(result); - return { - ok: parsed.ok, - status: parsed.status, - statusText: parsed.statusText, - url: parsed.url, - headers: { - get: function(name) { - return parsed.headers[name.toLowerCase()] || null; - } - }, - text: function() { return Promise.resolve(parsed.body); }, - json: function() { - try { - if (parsed.body === null || parsed.body === undefined || parsed.body === '') { - return Promise.resolve(null); - } - return Promise.resolve(JSON.parse(parsed.body)); - } catch (e) { - return Promise.resolve(null); - } - } - }; - }; - - if (typeof AbortSignal === 'undefined') { - var AbortSignal = function() { this.aborted = false; this.reason = undefined; this._listeners = []; }; - AbortSignal.prototype.addEventListener = function(type, listener) { - if (type !== 'abort' || typeof listener !== 'function') return; - this._listeners.push(listener); - }; - AbortSignal.prototype.removeEventListener = function(type, listener) { - if (type !== 'abort') return; - this._listeners = this._listeners.filter(function(l) { return l !== listener; }); - }; - AbortSignal.prototype.dispatchEvent = function(event) { - if (!event || event.type !== 'abort') return true; - for (var i = 0; i < this._listeners.length; i++) { - try { this._listeners[i].call(this, event); } catch (e) {} - } - return true; - }; - globalThis.AbortSignal = AbortSignal; - } - - if (typeof AbortController === 'undefined') { - var AbortController = function() { this.signal = new AbortSignal(); }; - AbortController.prototype.abort = function(reason) { - if (this.signal.aborted) return; - this.signal.aborted = true; - this.signal.reason = reason; - this.signal.dispatchEvent({ type: 'abort' }); - }; - globalThis.AbortController = AbortController; - } - - if (typeof atob === 'undefined') { - globalThis.atob = function(input) { - var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='; - var str = String(input).replace(/=+$/, ''); - if (str.length % 4 === 1) throw new Error('InvalidCharacterError'); - var output = ''; - var bc = 0, bs, buffer, idx = 0; - while ((buffer = str.charAt(idx++))) { - buffer = chars.indexOf(buffer); - if (buffer === -1) continue; - bs = bc % 4 ? bs * 64 + buffer : buffer; - if (bc++ % 4) output += String.fromCharCode(255 & (bs >> ((-2 * bc) & 6))); - } - return output; - }; - } - - if (typeof btoa === 'undefined') { - globalThis.btoa = function(input) { - var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='; - var str = String(input); - var output = ''; - for (var block, charCode, idx = 0, map = chars; - str.charAt(idx | 0) || (map = '=', idx % 1); - output += map.charAt(63 & (block >> (8 - (idx % 1) * 8)))) { - charCode = str.charCodeAt(idx += 3 / 4); - if (charCode > 0xFF) throw new Error('InvalidCharacterError'); - block = (block << 8) | charCode; - } - return output; - }; - } - - var URL = function(urlString, base) { - var fullUrl = urlString; - if (base && !/^https?:\/\//i.test(urlString)) { - var b = typeof base === 'string' ? base : base.href; - if (urlString.charAt(0) === '/') { - var m = b.match(/^(https?:\/\/[^\/]+)/); - fullUrl = m ? m[1] + urlString : urlString; - } else { - fullUrl = b.replace(/\/[^\/]*$/, '/') + urlString; - } - } - var parsed = __parse_url(fullUrl); - var data = JSON.parse(parsed); - this.href = fullUrl; - this.protocol = data.protocol; - this.host = data.host; - this.hostname = data.hostname; - this.port = data.port; - this.pathname = data.pathname; - this.search = data.search; - this.hash = data.hash; - this.origin = data.protocol + '//' + data.host; - this.searchParams = new URLSearchParams(data.search || ''); - }; - URL.prototype.toString = function() { return this.href; }; - - var URLSearchParams = function(init) { - this._params = {}; - var self = this; - if (init && typeof init === 'object' && !Array.isArray(init)) { - Object.keys(init).forEach(function(key) { self._params[key] = String(init[key]); }); - } else if (typeof init === 'string') { - init.replace(/^\?/, '').split('&').forEach(function(pair) { - var parts = pair.split('='); - if (parts[0]) self._params[decodeURIComponent(parts[0])] = decodeURIComponent(parts[1] || ''); - }); - } - }; - URLSearchParams.prototype.toString = function() { - var self = this; - return Object.keys(this._params).map(function(key) { - return encodeURIComponent(key) + '=' + encodeURIComponent(self._params[key]); - }).join('&'); - }; - URLSearchParams.prototype.get = function(key) { return this._params.hasOwnProperty(key) ? this._params[key] : null; }; - URLSearchParams.prototype.set = function(key, value) { this._params[key] = String(value); }; - URLSearchParams.prototype.append = function(key, value) { this._params[key] = String(value); }; - URLSearchParams.prototype.has = function(key) { return this._params.hasOwnProperty(key); }; - URLSearchParams.prototype.delete = function(key) { delete this._params[key]; }; - URLSearchParams.prototype.keys = function() { return Object.keys(this._params); }; - URLSearchParams.prototype.values = function() { - var self = this; - return Object.keys(this._params).map(function(k) { return self._params[k]; }); - }; - URLSearchParams.prototype.entries = function() { - var self = this; - return Object.keys(this._params).map(function(k) { return [k, self._params[k]]; }); - }; - URLSearchParams.prototype.forEach = function(callback) { - var self = this; - Object.keys(this._params).forEach(function(key) { callback(self._params[key], key, self); }); - }; - URLSearchParams.prototype.getAll = function(key) { - return this._params.hasOwnProperty(key) ? [this._params[key]] : []; - }; - URLSearchParams.prototype.sort = function() { - var sorted = {}; - var self = this; - Object.keys(this._params).sort().forEach(function(k) { sorted[k] = self._params[k]; }); - this._params = sorted; - }; - - function __hexToWords(hex) { - var words = []; - for (var i = 0; i < hex.length; i += 8) { - var chunk = hex.substring(i, i + 8); - while (chunk.length < 8) chunk += '0'; - words.push(parseInt(chunk, 16) | 0); - } - return words; - } - - function __wordsToHex(words, sigBytes) { - var hex = ''; - for (var i = 0; i < sigBytes; i++) { - var word = words[i >>> 2] || 0; - var byte = (word >>> (24 - (i % 4) * 8)) & 0xff; - var part = byte.toString(16); - if (part.length < 2) part = '0' + part; - hex += part; - } - return hex; - } - - function __wordArrayToHex(value) { - if (!value) return ''; - if (typeof value.__hex === 'string') return value.__hex.toLowerCase(); - if (Array.isArray(value.words) && typeof value.sigBytes === 'number') { - return __wordsToHex(value.words, value.sigBytes); - } - return __crypto_utf8_to_hex(String(value)); - } - - function __buildWordArray(hex, utf8Override) { - var normalizedHex = (hex || '').toLowerCase(); - if (normalizedHex.length % 2 !== 0) normalizedHex = '0' + normalizedHex; - var wordArray = { - __hex: normalizedHex, - __utf8: utf8Override !== undefined ? utf8Override : __crypto_hex_to_utf8(normalizedHex), - sigBytes: normalizedHex.length / 2, - words: __hexToWords(normalizedHex), - toString: function(encoder) { - if (!encoder || encoder === CryptoJS.enc.Hex) return this.__hex; - if (encoder === CryptoJS.enc.Utf8) return this.__utf8; - if (encoder === CryptoJS.enc.Base64) return __crypto_base64_encode(this.__utf8); - return this.__hex; - }, - clamp: function() { - return this; - }, - concat: function(other) { - var otherHex = __wordArrayToHex(other); - this.__hex += otherHex; - this.__utf8 = __crypto_hex_to_utf8(this.__hex); - this.sigBytes = this.__hex.length / 2; - this.words = __hexToWords(this.__hex); - return this; - } - }; - return wordArray; - } - - function __wordArrayFromHex(hex) { - return __buildWordArray(hex, undefined); - } - - function __wordArrayFromUtf8(text) { - var utf8 = text == null ? '' : String(text); - return __buildWordArray(__crypto_utf8_to_hex(utf8), utf8); - } - - function __wordArrayFromBase64(base64) { - return __wordArrayFromUtf8(__crypto_base64_decode(base64 || '')); - } - - function __normalizeWordArrayInput(value) { - if (value && typeof value === 'object' && typeof value.__utf8 === 'string') { - return value.__utf8; - } - if (value && typeof value === 'object' && typeof value.__hex === 'string') { - return __crypto_hex_to_utf8(value.__hex); - } - if (value && typeof value === 'object' && Array.isArray(value.words) && typeof value.sigBytes === 'number') { - return __crypto_hex_to_utf8(__wordsToHex(value.words, value.sigBytes)); - } - if (value == null) return ''; - return String(value); - } - - function __cryptoHashWordArray(algorithm, message) { - var utf8 = __normalizeWordArrayInput(message); - var hex = __crypto_digest_hex(algorithm, utf8); - return __wordArrayFromHex(hex); - } - - function __cryptoHmacWordArray(algorithm, message, key) { - var utf8Message = __normalizeWordArrayInput(message); - var utf8Key = __normalizeWordArrayInput(key); - var hex = __crypto_hmac_hex(algorithm, utf8Key, utf8Message); - return __wordArrayFromHex(hex); - } - - var CryptoJS = { - enc: { - Hex: { - stringify: function(wordArray) { - return __wordArrayToHex(wordArray); - }, - parse: function(hexStr) { - return __wordArrayFromHex(hexStr || ''); - } - }, - Utf8: { - stringify: function(wordArray) { - if (wordArray && typeof wordArray.__utf8 === 'string') return wordArray.__utf8; - if (wordArray && typeof wordArray.__hex === 'string') return __crypto_hex_to_utf8(wordArray.__hex); - return __normalizeWordArrayInput(wordArray); - }, - parse: function(text) { - return __wordArrayFromUtf8(text); - } - }, - Base64: { - stringify: function(wordArray) { - if (wordArray && typeof wordArray.__utf8 === 'string') { - return __crypto_base64_encode(wordArray.__utf8); - } - return __crypto_base64_encode(__normalizeWordArrayInput(wordArray)); - }, - parse: function(base64) { - return __wordArrayFromBase64(base64); - } - } - }, - MD5: function(message) { return __cryptoHashWordArray('MD5', message); }, - SHA1: function(message) { return __cryptoHashWordArray('SHA1', message); }, - SHA256: function(message) { return __cryptoHashWordArray('SHA256', message); }, - SHA512: function(message) { return __cryptoHashWordArray('SHA512', message); }, - HmacMD5: function(message, key) { return __cryptoHmacWordArray('MD5', message, key); }, - HmacSHA1: function(message, key) { return __cryptoHmacWordArray('SHA1', message, key); }, - HmacSHA256: function(message, key) { return __cryptoHmacWordArray('SHA256', message, key); }, - HmacSHA512: function(message, key) { return __cryptoHmacWordArray('SHA512', message, key); } - }; - globalThis.CryptoJS = CryptoJS; - - var cheerio = { - load: function(html) { - var docId = __cheerio_load(html); - var $ = function(selector, context) { - if (selector && selector._elementIds) return selector; - if (context && context._elementIds && context._elementIds.length > 0) { - var allIds = []; - for (var i = 0; i < context._elementIds.length; i++) { - var childIdsJson = __cheerio_find(docId, context._elementIds[i], selector); - var childIds = JSON.parse(childIdsJson); - allIds = allIds.concat(childIds); - } - return createCheerioWrapperFromIds(docId, allIds); - } - return createCheerioWrapper(docId, selector); - }; - $.html = function(el) { - if (el && el._elementIds && el._elementIds.length > 0) { - return __cheerio_html(docId, el._elementIds[0]); - } - return __cheerio_html(docId, ''); - }; - return $; - } - }; - - function createCheerioWrapper(docId, selector) { - var elementIds; - if (typeof selector === 'string') { - var idsJson = __cheerio_select(docId, selector); - elementIds = JSON.parse(idsJson); - } else { - elementIds = []; - } - return createCheerioWrapperFromIds(docId, elementIds); - } - - function createCheerioWrapperFromIds(docId, ids) { - var wrapper = { - _docId: docId, - _elementIds: ids, - length: ids.length, - each: function(callback) { - for (var i = 0; i < ids.length; i++) { - var elWrapper = createCheerioWrapperFromIds(docId, [ids[i]]); - callback.call(elWrapper, i, elWrapper); - } - return wrapper; - }, - find: function(sel) { - var allIds = []; - for (var i = 0; i < ids.length; i++) { - var childIdsJson = __cheerio_find(docId, ids[i], sel); - var childIds = JSON.parse(childIdsJson); - allIds = allIds.concat(childIds); - } - return createCheerioWrapperFromIds(docId, allIds); - }, - text: function() { - if (ids.length === 0) return ''; - return __cheerio_text(docId, ids.join(',')); - }, - html: function() { - if (ids.length === 0) return ''; - return __cheerio_inner_html(docId, ids[0]); - }, - attr: function(name) { - if (ids.length === 0) return undefined; - var val = __cheerio_attr(docId, ids[0], name); - return val === '__UNDEFINED__' ? undefined : val; - }, - first: function() { return createCheerioWrapperFromIds(docId, ids.length > 0 ? [ids[0]] : []); }, - last: function() { return createCheerioWrapperFromIds(docId, ids.length > 0 ? [ids[ids.length - 1]] : []); }, - next: function() { - var nextIds = []; - for (var i = 0; i < ids.length; i++) { - var nextId = __cheerio_next(docId, ids[i]); - if (nextId && nextId !== '__NONE__') nextIds.push(nextId); - } - return createCheerioWrapperFromIds(docId, nextIds); - }, - prev: function() { - var prevIds = []; - for (var i = 0; i < ids.length; i++) { - var prevId = __cheerio_prev(docId, ids[i]); - if (prevId && prevId !== '__NONE__') prevIds.push(prevId); - } - return createCheerioWrapperFromIds(docId, prevIds); - }, - eq: function(index) { - if (index >= 0 && index < ids.length) return createCheerioWrapperFromIds(docId, [ids[index]]); - return createCheerioWrapperFromIds(docId, []); - }, - get: function(index) { - if (typeof index === 'number') { - if (index >= 0 && index < ids.length) return createCheerioWrapperFromIds(docId, [ids[index]]); - return undefined; - } - return ids.map(function(id) { return createCheerioWrapperFromIds(docId, [id]); }); - }, - map: function(callback) { - var results = []; - for (var i = 0; i < ids.length; i++) { - var elWrapper = createCheerioWrapperFromIds(docId, [ids[i]]); - var result = callback.call(elWrapper, i, elWrapper); - if (result !== undefined && result !== null) results.push(result); - } - return { - length: results.length, - get: function(index) { return typeof index === 'number' ? results[index] : results; }, - toArray: function() { return results; } - }; - }, - filter: function(selectorOrCallback) { - if (typeof selectorOrCallback === 'function') { - var filteredIds = []; - for (var i = 0; i < ids.length; i++) { - var elWrapper = createCheerioWrapperFromIds(docId, [ids[i]]); - var result = selectorOrCallback.call(elWrapper, i, elWrapper); - if (result) filteredIds.push(ids[i]); - } - return createCheerioWrapperFromIds(docId, filteredIds); - } - return wrapper; - }, - children: function(sel) { return this.find(sel || '*'); }, - parent: function() { return createCheerioWrapperFromIds(docId, []); }, - toArray: function() { return ids.map(function(id) { return createCheerioWrapperFromIds(docId, [id]); }); } - }; - return wrapper; - } - - var require = function(moduleName) { - if (moduleName === 'cheerio' || moduleName === 'cheerio-without-node-native' || moduleName === 'react-native-cheerio') { - return cheerio; - } - if (moduleName === 'crypto-js') { - return CryptoJS; - } - throw new Error("Module '" + moduleName + "' is not available"); - }; - - if (!Array.prototype.flat) { - Array.prototype.flat = function(depth) { - depth = depth === undefined ? 1 : Math.floor(depth); - if (depth < 1) return Array.prototype.slice.call(this); - return (function flatten(arr, d) { - return d > 0 - ? arr.reduce(function(acc, val) { return acc.concat(Array.isArray(val) ? flatten(val, d - 1) : val); }, []) - : arr.slice(); - })(this, depth); - }; - } - - if (!Array.prototype.flatMap) { - Array.prototype.flatMap = function(callback, thisArg) { return this.map(callback, thisArg).flat(); }; - } - - if (!Object.entries) { - Object.entries = function(obj) { - var result = []; - for (var key in obj) { - if (obj.hasOwnProperty(key)) result.push([key, obj[key]]); - } - return result; - }; - } - - if (!Object.fromEntries) { - Object.fromEntries = function(entries) { - var result = {}; - for (var i = 0; i < entries.length; i++) { - result[entries[i][0]] = entries[i][1]; - } - return result; - }; - } - - if (!String.prototype.replaceAll) { - String.prototype.replaceAll = function(search, replace) { - if (search instanceof RegExp) { - if (!search.global) throw new TypeError('replaceAll must be called with a global RegExp'); - return this.replace(search, replace); - } - return this.split(search).join(replace); - }; - } - """.trimIndent() - } -} diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt index 7349e11d..040a1c8d 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt @@ -8,6 +8,7 @@ import com.nuvio.app.features.plugins.runtime.host.HostFunctions import com.nuvio.app.features.plugins.runtime.js.JsBindings import com.nuvio.app.features.plugins.runtime.js.JsRuntime import com.nuvio.app.features.plugins.runtime.network.FetchBridge +import com.nuvio.app.features.plugins.runtime.network.UrlBridge import com.nuvio.app.features.plugins.runtime.wasm.WasmBridge import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -65,6 +66,7 @@ internal object PluginRuntime { val hostRegistry = HostApiRegistry().apply { addModule(HostFunctions(scraperId) { resultJson = it }) addModule(FetchBridge()) + addModule(UrlBridge()) addModule(CryptoBridge()) addModule(WasmBridge()) addModule(domBridge) diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt index 0969bc70..81b45bd6 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt @@ -14,6 +14,7 @@ internal object JsBindings { ${base64Polyfill()} ${urlPolyfill()} ${cryptoPolyfill()} + ${textEncoderPolyfill()} ${cheerioPolyfill()} ${requirePolyfill()} ${arrayPolyfill()} @@ -395,6 +396,32 @@ internal object JsBindings { }; """.trimIndent() + private fun textEncoderPolyfill() = """ + if (typeof TextEncoder === 'undefined') { + globalThis.TextEncoder = function() {}; + TextEncoder.prototype.encode = function(str) { + var hex = __crypto_utf8_to_hex(str); + var bytes = new Uint8Array(hex.length / 2); + for (var i = 0; i < hex.length; i += 2) { + bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16); + } + return bytes; + }; + } + if (typeof TextDecoder === 'undefined') { + globalThis.TextDecoder = function() {}; + TextDecoder.prototype.decode = function(data) { + var bytes = data; + if (data instanceof ArrayBuffer) bytes = new Uint8Array(data); + var hex = ''; + for (var i = 0; i < bytes.length; i++) { + hex += bytes[i].toString(16).padStart(2, '0'); + } + return __crypto_hex_to_utf8(hex); + }; + } + """.trimIndent() + private fun cheerioPolyfill() = """ var cheerio = { load: function(html) { diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/network/UrlBridge.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/network/UrlBridge.kt new file mode 100644 index 00000000..092cf1e2 --- /dev/null +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/network/UrlBridge.kt @@ -0,0 +1,53 @@ +package com.nuvio.app.features.plugins.runtime.network + +import com.dokar.quickjs.QuickJs +import com.dokar.quickjs.binding.function +import com.nuvio.app.features.plugins.runtime.host.HostModule +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +internal class UrlBridge : HostModule { + override fun register(runtime: QuickJs) { + runtime.function("__parse_url") { args -> + val urlString = args.getOrNull(0)?.toString() ?: "" + parseUrl(urlString) + } + } + + private fun parseUrl(urlString: String): String { + return try { + val parsed = io.ktor.http.Url(urlString) + JsonObject( + mapOf( + "protocol" to JsonPrimitive("${parsed.protocol.name}:"), + "host" to JsonPrimitive( + if (parsed.port != parsed.protocol.defaultPort) { + "${parsed.host}:${parsed.port}" + } else { + parsed.host + }, + ), + "hostname" to JsonPrimitive(parsed.host), + "port" to JsonPrimitive( + if (parsed.port != parsed.protocol.defaultPort) parsed.port.toString() else "", + ), + "pathname" to JsonPrimitive(parsed.encodedPath.ifBlank { "/" }), + "search" to JsonPrimitive(parsed.encodedQuery?.let { "?$it" } ?: ""), + "hash" to JsonPrimitive(parsed.encodedFragment?.let { "#$it" } ?: ""), + ), + ).toString() + } catch (_: Exception) { + JsonObject( + mapOf( + "protocol" to JsonPrimitive(""), + "host" to JsonPrimitive(""), + "hostname" to JsonPrimitive(""), + "port" to JsonPrimitive(""), + "pathname" to JsonPrimitive("/"), + "search" to JsonPrimitive(""), + "hash" to JsonPrimitive(""), + ), + ).toString() + } + } +} diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/wasm/WasmBridge.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/wasm/WasmBridge.kt index bc1e8d16..6ef7a189 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/wasm/WasmBridge.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/wasm/WasmBridge.kt @@ -5,8 +5,8 @@ import com.nuvio.app.features.plugins.runtime.host.HostModule /** * Lightweight WASM Helpers bridge. - * For now, this is a placeholder for running small WASM modules. - * In the future, this could integrate a lightweight WASM interpreter like Chasm or wasm-interp.js. + * TODO: In the future, this will integrate a lightweight WASM interpreter like Chasm or wasm-interp.js + * to support advanced extraction logic (e.g. FlixCloud). */ internal class WasmBridge : HostModule { override fun register(runtime: QuickJs) { diff --git a/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.ios.kt b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.ios.kt index dc118695..d61a00e1 100644 --- a/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.ios.kt +++ b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.ios.kt @@ -336,11 +336,11 @@ internal fun pluginUtf8ToHex(value: String): String = byte.toUByte().toString(16).padStart(2, '0') } -internal fun pluginHexToUtf8(hex: String): String { +internal fun pluginHexToByteArray(hex: String): ByteArray { val normalized = hex.trim().lowercase() .replace(" ", "") .removePrefix("0x") - if (normalized.isEmpty()) return "" + if (normalized.isEmpty()) return ByteArray(0) val evenHex = if (normalized.length % 2 == 0) normalized else "0$normalized" val out = ByteArray(evenHex.length / 2) @@ -348,5 +348,9 @@ internal fun pluginHexToUtf8(hex: String): String { val part = evenHex.substring(index * 2, index * 2 + 2) out[index] = part.toInt(16).toByte() } - return out.decodeToString() + return out +} + +internal fun pluginHexToUtf8(hex: String): String { + return pluginHexToByteArray(hex).decodeToString() } From 405f00ab5bf59800c70edd9c2537e1a020750a61 Mon Sep 17 00:00:00 2001 From: paregi12 Date: Mon, 18 May 2026 09:21:56 +0530 Subject: [PATCH 03/24] fix: harden plugin runtime bridges --- .../features/plugins/runtime/PluginRuntime.kt | 9 +- .../features/plugins/runtime/js/JsBindings.kt | 103 +++++++++++++++--- .../plugins/runtime/network/FetchBridge.kt | 8 +- 3 files changed, 95 insertions(+), 25 deletions(-) diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt index 040a1c8d..6c680a9b 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt @@ -77,7 +77,10 @@ internal object PluginRuntime { hostRegistry.registerAll(this) val settingsJson = toJsonElement(scraperSettings).toString() - val polyfillCode = JsBindings.buildPolyfillCode(scraperId, settingsJson) + val polyfillCode = JsBindings.buildPolyfillCode( + scraperIdJson = JsonPrimitive(scraperId).toString(), + settingsJson = settingsJson, + ) evaluate(polyfillCode) val wrappedCode = """ @@ -89,6 +92,8 @@ internal object PluginRuntime { """.trimIndent() evaluate(wrappedCode) + val tmdbIdArg = JsonPrimitive(tmdbId).toString() + val mediaTypeArg = JsonPrimitive(mediaType).toString() val seasonArg = season?.toString() ?: "undefined" val episodeArg = episode?.toString() ?: "undefined" val callCode = """ @@ -100,7 +105,7 @@ internal object PluginRuntime { __capture_result(JSON.stringify([])); return; } - var result = await getStreams("$tmdbId", "$mediaType", $seasonArg, $episodeArg); + var result = await getStreams($tmdbIdArg, $mediaTypeArg, $seasonArg, $episodeArg); __capture_result(JSON.stringify(result || [])); } catch (e) { console.error("getStreams error:", e && e.message ? e.message : e, e && e.stack ? e.stack : ""); diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt index 81b45bd6..66c70e86 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt @@ -1,9 +1,9 @@ package com.nuvio.app.features.plugins.runtime.js internal object JsBindings { - fun buildPolyfillCode(scraperId: String, settingsJson: String): String { + fun buildPolyfillCode(scraperIdJson: String, settingsJson: String): String { return """ - globalThis.SCRAPER_ID = "$scraperId"; + globalThis.SCRAPER_ID = $scraperIdJson; globalThis.SCRAPER_SETTINGS = $settingsJson; if (typeof globalThis.global === 'undefined') globalThis.global = globalThis; if (typeof globalThis.window === 'undefined') globalThis.window = globalThis; @@ -24,10 +24,27 @@ internal object JsBindings { } private fun fetchPolyfill() = """ + function __normalize_fetch_headers(headers) { + var out = {}; + if (!headers) return out; + if (typeof headers.forEach === 'function') { + headers.forEach(function(value, key) { out[key] = String(value); }); + return out; + } + if (Array.isArray(headers)) { + headers.forEach(function(pair) { + if (pair && pair.length >= 2) out[pair[0]] = String(pair[1]); + }); + return out; + } + Object.keys(headers).forEach(function(key) { out[key] = String(headers[key]); }); + return out; + } + var fetch = async function(url, options) { options = options || {}; var method = (options.method || 'GET').toUpperCase(); - var headers = options.headers || {}; + var headers = __normalize_fetch_headers(options.headers); var body = options.body || ''; var followRedirects = options.redirect !== 'manual'; var result = __native_fetch(url, method, JSON.stringify(headers), body, followRedirects); @@ -223,6 +240,41 @@ internal object JsBindings { return hex; } + function __bytesToHex(bytes) { + bytes = __toUint8Array(bytes); + var hex = ''; + for (var i = 0; i < bytes.length; i++) { + var part = bytes[i].toString(16); + if (part.length < 2) part = '0' + part; + hex += part; + } + return hex; + } + + function __hexToBytes(hex) { + var normalizedHex = (hex || '').toLowerCase(); + if (normalizedHex.length % 2 !== 0) normalizedHex = '0' + normalizedHex; + var bytes = new Uint8Array(normalizedHex.length / 2); + for (var i = 0; i < normalizedHex.length; i += 2) { + bytes[i / 2] = parseInt(normalizedHex.substring(i, i + 2), 16) || 0; + } + return bytes; + } + + function __binaryStringToBytes(value) { + var text = value == null ? '' : String(value); + var bytes = new Uint8Array(text.length); + for (var i = 0; i < text.length; i++) bytes[i] = text.charCodeAt(i) & 0xff; + return bytes; + } + + function __bytesToBinaryString(bytes) { + bytes = __toUint8Array(bytes); + var out = ''; + for (var i = 0; i < bytes.length; i++) out += String.fromCharCode(bytes[i]); + return out; + } + function __wordArrayToHex(value) { if (!value) return ''; if (typeof value.__hex === 'string') return value.__hex.toLowerCase(); @@ -243,7 +295,7 @@ internal object JsBindings { toString: function(encoder) { if (!encoder || encoder === CryptoJS.enc.Hex) return this.__hex; if (encoder === CryptoJS.enc.Utf8) return this.__utf8; - if (encoder === CryptoJS.enc.Base64) return typeof __crypto_base64_encode !== 'undefined' ? __crypto_base64_encode(this.__utf8) : ''; + if (encoder === CryptoJS.enc.Base64) return btoa(__bytesToBinaryString(__hexToBytes(this.__hex))); return this.__hex; }, clamp: function() { return this; }, @@ -266,8 +318,11 @@ internal object JsBindings { return __buildWordArray(hex, utf8); } function __wordArrayFromBase64(base64) { - var utf8 = typeof __crypto_base64_decode !== 'undefined' ? __crypto_base64_decode(base64 || '') : ''; - return __wordArrayFromUtf8(utf8); + return __buildWordArray(__bytesToHex(__binaryStringToBytes(atob(base64 || ''))), undefined); + } + + function __wordArrayToBytes(value) { + return __hexToBytes(__wordArrayToHex(value)); } function __normalizeWordArrayInput(value) { @@ -280,6 +335,13 @@ internal object JsBindings { return String(value); } + function __toUint8Array(data) { + if (data instanceof Uint8Array) return data; + if (data instanceof ArrayBuffer) return new Uint8Array(data); + if (data && typeof data.length === 'number') return new Uint8Array(Array.prototype.slice.call(data)); + return new Uint8Array(0); + } + function __bufferToUint8(data) { if (data instanceof Uint8Array) return data; if (data instanceof ArrayBuffer) return new Uint8Array(data); @@ -302,31 +364,36 @@ internal object JsBindings { SHA256: function(m) { return __wordArrayFromHex(__crypto_digest_hex('SHA256', __normalizeWordArrayInput(m))); }, SHA512: function(m) { return __wordArrayFromHex(__crypto_digest_hex('SHA512', __normalizeWordArrayInput(m))); }, PBKDF2: function(pass, salt, options) { + options = options || {}; var pBytes = __bufferToUint8(__normalizeWordArrayInput(pass)); var sBytes = __bufferToUint8(__normalizeWordArrayInput(salt)); var iter = options.iterations || 1000; var kSize = options.keySize || (256/32); var algo = options.hasher === CryptoJS.algo.SHA256 ? 'SHA256' : 'SHA1'; var resBytes = typeof __crypto_pbkdf2_raw !== 'undefined' ? __crypto_pbkdf2_raw(pBytes, sBytes, iter, kSize * 32, algo) : new Uint8Array(0); - return __wordArrayFromHex(__wordsToHex(Array.from(resBytes), resBytes.length)); + return __wordArrayFromHex(__bytesToHex(resBytes)); }, AES: { encrypt: function(message, key, options) { + options = options || {}; var data = __bufferToUint8(__normalizeWordArrayInput(message)); - var kBytes = __bufferToUint8(__wordArrayToHex(key)); - var ivBytes = __bufferToUint8(__wordArrayToHex(options.iv || '')); + var kBytes = __wordArrayToBytes(key); + var ivBytes = __wordArrayToBytes(options.iv || ''); var mode = options.mode || 'AES-CBC'; var resBytes = typeof __crypto_aes_encrypt_raw !== 'undefined' ? __crypto_aes_encrypt_raw(mode, kBytes, ivBytes, data) : new Uint8Array(0); - var wa = __wordArrayFromHex(__wordsToHex(Array.from(resBytes), resBytes.length)); + var wa = __wordArrayFromHex(__bytesToHex(resBytes)); return { ciphertext: wa, toString: function() { return wa.toString(CryptoJS.enc.Base64); } }; }, decrypt: function(cipher, key, options) { - var data = typeof cipher === 'string' ? __bufferToUint8(typeof __crypto_base64_decode !== 'undefined' ? __crypto_base64_decode(cipher) : '') : (cipher.ciphertext ? __bufferToUint8(typeof __crypto_base64_decode !== 'undefined' ? __crypto_base64_decode(cipher.ciphertext.toString(CryptoJS.enc.Base64)) : '') : __bufferToUint8(cipher)); - var kBytes = __bufferToUint8(__wordArrayToHex(key)); - var ivBytes = __bufferToUint8(__wordArrayToHex(options.iv || '')); + options = options || {}; + var data = typeof cipher === 'string' + ? __binaryStringToBytes(atob(cipher)) + : (cipher.ciphertext ? __wordArrayToBytes(cipher.ciphertext) : __bufferToUint8(cipher)); + var kBytes = __wordArrayToBytes(key); + var ivBytes = __wordArrayToBytes(options.iv || ''); var mode = options.mode || 'AES-CBC'; var resBytes = typeof __crypto_aes_decrypt_raw !== 'undefined' ? __crypto_aes_decrypt_raw(mode, kBytes, ivBytes, data) : new Uint8Array(0); var plain = new TextDecoder().decode(resBytes); @@ -341,35 +408,35 @@ internal object JsBindings { digest: async function(algo, data) { var bytes = __bufferToUint8(data); var res = typeof __crypto_digest_raw !== 'undefined' ? __crypto_digest_raw(algo.name || algo, bytes) : new Uint8Array(0); - return res.buffer; + return __toUint8Array(res).buffer; }, importKey: async function(fmt, data, algo, ext, use) { return { _raw: data, _algo: algo }; }, deriveBits: async function(params, key, len) { var pBytes = __bufferToUint8(key._raw); var sBytes = __bufferToUint8(params.salt); var res = typeof __crypto_pbkdf2_raw !== 'undefined' ? __crypto_pbkdf2_raw(pBytes, sBytes, params.iterations, len, params.hash) : new Uint8Array(0); - return res.buffer; + return __toUint8Array(res).buffer; }, encrypt: async function(params, key, data) { var kBytes = __bufferToUint8(key._raw); var ivBytes = __bufferToUint8(params.iv || ''); var dBytes = __bufferToUint8(data); var res = typeof __crypto_aes_encrypt_raw !== 'undefined' ? __crypto_aes_encrypt_raw(params.name, kBytes, ivBytes, dBytes) : new Uint8Array(0); - return res.buffer; + return __toUint8Array(res).buffer; }, decrypt: async function(params, key, data) { var kBytes = __bufferToUint8(key._raw); var ivBytes = __bufferToUint8(params.iv || ''); var dBytes = __bufferToUint8(data); var res = typeof __crypto_aes_decrypt_raw !== 'undefined' ? __crypto_aes_decrypt_raw(params.name, kBytes, ivBytes, dBytes) : new Uint8Array(0); - return res.buffer; + return __toUint8Array(res).buffer; }, sign: async function(algo, key, data) { var algoName = typeof algo === 'string' ? algo : (algo.name || ''); var kBytes = __bufferToUint8(key._raw); var dBytes = __bufferToUint8(data); var res = typeof __crypto_sign_raw !== 'undefined' ? __crypto_sign_raw(algoName, kBytes, dBytes) : new Uint8Array(0); - return res.buffer; + return __toUint8Array(res).buffer; }, verify: async function(algo, key, sig, data) { var algoName = typeof algo === 'string' ? algo : (algo.name || ''); diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/network/FetchBridge.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/network/FetchBridge.kt index 5dd6d78b..22216c58 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/network/FetchBridge.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/network/FetchBridge.kt @@ -12,7 +12,6 @@ import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.jsonPrimitive -private const val MAX_FETCH_BODY_CHARS = 256 * 1024 private const val MAX_FETCH_HEADER_VALUE_CHARS = 8 * 1024 private const val FETCH_TRUNCATION_SUFFIX = "\n...[truncated]" @@ -67,16 +66,15 @@ internal class FetchBridge : HostModule { ) } - val responseHeaders = response.headers.mapValues { (_, value) -> - truncateString(value, MAX_FETCH_HEADER_VALUE_CHARS) - } + val responseHeaders = response.headers.mapKeys { (key, _) -> key.lowercase() } + .mapValues { (_, value) -> truncateString(value, MAX_FETCH_HEADER_VALUE_CHARS) } val result = JsonObject( mapOf( "ok" to JsonPrimitive(response.status in 200..299), "status" to JsonPrimitive(response.status), "statusText" to JsonPrimitive(response.statusText), "url" to JsonPrimitive(response.url), - "body" to JsonPrimitive(truncateString(response.body, MAX_FETCH_BODY_CHARS)), + "body" to JsonPrimitive(response.body), "headers" to JsonObject(responseHeaders.mapValues { JsonPrimitive(it.value) }), ), ) From a5493a84a7e3dc8ed14549cbe14b76acca565e7a Mon Sep 17 00:00:00 2001 From: paregi12 Date: Mon, 18 May 2026 10:40:44 +0530 Subject: [PATCH 04/24] feat(plugins): implement declarative plugin settings system with multi-platform storage --- .../plugins/PluginPlatform.android.kt | 10 + .../app/features/plugins/PluginModels.kt | 3 + .../app/features/plugins/PluginRepository.kt | 7 +- .../features/plugins/PluginSettingsDialog.kt | 204 ++++++++++++++++++ .../features/plugins/PluginsSettingsScreen.kt | 46 +++- .../features/plugins/runtime/PluginRuntime.kt | 63 +++++- .../features/plugins/PluginPlatform.ios.kt | 10 + 7 files changed, 331 insertions(+), 12 deletions(-) create mode 100644 composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginSettingsDialog.kt 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..525b6c6f 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 @@ -22,6 +22,16 @@ internal object PluginStorage { ?.putString("${pluginsStateKey}_$profileId", payload) ?.apply() } + + fun loadScraperSettings(scraperId: String): String? = + preferences?.getString("settings_${scraperId}", null) + + fun saveScraperSettings(scraperId: String, payload: String) { + preferences + ?.edit() + ?.putString("settings_${scraperId}", payload) + ?.apply() + } } internal fun currentPluginPlatform(): String = "android" diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginModels.kt index afca7988..e069a5fb 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginModels.kt @@ -21,6 +21,7 @@ data class PluginManifestScraper( val filename: String, @SerialName("supportedTypes") val supportedTypes: List = listOf("movie", "tv"), val enabled: Boolean = true, + val hasSettings: Boolean = false, val logo: String? = null, @SerialName("contentLanguage") val contentLanguage: List? = null, @SerialName("supportedPlatforms") val supportedPlatforms: List? = null, @@ -52,6 +53,7 @@ data class PluginScraper( val supportedTypes: List, val enabled: Boolean, val manifestEnabled: Boolean, + val hasSettings: Boolean = false, val logo: String? = null, val contentLanguage: List = emptyList(), val formats: List? = null, @@ -119,6 +121,7 @@ internal data class StoredPluginScraper( val supportedTypes: List, val enabled: Boolean, val manifestEnabled: Boolean, + val hasSettings: Boolean = false, val logo: String? = null, val contentLanguage: List = emptyList(), val formats: List? = null, 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 ec2741e7..25d405b8 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 @@ -329,7 +329,6 @@ actual object PluginRepository { season = season, episode = episode, scraperId = scraper.id, - scraperSettings = emptyMap(), ) } } @@ -383,6 +382,7 @@ actual object PluginRepository { supportedTypes = info.supportedTypes, enabled = enabled, manifestEnabled = info.enabled, + hasSettings = info.hasSettings, logo = info.logo, contentLanguage = info.contentLanguage ?: emptyList(), formats = info.formats ?: info.supportedFormats, @@ -476,12 +476,12 @@ actual object PluginRepository { supportedTypes = scraper.supportedTypes, enabled = scraper.enabled, manifestEnabled = scraper.manifestEnabled, + hasSettings = scraper.hasSettings, logo = scraper.logo, contentLanguage = scraper.contentLanguage, formats = scraper.formats, code = scraper.code, - ) - }, + ) }, ) PluginStorage.saveState(currentProfileId, json.encodeToString(payload)) } @@ -543,6 +543,7 @@ actual object PluginRepository { supportedTypes = it.supportedTypes, enabled = it.enabled, manifestEnabled = it.manifestEnabled, + hasSettings = it.hasSettings, logo = it.logo, contentLanguage = it.contentLanguage, formats = it.formats, diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginSettingsDialog.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginSettingsDialog.kt new file mode 100644 index 00000000..cb5a98f0 --- /dev/null +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginSettingsDialog.kt @@ -0,0 +1,204 @@ +package com.nuvio.app.features.plugins + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ArrowDropDown +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.nuvio.app.core.ui.NuvioInputField +import com.nuvio.app.core.ui.NuvioPrimaryButton +import kotlinx.serialization.json.* + +@Composable +fun PluginSettingsDialog( + scraperId: String, + scraperName: String, + layoutJson: String, + onDismiss: () -> Unit +) { + val json = remember { Json { ignoreUnknownKeys = true } } + val layout = remember(layoutJson) { + runCatching { json.parseToJsonElement(layoutJson).jsonArray }.getOrElse { JsonArray(emptyList()) } + } + + val savedSettingsJson = remember(scraperId) { PluginStorage.loadScraperSettings(scraperId) ?: "{}" } + val initialSettings = remember(savedSettingsJson) { + runCatching { json.parseToJsonElement(savedSettingsJson).jsonObject }.getOrElse { JsonObject(emptyMap()) } + } + + val currentSettings = remember { mutableStateMapOf().apply { putAll(initialSettings) } } + + Dialog(onDismissRequest = onDismiss) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + shape = MaterialTheme.shapes.large, + ) { + Column( + modifier = Modifier + .padding(20.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = "$scraperName Settings", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.primary + ) + + layout.forEach { element -> + val field = element.jsonObject + val type = field["type"]?.jsonPrimitive?.content ?: "info" + val key = field["key"]?.jsonPrimitive?.content ?: "" + val label = field["label"]?.jsonPrimitive?.content ?: "" + val description = field["description"]?.jsonPrimitive?.content + + when (type) { + "header" -> { + Text( + text = label, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.secondary, + modifier = Modifier.padding(top = 8.dp) + ) + } + "info" -> { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + "text" -> { + val value = currentSettings[key]?.jsonPrimitive?.content ?: "" + val isPassword = field["isPassword"]?.jsonPrimitive?.boolean ?: false + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(text = label, style = MaterialTheme.typography.labelLarge) + NuvioInputField( + value = value, + onValueChange = { currentSettings[key] = JsonPrimitive(it) }, + placeholder = field["placeholder"]?.jsonPrimitive?.content ?: "" + ) + description?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + "select" -> { + val options = field["options"]?.jsonArray ?: JsonArray(emptyList()) + val defaultValue = field["defaultValue"]?.jsonPrimitive?.content ?: "" + val currentValue = currentSettings[key]?.jsonPrimitive?.content ?: defaultValue + + var expanded by remember { mutableStateOf(false) } + + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(text = label, style = MaterialTheme.typography.labelLarge) + Box { + OutlinedButton( + onClick = { expanded = true }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + contentPadding = PaddingValues(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + val selectedLabel = options.find { + it.jsonObject["value"]?.jsonPrimitive?.content == currentValue + }?.jsonObject?.get("label")?.jsonPrimitive?.content ?: currentValue + + Text(text = selectedLabel.ifBlank { "Select option" }) + Icon(Icons.Rounded.ArrowDropDown, contentDescription = null) + } + } + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + modifier = Modifier.fillMaxWidth(0.8f) + ) { + options.forEach { optionElement -> + val option = optionElement.jsonObject + val optionLabel = option["label"]?.jsonPrimitive?.content ?: "" + val optionValue = option["value"]?.jsonPrimitive?.content ?: "" + DropdownMenuItem( + text = { Text(optionLabel) }, + onClick = { + currentSettings[key] = JsonPrimitive(optionValue) + expanded = false + } + ) + } + } + } + description?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + "toggle" -> { + val value = currentSettings[key]?.jsonPrimitive?.boolean ?: field["defaultValue"]?.jsonPrimitive?.boolean ?: false + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column(modifier = Modifier.weight(1f)) { + Text(text = label, style = MaterialTheme.typography.bodyLarge) + description?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + Switch( + checked = value, + onCheckedChange = { currentSettings[key] = JsonPrimitive(it) } + ) + } + } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.End) + ) { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + NuvioPrimaryButton( + text = "Save", + onClick = { + val result = JsonObject(currentSettings.toMap()) + PluginStorage.saveScraperSettings(scraperId, result.toString()) + onDismiss() + }, + modifier = Modifier.width(100.dp) + ) + } + } + } + } +} diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginsSettingsScreen.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginsSettingsScreen.kt index 71b7e4e3..95a6fee1 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginsSettingsScreen.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginsSettingsScreen.kt @@ -12,8 +12,10 @@ import androidx.compose.material.icons.rounded.Bolt import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Extension import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material.icons.rounded.Settings import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Switch import androidx.compose.material3.Text @@ -63,6 +65,9 @@ fun PluginsSettingsPageContent( var testingScraperId by remember { mutableStateOf(null) } val testResults = remember { mutableStateMapOf>() } + var configuringScraper by remember { mutableStateOf(null) } + var configuringLayout by remember { mutableStateOf(null) } + val sortedRepos = remember(uiState.repositories) { uiState.repositories.sortedBy { it.name.lowercase() } } @@ -350,11 +355,30 @@ fun PluginsSettingsPageContent( ) } } - Switch( - checked = scraper.enabled, - onCheckedChange = { PluginRepository.toggleScraper(scraper.id, it) }, - enabled = scraper.manifestEnabled, - ) + Row(verticalAlignment = Alignment.CenterVertically) { + if (scraper.hasSettings) { + IconButton(onClick = { + coroutineScope.launch { + val layout = PluginRuntime.getPluginSettingsLayout(scraper.code, scraper.id) + if (layout != null) { + configuringScraper = scraper + configuringLayout = layout + } + } + }) { + Icon( + imageVector = Icons.Rounded.Settings, + contentDescription = "Provider settings", + tint = MaterialTheme.colorScheme.primary, + ) + } + } + Switch( + checked = scraper.enabled, + onCheckedChange = { PluginRepository.toggleScraper(scraper.id, it) }, + enabled = scraper.manifestEnabled, + ) + } } Spacer(modifier = Modifier.height(10.dp)) @@ -439,6 +463,18 @@ fun PluginsSettingsPageContent( } } } + + if (configuringScraper != null && configuringLayout != null) { + PluginSettingsDialog( + scraperId = configuringScraper!!.id, + scraperName = configuringScraper!!.name, + layoutJson = configuringLayout!!, + onDismiss = { + configuringScraper = null + configuringLayout = null + } + ) + } } private fun String.fallbackRepositoryLabel(): String { diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt index 6c680a9b..1fc3e909 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt @@ -35,8 +35,12 @@ internal object PluginRuntime { season: Int?, episode: Int?, scraperId: String, - scraperSettings: Map = emptyMap(), ): List = withContext(Dispatchers.Default) { + val scraperSettingsJson = PluginStorage.loadScraperSettings(scraperId) ?: "{}" + val scraperSettingsMap = runCatching { + json.decodeFromString>(scraperSettingsJson) + }.getOrElse { emptyMap() } + withTimeout(PLUGIN_TIMEOUT_MS) { executePluginInternal( code = code, @@ -45,11 +49,62 @@ internal object PluginRuntime { season = season, episode = episode, scraperId = scraperId, - scraperSettings = scraperSettings, + scraperSettings = scraperSettingsMap, ) } } + suspend fun getPluginSettingsLayout( + code: String, + scraperId: String, + ): String? = withContext(Dispatchers.Default) { + withTimeout(PLUGIN_TIMEOUT_MS) { + val jsRuntime = JsRuntime() + var resultJson: String? = null + + try { + jsRuntime.use { + val polyfillCode = JsBindings.buildPolyfillCode( + scraperIdJson = JsonPrimitive(scraperId).toString(), + settingsJson = "{}" + ) + evaluate(polyfillCode) + + val wrappedCode = """ + var module = { exports: {} }; + var exports = module.exports; + (function() { + $code + })(); + """.trimIndent() + evaluate(wrappedCode) + + val callCode = """ + (async function() { + try { + var onSettings = module.exports.onSettings || globalThis.onSettings; + if (onSettings) { + var layout = await onSettings(); + globalThis.__settings_layout_result = JSON.stringify(layout || []); + } else { + globalThis.__settings_layout_result = null; + } + } catch (e) { + console.error("onSettings error:", e); + globalThis.__settings_layout_result = null; + } + })(); + """.trimIndent() + evaluate(callCode) + resultJson = evaluate("globalThis.__settings_layout_result") + } + resultJson + } catch (e: Exception) { + null + } + } + } + private suspend fun executePluginInternal( code: String, tmdbId: String, @@ -57,7 +112,7 @@ internal object PluginRuntime { season: Int?, episode: Int?, scraperId: String, - scraperSettings: Map, + scraperSettings: Map, ): List { val jsRuntime = JsRuntime() var resultJson = "[]" @@ -76,7 +131,7 @@ internal object PluginRuntime { jsRuntime.use { hostRegistry.registerAll(this) - val settingsJson = toJsonElement(scraperSettings).toString() + val settingsJson = JsonObject(scraperSettings).toString() val polyfillCode = JsBindings.buildPolyfillCode( scraperIdJson = JsonPrimitive(scraperId).toString(), settingsJson = settingsJson, 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..1a8ddaac 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 @@ -15,6 +15,16 @@ internal object PluginStorage { forKey = "${pluginsStateKey}_$profileId", ) } + + fun loadScraperSettings(scraperId: String): String? = + NSUserDefaults.standardUserDefaults.stringForKey("settings_${scraperId}") + + fun saveScraperSettings(scraperId: String, payload: String) { + NSUserDefaults.standardUserDefaults.setObject( + payload, + forKey = "settings_${scraperId}", + ) + } } internal fun currentPluginPlatform(): String = "ios" From a44aaf4ef7f3468790a3bec1e44076e63de5e3a4 Mon Sep 17 00:00:00 2001 From: paregi12 Date: Mon, 18 May 2026 10:54:46 +0530 Subject: [PATCH 05/24] fix(plugins): add missing imports and resolve compilation errors --- .../com/nuvio/app/features/plugins/PluginsSettingsScreen.kt | 1 + .../com/nuvio/app/features/plugins/runtime/PluginRuntime.kt | 1 + 2 files changed, 2 insertions(+) diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginsSettingsScreen.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginsSettingsScreen.kt index 95a6fee1..9204e8d6 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginsSettingsScreen.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginsSettingsScreen.kt @@ -41,6 +41,7 @@ import com.nuvio.app.core.ui.NuvioPrimaryButton import com.nuvio.app.core.ui.NuvioSectionLabel import com.nuvio.app.core.ui.NuvioSurfaceCard import com.nuvio.app.features.tmdb.TmdbSettingsRepository +import com.nuvio.app.features.plugins.runtime.PluginRuntime import kotlinx.coroutines.launch @Composable diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt index 1fc3e909..8afe6e06 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt @@ -1,6 +1,7 @@ package com.nuvio.app.features.plugins.runtime import com.nuvio.app.features.plugins.PluginRuntimeResult +import com.nuvio.app.features.plugins.PluginStorage import com.nuvio.app.features.plugins.runtime.crypto.CryptoBridge import com.nuvio.app.features.plugins.runtime.dom.DomBridge import com.nuvio.app.features.plugins.runtime.host.HostApiRegistry From 835473115fed4c7a5ca1025eec6081f8b36727ca Mon Sep 17 00:00:00 2001 From: paregi12 Date: Mon, 18 May 2026 11:27:12 +0530 Subject: [PATCH 06/24] fix(plugins): improve settings layout fetching with native capture function --- .../features/plugins/runtime/PluginRuntime.kt | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt index 8afe6e06..9e19114f 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt @@ -83,21 +83,28 @@ internal object PluginRuntime { val callCode = """ (async function() { try { - var onSettings = module.exports.onSettings || globalThis.onSettings; - if (onSettings) { + var onSettings = (typeof module !== 'undefined' && module.exports && module.exports.onSettings) || globalThis.onSettings; + if (typeof onSettings === 'function') { var layout = await onSettings(); - globalThis.__settings_layout_result = JSON.stringify(layout || []); + __capture_settings_result(JSON.stringify(layout || [])); } else { - globalThis.__settings_layout_result = null; + __capture_settings_result("[]"); } } catch (e) { console.error("onSettings error:", e); - globalThis.__settings_layout_result = null; + __capture_settings_result("[]"); } })(); """.trimIndent() + + var captureResult: String? = null + jsRuntime.function("__capture_settings_result") { args -> + captureResult = args.getOrNull(0)?.toString() + null + } + evaluate(callCode) - resultJson = evaluate("globalThis.__settings_layout_result") + resultJson = captureResult } resultJson } catch (e: Exception) { From 3415696df8945d3fcc2dc6dc5764b81a04a4178b Mon Sep 17 00:00:00 2001 From: paregi12 Date: Mon, 18 May 2026 11:46:28 +0530 Subject: [PATCH 07/24] fix(plugins): resolve unresolved reference and type inference errors in PluginRuntime --- .../com/nuvio/app/features/plugins/runtime/PluginRuntime.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt index 9e19114f..cfd72e18 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt @@ -8,6 +8,7 @@ import com.nuvio.app.features.plugins.runtime.host.HostApiRegistry import com.nuvio.app.features.plugins.runtime.host.HostFunctions import com.nuvio.app.features.plugins.runtime.js.JsBindings import com.nuvio.app.features.plugins.runtime.js.JsRuntime +import com.dokar.quickjs.binding.function import com.nuvio.app.features.plugins.runtime.network.FetchBridge import com.nuvio.app.features.plugins.runtime.network.UrlBridge import com.nuvio.app.features.plugins.runtime.wasm.WasmBridge @@ -98,7 +99,7 @@ internal object PluginRuntime { """.trimIndent() var captureResult: String? = null - jsRuntime.function("__capture_settings_result") { args -> + jsRuntime.function("__capture_settings_result") { args: Array -> captureResult = args.getOrNull(0)?.toString() null } From 422151bc23ee53a463126e8c9156bab6ad1969ab Mon Sep 17 00:00:00 2001 From: paregi12 Date: Mon, 18 May 2026 12:00:34 +0530 Subject: [PATCH 08/24] fix(plugins): fix receiver type mismatch in getPluginSettingsLayout --- .../com/nuvio/app/features/plugins/runtime/PluginRuntime.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt index cfd72e18..9d1da148 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt @@ -99,7 +99,7 @@ internal object PluginRuntime { """.trimIndent() var captureResult: String? = null - jsRuntime.function("__capture_settings_result") { args: Array -> + function("__capture_settings_result") { args: Array -> captureResult = args.getOrNull(0)?.toString() null } From a024e12bceb1f7e36d0996d676e4811b88ffb68b Mon Sep 17 00:00:00 2001 From: paregi12 Date: Mon, 18 May 2026 12:57:50 +0530 Subject: [PATCH 09/24] fix(plugins): ensure runtime waits for async settings and stream results --- .../features/plugins/runtime/PluginRuntime.kt | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt index 9d1da148..3da6404b 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt @@ -12,6 +12,7 @@ import com.dokar.quickjs.binding.function import com.nuvio.app.features.plugins.runtime.network.FetchBridge import com.nuvio.app.features.plugins.runtime.network.UrlBridge import com.nuvio.app.features.plugins.runtime.wasm.WasmBridge +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout @@ -62,7 +63,7 @@ internal object PluginRuntime { ): String? = withContext(Dispatchers.Default) { withTimeout(PLUGIN_TIMEOUT_MS) { val jsRuntime = JsRuntime() - var resultJson: String? = null + val deferred = CompletableDeferred() try { jsRuntime.use { @@ -98,16 +99,14 @@ internal object PluginRuntime { })(); """.trimIndent() - var captureResult: String? = null function("__capture_settings_result") { args: Array -> - captureResult = args.getOrNull(0)?.toString() + deferred.complete(args.getOrNull(0)?.toString()) null } evaluate(callCode) - resultJson = captureResult + deferred.await() } - resultJson } catch (e: Exception) { null } @@ -124,11 +123,11 @@ internal object PluginRuntime { scraperSettings: Map, ): List { val jsRuntime = JsRuntime() - var resultJson = "[]" + val deferred = CompletableDeferred() val domBridge = DomBridge() val hostRegistry = HostApiRegistry().apply { - addModule(HostFunctions(scraperId) { resultJson = it }) + addModule(HostFunctions(scraperId) { deferred.complete(it) }) addModule(FetchBridge()) addModule(UrlBridge()) addModule(CryptoBridge()) @@ -178,9 +177,10 @@ internal object PluginRuntime { })(); """.trimIndent() evaluate(callCode) + + val resultJson = deferred.await() + return parseJsonResults(resultJson) } - - return parseJsonResults(resultJson) } finally { domBridge.clear() } From 5c063405a0a344a68f1639d0f4dd5245398ed3af Mon Sep 17 00:00:00 2001 From: paregi12 Date: Mon, 18 May 2026 13:13:32 +0530 Subject: [PATCH 10/24] fix(plugins): remove prohibited return from inside jsRuntime.use block --- .../com/nuvio/app/features/plugins/runtime/PluginRuntime.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt index 3da6404b..fb4b7110 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt @@ -178,9 +178,9 @@ internal object PluginRuntime { """.trimIndent() evaluate(callCode) - val resultJson = deferred.await() - return parseJsonResults(resultJson) + deferred.await() } + return parseJsonResults(resultJson) } finally { domBridge.clear() } From 5ee2ad6d8a7b2442d6fd421228d395149266cc7a Mon Sep 17 00:00:00 2001 From: paregi12 Date: Mon, 18 May 2026 13:28:21 +0530 Subject: [PATCH 11/24] fix(plugins): move return outside jsRuntime.use block --- .../com/nuvio/app/features/plugins/runtime/PluginRuntime.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt index fb4b7110..5c884568 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt @@ -180,7 +180,9 @@ internal object PluginRuntime { deferred.await() } - return parseJsonResults(resultJson) + + // Result is captured inside use block, but returned outside to satisfy compiler + return parseJsonResults(deferred.await()) } finally { domBridge.clear() } From 9b372ea11823d71da3628402b85db2ad57260df2 Mon Sep 17 00:00:00 2001 From: paregi12 Date: Wed, 20 May 2026 18:25:31 +0530 Subject: [PATCH 12/24] feat(plugins): add support for plugin subtitles with header injection and format detection --- .../features/player/PlayerEngine.android.kt | 106 ++++++++++++++++-- .../commonMain/kotlin/com/nuvio/app/App.kt | 9 +- .../nuvio/app/features/player/PlayerEngine.kt | 1 + .../nuvio/app/features/player/PlayerModels.kt | 1 + .../nuvio/app/features/player/PlayerScreen.kt | 4 + .../player/PlayerStreamsRepository.kt | 8 ++ .../app/features/plugins/PluginModels.kt | 9 ++ .../app/features/streams/StreamModels.kt | 9 ++ .../app/features/streams/StreamsRepository.kt | 8 ++ .../features/player/PlayerLaunchStoreTest.kt | 1 + .../features/plugins/runtime/PluginRuntime.kt | 20 ++++ .../app/features/player/NuvioPlayerBridge.kt | 7 +- .../app/features/player/PlayerEngine.ios.kt | 17 ++- iosApp/iosApp/Player/MPVPlayerBridge.swift | 43 ++++++- 14 files changed, 224 insertions(+), 19 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt index 62ebd521..c44ac002 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt @@ -35,6 +35,9 @@ import androidx.media3.common.PlaybackException import androidx.media3.common.Player import androidx.media3.common.TrackSelectionOverride import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DataSpec +import androidx.media3.datasource.TransferListener import androidx.media3.exoplayer.DefaultLoadControl import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.ExoPlayer @@ -68,6 +71,7 @@ actual fun PlatformPlayerSurface( sourceAudioUrl: String?, sourceHeaders: Map, sourceResponseHeaders: Map, + externalSubtitles: List, useYoutubeChunkedPlayback: Boolean, modifier: Modifier, playWhenReady: Boolean, @@ -146,12 +150,26 @@ actual fun PlatformPlayerSurface( .setTsExtractorFlags(DefaultTsPayloadReaderFactory.FLAG_ENABLE_HDMV_DTS_AUDIO_STREAMS) .setTsExtractorTimestampSearchBytes(1500 * TsExtractor.TS_PACKET_SIZE) - val dataSourceFactory = PlatformPlaybackDataSourceFactory.create( - context = context, - defaultRequestHeaders = sanitizedSourceHeaders, + val baseNetworkFactory = if (useYoutubeChunkedPlayback) { + YoutubeChunkedDataSourceFactory(defaultRequestHeaders = sanitizedSourceHeaders) + } else { + PlayerPlaybackNetworking.createHttpDataSourceFactory(sanitizedSourceHeaders) + } + + val subtitleHeaderFactory = SubtitleRequestHeaderDataSourceFactory( + upstreamFactory = baseNetworkFactory, + externalSubtitles = externalSubtitles + ) + + val baseFactory: DataSource.Factory = DefaultDataSource.Factory(context, subtitleHeaderFactory) + val dataSourceFactory = if (sanitizedSourceResponseHeaders.isEmpty()) { + baseFactory + } else { + ResponseHeaderOverridingDataSourceFactory( + upstreamFactory = baseFactory, defaultResponseHeaders = sanitizedSourceResponseHeaders, - useYoutubeChunkedPlayback = useYoutubeChunkedPlayback, ) + } val player = if (useLibass) { ExoPlayer.Builder(context) @@ -179,13 +197,33 @@ actual fun PlatformPlayerSurface( } player.apply { + val mediaItemBuilder = MediaItem.Builder() + .setUri(Uri.parse(sourceUrl)) + .setMediaId(sourceUrl) + + val subtitleConfigs = externalSubtitles.mapNotNull { subtitle -> + val mimeType = resolveSubtitleMimeType(subtitle.url, subtitle.headers) + MediaItem.SubtitleConfiguration.Builder(Uri.parse(subtitle.url)) + .setMimeType(mimeType) + .setLanguage(subtitle.language) + .setLabel(subtitle.name ?: subtitle.language) + .setRoleFlags(C.ROLE_FLAG_SUBTITLE) + .build() + } + + if (subtitleConfigs.isNotEmpty()) { + mediaItemBuilder.setSubtitleConfigurations(subtitleConfigs) + } + + val mediaItem = mediaItemBuilder.build() + if (!sourceAudioUrl.isNullOrBlank()) { val msf = DefaultMediaSourceFactory(dataSourceFactory, extractorsFactory) - val videoSource = msf.createMediaSource(MediaItem.fromUri(sourceUrl)) + val videoSource = msf.createMediaSource(mediaItem) val audioSource = msf.createMediaSource(MediaItem.fromUri(sourceAudioUrl)) setMediaSource(MergingMediaSource(videoSource, audioSource)) } else { - setMediaItem(MediaItem.fromUri(sourceUrl)) + setMediaItem(mediaItem) } fallbackStartPositionMs?.let { seekTo(it.coerceAtLeast(0L)) } prepare() @@ -709,15 +747,15 @@ private fun ExoPlayer.logCurrentTracks(context: String) { Log.d(TAG, "--- end logCurrentTracks ---") } -private fun resolveSubtitleMimeType(url: String): String { - probeSubtitleHeaders(url)?.let { (contentType, contentDisposition) -> +private fun resolveSubtitleMimeType(url: String, headers: Map? = null): String { + probeSubtitleHeaders(url, headers)?.let { (contentType, contentDisposition) -> mapSubtitleMime(contentType)?.let { return it } filenameFromContentDisposition(contentDisposition)?.let(::guessSubtitleMime)?.let { return it } } return guessSubtitleMime(url) } -private fun probeSubtitleHeaders(url: String): Pair? { +private fun probeSubtitleHeaders(url: String, headers: Map? = null): Pair? { val methods = listOf("HEAD", "GET") methods.forEach { method -> runCatching { @@ -727,6 +765,9 @@ private fun probeSubtitleHeaders(url: String): Pair? { readTimeout = 5_000 instanceFollowRedirects = true setRequestProperty("Accept", "*/*") + headers?.forEach { (key, value) -> + setRequestProperty(key, value) + } } try { connection.responseCode @@ -781,3 +822,50 @@ private fun guessSubtitleMime(url: String): String { else -> MimeTypes.TEXT_VTT } } + +private class SubtitleRequestHeaderDataSourceFactory( + private val upstreamFactory: DataSource.Factory, + private val externalSubtitles: List, +) : DataSource.Factory { + override fun createDataSource(): DataSource = + SubtitleRequestHeaderDataSource( + upstream = upstreamFactory.createDataSource(), + externalSubtitles = externalSubtitles, + ) +} + +private class SubtitleRequestHeaderDataSource( + private val upstream: DataSource, + private val externalSubtitles: List, +) : DataSource { + override fun addTransferListener(transferListener: TransferListener) { + upstream.addTransferListener(transferListener) + } + + override fun open(dataSpec: DataSpec): Long { + val url = dataSpec.uri.toString() + val subtitle = externalSubtitles.find { it.url == url } + val headers = subtitle?.headers + + return if (headers.isNullOrEmpty()) { + upstream.open(dataSpec) + } else { + val mergedHeaders = dataSpec.httpRequestHeaders.toMutableMap() + headers.forEach { (key, value) -> + mergedHeaders[key] = value + } + upstream.open(dataSpec.buildUpon().setHttpRequestHeaders(mergedHeaders).build()) + } + } + + override fun read(buffer: ByteArray, offset: Int, length: Int): Int = + upstream.read(buffer, offset, length) + + override fun getUri(): Uri? = upstream.uri + + override fun getResponseHeaders(): Map> = upstream.responseHeaders + + override fun close() { + upstream.close() + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 4058c118..3ee87ef0 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -872,6 +872,7 @@ private fun MainAppContent( sourceUrl = localSourceUrl, sourceHeaders = emptyMap(), sourceResponseHeaders = emptyMap(), + externalSubtitles = emptyList(), logo = logo, poster = poster, background = background, @@ -1457,6 +1458,7 @@ private fun MainAppContent( sourceUrl = cached.url, sourceHeaders = sanitizePlaybackHeaders(cached.requestHeaders), sourceResponseHeaders = sanitizePlaybackResponseHeaders(cached.responseHeaders), + externalSubtitles = emptyList(), logo = launch.logo, poster = launch.poster, background = launch.background, @@ -1563,6 +1565,7 @@ private fun MainAppContent( sourceUrl = sourceUrl, sourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request), sourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response), + externalSubtitles = stream.externalSubtitles, logo = launch.logo, poster = launch.poster, background = launch.background, @@ -1582,8 +1585,7 @@ private fun MainAppContent( parentMetaType = launch.parentMetaType ?: launch.type, initialPositionMs = launch.resumePositionMs ?: 0L, initialProgressFraction = launch.resumeProgressFraction, - ) - StreamsRepository.consumeAutoPlay() + ) StreamsRepository.consumeAutoPlay() StreamsRepository.cancelLoading() if (playerSettings.externalPlayerEnabled) { openExternalPlayback(playerLaunch) @@ -1673,6 +1675,7 @@ private fun MainAppContent( sourceUrl = sourceUrl, sourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request), sourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response), + externalSubtitles = stream.externalSubtitles, logo = launch.logo, poster = launch.poster, background = launch.background, @@ -1803,6 +1806,7 @@ private fun MainAppContent( sourceAudioUrl = launch.sourceAudioUrl, sourceHeaders = launch.sourceHeaders, sourceResponseHeaders = launch.sourceResponseHeaders, + externalSubtitles = launch.externalSubtitles, logo = launch.logo, poster = launch.poster, background = launch.background, @@ -1908,6 +1912,7 @@ private fun MainAppContent( sourceUrl = sourceUrl, sourceHeaders = emptyMap(), sourceResponseHeaders = emptyMap(), + externalSubtitles = emptyList(), logo = item.logo, poster = item.poster, background = item.background, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt index ac0be69f..87773ddc 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt @@ -56,6 +56,7 @@ expect fun PlatformPlayerSurface( sourceAudioUrl: String? = null, sourceHeaders: Map = emptyMap(), sourceResponseHeaders: Map = emptyMap(), + externalSubtitles: List = emptyList(), useYoutubeChunkedPlayback: Boolean = false, modifier: Modifier = Modifier, playWhenReady: Boolean = true, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt index 773a276d..e43c0df6 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt @@ -13,6 +13,7 @@ data class PlayerLaunch( val sourceAudioUrl: String? = null, val sourceHeaders: Map = emptyMap(), val sourceResponseHeaders: Map = emptyMap(), + val externalSubtitles: List = emptyList(), val logo: String? = null, val poster: String? = null, val background: String? = null, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt index 476b0a77..d3f4483e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt @@ -126,6 +126,7 @@ fun PlayerScreen( sourceAudioUrl: String? = null, sourceHeaders: Map = emptyMap(), sourceResponseHeaders: Map = emptyMap(), + externalSubtitles: List = emptyList(), providerName: String, streamTitle: String, streamSubtitle: String?, @@ -144,6 +145,8 @@ fun PlayerScreen( videoId: String? = null, parentMetaId: String, parentMetaType: String, + parentMetaLogo: String? = null, + parentMetaPoster: String? = null, providerAddonId: String? = null, initialPositionMs: Long = 0L, initialProgressFraction: Float? = null, @@ -1713,6 +1716,7 @@ fun PlayerScreen( sourceAudioUrl = activeSourceAudioUrl, sourceHeaders = activeSourceHeaders, sourceResponseHeaders = activeSourceResponseHeaders, + externalSubtitles = externalSubtitles, modifier = Modifier.fillMaxSize(), playWhenReady = shouldPlay, resizeMode = resizeMode, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt index 013460c3..ff990846 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt @@ -401,5 +401,13 @@ private fun PluginRuntimeResult.toStreamItem(scraper: PluginScraper): StreamItem proxyHeaders = com.nuvio.app.features.streams.StreamProxyHeaders(request = requestHeaders), ) }, + externalSubtitles = subtitles?.map { + com.nuvio.app.features.streams.StreamSubtitle( + url = it.url, + language = it.language, + name = it.name, + headers = it.headers + ) + } ?: emptyList() ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginModels.kt index e069a5fb..98769a63 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginModels.kt @@ -78,6 +78,15 @@ data class PluginRuntimeResult( val peers: Int? = null, val infoHash: String? = null, val headers: Map? = null, + val subtitles: List? = null, +) + +@Serializable +data class PluginSubtitleResult( + val url: String, + val language: String, + val name: String? = null, + val headers: Map? = null ) data class PluginsUiState( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt index 0b3d8b24..67682bc3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt @@ -4,6 +4,14 @@ import kotlinx.coroutines.runBlocking import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.getString +@Serializable +data class StreamSubtitle( + val url: String, + val language: String, + val name: String? = null, + val headers: Map? = null +) + data class StreamItem( val name: String? = null, val title: String? = null, @@ -18,6 +26,7 @@ data class StreamItem( val addonId: String, val behaviorHints: StreamBehaviorHints = StreamBehaviorHints(), val clientResolve: StreamClientResolve? = null, + val externalSubtitles: List = emptyList(), ) { val streamLabel: String get() = name ?: runBlocking { getString(Res.string.stream_default_name) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt index 2fc87a24..a6787f53 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt @@ -665,6 +665,14 @@ private fun PluginRuntimeResult.toStreamItem( proxyHeaders = StreamProxyHeaders(request = requestHeaders), ) }, + externalSubtitles = subtitles?.map { + StreamSubtitle( + url = it.url, + language = it.language, + name = it.name, + headers = it.headers + ) + } ?: emptyList() ) } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/PlayerLaunchStoreTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/PlayerLaunchStoreTest.kt index 4128f45c..f0033b18 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/PlayerLaunchStoreTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/PlayerLaunchStoreTest.kt @@ -11,6 +11,7 @@ class PlayerLaunchStoreTest { val launch = PlayerLaunch( title = "Title", sourceUrl = "https://example.com/video.m3u8?token=a/b:c", + externalSubtitles = emptyList(), streamTitle = "Source", providerName = "Provider", parentMetaId = "tt1234567", diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt index 5c884568..3fe3e728 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt @@ -206,6 +206,25 @@ internal object PluginRuntime { ?.toMap() ?.takeIf { it.isNotEmpty() } + val subtitles = (item["subtitles"] as? JsonArray)?.mapNotNull { subElement -> + val subObj = subElement as? JsonObject ?: return@mapNotNull null + val subUrl = subObj["url"]?.jsonPrimitive?.contentOrNull ?: return@mapNotNull null + val subLang = subObj["language"]?.jsonPrimitive?.contentOrNull ?: "Unknown" + val subName = subObj["name"]?.jsonPrimitive?.contentOrNull + val subHeaders = (subObj["headers"] as? JsonObject) + ?.mapNotNull { (key, value) -> + value.jsonPrimitive.contentOrNull?.let { key to it } + } + ?.toMap() + ?.takeIf { it.isNotEmpty() } + com.nuvio.app.features.plugins.PluginSubtitleResult( + url = subUrl, + language = subLang, + name = subName, + headers = subHeaders + ) + }?.takeIf { it.isNotEmpty() } + PluginRuntimeResult( title = item.stringOrNull("title") ?: item.stringOrNull("name") ?: "Unknown", name = item.stringOrNull("name"), @@ -219,6 +238,7 @@ internal object PluginRuntime { peers = item["peers"]?.jsonPrimitive?.intOrNull, infoHash = item.stringOrNull("infoHash"), headers = headers, + subtitles = subtitles, ) }.filter { it.url.isNotBlank() } }.getOrElse { emptyList() } diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt index 9012a96c..311442ab 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt @@ -9,7 +9,12 @@ import platform.UIKit.UIViewController interface NuvioPlayerBridge { fun createPlayerViewController(): UIViewController fun loadFile(url: String) - fun loadFileWithAudio(videoUrl: String, audioUrl: String?, headersJson: String?) + fun loadFileWithAudio( + videoUrl: String, + audioUrl: String?, + headersJson: String?, + subtitlesJson: String? = null + ) fun play() fun pause() fun seekTo(positionMs: Long) diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt index 733bf162..0eaba036 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt @@ -26,6 +26,7 @@ actual fun PlatformPlayerSurface( sourceAudioUrl: String?, sourceHeaders: Map, sourceResponseHeaders: Map, + externalSubtitles: List, useYoutubeChunkedPlayback: Boolean, modifier: Modifier, playWhenReady: Boolean, @@ -222,12 +223,13 @@ actual fun PlatformPlayerSurface( } // Load file and set initial state - LaunchedEffect(bridge, sourceUrl, sourceAudioUrl, sourceHeaders) { + LaunchedEffect(bridge, sourceUrl, sourceAudioUrl, sourceHeaders, externalSubtitles) { bridge.applyIosVideoOutputSettings(latestPlayerSettings.value) bridge.loadFileWithAudio( - sourceUrl, - sourceAudioUrl, - encodePlaybackHeadersForBridge(sourceHeaders), + videoUrl = sourceUrl, + audioUrl = sourceAudioUrl, + headersJson = encodePlaybackHeadersForBridge(sourceHeaders), + subtitlesJson = encodeExternalSubtitlesForBridge(externalSubtitles), ) if (playWhenReady) { bridge.play() @@ -339,6 +341,13 @@ private fun Int.toHexByte(): String { } } +private fun encodeExternalSubtitlesForBridge(subtitles: List): String? { + if (subtitles.isEmpty()) return null + return runCatching { + Json.encodeToString(subtitles) + }.getOrNull() +} + private fun encodePlaybackHeadersForBridge(headers: Map): String? { val sanitized = sanitizePlaybackHeaders(headers) if (sanitized.isEmpty()) { diff --git a/iosApp/iosApp/Player/MPVPlayerBridge.swift b/iosApp/iosApp/Player/MPVPlayerBridge.swift index afcdc601..7cd94951 100644 --- a/iosApp/iosApp/Player/MPVPlayerBridge.swift +++ b/iosApp/iosApp/Player/MPVPlayerBridge.swift @@ -16,13 +16,41 @@ final class MPVPlayerBridgeImpl: NSObject, NuvioPlayerBridge { } func loadFile(url: String) { playerVC?.loadFile(url) } - func loadFileWithAudio(videoUrl: String, audioUrl: String?, headersJson: String?) { + func loadFileWithAudio(videoUrl: String, audioUrl: String?, headersJson: String?, subtitlesJson: String?) { playerVC?.loadFile( videoUrl, audioUrl: audioUrl, - requestHeaders: parseRequestHeaders(headersJson) + requestHeaders: parseRequestHeaders(headersJson), + subtitles: parseSubtitles(subtitlesJson) ) } + + private func parseSubtitles(_ json: String?) -> [PluginSubtitle] { + guard + let json, + let data = json.data(using: .utf8), + let raw = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] + else { + return [] + } + return raw.compactMap { dict in + guard let url = dict["url"] as? String else { return nil } + return PluginSubtitle( + url: url, + language: dict["language"] as? String ?? "Unknown", + name: dict["name"] as? String, + headers: dict["headers"] as? [String: String] + ) + } + } +} + +struct PluginSubtitle { + val url: String + val language: String + val name: String? + val headers: [String: String]? +} func play() { playerVC?.playPlayback() } func pause() { playerVC?.pausePlayback() } func seekTo(positionMs: Int64) { playerVC?.seekToMs(positionMs) } @@ -172,6 +200,7 @@ private struct PendingLoadRequest { let urlString: String let audioUrl: String? let requestHeaders: [String: String] + let subtitles: [PluginSubtitle] let queuedAtUptime: TimeInterval } @@ -357,11 +386,12 @@ final class MPVPlayerViewController: UIViewController { // MARK: - Playback API - func loadFile(_ urlString: String, audioUrl: String? = nil, requestHeaders: [String: String] = [:]) { + func loadFile(_ urlString: String, audioUrl: String? = nil, requestHeaders: [String: String] = [:], subtitles: [PluginSubtitle] = []) { let request = PendingLoadRequest( urlString: urlString, audioUrl: audioUrl, requestHeaders: requestHeaders, + subtitles: subtitles, queuedAtUptime: ProcessInfo.processInfo.systemUptime ) @@ -409,6 +439,13 @@ final class MPVPlayerViewController: UIViewController { self?.command("audio-add", args: [audioUrl, "select"], checkForErrors: false) } } + + // Add external subtitles + for subtitle in request.subtitles { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in + self?.command("sub-add", args: [subtitle.url, "auto", subtitle.name ?? subtitle.language, subtitle.language], checkForErrors: false) + } + } } private func isViewportReadyForPlayback(queuedAtUptime: TimeInterval) -> Bool { From 127ec3cf4af24e505432ec7247f899262a4799af Mon Sep 17 00:00:00 2001 From: paregi12 Date: Wed, 20 May 2026 21:53:47 +0530 Subject: [PATCH 13/24] fix: resolve compilation errors introduced by subtitle implementation --- .../com/nuvio/app/features/player/PlayerEngine.android.kt | 2 ++ composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt | 3 ++- .../kotlin/com/nuvio/app/features/streams/StreamModels.kt | 1 + 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt index c44ac002..a97ae136 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt @@ -37,12 +37,14 @@ import androidx.media3.common.TrackSelectionOverride import androidx.media3.common.util.UnstableApi import androidx.media3.datasource.DataSource import androidx.media3.datasource.DataSpec +import androidx.media3.datasource.DefaultDataSource import androidx.media3.datasource.TransferListener import androidx.media3.exoplayer.DefaultLoadControl import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.exoplayer.source.MergingMediaSource +import com.nuvio.app.features.trailer.YoutubeChunkedDataSourceFactory import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import androidx.media3.extractor.DefaultExtractorsFactory import androidx.media3.extractor.ts.DefaultTsPayloadReaderFactory diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index ed278554..d4496702 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -1586,7 +1586,8 @@ private fun MainAppContent( parentMetaType = launch.parentMetaType ?: launch.type, initialPositionMs = launch.resumePositionMs ?: 0L, initialProgressFraction = launch.resumeProgressFraction, - ) StreamsRepository.consumeAutoPlay() + ) + StreamsRepository.consumeAutoPlay() StreamsRepository.cancelLoading() if (playerSettings.externalPlayerEnabled) { openExternalPlayback(playerLaunch) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt index 67682bc3..90f45de0 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt @@ -1,6 +1,7 @@ package com.nuvio.app.features.streams import kotlinx.coroutines.runBlocking +import kotlinx.serialization.Serializable import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.getString From 73c97bfeb708365f020e72d1cae2e810132942dd Mon Sep 17 00:00:00 2001 From: paregi12 Date: Thu, 21 May 2026 15:56:42 +0530 Subject: [PATCH 14/24] feat(crypto): implement AES-GCM for iOS and enhance PBKDF2/Base64 support --- .../features/plugins/PluginCrypto.android.kt | 63 +++++- .../app/features/plugins/PluginCrypto.ios.kt | 213 +++++++++++++++--- .../cinterop/commoncrypto_shim.h | 74 ++++++ 3 files changed, 304 insertions(+), 46 deletions(-) diff --git a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.android.kt b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.android.kt index b1d4fedc..ca9d08b7 100644 --- a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.android.kt +++ b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.android.kt @@ -35,15 +35,54 @@ internal fun pluginPbkdf2( keySizeBits: Int, algorithm: String, ): ByteArray { - val normalizedAlgo = when (algorithm.uppercase()) { - "SHA256" -> "PBKDF2WithHmacSHA256" - "SHA1" -> "PBKDF2WithHmacSHA1" - else -> "PBKDF2WithHmacSHA256" + val prfAlgo = when (algorithm.uppercase()) { + "SHA256", "HMACSHA256" -> "HmacSHA256" + "SHA1", "HMACSHA1" -> "HmacSHA1" + "SHA512", "HMACSHA512" -> "HmacSHA512" + "MD5", "HMACMD5" -> "HmacMD5" + else -> "HmacSHA256" } - val factory = SecretKeyFactory.getInstance(normalizedAlgo) - val passChars = password.map { (it.toInt() and 0xFF).toChar() }.toCharArray() - val spec = PBEKeySpec(passChars, salt, iterations, keySizeBits) - return factory.generateSecret(spec).encoded + val mac = Mac.getInstance(prfAlgo) + mac.init(SecretKeySpec(password, prfAlgo)) + + val hLen = mac.macLength + val dkLen = keySizeBits / 8 + val dk = ByteArray(dkLen) + + val blocks = (dkLen + hLen - 1) / hLen + val u = ByteArray(hLen) + val t = ByteArray(hLen) + + val blockIndexBytes = ByteArray(4) + + for (i in 1..blocks) { + mac.reset() + mac.update(salt) + blockIndexBytes[0] = (i ushr 24).toByte() + blockIndexBytes[1] = (i ushr 16).toByte() + blockIndexBytes[2] = (i ushr 8).toByte() + blockIndexBytes[3] = i.toByte() + mac.update(blockIndexBytes) + + val u1 = mac.doFinal() + u1.copyInto(t) + u1.copyInto(u) + + for (j in 2..iterations) { + mac.reset() + val uj = mac.doFinal(u) + uj.copyInto(u) + for (k in 0 until hLen) { + t[k] = (t[k].toInt() xor uj[k].toInt()).toByte() + } + } + + val offset = (i - 1) * hLen + val len = minOf(hLen, dkLen - offset) + t.copyInto(dk, destinationOffset = offset, startIndex = 0, endIndex = len) + } + + return dk } internal fun pluginAesEncrypt( @@ -161,7 +200,13 @@ internal fun pluginBase64Encode(data: String): String = @OptIn(ExperimentalEncodingApi::class) internal fun pluginBase64Decode(data: String): String { - val normalized = data.trim().replace("\n", "").replace("\r", "").replace(" ", "") + var normalized = data.trim().replace("\n", "").replace("\r", "").replace(" ", "") + // Robust URL-safe base64 decoding fallback + normalized = normalized.replace("-", "+").replace("_", "/") + val padNeeded = (4 - (normalized.length % 4)) % 4 + if (padNeeded > 0) { + normalized += "=".repeat(padNeeded) + } val decoded = Base64.decode(normalized) return decoded.decodeToString() } diff --git a/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.ios.kt b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.ios.kt index d61a00e1..cfb64765 100644 --- a/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.ios.kt +++ b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.ios.kt @@ -13,30 +13,7 @@ import kotlinx.cinterop.ptr import kotlinx.cinterop.value import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi -import com.nuvio.app.features.plugins.cryptointerop.CC_MD5 -import com.nuvio.app.features.plugins.cryptointerop.CC_MD5_DIGEST_LENGTH -import com.nuvio.app.features.plugins.cryptointerop.CC_SHA1 -import com.nuvio.app.features.plugins.cryptointerop.CC_SHA1_DIGEST_LENGTH -import com.nuvio.app.features.plugins.cryptointerop.CC_SHA256 -import com.nuvio.app.features.plugins.cryptointerop.CC_SHA256_DIGEST_LENGTH -import com.nuvio.app.features.plugins.cryptointerop.CC_SHA512 -import com.nuvio.app.features.plugins.cryptointerop.CC_SHA512_DIGEST_LENGTH -import com.nuvio.app.features.plugins.cryptointerop.CCHmac -import com.nuvio.app.features.plugins.cryptointerop.kCCHmacAlgMD5 -import com.nuvio.app.features.plugins.cryptointerop.kCCHmacAlgSHA1 -import com.nuvio.app.features.plugins.cryptointerop.kCCHmacAlgSHA256 -import com.nuvio.app.features.plugins.cryptointerop.kCCHmacAlgSHA512 -import com.nuvio.app.features.plugins.cryptointerop.CCKeyDerivationPBKDF -import com.nuvio.app.features.plugins.cryptointerop.kCCPBKDF2 -import com.nuvio.app.features.plugins.cryptointerop.kCCPRFHmacAlgSHA1 -import com.nuvio.app.features.plugins.cryptointerop.kCCPRFHmacAlgSHA256 -import com.nuvio.app.features.plugins.cryptointerop.CCCrypt -import com.nuvio.app.features.plugins.cryptointerop.kCCDecrypt -import com.nuvio.app.features.plugins.cryptointerop.kCCAlgorithmAES -import com.nuvio.app.features.plugins.cryptointerop.kCCOptionECBMode -import com.nuvio.app.features.plugins.cryptointerop.kCCEncrypt -import com.nuvio.app.features.plugins.cryptointerop.kCCOptionPKCS7Padding -import com.nuvio.app.features.plugins.cryptointerop.kCCSuccess +import com.nuvio.app.features.plugins.cryptointerop.* import platform.Security.SecRandomCopyBytes import platform.Security.kSecRandomDefault @@ -86,8 +63,10 @@ internal fun pluginPbkdf2( algorithm: String, ): ByteArray { val prf = when (algorithm.uppercase()) { - "SHA256" -> kCCPRFHmacAlgSHA256 - "SHA1" -> kCCPRFHmacAlgSHA1 + "SHA256", "HMACSHA256" -> kCCPRFHmacAlgSHA256 + "SHA1", "HMACSHA1" -> kCCPRFHmacAlgSHA1 + "SHA384", "HMACSHA384" -> kCCPRFHmacAlgSHA384 + "SHA512", "HMACSHA512" -> kCCPRFHmacAlgSHA512 else -> kCCPRFHmacAlgSHA256 } @@ -130,9 +109,82 @@ internal fun pluginAesEncrypt( ): ByteArray { val isGcm = mode.uppercase().contains("GCM") if (isGcm) { - throw UnsupportedOperationException("AES-GCM Encrypt is not yet implemented on iOS") + var encryptedData: ByteArray? = null + memScoped { + val cryptorRefVar = alloc() + + key.usePinned { pinnedKey -> + iv.usePinned { pinnedIv -> + data.usePinned { pinnedData -> + val keyPtr = if (key.isNotEmpty()) pinnedKey.addressOf(0) else null + val ivPtr = if (iv.isNotEmpty()) pinnedIv.addressOf(0) else null + val dataPtr = if (data.isNotEmpty()) pinnedData.addressOf(0) else null + + val status = CCCryptorCreateWithMode( + op = kCCEncrypt, + mode = kCCModeGCM, + alg = kCCAlgorithmAES, + padding = ccNoPadding, + iv = ivPtr, + key = keyPtr, + keyLength = key.size.toULong(), + tweak = null, + tweakLength = 0UL, + numRounds = 0, + options = 0U, + cryptorRef = cryptorRefVar.ptr + ) + + if (status != kCCSuccess) { + error("CCCryptorCreateWithMode failed with status: $status") + } + + val cryptorRef = cryptorRefVar.value ?: error("Cryptor reference was null") + + try { + val cipherTextBytes = ByteArray(data.size) + cipherTextBytes.usePinned { pinnedCipher -> + val cipherPtr = if (data.isNotEmpty()) pinnedCipher.addressOf(0) else null + val cryptStatus = CCCryptorGCMEncrypt( + cryptorRef = cryptorRef, + dataIn = dataPtr, + dataInLength = data.size.toULong(), + dataOut = cipherPtr + ) + if (cryptStatus != kCCSuccess) { + error("CCCryptorGCMEncrypt failed with status: $cryptStatus") + } + } + + val tagBytes = ByteArray(16) + val tagLengthVar = alloc() + tagLengthVar.value = 16UL + + tagBytes.usePinned { pinnedTag -> + val tagPtr = pinnedTag.addressOf(0) + val finalStatus = CCCryptorGCMFinal( + cryptorRef = cryptorRef, + tag = tagPtr, + tagLength = tagLengthVar.ptr + ) + if (finalStatus != kCCSuccess) { + error("CCCryptorGCMFinal failed with status: $finalStatus") + } + } + + encryptedData = cipherTextBytes + tagBytes + } finally { + CCCryptorRelease(cryptorRef) + } + } + } + } + } + return encryptedData ?: ByteArray(0) } + val isEcb = mode.uppercase().contains("ECB") + val isNoPadding = mode.uppercase().contains("NOPADDING") val dataOutAvailable = data.size + 16 // AES block size val dataOut = ByteArray(dataOutAvailable) @@ -142,10 +194,12 @@ internal fun pluginAesEncrypt( memScoped { val dataOutMoved = alloc() - val options = if (isEcb) { - kCCOptionPKCS7Padding or kCCOptionECBMode - } else { - kCCOptionPKCS7Padding + var options = 0U + if (isEcb) { + options = options or kCCOptionECBMode + } + if (!isNoPadding) { + options = options or kCCOptionPKCS7Padding } key.usePinned { pinnedKey -> @@ -189,9 +243,87 @@ internal fun pluginAesDecrypt( ): ByteArray { val isGcm = mode.uppercase().contains("GCM") if (isGcm) { - throw UnsupportedOperationException("AES-GCM Decrypt is not yet implemented on iOS") + require(data.size >= 16) { "Data too short for GCM decryption" } + val ciphertextLen = data.size - 16 + val ciphertext = data.copyOfRange(0, ciphertextLen) + val tagBytes = data.copyOfRange(ciphertextLen, data.size) + + var decryptedData: ByteArray? = null + + memScoped { + val cryptorRefVar = alloc() + + key.usePinned { pinnedKey -> + iv.usePinned { pinnedIv -> + ciphertext.usePinned { pinnedCipher -> + tagBytes.usePinned { pinnedTag -> + val keyPtr = if (key.isNotEmpty()) pinnedKey.addressOf(0) else null + val ivPtr = if (iv.isNotEmpty()) pinnedIv.addressOf(0) else null + val cipherPtr = if (ciphertext.isNotEmpty()) pinnedCipher.addressOf(0) else null + val tagPtr = pinnedTag.addressOf(0) + + val status = CCCryptorCreateWithMode( + op = kCCDecrypt, + mode = kCCModeGCM, + alg = kCCAlgorithmAES, + padding = ccNoPadding, + iv = ivPtr, + key = keyPtr, + keyLength = key.size.toULong(), + tweak = null, + tweakLength = 0UL, + numRounds = 0, + options = 0U, + cryptorRef = cryptorRefVar.ptr + ) + + if (status != kCCSuccess) { + error("CCCryptorCreateWithMode failed with status: $status") + } + + val cryptorRef = cryptorRefVar.value ?: error("Cryptor reference was null") + + try { + val plainTextBytes = ByteArray(ciphertextLen) + plainTextBytes.usePinned { pinnedPlain -> + val plainPtr = if (ciphertextLen > 0) pinnedPlain.addressOf(0) else null + val cryptStatus = CCCryptorGCMDecrypt( + cryptorRef = cryptorRef, + dataIn = cipherPtr, + dataInLength = ciphertextLen.toULong(), + dataOut = plainPtr + ) + if (cryptStatus != kCCSuccess) { + error("CCCryptorGCMDecrypt failed with status: $cryptStatus") + } + } + + val tagLengthVar = alloc() + tagLengthVar.value = 16UL + + val finalStatus = CCCryptorGCMFinal( + cryptorRef = cryptorRef, + tag = tagPtr, + tagLength = tagLengthVar.ptr + ) + if (finalStatus != kCCSuccess) { + error("CCCryptorGCMFinal failed with status: $finalStatus (tag verification failed)") + } + + decryptedData = plainTextBytes + } finally { + CCCryptorRelease(cryptorRef) + } + } + } + } + } + } + return decryptedData ?: ByteArray(0) } + val isEcb = mode.uppercase().contains("ECB") + val isNoPadding = mode.uppercase().contains("NOPADDING") val dataOutAvailable = data.size + 16 // AES block size val dataOut = ByteArray(dataOutAvailable) @@ -201,10 +333,12 @@ internal fun pluginAesDecrypt( memScoped { val dataOutMoved = alloc() - val options = if (isEcb) { - kCCOptionPKCS7Padding or kCCOptionECBMode - } else { - kCCOptionPKCS7Padding + var options = 0U + if (isEcb) { + options = options or kCCOptionECBMode + } + if (!isNoPadding) { + options = options or kCCOptionPKCS7Padding } key.usePinned { pinnedKey -> @@ -326,7 +460,12 @@ internal fun pluginBase64Encode(data: String): String = @OptIn(ExperimentalEncodingApi::class) internal fun pluginBase64Decode(data: String): String { - val normalized = data.trim().replace("\n", "").replace("\r", "").replace(" ", "") + var normalized = data.trim().replace("\n", "").replace("\r", "").replace(" ", "") + normalized = normalized.replace("-", "+").replace("_", "/") + val padNeeded = (4 - (normalized.length % 4)) % 4 + if (padNeeded > 0) { + normalized += "=".repeat(padNeeded) + } val decoded = Base64.decode(normalized) return decoded.decodeToString() } diff --git a/composeApp/src/nativeInterop/cinterop/commoncrypto_shim.h b/composeApp/src/nativeInterop/cinterop/commoncrypto_shim.h index b2620dc1..255a0fd7 100644 --- a/composeApp/src/nativeInterop/cinterop/commoncrypto_shim.h +++ b/composeApp/src/nativeInterop/cinterop/commoncrypto_shim.h @@ -112,3 +112,77 @@ CCCryptorStatus CCCrypt( size_t dataOutAvailable, size_t *dataOutMoved ); + +typedef uint32_t CCMode; +enum { + kCCModeECB = 1, + kCCModeCBC = 2, + kCCModeCFB = 3, + kCCModeOFB = 4, + kCCModeCFB8 = 5, + kCCModeCTR = 6, + kCCModeF8 = 7, + kCCModeLRW = 8, + kCCModeOFB8 = 9, + kCCModeXTS = 10, + kCCModeRC4 = 11, + kCCModeCFB128 = 12, + kCCModeGCM = 13, + kCCModeCCM = 14, +}; + +typedef uint32_t CCPadding; +enum { + ccNoPadding = 0, + ccPKCS7Padding = 1, +}; + +typedef uint32_t CCModeOptions; + +typedef struct _CCCryptor *CCCryptorRef; + +CCCryptorStatus CCCryptorCreateWithMode( + CCOperation op, + CCMode mode, + CCAlgorithm alg, + CCPadding padding, + const void *iv, + const void *key, + size_t keyLength, + const void *tweak, + size_t tweakLength, + int numRounds, + CCModeOptions options, + CCCryptorRef *cryptorRef +); + +CCCryptorStatus CCCryptorGCMAddAAD( + CCCryptorRef cryptorRef, + const void *aData, + size_t aDataLen +); + +CCCryptorStatus CCCryptorGCMEncrypt( + CCCryptorRef cryptorRef, + const void *dataIn, + size_t dataInLength, + void *dataOut +); + +CCCryptorStatus CCCryptorGCMDecrypt( + CCCryptorRef cryptorRef, + const void *dataIn, + size_t dataInLength, + void *dataOut +); + +CCCryptorStatus CCCryptorGCMFinal( + CCCryptorRef cryptorRef, + void *tag, + size_t *tagLength +); + +CCCryptorStatus CCCryptorRelease( + CCCryptorRef cryptorRef +); + From 0a0aa00a0f349a56141fdea75887f37467bdedbd Mon Sep 17 00:00:00 2001 From: paregi12 Date: Thu, 21 May 2026 22:09:44 +0530 Subject: [PATCH 15/24] fix(plugins): use proper bitwise WordArray implementation for CryptoJS polyfill --- .../features/plugins/runtime/js/JsBindings.kt | 312 ++++++++++-------- 1 file changed, 166 insertions(+), 146 deletions(-) diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt index 66c70e86..f6f1ac1a 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt @@ -218,121 +218,81 @@ internal object JsBindings { """.trimIndent() private fun cryptoPolyfill() = """ - function __hexToWords(hex) { - var words = []; - for (var i = 0; i < hex.length; i += 8) { - var chunk = hex.substring(i, i + 8); - while (chunk.length < 8) chunk += '0'; - words.push(parseInt(chunk, 16) | 0); - } - return words; - } - - function __wordsToHex(words, sigBytes) { - var hex = ''; - for (var i = 0; i < sigBytes; i++) { - var word = words[i >>> 2] || 0; - var byte = (word >>> (24 - (i % 4) * 8)) & 0xff; - var part = byte.toString(16); - if (part.length < 2) part = '0' + part; - hex += part; - } - return hex; - } - - function __bytesToHex(bytes) { - bytes = __toUint8Array(bytes); - var hex = ''; - for (var i = 0; i < bytes.length; i++) { - var part = bytes[i].toString(16); - if (part.length < 2) part = '0' + part; - hex += part; - } - return hex; - } - - function __hexToBytes(hex) { - var normalizedHex = (hex || '').toLowerCase(); - if (normalizedHex.length % 2 !== 0) normalizedHex = '0' + normalizedHex; - var bytes = new Uint8Array(normalizedHex.length / 2); - for (var i = 0; i < normalizedHex.length; i += 2) { - bytes[i / 2] = parseInt(normalizedHex.substring(i, i + 2), 16) || 0; - } - return bytes; - } - - function __binaryStringToBytes(value) { - var text = value == null ? '' : String(value); - var bytes = new Uint8Array(text.length); - for (var i = 0; i < text.length; i++) bytes[i] = text.charCodeAt(i) & 0xff; - return bytes; - } - - function __bytesToBinaryString(bytes) { - bytes = __toUint8Array(bytes); - var out = ''; - for (var i = 0; i < bytes.length; i++) out += String.fromCharCode(bytes[i]); - return out; - } - - function __wordArrayToHex(value) { - if (!value) return ''; - if (typeof value.__hex === 'string') return value.__hex.toLowerCase(); - if (Array.isArray(value.words) && typeof value.sigBytes === 'number') { - return __wordsToHex(value.words, value.sigBytes); - } - return typeof __crypto_utf8_to_hex !== 'undefined' ? __crypto_utf8_to_hex(String(value)) : ''; - } - - function __buildWordArray(hex, utf8Override) { - var normalizedHex = (hex || '').toLowerCase(); - if (normalizedHex.length % 2 !== 0) normalizedHex = '0' + normalizedHex; - var wordArray = { - __hex: normalizedHex, - __utf8: utf8Override !== undefined ? utf8Override : (typeof __crypto_hex_to_utf8 !== 'undefined' ? __crypto_hex_to_utf8(normalizedHex) : ''), - sigBytes: normalizedHex.length / 2, - words: __hexToWords(normalizedHex), - toString: function(encoder) { - if (!encoder || encoder === CryptoJS.enc.Hex) return this.__hex; - if (encoder === CryptoJS.enc.Utf8) return this.__utf8; - if (encoder === CryptoJS.enc.Base64) return btoa(__bytesToBinaryString(__hexToBytes(this.__hex))); - return this.__hex; - }, - clamp: function() { return this; }, - concat: function(other) { - var otherHex = __wordArrayToHex(other); - this.__hex += otherHex; - this.__utf8 = typeof __crypto_hex_to_utf8 !== 'undefined' ? __crypto_hex_to_utf8(this.__hex) : ''; - this.sigBytes = this.__hex.length / 2; - this.words = __hexToWords(this.__hex); - return this; + var WordArray = { + init: function(words, sigBytes) { + words = this.words = words || []; + if (sigBytes != undefined) { + this.sigBytes = sigBytes; + } else { + this.sigBytes = words.length * 4; } - }; - return wordArray; + }, + toString: function(encoder) { + return (encoder || CryptoJS.enc.Hex).stringify(this); + }, + concat: function(wordArray) { + var thisWords = this.words; + var thatWords = wordArray.words; + var thisSigBytes = this.sigBytes; + var thatSigBytes = wordArray.sigBytes; + + this.clamp(); + + if (thisSigBytes % 4) { + for (var i = 0; i < thatSigBytes; i++) { + var thatByte = (thatWords[i >>> 2] >>> (24 - (i % 4) * 8)) & 0xff; + thisWords[(thisSigBytes + i) >>> 2] |= thatByte << (24 - ((thisSigBytes + i) % 4) * 8); + } + } else { + for (var j = 0; j < thatSigBytes; j += 4) { + thisWords[(thisSigBytes + j) >>> 2] = thatWords[j >>> 2]; + } + } + this.sigBytes += thatSigBytes; + return this; + }, + clamp: function() { + var words = this.words; + var sigBytes = this.sigBytes; + words[sigBytes >>> 2] &= 0xffffffff << (32 - (sigBytes % 4) * 8); + words.length = Math.ceil(sigBytes / 4); + return this; + }, + clone: function() { + var clone = Object.create(WordArray); + clone.init(this.words.slice(0), this.sigBytes); + return clone; + } + }; + + function __wordArrayCreate(words, sigBytes) { + var wa = Object.create(WordArray); + wa.init(words, sigBytes); + return wa; } - function __wordArrayFromHex(hex) { return __buildWordArray(hex, undefined); } - function __wordArrayFromUtf8(text) { - var utf8 = text == null ? '' : String(text); - var hex = typeof __crypto_utf8_to_hex !== 'undefined' ? __crypto_utf8_to_hex(utf8) : ''; - return __buildWordArray(hex, utf8); - } - function __wordArrayFromBase64(base64) { - return __buildWordArray(__bytesToHex(__binaryStringToBytes(atob(base64 || ''))), undefined); + function __wordArrayToBytes(wordArray) { + var bytes = new Uint8Array(wordArray.sigBytes); + for (var i = 0; i < wordArray.sigBytes; i++) { + bytes[i] = (wordArray.words[i >>> 2] >>> (24 - (i % 4) * 8)) & 0xff; + } + return bytes; } - function __wordArrayToBytes(value) { - return __hexToBytes(__wordArrayToHex(value)); + function __bytesToWordArray(bytes) { + var words = []; + for (var i = 0; i < bytes.length; i++) { + words[i >>> 2] |= (bytes[i] & 0xff) << (24 - (i % 4) * 8); + } + return __wordArrayCreate(words, bytes.length); } function __normalizeWordArrayInput(value) { - if (value && typeof value === 'object' && typeof value.__utf8 === 'string') return value.__utf8; - if (value && typeof value === 'object' && typeof value.__hex === 'string') return typeof __crypto_hex_to_utf8 !== 'undefined' ? __crypto_hex_to_utf8(value.__hex) : ''; if (value && typeof value === 'object' && Array.isArray(value.words) && typeof value.sigBytes === 'number') { - return typeof __crypto_hex_to_utf8 !== 'undefined' ? __crypto_hex_to_utf8(__wordsToHex(value.words, value.sigBytes)) : ''; + return __wordArrayToBytes(value); } - if (value == null) return ''; - return String(value); + if (typeof value === 'string') return new TextEncoder().encode(value); + return __toUint8Array(value); } function __toUint8Array(data) { @@ -342,46 +302,106 @@ internal object JsBindings { return new Uint8Array(0); } - function __bufferToUint8(data) { - if (data instanceof Uint8Array) return data; - if (data instanceof ArrayBuffer) return new Uint8Array(data); - if (typeof data === 'string') return new TextEncoder().encode(data); - return new Uint8Array(0); - } - var CryptoJS = { enc: { - Hex: { stringify: function(wa) { return __wordArrayToHex(wa); }, parse: function(s) { return __wordArrayFromHex(s); } }, - Utf8: { stringify: function(wa) { return wa.toString(CryptoJS.enc.Utf8); }, parse: function(s) { return __wordArrayFromUtf8(s); } }, - Base64: { stringify: function(wa) { return wa.toString(CryptoJS.enc.Base64); }, parse: function(s) { return __wordArrayFromBase64(s); } } + Hex: { + stringify: function(wordArray) { + var words = wordArray.words; + var sigBytes = wordArray.sigBytes; + var hexChars = []; + for (var i = 0; i < sigBytes; i++) { + var bite = (words[i >>> 2] >>> (24 - (i % 4) * 8)) & 0xff; + var hexStr = bite.toString(16); + if (hexStr.length < 2) hexStr = '0' + hexStr; + hexChars.push(hexStr); + } + return hexChars.join(''); + }, + parse: function(hexStr) { + var hexStrLength = hexStr.length; + var words = []; + for (var i = 0; i < hexStrLength; i += 2) { + words[i >>> 3] |= parseInt(hexStr.substr(i, 2), 16) << (24 - (i % 8) * 4); + } + return __wordArrayCreate(words, hexStrLength / 2); + } + }, + Utf8: { + stringify: function(wordArray) { + return new TextDecoder('utf-8').decode(__wordArrayToBytes(wordArray)); + }, + parse: function(utf8Str) { + return __bytesToWordArray(new TextEncoder().encode(String(utf8Str))); + } + }, + Base64: { + stringify: function(wordArray) { + var bytes = __wordArrayToBytes(wordArray); + var binaryStr = ''; + for (var j = 0; j < bytes.length; j++) { + binaryStr += String.fromCharCode(bytes[j]); + } + return btoa(binaryStr); + }, + parse: function(base64Str) { + var binaryStr = atob(String(base64Str || '')); + var bytes = new Uint8Array(binaryStr.length); + for (var i = 0; i < binaryStr.length; i++) { + bytes[i] = binaryStr.charCodeAt(i) & 0xff; + } + return __bytesToWordArray(bytes); + } + } + }, + lib: { + WordArray: { + create: function(words, sigBytes) { + return __wordArrayCreate(words, sigBytes); + } + } }, - lib: { WordArray: { create: function(words, sigBytes) { return __buildWordArray(__wordsToHex(words, sigBytes), undefined); } } }, mode: { CBC: 'AES-CBC', GCM: 'AES-GCM', ECB: 'AES-ECB' }, pad: { Pkcs7: 'Pkcs7', NoPadding: 'NoPadding' }, algo: { SHA256: 'SHA256' }, - MD5: function(m) { return __wordArrayFromHex(__crypto_digest_hex('MD5', __normalizeWordArrayInput(m))); }, - SHA1: function(m) { return __wordArrayFromHex(__crypto_digest_hex('SHA1', __normalizeWordArrayInput(m))); }, - SHA256: function(m) { return __wordArrayFromHex(__crypto_digest_hex('SHA256', __normalizeWordArrayInput(m))); }, - SHA512: function(m) { return __wordArrayFromHex(__crypto_digest_hex('SHA512', __normalizeWordArrayInput(m))); }, + MD5: function(m) { + var bytes = __normalizeWordArrayInput(m); + var res = typeof __crypto_digest_raw !== 'undefined' ? __crypto_digest_raw('MD5', bytes) : new Uint8Array(0); + return __bytesToWordArray(res); + }, + SHA1: function(m) { + var bytes = __normalizeWordArrayInput(m); + var res = typeof __crypto_digest_raw !== 'undefined' ? __crypto_digest_raw('SHA1', bytes) : new Uint8Array(0); + return __bytesToWordArray(res); + }, + SHA256: function(m) { + var bytes = __normalizeWordArrayInput(m); + var res = typeof __crypto_digest_raw !== 'undefined' ? __crypto_digest_raw('SHA256', bytes) : new Uint8Array(0); + return __bytesToWordArray(res); + }, + SHA512: function(m) { + var bytes = __normalizeWordArrayInput(m); + var res = typeof __crypto_digest_raw !== 'undefined' ? __crypto_digest_raw('SHA512', bytes) : new Uint8Array(0); + return __bytesToWordArray(res); + }, PBKDF2: function(pass, salt, options) { options = options || {}; - var pBytes = __bufferToUint8(__normalizeWordArrayInput(pass)); - var sBytes = __bufferToUint8(__normalizeWordArrayInput(salt)); + var pBytes = __normalizeWordArrayInput(pass); + var sBytes = __normalizeWordArrayInput(salt); var iter = options.iterations || 1000; var kSize = options.keySize || (256/32); var algo = options.hasher === CryptoJS.algo.SHA256 ? 'SHA256' : 'SHA1'; var resBytes = typeof __crypto_pbkdf2_raw !== 'undefined' ? __crypto_pbkdf2_raw(pBytes, sBytes, iter, kSize * 32, algo) : new Uint8Array(0); - return __wordArrayFromHex(__bytesToHex(resBytes)); + return __bytesToWordArray(resBytes); }, AES: { encrypt: function(message, key, options) { options = options || {}; - var data = __bufferToUint8(__normalizeWordArrayInput(message)); + var data = __normalizeWordArrayInput(message); var kBytes = __wordArrayToBytes(key); - var ivBytes = __wordArrayToBytes(options.iv || ''); + var ivBytes = options.iv ? __wordArrayToBytes(options.iv) : new Uint8Array(0); var mode = options.mode || 'AES-CBC'; var resBytes = typeof __crypto_aes_encrypt_raw !== 'undefined' ? __crypto_aes_encrypt_raw(mode, kBytes, ivBytes, data) : new Uint8Array(0); - var wa = __wordArrayFromHex(__bytesToHex(resBytes)); + var wa = __bytesToWordArray(resBytes); return { ciphertext: wa, toString: function() { return wa.toString(CryptoJS.enc.Base64); } @@ -390,10 +410,10 @@ internal object JsBindings { decrypt: function(cipher, key, options) { options = options || {}; var data = typeof cipher === 'string' - ? __binaryStringToBytes(atob(cipher)) - : (cipher.ciphertext ? __wordArrayToBytes(cipher.ciphertext) : __bufferToUint8(cipher)); + ? new Uint8Array(Array.from(atob(cipher), c => c.charCodeAt(0))) + : (cipher.ciphertext ? __wordArrayToBytes(cipher.ciphertext) : __toUint8Array(cipher)); var kBytes = __wordArrayToBytes(key); - var ivBytes = __wordArrayToBytes(options.iv || ''); + var ivBytes = options.iv ? __wordArrayToBytes(options.iv) : new Uint8Array(0); var mode = options.mode || 'AES-CBC'; var resBytes = typeof __crypto_aes_decrypt_raw !== 'undefined' ? __crypto_aes_decrypt_raw(mode, kBytes, ivBytes, data) : new Uint8Array(0); var plain = new TextDecoder().decode(resBytes); @@ -406,50 +426,50 @@ internal object JsBindings { globalThis.crypto = { subtle: { digest: async function(algo, data) { - var bytes = __bufferToUint8(data); + var bytes = __toUint8Array(data); var res = typeof __crypto_digest_raw !== 'undefined' ? __crypto_digest_raw(algo.name || algo, bytes) : new Uint8Array(0); return __toUint8Array(res).buffer; }, importKey: async function(fmt, data, algo, ext, use) { return { _raw: data, _algo: algo }; }, deriveBits: async function(params, key, len) { - var pBytes = __bufferToUint8(key._raw); - var sBytes = __bufferToUint8(params.salt); + var pBytes = __toUint8Array(key._raw); + var sBytes = __toUint8Array(params.salt); var res = typeof __crypto_pbkdf2_raw !== 'undefined' ? __crypto_pbkdf2_raw(pBytes, sBytes, params.iterations, len, params.hash) : new Uint8Array(0); return __toUint8Array(res).buffer; }, encrypt: async function(params, key, data) { - var kBytes = __bufferToUint8(key._raw); - var ivBytes = __bufferToUint8(params.iv || ''); - var dBytes = __bufferToUint8(data); + var kBytes = __toUint8Array(key._raw); + var ivBytes = __toUint8Array(params.iv || new Uint8Array(0)); + var dBytes = __toUint8Array(data); var res = typeof __crypto_aes_encrypt_raw !== 'undefined' ? __crypto_aes_encrypt_raw(params.name, kBytes, ivBytes, dBytes) : new Uint8Array(0); return __toUint8Array(res).buffer; }, decrypt: async function(params, key, data) { - var kBytes = __bufferToUint8(key._raw); - var ivBytes = __bufferToUint8(params.iv || ''); - var dBytes = __bufferToUint8(data); + var kBytes = __toUint8Array(key._raw); + var ivBytes = __toUint8Array(params.iv || new Uint8Array(0)); + var dBytes = __toUint8Array(data); var res = typeof __crypto_aes_decrypt_raw !== 'undefined' ? __crypto_aes_decrypt_raw(params.name, kBytes, ivBytes, dBytes) : new Uint8Array(0); return __toUint8Array(res).buffer; }, sign: async function(algo, key, data) { var algoName = typeof algo === 'string' ? algo : (algo.name || ''); - var kBytes = __bufferToUint8(key._raw); - var dBytes = __bufferToUint8(data); + var kBytes = __toUint8Array(key._raw); + var dBytes = __toUint8Array(data); var res = typeof __crypto_sign_raw !== 'undefined' ? __crypto_sign_raw(algoName, kBytes, dBytes) : new Uint8Array(0); return __toUint8Array(res).buffer; }, verify: async function(algo, key, sig, data) { var algoName = typeof algo === 'string' ? algo : (algo.name || ''); - var kBytes = __bufferToUint8(key._raw); - var sBytes = __bufferToUint8(sig); - var dBytes = __bufferToUint8(data); + var kBytes = __toUint8Array(key._raw); + var sBytes = __toUint8Array(sig); + var dBytes = __toUint8Array(data); return typeof __crypto_verify_raw !== 'undefined' ? __crypto_verify_raw(algoName, kBytes, sBytes, dBytes) : false; } }, getRandomValues: function(arr) { - - var bytes = typeof __crypto_get_random_values !== 'undefined' ? __crypto_get_random_values(arr.length) : new Uint8Array(arr.length); - for (var i = 0; i < arr.length; i++) arr[i] = bytes[i]; + if (!arr || !arr.length) return arr; + var res = typeof __crypto_get_random_values !== 'undefined' ? __crypto_get_random_values(arr.length) : new Uint8Array(arr.length); + for (var i = 0; i < arr.length; i++) arr[i] = res[i]; return arr; } }; From e664606adc71c686a4752467a238fa12e1338b08 Mon Sep 17 00:00:00 2001 From: paregi12 Date: Tue, 2 Jun 2026 10:32:20 +0530 Subject: [PATCH 16/24] fix(plugins): pass underlying ArrayBuffers to JNI to resolve typed array conversion bug --- .../features/plugins/runtime/js/JsBindings.kt | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt index f6f1ac1a..c4774b7e 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt @@ -365,22 +365,22 @@ internal object JsBindings { algo: { SHA256: 'SHA256' }, MD5: function(m) { var bytes = __normalizeWordArrayInput(m); - var res = typeof __crypto_digest_raw !== 'undefined' ? __crypto_digest_raw('MD5', bytes) : new Uint8Array(0); + var res = typeof __crypto_digest_raw !== 'undefined' ? __crypto_digest_raw('MD5', bytes.buffer) : new Uint8Array(0); return __bytesToWordArray(res); }, SHA1: function(m) { var bytes = __normalizeWordArrayInput(m); - var res = typeof __crypto_digest_raw !== 'undefined' ? __crypto_digest_raw('SHA1', bytes) : new Uint8Array(0); + var res = typeof __crypto_digest_raw !== 'undefined' ? __crypto_digest_raw('SHA1', bytes.buffer) : new Uint8Array(0); return __bytesToWordArray(res); }, SHA256: function(m) { var bytes = __normalizeWordArrayInput(m); - var res = typeof __crypto_digest_raw !== 'undefined' ? __crypto_digest_raw('SHA256', bytes) : new Uint8Array(0); + var res = typeof __crypto_digest_raw !== 'undefined' ? __crypto_digest_raw('SHA256', bytes.buffer) : new Uint8Array(0); return __bytesToWordArray(res); }, SHA512: function(m) { var bytes = __normalizeWordArrayInput(m); - var res = typeof __crypto_digest_raw !== 'undefined' ? __crypto_digest_raw('SHA512', bytes) : new Uint8Array(0); + var res = typeof __crypto_digest_raw !== 'undefined' ? __crypto_digest_raw('SHA512', bytes.buffer) : new Uint8Array(0); return __bytesToWordArray(res); }, PBKDF2: function(pass, salt, options) { @@ -390,7 +390,7 @@ internal object JsBindings { var iter = options.iterations || 1000; var kSize = options.keySize || (256/32); var algo = options.hasher === CryptoJS.algo.SHA256 ? 'SHA256' : 'SHA1'; - var resBytes = typeof __crypto_pbkdf2_raw !== 'undefined' ? __crypto_pbkdf2_raw(pBytes, sBytes, iter, kSize * 32, algo) : new Uint8Array(0); + var resBytes = typeof __crypto_pbkdf2_raw !== 'undefined' ? __crypto_pbkdf2_raw(pBytes.buffer, sBytes.buffer, iter, kSize * 32, algo) : new Uint8Array(0); return __bytesToWordArray(resBytes); }, AES: { @@ -400,7 +400,7 @@ internal object JsBindings { var kBytes = __wordArrayToBytes(key); var ivBytes = options.iv ? __wordArrayToBytes(options.iv) : new Uint8Array(0); var mode = options.mode || 'AES-CBC'; - var resBytes = typeof __crypto_aes_encrypt_raw !== 'undefined' ? __crypto_aes_encrypt_raw(mode, kBytes, ivBytes, data) : new Uint8Array(0); + var resBytes = typeof __crypto_aes_encrypt_raw !== 'undefined' ? __crypto_aes_encrypt_raw(mode, kBytes.buffer, ivBytes.buffer, data.buffer) : new Uint8Array(0); var wa = __bytesToWordArray(resBytes); return { ciphertext: wa, @@ -415,7 +415,7 @@ internal object JsBindings { var kBytes = __wordArrayToBytes(key); var ivBytes = options.iv ? __wordArrayToBytes(options.iv) : new Uint8Array(0); var mode = options.mode || 'AES-CBC'; - var resBytes = typeof __crypto_aes_decrypt_raw !== 'undefined' ? __crypto_aes_decrypt_raw(mode, kBytes, ivBytes, data) : new Uint8Array(0); + var resBytes = typeof __crypto_aes_decrypt_raw !== 'undefined' ? __crypto_aes_decrypt_raw(mode, kBytes.buffer, ivBytes.buffer, data.buffer) : new Uint8Array(0); var plain = new TextDecoder().decode(resBytes); return { toString: function(enc) { return plain; } }; } @@ -427,35 +427,35 @@ internal object JsBindings { subtle: { digest: async function(algo, data) { var bytes = __toUint8Array(data); - var res = typeof __crypto_digest_raw !== 'undefined' ? __crypto_digest_raw(algo.name || algo, bytes) : new Uint8Array(0); + var res = typeof __crypto_digest_raw !== 'undefined' ? __crypto_digest_raw(algo.name || algo, bytes.buffer) : new Uint8Array(0); return __toUint8Array(res).buffer; }, importKey: async function(fmt, data, algo, ext, use) { return { _raw: data, _algo: algo }; }, deriveBits: async function(params, key, len) { var pBytes = __toUint8Array(key._raw); var sBytes = __toUint8Array(params.salt); - var res = typeof __crypto_pbkdf2_raw !== 'undefined' ? __crypto_pbkdf2_raw(pBytes, sBytes, params.iterations, len, params.hash) : new Uint8Array(0); + var res = typeof __crypto_pbkdf2_raw !== 'undefined' ? __crypto_pbkdf2_raw(pBytes.buffer, sBytes.buffer, params.iterations, len, params.hash) : new Uint8Array(0); return __toUint8Array(res).buffer; }, encrypt: async function(params, key, data) { var kBytes = __toUint8Array(key._raw); var ivBytes = __toUint8Array(params.iv || new Uint8Array(0)); var dBytes = __toUint8Array(data); - var res = typeof __crypto_aes_encrypt_raw !== 'undefined' ? __crypto_aes_encrypt_raw(params.name, kBytes, ivBytes, dBytes) : new Uint8Array(0); + var res = typeof __crypto_aes_encrypt_raw !== 'undefined' ? __crypto_aes_encrypt_raw(params.name, kBytes.buffer, ivBytes.buffer, dBytes.buffer) : new Uint8Array(0); return __toUint8Array(res).buffer; }, decrypt: async function(params, key, data) { var kBytes = __toUint8Array(key._raw); var ivBytes = __toUint8Array(params.iv || new Uint8Array(0)); var dBytes = __toUint8Array(data); - var res = typeof __crypto_aes_decrypt_raw !== 'undefined' ? __crypto_aes_decrypt_raw(params.name, kBytes, ivBytes, dBytes) : new Uint8Array(0); + var res = typeof __crypto_aes_decrypt_raw !== 'undefined' ? __crypto_aes_decrypt_raw(params.name, kBytes.buffer, ivBytes.buffer, dBytes.buffer) : new Uint8Array(0); return __toUint8Array(res).buffer; }, sign: async function(algo, key, data) { var algoName = typeof algo === 'string' ? algo : (algo.name || ''); var kBytes = __toUint8Array(key._raw); var dBytes = __toUint8Array(data); - var res = typeof __crypto_sign_raw !== 'undefined' ? __crypto_sign_raw(algoName, kBytes, dBytes) : new Uint8Array(0); + var res = typeof __crypto_sign_raw !== 'undefined' ? __crypto_sign_raw(algoName, kBytes.buffer, dBytes.buffer) : new Uint8Array(0); return __toUint8Array(res).buffer; }, verify: async function(algo, key, sig, data) { @@ -463,7 +463,7 @@ internal object JsBindings { var kBytes = __toUint8Array(key._raw); var sBytes = __toUint8Array(sig); var dBytes = __toUint8Array(data); - return typeof __crypto_verify_raw !== 'undefined' ? __crypto_verify_raw(algoName, kBytes, sBytes, dBytes) : false; + return typeof __crypto_verify_raw !== 'undefined' ? __crypto_verify_raw(algoName, kBytes.buffer, sBytes.buffer, dBytes.buffer) : false; } }, getRandomValues: function(arr) { From 566966f3f0eacb09e3475306d780634f93fa2978 Mon Sep 17 00:00:00 2001 From: paregi12 Date: Tue, 2 Jun 2026 13:51:05 +0530 Subject: [PATCH 17/24] fix(plugins): repair crypto polyfills --- .../features/plugins/PluginCrypto.android.kt | 101 ++-- .../plugins/runtime/crypto/CryptoBridge.kt | 87 ++- .../features/plugins/runtime/js/JsBindings.kt | 502 +++++++++++++----- .../app/features/plugins/PluginCrypto.ios.kt | 116 ++-- 4 files changed, 576 insertions(+), 230 deletions(-) diff --git a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.android.kt b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.android.kt index ca9d08b7..b899e154 100644 --- a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.android.kt +++ b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.android.kt @@ -8,10 +8,8 @@ import java.security.spec.PKCS8EncodedKeySpec import java.security.spec.X509EncodedKeySpec import javax.crypto.Cipher import javax.crypto.Mac -import javax.crypto.SecretKeyFactory import javax.crypto.spec.GCMParameterSpec import javax.crypto.spec.IvParameterSpec -import javax.crypto.spec.PBEKeySpec import javax.crypto.spec.SecretKeySpec import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi @@ -19,13 +17,14 @@ import kotlin.io.encoding.ExperimentalEncodingApi private val secureRandom = SecureRandom() internal fun pluginGetRandomValues(length: Int): ByteArray { + require(length >= 0) { "Random byte length must be non-negative" } val bytes = ByteArray(length) secureRandom.nextBytes(bytes) return bytes } internal fun pluginDigest(algorithm: String, data: ByteArray): ByteArray { - return MessageDigest.getInstance(algorithm.uppercase()).digest(data) + return MessageDigest.getInstance(normalizeDigestAlgorithm(algorithm)).digest(data) } internal fun pluginPbkdf2( @@ -35,13 +34,10 @@ internal fun pluginPbkdf2( keySizeBits: Int, algorithm: String, ): ByteArray { - val prfAlgo = when (algorithm.uppercase()) { - "SHA256", "HMACSHA256" -> "HmacSHA256" - "SHA1", "HMACSHA1" -> "HmacSHA1" - "SHA512", "HMACSHA512" -> "HmacSHA512" - "MD5", "HMACMD5" -> "HmacMD5" - else -> "HmacSHA256" - } + require(iterations > 0) { "PBKDF2 iterations must be positive" } + require(keySizeBits > 0 && keySizeBits % 8 == 0) { "PBKDF2 key size must be a positive byte-aligned bit length" } + + val prfAlgo = normalizeHmacAlgorithm(algorithm) val mac = Mac.getInstance(prfAlgo) mac.init(SecretKeySpec(password, prfAlgo)) @@ -91,13 +87,11 @@ internal fun pluginAesEncrypt( iv: ByteArray, data: ByteArray, ): ByteArray { - val normalizedMode = when (mode.uppercase()) { - "AES-CBC", "CBC" -> "AES/CBC/PKCS5Padding" - "AES-GCM", "GCM" -> "AES/GCM/NoPadding" - "AES-ECB", "ECB" -> "AES/ECB/PKCS5Padding" - else -> "AES/CBC/PKCS5Padding" + val normalizedMode = normalizeAesTransformation(mode) + requireValidAesKey(key) + if (!normalizedMode.contains("ECB")) { + require(iv.isNotEmpty()) { "AES mode $mode requires an IV" } } - val cipher = Cipher.getInstance(normalizedMode) val keySpec = SecretKeySpec(key, "AES") @@ -120,13 +114,11 @@ internal fun pluginAesDecrypt( iv: ByteArray, data: ByteArray, ): ByteArray { - val normalizedMode = when (mode.uppercase()) { - "AES-CBC", "CBC" -> "AES/CBC/PKCS5Padding" - "AES-GCM", "GCM" -> "AES/GCM/NoPadding" - "AES-ECB", "ECB" -> "AES/ECB/PKCS5Padding" - else -> "AES/CBC/PKCS5Padding" + val normalizedMode = normalizeAesTransformation(mode) + requireValidAesKey(key) + if (!normalizedMode.contains("ECB")) { + require(iv.isNotEmpty()) { "AES mode $mode requires an IV" } } - val cipher = Cipher.getInstance(normalizedMode) val keySpec = SecretKeySpec(key, "AES") @@ -178,22 +170,67 @@ internal fun pluginDigestHex(algorithm: String, data: String): String { } } -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") - } +internal fun pluginHmac(algorithm: String, key: ByteArray, data: ByteArray): ByteArray { + val normalized = normalizeHmacAlgorithm(algorithm) val mac = Mac.getInstance(normalized) - mac.init(SecretKeySpec(key.encodeToByteArray(), normalized)) - val digest = mac.doFinal(data.encodeToByteArray()) + mac.init(SecretKeySpec(key, normalized)) + return mac.doFinal(data) +} + +internal fun pluginHmacHex(algorithm: String, key: String, data: String): String { + val digest = pluginHmac(algorithm, key.encodeToByteArray(), data.encodeToByteArray()) return digest.joinToString(separator = "") { byte -> byte.toUByte().toString(16).padStart(2, '0') } } +private fun normalizeDigestAlgorithm(algorithm: String): String { + return when (algorithm.normalizedAlgorithmToken()) { + "MD5" -> "MD5" + "SHA1" -> "SHA-1" + "SHA256" -> "SHA-256" + "SHA384" -> "SHA-384" + "SHA512" -> "SHA-512" + else -> error("Unsupported digest algorithm: $algorithm") + } +} + +private fun normalizeHmacAlgorithm(algorithm: String): String { + return when (algorithm.normalizedAlgorithmToken().removePrefix("HMAC")) { + "MD5" -> "HmacMD5" + "SHA1" -> "HmacSHA1" + "SHA256" -> "HmacSHA256" + "SHA384" -> "HmacSHA384" + "SHA512" -> "HmacSHA512" + else -> error("Unsupported HMAC algorithm: $algorithm") + } +} + +private fun normalizeAesTransformation(mode: String): String { + val normalized = mode.normalizedAlgorithmToken() + val noPadding = normalized.contains("NOPADDING") + val padding = if (noPadding) "NoPadding" else "PKCS5Padding" + return when { + normalized.contains("GCM") -> "AES/GCM/NoPadding" + normalized.contains("ECB") -> "AES/ECB/$padding" + normalized.contains("CBC") -> "AES/CBC/$padding" + else -> "AES/CBC/$padding" + } +} + +private fun requireValidAesKey(key: ByteArray) { + require(key.size == 16 || key.size == 24 || key.size == 32) { + "AES key must be 16, 24, or 32 bytes" + } +} + +private fun String.normalizedAlgorithmToken(): String = + uppercase() + .replace("-", "") + .replace("_", "") + .replace("/", "") + .replace(" ", "") + @OptIn(ExperimentalEncodingApi::class) internal fun pluginBase64Encode(data: String): String = Base64.encode(data.encodeToByteArray()) diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/crypto/CryptoBridge.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/crypto/CryptoBridge.kt index 0cbe87ce..a6754c67 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/crypto/CryptoBridge.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/crypto/CryptoBridge.kt @@ -9,8 +9,10 @@ import com.nuvio.app.features.plugins.pluginBase64Encode import com.nuvio.app.features.plugins.pluginBase64Decode import com.nuvio.app.features.plugins.pluginUtf8ToHex import com.nuvio.app.features.plugins.pluginHexToUtf8 +import com.nuvio.app.features.plugins.pluginHexToByteArray import com.nuvio.app.features.plugins.pluginGetRandomValues import com.nuvio.app.features.plugins.pluginDigest +import com.nuvio.app.features.plugins.pluginHmac import com.nuvio.app.features.plugins.pluginPbkdf2 import com.nuvio.app.features.plugins.pluginAesDecrypt import com.nuvio.app.features.plugins.pluginAesEncrypt @@ -19,71 +21,63 @@ import com.nuvio.app.features.plugins.pluginVerify internal class CryptoBridge : HostModule { override fun register(runtime: QuickJs) { - // --- Binary-Safe Bridges (New) --- - - runtime.function("__crypto_get_random_values") { args -> + // Hex transport keeps binary data stable across the QuickJS/native bridge. + runtime.function("__crypto_get_random_values_hex") { args -> val length = (args.getOrNull(0) as? Number)?.toInt() ?: 0 - runCatching { - pluginGetRandomValues(length) - }.getOrElse { ByteArray(0) } + pluginGetRandomValues(length).toHexString() } - runtime.function("__crypto_digest_raw") { args -> + runtime.function("__crypto_digest_hex_raw") { args -> val algorithm = args.getOrNull(0)?.toString() ?: "SHA256" - val data = args.getOrNull(1) as? ByteArray ?: ByteArray(0) - runCatching { - pluginDigest(algorithm, data) - }.getOrElse { ByteArray(0) } + val data = pluginHexToByteArray(args.getOrNull(1)?.toString() ?: "") + pluginDigest(algorithm, data).toHexString() } - runtime.function("__crypto_pbkdf2_raw") { args -> - val password = args.getOrNull(0) as? ByteArray ?: ByteArray(0) - val salt = args.getOrNull(1) as? ByteArray ?: ByteArray(0) + runtime.function("__crypto_hmac_hex_raw") { args -> + val algorithm = args.getOrNull(0)?.toString() ?: "SHA256" + val key = pluginHexToByteArray(args.getOrNull(1)?.toString() ?: "") + val data = pluginHexToByteArray(args.getOrNull(2)?.toString() ?: "") + pluginHmac(algorithm, key, data).toHexString() + } + + runtime.function("__crypto_pbkdf2_hex") { args -> + val password = pluginHexToByteArray(args.getOrNull(0)?.toString() ?: "") + val salt = pluginHexToByteArray(args.getOrNull(1)?.toString() ?: "") val iterations = (args.getOrNull(2) as? Number)?.toInt() ?: 1000 val keySizeBits = (args.getOrNull(3) as? Number)?.toInt() ?: 256 val algorithm = args.getOrNull(4)?.toString() ?: "SHA256" - runCatching { - pluginPbkdf2(password, salt, iterations, keySizeBits, algorithm) - }.getOrElse { ByteArray(0) } + pluginPbkdf2(password, salt, iterations, keySizeBits, algorithm).toHexString() } - runtime.function("__crypto_aes_encrypt_raw") { args -> + runtime.function("__crypto_aes_encrypt_hex") { args -> val mode = args.getOrNull(0)?.toString() ?: "AES-CBC" - val key = args.getOrNull(1) as? ByteArray ?: ByteArray(0) - val iv = args.getOrNull(2) as? ByteArray ?: ByteArray(0) - val data = args.getOrNull(3) as? ByteArray ?: ByteArray(0) - runCatching { - pluginAesEncrypt(mode, key, iv, data) - }.getOrElse { ByteArray(0) } + val key = pluginHexToByteArray(args.getOrNull(1)?.toString() ?: "") + val iv = pluginHexToByteArray(args.getOrNull(2)?.toString() ?: "") + val data = pluginHexToByteArray(args.getOrNull(3)?.toString() ?: "") + pluginAesEncrypt(mode, key, iv, data).toHexString() } - runtime.function("__crypto_aes_decrypt_raw") { args -> + runtime.function("__crypto_aes_decrypt_hex") { args -> val mode = args.getOrNull(0)?.toString() ?: "AES-CBC" - val key = args.getOrNull(1) as? ByteArray ?: ByteArray(0) - val iv = args.getOrNull(2) as? ByteArray ?: ByteArray(0) - val data = args.getOrNull(3) as? ByteArray ?: ByteArray(0) - runCatching { - pluginAesDecrypt(mode, key, iv, data) - }.getOrElse { ByteArray(0) } + val key = pluginHexToByteArray(args.getOrNull(1)?.toString() ?: "") + val iv = pluginHexToByteArray(args.getOrNull(2)?.toString() ?: "") + val data = pluginHexToByteArray(args.getOrNull(3)?.toString() ?: "") + pluginAesDecrypt(mode, key, iv, data).toHexString() } - runtime.function("__crypto_sign_raw") { args -> + runtime.function("__crypto_sign_hex") { args -> val algorithm = args.getOrNull(0)?.toString() ?: "" - val privateKey = args.getOrNull(1) as? ByteArray ?: ByteArray(0) - val data = args.getOrNull(2) as? ByteArray ?: ByteArray(0) - runCatching { - pluginSign(algorithm, privateKey, data) - }.getOrElse { ByteArray(0) } + val privateKey = pluginHexToByteArray(args.getOrNull(1)?.toString() ?: "") + val data = pluginHexToByteArray(args.getOrNull(2)?.toString() ?: "") + pluginSign(algorithm, privateKey, data).toHexString() } - runtime.function("__crypto_verify_raw") { args -> + runtime.function("__crypto_verify_hex") { args -> val algorithm = args.getOrNull(0)?.toString() ?: "" - val publicKey = args.getOrNull(1) as? ByteArray ?: ByteArray(0) - val signature = args.getOrNull(2) as? ByteArray ?: ByteArray(0) - val data = args.getOrNull(3) as? ByteArray ?: ByteArray(0) - runCatching { - pluginVerify(algorithm, publicKey, signature, data) - }.getOrDefault(false) + val publicKey = pluginHexToByteArray(args.getOrNull(1)?.toString() ?: "") + val signature = pluginHexToByteArray(args.getOrNull(2)?.toString() ?: "") + val data = pluginHexToByteArray(args.getOrNull(3)?.toString() ?: "") + pluginVerify(algorithm, publicKey, signature, data) } // --- Legacy Hex/String Bridges (Backward Compatibility) --- @@ -134,3 +128,8 @@ internal class CryptoBridge : HostModule { } } } + +private fun ByteArray.toHexString(): String = + joinToString(separator = "") { byte -> + byte.toUByte().toString(16).padStart(2, '0') + } diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt index c4774b7e..5b987ca5 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt @@ -220,12 +220,8 @@ internal object JsBindings { private fun cryptoPolyfill() = """ var WordArray = { init: function(words, sigBytes) { - words = this.words = words || []; - if (sigBytes != undefined) { - this.sigBytes = sigBytes; - } else { - this.sigBytes = words.length * 4; - } + this.words = words || []; + this.sigBytes = sigBytes != undefined ? sigBytes : this.words.length * 4; }, toString: function(encoder) { return (encoder || CryptoJS.enc.Hex).stringify(this); @@ -238,15 +234,9 @@ internal object JsBindings { this.clamp(); - if (thisSigBytes % 4) { - for (var i = 0; i < thatSigBytes; i++) { - var thatByte = (thatWords[i >>> 2] >>> (24 - (i % 4) * 8)) & 0xff; - thisWords[(thisSigBytes + i) >>> 2] |= thatByte << (24 - ((thisSigBytes + i) % 4) * 8); - } - } else { - for (var j = 0; j < thatSigBytes; j += 4) { - thisWords[(thisSigBytes + j) >>> 2] = thatWords[j >>> 2]; - } + for (var i = 0; i < thatSigBytes; i++) { + var thatByte = (thatWords[i >>> 2] >>> (24 - (i % 4) * 8)) & 0xff; + thisWords[(thisSigBytes + i) >>> 2] |= thatByte << (24 - ((thisSigBytes + i) % 4) * 8); } this.sigBytes += thatSigBytes; return this; @@ -254,24 +244,52 @@ internal object JsBindings { clamp: function() { var words = this.words; var sigBytes = this.sigBytes; - words[sigBytes >>> 2] &= 0xffffffff << (32 - (sigBytes % 4) * 8); + if (sigBytes % 4) { + words[sigBytes >>> 2] &= 0xffffffff << (32 - (sigBytes % 4) * 8); + } words.length = Math.ceil(sigBytes / 4); return this; }, clone: function() { - var clone = Object.create(WordArray); - clone.init(this.words.slice(0), this.sigBytes); - return clone; + return __wordArrayCreate(this.words.slice(0), this.sigBytes); } }; - + function __wordArrayCreate(words, sigBytes) { var wa = Object.create(WordArray); wa.init(words, sigBytes); return wa; } + function __isWordArray(value) { + return value && typeof value === 'object' && Array.isArray(value.words) && typeof value.sigBytes === 'number'; + } + + function __copyUint8Array(bytes) { + bytes = __toUint8Array(bytes); + var copy = new Uint8Array(bytes.length); + copy.set(bytes); + return copy; + } + + function __toUint8Array(data) { + if (!data) return new Uint8Array(0); + if (data instanceof Uint8Array) return data; + if (data instanceof ArrayBuffer) return new Uint8Array(data); + if (typeof ArrayBuffer !== 'undefined' && ArrayBuffer.isView && ArrayBuffer.isView(data)) { + return new Uint8Array(data.buffer, data.byteOffset || 0, data.byteLength); + } + if (Array.isArray(data)) return new Uint8Array(data); + if (typeof data.length === 'number') return new Uint8Array(Array.prototype.slice.call(data)); + return new Uint8Array(0); + } + + function __bytesToArrayBuffer(bytes) { + return __copyUint8Array(bytes).buffer; + } + function __wordArrayToBytes(wordArray) { + if (!__isWordArray(wordArray)) return typeof wordArray === 'string' ? new TextEncoder().encode(wordArray) : __toUint8Array(wordArray); var bytes = new Uint8Array(wordArray.sigBytes); for (var i = 0; i < wordArray.sigBytes; i++) { bytes[i] = (wordArray.words[i >>> 2] >>> (24 - (i % 4) * 8)) & 0xff; @@ -280,6 +298,7 @@ internal object JsBindings { } function __bytesToWordArray(bytes) { + bytes = __toUint8Array(bytes); var words = []; for (var i = 0; i < bytes.length; i++) { words[i >>> 2] |= (bytes[i] & 0xff) << (24 - (i % 4) * 8); @@ -288,42 +307,146 @@ internal object JsBindings { } function __normalizeWordArrayInput(value) { - if (value && typeof value === 'object' && Array.isArray(value.words) && typeof value.sigBytes === 'number') { - return __wordArrayToBytes(value); - } + if (__isWordArray(value)) return __wordArrayToBytes(value); if (typeof value === 'string') return new TextEncoder().encode(value); return __toUint8Array(value); } - function __toUint8Array(data) { - if (data instanceof Uint8Array) return data; - if (data instanceof ArrayBuffer) return new Uint8Array(data); - if (data && typeof data.length === 'number') return new Uint8Array(Array.prototype.slice.call(data)); - return new Uint8Array(0); + function __bytesToHex(bytes) { + bytes = __toUint8Array(bytes); + var out = []; + for (var i = 0; i < bytes.length; i++) { + var hex = bytes[i].toString(16); + out.push(hex.length < 2 ? '0' + hex : hex); + } + return out.join(''); + } + + function __hexToBytes(hex) { + hex = String(hex || '').replace(/[^0-9a-fA-F]/g, ''); + if (hex.length % 2) hex = '0' + hex; + var bytes = new Uint8Array(hex.length / 2); + for (var i = 0; i < hex.length; i += 2) { + bytes[i / 2] = parseInt(hex.substr(i, 2), 16) & 0xff; + } + return bytes; + } + + function __concatBytes() { + var total = 0; + var parts = []; + for (var i = 0; i < arguments.length; i++) { + var part = __toUint8Array(arguments[i]); + parts.push(part); + total += part.length; + } + var out = new Uint8Array(total); + var offset = 0; + for (var j = 0; j < parts.length; j++) { + out.set(parts[j], offset); + offset += parts[j].length; + } + return out; + } + + function __normalizeHashName(hash) { + var name = hash && hash.name ? hash.name : hash; + name = String(name || 'SHA-256').toUpperCase().replace(/[^A-Z0-9]/g, ''); + if (name === 'SHA1' || name === 'SHA256' || name === 'SHA384' || name === 'SHA512' || name === 'MD5') return name; + throw new Error('Unsupported hash algorithm: ' + name); + } + + function __normalizeAlgorithmName(algo) { + var name = algo && algo.name ? algo.name : algo; + name = String(name || '').toUpperCase(); + if (name.indexOf('AES-GCM') >= 0) return 'AES-GCM'; + if (name.indexOf('AES-CBC') >= 0) return 'AES-CBC'; + if (name.indexOf('AES-ECB') >= 0 || name === 'ECB') return 'AES-ECB'; + if (name.indexOf('PBKDF2') >= 0) return 'PBKDF2'; + if (name.indexOf('HMAC') >= 0) return 'HMAC'; + if (name.indexOf('RSASSA-PKCS1') >= 0) return 'RSASSA-PKCS1-V1_5'; + if (name.indexOf('ECDSA') >= 0) return 'ECDSA'; + return name; + } + + function __aesModeName(mode, padding) { + var normalized = __normalizeAlgorithmName(mode || 'AES-CBC'); + if (padding === CryptoJS.pad.NoPadding || padding === 'NoPadding') normalized += '-NoPadding'; + return normalized; + } + + function __nativeDigestBytes(hash, dataBytes) { + if (typeof __crypto_digest_hex_raw === 'undefined') throw new Error('Native digest bridge is unavailable'); + return __hexToBytes(__crypto_digest_hex_raw(__normalizeHashName(hash), __bytesToHex(dataBytes))); + } + + function __nativeHmacBytes(hash, keyBytes, dataBytes) { + if (typeof __crypto_hmac_hex_raw === 'undefined') throw new Error('Native HMAC bridge is unavailable'); + return __hexToBytes(__crypto_hmac_hex_raw(__normalizeHashName(hash), __bytesToHex(keyBytes), __bytesToHex(dataBytes))); + } + + function __nativePbkdf2Bytes(passwordBytes, saltBytes, iterations, keySizeBits, hash) { + if (typeof __crypto_pbkdf2_hex === 'undefined') throw new Error('Native PBKDF2 bridge is unavailable'); + return __hexToBytes(__crypto_pbkdf2_hex(__bytesToHex(passwordBytes), __bytesToHex(saltBytes), iterations, keySizeBits, __normalizeHashName(hash))); + } + + function __nativeAesBytes(encrypt, mode, keyBytes, ivBytes, dataBytes) { + var fn = encrypt ? __crypto_aes_encrypt_hex : __crypto_aes_decrypt_hex; + if (typeof fn === 'undefined') throw new Error('Native AES bridge is unavailable'); + return __hexToBytes(fn(mode, __bytesToHex(keyBytes), __bytesToHex(ivBytes), __bytesToHex(dataBytes))); + } + + function __evpKdf(passwordBytes, saltBytes, keySizeBytes, ivSizeBytes) { + var targetSize = keySizeBytes + ivSizeBytes; + var derived = new Uint8Array(targetSize); + var block = new Uint8Array(0); + var offset = 0; + while (offset < targetSize) { + block = __nativeDigestBytes('MD5', __concatBytes(block, passwordBytes, saltBytes || new Uint8Array(0))); + var take = Math.min(block.length, targetSize - offset); + derived.set(block.subarray(0, take), offset); + offset += take; + } + return { + key: derived.subarray(0, keySizeBytes), + iv: derived.subarray(keySizeBytes, keySizeBytes + ivSizeBytes) + }; + } + + function __opensslSaltHeader() { + return new Uint8Array([83, 97, 108, 116, 101, 100, 95, 95]); + } + + function __hasOpenSslSaltHeader(bytes) { + var header = __opensslSaltHeader(); + if (!bytes || bytes.length < 16) return false; + for (var i = 0; i < header.length; i++) { + if (bytes[i] !== header[i]) return false; + } + return true; + } + + function __makeCipherParams(ciphertext, key, iv, salt, mode) { + return { + ciphertext: __bytesToWordArray(ciphertext), + key: key ? __bytesToWordArray(key) : undefined, + iv: iv ? __bytesToWordArray(iv) : undefined, + salt: salt ? __bytesToWordArray(salt) : undefined, + mode: mode, + toString: function(formatter) { + return (formatter || CryptoJS.format.OpenSSL).stringify(this); + } + }; } var CryptoJS = { enc: { Hex: { stringify: function(wordArray) { - var words = wordArray.words; - var sigBytes = wordArray.sigBytes; - var hexChars = []; - for (var i = 0; i < sigBytes; i++) { - var bite = (words[i >>> 2] >>> (24 - (i % 4) * 8)) & 0xff; - var hexStr = bite.toString(16); - if (hexStr.length < 2) hexStr = '0' + hexStr; - hexChars.push(hexStr); - } - return hexChars.join(''); + return __bytesToHex(__wordArrayToBytes(wordArray)); }, parse: function(hexStr) { - var hexStrLength = hexStr.length; - var words = []; - for (var i = 0; i < hexStrLength; i += 2) { - words[i >>> 3] |= parseInt(hexStr.substr(i, 2), 16) << (24 - (i % 8) * 4); - } - return __wordArrayCreate(words, hexStrLength / 2); + return __bytesToWordArray(__hexToBytes(hexStr)); } }, Utf8: { @@ -334,143 +457,282 @@ internal object JsBindings { return __bytesToWordArray(new TextEncoder().encode(String(utf8Str))); } }, + Latin1: { + stringify: function(wordArray) { + var bytes = __wordArrayToBytes(wordArray); + var out = ''; + for (var i = 0; i < bytes.length; i++) out += String.fromCharCode(bytes[i]); + return out; + }, + parse: function(str) { + str = String(str || ''); + var bytes = new Uint8Array(str.length); + for (var i = 0; i < str.length; i++) bytes[i] = str.charCodeAt(i) & 0xff; + return __bytesToWordArray(bytes); + } + }, Base64: { stringify: function(wordArray) { var bytes = __wordArrayToBytes(wordArray); var binaryStr = ''; - for (var j = 0; j < bytes.length; j++) { - binaryStr += String.fromCharCode(bytes[j]); - } + for (var j = 0; j < bytes.length; j++) binaryStr += String.fromCharCode(bytes[j]); return btoa(binaryStr); }, parse: function(base64Str) { var binaryStr = atob(String(base64Str || '')); var bytes = new Uint8Array(binaryStr.length); - for (var i = 0; i < binaryStr.length; i++) { - bytes[i] = binaryStr.charCodeAt(i) & 0xff; - } + for (var i = 0; i < binaryStr.length; i++) bytes[i] = binaryStr.charCodeAt(i) & 0xff; return __bytesToWordArray(bytes); } + }, + Base64url: { + stringify: function(wordArray) { + return CryptoJS.enc.Base64.stringify(wordArray).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); + }, + parse: function(str) { + str = String(str || '').replace(/-/g, '+').replace(/_/g, '/'); + while (str.length % 4) str += '='; + return CryptoJS.enc.Base64.parse(str); + } } }, lib: { WordArray: { create: function(words, sigBytes) { + if (words == null) return __wordArrayCreate([], sigBytes || 0); + if (__isWordArray(words)) return words.clone(); + if (typeof words === 'string') return CryptoJS.enc.Utf8.parse(words); + if (words instanceof ArrayBuffer || (typeof ArrayBuffer !== 'undefined' && ArrayBuffer.isView && ArrayBuffer.isView(words))) { + var bytes = __toUint8Array(words); + return __bytesToWordArray(sigBytes != undefined ? bytes.subarray(0, sigBytes) : bytes); + } return __wordArrayCreate(words, sigBytes); + }, + random: function(nBytes) { + var bytes = new Uint8Array(nBytes || 0); + globalThis.crypto.getRandomValues(bytes); + return __bytesToWordArray(bytes); + } + }, + CipherParams: { + create: function(params) { + params = params || {}; + params.toString = params.toString || function(formatter) { + return (formatter || CryptoJS.format.OpenSSL).stringify(this); + }; + return params; + } + } + }, + format: { + OpenSSL: { + stringify: function(cipherParams) { + var cipherBytes = __wordArrayToBytes(cipherParams.ciphertext); + var out = cipherParams.salt + ? __concatBytes(__opensslSaltHeader(), __wordArrayToBytes(cipherParams.salt), cipherBytes) + : cipherBytes; + return CryptoJS.enc.Base64.stringify(__bytesToWordArray(out)); + }, + parse: function(str) { + var bytes = __wordArrayToBytes(CryptoJS.enc.Base64.parse(str)); + if (__hasOpenSslSaltHeader(bytes)) { + return CryptoJS.lib.CipherParams.create({ + salt: __bytesToWordArray(bytes.subarray(8, 16)), + ciphertext: __bytesToWordArray(bytes.subarray(16)) + }); + } + return CryptoJS.lib.CipherParams.create({ ciphertext: __bytesToWordArray(bytes) }); } } }, mode: { CBC: 'AES-CBC', GCM: 'AES-GCM', ECB: 'AES-ECB' }, pad: { Pkcs7: 'Pkcs7', NoPadding: 'NoPadding' }, - algo: { SHA256: 'SHA256' }, - MD5: function(m) { - var bytes = __normalizeWordArrayInput(m); - var res = typeof __crypto_digest_raw !== 'undefined' ? __crypto_digest_raw('MD5', bytes.buffer) : new Uint8Array(0); - return __bytesToWordArray(res); - }, - SHA1: function(m) { - var bytes = __normalizeWordArrayInput(m); - var res = typeof __crypto_digest_raw !== 'undefined' ? __crypto_digest_raw('SHA1', bytes.buffer) : new Uint8Array(0); - return __bytesToWordArray(res); - }, - SHA256: function(m) { - var bytes = __normalizeWordArrayInput(m); - var res = typeof __crypto_digest_raw !== 'undefined' ? __crypto_digest_raw('SHA256', bytes.buffer) : new Uint8Array(0); - return __bytesToWordArray(res); - }, - SHA512: function(m) { - var bytes = __normalizeWordArrayInput(m); - var res = typeof __crypto_digest_raw !== 'undefined' ? __crypto_digest_raw('SHA512', bytes.buffer) : new Uint8Array(0); - return __bytesToWordArray(res); - }, + algo: { MD5: 'MD5', SHA1: 'SHA1', SHA256: 'SHA256', SHA384: 'SHA384', SHA512: 'SHA512', AES: 'AES' }, + MD5: function(m) { return __bytesToWordArray(__nativeDigestBytes('MD5', __normalizeWordArrayInput(m))); }, + SHA1: function(m) { return __bytesToWordArray(__nativeDigestBytes('SHA1', __normalizeWordArrayInput(m))); }, + SHA256: function(m) { return __bytesToWordArray(__nativeDigestBytes('SHA256', __normalizeWordArrayInput(m))); }, + SHA512: function(m) { return __bytesToWordArray(__nativeDigestBytes('SHA512', __normalizeWordArrayInput(m))); }, + HmacMD5: function(m, k) { return __bytesToWordArray(__nativeHmacBytes('MD5', __normalizeWordArrayInput(k), __normalizeWordArrayInput(m))); }, + HmacSHA1: function(m, k) { return __bytesToWordArray(__nativeHmacBytes('SHA1', __normalizeWordArrayInput(k), __normalizeWordArrayInput(m))); }, + HmacSHA256: function(m, k) { return __bytesToWordArray(__nativeHmacBytes('SHA256', __normalizeWordArrayInput(k), __normalizeWordArrayInput(m))); }, + HmacSHA512: function(m, k) { return __bytesToWordArray(__nativeHmacBytes('SHA512', __normalizeWordArrayInput(k), __normalizeWordArrayInput(m))); }, PBKDF2: function(pass, salt, options) { options = options || {}; var pBytes = __normalizeWordArrayInput(pass); var sBytes = __normalizeWordArrayInput(salt); var iter = options.iterations || 1000; - var kSize = options.keySize || (256/32); - var algo = options.hasher === CryptoJS.algo.SHA256 ? 'SHA256' : 'SHA1'; - var resBytes = typeof __crypto_pbkdf2_raw !== 'undefined' ? __crypto_pbkdf2_raw(pBytes.buffer, sBytes.buffer, iter, kSize * 32, algo) : new Uint8Array(0); - return __bytesToWordArray(resBytes); + var kSize = options.keySize || 8; + var algo = options.hasher || 'SHA1'; + return __bytesToWordArray(__nativePbkdf2Bytes(pBytes, sBytes, iter, kSize * 32, algo)); }, AES: { encrypt: function(message, key, options) { options = options || {}; var data = __normalizeWordArrayInput(message); - var kBytes = __wordArrayToBytes(key); - var ivBytes = options.iv ? __wordArrayToBytes(options.iv) : new Uint8Array(0); - var mode = options.mode || 'AES-CBC'; - var resBytes = typeof __crypto_aes_encrypt_raw !== 'undefined' ? __crypto_aes_encrypt_raw(mode, kBytes.buffer, ivBytes.buffer, data.buffer) : new Uint8Array(0); - var wa = __bytesToWordArray(resBytes); - return { - ciphertext: wa, - toString: function() { return wa.toString(CryptoJS.enc.Base64); } - }; + var kBytes; + var ivBytes; + var saltBytes; + var isPassphrase = typeof key === 'string'; + if (isPassphrase) { + saltBytes = options.salt ? __wordArrayToBytes(options.salt) : __wordArrayToBytes(CryptoJS.lib.WordArray.random(8)); + var derived = __evpKdf(new TextEncoder().encode(key), saltBytes, 32, 16); + kBytes = derived.key; + ivBytes = options.iv ? __wordArrayToBytes(options.iv) : derived.iv; + } else { + kBytes = __wordArrayToBytes(key); + ivBytes = options.iv ? __wordArrayToBytes(options.iv) : new Uint8Array(0); + } + var mode = __aesModeName(options.mode || 'AES-CBC', options.padding); + var resBytes = __nativeAesBytes(true, mode, kBytes, ivBytes, data); + return __makeCipherParams(resBytes, kBytes, ivBytes, saltBytes, mode); }, decrypt: function(cipher, key, options) { options = options || {}; - var data = typeof cipher === 'string' - ? new Uint8Array(Array.from(atob(cipher), c => c.charCodeAt(0))) - : (cipher.ciphertext ? __wordArrayToBytes(cipher.ciphertext) : __toUint8Array(cipher)); - var kBytes = __wordArrayToBytes(key); - var ivBytes = options.iv ? __wordArrayToBytes(options.iv) : new Uint8Array(0); - var mode = options.mode || 'AES-CBC'; - var resBytes = typeof __crypto_aes_decrypt_raw !== 'undefined' ? __crypto_aes_decrypt_raw(mode, kBytes.buffer, ivBytes.buffer, data.buffer) : new Uint8Array(0); - var plain = new TextDecoder().decode(resBytes); - return { toString: function(enc) { return plain; } }; + var cipherParams = typeof cipher === 'string' ? CryptoJS.format.OpenSSL.parse(cipher) : cipher; + var data = cipherParams.ciphertext ? __wordArrayToBytes(cipherParams.ciphertext) : __toUint8Array(cipherParams); + var kBytes; + var ivBytes; + var isPassphrase = typeof key === 'string'; + if (isPassphrase) { + var saltBytes = options.salt ? __wordArrayToBytes(options.salt) : (cipherParams.salt ? __wordArrayToBytes(cipherParams.salt) : new Uint8Array(0)); + var derived = __evpKdf(new TextEncoder().encode(key), saltBytes, 32, 16); + kBytes = derived.key; + ivBytes = options.iv ? __wordArrayToBytes(options.iv) : derived.iv; + } else { + kBytes = __wordArrayToBytes(key); + ivBytes = options.iv ? __wordArrayToBytes(options.iv) : new Uint8Array(0); + } + var mode = __aesModeName(options.mode || 'AES-CBC', options.padding); + return __bytesToWordArray(__nativeAesBytes(false, mode, kBytes, ivBytes, data)); } } }; globalThis.CryptoJS = CryptoJS; + function __makeCryptoKey(type, algorithm, extractable, usages, rawBytes) { + return { + type: type, + extractable: !!extractable, + algorithm: algorithm, + usages: usages || [], + _raw: __copyUint8Array(rawBytes) + }; + } + + function __webCryptoAlgorithm(algo) { + var name = __normalizeAlgorithmName(algo); + var out = { name: name }; + if (algo && typeof algo === 'object' && algo.length) out.length = algo.length; + if (algo && typeof algo === 'object' && algo.hash) out.hash = { name: __normalizeHashName(algo.hash) }; + return out; + } + + function __signatureAlgorithmName(algo, key) { + var name = __normalizeAlgorithmName(algo || (key && key.algorithm)); + var hash = algo && algo.hash ? __normalizeHashName(algo.hash) : (key && key.algorithm && key.algorithm.hash ? key.algorithm.hash.name : 'SHA256'); + if (name === 'RSASSA-PKCS1-V1_5') return 'RSASSA-PKCS1-V1_5-' + hash; + if (name === 'ECDSA') return 'ECDSA-' + hash; + return name; + } + globalThis.crypto = { subtle: { digest: async function(algo, data) { - var bytes = __toUint8Array(data); - var res = typeof __crypto_digest_raw !== 'undefined' ? __crypto_digest_raw(algo.name || algo, bytes.buffer) : new Uint8Array(0); - return __toUint8Array(res).buffer; + return __bytesToArrayBuffer(__nativeDigestBytes(algo, __toUint8Array(data))); + }, + importKey: async function(fmt, data, algo, extractable, usages) { + fmt = String(fmt || 'raw').toLowerCase(); + if (fmt !== 'raw' && fmt !== 'pkcs8' && fmt !== 'spki') throw new Error('Unsupported key format: ' + fmt); + var algorithm = __webCryptoAlgorithm(algo || {}); + var type = fmt === 'spki' ? 'public' : (fmt === 'pkcs8' ? 'private' : 'secret'); + return __makeCryptoKey(type, algorithm, extractable, usages || [], __toUint8Array(data)); + }, + exportKey: async function(fmt, key) { + fmt = String(fmt || 'raw').toLowerCase(); + if (fmt !== 'raw' && fmt !== 'pkcs8' && fmt !== 'spki') throw new Error('Unsupported key format: ' + fmt); + return __bytesToArrayBuffer(key._raw); + }, + generateKey: async function(algo, extractable, usages) { + var algorithm = __webCryptoAlgorithm(algo || {}); + if (algorithm.name !== 'AES-CBC' && algorithm.name !== 'AES-GCM' && algorithm.name !== 'HMAC') { + throw new Error('Unsupported generateKey algorithm: ' + algorithm.name); + } + var length = algorithm.length || 256; + var bytes = new Uint8Array(length / 8); + globalThis.crypto.getRandomValues(bytes); + return __makeCryptoKey('secret', algorithm, extractable, usages || [], bytes); }, - importKey: async function(fmt, data, algo, ext, use) { return { _raw: data, _algo: algo }; }, deriveBits: async function(params, key, len) { + if (__normalizeAlgorithmName(params) !== 'PBKDF2') throw new Error('Only PBKDF2 deriveBits is supported'); var pBytes = __toUint8Array(key._raw); var sBytes = __toUint8Array(params.salt); - var res = typeof __crypto_pbkdf2_raw !== 'undefined' ? __crypto_pbkdf2_raw(pBytes.buffer, sBytes.buffer, params.iterations, len, params.hash) : new Uint8Array(0); - return __toUint8Array(res).buffer; + var hash = params.hash || 'SHA-256'; + return __bytesToArrayBuffer(__nativePbkdf2Bytes(pBytes, sBytes, params.iterations || 1000, len, hash)); + }, + deriveKey: async function(params, key, derivedKeyAlgo, extractable, usages) { + var algorithm = __webCryptoAlgorithm(derivedKeyAlgo || {}); + var length = algorithm.length || 256; + var raw = await globalThis.crypto.subtle.deriveBits(params, key, length); + return __makeCryptoKey('secret', algorithm, extractable, usages || [], new Uint8Array(raw)); }, encrypt: async function(params, key, data) { - var kBytes = __toUint8Array(key._raw); + var mode = __normalizeAlgorithmName(params); + if (mode !== 'AES-CBC' && mode !== 'AES-GCM') throw new Error('Unsupported encrypt algorithm: ' + mode); + if (mode === 'AES-GCM' && params.tagLength && params.tagLength !== 128) throw new Error('Only 128-bit AES-GCM tags are supported'); + if (mode === 'AES-GCM' && params.additionalData) throw new Error('AES-GCM additionalData is not supported'); var ivBytes = __toUint8Array(params.iv || new Uint8Array(0)); - var dBytes = __toUint8Array(data); - var res = typeof __crypto_aes_encrypt_raw !== 'undefined' ? __crypto_aes_encrypt_raw(params.name, kBytes.buffer, ivBytes.buffer, dBytes.buffer) : new Uint8Array(0); - return __toUint8Array(res).buffer; + return __bytesToArrayBuffer(__nativeAesBytes(true, mode, __toUint8Array(key._raw), ivBytes, __toUint8Array(data))); }, decrypt: async function(params, key, data) { - var kBytes = __toUint8Array(key._raw); + var mode = __normalizeAlgorithmName(params); + if (mode !== 'AES-CBC' && mode !== 'AES-GCM') throw new Error('Unsupported decrypt algorithm: ' + mode); + if (mode === 'AES-GCM' && params.tagLength && params.tagLength !== 128) throw new Error('Only 128-bit AES-GCM tags are supported'); + if (mode === 'AES-GCM' && params.additionalData) throw new Error('AES-GCM additionalData is not supported'); var ivBytes = __toUint8Array(params.iv || new Uint8Array(0)); - var dBytes = __toUint8Array(data); - var res = typeof __crypto_aes_decrypt_raw !== 'undefined' ? __crypto_aes_decrypt_raw(params.name, kBytes.buffer, ivBytes.buffer, dBytes.buffer) : new Uint8Array(0); - return __toUint8Array(res).buffer; + return __bytesToArrayBuffer(__nativeAesBytes(false, mode, __toUint8Array(key._raw), ivBytes, __toUint8Array(data))); }, sign: async function(algo, key, data) { - var algoName = typeof algo === 'string' ? algo : (algo.name || ''); - var kBytes = __toUint8Array(key._raw); - var dBytes = __toUint8Array(data); - var res = typeof __crypto_sign_raw !== 'undefined' ? __crypto_sign_raw(algoName, kBytes.buffer, dBytes.buffer) : new Uint8Array(0); - return __toUint8Array(res).buffer; + if (__normalizeAlgorithmName(algo || key.algorithm) === 'HMAC' || key.algorithm.name === 'HMAC') { + var hash = (algo && algo.hash) || (key.algorithm && key.algorithm.hash) || 'SHA-256'; + return __bytesToArrayBuffer(__nativeHmacBytes(hash, __toUint8Array(key._raw), __toUint8Array(data))); + } + if (typeof __crypto_sign_hex === 'undefined') throw new Error('Native signature bridge is unavailable'); + var sigHex = __crypto_sign_hex(__signatureAlgorithmName(algo, key), __bytesToHex(key._raw), __bytesToHex(__toUint8Array(data))); + return __bytesToArrayBuffer(__hexToBytes(sigHex)); }, verify: async function(algo, key, sig, data) { - var algoName = typeof algo === 'string' ? algo : (algo.name || ''); - var kBytes = __toUint8Array(key._raw); - var sBytes = __toUint8Array(sig); - var dBytes = __toUint8Array(data); - return typeof __crypto_verify_raw !== 'undefined' ? __crypto_verify_raw(algoName, kBytes.buffer, sBytes.buffer, dBytes.buffer) : false; + if (__normalizeAlgorithmName(algo || key.algorithm) === 'HMAC' || key.algorithm.name === 'HMAC') { + var expected = __nativeHmacBytes((algo && algo.hash) || (key.algorithm && key.algorithm.hash) || 'SHA-256', __toUint8Array(key._raw), __toUint8Array(data)); + var actual = __toUint8Array(sig); + if (expected.length !== actual.length) return false; + var diff = 0; + for (var i = 0; i < expected.length; i++) diff |= expected[i] ^ actual[i]; + return diff === 0; + } + if (typeof __crypto_verify_hex === 'undefined') throw new Error('Native signature bridge is unavailable'); + return __crypto_verify_hex(__signatureAlgorithmName(algo, key), __bytesToHex(key._raw), __bytesToHex(__toUint8Array(sig)), __bytesToHex(__toUint8Array(data))); } }, getRandomValues: function(arr) { - if (!arr || !arr.length) return arr; - var res = typeof __crypto_get_random_values !== 'undefined' ? __crypto_get_random_values(arr.length) : new Uint8Array(arr.length); - for (var i = 0; i < arr.length; i++) arr[i] = res[i]; + if (!arr) return arr; + var byteLength = arr.byteLength != undefined ? arr.byteLength : arr.length; + if (!byteLength) return arr; + if (typeof __crypto_get_random_values_hex === 'undefined') throw new Error('Native random bridge is unavailable'); + var random = __hexToBytes(__crypto_get_random_values_hex(byteLength)); + if (arr.buffer && arr.byteLength != undefined) { + new Uint8Array(arr.buffer, arr.byteOffset || 0, arr.byteLength).set(random); + } else { + for (var i = 0; i < arr.length; i++) arr[i] = random[i] || 0; + } return arr; + }, + randomUUID: function() { + var b = new Uint8Array(16); + globalThis.crypto.getRandomValues(b); + b[6] = (b[6] & 0x0f) | 0x40; + b[8] = (b[8] & 0x3f) | 0x80; + var h = __bytesToHex(b); + return h.substr(0, 8) + '-' + h.substr(8, 4) + '-' + h.substr(12, 4) + '-' + h.substr(16, 4) + '-' + h.substr(20); } }; diff --git a/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.ios.kt b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.ios.kt index cfb64765..1811ca98 100644 --- a/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.ios.kt +++ b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.ios.kt @@ -18,6 +18,8 @@ import platform.Security.SecRandomCopyBytes import platform.Security.kSecRandomDefault internal fun pluginGetRandomValues(length: Int): ByteArray { + require(length >= 0) { "Random byte length must be non-negative" } + if (length == 0) return ByteArray(0) val bytes = ByteArray(length) @OptIn(ExperimentalForeignApi::class) SecRandomCopyBytes(kSecRandomDefault, length.toULong(), bytes.refTo(0)) @@ -26,7 +28,7 @@ internal fun pluginGetRandomValues(length: Int): ByteArray { @OptIn(ExperimentalForeignApi::class) internal fun pluginDigest(algorithm: String, data: ByteArray): ByteArray { - val normalized = algorithm.uppercase() + val normalized = normalizeDigestAlgorithm(algorithm) val output = ByteArray( when (normalized) { "MD5" -> CC_MD5_DIGEST_LENGTH.toInt() @@ -62,13 +64,10 @@ internal fun pluginPbkdf2( keySizeBits: Int, algorithm: String, ): ByteArray { - val prf = when (algorithm.uppercase()) { - "SHA256", "HMACSHA256" -> kCCPRFHmacAlgSHA256 - "SHA1", "HMACSHA1" -> kCCPRFHmacAlgSHA1 - "SHA384", "HMACSHA384" -> kCCPRFHmacAlgSHA384 - "SHA512", "HMACSHA512" -> kCCPRFHmacAlgSHA512 - else -> kCCPRFHmacAlgSHA256 - } + require(iterations > 0) { "PBKDF2 iterations must be positive" } + require(keySizeBits > 0 && keySizeBits % 8 == 0) { "PBKDF2 key size must be a positive byte-aligned bit length" } + + val prf = normalizePbkdf2Prf(algorithm) val derivedKeyLen = keySizeBits / 8 val derivedKey = ByteArray(derivedKeyLen) @@ -107,6 +106,11 @@ internal fun pluginAesEncrypt( iv: ByteArray, data: ByteArray, ): ByteArray { + requireValidAesKey(key) + if (!mode.uppercase().contains("ECB")) { + require(iv.isNotEmpty()) { "AES mode $mode requires an IV" } + } + val isGcm = mode.uppercase().contains("GCM") if (isGcm) { var encryptedData: ByteArray? = null @@ -241,6 +245,11 @@ internal fun pluginAesDecrypt( iv: ByteArray, data: ByteArray, ): ByteArray { + requireValidAesKey(key) + if (!mode.uppercase().contains("ECB")) { + require(iv.isNotEmpty()) { "AES mode $mode requires an IV" } + } + val isGcm = mode.uppercase().contains("GCM") if (isGcm) { require(data.size >= 16) { "Data too short for GCM decryption" } @@ -387,7 +396,7 @@ private fun UByteArray.toHex(): String = joinToString(separator = "") { byte -> @OptIn(ExperimentalForeignApi::class) internal fun pluginDigestHex(algorithm: String, data: String): String { - val normalized = algorithm.uppercase() + val normalized = normalizeDigestAlgorithm(algorithm) val input = data.encodeToByteArray() val output = UByteArray( when (normalized) { @@ -417,12 +426,57 @@ internal fun pluginDigestHex(algorithm: String, data: String): String { } @OptIn(ExperimentalForeignApi::class) -internal fun pluginHmacHex(algorithm: String, key: String, data: String): String { - val normalized = algorithm.uppercase() - val keyBytes = key.encodeToByteArray() - val input = data.encodeToByteArray() +internal fun pluginHmac(algorithm: String, key: ByteArray, data: ByteArray): ByteArray { + val (alg, outputSize) = normalizeHmacAlgorithm(algorithm) + val output = ByteArray(outputSize) - val (alg, outputSize) = when (normalized) { + key.usePinned { pinnedKey -> + data.usePinned { pinnedInput -> + output.usePinned { pinnedOutput -> + val keyPtr = if (key.isNotEmpty()) pinnedKey.addressOf(0) else null + val inputPtr = if (data.isNotEmpty()) pinnedInput.addressOf(0) else null + + CCHmac( + alg, + keyPtr, + key.size.toULong(), + inputPtr, + data.size.toULong(), + pinnedOutput.addressOf(0).reinterpret(), + ) + } + } + } + + return output +} + +@OptIn(ExperimentalForeignApi::class) +internal fun pluginHmacHex(algorithm: String, key: String, data: String): String { + return pluginHmac(algorithm, key.encodeToByteArray(), data.encodeToByteArray()).toHex() +} + +private fun normalizeDigestAlgorithm(algorithm: String): String { + return when (algorithm.normalizedAlgorithmToken()) { + "MD5" -> "MD5" + "SHA1" -> "SHA1" + "SHA256" -> "SHA256" + "SHA512" -> "SHA512" + else -> error("Unsupported digest algorithm: $algorithm") + } +} + +private fun normalizePbkdf2Prf(algorithm: String) = + when (algorithm.normalizedAlgorithmToken().removePrefix("HMAC")) { + "SHA1" -> kCCPRFHmacAlgSHA1 + "SHA256" -> kCCPRFHmacAlgSHA256 + "SHA384" -> kCCPRFHmacAlgSHA384 + "SHA512" -> kCCPRFHmacAlgSHA512 + else -> error("Unsupported PBKDF2 hash algorithm: $algorithm") + } + +private fun normalizeHmacAlgorithm(algorithm: String) = + when (algorithm.normalizedAlgorithmToken().removePrefix("HMAC")) { "MD5" -> kCCHmacAlgMD5 to CC_MD5_DIGEST_LENGTH.toInt() "SHA1" -> kCCHmacAlgSHA1 to CC_SHA1_DIGEST_LENGTH.toInt() "SHA256" -> kCCHmacAlgSHA256 to CC_SHA256_DIGEST_LENGTH.toInt() @@ -430,29 +484,23 @@ internal fun pluginHmacHex(algorithm: String, key: String, data: String): String else -> error("Unsupported HMAC algorithm: $algorithm") } - val output = UByteArray(outputSize) - - keyBytes.usePinned { pinnedKey -> - input.usePinned { pinnedInput -> - output.usePinned { pinnedOutput -> - val keyPtr = if (keyBytes.isNotEmpty()) pinnedKey.addressOf(0) else null - val inputPtr = if (input.isNotEmpty()) pinnedInput.addressOf(0) else null - val outputPtr = pinnedOutput.addressOf(0) +private fun requireValidAesKey(key: ByteArray) { + require(key.size == 16 || key.size == 24 || key.size == 32) { + "AES key must be 16, 24, or 32 bytes" + } +} - CCHmac( - alg, - keyPtr, - keyBytes.size.toULong(), - inputPtr, - input.size.toULong(), - outputPtr, - ) - } - } +private fun ByteArray.toHex(): String = + joinToString(separator = "") { byte -> + byte.toUByte().toString(16).padStart(2, '0') } - return output.toHex() -} +private fun String.normalizedAlgorithmToken(): String = + uppercase() + .replace("-", "") + .replace("_", "") + .replace("/", "") + .replace(" ", "") @OptIn(ExperimentalEncodingApi::class) internal fun pluginBase64Encode(data: String): String = From 700181572fcb0228572fb7c000cfbf659c31f132 Mon Sep 17 00:00:00 2001 From: paregi12 Date: Tue, 2 Jun 2026 14:53:17 +0530 Subject: [PATCH 18/24] fix(plugins/crypto): validate ios entropy status and support sha384/hmac-sha384 --- .../com/nuvio/app/features/plugins/PluginCrypto.ios.kt | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.ios.kt b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.ios.kt index 1811ca98..c3f3a181 100644 --- a/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.ios.kt +++ b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.ios.kt @@ -22,7 +22,8 @@ internal fun pluginGetRandomValues(length: Int): ByteArray { if (length == 0) return ByteArray(0) val bytes = ByteArray(length) @OptIn(ExperimentalForeignApi::class) - SecRandomCopyBytes(kSecRandomDefault, length.toULong(), bytes.refTo(0)) + val status = SecRandomCopyBytes(kSecRandomDefault, length.toULong(), bytes.refTo(0)) + require(status == 0) { "Failed to generate secure random bytes: status $status" } return bytes } @@ -34,6 +35,7 @@ internal fun pluginDigest(algorithm: String, data: ByteArray): ByteArray { "MD5" -> CC_MD5_DIGEST_LENGTH.toInt() "SHA1" -> CC_SHA1_DIGEST_LENGTH.toInt() "SHA256" -> CC_SHA256_DIGEST_LENGTH.toInt() + "SHA384" -> CC_SHA384_DIGEST_LENGTH.toInt() "SHA512" -> CC_SHA512_DIGEST_LENGTH.toInt() else -> error("Unsupported digest algorithm: $algorithm") }, @@ -48,6 +50,7 @@ internal fun pluginDigest(algorithm: String, data: ByteArray): ByteArray { "MD5" -> CC_MD5(dataPtr, data.size.toUInt(), outputPtr) "SHA1" -> CC_SHA1(dataPtr, data.size.toUInt(), outputPtr) "SHA256" -> CC_SHA256(dataPtr, data.size.toUInt(), outputPtr) + "SHA384" -> CC_SHA384(dataPtr, data.size.toUInt(), outputPtr) "SHA512" -> CC_SHA512(dataPtr, data.size.toUInt(), outputPtr) } } @@ -403,6 +406,7 @@ internal fun pluginDigestHex(algorithm: String, data: String): String { "MD5" -> CC_MD5_DIGEST_LENGTH.toInt() "SHA1" -> CC_SHA1_DIGEST_LENGTH.toInt() "SHA256" -> CC_SHA256_DIGEST_LENGTH.toInt() + "SHA384" -> CC_SHA384_DIGEST_LENGTH.toInt() "SHA512" -> CC_SHA512_DIGEST_LENGTH.toInt() else -> error("Unsupported digest algorithm: $algorithm") }, @@ -417,6 +421,7 @@ internal fun pluginDigestHex(algorithm: String, data: String): String { "MD5" -> CC_MD5(dataPtr, input.size.toUInt(), outputPtr) "SHA1" -> CC_SHA1(dataPtr, input.size.toUInt(), outputPtr) "SHA256" -> CC_SHA256(dataPtr, input.size.toUInt(), outputPtr) + "SHA384" -> CC_SHA384(dataPtr, input.size.toUInt(), outputPtr) "SHA512" -> CC_SHA512(dataPtr, input.size.toUInt(), outputPtr) } } @@ -461,6 +466,7 @@ private fun normalizeDigestAlgorithm(algorithm: String): String { "MD5" -> "MD5" "SHA1" -> "SHA1" "SHA256" -> "SHA256" + "SHA384" -> "SHA384" "SHA512" -> "SHA512" else -> error("Unsupported digest algorithm: $algorithm") } @@ -480,6 +486,7 @@ private fun normalizeHmacAlgorithm(algorithm: String) = "MD5" -> kCCHmacAlgMD5 to CC_MD5_DIGEST_LENGTH.toInt() "SHA1" -> kCCHmacAlgSHA1 to CC_SHA1_DIGEST_LENGTH.toInt() "SHA256" -> kCCHmacAlgSHA256 to CC_SHA256_DIGEST_LENGTH.toInt() + "SHA384" -> kCCHmacAlgSHA384 to CC_SHA384_DIGEST_LENGTH.toInt() "SHA512" -> kCCHmacAlgSHA512 to CC_SHA512_DIGEST_LENGTH.toInt() else -> error("Unsupported HMAC algorithm: $algorithm") } From a1574820bca64da2156d95513ef238a9c0fb6da5 Mon Sep 17 00:00:00 2001 From: paregi12 Date: Tue, 2 Jun 2026 14:53:50 +0530 Subject: [PATCH 19/24] fix(plugins/crypto): expose SHA384 and HmacSHA384 shortcuts in JS polyfills --- .../com/nuvio/app/features/plugins/runtime/js/JsBindings.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt index 5b987ca5..5e239048 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt @@ -551,10 +551,12 @@ internal object JsBindings { MD5: function(m) { return __bytesToWordArray(__nativeDigestBytes('MD5', __normalizeWordArrayInput(m))); }, SHA1: function(m) { return __bytesToWordArray(__nativeDigestBytes('SHA1', __normalizeWordArrayInput(m))); }, SHA256: function(m) { return __bytesToWordArray(__nativeDigestBytes('SHA256', __normalizeWordArrayInput(m))); }, + SHA384: function(m) { return __bytesToWordArray(__nativeDigestBytes('SHA384', __normalizeWordArrayInput(m))); }, SHA512: function(m) { return __bytesToWordArray(__nativeDigestBytes('SHA512', __normalizeWordArrayInput(m))); }, HmacMD5: function(m, k) { return __bytesToWordArray(__nativeHmacBytes('MD5', __normalizeWordArrayInput(k), __normalizeWordArrayInput(m))); }, HmacSHA1: function(m, k) { return __bytesToWordArray(__nativeHmacBytes('SHA1', __normalizeWordArrayInput(k), __normalizeWordArrayInput(m))); }, HmacSHA256: function(m, k) { return __bytesToWordArray(__nativeHmacBytes('SHA256', __normalizeWordArrayInput(k), __normalizeWordArrayInput(m))); }, + HmacSHA384: function(m, k) { return __bytesToWordArray(__nativeHmacBytes('SHA384', __normalizeWordArrayInput(k), __normalizeWordArrayInput(m))); }, HmacSHA512: function(m, k) { return __bytesToWordArray(__nativeHmacBytes('SHA512', __normalizeWordArrayInput(k), __normalizeWordArrayInput(m))); }, PBKDF2: function(pass, salt, options) { options = options || {}; From 4c527a8d8d378ce787d1237ac7c4d71bc0ceb0ee Mon Sep 17 00:00:00 2001 From: paregi12 Date: Sat, 6 Jun 2026 00:04:46 +0530 Subject: [PATCH 20/24] feat(player): improve plugin subtitle handling --- .../player/PlayerSubtitleCueParser.kt | 162 +++++++++++++++++- iosApp/iosApp/Player/MPVPlayerBridge.swift | 37 +++- 2 files changed, 186 insertions(+), 13 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSubtitleCueParser.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSubtitleCueParser.kt index 67b52638..9e1b0381 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSubtitleCueParser.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSubtitleCueParser.kt @@ -11,10 +11,39 @@ object PlayerSubtitleCueParser { .trim() if (normalized.isBlank()) return emptyList() - return if (sourceUrl?.endsWith(".vtt", ignoreCase = true) == true || normalized.startsWith("WEBVTT")) { - parseWebVtt(normalized) - } else { - parseSrt(normalized) + return when (detectSubtitleFormat(sourceUrl, normalized)) { + SubtitleFormatHint.WebVtt -> parseWebVtt(normalized) + SubtitleFormatHint.Ass -> parseAss(normalized) + SubtitleFormatHint.Ttml -> parseTtml(normalized) + SubtitleFormatHint.Srt -> parseSrt(normalized) + } + } + + private enum class SubtitleFormatHint { + Srt, + WebVtt, + Ass, + Ttml, + } + + private fun detectSubtitleFormat(sourceUrl: String?, text: String): SubtitleFormatHint { + val sourcePath = sourceUrl + ?.substringBefore('?') + ?.substringBefore('#') + ?.lowercase() + .orEmpty() + val sample = text.take(4_096).lowercase() + + return when { + sourcePath.endsWith(".vtt") || sourcePath.endsWith(".webvtt") || text.startsWith("WEBVTT") -> + SubtitleFormatHint.WebVtt + sourcePath.endsWith(".ass") || sourcePath.endsWith(".ssa") || + (sample.contains("[events]") && sample.contains("dialogue:")) -> + SubtitleFormatHint.Ass + sourcePath.endsWith(".ttml") || sourcePath.endsWith(".dfxp") || sourcePath.endsWith(".xml") || + Regex("""]""", RegexOption.IGNORE_CASE).containsMatchIn(text.take(512)) -> + SubtitleFormatHint.Ttml + else -> SubtitleFormatHint.Srt } } @@ -53,6 +82,68 @@ object PlayerSubtitleCueParser { } .sortedBy { it.startTimeMs } + private fun parseAss(text: String): List { + var inEventsSection = false + var formatFields: List? = null + + return text.lines() + .mapNotNull { rawLine -> + val line = rawLine.trim() + when { + line.equals("[Events]", ignoreCase = true) -> { + inEventsSection = true + null + } + line.startsWith("[") && line.endsWith("]") -> { + inEventsSection = false + null + } + inEventsSection && line.startsWith("Format:", ignoreCase = true) -> { + formatFields = line.substringAfter(':') + .split(',') + .map { it.trim() } + null + } + inEventsSection && line.startsWith("Dialogue:", ignoreCase = true) -> + parseAssDialogue(line.substringAfter(':'), formatFields) + else -> null + } + } + .sortedBy { it.startTimeMs } + } + + private fun parseAssDialogue(payload: String, formatFields: List?): SubtitleSyncCue? { + val fields = formatFields.orEmpty() + val parts = payload + .split(',', limit = fields.ifEmpty { defaultAssFormatFields }.size) + .map { it.trim() } + val startIndex = fields.indexOfField("Start").takeIf { it >= 0 } ?: 1 + val textIndex = fields.indexOfField("Text").takeIf { it >= 0 } ?: 9 + + if (parts.size <= startIndex || parts.size <= textIndex) return null + val start = parseTimestamp(parts[startIndex]) ?: return null + val body = parts[textIndex] + .cleanAssCueText() + .cleanSubtitleCueText() + return if (body.isBlank()) null else SubtitleSyncCue(start, body) + } + + private fun parseTtml(text: String): List = + Regex("""]*)>(.*?)

""", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL)) + .findAll(text) + .mapNotNull { match -> + val attrs = match.groupValues[1] + val startRaw = attrs.attributeValue("begin") + ?: attrs.attributeValue("start") + ?: return@mapNotNull null + val start = parseTtmlTimestamp(startRaw) ?: return@mapNotNull null + val body = match.groupValues[2] + .replace(Regex("""""", RegexOption.IGNORE_CASE), " ") + .cleanSubtitleCueText() + if (body.isBlank()) null else SubtitleSyncCue(start, body) + } + .sortedBy { it.startTimeMs } + private fun parseCueStart(timingLine: String): Long? { val startPart = timingLine.substringBefore("-->").trim() return parseTimestamp(startPart) @@ -76,12 +167,75 @@ object PlayerSubtitleCueParser { return max(0L, hours * 3_600_000L + minutes * 60_000L + seconds * 1_000L + millis) } + private fun parseTtmlTimestamp(raw: String): Long? { + val cleaned = raw.trim().substringBefore(' ') + if (cleaned.isBlank()) return null + + parseClockTimeWithFrames(cleaned)?.let { return it } + parseTimestamp(cleaned)?.let { return it } + + val match = Regex("""^([0-9]+(?:\.[0-9]+)?)(ms|h|m|s)$""", RegexOption.IGNORE_CASE) + .matchEntire(cleaned) + ?: return null + val value = match.groupValues[1].toDoubleOrNull() ?: return null + val multiplier = when (match.groupValues[2].lowercase()) { + "h" -> 3_600_000.0 + "m" -> 60_000.0 + "s" -> 1_000.0 + "ms" -> 1.0 + else -> return null + } + return max(0L, (value * multiplier).toLong()) + } + + private fun parseClockTimeWithFrames(raw: String): Long? { + val parts = raw.split(':') + if (parts.size != 4) return null + + val hours = parts[0].toLongOrNull() ?: return null + val minutes = parts[1].toLongOrNull() ?: return null + val seconds = parts[2].toLongOrNull() ?: return null + val frames = parts[3].substringBefore('.').toLongOrNull() ?: return null + return max(0L, hours * 3_600_000L + minutes * 60_000L + seconds * 1_000L + frames * 1_000L / 30L) + } + + private val defaultAssFormatFields = listOf( + "Layer", + "Start", + "End", + "Style", + "Name", + "MarginL", + "MarginR", + "MarginV", + "Effect", + "Text", + ) + + private fun List.indexOfField(name: String): Int = + indexOfFirst { it.equals(name, ignoreCase = true) } + + private fun String.attributeValue(name: String): String? = + Regex("""\b${Regex.escape(name)}\s*=\s*["']([^"']+)["']""", RegexOption.IGNORE_CASE) + .find(this) + ?.groupValues + ?.getOrNull(1) + ?.takeIf { it.isNotBlank() } + + private fun String.cleanAssCueText(): String = + replace(Regex("""\{[^}]*}"""), "") + .replace("\\N", " ") + .replace("\\n", " ") + .replace("\\h", " ") + private fun String.cleanSubtitleCueText(): String = replace(Regex("<[^>]+>"), "") .replace(" ", " ") .replace("&", "&") .replace("<", "<") .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") .replace(Regex("\\s+"), " ") .trim() } diff --git a/iosApp/iosApp/Player/MPVPlayerBridge.swift b/iosApp/iosApp/Player/MPVPlayerBridge.swift index c053d781..7eb3126d 100644 --- a/iosApp/iosApp/Player/MPVPlayerBridge.swift +++ b/iosApp/iosApp/Player/MPVPlayerBridge.swift @@ -43,14 +43,7 @@ final class MPVPlayerBridgeImpl: NSObject, NuvioPlayerBridge { ) } } -} -struct PluginSubtitle { - val url: String - val language: String - val name: String? - val headers: [String: String]? -} func play() { playerVC?.playPlayback() } func pause() { playerVC?.pausePlayback() } func seekTo(positionMs: Int64) { playerVC?.seekToMs(positionMs) } @@ -198,6 +191,13 @@ struct PluginSubtitle { } } +struct PluginSubtitle { + let url: String + let language: String + let name: String? + let headers: [String: String]? +} + // MARK: - Track Info struct TrackInfo { @@ -453,10 +453,9 @@ final class MPVPlayerViewController: UIViewController { } } - // Add external subtitles for subtitle in request.subtitles { DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in - self?.command("sub-add", args: [subtitle.url, "auto", subtitle.name ?? subtitle.language, subtitle.language], checkForErrors: false) + self?.addSubtitle(subtitle, mode: "auto") } } } @@ -598,6 +597,26 @@ final class MPVPlayerViewController: UIViewController { command("sub-add", args: [url, "select"]) } + private func addSubtitle(_ subtitle: PluginSubtitle, mode: String) { + guard mpv != nil else { return } + let subtitleHeaders = sanitizeRequestHeaders(subtitle.headers ?? [:]) + let previousHeaders = activeRequestHeaders + + if !subtitleHeaders.isEmpty { + applyRequestHeaders(previousHeaders.merging(subtitleHeaders) { _, subtitleValue in subtitleValue }) + } + + command( + "sub-add", + args: [subtitle.url, mode, subtitle.name ?? subtitle.language, subtitle.language], + checkForErrors: false + ) + + if !subtitleHeaders.isEmpty { + applyRequestHeaders(previousHeaders) + } + } + func removeExternalSubtitles() { guard mpv != nil else { return } let count = getInt("track-list/count") From 8ebe7ccda372aefadd5c402e6f7f31bb390e3077 Mon Sep 17 00:00:00 2001 From: paregi12 Date: Sat, 6 Jun 2026 00:22:46 +0530 Subject: [PATCH 21/24] fix(player): resolve return type mismatch in parseTtml --- .../com/nuvio/app/features/player/PlayerSubtitleCueParser.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSubtitleCueParser.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSubtitleCueParser.kt index 9e1b0381..72a9bc50 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSubtitleCueParser.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSubtitleCueParser.kt @@ -143,6 +143,7 @@ object PlayerSubtitleCueParser { if (body.isBlank()) null else SubtitleSyncCue(start, body) } .sortedBy { it.startTimeMs } + .toList() private fun parseCueStart(timingLine: String): Long? { val startPart = timingLine.substringBefore("-->").trim() From 5b65ee20cb27007dadfddb484cca8b0f109d5901 Mon Sep 17 00:00:00 2001 From: paregi12 Date: Mon, 8 Jun 2026 10:32:18 +0530 Subject: [PATCH 22/24] Fix iOS compilation errors: add CC_SHA384 and fix size_tVar types --- .../com/nuvio/app/features/plugins/PluginCrypto.ios.kt | 10 ++++++---- .../src/nativeInterop/cinterop/commoncrypto_shim.h | 4 +++- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.ios.kt b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.ios.kt index c3f3a181..5e5b205e 100644 --- a/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.ios.kt +++ b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.ios.kt @@ -164,7 +164,7 @@ internal fun pluginAesEncrypt( } val tagBytes = ByteArray(16) - val tagLengthVar = alloc() + val tagLengthVar = alloc() tagLengthVar.value = 16UL tagBytes.usePinned { pinnedTag -> @@ -199,7 +199,7 @@ internal fun pluginAesEncrypt( var finalData: ByteArray? = null memScoped { - val dataOutMoved = alloc() + val dataOutMoved = alloc() var options = 0U if (isEcb) { @@ -310,7 +310,7 @@ internal fun pluginAesDecrypt( } } - val tagLengthVar = alloc() + val tagLengthVar = alloc() tagLengthVar.value = 16UL val finalStatus = CCCryptorGCMFinal( @@ -343,7 +343,7 @@ internal fun pluginAesDecrypt( var finalData: ByteArray? = null memScoped { - val dataOutMoved = alloc() + val dataOutMoved = alloc() var options = 0U if (isEcb) { @@ -472,6 +472,7 @@ private fun normalizeDigestAlgorithm(algorithm: String): String { } } +@OptIn(ExperimentalForeignApi::class) private fun normalizePbkdf2Prf(algorithm: String) = when (algorithm.normalizedAlgorithmToken().removePrefix("HMAC")) { "SHA1" -> kCCPRFHmacAlgSHA1 @@ -481,6 +482,7 @@ private fun normalizePbkdf2Prf(algorithm: String) = else -> error("Unsupported PBKDF2 hash algorithm: $algorithm") } +@OptIn(ExperimentalForeignApi::class) private fun normalizeHmacAlgorithm(algorithm: String) = when (algorithm.normalizedAlgorithmToken().removePrefix("HMAC")) { "MD5" -> kCCHmacAlgMD5 to CC_MD5_DIGEST_LENGTH.toInt() diff --git a/composeApp/src/nativeInterop/cinterop/commoncrypto_shim.h b/composeApp/src/nativeInterop/cinterop/commoncrypto_shim.h index 255a0fd7..c9b76212 100644 --- a/composeApp/src/nativeInterop/cinterop/commoncrypto_shim.h +++ b/composeApp/src/nativeInterop/cinterop/commoncrypto_shim.h @@ -7,6 +7,7 @@ enum { CC_MD5_DIGEST_LENGTH = 16, CC_SHA1_DIGEST_LENGTH = 20, CC_SHA256_DIGEST_LENGTH = 32, + CC_SHA384_DIGEST_LENGTH = 48, CC_SHA512_DIGEST_LENGTH = 64, }; @@ -21,6 +22,7 @@ typedef enum { unsigned char *CC_MD5(const void *data, CC_LONG len, unsigned char *md); unsigned char *CC_SHA1(const void *data, CC_LONG len, unsigned char *md); unsigned char *CC_SHA256(const void *data, CC_LONG len, unsigned char *md); +unsigned char *CC_SHA384(const void *data, CC_LONG len, unsigned char *md); unsigned char *CC_SHA512(const void *data, CC_LONG len, unsigned char *md); void CCHmac( @@ -48,7 +50,7 @@ enum { int CCKeyDerivationPBKDF( CCPBKDFAlgorithm algorithm, - const char *password, + const void *password, size_t passwordLen, const uint8_t *salt, size_t saltLen, From 5fd0ab6ea26ae2cf8c94aa274f8c3ceb63953b9b Mon Sep 17 00:00:00 2001 From: paregi12 Date: Fri, 19 Jun 2026 08:57:41 +0530 Subject: [PATCH 23/24] fix(streams): resolve redeclaration and unresolved reference build errors --- .../player/PlayerStreamsRepository.kt | 43 ------ .../app/features/streams/StreamsRepository.kt | 141 ------------------ 2 files changed, 184 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt index 6f734ac5..81ec1c1e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt @@ -508,47 +508,4 @@ private fun StreamsUiState.streamDiagnostics(): String { private fun com.nuvio.app.features.addons.ManagedAddon.streamAddonInstanceId(manifestId: String): String = "addon:$manifestId:$manifestUrl" -private fun PluginRuntimeResult.toStreamItem(scraper: PluginScraper): StreamItem { - val subtitleParts = listOfNotNull( - quality?.takeIf { it.isNotBlank() }, - size?.takeIf { it.isNotBlank() }, - language?.takeIf { it.isNotBlank() }, - ) - val requestHeaders = headers - .orEmpty() - .mapNotNull { (key, value) -> - val headerName = key.trim() - val headerValue = value.trim() - if (headerName.isBlank() || headerValue.isBlank() || headerName.equals("Range", ignoreCase = true)) { - null - } else { - headerName to headerValue - } - } - .toMap() - return StreamItem( - name = name ?: title, - description = subtitleParts.joinToString(" • ").ifBlank { null }, - url = url, - infoHash = infoHash, - addonName = scraper.name, - addonId = "plugin:${scraper.id}", - behaviorHints = if (requestHeaders.isEmpty()) { - com.nuvio.app.features.streams.StreamBehaviorHints() - } else { - com.nuvio.app.features.streams.StreamBehaviorHints( - notWebReady = true, - proxyHeaders = com.nuvio.app.features.streams.StreamProxyHeaders(request = requestHeaders), - ) - }, - externalSubtitles = subtitles?.map { - com.nuvio.app.features.streams.StreamSubtitle( - url = it.url, - language = it.language, - name = it.name, - headers = it.headers - ) - } ?: emptyList() - ) -} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt index 223b1dde..60ac5b36 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt @@ -799,145 +799,4 @@ object StreamsRepository { _uiState.update { it.copy(showDirectAutoPlayOverlay = visible, overlayMessage = message) } } } -private data class InstalledStreamAddonTarget( - val addonName: String, - val addonId: String, - val manifest: com.nuvio.app.features.addons.AddonManifest, -) -private fun com.nuvio.app.features.addons.ManagedAddon.streamAddonInstanceId(manifestId: String): String = - "addon:$manifestId:$manifestUrl" - -private data class PluginProviderGroup( - val addonId: String, - val addonName: String, - val scrapers: List, -) - -private sealed interface StreamLoadCompletion { - data class Addon(val group: AddonStreamGroup) : StreamLoadCompletion - data class PluginScraper( - val addonId: String, - val streams: List, - val error: String?, - ) : StreamLoadCompletion -} - -private fun List.toPluginProviderGroups( - repositories: List, - groupByRepository: Boolean, -): List { - if (!groupByRepository) { - return map { scraper -> - PluginProviderGroup( - addonId = "plugin:${scraper.id}", - addonName = scraper.name, - scrapers = listOf(scraper), - ) - } - } - - val repoNameByUrl = repositories.associate { it.manifestUrl to it.name } - return groupBy { it.repositoryUrl } - .map { (repositoryUrl, scrapers) -> - PluginProviderGroup( - addonId = "plugin-repo:${repositoryUrl.lowercase()}", - addonName = repoNameByUrl[repositoryUrl].orEmpty().ifBlank { repositoryUrl.fallbackRepositoryLabel() }, - scrapers = scrapers.sortedBy { it.name.lowercase() }, - ) - } - .sortedBy { it.addonName.lowercase() } -} - -private fun List.toEmptyStateReason(anyLoading: Boolean): StreamsEmptyStateReason? { - if (anyLoading || any { it.streams.isNotEmpty() }) { - return null - } - - return if (isNotEmpty() && all { !it.error.isNullOrBlank() }) { - StreamsEmptyStateReason.StreamFetchFailed - } else { - StreamsEmptyStateReason.NoStreamsFound - } -} - -private suspend fun runCatchingUnlessCancelled(block: suspend () -> T): Result = - try { - Result.success(block()) - } catch (error: CancellationException) { - throw error - } catch (error: Throwable) { - Result.failure(error) - } - -private fun PluginRuntimeResult.toStreamItem( - scraper: PluginScraper, - addonName: String = scraper.name, - addonId: String = "plugin:${scraper.id}", - includeScraperNameInSubtitle: Boolean = false, -): StreamItem { - val subtitleParts = listOfNotNull( - scraper.name.takeIf { includeScraperNameInSubtitle && it.isNotBlank() }, - quality?.takeIf { it.isNotBlank() }, - size?.takeIf { it.isNotBlank() }, - language?.takeIf { it.isNotBlank() }, - ) - val requestHeaders = headers - .orEmpty() - .mapNotNull { (key, value) -> - val headerName = key.trim() - val headerValue = value.trim() - if (headerName.isBlank() || headerValue.isBlank() || headerName.equals("Range", ignoreCase = true)) { - null - } else { - headerName to headerValue - } - } - .toMap() - - return StreamItem( - name = name ?: title, - description = subtitleParts.joinToString(" • ").ifBlank { null }, - url = url, - infoHash = infoHash, - sourceName = scraper.name, - addonName = addonName, - addonId = addonId, - behaviorHints = if (requestHeaders.isEmpty()) { - StreamBehaviorHints() - } else { - StreamBehaviorHints( - notWebReady = true, - proxyHeaders = StreamProxyHeaders(request = requestHeaders), - ) - }, - externalSubtitles = subtitles?.map { - StreamSubtitle( - url = it.url, - language = it.language, - name = it.name, - headers = it.headers - ) - } ?: emptyList() - ) -} - -private fun List.sortedForGroupedDisplay(): List = - sortedWith( - compareBy( - { it.sourceName.orEmpty().lowercase() }, - { it.streamLabel.lowercase() }, - { it.streamSubtitle.orEmpty().lowercase() }, - ), - ) - -private fun String.fallbackRepositoryLabel(): String { - val withoutQuery = substringBefore("?") - val withoutManifest = withoutQuery.removeSuffix("/manifest.json") - val host = withoutManifest.substringAfter("://", withoutManifest).substringBefore('/') - return host.ifBlank { - withoutManifest.substringAfterLast('/').ifBlank { - runBlocking { getString(Res.string.streams_plugin_repository_fallback) } - } - } -} From adf5d18462338754f503b86b33f633232488aaf2 Mon Sep 17 00:00:00 2001 From: paregi12 Date: Fri, 19 Jun 2026 10:00:39 +0530 Subject: [PATCH 24/24] fix(plugins): map plugin subtitles to StreamItem.externalSubtitles --- .../com/nuvio/app/features/streams/StreamFetchSupport.kt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamFetchSupport.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamFetchSupport.kt index 84d67e88..5bfa30be 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamFetchSupport.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamFetchSupport.kt @@ -124,6 +124,14 @@ internal fun PluginRuntimeResult.toStreamItem( proxyHeaders = StreamProxyHeaders(request = requestHeaders), ) }, + externalSubtitles = subtitles?.map { + StreamSubtitle( + url = it.url, + language = it.language, + name = it.name, + headers = it.headers + ) + } ?: emptyList() ) }