Skip to content

Apex keyboard support, and two rounds of audit fixes - #15

Merged
fbnlrz merged 17 commits into
mainfrom
dev/1.0.13
Aug 1, 2026
Merged

Apex keyboard support, and two rounds of audit fixes#15
fbnlrz merged 17 commits into
mainfrom
dev/1.0.13

Conversation

@fbnlrz

@fbnlrz fbnlrz commented Aug 1, 2026

Copy link
Copy Markdown
Owner

SteelSeries Apex keyboard support, and the results of two adversarial audit
passes over the parts of the codebase that had never been systematically
reviewed.

Keyboard

Per-key RGB, the 128×40 OLED, 24 themed scenes, and the switch settings the
firmware actually exposes — verified against an Apex Pro TKL Wireless (2023)
over /dev/hidraw, not inferred from third-party write-ups.

The protocol notes in website/reference/protocols.md record what was
measured, including the parts where the existing public documentation is
wrong: the apply command is 0x11 and not 0x09, the third-party 0x2D
"actuation" command is accepted by the hardware and does nothing, and the
real per-key table is 0x2F in tenths of a millimetre.

The switches tab is deliberately limited to boards whose firmware dialect
was measured. The vendor specification gives the Gen 3 wired boards an
entirely different opcode set, a different per-key struct order and a
driver-mode command wrapping the exchange; guessing one dialect from the
other would have produced a complete-looking tab that moves nothing.

Audit results

Two rounds, each finding candidates and then trying to disprove them. The
first covered the device layer, the second the mixer, the PipeWire core,
persistence, the shell and updater, the remote and the frontend stores.
Roughly half the candidates did not survive the disproof step, which is what
makes the rest worth reading.

The ones that mattered most:

A shelf EQ band could become an oscillator. Clamping a negative radicand
to zero avoided a NaN and produced alpha == 0, at which point the biquad's
a0 and a2 are the same expression — both poles exactly on the unit
circle. The band then rang for ever: measured at 77 Hz for a 100 Hz corner,
above full scale, continuing after the audio stopped. At slope 10 it started
at ±8.1 dB of shelf gain, so an ordinary steep bass boost reached it. The
guard against one failure was the other failure.

Channel volumes were read once and never again. Measured while writing
this: PipeWire had sink_system at 0.0 while the stored profile said 100%
and unmuted — a silent channel behind a fader reading 100%, with nothing on
screen to explain it. Three separate readers shared that stale cache.

Application streams were addressed by a recycled id. On a running
desktop the PipeWire global id space wraps hundreds of times per session
(measured: highest live id 242, object.serial at 183684). A debounced
volume write could land on whatever inherited the number, and the
auto-routing ledger could mark a new stream as already-handled for the rest
of its life.

Four reads that overtook optimistic writes, in the mixer, the microphone
config, the media store and the channel list — one mistake made in four
places. The regression tests for them live in one file, with the pattern
named at the top.

The post-update restart died again, for the third time and the third
reason: launched from the application menu, KDE wraps the app in a transient
unit with KillMode=control-group, and setsid leaves the session but not
the cgroup.

The remote never waited for a resource to actually be free — the
listener, the in-flight permit, the connection. Regenerating a token raced
itself for its own port; on an idle machine the teardown almost always won,
under load it lost between a quarter and two thirds of the time.

Full list with file references in the commit messages.

Interface

The design pass found three CSS variables that were never declared and a
class name used five times that does not exist. It also found that Tokyo
Night's --fg-muted was the palette's colour for comments and rules being
used as a text colour, at 2.76:1 against the surface it sits on — carrying
roughly every second line of text in the application.

The keyboard screen had grown to eight cards in one column, of which two
were visible at the default window size; it is now four sections with the
key map above the controls that change it. Both equalizers gained the thing
they were missing: the hardware one showed no values at all, and the
software one spanned ±24 dB when the loudest bundled preset peaks below 9.

Verification

474 Rust tests, 168 frontend tests, clippy -D warnings and tsc clean.
Every fix that could be tested has a test that fails without it — several
were checked by reverting the fix and watching the test go red, and one
regression test caught a mistake in its own fix within a minute of being
written.

Where a claim could be checked against the running system it was: the volume
divergence against pw-dump, the restart path against systemd-run, the
glob semantics against the glob crate rather than assumed — that last one
disproved my first theory for the icon bug.

