mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-26 22:42:17 +00:00
Merge branch 'cmp' into cmp-rewrite
This commit is contained in:
commit
82999686cc
39 changed files with 3308 additions and 1079 deletions
|
|
@ -11,13 +11,18 @@ internal object PlatformPlaybackDataSourceFactory {
|
|||
defaultRequestHeaders: Map<String, String>,
|
||||
defaultResponseHeaders: Map<String, String>,
|
||||
useYoutubeChunkedPlayback: Boolean,
|
||||
externalSubtitles: List<com.nuvio.app.features.streams.StreamSubtitle> = emptyList(),
|
||||
): DataSource.Factory {
|
||||
val networkFactory: DataSource.Factory = if (useYoutubeChunkedPlayback) {
|
||||
YoutubeChunkedDataSourceFactory(defaultRequestHeaders = defaultRequestHeaders)
|
||||
} else {
|
||||
PlayerPlaybackNetworking.createHttpDataSourceFactory(defaultRequestHeaders)
|
||||
}
|
||||
val baseFactory: DataSource.Factory = DefaultDataSource.Factory(context, networkFactory)
|
||||
val subtitleHeaderFactory = SubtitleRequestHeaderDataSourceFactory(
|
||||
upstreamFactory = networkFactory,
|
||||
externalSubtitles = externalSubtitles
|
||||
)
|
||||
val baseFactory: DataSource.Factory = DefaultDataSource.Factory(context, subtitleHeaderFactory)
|
||||
return if (defaultResponseHeaders.isEmpty()) {
|
||||
baseFactory
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -1,42 +1,249 @@
|
|||
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.spec.GCMParameterSpec
|
||||
import javax.crypto.spec.IvParameterSpec
|
||||
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 {
|
||||
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(normalizeDigestAlgorithm(algorithm)).digest(data)
|
||||
}
|
||||
|
||||
internal fun pluginPbkdf2(
|
||||
password: ByteArray,
|
||||
salt: ByteArray,
|
||||
iterations: Int,
|
||||
keySizeBits: Int,
|
||||
algorithm: String,
|
||||
): ByteArray {
|
||||
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))
|
||||
|
||||
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(
|
||||
mode: String,
|
||||
key: ByteArray,
|
||||
iv: ByteArray,
|
||||
data: ByteArray,
|
||||
): ByteArray {
|
||||
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")
|
||||
|
||||
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 = 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")
|
||||
|
||||
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')
|
||||
}
|
||||
}
|
||||
|
||||
internal fun pluginHmac(algorithm: String, key: ByteArray, data: ByteArray): ByteArray {
|
||||
val normalized = normalizeHmacAlgorithm(algorithm)
|
||||
val mac = Mac.getInstance(normalized)
|
||||
mac.init(SecretKeySpec(key, normalized))
|
||||
return mac.doFinal(data)
|
||||
}
|
||||
|
||||
internal fun pluginHmacHex(algorithm: String, key: String, data: String): String {
|
||||
val normalized = when (algorithm.uppercase()) {
|
||||
"SHA1" -> "HmacSHA1"
|
||||
"SHA256" -> "HmacSHA256"
|
||||
"SHA512" -> "HmacSHA512"
|
||||
"MD5" -> "HmacMD5"
|
||||
else -> error("Unsupported HMAC algorithm: $algorithm")
|
||||
}
|
||||
val mac = Mac.getInstance(normalized)
|
||||
mac.init(SecretKeySpec(key.encodeToByteArray(), normalized))
|
||||
val digest = mac.doFinal(data.encodeToByteArray())
|
||||
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())
|
||||
|
||||
@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()
|
||||
}
|
||||
|
|
@ -46,11 +253,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)
|
||||
|
|
@ -58,5 +265,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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -39,6 +39,10 @@ import androidx.media3.common.TrackSelectionOverride
|
|||
import androidx.media3.common.text.Cue
|
||||
import androidx.media3.common.text.CueGroup
|
||||
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
|
||||
|
|
@ -46,6 +50,7 @@ import androidx.media3.exoplayer.ForwardingRenderer
|
|||
import androidx.media3.exoplayer.Renderer
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import androidx.media3.exoplayer.source.MergingMediaSource
|
||||
import com.nuvio.app.features.trailer.YoutubeChunkedDataSourceFactory
|
||||
import androidx.media3.exoplayer.text.TextOutput
|
||||
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
||||
import androidx.media3.extractor.DefaultExtractorsFactory
|
||||
|
|
@ -76,6 +81,7 @@ actual fun PlatformPlayerSurface(
|
|||
sourceAudioUrl: String?,
|
||||
sourceHeaders: Map<String, String>,
|
||||
sourceResponseHeaders: Map<String, String>,
|
||||
externalSubtitles: List<com.nuvio.app.features.streams.StreamSubtitle>,
|
||||
streamType: String?,
|
||||
useYoutubeChunkedPlayback: Boolean,
|
||||
modifier: Modifier,
|
||||
|
|
@ -127,12 +133,28 @@ actual fun PlatformPlayerSurface(
|
|||
var fallbackStartPositionMs by remember(playerSourceKey) { mutableStateOf<Long?>(null) }
|
||||
val effectiveDecoderPriority = decoderPriorityOverride ?: playerSettings.decoderPriority
|
||||
|
||||
val initialMediaItem = remember(playerSourceKey) {
|
||||
val initialMediaItem = remember(playerSourceKey, externalSubtitles) {
|
||||
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()
|
||||
}
|
||||
playbackMediaItemFromUrl(
|
||||
url = sourceUrl,
|
||||
responseHeaders = sanitizedSourceResponseHeaders,
|
||||
streamType = normalizedStreamType,
|
||||
)
|
||||
).buildUpon()
|
||||
.setMediaId(sourceUrl)
|
||||
.apply {
|
||||
if (subtitleConfigs.isNotEmpty()) {
|
||||
setSubtitleConfigurations(subtitleConfigs)
|
||||
}
|
||||
}
|
||||
.build()
|
||||
}
|
||||
|
||||
var resolvedMediaItem by remember(playerSourceKey) { mutableStateOf(initialMediaItem) }
|
||||
|
|
@ -148,12 +170,14 @@ actual fun PlatformPlayerSurface(
|
|||
sanitizedSourceHeaders,
|
||||
sanitizedSourceResponseHeaders,
|
||||
useYoutubeChunkedPlayback,
|
||||
externalSubtitles,
|
||||
) {
|
||||
PlatformPlaybackDataSourceFactory.create(
|
||||
context = context,
|
||||
defaultRequestHeaders = sanitizedSourceHeaders,
|
||||
defaultResponseHeaders = sanitizedSourceResponseHeaders,
|
||||
useYoutubeChunkedPlayback = useYoutubeChunkedPlayback,
|
||||
externalSubtitles = externalSubtitles,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -309,6 +333,21 @@ actual fun PlatformPlayerSurface(
|
|||
resolvedMediaItem = MediaItem.Builder()
|
||||
.setUri(sourceUrl)
|
||||
.setMimeType(probedMime)
|
||||
.setMediaId(sourceUrl)
|
||||
.apply {
|
||||
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()) {
|
||||
setSubtitleConfigurations(subtitleConfigs)
|
||||
}
|
||||
}
|
||||
.build()
|
||||
latestOnError.value(null)
|
||||
return@launch
|
||||
|
|
@ -1021,15 +1060,15 @@ private class SubtitleOffsetRenderer(
|
|||
}
|
||||
}
|
||||
|
||||
private fun resolveSubtitleMimeType(url: String): String {
|
||||
probeSubtitleHeaders(url)?.let { (contentType, contentDisposition) ->
|
||||
private fun resolveSubtitleMimeType(url: String, headers: Map<String, String>? = 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<String?, String?>? {
|
||||
private fun probeSubtitleHeaders(url: String, headers: Map<String, String>? = null): Pair<String?, String?>? {
|
||||
val methods = listOf("HEAD", "GET")
|
||||
methods.forEach { method ->
|
||||
runCatching {
|
||||
|
|
@ -1039,6 +1078,9 @@ private fun probeSubtitleHeaders(url: String): Pair<String?, String?>? {
|
|||
readTimeout = 5_000
|
||||
instanceFollowRedirects = true
|
||||
setRequestProperty("Accept", "*/*")
|
||||
headers?.forEach { (key, value) ->
|
||||
setRequestProperty(key, value)
|
||||
}
|
||||
}
|
||||
try {
|
||||
connection.responseCode
|
||||
|
|
@ -1093,3 +1135,50 @@ private fun guessSubtitleMime(url: String): String {
|
|||
else -> MimeTypes.TEXT_VTT
|
||||
}
|
||||
}
|
||||
|
||||
internal class SubtitleRequestHeaderDataSourceFactory(
|
||||
private val upstreamFactory: DataSource.Factory,
|
||||
private val externalSubtitles: List<com.nuvio.app.features.streams.StreamSubtitle>,
|
||||
) : DataSource.Factory {
|
||||
override fun createDataSource(): DataSource =
|
||||
SubtitleRequestHeaderDataSource(
|
||||
upstream = upstreamFactory.createDataSource(),
|
||||
externalSubtitles = externalSubtitles,
|
||||
)
|
||||
}
|
||||
|
||||
internal class SubtitleRequestHeaderDataSource(
|
||||
private val upstream: DataSource,
|
||||
private val externalSubtitles: List<com.nuvio.app.features.streams.StreamSubtitle>,
|
||||
) : 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<String, List<String>> = upstream.responseHeaders
|
||||
|
||||
override fun close() {
|
||||
upstream.close()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,9 +10,14 @@ internal object PlatformPlaybackDataSourceFactory {
|
|||
defaultRequestHeaders: Map<String, String>,
|
||||
defaultResponseHeaders: Map<String, String>,
|
||||
useYoutubeChunkedPlayback: Boolean,
|
||||
externalSubtitles: List<com.nuvio.app.features.streams.StreamSubtitle> = emptyList(),
|
||||
): DataSource.Factory {
|
||||
val httpFactory = PlayerPlaybackNetworking.createHttpDataSourceFactory(defaultRequestHeaders)
|
||||
val baseFactory: DataSource.Factory = DefaultDataSource.Factory(context, httpFactory)
|
||||
val subtitleHeaderFactory = SubtitleRequestHeaderDataSourceFactory(
|
||||
upstreamFactory = httpFactory,
|
||||
externalSubtitles = externalSubtitles
|
||||
)
|
||||
val baseFactory: DataSource.Factory = DefaultDataSource.Factory(context, subtitleHeaderFactory)
|
||||
return if (defaultResponseHeaders.isEmpty()) {
|
||||
baseFactory
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -1201,6 +1201,7 @@ private fun MainAppContent(
|
|||
sourceUrl = localSourceUrl,
|
||||
sourceHeaders = emptyMap(),
|
||||
sourceResponseHeaders = emptyMap(),
|
||||
externalSubtitles = emptyList(),
|
||||
logo = logo,
|
||||
poster = poster,
|
||||
background = background,
|
||||
|
|
@ -2027,6 +2028,7 @@ private fun MainAppContent(
|
|||
sourceUrl = cached.url,
|
||||
sourceHeaders = sanitizePlaybackHeaders(cached.requestHeaders),
|
||||
sourceResponseHeaders = sanitizePlaybackResponseHeaders(cached.responseHeaders),
|
||||
externalSubtitles = emptyList(),
|
||||
streamType = cached.streamType,
|
||||
logo = launch.logo,
|
||||
poster = launch.poster,
|
||||
|
|
@ -2163,6 +2165,7 @@ private fun MainAppContent(
|
|||
sourceUrl = sourceUrl,
|
||||
sourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request),
|
||||
sourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response),
|
||||
externalSubtitles = stream.externalSubtitles,
|
||||
streamType = stream.streamType,
|
||||
logo = launch.logo,
|
||||
poster = launch.poster,
|
||||
|
|
@ -2290,6 +2293,7 @@ private fun MainAppContent(
|
|||
sourceUrl = sourceUrl,
|
||||
sourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request),
|
||||
sourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response),
|
||||
externalSubtitles = stream.externalSubtitles,
|
||||
streamType = stream.streamType,
|
||||
logo = launch.logo,
|
||||
poster = launch.poster,
|
||||
|
|
@ -2450,6 +2454,7 @@ private fun MainAppContent(
|
|||
sourceAudioUrl = launch.sourceAudioUrl,
|
||||
sourceHeaders = launch.sourceHeaders,
|
||||
sourceResponseHeaders = launch.sourceResponseHeaders,
|
||||
externalSubtitles = launch.externalSubtitles,
|
||||
streamType = launch.streamType,
|
||||
logo = launch.logo,
|
||||
poster = launch.poster,
|
||||
|
|
@ -2613,6 +2618,8 @@ private fun MainAppContent(
|
|||
sourceUrl = sourceUrl,
|
||||
sourceHeaders = emptyMap(),
|
||||
sourceResponseHeaders = emptyMap(),
|
||||
externalSubtitles = emptyList(),
|
||||
streamType = null,
|
||||
logo = item.logo,
|
||||
poster = item.poster,
|
||||
background = item.background,
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ expect fun PlatformPlayerSurface(
|
|||
sourceAudioUrl: String? = null,
|
||||
sourceHeaders: Map<String, String> = emptyMap(),
|
||||
sourceResponseHeaders: Map<String, String> = emptyMap(),
|
||||
externalSubtitles: List<com.nuvio.app.features.streams.StreamSubtitle> = emptyList(),
|
||||
streamType: String? = null,
|
||||
useYoutubeChunkedPlayback: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ data class PlayerLaunch(
|
|||
val sourceAudioUrl: String? = null,
|
||||
val sourceHeaders: Map<String, String> = emptyMap(),
|
||||
val sourceResponseHeaders: Map<String, String> = emptyMap(),
|
||||
val externalSubtitles: List<com.nuvio.app.features.streams.StreamSubtitle> = emptyList(),
|
||||
val streamType: String? = null,
|
||||
val logo: String? = null,
|
||||
val poster: String? = null,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ fun PlayerScreen(
|
|||
sourceAudioUrl: String? = null,
|
||||
sourceHeaders: Map<String, String> = emptyMap(),
|
||||
sourceResponseHeaders: Map<String, String> = emptyMap(),
|
||||
externalSubtitles: List<com.nuvio.app.features.streams.StreamSubtitle> = emptyList(),
|
||||
streamType: String? = null,
|
||||
providerName: String,
|
||||
streamTitle: String,
|
||||
|
|
@ -48,6 +49,7 @@ fun PlayerScreen(
|
|||
sourceAudioUrl = sourceAudioUrl,
|
||||
sourceHeaders = sourceHeaders,
|
||||
sourceResponseHeaders = sourceResponseHeaders,
|
||||
externalSubtitles = externalSubtitles,
|
||||
streamType = streamType,
|
||||
providerName = providerName,
|
||||
streamTitle = streamTitle,
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ internal data class PlayerScreenArgs(
|
|||
val parentMetaId: String,
|
||||
val parentMetaType: String,
|
||||
val providerAddonId: String?,
|
||||
val externalSubtitles: List<com.nuvio.app.features.streams.StreamSubtitle> = emptyList(),
|
||||
val torrentInfoHash: String?,
|
||||
val torrentFileIdx: Int?,
|
||||
val torrentFilename: String?,
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ internal class PlayerScreenRuntime(
|
|||
val torrentTrackers: List<String> get() = args.torrentTrackers
|
||||
val initialPositionMs: Long get() = args.initialPositionMs
|
||||
val initialProgressFraction: Float? get() = args.initialProgressFraction
|
||||
val externalSubtitles: List<com.nuvio.app.features.streams.StreamSubtitle> get() = args.externalSubtitles
|
||||
val isSeries: Boolean get() = parentMetaType == "series"
|
||||
|
||||
lateinit var scope: CoroutineScope
|
||||
|
|
|
|||
|
|
@ -121,6 +121,7 @@ internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() {
|
|||
sourceAudioUrl = activeSourceAudioUrl,
|
||||
sourceHeaders = activeSourceHeaders,
|
||||
sourceResponseHeaders = activeSourceResponseHeaders,
|
||||
externalSubtitles = externalSubtitles,
|
||||
streamType = activeStreamType,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
playWhenReady = shouldPlay,
|
||||
|
|
|
|||
|
|
@ -480,3 +480,32 @@ object PlayerStreamsRepository {
|
|||
setJob(job)
|
||||
}
|
||||
}
|
||||
private data class PlayerInstalledStreamAddonTarget(
|
||||
val addonName: String,
|
||||
val addonId: String,
|
||||
val manifest: com.nuvio.app.features.addons.AddonManifest,
|
||||
)
|
||||
|
||||
private fun StreamsUiState.streamDiagnostics(): String {
|
||||
val streamCount = groups.sumOf { it.streams.size }
|
||||
val loadingCount = groups.count { it.isLoading }
|
||||
val errorCount = groups.count { !it.error.isNullOrBlank() }
|
||||
val sampleGroups = groups.take(4).joinToString(prefix = "[", postfix = "]") { group ->
|
||||
buildString {
|
||||
append(group.addonName)
|
||||
append(':')
|
||||
append(group.streams.size)
|
||||
if (group.isLoading) append(":loading")
|
||||
if (!group.error.isNullOrBlank()) append(":error")
|
||||
}
|
||||
}
|
||||
val suffix = if (groups.size > 4) "+${groups.size - 4}" else ""
|
||||
return "groups=${groups.size} streams=$streamCount isAnyLoading=$isAnyLoading " +
|
||||
"loadingGroups=$loadingCount errorGroups=$errorCount empty=${emptyStateReason ?: "none"} " +
|
||||
"sample=$sampleGroups$suffix"
|
||||
}
|
||||
|
||||
private fun com.nuvio.app.features.addons.ManagedAddon.streamAddonInstanceId(manifestId: String): String =
|
||||
"addon:$manifestId:$manifestUrl"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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("""<tt[\s>]""", RegexOption.IGNORE_CASE).containsMatchIn(text.take(512)) ->
|
||||
SubtitleFormatHint.Ttml
|
||||
else -> SubtitleFormatHint.Srt
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -53,6 +82,69 @@ object PlayerSubtitleCueParser {
|
|||
}
|
||||
.sortedBy { it.startTimeMs }
|
||||
|
||||
private fun parseAss(text: String): List<SubtitleSyncCue> {
|
||||
var inEventsSection = false
|
||||
var formatFields: List<String>? = 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<String>?): 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<SubtitleSyncCue> =
|
||||
Regex("""<p\b([^>]*)>(.*?)</p>""", 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("""<br\s*/?>""", RegexOption.IGNORE_CASE), " ")
|
||||
.cleanSubtitleCueText()
|
||||
if (body.isBlank()) null else SubtitleSyncCue(start, body)
|
||||
}
|
||||
.sortedBy { it.startTimeMs }
|
||||
.toList()
|
||||
|
||||
private fun parseCueStart(timingLine: String): Long? {
|
||||
val startPart = timingLine.substringBefore("-->").trim()
|
||||
return parseTimestamp(startPart)
|
||||
|
|
@ -76,12 +168,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<String>.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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ data class PluginManifestScraper(
|
|||
val filename: String,
|
||||
@SerialName("supportedTypes") val supportedTypes: List<String> = listOf("movie", "tv"),
|
||||
val enabled: Boolean = true,
|
||||
val hasSettings: Boolean = false,
|
||||
val logo: String? = null,
|
||||
@SerialName("contentLanguage") val contentLanguage: List<String>? = null,
|
||||
@SerialName("supportedPlatforms") val supportedPlatforms: List<String>? = null,
|
||||
|
|
@ -52,6 +53,7 @@ data class PluginScraper(
|
|||
val supportedTypes: List<String>,
|
||||
val enabled: Boolean,
|
||||
val manifestEnabled: Boolean,
|
||||
val hasSettings: Boolean = false,
|
||||
val logo: String? = null,
|
||||
val contentLanguage: List<String> = emptyList(),
|
||||
val formats: List<String>? = null,
|
||||
|
|
@ -76,6 +78,15 @@ data class PluginRuntimeResult(
|
|||
val peers: Int? = null,
|
||||
val infoHash: String? = null,
|
||||
val headers: Map<String, String>? = null,
|
||||
val subtitles: List<PluginSubtitleResult>? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PluginSubtitleResult(
|
||||
val url: String,
|
||||
val language: String,
|
||||
val name: String? = null,
|
||||
val headers: Map<String, String>? = null
|
||||
)
|
||||
|
||||
data class PluginsUiState(
|
||||
|
|
@ -119,6 +130,7 @@ internal data class StoredPluginScraper(
|
|||
val supportedTypes: List<String>,
|
||||
val enabled: Boolean,
|
||||
val manifestEnabled: Boolean,
|
||||
val hasSettings: Boolean = false,
|
||||
val logo: String? = null,
|
||||
val contentLanguage: List<String> = emptyList(),
|
||||
val formats: List<String>? = null,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,9 +2,18 @@ package com.nuvio.app.features.streams
|
|||
|
||||
import com.nuvio.app.core.build.AppFeaturePolicy
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.Serializable
|
||||
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<String, String>? = null
|
||||
)
|
||||
|
||||
data class StreamItem(
|
||||
val name: String? = null,
|
||||
val title: String? = null,
|
||||
|
|
@ -22,6 +31,7 @@ data class StreamItem(
|
|||
val behaviorHints: StreamBehaviorHints = StreamBehaviorHints(),
|
||||
val clientResolve: StreamClientResolve? = null,
|
||||
val debridCacheStatus: StreamDebridCacheStatus? = null,
|
||||
val externalSubtitles: List<StreamSubtitle> = emptyList(),
|
||||
val badges: List<StreamBadge> = emptyList(),
|
||||
) {
|
||||
val streamLabel: String
|
||||
|
|
|
|||
|
|
@ -799,3 +799,4 @@ object StreamsRepository {
|
|||
_uiState.update { it.copy(showDirectAutoPlayOverlay = visible, overlayMessage = message) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ class PlayerLaunchStoreTest {
|
|||
profileId = 1,
|
||||
title = "Title",
|
||||
sourceUrl = "https://example.com/video.m3u8?token=a/b:c",
|
||||
externalSubtitles = emptyList(),
|
||||
streamTitle = "Source",
|
||||
providerName = "Provider",
|
||||
parentMetaId = "tt1234567",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -337,7 +338,6 @@ actual object PluginRepository {
|
|||
season = season,
|
||||
episode = episode,
|
||||
scraperId = scraper.id,
|
||||
scraperSettings = emptyMap(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -391,6 +391,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,
|
||||
|
|
@ -484,12 +485,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))
|
||||
}
|
||||
|
|
@ -551,6 +552,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,
|
||||
|
|
|
|||
|
|
@ -1,994 +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 nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.generic_unknown
|
||||
import org.jetbrains.compose.resources.getString
|
||||
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<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 documentCache = mutableMapOf<String, Document>()
|
||||
val elementCache = mutableMapOf<String, Element>()
|
||||
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<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 {
|
||||
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<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 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<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") ?: runBlocking { getString(Res.string.generic_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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String, JsonElement>().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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -39,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
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.plugins_badge_disabled
|
||||
|
|
@ -101,6 +104,9 @@ fun PluginsSettingsPageContent(
|
|||
var testingScraperId by remember { mutableStateOf<String?>(null) }
|
||||
val testResults = remember { mutableStateMapOf<String, List<PluginRuntimeResult>>() }
|
||||
|
||||
var configuringScraper by remember { mutableStateOf<PluginScraper?>(null) }
|
||||
var configuringLayout by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
val sortedRepos = remember(uiState.repositories) {
|
||||
uiState.repositories.sortedBy { it.name.lowercase() }
|
||||
}
|
||||
|
|
@ -408,11 +414,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))
|
||||
|
|
@ -501,6 +526,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(fallback: String): String {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,272 @@
|
|||
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
|
||||
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
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
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 nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.generic_unknown
|
||||
import org.jetbrains.compose.resources.getString
|
||||
|
||||
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,
|
||||
): List<PluginRuntimeResult> = withContext(Dispatchers.Default) {
|
||||
val scraperSettingsJson = PluginStorage.loadScraperSettings(scraperId) ?: "{}"
|
||||
val scraperSettingsMap = runCatching {
|
||||
json.decodeFromString<Map<String, JsonElement>>(scraperSettingsJson)
|
||||
}.getOrElse { emptyMap() }
|
||||
|
||||
withTimeout(PLUGIN_TIMEOUT_MS) {
|
||||
executePluginInternal(
|
||||
code = code,
|
||||
tmdbId = tmdbId,
|
||||
mediaType = mediaType,
|
||||
season = season,
|
||||
episode = episode,
|
||||
scraperId = scraperId,
|
||||
scraperSettings = scraperSettingsMap,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getPluginSettingsLayout(
|
||||
code: String,
|
||||
scraperId: String,
|
||||
): String? = withContext(Dispatchers.Default) {
|
||||
withTimeout(PLUGIN_TIMEOUT_MS) {
|
||||
val jsRuntime = JsRuntime()
|
||||
val deferred = CompletableDeferred<String?>()
|
||||
|
||||
try {
|
||||
jsRuntime.use {
|
||||
val polyfillCode = JsBindings.buildPolyfillCode(
|
||||
scraperIdJson = JsonPrimitive(scraperId).toString(),
|
||||
settingsJson = "{}"
|
||||
)
|
||||
evaluate<Any?>(polyfillCode)
|
||||
|
||||
val wrappedCode = """
|
||||
var module = { exports: {} };
|
||||
var exports = module.exports;
|
||||
(function() {
|
||||
$code
|
||||
})();
|
||||
""".trimIndent()
|
||||
evaluate<Any?>(wrappedCode)
|
||||
|
||||
val callCode = """
|
||||
(async function() {
|
||||
try {
|
||||
var onSettings = (typeof module !== 'undefined' && module.exports && module.exports.onSettings) || globalThis.onSettings;
|
||||
if (typeof onSettings === 'function') {
|
||||
var layout = await onSettings();
|
||||
__capture_settings_result(JSON.stringify(layout || []));
|
||||
} else {
|
||||
__capture_settings_result("[]");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("onSettings error:", e);
|
||||
__capture_settings_result("[]");
|
||||
}
|
||||
})();
|
||||
""".trimIndent()
|
||||
|
||||
function("__capture_settings_result") { args: Array<Any?> ->
|
||||
deferred.complete(args.getOrNull(0)?.toString())
|
||||
null
|
||||
}
|
||||
|
||||
evaluate<Any?>(callCode)
|
||||
deferred.await()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun executePluginInternal(
|
||||
code: String,
|
||||
tmdbId: String,
|
||||
mediaType: String,
|
||||
season: Int?,
|
||||
episode: Int?,
|
||||
scraperId: String,
|
||||
scraperSettings: Map<String, JsonElement>,
|
||||
): List<PluginRuntimeResult> {
|
||||
val jsRuntime = JsRuntime()
|
||||
val deferred = CompletableDeferred<String>()
|
||||
|
||||
val domBridge = DomBridge()
|
||||
val hostRegistry = HostApiRegistry().apply {
|
||||
addModule(HostFunctions(scraperId) { deferred.complete(it) })
|
||||
addModule(FetchBridge())
|
||||
addModule(UrlBridge())
|
||||
addModule(CryptoBridge())
|
||||
addModule(WasmBridge())
|
||||
addModule(domBridge)
|
||||
}
|
||||
|
||||
try {
|
||||
jsRuntime.use {
|
||||
hostRegistry.registerAll(this)
|
||||
|
||||
val settingsJson = JsonObject(scraperSettings).toString()
|
||||
val polyfillCode = JsBindings.buildPolyfillCode(
|
||||
scraperIdJson = JsonPrimitive(scraperId).toString(),
|
||||
settingsJson = settingsJson,
|
||||
)
|
||||
evaluate<Any?>(polyfillCode)
|
||||
|
||||
val wrappedCode = """
|
||||
var module = { exports: {} };
|
||||
var exports = module.exports;
|
||||
(function() {
|
||||
$code
|
||||
})();
|
||||
""".trimIndent()
|
||||
evaluate<Any?>(wrappedCode)
|
||||
|
||||
val tmdbIdArg = JsonPrimitive(tmdbId).toString()
|
||||
val mediaTypeArg = JsonPrimitive(mediaType).toString()
|
||||
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($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 : "");
|
||||
__capture_result(JSON.stringify([]));
|
||||
}
|
||||
})();
|
||||
""".trimIndent()
|
||||
evaluate<Any?>(callCode)
|
||||
|
||||
deferred.await()
|
||||
}
|
||||
|
||||
// Result is captured inside use block, but returned outside to satisfy compiler
|
||||
return parseJsonResults(deferred.await())
|
||||
} 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() }
|
||||
|
||||
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") ?: runBlocking { getString(Res.string.generic_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,
|
||||
subtitles = subtitles,
|
||||
)
|
||||
}.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())
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
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.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
|
||||
import com.nuvio.app.features.plugins.pluginSign
|
||||
import com.nuvio.app.features.plugins.pluginVerify
|
||||
|
||||
internal class CryptoBridge : HostModule {
|
||||
override fun register(runtime: QuickJs) {
|
||||
// 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
|
||||
pluginGetRandomValues(length).toHexString()
|
||||
}
|
||||
|
||||
runtime.function("__crypto_digest_hex_raw") { args ->
|
||||
val algorithm = args.getOrNull(0)?.toString() ?: "SHA256"
|
||||
val data = pluginHexToByteArray(args.getOrNull(1)?.toString() ?: "")
|
||||
pluginDigest(algorithm, data).toHexString()
|
||||
}
|
||||
|
||||
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"
|
||||
pluginPbkdf2(password, salt, iterations, keySizeBits, algorithm).toHexString()
|
||||
}
|
||||
|
||||
runtime.function("__crypto_aes_encrypt_hex") { args ->
|
||||
val mode = args.getOrNull(0)?.toString() ?: "AES-CBC"
|
||||
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_hex") { args ->
|
||||
val mode = args.getOrNull(0)?.toString() ?: "AES-CBC"
|
||||
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_hex") { args ->
|
||||
val algorithm = args.getOrNull(0)?.toString() ?: ""
|
||||
val privateKey = pluginHexToByteArray(args.getOrNull(1)?.toString() ?: "")
|
||||
val data = pluginHexToByteArray(args.getOrNull(2)?.toString() ?: "")
|
||||
pluginSign(algorithm, privateKey, data).toHexString()
|
||||
}
|
||||
|
||||
runtime.function("__crypto_verify_hex") { args ->
|
||||
val algorithm = args.getOrNull(0)?.toString() ?: ""
|
||||
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) ---
|
||||
|
||||
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("")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ByteArray.toHexString(): String =
|
||||
joinToString(separator = "") { byte ->
|
||||
byte.toUByte().toString(16).padStart(2, '0')
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,973 @@
|
|||
package com.nuvio.app.features.plugins.runtime.js
|
||||
|
||||
internal object JsBindings {
|
||||
fun buildPolyfillCode(scraperIdJson: String, settingsJson: String): String {
|
||||
return """
|
||||
globalThis.SCRAPER_ID = $scraperIdJson;
|
||||
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()}
|
||||
${textEncoderPolyfill()}
|
||||
${cheerioPolyfill()}
|
||||
${requirePolyfill()}
|
||||
${arrayPolyfill()}
|
||||
${objectPolyfill()}
|
||||
${stringPolyfill()}
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
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 = __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);
|
||||
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() = """
|
||||
var WordArray = {
|
||||
init: function(words, sigBytes) {
|
||||
this.words = words || [];
|
||||
this.sigBytes = sigBytes != undefined ? sigBytes : this.words.length * 4;
|
||||
},
|
||||
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();
|
||||
|
||||
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;
|
||||
},
|
||||
clamp: function() {
|
||||
var words = this.words;
|
||||
var sigBytes = this.sigBytes;
|
||||
if (sigBytes % 4) {
|
||||
words[sigBytes >>> 2] &= 0xffffffff << (32 - (sigBytes % 4) * 8);
|
||||
}
|
||||
words.length = Math.ceil(sigBytes / 4);
|
||||
return this;
|
||||
},
|
||||
clone: function() {
|
||||
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;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
return __wordArrayCreate(words, bytes.length);
|
||||
}
|
||||
|
||||
function __normalizeWordArrayInput(value) {
|
||||
if (__isWordArray(value)) return __wordArrayToBytes(value);
|
||||
if (typeof value === 'string') return new TextEncoder().encode(value);
|
||||
return __toUint8Array(value);
|
||||
}
|
||||
|
||||
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) {
|
||||
return __bytesToHex(__wordArrayToBytes(wordArray));
|
||||
},
|
||||
parse: function(hexStr) {
|
||||
return __bytesToWordArray(__hexToBytes(hexStr));
|
||||
}
|
||||
},
|
||||
Utf8: {
|
||||
stringify: function(wordArray) {
|
||||
return new TextDecoder('utf-8').decode(__wordArrayToBytes(wordArray));
|
||||
},
|
||||
parse: function(utf8Str) {
|
||||
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]);
|
||||
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);
|
||||
}
|
||||
},
|
||||
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: { 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))); },
|
||||
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 || {};
|
||||
var pBytes = __normalizeWordArrayInput(pass);
|
||||
var sBytes = __normalizeWordArrayInput(salt);
|
||||
var iter = options.iterations || 1000;
|
||||
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;
|
||||
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 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) {
|
||||
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);
|
||||
},
|
||||
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 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 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));
|
||||
return __bytesToArrayBuffer(__nativeAesBytes(true, mode, __toUint8Array(key._raw), ivBytes, __toUint8Array(data)));
|
||||
},
|
||||
decrypt: async function(params, key, data) {
|
||||
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));
|
||||
return __bytesToArrayBuffer(__nativeAesBytes(false, mode, __toUint8Array(key._raw), ivBytes, __toUint8Array(data)));
|
||||
},
|
||||
sign: async function(algo, key, data) {
|
||||
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) {
|
||||
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) 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);
|
||||
}
|
||||
};
|
||||
|
||||
// WebAssembly placeholder
|
||||
globalThis.WebAssembly = {
|
||||
instantiate: async function(bufferSource, importObject) {
|
||||
console.warn("WebAssembly.instantiate called (placeholder)");
|
||||
return { instance: { exports: {} }, module: {} };
|
||||
}
|
||||
};
|
||||
""".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) {
|
||||
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()
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
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_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.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(response.body),
|
||||
"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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
* 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) {
|
||||
// Placeholder for WASM instantiation bridge
|
||||
// runtime.function("__native_wasm_instantiate") { ... }
|
||||
}
|
||||
}
|
||||
|
|
@ -2,21 +2,396 @@ 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
|
||||
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.*
|
||||
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)
|
||||
val status = SecRandomCopyBytes(kSecRandomDefault, length.toULong(), bytes.refTo(0))
|
||||
require(status == 0) { "Failed to generate secure random bytes: status $status" }
|
||||
return bytes
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
internal fun pluginDigest(algorithm: String, data: ByteArray): ByteArray {
|
||||
val normalized = normalizeDigestAlgorithm(algorithm)
|
||||
val output = ByteArray(
|
||||
when (normalized) {
|
||||
"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")
|
||||
},
|
||||
)
|
||||
|
||||
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)
|
||||
"SHA384" -> CC_SHA384(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 {
|
||||
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)
|
||||
|
||||
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 {
|
||||
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
|
||||
memScoped {
|
||||
val cryptorRefVar = alloc<com.nuvio.app.features.plugins.cryptointerop.CCCryptorRefVar>()
|
||||
|
||||
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<platform.posix.size_tVar>()
|
||||
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)
|
||||
|
||||
var finalData: ByteArray? = null
|
||||
|
||||
memScoped {
|
||||
val dataOutMoved = alloc<platform.posix.size_tVar>()
|
||||
|
||||
var options = 0U
|
||||
if (isEcb) {
|
||||
options = options or kCCOptionECBMode
|
||||
}
|
||||
if (!isNoPadding) {
|
||||
options = options or 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 {
|
||||
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" }
|
||||
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<com.nuvio.app.features.plugins.cryptointerop.CCCryptorRefVar>()
|
||||
|
||||
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<platform.posix.size_tVar>()
|
||||
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)
|
||||
|
||||
var finalData: ByteArray? = null
|
||||
|
||||
memScoped {
|
||||
val dataOutMoved = alloc<platform.posix.size_tVar>()
|
||||
|
||||
var options = 0U
|
||||
if (isEcb) {
|
||||
options = options or kCCOptionECBMode
|
||||
}
|
||||
if (!isNoPadding) {
|
||||
options = options or 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')
|
||||
|
|
@ -24,62 +399,130 @@ 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) {
|
||||
"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")
|
||||
},
|
||||
)
|
||||
|
||||
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)
|
||||
"SHA384" -> CC_SHA384(dataPtr, input.size.toUInt(), outputPtr)
|
||||
"SHA512" -> CC_SHA512(dataPtr, input.size.toUInt(), outputPtr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return output.toHex()
|
||||
}
|
||||
|
||||
@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<UByteVar>(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
"SHA384" -> "SHA384"
|
||||
"SHA512" -> "SHA512"
|
||||
else -> error("Unsupported digest algorithm: $algorithm")
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
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")
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
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()
|
||||
"SHA384" -> kCCHmacAlgSHA384 to CC_SHA384_DIGEST_LENGTH.toInt()
|
||||
"SHA512" -> kCCHmacAlgSHA512 to CC_SHA512_DIGEST_LENGTH.toInt()
|
||||
else -> error("Unsupported HMAC algorithm: $algorithm")
|
||||
}
|
||||
|
||||
val output = UByteArray(outputSize)
|
||||
CCHmac(
|
||||
alg,
|
||||
keyBytes.refTo(0),
|
||||
keyBytes.size.toULong(),
|
||||
input.refTo(0),
|
||||
input.size.toULong(),
|
||||
output.refTo(0),
|
||||
)
|
||||
|
||||
return output.toHex()
|
||||
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 ByteArray.toHex(): String =
|
||||
joinToString(separator = "") { byte ->
|
||||
byte.toUByte().toString(16).padStart(2, '0')
|
||||
}
|
||||
|
||||
private fun String.normalizedAlgorithmToken(): String =
|
||||
uppercase()
|
||||
.replace("-", "")
|
||||
.replace("_", "")
|
||||
.replace("/", "")
|
||||
.replace(" ", "")
|
||||
|
||||
@OptIn(ExperimentalEncodingApi::class)
|
||||
internal fun pluginBase64Encode(data: String): String =
|
||||
Base64.encode(data.encodeToByteArray())
|
||||
|
||||
@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()
|
||||
}
|
||||
|
|
@ -89,11 +532,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)
|
||||
|
|
@ -101,5 +544,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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ actual fun PlatformPlayerSurface(
|
|||
sourceAudioUrl: String?,
|
||||
sourceHeaders: Map<String, String>,
|
||||
sourceResponseHeaders: Map<String, String>,
|
||||
externalSubtitles: List<com.nuvio.app.features.streams.StreamSubtitle>,
|
||||
streamType: String?,
|
||||
useYoutubeChunkedPlayback: Boolean,
|
||||
modifier: Modifier,
|
||||
|
|
@ -249,12 +250,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()
|
||||
|
|
@ -400,6 +402,13 @@ private fun Int.toHexByte(): String {
|
|||
}
|
||||
}
|
||||
|
||||
private fun encodeExternalSubtitlesForBridge(subtitles: List<com.nuvio.app.features.streams.StreamSubtitle>): String? {
|
||||
if (subtitles.isEmpty()) return null
|
||||
return runCatching {
|
||||
Json.encodeToString(subtitles)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun encodePlaybackHeadersForBridge(headers: Map<String, String>): String? {
|
||||
val sanitized = sanitizePlaybackHeaders(headers)
|
||||
if (sanitized.isEmpty()) {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
@ -31,3 +33,158 @@ 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 void *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
|
||||
);
|
||||
|
||||
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
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -16,13 +16,34 @@ 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]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func play() { playerVC?.playPlayback() }
|
||||
func pause() { playerVC?.pausePlayback() }
|
||||
func seekTo(positionMs: Int64) { playerVC?.seekToMs(positionMs) }
|
||||
|
|
@ -173,6 +194,13 @@ final class MPVPlayerBridgeImpl: NSObject, NuvioPlayerBridge {
|
|||
}
|
||||
}
|
||||
|
||||
struct PluginSubtitle {
|
||||
let url: String
|
||||
let language: String
|
||||
let name: String?
|
||||
let headers: [String: String]?
|
||||
}
|
||||
|
||||
// MARK: - Track Info
|
||||
|
||||
struct TrackInfo {
|
||||
|
|
@ -188,6 +216,7 @@ private struct PendingLoadRequest {
|
|||
let urlString: String
|
||||
let audioUrl: String?
|
||||
let requestHeaders: [String: String]
|
||||
let subtitles: [PluginSubtitle]
|
||||
let queuedAtUptime: TimeInterval
|
||||
}
|
||||
|
||||
|
|
@ -376,11 +405,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
|
||||
)
|
||||
|
||||
|
|
@ -428,6 +458,12 @@ final class MPVPlayerViewController: UIViewController {
|
|||
self?.command("audio-add", args: [audioUrl, "select"], checkForErrors: false)
|
||||
}
|
||||
}
|
||||
|
||||
for subtitle in request.subtitles {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
|
||||
self?.addSubtitle(subtitle, mode: "auto")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func isViewportReadyForPlayback(queuedAtUptime: TimeInterval) -> Bool {
|
||||
|
|
@ -572,6 +608,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")
|
||||
|
|
|
|||
Loading…
Reference in a new issue