# Settings and config

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.** Source in [examples/notes](https://github.com/FormalStudio/NativeDesktop/blob/main/examples/notes). Its page on the docs site shows it captured on macOS, Linux and Windows.


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.

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

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


The tables, from [nd-config](https://github.com/FormalStudio/NativeDesktop/blob/main/crates/nd-config/src/lib.rs):

- `[app]`: the reverse-DNS `id` (required), `display_name`, `icon`, `categories`, `url_schemes`
  (bare schemes, without `://`), and `[[app.file_associations]]` (`ext` without a dot, `role`,
  `name`, and `mime`). The schemes and associations go into `Info.plist`, the `.desktop` file,
  and the installer. The app receives them as [system events](https://native.formalsnake.dev/guides/system.md#app-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, `system` or `cef`. Packaging bundles Chromium only
  for `cef`. See [webview](https://native.formalsnake.dev/guides/webview.md).
- `[updates]`: `feed`, `channels`, `public_key`, and `[updates.publish]`. See
  [packaging](https://native.formalsnake.dev/guides/packaging.md#releases-and-updates).
- `[diagnostics]`: `retention` overrides and `sentry_dsn_env`, the name of the variable that
  holds the DSN. See [diagnostics](https://native.formalsnake.dev/tooling/diagnostics.md).
- `[macos]`, `[windows]`, `[linux]`: for macOS, the minimum system version, signing identity,
  notarization, entitlements, and `swift_packages`. For Windows, the publisher, installer splash
  and shortcuts, MSI, and signing. For Linux, the package formats.
- `[web]`: `base_path` for the static bundle. See [web](https://native.formalsnake.dev/guides/web.md).

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

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

<sub>From [examples/notes/src/settings.rs](https://github.com/FormalStudio/NativeDesktop/blob/main/examples/notes/src/settings.rs), line 7.</sub>


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

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

<sub>From [examples/notes/src/settings.rs](https://github.com/FormalStudio/NativeDesktop/blob/main/examples/notes/src/settings.rs), line 50.</sub>


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. `fspec.toml`, which fspec writes from your spec (see [fspec](#fspec)).
6. `config.local.toml`, a writable overlay.
7. Environment variables named `<APP>_KEY`, where `__` nests (`NOTES_EDITOR__FONT_SIZE` sets
   `editor.font_size`).
8. 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:

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

<sub>From [crates/nd-settings/tests/gate_m7_settings.rs](https://github.com/FormalStudio/NativeDesktop/blob/main/crates/nd-settings/tests/gate_m7_settings.rs), line 13.</sub>

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

<sub>From [crates/nd-settings/tests/gate_m7_settings.rs](https://github.com/FormalStudio/NativeDesktop/blob/main/crates/nd-settings/tests/gate_m7_settings.rs), line 26.</sub>


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

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

<sub>From [crates/nd-settings/tests/gate_m7_settings.rs](https://github.com/FormalStudio/NativeDesktop/blob/main/crates/nd-settings/tests/gate_m7_settings.rs), line 54.</sub>


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](https://github.com/FormalStudio/NativeDesktop/blob/main/nix/mk-app.nix) builds the package with `crane` and wires up the module
([nix/hm-module.nix](https://github.com/FormalStudio/NativeDesktop/blob/main/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](https://github.com/FormalStudio/FormalSpec) 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:

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

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

<sub>From [crates/nd-settings/tests/schema_fspec.rs](https://github.com/FormalStudio/NativeDesktop/blob/main/crates/nd-settings/tests/schema_fspec.rs), line 47.</sub>


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

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

<sub>From [crates/nd-settings/tests/store.rs](https://github.com/FormalStudio/NativeDesktop/blob/main/crates/nd-settings/tests/store.rs), line 50.</sub>


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

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

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

<sub>From [crates/nd-ipc/tests/gate_m7_ipc.rs](https://github.com/FormalStudio/NativeDesktop/blob/main/crates/nd-ipc/tests/gate_m7_ipc.rs), line 107.</sub>


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

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

<sub>From [crates/nd-ipc/tests/gate_m7_ipc.rs](https://github.com/FormalStudio/NativeDesktop/blob/main/crates/nd-ipc/tests/gate_m7_ipc.rs), line 18.</sub>


`connect` and `connect_path` give you a raw stream for protocols that aren't JSON lines. The
[Discord plugin](https://native.formalsnake.dev/guides/system.md#extending-outside-the-framework) 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.

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

<sub>From [crates/nd-data/tests/gate_m7_data.rs](https://github.com/FormalStudio/NativeDesktop/blob/main/crates/nd-data/tests/gate_m7_data.rs), line 7.</sub>

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

<sub>From [crates/nd-data/tests/gate_m7_data.rs](https://github.com/FormalStudio/NativeDesktop/blob/main/crates/nd-data/tests/gate_m7_data.rs), line 29.</sub>


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