Skip to content

Latest commit

 

History

History
4102 lines (3619 loc) · 255 KB

AGENTS.md

File metadata and controls

4102 lines (3619 loc) · 255 KB

AGENTS.md

Operational guide for AI coding agents working on LinuxStreamDeck. Read this before making changes. It is the single source of truth for how the project is built, how it is structured, and the non-obvious rules you must respect. (CLAUDE.md builds on this file with Claude-specific notes.)


1. What this project is

LinuxStreamDeck is a GTK4 / Libadwaita desktop app that turns an Elgato Stream Deck into a control surface on Linux, built around deep OBS Studio integration (obs-websocket v5). It also works as a virtual deck on screen, so it is fully usable and testable without the physical hardware connected.

  • Language / UI: Python ≥ 3.10, PyGObject (GTK 4 + Libadwaita/Adw).
  • Key dependencies: streamdeck (python-elgato-streamdeck, HID), pillow (key image composition), obsws-python (obs-websocket v5 client), GStreamer 1.0 playbin (local audio playback).
  • Target devices: every Stream Deck with key displays — Mini (3x2), Neo (4x2), Original/MK.2 (5x3, 0fd9:006d), Stream Deck + (4x2 keys, 4 dials and an 800x100 LCD strip) and XL (8x4). The Pedal has no displays and is refused. Geometry comes from key_layout() at connection time, never from a constant: GRID_COLS in ui/window.py and GRID_COLUMNS in the renderers are only the pre-connection default. Only the 15-key Original is tested on real hardware; the rest are verified in simulation (tests/test_multi_deck.py).
  • License: GPL-3.0-or-later. Author: JavocSoft.
  • Repo: github.com/javocsoft/linuxstreamdeck

2. Build, run, verify

All commands run from the project root. The virtual environment lives in .venv and is created with --system-site-packages so it can import the system GTK / PyGObject.

./build.sh              # check agent adapters; create .venv, install package + deps, compile-check
./build.sh --apt        # same, and install the system packages first (needs sudo)
sudo ./install-udev.sh  # one-time USB permissions for the Stream Deck
./run.sh                # launch the app
LSD_DEBUG=1 ./run.sh    # launch with debug logging

System packages required: gir1.2-gtk-4.0 gir1.2-adw-1 gir1.2-secret-1 gir1.2-gstreamer-1.0 gstreamer1.0-plugins-base gstreamer1.0-plugins-good gnome-keyring libhidapi-libusb0 python3-gi python3-gi-cairo.

Compile check (use after any code change):

.venv/bin/python -m compileall -q linuxstreamdeck

The automated unittest suite lives under tests/. Run it with an isolated configuration directory so no test can reach the user's real configuration:

TEST_CONFIG_DIR="$(mktemp -d)"
LSD_CONFIG_DIR="$TEST_CONFIG_DIR" .venv/bin/python -m unittest discover -s tests -v

tests/test_invariants.py is not a feature suite: it makes the silent rules of §5 fail loudly (BASIC font layout, RENDER_LOCK, the OBS request lock and the shutdown order). Treat a failure there as a real regression, never as a test that needs relaxing.

Also verify behaviour with targeted isolated scripts when appropriate (see §6) and, for rendering, by composing key PNGs offscreen. Do not rely on launching the GUI to "see" a change (see §5).

Debian package: ./packaging/build-deb.sh [X.Y.Z] produces dist/linux-stream-deck-<version>.deb. It is Architecture: all: the pure-Python app plus the two pip-only deps (StreamDeck, obsws_python) are vendored under /usr/lib/linuxstreamdeck/_vendor, while GTK4/Adw (PyGObject), Secret Service, GNOME Keyring, GStreamer plus its base/good plugins, Pillow, hidapi, websocket-client and ca-certificates come from apt Depends. ydotool (key injection), playerctl (media transport), pulseaudio-utils (per-application volume and the default audio device) and avahi-utils (finding Key Lights) are Recommends, so apt installs them by default while the package still installs where they are unavailable and the app degrades to a status message. fonts-noto-cjk is only a Suggests: it supplies the katakana of the Matrix Code screen saver, which falls back to Latin without it, and 91 MB of fonts is far too much to pull in by default for one animation. AI provider HTTPS calls and the local audio wrapper use the Python standard library / system GI bindings, so there is no additional pip dependency. The launcher /usr/bin/linuxstreamdeck runs the system python3 with those paths on sys.path. It also installs a desktop entry, the app icon, an AppStream metainfo (so installed software catalogues can discover its icon, description and screenshot), and the udev rule (reloaded by the postinst). The maintainer scripts also refresh the desktop entry and hicolor icon caches after install, upgrade and removal. Version defaults to pyproject.toml; the build syncs it into both pyproject.toml and linuxstreamdeck/__init__.py::VERSION, so passing X.Y.Z also bumps those sources.

Every package format keeps the shared desktop entry's hicolor theme name: Icon=com.javocsoft.LinuxStreamDeck. The Debian build asserts that exact value and installs com.javocsoft.LinuxStreamDeck.svg below /usr/share/icons/hicolor/scalable/apps/; postinst then runs update-desktop-database and gtk-update-icon-cache when available. This is load-bearing on COSMIC: its launcher and dock take different icon-loading paths, and an absolute SVG path can work in the launcher while the dock silently falls back to a generic icon. Do not rewrite the staged Debian desktop entry to an absolute icon path. packaging/refresh-appstream.sh refreshes separate software-catalogue metadata; it is not a substitute for the desktop/icon cache refresh in the maintainer scripts.

Everything else derives that one value: ui/about.py reads VERSION, __main__.py answers --version / -V before GTK parses argv (an unknown option would otherwise be rejected, and the bug report template tells people to run it), and the AppStream metainfo ships @VERSION@ / @DATE@ placeholders that the build substitutes. Keep that release entry templated: a hardcoded one would go stale on the next build without the scan below ever noticing, because the scan only recognises a version that is wrong, never one that is missing.

Before that sync, check_hardcoded_versions() scans every tracked file and fails the build when one still names a different version of this application — the way an issue template or a document silently goes stale after a release. Dependency constraints (streamdeck>=0.9.5) and lines marked version-check: ignore are exempt, and the two version sources are skipped because sync_version owns them. It deliberately runs before any file is rewritten, so a failed build leaves the working tree untouched. Keep documentation examples as literal X.Y.Z rather than a plausible number, or they trip the scan. After installing or upgrading the package, run sudo ./packaging/refresh-appstream.sh to refresh the system AppStream cache for software centres, then reopen the software centre.

Release version checklist: a version bump is more than editing one line, and it must follow this order so generated content cannot retain the previous release:

  1. Change both authoritative source values: pyproject.toml::version and linuxstreamdeck/__init__.py::VERSION. They must agree before importing the package; the landing generator reads VERSION while AppImage and Flatpak read pyproject.toml.
  2. Regenerate the landing with an isolated configuration: LSD_CONFIG_DIR="$(mktemp -d)" .venv/bin/python landing/generate.py. This stamps the version into landing/assets/actions.json, landing/index.html and landing/assets/img/og-card.png; never edit those generated values by hand.
  3. Run the compile check and the full isolated test suite from §2. Then run node landing/check.mjs, python3 agent-definitions/sync.py --check and git diff --check.
  4. Build ./packaging/build-deb.sh X.Y.Z, ./packaging/build-appimage.sh and ./packaging/build-flatpak.sh --bundle. The Flatpak script installs its result for the current user as part of its workflow; remove that temporary installation afterwards when the workstation is meant to keep only the Debian package.
  5. Verify the version inside all three final artefacts, not only their filenames. Also check that the release's new modules and package data are present, and calculate checksums only after the last rebuild.

The bug-report template is deliberately not a version source. Its placeholder: "X.Y.Z" asks reporters for the version they actually have installed, which may be older than the latest release; replacing it with the current version would be misleading and create release-by-release maintenance. tests/test_version.py pins that generic placeholder. The AppStream source is also not bumped by hand: its @VERSION@ and @DATE@ placeholders are filled by each package build.

Flatpak: ./packaging/build-flatpak.sh [--bundle] [--clean], from packaging/flatpak/com.javocsoft.LinuxStreamDeck.yml. It sits on org.gnome.Platform, which supplies GTK4, libadwaita, PyGObject, GStreamer and libsecret, so the only things built are libusb, hidapi and three pip packages. runtime-version must stay on a runtime GNOME still supports — 48 reached end of life in March 2026, and flatpak install says so on every run rather than failing, which is exactly how a manifest quietly stops getting security updates.

Two things about it are load-bearing:

  • hidapi must be built with the libusb backend, and therefore libusb must be built first. StreamDeck/Transport/LibUSBHIDAPI.py searches Linux for exactly ["libhidapi-libusb.so", "libhidapi-libusb.so.0"] and never falls back to the hidraw one. A hidraw-only build is smaller, configures cleanly and produces a Flatpak that starts perfectly and never finds a deck. The GNOME SDK has no libusb, so leaving it out fails the build outright — which is the good failure; the silent one is worse.
  • --talk-name=org.freedesktop.Flatpak is a deliberate hole. See core/host.py: system integrations run host programs, and an application that can spawn host processes is not meaningfully confined. Removing that line leaves a confined Flatpak in which those keys report what is missing, exactly as on a machine without the tools, and everything network-based is unaffected.

AppImage: ./packaging/build-appimage.sh, built in a container from packaging/appimage/Dockerfile. The base is Ubuntu 24.04 and that is not a preference: the UI uses Adw.Dialog and Adw.ToolbarView, which need libadwaita 1.5, and 24.04 is the oldest Ubuntu carrying it. An AppImage cannot run on an older glibc than it was built against, so the base sets the floor at glibc 2.39 — Fedora 40+, Ubuntu 24.04+, Arch and Tumbleweed work; Debian 12, Ubuntu 22.04 and RHEL 9 do not and want the Flatpak. Do not "fix" that by moving to an older base without first checking every Adw.* the UI uses.

Neither format can install the udev rule or the optional host tools, so both scripts print those two reminders when they finish. Never drop them: without the rule the deck is simply never opened.


3. Architecture

Composition root is app.py::LinuxStreamDeckApp, which wires everything together and wraps a single-instance Adw.Application. Components communicate through a thread-safe pub/sub EventBus; UI never talks to the device or OBS directly. LinuxStreamDeckApp also owns shutdown, in a fixed order: status icon, all controller workers, the bounded Arcade feedback-sound player, automatic Twitch-category monitor, HID fleet, OBS client. Controller workers must stop before the HID fleet so no render is submitted to a closed device. The category monitor must stop before OBS and Twitch, whose state and requests it uses; every deck must stop before OBS so nothing is left waiting on a request that can no longer be answered. The EventSub session stops after the deck and before the Twitch client whose requests it makes, for the same reason the deck stops before OBS. _on_shutdown also sets _shutting_down before stopping the icon, so hides_on_close() cannot intercept a quit already in progress. It reads like tidy-uppable boilerplate, so it is pinned by ShutdownOrderTests in tests/test_invariants.py. DeckFleet.stop() joins its one discovery monitor and stops every endpoint; each DeckManager.stop() joins its screen-saver thread, applies that workspace's clean-exit display and finally closes HID, so no background work outlives application teardown.

linuxstreamdeck/
├── __main__.py        Entry point; logging setup; `linuxstreamdeck` console script.
├── app.py             LinuxStreamDeckApp: builds config, credential stores, AI service,
│                      EventBus, OBS/Twitch clients, automatic category monitor,
│                      fleet/device runtimes, MainWindow and status icon; lifecycle.
├── basic_actions.py   System actions (clocks, audio, counter, mixer) plus page navigation.
├── web_actions.py     One generic HTTP endpoint action, with an optional value on the key.
├── light_actions.py   Elgato Key Light power, brightness and colour temperature.
├── steam_actions.py   Installed/recent game launch and Steam navigation actions.
├── system_stats.py    A live machine measurement on a key: CPU, RAM, GPU, temps, network.
├── ha_actions.py      Home Assistant: switch or run an entity, and show what it reports.
├── ai/
│   ├── constants.py   OpenAI/Claude provider ids, labels and default models.
│   └── service.py     Provider calls, bounded optional context, local proposal validation.
├── games/
│   ├── catalog.py     Stable IDs, names and user hints for all built-in games.
│   ├── common.py      Shared engine events, phases and adaptive lobby/layout model.
│   ├── mole_smash.py  Pure clock/RNG-injected reaction-game engine.
│   ├── circuit_breaker.py  Pure guaranteed-solvable Lights Out-style engine.
│   ├── pulse_memory.py Pure increasing sequence-recall engine.
│   ├── memory_match.py Pure pair-matching engine with odd-grid status slot.
│   ├── minesweeper.py Pure first-reveal-safe adaptive minefield engine.
│   ├── tic_tac_toe.py Pure compact/3x3 player-versus-AI engine.
│   ├── mastermind.py Pure adaptive colour-code and clue engine.
│   ├── neon_relay.py Pure endless edge-to-edge circuit-routing engine.
│   ├── neon_jackpot.py Pure three-reel fictional-credit slot engine.
│   ├── economy.py    Thread-safe persistent Deck Sparks wallet, unlocks and rescue.
│   ├── challenges.py One-time configuration/action/game Spark Challenges.
│   ├── manager.py     Shared exclusive input/display session, worker, persistence and restore.
│   ├── render.py      Snapshot dispatch plus Mole Smash key frames and Plus HUD.
│   ├── arcade_render.py Shared lock, insufficient-funds and paid START controls.
│   ├── arcade_audio.py Bounded player for refill and two-stage unlock cues.
│   ├── *_render.py    Key frames and Plus HUDs for the other eight games.
│   ├── rendering.py   Shared BASIC-font lobby and HUD rendering helpers.
│   └── audio.py       Bounded, cancellable workers for the bundled WAV effects.
├── core/
│   ├── events.py      EventBus + per-device ScopedEventBus; a `dispatcher`
│   │                  (GLib.idle_add) marshals callbacks to the UI thread.
│   ├── config.py      Data model + atomic user-only JSON persistence: Config →
│   │                  DeckWorkspace → Profile → Page → KeyConfig → Folder,
│   │                  plus global service/game settings. Legacy migration, backup,
│   │                  `.lsdconfig` and single-key `.lsdkey` I/O.
│   ├── actions.py     Action framework: `Action` base, declarative `Param`,
│   │                  `ActionContext`, global `REGISTRY`, `@register`, `by_category`.
│   ├── audio.py       Blocking local playback via GStreamer playbin; shutdown-aware.
│   ├── autostart.py   XDG autostart entry: read/write/remove, hidden-start flag.
│   ├── apps.py        Desktop applications: list, resolve, launch, find and close.
│   ├── steam.py       Local libraries/manifests/artwork, running AppID detection and launch.
│   ├── media.py       MPRIS transport (playerctl) and session volume (wpctl/pactl).
│   ├── mixer.py       Per-application volume, mic mute and the default device (pactl).
│   ├── soundboard.py  A null sink plus its monitor, so a sound key reaches the stream.
│   ├── nowplaying.py  What is playing over MPRIS, and its album art, off the render worker.
│   ├── keystrokes.py  Shortcut parsing, Linux presets, ydotool/wtype/xdotool.
│   ├── clocks.py      Thread-safe countdown/stopwatch runtime keyed by RuntimeKey.
│   ├── controller.py  DeckController: presses, action/render workers, running feedback,
│   │                  clocks/audio notifications, folder navigation, profile/key
│   │                  operations and imports.
│   ├── search.py      Key search across devices, profiles, pages and folders.
│   ├── references.py  Keys pointing at OBS objects that no longer exist.
│   ├── sysstats.py    Whole-machine readings from /proc and /sys: CPU, memory,
│   │                  network throughput, temperatures, GPU, free disk space.
│   ├── webrequest.py  HTTP calls, dotted-path value extraction, off-render cache.
│   ├── keylight.py    Elgato Key Lights: mDNS discovery, mired conversion, state cache.
│   ├── homeassistant.py  REST client: entity list, service calls, off-render state cache.
│   ├── starter.py     The keys a brand new configuration is offered.
│   ├── preflight.py   Checks worth running in the minute before going live.
│   ├── icons.py       Built-in icon library (Material Design Icons glyphs via
│   │                  Pillow, recolorable, cached). `RENDER_LOCK`.
│   └── secrets.py     Async Secret Service storage for OBS and per-provider API keys.
├── device/
│   ├── fleet.py       One HID enumerator, serial binding and reconnect coordinator.
│   ├── manager.py     One device endpoint: startup/saver, exit display and HID I/O.
│   ├── exit_display.py Validate/crop static full-deck images for clean shutdown.
│   ├── screensaver.py Pure-Pillow coordinated full-deck animation renderer.
│   ├── startup_animation.py 33-frame offscreen physical-deck wake/title sequence.
│   ├── touchscreen.py Stream Deck + LCD strip: one labelled panel per encoder.
│   ├── layout_sheet.py Whole profile as one printable, captioned PNG.
│   └── renderer.py    `compose()` — builds each configured key PNG with Pillow
│                      (bg, icon or centered value, sized label, badge, lighting,
│                      running halo).
├── obs/
│   ├── client.py      OBSClient: obs-websocket v5 connection, auto-reconnect,
│   │                  thread-safe requests, re-emits OBS events onto the bus.
│   └── actions.py     Catalogue of OBS actions (scenes, recording/streaming,
│                      audio, sources/filters, media, advanced + raw request).
├── twitch/
│   ├── constants.py   Client ID, endpoints, the scopes this application asks for.
│   ├── eventsub.py    One live WebSocket: chat, follows, subs, gifts, raids.
│   ├── events.py      Six payload shapes reduced to one Alert.
│   ├── attention.py   What is still waiting, per key, and when to make a noise.
│   ├── http.py        The one place this integration touches the network.
│   ├── auth.py        Device code flow, token refresh, validate and revoke.
│   ├── client.py      TwitchClient: Helix requests plus one cached channel
│   │                  snapshot that `feedback()` reads without ever blocking.
│   ├── auto_category.py Stable Steam detection, exact mappings, debounce and override policy.
│   └── actions.py     Viewers/followers/uptime on a key, title/category controls,
│                      automatic-category toggle, clip and stream marker.
├── ui/
│   ├── window.py      MainWindow: key grid (virtual deck), header (profiles,
│   │                  import/export, pages, deck display, brightness, OBS, About),
│   │                  folder breadcrumb and navigation, explicit saver activity,
│   │                  DnD, copy/paste, single-key export/import, unsaved guard,
│   │                  arrow-key grid navigation, OBS connection dot, status bar.
│   ├── editor.py      EditorPanel: key editor, canonical draft/baseline and key type.
│   ├── dials.py       Stream Deck + encoder row under the grid, and its editor.
│   ├── ai_assistant.py OpenAI/Claude proposal dialog and explicit editor handoff.
│   ├── steps.py       StepEditor / StepList / AppearanceBox — reused by the editor
│   │                  for single, multi and toggle key types.
│   ├── icon_picker.py Searchable grid to pick a library icon.
│   ├── action_picker.py Searchable list of every action, by name or by what it does.
│   ├── key_search.py  Find a key in any profile, page or folder, and go to it.
│   ├── tray.py       StatusNotifierItem + dbusmenu status icon (no GTK widgets).
│   ├── preferences.py Close behaviour and start-on-login settings dialog.
│   ├── about.py       About dialog: application identity, credits, license and source link.
│   ├── backups.py    Restore one of the automatic configuration backups.
│   ├── reference_check.py Report and repoint keys OBS no longer resolves.
│   ├── preflight.py   The pre-flight report in full sentences.
│   ├── obs_settings.py OBS connection dialog (host/port/password).
│   ├── twitch_settings.py Twitch account, automatic category and mapping dialogs.
│   ├── ha_settings.py Home Assistant dialog: address, token, and a check that both work.
│   ├── screensaver_settings.py Screen saver and clean-exit display settings.
│   ├── arcade_settings.py Wallet, refill options, unlocks and challenge progress.
│   └── profile_dialog.py New/edit profile dialog (name + description).
├── assets/icons/      MDI font (TTF) + icons.json index (bundled, Apache-2.0).
├── assets/arcade/     Original global Arcade refill/unlock WAVs and license.
└── assets/games/      One self-contained asset directory per built-in game ID.

EventBus topics

Topic Payload Meaning
deck.key deck_id, index:int, pressed:bool Physical key pressed/released.
deck.dial deck_id, index:int, direction:str, ticks:int Stream Deck + encoder turned (left/right) or pushed (press).
deck.touch deck_id, index:int, event:str LCD strip tapped, resolved to the dial under it.
deck.connected deck_id, model:str, keys:int, columns:int, dials:int Physical device ready after startup.
deck.disconnected deck_id Device removed.
deck.screensaver deck_id, active:bool, preview:bool, style:str Screen saver started/stopped; controller restores keys on stop.
deck.list_changed deck_id A workspace's name, binding or connection metadata changed.
deck.selection_changed deck_id, name The UI/tray target changed; physical runtimes are unaffected.
obs.connected / obs.disconnected — OBS websocket state.
obs.outputs recording:bool, streaming:bool Immutable output-state snapshot for screen-saver suppression and automatic Twitch categories.
game.state deck_id, active:bool, game:str, name:str, phase:str, hint:str A built-in game took or released exclusive control; inactive payload strings are empty.
game.settings deck_id when emitted by a game The active game's difficulty, sound choice or record changed and must be persisted.
game.request deck_id, game:str Start another game on the same deck only after the current exclusive session released it.
game.completed deck_id, game, difficulty, won, record One paid run ended; drives one-time game challenges.
arcade.state balance, jackpot, jackpot_progress, unlocked, completed, next_refill_at, … Wallet/progress snapshot for UI, tray and active Jackpot.
arcade.refill amount, balance, sound, visual The eligible zero-balance 24-hour rescue was granted.
arcade.challenge id, title, reward, balance One success-only or configuration challenge was awarded.
configuration.changed deck_id Durable configuration may satisfy a retroactive Spark Challenge.
action.succeeded deck_id, action:str An action returned without raising; never emitted for a failed action.
twitch.state linked:bool, login:str A Twitch account was linked, dropped or confirmed.
twitch.auto_category enabled, paused, streaming, status, detected_*, pending_*, category_name The automatic category policy changed; repaint its key and refresh settings.
notification id:str, title:str, body:str Show or replace one quiet desktop notification for background work.
twitch.live connected:bool The EventSub session came up or went away.
obs.state what:str Any OBS state change (drives key feedback).
page.changed deck_id, index:int, name:str Active page changed.
folder.changed deck_id, path:tuple[int, ...], trail:list A folder was opened, left or reset to the page root.
profile.changed deck_id, name, description, … Active profile changed.
ui.key_image deck_id, index:int, png:bytes A key was rendered; UI paints it when that deck is selected.
ui.game_hud deck_id, png:bytes Stream Deck + LCD game HUD for the selected virtual deck; empty bytes hide it.
ui.screensaver_frame deck_id, images:tuple[bytes | None, ...], size:(width, height) Changed raw RGB key frames for the selected visible virtual deck; None preserves an unchanged key.
preflight.report deck_id, checks:tuple A pre-flight run finished; the selected deck's window shows the detail.
status text:str Transient status-bar message.

The * topic receives every event. EventBus.dispatcher is GLib.idle_add in the app, so subscribers always run on the GTK main thread even when the emit came from the deck read thread or the obs-websocket event thread.

Multiple independent decks, of any shape

DeckFleet is the only production component that enumerates Stream Decks. It opens every visual device, reads (vendor id, product id, serial number) and assigns it to one persistent DeckWorkspace; doing this centrally is load-bearing, because a monitor per workspace would race to open the same HID path. A portable or migrated workspace starts unbound and is assigned in its persisted order. A genuinely new device creates a new workspace. The HID path is only a last-resort identity when a driver cannot return a serial, because paths can change across replug and resume.

Each workspace owns independent profiles, pages, keys, brightness, screen saver, exit display and remembered geometry. It has one endpoint DeckManager, one DeckController and one GameManager; OBS, Twitch, Home Assistant, AI, Steam and the Arcade wallet remain global services. Config.view(deck_id) is fixed to one workspace, while the compatibility properties on root Config follow selected_deck_id for the UI. Never give a controller the root's dynamic view: changing the editor selector must not make a press on deck A execute deck B's page.

Every device-local event carries deck_id. ScopedEventBus filters physical input and connection events for one runtime and tags everything that runtime emits, while still allowing global OBS/Twitch/service state to reach every controller. RuntimeKey is (profile, page, folder path, index, deck_id); the last field prevents class-wide action state and Twitch acknowledgements from colliding between identical slots on two devices without moving the historical profile/page/path/index positions. The main window filters display/navigation events to the selected workspace. Switching the selector changes only the editor and virtual deck: every physical device keeps running.

The Arcade wallet is thread-safe and shared, but only one built-in game may run across the fleet at a time. This avoids two Jackpot engines settling absolute wallet/jackpot snapshots over each other. A second game request explains that the active game must stop first.

Configuration format 6 stores all workspaces. Portable exports include every workspace and its assets but clear vendor/product/serial bindings so another computer assigns its own hardware. Versions 1–5 migrate losslessly to the primary workspace. A full import or backup restore shuts down every old controller before closing HID, rebuilds the fleet, then reconnects and renders the replacement workspaces.

