diff --git a/Cargo.lock b/Cargo.lock index 3ddac3b1..e195e42c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1382,6 +1382,7 @@ dependencies = [ "relm4", "relm4-components", "serde", + "serde_json", "serde_yaml", "thread-priority", "tracing", diff --git a/lact-client/src/lib.rs b/lact-client/src/lib.rs index dfc2b662..e247a019 100644 --- a/lact-client/src/lib.rs +++ b/lact-client/src/lib.rs @@ -3,7 +3,10 @@ mod connection; mod macros; pub use lact_schema as schema; -use lact_schema::{config::GpuConfig, ProfileRule}; +use lact_schema::{ + config::{GpuConfig, Profile}, + ProfileRule, +}; use amdgpu_sysfs::gpu_handle::power_profile_mode::PowerProfileModesTable; use anyhow::Context; @@ -143,6 +146,10 @@ impl DaemonClient { .await } + pub async fn get_profile(&self, name: Option) -> anyhow::Result> { + self.make_request(Request::GetProfile { name }).await + } + pub async fn set_profile(&self, name: Option, auto_switch: bool) -> anyhow::Result<()> { self.make_request(Request::SetProfile { name, auto_switch }) .await diff --git a/lact-daemon/src/config.rs b/lact-daemon/src/config.rs index 2603c054..f07b8889 100644 --- a/lact-daemon/src/config.rs +++ b/lact-daemon/src/config.rs @@ -1,7 +1,7 @@ use crate::server::gpu_controller::{GpuController, VENDOR_NVIDIA}; use anyhow::Context; use indexmap::IndexMap; -use lact_schema::{config::GpuConfig, ProfileRule}; +use lact_schema::config::{GpuConfig, Profile}; use nix::unistd::{getuid, Group}; use notify::{RecommendedWatcher, Watcher}; use serde::{Deserialize, Serialize}; @@ -87,14 +87,6 @@ impl Default for Daemon { } } -#[skip_serializing_none] -#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] -pub struct Profile { - #[serde(default, skip_serializing_if = "IndexMap::is_empty")] - pub gpus: IndexMap, - pub rule: Option, -} - impl Config { pub fn load() -> anyhow::Result> { let path = get_path(FILE_NAME); diff --git a/lact-daemon/src/server.rs b/lact-daemon/src/server.rs index aafca8b4..91181cd2 100644 --- a/lact-daemon/src/server.rs +++ b/lact-daemon/src/server.rs @@ -171,6 +171,9 @@ async fn handle_request<'a>(request: Request<'a>, handler: &'a Handler) -> anyho Request::ListProfiles { include_state } => { ok_response(handler.list_profiles(include_state).await) } + Request::GetProfile { name } => { + ok_response(handler.get_profile(name.map(Into::into)).await?) + } Request::SetProfile { name, auto_switch } => ok_response( handler .set_profile(name.map(Into::into), auto_switch) diff --git a/lact-daemon/src/server/handler.rs b/lact-daemon/src/server/handler.rs index 919785c5..4ed43d46 100644 --- a/lact-daemon/src/server/handler.rs +++ b/lact-daemon/src/server/handler.rs @@ -5,7 +5,7 @@ use super::{ }; use crate::{ bindings::intel::IntelDrm, - config::{Config, Profile}, + config::Config, server::{gpu_controller::init_controller, profiles, system::DAEMON_VERSION}, }; use amdgpu_sysfs::gpu_handle::{ @@ -13,7 +13,7 @@ use amdgpu_sysfs::gpu_handle::{ }; use anyhow::{anyhow, bail, Context}; use lact_schema::{ - config::{default_fan_static_speed, FanControlSettings, FanCurve, GpuConfig}, + config::{default_fan_static_speed, FanControlSettings, FanCurve, GpuConfig, Profile}, default_fan_curve, request::{ClockspeedType, ConfirmCommand, ProfileBase, SetClocksCommand}, ClocksInfo, DeviceInfo, DeviceListEntry, DeviceStats, FanControlMode, FanOptions, PmfwOptions, @@ -817,6 +817,16 @@ impl<'a> Handler { } } + pub async fn get_profile(&self, name: Option>) -> anyhow::Result> { + let config = self.config.read().await; + + let profile = match name { + Some(profile) => config.profiles.get(&profile).cloned(), + None => Some(config.default_profile()), + }; + Ok(profile) + } + pub async fn set_profile( &self, name: Option>, @@ -860,6 +870,7 @@ impl<'a> Handler { ProfileBase::Empty => Profile::default(), ProfileBase::Default => config.default_profile(), ProfileBase::Profile(name) => config.profile(&name)?.clone(), + ProfileBase::Provided(profile) => profile, }; config.profiles.insert(name.into(), profile); config.save(&self.config_last_saved)?; diff --git a/lact-gui/Cargo.toml b/lact-gui/Cargo.toml index dc15b850..a48a2f57 100644 --- a/lact-gui/Cargo.toml +++ b/lact-gui/Cargo.toml @@ -22,6 +22,7 @@ tracing-subscriber = { workspace = true } chrono = { workspace = true } serde = { workspace = true } serde_yaml = { workspace = true } +serde_json = { workspace = true } indexmap = { workspace = true } gtk = { version = "0.9", package = "gtk4", features = ["v4_6"] } diff --git a/lact-gui/src/app.rs b/lact-gui/src/app.rs index 10e93736..211ebce5 100644 --- a/lact-gui/src/app.rs +++ b/lact-gui/src/app.rs @@ -29,8 +29,8 @@ use header::{ use lact_client::{ConnectionStatusMsg, DaemonClient}; use lact_schema::{ args::GuiArgs, - config::GpuConfig, - request::{ConfirmCommand, SetClocksCommand}, + config::{GpuConfig, Profile}, + request::{ConfirmCommand, ProfileBase, SetClocksCommand}, DeviceStats, GIT_COMMIT, }; use msg::AppMsg; @@ -47,8 +47,14 @@ use relm4::{ prelude::{AsyncComponent, AsyncComponentParts}, tokio, AsyncComponentSender, Component, ComponentController, MessageBroker, RelmObjectExt, }; +use relm4_components::{ + open_dialog::{OpenDialog, OpenDialogMsg, OpenDialogResponse, OpenDialogSettings}, + save_dialog::{SaveDialog, SaveDialogMsg, SaveDialogResponse, SaveDialogSettings}, +}; use std::{ + fs, os::unix::net::UnixStream, + path::PathBuf, rc::Rc, sync::{ atomic::{AtomicBool, AtomicU32, Ordering}, @@ -80,13 +86,19 @@ pub struct AppModel { stats_task_handle: Option>, } +#[derive(Debug)] +pub enum CommandOutput { + ProfileImport(PathBuf), + Error(anyhow::Error), +} + #[relm4::component(pub, async)] impl AsyncComponent for AppModel { type Init = GuiArgs; type Input = AppMsg; type Output = (); - type CommandOutput = (); + type CommandOutput = Option; view! { #[root] @@ -259,6 +271,19 @@ impl AsyncComponent for AppModel { } self.update_view(widgets, sender); } + + async fn update_cmd( + &mut self, + msg: Self::CommandOutput, + sender: AsyncComponentSender, + _root: &Self::Root, + ) { + if let Some(msg) = msg { + if let Err(err) = self.handle_cmd_output(msg, &sender).await { + sender.input(AppMsg::Error(Arc::new(err))); + } + } + } } impl AppModel { @@ -314,6 +339,28 @@ impl AppModel { include_state: false, }); } + AppMsg::RenameProfile(old_name, new_name) => { + if old_name != new_name { + let original_profile = self + .daemon_client + .get_profile(Some(old_name.clone())) + .await + .context("Could not get profile by old name")? + .context("Original profile not found")?; + self.daemon_client + .create_profile(new_name, ProfileBase::Provided(original_profile)) + .await + .context("Could not create new profile")?; + self.daemon_client + .delete_profile(old_name) + .await + .context("Could not delete old name")?; + + sender.input(AppMsg::ReloadProfiles { + include_state: false, + }); + } + } AppMsg::DeleteProfile(profile) => { self.daemon_client.delete_profile(profile).await?; sender.input(AppMsg::ReloadProfiles { @@ -326,6 +373,56 @@ impl AppModel { include_state: false, }); } + AppMsg::ImportProfile => { + let json_filter = gtk::FileFilter::new(); + json_filter.add_mime_type("application/json"); + + let settings = OpenDialogSettings { + filters: vec![json_filter], + ..Default::default() + }; + let file_picker = OpenDialog::builder().launch(settings); + file_picker.emit(OpenDialogMsg::Open); + let stream = file_picker.into_stream(); + + sender.oneshot_command(async move { + if let Some(OpenDialogResponse::Accept(path)) = stream.recv_one().await { + Some(CommandOutput::ProfileImport(path)) + } else { + None + } + }); + } + AppMsg::ExportProfile(name) => { + if let Some(profile) = self.daemon_client.get_profile(name.clone()).await? { + let settings = SaveDialogSettings { + create_folders: true, + is_modal: true, + ..Default::default() + }; + let diag = SaveDialog::builder().launch(settings); + diag.emit(SaveDialogMsg::SaveAs(format!( + "LACT-profile-{}.json", + name.as_deref().unwrap_or("default") + ))); + + let stream = diag.into_stream(); + + sender.oneshot_command(async move { + if let Some(SaveDialogResponse::Accept(path)) = stream.recv_one().await { + let contents = serde_json::to_string(&profile) + .expect("Could not serialize profile"); + + if let Err(err) = + fs::write(path, contents).context("Could not export profile") + { + return Some(CommandOutput::Error(err)); + } + } + None + }); + } + } AppMsg::Stats(stats) => { let update = PageUpdate::Stats(stats.clone()); self.oc_page.emit(OcPageMsg::Update { @@ -423,6 +520,40 @@ impl AppModel { Ok(()) } + async fn handle_cmd_output( + &mut self, + msg: CommandOutput, + sender: &AsyncComponentSender, + ) -> anyhow::Result<()> { + match msg { + CommandOutput::ProfileImport(path) => { + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("Imported profile"); + + let contents = fs::read_to_string(&path).context("Could not read selected file")?; + let profile = serde_json::from_str::(&contents) + .context("Could not parse profile")?; + let profile_name = file_name + .trim_start_matches("LACT-profile-") + .trim_end_matches(".json"); + + self.daemon_client + .create_profile(profile_name.to_owned(), ProfileBase::Provided(profile)) + .await + .context("Could not import profile")?; + + sender.input(AppMsg::ReloadProfiles { + include_state: false, + }); + } + CommandOutput::Error(error) => return Err(error), + } + + Ok(()) + } + fn current_gpu_id(&self) -> anyhow::Result { self.header .model() diff --git a/lact-gui/src/app/header.rs b/lact-gui/src/app/header.rs index 055d39d6..678613db 100644 --- a/lact-gui/src/app/header.rs +++ b/lact-gui/src/app/header.rs @@ -1,4 +1,5 @@ mod new_profile_dialog; +mod profile_rename_dialog; mod profile_row; pub mod profile_rule_window; @@ -11,6 +12,7 @@ use gtk::*; use lact_client::schema::DeviceListEntry; use lact_schema::ProfilesInfo; use new_profile_dialog::NewProfileDialog; +use profile_rename_dialog::ProfileRenameDialog; use profile_row::{ProfileRow, ProfileRowType}; use profile_rule_window::{ProfileRuleWindow, ProfileRuleWindowMsg}; use relm4::{ @@ -37,9 +39,12 @@ pub enum HeaderMsg { Profiles(std::boxed::Box), AutoProfileSwitch(bool), ShowProfileEditor(DynamicIndex), + ExportProfile(DynamicIndex), + RenameProfile(DynamicIndex), SelectProfile, SelectGpu, CreateProfile, + ImportProfile, ClosePopover, } @@ -120,8 +125,16 @@ impl Component for Header { gtk::Button { set_expand: true, set_icon_name: "list-add", + set_tooltip: "Add new profile", connect_clicked => HeaderMsg::CreateProfile, }, + + gtk::Button { + set_icon_name: "document-import-symbolic", + set_tooltip: "Import profile from file", + set_expand: true, + connect_clicked => HeaderMsg::ImportProfile, + } }, } }, @@ -282,6 +295,20 @@ impl Component for Header { } } } + HeaderMsg::ExportProfile(index) => { + sender.input(HeaderMsg::ClosePopover); + + let profile = self + .profile_selector + .get(index.current_index()) + .expect("No profile with given index"); + + let name = match &profile.row { + ProfileRowType::Default => None, + ProfileRowType::Profile { name, .. } => Some(name.clone()), + }; + sender.output(AppMsg::ExportProfile(name)).unwrap(); + } HeaderMsg::CreateProfile => { sender.input(HeaderMsg::ClosePopover); @@ -292,6 +319,33 @@ impl Component for Header { }); diag_controller.detach_runtime(); } + HeaderMsg::RenameProfile(index) => { + sender.input(HeaderMsg::ClosePopover); + + let profile = self + .profile_selector + .get(index.current_index()) + .expect("No profile with given index"); + + let sender = sender.clone(); + if let ProfileRowType::Profile { name, .. } = profile.row.clone() { + let stream = ProfileRenameDialog::builder() + .launch(name.clone()) + .into_stream(); + + sender.clone().oneshot_command(async move { + if let Some(new_name) = stream.recv_one().await { + sender + .output(AppMsg::RenameProfile(name, new_name)) + .unwrap(); + } + }); + } + } + HeaderMsg::ImportProfile => { + sender.input(HeaderMsg::ClosePopover); + sender.output(AppMsg::ImportProfile).unwrap(); + } HeaderMsg::ShowProfileEditor(index) => { sender.input(HeaderMsg::ClosePopover); diff --git a/lact-gui/src/app/header/profile_rename_dialog.rs b/lact-gui/src/app/header/profile_rename_dialog.rs new file mode 100644 index 00000000..9a7254ed --- /dev/null +++ b/lact-gui/src/app/header/profile_rename_dialog.rs @@ -0,0 +1,66 @@ +use gtk::prelude::{ + BoxExt, DialogExt, DialogExtManual, EditableExt, EntryExt, GtkWindowExt, OrientableExt, + WidgetExt, +}; +use relm4::{ComponentParts, ComponentSender, RelmWidgetExt}; + +pub struct ProfileRenameDialog {} + +#[relm4::component(pub)] +impl relm4::SimpleComponent for ProfileRenameDialog { + type Init = String; + type Input = (); + type Output = String; + + view! { + gtk::Dialog { + set_default_size: (400, 50), + set_title: Some("Rename profile"), + set_hide_on_close: true, + connect_response[root, sender, name_entry] => move |_, response| { + match response { + gtk::ResponseType::Accept => { + sender.output(name_entry.text().to_string()).unwrap(); + root.close(); + } + gtk::ResponseType::Cancel => root.close(), + _ => (), + } + }, + add_buttons: &[("Cancel", gtk::ResponseType::Cancel), ("Save", gtk::ResponseType::Accept)], + + gtk::Box { + set_orientation: gtk::Orientation::Horizontal, + set_margin_all: 5, + set_spacing: 5, + + gtk::Label { + set_markup: &format!("Rename profile {old_name} to:"), + }, + + #[name = "name_entry"] + gtk::Entry { + set_text: &old_name, + set_hexpand: true, + connect_activate[root] => move |_| { + root.response(gtk::ResponseType::Accept); + } + }, + } + }, + } + + fn init( + old_name: Self::Init, + root: Self::Root, + sender: ComponentSender, + ) -> ComponentParts { + let model = Self {}; + + let widgets = view_output!(); + + root.present(); + + ComponentParts { widgets, model } + } +} diff --git a/lact-gui/src/app/header/profile_row.rs b/lact-gui/src/app/header/profile_row.rs index 20928694..c3680975 100644 --- a/lact-gui/src/app/header/profile_row.rs +++ b/lact-gui/src/app/header/profile_row.rs @@ -3,6 +3,7 @@ use crate::app::{msg::AppMsg, APP_BROKER}; use gtk::{pango, prelude::*}; use lact_schema::ProfileRule; use relm4::{ + css, factory::{DynamicIndex, FactoryComponent}, FactorySender, RelmWidgetExt, }; @@ -56,12 +57,53 @@ impl FactoryComponent for ProfileRow { set_width_request: 200, }, - gtk::Button { - set_icon_name: "preferences-other-symbolic", - set_tooltip: "Edit Profile Rules", - set_sensitive: matches!(self.row, ProfileRowType::Profile { auto: true, .. }), - connect_clicked[sender, index] => move |_| { - sender.output(HeaderMsg::ShowProfileEditor(index.clone())).unwrap(); + gtk::MenuButton { + set_icon_name: "open-menu-symbolic", + #[wrap(Some)] + set_popover = >k::Popover { + set_margin_all: 5, + + gtk::Box { + set_orientation: gtk::Orientation::Vertical, + set_spacing: 5, + + gtk::Button { + set_label: "Rename Profile", + set_sensitive: matches!(self.row, ProfileRowType::Profile { .. }), + connect_clicked[sender, index] => move |_| { + sender.output(HeaderMsg::RenameProfile(index.clone())).unwrap(); + }, + add_css_class: css::FLAT, + }, + + gtk::Button { + set_label: "Delete Profile", + set_sensitive: matches!(self.row, ProfileRowType::Profile { .. }), + connect_clicked[profile = self.row.clone()] => move |_| { + if let ProfileRowType::Profile { name, .. } = profile.clone() { + APP_BROKER.send(AppMsg::DeleteProfile(name)); + } + }, + add_css_class: css::FLAT, + }, + + gtk::Button { + set_label: "Edit Activation Rules", + set_sensitive: matches!(self.row, ProfileRowType::Profile { auto: true, .. }), + connect_clicked[sender, index] => move |_| { + sender.output(HeaderMsg::ShowProfileEditor(index.clone())).unwrap(); + }, + add_css_class: css::FLAT, + }, + + gtk::Button { + set_label: "Export To File", + connect_clicked[sender, index] => move |_| { + sender.output(HeaderMsg::ExportProfile(index.clone())).unwrap(); + }, + add_css_class: css::FLAT, + }, + }, } }, @@ -90,17 +132,6 @@ impl FactoryComponent for ProfileRow { APP_BROKER.send(move_profile_msg(&profile, &index, 1)); }, }, - - gtk::Button { - set_icon_name: "list-remove", - set_sensitive: matches!(self.row, ProfileRowType::Profile { .. }), - set_tooltip: "Delete Profile", - connect_clicked[profile = self.row.clone()] => move |_| { - if let ProfileRowType::Profile { name, .. } = profile.clone() { - APP_BROKER.send(AppMsg::DeleteProfile(name)); - } - }, - }, } } diff --git a/lact-gui/src/app/msg.rs b/lact-gui/src/app/msg.rs index aa611b29..344de675 100644 --- a/lact-gui/src/app/msg.rs +++ b/lact-gui/src/app/msg.rs @@ -31,11 +31,14 @@ pub enum AppMsg { CreateProfile(String, ProfileBase), DeleteProfile(String), MoveProfile(String, usize), + RenameProfile(String, String), EvaluateProfile(ProfileRule), SetProfileRule { name: String, rule: Option, }, + ImportProfile, + ExportProfile(Option), ConnectionStatus(ConnectionStatusMsg), AskConfirmation(ConfirmationOptions, Box), } diff --git a/lact-schema/src/config.rs b/lact-schema/src/config.rs index a70f4264..948d246b 100644 --- a/lact-schema/src/config.rs +++ b/lact-schema/src/config.rs @@ -6,9 +6,17 @@ use serde_with::skip_serializing_none; use crate::{ default_fan_curve, request::{ClockspeedType, SetClocksCommand}, - FanControlMode, FanCurveMap, PmfwOptions, + FanControlMode, FanCurveMap, PmfwOptions, ProfileRule, }; +#[skip_serializing_none] +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +pub struct Profile { + #[serde(default, skip_serializing_if = "IndexMap::is_empty")] + pub gpus: IndexMap, + pub rule: Option, +} + #[skip_serializing_none] #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] pub struct GpuConfig { diff --git a/lact-schema/src/request.rs b/lact-schema/src/request.rs index ae33a17e..8ee4bccf 100644 --- a/lact-schema/src/request.rs +++ b/lact-schema/src/request.rs @@ -1,6 +1,9 @@ use std::fmt; -use crate::{config::GpuConfig, FanOptions, ProfileRule}; +use crate::{ + config::{GpuConfig, Profile}, + FanOptions, ProfileRule, +}; use amdgpu_sysfs::gpu_handle::{PerformanceLevel, PowerLevelKind}; use serde::{Deserialize, Serialize}; @@ -63,6 +66,9 @@ pub enum Request<'a> { #[serde(default)] include_state: bool, }, + GetProfile { + name: Option, + }, SetProfile { name: Option, #[serde(default)] @@ -143,6 +149,7 @@ pub enum ProfileBase { Empty, Default, Profile(String), + Provided(Profile), } impl fmt::Display for ProfileBase { @@ -151,6 +158,7 @@ impl fmt::Display for ProfileBase { ProfileBase::Empty => "Empty", ProfileBase::Default => "Default", ProfileBase::Profile(name) => name, + ProfileBase::Provided(_) => "", }; text.fmt(f) }