diff --git a/src-tauri/src/events/frontend/instances.rs b/src-tauri/src/events/frontend/instances.rs index bc224c94..69595ba3 100644 --- a/src-tauri/src/events/frontend/instances.rs +++ b/src-tauri/src/events/frontend/instances.rs @@ -26,6 +26,7 @@ pub async fn create_instance(app: AppHandle, action: Action, context: Context) - action: action.clone(), context: ActionContext::from_context(context.clone(), index), states: action.states.clone(), + persisted_states: action.states.clone(), current_state: 0, settings: serde_json::Value::Object(serde_json::Map::new()), children: None, @@ -33,10 +34,12 @@ pub async fn create_instance(app: AppHandle, action: Action, context: Context) - children.push(instance.clone()); if parent.action.uuid == "opendeck.toggleaction" && parent.states.len() < children.len() { - parent.states.push(crate::shared::ActionState { + let state = crate::shared::ActionState { image: "opendeck/toggle-action.png".to_owned(), ..Default::default() - }); + }; + parent.states.push(state.clone()); + parent.persisted_states.push(state); let _ = update_state(&app, parent.context.clone(), &mut locks).await; } @@ -52,6 +55,7 @@ pub async fn create_instance(app: AppHandle, action: Action, context: Context) - action: action.clone(), context: ActionContext::from_context(context.clone(), 0), states: action.states.clone(), + persisted_states: action.states.clone(), current_state: 0, settings: serde_json::Value::Object(serde_json::Map::new()), children: if matches!(action.uuid.as_str(), "opendeck.multiaction" | "opendeck.toggleaction") { @@ -110,6 +114,7 @@ pub async fn move_instance(source: Context, destination: Context, retain: bool) state.image = instance.action.icon.clone(); } } + instance.persisted_states = instance.states.clone(); } } @@ -127,6 +132,12 @@ pub async fn move_instance(source: Context, destination: Context, retain: bool) state.image = new_dir.join(path.strip_prefix(&old_dir).unwrap()).to_string_lossy().into_owned(); } } + for state in new.persisted_states.iter_mut() { + let path = std::path::Path::new(&state.image); + if path.starts_with(&old_dir) { + state.image = new_dir.join(path.strip_prefix(&old_dir).unwrap()).to_string_lossy().into_owned(); + } + } let dst = get_slot_mut(&destination, &mut locks).await?; *dst = Some(new.clone()); @@ -181,6 +192,7 @@ pub async fn remove_instance(context: ActionContext) -> Result<(), Error> { } if !children.is_empty() { instance.states.pop(); + instance.persisted_states.pop(); let _ = update_state(crate::APP_HANDLE.get().unwrap(), instance.context.clone(), &mut locks).await; } } @@ -214,6 +226,7 @@ pub async fn set_state(context: ActionContext, index: u16, state: ActionState) - let mut locks = acquire_locks_mut().await; let reference = get_instance_mut(&context, &mut locks).await?.unwrap(); reference.states[index as usize] = state; + reference.persisted_states[index as usize] = reference.states[index as usize].clone(); let clone = reference.clone(); save_profile(&context.device, &mut locks).await?; crate::events::outbound::states::title_parameters_did_change(&clone, index).await?; diff --git a/src-tauri/src/events/inbound/settings.rs b/src-tauri/src/events/inbound/settings.rs index 58e0e4a7..0700ea6c 100644 --- a/src-tauri/src/events/inbound/settings.rs +++ b/src-tauri/src/events/inbound/settings.rs @@ -1,6 +1,6 @@ use crate::events::outbound::settings as outbound; use crate::shared::ActionContext; -use crate::store::profiles::{acquire_locks, acquire_locks_mut, get_instance, get_instance_mut, save_profile}; +use crate::store::profiles::{acquire_locks, acquire_locks_mut, debounce_profile_save, get_instance, get_instance_mut}; use std::io::Write; use std::str::FromStr; @@ -9,9 +9,13 @@ pub async fn set_settings(event: super::ContextAndPayloadEvent) -> Result } update_state(crate::APP_HANDLE.get().unwrap(), instance.context.clone(), &mut locks).await?; } - save_profile(&event.context.device, &mut locks).await?; Ok(()) } @@ -90,14 +89,6 @@ pub async fn set_image(mut event: ContextAndPayloadEvent) -> Re update_state(crate::APP_HANDLE.get().unwrap(), instance.context.clone(), &mut locks).await?; } - if let Some(image) = &event.payload.image - && image.trim().starts_with("data:") - { - debounce_profile_save(event.context); - } else { - save_profile(&event.context.device, &mut locks).await?; - } - Ok(()) } @@ -108,10 +99,13 @@ pub async fn set_state(event: ContextAndPayloadEvent) -> Result if event.payload.state >= instance.states.len() as u16 { return Ok(()); } + if instance.current_state == event.payload.state { + return Ok(()); + } instance.current_state = event.payload.state; update_state(crate::APP_HANDLE.get().unwrap(), instance.context.clone(), &mut locks).await?; + debounce_profile_save(event.context); } - save_profile(&event.context.device, &mut locks).await?; Ok(()) } diff --git a/src-tauri/src/events/outbound/keypad.rs b/src-tauri/src/events/outbound/keypad.rs index 15dba2da..d4f15b8c 100644 --- a/src-tauri/src/events/outbound/keypad.rs +++ b/src-tauri/src/events/outbound/keypad.rs @@ -2,7 +2,7 @@ use super::{GenericInstancePayload, send_to_plugin}; use crate::events::frontend::instances::{key_moved, update_state}; use crate::shared::{ActionContext, Context}; -use crate::store::profiles::{acquire_locks_mut, get_slot_mut, save_profile}; +use crate::store::profiles::{acquire_locks_mut, debounce_profile_save, get_slot_mut}; use std::sync::LazyLock; use std::time::Duration; @@ -75,7 +75,7 @@ pub async fn key_down(device: &str, key: u8) -> Result<(), anyhow::Error> { let _ = update_state(crate::APP_HANDLE.get().unwrap(), child, &mut locks).await; } - save_profile(device, &mut locks).await?; + debounce_profile_save(ActionContext::from_context(context, 0)); } else if instance.action.uuid == "opendeck.toggleaction" { let children = instance.children.as_ref().unwrap(); if children.is_empty() { @@ -167,8 +167,9 @@ pub async fn key_up(device: &str, key: u8) -> Result<(), anyhow::Error> { .await?; }; - let _ = update_state(crate::APP_HANDLE.get().unwrap(), instance.context.clone(), &mut locks).await; - save_profile(device, &mut locks).await?; + let instance_context = instance.context.clone(); + let _ = update_state(crate::APP_HANDLE.get().unwrap(), instance_context.clone(), &mut locks).await; + debounce_profile_save(instance_context); Ok(()) } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 6aeac3cf..908a00a2 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -375,6 +375,7 @@ If you have already donated, thank you so much for your support!"#, app.run(|app, event| { if let tauri::RunEvent::Exit = event { + tauri::async_runtime::block_on(store::profiles::flush_pending_profile_saves()); #[cfg(windows)] futures::executor::block_on(plugins::deactivate_plugins()); tokio::spawn(elgato::reset_devices()); diff --git a/src-tauri/src/shared.rs b/src-tauri/src/shared.rs index da90670c..39f39abd 100644 --- a/src-tauri/src/shared.rs +++ b/src-tauri/src/shared.rs @@ -302,6 +302,8 @@ pub struct ActionInstance { pub action: Action, pub context: ActionContext, pub states: Vec, + #[serde(skip)] + pub persisted_states: Vec, pub current_state: u16, pub settings: serde_json::Value, pub children: Option>, diff --git a/src-tauri/src/store/mod.rs b/src-tauri/src/store/mod.rs index d23ce2e7..4c6efd3c 100644 --- a/src-tauri/src/store/mod.rs +++ b/src-tauri/src/store/mod.rs @@ -78,6 +78,9 @@ where fs::create_dir_all(self.path.parent().unwrap())?; let contents = serde_json::to_string_pretty(&T::into_value(&self.value)?)?; + if fs::read_to_string(&self.path).map(|existing| existing == contents).unwrap_or(false) { + return Ok(()); + } let temp_path = self.path.with_extension("json.temp"); let backup_path = self.path.with_extension("json.bak"); diff --git a/src-tauri/src/store/profiles.rs b/src-tauri/src/store/profiles.rs index fc7caf25..beda959f 100644 --- a/src-tauri/src/store/profiles.rs +++ b/src-tauri/src/store/profiles.rs @@ -2,7 +2,7 @@ use super::Store; use crate::shared::{ActionInstance, DEVICES, DeviceInfo, Profile, config_dir, copy_dir}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::fs; use std::path::PathBuf; use std::sync::LazyLock; @@ -359,6 +359,29 @@ pub async fn save_profile(device: &str, locks: &mut LocksMut<'_>) -> Result<(), } pub static PROFILE_SAVE_DEBOUNCE: LazyLock>> = LazyLock::new(DashMap::new); + +pub async fn flush_pending_profile_saves() { + let contexts = PROFILE_SAVE_DEBOUNCE.iter().map(|entry| entry.key().clone()).collect::>(); + if contexts.is_empty() { + return; + } + + let mut devices = HashSet::new(); + for context in contexts { + devices.insert(context.device.clone()); + if let Some((_, handle)) = PROFILE_SAVE_DEBOUNCE.remove(&context) { + handle.abort(); + } + } + + let mut locks = acquire_locks_mut().await; + for device in devices { + if let Err(error) = save_profile(&device, &mut locks).await { + log::error!("Failed to save profile for device {device}: {error}"); + } + } +} + pub fn debounce_profile_save(context: crate::shared::ActionContext) { if let Some((_, handle)) = PROFILE_SAVE_DEBOUNCE.remove(&context) { handle.abort(); diff --git a/src-tauri/src/store/simplified_profile.rs b/src-tauri/src/store/simplified_profile.rs index 760a2aad..b813c2a9 100644 --- a/src-tauri/src/store/simplified_profile.rs +++ b/src-tauri/src/store/simplified_profile.rs @@ -75,6 +75,7 @@ impl From for DiskActionInstance { let disk_context: DiskActionContext = value.context.clone().into(); let config_dir = crate::shared::config_dir(); let image_dir = config_dir.join("images").join(&value.context.device).join(&value.context.profile).join(disk_context.to_string()); + let mut states = value.persisted_states.clone(); let normalise_path = |value: &str| -> String { let path = Path::new(value); @@ -87,7 +88,7 @@ impl From for DiskActionInstance { } }; - for (index, state) in value.states.iter_mut().enumerate() { + for (index, state) in states.iter_mut().enumerate() { if state.image.trim() == "data:" { state.image = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2NgYGD4DwABBAEAwS2OUAAAAABJRU5ErkJggg==".to_owned(); } @@ -112,7 +113,9 @@ impl From for DiskActionInstance { }; let filename = format!("{}.{}", index, extension); - if fs::create_dir_all(&image_dir).is_err() || fs::write(image_dir.join(&filename), data).is_err() { + let path = image_dir.join(&filename); + let image_changed = fs::read(&path).map(|existing| existing.as_slice() != data.as_slice()).unwrap_or(true); + if fs::create_dir_all(&image_dir).is_err() || (image_changed && fs::write(path, data).is_err()) { continue; }; state.image = filename; @@ -129,7 +132,7 @@ impl From for DiskActionInstance { Self { context: disk_context, action: value.action, - states: value.states, + states, current_state: value.current_state, settings: value.settings, children: value.children.map(|c| c.into_iter().map(|v| v.into()).collect()), @@ -178,6 +181,7 @@ impl DiskActionInstance { ActionInstance { context: self.context.into_action_context(device, profile), action, + persisted_states: states.clone(), states, current_state: self.current_state, settings: self.settings,