USB presence is not device health. After suspend/resume, python-elgato-streamdeck can still enumerate the same HID path after its reader has closed the stale handle. _alive() therefore requires both is_open() and connected(). A TransportError from a key, touchscreen, brightness or screen- saver write sets one bounded reconnect request and wakes the fleet immediately; image-conversion and other programming errors are logged with their traceback but must not masquerade as transport loss. The fleet detaches and closes only the failed endpoint, emits scoped deck.disconnected, and can reopen it in the same scan pass. Publishing the replacement handle and clearing the old request are atomic under that endpoint's HID lock, so a fresh failure cannot be erased. Other decks continue without interruption. Physical writes pause for the failed workspace during recovery, but its virtual deck and manual screen-saver preview remain usable.

Geometry comes from the device, never from a constant. _device_columns() reads key_layout() and DeckManager.columns carries it to every full-deck renderer: startup_frames(), screensaver_frame() and exit_image_tiles() all split their canvas along it. Assuming five columns scrambled the screen saver, the startup sequence and the custom exit image on every model that is not the 15-key original — a Mini has three columns, an XL eight. Per-key rendering was always fine, because compose() takes the real key size, and every HID write goes through PILHelper.to_native_key_format(), which applies each model's own size, flip and rotation.

MainWindow builds its grid through _build_key_grid() rather than once at startup: the window exists before anything is plugged in, so it opens on the MK.2 defaults and _on_deck_connected reshapes it when a deck of another shape arrives, dropping the selection first because every index the editor holds refers to the old grid. GRID_COLS is only the pre-connection default.

Anything that spreads text one character per key has to shrink, not truncate. startup_animation.title_layout() places the name across whatever grid exists, picking the longest entry of TITLE_FORMS that fits — a six-key Mini gets Linux, not the fragment LinuxS that cutting the name to length produced. The block is centered vertically but each row starts at the left, because these are a wrapped word rather than centered lines. The linuxstreamdeck screen saver shares that helper: it is nothing but the title, so returning an empty layout there would be a black screen that looks broken.

The Split-Flap Board has the same problem and its own answer: _flap_words() falls back to SPLIT_FLAP_SHORT_WORDS when the fifteen-character phrases do not fit, because a board that spells LINUXS and STANDB never becomes readable.

A deck with no key displays is refused outright: _is_visual() gates it and _reject_device() explains why, once per device. The Stream Deck Pedal has three keys and no screens, and most of this application is about what gets drawn. When the driver cannot answer, the deck is assumed visual — refusing one we merely failed to ask about would be worse than trying to draw on it.

Every broad HID handler keeps its traceback. Both the fleet scan and the legacy standalone manager catch Exception on purpose — hardware can fail in a dozen legitimate ways — but log with exc_info=True, because without it a TypeError from our own code reads exactly like a missing udev rule. Open failures surface only after two attempts on one path: a deck just plugged in often refuses the first while it is still enumerating, and repeating the warning on every scan would bury useful status messages.

Everything above is verified in simulation for every model the library knows (tests/test_multi_deck.py), but only the 15-key original has been run on real hardware. The simulations cover simultaneous assignment, scoped input, stable-serial reconnection, duplicate-scan suppression and independent config round trips; physical confirmation on mixed hardware is still valuable.

Stream Deck + dials and touchscreen

The Plus adds four rotary encoders and an 800x100 LCD strip above them. Both are gated on DeckManager.dial_count, which is 0 on every other model, so nothing here is reachable on hardware that has none.

A dial reuses KeyConfig with KIND_DIAL and three lists — steps_left, steps_right, steps_press, all three in STEP_FIELDS. That is the whole reason it is not its own dataclass: every walk over a key's actions and assets (portable bundles, nav.page migration, rename_page, the layout sheet) then reaches a dial without being taught about it. What genuinely differs is storage. Dials are numbered independently of keys, so they live in Page.dials, and _key_configs() plus the bundle exporter iterate that mapping alongside page.keys. Page.from_dict drops any index at or beyond MAX_DIALS, so a hand-edited file cannot invent a fifth encoder.

Transient state uses DIAL_PATH, the folder path (-1,). A real path holds key indices, which are never negative, so dial 0 and key 0 can never collide in RuntimeKey — and no change to that type was needed.

_dial_event() is the one place that knows the library reports a push as a boolean and a turn as a signed tick count. It drops the release edge (a dial acts on the way down, like a key) and keeps the tick count, because a fast spin arrives as several ticks in one event: flattening it would move a volume by one step no matter how far the hand turned. turn_dial() therefore runs the list once per tick, bounded by MAX_DIAL_TICKS so one flick cannot occupy an action worker long after the hand stopped.

device/touchscreen.py draws the strip as one panel per encoder. It is the only thing that can say what a dial does, so panels are labelled rather than free-form. segment_bounds() derives each panel from the running edge instead of a rounded per-segment width: four panels of 800 // 4 happen to tile, four of 801 // 4 leave a one-pixel seam that is plainly visible on black. A tap is resolved by touch_segment() into the dial beneath it and runs that dial's push list, which is why the strip needs no configuration of its own; a coordinate outside the strip is discarded rather than clamped.

ui/dials.py keeps dials out of EditorPanel deliberately. That panel's unsaved-change guard compares a canonical draft against a baseline for one slot of the key grid, and a dial is not a slot of that grid; its own dialog leaves that guard untouched instead of teaching it a second kind of index.

None of this has run on physical hardware — see the note in §1.

Physical deck startup and connection ordering

device/startup_animation.py::startup_frames() creates a 33-frame offscreen sequence for whatever grid the connected deck has: wake/energy wave, burst, progressive title, hold, fade and a final black frame. title_layout() assigns the name one character per key in row-major order — on the 15-key 5×3 deck that is Linux / Strea / mDeck — shortening it through TITLE_FORMS rather than cutting it when the grid is smaller. Frame brightness changes smoothly but _scaled_brightness() never exceeds the user's configured target.

DeckManager plays the sequence directly on a newly opened device from the monitor thread. Until it completes, the manager must not assign self.deck, register the key callback or emit deck.connected; this keeps normal controller renders and physical presses from interleaving with startup writes. The final deck.connected event triggers the controller's existing configured-key refresh after the black frame. The virtual deck does not display this animation.

Every wait and per-key write checks the monitor stop event. Shutdown therefore cancels startup promptly and closes the provisional device without publishing a connection. Rendering or HID I/O failure skips the remainder safely so connection can continue when the device remains available, and DeckManager restores the current configured brightness in finally on every exit path.

Animated screen saver

ScreenSaverSettings persists four top-level configuration values:

Field Range/default Meaning
enabled False Start automatically after inactivity.
style neon_pipes One of the eleven installed animation IDs.
idle_minutes 1-1440, default 5 Delay since the last tracked physical or virtual-deck activity.
intensity 5-100, default 35 Screen-saver brightness, independent of normal deck brightness.

The available ID/display-name pairs are neon_pipes / Neon Pipes, digital_rain / Digital Rain, aurora_flow / Aurora Flow, orbital_core / Orbital Core, circuit_pulse / Circuit Pulse, ember_field / Ember Field, hyperspace / Hyperspace, matrix_code / Matrix Code, hal_9000 / HAL 9000, split_flap / Split-Flap Board and linuxstreamdeck / LinuxStreamDeck. An unknown persisted ID falls back to Neon Pipes. Adding a style means four edits that must stay together: the tuple in SCREENSAVER_CHOICES, the renderer in the renderers table, its entry in brightness_factors and its intentional cadence in FRAME_DELAYS — a style present in the first but missing from any of those tables raises a KeyError on the frame that selects it. The settings dialog builds its list from SCREENSAVER_CHOICES; the other tables are render-time contracts and the tests require FRAME_DELAYS to cover exactly the installed IDs.

Ember Field is procedural on purpose. The obvious way to do fire is the Doom algorithm, where each frame is derived from the previous one, and that cannot work here: screensaver_frame() is a pure function of elapsed, so nothing may accumulate between frames. It instead scrolls three _noise_strip() layers at three speeds under a _ember_falloff() gradient and maps the result through _ember_palette() with point(), which keeps it all at C level. Image.effect_noise() is equally unusable — it draws from Pillow's own generator and answers differently on every call. _noise_strip() therefore seeds its own random.Random, and repeats the grid's first row and column in its last so the upscaled tile meets itself without a seam and any vertical offset can be cropped out of a double-height strip.

Its embers are drawn as an L mask that is blurred and colored afterwards, never as blurred RGBA. Blurring RGBA mixes the black of its transparent pixels into the colour, which turned them into olive rings instead of warm points of light. The larger glow layers elsewhere get away with it because they are blurred copies of content in the same hue; small isolated sprites do not.

Hyperspace is a wormhole rather than a starfield, and it is built from four things that each carry part of that: _hyperspace_tunnel() draws rings rushing out of the throat, warped by two out-of-step sine waves and turned further the deeper they are (HYPERSPACE_TWIST) so the throat twists; _hyperspace_streaks() samples each star along its path instead of drawing a straight line, with the angle turning as it flies (HYPERSPACE_SWIRL) so the streak curves the way the tunnel does; _hyperspace_aberration() splits the streak layer into colour; and a final bloom pass swells with the surge so the jump flares.

Three things there are load-bearing:

  • Star phases and speeds come from _scatter(), a multiplicative hash, never from a linear function of the index. The angles are a linear sequence already, so a linear phase correlates with them and every star lands on one curve — the field collapsed into a spiral at elapsed 0, which is exactly when the saver starts every time it wakes.
  • Depth is _hyperspace_depth(), a power law. A star crawls near the middle and tears past at the rim, which is what stretches it into a streak without faking motion blur, and it is what gives the rings their perspective.
  • The whole style composites additively on black. That is both how light behaves and why these layers can be blurred safely: black is the neutral value, so no transparent-pixel colour can bleed in the way it did into Ember Field's sparks.

Chromatic aberration is applied to the finished streak layer with two resizes (red scaled out, blue scaled in) rather than by drawing every streak three times. Keep HYPERSPACE_ABERRATION small — the split grows with distance from the centre, and at 0.013 the streaks near the rim separated into visibly distinct red and cyan bars instead of fringing.

Split-Flap Board is the one style whose grid is the point: one module per key, so the physical gaps between keys read as the gaps between modules. _flap_word() picks and centres the word, _flap_state() returns (outgoing, incoming, flip) for one module, and _flap_module() draws it. Both helpers are pure so the timing can be asserted without rendering.

Its one real trap is what a module shows at rest: both halves have to be the same character. The old one only lingers below the seam while the leaf carrying the new one is still falling — keeping it there when settled spells every word with mismatched halves, and the board never becomes readable. flip counts down to exactly 1.0 at rest, so flip >= 1.0 is the test for it.

Matrix Code is the only style that draws real characters, which makes it the only one with a font problem. It wants half-width katakana (MATRIX_KATAKANA, U+FF66-U+FF9D), and no font carrying them is a dependency: fonts-noto-cjk is a Suggests of the .deb, not a Recommends, because apt would then pull 91 MB of fonts for one screen saver. _matrix_alphabet() therefore falls back to MATRIX_FALLBACK (Latin plus digits) and the style always renders.

Detecting a usable font is the subtle part. getbbox() cannot answer it: a Latin-only font returns a perfectly good box for any codepoint, because it measures the empty rectangle it substitutes. _draws_katakana() instead draws the glyph next to an unassigned private-use codepoint and compares the two renderings; a font that drew the same thing twice has neither, and accepting it would fill the rain with tofu boxes. Keep that check — the obvious getbbox() version accepts DejaVu.

Two more things there are deliberate. Glyphs are pre-rendered once into cached grayscale masks (_matrix_glyph) that a frame only colorizes, because drawing text per cell per frame is far too slow for the hundreds of cells a frame paints. And the film's mirrored glyphs come from flipping the finished rain layer rather than each glyph: that also reverses the column order, which is invisible because every column is seeded from its own index anyway.

HAL 9000 is the one style that is a single still object rather than a moving field: one red camera eye centered on black, whose iris breathes on a slow wave (HAL_BREATH_CYCLES, about nine seconds) and whose center point breathes on a faster one of its own (HAL_DOT_CYCLES), so the two never lock together. Its brightness_factors entry rides the same iris wave deliberately, so the device breathes with the eye instead of against it. The lens does not move, scan or look around — what makes it recognisable is that it only ever watches.

Its four layers go on in a fixed order, and the order is the whole trick:

  1. bounce — a wide, faint wash reaching the rest of the deck, so the keys around the eye are lit by it rather than cut out of black. It scales with the same iris_level, because a spill that does not breathe with what casts it reads as a separate light.
  2. lens — housing rim, iris and pupil, opaque.
  3. glare — composited over the finished lens, not behind it. Behind it, any spill bright enough to be seen turned the opaque housing into a black cut-out ring, because the glow peaked exactly where the rim is; over it, the bloom washes across the rim as a camera sees it and the lens blends into the light it is casting.
  4. point — the center dot and its own glow, brightest and last.

So brightness on this style is not free: raising the bounce lifts the whole deck, and the tests pin the falloff (eye > neighbour > far key, far key lit but well under the eye) rather than any absolute value. The settings dialog can start any selection immediately as a preview, even while automatic activation is disabled and without physical hardware. Closing or saving stops the preview; only Save persists the four values. The dialog must unsubscribe its temporary deck.screensaver callback on close.

device/screensaver.py::screensaver_frame() builds one coordinated canvas for the complete deck, splits it into per-key RGB images and supplies a frame delay and brightness. Rendering is pure Pillow under the shared RENDER_LOCK; its title font must use ImageFont.Layout.BASIC. Every animated brightness is at least 1 and no greater than the configured screen-saver intensity. That intensity does not modify Config.brightness. FRAME_DELAYS deliberately gives the slow ambient effects a 0.18-0.20 second cadence while Hyperspace keeps 0.16 seconds; do not collapse them back to one global refresh rate without measuring both CPU cost and motion on the physical displays.

DeckManager owns a dedicated deck-screensaver thread and tracks monotonic idle time. Physical key handling and deliberate virtual-deck entry points call record_activity(); these include selecting or testing a key and opening the Stream Deck display controls. Do not infer activity from every window event or attach a broad Gtk.EventControllerLegacy hook. While the saver is active, normal controller/HID key renders are suppressed. The physical deck receives only keys whose raw RGB pixels changed since the previous successful write, so a static key is neither converted to native format nor sent over USB again. The one selected virtual deck receives only changed raw RGB keys through ui.screensaver_frame; MainWindow wraps them directly in Gdk.MemoryTexture, with no PNG encode/decode round trip. LinuxStreamDeckApp routes that virtual work only while the window is visible, mapped and not minimized, and retargets it when the selected workspace changes. The physical animation remains active when the window is hidden. An offline workspace that is not the selected visible virtual deck has no output at all, so its saver thread waits instead of rendering frames that cannot be displayed. The appropriate physical or virtual pixel cache is cleared when visibility, connection or saver lifecycle requires a complete first frame. A physical wake press and its matching release are consumed; a later press executes normally. Waking clears preview state, restores normal brightness, emits inactive deck.screensaver, and the controller refreshes configured key images.

LinuxStreamDeckApp subscribes to the dedicated obs.outputs topic and passes the event payload's recording or streaming value to DeckManager.set_screensaver_suppressed(). It must use that immutable snapshot, not reread OBSState, whose event worker can change it concurrently. Suppression applies only to automatic idle activation: an explicit dialog preview remains available and takes precedence. Entering suppression wakes an already active automatic saver, and both edges reset _last_activity; after recording and streaming are both off, a complete new configured idle interval must elapse before automatic activation.

Suppression is a set of named reasons, not one boolean. OBS uses the legacy external reason and a built-in game uses game; removing either must leave the other in force. Game ownership is stronger than OBS suppression and is checked before _screensaver_preview: a settings dialog left open cannot paint a manual preview over a live game. Both edges of the effective union still restart idle tracking, rather than every redundant addition/removal doing so.

OBSClient._prime_state() publishes the first obs.outputs snapshot immediately after GetRecordStatus and GetStreamStatus, before unrelated initial requests such as per-input mute queries can delay the policy. Live record and stream events update the cache and publish a new snapshot before the general obs.state repaint event.

Those initial requests and live callbacks arrive on different sockets, so their order is not guaranteed. _output_lock protects the output cache and two independent monotonic generations. Each prime request captures its output's generation before sending and applies the response only if no newer live event advanced that generation; otherwise an old outputActive=False response could overwrite a recording or stream that started while the request was in flight. Record and stream generations must stay separate so activity on one output does not discard a valid initial answer for the other. _emit_output_state() copies both booleans under the same lock before publishing the immutable snapshot.

On disconnect, _disconnect_clients() stops and joins the OBS event client before _teardown() advances both generations, resets the cache and emits the authoritative recording=False, streaming=False snapshot. The generation advance also invalidates prime requests already in flight, while the join prevents an in-flight callback from restoring stale active state after suppression has been lifted.

Screen-saver settings are part of normal JSON serialization and .lsdconfig import/export. Import applies them immediately through DeckManager.configure_screensaver() and restarts idle tracking. Shutdown sets the shared deck stop/wakeup signals, joins the screen-saver thread before the monitor thread, and proceeds to the configured clean-exit display only after both have exited.

Physical display after clean exit

ExitDisplaySettings persists mode and image_path at the top level of the configuration. The Stream Deck display dialog saves one of three modes:

ID / display name Clean-exit behavior
device_default / Device default Call the firmware reset so the device supplies its standby image.
blank / Off Write black to every key, then set brightness to 0.
custom / Custom Center-crop one image across the full key grid and leave it at normal configured brightness.

Custom images may be BMP, JPEG, PNG or WebP and are limited to 50 MiB. device/exit_display.py::exit_image_tiles() validates the local file, uses ImageOps.fit() under RENDER_LOCK to fill the complete 5-column grid, then returns one RGB tile per physical key. blank_exit_tiles() produces the black tiles used by Off.

DeckManager.stop() stops and joins both device workers before _close(..., apply_exit_display=True) writes the selected state and closes HID. Shutdown during the provisional startup animation also applies the selected state before closing that device. A missing, invalid or unwritable custom image falls back to the firmware default. This behavior is guaranteed only for a clean application shutdown; forced termination, a system crash or power loss may prevent the final HID writes.

The setting is part of normal JSON persistence. Custom images are bundled in .lsdconfig exports from format v3 onward and restored below CONFIG_DIR/imported-exit-images; importing applies both its mode and restored path immediately through DeckManager.configure_exit_display().

Action framework

Actions are declarative and self-registering:

  • Subclass Action, set id, name, category, params, optional default_icon ("mdi:name"), and decorate with @register.
  • Each Param has a kind (string | int | float | choice | duration | optional_duration | file | color). Duration fields accept MM:SS / H:MM:SS; optional_duration may stay blank. Numeric parameters may declare minimum / maximum / step; file parameters may declare extensions and file_filter_name for the native chooser, or directory=True to pick a folder instead (no extensions apply then). A color parameter is the picker plus clear button described in A colour parameter that can be left inherited below. Any parameter may set placeholder, which is worth doing when blank is a meaningful value rather than an unfinished one, so the empty field can say what it means — obs.stats's disk_folder is the case. A parameter may also set choices_source so the editor fills the dropdown live (scenes, inputs, media_inputs, transitions, scene_collections, profiles, sources_in_scene, audio_sources_in_scene, filters_of_source, text_inputs, browser_inputs, hotkeys, pages, deck_profiles, applications, audio_apps, audio_devices, key_lights, network_interfaces, ha_entities, steam_games, steam_recent_games). The last ten are the LOCAL_CHOICE_SOURCES and must stay above the obs.connected guard in _fetch_choices, so they still fill when OBS is not running. Note that profiles means OBS profiles; LinuxStreamDeck's own are deck_profiles. Not every local source is final when it comes back empty: pages, deck_profiles and applications are SETTLED_CHOICE_SOURCES, whose empty answer really means "nothing to pick". The other seven can answer empty for a different reason entirely — no mixer installed, no avahi, no Home Assistant configured yet, nothing playing audio — so _choices_known() leaves those as a plain text field instead of a dropdown with no way out. Conflating the two is a mistake that already happened once with ha_entities. A parameter whose full list cannot reasonably be enumerated uses completion_source instead (twitch_categories): the field stays a text entry storing free text and gains live suggestions searched as it is typed. Reach for it whenever a dropdown would have to omit the very value the action exists to set. A parameter that only applies to some settings of another one declares depends_on and depends_values; the editor then hides its row rather than dropping it, so the widget keeps its value and switching away and back does not silently reset what was chosen. twitch.alert's chat filter is the case: it answers a question only chat has. sources_in_scene, audio_sources_in_scene and filters_of_source depend on another parameter (scene, scene and source, read with _sibling_value()), so changing the parent rebuilds them through _repopulate(). That rebuild replaces the whole widget, not just its model: _choices_known() decides between a dropdown and a plain text field, and a dependent list that was empty when the step was built starts out as a text field. Updating only the model left it a text field for good, so a scene whose sources appeared later showed nothing until the key was saved and reopened. A stored value is always kept selectable (keep_unknown), while a value inherited from the previous parent is deliberately dropped. A dropdown may show something other than what it stores, and there are two ways in. A static choice list adds choice_labels ({stored value: label}) to its Param; _labelled_choices() builds the visible labels from it. Without it a choice shows what it stores, which is right for values that already read as words (toggle, start) and wrong for identifiers — a list reading stream_time and render_lag tells nobody what those keys would show. The stored value stays the identifier, so rewording a label cannot invalidate a saved key, and the AI catalogue keeps validating against param.choices rather than against the labels. A dynamic list goes through _display_options(), which returns the labels plus a display -> value map. Both fill the same _value_map on the widget, which _widget_value() consults. hotkeys and the two Steam lists use it because their protocol identifiers and AppIDs are not useful labels. GetHotkeyList only returns identifiers such as OBSBasic.StartStreaming and TriggerHotkeyByName needs exactly that string back. hotkey_display_name() in obs/client.py derives the readable text, and get_hotkeys() removes the duplicates OBS emits once per source. The map is rebuilt with the widget, so it can never drift from the options on screen.
  • execute(ctx, params) performs the action; feedback(ctx, params) may return any of active, color, badge and display for live key state. display replaces the icon with fitted, centered text.
  • Override label_for(params) when a selected local item can supply a useful label and the user's own label should still take priority. Override is_available(params) when a local program or selected item may be absent; an explicit False participates in the same all-actions-blocked fade as a missing service, while an exception remains unknown rather than disabling a potentially working key.
  • ActionContext.key identifies the executing device-scoped RuntimeKey (profile, page, folder path, key index, deck id) when available. Use ctx.for_key(key) to derive a context with that identity while preserving its cancellation controls.
  • Set immediate = True only for fast, non-blocking state changes. A single-action key then executes synchronously on press instead of occupying an action worker; multi/toggle sequences still use their normal worker.
  • Set running_feedback = True on a blocking action that should show the controller's temporary RUN/breathing feedback even as a single-action key.
  • Set supports_long_press = True and override long_press(ctx, params) when holding a single-action key should do something else. Returning False means "not handled", and the controller runs the normal press instead.
  • Set restart_on_repress = True when pressing the same device-scoped key again should cancel its prior invocation and restart the complete key sequence. Long-running actions must cooperate through ActionContext.stop_requested() or ActionContext.wait_until_stopped(), which observe both application shutdown and replacement cancellation.
  • needs_obs says the action can do nothing without the OBS connection, and requires_obs(params) lets a key answer per configuration (obs.stats overrides it). The deck fades a key whose every action needs OBS while OBS is closed. Everything in obs/actions.py is flagged by _mark_obs_dependency() from its category, so a new OBS action gets it without doing anything.
  • apply_default_icons({action_id: "mdi:…"}) assigns default icons after registration. Override icon_for(params) when one action id is really several buttons: sys.media is seven of them behind one parameter, and without it a row of transport keys all inherit the play/pause picture. Five places resolve an inherited icon — the deck, a step list, the editor preview, the printable sheet and the dial row — and every one goes through icon_for, so a key can never preview one picture and draw another. Same shape as requires_obs(params): an attribute for the simple case, a method for the one that has to look. Registration happens on import (app.py imports the catalogues).

Steam discovery and actions

core/steam.py is deliberately local and credential-free. It reads each known native, Flatpak and Snap Steam root, follows only paths registered in libraryfolders.vdf, and parses appmanifest_*.acf; it never scans arbitrary mounts and never asks for a Steam Web API key, account or public profile. The small KeyValues parser accepts nested quoted fields, comments and Valve's quote and backslash escapes. File size and host-command time limits bound malformed or unavailable metadata. Proton, Steam Linux Runtime, Steamworks redistributables and the two anti-cheat runtimes are filtered by their explicit component names; unknown manifests remain visible rather than being guessed away.

A configured key stores only the numeric AppID. steam_games and steam_recent_games turn that stable value into a friendly name in the editor, preserve an imported unknown value as editable text, and resolve the current installation again on the destination computer. Recent means a positive LastPlayed, newest first, and the picker is bounded to fifty. Discovery has a short locked cache because icon_for(), label_for() and is_available() can all be reached during one repaint.

Artwork comes only from Steam's local appcache/librarycache: a portrait cover is fitted over a dark blurred square and atomically cached below the LinuxStreamDeck user cache. Pillow decoding/composition stays under the shared RENDER_LOCK, but host reads and cache writes do not hold it. No copyrighted game art is bundled in this repository. A custom key icon and label still win; otherwise the cover and the manifest's current game name are inherited dynamically on the live key, the printable layout sheet and the Stream Deck + strip.

