Docs

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:

  1. Native source files in native/ios/ (Swift) or native/android/ (Kotlin) at your app root: views, screens, bridge components. Images go alongside them in native/ios/assets/ and native/android/assets/; see Images.
  2. A declaration in everywhere.yml under native.ios / native.android, naming what the shell should hook up.
config/everywhere.yml yaml
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:

native/ios/LaunchSplash.swift swift
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:

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

native/ios/MapScreen.swift swift
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. ContentUnavailableView and 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:

native/ios/BuildsScreen.swift swift
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.

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:

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

native/android/MapFragment.kt kotlin
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:

native/android/MapFragment.kt kotlin
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:

native/android/LaunchSplash.kt kotlin
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:

Code kotlin
// 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.

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

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

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

native/ios/ConfettiScreen.swift swift
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:

Output text
native/ios/assets/
โ”œโ”€โ”€ logo.png
โ”œโ”€โ”€ [email protected]
โ”œโ”€โ”€ [email protected]
โ”œโ”€โ”€ badge.svg
โ””โ”€โ”€ branding/
    โ””โ”€โ”€ wordmark.png
native/ios/LaunchSplash.swift swift
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>:

native/android/LaunchSplash.kt kotlin
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 R class, and res/ filenames must be lowercase: use lowercase letters, digits and _, starting with a letter. MyLogo.png and hero-image.png are fine on iOS and build errors here, and the error names the working spelling. ic_launcher* and ic_tab_placeholder are reserved by the shell.
  • Vectors are XML. Android has no runtime SVG or PDF renderer, so ship a VectorDrawable .xml (Android Studio: right-click res โ†’ New โ†’ Vector Asset) instead. PNG, JPEG, and WebP work as-is; XML drawables are scale-free, so they take no @2x suffix.

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:

native/ios/ChartComponent.swift swift
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.

Rails ยท Hanami ยท Sinatra โ€” built with Ruby