DeckBridge — architecture & development
Deep technical documentation: build pipeline, threading model, protocol handling, and module layout. For the user-facing overview see README.md; rendered docs at https://lukasmega.github.io/DeckBridge/.
Runtime: txiki.js (QuickJS-ng + libuv + libffi)
Quick start (from source)
# Prerequisites: libhidapi installed (txiki.js runtime is provided by mise)
mise run start
# Or step by step:
mise run build # fetch txiki.js (mise) + bundle TS + build Rust sidecars (cdylib + tray)
mise run compile # produce ./deckbridge binary
./deckbridge
Slim txiki.js runtime (size optimization)
mise run compile self-embeds the txiki.js runtime, so a smaller runtime means a smaller
deckbridge. The app uses only tjs:ffi, raw TCP and tjs.serve (HTTP + WebSocket) — none
of txiki.js's sqlite3 or WebAssembly/WASI — so it ships a slim runtime.
mise run tjs-setup (a dependency of build) puts it at $TJS under ../vendor, and no-ops
when it already exists. It downloads the prebuilt slim-ffi asset of $TXIKI_VERSION
(pinned in mise.toml) from
lukasMega/txiki.js-with-slim-builds;
no toolchain needed. The profile keeps tjs:ffi, WebCrypto, run/compile and the REPL, and
drops TLS, WebAssembly, SQLite, mimalloc and the eval/serve/test/bundle/app
subcommands (MinSizeRel, hardened, compressed bytecode). TLS costs ~430 KB and buys nothing:
every fetch() in this repo runs in the browser UI. Effect on the shipped binary (macOS
arm64): 6.4 MB → 2.4 MB.
Building from source is the fallback — required on macOS x86_64 (no prebuilt asset) and useful when changing the runtime itself:
mise run tjs-build # or TJS_FROM_SOURCE=1 mise run build
../scripts/tjs-build.mjs clones the fork at $TXIKI_VERSION and
runs its scripts/build-dist.mjs --profile ffi, the same driver that produces the published
assets.
Build deps (from-source only): git, cmake, npm, a C/C++ toolchain, libffi
— macOS: xcode-select --install && brew install cmake libffi; Debian/Ubuntu:
sudo apt-get install -y build-essential cmake git libffi-dev.
Running a packaged release
The standalone deckbridge binary is self-contained. Native dylibs (libdeckbridge_native,
libhidapi) are embedded (raw bytes as latin1 strings) and auto-extracted to a per-version cache
dir on first run (paths: Build pipeline). A deckbridge-tray sidecar next to the binary
is launched if present; the tray is optional.
Data flow
Concurrency model
Per connected device, DeckBridge's core loop runs on two threads joined only by
postMessage. Two further worker types sit outside that loop (HID enumeration and plugins;
see Supporting workers), for four thread contexts in total:
- Main thread — the CORA TCP servers (Elgato primary/child), the WebUI HTTP/WebSocket server, and the orchestration forwarding each received CORA image to the worker. It must stay responsive: CORA image chunks are ACK-paced (Elgato waits for our ACK before the next), so any stall here throttles image delivery and the WebUI previews riding on it.
- USB worker thread — owns the libhidapi handle and all synchronous, blocking work: the
JPEG/BMP transform (
image-render.ts→image_proc_transformFFI, ~1 ms) + LRU image cache, then HID I/O (hid_writeuploads,hid_read_timeoutkey polling). The main thread hands over raw CORA bytes viaWorkerHidDriver.renderCoraImage(), so neither the transform nor a large upload stalls the ACK loop (P1). One generic worker (hid-worker.ts, proxied byWorkerHidDriver) serves every device;USB_DRIVERS(usb-drivers.ts) picks bymodel.protocol:ElgatoHidDriver(MK.2, Mini),MiraboxDriver(293/293S/K1 Pro), orAkp05Driver(AKP05 family).
A full profile load is fast on both sides: the main thread pushes every image to the browser immediately while the device updates in parallel on the worker. Mock mode stays on the main thread. Multi-device: this pair (CORA server pair + worker thread) repeats per physical device.
WorkerHidDriver uses bounded HidWorkQueue admission. Waiting complete-key images can
coalesce; touch updates and controls preserve ordering; worker completion messages release
admission credits. See Threading and ordering.
Supporting workers
Three lighter worker types sit outside the CORA/image hot path:
- HID scan worker (hid-scan-worker.ts, proxied by
HidScanWorkerHostin hid-scan-worker-host.ts, messages in hid-scan-worker-protocol.ts) — owns HID enumeration.hid_enumeratecan block for seconds on Windows and on a wedged macOS HID interface, so it never runs on the main thread. One worker for the process lifetime; the host coalesces concurrent callers onto one native scan, so a fixed probe timer cannot queue duplicate scans behind a blocked interface. It builds its VID/PID match list fromDEVICE_MODELSand callsscanSupportedHidDevicesTimed()(ffi/hid-discovery.ts), returning the device list and the elapsed time that feeds the adaptive probe backoff below. Aresetmessage triggersresetHidDiscovery(), which must run on this thread: a newIOHIDManagerbinds to the run loop of whichever thread calledhid_init. - Plugin worker (plugin-worker.ts, proxied by
plugin-host.ts) — runs plugin-widget code for side keys,
lazily spawned. A crash/CPU isolation boundary, not a capability sandbox; the host pings
it every
HEARTBEAT_MS(2 s) and kills/restarts a wedged worker, giving up afterMAX_CONSECUTIVE_KILLS(3). - Extension workers (extension-worker.ts, one per
enabled extension, proxied by extension-host.ts and
owned by extensions-controller.ts) — run an
extension's
server.js, loaded at runtime from<cache>/extensions/. The dock hooks feed them through extension-events-wiring.ts, which builds no event at all while no extension runs; events reach a worker in one batch per 250 ms./api/ext/<id>/<path>is proxied to the worker and/ext/<id>/ui.jsis served to the web UI, which mounts it with its own Preact. Same heartbeat as the plugin worker, but crashes are never forgiven: the third one stops the extension until it is switched on again. Switching one on pins a SHA-256 digest of its whole folder; a scan that finds another digest stops it, and it runs again only after a switch off and on (trust).
WebUI log batching
To keep image bursts from flooding the WebUI, per-chunk CORA tx/ACK and keepalive logs are
debug level, and WebUIServer.notifyComm() batches comm entries into one commBatch message
every ~100 ms (COMM_BROADCAST_FLUSH_MS). In __SIMPLE_ONLY__ builds (the default — no
log/comm panel) the commBatch/logBatch broadcasts and the /api/state logs/commLogs
fields are skipped; the ring buffers keep filling, since diagnostics reads them directly.
Network exposure
The CORA servers (5343/5344) listen on all interfaces (0.0.0.0) with no
authentication — protocol-inherent, as the real Elgato Network Dock has none either. Any LAN
host can connect, push images, and read key events. DECKBRIDGE_BIND (e.g. 127.0.0.1)
restricts the listen address. The WebUI (3000) binds 127.0.0.1 by default; --bind /
DECKBRIDGE_BIND moves both the CORA servers and the WebUI.
Every WebUI response carries X-Frame-Options: DENY, X-Content-Type-Options: nosniff,
Referrer-Policy: same-origin and a Content-Security-Policy with frame-ancestors 'none', so
no other site can frame the WebUI. The app policy allows scripts only from the page itself and
the hashes of the inline scripts in ui.html, and it allows no inline styles and no
connections to other hosts. /requirements is a standalone page with inline script, so its
policy only sets object-src 'none', base-uri 'none' and frame-ancestors 'none'. The
/qr-sandbox page draws the pairing QR code. It is framed by the WebUI itself
(X-Frame-Options: SAMEORIGIN), its policy allows no connections, images or forms, and it runs with
sandbox allow-scripts, so opened top-level it cannot reach the WebUI origin. A route can set
its own header instead of the default; /go/docs keeps Referrer-Policy: no-referrer.
/api/push/* is the one token-authenticated path (bearer token, Origin absent or the
WebUI's own, no Host check; see push-api.md). A token authorizes nothing else,
and the admin routes stay unauthenticated on a LAN bind. A push flows: route → PushController
→ in-memory PushChannels → coalesced pushChanged event → ExtraKeyWidgets.paintChanged(),
which repaints only changed keys. The CORA servers are never touched.
To reduce session-stealing, a CORA connection that sent data within CLIENT_EVICTION_GRACE_MS
(default 10 s) can't be evicted by a new connection; the newcomer's socket is closed. A quiet
connection (desktop app closed) can still be replaced.
Malformed-input guards on the CORA path
- Incoming images (gen2 JPEG / gen1 BMP) are decoded by
deckbridge-nativewith bounded limits (max 800×500 px, 900 KiB decode alloc); real key images are ≤ ~800 px. - Image chunks with an out-of-range
keyIndexare dropped before assembly, so an unauthenticated peer can't grow assembly buffers or repaint key 0 via index coercion. - A frame header declaring a
payloadLengthlarger than the receive buffer forces a resync past the bad header instead of stalling the reader.
Startup & error handling
The CORA ports (5343/5344) are protocol-fixed and can't fall back like the WebUI port. If
either is in use (a second DeckBridge, a real Network Dock, the ESP32 bridge),
CoraDock.startWithRetry() (cora-dock.ts) logs "port in use" to
the console + WebUI feed and retries every few seconds (unbounded for the primary dock),
keeping the started WebUI alive instead of crashing. A shutdown signal during the wait still
exits cleanly. A scanned dock calls it with maxAttempts: 1: a bind failure throws back to the
DockScanner's scan-tick retry instead of looping in place.
Shutdown and unhandled rejections
A global unhandledrejection handler (app.ts) turns an otherwise-fatal rejection into a
graceful shutdown() (device disconnect handshake, socket teardown, tray kill). txiki aborts
on an un-preventDefault'd rejection, and a synchronous throw in a timer callback has no
global hook, so the two recurring timers (CORA keepalive, WebUI comm-flush) are wrapped too.
On shutdown the deckbridge-tray sidecar gets SIGTERM so it doesn't outlive the main
process.
CLI
CLI parsing internals
app.ts is the sole entry point (no separate CLI binary), and CLI parsing
(cli.ts) runs first: version/help/devices must exit
immediately, and flags must land in tjs.env before other modules read it. parseCliArgs() is
a hand-rolled parser that must not import anything else in the tree. It recognizes six commands
(run (default), devices, diagnose, push, version, help) plus flags: --bind,
--webui-port, --no-webui, --open, --headless, --log-level, --no-overrides,
--no-extensions, --cache-dir, -h/--help, -V/--version, and the diagnose-only --out and
--redact-commands. push has positional args and its own flags, so the parser hands it the
remaining args (rest) and cli/push.ts — a thin HTTP client for
/api/push/:channel that loads no native libs and opens no device — parses them.
applyFlagsToEnv() normalizes flags into DECKBRIDGE_* env vars (flags override env, which
overrides defaults), so every downstream reader, including the HID worker thread (env is
process-wide), keeps its existing env reads. Builds made with DECKBRIDGE_BUILD_MOCK=1 also
accept --mock; release builds omit the mock driver and simulation routes.
devices (cli/devices.ts) enumerates HID devices via
deckbridge-native — enumeration-only, never hid_open (the macOS SIGBUS rule of
driver-manager.ts's presence check) — and prints a table before tjs.exit(0). It is wired
into no mise task; invoke it as ./deckbridge devices.
diagnose (cli/diagnose.ts) writes a diagnostics report for bug
reports and exits — no server, never hid_open. It shares its builder with
GET /api/diagnostics: web/server/diagnostics.ts is a pure function over injected
sources, so both emit an identical report. The report embeds settings.json verbatim, commands
and all, by decision; --redact-commands opts out, and a review notice is the first line.
--out <path> overrides the default cache-dir location.
HID device detection
At startup app.ts constructs a DriverManager
(driver-manager.ts); probeAndOpen() iterates
DEVICE_MODELS in priority order and returns the first device that opens. It delegates three
concerns: driver-manager-discovery.ts (HidDiscovery) scans on the scan worker and answers
presence/path/serial queries from that snapshot; worker-pool.ts (WorkerPool) keeps the
parked worker of a failed open for reuse (shared with the dock scan); driver-manager-pacing.ts
(ProbePacer) owns the reconnect state — the one pending retry timer, the failed-attempt count
the tray shows, and the probe-in-flight flag. DriverManager itself holds only the mode
('real'/'mock'); the driver is dock 0's (Dock.driver). Tests inject fakes of the first two
through DriverManagerDeps.
Adaptive probe backoff
The retry interval is adaptive, not fixed. ProbePacer starts at HID_POLL_INTERVAL_MS
(3 s) and keeps it while scans stay fast; once a scan takes SLOW_ENUMERATE_MS (250 ms) or
longer, pacer.note() doubles the delay on each slow scan, capped at
RECONNECT_BACKOFF_MAX_MS (30 s). A machine where hid_enumerate is pathologically slow thus
backs off instead of scanning every 3 s.
Probe order
DEVICE_MODELS defines explicit probe priority; the registry currently holds eighteen USB
models. Elgato MK.2 and Mini come first, then Mirabox 293, 293S and K1, then AKP05E and AKP05,
then the remaining rebadges. See registry source.
Supported devices records support evidence and
Device specs derives registry values; do not duplicate those inventories
here.
Family differences that stay model-specific:
- D6 revisions use different packet sizes; descriptor correction accepts only listed candidates.
- AKP153E rev. 2 has calibrated mappings; AKP153R keeps unverified inherited defaults.
- Rev. 1 rebadges are registered separately, with image batching disabled by default.
- Stream Deck + is emulation-only:
CORA_PROFILESnever enters USB discovery.
Open strategy per device
Every driver opens by path only, through HidDeviceBase._openPath(path)
(devices/hid-device-base.ts). The scan worker
enumerates, HidDiscovery.paths(model) filters that snapshot by VID + PIDs (+ usagePage/
usage when the model sets them), and DriverManager/DockScanner pass one path per physical
unit to open(hidPath). A model with no matching path is skipped; the USB worker never
enumerates.
There is no hid_open(VID, PID) fallback. On macOS it opens the device's first IOKit
interface (often an unrelated collection), and a permission-denied open SIGBUSes the process;
elsewhere it can grab the wrong unit when two of the same model are plugged in. A refused
hid_open_path releases hidapi (_releaseLibAfterFailedOpen, so worker.terminate() stays
safe) and throws — on macOS with the Input Monitoring hint — and the next scan retries.
Mirabox/Ajazz/Fifine models set usagePage/usage (all 0xffa0/1) to pick the data
interface; Elgato models match on VID + PID alone.
libhidapi loading
libhidapi candidate paths
loadHidapi() (ffi/hidapi.ts) tries a platform-specific candidate
list via FFI.dlopen, HIDAPI_LIB first when set (packaged releases: the extracted embedded
lib):
| Platform | Candidates (tried in order, after HIDAPI_LIB) |
|---|---|
| macOS | /opt/homebrew/lib/libhidapi.dylib (Apple Silicon), /usr/local/lib/libhidapi.dylib (Intel), bare libhidapi.dylib |
| Linux | /usr/lib/x86_64-linux-gnu/libhidapi-hidraw.so.0, /usr/lib/libhidapi-hidraw.so.0, bare libhidapi-hidraw.so.0, bare libhidapi.so |
| Windows | hidapi.dll, C:\Windows\System32\hidapi.dll |
If all fail, the error includes install instructions (brew install hidapi /
sudo apt install libhidapi-dev). The handle is shared across the worker session
(_workerHidLib); hid_exit() + close() run on disconnect.
HID path enumeration
Discovery call chain
Production discovery uses mirabox_hid_list_supported(): hid-scan-worker.ts supplies
registry-derived VID/PID pairs, ffi/hid-discovery.ts parses the returned rows, and
HidDiscovery.paths(model) applies usage filters afterward. USB workers receive explicit
interface paths. Full diagnostics use mirabox_hid_list_all(). The native exports keep legacy
lookup helpers that production discovery bypasses. Missing native libraries produce empty
discovery. See HID via FFI.
Serial and firmware reading
Reading serial and firmware
ElgatoHidDriver.open() calls _readDeviceInfo() after acquiring the handle, reading serial
(feature report 0x03/0x06) and firmware (0x04/0x05) into deviceSerial/deviceFirmware. The
worker propagates them via the 'opened' message, and applyDeviceModel() forwards them to the
CORA capabilities packet — only when model.cora.usePhysicalIdentity is set.
Adding a new device model
Follow Adding support. Known protocols reuse existing worker drivers; a new model still needs:
- Model facts and registration.
- Variant-specific hardware evidence.
- Linux permissions and artwork.
- Regenerated published device data.
- Relevant independent regression cases.
New protocols need additional registrations (factories, tuning, documentation families), and
Elgato-compatible strategies need a complete ProtocolStrategy. Keep FFI imports inside
worker-side modules.
CORA device capabilities
The CORA capabilities packet (sent to the Elgato desktop on connect) advertises the child device geometry: rows, columns, key count, image dimensions, PID, product name, and serial.
applyDeviceModel() in driver-manager.ts is the entry
point for a primary-dock model change. It delegates its server-facing half to
applyModelToServers() (dock-status.ts), wrapped as
CoraDock.applyModel() (cora-dock.ts) for every dock
(Dock.setModel()/Dock.start()). The model's cora spec (DeviceCoraSpec) drives it:
- PID —
model.cora.productId. Elgato models use their real USB PID; Mirabox 293/293S advertiseELGATO_MK2_PID; K1 Pro advertises the Mini PID (0x0063). - Geometry —
advertisedGeometry(model)resolvesmodel.cora.advertiseAsthrough the registry and derives geometry from that canonical model (293/293S →mk2, K1 Pro →mini; Elgato models omit it). No copied geometry constants exist. - Identity — when
model.cora.usePhysicalIdentityis true (Elgato only), the worker's real serial/firmware is patched into the config; Mirabox keeps the default dock identity.
Server and WebUI update sequence
webui.resetImages()clears browser previews.Dock.setModel()selects model-specific retained frames. Worker lifecycle changes reset admission queues.server.setDeviceConfig(patch)— update PID (+ serial/firmware for Elgato).setChildGeometry(geo)on both CORA servers (the child reallocateskeyStates, keeping the overlapping prefix on a hot-swap).server.restartMdns(pid)— re-advertise with the new PID (skipped when PID + serial are unchanged, to avoid dns-sd/avahi churn on every unplug/replug).server.pushChildCapabilities()— push updated caps to a connected desktop.webui.notifyDeviceModel(...)— broadcast model state to the browser.
Called on real connect (with worker serial/firmware), disconnect (resets to DEFAULT_MODEL =
MK.2), mock-mode startup, and a WebUI model-selector change.
Multi-device: scanned docks
DeckBridge runs one dock by default: once the primary device is connected it stops scanning
USB, and no second dock is created. "multiDeck": true in settings.json (Settings → Multiple
decks) raises the cap to MAX_MULTI_DECK_DOCKS (= 2): dock 0 (the primary, the only one with
tray/key-activity coupling) plus one scanned dock. Turning it off tears the scanned dock
down. MAX_DOCKS (= 4) is the structural ceiling: it sizes the dock-index/CORA-port space, not
the user-facing limit.
Every dock is one Dock (dock.ts): a CoraDock server pair
(cora-dock.ts, which owns the pairing watchdog, applyModel() and
startWithRetry()), whichever driver is attached (attach()/detach() — a DockDriver
(devices/driver.ts): the WorkerHidDriver proxy, or MockDriver
whose worker-only calls are no-ops, so no call site needs ?.), the per-device identity and
DockPrefs, the ExtraKeyWidgets scheduler, knob (EncoderActions) and side-key
(ExtraKeyActions) overrides, and its own last-frame store (LastFrames,
dock-frames.ts). The Dock handles the CORA side itself: images
(wireDockImages(): driver, then frame store, then WebUI mirror), Elgato-app brightness (unless
the per-device override is on), and pairing (markPaired, auto-restart notice, the ~1 s
brightness re-push). Everything a Dock reports goes through optional DockHooks (status,
disconnect, key activity, WebUI mirrors, …), so the WebUI mirror is just the hook set the caller
passes.
Page-following layouts. The side keys and strip zones can show a different layout per Elgato
page. The USB worker already hashes every CORA frame for the image cache; it sends that hash to the main
thread on the existing imageSent message (re-emitted as frameHash), so no hashing happens there. Each Dock owns a DockPages
(dock-pages.ts) wrapping a PageTracker
(page-tracker.ts): noteFrame() stores the hash and arms a
settle timer, and once the key burst is quiet the pure matcher
(page-match.ts) scores the saved pages
(page-config.ts). A commit repaints the widgets, whose
configFor resolves through DockPages.layoutConfig(). A blank vector or a disconnected app holds
the current layout. The WebUI's PagesController (pages-controller.ts)
caches each dock's latest observation, owns the snapshot/edit routes and tells the Dock to
re-match (pagesChanged).
Standby and brightness (DockStandby)
Dock.brightness is the requested level (the slider, settings.json); the level on the
panel is the effective one, computed by the per-dock DockStandby
(dock-standby.ts, pure maths in
standby-policy.ts, injectable clock, main-thread timers
only). It owns the display state (active | dimmed | night | standby | off), the input gate that
swallows a waking press at the single choke point in wireDriver, the 15 s app-gone debounce
over the CORA child link, and the optional sleep/wake shell hooks
(standby-hooks.ts). StandbyClockPainter
(standby-clock.ts) paints the standby clock; the app's frames
come back from LastFrames. The only new worker message is setSleep, sent solely for a model
that declares sleep; the rest reuses setBrightness and the splash path. The WebUI reads the
state from the ordinary status broadcast (displayState, effectiveBrightness) and edits
settings through GET/POST /api/standby.
DriverManager dock bookkeeping
DriverManager emits one 'changed' event for any dock/probe state change (dock attached or
stopped, brightness, pairing, rename, failed probe); app.ts refreshes the WebUI dock list and
the tray from that one listener. It keeps every live dock in one Map<index, Dock>: WebUI
per-dock actions go to driverManager.dock(i) (or dockForDevice(deviceKey) for an identity
edit), getDockStatuses() lists every dock with a driver attached, and an image-only tuning
change loops the map (Dock.applyTuning() re-derives the effective model from the registry
entry). Dock 0's servers live for the process: DriverManager attaches each newly probed driver
to it, detaches it on unplug, and attach() replays the app's last frames over the splash when
the same model returns (the Elgato app keeps its pairing and never re-pushes).
DockScanner (dock-scanner.ts) runs its own scan timer
(every HID_POLL_INTERVAL_MS) over HID paths not claimed by the primary or another scanned
dock, and builds a Dock per newly found unit (createDock()) into that same map, claiming its
HID path and drawing its index from a pool of free indices (1..cap-1, lowest wins). With
multi-deck off the pool is empty and the timer never runs: a single connected deck ends all
USB enumeration, rather than a 3 s tick for the life of the process.
A scanned dock is self-contained: its own CORA server pair on ports strided by
CORA_PORT_STRIDE (5343 + 2·index / 5344 + 2·index), its own mDNS advertisement and
identity, its own WorkerHidDriver (own worker thread and libhidapi handle). Dock.start()
binds with maxAttempts: 1 (the primary's startWithRetry() retries unboundedly because its
ports are protocol-fixed); the scanner's tick is its retry. A scanned dock has no tray, no
key-activity feed and no replug replay: it stops with its USB unit, and a replugged unit is a
new dock.
app.ts wires a coraDockFactory returning a CoraDock per identity, calls
driverManager.startScan() on startup and stopScannedDocks() on shutdown. The primary dock's
CORA pair is wrapped in a CoraDock the same way and handed to DriverManager (cora dep),
which wraps it as dock 0.
Browser deck: a dock with no USB device
A phone or tablet page is dock 3 (VIRTUAL_DOCK_INDEX, CORA ports 5349/5350): fixed, not
pool-allocated, so its ports — and the Elgato pairing, which is by IP:port — never move. It is
the same Dock as every other, built around a VirtualDeckDriver
(main/virtual-deck/) instead of a USB
worker: a main-thread DockDriver with no FFI. renderCoraImage (on the ACK path, so O(1))
stores the frame and hands it to the DeckHub; a page's key press becomes a driver 'key'
event and takes the normal wireCommonDriverEvents → child.sendKeyEvent path.
DriverManager.addExternalDock registers it and reserves the index in the DockScanner, so a
scan can never take it.
The pages talk to a fourth listener, DeckServer
(web/server/virtual-deck/, port 44660): a
separate tjs.serve that exists only while the feature is on and serves the page,
POST /deck/api/pair and one WebSocket; no admin route is reachable on it.
VirtualDock (virtual-dock.ts) starts and
unwinds dock + hub + listener together; wireVirtualDeck() is the only thing app.ts calls.
Wire format, flow control and credentials
The wire format is contract-deck.ts: JSON control messages and
binary image frames ([type, key, format, 0] + JPEG). tjs has no dependable send-buffer signal,
so each page acks frames and FrameQueue keeps only the newest frame per key while the window
is full. HeldKeys merges mirrored pages; liveness (3.5 s of silence) and a lateness check
(InputFreshness, 1 s) mean a held key is always released and a press that sat in a socket
buffer is dropped, never replayed.
Credentials are the push plan's dbp_… records (hashed in settings.json accessTokens) with
scope deck. A pairing is a one-time code exchanged at /deck/api/pair, and the WebSocket
authenticates with its first message (browsers cannot set Authorization on a WebSocket). The
page bundle is built separately for old browsers (DECK_JS_TARGETS in ts/build.mjs, plus a
legacy-API check).
Settings persistence
Per-device settings (brightness, brightness override, extra-key widget config, saved Elgato
pages with their own layouts) and device
identity (a stable MAC/serial pair, so the Elgato desktop doesn't see a "new" device on every
reconnect) persist to <cacheRoot>/settings.json (same cache root as the extracted native libs;
see Build pipeline):
- settings-store.ts — disk I/O only:
loadSettings()/saveSettings()write atomically (<target>.tmp-<pid>-<counter>+ rename, as innative-libs.ts), andpluginsDir()resolves the plugin-file directory. - device-identity.ts — pure, no I/O.
deviceKeyFor(hidPath, serial?, modelId?)prefers a stableusb:<serial>key over the volatile HID path;generateMacAddress()/generateSerial()derive a deterministic MAC/serial from that key via FNV-1a, so replugging the same device reproduces its identity. - settings.ts —
PersistedSettingsis the solesettings.jsonwriter, loaded once byapp.tsbefore the WebUI (also under--no-webui). It shape-guards on load and on JSON import (a corruptextraKeysmap is stripped rather than dropping the whole identity entry, which would force an Elgato re-pair) and providesgetOrCreateIdentity(),importDevices(),syncDockBrightness(). - dock-prefs.ts —
settings.for(deviceKey)returns aDockPrefs: one dock's brightness override, extra keys, strip mode, knobs and tap feedback, read and written live. A dock with no entry (mock mode, pre-connect) gets a runtime-only fallback, never persisted. - web/server/settings-file-controller.ts — the WebUI's file surface: export, open in the OS, and import, pushing imported brightness/override/extra-key changes live to the driver and WS clients.
Nothing outside these files touches settings.json: DriverManager/Dock/DockScanner get
the PersistedSettings instance from app.ts and read per-device values through DockPrefs.
Identity keys for protocol_version 1 devices
protocol_version 1 devices (293S + its 7 rebadges, model.wire.sharedSerial) all report the
same hardcoded USB serial, so callers pass modelId for them and the key becomes
usb:<serial>:<modelId>. That keeps two different v1 models apart; two units of the same v1
model still collide, since no per-unit bit exists. settings-store.ts migrates a pre-fix bare
usb:355499441494 entry to the 293S-suffixed key on load, once, only when unambiguous (exactly
one bare entry, no suffixed entry yet), so an existing 293S user keeps their identity instead of
re-pairing in Elgato.
WebUI (http://localhost:3000)
Dynamic grid
The key grid rebuilds when the model changes: KeyPreview.rebuild(keyCount, columns)
(key-preview.ts) sets root.style.gridTemplateColumns and creates the buttons;
KeyGridPreview.tsx calls it from an effect keyed on
[keyCount, columns, modelId, coraProfile, clickable]. The initial render is 5×3 (MK.2
default); the first status WebSocket message rebuilds to the actual layout.
Grid per device
| Device | Grid |
|---|---|
| Stream Deck MK.2 | 5×3 (15 keys) |
| Stream Deck Mini | 3×2 (6 keys) |
| Mirabox 293V3 | 5×3 (advertised as MK.2) |
| Mirabox 293S | 5×3 (left 5 of 6 hardware columns; advertised as MK.2) |
| Mirabox K1 Pro | 3×2 (advertised as Mini) |
| Ajazz AKP153E/R (rev. 2) | 5×3 (same as 293V3; advertised as MK.2) |
| Fifine AmpliGame D6 (rev. 1 and rev. 2) | 5×3 (same as 293V3; advertised as MK.2) |
| AKP153/E/R, MSD-ONE, GK150K, Vision 01, TMICE Stream Controller (v1 rebadges) | 5×3 (left 5 of 6 hardware columns, same as 293S; advertised as MK.2) |
Device model selector
A <select id="model-select"> dropdown switches the advertised model in mock mode (visible
but disabled while a physical device is connected — the device determines the model).
Selecting one tears down the mock driver, creates a new MockDriver with that geometry, and
calls applyDeviceModel(model) (CORA caps, mDNS, grid). The dropdown is populated from
/api/state; changes POST to /api/device-model.
WebUIServer collaborators
web-ui-server.ts composes focused collaborators. Route handlers get the per-concern
controllers on RouteContext directly; WebUIController holds only the state that spans them
(full state, dock selection, mock device).
Collaborator table
| Class | File | Owns |
|---|---|---|
ActivityBuffers | web/server/activity-buffers.ts | ring buffers for logs/CORA-comm/key-events; batches comm entries on the COMM_BROADCAST_FLUSH_MS timer (see Concurrency model); broadcast skipped in __SIMPLE_ONLY__ builds, ring buffers still fill |
DockRegistry | web/server/dock-registry.ts | the live per-dock DockStatus[] list + which dock is selected |
ImageChannel | web/server/image-channel.ts | per-dock CORA image cache + the single live WS image channel (mirrors only the selected dock; instant dock-switch without an Elgato re-push) |
PersistedSettings | infra/settings.ts (passed in by app.ts) | settings.json — see Settings persistence |
SettingsFileController | web/server/settings-file-controller.ts | settings export/import + the multi-deck toggle |
ExtraKeysController | web/server/extra-keys-controller.ts | extra-key widget config — see Plugins and extra-key widgets |
MockConfig (defaultMockConfig/mergeMockConfig) | web/server/mock-config.ts | the mock driver's spoofed identity fields, validated for the WebUI's mock-config editor |
WebRequestGuard (isAllowedWebRequest) | web/server/web-request-guard.ts | Host/Origin check against localhost/127.0.0.1/[::1] (+ this machine's own interface IPs when --bind 0.0.0.0) — DNS-rebinding/CSRF hardening for the WebUI, independent of the CORA ports' lack of auth (see Network exposure) |
withSecurityHeaders (securityHeaders) | web/server/security-headers.ts | the headers on every WebUI response, wrapped around each dispatch in web-ui-server.ts: framing, CSP, nosniff, Referrer-Policy (see Network exposure) |
Plugins and extra-key widgets
Some device models expose physical keys outside the emulated CORA grid. The Mirabox 293S's
6th column (wire ids 16/17/18, model.keyMap.extraKeys) has no switches, so those keys are
display-only: not CORA keys, but able to show something. extra-keys.ts
lets the WebUI assign each a widget: clock, date, text, weather (Open-Meteo, no API
key, plain HTTP since the slim runtime has no TLS), command (runs a user-supplied shell command
and shows its stdout — full trust, like a build script), plugin (below), or none.
renderWidgetLines() picks the text; widget-layout.ts
lays it out in pixels and widget-raster.ts rasterizes it
into a 24-bit BMP in the style's colours. The BMP goes through the existing splash path, so the
worker's transform, not the main thread, does the FFI JPEG encode. Each paint (bitmap, lines,
style, clipped flag) is mirrored to the WebUI. A per-dock ExtraKeyWidgets scheduler (one
instance per Dock with a driver attached) ticks every second and repaints a key only when its
content changed.
Text layout, fonts and size preview
Layout uses one of three six-rung font ladders (assets/font-atlas.ts: Spleen monospace,
proportional X11 Helvetica with per-glyph advances, or Slim = Barlow Condensed, OFL-1.1,
rasterized anti-aliased at generate time into a 4-bit alpha atlas with tight per-glyph boxes),
per the key's style (ExtraKeyTextStyle: size step or 'fit', wrap, font, alignment,
padding, line gap −8..8 px, tight lines, bold/outline, ellipsis). Rasterization stamps 1-bit
glyphs and blends alpha glyphs over the canvas. POST /api/extra-key/preview re-lays the last
painted lines at every size in that style for the size picker; that is exact because the lines
don't depend on the size. All atlases hold ASCII, Latin-1 and …; their shared glyph index is
assets/font-glyph-index.ts, kept out of the atlas so the workers don't bundle it.
The atlas data are latin1 strings (one char per byte), decoded with Buffer.from(s, 'latin1').
scripts/gen-font-atlas.mjs regenerates both files from the upstream BDFs and TTFs.
Extra keys with a switch exist too: the AJAZZ AKP05E paired as a Stream Deck + (its default)
drops its right column from the 4×2 Plus grid, and its emulation key map lists those keys in
extraKeys (image wire ids 15/10) with input codes in the parallel extraKeyInputs (5/10).
wireCommonDriverEvents routes such a press to onExtraKey by image wire id instead of CORA,
and a per-dock ExtraKeyActions (command-actions.ts,
shared with the knob override in encoders.ts) runs the key's pressCommand, at most one
process per key.
web/server/extra-keys-controller.ts is the
WebUI-facing glue: assign/clear a widget or set a press command (independent fields of one
config; persists + broadcasts), "run now" for command widgets, and the plugin dropdown/status.
Plugins — a third, lazily-spawned worker thread
A plugin widget runs arbitrary user JS from a file in pluginsDir() (see
Settings persistence) on its own worker thread, much lighter than the
always-on USB worker. Plugin JS has the same trust level as the command widget (full
fs/spawn/FFI access); the worker is a crash/CPU isolation boundary only. app.ts and
driver-manager*.ts never reference plugins: the subsystem is private to extra-keys.ts plus
the WebUI controller, outside the core CORA/USB orchestration.
Plugin worker, host and protocol
plugin-worker.ts— the worker entry. A plugin exports{ interval?, async fetch(ctx) }. Before importing any plugin file it deletes the globalfetch/WebSocket, because calling either inside any txiki Worker aborts the whole process (a runtime constraint). A plugin'sctx.fetchis proxied to the main thread overpostMessageinstead.pollLoop()imports the plugin once, then polls it at its configured interval, posting avalue/errormessage per tick.plugin-host.ts— main-threadclass PluginHost. Spawns the worker lazily on the first request for a configured plugin key and tears it down once no plugin keys reference it. Runs aping/pongheartbeat (HEARTBEAT_MS = 2000), respawns a hung/crashed worker, and permanently disables a plugin afterMAX_CONSECUTIVE_KILLS = 3crash-loop restarts. Also executes thefetch()calls proxied from the worker (http://only — no TLS in the slim build). ExposespluginValueFor()(used byextra-keys.ts),pluginKeyStatus()andlistPluginFiles()(the WebUI dropdown).plugin-worker-protocol.ts— theMainToPluginWorker/PluginWorkerToMainmessage union, the plugin counterpart ofhid-worker-protocol.ts.
Built like the HID worker (see Build pipeline): a second esbuild pass bundles
plugin-worker.ts into an ESM string embedded as virtual:plugin-worker, spawned as a blob-URL
module worker.
System tray
A small Rust sidecar (deckbridge-tray, built with tray-icon + tao) shows a status icon and
menu. The main process spawns it and talks to it over two channels: the tray's stdout
(lifecycle + menu events) and a loopback TCP connection (icon/status pushes).
../ts/src/infra/tray.ts (TrayProcess) owns the TS side; app.ts pushes a TrayState on
every device/client connect and disconnect. TrayProcess.close() sends the sidecar SIGTERM
so it doesn't outlive the main process.
| Icon | Condition |
|---|---|
green (full) | USB device open and Elgato client connected |
yellow (usb_only) | USB device open, no Elgato client |
gray (disconnected) | no USB device |
The menu offers Open Web UI, Check Requirements (the /requirements diagnostics page),
Restart Elgato App (see Troubleshooting)
and Quit. Rust only emits the click event for the restart item; the restart runs on the TS
side, which owns settings, logging and the running-process check. The tray is spawned only when
DECKBRIDGE_TRAY_BIN points at the binary (mise run start sets it); if it is unset or the
spawn fails, startTray() returns null and the app runs normally. Protocol details:
rust/deckbridge-tray/README.md.
Build pipeline
Five esbuild passes (build.mjs). Each worker is bundled into a self-contained ESM string
and embedded into the main bundle through a virtual module:
| Pass | Entry | Virtual module | Also written to (debug) |
|---|---|---|---|
| 1 | hid-worker.ts (generic USB worker — Mirabox + Elgato gen1/gen2) | virtual:hid-worker | dist/hid-worker.js |
| 2 | hid-scan-worker.ts (HID enumeration, see Supporting workers) | virtual:hid-scan-worker | dist/hid-scan-worker.js |
| 3 | plugin-worker.ts (see Plugins and extra-key widgets) | virtual:plugin-worker | dist/plugin-worker.js |
| 4 | extension-worker.ts (see Supporting workers) | virtual:extension-worker | dist/extension-worker.js |
| 5 | app.ts + rest of src | — | dist/bundle.js |
The browser-side ui-entry.ts subtree is bundled the same way and embedded as text via the
ui-ts-as-text plugin. Extensions in ts/src/extensions/ are never part of the binary:
ts/scripts/build-extensions.mjs builds each into ts/dist/extensions/<id>/, and the release
ships each as its own zip.
Native dylibs (libdeckbridge_native, libhidapi) are embedded into bundle.js at build time
as latin1 strings (one char per byte; QuickJS keeps them 8-bit, and the compiled bytecode
deflates raw bytes smaller than base64 or gzip+base64). Default on; EMBED_NATIVE_LIBS=0 /
--no-embed disables. This makes the bundle platform-specific. On first run they extract to
~/Library/Caches/deckbridge/native-<hash>/ (macOS) or
${XDG_CACHE_HOME:-~/.cache}/deckbridge/native-<hash>/ (Linux). DECKBRIDGE_NATIVE_LIB /
HIDAPI_LIB take precedence when set (dev: mise run build populates them from the just-built
dylibs).
Build pipeline diagram
At runtime the worker starts as a blob-URL module worker
(new Worker(URL.createObjectURL(new Blob([src])), { type: 'module' })): a compiled tjs binary
can't load a worker from a disk path or data: URL, but a blob URL works in both tjs run and
the compiled binary, keeping the single-file binary self-contained.
Testing
mise run test # bundle + run every ts/test/*.test.ts on the txiki.js runtime
mise run test-client # browser regressions for the web UI, in headless Chrome
mise run ci-checks # lint + typecheck + test + test-client + knip
Tests run on the same QuickJS/txiki.js runtime as the app (not Node), with no test
framework: each ts/test/*.test.ts is a standalone script using tjs:assert + a local
test()/runTest() helper, and exits tjs.exit(failed > 0 ? 1 : 0). The test task bundles
each with node build.mjs --test <name> then runs $TJS run dist/test/<name>.js (also the
single-file recipe).
All tests are hardware-free (pure logic, fakes, local sockets). Real-device work lives in
the smoke task (USB HID, needs a Mirabox) or e2e (black-box test of a packaged zip).
The web UI has a separate suite: ts/test/client-regressions.tsx renders Preact against a real
DOM in headless Chrome. ts/scripts/test-client.mjs bundles it to an IIFE, loads it in a
temp HTML page with --dump-dom, and asserts the dump contains data-result="pass" (browser
from CHROME_BIN, else the macOS Chrome path, else google-chrome on PATH). It covers what
can't run under tjs: the hand-rolled useSyncExternalStore port, the shallow-memo useStore
selector (ts/src/web/client/lib/store.ts), and the clipboard copy UI. It is a .tsx, so
mise run test never picks it up; ci-checks runs it as test-client.
Test coverage by area
Test file names, by area (ts/test/<name>.test.ts):
- CORA framing —
packets,cora-frame(resync, oversizedpayloadLength),assembler,elgato-child-image-bounds(out-of-rangekeyIndexdrop). - Image pipeline —
translator,image-cache,dock-frames(driver-before-WebUI order, same-model replay),image-render. - Drivers & models —
device-models(probe order, keyMap permutations),driver-manager,dock,hid-worker-host(failed-openreuse),hid-worker-batching,mirabox-parse,k1pro-chunk-pad. - Device catalog —
device-catalog-baseline(hand-written expectations per model),device-catalog-validate(a failing fixture per validator check),packet-replay(versioned fixtures ints/test/fixtures/packets/replayed through the real drivers on a fake hidapi). - Servers —
server(primary + child over real TCP),pairing(full MK.2 handshake),feature-response. - Web & infra —
web-ui-server,ui-helpers-docks,key-preview,tray,mdns-advertiser,native-libs,buffer-shim. - Settings & identity —
settings-store(atomic write, corrupt JSON),device-identity(stable key, deterministic MAC/serial). - CLI —
cli(incl.tjs run <bundle>vs compiled argv shape),cli-devices. - Standby —
standby-settings,standby-policy,dock-standby,standby-clock,standby-hooks,mirabox-driver-sleep. - Plugins & extra keys —
extra-keys,plugin-host(lazy spawn, heartbeat respawn,MAX_CONSECUTIVE_KILLS). - Captured hardware —
hid-report-descriptorreplays the real report descriptor of a Fifine D6 rev. 2 (test/fixtures/fifine-d6-rev2.report-descriptor.json, frommise run d6-capture). - Probes (non-assertion) —
k1pro-probe-layout,splash-sizewrite samples under/tmpfor offline analysis.
Coverage
mise run coverage # instrument, run all tests, emit merged report
The task runs the full tjs suite under Istanbul source instrumentation, which is
engine-agnostic (JS rewritten to increment counters on globalThis.__coverage__), so tests run
on real txiki.js/QuickJS-ng, not Node/vitest (which can't host the FFI, socket and worker
tests). Each process flushes its map to ts/coverage/.tmp/<name>.json;
ts/scripts/coverage-report.mjs (Node) merges them into ../ts/coverage (stdout summary,
index.html + lcov-report/, lcov.info). The task builds the Rust dylib first
(depends = ["deckbridge-native"]) so FFI tests run for real. COVERAGE_ENFORCE=1 fails below
thresholds (off by default).
Platform abstraction layer
Platform shim diagram
Module map
Module dependency diagram
Directory layout
Repository tree
Tier import rules are enforced in ts/eslint.config.mjs.
deckbridge/
├── mise.toml ← task runner (build, compile, run, typecheck, test)
├── docs-site/ ← Docusaurus documentation site (npm; mermaid→SVG, local search)
├── ts/
│ ├── build.mjs ← esbuild config (also bundles tests via --test <name>)
│ ├── test/ ← *.test.ts suite (run on the txiki.js runtime; see "Testing")
│ ├── src/ ← one folder per runtime tier; each is one eslint-plugin-boundaries element
│ │ ├── main/ cora/ worker/ plugin/ transform/ devices/ infra/ cli/ shared/
│ │ ├── dev/ ← hardware probes + probe-utils
│ │ ├── ffi/ platform/ assets/ ← FFI bindings · txiki shims · generated blobs
│ │ └── web/ ← contract*.ts (wire DTOs) · server/ · client/ (browser UI)
│ └── dist/ ← bundle.js (~2.2 MB, workers + native dylibs inlined)
│ · hid-worker.js · hid-scan-worker.js · plugin-worker.js (debug copies)
├── rust/
│ ├── deckbridge-native/ ← JPEG resize/rotate + HID path-enum cdylib (FFI via DECKBRIDGE_NATIVE_LIB);
│ │ Cargo features: jpeg-upstream (default) / jpeg-fork, HID behind `usb`
│ ├── jpeg-encoder/ ← vendored jpeg-encoder 0.6.1 fork (interleaved optimized Huffman; JPEG_FORK=1)
│ └── deckbridge-tray/ ← system-tray sidecar binary (Rust; tray-icon + tao)