Skip to content

Native apps

Checked against 2994ef5 on 2026-11-10 by Capy (review pass: Subagent 124 and fable). The files it covers are unchanged since.

A niso app doesn’t have to be JavaScript. A native app builds its document with the same DOM calls a page makes (createElement, appendChild, addEventListener, requestAnimationFrame), written in its own language, and gets the same native controls, layout and text as a page. The repository has one such app, a stopwatch with start, stop, lap and reset buttons, a lap list, a “keep screen on” switch and a progress bar, written five times in examples/lang: in C, Rust, Zig, Go and Java.

An app is a library that exports one function, niso_app_main, and does everything else through niso.h, the C API generated from niso’s WebIDL. The host makes the DOM, calls niso_app_main once before the first frame, and from then on runs the app’s code only in the callbacks it registered: listeners, timers and animation frames. A JS page is the same contract, with the JS engine as its app.

crates/niso-app/include/niso_app.h
/* How a host runs a niso app written in any language.
*
* An app is a library that exports niso_app_main and does everything else
* through niso.h. The host creates the DOM and sets it up as for any page
* (viewport, niso_dom_set_control_size, niso_dom_set_button_style), then
* calls niso_app_main once, before the first frame. The app builds its
* document there: elements, a <style> element with its CSS, listeners for
* the events it handles, and timers and animation frame callbacks for work
* over time. From then on the app's code runs only in those callbacks, and
* the host runs the DOM's event loop exactly as for a JS page:
*
* on each display frame niso_dom_run(dom, niso_dom_now(dom));
* niso_render_frame(dom, &frame), then apply it
* on the user's input niso_dom_tap, niso_dom_control_input, ...
*
* A JS page is the same contract with the JS binding as its app: the host
* evaluates the bundle with niso_js_eval where it would call niso_app_main,
* and calls niso_js_run(js, 0) where it would call niso_dom_run.
*
* The DOM collects between tasks, so a handle the app keeps across callbacks
* must be connected to the document or retained (niso_retain).
*
* The app library leaves the niso_* functions to the host: it is linked
* against the library that has them, libniso_capi for a headless run,
* libniso_android on Android, or into the host's binary next to niso-app's
* static library on iOS.
*/

crates/niso-app/include/niso_app.h, line 1

Two rules follow from it. The DOM collects garbage between tasks, so a handle the app keeps across callbacks must point at a node in the document or be retained with niso_retain. And the library doesn’t link the niso_* functions itself: the host it’s loaded into provides them (libniso_capi for a headless run, libniso_android on Android, the app’s own binary on Apple platforms).

Language Source How it reaches the DOM Toolchain
C timer.c niso.h directly a C11 compiler
Rust src/lib.rs the part of niso.h it uses, declared by hand in src/niso.rs cargo
Zig timer.zig niso.h through zig translate-c Zig (the script says 0.17; 0.16.0 also passed)
Go timer.go niso.h through cgo Go with cgo
Java Timer.java the dev.niso.dom classes, generated from the WebIDL a JDK 17

Here is the C version’s entry point. Each call returns a status, and TRY returns early on the first one that fails:

examples/lang/c/timer.c
int32_t niso_app_main(niso_dom* dom) {
app.dom = dom;
niso_handle doc = niso_dom_document(dom), head = 0, body = 0, buttons = 0, label = 0;
TRY(niso_document_get_head(dom, doc, &head));
TRY(niso_document_get_body(dom, doc, &body));
TRY(append(head, S("style"), css, sizeof css - 1, NULL));
TRY(append(body, S("main"), NULL, 0, &app.main));
TRY(append(app.main, S("h1"), NULL, 0, &app.elapsed));
TRY(niso_element_set_id(dom, app.elapsed, S("elapsed")));
TRY(niso_document_create_text_node(dom, doc, S(""), &app.elapsed_text));
TRY(niso_node_append_child(dom, app.elapsed, app.elapsed_text, &app.elapsed_text));
TRY(append(app.main, S("progress"), NULL, 0, &app.minute));
TRY(niso_element_set_id(dom, app.minute, S("minute")));
TRY(niso_element_set_attribute(dom, app.minute, S("max"), S("60000")));
TRY(append(app.main, S("div"), NULL, 0, &buttons));
TRY(niso_element_set_class_name(dom, buttons, S("buttons")));
TRY(button(buttons, S("Start"), on_start, &app.start));
TRY(button(buttons, S("Stop"), on_stop, &app.stop));
TRY(button(buttons, S("Lap"), on_lap, &app.lap));
TRY(button(buttons, S("Reset"), on_reset, &app.reset));
TRY(niso_element_set_id(dom, app.start, S("start")));
TRY(niso_element_set_id(dom, app.stop, S("stop")));
TRY(niso_element_set_id(dom, app.lap, S("lap")));
TRY(niso_element_set_id(dom, app.reset, S("reset")));
TRY(append(app.main, S("label"), S("Keep screen on"), &label));
TRY(append(label, S("input"), NULL, 0, &app.awake));
TRY(niso_element_set_id(dom, app.awake, S("awake")));
TRY(niso_element_set_attribute(dom, app.awake, S("type"), S("checkbox")));
TRY(niso_element_set_attribute(dom, app.awake, S("switch"), S("")));
TRY(listen(app.awake, S("change"), on_awake));
TRY(append(app.main, S("ol"), NULL, 0, &app.laps));
TRY(niso_element_set_id(dom, app.laps, S("laps")));
TRY(show(0));
return update_buttons();
}

