RubyEverywhere

Ship desktop and mobile apps from the Ruby web apps you already know how to build.

rubyeverywhere.com · by Andrea Fomera

RubyEverywhere takes a Rails (or Sinatra, or Hanami) app and gives it a native life on the platforms your users are already on:

  • Desktop — a Tauri-based shell. In local mode your whole app is pressed into a single-file binary with Tebako and runs on-device, no Ruby install required; in remote mode the shell is a thin native wrapper around your deployed app.
  • iOS — a Hotwire Native shell with a real tab bar, nav-bar buttons, menus, action sheets, notifications, haptics, biometrics, deep links, and native sign-in.
  • Android — a feature-for-feature Hotwire Native Android port of the same shell, stamped from the same config/everywhere.yml.

One config file, one CLI, one JavaScript API. The web app stays the source of truth — most of what you'd normally rebuild natively (tabs, routes, menus, badges) deploys with your Rails app rather than through an app-store review.

Install

gem install ruby_everywhere

This gives you two equivalent executables — every and rbe — plus the packaging toolchain (the rbe-tebako fork) as a dependency.

Or add it to your app's Gemfile, which also wires up the Rails engine (bridge asset serving, importmap pin, view helpers, the live mobile config endpoint):

gem "ruby_everywhere"

Requires Ruby >= 3.2.

The gem is free, and it always will be. Everything here — the CLI, the desktop shell, both mobile shells, the bridge — is MIT licensed and yours to use, forever, with no account and no key. Local builds run on a Mac today (pressing, code signing, Xcode and the Android SDK all want a macOS host). If you don't own one, or you'd rather not keep a build toolchain healthy, the RubyEverywhere Platform is a paid service coming soon that runs the exact same builds for you — see Hosted builds below. It's a convenience, never a gate: nothing in this gem is held back for it.

Quick start

cd path/to/your/app

every install          # remote mode: a native shell around your deployed app
every install --local  # local mode: compile and ship the app itself

every doctor           # check the toolchain before your first build

every dev              # dev server + native shells on demand, live reload
every dev --ios        # ...straight into the iOS Simulator
every dev --android    # ...straight into the Android emulator
every dev --mobile     # ...both at once, built in parallel

every build            # package the macOS .app
every build --ios      # build the iOS shell
every build --android  # build the APK/AAB

every install is idempotent — it adds config/everywhere.yml, a boot stub, and the framework-specific config tweaks it needs, and skips anything already in place. For Rails it does the surgery for you; for Sinatra and Hanami it vendors the bridge and prints the remaining wiring rather than guessing at your boot files.

every dev never packages anything: it runs your ordinary dev server and points the native shells at it, so the edit-refresh loop is untouched Rails.

It keeps a dock pinned to the bottom of the terminal showing what each target is doing, while the logs scroll normally above it — ordinary scrollback, so you can still scroll up and search a backtrace:

web  │ Started GET "/notes" for 127.0.0.1
ios  │ ▸ compiling Swift sources
────────────────────────────────────────────────────────────────
● web :3000   ○ desktop   ⠙ iOS 42s   ✗ Android see dist/android-build.log
⌨ d desktop · i iOS · a Android · b browser · l logs · r restart · ? help · q quit

Every target runs on its own thread, so builds happen in parallel and the keys keep working while one is going. A build that fails marks that target red and leaves everything else — the dev server included — running; fix the error and press the key again. r restarts just the dev server, ? expands the full key list, and each line is tagged with the source that printed it once more than one thing is talking.

Set EVERY_NO_DOCK=1 (or pipe the output anywhere) to get plain, unadorned lines instead.

Configuration

Everything per-app lives in config/everywhere.yml. The native shell templates are frozen — the CLI stamps a copy per app and only ever writes the identity, config, icon, and color files, so a shell upgrade never collides with your app.

