diff --git a/Cargo.lock b/Cargo.lock index d5c2570c..2bf0114b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -689,6 +689,7 @@ dependencies = [ name = "cardwire-gui" version = "0.11.1" dependencies = [ + "chrono", "env_logger", "iced", "iced_aw", diff --git a/Cargo.toml b/Cargo.toml index 5e49b93e..17d8a2e6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -66,6 +66,7 @@ iced = { version = "0.14.0", default-features = false, features = [ iced_aw = "0.14.1" strum = { version = "0.28", features = ["derive"] } ksni = "0.3.6" +chrono = "0.4.45" [profile.dev] overflow-checks = true diff --git a/crates/cardwire-gui/Cargo.toml b/crates/cardwire-gui/Cargo.toml index b5861955..50946331 100644 --- a/crates/cardwire-gui/Cargo.toml +++ b/crates/cardwire-gui/Cargo.toml @@ -22,6 +22,7 @@ serde_json.workspace = true ksni.workspace = true toml.workspace = true xdg.workspace = true +chrono.workspace = true [[bin]] name = "cardwire-gui" diff --git a/crates/cardwire-gui/src/app.rs b/crates/cardwire-gui/src/app.rs index a93adc35..816c1721 100644 --- a/crates/cardwire-gui/src/app.rs +++ b/crates/cardwire-gui/src/app.rs @@ -5,7 +5,7 @@ use log::error; use std::collections::BTreeMap; use crate::{ - gui_config::{GuiConfig, PrimaryClickAction}, helpers::{CardwireDbus, GpuDevice}, message::Message, models::{DaemonSettings, MainState, Mode, Page, PciDevice, SettingState}, tray::{self, TrayAction, TrayHandle}, ui::{self, daemon_setting_page, error_bar, info_bar, pci_page} + gui_config::{GuiConfig, PrimaryClickAction}, helpers::{CardwireDbus, GpuDevice}, message::Message, models::{DaemonSettings, LogState, MainState, Mode, Page, PciDevice, SettingState}, tray::{self, TrayAction, TrayHandle}, ui::{self, daemon_setting_page, error_bar, info_bar, pci_page} }; #[derive(Debug)] @@ -18,6 +18,7 @@ pub struct AppState { pub pci_list: BTreeMap, pub main_state: MainState, pub setting_state: SettingState, + pub log_state: LogState, window_id: Option, tray_handle: Option, tray_available: bool, @@ -50,6 +51,7 @@ impl AppState { gui_config, ..SettingState::default() }, + log_state: LogState::default(), window_id, tray_handle: None, tray_available: true, @@ -361,6 +363,16 @@ impl AppState { Ok(()) => self.info = Some("GPU list refreshed".to_string()), Err(err) => self.error = Some(format!("Refresh error: {}", err)), }, + // Fetch the initial blocked process logs from dbus + Message::FetchedLogs(res) => match res { + Ok(logs) => { + self.log_state.replace(logs); + self.error = None; + } + Err(err) => self.error = Some(format!("Error fetching logs: {}", err)), + }, + // Append a new blocked process log received from dbus + Message::NewLog(log) => self.log_state.push(log), Message::OpenUrl(url) => { let _ = std::process::Command::new("xdg-open").arg(url).spawn(); } @@ -429,7 +441,7 @@ impl AppState { Page::Pci => pci_page(&self.pci_list), Page::SmartMode => text("Smart Mode TODO").into(), Page::CardwireSettings => daemon_setting_page(&self.setting_state), - Page::AccessLogs => text!("TODO").into(), + Page::Logs => ui::logs_page(&self.log_state, &self.gpu_list), Page::Advanced => ui::advanced_page(), Page::About => ui::about_page(), })); diff --git a/crates/cardwire-gui/src/message.rs b/crates/cardwire-gui/src/message.rs index aebc53f0..b47d6c0e 100644 --- a/crates/cardwire-gui/src/message.rs +++ b/crates/cardwire-gui/src/message.rs @@ -1,7 +1,7 @@ use crate::{ - gui_config::GuiConfig, helpers::GpuDevice, models::{DaemonSettings, LsofData, Mode, Page, PciDevice}, tray::{TrayAction, TrayHandle} + gui_config::GuiConfig, helpers::GpuDevice, models::{DaemonSettings, LogEntry, LsofData, Mode, Page, PciDevice}, tray::{TrayAction, TrayHandle} }; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, VecDeque}; #[derive(Debug, Clone)] pub enum Message { @@ -32,6 +32,8 @@ pub enum Message { CloseLsofWindow, RefreshGpu, RefreshGpuResult(Result<(), String>), + FetchedLogs(Result, String>), + NewLog(LogEntry), OpenUrl(String), ClearError, ClearInfo, diff --git a/crates/cardwire-gui/src/models.rs b/crates/cardwire-gui/src/models.rs index 9630a059..096144a4 100644 --- a/crates/cardwire-gui/src/models.rs +++ b/crates/cardwire-gui/src/models.rs @@ -1,5 +1,5 @@ use std::{ - collections::HashMap, fmt::{self, Display} + collections::{HashMap, VecDeque}, fmt::{self, Display}, time::SystemTime }; use strum::{EnumIter, FromRepr, VariantArray}; @@ -50,7 +50,7 @@ pub enum Page { Main, Pci, SmartMode, - AccessLogs, + Logs, CardwireSettings, Advanced, About, @@ -62,7 +62,7 @@ impl Display for Page { Page::Pci => write!(f, "PCI"), Page::SmartMode => write!(f, "Smart Mode"), Page::CardwireSettings => write!(f, "Cardwire Settings"), - Page::AccessLogs => write!(f, "Access Logs"), + Page::Logs => write!(f, "Logs"), Page::Advanced => write!(f, "Advanced"), Page::About => write!(f, "About"), } @@ -76,6 +76,40 @@ pub struct MainState { pub lsof_window: Option, } +#[derive(serde::Deserialize, zbus::zvariant::Type, Debug, Clone)] +pub struct LogEntry { + pub timestamp: SystemTime, + pub pid: u32, + pub comm: String, + pub gpu_id: u32, + pub wayland_app_id: String, +} + +// Maximum number of log entries kept in the GUI +const MAX_GUI_LOG_ENTRIES: usize = 500; + +#[derive(Default, Clone, Debug)] +pub struct LogState { + pub logs: VecDeque, +} + +impl LogState { + pub fn replace(&mut self, logs: VecDeque) { + let mut logs = logs; + while logs.len() > MAX_GUI_LOG_ENTRIES { + logs.pop_front(); + } + self.logs = logs; + } + + pub fn push(&mut self, log: LogEntry) { + self.logs.push_back(log); + while self.logs.len() > MAX_GUI_LOG_ENTRIES { + self.logs.pop_front(); + } + } +} + #[derive(Clone, Debug)] pub struct LsofData { pub gpu_id: usize, diff --git a/crates/cardwire-gui/src/subscription.rs b/crates/cardwire-gui/src/subscription.rs index 546610eb..2e215443 100644 --- a/crates/cardwire-gui/src/subscription.rs +++ b/crates/cardwire-gui/src/subscription.rs @@ -1,4 +1,4 @@ -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, HashMap, VecDeque}; use iced::{ Subscription, futures::{SinkExt, StreamExt, channel::mpsc::Sender}, stream @@ -9,7 +9,7 @@ use tokio::select; use tokio_stream::StreamMap; use crate::{ - helpers::CardwireDbus, message::Message, models::{DaemonSettings, Mode, PciDevice}, tray + helpers::CardwireDbus, message::Message, models::{DaemonSettings, LogEntry, Mode, PciDevice}, tray }; use zbus::{ Connection, Proxy, names::OwnedInterfaceName, proxy, zvariant::{OwnedObjectPath, OwnedValue} @@ -525,6 +525,78 @@ fn pci_sub() -> Subscription { }) } +// CardwireLogger is used to listen to log signals + +#[proxy( + default_service = "org.opengamingcollective.cardwire", + default_path = "/org/opengamingcollective/cardwire", + interface = "org.opengamingcollective.cardwire.Logger" +)] +// org.freedesktop.DBus.Properties +trait CardwireLogger { + fn process_blocked(&self) -> zbus::Result>; + #[zbus(signal)] + fn process_blocked_changed(&self, log: LogEntry) -> zbus::Result<()>; +} + +fn logger_sub() -> Subscription { + Subscription::run_with("cardwire_logger_subscription", |_id| { + stream::channel(100, |mut output: Sender| async move { + let connection = match Connection::system().await { + Ok(conn) => conn, + Err(e) => { + warn!("Failed to connect to D-Bus: {}", e); + let _ = output.send(Message::FetchedLogs(Err(e.to_string()))).await; + return; + } + }; + + let proxy = match CardwireLoggerProxy::new(&connection).await { + Ok(p) => p, + Err(e) => { + warn!("Failed to create D-Bus proxy: {}", e); + let _ = output.send(Message::FetchedLogs(Err(e.to_string()))).await; + return; + } + }; + // for startup, get current blocked apps logs + match proxy.process_blocked().await { + Ok(initial_logs) => { + if !initial_logs.is_empty() { + let _ = output.send(Message::FetchedLogs(Ok(initial_logs))).await; + } + } + Err(error) => { + let _ = output + .send(Message::FetchedLogs(Err(error.to_string()))) + .await; + } + } + let mut logs_stream = match proxy.receive_process_blocked_changed().await { + Ok(stream) => stream, + Err(err) => { + warn!("Failed to subscribe to D-Bus logs signal: {}", err); + let _ = output + .send(Message::FetchedLogs(Err(err.to_string()))) + .await; + return; + } + }; + while let Some(log_signal) = logs_stream.next().await { + if let Ok(log_arg) = log_signal.args() { + let log: LogEntry = log_arg.log().clone(); + let _ = output.send(Message::NewLog(log)).await; + } + } + let _ = output + .send(Message::FetchedLogs(Err( + "Cardwire daemon disconnected".to_string() + ))) + .await; + }) + }) +} + pub fn dbus_sub() -> Subscription { - Subscription::batch([config_sub(), mode_sub(), gpu_sub(), pci_sub()]) + Subscription::batch([config_sub(), mode_sub(), gpu_sub(), pci_sub(), logger_sub()]) } diff --git a/crates/cardwire-gui/src/ui.rs b/crates/cardwire-gui/src/ui.rs index 0a8d3764..df9c458f 100644 --- a/crates/cardwire-gui/src/ui.rs +++ b/crates/cardwire-gui/src/ui.rs @@ -1,3 +1,4 @@ +use chrono::{DateTime, Local}; use iced::{ Alignment, Border, Color, Element, Font, Length::{Fill, FillPortion, Fixed}, widget::{ button, column, container, pick_list, row, scrollable, space::horizontal, text, toggler @@ -8,7 +9,7 @@ use std::collections::BTreeMap; use strum::{IntoEnumIterator, VariantArray}; use crate::{ - gui_config::{GuiConfig, PrimaryClickAction}, helpers::GpuDevice, message::Message, models::{LsofData, MainState, Mode, Page, PciDevice, SettingState} + gui_config::{GuiConfig, PrimaryClickAction}, helpers::GpuDevice, message::Message, models::{LogEntry, LogState, LsofData, MainState, Mode, Page, PciDevice, SettingState} }; // Custom macro for box theming, used by cards @@ -140,7 +141,7 @@ pub fn main_page<'a>( ) -> Element<'a, Message> { column![ mode_element(main_state.current_mode), - gpu_cards(gpu_list, main_state.open_gpu_menu) + gpu_cards(gpu_list, main_state.open_gpu_menu, main_state.current_mode) ] .spacing(20) .into() @@ -160,6 +161,7 @@ fn mode_element(current_mode: Option) -> Element<'static, Message> { fn gpu_cards( gpu_list: &BTreeMap, open_dropdown: Option, + current_mode: Option, ) -> Element<'_, Message> { let cards = gpu_list .iter() @@ -180,14 +182,13 @@ fn gpu_cards( let gpu_id = *id; let is_blocked = gpu.blocked; - let is_offload_dgpu = !gpu.default && gpu.discrete; - let is_available = gpu.available; // Build dropdown menu items let mut dropdown_col = column![]; - // Block/Unblock (only for offload dGPU) - if is_offload_dgpu && is_available { + // Block/Unblock (only in manual mode and if not default) + if current_mode.is_some_and(|mode| mode == Mode::Manual) && !gpu.default && is_available + { if is_blocked { dropdown_col = dropdown_col.push( button("Unblock") @@ -437,6 +438,94 @@ pub fn lsof_overlay<'a>( .into() } +// A dark terminal-like page showing the blocked process logs +pub fn logs_page<'a>( + log_state: &'a LogState, + gpu_list: &'a BTreeMap, +) -> Element<'a, Message> { + let header = row![ + text("Blocked Process Logs") + .size(20) + .color(Color::from_rgb(0.9, 0.9, 0.9)), + horizontal(), + text!("{} entries", log_state.logs.len()).color(Color::from_rgb(0.6, 0.6, 0.6)), + ] + .align_y(Alignment::Center); + + let content = if log_state.logs.is_empty() { + column![ + text("No blocked process logs yet") + .color(Color::from_rgb(0.5, 0.5, 0.5)) + .font(Font::MONOSPACE) + ] + } else { + log_state + .logs + .iter() + .fold(column![].spacing(4), |col, log| { + col.push(log_line(log, gpu_list)) + }) + }; + + let terminal = container(scrollable(content)) + .width(Fill) + .height(Fill) + .padding(16) + .style(|_| container::Style { + background: Some(Color::from_rgb(0.07, 0.07, 0.08).into()), + border: Border { + radius: 8.0.into(), + width: 1.0, + color: Color::from_rgb(0.2, 0.2, 0.2), + }, + ..Default::default() + }); + + column![header, terminal].spacing(12).into() +} + +// One color-coded log line, the gpu id is replaced by the gpu name when known +fn log_line<'a>( + log: &'a LogEntry, + gpu_list: &'a BTreeMap, +) -> Element<'a, Message> { + let timestamp = DateTime::::from(log.timestamp) + .format("%H:%M:%S") + .to_string(); + let app_name = if log.wayland_app_id.is_empty() { + log.comm.as_str() + } else { + log.wayland_app_id.as_str() + }; + let gpu_name = gpu_list + .get(&(log.gpu_id as usize)) + .map(|g| g.name.as_str()) + .unwrap_or("Unknown"); + + row![ + text!("[{}] ", timestamp) + .color(Color::from_rgb(0.55, 0.55, 0.55)) + .font(Font::MONOSPACE), + text(app_name) + .color(Color::from_rgb(0.35, 0.8, 0.98)) + .font(Font::MONOSPACE), + text!("[{}] ", log.pid) + .color(Color::from_rgb(0.98, 0.2, 0.6)) + .font(Font::MONOSPACE), + text("tried to access GPU ") + .color(Color::from_rgb(0.75, 0.75, 0.75)) + .font(Font::MONOSPACE), + text(gpu_name) + .color(Color::from_rgb(0.4, 0.8, 0.4)) + .font(Font::MONOSPACE), + text(" (blocked by cardwire)") + .color(Color::from_rgb(0.5, 0.5, 0.5)) + .font(Font::MONOSPACE), + ] + .align_y(Alignment::Center) + .into() +} + pub fn about_page() -> Element<'static, Message> { let version = env!("CARGO_PKG_VERSION"); let content = column![