main.py Decky Plugin class + asyncio background watchers (triggers,
resume, fan, TDP, Sunshine, mDNS, GPU coexistence) + load hooks
py_modules/
docky.py engine: config/state, run_task, action/mode runners,
triggers, favorites, sunshine-engine wrappers (no decky deps)
sunshine.py Sunshine flatpak control (install/launch/encoder/compose/hdr/pair)
mdns.py avahi/mDNS discovery healing (keeps _nvstream advertised)
deckops.py built-in dock fixes (audio, controller, TDP, flatpak, detect)
padswap.py PCSX2 input-profile logic + dock signals
sysenv.py shared clean_env() (strips Decky's PyInstaller LD_LIBRARY_PATH)
src/
index.tsx the Quick Access panel
components/ EditorModal, PairModal, StatusModal, inputs
taskdefs.ts task-type table driving the add/edit forms
util.ts types + call()/toast()/clone() backend plumbing
dist/index.js built frontend bundle (gitignored build artifact — `pnpm run build`)
install.sh / uninstall.sh
Backend / frontend split. The Python engine (docky.py) is intentionally
decky-free and unit-testable — main.py is the only file that imports
decky. The backend runs as root; main.py pins HOME to the deck user
before importing the engine so ~ resolves correctly.
- Tasks → Actions → Modes → Triggers are plain data in
config.json;run_taskis the dispatch table. Add a task type by adding arun_taskbranch + ataskdefs.tsentry (and a status/verb probe if it's stateful). - State writes are serialized through
docky.update_state()under a lock so the watcher and frontend calls can't clobber each other's fields. - Blocking work runs off the event loop (
asyncio.to_thread) — mode activation,get_state, Sunshine install/launch/pairing — so the UI stays responsive. - Subprocess calls go through
sysenv.clean_env()and carry timeouts so a hang can't freeze the root backend.
Building the frontend needs Node + pnpm (installed locally under ~/.local on
the dev Deck). Build on the Deck, not another machine.
# frontend
pnpm install # first time
pnpm run build # → dist/index.js
node --check dist/index.js # sanity
# backend sanity
python3 -m py_compile main.py py_modules/*.py
# deploy + reload (rm -rf's the plugin dir, recopies, restarts the loader)
sudo ./install.shinstall.sh copies main.py, plugin.json, package.json, dist/index.js,
and every py_modules/*.py (glob — a new module can't be forgotten), chowns
root, and restarts plugin_loader.
Copy only the changed file(s) into /home/deck/homebrew/plugins/docky/ and
restart once: sudo systemctl restart plugin_loader. Avoid rapid repeated
restarts — they can leave orphaned backends holding the loader's port; if that
happens, kill leftover Docky (...main.py) processes by PID and
systemctl reset-failed plugin_loader.
api_versionmust be absent fromplugin.json(legacy/0) — the frontend usesdecky-frontend-lib's legacycallPluginMethod, which Decky blocks atapi_version > 0.- Background coroutines must be module-level, not
Pluginmethods — Decky's class wrapping breaksself.method()calls from within the backend. dist/index.jsis a gitignored build artifact, not committed — the Decky store rebuilds the frontend from source (matching the official plugin template), so a committed bundle is redundant. Runpnpm run buildafter anysrc/change;install.shrefuses to run if the bundle is missing. Ship it in Release zips for Node-less manual installs.- The frontend is gamepad-navigated: wrap side-by-side button rows in
<Focusable flow-children="horizontal">so the d-pad moves left/right.
For the why behind these and other deliberate trade-offs (root backend, fan
daemon handling, polling, the Stepper vs SliderField choice, etc.), see
Design notes before changing them.
There's no automated test suite yet. Before committing:
python3 -m py_compilethe backend,node --check dist/index.jsthe bundle.sudo ./install.sh, confirm a single backend and a clean load in theplugin_loaderjournal.- Eyeball the panel in Game Mode for UI changes.