NativeDesktop

Settings and config

The app manifest, user settings as a TOML file with layers and Nix support, machine state, local sockets, and SQLite.

An app has three kinds of configuration, each in its own place. Your choices as the developer live in nativedesktop.toml. The user's preferences live in a config.toml that the app edits without losing the user's comments. State that the app owns (window frames, recents, a library) lives in a Store<T>. Each one is a crate that you can use without the others.

Example: Notes. A multi-file app: source list sidebar, toolbar, a virtual list of notes, an editor, the command palette, gestures and settings. Source in examples/notes.
Notes on macOS

Captured on macOS 27.0, 2026-10-22.

Notes on Linux

Captured on NixOS 26.11 (Zokor), Hyprland, 2026-10-22.

Notes on Windows

Captured on Windows 11 26H2, 2026-10-22.

Wiring status: nd-settings, settings_form, and Store<T> ship and are tested. The nd facade doesn't call them for you yet. Application::settings::<T>(), cx.settings::<T>(), and Store::load(name, cx), as the Notes example writes them, don't exist yet, and Notes won't build until they do. Everything below uses the crates' real API. The Notes snippets show the settings struct and the form, which need nothing from the facade.

App manifest

nativedesktop.toml sits next to Cargo.toml. The name and version always come from Cargo, never from this file. Every table denies unknown keys, so a typo is an error that names the path and line. The examples start with a #:schema line that points at target/nd/nativedesktop.schema.json, which nd_config::schema_json() produces for editors.

#:schema ../../target/nd/nativedesktop.schema.json
[app]
id = "dev.formalsnake.notes"
display_name = "Notes"
icon = "assets/icon.icon"
categories = ["Office", "Utility"]
url_schemes = ["notes"]

[[app.file_associations]]
ext = ["md"]
role = "editor"

[updates]
feed = "https://updates.formalsnake.dev/notes"
channels = ["stable", "beta"]
public_key = "vAws645lwj7GviGmZGx3cgXFAtwJZLtG+qcAg1Vgg1c="

[updates.publish]
kind = "s3"                       # s3 (R2, S3, MinIO), github, rsync, dir
endpoint_env = "NOTES_S3_ENDPOINT"
bucket = "updates"
prefix = "notes"

[diagnostics]
sentry_dsn_env = "NOTES_SENTRY_DSN"   # optional upload; bundles are always written locally

[macos]
minimum_system_version = "14.0"
signing_identity = "Developer ID Application: CanaryCoders"
notarize = true
notary_profile = "formalsnake-notary"

[windows]
publisher = "FormalSnake"
installer = { splash = "assets/splash.png", shortcuts = ["start", "desktop"] }
signing = "azure"

[linux]
formats = ["appimage"]

From examples/notes/nativedesktop.toml, line 1.

The tables, from nd-config:

validate rejects what the schema can't express: a malformed app.id, a feed without a public_key, a bad channel name, or notarize without a notary_profile and a signing identity.

Settings struct

A settings struct is the single source of truth. The derive generates the JSON Schema, the metadata that a settings form needs, and the typed options of the Nix module.

#[derive(Settings, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[schemars(crate = "nd::schemars")]
#[serde(default, deny_unknown_fields)]
pub struct NotesSettings {
    /// One-line rows without a preview.
    pub compact_rows: bool,
    /// Editor text size in points, before pinch zoom.
    #[settings(range = 11.0..=24.0, step = 1.0)]
    pub editor_font_size: f32,
    /// Where new notes start.
    pub new_note_folder: NewNoteFolder,
}

From examples/notes/src/settings.rs, line 7.

#[serde(default, deny_unknown_fields)] is required. Defaults fill in what the file omits, and a misspelled key fails the load. Doc comments become help text. #[settings(range, step)] becomes a slider, #[settings(reload = "restart")] marks a key that's read once at startup, and #[settings(section)] nests a struct into a titled group. Enums become pickers.

Settings form

settings_form::<T>() renders the schema as native settings rows (turn on the settings-ui feature on nd). Edits go through the handle, so the file's comments survive.

// Rows come from the schema: doc comments become help text, ranges become sliders,
// enums become pickers. Keys managed by Nix show as read-only with their option path.
.child(settings_form::<NotesSettings>())

From examples/notes/src/settings.rs, line 50.

The form reads the handle from a global that you set with nd_components::forms::settings::provide(cx, handle). Without one, it shows the defaults as read-only. A key that a Nix-managed file sets is shown read-only, with its option path and a "Copy as Nix" action.

Loading and editing the file