The five actions in steam_actions.py are steam.game.launch, steam.game.resume, steam.open, steam.game.details and steam.game.recent. URI parts are selected internally and AppIDs are numeric, so none of them invokes a shell or accepts an arbitrary URI. The installation that owns a manifest chooses native versus flatpak run; if it disappeared, the other installed client is a safe fallback. A per-URI two-second cooldown turns a double press into one launch. In the LinuxStreamDeck Flatpak, bounded cat/find and the client command go through flatpak-spawn --host, using the existing deliberate host permission rather than adding another sandbox hole.

running_games() is the local signal used by automatic Twitch categories. It reads only same-user process environments and accepts STEAM_COMPAT_APP_ID, SteamAppId or SteamGameId when the numeric value also belongs to an indexed installed manifest. Native packages and AppImages enumerate /proc; the LinuxStreamDeck Flatpak asks host ps for PIDs and uses the existing bounded host-aware cat path for environ. Unreadable, malformed, non-Steam and unknown AppIDs are ignored. Never replace this with active-window titles: Wayland does not offer a reliable global foreground-window identity, and alt-tabbing to OBS must not look like a game change.

Countdown timer and stopwatch

The System category includes two stateful clock actions:

ID Parameters Press behavior Default icon
sys.timer duration; optional supported audio sound; volume 0-100 Idle shows the configured duration; first press starts, a running press resets, completion stays at 00:00:00, and a finished press resets/stops its sound mdi:timer-outline
sys.stopwatch None Idle shows 00:00:00; first press starts and the next resets to zero mdi:clock-outline

Both actions return their HH:MM:SS value through feedback display, which the renderer centers in place of the icon while retaining any label. They set immediate = True, so single-action keys update without entering the action executor. A timer rejects a non-positive duration. Its optional completion sound accepts the same .wav, .wave, .mp3, .ogg, .oga, .flac and .opus formats as sys.audio, with independently clamped volume.

ClockRuntime owns transient timer/stopwatch state under a lock, keyed by the device-scoped RuntimeKey. Its scheduler checks running clocks every 0.1 seconds but requests a render only when a displayed second changes, and only the visible profile/page is repainted. State continues to advance across page/profile switches. Completion is emitted once; its optional sound runs on the separate notification executor so it cannot consume action workers.

Drag/drop moves or swaps clock state with its configured key. Editing/saving, pasting over or clearing a key resets that position and cancels its timer sound. Profile/page deletion clears all clock state because stored indices shift, as do configuration import and controller shutdown. Pressing a running timer resets it; pressing a finished timer also cancels its completion sound.

A key that counts

sys.counter adds its step on a press and resets when the key is held -- supports_long_press, so a second key is not spent on something that happens once a session. long_press() returns True to claim the gesture; returning False would let the controller run the normal press as well, so holding it would reset the count and then add one.

The count is transient key state, kept beside _toggle and following exactly its lifecycle. It travels with a key that moves (_swap_key_state), is dropped when the key's configuration is replaced (key_config_changed), is discarded with a folder's subtree, and is cleared wherever indices shift — which is why the test asserts _counters.clear() appears exactly as often as _toggle.clear(). It deliberately survives a page or profile change, since a tally is kept over a session and a page switch happens by accident.

It is not persisted. A count written to the configuration on every press would be a file write plus a backup rotation per press, and the honest consequence is that a restart starts again from the configured value.

start is read from the key's own parameters rather than stored, so changing it moves an untouched counter with it — and editing the key resets the count anyway, as it does every other piece of transient key state. A step of zero counts as the default rather than as a choice: it is a key that looks configured and never changes anything.

Page navigation actions and migration

The visible Navigation category contains three explicit actions:

ID Parameters Behavior Default icon
nav.page.next None Next page, wrapping last → first mdi:page-next
nav.page.previous None Previous page, wrapping first → last mdi:page-previous
nav.page.go page, labelled Destination page, choices_source="pages" Named page in the active profile; emits status when missing mdi:book-open-page-variant

The old combined nav.page action is not registered or visible. AI proposals therefore see only the three current IDs. KeyConfig.from_dict() transparently migrates the legacy ID in single actions and every steps, steps_on and steps_off entry. Legacy next / next page and previous / previous page modes become their parameterless actions; other modes become nav.page.go with the stored page name. Normal serialization writes the new IDs on the next configuration save or export, so old local configs and imported bundles remain compatible without preserving the deprecated shape.

Named targets must stay unambiguous within a profile. add_page() and rename_page() reject an exact duplicate name and emit status without saving. A successful rename traverses every key on every page in the active profile, including single, multi and toggle steps, and rewrites matching nav.page.go parameters. Deleting a referenced destination is allowed; PageGo.execute() reports the missing name when the key is pressed. set_page_by_name() returns whether the name exists, while set_page() treats the already active page as a no-op to avoid redundant saves and events.

An empty KeyConfig.icon or icon_off is an inheritance marker, not a missing render value. The editor Appearance preview must resolve the same effective action/default icon as the controller and deck grid, but must keep the stored reference empty. Clearing an explicit icon therefore restores inheritance instead of copying the current fallback into config.

Desktop integration backends

Three modules keep the system-facing work out of the action definitions, so each action stays declarative and every backend is unit-testable on its own.

core/apps.py reads the application list from Gio.AppInfo, which is already available through GTK, so listing and launching need no extra dependency. Keys store the display name; find_application() accepts either that or a desktop id. running_pids() uses pgrep against the entry's executable, deliberately ignoring wrapper names (env, sh, flatpak) that would match everything. Relaunching a running application is what raises its window — the desktop routes the launch to the existing instance — so there is no separate "activate" path.

core/media.py drives transport through MPRIS (playerctl) and volume through the session mixer (wpctl, falling back to pactl). Never implement these by faking media keys: MPRIS and the mixer work identically on Wayland and X11 and need no privileges. Volume raises are capped at 1.0 so a key cannot push the sink past 100 %.

core/mixer.py is the other half of that, and the half a stream needs: turning the game down without touching the microphone, muting the microphone without muting everything, and moving the session between speakers and a headset. Everything in it goes through pactl -f json, which is a requirement rather than a preference, for three separate reasons:

  • wpctl cannot list what is playing in any parseable form. Its status is a tree drawn for a terminal.
  • pactl without -f json answers in the user's own language. On the machine this was written on it says Silenciado: no and Entrada del destino #108, so anything matching English words works until somebody who is not English runs it. A pactl too old for -f json (before PulseAudio 15) is reported as such rather than guessed at.
  • pactl covers PulseAudio and PipeWire alike through pipewire-pulse, so one backend serves both.

Four things about it are load-bearing:

  • A raise is written as an absolute level, never as pactl's own +5%. pactl clamps nothing, so a relative change on a key pressed a dozen times walks the sink to 160 %, where the sample is amplified in software and distorts. _level() reads the current value first, which is what puts a ceiling on it.
  • Every stream of an application moves together. A browser commonly owns several, and acting on the first alone leaves one tab audible after the key said it had muted it. For the same reason _app_state() calls an application muted only when all of it is.
  • An unanswerable state is None, never "not muted". A microphone key exists to be believed about exactly this, so a question that went unanswered draws nothing rather than reassuring.
  • One reading of the mixer serves every key. feedback() runs on the single render worker and each reading is four processes; without STATE_TTL a page of fifteen mute keys would spawn sixty per repaint. apply() drops the cached reading, so the repaint after a press cannot show the state from before it.

Only mute keys join _live_loop, at MIXER_REFRESH_SECONDS: the mixer can be changed from the desktop's own volume panel or a headset button, so that state has to keep up, while a volume key has none to show. The editor fills its two dropdowns from audio_apps and audio_devices, both in LOCAL_CHOICE_SOURCES since neither needs OBS. A device key stores the stable name and shows the description, exactly as an OBS hotkey does.

core/keystrokes.py is the only place that injects input. Wayland blocks synthetic events, so it shells out to ydotool (preferred: it writes to uinput and therefore works on every compositor), wtype or xdotool. They are Recommends of the .deb, never Depends: apt installs them by default while the package still installs on a distribution that lacks them, and backend() returns "" when none is present so the action reports what to install.

The two ydotool generations are incompatible. 0.x — still what Debian and Ubuntu ship — takes a key sequence (ydotool key ctrl+c), while 1.x takes press/release event codes (ydotool key 29:1 46:1 46:0 29:0). Never assume one: ydotool_syntax() detects it from the help text and caches the answer. A key is therefore held canonically as its Linux input name (esc, dot, sysrq, leftbrace), with _KEY_ALIASES accepting what a user might type, _KEY_CODES giving the 1.x codes and _XKB_NAMES the X keysyms for the other two backends. Presets are adapted to Linux — Windows-only entries of the original catalogue are dropped — and always stay editable, because desktops rebind them.

What is playing, on a key

sys.media gains an optional Show what is playing, off by default so a plain transport key costs nothing. core/nowplaying.py reads MPRIS through playerctl, which media.py already uses, so this adds no dependency.

A song title does not fit on a key, and that decided the whole design. Measured at 96 px: as a label it wraps to two lines and is cut — "Wish You Were Here" becomes "Wish You Were" — and centered it is worse, because compose() fits centered text to the key width, so a short title is drawn huge and a long one unreadably small. The artist fits every time, because artist names are short by nature. So a key shows the album art with the artist over it: the picture is what makes it recognisable before anything is read, and the artist is what stays legible when there is no picture.

Five things are load-bearing:

  • The separator is \x1f. Any punctuation would eventually land inside somebody's song title and split it in half. Verified against playerctl 2.4, where a metadata key the player does not publish renders as empty rather than as an error or as the literal template.
  • Feedback may replace the label, which is the only reason this works: _single_spec reads fb.get("label") or kc.label. The fallback is what makes the key show its own name again the moment nothing is playing, rather than blanking or keeping a stale artist. A player that is playing but publishes no metadata — a stream that has just started — takes the same path.
  • A file:// cover is read on the calling thread; an http(s) one is not. A local read is the same one compose() already does for a custom icon, and doing it in the background would blank the key for a refresh on every track change. Spotify publishes an https address, and feedback() runs on the single render worker, so that one goes through webrequest.background(). Any other scheme is refused and remembered as "no cover", so it is not examined again on every repaint.
  • One reading serves every key, at STATE_TTL: each is a process (measured at ~7 ms). A press drops it, so the repaint after pausing does not show the state from before.
  • No players found is not a failure. playerctl exits non-zero for it, and it is the ordinary answer when nothing is playing.

A sound key others can hear

sys.audio plays a file on the machine, which is right for a cue meant for the person at the deck and useless for a soundboard: nobody in the stream or the call hears it. core/soundboard.py is the missing piece, and it is not audio code at all — it is one device.

module-null-sink creates an output that goes nowhere and exposes what was played into it as a source. OBS, Discord or a browser then picks linuxstreamdeck.monitor as an input and hears exactly what a key played, with nothing else from the machine mixed in. play_audio(..., sink=) routes one playback there through a pulsesink, leaving every existing key untouched.

Four things are load-bearing:

  • A module-loopback comes with the sink, from its monitor back to the normal output. Without it the person pressing the key hears nothing, which is indistinguishable from a broken key. A loopback that fails to load is logged and no more: the sound still reaches the stream, and losing the monitoring must not lose the feature.
  • The device is looked for before it is created. It belongs to the audio session rather than to this process, so it survives a restart — and creating blindly would leave a day of restarts with a dozen of them.
  • Shutdown deliberately leaves it alone. Another application stores it by name: OBS as an audio source, Discord as an input. Removing it on exit would silently break their configuration every time this one closed, and the audio session clears it at logout anyway. remove() exists but nothing calls it.
  • A device that cannot be made reports, and plays nothing. Falling back to the default output would put a soundboard cue where the person pressing it can hear it and the stream cannot — which looks like it worked and is not. _route_to() raises for the same reason when the PulseAudio plugin is missing.

AUDIO_LOCAL stays the default, so no key already configured changes what it does by being loaded.

Local audio action

sys.audio (Play audio file) uses GStreamer playbin for local .wav, .wave, .mp3, .ogg, .oga, .flac and .opus files. Its declarative parameters are a filtered file chooser, integer volume from 0 to 100 with a step of 5, and an optional maximum duration. Blank duration means play to EOS; otherwise MM:SS / H:MM:SS limits playback.

Playback is deliberately blocking on the action worker so the next multi/toggle step starts only after EOS or the limit. The player polls ActionContext.stop_requested(), returns promptly during application shutdown or replacement cancellation and always resets the pipeline to NULL. Missing/unsupported files, pipeline creation/start failures and decoder errors must surface through the normal action error status. Both sys.audio and sys.wait opt single-action keys into running feedback.

sys.audio sets restart_on_repress = True, making its entire containing key sequence restartable. Execution controls are scoped by RuntimeKey. A same-key press signals the prior control and chains its replacement behind the prior finished event, so the old GStreamer pipeline reaches NULL before the new invocation starts. Rapid presses cancel queued replacements before they run, collapsing the queue to the latest invocation. sys.wait uses ActionContext.wait_until_stopped() so a wait anywhere in an audio-containing sequence can be interrupted promptly before the restart.

AI-assisted key proposals

AIService supports OpenAI and Claude with user-selected models. Provider API keys are stored separately per provider through ApiKeyStore in Secret Service / GNOME Keyring. They must never enter Config, config.json, its backup, or a .lsdconfig export. Provider API billing is separate from LinuxStreamDeck. When a saved key is displayed, its fixed read-only mask is only a presence indicator. The request must use the separately held stored key, never the mask. Replacing, returning to the saved key, and forgetting it are explicit UI actions.

AIKeyDialog is laid out around the fact that its configuration is set up once and its description field is used every time. Provider, model, API key and the context switch live inside one Adw.ExpanderRow, and the primary action sits in an Adw.ToolbarView bottom bar rather than at the end of the scrolled page. Measured at the default 580x820, that took the request page from 936 px of content in a 773 px viewport — description field starting 487 px down, Generate button entirely below the fold — to 606 px in a 727 px viewport, which needs no scrolling at all.

Four rules keep it from feeling like something is hidden or moving:

  • The folded row restates the whole configuration in its title and subtitle (_refresh_summary()): provider, model, key state, context and billing. A bare "Settings" row is the version that leaves people hunting.
  • The opening state is decided once, in _settle_opening_state(), from what the keyring holds: folded with the description focused when _configuration_ready(), expanded with the API key focused when not. It is guarded by _settings_settled so a later lookup — a provider switch triggers one — can never fold the settings away while they are being used.
  • Validation opens what it complains about. _generate() expands the row before reporting a missing API key, or the message points at a field nobody can see.
  • _show_page() moves the content stack and the action stack together. The bottom bar is shared by both pages, so switching only one leaves the wrong buttons under the wrong page.

The reassurance under Describe the key — that a proposal never runs or saves anything — stays outside the expander on purpose. It is a safety statement about what the feature does, not configuration, so it must be readable without expanding anything.

A group's children are not items of the scene. OBS reports a group as one scene item and hides everything inside it: GetSceneItemList never mentions the children, and GetSceneItemId against the scene fails for them, because OBS addresses them with the group's name in place of the scene's. Both halves of that have to be handled together, or the feature is broken either way round:

  • get_sources_in_scene() walks each isGroup item through GetGroupSceneItemList and lists its children after it, keeping the group itself selectable and de-duplicating a source that sits in two groups. Without this the editor offered only the group, so nothing inside it could be picked.
  • find_scene_item() returns the container that actually holds a source, not the scene, trying the scene first and then its groups. Every later scene-item request must be addressed to that container. Listing nested sources without this would offer sources that then fail on press.

OBS does not allow a group inside a group, so one level is the whole tree. get_audio_sources_in_scene() is built on the same walk and inherits the fix.

The Audio actions scope their input list to a scene: OBSClient.get_audio_sources_in_scene() takes the scene's items, adds the special inputs from GetSpecialInputs (Desktop Audio, Mic/Aux belong to no scene yet are audible on all of them) and keeps only the ones that answer GetInputMute, since OBS has no request for "the audio sources of a scene". Their scene parameter is an editor filter only: it defaults to "", is never sent to OBS, and the actions still target the input globally, so keys saved before it existed keep working untouched.

The optional generation context is an explicit user opt-in. It contains only bounded OBS names, page names, and installed Steam game names/AppIDs collected by collect_generation_context(); never send passwords, commands, the full configuration, or other secrets. The action catalogue exposed to a provider always excludes sys.command, obs.raw and web.request.

A provider response is untrusted data. It must pass local structural, action, parameter, choice and icon validation before becoming an AIProposal. Generation must never execute or save a key. The user first previews the proposal, explicitly loads it into the existing editor, reviews it, and presses Save to persist it.

A proposed step may carry an optional label, the same descriptive name the editor's step list shows. _step_label() treats it as what it is — text that names a row and is never matched against an action — so it only bounds it to MAX_STEP_LABEL_CHARS and flattens it to one line; a missing one is simply empty, and a non-string is rejected. The action id alone still decides what runs, so no label can select or reach an action. format_proposal() prints the name and its action (1. Cut to camera — Switch scene), so the preview can never hide behind a friendly name what the key would actually do.

Config / key model

Config holds profiles and delegates pages/current_page to the active Profile. Each KeyConfig.kind is one of:

  • single (KIND_SINGLE) — one action, with state feedback.
  • multi (KIND_MULTI) — an ordered list of ActionSteps run in sequence; pauses are an explicit Wait action (sys.wait, a duration param), not a per-step delay.

Each StepList row is a Gtk.Expander whose title is a widget (set_label_widget): a drag handle, the hexpanding title label and the move / copy / remove buttons, so none of them needs the step to be opened first. Rows also reorder by drag and drop, with one source/target pair per row in the bubble phase — capture would swallow the clicks of those very buttons and the expander's own toggle. Each row must also drop GtkExpander's own GtkDropControllerMotion (_disable_hover_expand()): it exists to spring open a tree node after a hover, so without removing it the row under the pointer opens itself mid-reorder. The remove button uses the .step-remove class rather than .destructive-action, because the latter paints a background that .flat drops, leaving the icon in the normal text colour instead of red. The payload is the internal typed string linuxstreamdeck-step:<index>, validated against the drag in progress exactly like the key grid's, and a drop moves the step to that position rather than swapping two. Because the title is a widget, renaming a row sets editor._title, not Gtk.Expander.set_label(); step_title() reads it back.

StepList rebuilds all of its rows whenever one is added, pasted, moved or removed, which makes two things easy to get wrong. It must call _remember_expansion() first, so each row keeps the state the user gave it rather than the one it was created with — otherwise adding a step silently reopens rows that were collapsed. And it must unparent each StepEditor explicitly before re-wrapping it, instead of relying on the previous expander being garbage collected: anything still holding that expander (a queued reveal, a handler) leaves the editor attached to it and the rebuild fails with gtk_box_append: assertion 'gtk_widget_get_parent (child) == NULL' failed. _reveal() scrolls a newly added or pasted step into view on a low-priority idle and resolves the expander late, so a rebuild in between scrolls to the current row. Reordering and removing instead go through _rebuild_keeping_scroll(), and _move() hands focus back to the same arrow on the row's new position: GtkViewport:scroll-to-focus is on by default, so the button being destroyed mid-click is what threw the list back to the top. That grab must be wrapped in a callback returning False: grab_focus() answers True when it succeeds, and GLib.idle_add(button.grab_focus, …) therefore kept the source alive and re-ran it for the rest of the session. Note that a collapsed GtkExpander keeps its child out of the traversable widget tree, so tests must walk down from get_child().

Copying a step between action lists

STEP_CLIPBOARD (a module-level _StepClipboard in ui/steps.py) holds one copied ActionStep. It cannot live on a StepList: EditorPanel._build_body() rebuilds every list whenever the selected key or the key type changes, so a copy kept there could never reach another list, let alone another key. It stores and returns a fresh copy each time, so pasting twice cannot share one params dict, and it refuses a step with no action.

The row header's copy button only fills that clipboard; nothing is inserted until a paste. Right-clicking a row offers everything that row can do — Copy action and Paste action, then Move up / Move down / Move to top / Move to bottom, then Remove action — with None between the groups standing for a separator. A paste lands at that row's index, pushing it down (_add(..., position=)); the move entries reuse _move() and _reorder(), and each is disabled where it would do nothing, so the only row of a list can only be copied, pasted onto or removed. Right-clicking the list beside its rows offers only Paste action, at the end. A right click inside a parameter entry reaches neither: the entry claims it first and shows its own text menu.

While a row menu is open its row carries the menu-target CSS class, because the popover covers part of the list and the entries act on a row the pointer has already left. _set_menu_row() holds that row as a widget, not an index: an entry may reorder the list, and the highlight has to come off whatever it went on. _ContextMenu.show(..., on_close=) clears it however the menu ends — choosing an entry, dismissing it, or a _rebuild() closing it.

A pasted row arrives collapsed — _add(..., expand=False) followed by an explicit _reveal(). It is scrolled into view but never opened: the copy is already configured, so expanding it would only push the rest of the list out of sight. Adding a new step still opens, because that one has to be filled in.

Both gestures are in the bubble phase and both see the same click, so the row's answer has to win twice over. _on_row_menu() claims the sequence, which should already deny the list's gesture, and raises _row_menu_click, which _on_list_menu() checks before showing anything; it is cleared on the following idle, so the next click works normally. Do not drop the flag and rely on claiming alone, and do not resolve the row from Gtk.Widget.pick() instead: pick() answers None on an unmapped widget, which makes that path impossible to verify without showing a window. row_menu_items() / list_menu_items() / StepEditor.menu_items() build the entries as plain (label, enabled, callback) tuples, so a test can assert them without popping up a popover.

A single-action key has no row to right-click, so EditorPanel calls StepEditor.enable_context_menu() on its editor; pasting there replaces the action and its parameters, and drops the step name, which only a list can show. That is opt-in precisely because a StepEditor inside a list must let the right click bubble to its row.

The same copy can also become a whole key: the grid's context menu carries Paste action (win.key-paste-action), enabled from STEP_CLIPBOARD, which MainWindow._paste_action() turns into a KIND_SINGLE KeyConfig and applies through the usual DeckController.paste_key(). It goes through the same guards as pasting a key — the unsaved-change dialog with offer_save=False, and _replacing_folder() — and it refuses the reserved Back slot. It drops the step name for the same reason the single-action editor does, and it leaves the clipboard filled, so one action can be dropped onto several keys in a row.

_ContextMenu keeps at most one popover, parented to the row it was opened on. Since every StepList rebuild destroys those rows, _rebuild() closes it first, and close() is idempotent — it also runs from the popover's closed signal, so dismissing the menu by clicking away cannot strand it either.

Every ActionStep also carries an optional label: a name shown in the editor's step list instead of the action name, so a long sequence stays readable. It is purely descriptive — nothing at run time reads it — and it is stripped of surrounding blanks, so a name of only spaces falls back to the action name. StepEditor(show_label=True) draws the field, which StepList sets and the single-action editor does not: a single-action key has no list entry to name, and its own label already lives in Appearance. Because the field is outside params_box, a name survives changing the step's action. Old configurations without the field load with an empty label, and older builds ignore it, so no bundle version changes.

  • multi_toggle (KIND_TOGGLE) — two lists (steps_on / steps_off) with an ON/OFF state; the state is keyed by RuntimeKey in the controller.
  • random (KIND_RANDOM) — reuses steps, but a press runs exactly one entry chosen with random.choice.
  • press (KIND_PRESS) — steps_single, steps_double and steps_long, one per press gesture.
  • folder (KIND_FOLDER) — holds a Folder of nested keys in folder and runs no action at all; see Folder keys below.

STEP_FIELDS lists every field holding ActionSteps. Anything walking a key's actions — portable bundles, legacy migration — must iterate it rather than hard-coding the three original names, or the new lists silently lose their bundled audio and their nav.page migration. For the same reason it must walk nested folder keys with Config._walk_keys() / _walk_raw_keys().

KeyConfig.on_error applies to every one of those lists: ON_ERROR_CONTINUE (the default) or ON_ERROR_STOP. See When a key cannot do its job.

Undo, action search and key search

DeckController keeps a bounded history (UNDO_DEPTH) of the key changes that can be taken back: clear_key(), paste_key() and swap_keys() each record what the affected slots held before they ran. Only the KeyConfigs are kept, never transient state — an undone key starts from a clean runtime state exactly as a pasted one does.

The history is scoped to the grid on screen. can_undo() requires the top entry's container to be the current one, and forget_undo() drops it on every page, profile and folder change and on deleting a page or profile or importing a configuration. Restoring into a grid the user has left would be invisible, and inside a folder the same index is a different key entirely. MainWindow routes undo through the usual unsaved-change guard, since it may replace the key the editor is showing.

ui/action_picker.py searches all registered actions at once — name, category, description and id, terms ANDed — because the two chained dropdowns need the category to be known before the action can be found. Results starting with the query rank first, so Enter picks the obvious one.

core/search.py finds a key anywhere in the configuration. locations() walks every profile, page and nested folder yielding a KeyLocation with enough to navigate back; key_terms() builds the haystack from labels, action ids and their registered names, step names and parameter values, so a key is found by what it does rather than by where it is. key_steps() deliberately does not descend into a folder: nested keys are yielded by the walk itself, and crediting a folder with its contents would make every folder match every search.

Press gestures

