Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions plugins/goxlr/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.DS_Store
*.log
*.tmp
84 changes: 84 additions & 0 deletions plugins/goxlr/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# Agent guide for the goxlr plugin

This folder is a Ryoku shell plugin. If you are an AI agent editing it, follow
every rule below. They are the same rules `ryoku plugin validate` enforces, so
work that breaks one will not install. This file is self-contained: you do not
need any other document to work here.

## The rules

R1 Place. A plugin is one folder named after its id. Author it under
`$(xdg-user-dir DOCUMENTS)/ryoku-plugins/<id>/` (fallback `~/ryoku-plugins/<id>/`);
`ryoku plugin new <id>` creates it there. Never write into
`~/.local/share/ryoku/plugins/` (the store's install root, receipt-owned) or
`~/.config/quickshell/` (the shipped shell).

R2 Shape. `manifest.json` at the root; `service/Main.qml` (logic, no UI);
`content/Widget.qml` (the one view); optional `content/Panel.qml` (a bar panel);
`README.md`; `LICENSE`; `assets/preview-widget.png` (a real capture); scripts
only under `bin/` with a shebang and the exec bit. Every extra file listed in
manifest `files`.

R3 Id. Lowercase `[a-z0-9][a-z0-9-]*`, unique, not a built-in widget id.

R4 Imports. Only `QtQuick*`, `Quickshell*`, `Ryoku.PluginKit`,
`Ryoku.PluginKit.Singletons`, and files inside the plugin folder. Never
`shell.*`, `Ryoku.Ui*` internals, or a relative import that climbs out of the
folder.

R5 Settings. Declared in `metadata.settings` (types the bar renders: toggle,
choice, multi, int, text); read through `pluginApi.pluginSettings` behind a
default; written only through `pluginApi.saveSetting(key, value)`. Never edit
`shell.json` or `plugins.json` directly.

R6 Commands. Every external program the plugin runs is either in its own `bin/`
or listed in `dependencies.commands`. No `sudo`, `doas`, `su`. A privileged
action is allowed only through `pkexec`, only on an explicit click, only when
listed in manifest `capabilities.privileged` (exact command strings) and
explained in README.

R7 Network. Every host the plugin talks to is listed in
`capabilities.network`. No `curl … | sh`, no downloading and running code.

R8 Files. Write only under `pluginApi.stateDir`
(`$XDG_STATE_HOME/ryoku/plugins/<id>`), `$XDG_CACHE_HOME/ryoku/plugins/<id>`, or
a temp dir. Never touch `~/.ssh`, `/etc`, shell rc files, or another plugin's
folder.

R9 Shell. No `sh -c` with a string built from settings or program output
(injection). Pass argv arrays.

R10 Secrets and binaries. No tokens, keys or credentials in the tree; no
compiled binaries (ELF/Mach-O/.so); scripts only.

R11 Honesty. `official` is never true for a community plugin. `author` is
`Name <mail>`. README says what runs, what it reads, what it writes, and every
privileged/network capability.

## The workflow

1. `ryoku plugin new <id>` scaffolds this folder (already done).
2. Edit: `service/Main.qml` for logic, `content/Widget.qml` for the view, and
`content/Panel.qml` for the bar panel. Capture `assets/preview-widget.png`.
3. `ryoku plugin validate <dir>` and fix every blocking finding (warnings are
advisory but read them).
4. `ryoku plugin add <dir> --bar --yes` to install it, then verify it on the bar
and under **QS Bar Settings > Community**.
5. `ryoku plugin share <id>` ONLY when the user asks to publish. Never share a
plugin unless asked.

## What the host sets

Every entry point receives, from the host: `pluginApi` (see below), `s` (a scale
factor), `active`, `density`, and `widthBudget`. Read them; never assign them.

`pluginApi` exposes:

- `mainInstance`: the live `service/Main.qml` instance every view shares.
- `pluginSettings`: the resolved settings object; read behind a default.
- `pluginDir`: this plugin's folder.
- `stateDir`: `$XDG_STATE_HOME/ryoku/plugins/<id>`; the only place to write.
- `saveSetting(key, value)`: persist one setting.
- `panelOpen` (bool), `openPanel()`, `closePanel()`, `togglePanel()`: the bar
panel controls (present but inert off the bar). The sanctioned widget click is
`onClicked: pluginApi.togglePanel()`.
4 changes: 4 additions & 0 deletions plugins/goxlr/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# CLAUDE.md

Read `AGENTS.md` in this folder before editing anything here.
It holds every rule this plugin must follow and the full authoring workflow.
21 changes: 21 additions & 0 deletions plugins/goxlr/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 josh

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
104 changes: 104 additions & 0 deletions plugins/goxlr/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# GoXLR

A Ryoku shell plugin (`goxlr`) that ports the core of the Noctalia GoXLR
plugin: a bar capsule showing one channel's volume, a mic-mute toggle, a
flash-style notification when any channel changes, and per-channel volume
controls in the panel. It talks to the [goxlr-utility](https://github.com/GoXLR-on-Linux/goxlr-utility)
daemon's local HTTP API — the same daemon the official GoXLR app and the
Noctalia plugin use.

## Requirements

- `goxlr-utility`'s `goxlr-daemon` running and reachable (default
`localhost:14564`).
- `curl` and `jq` on `PATH` (listed in `dependencies.commands`).

## What it does (and deliberately does not)

Ported:

- **Bar capsule**: shows one configured channel's volume (icon, value, or
both), coloured to reflect connection/mute state.
- **Flash as OSD substitute**: Ryoku bar plugins don't get an OSD surface the
way Noctalia widgets can, so instead of a popup, the capsule itself
temporarily switches to show whichever channel just changed — glyph, name,
and new value — for `flashTimeoutMs`, then reverts to the configured
channel. This happens for changes from any source: the GoXLR hardware
itself, the vendor app, or another copy of this plugin on a second
monitor.
- **Mic mute**: right-click the capsule to toggle the mic's cough/mute.
- **Per-channel volumes**: left-click opens the panel, which lists every
channel with a slider and shows live mute state.

