System
Use dialogs, the clipboard, notifications, the keychain, a tray icon, media keys, audio, and OS events through nd::system, and add plugins for anything the framework doesn't ship.
nd::system is how your app reaches OS services. One System handle fronts every service, and the examples/system app puts each one behind a button.

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.
The handle
A System is a set of services over a Backend. System::native() talks to the OS. System::fake() returns a recording backend and a handle that answers from a script, which is what tier 1 tests use (see testing). nd::system::system() is the native handle that the app already runs with, and the example's main passes it to open.
Calls that wait on the user or the OS return a Pending<T>, a plain oneshot future. To run one, call nd::system::spawn(future, continuation). The future runs off the UI thread, and the continuation runs on it with &mut App, so it can update a view. The example sends every call through one run helper that logs the outcome.
On a target that can't do something, the call fails with Error::Unsupported. To find out ahead of time, call nd::platform::has(Feature). That's how an app hides a button instead of showing a failure (see web).
Dialogs
App-level dialogs aren't tied to a window. dialogs().open takes OpenOptions (file or folder, multiple, and a title) and resolves to the chosen paths, or to None if the user cancels. save takes SaveOptions. message shows an alert and resolves to the index of the button that the user selected.
Action::Open => {
self.run(cx, "open", sys.dialogs().open(OpenOptions::file().multiple().title("Pick files")))
}
Action::Folder => self.run(cx, "folder", sys.dialogs().open(OpenOptions::folder())),
Action::Save => self.run(cx, "save", sys.dialogs().save(SaveOptions::new().file_name("notes.ndsys"))),
From examples/system/src/lib.rs, line 164.
An alert takes at most three buttons, the common limit across the native alerts. More than three is Error::Invalid. For a sheet attached to one window, use nd::system::window::{alert, open, save}, which take the window id. The example's "Sheet" button shows one.
Clipboard
The clipboard holds text, images, and file lists. An image is ClipboardImage { width, height, rgba }, with straight RGBA bytes. A read returns None when the clipboard holds something else.
Action::CopyImage => {
let rgba = (0..64 * 64).flat_map(|i| [(i % 64 * 4) as u8, (i / 64 * 4) as u8, 200, 255]).collect();
self.run(cx, "copy image", sys.clipboard().write_image(ClipboardImage { width: 64, height: 64, rgba }));
}
Action::PasteImage => {
let image = sys.clipboard().read_image();
self.run(cx, "paste image", async move { image.await.map(|i| i.map(|i| (i.width, i.height))) });
}
From examples/system/src/lib.rs, line 193.
Notifications
notifications().show takes a Notification with a title, subtitle, body, action buttons, and a data value. When the user selects the notification or one of its actions, the app receives SystemEvent::NotificationClicked with that data. This works for notifications that this process showed.
Action::Notify => {
let n = Notification::new("System demo")
.subtitle("A notification")
.body("Click it, or one of its buttons.")
.action("reply", "Reply")
.data(serde_json::json!({ "from": "the demo" }));
self.run(cx, "notify", sys.notifications().show(n));
From examples/system/src/lib.rs, line 204.
On macOS, notifications need a bundled, signed app. An unbundled binary gets a clear error, because macOS 27 returns no legacy notification center. Linux and Windows use notify-rust.
Keychain, recent documents and the shell
credentials() has set, get, and delete over a service and account name. It uses the Keychain on macOS, the Secret Service on Linux, and Credential Manager on Windows. A missing item is Error::NotFound, and a locked keychain is Error::Denied.
Action::Remember => self.run(cx, "remember", sys.credentials().set(SECRET_SERVICE, "demo", "hunter2")),
Action::Recall => self.run(cx, "recall", sys.credentials().get(SECRET_SERVICE, "demo")),
Action::Forget => self.run(cx, "forget", sys.credentials().delete(SECRET_SERVICE, "demo")),
From examples/system/src/lib.rs, line 214.
recent_documents().add(path) adds the path to the OS recent list. On Linux, the list shows only this app's entries. shell() has open_url, open_path, and reveal_path, which are plain synchronous calls. App directories come from nd::system::app_dirs() (ensure_data, ensure_cache) and are named after the [app] identity in nativedesktop.toml.
Tray
tray().set creates or replaces a status item from a Tray builder. The builder takes an icon, a tooltip, and menu items (plain, checkable, and separators). TrayIcon::Named is an SF Symbol name on macOS and an icon theme name elsewhere. remove takes the item's TrayId. A click on the icon arrives as SystemEvent::Tray { item: None }, and a menu choice carries the item id.
Action::Tray => {
self.tray = !self.tray;
if self.tray {
let tray = Tray::new(TRAY)
.icon(TrayIcon::Named(if cfg!(target_os = "macos") {
"waveform".into()
} else {
"audio-x-generic".into()
}))
.tooltip("System demo")
.item(TrayMenuItem::item("hello", "Say hello"))
.item(TrayMenuItem::check("checked", "A checked item", true))
.item(TrayMenuItem::Separator)
.item(TrayMenuItem::item("quit-tray", "Hide this icon"));
self.run(cx, "tray", sys.tray().set(tray));
} else {
self.run(cx, "tray removed", sys.tray().remove(TrayId(TRAY.into())));
}
}
From examples/system/src/lib.rs, line 235.
Media session and audio
audio().play(PlayOptions::path(p).volume(v).spectrum()) returns a Player and a future that resolves once playback starts. Player has pause, resume, stop, seek, and set_volume. State changes arrive as SystemEvent::Audio with AudioEvent::State. With .spectrum() set, AudioEvent::Spectrum carries 32 log-spaced bins in 0..1, about 15 times a second. The example draws them as bars.
media().set(NowPlaying { .. }) publishes the title, artist, and duration to the OS controls (Now Playing, MPRIS, and SMTC), and clear removes them. Media keys and the OS controls come back as SystemEvent::Media(MediaCommand::..).
Action::Play => {
let path = nd::system::app_dirs().ensure_cache().map(|dir| dir.join("tone.wav"));
let written = path.and_then(|p| nd::files::write(&p, tone_wav()).map(|()| p));
let Ok(path) = written else {
return self.note(format!("play failed: {written:?}"));
};
let (player, started) = sys.audio().play(PlayOptions::path(path).volume(0.3).spectrum());
self.player = Some(player);
let media = sys.media().set(NowPlaying {
title: "A 440 Hz tone".into(),
artist: Some("System demo".into()),
duration: Some(Duration::from_secs(1)),
state: PlaybackState::Playing,
..NowPlaying::default()
});
let view = cx.weak_entity();
nd::system::spawn(
async move {
started.await?;
let _ = media.await;
Ok::<_, nd::system::Error>(())
},
move |result, app| {
view.update(app, |this, cx| {
if let Err(e) = result {
this.player = None;
this.note(format!("play failed: {e}"));
}
cx.notify();
});
},
);
}
From examples/system/src/lib.rs, line 256.
App events
Everything the OS tells the app arrives as a SystemEvent. That covers activation and deactivation, URLs (OpenUrls), documents (OpenFiles), files dropped on a window (FileDrop, with the window id and drop position), notification and tray clicks, media commands, and audio state. Subscribe once with nd::system::on_event. Events that arrive before the first subscriber are held and delivered to it.
nd::system::on_event(&events, move |event, app| {
view.update(app, |this, cx| {
this.event(event);
cx.notify();
});
});
From examples/system/src/lib.rs, line 44.
fn event(&mut self, event: &SystemEvent) {
match event {
SystemEvent::Audio { event: AudioEvent::Spectrum(bins), .. } => self.spectrum = bins.clone(),
SystemEvent::Audio { event: AudioEvent::State { state, .. }, .. } => {
self.note(format!("audio: {state:?}"));
self.playing = match state {
nd::system::AudioState::Playing => PlaybackState::Playing,
nd::system::AudioState::Paused => PlaybackState::Paused,
_ => {
self.spectrum.clear();
PlaybackState::Stopped
}
};
}
SystemEvent::Media(MediaCommand::Toggle | MediaCommand::Play | MediaCommand::Pause) => {
self.note(format!("{event:?}"));
if let Some(player) = &self.player {
let wants_play = self.playing != PlaybackState::Playing;
nd::system::spawn(if wants_play { player.resume() } else { player.pause() }, |_, _| {});
}
}
SystemEvent::Tray { item: Some(item), .. } if item == "quit-tray" => {
self.note("tray: hide");
self.tray = false;
nd::system::spawn(self.sys.tray().remove(TrayId(TRAY.into())), |_, _| {});
}
other => self.note(format!("{other:?}")),
}
}
From examples/system/src/lib.rs, line 91.
Declare URL schemes and document types in nativedesktop.toml. Packaging writes them into the Info.plist, the .desktop file, and the Windows registration:
#:schema ../../target/nd/nativedesktop.schema.json
[app]
id = "dev.formalsnake.system-demo"
display_name = "System Demo"
url_schemes = ["nd-system-demo"]
[[app.file_associations]]
ext = ["ndsys"]
name = "System Demo Note"
role = "viewer"
From examples/system/nativedesktop.toml, line 1.
On Linux and Windows, the OS opens a second instance with the URL or path as a command-line argument. System::open_from_args turns those arguments into OpenUrls and OpenFiles, and the nd run does this for the launch's own arguments.
What works where
| Service | macOS | Linux | Windows | Web |
|---|---|---|---|---|
| Dialogs, clipboard, shell, credentials | yes | yes | code written, not run | clipboard text, open_url |
| Notifications | needs a bundled, signed app | yes | code written, not run | no |
| Recent documents, tray | yes | yes | code written, not run | no |
| Media session | yes | yes (MPRIS) | code written, not run | no |
| Audio and spectrum | yes (AVPlayer) | yes (GStreamer, loaded at run time) | no | no |
Linux was tested end to end on a real machine with a fake notification daemon, StatusNotifier and MPRIS over D-Bus, an isolated keyring, and a headless Hyprland session. The Windows backend type-checks for x86_64-pc-windows-msvc but hasn't run on Windows. Real media-key presses are untested. On the web, only clipboard text and open_url work.
Extending outside the framework
For anything past this list, write a plugin. A crate implements nd_plugin::Plugin, the app registers it on a PluginHost, and you don't fork anything in the framework. A plugin owns one [plugins.<name>] table in the app's config. The host splits every plugins table off the config before the app parses it, so the app keeps deny_unknown_fields on its own root struct. A section that fails to parse disables that plugin without failing the app. The plugin's tasks go through PluginContext::spawn, so the host can stop and restart them when the config file changes. The app publishes any state that the plugin needs into Services, and the plugin fetches it by type.
examples/discord-rpc is Discord Rich Presence written this way. It depends only on nd-plugin and nd-ipc, the way a crate outside this repository would. Its config is a plain serde struct:
/// `[plugins.discord]`.
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields, default)]
pub struct DiscordConfig {
pub enabled: bool,
/// The Discord application id (developer portal). Empty keeps the plugin idle.
pub client_id: String,
pub show_elapsed: bool,
/// Asset key used when the activity names none.
pub default_image: Option<String>,
/// Look for `discord-ipc-N` here instead of the usual runtime and temp dirs.
pub socket_dir: Option<PathBuf>,
}
From examples/discord-rpc/src/lib.rs, line 23.
The plugin reads a PresenceFeed that the app published and reports a DiscordStatus back. Discord often isn't running, and that's expected: the plugin keeps looking on nd-ipc's reconnect ladder and never touches the app.
impl Plugin for DiscordPlugin {
type Config = DiscordConfig;
fn name(&self) -> &'static str {
"discord"
}
fn description(&self) -> &'static str {
"Show what the app is doing as Discord Rich Presence."
}
fn init(&mut self, cx: &PluginContext, config: DiscordConfig) -> Result<(), PluginError> {
let (status_tx, status_rx) = watch::channel(DiscordStatus::Idle);
cx.services().insert(DiscordStatusFeed(status_rx));
if !config.enabled || config.client_id.trim().is_empty() {
tracing::debug!("discord presence idle: disabled or no client_id");
return Ok(());
}
let Some(feed) = cx.services().get::<PresenceFeed>().map(|f| f.0.clone()) else {
return Err(PluginError::init("the app did not publish a PresenceFeed"));
};
let ladder = Ladder::new(self.ladder.unwrap_or_default());
cx.spawn(connection_loop(config, feed, status_tx, ladder));
Ok(())
}
}
From examples/discord-rpc/src/lib.rs, line 76.
On the app side, you register the plugin and add a config table:
let (activity_tx, activity_rx) = watch::channel(None);
let services = Services::new();
services.insert(PresenceFeed(activity_rx));
let mut host = PluginHost::new("discord-rpc-demo", tokio::runtime::Handle::current(), services.clone());
host.register(DiscordPlugin::new());
// What the app's own config file would carry.
let mut config: toml::Table =
format!("[plugins.discord]\nclient_id = \"{client_id}\"\n").parse().expect("valid toml");
let report = host.load(&mut config);
for failure in &report.failed {
eprintln!("plugin {} not started: {}", failure.plugin, failure.error);
}
From examples/discord-rpc/src/main.rs, line 15.
nd-ipc is the same local-socket layer that the single-instance apps use. It's covered with the rest of the config and storage crates in settings and config.