fbnlrz added 17 commits August 1, 2026 15:30
A base station could sit in the UI as "connected" while showing nothing,
and only an unplug-replug plus an app restart brought it back. Three
things combined to produce that, all of them in the discovery path:

  - Presence was emitted as soon as the hidraw node could be OPENED.
    Opening proves a file exists, not that the device on the other end
    speaks the protocol - so a node that never answered was announced as
    a working headset and the UI waited forever.
  - Only one candidate was ever tried. device::scan returned the single
    best node and the supervisor retried that same node every three
    seconds, so a silent one was a dead end rather than a reason to look
    at the next.
  - Which node won was decided by read_dir order, which is arbitrary and
    can differ between boots. That is the part that makes the symptom
    look intermittent and superstitious: replugging changes enumeration,
    so sometimes the good node happened to come first.

Now discovery returns every candidate in a total order (priority, then
node number), and the supervisor rotates through them: open, send the
safe handshake, then wait up to three seconds for a frame the
generation's decoder actually accepts. Only then is anyone told the
headset is connected. A node that stays silent is logged and skipped.

Adds `inari doctor` for the case where it still goes wrong. It runs in
the caller's process - unlike every other verb, which is forwarded to the
running instance and prints into its log - so it works on a machine where
Inari will not start properly, which is when it is needed. It lists every
SteelSeries HID node with its product id, interface, descriptor prefix,
whether the table claims it and whether it opens, then per class the
candidates in try order and whether each answers. It writes nothing to
any device.

Also fixes two things the vendor's own specification exposed, both
verified against the hardware here:

  - Command 0x47 is the stream mix - [main][unused][aux][mic] - not
    "line out volumes" [left][right][aux]. The second slider was writing
    into the unused byte and the microphone share could not be set at
    all. Reading the audio-settings frame back confirms the layout, and
    the station floors these to steps of five (42 reads back as 40, 99 as
    95), so the values are snapped where they are built rather than
    letting the UI show a number the device is not on. The sliders are
    now Main / Aux / Microphone and are seeded from the device.
  - The keyboard battery byte is the same encoding as every other
    SteelSeries device (bit 7 charging, low bits in 5% steps), not two
    BCD digits. BCD agrees at 95% and disagrees everywhere else, and
    cannot express 100% at all. One decoder now serves all families.

Queries on the keyboard now match replies to the command that asked. The
device answers one behind - a live probe found 24 stale replies queued,
and a read after 0xBC returned the firmware string from an earlier 0x90 -
so a battery read could decode a byte of an ASCII version string into a
plausible-looking percentage. Acceptance is per command because the
device is not consistent about it: most replies echo the command, the
firmware query answers with bare ASCII and no echo.

449 Rust tests, 161 frontend tests, clippy -D warnings clean. Verified on
the attached base station, keyboard and mouse.
The switches tab was a placeholder built on a third-party write-up: it sent
0x2D, which the hardware accepts and ignores, and the page said Rapid
Trigger, Rapid Tap and Protection Mode "have never been captured by
anyone". That was true of the public reverse-engineering sources and false
of the hardware. The real commands are ordinary, and now implemented:

  - 0x2F  per-key actuation, a table of [hid, press, release]
  - 0x37  Rapid Trigger sensitivity per key
  - 0x36  release mode — what actually switches Rapid Trigger on
  - 0x14  Protection Mode per key
  - 0x17  Rapid Tap / SOCD
  - 0x20  lighting config: brightness, idle brightness, idle timeout
  - 0x29  power saving: sleep timeout, high-efficiency mode
  - 0xA0/0xA9 read both configs back

Measured rather than assumed, on the attached board: writing 40 to a single
key made it trigger visibly later and 15 restored it, so the unit is tenths
of a millimetre and the switch's range is its full travel. num_keys is
honest — one key works, though the vendor driver always sends all seventy.

Four traps, each of which produces a feature that looks implemented and is
not:

  - Rapid Trigger needs TWO commands. The sensitivity table alone changes
    nothing; 0x36 is the switch, and its "on" value is 1 on wireless boards
    and 2 on wired ones.
  - The wired Gen 3 boards do not take millimetres at all — they take raw
    sensor counts through a forty-entry table that is nowhere near linear
    (2.0 mm is 50, not the ~100 a straight line between its ends gives) and
    whose last rows are not even monotonic. It is transcribed verbatim;
    interpolating would have put keys at twice the intended depth.
  - The keyboard saves to flash with 0x11, not 0x09. 0x09 is the Arctis
    base station's save command, which the write-up had copied across.
  - Only lighting config takes fields one at a time, each behind its own
    apply flag. That is what lets the idle timer change without disturbing
    brightness — and it is why "the screensaver setting does nothing" was a
    real bug: Inari's slider governed its own OLED blanking, while the
    timer the user meant lives in the keyboard and was never touched.

