Settings and config
The app manifest, user settings as a TOML file with layers and Nix support, machine state, local sockets and SQLite.
Three kinds of configuration, in three places. The developer's choices about the app live in nativedesktop.toml. The user's preferences live in a config.toml that the app edits without losing the user's comments. State the app owns (window frames, recents, a library) lives in a Store<T>. Each is a crate you can use without the others.
Wiring status: nd-settings, settings_form and Store<T> ship and are tested. The nd facade does not call them for you yet, so Application::settings::<T>(), cx.settings::<T>() and Store::load(name, cx) as the Notes example writes them do not exist, and Notes does not build until they do. Everything below names 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 beside Cargo.toml. Name and version come from Cargo, never from this file. Every table denies unknown keys, so a typo is an error with the path and line. The examples start with a #:schema line pointing 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]: reverse-DNSid(required),display_name,icon,categories,url_schemes(bare schemes, no://) and[[app.file_associations]](extwithout a dot,role,name,mime). The schemes and associations reachInfo.plist, the.desktopfile and the installer; the app receives them as system events.[patterns]: project-wide pattern choices (tooltips, menus, popovers, context menus, settings window or sheet) that code can still override per app, window or element.[webview]and[webview.cef]: the engine,systemorcef. Packaging bundles Chromium only forcef. See webview.[updates]:feed,channels,public_keyand[updates.publish]. See packaging.[diagnostics]:retentionoverrides andsentry_dsn_env, the name of the variable that holds the DSN. See diagnostics.[macos],[windows],[linux]: minimum system version, signing identity, notarization, entitlements andswift_packages; publisher, installer splash and shortcuts, MSI, signing; package formats.[web]:base_pathfor the static bundle. See web.
validate rejects what the schema cannot express: a malformed app.id, a feed without a public_key, a bad channel name, notarize without a notary_profile and a signing identity.
Settings struct
A settings struct is the single source. The derive gives the JSON Schema, the metadata 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 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 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 (feature settings-ui 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 set with nd_components::forms::settings::provide(cx, handle). Without one it shows the defaults, read-only. A key that a Nix-managed file sets shows 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, config.local.toml, a writable overlay,- environment variables,
<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, %APPDATA%\<app> on Windows, or ND_APP_CONFIG_DIR when set. Every key remembers its layer: source(key) says which, and key_state(key) says 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; 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 is 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 stale. mkApp in nix/mk-app.nix builds the package with crane and wires 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 that the settings schema is in sync. Updates are off for a binary under /nix/store.
Machine state
Store<T> holds state that is not a preference. It is JSON on disk as {"version": N, "data": ..}. Writes are debounced (250 ms, never more than 2 s behind), go through a temp file and a rename, and flush on drop. A file that cannot be read or migrated is moved 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 is 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 and gets Instance::Forwarded; then it 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 is 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 a raw stream for protocols that are not 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 depends on no ORM: SqliteExecutor is the contract 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: a failing statement rolls the whole batch back. nd-data is not re-exported by nd; add the crate directly. It bundles SQLite through rusqlite and is not built for the web.