Locara

37 — App Settings & User Preferences

Every nontrivial app accumulates user-tunable preferences: theme, model size, default language, hotkeys, organizational defaults. This document specifies a uniform settings primitive so apps don’t reinvent it badly.

Why a framework primitive

Without a shared abstraction:

  • Each app picks its own storage (JSON file? sqlite table? localStorage?).
  • Settings get destroyed on update.
  • Apps don’t consistently support import/export.
  • There’s no uniform settings surface across apps without a primitive.
  • Settings UI varies wildly in quality.

With a primitive:

  • One canonical place: app-data/settings.sqlite table or JSON file.
  • Migration semantics handled by the framework.
  • The optional Locara Manager utility (phase 3+) can offer a unified settings surface across apps.
  • Components for common settings widgets ship in @locara/components.

Storage model

Settings live in a special SQLite table per app, with a fixed schema:

CREATE TABLE __locara_settings (
  key      TEXT PRIMARY KEY,
  value    TEXT NOT NULL,         -- JSON-encoded
  updated  INTEGER NOT NULL,      -- unix ms
  scope    TEXT NOT NULL DEFAULT 'user'
                                  -- 'user' or 'system' or 'profile:<name>'
);

This table is auto-created by the runtime; apps don’t need to declare it in their schema.sql.

SDK API

import { settings } from '@locara/sdk'

// Get a value (with type inference)
const theme = await settings.get<'light' | 'dark' | 'auto'>('theme', 'auto')
// Default if not set; returns 'auto' if no value

// Set a value
await settings.set('theme', 'dark')

// Subscribe to changes
const unsub = settings.subscribe('theme', (newValue) => {
  applyTheme(newValue)
})

// Bulk read
const all = await settings.getAll()
// Returns Record<string, any>

// Delete a key
await settings.delete('experimental.feature-x')

// Profile / scoped settings (for power users)
await settings.set('export-format', 'markdown', { scope: 'profile:work' })

Schema declaration

Apps optionally declare their settings schema in app/settings.ts or similar:

import { defineSettings } from '@locara/sdk'

export const settings = defineSettings({
  theme: {
    type: 'enum',
    values: ['light', 'dark', 'auto'],
    default: 'auto',
    label: 'Theme',
  },
  modelSize: {
    type: 'enum',
    values: ['small', 'medium', 'large'],
    default: 'medium',
    label: 'Model size',
    description: 'Larger models are more accurate but use more memory.',
  },
  exportFormat: {
    type: 'enum',
    values: ['markdown', 'plain', 'json'],
    default: 'markdown',
    label: 'Default export format',
  },
  hotkey: {
    type: 'string',
    default: 'cmd+shift+t',
    label: 'Quick capture hotkey',
  },
  language: {
    type: 'string',
    default: 'auto',
    label: 'Transcription language',
  },
})

Benefits of declaration:

  • TypeScript types auto-generated.
  • The framework can render a default settings UI.
  • The optional Locara Manager utility (phase 3+) can show app settings without the app being open.
  • Validation enforced at write time.

Default settings UI

The framework provides a <Settings> component that introspects the declared schema and renders a panel:

import { Settings } from '@locara/components/settings'

function SettingsPage() {
  return <Settings schema={appSettings} />
}

Renders:

  • One section per setting category (declared via category: 'appearance' etc.).
  • Native macOS form controls (toggle, segmented control, picker, text field).
  • Live updates — changes apply as the user makes them.
  • “Reset to default” buttons.
  • Search across settings for apps with many.

Apps can override individual setting renderers for custom needs.

Settings categories

Optional grouping via the schema:

defineSettings({
  theme: { type: 'enum', category: 'appearance', ... },
  hotkey: { type: 'string', category: 'shortcuts', ... },
  exportFormat: { type: 'enum', category: 'export', ... },
})

Categories are free-form; the framework alphabetizes within each.

Sync / portability

Settings are local. They don’t sync to a cloud (privacy thesis). They DO travel with app data in two ways:

  1. db.export.* includes settings. When the user exports their data, settings come along.
  2. Backup includes settings. Migration backups include the settings table.

Restoring from a backup restores settings.

Profile / scoping

For power users who want app behavior to differ in different contexts (e.g., “work” vs “personal” usage of the same app):

// Set a profile
await settings.setProfile('work')

// Now reads/writes default to scope: 'profile:work'
const format = await settings.get('exportFormat')

Profiles are an opt-in feature. Apps that don’t need them ignore the profile API entirely.

System-vs-user scope

Some settings are per-user; some are system-wide for the app installation. Distinguished via the scope column.

ScopeWhen to use
user (default)Most settings — user preference
systemApp-wide config that survives user-switching (rare)
profile:<name>Per-context for the same user

System-scope settings require the user to confirm changes (since they affect all uses).

Settings UI in the optional Locara Manager utility (phase 3+)

If shipped, the optional Locara Manager utility provides a “Settings for installed apps” surface:

Locara → Apps → Transcribe → Settings

This shows the app’s declared settings without the app needing to be open. Edits write to the same SQLite store the app reads at next launch.

This is useful for:

  • Pre-configuring an app before first use.
  • Bulk-managing settings across apps.
  • Resetting an app to defaults if it’s misbehaving.

Settings + capabilities

Settings cannot grant capabilities. An app can’t have a setting “Enable network” that bypasses net: false. Capabilities are set in the manifest and immutable for that version.

What settings CAN do: switch between behaviors that all live within the declared capabilities. E.g., “model size” toggles between three models, all of which are declared in capabilities.models[].

Validation

Set operations validate against the declared schema:

  • set('theme', 'green') → fails because ‘green’ isn’t in the enum.
  • set('hotkey', { complex: 'object' }) → fails because the schema declares string.
  • set('unknownKey', 'foo') → fails (or warns) because the key isn’t declared.

For apps that need dynamic settings (uncommon), defineSettings can include additionalProperties: true.

Migration

When an app version changes the settings schema:

  • Adding a new setting with default → no migration needed; reads return the default if not set.
  • Removing a setting → values for that key persist in the table but are unused; can be cleaned up by app code.
  • Renaming a setting → app provides a migration function:
defineSettings({
  // ... new schema
  migrations: {
    1: (db) => {
      db.run`UPDATE __locara_settings SET key = 'modelSize' WHERE key = 'model_size'`
    }
  }
})

Migration version stored in the same table.

Performance

Settings reads are fast (SQLite point reads). The SDK caches settings in memory after first read; updates invalidate the cache. Subscribers are notified on change.

For apps with hundreds of settings (rare), consider lazy-loading.

What’s not in v1

  • Cloud sync. No.
  • Cross-app shared settings beyond the explicit IPC shared-folder mechanism.
  • Encrypted settings for secrets. Use Keychain via a separate capability if you need actual secret storage.
  • Server-side settings management for org/enterprise deployments. v2+ if demand justifies.

Open questions

  • (open) Should the framework expose a “preferences pane” that opens via the standard macOS Cmd+, shortcut universally? Probably yes — the <Settings> component handles it.
  • (open) Should certain settings be required to be set at install time (no default)? E.g., “where do you want files saved?” Probably opt-in via schema.
  • (open) UI for showing which settings differ from defaults — useful for “what have I customized?”

Cross-references