The idle and power timers are read back from the board on connect and the
sliders show those, so they reflect what the keyboard is actually set to
even when something else changed it. The measured board reported 60 s to
dim and 5 min to sleep.

Docs corrected, including an explicit note that the earlier page was wrong.

459 Rust tests, 161 frontend tests, clippy -D warnings clean.
The four that were live on the connected board:

* `lighting_config` (0x20), `power_saving` (0x29) and `rapid_tap_enable`
  (0x17) went out as feature reports. They are 65-byte output reports. The
  ioctl succeeds either way — the length happens to be valid — so the idle
  timer, sleep timeout, high-efficiency mode and the rapid-tap switch were
  set in the UI and not on the board.
* Those same sliders then snapped back: the status is a connect-time
  snapshot the UI treats as the truth, so the next event handed the slider
  its pre-write value and the UI wrote *that* to the board. Successful
  writes are now mirrored into the status.
* The EQ faders started at ten zeros and flattened the station's stored
  curve on first touch, with a flash save behind it. The 0x20 frame carries
  all ten bands at offsets 7..16 and was already being parsed for the
  stream mix; they now seed the faders.
* Actuation, Rapid Trigger, protection mode and rapid tap were never
  re-sent after the board lost power. These writes deliberately skip the
  flash-save, so a power cycle silently returned every switch to its
  onboard value while the UI kept showing the user's numbers.

And the rest:

* The OLED was never handed back. `0x0B` (`0x69` on the wired and older
  boards) on the mode-off transition and on exit.
* `brightness()` sent 0x22, which on these boards is `clear_direct_write` —
  a byte-identical valid command that wipes the direct-write buffer, with
  the percentage landing in the reply's error byte. Gen 2+ boards now set
  brightness through `lighting_config`; `zone_color` is dropped for them,
  since 0x21 there is the per-key direct write.
* Painting one key set every other key to 1.5 mm, because a per-key value
  was thought to need a global baseline. 0x2F addresses keys individually.
* The headset gain toggle was hardcoded to 0 and always read "Low", so
  clicking "Low" to toggle actually undid the change. The frame carries
  gain at offset 4.
* One failure counter served two independent write paths: an animated
  effect zeroed it every 33 ms and hid a permanently failing panel, while
  an onboard effect never zeroed it and let transients spread over hours
  add up to a dropped connection.
* `alt_envelope` and `oled_choice` latched for the process lifetime and
  survived a device swap. The envelope latch would have sent the next board
  the fallback shape, which the verified 0x1632 answers with EPIPE.
* `release()` re-derived the model from the table and lost the firmware
  generation correction; the corrected model is now kept.
* "Fill all"/"Clear" bypassed the 150 ms debounce, so a pending key colour
  landed afterwards and repainted that key with nothing left to tell the
  UI. The debouncer grows a cancel-by-prefix.
* Swapping the mouse cable for the dongle leaves no gap where the device is
  absent, so the transport badge kept describing the connection that ended.

The switches tab is now gated on boards whose firmware dialect Inari
actually knows. The vendor specification gives the Gen 3 wired boards an
entirely different set of opcodes, a different per-key struct order and a
driver-mode command around the whole exchange — the existing implementation
would have produced a complete-looking tab that moves nothing. Same gate on
the 0xA0/0xA9/0xF5 connect reads, which cost a 700 ms timeout each on
boards that do not implement them.

Also corrects the keyboard battery decode in the docs: 0x95 is 100 % on the
cable, not 95 % in BCD.
Outright defects first — these are not taste:

* Three CSS variables were never declared. `--fs-small` (4 rules) made the
  scene names fall back to 15px body text while the hint directly under
  them sat at 10px, which is where the broken hierarchy in the scene tiles
  came from; `--warn` landed on a hardcoded orange although `--warning`
  exists; `--fs-title-lg` did the same to the dialog title.
