Docs

Getting started

This guide creates an NSH app, runs it on the platforms you target, and ships it. NSH apps are written in Rust or TSX, on the same engine: Rust compiles your app into native code with no JavaScript VM, and TSX runs on QuickJS with hot updates, as modules a native app can load at run time. The guide follows a Rust app first, then the same app in TSX. It assumes nsh is installed and on your PATH (Installation); in a checkout, use ./target/debug/nsh instead.

guide-getting-started.tsx
guide-getting-started.tsxLive
import { mount, signal, Text, View } from "nsh";
import { desktop, size, ui } from "./palette";

// The app `nsh init` creates, with a reset button and a short history.

/** A button's box: a 32-point control with a pointer, a touch target otherwise. */
const button = () =>
  desktop
    ? { height: size.control, paddingHorizontal: 14, borderRadius: 8, justifyContent: "center" as const }
    : { paddingHorizontal: 20, paddingVertical: 12, borderRadius: 10 };
function App() {
  const count = signal(0);
  const history = signal<number[]>([]);

  const change = (delta: number) => {
    const next = count() + delta;
    count.set(next);
    history.update((h) => [next, ...h].slice(0, 4));
  };

  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, alignItems: "center", justifyContent: "center", gap: 16 }}>
      <Text style={{ fontSize: 15, color: ui().muted }}>{desktop ? "You clicked" : "You tapped"}</Text>
      <Text style={{ fontSize: 56, fontWeight: "700", color: count() < 0 ? ui().down : ui().text }}>{count()}</Text>
      <View style={{ flexDirection: "row", gap: 12 }}>
        <View
          onPress={() => change(-1)}
          activeStyle={{ opacity: 0.6 }}
          role="button"
          style={{
            ...button(),
            ...ui().raised,
          }}
        >
          <Text style={{ color: ui().text, fontSize: desktop ? 14 : 17 }}>−1</Text>
        </View>
        <View
          onPress={() => change(1)}
          activeStyle={{ opacity: 0.6 }}
          role="button"
          style={{
            ...button(),
            ...ui().accentFill,
          }}
        >
          <Text style={{ color: ui().onAccent, fontSize: desktop ? 14 : 17, fontWeight: "600" }}>{desktop ? "+1" : "Tap"}</Text>
        </View>
      </View>
      <If when={history().length > 0}>
        <Text style={{ fontSize: 13, color: ui().muted }}>Recent: {history().join(", ")}</Text>
      </If>
      <View onPress={() => (count.set(0), history.set([]))} hitSlop={8}>
        <Text style={{ fontSize: 15, color: ui().accent }}>Reset</Text>
      </View>
    </View>
  );
}

mount(App);

#Create an app

On a terminal, nsh init asks what the app is written in: rust, ts or mixed (TSX with a Rust crate of its own). --template chooses without asking.

#In Rust

nsh init my-app --template rust

nsh init creates the directory and writes a Cargo project:

FileWhat it is
src/lib.rsThe app: its root component, state and handlers
src/main.rsMounts the same root on macOS, Windows or Linux for cargo run
Cargo.tomlThe crate, depending on nsh-core and nsh-base at the CLI’s version
nsh.jsonThe app’s name, id, version and build number, its crate ("rust": ".") and root component ("root": "App") (Project structure)
App.test.tsxThe root’s test, in TypeScript (nsh test), with tsconfig.json and .nsh/types for your editor
README.mdHow to run, test and package the app, and how to add a TSX entry later
.gitignoreIgnores .nsh/ and target/

Rust development needs a Rust toolchain (rustup); the nsh commands build the app’s runners from NSH’s crates on crates.io, at the CLI’s version.

#In TSX

nsh init my-app --template ts

A TSX app is one or more .tsx files with an entry: what nsh run runs, and what a host app loads as a module.

FileWhat it is
App.tsxThe entry module: renders the app with mount
App.test.tsxIts test (nsh test)
nsh.jsonThe app’s name, id, version and build number (Project structure)
tsconfig.jsonExtends .nsh/tsconfig.json, NSH’s TypeScript settings, which resolve nsh to the declarations in .nsh/types
.gitignoreIgnores .nsh/ and target/
.nsh/types/Generated declarations of every NSH module, for your editor

