mirror of
https://github.com/ilya-zlobintsev/LACT.git
synced 2025-02-25 18:55:26 -06:00
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
This commit is contained in:
parent
f0a878909c
commit
64a2d3106b
215
Cargo.lock
generated
215
Cargo.lock
generated
@ -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]]
|
||||
|
@ -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"
|
||||
|
@ -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<u8>);
|
||||
|
||||
pub async fn set_profile(&self, name: Option<String>) -> anyhow::Result<()> {
|
||||
self.make_request(Request::SetProfile { name })
|
||||
pub async fn list_profiles(&self, include_state: bool) -> anyhow::Result<ProfilesInfo> {
|
||||
self.make_request(Request::ListProfiles { include_state })
|
||||
.await?
|
||||
.inner()
|
||||
}
|
||||
|
||||
pub async fn set_profile(&self, name: Option<String>, 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<bool> {
|
||||
self.make_request(Request::EvaluateProfileRule { rule })
|
||||
.await?
|
||||
.inner()
|
||||
}
|
||||
|
||||
pub async fn set_profile_rule(
|
||||
&self,
|
||||
name: String,
|
||||
rule: Option<ProfileRule>,
|
||||
) -> anyhow::Result<()> {
|
||||
self.make_request(Request::SetProfileRule { name, rule })
|
||||
.await?
|
||||
.inner()
|
||||
}
|
||||
|
||||
pub async fn set_performance_level(
|
||||
&self,
|
||||
id: &str,
|
||||
|
@ -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
|
||||
|
5
lact-daemon/benches/daemon.rs
Normal file
5
lact-daemon/benches/daemon.rs
Normal file
@ -0,0 +1,5 @@
|
||||
fn main() {
|
||||
// Include the daemon lib
|
||||
let _ = lact_daemon::MODULE_CONF_PATH;
|
||||
divan::main();
|
||||
}
|
@ -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<String, Gpu>,
|
||||
#[serde(default, skip_serializing_if = "IndexMap::is_empty")]
|
||||
pub profiles: IndexMap<String, Profile>,
|
||||
gpus: IndexMap<String, Gpu>,
|
||||
#[serde(default, skip_serializing_if = "IndexMap::is_empty")]
|
||||
pub profiles: IndexMap<Rc<str>, Profile>,
|
||||
#[serde(default)]
|
||||
pub current_profile: Option<String>,
|
||||
pub current_profile: Option<Rc<str>>,
|
||||
#[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<String, Gpu>,
|
||||
#[serde(default, skip_serializing_if = "IndexMap::is_empty")]
|
||||
pub gpus: IndexMap<String, Gpu>,
|
||||
pub rule: Option<ProfileRule>,
|
||||
}
|
||||
|
||||
#[skip_serializing_none]
|
||||
@ -177,14 +185,13 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save(&self, config_last_saved: &Mutex<Instant>) -> anyhow::Result<()> {
|
||||
pub fn save(&self, config_last_saved: &Cell<Instant>) -> 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<String, Gpu>> {
|
||||
pub fn gpus(&self) -> anyhow::Result<&IndexMap<String, Gpu>> {
|
||||
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<String, Gpu>> {
|
||||
pub fn gpus_mut(&mut self) -> anyhow::Result<&mut IndexMap<String, Gpu>> {
|
||||
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<Mutex<Instant>>) -> mpsc::UnboundedReceiver<Config> {
|
||||
pub fn start_watcher(config_last_saved: Rc<Cell<Instant>>) -> mpsc::UnboundedReceiver<Config> {
|
||||
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<Mutex<Instant>>) -> 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");
|
||||
|
@ -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(())
|
||||
})
|
||||
|
@ -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?),
|
||||
|
@ -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<RefCell<Config>>,
|
||||
pub gpu_controllers: Rc<BTreeMap<String, Box<dyn GpuController>>>,
|
||||
confirm_config_tx: Rc<RefCell<Option<oneshot::Sender<ConfirmCommand>>>>,
|
||||
pub config_last_saved: Arc<Mutex<Instant>>,
|
||||
pub config_last_saved: Rc<Cell<Instant>>,
|
||||
pub profile_watcher_tx: Rc<RefCell<Option<mpsc::Sender<ProfileWatcherCommand>>>>,
|
||||
pub profile_watcher_state: Rc<RefCell<Option<ProfileWatcherState>>>,
|
||||
}
|
||||
|
||||
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<F: FnOnce(&mut config::Gpu)>(
|
||||
&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<String>) -> anyhow::Result<()> {
|
||||
pub async fn set_profile(
|
||||
&self,
|
||||
name: Option<Rc<str>>,
|
||||
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<Rc<str>>) -> anyhow::Result<()> {
|
||||
if let Some(name) = &name {
|
||||
self.config.borrow().profile(name)?;
|
||||
}
|
||||
@ -699,14 +764,14 @@ 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<()> {
|
||||
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) {
|
||||
if config.profiles.contains_key(name.as_str()) {
|
||||
bail!("Profile {name} already exists");
|
||||
}
|
||||
|
||||
@ -715,20 +780,92 @@ impl<'a> Handler {
|
||||
ProfileBase::Default => config.default_profile(),
|
||||
ProfileBase::Profile(name) => config.profile(&name)?.clone(),
|
||||
};
|
||||
config.profiles.insert(name, profile);
|
||||
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;
|
||||
}
|
||||
|
||||
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<ProfileRule>,
|
||||
) -> 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<bool> {
|
||||
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
|
||||
|
397
lact-daemon/src/server/profiles.rs
Normal file
397
lact-daemon/src/server/profiles.rs
Normal file
@ -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<ProfileWatcherCommand>) {
|
||||
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<Item = (&'a Rc<str>, &'a ProfileRule)>,
|
||||
) -> Option<&'a Rc<str>> {
|
||||
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))),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
143
lact-daemon/src/server/profiles/gamemode.rs
Normal file
143
lact-daemon/src/server/profiles/gamemode.rs
Normal file
@ -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<i32>;
|
||||
|
||||
fn list_games(&self) -> zbus::Result<Vec<(i32, OwnedObjectPath)>>;
|
||||
|
||||
#[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<i32>;
|
||||
|
||||
#[zbus(property)]
|
||||
fn executable(&self) -> zbus::Result<String>;
|
||||
}
|
||||
|
||||
pub async fn connect(process_list: &ProcessMap) -> Option<GameModeProxy<'static>> {
|
||||
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)
|
||||
}
|
77
lact-daemon/src/server/profiles/process.rs
Normal file
77
lact-daemon/src/server/profiles/process.rs
Normal file
@ -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::<i32>().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<ProfileWatcherEvent>) {
|
||||
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<ProcessInfo> {
|
||||
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(),
|
||||
})
|
||||
}
|
@ -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<AppMsg> = 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<Self>,
|
||||
root: >k::ApplicationWindow,
|
||||
widgets: &AppModelWidgets,
|
||||
) -> Result<(), Rc<anyhow::Error>> {
|
||||
) -> Result<(), Arc<anyhow::Error>> {
|
||||
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<AppModel>,
|
||||
header_sender: relm4::Sender<HeaderMsg>,
|
||||
) -> 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:#}");
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@ -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<DeviceStats>),
|
||||
Stats(Arc<DeviceStats>),
|
||||
VramClockRatio(f64),
|
||||
Refresh,
|
||||
Show,
|
||||
|
@ -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<ProfileRuleWindowMsg> = MessageBroker::new();
|
||||
|
||||
pub struct Header {
|
||||
profiles_info: ProfilesInfo,
|
||||
gpu_selector: TypedListView<GpuListItem, gtk::SingleSelection>,
|
||||
profile_selector: TypedListView<ProfileListItem, gtk::SingleSelection>,
|
||||
profile_selector: FactoryVecDeque<ProfileRow>,
|
||||
selector_label: String,
|
||||
stack: Option<Stack>,
|
||||
rule_window: relm4::Controller<ProfileRuleWindow>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum HeaderMsg {
|
||||
Stack(Stack),
|
||||
Profiles(ProfilesInfo),
|
||||
Profiles(std::boxed::Box<ProfilesInfo>),
|
||||
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,19 +117,11 @@ 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!(
|
||||
let profile_selector = FactoryVecDeque::<ProfileRow>::builder()
|
||||
.launch_default()
|
||||
.forward(sender.input_sender(), |msg| msg);
|
||||
profile_selector.widget().connect_row_selected(clone!(
|
||||
#[strong]
|
||||
sender,
|
||||
move |_, _, _| {
|
||||
sender.input(HeaderMsg::SelectProfile);
|
||||
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();
|
||||
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(selected_profile))
|
||||
.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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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(|| "<Unknown>".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());
|
||||
}
|
||||
}
|
||||
|
@ -43,7 +43,7 @@ impl Component for NewProfileDialog {
|
||||
set_spacing: 5,
|
||||
|
||||
gtk::Label {
|
||||
set_label: "Base profile:",
|
||||
set_label: "Copy settings from:",
|
||||
},
|
||||
|
||||
#[local_ref]
|
||||
|
116
lact-gui/src/app/header/profile_row.rs
Normal file
116
lact-gui/src/app/header/profile_row.rs
Normal file
@ -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<ProfileRule>,
|
||||
},
|
||||
}
|
||||
|
||||
impl ProfileRowType {
|
||||
pub fn name(&self) -> Option<String> {
|
||||
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 {
|
||||
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)
|
||||
}
|
475
lact-gui/src/app/header/profile_rule_window.rs
Normal file
475
lact-gui/src/app/header/profile_rule_window.rs
Normal file
@ -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<ProcessListItem, SingleSelection>,
|
||||
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!("<span font_desc='11'><b>Activate profile '{}' when:</b></span>", 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 <b>{}</b>",
|
||||
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<Self>,
|
||||
) -> ComponentParts<Self> {
|
||||
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<Self>,
|
||||
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!("<b>{}</b> ({})", self.0.name, self.0.cmdline);
|
||||
widgets.label.set_markup(&text);
|
||||
}
|
||||
}
|
@ -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<anyhow::Error>),
|
||||
ReloadData { full: bool },
|
||||
Stats(Rc<DeviceStats>),
|
||||
Error(Arc<anyhow::Error>),
|
||||
ReloadData {
|
||||
full: bool,
|
||||
},
|
||||
Stats(Arc<DeviceStats>),
|
||||
ApplyChanges,
|
||||
RevertChanges,
|
||||
ResetClocks,
|
||||
@ -18,10 +20,21 @@ pub enum AppMsg {
|
||||
EnableOverdrive,
|
||||
DisableOverdrive,
|
||||
ResetConfig,
|
||||
ReloadProfiles,
|
||||
SelectProfile(Option<String>),
|
||||
ReloadProfiles {
|
||||
include_state: bool,
|
||||
},
|
||||
SelectProfile {
|
||||
profile: Option<String>,
|
||||
auto_switch: bool,
|
||||
},
|
||||
CreateProfile(String, ProfileBase),
|
||||
DeleteProfile(String),
|
||||
MoveProfile(String, usize),
|
||||
EvaluateProfile(ProfileRule),
|
||||
SetProfileRule {
|
||||
name: String,
|
||||
rule: Option<ProfileRule>,
|
||||
},
|
||||
ConnectionStatus(ConnectionStatusMsg),
|
||||
AskConfirmation(ConfirmationOptions, Box<AppMsg>),
|
||||
}
|
||||
|
@ -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<DeviceInfo>),
|
||||
Stats(Rc<DeviceStats>),
|
||||
Info(Arc<DeviceInfo>),
|
||||
Stats(Arc<DeviceStats>),
|
||||
}
|
||||
|
||||
fn values_row<W: IsA<Widget>>(
|
||||
|
@ -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::<AppModel>(args);
|
||||
RelmApp::new(APP_ID)
|
||||
.with_broker(&APP_BROKER)
|
||||
.with_args(vec![])
|
||||
.run_async::<AppModel>(args);
|
||||
Ok(())
|
||||
}
|
||||
|
@ -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<u64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
|
||||
#[derive(Serialize, Deserialize, Debug, Default)]
|
||||
pub struct ProfilesInfo {
|
||||
pub profiles: Vec<String>,
|
||||
pub profiles: IndexMap<String, Option<ProfileRule>>,
|
||||
pub current_profile: Option<String>,
|
||||
pub auto_switch: bool,
|
||||
pub watcher_state: Option<ProfileWatcherState>,
|
||||
}
|
||||
|
||||
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<ProcessProfileRule>),
|
||||
}
|
||||
|
||||
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<str>,
|
||||
pub args: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for ProcessProfileRule {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
name: String::new().into(),
|
||||
args: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type ProcessMap = IndexMap<i32, ProcessInfo>;
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Default)]
|
||||
pub struct ProfileWatcherState {
|
||||
pub process_list: ProcessMap,
|
||||
pub gamemode_games: IndexSet<i32>,
|
||||
pub process_names_map: HashMap<Arc<str>, HashSet<i32>>,
|
||||
}
|
||||
|
||||
#[allow(clippy::module_name_repetitions)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct ProcessInfo {
|
||||
pub name: Arc<str>,
|
||||
pub cmdline: Box<str>,
|
||||
}
|
||||
|
42
lact-schema/src/profiles.rs
Normal file
42
lact-schema/src/profiles.rs
Normal file
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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<String>,
|
||||
#[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<ProfileRule>,
|
||||
},
|
||||
EnableOverdrive,
|
||||
DisableOverdrive,
|
||||
GenerateSnapshot,
|
||||
|
2
rust-toolchain.toml
Normal file
2
rust-toolchain.toml
Normal file
@ -0,0 +1,2 @@
|
||||
[toolchain]
|
||||
channel = "1.78.0"
|
Loading…
Reference in New Issue
Block a user