Extended key support, OS notifications, and six silent bugs fixed
Today had two distinct acts: shipping OS desktop notifications (Epic 16) and overhauling the keyboard key dispatch layer (Epic 17). The second one started as "add F13–F24 and media keys" and quickly turned into an audit that uncovered six categories of silent failure. Keys that profiles happily accepted but that never actually fired.
OS desktop notifications (Epic 16)
mapxr now sends native OS notifications for device connect/disconnect, layer switches, and profile switches. Each event type is independently toggleable in Settings, so users who switch layers constantly via combos don't have to live with a notification storm.
A few design decisions worth noting. Notifications are best-effort. OS errors are logged at warn! and never propagated to the UI. The layer-switch toggle defaults to off for this reason. Profile switches default to on since they're infrequent and
represent a meaningful mode change the user almost certainly wants to know about.
Device notifications now include the human-readable device name and role in the body, e.g. "TapXR_A0363 (Right) connected". Getting the name into the notification required
fetching it from TapDevice before the map entry is removed at disconnect time. This
was a subtle ordering issue that had caused the name to show as empty in an earlier attempt.
On the same day: save_profile gained a hot-reload path. If the profile being saved
is currently active in the engine's layer stack, the engine's set_profile is called
immediately and a layer-changed event fires. No need to deactivate and reactivate
to pick up edits.
The key audit (Epic 17)
The work started with an enigo 0.2 source audit: what named keys does it actually expose, on which platforms, and how do those map to mapxr's key name strings? The answer was uncomfortable.
The key dispatch path has two moving parts that need to stay in sync: VALID_KEYS in mapping-core (the list profiles are validated against at load time) and name_to_key in pump.rs (the function that converts a name string to an enigo::Key at fire time). They had drifted badly.
Bug 1: All arrow keys silently broken
VALID_KEYS uses "left_arrow", "right_arrow", "up_arrow", "down_arrow". name_to_key was matching "left", "right", "up", "down". Every arrow
key in every profile was firing nothing and logging a warning. Since most profiles use ctrl+c-style shortcuts rather than arrows this went unnoticed.
Bug 2: All function keys silently broken
VALID_KEYS stores lowercase "f1"–"f12". name_to_key was matching uppercase "F1"–"F12". Zero
function keys worked. The spec examples use "f13" and "f15" extensively
for virtual-button tricks. All of those were silently no-ops.
Bugs 3–6: F13–F24, punctuation, system keys, and media keys
Even if the case had been right, F13–F24 had no arms at all. The same was true for every
punctuation key (grave, minus, left_bracket, etc.),
all locking/system keys (caps_lock, insert, num_lock, print_screen, scroll_lock), and all media/volume keys
(media_play, volume_up, etc.). All were in VALID_KEYS,
all silently no-oped.
The fix and new additions
name_to_key was rewritten from scratch. The new version is around 130 lines of
straightforward match arms organised by group. Platform-specific keys use #[cfg(...)] directly on match arms. On unsupported platforms those arms simply
aren't compiled, so the name falls through to the catch-all other arm which logs a
warning and returns None. No runtime platform detection needed.
Five new keys were also added to the canonical list: media_stop, pause, brightness_down and brightness_up (macOS only), eject (macOS), and mic_mute (Linux). Platform availability is
documented in a new docs/spec/extended-keys-spec.md.
The platform matrix in brief:
- Cross-platform: a–z, 0–9, F1–F20, all punctuation, all navigation (arrows, home/end, page up/down, backspace, delete, etc.), caps lock, media play/next/prev, volume up/down/mute
- Windows + Linux, not macOS: insert, num lock, print screen, pause, media stop, F21–F24, scroll lock (Linux only)
- macOS only: brightness down/up, eject
- Linux only: mic mute, scroll lock
Key picker UI
The key picker in the action editor was a plain text input with a <datalist> autocomplete. Fine for a small list, but impractical with 100+ keys and no way to convey which
keys are platform-limited. It's now a <select> with four <optgroup> sections: Standard, Navigation, Function, and Media / System.
Platform-limited keys show a note in parentheses (e.g. f24 (Windows / Linux)) so users
know before they bind.
The key name data lives in a KEY_GROUPS constant in types.ts. The
flat KNOWN_KEY_NAMES array that other parts of the codebase use is now derived from
it via flatMap, so there's a single source of truth.
Haptic feedback (Epic 18)
mapxr can now send vibration patterns to connected Tap devices. There are two surfaces: a vibrate action type in the profile editor (bind any tap code to a custom on/off
sequence), and automatic event-driven haptics for tap confirmation, layer switches, and profile
switches. Each is independently toggleable in Settings.
The BLE protocol is straightforward. The haptic characteristic (C3FF0009) accepts a
payload of alternating on/off durations encoded as duration_ms / 10 per byte.
Durations are clamped to [10, 2550] ms with 10 ms resolution, and sequences longer than 18
elements are truncated before sending.
Shipping it surfaced two bugs, one predictable and one not.
Bug 1: Context monitor firing haptics on every browser tab switch
After connecting a device and triggering a layer shift, the device would vibrate again and again
without any user input. The culprit was the Wayland focus monitor: it calls publish_focused() on every Done event from any toplevel, which includes
window title changes of the already-focused window (browser tabs, document titles, anything).
The context monitor had no guard against re-applying a profile that was already active, so every
title change on a matching window re-fired the profile-switch haptic. The fix was a one-line
idempotency check: skip the activation if last_active_profile_id already matches the
rule's target.
Bug 2: Every vibrate action produced a shower of phantom buzzes
Even with all event-driven haptics disabled, firing a vibrate action with pattern [1000, 100, 200] would produce the correct long-then-short buzz followed by three to
five additional random-length vibrations. The first suspects (duplicate BLE notifications,
double-tap buffering, the context monitor) were all ruled out by adding diagnostic logging that
confirmed a single BLE notification and a single software dispatch per physical tap.
The real cause was in VibrationPattern::encode(). Our implementation sent only as
many bytes as the pattern contained. Five bytes for a three-element pattern. The Tap device
firmware, it turns out, requires exactly 20 bytes (2-byte header + 18 duration slots). When the
payload is short, the firmware reads the remainder from uninitialised RAM and plays whatever
garbage values it finds as additional durations. The fix came from comparing against the C# SDK,
which always zero-initialises a 20-byte buffer before filling in the pattern. We now do the same: encode() always returns a fixed 20-byte payload with unused slots explicitly zeroed.