app:
  name: My Really Awesome App
  bundle_id: com.example.myapp     # set it once; renaming moves users' data
  version: 1.2.0
  entry_path: /dashboard           # where the app opens
  icon: icon.png

appearance:
  tint_color: "#CC342D"
  background_color:                # one hex string, or split by system theme
    light: "#FAF9F7"
    dark: "#1C1B1A"

remote:
  url: https://app.example.com     # remote mode: the deployed app to wrap

window:                            # desktop window chrome
  title_bar: overlay               # decorated (default) | overlay | frameless
  title: false                     # draw the title text
  size: [1100, 750]
  min_size: [600, 400]
  drag_height: 28                  # top strip that drags the window; 0 to opt out

tabs:                              # the native mobile tab bar
  - name: Builds
    path: /builds
    icons:
      ios: hammer                  # SF Symbol
      android: build               # Material icon

permissions:                       # only declared permissions can ever prompt
  notifications: true
  camera: "Scan QR codes to pair devices."   # the sentence iOS shows

rules:                             # Hotwire Native path configuration
  - patterns: ["/preferences$"]
    properties:
      context: modal

native:                            # code compiled into the native shells
  desktop:
    commands: [scan_ports]         # Rust in native/desktop/, callable from the page
    crates:
      serialport: "4.3"

build:
  ruby: "4.0.6"
  targets: [macos-arm64, ios-arm64, android-arm64]

updates:                           # self-hosted, signed auto-updates
  url: https://updates.example.com
  public_key: RWQ…
  auto: check                      # off | check | download | install

Also supported: auth: (native OAuth sign-in with a callback scheme), deep_linking: (universal links / App Links — the gem serves the apple-app-site-association and assetlinks.json files for you), and native: for "supernative" extensions — Swift you keep in your app repo under native/ios/ that every build compiles into the shell, including SwiftUI screens, bridge components, a custom splash, and pinned SPM packages.

Desktop has the same seam in native/desktop/, shaped for what a desktop app actually wants: not native screens (the whole UI is a webview) but the machine underneath it. Drop in Rust, pin the crates you need, and call it from the page:

native:
  desktop:
    commands: [scan_ports]
    setup: true              # run native/desktop/setup.rs at boot
    crates:
      serialport: "4.3"
// native/desktop/serial.rs
pub fn scan_ports(_app: &tauri::AppHandle, payload: serde_json::Value)
    -> Result<serde_json::Value, String> { … }
const ports = await Everywhere.desktop.invoke("scan_ports", { baud: 9600 })

Declaring anything under native.desktop has a real cost the mobile sections don't: the desktop shell stops being one prebuilt binary shared by every app and gets compiled per app, so the first build takes minutes instead of seconds. Apps that declare nothing are untouched.

Images the desktop shell itself draws live in native/desktop/assets/, using the same <name>[@2x|@3x][~dark].png filenames the mobile assets folders use. Name one tray (or tray-template for a macOS template image) and it becomes the menu-bar icon; a custom splash: can reference the rest as assets/logo.png. These are for the shell, not your pages — images your HTML references should keep coming from the asset pipeline.

window: shapes the desktop frame. title_bar: overlay is the macOS look where the traffic lights float over your page and there's no title text — pair it with the .everywhere-titlebar-inset class from everywhere/native.css, which reserves exactly the space they sit in. frameless removes the system chrome altogether; the page then draws its own bar and drives the buttons through Everywhere.window. Windows and Linux have no overlay style, so overlay behaves as frameless there.

Either way the window stays draggable with no markup: the shell makes the top 28px of the page drag it (and double-click zoom it), while buttons and links up there keep their clicks. window.drag_height: 0 hands that back to you.

Tabs and path rules ship two ways from that one source: baked into the app bundle for offline/first launch, and served live from /everywhere/{ios,android}_v1.json, so changing them deploys with your web app. Everywhere.filter_tabs lets you vary the tab bar per request — it shares the app's session, so signed-out users can get a different bar without a release.

