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", "...": "..." }
}| OS | Location |
|---|---|
| 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.
| Tab | Section file | Controls |
|---|---|---|
| General | GeneralSection | Language, launch at login, minimize to tray |
| Appearance | AppearanceSection | Theme, accent color |
| Notifications | NotificationsSection | Native-notification toggle |
| Shortcuts | ShortcutsSection | Keyboard-shortcut list |
| Proxy | ProxySection | Network proxy mode + URL |
| Storage | StorageSection | Data directory, backup export/import |
| Updates | UpdatesSection | Auto-update toggle, channel (flag-gated) |
| Advanced | AdvancedSection | Error 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
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:
- Add the field to the store (above).
- Render the control in the right section, calling
updateSettings({ field }). - Add a
settings-search.tsentry 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.
