v0.21

native bridge and plugins

introduction

the native bridge gives your javascript code access to device apis on ios, android, and desktop without leaving the web stack. everything is exposed through a single object, App.native, that works the same way on every platform.

each capability is a plugin. the framework ships six core plugins and lets you add as many of your own as you need. every plugin follows the same four file convention (one file per language), so a method you write once in javascript has matching implementations for swift, kotlin, and rust that you can fill in or skip.

this document covers both ends of that: the public App.native.* surface you call from your application code, and the four file layout you use when you want a capability to run natively on a platform.

the App.native facade

App.native is an object automatically attached to the global scope in both the browser and the native webview. each plugin is a property on that object, and each property exposes the plugin's methods directly.

1// every call is a promise
2const pub = await App.native.crypto.generate();
3await App.native.storage.set('theme', 'dark');
4const id = await App.native.notifications.show('pairing complete', 'tap to open', 'pairing-1');

how method calls resolve

when you call App.native.<plugin>.<method>(...), the bridge looks at the per plugin method map that the native runtime registered at boot:

this decision is per method, not per plugin. you can register audio.play as native (so it uses avplayer on ios) and leave audio.load unregistered (so it uses the html audio element) on the same platform if you want to mix approaches.

promises and errors

every method on App.native.* returns a promise. rejections carry a clear error message; methods that return a boolean (a successful storage write, for example) return false on failure rather than throwing. methods that cannot continue at all (notifications when the api is unavailable) throw.

use a single await in an async function for the simplest call:

1const granted = await App.native.notifications.requestPermission();
2if (granted !== 'granted') {
3 notify('enable notifications to receive pairing events', 'warning');
4 return;
5}

the auto registered core plugins

the framework registers six core plugins automatically. each one is documented in full below:

auth plugin

the auth plugin gives every device a stable session id and a small key value store for persisting your own data. the web implementation uses localStorage; the ios implementation uses UserDefaults; the android implementation uses a dedicated SharedPreferences file. on every platform, the session key is namespaced under dstrn.session.id and the key value store is namespaced under dstrn.db.<key>, so your own keys cannot collide with the session.

App.native.auth.setSession(id)

stores id as the current device session. coerces to a string. returns true on success, false if the underlying storage throws (for example, in private browsing mode where localStorage writes are blocked).

1await App.native.auth.setSession(loginResponse.token);

App.native.auth.getSession()

returns the stored session id as a string, or null if none was ever stored or if the read threw.

1const id = await App.native.auth.getSession();
2if (!id) redirect('/login');

App.native.auth.clearSession()

removes the stored session. returns true on success, false on a storage error.

1await App.native.auth.clearSession();
2redirect('/');

App.native.auth.db_get(key)

reads the value previously stored under dstrn.db.<key>. returns the raw string the caller stored, or null if the key was never set.

1const lastDeviceId = await App.native.auth.db_get('last_device_id');

App.native.auth.db_set(key, value)

stores value under dstrn.db.<key>. value is stored as is, so json strings survive a round trip without being unwrapped or re encoded. returns true on success, false on a storage error.

1await App.native.auth.db_set('last_device_id', deviceId);
2await App.native.auth.db_set('user_blob', JSON.stringify(user));

auth plugin notes

device plugin

the device plugin reports the platform and model of the current device. on ios it returns the device's UIDevice.current.name and model identifier; on android it returns Build.MANUFACTURER + Build.MODEL; on desktop it returns the hostname. on the web, the three methods return navigator.userAgent, navigator.platform, and the literal string 'web'.

App.native.device.name()

returns a human readable device name. on ios, this is the name set in Settings > General > About > Name (for example, "jane's iphone"). on android, the manufacturer and model joined (for example, "google pixel 8"). on desktop, the hostname. on the web, the user agent string. always returns a string; falls back to 'unknown' if the platform is unreachable.

1const name = await App.native.device.name();
2els.deviceBadge.textContent = name;

App.native.device.model()

returns a model identifier without the manufacturer: 'iPhone' or 'iPad' on ios, the model name on android, the hostname on desktop. on the web, navigator.platform. always returns a string; falls back to 'unknown'.

