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/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 bf584de..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,6 +357,32 @@ internal suspend fun runFullSuite( } } + 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")) + 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", 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. + 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/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 { 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;