Back to logs
2026-03-18 uxfeaturebug-fix

Profile persistence and startup profile selection

Two related quality-of-life improvements landed today around how the app decides which profile to activate at startup, and how it remembers that decision across sessions.

The problem: alphabetical roulette

Before this change, the startup profile was determined by a single line:

layer_registry.profiles().min_by_key(|p| &p.name)

Whichever profile name sorted first alphabetically became the active profile on every launch. No "last used" memory, no user intent. Just alphabetical order. If you had spent the session using a specific profile, closing and reopening the app silently reset to whatever name happened to sort first.

The fix: preferences.json

A new preferences.json file lives alongside devices.json in the app config directory. It is written every time the user explicitly activates or deactivates a profile.

{
  "version": 1,
  "profile_active": true,
  "last_active_profile_id": "starter-right"
}

On startup, the backend reads this file before initialising the engine. The selection logic is:

  1. If profile_active is false: start with the built-in empty profile (no mappings). The user explicitly asked for no profile.
  2. If profile_active is true and last_active_profile_id names a profile that still exists: activate it.
  3. If the named profile no longer exists (deleted between sessions): fall back to alphabetically-first.
  4. If there are no profiles at all: use the built-in empty profile.

Persistence lives on the Rust side so the engine is initialised with the correct profile before the frontend even loads. Doing this in localStorage and calling activate_profile from the frontend would have caused a brief flicker. The wrong profile would be active during the window between page load and the first invoke.

Bug: deactivate didn't stick across restarts

The initial implementation had a subtle flaw. When deactivate_profile was called, it saved last_active_profile_id: null to disk. On the next launch, this was indistinguishable from a first-launch state where preferences.json doesn't exist yet. In both cases the field is absent or null, and both fell through to the alphabetical fallback. So the app always activated a profile on restart regardless of whether the user had explicitly deactivated.

The fix is the profile_active boolean. It defaults to true (so first-launch still picks the seeded starter profile), and is only set to false by an explicit deactivate_profile call. The startup logic now checks this flag first and short-circuits to the empty built-in if it is false, skipping all fallbacks.

The field uses #[serde(default = "default_true")] so any preferences.json written before this field existed (or on a fresh install before the first deactivate) reads as true and behaves identically to before.

Device-aware profile suggestions

A related UX gap: the app already warned when a dual profile was active with only one device connected, but said nothing about the reverse. Two devices connected with only a single-hand profile active. A dismissible suggestion banner now appears on the Devices page in that case:

"You have two devices connected. Consider switching to a dual profile."

The banner includes a direct link to the Profiles page and a Dismiss button. The dismiss is session-only and resets on the next launch so the user is reminded if the condition still holds. Auto-switching was deliberately not implemented: changing the active profile clears the layer stack and fires on_exit actions, which would be disruptive mid-session without explicit user intent.