From b45080dcc817bd9840fa2f3aaca8574c9a59ba42 Mon Sep 17 00:00:00 2001 From: kaoru Date: Mon, 8 Jun 2026 20:40:37 -0400 Subject: [PATCH] add ad proxying --- .fvmrc | 3 + .gitignore | 145 +- .gitmodules | 3 + .vscode/settings.json | 3 + android/app/build.gradle.kts | 5 + .../com/tommychow/frosty/MainActivity.kt | 6 + .../frosty/streamproxy/StreamProxyChannel.kt | 120 + .../frosty/streamproxy/StreamProxyConfig.kt | 114 + .../frosty/streamproxy/StreamProxyFetcher.kt | 712 ++++ .../streamproxy/StreamProxyPageScript.kt | 651 ++++ .../streamproxy/StreamProxyRequestRouter.kt | 270 ++ .../streamproxy/StreamProxyWebViewClient.kt | 167 + android/gradle.properties | 10 +- .../channel/chat/stores/chat_tabs_store.dart | 6 +- .../channel/chat/widgets/chat_tabs.dart | 9 +- lib/screens/channel/video/video.dart | 5 +- lib/screens/channel/video/video_store.dart | 115 +- lib/screens/channel/video/video_store.g.dart | 6 +- .../settings/stores/settings_store.dart | 26 + .../settings/stores/settings_store.g.dart | 86 + lib/screens/settings/video_settings.dart | 51 + .../widgets/settings_string_list_editor.dart | 174 + lib/services/stream_proxy_bridge.dart | 84 + lib/services/stream_proxy_config.dart | 94 + pubspec.lock | 3006 +++++++++-------- references/ttv-lol-pro | 1 + .../settings/stores/settings_store_test.dart | 34 + test/services/stream_proxy_config_test.dart | 61 + 28 files changed, 4366 insertions(+), 1601 deletions(-) create mode 100644 .fvmrc create mode 100644 .gitmodules create mode 100644 .vscode/settings.json create mode 100644 android/app/src/main/kotlin/com/tommychow/frosty/streamproxy/StreamProxyChannel.kt create mode 100644 android/app/src/main/kotlin/com/tommychow/frosty/streamproxy/StreamProxyConfig.kt create mode 100644 android/app/src/main/kotlin/com/tommychow/frosty/streamproxy/StreamProxyFetcher.kt create mode 100644 android/app/src/main/kotlin/com/tommychow/frosty/streamproxy/StreamProxyPageScript.kt create mode 100644 android/app/src/main/kotlin/com/tommychow/frosty/streamproxy/StreamProxyRequestRouter.kt create mode 100644 android/app/src/main/kotlin/com/tommychow/frosty/streamproxy/StreamProxyWebViewClient.kt create mode 100644 lib/screens/settings/widgets/settings_string_list_editor.dart create mode 100644 lib/services/stream_proxy_bridge.dart create mode 100644 lib/services/stream_proxy_config.dart create mode 160000 references/ttv-lol-pro create mode 100644 test/services/stream_proxy_config_test.dart diff --git a/.fvmrc b/.fvmrc new file mode 100644 index 00000000..a8ea8515 --- /dev/null +++ b/.fvmrc @@ -0,0 +1,3 @@ +{ + "flutter": "3.38.9" +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 1cbf0d64..3ba480ef 100644 --- a/.gitignore +++ b/.gitignore @@ -1,71 +1,74 @@ -# Miscellaneous -*.class -*.log -*.pyc -*.swp -.DS_Store -.atom/ -.build/ -.buildlog/ -.history -.svn/ -.swiftpm/ -migrate_working_dir/ - -# IntelliJ related -*.iml -*.ipr -*.iws -.idea/ - -# The .vscode folder contains launch configuration and tasks you configure in -# VS Code which you may wish to be included in version control, so this line -# is commented out by default. -# .vscode/ - -# Flutter/Dart/Pub related -**/doc/api/ -**/ios/Flutter/.last_build_id -.dart_tool/ -.flutter-plugins -.flutter-plugins-dependencies -.pub-cache/ -.pub/ -/build/ -/coverage/ - -# Symbolication related -app.*.symbols - -# Obfuscation related -app.*.map.json - -# Android Studio will place build artifacts here -/android/app/debug -/android/app/profile -/android/app/release - -# Folder containing random testing files -random/ - -# fastlane specific -**/fastlane/report.xml - -# deliver temporary files -**/fastlane/Preview.html - -# snapshot generated screenshots -**/fastlane/screenshots -**/fastlane/metadata/android/en-US/images - -# scan temporary files -**/fastlane/test_output - -# potentially sensitive review information -**/fastlane/metadata/review_information - -# secrets -.env - -# ai -.claude/settings.local.json \ No newline at end of file +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +# .vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release + +# Folder containing random testing files +random/ + +# fastlane specific +**/fastlane/report.xml + +# deliver temporary files +**/fastlane/Preview.html + +# snapshot generated screenshots +**/fastlane/screenshots +**/fastlane/metadata/android/en-US/images + +# scan temporary files +**/fastlane/test_output + +# potentially sensitive review information +**/fastlane/metadata/review_information + +# secrets +.env + +# ai +.claude/settings.local.json + +# FVM Version Cache +.fvm/ \ No newline at end of file diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..c73c81ff --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "references/ttv-lol-pro"] + path = references/ttv-lol-pro + url = https://github.com/younesaassila/ttv-lol-pro diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..28739dee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "dart.flutterSdkPath": ".fvm/versions/3.38.9" +} \ No newline at end of file diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index c9f675d5..47486ae1 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -58,3 +58,8 @@ android { flutter { source = "../.." } + +dependencies { + implementation("androidx.webkit:webkit:1.15.0") + testImplementation("junit:junit:4.13.2") +} diff --git a/android/app/src/main/kotlin/com/tommychow/frosty/MainActivity.kt b/android/app/src/main/kotlin/com/tommychow/frosty/MainActivity.kt index a7791025..defbd341 100644 --- a/android/app/src/main/kotlin/com/tommychow/frosty/MainActivity.kt +++ b/android/app/src/main/kotlin/com/tommychow/frosty/MainActivity.kt @@ -1,6 +1,12 @@ package com.tommychow.frosty import cl.puntito.simple_pip_mode.PipCallbackHelperActivityWrapper +import com.tommychow.frosty.streamproxy.StreamProxyChannel +import io.flutter.embedding.engine.FlutterEngine class MainActivity: PipCallbackHelperActivityWrapper() { + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + StreamProxyChannel.register(flutterEngine) + } } diff --git a/android/app/src/main/kotlin/com/tommychow/frosty/streamproxy/StreamProxyChannel.kt b/android/app/src/main/kotlin/com/tommychow/frosty/streamproxy/StreamProxyChannel.kt new file mode 100644 index 00000000..d473efa5 --- /dev/null +++ b/android/app/src/main/kotlin/com/tommychow/frosty/streamproxy/StreamProxyChannel.kt @@ -0,0 +1,120 @@ +package com.tommychow.frosty.streamproxy + +import android.os.Handler +import android.os.Looper +import android.webkit.WebViewClient +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugins.webviewflutter.WebViewFlutterAndroidExternalApi +import java.util.concurrent.ConcurrentHashMap + +object StreamProxyChannel { + private const val CHANNEL_NAME = "frosty/stream_proxy" + + fun register(flutterEngine: FlutterEngine) { + val mainHandler = Handler(Looper.getMainLooper()) + val methodChannel = MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, + CHANNEL_NAME, + ) + val routers = ConcurrentHashMap() + val clients = ConcurrentHashMap() + + methodChannel.setMethodCallHandler { call, result -> + try { + when (call.method) { + "attach" -> { + val webViewIdentifier = webViewIdentifier(call) + ?: return@setMethodCallHandler result.error( + "missing_webview_identifier", + "Missing webViewIdentifier", + null, + ) + val webView = WebViewFlutterAndroidExternalApi.getWebView( + flutterEngine, + webViewIdentifier, + ) ?: return@setMethodCallHandler result.error( + "webview_not_found", + "No WebView found for identifier $webViewIdentifier", + null, + ) + val config = config(call) + val router = routers.getOrPut(webViewIdentifier) { + StreamProxyRequestRouter() + } + val client = clients[webViewIdentifier]?.also { + it.updateConfig(config) + } ?: StreamProxyWebViewClient( + initialConfig = config, + router = router, + onPageFinished = { url -> + mainHandler.post { + methodChannel.invokeMethod( + "pageFinished", + mapOf( + "webViewIdentifier" to webViewIdentifier, + "url" to url, + ), + ) + } + }, + ).also { + clients[webViewIdentifier] = it + } + + webView.webViewClient = client + client.attachToWebView(webView) + result.success(null) + } + "updateConfig" -> { + val config = config(call) + val webViewIdentifier = webViewIdentifier(call) + if (webViewIdentifier != null) { + clients[webViewIdentifier]?.updateConfig(config) + } else { + clients.values.forEach { it.updateConfig(config) } + } + result.success(null) + } + "detach" -> { + val webViewIdentifier = webViewIdentifier(call) + ?: return@setMethodCallHandler result.success(null) + clients.remove(webViewIdentifier) + ?.detachFromWebView() + routers.remove(webViewIdentifier) + + WebViewFlutterAndroidExternalApi.getWebView( + flutterEngine, + webViewIdentifier, + )?.webViewClient = WebViewClient() + + result.success(null) + } + else -> result.notImplemented() + } + } catch (error: Exception) { + result.error( + "stream_proxy_error", + error.message, + error.javaClass.simpleName, + ) + } + } + } + + private fun webViewIdentifier(call: MethodCall): Long? { + val arguments = call.arguments as? Map<*, *> ?: return null + return when (val value = arguments["webViewIdentifier"]) { + is Int -> value.toLong() + is Long -> value + is Number -> value.toLong() + else -> null + } + } + + private fun config(call: MethodCall): StreamProxyConfig { + val arguments = call.arguments as? Map<*, *> + return StreamProxyConfig.fromMap(arguments?.get("config") as? Map<*, *>) + } +} diff --git a/android/app/src/main/kotlin/com/tommychow/frosty/streamproxy/StreamProxyConfig.kt b/android/app/src/main/kotlin/com/tommychow/frosty/streamproxy/StreamProxyConfig.kt new file mode 100644 index 00000000..322a1a02 --- /dev/null +++ b/android/app/src/main/kotlin/com/tommychow/frosty/streamproxy/StreamProxyConfig.kt @@ -0,0 +1,114 @@ +package com.tommychow.frosty.streamproxy + +import java.net.URI +import java.net.PasswordAuthentication +import java.util.Locale + +enum class StreamProxyMode { + OFF, + TTV_LOL_PRO, +} + +enum class StreamProxyRequestType(val logName: String) { + PASSPORT("passport"), + USHER("usher"), + VIDEO_WEAVER("videoWeaver"), + GRAPHQL("graphQL"), + TWITCH_WEBPAGE("twitchWebpage"), + UNKNOWN("unknown"), +} + +enum class StreamProxyAction(val logName: String) { + PROXY("proxy"), + SKIP("skip"), + DIRECT("direct"), +} + +data class StreamProxyDecision( + val requestType: StreamProxyRequestType, + val action: StreamProxyAction, + val channel: String? = null, + val reason: String? = null, + val flagged: Boolean = false, +) + +data class StreamProxyConfig( + val mode: StreamProxyMode, + val currentChannelLogin: String, + val proxyUrls: List, + val whitelistedChannels: Set, + val debugLogging: Boolean, +) { + val enabled: Boolean + get() = mode == StreamProxyMode.TTV_LOL_PRO + + companion object { + fun fromMap(map: Map<*, *>?): StreamProxyConfig { + val mode = when (map?.get("mode") as? String) { + "ttvLolPro" -> StreamProxyMode.TTV_LOL_PRO + else -> StreamProxyMode.OFF + } + + return StreamProxyConfig( + mode = mode, + currentChannelLogin = (map?.get("currentChannelLogin") as? String) + ?.trim() + ?.lowercase(Locale.US) + .orEmpty(), + proxyUrls = stringList(map, "proxyUrls"), + whitelistedChannels = stringList(map, "whitelistedChannels") + .map { it.lowercase(Locale.US) } + .toSet(), + debugLogging = (map?.get("debugLogging") as? Boolean) ?: false, + ) + } + + private fun stringList(map: Map<*, *>?, key: String): List { + return (map?.get(key) as? List<*>) + ?.mapNotNull { (it as? String)?.trim()?.takeIf(String::isNotEmpty) } + ?: emptyList() + } + } +} + +data class ParsedProxy( + val scheme: String, + val host: String, + val port: Int, + val username: String?, + val password: String?, +) { + val displayHost: String + get() = "$host:$port" + + val hasCredentials: Boolean + get() = username != null + + val passwordAuthentication: PasswordAuthentication? + get() { + val user = username ?: return null + return PasswordAuthentication(user, (password ?: "").toCharArray()) + } +} + +fun parseProxyUrl(rawUrl: String): ParsedProxy? { + val trimmed = rawUrl.trim() + if (trimmed.isEmpty()) return null + + val normalized = if (trimmed.contains("://")) trimmed else "http://$trimmed" + val uri = runCatching { URI(normalized) }.getOrNull() ?: return null + val scheme = uri.scheme?.lowercase(Locale.US) ?: return null + if (scheme != "http" && scheme != "https") return null + + val host = uri.host?.takeIf(String::isNotEmpty) ?: return null + val port = uri.port.takeIf { it in 1..65535 } ?: return null + val credentials = uri.userInfo?.split(":", limit = 2) + + return ParsedProxy( + scheme = scheme, + host = host, + port = port, + username = credentials?.getOrNull(0), + password = credentials?.getOrNull(1), + ) +} diff --git a/android/app/src/main/kotlin/com/tommychow/frosty/streamproxy/StreamProxyFetcher.kt b/android/app/src/main/kotlin/com/tommychow/frosty/streamproxy/StreamProxyFetcher.kt new file mode 100644 index 00000000..6ae944ed --- /dev/null +++ b/android/app/src/main/kotlin/com/tommychow/frosty/streamproxy/StreamProxyFetcher.kt @@ -0,0 +1,712 @@ +package com.tommychow.frosty.streamproxy + +import android.util.Base64 +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.FilterInputStream +import java.io.InputStream +import java.net.Authenticator +import java.net.HttpURLConnection +import java.net.InetSocketAddress +import java.net.PasswordAuthentication +import java.net.Proxy +import java.net.Socket +import java.net.URL +import java.nio.charset.Charset +import java.nio.charset.StandardCharsets +import java.util.Locale +import javax.net.ssl.SSLSocket +import javax.net.ssl.SSLSocketFactory + +class StreamProxyFetcher( + private val log: (String) -> Unit, +) { + private val acceptFlag = "TTV-LOL-PRO" + private val hopByHopHeaders = setOf( + "connection", + "content-length", + "host", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", + "accept-encoding", + ) + + fun fetch( + request: WebResourceRequest, + decision: StreamProxyDecision, + config: StreamProxyConfig, + router: StreamProxyRequestRouter, + ): WebResourceResponse? { + if (config.proxyUrls.isEmpty()) { + return if (decision.flagged) { + fetchDirect(request, decision, router, "no_proxy_urls") + } else { + null + } + } + + val requestUrl = request.url.toString() + config.proxyUrls.forEachIndexed { index, proxyUrl -> + val parsedProxy = parseProxyUrl(proxyUrl) + if (parsedProxy == null) { + log( + "matched type=${decision.requestType.logName} " + + "channel=${decision.channel ?: ""} action=skip " + + "proxy=${index + 1} reason=invalid_proxy", + ) + return@forEachIndexed + } + + var connection: HttpURLConnection? = null + try { + log( + "matched type=${decision.requestType.logName} " + + "channel=${decision.channel ?: ""} action=proxy " + + "proxy=${index + 1} host=${parsedProxy.displayHost} " + + "auth=${parsedProxy.hasCredentials}", + ) + + if (URL(requestUrl).protocol.equals("https", ignoreCase = true)) { + return fetchHttpsViaConnect( + request = request, + decision = decision, + proxy = parsedProxy, + router = router, + ) + } + + return ProxyAuthenticator.withCredentials(parsedProxy) { + val proxy = Proxy( + Proxy.Type.HTTP, + InetSocketAddress(parsedProxy.host, parsedProxy.port), + ) + connection = URL(requestUrl).openConnection(proxy) as HttpURLConnection + connection.connectTimeout = CONNECT_TIMEOUT_MS + connection.readTimeout = READ_TIMEOUT_MS + connection.instanceFollowRedirects = true + connection.requestMethod = request.method + copyRequestHeaders(request.requestHeaders ?: emptyMap(), connection) + setProxyAuthorization(parsedProxy, connection) + + val statusCode = connection.responseCode + val responseStream = if (request.method.equals("HEAD", ignoreCase = true)) { + ByteArrayInputStream(ByteArray(0)) + } else if (statusCode >= 400) { + connection.errorStream ?: ByteArrayInputStream(ByteArray(0)) + } else { + connection.inputStream + } + + val contentType = connection.contentType + val responseHeaders = responseHeaders(connection) + val mimeType = mimeTypeFrom(contentType, requestUrl) + val encoding = encodingFrom(contentType) + val reason = connection.responseMessage ?: "OK" + + if ( + decision.requestType == StreamProxyRequestType.USHER && + statusCode in 200..399 && + !request.method.equals("HEAD", ignoreCase = true) + ) { + val bytes = responseStream.readBytes() + val body = decodeBody(bytes, encoding) + router.rememberUsherManifest( + channel = decision.channel, + manifestUrl = requestUrl, + manifestBody = body, + ) + connection.disconnect() + + return@withCredentials WebResourceResponse( + mimeType, + encoding, + statusCode, + reason, + responseHeaders, + ByteArrayInputStream(bytes), + ) + } + + WebResourceResponse( + mimeType, + encoding, + statusCode, + reason, + responseHeaders, + responseStream, + ) + } + } catch (error: Exception) { + connection?.disconnect() + log( + "matched type=${decision.requestType.logName} " + + "channel=${decision.channel ?: ""} action=proxy_failed " + + "proxy=${index + 1} host=${parsedProxy.displayHost} " + + "reason=${formatError(error)}", + ) + } + } + + return if (decision.flagged) { + fetchDirect(request, decision, router, "proxy_failed") + } else { + null + } + } + + private fun fetchDirect( + request: WebResourceRequest, + decision: StreamProxyDecision, + router: StreamProxyRequestRouter, + reason: String, + ): WebResourceResponse? { + var connection: HttpURLConnection? = null + val requestUrl = request.url.toString() + + return try { + log( + "matched type=${decision.requestType.logName} " + + "channel=${decision.channel ?: ""} action=direct_fallback " + + "reason=$reason", + ) + connection = URL(requestUrl).openConnection() as HttpURLConnection + connection.connectTimeout = CONNECT_TIMEOUT_MS + connection.readTimeout = READ_TIMEOUT_MS + connection.instanceFollowRedirects = true + connection.requestMethod = request.method + copyRequestHeaders(request.requestHeaders ?: emptyMap(), connection) + + val statusCode = connection.responseCode + val responseStream = if (request.method.equals("HEAD", ignoreCase = true)) { + ByteArrayInputStream(ByteArray(0)) + } else if (statusCode >= 400) { + connection.errorStream ?: ByteArrayInputStream(ByteArray(0)) + } else { + connection.inputStream + } + + val contentType = connection.contentType + val responseHeaders = responseHeaders(connection) + val mimeType = mimeTypeFrom(contentType, requestUrl) + val encoding = encodingFrom(contentType) + val reasonPhrase = connection.responseMessage ?: "OK" + + if ( + decision.requestType == StreamProxyRequestType.USHER && + statusCode in 200..399 && + !request.method.equals("HEAD", ignoreCase = true) + ) { + val bytes = responseStream.readBytes() + val body = decodeBody(bytes, encoding) + router.rememberUsherManifest( + channel = decision.channel, + manifestUrl = requestUrl, + manifestBody = body, + ) + connection.disconnect() + + WebResourceResponse( + mimeType, + encoding, + statusCode, + reasonPhrase, + responseHeaders, + ByteArrayInputStream(bytes), + ) + } else { + WebResourceResponse( + mimeType, + encoding, + statusCode, + reasonPhrase, + responseHeaders, + responseStream, + ) + } + } catch (error: Exception) { + connection?.disconnect() + log( + "matched type=${decision.requestType.logName} " + + "channel=${decision.channel ?: ""} action=direct_failed " + + "reason=${formatError(error)}", + ) + null + } + } + + private fun fetchHttpsViaConnect( + request: WebResourceRequest, + decision: StreamProxyDecision, + proxy: ParsedProxy, + router: StreamProxyRequestRouter, + ): WebResourceResponse { + val requestUrl = request.url.toString() + val url = URL(requestUrl) + val port = if (url.port != -1) url.port else 443 + + val proxySocket = Socket() + proxySocket.connect( + InetSocketAddress(proxy.host, proxy.port), + CONNECT_TIMEOUT_MS, + ) + proxySocket.soTimeout = READ_TIMEOUT_MS + + try { + val proxyInput = proxySocket.getInputStream().buffered() + val proxyOutput = proxySocket.getOutputStream() + writeConnectRequest(proxyOutput, proxy, url.host, port) + val connectStatus = readStatusLine(proxyInput, "proxy CONNECT") + val connectHeaders = readHeaders(proxyInput) + if (connectStatus.statusCode !in 200..299) { + throw java.io.IOException( + "proxy CONNECT ${connectStatus.statusCode} " + + "${connectStatus.reason} headers=${connectHeaders.keys.joinToString(",")}", + ) + } + + val sslSocketFactory = SSLSocketFactory.getDefault() as SSLSocketFactory + val sslSocket = sslSocketFactory.createSocket( + proxySocket, + url.host, + port, + true, + ) as SSLSocket + sslSocket.soTimeout = READ_TIMEOUT_MS + sslSocket.startHandshake() + + val responseInput = sslSocket.getInputStream().buffered() + val responseOutput = sslSocket.getOutputStream() + writeOriginRequest( + output = responseOutput, + request = request, + url = url, + ) + + val status = readStatusLine(responseInput, "origin response") + val headers = readHeaders(responseInput) + val bodyStream = responseBodyStream( + input = responseInput, + headers = headers, + closeSocket = sslSocket, + isHead = request.method.equals("HEAD", ignoreCase = true), + ) + val contentType = headers.entries + .firstOrNull { it.key.equals("content-type", ignoreCase = true) } + ?.value + val mimeType = mimeTypeFrom(contentType, requestUrl) + val encoding = encodingFrom(contentType) + val responseHeaders = headers + .filterKeys { !it.equals("transfer-encoding", ignoreCase = true) } + + if ( + decision.requestType == StreamProxyRequestType.USHER && + status.statusCode in 200..399 && + !request.method.equals("HEAD", ignoreCase = true) + ) { + val bytes = bodyStream.readBytes() + val body = decodeBody(bytes, encoding) + router.rememberUsherManifest( + channel = decision.channel, + manifestUrl = requestUrl, + manifestBody = body, + ) + sslSocket.close() + + return WebResourceResponse( + mimeType, + encoding, + status.statusCode, + status.reason, + responseHeaders, + ByteArrayInputStream(bytes), + ) + } + + return WebResourceResponse( + mimeType, + encoding, + status.statusCode, + status.reason, + responseHeaders, + bodyStream, + ) + } catch (error: Exception) { + proxySocket.close() + throw error + } + } + + private fun copyRequestHeaders( + headers: Map, + connection: HttpURLConnection, + ) { + copyRequestHeaders(headers) { name, value -> + connection.setRequestProperty(name, value) + } + } + + private fun copyRequestHeaders( + headers: Map, + appendHeader: (String, String) -> Unit, + ) { + headers.forEach { (name, value) -> + if (!hopByHopHeaders.contains(name.lowercase(Locale.US))) { + sanitizedHeaderValue(name, value)?.let { sanitizedValue -> + appendHeader(name, sanitizedValue) + } + } + } + } + + private fun sanitizedHeaderValue(name: String, value: String): String? { + if (!name.equals("accept", ignoreCase = true)) return value + + val sanitized = value + .replace(acceptFlag, "", ignoreCase = true) + .trim() + + return sanitized.takeIf(String::isNotEmpty) + } + + private fun setProxyAuthorization( + proxy: ParsedProxy, + connection: HttpURLConnection, + ) { + connection.setRequestProperty( + "Proxy-Authorization", + proxyAuthorizationHeader(proxy) ?: return, + ) + } + + private fun proxyAuthorizationHeader(proxy: ParsedProxy): String? { + val username = proxy.username ?: return null + val password = proxy.password ?: "" + val credentials = "$username:$password" + val encodedCredentials = Base64.encodeToString( + credentials.toByteArray(Charsets.UTF_8), + Base64.NO_WRAP, + ) + return "Basic $encodedCredentials" + } + + private fun writeConnectRequest( + output: java.io.OutputStream, + proxy: ParsedProxy, + host: String, + port: Int, + ) { + val request = buildString { + append("CONNECT ") + append(host) + append(":") + append(port) + append(" HTTP/1.1\r\n") + append("Host: ") + append(host) + append(":") + append(port) + append("\r\n") + append("Connection: keep-alive\r\n") + proxyAuthorizationHeader(proxy)?.let { authorization -> + append("Proxy-Authorization: ") + append(authorization) + append("\r\n") + } + append("\r\n") + } + output.write(request.toByteArray(StandardCharsets.ISO_8859_1)) + output.flush() + } + + private fun writeOriginRequest( + output: java.io.OutputStream, + request: WebResourceRequest, + url: URL, + ) { + val path = url.file.takeIf(String::isNotEmpty) ?: "/" + val requestText = buildString { + append(request.method.uppercase(Locale.US)) + append(" ") + append(path) + append(" HTTP/1.1\r\n") + append("Host: ") + append(url.host) + if (url.port != -1 && url.port != 443) { + append(":") + append(url.port) + } + append("\r\n") + copyRequestHeaders(request.requestHeaders ?: emptyMap()) { name, value -> + append(name) + append(": ") + append(value) + append("\r\n") + } + append("Connection: close\r\n") + append("\r\n") + } + output.write(requestText.toByteArray(StandardCharsets.ISO_8859_1)) + output.flush() + } + + private fun responseHeaders(connection: HttpURLConnection): Map { + return connection.headerFields + .mapNotNull { (name, values) -> + val value = values?.firstOrNull() + if (name != null && value != null) { + name to value + } else { + null + } + } + .toMap() + } + + private fun readStatusLine( + input: java.io.BufferedInputStream, + context: String, + ): HttpStatus { + val statusLine = readAsciiLine(input) + ?: throw java.io.IOException("$context missing status line") + val parts = statusLine.split(" ", limit = 3) + val statusCode = parts.getOrNull(1)?.toIntOrNull() + ?: throw java.io.IOException("$context invalid status line '$statusLine'") + return HttpStatus( + statusCode = statusCode, + reason = parts.getOrNull(2)?.takeIf(String::isNotEmpty) ?: "OK", + ) + } + + private fun readHeaders(input: java.io.BufferedInputStream): Map { + val headers = linkedMapOf() + while (true) { + val line = readAsciiLine(input) ?: break + if (line.isEmpty()) break + + val separatorIndex = line.indexOf(":") + if (separatorIndex <= 0) continue + + val name = line.substring(0, separatorIndex).trim() + val value = line.substring(separatorIndex + 1).trim() + headers[name] = value + } + return headers + } + + private fun readAsciiLine(input: java.io.BufferedInputStream): String? { + val buffer = ByteArrayOutputStream() + while (true) { + val next = input.read() + if (next == -1) { + if (buffer.size() == 0) return null + break + } + if (next == '\n'.code) break + if (next != '\r'.code) { + buffer.write(next) + } + } + return buffer.toString(StandardCharsets.ISO_8859_1.name()) + } + + private fun responseBodyStream( + input: java.io.BufferedInputStream, + headers: Map, + closeSocket: Socket, + isHead: Boolean, + ): InputStream { + if (isHead) { + closeSocket.close() + return ByteArrayInputStream(ByteArray(0)) + } + + val transferEncoding = headers.entries + .firstOrNull { it.key.equals("transfer-encoding", ignoreCase = true) } + ?.value + + val bodyStream = if (transferEncoding?.contains("chunked", ignoreCase = true) == true) { + ChunkedInputStream(input) + } else { + input + } + + return ClosingInputStream(bodyStream, closeSocket) + } + + private fun mimeTypeFrom(contentType: String?, url: String): String? { + val mimeType = contentType + ?.substringBefore(";") + ?.trim() + ?.takeIf(String::isNotEmpty) + + if (mimeType != null) return mimeType + + return when { + url.endsWith(".m3u8", ignoreCase = true) -> "application/vnd.apple.mpegurl" + url.endsWith(".ts", ignoreCase = true) -> "video/mp2t" + url.endsWith(".mp4", ignoreCase = true) -> "video/mp4" + else -> null + } + } + + private fun encodingFrom(contentType: String?): String? { + return contentType + ?.split(";") + ?.map(String::trim) + ?.firstOrNull { it.startsWith("charset=", ignoreCase = true) } + ?.substringAfter("=") + ?.takeIf(String::isNotEmpty) + } + + private fun decodeBody(bytes: ByteArray, encoding: String?): String { + return try { + bytes.toString(Charset.forName(encoding ?: "UTF-8")) + } catch (_: Exception) { + bytes.toString(Charsets.UTF_8) + } + } + + companion object { + private const val CONNECT_TIMEOUT_MS = 8000 + private const val READ_TIMEOUT_MS = 15000 + + private fun formatError(error: Exception): String { + val message = error.message + ?.replace(Regex("\\s+"), " ") + ?.take(180) + ?.let { ":$it" } + ?: "" + return "${error.javaClass.simpleName}$message" + } + } + + private data class HttpStatus( + val statusCode: Int, + val reason: String, + ) +} + +private class ClosingInputStream( + delegate: InputStream, + private val socket: Socket, +) : FilterInputStream(delegate) { + override fun close() { + try { + super.close() + } finally { + socket.close() + } + } +} + +private class ChunkedInputStream( + private val input: java.io.BufferedInputStream, +) : InputStream() { + private var remainingInChunk = 0 + private var done = false + + override fun read(): Int { + if (!prepareChunk()) return -1 + + val value = input.read() + if (value == -1) { + done = true + return -1 + } + + remainingInChunk -= 1 + if (remainingInChunk == 0) { + readAsciiLine(input) + } + return value + } + + override fun read(buffer: ByteArray, offset: Int, length: Int): Int { + if (!prepareChunk()) return -1 + + val count = input.read(buffer, offset, minOf(length, remainingInChunk)) + if (count == -1) { + done = true + return -1 + } + + remainingInChunk -= count + if (remainingInChunk == 0) { + readAsciiLine(input) + } + return count + } + + private fun prepareChunk(): Boolean { + if (done) return false + if (remainingInChunk > 0) return true + + val chunkHeader = readAsciiLine(input) ?: run { + done = true + return false + } + val chunkSize = chunkHeader + .substringBefore(";") + .trim() + .toIntOrNull(16) + ?: throw java.io.IOException("invalid chunk size '$chunkHeader'") + + if (chunkSize == 0) { + while (true) { + val trailerLine = readAsciiLine(input) ?: break + if (trailerLine.isEmpty()) break + } + done = true + return false + } + + remainingInChunk = chunkSize + return true + } + + private fun readAsciiLine(input: java.io.BufferedInputStream): String? { + val buffer = ByteArrayOutputStream() + while (true) { + val next = input.read() + if (next == -1) { + if (buffer.size() == 0) return null + break + } + if (next == '\n'.code) break + if (next != '\r'.code) { + buffer.write(next) + } + } + return buffer.toString(StandardCharsets.ISO_8859_1.name()) + } +} + +private object ProxyAuthenticator : Authenticator() { + private val credentials = ThreadLocal() + + init { + System.setProperty("jdk.http.auth.tunneling.disabledSchemes", "") + Authenticator.setDefault(this) + } + + fun withCredentials(proxy: ParsedProxy, block: () -> T): T { + credentials.set(proxy.passwordAuthentication) + try { + return block() + } finally { + credentials.remove() + } + } + + override fun getPasswordAuthentication(): PasswordAuthentication? { + if (requestorType != RequestorType.PROXY) return null + return credentials.get() + } +} diff --git a/android/app/src/main/kotlin/com/tommychow/frosty/streamproxy/StreamProxyPageScript.kt b/android/app/src/main/kotlin/com/tommychow/frosty/streamproxy/StreamProxyPageScript.kt new file mode 100644 index 00000000..c953ddc2 --- /dev/null +++ b/android/app/src/main/kotlin/com/tommychow/frosty/streamproxy/StreamProxyPageScript.kt @@ -0,0 +1,651 @@ +package com.tommychow.frosty.streamproxy + +import org.json.JSONArray +import org.json.JSONObject + +object StreamProxyPageScript { + fun create(config: StreamProxyConfig): String { + val workerInstaller = INSTALLER.replace("__INSTALLER_SOURCE__", "null") + val pageInstaller = INSTALLER.replace( + "__INSTALLER_SOURCE__", + JSONObject.quote(workerInstaller), + ) + return "($pageInstaller)(${config.toPageJson()});" + } + + fun updateConfigScript(config: StreamProxyConfig): String { + return "globalThis.__frostyStreamProxy?.updateConfig(${config.toPageJson()});" + } + + private fun StreamProxyConfig.toPageJson(): String { + return JSONObject() + .put("enabled", enabled) + .put("currentChannelLogin", currentChannelLogin) + .put("hasProxyUrls", proxyUrls.isNotEmpty()) + .put("whitelistedChannels", JSONArray(whitelistedChannels.toList())) + .put("debugLogging", debugLogging) + .toString() + } + + private val INSTALLER = """ +function(initialConfig) { + "use strict"; + + const ACCEPT_FLAG = "TTV-LOL-PRO"; + const GRAPHQL_URL = "https://gql.twitch.tv/gql"; + const TWITCH_CLIENT_ID = "kimne78kx3ncx6brgo4mv6wki5h1ko"; + const scope = typeof WorkerGlobalScope !== "undefined" && self instanceof WorkerGlobalScope + ? "worker" + : "page"; + + if (globalThis.__frostyStreamProxy) { + globalThis.__frostyStreamProxy.updateConfig(initialConfig); + return; + } + + const nativeFetch = globalThis.fetch.bind(globalThis); + const nativeWorker = scope === "page" ? globalThis.Worker : null; + const state = { + config: normalizeConfig(initialConfig), + cachedUsherUrl: null, + cachedPlaybackTokenRequestBody: null, + cachedPlaybackTokenRequestHeaders: null, + manifests: [], + proxiedVideoWeaverUrls: new Map(), + videoWeaverUrlsToNotProxy: new Set(), + locks: new Map(), + workerBlobUrls: [], + }; + + globalThis.__frostyStreamProxy = { + updateConfig(config) { + state.config = normalizeConfig(config); + log("config updated"); + }, + }; + + globalThis.fetch = streamProxyFetch; + if (scope === "page" && nativeWorker) { + installWorkerWrapper(); + } + + log("fetch hook installed in " + scope); + + async function streamProxyFetch(input, init) { + const originalRequest = toRequest(input, init); + const url = normalizeUrl(originalRequest.url); + const host = hostFromUrl(url); + let request = originalRequest; + let requestType = null; + let flagged = false; + let response; + + try { + if (!state.config.enabled) { + return nativeFetch(request); + } + + if (host === "gql.twitch.tv") { + requestType = "graphQL"; + request = await handleGraphQlRequest(request); + } else if (host === "usher.ttvnw.net") { + requestType = "usher"; + const usherRequest = handleUsherRequest(request, url); + request = usherRequest.request; + flagged = usherRequest.flagged; + } else if (isVideoWeaverHost(host)) { + requestType = "videoWeaver"; + const weaverRequest = handleVideoWeaverRequest(request, url); + request = weaverRequest.request; + flagged = weaverRequest.flagged; + } + + if (flagged && requestType) { + response = await withTypeLock(requestType, () => nativeFetch(request)); + } else { + if (requestType) { + await waitForTypeLock(requestType); + } + response = await nativeFetch(request); + } + + if (host === "usher.ttvnw.net" && response.status < 400) { + await handleUsherResponse(response, url); + } else if (isVideoWeaverHost(host) && response.status < 400) { + await handleVideoWeaverResponse(response, url, flagged); + } + + return response; + } catch (error) { + log("fetch hook error type=" + (requestType || "unknown") + " reason=" + formatError(error)); + throw error; + } + } + + async function handleGraphQlRequest(request) { + const body = await request.clone().text().catch(() => null); + if (!body || body.indexOf("PlaybackAccessToken") === -1) { + return request; + } + + state.cachedPlaybackTokenRequestBody = body; + state.cachedPlaybackTokenRequestHeaders = headersToObject(request.headers); + + let parsed; + try { + parsed = JSON.parse(body); + } catch (error) { + log("graphQL parse failed reason=" + formatError(error)); + return request; + } + + const queries = Array.isArray(parsed) ? parsed : [parsed]; + const eligible = queries.some((query) => { + const login = query && query.variables && query.variables.login; + if (!login || isVodLogin(login)) return false; + if (query.variables.playerType === "frontpage") return false; + return !isWhitelisted(login); + }); + if (!eligible) { + log("graphQL playback token skipped reason=ineligible"); + return request; + } + + const headers = new Headers(request.headers); + setHeaderIfExists(headers, "Authorization", "undefined"); + setHeaderIfExists(headers, "Client-Session-Id", randomString(16, "abcdefghijklmnopqrstuvwxyz0123456789")); + setHeaderIfExists(headers, "Device-ID", randomString(32)); + setHeaderIfExists(headers, "X-Device-Id", randomString(32)); + log("graphQL playback token anonymized"); + return new Request(request, { headers }); + } + + function handleUsherRequest(request, url) { + state.cachedUsherUrl = url; + const channel = channelFromUsherUrl(url); + if (url.toLowerCase().indexOf("/vod/") !== -1) { + log("usher skipped channel=" + (channel || "") + " reason=vod"); + return { request, flagged: false }; + } + if (url.indexOf(encodeURIComponent('"player_type":"frontpage"')) !== -1) { + log("usher skipped channel=" + (channel || "") + " reason=frontpage"); + return { request, flagged: false }; + } + if (isWhitelisted(channel)) { + log("usher skipped channel=" + (channel || "") + " reason=whitelisted"); + return { request, flagged: false }; + } + + let nextRequest = request; + const anonymizedUrl = anonymizeUsherUrl(url); + if (anonymizedUrl !== url) { + nextRequest = new Request(anonymizedUrl, request); + } + + if (!state.config.hasProxyUrls) { + return { request: nextRequest, flagged: false }; + } + + log("usher flagged channel=" + (channel || "")); + return { request: flagRequest(nextRequest), flagged: true }; + } + + function handleVideoWeaverRequest(request, url) { + if (state.videoWeaverUrlsToNotProxy.has(url)) { + log("videoWeaver skipped reason=marked_direct"); + return { request, flagged: false }; + } + + const manifest = findManifestForUrl(url); + let videoWeaverUrl = url; + if (manifest && manifest.replacementMap) { + const quality = qualityForUrl(manifest.assignedMap, url); + videoWeaverUrl = quality && manifest.replacementMap.has(quality) + ? manifest.replacementMap.get(quality) + : firstMapValue(manifest.replacementMap); + if (videoWeaverUrl && videoWeaverUrl !== url) { + request = new Request(videoWeaverUrl, request); + log("videoWeaver replaced quality=" + (quality || "fallback")); + } + } + + if (!state.config.hasProxyUrls || !isVideoWeaverPlaylistUrl(videoWeaverUrl)) { + return { request, flagged: false }; + } + + const count = state.proxiedVideoWeaverUrls.get(videoWeaverUrl) || 0; + if (count >= 1) { + return { request, flagged: false }; + } + + state.proxiedVideoWeaverUrls.set(videoWeaverUrl, count + 1); + log("videoWeaver flagged count=" + (count + 1)); + return { request: flagRequest(request), flagged: true }; + } + + async function handleUsherResponse(response, url) { + if (url.toLowerCase().indexOf("/vod/") !== -1) return; + const body = await response.clone().text().catch(() => null); + if (!body) return; + + const channel = channelFromUsherUrl(url); + const assignedMap = parseUsherManifest(body, url); + if (!assignedMap || assignedMap.size === 0) { + log("usher response parse failed channel=" + (channel || "")); + return; + } + + state.manifests = state.manifests.filter((manifest) => !manifest.deleted); + const manifest = { + channelName: channel, + assignedMap, + replacementMap: null, + consecutiveAdResponses: 0, + consecutiveAdCooldown: 0, + deleted: false, + }; + state.manifests.push(manifest); + + for (const videoWeaverUrl of assignedMap.values()) { + state.proxiedVideoWeaverUrls.delete(videoWeaverUrl); + state.videoWeaverUrlsToNotProxy.delete(videoWeaverUrl); + if (url.indexOf(encodeURIComponent('"player_type":"frontpage"')) !== -1 || isWhitelisted(channel)) { + state.videoWeaverUrlsToNotProxy.add(videoWeaverUrl); + } + } + log("usher response tracked channel=" + (channel || "") + " variants=" + assignedMap.size); + } + + async function handleVideoWeaverResponse(response, url, flagged) { + const manifest = findManifestForUrl(url); + if (!manifest) return; + + const body = await response.clone().text().catch(() => null); + if (!body) return; + + const includesAd = body.toLowerCase().indexOf("stitched-ad") !== -1; + if (!includesAd) { + if (manifest.consecutiveAdCooldown > 0) { + manifest.consecutiveAdCooldown -= 1; + } else { + manifest.consecutiveAdResponses = 0; + } + return; + } + + log("videoWeaver ad_detected channel=" + (manifest.channelName || "")); + if (state.videoWeaverUrlsToNotProxy.has(url)) return; + + manifest.consecutiveAdResponses += 1; + manifest.consecutiveAdCooldown = 15; + if (manifest.consecutiveAdResponses <= 1) { + const replacementUrls = await updateVideoWeaverReplacementMap(manifest, flagged ? false : undefined); + if (flagged) { + replacementUrls.forEach((replacementUrl) => state.videoWeaverUrlsToNotProxy.add(replacementUrl)); + } + log("videoWeaver replacement_ready count=" + replacementUrls.length); + throw new Error("Frosty stream proxy cancelled ad playlist request"); + } + + if (manifest.consecutiveAdResponses === 2 && manifest.replacementMap && !flagged) { + manifest.replacementMap = null; + throw new Error("Frosty stream proxy reset replacement map"); + } + } + + async function updateVideoWeaverReplacementMap(manifest, flaggedOverride) { + const token = await fetchReplacementPlaybackAccessToken(flaggedOverride); + if (!token || !token.value || !token.signature) { + throw new Error("replacement token unavailable"); + } + + const usherManifest = await fetchReplacementUsherManifest(token, flaggedOverride); + const replacementMap = parseUsherManifest(usherManifest || "", state.cachedUsherUrl || ""); + if (!replacementMap || replacementMap.size === 0) { + throw new Error("replacement usher parse failed"); + } + + manifest.replacementMap = replacementMap; + return Array.from(replacementMap.values()); + } + + async function fetchReplacementPlaybackAccessToken(flaggedOverride) { + const channel = state.config.currentChannelLogin; + if (!channel) return null; + + const headers = new Headers(); + headers.set("Authorization", "undefined"); + headers.set("Client-ID", TWITCH_CLIENT_ID); + headers.set("Content-Type", "text/plain; charset=UTF-8"); + headers.set("Device-ID", randomString(32)); + + const queryText = "query PlaybackAccessToken_Template { streamPlaybackAccessToken(channelName: " + + JSON.stringify(channel) + + ", params: {platform: \"web\", playerBackend: \"mediaplayer\", playerType: \"site\"}) { value signature authorization { isForbidden forbiddenReasonCode } __typename }}"; + const query = { + operationName: "PlaybackAccessToken_Template", + query: queryText, + variables: {}, + }; + + let request = new Request(GRAPHQL_URL, { + method: "POST", + headers, + body: JSON.stringify(query), + }); + if (flaggedOverride === true && state.config.hasProxyUrls) { + request = flagRequest(request); + } + + const response = await nativeFetch(request); + if (response.status >= 400) return null; + const json = await response.json(); + return json && json.data && json.data.streamPlaybackAccessToken; + } + + async function fetchReplacementUsherManifest(token, flaggedOverride) { + const replacementUrl = getReplacementUsherUrl(token); + if (!replacementUrl) return null; + + let request = new Request(replacementUrl); + const shouldFlag = flaggedOverride === undefined ? state.config.hasProxyUrls : flaggedOverride; + if (shouldFlag && state.config.hasProxyUrls) { + request = flagRequest(request); + } + + const response = await nativeFetch(request); + if (response.status >= 400) return null; + return response.text(); + } + + function getReplacementUsherUrl(token) { + if (!state.cachedUsherUrl) return null; + try { + const url = new URL(state.cachedUsherUrl); + url.searchParams.set("sig", token.signature); + url.searchParams.set("token", token.value); + return anonymizeUsherUrl(url.toString()); + } catch (error) { + return null; + } + } + + function parseUsherManifest(body, manifestUrl) { + const lines = body.split(/\r?\n/).map((line) => line.trim()); + const variants = []; + let pendingAttributes = null; + + for (const line of lines) { + if (!line) continue; + if (line.toUpperCase().indexOf("#EXT-X-STREAM-INF:") === 0) { + pendingAttributes = parseAttributes(line.substring(line.indexOf(":") + 1)); + continue; + } + if (line.charAt(0) === "#") continue; + if (!pendingAttributes) continue; + + variants.push({ + attributes: pendingAttributes, + uri: absolutize(line, manifestUrl), + }); + pendingAttributes = null; + } + + if (variants.length === 0) return null; + + variants.sort((a, b) => { + const scoreA = Number(a.attributes.SCORE || 0); + const scoreB = Number(b.attributes.SCORE || 0); + if (scoreA && scoreB) return scoreB - scoreA; + return b.uri.length - a.uri.length; + }); + + const keys = new Map(); + const map = new Map(); + variants.forEach((variant, index) => { + const resolution = variant.attributes.RESOLUTION || ""; + const height = resolution.indexOf("x") !== -1 ? resolution.split("x")[1] : ""; + const frameRate = variant.attributes["FRAME-RATE"] ? Math.round(Number(variant.attributes["FRAME-RATE"])) : ""; + const baseKey = height ? height + "p" + frameRate : "idx_" + index; + const count = keys.get(baseKey) || 0; + keys.set(baseKey, count + 1); + map.set(count === 0 ? baseKey : baseKey + "_" + count, variant.uri); + }); + return map; + } + + function parseAttributes(raw) { + const attributes = {}; + let key = ""; + let value = ""; + let readingKey = true; + let quoted = false; + + function commit() { + if (key) attributes[key.trim()] = value.trim().replace(/^"|"$/g, ""); + key = ""; + value = ""; + readingKey = true; + } + + for (let i = 0; i < raw.length; i += 1) { + const char = raw.charAt(i); + if (readingKey && char === "=") { + readingKey = false; + continue; + } + if (!readingKey && char === '"') { + quoted = !quoted; + } + if (!readingKey && char === "," && !quoted) { + commit(); + continue; + } + if (readingKey) key += char; + else value += char; + } + commit(); + return attributes; + } + + function installWorkerWrapper() { + globalThis.Worker = class FrostyStreamProxyWorker extends nativeWorker { + constructor(scriptUrl, options) { + const fullUrl = absolutize(String(scriptUrl), String(location.href)); + if (fullUrl.indexOf(".twitch.tv") === -1) { + super(scriptUrl, options); + return; + } + + let twitchWorkerScript = ""; + try { + const request = new XMLHttpRequest(); + request.open("GET", fullUrl, false); + request.send(); + if (request.status >= 200 && request.status < 300) { + twitchWorkerScript = request.responseText; + } else { + twitchWorkerScript = "importScripts(" + JSON.stringify(fullUrl) + ");"; + } + } catch (error) { + twitchWorkerScript = "importScripts(" + JSON.stringify(fullUrl) + ");"; + } + + const workerInstallerSource = __INSTALLER_SOURCE__; + if (!workerInstallerSource) { + super(scriptUrl, options); + return; + } + + const workerScript = "(" + workerInstallerSource + ")(" + JSON.stringify(state.config) + ");\n" + twitchWorkerScript; + const blobUrl = URL.createObjectURL(new Blob([workerScript], { type: "text/javascript" })); + state.workerBlobUrls.push(blobUrl); + super(blobUrl, options); + } + }; + log("Worker hook installed"); + } + + function toRequest(input, init) { + return input instanceof Request ? new Request(input, init) : new Request(input, init); + } + + function normalizeUrl(url) { + return absolutize(url, String(location.href)); + } + + function absolutize(url, base) { + try { + return new URL(url, base).toString(); + } catch (error) { + return url; + } + } + + function hostFromUrl(url) { + try { + return new URL(url).host.toLowerCase(); + } catch (error) { + return ""; + } + } + + function channelFromUsherUrl(url) { + const match = /\/hls\/([^/?#]+)\.m3u8/i.exec(url); + return match ? decodeURIComponent(match[1]).toLowerCase() : null; + } + + function isVideoWeaverHost(host) { + return /^(?:[a-z0-9-]+\.playlist\.(?:live-video|ttvnw)\.net|video-weaver\.[a-z0-9-]+\.hls\.ttvnw\.net)$/i.test(host); + } + + function isVideoWeaverPlaylistUrl(url) { + return /^https?:\/\/(?:[a-z0-9-]+\.playlist\.(?:live-video|ttvnw)\.net|video-weaver\.[a-z0-9-]+\.hls\.ttvnw\.net)\/v1\/playlist\/.+\.m3u8(?:$|[?#])/i.test(url); + } + + function isVodLogin(login) { + return /^\d+$/.test(String(login)); + } + + function isWhitelisted(channel) { + if (!channel) return false; + return state.config.whitelistedChannels.indexOf(String(channel).toLowerCase()) !== -1; + } + + function flagRequest(request) { + const headers = new Headers(request.headers); + const accept = headers.get("Accept") || ""; + if (accept.indexOf(ACCEPT_FLAG) === -1) { + headers.set("Accept", accept + ACCEPT_FLAG); + } + return new Request(request, { headers }); + } + + function anonymizeUsherUrl(rawUrl) { + try { + const url = new URL(rawUrl); + url.searchParams.set("p", String(Math.floor(Math.random() * 10000000))); + url.searchParams.set("play_session_id", randomString(32, "abcdefghijklmnopqrstuvwxyz0123456789")); + return url.toString(); + } catch (error) { + return rawUrl; + } + } + + function findManifestForUrl(url) { + for (const manifest of state.manifests) { + for (const assignedUrl of manifest.assignedMap.values()) { + if (assignedUrl === url) return manifest; + } + } + return null; + } + + function qualityForUrl(map, url) { + for (const entry of map.entries()) { + if (entry[1] === url) return entry[0]; + } + return null; + } + + function firstMapValue(map) { + const next = map.values().next(); + return next.done ? null : next.value; + } + + function setHeaderIfExists(headers, name, value) { + if (headers.has(name)) headers.set(name, value); + } + + function headersToObject(headers) { + const object = {}; + headers.forEach((value, key) => { + object[key] = value; + }); + return object; + } + + function normalizeConfig(config) { + const whitelistedChannels = Array.isArray(config && config.whitelistedChannels) + ? config.whitelistedChannels.map((channel) => String(channel).toLowerCase()) + : []; + return { + enabled: !!(config && config.enabled), + currentChannelLogin: String((config && config.currentChannelLogin) || "").toLowerCase(), + hasProxyUrls: !!(config && config.hasProxyUrls), + whitelistedChannels, + debugLogging: !!(config && config.debugLogging), + }; + } + + function randomString(length, alphabet) { + const chars = alphabet || "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + let result = ""; + const cryptoObject = globalThis.crypto; + if (cryptoObject && cryptoObject.getRandomValues) { + const bytes = new Uint8Array(length); + cryptoObject.getRandomValues(bytes); + for (let i = 0; i < length; i += 1) { + result += chars.charAt(bytes[i] % chars.length); + } + return result; + } + for (let i = 0; i < length; i += 1) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result; + } + + async function withTypeLock(type, task) { + const previous = state.locks.get(type) || Promise.resolve(); + let release; + const next = new Promise((resolve) => { + release = resolve; + }); + state.locks.set(type, previous.catch(() => undefined).then(() => next)); + await previous.catch(() => undefined); + try { + return await task(); + } finally { + release(); + } + } + + async function waitForTypeLock(type) { + const lock = state.locks.get(type); + if (lock) await lock.catch(() => undefined); + } + + function formatError(error) { + if (!error) return "unknown"; + return error.message || String(error); + } + + function log(message) { + if (state.config.debugLogging) { + console.log("[FrostyStreamProxy] " + message); + } + } +} +""" +} diff --git a/android/app/src/main/kotlin/com/tommychow/frosty/streamproxy/StreamProxyRequestRouter.kt b/android/app/src/main/kotlin/com/tommychow/frosty/streamproxy/StreamProxyRequestRouter.kt new file mode 100644 index 00000000..f31a0a80 --- /dev/null +++ b/android/app/src/main/kotlin/com/tommychow/frosty/streamproxy/StreamProxyRequestRouter.kt @@ -0,0 +1,270 @@ +package com.tommychow.frosty.streamproxy + +import java.net.URI +import java.util.Locale +import java.util.concurrent.ConcurrentHashMap + +class StreamProxyRequestRouter { + private val videoWeaverChannels = ConcurrentHashMap() + private val proxiedVideoWeaverUrls = ConcurrentHashMap.newKeySet() + private val usherChannelRegex = Regex("/hls/(.+)\\.m3u8", RegexOption.IGNORE_CASE) + private val videoWeaverUrlRegex = Regex( + "^https?://(?:[a-z0-9-]+\\.playlist\\.(?:live-video|ttvnw)\\.net|video-weaver\\.[a-z0-9-]+\\.hls\\.ttvnw\\.net)/v1/playlist/.+\\.m3u8(?:$|[?#])", + RegexOption.IGNORE_CASE, + ) + + fun route( + url: String, + method: String, + headers: Map, + config: StreamProxyConfig, + ): StreamProxyDecision { + if (!config.enabled) { + return StreamProxyDecision( + requestType = StreamProxyRequestType.UNKNOWN, + action = StreamProxyAction.DIRECT, + reason = "mode_off", + ) + } + + val uri = runCatching { URI(url) }.getOrNull() + ?: return StreamProxyDecision( + requestType = StreamProxyRequestType.UNKNOWN, + action = StreamProxyAction.SKIP, + reason = "invalid_url", + ) + val host = uri.host?.lowercase(Locale.US).orEmpty() + val requestType = requestTypeForHost(host) + val normalizedMethod = method.uppercase(Locale.US) + val flagged = hasTtvLolProAcceptFlag(headers) + + if (requestType == StreamProxyRequestType.UNKNOWN && !flagged) { + return StreamProxyDecision( + requestType = requestType, + action = StreamProxyAction.SKIP, + reason = "unknown", + ) + } + + if (requestType == StreamProxyRequestType.GRAPHQL) { + return StreamProxyDecision( + requestType = requestType, + action = StreamProxyAction.SKIP, + reason = "post_body_unavailable", + flagged = flagged, + ) + } + + if (normalizedMethod != "GET" && normalizedMethod != "HEAD") { + return StreamProxyDecision( + requestType = requestType, + action = StreamProxyAction.SKIP, + reason = "method_not_supported", + flagged = flagged, + ) + } + + if (config.proxyUrls.isEmpty()) { + return StreamProxyDecision( + requestType = requestType, + action = StreamProxyAction.SKIP, + reason = "no_proxy_urls", + flagged = flagged, + ) + } + + return when (requestType) { + StreamProxyRequestType.USHER -> routeUsher(url, config, flagged) + StreamProxyRequestType.VIDEO_WEAVER -> routeVideoWeaver(url, config, flagged) + StreamProxyRequestType.PASSPORT, + StreamProxyRequestType.TWITCH_WEBPAGE -> StreamProxyDecision( + requestType = requestType, + action = StreamProxyAction.SKIP, + reason = "optimized_unproxied", + flagged = flagged, + ) + StreamProxyRequestType.UNKNOWN -> StreamProxyDecision( + requestType = requestType, + action = StreamProxyAction.SKIP, + reason = "unknown", + flagged = flagged, + ) + StreamProxyRequestType.GRAPHQL -> StreamProxyDecision( + requestType = requestType, + action = StreamProxyAction.SKIP, + reason = "post_body_unavailable", + flagged = flagged, + ) + } + } + + fun rememberUsherManifest(channel: String?, manifestUrl: String, manifestBody: String) { + val normalizedChannel = channel?.lowercase(Locale.US) ?: return + val baseUri = runCatching { URI(manifestUrl) }.getOrNull() ?: return + + manifestBody + .lineSequence() + .map(String::trim) + .filter { it.isNotEmpty() && !it.startsWith("#") } + .mapNotNull { line -> + runCatching { baseUri.resolve(line).toString() }.getOrNull() + } + .filter { videoWeaverUrlRegex.containsMatchIn(it) } + .forEach { videoWeaverUrl -> + videoWeaverChannels[videoWeaverUrl] = normalizedChannel + } + } + + private fun routeUsher( + url: String, + config: StreamProxyConfig, + flagged: Boolean, + ): StreamProxyDecision { + val channel = extractUsherChannel(url) + + if (url.contains("/vod/", ignoreCase = true)) { + return StreamProxyDecision( + requestType = StreamProxyRequestType.USHER, + action = StreamProxyAction.SKIP, + channel = channel, + reason = "vod", + flagged = flagged, + ) + } + + if (isFrontpageUsherUrl(url)) { + return StreamProxyDecision( + requestType = StreamProxyRequestType.USHER, + action = StreamProxyAction.SKIP, + channel = channel, + reason = "frontpage", + flagged = flagged, + ) + } + + if (!flagged) { + return StreamProxyDecision( + requestType = StreamProxyRequestType.USHER, + action = StreamProxyAction.SKIP, + channel = channel, + reason = "optimized_unflagged", + flagged = flagged, + ) + } + + if (channel != null && config.whitelistedChannels.contains(channel)) { + return StreamProxyDecision( + requestType = StreamProxyRequestType.USHER, + action = StreamProxyAction.SKIP, + channel = channel, + reason = "whitelisted", + flagged = flagged, + ) + } + + return StreamProxyDecision( + requestType = StreamProxyRequestType.USHER, + action = StreamProxyAction.PROXY, + channel = channel, + flagged = flagged, + ) + } + + private fun routeVideoWeaver( + url: String, + config: StreamProxyConfig, + flagged: Boolean, + ): StreamProxyDecision { + val channel = videoWeaverChannels[url] ?: config.currentChannelLogin.takeIf(String::isNotEmpty) + + if (!isVideoWeaverPlaylistUrl(url)) { + return StreamProxyDecision( + requestType = StreamProxyRequestType.VIDEO_WEAVER, + action = StreamProxyAction.SKIP, + channel = channel, + reason = "not_playlist", + flagged = flagged, + ) + } + + if (!flagged) { + return StreamProxyDecision( + requestType = StreamProxyRequestType.VIDEO_WEAVER, + action = StreamProxyAction.SKIP, + channel = channel, + reason = "optimized_unflagged", + flagged = flagged, + ) + } + + if (channel != null && config.whitelistedChannels.contains(channel)) { + return StreamProxyDecision( + requestType = StreamProxyRequestType.VIDEO_WEAVER, + action = StreamProxyAction.SKIP, + channel = channel, + reason = "whitelisted", + flagged = flagged, + ) + } + + if (!proxiedVideoWeaverUrls.add(url)) { + return StreamProxyDecision( + requestType = StreamProxyRequestType.VIDEO_WEAVER, + action = StreamProxyAction.SKIP, + channel = channel, + reason = "optimized_already_proxied", + flagged = flagged, + ) + } + + return StreamProxyDecision( + requestType = StreamProxyRequestType.VIDEO_WEAVER, + action = StreamProxyAction.PROXY, + channel = channel, + flagged = flagged, + ) + } + + private fun requestTypeForHost(host: String): StreamProxyRequestType { + return when { + host == "passport.twitch.tv" -> StreamProxyRequestType.PASSPORT + host == "usher.ttvnw.net" -> StreamProxyRequestType.USHER + isVideoWeaverHost(host) -> StreamProxyRequestType.VIDEO_WEAVER + host == "gql.twitch.tv" -> StreamProxyRequestType.GRAPHQL + host == "www.twitch.tv" || host == "m.twitch.tv" -> StreamProxyRequestType.TWITCH_WEBPAGE + else -> StreamProxyRequestType.UNKNOWN + } + } + + private fun isVideoWeaverHost(host: String): Boolean { + return Regex( + "^(?:[a-z0-9-]+\\.playlist\\.(?:live-video|ttvnw)\\.net|video-weaver\\.[a-z0-9-]+\\.hls\\.ttvnw\\.net)$", + RegexOption.IGNORE_CASE, + ).matches(host) + } + + private fun isVideoWeaverPlaylistUrl(url: String): Boolean { + return videoWeaverUrlRegex.containsMatchIn(url) + } + + private fun extractUsherChannel(url: String): String? { + return usherChannelRegex + .find(url) + ?.groupValues + ?.getOrNull(1) + ?.lowercase(Locale.US) + } + + private fun isFrontpageUsherUrl(url: String): Boolean { + return url.contains("player_type=frontpage", ignoreCase = true) || + url.contains("%22player_type%22:%22frontpage%22", ignoreCase = true) || + url.contains("%22playerType%22:%22frontpage%22", ignoreCase = true) + } + + private fun hasTtvLolProAcceptFlag(headers: Map): Boolean { + return headers.any { (name, value) -> + name.equals("accept", ignoreCase = true) && + value.contains("TTV-LOL-PRO", ignoreCase = true) + } + } +} diff --git a/android/app/src/main/kotlin/com/tommychow/frosty/streamproxy/StreamProxyWebViewClient.kt b/android/app/src/main/kotlin/com/tommychow/frosty/streamproxy/StreamProxyWebViewClient.kt new file mode 100644 index 00000000..f33b49aa --- /dev/null +++ b/android/app/src/main/kotlin/com/tommychow/frosty/streamproxy/StreamProxyWebViewClient.kt @@ -0,0 +1,167 @@ +package com.tommychow.frosty.streamproxy + +import android.graphics.Bitmap +import android.util.Log +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.webkit.ScriptHandler +import androidx.webkit.WebViewCompat +import androidx.webkit.WebViewFeature + +class StreamProxyWebViewClient( + initialConfig: StreamProxyConfig, + private val router: StreamProxyRequestRouter, + private val onPageFinished: (String) -> Unit, +) : WebViewClient() { + @Volatile + private var config = initialConfig + + private val fetcher = StreamProxyFetcher(::log) + private var webView: WebView? = null + private var documentStartScript: ScriptHandler? = null + private var documentStartSupported = false + + fun attachToWebView(view: WebView) { + webView = view + installDocumentStartScript() + } + + fun updateConfig(newConfig: StreamProxyConfig) { + config = newConfig + installDocumentStartScript() + webView?.post { + webView?.evaluateJavascript( + StreamProxyPageScript.updateConfigScript(newConfig), + null, + ) + } + } + + fun detachFromWebView() { + removeDocumentStartScript() + webView = null + } + + override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { + super.onPageStarted(view, url, favicon) + if (!documentStartSupported && config.enabled) { + view?.evaluateJavascript(StreamProxyPageScript.create(config), null) + } + } + + override fun onPageFinished(view: WebView?, url: String?) { + super.onPageFinished(view, url) + if (url != null) { + onPageFinished(url) + } + } + + override fun shouldInterceptRequest( + view: WebView?, + request: WebResourceRequest?, + ): WebResourceResponse? { + if (request == null) return null + + val currentConfig = config + val decision = router.route( + url = request.url.toString(), + method = request.method, + headers = request.requestHeaders ?: emptyMap(), + config = currentConfig, + ) + + if (decision.action != StreamProxyAction.PROXY) { + logDecision(decision) + return null + } + + return try { + val response = fetcher.fetch( + request = request, + decision = decision, + config = currentConfig, + router = router, + ) + + if (response == null) { + log( + "matched type=${decision.requestType.logName} " + + "channel=${decision.channel ?: ""} action=fallback " + + "reason=proxy_failed", + ) + } + + response + } catch (error: Exception) { + log( + "matched type=${decision.requestType.logName} " + + "channel=${decision.channel ?: ""} action=fallback " + + "reason=${error.javaClass.simpleName}", + ) + null + } + } + + private fun logDecision(decision: StreamProxyDecision) { + log( + "matched type=${decision.requestType.logName} " + + "channel=${decision.channel ?: ""} " + + "action=${decision.action.logName} " + + "reason=${decision.reason ?: "none"}", + ) + } + + private fun log(message: String) { + if (config.debugLogging) { + Log.d(LOG_TAG, message) + } + } + + private fun installDocumentStartScript() { + val view = webView ?: return + removeDocumentStartScript() + documentStartSupported = WebViewFeature.isFeatureSupported( + WebViewFeature.DOCUMENT_START_SCRIPT, + ) + + if (!config.enabled) return + + if (!documentStartSupported) { + log("page_hook action=skip reason=document_start_unsupported") + return + } + + try { + documentStartScript = WebViewCompat.addDocumentStartJavaScript( + view, + StreamProxyPageScript.create(config), + setOf( + "https://player.twitch.tv", + "https://www.twitch.tv", + "https://m.twitch.tv", + "https://*.twitch.tv", + ), + ) + log("page_hook action=installed") + } catch (error: Exception) { + documentStartSupported = false + log("page_hook action=install_failed reason=${error.javaClass.simpleName}") + } + } + + private fun removeDocumentStartScript() { + try { + documentStartScript?.remove() + } catch (error: Exception) { + log("page_hook action=remove_failed reason=${error.javaClass.simpleName}") + } finally { + documentStartScript = null + } + } + + companion object { + private const val LOG_TAG = "FrostyStreamProxy" + } +} diff --git a/android/gradle.properties b/android/gradle.properties index 01a11bb7..102ab549 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,4 +1,6 @@ -org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError -android.useAndroidX=true -org.gradle.caching=true -org.gradle.parallel=true +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +org.gradle.caching=true +org.gradle.parallel=true +android.builtInKotlin=false +android.newDsl=false diff --git a/lib/screens/channel/chat/stores/chat_tabs_store.dart b/lib/screens/channel/chat/stores/chat_tabs_store.dart index 0fc54c46..a8538499 100644 --- a/lib/screens/channel/chat/stores/chat_tabs_store.dart +++ b/lib/screens/channel/chat/stores/chat_tabs_store.dart @@ -615,15 +615,13 @@ abstract class ChatTabsStoreBase with Store { /// Reorders a tab from oldIndex to newIndex. /// The primary tab (index 0) cannot be moved, and no tab can be placed before it. + /// The caller must pass an insertion index already adjusted for Flutter's + /// ReorderableListView remove-then-insert behavior. @action void reorderTab(int oldIndex, int newIndex) { // Primary tab (index 0) cannot be moved, and nothing can move before it if (oldIndex == 0 || newIndex == 0) return; - // ReorderableListView passes newIndex as if item was already removed - if (oldIndex < newIndex) { - newIndex -= 1; - } if (oldIndex == newIndex) return; final tab = _tabs.removeAt(oldIndex); diff --git a/lib/screens/channel/chat/widgets/chat_tabs.dart b/lib/screens/channel/chat/widgets/chat_tabs.dart index 500831b4..0acce13c 100644 --- a/lib/screens/channel/chat/widgets/chat_tabs.dart +++ b/lib/screens/channel/chat/widgets/chat_tabs.dart @@ -144,9 +144,16 @@ class ChatTabs extends StatelessWidget { left: 12, right: showMerge ? 32 : 12, ), + // ignore: deprecated_member_use onReorder: (oldIndex, newIndex) { HapticFeedback.lightImpact(); - chatTabsStore.reorderTab(oldIndex, newIndex); + final adjustedNewIndex = oldIndex < newIndex + ? newIndex - 1 + : newIndex; + chatTabsStore.reorderTab( + oldIndex, + adjustedNewIndex, + ); }, proxyDecorator: (child, index, animation) { return Material( diff --git a/lib/screens/channel/video/video.dart b/lib/screens/channel/video/video.dart index fa9cc703..1a1a5eb0 100644 --- a/lib/screens/channel/video/video.dart +++ b/lib/screens/channel/video/video.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; @@ -21,9 +22,7 @@ class _VideoState extends State