Initial Lightbar panel with native audio controls

This commit is contained in:
Lightbar contributors
2026-09-29 13:01:10 +02:00
commit facd7fabd8
29 changed files with 11585 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/target/
*.profraw

2134
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

46
Cargo.toml Normal file
View File

@@ -0,0 +1,46 @@
[package]
name = "lightbar"
version = "0.1.0"
edition = "2024"
rust-version = "1.93"
description = "A small, event-driven native Wayland panel for Sway"
license = "MIT"
[dependencies]
anyhow = "1.0"
calloop = { version = "0.14", features = ["signals"] }
chrono = { version = "0.4", default-features = false, features = ["clock"] }
clap = { version = "4.5", features = ["derive"] }
cosmic-text = "0.19"
crossbeam-channel = "0.5"
futures-lite = "2.6"
libc = "0.2"
notify = "8.2"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
smithay-client-toolkit = { version = "0.21", features = ["calloop"] }
thiserror = "2.0"
tiny-skia = "0.11"
toml = "0.9"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
wayland-client = "0.31"
wayland-protocols = { version = "0.32", features = ["client"] }
wayland-protocols-wlr = { version = "0.3", features = ["client"] }
zbus = { version = "5.11", default-features = false, features = ["blocking-api", "tokio"] }
tokio = { version = "1.47", features = ["rt", "sync", "time", "process", "io-util", "macros"] }
[dev-dependencies]
tempfile = "3.20"
[profile.release]
lto = "thin"
codegen-units = 1
strip = "debuginfo"
[lints.rust]
unsafe_code = "forbid"
[lints.clippy]
all = "warn"
pedantic = "warn"

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Lightbar contributors
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.

180
README.md Normal file
View File

@@ -0,0 +1,180 @@
# Lightbar
Lightbar is a small native Wayland panel for Sway. It draws directly into
shared-memory layer-shell surfaces and updates only when module state changes or
the compositor grants a frame. There is no GTK widget tree, CSS engine, browser,
or free-running render loop.
The initial target is Ubuntu 26.04 and Sway 1.11. The implementation checklist
and the deliberately deferred stress tests are tracked in [TOOD.md](TOOD.md).
## What works
- one top or bottom bar on every selected output, with output-local workspaces;
- left, centered, and right module groups with hover/click/scroll hit testing;
- Sway workspaces, binding mode, scratchpad list and direct IPC actions;
- exact-boundary clock and calendar popup;
- NetworkManager, PipeWire audio, battery, backlight, and hwmon temperature;
- default output and microphone selection, volume and mute controls, backlight
sliders, and external advanced settings launchers;
- interval and streaming custom commands with limits, timeout, stale state, and
explicit shell opt-in;
- StatusNotifier watcher/host, validated ARGB pixmaps, attention state,
activation, secondary activation, item context menus, and scrolling;
- strict TOML validation and atomic config/theme hot reload;
- `check-config` and `doctor` commands.
Tray items that only publish an icon-theme name currently use a dot fallback.
Pixmap icons are rendered directly. DBusMenu is also still delegated to the tray
item through `ContextMenu`; native nested DBusMenu rendering is tracked in the
checklist.
## Build and try it
Rust 1.93 or newer is required. On Ubuntu 26.04, install the normal Rust build
toolchain and `pkg-config`; the Wayland and D-Bus protocol clients themselves are
pure Rust.
```sh
cargo test --all-targets
cargo run -- check-config --config examples/config.toml
cargo run -- doctor --config examples/config.toml
cargo run -- --config examples/config.toml
```
The example uses `UbuntuMono Nerd Font`. Install that font for the intended icon
glyphs; cosmic-text/fontconfig will otherwise select an installed fallback such
as Noto Sans.
## Install without replacing Waybar
```sh
./scripts/install.sh
```
This builds a locked release, installs `~/.local/bin/lightbar`, and copies the
example files only when the destination does not already exist. It also writes
`~/.config/lightbar/bar.conf`, a generated Sway fragment containing the
current user's absolute binary and configuration paths. It does not edit your
Sway or Waybar configuration.
Validate the installed configuration:
```sh
~/.local/bin/lightbar check-config
~/.local/bin/lightbar doctor
```
Then remove or comment out your existing top-level `bar { ... }` block, add the
portable include below at top level, and reload Sway:
```text
include ~/.config/lightbar/bar.conf
```
The included file is generated separately for each user, so a shared Sway
configuration never contains a username or `/home/...` path. Absolute paths in
that generated file also avoid depending on the graphical Sway session's
`PATH`, which does not always include `~/.local/bin`. If `XDG_CONFIG_HOME` is
customized, use the absolute `include` line printed by the installer instead.
Sway appends `-b lightbar`; the CLI accepts that automatically. To roll back,
remove the include, restore the old bar block, and reload. For a Waybar setup the
bar block can contain:
```text
bar {
swaybar_command waybar
}
```
Lightbar never edits or removes Waybar's files.
## Configuration
The configuration defaults to `$XDG_CONFIG_HOME/lightbar/config.toml` (or
`~/.config/lightbar/config.toml`). `theme` is resolved relative to that file. Both
files reject unknown fields, and an invalid live edit is logged while the last
valid configuration remains active.
Module actions live below `common.actions` and support four explicit types:
```toml
[modules.example]
kind = "command"
mode = "interval" # or "stream"
argv = ["date", "+%s"] # no shell interpretation
interval = "30s"
timeout = "2s"
max_output_bytes = 65536
[modules.example.common.actions.left]
type = "exec"
argv = ["notify-send", "clicked"]
```
Use `type = "shell"` only when shell expansion is intentional. Command output
may be plain text or a JSON object:
```json
{"text":"42","tooltip":"answer","state":"warning","visible":true}
```
See [examples/config.toml](examples/config.toml) and
[examples/theme.toml](examples/theme.toml) for every built-in used by the
current layout.
### Audio
Audio requires PipeWire, WirePlumber (`wpctl`), and `pw-dump` on `PATH`.
The bar shows the current default output and microphone independently. With
the example actions, middle-click toggles mute and scrolling changes volume
for the speaker or microphone segment under the pointer. Left-click opens
the audio popup.
Click a device name in the popup to choose a default output or microphone.
Each default has its own volume slider and mute button. Click the slider or
scroll over it to adjust volume between 0% and 150%; changing volume preserves
mute state. External changes and device connections update the bar and open
popup automatically. A missing default is shown as unavailable while the other
endpoint remains usable. If the audio service disconnects, the popup closes
and the module reconnects automatically.
Use Tab/Shift+Tab or Up/Down to focus controls, Enter/Space to activate buttons,
Left/Right to adjust a focused slider, Home/End for its limits, and Escape to
close. Long device lists support scrolling and Previous/More buttons.
`format` and `muted_format` configure the output segment;
`microphone_format` and `microphone_muted_format` configure the microphone
segment. All four accept `{volume}`, `{device}` (description), and `{name}`
(PipeWire node name). Existing output formats remain valid. The complete
[example configuration](examples/config.toml) includes both microphone formats.
The popup's **Open pavucontrol** button provides per-application routing and
device profile controls when `pavucontrol` is installed. Default-device
selection uses [WirePlumber's `set-default` command](https://pipewire.pages.freedesktop.org/wireplumber/man/wpctl.html#set-default);
routing of existing streams follows the session manager's policy.
## CPU and memory comparison
Build release binaries, run one bar configuration at a time, and sample the
process for at least ten minutes:
```sh
./scripts/sample-process.sh $(pgrep -n lightbar) 600 > lightbar-core.tsv
./scripts/sample-process.sh $(pgrep -n waybar) 600 > waybar.tsv
```
Repeat with the tray removed from each layout, then interact with workspaces,
audio, popups, and tray items. Compare the samples and inspect whether RSS or
CPU trends upward. An eight-hour soak and event-storm testing remain release
gates in `TOOD.md`; a short local sample is not evidence that those gates pass.
## Design guardrails
- Module events are dirty-compared before drawing.
- Frame callbacks prevent drawing faster than the compositor.
- D-Bus tray signal queues, process output, IPC replies, and pixmaps are bounded.
- Module workers back off or wait on events; low-frequency hardware values use
sparse polling.
- Dropping a configuration generation stops its workers and monitored children.

136
TOOD.md Normal file
View File

@@ -0,0 +1,136 @@
# Lightbar implementation checklist
This file tracks the implementation of the agreed native, low-CPU Sway panel.
The first target is Ubuntu 26.04 with Sway 1.11. The core bar ships before the
StatusNotifier tray.
## 1. Project and configuration
- [ ] Rename the project and source folder to Lightbar, migrate the installed
configuration, and verify the running build and tests.
- [ ] Initialize Git and push the reviewed source to the Lightbar repository;
verify that the remote branch matches the local commit.
- [x] Rename the package, executable, configuration, documentation, and source
folder to Lightbar; migrate the installation and verify the running build.
- [x] Create a locked Rust application named `lightbar`.
- [x] Add CLI support for Sway's `-b <bar-id>`, `--config`, `check-config`,
`doctor`, and `--version`.
- [x] Define strict, documented TOML models for the bar, layout, modules,
actions, custom commands, and theme.
- [x] Load `$XDG_CONFIG_HOME/lightbar/config.toml` and its relative theme file.
- [x] Validate module references, colors, dimensions, durations, formats, and
command limits before starting the UI.
- [x] Watch configuration and theme files; reload atomically and keep the last
valid state after an invalid edit.
- [x] Filter and coalesce configuration filesystem events so reads and unrelated
files cannot trigger reload loops.
- [ ] Add rate-limited structured logging and useful exit codes.
- [x] Add `doctor` checks for configuration, fonts, Sway/Wayland environment,
D-Bus services, hardware, and optional helper applications.
## 2. Native Wayland shell and rendering
- [x] Connect through `WAYLAND_SOCKET` when Sway launches the bar and through
the normal Wayland environment during development.
- [x] Create a top layer-shell surface with a 24 logical-pixel exclusive zone
for every active output.
- [ ] Handle output creation/removal, integer and fractional scaling, surface
configure events, and graceful compositor shutdown.
- [x] Implement reusable shared-memory buffers and compositor-frame-throttled
drawing without a free-running render loop.
- [x] Render backgrounds, borders, state colors, and shaped system-font text.
- [x] Implement left/center/right layout, width measurement, clipping, and
stable pointer hit boxes.
- [x] Handle hover, left/middle/right click, scrolling, and keyboard focus only
while a popup needs it.
- [x] Implement one clamped `xdg_popup` per seat with dismissal, buttons,
sliders, scrolling, and calendar/menu layouts.
## 3. Core data model and modules
- [x] Define a common module state/event/action interface with dirty-state
comparison so unchanged data cannot trigger redraws.
- [ ] Use bounded latest-value channels and coalesce bursts into one frame.
- [x] Implement Sway IPC framing, initialization, persistent request and event
connections, commands, output-local workspaces, binding mode, and
scratchpad discovery.
- [x] Preserve the last workspace list and focused selection across recoverable
Sway IPC disconnects instead of replacing it with an empty snapshot.
- [x] Implement a navigable scratchpad window popover and reveal/move actions.
- [x] Implement an exact-boundary clock and navigable calendar popover.
- [ ] Implement NetworkManager status and detail through D-Bus, with an
`nm-connection-editor` launcher for advanced management.
- [ ] Implement UPower battery status and detail through D-Bus.
- [x] Implement PulseAudio/PipeWire-Pulse volume events, mute/volume controls,
and a `pavucontrol` launcher.
- [x] Extend audio with current default output and microphone names, independent
volume/mute controls, and default-device selection in the popup.
- [x] Verify audio event synchronization, missing/disconnected devices, control
routing, and popup keyboard interaction with regression tests.
Verified with 37 automated tests, a read-only comparison with live
PipeWire/WirePlumber, and an isolated Sway session using simulated devices.
- [x] Install the updated audio build, validate the installed configuration,
and verify that the running bar uses the installed binary.
- [x] Implement sysfs/logind backlight discovery and adjustment.
- [x] Implement configurable `hwmon` temperature discovery and sparse polling.
- [x] Auto-detect current hardware names while retaining explicit overrides.
- [x] Hide or mark an unavailable optional module without crashing the bar.
## 4. Custom command modules
- [x] Support interval commands and long-running newline streams.
- [x] Accept plain text or JSON objects containing `text`, `tooltip`, `state`,
and `visible`.
- [x] Enforce argument-array execution by default, explicit shell opt-in,
two-second default timeouts, 64 KiB limits, and child cleanup.
- [x] Preserve the last good value as stale after transient failures and use
bounded exponential restart backoff.
- [x] Interrupt and reap monitored children during reload without holding child
locks across blocking waits.
- [x] Support typed built-in, Sway, executable, and explicit-shell actions for
clicks and scroll directions.
## 5. StatusNotifier tray (second milestone)
- [x] Reuse an existing `org.kde.StatusNotifierWatcher`, or own and implement
it when no watcher exists.
- [x] Register a StatusNotifierHost and track item registration, property
changes, and duplicate notifications.
- [x] Remove owned-watcher registrations immediately when their D-Bus name
disappears, and never render ownerless or unreadable items as fallback
circles.
- [x] Validate and render item-provided ARGB pixmaps.
- [ ] Resolve icon-name-only items from installed XDG icon themes; do not bundle
a vector icon set.
- [x] Implement activation, secondary activation, context menus, and scrolling.
- [ ] Implement DBusMenu layout, updates, nested menus, separators, toggles,
enabled/visible state, and activation in native popups.
- [ ] Reject malformed/oversized pixmaps, cap menu depth and item count,
isolate broken items, and rate-limit event floods.
## 6. Tests, profiling, and rollout
- [x] Unit-test config validation, duration/color parsing, format expansion,
hit testing, Sway frames/filtering, custom output, sysfs selection, and
hostile tray pixmaps.
- [ ] Add focused tests for state transitions, retries, popup placement, output
scoping, and rendering cache eviction.
- [ ] Test modules through fake Sway streams, D-Bus services, audio events,
sysfs trees, and hostile custom processes.
- [ ] Add rendering snapshots for multiple scales, Unicode, missing glyphs,
urgent states, and narrow outputs.
- [ ] Run end-to-end tests in a nested headless Sway session.
- [ ] Add adversarial tray tests for malformed pixmaps/menus, duplicate items,
disappearing services, and signal floods.
- [ ] Compare Waybar with/without tray against Lightbar core/with tray in repeated
idle and interaction runs.
- [ ] Complete an eight-hour soak with flat memory and no sustained CPU spin
after disconnects, reloads, event storms, or malformed input.
- [x] Provide a current-layout example using UbuntuMono Nerd Font, while
warning and falling back to Noto Sans when it is absent.
- [x] Document development dependencies, release build, user installation,
Sway configuration, validation, profiling, and one-line Waybar rollback.
- [x] Generate a per-user Sway include fragment so shared configurations do not
hardcode usernames or depend on the graphical session's `PATH`.
- [ ] Install as `~/.local/bin/lightbar`, preserve the existing Waybar files, and
switch only `swaybar_command` after all release checks pass.

126
examples/config.toml Normal file
View File

@@ -0,0 +1,126 @@
version = 1
theme = "theme.toml"
[bar]
position = "top"
height = 24
layer = "top"
outputs = ["*"]
namespace = "lightbar"
popup_width = 320
popup_max_height = 480
[layout]
left = ["workspaces", "scratchpad", "mode"]
center = []
right = ["network", "temperature", "backlight", "audio", "battery", "tray", "clock"]
[modules.workspaces]
kind = "workspaces"
format = "{index}"
[modules.scratchpad]
kind = "scratchpad"
format = "^{count}"
empty_format = "^0"
[modules.scratchpad.common.actions.left]
type = "builtin"
name = "toggle-popover"
[modules.scratchpad.common.actions.right]
type = "sway"
command = "move scratchpad"
[modules.mode]
kind = "mode"
format = "{mode}"
[modules.network]
kind = "network"
format_connected = " {ssid}"
format_disconnected = "睊 Disconnected"
[modules.network.common.actions.left]
type = "builtin"
name = "toggle-popover"
[modules.temperature]
kind = "temperature"
format = " {temperature_c:.0}°C"
interval = "30s"
warning_c = 75.0
critical_c = 90.0
[modules.temperature.common.actions.left]
type = "builtin"
name = "toggle-popover"
[modules.backlight]
kind = "backlight"
format = " {percent}%"
step_percent = 5
[modules.backlight.common.actions.left]
type = "builtin"
name = "toggle-popover"
[modules.backlight.common.actions.scroll_up]
type = "builtin"
name = "brightness-up"
[modules.backlight.common.actions.scroll_down]
type = "builtin"
name = "brightness-down"
[modules.audio]
kind = "audio"
format = " {volume}%"
muted_format = "婢 Mute"
microphone_format = " {volume}%"
microphone_muted_format = " Mute"
step_percent = 5
[modules.audio.common.actions.left]
type = "builtin"
name = "toggle-popover"
[modules.audio.common.actions.middle]
type = "builtin"
name = "toggle-mute"
[modules.audio.common.actions.scroll_up]
type = "builtin"
name = "volume-up"
[modules.audio.common.actions.scroll_down]
type = "builtin"
name = "volume-down"
[modules.battery]
kind = "battery"
format = "{icon} {percent}%"
full_format = " {percent}%"
warning_percent = 20
critical_percent = 10
[modules.battery.common.actions.left]
type = "builtin"
name = "toggle-popover"
[modules.clock]
kind = "clock"
format = "%a, %d. %b %H:%M:%S"
interval = "1s"
[modules.tray]
kind = "tray"
icon_size = 16
spacing = 8
[modules.clock.common]
style = { margin_x = 6 }
[modules.clock.common.actions.left]
type = "builtin"
name = "toggle-popover"

76
examples/theme.toml Normal file
View File

@@ -0,0 +1,76 @@
[font]
family = "UbuntuMono Nerd Font"
fallbacks = ["Noto Sans", "Noto Color Emoji"]
size = 12.0
[bar]
background = "#111111"
border = "#00000000"
border_width = 0
padding = 0
[popup]
background = "#222222"
border = "#555555"
border_width = 1
padding = 10
[module]
foreground = "#dddddd"
background = "#00000000"
border = "#00000000"
border_width = 0
padding_x = 10
padding_y = 0
margin_x = 0
min_width = 1
[states.focused]
foreground = "#000000"
background = "#7cafc2"
border = "#dddddd"
border_width = 1
[states.active]
foreground = "#dddddd"
background = "#323232"
border = "#555555"
border_width = 1
[states.urgent]
foreground = "#222222"
background = "#ab4642"
border = "#ab4642"
border_width = 1
[states.warning]
foreground = "#ffcc66"
[states.critical]
foreground = "#ff5555"
[states.disconnected]
foreground = "#ff5555"
[states.hover]
background = "#3b3b3b"
[states.stale]
foreground = "#888888"
[modules.workspaces]
padding_x = 6
border = "#555555"
border_width = 1
[modules.scratchpad]
foreground = "#b8b8b8"
background = "#323232"
padding_x = 9
[modules.mode]
foreground = "#ffffff"
background = "#900000"
border = "#2f343a"
border_width = 1
padding_x = 5

48
scripts/install.sh Executable file
View File

@@ -0,0 +1,48 @@
#!/usr/bin/env bash
set -euo pipefail
project_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
data_home=${XDG_DATA_HOME:-"${HOME}/.local/share"}
config_home=${XDG_CONFIG_HOME:-"${HOME}/.config"}
binary_dir=${HOME}/.local/bin
bar_config=${config_home}/lightbar/bar.conf
cargo build --locked --release --manifest-path "${project_dir}/Cargo.toml"
install -Dm755 "${project_dir}/target/release/lightbar" "${binary_dir}/lightbar"
install -d "${config_home}/lightbar"
if [[ ! -e "${config_home}/lightbar/config.toml" ]]; then
install -m644 "${project_dir}/examples/config.toml" "${config_home}/lightbar/config.toml"
fi
if [[ ! -e "${config_home}/lightbar/theme.toml" ]]; then
install -m644 "${project_dir}/examples/theme.toml" "${config_home}/lightbar/theme.toml"
fi
# Sway may be started with a PATH that does not contain ~/.local/bin. Keep the
# path resolution out of the shared Sway config by generating this per-user
# fragment with absolute paths at install time.
bar_config_tmp=$(mktemp)
trap 'rm -f "${bar_config_tmp}"' EXIT
printf '%s\n' \
'# Generated by Lightbar. Re-run scripts/install.sh after moving the install.' \
'bar lightbar {' \
" swaybar_command ${binary_dir}/lightbar --config ${config_home}/lightbar/config.toml" \
'}' > "${bar_config_tmp}"
install -m644 "${bar_config_tmp}" "${bar_config}"
rm -f "${bar_config_tmp}"
trap - EXIT
install -d "${data_home}/doc/lightbar"
install -m644 "${project_dir}/README.md" "${data_home}/doc/lightbar/README.md"
install -m644 "${project_dir}/TOOD.md" "${data_home}/doc/lightbar/TOOD.md"
printf 'Installed %s\n' "${binary_dir}/lightbar"
printf 'Configuration: %s\n' "${config_home}/lightbar/config.toml"
printf 'Sway bar fragment: %s\n' "${bar_config}"
printf 'Run: %s doctor\n' "${binary_dir}/lightbar"
printf '\nAdd this at top level in your Sway config (and remove the old bar block):\n'
if [[ "${config_home}" == "${HOME}/.config" ]]; then
printf ' include ~/.config/lightbar/bar.conf\n'
else
printf ' include %s\n' "${bar_config}"
fi

43
scripts/sample-process.sh Executable file
View File

@@ -0,0 +1,43 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -lt 1 || $# -gt 2 || ! $1 =~ ^[0-9]+$ ]]; then
printf 'usage: %s PID [SECONDS]\n' "$0" >&2
exit 64
fi
sample_pid=$1
duration=${2:-600}
if [[ ! ${duration} =~ ^[0-9]+$ || ${duration} -lt 1 ]]; then
printf 'SECONDS must be a positive integer\n' >&2
exit 64
fi
if [[ ! -r "/proc/${sample_pid}/stat" ]]; then
printf 'PID %s is not readable\n' "${sample_pid}" >&2
exit 66
fi
printf 'elapsed_s\tcpu_percent\trss_kib\n'
clock_ticks=$(getconf CLK_TCK)
read_ticks() {
local stat_line stat_fields
IFS= read -r stat_line < "/proc/${sample_pid}/stat" || return 1
stat_fields=${stat_line##*) }
read -r -a stat_fields <<<"${stat_fields}"
printf '%s\n' "$((stat_fields[11] + stat_fields[12]))"
}
previous_ticks=$(read_ticks)
for ((elapsed = 1; elapsed <= duration; elapsed++)); do
sleep 1
if [[ ! -r "/proc/${sample_pid}/stat" ]] || ! current_ticks=$(read_ticks); then
printf 'process %s exited after %s seconds\n' "${sample_pid}" "${elapsed}" >&2
exit 1
fi
delta_ticks=$((current_ticks - previous_ticks))
cpu=$(awk -v ticks="${delta_ticks}" -v hz="${clock_ticks}" \
'BEGIN { printf "%.2f", ticks * 100 / hz }')
rss=$(awk '/^VmRSS:/ { print $2; exit }' "/proc/${sample_pid}/status")
printf '%s\t%s\t%s\n' "${elapsed}" "${cpu}" "${rss}"
previous_ticks=${current_ticks}
done

1770
src/app.rs Normal file

File diff suppressed because it is too large Load Diff

51
src/cli.rs Normal file
View File

@@ -0,0 +1,51 @@
use std::path::PathBuf;
use clap::{Parser, Subcommand, ValueEnum};
#[derive(Debug, Clone, Parser)]
#[command(name = "lightbar", version, about)]
pub struct Cli {
/// Sway bar identifier. Sway appends this argument to `swaybar_command`.
#[arg(short = 'b', long, global = true)]
pub bar_id: Option<String>,
/// Override the default `$XDG_CONFIG_HOME/lightbar/config.toml` path.
#[arg(short, long, global = true, value_name = "FILE")]
pub config: Option<PathBuf>,
/// Logging verbosity. `LIGHTBAR_LOG` and `RUST_LOG` are also recognized.
#[arg(long, global = true, value_enum, default_value_t = LogLevel::Info)]
pub log_level: LogLevel,
#[command(subcommand)]
pub command: Option<Command>,
}
#[derive(Debug, Clone, Subcommand)]
pub enum Command {
/// Parse and validate the configuration without opening a Wayland surface.
CheckConfig,
/// Check the local environment and report actionable problems.
Doctor,
}
#[derive(Debug, Copy, Clone, Eq, PartialEq, ValueEnum)]
pub enum LogLevel {
Error,
Warn,
Info,
Debug,
Trace,
}
impl std::fmt::Display for LogLevel {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(match self {
Self::Error => "error",
Self::Warn => "warn",
Self::Info => "info",
Self::Debug => "debug",
Self::Trace => "trace",
})
}
}

1060
src/config.rs Normal file

File diff suppressed because it is too large Load Diff

290
src/doctor.rs Normal file
View File

@@ -0,0 +1,290 @@
use std::{
env, fs,
path::{Path, PathBuf},
process::Command,
};
use crate::config::ConfigBundle;
#[derive(Debug, Copy, Clone, Eq, PartialEq)]
pub enum CheckStatus {
Pass,
Warn,
Fail,
}
#[derive(Debug, Clone)]
pub struct Check {
pub status: CheckStatus,
pub name: &'static str,
pub detail: String,
}
impl Check {
fn pass(name: &'static str, detail: impl Into<String>) -> Self {
Self {
status: CheckStatus::Pass,
name,
detail: detail.into(),
}
}
fn warn(name: &'static str, detail: impl Into<String>) -> Self {
Self {
status: CheckStatus::Warn,
name,
detail: detail.into(),
}
}
fn fail(name: &'static str, detail: impl Into<String>) -> Self {
Self {
status: CheckStatus::Fail,
name,
detail: detail.into(),
}
}
}
pub fn run(config_path: Option<&Path>) -> Vec<Check> {
let mut checks = Vec::new();
let bundle = match ConfigBundle::load(config_path) {
Ok(bundle) => {
checks.push(Check::pass(
"configuration",
format!("{} is valid", bundle.config_path.display()),
));
Some(bundle)
}
Err(error) => {
checks.push(Check::fail("configuration", format!("{error:#}")));
None
}
};
checks.push(env_check("Wayland display", "WAYLAND_DISPLAY"));
checks.push(env_check_any("Sway IPC", &["SWAYSOCK", "I3SOCK"]));
checks.push(command_check("Sway", "sway", &["--version"], true));
checks.push(command_check(
"Network manager UI",
"nm-connection-editor",
&[],
false,
));
checks.push(command_check("Audio control UI", "pavucontrol", &[], false));
checks.push(command_check("PipeWire control", "wpctl", &[], false));
checks.push(command_check(
"PipeWire audio events",
"pw-dump",
&[],
false,
));
checks.push(dbus_service_check(
"NetworkManager D-Bus",
"system",
"org.freedesktop.NetworkManager",
false,
));
checks.push(dbus_service_check(
"UPower D-Bus",
"system",
"org.freedesktop.UPower",
false,
));
checks.push(dbus_service_check(
"StatusNotifier watcher",
"user",
"org.kde.StatusNotifierWatcher",
false,
));
if let Some(bundle) = bundle {
checks.push(font_check(&bundle.theme.font.family));
}
checks.push(path_group_check(
"battery devices",
Path::new("/sys/class/power_supply"),
false,
));
checks.push(path_group_check(
"backlight devices",
Path::new("/sys/class/backlight"),
false,
));
checks.push(path_group_check(
"temperature sensors",
Path::new("/sys/class/hwmon"),
false,
));
checks
}
fn env_check(name: &'static str, variable: &'static str) -> Check {
match env::var_os(variable) {
Some(value) if !value.is_empty() => {
Check::pass(name, format!("{variable}={}", value.to_string_lossy()))
}
_ => Check::fail(name, format!("{variable} is not set")),
}
}
fn env_check_any(name: &'static str, variables: &[&'static str]) -> Check {
variables
.iter()
.find_map(|variable| {
env::var_os(variable)
.filter(|value| !value.is_empty())
.map(|value| Check::pass(name, format!("{variable}={}", value.to_string_lossy())))
})
.unwrap_or_else(|| Check::fail(name, format!("none of {} is set", variables.join(", "))))
}
fn command_check(
name: &'static str,
command: &'static str,
args: &[&str],
required: bool,
) -> Check {
let Some(path) = find_command(command) else {
return if required {
Check::fail(name, format!("{command} was not found in PATH"))
} else {
Check::warn(
name,
format!("optional command {command} was not found in PATH"),
)
};
};
if args.is_empty() {
return Check::pass(name, path.display().to_string());
}
match Command::new(&path).args(args).output() {
Ok(output) if output.status.success() => {
let version = String::from_utf8_lossy(&output.stdout).trim().to_owned();
Check::pass(
name,
if version.is_empty() {
path.display().to_string()
} else {
version
},
)
}
Ok(output) => Check::warn(
name,
format!("{} exited with {}", path.display(), output.status),
),
Err(error) => Check::warn(name, format!("could not run {}: {error}", path.display())),
}
}
fn find_command(command: &str) -> Option<PathBuf> {
let paths = env::var_os("PATH")?;
env::split_paths(&paths)
.map(|path| path.join(command))
.find(|candidate| candidate.is_file())
}
fn dbus_service_check(
name: &'static str,
bus: &'static str,
service: &'static str,
required: bool,
) -> Check {
let Some(busctl) = find_command("busctl") else {
return Check::warn(name, "busctl is unavailable; service cannot be checked");
};
let bus_flag = if bus == "system" {
"--system"
} else {
"--user"
};
match Command::new(busctl)
.args([bus_flag, "--no-pager", "status", service])
.output()
{
Ok(output) if output.status.success() => Check::pass(name, format!("{service} is active")),
Ok(_) if required => Check::fail(name, format!("{service} has no owner on the {bus} bus")),
Ok(_) => Check::warn(
name,
if service == "org.kde.StatusNotifierWatcher" {
"no watcher is active; Lightbar will provide one".to_owned()
} else {
format!("{service} has no owner on the {bus} bus")
},
),
Err(error) => Check::warn(name, format!("could not query {service}: {error}")),
}
}
fn font_check(family: &str) -> Check {
let Some(fc_match) = find_command("fc-match") else {
return Check::warn(
"configured font",
"fc-match is unavailable; font cannot be verified",
);
};
match Command::new(fc_match)
.args(["-f", "%{family}\n", family])
.output()
{
Ok(output) if output.status.success() => {
let matched = String::from_utf8_lossy(&output.stdout)
.lines()
.next()
.unwrap_or_default()
.trim()
.to_owned();
if matched.eq_ignore_ascii_case(family) {
Check::pass("configured font", format!("found {matched}"))
} else {
Check::warn(
"configured font",
format!("requested {family:?}, but fontconfig selected {matched:?}"),
)
}
}
Ok(output) => Check::warn(
"configured font",
format!("fc-match exited with {}", output.status),
),
Err(error) => Check::warn(
"configured font",
format!("could not run fc-match: {error}"),
),
}
}
fn path_group_check(name: &'static str, path: &Path, required: bool) -> Check {
match fs::read_dir(path) {
Ok(entries) => {
let count = entries.filter_map(Result::ok).count();
if count == 0 {
Check::warn(name, format!("{} exists but is empty", path.display()))
} else {
Check::pass(name, format!("found {count} entries in {}", path.display()))
}
}
Err(error) if required => {
Check::fail(name, format!("cannot inspect {}: {error}", path.display()))
}
Err(error) => Check::warn(name, format!("cannot inspect {}: {error}", path.display())),
}
}
pub fn print(checks: &[Check]) {
for check in checks {
let marker = match check.status {
CheckStatus::Pass => "PASS",
CheckStatus::Warn => "WARN",
CheckStatus::Fail => "FAIL",
};
println!("{marker:4} {:20} {}", check.name, check.detail);
}
}
pub fn has_failures(checks: &[Check]) -> bool {
checks.iter().any(|check| check.status == CheckStatus::Fail)
}

