ESC
Type to search...
S
Soli Docs

Native Bridge

Reach the shell your app is being viewed in. While the app is open, server-side Soli code raises a real OS notification with no push service, no certificates and no keys. For an app that is closed, this page also covers APNs, which needs all three.

An app packaged with soli desktop build, or wrapped in a WebView on a phone, renders inside an embedded web view — and neither WKWebView nor Android's WebView implements the Push API or the Notifications API. Both platforms reserve those for the browser proper. So an app that ships web push reaches browsers and installed PWAs, and silently reaches nothing at all inside its own native shell.

Native.notify("user:42", {
  "title": "New ping",
  "body":  "Ana replied to your comment",
  "url":   "/pings/3"
})

In a shell that raises a real OS notification. In a browser it raises a Web Notification. Where neither exists it does nothing.

Per feature

What reaches whom

The bridge covers the app-is-open case, and nothing else. That is a deliberate boundary rather than a shortcoming — it is what lets it work with no push service, no certificates and no keys — but you need the whole picture to choose transports:

ClientApp openApp closed
Browserbridge (or Web Notification)web push (VAPID)
Installed PWAbridgeweb push (VAPID)
macOS / iOS shellbridgeAPNs
Android shellbridgeFCM (sender in Soli; token via --fcm client)

The bridge is the only thing that reaches the two shell rows on the left, because an embedded web view has neither the Push API nor the Notifications API. Everything on the right needs a push service, because a closed app is not executing and something else has to be listening.

notify returns how many clients it reached, which makes the two compose without a branch on platform:

reached = Native.notify("user:#{str(user_id)}", payload)
if reached == 0
  Apns.send(device_token, payload, apns_options)   # or WebPush, for a browser
end

One case where the right-hand column is simply empty: a desktop app carrying its own database has nothing writing to it while closed, so there is nothing to announce. There the bridge is not a partial answer — it is the whole one.

Turning it on

One helper in your layout names the channel this page listens to:

<% user_id = session_get("user_id") %>
<%- native_channel("user:#{str(user_id)}") rescue "" unless user_id.nil? %>

That emits a <meta name="soli-native"> tag whose presence is what switches the bridge on. A page that never calls it downloads no script and opens no connection.

The channel travels as a signed token, not as plain text. Subscribing is a GET the browser makes, so an unsigned ?channel=user:42 would let anyone listen to anyone. The token is keyed by HKDF-SHA256 from SOLI_SESSION_SECRET (32+ characters, the same secret sealed cookies use, with its own domain-separating label), carries a 12-hour expiry, and is verified before any subscription is accepted. Rotating the secret invalidates outstanding tokens, exactly as it does sealed cookies.

API

CallReturnsNotes
Native.notify(channel, payload)IntClients reached. 0 means nobody has the app open.
Native.subscribers(channel)IntLive listeners, without sending anything.
Native.channel_token(channel)StringA raw token, for a client that is not a rendered page.
native_channel(channel)StringThe meta tag, for a view. The usual way in.

Payload keys the shells understand: title, body, url (opened on click), tag (a stable id, so an update replaces its predecessor rather than stacking), icon.

Channel names are yours — user:42, room:7, deploy:prod. They may not contain ., | or control characters, and are namespaced internally so they can never collide with, or be reached through, an app's own sse_broadcast topics.

Capabilities

A shell declares what it can do, and the page can branch on it without sniffing user agents:

window.soli.nativeBridge
// { available: true, platform: "android",
//   capabilities: ["notify", "geolocation", "vibrate", "share", "keep_awake",
//                  "print", "clipboard", "camera", "nfc", "biometric"] }

The two Notifications rows are unified by Push.deliver — one server-side call that reaches a user over the bridge if the app is open and over Web Push / APNs / FCM if it is closed, chosen by platform. The rows below are the transports it routes between; you rarely call them directly.

Two rules explain the shape of the rest of this table:

  1. Prefer the web API when the host already has one. getUserMedia needs no bridge call — it needs the shell to stop denying it, which is permission wiring rather than an API.
  2. Only embedded web views need a bridge at all. On Windows and Linux a soli desktop build artifact opens the user's real browser (chrome-less, but Chrome), so every web API is already there. There is nothing to bridge until you replace that browser with a native window.
