Docs

Getting started

NSH’s main use is hosting modules inside a native app (see Introduction). This guide follows that path: write a module, develop it with hot reload on the platforms you target, show it in your app, and ship an update as a signed package. It assumes nsh is installed and on your PATH (Installation); in a checkout, use ./target/debug/nsh instead.

guide-getting-started.tsx Edit in Playground Open in new tab
guide-getting-started.tsxLive
import { mount, signal, Text, View } from "nsh";
import { ui } from "./palette";

// The app `nsh init` creates, with a reset button and a short history.
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 }}>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)}
          activeOpacity={0.6}
          accessibilityRole="button"
          style={{
            paddingHorizontal: 20,
            paddingVertical: 12,
            borderRadius: 10,
            ...ui().raised,
          }}
        >
          <Text style={{ color: ui().text, fontSize: 17 }}>−1</Text>
        </View>
        <View
          onPress={() => change(1)}
          activeOpacity={0.6}
          accessibilityRole="button"
          style={{
            paddingHorizontal: 20,
            paddingVertical: 12,
            borderRadius: 10,
            ...ui().accentFill,
          }}
        >
          <Text style={{ color: ui().onAccent, fontSize: 17, fontWeight: "600" }}>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 a module

nsh init my-app

A module is what a host app loads: one or more .tsx files with an entry. nsh init creates the directory if it is missing and writes:

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

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. Files that already exist are kept, so you can run nsh init in a directory that has some of them.

#The entry module

import { mount, signal, Text, View } from "nsh";
import { SafeAreaView } from "nsh/ui/mobile";

function App() {
  const count = signal(0);
  return (
    <SafeAreaView style={{ flex: 1, alignItems: "center", justifyContent: "center", gap: 16 }}>
      <Text style={{ fontSize: 28, fontWeight: "600" }}>{count()}</Text>
      <View
        onPress={() => count.update((c) => c + 1)}
        activeOpacity={0.6}
        accessibilityRole="button"
        style={{ paddingHorizontal: 20, paddingVertical: 12, borderRadius: 10, backgroundColor: "#0969da" }}
      >
        <Text style={{ color: "white", fontSize: 17 }}>Tap</Text>
      </View>
    </SafeAreaView>
  );
}

mount(App);

Line by line:

  • import { mount, signal, Text, View } from "nsh": nsh is the core module: signals, mount, the other reactive primitives, and the host elements View and Text. It is served by the native runtime, not bundled.
  • import { SafeAreaView } from "nsh/ui/mobile": the mobile UI set. SafeAreaView pads its content so it stays clear of the notch, the status bar and the home indicator; on desktops, where the safe area is 0, it adds no padding.
  • function App() is a component: a function that returns JSX. It runs once. signal(0) makes a value that changes over time.
  • {count()} reads the signal inside JSX. The compiler sees a call and makes this text follow the signal: when count changes, the runtime updates this one text, nothing else.
  • <View onPress> is a native view that reacts to presses; activeOpacity fades it while pressed, natively. View, Text, Image, Scroll, Input and the other host elements are imported from NSH’s modules, like any component; only If and Each need no import.
  • style={{ … }} takes an object of camelCase style properties, with numbers in points. Layout is flexbox, and children stack in a column by default (Layout).
  • mount(App) renders the component into the app’s surface.

#Develop it

#Run it

nsh run starts a dev server, builds the app, launches a development host that loads it, and keeps rebuilding while you edit. Logs from the app (console.log, errors) appear in the terminal.

Without --target, nsh run uses the machine you are on: a Win32 window on Windows, a GTK window on Linux, and on macOS a Mac Catalyst window that shows the app as an iPhone would. Pick another platform with --target:

TargetCommandRuns from
iOS simulatornsh run my-app/App.tsx --target iosmacOS with Xcode
Androidnsh run my-app/App.tsx --target androidmacOS, Windows, Linux
macOS (AppKit)nsh run my-app/App.tsx --target macosmacOS
Windowsnsh run my-app/App.tsxWindows (the default)
Linux (GTK 4)nsh run my-app/App.tsxLinux (the default)
Mac Catalystnsh run my-app/App.tsxmacOS (the default)

#iOS simulator

nsh run my-app/App.tsx --target ios
nsh run my-app/App.tsx --target ios --device "iPhone 16 Pro"

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

#Android

nsh run my-app/App.tsx --target android

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

#macOS (AppKit)

nsh run my-app/App.tsx --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/App.tsx

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/App.tsx

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

#Mac Catalyst (default on macOS)

nsh run my-app/App.tsx

The app opens in an iPhone-sized window (390×844 points) on your Mac. This is the quickest way to see a module 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 with hot reload

Leave nsh run running and change the label in App.tsx:

<Text style={{ color: "white", fontSize: 17 }}>Add one</Text>

Save. The CLI rebuilds and sends a patch; 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. State survives when you edit a component that does not own it. Move the button into a component of its own:

function TapButton(props: { onPress: () => void }) {
  return (
    <View onPress={props.onPress} activeOpacity={0.6} style={{ padding: 12, borderRadius: 10, backgroundColor: "#0969da" }}>
      <Text style={{ color: "white", fontSize: 17 }}>Tap</Text>
    </View>
  );
}

Use <TapButton onPress={() => count.update((c) => c + 1)} /> in App, tap a few times, then change TapButton’s color: the button updates and 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 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

Your app shows a module in a view of its own: NshAppView (a UIView) on iOS, NshView (an Android View) on Android, AppView on macOS, Windows and Linux. Place it like any other view, on any screen, as many times as you need. During development, point it at the dev server; in a release, load a built bundle or a downloaded package.

#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: "qbc", subdirectory: "nsh")!)
#endif

nsh dev my-app/App.tsx 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/App.tsx --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/App.tsx --release --out app/src/main/assets/nsh`
}

#macOS, Windows and Linux

Desktop apps written in Rust embed modules 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 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 the 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 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/App.tsx --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

The same module can also be a whole app. nsh package builds an installable app around it from the prebuilt runtime, 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

This is a supported use on every platform, and the quickest way to put a module on a device or a desktop, but NSH is designed around hosting modules in native apps. Options, signing and icons: Packaging.

#Try it in the terminal

nsh trace runs an app headless (no device) and prints the native view operations it produces and their timings, then taps the first pressable view twice:

nsh trace my-app/App.tsx

It is a quick way to check that an app starts without errors, and to see how much work an interaction causes (Testing and trace).

#Next steps