* `className="rtext"` is used five times in the keyboard screen and no such
  rule exists. Without `flex: 1; min-width: 0` the toggle sat against the
  text instead of the card's right edge. The real class is `rmain`.
* `--font-mono` resolved through Tailwind's default theme to a foreign
  stack, so the keyboard's inputs were the only ones in the app not in
  Fira Code.
* The effect chip said "Twelve themed scenes"; there are 24.
* The per-key actuation panel still warned that the command "changed
  nothing on the one board this could be tested against". It was verified
  on that board.

Tokyo Night's `--fg-muted` was `#565f89` — that palette's colour for
comments and rules, used here as a text colour at 2.76:1 on `--bg-surface`.
It carries roughly every second line of text in the app. Lightened to the
same hue at 5.78:1; the original stays as `--line-muted` for the ticks and
rules it was meant for.

Keyboard screen, which had grown to eight cards in one column — two of them
visible at the default window size, with the key map starting around 800px
down:

* Sub-tabs in the head (Lighting / Keys / Display / System), the last two
  gated on what the board actually has, falling back if the open tab
  disappears with the device.
* The key map is its own card at the top of the tabs that can change it.
  It is the only picture of what is lit, and it used to be four cards below
  the chips that change it.
* The "Scenes" effect chip is gone. It set `kind: "scene"` without choosing
  one, silently restarting whichever scene was picked last; the card below
  is where a scene gets chosen. The EFFECTS entry stays — the colour picker
  and speed slider read `colors` and `speed` from it.
* With the master switch off the cards the backend ignores are now inert
  and say so, instead of showing active states for controls that reach
  nothing.
* Origin badges replace three prose hints: live from Inari, stored in the
  board, or sent to the board and volatile.
* The transport comparison and the raw command probe fold into `<details>`.
  They are diagnostics, and open by default they cost ~350px of the column.
* Scene tiles carry their own palette as a tint and a 12px band instead of
  a 4px strip over neutral grey, with the running scene marked on the tile.

Both equalizers:

* The hardware EQ's ten faders showed no value at all. They now carry their
  dB reading, a 0dB line with ±5 references, and an axis — the EQ that
  actually lives in the headset was neither readable nor reproducible.
* The software curve spanned ±24dB although the loudest bundled preset
  peaks below 9.2, so a +6dB shelf drew 25px tall. The axis starts at ±12
  and opens up only for curves that need it.
* Each band draws its own response under the sum, so scrolling a dot to
  change Q is visible, and a label follows the pointer while dragging.

And the rest:

* `.hs-body` never matched `.screen-head`'s centring, so on a 1280px window
  the title started 242px in and the first card at 20px. Device screens get
  their own column width — 720px is a form measure, and these screens carry
  pictures of hardware.
* Battery rows render a placeholder instead of nothing, so the card no
  longer grows after the first status frame. "Charging" gets its own tag
  and a striped bar rather than the same grey as "Offline".
* Status values are tinted by state instead of setting "Unpaired" and
  "Muted" in the same weight as "Paired" and "Live".
* The mixer's drag grip keeps a resting visibility. That strips can be
  reordered is something you have to be told; deleting one is not.
* Channel/mix colour coding and the danger and online tints were raw hex in
  the rules, so the theme picker moved the accent while the strips stayed
  violet. All of it is tokens in both theme blocks now.
* Preset lists get a filter that also matches descriptions, and the
  descriptions show as a second line rather than only in a tooltip. The
  hardware EQ's categories become a visible row of switches with grouped
  headings instead of a dropdown in the card header.
* Type scale: `--fs-h2` to 24, `--fs-title` folded into `--fs-body`, and
  `--fs-micro`/`--fs-nano` for the fifteen rules that had been picking
  their own 9-11px.
Three sets of near-duplicates that had drifted:

* `.card` and `.hs-card` both wrap the same `.row` but set different
  padding and a different radius, so the same row began 22px from the card
  edge in Settings and 30px on a device screen, with corners 2px apart.
  The shared look is declared once; `.hs-card` keeps only what genuinely
  differs, which is that it lays its children out with a gap instead of
  relying on `.row + .row`. Six inline `padding: var(--sp-2)` styles that
  repeated what `.card` already sets are gone with it — they would have
  overridden the shared rule from the outside.
