Initial Lightbar panel with native audio controls
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/target/
|
||||
*.profraw
|
||||
2134
Cargo.lock
generated
Normal file
2134
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
46
Cargo.toml
Normal file
46
Cargo.toml
Normal 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
21
LICENSE
Normal 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
180
README.md
Normal 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
136
TOOD.md
Normal 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
126
examples/config.toml
Normal 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
76
examples/theme.toml
Normal 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
48
scripts/install.sh
Executable 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
43
scripts/sample-process.sh
Executable 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
1770
src/app.rs
Normal file
File diff suppressed because it is too large
Load Diff
51
src/cli.rs
Normal file
51
src/cli.rs
Normal 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
1060
src/config.rs
Normal file
File diff suppressed because it is too large
Load Diff
290
src/doctor.rs
Normal file
290
src/doctor.rs
Normal 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
131
src/format.rs
Normal 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
10
src/lib.rs
Normal 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
67
src/main.rs
Normal 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
360
src/model.rs
Normal 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
812
src/modules/audio.rs
Normal 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
78
src/modules/clock.rs
Normal 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
334
src/modules/command.rs
Normal 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
308
src/modules/mod.rs
Normal 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
335
src/modules/process.rs
Normal 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
638
src/modules/sway.rs
Normal 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
427
src/modules/sysfs.rs
Normal 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
751
src/modules/tray.rs
Normal 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| (®istered[..index], ®istered[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(®istration);
|
||||
if inserted {
|
||||
Self::status_notifier_item_registered(&emitter, ®istered).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
1201
src/render.rs
Normal file
File diff suppressed because it is too large
Load Diff
138
tests/audio_popup.rs
Normal file
138
tests/audio_popup.rs
Normal 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
12
tests/fixtures/audio.json
vendored
Normal 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]}]}}}
|
||||
]
|
||||
Reference in New Issue
Block a user