1const model = await App.native.device.model();

App.native.device.platform()

returns one of 'ios', 'android', 'desktop', or 'web'. this is the simplest way to branch behavior by runtime.

1const platform = await App.native.device.platform();
2if (platform === 'ios' || platform === 'android') {
3 // enable native only features
4}

storage plugin

a small, generic key value store for user preferences and ui state. on the web it is backed by localStorage; on ios by UserDefaults; on android by SharedPreferences; on desktop by a json file in the platform's application data directory. values are always strings — the plugin coerces non string values to strings on write and returns them unchanged on read.

the storage plugin and the auth db store are independent namespaces; use whichever fits the semantics of what you're storing.

App.native.storage.set(key, value)

stores value under key. returns true on success, false on failure. non string values are coerced via String(value), so numbers, booleans, and json strings all work as long as you read them back consistently.

1await App.native.storage.set('theme', 'dark');
2await App.native.storage.set('fontSize', 16);
3await App.native.storage.set('lastSeen', String(Date.now()));

App.native.storage.get(key)

returns the stored string, or null if the key was never set. to distinguish a missing key from an empty string, check for null explicitly.

1const theme = await App.native.storage.get('theme');
2if (theme === null) {
3 // first run
4}

App.native.storage.remove(key)

deletes the stored value. returns true if a value was removed, false otherwise (including when the key was already missing).

1await App.native.storage.remove('theme');

storage plugin notes

crypto plugin

the crypto plugin gives every device a stable ecdsa p 256 sha 256 keypair, plus helpers for signing, verifying, and digesting. every platform returns the same keys and signatures byte for byte, so a signature generated on ios verifies on android, the web, and desktop.

the keypair is persisted to a stable alias on every platform: com.dstrn.crypto.main_key in the ios keychain, dstrn_crypto_main_key in the android keystore, and the same alias in the web's indexeddb. calling generate() is idempotent — the first call creates and persists the keypair, every later call returns the same public key. the private key never leaves the secure store.

App.native.crypto.generate()

returns the device's public key as a base64 encoded raw uncompressed point (65 bytes, starting with 0x04) and the algorithm string 'ECDSA-P256-SHA256'. idempotent: calling generate() repeatedly always returns the same public key on the same device.

1const { publicKey, algorithm } = await App.native.crypto.generate();
2console.log(algorithm); // 'ECDSA-P256-SHA256'

the returned object is { publicKey: string, algorithm: string }.

App.native.crypto.sign(data)

signs data (base64 string) with the device's private key. returns { signature: string, algorithm: string } where signature is a base64 encoded der asn.1 signature. rejects with a clear error if the keypair is unavailable (which should not happen after a successful generate()) or if data is not a valid base64 string.

1const payload = btoa(JSON.stringify({ op: 'pair', device: deviceName }));
2const { signature } = await App.native.crypto.sign(payload);

the same payload and the same publicKey will verify on every platform.

App.native.crypto.verify(data, signature, publicKey)

verifies that signature is a valid signature of data under publicKey. all three arguments are base64 strings. returns true on success, false on failure (including malformed base64, malformed signatures, and tampered signatures).

1const ok = await App.native.crypto.verify(payload, signature, senderPublicKey);
2if (!ok) throw new Error('signature mismatch');

App.native.crypto.digest(data, algo?)

hashes data (base64 string) with the requested algorithm. algo defaults to 'SHA-256'. also accepts 'SHA-1' and any algorithm the underlying platform supports (the web and ios accept 'SHA-256' and 'SHA-1'; android accepts the same in dashed or undashed form). returns the hash as a base64 string.

1const hash = await App.native.crypto.digest(btoa('abc'), 'SHA-256');
2// hash === 'ungWv48Bz+pBQUDeXa4iI7ADYaOWG3Yr0FSxiyEyvg7gq9B3/L1lqg=='

App.native.crypto.export()

returns { publicKey, algorithm } for the device's current keypair without signing anything. useful when you only need to share your public identity with a peer. behaves identically to generate() but is shorter to call.

1const { publicKey } = await App.native.crypto.export();

