System
Dialogs, clipboard, notifications, keychain, tray, media keys, audio and OS events through nd::system, plus plugins for what the framework does not ship.
nd::system is the app's door to OS services. One System handle fronts every service, and the examples/system app puts each of them behind a button.

Captured on macOS 27.0, 2026-10-14.
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 the app already runs with; 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. Run one with 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 funnels every call through one run helper that logs the outcome.
On a target that cannot do something, the call fails with Error::Unsupported. nd::platform::has(Feature) answers ahead of time, which is how an app hides a button instead of showing a failure (see web).
Dialogs
App-level dialogs are not tied to a window. dialogs().open takes OpenOptions (file or folder, multiple, a title) and resolves to the chosen paths or None on cancel. save takes SaveOptions. message shows an alert and resolves to the index of the pressed button.
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 of the native alerts; more is Error::Invalid. A sheet attached to one window comes from nd::system::window::{alert, open, save}, which take the window id; the example's "Sheet" button shows it.
Clipboard
Text, images and file lists. An image is ClipboardImage { width, height, rgba }, straight RGBA bytes. Reading 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. Clicking the notification or an action arrives as SystemEvent::NotificationClicked with that data back, for notifications 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: the Keychain on macOS, the Secret Service on Linux, Credential Manager on Windows. A missing item is Error::NotFound; 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) feeds the OS recent list (the entries of this app only on Linux). shell() has open_url, open_path and reveal_path, and they are plain synchronous calls. App directories come from nd::system::app_dirs() (ensure_data, ensure_cache), named by the [app] identity in nativedesktop.toml.
Tray
tray().set creates or replaces a status item from a Tray builder: an icon (TrayIcon::Named is an SF Symbol name on macOS and an icon theme name elsewhere), a tooltip and menu items (plain, checkable, separators). remove takes its TrayId. A click on the icon is SystemEvent::Tray { item: None }, 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 started. Player has pause, resume, stop, seek and set_volume. State changes arrive as SystemEvent::Audio with AudioEvent::State, and 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, SMTC); clear removes it. Media keys and the 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: 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.
URL schemes and document types are declared in nativedesktop.toml, and 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 it 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 driven 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 and has not run on Windows. Real media-key presses are untested. Web has clipboard text and open_url only.
Extending outside the framework
Anything past this list is a plugin: a crate implements nd_plugin::Plugin, the app registers it on a PluginHost, and nothing in the framework is forked. 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, and 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. State the plugin needs from the app is published into Services and fetched by type.
examples/discord-rpc is Discord Rich Presence written that way. It depends on nd-plugin and nd-ipc only, 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 the app published, and reports a DiscordStatus back. Discord not running is the normal case: 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.
The app side is a registration and 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 the single-instance apps use; it is covered with the rest of the config and storage crates in settings and config.