feat(plugin-runtime): implement robust, future-proof native crypto engine (AES, PBKDF2, Web Crypto)

This commit is contained in:
paregi12 2026-05-16 15:22:07 +05:30
parent 70d3eee9d2
commit 4c59ab90a3
12 changed files with 1692 additions and 18 deletions

View file

@ -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')
}

View file

@ -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<String, Any> = emptyMap(),
): List<PluginRuntimeResult> = 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<String, Any>,
): List<PluginRuntimeResult> {
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<Any?>(polyfillCode)
val wrappedCode = """
var module = { exports: {} };
var exports = module.exports;
(function() {
$code
})();
""".trimIndent()
evaluate<Any?>(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<Any?>(callCode)
}
return parseJsonResults(resultJson)
} finally {
domBridge.clear()
}
}
private fun parseJsonResults(rawJson: String): List<PluginRuntimeResult> {
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())
}
}

View file

@ -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("")
}
}
}

View file

@ -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<String, Document>()
private val elementCache = mutableMapOf<String, Element>()
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()
}
}

View file

@ -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<HostModule>()
fun addModule(module: HostModule) {
modules.add(module)
}
fun registerAll(runtime: QuickJs) {
modules.forEach { it.register(runtime) }
}
}

View file

@ -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
}
}
}

View file

@ -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()
}

View file

@ -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 <T> use(block: suspend QuickJs.() -> T): T {
return quickJs(dispatcher) {
block()
}
}
}

View file

@ -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<String, String> {
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
}
}

View file

@ -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") { ... }
}
}

View file

@ -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<UByteVar>()
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<ByteVar>() else null
val saltPtr = if (salt.isNotEmpty()) pinnedSalt.addressOf(0).reinterpret<UByteVar>() else null
val derivedKeyPtr = pinnedDerivedKey.addressOf(0).reinterpret<UByteVar>()
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<kotlinx.cinterop.size_tVar>()
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<kotlinx.cinterop.size_tVar>()
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()
}

View file

@ -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
);