Skip to content

Commit f570b9b

Browse files
Sebastian Glödeclaude
andcommitted
Add WebRequestInterceptResult.Respond for custom URL scheme support
This adds the ability to respond with custom data from a RequestInterceptor, enabling implementation of custom URL schemes (e.g., app://, local://). Changes: - Add WebRequestInterceptResult.Respond class with data, mimeType, statusCode, headers - Add WKSchemeHandler (iOS) implementing WKURLSchemeHandlerProtocol in Kotlin/Native - Add PlatformWebViewParams.customSchemes parameter (iOS) to register custom schemes - Handle Respond result in all platforms (iOS, Android, Desktop) This addresses the feature request from issue #180 for shouldInterceptRequest-like functionality that allows returning custom responses. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 68e7690 commit f570b9b

6 files changed

Lines changed: 214 additions & 1 deletion

File tree

webview/src/androidMain/kotlin/com/multiplatform/webview/web/AccompanistWebView.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,12 @@ open class AccompanistWebViewClient : WebViewClient() {
430430
}
431431
true
432432
}
433+
434+
is WebRequestInterceptResult.Respond -> {
435+
// Respond is handled in shouldInterceptRequest, not here
436+
KLogger.w { "Respond interceptResult not supported in shouldOverrideUrlLoading" }
437+
true
438+
}
433439
}
434440
}
435441
}

webview/src/commonMain/kotlin/com/multiplatform/webview/request/WebRequestInterceptResult.kt

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,23 @@ sealed interface WebRequestInterceptResult {
1111
class Modify(
1212
val request: WebRequest,
1313
) : WebRequestInterceptResult
14+
15+
/**
16+
* Respond with custom data instead of making a network request.
17+
* This allows implementing custom URL schemes or serving local content.
18+
*
19+
* Note: This result type requires a custom URL scheme handler to be registered
20+
* on iOS (via WKURLSchemeHandler) or Android (via shouldInterceptRequest).
21+
*
22+
* @param data The response body as a byte array
23+
* @param mimeType The MIME type of the response (e.g., "text/html", "application/json")
24+
* @param statusCode The HTTP status code (default: 200)
25+
* @param headers Optional response headers
26+
*/
27+
class Respond(
28+
val data: ByteArray,
29+
val mimeType: String,
30+
val statusCode: Int = 200,
31+
val headers: Map<String, String> = emptyMap(),
32+
) : WebRequestInterceptResult
1433
}

