Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@ internal fun suiteCatalog(): List<SuiteCase> =
SuiteCase("B09", "JS Bridge", "rapid IPC burst (×12) drains without drop"),
SuiteCase("B10", "JS Bridge", "bridge callable from an inline script (document start)"),
SuiteCase("B11", "JS Bridge", "bridge survives loads that keep the same URL (×3)"),
SuiteCase("B12", "JS Bridge", "message sender: origin of the main frame"),
SuiteCase("B13", "JS Bridge", "message sender: embedded frame is not the main frame"),
SuiteCase("B14", "JS Bridge", "message sender: only the main frame reaches the bridge"),
SuiteCase("B15", "JS Bridge", "message sender: a frame cannot pass for the main frame"),
SuiteCase("B16", "JS Bridge", "bridge call still arrives after document.open()"),
SuiteCase("B17", "JS Bridge", "message sender: a call the init script makes at document start"),
// Cookies (Wry: set/get/clear_for_url/clear_all + attributes)
SuiteCase("K01", "Cookies", "setCookie + getCookies finds cookie"),
SuiteCase("K02", "Cookies", "removeCookies drops cookie"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,96 @@ internal fun pageWithMarker(marker: String, title: String = "SUITE"): String =
</body></html>
""".trimIndent()

/**
* The suite view's init script. It runs ahead of everything else in each document and calls
* the bridge at once, which makes its call the earliest message a page can send (case B17).
*/
internal const val SUITE_INIT_SCRIPT =
"window.kmpJsBridge.callNative('suiteEarly', JSON.stringify({from: location.href}));"

/**
* A page embedding a sandboxed frame that calls the bridge on its own. The frame's
* origin is opaque, so the engine must report it as `"null"` and not as the embedder's.
*/
internal fun pageWithBridgeCallingFrame(tag: String): String {
val frame =
"<script>window.kmpJsBridge.callNative('suiteSender'," +
"JSON.stringify({from:'frame'}),function(d){});</script>"
return """
<!DOCTYPE html><html><head><meta charset="utf-8"><title>FrameSender</title></head>
<body><div id="marker">$tag</div>
<iframe sandbox="allow-scripts" srcdoc="${frame.replace("\"", "&quot;")}"></iframe>
</body></html>
""".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 =
"<script>window.kmpJsBridge.callNative('suiteSender'," +
"JSON.stringify({from:'frame'}),function(d){});" +
"window.parent.postMessage('frame-called','*');</script>"
return """
<!DOCTYPE html><html><head><meta charset="utf-8"><title>AnsweredFrame</title></head>
<body><div id="marker">$tag</div>
<script>
window.addEventListener('message', function (e) {
if (e.data !== 'frame-called') { return; }
window.kmpJsBridge.callNative('suiteSender', JSON.stringify({from: 'main'}), function (d) {});
});
</script>
<iframe sandbox="allow-scripts" srcdoc="${frame.replace("\"", "&quot;")}"></iframe>
</body></html>
""".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 =
"""
<script>
(function () {
function message(from, extra) {
var m = extra || {};
m.methodName = 'suiteSender';
m.params = JSON.stringify({from: from});
m.callbackId = -1;
return JSON.stringify(m);
}
try {
window.webkit.messageHandlers.nucleusIpcRelay.postMessage(
{origin: 'https://suite.local', isMainFrame: true, body: message('forged-handler')});
} catch (e) {}
try {
window.top.dispatchEvent(new CustomEvent(
'__nucleusIpcMessage', {cancelable: true, detail: message('forged-event')}));
} catch (e) {}
try { window.top.ipc.postMessage(message('forged-embedder')); } catch (e) {}
window.ipc.postMessage(message('forged-fields', {
origin: 'https://suite.local',
isMainFrame: true,
sender: {origin: 'https://suite.local', isMainFrame: true}
}));
window.kmpJsBridge.callNative('suiteSender', JSON.stringify({from: 'frame'}), function (d) {});
})();
</script>
""".trimIndent()
return """
<!DOCTYPE html><html><head><meta charset="utf-8"><title>ForgingFrame</title></head>
<body><div id="marker">$tag</div>
<iframe sandbox="allow-scripts" srcdoc="${frame.replace("\"", "&quot;")}"></iframe>
</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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,18 @@ enum class SuiteCapability {
* Android / iOS / WasmJs still inject it after load.
*/
DocumentStartJsBridge,

/**
* Bridge messages carry the sender (origin, main frame or not) as the engine
* reports it. macOS and Linux desktop.
*/
JsMessageSender,

/**
* The engine hands the bridge the messages of the top-level document only, so
* every message is the main frame's; its origin is not reported. Windows desktop.
*/
MainFrameOnlyJsBridge,
}