* `.media-seek-fill` and `.media-seek-cap` were byte-for-byte copies of
  `.hs-fill` and `.hs-cap`, and `.media-seek-track` differed from
  `.hs-track` in one declaration. Merged, with `flex: 1` kept as the one
  real difference: the headset's tracks share a row with a label and a
  value, the seek bar owns its line.
Setting a shelf band to a steep slope put both poles exactly on the unit
circle. The band then never stopped ringing: a measured 77 Hz tone for a
100 Hz corner, at an amplitude above full scale, continuing after the audio
stopped and lasting until the setting was changed.

The cookbook's `alpha` carries `sqrt((A + 1/A)(1/S - 1) + 2)`. That radicand
goes negative once `A + 1/A > S/(S - 1)`, and the code clamped it at zero to
avoid a NaN. That avoids the NaN and produces `alpha == 0`, at which point
`a0 = ap1 + am1*cos - 0` and `a2 = ap1 + am1*cos + 0` are the same
expression: after normalisation a2 is exactly 1.0, so the poles multiply to
exactly 1. The zeros do not cancel them.

This is not an extreme corner. At slope 10 it starts at ±8.1 dB of shelf
gain, at slope 5 at ±12 — an ordinary steep bass boost, and cuts as well as
boosts, since A and 1/A are symmetric. The UI offers slope up to 10 and gain
up to ±24.

Clamp the slope to what the gain can carry instead, leaving the radicand
alone, and mirror the same limit in the curve the UI draws so what is shown
stays what is heard.

Two tests, both of which fail on the old code:

* Every (kind, gain, Q, rate, frequency) the UI permits must keep its poles
  inside the unit circle. The bar is `|pole| < 1`, not a comfortable margin:
  a narrow peaking band legitimately reaches 0.99999 and that is what such a
  filter is. On the old code this reports a radius of exactly 1.
* A shelf on the settings that were measured to ring must fall silent after
  its input does. On the old code the output still sits at 7.08 — seven
  times full scale — five seconds into the silence.
The strips were a cache that was filled once, at startup, and never again.
`adopt_live_channel_state` exists precisely for this and its own doc comment
describes the symptom — "a channel restored to 0% used to show 100% in the
UI and be silent with no visible reason" — but it was only ever called from
`init_virtual_devices`. Anything that changed a volume outside Inari left
the fader showing a number the sink had stopped using, and the same one-shot
lost the reading entirely when the first Props event had not arrived yet at
init, with no second try.

Measured on this machine while writing this: PipeWire had sink_system at
0.0 and sink_music at 0.6585 (87%), while the stored profile carried 100 and
unmuted for both. One channel was silent behind a fader reading 100%.

`get_virtual_devices` now refreshes from the sinks before answering — an
in-memory read of what the loop thread already mirrors, no server traffic —
and the Props listener announces volume and mute changes the way the info
handler beside it already announces its own fields, so the UI is pushed as
well as pulled.

Three variants of the same mistake in the frontend, each with a regression
test that fails without its fix:

* `set_mic_config` writes the whole struct, and the payload was frozen when
  the call was made rather than when the debounce fired. Pressing the
  mic-mute hotkey during a gain drag reopened the microphone while the UI,
  the tray and the status label all still said "Muted". Built at send time
  from the current store now.
* `state-changed` fires for a hotkey, the tray, the CLI, a tablet, even
  showing the window, and each one refetches the channels. During the 90 ms
  debounce the backend still holds the old volume, so the refetch handed the
  fader its old position back while the write went out with the new one —
  sink and strip diverged permanently, because nothing refetches again.
  Channels with a write in flight now keep the user's value.
* A `media_status` read already in flight when the user taps pause carries
  the state from before the tap, and the backend shells out to playerctl so
  it is easily the slower of the two. The button flipped straight back.
  Optimistic edits bump a counter that stale reads check.

And the headset screen's mix and EQ seeds are one-shot by design but were
never re-armed, so the next base station kept the previous one's values on
screen while its own sat unread in the status.
`drain` is a `swap(0)`, so reading a peak destroys it. Three loops read the
same store — the keyboard's audio-reactive lighting every 33 ms, the OLED's
VU mode every 40, the window's level emitter every 100. Whichever ticked
first took the peak; the others got whatever had accumulated since. With a
lit keyboard and the OLED in VU mode, the meters in the window were showing
the leftovers of the other two.

