Guide

Embedding in iOS Apps

An existing iOS app can show nsh apps inside its own UI. The runtime ships as NativeShell.xcframework, a static framework with a Swift module: you create an NshAppView (a UIView), add it anywhere in your view hierarchy and load an app into it. Your native screens, navigation and the nsh app live side by side in one UIKit hierarchy.

Each NshAppView is one independent engine with its own JS VM; there are no global singletons, so an app can show several nsh apps at once, for example one per tab.

One host app with two NshAppView regions: each is its own engine with its own thread, VM, limits and capabilities; the host grants capabilities, sets limits and registers native components such as a map. One host app with two NshAppView regions: each is its own engine with its own thread, VM, limits and capabilities; the host grants capabilities, sets limits and registers native components such as a map.

#Get the framework

The distribution contains both variants:

dist/nsh/embed/ios/NativeShell.xcframework      release: bundles and signed packages
dist/nsh/embed/ios/NativeShellDev.xcframework   also connects to a `nsh dev` server

From a checkout of nsh you can build just the frameworks:

cargo xtask ios-embed    # writes dist/nsh/embed/ios/{NativeShell,NativeShellDev}.xcframework

Each XCFramework has three arm64 slices: iOS devices, the iOS simulator (Apple silicon) and Mac Catalyst. The minimum deployment target is iOS 15.0. The Swift module is compiled with library evolution, so apps built with other Swift compiler versions can import it, and the framework links everything it needs: you add nothing else.

#Add it to an Xcode project

  1. Drag NativeShell.xcframework (or NativeShellDev.xcframework for development builds) into the app target’s Frameworks, Libraries, and Embedded Content. It is a static framework, so choose Do Not Embed.
  2. In Swift, import NativeShell.

#Ship a bundle with the app

Build the nsh app into a folder and add that folder to the app’s resources as a folder reference named nsh:

nsh build App.tsx --release --out MyApp/nsh

nsh build writes:

FileContents
bundle.qbcThe app compiled to QuickJS bytecode: the fastest to load.
bundle.jsThe same app as JS source.
bundle.js.mapIts source map (stack traces point at your .tsx files).
capabilities.jsonThe capabilities the app declares, when nsh.json has any.
assets/Images and fonts the app imports, at their paths relative to the entry.

#Show the app

import NativeShell
import UIKit

final class EmbedController: UIViewController {
    private let app = NshAppView()

    override func viewDidLoad() {
        super.viewDidLoad()
        app.translatesAutoresizingMaskIntoConstraints = false
        app.onError = { message in print("nsh error: \(message)") }
        view.addSubview(app)
        NSLayoutConstraint.activate([
            app.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
            app.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            app.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            app.bottomAnchor.constraint(equalTo: view.bottomAnchor),
        ])

        guard let url = Bundle.main.url(forResource: "bundle", withExtension: "qbc", subdirectory: "nsh") else { return }
        do {
            try app.loadBundle(at: url)
        } catch {
            print("cannot load: \(error)")
        }
    }

    // Let the embedded app's StatusBar decide the status bar.
    override var preferredStatusBarStyle: UIStatusBarStyle { NshAppView.preferredStatusBarStyle }
    override var prefersStatusBarHidden: Bool { NshAppView.prefersStatusBarHidden }
    override var preferredStatusBarUpdateAnimation: UIStatusBarAnimation {
        NshAppView.preferredStatusBarUpdateAnimation
    }
}

The view’s size is the app’s window: nsh lays the app out at the view’s bounds and again whenever they change. Loading again replaces the running app; the app stops when the view is deallocated.

The complete sample, a UIKit app that shows a nsh app below its own native header, is examples/embed-ios/main.swift.

#NshAppView

NshAppView is a UIView subclass isolated to the main actor.

MemberDescription
init(frame: CGRect = .zero, trace: Bool = false)Creates the view. trace logs the time of every commit. init?(coder:) supports storyboards and nibs.
onError: ((String) -> Void)?Receives fatal app errors (a bundle that fails to load, uncaught JS exceptions, a VM stopped by a limit) on the main thread. Errors are always logged; development builds also show them over the app.
limits: NshLimitsThe VM limits of the apps loaded from now on.
grantCapabilities(_ json: String?) throwsThe most the apps loaded from now on may do, as JSON in the form of nsh.json’s capabilities; nil grants what each app declares. Throws NshError for invalid JSON.
loadBundle(at url: URL) throwsRuns a bundle written by nsh build. Assets come from assets/ next to it; bundle.js.map and capabilities.json next to it are read too.
run(_ package: NshPackage)Runs a verified signed package (see below). A package runs once.
loadPackage(at:trust:) async throws -> NshPackage.InfoVerifies the package at a URL off the main thread, then runs it. Throws why it was refused; the running app stays.
connectDevServer(_ url: URL) throwsDevelopment: loads from a nsh dev server and reloads on every change. Throws with NativeShell.xcframework, which has no development support.
register(component:_:)Registers a native component.
static preferredStatusBarStyle, prefersStatusBarHidden, preferredStatusBarUpdateAnimationWhat the running apps asked for with StatusBar: return them from the hosting view controller’s overrides.