Not ported (out of scope for this port): routing matrix, effects/FX
presets, sampler, lighting, and profile switching. These are higher-risk,
higher-surface-area features that don't fit a single bar capsule + panel;
they're better served by the full GoXLR app for now.

## Settings

| key | type | default | description |
| ---------------- | ------ | -------------- | ------------------------------------------------------------ |
| host | text | `localhost` | Daemon host |
| port | int | `14564` | Daemon port |
| refreshMs | int | `1000` | Poll interval while the panel is closed (ms) |
| volumeDisplay | choice | `percent` | Show volumes as percent or raw (0-255) |
| channel | choice | `Mic` | Channel shown on the bar |
| display | choice | `glyph_value` | Bar display: icon only, icon + value, or value only |
| glyph | text | `◆` | Bar icon: a literal character/emoji, or (with `glyphStyle: icon`) a [Material Symbols](https://fonts.google.com/icons) ligature name like `mic`; the mute icon stays `✖` |
| glyphStyle | choice | `text` | Interpret `glyph` as literal text/emoji, or as a Material Symbols icon name |
| showChannelName | toggle | `false` | Prefix the bar value with the channel name |
| scrollStep | int | `2` | Volume change per scroll notch on the capsule (%) |
| muteIndicator | toggle | `true` | Change colour while the shown channel is muted |
| hideWhenAbsent | toggle | `false` | Hide the capsule entirely when no GoXLR is connected |
| flashEnabled | toggle | `true` | Flash the capsule when any channel's volume or mute changes |
| flashTimeoutMs | int | `1500` | How long the flash stays up (ms) |
| flashPollMs | int | `200` | Poll interval while watching for changes to flash (ms) |

## Usage

- **Left click** the capsule: open/close the panel (per-channel volume
sliders and mute states).
- **Right click** the capsule: toggle mic mute.
- **Scroll** over the capsule: adjust the configured channel's volume by
`scrollStep`.

## `bin/` scripts (for compositor keybinds)

Two standalone scripts talk to the daemon directly over HTTP, independent of
the plugin runtime, for binding to Hyprland/compositor keybinds:

- `bin/mic-mute-toggle` — toggles the mic's cough/mute.
- `bin/volume-step <channel> <percent>` — nudges a channel's volume by a
signed percent (defaults: `Mic 2`).

Both honour `GOXLR_HOST`/`GOXLR_PORT` env vars (default
`localhost:14564`). `ryoku plugin validate` flags an `undeclared-host`
warning on both — expected, since the host comes from a shell variable the
static analyzer can't resolve to a literal; it is non-blocking.

## What it reads and writes

Reads: GoXLR channel volumes and mute state from the daemon's HTTP API only
(`localhost`/`127.0.0.1` per `capabilities.network`). Writes: volume/mute
commands back to the same daemon API, and plugin settings via
`pluginApi.saveSetting`. No files, no privileged actions, no other hosts.

## Preview

See `assets/preview-widget.png`.

## Build, check, install

```
ryoku plugin validate .
ryoku plugin add . --bar --yes
```

It lists under **Community** in QS Bar Settings. Publish it only when you
want to share it: `ryoku plugin share goxlr`.

## Author

josh <[email protected]>: this plugin is community-made (`official` is
false).
Binary file added plugins/goxlr/assets/preview-panel.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added plugins/goxlr/assets/preview-widget.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
31 changes: 31 additions & 0 deletions plugins/goxlr/bin/mic-mute-toggle
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Toggles the GoXLR's mic mute (the cough button) directly against the
# goxlr-daemon HTTP API, independent of the Quickshell plugin process. Meant
# to be bound to a key, e.g. in Hyprland:
# bind = , XF86AudioMicMute, exec, ~/.local/share/ryoku/plugins/goxlr/bin/mic-mute-toggle
#
# Reads GOXLR_HOST / GOXLR_PORT from the environment, defaulting to the
# daemon's own defaults (localhost:14564). Requires curl and jq.
set -euo pipefail

host="${GOXLR_HOST:-localhost}"
port="${GOXLR_PORT:-14564}"
url="http://${host}:${port}/api/command"

status=$(curl -fs --max-time 3 -X POST "$url" -H 'Content-Type: application/json' --data-binary '"GetStatus"')
serial=$(printf '%s' "$status" | jq -r '.Status.mixers | keys[0] // empty')
if [ -z "$serial" ]; then
echo "goxlr: no device connected" >&2
exit 1
fi

state=$(printf '%s' "$status" | jq -r --arg s "$serial" '.Status.mixers[$s].cough_button.state // "Unmuted"')
if [ "$state" = "Unmuted" ]; then
target="MutedToAll"
else
target="Unmuted"
fi

body=$(jq -nc --arg s "$serial" --arg t "$target" '{"Command":[$s,{"SetCoughMuteState":$t}]}')
curl -fs --max-time 3 -X POST "$url" -H 'Content-Type: application/json' --data-binary "$body" >/dev/null
echo "goxlr: mic ${target}"
38 changes: 38 additions & 0 deletions plugins/goxlr/bin/volume-step
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# Nudges one channel's volume directly against the goxlr-daemon HTTP API,
# independent of the Quickshell plugin process. Meant to be bound to a key,
# e.g. in Hyprland:
# bind = , XF86AudioRaiseVolume, exec, ~/.local/share/ryoku/plugins/goxlr/bin/volume-step Mic 5
# bind = , XF86AudioLowerVolume, exec, ~/.local/share/ryoku/plugins/goxlr/bin/volume-step Mic -5
#
# Args: <channel> <percent>, both optional (defaults: Mic, 2). <percent> is
# the signed change in percent, e.g. "5" raises, "-5" lowers. <channel> is a
# daemon channel name: Mic, LineIn, Console, System, Game, Chat, Sample,
# Music, Headphones, MicMonitor, LineOut.
#
# Reads GOXLR_HOST / GOXLR_PORT from the environment, defaulting to the
# daemon's own defaults (localhost:14564). Requires curl and jq.
set -euo pipefail

channel="${1:-Mic}"
percent="${2:-2}"
host="${GOXLR_HOST:-localhost}"
port="${GOXLR_PORT:-14564}"
url="http://${host}:${port}/api/command"

status=$(curl -fs --max-time 3 -X POST "$url" -H 'Content-Type: application/json' --data-binary '"GetStatus"')
serial=$(printf '%s' "$status" | jq -r '.Status.mixers | keys[0] // empty')
if [ -z "$serial" ]; then
echo "goxlr: no device connected" >&2
exit 1
fi

current=$(printf '%s' "$status" | jq -r --arg s "$serial" --arg c "$channel" '.Status.mixers[$s].levels.volumes[$c] // 0')
delta=$(( percent * 255 / 100 ))
target=$(( current + delta ))
if [ "$target" -lt 0 ]; then target=0; fi
if [ "$target" -gt 255 ]; then target=255; fi

body=$(jq -nc --arg s "$serial" --arg c "$channel" --argjson v "$target" '{"Command":[$s,{"SetVolume":[$c,$v]}]}')
curl -fs --max-time 3 -X POST "$url" -H 'Content-Type: application/json' --data-binary "$body" >/dev/null
echo "goxlr: ${channel} -> ${target}"
Loading