From 3f5d2346b4eb731283932b982faaf3a5dbd3b8aa Mon Sep 17 00:00:00 2001 From: paregi12 Date: Sat, 20 Jun 2026 03:37:26 +0530 Subject: [PATCH 01/20] Add Cloudflare solver for plugin fetch --- .../plugins/PluginPlatform.android.kt | 17 ++ .../network/CloudflareKiller.android.kt | 232 ++++++++++++++++++ .../CloudflareRuntimeInitializer.android.kt | 9 + .../kotlin/com/nuvio/app/MainActivity.kt | 2 + .../CloudflareRuntimeInitializer.android.kt | 7 + .../features/plugins/runtime/PluginRuntime.kt | 2 +- .../features/plugins/runtime/js/JsBindings.kt | 3 +- .../runtime/network/CloudflareKiller.kt | 138 +++++++++++ .../plugins/runtime/network/FetchBridge.kt | 133 +++++++++- .../features/plugins/PluginPlatform.ios.kt | 14 ++ .../runtime/network/CloudflareKiller.ios.kt | 161 ++++++++++++ 11 files changed, 712 insertions(+), 6 deletions(-) create mode 100644 composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.android.kt create mode 100644 composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareRuntimeInitializer.android.kt create mode 100644 composeApp/src/androidPlaystore/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareRuntimeInitializer.android.kt create mode 100644 composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.kt create mode 100644 composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.ios.kt diff --git a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginPlatform.android.kt b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginPlatform.android.kt index 525b6c6f..96af5b11 100644 --- a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginPlatform.android.kt +++ b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginPlatform.android.kt @@ -32,6 +32,23 @@ internal object PluginStorage { ?.putString("settings_${scraperId}", payload) ?.apply() } + + fun loadCfSession(host: String): String? = + preferences?.getString("cf_session_${host}", null) + + fun saveCfSession(host: String, payload: String) { + preferences + ?.edit() + ?.putString("cf_session_${host}", payload) + ?.apply() + } + + fun removeCfSession(host: String) { + preferences + ?.edit() + ?.remove("cf_session_${host}") + ?.apply() + } } internal fun currentPluginPlatform(): String = "android" diff --git a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.android.kt b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.android.kt new file mode 100644 index 00000000..fea41130 --- /dev/null +++ b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.android.kt @@ -0,0 +1,232 @@ +package com.nuvio.app.features.plugins.runtime.network + +import android.annotation.SuppressLint +import android.content.Context +import android.net.Uri +import android.net.http.SslError +import android.webkit.CookieManager +import android.webkit.SslErrorHandler +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebView +import android.webkit.WebViewClient +import co.touchlab.kermit.Logger +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +internal fun platformWebViewSolverImpl(): WebViewSolver = AndroidWebViewSolver + +internal object AndroidWebViewSolver : WebViewSolver { + private val log = Logger.withTag("AndroidWebViewSolver") + + @Volatile + private var appContext: Context? = null + + private val blockedPathSuffixes = listOf( + ".jpg", + ".jpeg", + ".png", + ".webp", + ".mpg", + ".mpeg", + ".mp4", + ".webm", + ".gifv", + ".flv", + ".asf", + ".mov", + ".mng", + ".mkv", + ".ogg", + ".avi", + ".mp3", + ".wav", + ".woff2", + ".woff", + ".ttf", + ".css", + ".vtt", + ".srt", + ".ts", + ".gif", + ) + + fun initialize(context: Context) { + appContext = context.applicationContext + } + + @SuppressLint("SetJavaScriptEnabled") + override suspend fun solve( + url: String, + headers: Map, + forceFresh: Boolean, + timeoutMs: Long, + ): CfSolveResult? { + val context = appContext ?: run { + log.e { "AndroidWebViewSolver is not initialized" } + return null + } + + val host = extractHost(url) + val cookieManager = CookieManager.getInstance() + withContext(Dispatchers.Main) { + cookieManager.setAcceptCookie(true) + } + + if (!forceFresh) { + val existing = withContext(Dispatchers.Main) { + runCatching { cookieManager.getCookie(url) }.getOrNull() + } + if (existing?.contains("cf_clearance") == true) { + return CfSolveResult( + cookies = parseCookieString(existing), + userAgent = captureUserAgent(context), + ) + } + } + + runCatching { clearCookiesForUrl(url) } + + var webViewRef: WebView? = null + var webViewUserAgent = "" + val finalUrlRef = java.util.concurrent.atomic.AtomicReference(url) + + try { + withContext(Dispatchers.Main) { + val webView = WebView(context).apply { + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + webViewUserAgent = settings.userAgentString + + CookieManager.getInstance().setAcceptThirdPartyCookies(this, true) + + webViewClient = object : WebViewClient() { + override fun onPageStarted( + view: WebView?, + pageUrl: String?, + favicon: android.graphics.Bitmap?, + ) { + if (pageUrl != null && !pageUrl.contains("challenges.cloudflare.com")) { + finalUrlRef.set(pageUrl) + } + super.onPageStarted(view, pageUrl, favicon) + } + + override fun onPageFinished(view: WebView?, pageUrl: String?) { + if (pageUrl != null && !pageUrl.contains("challenges.cloudflare.com")) { + finalUrlRef.set(pageUrl) + } + super.onPageFinished(view, pageUrl) + } + + override fun shouldInterceptRequest( + view: WebView, + request: WebResourceRequest, + ): WebResourceResponse? { + val requestUrl = request.url.toString() + + if (requestUrl.contains("recaptcha") || requestUrl.contains("/cdn-cgi/")) { + return super.shouldInterceptRequest(view, request) + } + + if (requestUrl.endsWith("/favicon.ico") || requestUrl.startsWith("wss://")) { + return WebResourceResponse("text/plain", "utf-8", null) + } + + val path = runCatching { Uri.parse(requestUrl).path.orEmpty() }.getOrDefault("") + if (blockedPathSuffixes.any { path.contains(it, ignoreCase = true) }) { + return WebResourceResponse("text/plain", "utf-8", null) + } + + return super.shouldInterceptRequest(view, request) + } + + @SuppressLint("WebViewClientOnReceivedSslError") + override fun onReceivedSslError( + view: WebView?, + handler: SslErrorHandler?, + error: SslError?, + ) { + handler?.proceed() + } + } + } + webViewRef = webView + webView.loadUrl(url, headers) + } + + val result = withTimeoutOrNull(timeoutMs) { + var solved: CfSolveResult? = null + while (solved == null) { + delay(100L) + val currentUrl = finalUrlRef.get() + val raw = runCatching { + cookieManager.getCookie(currentUrl) ?: cookieManager.getCookie(url) + }.getOrNull() + + if (raw?.contains("cf_clearance") == true) { + delay(500L) + val finalUrl = finalUrlRef.get() + val finalRaw = runCatching { + cookieManager.getCookie(finalUrl) ?: cookieManager.getCookie(url) + }.getOrNull() ?: raw + solved = CfSolveResult( + cookies = parseCookieString(finalRaw), + userAgent = webViewUserAgent, + redirectUrl = finalUrl.takeIf { it != url }, + ) + } + } + solved + } + + if (result == null) { + log.w { "CF solve timed out after ${timeoutMs}ms for $host" } + } + return result + } finally { + withContext(NonCancellable + Dispatchers.Main) { + webViewRef?.stopLoading() + webViewRef?.destroy() + webViewRef = null + } + } + } + + private suspend fun clearCookiesForUrl(url: String) = withContext(Dispatchers.Main) { + val cookieString = CookieManager.getInstance().getCookie(url).orEmpty() + if (cookieString.isBlank()) return@withContext + + val host = Uri.parse(url).host ?: return@withContext + val rootDomain = host.split(".").takeIf { it.size > 2 } + ?.takeLast(2) + ?.joinToString(separator = ".", prefix = ".") + val cookieNames = parseCookieString(cookieString).keys + + cookieNames.forEach { name -> + expireCookie(url, "$name=; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/; Domain=$host") + if (rootDomain != null) { + expireCookie(url, "$name=; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/; Domain=$rootDomain") + } + } + CookieManager.getInstance().flush() + } + + private suspend fun expireCookie(url: String, value: String) = suspendCoroutine { cont -> + CookieManager.getInstance().setCookie(url, value) { + cont.resume(Unit) + } + } + + private suspend fun captureUserAgent(context: Context): String = withContext(Dispatchers.Main) { + val webView = WebView(context) + val userAgent = webView.settings.userAgentString + webView.destroy() + userAgent + } +} diff --git a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareRuntimeInitializer.android.kt b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareRuntimeInitializer.android.kt new file mode 100644 index 00000000..f504edd3 --- /dev/null +++ b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareRuntimeInitializer.android.kt @@ -0,0 +1,9 @@ +package com.nuvio.app.features.plugins.runtime.network + +import android.content.Context + +internal object CloudflareRuntimeInitializer { + fun initialize(context: Context) { + AndroidWebViewSolver.initialize(context) + } +} diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt index 745c91d5..52a4e76d 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt @@ -34,6 +34,7 @@ import com.nuvio.app.features.player.PlayerPictureInPictureManager import com.nuvio.app.features.p2p.P2pSettingsStorage import com.nuvio.app.features.p2p.P2pStreamingEngine import com.nuvio.app.features.plugins.PluginStorage +import com.nuvio.app.features.plugins.runtime.network.CloudflareRuntimeInitializer import com.nuvio.app.features.profiles.AvatarStorage import com.nuvio.app.features.profiles.ProfilePinCacheStorage import com.nuvio.app.features.profiles.ProfileStorage @@ -102,6 +103,7 @@ class MainActivity : AppCompatActivity() { StreamBadgeSettingsStorage.initialize(applicationContext) BingeGroupCacheStorage.initialize(applicationContext) PluginStorage.initialize(applicationContext) + CloudflareRuntimeInitializer.initialize(applicationContext) CollectionMobileSettingsStorage.initialize(applicationContext) CollectionStorage.initialize(applicationContext) DownloadsStorage.initialize(applicationContext) diff --git a/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareRuntimeInitializer.android.kt b/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareRuntimeInitializer.android.kt new file mode 100644 index 00000000..3718ff33 --- /dev/null +++ b/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareRuntimeInitializer.android.kt @@ -0,0 +1,7 @@ +package com.nuvio.app.features.plugins.runtime.network + +import android.content.Context + +internal object CloudflareRuntimeInitializer { + fun initialize(context: Context) = Unit +} diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt index fc014ee7..25c49d9f 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt @@ -132,7 +132,7 @@ internal object PluginRuntime { val domBridge = DomBridge() val hostRegistry = HostApiRegistry().apply { addModule(HostFunctions(scraperId) { deferred.complete(it) }) - addModule(FetchBridge()) + addModule(FetchBridge(scraperId)) addModule(UrlBridge()) addModule(CryptoBridge()) addModule(WasmBridge()) diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt index 5e239048..a3d3919c 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt @@ -47,7 +47,8 @@ internal object JsBindings { 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 useCfKiller = options.cfKiller === true; + var result = __native_fetch(url, method, JSON.stringify(headers), body, followRedirects, useCfKiller); var parsed = JSON.parse(result); return { ok: parsed.ok, diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.kt new file mode 100644 index 00000000..ae862eb0 --- /dev/null +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.kt @@ -0,0 +1,138 @@ +package com.nuvio.app.features.plugins.runtime.network + +import kotlinx.coroutines.sync.Mutex +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put + +internal data class CfSolveResult( + val cookies: Map, + val userAgent: String, + val redirectUrl: String? = null, +) + +internal interface WebViewSolver { + suspend fun solve( + url: String, + headers: Map = emptyMap(), + forceFresh: Boolean = false, + timeoutMs: Long = 60_000L, + ): CfSolveResult? +} + +internal fun createPlatformWebViewSolver(): WebViewSolver = platformWebViewSolverImpl() + +internal object CfSessionCache { + private val sessions = mutableMapOf() + private val hostLocks = mutableMapOf() + + @Synchronized + fun getMutex(host: String): Mutex = hostLocks.getOrPut(host) { Mutex() } + + @Synchronized + fun get(host: String, pluginId: String): CfSolveResult? { + sessions[host]?.let { return it } + val persisted = runCatching { + com.nuvio.app.features.plugins.PluginStorage.loadCfSession(host) + }.getOrNull() + val parsed = persisted?.let(::jsonStringToCfSolveResult) ?: return null + sessions[host] = parsed + return parsed + } + + @Synchronized + fun put(host: String, pluginId: String, result: CfSolveResult) { + sessions[host] = result + runCatching { + com.nuvio.app.features.plugins.PluginStorage.saveCfSession(host, result.toJsonString()) + } + } + + @Synchronized + fun evict(host: String, pluginId: String) { + sessions.remove(host) + runCatching { + com.nuvio.app.features.plugins.PluginStorage.removeCfSession(host) + } + } + + @Synchronized + fun evictIfSame(host: String, result: CfSolveResult) { + if (sessions[host] == result) { + sessions.remove(host) + runCatching { + com.nuvio.app.features.plugins.PluginStorage.removeCfSession(host) + } + } + } + + @Synchronized + fun clear() { + sessions.clear() + } +} + +private val COOKIE_DIRECTIVES = setOf( + "path", + "domain", + "expires", + "max-age", + "secure", + "httponly", + "samesite", + "priority", +) + +internal fun parseCookieString(raw: String): Map = + raw.split(";").mapNotNull { part -> + val eq = part.indexOf('=') + if (eq < 0) return@mapNotNull null + val name = part.substring(0, eq).trim() + val value = part.substring(eq + 1).trim() + if (name.isBlank() || value.isBlank() || name.lowercase() in COOKIE_DIRECTIVES) { + null + } else { + name to value + } + }.toMap() + +internal fun Map.toCookieHeader(): String = + entries.joinToString("; ") { (key, value) -> "$key=$value" } + +internal fun extractHost(url: String): String = runCatching { + val authority = url + .substringAfter("://", url) + .substringBefore("/") + .substringBefore("?") + .substringBefore("#") + .substringAfterLast("@") + if (authority.startsWith("[")) { + authority.substringBefore("]").removePrefix("[") + } else { + authority.substringBefore(":") + }.lowercase() +}.getOrDefault(url) + +private fun CfSolveResult.toJsonString(): String = buildJsonObject { + put("cookies", buildJsonObject { + cookies.forEach { (key, value) -> put(key, value) } + }) + put("userAgent", userAgent) + redirectUrl?.let { put("redirectUrl", it) } +}.toString() + +private fun jsonStringToCfSolveResult(raw: String): CfSolveResult? = runCatching { + val obj = Json.parseToJsonElement(raw).jsonObject + val cookies = obj["cookies"]?.jsonObject?.entries?.associate { (key, value) -> + key to value.jsonPrimitive.content + }.orEmpty() + val userAgent = obj["userAgent"]?.jsonPrimitive?.content.orEmpty() + val redirectUrl = obj["redirectUrl"]?.jsonPrimitive?.content + if (cookies.isEmpty() || userAgent.isBlank()) { + null + } else { + CfSolveResult(cookies = cookies, userAgent = userAgent, redirectUrl = redirectUrl) + } +}.getOrNull() diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/network/FetchBridge.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/network/FetchBridge.kt index 22216c58..0d6655fc 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/network/FetchBridge.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/network/FetchBridge.kt @@ -5,7 +5,9 @@ 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.delay import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.withLock import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive @@ -14,10 +16,13 @@ import kotlinx.serialization.json.jsonPrimitive private const val MAX_FETCH_HEADER_VALUE_CHARS = 8 * 1024 private const val FETCH_TRUNCATION_SUFFIX = "\n...[truncated]" +private val CF_BLOCK_CODES = setOf(403, 503) +private val CF_SERVER_MARKERS = setOf("cloudflare", "cloudflare-nginx") -internal class FetchBridge : HostModule { +internal class FetchBridge(private val pluginId: String) : HostModule { private val log = Logger.withTag("PluginRuntime") private val json = Json { ignoreUnknownKeys = true } + private val cfSolver: WebViewSolver by lazy { createPlatformWebViewSolver() } override fun register(runtime: QuickJs) { runtime.function("__native_fetch") { args -> @@ -26,8 +31,9 @@ internal class FetchBridge : HostModule { val headersJson = args.getOrNull(2)?.toString() ?: "{}" val body = args.getOrNull(3)?.toString() ?: "" val followRedirects = args.getOrNull(4) as? Boolean ?: true + val useCfKiller = args.getOrNull(5) as? Boolean ?: false try { - performNativeFetch(url, method, headersJson, body, followRedirects) + performNativeFetch(url, method, headersJson, body, followRedirects, useCfKiller) } catch (t: Throwable) { log.e(t) { "Fetch bridge error for $method $url" } JsonObject( @@ -50,10 +56,25 @@ internal class FetchBridge : HostModule { headersJson: String, body: String, followRedirects: Boolean, + useCfKiller: 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" + if (headers.getIgnoreCase("User-Agent") == null) { + headers["User-Agent"] = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Mobile Safari/537.36" + } + + val host = extractHost(url) + var usedCachedSession: CfSolveResult? = null + if (useCfKiller) { + CfSessionCache.get(host, pluginId)?.let { cached -> + headers.setIgnoreCase("User-Agent", cached.userAgent) + headers.setIgnoreCase( + "Cookie", + mergeCookies(headers.getIgnoreCase("Cookie").orEmpty(), cached.cookies.toCookieHeader()), + ) + usedCachedSession = cached + log.d { "CF: using cached session for $host" } + } } val response = runBlocking { @@ -66,6 +87,81 @@ internal class FetchBridge : HostModule { ) } + if (useCfKiller && isCloudflareBlocked(response.status, response.headers)) { + log.i { "CF: blocked (${response.status}) at $url; launching WebView solver" } + val solveResult = runBlocking { + CfSessionCache.getMutex(host).withLock { + usedCachedSession?.let { CfSessionCache.evictIfSame(host, it) } + + CfSessionCache.get(host, pluginId) ?: cfSolver + .solve( + url = url, + headers = webViewHeaders(headers), + forceFresh = true, + ) + ?.also { solved -> + CfSessionCache.put(host, pluginId, solved) + } + } + } + + if (solveResult != null) { + solveResult.redirectUrl?.let { redirectUrl -> + val redirectHost = extractHost(redirectUrl) + if (redirectHost != host) { + CfSessionCache.put(redirectHost, pluginId, solveResult) + } + } + + val retryHeaders = parseHeaders(headersJson).toMutableMap() + retryHeaders.setIgnoreCase("User-Agent", solveResult.userAgent) + retryHeaders.setIgnoreCase( + "Cookie", + mergeCookies(retryHeaders.getIgnoreCase("Cookie").orEmpty(), solveResult.cookies.toCookieHeader()), + ) + + val retryUrl = solveResult.redirectUrl ?: url + var retryResponse = runBlocking { + httpRequestRaw( + method = method, + url = retryUrl, + headers = retryHeaders, + body = body, + followRedirects = followRedirects, + ) + } + + if (isCloudflareBlocked(retryResponse.status, retryResponse.headers)) { + runBlocking { delay(500L) } + retryResponse = runBlocking { + httpRequestRaw( + method = method, + url = retryUrl, + headers = retryHeaders, + body = body, + followRedirects = followRedirects, + ) + } + } + + if (isCloudflareBlocked(retryResponse.status, retryResponse.headers)) { + log.w { "CF: retry still blocked (${retryResponse.status}) at $retryUrl; evicting session" } + CfSessionCache.evict(host, pluginId) + solveResult.redirectUrl?.let { CfSessionCache.evict(extractHost(it), pluginId) } + } else { + log.i { "CF: solve success (${retryResponse.status}) for $retryUrl" } + } + + return buildResponseJson(retryResponse) + } + + log.w { "CF: WebView solver timed out for $url" } + } + + return buildResponseJson(response) + } + + private fun buildResponseJson(response: com.nuvio.app.features.addons.RawHttpResponse): String { val responseHeaders = response.headers.mapKeys { (key, _) -> key.lowercase() } .mapValues { (_, value) -> truncateString(value, MAX_FETCH_HEADER_VALUE_CHARS) } val result = JsonObject( @@ -92,10 +188,39 @@ internal class FetchBridge : HostModule { }.getOrDefault(emptyMap()) } + private fun isCloudflareBlocked(status: Int, headers: Map): Boolean { + if (status !in CF_BLOCK_CODES) return false + val server = headers.getIgnoreCase("Server").orEmpty().lowercase() + val hasCfServer = CF_SERVER_MARKERS.any { marker -> server.contains(marker) } + val hasCfHeaders = headers.keys.any { key -> key.lowercase().startsWith("cf-") } + return hasCfServer || hasCfHeaders + } + + private fun mergeCookies(existing: String, extra: String): String { + if (existing.isBlank()) return extra + if (extra.isBlank()) return existing + return (parseCookieString(existing) + parseCookieString(extra)).toCookieHeader() + } + + private fun webViewHeaders(headers: Map): Map = + headers.filterKeys { key -> + !key.equals("User-Agent", ignoreCase = true) && + !key.equals("Cookie", ignoreCase = true) && + !key.equals("X-Requested-With", ignoreCase = true) + } + 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 MutableMap.setIgnoreCase(name: String, value: String) { + keys.filter { it.equals(name, ignoreCase = true) }.forEach { remove(it) } + put(name, value) + } + + private fun Map.getIgnoreCase(name: String): String? = + entries.firstOrNull { (key, _) -> key.equals(name, ignoreCase = true) }?.value } diff --git a/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginPlatform.ios.kt b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginPlatform.ios.kt index 1a8ddaac..ca6981fa 100644 --- a/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginPlatform.ios.kt +++ b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginPlatform.ios.kt @@ -25,6 +25,20 @@ internal object PluginStorage { forKey = "settings_${scraperId}", ) } + + fun loadCfSession(host: String): String? = + NSUserDefaults.standardUserDefaults.stringForKey("cf_session_${host}") + + fun saveCfSession(host: String, payload: String) { + NSUserDefaults.standardUserDefaults.setObject( + payload, + forKey = "cf_session_${host}", + ) + } + + fun removeCfSession(host: String) { + NSUserDefaults.standardUserDefaults.removeObjectForKey("cf_session_${host}") + } } internal fun currentPluginPlatform(): String = "ios" diff --git a/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.ios.kt b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.ios.kt new file mode 100644 index 00000000..523942e8 --- /dev/null +++ b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.ios.kt @@ -0,0 +1,161 @@ +package com.nuvio.app.features.plugins.runtime.network + +import co.touchlab.kermit.Logger +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import platform.Foundation.NSHTTPCookie +import platform.Foundation.NSURL +import platform.Foundation.NSURLRequest +import platform.WebKit.WKHTTPCookieStore +import platform.WebKit.WKWebView +import platform.WebKit.WKWebViewConfiguration +import platform.WebKit.WKWebsiteDataStore +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +internal fun platformWebViewSolverImpl(): WebViewSolver = IosWebViewSolver + +@OptIn(ExperimentalForeignApi::class) +internal object IosWebViewSolver : WebViewSolver { + private val log = Logger.withTag("IosWebViewSolver") + private var cachedUserAgent: String? = null + + override suspend fun solve( + url: String, + headers: Map, + forceFresh: Boolean, + timeoutMs: Long, + ): CfSolveResult? { + val host = extractHost(url) + val nsUrl = NSURL.URLWithString(url) ?: run { + log.e { "CF solve: invalid URL: $url" } + return null + } + + val cookieStore = WKWebsiteDataStore.defaultDataStore().httpCookieStore + if (!forceFresh) { + val existing = getWkCookies(cookieStore, host) + if (existing.containsKey("cf_clearance")) { + return CfSolveResult(cookies = existing, userAgent = getOrCaptureUserAgent()) + } + } + + withContext(Dispatchers.Main) { + clearWkCookiesForHost(host) + } + + var webViewRef: WKWebView? = null + val webViewUserAgent = getOrCaptureUserAgent() + + try { + withContext(Dispatchers.Main) { + val config = WKWebViewConfiguration() + val webView = WKWebView( + frame = platform.CoreGraphics.CGRectZero, + configuration = config, + ) + webView.customUserAgent = webViewUserAgent + webViewRef = webView + webView.loadRequest(NSURLRequest.requestWithURL(nsUrl)) + } + + val result = withTimeoutOrNull(timeoutMs) { + var solved: CfSolveResult? = null + while (solved == null) { + delay(100L) + val cookies = getWkCookies(cookieStore, host) + if (cookies.containsKey("cf_clearance")) { + val finalUrl = withContext(Dispatchers.Main) { + webViewRef?.URL?.absoluteString ?: url + } + solved = CfSolveResult( + cookies = cookies, + userAgent = webViewUserAgent, + redirectUrl = finalUrl.takeIf { it != url }, + ) + } + } + solved + } + + if (result == null) { + log.w { "CF solve timed out after ${timeoutMs}ms for $host" } + } + return result + } finally { + withContext(NonCancellable + Dispatchers.Main) { + webViewRef?.stopLoading() + webViewRef = null + } + } + } + + private suspend fun getOrCaptureUserAgent(): String { + cachedUserAgent?.let { return it } + return withContext(Dispatchers.Main) { + val webView = WKWebView( + frame = platform.CoreGraphics.CGRectZero, + configuration = WKWebViewConfiguration(), + ) + val deferred = CompletableDeferred() + webView.evaluateJavaScript("navigator.userAgent") { result, _ -> + deferred.complete( + result as? String + ?: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148", + ) + } + deferred.await().also { cachedUserAgent = it } + } + } + + private suspend fun getWkCookies( + cookieStore: WKHTTPCookieStore, + host: String, + ): Map = suspendCoroutine { cont -> + cookieStore.getAllCookies { rawList -> + @Suppress("UNCHECKED_CAST") + val cookies = (rawList as? List) + ?.filter { cookieMatchesHost(it.domain, host) } + ?.associate { it.name to it.value } + ?.filter { (key, value) -> key.isNotBlank() && value.isNotBlank() } + .orEmpty() + cont.resume(cookies) + } + } + + private suspend fun clearWkCookiesForHost(host: String) = suspendCoroutine { cont -> + val store = WKWebsiteDataStore.defaultDataStore().httpCookieStore + store.getAllCookies { rawList -> + @Suppress("UNCHECKED_CAST") + val cookies = (rawList as? List) + ?.filter { cookieMatchesHost(it.domain, host) } + .orEmpty() + + if (cookies.isEmpty()) { + cont.resume(Unit) + return@getAllCookies + } + + var remaining = cookies.size + cookies.forEach { cookie -> + store.deleteCookie(cookie) { + remaining -= 1 + if (remaining == 0) { + cont.resume(Unit) + } + } + } + } + } + + private fun cookieMatchesHost(domain: String, host: String): Boolean { + val normalizedDomain = domain.trimStart('.').lowercase() + val normalizedHost = host.lowercase() + return normalizedHost == normalizedDomain || normalizedHost.endsWith(".$normalizedDomain") + } +} From df33a39fe88cc8a85e38866f68dc30568bf81457 Mon Sep 17 00:00:00 2001 From: paregi12 Date: Sat, 20 Jun 2026 03:44:39 +0530 Subject: [PATCH 02/20] Add WebView fallback for Cloudflare fetches --- .../network/CloudflareKiller.android.kt | 190 ++++++++++++++++++ .../runtime/network/CloudflareKiller.kt | 14 ++ .../plugins/runtime/network/FetchBridge.kt | 41 +++- 3 files changed, 244 insertions(+), 1 deletion(-) diff --git a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.android.kt b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.android.kt index fea41130..c8098aaf 100644 --- a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.android.kt +++ b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.android.kt @@ -24,6 +24,50 @@ internal fun platformWebViewSolverImpl(): WebViewSolver = AndroidWebViewSolver internal object AndroidWebViewSolver : WebViewSolver { private val log = Logger.withTag("AndroidWebViewSolver") + private const val PAGE_STATE_JS = """ + (function() { + try { + if (document.readyState !== 'interactive' && document.readyState !== 'complete') return 'wait'; + var title = (document.title || '').toLowerCase(); + if (title.indexOf('attention required') !== -1 || title.indexOf('access denied') !== -1) return 'blocked'; + if (title.indexOf('just a moment') !== -1) return 'wait'; + if (document.querySelector('#challenge-running, #challenge-stage, #cf-challenge-running, .cf-browser-verification, #turnstile-wrapper, #cf-please-wait, script[src*="challenge-platform"]')) return 'wait'; + if (!document.documentElement || !document.body) return 'wait'; + var contentType = (document.contentType || '').toLowerCase(); + if ((contentType.indexOf('json') !== -1 || contentType.indexOf('text/plain') !== -1) && (document.body.innerText || '').length > 0) return 'ok'; + var html = document.documentElement.outerHTML || ''; + if (html.length < 64) return 'wait'; + return 'ok'; + } catch (e) { + return 'wait'; + } + })() + """ + + private const val PAGE_BODY_JS = """ + (function() { + try { + var contentType = (document.contentType || '').toLowerCase(); + if (contentType.indexOf('json') !== -1 || contentType.indexOf('text/plain') !== -1) { + return document.body ? (document.body.innerText || '') : ''; + } + return document.documentElement ? (document.documentElement.outerHTML || '') : ''; + } catch (e) { + return ''; + } + })() + """ + + private const val PAGE_CONTENT_TYPE_JS = """ + (function() { + try { + return document.contentType || 'text/html'; + } catch (e) { + return 'text/html'; + } + })() + """ + @Volatile private var appContext: Context? = null @@ -198,6 +242,136 @@ internal object AndroidWebViewSolver : WebViewSolver { } } + @SuppressLint("SetJavaScriptEnabled") + override suspend fun fetchRenderedPage( + url: String, + headers: Map, + timeoutMs: Long, + ): WebViewFetchResult? { + val context = appContext ?: run { + log.e { "AndroidWebViewSolver is not initialized" } + return null + } + + var webViewRef: WebView? = null + val finalUrlRef = java.util.concurrent.atomic.AtomicReference(url) + val statusRef = java.util.concurrent.atomic.AtomicInteger(200) + val statusTextRef = java.util.concurrent.atomic.AtomicReference("OK") + val responseHeadersRef = java.util.concurrent.atomic.AtomicReference>(emptyMap()) + + try { + withContext(Dispatchers.Main) { + val webView = WebView(context).apply { + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + CookieManager.getInstance().setAcceptThirdPartyCookies(this, true) + + webViewClient = object : WebViewClient() { + override fun onPageStarted( + view: WebView?, + pageUrl: String?, + favicon: android.graphics.Bitmap?, + ) { + pageUrl?.let(finalUrlRef::set) + super.onPageStarted(view, pageUrl, favicon) + } + + override fun onPageFinished(view: WebView?, pageUrl: String?) { + pageUrl?.let(finalUrlRef::set) + super.onPageFinished(view, pageUrl) + } + + override fun onReceivedHttpError( + view: WebView?, + request: WebResourceRequest?, + errorResponse: WebResourceResponse?, + ) { + if (request?.isForMainFrame == true && errorResponse != null) { + statusRef.set(errorResponse.statusCode) + statusTextRef.set(errorResponse.reasonPhrase ?: "") + responseHeadersRef.set(errorResponse.responseHeaders.orEmpty()) + } + super.onReceivedHttpError(view, request, errorResponse) + } + + override fun shouldInterceptRequest( + view: WebView, + request: WebResourceRequest, + ): WebResourceResponse? { + val requestUrl = request.url.toString() + if (requestUrl.contains("recaptcha") || requestUrl.contains("/cdn-cgi/")) { + return super.shouldInterceptRequest(view, request) + } + if (requestUrl.endsWith("/favicon.ico") || requestUrl.startsWith("wss://")) { + return WebResourceResponse("text/plain", "utf-8", null) + } + return super.shouldInterceptRequest(view, request) + } + + @SuppressLint("WebViewClientOnReceivedSslError") + override fun onReceivedSslError( + view: WebView?, + handler: SslErrorHandler?, + error: SslError?, + ) { + handler?.proceed() + } + } + } + webViewRef = webView + webView.loadUrl(url, headers) + } + + val result = withTimeoutOrNull(timeoutMs) { + var rendered: WebViewFetchResult? = null + while (rendered == null) { + delay(250L) + val state = withContext(Dispatchers.Main) { + webViewRef?.evaluateJavascriptString(PAGE_STATE_JS) + } ?: "wait" + + if (state == "blocked") { + return@withTimeoutOrNull null + } + + if (state == "ok") { + val body = withContext(Dispatchers.Main) { + webViewRef?.evaluateJavascriptString(PAGE_BODY_JS) + }.orEmpty() + if (body.isNotBlank()) { + val contentType = withContext(Dispatchers.Main) { + webViewRef?.evaluateJavascriptString(PAGE_CONTENT_TYPE_JS) + }.orEmpty().ifBlank { "text/html" } + val headersWithContentType = responseHeadersRef.get().toMutableMap() + if (headersWithContentType.keys.none { it.equals("content-type", ignoreCase = true) }) { + headersWithContentType["content-type"] = contentType + } + rendered = WebViewFetchResult( + status = statusRef.get(), + statusText = statusTextRef.get(), + url = finalUrlRef.get(), + body = body, + headers = headersWithContentType, + ) + } + } + } + rendered + } + + if (result == null) { + log.w { "CF WebView fetch fallback timed out after ${timeoutMs}ms for $url" } + } + return result + } finally { + withContext(NonCancellable + Dispatchers.Main) { + webViewRef?.stopLoading() + webViewRef?.destroy() + webViewRef = null + } + } + } + private suspend fun clearCookiesForUrl(url: String) = withContext(Dispatchers.Main) { val cookieString = CookieManager.getInstance().getCookie(url).orEmpty() if (cookieString.isBlank()) return@withContext @@ -229,4 +403,20 @@ internal object AndroidWebViewSolver : WebViewSolver { webView.destroy() userAgent } + + private suspend fun WebView.evaluateJavascriptString(script: String): String = + suspendCoroutine { cont -> + evaluateJavascript(script) { raw -> + cont.resume(decodeJavascriptString(raw)) + } + } + + private fun decodeJavascriptString(raw: String?): String { + if (raw == null || raw == "null") return "" + return runCatching { + org.json.JSONArray("[$raw]").getString(0) + }.getOrElse { + raw.removeSurrounding("\"") + } + } } diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.kt index ae862eb0..02f66301 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.kt @@ -13,6 +13,14 @@ internal data class CfSolveResult( val redirectUrl: String? = null, ) +internal data class WebViewFetchResult( + val status: Int, + val statusText: String, + val url: String, + val body: String, + val headers: Map = emptyMap(), +) + internal interface WebViewSolver { suspend fun solve( url: String, @@ -20,6 +28,12 @@ internal interface WebViewSolver { forceFresh: Boolean = false, timeoutMs: Long = 60_000L, ): CfSolveResult? + + suspend fun fetchRenderedPage( + url: String, + headers: Map = emptyMap(), + timeoutMs: Long = 60_000L, + ): WebViewFetchResult? = null } internal fun createPlatformWebViewSolver(): WebViewSolver = platformWebViewSolverImpl() diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/network/FetchBridge.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/network/FetchBridge.kt index 0d6655fc..969af707 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/network/FetchBridge.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/network/FetchBridge.kt @@ -145,7 +145,20 @@ internal class FetchBridge(private val pluginId: String) : HostModule { } if (isCloudflareBlocked(retryResponse.status, retryResponse.headers)) { - log.w { "CF: retry still blocked (${retryResponse.status}) at $retryUrl; evicting session" } + log.w { "CF: retry still blocked (${retryResponse.status}) at $retryUrl; trying WebView fetch fallback" } + val renderedResponse = runBlocking { + cfSolver.fetchRenderedPage( + url = retryUrl, + headers = webViewHeaders(retryHeaders), + ) + } + + if (renderedResponse != null && !isRenderedCloudflareBlocked(renderedResponse)) { + log.i { "CF: WebView fetch fallback success for ${renderedResponse.url}" } + return buildResponseJson(renderedResponse) + } + + log.w { "CF: WebView fetch fallback failed for $retryUrl; evicting session" } CfSessionCache.evict(host, pluginId) solveResult.redirectUrl?.let { CfSessionCache.evict(extractHost(it), pluginId) } } else { @@ -177,6 +190,22 @@ internal class FetchBridge(private val pluginId: String) : HostModule { return result.toString() } + private fun buildResponseJson(response: WebViewFetchResult): String { + 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 { return runCatching { val obj = json.parseToJsonElement(headersJson) as? JsonObject ?: JsonObject(emptyMap()) @@ -196,6 +225,16 @@ internal class FetchBridge(private val pluginId: String) : HostModule { return hasCfServer || hasCfHeaders } + private fun isRenderedCloudflareBlocked(response: WebViewFetchResult): Boolean { + if (isCloudflareBlocked(response.status, response.headers)) return true + val body = response.body.lowercase() + return body.contains("cf-browser-verification") || + body.contains("challenge-platform") || + body.contains("cf-challenge-running") || + body.contains("turnstile-wrapper") || + body.contains("just a moment") + } + private fun mergeCookies(existing: String, extra: String): String { if (existing.isBlank()) return extra if (extra.isBlank()) return existing From 2e9aca44945bb64cb813ea3844ce05d2e7c08d54 Mon Sep 17 00:00:00 2001 From: paregi12 Date: Sun, 28 Jun 2026 01:16:02 +0530 Subject: [PATCH 03/20] Add files via upload --- .github/workflows/duild-mobile.yml | 189 +++++++++++++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 .github/workflows/duild-mobile.yml diff --git a/.github/workflows/duild-mobile.yml b/.github/workflows/duild-mobile.yml new file mode 100644 index 00000000..04107ce2 --- /dev/null +++ b/.github/workflows/duild-mobile.yml @@ -0,0 +1,189 @@ +name: Build Nuvio Mobile (iOS & Android) + +on: + push: + branches: + - 'kmp' + - '*' + workflow_dispatch: + +jobs: + build-ios-full: + name: Build iOS Full Version + runs-on: macos-latest + env: + GRADLE_OPTS: "-Dorg.gradle.jvmargs=-Xmx11264M -Dkotlin.daemon.jvmargs=-Xmx11264M -Dkotlin.native.jvmargs=-Xmx11264M" + steps: + - name: Checkout Repository & Submodules + uses: actions/checkout@v4 + + - name: Fetch iOS Specific Submodule + run: | + set -euo pipefail + git submodule sync -- MPVKit + git submodule update --init --depth 1 -- MPVKit + + - name: Pull Git LFS files + run: | + set -euo pipefail + git lfs install --local + git lfs pull + cd MPVKit + git lfs install --local + git lfs pull + + - name: Select Latest Stable Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + + - name: Make gradlew executable + run: chmod +x ./gradlew + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Generate local.properties + env: + SUPABASE_URL_VAR: ${{ secrets.SUPABASE_URL || 'https://dpyhjjcoabcglfmgecug.supabase.co' }} + SUPABASE_ANON_KEY_VAR: ${{ secrets.SUPABASE_ANON_KEY || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImRweWhqamNvYWJjZ2xmbWdlY3VnIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzA3OTAwMDAwfQ.dummy' }} + TRAKT_CLIENT_ID_VAR: ${{ secrets.TRAKT_CLIENT_ID || 'dummy-key' }} + TRAKT_CLIENT_SECRET_VAR: ${{ secrets.TRAKT_CLIENT_SECRET || 'dummy-key' }} + TRAKT_REDIRECT_URI_VAR: ${{ secrets.TRAKT_REDIRECT_URI || 'nuvio://auth/trakt' }} + PREMIUMIZE_CLIENT_ID_VAR: ${{ secrets.PREMIUMIZE_CLIENT_ID || 'dummy-key' }} + PREMIUMIZE_CLIENT_SECRET_VAR: ${{ secrets.PREMIUMIZE_CLIENT_SECRET || 'dummy-key' }} + PREMIUMIZE_REDIRECT_URI_VAR: ${{ secrets.PREMIUMIZE_REDIRECT_URI || 'nuvio://auth/trakt' }} + TMDB_API_KEY_VAR: ${{ secrets.TMDB_API_KEY || 'dummy-key' }} + INTRODB_API_URL_VAR: ${{ secrets.INTRODB_API_URL || 'https://api.introdb.app' }} + CONTRIBUTIONS_URL_VAR: ${{ secrets.CONTRIBUTIONS_URL || '' }} + DONATIONS_BASE_URL_VAR: ${{ secrets.DONATIONS_BASE_URL || '' }} + DONATIONS_DONATE_URL_VAR: ${{ secrets.DONATIONS_DONATE_URL || '' }} + DIRECT_DEBRID_API_BASE_URL_VAR: ${{ secrets.DIRECT_DEBRID_API_BASE_URL || '' }} + run: | + cat << EOF > local.properties + SUPABASE_URL=${SUPABASE_URL_VAR} + SUPABASE_ANON_KEY=${SUPABASE_ANON_KEY_VAR} + TRAKT_CLIENT_ID=${TRAKT_CLIENT_ID_VAR} + TRAKT_CLIENT_SECRET=${TRAKT_CLIENT_SECRET_VAR} + TRAKT_REDIRECT_URI=${TRAKT_REDIRECT_URI_VAR} + PREMIUMIZE_CLIENT_ID=${PREMIUMIZE_CLIENT_ID_VAR} + PREMIUMIZE_CLIENT_SECRET=${PREMIUMIZE_CLIENT_SECRET_VAR} + PREMIUMIZE_REDIRECT_URI=${PREMIUMIZE_REDIRECT_URI_VAR} + TMDB_API_KEY=${TMDB_API_KEY_VAR} + INTRODB_API_URL=${INTRODB_API_URL_VAR} + CONTRIBUTIONS_URL=${CONTRIBUTIONS_URL_VAR} + DONATIONS_BASE_URL=${DONATIONS_BASE_URL_VAR} + DONATIONS_DONATE_URL=${DONATIONS_DONATE_URL_VAR} + DIRECT_DEBRID_API_BASE_URL=${DIRECT_DEBRID_API_BASE_URL_VAR} + NUVIO_IOS_DISTRIBUTION=full + EOF + + - name: Build iOS App via Xcode + run: | + rm -rf ~/Library/Caches/org.swift.swiftpm/ + rm -rf ~/Library/Developer/Xcode/DerivedData/ + cd iosApp + rm -rf build + xcodebuild -resolvePackageDependencies -project iosApp.xcodeproj -scheme iosApp -derivedDataPath build + xcodebuild \ + -project iosApp.xcodeproj \ + -scheme iosApp \ + -configuration Release \ + -sdk iphoneos \ + -destination "generic/platform=iOS" \ + -derivedDataPath build \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGN_IDENTITY="" \ + build + + - name: Package App into .ipa + run: | + cd iosApp/build/Build/Products/Release-iphoneos + APP_VERSION=$(/usr/libexec/PlistBuddy -c "Print CFBundleShortVersionString" Nuvio.app/Info.plist) + mkdir -p Payload + mv Nuvio.app Payload/ + zip -q -r "Nuvio-v${APP_VERSION}-Full.ipa" Payload + echo "IPA_FILENAME=Nuvio-v${APP_VERSION}-Full.ipa" >> $GITHUB_ENV + + - name: Upload iOS Artifact + uses: actions/upload-artifact@v4 + with: + name: Nuvio-iOS-Full + path: iosApp/build/Build/Products/Release-iphoneos/${{ env.IPA_FILENAME }} + + build-android: + name: Build Android Full Version + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + + - name: Generate local.properties + env: + SUPABASE_URL_VAR: ${{ secrets.SUPABASE_URL || 'https://dpyhjjcoabcglfmgecug.supabase.co' }} + SUPABASE_ANON_KEY_VAR: ${{ secrets.SUPABASE_ANON_KEY || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImRweWhqamNvYWJjZ2xmbWdlY3VnIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzA3OTAwMDAwfQ.dummy' }} + TRAKT_CLIENT_ID_VAR: ${{ secrets.TRAKT_CLIENT_ID || 'dummy-key' }} + TRAKT_CLIENT_SECRET_VAR: ${{ secrets.TRAKT_CLIENT_SECRET || 'dummy-key' }} + TRAKT_REDIRECT_URI_VAR: ${{ secrets.TRAKT_REDIRECT_URI || 'nuvio://auth/trakt' }} + PREMIUMIZE_CLIENT_ID_VAR: ${{ secrets.PREMIUMIZE_CLIENT_ID || 'dummy-key' }} + PREMIUMIZE_CLIENT_SECRET_VAR: ${{ secrets.PREMIUMIZE_CLIENT_SECRET || 'dummy-key' }} + PREMIUMIZE_REDIRECT_URI_VAR: ${{ secrets.PREMIUMIZE_REDIRECT_URI || 'nuvio://auth/trakt' }} + TMDB_API_KEY_VAR: ${{ secrets.TMDB_API_KEY || 'dummy-key' }} + INTRODB_API_URL_VAR: ${{ secrets.INTRODB_API_URL || 'https://api.introdb.app' }} + CONTRIBUTIONS_URL_VAR: ${{ secrets.CONTRIBUTIONS_URL || '' }} + DONATIONS_BASE_URL_VAR: ${{ secrets.DONATIONS_BASE_URL || '' }} + DONATIONS_DONATE_URL_VAR: ${{ secrets.DONATIONS_DONATE_URL || '' }} + DIRECT_DEBRID_API_BASE_URL_VAR: ${{ secrets.DIRECT_DEBRID_API_BASE_URL || '' }} + run: | + cat << EOF > local.properties + SUPABASE_URL=${SUPABASE_URL_VAR} + SUPABASE_ANON_KEY=${SUPABASE_ANON_KEY_VAR} + TRAKT_CLIENT_ID=${TRAKT_CLIENT_ID_VAR} + TRAKT_CLIENT_SECRET=${TRAKT_CLIENT_SECRET_VAR} + TRAKT_REDIRECT_URI=${TRAKT_REDIRECT_URI_VAR} + PREMIUMIZE_CLIENT_ID=${PREMIUMIZE_CLIENT_ID_VAR} + PREMIUMIZE_CLIENT_SECRET=${PREMIUMIZE_CLIENT_SECRET_VAR} + PREMIUMIZE_REDIRECT_URI=${PREMIUMIZE_REDIRECT_URI_VAR} + TMDB_API_KEY=${TMDB_API_KEY_VAR} + INTRODB_API_URL=${INTRODB_API_URL_VAR} + CONTRIBUTIONS_URL=${CONTRIBUTIONS_URL_VAR} + DONATIONS_BASE_URL=${DONATIONS_BASE_URL_VAR} + DONATIONS_DONATE_URL=${DONATIONS_DONATE_URL_VAR} + DIRECT_DEBRID_API_BASE_URL=${DIRECT_DEBRID_API_BASE_URL_VAR} + NUVIO_ANDROID_DISTRIBUTION=full + EOF + + - name: Cache Gradle + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + + - name: Build Full APK + run: | + ./gradlew \ + :androidApp:assembleFullDebug \ + -Pnuvio.android.distribution=full \ + -Pnuvio.splitAbi=true \ + --stacktrace + + - name: Upload Android Artifact + uses: actions/upload-artifact@v4 + with: + name: Nuvio-Android-Full + path: androidApp/build/outputs/apk/full/debug/*.apk From aa265015ede360ffdd0a485c7276047321da8679 Mon Sep 17 00:00:00 2001 From: paregi12 Date: Sun, 28 Jun 2026 01:24:01 +0530 Subject: [PATCH 04/20] Update Build Nuvio workflow --- .github/workflows/duild-mobile.yml | 128 +++++++++++++++++++++-------- 1 file changed, 94 insertions(+), 34 deletions(-) diff --git a/.github/workflows/duild-mobile.yml b/.github/workflows/duild-mobile.yml index 04107ce2..afc04f0d 100644 --- a/.github/workflows/duild-mobile.yml +++ b/.github/workflows/duild-mobile.yml @@ -1,4 +1,4 @@ -name: Build Nuvio Mobile (iOS & Android) +name: Build Nuvio on: push: @@ -7,30 +7,95 @@ on: - '*' workflow_dispatch: +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + jobs: - build-ios-full: - name: Build iOS Full Version + build-ios-enhanced: + name: Build iOS Enhanced Version runs-on: macos-latest - env: - GRADLE_OPTS: "-Dorg.gradle.jvmargs=-Xmx11264M -Dkotlin.daemon.jvmargs=-Xmx11264M -Dkotlin.native.jvmargs=-Xmx11264M" steps: - name: Checkout Repository & Submodules uses: actions/checkout@v4 + with: + lfs: true - name: Fetch iOS Specific Submodule - run: | - set -euo pipefail - git submodule sync -- MPVKit - git submodule update --init --depth 1 -- MPVKit + run: git submodule update --init --recursive MPVKit - - name: Pull Git LFS files + - name: Patch MPVKit Package.swift for CI (replace local path targets with remote URLs) run: | - set -euo pipefail - git lfs install --local - git lfs pull cd MPVKit - git lfs install --local - git lfs pull + python3 - << 'PYEOF' + import re + + replacements = { + "Libplacebo": ( + "https://github.com/mpvkit/libplacebo-build/releases/download/7.360.1/Libplacebo.xcframework.zip", + "2fa3d54cb81f302d6f11c7b2f509af30944381c3b11ee9d35096eb4637a6e2dd" + ), + "Libavcodec": ( + "https://github.com/mpvkit/MPVKit/releases/download/0.41.0-n8.1/Libavcodec.xcframework.zip", + "22af1a028043c1953cae8cc8b9d632da8d8c733364dc896242010457e2601905" + ), + "Libavdevice": ( + "https://github.com/mpvkit/MPVKit/releases/download/0.41.0-n8.1/Libavdevice.xcframework.zip", + "b1ba57380014e7680918a5f9f9d52cf91883f600ba1d53857f3720494de91c99" + ), + "Libavformat": ( + "https://github.com/mpvkit/MPVKit/releases/download/0.41.0-n8.1/Libavformat.xcframework.zip", + "ded64005204d4af754273ffe11e69a91752f8a6a1c64f4540205e1666698e317" + ), + "Libavfilter": ( + "https://github.com/mpvkit/MPVKit/releases/download/0.41.0-n8.1/Libavfilter.xcframework.zip", + "aedda48ffcb27e461e962af7ad7e92be736dca82943cb18dee9e4fec756b0aa4" + ), + "Libavutil": ( + "https://github.com/mpvkit/MPVKit/releases/download/0.41.0-n8.1/Libavutil.xcframework.zip", + "947c31241e317e21e20e7ac0c24c467541d383b6ab88bd42acccc5947e1a674f" + ), + "Libswresample": ( + "https://github.com/mpvkit/MPVKit/releases/download/0.41.0-n8.1/Libswresample.xcframework.zip", + "3832feceb12606ac342dd93219ae1ca2f4bcabf037ab9299cb5b633060a34248" + ), + "Libswscale": ( + "https://github.com/mpvkit/MPVKit/releases/download/0.41.0-n8.1/Libswscale.xcframework.zip", + "ed201cdae3c8e8262f3d9f3618cf3de552db47e607e39d45f93eb336ed05a075" + ), + "Libmpv": ( + "https://github.com/mpvkit/MPVKit/releases/download/0.41.0-n8.1/Libmpv.xcframework.zip", + "b4d0a9ef26abd7bbf1e0a386902206deddaeae5cd36a8ab1ba638c5327389ca7" + ), + } + + with open("Package.swift", "r") as f: + content = f.read() + + for name, (url, checksum) in replacements.items(): + # Match binaryTarget with path: for this exact name + pattern = ( + r'(\.binaryTarget\(\s*\n\s*name:\s*"' + re.escape(name) + r'",\s*\n)' + r'\s*path:\s*"[^"]+"\s*\n' + r'(\s*\))' + ) + replacement = ( + r'\g<1>' + f' url: "{url}",\n' + f' checksum: "{checksum}"\n' + r'\g<2>' + ) + new_content, count = re.subn(pattern, replacement, content) + if count: + print(f"✓ Patched {name}") + content = new_content + else: + print(f" (skipped {name} — already url-based or not found)") + + with open("Package.swift", "w") as f: + f.write(content) + + print("\nDone.") + PYEOF - name: Select Latest Stable Xcode uses: maxim-lobanov/setup-xcode@v1 @@ -41,7 +106,7 @@ jobs: run: chmod +x ./gradlew - name: Set up JDK 17 - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: java-version: '17' distribution: 'temurin' @@ -83,11 +148,7 @@ jobs: - name: Build iOS App via Xcode run: | - rm -rf ~/Library/Caches/org.swift.swiftpm/ - rm -rf ~/Library/Developer/Xcode/DerivedData/ cd iosApp - rm -rf build - xcodebuild -resolvePackageDependencies -project iosApp.xcodeproj -scheme iosApp -derivedDataPath build xcodebuild \ -project iosApp.xcodeproj \ -scheme iosApp \ @@ -104,32 +165,32 @@ jobs: run: | cd iosApp/build/Build/Products/Release-iphoneos APP_VERSION=$(/usr/libexec/PlistBuddy -c "Print CFBundleShortVersionString" Nuvio.app/Info.plist) - mkdir -p Payload + mkdir Payload mv Nuvio.app Payload/ - zip -q -r "Nuvio-v${APP_VERSION}-Full.ipa" Payload - echo "IPA_FILENAME=Nuvio-v${APP_VERSION}-Full.ipa" >> $GITHUB_ENV + zip -q -r "Nuvio-v${APP_VERSION}-Enhanced.ipa" Payload + echo "IPA_FILENAME=Nuvio-v${APP_VERSION}-Enhanced.ipa" >> $GITHUB_ENV - name: Upload iOS Artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: - name: Nuvio-iOS-Full + name: Nuvio-iOS-Enhanced path: iosApp/build/Build/Products/Release-iphoneos/${{ env.IPA_FILENAME }} - build-android: - name: Build Android Full Version + build-android-enhanced: + name: Build Android Enhanced Version runs-on: ubuntu-latest steps: - name: Checkout Repository uses: actions/checkout@v4 - name: Set up JDK 17 - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: java-version: '17' distribution: 'temurin' - name: Setup Android SDK - uses: android-actions/setup-android@v3 + uses: android-actions/setup-android@v4 - name: Generate local.properties env: @@ -167,23 +228,22 @@ jobs: EOF - name: Cache Gradle - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.gradle/caches ~/.gradle/wrapper key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - - name: Build Full APK + - name: Build Enhanced APK run: | ./gradlew \ :androidApp:assembleFullDebug \ - -Pnuvio.android.distribution=full \ -Pnuvio.splitAbi=true \ --stacktrace - name: Upload Android Artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: - name: Nuvio-Android-Full + name: Nuvio-Android-Enhanced path: androidApp/build/outputs/apk/full/debug/*.apk From 480892d86430bb37a32c77ba91fc75c10248bec5 Mon Sep 17 00:00:00 2001 From: paregi12 Date: Sun, 28 Jun 2026 01:37:25 +0530 Subject: [PATCH 05/20] Add GRADLE_OPTS to build-ios-enhanced job --- .github/workflows/duild-mobile.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/duild-mobile.yml b/.github/workflows/duild-mobile.yml index afc04f0d..07793e8f 100644 --- a/.github/workflows/duild-mobile.yml +++ b/.github/workflows/duild-mobile.yml @@ -14,6 +14,8 @@ jobs: build-ios-enhanced: name: Build iOS Enhanced Version runs-on: macos-latest + env: + GRADLE_OPTS: "-Dorg.gradle.jvmargs=-Xmx11264M -Dkotlin.daemon.jvmargs=-Xmx11264M -Dkotlin.native.jvmargs=-Xmx11264M" steps: - name: Checkout Repository & Submodules uses: actions/checkout@v4 From 61a559ad93f8dda040600aa410db69487ddfb91e Mon Sep 17 00:00:00 2001 From: paregi12 Date: Sun, 28 Jun 2026 02:24:29 +0530 Subject: [PATCH 06/20] Fix iOS build error: CGRect type mismatch in WKWebView frame --- .../features/plugins/runtime/network/CloudflareKiller.ios.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.ios.kt b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.ios.kt index 523942e8..ab288268 100644 --- a/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.ios.kt +++ b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.ios.kt @@ -2,6 +2,7 @@ package com.nuvio.app.features.plugins.runtime.network import co.touchlab.kermit.Logger import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.readValue import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable @@ -56,7 +57,7 @@ internal object IosWebViewSolver : WebViewSolver { withContext(Dispatchers.Main) { val config = WKWebViewConfiguration() val webView = WKWebView( - frame = platform.CoreGraphics.CGRectZero, + frame = platform.CoreGraphics.CGRectZero.readValue(), configuration = config, ) webView.customUserAgent = webViewUserAgent @@ -99,7 +100,7 @@ internal object IosWebViewSolver : WebViewSolver { cachedUserAgent?.let { return it } return withContext(Dispatchers.Main) { val webView = WKWebView( - frame = platform.CoreGraphics.CGRectZero, + frame = platform.CoreGraphics.CGRectZero.readValue(), configuration = WKWebViewConfiguration(), ) val deferred = CompletableDeferred() From a0f3550944a9c5fc0695bd27de39b9a07f12b5ec Mon Sep 17 00:00:00 2001 From: paregi12 Date: Sun, 28 Jun 2026 03:08:20 +0530 Subject: [PATCH 07/20] Import kotlin.concurrent.Synchronized for KMP compatibility --- .../app/features/plugins/runtime/network/CloudflareKiller.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.kt index 02f66301..7199ffa4 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.kt @@ -1,5 +1,6 @@ package com.nuvio.app.features.plugins.runtime.network +import kotlin.concurrent.Synchronized import kotlinx.coroutines.sync.Mutex import kotlinx.serialization.json.Json import kotlinx.serialization.json.buildJsonObject From 2bd2d654c6ed6be68365881f9fb971687522709a Mon Sep 17 00:00:00 2001 From: paregi12 Date: Sun, 28 Jun 2026 03:15:30 +0530 Subject: [PATCH 08/20] Refactor CfSessionCache to use kotlinx.atomicfu locks for KMP compatibility --- .../runtime/network/CloudflareKiller.kt | 26 +++++++++---------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.kt index 7199ffa4..126226b0 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.kt @@ -1,6 +1,7 @@ package com.nuvio.app.features.plugins.runtime.network -import kotlin.concurrent.Synchronized +import kotlinx.atomicfu.locks.SynchronizedObject +import kotlinx.atomicfu.locks.synchronized import kotlinx.coroutines.sync.Mutex import kotlinx.serialization.json.Json import kotlinx.serialization.json.buildJsonObject @@ -42,39 +43,37 @@ internal fun createPlatformWebViewSolver(): WebViewSolver = platformWebViewSolve internal object CfSessionCache { private val sessions = mutableMapOf() private val hostLocks = mutableMapOf() + private val lock = SynchronizedObject() - @Synchronized - fun getMutex(host: String): Mutex = hostLocks.getOrPut(host) { Mutex() } + fun getMutex(host: String): Mutex = synchronized(lock) { + hostLocks.getOrPut(host) { Mutex() } + } - @Synchronized - fun get(host: String, pluginId: String): CfSolveResult? { + fun get(host: String, pluginId: String): CfSolveResult? = synchronized(lock) { sessions[host]?.let { return it } val persisted = runCatching { com.nuvio.app.features.plugins.PluginStorage.loadCfSession(host) }.getOrNull() val parsed = persisted?.let(::jsonStringToCfSolveResult) ?: return null sessions[host] = parsed - return parsed + parsed } - @Synchronized - fun put(host: String, pluginId: String, result: CfSolveResult) { + fun put(host: String, pluginId: String, result: CfSolveResult) = synchronized(lock) { sessions[host] = result runCatching { com.nuvio.app.features.plugins.PluginStorage.saveCfSession(host, result.toJsonString()) } } - @Synchronized - fun evict(host: String, pluginId: String) { + fun evict(host: String, pluginId: String) = synchronized(lock) { sessions.remove(host) runCatching { com.nuvio.app.features.plugins.PluginStorage.removeCfSession(host) } } - @Synchronized - fun evictIfSame(host: String, result: CfSolveResult) { + fun evictIfSame(host: String, result: CfSolveResult) = synchronized(lock) { if (sessions[host] == result) { sessions.remove(host) runCatching { @@ -83,8 +82,7 @@ internal object CfSessionCache { } } - @Synchronized - fun clear() { + fun clear() = synchronized(lock) { sessions.clear() } } From fef4ae486d218aab62b5d3b3d12c26c897db3969 Mon Sep 17 00:00:00 2001 From: paregi12 Date: Mon, 29 Jun 2026 08:19:10 +0530 Subject: [PATCH 09/20] Fix iOS crash: Keep WKWebView alive during user-agent capture suspension --- .../features/plugins/runtime/network/CloudflareKiller.ios.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.ios.kt b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.ios.kt index ab288268..927fce31 100644 --- a/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.ios.kt +++ b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.ios.kt @@ -110,7 +110,10 @@ internal object IosWebViewSolver : WebViewSolver { ?: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148", ) } - deferred.await().also { cachedUserAgent = it } + val userAgent = deferred.await() + // Keep the WebView reference alive during suspension by referencing it here + log.d { "Captured User-Agent using WebView. WebView description: ${webView.description}" } + userAgent.also { cachedUserAgent = it } } } From 7f583238afbf328c6bd70be9cb8d0c1df05bb49d Mon Sep 17 00:00:00 2001 From: paregi12 Date: Tue, 30 Jun 2026 08:49:14 +0530 Subject: [PATCH 10/20] Fix iOS crash: force WKHTTPCookieStore operations to run on the main UI thread --- .../runtime/network/CloudflareKiller.ios.kt | 56 ++++++++++--------- 1 file changed, 30 insertions(+), 26 deletions(-) diff --git a/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.ios.kt b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.ios.kt index 927fce31..b6bb569c 100644 --- a/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.ios.kt +++ b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.ios.kt @@ -120,37 +120,41 @@ internal object IosWebViewSolver : WebViewSolver { private suspend fun getWkCookies( cookieStore: WKHTTPCookieStore, host: String, - ): Map = suspendCoroutine { cont -> - cookieStore.getAllCookies { rawList -> - @Suppress("UNCHECKED_CAST") - val cookies = (rawList as? List) - ?.filter { cookieMatchesHost(it.domain, host) } - ?.associate { it.name to it.value } - ?.filter { (key, value) -> key.isNotBlank() && value.isNotBlank() } - .orEmpty() - cont.resume(cookies) + ): Map = withContext(Dispatchers.Main) { + suspendCoroutine { cont -> + cookieStore.getAllCookies { rawList -> + @Suppress("UNCHECKED_CAST") + val cookies = (rawList as? List) + ?.filter { cookieMatchesHost(it.domain, host) } + ?.associate { it.name to it.value } + ?.filter { (key, value) -> key.isNotBlank() && value.isNotBlank() } + .orEmpty() + cont.resume(cookies) + } } } - private suspend fun clearWkCookiesForHost(host: String) = suspendCoroutine { cont -> - val store = WKWebsiteDataStore.defaultDataStore().httpCookieStore - store.getAllCookies { rawList -> - @Suppress("UNCHECKED_CAST") - val cookies = (rawList as? List) - ?.filter { cookieMatchesHost(it.domain, host) } - .orEmpty() + private suspend fun clearWkCookiesForHost(host: String) = withContext(Dispatchers.Main) { + suspendCoroutine { cont -> + val store = WKWebsiteDataStore.defaultDataStore().httpCookieStore + store.getAllCookies { rawList -> + @Suppress("UNCHECKED_CAST") + val cookies = (rawList as? List) + ?.filter { cookieMatchesHost(it.domain, host) } + .orEmpty() - if (cookies.isEmpty()) { - cont.resume(Unit) - return@getAllCookies - } + if (cookies.isEmpty()) { + cont.resume(Unit) + return@getAllCookies + } - var remaining = cookies.size - cookies.forEach { cookie -> - store.deleteCookie(cookie) { - remaining -= 1 - if (remaining == 0) { - cont.resume(Unit) + var remaining = cookies.size + cookies.forEach { cookie -> + store.deleteCookie(cookie) { + remaining -= 1 + if (remaining == 0) { + cont.resume(Unit) + } } } } From 1414fd75ee4bcd669e3ece7abde5c1850ab399a9 Mon Sep 17 00:00:00 2001 From: paregi12 Date: Tue, 30 Jun 2026 09:11:30 +0530 Subject: [PATCH 11/20] Implement fetchRenderedPage WebView fetch fallback for iOS solver --- .../runtime/network/CloudflareKiller.ios.kt | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) diff --git a/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.ios.kt b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.ios.kt index b6bb569c..ec6df701 100644 --- a/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.ios.kt +++ b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.ios.kt @@ -26,6 +26,50 @@ internal object IosWebViewSolver : WebViewSolver { private val log = Logger.withTag("IosWebViewSolver") private var cachedUserAgent: String? = null + private const val PAGE_STATE_JS = """ + (function() { + try { + if (document.readyState !== 'interactive' && document.readyState !== 'complete') return 'wait'; + var title = (document.title || '').toLowerCase(); + if (title.indexOf('attention required') !== -1 || title.indexOf('access denied') !== -1) return 'blocked'; + if (title.indexOf('just a moment') !== -1) return 'wait'; + if (document.querySelector('#challenge-running, #challenge-stage, #cf-challenge-running, .cf-browser-verification, #turnstile-wrapper, #cf-please-wait, script[src*="challenge-platform"]')) return 'wait'; + if (!document.documentElement || !document.body) return 'wait'; + var contentType = (document.contentType || '').toLowerCase(); + if ((contentType.indexOf('json') !== -1 || contentType.indexOf('text/plain') !== -1) && (document.body.innerText || '').length > 0) return 'ok'; + var html = document.documentElement.outerHTML || ''; + if (html.length < 64) return 'wait'; + return 'ok'; + } catch (e) { + return 'wait'; + } + })() + """ + + private const val PAGE_BODY_JS = """ + (function() { + try { + var contentType = (document.contentType || '').toLowerCase(); + if (contentType.indexOf('json') !== -1 || contentType.indexOf('text/plain') !== -1) { + return document.body ? (document.body.innerText || '') : ''; + } + return document.documentElement ? (document.documentElement.outerHTML || '') : ''; + } catch (e) { + return ''; + } + })() + """ + + private const val PAGE_CONTENT_TYPE_JS = """ + (function() { + try { + return document.contentType || 'text/html'; + } catch (e) { + return 'text/html'; + } + })() + """ + override suspend fun solve( url: String, headers: Map, @@ -96,6 +140,83 @@ internal object IosWebViewSolver : WebViewSolver { } } + override suspend fun fetchRenderedPage( + url: String, + headers: Map, + timeoutMs: Long, + ): WebViewFetchResult? { + val nsUrl = NSURL.URLWithString(url) ?: run { + log.e { "CF fetch fallback: invalid URL: $url" } + return null + } + + var webViewRef: WKWebView? = null + val webViewUserAgent = getOrCaptureUserAgent() + + try { + withContext(Dispatchers.Main) { + val config = WKWebViewConfiguration() + val webView = WKWebView( + frame = platform.CoreGraphics.CGRectZero.readValue(), + configuration = config, + ) + webView.customUserAgent = webViewUserAgent + webViewRef = webView + webView.loadRequest(NSURLRequest.requestWithURL(nsUrl)) + } + + val result = withTimeoutOrNull(timeoutMs) { + var rendered: WebViewFetchResult? = null + while (rendered == null) { + delay(250L) + val state = webViewRef?.evaluateJavascriptString(PAGE_STATE_JS).orEmpty() + if (state == "blocked") { + return@withTimeoutOrNull null + } + + if (state == "ok") { + val body = webViewRef?.evaluateJavascriptString(PAGE_BODY_JS).orEmpty() + if (body.isNotBlank()) { + val contentType = webViewRef?.evaluateJavascriptString(PAGE_CONTENT_TYPE_JS) + .orEmpty().ifBlank { "text/html" } + val headersWithContentType = mutableMapOf() + headersWithContentType["content-type"] = contentType + rendered = WebViewFetchResult( + status = 200, + statusText = "OK", + url = withContext(Dispatchers.Main) { + webViewRef?.URL?.absoluteString ?: url + }, + body = body, + headers = headersWithContentType, + ) + } + } + } + rendered + } + + if (result == null) { + log.w { "CF WebView fetch fallback timed out after ${timeoutMs}ms for $url" } + } + return result + } finally { + withContext(NonCancellable + Dispatchers.Main) { + webViewRef?.stopLoading() + webViewRef = null + } + } + } + + private suspend fun WKWebView.evaluateJavascriptString(script: String): String = + withContext(Dispatchers.Main) { + suspendCoroutine { cont -> + evaluateJavaScript(script) { result, _ -> + cont.resume(result as? String ?: "") + } + } + } + private suspend fun getOrCaptureUserAgent(): String { cachedUserAgent?.let { return it } return withContext(Dispatchers.Main) { From f0d9ca8bf3325147517a427b76eb5d07250f9d95 Mon Sep 17 00:00:00 2001 From: paregi12 Date: Sat, 4 Jul 2026 19:05:36 +0530 Subject: [PATCH 12/20] Fix iOS Cloudflare solver issues: handle SSL challenges, capture actual HTTP responses, and prevent silent hangs on network errors --- .../runtime/network/CloudflareKiller.ios.kt | 101 +++++++++++++++++- 1 file changed, 97 insertions(+), 4 deletions(-) diff --git a/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.ios.kt b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.ios.kt index ec6df701..a1a1d9a1 100644 --- a/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.ios.kt +++ b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.ios.kt @@ -18,6 +18,21 @@ import platform.WebKit.WKWebViewConfiguration import platform.WebKit.WKWebsiteDataStore import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine +import platform.WebKit.WKNavigationDelegateProtocol +import platform.WebKit.WKNavigationResponse +import platform.WebKit.WKNavigationResponsePolicy +import platform.WebKit.WKNavigation +import platform.Foundation.NSHTTPURLResponse +import platform.Foundation.NSError +import platform.Foundation.NSURLAuthenticationChallenge +import platform.Foundation.NSURLSessionAuthChallengeDisposition +import platform.Foundation.NSURLCredential +import platform.Foundation.NSURLSessionAuthChallengeUseCredential +import platform.Foundation.NSURLSessionAuthChallengePerformDefaultHandling +import platform.Foundation.credentialForTrust +import platform.darwin.NSObject +import platform.WebKit.WKNavigationResponsePolicyAllow + internal fun platformWebViewSolverImpl(): WebViewSolver = IosWebViewSolver @@ -98,6 +113,9 @@ internal object IosWebViewSolver : WebViewSolver { val webViewUserAgent = getOrCaptureUserAgent() try { + val delegate = withContext(Dispatchers.Main) { + CloudflareNavigationDelegate() + } withContext(Dispatchers.Main) { val config = WKWebViewConfiguration() val webView = WKWebView( @@ -105,6 +123,7 @@ internal object IosWebViewSolver : WebViewSolver { configuration = config, ) webView.customUserAgent = webViewUserAgent + webView.navigationDelegate = delegate webViewRef = webView webView.loadRequest(NSURLRequest.requestWithURL(nsUrl)) } @@ -113,6 +132,11 @@ internal object IosWebViewSolver : WebViewSolver { var solved: CfSolveResult? = null while (solved == null) { delay(100L) + val error = withContext(Dispatchers.Main) { delegate.error } + if (error != null) { + log.e { "CF solve failed due to network/navigation error: ${error.localizedDescription}" } + break + } val cookies = getWkCookies(cookieStore, host) if (cookies.containsKey("cf_clearance")) { val finalUrl = withContext(Dispatchers.Main) { @@ -154,6 +178,9 @@ internal object IosWebViewSolver : WebViewSolver { val webViewUserAgent = getOrCaptureUserAgent() try { + val delegate = withContext(Dispatchers.Main) { + CloudflareNavigationDelegate() + } withContext(Dispatchers.Main) { val config = WKWebViewConfiguration() val webView = WKWebView( @@ -161,6 +188,7 @@ internal object IosWebViewSolver : WebViewSolver { configuration = config, ) webView.customUserAgent = webViewUserAgent + webView.navigationDelegate = delegate webViewRef = webView webView.loadRequest(NSURLRequest.requestWithURL(nsUrl)) } @@ -169,6 +197,11 @@ internal object IosWebViewSolver : WebViewSolver { var rendered: WebViewFetchResult? = null while (rendered == null) { delay(250L) + val error = withContext(Dispatchers.Main) { delegate.error } + if (error != null) { + log.e { "CF fetch fallback failed due to network/navigation error: ${error.localizedDescription}" } + break + } val state = webViewRef?.evaluateJavascriptString(PAGE_STATE_JS).orEmpty() if (state == "blocked") { return@withTimeoutOrNull null @@ -179,11 +212,15 @@ internal object IosWebViewSolver : WebViewSolver { if (body.isNotBlank()) { val contentType = webViewRef?.evaluateJavascriptString(PAGE_CONTENT_TYPE_JS) .orEmpty().ifBlank { "text/html" } - val headersWithContentType = mutableMapOf() - headersWithContentType["content-type"] = contentType + val headersWithContentType = withContext(Dispatchers.Main) { + delegate.headers.toMutableMap() + } + if (headersWithContentType.keys.none { it.equals("content-type", ignoreCase = true) }) { + headersWithContentType["content-type"] = contentType + } rendered = WebViewFetchResult( - status = 200, - statusText = "OK", + status = withContext(Dispatchers.Main) { delegate.status }, + statusText = withContext(Dispatchers.Main) { delegate.statusText }, url = withContext(Dispatchers.Main) { webViewRef?.URL?.absoluteString ?: url }, @@ -288,3 +325,59 @@ internal object IosWebViewSolver : WebViewSolver { return normalizedHost == normalizedDomain || normalizedHost.endsWith(".$normalizedDomain") } } + +private class CloudflareNavigationDelegate : NSObject(), WKNavigationDelegateProtocol { + var status: Int = 200 + var statusText: String = "OK" + val headers = mutableMapOf() + var error: NSError? = null + + override fun webView( + webView: WKWebView, + decidePolicyForNavigationResponse: WKNavigationResponse, + decisionHandler: (WKNavigationResponsePolicy) -> Unit + ) { + val httpResponse = decidePolicyForNavigationResponse.response as? NSHTTPURLResponse + if (httpResponse != null) { + status = httpResponse.statusCode.toInt() + statusText = NSHTTPURLResponse.localizedStringForStatusCode(httpResponse.statusCode) + headers.clear() + httpResponse.allHeaderFields.forEach { (key, value) -> + headers[key.toString()] = value.toString() + } + } + decisionHandler(WKNavigationResponsePolicyAllow) + } + + override fun webView( + webView: WKWebView, + didFailNavigation: WKNavigation?, + withError: NSError + ) { + error = withError + } + + override fun webView( + webView: WKWebView, + didFailProvisionalNavigation: WKNavigation?, + withError: NSError + ) { + error = withError + } + + override fun webView( + webView: WKWebView, + didReceiveAuthenticationChallenge: platform.Foundation.NSURLAuthenticationChallenge, + completionHandler: (platform.Foundation.NSURLSessionAuthChallengeDisposition, platform.Foundation.NSURLCredential?) -> Unit + ) { + val trust = didReceiveAuthenticationChallenge.protectionSpace.serverTrust + if (trust != null) { + completionHandler( + platform.Foundation.NSURLSessionAuthChallengeUseCredential, + NSURLCredential.credentialForTrust(trust) + ) + } else { + completionHandler(platform.Foundation.NSURLSessionAuthChallengePerformDefaultHandling, null) + } + } +} From 0b7a5d635bbdb8dc53bb5007bef4e6b262e50c76 Mon Sep 17 00:00:00 2001 From: paregi12 Date: Sun, 5 Jul 2026 13:17:30 +0530 Subject: [PATCH 13/20] Fix iOS compilation errors in CloudflareKiller.ios.kt --- .../plugins/runtime/network/CloudflareKiller.ios.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.ios.kt b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.ios.kt index a1a1d9a1..0f553f19 100644 --- a/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.ios.kt +++ b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.ios.kt @@ -31,7 +31,8 @@ import platform.Foundation.NSURLSessionAuthChallengeUseCredential import platform.Foundation.NSURLSessionAuthChallengePerformDefaultHandling import platform.Foundation.credentialForTrust import platform.darwin.NSObject -import platform.WebKit.WKNavigationResponsePolicyAllow +import platform.Foundation.NSURLProtectionSpace +import platform.Security.SecTrustRef internal fun platformWebViewSolverImpl(): WebViewSolver = IosWebViewSolver @@ -326,6 +327,8 @@ internal object IosWebViewSolver : WebViewSolver { } } +@Suppress("CONFLICTING_OVERLOADS") +@OptIn(ExperimentalForeignApi::class) private class CloudflareNavigationDelegate : NSObject(), WKNavigationDelegateProtocol { var status: Int = 200 var statusText: String = "OK" @@ -346,7 +349,7 @@ private class CloudflareNavigationDelegate : NSObject(), WKNavigationDelegatePro headers[key.toString()] = value.toString() } } - decisionHandler(WKNavigationResponsePolicyAllow) + decisionHandler(WKNavigationResponsePolicy.WKNavigationResponsePolicyAllow) } override fun webView( From 1afe43db9a9e73713f31267ae795bd7506206c00 Mon Sep 17 00:00:00 2001 From: paregi12 Date: Sun, 5 Jul 2026 13:37:35 +0530 Subject: [PATCH 14/20] Fix conflicting overloads and import platform.Foundation to bring serverTrust into scope --- .../runtime/network/CloudflareKiller.ios.kt | 27 +++++-------------- 1 file changed, 6 insertions(+), 21 deletions(-) diff --git a/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.ios.kt b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.ios.kt index 0f553f19..441becea 100644 --- a/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.ios.kt +++ b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.ios.kt @@ -1,3 +1,4 @@ +@file:Suppress("CONFLICTING_OVERLOADS") package com.nuvio.app.features.plugins.runtime.network import co.touchlab.kermit.Logger @@ -9,29 +10,11 @@ import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.delay import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull -import platform.Foundation.NSHTTPCookie -import platform.Foundation.NSURL -import platform.Foundation.NSURLRequest -import platform.WebKit.WKHTTPCookieStore -import platform.WebKit.WKWebView -import platform.WebKit.WKWebViewConfiguration -import platform.WebKit.WKWebsiteDataStore import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine -import platform.WebKit.WKNavigationDelegateProtocol -import platform.WebKit.WKNavigationResponse -import platform.WebKit.WKNavigationResponsePolicy -import platform.WebKit.WKNavigation -import platform.Foundation.NSHTTPURLResponse -import platform.Foundation.NSError -import platform.Foundation.NSURLAuthenticationChallenge -import platform.Foundation.NSURLSessionAuthChallengeDisposition -import platform.Foundation.NSURLCredential -import platform.Foundation.NSURLSessionAuthChallengeUseCredential -import platform.Foundation.NSURLSessionAuthChallengePerformDefaultHandling -import platform.Foundation.credentialForTrust +import platform.Foundation.* +import platform.WebKit.* import platform.darwin.NSObject -import platform.Foundation.NSURLProtectionSpace import platform.Security.SecTrustRef @@ -352,6 +335,7 @@ private class CloudflareNavigationDelegate : NSObject(), WKNavigationDelegatePro decisionHandler(WKNavigationResponsePolicy.WKNavigationResponsePolicyAllow) } + @Suppress("CONFLICTING_OVERLOADS") override fun webView( webView: WKWebView, didFailNavigation: WKNavigation?, @@ -360,6 +344,7 @@ private class CloudflareNavigationDelegate : NSObject(), WKNavigationDelegatePro error = withError } + @Suppress("CONFLICTING_OVERLOADS") override fun webView( webView: WKWebView, didFailProvisionalNavigation: WKNavigation?, @@ -373,7 +358,7 @@ private class CloudflareNavigationDelegate : NSObject(), WKNavigationDelegatePro didReceiveAuthenticationChallenge: platform.Foundation.NSURLAuthenticationChallenge, completionHandler: (platform.Foundation.NSURLSessionAuthChallengeDisposition, platform.Foundation.NSURLCredential?) -> Unit ) { - val trust = didReceiveAuthenticationChallenge.protectionSpace.serverTrust + val trust = didReceiveAuthenticationChallenge.protectionSpace?.serverTrust if (trust != null) { completionHandler( platform.Foundation.NSURLSessionAuthChallengeUseCredential, From 45f58aa473e1d737e299d5c2d74cf2ed8acaaee7 Mon Sep 17 00:00:00 2001 From: paregi12 Date: Sun, 5 Jul 2026 23:05:31 +0530 Subject: [PATCH 15/20] Use ObjCSignatureOverride to fix conflicting overloads on WKNavigationDelegate methods --- .../plugins/runtime/network/CloudflareKiller.ios.kt | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.ios.kt b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.ios.kt index 441becea..2ab3e920 100644 --- a/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.ios.kt +++ b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.ios.kt @@ -1,8 +1,8 @@ -@file:Suppress("CONFLICTING_OVERLOADS") package com.nuvio.app.features.plugins.runtime.network import co.touchlab.kermit.Logger import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.ObjCSignatureOverride import kotlinx.cinterop.readValue import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers @@ -310,7 +310,6 @@ internal object IosWebViewSolver : WebViewSolver { } } -@Suppress("CONFLICTING_OVERLOADS") @OptIn(ExperimentalForeignApi::class) private class CloudflareNavigationDelegate : NSObject(), WKNavigationDelegateProtocol { var status: Int = 200 @@ -335,7 +334,7 @@ private class CloudflareNavigationDelegate : NSObject(), WKNavigationDelegatePro decisionHandler(WKNavigationResponsePolicy.WKNavigationResponsePolicyAllow) } - @Suppress("CONFLICTING_OVERLOADS") + @ObjCSignatureOverride override fun webView( webView: WKWebView, didFailNavigation: WKNavigation?, @@ -344,7 +343,7 @@ private class CloudflareNavigationDelegate : NSObject(), WKNavigationDelegatePro error = withError } - @Suppress("CONFLICTING_OVERLOADS") + @ObjCSignatureOverride override fun webView( webView: WKWebView, didFailProvisionalNavigation: WKNavigation?, From 05b64dc6b0b959d53a884e2e60d3196a0b9165b1 Mon Sep 17 00:00:00 2001 From: paregi12 Date: Wed, 8 Jul 2026 12:46:49 +0530 Subject: [PATCH 16/20] Fix thread-safety crash on iOS Cloudflare Solver --- .../plugins/runtime/network/CloudflareKiller.ios.kt | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.ios.kt b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.ios.kt index 2ab3e920..2dc531c0 100644 --- a/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.ios.kt +++ b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.ios.kt @@ -81,9 +81,8 @@ internal object IosWebViewSolver : WebViewSolver { return null } - val cookieStore = WKWebsiteDataStore.defaultDataStore().httpCookieStore if (!forceFresh) { - val existing = getWkCookies(cookieStore, host) + val existing = getWkCookies(host) if (existing.containsKey("cf_clearance")) { return CfSolveResult(cookies = existing, userAgent = getOrCaptureUserAgent()) } @@ -121,7 +120,7 @@ internal object IosWebViewSolver : WebViewSolver { log.e { "CF solve failed due to network/navigation error: ${error.localizedDescription}" } break } - val cookies = getWkCookies(cookieStore, host) + val cookies = getWkCookies(host) if (cookies.containsKey("cf_clearance")) { val finalUrl = withContext(Dispatchers.Main) { webViewRef?.URL?.absoluteString ?: url @@ -260,9 +259,9 @@ internal object IosWebViewSolver : WebViewSolver { } private suspend fun getWkCookies( - cookieStore: WKHTTPCookieStore, host: String, ): Map = withContext(Dispatchers.Main) { + val cookieStore = WKWebsiteDataStore.defaultDataStore().httpCookieStore suspendCoroutine { cont -> cookieStore.getAllCookies { rawList -> @Suppress("UNCHECKED_CAST") From eeb478b1b8bc88dfb853abc59712c6842b84e7de Mon Sep 17 00:00:00 2001 From: paregi12 Date: Wed, 8 Jul 2026 12:50:08 +0530 Subject: [PATCH 17/20] Optimize iOS Cloudflare solver threading and loop context switches --- .../runtime/network/CloudflareKiller.ios.kt | 139 +++++++++--------- 1 file changed, 70 insertions(+), 69 deletions(-) diff --git a/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.ios.kt b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.ios.kt index 2dc531c0..c77ee58a 100644 --- a/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.ios.kt +++ b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.ios.kt @@ -97,39 +97,40 @@ internal object IosWebViewSolver : WebViewSolver { try { val delegate = withContext(Dispatchers.Main) { - CloudflareNavigationDelegate() - } - withContext(Dispatchers.Main) { + val d = CloudflareNavigationDelegate() val config = WKWebViewConfiguration() val webView = WKWebView( frame = platform.CoreGraphics.CGRectZero.readValue(), configuration = config, ) webView.customUserAgent = webViewUserAgent - webView.navigationDelegate = delegate + webView.navigationDelegate = d webViewRef = webView webView.loadRequest(NSURLRequest.requestWithURL(nsUrl)) + d } val result = withTimeoutOrNull(timeoutMs) { var solved: CfSolveResult? = null - while (solved == null) { + var isFailed = false + while (solved == null && !isFailed) { delay(100L) - val error = withContext(Dispatchers.Main) { delegate.error } - if (error != null) { - log.e { "CF solve failed due to network/navigation error: ${error.localizedDescription}" } - break - } - val cookies = getWkCookies(host) - if (cookies.containsKey("cf_clearance")) { - val finalUrl = withContext(Dispatchers.Main) { - webViewRef?.URL?.absoluteString ?: url + withContext(Dispatchers.Main) { + val error = delegate.error + if (error != null) { + log.e { "CF solve failed due to network/navigation error: ${error.localizedDescription}" } + isFailed = true + return@withContext + } + val cookies = getWkCookies(host) + if (cookies.containsKey("cf_clearance")) { + val finalUrl = webViewRef?.URL?.absoluteString ?: url + solved = CfSolveResult( + cookies = cookies, + userAgent = webViewUserAgent, + redirectUrl = finalUrl.takeIf { it != url }, + ) } - solved = CfSolveResult( - cookies = cookies, - userAgent = webViewUserAgent, - redirectUrl = finalUrl.takeIf { it != url }, - ) } } solved @@ -141,6 +142,7 @@ internal object IosWebViewSolver : WebViewSolver { return result } finally { withContext(NonCancellable + Dispatchers.Main) { + webViewRef?.navigationDelegate = null webViewRef?.stopLoading() webViewRef = null } @@ -162,54 +164,54 @@ internal object IosWebViewSolver : WebViewSolver { try { val delegate = withContext(Dispatchers.Main) { - CloudflareNavigationDelegate() - } - withContext(Dispatchers.Main) { + val d = CloudflareNavigationDelegate() val config = WKWebViewConfiguration() val webView = WKWebView( frame = platform.CoreGraphics.CGRectZero.readValue(), configuration = config, ) webView.customUserAgent = webViewUserAgent - webView.navigationDelegate = delegate + webView.navigationDelegate = d webViewRef = webView webView.loadRequest(NSURLRequest.requestWithURL(nsUrl)) + d } val result = withTimeoutOrNull(timeoutMs) { var rendered: WebViewFetchResult? = null - while (rendered == null) { + var isFailed = false + while (rendered == null && !isFailed) { delay(250L) - val error = withContext(Dispatchers.Main) { delegate.error } - if (error != null) { - log.e { "CF fetch fallback failed due to network/navigation error: ${error.localizedDescription}" } - break - } - val state = webViewRef?.evaluateJavascriptString(PAGE_STATE_JS).orEmpty() - if (state == "blocked") { - return@withTimeoutOrNull null - } + withContext(Dispatchers.Main) { + val error = delegate.error + if (error != null) { + log.e { "CF fetch fallback failed due to network/navigation error: ${error.localizedDescription}" } + isFailed = true + return@withContext + } + val state = webViewRef?.evaluateJavascriptString(PAGE_STATE_JS).orEmpty() + if (state == "blocked") { + isFailed = true + return@withContext + } - if (state == "ok") { - val body = webViewRef?.evaluateJavascriptString(PAGE_BODY_JS).orEmpty() - if (body.isNotBlank()) { - val contentType = webViewRef?.evaluateJavascriptString(PAGE_CONTENT_TYPE_JS) - .orEmpty().ifBlank { "text/html" } - val headersWithContentType = withContext(Dispatchers.Main) { - delegate.headers.toMutableMap() + if (state == "ok") { + val body = webViewRef?.evaluateJavascriptString(PAGE_BODY_JS).orEmpty() + if (body.isNotBlank()) { + val contentType = webViewRef?.evaluateJavascriptString(PAGE_CONTENT_TYPE_JS) + .orEmpty().ifBlank { "text/html" } + val headersWithContentType = delegate.headers.toMutableMap() + if (headersWithContentType.keys.none { it.equals("content-type", ignoreCase = true) }) { + headersWithContentType["content-type"] = contentType + } + rendered = WebViewFetchResult( + status = delegate.status, + statusText = delegate.statusText, + url = webViewRef?.URL?.absoluteString ?: url, + body = body, + headers = headersWithContentType, + ) } - if (headersWithContentType.keys.none { it.equals("content-type", ignoreCase = true) }) { - headersWithContentType["content-type"] = contentType - } - rendered = WebViewFetchResult( - status = withContext(Dispatchers.Main) { delegate.status }, - statusText = withContext(Dispatchers.Main) { delegate.statusText }, - url = withContext(Dispatchers.Main) { - webViewRef?.URL?.absoluteString ?: url - }, - body = body, - headers = headersWithContentType, - ) } } } @@ -222,6 +224,7 @@ internal object IosWebViewSolver : WebViewSolver { return result } finally { withContext(NonCancellable + Dispatchers.Main) { + webViewRef?.navigationDelegate = null webViewRef?.stopLoading() webViewRef = null } @@ -237,25 +240,23 @@ internal object IosWebViewSolver : WebViewSolver { } } - private suspend fun getOrCaptureUserAgent(): String { - cachedUserAgent?.let { return it } - return withContext(Dispatchers.Main) { - val webView = WKWebView( - frame = platform.CoreGraphics.CGRectZero.readValue(), - configuration = WKWebViewConfiguration(), + private suspend fun getOrCaptureUserAgent(): String = withContext(Dispatchers.Main) { + cachedUserAgent?.let { return@withContext it } + val webView = WKWebView( + frame = platform.CoreGraphics.CGRectZero.readValue(), + configuration = WKWebViewConfiguration(), + ) + val deferred = CompletableDeferred() + webView.evaluateJavaScript("navigator.userAgent") { result, _ -> + deferred.complete( + result as? String + ?: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148", ) - val deferred = CompletableDeferred() - webView.evaluateJavaScript("navigator.userAgent") { result, _ -> - deferred.complete( - result as? String - ?: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148", - ) - } - val userAgent = deferred.await() - // Keep the WebView reference alive during suspension by referencing it here - log.d { "Captured User-Agent using WebView. WebView description: ${webView.description}" } - userAgent.also { cachedUserAgent = it } } + val userAgent = deferred.await() + // Keep the WebView reference alive during suspension by referencing it here + log.d { "Captured User-Agent using WebView. WebView description: ${webView.description}" } + userAgent.also { cachedUserAgent = it } } private suspend fun getWkCookies( From eb7fa94529afbad367c93473b7f7119455a9cb12 Mon Sep 17 00:00:00 2001 From: paregi12 Date: Thu, 9 Jul 2026 13:44:33 +0530 Subject: [PATCH 18/20] feat: optimize hidden iOS WKWebView layout size for Turnstile bypass --- .../plugins/runtime/network/CloudflareKiller.ios.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.ios.kt b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.ios.kt index c77ee58a..e77e1cfa 100644 --- a/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.ios.kt +++ b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.ios.kt @@ -100,7 +100,7 @@ internal object IosWebViewSolver : WebViewSolver { val d = CloudflareNavigationDelegate() val config = WKWebViewConfiguration() val webView = WKWebView( - frame = platform.CoreGraphics.CGRectZero.readValue(), + frame = platform.CoreGraphics.CGRectMake(0.0, 0.0, 375.0, 812.0).readValue(), configuration = config, ) webView.customUserAgent = webViewUserAgent @@ -167,7 +167,7 @@ internal object IosWebViewSolver : WebViewSolver { val d = CloudflareNavigationDelegate() val config = WKWebViewConfiguration() val webView = WKWebView( - frame = platform.CoreGraphics.CGRectZero.readValue(), + frame = platform.CoreGraphics.CGRectMake(0.0, 0.0, 375.0, 812.0).readValue(), configuration = config, ) webView.customUserAgent = webViewUserAgent @@ -243,7 +243,7 @@ internal object IosWebViewSolver : WebViewSolver { private suspend fun getOrCaptureUserAgent(): String = withContext(Dispatchers.Main) { cachedUserAgent?.let { return@withContext it } val webView = WKWebView( - frame = platform.CoreGraphics.CGRectZero.readValue(), + frame = platform.CoreGraphics.CGRectMake(0.0, 0.0, 375.0, 812.0).readValue(), configuration = WKWebViewConfiguration(), ) val deferred = CompletableDeferred() From a767eee1d5638a1b455943e6c324f7a3597375ac Mon Sep 17 00:00:00 2001 From: paregi12 Date: Thu, 9 Jul 2026 14:59:44 +0530 Subject: [PATCH 19/20] fix: remove readValue() from CGRectMake calls to resolve iOS compilation failure --- .../plugins/runtime/network/CloudflareKiller.ios.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.ios.kt b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.ios.kt index e77e1cfa..7f208693 100644 --- a/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.ios.kt +++ b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/runtime/network/CloudflareKiller.ios.kt @@ -100,7 +100,7 @@ internal object IosWebViewSolver : WebViewSolver { val d = CloudflareNavigationDelegate() val config = WKWebViewConfiguration() val webView = WKWebView( - frame = platform.CoreGraphics.CGRectMake(0.0, 0.0, 375.0, 812.0).readValue(), + frame = platform.CoreGraphics.CGRectMake(0.0, 0.0, 375.0, 812.0), configuration = config, ) webView.customUserAgent = webViewUserAgent @@ -167,7 +167,7 @@ internal object IosWebViewSolver : WebViewSolver { val d = CloudflareNavigationDelegate() val config = WKWebViewConfiguration() val webView = WKWebView( - frame = platform.CoreGraphics.CGRectMake(0.0, 0.0, 375.0, 812.0).readValue(), + frame = platform.CoreGraphics.CGRectMake(0.0, 0.0, 375.0, 812.0), configuration = config, ) webView.customUserAgent = webViewUserAgent @@ -243,7 +243,7 @@ internal object IosWebViewSolver : WebViewSolver { private suspend fun getOrCaptureUserAgent(): String = withContext(Dispatchers.Main) { cachedUserAgent?.let { return@withContext it } val webView = WKWebView( - frame = platform.CoreGraphics.CGRectMake(0.0, 0.0, 375.0, 812.0).readValue(), + frame = platform.CoreGraphics.CGRectMake(0.0, 0.0, 375.0, 812.0), configuration = WKWebViewConfiguration(), ) val deferred = CompletableDeferred() From 8f66c6d88b7162798acc7589e10de966bc58024e Mon Sep 17 00:00:00 2001 From: paregi12 Date: Fri, 10 Jul 2026 08:25:07 +0530 Subject: [PATCH 20/20] Delete .github/workflows/duild-mobile.yml --- .github/workflows/duild-mobile.yml | 251 ----------------------------- 1 file changed, 251 deletions(-) delete mode 100644 .github/workflows/duild-mobile.yml diff --git a/.github/workflows/duild-mobile.yml b/.github/workflows/duild-mobile.yml deleted file mode 100644 index 07793e8f..00000000 --- a/.github/workflows/duild-mobile.yml +++ /dev/null @@ -1,251 +0,0 @@ -name: Build Nuvio - -on: - push: - branches: - - 'kmp' - - '*' - workflow_dispatch: - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - -jobs: - build-ios-enhanced: - name: Build iOS Enhanced Version - runs-on: macos-latest - env: - GRADLE_OPTS: "-Dorg.gradle.jvmargs=-Xmx11264M -Dkotlin.daemon.jvmargs=-Xmx11264M -Dkotlin.native.jvmargs=-Xmx11264M" - steps: - - name: Checkout Repository & Submodules - uses: actions/checkout@v4 - with: - lfs: true - - - name: Fetch iOS Specific Submodule - run: git submodule update --init --recursive MPVKit - - - name: Patch MPVKit Package.swift for CI (replace local path targets with remote URLs) - run: | - cd MPVKit - python3 - << 'PYEOF' - import re - - replacements = { - "Libplacebo": ( - "https://github.com/mpvkit/libplacebo-build/releases/download/7.360.1/Libplacebo.xcframework.zip", - "2fa3d54cb81f302d6f11c7b2f509af30944381c3b11ee9d35096eb4637a6e2dd" - ), - "Libavcodec": ( - "https://github.com/mpvkit/MPVKit/releases/download/0.41.0-n8.1/Libavcodec.xcframework.zip", - "22af1a028043c1953cae8cc8b9d632da8d8c733364dc896242010457e2601905" - ), - "Libavdevice": ( - "https://github.com/mpvkit/MPVKit/releases/download/0.41.0-n8.1/Libavdevice.xcframework.zip", - "b1ba57380014e7680918a5f9f9d52cf91883f600ba1d53857f3720494de91c99" - ), - "Libavformat": ( - "https://github.com/mpvkit/MPVKit/releases/download/0.41.0-n8.1/Libavformat.xcframework.zip", - "ded64005204d4af754273ffe11e69a91752f8a6a1c64f4540205e1666698e317" - ), - "Libavfilter": ( - "https://github.com/mpvkit/MPVKit/releases/download/0.41.0-n8.1/Libavfilter.xcframework.zip", - "aedda48ffcb27e461e962af7ad7e92be736dca82943cb18dee9e4fec756b0aa4" - ), - "Libavutil": ( - "https://github.com/mpvkit/MPVKit/releases/download/0.41.0-n8.1/Libavutil.xcframework.zip", - "947c31241e317e21e20e7ac0c24c467541d383b6ab88bd42acccc5947e1a674f" - ), - "Libswresample": ( - "https://github.com/mpvkit/MPVKit/releases/download/0.41.0-n8.1/Libswresample.xcframework.zip", - "3832feceb12606ac342dd93219ae1ca2f4bcabf037ab9299cb5b633060a34248" - ), - "Libswscale": ( - "https://github.com/mpvkit/MPVKit/releases/download/0.41.0-n8.1/Libswscale.xcframework.zip", - "ed201cdae3c8e8262f3d9f3618cf3de552db47e607e39d45f93eb336ed05a075" - ), - "Libmpv": ( - "https://github.com/mpvkit/MPVKit/releases/download/0.41.0-n8.1/Libmpv.xcframework.zip", - "b4d0a9ef26abd7bbf1e0a386902206deddaeae5cd36a8ab1ba638c5327389ca7" - ), - } - - with open("Package.swift", "r") as f: - content = f.read() - - for name, (url, checksum) in replacements.items(): - # Match binaryTarget with path: for this exact name - pattern = ( - r'(\.binaryTarget\(\s*\n\s*name:\s*"' + re.escape(name) + r'",\s*\n)' - r'\s*path:\s*"[^"]+"\s*\n' - r'(\s*\))' - ) - replacement = ( - r'\g<1>' - f' url: "{url}",\n' - f' checksum: "{checksum}"\n' - r'\g<2>' - ) - new_content, count = re.subn(pattern, replacement, content) - if count: - print(f"✓ Patched {name}") - content = new_content - else: - print(f" (skipped {name} — already url-based or not found)") - - with open("Package.swift", "w") as f: - f.write(content) - - print("\nDone.") - PYEOF - - - name: Select Latest Stable Xcode - uses: maxim-lobanov/setup-xcode@v1 - with: - xcode-version: latest-stable - - - name: Make gradlew executable - run: chmod +x ./gradlew - - - name: Set up JDK 17 - uses: actions/setup-java@v5 - with: - java-version: '17' - distribution: 'temurin' - - - name: Generate local.properties - env: - SUPABASE_URL_VAR: ${{ secrets.SUPABASE_URL || 'https://dpyhjjcoabcglfmgecug.supabase.co' }} - SUPABASE_ANON_KEY_VAR: ${{ secrets.SUPABASE_ANON_KEY || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImRweWhqamNvYWJjZ2xmbWdlY3VnIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzA3OTAwMDAwfQ.dummy' }} - TRAKT_CLIENT_ID_VAR: ${{ secrets.TRAKT_CLIENT_ID || 'dummy-key' }} - TRAKT_CLIENT_SECRET_VAR: ${{ secrets.TRAKT_CLIENT_SECRET || 'dummy-key' }} - TRAKT_REDIRECT_URI_VAR: ${{ secrets.TRAKT_REDIRECT_URI || 'nuvio://auth/trakt' }} - PREMIUMIZE_CLIENT_ID_VAR: ${{ secrets.PREMIUMIZE_CLIENT_ID || 'dummy-key' }} - PREMIUMIZE_CLIENT_SECRET_VAR: ${{ secrets.PREMIUMIZE_CLIENT_SECRET || 'dummy-key' }} - PREMIUMIZE_REDIRECT_URI_VAR: ${{ secrets.PREMIUMIZE_REDIRECT_URI || 'nuvio://auth/trakt' }} - TMDB_API_KEY_VAR: ${{ secrets.TMDB_API_KEY || 'dummy-key' }} - INTRODB_API_URL_VAR: ${{ secrets.INTRODB_API_URL || 'https://api.introdb.app' }} - CONTRIBUTIONS_URL_VAR: ${{ secrets.CONTRIBUTIONS_URL || '' }} - DONATIONS_BASE_URL_VAR: ${{ secrets.DONATIONS_BASE_URL || '' }} - DONATIONS_DONATE_URL_VAR: ${{ secrets.DONATIONS_DONATE_URL || '' }} - DIRECT_DEBRID_API_BASE_URL_VAR: ${{ secrets.DIRECT_DEBRID_API_BASE_URL || '' }} - run: | - cat << EOF > local.properties - SUPABASE_URL=${SUPABASE_URL_VAR} - SUPABASE_ANON_KEY=${SUPABASE_ANON_KEY_VAR} - TRAKT_CLIENT_ID=${TRAKT_CLIENT_ID_VAR} - TRAKT_CLIENT_SECRET=${TRAKT_CLIENT_SECRET_VAR} - TRAKT_REDIRECT_URI=${TRAKT_REDIRECT_URI_VAR} - PREMIUMIZE_CLIENT_ID=${PREMIUMIZE_CLIENT_ID_VAR} - PREMIUMIZE_CLIENT_SECRET=${PREMIUMIZE_CLIENT_SECRET_VAR} - PREMIUMIZE_REDIRECT_URI=${PREMIUMIZE_REDIRECT_URI_VAR} - TMDB_API_KEY=${TMDB_API_KEY_VAR} - INTRODB_API_URL=${INTRODB_API_URL_VAR} - CONTRIBUTIONS_URL=${CONTRIBUTIONS_URL_VAR} - DONATIONS_BASE_URL=${DONATIONS_BASE_URL_VAR} - DONATIONS_DONATE_URL=${DONATIONS_DONATE_URL_VAR} - DIRECT_DEBRID_API_BASE_URL=${DIRECT_DEBRID_API_BASE_URL_VAR} - NUVIO_IOS_DISTRIBUTION=full - EOF - - - name: Build iOS App via Xcode - run: | - cd iosApp - xcodebuild \ - -project iosApp.xcodeproj \ - -scheme iosApp \ - -configuration Release \ - -sdk iphoneos \ - -destination "generic/platform=iOS" \ - -derivedDataPath build \ - CODE_SIGNING_ALLOWED=NO \ - CODE_SIGNING_REQUIRED=NO \ - CODE_SIGN_IDENTITY="" \ - build - - - name: Package App into .ipa - run: | - cd iosApp/build/Build/Products/Release-iphoneos - APP_VERSION=$(/usr/libexec/PlistBuddy -c "Print CFBundleShortVersionString" Nuvio.app/Info.plist) - mkdir Payload - mv Nuvio.app Payload/ - zip -q -r "Nuvio-v${APP_VERSION}-Enhanced.ipa" Payload - echo "IPA_FILENAME=Nuvio-v${APP_VERSION}-Enhanced.ipa" >> $GITHUB_ENV - - - name: Upload iOS Artifact - uses: actions/upload-artifact@v6 - with: - name: Nuvio-iOS-Enhanced - path: iosApp/build/Build/Products/Release-iphoneos/${{ env.IPA_FILENAME }} - - build-android-enhanced: - name: Build Android Enhanced Version - runs-on: ubuntu-latest - steps: - - name: Checkout Repository - uses: actions/checkout@v4 - - - name: Set up JDK 17 - uses: actions/setup-java@v5 - with: - java-version: '17' - distribution: 'temurin' - - - name: Setup Android SDK - uses: android-actions/setup-android@v4 - - - name: Generate local.properties - env: - SUPABASE_URL_VAR: ${{ secrets.SUPABASE_URL || 'https://dpyhjjcoabcglfmgecug.supabase.co' }} - SUPABASE_ANON_KEY_VAR: ${{ secrets.SUPABASE_ANON_KEY || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImRweWhqamNvYWJjZ2xmbWdlY3VnIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzA3OTAwMDAwfQ.dummy' }} - TRAKT_CLIENT_ID_VAR: ${{ secrets.TRAKT_CLIENT_ID || 'dummy-key' }} - TRAKT_CLIENT_SECRET_VAR: ${{ secrets.TRAKT_CLIENT_SECRET || 'dummy-key' }} - TRAKT_REDIRECT_URI_VAR: ${{ secrets.TRAKT_REDIRECT_URI || 'nuvio://auth/trakt' }} - PREMIUMIZE_CLIENT_ID_VAR: ${{ secrets.PREMIUMIZE_CLIENT_ID || 'dummy-key' }} - PREMIUMIZE_CLIENT_SECRET_VAR: ${{ secrets.PREMIUMIZE_CLIENT_SECRET || 'dummy-key' }} - PREMIUMIZE_REDIRECT_URI_VAR: ${{ secrets.PREMIUMIZE_REDIRECT_URI || 'nuvio://auth/trakt' }} - TMDB_API_KEY_VAR: ${{ secrets.TMDB_API_KEY || 'dummy-key' }} - INTRODB_API_URL_VAR: ${{ secrets.INTRODB_API_URL || 'https://api.introdb.app' }} - CONTRIBUTIONS_URL_VAR: ${{ secrets.CONTRIBUTIONS_URL || '' }} - DONATIONS_BASE_URL_VAR: ${{ secrets.DONATIONS_BASE_URL || '' }} - DONATIONS_DONATE_URL_VAR: ${{ secrets.DONATIONS_DONATE_URL || '' }} - DIRECT_DEBRID_API_BASE_URL_VAR: ${{ secrets.DIRECT_DEBRID_API_BASE_URL || '' }} - run: | - cat << EOF > local.properties - SUPABASE_URL=${SUPABASE_URL_VAR} - SUPABASE_ANON_KEY=${SUPABASE_ANON_KEY_VAR} - TRAKT_CLIENT_ID=${TRAKT_CLIENT_ID_VAR} - TRAKT_CLIENT_SECRET=${TRAKT_CLIENT_SECRET_VAR} - TRAKT_REDIRECT_URI=${TRAKT_REDIRECT_URI_VAR} - PREMIUMIZE_CLIENT_ID=${PREMIUMIZE_CLIENT_ID_VAR} - PREMIUMIZE_CLIENT_SECRET=${PREMIUMIZE_CLIENT_SECRET_VAR} - PREMIUMIZE_REDIRECT_URI=${PREMIUMIZE_REDIRECT_URI_VAR} - TMDB_API_KEY=${TMDB_API_KEY_VAR} - INTRODB_API_URL=${INTRODB_API_URL_VAR} - CONTRIBUTIONS_URL=${CONTRIBUTIONS_URL_VAR} - DONATIONS_BASE_URL=${DONATIONS_BASE_URL_VAR} - DONATIONS_DONATE_URL=${DONATIONS_DONATE_URL_VAR} - DIRECT_DEBRID_API_BASE_URL=${DIRECT_DEBRID_API_BASE_URL_VAR} - NUVIO_ANDROID_DISTRIBUTION=full - EOF - - - name: Cache Gradle - uses: actions/cache@v5 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - - - name: Build Enhanced APK - run: | - ./gradlew \ - :androidApp:assembleFullDebug \ - -Pnuvio.splitAbi=true \ - --stacktrace - - - name: Upload Android Artifact - uses: actions/upload-artifact@v6 - with: - name: Nuvio-Android-Enhanced - path: androidApp/build/outputs/apk/full/debug/*.apk