From 64a2d3106b8b8f506961e06b3f441d616b016cd7 Mon Sep 17 00:00:00 2001 From: Ilya Zlobintsev Date: Wed, 25 Dec 2024 23:52:35 +0200 Subject: [PATCH] feat: automatic profile switching based on rules (#424) * feat: profile ordering * feat: profile matching logic * feat: automatic switching * perf: optimize profile rules evaluation * feat: detect gamemode process start * feat: add benchmark for profiles matching * perf: string interner, inline hot function * feat: option to enable auto profile switching * wip * chore: change label * chore: minor profile switching fixes * chore: drop interner * wip * refactor: header messages * feat: delay profile evaluation * fix: moving profiles around * feat: initial profile rule editor UI * refactor: only create one RuleWindow instead of creating it per-row * feat: add option to include profile watcher state in api response * feat: ui for selecting process name * feat: API endpoint to evaluate a single profile rule * perf: store process names in a map to speed up profile evaluation * feat: full configurability in the UI, many improvements * fix: downgrade dependency to build on older rust * fix: deleting currently active profile * fix: populate profiles list when it's empty * fix: pin project to rust 1.78 * fix: setting args * fix: running benchmarks --- Cargo.lock | 215 +++++++- Cargo.toml | 2 + lact-client/src/lib.rs | 34 +- lact-daemon/Cargo.toml | 13 +- lact-daemon/benches/daemon.rs | 5 + lact-daemon/src/config.rs | 44 +- lact-daemon/src/lib.rs | 13 +- lact-daemon/src/server.rs | 22 +- lact-daemon/src/server/handler.rs | 189 ++++++- lact-daemon/src/server/profiles.rs | 397 +++++++++++++++ lact-daemon/src/server/profiles/gamemode.rs | 143 ++++++ lact-daemon/src/server/profiles/process.rs | 77 +++ lact-gui/src/app.rs | 101 +++- lact-gui/src/app/graphs_window/mod.rs | 4 +- lact-gui/src/app/header.rs | 299 ++++++----- lact-gui/src/app/header/new_profile_dialog.rs | 2 +- lact-gui/src/app/header/profile_row.rs | 116 +++++ .../src/app/header/profile_rule_window.rs | 475 ++++++++++++++++++ lact-gui/src/app/msg.rs | 27 +- lact-gui/src/app/pages.rs | 7 +- lact-gui/src/lib.rs | 10 +- lact-schema/src/lib.rs | 66 ++- lact-schema/src/profiles.rs | 42 ++ lact-schema/src/request.rs | 20 +- rust-toolchain.toml | 2 + 25 files changed, 2094 insertions(+), 231 deletions(-) create mode 100644 lact-daemon/benches/daemon.rs create mode 100644 lact-daemon/src/server/profiles.rs create mode 100644 lact-daemon/src/server/profiles/gamemode.rs create mode 100644 lact-daemon/src/server/profiles/process.rs create mode 100644 lact-gui/src/app/header/profile_row.rs create mode 100644 lact-gui/src/app/header/profile_rule_window.rs create mode 100644 lact-schema/src/profiles.rs create mode 100644 rust-toolchain.toml diff --git a/Cargo.lock b/Cargo.lock index 1890378..552bf51 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -286,6 +286,29 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "bindgen" +version = "0.68.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "726e4313eb6ec35d2730258ad4e15b547ee75d6afaa1361a922e78e59b7d8078" +dependencies = [ + "bitflags 2.6.0", + "cexpr", + "clang-sys", + "lazy_static", + "lazycell", + "log", + "peeking_take_while", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn", + "which", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -390,6 +413,15 @@ dependencies = [ "shlex", ] +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + [[package]] name = "cfg-expr" version = "0.17.0" @@ -426,6 +458,17 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading 0.8.5", +] + [[package]] name = "clap" version = "4.5.20" @@ -446,6 +489,7 @@ dependencies = [ "anstyle", "clap_lex", "strsim", + "terminal_size", ] [[package]] @@ -481,6 +525,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "condtype" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf0a07a401f374238ab8e2f11a104d2851bf9ce711ec69804834de8af45c7af" + [[package]] name = "console" version = "0.15.10" @@ -493,6 +543,21 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "copes" +version = "1.1.0-dev" +source = "git+https://gitlab.com/corectrl/copes#1bc002a030345787f0e11e0317975a2e4f2a22ee" +dependencies = [ + "anyhow", + "bindgen", + "clap", + "ctrlc", + "libc", + "log", + "simple_logger", + "termcolor", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -578,6 +643,16 @@ dependencies = [ "typenum", ] +[[package]] +name = "ctrlc" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90eeab0aa92f3f9b4e87f258c72b139c207d251f9cbc1080a0086b86a8870dd3" +dependencies = [ + "nix", + "windows-sys 0.59.0", +] + [[package]] name = "darling" version = "0.20.10" @@ -644,6 +719,31 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "divan" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e05d17bd4ff1c1e7998ed4623d2efd91f72f1e24141ac33aac9377974270e1f" +dependencies = [ + "cfg-if", + "clap", + "condtype", + "divan-macros", + "libc", + "regex-lite", +] + +[[package]] +name = "divan-macros" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b4464d46ce68bfc7cb76389248c7c254def7baca8bece0693b02b83842c4c88" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "either" version = "1.13.0" @@ -1046,6 +1146,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "glob" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" + [[package]] name = "gobject-sys" version = "0.20.4" @@ -1220,6 +1326,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "home" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3d1354bf6b7235cb4a0576c2619fd4ed18183f689b12b006a0ee7329eeff9a5" +dependencies = [ + "windows-sys 0.52.0", +] + [[package]] name = "iana-time-zone" version = "0.1.61" @@ -1387,9 +1502,12 @@ dependencies = [ "anyhow", "bitflags 2.6.0", "chrono", + "copes", + "divan", "futures", "indexmap", "insta", + "lact-daemon", "lact-schema", "libdrm_amdgpu_sys", "libflate", @@ -1398,6 +1516,8 @@ dependencies = [ "nvml-wrapper", "os-release", "pciid-parser", + "pretty_assertions", + "regex", "serde", "serde_json", "serde_with", @@ -1455,6 +1575,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + [[package]] name = "libadwaita" version = "0.7.1" @@ -1542,7 +1668,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4979f22fdb869068da03c9f7528f8297c6fd2606bc3a4affe42e6a823fdb8da4" dependencies = [ "cfg-if", - "windows-targets 0.52.6", + "windows-targets 0.48.5", ] [[package]] @@ -1617,6 +1743,12 @@ dependencies = [ "autocfg", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.0" @@ -1672,6 +1804,16 @@ dependencies = [ "memoffset", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "notify" version = "6.1.1" @@ -1855,6 +1997,12 @@ dependencies = [ "serde", ] +[[package]] +name = "peeking_take_while" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" + [[package]] name = "pin-project-lite" version = "0.2.15" @@ -1952,6 +2100,16 @@ dependencies = [ "yansi", ] +[[package]] +name = "prettyplease" +version = "0.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64d1ec885c64d0457d564db4ec299b2dae3f9c02808b8ad9c3a089c591b18033" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro-crate" version = "3.2.0" @@ -2056,6 +2214,12 @@ dependencies = [ "regex-syntax 0.8.5", ] +[[package]] +name = "regex-lite" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53a49587ad06b26609c52e423de037e7f57f20d53535d66e08c695f347df952a" + [[package]] name = "regex-syntax" version = "0.6.29" @@ -2126,6 +2290,12 @@ version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustc_version" version = "0.4.1" @@ -2319,6 +2489,16 @@ version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1de1d4f81173b03af4c0cbed3c898f6bff5b870e4a7f5d6f4057d62a7a4b686e" +[[package]] +name = "simple_logger" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8c5dfa5e08767553704aa0ffd9d9794d527103c736aba9854773851fd7497eb" +dependencies = [ + "log", + "windows-sys 0.48.0", +] + [[package]] name = "slab" version = "0.4.9" @@ -2419,6 +2599,25 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "terminal_size" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5352447f921fda68cf61b4101566c0bdb5104eff6804d0678e5227580ab6a4e9" +dependencies = [ + "rustix", + "windows-sys 0.59.0", +] + [[package]] name = "thiserror" version = "1.0.68" @@ -2824,6 +3023,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix", +] + [[package]] name = "winapi" version = "0.3.9" @@ -2846,7 +3057,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.48.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index c8fb2be..02f2240 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,8 @@ tokio = { version = "1.35.1", default-features = false } nix = { version = "0.29.0", default-features = false } chrono = "0.4.31" indexmap = { version = "2.5.0", features = ["serde"] } +pretty_assertions = "1.4.0" +divan = "0.1" [profile.release] strip = "symbols" diff --git a/lact-client/src/lib.rs b/lact-client/src/lib.rs index d327850..7d57b2c 100644 --- a/lact-client/src/lib.rs +++ b/lact-client/src/lib.rs @@ -3,6 +3,7 @@ mod connection; mod macros; pub use lact_schema as schema; +use lact_schema::ProfileRule; use amdgpu_sysfs::gpu_handle::{ power_profile_mode::PowerProfileModesTable, PerformanceLevel, PowerLevelKind, @@ -133,7 +134,6 @@ impl DaemonClient { request_plain!(disable_overdrive, DisableOverdrive, String); request_plain!(generate_debug_snapshot, GenerateSnapshot, String); request_plain!(reset_config, RestConfig, ()); - request_plain!(list_profiles, ListProfiles, ProfilesInfo); request_with_id!(get_device_info, DeviceInfo, DeviceInfo); request_with_id!(get_device_stats, DeviceStats, DeviceStats); request_with_id!(get_device_clocks_info, DeviceClocksInfo, ClocksInfo); @@ -146,8 +146,14 @@ impl DaemonClient { request_with_id!(reset_pmfw, ResetPmfw, u64); request_with_id!(dump_vbios, VbiosDump, Vec); - pub async fn set_profile(&self, name: Option) -> anyhow::Result<()> { - self.make_request(Request::SetProfile { name }) + pub async fn list_profiles(&self, include_state: bool) -> anyhow::Result { + self.make_request(Request::ListProfiles { include_state }) + .await? + .inner() + } + + pub async fn set_profile(&self, name: Option, auto_switch: bool) -> anyhow::Result<()> { + self.make_request(Request::SetProfile { name, auto_switch }) .await? .inner() } @@ -164,6 +170,28 @@ impl DaemonClient { .inner() } + pub async fn move_profile(&self, name: String, new_position: usize) -> anyhow::Result<()> { + self.make_request(Request::MoveProfile { name, new_position }) + .await? + .inner() + } + + pub async fn evaluate_profile_rule(&self, rule: ProfileRule) -> anyhow::Result { + self.make_request(Request::EvaluateProfileRule { rule }) + .await? + .inner() + } + + pub async fn set_profile_rule( + &self, + name: String, + rule: Option, + ) -> anyhow::Result<()> { + self.make_request(Request::SetProfileRule { name, rule }) + .await? + .inner() + } + pub async fn set_performance_level( &self, id: &str, diff --git a/lact-daemon/Cargo.toml b/lact-daemon/Cargo.toml index b728e23..c6cee72 100644 --- a/lact-daemon/Cargo.toml +++ b/lact-daemon/Cargo.toml @@ -5,6 +5,7 @@ edition = "2021" [features] default = [] +bench = ["dep:divan"] [dependencies] lact-schema = { path = "../lact-schema" } @@ -12,7 +13,7 @@ lact-schema = { path = "../lact-schema" } amdgpu-sysfs = { workspace = true } anyhow = { workspace = true } tracing = { workspace = true } -serde = { workspace = true } +serde = { workspace = true, features = ["rc"] } serde_with = { workspace = true } serde_json = { workspace = true } tracing-subscriber = { workspace = true } @@ -28,6 +29,7 @@ tokio = { workspace = true, features = [ ] } futures = { workspace = true } indexmap = { workspace = true } +divan = { workspace = true, optional = true } nvml-wrapper = { git = "https://github.com/ilya-zlobintsev/nvml-wrapper", branch = "lact" } bitflags = "2.6.0" @@ -40,6 +42,15 @@ tar = "0.4.40" libflate = "2.0.0" os-release = "0.1.0" notify = { version = "6.1.1", default-features = false } +regex = "1.11.0" +copes = { git = "https://gitlab.com/corectrl/copes" } [dev-dependencies] +divan = { workspace = true } +pretty_assertions = { workspace = true } +lact-daemon = { path = ".", features = ["bench"] } insta = { version = "1.41.1", features = ["json"] } + +[[bench]] +name = "daemon" +harness = false diff --git a/lact-daemon/benches/daemon.rs b/lact-daemon/benches/daemon.rs new file mode 100644 index 0000000..f90e26e --- /dev/null +++ b/lact-daemon/benches/daemon.rs @@ -0,0 +1,5 @@ +fn main() { + // Include the daemon lib + let _ = lact_daemon::MODULE_CONF_PATH; + divan::main(); +} diff --git a/lact-daemon/src/config.rs b/lact-daemon/src/config.rs index 05cb202..18d8d48 100644 --- a/lact-daemon/src/config.rs +++ b/lact-daemon/src/config.rs @@ -2,16 +2,19 @@ use crate::server::gpu_controller::fan_control::FanCurve; use amdgpu_sysfs::gpu_handle::{PerformanceLevel, PowerLevelKind}; use anyhow::Context; use indexmap::IndexMap; -use lact_schema::{default_fan_curve, request::SetClocksCommand, FanControlMode, PmfwOptions}; +use lact_schema::{ + default_fan_curve, request::SetClocksCommand, FanControlMode, PmfwOptions, ProfileRule, +}; use nix::unistd::getuid; use notify::{RecommendedWatcher, Watcher}; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; use std::{ + cell::Cell, collections::HashMap, env, fs, path::PathBuf, - sync::{Arc, Mutex}, + rc::Rc, time::{Duration, Instant}, }; use tokio::{sync::mpsc, time}; @@ -29,12 +32,14 @@ pub struct Config { pub daemon: Daemon, #[serde(default = "default_apply_settings_timer")] pub apply_settings_timer: u64, - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - gpus: HashMap, #[serde(default, skip_serializing_if = "IndexMap::is_empty")] - pub profiles: IndexMap, + gpus: IndexMap, + #[serde(default, skip_serializing_if = "IndexMap::is_empty")] + pub profiles: IndexMap, Profile>, #[serde(default)] - pub current_profile: Option, + pub current_profile: Option>, + #[serde(default)] + pub auto_switch_profiles: bool, } impl Default for Config { @@ -42,9 +47,10 @@ impl Default for Config { Self { daemon: Daemon::default(), apply_settings_timer: default_apply_settings_timer(), - gpus: HashMap::new(), + gpus: IndexMap::new(), profiles: IndexMap::new(), current_profile: None, + auto_switch_profiles: false, } } } @@ -70,10 +76,12 @@ impl Default for Daemon { } } +#[skip_serializing_none] #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] pub struct Profile { - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub gpus: HashMap, + #[serde(default, skip_serializing_if = "IndexMap::is_empty")] + pub gpus: IndexMap, + pub rule: Option, } #[skip_serializing_none] @@ -177,14 +185,13 @@ impl Config { } } - pub fn save(&self, config_last_saved: &Mutex) -> anyhow::Result<()> { + pub fn save(&self, config_last_saved: &Cell) -> anyhow::Result<()> { let path = get_path(); debug!("saving config to {path:?}"); let raw_config = serde_yaml::to_string(self)?; - let mut instant_guard = config_last_saved.lock().unwrap(); fs::write(path, raw_config).context("Could not write config")?; - *instant_guard = Instant::now(); + config_last_saved.set(Instant::now()); Ok(()) } @@ -194,13 +201,13 @@ impl Config { Ok(config) } else { let config = Config::default(); - config.save(&Mutex::new(Instant::now()))?; + config.save(&Cell::new(Instant::now()))?; Ok(config) } } /// Gets the GPU configs according to the current profile. Returns an error if the current profile could not be found. - pub fn gpus(&self) -> anyhow::Result<&HashMap> { + pub fn gpus(&self) -> anyhow::Result<&IndexMap> { match &self.current_profile { Some(profile) => { let profile = self @@ -214,7 +221,7 @@ impl Config { } /// Same as [`gpus`], but with a mutable reference - pub fn gpus_mut(&mut self) -> anyhow::Result<&mut HashMap> { + pub fn gpus_mut(&mut self) -> anyhow::Result<&mut IndexMap> { match &self.current_profile { Some(profile) => { let profile = self @@ -238,6 +245,7 @@ impl Config { pub fn default_profile(&self) -> Profile { Profile { gpus: self.gpus.clone(), + rule: None, } } @@ -248,11 +256,11 @@ impl Config { } } -pub fn start_watcher(config_last_saved: Arc>) -> mpsc::UnboundedReceiver { +pub fn start_watcher(config_last_saved: Rc>) -> mpsc::UnboundedReceiver { let (config_tx, config_rx) = mpsc::unbounded_channel(); let (event_tx, mut event_rx) = mpsc::channel(64); - tokio::spawn(async move { + tokio::task::spawn_local(async move { let mut watcher = RecommendedWatcher::new(SenderEventHandler(event_tx), notify::Config::default()) .expect("Could not create config file watcher"); @@ -274,7 +282,7 @@ pub fn start_watcher(config_last_saved: Arc>) -> mpsc::UnboundedR if let EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_) = event.kind { - if config_last_saved.lock().unwrap().elapsed() + if config_last_saved.get().elapsed() < Duration::from_millis(SELF_CONFIG_EDIT_PERIOD_MILLIS) { debug!("ignoring fs event after self-inflicted config change"); diff --git a/lact-daemon/src/lib.rs b/lact-daemon/src/lib.rs index 1f89cf4..d1a26cd 100644 --- a/lact-daemon/src/lib.rs +++ b/lact-daemon/src/lib.rs @@ -12,7 +12,6 @@ use anyhow::Context; use config::Config; use futures::future::select_all; use server::{handle_stream, handler::Handler, Server}; -use std::str::FromStr; use std::{os::unix::net::UnixStream as StdUnixStream, time::Duration}; use tokio::net::UnixStream; use tokio::{ @@ -20,7 +19,9 @@ use tokio::{ signal::unix::{signal, SignalKind}, task::LocalSet, }; -use tracing::{debug, debug_span, error, info, warn, Instrument, Level}; +use tracing::level_filters::LevelFilter; +use tracing::{debug, debug_span, error, info, warn, Instrument}; +use tracing_subscriber::EnvFilter; /// RDNA3, minimum family that supports the new pmfw interface pub const AMDGPU_FAMILY_GC_11_0_0: u32 = 145; @@ -47,8 +48,11 @@ pub fn run() -> anyhow::Result<()> { rt.block_on(async { let config = Config::load_or_create()?; - let max_level = Level::from_str(&config.daemon.log_level).context("Invalid log level")?; - tracing_subscriber::fmt().with_max_level(max_level).init(); + let env_filter = EnvFilter::builder() + .with_default_directive(LevelFilter::INFO.into()) + .parse(&config.daemon.log_level) + .context("Invalid log level")?; + tracing_subscriber::fmt().with_env_filter(env_filter).init(); ensure_sufficient_uptime().await; @@ -60,6 +64,7 @@ pub fn run() -> anyhow::Result<()> { tokio::task::spawn_local(listen_config_changes(handler.clone())); tokio::task::spawn_local(listen_exit_signals(handler.clone())); tokio::task::spawn_local(suspend::listen_events(handler)); + server.run().await; Ok(()) }) diff --git a/lact-daemon/src/server.rs b/lact-daemon/src/server.rs index 7668f81..0ab9132 100644 --- a/lact-daemon/src/server.rs +++ b/lact-daemon/src/server.rs @@ -1,5 +1,6 @@ pub mod gpu_controller; pub mod handler; +mod profiles; pub(crate) mod system; mod vulkan; @@ -164,10 +165,25 @@ async fn handle_request<'a>(request: Request<'a>, handler: &'a Handler) -> anyho ok_response(handler.set_enabled_power_states(id, kind, states).await?) } Request::VbiosDump { id } => ok_response(handler.vbios_dump(id)?), - Request::ListProfiles => ok_response(handler.list_profiles()), - Request::SetProfile { name } => ok_response(handler.set_profile(name).await?), - Request::CreateProfile { name, base } => ok_response(handler.create_profile(name, base)?), + Request::ListProfiles { include_state } => { + ok_response(handler.list_profiles(include_state)) + } + Request::SetProfile { name, auto_switch } => ok_response( + handler + .set_profile(name.map(Into::into), auto_switch) + .await?, + ), + Request::CreateProfile { name, base } => { + ok_response(handler.create_profile(name, base).await?) + } Request::DeleteProfile { name } => ok_response(handler.delete_profile(name).await?), + Request::MoveProfile { name, new_position } => { + ok_response(handler.move_profile(&name, new_position).await?) + } + Request::EvaluateProfileRule { rule } => ok_response(handler.evaluate_profile_rule(&rule)?), + Request::SetProfileRule { name, rule } => { + ok_response(handler.set_profile_rule(&name, rule).await?) + } Request::EnableOverdrive => ok_response(system::enable_overdrive().await?), Request::DisableOverdrive => ok_response(system::disable_overdrive().await?), Request::GenerateSnapshot => ok_response(handler.generate_snapshot().await?), diff --git a/lact-daemon/src/server/handler.rs b/lact-daemon/src/server/handler.rs index 3e0c065..d056cd8 100644 --- a/lact-daemon/src/server/handler.rs +++ b/lact-daemon/src/server/handler.rs @@ -1,10 +1,14 @@ use super::{ gpu_controller::{fan_control::FanCurve, GpuController}, + profiles::ProfileWatcherCommand, system::{self, detect_initramfs_type, PP_FEATURE_MASK_PATH}, }; use crate::{ config::{self, default_fan_static_speed, Config, FanControlSettings, Profile}, - server::gpu_controller::{AmdGpuController, NvidiaGpuController}, + server::{ + gpu_controller::{AmdGpuController, NvidiaGpuController}, + profiles, + }, }; use amdgpu_sysfs::{ gpu_handle::{power_profile_mode::PowerProfileModesTable, PerformanceLevel, PowerLevelKind}, @@ -15,7 +19,7 @@ use lact_schema::{ default_fan_curve, request::{ConfirmCommand, ProfileBase, SetClocksCommand}, ClocksInfo, DeviceInfo, DeviceListEntry, DeviceStats, FanControlMode, FanOptions, PmfwOptions, - PowerStates, ProfilesInfo, + PowerStates, ProfileRule, ProfileWatcherState, ProfilesInfo, }; use libflate::gzip; use nix::libc; @@ -24,7 +28,7 @@ use os_release::OS_RELEASE; use pciid_parser::Database; use serde_json::json; use std::{ - cell::RefCell, + cell::{Cell, RefCell}, collections::{BTreeMap, HashMap}, env, fs::{self, File, Permissions}, @@ -32,10 +36,13 @@ use std::{ os::unix::fs::{MetadataExt, PermissionsExt}, path::{Path, PathBuf}, rc::Rc, - sync::{Arc, Mutex}, time::{Duration, Instant}, }; -use tokio::{process::Command, sync::oneshot, time::sleep}; +use tokio::{ + process::Command, + sync::{mpsc, oneshot}, + time::sleep, +}; use tracing::{debug, error, info, trace, warn}; const CONTROLLERS_LOAD_RETRY_ATTEMPTS: u8 = 5; @@ -86,7 +93,9 @@ pub struct Handler { pub config: Rc>, pub gpu_controllers: Rc>>, confirm_config_tx: Rc>>>, - pub config_last_saved: Arc>, + pub config_last_saved: Rc>, + pub profile_watcher_tx: Rc>>>, + pub profile_watcher_state: Rc>>, } impl<'a> Handler { @@ -153,12 +162,22 @@ impl<'a> Handler { gpu_controllers: Rc::new(controllers), config: Rc::new(RefCell::new(config)), confirm_config_tx: Rc::new(RefCell::new(None)), - config_last_saved: Arc::new(Mutex::new(Instant::now())), + config_last_saved: Rc::new(Cell::new(Instant::now())), + profile_watcher_tx: Rc::new(RefCell::new(None)), + profile_watcher_state: Rc::new(RefCell::new(None)), }; if let Err(err) = handler.apply_current_config().await { error!("could not apply config: {err:#}"); } + if let Some(profile_name) = &handler.config.borrow().current_profile { + info!("using profile '{profile_name}'"); + } + + if handler.config.borrow().auto_switch_profiles { + handler.start_profile_watcher().await; + } + // Eagerly release memory // `load_controllers` allocates and deallocates the entire PCI ID database, // this tells the os to release it right away, lowering measured memory usage (the actual usage is low regardless as it was already deallocated) @@ -187,6 +206,22 @@ impl<'a> Handler { Ok(()) } + async fn stop_profile_watcher(&self) { + let tx = self.profile_watcher_tx.borrow_mut().take(); + if let Some(existing_stop_notify) = tx { + let _ = existing_stop_notify.send(ProfileWatcherCommand::Stop).await; + } + } + + pub async fn start_profile_watcher(&self) { + self.stop_profile_watcher().await; + + let (profile_watcher_tx, profile_watcher_rx) = mpsc::channel(5); + *self.profile_watcher_tx.borrow_mut() = Some(profile_watcher_tx); + tokio::task::spawn_local(profiles::run_watcher(self.clone(), profile_watcher_rx)); + info!("started new profile watcher"); + } + async fn edit_gpu_config( &self, id: String, @@ -265,6 +300,7 @@ impl<'a> Handler { match result { Ok(ConfirmCommand::Confirm) => { info!("saving updated config"); + let mut config_guard = handler.config.borrow_mut(); match config_guard.gpus_mut() { Ok(gpus) => { @@ -682,15 +718,44 @@ impl<'a> Handler { .collect() } - pub fn list_profiles(&self) -> ProfilesInfo { + pub fn list_profiles(&self, include_state: bool) -> ProfilesInfo { + let watcher_state = if include_state { + self.profile_watcher_state.borrow().as_ref().cloned() + } else { + None + }; + let config = self.config.borrow(); ProfilesInfo { - profiles: config.profiles.keys().cloned().collect(), - current_profile: config.current_profile.clone(), + profiles: config + .profiles + .iter() + .map(|(name, profile)| (name.to_string(), profile.rule.clone())) + .collect(), + current_profile: config.current_profile.as_ref().map(Rc::to_string), + auto_switch: config.auto_switch_profiles, + watcher_state, } } - pub async fn set_profile(&self, name: Option) -> anyhow::Result<()> { + pub async fn set_profile( + &self, + name: Option>, + auto_switch: bool, + ) -> anyhow::Result<()> { + if auto_switch { + self.start_profile_watcher().await; + } else { + self.stop_profile_watcher().await; + self.set_current_profile(name).await?; + } + self.config.borrow_mut().auto_switch_profiles = auto_switch; + self.config.borrow_mut().save(&self.config_last_saved)?; + + Ok(()) + } + + pub(super) async fn set_current_profile(&self, name: Option>) -> anyhow::Result<()> { if let Some(name) = &name { self.config.borrow().profile(name)?; } @@ -699,36 +764,108 @@ impl<'a> Handler { self.config.borrow_mut().current_profile = name; self.apply_current_config().await?; - self.config.borrow_mut().save(&self.config_last_saved)?; Ok(()) } - pub fn create_profile(&self, name: String, base: ProfileBase) -> anyhow::Result<()> { - let mut config = self.config.borrow_mut(); - if config.profiles.contains_key(&name) { - bail!("Profile {name} already exists"); + pub async fn create_profile(&self, name: String, base: ProfileBase) -> anyhow::Result<()> { + { + let mut config = self.config.borrow_mut(); + if config.profiles.contains_key(name.as_str()) { + bail!("Profile {name} already exists"); + } + + let profile = match base { + ProfileBase::Empty => Profile::default(), + ProfileBase::Default => config.default_profile(), + ProfileBase::Profile(name) => config.profile(&name)?.clone(), + }; + config.profiles.insert(name.into(), profile); + config.save(&self.config_last_saved)?; + } + + let tx = self.profile_watcher_tx.borrow().clone(); + if let Some(tx) = tx { + let _ = tx.send(ProfileWatcherCommand::Update).await; } - let profile = match base { - ProfileBase::Empty => Profile::default(), - ProfileBase::Default => config.default_profile(), - ProfileBase::Profile(name) => config.profile(&name)?.clone(), - }; - config.profiles.insert(name, profile); - config.save(&self.config_last_saved)?; Ok(()) } pub async fn delete_profile(&self, name: String) -> anyhow::Result<()> { - if self.config.borrow().current_profile.as_ref() == Some(&name) { - self.set_profile(None).await?; + if self.config.borrow().current_profile.as_deref() == Some(&name) { + self.set_current_profile(None).await?; } - self.config.borrow_mut().profiles.shift_remove(&name); + self.config + .borrow_mut() + .profiles + .shift_remove(name.as_str()); + self.config.borrow().save(&self.config_last_saved)?; + + let tx = self.profile_watcher_tx.borrow().clone(); + if let Some(tx) = tx { + let _ = tx.send(ProfileWatcherCommand::Update).await; + } + Ok(()) } + pub async fn move_profile(&self, name: &str, new_position: usize) -> anyhow::Result<()> { + { + let mut config = self.config.borrow_mut(); + + let current_index = config + .profiles + .get_index_of(name) + .with_context(|| format!("Profile {name} not found"))?; + + if new_position >= config.profiles.len() { + bail!("Provided index is out of bounds"); + } + + config.profiles.swap_indices(current_index, new_position); + config.save(&self.config_last_saved)?; + } + + let tx = self.profile_watcher_tx.borrow().clone(); + if let Some(tx) = tx { + let _ = tx.send(ProfileWatcherCommand::Update).await; + } + + Ok(()) + } + + pub async fn set_profile_rule( + &self, + name: &str, + rule: Option, + ) -> anyhow::Result<()> { + self.config + .borrow_mut() + .profiles + .get_mut(name) + .with_context(|| format!("Profile {name} not found"))? + .rule = rule; + + let tx = self.profile_watcher_tx.borrow().clone(); + if let Some(tx) = tx { + let _ = tx.send(ProfileWatcherCommand::Update).await; + } + + Ok(()) + } + + pub fn evaluate_profile_rule(&self, rule: &ProfileRule) -> anyhow::Result { + let profile_watcher_state_guard = self.profile_watcher_state.borrow(); + match profile_watcher_state_guard.as_ref() { + Some(state) => Ok(profiles::profile_rule_matches(state, rule)), + None => Err(anyhow!( + "Automatic profile switching is not currently active" + )), + } + } + pub fn confirm_pending_config(&self, command: ConfirmCommand) -> anyhow::Result<()> { if let Some(tx) = self .confirm_config_tx diff --git a/lact-daemon/src/server/profiles.rs b/lact-daemon/src/server/profiles.rs new file mode 100644 index 0000000..cae264a --- /dev/null +++ b/lact-daemon/src/server/profiles.rs @@ -0,0 +1,397 @@ +mod gamemode; +mod process; + +use crate::server::handler::Handler; +use copes::solver::PEvent; +use futures::StreamExt; +use lact_schema::{ProfileRule, ProfileWatcherState}; +use std::{ + rc::Rc, + time::{Duration, Instant}, +}; +use tokio::{select, sync::mpsc, time::sleep}; +use tracing::{debug, error, info, trace}; + +const PROFILE_WATCHER_MIN_DELAY_MS: u64 = 50; +const PROFILE_WATCHER_MAX_DELAY_MS: u64 = 500; + +#[derive(Debug)] +enum ProfileWatcherEvent { + Process(PEvent), + Gamemode(PEvent), +} + +pub enum ProfileWatcherCommand { + Stop, + /// Manually force a re-evaluation of the rules, such as when the rules were edited + Update, +} + +pub async fn run_watcher(handler: Handler, mut command_rx: mpsc::Receiver) { + let mut state = ProfileWatcherState::default(); + process::load_full_process_list(&mut state); + info!("loaded {} processes", state.process_list.len()); + + let (event_tx, mut event_rx) = mpsc::channel(128); + + process::start_listener(event_tx.clone()); + + let mut gamemode_task = None; + if let Some(gamemode_proxy) = gamemode::connect(&state.process_list).await { + match gamemode_proxy.list_games().await { + Ok(games) => { + for (pid, _) in games { + state.gamemode_games.insert(pid); + } + } + Err(err) => { + error!("could not list gamemode games: {err}"); + } + } + + match ( + gamemode_proxy.receive_game_registered().await, + gamemode_proxy.receive_game_unregistered().await, + ) { + (Ok(mut registered_stream), Ok(mut unregistered_stream)) => { + let event_tx = event_tx.clone(); + + let handle = tokio::task::spawn_local(async move { + loop { + let mut event = None; + + select! { + Some(registered_event) = registered_stream.next() => { + match registered_event.args() { + Ok(args) => { + debug!("gamemode activated for process {}", args.pid); + event = Some(PEvent::Exec(args.pid.into())); + } + Err(err) => error!("could not get event args: {err}"), + } + }, + Some(unregistered_event) = unregistered_stream.next() => { + match unregistered_event.args() { + Ok(args) => { + debug!("gamemode exited for process {}", args.pid); + event = Some(PEvent::Exit(args.pid.into())); + } + Err(err) => error!("could not get event args: {err}"), + } + }, + }; + + if let Some(event) = event { + let _ = event_tx.send(ProfileWatcherEvent::Gamemode(event)).await; + } + } + }); + gamemode_task = Some(handle); + } + err_info => { + error!("Could not get gamemode event stream: {err_info:?}"); + } + } + } + + *handler.profile_watcher_state.borrow_mut() = Some(state); + + update_profile(&handler).await; + + loop { + select! { + Some(cmd) = command_rx.recv() => { + match cmd { + ProfileWatcherCommand::Stop => break, + ProfileWatcherCommand::Update => { + update_profile(&handler).await; + } + } + } + Some(event) = event_rx.recv() => { + handle_profile_event(&event, &handler).await; + + // It is very common during system usage that multiple processes start at the same time, or there are processes + // that start and exit right away. + // Due to this, it does not make sense to re-evaluate profile rules as soon as there is a process event. + // Instead, we accumulate multiple events that come in quick succession, and only evaluate the rules once. + // + // After getting an event we wait for a period of time (the minimum delay option). + // If there are no new events since, rules are evaluated. If there are, + // the timer is reset and the evaluation is delayed. + // There is also a maximum delay period (counted since the first event) to force + // a rule evaluation at some point in case the events don't stop coming in + // and resetting the minimum delay. + let min_timeout = sleep(Duration::from_millis(PROFILE_WATCHER_MIN_DELAY_MS)); + let max_timeout = sleep(Duration::from_millis(PROFILE_WATCHER_MAX_DELAY_MS)); + tokio::pin!(min_timeout, max_timeout); + + loop { + select! { + () = &mut min_timeout => { + break + }, + () = &mut max_timeout => { + trace!("profile update deadline reached"); + break + }, + Some(event) = event_rx.recv() => { + trace!("got another process event, delaying profile update"); + min_timeout.as_mut().reset(tokio::time::Instant::now() + Duration::from_millis(PROFILE_WATCHER_MIN_DELAY_MS)); + handle_profile_event(&event, &handler).await; + } + } + } + + update_profile(&handler).await; + }, + } + } + + handler.profile_watcher_state.borrow_mut().take(); + + if let Some(handle) = gamemode_task { + handle.abort(); + } +} + +async fn handle_profile_event(event: &ProfileWatcherEvent, handler: &Handler) { + trace!("profile watcher event: {event:?}"); + + let mut should_reload = false; + { + let mut state_guard = handler.profile_watcher_state.borrow_mut(); + let Some(state) = state_guard.as_mut() else { + return; + }; + + match *event { + ProfileWatcherEvent::Process(PEvent::Exec(pid)) => match process::get_pid_info(pid) { + Ok(info) => { + if info.name.as_ref() == gamemode::PROCESS_NAME { + info!("detected gamemode daemon, reloading profile watcher"); + should_reload = true; + } + state.push_process(*pid.as_ref(), info); + } + Err(err) => { + debug!("could not get info for process {pid}: {err}"); + } + }, + ProfileWatcherEvent::Process(PEvent::Exit(pid)) => { + state.remove_process(*pid.as_ref()); + } + ProfileWatcherEvent::Gamemode(PEvent::Exec(pid)) => { + state.gamemode_games.insert(*pid.as_ref()); + } + ProfileWatcherEvent::Gamemode(PEvent::Exit(pid)) => { + state.gamemode_games.shift_remove(pid.as_ref()); + } + } + } + + if should_reload { + handler.start_profile_watcher().await; + } +} + +async fn update_profile(handler: &Handler) { + let new_profile = { + let config = handler.config.borrow(); + let profile_rules = config + .profiles + .iter() + .filter_map(|(name, profile)| Some((name, profile.rule.as_ref()?))); + + let state_guard = handler.profile_watcher_state.borrow(); + if let Some(state) = state_guard.as_ref() { + let started_at = Instant::now(); + let new_profile = evaluate_current_profile(state, profile_rules); + trace!("evaluated profile rules in {:?}", started_at.elapsed()); + new_profile.cloned() + } else { + None + } + }; + + if handler.config.borrow().current_profile != new_profile { + if let Some(name) = &new_profile { + info!("setting current profile to '{name}'"); + } else { + info!("setting default profile"); + } + + if let Err(err) = handler.set_current_profile(new_profile).await { + error!("failed to apply profile: {err:#}"); + } + } +} + +/// Returns the new active profile +fn evaluate_current_profile<'a>( + state: &ProfileWatcherState, + profile_rules: impl Iterator, &'a ProfileRule)>, +) -> Option<&'a Rc> { + for (profile_name, rule) in profile_rules { + if profile_rule_matches(state, rule) { + return Some(profile_name); + } + } + + None +} + +#[inline] +pub(crate) fn profile_rule_matches(state: &ProfileWatcherState, rule: &ProfileRule) -> bool { + match rule { + ProfileRule::Process(process_rule) => { + if let Some(pids) = state.process_names_map.get(&process_rule.name) { + match &process_rule.args { + Some(args_filter) => { + for pid in pids { + if let Some(process_info) = state.process_list.get(pid) { + if process_info.cmdline.contains(args_filter) { + return true; + } + } else { + error!("process {pid} not found in process map"); + } + } + } + None => return true, + } + } + } + ProfileRule::Gamemode(None) => return !state.gamemode_games.is_empty(), + ProfileRule::Gamemode(Some(gamemode_rule)) => { + if let Some(pids) = state.process_names_map.get(&gamemode_rule.name) { + for pid in pids { + if state.gamemode_games.contains(pid) { + match &gamemode_rule.args { + Some(args_filter) => { + if let Some(process_info) = state.process_list.get(pid) { + if process_info.cmdline.contains(args_filter) { + return true; + } + } else { + error!("process {pid} not found in process map"); + } + } + None => return true, + } + } + } + } + } + } + false +} + +#[cfg(test)] +mod tests { + use super::evaluate_current_profile; + use lact_schema::{ProcessInfo, ProcessProfileRule, ProfileRule, ProfileWatcherState}; + use pretty_assertions::assert_eq; + use std::rc::Rc; + + #[test] + fn evaluate_basic_profile() { + let mut state = ProfileWatcherState::default(); + state.push_process( + 1, + ProcessInfo { + name: "game1".into(), + cmdline: "".into(), + }, + ); + + let profile_rules = [ + ( + "1".into(), + ProfileRule::Process(ProcessProfileRule { + name: "game1".into(), + args: None, + }), + ), + ( + "2".into(), + ProfileRule::Process(ProcessProfileRule { + name: "game2".into(), + args: None, + }), + ), + ]; + + assert_eq!( + Some(&Rc::from("1")), + evaluate_current_profile(&state, profile_rules.iter().map(|(key, rule)| (key, rule))) + ); + + state.push_process( + 1, + ProcessInfo { + name: "game2".into(), + cmdline: "".into(), + }, + ); + assert_eq!( + Some(&Rc::from("2")), + evaluate_current_profile(&state, profile_rules.iter().map(|(key, rule)| (key, rule))) + ); + + state.push_process( + 1, + ProcessInfo { + name: "game3".into(), + cmdline: "".into(), + }, + ); + assert_eq!( + None, + evaluate_current_profile(&state, profile_rules.iter().map(|(key, rule)| (key, rule))) + ); + } +} + +#[cfg(feature = "bench")] +mod benches { + use super::evaluate_current_profile; + use divan::Bencher; + use lact_schema::{ProcessInfo, ProcessProfileRule, ProfileRule, ProfileWatcherState}; + use std::hint::black_box; + + #[divan::bench(sample_size = 1000, min_time = 2)] + fn evaluate_profiles(bencher: Bencher) { + let mut state = ProfileWatcherState::default(); + + for pid in 1..2000 { + let name = format!("process-{pid}").into(); + let cmdline = format!("{name} arg1 arg2 --arg3").into(); + state.push_process(pid, ProcessInfo { name, cmdline }); + } + + let profile_rules = [ + ( + "1".into(), + ProfileRule::Process(ProcessProfileRule { + name: "game-abc".into(), + args: None, + }), + ), + ( + "2".into(), + ProfileRule::Process(ProcessProfileRule { + name: "game-1034".into(), + args: None, + }), + ), + ]; + + bencher.bench_local(move || { + evaluate_current_profile( + black_box(&state), + black_box(profile_rules.iter().map(|(key, rule)| (key, rule))), + ); + }); + } +} diff --git a/lact-daemon/src/server/profiles/gamemode.rs b/lact-daemon/src/server/profiles/gamemode.rs new file mode 100644 index 0000000..062f140 --- /dev/null +++ b/lact-daemon/src/server/profiles/gamemode.rs @@ -0,0 +1,143 @@ +use copes::solver::PID; +use lact_schema::ProcessMap; +use nix::unistd::{geteuid, seteuid, Uid}; +use std::{ + env, fs, + os::unix::fs::MetadataExt, + path::{Path, PathBuf}, +}; +use tracing::{error, info}; +use zbus::{ + proxy, + zvariant::{ObjectPath, OwnedObjectPath}, +}; + +pub const PROCESS_NAME: &str = "gamemoded"; +const DBUS_ADDRESS_ENV: &str = "DBUS_SESSION_BUS_ADDRESS"; + +#[proxy( + interface = "com.feralinteractive.GameMode", + default_service = "com.feralinteractive.GameMode", + default_path = "/com/feralinteractive/GameMode" +)] +pub trait GameMode { + #[zbus(property)] + fn client_count(&self) -> zbus::Result; + + fn list_games(&self) -> zbus::Result>; + + #[zbus(signal)] + fn game_registered(&self, pid: i32, object_path: ObjectPath<'_>) -> zbus::Result<()>; + + #[zbus(signal)] + fn game_unregistered(&self, pid: i32, object_path: ObjectPath<'_>) -> zbus::Result<()>; +} + +#[proxy( + interface = "com.feralinteractive.GameMode.Game", + default_service = "com.feralinteractive.GameMode" +)] +pub trait GameModeGame { + #[zbus(property)] + fn process_id(&self) -> zbus::Result; + + #[zbus(property)] + fn executable(&self) -> zbus::Result; +} + +pub async fn connect(process_list: &ProcessMap) -> Option> { + let address; + let gamemode_uid; + + if let Ok(raw_address) = env::var(DBUS_ADDRESS_ENV) { + match Path::new(&raw_address.trim_start_matches("unix:path=")).metadata() { + Ok(metadata) => { + gamemode_uid = metadata.uid(); + } + Err(err) => { + error!("could not read DBus socket metadata from {raw_address}: {err}"); + gamemode_uid = geteuid().into(); + } + } + + address = raw_address; + } else if let Some((pid, _)) = process_list + .iter() + .find(|(_, info)| info.name.as_ref() == PROCESS_NAME) + { + let pid = PID::from(*pid); + let process_path = PathBuf::from(pid); + let metadata = process_path + .metadata() + .map_err(|err| error!("could not read gamemode process metadata: {err}")) + .ok()?; + + gamemode_uid = metadata.uid(); + + let gamemode_env = fs::read(process_path.join("environ")) + .map_err(|err| error!("could not read gamemode process env: {err}")) + .ok()?; + + let dbus_addr_env = gamemode_env + .split(|c| *c == b'\0') + .filter_map(|pair| std::str::from_utf8(pair).ok()) + .filter_map(|pair| pair.split_once('=')) + .find(|(key, _)| *key == DBUS_ADDRESS_ENV); + + if let Some((_, env_address)) = dbus_addr_env { + address = env_address.to_owned(); + } else { + error!("could not find DBus address env variable on gamemode's process"); + return None; + } + } else { + info!("gamemode daemon not found"); + return None; + } + + info!("attempting to connect to gamemode on DBus address {address}"); + + let builder = zbus::conn::Builder::address(address.as_str()) + .map_err(|err| error!("could not construct DBus connection: {err}")) + .ok()?; + + let connection_result; + + // It is very important that the euid gets reset back to the original, + // regardless of what's happening with the dbus connection + let original_uid = geteuid(); + let gamemode_uid = Uid::from(gamemode_uid); + + if original_uid == gamemode_uid { + connection_result = builder.build().await; + } else { + info!("gamemode session uid: {gamemode_uid}"); + + seteuid(gamemode_uid) + .map_err(|err| error!("failed to set euid to gamemode's uid: {err}")) + .ok()?; + + connection_result = builder.build().await; + + // If this fails then something is terribly wrong and we cannot continue + seteuid(original_uid).expect("Failed to reset euid back to original"); + }; + + let connection = connection_result + .map_err(|err| error!("could not connect to DBus: {err}")) + .ok()?; + + let proxy = GameModeProxy::new(&connection) + .await + .map_err(|err| info!("could not connect to gamemode: {err}")) + .ok()?; + let client_count = proxy + .client_count() + .await + .map_err(|err| error!("could not fetch gamemode client count: {err}")) + .ok()?; + + info!("connected to gamemode daemon, games active: {client_count}"); + + Some(proxy) +} diff --git a/lact-daemon/src/server/profiles/process.rs b/lact-daemon/src/server/profiles/process.rs new file mode 100644 index 0000000..0bbf0f9 --- /dev/null +++ b/lact-daemon/src/server/profiles/process.rs @@ -0,0 +1,77 @@ +use super::ProfileWatcherEvent; +use copes::{io::connector::ProcessEventsConnector, solver::PID}; +use lact_schema::{ProcessInfo, ProfileWatcherState}; +use std::fs; +use tokio::sync::mpsc; +use tracing::{debug, error}; + +pub fn load_full_process_list(state: &mut ProfileWatcherState) { + let process_entries = fs::read_dir("/proc") + .inspect_err(|err| error!("could not read /proc: {err}")) + .into_iter() + .flatten() + .filter_map(|result| match result { + Ok(entry) => entry + .file_name() + .to_str() + .and_then(|name| name.parse::().ok()), + Err(err) => { + error!("could not read /proc entry: {err}"); + None + } + }); + + for raw_pid in process_entries { + let pid = PID::from(raw_pid); + if let Ok(info) = get_pid_info(pid) { + state.push_process(raw_pid, info); + } + } +} + +pub fn start_listener(event_tx: mpsc::Sender) { + match ProcessEventsConnector::try_new() { + Ok(connector) => { + tokio::task::spawn_blocking(move || { + let iter = connector.into_iter(); + for result in iter { + match result { + Ok(event) => { + if event_tx + .blocking_send(ProfileWatcherEvent::Process(event)) + .is_err() + { + debug!( + "profile watcher channel closed, exiting process event listener" + ); + break; + } + } + Err(err) => { + debug!("process event error: {err}"); + } + } + } + }); + } + Err(err) => { + error!("could not subscribe to process events: {err}"); + } + } +} + +pub fn get_pid_info(pid: PID) -> std::io::Result { + let exe = copes::io::proc::exe_reader(pid)?; + let cmdline = copes::io::proc::cmdline_reader(pid)?; + let name = copes::solver::get_process_executed_file(exe, &cmdline) + .to_string() + .into(); + + Ok(ProcessInfo { + name, + cmdline: cmdline + .to_string() + .trim_matches(|c| c == '[' || c == ']') + .into(), + }) +} diff --git a/lact-gui/src/app.rs b/lact-gui/src/app.rs index a475fb4..d2cb1f6 100644 --- a/lact-gui/src/app.rs +++ b/lact-gui/src/app.rs @@ -21,7 +21,9 @@ use gtk::{ ApplicationWindow, ButtonsType, FileChooserAction, FileChooserDialog, MessageDialog, MessageType, ResponseType, }; -use header::{Header, HeaderMsg}; +use header::{ + profile_rule_window::ProfileRuleWindowMsg, Header, HeaderMsg, PROFILE_RULE_WINDOW_BROKER, +}; use lact_client::{ConnectionStatusMsg, DaemonClient}; use lact_daemon::MODULE_CONF_PATH; use lact_schema::{ @@ -37,11 +39,18 @@ use pages::{ use relm4::{ actions::{RelmAction, RelmActionGroup}, prelude::{AsyncComponent, AsyncComponentParts}, - tokio, AsyncComponentSender, Component, ComponentController, + tokio, AsyncComponentSender, Component, ComponentController, MessageBroker, +}; +use std::{ + os::unix::net::UnixStream, + rc::Rc, + sync::{atomic::AtomicBool, Arc}, + time::Duration, }; -use std::{os::unix::net::UnixStream, rc::Rc, sync::atomic::AtomicBool, time::Duration}; use tracing::{debug, error, info, trace, warn}; +pub(crate) static APP_BROKER: MessageBroker = MessageBroker::new(); + const STATS_POLL_INTERVAL_MS: u64 = 250; pub struct AppModel { @@ -234,8 +243,13 @@ impl AsyncComponent for AppModel { model .header - .emit(HeaderMsg::Stack(widgets.root_stack.clone())); - sender.input(AppMsg::ReloadProfiles); + .widgets() + .stack_switcher + .set_stack(Some(&widgets.root_stack)); + + sender.input(AppMsg::ReloadProfiles { + include_state: false, + }); AsyncComponentParts { model, widgets } } @@ -262,11 +276,11 @@ impl AppModel { sender: AsyncComponentSender, root: >k::ApplicationWindow, widgets: &AppModelWidgets, - ) -> Result<(), Rc> { + ) -> Result<(), Arc> { match msg { AppMsg::Error(err) => return Err(err), - AppMsg::ReloadProfiles => { - self.reload_profiles().await?; + AppMsg::ReloadProfiles { include_state } => { + self.reload_profiles(include_state).await?; sender.input(AppMsg::ReloadData { full: false }); } AppMsg::ReloadData { full } => { @@ -277,20 +291,40 @@ impl AppModel { self.update_gpu_data(gpu_id, sender).await?; } } - AppMsg::SelectProfile(profile) => { - self.daemon_client.set_profile(profile).await?; - sender.input(AppMsg::ReloadData { full: false }); + AppMsg::SelectProfile { + profile, + auto_switch, + } => { + self.daemon_client.set_profile(profile, auto_switch).await?; + sender.input(AppMsg::ReloadProfiles { + include_state: false, + }); } AppMsg::CreateProfile(name, base) => { self.daemon_client .create_profile(name.clone(), base) .await?; - self.daemon_client.set_profile(Some(name)).await?; - sender.input(AppMsg::ReloadProfiles); + + let auto_switch = self.header.model().auto_switch_profiles(); + self.daemon_client + .set_profile(Some(name), auto_switch) + .await?; + + sender.input(AppMsg::ReloadProfiles { + include_state: false, + }); } AppMsg::DeleteProfile(profile) => { self.daemon_client.delete_profile(profile).await?; - sender.input(AppMsg::ReloadProfiles); + sender.input(AppMsg::ReloadProfiles { + include_state: false, + }); + } + AppMsg::MoveProfile(name, new_position) => { + self.daemon_client.move_profile(name, new_position).await?; + sender.input(AppMsg::ReloadProfiles { + include_state: false, + }); } AppMsg::Stats(stats) => { self.info_page.emit(PageUpdate::Stats(stats.clone())); @@ -363,6 +397,14 @@ impl AppModel { }); controller.detach_runtime(); } + AppMsg::EvaluateProfile(rule) => { + let matches = self.daemon_client.evaluate_profile_rule(rule).await?; + PROFILE_RULE_WINDOW_BROKER.send(ProfileRuleWindowMsg::EvaluationResult(matches)); + } + AppMsg::SetProfileRule { name, rule } => { + self.daemon_client.set_profile_rule(name, rule).await?; + self.reload_profiles(false).await?; + } } Ok(()) } @@ -374,9 +416,15 @@ impl AppModel { .context("No GPU selected") } - async fn reload_profiles(&mut self) -> anyhow::Result<()> { - let profiles = self.daemon_client.list_profiles().await?.inner()?; - self.header.emit(HeaderMsg::Profiles(profiles)); + async fn reload_profiles(&mut self, include_state: bool) -> anyhow::Result<()> { + let mut profiles = self.daemon_client.list_profiles(include_state).await?; + + if let Some(state) = profiles.watcher_state.take() { + PROFILE_RULE_WINDOW_BROKER.send(ProfileRuleWindowMsg::WatcherState(state)); + } + + self.header.emit(HeaderMsg::Profiles(Box::new(profiles))); + Ok(()) } @@ -390,11 +438,11 @@ impl AppModel { .get_device_info(&gpu_id) .await .context("Could not fetch info")?; - let info = Rc::new(info_buf.inner()?); + let info = Arc::new(info_buf.inner()?); // Plain `nvidia` means that the nvidia driver is loaded, but it does not contain a version fetched from NVML if info.driver == "nvidia" { - sender.input(AppMsg::Error(Rc::new(anyhow!("Nvidia driver detected, but the management library could not be loaded. Check lact service status for more information.")))); + sender.input(AppMsg::Error(Arc::new(anyhow!("Nvidia driver detected, but the management library could not be loaded. Check lact service status for more information.")))); } self.info_page.emit(PageUpdate::Info(info.clone())); @@ -435,7 +483,7 @@ impl AppModel { .await .context("Could not fetch stats")? .inner()?; - let stats = Rc::new(stats); + let stats = Arc::new(stats); self.oc_page.set_stats(&stats, true); self.thermals_page.set_stats(&stats, true); @@ -516,6 +564,7 @@ impl AppModel { gpu_id.to_owned(), self.daemon_client.clone(), sender, + self.header.sender().clone(), )); Ok(()) @@ -891,6 +940,7 @@ fn start_stats_update_loop( gpu_id: String, daemon_client: DaemonClient, sender: AsyncComponentSender, + header_sender: relm4::Sender, ) -> glib::JoinHandle<()> { debug!("spawning new stats update task with {STATS_POLL_INTERVAL_MS}ms interval"); let duration = Duration::from_millis(STATS_POLL_INTERVAL_MS); @@ -904,12 +954,21 @@ fn start_stats_update_loop( .and_then(|buffer| buffer.inner()) { Ok(stats) => { - sender.input(AppMsg::Stats(Rc::new(stats))); + sender.input(AppMsg::Stats(Arc::new(stats))); } Err(err) => { error!("could not fetch stats: {err:#}"); } } + + match daemon_client.list_profiles(false).await { + Ok(profiles) => { + let _ = header_sender.send(HeaderMsg::Profiles(Box::new(profiles))); + } + Err(err) => { + error!("could not fetch profile info: {err:#}"); + } + } } }) } diff --git a/lact-gui/src/app/graphs_window/mod.rs b/lact-gui/src/app/graphs_window/mod.rs index 3df1c09..0336bd4 100644 --- a/lact-gui/src/app/graphs_window/mod.rs +++ b/lact-gui/src/app/graphs_window/mod.rs @@ -4,7 +4,7 @@ use gtk::prelude::*; use lact_schema::DeviceStats; use plot::{Plot, PlotData}; use relm4::{ComponentParts, ComponentSender, RelmWidgetExt}; -use std::rc::Rc; +use std::sync::Arc; pub struct GraphsWindow { time_period_seconds_adj: gtk::Adjustment, @@ -13,7 +13,7 @@ pub struct GraphsWindow { #[derive(Debug)] pub enum GraphsWindowMsg { - Stats(Rc), + Stats(Arc), VramClockRatio(f64), Refresh, Show, diff --git a/lact-gui/src/app/header.rs b/lact-gui/src/app/header.rs index 533ea24..2bf6535 100644 --- a/lact-gui/src/app/header.rs +++ b/lact-gui/src/app/header.rs @@ -1,4 +1,6 @@ mod new_profile_dialog; +mod profile_row; +pub mod profile_rule_window; use super::{AppMsg, DebugSnapshot, DisableOverdrive, DumpVBios, ResetConfig, ShowGraphsWindow}; use glib::clone; @@ -7,27 +9,36 @@ use gtk::*; use lact_client::schema::DeviceListEntry; use lact_schema::ProfilesInfo; use new_profile_dialog::NewProfileDialog; +use profile_row::{ProfileRow, ProfileRowType}; +use profile_rule_window::{ProfileRuleWindow, ProfileRuleWindowMsg}; use relm4::{ + factory::FactoryVecDeque, + prelude::DynamicIndex, typed_view::list::{RelmListItem, TypedListView}, - Component, ComponentController, ComponentParts, ComponentSender, RelmWidgetExt, + Component, ComponentController, ComponentParts, ComponentSender, MessageBroker, + RelmIterChildrenExt, RelmWidgetExt, }; -use std::fmt; +use tracing::debug; + +pub static PROFILE_RULE_WINDOW_BROKER: MessageBroker = MessageBroker::new(); pub struct Header { + profiles_info: ProfilesInfo, gpu_selector: TypedListView, - profile_selector: TypedListView, + profile_selector: FactoryVecDeque, selector_label: String, - stack: Option, + rule_window: relm4::Controller, } #[derive(Debug)] pub enum HeaderMsg { - Stack(Stack), - Profiles(ProfilesInfo), + Profiles(std::boxed::Box), + AutoProfileSwitch(bool), + ShowProfileEditor(DynamicIndex), SelectProfile, SelectGpu, CreateProfile, - DeleteProfile, + ClosePopover, } #[relm4::component(pub)] @@ -42,10 +53,7 @@ impl Component for Header { set_show_title_buttons: true, #[wrap(Some)] - set_title_widget = &StackSwitcher { - #[watch] - set_stack: model.stack.as_ref(), - }, + set_title_widget: stack_switcher = &StackSwitcher {}, #[name = "menu_button"] pack_start = >k::MenuButton { @@ -54,7 +62,6 @@ impl Component for Header { #[wrap(Some)] set_popover = >k::Popover { set_margin_all: 5, - set_autohide: false, gtk::Box { set_orientation: gtk::Orientation::Vertical, @@ -83,12 +90,25 @@ impl Component for Header { set_orientation: gtk::Orientation::Vertical, set_spacing: 5, + gtk::CheckButton { + set_label: Some("Switch automatically"), + set_margin_horizontal: 5, + #[watch] + #[block_signal(toggle_auto_profile_handler)] + set_active: model.profiles_info.auto_switch, + connect_toggled[sender] => move |button| { + sender.input(HeaderMsg::AutoProfileSwitch(button.is_active())); + } @ toggle_auto_profile_handler + }, + gtk::ScrolledWindow { set_policy: (gtk::PolicyType::Never, gtk::PolicyType::Automatic), set_propagate_natural_height: true, #[local_ref] - profile_selector -> gtk::ListView { } + profile_selector -> gtk::ListBox { + set_selection_mode: gtk::SelectionMode::Single, + } }, gtk::Box { @@ -97,18 +117,10 @@ impl Component for Header { gtk::Button { set_expand: true, - set_icon_name: "list-add-symbolic", + set_icon_name: "list-add", connect_clicked => HeaderMsg::CreateProfile, }, - - gtk::Button { - set_expand: true, - set_icon_name: "list-remove-symbolic", - connect_clicked => HeaderMsg::DeleteProfile, - #[watch] - set_sensitive: model.profile_selector.selection_model.selected() != 0, - }, - } + }, } }, } @@ -159,26 +171,32 @@ impl Component for Header { } )); - let profile_selector = TypedListView::<_, gtk::SingleSelection>::new(); - profile_selector - .selection_model - .connect_selection_changed(clone!( - #[strong] - sender, - move |_, _, _| { - sender.input(HeaderMsg::SelectProfile); - } - )); + let profile_selector = FactoryVecDeque::::builder() + .launch_default() + .forward(sender.input_sender(), |msg| msg); + profile_selector.widget().connect_row_selected(clone!( + #[strong] + sender, + move |_, _| { + let _ = sender.input_sender().send(HeaderMsg::SelectProfile); + } + )); + + let rule_window = ProfileRuleWindow::builder() + .transient_for(&root) + .launch_with_broker((), &PROFILE_RULE_WINDOW_BROKER) + .detach(); let model = Self { gpu_selector, profile_selector, selector_label: String::new(), - stack: None, + profiles_info: ProfilesInfo::default(), + rule_window, }; let gpu_selector = &model.gpu_selector.view; - let profile_selector = &model.profile_selector.view; + let profile_selector = model.profile_selector.widget(); let widgets = view_output!(); widgets.menu_button.connect_label_notify(|menu_button| { @@ -212,42 +230,41 @@ impl Component for Header { _root: &Self::Root, ) { match msg { - HeaderMsg::Stack(stack) => { - self.stack = Some(stack); - } - HeaderMsg::Profiles(profiles_info) => { - let selected_index = match &profiles_info.current_profile { - Some(profile) => { - profiles_info - .profiles - .iter() - .position(|value| value == profile) - .expect("Active profile is not in the list") - + 1 - } - None => 0, - }; - - self.profile_selector.clear(); - self.profile_selector.append(ProfileListItem::Default); - - for profile in profiles_info.profiles { - self.profile_selector - .append(ProfileListItem::Profile(profile)); - } - - self.profile_selector - .selection_model - .set_selected(selected_index as u32); + HeaderMsg::ClosePopover => { + widgets.menu_button.popdown(); } + HeaderMsg::Profiles(profiles_info) => self.set_profiles_info(*profiles_info), HeaderMsg::SelectGpu => sender.output(AppMsg::ReloadData { full: true }).unwrap(), + HeaderMsg::AutoProfileSwitch(auto_switch) => { + let msg = AppMsg::SelectProfile { + profile: self + .selected_profile() + .filter(|_| !auto_switch) + .map(str::to_owned), + auto_switch, + }; + sender.output(msg).unwrap(); + } HeaderMsg::SelectProfile => { - let selected_profile = self.selected_profile(); - sender - .output(AppMsg::SelectProfile(selected_profile)) - .unwrap(); + let profile = self.selected_profile(); + + if self.profiles_info.current_profile.as_deref() != profile { + if self.profiles_info.auto_switch { + // Revert to the previous profile + self.update_selected_profile(); + } else { + sender + .output(AppMsg::SelectProfile { + profile: profile.map(str::to_owned), + auto_switch: false, + }) + .unwrap(); + } + } } HeaderMsg::CreateProfile => { + sender.input(HeaderMsg::ClosePopover); + let mut diag_controller = NewProfileDialog::builder() .launch(self.custom_profiles()) .forward(sender.output_sender(), |(name, base)| { @@ -255,18 +272,19 @@ impl Component for Header { }); diag_controller.detach_runtime(); } - HeaderMsg::DeleteProfile => { - if let Some(selected_profile) = self.selected_profile() { - let msg = - format!("Are you sure you want to delete profile \"{selected_profile}\""); - sender - .output(AppMsg::ask_confirmation( - AppMsg::DeleteProfile(selected_profile), - "Delete profile", - &msg, - gtk::ButtonsType::OkCancel, - )) - .unwrap(); + HeaderMsg::ShowProfileEditor(index) => { + sender.input(HeaderMsg::ClosePopover); + + let profile = self + .profile_selector + .get(index.current_index()) + .expect("No profile with given index"); + + if let ProfileRowType::Profile { name, rule, .. } = &profile.row { + self.rule_window.emit(ProfileRuleWindowMsg::Show { + profile_name: name.clone(), + rule: rule.clone().unwrap_or_default(), + }); } } } @@ -277,6 +295,64 @@ impl Component for Header { } impl Header { + fn set_profiles_info(&mut self, profiles_info: ProfilesInfo) { + if self.profiles_info == profiles_info && !self.profile_selector.is_empty() { + return; + } + debug!("setting new profiles info: {profiles_info:?}"); + + let mut profiles = self.profile_selector.guard(); + profiles.clear(); + + let last = profiles_info.profiles.len().saturating_sub(1); + for (i, (name, rule)) in profiles_info.profiles.iter().enumerate() { + let profile = ProfileRowType::Profile { + name: name.to_string(), + first: i == 0, + last: i == last, + auto: profiles_info.auto_switch, + rule: rule.clone(), + }; + profiles.push_back(profile); + } + profiles.push_back(ProfileRowType::Default); + drop(profiles); + + self.profiles_info = profiles_info; + + self.update_selected_profile(); + + if self.auto_switch_profiles() { + let profiles_listbox = self.profile_selector.widget(); + for row in profiles_listbox.iter_children() { + row.remove_css_class("activatable"); + } + } + } + + fn update_selected_profile(&self) { + let selected_profile_index = self.profiles_info.current_profile.as_ref().map(|profile| { + self.profiles_info + .profiles + .iter() + .position(|(value, _)| value == profile) + .expect("Active profile is not in the list") + }); + + let new_selected_index = + selected_profile_index.unwrap_or_else(|| self.profile_selector.len() - 1); + + let new_selected_row = self + .profile_selector + .widget() + .row_at_index(new_selected_index as i32) + .unwrap(); + + self.profile_selector + .widget() + .select_row(Some(&new_selected_row)); + } + pub fn selected_gpu_id(&self) -> Option { let selected = self.gpu_selector.selection_model.selected(); self.gpu_selector @@ -285,39 +361,35 @@ impl Header { .map(|item| item.borrow().0.id.clone()) } + pub fn auto_switch_profiles(&self) -> bool { + self.profiles_info.auto_switch + } + fn custom_profiles(&self) -> Vec { - let mut profiles = Vec::with_capacity(self.profile_selector.len() as usize); + let mut profiles = Vec::with_capacity(self.profile_selector.len()); for i in 0..self.profile_selector.len() { let item = self.profile_selector.get(i).unwrap(); - let item = item.borrow(); - if let ProfileListItem::Profile(name) = &*item { + if let ProfileRowType::Profile { name, .. } = &item.row { profiles.push(name.clone()); } } profiles } - fn selected_profile(&self) -> Option { - let selected_index = self.profile_selector.selection_model.selected(); - let item = self - .profile_selector - .get(selected_index) - .expect("Invalid item selected"); - let item = item.borrow().clone(); - match item { - ProfileListItem::Default => None, - ProfileListItem::Profile(name) => Some(name), - } + fn selected_profile(&self) -> Option<&str> { + self.profile_selector + .widget() + .selected_row() + .and_then(|row| self.profile_selector.get(row.index() as usize)) + .and_then(|item| match &item.row { + ProfileRowType::Default => None, + ProfileRowType::Profile { name, .. } => Some(name.as_str()), + }) } fn update_label(&mut self) { let gpu_index = self.gpu_selector.selection_model.selected(); - let profile = self - .profile_selector - .get(self.profile_selector.selection_model.selected()) - .as_ref() - .map(|item| item.borrow().to_string()) - .unwrap_or_else(|| "".to_owned()); + let profile = self.selected_profile().unwrap_or("Default"); self.selector_label = format!("GPU {gpu_index} | {profile}"); } @@ -339,34 +411,3 @@ impl RelmListItem for GpuListItem { widgets.set_label(self.0.name.as_deref().unwrap_or(&self.0.id)); } } - -#[derive(Clone)] -enum ProfileListItem { - Default, - Profile(String), -} - -impl fmt::Display for ProfileListItem { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let text = match self { - ProfileListItem::Default => "Default", - ProfileListItem::Profile(name) => name, - }; - text.fmt(f) - } -} - -impl RelmListItem for ProfileListItem { - type Root = Label; - type Widgets = Label; - - fn setup(_list_item: >k::ListItem) -> (Self::Root, Self::Widgets) { - let label = gtk::Label::new(None); - label.set_margin_all(5); - (label.clone(), label) - } - - fn bind(&mut self, widgets: &mut Self::Widgets, _root: &mut Self::Root) { - widgets.set_label(&self.to_string()); - } -} diff --git a/lact-gui/src/app/header/new_profile_dialog.rs b/lact-gui/src/app/header/new_profile_dialog.rs index b592a14..9f586d3 100644 --- a/lact-gui/src/app/header/new_profile_dialog.rs +++ b/lact-gui/src/app/header/new_profile_dialog.rs @@ -43,7 +43,7 @@ impl Component for NewProfileDialog { set_spacing: 5, gtk::Label { - set_label: "Base profile:", + set_label: "Copy settings from:", }, #[local_ref] diff --git a/lact-gui/src/app/header/profile_row.rs b/lact-gui/src/app/header/profile_row.rs new file mode 100644 index 0000000..2092869 --- /dev/null +++ b/lact-gui/src/app/header/profile_row.rs @@ -0,0 +1,116 @@ +use super::HeaderMsg; +use crate::app::{msg::AppMsg, APP_BROKER}; +use gtk::{pango, prelude::*}; +use lact_schema::ProfileRule; +use relm4::{ + factory::{DynamicIndex, FactoryComponent}, + FactorySender, RelmWidgetExt, +}; + +pub struct ProfileRow { + pub(super) row: ProfileRowType, +} + +#[derive(Debug, Clone)] +pub enum ProfileRowType { + Default, + Profile { + name: String, + first: bool, + last: bool, + auto: bool, + rule: Option, + }, +} + +impl ProfileRowType { + pub fn name(&self) -> Option { + match self { + Self::Default => None, + Self::Profile { name, .. } => Some(name.clone()), + } + } +} + +#[relm4::factory(pub)] +impl FactoryComponent for ProfileRow { + type Init = ProfileRowType; + type Input = (); + type Output = HeaderMsg; + type CommandOutput = (); + type ParentWidget = gtk::ListBox; + + view! { + gtk::Box { + #[name = "name_label"] + gtk::Label { + set_label: match &self.row { + ProfileRowType::Default => "Default", + ProfileRowType::Profile { name, .. } => name, + }, + set_margin_all: 5, + set_halign: gtk::Align::Start, + set_hexpand: true, + set_xalign: 0.0, + set_ellipsize: pango::EllipsizeMode::End, + 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::Button { + set_icon_name: "go-up", + set_tooltip: "Move Up", + set_sensitive: match &self.row { + ProfileRowType::Profile { first, .. } => !*first, + _ => false, + + }, + connect_clicked[index, profile = self.row.clone()] => move |_| { + APP_BROKER.send(move_profile_msg(&profile, &index, -1)); + }, + }, + + gtk::Button { + set_icon_name: "go-down", + set_tooltip: "Move Down", + set_sensitive: match &self.row { + ProfileRowType::Profile { last, .. } => !*last, + _ => false, + + }, + connect_clicked[index, profile = self.row.clone()] => move |_| { + 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)); + } + }, + }, + } + } + + fn init_model(row: Self::Init, _index: &DynamicIndex, _sender: FactorySender) -> Self { + Self { row } + } +} + +fn move_profile_msg(profile: &ProfileRowType, index: &DynamicIndex, offset: i64) -> AppMsg { + let name = profile.name().expect("Default profile cannot be moved"); + let new_index = (index.current_index() as i64).saturating_add(offset); + AppMsg::MoveProfile(name, new_index as usize) +} diff --git a/lact-gui/src/app/header/profile_rule_window.rs b/lact-gui/src/app/header/profile_rule_window.rs new file mode 100644 index 0000000..7294677 --- /dev/null +++ b/lact-gui/src/app/header/profile_rule_window.rs @@ -0,0 +1,475 @@ +use std::time::Duration; + +use crate::app::{msg::AppMsg, APP_BROKER}; +use gtk::{ + glib::{GStr, GString}, + prelude::{ + BoxExt, CheckButtonExt, DialogExt, DialogExtManual, EditableExt, EntryBufferExtManual, + EntryExt, GridExt, GtkWindowExt, ObjectExt, OrientableExt, PopoverExt, SelectionModelExt, + WidgetExt, + }, + SingleSelection, +}; +use lact_schema::{ProcessInfo, ProcessProfileRule, ProfileRule, ProfileWatcherState}; +use relm4::{ + tokio::time::sleep, + typed_view::list::{RelmListItem, TypedListView}, + view, ComponentParts, ComponentSender, RelmWidgetExt, +}; +use tracing::debug; + +const EVALUATE_INTERVAL_MS: u64 = 250; + +const PROCESS_PAGE: &str = "process"; +const GAMEMODE_PAGE: &str = "gamemode"; + +pub struct ProfileRuleWindow { + profile_name: String, + process_name_buffer: gtk::EntryBuffer, + args_buffer: gtk::EntryBuffer, + rule: ProfileRule, + process_list_view: TypedListView, + currently_matches: bool, +} + +#[derive(Debug)] +pub enum ProfileRuleWindowMsg { + Show { + profile_name: String, + rule: ProfileRule, + }, + ProcessFilterChanged(GString), + WatcherState(ProfileWatcherState), + SetFromSelectedProcess, + Evaluate, + EvaluationResult(bool), + Save, +} + +#[relm4::component(pub)] +impl relm4::Component for ProfileRuleWindow { + type Init = (); + type Input = ProfileRuleWindowMsg; + type Output = (); + type CommandOutput = (); + + view! { + gtk::Dialog { + set_default_size: (600, 300), + set_title: Some("Profile activation rules"), + set_hide_on_close: true, + connect_response[root, sender] => move |_, response| { + match response { + gtk::ResponseType::Accept => { + sender.input(ProfileRuleWindowMsg::Save); + root.hide(); + } + gtk::ResponseType::Cancel => root.hide(), + _ => (), + } + }, + + gtk::Box { + set_orientation: gtk::Orientation::Vertical, + set_margin_all: 5, + + gtk::Label { + #[watch] + set_markup: &format!("Activate profile '{}' when:", model.profile_name), + set_halign: gtk::Align::Start, + set_margin_all: 10, + }, + + gtk::Separator {}, + + gtk::Box { + set_orientation: gtk::Orientation::Horizontal, + set_expand: true, + + gtk::StackSidebar { + set_stack: &stack, + }, + + gtk::Box { + set_orientation: gtk::Orientation::Vertical, + set_margin_all: 10, + set_spacing: 10, + + #[name = "stack"] + gtk::Stack { + connect_visible_child_name_notify => ProfileRuleWindowMsg::Evaluate, + + add_titled[Some(PROCESS_PAGE), "A process is running"] = >k::Grid { + set_row_spacing: 5, + set_column_spacing: 5, + + attach[0, 0, 1, 1] = >k::Label { + set_label: "Process Name:", + set_halign: gtk::Align::Start, + }, + + attach[2, 0, 1, 1] = >k::Entry { + set_buffer: &model.process_name_buffer, + set_hexpand: true, + set_placeholder_text: Some("Cyberpunk2077.exe"), + connect_changed => ProfileRuleWindowMsg::Evaluate, + }, + + attach[3, 0, 1, 1] = >k::MenuButton { + set_icon_name: "view-list-symbolic", + + #[wrap(Some)] + set_popover: process_filter_popover = >k::Popover { + gtk::Box { + set_orientation: gtk::Orientation::Vertical, + set_spacing: 5, + + #[name = "process_search_entry"] + gtk::SearchEntry { + connect_search_changed[sender] => move |entry| { + sender.input(ProfileRuleWindowMsg::ProcessFilterChanged(entry.text())); + }, + }, + + gtk::ScrolledWindow { + set_size_request: (400, 350), + + #[local_ref] + process_listview -> gtk::ListView { + set_show_separators: true, + }, + } + }, + + connect_visible_notify => |_| { + debug!("requesting profile watcher state"); + APP_BROKER.send(AppMsg::ReloadProfiles { include_state: true }); + }, + }, + }, + + attach[0, 1, 1, 1] = >k::Label { + set_label: "Arguments Contain:", + set_halign: gtk::Align::Start, + }, + + + attach[1, 1, 1, 1]: filter_by_args_checkbutton = >k::CheckButton { + connect_toggled => ProfileRuleWindowMsg::Evaluate, + }, + + attach[2, 1, 1, 1]: args_entry = >k::Entry { + set_buffer: &model.args_buffer, + set_hexpand: true, + set_sensitive: false, + connect_changed => ProfileRuleWindowMsg::Evaluate, + }, + }, + + add_titled[Some(GAMEMODE_PAGE), "Gamemode is active"] = >k::Grid { + set_row_spacing: 5, + set_column_spacing: 10, + + attach[0, 0, 1, 1] = >k::Label { + set_label: "With a specific process:", + set_halign: gtk::Align::Start, + }, + + attach[1, 0, 1, 1]: gamemode_filter_by_process_checkbutton = >k::CheckButton { + connect_toggled => ProfileRuleWindowMsg::Evaluate, + }, + + attach[2, 0, 1, 1]: gamemode_process_name_entry = >k::Entry { + set_buffer: &model.process_name_buffer, + set_hexpand: true, + set_placeholder_text: Some("Cyberpunk2077.exe"), + set_sensitive: false, + connect_changed => ProfileRuleWindowMsg::Evaluate, + }, + + attach[0, 1, 1, 1] = >k::Label { + set_label: "Arguments Contain:", + set_halign: gtk::Align::Start, + }, + + attach[1, 1, 1, 1]: gamemode_filter_by_args_checkbutton = >k::CheckButton { + connect_toggled => ProfileRuleWindowMsg::Evaluate, + }, + + attach[2, 1, 1, 1]: gamemode_args_entry = >k::Entry { + set_buffer: &model.args_buffer, + set_hexpand: true, + set_sensitive: false, + connect_changed => ProfileRuleWindowMsg::Evaluate, + }, + }, + + set_visible_child_name: match &model.rule { + ProfileRule::Process(_) => PROCESS_PAGE, + ProfileRule::Gamemode(_) => GAMEMODE_PAGE, + } + }, + + gtk::Separator {}, + + gtk::Box { + set_orientation: gtk::Orientation::Horizontal, + set_spacing: 5, + + gtk::Label { + #[watch] + set_markup: &format!( + "Selected settings are currently {}", + if model.currently_matches { "matched" } else { "not matched" } + ), + }, + + gtk::Image { + #[watch] + set_icon_name: match model.currently_matches { + true => Some("object-select-symbolic"), + false => Some("list-remove-symbolic"), + } + }, + } + }, + }, + + gtk::Separator {}, + }, + + add_buttons: &[("Cancel", gtk::ResponseType::Cancel), ("Save", gtk::ResponseType::Accept)], + } + } + + fn init( + (): Self::Init, + root: Self::Root, + sender: ComponentSender, + ) -> ComponentParts { + let task_sender = sender.clone(); + relm4::spawn_local(async move { + loop { + sleep(Duration::from_millis(EVALUATE_INTERVAL_MS)).await; + task_sender.input(ProfileRuleWindowMsg::Evaluate); + } + }); + + let mut model = Self { + rule: ProfileRule::default(), + profile_name: String::new(), + process_name_buffer: gtk::EntryBuffer::new(GStr::NONE), + args_buffer: gtk::EntryBuffer::new(GStr::NONE), + process_list_view: TypedListView::new(), + currently_matches: false, + }; + + model + .process_list_view + .selection_model + .set_autoselect(false); + + let process_listview = &model.process_list_view.view; + let widgets = view_output!(); + + model.process_list_view.add_filter({ + let search_entry = widgets.process_search_entry.clone(); + move |process| process.0.cmdline.contains(search_entry.text().as_str()) + }); + model + .process_list_view + .selection_model + .connect_selected_item_notify(move |_| { + sender.input(ProfileRuleWindowMsg::SetFromSelectedProcess); + }); + + widgets + .filter_by_args_checkbutton + .bind_property("active", &widgets.args_entry, "sensitive") + .bidirectional() + .build(); + + widgets + .gamemode_filter_by_process_checkbutton + .bind_property("active", &widgets.gamemode_process_name_entry, "sensitive") + .bidirectional() + .build(); + + widgets + .gamemode_filter_by_args_checkbutton + .bind_property("active", &widgets.gamemode_args_entry, "sensitive") + .bidirectional() + .build(); + + ComponentParts { model, widgets } + } + + fn update_with_view( + &mut self, + widgets: &mut Self::Widgets, + msg: Self::Input, + sender: ComponentSender, + root: &Self::Root, + ) { + match msg { + ProfileRuleWindowMsg::Show { profile_name, rule } => { + self.profile_name = profile_name; + + let page = match rule { + ProfileRule::Process(rule) => { + self.process_name_buffer.set_text(rule.name.as_ref()); + self.args_buffer + .set_text(rule.args.as_deref().unwrap_or_default()); + PROCESS_PAGE + } + ProfileRule::Gamemode(Some(rule)) => { + self.process_name_buffer.set_text(rule.name.as_ref()); + self.args_buffer + .set_text(rule.args.as_deref().unwrap_or_default()); + GAMEMODE_PAGE + } + ProfileRule::Gamemode(None) => { + self.process_name_buffer.set_text(""); + self.args_buffer.set_text(""); + GAMEMODE_PAGE + } + }; + widgets.stack.set_visible_child_name(page); + + widgets + .filter_by_args_checkbutton + .set_active(self.args_buffer.length() > 0); + widgets + .gamemode_filter_by_process_checkbutton + .set_active(self.process_name_buffer.length() > 0); + widgets + .gamemode_filter_by_args_checkbutton + .set_active(self.args_buffer.length() > 0); + + root.present(); + } + ProfileRuleWindowMsg::ProcessFilterChanged(filter) => { + self.process_list_view.set_filter_status(0, false); + if !filter.is_empty() { + self.process_list_view.set_filter_status(0, true); + } + } + ProfileRuleWindowMsg::WatcherState(state) => { + self.process_list_view.clear(); + self.process_list_view + .extend_from_iter(state.process_list.into_values().map(ProcessListItem).rev()); + } + ProfileRuleWindowMsg::SetFromSelectedProcess => { + let index = self.process_list_view.selection_model.selected(); + + let filter_text = widgets.process_search_entry.text(); + let item = if filter_text.is_empty() { + self.process_list_view.get(index) + } else { + // Indexing is not aware of filters, so we have to apply the filter here to find a matching index + (0..self.process_list_view.len()) + .map(|i| self.process_list_view.get(i).unwrap()) + .filter(|item| item.borrow().0.cmdline.contains(filter_text.as_str())) + .nth(index as usize) + }; + if let Some(item) = item { + let info = &item.borrow().0; + self.process_name_buffer.set_text(info.name.as_ref()); + self.args_buffer.set_text(info.cmdline.as_ref()); + } + + self.process_list_view.selection_model.unselect_all(); + widgets.process_filter_popover.popdown(); + } + ProfileRuleWindowMsg::Evaluate => { + if root.is_visible() { + let rule = self.get_rule(widgets); + APP_BROKER.send(AppMsg::EvaluateProfile(rule)); + } + } + ProfileRuleWindowMsg::EvaluationResult(matches) => { + self.currently_matches = matches; + } + ProfileRuleWindowMsg::Save => { + APP_BROKER.send(AppMsg::SetProfileRule { + name: self.profile_name.clone(), + rule: Some(self.get_rule(widgets)), + }); + } + } + + self.update_view(widgets, sender); + } +} + +impl ProfileRuleWindow { + fn get_rule(&self, widgets: &ProfileRuleWindowWidgets) -> ProfileRule { + let process_name = self.process_name_buffer.text(); + let process_args = self.args_buffer.text(); + + match widgets.stack.visible_child_name().as_deref() { + Some(PROCESS_PAGE) => { + let args = if widgets.filter_by_args_checkbutton.is_active() { + Some(process_args.as_str().into()) + } else { + None + }; + ProfileRule::Process(ProcessProfileRule { + name: process_name.as_str().into(), + args, + }) + } + Some(GAMEMODE_PAGE) => { + let args = if widgets.gamemode_filter_by_args_checkbutton.is_active() { + Some(process_args.as_str().into()) + } else { + None + }; + let rule = if !widgets.gamemode_filter_by_process_checkbutton.is_active() + && args.is_none() + { + None + } else { + Some(ProcessProfileRule { + name: process_name.as_str().into(), + args, + }) + }; + ProfileRule::Gamemode(rule) + } + _ => unreachable!(), + } + } +} + +struct ProcessListItem(ProcessInfo); + +struct ProcessListItemWidgets { + label: gtk::Label, +} + +impl RelmListItem for ProcessListItem { + type Root = gtk::Box; + type Widgets = ProcessListItemWidgets; + + fn setup(_list_item: >k::ListItem) -> (Self::Root, Self::Widgets) { + view! { + root_box = gtk::Box { + #[name = "label"] + gtk::Label { + set_halign: gtk::Align::Start, + set_hexpand: true, + set_selectable: false, + }, + } + } + + let widgets = ProcessListItemWidgets { label }; + (root_box, widgets) + } + + fn bind(&mut self, widgets: &mut Self::Widgets, _root: &mut Self::Root) { + let text = format!("{} ({})", self.0.name, self.0.cmdline); + widgets.label.set_markup(&text); + } +} diff --git a/lact-gui/src/app/msg.rs b/lact-gui/src/app/msg.rs index f137aee..e1f5d91 100644 --- a/lact-gui/src/app/msg.rs +++ b/lact-gui/src/app/msg.rs @@ -1,13 +1,15 @@ use super::confirmation_dialog::ConfirmationOptions; use lact_client::ConnectionStatusMsg; -use lact_schema::{request::ProfileBase, DeviceStats}; -use std::rc::Rc; +use lact_schema::{request::ProfileBase, DeviceStats, ProfileRule}; +use std::sync::Arc; #[derive(Debug, Clone)] pub enum AppMsg { - Error(Rc), - ReloadData { full: bool }, - Stats(Rc), + Error(Arc), + ReloadData { + full: bool, + }, + Stats(Arc), ApplyChanges, RevertChanges, ResetClocks, @@ -18,10 +20,21 @@ pub enum AppMsg { EnableOverdrive, DisableOverdrive, ResetConfig, - ReloadProfiles, - SelectProfile(Option), + ReloadProfiles { + include_state: bool, + }, + SelectProfile { + profile: Option, + auto_switch: bool, + }, CreateProfile(String, ProfileBase), DeleteProfile(String), + MoveProfile(String, usize), + EvaluateProfile(ProfileRule), + SetProfileRule { + name: String, + rule: Option, + }, ConnectionStatus(ConnectionStatusMsg), AskConfirmation(ConfirmationOptions, Box), } diff --git a/lact-gui/src/app/pages.rs b/lact-gui/src/app/pages.rs index c91f7c7..0d1a551 100644 --- a/lact-gui/src/app/pages.rs +++ b/lact-gui/src/app/pages.rs @@ -4,15 +4,14 @@ pub mod oc_page; pub mod software_page; pub mod thermals_page; -use std::rc::Rc; - use gtk::{prelude::*, *}; use lact_schema::{DeviceInfo, DeviceStats}; +use std::sync::Arc; #[derive(Debug)] pub enum PageUpdate { - Info(Rc), - Stats(Rc), + Info(Arc), + Stats(Arc), } fn values_row>( diff --git a/lact-gui/src/lib.rs b/lact-gui/src/lib.rs index 174b563..255a22e 100644 --- a/lact-gui/src/lib.rs +++ b/lact-gui/src/lib.rs @@ -1,7 +1,7 @@ -pub mod app; +mod app; use anyhow::Context; -use app::AppModel; +use app::{AppModel, APP_BROKER}; use lact_schema::args::GuiArgs; use relm4::RelmApp; use tracing::metadata::LevelFilter; @@ -17,7 +17,9 @@ pub fn run(args: GuiArgs) -> anyhow::Result<()> { .context("Invalid log level")?; tracing_subscriber::fmt().with_env_filter(env_filter).init(); - let app = RelmApp::new(APP_ID).with_args(vec![]); - app.run_async::(args); + RelmApp::new(APP_ID) + .with_broker(&APP_BROKER) + .with_args(vec![]) + .run_async::(args); Ok(()) } diff --git a/lact-schema/src/lib.rs b/lact-schema/src/lib.rs index eed0a86..a4de06d 100644 --- a/lact-schema/src/lib.rs +++ b/lact-schema/src/lib.rs @@ -1,5 +1,6 @@ #[cfg(feature = "args")] pub mod args; +mod profiles; pub mod request; mod response; @@ -17,14 +18,15 @@ use amdgpu_sysfs::{ }, hw_mon::Temperature, }; -use indexmap::IndexMap; +use indexmap::{IndexMap, IndexSet}; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; use std::{ borrow::Cow, - collections::{BTreeMap, HashMap}, + collections::{BTreeMap, HashMap, HashSet}, fmt, str::FromStr, + sync::Arc, }; pub const GIT_COMMIT: &str = env!("VERGEN_GIT_SHA"); @@ -349,8 +351,64 @@ pub struct FanOptions<'a> { pub change_threshold: Option, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] +#[derive(Serialize, Deserialize, Debug, Default)] pub struct ProfilesInfo { - pub profiles: Vec, + pub profiles: IndexMap>, pub current_profile: Option, + pub auto_switch: bool, + pub watcher_state: Option, +} + +impl PartialEq for ProfilesInfo { + fn eq(&self, other: &Self) -> bool { + self.profiles.as_slice() == other.profiles.as_slice() + && self.current_profile == other.current_profile + && self.auto_switch == other.auto_switch + } +} + +#[skip_serializing_none] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(tag = "type", content = "filter", rename_all = "lowercase")] +pub enum ProfileRule { + Process(ProcessProfileRule), + Gamemode(Option), +} + +impl Default for ProfileRule { + fn default() -> Self { + Self::Process(ProcessProfileRule::default()) + } +} + +#[skip_serializing_none] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub struct ProcessProfileRule { + pub name: Arc, + pub args: Option, +} + +impl Default for ProcessProfileRule { + fn default() -> Self { + Self { + name: String::new().into(), + args: None, + } + } +} + +pub type ProcessMap = IndexMap; + +#[derive(Serialize, Deserialize, Clone, Default)] +pub struct ProfileWatcherState { + pub process_list: ProcessMap, + pub gamemode_games: IndexSet, + pub process_names_map: HashMap, HashSet>, +} + +#[allow(clippy::module_name_repetitions)] +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct ProcessInfo { + pub name: Arc, + pub cmdline: Box, } diff --git a/lact-schema/src/profiles.rs b/lact-schema/src/profiles.rs new file mode 100644 index 0000000..5db18aa --- /dev/null +++ b/lact-schema/src/profiles.rs @@ -0,0 +1,42 @@ +use crate::{ProcessInfo, ProfileWatcherState}; +use std::{collections::hash_map::Entry, fmt}; + +impl fmt::Debug for ProfileWatcherState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ProfileWatcherState") + .field("process_list", &self.process_list.len()) + .field("gamemode_games", &self.gamemode_games.len()) + .field("process_names_map", &self.process_names_map.len()) + .finish() + } +} + +impl ProfileWatcherState { + pub fn push_process(&mut self, pid: i32, info: ProcessInfo) { + let name = info.name.clone(); + + if let Some(old_info) = self.process_list.insert(pid, info) { + // In case we replaced a process with the same PID (this should normally never happen, but maybe we missed an exit event?) + // the old name needs to be dropped as well. + if let Entry::Occupied(mut entry) = self.process_names_map.entry(old_info.name) { + entry.get_mut().remove(&pid); + if entry.get().is_empty() { + entry.remove(); + } + } + } + + self.process_names_map.entry(name).or_default().insert(pid); + } + + pub fn remove_process(&mut self, pid: i32) { + if let Some(info) = self.process_list.shift_remove(&pid) { + if let Entry::Occupied(mut entry) = self.process_names_map.entry(info.name) { + entry.get_mut().remove(&pid); + if entry.get().is_empty() { + entry.remove(); + } + } + } + } +} diff --git a/lact-schema/src/request.rs b/lact-schema/src/request.rs index 8afc65f..10347f2 100644 --- a/lact-schema/src/request.rs +++ b/lact-schema/src/request.rs @@ -1,6 +1,6 @@ use std::fmt; -use crate::FanOptions; +use crate::{FanOptions, ProfileRule}; use amdgpu_sysfs::gpu_handle::{PerformanceLevel, PowerLevelKind}; use serde::{Deserialize, Serialize}; @@ -59,9 +59,14 @@ pub enum Request<'a> { VbiosDump { id: &'a str, }, - ListProfiles, + ListProfiles { + #[serde(default)] + include_state: bool, + }, SetProfile { name: Option, + #[serde(default)] + auto_switch: bool, }, CreateProfile { name: String, @@ -70,6 +75,17 @@ pub enum Request<'a> { DeleteProfile { name: String, }, + MoveProfile { + name: String, + new_position: usize, + }, + EvaluateProfileRule { + rule: ProfileRule, + }, + SetProfileRule { + name: String, + rule: Option, + }, EnableOverdrive, DisableOverdrive, GenerateSnapshot, diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..5198580 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "1.78.0"