131
src/format.rs Normal file
View File

@@ -0,0 +1,131 @@
use std::collections::BTreeMap;
#[derive(Debug, Clone, PartialEq)]
pub enum Value {
Text(String),
Integer(i64),
Number(f64),
Boolean(bool),
}
impl From<&str> for Value {
fn from(value: &str) -> Self {
Self::Text(value.to_owned())
}
}
impl From<String> for Value {
fn from(value: String) -> Self {
Self::Text(value)
}
}
impl From<i64> for Value {
fn from(value: i64) -> Self {
Self::Integer(value)
}
}
impl From<u8> for Value {
fn from(value: u8) -> Self {
Self::Integer(i64::from(value))
}
}
impl From<f64> for Value {
fn from(value: f64) -> Self {
Self::Number(value)
}
}
impl From<bool> for Value {
fn from(value: bool) -> Self {
Self::Boolean(value)
}
}
pub fn expand(template: &str, values: &BTreeMap<&str, Value>) -> String {
let mut rendered = String::with_capacity(template.len());
let mut chars = template.char_indices().peekable();
while let Some((_, character)) = chars.next() {
match character {
'{' if chars.peek().is_some_and(|(_, next)| *next == '{') => {
chars.next();
rendered.push('{');
}
'}' if chars.peek().is_some_and(|(_, next)| *next == '}') => {
chars.next();
rendered.push('}');
}
'{' => {
let mut expression = String::new();
let mut closed = false;
for (_, next) in chars.by_ref() {
if next == '}' {
closed = true;
break;
}
expression.push(next);
}
if !closed {
rendered.push('{');
rendered.push_str(&expression);
break;
}
if let Some(value) = render_expression(&expression, values) {
rendered.push_str(&value);
} else {
rendered.push('{');
rendered.push_str(&expression);
rendered.push('}');
}
}
other => rendered.push(other),
}
}
rendered
}
fn render_expression(expression: &str, values: &BTreeMap<&str, Value>) -> Option<String> {
let (key, format) = expression
.split_once(':')
.map_or((expression, None), |(key, format)| (key, Some(format)));
let value = values.get(key)?;
Some(match (value, format) {
(Value::Number(number), Some(format)) => format_number(*number, format)?,
(Value::Number(number), None) => number.to_string(),
(Value::Integer(number), _) => number.to_string(),
(Value::Text(text), _) => text.clone(),
(Value::Boolean(boolean), _) => boolean.to_string(),
})
}
fn format_number(number: f64, format: &str) -> Option<String> {
let precision = format.strip_prefix('.')?.parse::<usize>().ok()?;
if precision > 12 {
return None;
}
Some(format!("{number:.precision$}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn expands_named_values_and_precision() {
let values = BTreeMap::from([
("name", Value::from("wifi")),
("temperature", Value::from(42.67)),
]);
assert_eq!(expand("{name} {temperature:.1}°C", &values), "wifi 42.7°C");
}
#[test]
fn preserves_unknown_and_unclosed_expressions() {
let values = BTreeMap::new();
assert_eq!(expand("{missing}", &values), "{missing}");
assert_eq!(expand("value {", &values), "value {");
assert_eq!(expand("{{ok}}", &values), "{ok}");
}
}

10
src/lib.rs Normal file
View File

@@ -0,0 +1,10 @@
pub mod app;
pub mod cli;
pub mod config;
pub mod doctor;
pub mod format;
pub mod model;
pub mod modules;
pub mod render;
pub const APP_NAME: &str = "lightbar";

67
src/main.rs Normal file
View File

@@ -0,0 +1,67 @@
use std::{env, process::ExitCode};
use anyhow::Result;
use clap::Parser;
use lightbar::{
cli::{Cli, Command},
config::ConfigBundle,
doctor,
};
use tracing_subscriber::EnvFilter;
fn main() -> ExitCode {
let cli = Cli::parse();
init_logging(&cli);
match run(cli) {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
tracing::error!(error = %format_args!("{error:#}"), "lightbar failed");
eprintln!("lightbar: {error:#}");
ExitCode::from(1)
}
}
}
fn init_logging(cli: &Cli) {
let fallback = format!("lightbar={}", cli.log_level);
let filter = env::var("LIGHTBAR_LOG")
.or_else(|_| env::var("RUST_LOG"))
.unwrap_or(fallback);
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::new(filter))
.with_target(false)
.compact()
.init();
}
fn run(cli: Cli) -> Result<()> {
match cli.command {
Some(Command::CheckConfig) => {
let bundle = ConfigBundle::load(cli.config.as_deref())?;
println!(
"valid: {} (theme {})",
bundle.config_path.display(),
bundle.theme_path.display()
);
Ok(())
}
Some(Command::Doctor) => {
let checks = doctor::run(cli.config.as_deref());
doctor::print(&checks);
if doctor::has_failures(&checks) {
anyhow::bail!("one or more required checks failed");
}
Ok(())
}
None => {
let bundle = ConfigBundle::load(cli.config.as_deref())?;
tracing::info!(
config = %bundle.config_path.display(),
theme = %bundle.theme_path.display(),
bar_id = ?cli.bar_id,
"configuration loaded"
);
lightbar::app::run(bundle, cli.bar_id)
}
}
}

360
src/model.rs Normal file
View File