LONG_PRESS_SECONDS (0.5) and DOUBLE_PRESS_SECONDS (0.35) live in core/controller.py. _on_deck_key now routes both edges: key_down() and key_up(). _gesture_mode() decides what a key needs — "press" for KIND_PRESS, "long" for a single-action key whose action sets supports_long_press, and "" for everything else, which keeps running on the press exactly as before.

A gesture key resolves on release: held past the long threshold runs steps_long; otherwise a threading.Timer waits out the double-press window, and a second release inside it cancels that timer and runs steps_double instead of steps_single. Pending timers must be cancelled whenever the key's meaning changes — key_config_changed(), swap_keys(), _clear_time_actions() (page or profile change) and shutdown() all call into _cancel_gesture() or _clear_gestures(), because a stored index refers to a different key afterwards.

A virtual press has no release to time, so press() runs steps_single directly; the editor says so. It also accepts an explicit gesture (GESTURE_SINGLE / _DOUBLE / _LONG, resolved by gesture_steps()), which only the editor's Test buttons pass: naming a gesture is a different thing from making the virtual deck wait out the double-press window, which it must never do. Action.long_press(ctx, params) returns False to decline, and the controller then falls through to a normal press, which is what makes "Nothing" behave exactly like a short press.

Built-in games and exclusive deck ownership

Built-in games are launched from the status icon's Games submenu. They are not actions stored on a key and never modify the active profile. GameManager owns one daemon worker and sits in DeckController ahead of every normal input path: physical press and release edges, virtual presses, dials and touchscreen taps. While games.active is true, _standing_aside() also stops configured renders and Twitch alert flashes, the window disables editing/navigation and DnD, and the screen saver is suppressed with the named game reason. An already-active saver is explicitly woken before the first game frame.

The physical release belonging to a press that exits the game must still be consumed after active clears. _owned_down records presses that began inside the session for exactly that reason; without it the release would reach the gesture resolver of whatever configured key is underneath. On exit, disconnect, layout-changing connect, error or shutdown, the manager cancels queued sounds, releases only its own screen-saver reason and restores the current configured page. _restore_after_game() clears any report that completed late so a pre-flight board cannot suddenly appear as the game lets go. The game worker must stop from DeckController.shutdown() before DeckManager.stop() closes HID, preserving the application-level controller-before-deck shutdown order.

games/catalog.py holds the stable identities and active-window hints; the status menu's fixed IDs/labels must mirror it. Each registered game has its own pure state machine with injected monotonic time and randomness, and none knows about GTK, HID, Pillow or audio. They share common.py's GameLayout, EngineEvent, phases and lobby contract: Start, difficulty, sound, record and Back. Their rules differ:

ArcadeEconomy is the single authority for Deck Sparks. A missing legacy games.arcade object migrates to 250 Sparks and the three starter unlocks (Circuit Breaker, Mole Smash and Neon Jackpot). Every non-Jackpot START/AGAIN is intercepted by GameManager before the pure engine sees it and atomically spends the catalogue's per-run price. Locked games stay in the menu and start a manager-owned access screen; UNLOCK requires a second press, and EARN SPARKS ends the current exclusive session before game.request starts Neon Jackpot. Never start the replacement while _finishing still owns pixels. The first valid UNLOCK press queues unlock_prompt; the successful debit queues the distinct unlock_success achievement cue. Both are global Arcade assets, not files borrowed from any individual game's self-contained asset directory.

Unlocks, wallet, progressive jackpot, its fractional fifth-Spark contribution remainder, completed challenges, next refill UTC timestamp and the two refill-alert switches live in ArcadeSettings, so JSON, automatic backups and .lsdconfig exports preserve all of them. There is no Free Play switch. Deck Sparks are local fictional data with no purchase, real-money value or cash-out, and are deliberately not encrypted or presented as cheat-proof. When the balance is zero, an overdue 24-hour eligibility grants 100 rescue Sparks; a positive balance leaves an overdue grant ready rather than consuming it. The refill sound and full-deck gold pulse are independently optional, and app.py defers both while a game owns the deck or OBS is recording or streaming.

ChallengeService awards each stable challenge ID once. Configuration facts are scanned retroactively (one summary for existing users); OBS/Steam rewards listen only to action.succeeded or obs.connected, never to a press that may have failed. The scan covers configured keys, profiles, page navigation, folders, three-step multi keys, toggles, press gestures, Steam game keys, five catalogue categories, exports and deck-display settings. Game completion, victory and record rewards come from game.completed. Adding repetitive or risky rewards would turn ordinary control use into farming pressure and is out of scope.

MainWindow owns at most one ArcadeSettingsDialog; repeated menu or tray requests present that instance, and its close callback clears the reference. Every Games row is activatable and routes through app.request_game, preserving the unsaved-key guard while allowing a locked title to open its physical-deck access screen. Completed challenge rows must remain distinguishable without reading their subtitle: green tint, success icon and reward colour are all state-driven and removed again for an available row.

  • mole_smash.py runs a three-second countdown and a 45-second reaction round. Normal targets score 10, golden targets 25, an empty press costs 2 and breaks the combo; difficulty changes visible/gap timing and targets accelerate.

  • circuit_breaker.py creates a non-empty, guaranteed-solvable board by applying legal cross toggles to an all-off grid. A press toggles its cell plus the orthogonal neighbours, difficulty changes scramble density, and finishing in fewer moves replaces the record.

  • pulse_memory.py starts after a three-second countdown, shows a sequence and accepts input only after the display phase. Easy/normal/hard begin at two, three or four steps and use progressively shorter light/gap intervals. A correct replay appends one step that cannot immediately repeat the same key; a wrong key ends the session, and the longest completed sequence is the record.

  • memory_match.py shuffles pairs over the largest even number of keys. An odd grid reserves its final key for moves/pairs; Easy and Normal preview all cards for 2.2 or 1.1 seconds while Hard starts hidden. Mismatches block input until their difficulty-dependent reveal expires, and fewer completed turns wins.

  • minesweeper.py reserves the final key as a Reveal/Flag mode switch and uses every other key as the minefield. Mines are placed only on the first reveal, keeping that cell and its neighbours safe whenever capacity permits; revealing a zero expands through its safe region. Larger layouts use 12%, 18% and 25% mine density, while compact layouts use exactly one, two or three mines so all difficulties stay distinct. A mine ends the round; revealing every safe cell records the lowest elapsed whole-second time. Results keep every final cell visible except the controls: the former mode key becomes Again, and Back prefers the normal lobby exit position but must move rather than cover the exploded cell.

  • tic_tac_toe.py gives the player X and the computer O, with a 0.34-second visible AI turn. Layouts with at least nine keys receive a centered 3x3 board; smaller layouts use every key and derive every horizontal, vertical or diagonal three-cell line their geometry supports. Easy chooses a random free cell, Normal first wins or blocks and otherwise chooses randomly, and Hard completes that tactical pass with minimax. Each player win increments the persisted win count. Results retain and highlight the winning line; Again and Back first use keys outside the board, then non-winning cells on a compact board, so neither erases that line.

  • mastermind.py lets each peg cycle through a difficulty-dependent palette, then scores a submitted guess as exact-position plus colour-only matches with duplicate colours counted once. Easy is 3 pegs/4 colours/8 attempts, Normal 4/6/10 and Hard 5/8/12; the code shortens only when a compact deck needs two control keys. Spare keys show newest-first clue history. A six-key Mini has no spare history key at Normal, so Submit keeps the latest E/C clue visible; results reveal every solution peg and turn Submit/Reset into Again/Back. The lowest successful attempt count is the record.

  • neon_relay.py is an endless circuit-routing run. Each sector builds a randomized, non-repeating, guaranteed orthogonal route between two boundary portals, then scrambles its straight/corner tiles among decoys. The player rotates tiles before the travelling spark reaches them, collects crystals and carries a growing sector combo; efficient rotations earn a perfect-sector bonus. Speed rises by sector to the difficulty's floor. Easy/Normal/Hard also differ in initial hop time, route brightness, pre-aligned safe tiles and starting shields. A crash spends a shield and rebuilds the sector after a recovery pause, or ends the run when none remain. Crystals, perfect clears and completed sectors charge a six-second score-doubling Overdrive. Every third cleared sector presents shuffled shield, permanent stasis-slowdown and score/Overdrive-surge upgrades. The highest score is the record.

  • neon_jackpot.py is a three-reel arcade slot game using the global fictional Deck Sparks wallet only, with no payment, real-money stake or cash-out path. A fresh progressive pot starts and resets after a win at JACKPOT_SEED=40; loading preserves an existing pot above that floor. Every paid spin contributes exactly 20% of its wager. jackpot_progress persists the remainder as zero to four fifths, so bets of one or two Sparks do not round a whole Spark into the pot. A spin chooses all unheld outcomes before animation begins; pressing a moving reel only brings its predetermined stop time forward. Both recovery paths require an ordinary paid, unheld loss: NUDGE may advance one reel by one position and, only when no nudge was granted, HOLD may retain up to two reels for exactly one following spin. Neither is offered after a win, free spin or held spin, they are never offered together, and assisted spins cannot chain. WILD substitutes in a three-symbol match, any two cherries return the bet, three sevens add the growing jackpot, and three bolts add three free spins and a 2x multiplier. A granted one-shot nudge advances the chosen reel one strip position and settles once more. The Info control cycles PAYTABLE_HINTS, the declared 3-match, two-cherry, bolt and jackpot rules, rather than hiding the payout model. Easy/Normal/Hard use 12%/10%/8% nudge chances, 5%/4%/3% hold chances and 2.25/1.95/1.72-second base spins. Payout multipliers stay equal: once the wallet buys other games, Hard doubling every reward would make every other difficulty an irrational economy choice. The wallet and growing progressive jackpot and fractional contribution remainder persist across sessions. The highest bankroll is the record. A deterministic optimal-strategy simulation over 1,000,000 paid spins per difficulty measured 86.81%/84.38%/82.06% total RTP on Easy/Normal/Hard respectively, including assists and the progressive pot; treat those figures as balance checks rather than a per-session promise.

    A paid spin first exposes a bounded WAGER_SECONDS presentation phase: the bet and jackpot accounting is already committed, but reel motion and its motor cue begin only when that phase finishes. If the first two predetermined stops are compatible with a three-symbol match, the third reel enters a bounded ease-out anticipation phase; this changes only its presentation time, never the target strip position. Each stop records its own short impact window. The snapshot carries wager progress, three impact strengths, anticipation progress and a stable celebration tier so physical keys, the virtual deck and the Plus HUD render the same coordinated sequence.

Neon Relay's difficulty tuple is load-bearing rather than cosmetic:

Difficulty Initial/floor seconds per tile Sector acceleration Pre-aligned route tiles Starting shields Route light
Easy 1.08 / 0.58 0.035 3 2 1.00
Normal 0.84 / 0.42 0.040 2 1 0.78
Hard 0.66 / 0.32 0.044 1 0 0.58

A collected crystal scores 30 and adds 16 charge. A cleared sector scores 110 + sector*28 + crystals*35, plus 85 for optimal rotations, under a combo multiplier that rises every three clears to a maximum of 5; active Overdrive doubles both crystal and clear rewards. A clear adds 18 + crystals*8, plus 24 for a perfect sector, to charge. The shield upgrade adds one to a maximum of three, stasis adds 0.07 seconds per tile to a 0.24-second cap, and surge adds 300 points plus 55 charge.

Each record key is <columns>x<rows>[+lcd]:<difficulty>, so decks with different playable areas do not share an unfair leaderboard.

game_layout() derives every shared control from live key_count and columns. On decks without a touchscreen, Mole Smash reserves the two top corners for score/time and Memory Match reserves the last key only when an odd key count would leave an unpaired card; Circuit Breaker and Pulse Memory use the full grid. Minesweeper reserves one mode key, Tic-Tac-Toe centers 3x3 when it can and uses the complete compact grid below nine keys, Colour Mastermind sizes its peg row around Submit/Reset before assigning leftover keys to clue history, and Neon Relay uses every key as a circuit tile. Neon Jackpot is the one catalog game with a minimum geometry: GameInfo.minimum_keys=8 makes GameManager.start() refuse a six-key Mini with a status message. slot_layout() uses three single-key reels plus shared status/controls on 4x2 Neo and Plus layouts, three three-key vertical reel windows on 5x3 and 8x4 layouts, and never changes its three predetermined payline symbols merely to animate them. On Stream Deck +, dial_count selects the +lcd layout and each renderer puts its live score/moves/sequence/pairs/time/clue/progress state on the strip where appropriate. The same PNG travels on ui.game_hud to a 480x60 aspect-preserving Gtk.Picture below the virtual grid; an empty payload and the inactive state both hide it. Lobby controls may reuse play positions because the phases never render at once. Keep the controls unique even on a six-key Mini and never assume a 5x3 grid.

The phase-specific presentation is intentional. Compact Tic-Tac-Toe has no spare status key, so every board tile receives a subtle waiting tint during the AI delay; larger boards use exactly one central extra key for turn/result text and render the other extras as subdued dots rather than repeated status tiles. Colour Mastermind's Plus HUD uses compact E/C feedback and supplies the E = exact / C = colour legend on its lower row; keep that legend, Best, attempts and the title in separate measured regions so they cannot overlap. Neon Relay's Plus HUD carries score, sector, shields, speed or active Overdrive/stasis state and the charge bar. Turning a dial rotates every tile in that dial's column by the reported tick count, except the tile currently holding the spark. Pushing any dial or tapping its LCD segment spends 40 charge to apply 3.5 seconds of stasis and extends the current hop, so a near-deadline press takes effect immediately; the direct extension is one second. This has simulation coverage only; no Plus game control has been tested on physical hardware. Neon Jackpot's Plus HUD shows its three symbols, fictional bank, bet, jackpot, result and free-spin multiplier. Dial 0 turns through affordable bets and pushes Spin; dials 1-3 brake their corresponding moving reel, or push to hold/nudge it between spins. Its deck renderer also owns the Bet-to-Jackpot wager token, reel impact waves, focused third-reel anticipation, symbol-specific celebrations and the four-act jackpot blackout/777/title/coin-payout sequence. These controls have simulation coverage only too.

games/render.py dispatches engine snapshots to the Mole Smash renderer or the eight *_render.py modules, and sends the same Pillow images to physical and virtual decks under the shared RENDER_LOCK; every TrueType font uses BASIC layout. GameManager is shared by all nine engines, compares encoded PNGs and sends only changed keys at its 20 Hz tick; the engines remain deterministic even though their sessions are animated.

Every game owns a self-contained asset directory named after its catalog ID: assets/games/mole_smash/, circuit_breaker/, pulse_memory/ and memory_match/, plus minesweeper/, tic_tac_toe/, mastermind/ and neon_relay/ and neon_jackpot/. The Mole Smash directory also owns its original transparent mole sprite. Neon Jackpot owns eight original transparent symbol sprites: cherry, lemon, star, bell, bolt, diamond, seven and WILD. games/audio.py::CUE_FILES is keyed first by game ID, and every cue resolves only below that game's directory; shared waveforms are duplicated deliberately so one game never depends on another game's assets. Each directory's WAV set must exactly match its cue mapping and its own LICENSE-GAME-ASSETS.txt. tools/generate_game_sounds.py deterministically writes the complete set for all nine directories, including Circuit Breaker's circuit.wav, Pulse Memory's six pitch-mapped pulse-0.wav through pulse-5.wav, and the distinct reveal/flag/explosion, mark/AI/result and peg/submit/result cue sets of the three new games, Neon Relay's reactor cues and Neon Jackpot's cabinet cues. Keep all nine directory patterns in pyproject.toml package data whenever the mapping changes. Playback uses a bounded two-worker queue and a per-session generation so rapid hits never leave delayed sounds playing after exit. Neon Jackpot deliberately has no continuous or fixed-duration spin bed: each integer reel-travel crossing emits its short reel_tick_1/2/3 cue, all backed by the same quiet reel-tick.wav, while the final position emits only its distinct stop cue.

The exact new-game WAV sets are load-bearing: Minesweeper owns explosion.wav, finish.wav, flag.wav, go.wav, record.wav, reveal.wav and select.wav; Tic-Tac-Toe owns ai.wav, draw.wav, go.wav, lose.wav, mark.wav, select.wav and win.wav; Colour Mastermind owns finish.wav, go.wav, peg.wav, record.wav, select.wav, submit.wav and wrong.wav. Each set is original synthesized audio reproduced by tools/generate_game_sounds.py and licensed by the file beside it. Neon Relay owns exactly crash.wav, crystal.wav, gate.wav, go.wav, overdrive.wav, record.wav, rotate.wav, select.wav, shield.wav, stasis.wav and upgrade.wav under assets/games/neon_relay/; its license, deterministic generator mapping, cue map and package-data pattern must move in lockstep. Neon Jackpot owns exactly anticipation.wav, bell-win.wav, bet.wav, bonus.wav, brake.wav, diamond-win.wav, game-over.wav, hold.wav, jackpot.wav, lose.wav, nudge.wav, reel-stop-1.wav, reel-stop-2.wav, reel-stop-3.wav, reel-tick.wav, select.wav, star-win.wav, start.wav, wild-win.wav and win.wav below assets/games/neon_jackpot/. Its generated sprites, exact deterministic WAV set, license, cue map and package data patterns must remain isolated and move together.

GameSettings stores separate difficulty, sound, bounded volume and bounded per-layout record mappings for all nine games in the normal JSON/export. Minesweeper persists best times, Tic-Tac-Toe persists cumulative player wins, Colour Mastermind persists best attempt counts and Neon Relay persists high scores through relay_difficulty, relay_sound_enabled, relay_volume and relay_high_scores; Neon Jackpot uses slot_difficulty, slot_sound_enabled, slot_volume and slot_high_scores for its highest bankrolls. Its nested ArcadeSettings stores the shared wallet/progressive jackpot, its persisted jackpot_progress remainder in fifths, permanent unlocks, completed challenge IDs, refill eligibility and alert switches. game.settings asks the GTK thread to save only when the active game's choice or record actually changes. Import and backup restore replace Config.games as an object, so _adopt_replaced_configuration() must also pass that new object to GameManager.adopt_settings(); keeping the old reference would make later records disappear into detached state.

tests/test_new_games.py pins registry parity, first-reveal safety, flagging, compact mine densities, deferred result-screen difficulty, both Tic-Tac-Toe geometries and AI levels, duplicate-safe Mastermind clues, Mini clue/results UX, all supported key counts and every new Plus HUD. tests/test_neon_relay.py pins boundary-to-boundary route generation, spark traversal, shield recovery, difficulty speed progression, third-sector upgrades, Overdrive, Plus column rotation/stasis and representative key/HUD rendering. tests/test_games.py pins the manager's exclusive Plus-dial routing. tests/test_classic_games.py pins exact per-game asset isolation and persisted settings for all nine; tests/test_neon_jackpot.py pins its minimum geometry, adaptive reel layouts, predetermined outcomes/braking, paid-unheld-loss-only non-chainable holds and nudge, exact 20% progressive contribution, the 40-Spark reset floor, the independent-spin return bound, difficulty assist probabilities, bolt bonus, delayed paid-spin wager, fair anticipation, distinct reel impacts, tiered celebrations, jackpot stages, expanded sounds, Plus controls, sprites and representative 8/15/32-key and HUD rendering. Finally, tests/test_invariants.py drives all nine dispatch paths through BASIC font and shared-RENDER_LOCK checks.

Status icon and application lifetime

GTK 4 has no tray API, so ui/tray.py publishes the icon directly on D-Bus with org.kde.StatusNotifierItem plus com.canonical.dbusmenu, over Gio.DBusConnection. It adds no dependency and works on COSMIC and KDE natively, and on GNOME with an AppIndicator extension. The module creates no GTK widgets; D-Bus handlers run on the GTK main thread, and every user action is still routed back through GLib.idle_add.

The status icon is published as actual IconPixmap data at several panel sizes, not only as the application theme name. TrayIcon locates the same SVG in a source tree, Debian install, Flatpak or AppImage, rasterizes it with GdkPixbuf and serializes it as network-order ARGB32. Before scaling, it finds the alpha bounding box and removes only fully transparent outer padding, then fits that visible rectangle inside each panel size without forcing it square. The published pixmaps may therefore be rectangular: preserving the artwork's aspect ratio is what keeps the status icon large without distorting it. This crop is local to IconPixmap; the launcher and dock continue to use the original hicolor SVG. While those pixels are available it leaves IconName empty because hosts are encouraged to prefer the name, and COSMIC can replace a valid pixmap with a generic placeholder when its theme lookup fails. Failure to locate or decode the SVG falls back to the named icon instead.

Menu-item icons use the separate standard dbusmenu icon-data property. Open, Profile, Games, Arcade, Stop and Quit carry distinct 16x16 coloured PNGs rendered from the bundled MDI font. Individual game rows are deliberately text-only: their price and unlock labels are already information-dense, and repeating one icon per title makes the submenu busier without improving navigation. Never replace the fixed icons with icon-name: the host desktop is not guaranteed to provide the requested glyph, and source, Debian, Flatpak and AppImage installs must look the same without a theme-specific dependency. _menu_icon_png() supersamples before reducing to the published size, is cached, and holds the shared reentrant RENDER_LOCK through Pillow drawing, MDI rendering and PNG encoding. Missing font data or a PNG-encoding failure must omit only that item's icon-data; the text entry and the rest of the menu remain usable. Hosts that ignore optional item icons receive the same complete text menu.

TrayIcon.start() exports both objects, owns an org.kde.StatusNotifierItem-<pid>-1 bus name and then watches the watcher name, so the icon survives a panel restart and can be published before the status area exists. is_supported() is only informational: app.py must start the icon even when it returns False, because a session that is still logging in — the normal autostart case — very often has no status area yet, and the name watcher publishes the icon the moment one appears. RegisterStatusNotifierItem is called asynchronously; it runs on the GTK main thread, so a synchronous call would freeze the window whenever the panel is slow. Until the reply arrives the icon counts as unregistered, which is the safe direction. The menu is Open, a Profile submenu of radio entries, a Games submenu and Quit. Games starts with Arcade — N Sparks, then lists every entry in games/catalog.py alphabetically with its run price, unlock price or Neon Jackpot's Earn Sparks role: Circuit Breaker, Colour Mastermind, Memory Match, Minesweeper, Mole Smash, Neon Jackpot, Neon Relay, Pulse Memory and Tic-Tac-Toe. While a game is active, Profile is disabled and that submenu retains the Arcade balance above Stop [game name]; ItemIsMenu is true, so a plain click opens it. menu_items() and build_layout() are pure functions, which is what the tests exercise. Profile entry IDs start at PROFILE_ID_BASE so they never collide with the fixed ones. app.py subscribes to profile.changed, game.state and arcade.state and calls refresh(), which bumps the revision and emits LayoutUpdated; those events cover adding, renaming, deleting and switching a profile as well as both game-session edges.

Config.close_action is tray (default) or quit. LinuxStreamDeckApp.hides_on_close() is the single decision point and requires all three of: no explicit quit in progress, the configured tray action, and a registered icon. A missing or unregistered status area therefore always falls back to quitting, so the window can never vanish with no way back. MainWindow hides instead of closing by returning True from close-request; the window is only hidden, never destroyed, so Adw.Application keeps the process alive and the whole session — including an unsaved key draft — survives. Hiding deliberately asks nothing.

Quitting and switching profiles from the icon go through MainWindow.request_quit() and request_profile(), which reuse the normal unsaved-change guard and present the window first when a confirmation is needed, because that dialog is modal to it. app.quit() sets the explicit-quit flag before Gtk.Application.quit() so the hide rule cannot intercept it, and _on_shutdown stops the icon before the controller, deck and OBS client.

core/autostart.py owns the XDG entry at ~/.config/autostart/<APP_ID>.desktop (overridable with LSD_AUTOSTART_DIR in tests). Its state lives in that file and never in config.json: an exported configuration must not enable autostart on another computer, and the desktop's own startup tool may disable the entry, which is_enabled() honours by checking both Hidden and X-GNOME-Autostart-enabled. The entry runs the installed console script when there is one, falling back to the current interpreter, and always adds the --hidden flag that strip_hidden_flag() removes before GTK parses argv. A hidden start only skips presenting the window when the icon actually registered.

Folder keys

A KIND_FOLDER key opens its own grid instead of running anything, so related actions group without spending a page. Its contents live in KeyConfig.folder (a Folder), its name is the key's own label (folder_name() falls back to DEFAULT_FOLDER_NAME) and its icon is the key's icon, defaulting to DEFAULT_FOLDER_ICON. Page and Folder both extend the plain KeyGrid mixin, which supplies key(), set_key() and configured_keys(); the mixin declares no dataclass fields, so both keep their own field order and JSON layout.

Three rules make the shape safe:

  • folder is compare=False. Its contents are edited by navigating inside and saved there, so the editor's unsaved-change comparison must not read an edit made inside a folder as a pending edit of the folder key itself.
  • Slot FOLDER_BACK_INDEX (0) is reserved. Inside a folder it always renders the Back key (_back_spec, showing the folder name), it is never stored, and is_reserved_key() blocks selecting, dragging, dropping, pasting, clearing and swapping it. The physical deck can therefore never enter a folder it cannot leave. Folder contents keep the deck's own numbering, so no index shifts.
  • Nesting stops at MAX_FOLDER_DEPTH (5). _folder_contents() drops the contents of anything deeper while keeping the key itself, so one hand-written branch cannot cost the whole configuration or make loading recurse without end. can_add_folder() and fits_here() apply the same limit to new folders and to pasted or imported subtrees.

