A Tauri v2 plugin that bridges a WebView-hosted call (e.g. a MatrixRTC call in Sable) to a native LiveKit audio room on Android and iOS. The room (connection, tracks, reconnections, encoding) lives in the native plugin (LiveKit Kotlin/Swift SDK, plus an Android foreground service so audio survives backgrounding). The native side is the single source of truth for room state; the Rust crate is a thin transport that validates basic input, forwards time-bounded invocations, and delivers native snapshots and events to the owning webview.
Breaking: 0.2.0 replaces the lifecycle-only
getPlatformCallCapabilities/startPlatformCallLifecycle/stopPlatformCallLifecycle/getPlatformCallStateAPI with the native call bridge below. There is no compatibility shim.
The plugin does not:
- mint, refresh, validate, log, echo, or persist LiveKit access tokens;
- provide MatrixRTC or any other signaling, focus negotiation, or transport beyond the LiveKit room itself;
- run the LiveKit
Room, WebRTC, or any media in the WebView; - touch screen capture;
- run background JavaScript.
The host application owns everything around the room. For each call it must:
- pick the LiveKit focus/server and obtain an access token (JWT) from its MatrixRTC LiveKit auth service, with a TTL that covers the call;
- call
connectNativeCallwith{ callId, url, token, microphoneEnabled }, choosing an opaquecallId(e.g. the Matrix call id); - refresh or rotate tokens out-of-band; if a token expires mid-call the native bridge reports a bounded failure and the host disconnects and reconnects with a fresh token.
The token crosses the bridge only inside the connect payload. Rust-side
Debug implementations redact it, and neither native plugin logs or
re-emits it.
| Platform | Support |
|---|---|
| Android | Yes |
| iOS | Yes |
| Desktop (any) | No. getNativeCallCapabilities reports supported: false, nativeRoom: false, camera: false, nativeVideoOverlay: false; room commands fail with unavailable |
NativeCallCapabilities.camera advertises per platform whether the native
lane accepts the camera commands (setNativeCallCameraEnabled /
switchNativeCallCamera).
NativeCallCapabilities.nativeVideoOverlay advertises whether the native lane
accepts remote-video overlay placement commands.
The crate is not yet published to crates.io; use a Git or path dependency.
[dependencies]
tauri-plugin-livekit-mobile = { git = "https://github.com/SableClient/tauri-plugin-livekit-mobile.git" }This plugin ships no JavaScript package: call the commands with
invoke('plugin:livekit-mobile|<command>') from @tauri-apps/api/core, and
subscribe to snapshots with addPluginListener.
Register the plugin in src-tauri/src/lib.rs (or main.rs):
tauri::Builder::default()
.plugin(tauri_plugin_livekit_mobile::init())Tauri v2 registers the Android Kotlin and iOS Swift sources declared by this
crate (build.rs: android_path("android"), ios_path("ios")). On iOS the
plugin is bound via ios_plugin_binding!(init_plugin_livekit_mobile); rebuild
the mobile scaffold (tauri android init / tauri ios init) after adding the
dependency.
Allow the default permission set in src-tauri/capabilities/default.json:
{
"permissions": ["livekit-mobile:default"]
}This grants getNativeCallCapabilities, connectNativeCall,
disconnectNativeCall, setNativeCallMicrophoneEnabled,
setNativeCallCameraEnabled, switchNativeCallCamera, and
setNativeCallRemoteVideoOverlay, clearNativeCallRemoteVideoOverlay,
setNativeCallEncryptionKey, and getNativeCallState.
The plugin's library manifest merges the required permissions
(RECORD_AUDIO, FOREGROUND_SERVICE*, POST_NOTIFICATIONS) and declares
the foreground service. No manifest edits are required in the host app.
RECORD_AUDIO is a runtime permission and is requested by the plugin when
connecting with microphoneEnabled: true (or when enabling the microphone
mid-call) if it is not granted yet.
While a call is active, the foreground service shows a single ongoing,
low-importance notification on the stable channel livekit_mobile_audio
(created once at service creation). The notification uses the host
application's launcher icon and label; there is no configuration API for its
content.
CAMERA is a runtime permission; the plugin requests it when enabling the
camera without a grant. Declining the prompt rejects
setNativeCallCameraEnabled with the bounded permission_denied code and
surfaces the same code in snapshot lastError.
On Android 13 (API 33) and above, POST_NOTIFICATIONS is a runtime
permission. Requesting it is the host app's responsibility (the plugin
declares the permission in its manifest but never asks at runtime). Without
the grant, the foreground-service notice is absent from the notification
drawer but remains visible in Task Manager; service behavior is unaffected.
Add to the host app's Info.plist:
NSMicrophoneUsageDescription: required when connecting withmicrophoneEnabled: trueor turning the microphone on mid-call; the plugin requests record permission before enabling the microphone.UIBackgroundModescontainingaudio: required for room audio to continue while the app is backgrounded.
The host app must also bundle LiveKit's two binary frameworks. They are
dynamic libraries loaded at @rpath, so unlike the rest of the LiveKit
SDK they cannot be absorbed into this plugin's static archive; nothing in
Tauri's generated Xcode project embeds them on your behalf. Without them the
app fails at launch with dyld: Library not loaded: @rpath/LiveKitWebRTC.framework/LiveKitWebRTC.
Download the versions pinned by ios/Package.resolved and list them in
tauri.conf.json. Any .xcframework entry is treated as a vendor framework
and gets embedded and signed into the bundle:
{
"bundle": {
"iOS": {
"frameworks": [
"frameworks/LiveKitWebRTC.xcframework",
"frameworks/RustLiveKitUniFFI.xcframework"
]
}
}
}Paths are relative to the Tauri directory (src-tauri). Recreate the iOS
project after changing this list. The matching release archives are
LiveKitWebRTC and
RustLiveKitUniFFI.
import {
getNativeCallCapabilities,
connectNativeCall,
disconnectNativeCall,
setNativeCallMicrophoneEnabled,
setNativeCallCameraEnabled,
switchNativeCallCamera,
setNativeCallRemoteVideoOverlay,
clearNativeCallRemoteVideoOverlay,
getNativeCallState,
listenNativeCallSnapshot,
} from './livekitMobileBridge';
const capabilities = await getNativeCallCapabilities();
if (!capabilities.supported || !capabilities.nativeRoom) {
// Desktop or unsupported platform: there is no native room.
}
const unlisten = await listenNativeCallSnapshot((snapshot) => {
// Every event is one full NativeCallSnapshot with a native-owned revision.
});
// Every command resolves with the same NativeCallSnapshot shape.
const state = await connectNativeCall({
callId: 'mxc-call-id',
url: 'wss://livekit.example',
token: livekitJwt,
microphoneEnabled: true,
});
await setNativeCallMicrophoneEnabled({ callId: 'mxc-call-id', enabled: false });
if (capabilities.camera) {
await setNativeCallCameraEnabled({ callId: 'mxc-call-id', enabled: true });
await switchNativeCallCamera({ callId: 'mxc-call-id' });
}
if (capabilities.nativeVideoOverlay) {
await setNativeCallRemoteVideoOverlay({
callId: 'mxc-call-id',
participantIdentity: '@alice:example.org',
trackId: 'TR_abcdef',
x: 0,
y: 0,
width: 320,
height: 180,
devicePixelRatio: window.devicePixelRatio,
});
}
// On call end:
await disconnectNativeCall({ callId: 'mxc-call-id' });
await unlisten();-
The native side owns the room. Connection state, idempotency (repeated disconnects), busy handling (a second connect while one is active) and stale-call rejection are all decided natively. Rust never predicts, mirrors or replays room state: it validates basic input, forwards invocations and returns or forwards native snapshots.
-
Every command (
connectNativeCall,disconnectNativeCall,setNativeCallMicrophoneEnabled,setNativeCallCameraEnabled,switchNativeCallCamera,setNativeCallRemoteVideoOverlay,clearNativeCallRemoteVideoOverlay,setNativeCallEncryptionKey,getNativeCallState) resolves with the authoritativeNativeCallSnapshot:{ revision: number; // native-owned, bumped on every change callId: string | null; connectionState: 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'failed'; microphoneEnabled: boolean; cameraEnabled: boolean; participantCount: number; remoteParticipants: Array<{ identity: string; // opaque LiveKit/MatrixRTC backend identity camera?: { sid: string; muted: boolean; subscribed: boolean }; }>; lastError?: { code: NativeCallFailureCode; message: string }; }
revisionpasses through the bridge untouched (Rust never creates one), so consumers can drop out-of-order snapshots by comparing revisions. -
setNativeCallRemoteVideoOverlayforwards a stateless placement for one remote track:{ callId, participantIdentity, trackId, x, y, width, height, devicePixelRatio }. Every value must be finite;width,height, anddevicePixelRatiomust be strictly positive.xandymay be negative for partially offscreen DOM rectangles. Rust applies no coordinate or size caps; native viewports own clipping and caps.clearNativeCallRemoteVideoOverlayremoves the call's native remote-video overlay. The native side owns rendering and any overlay state; neither is represented in snapshots. -
remoteParticipantsis a remote-only projection sized for rendering one remote-video tile per remote participant:identityis the opaque backend identity, andcamerais present only while that participant has a remote camera publication (sidis its LiveKit track id). The bridge passes the roster through untouched; it keeps no roster state of its own. Missing keys on older natives decode as an empty list. -
Every native invocation is time-bounded (60s for connect, 30s for the rest) so a hung native call cannot wedge the bridge. An elapsed bound surfaces as an error with code
timeout. A timed-out connect is not dropped: the bridge internally cancels the pending connect on the native side, reconciles withgetNativeCallState, keeps event delivery alive until the native snapshot is known, and resolves with that reconciled snapshot (ortimeoutif the native side cannot be reached at all). The channel is never merely abandoned, so a room that survives cancellation is never orphaned from its events.
The bridge is key-agnostic: it validates and forwards shared key material
({ identity, keyIndex, key }, with key the raw bytes base64-encoded) but
never mints, stores, logs, or echoes it, and keys never appear in snapshots
or events. No key byte length is enforced; key sizes are the native side's
decision.
- Initial keys ride on
connectNativeCallas optionalencryptionKeys. The native side installs them beforeroom.connect; omitted or empty means an ordinary unencrypted LiveKit call. setNativeCallEncryptionKey({ callId, identity, keyIndex, key })installs or rotates a key mid-call (Android:setNativeCallEncryptionKey, iOS:setEncryptionKey) and resolves with the currentNativeCallSnapshot.
Blank callId/identity, or key material that does not base64-decode to
nonempty bytes, rejects with invalid_request.
connectNativeCall records the calling webview as the call's owner. Snapshot
events are emitted on plugin:livekit-mobile://native-call-event targeted
at the owner webview only (EventTarget::webview(label)): there is no
global broadcast, so parallel webviews never receive another call's events. A
listenNativeCallSnapshot in any other webview stays silent.
If the owner webview is destroyed without calling disconnectNativeCall
while the native room survives, the room is orphaned: its owner label points
at a dead context. The first webview to call getNativeCallState while such
an otherwise unowned room is reported live becomes its new owner and receives
subsequent snapshots.
Limitations to be aware of:
- Ownership is tracked by webview label (the only stable identity Tauri command arguments expose); if an app runs two webviews sharing one label, both are the target. Keep webview labels unique.
- If the owner webview is destroyed without calling
disconnectNativeCall, the native room keeps running until the native side tears it down,getNativeCallStateis used to take over, or the next connect replaces it.
The native side emits one channel event shape, { event: "snapshot_changed", snapshot }, and the bridge forwards the snapshot payload to the owner
webview. There are no separate participant, state or failure event kinds:
connection changes, participant counts, microphone/camera flips and failures
(via lastError) all surface as snapshots.
Command rejections serialize as { code, message }. Codes come from one
bounded vocabulary shared with snapshot lastError: invalid_request,
busy, permission_denied, connect_failed, media_failed,
disconnected, cancelled, unavailable, unexpected, plus the
bridge-level timeout. Messages are static strings derived from the code;
raw native error strings and platform-specific codes are folded into this
vocabulary at the boundary and never forwarded to JavaScript.
AGPL-3.0-only. See LICENSE.