Bindrune
A Bluetooth and OBEX client for Linux desktops that is straight to the point, DE/WM agnostic, and beautiful.
The name comes from the Bluetooth logo, a bindrune that joins the runes ᚼ and ᛒ, the initials of Harald Bluetooth. Bindrune is a hard fork of Overskride by kaii.
What it does
- Lists the devices the adapter knows or finds while scanning.
- Shows pairing, trust and connection as one timeline per device, with the reason when a step fails and what fixes it.
- Shows each adapter’s hardware and whether other devices can see it.
- Sends and receives files over OBEX.
- Switches audio profiles and reports battery levels and link quality.
- Keeps running in the background, so phones can still pair and send files while the window is closed.
Installation
Bindrune talks to BlueZ, so the Bluetooth service has to run:
sudo systemctl enable --now bluetooth
File transfers also need obexd (often packaged as bluez-obex). The state of each
radio of a device and the choice of radio to connect over need BlueZ’s experimental
features (Experimental = true in /etc/bluetooth/main.conf). Kernel errors for an input device need read access to
the system journal; without it, they are not shown.
Try it from a checkout
On any distribution with Nix and flakes enabled:
git clone https://codeberg.org/wvhulle/bindrune
cd bindrune
nix develop
cargo run -p bindrune-gtk
Or run the packaged binary without cloning:
nix run git+https://codeberg.org/wvhulle/bindrune
NixOS and home-manager
Add the flake as an input. Both modules install the package and run Bindrune as a systemd user service, so pairing requests and incoming files are handled while the window is closed.
{
inputs.bindrune = {
url = "git+https://codeberg.org/wvhulle/bindrune";
inputs.nixpkgs.follows = "nixpkgs";
};
}
- NixOS: import
bindrune.nixosModules.defaultand setservices.bindrune.enable = true;. - home-manager: import
bindrune.homeManagerModules.defaultand setservices.bindrune.enable = true;.
Debian and Ubuntu
Each release has a package attached, and its notes name the supported versions:
sudo apt install ./bindrune_*.deb
The path has to start with ./, so that apt reads the file and installs the
dependencies. The package does not add an apt repository, so install new versions
the same way. On older releases, such as Debian 13, use the Flatpak.
Flatpak
Each release has a bundle attached:
flatpak install --user bindrune-*.flatpak
Other distributions
Build and install with cargo. This needs Rust (preferably through rustup, which picks up the pinned version) and the development packages for GTK 4, libadwaita, D-Bus, udev, systemd and PulseAudio. The build reports any that are missing or too old.
cargo build --release --locked --package bindrune-gtk --package xtask
sudo target/release/xtask install # --prefix defaults to /usr/local
sudo target/release/xtask uninstall
xtask install --destdir … --prefix /usr stages the files for distribution
packages. The data folder is laid out like the installed prefix, so its share
and lib folders show exactly what gets installed.
Running in the background
Closing the window keeps Bindrune running, and pairing requests and incoming files show a notification. On desktops with tray support, a tray icon reopens the window. A remote device never opens a hidden window by itself.
To start Bindrune hidden at login, enable its user service:
systemctl --user enable --now bindrune
Coming from Overskride
Bindrune installs next to Overskride as a separate application, so nothing is carried over: set the folder for received files and the devices that may send files without asking again. Pairings and trust live in BlueZ, so paired devices stay paired. Run only one of the two at a time, because both register as the pairing agent and answer file transfers.
Pairing
Find a device
The sidebar lists the devices the adapter knows. Put a new device in pairing mode and start a scan to find it.
Phones and computers start the pairing themselves. For those, make the adapter visible for pairing on its page.
The pairing timeline
Each device page shows the path from an unknown device to a working connection as one timeline. A device goes through these stages:
| Stage | Done when |
|---|---|
| Pairing | Both sides generated a shared key. |
| Saved pairing | The key is stored, so it survives power-off and reboot. |
| Permission to connect | The device may connect without asking. |
| In range | The device is measured in range, or connected. |
| Connection | The device is connected. |
| Input device | The kernel bound a driver. This only applies to input devices. |
| Audio device | The audio server made it available as a sound device. This only applies to audio devices. |
Each step offers the action that moves it forward, and explains its current status and the Bluetooth mechanism behind it, for the radio the device uses. A step that needs attention opens by itself. The audio device step also lets you choose the audio profile.
Pair a new device
Start pairing on the first step. Bindrune then pairs, confirms the bond, trusts the device and connects, and the timeline follows along:
If the device asks for a passkey or PIN, the question opens inside the step. A stray key press can’t accept a pairing:
When another device asks to pair
A request from a device you are not looking at never takes you away from the current page. Bindrune tells you which device asks and lets you open it to answer on its timeline. A connection request from a device that isn’t trusted waits on the timeline the same way, and is denied if nobody answers in time:
When pairing fails
The timeline stops at the step that failed and shows the reason with what to do next:
Reconnect a paired device
A paired device keeps its completed steps. Headsets and controllers usually reconnect by themselves when they are in range; connect the others from their timeline:
The connection policy is set per device: it can connect without asking, ask each time, or be rejected.
Forgetting a device deletes its keys and trust for this adapter, so it has to pair again before it can connect.
Classic Bluetooth and Low Energy
Bluetooth is two radio systems that share a name:
- Classic Bluetooth (BR/EDR) keeps a steady link. It carries high-quality audio, calls and file transfers. A new device answers the inquiry this computer sends while scanning, and pairing produces a link key.
- Bluetooth Low Energy sleeps between short, scheduled exchanges, which saves power. A device announces itself with short advertising packets. Pairing produces a long-term key and, when the device shares one, an identity key that recognizes the device behind its changing addresses. Most of what a Low Energy device offers is described in GATT: services that group characteristics, small values that can be read, written or subscribed to.
Many phones, headsets and earbuds have both radios. Bindrune works out which radios a device uses from what the device reports, says what it based that on, and explains every step of the timeline for those radios. For a device with both, it shows the state of each radio and lets you choose which one to connect over first. A connected Low Energy device also shows its GATT services, with their standard names, and lets you read their values.
Low Energy devices often use random addresses. A static random address may change when the device restarts, but never while it is running. A private address changes regularly, so the device can’t be tracked; when the device shares its identity key during pairing, Bindrune shows its fixed identity address instead.
The state of each radio and the choice of radio need BlueZ’s experimental features. Without them, the device page says what is missing.
Connection monitor
A controller that lags, repeats buttons or disconnects is not always out of range. Something on the computer can take radio time away from it. The connection step of a paired device opens a monitor page to find out what.
The monitor only measures while it is on screen. Going back or closing the window stops all measuring.
The monitor starts with three counts:
- Connections: how often the kernel set up a device of this model in the last hour, read from the kernel log. It is complete even for the time Bindrune was not running, but it cannot tell two devices of the same model apart.
- Drops: disconnects and their reason, such as a lost signal, and for a device with both radios which one dropped. Only Bindrune sees the reason, so this count starts when Bindrune started, and the monitor says so.
- Device errors: warnings the kernel logged for devices of this model in the last hour, also from the kernel log.
The monitor charts the last two minutes, one bar per second:
- Report rate: how many input reports the device sent. A controller in use sends a steady rate, so dips mean lost or late input.
- Worst gap: the longest wait between two reports. Two dashed lines mark when a gap becomes noticeable and when the link counts as unstable.
Both charts shade the time the adapter spent scanning, and mark connection drops, kernel errors for the device and kernel errors for the radio. When a gap lines up with a shaded band or a mark, that is the likely cause.
Below the charts, the monitor checks these causes:
| Cause | Why it hurts |
|---|---|
| The radio reports errors | A radio that stops answering the computer drops input until it recovers. This can come from USB power saving or faulty firmware. |
| The adapter is scanning | Scanning takes radio time from connected devices. Bindrune can stop its own scan; scans started by other programs end when you close them. |
| Another Bluetooth radio is on | Two radios in one computer share the same band, and built-in radios also share it with Wi-Fi. Bindrune can turn off the one you don’t use. |
| USB power saving on the radio | The radio may sleep and miss input. |
| Enhanced retransmission | Some game controllers don’t work reliably with it. |
When the device is connected through a built-in radio while a plug-in radio is also on, the monitor suggests moving the device to the plug-in radio, rather than turning off the radio the device is using. Turning a radio off from Bindrune lasts until the computer restarts.
USB power saving and enhanced retransmission need a change to the kernel settings. The monitor names the setting for each one.
When a check cannot run, for example because a setting file cannot be read, the monitor shows why.
Bindrune measures input timing by reading the device’s raw input reports. When it has no permission to read them, for example in a sandbox, the monitor says so and only shows the checks.
Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[Unreleased]
0.4.0 - 2026-11-07
Added
- separate icon
- link monitor
- low energy support
Fixed
- cancellation of pairing
- parse journal for disconnects
- keep a dual-mode device connected when only its LE bearer disconnects
- explain Bluetooth accurately and in short pieces
0.3.1 - 2026-11-05
Fixed
- wording of ‘in range’
- make audio selection menu wider
0.3.0 - 2026-11-04
Added
- more capabilities per device
- show audio pane in timeline
Changed
- declare the pairing timeline and main window with the Relm4 DSL
0.2.0 - 2026-11-04
Added
- (gtk) tidy the pairing timeline panes
Fixed
- (gtk) remove the radio expander from the pairing timeline
Miscellaneous
- (deps) bump all dependencies
- merge the Debian package, pairing timeline fix and panes
0.1.0 - 2026-11-02
Added
- open a device page at startup with –open-device=ADDRESS
- show kernel errors, charging status and sidebar battery for devices
- raise a toast when a timeline step fails
- answer pairing prompts inside the timeline step
- answer service authorization on the Authorization step
CI
- move pipeline scripts into cargo xtask
Changed
- use zbus for obex
- move the device change stream from the sidebar to the root view
- keep the preferences handle in the shell and the root view
- stop reading child models from parent components
- forward child outputs directly instead of through Input variants
- make the prompt queue self-contained
- split TrayRequest by the component that handles it
- move the pairing agent stream into the prompt queue
- subscribe to connection drops from the device change feed
- split the root view at its real seams
Fixed
- show the error when a tray icon could not be created
- dismiss the file transfer prompt when BlueZ cancels a push
- replace Pair Again with a repair-only Forget and Rescan
Overskride Version 0.5.5
- changed the way window titles work
- added a distance approximation in more info page
Development
Setup and workflow for working on Bindrune. The rules the code follows are in Style.
Workspace
Work inside the Nix devshell (nix develop, or direnv allow). The repository
is a cargo workspace with:
- a crate that wraps BlueZ and the other system services, and knows nothing about GTK;
- the Relm4 application;
- a crate that renders the screenshots;
xtask, which holds the CI and packaging scripts.
Building and checking
cargo run -p bindrune-gtk # run from the checkout
cargo test --workspace
cargo clippy --workspace --all-targets
cargo xtask check-install-data # validate the installed desktop, metainfo and schema files
nix flake check # everything CI checks, plus the VM tests
nix fmt
cargo xtask --help # the CI and packaging tasks
mdbook serve docs # preview the documentation site
End-to-end tests
The VM tests run the real application against a real BlueZ. Scenarios that need another device start a second virtual machine with its own BlueZ to play it, and both machines share one emulated radio. The tests operate the window the way a user does and check both what it shows and what BlueZ reports on each side. They need KVM.
cargo xtask list-vm-scenarios # the scenarios, one per file in vm-tests/scenarios
cargo xtask vm-tests # run all of them
cargo xtask vm-tests pairing low-energy # run some of them
cargo xtask vm-tests low-energy --repeat 5 # run one several times to catch flaky steps
nix run .#checks.x86_64-linux.vm-pairing.driverInteractive # step through by hand
Each attempt keeps its screenshots and log in target/vm-tests/<scenario>/attempt-<n>/,
also when it fails, and a failing attempt prints the end of its log. When a check is
new, break the behaviour it covers on purpose and run it once more, to see it fail.
The radios are emulated, so these tests say nothing about real hardware: quirks of particular phones, signal strength, audio and input timing still need a manual check.
Packaging
cargo xtask builds the release packages into dist/, the same way CI does.
The Debian package has to be built on Debian, because its dependencies are read
from Debian’s libraries and a binary from the devshell links against the Nix store.
To build it locally, run the steps of its pipeline in a Debian container. Give the
container its own volume for target/, so the build does not mix with the artifacts
of the devshell.
CI
Woodpecker CI builds the unit tests, lints and formatting check on pull requests and
on pushes to main, and checks pull request commits against Conventional Commits.
Pipelines run on a self-hosted agent, so they queue while that machine is off.
The VM tests run on Forgejo Actions, which provides KVM, on pushes to main, and can
be started manually on any branch. They do not run on pull requests, because those
jobs run in privileged containers.
CI scripts live in xtask, and pipelines call them through flake apps, which add the
tools each task needs.
Releases
Releases are automated with release-plz in git-only mode, so versions come from tags and nothing goes to crates.io.
- Every push to
mainopens or updates a release pull request with the next version and the generated changelog entry. - Merging that pull request creates the tag and the Codeberg release.
- The release event builds the packages and attaches them to the release with their checksums.
The Flatpak builds on Forgejo Actions, because flatpak-builder needs privileged
containers, and can be started manually without a release. Other packaging runs on
Woodpecker, where pull requests that touch it run everything except the attachment.
Test it that way: a manual Woodpecker run also updates the release pull request.
Which commits count towards a release is set in the release-plz configuration. The secrets each pipeline needs are named in its pipeline file.
To preview the next version and changelog, run release-plz update in a fresh clone
and discard the result. Do this outside your working checkout if it has the tags
inherited from Overskride, because they can change the result.
Screenshots
The screenshots are rendered from the real application, not drawn by hand. The showcase serves a fake BlueZ with fixture devices on an isolated D-Bus and captures the application in a headless compositor. A run is reproducible, so rebuilding the flake’s screenshot package compares two renders byte for byte.
A pairing scenario drives the real window, so a layout change can make it time out. Adjust the scenario when that happens.
After the checks pass on main, CI renders the screenshots in the Nix sandbox and
force-pushes them as the single commit of the orphan screenshots branch, which the
other documents embed.
Style
The rules the code in Bindrune follows. Setup, building and CI are in Development.
- No comments, doc-comments or docstrings. Intent lives in names and call flow; code that cannot be made self-evident that way is restructured, not annotated.
- Names have two to four concrete keywords and spell words out. A type is named after its file.
- The GTK application is grouped by the region of the window a module draws. Code shared by several regions lives in a top-level shared module instead of one region importing from another.
- The BlueZ crate is grouped by BlueZ concept and never depends on GTK.
- A region that grows large is split into Relm4 child components, not into files of loose helper functions. A child owns its state, receives new snapshots through its input messages and reports through its output messages; parents do not reach into child widgets. Repeated rows are factory components. Presentation logic that turns snapshots into text and view models stays as plain functions, so it is tested without GTK.
- Files stay between roughly 120 and 400 lines of code, not counting tests. Split a longer file at a real seam; merge a shorter one into its only user or into the module about the same concept.
- Show values as BlueZ and the kernel report them; do not reformat or capitalize them.
- Formatting is nightly rustfmt with imports grouped by crate (
nix fmt). - Markdown describes concepts and stable commands, so a fix or a small refactor
does not have to update it. It does not name identifiers, source files, internal
tasks, pipeline steps, dependency versions, or exact labels and layout of the
interface. The code,
--helpand the rendered screenshots are the reference for those.