The JavaScript bridge

The gem ships @rubyeverywhere/bridge — one API for the browser, the desktop shell, and the mobile shells. Rails apps get it served and importmap-pinned automatically; Sinatra and Hanami get it vendored to public/bridge.js.

import Everywhere from "@rubyeverywhere/bridge"

Everywhere.platform          // "desktop" | "mobile" | "browser"
Everywhere.native            // true inside a RubyEverywhere app

Everywhere.notify({ title, body })
Everywhere.menu({ title, items })
Everywhere.haptics.impact("medium")
Everywhere.badge.setTab("/inbox", 3)
Everywhere.permissions.request("camera")
Everywhere.storage.set("theme", "dark")
Everywhere.updates.check()
Everywhere.window.close()    // desktop, for a page-drawn title bar

Everything degrades gracefully — the same page works in a plain browser tab and gains native powers inside the app. See bridge/README.md for the full surface.

Most native chrome needs no JavaScript at all. Tag ordinary markup and the bridge lifts it into real native controls; tapping the native control just .click()s the element it mirrors, so behavior stays defined once in the DOM. Rails ships helpers that emit exactly that markup:

<%= everywhere_nav_button "New", new_note_path, icons: { ios: "plus", android: "add" } %>
<%= everywhere_fab new_note_path %>
<%= everywhere_nav_menu "More" do %>
  <%= everywhere_menu_item "Delete", note_path(@note), method: :delete, style: "destructive" %>
<% end %>

Plus native_app?, native_platform, everywhere_badge, everywhere_biometric_lock, and friends.

Commands

Command What it does
every install Prepare an app (boot stub, config/everywhere.yml, config tweaks)
every doctor Check the toolchain — Tebako, Rust, brew deps, Xcode, Android SDK/JDK
every dev Dev server with native shells on demand (--desktop --ios --android --mobile --browser)
every logs Stream shell logs from the Simulator or Android device
every build Package natively — desktop, --ios, or --android
every icon Generate .icns / .ico / Linux icons from a source PNG
every release Sign, notarize, staple, and emit a release.json receipt
every updates keygen Generate a minisign-compatible update-signing keypair
every publish Publish a release to your update bucket
every clean Remove the shell build caches
every shell-dir Print the path of the bundled shell template
every platform … Hosted builds: login, logout, auth status, build

Run every with no arguments for the full list, or every <command> --help for a command's flags.

Auto-updates

Desktop releases can update themselves from a bucket you own, with no service in the middle:

every updates keygen                  # once, per app
every release                         # build, sign, notarize, staple
every publish --notes-file NOTES.md   # sign the manifest, upload, flip latest.json

The public key ships in the app; the secret key never leaves your machine. Apps can check, install, and switch channels at runtime via Everywhere.updates.

Hosted builds

Don't have a Mac, or want iOS and Android built without babysitting Xcode, the Android SDK, and a signing setup? The RubyEverywhere Platform — a paid service, coming soon — runs the same builds on managed machines and hands back the artifacts. The CLI already speaks to it:

every platform login
every platform build --target ios-arm64,android-arm64

Same gem, same config/everywhere.yml, same output — you're paying for the build machines and the toolchain upkeep, not for features. The gem stays free and complete on its own.

Project layout

lib/everywhere/          the CLI, config, builders, and Rails engine
exe/every, exe/rbe       the executables
bridge/                  @rubyeverywhere/bridge — the JS half
support/desktop/         the Tauri desktop shell template
support/mobile/ios/      the Hotwire Native iOS shell template
support/mobile/android/  the Hotwire Native Android shell template

The shell templates have their own READMEs documenting the freeze rules — what the CLI stamps per app and what it must never touch: iOS · Android.

Contributing

Bug reports and pull requests are welcome. Run the test suite with rake test.

License

Released under the MIT License. Copyright © 2026 Andrea Fomera.