App.native.crypto.import(publicKey)

imports a foreign public key into the local keystore so subsequent verify calls can use it without reimporting. returns true on success. on the web, this is a no op that just validates the key shape and resolves to true.

1await App.native.crypto.import(peerPublicKey);
2const ok = await App.native.crypto.verify(data, signature, peerPublicKey);

crypto plugin notes

audio plugin

single url audio playback with media session metadata, so the operating system can show title, artist, album, artwork, and playback controls on the lock screen and in the notification shade. a single shared player per page, so calling load() swaps the source. there is no queue and no multi url mode — one track at a time.

on ios the plugin uses AVPlayer and supports background playback and native now playing controls. on android it uses MediaPlayer plus MediaSessionCompat for the same effect. on the web and on desktop, the plugin falls back to HTMLAudioElement plus MediaSession, which is enough for in app playback and lock screen controls but cannot continue playing when the app is suspended.

App.native.audio.load(url)

loads url as the current source. only one url is supported; calling load() again replaces the previous track. returns true on success, false if the url is invalid or the audio element rejected.

1await App.native.audio.load(episodeUrl);
2await App.native.audio.play();

App.native.audio.play()

starts playback of the currently loaded track. returns true. if the browser blocks autoplay (no user gesture yet), the underlying promise rejection is swallowed and true is returned; you should still test for user gesture by listening to the user event yourself before calling play().

1button.addEventListener('click', async () => {
2 await App.native.audio.play();
3});

App.native.audio.pause()

pauses playback without resetting the current position. returns true.

App.native.audio.resume()

alias for play(). useful when the api is exposed as play/pause from the system media controls but your own code models it as paused/resumed.

App.native.audio.seek(seconds)

moves the playhead to seconds from the start of the track. accepts a number; fractional values are honored for frame accurate seeking. returns true if a track is loaded, false if no element exists yet (call load first).

1await App.native.audio.seek(120); // jump to two minutes in

App.native.audio.stop()

pauses playback and rewinds to the start of the track. returns true.

App.native.audio.setRate(rate, preservePitch?)

sets playback speed. rate is a number (0.5 for half speed, 2 for double, etc). preservePitch defaults to false: when false, the pitch tracks the rate; when true, the pitch is held constant and only the speed changes. returns true.

1await App.native.audio.setRate(1.5, true); // 1.5x speed, normal pitch

App.native.audio.setMetadata(title, artist, album, duration, artwork)

publishes metadata to the system media session so the lock screen and notification shade can show what's playing. all arguments are optional except title. duration is in seconds and must be a number. artwork is a url string. returns true on success, false if the platform has no media session support.

1await App.native.audio.setMetadata(
2 'episode 42: pairing flows',
3 'dframework weekly',
4 'season 2',
5 1820,
6 'https://cdn.example.com/art/episode-42.jpg'
7);

audio plugin notes

notifications plugin

local notifications with a stable id per notification so you can update or cancel them after they're shown. on the web and desktop the plugin uses window.Notification; on ios it uses UNUserNotificationCenter; on android it uses NotificationManagerCompat with a runtime permission request on android 13+.

App.native.notifications.requestPermission()

returns the current permission state as a string: 'granted', 'denied', or 'prompt'. if the state is 'prompt', the call will also prompt the user (which itself may resolve to 'granted' or 'denied'). on platforms without a notification api, returns 'denied'.

1const status = await App.native.notifications.requestPermission();
2if (status !== 'granted') {
3 return notify('enable notifications in settings', 'warning');
4}

App.native.notifications.show(title, body?, id?)

shows a local notification with title as the visible title and body as the visible body. id is optional; if you supply one, you can later cancel(id) to dismiss it. if you don't, the plugin generates an id like 'dstrn-0', 'dstrn-1', etc. and you cannot reliably cancel that specific one later.

returns the actual notification id used (your id, or the generated one). throws if the api is unavailable or if the permission has been denied.

1const id = await App.native.notifications.show(
2 'pairing complete',
3 'tap to open the dashboard',
4 'pairing-success-1'
5);

App.native.notifications.cancel(id)