Each consumer now has its own peak array and asks for it by name. `raise`
writes all of them, registered or not: taking only the registered ones would
make the result depend on whether a consumer happened to tick before the
audio started. It stays lock-free and fit for the realtime callback — a
handful of relaxed compare-exchanges on cache lines the thread just touched.

Two more from the same corner:

* `MAX_METERS` was 12, but the UI allows 10 channels plus four buses plus the
  master, and the channel and bus sinks claim their slots before the
  microphone chain is built. A full configuration made the *microphone* fail
  with "meter budget exhausted", which is nowhere near where the limit is.
  It now derives from `MAX_CHANNELS + MAX_BUSES`, with a const assertion so
  raising either limit breaks the build instead of the microphone.
* Turning the microphone chain off never released its meter slot, leaving a
  permanently silent "MIC" bar in the OLED's VU mode — which divides the
  panel by the number of registered names, so it also made every real
  channel shorter.

And `DestroySink` only cleared five of the nine maps keyed by node name.
Node names are derived from the label and deterministic, so deleting "Game"
and creating it again produces `sink_game` a second time and inherits
whatever was left: the failover-off flag survived that way, so a fresh
channel came up with automatic fallback disabled while the UI, which had
correctly forgotten the setting, showed it as on. All nine now go through
one `forget_channel`, so the next one added has a single place to be added
to.
Global ids are recycled hard — measured on this machine, the highest live id
is 242 while `object.serial` is at 183684, so the id space has wrapped
hundreds of times this session. Anything that holds an id across a delay can
act on whatever inherited the number.

Two places did:

* `set_app_volume` is debounced by 90 ms, and browsers create and destroy a
  stream per media element. Drag an app's fader while its stream ends and
  the write lands on the new owner of that id. The UI now sends the serial
  it was looking at, and the command resolves it to the current index
  immediately before acting; a stream that has genuinely ended is an error
  rather than a write to a stranger. `route_app_to_channel` the same.
* The auto-routing ledger was keyed by index, and its "forget streams that
  have gone away" pass cannot tell a recycled id from a surviving one.
  Discord closing and Spotify opening onto the same id between two polls
  made Spotify look already-handled, and it never reached its channel for
  the rest of its life. The window is not small: with the window minimised
  the poll stops entirely.

`StreamKey` carries the serial where there is one and falls back to the
index for the pactl backend, which does not expose serials.

Underneath both, the loop thread now refuses `SetNodeVolumeById` for a node
that is not an application stream. If an id has been recycled onto a sink or
a device, writing an app's fader value onto the headset's output node is
exactly the failure worth refusing.
**The restart still died, by a different route.** 1.0.11 fixed the case where
we run as our own unit and systemd killed the helper in the cgroup sweep.
Launched from the application menu, KDE wraps us in a transient
`app-<id>@<hash>.service` with `KillMode=control-group` — verified here for
Dolphin, kitty and Chrome — and `running_as_unit()` deliberately returns
false for a launcher scope, so the `setsid` branch ran. `setsid` leaves the
session but stays in the cgroup, so the relauncher was swept a second after
being spawned and the app never came back. It now goes through
`systemd-run --user --collect`, which puts it in a transient unit of its
own; `setsid` and a plain shell remain as fallbacks for systems without
systemd, where nothing sweeps a cgroup in the first place.

**The download directory was predictable and shared.** `/tmp/inari-update`
is a fixed name, `/tmp` is world-writable with the sticky bit, and the
service runs without `PrivateTmp`. Another local user creating that
directory first turns `create_dir_all` into a no-op and the `set_permissions`
that follows into a swallowed `let _ =` — leaving the `.deb` somewhere they
control between the SHA256 check and `pkexec apt-get`, where swapping it
installs their package as root. The directory is now created exclusively
with `create_dir`, under a name carrying the pid and a clock reading, and
failing to make the package readable is an error rather than something to
shrug at.

**The tray could not un-minimise the window.** `show()` is a no-op for a
window that is visible but minimised, and `set_focus()` does not restore one
under KWin. The single-instance handler had `unminimize()` and the tray's
"Show Window" did not, so clicking it did nothing at all. Both go through
one `reveal_window` now.
Three variants of the same omission — the listener, the permit, the
connection — plus the contract test that should have caught the fourth.

