Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ internal fun suiteCatalog(): List<SuiteCase> =
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"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,38 @@ internal fun pageWithMarker(marker: String, title: String = "SUITE"): String =
</body></html>
""".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 =
"""
<!DOCTYPE html><html><head><meta charset="utf-8"><title>EarlyBridge</title>
<style>html,body{margin:0;background:#ffffff;color:#111;font-family:system-ui}
#marker{padding:16px;font-size:18px;font-weight:700}</style>
</head><body><div id="marker">$tag</div>
<script>
window.__suiteLastCallback=null;
window.__suiteOnCallback=function(d){
window.__suiteLastCallback=(typeof d==='string')?d:JSON.stringify(d);
};
window.__earlyBridge =
typeof window.kmpJsBridge !== 'undefined' &&
typeof window.kmpJsBridge.callNative === 'function';
if (window.__earlyBridge) {
window.kmpJsBridge.callNative(
'suitePing',
JSON.stringify({early:'$tag'}),
function(d){ window.__suiteOnCallback(d); }
);
}
</script>
</body></html>
""".trimIndent()

internal fun pageSolidColor(hex: String): String =
"""
<!DOCTYPE html><html><head><meta charset="utf-8"><title>Color</title>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<SuiteCapability>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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/"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ actual fun suiteCapabilities(): Set<SuiteCapability> =
SuiteCapability.ScreenshotPixels,
SuiteCapability.IsolatedNativeWebView,
SuiteCapability.DesktopNativeControls,
SuiteCapability.DocumentStartJsBridge,
)

actual fun isPlatformWebViewReady(state: WebViewState): Boolean {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package dev.nucleusframework.webview.jsbridge

/**
* Builds the `window.<name>` 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()
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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.<name>.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"))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -36,15 +37,38 @@ 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).
*/
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
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -140,15 +167,25 @@ 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.
val existing = state.webView?.nativeWebView
if (existing != null && existing.isReady() && existing.isLiveBackend()) {
existing
} else {
factory(WebViewFactoryParam(state, parentHwnd = parentHwnd))
factory(
WebViewFactoryParam(
state,
parentHwnd = parentHwnd,
jsBridgeName = jsBridgeName,
),
)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ internal object WebKitLinuxBridge {
userAgent: String?,
dataDirectory: String?,
initScript: String?,
jsBridgeScript: String?,
incognito: Boolean,
enableDevtools: Boolean,
javascriptEnabled: Boolean,
Expand Down
Loading
Loading