The Bridge API
The bridge is the seam between your app's JavaScript and the platform it runs
on. It is the @rubyeverywhere/bridge package, vendored and pinned by
every install, exposed as a global Everywhere object. The same call resolves
to a native API on desktop, native components on iOS and Android, and a web API
in a plain browser tab.
This page applies to every RubyEverywhere app, whether you build it yourself with the gem CLI or on the Platform.
Never reference
__TAURI__directly. Reach forEverywhere.*instead. That one indirection is what lets a single codebase run as a desktop app, a mobile app, and a plain website with no platform branches.
Knowing where you are
Four read-only properties, set before your code runs:
Everywhere.platform // "desktop" | "mobile" | "browser"
Everywhere.os // "macos" | "ios" | "android" | "windows" | "linux" | …
Everywhere.native // true in any shell, false in a browser tab
Everywhere.version // the app's version from everywhere.yml, or null in a browser
Most of the time you should not need them. Every call below already does the right thing on every platform. Reach for these when you want to show or hide something, not to pick an implementation.
Notifications
Everywhere.notify shows a native OS notification on desktop, a local
notification on mobile, and falls back to the Web Notifications API in a
browser.
Everywhere.notify({ title: "Saved", body: "Your note is safe." })
Confirm dialogs
Everywhere.confirm returns a promise resolving to true or false. A native
dialog in the app, window.confirm in a browser.
async destroy() {
if (await Everywhere.confirm("Delete this note?")) {
this.element.requestSubmit()
}
}
Events
Everywhere.on subscribes to bridge events. Native menu and tray items emit
events your app can react to.
Everywhere.on("menu:settings", () => {
Everywhere.visit("/settings")
})
It returns an unsubscribe function.
Navigation
Everywhere.visit navigates the app window to a route. It is what native
menus and tray items use, and you can call it
from any handler.
Everywhere.visit("/notes/new")
Two heavier moves sit alongside it, both mobile-only:
Everywhere.reloadTabs() // refetch the tab bar from the live config
Everywhere.resetApp("/home") // fresh web views, refetched tabs, land on a path
resetApp is what sign-in and sign-out want. In Rails,
everywhere_auth_redirect does it for you.
Native chrome: nav bar, menus, action sheets, FAB
Tag ordinary markup and the mobile shell lifts it into real native controls: a
navigation-bar button, a pull-down or overflow menu, an action sheet. Tapping
the native control calls .click() on the element it mirrors, so a link
navigates, a submit submits, and a button fires its handler. Behavior is defined
once, in the DOM, and the same markup is plain HTML in a browser. No JavaScript,
no Stimulus, so it clears CSP.
Rails ships helpers that emit the contract. Elsewhere, write the
data-everywhere-* attributes by hand.
Navigation-bar buttons
A link, or a submit button, that rides in the top navigation bar. Icons are
per-platform, like tabs: icons: { ios:, android: } name an SF Symbol and a
Material icon, and a bare icon: is the shared fallback.
<%= everywhere_nav_button "New", new_note_path, icons: { ios: "plus", android: "add" } %>
Inside a <form>, everywhere_submit_button puts the Save button in the nav
bar and submits the form when tapped:
<%= form_with model: @note do |f| %>
<%= everywhere_submit_button "Save" %>
<!-- fields… -->
<% end %>
Menus and overflow menus
everywhere_nav_menu becomes a native pull-down attached to a nav-bar button,
the ⋯ overflow by default. Each everywhere_menu_item is a normal link or
button. style: "destructive" tints it; method: makes a Turbo method link.
<%= everywhere_nav_menu do %>
<%= everywhere_menu_item "Share", share_path, icons: { ios: "square.and.arrow.up" } %>
<%= everywhere_menu_item "Delete", note_path(@note), method: :delete, style: "destructive" %>
<% end %>
Action sheets
everywhere_menu is an in-content trigger plus the items it opens: a native
action sheet in the app, an inline menu in a browser.
<%= everywhere_menu "Post options" do %>
<%= everywhere_menu_item "Edit", edit_post_path(@post) %>
<%= everywhere_menu_item "Delete", post_path(@post), method: :delete, style: "destructive" %>
<% end %>
Prefer to drive it from code? Everywhere.menu resolves to the chosen item, or
null on cancel:
const choice = await Everywhere.menu({
title: "Post",
items: [{ id: "share", title: "Share" },
{ id: "delete", title: "Delete", style: "destructive" }]
})
if (choice?.id === "delete") { /* … */ }
Floating action button
everywhere_fab is a fixed, safe-area-aware circular button. It is a real link,
so it shows in a browser too, and it gains a tap haptic in the app. Pass icon:
for a built-in glyph or a block for your own content; extended: true adds a
label pill.
<%= everywhere_fab new_note_path, icon: :plus, label: "New note" %>
Include the stylesheet. The FAB, the action-sheet fallback, and the safe-area utilities live in
everywhere/native.css:<%= stylesheet_link_tag "everywhere/native" %>in Rails. Sinatra and Hanami get it vendored topublic/byevery install.
Haptics
Everywhere.haptics plays tactile feedback in the mobile shell, real
UIFeedbackGenerator haptics on iOS. Browsers get the Vibration API where one
exists, on Android. Everywhere else the calls do nothing, so you can use them
freely.
Everywhere.haptics.impact("light") // light | medium | heavy | soft | rigid
Everywhere.haptics.notification("error") // success | warning | error
Everywhere.haptics.selection()
The common case needs no JavaScript. Tag any clickable element and the bridge plays the haptic on tap:
<%= form.submit "Save changes", data: { everywhere_haptic: "impact:light" } %>
<%= button_to "Sign out", session_path, method: :delete,
data: { everywhere_haptic: "impact:heavy" } %>
The attribute takes "impact:heavy", "notification:error", "selection", or
a bare style like "light", which is short for an impact.
Badges
Everywhere.badge drives the app-icon badge and, on mobile, per-tab badges on
the native tab bar. Tabs are keyed by their everywhere.yml path.
Everywhere.badge.set(3) // app icon
Everywhere.badge.clear()
Everywhere.badge.setTab("/inbox", 12) // native tab bar
Everywhere.badge.clearTab("/inbox")
Usually you will not call these at all. Render the counts server-side with the Rails helpers and the bridge applies them on every Turbo visit: CSP-safe, no inline JavaScript, correct on the first paint.
<% if native_app? %>
<%= everywhere_badge Current.user.unread_count %>
<%= everywhere_tab_badge "/inbox", Current.user.unread_count %>
<% end %>
A count of 0 clears the badge. In browsers, everywhere_badge falls back to
the PWA Badging API for installed PWAs; tab badges are mobile-only.
A badge sticks until something changes it, so render the helpers on every page
that can carry one, signed out pages included, as 0. A page that omits the
meta tag leaves the previous count in place. The shell clears badges itself on a
reset, so a sign-out through everywhere_auth_redirect or leaving an instance
is already covered. The 0 is for pages a reset never runs through, like a cold
launch into an expired session.
Clipboard
await Everywhere.clipboard.write("hello")
const text = await Everywhere.clipboard.read()
The desktop shell uses the native clipboard. Browsers and the mobile web view use the async Clipboard API.
Permissions
Everywhere.permissions checks and requests native permissions: notifications,
camera, and location.
const { status } = await Everywhere.permissions.query("camera")
// "granted" | "denied" | "prompt" | "undeclared" | "unsupported"
if (status === "prompt") {
const result = await Everywhere.permissions.request("camera")
}
Two rules keep this safe:
Declared only. A mobile app can request only what its everywhere.yml
declares, and for camera and location the declaration is the sentence iOS
shows in the prompt. Write a real reason: the build fails without one. An app
that declares nothing can never ask, and request() resolves
{status: "undeclared"} without prompting.
permissions:
notifications: true # no usage string needed
camera: "Scan QR codes to pair devices." # shown in the iOS prompt
location: "Find build agents near you."
Denied is final. iOS asks once. After a denial, request() keeps resolving
"denied" with no prompt. Offer the system settings instead:
if (status === "denied") {
// e.g. behind an "Enable camera access in Settings" button
Everywhere.permissions.openSettings()
}
In browsers, query uses the web Permissions API and request triggers each
capability's own prompt (Notification permission, getUserMedia, geolocation).
On desktop the calls resolve "unsupported".
Biometrics
Everywhere.biometrics shows the Face ID or Touch ID sheet from your web code,
to gate a sensitive screen or confirm a destructive action:
const { available, biometry } = await Everywhere.biometrics.available()
// biometry: "faceID" | "touchID" | "opticID" | "none"
if (available) {
const { authenticated } = await Everywhere.biometrics.authenticate({
reason: "Unlock your account" // the sentence in the system sheet
})
if (authenticated) revealTheThing()
}
Pass allowPasscode: true to let the device passcode satisfy the check when
biometrics fail or are not enrolled. On failure the result carries an error:
"canceled", "lockout", "notEnrolled", "notAvailable",
"passcodeNotSet", "fallback", or "failed".
Like camera and location, biometrics is declared only. The declaration is the
Face ID usage string iOS shows on first use, and undeclared calls resolve
{error: "undeclared"} without touching the system APIs:
permissions:
biometrics: "Unlock your account with Face ID."
One caveat worth stating plainly: a passed check proves presence to the page,
in the moment. Your server cannot verify it. Keep real session authentication
for anything the backend must trust, and treat biometrics as a local gate on
top. In browsers and the desktop shell the calls resolve {available: false}
and {error: "unsupported"}, so gate on available() and fall back to your
normal flow.
Sign in with Face ID
Everywhere.biometrics.credential stores one server-issued secret in the device
keychain, locked behind biometrics: the storage half of a real "sign in with
Face ID" flow. Your app owns the other half.
// After password sign-in (enrollment): mint a device token server-side…
const { token } = await (await fetch("/biometric_credential", { method: "POST", headers })).json()
await Everywhere.biometrics.credential.store(token)
// On the login page: reveal the button if a credential is enrolled…
const { enrolled } = await Everywhere.biometrics.credential.status() // no prompt
// …and on tap, the Face ID sheet releases the token for a normal form POST:
const { token } = await Everywhere.biometrics.credential.get({ reason: "Sign in" })
// → POST /session/biometric exchanges it for a session
Server rules that keep this safe: issue a long random token and store only its
digest, since it is a password equivalent, revocable, shown once, never logged;
exchange it through your normal session machinery with the same rate limiting as
password login. On the device, iOS invalidates the keychain item whenever
biometric enrollment changes, so a stale credential resolves
{error: "notEnrolled"}. Hide the button and fall back to a password. clear()
removes the item; do it alongside a server-side revoke when the user turns the
feature off.
The biometric lock, declaratively
Most apps never need those calls. The bridge ships a device-local "require biometrics" preference and two Rails helpers around it. A settings switch, hidden everywhere except the mobile shell with working biometrics:
<div data-everywhere-biometric-toggle-row hidden>
<label>Require Face ID <%= everywhere_biometric_toggle %></label>
</div>
And a gate around anything sensitive:
<%= everywhere_biometric_lock reason: "Unlock account settings" do %>
...profile, password, sessions...
<% end %>
Flipping the switch runs the biometric check first, in both directions, so a passer-by cannot quietly disable the lock. Once it is on, every gated block keeps its content hidden until Face ID or Touch ID passes: prompted on page load, retryable through the overlay's unlock button, remembered for the app session, and re-hidden before Turbo caches the page. Browsers and the desktop shell render the content plainly.
The same preference is scriptable as Everywhere.biometrics.lockEnabled and
setLockEnabled(bool), and the helpers emit a plain data-attribute contract
(data-everywhere-biometric-lock, -content, -locked, -unlock, -toggle)
you can hand-write for a custom overlay. Keep sign-out outside the gate:
locking someone out of their own escape hatch is never right.
Device storage
Everywhere.storage is a small promise-based key/value store for device-local
settings, the kind the server should not own: a collapsed sidebar, a preferred
sort order, "don't show this again". Any JSON value round-trips, and get
resolves null for a missing key.
await Everywhere.storage.set("sidebar", { collapsed: true })
const prefs = await Everywhere.storage.get("sidebar") // { collapsed: true }
await Everywhere.storage.remove("sidebar")
await Everywhere.storage.clear() // app keys only
In the mobile shell, values live in the app's UserDefaults, so they survive web
view resets, cache clears, and Everywhere.resetApp(), which localStorage does
not. Browsers and the desktop shell fall back to namespaced localStorage.
Settings, not secrets. Server tokens belong in the keychain through
Everywhere.biometrics.credential; sessions belong in cookies.
Third-party sign-in
Everywhere.auth.signIn(path) starts a provider flow in the system browser,
where Google and friends actually work. Use it when your sign-in is not a link
the shell can see: a fetch, a custom element, a button with other work to do
first.
Everywhere.auth.signIn("/auth/github")
Plain links and buttons to paths matching auth.oauth_paths are diverted
automatically. The whole flow, including the OmniAuth 2 initializer line it
needs, is on the
iOS page.
Desktop window controls
For apps that draw their own title bar. Set window.title_bar: overlay or
frameless in everywhere.yml;
frameless is the one that needs these, since the page then owns dragging and
the buttons.
Everywhere.window.supported // true in the desktop shell
Everywhere.window.minimize()
Everywhere.window.toggleMaximize()
Everywhere.window.close()
await Everywhere.window.isMaximized()
Everywhere.window.startDragging() // from a pointerdown handler
Everything resolves to null in a browser tab and on mobile, where the OS owns
the frame, so the same markup is safe everywhere.
Native Rust on the desktop
Functions you declared under native.desktop.commands are reachable from the
page:
Everywhere.desktop.supported // true in the desktop shell
const ports = await Everywhere.desktop.invoke("scan_ports", { baud: 9600 })
See Native extensions.
Self-updates
Desktop only, and live only when updates.url and updates.public_key both
made it into the build.
Everywhere.updates.supported // true when the shell has a feed
Everywhere.updates.version // the running version
Everywhere.updates.channel // what the shell checks now
await Everywhere.updates.check()
await Everywhere.updates.setChannel("beta") // persisted across relaunches
Everywhere.updates.install() // downloads, verifies, restarts
Everywhere.updates.on("progress", handler) // also "ready" and "error"
See App updates.
Multi-instance apps
Some apps are not one site: users pick a server. One hosted platform, many
instances, the way Mastodon or a self-hosted product works. Opt in with
remote.instances: true in everywhere.yml and
point remote.url at your hosted instance picker page:
remote:
url: https://accounts.example.com/pick # your hosted picker
instances: true
The shell boots into the picker until that page calls set() with the chosen
instance's root. The shell validates the URL, saves it, and resets onto the
instance. Every later launch boots straight there until clear() returns to the
picker.
Everywhere.instance.supported // true in a shell built with remote.instances
Everywhere.instance.current // active instance URL, or null
Everywhere.instance.set("https://acme.example.com") // persist + reset
Everywhere.instance.set(url, { to: "/welcome" }) // land somewhere specific
Everywhere.instance.clear() // back to the picker
Sessions stay separate per instance, since cookies are per-domain, and
everything derived from the app's root (entry path, tabs, path rules) follows
the picked instance. In a plain browser, set() navigates to the URL: going
there is what picking an instance means on the web.
While re-rooted, the mobile shells keep an escape hatch. On screens where the
tab bar is hidden or absent, meaning modal pages or an instance serving no tabs,
the toolbar carries a "back to start" action that does what
Everywhere.instance.clear() does. That matters more than it sounds: an
instance's whole signed-out surface can be modal, such as a /session/new
behind a /new$ rule, which would otherwise strand the user the moment they
sign out. An app that wants no shell chrome opts out with
remote.instances_escape: false, and then the instance's own pages are the only
way back.
Instance switching is opt-in because it lets page JavaScript re-root the whole app. Apps that are not multi-instance should not carry that surface.
The whole surface
| Call | Mobile (iOS/Android) | Desktop | Browser |
|---|---|---|---|
Everywhere.platform / .os / .native / .version |
✓ | ✓ | ✓ |
Everywhere.notify(opts) |
Native local notification | Native OS notification | Web Notifications API |
Everywhere.confirm(msg) |
Native alert | Native dialog | window.confirm |
Everywhere.on(event, fn) |
Custom events | Menu and tray events | Custom events |
Everywhere.visit(path) |
Turbo visit | Navigate the window | Turbo.visit / location |
Everywhere.reloadTabs() / resetApp() |
Refetch tabs, reset web views | no-op | no-op |
Everywhere.menu(opts) |
Native action sheet | DOM bottom sheet | DOM bottom sheet |
Native chrome (data-everywhere-nav-*) |
Nav bar buttons, menus, sheets | Plain HTML | Plain HTML |
Everywhere.haptics.* |
Real haptics | no-op | Vibration API (Android) |
Everywhere.badge.* |
App icon and tab bar badges | no-op | Badging API (installed PWAs) |
Everywhere.clipboard.* |
Clipboard API | Native clipboard | Clipboard API |
Everywhere.permissions.* |
Native prompts, declared only | unsupported |
Permissions API and native prompts |
Everywhere.biometrics.* |
Face ID / Touch ID | unsupported |
unsupported |
Everywhere.storage.* |
UserDefaults | localStorage | localStorage |
Everywhere.instance.* |
Persisted re-root | set() navigates |
set() navigates |
Everywhere.auth.signIn(path) |
System browser | Navigates | Navigates |
Everywhere.window.* |
null |
Window controls | null |
Everywhere.desktop.invoke(name) |
unsupported |
Your Rust command | unsupported |
Everywhere.updates.* |
unsupported |
Signed self-update | unsupported |
Because the surface is the same everywhere, your app code never needs to know which platform it is running on. Configure the native chrome that drives these events in everywhere.yml.