diff --git a/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuiteCatalog.kt b/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuiteCatalog.kt index 4180059..06c8fc7 100644 --- a/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuiteCatalog.kt +++ b/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuiteCatalog.kt @@ -51,6 +51,12 @@ internal fun suiteCatalog(): List = SuiteCase("B09", "JS Bridge", "rapid IPC burst (×12) drains without drop"), SuiteCase("B10", "JS Bridge", "bridge callable from an inline script (document start)"), SuiteCase("B11", "JS Bridge", "bridge survives loads that keep the same URL (×3)"), + SuiteCase("B12", "JS Bridge", "message sender: origin of the main frame"), + SuiteCase("B13", "JS Bridge", "message sender: embedded frame is not the main frame"), + SuiteCase("B14", "JS Bridge", "message sender: only the main frame reaches the bridge"), + SuiteCase("B15", "JS Bridge", "message sender: a frame cannot pass for the main frame"), + SuiteCase("B16", "JS Bridge", "bridge call still arrives after document.open()"), + SuiteCase("B17", "JS Bridge", "message sender: a call the init script makes at document start"), // Cookies (Wry: set/get/clear_for_url/clear_all + attributes) SuiteCase("K01", "Cookies", "setCookie + getCookies finds cookie"), SuiteCase("K02", "Cookies", "removeCookies drops cookie"), diff --git a/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuitePages.kt b/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuitePages.kt index 30909e6..79d71ab 100644 --- a/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuitePages.kt +++ b/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuitePages.kt @@ -41,6 +41,96 @@ internal fun pageWithMarker(marker: String, title: String = "SUITE"): String = """.trimIndent() +/** + * The suite view's init script. It runs ahead of everything else in each document and calls + * the bridge at once, which makes its call the earliest message a page can send (case B17). + */ +internal const val SUITE_INIT_SCRIPT = + "window.kmpJsBridge.callNative('suiteEarly', JSON.stringify({from: location.href}));" + +/** + * A page embedding a sandboxed frame that calls the bridge on its own. The frame's + * origin is opaque, so the engine must report it as `"null"` and not as the embedder's. + */ +internal fun pageWithBridgeCallingFrame(tag: String): String { + val frame = + "" + return """ + FrameSender +
$tag
+ + + """.trimIndent() +} + +/** + * A page embedding a sandboxed frame that calls the bridge and then tells the page it did; + * the page answers with a bridge call of its own. So the page's call arriving means the + * frame's was made before it. + */ +internal fun pageAnsweringBridgeCallingFrame(tag: String): String { + val frame = + "" + return """ + AnsweredFrame +
$tag
+ + + + """.trimIndent() +} + +/** + * A page embedding a sandboxed frame that tries every way it has to send a bridge message + * as the embedding page before sending one of its own: the handler and the event of the + * Linux backend's relay, the embedder's own `ipc`, and a message that names its sender. + */ +internal fun pageWithSenderForgingFrame(tag: String): String { + val frame = + """ + + """.trimIndent() + return """ + ForgingFrame +
$tag
+ + + """.trimIndent() +} + /** * Calls the JS bridge from an inline script, i.e. while the document is still * parsing, and records in `window.__earlyBridge` whether the bridge was there. diff --git a/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuitePlatform.kt b/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuitePlatform.kt index f1b9726..cdca65d 100644 --- a/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuitePlatform.kt +++ b/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuitePlatform.kt @@ -43,6 +43,18 @@ enum class SuiteCapability { * Android / iOS / WasmJs still inject it after load. */ DocumentStartJsBridge, + + /** + * Bridge messages carry the sender (origin, main frame or not) as the engine + * reports it. macOS and Linux desktop. + */ + JsMessageSender, + + /** + * The engine hands the bridge the messages of the top-level document only, so + * every message is the main frame's; its origin is not reported. Windows desktop. + */ + MainFrameOnlyJsBridge, } expect fun suiteCapabilities(): Set diff --git a/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuiteRunner.kt b/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuiteRunner.kt index 69ccbcd..92f1a98 100644 --- a/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuiteRunner.kt +++ b/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuiteRunner.kt @@ -4,6 +4,7 @@ import composewebview.e2e_shared.generated.resources.Res import dev.nucleusframework.webview.cookie.Cookie import dev.nucleusframework.webview.jsbridge.IJsMessageHandler import dev.nucleusframework.webview.jsbridge.JsMessage +import dev.nucleusframework.webview.jsbridge.JsMessageSender import dev.nucleusframework.webview.web.LoadingState import dev.nucleusframework.webview.web.WebContent import dev.nucleusframework.webview.web.WebViewFileReadType @@ -382,6 +383,93 @@ internal suspend fun runFullSuite( } } } + case("B12", required = setOf(SuiteCapability.JsMessageSender)) { + val senders = collectSenders(ctx) { + loadHtmlAwaitMarker(ctx.navigator, "sender-b12") + evalJs( + ctx.navigator, + "window.kmpJsBridge.callNative('suiteSender', JSON.stringify({from:'main'}), function(d){}); 's'", + ) + } + val sender = senders["main"] + assertThat( + sender == JsMessageSender(origin = "https://suite.local", isMainFrame = true), + "sender=$sender", + ) + } + case("B13", required = setOf(SuiteCapability.JsMessageSender)) { + val senders = collectSenders(ctx, expect = "frame") { + loadHtmlAwaitMarker(ctx.navigator, "sender-b13", pageWithBridgeCallingFrame("sender-b13")) + } + val sender = senders["frame"] + assertThat( + sender == JsMessageSender(origin = "null", isMainFrame = false), + "sender=$sender", + ) + } + case("B14", required = setOf(SuiteCapability.MainFrameOnlyJsBridge)) { + val senders = collectSenders(ctx, expect = "main-again") { received -> + loadHtmlAwaitMarker(ctx.navigator, "sender-b14", pageAnsweringBridgeCallingFrame("sender-b14")) + // The page answers the frame's call, and one more round trip follows it: a + // message of the frame's that the engine did deliver would be here by then. + awaitUntil(12_000, "suiteSender from main") { received.containsKey("main") } + evalJs( + ctx.navigator, + "window.kmpJsBridge.callNative('suiteSender', JSON.stringify({from:'main-again'}), function(d){}); 's'", + ) + } + val main = JsMessageSender(origin = null, isMainFrame = true) + assertThat( + senders == mapOf("main" to main, "main-again" to main), + "senders=$senders", + ) + } + case("B15", required = setOf(SuiteCapability.JsMessageSender)) { + val senders = collectSenders(ctx, expect = "frame") { + loadHtmlAwaitMarker(ctx.navigator, "sender-b15", pageWithSenderForgingFrame("sender-b15")) + } + // Only what the frame sent through its own bridge arrives, and as the frame's. + val frame = JsMessageSender(origin = "null", isMainFrame = false) + assertThat( + senders == mapOf("forged-fields" to frame, "frame" to frame), + "senders=$senders", + ) + } + case("B16", required = setOf(SuiteCapability.DocumentStartJsBridge)) { + // document.open() drops every listener on the window but keeps its script objects, + // the bridge among them: a call made before the rewritten document exists and one + // made after it must both arrive. + loadHtmlAwaitMarker(ctx.navigator, "reopen-b16") + ctx.clearBridgeHits() + evalJs( + ctx.navigator, + """ + (function(){ + document.open(); + window.kmpJsBridge.callNative('suitePing', JSON.stringify({reopen:'b16-opened'}), function(d){}); + document.write('
reopened-b16
'); + document.close(); + window.kmpJsBridge.callNative('suitePing', JSON.stringify({reopen:'b16-written'}), function(d){}); + return 'reopened'; + })() + """.trimIndent(), + ) + awaitUntil(12_000, "pings around document.open()") { + ctx.bridgeHits.any { it.contains("b16-opened") } && ctx.bridgeHits.any { it.contains("b16-written") } + } + } + case("B17", required = setOf(SuiteCapability.JsMessageSender)) { + // SUITE_INIT_SCRIPT calls the bridge before anything else in the document has run. + val page = "https://suite.local/sender-b17" + val senders = collectSenders(ctx, expect = page, method = "suiteEarly") { + loadHtmlAwaitMarker(ctx.navigator, "sender-b17") + } + val sender = senders[page] + assertThat( + sender == JsMessageSender(origin = "https://suite.local", isMainFrame = true), + "sender=$sender", + ) + } // ── Cookies ────────────────────────────────────────────────────── case("K01", required = setOf(SuiteCapability.CookieDomainApi)) { @@ -751,3 +839,38 @@ internal suspend fun runFullSuite( * (CI runners render in software). */ private const val MIN_ANIMATING_FPS = 10 + +/** + * Runs [send] with a handler registered for [method] and returns the sender of each + * message it received, keyed by the message's `from` param, once [expect] has arrived. + * [send] is given the senders as they arrive. + */ +private suspend fun collectSenders( + ctx: SuiteContext, + expect: String = "main", + method: String = "suiteSender", + send: suspend (received: Map) -> Unit, +): Map { + val senders = mutableMapOf() + val handler = + object : IJsMessageHandler { + override fun methodName() = method + + override fun handle( + message: JsMessage, + navigator: WebViewNavigator?, + callback: (String) -> Unit, + ) { + val from = Regex("\"from\":\"([^\"]*)\"").find(message.params)?.groupValues?.get(1) ?: return + senders[from] = message.sender + } + } + ctx.jsBridge.register(handler) + try { + send(senders) + awaitUntil(12_000, "$method from $expect") { senders.containsKey(expect) } + } finally { + ctx.jsBridge.unregister(handler) + } + return senders +} diff --git a/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/VisualSuiteApp.kt b/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/VisualSuiteApp.kt index 056925a..e92ccfc 100644 --- a/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/VisualSuiteApp.kt +++ b/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/VisualSuiteApp.kt @@ -108,6 +108,7 @@ fun VisualSuiteApp( baseUrl = "https://suite.local/boot", ).also { it.webSettings.desktopWebSettings.transparent = false + it.webSettings.desktopWebSettings.initScript = SUITE_INIT_SCRIPT it.webSettings.backgroundColor = Color.White it.webSettings.isJavaScriptEnabled = true } diff --git a/e2e-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuitePlatform.jvm.kt b/e2e-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuitePlatform.jvm.kt index 536d727..abc378d 100644 --- a/e2e-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuitePlatform.jvm.kt +++ b/e2e-shared/src/jvmMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuitePlatform.jvm.kt @@ -14,6 +14,14 @@ import java.util.Locale import kotlinx.coroutines.delay actual fun suiteCapabilities(): Set = + desktopCapabilities + + if (System.getProperty("os.name", "").lowercase(Locale.ENGLISH).contains("win")) { + setOf(SuiteCapability.MainFrameOnlyJsBridge) + } else { + setOf(SuiteCapability.JsMessageSender) + } + +private val desktopCapabilities = setOf( SuiteCapability.HistoryNavigation, SuiteCapability.DataUrlNavigation, diff --git a/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/jsbridge/JsMessage.kt b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/jsbridge/JsMessage.kt index f6e4265..06f2a56 100644 --- a/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/jsbridge/JsMessage.kt +++ b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/jsbridge/JsMessage.kt @@ -1,16 +1,21 @@ package dev.nucleusframework.webview.jsbridge import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient /** * Message dispatched from JS to native. * * `params` is expected to be a JSON string (API compatibility with compose-webview-multiplatform). + * + * @property sender Who sent the message, when the engine reports it; null on the platforms + * that do not. It is set by the native side only and is never read from the page's JSON. */ @Serializable data class JsMessage( val callbackId: Int, val methodName: String, - val params: String + val params: String, + @Transient val sender: JsMessageSender? = null, ) diff --git a/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/jsbridge/JsMessageParsing.kt b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/jsbridge/JsMessageParsing.kt index 09ea924..1ca9dce 100644 --- a/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/jsbridge/JsMessageParsing.kt +++ b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/jsbridge/JsMessageParsing.kt @@ -19,6 +19,7 @@ private data class ParsedJsBridgeMessage( internal fun parseJsMessage( raw: String, expectedType: String? = null, + sender: JsMessageSender? = null, ): JsMessage? = runCatching { val message = jsBridgeJson.decodeFromString(raw) if (expectedType != null && message.type != expectedType) return null @@ -31,7 +32,8 @@ internal fun parseJsMessage( JsMessage( callbackId = callbackId, methodName = methodName, - params = params + params = params, + sender = sender, ) }.getOrNull() diff --git a/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/jsbridge/JsMessageSender.kt b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/jsbridge/JsMessageSender.kt new file mode 100644 index 0000000..d3fcbe6 --- /dev/null +++ b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/jsbridge/JsMessageSender.kt @@ -0,0 +1,26 @@ +package dev.nucleusframework.webview.jsbridge + +/** + * Who sent a [JsMessage], as reported by the web engine itself and never by the page. + * + * A handler that acts on a message's authority (showing a permission prompt, returning + * account data) should check it: the bridge object is installed in every frame, so an + * embedded third-party frame can call the same handlers as the page that embeds it. + * + * What the desktop engines report: + * - macOS (WKWebView) and Linux (WebKitGTK): both properties, for every frame. On Linux a + * message has no sender at all when the page posts it to the WebKit message handler + * itself instead of through the bridge, or between a `document.open()` and the document + * it then writes. + * - Windows (WebView2): only the top-level document's messages reach the bridge, so + * [isMainFrame] is always true there, and [origin] is not reported. + * + * @property origin The sending document's origin, serialized as `scheme://host[:port]`, + * or `"null"` for an opaque origin (sandboxed frames, `data:` documents). Null when the + * engine does not report an origin. + * @property isMainFrame Whether the message came from the top-level document. + */ +data class JsMessageSender( + val origin: String?, + val isMainFrame: Boolean, +) diff --git a/webview-compose/src/commonTest/kotlin/dev/nucleusframework/webview/jsbridge/JsMessageParsingTest.kt b/webview-compose/src/commonTest/kotlin/dev/nucleusframework/webview/jsbridge/JsMessageParsingTest.kt index 1b106bb..5fdaaba 100644 --- a/webview-compose/src/commonTest/kotlin/dev/nucleusframework/webview/jsbridge/JsMessageParsingTest.kt +++ b/webview-compose/src/commonTest/kotlin/dev/nucleusframework/webview/jsbridge/JsMessageParsingTest.kt @@ -56,4 +56,32 @@ class JsMessageParsingTest { fun returnsNullWhenMethodMissing() { assertNull(parseJsMessage("""{"callbackId":1,"params":"{}"}""")) } + + @Test + fun carriesTheSenderTheEngineReported() { + val raw = """{"callbackId":1,"methodName":"echo","params":"{}","type":"call"}""" + val sender = JsMessageSender(origin = "https://example.com", isMainFrame = false) + + assertEquals(sender, parseJsMessage(raw, sender = sender)?.sender) + } + + @Test + fun senderIsNullWhenTheEngineDoesNotReportOne() { + val raw = """{"callbackId":1,"methodName":"echo","params":"{}","type":"call"}""" + + assertNull(parseJsMessage(raw)?.sender) + } + + @Test + fun pageCannotSupplyItsOwnSender() { + val raw = + """ + {"callbackId":1,"methodName":"echo","params":"{}","type":"call", + "sender":{"origin":"https://trusted.example","isMainFrame":true}} + """.trimIndent() + + val message = parseJsMessage(raw) + assertNotNull(message) + assertNull(message.sender) + } } diff --git a/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/NativeWebView.desktop.kt b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/NativeWebView.desktop.kt index ad65fc5..dde0040 100644 --- a/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/NativeWebView.desktop.kt +++ b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/NativeWebView.desktop.kt @@ -1,5 +1,7 @@ package dev.nucleusframework.webview.web +import dev.nucleusframework.webview.jsbridge.JsMessageSender + /** * Desktop [NativeWebView] base. * @@ -8,6 +10,12 @@ package dev.nucleusframework.webview.web * - [dev.nucleusframework.webview.web.macos.MacOsWebKitNativeWebView] on macOS * - [dev.nucleusframework.webview.web.windows.WindowsWebView2NativeWebView] on Windows */ +/** A raw page message and who sent it, as drained from a native backend. */ +data class NativeIpcMessage( + val body: String, + val sender: JsMessageSender?, +) + actual open class NativeWebView { open fun isReady(): Boolean = false @@ -39,6 +47,13 @@ actual open class NativeWebView { open fun drainIpcMessages(): List = emptyList() + /** + * [drainIpcMessages] with the sender the engine reported for each message. Backends whose + * engine does not report one keep the default, which leaves every sender null. + */ + open fun drainIpcMessagesWithSender(): List = + drainIpcMessages().map { NativeIpcMessage(body = it, sender = null) } + open fun addNavigateListener(listener: (String) -> Boolean) = Unit open fun removeNavigateListener(listener: (String) -> Boolean) = Unit diff --git a/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/WebViewDesktop.kt b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/WebViewDesktop.kt index 3fd4c15..5855da4 100644 --- a/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/WebViewDesktop.kt +++ b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/WebViewDesktop.kt @@ -280,8 +280,8 @@ actual fun ActualWebView( return@LaunchedEffect } while (true) { - for (raw in nativeWebView.drainIpcMessages()) { - parseJsMessage(raw)?.let { webViewJsBridge.dispatch(it) } + for (message in nativeWebView.drainIpcMessagesWithSender()) { + parseJsMessage(message.body, sender = message.sender)?.let { webViewJsBridge.dispatch(it) } } delay(50.milliseconds) } diff --git a/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/linux/LinuxWebKitNativeWebView.kt b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/linux/LinuxWebKitNativeWebView.kt index be7d5e6..f8c5796 100644 --- a/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/linux/LinuxWebKitNativeWebView.kt +++ b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/linux/LinuxWebKitNativeWebView.kt @@ -2,6 +2,7 @@ package dev.nucleusframework.webview.web.linux import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb +import dev.nucleusframework.webview.web.NativeIpcMessage import dev.nucleusframework.webview.web.NativeWebView import dev.nucleusframework.window.tao.NucleusPlatformView import kotlinx.coroutines.CompletableDeferred @@ -146,7 +147,9 @@ class LinuxWebKitNativeWebView( WebKitLinuxBridge.nativeEvaluateJavaScript(handle, script) } - override fun drainIpcMessages(): List = + override fun drainIpcMessages(): List = drainIpcMessagesWithSender().map { it.body } + + override fun drainIpcMessagesWithSender(): List = if (!isReady()) emptyList() else WebKitLinuxBridge.drainIpcMessages(handle) override fun addNavigateListener(listener: (String) -> Boolean) { diff --git a/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/linux/WebKitLinuxBridge.kt b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/linux/WebKitLinuxBridge.kt index 0b12119..0b27b70 100644 --- a/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/linux/WebKitLinuxBridge.kt +++ b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/linux/WebKitLinuxBridge.kt @@ -1,6 +1,8 @@ package dev.nucleusframework.webview.web.linux import dev.nucleusframework.core.runtime.NativeLibraryLoader +import dev.nucleusframework.webview.jsbridge.JsMessageSender +import dev.nucleusframework.webview.web.NativeIpcMessage import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.atomic.AtomicLong @@ -24,7 +26,7 @@ internal object WebKitLinuxBridge { private val navigateHandlers = ConcurrentHashMap Boolean>>() private val ipcQueues = - ConcurrentHashMap>() + ConcurrentHashMap>() private val jsCallbacks = ConcurrentHashMap Unit>>() private val cookieDeferreds = @@ -42,9 +44,9 @@ internal object WebKitLinuxBridge { navigateHandlers[handle]?.remove(listener) } - fun drainIpcMessages(handle: Long): List { + fun drainIpcMessages(handle: Long): List { val queue = ipcQueues[handle] ?: return emptyList() - val drained = ArrayList() + val drained = ArrayList() while (true) { val next = queue.poll() ?: break drained += next @@ -86,9 +88,14 @@ internal object WebKitLinuxBridge { return handlers.any { it(url) } } + /** + * [origin] and [isMainFrame] are what the relay script read about the document that sent + * [message]; [origin] is null for a message that did not come through the relay. + */ @JvmStatic - fun nativeOnIpcMessage(handle: Long, message: String) { - ipcQueues.getOrPut(handle) { ConcurrentLinkedQueue() }.add(message) + fun nativeOnIpcMessage(handle: Long, message: String, origin: String?, isMainFrame: Boolean) { + val sender = origin?.let { JsMessageSender(origin = it, isMainFrame = isMainFrame) } + ipcQueues.getOrPut(handle) { ConcurrentLinkedQueue() }.add(NativeIpcMessage(message, sender)) } @JvmStatic diff --git a/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/macos/MacOsWebKitNativeWebView.kt b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/macos/MacOsWebKitNativeWebView.kt index 2b06ca1..c869d1a 100644 --- a/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/macos/MacOsWebKitNativeWebView.kt +++ b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/macos/MacOsWebKitNativeWebView.kt @@ -2,6 +2,7 @@ package dev.nucleusframework.webview.web.macos import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb +import dev.nucleusframework.webview.web.NativeIpcMessage import dev.nucleusframework.webview.web.NativeWebView import dev.nucleusframework.window.tao.NucleusPlatformView import kotlinx.coroutines.CompletableDeferred @@ -145,7 +146,9 @@ class MacOsWebKitNativeWebView( WebKitMacOsBridge.nativeEvaluateJavaScript(handle, script) } - override fun drainIpcMessages(): List = + override fun drainIpcMessages(): List = drainIpcMessagesWithSender().map { it.body } + + override fun drainIpcMessagesWithSender(): List = if (!isReady()) emptyList() else WebKitMacOsBridge.drainIpcMessages(handle) override fun addNavigateListener(listener: (String) -> Boolean) { diff --git a/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/macos/WebKitMacOsBridge.kt b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/macos/WebKitMacOsBridge.kt index dd216fc..1f2478a 100644 --- a/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/macos/WebKitMacOsBridge.kt +++ b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/macos/WebKitMacOsBridge.kt @@ -1,6 +1,8 @@ package dev.nucleusframework.webview.web.macos import dev.nucleusframework.core.runtime.NativeLibraryLoader +import dev.nucleusframework.webview.jsbridge.JsMessageSender +import dev.nucleusframework.webview.web.NativeIpcMessage import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentLinkedQueue import kotlinx.coroutines.CompletableDeferred @@ -23,7 +25,7 @@ internal object WebKitMacOsBridge { private val navigateHandlers = ConcurrentHashMap Boolean>>() private val ipcQueues = - ConcurrentHashMap>() + ConcurrentHashMap>() private val jsCallbacks = ConcurrentHashMap Unit>>() private val cookieDeferreds = @@ -39,9 +41,9 @@ internal object WebKitMacOsBridge { navigateHandlers[handle]?.remove(listener) } - fun drainIpcMessages(handle: Long): List { + fun drainIpcMessages(handle: Long): List { val queue = ipcQueues[handle] ?: return emptyList() - val drained = ArrayList() + val drained = ArrayList() while (true) { val next = queue.poll() ?: break drained += next @@ -82,9 +84,14 @@ internal object WebKitMacOsBridge { return handlers.any { it(url) } } + /** + * [origin] and [isMainFrame] come from the message's `WKFrameInfo`; [origin] is null when + * WebKit supplied no frame. + */ @JvmStatic - fun nativeOnIpcMessage(handle: Long, message: String) { - ipcQueues.getOrPut(handle) { ConcurrentLinkedQueue() }.add(message) + fun nativeOnIpcMessage(handle: Long, message: String, origin: String?, isMainFrame: Boolean) { + val sender = origin?.let { JsMessageSender(origin = it, isMainFrame = isMainFrame) } + ipcQueues.getOrPut(handle) { ConcurrentLinkedQueue() }.add(NativeIpcMessage(message, sender)) } @JvmStatic diff --git a/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/windows/WindowsWebView2NativeWebView.kt b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/windows/WindowsWebView2NativeWebView.kt index e74368f..3bcedf2 100644 --- a/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/windows/WindowsWebView2NativeWebView.kt +++ b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/windows/WindowsWebView2NativeWebView.kt @@ -2,6 +2,8 @@ package dev.nucleusframework.webview.web.windows import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb +import dev.nucleusframework.webview.jsbridge.JsMessageSender +import dev.nucleusframework.webview.web.NativeIpcMessage import dev.nucleusframework.webview.web.NativeWebView import dev.nucleusframework.window.tao.NucleusPlatformView import kotlinx.coroutines.CompletableDeferred @@ -172,6 +174,16 @@ class WindowsWebView2NativeWebView( override fun drainIpcMessages(): List = if (!isReady()) emptyList() else WebView2WindowsBridge.drainIpcMessages(handle) + /** + * WebView2 raises the view's `WebMessageReceived` for the top-level document only: a + * frame's `chrome.webview.postMessage` goes to an event of that frame, which this backend + * does not subscribe to. So every message is the main frame's. Its origin stays unknown: + * the event names the document's URL, and a URL does not settle an origin (a sandboxed + * document's is opaque). + */ + override fun drainIpcMessagesWithSender(): List = + drainIpcMessages().map { NativeIpcMessage(it, JsMessageSender(origin = null, isMainFrame = true)) } + override fun addNavigateListener(listener: (String) -> Boolean) { if (!isReady()) return WebView2WindowsBridge.addNavigateListener(handle, listener) diff --git a/webview-compose/src/jvmMain/native/linux/compose_webview_internal.h b/webview-compose/src/jvmMain/native/linux/compose_webview_internal.h index f08ad9e..9b5907f 100644 --- a/webview-compose/src/jvmMain/native/linux/compose_webview_internal.h +++ b/webview-compose/src/jvmMain/native/linux/compose_webview_internal.h @@ -23,6 +23,7 @@ typedef struct { WebKitWebContext *context; gulong decide_policy_handler; gulong ipc_handler; + gulong ipc_relay_handler; } ComposeWebViewState; /* jni_bridge.c */ @@ -49,5 +50,9 @@ void compose_webview_on_script_message( WebKitUserContentManager *manager, WebKitJavascriptResult *js_result, gpointer user_data); +void compose_webview_on_relayed_script_message( + WebKitUserContentManager *manager, + WebKitJavascriptResult *js_result, + gpointer user_data); #endif /* COMPOSE_WEBVIEW_INTERNAL_H */ diff --git a/webview-compose/src/jvmMain/native/linux/jni_bridge.c b/webview-compose/src/jvmMain/native/linux/jni_bridge.c index 7df9eee..8daf968 100644 --- a/webview-compose/src/jvmMain/native/linux/jni_bridge.c +++ b/webview-compose/src/jvmMain/native/linux/jni_bridge.c @@ -39,7 +39,7 @@ void compose_webview_ensure_bridge_methods(JNIEnv *env) { g_on_navigate = (*env)->GetStaticMethodID( env, g_bridge_class, "nativeOnNavigate", "(JLjava/lang/String;)Z"); g_on_ipc = (*env)->GetStaticMethodID( - env, g_bridge_class, "nativeOnIpcMessage", "(JLjava/lang/String;)V"); + env, g_bridge_class, "nativeOnIpcMessage", "(JLjava/lang/String;Ljava/lang/String;Z)V"); g_on_js_result = (*env)->GetStaticMethodID( env, g_bridge_class, "nativeOnJsResult", "(JLjava/lang/String;)V"); g_on_cookies = (*env)->GetStaticMethodID( diff --git a/webview-compose/src/jvmMain/native/linux/view_lifecycle.c b/webview-compose/src/jvmMain/native/linux/view_lifecycle.c index 465a1d1..a312c0e 100644 --- a/webview-compose/src/jvmMain/native/linux/view_lifecycle.c +++ b/webview-compose/src/jvmMain/native/linux/view_lifecycle.c @@ -1,5 +1,11 @@ #include "compose_webview_internal.h" +/* The relay's script world and message handler, and the events it trades with the page's shim. */ +#define IPC_RELAY_WORLD "nucleusIpcRelay" +#define IPC_RELAY_HANDLER "nucleusIpcRelay" +#define IPC_MESSAGE_EVENT "__nucleusIpcMessage" +#define IPC_RELAY_STARTED_EVENT "__nucleusIpcRelayStarted" + ComposeWebViewState *compose_webview_state_from_handle(jlong handle) { if (handle == 0) return NULL; return (ComposeWebViewState *) (uintptr_t) handle; @@ -64,20 +70,47 @@ Java_dev_nucleusframework_webview_web_linux_WebKitLinuxBridge_nativeCreate( state); webkit_user_content_manager_register_script_message_handler(state->ucm, "ipc"); - /* Always inject a small window.ipc shim so the Kotlin JS bridge can attach. */ + /* + * Always inject a small window.ipc shim so the Kotlin JS bridge can attach. It offers each + * message to the relay below, which takes a message by cancelling its event. WebKit starts + * the two scripts in no fixed order, so until the relay has started in a document, messages + * wait for it. After that, a message the relay does not take goes straight to the "ipc" + * handler and arrives without a sender: so it is between a page's document.open(), which + * removes the relay's listener, and the document the page then writes, where the relay + * starts again. + */ const char *ipc_shim = - "if (typeof window.ipc === 'undefined') {" + "(function () {" + " if (typeof window.ipc !== 'undefined') { return; }" + " var dispatch = window.dispatchEvent.bind(window);" + " var CustomEvent = window.CustomEvent;" + " var waiting = [];" + " function offer(body) {" + " return !dispatch(new CustomEvent('" IPC_MESSAGE_EVENT "'," + " { cancelable: true, detail: body }));" + " }" + " function post(body) {" + " if (offer(body)) { return; }" + " if (window.webkit && window.webkit.messageHandlers &&" + " window.webkit.messageHandlers.ipc) {" + " window.webkit.messageHandlers.ipc.postMessage(body);" + " }" + " }" + " var relayStarted = offer(null);" + " window.addEventListener('" IPC_RELAY_STARTED_EVENT "', function (e) {" + " e.stopImmediatePropagation();" + " relayStarted = true;" + " var held = waiting;" + " waiting = [];" + " for (var i = 0; i < held.length; i++) { post(held[i]); }" + " }, true);" " window.ipc = {" " postMessage: function(message) {" - " if (window.webkit && window.webkit.messageHandlers &&" - " window.webkit.messageHandlers.ipc) {" - " window.webkit.messageHandlers.ipc.postMessage(" - " (typeof message === 'string') ? message : JSON.stringify(message)" - " );" - " }" + " var body = (typeof message === 'string') ? message : JSON.stringify(message);" + " if (relayStarted) { post(body); } else { waiting.push(body); }" " }" " };" - "}"; + "})();"; WebKitUserScript *shim = webkit_user_script_new( ipc_shim, WEBKIT_USER_CONTENT_INJECT_ALL_FRAMES, @@ -87,6 +120,48 @@ Java_dev_nucleusframework_webview_web_linux_WebKitLinuxBridge_nativeCreate( webkit_user_content_manager_add_script(state->ucm, shim); webkit_user_script_unref(shim); + /* + * WebKitGTK reports nothing about who posted a script message, so the shim's messages go + * through this relay. It runs in every frame, in a script world of its own: the page's + * scripts share the DOM with it but none of its JavaScript objects, so they can neither + * reach the handler registered in that world nor change what the relay reads there. It + * stamps each message with the origin of its frame's document and whether that frame is + * the top one. A page reaches only the relay of its own frame, through an event on its + * `window`, which a document of another origin cannot dispatch. + */ + const char *ipc_relay = + "(function () {" + " var handlers = window.webkit && window.webkit.messageHandlers;" + " var handler = handlers && handlers." IPC_RELAY_HANDLER ";" + " if (!handler) { return; }" + " var post = handler.postMessage.bind(handler);" + " window.addEventListener('" IPC_MESSAGE_EVENT "', function (e) {" + " e.stopImmediatePropagation();" + " e.preventDefault();" + " if (typeof e.detail === 'string') {" + " post({ origin: window.origin, isMainFrame: window.top === window, body: e.detail });" + " }" + " }, true);" + " window.dispatchEvent(new CustomEvent('" IPC_RELAY_STARTED_EVENT "'));" + "})();"; + /* Connect BEFORE register, as for "ipc" above. */ + state->ipc_relay_handler = g_signal_connect( + state->ucm, + "script-message-received::" IPC_RELAY_HANDLER, + G_CALLBACK(compose_webview_on_relayed_script_message), + state); + webkit_user_content_manager_register_script_message_handler_in_world( + state->ucm, IPC_RELAY_HANDLER, IPC_RELAY_WORLD); + WebKitUserScript *relay = webkit_user_script_new_for_world( + ipc_relay, + WEBKIT_USER_CONTENT_INJECT_ALL_FRAMES, + WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START, + IPC_RELAY_WORLD, + NULL, + NULL); + webkit_user_content_manager_add_script(state->ucm, relay); + webkit_user_script_unref(relay); + /* * JS bridge object, built once in Kotlin. Injected at document start so * page scripts can call it without waiting for a post-load injection. @@ -230,6 +305,10 @@ Java_dev_nucleusframework_webview_web_linux_WebKitLinuxBridge_nativeRelease( g_signal_handler_disconnect(state->ucm, state->ipc_handler); state->ipc_handler = 0; } + if (state->ipc_relay_handler != 0) { + g_signal_handler_disconnect(state->ucm, state->ipc_relay_handler); + state->ipc_relay_handler = 0; + } g_object_unref(state->ucm); state->ucm = NULL; } diff --git a/webview-compose/src/jvmMain/native/linux/view_signals.c b/webview-compose/src/jvmMain/native/linux/view_signals.c index 8a5259a..53d9615 100644 --- a/webview-compose/src/jvmMain/native/linux/view_signals.c +++ b/webview-compose/src/jvmMain/native/linux/view_signals.c @@ -61,6 +61,39 @@ gboolean compose_webview_on_decide_policy( return TRUE; } +/* + * Hands a page message to Kotlin with what is known about its sender. `origin` is NULL when + * nothing vouches for the sender, and `is_main_frame` says nothing then. + */ +static void compose_webview_deliver_ipc( + ComposeWebViewState *state, + const gchar *message, + const gchar *origin, + gboolean is_main_frame) +{ + JNIEnv *env = compose_webview_get_env(); + if (env != NULL) { + compose_webview_ensure_bridge_methods(env); + if (compose_webview_bridge_class() != NULL && compose_webview_on_ipc() != NULL) { + jlong handle = (jlong) (uintptr_t) state; + jstring jmsg = (*env)->NewStringUTF(env, message); + jstring jorigin = origin != NULL ? (*env)->NewStringUTF(env, origin) : NULL; + (*env)->CallStaticVoidMethod( + env, compose_webview_bridge_class(), compose_webview_on_ipc(), handle, jmsg, + jorigin, (jboolean) (is_main_frame ? JNI_TRUE : JNI_FALSE)); + (*env)->DeleteLocalRef(env, jmsg); + if (jorigin != NULL) (*env)->DeleteLocalRef(env, jorigin); + if ((*env)->ExceptionCheck(env)) { + (*env)->ExceptionClear(env); + } + } + } +} + +/* + * A message posted straight to the page-visible handler. WebKitGTK says nothing about who + * posted it, so it carries no sender. + */ void compose_webview_on_script_message( WebKitUserContentManager *manager, WebKitJavascriptResult *js_result, @@ -83,19 +116,40 @@ void compose_webview_on_script_message( } if (message == NULL) return; - JNIEnv *env = compose_webview_get_env(); - if (env != NULL) { - compose_webview_ensure_bridge_methods(env); - if (compose_webview_bridge_class() != NULL && compose_webview_on_ipc() != NULL) { - jlong handle = (jlong) (uintptr_t) state; - jstring jmsg = (*env)->NewStringUTF(env, message); - (*env)->CallStaticVoidMethod( - env, compose_webview_bridge_class(), compose_webview_on_ipc(), handle, jmsg); - (*env)->DeleteLocalRef(env, jmsg); - if ((*env)->ExceptionCheck(env)) { - (*env)->ExceptionClear(env); - } - } - } + compose_webview_deliver_ipc(state, message, NULL, FALSE); g_free(message); } + +/* + * A page message forwarded by the relay script (view_lifecycle.c). Only that script can reach + * the handler this arrives on, so the origin and frame it names are the ones WebKit gave the + * sending document, not ones the page chose. + */ +void compose_webview_on_relayed_script_message( + WebKitUserContentManager *manager, + WebKitJavascriptResult *js_result, + gpointer user_data) +{ + (void) manager; + ComposeWebViewState *state = (ComposeWebViewState *) user_data; + if (state == NULL || state->web_view == NULL || js_result == NULL) return; + + JSCValue *value = webkit_javascript_result_get_js_value(js_result); + if (value == NULL || !JSC_IS_VALUE(value) || !jsc_value_is_object(value)) return; + + JSCValue *origin = jsc_value_object_get_property(value, "origin"); + JSCValue *is_main_frame = jsc_value_object_get_property(value, "isMainFrame"); + JSCValue *body = jsc_value_object_get_property(value, "body"); + if (jsc_value_is_string(origin) && jsc_value_is_boolean(is_main_frame) && + jsc_value_is_string(body)) { + gchar *origin_text = jsc_value_to_string(origin); + gchar *body_text = jsc_value_to_string(body); + compose_webview_deliver_ipc( + state, body_text, origin_text, jsc_value_to_boolean(is_main_frame)); + g_free(origin_text); + g_free(body_text); + } + g_object_unref(origin); + g_object_unref(is_main_frame); + g_object_unref(body); +} diff --git a/webview-compose/src/jvmMain/native/macos/jni_bridge.m b/webview-compose/src/jvmMain/native/macos/jni_bridge.m index 745223b..a1ee77f 100644 --- a/webview-compose/src/jvmMain/native/macos/jni_bridge.m +++ b/webview-compose/src/jvmMain/native/macos/jni_bridge.m @@ -39,7 +39,7 @@ void compose_webview_ensure_bridge_methods(JNIEnv *env) { g_on_navigate = (*env)->GetStaticMethodID( env, g_bridge_class, "nativeOnNavigate", "(JLjava/lang/String;)Z"); g_on_ipc = (*env)->GetStaticMethodID( - env, g_bridge_class, "nativeOnIpcMessage", "(JLjava/lang/String;)V"); + env, g_bridge_class, "nativeOnIpcMessage", "(JLjava/lang/String;Ljava/lang/String;Z)V"); g_on_js_result = (*env)->GetStaticMethodID( env, g_bridge_class, "nativeOnJsResult", "(JLjava/lang/String;)V"); g_on_cookies = (*env)->GetStaticMethodID( diff --git a/webview-compose/src/jvmMain/native/macos/view_signals.m b/webview-compose/src/jvmMain/native/macos/view_signals.m index 37a85e1..bb2fb58 100644 --- a/webview-compose/src/jvmMain/native/macos/view_signals.m +++ b/webview-compose/src/jvmMain/native/macos/view_signals.m @@ -1,5 +1,15 @@ #include "compose_webview_internal.h" +// Serializes a frame's origin the way `window.origin` does: "scheme://host[:port]", or +// "null" for an opaque origin, which WebKit reports with an empty scheme and host. +static NSString *compose_webview_serialize_origin(WKSecurityOrigin *origin) { + if (origin == nil || origin.protocol.length == 0 || origin.host.length == 0) return @"null"; + NSString *host = origin.host; + if ([host containsString:@":"]) host = [NSString stringWithFormat:@"[%@]", host]; + if (origin.port == 0) return [NSString stringWithFormat:@"%@://%@", origin.protocol, host]; + return [NSString stringWithFormat:@"%@://%@:%ld", origin.protocol, host, (long)origin.port]; +} + @implementation ComposeWebViewState (Signals) - (void)userContentController:(WKUserContentController *)userContentController @@ -28,10 +38,17 @@ - (void)userContentController:(WKUserContentController *)userContentController compose_webview_ensure_bridge_methods(env); if (compose_webview_bridge_class() == NULL || compose_webview_on_ipc() == NULL) return; + // The sender as WebKit knows it, not as the page describes itself. + WKFrameInfo *frame = message.frameInfo; jstring jmsg = compose_webview_ns_to_jstring(env, payload); + jstring jorigin = frame == nil + ? NULL + : compose_webview_ns_to_jstring(env, compose_webview_serialize_origin(frame.securityOrigin)); (*env)->CallStaticVoidMethod( - env, compose_webview_bridge_class(), compose_webview_on_ipc(), self.handle, jmsg); + env, compose_webview_bridge_class(), compose_webview_on_ipc(), self.handle, jmsg, + jorigin, (jboolean)(frame != nil && frame.isMainFrame)); if (jmsg != NULL) (*env)->DeleteLocalRef(env, jmsg); + if (jorigin != NULL) (*env)->DeleteLocalRef(env, jorigin); if ((*env)->ExceptionCheck(env)) { (*env)->ExceptionClear(env); } diff --git a/webview-compose/src/jvmMain/resources/META-INF/native-image/dev.nucleusframework/composewebview/reachability-metadata.json b/webview-compose/src/jvmMain/resources/META-INF/native-image/dev.nucleusframework/composewebview/reachability-metadata.json index af82e85..af79f05 100644 --- a/webview-compose/src/jvmMain/resources/META-INF/native-image/dev.nucleusframework/composewebview/reachability-metadata.json +++ b/webview-compose/src/jvmMain/resources/META-INF/native-image/dev.nucleusframework/composewebview/reachability-metadata.json @@ -7,7 +7,7 @@ "allDeclaredFields": true, "methods": [ { "name": "nativeOnNavigate", "parameterTypes": ["long", "java.lang.String"] }, - { "name": "nativeOnIpcMessage", "parameterTypes": ["long", "java.lang.String"] }, + { "name": "nativeOnIpcMessage", "parameterTypes": ["long", "java.lang.String", "java.lang.String", "boolean"] }, { "name": "nativeOnJsResult", "parameterTypes": ["long", "java.lang.String"] }, { "name": "nativeOnCookiesResult", "parameterTypes": ["long", "java.lang.String"] }, { "name": "nativeOnScreenshotResult", "parameterTypes": ["long", "byte[]"] } @@ -20,7 +20,7 @@ "allDeclaredFields": true, "methods": [ { "name": "nativeOnNavigate", "parameterTypes": ["long", "java.lang.String"] }, - { "name": "nativeOnIpcMessage", "parameterTypes": ["long", "java.lang.String"] }, + { "name": "nativeOnIpcMessage", "parameterTypes": ["long", "java.lang.String", "java.lang.String", "boolean"] }, { "name": "nativeOnJsResult", "parameterTypes": ["long", "java.lang.String"] }, { "name": "nativeOnCookiesResult", "parameterTypes": ["long", "java.lang.String"] }, { "name": "nativeOnScreenshotResult", "parameterTypes": ["long", "byte[]"] } @@ -110,7 +110,7 @@ "type": "dev.nucleusframework.webview.web.linux.WebKitLinuxBridge", "methods": [ { "name": "nativeOnNavigate", "parameterTypes": ["long", "java.lang.String"] }, - { "name": "nativeOnIpcMessage", "parameterTypes": ["long", "java.lang.String"] }, + { "name": "nativeOnIpcMessage", "parameterTypes": ["long", "java.lang.String", "java.lang.String", "boolean"] }, { "name": "nativeOnJsResult", "parameterTypes": ["long", "java.lang.String"] }, { "name": "nativeOnCookiesResult", "parameterTypes": ["long", "java.lang.String"] }, { "name": "nativeOnScreenshotResult", "parameterTypes": ["long", "byte[]"] } @@ -120,7 +120,7 @@ "type": "dev.nucleusframework.webview.web.macos.WebKitMacOsBridge", "methods": [ { "name": "nativeOnNavigate", "parameterTypes": ["long", "java.lang.String"] }, - { "name": "nativeOnIpcMessage", "parameterTypes": ["long", "java.lang.String"] }, + { "name": "nativeOnIpcMessage", "parameterTypes": ["long", "java.lang.String", "java.lang.String", "boolean"] }, { "name": "nativeOnJsResult", "parameterTypes": ["long", "java.lang.String"] }, { "name": "nativeOnCookiesResult", "parameterTypes": ["long", "java.lang.String"] }, { "name": "nativeOnScreenshotResult", "parameterTypes": ["long", "byte[]"] }