Skip to content

Latest commit

 

History

5 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

omarchy-roon-widget

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.

The Roon panel: zone selector, now playing with album art and seek bar, transport controls, and the top-level Explore menu. Browsing the Albums list inside the panel, each row with its cover art.

Now playing and the Explore menu  ·  browsing the library, with cover art

Why a bridge daemon

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.

Install

git clone https://github.com/mikstraz/omarchy-roon-widget
cd omarchy-roon-widget
./install.sh

Then enable Omarchy Bar in Roon → Settings → Extensions on any remote, and add the widget:

omarchy bar put xpresso.roon --section right

Finding your Roon Core

The daemon tries, in order:

  1. ROON_HOST from the systemd unit, if set.
  2. The address that last paired successfully, cached in daemon/last-core.json.
  3. 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.

Connection handling

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.

Diagnostics

/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.

Security

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 Host header is one of 127.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 Origin header is present, and Sec-Fetch-Site is absent, none, or same-origin (browsers attach these to page-initiated requests; the widget's curl calls 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.

HTTP API

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)

Files

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.

Troubleshooting

systemctl --user status omarchy-roon
journalctl --user -u omarchy-roon -f
curl -s http://127.0.0.1:9377/status | jq

Retry logging is throttled to roughly once a minute while disconnected, so an overnight outage does not bury the journal.

Requirements

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.

License

MIT — see LICENSE.

About

Roon now-playing, transport controls, and library browsing for the Omarchy bar

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages