Skip to content

App Settings Store & Persistence

dazeb edited this page Sep 17, 2026 · 2 revisions

App Settings Store & Persistence

src/core/app-settings.ts is the app-wide settings layer — not per-project configuration. It defines the AppSettings schema re-export, the canonical defaults, the tolerant normalizer that every read and every write passes through, and the two filesystem entrypoints loadAppSettings / saveAppSettings.

Two design constraints shape the whole module:

  • Electron-free. The header comment states this explicitly so the Server Edition can share the identical file. The module only imports node:fs and node:path; the src/core no-electron guard keeps that boundary honest.
  • Path-injected. Nothing here knows about app.getPath('userData'). Every function takes a userDataPath and resolves settings.json beneath it through settingsPath(), so the Electron main process and the Server data dir can both reuse it unchanged.

The consequence of centralizing everything here is that a key which is not carried through normalization is silently erased from disk on the next save — a failure mode the relay sub-normalizer calls out in a comment (app-settings.ts#L114-L119).

Files and responsibilities

File Role
src/core/app-settings.ts DEFAULT_APP_SETTINGS, settingsPath, per-domain normalizers, normalizeAppSettings, loadAppSettings, saveAppSettings
src/core/app-settings.test.ts Round-trip-to-disk tests, garbage-input normalization tests, the "empty input equals defaults" invariant
src/shared/ipc.ts Channel constants app:settings-get / app:settings-set (src/shared/ipc.ts#L12-L13) — the renderer-facing surface

What the module deliberately does not do: cache, watch, lock, or validate cross-references. Every loadAppSettings call re-reads and re-parses the file, and saveAppSettings performs a full read-modify-write with no lock. There is also no in-memory singleton — callers get a fresh object each time.

Defaults and schema shape

DEFAULT_APP_SETTINGS (app-settings.ts#L11-L32) is the complete always-present shape:

Key Default Notes
autoDownloadUpdates false Auto-update download stays opt-in
accounts [] Managed agent accounts; normalization accepts only agentId === 'claude'
activeAccountId null Non-empty string or null; no referential check against accounts
dismissedAnnouncementVersion null Any string passes, including ''
a2aPeers [] Outbound A2A peers (id, label, endpoint, optional token)
apiProviders [] OpenAI-compatible providers (id, name, baseUrl)
theme 'system' Narrowed to light | dark | system
enterBehavior 'queue' Any non-empty string survives; not narrowed to a union at runtime
agentBrowserControl false CDP facade + /open agent surface, opt-in off by default
agentA2aServer false Exposing agent nodes to peers, opt-in off by default
invertWheelZoom false Mousewheel zoom direction
terminalFontFamily 'Geist Mono, JetBrains Mono, monospace' Trimmed, must be non-empty
terminalProfile '' Trimmed
httpProxy '' Trimmed
telegram { enabled: false, allowedChatIds: [] } Always emitted in full default shape

Four keys are intentionally absent from the defaults object, because presence itself carries meaning:

Key Absent means
onboardedAt First run has not finished — onboarding may show
browserHomeUrl Renderer falls back to DuckDuckGo
chat No chat provider/model/keys configured
relay No relay URL to dial, i.e. relay is unconfigured

The tested invariant is that normalizeAppSettings(undefined), normalizeAppSettings({}), and normalizeAppSettings({ junk: true }) all deep-equal DEFAULT_APP_SETTINGS (app-settings.test.ts#L27-L28). Any new field must therefore land in both the defaults object and the normalizer, or that equality breaks.

Validation & normalization semantics

normalizeAppSettings (app-settings.ts#L134-L223) is the only validator. It never throws and never rejects a whole file for one bad field — it narrows field by field and falls back per field. The recurring patterns:

Pattern Rule Fields
Strict boolean Kept only when === true; strings like 'yes' become false autoDownloadUpdates, agentBrowserControl, agentA2aServer, invertWheelZoom, telegram.enabled
Enum narrowing Literal allow-list, else default theme, accounts[].permissionMode (via isPermissionMode)
Trimmed non-empty string Trimmed and required non-empty, else default/omitted terminalFontFamily, onboardedAt, browserHomeUrl, relay.url
Non-empty / typed string Length-checked but not trimmed, or only typeof-checked activeAccountId, a2aPeers[].token, relay.invite, trustedFingerprint, chat.keys[].key
Defaulted literal Fallback only on junk/absence enterBehavior → 'queue', relay.role → 'host'
Array of narrowed records map → filter → map; invalid entries dropped silently accounts, a2aPeers, apiProviders, telegram.allowedChatIds, chat.keys
Optional sub-object Returns undefined when nothing valid was configured; spread into the result only when present chat, relay
Presence-marked scalar Key entirely omitted unless a valid value parses onboardedAt, browserHomeUrl

Domain-specific normalizers keep nesting out of the main function:

  • normalizeTelegram (#L59-L70) always returns the full default shape so a fresh file equals the defaults; the token is kept only when it is a non-empty string, and chat ids are filtered to non-empty strings.
  • normalizeChat (#L76-L112) drops empty keys arrays and empty override maps, keeps priceOverrides only when in/out are both numbers, and returns undefined when nothing parsed — preserving the default-settings shape for existing consumers.
  • normalizeRelay (#L120-L132) returns undefined when there is no non-empty trimmed URL, then defaults role to 'host' and carries invite / trustedFingerprint through.

Load path

loadAppSettings(userDataPath) (app-settings.ts#L225-L233):

  1. Resolve settingsPath(userDataPath) → <userDataPath>/settings.json.
  2. If the file does not exist, return a spread copy of DEFAULT_APP_SETTINGS, so callers can never mutate the module-level constant.
  3. Otherwise readFileSync(..., 'utf8') and JSON.parse, then hand the parsed value to normalizeAppSettings.
  4. Any throw from read or parse is caught and returns the same defaults copy.

The load path never writes, never migrates in place, and never reports an error upward. A corrupt file is tolerated by discarding it in memory; the next save overwrites it.

Save path

saveAppSettings(userDataPath, patch) (app-settings.ts#L235-L240) is a read-modify-write:

  1. loadAppSettings(userDataPath) — the current normalized state.
  2. Merge: { ...loaded, ...patch }. Shallow merge, so nested objects are replaced wholesale, not deep-merged.
  3. normalizeAppSettings(...) on the merged object — this both validates the patch and determines what actually gets persisted.
  4. mkdirSync(userDataPath, { recursive: true }) so a first-run save creates the data directory.
  5. writeFileSync(settingsPath(...), JSON.stringify(next, null, 2) + '\n', 'utf8') — whole-file overwrite with a trailing newline.
  6. Return next, so the caller receives the canonical persisted value without re-reading.

Two merge subtleties worth knowing:

  • Normalization prunes. Because normalizeAppSettings builds its result from an explicit allow-list of keys, anything the patch adds that the normalizer doesn't recognize is gone from the merged object and from disk. This is why the relay comment insists normalization must carry new keys through.
  • undefined in a patch deletes a key. The spread overwrites the loaded value with undefined, and normalization omits non-conforming values — so saveAppSettings(dir, { onboardedAt: undefined }) removes the key entirely, which is exactly how "re-run onboarding" is expressed (tested at app-settings.test.ts#L90-L98).

Flow

flowchart TD
    RGet["Renderer: read settings"] -->|"IPC app:settings-get"| Load["loadAppSettings(userDataPath)"]
    RSet["Renderer: patch settings"] -->|"IPC app:settings-set"| Save["saveAppSettings(userDataPath, patch)"]

    subgraph LoadPath["Load path"]
        Load --> Path["settingsPath() → userDataPath/settings.json"]
        Path --> Exists{"file exists?"}
        Exists -->|no| Defs["{ ...DEFAULT_APP_SETTINGS }"]
        Exists -->|yes| Parse["JSON.parse(readFileSync)"]
        Parse -->|throws| Defs
        Parse -->|ok| NormLoad["normalizeAppSettings"]
        NormLoad --> Result["AppSettings (canonical)"]
        Defs --> Result
    end

    subgraph SavePath["Save path (read-modify-write)"]
        Save --> Merge["{ ...loadAppSettings(), ...patch }"]
        Merge --> NormSave["normalizeAppSettings"]
        NormSave --> Mkdir["mkdirSync recursive"]
        Mkdir --> Write["writeFileSync settings.json<br/>JSON.stringify(next, null, 2) + newline"]
        Write --> Ret["returns normalized next"]
    end
Loading

Key nodes:

  • Two arrows into normalizeAppSettings. One function is the single validation chokepoint in both directions. There is no separate "validate on write" path, which is what makes patch semantics and key deletion fall out of the same rules.
  • loadAppSettings inside the save path. Saves are partial patches, so the previous state is always read back and merged before writing. Concurrent writers can therefore lose each other's changes; nothing in this module serializes them.
  • Defaults on both the missing-file and parse-failure branches. A truncated or hand-edited-broken settings.json degrades to a defaults view rather than an error, and is overwritten on the next save.
  • The write is a plain whole-file writeFileSync. Because the import list only contains existsSync, mkdirSync, readFileSync, and writeFileSync, there is no temp-file-plus-rename: a crash mid-write can leave invalid JSON, which the load catch turns into "defaults" — recoverable, but the previous configuration is lost.

Boundary conditions

  • Unknown or stale keys are dropped. Anything not represented in normalizeAppSettings disappears from disk on the next save. Schema evolution must therefore be additive at the normalizer, not just at the type.
  • accounts is Claude-only. Entries missing id, label, or configDir, or whose agentId is not 'claude', are filtered out with no error. permissionMode is attached only when it is one of default / acceptEdits / bypassPermissions.
  • activeAccountId has no referential integrity. A non-empty string is kept even if no account with that id exists after filtering.
  • chat.keys[].key is only typeof-checked. An empty-string API key survives normalization, while an empty providerId does not.
  • enterBehavior is not narrowed. Any non-empty string persists; only absent/empty values fall back to 'queue'.
  • dismissedAnnouncementVersion accepts ''. The check is typeof === 'string', with null as the only other outcome.
  • normalizeChat and normalizeRelay each run twice in the return object spread (#L217-L218) — once as the presence guard, once for the value. Harmless for these small pure functions, but relevant if one grows expensive.
  • loadAppSettings assumes the caller's directory may not exist. It only checks the file; directory creation happens on the save side.

Extension points

To add a setting:

  1. Extend AppSettings (imported as a type from ../shared/types, #L7) — the shared domain type is the schema's public face.
  2. If it must always be present, add it to DEFAULT_APP_SETTINGS; if absence is meaningful (like onboardedAt / browserHomeUrl / chat / relay), omit it and gate it behind a spread.
  3. Add an explicit normalization branch — including nested domains via a dedicated normalizeX helper when the value is a structured object. Skipping this step means the value will not survive a save.
  4. Cross-process access needs no new channel: app:settings-get / app:settings-set already carry the whole object, and channel names must come from IPC constants in src/shared/ipc.ts, never string literals.
  5. Only add a dedicated IPC verb when the operation is more than a settings field write — the capability pages use that pattern (settings:capabilities-get, settings:set-skill-enabled, settings:set-plugin-enabled, settings:reinstall-hooks), since CLI-side config files are not part of this schema.
  6. Add a round-trip test and a garbage-input test in app-settings.test.ts, using the existing mkdtempSync scratch-dir pattern and asserting the raw JSON on disk when persistence matters.

Consumers at a glance

The fields here back, among others: auto-update behavior, the first-run onboarding gate, theme selection, canvas wheel-zoom direction, terminal font/profile, the HTTP proxy, the agent browser-control surface and browser home URL, the A2A server and peer list, managed agent accounts, chat provider/model/keys/pricing, the relay client configuration, the Telegram bot, and announcement dismissal.

Sources: src/core/app-settings.ts Sources: src/core/app-settings.test.ts Sources: src/shared/ipc.ts

termsprawl

App Shell & Platform Foundations

Canvas, Nodes & Renderer State

Terminals & Session Continuity

Persistence, Projects & Files

Agent Runtime & Tooling

Chat Nodes & Model Providers

Git & Source Control

Embedded Browser Nodes

Server Edition

Relay & Remote Access

Integrations & Secondary Surfaces

Settings, Updates & Maintenance

Clone this wiki locally