expect fun suiteCapabilities(): Set<SuiteCapability>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -382,6 +383,93 @@ internal suspend fun runFullSuite(
}
}
}
case("B12", required = setOf(SuiteCapability.JsMessageSender)) {
val senders = collectSenders(ctx) {
loadHtmlAwaitMarker(ctx.navigator, "sender-b12")
evalJs(
ctx.navigator,
"window.kmpJsBridge.callNative('suiteSender', JSON.stringify({from:'main'}), function(d){}); 's'",
)
}
val sender = senders["main"]
assertThat(
sender == JsMessageSender(origin = "https://suite.local", isMainFrame = true),
"sender=$sender",
)
}
case("B13", required = setOf(SuiteCapability.JsMessageSender)) {
val senders = collectSenders(ctx, expect = "frame") {
loadHtmlAwaitMarker(ctx.navigator, "sender-b13", pageWithBridgeCallingFrame("sender-b13"))
}
val sender = senders["frame"]
assertThat(
sender == JsMessageSender(origin = "null", isMainFrame = false),
"sender=$sender",
)
}
case("B14", required = setOf(SuiteCapability.MainFrameOnlyJsBridge)) {
val senders = collectSenders(ctx, expect = "main-again") { received ->
loadHtmlAwaitMarker(ctx.navigator, "sender-b14", pageAnsweringBridgeCallingFrame("sender-b14"))
// The page answers the frame's call, and one more round trip follows it: a
// message of the frame's that the engine did deliver would be here by then.
awaitUntil(12_000, "suiteSender from main") { received.containsKey("main") }
evalJs(
ctx.navigator,
"window.kmpJsBridge.callNative('suiteSender', JSON.stringify({from:'main-again'}), function(d){}); 's'",
)
}
val main = JsMessageSender(origin = null, isMainFrame = true)
assertThat(
senders == mapOf("main" to main, "main-again" to main),
"senders=$senders",
)
}
case("B15", required = setOf(SuiteCapability.JsMessageSender)) {
val senders = collectSenders(ctx, expect = "frame") {
loadHtmlAwaitMarker(ctx.navigator, "sender-b15", pageWithSenderForgingFrame("sender-b15"))
}
// Only what the frame sent through its own bridge arrives, and as the frame's.
val frame = JsMessageSender(origin = "null", isMainFrame = false)
assertThat(
senders == mapOf("forged-fields" to frame, "frame" to frame),
"senders=$senders",
)
}
case("B16", required = setOf(SuiteCapability.DocumentStartJsBridge)) {
// document.open() drops every listener on the window but keeps its script objects,
// the bridge among them: a call made before the rewritten document exists and one
// made after it must both arrive.
loadHtmlAwaitMarker(ctx.navigator, "reopen-b16")
ctx.clearBridgeHits()
evalJs(
ctx.navigator,
"""
(function(){
document.open();
window.kmpJsBridge.callNative('suitePing', JSON.stringify({reopen:'b16-opened'}), function(d){});
document.write('<!DOCTYPE html><html><body><div id="marker">reopened-b16</div></body></html>');
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)) {
Expand Down Expand Up @@ -751,3 +839,38 @@ internal suspend fun runFullSuite(
* (CI runners render in software).
*/
private const val MIN_ANIMATING_FPS = 10

/**
* Runs [send] with a handler registered for [method] and returns the sender of each
* message it received, keyed by the message's `from` param, once [expect] has arrived.
* [send] is given the senders as they arrive.
*/
private suspend fun collectSenders(
ctx: SuiteContext,
expect: String = "main",
method: String = "suiteSender",
send: suspend (received: Map<String, JsMessageSender?>) -> Unit,
): Map<String, JsMessageSender?> {
val senders = mutableMapOf<String, JsMessageSender?>()
val handler =
object : IJsMessageHandler {
override fun methodName() = method

override fun handle(
message: JsMessage,
navigator: WebViewNavigator?,
callback: (String) -> Unit,
) {
val from = Regex("\"from\":\"([^\"]*)\"").find(message.params)?.groupValues?.get(1) ?: return
senders[from] = message.sender
}
}
ctx.jsBridge.register(handler)
try {
send(senders)
awaitUntil(12_000, "$method from $expect") { senders.containsKey(expect) }
} finally {
ctx.jsBridge.unregister(handler)
}
return senders
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ import java.util.Locale
import kotlinx.coroutines.delay

actual fun suiteCapabilities(): Set<SuiteCapability> =
desktopCapabilities +
if (System.getProperty("os.name", "").lowercase(Locale.ENGLISH).contains("win")) {
setOf(SuiteCapability.MainFrameOnlyJsBridge)
} else {
setOf(SuiteCapability.JsMessageSender)
}

private val desktopCapabilities =
setOf(
SuiteCapability.HistoryNavigation,
SuiteCapability.DataUrlNavigation,
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
)

Original file line number Diff line number Diff line change
Expand Up @@ -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<ParsedJsBridgeMessage>(raw)
if (expectedType != null && message.type != expectedType) return null
Expand All @@ -31,7 +32,8 @@ internal fun parseJsMessage(
JsMessage(
callbackId = callbackId,
methodName = methodName,
params = params
params = params,
sender = sender,
)
}.getOrNull()

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package dev.nucleusframework.webview.jsbridge

/**
* Who sent a [JsMessage], as reported by the web engine itself and never by the page.
*
* A handler that acts on a message's authority (showing a permission prompt, returning
* account data) should check it: the bridge object is installed in every frame, so an
* embedded third-party frame can call the same handlers as the page that embeds it.
*
* What the desktop engines report:
* - macOS (WKWebView) and Linux (WebKitGTK): both properties, for every frame. On Linux a
* message has no sender at all when the page posts it to the WebKit message handler
* itself instead of through the bridge, or between a `document.open()` and the document
* it then writes.
* - Windows (WebView2): only the top-level document's messages reach the bridge, so
* [isMainFrame] is always true there, and [origin] is not reported.
*
* @property origin The sending document's origin, serialized as `scheme://host[:port]`,
* or `"null"` for an opaque origin (sandboxed frames, `data:` documents). Null when the
* engine does not report an origin.
* @property isMainFrame Whether the message came from the top-level document.
*/
data class JsMessageSender(
val origin: String?,
val isMainFrame: Boolean,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading