From b0b898fd2417aaf4dd251316153ad24d654386b8 Mon Sep 17 00:00:00 2001 From: Angelos Veglektsis Date: Tue, 10 Nov 2026 21:19:37 +0200 Subject: [PATCH 1/3] Add the engine-reported sender to JS bridge messages (macOS) The bridge object is installed in every frame, so a handler could not tell a call made by the top-level page from one made by a frame it embeds. JsMessage.sender now carries the sending frame's origin and whether it is the main frame, read from WKScriptMessage.frameInfo and never from the page's JSON. It is null on the backends whose engine does not report a sender yet. --- .../webview/e2e/visualsuite/SuiteCatalog.kt | 2 + .../webview/e2e/visualsuite/SuitePages.kt | 16 +++++ .../webview/e2e/visualsuite/SuitePlatform.kt | 6 ++ .../webview/e2e/visualsuite/SuiteRunner.kt | 58 +++++++++++++++++++ .../e2e/visualsuite/SuitePlatform.jvm.kt | 8 +++ .../webview/jsbridge/JsMessage.kt | 7 ++- .../webview/jsbridge/JsMessageParsing.kt | 4 +- .../webview/jsbridge/JsMessageSender.kt | 17 ++++++ .../webview/jsbridge/JsMessageParsingTest.kt | 28 +++++++++ .../webview/web/NativeWebView.desktop.kt | 15 +++++ .../webview/web/WebViewDesktop.kt | 4 +- .../web/macos/MacOsWebKitNativeWebView.kt | 5 +- .../webview/web/macos/WebKitMacOsBridge.kt | 17 ++++-- .../src/jvmMain/native/macos/jni_bridge.m | 2 +- .../src/jvmMain/native/macos/view_signals.m | 19 +++++- .../composewebview/reachability-metadata.json | 4 +- 16 files changed, 198 insertions(+), 14 deletions(-) create mode 100644 webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/jsbridge/JsMessageSender.kt 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..dfd4c09 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,8 @@ 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"), // 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..a9d023f 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,22 @@ internal fun pageWithMarker(marker: String, title: String = "SUITE"): String = """.trimIndent() +/** + * 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() +} + /** * 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..7879748 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,12 @@ 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 desktop only so far. + */ + JsMessageSender, } 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..da7fee6 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,30 @@ 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", + ) + } // ── Cookies ────────────────────────────────────────────────────── case("K01", required = setOf(SuiteCapability.CookieDomainApi)) { @@ -751,3 +776,36 @@ internal suspend fun runFullSuite( * (CI runners render in software). */ private const val MIN_ANIMATING_FPS = 10 + +/** + * Runs [send] with a `suiteSender` handler registered and returns the sender of each + * message it received, keyed by the message's `from` param, once [expect] has arrived. + */ +private suspend fun collectSenders( + ctx: SuiteContext, + expect: String = "main", + send: suspend () -> Unit, +): Map { + val senders = mutableMapOf() + val handler = + object : IJsMessageHandler { + override fun methodName() = "suiteSender" + + override fun handle( + message: JsMessage, + navigator: WebViewNavigator?, + callback: (String) -> Unit, + ) { + val from = if (message.params.contains("frame")) "frame" else "main" + senders[from] = message.sender + } + } + ctx.jsBridge.register(handler) + try { + send() + awaitUntil(12_000, "suiteSender from $expect") { senders.containsKey(expect) } + } finally { + ctx.jsBridge.unregister(handler) + } + return senders +} 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..7050e69 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("mac")) { + setOf(SuiteCapability.JsMessageSender) + } else { + emptySet() + } + +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..962ca83 --- /dev/null +++ b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/jsbridge/JsMessageSender.kt @@ -0,0 +1,17 @@ +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. + * + * @property origin The sending document's origin, serialized as `scheme://host[:port]`, + * or `"null"` for an opaque origin (sandboxed frames, `data:` documents). + * @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/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/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..b3af777 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 @@ -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[]"] } @@ -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[]"] } From 6eb655a81fcaff4392aea002933105bd30a120df Mon Sep 17 00:00:00 2001 From: Angelos Veglektsis Date: Wed, 11 Nov 2026 19:26:25 +0200 Subject: [PATCH 2/3] Report the main frame as the sender of JS bridge messages on Windows WebView2 raises a view's WebMessageReceived for the top-level document only. A frame's chrome.webview.postMessage goes to an event of that frame, and is dropped when nothing subscribes to it, which this backend never does. So every bridge message on Windows is the main frame's, and is now reported as such. Its origin is not reported: the event names the sending document's URL, and a URL does not settle an origin. JsMessageSender.origin becomes optional for it. e2e: B14 has a sandboxed frame call the bridge and tell its page, whose own calls then arrive alone, each as the main frame's. --- .../webview/e2e/visualsuite/SuiteCatalog.kt | 1 + .../webview/e2e/visualsuite/SuitePages.kt | 24 +++++++++++++++++++ .../webview/e2e/visualsuite/SuitePlatform.kt | 6 +++++ .../webview/e2e/visualsuite/SuiteRunner.kt | 24 ++++++++++++++++--- .../e2e/visualsuite/SuitePlatform.jvm.kt | 10 ++++---- .../webview/jsbridge/JsMessageSender.kt | 10 ++++++-- .../windows/WindowsWebView2NativeWebView.kt | 12 ++++++++++ 7 files changed, 78 insertions(+), 9 deletions(-) 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 dfd4c09..5e18379 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 @@ -53,6 +53,7 @@ internal fun suiteCatalog(): List = 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"), // 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 a9d023f..a9b2309 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 @@ -57,6 +57,30 @@ internal fun pageWithBridgeCallingFrame(tag: String): String { """.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() +} + /** * 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 7879748..3b8bcc2 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 @@ -49,6 +49,12 @@ enum class SuiteCapability { * reports it. macOS desktop only so far. */ 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 da7fee6..58e042b 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 @@ -407,6 +407,23 @@ internal suspend fun runFullSuite( "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", + ) + } // ── Cookies ────────────────────────────────────────────────────── case("K01", required = setOf(SuiteCapability.CookieDomainApi)) { @@ -780,11 +797,12 @@ private const val MIN_ANIMATING_FPS = 10 /** * Runs [send] with a `suiteSender` handler registered 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", - send: suspend () -> Unit, + send: suspend (received: Map) -> Unit, ): Map { val senders = mutableMapOf() val handler = @@ -796,13 +814,13 @@ private suspend fun collectSenders( navigator: WebViewNavigator?, callback: (String) -> Unit, ) { - val from = if (message.params.contains("frame")) "frame" else "main" + val from = Regex("\"from\":\"([^\"]*)\"").find(message.params)?.groupValues?.get(1) ?: return senders[from] = message.sender } } ctx.jsBridge.register(handler) try { - send() + send(senders) awaitUntil(12_000, "suiteSender from $expect") { senders.containsKey(expect) } } finally { ctx.jsBridge.unregister(handler) 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 7050e69..8ea28b7 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 @@ -15,10 +15,12 @@ import kotlinx.coroutines.delay actual fun suiteCapabilities(): Set = desktopCapabilities + - if (System.getProperty("os.name", "").lowercase(Locale.ENGLISH).contains("mac")) { - setOf(SuiteCapability.JsMessageSender) - } else { - emptySet() + System.getProperty("os.name", "").lowercase(Locale.ENGLISH).let { os -> + when { + os.contains("mac") -> setOf(SuiteCapability.JsMessageSender) + os.contains("win") -> setOf(SuiteCapability.MainFrameOnlyJsBridge) + else -> emptySet() + } } private val desktopCapabilities = 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 index 962ca83..40f37ea 100644 --- a/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/jsbridge/JsMessageSender.kt +++ b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/jsbridge/JsMessageSender.kt @@ -7,11 +7,17 @@ package dev.nucleusframework.webview.jsbridge * 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): both properties, for every frame. + * - 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). + * 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 origin: String?, val isMainFrame: Boolean, ) 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) From 7dfaf05d1947cff9ceb83dbb0485db4ef190e61b Mon Sep 17 00:00:00 2001 From: Angelos Veglektsis Date: Wed, 11 Nov 2026 19:27:25 +0200 Subject: [PATCH 3/3] Report the sender of JS bridge messages on Linux WebKitGTK tells the host nothing about who posted a script message. The window.ipc shim now hands each message to a relay script that runs in every frame, in a script world of its own: page scripts share the DOM with it but none of its JavaScript objects, so they can neither reach the message handler registered in that world nor change what the relay reads there. The relay 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. WebKit runs the two scripts in no fixed order, so a message sent before the relay has started in a document waits in the shim for it. A message that does not come through the relay arrives without a sender: one posted to the "ipc" handler directly, or sent between a page's document.open(), which removes the relay's listener, and the document the page then writes. e2e: B12 and B13 now run on Linux. B15 has a sandboxed frame try each way it has to send as the page that embeds it. B16 calls the bridge on both sides of document.open(). B17 has the suite view's init script call the bridge at document start. --- .../webview/e2e/visualsuite/SuiteCatalog.kt | 3 + .../webview/e2e/visualsuite/SuitePages.kt | 50 ++++++++++ .../webview/e2e/visualsuite/SuitePlatform.kt | 2 +- .../webview/e2e/visualsuite/SuiteRunner.kt | 53 +++++++++- .../webview/e2e/visualsuite/VisualSuiteApp.kt | 1 + .../e2e/visualsuite/SuitePlatform.jvm.kt | 10 +- .../webview/jsbridge/JsMessageSender.kt | 5 +- .../web/linux/LinuxWebKitNativeWebView.kt | 5 +- .../webview/web/linux/WebKitLinuxBridge.kt | 17 +++- .../native/linux/compose_webview_internal.h | 5 + .../src/jvmMain/native/linux/jni_bridge.c | 2 +- .../src/jvmMain/native/linux/view_lifecycle.c | 97 +++++++++++++++++-- .../src/jvmMain/native/linux/view_signals.c | 82 +++++++++++++--- .../composewebview/reachability-metadata.json | 4 +- 14 files changed, 293 insertions(+), 43 deletions(-) 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 5e18379..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 @@ -54,6 +54,9 @@ internal fun suiteCatalog(): List = 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 a9b2309..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,13 @@ 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. @@ -81,6 +88,49 @@ internal fun pageAnsweringBridgeCallingFrame(tag: String): String { """.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 3b8bcc2..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 @@ -46,7 +46,7 @@ enum class SuiteCapability { /** * Bridge messages carry the sender (origin, main frame or not) as the engine - * reports it. macOS desktop only so far. + * reports it. macOS and Linux desktop. */ JsMessageSender, 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 58e042b..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 @@ -424,6 +424,52 @@ internal suspend fun runFullSuite( "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)) { @@ -795,19 +841,20 @@ internal suspend fun runFullSuite( private const val MIN_ANIMATING_FPS = 10 /** - * Runs [send] with a `suiteSender` handler registered and returns the sender of each + * 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() = "suiteSender" + override fun methodName() = method override fun handle( message: JsMessage, @@ -821,7 +868,7 @@ private suspend fun collectSenders( ctx.jsBridge.register(handler) try { send(senders) - awaitUntil(12_000, "suiteSender from $expect") { senders.containsKey(expect) } + awaitUntil(12_000, "$method from $expect") { senders.containsKey(expect) } } finally { ctx.jsBridge.unregister(handler) } 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 8ea28b7..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 @@ -15,12 +15,10 @@ import kotlinx.coroutines.delay actual fun suiteCapabilities(): Set = desktopCapabilities + - System.getProperty("os.name", "").lowercase(Locale.ENGLISH).let { os -> - when { - os.contains("mac") -> setOf(SuiteCapability.JsMessageSender) - os.contains("win") -> setOf(SuiteCapability.MainFrameOnlyJsBridge) - else -> emptySet() - } + if (System.getProperty("os.name", "").lowercase(Locale.ENGLISH).contains("win")) { + setOf(SuiteCapability.MainFrameOnlyJsBridge) + } else { + setOf(SuiteCapability.JsMessageSender) } private val desktopCapabilities = 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 index 40f37ea..d3fcbe6 100644 --- a/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/jsbridge/JsMessageSender.kt +++ b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/jsbridge/JsMessageSender.kt @@ -8,7 +8,10 @@ package dev.nucleusframework.webview.jsbridge * embedded third-party frame can call the same handlers as the page that embeds it. * * What the desktop engines report: - * - macOS (WKWebView): both properties, for every frame. + * - 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. * 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/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/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 b3af777..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[]"] } @@ -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[]"] }