Skip to content

refactor: profile-scope set_profile_setting + factor out the duplicated preference-hook pattern #485

Description

@InstaZDLL

Two related weaknesses surfaced during the review of #483 (artist hero, #482). Both are pre-existing and shared across the codebase, so they were deliberately left out of that PR rather than fixed for one hook only — fixing a single call site would have given the illusion of coverage while 20+ others stayed exposed.

1. set_profile_setting is not scoped to a profile (TOCTOU)

commands/profile.rs::set_profile_setting takes key / value / value_type and resolves the pool itself:

pub async fn set_profile_setting(state, key, value, value_type) -> AppResult<()> {
    let pool = state.require_profile_pool().await?;
    // INSERT ... ON CONFLICT DO UPDATE
}

Every JS caller guards the write against a profile switch by comparing a captured id with the live one — the pattern in useCoverSlideshow and its copies:

const write = writeChainRef.current.then(async () => {
  if (activeProfileIdRef.current !== profileId) return;
  await setProfileSetting(KEY, next ? "true" : "false", "bool");
  ...
});

That check happens before the await. A switch_profile landing between the guard and require_profile_pool() acquiring the lease writes the preference into the wrong profile. The window is small and the blast radius is one UI preference (no data loss), but it's a genuine correctness gap and it exists on every write path.

Fix: take an expected profile id on the command and validate it while holding the same lock used to acquire the active pool (see state.rs) — a JS-side guard fundamentally cannot close this, only the backend can. Mismatch should be a no-op or an explicit error the caller can ignore, not a write.

Scope: 22 call sites across 18 files (setProfileSetting in src/), including every preference hook, ThemeContext, SkinContext and lib/shortcuts.ts. get_profile_setting deserves the same treatment for symmetry — a read racing a switch returns another profile's value.

2. The preference-hook pattern is copy-pasted 7×

These all reimplement the same machinery — serialized write chain, profile-switch guards, rollback to the last backend-confirmed value, a window event to re-read across mounted consumers:

The review of #483 hardened only the last one, which now carries fixes the other six don't:

  • A read in flight clobbering an optimistic toggle — a read started at mount (or by the broadcast event) applied its result unconditionally, overwriting a toggle clicked meanwhile. useArtistHero now tags each read with a token bumped by every read and every write.
  • A stale resolved marker across profiles — only relevant to hooks whose default is ON (useArtistHero is the only one today, hence resolved + the reset of the outgoing profile's value on switch). The others default to OFF, so an unresolved frame paints nothing and the bug is invisible — but it becomes real the day any of them flips its default.

Fix: factor a single useProfileBooleanSetting(key, { default }) (plus a value-typed sibling for useVisualizerColor / useHiddenKpis) carrying the hardened logic, and port the seven hooks onto it. That also makes #1 a one-place change on the JS side.

Why now

Neither is urgent — #1 needs a rare interleaving, #2 is latent for six of the seven hooks. But both compound: every new preference hook copies the pattern again and adds a call site to #1. Worth doing before the next one lands.

Found during: #483 review.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions