Docs

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 for Everywhere.* 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:

Code js
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.

app/javascript/controllers/notes_controller.js js
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.

app/javascript/controllers/notes_controller.js js
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.

app/javascript/application.js js
Everywhere.on("menu:settings", () => {
  Everywhere.visit("/settings")
})

It returns an unsubscribe function.

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.

app/javascript/application.js js
Everywhere.visit("/notes/new")

Two heavier moves sit alongside it, both mobile-only:

Code js
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.

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.

app/views/notes/index.html.erb erb
<%= 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:

Code erb
<%= form_with model: @note do |f| %>
  <%= everywhere_submit_button "Save" %>
  <!-- fields… -->
<% end %>

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.

Code erb
<%= 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.

Code erb
<%= 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:

Code js
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.

app/views/notes/index.html.erb erb
<%= 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 to public/ by every 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.

app/javascript/application.js js
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:

Code erb
<%= 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.

app/javascript/application.js js
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.

app/views/layouts/application.html.erb erb
<% 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

Code js
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.

app/javascript/application.js js
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.

config/everywhere.yml yaml
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:

Code js
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:

app/javascript/application.js js
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:

config/everywhere.yml yaml
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.

Code js
// 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:

app/views/app_settings/show.html.erb erb
<div data-everywhere-biometric-toggle-row hidden>
  <label>Require Face ID <%= everywhere_biometric_toggle %></label>
</div>

And a gate around anything sensitive:

app/views/settings/accounts/show.html.erb erb
<%= 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.

app/javascript/controllers/prefs_controller.js js
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.

Code js
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.

Code js
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:

Code js
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.

Code js
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:

config/everywhere.yml yaml
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.

app/javascript/controllers/picker_controller.js js
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.

Rails · Hanami · Sinatra — built with Ruby