A macOS Stream Deck plugin (public name Switchboard, by Moving Average Labs).
Built with the Elgato SDK v2 (TypeScript/Node). The plugin UUID is
com.movingavg.switchboard (renamed from the legacy com.johnknox.safarijump).
Don't change it casually — installed buttons reference it, so a change orphans
configured keys unless migrated. scripts/rename.sh performs such a migration
(it rewrites the UUIDs in the Stream Deck profile store so settings survive); see
that script before ever renaming again. Twelve actions today (the manifest is the
source of truth — scripts/make-hero.py reads it).
src/
plugin.ts # entry: registers every action, streamDeck.connect()
actions/*.ts # thin SDK glue per action (onKeyDown/onDialRotate/…)
applescript/
escape.ts # escapeForAppleScript (shared)
runner.ts # runAppleScript via osascript; classifyError (TCC)
mac/ # PURE, tested logic (no SDK, no I/O)
targets/safari ... # see files below
safari/ # Safari tab logic (targets.ts, applescript.ts, runner re-export)
mac/claude-*.ts # Claude Code detection: claude-scan (ps→batched lsof +
# shell-snapshot busy children = "N shells still running"; pgrep
# misses ancestors), claude-state (title marker: braille=working,
# ✳=waiting; tmux pane-tty map), claude-transcript (~/.claude/
# projects freshness), claude-project (state decision + key face)
mac/{coalesce,serialize,press-gate}.ts # shared async plumbing (all unit-tested)
tests/*.test.ts # vitest; one file per pure module
com.movingavg.switchboard.sdPlugin/
manifest.json # actions, layouts, icons, CodePath -> bin/plugin.js
ui/*.html # property inspectors (sdpi-components from CDN)
ui/lib/permissions.js # shared PI Accessibility-warning banner (see below)
layouts/*.json # custom encoder (touchscreen) layouts
imgs/actions/<a>/*.png # icon.png/@2x + key.png/@2x per action
bin/plugin.js # rollup output — COMMITTED (self-contained bundle)
bin/macos/{scroll,tile,axcheck} # compiled Swift helpers — committed, UNIVERSAL
# (arm64+x86_64), Developer ID signed + notarized
helper/{scroll,tile,axcheck}.swift # native helpers (scroll wheel / window tiling / AX probe)
scripts/
build-helpers.sh # build the 3 helpers universal; auto-sign if a Developer ID cert is present
notarize-helpers.sh # no-Fastlane notarize fallback
make-hero.py # regenerate docs/switchboard-hero.png from the manifest
rename.sh # UUID migration tool (see Rename section)
fastlane/ # Developer ID signing + notarization (see Releasing)
Design rule: put all logic in pure src/mac/* (or safari/*) functions and
unit-test them; keep src/actions/* as a thin shell that wires SDK events to
those functions. Every action's hard part lives in a tested pure module.
Interaction grammar (keep it consistent): rotate = browse a set; dial press
= escape to a known place (top / maximize / last window / previous doc / exit
copy-mode) or toggle the mode when there is no "known place"; touchscreen tap =
flip the dial's mode/scope (speed, windows↔apps, panes↔windows, session↔ALL,
tile arrangement A↔B) and the strip always shows the current mode (shared
layouts/mode-dial.json + a per-action *Feedback() pure fn); key long-press (500ms, PressGate in
src/mac/press-gate.ts, fires AT the threshold) = capture the current context
into the button ("teach the button": front tab / frontmost app / current tmux
window; Window Ring's add/remove is the same gesture). Modes/scopes are
transient per-dial memory (Map keyed by action id, cleared onWillDisappear) —
they intentionally reset to the default on appearance. EXCEPTION: the tmux
pane dial's panes/windows mode persists in settings (user request). BOTH tmux
dials scope every command to the client/session in the FRONTMOST macOS window
via resolveFrontTmux (src/mac/front-tmux.ts, ~0.3s probe cached 2s) and DO
NOTHING when iTerm isn't frontmost (strip shows a dash) — untargeted tmux
commands act on tmux's own "current" session/client, which can be a background
terminal.
npm run typecheck # tsc --noEmit
npm test # vitest (pure modules) — 435 tests today
npm run build # rollup -> bin/plugin.js, then postbuild runs `streamdeck validate`
npm run build:helper # build all 3 Swift helpers UNIVERSAL (scripts/build-helpers.sh);
# auto-signs with Developer ID if that cert is in the keychain
python3 scripts/make-icons.py # regenerate ALL key faces + list icons from the design system
python3 scripts/make-hero.py # regenerate the README hero after adding/renaming actions
npx @elgato/cli restart com.movingavg.switchboard # reload the plugin live
Committed build artifacts (important): unlike a typical SDK plugin, both
bin/plugin.js (the rollup bundle, with the SDK bundled in — external: []) and
the three bin/macos/* helpers are committed, so the .sdPlugin folder is
self-contained for drag-and-drop / Homebrew installs. Therefore: after any
source change, npm run build and commit the updated bin/plugin.js, or the
installed copy ships stale code. The build step is gated by streamdeck validate.
Reload semantics (important):
- Code-only change (rebuilt
bin/plugin.js):streamdeck restart(above) reloads it live — no app restart. streamdeck restartsilently no-ops on this setup (SD 7.5): the CLI prints ✔ and StreamDeck.log shows the deep link "Handled", yet the old plugin process keeps running (bit us twice in one day — new features "didn't work" because the deck was still on a days-old bundle). ALWAYS verify the reload took:ps -o lstart -p $(pgrep -f switchboard.sdPlugin/bin/plugin.js)— the start time must be now. Reliable reload:kill $(pgrep -f switchboard.sdPlugin/bin/plugin.js)— Stream Deck respawns the plugin within seconds on the new bundle. Full app relaunch (killall "Stream Deck" && open -a "Elgato Stream Deck") only needed for manifest changes; an osascriptquitmay be blocked with error -128.- Debugging the LIVE plugin: temporarily add
import { appendFileSync } from "node:fs"and trace to/tmp/sb-trace.loginside the poll, rebuild, kill-respawn, read the file, then STRIP the trace. (require()throws in the ESM bundle — and the resulting per-tick throw is swallowed by CoalescedRunner, so a broken trace looks like a dead poll.) This is how the C-locale bug was caught: the trace showed the plugin's raw bytes differing from every shell probe. - Manifest change that adds/renames an ACTION: the Stream Deck app caches the action list — the user must fully quit and relaunch Stream Deck for new actions to appear. PI/code changes don't need this; new actions do.
- Property-inspector HTML is re-served whenever the inspector is opened; just reopen it.
- Node plugins require
"CodePath": "bin/plugin.js"in the manifest. Without it Stream Deck rejects the plugin ("missing code path") and no actions appear. - Settings types must be
type, notinterface.SingletonAction<T>needsT extends JsonObject(an index signature). Aninterfacefails to satisfy it; atypealias gets an implicit index signature. Same for any object passed tosetFeedback(FeedbackPayload). sdpi-selectdoes NOT persist its default until actively changed. A button left on the first shown option saves no value (bit us on Gmail service + the tmux window dropdown). Fix: default in the ACTION code (x ?? "...") and/or have the PI persist the shown value on load.- The built-in $B1 layout's
titleitem ignoressetFeedbackpushes — it is bound to the user-editable action title, so a plugin cannot change it at runtime (thevalueitem updates fine; bit us on the App Windows mode label). For any plugin-driven text above the value line, use a CUSTOM layout with your own item keys (layouts/mode-dial.json, painted via*Feedback()fns). A layout/manifest change needs a full Stream Deck quit + relaunch to take. UserTitleEnabled: falsehides the Title FIELD but not a stored title. A title the user already typed keeps rendering over the key image (bit us on the live tmux key face — it overlapped the status-bar cursor). To stop the title being DRAWN, set"ShowTitle": falseon the action's manifest State. Use both for a fully plugin-owned key face.- Layout (touchscreen) items must NOT overlap. You can't layer text over a
full-area pixmap. To draw text on a background, render everything in ONE SVG
pixmap (
buildBackgroundSvg) — seemac/tmux-window.ts+layouts/tmux-window.json. - SVG data URIs work as
setImage(keys) and pixmap (setFeedback) values:data:image/svg+xml;base64,<b64>. Lets you render dynamic graphics with no rasterizer at runtime. XML-escape any user text in the SVG. - The KEY rasterizer paints
hsl()colors as BLACK (the touchscreen pixmap pipeline renders them fine). Cost three debugging rounds on the tmux key face: every hex element showed, every hsl() element was "invisible" black-on-black. Key-face SVGs must emit hex only — convert withhslToHex(src/mac/svg.ts); a tmux-key test asserts nohsl(literal survives. - Synthetic keystrokes coalesce. Sending N arrow keys back-to-back via System
Events drops most of them, so "lines per tick" didn't scale. The scroll dial now
posts ONE proportional
CGScrollWheelevent via the nativebin/macos/scrollhelper. For one-shot keys (Cmd+↑, Cmd+`) keystrokes are fine. - Safari returns
missing value(no error!) forURL of tab/name of tabon an unloaded session-restored tab. Atry/on errorguard doesn't catch it, andlength of missing valuethrows -1728, killing the whole tab scan — one stale tab broke every Jump to Tab button. Coercemissing valueto""explicitly (seebuildNormalScriptinsrc/safari/applescript.ts). - Stream Deck's minimal env has NO LANG/LC_ALL → child processes run in the C
locale → tmux TRANSLITERATES all non-ASCII output to "_" — the Claude
braille/✳ title markers arrived as underscores and every session read
"waiting" ON-DEVICE while every UTF-8 shell probe looked correct (hid for a
week; found only by tracing the plugin's raw stdout). ALL subprocess runners
must pass
env: UTF8_ENV(exported fromtmux-runner.ts). When on-device behavior contradicts an out-of-process probe, suspect the ENVIRONMENT first and dump the plugin's raw bytes. - Polling actions must repaint through
CoalescedRunner(src/mac/coalesce.ts). A bareif (refreshing) returnguard silently DROPPED the explicit repaint after hold-to-capture whenever it collided with an in-flight poll tick — shipped as a "stale hot indicator" bug. Never guard-and-drop; coalesce. - Scripted doc edits: verify the replaced string is unique first. A python
str.replacetargeting a "What it does" bullet also matched the README intro and shipped it broken (bullet spliced mid-sentence) for two releases. Re-read the rendered section after any scripted edit. - README figures are code-generated. docs/tmux-live-keys.png, claude-project-keys.png, claude-spark.gif, and dial-strips.png render FROM the real image builders (tsx snippet + inkscape) — regenerate them whenever a key-face design changes, or the README silently lies.
- Verifying tmux syntax: use a scratch session (
tmux new-session -d -s __sdtest…kill-session -t __sdtest) — never experiment on live sessions. - Two distinct macOS permissions, classified separately in
applescript/runner.ts: Automation (Apple Events, error -1743) for controlling apps via AppleScript; Accessibility (error -1719) for keystrokes / scroll /CGEventPost. Surface the right re-enable path; never fail silently. - Live Accessibility warning in the PI. Actions needing Accessibility show a
⚠️ banner in their settings screen when the grant is missing. Wiring: the PI includes<script src="lib/permissions.js"></script>(self-injects the banner- Re-check button), which sends
{event:"checkAccessibility"}to the plugin; the action'sonSendToPlugincallsrespondToAccessibilityCheck(ev.payload, import.meta.url)(src/actions/pi-permissions.ts), which runs the side-effect- freebin/macos/axcheckprobe and replies. Only a definitiveuntrustedshows the banner (missing/old helper → treated as granted, no false alarm). Automation (Apple Events) can't be probed without prompting, so those PIs use a static Requires note instead.
- Re-check button), which sends
- Dial direction.
mac/rotation.tsmaps ticks →next/prev(positive = clockwise per the SDK). For a visible rotation (the tile dial orbiting a window) the on-hardware sign can feel inverted;tile-dial.tsexposes an Invert dial direction setting rather than hard-coding a sign. Don't change the sharedrotationDirectionto "fix" one action — flip at the action boundary. - PI ↔ plugin messaging uses
SDPIComponents.streamDeckClient: receive with.sendToPropertyInspector.subscribe(e => e.payload), send with.send("sendToPlugin", payload). The dropdowndatasource=mechanism and the permission check both ride this (seefocus-tmux.ts/pi-permissions.ts). fsdoes not expand~. Resolve a leading tilde yourself (files.tsexpandHome). Stream Deck also launches plugins with a minimal PATH — use absolute binary paths (e.g.tmux-runner.tsfindTmuxPathprobes/opt/homebrew/bin/tmux…).- tmux ↔ iTerm mapping: a tmux session's attached client tty (
list-clients) equals the iTerm2 session'stty. Match on that to raise the right GUI window (mac/iterm.ts). Detached tmux commands act on tmux's "current" pane/window, which is usually—but not always—what the user is looking at. - PI file pickers: the SD webview exposes a chosen file's absolute path on
file.path(what<sdpi-file>relies on). Folders use<input webkitdirectory>- deriving the path (see
ui/open-file.html).
- deriving the path (see
- Icons come from ONE generator —
scripts/make-icons.pyrenders every key face and list icon (20/40/72/144 via inkscape) from a shared token system: ink ground #0F1211 (matches the live key faces), 3px strokes on a 72 grid, family colours (phosphor=tmux, azure=windows/apps/web, amber=BBEdit, teal=files), and the "jack-line" strip at each key's foot. Add new actions by adding a glyph fn there — don't hand-draw one-off icons. Live faces (tmux-key.ts, window-ring.ts, key-image.ts) use the same tokens in hex.
- Write the pure logic + tests in
src/mac/<feature>.ts+tests/<feature>.test.ts. - Write the action shell in
src/actions/<feature>.ts(@action({ UUID: "com.movingavg.switchboard.<x>" })). - Register it in
src/plugin.ts. - Add the action object to
manifest.json(Keypad or Encoder; customlayoutif needed;PropertyInspectorPathif it has a settings screen). - Add icons under
imgs/actions/<x>/(icon 20/40 + key 72/144 viainkscape). Add a PI underui/— include<script src="lib/permissions.js"></script>if the action needs Accessibility (and wireonSendToPlugin→respondToAccessibilityCheck); add a Requires: note for any app/permission. - Native helper? add
helper/<x>.swift, abuild()line inscripts/build-helpers.sh, and a runner insrc/mac/that resolves the binary viaimport.meta.url(seetile-runner.ts). Runnpm run build:helper. npm run typecheck && npm test && npm run build(commits-worthy: also the rebuiltbin/plugin.js), thennpx @elgato/cli restart …. Quit + relaunch Stream Deck so the new action shows in the list.python3 scripts/make-hero.pyto refresh the README hero, and bump the action count + test count inREADME.md.
Short version — see that command for the exact, ordered steps. Session-learned traps:
gh release createmakes the tag REMOTELY only — rungit fetch --tagsbefore anygit log vX.Y.Z..HEADdelta check, or it fails "unknown revision".- Before picking the version, check
git log vLAST..HEAD --oneline— an unreleasedfeat:in the delta makes it a MINOR, whatever prompted the release. npm run packrewrites manifest.json WITHOUT its trailing newline — after packing,git checkout -- com.movingavg.switchboard.sdPlugin/manifest.json.- Every
npm run builddrops plugin.js's executable bit: commit withgit add -A && git update-index --chmod=+x com.movingavg.switchboard.sdPlugin/bin/plugin.js.
- Bump
package.jsonversion; update README counts. Commit,git push origin main. npm run build(NOTbuild:helper— see below) thennpm run pack+npm run pack:zip→dist/*.streamDeckPlugin+dist/*.sdPlugin.zip.shasum -a 256 dist/com.movingavg.switchboard.sdPlugin.zip; put that version + sha in BOTHpackaging/homebrew/switchboard.rb(this repo) and~/code/homebrew-switchboard/Casks/switchboard.rb(the separate tap repo). Commit + push both.gh release create vX.Y.Z dist/...streamDeckPlugin dist/...sdPlugin.zip --repo windaddict/switchboard-streamdeck --title … --notes ….- Verify:
brew update && brew fetch --cask windaddict/switchboard/switchboardshould print✔︎ Cask switchboard (X.Y.Z).
CRITICAL — ship the exact notarized binaries. The bin/macos/* helpers are
notarized by their content hash. npm run build only rebuilds bin/plugin.js
(JS, fine). Do NOT run npm run build:helper during a release — re-signing
changes the binaries' hashes and invalidates notarization. Only rebuild helpers
when their .swift changed, and then re-notarize (below) before releasing.
The helpers must be Developer ID signed + notarized or Gatekeeper blocks them on a downloaded install (Scroll/Arrange silently do nothing on someone else's Mac).
- One command:
bundle exec fastlane mac notarize_helpers. It fetches the Developer ID cert (readonlymatch developer_id, falling back to the keychain), rebuilds + signs the helpers, and notarizes the zip via the App Store Connect API key (~/.keys/AuthKey_RP35L4P23G.p8, Team9CHGJ6ZAE6). - One-time, Account Holder only: Apple forbids creating a Developer ID cert via API key. Create it once in Xcode → Settings → Accounts → 9CHGJ6ZAE6 → Manage Certificates → + → Developer ID Application. The lane never tries to create it.
- After notarization, commit the freshly signed
bin/macos/*— those exact files are what Apple notarized and what the release must ship. Then cut the release (above) WITHOUT rerunningbuild:helper. - Gotchas baked into the Fastfile (don't undo them):
matchneedsapp_identifier: [](it validates every identifier before honoringskip_provisioning_profiles);notarizein this fastlane version has nouse_notarytooloption; bare CLI binaries can't be stapled (skip_stapling), so Gatekeeper verifies them online on first run. - Debugging the Fastfile:
bundle exec fastlane lanesparses + lists lanes without contacting Apple. When an action rejects an option or errors cryptically, read the installed gem source rather than guessing —bundle exec ruby -e 'puts Gem::Specification.find_by_name("match").gem_dir'— (that's howapp_identifier: []and the droppeduse_notarytoolwere found).
- This repo commits as the GitHub noreply email
(
4132973+windaddict@users.noreply.github.com, set as the repo-localuser.email) — history was scrubbed of the personal address. Don't reintroduce it. - Commit each logical unit separately; messages
type: description(feat | fix | docs | build | release | chore). Push only when asked / at a clean checkpoint. chmodis unavailable here. To mark a script executable in git:git add <f>thengit update-index --chmod=+x <f>. That sets the index mode but not the working-tree mode, so the file then shows "modified" — fix the disk mode withgit checkout -- <f>.
The full rename is done (UUID + all action UUIDs, the .sdPlugin folder,
rollup.config.mjs, package.json name, the repo directory, and the Stream Deck
symlink). scripts/rename.sh performed it and migrated the configured buttons
by rewriting the action UUIDs in the Stream Deck profile store (settings preserved
— no re-configuring). The script remains as the tool to re-run if the id ever
changes again: quit Stream Deck, then scripts/rename.sh --dry-run to preview,
scripts/rename.sh --yes to apply.
Installed buttons' ACTUAL settings (targets, titles) live in the profile store:
~/Library/Application Support/com.elgato.StreamDeck/ProfilesV3/<id>.sdProfile/Profiles/<id>/manifest.json
— read it to diagnose what a key is really configured to do (quit Stream Deck
before ever EDITING it).
What a UUID rename touches (the script covers all of these — reference list):
manifest.json— pluginUUID+ every actionUUID(10 actions).src/actions/*.ts@action({ UUID })decorators — easy to forget; a manifest/code UUID mismatch makes the SDK reject the plugin and Stream Deck disables it (recover with a full SD quit+relaunch). The script now sed-replaces across the whole repo (grep -rl) precisely to avoid this.- The
.sdPluginfolder name,rollup.config.mjsconst,package.jsonname +build:helper/validatepaths,tests/scroll-runner.test.tspath literals, README/CLAUDE self-references, the repo directory, and the SD Plugins symlink.
import.meta.url resolves the scroll helper relative to the bundle, so no code
change is needed there once the folder is renamed. After renaming the live folder,
Stream Deck caches the old plugin path — fully quit + relaunch SD so it
re-resolves the symlink (a CLI restart re-uses the stale path).
This plugin is also a marketing/credibility artifact for John Knox / Moving Average (AI-advisor positioning) — see the strategy doc + flagship essay ("I Directed an AI to Ship Real Software"). Framing: built by directing an AI agent, shared as-is. Keep it tested and honest; the README leads with the story, dev detail second.