dismisses the notification with the given id. returns true if the notification was found and dismissed, false if no notification with that id is known.

1await App.native.notifications.cancel('pairing-success-1');

notifications plugin notes

creating your own plugins

every plugin is a folder with up to four files, one per language. the framework auto discovers any folder under native/plugins/<name>/ in your project; there is no manifest, no schema, no registry, and no build wiring for you to edit. if the folder is there at build time, the plugin is shipped.

the four file convention

a plugin named barcode lives in native/plugins/barcode/:

1native/plugins/barcode/
2├── index.js # required. javascript reference impl. source of truth for method names.
3├── Plugin.swift # optional. ios native impl. must export the same method names.
4├── Plugin.kt # optional. android native impl. must export the same method names.
5└── Plugin.rs # optional. desktop native impl. must export the same method names.

three rules govern this layout:

IMPORTANT

`index.js` is mandatory for every plugin. it serves as the reference implementation, the source of truth for method names, and the automatic fallback whenever native implementations are absent or fail.

the javascript reference implementation

index.js is a plain module that exports a default object whose keys are method names and whose values are functions. the function body runs in the browser or webview and has access to all the normal browser globals: window, localStorage, navigator, fetch, crypto.subtle, MediaSession, Notification, Audio, and so on.

the methods run with the full power of the host environment. treat them like ordinary frontend javascript, just packaged into a plugin so the native side can override them per method per platform.

1// native/plugins/barcode/index.js
2export default {
3 scan() {
4 return { ok: false, error: 'not implemented on this platform' };
5 },
6};

scaffolding a plugin

the cli generates the four files with a working javascript impl and stub native files that you fill in:

1dstrn make:plugin barcode

this creates native/plugins/barcode/ with index.js, Plugin.swift, Plugin.kt, and Plugin.rs. after editing the methods you care about, the next dstrn simulate or dstrn build picks them up automatically.

writing the javascript source of truth

because the javascript impl is the fallback for any missing native file, design it to work everywhere and treat native impls as performance or capability upgrades on top of it. if a feature genuinely cannot work on the web (camera capture, for example), return a clear error from the javascript impl:

1export default {
2 async scan() {
3 if (typeof navigator === 'undefined' || !navigator.mediaDevices) {
4 throw new Error('barcode: camera unavailable in this environment');
5 }
6 const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } });
7 // ... decode the stream ...
8 stream.getTracks().forEach((t) => t.stop());
9 return { value: '...', format: 'qr_code' };
10 },
11};

the plugin then upgrades to native implementations on ios and android while keeping the web behavior intact.

ios implementation

the framework generates a Plugins.swift file in the ios runtime project during build that calls into your per plugin Plugin.swift. inside your Plugin.swift, follow this shape:

1import Foundation
2
3struct BarcodePlugin {
4 var bridge: Bridge?
5
6 func handle(method: String, params: [String: Any], callId: String) {
7 switch method {
8 case "scan":
9 scan(callId: callId)
10 default:
11 bridge?.reject(callId: callId, error: "barcode: unknown method \(method)")
12 }
13 }
14
15 private func scan(callId: String) {
16 // AVCaptureSession setup, delegate callbacks, etc.
17 // call bridge?.resolve(callId: callId, result: [...]) on success
18 // or bridge?.reject(callId: callId, error: "...") on failure
19 }
20}

the bridge will register barcode.scan as a native method at boot. the call returns whatever you pass to resolve.

android implementation

the framework generates a Plugins.kt dispatcher that routes each method to your Plugin.kt. inside your Plugin.kt:

1package com.dframework.native
2
3import android.content.Context
4import org.json.JSONObject
5
6class BarcodePlugin(private val context: Context, private val bridge: Bridge) {
7 fun handle(method: String, args: JSONObject, callId: String) {
8 when (method) {
9 "scan" -> scan(callId)
10 else -> bridge.reject(callId, "barcode: unknown method $method")
11 }
12 }
13
14 private fun scan(callId: String) {
15 // CameraX or ML Kit setup
16 // call bridge.resolve(callId, JSONObject().put("value", "...")) on success
17 // or bridge.reject(callId, "...") on failure
18 }
19}

