Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +434 to +437
Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment states "Respond is handled in shouldInterceptRequest, not here", but the shouldInterceptRequest method (lines 323-332) doesn't actually implement handling for WebRequestInterceptResult.Respond. It only delegates to assetLoader and doesn't check the request interceptor or handle custom responses.

The shouldInterceptRequest method needs to be updated to:

  1. Call navigator.requestInterceptor?.onInterceptUrlRequest()
  2. When the result is WebRequestInterceptResult.Respond, create and return a WebResourceResponse with the response data, mimeType, statusCode, and headers
  3. This is critical for Android support of custom URL schemes as mentioned in issue Add shouldInterceptRequest method #180

Without this implementation, the Respond functionality will not work on Android.

Copilot uses AI. Check for mistakes.
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String> = emptyMap(),
) : WebRequestInterceptResult
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Int, Boolean>()
Comment thread
sebastiangl marked this conversation as resolved.

@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<String, String>()
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<Any?, Any?>()
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}" }
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -48,6 +49,7 @@ actual fun ActualWebView(
webViewJsBridge = webViewJsBridge,
onCreated = onCreated,
onDispose = onDispose,
platformWebViewParams = platformWebViewParams,
factory = factory,
)
}
Expand All @@ -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<String> = emptyList(),
)

/** Default WebView factory for iOS. */
@OptIn(ExperimentalForeignApi::class)
Expand All @@ -80,6 +101,7 @@ fun IOSWebView(
webViewJsBridge: WebViewJsBridge?,
onCreated: (NativeWebView) -> Unit,
onDispose: (NativeWebView) -> Unit,
platformWebViewParams: PlatformWebViewParams?,
factory: (WebViewFactoryParam) -> NativeWebView,
) {
val observer =
Expand All @@ -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(
Expand All @@ -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 {
Expand Down