examples/lang/c/timer.c, line 148

The Java version is the same app over generated classes, so it reads like a page’s script:

examples/lang/java/Timer.java
public void accept(Dom dom) {
doc = dom.getDocument();
window = dom.getWindow();
append(doc.getHead(), "style", CSS);
main = append(doc.getBody(), "main", null);
elapsed = (HTMLElement) append(main, "h1", null);
elapsed.setId("elapsed");
elapsedText = doc.createTextNode("");
elapsed.appendChild(elapsedText);
minute = (HTMLProgressElement) append(main, "progress", null);
minute.setId("minute");
minute.setAttribute("max", "60000");
Element buttons = append(main, "div", null);
buttons.setClassName("buttons");
start = button(buttons, "start", "Start", this::onStart);
stop = button(buttons, "stop", "Stop", this::onStop);
lap = button(buttons, "lap", "Lap", this::onLap);
reset = button(buttons, "reset", "Reset", this::onReset);
Element label = append(main, "label", "Keep screen on");
awake = (HTMLInputElement) append(label, "input", null);
awake.setId("awake");
awake.setAttribute("type", "checkbox");
awake.setAttribute("switch", "");
awake.addEventListener("change", e -> {
if (awake.getChecked()) {
main.setAttribute("data-keep-awake", "");
} else {
main.removeAttribute("data-keep-awake");
}
});
laps = append(main, "ol", null);
laps.setId("laps");
show(0);
updateButtons();
}

examples/lang/java/Timer.java, line 96

The language pages walk through each binding: C, Zig and Go, Rust, Java and Kotlin.

Each language has a build script that builds the app as a library and then runs its headless test. The test is a small host: it makes a DOM, calls the app’s entry point, taps the buttons, runs the timers in virtual time, and checks both the document and the render stream a native host would draw. The C API they link has no JavaScript engine, so the C, Zig, Go and Rust apps don’t need the QuickJS-ng submodule.

Terminal window
examples/lang/c/build.sh
c timer: ok
Terminal window
examples/lang/rust/build.sh

The Rust app is a workspace member, so cargo test --workspace runs its test too.

Terminal window
JAVA_HOME=$(/usr/libexec/java_home -v 17) examples/lang/java/build.sh
java timer: ok
java binding: ok
java module: ok

That Java run is the macOS form; on Linux, set JAVA_HOME to a JDK 17. Besides the timer, it runs the binding’s own test and a page that calls a JVM native module.

Terminal window
examples/lang/zig/build.sh

Zig’s test runner reports on stderr: 1/1 timer_test.test.timer...OK and All 1 tests passed.

Terminal window
examples/lang/go/build.sh

The builds go to target/lang/<language>/: libtimer.so for C, Zig and Go, the classes for Java.

The Android and Apple hosts run an app library in place of a page:

  • Android: hosts/android/build.sh --app LIB with an arm64 shared library linked against libniso_android.so, or hosts/android/build.sh --jvm examples/lang/java Timer for the Java version, built into the app. See Build for Android.
  • Apple platforms: hosts/apple/build.sh --app LIB with a static library for the platform. See Build for Apple platforms.

The desktop host, niso-iced, runs only JS pages for now: it takes a script or an HTML page, not an app library.