desktop implementation

the framework generates a plugins.rs dispatcher. inside your Plugin.rs:

1use serde_json::{json, Value};
2
3pub fn dispatch(module: &str, method: &str, params: &Value) -> (bool, Value) {
4 match (module, method) {
5 ("barcode", "scan") => (true, json!({ "ok": false, "error": "no camera in desktop build" })),
6 _ => (false, json!(format!("barcode: unknown method {}", method))),
7 }
8}

desktop webviews can use most browser apis (fetch, crypto.subtle, localStorage, etc), so a desktop plugin typically exists only when you need tauri specific apis like notifications or filesystem access that the browser does not have. if you don't ship a desktop file, the bridge falls back to the javascript impl automatically.

fallback rules

deciding which implementation runs is per method per platform:

end to end example a barcode scanner

a complete minimal plugin that scans a barcode and returns its value, with a working web fallback and ios / android native stubs.

1// native/plugins/barcode/index.js
2export default {
3 async scan() {
4 if (typeof navigator === 'undefined' || !navigator.mediaDevices) {
5 throw new Error('barcode: camera unavailable');
6 }
7 const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } });
8 try {
9 // decoder logic, returns { value, format }
10 return { value: 'placeholder-decoded-value', format: 'qr_code' };
11 } finally {
12 stream.getTracks().forEach((t) => t.stop());
13 }
14 },
15};
1// native/plugins/barcode/Plugin.swift
2import AVFoundation
3
4struct BarcodePlugin {
5 var bridge: Bridge?
6
7 func handle(method: String, params: [String: Any], callId: String) {
8 switch method {
9 case "scan": scan(callId: callId)
10 default: bridge?.reject(callId: callId, error: "barcode: unknown method \(method)")
11 }
12 }
13
14 private func scan(callId: String) {
15 // AVCaptureMetadataOutput with .qr type, callback writes
16 // bridge?.resolve(callId: callId, result: ["value": "...", "format": "qr_code"])
17 }
18}
1// native/plugins/barcode/Plugin.kt
2package com.dframework.native
3
4import android.content.Context
5import org.json.JSONObject
6
7class BarcodePlugin(private val context: Context, private val bridge: Bridge) {
8 fun handle(method: String, args: JSONObject, callId: String) {
9 when (method) {
10 "scan" -> scan(callId)
11 else -> bridge.reject(callId, "barcode: unknown method $method")
12 }
13 }
14
15 private fun scan(callId: String) {
16 // CameraX + ML Kit, callback writes
17 // bridge.resolve(callId, JSONObject().put("value", "...").put("format", "qr_code"))
18 }
19}
1// native/plugins/barcode/Plugin.rs
2use serde_json::{json, Value};
3
4pub fn dispatch(module: &str, method: &str, params: &Value) -> (bool, Value) {
5 match (module, method) {
6 ("barcode", "scan") => (false, json!("barcode: camera not available on desktop")),
7 _ => (false, json!(format!("barcode: unknown method {}", method))),
8 }
9}

calling this plugin from your application:

1try {
2 const result = await App.native.barcode.scan();
3 console.log(result.value, result.format);
4} catch (e) {
5 notify('could not scan: ' + e.message, 'error');
6}

on ios and android, the native impl runs. on the web, the javascript impl runs. on desktop, the rust dispatch rejects with a clear error. one call site, four implementations.

troubleshooting

the bridge never resolves a call. check that the method is registered as native on the platform you're testing on. the framework ships dstrn native:status and dstrn native:doctor commands that report which methods are registered on each platform.

a method returns a different value across platforms. check that the json shape you return is the same on every platform. the bridge passes values through JSONSerialization on ios, JSONObject on android, and serde_json on desktop, so the wire format is the same; any difference is in the source language. compare the resolved value in your debugger.

a plugin folder is not picked up. make sure the folder name matches <name>/ and that index.js exists. dstrn native:doctor reports discovered plugins and flags any that are missing index.js.

native call times out. the default timeout is thirty seconds, configurable via native.timeout in config/native.js. if your impl needs longer (large file uploads, network bound work), raise the timeout or break the work into smaller calls.