webview/src/desktopMain/kotlin/com/multiplatform/webview/web/WebEngineExt.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,12 @@ internal fun KCEFBrowser.addRequestHandler(
202202
}
203203
true
204204
}
205+
206+
is WebRequestInterceptResult.Respond -> {
207+
// Respond is not supported on Desktop
208+
KLogger.w { "Respond interceptResult not supported on Desktop" }
209+
true
210+
}
205211
}
206212
}
207213
return super.onBeforeBrowse(browser, frame, request, userGesture, isRedirect)
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
package com.multiplatform.webview.request
2+
3+
import com.multiplatform.webview.util.KLogger
4+
import com.multiplatform.webview.web.WebViewNavigator
5+
import kotlinx.cinterop.BetaInteropApi
6+
import kotlinx.cinterop.ExperimentalForeignApi
7+
import kotlinx.cinterop.ObjCSignatureOverride
8+
import kotlinx.cinterop.addressOf
9+
import kotlinx.cinterop.usePinned
10+
import platform.Foundation.HTTPMethod
11+
import platform.Foundation.NSData
12+
import platform.Foundation.NSHTTPURLResponse
13+
import platform.Foundation.NSURL
14+
import platform.Foundation.allHTTPHeaderFields
15+
import platform.Foundation.create
16+
import platform.WebKit.WKURLSchemeHandlerProtocol
17+
import platform.WebKit.WKURLSchemeTaskProtocol
18+
import platform.WebKit.WKWebView
19+
import platform.darwin.NSObject
20+
21+
/**
22+
* WKURLSchemeHandler implementation for custom URL schemes.
23+
* This allows intercepting requests with custom schemes (e.g., "app://", "local://")
24+
* and providing custom responses.
25+
*/
26+
@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
27+
class WKSchemeHandler(
28+
private val navigator: WebViewNavigator,
29+
) : NSObject(), WKURLSchemeHandlerProtocol {
30+
31+
private val activeTasks = mutableMapOf<Int, Boolean>()
32+
33+
@ObjCSignatureOverride
34+
override fun webView(webView: WKWebView, startURLSchemeTask: WKURLSchemeTaskProtocol) {
35+
val taskId = startURLSchemeTask.hashCode()
36+
activeTasks[taskId] = true
37+
38+
val request = startURLSchemeTask.request
39+
val url = request.URL?.absoluteString ?: ""
40+
41+
KLogger.info { "WKSchemeHandler: Intercepting request for $url" }
42+
43+
// Build WebRequest
44+
val headerMap = mutableMapOf<String, String>()
45+
request.allHTTPHeaderFields?.forEach {
46+
headerMap[it.key.toString()] = it.value.toString()
47+
}
48+
49+
val webRequest = WebRequest(
50+
url = url,
51+
headers = headerMap,
52+
isForMainFrame = true,
53+
isRedirect = false,
54+
method = request.HTTPMethod ?: "GET",
55+
)
56+
57+
// Check if we have an interceptor
58+
val interceptor = navigator.requestInterceptor
59+
if (interceptor == null) {
60+
KLogger.w { "WKSchemeHandler: No request interceptor set, failing request" }
61+
failTask(startURLSchemeTask, "No request interceptor configured")
62+
activeTasks.remove(taskId)
63+
return
64+
}
65+
66+
// Call the interceptor
67+
val result = interceptor.onInterceptUrlRequest(webRequest, navigator)
68+
69+
// Check if task was cancelled
70+
if (activeTasks[taskId] != true) {
71+
KLogger.info { "WKSchemeHandler: Task was cancelled" }
72+
return
73+
}
74+
75+
when (result) {
76+
is WebRequestInterceptResult.Respond -> {
77+
respondWithData(startURLSchemeTask, result, url)
78+
}
79+
is WebRequestInterceptResult.Reject -> {
80+
failTask(startURLSchemeTask, "Request rejected by interceptor")
81+
}
82+
else -> {
83+
// For Allow and Modify, we can't actually make the request
84+
// because this is a custom scheme. Return an error.
85+
failTask(startURLSchemeTask, "Custom scheme requires Respond result")
86+
}
87+
}
88+
89+
activeTasks.remove(taskId)
90+
}
91+
92+
@ObjCSignatureOverride
93+
override fun webView(webView: WKWebView, stopURLSchemeTask: WKURLSchemeTaskProtocol) {
94+
val taskId = stopURLSchemeTask.hashCode()
95+
activeTasks[taskId] = false
96+
KLogger.info { "WKSchemeHandler: Task stopped" }
97+
}
98+
99+
private fun respondWithData(
100+
task: WKURLSchemeTaskProtocol,
101+
result: WebRequestInterceptResult.Respond,
102+
url: String,
103+
) {
104+
try {
105+
// Build response headers
106+
val headerFields = mutableMapOf<Any?, Any?>()
107+
headerFields["Content-Type"] = result.mimeType
108+
headerFields["Content-Length"] = result.data.size.toString()
109+
result.headers.forEach { (key, value) ->
110+
headerFields[key] = value
111+
}
112+
113+
// Create HTTP response
114+
val response = NSHTTPURLResponse(
115+
uRL = NSURL.URLWithString(url)!!,
116+
statusCode = result.statusCode.toLong(),
117+
HTTPVersion = "HTTP/1.1",
118+
headerFields = headerFields,
119+
)
120+
121+
// Send response
122+
task.didReceiveResponse(response!!)
123+
124+
// Send data
125+
if (result.data.isNotEmpty()) {
126+
result.data.usePinned { pinned ->
127+
val nsData = NSData.create(
128+
bytes = pinned.addressOf(0),
129+
length = result.data.size.toULong(),
130+
)
131+
task.didReceiveData(nsData)
132+
}
133+
}
134+
135+
// Finish
136+
task.didFinish()
137+
138+
KLogger.info { "WKSchemeHandler: Successfully responded with ${result.data.size} bytes" }
139+
} catch (e: Exception) {
140+
KLogger.e { "WKSchemeHandler: Error responding: ${e.message}" }
141+
failTask(task, e.message ?: "Unknown error")
142+
}
143+
}
144+
145+
private fun failTask(task: WKURLSchemeTaskProtocol, message: String) {
146+
try {
147+
val error = platform.Foundation.NSError.errorWithDomain(
148+
domain = "WKSchemeHandler",
149+
code = -1,
150+
userInfo = mapOf("NSLocalizedDescriptionKey" to message),
151+
)
152+
task.didFailWithError(error)
153+
} catch (e: Exception) {
154+
KLogger.e { "WKSchemeHandler: Error failing task: ${e.message}" }
155+
}
156+
}
157+
}

