From 424093ccc5baf220ca75acfc1104fb3b1bc388bb Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Thu, 3 Sep 2026 14:55:50 +0300 Subject: [PATCH 1/2] Install the JS bridge at document start on every desktop backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop bridge object was injected from Compose after load, gated on a LoadingState/lastLoadedUrl change derived from a 120 ms poller. A navigation that starts and finishes inside one tick, or that keeps the same document URL (loadHtml without baseUrl stays on about:blank), produced no emission, so window.kmpJsBridge was never re-injected. Page startup scripts never saw it either, since injection only happened once the load had finished. Windows already worked around this with a hardcoded document-start shim. The JS half of the bridge now lives in one place (jsBridgeObjectScript) and is handed to nativeCreate, which installs it as a document-start user script on WebKit2GTK, WKWebView and WebView2 — so it exists before the first page statement of every document, honours a custom jsBridgeName on all three backends, and no longer depends on poller timing. Suite cases B10/B11 cover it: calling the bridge from an inline script, and three successive loads that keep the same URL. Both fail against the previous behaviour (verified on macOS) and pass with the fix (70/70). Fixes #60 --- README.md | 7 ++- .../webview/e2e/visualsuite/SuiteCatalog.kt | 2 + .../webview/e2e/visualsuite/SuitePages.kt | 32 +++++++++++++ .../webview/e2e/visualsuite/SuiteRunner.kt | 26 ++++++++++ .../webview/jsbridge/JsBridgeScript.kt | 45 ++++++++++++++++++ .../nucleusframework/webview/web/IWebView.kt | 35 ++------------ .../webview/jsbridge/JsBridgeScriptTest.kt | 47 +++++++++++++++++++ .../webview/web/WebViewDesktop.kt | 41 +++++++++++++++- .../web/linux/LinuxWebKitNativeWebView.kt | 3 ++ .../webview/web/linux/WebKitLinuxBridge.kt | 1 + .../web/macos/MacOsWebKitNativeWebView.kt | 3 ++ .../webview/web/macos/WebKitMacOsBridge.kt | 1 + .../web/windows/WebView2WindowsBridge.kt | 1 + .../windows/WindowsWebView2NativeWebView.kt | 3 ++ .../src/jvmMain/native/linux/view_lifecycle.c | 22 +++++++++ .../src/jvmMain/native/macos/view_lifecycle.m | 14 ++++++ .../native/windows/compose_webview_internal.h | 1 + .../jvmMain/native/windows/view_lifecycle.cpp | 41 +++++----------- 18 files changed, 263 insertions(+), 62 deletions(-) create mode 100644 webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/jsbridge/JsBridgeScript.kt create mode 100644 webview-compose/src/commonTest/kotlin/dev/nucleusframework/webview/jsbridge/JsBridgeScriptTest.kt diff --git a/README.md b/README.md index a794dd4..65f9ba4 100644 --- a/README.md +++ b/README.md @@ -222,9 +222,12 @@ navigator.evaluateJavaScript("document.title = 'Hello'") ### JS ↔ Kotlin bridge -* injected automatically after page load +* injected automatically — at **document start** on Desktop (available to your + page's own startup scripts), after page load on Android / iOS / WasmJs * callback-based -* works on Android / iOS / WasmJs / Desktop (Linux WebKit) +* works on **all platforms**: Android, iOS, WasmJs and Desktop + (Linux WebKit2GTK, macOS WKWebView, Windows WebView2) +* honours a custom name: `WebViewJsBridge(jsBridgeName = "myBridge")` ```js window.kmpJsBridge.callNative("echo", {...}, callback) 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 a0f3fbe..4180059 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 @@ -49,6 +49,8 @@ internal fun suiteCatalog(): List = SuiteCase("B07", "JS Bridge", "second handler registration works"), SuiteCase("B08", "JS Bridge", "unregister stops dispatch"), 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)"), // 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 3902b8b..30909e6 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,38 @@ internal fun pageWithMarker(marker: String, title: String = "SUITE"): String = """.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. + * + * Only a bridge installed at document start can serve that call — the + * post-load injection driven by Compose runs far too late. + */ +internal fun pageEarlyBridgeCall(tag: String): String = + """ + EarlyBridge + +
$tag
+ + + """.trimIndent() + internal fun pageSolidColor(hex: String): String = """ Color 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 bf584de..5582ef8 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 @@ -357,6 +357,32 @@ internal suspend fun runFullSuite( } } + case("B10") { + // Bridge must answer a call made while the document is still parsing. + ctx.clearBridgeHits() + loadHtmlAwaitMarker(ctx.navigator, "early-b10", pageEarlyBridgeCall("early-b10")) + assertThat( + evalJs(ctx.navigator, "window.__earlyBridge === true").contains("true"), + "bridge absent while the document was parsing", + ) + awaitUntil(12_000, "early ping") { + ctx.getLastPingPayload()?.contains("early-b10") == true + } + } + case("B11") { + // Without a baseUrl the document URL stays about:blank, so neither the + // polled loadingState nor lastLoadedUrl need to change between loads: + // only a document-start bridge survives every navigation. + repeat(3) { i -> + val tag = "same-url-$i" + ctx.clearBridgeHits() + ctx.navigator.loadHtml(pageEarlyBridgeCall(tag), baseUrl = null) + awaitUntil(12_000, "early ping $tag") { + ctx.getLastPingPayload()?.contains(tag) == true + } + } + } + // ── Cookies ────────────────────────────────────────────────────── case("K01", required = setOf(SuiteCapability.CookieDomainApi)) { val url = "https://suite.local/" diff --git a/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/jsbridge/JsBridgeScript.kt b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/jsbridge/JsBridgeScript.kt new file mode 100644 index 0000000..389930c --- /dev/null +++ b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/jsbridge/JsBridgeScript.kt @@ -0,0 +1,45 @@ +package dev.nucleusframework.webview.jsbridge + +/** + * Builds the `window.` bridge object that JS uses to call Kotlin. + * + * Single source of truth for the JS half of the bridge: a platform only + * supplies [postMessageBody], the statements that hand a serialized + * [JsMessage] to its native transport. Desktop backends inject the result at + * document start (native user script), so the object exists before any page + * script runs; other platforms evaluate it after load. + * + * The definition is idempotent — re-injecting keeps the pending callbacks of + * an already installed bridge. + */ +internal fun jsBridgeObjectScript( + name: String, + postMessageBody: String, +): String = + """ + if (typeof window.$name === 'undefined') { + window.$name = { + callbacks: {}, + callbackId: 0, + callNative: function (methodName, params, callback) { + var message = { + methodName: methodName, + params: params, + callbackId: callback ? window.$name.callbackId++ : -1 + }; + if (callback) { + window.$name.callbacks[message.callbackId] = callback; + } + window.$name.postMessage(JSON.stringify(message)); + }, + onCallback: function (callbackId, data) { + var callback = window.$name.callbacks[callbackId]; + if (callback) { + callback(data); + delete window.$name.callbacks[callbackId]; + } + }, + postMessage: function (message) { $postMessageBody } + }; + } + """.trimIndent() diff --git a/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/web/IWebView.kt b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/web/IWebView.kt index 9f08c27..8d39db4 100644 --- a/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/web/IWebView.kt +++ b/webview-compose/src/commonMain/kotlin/dev/nucleusframework/webview/web/IWebView.kt @@ -1,6 +1,7 @@ package dev.nucleusframework.webview.web import dev.nucleusframework.webview.jsbridge.WebViewJsBridge +import dev.nucleusframework.webview.jsbridge.jsBridgeObjectScript import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.suspendCancellableCoroutine import kotlin.coroutines.resume @@ -88,36 +89,10 @@ interface IWebView { fun injectJsBridge() { val bridge = webViewJsBridge ?: return - val name = bridge.jsBridgeName - val initJs = - """ - if (typeof window.$name === 'undefined') { - window.$name = { - callbacks: {}, - callbackId: 0, - callNative: function (methodName, params, callback) { - var message = { - methodName: methodName, - params: params, - callbackId: callback ? window.$name.callbackId++ : -1 - }; - if (callback) { - window.$name.callbacks[message.callbackId] = callback; - } - window.$name.postMessage(JSON.stringify(message)); - }, - onCallback: function (callbackId, data) { - var callback = window.$name.callbacks[callbackId]; - if (callback) { - callback(data); - delete window.$name.callbacks[callbackId]; - } - }, - postMessage: function(_) { /* platform override */ } - }; - } - """.trimIndent() - evaluateJavaScript(initJs) + // Transport is attached by the platform override right after this call. + evaluateJavaScript( + jsBridgeObjectScript(bridge.jsBridgeName, "/* platform override */"), + ) } fun initJsBridge(webViewJsBridge: WebViewJsBridge) diff --git a/webview-compose/src/commonTest/kotlin/dev/nucleusframework/webview/jsbridge/JsBridgeScriptTest.kt b/webview-compose/src/commonTest/kotlin/dev/nucleusframework/webview/jsbridge/JsBridgeScriptTest.kt new file mode 100644 index 0000000..64fa834 --- /dev/null +++ b/webview-compose/src/commonTest/kotlin/dev/nucleusframework/webview/jsbridge/JsBridgeScriptTest.kt @@ -0,0 +1,47 @@ +package dev.nucleusframework.webview.jsbridge + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Shared multiplatform suite — must pass on JVM, Android host, iOS simulator, Wasm. + * + * The script is the single source of truth for the JS half of the bridge: it is + * evaluated after load on mobile/Wasm and injected as a native user script at + * document start on desktop, so a regression here breaks every platform. + */ +class JsBridgeScriptTest { + @Test + fun definesBridgeUnderConfiguredName() { + val script = jsBridgeObjectScript("myBridge", "noop();") + + assertTrue(script.contains("typeof window.myBridge === 'undefined'")) + assertTrue(script.contains("window.myBridge.callbackId++")) + assertTrue(script.contains("window.myBridge.postMessage(JSON.stringify(message));")) + assertFalse(script.contains("kmpJsBridge")) + } + + @Test + fun routesPostMessageThroughPlatformBody() { + val script = + jsBridgeObjectScript( + name = "kmpJsBridge", + postMessageBody = "window.ipc.postMessage(message);", + ) + + assertTrue( + script.contains("postMessage: function (message) { window.ipc.postMessage(message); }"), + ) + } + + @Test + fun keepsCallbackContractUsedByWebViewJsBridge() { + val script = jsBridgeObjectScript("kmpJsBridge", "noop();") + + // WebViewJsBridge.onCallback evaluates window..onCallback(id, data). + assertTrue(script.contains("onCallback: function (callbackId, data)")) + // A call without a JS callback must not allocate a callback id. + assertTrue(script.contains("callbackId: callback ? window.kmpJsBridge.callbackId++ : -1")) + } +} 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 9cd1885..3fd4c15 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 @@ -12,6 +12,7 @@ import androidx.compose.ui.Modifier import dev.nucleusframework.core.runtime.Platform import dev.nucleusframework.webview.cookie.DesktopCookieManager import dev.nucleusframework.webview.jsbridge.WebViewJsBridge +import dev.nucleusframework.webview.jsbridge.jsBridgeObjectScript import dev.nucleusframework.webview.jsbridge.parseJsMessage import dev.nucleusframework.webview.request.WebRequest import dev.nucleusframework.webview.request.WebRequestInterceptResult @@ -36,8 +37,30 @@ actual class WebViewFactoryParam( val fileContent: String = "", /** Windows only: parent Tao HWND. Required to create a real WebView2. */ val parentHwnd: Long = 0L, + /** + * Name of the JS bridge object to install at document start, or null when + * the WebView is used without a [WebViewJsBridge]. + */ + val jsBridgeName: String? = null, ) +/** + * JS bridge bootstrap injected natively at document start. + * + * Desktop [LoadingState] is derived from a poller, so post-load injection can + * miss a navigation that starts and finishes inside one tick (in-memory HTML, + * `data:` URLs, cached pages) or that keeps the same URL. Installing the + * object as a native user script makes it available to page scripts from the + * first statement of every document, on every backend. + */ +private fun desktopJsBridgeScript(jsBridgeName: String?): String? { + val name = jsBridgeName?.trim()?.takeIf { it.isNotEmpty() } ?: return null + return jsBridgeObjectScript( + name = name, + postMessageBody = "if (window.ipc && window.ipc.postMessage) window.ipc.postMessage(message);", + ) +} + /** * Default factory: real WebKit2GTK on Linux, WKWebView on macOS, WebView2 on * Windows when the native lib loads (and Windows parent HWND is available). @@ -45,6 +68,7 @@ actual class WebViewFactoryParam( actual fun defaultWebViewFactory(param: WebViewFactoryParam): NativeWebView { val settings = param.state.webSettings val desktop = settings.desktopWebSettings + val bridgeScript = desktopJsBridgeScript(param.jsBridgeName) val background = if (desktop.transparent) { settings.backgroundColor @@ -58,6 +82,7 @@ actual fun defaultWebViewFactory(param: WebViewFactoryParam): NativeWebView { customUserAgent = settings.customUserAgentString, dataDirectory = desktop.dataDirectory, initScript = desktop.initScript, + jsBridgeScript = bridgeScript, incognito = desktop.incognito, enableDevtools = desktop.enableDevtools, javascriptEnabled = settings.isJavaScriptEnabled, @@ -72,6 +97,7 @@ actual fun defaultWebViewFactory(param: WebViewFactoryParam): NativeWebView { customUserAgent = settings.customUserAgentString, dataDirectory = desktop.dataDirectory, initScript = desktop.initScript, + jsBridgeScript = bridgeScript, incognito = desktop.incognito, enableDevtools = desktop.enableDevtools, javascriptEnabled = settings.isJavaScriptEnabled, @@ -91,6 +117,7 @@ actual fun defaultWebViewFactory(param: WebViewFactoryParam): NativeWebView { customUserAgent = settings.customUserAgentString, dataDirectory = desktop.dataDirectory, initScript = desktop.initScript, + jsBridgeScript = bridgeScript, incognito = desktop.incognito, enableDevtools = desktop.enableDevtools, javascriptEnabled = settings.isJavaScriptEnabled, @@ -140,7 +167,11 @@ actual fun ActualWebView( 0L } - val nativeWebView = remember(state, factory, parentHwnd) { + // Keyed by name (not identity) so a remembered bridge never recreates the + // WebView, while a late-arriving bridge still gets its document-start script. + val jsBridgeName = webViewJsBridge?.jsBridgeName + + val nativeWebView = remember(state, factory, parentHwnd, jsBridgeName) { // Prefer a ready live backend across recompositions. Windows may // first compose with parentHwnd=0 (no-op) then recreate once the // Tao HWND is available — do not lock in a permanent no-op. @@ -148,7 +179,13 @@ actual fun ActualWebView( if (existing != null && existing.isReady() && existing.isLiveBackend()) { existing } else { - factory(WebViewFactoryParam(state, parentHwnd = parentHwnd)) + factory( + WebViewFactoryParam( + state, + parentHwnd = parentHwnd, + jsBridgeName = jsBridgeName, + ), + ) } } 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 fcc73ae..be7d5e6 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 @@ -16,6 +16,8 @@ class LinuxWebKitNativeWebView( customUserAgent: String? = null, dataDirectory: String? = null, initScript: String? = null, + /** JS bridge bootstrap injected at document start in all frames. */ + jsBridgeScript: String? = null, incognito: Boolean = false, enableDevtools: Boolean = false, javascriptEnabled: Boolean = true, @@ -49,6 +51,7 @@ class LinuxWebKitNativeWebView( userAgent = customUserAgent?.trim()?.takeIf { it.isNotEmpty() }, dataDirectory = dataDirectory?.trim()?.takeIf { it.isNotEmpty() }, initScript = initScript?.trim()?.takeIf { it.isNotEmpty() }, + jsBridgeScript = jsBridgeScript?.trim()?.takeIf { it.isNotEmpty() }, incognito = incognito, enableDevtools = enableDevtools, javascriptEnabled = javascriptEnabled, 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 e34f8c4..0b12119 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 @@ -113,6 +113,7 @@ internal object WebKitLinuxBridge { userAgent: String?, dataDirectory: String?, initScript: String?, + jsBridgeScript: String?, incognito: Boolean, enableDevtools: Boolean, javascriptEnabled: Boolean, 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 c8315a0..2b06ca1 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 @@ -16,6 +16,8 @@ class MacOsWebKitNativeWebView( customUserAgent: String? = null, dataDirectory: String? = null, initScript: String? = null, + /** JS bridge bootstrap injected at document start in all frames. */ + jsBridgeScript: String? = null, incognito: Boolean = false, enableDevtools: Boolean = false, javascriptEnabled: Boolean = true, @@ -48,6 +50,7 @@ class MacOsWebKitNativeWebView( userAgent = customUserAgent?.trim()?.takeIf { it.isNotEmpty() }, dataDirectory = dataDirectory?.trim()?.takeIf { it.isNotEmpty() }, initScript = initScript?.trim()?.takeIf { it.isNotEmpty() }, + jsBridgeScript = jsBridgeScript?.trim()?.takeIf { it.isNotEmpty() }, incognito = incognito, enableDevtools = enableDevtools, javascriptEnabled = javascriptEnabled, 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 619a364..dd216fc 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 @@ -109,6 +109,7 @@ internal object WebKitMacOsBridge { userAgent: String?, dataDirectory: String?, initScript: String?, + jsBridgeScript: String?, incognito: Boolean, enableDevtools: Boolean, javascriptEnabled: Boolean, diff --git a/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/windows/WebView2WindowsBridge.kt b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/windows/WebView2WindowsBridge.kt index abb7ed6..ec3b584 100644 --- a/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/windows/WebView2WindowsBridge.kt +++ b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/windows/WebView2WindowsBridge.kt @@ -114,6 +114,7 @@ internal object WebView2WindowsBridge { userAgent: String?, dataDirectory: String?, initScript: String?, + jsBridgeScript: String?, incognito: Boolean, enableDevtools: Boolean, javascriptEnabled: 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 2422a67..e74368f 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 @@ -24,6 +24,8 @@ class WindowsWebView2NativeWebView( customUserAgent: String? = null, dataDirectory: String? = null, initScript: String? = null, + /** JS bridge bootstrap injected at document start in all frames. */ + jsBridgeScript: String? = null, incognito: Boolean = false, enableDevtools: Boolean = false, javascriptEnabled: Boolean = true, @@ -57,6 +59,7 @@ class WindowsWebView2NativeWebView( userAgent = customUserAgent?.trim()?.takeIf { it.isNotEmpty() }, dataDirectory = dataDirectory?.trim()?.takeIf { it.isNotEmpty() }, initScript = initScript?.trim()?.takeIf { it.isNotEmpty() }, + jsBridgeScript = jsBridgeScript?.trim()?.takeIf { it.isNotEmpty() }, incognito = incognito, enableDevtools = enableDevtools, javascriptEnabled = javascriptEnabled, diff --git a/webview-compose/src/jvmMain/native/linux/view_lifecycle.c b/webview-compose/src/jvmMain/native/linux/view_lifecycle.c index 6a45a98..465a1d1 100644 --- a/webview-compose/src/jvmMain/native/linux/view_lifecycle.c +++ b/webview-compose/src/jvmMain/native/linux/view_lifecycle.c @@ -17,6 +17,7 @@ Java_dev_nucleusframework_webview_web_linux_WebKitLinuxBridge_nativeCreate( jstring user_agent, jstring data_directory, jstring init_script, + jstring js_bridge_script, jboolean incognito, jboolean enable_devtools, jboolean javascript_enabled, @@ -86,6 +87,27 @@ Java_dev_nucleusframework_webview_web_linux_WebKitLinuxBridge_nativeCreate( webkit_user_content_manager_add_script(state->ucm, shim); webkit_user_script_unref(shim); + /* + * JS bridge object, built once in Kotlin. Injected at document start so + * page scripts can call it without waiting for a post-load injection. + */ + if (js_bridge_script != NULL) { + const char *bridge_src = (*env)->GetStringUTFChars(env, js_bridge_script, NULL); + if (bridge_src != NULL && bridge_src[0] != '\0') { + WebKitUserScript *bridge = webkit_user_script_new( + bridge_src, + WEBKIT_USER_CONTENT_INJECT_ALL_FRAMES, + WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START, + NULL, + NULL); + webkit_user_content_manager_add_script(state->ucm, bridge); + webkit_user_script_unref(bridge); + } + if (bridge_src != NULL) { + (*env)->ReleaseStringUTFChars(env, js_bridge_script, bridge_src); + } + } + /* * Opaque mode: force a solid page background. Many pages (and about:blank) * leave html/body transparent; without this the Compose clear-through diff --git a/webview-compose/src/jvmMain/native/macos/view_lifecycle.m b/webview-compose/src/jvmMain/native/macos/view_lifecycle.m index 3de1860..5503e77 100644 --- a/webview-compose/src/jvmMain/native/macos/view_lifecycle.m +++ b/webview-compose/src/jvmMain/native/macos/view_lifecycle.m @@ -26,6 +26,7 @@ - (void)teardown { jstring user_agent, jstring data_directory, jstring init_script, + jstring js_bridge_script, jboolean incognito, jboolean enable_devtools, jboolean javascript_enabled, @@ -80,6 +81,19 @@ - (void)teardown { forMainFrameOnly:NO]; [ucm addUserScript:shim]; + // JS bridge object, built once in Kotlin. Injected at document start so + // page scripts can call it without waiting for a post-load injection. + if (js_bridge_script != NULL) { + NSString *bridgeSrc = compose_webview_jstring_to_ns(env, js_bridge_script); + if (bridgeSrc != nil && bridgeSrc.length > 0) { + WKUserScript *bridge = [[WKUserScript alloc] + initWithSource:bridgeSrc + injectionTime:WKUserScriptInjectionTimeAtDocumentStart + forMainFrameOnly:NO]; + [ucm addUserScript:bridge]; + } + } + if (!transparent) { NSString *css = @"(function(){var s=document.createElement('style');" diff --git a/webview-compose/src/jvmMain/native/windows/compose_webview_internal.h b/webview-compose/src/jvmMain/native/windows/compose_webview_internal.h index 2d8860c..705afac 100644 --- a/webview-compose/src/jvmMain/native/windows/compose_webview_internal.h +++ b/webview-compose/src/jvmMain/native/windows/compose_webview_internal.h @@ -69,6 +69,7 @@ struct ComposeWebViewCreateOptions { std::wstring userAgent; std::wstring dataDirectory; std::wstring initScript; + std::wstring jsBridgeScript; bool incognito = false; bool enableDevtools = false; bool javascriptEnabled = true; diff --git a/webview-compose/src/jvmMain/native/windows/view_lifecycle.cpp b/webview-compose/src/jvmMain/native/windows/view_lifecycle.cpp index 4fbc4aa..62cb7ee 100644 --- a/webview-compose/src/jvmMain/native/windows/view_lifecycle.cpp +++ b/webview-compose/src/jvmMain/native/windows/view_lifecycle.cpp @@ -320,9 +320,8 @@ ComposeWebViewState *compose_webview_create( compose_webview_apply_bounds(*s); s->dcompDevice->Commit(); - /* ipc shim + kmpJsBridge at document start so the suite does not depend - * on a Compose Finished race to inject the bridge after each navigation. */ - const wchar_t *ipcAndBridgeShim = + /* window.ipc transport shim (WebView2 flavour). */ + const wchar_t *ipcShim = L"(function(){" L" if (typeof window.ipc === 'undefined') {" L" window.ipc = {" @@ -337,32 +336,16 @@ ComposeWebViewState *compose_webview_create( L" }" L" };" L" }" - L" if (typeof window.kmpJsBridge === 'undefined') {" - L" window.kmpJsBridge = {" - L" callbacks: {}," - L" callbackId: 0," - L" callNative: function(methodName, params, callback) {" - L" var message = {" - L" methodName: methodName," - L" params: params," - L" callbackId: callback ? window.kmpJsBridge.callbackId++ : -1" - L" };" - L" if (callback) {" - L" window.kmpJsBridge.callbacks[message.callbackId] = callback;" - L" }" - L" window.kmpJsBridge.postMessage(JSON.stringify(message));" - L" }," - L" onCallback: function(callbackId, data) {" - L" var cb = window.kmpJsBridge.callbacks[callbackId];" - L" if (cb) { cb(data); delete window.kmpJsBridge.callbacks[callbackId]; }" - L" }," - L" postMessage: function(message) {" - L" if (window.ipc && window.ipc.postMessage) window.ipc.postMessage(message);" - L" }" - L" };" - L" }" L"})();"; - s->webview->AddScriptToExecuteOnDocumentCreated(ipcAndBridgeShim, nullptr); + s->webview->AddScriptToExecuteOnDocumentCreated(ipcShim, nullptr); + + /* JS bridge object, built once in Kotlin (honours a custom jsBridgeName). + * Injected at document start so page scripts can call it without waiting + * on a Compose Finished race after each navigation. */ + if (!opts.jsBridgeScript.empty()) { + s->webview->AddScriptToExecuteOnDocumentCreated( + opts.jsBridgeScript.c_str(), nullptr); + } if (!opts.transparent) { s->webview->AddScriptToExecuteOnDocumentCreated( @@ -403,6 +386,7 @@ Java_dev_nucleusframework_webview_web_windows_WebView2WindowsBridge_nativeCreate jstring userAgent, jstring dataDirectory, jstring initScript, + jstring jsBridgeScript, jboolean incognito, jboolean enableDevtools, jboolean javascriptEnabled, @@ -422,6 +406,7 @@ Java_dev_nucleusframework_webview_web_windows_WebView2WindowsBridge_nativeCreate opts.userAgent = compose_webview_jstring_to_wide(env, userAgent); opts.dataDirectory = compose_webview_jstring_to_wide(env, dataDirectory); opts.initScript = compose_webview_jstring_to_wide(env, initScript); + opts.jsBridgeScript = compose_webview_jstring_to_wide(env, jsBridgeScript); opts.incognito = incognito == JNI_TRUE; opts.enableDevtools = enableDevtools == JNI_TRUE; opts.javascriptEnabled = javascriptEnabled == JNI_TRUE; From 27388a9be8bea727e0a83e076cb0f41178947f2e Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Thu, 3 Sep 2026 15:11:40 +0300 Subject: [PATCH 2/2] Gate the document-start bridge cases on a desktop capability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Android / iOS / WasmJs still inject the bridge after load, so B10/B11 report Skipped there instead of Failed — the catalog stays identical across platforms and the matrix stays honest. --- .../webview/e2e/visualsuite/SuitePlatform.kt | 7 +++++++ .../webview/e2e/visualsuite/SuiteRunner.kt | 4 ++-- .../webview/e2e/visualsuite/SuitePlatform.jvm.kt | 1 + 3 files changed, 10 insertions(+), 2 deletions(-) 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 c95402e..f1b9726 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 @@ -36,6 +36,13 @@ enum class SuiteCapability { /** Native isReady / focus / zoom / devtools (desktop JNI backends). */ DesktopNativeControls, + + /** + * JS bridge installed as a native user script at document start, so page + * scripts can call it while the document is still parsing. Desktop only: + * Android / iOS / WasmJs still inject it after load. + */ + DocumentStartJsBridge, } 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 5582ef8..69ccbcd 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 @@ -357,7 +357,7 @@ internal suspend fun runFullSuite( } } - case("B10") { + case("B10", required = setOf(SuiteCapability.DocumentStartJsBridge)) { // Bridge must answer a call made while the document is still parsing. ctx.clearBridgeHits() loadHtmlAwaitMarker(ctx.navigator, "early-b10", pageEarlyBridgeCall("early-b10")) @@ -369,7 +369,7 @@ internal suspend fun runFullSuite( ctx.getLastPingPayload()?.contains("early-b10") == true } } - case("B11") { + case("B11", required = setOf(SuiteCapability.DocumentStartJsBridge)) { // Without a baseUrl the document URL stays about:blank, so neither the // polled loadingState nor lastLoadedUrl need to change between loads: // only a document-start bridge survives every navigation. 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 ec07c0a..536d727 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 @@ -22,6 +22,7 @@ actual fun suiteCapabilities(): Set = SuiteCapability.ScreenshotPixels, SuiteCapability.IsolatedNativeWebView, SuiteCapability.DesktopNativeControls, + SuiteCapability.DocumentStartJsBridge, ) actual fun isPlatformWebViewReady(state: WebViewState): Boolean {