Call any AppKit, UIKit or Android API directly from a Native Shell (nsh) TSX app, with no Rust, Swift, Kotlin or Java.
This repo runs NativeScript inside nsh's own JavaScript VM. One TSX app reaches each platform's real native APIs, places native views in nsh's layout, and updates live as you edit. Native callbacks update nsh signals.
| macOS | iOS | Android |
|---|---|---|
![]() |
![]() |
![]() |
Experimental, and not an official NativeScript or
nshrelease. Tested on a macOS 26 Mac, an iOS 26.5 simulator and an Android 16 emulator.nshneeds three small hooks (patch), which would need to land upstream.
A real menu bar item, in TypeScript, in the same VM as your nsh signals:
const clicks = signal(0);
const item = NSStatusBar.systemStatusBar.statusItemWithLength(NSVariableStatusItemLength);
item.button.image = NSImage.imageWithSystemSymbolNameAccessibilityDescription("bolt.fill", "Status");
item.button.target = target; // a NativeClass whose action calls clicks.set(clicks() + 1)
mount(() => <Text>Clicks: {clicks()}</Text>);A native view in nsh's layout, written in TypeScript and bound to a signal both ways:
<NativeView
style={{ height: 36, alignSelf: "stretch" }}
create={() => controls.slider((value) => level.set(value))}
update={(slider) => controls.setSlider(slider, level())}
/>It sizes, scrolls and clips like any other element. Each platform needs only a small generic container, and native-view.tsx does the rest. The showcase is in apps/showcase.
Without this, an API nsh doesn't wrap needs native code for each platform. The same macOS tray, with the same click, menu, tooltip and accessibility behaviour:
nsh built-in tray |
This repo | |
|---|---|---|
| Language | Rust | TypeScript |
| macOS implementation | 242 lines | 97 lines |
| Change it | Rebuild and ship the framework | Rebundle in milliseconds |
The built-in tray also loads icons through nsh's image pipeline and offers one API across platforms. The TypeScript version takes SF Symbols and is macOS only.
Median of five process runs, each the median of seven timed loops, in nanoseconds.
Calling a platform API that must run on the main thread:
| Route | macOS | iOS simulator | Android emulator |
|---|---|---|---|
Rust nsh module, standard route (dispatch_sync / Handler) |
8,162 | 8,106 | 92,558 |
Rust nsh module, using this bridge's hand-off |
189 | 204 | 267 |
| TypeScript through NativeScript | 733 | 434 | 422 |
Updating the tray (macOS, two AppKit property sets per update):
| Route | Time |
|---|---|
Rust nsh module, standard route |
8,873 |
Rust nsh module, using this bridge's hand-off |
619 |
| TypeScript through NativeScript | 1,960 |
Calls that can stay off the main thread:
| Route | macOS | iOS simulator | Android emulator |
|---|---|---|---|
Rust nsh module |
38 | 49 | 61 |
| TypeScript through NativeScript | 532 | 291 | 223 |
What this shows:
- TypeScript is 11–219× faster than the standard Rust route to the main thread, because that route wakes a sleeping thread on every call.
- Rust is still faster on equal footing. With the same hand-off, TypeScript adds 0.15–0.54 µs per call for full dynamic access, with no native code to write or ship.
- Calls that don't need the main thread are where NativeScript costs the most: 4–14× slower than Rust, though still under a microsecond.
The first call after both threads have gone idle pays a full thread wake-up on every route, and costs roughly the same for Rust and TypeScript. Results come from a simulator and emulator on one Mac, so they do not represent a phone. Raw data is in apps/bench/results.
What adding NativeScript costs, measured against the same app with NativeScript off. "Trimmed" keeps only the native APIs the app uses, plus the types they depend on.
| macOS | iOS | Android | |
|---|---|---|---|
| API metadata, full SDK | 4.7 MB | 4.6 MB | 2.9 MB |
| API metadata, trimmed for the showcase | 0.67 MB | 0.83 MB | 0.81 MB |
| Added on disk, full SDK | 8.2 MB | 7.2 MB | 6.7 MB |
| Added on disk, trimmed | 4.2 MB | 3.4 MB | about 4.6 MB |
| Added to a compressed download, full SDK | 2.4 MB | 1.9 MB | 2.6 MB |
| Added to a compressed download, trimmed | 0.95 MB | 0.84 MB | about 1.9 MB |
| Extra memory at idle, full SDK | +26 MB | +16 MB | +6 MB |
| Extra memory at idle, trimmed | +6 MB | +9 MB | see below |
| Startup added at launch, full SDK | about 54 ms | about 36 ms | about 35–50 ms |
| Startup added at launch, trimmed | about 37 ms | about 17 ms | no clear change |
Memory and startup use a blank app. iOS numbers come from the simulator and Android's from an emulator. Sizes are for one CPU architecture. The bridge itself is about 75 KB of code on macOS.
On Android, trimming also turns on R8 code shrinking. That took total app memory from 50.0 MB to 40.3 MB and the APK from 23.8 MB to 21.4 MB, but most of that comes from the shrunk app code rather than the metadata, so the Android memory cost of trimmed metadata alone isn't isolated here.
ns metadata trim, a NativeScript CLI command in draft (RFC), reads the emitted bundle, lists the native APIs it names, and generates metadata for those and everything they depend on. On Apple platforms it also shrinks the framework binary to fit. If the bundle reads globals dynamically, such as through an alias of globalThis, the CLI stops rather than risk removing something the app needs.
There is one JS engine in the app: the QuickJS that nsh already ships.
- NativeScript brings no engine here. Its macOS and iOS packages are engine-agnostic and bind through Node-API, and the Android runtime is built without its own QuickJS.
- TSX, signals and native calls share one VM and one heap, so nothing is copied between engines.
nshusesquickjs-jit, based on quickjs-ng. At the pinned commit the separate JIT compiler is not linked, so JS runs in the bytecode interpreter.- App logic therefore runs at QuickJS speed, not V8 speed. An empty JS function call takes about 8 ns here and 0.3 ns on V8, so CPU-heavy JS is the main thing that gets slower compared with a standard V8-based NativeScript app.
- The bridge is compiled against
nsh's exact QuickJS headers, so it has to be rebuilt whennshupgrades its engine.
nshruns JS on a dedicated engine thread, while platform UI work must run on the main thread.- NativeScript's runtime is engine-agnostic through Node-API, so
crates/nsh-ns/c/quickjs-api.cprovides Node-API onnsh's own QuickJS VM. baton.cpasses control between the two threads. JS always runs on the engine, and native calls run on main by default.- Each thread spins briefly on a shared slot before sleeping, so a burst of native calls needs no kernel wake-up after the first.
- On Apple platforms, NativeScript's roughly 60,000 global accessors are built on first use instead of at startup, which saves about 24 MB.
- A native view is an empty container
nshlays out. Creating it only reports its address, so the main thread never has to enter JS whilenshapplies a commit, and TypeScript fills it on the next turn. - Live edits are evaluated in the running VM, so native objects and app state survive. A from-scratch reload (Cmd+R) needs a new VM, and NativeScript binds to one VM per process, so on macOS the process restarts itself.
- Experimental. Not tested on physical phones. Linux and Windows are not implemented yet.
- Sandbox. NativeScript bypasses
nsh's capability system, so don't enable it for untrusted signed modules. - Teardown. The VM is deliberately never freed once NativeScript is attached, so tearing down a view leaks it.
- Quitting from JS. Calling
NSApplication.terminatedirectly from JS hangs, because quitting waits on the engine thread that is waiting on the call. Defer it withperformSelector:withObject:afterDelay:, as a Quit menu item does. - Full reloads on phones. Live edits work on iOS and Android, but a from-scratch reload exits the app there, and it has to be reopened.
- Trimming. Apps that look up native APIs by computed names can't be trimmed automatically;
--keepadds what the analysis can't see. Trimmed builds need the app's own testing, because a missing type only shows up when that code runs. - Blocking native calls. A native module that blocks on the main thread from a JS callback the main thread is waiting on will deadlock.
- Long blocking calls.
nshinterrupts a JS turn after 2 seconds, so use asynchronous sheets over synchronous modals.
Full notes are in docs/research-notes.md.
You need macOS, Rust stable, Node.js, Xcode, and for Android the Android SDK with NDK 30.0.16248370.
scripts/setup.sh # fetches pinned nsh and NativeScript, applies patches
scripts/run-macos.sh apps/showcase # macOSiOS simulator:
(cd vendor/nsh && cargo +stable build -p nsh-ns --release --target aarch64-apple-ios-sim)
scripts/ios-sim.sh apps/showcase <simulator-udid> /tmp/showcase.logLive reload, editing TSX or native-calling TypeScript while the app runs:
scripts/dev-macos.sh apps/showcase
scripts/dev-ios-sim.sh apps/showcase <simulator-udid> /tmp/showcase.log
scripts/dev-android.sh apps/showcaseTrim metadata to what an app uses, with a NativeScript CLI checkout of the feat/metadata-trim branch:
scripts/build-metadata-tools.sh
NS_CLI=<nativescript-cli checkout> scripts/trim-metadata.sh apps/showcase macos # also: ios, androidThe script prints the setting to run the trimmed build with.
Android, with an arm64 emulator running:
scripts/build-android.sh
cp vendor/nsh/target/aarch64-linux-android/release/libnsh_android_host.so apps/android-host/jniLibs/arm64-v8a/libnsh.so
scripts/android-apk.sh apps/showcase
scripts/android-run.sh 8MIT, except for third-party code under its own terms:
crates/nsh-ns/c/quickjs-api.cis adapted from NativeScript runtimes, and the files inpatches/modify NativeScript runtimes andnsh. These are Apache-2.0.- The headers in
crates/nsh-ns/ccome from QuickJS, Node.js and NativeScript runtimes, and keep their original notices.


