A Roon bar widget for Omarchy — now playing, transport controls, and full library browsing, in the Omarchy shell.
It is two pieces: a small Node bridge that speaks the Roon extension API and
exposes it as a localhost HTTP API, and a Quickshell Panel.qml that renders it.
Now playing and the Explore menu · browsing the library, with cover art
The Roon API is a WebSocket protocol with a pairing handshake and a subscription
model. That does not map onto a QML panel that can be created and destroyed as
the bar reloads, so the connection lives in a long-running daemon and the widget
polls http://127.0.0.1:9377/status.
git clone https://github.com/mikstraz/omarchy-roon-widget
cd omarchy-roon-widget
./install.shThen enable Omarchy Bar in Roon → Settings → Extensions on any remote, and add the widget:
omarchy bar put xpresso.roon --section rightThe daemon tries, in order:
ROON_HOSTfrom the systemd unit, if set.- The address that last paired successfully, cached in
daemon/last-core.json. - SOOD multicast discovery (UDP 9003,
239.255.90.90).
Leaving ROON_HOST unset is fine — discovery finds the Core on its own, and
whatever address pairs gets cached for next time. Setting it just skips a round
trip on the first attempt. Because the cache is consulted before discovery, a
Core whose DHCP lease moves is picked up again without any config change.
A few things about the Roon client library and about systemd shape this design, and are worth knowing if you plan to modify it.
onclose does not fire for a connection that never opened.
Transport.close() in node-roon-api guards its callback on
_isonopencalled, so an attempt that fails outright — at boot before the
network is up, or against an address nothing answers on — dies silently and
never calls back. Anything built purely on onclose will hang forever the first
time it fails. The daemon therefore drives reconnection from its own timer.
A hung connect blocks retries for ~130 seconds. If the Core is asleep it may accept the SYN but never reply. The socket stays live for the kernel's full SYN timeout, so a naive "is a connection already in flight?" guard suppresses every retry behind it. The daemon abandons an attempt after 8 seconds instead.
After=network-online.target does nothing in a systemd user unit. That
target does not exist in the user manager, and Omarchy masks
NetworkManager-wait-online.service for fast boot, so it never activates
system-side either. There is nothing meaningful to order against; the daemon
just retries until the network is there.
/status reports why it is not connected, and the widget shows it, so a dark
panel tells you something useful:
| Condition | Message |
|---|---|
| Nothing answers at the address | "Nucleus One appears to be off" |
| Host up, Roon not serving yet | "Nucleus One is starting up" |
| No route to the host | "No network" |
| Accepts nothing, never replies | "Nucleus One is not responding" |
| Connected but not approved | "Waiting for authorization" |
| Searching by multicast | "Looking for a Roon Core" |
The Core's name is cached, so it can still be named in the message after a reboot when the Core itself is off.
The bridge holds a credential to your Roon Core and can enumerate your entire library, so it is treated as sensitive rather than as a toy local endpoint.
Loopback binding is not the whole story. The listener is bound to
127.0.0.1, which keeps other machines out, but it is not by itself a boundary
against a web browser: any page you visit can issue requests to 127.0.0.1, and
a DNS-rebinding attack — attacker-controlled hostname, very short TTL, second
answer of 127.0.0.1 — makes those requests same-origin, so the page can read
the replies too. That would expose the library listing and hand over transport
control. Requests are therefore accepted only when:
- the
Hostheader is one of127.0.0.1:9377,localhost:9377,[::1]:9377(a rebound request carries the attacker's hostname, so it is rejected — this is the load-bearing check); - no
Originheader is present, andSec-Fetch-Siteis absent,none, orsame-origin(browsers attach these to page-initiated requests; the widget'scurlcalls never do); - the peer address is loopback.
Anything else gets 403. There is no CORS header, so nothing is readable
cross-origin either.
Credentials on disk. daemon/config.json holds the Roon pairing token. The
daemon sets umask(0077) at startup so everything it writes is owner-only, and
config.json / last-core.json are gitignored. If you are adapting this, do
not commit them.
Dependencies. The five node-roon-api-* packages are pinned to exact commit
SHAs in package-lock.json, fetched over HTTPS. install.sh uses
npm ci --ignore-scripts; none of the dependencies define install hooks, and
this keeps it that way if that ever changes.
Shell safety. The widget shells out only once, to persist the selected zone,
and passes the zone name as a positional argument (bash -c '…' -- "$1") rather
than interpolating it into the script. All HTTP calls are argv arrays with
encodeURIComponent on user-controlled values, so there is no shell to inject
into.
All GET, JSON unless noted. Bound to 127.0.0.1:9377.
| Endpoint | Purpose |
|---|---|
/status |
Pairing state, zones with now-playing, disconnect diagnostics |
/control?zone=ID&action=A |
play, pause, playpause, stop, next, previous |
/seek?zone=ID&pos=SECONDS |
Absolute seek |
/browse?zone=ID[&item_key=K][&input=TEXT][&pop_all=1][&pop_levels=N] |
Walk the browse hierarchy |
/image?key=K[&w=N&h=N] |
Album art (binary) |
| Path | Role |
|---|---|
Panel.qml |
The bar widget |
daemon/roon-daemon.js |
Roon API bridge |
systemd/omarchy-roon.service |
User service for the bridge |
manifest.json |
Omarchy plugin manifest |
daemon/config.json holds the Roon pairing token and is generated on first
pairing. It is gitignored and should stay off version control.
systemctl --user status omarchy-roon
journalctl --user -u omarchy-roon -f
curl -s http://127.0.0.1:9377/status | jqRetry logging is throttled to roughly once a minute while disconnected, so an overnight outage does not bury the journal.
Omarchy 4.x (Quickshell-based shell), Node.js, and a Roon Core on the same network.
Not affiliated with or endorsed by Roon Labs. "Roon" is their trademark; this is an independent extension built on their public API.
MIT — see LICENSE.