DeckController._folder_path is a tuple of key indices from the page root, and container resolves it into the grid on screen, self-healing back to the page when a step stops resolving. It is view state, never configuration: opening or closing a folder saves nothing, and _leave_folders() returns to the page root on every page, profile and configuration change, because those indices would otherwise address a different grid. folder.changed carries path and the folder_trail() of (path, name) pairs that the window renders as a clickable breadcrumb.

On the virtual deck a folder opens by double-clicking its key. MainWindow times that from the button's own clicked signal (_last_key_click plus the pure _completes_double_click()), never from an extra Gtk.GestureClick on the key button: a GtkButton claims the primary-button sequence on press, which cancels any other primary gesture on the same widget, so such a gesture never reaches n_press == 2. The secondary-button context menu on those same buttons is unaffected, because GtkButton never claims that sequence. The second click is consumed, so a third one starts a new pair instead of reopening the folder just entered. A drag held over the key opens it too — see Spring-loaded folders — which is the only way to put a key inside one without cutting and pasting it.

Transient state therefore keys on RuntimeKey — (profile, page, folder path, key index, deck id) — so the same index inside a folder or on another device is a different key from the one on its page, and clocks inside a folder keep running while you are elsewhere. _discard_folder_state() drops every toggle, clock, timer sound, execution control and gesture under a folder slot when that folder is cleared, pasted over or moved; editing the folder key itself keeps them, so renaming a folder does not reset the timers inside it. Pressing a folder key calls open_folder() directly and never reaches an action worker.

Anything walking a key's actions or assets must recurse with _walk_keys() / _walk_raw_keys(): _key_configs(), both bundle exporters (through the shared _bundle_key_icons() / _bundle_key_audio()), import_key_bundle() and rename_page() all do. Missing one silently loses a folder's nested audio, icons or nav.page.go rewrites.

The editor offers Folder as a key type, hides it once the depth limit is reached, and locks the whole type list while the stored key is a folder that holds keys, so its contents cannot be replaced by switching type. Clearing, pasting over or importing onto a non-empty folder asks for confirmation first. The AI catalogue never exposes folders: ai/service.py still restricts proposals to single, multi and toggle.

Key label font size