Files that already exist are kept, so you can run it in a directory that has some of them; a Rust app is created in a new directory.

For both, the display name defaults to the directory’s name; pass --name "My App" to choose another. The id becomes com.example.<name>: change it in nsh.json before you ship. nsh init my-app --template mixed creates a TSX app that is also a Rust crate (Cargo.toml and src/lib.rs beside App.tsx), whose component, module and class App.tsx imports (Building with Rust).

#The root component

The two templates make the same app, a counter:

// src/lib.rs
use nsh_base::{safe_area_view, theme};
use nsh_core::ui::prelude::*;

/// The crate's UI set: what a TSX entry may import from `my-app/native`.
#[native_module("my-app/native")]
fn native(registry: &mut Registry) {
    registry.register::<App>();
}

/// The application's root component.
#[component]
pub fn App() -> impl IntoElement {
    let count = signal(0_u32);
    view()
        .flex_1()
        .bg(move || theme().background)
        .text_color(move || theme().foreground)
        .child(
            safe_area_view()
                .flex_1()
                .child(
                    view()
                        .flex_1()
                        .items_center()
                        .justify_center()
                        .gap_4()
                        .child(text(move || format!("Count: {}", count.get())).text_size(px(28.0)))
                        .child(
                            view()
                                .px_5()
                                .py_3()
                                .rounded(px(10.0))
                                .bg(move || theme().primary)
                                .active(|s| s.opacity(0.6))
                                .on_press(move || count.update(|value| *value += 1))
                                .child(
                                    text("Tap")
                                        .text_size(px(17.0))
                                        .text_color(move || theme().primary_foreground),
                                ),
                        ),
                ),
        )
}

In Rust:

  • use nsh_core::ui::prelude::* brings the primitives (view(), text(), scroll(), input(), image(), …), signals and the typed style methods; nsh_base has the unstyled base components (safe_area_view(), button(), the keyboard, dialogs) and theme(), the colors and radii the app’s components draw with (Theme).
  • #[component] pub fn App() is a component: a function that runs once and returns its elements. nsh.json’s "root": "App" names it, and nsh run mounts it. signal(0_u32) makes a value that changes over time.
  • text(move || format!(…, count.get())) binds the text to the signal: when count changes, NSH runs this closure again and updates this one text, nothing else. Styles take closures the same way: .bg(move || theme().primary) follows the theme into dark mode.
  • .on_press(..) makes the view answer presses; .active(|s| s.opacity(0.6)) is its style while pressed, applied natively. The button is the app’s own: NSH provides the primitives and the capabilities, and the app draws its UI from them.
  • .px_5(), .rounded(px(10.0)), .gap_4() are typed style methods named as Tailwind’s utilities are, in points (px). Layout is flexbox, and children stack in a column by default (Layout).
  • #[native_module("my-app/native")] names what a TSX entry may import from the crate, for when you add TSX screens on top (Building with Rust).

In TSX:

  • import { mount, signal, Text, View } from "nsh": nsh is the core module: signals, mount, the other reactive primitives, and the host elements. It is served by the native runtime, not bundled. nsh/base has the base components, as nsh_base does in Rust.
  • function App() is a component that runs once. {count()} reads the signal inside JSX: the compiler sees a call and makes this text follow the signal.
  • <View onPress> is a native view that reacts to presses; active: classes are its style while pressed. View, Text, Image, Scroll, Input and the other host elements are imported from nsh; only If and Each need no import.
  • class takes Tailwind classes; style={{ … }} takes an object of camelCase style properties, with numbers in points. bg-background, bg-primary, text-primary-foreground and rounded-(--radius) name the tokens of the app’s theme (Theme tokens): they follow dark mode by themselves, and "theme" in nsh.json restyles them.
  • mount(App) renders the component into the app’s surface.

#Develop it

#Run it

nsh run builds the app, launches it and rebuilds while you edit; the app’s logs appear in the terminal. Give it the app’s directory: it runs a Rust app’s root, or a TSX app’s App.tsx (nsh run my-app/App.tsx names another entry).

For a Rust app, nsh run builds the app’s own runner for the target, with your crate and the root mounted and no QuickJS, and starts it; a change to the Rust code builds it again and restarts the app. cargo run in the app’s directory starts the same root straight on the desktop backend. For a TSX app, nsh run starts a dev server and a development host that loads the app from it, and patches the running app on every save.

