Docs

Building for iOS

iOS apps are remote mode only: the native shell is a thin Hotwire Native wrapper around your deployed app. Nothing is packaged locally, so no Tebako and no embedded Ruby. Your server keeps doing what it already does, and the app gains native navigation (pushes, modals, pull-to-refresh) for free.

Prerequisites

  • Full Xcode, not just the Command Line Tools, with the iOS Simulator runtime installed. every doctor --target ios-arm64 checks all of it.
  • A deployed app with remote.url set in config/everywhere.yml:
Code yaml
app:
  name: Notes
remote:
  url: "https://notes.example.com"
platforms:
  ios:
    bundle_id: com.example.notes.ios   # optional override

Build

Terminal bash
every build --ios              # short for --target ios-arm64
# → dist/Notes.simulator.app

every build --ios --device     # archive and export a signed device build
# → dist/Notes.ipa

The first build resolves and compiles the Hotwire Native Swift package, which takes a few minutes. After that the shared caches under ~/.rubyeverywhere keep rebuilds fast.

What happens: the CLI copies its bundled Xcode template to ~/.rubyeverywhere/ios/<bundle_id>/ and stamps it. Your name, bundle id and version go into an xcconfig; the shell config into everywhere.json; your icon (alpha-flattened, full-bleed) into the asset catalog. Then it hands the project to xcodebuild. The stamped project is a normal Xcode project, so open it any time to poke around or run on a device by hand.

To ship to TestFlight or the App Store, use every release --target ios-arm64 --channel testflight with your certificate and profile, or let the Platform build it on a hosted macOS runner.

Test locally

Terminal bash
every dev --ios          # or: every dev --target ios

This starts your framework's dev server (bin/dev), builds the shell, boots a Simulator, installs the app, and launches it pointed at http://127.0.0.1:3000, live reload and all. Ctrl-C stops everything. The dev URL is passed at launch, so switching between dev and your deployed URL never rebuilds the shell.

Shell logs

Terminal bash
every logs --ios         # watch live, Ctrl-C to stop
every logs --ios --last 5m

This tails the Simulator's unified log scoped to the shell: your bridge components' NSLog lines, Hotwire Native's own logging, and UIKit warnings, with Apple-framework chatter filtered out. --verbose puts it back, at debug level. --last replays history instead of watching, and --udid picks a Simulator when several are booted.

Inside every dev, press l to toggle the same stream alongside the server output.

Tabs

Give the app a native tab bar straight from everywhere.yml:

Code yaml
tabs:
  - name: Home
    path: /
    icons:
      ios: house        # SF Symbol name
  - name: Builds
    path: /builds
    icons:
      ios: hammer

Icons are per-platform (icons.ios takes SF Symbols, icons.android takes Material names), with a shared icon: fallback. No tabs: key means no tab bar, just full-screen navigation.

By default every tab makes its first visit as soon as the tab bar loads. To defer each unselected tab's first load until the user taps it, and the selected tab still loads right away, opt in under native.ios:

Code yaml
native:
  ios:
    lazy_load_tabs: true    # false opts out explicitly; omit to keep the default

This maps to Hotwire Native's lazyLoadTabs and is a build-time setting, so changing it ships with the next app release.

Tabs travel two ways from that one definition:

  • Baked into the app at build time, so they are instant and work offline.
  • Served live at /everywhere/ios_v1.json, which the shell loads as a remote path configuration source. In Rails the gem adds a real route for it (everywhere_mobile_config, visible in rails routes; draw your own to override it). Sinatra and Hanami use the Rack middleware instead: add use Everywhere::MobileConfigEndpoint to config.ru.

everywhere.yml is re-read on every request, and the shell refetches on every launch and every return to the foreground. So changing tabs is: edit the file, deploy, and installed apps reshape themselves next time they open. No app-store release.

Development works the same way, minus the deploy. Save everywhere.yml, background and foreground the app in the Simulator (Cmd+Shift+H twice, or click away and back), and the tab bar reshapes. Dev responses are served uncached, so a foreground always sees your latest save.

Conditional tabs

