Native extensions
Most apps never need native code. Tabs, rules, permissions, haptics, badges,
and biometrics all come from everywhere.yml and the
bridge. But when a screen genuinely wants to be
native, whether that is a branded launch moment, a fully native settings page,
or a screen built around a device API, your app repo can carry Swift and Kotlin
that every build compiles straight into the shell.
Two pieces:
- Native source files in
native/ios/(Swift) ornative/android/(Kotlin) at your app root: views, screens, bridge components. Images go alongside them innative/ios/assets/andnative/android/assets/; see Images. - A declaration in
everywhere.ymlundernative.ios/native.android, naming what the shell should hook up.
native:
ios:
components: [ChartComponent] # BridgeComponent subclasses to register
screens: # path rules with view_controller: <id>
map: MapScreen # SwiftUI View or UIViewController, init(url:)
splash: LaunchSplash # SwiftUI View or UIViewController, init()
# splash_min_seconds: 1.0 # minimum time the splash stays up (max 8)
android:
components: [ChartComponent] # BridgeComponent subclasses to register
screens: # same ids as iOS; one rules: entry serves both
map: MapFragment # a Fragment destination
splash: LaunchSplash # a View with a (Context) constructor
Both platforms are optional and independent: declare one, the other, or both.
The same screen ids on both sides is what lets a single rules: entry drive
iOS and Android at once. See Both platforms, one rule.
Build-time only, by design. App stores prohibit downloading executable native code, so extensions compile into the app binary when you build. They ship with the app, not with a deploy. Everything that can live server-side (tabs, rules, content) still deploys with your web app.
A custom splash screen
The default splash continues your launch color with a spinner until the first
page arrives. Declare splash: and the shell shows your view instead. A
SwiftUI view with a plain init() is all it takes:
import SwiftUI
struct LaunchSplash: View {
var body: some View {
VStack(spacing: 16) {
Image(systemName: "diamond.fill")
.font(.system(size: 44))
.foregroundStyle(.red)
Text("Notes").font(.headline)
ProgressView()
}
}
}
Custom splashes stay on screen at least one second. Against a fast server the
first page can be ready before the first frame, and a brand moment that flashes
for 50ms reads as a glitch. Tune it with splash_min_seconds (up to the shell's
8-second give-up timeout). The splash dismisses when the first request finishes,
or immediately when your entry path resolves to a native screen.
Native screens
A screen is any type with an init(url:): a SwiftUI View or a
UIViewController subclass, your choice. The shell wraps SwiftUI in a hosting
controller for you. Declare it under screens: with an identifier, then route
paths to it with an ordinary rules: entry:
tabs:
- name: Map
path: /native/map
icons: { ios: map }
rules:
- patterns: ["/native/map$"]
properties:
view_controller: map # โ resolves through native.ios.screens
native:
ios:
screens:
map: MapScreen
Any visit to a matching path, whether from a tab, a web link, or a redirect,
pushes the native screen instead of loading the web. The url handed to your
init is the intercepted visit's location, and unknown identifiers fall through
to a normal web visit, so an older build degrades gracefully.
import SwiftUI
struct MapScreen: View {
let url: URL
var body: some View {
Text("Native at \(url.path)")
.navigationTitle("Map")
}
}
The shell targets iOS 15, so stick to SwiftUI available there.
ContentUnavailableViewand friends, which need iOS 17, will not compile.
Talking to your server
Native screens compile into the same module as the shell, so its helpers are
yours to use. For requests to your own app, use everywhereURLSession. It
shares the web views' session cookies, which the shell mirrors at launch, on
foreground, and after auth resets, and it sends the app's User-Agent, so
server-side native_app? checks hold:
struct BuildsScreen: View {
let url: URL
@State private var builds: [RecentBuild] = []
var body: some View {
List(builds) { build in Text(build.appName) }
.task {
let endpoint = EverywhereConfig.shared.url(forPath: "/builds.json")
if let (data, _) = try? await everywhereURLSession.data(from: endpoint) {
builds = (try? JSONDecoder().decode([RecentBuild].self, from: data)) ?? []
}
}
}
}
Build URLs with EverywhereConfig.shared.url(forPath:) rather than hardcoding
your domain. It resolves against the effective root, so dev servers and
picked instances both work.
External APIs are plain URLSession + Codable; external hosts must be
https (App Transport Security).
Two server-side notes. POSTs to your Rails app hit CSRF protection, so prefer
API-style endpoints (token auth or null_session) for mutations from native
code. JSON GETs ride the session cookie for free.
Navigating from native code
everywhereVisit(path) is the native counterpart of Everywhere.visit(): the
visit routes through the active navigator with full path-configuration
treatment, so web routes load in the web view, native-screen routes push their
screen, and modals behave like modals:
Button("Recent builds") { everywhereVisit("/native/builds") }
Button("Settings") { everywhereVisit("/settings") }
Kotlin on Android
Everything above has an Android half, built the same way: Kotlin in
native/android/ compiled into the shell by every build --android, declared
under native.android. What differs is the platform, not the model.
Every file must start with the shell's extension package. The generated
registry names your types, and Kotlin ties nothing to the directory, so this line
is what makes them referenceable, and every build refuses a file without it:
package com.rubyeverywhere.shell.extensions
A screen is a Fragment carrying its own @HotwireDestinationDeepLink
whose uri is hotwire://fragment/<id>, the id you declared under screens:.
Extend HotwireFragment and hand your toolbar back through
toolbarForNavigation() so the framework can wire the up button and title:
package com.rubyeverywhere.shell.extensions
import dev.hotwire.navigation.destinations.HotwireDestinationDeepLink
import dev.hotwire.navigation.fragments.HotwireFragment
@HotwireDestinationDeepLink(uri = "hotwire://fragment/map")
class MapFragment : HotwireFragment() {
// `location` is the intercepted visit's URL, the equivalent of iOS's init(url:)
override fun onCreateView(inflater: LayoutInflater, parent: ViewGroup?, state: Bundle?) =
TextView(requireContext()).apply { text = "Native at $location" }
}
There is no per-app XML layout in the stamped project, since only .kt files
are copied in, so build views in code or inflate from a
drawable you shipped.
A splash is any View with a single Context constructor:
package com.rubyeverywhere.shell.extensions
class LaunchSplash(context: Context) : LinearLayout(context) {
init {
orientation = VERTICAL
gravity = Gravity.CENTER
addView(ProgressBar(context))
}
}
Shell helpers
Two top-level functions, named to match their Swift counterparts so extension code reads the same on both platforms:
// Route through the active navigator: web routes load the web view,
// native-screen routes push their screen. Safe from any thread.
everywhereVisit("/settings")
// GET from your own server, carrying the WebView's session cookie and the
// native User-Agent, so native_app? holds server-side. Suspending; a relative
// path resolves against the effective root, and a non-2xx throws
// EverywhereFetchError.
val json = everywhereFetch("/builds.json")
Use viewLifecycleOwner.lifecycleScope to call the suspending one. A coroutine
that outlives the view resumes holding views that are already gone.
Maven packages
The Android counterpart of Swift packages: coordinate strings under
native.android.packages.
native:
android:
screens:
confetti: ConfettiFragment
packages:
- "nl.dionsegijn:konfetti-xml:2.0.5"
every build writes them into a generated Gradle script that the shell's frozen
build file applies, so a third-party native dependency never means editing a
Gradle file by hand. Import them normally: there is no re-export shim to go
through, because Gradle has no equivalent of SPM's product linking problem.
Both platforms, one rule
Declare the same screen id on both sides and a single rules: entry routes to
the right native screen on each platform. iOS reads view_controller:; Android
needs uri: hotwire://fragment/<id>, which every build derives from that same
id when it generates the Android path configuration:
rules:
- patterns: ["/native/map$"]
properties:
view_controller: map # iOS reads this; Android gets a derived uri
native:
ios:
screens: { map: MapScreen }
android:
screens: { map: MapFragment }
The uri is derived only for ids you declared under native.android.screens,
so an iOS-only screen falls through to a normal web visit on Android rather than
routing to a Fragment that isn't there. An explicit uri: in your rule always
wins.
The live config has to know too. Your deployed app serves the same document at
/everywhere/android_v1.json, and the shell prefers the server copy over the baked one. A new native Android screen therefore needs both a rebuild (for the Kotlin) and a deploy (for the routing) before it appears on a device.
Swift packages
Native screens often want a library: confetti, charts, image loading, a device
SDK. Pin Swift packages under native.ios.packages and every build
makes them available to your native/ios/ code:
native:
ios:
screens:
confetti: ConfettiScreen
packages:
- url: https://github.com/simibac/ConfettiSwiftUI
from: "1.1.0" # or exact: / branch: / revision:
products: [ConfettiSwiftUI] # optional; defaults to the repo name
Each entry is one SPM dependency. Give it a url and exactly one version
requirement: from: (up to the next major), exact:, branch:, or
revision:. products: names the modules to link; omit it and it defaults to
the repository's name.
Reach everything you pinned through a single import NativeExtensions. The
build re-exports each product, so your screen uses the API directly:
import SwiftUI
import NativeExtensions // re-exports every package you pinned
struct ConfettiScreen: View {
let url: URL
@State private var counter = 0
var body: some View {
Button("Shoot confetti") { counter += 1 }
.buttonStyle(.borderedProminent)
.confettiCannon(counter: $counter, num: 60, radius: 400)
}
}
Route to it like any other native screen: a rules: entry with
view_controller: confetti, and optionally a tab. Tap through and the button fires
real, native confetti drawn entirely by the pinned package.
Pins are reproducible.
from:resolves to the newest matching release at build time; commit the resulting lock so CI and your machine build the same versions. Package sources must be reachable from wherever you build (the CI runner needs network to the repo host).
Images
Drop images in native/ios/assets/ and your Swift reaches them by filename.
No everywhere.yml entry, no Xcode step:
native/ios/assets/
โโโ logo.png
โโโ [email protected]
โโโ [email protected]
โโโ badge.svg
โโโ branding/
โโโ wordmark.png
Image("logo").resizable().scaledToFit().frame(width: 120)
every build --ios compiles each file into an imageset inside the shell's asset
catalog, so Image("logo") in SwiftUI and UIImage(named: "logo") in UIKit both
resolve. PNG, JPEG, SVG, and PDF are supported; vectors keep their vector data.
The filename is the whole API:
| Filename | Becomes |
|---|---|
logo.png |
Image("logo"), one file used at every scale |
logo.png + [email protected] + [email protected] |
Image("logo") with proper 1x/2x/3x slots |
logo~dark.png |
the dark-mode variant, picked automatically |
logo@2x~dark.png |
dark variant for a specific scale |
branding/wordmark.png |
Image("wordmark"). Folders organize; they do not namespace |
A bare logo.png with no @2x/@3x siblings is treated as single scale (one
file for every display), so a high-resolution export isn't silently rendered at
triple size. Add the scale suffixes when you want real 1x/2x/3x art.
Because names are global to the catalog, the same name in two folders is a build
error, and AppIcon, AccentColor, and LaunchBackground are reserved, since
those are stamped from everywhere.yml (app.icon,
appearance.background_color).
On Android
Same idea, same filenames. native/android/assets/ compiles into the shell's
drawable resources, so your Kotlin reaches them as R.drawable.<name>:
ImageView(context).apply { setImageResource(R.drawable.logo) }
The filename API is deliberately identical, so one assets tree can be authored once and dropped into both platforms. What differs is that Android expresses the markers as directory qualifiers rather than catalog metadata:
| Filename | Becomes |
|---|---|
logo.png |
res/drawable/logo.png, the baseline every density falls back to |
[email protected] |
res/drawable-xhdpi/logo.png |
[email protected] |
res/drawable-xxhdpi/logo.png |
logo~dark.png |
res/drawable-night/logo.png |
logo@2x~dark.png |
res/drawable-night-xhdpi/logo.png |
branding/wordmark.png |
R.drawable.wordmark. Folders organize; they do not namespace |
Two differences worth knowing before you copy a tree across:
- Names are stricter. An Android resource name is a field on the generated
Rclass, andres/filenames must be lowercase: use lowercase letters, digits and_, starting with a letter.MyLogo.pngandhero-image.pngare fine on iOS and build errors here, and the error names the working spelling.ic_launcher*andic_tab_placeholderare reserved by the shell. - Vectors are XML. Android has no runtime SVG or PDF renderer, so ship a
VectorDrawable.xml(Android Studio: right-clickresโ New โ Vector Asset) instead. PNG, JPEG, and WebP work as-is; XML drawables are scale-free, so they take no@2xsuffix.
Bridge components
Types listed under components: are BridgeComponent subclasses registered
alongside the built-ins, using the same
Hotwire Native bridge API
the shell's own notification, haptics, and biometrics components use. Pair
one with a small JavaScript adapter in your app and you've extended the bridge
surface itself:
import HotwireNative
final class ChartComponent: BridgeComponent {
override nonisolated class var name: String { "chart" }
override func onReceive(message: Message) {
// respond with reply(with: message.replacing(data: ...))
}
}
How it builds
every build --ios (and every dev --ios) copies native/ios/**/*.swift into
the shell template's synchronized Extensions/ folder, compiles
native/ios/assets/ into its asset catalog, and generates a registry
from your native.ios declaration. Any packages you pinned are written into a
local Swift package that ships in the template (NativeExtensions), which the
app target already links, so the Xcode project itself is never modified.
Dependencies flow through a generated Package.swift, not the project file.
Declared type names must be plain Swift identifiers; a declared but missing type
fails the build with a Swift error naming it. The filename
EverywhereExtensions.swift is reserved for the generated registry.
every build --android does the same with the Gradle template: native/android/**/*.kt
is copied into the shell's extensions/ package, native/android/assets/
becomes drawable resources in a generated source set, your Maven coordinates
become a generated Gradle script the frozen build file applies, and a registry is
generated from native.android. As on iOS, no build script is ever edited.
EverywhereExtensions.kt is reserved for the generated registry.
Nothing else changes about your workflow: rebuild, and the shell picks the
extensions up. If native/ios/ or native/android/ has files but the matching
native.* block declares nothing, the files compile in but nothing is hooked
up, and the build warns.