limits and grantCapabilities apply from the next load: set them before loadBundle / run.

#Limits

app.limits = NshLimits(memoryMB: 128, callBudget: 1, maxNodes: 50_000)
NshLimits fieldDefaultMeaning
memoryMB: UInt32?256JS heap in MB. Running out stops the app.
stackKB: UInt32?4096 (also the maximum)Interpreter stack in KB. Deeper recursion throws a RangeError.
loadBudget: TimeInterval?10 sTime to evaluate the bundle.
callBudget: TimeInterval?2 sTime of any other call into JS (one event, one timer, one frame). An overrun call is cut off.
maxNodes: UInt32?200 000Nodes the app may keep.

nil keeps the default. What each limit does when it is hit: Sandbox and capabilities.

#Capabilities

An app declares what it may reach in its nsh.json; the embedding app can grant less. A request must be allowed by both:

try app.grantCapabilities("""
{
  "network": { "hosts": ["api.example.com", "*.cdn.example.com"] },
  "webview": { "hosts": ["help.example.com"] }
}
""")
try app.loadBundle(at: url)

Here the app may not set the status bar (no statusBar), even if its nsh.json asks for it. Details: Sandbox and capabilities.

#Signed packages

To run code the app downloads (updates, third-party mini apps), use signed packages: nsh publish writes a .nshpkg signed by a release key that your root key certified, and the app verifies it before running it.

let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
let trust = try NshTrust(stateDirectory: support.appendingPathComponent("nsh"))
try trust.addRoot(rootPublicKey)   // the contents of root.pub from `nsh keygen`

Task {
    do {
        let info = try await app.loadPackage(at: downloadedFile, trust: trust)
        print("running \(info.id) \(info.version) (build \(info.build))")
    } catch {
        print("refused: \(error)")   // the running app stays
    }
}

To verify first and run later, split the two steps:

let package = try await trust.openPackage(at: downloadedFile)   // verifies off the main thread
// ... decide, ask the user, ...
app.run(package)
APIDescription
NshTrust(stateDirectory: URL) throwsOpens or creates the trust store. Its state (newest revocation list, highest build accepted per app) persists there, and packages’ assets are extracted under it. Trusts no key until addRoot. Thread-safe.
addRoot(_ publicKey: String) throwsTrusts a root public key (base64, the contents of a .pub file). Several roots may be trusted.
updateRevocations(_ json: String) throwsApplies a revocation list written by nsh revoke, signed by a trusted root and newer than the one the store has.
openPackage(at: URL) async throws -> NshPackageVerifies, extracts assets and accepts the package (older builds of that app are refused from then on).
NshPackage.infoid, name, version, build of the app inside.
NshErrorThe error every throwing call throws; message says why.

#Development

With NativeShellDev.xcframework, point the view at a dev server instead of a bundle:

#if DEBUG
try app.connectDevServer(URL(string: "ws://127.0.0.1:8790/ws")!)
#else
try app.loadBundle(at: bundledURL)
#endif

Run nsh dev App.tsx on your Mac. The simulator shares the Mac’s network, so 127.0.0.1 reaches the server; a device uses the LAN address nsh dev prints (devices on this network: ws://192.168.1.20:8790/ws). Every save updates the app in place, logs appear in the nsh dev terminal, and errors show over the app. See Dev server and debugging.

#Notes

  • Threads. NshAppView is main-actor isolated. NshTrust, NshPackage and NshComponentContext are thread-safe. The JS engine runs on its own thread; the main thread never waits for it.
  • Status bar. The embedded app’s StatusBar changes what NshAppView.preferredStatusBarStyle and friends return; the hosting view controller must forward them, as in the example above.
  • Safe area. Apps use SafeAreaView or useSafeAreaInsets() from nsh/ui/mobile as usual. An NshAppView placed below your own header gets the insets that apply to it, not the window’s.
  • Mac Catalyst. The same framework works in Catalyst apps.

#See also