* `stop()` signalled and returned; the listener closed on another thread.
  Every caller — regenerating the token, changing the bind address, the
  enable toggle — binds the same port again immediately afterwards, so
  whether that worked was a thread race. It almost always won on an idle
  machine and lost 25-70% of the time under load, which is exactly when
  someone reaches for the tablet. Losing meant "Address already in use", a
  dead server, and the remote switched off behind the user's back. `stop`
  now joins the server thread with a 2 s ceiling.
* The in-flight permit was held across `replies.send()`. It is meant to
  bound how many handlers *run*; holding it over the send made it bound how
  many wait for one client's TCP back-pressure. There are eight for all
  clients together, so a tablet that keeps sending and stops reading filled
  its 32-slot reply channel, parked all eight handlers, and every other
  client stopped getting answers. The permit is released before the send,
  and a client that will not drain its replies is now dropped rather than
  waited on.
* The idle deadline was only checked in `poll_read`. A connection stuck in a
  write is precisely one where `poll_read` is never polled, so it outlived
  the 90 s timeout indefinitely against a peer that acknowledges without
  reading — holding one of the 64 slots, keeping `Clients::leave` from ever
  running, and keeping the level and graph emitters pushing at 10 Hz into a
  window nobody was looking at.

`is_read` guesses from the name prefix, and the media reads do not fit the
pattern, so a tablet sitting on the media screen — polling once a second —
made the desktop and every other tablet re-read channels and microphone
every second for nothing. They are named explicitly now, along with the
three `headset_get_*` reads. `headset_oled_status` stays a write on purpose.

And the contract test only saw `call("literal")`, so everything routed
through a helper was invisible to it — which is the normal shape in the
mixer store, where all five write commands go through `debouncedInvoke`.
The proof that the net had holes is in the test itself: three of the names
in its exemption list were not real commands, and nothing noticed, because
the scanner never produced them either. It now reads the `Command` union,
which `ipc_contract` already pins to `generate_handler!`, so indirection
cannot hide anything from it — and it immediately found a real gap,
`headset_oled_media`, which is correctly hidden behind `isTauri` in the UI
but had never been written down as deliberate.
…rine

**Deleting a channel committed before it could fail.** `remove_channel` took
the definition out of `channel_defs`, then evacuated the streams, then
destroyed the sink — and returned on failure without putting anything back.
The two halves of the mixer then disagreed with immediate consequences and
no file written: the strip stayed on screen while `teardown_virtual_sinks`,
which iterates `channel_defs`, never destroyed the sink at exit; the output
and failover rows vanished from that strip; monitoring refused it. The next
unrelated save made it permanent while assignments.json and the WirePlumber
fragment still pointed apps at a channel that no longer existed. Validation
is now a read (`can_remove`), and both halves are committed together once
the parts that can fail have succeeded — the same shape `add_channel` has
had all along.

**A new channel could come up silent.** Channel names are derived from the
label, so recreating one you deleted produces the same node name, and
WirePlumber remembers levels per node name and never forgets them — this
machine still has `sink_system={"channelVolumes":[0.0]}` stored for a
channel that no longer exists. Its restore lands after our write, so the
strip claimed 100% over a silent sink. `add_channel` now reads back what the
sink ended up at, insists once if something moved it, and shows what it
actually got.

**Two existence checks asked the parser instead of the filesystem.**
`create_blank_profile` used `load(..).is_ok()`, which answers "is there a
*readable* profile" — so a profile that had become unparseable read as
absent and was silently overwritten, while remaining visible in the list the
whole time because `list` works off filenames. Saving an EQ preset had the
same shape and now refuses to replace an existing one, which is what "save a
copy as a new preset" meant anyway.

**Three stores ignored the project's own schema doctrine.** json.rs requires
a `version` and a `#[serde(flatten)] extra` on every store so an older Inari
can load a newer file and save it back without eating what it did not
understand. Eight stores comply; `Channels`, `ChannelDef` and `Profile` did
not — and those hold the channel set itself.

**Autosave writes could land out of order.** The snapshot is taken under the
mixer guard and written outside it, which is deliberate (an fsync under the
guard stalls every other command, TD-004) but removed the only thing making
writes happen in snapshot order. With a second thread in play — the remote's
runtime, the hotkey callback, the CLI's D-Bus thread — the slower writer
wins the `rename` and puts an older whole-profile snapshot on top of a newer
one. Snapshots now carry a sequence handed out under the guard, and the
writer holds a lock of its own and drops anything older than what is already
on disk.

