Settings Store

The versioned settings store, settings center UI, backup, and secure storage.

User preferences live in the main process as a versioned JSON file. The renderer reads and writes them only over Conveyor, the settings center UI is a set of searchable tabs, and secrets never touch this store — they go through safeStorage instead.

The store

lib/main/settings.ts (SettingsStore) owns settings.json under Electron's userData directory. It is written as a versioned envelope:

{
  "version": 10,
  "settings": { "theme": "dark", "locale": "system", "...": "..." }
}
OSLocation
macOS~/Library/Application Support/<AppName>/settings.json
Windows%APPDATA%\<AppName>\settings.json
Linux~/.config/<AppName>/settings.json

Every field is defined by appSettingsSchema (lib/conveyor/schemas/app-schema.ts) and validated on read and write, so a hand-edited or corrupt file falls back to defaults rather than crashing the app.

Forward migrations

The store is versioned with a chain of forward migrations. CURRENT_SETTINGS_VERSION is the target; each entry in SETTINGS_MIGRATIONS upgrades one version to the next. On load, migrateForward runs every migrator from the file's version up to current, then re-validates:

// lib/main/settings.ts
export const CURRENT_SETTINGS_VERSION = 10

const SETTINGS_MIGRATIONS = {
  // ...
  7: (v7) => ({ ...v7, accentColor: 'default' }),
  8: (v8) => ({ ...v8, onboardingCompleted: false, eulaAcceptedVersion: 0 }),
  9: (v9) => ({ ...v9, zoomLevel: 0 }),
}

Adding a setting is a small, mechanical change:

Add the field to the schema

Extend appSettingsSchema and appSettingsPatchSchema in app-schema.ts, and add it to DEFAULT_SETTINGS.

Bump the version and add a migrator

Increment CURRENT_SETTINGS_VERSION and add a SETTINGS_MIGRATIONS entry that seeds the new field on top of the previous payload.

Merge it in update()

Add the if (patch.<field> !== undefined) line so the renderer can write it.

A file newer than the app is left untouched and the store falls back to defaults, rather than silently downgrading data it doesn't understand. A legacy unversioned file is treated as v1 and migrated forward.

The settings center

app/routes/SettingsPage.tsx renders the settings center as tabs. There are eight tabs; Account is its own page (/account), not a settings tab.

TabSection fileControls
GeneralGeneralSectionLanguage, launch at login, minimize to tray
AppearanceAppearanceSectionTheme, accent color
NotificationsNotificationsSectionNative-notification toggle
ShortcutsShortcutsSectionKeyboard-shortcut list
ProxyProxySectionNetwork proxy mode + URL
StorageStorageSectionData directory, backup export/import
UpdatesUpdatesSectionAuto-update toggle, channel (flag-gated)
AdvancedAdvancedSectionError reporting, analytics, reset to defaults

The Updates tab only appears when features.autoUpdate is on, so a build with auto-update stripped has no orphaned tab.

settings-search.ts is a small index of individual controls, each pointing at its owning tab. SettingsSearch matches the query against the translated label, description, and keywords, so a user can jump to a control without knowing which tab it lives on. Hidden tabs (e.g. Updates when the flag is off) are excluded from results. The same index feeds the command palette's Settings group — see UI & Pages.

The use-settings-form pattern

use-settings-form.ts loads settings once over Conveyor and exposes updateSettings / resetSettings. Each section takes settings and updateSettings and writes a partial patch. To add a control end to end:

  1. Add the field to the store (above).
  2. Render the control in the right section, calling updateSettings({ field }).
  3. Add a settings-search.ts entry so it's findable and appears in the palette.

Backup

lib/main/backup.ts powers export/import from the Storage tab (app-export-data / app-import-data Conveyor channels). A backup is a JSON file containing your settings plus a manifest of which secure keys exist on the device.

A backup never contains decrypted secrets. The Supabase session, tokens, and license key are device-bound via safeStorage; exporting them in plaintext would defeat the OS encryption. Restoring on a new device means signing in again to re-establish them.

Import validates the file with parseBackup (Zod) and restores the settings through settingsStore.restore(), re-applying tray and updater preferences. An invalid file returns a clean error instead of restoring garbage.

Secure storage

lib/main/secure-storage.ts (SecureStorage) is the encrypted counterpart to the settings store, backed by Electron's safeStorage (OS-keychain-backed encryption). It persists to secrets.bin in userData, written with 0o600 permissions. The renderer reaches it through the secure Conveyor domain (window.conveyor.secure.get/set/delete).

The known secure keys are a fixed enum:

// lib/conveyor/schemas/secure-schema.ts
export const secureKeySchema = z.enum([
  'access-token', 'refresh-token', 'supabase-session', 'license-key',
])

In practice the template stores the Supabase session here — see Authentication for why localStorage isn't good enough on desktop.

When encryption is unavailable (for example, a Linux box with no keyring), a write throws a typed SecureStorageError with code ENCRYPTION_UNAVAILABLE rather than silently writing plaintext. Callers surface that as a sign-in that won't persist across restart; see Troubleshooting.

On this page