-
Notifications
You must be signed in to change notification settings - Fork 0
Add native invoke bridge and slim down install() #2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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/*" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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>; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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)") | ||
| 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) | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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") | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.