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
Native clients
soli generate client — iOS / Android / Linux / Windows shells.
Device registration
soli generate devices, registerDevice, prune helpers.
Notifications
Push.deliver — bridge if open, VAPID / APNs / FCM if closed.
Deep Links
AppLinks + soli generate app_links.
Camera & Microphone
getUserMedia, once the shell stops denying it silently.
Barcode & QR
Scan loop; WebKit auto-loads optional jsQR.
Offline mobile
soli generate offline — outbox, not local SolidB.
Platform limits
Background location, IAP, widgets — honest stubs.
Apple Push (APNs)
Apns.send reaches a closed macOS or iOS app.
Android Push (FCM)
Fcm.send + generate client android --fcm for tokens.
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:
| Client | App open | App closed |
|---|---|---|
| Browser | bridge (or Web Notification) | web push (VAPID) |
| Installed PWA | bridge | web push (VAPID) |
| macOS / iOS shell | bridge | APNs |
| Android shell | bridge | FCM (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
| Call | Returns | Notes |
|---|---|---|
Native.notify(channel, payload) | Int | Clients reached. 0 means nobody has the app open. |
Native.subscribers(channel) | Int | Live listeners, without sending anything. |
Native.channel_token(channel) | String | A raw token, for a client that is not a rendered page. |
native_channel(channel) | String | The 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:
- Prefer the web API when the host already has one.
getUserMedianeeds no bridge call — it needs the shell to stop denying it, which is permission wiring rather than an API. - Only embedded web views need a bridge at all. On Windows and Linux a
soli desktop buildartifact 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.
| Capability | Browser / PWA | Windows | Linux | macOS shell | iOS | Android shell |
|---|---|---|---|---|---|---|
| Notifications, app open | ✅ | ✅ browser | ✅ browser | ✅ shipped | ✅ shipped | ✅ shipped |
| Notifications, app closed | ✅ web push | ✅ web push | ✅ web push | ✅ APNs | ✅ APNs | ✅ FCM 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 |
| NFC | Chrome 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
| Host | Window today | Native shell |
|---|---|---|
| Windows | the user's browser, chrome-less | none — would be WebView2 |
| Linux | the user's browser, chrome-less | none — would be WebKitGTK |
| macOS | native, frameless | ✅ AppKit + WKWebView |
| Android | native | ✅ WebView |
| 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 itnative_channelraises rather than emitting an unsigned tag.- Nothing else. No push service, no keys, no certificates.