webview/src/iosMain/kotlin/com/multiplatform/webview/web/WKNavigationDelegate.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,13 @@ class WKNavigationDelegate(
173173
}
174174
decisionHandler(WKNavigationActionPolicy.WKNavigationActionPolicyCancel)
175175
}
176+
177+
is WebRequestInterceptResult.Respond -> {
178+
// Respond is handled by WKSchemeHandler for custom schemes.
179+
// For navigation requests, treat as reject since we can't provide custom response here.
180+
KLogger.w { "Respond interceptResult not supported in navigation delegate, use custom scheme" }
181+
decisionHandler(WKNavigationActionPolicy.WKNavigationActionPolicyCancel)
182+
}
176183
}
177184
}
178185
} else {

webview/src/iosMain/kotlin/com/multiplatform/webview/web/WebView.ios.kt

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import androidx.compose.ui.viewinterop.UIKitInteropProperties
1010
import androidx.compose.ui.viewinterop.UIKitView
1111
import com.multiplatform.webview.jsbridge.ConsoleBridge
1212
import com.multiplatform.webview.jsbridge.WebViewJsBridge
13+
import com.multiplatform.webview.request.WKSchemeHandler
1314
import com.multiplatform.webview.util.toUIColor
1415
import kotlinx.cinterop.ExperimentalForeignApi
1516
import kotlinx.cinterop.cValue
@@ -48,6 +49,7 @@ actual fun ActualWebView(
4849
webViewJsBridge = webViewJsBridge,
4950
onCreated = onCreated,
5051
onDispose = onDispose,
52+
platformWebViewParams = platformWebViewParams,
5153
factory = factory,
5254
)
5355
}
@@ -57,7 +59,16 @@ actual data class WebViewFactoryParam(
5759
val config: WKWebViewConfiguration,
5860
)
5961

60-
actual class PlatformWebViewParams
62+
/**
63+
* iOS-specific WebView parameters.
64+
*
65+
* @param customSchemes List of custom URL schemes to register (e.g., "app", "local").
66+
* Requests to these schemes will be handled by the RequestInterceptor,
67+
* which should return WebRequestInterceptResult.Respond with the response data.
68+
*/
69+
actual class PlatformWebViewParams(
70+
val customSchemes: List<String> = emptyList(),
71+
)
6172

6273
/** Default WebView factory for iOS. */
6374
@OptIn(ExperimentalForeignApi::class)
@@ -80,6 +91,7 @@ fun IOSWebView(
8091
webViewJsBridge: WebViewJsBridge?,
8192
onCreated: (NativeWebView) -> Unit,
8293
onDispose: (NativeWebView) -> Unit,
94+
platformWebViewParams: PlatformWebViewParams?,
8395
factory: (WebViewFactoryParam) -> NativeWebView,
8496
) {
8597
val observer =
@@ -90,6 +102,7 @@ fun IOSWebView(
90102
)
91103
}
92104
val navigationDelegate = remember { WKNavigationDelegate(state, navigator) }
105+
val schemeHandler = remember { WKSchemeHandler(navigator) }
93106
val scope = rememberCoroutineScope()
94107

95108
UIKitView(
@@ -116,6 +129,11 @@ fun IOSWebView(
116129
value = state.webSettings.allowUniversalAccessFromFileURLs,
117130
forKey = "allowUniversalAccessFromFileURLs",
118131
)
132+
133+
// Register custom URL scheme handlers
134+
platformWebViewParams?.customSchemes?.forEach { scheme ->
135+
setURLSchemeHandler(schemeHandler, forURLScheme = scheme)
136+
}
119137
}
120138
factory(WebViewFactoryParam(config))
121139
.apply {

0 commit comments

Comments
 (0)