Dokumentation
iOS
NSH-Apps laufen unter iOS mit UIKit-Views und CoreText-Text, auf Geräten und im Simulator, ab iOS 15.0. Ganze Apps laufen und werden verpackt wie auf den anderen Plattformen. Außerdem kann eine bestehende App NSH-Apps in ihren eigenen Views anzeigen: ein Rust-Root, das sie linkt, oder TSX, das sie lädt, einschließlich signierter Pakete, die sie herunterlädt, innerhalb der Limits und Capabilities, die sie festlegt. Unter iOS ist os() Os::Ios (platform.os ist "ios" in TSX), und die ios={{…}}-Props von TSX gelten.
#Voraussetzungen
- Xcode auf einem Mac, mit einer iOS-Simulator-Runtime (
xcodebuild -downloadPlatform iOSinstalliert eine). Siehe Installation. - Apps benötigen iOS 15.0 oder neuer, auf iPhone und iPad.
#Eine App ausführen
Eine Rust-App läuft aus ihrem Verzeichnis, eine TSX-App benennt ihren Einstiegspunkt:
nsh run --target ios # a Rust app
nsh run --target ios --device "iPhone 16 Pro"
nsh run App.tsx --target ios # a TSX app
nsh verwendet einen gestarteten Simulator oder startet das neueste iPhone. Bei einer Rust-App baut es den eigenen Runner der App mit deinem Crate, installiert ihn und zeigt seine Logs im Terminal an; eine Änderung am Rust-Code des Crates baut ihn neu und startet die App neu. Bei einer TSX-App installiert es den Development-Host und hält die App beim Speichern aktuell, mit Logs im Terminal. --device wählt einen Simulator per Name oder UDID. Für einen schnelleren Zyklus ohne Simulator öffnet nsh run --target catalyst auf einem Mac dasselbe UIKit-Backend in einem Mac Catalyst-Fenster in iPhone-Größe.
#Paketierung
nsh package --target ios-simulator --run # .nsh/out/<Name>.app, installed and launched in the simulator
nsh package --target ios --sign "Apple Distribution: Example Inc (ABCDE12345)" --profile AppStore.mobileprovision
Aus dem Verzeichnis einer Rust-App ist die App der iOS-Runner, der mit deinem Crate gebaut wird; eine TSX-App benennt ihren Einstiegspunkt (nsh package App.tsx --target ios-simulator --run).
--target ios schreibt eine signierte .ipa für Geräte, TestFlight oder den App Store. Signing Identities, Provisioning Profiles und Icons: Paketierung.
#In eine iOS-App einbetten
Eine bestehende iOS-App kann NSH-Apps in ihrer eigenen UI anzeigen. Eine iOS-App mit Rust-Code linkt dein Crate und das Crate nsh-platform-ios und zeigt dein Root in einem AppView an (Eine Rust-App einbetten). Eine Swift-App linkt NativeShell.xcframework, ein Framework mit einem Swift-Modul: Du erstellst ein NshAppView (ein UIView), fügst es an beliebiger Stelle in deine View-Hierarchie ein und lädst eine TSX-App oder ein signiertes Paket hinein. Deine nativen Screens, die Navigation und die NSH-App existieren nebeneinander in einer UIKit-Hierarchie.
Jede View ist eine unabhängige Engine; es gibt keine globalen Singletons, sodass eine App mehrere NSH-Apps gleichzeitig anzeigen kann, zum Beispiel eine pro Tab.
#Eine Rust-App einbetten
nsh_platform_ios::AppView ist eine Engine als UIView für eine iOS-App, die mit Rust gebaut ist: Sie bindet die Root-Komponente deines Crates ein, ganz ohne JavaScript-VM.
use nsh_platform_ios::{set_ui_sets, AppView, Root, SurfaceOptions};
use objc2::MainThreadMarker;
use objc2_ui_kit::UIView;
fn show(container: &UIView) -> AppView {
// Once, before the first view: the UI sets the apps import (NSH's and your crate's).
set_ui_sets(vec![nsh_base::registry(), my_app::registry()]);
let mtm = MainThreadMarker::new().expect("main thread");
let app = AppView::new(mtm, container.bounds(), SurfaceOptions::default());
container.addSubview(app.view());
app.run(Root::of::<my_app::App>());
// Keep the AppView: dropping it stops the app.
app
}
Die View passt ihre Größe an ihre Superview an. AppView::run ersetzt, was bisher lief; AppView::load führt stattdessen ein TSX-Bundle aus (Bundle::source oder unsafe { Bundle::bytecode(bytes) } für ein bundle.nshc im App-Bundle). SurfaceOptions enthält die Limits, die gewährten Capabilities, das Datenverzeichnis und die nativen Komponenten, so wie es die Member von NshAppView weiter unten tun; AppView::set_locale, set_font_scale und set_error_handler entsprechen locale, fontScale und onError. nsh package --target ios baut diese Art von App für dich, mit eingebundenem Root.
#Das Framework beziehen
Die Distribution enthält beide Varianten:
dist/nsh/embed/ios/NativeShell.xcframework release: bundles and signed packages
dist/nsh/embed/ios/NativeShellDev.xcframework also connects to a `nsh dev` server
Aus einem Checkout von NSH kannst du nur die Frameworks bauen:
cargo xtask ios-embed # writes dist/nsh/embed/ios/{NativeShell,NativeShellDev}.xcframework
Der Standard-Build erzeugt statische Frameworks. Um dynamische Frameworks zu bauen, verwende:
cargo xtask ios-embed --dynamic
Dynamische Frameworks geben der Runtime ein eigenes Library-Image und eine eigene Unwind-Tabelle, was Host-Apps unterstützt, die bereits andere Rust-Runtimes linken. Wähle die Xcode-Embedding-Einstellung unten passend zum Framework, das du baust.
Standardmäßig hat jedes XCFramework drei arm64-Slices: iOS-Geräte, den iOS-Simulator (Apple Silicon) und Mac Catalyst. Das minimale Deployment-Target ist iOS 15.0. Das Swift-Modul wird mit Library Evolution kompiliert, sodass Apps, die mit anderen Versionen des Swift-Compilers gebaut wurden, es importieren können, und das Framework linkt alles, was es braucht: Du musst nichts weiter hinzufügen.
#Zu einem Xcode-Projekt hinzufügen
- Ziehe
NativeShell.xcframework(oderNativeShellDev.xcframeworkfür Development-Builds) in Frameworks, Libraries, and Embedded Content des App-Targets. Wähle Do Not Embed für die statischen Standard-Frameworks oder Embed & Sign für dynamische Frameworks, die mit--dynamicgebaut wurden. - In Swift:
import NativeShell. - Setze in der
Info.plistder AppCADisableMinimumFrameDurationOnPhoneaufYES: Auf ProMotion-iPhones bewegen sich die Animationen der App dann mit 120 Hz, wie auch das Scrollen (vonnsh packagegebaute Apps haben das).
#Ein Bundle mit der App ausliefern
Eine Swift-App liefert TSX-Apps als Bundles aus. Baue die NSH-App in einen Ordner und füge diesen Ordner den Ressourcen der App als Folder Reference mit dem Namen nsh hinzu:
nsh build App.tsx --release --out MyApp/nsh
nsh build schreibt:
| Datei | Inhalt |
|---|---|
bundle.nshc |
Die App, zu Bytecode kompiliert: lädt am schnellsten. |
bundle.js |
Dieselbe App als JS-Quelltext. |
bundle.js.map |
Ihre Source Map (Stack Traces verweisen auf deine .tsx-Dateien). |
capabilities.json |
Die Capabilities, die die App deklariert, sofern nsh.json welche enthält. |
assets/ |
Bilder und Schriften, die die App importiert, unter ihren Pfaden relativ zum Einstiegspunkt. |
#Die App anzeigen
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: "nshc", subdirectory: "nsh") else { return }
do {
try app.loadBundle(at: url)
} catch {
print("cannot load: \(error)")
}
}
// Let the embedded app's pages and dialogs decide the status bar.
override var preferredStatusBarStyle: UIStatusBarStyle { NshAppView.preferredStatusBarStyle }
override var prefersStatusBarHidden: Bool { NshAppView.prefersStatusBarHidden }
override var preferredStatusBarUpdateAnimation: UIStatusBarAnimation {
NshAppView.preferredStatusBarUpdateAnimation
}
}
Die Größe der View ist das Fenster der App: NSH legt die App an den Bounds der View aus und erneut, sobald sie sich ändern. Erneutes Laden ersetzt die laufende App; die App wird beendet, wenn die View deallokiert wird.
Das vollständige Beispiel, eine UIKit-App, die eine NSH-App unterhalb ihres eigenen nativen Headers anzeigt, ist examples/embed-ios/main.swift.
#NshAppView
NshAppView ist eine UIView-Subklasse, die auf den Main Actor isoliert ist.
| Member | Beschreibung |
|---|---|
init(frame: CGRect = .zero, trace: Bool = false) |
Erstellt die View. trace protokolliert die Zeit jedes Commits. init?(coder:) unterstützt Storyboards und Nibs. |
onError: ((String) -> Void)? |
Erhält fatale App-Fehler (ein Bundle, das nicht geladen werden kann, nicht abgefangene JS-Exceptions, eine durch ein Limit gestoppte VM) auf dem Main Thread. Fehler werden immer protokolliert; Development-Builds zeigen sie zusätzlich über der App an. |
limits: NshLimits |
Die VM-Limits der Apps, die ab jetzt geladen werden. |
grantCapabilities(_ json: String?) throws |
Das Maximum, das die ab jetzt geladenen Apps tun dürfen, als JSON im Format von nsh.json capabilities; nil gewährt, was jede App deklariert. Wirft NshError bei ungültigem JSON. |
locale: Locale? |
Sprache und Region der App anstelle derer des Benutzers: Intl, locale() und die Komponenten folgen ihr sofort, und die später geladenen Apps starten damit. nil folgt der des Systems. |
fontScale: CGFloat? |
Die Textgröße anstelle der von Dynamic Type (1 ist der Standard): Texte und Eingaben werden mit ihr neu ausgelegt und fontScale() liest sie, sofort und für die später geladenen Apps; Längen behalten ihre Größe. nil folgt der des Systems. |
setThemeOverrides(_ json: String?) throws |
Die Marke des Hosts über dem Theme der App: eine Theme-Datei im Format von nsh.json "theme". Ihre Tokens haben Vorrang vor denen der App mit denselben Namen, im Light- und Dark-Modus, und die übrigen Tokens der App bleiben bestehen. Wirkt sofort und auf die später geladenen Apps; nil gibt der App ihr eigenes Theme zurück. Wirft NshError bei einer Datei, die sich nicht parsen lässt. |
storageDirectory: URL? |
Wo die ab jetzt geladenen Apps ihren Storage ablegen; nil: Library/Application Support/nsh/<app id>, nach der eigenen ID der App. |
cacheOptions: NshCacheOptions |
Wo die ab jetzt geladenen Apps ihre Caches ablegen, die Bilder, die sie herunterladen, und wie viel jede behält: location .system (Library/Caches/nsh, jede App in <app id>), .directory(URL) oder .off; images .default (256 MB), .maxSize(megabytes:) oder .off. |
loadBundle(at url: URL) throws |
Führt ein von nsh build geschriebenes Bundle aus. Assets stammen aus assets/ daneben; bundle.js.map und capabilities.json daneben werden ebenfalls gelesen. |
run(_ package: NshPackage) |
Führt ein verifiziertes signiertes Paket aus (siehe unten). Ein Paket läuft einmal. |
loadPackage(at:trust:) async throws -> NshPackage.Info |
Verifiziert das Paket unter einer URL abseits des Main Threads und führt es dann aus. Wirft, warum es abgelehnt wurde; die laufende App bleibt bestehen. |
connectDevServer(_ url: URL) throws |
Development: lädt von einem nsh dev-Server und lädt bei jeder Änderung neu. Wirft bei NativeShell.xcframework, das keinen Development-Support hat. |
register(component:_:) |
Registriert eine native Komponente. |
params: [String: Any] |
Die Startparameter der ab jetzt geladenen Apps: Die App liest sie beim Start als host.params (host). |
send(_ name: String, _ data: Any? = nil) |
Sendet ein Event an die App (host.on(name, …)); wird aufbewahrt, bis sie zuhört, oder für die nächste App, solange keine läuft. |
handle(_ name: String, _ handler:) |
Behandelt die Calls (host.call) und Posts (host.post) der App von name auf dem Main Thread: { data, reply in … } oder eine async throws-Funktion { data in … }, deren Ergebnis den Call beantwortet. |
removeHandler(_ name: String) |
Entfernt den Handler von name: Die Calls der App darauf schlagen sofort fehl. |
static preferredStatusBarStyle, prefersStatusBarHidden, preferredStatusBarUpdateAnimation |
Was die laufenden Apps angefordert haben (statusBar ihrer Seiten und Dialoge, der Standard von nsh.json): Gib sie aus den Overrides des hostenden View Controllers zurück. |
limits, grantCapabilities, storageDirectory und cacheOptions gelten ab dem nächsten Laden: Setze sie vor loadBundle / run.
Jede App behält ihren Storage unter ihrer eigenen ID, ohne Einstellung: ein signiertes Paket unter der ID, für die das Zertifikat seines Release-Schlüssels bürgt, ein Bundle unter dem id seines nsh.json (app.json daneben, geschrieben von nsh build). Setze storageDirectory, um die Daten einer App an einem vom Host gewählten Ort abzulegen:
app.storageDirectory = FileManager.default
.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("nsh/com.example.orders")
try app.loadBundle(at: url)
#Mit der App kommunizieren
Die View und die App, die sie ausführt, kommunizieren über einen eigenen Kanal (host): Startparameter, die die App beim Start liest, Events der Host-App sowie Calls und Posts der App, mit JSON-Daten (auf dieser Seite Foundation-Objekte). Dafür ist keine View in der App und keine native Komponente nötig.
// Before loading: what the app opens first (`host.params` in TSX).
app.params = ["conversation": notification.conversationID]
try app.loadBundle(at: url)
// The app's `await host.call("user.profile", { id })`: return the answer, or throw to reject it.
app.handle("user.profile") { data in
let id = (data as? [String: Any])?["id"] as? Int ?? 0
let profile = try await api.profile(id: id)
return ["id": id, "name": profile.name]
}
// A handler that answers later, from any thread, with its reply.
app.handle("photo.pick") { _, reply in
picker.present { url in
if let url { reply.resolve(url.absoluteString) } else { reply.reject("cancelled") }
}
}
// The app's `host.post("analytics", …)`: no answer.
app.handle("analytics") { data, _ in analytics.log(data) }
// Later: the app hears `host.on("conversation.open", …)`.
app.send("conversation.open", ["id": tapped.conversationID])
Handler laufen auf dem Main Thread; ein Call wartet in der App auf seine Antwort, ohne eine der beiden Seiten zu blockieren. Ein Reply, das unbeantwortet freigegeben wird, lässt den Call fehlschlagen, und ein Call eines Namens ohne Handler schlägt sofort fehl. Events mit Namen, auf die die App noch nicht hört, werden aufbewahrt, bis sie es tut, und Events, die gesendet werden, während keine App läuft (vor loadBundle, während eines Reloads), gehen an die nächste, sodass eine getippte Benachrichtigung, während die App startet, sie dennoch erreicht.
#Theme-Overrides
Eine App, die in mehrere gebrandete Host-Apps eingebettet ist, übernimmt die Marke des jeweiligen Hosts: Der Host gibt die Tokens an, die seine Marke ändert, als Theme-Datei im Format von nsh.json "theme" (cssVars von shadcn/ui, Werte so, wie CSS sie schreibt). Sie haben Vorrang vor den Tokens der App mit denselben Namen; die übrigen Tokens der App bleiben bestehen. Gib einem Token seinen Dark-Mode-Wert unter dark an (ein Wert, der nur unter light oder theme steht, ist diese Farbe in beiden Modi):
try app.setThemeOverrides("""
{ "cssVars": {
"theme": { "radius": "0.75rem" },
"light": { "primary": "#e11d48", "primary-foreground": "#ffffff" },
"dark": { "primary": "#fb7185", "primary-foreground": "#1c1917" }
} }
""")
try app.loadBundle(at: url)
Rufe es vor oder nach dem Laden auf: Klassen wie bg-primary und rounded-(--radius) werden sofort neu gestylt, und jede später geladene App startet mit den Overrides in ihrem ersten Frame. setThemeOverrides(nil) gibt der App ihr eigenes Theme zurück.
#Links, die die App öffnet
Die URLs, die die App öffnet (openUrl, die Links einer TextView mit links="open"), erreichen zuerst onOpenURL, auf dem Main Thread: Gib true zurück, wenn die Host-App die URL behandelt hat, etwa indem sie sie über ihren eigenen Router geleitet hat, und false, damit das System sie mit UIApplication.open öffnet. Ohne onOpenURL öffnet das System jede URL. URLs erreichen es, nachdem die Capability openUrl der App sie erlaubt hat.
app.onOpenURL = { url in
// The host app's own links open in the host app.
if url.scheme == "lb" || url.host == "longbridge.com" {
router.open(url)
return true
}
return false
}
#Limits
app.limits = NshLimits(memoryMB: 128, callBudget: 1, maxNodes: 50_000)
NshLimits-Feld |
Standard | Bedeutung |
|---|---|---|
memoryMB: UInt32? |
256 | JS-Heap in MB. Ist er erschöpft, wird die App gestoppt. |
stackKB: UInt32? |
4096 (zugleich das Maximum) | Interpreter-Stack in KB. Tiefere Rekursion wirft einen RangeError. |
loadBudget: TimeInterval? |
10 s | Zeit zum Auswerten des Bundles. |
callBudget: TimeInterval? |
2 s | Zeit jedes anderen Aufrufs in JS (ein Event, ein Timer, ein Frame). Ein Aufruf, der das Limit überschreitet, wird abgebrochen. |
maxNodes: UInt32? |
200 000 | Nodes, die die App behalten darf. |
nil behält den Standard. Was jedes Limit bewirkt, wenn es erreicht wird: Sandbox und Capabilities.
#Capabilities
Eine App deklariert in ihrer nsh.json, worauf sie zugreifen darf; die einbettende App kann weniger gewähren. Eine Anfrage muss von beiden erlaubt sein:
try app.grantCapabilities("""
{
"network": { "hosts": ["api.example.com", "*.cdn.example.com"] },
"webview": { "hosts": ["help.example.com"] }
}
""")
try app.loadBundle(at: url)
Hier darf die App die Statusleiste nicht setzen (kein statusBar), selbst wenn ihre nsh.json danach fragt. Details: Sandbox und Capabilities.
#Signierte Pakete
Um Code auszuführen, den die App herunterlädt (Updates, Mini-Apps von Drittanbietern), verwende signierte Pakete: nsh publish schreibt eine .nshpkg, die mit einem Release-Schlüssel signiert ist, den dein Root-Schlüssel zertifiziert hat, und die App verifiziert sie, bevor sie sie ausführt.
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
}
}
Um zuerst zu verifizieren und später auszuführen, trenne die beiden Schritte:
let package = try await trust.openPackage(at: downloadedFile) // verifies off the main thread
// ... decide, ask the user, ...
app.run(package)
#Entwicklung
Mit NativeShellDev.xcframework richtest du die View auf einen Dev-Server statt auf ein Bundle:
#if DEBUG
try app.connectDevServer(URL(string: "ws://127.0.0.1:8790/ws")!)
#else
try app.loadBundle(at: bundledURL)
#endif
Führe nsh dev App.tsx auf deinem Mac aus. Der Simulator nutzt das Netzwerk des Macs, daher erreicht 127.0.0.1 den Server; ein
Gerät verwendet die LAN-Adresse, die nsh dev ausgibt (devices on this network: ws://192.168.1.20:8790/ws). Jedes Speichern
aktualisiert die App an Ort und Stelle, Logs erscheinen im Terminal von nsh dev, und Fehler werden über der App angezeigt. Siehe
Dev-Server und Debugging.
#Hinweise
- Threads.
NshAppViewist auf den Main Actor isoliert.NshTrust,NshPackageundNshComponentContextsind threadsicher. Die JS-Engine läuft auf einem eigenen Thread; der Main Thread wartet nie auf sie. - Statusleiste. Die Seiten und Dialoge der eingebetteten App (
statusBar) ändern, wasNshAppView.preferredStatusBarStyleund verwandte Methoden zurückgeben; der hostende View Controller muss sie weiterleiten, wie im obigen Beispiel. - Safe Area. Apps verwenden
safe_area()in Rust undenv(safe-area-inset-*)im Padding odersafeAreaInsets()ausnshin TSX, wie gewohnt. EinNshAppView, der unterhalb deines eigenen Headers platziert wird, erhält die Insets, die für ihn gelten, nicht die des Fensters. - Mac Catalyst. Dasselbe Framework funktioniert in Catalyst-Apps.