Without --target, nsh run uses the machine you are on: an AppKit window on macOS, a Win32 window on Windows and a GTK window on Linux. Pick another platform with --target:

TargetCommandRuns from
iOS simulatornsh run my-app --target iosmacOS with Xcode
Androidnsh run my-app --target androidmacOS, Windows, Linux
macOS (AppKit)nsh run my-appmacOS (the default)
Windowsnsh run my-appWindows (the default)
Linux (GTK 4)nsh run my-appLinux (the default)
Webnsh run my-app --target webmacOS, Windows, Linux
Mac Catalystnsh run my-app --target catalystmacOS

#iOS simulator

nsh run my-app --target ios
nsh run my-app --target ios --device "iPhone 16 Pro"

nsh uses a booted simulator, or boots the newest iPhone if none is running, and installs the app (a Rust app’s runner, or the development host) into it. --device picks a simulator by name or UDID.

#Android

nsh run my-app --target android

Start an emulator or connect a device first (adb devices lists them; NSH uses the first one). nsh installs the app on it: a Rust app’s own libnsh.so, or a development host APK (rebuilt only when the runtime changes) whose dev server port is forwarded to the device with adb reverse, so it works over USB without a shared network.

#macOS (AppKit)

nsh run my-app --target macos

The app opens as a desktop Mac app (AppKit, in a resizable window), with mouse, trackpad and keyboard input instead of touches. It needs macOS 14 or later; see macOS.

#Windows (default on Windows)

nsh run my-app

The app opens in a desktop window (1200×800 points), with mouse, touchpad, touch and keyboard input. It needs Windows 10 1809 or later; see Windows.

#Linux (default on Linux)

nsh run my-app

The app opens in a GTK window (390×844 points). It needs GTK 4.14 or later; see Linux.

#Web

nsh run my-app --target web

nsh prints the page’s address; open it in a browser. A Rust app runs as WebAssembly with your crate, and the open page loads the new build by itself after a change; see Web.

#Mac Catalyst

nsh run my-app --target catalyst

The app opens in an iPhone-sized window (390×844 points) on your Mac. This is the quickest way to see an app as a phone shows it: no simulator to boot. It uses UIKit like iOS, so what you see is what an iPhone shows, apart from Mac-specific behavior such as scrolling with a mouse wheel. For the Mac app itself, use --target macos.

#Edit it

Leave nsh run running and change the button’s label:

text("Add one")
    .text_size(px(17.0))
    .text_color(move || theme().primary_foreground)

Save. A Rust app is built again and restarts with the new label; the count starts over. A TSX app gets a hot update: the CLI rebuilds and sends a patch, and the host runs the changed module again and renders again only the components whose code changed. Here that is App, which owns the count signal, so the count starts over too.

Pieces of UI move into components of their own, or plain functions that return elements:

/// The app's button: a view that answers presses.
fn tap_button(on_press: impl Fn() + 'static) -> impl IntoElement {
    view()
        .p_3()
        .rounded(px(10.0))
        .bg(move || theme().primary)
        .active(|s| s.opacity(0.6))
        .on_press(on_press)
        .child(text("Tap").text_size(px(17.0)).text_color(move || theme().primary_foreground))
}

// In App: .child(tap_button(move || count.update(|value| *value += 1)))

In TSX, state survives when you edit a component that does not own it: tap a few times, then change TapButton’s color, and the button updates while the count stays. Hot replacement works per top-level component: a function with a capitalized name whose body contains JSX. A component renders again when its own code changes, or when the module’s code outside components changes (a constant it uses, a helper). If an edit cannot be applied in place (for example, it imports an NSH module the running app did not import yet, or changes nsh.json’s capabilities), the host reloads the whole app.

#Errors

A Rust compile error is printed in the terminal with cargo’s output, and nsh run builds again with the next save. A TSX build error is printed in the terminal and shown on the device as a red overlay with the file, line and column; the app keeps running the last good build. The overlay disappears with the next successful build (tap it to dismiss it earlier). An exception thrown at run time is shown the same way, with its stack mapped back to your .tsx source.

#Host it in your app

A native app shows NSH in a view of its own: NshAppView (a UIView) on iOS, NshView (an Android View) on Android, AppView on macOS, Windows, Linux and in GPUI apps. Place it like any other view, on any screen, as many times as you need.

