feat: Add a lot more features
CI / build-ubuntu (, default) (push) Has been cancelled
CI / build-ubuntu (--features networking, networking) (push) Has been cancelled
CI / build-ubuntu (--no-default-features, no-default-features) (push) Has been cancelled
CI / build-debian (push) Has been cancelled
CI / build-fedora (push) Has been cancelled
CI / build-arch (push) Has been cancelled
CI / build-opensuse (push) Has been cancelled

- Add new system.rs module for system monitoring via D-Bus:
  - Battery status (UPower)
  - Media player controls and metadata (MPRIS)
  - WiFi/Bluetooth status (NetworkManager)
  - Keyboard layout tracking
- Add media key support (XF86 Play/Pause/Next/Prev)
- Add function keys F1-F3 for Suspend/Reboot/PowerOff
- Replace deprecated timer.rs with async system management
- Add GitHub Actions CI/CD workflows (CI + Release)
- Update dependencies and add optional networking feature
This commit is contained in:
2026-03-16 14:12:43 +01:00
parent 1dc4d68cf7
commit f489a6e46e
15 changed files with 3582 additions and 829 deletions
+277
View File
@@ -0,0 +1,277 @@
use std::sync::{Arc, Mutex};
use mpris::PlayerFinder;
use zbus::Connection;
use log::{error, debug};
use tokio::sync::mpsc;
#[derive(Clone, Default)]
pub struct SystemStatus {
pub battery_percent: Option<f64>,
pub is_charging: bool,
pub media_title: Option<String>,
pub media_artist: Option<String>,
pub media_playing: bool,
pub media_art_url: Option<String>,
pub media_art_data: Option<Arc<Vec<u8>>>,
pub wifi_ssid: Option<String>,
pub wifi_strength: Option<u8>,
pub bluetooth_connected: bool,
pub bluetooth_devices: Vec<String>,
pub keyboard_layout: Option<u32>,
}
#[derive(Debug, Clone, Copy)]
pub enum SystemCommand {
PowerOff,
Reboot,
Suspend,
}
pub struct SystemManager {
status: Arc<Mutex<SystemStatus>>,
cmd_tx: mpsc::UnboundedSender<SystemCommand>,
}
impl SystemManager {
pub fn new() -> Self {
let status = Arc::new(Mutex::new(SystemStatus::default()));
let s_clone = status.clone();
let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel::<SystemCommand>();
// Spawn a thread to update status periodically and handle commands
std::thread::spawn(move || {
let rt = match tokio::runtime::Runtime::new() {
Ok(rt) => rt,
Err(e) => {
error!("Failed to create tokio runtime for SystemManager: {}", e);
return;
}
};
rt.block_on(async {
let mut conn: Option<Connection> = None;
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(2));
let mut last_art_url: Option<String> = None;
let mut last_art_data: Option<Arc<Vec<u8>>> = None;
loop {
// Try to connect to system DBus if not connected
if conn.is_none() {
match Connection::system().await {
Ok(c) => conn = Some(c),
Err(e) => {
error!("Failed to connect to system DBus: {}", e);
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
}
}
}
tokio::select! {
_ = interval.tick() => {
let mut new_status = SystemStatus::default();
if let Some(ref c) = conn {
// 1. Battery status
if let Ok(proxy) = upower_dbus::UPowerProxy::new(c).await {
if let Ok(display_device) = proxy.get_display_device().await {
new_status.battery_percent = display_device.percentage().await.ok();
if let Ok(state) = display_device.state().await {
new_status.is_charging = format!("{:?}", state).contains("Charging");
}
}
}
}
// 2. MPRIS status
if let Ok(finder) = PlayerFinder::new() {
if let Ok(player) = finder.find_active() {
if let Ok(metadata) = player.get_metadata() {
new_status.media_title = metadata.title().map(|s| s.to_string());
new_status.media_artist = metadata.artists().map(|a| a.join(", "));
new_status.media_art_url = metadata.art_url().map(|u| u.to_string());
if new_status.media_art_url != last_art_url {
last_art_url = new_status.media_art_url.clone();
last_art_data = None;
if let Some(ref url) = last_art_url {
if url.starts_with("file://") {
let path = url.trim_start_matches("file://");
if let Ok(data) = std::fs::read(path) {
last_art_data = Some(Arc::new(data));
}
} else if url.starts_with("http") {
#[cfg(feature = "networking")]
if let Ok(resp) = reqwest::get(url).await {
if let Ok(bytes) = resp.bytes().await {
last_art_data = Some(Arc::new(bytes.to_vec()));
}
}
#[cfg(not(feature = "networking"))]
{
log::debug!("Networking disabled, skipping remote album art: {}", url);
}
}
}
}
new_status.media_art_data = last_art_data.clone();
}
new_status.media_playing = player.get_playback_status().map(|s| matches!(s, mpris::PlaybackStatus::Playing)).unwrap_or(false);
}
}
// 3. WiFi status (NetworkManager)
if let Some(ref c) = conn {
if let Ok(reply) = c.call_method(
Some("org.freedesktop.NetworkManager"),
"/org/freedesktop/NetworkManager",
Some("org.freedesktop.NetworkManager"),
"GetDevices",
&(),
).await {
let devices: Vec<zbus::zvariant::OwnedObjectPath> = reply.body().unwrap();
for dev_path in devices {
if let Ok(dev_type_reply) = c.call_method(
Some("org.freedesktop.NetworkManager"),
&dev_path,
Some("org.freedesktop.DBus.Properties"),
"Get",
&("org.freedesktop.NetworkManager.Device", "DeviceType"),
).await {
let dev_type: u32 = dev_type_reply.body::<zbus::zvariant::Value>().unwrap().downcast().unwrap();
if dev_type == 2 { // WiFi
if let Ok(active_ap_reply) = c.call_method(
Some("org.freedesktop.NetworkManager"),
&dev_path,
Some("org.freedesktop.DBus.Properties"),
"Get",
&("org.freedesktop.NetworkManager.Device.Wireless", "ActiveAccessPoint"),
).await {
let ap_path: zbus::zvariant::OwnedObjectPath = active_ap_reply.body::<zbus::zvariant::Value>().unwrap().downcast().unwrap();
if ap_path.as_str() != "/" {
if let Ok(ssid_reply) = c.call_method(
Some("org.freedesktop.NetworkManager"),
&ap_path,
Some("org.freedesktop.DBus.Properties"),
"Get",
&("org.freedesktop.NetworkManager.AccessPoint", "Ssid"),
).await {
let ssid_bytes: Vec<u8> = ssid_reply.body::<zbus::zvariant::Value>().unwrap().downcast().unwrap();
new_status.wifi_ssid = Some(String::from_utf8_lossy(&ssid_bytes).to_string());
}
if let Ok(strength_reply) = c.call_method(
Some("org.freedesktop.NetworkManager"),
&ap_path,
Some("org.freedesktop.DBus.Properties"),
"Get",
&("org.freedesktop.NetworkManager.AccessPoint", "Strength"),
).await {
new_status.wifi_strength = Some(strength_reply.body::<zbus::zvariant::Value>().unwrap().downcast().unwrap());
}
}
}
break;
}
}
}
}
// 4. Bluetooth status (BlueZ)
if let Ok(objects_reply) = c.call_method(
Some("org.bluez"),
"/",
Some("org.freedesktop.DBus.ObjectManager"),
"GetManagedObjects",
&(),
).await {
use std::collections::HashMap;
type ManagedObjects = HashMap<zbus::zvariant::OwnedObjectPath, HashMap<String, HashMap<String, zbus::zvariant::OwnedValue>>>;
if let Ok(objects) = objects_reply.body::<ManagedObjects>() {
for (_path, interfaces) in objects {
if let Some(device) = interfaces.get("org.bluez.Device1") {
if let Some(connected) = device.get("Connected") {
if connected.downcast_ref::<bool>().copied().unwrap_or(false) {
new_status.bluetooth_connected = true;
if let Some(name) = device.get("Name") {
let name_str: String = name.downcast_ref::<str>().map(|s| s.to_string()).unwrap_or_default();
new_status.bluetooth_devices.push(name_str);
}
}
}
}
}
}
}
}
{
if let Ok(mut s) = s_clone.lock() {
*s = new_status;
}
}
}
Some(command) = cmd_rx.recv() => {
if let Some(ref c) = conn {
let method = match command {
SystemCommand::PowerOff => "PowerOff",
SystemCommand::Reboot => "Reboot",
SystemCommand::Suspend => "Suspend",
};
debug!("Executing system command: {}", method);
// Set a timeout for the DBus call to prevent hanging the background thread
let result = tokio::time::timeout(
tokio::time::Duration::from_secs(5),
c.call_method(
Some("org.freedesktop.login1"),
"/org/freedesktop/login1",
Some("org.freedesktop.login1.Manager"),
method,
&(true),
)
).await;
if let Err(_) = result {
error!("System command {} timed out", method);
}
}
}
}
}
});
});
Self { status, cmd_tx }
}
pub fn get_status(&self) -> SystemStatus {
self.status.lock().unwrap().clone()
}
pub fn send_command(&self, cmd: SystemCommand) {
let _ = self.cmd_tx.send(cmd);
}
pub fn media_play_pause(&self) {
if let Ok(finder) = PlayerFinder::new() {
if let Ok(player) = finder.find_active() {
let _ = player.play_pause();
}
}
}
pub fn media_next(&self) {
if let Ok(finder) = PlayerFinder::new() {
if let Ok(player) = finder.find_active() {
let _ = player.next();
}
}
}
pub fn media_prev(&self) {
if let Ok(finder) = PlayerFinder::new() {
if let Ok(player) = finder.find_active() {
let _ = player.previous();
}
}
}
}