# Settings and config

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.

> **Example: Notes.** Source in [examples/notes](https://github.com/FormalSnake/NativeDesktop/blob/main/examples/notes). Live on the web and captured on macOS, Linux and Windows in the docs app.


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.

```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/FormalSnake/NativeDesktop/blob/main/examples/notes/nativedesktop.toml), line 1.</sub>


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

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

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

```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/FormalSnake/NativeDesktop/blob/main/examples/notes/src/settings.rs), line 7.</sub>


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

```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/FormalSnake/NativeDesktop/blob/main/examples/notes/src/settings.rs), line 50.</sub>


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:

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, `<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, `%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:

```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/FormalSnake/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/FormalSnake/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; `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`.

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

```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/FormalSnake/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 is no file watcher and no Nix layer. See [web](https://nativedesktop.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 and gets `Instance::Forwarded`; then it 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/FormalSnake/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 is 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/FormalSnake/NativeDesktop/blob/main/crates/nd-ipc/tests/gate_m7_ipc.rs), line 18.</sub>


`connect` and `connect_path` give a raw stream for protocols that are not JSON lines; the
[Discord plugin](https://nativedesktop.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 depends on no
ORM: `SqliteExecutor` is the contract 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/FormalSnake/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/FormalSnake/NativeDesktop/blob/main/crates/nd-data/tests/gate_m7_data.rs), line 29.</sub>


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