A Rust root is part of the app’s build: the app links your crate and mounts the root with AppSource::Rust on the AppView of macOS, Windows, Linux, GPUI and iOS (Building with Rust). A TSX app is a module the view loads: during development from the dev server, in a release from a built bundle or a downloaded, signed package. The sections below load TSX modules.

#iOS

Link NativeShellDev.xcframework in Debug and NativeShell.xcframework in Release (Embedding in iOS apps), then:

import NativeShell

let app = NshAppView()
app.onError = { message in print("nsh error: \(message)") }
view.addSubview(app)   // and constrain it like any view

#if DEBUG
try app.connectDevServer(URL(string: "ws://127.0.0.1:8790/ws")!)   // `nsh dev my-app` on your Mac
#else
try app.loadBundle(at: Bundle.main.url(forResource: "bundle", withExtension: "nshc", subdirectory: "nsh")!)
#endif

nsh dev my-app serves the module to the app and pushes every save, as nsh run does with its own host. For the release build, nsh build my-app --release --out MyApp/nsh writes the bundle into a folder you add to the app’s resources.

#Android

Add nsh-dev.aar for debug builds and nsh.aar for release builds (Embedding in Android apps), then:

val nsh = NshView(this)
nsh.setErrorListener { message -> Log.e("nsh", message) }
setContentView(nsh)   // or add it to any layout

if (BuildConfig.DEBUG) {
    nsh.connectDevServer("ws://127.0.0.1:8790/ws")   // with `adb reverse tcp:8790 tcp:8790`
} else {
    nsh.loadAssetBundle("nsh")   // assets/nsh, from `nsh build my-app --release --out app/src/main/assets/nsh`
}

#macOS, Windows and Linux

Desktop apps written in Rust embed NSH with AppView, from the nsh-platform-macos, nsh-platform-windows, nsh-platform-linux and nsh-platform-gpui crates: an NSView, a child window, a GTK widget or a GPUI view that mounts a Rust root or loads a built bundle. See macOS, Windows, Linux and GPUI, and Platforms for every host side by side.

#What the module may do

On iOS and Android, the host decides before loading what a module may do: grantCapabilities sets the network hosts and other capabilities it may reach, and limits cap its memory, call time and node count (Sandbox and capabilities). To give modules your own native views (a map, a chart, a design-system component), register them as native components.

#Ship updates as signed packages

A TSX module the app downloads after release comes as a signed package. Create a root key once and keep it offline, certify a release key with it, and publish:

nsh keygen keys/root && nsh keygen keys/release
nsh certify --root keys/root.secret --key keys/release.pub --apps 'com.example.*' -o keys/release.cert
nsh publish my-app --key keys/release.secret --cert keys/release.cert --build 2

The app trusts the root’s public key and loads what it downloads; NSH verifies the signature, the certificate and the revocation list before running anything:

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 keys/root.pub
let info = try await app.loadPackage(at: downloadedFile, trust: trust)

Android has the same flow with NshTrust and NshPackage. Hosting, delivery and revocation in detail: Signed packages.

#Or ship it as a standalone app

nsh package builds an installable app, Rust or TSX, with no Xcode project or Gradle build of your own:

nsh package my-app --target ios-simulator --run
nsh package my-app --target android
nsh package my-app --target macos
nsh package my-app --target linux
nsh package my-app --target windows
nsh package my-app --target web

A Rust app is packaged with its own runner, built from your crate and NSH’s crates, its root mounted; a TSX app with the prebuilt runtime. Options, signing and icons: Packaging.

#Try it in the terminal

nsh test runs an app headless, with no device or window. Given a Rust app’s nsh.json or a TSX app’s entry, it opens the app, lets it run and prints what it mounts; with --trace, also the native view operations and the time of every commit (Testing):

nsh test my-app/nsh.json --trace     # a Rust app's root
nsh test my-app/App.tsx --trace      # a TSX app's entry

Given a directory, it runs the app’s tests: each template’s App.test.tsx opens the app, presses its button and checks what it shows (nsh test my-app). A Rust app’s opens its root (open(".")); it can also be tested in cargo test with nsh_testing::Headless (Building with Rust).

#Next steps