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
4 changes: 3 additions & 1 deletion e2e/.yarnrc.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
nodeLinker: node-modules
enableScripts: true
npmMinimalAgeGate: 0
npmPreapprovedPackages:
- "@wvb/*"
- "@wvb-playground/*"
4 changes: 2 additions & 2 deletions e2e/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
"test": "vitest run"
},
"dependencies": {
"@wvb-playground/testing": "^0.0.0",
"@wvb-playground/webview-hacker-news": "^0.0.0"
"@wvb-playground/testing": "0.0.1",
"@wvb-playground/webview-hacker-news": "0.0.0"
},
"devDependencies": {
"@types/node": "^25",
Expand Down
24 changes: 21 additions & 3 deletions e2e/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -966,6 +966,24 @@ __metadata:
languageName: node
linkType: hard

"@wvb-playground/testing@npm:0.0.1":
version: 0.0.1
resolution: "@wvb-playground/testing@npm:0.0.1"
peerDependencies:
playwright-core: ^1.40.0
selenium-webdriver: ^4.0.0
webdriverio: ^8.0.0 || ^9.0.0
peerDependenciesMeta:
playwright-core:
optional: true
selenium-webdriver:
optional: true
webdriverio:
optional: true
checksum: 10c0/348f5199a5eea52b0af30c069d3d8687fb44abd3e420f856219d1911904b61715b1eb3d074c89d9fbc3826514ec72a8dcd6f15205574b77137c74137d9085759
languageName: node
linkType: hard

"@wvb-playground/testing@npm:^0.0.0":
version: 0.0.0
resolution: "@wvb-playground/testing@npm:0.0.0"
Expand All @@ -984,7 +1002,7 @@ __metadata:
languageName: node
linkType: hard

"@wvb-playground/webview-hacker-news@npm:^0.0.0":
"@wvb-playground/webview-hacker-news@npm:0.0.0":
version: 0.0.0
resolution: "@wvb-playground/webview-hacker-news@npm:0.0.0"
dependencies:
Expand Down Expand Up @@ -5539,8 +5557,8 @@ __metadata:
resolution: "webview-bundle-android-e2e@workspace:."
dependencies:
"@types/node": "npm:^25"
"@wvb-playground/testing": "npm:^0.0.0"
"@wvb-playground/webview-hacker-news": "npm:^0.0.0"
"@wvb-playground/testing": "npm:0.0.1"
"@wvb-playground/webview-hacker-news": "npm:0.0.0"
appium: "npm:^3.5.0"
execa: "npm:^9.5.2"
tinyglobby: "npm:^0.2.10"
Expand Down
8 changes: 8 additions & 0 deletions lib/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ android {

defaultConfig {
minSdk = 24
// Keep the @JavascriptInterface bridge methods in minified consumer apps.
consumerProguardFiles("consumer-rules.pro")
}

compileOptions {
Expand All @@ -27,6 +29,12 @@ android {
keepDebugSymbols.add("**/*.so")
}
}

testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
}

dependencies {
Expand Down
6 changes: 6 additions & 0 deletions lib/consumer-rules.pro
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# The `wvbAndroid` invoke bridge is reached only from JavaScript via
# @JavascriptInterface, so R8 sees no Kotlin/Java caller and would otherwise be
# free to strip or rename the exposed method in a minified consumer app.
-keepclasseswithmembers,includedescriptorclasses class dev.wvb.** {
@android.webkit.JavascriptInterface <methods>;
}
164 changes: 164 additions & 0 deletions lib/src/main/kotlin/dev/wvb/Bridge.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
package dev.wvb

import android.webkit.WebView
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import org.json.JSONArray
import org.json.JSONObject

/**
* The native end of the `@wvb/bridge` `invoke()` bridge: the `window.wvbAndroid`
* object the web side posts `{ name, params, success, error }` messages to. It
* dispatches to a registered [handler] and replies via the JS success/error
* callbacks.
*/
class Bridge internal constructor() : AutoCloseable {
// Concurrent: registered on the UI thread, read from the background dispatch.
private val handlers = ConcurrentHashMap<String, suspend (params: Any?) -> Any?>()

// Handlers run on this background scope regardless of the delivering thread;
// only the final evaluateJavascript reply hops to the UI thread.
private val scope = CoroutineScope(SupervisorJob())

@Volatile
private var webView: WebView? = null

/**
* Registers a handler for the `invoke()` command [name], replacing any existing one.
*
* [block] receives the decoded `params` (an `org.json` value or `null`), may
* suspend, and returns a JSON-encodable value. Throwing rejects the `Promise`
* with `{ code?, message }`; throw a [BridgeError] to control the `code`.
*/
@Suppress("unused")
fun handler(name: String, block: suspend (params: Any?) -> Any?): Bridge {
handlers[name] = block
return this
}

/** Adds a reusable group of commands. See [BridgeHandlers]. */
fun add(handlers: BridgeHandlers): Bridge {
handlers.register(this)
return this
}

internal fun attach(webView: WebView) {
this.webView = webView
if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
WebViewCompat.addWebMessageListener(
webView, INTERFACE_NAME, setOf("*"),
) { _, message, _, isMainFrame, _ ->
if (!isMainFrame) {
Log.bridge.error("invoke message dropped: not from the main frame")
} else {
message.data?.let { dispatch(it) }
}
}
} else {
Log.bridge.warning(
"bridge not attached: this WebView does not supports WEB_MESSAGE_LISTENER " +
"(needs a modern WebView / Chrome 88+)",
)
}
}

/**
* Cancels in-flight dispatches and detaches from the WebView. Returned from
* [WebViewBundle.install]; call it when the WebView is destroyed.
*/
override fun close() {
scope.cancel()
webView = null
}

private fun dispatch(message: String) {
val json = runCatching { JSONObject(message) }.getOrNull() ?: run {
Log.bridge.error("invoke message dropped: malformed JSON")
return
}
val name = json.optString("name")
val success = json.optString("success")
val error = json.optString("error")
// No callback to reply through: the message is dropped and the web Promise
// hangs with no other signal, so log it loudly.
if (success.isEmpty() || error.isEmpty()) {
Log.bridge.error(
"invoke message dropped: missing success/error callback " +
"(command: ${name.ifEmpty { "<unknown>" }})",
)
return
}
val params = json.opt("params")?.takeUnless { it === JSONObject.NULL }
scope.launch {
val (callback, arg) = try {
val handler = handlers[name]
?: throw BridgeError(
code = "handler_not_found",
message = "no invoke handler registered for \"$name\"",
)
success to encodeValue(handler(params))
} catch (failure: Throwable) {
error to encodeError(failure)
}
reply(name, callback, arg)
}
}

private fun reply(name: String, callback: String, jsonArg: String) {
val webView = webView ?: return
val js = escapeForJs("$callback($jsonArg)")
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
webView.post {
runCatching { webView.evaluateJavascript(js, null) }
.onFailure {
// Usually benign (the page navigated), so a warning, not an error.
Log.bridge.warning("invoke reply eval failed (command: $name): ${it.message}")
}
}
}

internal companion object {
const val INTERFACE_NAME = "wvbAndroid"

// U+2028/U+2029 are valid in JSON strings but are JS line terminators that
// would break the `callback(<json>)` we evaluate, so escape them.
val LINE_SEPARATOR: String = Char(0x2028).toString()
val PARAGRAPH_SEPARATOR: String = Char(0x2029).toString()

fun encodeValue(value: Any?): String = when (value) {
null, JSONObject.NULL -> "null"
is JSONObject, is JSONArray -> value.toString()
is String -> JSONObject.quote(value)
is Boolean -> value.toString()
is Double -> if (value.isFinite()) value.toString() else "null"
is Float -> if (value.isFinite()) value.toString() else "null"
is Number -> value.toString()
is Map<*, *> -> JSONObject(value).toString()
is Collection<*> -> JSONArray(value).toString()
is Array<*> -> JSONArray(value.asList()).toString()
else -> throw BridgeError(
code = "unencodable_result",
message = "invoke handler returned a value that is not JSON-encodable: ${value::class.java.name}",
)
}

fun encodeError(error: Throwable): String {
val json = JSONObject().put("message", error.message ?: error.toString())
(error as? BridgeFailure)?.code?.let { json.put("code", it) }
return json.toString()
}

fun escapeForJs(js: String): String =
js.replace(LINE_SEPARATOR, "\\u2028").replace(PARAGRAPH_SEPARATOR, "\\u2029")
}
}

/** A reusable, self-contained group of `invoke()` commands, added via [Bridge.add]. */
interface BridgeHandlers {
/** Registers this group's commands on [bridge]. */
fun register(bridge: Bridge)
}
72 changes: 72 additions & 0 deletions lib/src/main/kotlin/dev/wvb/BridgeCodec.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package dev.wvb

import org.json.JSONObject

/** Decodes `invoke()` params into typed values. */
internal object BridgeCodec {
/** The params object, or an empty one when absent. */
fun params(raw: Any?): JSONObject = raw as? JSONObject ?: JSONObject()
}

/** A required string param; throws [BridgeError] (`invalid_params`) if missing. */
internal fun JSONObject.requireString(key: String): String {
if (!has(key) || isNull(key)) {
throw BridgeError(code = "invalid_params", message = "expected string param \"$key\"")
}
return getString(key)
}

/** An optional string param; `null` when absent or JSON `null`. */
internal fun JSONObject.optionalString(key: String): String? =
if (!has(key) || isNull(key)) null else getString(key)

// Response payloads. These mirror the JSON the web side consumes from the
// Electron bridge (`@wvb/node`): the source kind is keyed `type` on the wire
// though the FFI names it `kind`, and nullable fields are omitted (putOpt) to
// match the TS `?` optionals.

private fun BundleSourceKind.bridgeValue(): String = when (this) {
BundleSourceKind.BUILTIN -> "builtin"
BundleSourceKind.REMOTE -> "remote"
}

internal fun BundleManifestMetadata.toJson(): JSONObject =
JSONObject()
.putOpt("etag", etag)
.putOpt("integrity", integrity)
.putOpt("signature", signature)
.putOpt("lastModified", lastModified)

internal fun BundleSourceVersion.toJson(): JSONObject =
JSONObject().put("type", kind.bridgeValue()).put("version", version)

internal fun ListBundleItem.toJson(): JSONObject =
JSONObject()
.put("type", kind.bridgeValue())
.put("name", name)
.put("version", version)
.put("current", current)
.put("metadata", metadata.toJson())

internal fun ListRemoteBundleInfo.toJson(): JSONObject =
JSONObject().put("name", name).put("version", version)

internal fun RemoteBundleInfo.toJson(): JSONObject =
JSONObject()
.put("name", name)
.put("version", version)
.putOpt("etag", etag)
.putOpt("integrity", integrity)
.putOpt("signature", signature)
.putOpt("lastModified", lastModified)

internal fun BundleUpdateInfo.toJson(): JSONObject =
JSONObject()
.put("name", name)
.put("version", version)
.put("isAvailable", isAvailable)
.putOpt("localVersion", localVersion)
.putOpt("etag", etag)
.putOpt("integrity", integrity)
.putOpt("signature", signature)
.putOpt("lastModified", lastModified)
17 changes: 17 additions & 0 deletions lib/src/main/kotlin/dev/wvb/BridgeError.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package dev.wvb

/**
* A throwable that maps to the web-facing `{ code?, message }` bridge error
* shape. Implement it to deliver a `code` alongside the `message`; other thrown
* errors are encoded with their message and no `code`.
*/
interface BridgeFailure {
val code: String?
val message: String
}

/** The canonical `{ code?, message }` error thrown to reject an `invoke()` command. */
class BridgeError(
override val code: String? = null,
override val message: String,
) : RuntimeException(message), BridgeFailure
43 changes: 43 additions & 0 deletions lib/src/main/kotlin/dev/wvb/Log.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package dev.wvb

import android.util.Log as AndroidLog

/**
* Severity of an SDK log event; mirrors Rust `tracing` levels so core events can
* forward into the same pipeline (see [CoreLog]).
*/
internal enum class LogLevel { TRACE, DEBUG, INFO, WARNING, ERROR }

/** Unified logcat channels. Filter on the `webview-bundle.*` tags. */
internal object Log {
val bridge: Channel = Channel("bridge")
val core: Channel = Channel("core")

internal class Channel(category: String) {
private val tag = "${WebViewBundle.LOG_SUBSYSTEM}.$category"

fun error(message: String) = log(LogLevel.ERROR, message)

fun warning(message: String) = log(LogLevel.WARNING, message)

fun log(level: LogLevel, message: String) {
when (level) {
LogLevel.TRACE, LogLevel.DEBUG -> AndroidLog.d(tag, message)
LogLevel.INFO -> AndroidLog.i(tag, message)
LogLevel.WARNING -> AndroidLog.w(tag, message)
LogLevel.ERROR -> AndroidLog.e(tag, message)
}
}
}
}

/**
* Seam for forwarding the Rust core's `tracing` into the unified `core` channel.
* The level/route mapping is implemented; wiring it to an FFI log-subscriber
* callback is pending (the FFI exposes none yet).
*/
internal object CoreLog {
fun forward(level: LogLevel, target: String, message: String) {
Log.core.log(level, "[$target] $message")
}
}
Loading