NatUI
Guides

Application bundles

Package a NatUI entry as a native macOS app or a portable Windows executable.

NatUI includes a repository-local reference packager for turning an embedded React entry into a native application artifact. The packaged application runs React inside JavaScriptCore on macOS or V8 on Windows. It does not start Node at application runtime.

Package the demo

From the repository root:

pnpm package:demo

The command builds packages/natui, reads examples/demo/natui.app.json, bundles the configured entry with esbuild, and builds the native host for the current operating system and architecture.

Output goes to examples/demo/dist/package:

  • Windows x64: NatUIDemo-<version>-windows-x64.exe
  • Windows ARM64: NatUIDemo-<version>-windows-arm64.exe
  • macOS: NatUIDemo.app

<version> is the version field from natui.app.json.

Packaging is platform-specific. Build each macOS architecture on that architecture. Windows packaging supports x64 and ARM64 targets.

Application configuration

create-natui-app writes an Expo-style static config with one shared source entry and platform-specific native icon paths:

{
  "$schema": "https://natui.dev/schemas/natui-app.schema.json",
  "schemaVersion": 1,
  "id": "com.example.myapp",
  "name": "My App",
  "version": "0.1.0",
  "buildNumber": "1",
  "entry": "src/main.tsx",
  "executable": "MyApp",
  "output": "dist/package",
  "icons": {
    "macos": "assets/AppIcon.icns",
    "windows": "assets/AppIcon.ico"
  }
}
FieldPurpose
schemaVersionApplication configuration schema, currently 1
idLowercase reverse-DNS application identifier
nameNative display name, up to 80 characters
versionThree-part application version such as 1.2.3
buildNumberPositive integer string used by native version metadata
entryShared React entry used by development and packaging
executableNative executable name without spaces
outputOutput directory relative to natui.app.json
icons.macosOptional .icns path
icons.windowsOptional .ico path

Entry, output, and icon paths must stay inside the application directory. Packaging fails on unknown configuration fields rather than ignoring them.

natui dev chooses its entry in this order: a positional CLI argument, the configured entry, then src/main.tsx. All config paths resolve relative to natui.app.json.

NatUI does not convert source artwork during packaging. Prepare each icon for its platform:

  • macOS: Provide an .icns file with complete PNG or JPEG 2000 representations. For broad display-quality coverage, it should include 16, 32, 128, 256, and 512 point representations at 1x and 2x, through 1024 pixels. The packager copies it to Contents/Resources/AppIcon.icns and writes CFBundleIconFile in Info.plist. Follow Apple's app icon guidance.
  • Windows: Provide a valid multi-image .ico file. For broad display-quality coverage, it should include 16, 24, 32, 48, and 256 pixel representations. Prefer a transparent background. The packager passes it to MSBuild as ApplicationIcon, which embeds it in the executable. Follow Microsoft's app icon guidance.

Packaging rejects an icon with the wrong extension, an invalid or incomplete image payload, a missing or non-file path, or a path outside the application directory. A valid icon that omits recommended sizes produces a warning.

The configured entry calls the normal run() API from @natui/core, just as it does during development. While bundling, the packager maps that import to the Node-free component surface and runEmbedded. This lets one src/main.tsx serve both runtime modes. The packaged source graph must not use Node built-ins.

Platform artifacts

Windows

Windows packaging produces one portable, architecture-specific, self-contained EXE. The file contains the minified application JavaScript, generated manifest, WinUI host, .NET runtime, Windows App SDK runtime, and V8 dependencies.

The .NET single-file host extracts its runtime dependencies into a cache under the current user's temporary directory at launch. The runtime manages the extraction and installs nothing machine-wide. Copy the one EXE to ship the application.

macOS

macOS packaging produces a standard .app directory. Its executable is under Contents/MacOS, while main.js and manifest.json are under Contents/Resources/NatUI. Copy the complete .app directory when moving the application.

The generated Info.plist carries the configured application identifier, display name, version, build number, minimum macOS version, and optional icon.

Generated manifest and compatibility

Each artifact contains a generated manifest shaped like:

{
  "schemaVersion": 1,
  "id": "dev.natui.demo",
  "name": "NatUI Demo",
  "version": "0.2.0",
  "buildNumber": "1",
  "entry": "main.js",
  "entrySha256": "<64 lowercase hexadecimal characters>",
  "protocolVersion": 1,
  "minHostApi": 2,
  "platform": "windows",
  "architecture": "x64"
}

Before evaluating JavaScript, the native loader validates:

  • Bundle schema version
  • Exact wire protocol version
  • Minimum host API level
  • Target platform and architecture
  • SHA-256 digest of main.js

The protocol version covers message compatibility. The host API level covers additive native components and host behavior that use the same wire format. A newer host API can run a bundle that requires an older level. An older host cannot run a bundle that requires newer capabilities.

Embedded application lifecycle

runEmbedded resolves to an EmbeddedApp after the first React commit:

const app = await runEmbedded(<App />, {
  title: 'My app',
  onClose() {
    console.log('The native window requested close');
  },
});

app.update(<App />);
app.quit();

quit() is idempotent. The first call synchronously unmounts React, runs effect cleanup, sends one native quit request, disposes pending bridge work, and detaches globalThis.__natui_recv. Later calls do nothing. A native window close invokes onClose once and follows the same shutdown path.

Startup protocol or host API incompatibility also asks the native host to quit and removes the receive hook, so a broken packaged application does not remain alive without a usable React tree.

Verify the packaged lifecycle

Run:

pnpm verify:package

This command rebuilds the demo package for the current platform, launches the artifact from an unrelated working directory, validates the ready handshake, waits for the demo tree, requests a native close, and requires a clean exit. On macOS it then launches the .app through LaunchServices, requires a rendered window screenshot, and verifies the same native close path. It needs a normal desktop window session.

Current boundaries

The reference packager does not currently provide:

  • Code signing
  • macOS notarization
  • Installer generation
  • Automatic updates
  • Single-instance orchestration
  • Multi-window application APIs

Packaging creates runnable artifacts. Release distribution still requires the platform-specific signing, notarization, installer, and update work appropriate for the application.

On this page