@@ -0,0 +1,360 @@
use std::collections::BTreeMap;
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ModuleSnapshot {
pub segments: Vec<Segment>,
pub tooltip: Option<String>,
pub popup: Option<PopupModel>,
pub visible: bool,
pub stale: bool,
}
impl ModuleSnapshot {
pub fn text(text: impl Into<String>) -> Self {
Self {
segments: vec![Segment::new(text)],
visible: true,
..Self::default()
}
}
pub fn unavailable(placeholder: Option<&str>) -> Self {
placeholder.map_or_else(Self::default, |text| {
let mut snapshot = Self::text(text);
snapshot.segments[0].state = "disabled".to_owned();
snapshot
})
}
pub fn with_state(mut self, state: impl Into<String>) -> Self {
if let Some(segment) = self.segments.first_mut() {
segment.state = state.into();
}
self
}
pub fn for_output(&self, output: &str) -> Self {
let mut filtered = self.clone();
filtered.segments.retain(|segment| {
segment
.output
.as_deref()
.is_none_or(|candidate| candidate == output)
});
filtered.visible &= !filtered.segments.is_empty();
filtered
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Segment {
pub id: Option<String>,
pub text: String,
pub state: String,
pub output: Option<String>,
pub icon: Option<IconPixmap>,
}
impl Segment {
pub fn new(text: impl Into<String>) -> Self {
Self {
id: None,
text: text.into(),
state: "normal".to_owned(),
output: None,
icon: None,
}
}
}
/// A small premultiplied BGRA bitmap, ready to blend into an ARGB8888
/// shared-memory Wayland buffer.
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct IconPixmap {
pub width: u32,
pub height: u32,
pub pixels: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct PopupModel {
pub title: String,
pub content: PopupContent,
}
#[derive(Debug, Clone, PartialEq)]
pub enum PopupContent {
Rows(Vec<PopupRow>),
Calendar(CalendarModel),
Slider(SliderModel),
Audio(AudioModel),
}
#[derive(Debug, Copy, Clone, Default, Eq, PartialEq)]
pub enum AudioView {
#[default]
Mixer,
Outputs,
Inputs,
}
#[derive(Debug, Clone, PartialEq)]
pub struct AudioDevice {
pub id: u32,
pub name: String,
pub description: String,
pub input: bool,
pub is_default: bool,
pub volume: Option<f64>,
pub muted: Option<bool>,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct AudioModel {
pub devices: Vec<AudioDevice>,
pub view: AudioView,
pub focused: Option<String>,
pub offset: usize,
}
#[derive(Debug, Clone, PartialEq)]
pub struct AudioControl {
pub id: Option<String>,
pub label: String,
pub slider: Option<f64>,
pub active: bool,
}
impl AudioControl {
#[must_use]
pub fn height(&self) -> f64 {
if self.slider.is_some() { 54.0 } else { 32.0 }
}
}
impl AudioModel {
#[must_use]
pub fn default_device(&self, input: bool) -> Option<&AudioDevice> {
self.devices
.iter()
.find(|device| device.input == input && device.is_default)
}
#[must_use]
pub fn controls(&self) -> Vec<AudioControl> {
let mut controls = Vec::new();
if self.view == AudioView::Mixer {
for (input, label, selector) in [
(false, "Output", "audio-outputs"),
(true, "Microphone", "audio-inputs"),
] {
let device = self.default_device(input);
controls.push(AudioControl {
id: None,
label: label.to_owned(),
slider: None,
active: false,
});
controls.push(AudioControl {
id: Some(selector.to_owned()),
label: device.map_or_else(
|| "No default device · Choose…".to_owned(),
|device| format!("{} ›", device.description),
),
slider: None,
active: false,
});
if let Some(device) = device {
if let Some(volume) = device.volume {
controls.push(AudioControl {
id: Some(format!("audio-volume:{}", device.id)),
label: format!(
"Volume {volume:.0}%{}",
if device.muted == Some(true) {
" · Muted"
} else {
""
}
),
slider: Some(volume),
active: false,
});
}
if let Some(muted) = device.muted {
controls.push(AudioControl {
id: Some(format!("audio-mute:{}", device.id)),
label: if muted { "Unmute" } else { "Mute" }.to_owned(),
slider: None,
active: muted,
});
}
}
}
controls.push(AudioControl {
id: Some("open-settings".to_owned()),
label: "Open pavucontrol…".to_owned(),
slider: None,
active: false,
});
} else {
controls.push(AudioControl {
id: Some("audio-back".to_owned()),
label: "‹ Back to volume controls".to_owned(),
slider: None,
active: false,
});
let input = self.view == AudioView::Inputs;
controls.push(AudioControl {
id: None,
label: if input {
"Choose default microphone"
} else {
"Choose default output"
}
.to_owned(),
slider: None,
active: false,
});
for device in self.devices.iter().filter(|device| device.input == input) {
controls.push(AudioControl {
id: Some(format!("audio-default:{}", device.id)),
label: format!(
"{}{}",
if device.is_default { "✓ " } else { "" },
device.description
),
slider: None,
active: device.is_default,
});
}
if controls.len() == 2 {
controls.push(AudioControl {
id: None,
label: "No devices available".to_owned(),
slider: None,
active: false,
});
}
}
controls
}
pub fn set_view(&mut self, view: AudioView) {
self.view = view;
self.offset = 0;
self.focused = None;
}
pub fn move_focus(&mut self, backwards: bool, available_height: f64) {
let controls = self.controls();
let indices: Vec<_> = controls
.iter()
.enumerate()
.filter_map(|(i, c)| c.id.as_ref().map(|_| i))
.collect();
if indices.is_empty() {
return;
}
let current = indices.iter().position(|&i| controls[i].id == self.focused);
let next = match (current, backwards) {
(Some(i), true) => (i + indices.len() - 1) % indices.len(),
(Some(i), false) => (i + 1) % indices.len(),
(None, true) => indices.len() - 1,
(None, false) => 0,
};
let index = indices[next];
self.focused.clone_from(&controls[index].id);
self.offset = self.offset.min(index);
while self.offset < index
&& controls[self.offset..=index]
.iter()
.map(AudioControl::height)
.sum::<f64>()
> available_height
{
self.offset += 1;
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct PopupRow {
pub id: Option<String>,
pub label: String,
pub detail: Option<String>,
pub state: String,
pub enabled: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub struct CalendarModel {
pub year: i32,
pub month: u32,
pub selected_day: u32,
}
#[derive(Debug, Clone, PartialEq)]
pub struct SliderModel {
pub label: String,
pub value: f64,
pub minimum: f64,
pub maximum: f64,
pub muted: bool,
pub detail_rows: Vec<PopupRow>,
}
#[derive(Debug, Clone)]
pub struct ModuleEvent {
pub module: String,
pub snapshot: ModuleSnapshot,
}
#[derive(Debug, Clone, Default)]
pub struct ModuleStore {
snapshots: BTreeMap<String, ModuleSnapshot>,
}
impl ModuleStore {
pub fn get(&self, module: &str) -> Option<&ModuleSnapshot> {
self.snapshots.get(module)
}
pub fn update(&mut self, event: ModuleEvent) -> bool {
if self.snapshots.get(&event.module) == Some(&event.snapshot) {
return false;
}
self.snapshots.insert(event.module, event.snapshot);
true
}
pub fn insert(&mut self, module: impl Into<String>, snapshot: ModuleSnapshot) {
self.snapshots.insert(module.into(), snapshot);
}
}
#[derive(Debug, Copy, Clone, Eq, PartialEq)]
pub enum PointerButton {
Left,
Middle,
Right,
}
#[derive(Debug, Copy, Clone, Eq, PartialEq)]
pub enum ScrollDirection {
Up,
Down,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Interaction {
Click {
module: String,
segment: Option<String>,
button: PointerButton,
},
Scroll {
module: String,
segment: Option<String>,
direction: ScrollDirection,
},
}

812
src/modules/audio.rs Normal file
View File

@@ -0,0 +1,812 @@
use std::{
collections::BTreeMap,
process::Stdio,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
thread::{self, JoinHandle},
time::Duration,
};
use anyhow::{Context, Result, bail};
use calloop::channel::Sender;
use serde_json::Value as Json;
use tokio::{io::AsyncReadExt, process::Command, sync::mpsc};
use crate::{
config::{AudioModule, UnavailableBehavior},
format::{Value, expand},
model::{
AudioDevice, AudioModel, ModuleEvent, ModuleSnapshot, PopupContent, PopupModel, Segment,
},
};
const MAX_FRAME_BYTES: usize = 8 * 1024 * 1024;
const MAX_OBJECTS: usize = 4096;
const COMMAND_TIMEOUT: Duration = Duration::from_secs(2);
#[derive(Debug, Clone, Copy)]
pub enum AudioTarget {
DefaultOutput,
DefaultInput,
Device(u32),
}
impl AudioTarget {
fn argument(self) -> String {
match self {
Self::DefaultOutput => "@DEFAULT_AUDIO_SINK@".to_owned(),
Self::DefaultInput => "@DEFAULT_AUDIO_SOURCE@".to_owned(),
Self::Device(id) => id.to_string(),
}
}
}
#[derive(Debug, Clone, Copy)]
pub enum AudioAction {
SetVolume(u32, f64),
ChangeVolume(AudioTarget, i16),
ToggleMute(AudioTarget),
SetDefault(u32),
}
impl AudioAction {
fn arguments(self) -> Vec<String> {
match self {
Self::SetVolume(id, percent) => vec![
"set-volume".into(),
id.to_string(),
format!("{:.3}", percent.clamp(0.0, 150.0) / 100.0),
],
Self::ChangeVolume(target, step) => vec![
"set-volume".into(),
"-l".into(),
"1.5".into(),
target.argument(),
format!(
"{}%{}",
step.unsigned_abs(),
if step < 0 { "-" } else { "+" }
),
],
Self::ToggleMute(target) => vec!["set-mute".into(), target.argument(), "toggle".into()],
Self::SetDefault(id) => vec!["set-default".into(), id.to_string()],
}
}
}
pub fn spawn(
name: String,
settings: AudioModule,
sender: Sender<ModuleEvent>,
stop: Arc<AtomicBool>,
) -> (JoinHandle<()>, mpsc::Sender<AudioAction>) {
let (actions, mut requests) = mpsc::channel(32);
let worker = thread::Builder::new().name(format!("lightbar-audio-{name}")).spawn(move || {
let runtime = match tokio::runtime::Builder::new_current_thread().enable_all().build() {
Ok(runtime) => runtime,
Err(error) => {
tracing::error!(module = %name, %error, "could not start audio runtime");
return;
}
};
runtime.block_on(async {
let mut failures = 0_u32;
while !stop.load(Ordering::Acquire) {
let result = monitor(&name, &settings, &sender, &stop, &mut requests, &mut failures).await;
if stop.load(Ordering::Acquire) { break; }
if let Err(error) = result {
tracing::warn!(module = %name, %error, "audio monitor stopped; reconnecting");
}
let placeholder = match settings.common.unavailable {
UnavailableBehavior::Hide => None,
UnavailableBehavior::Placeholder => Some("Audio unavailable"),
};
if sender.send(ModuleEvent { module: name.clone(), snapshot: ModuleSnapshot::unavailable(placeholder) }).is_err() { break; }
// Commands queued for a disconnected generation must not affect a new device.
while requests.try_recv().is_ok() {
tracing::warn!(module = %name, "discarded audio action after disconnect");
}
failures = failures.saturating_add(1);
let deadline = tokio::time::Instant::now() + super::process::backoff(failures);
while !stop.load(Ordering::Acquire) && tokio::time::Instant::now() < deadline {
tokio::select! {
() = tokio::time::sleep_until(deadline) => break,
action = requests.recv() => {
if action.is_none() { return; }
tracing::warn!(module = %name, "discarded audio action while disconnected");
}
}
}
}
});
}).expect("audio worker thread");
(worker, actions)
}
async fn monitor(
name: &str,
settings: &AudioModule,
sender: &Sender<ModuleEvent>,
stop: &AtomicBool,
requests: &mut mpsc::Receiver<AudioAction>,
failures: &mut u32,
) -> Result<()> {
let mut child = Command::new("pw-dump")
.args(["--monitor", "--no-colors"])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.kill_on_drop(true)
.spawn()
.context("could not start pw-dump")?;
let mut stdout = child
.stdout
.take()
.context("pw-dump stdout was not piped")?;
let result = async {
let mut frames = JsonFrames::default();
let mut state = AudioState::default();
let mut last = None;
let mut buffer = [0_u8; 8192];
while !stop.load(Ordering::Acquire) {
tokio::select! {
read = stdout.read(&mut buffer) => {
let read = read.context("could not read pw-dump events")?;
if read == 0 { bail!("pw-dump event stream closed"); }
for batch in frames.push(&buffer[..read])? {
if state.apply(batch)? {
// pw-dump's metadata deltas omit deleted keys. Re-read the
// default metadata to distinguish a removal from no change.
let defaults = read_defaults().await?;
state.replace_defaults(defaults)?;
}
let snapshot = state.snapshot(settings);
if last.as_ref() != Some(&snapshot) {
sender.send(ModuleEvent { module: name.to_owned(), snapshot: snapshot.clone() })
.map_err(|_| anyhow::anyhow!("UI event channel closed"))?;
last = Some(snapshot);
}
*failures = 0;
}
}
action = requests.recv() => {
let Some(action) = action else { break; };
if let Err(error) = run_action(action).await { tracing::warn!(module = name, %error, "audio control failed"); }
}
}
}
Ok(())
}.await;
// Always reap the monitor, including malformed input and closed UI channels.
if child.try_wait()?.is_none() {
child.kill().await.context("could not stop pw-dump")?;
}
child.wait().await.context("could not reap pw-dump")?;
result
}
async fn run_action(action: AudioAction) -> Result<()> {
let args = action.arguments();
let mut child = Command::new("wpctl")
.args(&args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::inherit())
.kill_on_drop(true)
.spawn()
.with_context(|| format!("could not run wpctl {}", args[0]))?;
if let Ok(status) = tokio::time::timeout(COMMAND_TIMEOUT, child.wait()).await {
let status = status.context("could not wait for wpctl")?;
if !status.success() {
bail!("wpctl {} exited with {status}", args[0]);
}
Ok(())
} else {
child
.kill()
.await
.context("could not stop timed-out wpctl")?;
child.wait().await.context("could not reap wpctl")?;
bail!("wpctl {} timed out", args[0]);
}
}
async fn read_defaults() -> Result<Vec<Json>> {
let mut child = Command::new("pw-dump")
.args(["--no-colors", "default"])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.kill_on_drop(true)
.spawn()
.context("could not read default audio metadata")?;
let stdout = child
.stdout
.take()
.context("metadata stdout was not piped")?;
let operation = async {
let mut bytes = Vec::new();
stdout
.take(MAX_FRAME_BYTES as u64 + 1)
.read_to_end(&mut bytes)
.await?;
if bytes.len() > MAX_FRAME_BYTES {
bail!("default audio metadata exceeded the size limit");
}
let status = child.wait().await?;
if !status.success() {
bail!("default audio metadata command exited with {status}");
}
serde_json::from_slice(&bytes).context("invalid default audio metadata")
};
let result = tokio::time::timeout(COMMAND_TIMEOUT, operation).await;
if child.try_wait()?.is_none() {
child
.kill()
.await
.context("could not stop audio metadata command")?;
}
child
.wait()
.await
.context("could not reap audio metadata command")?;
result.context("default audio metadata query timed out")?
}
#[derive(Default)]
struct JsonFrames {
bytes: Vec<u8>,
depth: usize,
quoted: bool,
escaped: bool,
}
impl JsonFrames {
fn push(&mut self, bytes: &[u8]) -> Result<Vec<Vec<Json>>> {
let mut batches = Vec::new();
for &byte in bytes {
if self.bytes.is_empty() {
if byte.is_ascii_whitespace() {
continue;
}
if byte != b'[' {
bail!("pw-dump event must be a JSON array");
}
}
if self.bytes.len() >= MAX_FRAME_BYTES {
bail!("pw-dump event exceeded {MAX_FRAME_BYTES} bytes");
}
self.bytes.push(byte);
if self.quoted {
if self.escaped {
self.escaped = false;
} else if byte == b'\\' {
self.escaped = true;
} else if byte == b'"' {
self.quoted = false;
}
} else {
match byte {
b'"' => self.quoted = true,
b'[' | b'{' => self.depth += 1,
b']' | b'}' => {
self.depth = self
.depth
.checked_sub(1)
.context("unbalanced pw-dump JSON")?;
if self.depth == 0 {
batches.push(
serde_json::from_slice(&self.bytes)
.context("invalid pw-dump JSON")?,
);
self.bytes.clear();
}
}
_ => {}
}
}
}
Ok(batches)
}
}
#[derive(Default)]
struct AudioState {
objects: BTreeMap<u32, Json>,
metadata_id: Option<u32>,
default_output: Option<String>,
default_input: Option<String>,
}
impl AudioState {
fn apply(&mut self, batch: Vec<Json>) -> Result<bool> {
let mut metadata_changed = false;
for object in batch {
let id = object["id"]
.as_u64()
.and_then(|id| u32::try_from(id).ok())
.context("pw-dump object has no valid id")?;
if object.get("info").is_some_and(Json::is_null) {
self.objects.remove(&id);
if self.metadata_id == Some(id) {
self.metadata_id = None;
self.default_output = None;
self.default_input = None;
}
continue;
}
if object["props"]["metadata.name"] == "default" || self.metadata_id == Some(id) {
metadata_changed = true;
self.metadata_id = Some(id);
if let Some(entries) = object["metadata"].as_array() {
for entry in entries {
if entry["subject"].as_u64() != Some(0) {
continue;
}
let target = match entry["key"].as_str() {
Some("default.audio.sink") => &mut self.default_output,
Some("default.audio.source") => &mut self.default_input,
_ => continue,
};
*target = entry["value"]["name"].as_str().map(str::to_owned);
}
}
continue;
}
let class = object["info"]["props"]["media.class"].as_str();
if matches!(
class,
Some("Audio/Sink" | "Audio/Source" | "Audio/Source/Virtual" | "Audio/Device")
) || self.objects.contains_key(&id)
{
let stored = self
.objects
.entry(id)
.or_insert_with(|| serde_json::json!({}));
// Node properties are complete; parameter keys are incremental.
if let Some(info) = object["info"].as_object() {
if stored.get("info").is_none() {
stored["info"] = serde_json::json!({});
}
if let Some(props) = info.get("props") {
stored["info"]["props"] = props.clone();
}
if let Some(params) = info.get("params").and_then(Json::as_object) {
if stored["info"].get("params").is_none() {
stored["info"]["params"] = serde_json::json!({});
}
for (key, value) in params {
stored["info"]["params"][key] = value.clone();
}
}
}
}
if self.objects.len() > MAX_OBJECTS {
bail!("too many audio objects");
}
}
Ok(metadata_changed)
}
fn replace_defaults(&mut self, snapshot: Vec<Json>) -> Result<()> {
self.default_output = None;
self.default_input = None;
self.metadata_id = None;
self.apply(snapshot)?;
Ok(())
}
fn devices(&self) -> Vec<AudioDevice> {
let mut devices = Vec::new();
for (&id, object) in &self.objects {
let props = &object["info"]["props"];
let input = match props["media.class"].as_str() {
Some("Audio/Sink") => false,
Some("Audio/Source" | "Audio/Source/Virtual") => true,
_ => continue,
};
let Some(name) = props["node.name"].as_str() else {
continue;
};
let description = props["node.description"]
.as_str()
.or_else(|| props["node.nick"].as_str())
.unwrap_or(name);
let route_volume = number(&props["device.id"])
.and_then(|id| self.objects.get(&id))
.and_then(|device| device["info"]["params"]["Route"].as_array())
.and_then(|routes| {
routes.iter().find(|route| {
number(&route["device"]) == number(&props["card.profile.device"])
&& number(&route["device"]).is_some()
})
})
.and_then(|route| volume(&route["props"]));
let node_volume = || {
object["info"]["params"]["Props"]
.as_array()
.and_then(|params| params.iter().find_map(volume))
};
let level = route_volume.or_else(node_volume);
let default = if input {
&self.default_input
} else {
&self.default_output
};
devices.push(AudioDevice {
id,
name: name.to_owned(),
description: description.to_owned(),
input,
is_default: default.as_deref() == Some(name),
volume: level.map(|(volume, _)| volume),
muted: level.map(|(_, muted)| muted),
});
}
devices.sort_by(|a, b| {
a.input
.cmp(&b.input)
.then_with(|| a.description.cmp(&b.description))
.then_with(|| a.name.cmp(&b.name))
});
devices
}
fn snapshot(&self, settings: &AudioModule) -> ModuleSnapshot {
let model = AudioModel {
devices: self.devices(),
..AudioModel::default()
};
let mut snapshot = ModuleSnapshot {
visible: true,
..ModuleSnapshot::default()
};
let mut descriptions = Vec::new();
for (input, id, label) in [(false, "output", "Output"), (true, "input", "Microphone")] {
let device = model.default_device(input);
let mut segment = Segment::new(format!("{label} N/A"));
segment.id = Some(id.to_owned());
if let Some(device) = device {
let format = match (input, device.muted == Some(true)) {
(false, false) => &settings.format,
(false, true) => &settings.muted_format,
(true, false) => &settings.microphone_format,
(true, true) => &settings.microphone_muted_format,
};
segment.text = expand(
format,
&BTreeMap::from([
(
"volume",
device
.volume
.map_or_else(|| Value::from("N/A"), Value::from),
),
("device", Value::from(device.description.clone())),
("name", Value::from(device.name.clone())),
]),
);
descriptions.push(format!(
"{label}: {} · {}{}",
device.description,
device.volume.map_or_else(
|| "Volume unavailable".to_owned(),
|volume| format!("{volume:.0}%")
),
if device.muted == Some(true) {
" · Muted"
} else {
""
}
));
} else {
descriptions.push(format!("{label}: no default device"));
}
if device.is_none_or(|device| device.volume.is_none() || device.muted == Some(true)) {
"disabled".clone_into(&mut segment.state);
}
snapshot.segments.push(segment);
}
snapshot.tooltip = Some(descriptions.join("\n"));
snapshot.popup = Some(PopupModel {
title: "Audio".to_owned(),
content: PopupContent::Audio(model),
});
snapshot
}
}
fn number(value: &Json) -> Option<u32> {
value
.as_u64()
.and_then(|value| u32::try_from(value).ok())
.or_else(|| value.as_str()?.parse().ok())
}
fn volume(props: &Json) -> Option<(f64, bool)> {
let muted = props["mute"].as_bool()?;
let linear = props["channelVolumes"]
.as_array()
.and_then(|channels| channels.first())
.and_then(Json::as_f64)
.or_else(|| props["volume"].as_f64())?;
// WirePlumber exposes the first channel on a cubic scale, including route volume.
(linear.is_finite() && linear >= 0.0).then(|| ((linear.cbrt() * 100.0).round(), muted))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn fixture() -> AudioState {
let mut state = AudioState::default();
state
.apply(serde_json::from_str(include_str!("../../tests/fixtures/audio.json")).unwrap())
.unwrap();
state
}
#[test]
fn reports_actual_defaults_route_volume_and_independent_mute() {
let state = fixture();
let snapshot = state.snapshot(&AudioModule::default());
let PopupContent::Audio(audio) = snapshot.popup.unwrap().content else {
panic!("audio popup missing");
};
assert_eq!(
audio.devices.len(),
3,
"application streams are not devices"
);
let output = audio.default_device(false).unwrap();
assert_eq!(
(output.id, output.volume, output.muted),
(10, Some(50.0), Some(false))
);
let input = audio.default_device(true).unwrap();
assert_eq!(
(input.id, input.volume, input.muted),
(11, Some(80.0), Some(true))
);
assert_eq!(snapshot.segments[0].state, "normal");
assert_eq!(snapshot.segments[1].state, "disabled");
assert!(
snapshot
.tooltip
.unwrap()
.contains("USB Microphone · 80% · Muted")
);
}
#[test]
fn follows_external_defaults_and_keeps_other_endpoint() {
let mut state = fixture();
state.apply(vec![json!({"id":1,"metadata":[{"subject":0,"key":"default.audio.sink","value":{"name":"headphones"}}]})]).unwrap();
let devices = state.devices();
assert!(
devices
.iter()
.any(|device| device.id == 12 && device.is_default)
);
assert!(
devices
.iter()
.any(|device| device.id == 11 && device.is_default)
);
state.apply(vec![json!({"id":11,"info":null})]).unwrap();
let snapshot = state.snapshot(&AudioModule::default());
assert_eq!(snapshot.segments[1].text, "Microphone N/A");
let PopupContent::Audio(audio) = snapshot.popup.unwrap().content else {
panic!();
};
assert!(audio.default_device(false).is_some());
assert!(audio.default_device(true).is_none());
assert!(
!audio
.controls()
.iter()
.any(|control| control.id.as_deref() == Some("audio-mute:11"))
);
state.apply(vec![json!({"id":1,"info":null})]).unwrap();
assert!(!state.devices().iter().any(|device| device.is_default));
}
#[test]
fn removing_a_default_clears_only_that_endpoint() {
let mut state = fixture();
assert!(state.apply(vec![json!({"id":1,"metadata":[]})]).unwrap());
state
.replace_defaults(vec![
json!({"id":1,"props":{"metadata.name":"default"},"metadata":[
{"subject":0,"key":"default.audio.sink","value":{"name":"speakers"}}
]}),
])
.unwrap();
let devices = state.devices();
assert!(
devices
.iter()
.any(|device| device.id == 10 && device.is_default)
);
assert!(
!devices
.iter()
.any(|device| device.input && device.is_default)
);
}
#[test]
fn preserves_final_event_in_a_burst_and_handles_partial_parameters() {
let mut frames = JsonFrames::default();
let mut state = fixture();
let stream = concat!(
"[{\"id\":11,\"info\":{\"params\":{\"Props\":[{\"mute\":false,\"channelVolumes\":[0.001]}]}}}]\n",
"[{\"id\":11,\"info\":{\"params\":{\"Props\":[{\"mute\":false,\"channelVolumes\":[0.729]}]}}}]\n"
);
for chunk in stream.as_bytes().chunks(7) {
for batch in frames.push(chunk).unwrap() {
state.apply(batch).unwrap();
}
}
let microphone = state
.devices()
.into_iter()
.find(|device| device.id == 11)
.unwrap();
assert_eq!(
(microphone.volume, microphone.muted),
(Some(90.0), Some(false))
);
assert_eq!(microphone.description, "USB Microphone");
}
#[test]
fn stream_parser_handles_escaped_names_and_limits() {
let data = json!([{"id":1,"text":"Device [USB] \"name\" \\ test"}]).to_string();
let mut frames = JsonFrames::default();
let mut batches = Vec::new();
for byte in data.as_bytes() {
batches.extend(frames.push(&[*byte]).unwrap());
}
assert_eq!(batches.len(), 1);
assert_eq!(batches[0][0]["text"], "Device [USB] \"name\" \\ test");
assert!(JsonFrames::default().push(b"not json").is_err());
assert!(JsonFrames::default().push(b"[{broken}]").is_err());
let mut oversized = JsonFrames::default();
oversized.push(b"[\"").unwrap();
assert!(oversized.push(&vec![b'x'; MAX_FRAME_BYTES]).is_err());
}
#[test]
fn volume_keeps_overamplification_and_distinguishes_silence_from_mute() {
assert_eq!(
volume(&json!({"mute":false,"channelVolumes":[0.0]})),
Some((0.0, false))
);
assert_eq!(
volume(&json!({"mute":true,"channelVolumes":[8.0]})),
Some((200.0, true))
);
assert_eq!(
volume(&json!({"mute":false,"volume":0.125})),
Some((50.0, false))
);
assert_eq!(volume(&json!({"mute":false,"channelVolumes":[-1.0]})), None);
assert_eq!(volume(&json!({"channelVolumes":[1.0]})), None);
}
#[test]
fn audio_commands_target_the_selected_endpoint_and_limit_volume() {
assert_eq!(
AudioAction::ToggleMute(AudioTarget::DefaultInput).arguments(),
["set-mute", "@DEFAULT_AUDIO_SOURCE@", "toggle"]
);
assert_eq!(
AudioAction::ChangeVolume(AudioTarget::DefaultOutput, 7).arguments(),
["set-volume", "-l", "1.5", "@DEFAULT_AUDIO_SINK@", "7%+"]
);
assert_eq!(
AudioAction::ChangeVolume(AudioTarget::Device(11), -7).arguments(),
["set-volume", "-l", "1.5", "11", "7%-"]
);
assert_eq!(
AudioAction::SetDefault(12).arguments(),
["set-default", "12"]
);
assert_eq!(
AudioAction::SetVolume(11, 180.0).arguments(),
["set-volume", "11", "1.500"]
);
assert_eq!(
AudioAction::SetVolume(11, -10.0).arguments(),
["set-volume", "11", "0.000"]
);
}
#[test]
fn custom_formats_include_names_and_numeric_volume() {
let settings = AudioModule {
format: "{device}: {volume:.1}%".to_owned(),
microphone_muted_format: "{name}: muted at {volume}%".to_owned(),
..AudioModule::default()
};
let snapshot = fixture().snapshot(&settings);
assert_eq!(snapshot.segments[0].text, "Desktop Speakers: 50.0%");
assert_eq!(snapshot.segments[1].text, "microphone: muted at 80%");
}
#[test]
#[ignore = "requires a running PipeWire/WirePlumber session; read-only"]
fn live_pipewire_matches_wpctl_and_worker_stops_when_idle() {
let output = std::process::Command::new("pw-dump")
.arg("--no-colors")
.output()
.unwrap();
assert!(output.status.success());
let mut state = AudioState::default();
state
.apply(serde_json::from_slice(&output.stdout).unwrap())
.unwrap();
let devices = state.devices();
for target in [AudioTarget::DefaultOutput, AudioTarget::DefaultInput] {
let output = std::process::Command::new("wpctl")
.args(["inspect", &target.argument()])
.output()
.unwrap();
assert!(output.status.success());
let text = String::from_utf8(output.stdout).unwrap();
let id: u32 = text
.split_whitespace()
.nth(1)
.unwrap()
.trim_end_matches(',')
.parse()
.unwrap();
let device = devices.iter().find(|device| device.id == id).unwrap();
assert!(device.is_default);
let output = std::process::Command::new("wpctl")
.args(["get-volume", &target.argument()])
.output()
.unwrap();
assert!(output.status.success());
let text = String::from_utf8(output.stdout).unwrap();
let percent = (text
.split_whitespace()
.nth(1)
.unwrap()
.parse::<f64>()
.unwrap()
* 100.0)
.round();
assert_eq!(device.volume, Some(percent));
assert_eq!(device.muted, Some(text.contains("[MUTED]")));
}
let (sender, receiver) = calloop::channel::channel();
let stop = Arc::new(AtomicBool::new(false));
let (worker, actions) = spawn(
"audio-test".to_owned(),
AudioModule::default(),
sender,
Arc::clone(&stop),
);
let deadline = std::time::Instant::now() + Duration::from_secs(5);
let first_event = loop {
if let Ok(event) = receiver.try_recv() {
break Some(event);
}
if std::time::Instant::now() >= deadline {
break None;
}
thread::sleep(Duration::from_millis(10));
};
stop.store(true, Ordering::Release);
drop(actions);
let start = std::time::Instant::now();
worker.join().unwrap();
assert!(start.elapsed() < Duration::from_secs(1));
assert!(first_event.unwrap().snapshot.popup.is_some());
}
}

78
src/modules/clock.rs Normal file
View File

@@ -0,0 +1,78 @@
use std::{
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
thread::{self, JoinHandle},
time::{Duration, SystemTime, UNIX_EPOCH},
};
use calloop::channel::Sender;
use chrono::{Datelike, Local};
use crate::{
config::ClockModule,
model::{CalendarModel, ModuleEvent, ModuleSnapshot, PopupContent, PopupModel},
};
pub fn spawn(
name: String,
settings: ClockModule,
sender: Sender<ModuleEvent>,
stop: Arc<AtomicBool>,
) -> JoinHandle<()> {
thread::Builder::new()
.name(format!("lightbar-clock-{name}"))
.spawn(move || {
while !stop.load(Ordering::Acquire) {
let now = Local::now();
let mut snapshot = ModuleSnapshot::text(now.format(&settings.format).to_string());
snapshot.tooltip = Some(now.format("%A, %F %T %Z").to_string());
snapshot.popup = Some(PopupModel {
title: now.format("%B %Y").to_string(),
content: PopupContent::Calendar(CalendarModel {
year: now.year(),
month: now.month(),
selected_day: now.day(),
}),
});
if sender
.send(ModuleEvent {
module: name.clone(),
snapshot,
})
.is_err()
{
break;
}
thread::park_timeout(until_next_boundary(settings.interval.0));
}
})
.expect("clock worker thread")
}
fn until_next_boundary(interval: Duration) -> Duration {
let interval_nanos = interval.as_nanos();
if interval_nanos == 0 {
return Duration::from_secs(1);
}
let now_nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let remaining = interval_nanos - now_nanos % interval_nanos;
Duration::from_nanos(u64::try_from(remaining.min(u128::from(u64::MAX))).unwrap_or(u64::MAX))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn next_boundary_is_positive_and_bounded() {
let interval = Duration::from_secs(1);
let remaining = until_next_boundary(interval);
assert!(remaining > Duration::ZERO);
assert!(remaining <= interval);
}
}

334
src/modules/command.rs Normal file
View File

@@ -0,0 +1,334 @@
use std::{
io::{BufRead, BufReader, Read},
process::{Child, Command, ExitStatus, Stdio},
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
},
thread::{self, JoinHandle},
time::{Duration, Instant},
};
use anyhow::{Context, Result, anyhow, bail};
use calloop::channel::Sender;
use serde::Deserialize;
use crate::{
config::{CommandMode, CommandModule},
model::{ModuleEvent, ModuleSnapshot},
};
type ChildRegistry = Arc<Mutex<Vec<Arc<Mutex<Child>>>>>;
pub fn spawn(
name: String,
settings: CommandModule,
sender: Sender<ModuleEvent>,
stop: Arc<AtomicBool>,
children: ChildRegistry,
) -> JoinHandle<()> {
thread::Builder::new()
.name(format!("lightbar-command-{name}"))
.spawn(move || match settings.mode {
CommandMode::Interval => run_interval(name, settings, sender, stop, children),
CommandMode::Stream => run_stream(name, settings, sender, stop, children),
})
.expect("custom command worker thread")
}
fn run_interval(
name: String,
settings: CommandModule,
sender: Sender<ModuleEvent>,
stop: Arc<AtomicBool>,
children: ChildRegistry,
) {
let mut last_good: Option<ModuleSnapshot> = None;
let mut failures = 0_u32;
while !stop.load(Ordering::Acquire) {
let started = Instant::now();
let event = match run_once(&settings, &children, &stop) {
Ok(output) => match parse_output(&output) {
Ok(snapshot) => {
failures = 0;
last_good = Some(snapshot.clone());
snapshot
}
Err(error) => {
failures = failures.saturating_add(1);
tracing::warn!(module = %name, %error, "custom module output was invalid");
stale(&last_good)
}
},
Err(error) => {
failures = failures.saturating_add(1);
tracing::warn!(module = %name, %error, "custom module command failed");
stale(&last_good)
}
};
if sender
.send(ModuleEvent {
module: name.clone(),
snapshot: event,
})
.is_err()
{
break;
}
let delay = if failures == 0 {
settings.interval.0.saturating_sub(started.elapsed())
} else {
super::process::backoff(failures).min(Duration::from_secs(60))
};
thread::park_timeout(delay);
}
}
fn run_stream(
name: String,
settings: CommandModule,
sender: Sender<ModuleEvent>,
stop: Arc<AtomicBool>,
children: ChildRegistry,
) {
let mut last_good: Option<ModuleSnapshot> = None;
let mut failures = 0_u32;
while !stop.load(Ordering::Acquire) {
match stream_once(&name, &settings, &sender, &stop, &children, &mut last_good) {
Ok(()) if stop.load(Ordering::Acquire) => break,
Ok(()) => failures = failures.saturating_add(1),
Err(error) => {
failures = failures.saturating_add(1);
tracing::warn!(module = %name, %error, "custom stream stopped");
}
}
let _ = sender.send(ModuleEvent {
module: name.clone(),
snapshot: stale(&last_good),
});
thread::park_timeout(super::process::backoff(failures).min(Duration::from_secs(60)));
}
}
fn stream_once(
name: &str,
settings: &CommandModule,
sender: &Sender<ModuleEvent>,
stop: &AtomicBool,
children: &ChildRegistry,
last_good: &mut Option<ModuleSnapshot>,
) -> Result<()> {
let mut child = command(settings)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.with_context(|| format!("could not spawn custom stream for {name}"))?;
let stdout = child
.stdout
.take()
.context("custom stream stdout was not piped")?;
let child = Arc::new(Mutex::new(child));
super::process::register_child(children, &child);
if stop.load(Ordering::Acquire)
&& let Ok(mut process) = child.lock()
{
let _ = process.kill();
}
let mut reader = BufReader::new(stdout);
let mut line = Vec::new();
loop {
line.clear();
let read = reader.read_until(b'\n', &mut line)?;
if read == 0 || stop.load(Ordering::Acquire) {
break;
}
if line.len() > settings.max_output_bytes {
if let Ok(mut process) = child.lock() {
let _ = process.kill();
}
bail!("stream line exceeded {} bytes", settings.max_output_bytes);
}
while matches!(line.last(), Some(b'\n' | b'\r')) {
line.pop();
}
let text = std::str::from_utf8(&line).context("stream emitted non-UTF-8 output")?;
match parse_output(text) {
Ok(snapshot) => {
*last_good = Some(snapshot.clone());
if sender
.send(ModuleEvent {
module: name.to_owned(),
snapshot,
})
.is_err()
{
break;
}
}
Err(error) => tracing::warn!(module = name, %error, "ignored invalid stream line"),
}
}
let status = super::process::wait_child(&child, stop)?;
if !status.success() && !stop.load(Ordering::Acquire) {
bail!("custom stream exited with {status}");
}
super::process::unregister_child(children, &child);
Ok(())
}
fn run_once(
settings: &CommandModule,
children: &ChildRegistry,
stop: &AtomicBool,
) -> Result<String> {
let mut child = command(settings)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.context("could not spawn custom command")?;
let stdout = child
.stdout
.take()
.context("custom command stdout was not piped")?;
let stderr = child
.stderr
.take()
.context("custom command stderr was not piped")?;
let child = Arc::new(Mutex::new(child));
super::process::register_child(children, &child);
let limit = u64::try_from(settings.max_output_bytes.saturating_add(1)).unwrap_or(u64::MAX);
let stdout_thread = thread::spawn(move || read_limited(stdout, limit));
let stderr_thread = thread::spawn(move || read_limited(stderr, limit));
let started = Instant::now();
let status: ExitStatus = loop {
if (stop.load(Ordering::Acquire) || started.elapsed() >= settings.timeout.0)
&& let Ok(mut process) = child.lock()
{
let _ = process.kill();
}
let maybe_status = child
.lock()
.map_err(|_| anyhow!("custom command child lock was poisoned"))?
.try_wait()?;
if let Some(status) = maybe_status {
break status;
}
thread::sleep(Duration::from_millis(10));
};
let stdout = stdout_thread
.join()
.map_err(|_| anyhow!("stdout reader panicked"))??;
let stderr = stderr_thread
.join()
.map_err(|_| anyhow!("stderr reader panicked"))??;
super::process::unregister_child(children, &child);
if started.elapsed() >= settings.timeout.0 && !status.success() {
bail!(
"custom command exceeded timeout of {:?}",
settings.timeout.0
);
}
if stdout.len() > settings.max_output_bytes || stderr.len() > settings.max_output_bytes {
bail!(
"custom command exceeded {} byte output limit",
settings.max_output_bytes
);
}
if !status.success() {
let stderr = String::from_utf8_lossy(&stderr);
bail!("custom command exited with {status}: {}", stderr.trim());
}
Ok(String::from_utf8(stdout)?.trim_end().to_owned())
}
fn command(settings: &CommandModule) -> Command {
if let Some(shell) = &settings.shell {
let mut command = Command::new("/bin/sh");
command.args(["-c", shell]);
command
} else {
let mut command = Command::new(&settings.argv[0]);
command.args(&settings.argv[1..]);
command
}
}
fn read_limited(reader: impl Read, limit: u64) -> Result<Vec<u8>> {
let mut output = Vec::new();
reader.take(limit).read_to_end(&mut output)?;
Ok(output)
}
fn stale(last_good: &Option<ModuleSnapshot>) -> ModuleSnapshot {
let mut snapshot = last_good.clone().unwrap_or_default();
snapshot.stale = last_good.is_some();
snapshot
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct JsonOutput {
text: String,
#[serde(default)]
tooltip: Option<String>,
#[serde(default = "normal_state")]
state: String,
#[serde(default = "default_visible")]
visible: bool,
}
fn normal_state() -> String {
"normal".to_owned()
}
const fn default_visible() -> bool {
true
}
fn parse_output(output: &str) -> Result<ModuleSnapshot> {
let trimmed = output.trim();
if trimmed.is_empty() {
return Ok(ModuleSnapshot::default());
}
if trimmed.starts_with('{') {
let parsed: JsonOutput = serde_json::from_str(trimmed)?;
if parsed.state.is_empty()
|| !parsed.state.chars().all(|character| {
character.is_ascii_alphanumeric() || matches!(character, '-' | '_')
})
{
bail!("custom state must be an ASCII identifier");
}
let mut snapshot = ModuleSnapshot::text(parsed.text).with_state(parsed.state);
snapshot.tooltip = parsed.tooltip;
snapshot.visible = parsed.visible;
Ok(snapshot)
} else {
Ok(ModuleSnapshot::text(trimmed))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_plain_and_json_output() {
assert_eq!(parse_output("hello\n").unwrap().segments[0].text, "hello");
let parsed =
parse_output(r#"{"text":"hot","tooltip":"details","state":"critical","visible":true}"#)
.unwrap();
assert_eq!(parsed.segments[0].state, "critical");
assert_eq!(parsed.tooltip.as_deref(), Some("details"));
}
#[test]
fn rejects_unknown_json_fields() {
assert!(parse_output(r#"{"text":"x","bad":true}"#).is_err());
}
}

308
src/modules/mod.rs Normal file
View File

@@ -0,0 +1,308 @@
mod audio;
mod clock;
mod command;
mod process;
mod sway;
mod sysfs;
mod tray;
use std::{
collections::BTreeMap,
process::Child,
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
},
thread::JoinHandle,
};
use anyhow::{Context, Result, bail};
use calloop::channel::{Channel, Sender, channel};
use crate::{
config::{
Action, AudioModule, BacklightModule, BuiltinAction, Config, ModuleConfig,
UnavailableBehavior,
},
model::{ModuleEvent, ModuleSnapshot},
};
pub use audio::{AudioAction, AudioTarget};
pub use sway::run_sway_command;
pub use sysfs::set_backlight_percent;
pub use tray::{activate_tray_item, scroll_tray_item};
#[derive(Debug)]
pub struct ModuleRuntime {
stop: Arc<AtomicBool>,
threads: Vec<JoinHandle<()>>,
children: Arc<Mutex<Vec<Arc<Mutex<Child>>>>>,
module_kinds: BTreeMap<String, &'static str>,
audio_actions: BTreeMap<String, tokio::sync::mpsc::Sender<AudioAction>>,
}
impl ModuleRuntime {
pub fn start(config: &Config) -> Result<(Self, Channel<ModuleEvent>)> {
let (sender, receiver) = channel();
let runtime = Self::start_with_sender(config, sender)?;
Ok((runtime, receiver))
}
pub fn start_with_sender(config: &Config, sender: Sender<ModuleEvent>) -> Result<Self> {
let stop = Arc::new(AtomicBool::new(false));
let children = Arc::new(Mutex::new(Vec::new()));
let mut threads = Vec::new();
let mut module_kinds = BTreeMap::new();
let mut audio_actions = BTreeMap::new();
let sway_modules = sway::ConfiguredSwayModules::from_config(config);
if !sway_modules.is_empty() {
threads.push(sway::spawn(sway_modules, sender.clone(), Arc::clone(&stop)));
}
for (name, module) in &config.modules {
module_kinds.insert(name.clone(), kind_name(module));
if !module.common().enabled {
continue;
}
send_initial(name, module, &sender);
match module {
ModuleConfig::Clock(settings) => threads.push(clock::spawn(
name.clone(),
settings.clone(),
sender.clone(),
Arc::clone(&stop),
)),
ModuleConfig::Temperature(settings) => threads.push(sysfs::spawn_temperature(
name.clone(),
settings.clone(),
sender.clone(),
Arc::clone(&stop),
)),
ModuleConfig::Backlight(settings) => threads.push(sysfs::spawn_backlight(
name.clone(),
settings.clone(),
sender.clone(),
Arc::clone(&stop),
)),
ModuleConfig::Battery(settings) => threads.push(sysfs::spawn_battery(
name.clone(),
settings.clone(),
sender.clone(),
Arc::clone(&stop),
)),
ModuleConfig::Network(settings) => threads.push(process::spawn_network(
name.clone(),
settings.clone(),
sender.clone(),
Arc::clone(&stop),
Arc::clone(&children),
)),
ModuleConfig::Audio(settings) => {
let (worker, actions) = audio::spawn(
name.clone(),
settings.clone(),
sender.clone(),
Arc::clone(&stop),
);
threads.push(worker);
audio_actions.insert(name.clone(), actions);
}
ModuleConfig::Command(settings) => threads.push(command::spawn(
name.clone(),
settings.clone(),
sender.clone(),
Arc::clone(&stop),
Arc::clone(&children),
)),
ModuleConfig::Tray(settings) => threads.push(tray::spawn(
name.clone(),
settings.clone(),
sender.clone(),
Arc::clone(&stop),
)),
ModuleConfig::Workspaces(_)
| ModuleConfig::Mode(_)
| ModuleConfig::Scratchpad(_) => {}
}
}
Ok(Self {
stop,
threads,
children,
module_kinds,
audio_actions,
})
}
pub fn kind(&self, module: &str) -> Option<&'static str> {
self.module_kinds.get(module).copied()
}
/// Queues a control without blocking the UI; the module worker owns execution.
///
/// # Errors
/// Returns an error if the module is stopped or its bounded queue is full.
pub fn audio_action(&self, module: &str, action: AudioAction) -> Result<()> {
self.audio_actions
.get(module)
.context("audio module is not running")?
.try_send(action)
.context("could not queue audio control")
}
}
impl Drop for ModuleRuntime {
fn drop(&mut self) {
self.stop.store(true, Ordering::Release);
self.audio_actions.clear();
for thread in &self.threads {
thread.thread().unpark();
}
let children = self
.children
.lock()
.map(|children| children.clone())
.unwrap_or_default();
for child in children {
if let Ok(mut child) = child.lock() {
let _ = child.kill();
}
}
for thread in self.threads.drain(..) {
if let Err(error) = thread.join() {
tracing::debug!(?error, "module worker did not shut down cleanly");
}
}
}
}
fn send_initial(name: &str, module: &ModuleConfig, sender: &Sender<ModuleEvent>) {
let placeholder = match module.common().unavailable {
UnavailableBehavior::Hide => None,
UnavailableBehavior::Placeholder => Some("N/A"),
};
let _ = sender.send(ModuleEvent {
module: name.to_owned(),
snapshot: ModuleSnapshot::unavailable(placeholder),
});
}
fn kind_name(module: &ModuleConfig) -> &'static str {
match module {
ModuleConfig::Workspaces(_) => "workspaces",
ModuleConfig::Mode(_) => "mode",
ModuleConfig::Scratchpad(_) => "scratchpad",
ModuleConfig::Network(_) => "network",
ModuleConfig::Temperature(_) => "temperature",
ModuleConfig::Backlight(_) => "backlight",
ModuleConfig::Audio(_) => "audio",
ModuleConfig::Battery(_) => "battery",
ModuleConfig::Clock(_) => "clock",
ModuleConfig::Command(_) => "command",
ModuleConfig::Tray(_) => "tray",
}
}
pub fn execute_action(action: &Action) -> Result<()> {
match action {
Action::Builtin { name } => match name {
BuiltinAction::TogglePopover
| BuiltinAction::ClosePopover
| BuiltinAction::PreviousMonth
| BuiltinAction::NextMonth => {
bail!("popup action must be handled by the UI")
}
BuiltinAction::ToggleMute => {
process::wpctl(&["set-mute", "@DEFAULT_AUDIO_SINK@", "toggle"])
}
BuiltinAction::VolumeUp => {
process::wpctl(&["set-volume", "-l", "1.5", "@DEFAULT_AUDIO_SINK@", "5%+"])
}
BuiltinAction::VolumeDown => {
process::wpctl(&["set-volume", "@DEFAULT_AUDIO_SINK@", "5%-"])
}
BuiltinAction::BrightnessUp | BuiltinAction::BrightnessDown => {
bail!("brightness action requires module settings")
}
},
Action::Sway { command } => sway::run_sway_command(command),
Action::Exec { argv } => spawn_detached(argv),
Action::Shell { command } => spawn_shell(command),
}
}
pub fn execute_builtin_for_module(builtin: BuiltinAction, module: &ModuleConfig) -> Result<()> {
match (builtin, module) {
(BuiltinAction::BrightnessUp, ModuleConfig::Backlight(settings)) => {
sysfs::adjust_backlight(settings, i16::from(settings.step_percent))
}
(BuiltinAction::BrightnessDown, ModuleConfig::Backlight(settings)) => {
sysfs::adjust_backlight(settings, -i16::from(settings.step_percent))
}
(BuiltinAction::VolumeUp, ModuleConfig::Audio(settings)) => {
process::change_volume(settings, true)
}
(BuiltinAction::VolumeDown, ModuleConfig::Audio(settings)) => {
process::change_volume(settings, false)
}
_ => execute_action(&Action::Builtin { name: builtin }),
}
}
fn spawn_detached(argv: &[String]) -> Result<()> {
let (program, arguments) = argv.split_first().context("exec action has no program")?;
std::process::Command::new(program)
.args(arguments)
.spawn()
.with_context(|| format!("could not spawn {program:?}"))?;
Ok(())
}
fn spawn_shell(command: &str) -> Result<()> {
std::process::Command::new("/bin/sh")
.args(["-c", command])
.spawn()
.with_context(|| format!("could not execute shell action {command:?}"))?;
Ok(())
}
pub fn action_for_button(
module: &ModuleConfig,
button: crate::model::PointerButton,
) -> Option<&Action> {
let actions = &module.common().actions;
match button {
crate::model::PointerButton::Left => actions.left.as_ref(),
crate::model::PointerButton::Middle => actions.middle.as_ref(),
crate::model::PointerButton::Right => actions.right.as_ref(),
}
}
pub fn action_for_scroll(
module: &ModuleConfig,
direction: crate::model::ScrollDirection,
) -> Option<&Action> {
let actions = &module.common().actions;
match direction {
crate::model::ScrollDirection::Up => actions.scroll_up.as_ref(),
crate::model::ScrollDirection::Down => actions.scroll_down.as_ref(),
}
}
pub fn configured_audio(module: &ModuleConfig) -> Option<&AudioModule> {
if let ModuleConfig::Audio(settings) = module {
Some(settings)
} else {
None
}
}
pub fn configured_backlight(module: &ModuleConfig) -> Option<&BacklightModule> {
if let ModuleConfig::Backlight(settings) = module {
Some(settings)
} else {
None
}
}

335
src/modules/process.rs Normal file
View File

@@ -0,0 +1,335 @@
use std::{
collections::BTreeMap,
io::{BufRead, BufReader},
process::{Child, Command, ExitStatus, Stdio},
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
},
thread::{self, JoinHandle},
time::{Duration, Instant},
};
use anyhow::{Context, Result, bail};
use calloop::channel::Sender;
use crate::{
config::{AudioModule, NetworkModule},
format::{Value, expand},
model::{ModuleEvent, ModuleSnapshot, PopupContent, PopupModel, PopupRow},
};
type ChildRegistry = Arc<Mutex<Vec<Arc<Mutex<Child>>>>>;
pub fn spawn_network(
name: String,
settings: NetworkModule,
sender: Sender<ModuleEvent>,
stop: Arc<AtomicBool>,
children: ChildRegistry,
) -> JoinHandle<()> {
thread::Builder::new()
.name(format!("lightbar-network-{name}"))
.spawn(move || {
let mut failures = 0_u32;
while !stop.load(Ordering::Acquire) {
if publish_network(&name, &settings, &sender).is_ok() {
failures = 0;
} else {
failures = failures.saturating_add(1);
}
if stop.load(Ordering::Acquire) {
break;
}
match monitored_lines(
"nmcli",
&["monitor"],
&stop,
&children,
Duration::from_millis(250),
|| publish_network(&name, &settings, &sender),
) {
Ok(()) if stop.load(Ordering::Acquire) => break,
Ok(()) => {}
Err(error) => {
tracing::warn!(module = %name, %error, "network monitor stopped");
failures = failures.saturating_add(1);
}
}
thread::park_timeout(backoff(failures));
}
})
.expect("network worker thread")
}
fn publish_network(
name: &str,
settings: &NetworkModule,
sender: &Sender<ModuleEvent>,
) -> Result<()> {
let output = Command::new("nmcli")
.args([
"--terse",
"--escape",
"no",
"--fields",
"DEVICE,TYPE,STATE,CONNECTION",
"device",
"status",
])
.output()
.context("could not run nmcli")?;
if !output.status.success() {
bail!("nmcli exited with {}", output.status);
}
let text = String::from_utf8(output.stdout)?;
let mut candidates = text.lines().filter_map(parse_network_line).filter(|entry| {
settings
.interface
.as_deref()
.is_none_or(|interface| entry.device == interface)
});
let selected = candidates
.clone()
.find(|entry| entry.connected && entry.kind == "wifi")
.or_else(|| candidates.find(|entry| entry.connected));
let mut snapshot = if let Some(entry) = selected {
let values = BTreeMap::from([
("ssid", Value::from(entry.connection.clone())),
("connection", Value::from(entry.connection.clone())),
("interface", Value::from(entry.device.clone())),
]);
let mut snapshot = ModuleSnapshot::text(expand(&settings.format_connected, &values));
let addresses = network_addresses(&entry.device).unwrap_or_default();
snapshot.tooltip = Some(format!(
"{} on {}{}",
entry.connection,
entry.device,
if addresses.is_empty() {
String::new()
} else {
format!(" · {}", addresses.join(", "))
}
));
snapshot.popup = Some(PopupModel {
title: "Network".to_owned(),
content: PopupContent::Rows(vec![
PopupRow {
id: None,
label: "Connection".to_owned(),
detail: Some(entry.connection.clone()),
state: "normal".to_owned(),
enabled: false,
},
PopupRow {
id: None,
label: "Interface".to_owned(),
detail: Some(entry.device.clone()),
state: "normal".to_owned(),
enabled: false,
},
PopupRow {
id: Some("open-settings".to_owned()),
label: "Open connection editor".to_owned(),
detail: None,
state: "normal".to_owned(),
enabled: true,
},
]),
});
snapshot
} else {
ModuleSnapshot::text(settings.format_disconnected.clone()).with_state("disconnected")
};
snapshot.visible = true;
sender
.send(ModuleEvent {
module: name.to_owned(),
snapshot,
})
.map_err(|_| anyhow::anyhow!("UI event channel closed"))
}
#[derive(Debug, Clone)]
struct NetworkEntry {
device: String,
kind: String,
connected: bool,
connection: String,
}
fn parse_network_line(line: &str) -> Option<NetworkEntry> {
let mut fields = line.splitn(4, ':');
let device = fields.next()?.to_owned();
let kind = fields.next()?.to_owned();
let state = fields.next()?.to_owned();
let connection = fields.next().unwrap_or_default().to_owned();
Some(NetworkEntry {
device,
kind,
connected: state.starts_with("connected"),
connection,
})
}
fn network_addresses(device: &str) -> Result<Vec<String>> {
let output = Command::new("nmcli")
.args([
"--terse",
"--get-values",
"IP4.ADDRESS",
"device",
"show",
device,
])
.output()?;
if !output.status.success() {
return Ok(Vec::new());
}
Ok(String::from_utf8(output.stdout)?
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(str::to_owned)
.collect())
}
pub fn wpctl(args: &[&str]) -> Result<()> {
let status = Command::new("wpctl").args(args).status()?;
if !status.success() {
bail!("wpctl exited with {status}");
}
Ok(())
}
pub fn change_volume(settings: &AudioModule, increase: bool) -> Result<()> {
let change = if increase {
format!("{}%+", settings.step_percent)
} else {
format!("{}%-", settings.step_percent)
};
let mut args = vec!["set-volume"];
if increase {
args.extend(["-l", "1.5"]);
}
args.extend(["@DEFAULT_AUDIO_SINK@", &change]);
wpctl(&args)
}
fn monitored_lines<F>(
program: &str,
args: &[&str],
stop: &AtomicBool,
children: &ChildRegistry,
debounce: Duration,
mut callback: F,
) -> Result<()>
where
F: FnMut() -> Result<()>,
{
let mut child = Command::new(program)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.with_context(|| format!("could not start {program}"))?;
let stdout = child
.stdout
.take()
.context("monitor stdout was not piped")?;
let child = Arc::new(Mutex::new(child));
register_child(children, &child);
if stop.load(Ordering::Acquire)
&& let Ok(mut process) = child.lock()
{
let _ = process.kill();
}
let mut last_update = Instant::now()
.checked_sub(debounce)
.unwrap_or_else(Instant::now);
for line in BufReader::new(stdout).lines() {
if stop.load(Ordering::Acquire) {
break;
}
line.with_context(|| format!("could not read {program} output"))?;
if last_update.elapsed() >= debounce {
if let Err(error) = callback() {
tracing::debug!(%error, program, "monitor refresh failed");
}
last_update = Instant::now();
}
}
let status = wait_child(&child, stop)?;
if !status.success() && !stop.load(Ordering::Acquire) {
bail!("{program} monitor exited with {status}");
}
unregister_child(children, &child);
Ok(())
}
pub(super) fn wait_child(child: &Arc<Mutex<Child>>, stop: &AtomicBool) -> Result<ExitStatus> {
loop {
let status = {
let mut process = child
.lock()
.map_err(|_| anyhow::anyhow!("module child lock was poisoned"))?;
if stop.load(Ordering::Acquire) {
let _ = process.kill();
}
process.try_wait()?
};
if let Some(status) = status {
return Ok(status);
}
thread::park_timeout(Duration::from_millis(10));
}
}
pub(super) fn register_child(children: &ChildRegistry, child: &Arc<Mutex<Child>>) {
if let Ok(mut children) = children.lock() {
children.push(Arc::clone(child));
}
}
pub(super) fn unregister_child(children: &ChildRegistry, child: &Arc<Mutex<Child>>) {
if let Ok(mut children) = children.lock() {
children.retain(|candidate| !Arc::ptr_eq(candidate, child));
}
}
pub(super) fn backoff(failures: u32) -> Duration {
Duration::from_secs(1_u64 << failures.min(6))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_network_status() {
let parsed = parse_network_line("wlan0:wifi:connected:home").unwrap();
assert_eq!(parsed.device, "wlan0");
assert!(parsed.connected);
assert_eq!(parsed.connection, "home");
}
#[test]
fn stopped_child_wait_is_interruptible() {
let child = Command::new("sleep").arg("30").spawn().unwrap();
let child = Arc::new(Mutex::new(child));
let stop = Arc::new(AtomicBool::new(false));
let waiter_child = Arc::clone(&child);
let waiter_stop = Arc::clone(&stop);
let waiter = thread::spawn(move || wait_child(&waiter_child, &waiter_stop).unwrap());
thread::sleep(Duration::from_millis(20));
stop.store(true, Ordering::Release);
waiter.thread().unpark();
let status = waiter.join().unwrap();
assert!(!status.success());
}
}

638
src/modules/sway.rs Normal file
View File

@@ -0,0 +1,638 @@
use std::{
collections::BTreeMap,
env,
io::{self, Read, Write},
os::unix::net::UnixStream,
path::PathBuf,
process::Command,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
thread::{self, JoinHandle},
time::Duration,
};
use anyhow::{Context, Result, anyhow, bail};
use calloop::channel::Sender;
use serde::Deserialize;
use serde_json::Value as JsonValue;
use crate::{
config::{Config, ModeModule, ModuleConfig, ScratchpadModule, WorkspacesModule},
format::{Value, expand},
model::{ModuleEvent, ModuleSnapshot, PopupContent, PopupModel, PopupRow, Segment},
};
const MAGIC: &[u8; 6] = b"i3-ipc";
const GET_WORKSPACES: u32 = 1;
const SUBSCRIBE: u32 = 2;
const RUN_COMMAND: u32 = 0;
const GET_TREE: u32 = 4;
const GET_BINDING_STATE: u32 = 12;
const EVENT_MASK: u32 = 0x8000_0000;
const EVENT_WORKSPACE: u32 = EVENT_MASK;
const EVENT_OUTPUT: u32 = EVENT_MASK | 1;
const EVENT_MODE: u32 = EVENT_MASK | 2;
const EVENT_WINDOW: u32 = EVENT_MASK | 3;
const EVENT_SHUTDOWN: u32 = EVENT_MASK | 6;
#[derive(Debug, Clone, Default)]
pub struct ConfiguredSwayModules {
workspaces: Vec<(String, WorkspacesModule)>,
modes: Vec<(String, ModeModule)>,
scratchpads: Vec<(String, ScratchpadModule)>,
}
impl ConfiguredSwayModules {
pub fn from_config(config: &Config) -> Self {
let mut configured = Self::default();
for (name, module) in &config.modules {
if !module.common().enabled {
continue;
}
match module {
ModuleConfig::Workspaces(settings) => {
configured.workspaces.push((name.clone(), settings.clone()));
}
ModuleConfig::Mode(settings) => {
configured.modes.push((name.clone(), settings.clone()));
}
ModuleConfig::Scratchpad(settings) => {
configured
.scratchpads
.push((name.clone(), settings.clone()));
}
_ => {}
}
}
configured
}
pub fn is_empty(&self) -> bool {
self.workspaces.is_empty() && self.modes.is_empty() && self.scratchpads.is_empty()
}
}
pub fn spawn(
modules: ConfiguredSwayModules,
sender: Sender<ModuleEvent>,
stop: Arc<AtomicBool>,
) -> JoinHandle<()> {
thread::Builder::new()
.name("lightbar-sway-ipc".to_owned())
.spawn(move || {
let mut failures = 0_u32;
let mut last_good = BTreeMap::new();
while !stop.load(Ordering::Acquire) {
match run_subscription(&modules, &sender, &stop, &mut last_good) {
Ok(()) if stop.load(Ordering::Acquire) => break,
Ok(()) => failures = 0,
Err(error) => {
failures = failures.saturating_add(1);
tracing::warn!(%error, "Sway IPC disconnected");
publish_stale(&modules, &last_good, &sender);
}
}
let delay = Duration::from_secs(1_u64 << failures.min(5));
thread::park_timeout(delay);
}
})
.expect("Sway IPC worker thread")
}
fn run_subscription(
modules: &ConfiguredSwayModules,
sender: &Sender<ModuleEvent>,
stop: &AtomicBool,
last_good: &mut BTreeMap<String, ModuleSnapshot>,
) -> Result<()> {
let socket_path = socket_path()?;
// Keep a separate request connection open for the lifetime of the event
// subscription. Besides avoiding connection churn, this lets an existing
// bar keep working if the filesystem name of Sway's listening socket is
// accidentally removed while the compositor is still running.
let mut control = connect(&socket_path, Duration::from_secs(3))?;
let mut events = connect(&socket_path, Duration::from_secs(2))?;
write_message(
&mut events,
SUBSCRIBE,
br#"["workspace","mode","window","output","shutdown"]"#,
)?;
let (_, reply) = read_message(&mut events)?;
let accepted = serde_json::from_slice::<JsonValue>(&reply)?
.get("success")
.and_then(JsonValue::as_bool)
.unwrap_or(false);
if !accepted {
bail!("Sway rejected event subscription");
}
refresh_workspaces(modules, sender, &mut control, last_good)?;
refresh_mode(modules, sender, &mut control, last_good)?;
refresh_scratchpad(modules, sender, &mut control, last_good)?;
while !stop.load(Ordering::Acquire) {
match read_message(&mut events) {
Ok((EVENT_WORKSPACE | EVENT_OUTPUT, _)) => {
refresh_workspaces(modules, sender, &mut control, last_good)?;
}
Ok((EVENT_MODE, payload)) => {
publish_mode_event(modules, sender, &payload, last_good)?;
}
Ok((EVENT_WINDOW, payload)) if window_may_affect_scratchpad(&payload) => {
refresh_scratchpad(modules, sender, &mut control, last_good)?;
}
Ok((EVENT_WINDOW, _)) => {}
Ok((EVENT_SHUTDOWN, _)) => bail!("Sway is shutting down"),
Ok((kind, _)) => tracing::trace!(kind, "ignored Sway IPC event"),
Err(error)
if error.downcast_ref::<io::Error>().is_some_and(|error| {
matches!(
error.kind(),
io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut
)
}) => {}
Err(error) => return Err(error),
}
}
Ok(())
}
fn refresh_workspaces(
modules: &ConfiguredSwayModules,
sender: &Sender<ModuleEvent>,
control: &mut UnixStream,
last_good: &mut BTreeMap<String, ModuleSnapshot>,
) -> Result<()> {
if modules.workspaces.is_empty() {
return Ok(());
}
let payload = request_on(control, GET_WORKSPACES, b"")?;
let mut workspaces: Vec<Workspace> = serde_json::from_slice(&payload)?;
workspaces.sort_by(|left, right| {
left.num
.cmp(&right.num)
.then_with(|| left.name.cmp(&right.name))
});
for (name, settings) in &modules.workspaces {
let segments = workspaces
.iter()
.map(|workspace| {
let index = if workspace.num >= 0 {
workspace.num.to_string()
} else {
workspace.name.clone()
};
let text = expand(
&settings.format,
&std::collections::BTreeMap::from([
("index", Value::from(index)),
("name", Value::from(workspace.name.clone())),
]),
);
Segment {
id: Some(workspace.name.clone()),
text,
state: if workspace.urgent {
"urgent"
} else if workspace.focused {
"focused"
} else if workspace.visible {
"active"
} else {
"normal"
}
.to_owned(),
output: Some(workspace.output.clone()),
icon: None,
}
})
.collect();
publish_snapshot(
name,
ModuleSnapshot {
segments,
visible: true,
..ModuleSnapshot::default()
},
sender,
last_good,
);
}
Ok(())
}
fn refresh_mode(
modules: &ConfiguredSwayModules,
sender: &Sender<ModuleEvent>,
control: &mut UnixStream,
last_good: &mut BTreeMap<String, ModuleSnapshot>,
) -> Result<()> {
if modules.modes.is_empty() {
return Ok(());
}
let payload = request_on(control, GET_BINDING_STATE, b"")?;
let mode: BindingState = serde_json::from_slice(&payload)?;
publish_mode(modules, sender, &mode.name, last_good);
Ok(())
}
fn publish_mode_event(
modules: &ConfiguredSwayModules,
sender: &Sender<ModuleEvent>,
payload: &[u8],
last_good: &mut BTreeMap<String, ModuleSnapshot>,
) -> Result<()> {
let mode: ModeEvent = serde_json::from_slice(payload)?;
publish_mode(modules, sender, &mode.change, last_good);
Ok(())
}
fn publish_mode(
modules: &ConfiguredSwayModules,
sender: &Sender<ModuleEvent>,
mode: &str,
last_good: &mut BTreeMap<String, ModuleSnapshot>,
) {
for (name, settings) in &modules.modes {
let snapshot = if mode == "default" {
ModuleSnapshot::default()
} else {
ModuleSnapshot::text(expand(
&settings.format,
&std::collections::BTreeMap::from([("mode", Value::from(mode))]),
))
.with_state("urgent")
};
publish_snapshot(name, snapshot, sender, last_good);
}
}
fn refresh_scratchpad(
modules: &ConfiguredSwayModules,
sender: &Sender<ModuleEvent>,
control: &mut UnixStream,
last_good: &mut BTreeMap<String, ModuleSnapshot>,
) -> Result<()> {
if modules.scratchpads.is_empty() {
return Ok(());
}
let tree: TreeNode = serde_json::from_slice(&request_on(control, GET_TREE, b"")?)?;
let scratch = find_scratch_node(&tree);
let windows: Vec<&TreeNode> = scratch.map_or_else(Vec::new, |node| {
node.floating_nodes.iter().chain(&node.nodes).collect()
});
for (name, settings) in &modules.scratchpads {
let count = i64::try_from(windows.len()).unwrap_or(i64::MAX);
let text = if windows.is_empty() {
settings.empty_format.clone()
} else {
expand(
&settings.format,
&std::collections::BTreeMap::from([("count", Value::from(count))]),
)
};
let popup_rows = windows
.iter()
.map(|window| PopupRow {
id: Some(window.id.to_string()),
label: window
.name
.clone()
.or_else(|| window.app_id.clone())
.unwrap_or_else(|| "Unnamed scratchpad window".to_owned()),
detail: window.app_id.clone(),
state: "normal".to_owned(),
enabled: true,
})
.collect();
let mut snapshot = ModuleSnapshot::text(text);
snapshot.tooltip = Some(format!("{} scratchpad window(s)", windows.len()));
snapshot.popup = Some(PopupModel {
title: "Scratchpad".to_owned(),
content: PopupContent::Rows(popup_rows),
});
publish_snapshot(name, snapshot, sender, last_good);
}
Ok(())
}
fn find_scratch_node(node: &TreeNode) -> Option<&TreeNode> {
if node.name.as_deref() == Some("__i3_scratch") {
return Some(node);
}
node.nodes
.iter()
.chain(&node.floating_nodes)
.find_map(find_scratch_node)
}
fn window_may_affect_scratchpad(payload: &[u8]) -> bool {
let Ok(event) = serde_json::from_slice::<JsonValue>(payload) else {
// A future Sway payload shape should cost one safe refresh, not leave
// the scratchpad permanently stale.
return true;
};
let scratchpad_state = event
.pointer("/container/scratchpad_state")
.and_then(JsonValue::as_str)
.unwrap_or("none");
scratchpad_state != "none" || event.get("change").and_then(JsonValue::as_str) == Some("move")
}
fn publish_snapshot(
name: &str,
snapshot: ModuleSnapshot,
sender: &Sender<ModuleEvent>,
last_good: &mut BTreeMap<String, ModuleSnapshot>,
) {
last_good.insert(name.to_owned(), snapshot.clone());
let _ = sender.send(ModuleEvent {
module: name.to_owned(),
snapshot,
});
}
fn publish_stale(
modules: &ConfiguredSwayModules,
last_good: &BTreeMap<String, ModuleSnapshot>,
sender: &Sender<ModuleEvent>,
) {
// Keep the last focused/urgent workspace styling intact. The generic
// `stale` rendering state intentionally dims a module, which would make
// the selected workspace appear to vanish even though its segment is
// still present.
for (name, _) in &modules.workspaces {
let Some(snapshot) = last_good.get(name) else {
continue;
};
let _ = sender.send(ModuleEvent {
module: name.clone(),
snapshot: disconnected_workspace_snapshot(snapshot),
});
}
for name in modules
.modes
.iter()
.map(|(name, _)| name)
.chain(modules.scratchpads.iter().map(|(name, _)| name))
{
if let Some(snapshot) = last_good.get(name) {
let _ = sender.send(ModuleEvent {
module: name.clone(),
snapshot: stale_snapshot(snapshot),
});
}
}
}
fn disconnected_workspace_snapshot(snapshot: &ModuleSnapshot) -> ModuleSnapshot {
let mut disconnected = snapshot.clone();
disconnected.tooltip = Some("Sway IPC disconnected; showing last known workspaces".to_owned());
disconnected
}
fn stale_snapshot(snapshot: &ModuleSnapshot) -> ModuleSnapshot {
let mut stale = snapshot.clone();
stale.stale = true;
stale
}
pub fn run_sway_command(command: &str) -> Result<()> {
let payload = request(RUN_COMMAND, command.as_bytes())?;
let replies: Vec<CommandReply> = serde_json::from_slice(&payload)?;
if let Some(failure) = replies.iter().find(|reply| !reply.success) {
bail!(
"Sway command failed: {}",
failure.error.as_deref().unwrap_or("unknown error")
);
}
Ok(())
}
fn request(kind: u32, payload: &[u8]) -> Result<Vec<u8>> {
let path = socket_path()?;
let mut stream = connect(&path, Duration::from_secs(3))?;
request_on(&mut stream, kind, payload)
}
fn connect(path: &std::path::Path, read_timeout: Duration) -> Result<UnixStream> {
let stream = UnixStream::connect(path)
.with_context(|| format!("could not connect to Sway IPC at {}", path.display()))?;
stream.set_read_timeout(Some(read_timeout))?;
stream.set_write_timeout(Some(Duration::from_secs(3)))?;
Ok(stream)
}
fn request_on(stream: &mut (impl Read + Write), kind: u32, payload: &[u8]) -> Result<Vec<u8>> {
write_message(stream, kind, payload)?;
let (reply_kind, reply) = read_message(stream)?;
if reply_kind != kind {
bail!("unexpected Sway IPC reply type {reply_kind}, expected {kind}");
}
Ok(reply)
}
fn socket_path() -> Result<PathBuf> {
if let Some(path) = env::var_os("SWAYSOCK").or_else(|| env::var_os("I3SOCK")) {
return Ok(PathBuf::from(path));
}
let output = Command::new("sway")
.arg("--get-socketpath")
.output()
.context("SWAYSOCK is unset and sway --get-socketpath could not be executed")?;
if !output.status.success() {
bail!("sway --get-socketpath exited with {}", output.status);
}
let path = String::from_utf8(output.stdout)?.trim().to_owned();
if path.is_empty() {
bail!("sway --get-socketpath returned an empty path");
}
Ok(PathBuf::from(path))
}
fn write_message(stream: &mut impl Write, kind: u32, payload: &[u8]) -> Result<()> {
let length = u32::try_from(payload.len()).context("Sway IPC payload exceeds 4 GiB")?;
stream.write_all(MAGIC)?;
stream.write_all(&length.to_ne_bytes())?;
stream.write_all(&kind.to_ne_bytes())?;
stream.write_all(payload)?;
stream.flush()?;
Ok(())
}
fn read_message(stream: &mut impl Read) -> Result<(u32, Vec<u8>)> {
let mut header = [0_u8; 14];
stream.read_exact(&mut header)?;
if &header[..6] != MAGIC {
return Err(anyhow!("invalid Sway IPC magic"));
}
let length = u32::from_ne_bytes(header[6..10].try_into().expect("fixed header length"));
let kind = u32::from_ne_bytes(header[10..14].try_into().expect("fixed header length"));
if length > 64 * 1024 * 1024 {
bail!("Sway IPC payload exceeds 64 MiB safety limit");
}
let mut payload = vec![0_u8; length as usize];
stream.read_exact(&mut payload)?;
Ok((kind, payload))
}
#[derive(Debug, Deserialize)]
struct Workspace {
num: i64,
name: String,
visible: bool,
focused: bool,
urgent: bool,
output: String,
}
#[derive(Debug, Deserialize)]
struct BindingState {
name: String,
}
#[derive(Debug, Deserialize)]
struct ModeEvent {
change: String,
}
#[derive(Debug, Deserialize)]
struct TreeNode {
#[serde(default)]
id: i64,
#[serde(default)]
name: Option<String>,
#[serde(default)]
app_id: Option<String>,
#[serde(default)]
nodes: Vec<TreeNode>,
#[serde(default)]
floating_nodes: Vec<TreeNode>,
}
#[derive(Debug, Deserialize)]
struct CommandReply {
success: bool,
#[serde(default)]
error: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
struct MemoryDuplex {
replies: std::io::Cursor<Vec<u8>>,
requests: Vec<u8>,
}
impl Read for MemoryDuplex {
fn read(&mut self, buffer: &mut [u8]) -> io::Result<usize> {
self.replies.read(buffer)
}
}
impl Write for MemoryDuplex {
fn write(&mut self, buffer: &[u8]) -> io::Result<usize> {
self.requests.extend_from_slice(buffer);
Ok(buffer.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
#[test]
fn finds_nested_scratch_node() {
let root: TreeNode = serde_json::from_str(
r#"{"name":"root","nodes":[{"name":"__i3","nodes":[{"name":"__i3_scratch","floating_nodes":[{"name":"term"}]}]}]}"#,
)
.unwrap();
let scratch = find_scratch_node(&root).unwrap();
assert_eq!(scratch.floating_nodes[0].name.as_deref(), Some("term"));
}
#[test]
fn round_trips_an_ipc_frame() {
let mut frame = Vec::new();
write_message(&mut frame, GET_WORKSPACES, br#"[{"name":"1"}]"#).unwrap();
let (kind, payload) = read_message(&mut std::io::Cursor::new(frame)).unwrap();
assert_eq!(kind, GET_WORKSPACES);
assert_eq!(payload, br#"[{"name":"1"}]"#);
}
#[test]
fn reuses_a_control_connection_for_multiple_requests() {
let mut replies = Vec::new();
write_message(&mut replies, GET_WORKSPACES, br#"{"workspaces":true}"#).unwrap();
write_message(
&mut replies,
GET_BINDING_STATE,
br#"{"binding_state":true}"#,
)
.unwrap();
let mut connection = MemoryDuplex {
replies: std::io::Cursor::new(replies),
requests: Vec::new(),
};
assert_eq!(
request_on(&mut connection, GET_WORKSPACES, b"").unwrap(),
br#"{"workspaces":true}"#
);
assert_eq!(
request_on(&mut connection, GET_BINDING_STATE, b"").unwrap(),
br#"{"binding_state":true}"#
);
let mut requests = std::io::Cursor::new(connection.requests);
for expected_kind in [GET_WORKSPACES, GET_BINDING_STATE] {
let (kind, payload) = read_message(&mut requests).unwrap();
assert_eq!(kind, expected_kind);
assert!(payload.is_empty());
}
}
#[test]
fn disconnected_workspace_snapshot_preserves_focused_style() {
let snapshot = ModuleSnapshot {
segments: vec![Segment {
id: Some("2:web".to_owned()),
text: "2".to_owned(),
state: "focused".to_owned(),
output: Some("DP-1".to_owned()),
icon: None,
}],
visible: true,
..ModuleSnapshot::default()
};
let disconnected = disconnected_workspace_snapshot(&snapshot);
assert!(!disconnected.stale);
assert!(disconnected.visible);
assert_eq!(disconnected.segments, snapshot.segments);
assert!(
disconnected
.tooltip
.as_deref()
.is_some_and(|tooltip| tooltip.contains("last known"))
);
}
#[test]
fn ignores_unrelated_window_title_churn() {
assert!(!window_may_affect_scratchpad(
br#"{"change":"title","container":{"scratchpad_state":"none"}}"#
));
assert!(window_may_affect_scratchpad(
br#"{"change":"title","container":{"scratchpad_state":"changed"}}"#
));
assert!(window_may_affect_scratchpad(
br#"{"change":"move","container":{"scratchpad_state":"none"}}"#
));
}
}

427
src/modules/sysfs.rs Normal file
View File

@@ -0,0 +1,427 @@
use std::{
collections::BTreeMap,
fs,
path::{Path, PathBuf},
process::Command,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
thread::{self, JoinHandle},
time::Duration,
};
use anyhow::{Context, Result, bail};
use calloop::channel::Sender;
use crate::{
config::{BacklightModule, BatteryModule, TemperatureModule},
format::{Value, expand},
model::{ModuleEvent, ModuleSnapshot, PopupContent, PopupModel, PopupRow, SliderModel},
};
pub fn spawn_temperature(
name: String,
settings: TemperatureModule,
sender: Sender<ModuleEvent>,
stop: Arc<AtomicBool>,
) -> JoinHandle<()> {
spawn_polling(name, settings.interval.0, sender, stop, move || {
temperature_snapshot(&settings)
})
}
pub fn spawn_backlight(
name: String,
settings: BacklightModule,
sender: Sender<ModuleEvent>,
stop: Arc<AtomicBool>,
) -> JoinHandle<()> {
spawn_polling(name, Duration::from_secs(5), sender, stop, move || {
backlight_snapshot(&settings)
})
}
pub fn spawn_battery(
name: String,
settings: BatteryModule,
sender: Sender<ModuleEvent>,
stop: Arc<AtomicBool>,
) -> JoinHandle<()> {
spawn_polling(name, Duration::from_secs(30), sender, stop, move || {
battery_snapshot(&settings)
})
}
fn spawn_polling<F>(
name: String,
interval: Duration,
sender: Sender<ModuleEvent>,
stop: Arc<AtomicBool>,
mut read: F,
) -> JoinHandle<()>
where
F: FnMut() -> Result<ModuleSnapshot> + Send + 'static,
{
thread::Builder::new()
.name(format!("lightbar-sysfs-{name}"))
.spawn(move || {
let mut last_good: Option<ModuleSnapshot> = None;
let mut failures = 0_u32;
while !stop.load(Ordering::Acquire) {
let snapshot = match read() {
Ok(snapshot) => {
failures = 0;
last_good = Some(snapshot.clone());
snapshot
}
Err(error) => {
failures = failures.saturating_add(1);
tracing::debug!(module = %name, %error, "sysfs module update failed");
let mut stale = last_good.clone().unwrap_or_default();
stale.stale = last_good.is_some();
stale
}
};
if sender
.send(ModuleEvent {
module: name.clone(),
snapshot,
})
.is_err()
{
break;
}
let retry = if failures == 0 {
interval
} else {
super::process::backoff(failures).min(interval)
};
thread::park_timeout(retry);
}
})
.expect("sysfs worker thread")
}
fn temperature_snapshot(settings: &TemperatureModule) -> Result<ModuleSnapshot> {
let sensor = settings
.sensor
.clone()
.map_or_else(|| discover_temperature(settings.label.as_deref()), Ok)?;
let raw = read_number(&sensor)?;
let temperature = if raw.abs() > 1_000.0 {
raw / 1_000.0
} else {
raw
};
let text = expand(
&settings.format,
&BTreeMap::from([("temperature_c", Value::from(temperature))]),
);
let state = if temperature >= settings.critical_c {
"critical"
} else if temperature >= settings.warning_c {
"warning"
} else {
"normal"
};
let mut snapshot = ModuleSnapshot::text(text).with_state(state);
snapshot.tooltip = Some(format!("{}: {temperature:.1}°C", sensor.display()));
snapshot.popup = Some(PopupModel {
title: "Temperature".to_owned(),
content: PopupContent::Rows(vec![PopupRow {
id: None,
label: sensor.display().to_string(),
detail: Some(format!("{temperature:.1}°C")),
state: state.to_owned(),
enabled: false,
}]),
});
Ok(snapshot)
}
fn discover_temperature(label: Option<&str>) -> Result<PathBuf> {
let mut candidates = Vec::new();
for hwmon in read_dirs(Path::new("/sys/class/hwmon"))? {
let chip = read_trimmed(&hwmon.join("name")).unwrap_or_default();
for input in read_dirs(&hwmon)?.into_iter().filter(|path| {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with("temp") && name.ends_with("_input"))
}) {
let base = input
.file_name()
.and_then(|name| name.to_str())
.and_then(|name| name.strip_suffix("_input"))
.unwrap_or_default();
let sensor_label =
read_trimmed(&hwmon.join(format!("{base}_label"))).unwrap_or_default();
if label.is_some_and(|wanted| {
!sensor_label.eq_ignore_ascii_case(wanted) && !chip.eq_ignore_ascii_case(wanted)
}) {
continue;
}
let score = temperature_score(&chip, &sensor_label);
candidates.push((score, input));
}
}
candidates
.into_iter()
.max_by_key(|(score, _)| *score)
.map(|(_, path)| path)
.context("no hwmon temperature sensor found")
}
fn temperature_score(chip: &str, label: &str) -> u8 {
let combined = format!("{chip} {label}").to_ascii_lowercase();
if combined.contains("package") || combined.contains("tctl") {
3
} else if combined.contains("coretemp") || combined.contains("k10temp") {
2
} else {
1
}
}
fn backlight_snapshot(settings: &BacklightModule) -> Result<ModuleSnapshot> {
let device = discover_backlight(settings.device.as_deref())?;
let current = read_number(&device.join("actual_brightness"))
.or_else(|_| read_number(&device.join("brightness")))?;
let maximum = read_number(&device.join("max_brightness"))?;
if maximum <= 0.0 {
bail!("backlight maximum is zero");
}
let percent = (current / maximum * 100.0).round().clamp(0.0, 100.0) as i64;
let text = expand(
&settings.format,
&BTreeMap::from([("percent", Value::from(percent))]),
);
let mut snapshot = ModuleSnapshot::text(text);
snapshot.tooltip = Some(format!("Backlight: {percent}%"));
snapshot.popup = Some(PopupModel {
title: "Backlight".to_owned(),
content: PopupContent::Slider(SliderModel {
label: device
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("Backlight")
.to_owned(),
value: f64::from(percent as i32),
minimum: 0.0,
maximum: 100.0,
muted: false,
detail_rows: Vec::new(),
}),
});
Ok(snapshot)
}
pub fn adjust_backlight(settings: &BacklightModule, delta_percent: i16) -> Result<()> {
let device = discover_backlight(settings.device.as_deref())?;
let current = read_number(&device.join("brightness"))?;
let maximum = read_number(&device.join("max_brightness"))?;
let delta = maximum * f64::from(delta_percent) / 100.0;
let value = (current + delta).round().clamp(1.0, maximum) as u64;
set_backlight_value(&device, value)
}
pub fn set_backlight_percent(settings: &BacklightModule, percent: f64) -> Result<()> {
let device = discover_backlight(settings.device.as_deref())?;
let maximum = read_number(&device.join("max_brightness"))?;
let value = (maximum * percent.clamp(0.0, 100.0) / 100.0)
.round()
.clamp(1.0, maximum) as u64;
set_backlight_value(&device, value)
}
fn set_backlight_value(device: &Path, value: u64) -> Result<()> {
let brightness = device.join("brightness");
if fs::write(&brightness, value.to_string()).is_ok() {
return Ok(());
}
let device_name = device
.file_name()
.and_then(|name| name.to_str())
.context("backlight device has no UTF-8 name")?;
let status = Command::new("busctl")
.args([
"--system",
"call",
"org.freedesktop.login1",
"/org/freedesktop/login1/session/self",
"org.freedesktop.login1.Session",
"SetBrightness",
"ssu",
"backlight",
device_name,
&value.to_string(),
])
.status()
.context("could not invoke logind SetBrightness")?;
if !status.success() {
bail!("logind SetBrightness exited with {status}");
}
Ok(())
}
fn discover_backlight(device: Option<&str>) -> Result<PathBuf> {
let root = Path::new("/sys/class/backlight");
if let Some(device) = device {
let path = root.join(device);
if path.is_dir() {
return Ok(path);
}
bail!(
"configured backlight device {} does not exist",
path.display()
);
}
read_dirs(root)?
.into_iter()
.max_by(|left, right| {
read_number(&left.join("max_brightness"))
.unwrap_or_default()
.total_cmp(&read_number(&right.join("max_brightness")).unwrap_or_default())
})
.context("no backlight device found")
}
fn battery_snapshot(settings: &BatteryModule) -> Result<ModuleSnapshot> {
let battery = discover_battery()?;
let percent = read_number(&battery.join("capacity"))?
.round()
.clamp(0.0, 100.0) as u8;
let status = read_trimmed(&battery.join("status")).unwrap_or_else(|_| "Unknown".to_owned());
let icon = battery_icon(percent);
let values = BTreeMap::from([
("percent", Value::from(percent)),
("status", Value::from(status.clone())),
("icon", Value::from(icon)),
]);
let format = if percent >= 99 {
&settings.full_format
} else {
&settings.format
};
let state = if percent <= settings.critical_percent {
"critical"
} else if percent <= settings.warning_percent {
"warning"
} else {
"normal"
};
let mut snapshot = ModuleSnapshot::text(expand(format, &values)).with_state(state);
let time = estimated_battery_time(&battery, &status);
snapshot.tooltip = Some(match time.as_deref() {
Some(time) => format!("{status}: {percent}% · {time}"),
None => format!("{status}: {percent}%"),
});
snapshot.popup = Some(PopupModel {
title: "Battery".to_owned(),
content: PopupContent::Rows(vec![
PopupRow {
id: None,
label: "Charge".to_owned(),
detail: Some(format!("{percent}%")),
state: state.to_owned(),
enabled: false,
},
PopupRow {
id: None,
label: "Status".to_owned(),
detail: Some(status),
state: "normal".to_owned(),
enabled: false,
},
PopupRow {
id: None,
label: "Estimate".to_owned(),
detail: time,
state: "normal".to_owned(),
enabled: false,
},
]),
});
Ok(snapshot)
}
fn discover_battery() -> Result<PathBuf> {
read_dirs(Path::new("/sys/class/power_supply"))?
.into_iter()
.find(|path| {
read_trimmed(&path.join("type")).is_ok_and(|kind| kind.eq_ignore_ascii_case("battery"))
})
.context("no battery power supply found")
}
fn battery_icon(percent: u8) -> &'static str {
match percent {
0..=10 => "",
11..=35 => "",
36..=60 => "",
61..=85 => "",
_ => "",
}
}
fn estimated_battery_time(battery: &Path, status: &str) -> Option<String> {
let power = read_number(&battery.join("power_now")).ok()?;
if power <= 0.0 {
return None;
}
let energy = if status.eq_ignore_ascii_case("charging") {
read_number(&battery.join("energy_full")).ok()?
- read_number(&battery.join("energy_now")).ok()?
} else {
read_number(&battery.join("energy_now")).ok()?
};
let hours = (energy / power).max(0.0);
let total_minutes = (hours * 60.0).round() as u64;
Some(format!(
"{}h {:02}m",
total_minutes / 60,
total_minutes % 60
))
}
fn read_dirs(path: &Path) -> Result<Vec<PathBuf>> {
let mut paths = Vec::new();
for entry in fs::read_dir(path).with_context(|| format!("cannot read {}", path.display()))? {
let entry = entry?;
paths.push(entry.path());
}
Ok(paths)
}
fn read_trimmed(path: &Path) -> Result<String> {
Ok(fs::read_to_string(path)
.with_context(|| format!("cannot read {}", path.display()))?
.trim()
.to_owned())
}
fn read_number(path: &Path) -> Result<f64> {
read_trimmed(path)?
.parse()
.with_context(|| format!("{} does not contain a number", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn chooses_expected_battery_icons() {
assert_eq!(battery_icon(5), "");
assert_eq!(battery_icon(50), "");
assert_eq!(battery_icon(100), "");
}
#[test]
fn scores_cpu_package_sensors_first() {
assert!(
temperature_score("coretemp", "Package id 0") > temperature_score("acpitz", "temp1")
);
}
}

751
src/modules/tray.rs Normal file
View File

@@ -0,0 +1,751 @@
use std::{
collections::{BTreeMap, BTreeSet},
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
},
thread::{self, JoinHandle},
time::Duration,
};
use anyhow::{Context, Result, anyhow};
use calloop::channel::Sender;
use futures_lite::StreamExt;
use tokio::sync::mpsc;
use zbus::{
Connection, MatchRule, MessageStream, Proxy,
fdo::{DBusProxy, NameOwnerChanged},
message::{Header, Type},
names::BusName,
object_server::SignalEmitter,
};
use crate::{
config::TrayModule,
model::{IconPixmap, ModuleEvent, ModuleSnapshot, PopupContent, PopupModel, PopupRow, Segment},
};
const WATCHER_SERVICE: &str = "org.kde.StatusNotifierWatcher";
const WATCHER_PATH: &str = "/StatusNotifierWatcher";
const WATCHER_INTERFACE: &str = "org.kde.StatusNotifierWatcher";
const ITEM_INTERFACE: &str = "org.kde.StatusNotifierItem";
#[derive(Debug, Clone, Eq, Ord, PartialEq, PartialOrd)]
struct TrayAddress {
service: String,
path: String,
}
impl TrayAddress {
fn parse(registered: &str) -> Option<Self> {
if registered.starts_with('/') {
return None;
}
let (service, path) = registered.find('/').map_or_else(
|| (registered, "/StatusNotifierItem"),
|index| (&registered[..index], &registered[index..]),
);
(!service.is_empty() && path.starts_with('/')).then(|| Self {
service: service.to_owned(),
path: path.to_owned(),
})
}
fn id(&self) -> String {
format!("{}|{}", self.service, self.path)
}
fn from_id(id: &str) -> Option<Self> {
let (service, path) = id.split_once('|')?;
(!service.is_empty() && path.starts_with('/')).then(|| Self {
service: service.to_owned(),
path: path.to_owned(),
})
}
fn registered_name(&self) -> String {
if self.path == "/StatusNotifierItem" {
self.service.clone()
} else {
format!("{}{}", self.service, self.path)
}
}
}
#[derive(Debug, Copy, Clone)]
enum RegistryEvent {
Changed,
}
#[derive(Debug, Clone, Eq, PartialEq)]
struct ItemRegistration {
owner: String,
service: String,
}
type ItemRegistry = Arc<Mutex<BTreeMap<String, ItemRegistration>>>;
#[derive(Debug)]
struct Watcher {
items: ItemRegistry,
hosts: Arc<Mutex<BTreeSet<String>>>,
changed: mpsc::Sender<RegistryEvent>,
}
#[zbus::interface(name = "org.kde.StatusNotifierWatcher")]
impl Watcher {
async fn register_status_notifier_item(
&self,
service_or_path: String,
#[zbus(header)] header: Header<'_>,
#[zbus(signal_emitter)] emitter: SignalEmitter<'_>,
) -> zbus::fdo::Result<()> {
let sender = header
.sender()
.map(ToString::to_string)
.ok_or_else(|| zbus::fdo::Error::Failed("registration has no sender".to_owned()))?;
let owner = sender.clone();
let address = if service_or_path.starts_with('/') {
TrayAddress {
service: sender,
path: service_or_path,
}
} else {
TrayAddress {
service: service_or_path,
path: "/StatusNotifierItem".to_owned(),
}
};
let registered = address.registered_name();
let registration = ItemRegistration {
owner,
service: address.service,
};
let previous = self
.items
.lock()
.map_err(|_| zbus::fdo::Error::Failed("watcher lock poisoned".to_owned()))?
.insert(registered.clone(), registration.clone());
let inserted = previous.is_none();
let changed = previous.as_ref() != Some(&registration);
if inserted {
Self::status_notifier_item_registered(&emitter, &registered).await?;
}
if changed {
let _ = self.changed.try_send(RegistryEvent::Changed);
}
Ok(())
}
async fn register_status_notifier_host(
&self,
service: String,
#[zbus(signal_emitter)] emitter: SignalEmitter<'_>,
) -> zbus::fdo::Result<()> {
let inserted = self
.hosts
.lock()
.map_err(|_| zbus::fdo::Error::Failed("watcher lock poisoned".to_owned()))?
.insert(service.clone());
if inserted {
Self::status_notifier_host_registered(&emitter).await?;
let _ = self.changed.try_send(RegistryEvent::Changed);
}
Ok(())
}
#[zbus(property, name = "RegisteredStatusNotifierItems")]
fn registered_status_notifier_items(&self) -> Vec<String> {
self.items
.lock()
.map_or_else(|_| Vec::new(), |items| items.keys().cloned().collect())
}
#[zbus(property, name = "IsStatusNotifierHostRegistered")]
fn is_status_notifier_host_registered(&self) -> bool {
self.hosts.lock().is_ok_and(|hosts| !hosts.is_empty())
}
#[zbus(property, name = "ProtocolVersion")]
fn protocol_version(&self) -> i32 {
0
}
#[zbus(signal, name = "StatusNotifierItemRegistered")]
async fn status_notifier_item_registered(
emitter: &SignalEmitter<'_>,
service: &str,
) -> zbus::Result<()>;
#[zbus(signal, name = "StatusNotifierItemUnregistered")]
async fn status_notifier_item_unregistered(
emitter: &SignalEmitter<'_>,
service: &str,
) -> zbus::Result<()>;
#[zbus(signal, name = "StatusNotifierHostRegistered")]
async fn status_notifier_host_registered(emitter: &SignalEmitter<'_>) -> zbus::Result<()>;
}
pub fn spawn(
module: String,
settings: TrayModule,
sender: Sender<ModuleEvent>,
stop: Arc<AtomicBool>,
) -> JoinHandle<()> {
thread::Builder::new()
.name(format!("lightbar-tray-{module}"))
.spawn(move || {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build();
let result = runtime
.context("could not create tray runtime")
.and_then(|runtime| runtime.block_on(run(module.clone(), settings, sender, stop)));
if let Err(error) = result {
tracing::warn!(module, error = %format_args!("{error:#}"), "tray stopped");
}
})
.expect("tray worker thread")
}
async fn run(
module: String,
settings: TrayModule,
sender: Sender<ModuleEvent>,
stop: Arc<AtomicBool>,
) -> Result<()> {
let connection = Connection::session()
.await
.context("could not connect to the session D-Bus")?;
let (registry_sender, mut registry_receiver) = mpsc::channel(32);
let owned_items = ensure_watcher(&connection, registry_sender).await?;
let host_name = format!("org.kde.StatusNotifierHost-{}-1", std::process::id());
connection
.request_name(host_name.as_str())
.await
.context("could not own the StatusNotifierHost bus name")?;
let watcher = watcher_proxy(&connection).await?;
watcher
.call::<_, _, ()>("RegisterStatusNotifierHost", &(host_name.clone()))
.await
.context("watcher rejected StatusNotifierHost registration")?;
let watcher_rule = MatchRule::builder()
.msg_type(Type::Signal)
.interface(WATCHER_INTERFACE)?
.build();
let item_rule = MatchRule::builder()
.msg_type(Type::Signal)
.interface(ITEM_INTERFACE)?
.build();
let properties_rule = MatchRule::builder()
.msg_type(Type::Signal)
.interface("org.freedesktop.DBus.Properties")?
.member("PropertiesChanged")?
.add_arg(ITEM_INTERFACE)?
.build();
let owner_rule = MatchRule::builder()
.msg_type(Type::Signal)
.sender("org.freedesktop.DBus")?
.interface("org.freedesktop.DBus")?
.member("NameOwnerChanged")?
.build();
let mut watcher_signals = MessageStream::for_match_rule(watcher_rule, &connection, Some(64))
.await
.context("could not subscribe to tray watcher changes")?;
let mut item_signals = MessageStream::for_match_rule(item_rule, &connection, Some(128))
.await
.context("could not subscribe to tray item changes")?;
let mut property_signals =
MessageStream::for_match_rule(properties_rule, &connection, Some(128))
.await
.context("could not subscribe to tray property changes")?;
let mut owner_signals = MessageStream::for_match_rule(owner_rule, &connection, Some(128))
.await
.context("could not subscribe to D-Bus owner changes")?;
let mut reconcile = tokio::time::interval(Duration::from_secs(30));
let mut stop_check = tokio::time::interval(Duration::from_millis(250));
publish(&connection, &module, &settings, &sender).await;
loop {
tokio::select! {
_ = stop_check.tick() => {
if stop.load(Ordering::Acquire) {
break;
}
continue;
}
_ = reconcile.tick() => {}
event = registry_receiver.recv() => {
if event.is_none() {
break;
}
}
event = watcher_signals.next() => {
if event.is_none() {
break;
}
}
event = item_signals.next() => {
if event.is_none() {
break;
}
}
event = property_signals.next() => {
if event.is_none() {
break;
}
}
event = owner_signals.next() => {
let Some(event) = event else {
break;
};
let Some(vanished) = event.ok().and_then(vanished_bus_name) else {
continue;
};
let Some(items) = &owned_items else {
continue;
};
match unregister_vanished_items(&connection, items, &vanished).await {
Ok(0) => continue,
Ok(count) => {
tracing::debug!(%vanished, count, "removed vanished tray items");
}
Err(error) => {
tracing::warn!(%vanished, %error, "could not unregister vanished tray items");
continue;
}
}
}
}
// Collapse icon/property signal bursts into a single bounded refresh.
tokio::time::sleep(Duration::from_millis(30)).await;
publish(&connection, &module, &settings, &sender).await;
}
Ok(())
}
async fn ensure_watcher(
connection: &Connection,
changed: mpsc::Sender<RegistryEvent>,
) -> Result<Option<ItemRegistry>> {
if watcher_proxy(connection)
.await?
.get_property::<i32>("ProtocolVersion")
.await
.is_ok()
{
tracing::debug!("using existing StatusNotifierWatcher");
return Ok(None);
}
let items = Arc::new(Mutex::new(BTreeMap::new()));
let watcher = Watcher {
items: items.clone(),
hosts: Arc::new(Mutex::new(BTreeSet::new())),
changed,
};
connection
.object_server()
.at(WATCHER_PATH, watcher)
.await
.context("could not export StatusNotifierWatcher")?;
connection
.request_name(WATCHER_SERVICE)
.await
.context("could not own StatusNotifierWatcher")?;
tracing::info!("providing StatusNotifierWatcher");
Ok(Some(items))
}
fn vanished_bus_name(message: zbus::Message) -> Option<String> {
let signal = NameOwnerChanged::from_message(message)?;
let args = signal.args().ok()?;
args.new_owner()
.as_ref()
.is_none()
.then(|| args.name().as_str().to_owned())
}
async fn unregister_vanished_items(
connection: &Connection,
items: &ItemRegistry,
vanished: &str,
) -> Result<usize> {
let removed = {
let mut items = items
.lock()
.map_err(|_| anyhow!("watcher item registry lock poisoned"))?;
remove_registrations_for_name(&mut items, vanished)
};
if removed.is_empty() {
return Ok(0);
}
let emitter = SignalEmitter::new(connection, WATCHER_PATH)?;
for registered in &removed {
Watcher::status_notifier_item_unregistered(&emitter, registered).await?;
}
Ok(removed.len())
}
fn remove_registrations_for_name(
items: &mut BTreeMap<String, ItemRegistration>,
vanished: &str,
) -> Vec<String> {
let mut removed = Vec::new();
items.retain(|registered, registration| {
let keep = registration.owner != vanished && registration.service != vanished;
if !keep {
removed.push(registered.clone());
}
keep
});
removed
}
async fn watcher_proxy(connection: &Connection) -> Result<Proxy<'_>> {
Proxy::new(connection, WATCHER_SERVICE, WATCHER_PATH, WATCHER_INTERFACE)
.await
.map_err(Into::into)
}
async fn publish(
connection: &Connection,
module: &str,
settings: &TrayModule,
sender: &Sender<ModuleEvent>,
) {
match tokio::time::timeout(
Duration::from_secs(2),
collect_snapshot(connection, settings),
)
.await
{
Ok(Ok(snapshot)) => {
let _ = sender.send(ModuleEvent {
module: module.to_owned(),
snapshot,
});
}
Ok(Err(error)) => tracing::warn!(%error, "could not refresh tray"),
Err(_) => tracing::warn!("tray refresh exceeded two seconds"),
}
}
async fn collect_snapshot(
connection: &Connection,
settings: &TrayModule,
) -> Result<ModuleSnapshot> {
let registered = watcher_proxy(connection)
.await?
.get_property::<Vec<String>>("RegisteredStatusNotifierItems")
.await
.context("could not read registered tray items")?;
let mut addresses: BTreeSet<_> = registered
.iter()
.filter_map(|item| TrayAddress::parse(item))
.collect();
let dbus = DBusProxy::new(connection).await?;
let mut segments = Vec::new();
let mut rows = Vec::new();
for address in &addresses {
let bus_name = match BusName::try_from(address.service.as_str()) {
Ok(name) => name,
Err(error) => {
tracing::debug!(service = %address.service, %error, "ignoring invalid tray service name");
continue;
}
};
match dbus.name_has_owner(bus_name).await {
Ok(false) => {
tracing::debug!(service = %address.service, "ignoring vanished tray item");
continue;
}
Ok(true) => {}
Err(error) => {
tracing::debug!(service = %address.service, %error, "could not verify tray item owner");
}
}
match read_item(connection, address, settings).await {
Ok(Some((segment, row))) => {
segments.push(segment);
rows.push(row);
}
Ok(None) => {}
Err(error) => {
tracing::debug!(service = %address.service, %error, "ignoring broken tray item");
}
}
}
// Deduplicate watcher bugs and ensure the set cannot grow while iterating.
addresses.clear();
Ok(ModuleSnapshot {
visible: !segments.is_empty(),
segments,
tooltip: (!rows.is_empty()).then(|| {
rows.iter()
.map(|row| row.label.as_str())
.collect::<Vec<_>>()
.join("\n")
}),
popup: (!rows.is_empty()).then(|| PopupModel {
title: "System tray".to_owned(),
content: PopupContent::Rows(rows),
}),
stale: false,
})
}
async fn read_item(
connection: &Connection,
address: &TrayAddress,
settings: &TrayModule,
) -> Result<Option<(Segment, PopupRow)>> {
let proxy = Proxy::new(
connection,
address.service.as_str(),
address.path.as_str(),
ITEM_INTERFACE,
)
.await?;
let status = proxy
.get_property::<String>("Status")
.await
.context("tray item has no readable Status property")?;
if status == "Passive" {
return Ok(None);
}
let title = proxy
.get_property::<String>("Title")
.await
.unwrap_or_else(|_| address.service.clone());
let icon_name = proxy
.get_property::<String>("IconName")
.await
.unwrap_or_default();
let pixmaps = proxy
.get_property::<Vec<(i32, i32, Vec<u8>)>>("IconPixmap")
.await
.unwrap_or_default();
let icon = choose_pixmap(pixmaps, settings)?;
let mut segment = Segment::new(if icon.is_some() {
String::new()
} else if icon_name.is_empty() {
"●".to_owned()
} else {
// Keep a deterministic, font-renderable fallback when an item only
// exports a theme icon name. Pixmap-exporting items use the real icon.
"●".to_owned()
});
segment.id = Some(address.id());
segment.state = if status == "NeedsAttention" {
"urgent".to_owned()
} else {
"normal".to_owned()
};
segment.icon = icon;
let row = PopupRow {
id: Some(address.id()),
label: title,
detail: (!icon_name.is_empty()).then_some(icon_name),
state: segment.state.clone(),
enabled: true,
};
Ok(Some((segment, row)))
}
fn choose_pixmap(
pixmaps: Vec<(i32, i32, Vec<u8>)>,
settings: &TrayModule,
) -> Result<Option<IconPixmap>> {
let target = i64::from(settings.icon_size);
let selected = pixmaps
.into_iter()
.filter(|(width, height, bytes)| {
if *width <= 0 || *height <= 0 || *width > 512 || *height > 512 {
return false;
}
let expected = i64::from(*width)
.checked_mul(i64::from(*height))
.and_then(|pixels| pixels.checked_mul(4));
expected.is_some_and(|expected| {
usize::try_from(expected).ok() == Some(bytes.len())
&& bytes.len() <= settings.pixmap_max_bytes
})
})
.min_by_key(|(width, height, _)| {
(i64::from(*width) - target).abs() + (i64::from(*height) - target).abs()
});
let Some((width, height, argb)) = selected else {
return Ok(None);
};
let mut pixels = Vec::with_capacity(argb.len());
for pixel in argb.chunks_exact(4) {
let alpha = pixel[0];
let premultiply = |channel: u8| {
u8::try_from((u16::from(channel) * u16::from(alpha) + 127) / 255).unwrap_or(u8::MAX)
};
pixels.extend_from_slice(&[
premultiply(pixel[3]),
premultiply(pixel[2]),
premultiply(pixel[1]),
alpha,
]);
}
Ok(Some(IconPixmap {
width: u32::try_from(width).map_err(|_| anyhow!("negative tray pixmap width"))?,
height: u32::try_from(height).map_err(|_| anyhow!("negative tray pixmap height"))?,
pixels,
}))
}
pub fn activate_tray_item(id: &str, method: &'static str, x: i32, y: i32) -> Result<()> {
let address = TrayAddress::from_id(id).context("invalid tray item identity")?;
thread::Builder::new()
.name("lightbar-tray-action".to_owned())
.spawn(move || {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build();
let result: Result<()> = match runtime {
Ok(runtime) => runtime
.block_on(async move {
let connection = Connection::session().await?;
let proxy =
Proxy::new(&connection, address.service, address.path, ITEM_INTERFACE)
.await?;
tokio::time::timeout(
Duration::from_secs(2),
proxy.call::<_, _, ()>(method, &(x, y)),
)
.await
.map_err(|_| zbus::Error::Failure("tray action timed out".to_owned()))??;
Ok::<_, zbus::Error>(())
})
.map_err(Into::into),
Err(error) => Err(error.into()),
};
if let Err(error) = result {
tracing::warn!(%error, method, "tray action failed");
}
})
.context("could not start tray action")?;
Ok(())
}
pub fn scroll_tray_item(id: &str, delta: i32, orientation: &'static str) -> Result<()> {
let address = TrayAddress::from_id(id).context("invalid tray item identity")?;
thread::Builder::new()
.name("lightbar-tray-scroll".to_owned())
.spawn(move || {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build();
let result: Result<()> = match runtime {
Ok(runtime) => runtime
.block_on(async move {
let connection = Connection::session().await?;
let proxy =
Proxy::new(&connection, address.service, address.path, ITEM_INTERFACE)
.await?;
tokio::time::timeout(
Duration::from_secs(2),
proxy.call::<_, _, ()>("Scroll", &(delta, orientation)),
)
.await
.map_err(|_| zbus::Error::Failure("tray scroll timed out".to_owned()))??;
Ok::<_, zbus::Error>(())
})
.map_err(Into::into),
Err(error) => Err(error.into()),
};
if let Err(error) = result {
tracing::warn!(%error, "tray scroll failed");
}
})
.context("could not start tray scroll action")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_registered_item_forms() {
assert_eq!(
TrayAddress::parse("org.example.Item").unwrap().path,
"/StatusNotifierItem"
);
let item = TrayAddress::parse(":1.42/Custom/Item").unwrap();
assert_eq!(item.service, ":1.42");
assert_eq!(TrayAddress::from_id(&item.id()), Some(item));
}
#[test]
fn rejects_oversized_and_converts_argb() {
let settings = TrayModule::default();
let icon = choose_pixmap(vec![(1, 1, vec![128, 255, 0, 0])], &settings)
.unwrap()
.unwrap();
assert_eq!(icon.pixels, vec![0, 0, 128, 128]);
assert!(
choose_pixmap(vec![(1024, 1024, vec![0; 4])], &settings)
.unwrap()
.is_none()
);
}
#[test]
fn removes_all_registrations_owned_by_a_vanished_bus_connection() {
let mut items = BTreeMap::from([
(
":1.10".to_owned(),
ItemRegistration {
owner: ":1.10".to_owned(),
service: ":1.10".to_owned(),
},
),
(
"org.example.First".to_owned(),
ItemRegistration {
owner: ":1.10".to_owned(),
service: "org.example.First".to_owned(),
},
),
(
"org.example.Other".to_owned(),
ItemRegistration {
owner: ":1.11".to_owned(),
service: "org.example.Other".to_owned(),
},
),
]);
let removed = remove_registrations_for_name(&mut items, ":1.10");
assert_eq!(removed.len(), 2);
assert!(removed.contains(&":1.10".to_owned()));
assert!(removed.contains(&"org.example.First".to_owned()));
assert_eq!(items.len(), 1);
assert!(items.contains_key("org.example.Other"));
}
#[test]
fn removes_registration_when_its_well_known_name_is_released() {
let mut items = BTreeMap::from([(
"org.example.Tray".to_owned(),
ItemRegistration {
owner: ":1.20".to_owned(),
service: "org.example.Tray".to_owned(),
},
)]);
assert_eq!(
remove_registrations_for_name(&mut items, "org.example.Tray"),
vec!["org.example.Tray".to_owned()]
);
assert!(items.is_empty());
}
}

1201
src/render.rs Normal file

File diff suppressed because it is too large Load Diff

138
tests/audio_popup.rs Normal file
View File

@@ -0,0 +1,138 @@
use lightbar::{
config::ConfigBundle,
model::{AudioDevice, AudioModel, AudioView, PopupContent, PopupModel},
render::{Renderer, hit_test},
};
fn mixer() -> AudioModel {
AudioModel {
devices: vec![
AudioDevice {
id: 10,
name: "speakers".into(),
description: "Desktop Speakers".into(),
input: false,
is_default: true,
volume: Some(50.0),
muted: Some(false),
},
AudioDevice {
id: 11,
name: "microphone".into(),
description: "USB Microphone".into(),
input: true,
is_default: true,
volume: Some(80.0),
muted: Some(true),
},
],
..AudioModel::default()
}
}
#[test]
fn example_popup_exposes_both_endpoints_and_keyboard_focus() {
let bundle = ConfigBundle::load(Some(std::path::Path::new("examples/config.toml"))).unwrap();
let mut renderer = Renderer::default();
for scale in [1, 2] {
let mut audio = mixer();
let mut visited = Vec::new();
for _ in 0..7 {
audio.move_focus(
false,
420.0 - 2.0 * f64::from(bundle.theme.popup.padding) - 58.0,
);
let popup = PopupModel {
title: "Audio".into(),
content: PopupContent::Audio(audio.clone()),
};
let mut pixels = vec![0; (320 * 420 * scale * scale * 4) as usize];
let result = renderer.render_popup(
&mut pixels,
320 * scale,
420 * scale,
scale,
"audio",
&popup,
&bundle.theme,
);
let focused = result
.hit_boxes
.iter()
.find(|hit| hit.segment == audio.focused)
.expect("keyboard focus must be visible and actionable");
assert_eq!(
hit_test(
&result.hit_boxes,
focused.rect.x + 1.0,
focused.rect.y + 1.0
),
Some(focused)
);
visited.push(audio.focused.clone().unwrap());
}
assert_eq!(
visited,
[
"audio-outputs",
"audio-volume:10",
"audio-mute:10",
"audio-inputs",
"audio-volume:11",
"audio-mute:11",
"open-settings"
]
);
audio.move_focus(false, 342.0);
assert_eq!(audio.focused.as_deref(), Some("audio-outputs"));
audio.move_focus(true, 342.0);
assert_eq!(audio.focused.as_deref(), Some("open-settings"));
}
}
#[test]
fn keyboard_reaches_every_device_in_a_short_popup() {
let bundle = ConfigBundle::load(Some(std::path::Path::new("examples/config.toml"))).unwrap();
let mut audio = mixer();
for id in 20..40 {
audio.devices.push(AudioDevice {
id,
name: format!("output-{id}"),
description: format!("Output {id}"),
input: false,
is_default: false,
volume: Some(100.0),
muted: Some(false),
});
}
audio.set_view(AudioView::Outputs);
let mut renderer = Renderer::default();
for _ in 0..22 {
audio.move_focus(
false,
180.0 - 2.0 * f64::from(bundle.theme.popup.padding) - 58.0,
);
let mut pixels = vec![0; 320 * 180 * 4];
let popup = PopupModel {
title: "Audio".into(),
content: PopupContent::Audio(audio.clone()),
};
let result =
renderer.render_popup(&mut pixels, 320, 180, 1, "audio", &popup, &bundle.theme);
assert!(
result
.hit_boxes
.iter()
.any(|hit| hit.segment == audio.focused)
);
}
assert_eq!(audio.focused.as_deref(), Some("audio-default:39"));
assert!(audio.offset > 0);
audio.set_view(AudioView::Inputs);
let ids: Vec<_> = audio
.controls()
.into_iter()
.filter_map(|control| control.id)
.collect();
assert_eq!(ids, ["audio-back", "audio-default:11"]);
}

12
tests/fixtures/audio.json vendored Normal file
View File

@@ -0,0 +1,12 @@
[
{"id":1,"type":"PipeWire:Interface:Metadata","props":{"metadata.name":"default"},"metadata":[
{"subject":0,"key":"default.configured.audio.sink","value":{"name":"disconnected-headset"}},
{"subject":0,"key":"default.audio.sink","value":{"name":"speakers"}},
{"subject":0,"key":"default.audio.source","value":{"name":"microphone"}}
]},
{"id":10,"type":"PipeWire:Interface:Node","info":{"props":{"media.class":"Audio/Sink","node.name":"speakers","node.description":"Desktop Speakers","device.id":30,"card.profile.device":0},"params":{"Props":[{"mute":false,"channelVolumes":[1.0,1.0]}]}}},
{"id":11,"type":"PipeWire:Interface:Node","info":{"props":{"media.class":"Audio/Source","node.name":"microphone","node.description":"USB Microphone"},"params":{"Props":[{"mute":true,"channelVolumes":[0.512]}]}}},
{"id":12,"type":"PipeWire:Interface:Node","info":{"props":{"media.class":"Audio/Sink","node.name":"headphones","node.description":"USB Headphones"},"params":{"Props":[{"mute":false,"channelVolumes":[1.0,1.0]}]}}},
{"id":30,"type":"PipeWire:Interface:Device","info":{"props":{"media.class":"Audio/Device"},"params":{"Route":[{"device":0,"props":{"mute":false,"channelVolumes":[0.125,0.125]}}]}}},
{"id":40,"type":"PipeWire:Interface:Node","info":{"props":{"media.class":"Stream/Output/Audio","node.name":"music"},"params":{"Props":[{"mute":false,"channelVolumes":[1.0,1.0]}]}}}
]