NativeDesktop

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.

Example: System. Every nd::system service behind a button: dialogs, clipboard, notifications, keychain, tray, media keys and audio with a spectrum. Source in examples/system.
System on macOS

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

ServicemacOSLinuxWindowsWeb
Dialogs, clipboard, shell, credentialsyesyescode written, not runclipboard text, open_url
Notificationsneeds a bundled, signed appyescode written, not runno
Recent documents, trayyesyescode written, not runno
Media sessionyesyes (MPRIS)code written, not runno
Audio and spectrumyes (AVPlayer)yes (GStreamer, loaded at run time)nono

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.