SettingsHandle::<T>::load(Options::new("notes")) reads every layer, lowest precedence first:

  1. The defaults in the struct.
  2. A Nix-managed file (any config file that resolves into /nix/store).
  3. The user's config.toml.
  4. Drop-ins in config.d/*.toml, in name order.
  5. config.local.toml, a writable overlay.
  6. Environment variables named <APP>_KEY, where __ nests (NOTES_EDITOR__FONT_SIZE sets editor.font_size).
  7. Command-line overrides.

The directory is ~/Library/Application Support/<app> on macOS, $XDG_CONFIG_HOME/<app> on Linux, and %APPDATA%\<app> on Windows. ND_APP_CONFIG_DIR overrides it when set. Every key remembers its layer: source(key) tells you which one, and key_state(key) tells you whether an edit would stick.

Writes use toml_edit, so comments, ordering, and spacing survive. This test starts from a hand-commented file:

    const COMMENTED: &str = "\
# Notes settings, edited by hand.
compact_rows = true   # I like it dense

# Font
editor_font_size = 15  # points, not pixels
spellcheck = false
";

From crates/nd-settings/tests/gate_m7_settings.rs, line 13.

let dirs = Dirs::new();
dirs.write("config.toml", COMMENTED);
let settings = SettingsHandle::<NotesSettings>::load(dirs.options("notes")).unwrap();
assert!(settings.get().compact_rows);

settings.set("editor_font_size", &18.0).unwrap();
settings.set("new_note_folder", &"pinned").unwrap();

let text = read(&dirs.user());
for kept in ["# Notes settings, edited by hand.", "# I like it dense", "# Font", "# points, not pixels"] {
    assert!(text.contains(kept), "lost `{kept}` in:\n{text}");
}
assert!(text.contains("editor_font_size = 18.0  # points, not pixels"), "{text}");
assert!(text.contains("new_note_folder = \"pinned\""), "{text}");
assert_eq!(settings.get().editor_font_size, 18.0);

settings.unset("spellcheck").unwrap();
let text = read(&dirs.user());
assert!(!text.contains("spellcheck") && text.contains("# Font"), "{text}");
assert!(settings.get().spellcheck, "default applies again");

From crates/nd-settings/tests/gate_m7_settings.rs, line 26.

set validates against the schema before it writes, so an out-of-range value or an unknown key returns WriteError, and the file stays as it was. A file that no longer loads reports its path, key, and line, and the last good settings stay in force (Event::Rejected). The handle watches the files, and on_event delivers a Change with the changed keys and which of them need a restart. The app's own writes are tagged, so the watcher ignores them.

Nix

When the main file is a symlink into the Nix store, it's read-only. The handle then writes edits to config.local.toml instead, and the managed keys refuse edits with WriteError::ManagedByNix.

let dirs = Dirs::new();
let managed = dirs.store.join("abc-notes-config.toml");
fs::write(&managed, "editor_font_size = 20\n").unwrap();
std::os::unix::fs::symlink(&managed, dirs.user()).unwrap();

let settings = SettingsHandle::<NotesSettings>::load(dirs.options("notes")).unwrap();
assert_eq!(settings.get().editor_font_size, 20.0);
assert!(settings.main_file_read_only());
assert_eq!(settings.source("editor_font_size").unwrap().kind, LayerKind::Nix);
match settings.key_state("editor_font_size") {
    KeyState::ManagedByNix { option, .. } => {
        assert_eq!(option, "programs.notes.settings.editor_font_size");
    }
    other => panic!("{other:?}"),
}
assert_eq!(settings.key_state("spellcheck"), KeyState::Editable);
assert!(matches!(settings.set("editor_font_size", &12.0), Err(WriteError::ManagedByNix { .. })));
assert_eq!(read(&managed), "editor_font_size = 20\n", "the store file is never touched");

settings.set("spellcheck", &false).unwrap();
assert_eq!(read(&dirs.local()), "spellcheck = false\n");
assert!(!settings.get().spellcheck);
assert_eq!(settings.write_target().path, dirs.local());
assert_eq!(settings.copy_as_nix("editor_font_size").unwrap(), "programs.notes.settings.editor_font_size = 20;");

From crates/nd-settings/tests/gate_m7_settings.rs, line 54.

The home-manager module is generated, not written by hand. nd-hm-gen --schema settings.schema.json --out nix/settings-options.nix turns the committed schema into typed options for programs.<app>.settings, and --check fails when the file is out of date. mkApp in nix/mk-app.nix builds the package with crane and wires up the module (nix/hm-module.nix): settings written to config.toml, theme tokens, autostart, and environmentFile. Its checks include clippy, nextest, the module eval, and a check that the settings schema is in sync. Updates are off for a binary under /nix/store.

Machine state

Store<T> holds state that isn't a preference. On disk, it's JSON in the form {"version": N, "data": ..}. Writes are debounced (250 ms, and never more than 2 s behind), go through a temp file and a rename, and flush on drop. If a file can't be read or migrated, the store moves it aside as <name>.corrupt-<unix time>, the defaults win, and load_error() says why. A Migrate function upgrades older versions on load.

let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("state.json");
{
    let store = Store::<Machine>::open(&path, 1, None);
    store.update(|m| m.last_folder = "Inbox".into());
}
let reopened = Store::<Machine>::open(&path, 1, None);
assert_eq!(reopened.get().last_folder, "Inbox");
assert!(reopened.load_error().is_none());

From crates/nd-settings/tests/store.rs, line 50.

Web

On the web, every settings file and every Store<T> is a localStorage key, with the same API. There's no file watcher and no Nix layer. See web.

Local sockets

nd-ipc is JSON over a Unix socket or a Windows named pipe, in three layers on one Endpoint.

instance::acquire is the single-instance lock. The primary instance holds a file lock, which the OS releases when the process dies, so a crash leaves no stale lock. A second launch forwards its arguments and URLs to the primary, gets Instance::Forwarded, and then exits.

let dir = tempfile::tempdir().unwrap();
let endpoint = Endpoint::at_path(dir.path().join("i.sock"));
let first = Launch::from_args(["--a".into()]);
let Instance::Primary(mut primary) = acquire(&endpoint, &first).await.unwrap() else { panic!("not primary") };

let second = Launch::from_args(["file.txt".into(), "myapp://open/3".into()]);
assert!(matches!(acquire(&endpoint, &second).await.unwrap(), Instance::Forwarded));
let got = primary.next_launch().await.unwrap();
assert_eq!(got.args, ["file.txt", "myapp://open/3"]);
assert_eq!(got.urls, ["myapp://open/3"]);

From crates/nd-ipc/tests/gate_m7_ipc.rs, line 107.

rpc is JSON-RPC 2.0 with typed contracts. A Method fixes the name, parameters, and result, so the Server handler and every Client call agree at compile time. The client reconnects on a Ladder and fails calls fast while it's disconnected.

impl Method for Add {
    const NAME: &'static str = "add";
    type Params = Pair;
    type Output = i64;
}

From crates/nd-ipc/tests/gate_m7_ipc.rs, line 18.

connect and connect_path give you a raw stream for protocols that aren't JSON lines. The Discord plugin uses it.

SQLite

nd-data is one SQLite connection on its own thread behind an async API. Its futures are plain oneshot receivers, so they run under GPUI's cx.spawn, tokio, or block_on. It doesn't depend on an ORM: SqliteExecutor is the contract that an adapter targets. migrate applies numbered migrations once and returns the schema version.

const MIGRATIONS: &[Migration] = &[
    Migration { version: 1, sql: "CREATE TABLE note (id INTEGER PRIMARY KEY, title TEXT NOT NULL, body BLOB)" },
    Migration { version: 2, sql: "ALTER TABLE note ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0" },
];

#[derive(Debug, Deserialize, PartialEq)]
struct Note {
    id: i64,
    title: String,
    pinned: i64,
}

From crates/nd-data/tests/gate_m7_data.rs, line 7.

block_on(async {
    let db = db().await;
    let run =
        db.mutate("INSERT INTO note (title, body) VALUES (?, ?)", params!["a", vec![1u8, 2]]).await.unwrap();
    assert_eq!((run.changes, run.last_insert_rowid), (1, 1));
    db.mutate("INSERT INTO note (title, pinned) VALUES (?, ?)", params!["b", true]).await.unwrap();

    let rows = db.query("SELECT * FROM note ORDER BY id", vec![]).await.unwrap();
    assert_eq!(rows[0].get("body"), Some(&Value::Blob(vec![1, 2])));
    assert_eq!(rows[1].get("pinned"), Some(&Value::Integer(1)));

    let notes: Vec<Note> =
        db.query_as("SELECT id, title, pinned FROM note WHERE pinned = ?", params![1]).await.unwrap();
    assert_eq!(notes, [Note { id: 2, title: "b".into(), pinned: 1 }]);
});

From crates/nd-data/tests/gate_m7_data.rs, line 29.

transaction is all or nothing: if one statement fails, the whole batch rolls back. nd doesn't re-export nd-data, so add the crate directly. It bundles SQLite through rusqlite and isn't built for the web.