Skip to content

Commit ba55c25

Browse files
committed
feat: WebSocket transport — replace base64 bridge for native (#17)
Products running in a WebView/WKWebView now connect to the Rust core through the localhost WebSocket bridge instead of a base64-over- JavascriptInterface (Android) / base64-over-WKScriptMessageHandler (iOS) shim. The Rust ws_bridge already existed; this commit adds the matching JS client and strips the obsolete native transport. @parity/truapi: - New `createWebSocketProvider(url, opts?)` producing a `Provider` over a binary WebSocket. Connects to the localhost endpoint the native shell prints via `startWsBridge`. android/TrUAPIHost.kt: - Drop `WebViewTransport`, `CoreInbound`, `bootstrapScript`, and all base64/JavascriptInterface plumbing. - `TrUAPIHostCore` keeps its `receiveFromProduct` entrypoint for tests and alternative transports; the WS bridge feeds the core internally. ios/TrUAPIHost.swift: - Drop `WebViewTransport` (`WKScriptMessageHandler` + base64 path) and `CoreInbound` protocol. Drop the `WebKit` import. - Keep `LocalhostBridgeBootstrap` — its purpose is exactly to publish the WS endpoint to the product page so it can call `createWebSocketProvider(url)`. READMEs for both native shells now describe the WS-bridge flow: 1. Host calls `core.startWsBridge()` → gets port + token. 2. Inject the endpoint into the product page (Android: query string; iOS: `LocalhostBridgeBootstrap.script()` as a WKUserScript). 3. Product JS reads the URL and passes it to `createWebSocketProvider`. @parity/truapi build + tests pass with the new export.
1 parent 0b532f8 commit ba55c25

7 files changed

Lines changed: 248 additions & 281 deletions

File tree

android/README.md

Lines changed: 22 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# TrUAPI Android host adapter
22

3-
*Thin Kotlin shell over the Rust TrUAPI core (UniFFI) plus an Android `WebView` byte transport. Wire decoding, request routing, and subscription lifecycle stay in the Rust core.*
3+
*Thin Kotlin shell over the Rust TrUAPI core (UniFFI). Wire decoding, request routing, and subscription lifecycle stay in the Rust core; products connect through the localhost WebSocket bridge.*
44

55
This directory is an Android library module: include it from a parent project's `settings.gradle.kts` (e.g. `include(":truapi-android"); project(":truapi-android").projectDir = file("vendor/truapi/android")`). It does not ship with its own Gradle wrapper or root settings — pulling it into a consuming project supplies those.
66

@@ -9,36 +9,24 @@ This directory is an Android library module: include it from a parent project's
99
The public surface lives in [`src/main/kotlin/io/parity/truapi/TrUAPIHost.kt`](src/main/kotlin/io/parity/truapi/TrUAPIHost.kt):
1010

1111
- `HostBridge` - callback bundle the embedding app implements. Split into device permissions, remote permissions, navigation, push, feature support, and scoped storage.
12-
- `TrUAPIHostCore` - owning wrapper around the UniFFI-generated `NativeTrUApiCore`. Implements `CoreInbound`, owns the bridge lifetime, exposes session and WS bridge controls.
13-
- `WebViewTransport` - base64-over-`JavascriptInterface` byte pipe between a `WebView` and any `CoreInbound`. Injects a `window.trUApi` shim that matches the JS host adapter shape.
14-
- `bootstrapScript` - the JS shim, exposed so apps can inject it through their own WebView bootstrap path.
12+
- `HostStorage` - simple read/write/clear interface the host backs with its own persistence.
13+
- `TrUAPIHostCore` - owning wrapper around the UniFFI-generated `NativeTrUApiCore`. Holds the bridge alive for the lifetime of the core, exposes session controls and the localhost WebSocket bridge.
1514

1615
The generated UniFFI bindings live under `src/main/kotlin/generated/uniffi/truapi_server/`. They are committed (they're large and consumers should not need a Rust toolchain).
1716

1817
## Architecture
1918

2019
```text
2120
product app in WebView
22-
Uint8Array frames via window.trUApi
21+
Uint8Array frames via @parity/truapi createWebSocketProvider
2322
|
24-
v
25-
WebViewTransport
26-
base64 over Android JS bridge
27-
|
28-
v
29-
TrUAPIHostCore (CoreInbound)
30-
→ uniffi → libtruapi_server.so
23+
v ws://127.0.0.1:<port>/?t=<token>
24+
TrUAPIHostCore.startWsBridge()
25+
→ libtruapi_server.so (tokio WS server)
26+
→ Rust dispatcher
3127
```
3228

33-
Inbound flow:
34-
35-
1. Product JS calls `window.trUApi.postMessage(bytes)`
36-
2. `WebViewTransport` receives base64 through `@JavascriptInterface`
37-
3. `TrUAPIHostCore.receiveFromProduct(...)` forwards bytes into the Rust dispatcher
38-
4. The Rust core emits a response frame; `HostBridge.onCoreResponse(...)` fires
39-
5. The embedder typically pumps the response back through `WebViewTransport.sendToProduct(...)`, which calls `window.__trUApiReceive(...)`
40-
41-
The Rust core also calls `HostBridge` directly for platform capabilities: `navigateTo`, `pushNotification`, `devicePermission`, `remotePermission`, `featureSupported`, and the `storage` slot.
29+
The product running in the `WebView` opens a `WebSocket` to the localhost port + token returned by `startWsBridge`. From there the Rust core handles the wire protocol directly. Outbound responses and host-side capability callbacks (`navigateTo`, `pushNotification`, `devicePermission`, `remotePermission`, `featureSupported`, `storage`) reach the embedder through `HostBridge`.
4230

4331
## Permissions split
4432

@@ -56,7 +44,6 @@ import android.webkit.WebView
5644
import io.parity.truapi.HostBridge
5745
import io.parity.truapi.HostStorage
5846
import io.parity.truapi.TrUAPIHostCore
59-
import io.parity.truapi.WebViewTransport
6047
import uniffi.truapi_server.HostNavigateRejection
6148
import uniffi.truapi_server.HostRejection
6249

@@ -67,27 +54,27 @@ class MyStorage : HostStorage {
6754
override fun clear(key: String) { map.remove(key) }
6855
}
6956

70-
class MyBridge(private val transport: WebViewTransport) : HostBridge {
57+
class MyBridge : HostBridge {
7158
override val storage = MyStorage()
72-
override fun onCoreResponse(frame: ByteArray) = transport.sendToProduct(frame)
59+
override fun onCoreResponse(frame: ByteArray) { /* not used in WS-bridge mode */ }
7360
override fun navigateTo(url: String) { /* open in browser */ }
7461
override fun pushNotification(payload: ByteArray) { /* show notification */ }
7562
override fun devicePermission(request: ByteArray): Boolean = TODO("prompt user")
7663
override fun remotePermission(request: ByteArray): Boolean = TODO("prompt user")
7764
override fun featureSupported(request: ByteArray): Boolean = false
7865
}
7966

67+
val core = TrUAPIHostCore(MyBridge())
68+
val endpoint = core.startWsBridge()
69+
val wsUrl = "ws://127.0.0.1:${endpoint.port.toInt()}/?t=${endpoint.token}"
70+
71+
// Inject `wsUrl` into the product page (e.g. as a query string or via an
72+
// initial WKUserScript). Product JS uses `@parity/truapi`'s
73+
// `createWebSocketProvider(wsUrl)` to open the wire.
8074
val webView: WebView = existingWebView
81-
lateinit var transport: WebViewTransport
82-
val bridge = MyBridge(transport = WebViewTransport(webView, core = object : io.parity.truapi.CoreInbound {
83-
override fun receiveFromProduct(frame: ByteArray) = core.receiveFromProduct(frame)
84-
}).also { transport = it })
85-
val core = TrUAPIHostCore(bridge)
86-
transport.attach()
75+
webView.loadUrl("https://your-product.example/?truapi=${java.net.URLEncoder.encode(wsUrl, "UTF-8")}")
8776
```
8877

89-
(In practice, build the `TrUAPIHostCore` first, hand it to a `WebViewTransport`, and have the bridge close back over the same transport instance.)
90-
9178
## Loading the cdylib
9279

9380
JNA looks for `libtruapi_server.so` in the standard `jniLibs` paths. Bundle the per-ABI builds under:
@@ -98,7 +85,7 @@ src/main/jniLibs/armeabi-v7a/libtruapi_server.so
9885
src/main/jniLibs/x86_64/libtruapi_server.so
9986
```
10087

101-
Build the cdylib with the `ws-bridge` feature if you want `startWsBridge` to be functional:
88+
Build the cdylib with the `ws-bridge` feature so `startWsBridge` is functional:
10289

10390
```
10491
cargo build -p truapi-server --release --features ws-bridge --target <android-target>
@@ -115,3 +102,5 @@ cargo run -p uniffi-bindgen-cli -- generate \
115102
--language kotlin \
116103
--out-dir android/src/main/kotlin/generated
117104
```
105+
106+
Or run `make uniffi` from the repo root.

android/build.gradle.kts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
// TrUAPI Android host adapter.
22
//
33
// Wraps the UniFFI-generated bindings in `src/main/kotlin/generated/uniffi/`
4-
// behind a thin Kotlin API and provides a WebView byte transport for the
5-
// product page. The Rust core (compiled to libtruapi_server.so) handles all
6-
// wire decoding, routing, subscription lifecycle, and host capability
7-
// dispatch.
4+
// behind a thin Kotlin API. Products running in a `WebView` connect to the
5+
// Rust core through its localhost WebSocket bridge (see
6+
// `TrUAPIHostCore.startWsBridge`); the Rust core (compiled to
7+
// `libtruapi_server.so`) handles wire decoding, routing, subscription
8+
// lifecycle, and host capability dispatch.
89

910
plugins {
1011
id("com.android.library")

android/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt

Lines changed: 33 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -5,24 +5,24 @@
55
// wire protocol, request routing, subscription lifecycle, and platform trait
66
// dispatch.
77
//
8-
// This file exposes two things on top of the generated bindings:
8+
// This file exposes:
99
//
10-
// * `HostBridge` - a Kotlin-friendly callback interface the embedding app
10+
// * `HostBridge` - the Kotlin-friendly callback interface the embedding app
1111
// implements. It splits device and remote permissions, mirroring the
1212
// `Permissions` platform trait in the Rust core.
13-
// * `WebViewTransport` - a thin byte transport that forwards opaque wire
14-
// bytes between a `WebView` and the Rust core. Bytes traverse the
15-
// `JavascriptInterface` boundary as base64 because the bridge cannot
16-
// carry binary types directly.
13+
// * `TrUAPIHostCore` - owning wrapper around the UniFFI-generated
14+
// `NativeTrUApiCore`. Holds the bridge alive for the lifetime of the
15+
// core and exposes session + WS-bridge controls.
1716
//
18-
// The transport is independent of the core: tests can stand up a
19-
// `WebViewTransport` against a non-UniFFI stub by implementing `CoreInbound`.
17+
// Products running inside a `WebView` connect to the Rust core via the
18+
// localhost WebSocket bridge. Start it with `core.startWsBridge()` and load
19+
// the product page with the resulting `ws://127.0.0.1:<port>/?t=<token>` URL
20+
// passed through to the product's `@parity/truapi` `createWebSocketProvider`
21+
// call. The base64 `JavascriptInterface` shim previously living here has
22+
// been removed.
2023

2124
package io.parity.truapi
2225

23-
import android.util.Base64
24-
import android.webkit.JavascriptInterface
25-
import android.webkit.WebView
2626
import uniffi.truapi_server.HostCallbacks
2727
import uniffi.truapi_server.HostNavigateRejection
2828
import uniffi.truapi_server.HostRejection
@@ -137,27 +137,28 @@ private class HostCallbackAdapter(private val bridge: HostBridge) : HostCallback
137137
}
138138

139139
/**
140-
* Sink for opaque wire frames coming from the WebView. The Rust core is the
141-
* typical implementor (via [TrUAPIHostCore]); tests may use a stub.
142-
*/
143-
fun interface CoreInbound {
144-
fun receiveFromProduct(frame: ByteArray)
145-
}
146-
147-
/**
148-
* Owning wrapper around the Rust-backed [NativeTrUApiCore]. Implements
149-
* [CoreInbound] so a [WebViewTransport] can deliver inbound frames directly,
150-
* and exposes session and WS bridge controls.
140+
* Owning wrapper around the Rust-backed [NativeTrUApiCore]. Holds the bridge
141+
* alive for the lifetime of the core and exposes session + WS-bridge
142+
* controls.
151143
*
152-
* The wrapper holds a strong reference to the bridge so the JNA callback
153-
* registration stays alive for the lifetime of the core.
144+
* Hosts integrating with a `WebView`-based product call [startWsBridge] and
145+
* pass the resulting `ws://127.0.0.1:<port>/?t=<token>` URL to the product
146+
* (typically via a query string or page-bootstrap hook). The product wires
147+
* that URL into `@parity/truapi`'s `createWebSocketProvider`. Direct
148+
* [receiveFromProduct] calls are still available for tests or alternative
149+
* transports.
154150
*/
155-
class TrUAPIHostCore(bridge: HostBridge) : CoreInbound, AutoCloseable {
151+
class TrUAPIHostCore(bridge: HostBridge) : AutoCloseable {
156152
@Suppress("unused") // retained to keep JNA callbacks alive
157153
private val callbackRetainer: HostCallbacks = HostCallbackAdapter(bridge)
158154
private val inner: NativeTrUApiCore = NativeTrUApiCore(callbackRetainer)
159155

160-
override fun receiveFromProduct(frame: ByteArray): Unit {
156+
/**
157+
* Deliver an opaque SCALE-encoded wire frame into the Rust core. The WS
158+
* bridge feeds the core internally; this entrypoint is exposed for tests
159+
* and alternative transports.
160+
*/
161+
fun receiveFromProduct(frame: ByteArray) {
161162
inner.receiveFromProduct(frame)
162163
}
163164

@@ -173,7 +174,12 @@ class TrUAPIHostCore(bridge: HostBridge) : CoreInbound, AutoCloseable {
173174
inner.clearActiveSession()
174175
}
175176

176-
/** Start the localhost WebSocket bridge (requires the `ws-bridge` feature in the cdylib). */
177+
/**
178+
* Start the localhost WebSocket bridge (requires the `ws-bridge` feature
179+
* in the cdylib). The returned [WsBridgeEndpoint] carries the port and
180+
* session token; build a `ws://127.0.0.1:<port>/?t=<token>` URL and pass
181+
* it to the product's `createWebSocketProvider` call.
182+
*/
177183
@Throws(WsBridgeStartException::class)
178184
fun startWsBridge(bindPort: UShort = 0u): WsBridgeEndpoint =
179185
inner.startWsBridge(bindPort)
@@ -191,77 +197,3 @@ class TrUAPIHostCore(bridge: HostBridge) : CoreInbound, AutoCloseable {
191197
inner.close()
192198
}
193199
}
194-
195-
/**
196-
* Wraps a [WebView] and forwards opaque wire bytes between JS and [core].
197-
* Attach with [attach] before loading the page so the JS shim is installed.
198-
*/
199-
class WebViewTransport(
200-
private val webView: WebView,
201-
private val core: CoreInbound,
202-
private val callbackName: String = "__trUApiReceive",
203-
private val interfaceName: String = "TrUApi",
204-
) {
205-
fun attach() {
206-
webView.addJavascriptInterface(JsInterface(), interfaceName)
207-
}
208-
209-
fun detach() {
210-
webView.removeJavascriptInterface(interfaceName)
211-
}
212-
213-
/**
214-
* JS bootstrap to inject at document start so the page exposes a
215-
* `window.trUApi` byte-pipe matching the JS host adapter shape.
216-
*/
217-
val bootstrapScript: String = """
218-
(function() {
219-
var listeners = [];
220-
window.$callbackName = function(b64) {
221-
try {
222-
var bin = atob(b64);
223-
var bytes = new Uint8Array(bin.length);
224-
for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
225-
listeners.forEach(function(l) { l(bytes); });
226-
} catch (e) { console.error('trUApi recv error', e); }
227-
};
228-
function toB64(u8) {
229-
var s = '';
230-
for (var i = 0; i < u8.length; i++) s += String.fromCharCode(u8[i]);
231-
return btoa(s);
232-
}
233-
window.trUApi = {
234-
postMessage: function(bytes) {
235-
window.$interfaceName.postMessage(toB64(bytes));
236-
},
237-
subscribe: function(cb) {
238-
listeners.push(cb);
239-
return function() {
240-
var i = listeners.indexOf(cb);
241-
if (i >= 0) listeners.splice(i, 1);
242-
};
243-
}
244-
};
245-
window.dispatchEvent(new Event('truapi-native-ready'));
246-
})();
247-
""".trimIndent()
248-
249-
/** Called when the core has bytes to push to the product app. */
250-
fun sendToProduct(frame: ByteArray) {
251-
val b64 = Base64.encodeToString(frame, Base64.NO_WRAP)
252-
val js = "window.$callbackName && window.$callbackName('$b64')"
253-
webView.post { webView.evaluateJavascript(js, null) }
254-
}
255-
256-
private inner class JsInterface {
257-
@JavascriptInterface
258-
fun postMessage(b64: String) {
259-
val frame = try {
260-
Base64.decode(b64, Base64.NO_WRAP)
261-
} catch (_: IllegalArgumentException) {
262-
return
263-
}
264-
core.receiveFromProduct(frame)
265-
}
266-
}
267-
}

0 commit comments

Comments
 (0)