Getting started
Install the nd CLI, create an app, run it on the desktop and in a browser, and run its tests.
This page takes you from installing the nd CLI to a running, tested app.

Captured on macOS 27.0, 2026-10-22.

Captured on NixOS 26.11 (Zokor), Hyprland, 2026-10-22.

Captured on Windows 11 26H2, 2026-10-22.
Install
nd releases are git tags on github.com/FormalStudio/NativeDesktop, not crates.io versions. nd builds on a patched GPUI, and crates.io doesn't accept git or patched dependencies.
- Install rustup and git.
- On macOS, install Xcode or the Command Line Tools (
xcode-select --install). The AppKit backend links Swift. - If you use the terminal widget (
nd::terminal), put Zig 0.16 onPATH. - Install
cargo-nextest, whichnd testruns through, and then the CLI from the release tag. The CLI installs a binary namednd.
cargo install cargo-nextest --locked
cargo install --git https://github.com/FormalStudio/NativeDesktop --tag v0.6.1 --locked nd-cli
--locked builds with the versions that nd was tested with. Without it, cargo takes the newest releases, and some of those have since been yanked. In a clone of the repository, nix develop gives you all of these tools, and cargo install --path crates/nd-cli installs the CLI from the checkout.
Windows
Run these commands in PowerShell:
winget install --id Microsoft.VisualStudio.2022.BuildTools --override "--quiet --wait --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended"
winget install --id Rustlang.Rustup
winget install --id Git.Git
The first command installs the MSVC compiler, the linker, and the Windows SDK that the x86_64-pc-windows-msvc toolchain links with. Then:
- Open a new terminal so that
cargoandgitare onPATH. - Run the two
cargo installcommands from the previous section.
nd package windows also needs the .NET SDK (winget install --id Microsoft.DotNet.SDK.8), which it uses to fetch Velopack's vpk.
A debug build of an app and its tests fills several gigabytes under target. If C: is low on space, set CARGO_TARGET_DIR to a folder on another drive.
Create an app
nd new demo
The new project depends on nd at the CLI's own release tag (tag = "v0.6.1" for CLI 0.6.1), and its [patch.crates-io] block points the GPUI crates at the fork vendored in the same tag. nd new also writes nd's Cargo.lock, so the first build resolves to the versions nd was released with. Add --standalone if the new directory is inside another Cargo workspace. The name must start with a lowercase letter and can contain lowercase letters, digits, -, and _. --id sets the reverse-DNS app id, which defaults to com.example.demo. To work against a local checkout of nd instead, pass --nd-path /path/to/nativedesktop/crates/nd (or set ND_PATH).
nd new refuses a directory that isn't empty. It writes these files:
| File | What it is |
|---|---|
src/app.rs | The Counter view. This is the file you edit first. |
src/lib.rs, src/main.rs | app(), open() and run(); main calls run. |
src/demo.rs | Fixed state when <NAME>_DEMO is set (DEMO_DEMO=1 here), for tests and screenshots. |
tests/app.rs | A tier 1 test that clicks the button on the test backend. |
tests/appearance.rs | The tier 2 appearance audit: one .window(..) per window you add. |
nativedesktop.toml | App id, display name, and update feed. |
build.rs | One call, nd::build::app(), which links the app's Swift on macOS. |
justfile | dev, check, and test recipes. |
CLAUDE.md, README.md | The rules an agent follows in this project, and a README with nd: placeholders. |
rustfmt.toml, clippy.toml, .config/nextest.toml | Format, lint, and test-tier settings. |
Run it
cd demo
just dev
just dev runs cargo run. On macOS, the window uses AppKit. On Linux and Windows, it uses the GPUI backend. On a target that has no backend, run returns no platform backend is linked for this target.
Check and test
nd check
nd test
nd check runs cargo fmt --check, clippy with the framework's deny table (unwrap_used, expect_used, dbg_macro, todo, and warnings), the framework lints, and then the appearance audit. Add --no-audit to skip the audit, or --target web to add the web lints. nd test runs tiers 1 and 2 through nextest with no window server. nd test --tier real runs tier 3, which opens real windows one at a time. For details on each tier, see the testing page.
Keep nd up to date
nd upgrade
This moves the nd dependencies and the GPUI patch block to the newest release tag, updates Cargo.lock, and prints the changelog entries between the two versions. To pick a tag, add --to v0.7.0. Then install the matching CLI with the same cargo install --git ... --tag <tag> --locked nd-cli command.
Run it in a browser
nd serve --target web
This compiles the app to WebAssembly, runs wasm-bindgen and serves the bundle at http://127.0.0.1:8080/ (--port changes it). The first run installs a wasm-bindgen-cli that matches your lockfile. nd build --target web writes the bundle without serving it. Some framework calls don't exist in a browser. nd check --target web flags them, and the web guide covers the rest.
The hello app
Hello is the scaffold's counter with a toolbar button. The view is a struct and an impl Render:
pub struct Counter {
count: u32,
}
impl Render for Counter {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = cx.theme();
scaffold()
.toolbar(toolbar().item(button("add", "Add one").on_click(cx.listener(
|this, _: &ClickEvent, _window, cx| {
this.count += 1;
cx.notify();
},
))))
.content(
v_flex()
.size_full()
.items_center()
.justify_center()
.gap_2()
.child(window_controls().at(point(px(16.), px(10.))))
.child(text(self.count.to_string()).text_size(px(48.)).tabular_nums())
.child(text("clicks").text_color(theme.text.secondary)),
)
}
}
From examples/hello/src/lib.rs, line 4.
scaffold() is the window structure: a toolbar and a content area. button("add", "Add one") is a toolbar item; the string "add" is its id, which tests use to find it. cx.listener wraps the click handler so that it receives &mut Counter. cx.notify() marks this view dirty, and the next frame runs render again. cx.theme() provides color tokens, so the secondary text color isn't a literal.
Two functions open the window. Application::new takes the manifest that build.rs filled from nativedesktop.toml, and open builds the root view as an entity:
pub fn app() -> Application {
Application::new(nd::manifest!())
}
pub fn open(cx: &mut App) -> WindowHandle<Counter> {
cx.open_window(WindowOptions::main("main").title("Hello"), |_window, cx| cx.new(|_cx| Counter { count: 0 }))
}
From examples/hello/src/lib.rs, line 35.
src/main.rs is hello::app().run(|cx| { hello::open(cx); }). The test drives the same open:
#[nd::test]
fn starts_at_zero(cx: &mut TestApp) {
let window = hello::open(cx).id();
assert!(text(cx, window).contains("0 clicks"), "{}", text(cx, window));
}
#[nd::test]
fn add_one_counts_each_click(cx: &mut TestApp) {
let window = hello::open(cx).id();
click(cx, window, "add");
click(cx, window, "add");
assert!(text(cx, window).contains("2 clicks"), "{}", text(cx, window));
}
From examples/hello/tests/tier1.rs, line 7.
A #[nd::test] test runs natively, and in headless Chromium when you compile it for wasm32-unknown-unknown. Next: the programming model.