Settings and config
The app manifest, user settings as a TOML file with layers and Nix and fspec 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.

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.
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:
[app]: the reverse-DNSid(required),display_name,icon,categories,url_schemes(bare schemes, without://), and[[app.file_associations]](extwithout a dot,role,name, andmime). The schemes and associations go intoInfo.plist, the.desktopfile, and the installer. The app receives them as system events.[patterns]: project-wide pattern choices (tooltips, menus, popovers, context menus, and a settings window or sheet). Code can still override them per app, window, or element.[webview]and[webview.cef]: the engine,systemorcef. Packaging bundles Chromium only forcef. See webview.[updates]:feed,channels,public_key, and[updates.publish]. See packaging.[diagnostics]:retentionoverrides andsentry_dsn_env, the name of the variable that holds the DSN. See diagnostics.[macos],[windows],[linux]: for macOS, the minimum system version, signing identity, notarization, entitlements, andswift_packages. For Windows, the publisher, installer splash and shortcuts, MSI, and signing. For Linux, the package formats.[web]:base_pathfor the static bundle. See web.
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:
- The defaults in the struct.
- A Nix-managed file (any config file that resolves into
/nix/store). - The user's
config.toml. - Drop-ins in
config.d/*.toml, in name order. fspec.toml, which fspec writes from your spec (see fspec).config.local.toml, a writable overlay.- Environment variables named
<APP>_KEY, where__nests (NOTES_EDITOR__FONT_SIZEsetseditor.font_size). - 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.
fspec
On a machine that fspec manages, the app installs and updates through fspec instead of Nix. Each app ships a generated fspec.star at its repo root, and a spec loads it straight from the app's repo:
load("gh:FormalStudio/formalmusic@main//fspec.star", "formalmusic")
hosts = [host("e1504g", platform = "x86_64-linux", system = tumbleweed(), modules = [
formalmusic(settings = {"playback": {"quality": "high"}}, autostart = True),
])]
@main tracks the branch, so fspec update moves the lock to its newest commit. @<commit> pins it. The package builds the same commit its fspec.star came from: fspec build compiles it on Linux in a Tumbleweed container, with Zig for the terminal widget and ND_NO_UPDATES=1. rev builds another commit, and source takes any fspec source instead (macOS needs one, since there's no build recipe for it yet). The other arguments match the home-manager module: settings is written to fspec.toml, theme to theme.json, autostart adds a user service tied to the graphical session, and environment_file passes secrets to it. Removing the module removes the app, its files and its unit, and moves the config directory to fspec's trash.
settings is checked when the spec is evaluated. An unknown key, a value of the wrong type, a choice that isn't listed, or a number out of range fails the plan with the key's path. fspec copies fspec.toml instead of linking it, so the layer sits above the user's files, and the spec wins. Its keys are read-only, with WriteError::ManagedByFspec and a "Copy as fspec" literal. Every other key stays editable in config.toml.
let dirs = Dirs::new();
dirs.write("config.toml", "editor_font_size = 13\ncompact_rows = true\n");
dirs.write("fspec.toml", "editor_font_size = 20\n");
let settings = SettingsHandle::<NotesSettings>::load(dirs.options("notes")).unwrap();
assert_eq!(settings.get().editor_font_size, 20.0);
assert_eq!(settings.source("editor_font_size").unwrap().kind, LayerKind::Fspec);
assert!(matches!(settings.key_state("editor_font_size"), KeyState::ManagedByFspec { .. }));
assert!(matches!(settings.set("editor_font_size", &12.0), Err(WriteError::ManagedByFspec { .. })));
assert_eq!(settings.copy_as_fspec("editor_font_size").unwrap(), r#"settings = {"editor_font_size": 20}"#);
// Keys the spec leaves alone stay the user's, in config.toml.
assert_eq!(settings.key_state("compact_rows"), KeyState::Editable);
settings.set("compact_rows", &false).unwrap();
assert!(read(&dirs.user()).contains("compact_rows = false"));
From crates/nd-settings/tests/schema_fspec.rs, line 47.
fspec.star is generated from the same schema as the Nix options, so the two can't drift. nd_settings::fspec::star_module(&App::new(name, repo), &schema) returns the file. An app checks it in the test that already checks nix/settings-options.nix. nd-fspec-gen --schema settings.schema.json --name <app> --repo <url> --out fspec.star writes it from a committed schema, and --check fails when the file is out of date. App::build_deps adds openSUSE packages that the app's own crates need on top of nd's.
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.