diff --git a/webview/src/androidMain/kotlin/com/multiplatform/webview/web/AccompanistWebView.kt b/webview/src/androidMain/kotlin/com/multiplatform/webview/web/AccompanistWebView.kt index 2ac8cc58..ed7f2c88 100644 --- a/webview/src/androidMain/kotlin/com/multiplatform/webview/web/AccompanistWebView.kt +++ b/webview/src/androidMain/kotlin/com/multiplatform/webview/web/AccompanistWebView.kt @@ -430,6 +430,12 @@ open class AccompanistWebViewClient : WebViewClient() { } true } + + is WebRequestInterceptResult.Respond -> { + // Respond is handled in shouldInterceptRequest, not here + KLogger.w { "Respond interceptResult not supported in shouldOverrideUrlLoading" } + true + } } } } diff --git a/webview/src/commonMain/kotlin/com/multiplatform/webview/request/WebRequestInterceptResult.kt b/webview/src/commonMain/kotlin/com/multiplatform/webview/request/WebRequestInterceptResult.kt index 9cf87b06..8e924b23 100644 --- a/webview/src/commonMain/kotlin/com/multiplatform/webview/request/WebRequestInterceptResult.kt +++ b/webview/src/commonMain/kotlin/com/multiplatform/webview/request/WebRequestInterceptResult.kt @@ -11,4 +11,29 @@ sealed interface WebRequestInterceptResult { class Modify( val request: WebRequest, ) : WebRequestInterceptResult + + /** + * Respond with custom data instead of making a network request. + * This allows implementing custom URL schemes or serving local content. + * + * Platform support: + * - **iOS**: Supported via custom URL scheme handler registered with [WKURLSchemeHandler]. + * Use [PlatformWebViewParams.customSchemes] to register your custom schemes. + * - **Android**: Not currently implemented. The Respond result will be logged as a warning + * and the request will be rejected. Future implementation would use shouldInterceptRequest. + * - **Desktop**: Not supported. The Respond result will be logged as a warning + * and the request will be rejected. + * + * @param data The response body as a byte array + * @param mimeType The MIME type of the response (e.g., "text/html", "application/json"). + * This takes precedence over any "Content-Type" header in [headers]. + * @param statusCode The HTTP status code (default: 200) + * @param headers Optional response headers. Note: "Content-Type" will be overridden by [mimeType]. + */ + class Respond( + val data: ByteArray, + val mimeType: String, + val statusCode: Int = 200, + val headers: Map = emptyMap(), + ) : WebRequestInterceptResult } diff --git a/webview/src/desktopMain/kotlin/com/multiplatform/webview/web/WebEngineExt.kt b/webview/src/desktopMain/kotlin/com/multiplatform/webview/web/WebEngineExt.kt index 2b842761..3bfa06a2 100644 --- a/webview/src/desktopMain/kotlin/com/multiplatform/webview/web/WebEngineExt.kt +++ b/webview/src/desktopMain/kotlin/com/multiplatform/webview/web/WebEngineExt.kt @@ -202,6 +202,12 @@ internal fun KCEFBrowser.addRequestHandler( } true } + + is WebRequestInterceptResult.Respond -> { + // Respond is not supported on Desktop + KLogger.w { "Respond interceptResult not supported on Desktop" } + true + } } } return super.onBeforeBrowse(browser, frame, request, userGesture, isRedirect) diff --git a/webview/src/iosMain/kotlin/com/multiplatform/webview/request/WKSchemeHandler.kt b/webview/src/iosMain/kotlin/com/multiplatform/webview/request/WKSchemeHandler.kt new file mode 100644 index 00000000..8ab98ec1 --- /dev/null +++ b/webview/src/iosMain/kotlin/com/multiplatform/webview/request/WKSchemeHandler.kt @@ -0,0 +1,203 @@ +package com.multiplatform.webview.request + +import com.multiplatform.webview.util.KLogger +import com.multiplatform.webview.web.WebViewNavigator +import kotlinx.cinterop.BetaInteropApi +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.ObjCSignatureOverride +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.usePinned +import platform.Foundation.HTTPMethod +import platform.Foundation.NSData +import platform.Foundation.NSHTTPURLResponse +import platform.Foundation.NSURL +import platform.Foundation.allHTTPHeaderFields +import platform.Foundation.create +import platform.WebKit.WKURLSchemeHandlerProtocol +import platform.WebKit.WKURLSchemeTaskProtocol +import platform.WebKit.WKWebView +import platform.darwin.NSObject + +/** + * WKURLSchemeHandler implementation for custom URL schemes. + * This allows intercepting requests with custom schemes (e.g., "app://", "local://") + * and providing custom responses. + * + * Note: WKURLSchemeHandler methods are called on the main thread by WebKit, + * so the activeTasks map access is thread-safe in this context. + */ +@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) +class WKSchemeHandler( + private val navigator: WebViewNavigator, +) : NSObject(), + WKURLSchemeHandlerProtocol { + private val activeTasks = mutableMapOf() + + @ObjCSignatureOverride + override fun webView( + webView: WKWebView, + startURLSchemeTask: WKURLSchemeTaskProtocol, + ) { + val taskId = startURLSchemeTask.hashCode() + activeTasks[taskId] = true + + val request = startURLSchemeTask.request + val url = request.URL?.absoluteString ?: "" + + KLogger.info { "WKSchemeHandler: Intercepting request for $url" } + + // Build WebRequest + val headerMap = mutableMapOf() + request.allHTTPHeaderFields?.forEach { + headerMap[it.key.toString()] = it.value.toString() + } + + // WKURLSchemeTaskProtocol does not expose frame info directly. + // Assume main frame for custom scheme requests as a reasonable default. + val isForMainFrame = true + + val webRequest = + WebRequest( + url = url, + headers = headerMap, + isForMainFrame = isForMainFrame, + isRedirect = false, + method = request.HTTPMethod ?: "GET", + ) + + // Check if we have an interceptor + val interceptor = navigator.requestInterceptor + if (interceptor == null) { + KLogger.w { "WKSchemeHandler: No request interceptor set, failing request" } + failTask(startURLSchemeTask, "No request interceptor configured") + activeTasks.remove(taskId) + return + } + + try { + // Call the interceptor + val result = interceptor.onInterceptUrlRequest(webRequest, navigator) + + // Check if task was cancelled + if (activeTasks[taskId] != true) { + KLogger.info { "WKSchemeHandler: Task was cancelled" } + failTask(startURLSchemeTask, "Task was cancelled") + activeTasks.remove(taskId) + return + } + + when (result) { + is WebRequestInterceptResult.Respond -> { + respondWithData(startURLSchemeTask, result, url) + } + is WebRequestInterceptResult.Reject -> { + failTask(startURLSchemeTask, "Request rejected by interceptor") + } + else -> { + // For Allow and Modify, we can't actually make the request + // because this is a custom scheme. Return an error. + failTask(startURLSchemeTask, "Custom scheme requires Respond result") + } + } + } catch (e: Exception) { + KLogger.e { "WKSchemeHandler: Exception in request interceptor: ${e.message}" } + failTask(startURLSchemeTask, "Request interceptor threw an exception: ${e.message}") + } finally { + activeTasks.remove(taskId) + } + } + + @ObjCSignatureOverride + override fun webView( + webView: WKWebView, + stopURLSchemeTask: WKURLSchemeTaskProtocol, + ) { + val taskId = stopURLSchemeTask.hashCode() + activeTasks[taskId] = false + KLogger.info { "WKSchemeHandler: Task stopped" } + } + + private fun respondWithData( + task: WKURLSchemeTaskProtocol, + result: WebRequestInterceptResult.Respond, + url: String, + ) { + try { + // Validate URL + val nsUrl = NSURL.URLWithString(url) + if (nsUrl == null) { + val message = "WKSchemeHandler: Invalid URL: $url" + KLogger.e { message } + failTask(task, message) + return + } + + // Build response headers + // Add custom headers first, then set Content-Type from mimeType to ensure + // mimeType takes precedence over any Content-Type in headers + val headerFields = mutableMapOf() + result.headers.forEach { (key, value) -> + // Skip Content-Type from headers - we use result.mimeType instead + if (!key.equals("Content-Type", ignoreCase = true)) { + headerFields[key] = value + } + } + headerFields["Content-Type"] = result.mimeType + headerFields["Content-Length"] = result.data.size.toString() + + // Create HTTP response + val response = + NSHTTPURLResponse( + uRL = nsUrl, + statusCode = result.statusCode.toLong(), + HTTPVersion = "HTTP/1.1", + headerFields = headerFields, + ) + + if (response == null) { + failTask(task, "Failed to create HTTP response") + return + } + + // Send response + task.didReceiveResponse(response) + + // Send data + if (result.data.isNotEmpty()) { + result.data.usePinned { pinned -> + val nsData = + NSData.create( + bytes = pinned.addressOf(0), + length = result.data.size.toULong(), + ) + task.didReceiveData(nsData) + } + } + + // Finish + task.didFinish() + + KLogger.info { "WKSchemeHandler: Successfully responded with ${result.data.size} bytes" } + } catch (e: Exception) { + KLogger.e { "WKSchemeHandler: Error responding: ${e.message}" } + failTask(task, e.message ?: "Unknown error") + } + } + + private fun failTask( + task: WKURLSchemeTaskProtocol, + message: String, + ) { + try { + val error = + platform.Foundation.NSError.errorWithDomain( + domain = "WKSchemeHandler", + code = -1, + userInfo = mapOf("NSLocalizedDescriptionKey" to message), + ) + task.didFailWithError(error) + } catch (e: Exception) { + KLogger.e { "WKSchemeHandler: Error failing task: ${e.message}" } + } + } +} diff --git a/webview/src/iosMain/kotlin/com/multiplatform/webview/web/WKNavigationDelegate.kt b/webview/src/iosMain/kotlin/com/multiplatform/webview/web/WKNavigationDelegate.kt index 78b2e6bb..9520270a 100644 --- a/webview/src/iosMain/kotlin/com/multiplatform/webview/web/WKNavigationDelegate.kt +++ b/webview/src/iosMain/kotlin/com/multiplatform/webview/web/WKNavigationDelegate.kt @@ -173,6 +173,13 @@ class WKNavigationDelegate( } decisionHandler(WKNavigationActionPolicy.WKNavigationActionPolicyCancel) } + + is WebRequestInterceptResult.Respond -> { + // Respond is handled by WKSchemeHandler for custom schemes. + // For navigation requests, treat as reject since we can't provide custom response here. + KLogger.w { "Respond interceptResult not supported in navigation delegate, use custom scheme" } + decisionHandler(WKNavigationActionPolicy.WKNavigationActionPolicyCancel) + } } } } else { diff --git a/webview/src/iosMain/kotlin/com/multiplatform/webview/web/WebView.ios.kt b/webview/src/iosMain/kotlin/com/multiplatform/webview/web/WebView.ios.kt index 6d9e0a7a..7118f23a 100644 --- a/webview/src/iosMain/kotlin/com/multiplatform/webview/web/WebView.ios.kt +++ b/webview/src/iosMain/kotlin/com/multiplatform/webview/web/WebView.ios.kt @@ -10,6 +10,7 @@ import androidx.compose.ui.viewinterop.UIKitInteropProperties import androidx.compose.ui.viewinterop.UIKitView import com.multiplatform.webview.jsbridge.ConsoleBridge import com.multiplatform.webview.jsbridge.WebViewJsBridge +import com.multiplatform.webview.request.WKSchemeHandler import com.multiplatform.webview.util.toUIColor import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.cValue @@ -48,6 +49,7 @@ actual fun ActualWebView( webViewJsBridge = webViewJsBridge, onCreated = onCreated, onDispose = onDispose, + platformWebViewParams = platformWebViewParams, factory = factory, ) } @@ -57,7 +59,26 @@ actual data class WebViewFactoryParam( val config: WKWebViewConfiguration, ) -actual class PlatformWebViewParams +/** + * iOS-specific WebView parameters. + * + * @param customSchemes List of custom URL schemes to register at WebView creation time + * (for example, "app", "local"). These schemes are added to the + * underlying [WKWebViewConfiguration] when the WebView is created + * and cannot be added to or removed from an existing WebView instance. + * + * Requests to these schemes will be handled by the RequestInterceptor, + * which should return [WebRequestInterceptResult.Respond] with the + * response data. + * + * Note: WKWebView does not allow certain built-in schemes such as + * "http", "https", "file", "ftp", "about", "data", or "javascript" + * to be used as custom schemes. These reserved schemes will be + * automatically filtered out and not registered. + */ +actual class PlatformWebViewParams( + val customSchemes: List = emptyList(), +) /** Default WebView factory for iOS. */ @OptIn(ExperimentalForeignApi::class) @@ -80,6 +101,7 @@ fun IOSWebView( webViewJsBridge: WebViewJsBridge?, onCreated: (NativeWebView) -> Unit, onDispose: (NativeWebView) -> Unit, + platformWebViewParams: PlatformWebViewParams?, factory: (WebViewFactoryParam) -> NativeWebView, ) { val observer = @@ -90,6 +112,8 @@ fun IOSWebView( ) } val navigationDelegate = remember { WKNavigationDelegate(state, navigator) } + // Recreate scheme handler if navigator changes to avoid stale state + val schemeHandler = remember(navigator) { WKSchemeHandler(navigator) } val scope = rememberCoroutineScope() UIKitView( @@ -116,6 +140,31 @@ fun IOSWebView( value = state.webSettings.allowUniversalAccessFromFileURLs, forKey = "allowUniversalAccessFromFileURLs", ) + + // Register custom URL scheme handlers + // Filter out reserved schemes that WKWebView doesn't allow + val reservedSchemes = + setOf( + "http", + "https", + "file", + "ftp", + "about", + "data", + "javascript", + ) + platformWebViewParams + ?.customSchemes + ?.filter { scheme -> + val normalized = scheme.lowercase() + val isReserved = normalized in reservedSchemes + if (isReserved) { + println("WKWebView: Skipping registration of reserved URL scheme: $scheme") + } + !isReserved + }?.forEach { scheme -> + setURLSchemeHandler(schemeHandler, forURLScheme = scheme) + } } factory(WebViewFactoryParam(config)) .apply {