Everywhere.tabs builds the bar per request. It runs in the config endpoint, which shares your app's session cookie, so the block can branch on anything your app can answer while serving a request:

Code ruby
# config/initializers/everywhere.rb
Rails.application.config.to_prepare do
  Everywhere.tabs do |tabs, ctx|
    ctx.cookies.signed[:session_id] ? tabs : []
  end
end

Return [] to hide the tab bar entirely; the shell falls back to a single navigation stack.

Wrap the registration in to_prepare so a reload in development does not leave the block holding stale constants. ctx also carries platform ("ios" or "android", for a bar that differs between them) and session.

Looking users up

Tell the gem how this app identifies a request, once, and every hook can ask. The config endpoint deliberately does not inherit ApplicationController, since your before_actions would lock the shells out, so this is where that gap closes:

Code ruby
Rails.application.config.to_prepare do
  Everywhere.current_user do |ctx|
    session_id = ctx.cookies.signed[:session_id]
    Session.find_by(id: session_id)&.user if session_id
  end
end

Devise, or anything else on Warden, needs no cookie work at all:

Code ruby
Everywhere.current_user { |ctx| ctx.request.env["warden"]&.user }

Then ctx.current_user is available in the tabs block, resolved at most once per request, and real conditions get short:

Code ruby
Everywhere.tabs do |tabs, ctx|
  next [] unless ctx.current_user

  tabs.reject! { |tab| tab["title"] == "Labs" } unless Flipper.enabled?(:labs, ctx.current_user)
  tabs << ctx.tab("Admin") if ctx.current_user.admin?
  tabs
end

Tabs not everyone gets

ctx.tab("Admin") returns a tab from everywhere.yml by name, including one declared visible: false:

Code yaml
tabs:
  - name: Home
    path: /
    icons: { ios: house, android: home }
  - name: Admin
    path: /admin
    icons: { ios: shield, android: security }
    visible: false      # never baked, never served, until a block adds it

Declaring it is what makes the build check the icon name. An undeclared tab's mistyped Android icon renders as a blank square on the device with nothing in the log to explain it. visible: false keeps it out of the bundled config, so it cannot flash on first launch before the live config arrives.

Then, on sign-in and sign-out, redirect the native app through the reset page so it rebuilds at once: fresh web views, no stale signed-in screens, and refetched tabs.

Code ruby
# sign in / sign out controllers
redirect_to everywhere_auth_redirect(after_authentication_url)  # native → /everywhere/reset
redirect_to everywhere_auth_redirect(new_session_url)           # browsers → straight there

everywhere_auth_redirect sends native clients through /everywhere/reset, added for you, and everyone else straight to the target. This is the standard Hotwire Native "reset the app" pattern.

Third-party sign-in (Apple, Google, GitHub)

OAuth cannot run in the app's web view. Google rejects it outright (disallowed_useragent), and Apple and GitHub work badly there: no password manager, none of the provider session the user already has in Safari, and 2FA fighting a keyboard the page does not control.

Declare which paths start a provider flow and the shell takes them out of the web view entirely, into ASWebAuthenticationSession, the system browser:

Code yaml
auth:
  oauth_paths:
    - ^/auth/          # the OmniAuth convention, and the default

Your app keeps its own auth: OmniAuth, the Rails 8 generator, whatever you have. Nothing here knows what a provider is, only which paths not to open in the web view.

On OmniAuth 2, add one initializer line. Since CVE-2015-9284, OmniAuth starts a flow only from a POST carrying a CSRF token. That is right for browsers and impossible for the native flow, which enters your provider path through a redirect: a GET, in a fresh browser session with no CSRF token to give. Teach it the difference:

config/initializers/omniauth.rb ruby
# ...your provider setup...

require "everywhere/omniauth"
Everywhere::OmniAuth.protect!

Browsers keep exactly the protection they had. omniauth-rails_csrf_protection, or whatever your request_validation_phase is, keeps validating as before. The one extra shape allowed through is the native flow's entry: a top-level, same-site GET carrying a marker cookie that /everywhere/auth/start sealed seconds ago, which only your server can mint.