KeyConfig.font_size and KeyConfig.font_size_off hold one of the IDs in KEY_FONT_SIZE_CHOICES: "" / Automatic, xs / Extra small, s / Small, m / Medium, l / Large, xl / Extra large. Like an empty icon, the empty value is an inheritance marker, not a missing value: renderer._label_font_size() then derives the size from the key height (max(10, h // 6), the historical automatic size). Every named ID is a divisor of the key height in FONT_SIZE_DIVISORS, so a chosen size keeps its proportion on any key geometry, and no resolved size drops below MIN_FONT_SIZE.

KeyConfig.from_dict() normalizes the stored value and falls back to automatic for anything unknown, so old configurations and hand-edited files stay loadable. The controller passes font_size through every _key_spec variant; a toggle's OFF state uses font_size_off or font_size, mirroring how label_off falls back to label. The badge keeps its own fixed size, and because the label box grows with the font, the icon and any centered clock value shrink to fit.

Key label colour

KeyConfig.text_color / text_color_off follow exactly the same inheritance rule: empty means the renderer's own TEXT_COLOR, so a key that never chose one renders identically and clearing the choice restores inheritance rather than freezing today's default into the configuration. _text_color() validates the stored value to #rgb / #rrggbb at load, because a hand-edited file would otherwise reach Pillow mid-render, on a worker thread, once per repaint.

compose() resolves it once into ink and uses it for the label, a centered value such as a clock, and the badge. The badge is included on purpose: leaving it white would preserve the very problem the setting exists for, since a pale background hides a white badge exactly as it hid the label.

AppearanceBox blocks its own notify::rgba handler while showing the inherited colour. Without that, displaying the default is indistinguishable from the user picking it, and clearing the choice would immediately write the current default back into the key.

Reordering and duplicating pages and profiles

move_page(), duplicate_page() and duplicate_profile() all shift what a stored index means, so each clears the transient toggle/clock state and the undo history exactly as delete_page() does. What they must not disturb is nav.page.go, which targets a page by name: moving a page changes no name, and Profile.clone() keeps every page name so a copied profile's navigation points inside the copy rather than back at the original. unique_name() supplies the "Live copy", "Live copy 2" sequence — duplication is the one operation where a clashing name is guaranteed rather than possible, so the user is never asked.

_shifted_index() keeps the page the user is looking at on screen after a move; the arithmetic is worth keeping because every off-by-one there silently switches the visible page.

Redo

_apply_entry() restores an undo entry and returns the entry that reverses it, which is what makes undo and redo mirror images: a change can be walked back and forward any number of times without the two stacks drifting apart. _redo is bounded by the same UNDO_DEPTH, cleared by _record_undo() (a new change branches away from the undone future) and by forget_undo(), and gated by can_redo() on the same container check as can_undo().

Layout sheet

device/layout_sheet.py composes a whole profile into one printable PNG: every page, then every folder inside it, with each key drawn by the real compose() and captioned with what it does. The caption is the point — an icon-only key is unidentifiable away from the hardware. It takes columns/rows from the connected deck rather than assuming 5x3, and it deliberately never consults live OBS feedback, or the same profile would render differently every time.

_draw_caption() trims against the room left once the ellipsis is paid for. Testing whether text + "…" fits on each pass never succeeds: by then the bare text fits too, the loop ends, and the caption is left cut mid-word with nothing to show it was shortened.

Rotating configuration backups

Alongside the single-step config.json.bak, rotate_backups() keeps up to BACKUP_KEEP timestamped copies under CONFIG_DIR/backups. BACKUP_MIN_INTERVAL is the whole point of it: saves are frequent — a page switch, a brightness change and every key save is one — so without a floor between snapshots a burst of saves would push every older state out of the history at exactly the moment it becomes worth having. import_bundle() and restore_backup() pass force=True, because replacing the entire configuration is precisely when the previous one is worth keeping. Rotation never raises: losing a backup must not stop the save it precedes.

Two details there are load-bearing and easy to undo:

  • The history is ordered by modification time, not by name. Two snapshots can land in the same second — a restore forces one immediately after the save before it — and _free_backup_path() disambiguates with a -NN suffix. Any such suffix sorts before the plain name (- precedes .), so name order would report the older copy as the newest.
  • It copies with shutil.copy, not copy2. copy2 preserves the source's timestamp, which would both break that ordering and label every backup with when the configuration was last edited rather than when the copy was taken.

describe_backup() reads each file to report its profile, page and key counts, because a row showing only a filename cannot tell you which copy you want. restore_backup() refuses any path outside BACKUP_DIR, keeps the keyring password of this computer exactly as importing does, and goes through _adopt_replaced_configuration() — the same runtime reapplication as an import, since the whole configuration has just been replaced.

Checking keys against OBS

Renaming a scene in OBS is the one failure this application never reported: the key stays where it is, with its icon, and silently does nothing when pressed. core/references.py finds those keys, and Param.choices_source is what makes it possible — a parameter that fills from scenes holds a scene name, so the metadata already says what points at what.

It never runs by itself, and the scope is where you are. Switching scene collection replaces every name at once, so a checker that ran on its own would report the whole page as broken on every switch, and be ignored by the time it mattered. The user starts it, standing in a page or a folder, and therefore reads the result in the right context. check_references() walks the current grid plus everything nested inside it, with the page's dials in scope only at the page root, since dials belong to the page.

The report names the collection it compared against, because obs-websocket can only list what is loaded: there is no way to ask what an unloaded collection holds without switching to it, so no other answer is honest.

Four rules keep it from crying wolf, which is the only way this feature fails:

  • Param.advisory marks a value that is never sent to OBS. The audio actions' scene only narrows the editor's input list, so a rename leaves it stale but breaks nothing, and reporting it would be a false alarm.
  • Unknown is not missing. _ObsNames.available() returns None rather than an empty list when it cannot judge, and check() skips those. A source whose scene is itself gone is unknowable, and reporting both would count one rename twice and offer a fix that could not work.
  • Each list is fetched once per check. A page can hold a dozen references to one scene, and every lookup is a request through the one serialized connection.
  • The report says how much it looked at. A checker that only speaks up on failure never tells you it ran.

Repointing rewrites parameters across more keys than the undo history is scoped to hold, so the safety net is a forced backup instead — which is why apply_reference_fix() saves the configuration first when none is on disk yet: rotate_backups() copies that file, and without one the promise the dialog makes would silently be false.

Live scene previews on a key

obs.scene_switch and obs.scene_preview take a preview parameter — off, once a second, or twice a second — that turns the key into a live thumbnail of what that scene is showing. It is off by default and chosen per key on purpose: every capture makes OBS render and encode the scene, on the machine that is also encoding the stream, so the user decides which keys are worth that. The point is not decoration. A scene whose capture died renders black, which is visible before cutting to it rather than live on air.

feedback() gained an image key for it, carrying raw JPEG bytes, which _single_spec passes to compose().

Four things there are load-bearing:

  • OBSClient.source_thumbnail() caches per source and size, and takes the staleness the caller will accept, so a key at two frames a second and one at one frame a second share a cache without either being held to the other's rate. Six keys on one scene cost one capture. _thumbs_pending stops a render burst from starting the same slow capture twice, and the request is made outside the cache lock so no other key's feedback queues behind it.
  • A failure keeps serving the last good frame, and unreadable bytes fall all the way back to the ordinary key. A live picture is decoration over a key that must keep working; it may never leave a blank square.
  • The label needs _scrim(), and its ramp is not linear. A straight gradient is still nearly transparent where the top of the glyphs sits, so a pale scene left the first row of the label at almost its own brightness — measured at 226 of 255. SCRIM_RAMP below 1 darkens early and then flattens, which brought that to 116 while leaving the picture above untouched.
  • The active state changes shape. Lightening the background is invisible over a photograph, so a previewing key marks the live scene with an accent border instead.

Calling an HTTP endpoint from a key

web.request is deliberately the widest action in the catalogue. A great many single-purpose plugins in the Stream Deck ecosystem are one REST call with an icon on top — home automation bridges, dashboards, webhooks, CI servers, uptime monitors — so an endpoint plus a value read out of its answer covers all of them at once, and covers the next one nobody has written yet.

core/webrequest.py holds the network side; web_actions.py is the action. Four things about it are load-bearing:

  • feedback() never performs a request. It runs on the render worker, so a round trip there would hold that worker for the latency of the internet on every repaint — the same reason TwitchClient.channel() performs none. cached_value() answers from the cache and schedules its own fetch on a small executor of its own; _pending stops a burst of renders from starting the same fetch several times.
  • min_age is what keeps a repaint from being a request. Repainting is what makes the key ask again, and _live_loop is not the only thing that repaints a key — an obs.state storm or a page change does too. The value is only asked for again once it is older than the interval that key chose.
  • Both staleness bounds do different jobs, exactly as Twitch's do. The chosen interval is when a value is worth replacing; VALUE_STALE_SECONDS is when it stops being shown at all, so a blip keeps the last good reading and a sustained outage blanks the key rather than leaving a number that stopped being true. A failed fetch therefore keeps the previous value rather than wiping it.
  • A response is data from outside. Only http and https can be reached (a typo must not be able to open a local file), the body is capped before it is read, and extract() reduces it to one short string — answering "" for anything that does not resolve rather than raising, because a key showing nothing is a far better failure than a key that goes red every second.

It is in BLOCKED_ACTIONS beside sys.command and obs.raw, and for the same reason: each can reach something outside this application on the strength of a string, and somebody reviewing an AI proposal cannot be expected to audit a URL.

webrequest.shutdown() runs last in _on_shutdown, after the controller, since the controller's workers are what ask for a value.

webrequest.background() is public for the same reason. Anything drawing a key has this module's problem — feedback() is on the single render worker — so one executor serves them all rather than each growing its own thing to remember to stop. core/keylight.py is its other user. It answers whether the work was accepted, so a caller can release its own marks when it was not.

Home Assistant

The third service, and the smallest. web.request could already call these endpoints, so this exists for the one thing it cannot do: fill a dropdown with the entities that server actually has. Typing light.kitchen_ceiling_2 from memory is where the real failures live — a key that saves cleanly, looks configured and does nothing when pressed.

Only base_url lives in Config. The long-lived access token goes to Secret Service through HomeAssistantTokenStore, exactly as the OBS password, the AI provider keys and the Twitch tokens do, so it never reaches config.json, its backup or a .lsdconfig export. The address does travel, unlike the Twitch tokens and unlike autostart: a configuration moved to another computer in the same house still points at the right box.

There is deliberately no login flow. Home Assistant's is built around a browser redirect to a registered address, which a desktop application on somebody's LAN cannot offer, and asking for the account password instead would be worse than asking for a token the user can revoke on its own.

Five things are load-bearing:

  • cached_state() performs no request, like TwitchClient.channel() and web.request before it. It runs on the single render worker, and the server is a box in the house. STATE_TTL and STATE_STALE are the same pair of bounds: a blip keeps the last state, an outage blanks the key, and a state that never arrived draws nothing rather than off — a light that looks switched off while it is lighting the room.
  • A service call is addressed to the homeassistant domain, not the entity's own, which is what makes one key work on a light, a switch, a fan and a media player without knowing which it is.
  • "On" is an exclusion list, not an inclusion one. OFF_STATES names what counts as off and everything else is on, because no list of on states can be complete: a media player is playing, paused, idle or buffering, a vacuum is cleaning, a cover is opening. The first version enumerated the on ones and read an idle Chromecast as switched off. Home Assistant's own media player toggle settled on the same rule in 2025. Unknown counts as off, so a key never claims a device is on because nothing answered.
  • A toggle is resolved here, never sent as homeassistant.toggle. _resolve_toggle() reads the state and picks turn_on or turn_off from is_on(). The generic service reads a media player sitting idle as off and turns it on again, so a Chromecast key toggled on and never off. A scene or script is run rather than toggled — it has nothing to toggle — and that short-circuit is load-bearing for a second reason: a scene's state is the timestamp it last ran, which is_on() reads as on, so without it a scene that had ever run would resolve to turn_off and do nothing.
  • The answer to a service call matters as much as the call. Home Assistant accepts turn_on for any entity and returns 200 whether or not the device can do it — a Chromecast that cannot be woken is the case that found this — so a key checking only for an error reported success while nothing happened. The REST API hands back the states that changed while the service ran, and _new_state() reports the target's. An empty list is deliberately not called a failure: an entity already on does not change either, so the key states the fact and leaves the verdict to whoever pressed it.
  • _clean_url() accepts the three shapes people paste. With a trailing slash or an /api suffix left on, every path becomes http://box:8123/api/api/states, which fails with a 404 that says nothing about what went wrong.
  • A refusal becomes a sentence. _explain() turns a 401 into "create a new token" and a 404 into "that address was not recognised"; the status code alone sends somebody hunting.
  • Listing entities also updates every state it carried. The answer holds them all, so refreshing the editor's dropdown is a free update of everything a key on that page might be drawing.

A press shows its result at once, without a round trip. The service call's own answer carries the state the entity ended in, so act() stores it rather than dropping the cache: without that the key blanked after every press and waited for a refetch, which is exactly the gap in which somebody presses again thinking it failed. When the server does not say, the read-back happens on the action worker, so the key keeps showing RUN until the answer is in instead of going blank mid-way.

A state that really changed repaints the deck at once. _remember() calls on_change — wired in app.py to controller.refresh — but only on a real change: most polls find the same value, and refreshing per poll would redraw every key on the page for nothing. Without it a light switched from a phone took up to the full live-loop interval to show on the deck.

Both HA actions join _live_loop. ha.state on its own interval, ha.switch on the fixed HA_SWITCH_REFRESH_SECONDS. Without that second one the switch key fetches its state in the background and then nothing ever repaints it, so the key never shows whether the entity is on — and it has to notice a light somebody turned off from their phone anyway. Its on state is coloured, not merely active: lightening alone rendered almost indistinguishable from an idle key at 96 px, and telling on from off at a glance is that key's whole job.

Both actions set requires_home_assistant, the third service in _action_blocked() beside OBS and Twitch, so a key that cannot work fades rather than looking idle. ENTITY_TTL exists because the editor fetches that list on the GTK thread while building a row, and a house has hundreds of entities.

A machine measurement on a key

sys.stats shows CPU, memory, GPU, temperatures, network throughput and free disk space, all read from /proc and /sys by core/sysstats.py. It lives in System rather than under OBS because a key showing GPU temperature has nothing to do with OBS and must not have to be looked for there, and it shares STAT_METRICS's table shape: one entry per measurement rather than a branch in three methods.

The two overlapping metrics stay in obs.stats as well. Moving them would silently change what every key already configured with them shows, and a duplicate dropdown entry costs nothing next to that.

Six things here are load-bearing, and each is a way of being wrong that looks right:

  • Memory used comes from MemAvailable, never MemFree. Free memory on a healthy Linux box is nearly zero because the kernel spends it on cache, so a key built on MemFree reads 98 % on a machine doing nothing at all.
  • Throughput is a difference between two readings, like cpu_percent(), so the first call answers None rather than an average since boot. A counter that went backwards — an interface reset or replaced — counts as no traffic, since reporting that jump as a rate shows an absurd spike.
  • The temperature is the processor package, not the hottest core. One core spiking is normal and says nothing. The package input is found by label, and the fallback matters: sorted() puts temp10_input before temp1_input (because 0 sorts before _), so a version that simply took the first labelled input looks correct on a tidy fixture and reports a core on real hardware.
  • A measurement the machine does not report answers None, and the key shows a dash. A zero is a claim: it says the GPU is idle rather than absent. An integrated chip has no busy counter and plenty of desktop boards have no package sensor.
  • A metric with no threshold is never painted, not even green. Green reads as "checked, and fine", which is a claim nothing measured.
  • Throughput is shown in bits. A connection is sold in megabits, so bytes invite exactly the wrong conclusion about whether a line is keeping up.
  • The interface dropdown shows what the device is, not its name. enx00e04c3676eb is derived from a MAC address and tells nobody anything. interface_label() prefers the USB product string the device publishes ("Realtek USB 10/100 LAN"), falls back to the kind of link read from the kernel — the wireless directory or the uevent DEVTYPE, never the name — and keeps the interface on the label either way, since two adapters of one model would otherwise be identical in the list. A PCI card publishes only numeric vendor and device ids, which would need a hardware database to read, so those get the link kind. The key still stores the interface name: it is what /proc/net/dev is keyed by.

nvidia-smi is a process (measured at ~30 ms), so its reading is shared for GPU_INTERVAL exactly as the disk one is: a page of keys must not each spawn one. AMD is read from gpu_busy_percent in sysfs instead, and an integrated Intel chip reports neither, which is why every field is optional.

Elgato Key Lights

The other half of the Elgato desk with no Linux software at all, and whoever owns a Stream Deck very often owns a Key Light. They run an unauthenticated HTTP server on port 9123 answering GET/PUT on /elgato/lights, so this needs no account, no cloud service and no vendor library.

Two things about the protocol are silent when wrong:

  • Temperature is in mireds, not kelvin. The device takes 143 to 344, which is 1000000 / kelvin, so a higher number is a warmer light. The actions speak kelvin and kelvin_to_device() converts. LightTemperature therefore inverts twice — "warmer" is fewer kelvin, and the device's unit runs the other way again — and both inversions are deliberately in one place. The two ends are written down rather than derived: 1000000/7000 is 142.86 and the firmware's floor is 143, so the formula is off by one at exactly the end a "coolest" key lands on.
  • A PUT replaces the fields it names. apply() sends only what it was asked to change; resending a whole light object would push back whatever was last read, turning a light on again because a brightness key remembered it that way.

discover() shells out to avahi-browse -rpt _elg._tcp, whose parseable form is =;iface;protocol;name;type;domain;host;address;port;txt. Only resolved (=) IPv4 lines are kept: a link-local IPv6 address cannot go in a URL without a zone index, so a light with no published host name must not fall back to one. A key stores the published host name, since an address comes from DHCP and changes; avahi resolving it is guaranteed by having found the light at all. Without avahi-utils discovery answers nothing, the field stays a plain text entry, and an address can still be typed by hand.

Three bounds matter. DISCOVERY_TTL exists because the editor fills that dropdown on the GTK thread and avahi-browse -t takes about a second even when nothing answers. STATE_TTL and STATE_STALE are the pair web.request and the Twitch snapshot both use: a light behind a slow access point keeps its last state rather than flickering, and one that stopped answering goes unknown rather than being drawn as off — a light that looks switched off while it is lighting the room is worse than a key showing nothing.

Live OBS statistics on a key

obs.stats draws a live measurement — dropped frames, bitrate, congestion, stream or recording time, free disk, OBS and system CPU, OBS memory, FPS, skipped render frames — through the display feedback the clocks already use. STAT_METRICS holds each one's label, reader, formatter and thresholds in a single table, so adding a metric is one entry rather than a branch in three methods.

"CPU usage" is two different measurements and both must say which. GetStats.cpuUsage is the OBS process, the number OBS's own Stats window shows; a system monitor shows the whole machine. Labelling the first plainly "CPU usage" makes anyone comparing the two conclude, reasonably, that the key is broken. So OBS's is "OBS CPU usage" and core/sysstats.py reads the machine's from /proc/stat as "System CPU usage". That reading is a difference between two samples, so the first call after start-up answers None rather than reporting use since boot, and MIN_INTERVAL keeps a second caller from measuring noise across a few microseconds.

A metric that does not come from OBS sets needs_obs = False, which both Stats._read() and DeckController._live_interval() honour: a kernel reading keeps working, and keeps being repainted, while OBS is closed. That is also why _live_loop() has no obs.connected guard of its own. Such a reader is handed the key's own parameters in place of an OBS sample, since what it needs is configured rather than reported.

Free disk space is a kernel reading for a reason of its own. OBS reports it as GetStats.availableDiskSpace, but only while OBS is running, and "have I got room to record" is asked before opening it at least as often as during a session — so the key that answers it must not go blank with OBS. disk_free_mb() reads the filesystem directly and client.py deliberately no longer carries availableDiskSpace into the sample, so there is one source rather than two that can disagree.

Which filesystem is asked comes from the key's own disk_folder parameter (kind="file", directory=True), blank meaning the home folder. It is asked rather than taken from OBS's recording path on purpose: a key that measured home with OBS closed and the recording drive with it open would silently be showing two different numbers, which on a warning key is worse than showing nothing. disk_folder() resolves blank and ~; a folder that does not exist answers None rather than being walked up to its nearest existing parent, because an unmounted recording drive would then report the root filesystem's free space. Readings are cached per folder for DISK_INTERVAL and the cache is bounded by DISK_CACHE_LIMIT, since a page can hold several keys watching one drive.

Percentages go through _percent_text(), which keeps one decimal below 10. Whole numbers threw away most of the information exactly where these values live: an OBS process at 1.4% and one at 0.6% both printed "1%".

Two things about it are unlike every other action:

  • Its value changes with nothing happening. No bus event announces a rising frame counter, so _live_loop() repaints it on a clock — the same loop that drives live scene previews, since they are the only two kinds of key with that problem. _live_keys() returns each visible one with the interval it asked for, and the loop ticks at LIVE_TICK_SECONDS, which must stay at least as fast as the quickest rate any of them offers. That thread submits to the render executor, so it is joined beside the activity thread and before that executor stops. Only a single-action key ever qualifies: feedback is resolved for a key's own action, never for a step inside a list.
  • Several keys can ask at once. OBSClient.stats() caches one sample per STATS_INTERVAL behind its own lock — never the request lock, which would serialize the whole client behind a display refresh. Without the cache, a page showing six statistics keys would fire eighteen websocket requests per repaint, through the one serialized connection, competing with the feedback of every other key.

Bitrate is derived, not reported: OBS only exposes total bytes sent, so the rate exists solely as a difference between two samples. The first sample after connecting or going live has nothing to compare against and reports nothing rather than an absurd spike from a counter that started at zero.

Keyboard navigation of the key grid

neighbour_index() walks the deck's own numbering, so left and right lead from the end of one row into the start of the next — the grid is one sequence of keys drawn in rows, not a set of independent rows. Up and down stop at the edges; wrapping them would throw the focus to the far end of the deck.

Focus is not selection. Selecting runs the unsaved-change guard, so moving the focus with an arrow key would raise a dialog per key press; activating a key still selects it. _on_grid_key_pressed() returns True even at an edge, because falling through hands the focus to GTK's own directional search and leaves the grid entirely. It is a scoped, bubble-phase Gtk.EventControllerKey on the grid — not the broad Gtk.EventControllerLegacy hook that §5.15 forbids.

The editor panel width, and why it may only change when asked

The window's content is a Gtk.Paned: the grid box on the left, the editor on the right, with a handle the user drags. The width they leave it at is Config.editor_width, clamped to MIN_EDITOR_WIDTH / MAX_EDITOR_WIDTH on load and serialized like brightness.

A drag is the only thing that may move it. Before the pane, the panel had no hexpand of its own, so it inherited one from its contents — and its Save button hexpands while the whole button row is hidden by clear(). The panel stopped expanding whenever no key was selected and started again the moment one was loaded. The grid box went from receiving all the spare width to half of it, and since it is halign=CENTER it slid left by a quarter of that: 247 px measured at 1920, against a 96 px key. The pointer ended up two keys from the one it had just pressed, so the second click of a folder's double-click landed somewhere else. self.editor.set_hexpand(False) is what fixes that, and it has to stay explicit: any widget whose expand flag can follow its contents will renegotiate the layout under whatever the user was pointing at.

Four details:

  • The deck is what stops the handle. shrink_start_child and shrink_end_child are both False, so neither side may go below its own minimum — and the grid box's minimum is the key grid. The cap is therefore the deck itself rather than a number kept somewhere that could drift from the model connected.
  • resize_start_child alone. A window resize is absorbed by the grid area while the panel keeps the width it was given, which is what makes a remembered width mean anything on a window that opens maximized.
  • The position is restored once, and it needs a retry. A pane position is a distance from the left edge, so it cannot be computed until the pane has a width — and it has none at map, because allocation comes afterwards. Hence RESTORE_TRIES, and hence the bound on it: an unbounded retry would spin, and the honest fallback is to open at the minimum and be dragged once. It runs once because map fires again when the window returns from the status area, and re-running it would discard a width set since.
  • The drag is saved when it comes to rest. notify::position arrives per pixel of pointer movement and every save is a file write plus a backup rotation, so EDITOR_WIDTH_SAVE_MS debounces it. A position change arriving before the restore is ignored: GTK assigns the pane one of its own during the first allocation, and storing that would overwrite the remembered width with a default nobody picked.

The panel's scrollbar is not an overlay one. set_overlay_scrolling(False) in EditorPanel. An overlay scrollbar is drawn over the content instead of taking width of its own, which put it on top of the right-hand end of every row — the arrow of each dropdown, the action picker's search button. That is fine over a document, where the text simply continues behind it, and wrong over a column of controls whose last few pixels are the part you click.

Running key feedback and workers

Multi and multi-toggle invocations, plus single actions with running_feedback = True, are counted from queueing through completion, keyed by the device-scoped RuntimeKey. While the count is nonzero, that key shows a RUN badge and a subtle slow blue breathing halo. Repeated presses increment the count; for restartable sequences, canceled running or queued invocations remain counted until they finish unwinding, so feedback stays visible across the replacement. Toggle keys keep their ON/OFF state underneath the temporary running feedback and restore the appropriate badge when the final invocation completes.

Action execution and rendering use separate ThreadPoolExecutor instances. Two long Wait actions may occupy both action workers, but must never prevent a key image or activity pulse from rendering. The activity thread alternates the pulse phase and asks the render executor to refresh only busy keys on the active profile/page; activity belonging to another profile or page must not leak into the current view.

Controller shutdown order is deliberate: set the stopping signal, stop and clear the clock runtime/completion sounds, shut down the action executor, shut down the timer-notification executor, join the activity thread, then shut down the render executor. Wait observes both the stopping signal and per-invocation cancellation through its ActionContext, so shutdown and same-key replacement can end it promptly. Do not merge the executors or reorder teardown in a way that lets actions, clocks or activity submit work after their destination executor has stopped.

When a key cannot do its job

Three separate silences used to leave someone pressing a key and watching nothing happen. All three are now answered on the deck itself, because that is where the user is looking.

A failing action marks its own key. _note_failure() records the key and a deadline in _failed, and _mark_key_state() turns that into failed=True plus the ! badge on whatever spec the key would otherwise have produced. renderer.compose() draws it as a border, last, over artwork and live previews alike — a background change is invisible on a key carrying a scene thumbnail. The mark is expired by the activity thread, not by a timer of its own: that thread already pulses running keys, so _busy_loop() gained _expire_failures() and now also stays awake while anything is marked. It must keep repainting the keys that just expired even on the tick where it goes back to sleep, or a mark stays on screen until something else repaints the key.

The mark is transient state keyed by RuntimeKey, so it is dropped everywhere the others are: editing, clearing, pasting, swapping, discarding a folder's subtree and every page/profile change. A run still in flight keeps its RUN badge, because that is the more useful message while it lasts.

The reason this exists rather than a status message: _flash_status() shows a message for five seconds in a window that close_action = tray normally keeps hidden, and the traceback went to a stderr that a session autostart discards.

A key that cannot work does not look idle. Action.needs_obs says whether an action can do anything without the connection, and Action.requires_obs(params) lets a key answer per configuration — obs.stats overrides it, since free disk space and system CPU come from the kernel. _mark_obs_dependency() in obs/actions.py sets the flag from the category rather than on each class, so an action added later cannot forget it. DeckController._unavailable() fades a key only when every action of it is blocked — by a missing OBS connection or, since _action_blocked() also checks Twitch, Home Assistant and Action.is_available() (see The unavailable fade covers services and local targets below), by any of those: one that mixes a blocked action with a working local one still does half its job, and an unregistered action is unknowable, so neither is faded. compose(unavailable=True) blends the finished key toward black last, so nothing on it can escape the fade and look usable. The keys come back on obs.state, which the client emits on connect and disconnect alike.

A list can stop at the first failure. KeyConfig.on_error is ON_ERROR_CONTINUE (the default and the old behaviour) or ON_ERROR_STOP, and _stops_on_error() carries it into _submit_steps / _run_steps. Continuing is right for a list of independent things done together; stopping is right when the list is a recipe, because "switch scene, wait, start recording" must not record the wrong scene after the switch failed. The message says which happened, in one status emit rather than two — a second would immediately overwrite the first. Only list kinds offer it (ERROR_POLICY_KINDS in ui/editor.py): a single action has nothing left to abandon, and _on_error() normalizes anything unknown back to continuing, so no stored key changes behaviour by being loaded.

The log is a file. CONFIG_DIR/linuxstreamdeck.log, rotated by _add_file_logging() at LOG_MAX_BYTES with LOG_KEEP older copies, and reachable from the menu as Open log file. Failing to open it must never stop the application: a read-only home is a reason to lose the log, not the deck.

Pre-flight check

obs.preflight answers, on the deck, the question asked in the minute before going live. core/preflight.py holds the checks; obs/actions.py drives them and paints the board; ui/preflight.py carries the sentences.

Nothing here has a side effect and nothing here overclaims. Those are the two rules, and both were arrived at by measuring rather than by reasoning:

  • UNCHECKED is a result, not a gap, ranked above OK in STATE_ORDER, always named in Report.summary(), drawn with its own colour, a ? badge and unavailable=True so it fades. Several checks can only be answered on the machine OBS runs on (is_local()), and a check that was quietly absent would look exactly like one that passed. There is deliberately no verdict word: a board is honest, "READY" is a promise this cannot keep, and the first time something broke with everything green it would be worthless for ever after.
  • Every detail states its own limit, because a key carries two words and the dialog carries the sentence.

Audio is judged on the mute state, not on the level. muted_inputs() answers a fact; a level does not. OBS meters read before the mute, measured live at -49.8 dB on an input that was muted, so a level never meant it would be heard. And silence is not a failure: someone running a pre-flight is usually not talking, so treating a quiet room as a dead microphone would cry wolf on nearly every run. Muted is FAIL, silence with nothing muted is WARN telling you to say something and try again.

Audio levels use a second, short-lived EventClient. OBSClient.measure_audio() opens one subscribed to Subs.INPUTVOLUMEMETERS — a high-volume subscription the normal connection deliberately never asks for — listens, and closes. That separation is not tidiness: the second socket only receives, so it makes no request and can never touch the _lock that must serialize every request (§5.3). Measured live: 20 events a second, exactly as the protocol documents. It returns None rather than an empty mapping when no events arrived at all, and that distinction is the check: meters arriving and flat means a dead microphone, while no meters means the question went unanswered.

Cameras are checked against the kernel, never against the picture. The obvious version — screenshot the source and call a black frame a dead camera — was tried against a real setup and reported two of three working cameras as dead. GetSourceScreenshot does render inactive sources, but at the moment someone runs a pre-flight the program scene is a holding card, so a black frame means nothing. Instead capture_sources() reads each V4L2 source's device_id and held_video_devices() reads /proc/<obs-pid>/fd: on Linux OBS never releases a V4L2 device when the source is hidden (there is no "deactivate when not showing" for V4L2), so a healthy camera has its device open even with the deck on a black scene. Resolve symlinks on both sides — a source is commonly configured as /dev/v4l/by-id/... while the descriptor reports /dev/videoN, and comparing them as strings fails a working camera. That is a real bug that was caught live, and test_a_by_id_symlink_resolves_to_the_same_device pins it.

Comparing the deck's keys against OBS cannot be automated, so it is not. obs-websocket can only list the loaded scene collection, so a key belonging to any other one is indistinguishable from a key whose scene was renamed. On a real configuration with nine collections and a folder per collection, the automatic version reported 99 perfectly good references as broken — from the page root and from inside each folder, since only one collection is ever loaded. check_collection() replaced it: it states which collection OBS has loaded, which is decidable, useful (with nine of them that is exactly what gets forgotten) and judges nothing. check_references() stays as the deliberate tool, and its docstring now says why nothing may call it on its own.

The stream key must never leave stream_target(). GetStreamServiceSettings returns it in the clear; only a boolean crosses into check_stream_target(), whose signature is pinned by a test. The application writes a log file now, so one careless line would put someone's stream key on disk in plain text.

The board is a layer, not a device mode. DeckController.show_board() sets _board, which _key_spec() consults first; the screen saver owns the deck and needs a thread, a brightness and a wake press, and a second thing like that would fight it. A press dismisses the board instead of running the key under it, _clear_time_actions() drops it on every view change, and the action clears it in a finally — a report left up hides every real key.

dismiss_board() is the one way it is put away, and the hold watches for it. PreFlight._hold() polls board_active() beside its own deadline, so anything that clears the board ends the hold: a press on the deck, closing the report window, or leaving the page the report describes. Both halves are needed. The window's close-request calls dismiss_board, or the deck kept showing a report the user had read and closed for the rest of PREFLIGHT_HOLD — twelve seconds of a deck they could not use. And the hold has to notice, or the key goes on pulsing RUN over a report that is no longer there. The dialog must return False from that handler: claiming the signal stops the window closing at all.

sysstats.cpu_percent() is primed before the audio check on purpose: machine CPU only exists as a difference between two readings, and the two seconds the meters take is exactly the gap it needs.

Twitch

The second service this application talks to. It is in the tree rather than behind some extension mechanism because a deck built around OBS is built for people who stream, and Twitch is where most of them stream: it is the other half of the core use case, not a peripheral integration.

The authorization is the device code flow, and that choice decides the rest. Twitch offers it for clients that cannot keep a secret, which is exactly a desktop application. There is no client secret to ship, no redirect URI and no local web server listening on a port; the user reads a code off the dialog, types it at twitch.tv/activate, and auth.poll_for_tokens() waits.

Three things about that flow are easy to get wrong and are pinned by tests:

  • Twitch reports every device-flow outcome as an RFC 8628 error code in the message field of a 400, never through the status code. _is_pending() matches after _normalize() flattens separators and case, because the first build compared against the literal "authorization pending" while Twitch sends authorization_pending: the very first poll read a normal "not yet" as a refusal and abandoned the flow while the user was still typing the code. slow_down arrives the same way and means back off, not stop. Treating every 400 as pending loops forever on a revoked code; treating every 400 as fatal ends the flow the instant it starts.
  • An error code must never reach the user. describe_error() maps the known ones to sentences and answers GENERIC_DEVICE_ERROR for anything that still looks like an identifier, so a code Twitch adds later cannot leak either. Real prose — Helix answers in words, such as "Missing scope: clips:edit" — passes through untouched, because that text is worth showing.
  • Refresh tokens are single use. The one spent on a renewal is dead the moment the answer arrives, and that answer carries its replacement. _persist() therefore stores the new pair before it is adopted, and _renew_lock serializes renewals so two workers cannot spend the same token twice — the second would get a refusal that unlinks a perfectly good account.
  • A missing scope is also a 401, and renewing can never fix it. _is_scope_error() keeps that case out of the retry path, or a key needing a permission that was never granted would spend a single-use refresh token on every repaint.

Disconnecting revokes the access token, and nothing else, and the dialog says so. Twitch has no API to remove an application from the user's Connections page — only the user can, at CONNECTIONS_URL — so the dialog reports what actually happened plus a button to finish it there. Saying "disconnected" full stop would be exactly the unearned reassurance the pre-flight board exists to avoid, and it is checkable, because the application stays visible in Twitch's own list.

revoke() must never be given the refresh token. It was, once, on the reasoning that revoking an access token leaves the refresh token usable. RFC 7009 has an authorization server invalidate every token issued from the same grant, and Twitch does: disconnecting tore down the whole authorization, so someone who reconnected got a session whose EventSub subscriptions were revoked with authorization_revoked twelve seconds later and whose new scopes were refused with a flat 403. An access token expires by itself in about four hours; a torn-down grant does not repair.

For the same reason the revoke is guarded by _revoke_if_still_gone(). It runs off the calling thread so the dialog does not wait on the network, which means it can land after somebody has reconnected — and revoking then destroys the authorization they just granted. The account currently held is the one that matters.

The Client ID is public and is meant to be distributed. The device flow exists for clients that cannot keep a secret, and Twitch counts its rate limit per Client ID per user, so one shared identifier cannot let one person exhaust anyone else's budget. DEFAULT_CLIENT_ID holds this project's own registered application; a user who prefers their own overrides it from the dialog, and emptying it puts the dialog back into asking for one — which _client_id_help() already switches its wording for. Tokens live in Secret Service through TwitchTokenStore, never in Config, config.json, its backup or a .lsdconfig export. TwitchSettings holds only public, portable state: the Client ID, whether automatic categories are enabled/paused and bounded Steam AppID → Twitch category ID/name mappings.

TwitchTokenStore is synchronous, unlike SecretStore and ApiKeyStore. Its caller is not the GTK thread: the client reads and rewrites tokens from its own worker while renewing them, and a callback-based lookup there would need a main loop it does not have. It stores the pair as one JSON item so a refresh replaces both together. HomeAssistantTokenStore is synchronous for the same reason — the client reads it from its own worker too — so there are now two synchronous stores and two asynchronous ones, not one exception among three.

channel() never performs a request. It is called from feedback() while a key image is being composed, on a render worker, so a Helix round trip there would hold that worker for the latency of the internet on every repaint — far worse than the OBS captures that already had to be cached. It returns what is already known and schedules its own refresh on the client's single worker; _channel_pending stops a render burst from starting the same refresh twice. With no Twitch key on screen nothing calls it, so an unused integration costs nothing at all.

Two staleness bounds do different jobs. CHANNEL_TTL (20 s) is when a snapshot is worth replacing — Twitch aggregates viewer counts on its own side and does not move faster. CHANNEL_STALE (90 s) is when it stops being shown at all: a brief network failure keeps the last value, which is what makes a key feel steady, while a sustained one blanks it, because a number that stopped being true is worse than no number.

The category is a text field with live suggestions, resolved when the key is pressed. A dropdown was the obvious choice and cannot work: Twitch has tens of thousands of categories and the one a person streams is very often not in any list short enough to offer, so a list would fail at exactly the case the action exists for. Free text alone was not enough either — a typo was only discovered when the key was pressed, live. Param.completion_source is the answer, and it is a different thing from choices_source: the field stays a text entry and still stores free text, so a key configured before the suggestions existed still loads, while TwitchClient.search_categories() makes a real name easy to type.

_resolve_category() still runs at press time, and an exact name beats the search ranking — searching "Doom" also matches "Doom Eternal", and taking the first ranked result would quietly set the wrong game for anyone who typed the exact one. What Twitch matched is reported back in the status message, so a wrong guess is visible.

Automatic categories are an opt-in global policy, not key state. twitch/auto_category.py::AutomaticCategoryManager listens to the immutable obs.outputs snapshot and does no process scan or Helix mutation unless the policy is enabled, unpaused, the account has channel:manage:broadcast, and OBS reports streaming. Recording alone never qualifies. One daemon worker samples core.steam.running_games() every four seconds, requires a selected AppID to remain stable for twelve seconds, and updates through TwitchClient.set_category_id() so an already-mapped transition performs no search and cannot choose a ranked near-match.

Multiple games are deliberately asymmetric. One game is unambiguous; exactly one AppID newly appearing beside games already seen is the transition; an initial or otherwise ambiguous set leaves Twitch untouched. Closing the game never clears the category. A missing mapping runs one strict exact-name Twitch search: an exact result is saved automatically, while no exact result becomes a pending choice in Game mappings and is not searched again during that game session. Network failure is retryable with bounded exponential back-off and must never be turned into a pending/manual choice.

Mappings persist both the Twitch ID and display name below TwitchSettings and therefore travel in .lsdconfig; tokens still do not. UI mutations and exact matches save on the EventBus-dispatched GTK thread. The mapping editor reuses the off-thread category completion, then resolves the final text strictly: a ranked approximate result is acceptable for the deliberate Set category key, never for unattended automation. twitch.auto_category is the optional deck action: first press enables the global policy, later presses pause/resume it, and feedback is OFF, PAUSE or active AUTO.

A successful manual twitch.set_category marks the currently detected AppID as overridden; automation resumes only when another game is selected. Changes made outside LinuxStreamDeck are noticed from the cached channel category_id after a propagation grace period and receive the same treatment. Never compare the stale snapshot immediately after our own PATCH or the old category will be misread as a manual override. The manager stops after controller workers and before HID/OBS/Twitch shutdown, joins its detector thread, and unsubscribes all three bus handlers.

ui/steps.py::_CompletionPopup drives it, and none of it is about suggestions:

  • The search never touches the GTK thread. Each one is a request over the network, so it runs on its own worker and returns through GLib.idle_add.
  • COMPLETION_DEBOUNCE_MS is what keeps a rate limit from being spent on prefixes nobody meant to search for, and COMPLETION_MIN_CHARS drops the queries that would match most of the catalogue anyway.
  • A stale answer is discarded, by generation counter and by comparing against the field's current text. A slow reply arriving after the typing moved on would replace the current suggestions with older ones, which is worse than showing none. close() bumps the same counter, so work in flight cannot reopen a popup the field has finished with.
  • The popover does not autohide. An autohiding one takes the focus when it opens, which stops the typing that opened it.
  • A suggestion is taken on the press, through a CAPTURE-phase Gtk.GestureClick, never through a button's clicked. That signal needs the press and the release to reach the same widget while it is still mapped, and pressing moves the focus off the entry — which closed this popover, so the release landed on a widget that had gone and the suggestion was silently never applied. For the same reason _on_focus_changed() ignores a focus that landed inside the popover.
  • _place() sets the pointing rectangle explicitly and the popover is halign=START. Left alone, a popover points at the entry's whole allocation — shadow included, so wider than the visible field and starting a few pixels to its left — and centres itself in it, which reads as a floating window rather than as a list belonging to the line being typed in.
  • _choose() closes after setting the text. Filling the field fires changed, which would schedule a search for the very name just chosen and reopen the list under it.
  • Each row shows the category's box art, which is the point rather than decoration: a search for "detroit" answers "Detroit", "Detroit: Become Human" and "The Detroit After", hard to tell apart as words and immediate as pictures. The slot is reserved whether or not the picture arrives, so a slow or missing one cannot make the rows jump.
  • There are two counters, and conflating them is a bug that happened. _generation says which query is current and close() bumps it, so an answer for a field that stopped asking cannot reopen it. _fill_generation says which rows are on screen, and closing does not replace rows — sharing one counter meant every close threw away artwork for rows that were still there, and since a close fires on focus loss the list showed empty placeholders that never filled in. In _artwork_work that counter stops the downloads rather than the display: rejecting a picture on arrival is too late, it has already been fetched for a row that is gone.

Only a value the service recognises is stored. settled_value() is what _widget_value() reads for a field with suggestions, and it answers empty for text Twitch never offered. Free text that merely looks like a category is the worst case there is for this action: the key saves cleanly, looks configured, and fails the first time it is pressed — which here means live, on air. Saving nothing makes the key plainly unconfigured instead, and SetCategory.execute() turns that into a message naming the fix rather than an internal complaint.

Three details keep it from being destructive:

  • The value the field opened with is seeded into _known. It was either chosen from this list when it was set or it predates the list, so opening a working key and saving it must not empty it. Only what someone types now has to be recognised.
  • A name that was merely displayed counts, not just one that was clicked. Typing a category out in full is not a mistake, and the list showed it.
  • A field with no suggestions passes its text through untouched. With no account there is nothing to check against, and unknown is not the same as wrong — the same rule the pre-flight board follows.

settled_value() returns the name as Twitch spells it, so the configuration holds a real category rather than a near miss that only happens to resolve, and _mark_unsettled() warns in the field itself once a search has answered — half a typed word is not yet wrong, so it is not marked as it is typed.

Box art is fetched with http.request_bytes, which refuses any host that is not Twitch's. That address arrives inside an API response, making it data from outside rather than a URL this application chose, so ASSET_HOSTS is checked before anything is opened and the match is on whole labels — jtvnw.net.evil must not pass. MAX_IMAGE_BYTES bounds what a wrong or redirected address can stream into memory.

Twitch's own search shows a viewer and follower count per category; this cannot. Helix exposes neither, and the only source is Twitch's private GraphQL API — undocumented, outside the terms for third-party clients and free to change without notice. Showing the name and the artwork is what this can stand behind, and inventing the rest would be the same overclaiming the pre-flight board exists to avoid.

  • Without a linked account _completion_search() answers None and the field stays a plain entry, rather than one that never suggests anything.

Actions report failure by raising. The controller turns that into the key's own red border and a status message, which is where someone pressing a key is looking; catching the error and emitting a status instead would leave the key looking like it had worked.

The editor offers the connection the key needs. EditorPanel carries an Adw.Banner that appears while the draft holds any action whose requires_twitch() is true and no account is linked. It exists because the deck's own answer — a faded key that does nothing — is only half a message: the fix lived in a dialog under the profile menu, which is the last place someone configuring a key would look. ha_banner follows the same shape for requires_home_assistant(), and _refresh_service_banners() decides both together through the shared _draft_needs(predicate), since a key can equally be missing an unconfigured Home Assistant server — there the message matters more, because the failure is a silently empty entity dropdown rather than anything on the deck.

Three details keep it honest. Both banners sit above scroller, not inside body, so rebuilding the body for a new key type cannot destroy them mid-edit. Each is decided from the draft rather than the stored key, so picking a Twitch or Home Assistant action offers the connection immediately instead of after a save. And any one action is enough, unlike the fade, which needs all of them: a key that is half local still cannot do the other half, and the person editing it is the one who can fix that.

_on_step_changed() is the single handler behind every on_change= in the editor, so the icon preview and the banner follow the chosen action together. Note that _build_body() also refreshes explicitly: today the step widgets happen to report a change while being built, which covers it, but that is a signal from a child widget rather than a decision by this panel, and a build that produced no step widgets would otherwise leave the previous key's banner on screen.

Ad breaks, raids and announcements are the rest of the catalogue. start_commercial() answers the cooldown as well as the length, because Twitch refuses another break until it passes and a key that only said "started" would leave someone pressing it into a refusal. start_raid() resolves the channel name through find_user() (cached: the same few channels get raided again and again) and its status message says the raid still has to be confirmed in chat — Twitch opens a 90-second countdown rather than moving anyone, and a key that implied otherwise would be frightening to press. search_channels() sorts live channels first, since a raid goes to somebody who is streaming, and primes the same id cache so the raid itself costs no second lookup.

Each of those brought a scope, and an account linked before them holds a token that cannot perform them. That is not an error case, it is the ordinary upgrade path, and it is answered in three places:

  • The key fades. Action.twitch_scope names the one permission an action cannot work without, and DeckController._twitch_allows() checks it against missing_scopes(). A connection is not enough — without this the refusal arrives on the press, which for these actions means live. Only the affected keys fade; an authorization whose scopes were never recorded answers empty and is treated as unknown rather than blocked.
  • The refusal says what to do. Twitch answers "User access token requires the X scope." and stops. missing_scope_message() turns that into the sentence that follows it, and TwitchScopeError carries it. That type stays a TwitchHTTPError with its status, because _read_channel() tolerates a 401 from the optional follower scope and keeps the rest of the snapshot — raising a plain error there lost the viewer count along with it, which is a regression a test caught and now pins.
  • The pre-flight reports it, as a warning naming the missing scope.

Running ads also needs the account to be an Affiliate or Partner, and Twitch cannot be asked about it usefully. Its own issue tracker has /channels/commercial answering an ordinary account with a 429 cooldown it can never wait out — which is what someone hit while testing — and, worse, sometimes with a plain success for an ad that never ran. Neither can be read, so can_run_ads() establishes it from broadcaster_type on GET /helix/users instead: partner or affiliate yes, empty no, and None when it was never established, which is deliberately a third answer. Action.twitch_needs_affiliate carries it to the same fade as a missing scope, and start_commercial() refuses before sending. A lookup that failed must never disable a key that would have worked, so only a definite False blocks anything, and only the ad key: nothing else depends on it.

Live events: EventSub, and what is still waiting

twitch/eventsub.py holds one WebSocket subscribed to chat messages, follows, subscriptions, gifts, resubs and raids. It is the first persistent socket here other than OBS, and three of its rules are the kind that fail silently:

  • Silence is a failure. Twitch sends a keepalive whenever nothing else has, so going quiet past the session's own interval means the connection is gone while the socket still looks open. Only the watchdog notices; without it the deck reports nothing and looks perfectly healthy.
  • Subscriptions belong to a session, not to a connection. They are created against the welcome message's session_id — except across a session_reconnect, where Twitch carries them over and recreating them would deliver every event twice.
  • One refused subscription costs only itself. Somebody who never granted subscription reading must still get their chat and their raids, so a scope failure is logged per type; only a session that could subscribe to nothing at all is an error. Each entry of SUBSCRIPTIONS carries the permission it needs, because Twitch refuses one the token cannot have with a flat "subscription missing proper authorization" that names nothing anyone can act on. The log names the scope and one status message says how to grant it — otherwise the keys watching those events are simply silent, which is indistinguishable from nothing having happened yet.

channel.follow is version 2 — version 1 was withdrawn — and a raid condition may name only one side.

twitch/attention.py is deliberately not a counter. "3 messages" says nothing about whether somebody is being ignored; "somebody has been waiting four minutes" says exactly that, and it is the number that corresponds to the viewer who writes once, gets nothing back and does not return. So the wait is measured from the oldest unacknowledged alert, and urgency() turns it into the escalation the key draws.

One history is kept and each key reads it through its own acknowledgement, so two keys watching different things forget independently and pressing one never silences another. FORGET_SECONDS matters as much: a key that never goes quiet again is one nobody looks at. A key that has acknowledged nothing yet is NEVER_ACKNOWLEDGED, which is -inf rather than 0.0 — monotonic time counts from boot, so on a machine that started moments ago 0.0 is a perfectly ordinary timestamp and an alert stamped before it would be dropped as already seen.

should_sound() is the mailbox rule, not the keystroke one. A sound per message is unbearable the moment a chat wakes up, and the first thing anyone does about that is turn it off — which puts them straight back to missing messages. So it sounds when a key goes from quiet to somebody-waiting, and again only after an optional reminder interval. The sound goes on the notification executor, never an action worker, exactly like a timer's.

"Was this key already waiting" is asked of the alerts, never remembered as a flag. _on_alert() derives it from the key's own pending set minus the alert that has just arrived; _alerting holds only when a noise was last made, for the reminder. A flag has to be cleared when the key goes quiet again and nothing is in a position to see that: alerts expire on their own clock and the key is never told. The first version kept one, so a key nobody pressed made its noise once and then stayed silent for good — and the method meant to clear it on a press was never called by anything.

The whole deck can light up, on the same rule as the sound. flash is per key and off by default; when it is on, _start_flash() pulses every key between the colour of what arrived and black, FLASH_PULSES times. It exists for the case the sound does not cover — headphones, a game, somebody concentrating — and it rides should_sound() deliberately: a flash per message would be far worse than a noise per message, because a busy chat would leave the deck strobing without a pause.

The middle key says what arrived, in one short upper-case word. The colour alone says which of the four it was only to somebody who has learnt the four colours; a word says it to anybody. FLASH_WORDS holds them, _flash_center() finds the middle of whatever grid is connected, and FLASH_WORD_CHARS bounds them at six characters — that bound is the whole design. compose() fits centered text to the key's width, so a long word is drawn small, and measured at 72 px "MESSAGE" and "FOLLOWER" came out at roughly half the size of "CHAT" and "RAID": readable if peered at, which is exactly what somebody deep in a game is not doing. Hence CHAT / FOLLOW / SUB / RAID, and TWITCH for anything else.

The colour can be overridden per key, and defaults to the one per event. flash_color depends_on the flash being on, so the row is hidden rather than dropped and turning the flash off and on again keeps what was chosen. _hex_color() validates it to #rgb / #rrggbb in twitch/actions.py, not in the renderer: this value reaches Pillow on a worker thread, once per pulse, and a colour typed with a digit missing would raise there rather than simply not being used. Anything it rejects — including blank, which is the default — falls back to FLASH_COLORS.

A colour parameter that can be left inherited

kind="color" is _ColorEntry in ui/steps.py: a Gtk.ColorDialogButton plus a clear button. It exists because flash_color was a plain text field first, and that answered neither half of what such a field is for — nothing was shown while the value was inherited, and typing a colour by hand meant guessing both the format and the shade.

It follows AppearanceBox's text-colour contract, not its background one, and §5.24 is the reason: an empty value means inherit. So the button has to show the colour that would be used while the key stores nothing, and its notify::rgba handler is blocked while that colour is put on screen. Without the block, displaying the default is indistinguishable from the user picking that exact colour, and merely opening a key and saving it would freeze today's default into it. set_value("") is what the clear button gives back.

Which colour an empty swatch shows is Param.default_color, or default_color_source when a literal cannot answer. twitch_flash is the case: the flash colour is one per kind of event, so the honest swatch is the one belonging to what that key actually watches, and flash_color_for_choice() resolves it — falling back to FLASH_DEFAULT_COLOR for a choice like Everything that has no single answer. A followers key showing the chat blue would be a default that is simply wrong for it.

COLOR_SOURCE_PARENTS is what keeps the swatch current. It names the parameter each source reads, so _watch_dependencies() follows that dropdown for the same reason it follows a depends_on parent — otherwise switching a key from followers to raids leaves the swatch green until the key is closed and reopened. set_inherited() decides whether that changes anything on screen, and deliberately only it: a colour the user chose is theirs. That is enforced by value or self._inherited rather than by the short-circuit in set_inherited(), which is why mutating the short-circuit is equivalent — set_value() is idempotent.

The word's ink comes from renderer.contrasting_ink(), so a custom colour cannot make it invisible. A fixed white word disappears on the pale yellow somebody will inevitably pick; the helper is the hex form of the renderer's own _contrasting, which the alert key's outlined clock already uses.

Five things about it are load-bearing:

  • It stands aside from normal renders, and they stand aside from it. _standing_aside() is what refresh() and _render_keys() consult, and it now answers for the screen saver and the flash — without the second, an obs.state or a live-loop tick would paint real keys over half the pulses. _flashing is raised before the work is queued and cleared before the closing refresh(), or that refresh stands aside for a flash that has finished and leaves the deck black.
  • A frame is composed once, not once per key. A whole flash is three images: the lit key, the lit key carrying the word, and the dark key, each reused across every slot and every pulse. Composing per key would make one flash 6 × key_count renders — 192 on an XL — on the single worker that also draws every real key.
  • A sleeping deck is woken first. The saver owns the deck and the render guards would drop the flash entirely, and asleep is exactly the state somebody deep in a game is in, so the feature would fail precisely when it is needed. _wake_for_flash() uses the ordinary record_activity() path and then waits, bounded by FLASH_WAKE_SECONDS, for the saver's own thread to let go between frames. The consequence is honest and worth knowing: waking counts as activity, so the idle timer starts again from there.
  • One at a time. A second flash over the first would only cut it short, and a deck already flashing is already asking to be looked at.
  • It stays under three flashes a second. A Stream Deck is nowhere near the screen area photosensitivity guidance is written for, but staying the right side of that threshold costs nothing at all.

play_audio() takes a volume as a percentage and a stop signal it can call. Handing it volume / 100.0 and the _stopping Event — both of which look right — made every alert sound play at a hundredth of its volume and then raise TypeError on the first turn of its loop. Nothing noticed, because a ThreadPoolExecutor keeps a worker's exception in a Future nobody reads: hence _alert_sound(), which reports a failure the way the timer's sound does. A sound that cannot be played has to be as visible as one that can, or the key is silent in exactly the way it is silent when nothing has happened. Tests that assert the controller called something cannot see any of this; the ones that matter feed what it passed to the real play_audio.

events.py normalizes six payload shapes into one Alert. Note that a channel's first-ever message from someone is marked by Twitch with its own message type (user_intro) rather than a flag, and that is the one nobody can afford to miss — it is what the first filter is for. The filters are what let one key work on a channel of three viewers and one of three hundred: unfiltered, "somebody said something" stops meaning anything the moment the room is busy.

The alert key draws its urgency as a border, and breathes in its own colour. Both were arrived at by rendering it. A background colour is invisible behind the waiting person's avatar — the same lesson the failure mark already carries — so feedback() returns border and compose() draws it last, with a failure still outranking it. And the running-key pulse blends towards the accent, which turned a red "waiting five minutes" key into a calm blue; the pulse flag lightens instead, so the colour keeps saying what it said. Its phase comes from the clock rather than from the activity thread, whose own phase only advances while something is running.

feedback() reads cached_avatar() and never fetches: it runs on a render worker, and the picture was fetched when the alert arrived.

A value drawn over a picture is outlined, not scrimmed. The label gets _scrim() because it sits along an edge, where a gradient is invisible; the waiting clock sits in the middle of the subject, where a band would cover the very thing the picture is there to show. Twitch's default avatars are flat bright fields, and a white "15s" on a white one is not dim — it is gone. _outline_width() scales the stroke with the text so it reads the same on a Mini and an XL, and _contrasting() picks black or white against the ink rather than a fixed colour, because a key whose text colour was set to something dark needs a light outline. The outline is added only when there is a picture; a plain key needs none and looks heavy-handed with one.

Twitch in the pre-flight

Four checks: the account and its scopes, the title, the category, and whether Twitch already shows the channel as live. Three things about them are deliberate.

They run even when OBS is closed. Everything else in run() asks OBS something, so a disconnected OBS reports the rest as unchecked and returns — but Twitch is a different service, and "is my title set" does not stop being worth knowing because OBS happens not to be running.

They read the channel with refresh_channel(), not channel(). The cached one answers from up to twenty seconds ago, which is the right trade for a key repainting itself and exactly the wrong one for a decision about going live. This runs on an action worker and is asked once, so it can afford to block.

No account is UNCHECKED, never FAIL. Somebody who does not stream to Twitch has nothing wrong with their setup and the board must not tell them they have; the four rows still appear, because a check that is quietly absent looks exactly like one that passed. An unreachable Twitch is likewise unchecked rather than reported as a missing title — that would send somebody hunting for a problem they do not have.

Both check_twitch_title() and check_twitch_category() state their own limit in the detail, as every check here does: they establish that something is set, never that it is the right thing for today. Left on yesterday's game is the classic mistake and it is not decidable from here.

The unavailable fade covers services and local targets

DeckController._unavailable() replaced _needs_obs(). The documented rule is unchanged — a key fades only when every one of its actions is blocked, and an unregistered action is unknowable rather than unavailable — but the question is now asked per action rather than per connection, because a single key can mix them. _action_blocked() combines Action.requires_obs(params) against the OBS connection, Action.requires_twitch(params) against the linked account, and Action.requires_home_assistant(params) against the configured server, plus Action.is_available(params) for a missing local program or selected target — one rule.

The consequence worth knowing: a key holding a Twitch marker and an OBS chapter marker does not fade when OBS is closed, because it still places the Twitch marker. It fades only when none of the services a key needs is there. Conflating the conditions would fade a Twitch key whenever OBS merely happened to be closed.

twitch.stats repaints on the same _live_loop() that drives obs.stats and the scene previews, at TWITCH_REFRESH_SECONDS, and asks for nothing while no account is linked — repainting is what makes the key ask for a fresh snapshot, so with no account there is nothing to ask.

First run

Config.load() on a clean machine yields one profile, one page and no keys, so a fresh install opened on an empty deck and an editor to decipher. core/starter.py answers that, and two rules define it:

  • Offered, never imposed. app.py records is_first_run() before loading (loading is what creates the file), and MainWindow.offer_starter_keys() asks once, after the window is up, since the dialog is modal to it. apply_starter_keys() refuses a page that already holds anything, because the answer can arrive long after the question.
  • Every key works on arrival. That is the whole selection rule for STARTER_KEYS: nothing that has to be pointed at a scene or an audio input, on a machine where OBS may not even be running. They are ordered by how much someone wants them, because starter_keys(capacity) stops at the deck's key count — a Mini has six.

The OBS keys among them are faded until OBS is reachable, which makes the first run explain the fade rather than look broken.

Unsaved editor protection

EditorPanel.current_key_config() must reconstruct the complete canonical KeyConfig draft without persisting it. On load and after a successful save, the editor stores a cloned baseline and reports unsaved changes by comparing the current draft with that baseline. Do not replace this with a sticky dirty flag: action changes, parameter values/order, appearance and AI-loaded proposals must all count, while reverting every value must become clean again.

The editor retains both its source Page object and key index. A deferred Save and continue must write to that source page, never whichever page became active later. MainWindow gates key selection, key moves, page/profile switches and creation, configuration import and window close through the unsaved-change dialog. It offers Keep editing, Discard changes and, when the destination will preserve the saved key, Save and continue. Paste or clear of the edited key deliberately omits the save option because the next operation overwrites that same key.

Clicking the already selected key must not reload the editor. Page-change handling tracks the selected page by object identity so renaming that same page preserves the selection, while a real page change clears it.

A button whose result is not on the button

acknowledge_press() lights a button for PRESS_ECHO_MS and optionally replaces its text meanwhile. Only the editor's Save and its three Test buttons use it, and the reason is specific: this panel looks identical after either of them ran, so the only thing that moved was a line in the status bar at the far end of the window. Every other action button in the application closes its dialog, opens something or rewrites its own status line, and needs none of this.

The theme's own :active state does not cover it. A quick click is released before that state is ever painted — press and release land in the same frame — which is exactly what made Save feel like it had not registered. So the press is acknowledged deliberately, and for long enough to be read.

Four details are load-bearing:

  • The word is only safe on a button with room to spare. Save hexpands, so its allocation stays put while its natural width goes from 68 to 77 px (measured); on a button sized to its label, "Saved" would shove the rest of the row sideways under the pointer. The Test buttons therefore only light up.
  • Only Save names an outcome, and only when there was one. _save() checks what save() returned, so a press with no key selected confirms nothing. Test deliberately says no word at all: it starts the key, and a key that fails reports that itself with its own red mark — a button claiming success would be quicker and louder than the truth.
  • The first press of a run captures the label. A second one while the echo is still up would capture the echo instead, and the button would read "Saved" for the rest of the session.
  • The transition lives on .press-echo, the lit state on .press-echo-on. A removed class can only animate out from a rule the widget still matches, so collapsing them into one leaves the button snapping back instead of fading.

.press-echo-on brightens the background and draws a ring inside the button in @accent_fg_color. Both are needed: Save is already accent-filled, so a background turning accent would change nothing, while the flat Test buttons need the fill or the ring alone reads as focus. The pair is guaranteed to contrast whatever the desktop's accent is.

Grid drag and drop

MainWindow owns one Gtk.DragSource / Gtk.DropTarget pair on the entire key grid, not one pair per button. Both controllers use Gtk.PropagationPhase.CAPTURE, the source accepts only Gdk.BUTTON_PRIMARY, and the target enables preload. This lets the grid claim the gesture before a child Gtk.Button or Gtk.Picture consumes it.

Drag data is an internal GObject.TYPE_STRING value prefixed with linuxstreamdeck-key:. Preparation resolves the source from the pointer with Gtk.Grid.pick() and walks parent widgets until it finds the owning key button. An empty key returns no content provider and therefore cannot start a drag. Motion and drop resolve the destination the same way, so configured keys can move upward or downward into any empty slot, or swap with any occupied slot.

The drop handler must decode the typed payload and require its source index to match the active drag. Foreign, malformed, stale, outside-grid and same-key drops are rejected. CSS classes dim the source and subtly highlight the current valid destination. Destination feedback clears on leave/drop, and drag end clears all remaining feedback. A valid drop still goes through the unsaved-change confirmation before DeckController.move_key_to(); the key configuration and transient toggle/clock state travel together, and selection follows the destination. Copy/paste remains a separate context-menu/shortcut operation.

Spring-loaded folders

A drag that rests on a folder key for SPRING_OPEN_MS opens it, so a key can be dropped inside rather than swapped with the folder. Resting on the Back key leaves the folder the same way, which is what stops it being a one-way door. Only a pointer that stops counts: _arm_spring() restarts the countdown whenever the hovered index changes, so crossing a folder on the way somewhere else opens nothing.

The consequence is that _drag_source is a (folder path, key index) pair, not an index. The grid changes under a drag in progress, so an index alone names a slot the carried key is no longer in — and after springing into a folder, slot 7 of that folder would compare equal to the slot 7 the drag started from on the page, and the drop would be refused as "onto itself". _is_drag_source() is the one place that comparison lives, and _refresh_drag_source_feedback() dims the source only while its own grid is the one on screen.

Three more rules keep it from doing damage:

  • A dirty editor stops the spring rather than asking. A drag cannot put up a modal dialog, and entering a folder clears the selection, so _spring_open() checks has_unsaved_changes() and says so in the status bar instead. Nothing is lost: the ordinary swap-on-drop still happens, with its usual confirmation.
  • .spring-target is deliberately not .drop-target. Dropping on a folder swaps the two keys; waiting on it goes inside. Two different outcomes on one key must not look the same, hence the warning colour against the accent one.
  • The depth limit is the same one open_folder() applies (can_add_folder()), so a folder at MAX_FOLDER_DEPTH never springs.

DeckController.move_key_to(source_path, source_index, dest_index) performs the drop. It delegates to swap_keys() when both paths are the same, and otherwise moves the key into the grid on screen, sending whatever was there back to the source position. It refuses the reserved Back slot at either end, a source grid that no longer resolves, a key whose folders do not fit at the destination depth, and a folder dropped inside itself — the destination grid only exists because the key being carried holds it. It reuses _swap_key_state() (shared with swap_keys(), so what travels with a key cannot drift apart between them), discards each moved folder's subtree state exactly as a same-grid swap does, and calls forget_undo(): an undo entry names indices of one container, so replaying one across this change would restore a key over the one just moved in and leave the moved key in neither grid.

Persistence is atomic JSON with user-only (0600) permissions at ~/.config/linuxstreamdeck/config.json, with a config.json.bak backup written on every save. Loading migrates both the old single-profile format and legacy combined nav.page actions before the model reaches the editor/controller. The OBS password is stored asynchronously in Secret Service / GNOME Keyring, never in either JSON file. On first run, legacy plaintext password fields are migrated and removed from both files; if Secret Service is unavailable, they are still removed and the password is session-only. OpenAI and Claude API keys are also stored only in Secret Service, under separate provider identities; AI preferences in config never contain either key. The Twitch access and refresh tokens follow the same rule under their own schema, as one JSON item so a renewal replaces both together; TwitchSettings in config holds only public state: the Client ID, automatic-category policy and Steam AppID → Twitch category mappings. The Home Assistant long-lived access token is the fourth, under its own schema as one plain string, because nothing about it is ever renewed; HomeAssistantSettings holds only the server address, which travels with an export on purpose so a configuration moved to another computer in the same house still points at the right box.

The profiles menu exports .lsdconfig ZIP format v6 with the full JSON configuration, including every device workspace, its screen-saver and clean-exit display settings, available custom key icons, custom exit images and files referenced by sys.audio or the sound parameter of sys.timer; identical audio content is deduplicated even across both actions and workspaces. Version 6 deliberately clears hardware vendor/product/serial bindings so another computer can assign its own devices; version 5 added Arcade Sparks, permanent unlocks, completed challenges and refill state, and versions 1–5 remain accepted. Audio is limited to 200 MiB per file and 500 MiB total, while exit images share a 50 MiB limit. Built-in mdi: icons stay as references, and OBS passwords and provider API keys are never exported. close_action and editor_width travel with the configuration like brightness does — the width is clamped on load and clamped again by the pane to what the window can actually spare, so one carried over from a much wider screen is harmless — but the autostart entry never does: it stays local to each computer. Missing, unreadable, unsupported or oversized portable files remain local references and produce an export warning.

Import accepts every version in SUPPORTED_EXPORT_VERSIONS (v1 to v6), validates archive member paths and size limits, restores bundled icons below CONFIG_DIR/imported-icons, audio below CONFIG_DIR/imported-audio and exit images below CONFIG_DIR/imported-exit-images, replaces the complete configuration and writes the prior configuration to config.json.bak. It keeps the destination computer's keyring credentials and ignores password fields in old exports, so an OBS password must be entered once after moving to a new computer. The application rebuilds every device runtime, applies its imported brightness, screen-saver and clean-exit display settings, then reconnects OBS.

Portable single-key bundles

The key context menu exports one key as a .lsdkey ZIP through Config.export_key_bundle() and reads it back with Config.import_key_bundle(). Both are classmethods that reuse the configuration bundle's archive layout, EXPORT_ICON_PREFIX / EXPORT_AUDIO_PREFIX rewriting, size limits, member validation and atomic _write_bundle_archive() writer. The manifest carries its own KEY_EXPORT_FORMAT (linuxstreamdeck-key) and KEY_EXPORT_VERSION, so a full .lsdconfig cannot be imported as a key and a .lsdkey cannot be imported as a configuration; the key itself lives in key.json. Both formats were raised when folder keys landed (bundle v4, key v2) so an older application refuses the file instead of silently dropping whole folders; the loaders still accept every earlier version.

Export never mutates the source KeyConfig (it works on an asdict() copy), bundles only that key's custom icons plus sys.audio / sys.timer audio, deduplicates identical audio across both actions and reports missing files as warnings instead of failing. Import validates paths and sizes before writing, restores assets below CONFIG_DIR/imported-icons and CONFIG_DIR/imported-audio, runs the value through KeyConfig.from_dict() so legacy nav.page actions still migrate, and returns the key without saving. MainWindow then applies it through DeckController.paste_key(), which resets that position's toggle/clock state and persists the configuration. Replacing the key currently open in the editor goes through the unsaved-change guard with offer_save=False, exactly like paste.


4. Conventions

  • English only, everywhere. All user-facing text (action names, categories, descriptions, parameter labels, choice values, dialogs, menus, tooltips, status messages) and all code comments, docstrings, log messages, README, shell scripts, the udev rule and pyproject.toml. No Spanish and no accented characters in any versioned file. When you add any text, write it in English.
  • Match the surrounding style. Follow the existing naming, comment density and idioms of the file you are editing. Keep helpers small and private (_name).
  • Threading discipline is not optional — see §5.
  • Reference code as path:line in explanations.

5. Critical gotchas (read before editing rendering, devices, OBS or the UI)

These are hard-won and easy to reintroduce. Violating them causes intermittent, hard-to-diagnose failures.

The four whose violation is silent — nothing crashes, nothing asserts, and the symptom only appears on some launches or under concurrency — are guarded by tests/test_invariants.py. That file tests no feature: it exists so these rules fail loudly instead of being enforced by prose alone. Each guard is mutation-tested, meaning it has been confirmed to fail when its invariant is deliberately broken; if you change one, re-confirm that, because a guard that cannot fail is worse than no guard. When mutation-testing by hand, purge __pycache__ between runs: bytecode is invalidated on source mtime and size, so a revert that restores the same byte count within the same second is silently ignored and you will chase a ghost.

  1. Pillow text layout must be BASIC. Pillow's pip wheel bundles its own harfbuzz; used alongside GTK/Pango's system harfbuzz it intermittently draws blank glyphs (random per process — some launches all icons fine, others all blank). The fix, which must stay in place, is ImageFont.truetype(path, size, layout_engine=ImageFont.Layout.BASIC) in core/icons.py, device/renderer.py, device/startup_animation.py and device/screensaver.py, plus every game font loader in games/render.py and games/rendering.py. Do not use raqm, anchor="mm", or oversized fonts for glyphs (the latter caused giant masks → DecompressionBombWarning + blank keys). Configured-key glyph centering uses textbbox + ink-bbox recenter; the startup title centers each character from its text bounding box within one hardware-key cell, and the screen-saver title uses the same BASIC-only discipline. User-selectable label sizes stay bounded for the same reason: keep them expressed as FONT_SIZE_DIVISORS of the key height rather than absolute point sizes, so no choice can grow into an oversized mask. Where the font is found is a separate trap, and it is silent. Every renderer used to carry its own list of font paths and all four lists held Debian and Arch locations only. Pillow answers a missing font with ImageFont.load_default() rather than raising, so Fedora, openSUSE and the Flatpak runtime — which keeps DejaVu at /usr/share/fonts/dejavu/, a path none of the lists had — drew every key label, the startup title and the screen saver at roughly 40% size with nothing logged. Measured: "Record" at size 20 is 79 px wide with a real font and 32 px with the default one. core/fonts.py is now the one list, and it ends with a copy the package carries so an AppImage can run on a machine with no fonts at all. Keep the bundled entry last, or it overrides the font the machine actually has. Guarded by tests/test_fonts.py.

    Guarded by BasicFontLayoutTests, which records every ImageFont.truetype call made while rendering an icon, key images, four screen-saver styles, the startup sequence, exit tiles and all nine built-in game renderers, and fails if any of them omitted layout_engine. Clear the cached font loaders first or a patched loader is never reached.

  2. Rendering is not thread-safe → RENDER_LOCK. Pillow/FreeType is not thread-safe. Configured keys render on a worker, the screen saver renders on its own thread, and the icon picker/preview render on the main thread; concurrent use produced blank (and blank-cached) glyphs. A shared reentrant RENDER_LOCK (defined in core/icons.py, imported by every device and game drawing module) serializes drawing. It is reentrant so compose() can call library.render without deadlocking. The glyph cache is manual and never caches failures (safety net). Guarded by RenderLockTests, which swaps the lock for a depth-tracking proxy, patches ImageDraw.Draw to record anything drawn at depth 0 while exercising all nine games too, and pins that every drawing module holds the same object: each imports it by value, so a second lock would serialize nothing while still looking correct.

  3. OBS requests must be fully serialized. obsws_python.ReqClient uses a single websocket that is not thread-safe. In obs/client.py the _lock must be held for the entire req.send(...), not just while reading the client pointer. Requests come from two threads at once (the GTK thread filling editor dropdowns, and the render worker for feedback() calls like obs.source_visibility / filters that query OBS live). Overlap corrupts the protocol → hang at ~73% CPU and disconnect. Guarded by ObsRequestSerializationTests, the only test that builds a real OBSClient rather than a fake: a stub send blocks while a second thread tries to get in, and a semaphore fails the test if two calls are ever inside send at once, so it catches the overlap even if the timing shifts. It also pins that a raising send releases the lock instead of wedging every later request.

  4. Single-instance app. It is a single-instance Adw.Application. Editing .py files does not affect an already-running process, and relaunching while a window is open merely re-activates the existing one. After a code change the running window must be fully closed before relaunching. Proof that a given instance is the live one: its log shows the deck "connected" line (HID access is exclusive — only one process can open the device).

  5. Do not launch the GUI yourself to verify changes. Background launches leave zombie instances and cross-activations, and the user ends up looking at a stale window with cached state (e.g. blank glyphs cached before a fix). To verify UI rendering, compose key PNGs offscreen (build objects directly, or use Gtk.WidgetPaintable + Gtk.Snapshot + renderer.render_texture → save_to_png). To verify the running app, ask the user to close any old window and launch it themselves with ./run.sh.

  6. AI output is an untrusted proposal, never an action. Keep provider API keys in ApiKeyStore; a display mask must never become a request credential. Keep context optional and limited to bounded OBS/page/Steam names and Steam AppIDs, and exclude sys.command, obs.raw and web.request from the provider catalogue: each reaches something outside this application on the strength of a string. Validate every response locally. Generation must not execute or save anything; only the user's explicit Save from the existing editor persists the key.

  7. Unsaved key edits must survive navigation decisions. Dirty state is a canonical KeyConfig comparison, not a sticky UI flag. Keep the source page reference stable across deferred dialog responses, preserve the guarded paths described in §3, and never reload on a same-key click or a rename event for the same page object.

  8. Action work must not starve running feedback. Keep action, timer-sound and render executors separate, count every queued/running invocation per device-scoped RuntimeKey, and pulse only busy keys in the active view. Preserve the complete controller shutdown order: set the stopping signal; stop the clock scheduler; clear clock state and signal completion sounds; wake activity and clear the pending-render marker; shut down the action executor; shut down the timer-sound executor; join the activity thread; then shut down the render executor.

  9. Restartable actions must never overlap. Keep cancellation controls scoped by device-scoped RuntimeKey, cancel the prior same-key invocation and wait for its finished event before starting the replacement. Long-running actions and waits must observe their ActionContext; canceled queued replacements must exit without executing so rapid presses collapse to the latest invocation.

  10. Portable assets must stay bounded and path-safe. Keep .lsdconfig v1-v5 import compatibility while exporting v6, which preserves every deck workspace, folder subtree and Arcade progress while stripping computer-specific hardware bindings. Bundle only supported sys.audio files and sys.timer sound parameters, deduplicate audio content across both actions and restore it only below CONFIG_DIR/imported-audio. Bundle the supported BMP/JPEG/PNG/WebP custom exit images with content deduplication, enforce their shared 50 MiB limit and restore them only below CONFIG_DIR/imported-exit-images. Validate every archive path and size before extraction. Single-key .lsdkey bundles import v1 and export v2 for folder support; they follow the same rules and must keep their distinct manifest format, so neither bundle type can be imported through the other's entry point. Export must not mutate the KeyConfig it is given, and import must not save the configuration itself.

  11. Physical startup must remain exclusive and cancellable. Generate frames offscreen under RENDER_LOCK, keep their brightness at or below the configured target and restore that target in finally. Play them directly on the provisional device before assigning self.deck, installing its callback or emitting deck.connected. Check the monitor stop event during writes and waits; only a completed or safely skipped animation may proceed to connection publication and the configured-key refresh. If shutdown cancels provisional startup, apply the configured clean-exit display before closing that device.

  12. Named page navigation must stay compatible and unambiguous. Keep the legacy nav.page loader migration for single/multi/toggle actions, but never expose that ID in the registry or AI catalogue. Populate nav.page.go choices from the active profile, reject duplicate names and update every same-profile reference on rename. Next/previous must wrap, missing names must report status and a same-page selection must not save or emit redundant events.

  13. Grid DnD must survive child widgets and reject foreign data. Keep one CAPTURE-phase, primary-button source/target pair on the grid, preload the typed internal string payload and resolve source/destination by walking from the picked child to its key button. Empty keys cannot be sources; empty and occupied keys are valid destinations in any direction. Validate the payload against the active source, preserve unsaved-change confirmation, move toggle and clock state with the key and always clear source/destination feedback. Keep _drag_source a (folder path, key index) pair and compare it through _is_drag_source(): a folder can spring open mid-drag, and an index alone then names a slot of a grid the carried key is not in. A spring must never run while the editor is dirty — a drag cannot stop to ask, and entering a folder clears the selection — and it must keep its own visual state distinct from the drop target, since resting and dropping do different things. move_key_to() refuses the reserved Back slot at either end, a folder that does not fit at the destination depth and a folder dropped inside itself, and drops the undo history, whose entries only name indices of one container. Keep self.editor.set_hexpand(False): without the explicit flag the panel inherits one from its hidden-until-selected button row, the grid box loses half its spare width on the click that selects a key, and the centered grid slides 247 px left at 1920 — so the pointer is two keys away and a folder's second click misses. Keep both shrink_*_child False, which is what makes the key grid's own minimum the stop for the drag handle, and keep the restore bounded, run once and told apart from a drag.

  14. Stateful clocks must remain key-scoped and cheap. Keep countdown and stopwatch state keyed by device-scoped RuntimeKey and pass that identity through ActionContext. A single-action immediate clock must never occupy an action worker. The scheduler may poll at 0.1 seconds but should render only changed seconds in the visible view; switching views must not pause state. Move state with DnD, reset it on edit/paste/clear, clear it when indices or configuration are replaced, and emit each timer completion once. Completion audio must stay on its separate executor and stop on reset or shutdown.

  15. The screen saver must own the deck without stealing actions. Render every style as a coordinated full-deck frame under RENDER_LOCK and BASIC text layout, with frame brightness capped by its independent intensity. While active, suppress normal controller/HID renders. Track physical key activity and deliberate virtual-deck interactions through explicit record_activity() calls. Never attach a broad/global Gtk.EventControllerLegacy activity hook: nullable events previously caused AttributeError log storms, near-100% CPU use and application freezes on Pop!_OS. Consume both edges of the first physical wake press, then restore normal brightness and configured keys. Preview must work while disabled and without hardware, and temporary dialog subscribers must unsubscribe on close. Suppress automatic activation while OBS is recording or streaming, wake an already active automatic saver when either begins, and restart a complete idle interval only after both stop. Keep manual preview ahead of suppression so an explicit preview remains available during an OBS session. Drive the policy only from immutable obs.outputs payloads: publish the first snapshot immediately after priming record and stream status, publish live output events before the general repaint event, and join the OBS event producer before resetting state and emitting the authoritative false snapshot on disconnect. Because prime requests and events use separate sockets, keep output state under _output_lock with independent monotonic record/stream generations: a prime response may apply only when its captured generation is still current, live events advance their own generation, and teardown advances both after the event-worker join to invalidate in-flight primes. Keep every installed style's intentional FRAME_DELAYS cadence. Compare raw RGB pixels so unchanged physical keys skip native conversion and USB writes; publish only changed raw RGB keys to Gdk.MemoryTexture for the selected, visible, non-minimized virtual workspace. Hiding the window must not stop a connected physical animation, while an offline hidden workspace must wait without rendering until it has an output. Invalidate the appropriate caches on visibility, connection and saver transitions so the next destination gets one complete frame. On shutdown, signal the saver wakeup, join its thread before the monitor thread, and apply the clean-exit display before HID closes.

Treat suspend/resume transport loss separately from rendering failures. Device health requires an open HID handle as well as an enumerated USB path; a real TransportError from a writer must wake the monitor, stop further writes to that handle and cause close/reopen in the same monitor pass. Do not reconnect for image-conversion or arbitrary programming errors. Clear a failed handle's request when detaching it so physical recovery never disables virtual/manual preview, and publish a replacement handle atomically with the request clear under the HID lock.

  1. Never nest a built GLib.Variant inside a format string. The dbusmenu reply is (u(ia{sv}av)) and its layout is already a typed variant. Passing it as a tuple element of GLib.Variant("(u(ia{sv}av))", …) makes PyGObject try to rebuild it from its unpacked value and fail with TypeError: Expected GLib.Variant, but got str. Assemble such replies with GLib.Variant.new_tuple(...) instead. This only shows up on a real D-Bus call, never when building the layout in isolation, so keep the reply-shape test. Menu-item artwork is embedded as 16x16 PNG icon-data, never resolved through the host icon theme. Keep Open, Profile, Games, Arcade, Stop and Quit mapped to distinct bundled MDI glyphs, perform their Pillow work under the shared RENDER_LOCK, and fail open to the text-only item if rendering fails. Keep individual game rows text-only so their price and unlock labels stay compact.

  2. Hiding to the status area must always be reversible. Keep hides_on_close() as the only decision point and keep all three of its conditions, so a missing, unregistered or vanished status area falls back to quitting. Hide by returning True from close-request and only setting the window invisible; destroying it would end the process and lose the session. Route icon-driven quit and profile switches through the window so the unsaved-change guard still runs, and present the window before any confirmation, since those dialogs are modal to it. Autostart state belongs in the XDG entry, never in config.json or an export. Never gate starting the icon on is_supported() and never register with call_sync: the status area commonly appears after the application at session login, and a blocking call on the GTK main thread freezes the window for its whole timeout. Publish the real application SVG as multi-size, network-order ARGB32 IconPixmap data; crop only its fully transparent outer padding and preserve the visible artwork's aspect ratio instead of forcing square pixmaps. Keep that raster-only crop isolated from the original hicolor SVG used by the launcher and dock; while those pixels load, leave IconName empty so COSMIC cannot prefer a failed theme lookup, and fall back to the theme name when they do not. Keep the late-appearance and icon-pixmap regression tests.

  3. Press gestures must not leak across keys. Resolve them on release, keep the pending double-press timer keyed by device-scoped RuntimeKey, and cancel it from every path that changes what that index means: key edit, drag/drop swap, page or profile change and shutdown. A key with no gesture mode must still run on the press, so plain keys keep their current latency. Never make the virtual deck wait out the double-press window; it runs the single-press list directly.

  4. Opening the deck disturbs the USB bus, and that is not our bug. hidapi detaches the kernel driver and unbinds the interface, which removes the deck's input node (eventN) and, on a hub or dock, can make the whole dock re-enumerate. On a dock that also carries video, the desktop may restart its panel. This was diagnosed once on COSMIC and traced to the dock: connecting the deck directly made it stop. Do not "fix" it in the application or with a LIBINPUT_IGNORE_DEVICE udev rule — neither is the cause. When something like this appears, correlate udevadm monitor with the affected process's PID, and check how the deck is physically connected before changing any code.

  5. Never fake media or modifier keys. Media transport goes through MPRIS and volume through the session mixer, both of which work unprivileged on Wayland. Only genuine application shortcuts use core/keystrokes.py, and its backends must stay optional: no code path may assume ydotool exists, and a missing backend must surface as a status message, never an exception that breaks a multi-action sequence. Keep ydotool_syntax() detection: emitting 1.x key codes to the 0.x binary that Debian and Ubuntu ship fails silently for the user. core/mixer.py follows the same rules and adds one of its own: read the mixer only through pactl -f json, because plain pactl answers in the user's language and wpctl cannot be parsed at all. Write an absolute level rather than pactl's relative +5%, which clamps nothing; move every stream of an application together; answer None rather than "not muted" when the question could not be answered; and keep one cached reading serving all the keys, since feedback() runs on the single render worker.

  6. The clean-exit display must be the final HID state. Keep device_default as a firmware reset, blank as black tiles followed by brightness 0, and custom as a validated full-grid center crop written at normal configured brightness. Apply it only after device workers have joined and immediately before closing HID; if custom preparation or writes fail, fall back to the device default. Do not promise this state after forced termination, a crash or power loss.

  7. Folders must stay bounded, reversible and key-scoped. Slot FOLDER_BACK_INDEX inside a folder is always the Back key: never store, select, drag, drop, paste, clear or swap it, or the physical deck can enter a folder with no way out. Keep MAX_FOLDER_DEPTH enforced in four places — loading (_folder_contents), creating (can_add_folder, which also gates the spring), pasting or importing a subtree (fits_here) and dragging one across a boundary (move_key_to). Keep KeyConfig.folder compare=False, or editing a key inside a folder makes its parent key look permanently dirty. Keep the open folder out of config.json: it is view state, and _leave_folders() must reset it on every page, profile and configuration change. Keep transient state on the full RuntimeKey, and discard a subtree's state when its folder is replaced or moved but not when the folder key itself is merely edited. Every walk over a key's actions or assets must recurse with _walk_keys() / _walk_raw_keys().

  8. Dials are keys everywhere except in storage. Keep KIND_DIAL on KeyConfig and its three lists in STEP_FIELDS, so bundles, migrations and renames reach a dial for free; keep them in Page.dials, walked alongside page.keys by _key_configs() and the bundle exporter. Keep DIAL_PATH negative so a dial can never share a RuntimeKey with the key of the same number. Preserve the tick count of a turn and the MAX_DIAL_TICKS ceiling, and derive touchscreen panels from the running edge so they tile without a seam. Everything dial-related must stay gated on dial_count, which is 0 on every model but the Plus.

  9. An empty appearance value means inherit, never "the default". That holds for icon, font_size, text_color and every kind="color" parameter: store nothing and let the renderer decide, or clearing a choice silently freezes the current default into the configuration. When the editor shows an inherited value it must block its own change handler while doing so. A color parameter whose inherited value depends on a sibling must name that sibling in COLOR_SOURCE_PARENTS, or the swatch keeps showing a default that stopped being the right one.

  10. A checker may never overclaim. Keep UNCHECKED above OK in STATE_ORDER, always named in the summary, and drawn faded with its own badge; keep every detail stating its own limit and keep any verdict word out. Check cameras through the device handle in /proc, never through a screenshot, and resolve symlinks on both sides. Keep the volume meters on their own short-lived EventClient so the flood never reaches the request lock. Never let the stream key past stream_target(). Keep dismiss_board() the single way a report is put away and keep PreFlight._hold() polling board_active(), so a press on the deck and closing the report window both give the deck straight back instead of leaving it held for the rest of PREFLIGHT_HOLD.

  11. A key that cannot do its job has to say so on the key. Keep the failure mark keyed by RuntimeKey, expired by the activity thread (never by a timer per failure), drawn as a border so artwork and live previews cannot hide it, and dropped everywhere the other transient state is dropped. Keep the fade driven by Action.requires_obs(params) and applied only when every action of the key needs OBS; an unregistered action is unknown, not unavailable. Keep _mark_obs_dependency() deriving the flag from the category, or an action added later silently renders as usable while OBS is closed. Keep ON_ERROR_CONTINUE as the default and normalize unknown values to it, so no stored key changes behaviour by being loaded. Keep the log file optional: failing to open it must never stop the application.

  12. A Twitch refresh token is spent exactly once, and key feedback must never wait on the network. Keep _persist() storing a renewed pair before adopting it and keep _renew_lock serializing renewals, or two workers spend the same single-use token and the second refusal unlinks a good account. Keep _is_scope_error() out of the retry path: a missing scope is also a 401 and no renewal can grant it, so retrying spends a token on every repaint. Keep channel() free of any request — it runs on a render worker inside feedback() — and keep both staleness bounds, so a blip keeps the last value while an outage blanks the key rather than showing a number that stopped being true. Keep tokens in Secret Service and out of Config, config.json, its backup and every export; only the public Client ID and automatic-category policy/mappings may travel. Keep the poll loop matching Twitch's device-flow code through _normalize(), since the status code alone cannot tell "not yet" from "no" and the exact spelling already broke the flow once; keep slow_down a back-off rather than a refusal. Keep describe_error() between Twitch and the user, so no identifier is ever displayed. Keep the category a text field resolved at press time with an exact name beating the search ranking, and keep its suggestions debounced, off the GTK thread and guarded against a stale answer overwriting a newer one. Never let the account dialog claim a completed disconnection: Twitch has no API to remove the authorization, so revoking is all this can do and the user has to finish it on Twitch's own Connections page. Keep an alert key's "was it already waiting" derived from its pending alerts rather than from a flag, or the key sounds once and never again; keep the sound going to play_audio as a percentage with a callable stop signal, and keep it wrapped so a failure is reported — an executor swallows a worker's exception, and a sound that cannot play is otherwise indistinguishable from nothing happening. Keep the deck flash on the same rule as the sound rather than per message, keep _standing_aside() covering it so normal renders cannot paint over its pulses, raise _flashing before queueing and clear it before the closing refresh(), compose each frame once for the whole grid, and keep it under three flashes a second. Keep its word within FLASH_WORD_CHARS and its ink from renderer.contrasting_ink(): compose() fits centered text to the key width, so a longer word is drawn at half the size for a pulse that lasts a fifth of a second, and a fixed white one vanishes on a pale chosen colour. Validate that chosen colour in twitch/actions.py, never at the renderer — it reaches Pillow on a worker once per pulse. Never let 0.0 stand for "this key has never been acknowledged": monotonic time counts from boot, so on a machine that just started it is an ordinary timestamp, and anything stamped before it is silently dropped as already seen.

  13. A game owns both input and pixels until it restores the page. Keep the catalog, status-menu mapping, engine registry, per-game settings fields and render dispatch in agreement: an entry present in only some of them can appear in the menu but fail to start, render through the wrong path or save into another game. Engines stay pure with injected time/RNG; shared lifecycle belongs in GameManager. Route physical input through GameManager before actions and preserve _owned_down until each release, including a release after Back made the session inactive. Suppress virtual actions, normal renders, report boards, alert flashes, editing and DnD while active. Every active game must consume every dial turn, dial push and touchscreen tap before configured dial actions; a game without a handler simply ignores the gesture. Screen-saver suppression must be a union of named reasons: releasing game may not release OBS, and game ownership must block even manual preview while ordinary OBS suppression must not. Derive layouts from live key count/columns, use the Plus LCD only when dials are present and keep Mini controls unique. Preserve the one/two/three compact Minesweeper densities and defer a result-time difficulty change until Start; derive valid compact Tic-Tac-Toe lines below nine keys; and keep Mastermind's latest Mini clue plus complete solution/Again/Back result controls. Neon Relay must preserve its guaranteed randomized boundary route, difficulty-specific visibility/safe-tile/shield/speed settings, sector acceleration, shield recovery, combo/crystal scoring, timed Overdrive and shuffled every-third-sector upgrades. On Plus, it rotates a whole dial column by the reported ticks and spends 40 charge on immediate stasis for a dial or strip press. Neon Jackpot must refuse layouts below eight display keys; keep its 4x2/5x3/8x4 reel/control layouts, predetermined outcomes despite braking, recovery only after an ordinary paid unheld loss, with either at most two one-spin holds or one nudge and never both, and no help after a win, free spin or held spin; keep fictional Sparks/bets/progressive jackpot, bolt free spins/multiplier and Plus reel controls. Preserve its committed Bet-to-Jackpot wager phase, outcome-neutral third-reel anticipation, three distinct impact windows, symbol-tier celebrations and four-act jackpot sequence. It must never add real-money, payment or cash-out behaviour. Draw under the shared RENDER_LOCK with BASIC fonts. Keep every game's cue map, exact WAV set, asset license, generator output and package-data entry isolated below that game's own assets/games/<game_id>/ directory. Bound/cancel audio work, persist records per geometry, game and difficulty, and stop the game worker before HID shutdown. Exit, disconnect and failure must restore the configured page without resurrecting a delayed report overlay.

Keep ArcadeEconomy as the one wallet authority: 250 starting Sparks, the three starter unlocks, catalogue-defined permanent unlock/run costs, no Free Play switch, and a 100-Spark rescue only when both zero balance and 24-hour eligibility are true. UNLOCK needs confirmation; EARN SPARKS must release the current session before requesting Neon Jackpot. Persist/export wallet, progressive pot and its fifth-Spark contribution remainder, unlocks, challenges, refill time and alert switches. Keep the pot floor/reset at 40, preserve larger existing pots, and feed it exactly 20% of each paid wager without per-spin rounding. Defer refill sound/visuals during games and OBS output. Challenges are stable, one-time IDs: scan durable configuration retroactively, but reward service operations only from success events and never reward repetition or risky actions.

  1. Automatic Twitch categories may observe, but never guess. Keep process detection local, same-user and AppID-based, and accept only AppIDs that also resolve to installed Steam manifests. Never infer a game from focus or a window title. Scan and call Twitch only while the global policy is enabled, unpaused, authorized and obs.outputs.streaming is true; recording is not a Twitch stream. Preserve the twelve-second stable candidate, the one-new-AppID transition rule and the ambiguous-set refusal. Closing a game keeps the last category. Store stable Twitch IDs with their names, auto-save only strict exact-name matches and leave every other title pending for the user; a network error retries with back-off and is not an ambiguous match. A manual category wins until the detected AppID changes, including an external change observed after the post-PATCH grace period. Keep the policy global and its toggle key a view/control of that same state, never a per-key copy. Stop and join AutomaticCategoryManager before OBS and Twitch, and keep its state events repaint-only: action feedback and the GTK thread must never perform a process scan or Helix request.

6. Safe local experimentation

Config.save() always writes to ~/.config/linuxstreamdeck/config.json unless LSD_CONFIG_DIR is set. Any script that exercises code calling save() (e.g. set_page, set_profile, add_profile, add_page, rename_page, paste_key, clear_key, brightness, screen-saver or exit-display changes, game lobby choices/high scores, Twitch automatic-category settings/mappings, saving a key) will overwrite the user's real config — this has happened and lost real keys and settings. Config.import_bundle() also saves a replacement configuration and writes imported icons, audio and exit images below the config directory, so it must always be isolated too.

Always redirect the config directory before importing the package:

LSD_CONFIG_DIR="$SOME_TMP_DIR" .venv/bin/python your_script.py

Set LSD_CONFIG_DIR in the environment before linuxstreamdeck.core.config is imported. A config.json.bak backup is written on each save, but do not rely on it — isolate first.

The six 1600x900 images in marketing/ are reproducible feature presentations, not invented interfaces or hardware mockups. marketing/generate.py sends configured keys through the normal key renderer, deterministic states through all nine real game engines and render dispatcher, including Neon Jackpot's original transparent symbol sprites, screen savers through their full-deck renderer, and the Stream Deck + strip through its LCD renderer. The main-window panel embeds the canonical real GTK window capture in docs/screenshot.png instead of synthesizing a window. Regenerate the gallery only with an isolated config:

LSD_CONFIG_DIR="$(mktemp -d)" .venv/bin/python marketing/generate.py

The numbers shown on live-feedback keys are representative supported values, not readings from the developer's current system or accounts. Keep that disclosure, and keep the hardware disclosure exact: the MK.2 is tested on physical hardware while Mini, Neo, XL and Stream Deck + layouts are verified in simulation. Do not hand-edit generated gallery PNGs; update the generator or its canonical docs/screenshot.png input and regenerate all six images together.

marketing/generate_press_kit.py then builds marketing/press-kit/ and the byte-reproducible landing download at landing/assets/downloads/linuxstreamdeck-press-kit.zip. The package SVG at packaging/com.javocsoft.LinuxStreamDeck.svg is the only icon source: the generator copies it byte-for-byte as the master vector and rasterizes the transparent 1024/512/256/128 PNGs plus prepared light/dark previews. It copies the six gallery PNGs unchanged, includes the repository GPL text, writes SHA256SUMS for every other press-kit file, and archives the result under stable timestamps and permissions. Rebuild it after the gallery, package icon or press-kit README:

.venv/bin/python marketing/generate_press_kit.py
node landing/check.mjs

The press-kit README must preserve the gallery's representative-value and hardware disclosures; the copied compositions are not live desktop or hardware captures. Do not hand-edit its generated icons, screenshots, checksums or ZIP. landing/check.mjs asserts that the landing contains the download link and that the archive exists, in addition to its catalogue checks; it does not replace regenerating or inspecting the archive.


7. Documentation

The project's documentation must stay in sync with the code. Documentation files:

  • README.md — user-facing overview, features, install/usage.
  • AGENTS.md — this file (agent operational guide).
  • CLAUDE.md — Claude-specific notes; imports this file.
  • CUSTOMAGENTS.md — provider-neutral catalogue of the custom subagents.
  • agent-definitions/ — canonical prompts, provider settings and the generator for Claude/Codex agent adapters.
  • docs/ — assets such as screenshot.png.
  • marketing/ — reproducible feature gallery, press kit, both generators and their provenance notes; the generated press-kit ZIP is served from landing/assets/downloads/.

Custom agent prompts must be edited only below agent-definitions/; files below .claude/agents/ and .codex/agents/ are generated provider adapters. After an agent prompt or manifest change, run:

python3 agent-definitions/sync.py
python3 agent-definitions/sync.py --check

There is a dedicated documenter agent for both Claude and Codex. Its job is to review every Markdown file and bring it up to date with the current code and canonical agent definitions. Invoke it when code or agent changes affect the docs, or when asked to "update the documentation". It must include generated agent Markdown in its audit and finish with the synchronization check above.