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-arm64checks all of it. - A deployed app with
remote.urlset inconfig/everywhere.yml:
app:
name: Notes
remote:
url: "https://notes.example.com"
platforms:
ios:
bundle_id: com.example.notes.ios # optional override
Build
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
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
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:
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:
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 inrails routes; draw your own to override it). Sinatra and Hanami use the Rack middleware instead: adduse Everywhere::MobileConfigEndpointtoconfig.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:
# 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:
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:
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:
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:
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.
# 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:
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:
# ...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:
<%= 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:
- The shell opens
/everywhere/auth/startin the auth session, which marks that jar and continues into your provider path. - Your app's callback signs the user in and redirects, exactly as in a browser.
- 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.
- The shell spends the token at
/everywhere/auth/handoffin its web view, which puts the session where the app can use it, then lands on whatever page your sign-in redirected to. - 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:
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, setSECRET_KEY_BASEin 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 withauth.cookies.onlyorauth.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-AgeorExpires, 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.platformin JavaScript) until desktop support lands. - Signing in in the app signs the user in in Safari too. That is standard
ASWebAuthenticationSessionbehavior, 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
ASAuthorizationControllersheet, 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:
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:
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:
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.
<%# 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:
<%# 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:
<%= stylesheet_link_tag "everywhere/native" %>
<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;
/newand/editpaths present as modals. Change it withrules:. - Notifications.
Everywhere.notify(...)from the bridge delivers a real local notification through the shell'severywhere--notificationcomponent. - 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.