In views, use POST buttons, OmniAuth's rule for browsers. The shell diverts them all the same:

Code erb
<%= button_to "Sign in with GitHub", "/auth/github" %>
<%= button_to "Sign in with Google", "/auth/google_oauth2" %>

What happens

The session cookie your OAuth flow ends with lands in the browser's cookie jar, which the app's web view can never read. The gem bridges the two:

  1. The shell opens /everywhere/auth/start in the auth session, which marks that jar and continues into your provider path.
  2. Your app's callback signs the user in and redirects, exactly as in a browser.
  3. When the browser asks for that page, the first navigation back out of your provider paths, the gem seals the cookie jar into a one-time token (60 seconds, single use, AES-GCM) and returns it through the app's callback scheme, which iOS delivers only to the app that opened the session.
  4. The shell spends the token at /everywhere/auth/handoff in its web view, which puts the session where the app can use it, then lands on whatever page your sign-in redirected to.
  5. The app resets around the new session: fresh web views, and the tab bar refetched as the now signed-in user.

All four /everywhere/auth/* endpoints are served by the gem. In Rails they arrive with the engine, at the top of the middleware stack, so provider paths are diverted before anything else can answer them. Sinatra and Hanami add one line to config.ru, first, above any OmniAuth-style middleware, for the same reason:

Code ruby
require "everywhere/auth_handoff"
use Everywhere::AuthHandoff

The callback scheme defaults to your iOS bundle_id and is stamped into Info.plist at build time. Override it with auth.scheme if you need to.

Notes

  • Rebuild after adding auth:. The paths and the scheme are stamped into the app, so the shell needs a new build to know about them.
  • The token is sealed with your secret_key_base. Outside Rails, set SECRET_KEY_BASE in the environment.
  • Where you land is wherever your own sign-in redirected to. The one exception is the site root: in the auth browser your app has no stored location to return the user to, so the shell supplies the page they started from.
  • Every cookie the flow set crosses back, replayed HttpOnly. The gem cannot know which one your auth library signs its session with. Narrow it with auth.cookies.only or auth.cookies.except. If your own JavaScript needs to read one of these cookies, have the app re-set it after sign-in.
  • Cookies keep the lifetime the flow gave them. A cookie your callback sets with Max-Age or Expires, such as a remember-me token, crosses back with the time it has left, so it survives app relaunches. Cookies the flow only saw in a request stay session-scoped.
  • Mobile only, today. The desktop shell does not divert these paths yet, and Google refuses any embedded web view. If your desktop app offers the same sign-in, keep provider buttons off it (check Everywhere.platform in JavaScript) until desktop support lands.
  • Signing in in the app signs the user in in Safari too. That is standard ASWebAuthenticationSession behavior, and sharing that jar is what makes the one-tap sign-in work.
  • Sign in with Apple goes through the same web flow. The native ASAuthorizationController sheet, with Face ID and one tap, is not wired up yet.

Driving it from JavaScript

If your sign-in is not a link the shell can see, such as a fetch, a custom element, or a button with other work to do first, call it directly:

Code js
Everywhere.auth.signIn("/auth/github")

Route rules (modals, pull-to-refresh)

The shell ships sensible path-configuration defaults: links push, /new and /edit present as modals, pull-to-refresh is on. Override or extend per route with a top-level rules: key, appended after the defaults, so the last matching rule wins per property:

config/everywhere.yml yaml
rules:
  - patterns: ["/preferences$"]
    properties:
      context: default              # not a modal, unlike other /edit routes
  - patterns: ["/live/"]
    properties:
      pull_to_refresh_enabled: false

Any Hotwire Native path property works. Rules ship both baked and live, exactly like tabs: edit, deploy, and installed apps pick them up on the next foreground.

Splash screen

The launch screen, and the brief spinner while the first page loads over the network, are stamped from appearance.background_color. Set the light and dark values once and the launch frame, the splash, and the native window chrome all match your app. Nothing else to configure.

Want a branded splash instead of the spinner? Declare a SwiftUI view under native.ios.splash. See Native extensions.

Permissions

Declare the native permissions your app may request; nothing else can ever prompt. For camera and location the value is the sentence iOS shows in the permission dialog, and the build fails without a real one, because requesting a permission with no usage string crashes the app:

config/everywhere.yml yaml
permissions:
  notifications: true
  camera: "Scan QR codes to pair devices."

At runtime, Everywhere.permissions queries and requests them, including the denied path to openSettings(), since iOS only asks once. Undeclared permissions resolve {status: "undeclared"}: no prompt, no crash.

Haptics and badges

Both come from the bridge. Everywhere.haptics.* plays real iOS haptics, or tag elements with data-everywhere-haptic, and Everywhere.badge.* drives the app icon and native tab bar badges, with everywhere_badge and everywhere_tab_badge Rails helpers to render counts server-side.

Native chrome (nav bar, menus, FAB)

Tag ordinary markup and the shell lifts it into real UIKit controls: a navigation-bar button, a pull-down or overflow UIMenu, a UIAlertController action sheet. Tapping the native control calls .click() on the element it mirrors, so a link navigates and a submit submits, and the same markup is plain HTML in a browser. Icons are per-platform SF Symbols and Material names (icons: { ios:, android: }, with a shared icon: fallback), matching the tab-icon convention.

Code erb
<%# a button in the top bar %>
<%= everywhere_nav_button "New", new_note_path, icons: { ios: "plus" } %>

<%# a form's Save button, lifted into the nav bar %>
<%= form_with model: @note do |f| %>
  <%= everywhere_submit_button "Save" %>
<% end %>

<%# an overflow (⋯) pull-down menu %>
<%= 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 %>

<%# an in-content action sheet %>
<%= everywhere_menu "Options" do %>
  <%= everywhere_menu_item "Edit", edit_note_path(@note) %>
<% end %>

<%# a floating action button (needs everywhere/native.css) %>
<%= everywhere_fab new_note_path, icon: :plus, label: "New note" %>

The full reference, including the Everywhere.menu() call, is in the Bridge API.

Native-aware views

The gem adds view helpers so your ERB can tell when it is running inside the shell, the server-side counterpart to the bridge's Everywhere.platform:

Code erb
<%# Hide a web-only "Install the app" banner inside the app %>
<%= render "install_banner" unless native_app? %>

<%# Branch on platform %>
<% if native_platform == :ios %> … <% end %>

<%# Gate a feature on a newer shell than some users may have %>
<% if native_version && native_version >= Gem::Version.new("1.2") %>
  <%= render "barcode_button" %>
<% end %>

native_app?, native_platform (:ios, :android, :desktop or nil) and native_version all read the shell's User-Agent, so they are right on the very first request, before any JavaScript runs. The full list is on the Rails page.

Safe-area insets

For content that runs edge to edge, include the safe-area stylesheet and use its utility classes so nothing hides behind the status bar, Dynamic Island, or home indicator:

Code erb
<%= stylesheet_link_tag "everywhere/native" %>
Code erb
<header class="native-inset-top">…</header>
<footer class="native-safe-bottom">…</footer>

The classes read env(safe-area-inset-*), so pair them with viewport-fit=cover in your viewport meta. They collapse to zero on desktop and in the browser, which makes them safe to apply everywhere. In the default tab-bar layout iOS already insets content below its own bars, so these matter most on full-bleed screens. Sinatra and Hanami get the file vendored to public/native.css by every install.

What you get by default

  • Navigation. Links push; /new and /edit paths present as modals. Change it with rules:.
  • Notifications. Everywhere.notify(...) from the bridge delivers a real local notification through the shell's everywhere--notification component.
  • Errors. Offline and unreachable states show a native retry screen, and a 401 routes to /session/new.

Shipping it

Local device builds come from every build --ios --device and every release --target ios-arm64 --channel testflight, both of which need Xcode, your Apple Distribution certificate, and a provisioning profile. If you would rather not hold those on your own machine, the Platform builds iOS on hosted macOS runners and uploads to App Store Connect for you.

Rails · Hanami · Sinatra — built with Ruby