Goal: split PolyKybdHost into a Qt-free operational core ("the server") and
thin clients, so the keyboard host can run and be driven without Qt — from
a CLI (polyctl), a headless service, or the existing Qt tray GUI.
This builds directly on the HID-worker refactor
(docs/hid-worker-refactor.md): the device layer already runs on its own
Qt-free thread and the GUI already talks to it through a job/event seam
(WorkerBridge). This plan widens that seam into a process-level API.
Already Qt-free (no changes needed):
polyhost/device/—PolyKybd,HidWorker,HidHelper, caches, mock (exceptim_converter.py, see below)polyhost/handler/— window tracking (pywinctl), remote TCP, KDE reporterpolyhost/input/— all platform input helpers (pynput / D-Bus subprocess)polyhost/settings.py,services/sunlight_helper.py,services/lang_regions.py
Qt remaining in operational paths — the complete list:
| File | Qt usage | Disposition |
|---|---|---|
device/im_converter.py |
QPixmap PNG decode → numpy |
Replace with Pillow. Also a latent bug today: since the worker refactor this runs on the HID worker thread, and QPixmap is documented GUI-thread-only. Fix early regardless of decoupling. |
services/updater.py |
QThread + pyqtSignal |
Re-base on threading.Thread + plain callbacks; the Qt signal becomes a core event. |
host.py |
QApplication orchestration: timers, bridge, QtDBus sleep listener, all GUI |
Orchestration moves to PolyCore; GUI stays. Sleep listener: see §5.3. |
services/unicode_cache.py |
QPixmap/QIcon menu icons |
GUI-only concern — moves/stays client-side. Not a core dependency. |
forwarder.py |
Qt tray app | Untouched in this plan; optional later client (§7, H4). |
That's the whole list — the decoupling is smaller than it looks.
┌────────────────────────┐ ┌──────────────────────┐
│ Qt tray GUI (client) │ │ polyctl CLI (client)│ future: TUI / web
└───────────┬────────────┘ └──────────┬───────────┘
│ in-process observer (M1) │ JSON-RPC over local socket
▼ ▼
┌──────────────────────────────────────────────────────┐
│ Control server: JSON-RPC 2.0-shaped, framed JSON │
│ (stdlib multiprocessing.connection: UDS / named pipe│
│ + authkey HMAC) │
├──────────────────────────────────────────────────────┤
│ PolyCore (Qt-free facade) │
│ commands in → events out (observer callbacks) │
│ owns: PolyKybd, HidWorker, DeviceManager, │
│ OverlayHandler tick thread, Sunlight, Updater, │
│ settings, MRU caches, reconnect state machine │
└──────────────────────────────────────────────────────┘
A plain-Python object that absorbs the operational half of today's
PolyHost:
- owns
PolyKybd+HidWorker+DeviceManager+OverlayHandler+Sunlight+ updater + settings - runs the 250 ms window-tracking tick on its own thread (today: QTimer on the Qt main thread; pywinctl polling does not need Qt — but see §5.4)
- the reconnect probe/apply split stays, except "apply" no longer touches
widgets: it updates core state and emits a semantic event. The
user-facing status strings are built core-side (CLI and GUI must show the
same text); icon choice from the status enum is client-side.
decide_reconnect_applyis already Qt-free and moves into core untouched. - public surface:
core.call(name, **args)mapped to explicit methods, andcore.subscribe(callback)— callbacks invoked on core threads; clients marshal to their own loop (the Qt client keeps doing exactly whatWorkerBridgedoes today).
PolyHost shrinks to: tray icon + menus + dialogs + a Qt adapter that turns
core events into queued signals. Nothing else.
JSON-RPC 2.0-shaped messages over stdlib multiprocessing.connection.
The transport — the genuinely fiddly cross-platform part — comes entirely
from the standard library: multiprocessing.connection.Listener/Client
provide Unix domain sockets (Linux/macOS), real Windows named pipes
(AF_PIPE, no pywin32), built-in HMAC challenge authentication
(authkey; Python 3.12+ negotiates a stronger digest, while older
interpreters — which this repo does not exclude via python_requires —
fall back to legacy HMAC-MD5; acceptable here because the transport is
local-only and gated by filesystem permissions, not a network boundary),
and length-prefixed message framing (send_bytes/recv_bytes). Zero
new dependencies, maintained with CPython itself — which also serves the
"stays fully update-able" constraint.
- Endpoint location via
platformdirs(already a dependency): Linuxuser_runtime_dir, macOS~/Library/Application Support/PolyHost/— socket files mode 0600. Windows: named pipe\\.\pipe\polykybd-<user>. Theauthkeysecret lives in a 0600 file in the config dir on all platforms (defense in depth on POSIX, the primary gate on Windows). - Never use the pickling
send()/recv()— onlysend_bytes/recv_bytescarrying UTF-8 JSON, keeping the protocol language-agnostic and safe. The dispatch layer is JSON-RPC 2.0-shaped (id/method/params/result/error) but hand-rolled (~100 lines for our ~20 methods). - Requests/responses per JSON-RPC; server-push events as JSON-RPC
notifications on the same connection after the client sends
events.subscribe. One reader thread per connection server-side; writes serialized through a per-connection lock. No asyncio conversion of the codebase. - First exchange is
hellocarrying protocol version + host version; clients refuse on major mismatch (same philosophy as the firmware protocol gate).
Library evaluation (2026-06):
| Candidate | Verdict |
|---|---|
stdlib multiprocessing.connection |
Chosen — transport/auth/framing for free, no deps |
| RPyC 6.x | Closest full framework: threaded, bidirectional (events = callbacks), maintained. Rejected because the API becomes implicit (transparent object proxies — no clean hello version gate, harder protocol evolution across self-updates) and clients must be Python. Revisit if the explicit protocol becomes a burden. |
jsonrpcserver/jsonrpcclient |
Protocol-only helpers; dormant (last release 2022). Hand dispatch is less code than integrating them. |
| Pyro5 | Maintained but server→client callbacks need a client-side daemon (clunky for tray events); TCP-only on Windows. |
| zerorpc | Depends on gevent — a hard conflict with PyQt and modern CPython; effectively legacy. |
| gRPC / FastAPI+WebSocket | Heavy dependency/codegen or an asyncio web stack for a tray app; rejected (gRPC also per original plan). |
| D-Bus / REST / raw pickle | Rejected as before (portability / no push / unsafe). |
| RPC | Backs onto |
|---|---|
status.get |
core state (connected, fw/proto version, name, hw, current lang, paused) |
lang.list / lang.set |
keeb.get_lang_list() / change_language job |
brightness.set, idle.set |
set_brightness / set_idle jobs |
overlay.send {files} / overlay.enable/disable/reset |
the existing coalesced "overlay" job |
keymap.layer_count / keymap.buffer / keymap.set {layer,row,col,keycode} / keymap.default_layer |
run_sync reads / set_dynamic_keycode job |
commands.execute {lines} |
execute_commands job (cancel-aware) |
fw.version / fw.flash {path, apply} / fw.apply_staged |
get_fw_version via run_sync; flash inside worker.exclusive() with fw.progress events |
pause.set {bool} |
worker suspend/resume + core state |
update.check / update.install {component} |
threaded updater (host + firmware); progress via events |
mru.save, settings.get/set, host.shutdown |
existing calls |
logs.tail {n} |
core-owned log files |
Events: status_changed, lang_changed, overlay_activity
(thinking/idle — drives the tray icon), warning {text, timeout},
fw_progress {pct, msg}, update_available {component, version},
console_line. The GUI's icon state machine becomes a pure consumer of
these; CLI polyctl watch just prints them.
stdlib-only (argparse + socket + json), new console-script entry point.
polyctl status, polyctl lang list|set deDE, polyctl brightness 50,
polyctl overlay send file.png, polyctl keymap set 1 2 3 0x29,
polyctl flash fw.bin --apply (renders fw.progress as a progress bar),
polyctl pause|resume, polyctl watch, polyctl shutdown.
Must work with PyQt5 not installed (enforced by test, §6).
- M1 (in-process server): the existing tray app embeds core + control server. CLI talks to the running tray app. No lifecycle changes, immediate CLI value. GUI keeps an in-process observer (no socket hop for itself).
- M2 (headless mode):
python -m polyhost --headlessstarts core + server with no Qt import anywhere in the process. For machines without a display / for SSH use. The socket doubles as the single-instance lock: GUI start = connect to existing core if the socket answershello, else become the host process. - M3 (optional, later): GUI always a socket client, core always a
daemon (systemd user unit / autostart launches
--headless; tray app is optional chrome). Only do this once M1/M2 have soaked — it changes startup/update UX and the autostart story (add_to_startup.py).
- Tray icon, menus, all dialogs/widgets, balloon notifications,
unicode_cacheicon rendering,get_icon. - Update confirmation UX (core emits
update_available; the install command comes from a client; progress/relaunch handled core-side). - The layout editor stays a Qt dialog but reads/writes via the API (keymap buffer is ~1.4 KB — trivially RPC-able).
ImageConverter→ Pillow (new dependency, replaces the QPixmap decode). Must produce byte-identicalOverlayData: add a golden test comparing Qt-decode vs Pillow-decode over the existingtests/device/*.pngfixtures before deleting the Qt path. Also fixes the current QPixmap-on-worker-thread violation.- Updater de-Qt:
UpdateChecker/UpdateInstaller/FwUpDownloaderbecome plain threads withon_progress/on_done/on_errorcallbacks → core events. The Windows locked-DLL relay-restart logic is process-level and stays in core. - logind sleep listener (QtDBus in
host.py): two options — (a)jeepney(pure-Python D-Bus, tiny) in core; (b) keep a listener in whichever client has Qt and have it callmru.saveover the API. Take (a): headless mode must save MRU on sleep without any client attached. - pywinctl headless reality: it
sys.exit(1)s at import without a display (observed in this container). Core must lazy-import it inside the tick thread, degrade to "no window tracking" with a warning, and expose--no-window-tracking. Overlay switching by active window simply stays off in that mode; explicitoverlay.sendvia CLI still works. - OS input-language switching (
input/*, pynput): session-bound — works from any per-user process, no change needed. It moves with the reconnect apply logic into core (it's operational, the CLI needs it too). - Overlay file references: v1 passes file paths (same-machine assumption, like today). A content-upload RPC is a later extension if a remote client ever needs it.
- Threading contract: core event callbacks fire on core threads. The Qt
adapter re-uses today's bridge pattern (emit queued signal);
polyctlprints from its reader thread. Document per-event payloads as plain JSON-serializable dicts from day one — that keeps the in-process observer and the socket path identical. HidHelperlock-passing API removal (follow-up noted in the worker refactor) folds naturally into H1 — single-consumer ownership makes it dead code.
Hard requirement: every phase works on all three OSes. Per-component matrix:
| Component | Linux | Windows | macOS |
|---|---|---|---|
| Control transport | UDS via multiprocessing.connection (platformdirs runtime dir) |
named pipe (AF_PIPE) + authkey, no pywin32 |
UDS (app-support dir) |
| Image decode (Pillow) | wheels everywhere — strictly more portable than the Qt decode (no Qt platform plugin needed headless) | ✓ | ✓ |
| Window tracking | pywinctl/X11 (needs display; lazy import, §5.4) | pywinctl ✓ | pywinctl (needs Accessibility permission — unchanged from today) |
| Sleep → MRU save | logind via jeepney (replaces QtDBus; Linux-only as today — the current listener is already guarded by sys.platform) |
none today; firmware-side USB suspend covers it. Optional later: WM_POWERBROADCAST listener in the GUI client forwarding mru.save |
none today; USB suspend covers it |
| Autostart | unchanged through M1/M2 — autostart keeps launching the same entry point it does today. Windows: do NOT touch the venv-activating .bat/.vbs wrapper chain (add_to_startup.py, see CLAUDE.md — regressed once before). Only M3 (daemon-by-default) would revisit autostart, which is one more reason it's deferred. |
||
polyctl |
console-script entry point; stdlib sockets on all three | ✓ (polyctl.exe shim from the same venv) |
✓ |
CI-less repo: the loopback/RPC tests run against whatever address family the
host OS provides (multiprocessing.connection picks UDS on POSIX, named
pipes on Windows behind the same Listener/Client API), so the transport
code under test is identical on every platform; the authkey handshake and
framing are exercised everywhere.
The current mechanism (GitHub-release check → in-place file replacement →
restart; Windows locked-DLL relay restart for hidapi.dll; firmware download
- HID flash) must survive every milestone:
- Single package, no skew: core, GUI, and
polyctlship in one install/venv. An update replaces all of them atomically (same installer as today), so client↔core version skew can only exist during the restart window — and thehelloprotocol-version gate catches exactly that: a client that reconnects to a newer/older core gets a clean "restart me" signal instead of undefined behavior. - Updater lives in core (it needs to: headless mode must self-update with
no GUI attached).
update.checkruns on the same 24 h cadence as today;update_availableis an event; the decision comes from any client (polyctl update installor the GUI prompt) or — config-gated — a future auto-install option for unattended headless machines. - Restart paths:
- M1 (GUI hosts core): identical to today — installer replaces files,
restart_app()relaunches the whole process.restart_appand the installer are already Qt-free logic; the H0 de-Qt ofupdater.pykeeps them that way. - M2 (
--headless): same flow; the Windows relay script must re-exec the original command line (capturesys.argvwhen writing the relay, so a headless core relaunches headless, a GUI process relaunches the GUI). This is a one-line generalization of the existing relay writer. - Clients across a core restart:
polyctlfails fast with a clear message; the GUI runs a reconnect loop with backoff and greys the tray icon while the core is down (it already has a disconnected icon state).
- M1 (GUI hosts core): identical to today — installer replaces files,
- Update vs. firmware flash mutual exclusion: an update install must
never restart the process while a fw flash holds
worker.exclusive(). The installer's final restart step is sequenced through the worker (a normal job that the exclusive section naturally delays), mirroring how the flash already serializes against everything else. - Firmware updates are unaffected:
fw.flashwraps the existing exclusive-flash path;polyctl flashmakes firmware updates scriptable on headless machines — a net gain for updatability.
- H0 golden tests: Pillow vs Qt conversion byte-equality over all fixture overlays; updater logic tests re-pointed at the threaded versions.
- Core facade tests: drive
PolyCoreagainstPolyKybdMock(exists) — command→job mapping, event emission order, reconnect state machine (extendstests/gui/worker_bridge_test.py's pure-logic approach). - RPC loopback tests: start the server on a temp socket in-process, speak raw JSON over a client socket — request/response, event push, malformed input, version handshake, authkey rejection on mismatch. No Qt.
- Import-guard test: poison
PyQt5insys.modules(sys.modules['PyQt5'] = None) and importpolyhost.core,polyhost.server,polyhost.cli— proves the headless tree never touches Qt. Run in CI-less repo via the normal unittest suite. - CLI smoke: spawn
--headlesswith the mock device enabled, runpolyctl status/lang listagainst it.
| Phase | Scope | Ships value | Risk |
|---|---|---|---|
| H0 ✅ done | Qt-ectomy of operational deps: im_converter→Pillow (+golden tests), updater→threads, sleep listener→jeepney |
Fixes QPixmap-off-main-thread bug | Low (golden tests gate) |
| H1 ✅ done | Extract PolyCore from host.py; window-tracking decision (tick_window_tracking) + events (status_changed/overlay_activity) replace direct UI calls; Qt adapter on the existing bridge; drop HidHelper lock-passing API |
host.py 1401→1185 lines, core 515; PolyCore runs a full lifecycle headless with PyQt5 poisoned |
Highest — done as C1 (extract) / C2 (reconnect apply) / C3 (window tick + event) |
| H2 ✅ done | JSON-RPC control server (polyhost/server/) + protocol.py contract + loopback tests; polyctl CLI (polyhost/cli/, stdlib-only); socket-as-instance-lock |
polyctl drives the running tray app (M1); verified CLI→server→core end-to-end headless with PyQt5 poisoned |
Low–medium |
| H3 ✅ done | --headless entry point (polyhost/headless.py, lazy Qt imports in main_app → zero Qt in-process); core-owned window-tick thread (PolyCore.start_window_tracking); core auto-applies reconnect headless (apply_reconnect_in_core); socket instance-lock shared with the GUI. Updater-check-trigger-into-core deferred to a follow-up (the updater is already Qt-free; headless self-update is additive). |
Server without Qt (M2) — the stated goal: python -m polyhost --headless + polyctl, verified end-to-end |
Done |
| H4 (optional, out of this effort) | GUI as pure socket client; daemon-by-default; forwarder as client | Full symmetry (M3) | Medium — only after M1/M2 soak |
H1 actuals / notes for H2–H3:
- The window-tracking decision is
PolyCore.tick_window_tracking(); the pywinctl poll itself stays on the GUI main thread (macOS Accessibility constraint, per the worker refactor). H3 wires a core-owned tick thread for headless. - Per-user-action device methods the CLI needs (
lang.set←change_keeb_language,overlay.send←send_shortcuts/send_overlay_data, firmware flow) are still GUI-orchestrated; H2 promotes them to explicitPolyCoremethods that both the GUI and the JSON-RPC layer call. - The updater threads are Qt-free (H0b) but still constructed/triggered in
host.py; H3 moves the check trigger into the core for headless self-update (§5c). - Event names/payloads pinned by H1 (mirror these in the H2 notification schema):
reconnect(probe snapshot dict),status_changed{connected, device_present, paused, state_changed, text, icon, lang},overlay_activity{state: "thinking"},overlay(completion → idle),overlay_warning(str),console(serial, text),change_keeb_language(lang, ok, msg), plus the updater events incore/events.py.
Each phase is independently shippable and keeps the full suite green. H0 touches disjoint files and can start any time. H2 can be developed in parallel with H1 only after H1's event names/payloads are pinned in this doc (the server's notification schema mirrors them) — until then H2 waits. H3 builds on H1+H2; same agent-orchestration pattern as the worker refactor.
- Remote (cross-machine) control API — the forwarder's TCP relay remains the only network path; the control socket stays local-only.
- Any firmware change (none needed).
- Rewriting dialogs/TUI — clients beyond
polyctlare future work.