diff --git a/Cargo.lock b/Cargo.lock index 46340275..e1dad23f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -693,6 +693,7 @@ version = "0.11.1" dependencies = [ "chrono", "env_logger", + "freedesktop-desktop-entry", "iced", "iced_aw", "ksni", diff --git a/crates/cardwire-gui/Cargo.toml b/crates/cardwire-gui/Cargo.toml index 50946331..3cedbf21 100644 --- a/crates/cardwire-gui/Cargo.toml +++ b/crates/cardwire-gui/Cargo.toml @@ -23,6 +23,7 @@ ksni.workspace = true toml.workspace = true xdg.workspace = true chrono.workspace = true +freedesktop-desktop-entry.workspace = true [[bin]] name = "cardwire-gui" diff --git a/crates/cardwire-gui/src/app.rs b/crates/cardwire-gui/src/app.rs index 816c1721..caba719a 100644 --- a/crates/cardwire-gui/src/app.rs +++ b/crates/cardwire-gui/src/app.rs @@ -1,11 +1,13 @@ use iced::{ - Alignment, Element, Length::{Fill, Fixed}, Subscription, Task, widget::{column, container, row, stack, text}, window + Alignment, Element, Length::{Fill, Fixed}, Subscription, Task, widget::{column, container, row, stack}, window }; use log::error; use std::collections::BTreeMap; use crate::{ - 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} + gui_config::{GuiConfig, PrimaryClickAction}, helpers::{CardwireDbus, GpuDevice}, message::Message, models::{ + DaemonSettings, LogState, MainState, Mode, Page, PciDevice, SettingState, SmartState + }, tray::{self, TrayAction, TrayHandle}, ui::{self, daemon_setting_page, error_bar, info_bar, pci_page} }; #[derive(Debug)] @@ -19,6 +21,7 @@ pub struct AppState { pub main_state: MainState, pub setting_state: SettingState, pub log_state: LogState, + pub smart_state: SmartState, window_id: Option, tray_handle: Option, tray_available: bool, @@ -52,6 +55,7 @@ impl AppState { ..SettingState::default() }, log_state: LogState::default(), + smart_state: SmartState::default(), window_id, tray_handle: None, tray_available: true, @@ -373,6 +377,57 @@ impl AppState { }, // Append a new blocked process log received from dbus Message::NewLog(log) => self.log_state.push(log), + Message::FetchedAppPolicies(res) => match res { + Ok(policies) => { + let mut map = BTreeMap::new(); + for (app_id, meta) in policies { + let resolved = crate::helpers::resolve_app_metadata(&app_id, &meta); + map.insert(app_id, resolved); + } + self.smart_state.app_policies = map; + self.smart_state.loading = false; + self.error = None; + } + Err(err) => { + self.smart_state.loading = false; + self.error = Some(format!("Error fetching app policies: {}", err)); + } + }, + Message::SetAppPolicy(app_id, policy) => { + let conn = self.zbus_conn.clone(); + let app_id_clone = app_id.clone(); + return Task::perform( + async move { + conn.set_app_policy(app_id_clone.clone(), policy) + .await + .map_err(|e| e.to_string())?; + Ok((app_id_clone, policy)) + }, + Message::AppPolicyResult, + ); + } + Message::AppPolicyResult(res) => match res { + Ok((app_id, policy)) => { + if let Some(app) = self.smart_state.app_policies.get_mut(&app_id) { + app.gpu_policy = policy as u32; + } + let status = if policy == 1 { "Allowed" } else { "Blocked" }; + self.info = Some(format!("App policy for {} updated to {}", app_id, status)); + self.error = None; + } + Err(err) => self.error = Some(format!("App policy error: {}", err)), + }, + Message::UpdateSmartSearch(query) => { + self.smart_state.search_query = query; + } + Message::RefreshSmartPolicies => { + self.smart_state.loading = true; + let conn = self.zbus_conn.clone(); + return Task::perform( + async move { conn.get_app_policies().await.map_err(|e| e.to_string()) }, + Message::FetchedAppPolicies, + ); + } Message::OpenUrl(url) => { let _ = std::process::Command::new("xdg-open").arg(url).spawn(); } @@ -439,7 +494,7 @@ impl AppState { main_content = main_content.push(container(match &self.current_tab { Page::Main => ui::main_page(&self.main_state, &self.gpu_list), Page::Pci => pci_page(&self.pci_list), - Page::SmartMode => text("Smart Mode TODO").into(), + Page::SmartMode => ui::smart_mode_page(&self.smart_state, self.main_state.current_mode), Page::CardwireSettings => daemon_setting_page(&self.setting_state), Page::Logs => ui::logs_page(&self.log_state, &self.gpu_list), Page::Advanced => ui::advanced_page(), diff --git a/crates/cardwire-gui/src/helpers/app_resolver.rs b/crates/cardwire-gui/src/helpers/app_resolver.rs new file mode 100644 index 00000000..d5b4669c --- /dev/null +++ b/crates/cardwire-gui/src/helpers/app_resolver.rs @@ -0,0 +1,176 @@ +use std::{ + collections::HashSet, path::{Path, PathBuf} +}; + +use crate::models::{DbusAppMetadata, ResolvedApp}; +use freedesktop_desktop_entry::DesktopEntry; + +/// Returns all XDG data directories to search for applications and icons. +fn get_xdg_data_dirs() -> Vec { + let mut dirs = Vec::new(); + + if let Ok(home) = std::env::var("HOME") { + let user_share = PathBuf::from(home.clone()).join(".local/share"); + if user_share.exists() { + dirs.push(user_share); + } + let user_icons = PathBuf::from(home).join(".icons"); + if user_icons.exists() { + dirs.push(user_icons); + } + } + + if let Ok(val) = std::env::var("XDG_DATA_DIRS") { + for p in val.split(':') { + if !p.is_empty() { + let pb = PathBuf::from(p); + if pb.exists() && !dirs.contains(&pb) { + dirs.push(pb); + } + } + } + } + + // Fallback standard locations on Linux and nix + let fallbacks = [ + "/run/current-system/sw/share", + "/var/lib/flatpak/exports/share", + "/usr/local/share", + "/usr/share", + ]; + + for fb in fallbacks { + let pb = PathBuf::from(fb); + if pb.exists() && !dirs.contains(&pb) { + dirs.push(pb); + } + } + + dirs +} + +/// Resolves raw DbusAppMetadata into a ResolvedApp +pub fn resolve_app_metadata(app_id: &str, raw: &DbusAppMetadata) -> ResolvedApp { + let data_dirs = get_xdg_data_dirs(); + let locales = freedesktop_desktop_entry::get_languages_from_env(); + + let mut resolved_name: Option = None; + let mut resolved_icon_name: Option = raw.icon_name.clone(); + + let mut candidate_filenames = Vec::new(); + if let Some(ref dt_id) = raw.desktop_file_id { + if dt_id.ends_with(".desktop") { + candidate_filenames.push(dt_id.clone()); + } else { + candidate_filenames.push(format!("{}.desktop", dt_id)); + } + } + candidate_filenames.push(format!("{}.desktop", app_id)); + + 'search_desktop: for data_dir in &data_dirs { + let apps_dir = data_dir.join("applications"); + for candidate in &candidate_filenames { + let path = apps_dir.join(candidate); + if path.exists() + && let Ok(entry) = DesktopEntry::from_path(&path, Some(&locales)) + { + if let Some(name) = entry.name(&locales) { + let name_str = name.to_string(); + if !name_str.trim().is_empty() { + resolved_name = Some(name_str); + } + } + if resolved_icon_name.is_none() + && let Some(icon) = entry.icon() + { + resolved_icon_name = Some(icon.to_string()); + } + if resolved_name.is_some() { + break 'search_desktop; + } + } + } + } + + // Determine display name + let display_name = if let Some(name) = resolved_name { + name + } else if !raw.display_name.trim().is_empty() { + raw.display_name.clone() + } else { + app_id + .split(&['-', '_'][..]) + .map(|s| { + let mut c = s.chars(); + match c.next() { + None => String::new(), + Some(f) => f.to_uppercase().collect::() + c.as_str(), + } + }) + .collect::>() + .join(" ") + }; + + let icon_path = resolve_icon_path(resolved_icon_name.as_deref(), app_id, &data_dirs); + + ResolvedApp { + app_id: app_id.to_string(), + display_name, + desktop_file_id: raw.desktop_file_id.clone(), + icon_name: resolved_icon_name, + icon_path, + gpu_policy: raw.gpu_policy, + } +} + +/// Resolves an icon name or app_id to a image +fn resolve_icon_path( + icon_name: Option<&str>, + app_id: &str, + data_dirs: &[PathBuf], +) -> Option { + let mut names_to_check = Vec::new(); + if let Some(name) = icon_name + && !name.trim().is_empty() + { + let p = Path::new(name); + if p.is_absolute() && p.exists() { + return Some(p.to_path_buf()); + } + names_to_check.push(name); + } + names_to_check.push(app_id); + + let extensions = ["png", "svg", "xpm"]; + let icon_subdirs = [ + "icons/hicolor/128x128/apps", + "icons/hicolor/256x256/apps", + "icons/hicolor/512x512/apps", + "icons/hicolor/64x64/apps", + "icons/hicolor/48x48/apps", + "icons/hicolor/scalable/apps", + "pixmaps", + "icons/hicolor/32x32/apps", + ]; + + let mut searched_paths = HashSet::new(); + + for dir in data_dirs { + for sub in &icon_subdirs { + let base = dir.join(sub); + if !base.exists() { + continue; + } + for name in &names_to_check { + for ext in &extensions { + let file_path = base.join(format!("{}.{}", name, ext)); + if searched_paths.insert(file_path.clone()) && file_path.exists() { + return Some(file_path); + } + } + } + } + } + + None +} diff --git a/crates/cardwire-gui/src/helpers/dbus.rs b/crates/cardwire-gui/src/helpers/dbus.rs index 83a6b679..b1e74aa8 100644 --- a/crates/cardwire-gui/src/helpers/dbus.rs +++ b/crates/cardwire-gui/src/helpers/dbus.rs @@ -4,7 +4,7 @@ use zbus::{ self, Connection, fdo, names::OwnedInterfaceName, zvariant::{OwnedObjectPath, OwnedValue} }; -use crate::models::{DaemonSettings, LsofData, Mode}; +use crate::models::{DaemonSettings, DbusAppMetadata, LsofData, Mode}; #[derive(serde::Deserialize, serde::Serialize, Debug, Clone)] pub struct GpuDevice { @@ -209,4 +209,26 @@ impl CardwireDbus { .await?; proxy.call("RefreshGpu", &()).await } + pub async fn get_app_policies(&self) -> zbus::Result> { + let connection = Connection::system().await?; + let proxy = zbus::Proxy::new( + &connection, + "org.opengamingcollective.cardwire", + "/org/opengamingcollective/cardwire", + "org.opengamingcollective.cardwire.SmartPolicy", + ) + .await?; + proxy.call("GetAppPolicies", &()).await + } + pub async fn set_app_policy(&self, app_id: String, policy: i32) -> zbus::Result<()> { + let connection = Connection::system().await?; + let proxy = zbus::Proxy::new( + &connection, + "org.opengamingcollective.cardwire", + "/org/opengamingcollective/cardwire", + "org.opengamingcollective.cardwire.SmartPolicy", + ) + .await?; + proxy.call("SetAppPolicy", &(app_id, policy)).await + } } diff --git a/crates/cardwire-gui/src/helpers/mod.rs b/crates/cardwire-gui/src/helpers/mod.rs index c04541f6..2274fc98 100644 --- a/crates/cardwire-gui/src/helpers/mod.rs +++ b/crates/cardwire-gui/src/helpers/mod.rs @@ -1,3 +1,5 @@ +pub mod app_resolver; mod dbus; +pub use app_resolver::resolve_app_metadata; pub use dbus::{CardwireDbus, GpuDevice}; diff --git a/crates/cardwire-gui/src/message.rs b/crates/cardwire-gui/src/message.rs index b47d6c0e..815d5d79 100644 --- a/crates/cardwire-gui/src/message.rs +++ b/crates/cardwire-gui/src/message.rs @@ -34,6 +34,13 @@ pub enum Message { RefreshGpuResult(Result<(), String>), FetchedLogs(Result, String>), NewLog(LogEntry), + FetchedAppPolicies( + Result, String>, + ), + SetAppPolicy(String, i32), + AppPolicyResult(Result<(String, i32), String>), + UpdateSmartSearch(String), + RefreshSmartPolicies, OpenUrl(String), ClearError, ClearInfo, diff --git a/crates/cardwire-gui/src/models.rs b/crates/cardwire-gui/src/models.rs index 096144a4..e374e185 100644 --- a/crates/cardwire-gui/src/models.rs +++ b/crates/cardwire-gui/src/models.rs @@ -156,3 +156,29 @@ pub struct PciDevice { pub parent_pci: String, pub child_pci: String, } + +#[derive(serde::Deserialize, serde::Serialize, zbus::zvariant::Type, Debug, Clone)] +pub struct DbusAppMetadata { + pub display_name: String, + pub desktop_file_id: Option, + pub icon_name: Option, + pub gpu_policy: u32, +} + +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct ResolvedApp { + pub app_id: String, + pub display_name: String, + pub desktop_file_id: Option, + pub icon_name: Option, + pub icon_path: Option, + pub gpu_policy: u32, +} + +#[derive(Default, Clone, Debug)] +pub struct SmartState { + pub app_policies: std::collections::BTreeMap, + pub search_query: String, + pub loading: bool, +} diff --git a/crates/cardwire-gui/src/subscription.rs b/crates/cardwire-gui/src/subscription.rs index 2e215443..1fc9610b 100644 --- a/crates/cardwire-gui/src/subscription.rs +++ b/crates/cardwire-gui/src/subscription.rs @@ -597,6 +597,31 @@ fn logger_sub() -> Subscription { }) } +fn smart_sub() -> Subscription { + Subscription::run_with("cardwire_smart_subscription", |_| { + stream::channel(10, |mut output: Sender| async move { + let conn = CardwireDbus::new(); + match conn.get_app_policies().await { + Ok(policies) => { + let _ = output.send(Message::FetchedAppPolicies(Ok(policies))).await; + } + Err(err) => { + let _ = output + .send(Message::FetchedAppPolicies(Err(err.to_string()))) + .await; + } + } + }) + }) +} + pub fn dbus_sub() -> Subscription { - Subscription::batch([config_sub(), mode_sub(), gpu_sub(), pci_sub(), logger_sub()]) + Subscription::batch([ + config_sub(), + mode_sub(), + gpu_sub(), + pci_sub(), + logger_sub(), + smart_sub(), + ]) } diff --git a/crates/cardwire-gui/src/ui.rs b/crates/cardwire-gui/src/ui.rs index df9c458f..44957eb4 100644 --- a/crates/cardwire-gui/src/ui.rs +++ b/crates/cardwire-gui/src/ui.rs @@ -1,7 +1,7 @@ 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 + button, column, container, pick_list, row, scrollable, space::horizontal, text, text_input, toggler } }; use iced_aw::DropDown; @@ -9,7 +9,9 @@ use std::collections::BTreeMap; use strum::{IntoEnumIterator, VariantArray}; use crate::{ - gui_config::{GuiConfig, PrimaryClickAction}, helpers::GpuDevice, message::Message, models::{LogEntry, LogState, LsofData, MainState, Mode, Page, PciDevice, SettingState} + gui_config::{GuiConfig, PrimaryClickAction}, helpers::GpuDevice, message::Message, models::{ + LogEntry, LogState, LsofData, MainState, Mode, Page, PciDevice, ResolvedApp, SettingState, SmartState + } }; // Custom macro for box theming, used by cards @@ -854,3 +856,221 @@ pub fn info_bar(msg: &str) -> Element<'_, Message> { }) .into() } + +pub fn smart_mode_page<'a>( + smart_state: &'a SmartState, + current_mode: Option, +) -> Element<'a, Message> { + let is_smart = current_mode == Some(Mode::Smart); + + let header = row![ + text("Smart Mode App Policies") + .size(20) + .color(Color::from_rgb(0.9, 0.9, 0.9)), + horizontal(), + text!("{} apps", smart_state.app_policies.len()).color(Color::from_rgb(0.6, 0.6, 0.6)), + ] + .align_y(Alignment::Center); + + let warning = if !is_smart { + let current_mode_str = current_mode.map_or("Unknown".to_string(), |m| m.to_string()); + Some( + container( + row![ + text("⚠ ").size(20).color(Color::from_rgb(1.0, 0.8, 0.0)), + text!("Warning: Smart Mode is inactive (Current mode: {}). App policies are not enforced.", current_mode_str) + .color(Color::from_rgb(1.0, 0.8, 0.0)), + ] + .align_y(Alignment::Center) + .padding(10), + ) + .style(|_| container::Style { + background: Some(Color::from_rgb(0.25, 0.2, 0.05).into()), + border: Border { + radius: 8.0.into(), + width: 1.0, + color: Color::from_rgb(0.5, 0.4, 0.1), + }, + ..Default::default() + }) + .width(Fill), + ) + } else { + None + }; + + // Search input & Refresh button + let search_bar = text_input("Search app name or binary ID...", &smart_state.search_query) + .on_input(Message::UpdateSmartSearch) + .padding(8) + .width(Fill); + + let refresh_btn = button("Refresh Policies") + .on_press(Message::RefreshSmartPolicies) + .padding([8, 14]); + + let controls = row![search_bar, refresh_btn] + .spacing(12) + .align_y(Alignment::Center); + + let query = smart_state.search_query.to_lowercase(); + let filtered_apps: Vec<(&String, &ResolvedApp)> = smart_state + .app_policies + .iter() + .filter(|(app_id, app)| { + if query.is_empty() { + true + } else { + app_id.to_lowercase().contains(&query) + || app.display_name.to_lowercase().contains(&query) + } + }) + .collect(); + + // App list container + let mut app_list_col = column![].spacing(8).width(Fill); + + if filtered_apps.is_empty() { + let empty_text = if smart_state.loading { + "Loading application policies..." + } else if smart_state.app_policies.is_empty() { + "No applications detected in Cardwire database yet." + } else { + "No applications match the search query." + }; + app_list_col = app_list_col.push( + container(text!("{}", empty_text).color(Color::from_rgb(0.5, 0.5, 0.5))) + .padding(30) + .width(Fill) + .align_x(Alignment::Center), + ); + } else { + for (app_id, app) in filtered_apps { + let is_allowed = app.gpu_policy == 1; + + // App badge icon resolution + let initial = app + .display_name + .chars() + .next() + .unwrap_or('A') + .to_uppercase() + .to_string(); + + let badge_bg = if is_allowed { + Color::from_rgb(0.15, 0.35, 0.25) + } else { + Color::from_rgb(0.35, 0.2, 0.2) + }; + + let icon_element: Element<'_, Message> = + container(text!("{}", initial).color(Color::WHITE)) + .width(38) + .height(38) + .align_x(Alignment::Center) + .align_y(Alignment::Center) + .style(move |_| container::Style { + background: Some(badge_bg.into()), + border: Border { + radius: 8.0.into(), + ..Default::default() + }, + ..Default::default() + }) + .into(); + + // App title & binary ID + let text_color = if !is_smart { + Color::from_rgb(0.5, 0.5, 0.5) + } else { + Color::WHITE + }; + let subtext_color = if !is_smart { + Color::from_rgb(0.4, 0.4, 0.4) + } else { + Color::from_rgb(0.6, 0.6, 0.6) + }; + + let dt_id = app.desktop_file_id.as_deref(); + let sub_text = if let Some(dt) = dt_id { + format!("ID: {} ({}.desktop)", app_id, dt) + } else { + format!("ID: {}", app_id) + }; + + let app_info = column![ + text!("{}", app.display_name).color(text_color), + text!("{}", sub_text).color(subtext_color), + ] + .spacing(2); + + // Policy toggle widget & status label + let policy_label = if is_allowed { + text("Allowed (dGPU)").color(if is_smart { + Color::from_rgb(0.3, 0.8, 0.4) + } else { + Color::from_rgb(0.4, 0.6, 0.4) + }) + } else { + text("Blocked (iGPU)").color(if is_smart { + Color::from_rgb(0.8, 0.4, 0.4) + } else { + Color::from_rgb(0.6, 0.4, 0.4) + }) + }; + + let app_id_owned = app_id.clone(); + let toggle_widget = if is_smart { + toggler(is_allowed).on_toggle(move |val| { + Message::SetAppPolicy(app_id_owned.clone(), if val { 1 } else { 0 }) + }) + } else { + toggler(is_allowed) + }; + + let policy_control = row![policy_label, toggle_widget] + .spacing(12) + .align_y(Alignment::Center); + + let row_content = row![icon_element, app_info, horizontal(), policy_control] + .spacing(15) + .align_y(Alignment::Center); + + // Greyed out styling when not in Smart mode + let card = + container(row_content) + .width(Fill) + .padding(14) + .style(move |_: &iced::Theme| { + if !is_smart { + container::Style { + background: Some(Color::from_rgba(0.12, 0.12, 0.12, 0.6).into()), + border: Border { + radius: 8.0.into(), + width: 1.0, + color: Color::from_rgb(0.2, 0.2, 0.2), + }, + ..Default::default() + } + } else { + box_theme!() + } + }); + + app_list_col = app_list_col.push(card); + } + } + + let list_scrollable = scrollable(app_list_col).height(Fill); + + let mut content = column![header].spacing(12).width(Fill).height(Fill); + + if let Some(w) = warning { + content = content.push(w); + } + + content = content.push(controls); + content = content.push(list_scrollable); + + content.into() +}