# System

`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.** Source in [examples/system](https://github.com/FormalSnake/NativeDesktop/blob/main/examples/system). Live on the web and captured on macOS, Linux and Windows in the docs app.


## 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](https://nativedesktop.formalsnake.dev/tooling/testing.md)). `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](https://nativedesktop.formalsnake.dev/guides/web.md#feature-checks)).

## 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.

```rust
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"))),
```

<sub>From [examples/system/src/lib.rs](https://github.com/FormalSnake/NativeDesktop/blob/main/examples/system/src/lib.rs), line 164.</sub>


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.

```rust
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))) });
}
```

<sub>From [examples/system/src/lib.rs](https://github.com/FormalSnake/NativeDesktop/blob/main/examples/system/src/lib.rs), line 193.</sub>


## 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.

```rust
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));
```

<sub>From [examples/system/src/lib.rs](https://github.com/FormalSnake/NativeDesktop/blob/main/examples/system/src/lib.rs), line 204.</sub>


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`.

```rust
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")),
```

<sub>From [examples/system/src/lib.rs](https://github.com/FormalSnake/NativeDesktop/blob/main/examples/system/src/lib.rs), line 214.</sub>


`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](https://nativedesktop.formalsnake.dev/guides/settings-and-config.md#app-manifest).

## 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.

```rust
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())));
    }
}
```

<sub>From [examples/system/src/lib.rs](https://github.com/FormalSnake/NativeDesktop/blob/main/examples/system/src/lib.rs), line 235.</sub>


## 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::..)`.

```rust
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();
            });
        },
    );
}
```

<sub>From [examples/system/src/lib.rs](https://github.com/FormalSnake/NativeDesktop/blob/main/examples/system/src/lib.rs), line 256.</sub>


## 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.

```rust
nd::system::on_event(&events, move |event, app| {
    view.update(app, |this, cx| {
        this.event(event);
        cx.notify();
    });
});
```

<sub>From [examples/system/src/lib.rs](https://github.com/FormalSnake/NativeDesktop/blob/main/examples/system/src/lib.rs), line 44.</sub>

```rust
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:?}")),
    }
}
```

<sub>From [examples/system/src/lib.rs](https://github.com/FormalSnake/NativeDesktop/blob/main/examples/system/src/lib.rs), line 91.</sub>


URL schemes and document types are declared in `nativedesktop.toml`, and
[packaging](https://nativedesktop.formalsnake.dev/guides/packaging.md) writes them into the `Info.plist`, the `.desktop` file and the Windows
registration:

```toml
#: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"
```

<sub>From [examples/system/nativedesktop.toml](https://github.com/FormalSnake/NativeDesktop/blob/main/examples/system/nativedesktop.toml), line 1.</sub>


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](https://nativedesktop.formalsnake.dev/guides/web.md) 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:

```rust
/// `[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>,
}
```

<sub>From [examples/discord-rpc/src/lib.rs](https://github.com/FormalSnake/NativeDesktop/blob/main/examples/discord-rpc/src/lib.rs), line 23.</sub>


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.

```rust
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(())
    }
}
```

<sub>From [examples/discord-rpc/src/lib.rs](https://github.com/FormalSnake/NativeDesktop/blob/main/examples/discord-rpc/src/lib.rs), line 76.</sub>


The app side is a registration and a config table:

```rust
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);
}
```

<sub>From [examples/discord-rpc/src/main.rs](https://github.com/FormalSnake/NativeDesktop/blob/main/examples/discord-rpc/src/main.rs), line 15.</sub>


`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](https://nativedesktop.formalsnake.dev/guides/settings-and-config.md#local-sockets).
