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.
#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
- Drag
NativeShell.xcframework(orNativeShellDev.xcframeworkfor development builds) into the app target’s Frameworks, Libraries, and Embedded Content. It is a static framework, so choose Do Not Embed. - 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:
| File | Contents |
|---|---|
bundle.qbc | The app compiled to QuickJS bytecode: the fastest to load. |
bundle.js | The same app as JS source. |
bundle.js.map | Its source map (stack traces point at your .tsx files). |
capabilities.json | The 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.
| Member | Description |
|---|---|
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: NshLimits | The VM limits of the apps loaded from now on. |
grantCapabilities(_ json: String?) throws | The 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) throws | Runs 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.Info | Verifies the package at a URL off the main thread, then runs it. Throws why it was refused; the running app stays. |
connectDevServer(_ url: URL) throws | Development: 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, preferredStatusBarUpdateAnimation | What 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 field | Default | Meaning |
|---|---|---|
memoryMB: UInt32? | 256 | JS 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 s | Time to evaluate the bundle. |
callBudget: TimeInterval? | 2 s | Time of any other call into JS (one event, one timer, one frame). An overrun call is cut off. |
maxNodes: UInt32? | 200 000 | Nodes 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)
| API | Description |
|---|---|
NshTrust(stateDirectory: URL) throws | Opens 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) throws | Trusts a root public key (base64, the contents of a .pub file). Several roots may be trusted. |
updateRevocations(_ json: String) throws | Applies 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 -> NshPackage | Verifies, extracts assets and accepts the package (older builds of that app are refused from then on). |
NshPackage.info | id, name, version, build of the app inside. |
NshError | The 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.
NshAppViewis main-actor isolated.NshTrust,NshPackageandNshComponentContextare thread-safe. The JS engine runs on its own thread; the main thread never waits for it. - Status bar. The embedded app’s
StatusBarchanges whatNshAppView.preferredStatusBarStyleand friends return; the hosting view controller must forward them, as in the example above. - Safe area. Apps use
SafeAreaVieworuseSafeAreaInsets()fromnsh/ui/mobileas usual. AnNshAppViewplaced below your own header gets the insets that apply to it, not the window’s. - Mac Catalyst. The same framework works in Catalyst apps.