CapabilityBrowser / PWAWindowsLinuxmacOS shelliOSAndroid shell
Notifications, app open✅ browser✅ browser✅ shipped✅ shipped✅ shipped
Notifications, app closed✅ web push✅ web push✅ web pushAPNsAPNsFCM sender*
Camera / microphone✅ browser✅ browser✅ shipped✅ shipped✅ shipped
Barcode / QR scan✅ native✅ native✅ native✅ decoder needed✅ decoder needed✅ native
File upload / capture✅ shipped
Clipboard✅ shipped
Geolocation✅ browser✅ browser✅ shipped✅ shipped✅ shipped
Motion sensors✅ mobile— no sensor— no sensor— no sensor✅ shipped✅ shipped
Vibration / haptics✅ Android✅ trackpad only✅ haptics✅ shipped
Deep links into the app🔜🔜✅ scheme✅ scheme✅ shipped
NFCChrome Android only✗ no hardware✅ Core NFC✅ shipped
Biometric unlock✅ WebAuthn✅ browser✅ browser✅ Touch ID✅ Face/Touch ID✅ shipped
Badge count✅ Badging API🔜🔜✅ dock tile✅ shipped✅ via notification
Share sheet✅ Web Share✅ browser🔜✅ shipped✅ shipped✅ shipped
Keep screen awake✅ Wake Lock✅ browser✅ browser✅ shipped✅ shipped✅ shipped
Printing✅ shipped✅ shipped

✅ shipped  ·  🔜 planned  ·  ✗ not possible on the platform  ·  “browser” = provided by the browser the artifact opens, not by a shell
* The sender ships; the Android app still needs the Firebase SDK to obtain a device token, which means a Gradle build.

The iOS column is the native shell (clients/ios), a UIKit + WKWebView app onto the remote deployment, like the Android one. It carries the full bridge — and because iOS has an arbitrary icon badge and Core NFC, it reaches two things the macOS shell cannot. Where you would rather not ship a native app, the iOS PWA is unusually strong on its own: home-screen web apps get push (16.4+), camera and geolocation with no app at all.

Where the shells stand

HostWindow todayNative shell
Windowsthe user's browser, chrome-lessnone — would be WebView2
Linuxthe user's browser, chrome-lessnone — would be WebKitGTK
macOSnative, frameless✅ AppKit + WKWebView
AndroidnativeWebView
iOS✅ UIKit + WKWebView

A Windows or Linux shell is a real option, not a missing feature: it buys a native frameless window and an icon, and costs you the web APIs the browser was providing for free. Both embed a web view that suppresses notifications by default — WebView2 raises NotificationReceived for the host to handle, WebKitGTK emits show-notification — so both would implement the same bridge contract the macOS and Android shells do, and both would need their own permission wiring for camera.

macOS and iOS are separate shells. WKWebView, WKScriptMessageHandler, UNUserNotificationCenter and LocalAuthentication are shared, so the bridge ports almost verbatim — but the window layer differs (AppKit NSWindow vs UIKit), so they are two targets: clients/macos (AppKit, hosts an embedded server) and clients/ios (UIKit, a WebView onto the remote deployment, since iOS forbids a bundled server). iOS additionally has an arbitrary icon badge and Core NFC that macOS lacks. APNs is platform-neutral: the same Apns.send reaches both.

Rows marked 🔜 are not implemented yet. A capability only appears in a shell's capabilities list once it actually works there, so feature-detection stays honest:

if (window.soli.nativeBridge.capabilities.includes("camera")) {
  // safe to offer the in-app scanner
}

Writing a shell

A shell injects an object the client script looks for. WebKit can define it at document start:

window.soli = window.soli || {};
window.soli.native = {
  platform: "macos",
  capabilities: ["notify"],
  notify: function (json) { window.webkit.messageHandlers.soliNative.postMessage(json); }
};

Android binds a Java object by name instead, which the script also accepts — addJavascriptInterface is how the platform injects, and evaluating a wrapper script early enough to dress it up races page load:

webView.addJavascriptInterface(new SoliNativeBridge(), "soliNativeHost");

Either way notify receives one JSON string.

Pages can branch on what the host supports without sniffing user agents: window.soli.nativeBridge reports { available, platform, capabilities }.

Connection behaviour

The client subscribes over SSE and reconnects with exponential backoff, up to 30 seconds. It drops the connection while the tab is hidden — an idle backgrounded stream costs the server a task for nothing — and reconnects when it returns. Thousands of idle subscribers cost async tasks, not worker threads.

Requirements

  • SOLI_SESSION_SECRET, 32+ characters. Without it native_channel raises rather than emitting an unsigned tag.
  • Nothing else. No push service, no keys, no certificates.