- Go 91.6%
- JavaScript 5.1%
- HTML 1.6%
- CSS 1.3%
- Makefile 0.4%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
All checks were successful
Release / Build and publish release (push) Successful in 33s
Two KDE symptoms, one identity and one reconciliation problem. The GlobalShortcuts portal names its KGlobalAccel component after the caller's app id. An unsandboxed process started from a terminal inherits the terminal's app id (org.kde.konsole), so shortcuts appeared under a second "Konsole" entry. The host registry only accepts an app id that names an installed desktop file, and the per-user install had none. The portal now installs io.github.psychofox.OrebuoysDesktop.desktop under XDG_DATA_HOME before Register, unless a packaged copy is already visible. It never overwrites a file it did not create. doctor reports whether the entry is visible. BindShortcuts reconciles removed actions only when it is called with the new set. bind() used to return early for an empty config, so removing the last shortcut left its action in KDE forever. It now performs the empty bind (and tolerates a portal that refuses it). run and reload with no shortcuts open a short-lived cleanup session instead of skipping the backend. KGlobalAccel has no equivalent bind call, so the fallback lists the component's actions and unregisters every ID no longer in the config. Tests cover the desktop entry (creation, 0644, no rewrite, packaged precedence) and the stale-action diff. Live KDE dialog grouping and cleanup still need the Plasma session. |
||
| .forgejo/workflows | ||
| docs | ||
| internal | ||
| packaging | ||
| tools/release | ||
| .gitignore | ||
| go.mod | ||
| go.sum | ||
| LICENSE | ||
| main.go | ||
| Makefile | ||
| README.md | ||
Orebuoys Desktop
A pure-Go tray daemon that fires an Orebuoys board's API from global keyboard shortcuts, on KDE Plasma 6 / Wayland first.
It is deliberately small. The board owns the library, tags, uploads, roles, history and playback controls; this process owns a token, a list of bindings and a tray icon. There is no embedded browser, no Electron, no Qt, no CGO and no local audio path — shortcuts queue playback in the board's voice channel, which is the only place the audio already lives.
- Global shortcuts: the XDG GlobalShortcuts portal
(
org.freedesktop.portal.GlobalShortcuts), with a KGlobalAccel D-Bus fallback for KDE sessions where the portal is absent. - Tray: StatusNotifierItem +
com.canonical.dbusmenuover D-Bus. - Board API: bearer token,
?guild=for multi-server boards. - Pairing:
setupopens the board, you approve in the browser, and the token is saved without copying a secret by hand.
Quick start
orebuoys-desktop setup # pair: approve the device in your browser
orebuoys-desktop configure # open the setup page (or use the tray item)
# choose shortcuts, capture keys, accept the portal's assignment dialog
orebuoys-desktop run # the tray daemon binds the saved keys
setup seeds snapshot, silence and repeat with no keys when the config
has no bindings, so configure has rows to edit and run never fails with
"the config has no shortcuts". configure works whether or not run is
already active; a save asks a running daemon to reload.
What is unit-tested, and what is not
This repository was written on a machine with no compositor session, so the desktop integration is reasoned and unit-tested, not live-tested.
Unit-tested here (see go test ./...):
- config loading, saving, the 0600 permission refusal, validation, stable shortcut IDs;
- the pairing client (start, pending, token, expiry/404), injected polling, guild selection, browser-open fallback and the no-token run path;
- trigger parsing and the KGlobalAccel Qt key encoding;
- exact HTTP method, path,
?guild=, bearer header and JSON body for every action, againsthttptest; - tag/library name resolution, overlap/variant/exclusion propagation, repeat and silence;
- the D-Bus signatures of portal and KGlobalAccel message structs;
- the tray menu model (ids, labels, separators, clicks, revision changes);
- the setup page: Host/Origin/token rejection, state shape without the board token, clip-search proxying and board errors, add/edit/remove validation, atomic saves, daemon reload, concurrent saves and repairing duplicate keys;
- key-capture keysym names, plus Node DOM harnesses that drive the row editor's
Edit → Capture → Save path (PUT carries the key, no second shortcut) and
adding the same clip twice (second binding gets
-2, two config entries); - portal fallback selection and the
configurelifecycle, including flags with no config file and file creation on first save; - doctor formatting and its failure exits.
Still needs a real Plasma Wayland session: portal binding and the permission
dialog, activation delivery from the compositor, tray rendering, KGlobalAccel
registration, and of course audio in a voice channel. The checklist is at the
end of docs/wayland-hotkeys.md.
Requirements
- Go 1.24 or newer to build (developed with Go 1.27).
- KDE Plasma 6 on Wayland for the intended experience, with:
xdg-desktop-portal(frontend),xdg-desktop-portal-kde(implements GlobalShortcuts),- a StatusNotifier host (Plasma's system tray),
xdg-utilsforxdg-open(the setup page and the board open in your browser; the URL is printed if it is missing).
- A board account whose roles include the permissions for the actions you bind:
playfor clips/filters/silence/repeat,recordfor snapshots. A token is exactly its owner; it cannot exceed them.
Build and install
make check # gofmt check, go vet, go test
make build # CGO_ENABLED=0 builds ./orebuoys-desktop
sudo make install # binary, desktop file and systemd user unit
make install puts the desktop file at
/usr/local/share/applications/io.github.psychofox.OrebuoysDesktop.desktop.
That file's basename is the application id the portal associates with this
process (through org.freedesktop.host.portal.Registry). Install it; without it
the portal may see an empty app id and may not remember key assignments.
Releases
Pushing a v* tag builds both architectures on the buildah Forgejo runner and
publishes a release. A manual Release workflow dispatch takes the tag as an
input, so an existing tag can be rebuilt and re-published.
| Asset | Contents |
|---|---|
orebuoys-desktop-linux-amd64, -arm64 |
the static binary |
orebuoys-desktop-linux-amd64.tar.gz, -arm64.tar.gz |
binary, README, LICENSE, desktop file and systemd user unit |
SHA256SUMS |
checksums for the four files above |
tar -xzf orebuoys-desktop-linux-amd64.tar.gz # or -arm64
cd orebuoys-desktop-amd64 # or orebuoys-desktop-arm64
sudo install -Dm755 orebuoys-desktop /usr/local/bin/orebuoys-desktop
sudo install -Dm644 io.github.psychofox.OrebuoysDesktop.desktop \
/usr/local/share/applications/io.github.psychofox.OrebuoysDesktop.desktop
sudo install -Dm644 orebuoys-desktop.service \
/usr/local/lib/systemd/user/orebuoys-desktop.service
cd .. # back to the directory with the downloaded assets
sha256sum -c SHA256SUMS
The release body comes from docs/releases/<tag>.md; see the
template and the
v0.1.0 notes. If there is no file for the tag, the
workflow writes the commits since the previous v* tag (without merge
commits). Either way it appends the install section and the asset table with
checksums, and prints the whole body into the job log. A manual dispatch reads
the notes and the release tool from the default branch, but builds the source
at the tag it is given.
The workflow authenticates with secrets.RELEASE_TOKEN when that repository
secret exists, otherwise with the automatic secrets.GITHUB_TOKEN. Forgejo's
automatic token is writable for push and tag events and read-only for pull
requests from forks; if the API answers 403, create a repository secret
RELEASE_TOKEN with repository write and rerun the job. The token goes to the
release helper in an environment file and is never printed.
Pair this device
The normal setup path is pairing. The app opens the board in your browser; you sign in and approve it, and the app receives its own token and saves it.
orebuoys-desktop setup
setup prompts for the board URL when the config has none. The usual flags
also work, so the flow can be scripted:
orebuoys-desktop setup --server https://soundboard.example.com
orebuoys-desktop setup --server https://soundboard.example.com \
--guild 900000000000000010
--name overrides the name shown on the approval page (default: “Orebuoys
Desktop on ”).
What happens:
setupchecks/api/boardand/healthz, then asks the board for a pairing code (POST /api/auth/desktop/start).- It opens the verification URL with
xdg-open. If no browser opens, paste the printed URL into a browser on this machine or another device. - Sign in to the board and approve the device. Approval requires the
tokenspermission. The request expires after ten minutes; if it expires, runorebuoys-desktop setupagain. - The app polls until approval, saves the token in the 0600 config, then reads
/api/meand/api/guilds. One enabled server is selected automatically; several produce a numbered prompt, and--guildanswers it without a prompt. - It prints the user, the default server and the token expiry.
If the config had no shortcuts, setup seeds snapshot, silence and
repeat with no keys. That is deliberate: a paired device is not a configured
one, and a binding with no key lets the portal ask for an assignment instead of
leaving run with nothing to bind.
Then choose shortcuts and keys in the setup page:
orebuoys-desktop configure
The page opens in your browser on a loopback address. Search the board's clips, add function/clip/filter bindings, capture keys, and ask the portal to assign them. It works with or without a running daemon. Finally start the daemon:
orebuoys-desktop run
The tray's Configure shortcuts… opens the same page in a separate
configure process, which is why it also works when the daemon has no shortcut
backend yet.
Running run with no config token, no OREBUOYS_TOKEN and no --token offers
the same pairing flow before it starts.
Running run with an empty shortcuts list does not fail. The daemon stays up
with its tray and control bus, logs that no shortcuts are configured, and the
tray's Configure shortcuts… adds them; a saved change asks the running
daemon to reload. A list with shortcuts but no keys behaves the same way and
says "no keys assigned yet" in the tray status.
The token is stored only in ~/.config/orebuoys-desktop/config.json (mode
0600). This program never prints it, logs it, puts it in a URL or passes it on a
command line. Revoke it in the board under Account → API tokens; the row is
named after the name you approved. Revocation is immediate, and running setup
again creates a replacement.
Manual token (advanced / automation)
A token minted in the board still works as an override. Open Account → API
tokens (or call POST /api/me/tokens as your signed-in session, e.g.
{ "name": "desktop", "expiresInDays": 90 }) and use it in either form:
OREBUOYS_TOKEN=sb_… orebuoys-desktop run
orebuoys-desktop run --token sb_…
--token wins over OREBUOYS_TOKEN, which wins over token in the config.
Prefer the 0600 config file for a daemon that starts at login: a token in the
environment or on a command line is visible to anything that can read your
process list. A manually supplied token has no recorded expiry, so doctor
describes it as not paired. It carries exactly its owner's permissions and
guild access, so treat it as a password: mint one with an expiry, and revoke it
when this machine stops using it.
Configuration
Default path: ~/.config/orebuoys-desktop/config.json, mode 0600. The file
may contain the token, so group- or other-readable files are refused with the
chmod command to run. --config PATH overrides the location.
{
"server": "https://soundboard.example.com",
"guild": "900000000000000010",
"token": "sb_…",
"paired": true,
"tokenExpiresAt": "2027-01-16T12:00:00Z",
"defaults": {
"overlap": false,
"variant": "normalized",
"excludeRecent": 5,
"tagMode": "or"
},
"shortcuts": [
{
"action": "clip",
"clipId": "0f8c2c1e-…",
"keys": "CTRL+ALT+1"
},
{
"action": "filter",
"q": "bomb",
"tagName": "impact",
"library": "SFX",
"keys": "CTRL+ALT+2",
"overlap": true,
"excludeRecent": 8
},
{ "action": "snapshot", "keys": "CTRL+ALT+S" },
{ "action": "silence", "keys": "CTRL+ALT+X" },
{ "action": "repeat", "keys": "CTRL+ALT+R" }
]
}
chmod 600 ~/.config/orebuoys-desktop/config.json
orebuoys-desktop doctor
Field notes:
server— board base URL.http/https, no query or fragment. A base path is allowed if the board is reverse-proxied under one.guild— optional Discord server id. When set, every guild-scoped request carries?guild=; the board still authorises it against your grants.setupwrites it after reading/api/guilds;--guildoverrides the choice. Omit it on a one-server board and the server chooses its default.token— optional;--tokenwins overOREBUOYS_TOKEN, which wins over this field.setupwrites the token here. Never pass the token on a command line you do not control: it is visible in/proc.pairedandtokenExpiresAt— bookkeeping written bysetupsodoctorcan say the token came from pairing and when it expires. They grant nothing; a manually supplied token leaves them unset.defaults—overlapjoins the mix immediately instead of waiting in the queue;variantisoriginalornormalized(omit for the board's setting);excludeRecentavoids the N most recently played clips;tagModeisororand.shortcuts[].keys— an XDG shortcuts-spec trigger such asCTRL+ALT+1orCTRL+SHIFT+b. At least one modifier is required; a bare global key would steal that key from every application. Optional: without it the portal asks you for a key at startup (or the action appears unbound in System Settings under KGlobalAccel); the setup page can capture one for you. Two shortcuts may not share a trigger. With the KGlobalAccel fallback a configured key is applied at startup and therefore wins over an earlier System Settings edit; omitkeysto leave that assignment to KGlobalAccel. A key KGlobalAccel cannot encode (for example a keypad keysym) is left unassigned with a warning, rather than taking the other bindings down; assign it in System Settings or use the portal.shortcuts[].id— optional explicit id. Otherwise a stable id is derived:play-<clip id>(lower-cased and truncated to 20 safe characters) for clips,play-<hash>for filters, andsnapshot,silence,repeatfor the fixed actions. IDs are what your assigned key is stored against, so they do not change when you reorder the list. The setup page appends-2,-3, … when a derived id is already used, which is how one clip can have several hotkeys; two identical explicitidvalues are still refused, as is an explicit id that collides with a derived one.- Per-shortcut
overlap,variant,excludeRecent,tagModeoverride the matching default for that binding.
Unknown JSON fields are refused, so a typo is an error rather than a shortcut that silently does nothing.
Commands
orebuoys-desktop [run] [flags] run the tray daemon (default)
orebuoys-desktop setup [flags] pair this device through the board in your browser
orebuoys-desktop configure [flags] open the loopback shortcut setup page
orebuoys-desktop doctor [flags] diagnose server, token, portal, tray and bindings
orebuoys-desktop list clips [flags] list clips with their ids
orebuoys-desktop list tags [flags] list tags with their ids and names
orebuoys-desktop --version
Global flags: --config, --server, --token, --guild, -h/--help.
setup additionally accepts --name for the label shown on the approval page.
list clips accepts --q TEXT, --tag NAME|ID and --limit N (1–500).
doctor exits non-zero when a required check fails and prints the fix next to
the failure: which piece is missing, what to install or enable, and which
permission a token lacks.
Examples:
orebuoys-desktop setup
orebuoys-desktop setup --server https://soundboard.example.com
orebuoys-desktop run
orebuoys-desktop list clips --q bomb
orebuoys-desktop list tags
orebuoys-desktop doctor --config ~/.config/orebuoys-desktop/config.json
orebuoys-desktop configure
What each action sends
All requests use Authorization: Bearer <token> and, when guild is set,
?guild=. The routes are the board's existing API; nothing here needs a new
endpoint.
| Action | Request |
|---|---|
clip |
POST /api/play?guild=… with {"clipId": …, "overlap"?, "variant"?} |
filter |
optionally GET /api/tags and GET /api/libraries to resolve tagName/library to ids, then POST /api/play with {"q"?, "tagIds"?, "tagMode", "libraryId"?, "excludeRecent"?, "overlap"?, "variant"?} |
snapshot |
POST /api/capture/snapshot?guild=… (needs record, a grant for the server, and you in the voice channel) |
silence |
DELETE /api/play?guild=… |
repeat |
POST /api/play with the clipId returned by the last successful play |
list clips |
GET /api/clips?guild=…&sort=name&q=…&tags=…&limit=… |
list tags |
GET /api/tags?guild=…&sort=name |
doctor |
GET /healthz, GET /api/me, GET /api/guilds, plus the targets above |
Silence is not a stop button. The board's member-side route withdraws your own waiting plays; it cannot cut off the clip the channel is already hearing, by design. Skip/clear are admin playback controls in the web board. There is no local preview, so there is nothing local to silence.
repeat remembers the last clip this process queued (including the clip the
server picked for a filter). After a restart, or after Reload config (which
creates a fresh dispatcher), it has nothing to repeat until you play something.
A deleted clip comes back as the board's NOT_FOUND sentence.
A hotkey press that arrives while the previous action is still in flight is dropped, not queued. The board's own per-principal cooldown and play rate limit still apply.
Shortcut configuration
orebuoys-desktop configure (and the tray's Configure shortcuts…) opens a
setup page on a loopback address. It edits the config file; it does not change
the board or the token. It does not need a running daemon: the file is the
source of truth, and the next run reads it. If a daemon is running, each save
asks it to reload over the session bus, so the change is live immediately; the
page says so, or says that the next run will pick it up.
The page shows:
- the pairing state: board URL, whether the token came from pairing, your user and role, the pinned server, and the token's recorded expiry;
- the binding list with the action description, keys, and Edit/Remove;
- a clip search that goes through the daemon-side board client (
/api/clips, with the configured guild and bearer token) and shows title, tags, duration and library, with an Add clip binding button. If no key was captured, the new row opens in the editor so it is not left silently unbound. Adding the same clip again creates a second binding with a suffixed id (play-<id>-2), so one sound can have several hotkeys; - function actions (
snapshot,silence,repeat) and filter actions (search text, tag name/id, library name/id); - a key-capture control and a Set keys in the system dialog action.
The board token never reaches the page: searches and identity reads happen in
the configure process. The page has no external assets, no inline script and
no network access except back to its own loopback origin. It reads the token
from the config file or OREBUOYS_TOKEN; a token supplied to the daemon only as
run --token is not copied into the child process (argv is visible in /proc),
so pair with setup or keep the token in the 0600 config if you want clip
search in the page.
configure --server https://board.example.com --token sb_… works before a
config file exists: the flags are the effective server and token, the page can
search clips, and the first save creates the file (mode 0600) with those values.
The usual flow still comes from setup; the flags make the page scriptable and
are useful when the daemon runs from an environment token.
The page's security model
The listener binds 127.0.0.1 on an ephemeral port. Loopback is not a trust
boundary — every local process and every page loaded in your browser can reach
it — so the page also requires:
- a per-run 256-bit random token, printed inside the URL fragment. The fragment
is not sent in the HTTP request line or a server access log; the page reads it
and sends it as
Authorization: Bearer …. A token in the query string is refused. - an exact
Hostmatch for the listener's127.0.0.1:port. A DNS-rebinding page that resolves its own name to127.0.0.1still sends its ownHostand is refused. - an exact
Originmatch on every POST/PUT/DELETE, plus a JSON content type when there is a body, so another local page cannot submit a form to it. - a restrictive
Content-Security-Policy(default-src 'none', scripts and styles only from self),X-Frame-Options: DENY,nosniffandno-referrer.
Every write is validated as a whole file (actions, filter shapes, trigger
syntax, no duplicate IDs, no two shortcuts on one trigger) and replaced
atomically with mode 0600; other fields such as defaults, the token, and the
pairing metadata are preserved. The page can repair a file whose only problem is
two shortcuts sharing a trigger, by removing or re-keying one.
Keys, the portal and browser capture
Capturing a key in the page only writes a preference to shortcuts[].keys. It
is what the daemon asks the portal to use as preferred_trigger; the system
dialog is what actually grants the global assignment. On Wayland the portal
(the compositor) is the key authority, so expect it to confirm or ask you to
assign the same key again.
Set keys in the system dialog asks a running daemon to call the portal's
ConfigureShortcuts (portal interface version 2), which opens Plasma's shortcut
editor for this app. With no daemon, the page opens a temporary
GlobalShortcuts session with the file's bindings and requests the same dialog;
that session stays alive until the page stops.
At startup the daemon calls BindShortcuts with your keys as
preferred_trigger. Plasma shows its global-shortcuts dialog; accept it to
grant the bindings. If you reject the dialog and no shortcut ends up active,
startup fails with a clear error and nothing is registered. A portal that keeps
existing bindings may answer success with the new ones unassigned; the daemon
logs the bound subset, so "no key yet" is visible rather than guessed at.
The KGlobalAccel fallback has no per-application configuration method. The page's system-dialog action reports that and names System Settings → Shortcuts → Orebuoys Desktop, where you assign keys. This fallback is KDE-only and is not a Wayland-independent mechanism — KWin still owns the input.
Do not run two daemons: the second refuses to start because the control bus name
is taken. configure is not a daemon; it is a page that can talk to the daemon
you already have.
Tray
A left click opens the board in your browser. The menu is:
- Status — the board URL, the pinned server, the number of bindings and the active shortcut backend.
- Configure shortcuts… — starts
orebuoys-desktop configurein a separate process and opens the same loopback setup page. It needs no shortcut backend, so it is also the way out of a config with no bindings. - Silence my waiting clips —
DELETE /api/play; it withdraws your waiting plays only. - Reload config — re-reads the config and re-binds shortcuts. A reload creates a new shortcut session and a fresh repeat history.
- Open board —
xdg-openon the configured server. - Quit — stops the daemon cleanly (closes the portal session/KGlobalAccel actions and releases the tray and control bus names).
Multi-server boards
Set guild to the server the shortcuts should act in. setup asks once when
the token can act in several enabled servers and writes the answer;
orebuoys-desktop setup --guild <id> makes the same choice non-interactively.
The daemon sends ?guild= on guild-scoped routes; the board checks that your
token's owner may act there and that the server is enabled. Without guild, the
board's own default (the seeded server when you may act in it) is used.
One daemon drives one server. Two instances on the same session would collide
on the control bus name, the portal app id and the fixed shortcut ids
(snapshot, silence, repeat), so switch servers by editing guild and
using Reload config (or restarting), not by running two copies.
Run at login
The installed user unit is a sample:
mkdir -p ~/.config/systemd/user
cp /usr/local/lib/systemd/user/orebuoys-desktop.service ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now orebuoys-desktop.service
journalctl --user -u orebuoys-desktop -f
The unit starts with graphical-session.target and restarts on failure. Pair
once with orebuoys-desktop setup; the unit then reads the token from the 0600
config. Do not put the token in ExecStart or an environment file that others
can read.
Wayland and compositor caveats
The short version: under Wayland an application cannot grab keys itself; the compositor must do it through the portal. That works on Plasma 6. It does not become a general-purpose global-hotkey system for every nested or remote compositor.
- Browser key capture is a preference, not a grab: the setup page can only record what you pressed while it was focused. It cannot make that key global; the portal (or KGlobalAccel) does. If the portal asks you to confirm or assign the key again, do that — the captured string is a suggestion.
- gamescope / nested compositors: a nested compositor may not expose the host's GlobalShortcuts portal, and a portal service on a different session bus will not see the keys you press in the host session. Run the daemon in the session whose compositor owns the input and whose bus hosts the portal; test the binding there. See the checklist for a concrete test.
- X11: the portal may still be available with the KDE backend; otherwise the KGlobalAccel fallback is the path. This build has not been live-tested on X11.
- No portal backend:
doctorreports the portal error and whether KGlobalAccel is reachable. On a non-KDE Wayland compositor without a GlobalShortcuts backend, there is no supported way for this daemon to grab keys; that is a compositor limitation, not a daemon setting. - Portal restarts: a portal restart invalidates the session. Restart the
daemon (
systemctl --user restart orebuoys-desktop) or use the tray's Reload config, which creates a fresh session and re-binds. - Tray host restarts: StatusNotifierItem registration is one-shot. The daemon retries for a minute at startup for a late Plasma tray, but if the tray host restarts later, restart the daemon to re-register the icon.
More detail, with the protocol evidence and limits:
docs/wayland-hotkeys.md.
Security
- The config may hold a token; it is required to be 0600.
setupwrites it with 0600 and preserves the rest of the file. The daemon never logs the token, theAuthorizationheader, or request URLs with credentials; the pairing code is sent in a POST body, not in a URL this program logs. - The token is a board personal access token: it is exactly its owner, including
the ability to play, snapshot (with
record) and any other permission that owner holds. Pairing mints one with the board's default expiry (90 days) and records it intokenExpiresAt; revoke it under Account → API tokens when this machine stops using it. Rundoctorto see the recorded expiry. - The control object
io.github.psychofox.OrebuoysDesktopis on the session bus, which is already a per-user trust boundary. Anything running as your user can ask it to reload, open the portal dialog or quit; that grants no new board authority. - The setup page is a loopback server, not a local-only file: the per-run URL
token, exact Host/Origin checks and JSON content type are what stop another
local page from driving it. The board token stays in the
configureprocess; only status is returned to the page. Treat the printed URL as a short-lived secret while the page is running, and stop it with Ctrl-C when you are done.
Licence
AGPL-3.0. See LICENSE.
The board, its API and its documentation are a separate project; this repository contains only the desktop daemon.