feat: support exporting and importing profiles (#579)

* feat: support exporting and importing profiles

* feat: renaming profiles
This commit is contained in:
Ilya Zlobintsev
2025-05-18 14:51:03 +03:00
committed by GitHub
parent ba5ee6b910
commit 243a1ecd45
13 changed files with 350 additions and 34 deletions
Generated
+1
View File
@@ -1382,6 +1382,7 @@ dependencies = [
"relm4",
"relm4-components",
"serde",
"serde_json",
"serde_yaml",
"thread-priority",
"tracing",
+8 -1
View File
@@ -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<String>) -> anyhow::Result<Option<Profile>> {
self.make_request(Request::GetProfile { name }).await
}
pub async fn set_profile(&self, name: Option<String>, auto_switch: bool) -> anyhow::Result<()> {
self.make_request(Request::SetProfile { name, auto_switch })
.await
+1 -9
View File
@@ -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<String, GpuConfig>,
pub rule: Option<ProfileRule>,
}
impl Config {
pub fn load() -> anyhow::Result<Option<Self>> {
let path = get_path(FILE_NAME);
+3
View File
@@ -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)
+13 -2
View File
@@ -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<Rc<str>>) -> anyhow::Result<Option<Profile>> {
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<Rc<str>>,
@@ -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)?;
+1
View File
@@ -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"] }
+134 -3
View File
@@ -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<glib::JoinHandle<()>>,
}
#[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<CommandOutput>;
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<Self>,
_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<AppModel>,
) -> 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::<Profile>(&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<String> {
self.header
.model()
+54
View File
@@ -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<ProfilesInfo>),
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);
@@ -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 <b>{old_name}</b> 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<Self>,
) -> ComponentParts<Self> {
let model = Self {};
let widgets = view_output!();
root.present();
ComponentParts { widgets, model }
}
}
+48 -17
View File
@@ -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 = &gtk::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));
}
},
},
}
}
+3
View File
@@ -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<ProfileRule>,
},
ImportProfile,
ExportProfile(Option<String>),
ConnectionStatus(ConnectionStatusMsg),
AskConfirmation(ConfirmationOptions, Box<AppMsg>),
}
+9 -1
View File
@@ -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<String, GpuConfig>,
pub rule: Option<ProfileRule>,
}
#[skip_serializing_none]
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct GpuConfig {
+9 -1
View File
@@ -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<String>,
},
SetProfile {
name: Option<String>,
#[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(_) => "<Provided>",
};
text.fmt(f)
}