mirror of
https://github.com/nekename/OpenDeck.git
synced 2026-10-28 09:44:01 +00:00
Co-authored-by: nekename <63245705+nekename@users.noreply.github.com>
394 lines
14 KiB
Rust
394 lines
14 KiB
Rust
use super::Store;
|
|
|
|
use crate::shared::{ActionInstance, DEVICES, DeviceInfo, Profile, config_dir, copy_dir};
|
|
|
|
use std::collections::HashMap;
|
|
use std::fs;
|
|
use std::path::PathBuf;
|
|
use std::sync::LazyLock;
|
|
|
|
use anyhow::{Context, anyhow};
|
|
use dashmap::DashMap;
|
|
use serde::{Deserialize, Serialize};
|
|
use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard};
|
|
use tokio::task::JoinHandle;
|
|
|
|
pub struct ProfileStores {
|
|
stores: HashMap<String, Store<Profile>>,
|
|
}
|
|
|
|
impl ProfileStores {
|
|
fn canonical_id(device: &str, id: &str) -> String {
|
|
if cfg!(target_os = "windows") {
|
|
PathBuf::from(device).join(id.replace('/', "\\")).to_str().unwrap().to_owned()
|
|
} else {
|
|
PathBuf::from(device).join(id).to_str().unwrap().to_owned()
|
|
}
|
|
}
|
|
|
|
pub fn get_profile_store(&self, device: &DeviceInfo, id: &str) -> Result<&Store<Profile>, anyhow::Error> {
|
|
self.stores.get(&Self::canonical_id(&device.id, id)).ok_or_else(|| anyhow!("profile not found"))
|
|
}
|
|
|
|
pub async fn get_profile_store_mut(&mut self, device: &DeviceInfo, id: &str) -> Result<&mut Store<Profile>, anyhow::Error> {
|
|
let canonical_id = Self::canonical_id(&device.id, id);
|
|
if self.stores.contains_key(&canonical_id) {
|
|
Ok(self.stores.get_mut(&canonical_id).unwrap())
|
|
} else {
|
|
let default = Profile {
|
|
id: id.to_owned(),
|
|
keys: Vec::new(),
|
|
sliders: Vec::new(),
|
|
infobars: Vec::new(),
|
|
};
|
|
|
|
let mut store = Store::new(&canonical_id, &config_dir().join("profiles"), default).context(format!("Failed to create store for profile {}", canonical_id))?;
|
|
store.value.keys.resize((device.rows * device.columns + device.touchpoints) as usize, None);
|
|
store.value.sliders.resize(device.encoders as usize, None);
|
|
store.value.infobars.resize(device.infobars as usize, None);
|
|
|
|
let categories = crate::shared::CATEGORIES.read().await;
|
|
let actions = categories.values().flat_map(|v| v.actions.iter()).collect::<Vec<_>>();
|
|
let plugins_dir = config_dir().join("plugins");
|
|
let registered = crate::events::registered_plugins().await;
|
|
let keep_instance = |instance: &ActionInstance| -> bool {
|
|
instance.action.plugin == "opendeck"
|
|
|| (plugins_dir.join(&instance.action.plugin).exists() && (!registered.contains(&instance.action.plugin) || actions.iter().any(|v| v.uuid == instance.action.uuid)))
|
|
};
|
|
for slot in store.value.keys.iter_mut().chain(store.value.sliders.iter_mut()).chain(store.value.infobars.iter_mut()) {
|
|
if let Some(instance) = slot {
|
|
if !keep_instance(instance) {
|
|
*slot = None;
|
|
} else if let Some(children) = &mut instance.children {
|
|
children.retain_mut(|child| keep_instance(child));
|
|
}
|
|
}
|
|
}
|
|
|
|
// We need to populate instances from a profile without encoders with them
|
|
for slot in store.value.sliders.iter_mut() {
|
|
if let Some(instance) = slot
|
|
&& instance.action.encoder.is_none()
|
|
&& let Some(action) = actions.iter().find(|a| a.uuid == *instance.action.uuid)
|
|
&& action.encoder.is_some()
|
|
{
|
|
instance.action.encoder = action.encoder.clone();
|
|
}
|
|
}
|
|
|
|
store.save()?;
|
|
|
|
self.stores.insert(canonical_id.clone(), store);
|
|
Ok(self.stores.get_mut(&canonical_id).unwrap())
|
|
}
|
|
}
|
|
|
|
pub fn remove_profile(&mut self, device: &str, id: &str) {
|
|
self.stores.remove(&Self::canonical_id(device, id));
|
|
}
|
|
|
|
pub fn delete_profile(&mut self, device: &str, id: &str) {
|
|
self.remove_profile(device, id);
|
|
let config_dir = config_dir();
|
|
#[cfg(target_os = "windows")]
|
|
let id = &id.replace('/', "\\");
|
|
let path = config_dir.join("profiles").join(device).join(format!("{id}.json"));
|
|
let _ = fs::remove_file(&path);
|
|
// This is safe as `remove_dir` errors if the directory is not empty.
|
|
let _ = fs::remove_dir(path.parent().unwrap());
|
|
let images_path = config_dir.join("images").join(device).join(id);
|
|
let _ = fs::remove_dir_all(images_path);
|
|
}
|
|
|
|
pub async fn rename_profile(&mut self, device: &DeviceInfo, old_id: &str, new_id: &str, retain: bool) -> Result<(), anyhow::Error> {
|
|
if !retain {
|
|
// Remove from the store but don't delete the file
|
|
self.remove_profile(&device.id, old_id);
|
|
}
|
|
|
|
let config_dir = config_dir();
|
|
|
|
// Construct old and new paths (handling Windows path separators)
|
|
#[cfg(target_os = "windows")]
|
|
let old_path_id = old_id.replace('/', "\\");
|
|
#[cfg(not(target_os = "windows"))]
|
|
let old_path_id = old_id;
|
|
|
|
#[cfg(target_os = "windows")]
|
|
let new_path_id = new_id.replace('/', "\\");
|
|
#[cfg(not(target_os = "windows"))]
|
|
let new_path_id = new_id;
|
|
|
|
let old_path = config_dir.join("profiles").join(&device.id).join(format!("{}.json", old_path_id));
|
|
let new_path = config_dir.join("profiles").join(&device.id).join(format!("{}.json", new_path_id));
|
|
|
|
// Create parent directory for new path if it doesn't exist
|
|
if let Some(parent) = new_path.parent() {
|
|
fs::create_dir_all(parent)?;
|
|
}
|
|
|
|
// Rename the profile file
|
|
if !retain {
|
|
fs::rename(&old_path, &new_path)?;
|
|
|
|
// Clean up empty old directory if profile was in a folder
|
|
if let Some(parent) = old_path.parent() {
|
|
// This is safe as `remove_dir` errors if the directory is not empty.
|
|
let _ = fs::remove_dir(parent);
|
|
}
|
|
} else {
|
|
fs::copy(&old_path, &new_path)?;
|
|
}
|
|
|
|
// Rename images directory if it exists
|
|
let old_images_path = config_dir.join("images").join(&device.id).join(old_path_id);
|
|
let new_images_path = config_dir.join("images").join(&device.id).join(new_path_id);
|
|
|
|
if old_images_path.exists() {
|
|
if let Some(parent) = new_images_path.parent() {
|
|
fs::create_dir_all(parent)?;
|
|
}
|
|
|
|
if !retain {
|
|
fs::rename(&old_images_path, &new_images_path)?;
|
|
|
|
// Clean up empty old images directory
|
|
if let Some(parent) = old_images_path.parent() {
|
|
// This is safe as `remove_dir` errors if the directory is not empty.
|
|
let _ = fs::remove_dir(parent);
|
|
}
|
|
} else {
|
|
copy_dir(&old_images_path, &new_images_path)?;
|
|
}
|
|
}
|
|
|
|
// Reload the new profile
|
|
self.get_profile_store_mut(device, new_id).await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub fn all_from_plugin(&self, plugin: &str) -> Vec<crate::shared::ActionContext> {
|
|
let mut all = vec![];
|
|
for store in self.stores.values() {
|
|
for instance in store.value.keys.iter().chain(&store.value.sliders).chain(&store.value.infobars).flatten() {
|
|
if instance.action.plugin == plugin {
|
|
all.push(instance.context.clone());
|
|
} else if let Some(children) = &instance.children {
|
|
for child in children {
|
|
if child.action.plugin == plugin {
|
|
all.push(child.context.clone());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
all
|
|
}
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize)]
|
|
pub struct DeviceConfig {
|
|
pub selected_profile: String,
|
|
}
|
|
|
|
impl super::NotProfile for DeviceConfig {}
|
|
|
|
pub struct DeviceStores {
|
|
stores: HashMap<String, Store<DeviceConfig>>,
|
|
}
|
|
|
|
impl DeviceStores {
|
|
pub fn get_selected_profile(&mut self, device: &str) -> Result<String, anyhow::Error> {
|
|
if !self.stores.contains_key(device) {
|
|
let default = DeviceConfig {
|
|
selected_profile: "Default".to_owned(),
|
|
};
|
|
|
|
let store = Store::new(device, &config_dir().join("profiles"), default).context(format!("Failed to create store for device config {}", device))?;
|
|
store.save()?;
|
|
|
|
self.stores.insert(device.to_owned(), store);
|
|
}
|
|
|
|
let from_store = &self.stores.get(device).unwrap().value.selected_profile;
|
|
let all = get_device_profiles(device)?;
|
|
if all.contains(from_store) { Ok(from_store.clone()) } else { Ok(all.first().unwrap().clone()) }
|
|
}
|
|
|
|
pub fn set_selected_profile(&mut self, device: &str, id: String) -> Result<(), anyhow::Error> {
|
|
if self.stores.contains_key(device) {
|
|
let store = self.stores.get_mut(device).unwrap();
|
|
store.value.selected_profile = id;
|
|
store.save()?;
|
|
} else {
|
|
let default = DeviceConfig { selected_profile: id };
|
|
|
|
let store = Store::new(device, &config_dir().join("profiles"), default).context(format!("Failed to create store for device config {}", device))?;
|
|
store.save()?;
|
|
|
|
self.stores.insert(device.to_owned(), store);
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
pub fn get_device_profiles(device: &str) -> Result<Vec<String>, anyhow::Error> {
|
|
let mut profiles: Vec<String> = vec![];
|
|
|
|
let device_path = config_dir().join("profiles").join(device);
|
|
fs::create_dir_all(&device_path)?;
|
|
let entries = fs::read_dir(device_path)?;
|
|
|
|
for entry in entries.flatten() {
|
|
if entry.metadata()?.is_file() {
|
|
let mut id = entry.file_name().to_string_lossy().into_owned();
|
|
if id.ends_with(".json") {
|
|
id.truncate(id.len() - 5);
|
|
} else if id.ends_with(".json.bak") {
|
|
id.truncate(id.len() - 9);
|
|
} else if id.ends_with(".json.temp") {
|
|
id.truncate(id.len() - 10);
|
|
} else {
|
|
continue;
|
|
}
|
|
profiles.push(id);
|
|
} else if entry.metadata()?.is_dir() {
|
|
let entries = fs::read_dir(entry.path())?;
|
|
for subentry in entries.flatten() {
|
|
if subentry.metadata()?.is_file() {
|
|
let mut id = format!("{}/{}", entry.file_name().to_string_lossy(), &subentry.file_name().to_string_lossy());
|
|
if id.ends_with(".json") {
|
|
id.truncate(id.len() - 5);
|
|
} else if id.ends_with(".json.bak") {
|
|
id.truncate(id.len() - 9);
|
|
} else if id.ends_with(".json.temp") {
|
|
id.truncate(id.len() - 10);
|
|
} else {
|
|
continue;
|
|
}
|
|
profiles.push(id);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if profiles.is_empty() {
|
|
profiles.push("Default".to_owned());
|
|
}
|
|
|
|
Ok(profiles)
|
|
}
|
|
|
|
/// A singleton object to contain all active Store instances that hold a profile.
|
|
pub static PROFILE_STORES: LazyLock<RwLock<ProfileStores>> = LazyLock::new(|| RwLock::new(ProfileStores { stores: HashMap::new() }));
|
|
|
|
/// A singleton object to manage Store instances for device configurations.
|
|
pub static DEVICE_STORES: LazyLock<RwLock<DeviceStores>> = LazyLock::new(|| RwLock::new(DeviceStores { stores: HashMap::new() }));
|
|
|
|
pub struct Locks<'a> {
|
|
#[allow(dead_code)]
|
|
pub device_stores: RwLockReadGuard<'a, DeviceStores>,
|
|
pub profile_stores: RwLockReadGuard<'a, ProfileStores>,
|
|
}
|
|
|
|
pub async fn acquire_locks() -> Locks<'static> {
|
|
let device_stores = DEVICE_STORES.read().await;
|
|
let profile_stores = PROFILE_STORES.read().await;
|
|
Locks { device_stores, profile_stores }
|
|
}
|
|
|
|
pub struct LocksMut<'a> {
|
|
pub device_stores: RwLockWriteGuard<'a, DeviceStores>,
|
|
pub profile_stores: RwLockWriteGuard<'a, ProfileStores>,
|
|
}
|
|
|
|
pub async fn acquire_locks_mut() -> LocksMut<'static> {
|
|
let device_stores = DEVICE_STORES.write().await;
|
|
let profile_stores = PROFILE_STORES.write().await;
|
|
LocksMut { device_stores, profile_stores }
|
|
}
|
|
|
|
pub async fn get_slot<'a>(context: &crate::shared::Context, locks: &'a Locks<'_>) -> Result<&'a Option<crate::shared::ActionInstance>, anyhow::Error> {
|
|
let device = DEVICES.get(&context.device).ok_or_else(|| anyhow!("device not found"))?;
|
|
let store = locks.profile_stores.get_profile_store(&device, &context.profile)?;
|
|
|
|
let configured = match &context.controller[..] {
|
|
"Encoder" => store.value.sliders.get(context.position as usize).ok_or_else(|| anyhow!("index out of bounds"))?,
|
|
"Infobar" => store.value.infobars.get(context.position as usize).ok_or_else(|| anyhow!("index out of bounds"))?,
|
|
_ => store.value.keys.get(context.position as usize).ok_or_else(|| anyhow!("index out of bounds"))?,
|
|
};
|
|
|
|
Ok(configured)
|
|
}
|
|
|
|
pub async fn get_slot_mut<'a>(context: &crate::shared::Context, locks: &'a mut LocksMut<'_>) -> Result<&'a mut Option<crate::shared::ActionInstance>, anyhow::Error> {
|
|
let device = DEVICES.get(&context.device).ok_or_else(|| anyhow!("device not found"))?;
|
|
let store = locks.profile_stores.get_profile_store_mut(&device, &context.profile).await?;
|
|
|
|
let configured = match &context.controller[..] {
|
|
"Encoder" => store.value.sliders.get_mut(context.position as usize).ok_or_else(|| anyhow!("index out of bounds"))?,
|
|
"Infobar" => store.value.infobars.get_mut(context.position as usize).ok_or_else(|| anyhow!("index out of bounds"))?,
|
|
_ => store.value.keys.get_mut(context.position as usize).ok_or_else(|| anyhow!("index out of bounds"))?,
|
|
};
|
|
|
|
Ok(configured)
|
|
}
|
|
|
|
pub async fn get_instance<'a>(context: &crate::shared::ActionContext, locks: &'a Locks<'_>) -> Result<Option<&'a crate::shared::ActionInstance>, anyhow::Error> {
|
|
let slot = get_slot(&(context.into()), locks).await?;
|
|
if let Some(instance) = slot {
|
|
if instance.context == *context {
|
|
return Ok(Some(instance));
|
|
} else if let Some(children) = &instance.children {
|
|
for child in children {
|
|
if child.context == *context {
|
|
return Ok(Some(child));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(None)
|
|
}
|
|
|
|
pub async fn get_instance_mut<'a>(context: &crate::shared::ActionContext, locks: &'a mut LocksMut<'_>) -> Result<Option<&'a mut crate::shared::ActionInstance>, anyhow::Error> {
|
|
let slot = get_slot_mut(&(context.into()), locks).await?;
|
|
if let Some(instance) = slot {
|
|
if instance.context == *context {
|
|
return Ok(Some(instance));
|
|
} else if let Some(children) = &mut instance.children {
|
|
for child in children {
|
|
if child.context == *context {
|
|
return Ok(Some(child));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(None)
|
|
}
|
|
|
|
pub async fn save_profile(device: &str, locks: &mut LocksMut<'_>) -> Result<(), anyhow::Error> {
|
|
let selected_profile = locks.device_stores.get_selected_profile(device)?;
|
|
let device = DEVICES.get(device).ok_or_else(|| anyhow!("device not found"))?;
|
|
let store = locks.profile_stores.get_profile_store(&device, &selected_profile)?;
|
|
store.save()
|
|
}
|
|
|
|
pub static PROFILE_SAVE_DEBOUNCE: LazyLock<DashMap<crate::shared::ActionContext, JoinHandle<()>>> = LazyLock::new(DashMap::new);
|
|
pub fn debounce_profile_save(context: crate::shared::ActionContext) {
|
|
if let Some((_, handle)) = PROFILE_SAVE_DEBOUNCE.remove(&context) {
|
|
handle.abort();
|
|
}
|
|
PROFILE_SAVE_DEBOUNCE.insert(
|
|
context.clone(),
|
|
tokio::spawn(async move {
|
|
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
|
let mut locks = acquire_locks_mut().await;
|
|
if let Err(error) = save_profile(&context.device, &mut locks).await {
|
|
log::error!("Failed to save profile for device {}: {error}", context.device);
|
|
}
|
|
PROFILE_SAVE_DEBOUNCE.remove(&context);
|
|
}),
|
|
);
|
|
}
|