**And the stream-mix migration was dead code.** It wrote the old
per-channel `stream_mix` flags into the *master* mix, which carries every
channel by definition and gets its membership rewritten by `sync_master` at
init. A user who had deliberately kept music out of the recorded stream
silently got it back on the first start after upgrading. It becomes a mix of
its own now — which is what the flag meant, and which `sync_master` leaves
alone.
Every claim here was checked against the code as it now stands, and the
entries say plainly what the old behaviour was — the point of a
troubleshooting page is to be findable by someone still running the version
that has the bug.

* **eq** — shelf slope is capped by the gain, and why. The dB axis is now
  adaptive and each band draws its own curve.
* **mixer** — volume and mute follow the sinks continuously rather than
  being read once at startup, with the deliberate exception that a newly
  created channel is put back to 100% rather than inheriting the level
  WirePlumber remembers for a channel of the same name that used to exist.
* **mixes** — the pre-mixes `stream_mix` flag now migrates into a mix of its
  own instead of into the Master Mix, which was overwritten immediately.
* **headset** — the hardware EQ faders start from the station's own curve
  and show their values; the gain control shows the real state; the line-out
  section is the stream mix, including the microphone share.
* **keyboard** — the screen's four sections and why the key map sits above
  the controls that change it.
* **configuration** — every store now carries a schema version and a
  catch-all, and atomic writes are not ordered writes: the autosave sequence
  is explained.
* **remote** — regenerating a token no longer races itself for the port, and
  one wedged client can no longer starve the others.
* **troubleshooting** — two new entries (a shelf band that rings for ever;
  meters under-reading while the keyboard or OLED is lit), and the
  "didn't restart after update" entry now covers all three causes, since it
  has been fixed three times for three different reasons.
* **protocols** — the `0x20` audio-settings frame documents the fields
  Inari was ignoring.
…cons

**The channel refresh was in the wrong place.** The previous commit taught
`get_virtual_devices` to read the sinks before answering, which fixed the
window and nothing else: `mixer.channels` is also read by the tray's mute
rows and by `inari status`, and neither goes through that command. Whether
the tray told the truth depended on whether the window happened to be open.
Caught by installing the build and comparing `inari status` against
`pw-dump` — the CLI still reported 100% for a sink sitting at 0%. The read
now lives in one `AppState::refresh_channel_state` that all three call.

**Symlinked icons never loaded.** `/usr/share/pixmaps` is mostly links into
each application's own directory, and the asset protocol matches its allowed
scope against the *canonical* path — so the scope was checked against a path
the caller never handed it. Chromium's icon worked because its link points
back into `/usr/share/icons`, which was in scope; Discord's points at
`/usr/share/discord/discord.png`, which was not, and the icon silently
failed with one line in the log.

Two halves to that: icon lookup now canonicalises before handing the path
over, so the path we pass and the path that gets checked are the same thing,
and the scope covers where those links actually point. The scope is
correspondingly narrower in the dimension that matters — it was any file
under a handful of icon directories, and is now only `.png`, `.svg` and
`.xpm` under the share trees.

`**` matching direct children was checked against the glob crate rather than
assumed; that was my first theory for this bug and it was wrong.
`inari status` showed "Microphone  sink_mic  94%", but 94% is the gain of
Inari's microphone chain, not that node's volume — `sink_mic` was sitting at
8% at the same moment. Printing the node name beside it invited exactly the
comparison against `pw-dump` that it fails, which is how I noticed. The
number was always right; the column next to it was not.
The shelf-stability test swept gain per dB and Q per 0.1, which is 9,800
configurations and six seconds of curve evaluation. That fit inside vitest's
5 s default on this machine and did not on a CI runner, so it timed out.

The unstable region is an area, not a knife edge — every combination past
the boundary fails, not just the ones next to it — so a 3 dB / 0.5 Q grid
still crosses it many times over. The combinations that were actually
measured to ring are now asserted explicitly rather than left to the grid to
happen to land on.

Verified the faster test still fails on the unfixed code: reverting the
slope clamp puts it at 69.8 dB where the bound is 60. 6025 ms to 47 ms.
@fbnlrz
fbnlrz merged commit dc10670 into main Aug 1, 2026
6 checks passed
@fbnlrz
fbnlrz deleted the dev/1.0.13 branch August 1, 2026 21:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant