From 9251d8ade29a3b0dccfbb6d345ffab0f57edbb4f Mon Sep 17 00:00:00 2001 From: PalmarHealer Date: Sat, 12 Sep 2026 17:03:28 +0200 Subject: [PATCH] feat(profiles): refuse to write a profile this version cannot safely save MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A profile now records the newest Slidr that wrote it (`app_version`, raised on save and never lowered). Opening one from a newer build warns — and, more to the point, stops Slidr writing to it. The warning alone would not help. Loading a newer profile is harmless, because serde drops fields it does not know; the damage happens on the *next save*, which writes the profile back without them. So every write to the active profile now goes through `Shared::save_preset`, which refuses while the guard is armed — including the save on exit, and including renaming or importing such a profile (both rewrite the whole file). While looking for a way to test this, a worse variant turned up: a profile that fails to parse was silently replaced by a *blank* profile of the same name, which the exit save then wrote over the file. One unknown enum value — exactly what a newer version introduces — was enough to lose a profile. That path is now guarded the same way, with the parse error shown and logged instead of swallowed, and the file left untouched. Either way the user decides: "View only" keeps the guard (a banner on the settings page says changes are not being saved), or they take ownership, which stamps the profile with this version since the newer data is dropped by that save regardless. Upgrades are a non-event: an older stamp, or none at all, loads without comment. Verified against a throwaway config with three profiles — stamped 99.0.0, stamped 0.1.0, and one with an unknown curve preset. The two blocked cases left the file byte-identical after a graceful quit (the unknown field still in it); the old profile was rewritten with the stamp raised to this version. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 5 ++ src/glue.rs | 211 ++++++++++++++++++++++++++++++++++++++++++++----- src/main.rs | 32 ++++++-- src/model.rs | 5 ++ src/version.rs | 102 ++++++++++++++++++++++++ ui/app.slint | 67 ++++++++++++++++ 6 files changed, 396 insertions(+), 26 deletions(-) create mode 100644 src/version.rs diff --git a/README.md b/README.md index 877eb2e..dd7bfa8 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,11 @@ and macro board. (anything under a Steam/Epic/GOG library is a game), and from the OS itself (browsers registered with Windows). Extend or add categories with an `app_groups.json` in the config directory. +- **Profile safety** — a profile records the newest Slidr that wrote it. Opening + one from a *newer* build (or one this build cannot parse at all) shows a + warning and, crucially, stops Slidr writing to it — an older version would + otherwise silently save the profile back without the fields it does not know. + Upgrades are never flagged. - **Profiles** — each profile carries its own categories, slider/button assignments, **and** appearance + slider settings (theme, accent, dead-zone, curve, unmute-on-change). Create / rename / import / export / switch. diff --git a/src/glue.rs b/src/glue.rs index b7df352..1bc01ec 100644 --- a/src/glue.rs +++ b/src/glue.rs @@ -43,9 +43,83 @@ pub struct Shared { pub last_status_refresh: Option, /// Working copy for the configure-LED popup: (led index, config being edited). pub editing_led: Option<(usize, LedConfig)>, + /// Set when the active profile must not be written back — see + /// [`ProfileBlock`]. While it is set, [`Shared::save_preset`] refuses to + /// write. The user clears it by acknowledging the warning. + pub profile_block: Option, +} + +/// Why the active profile must not be written back. +#[derive(Debug, Clone)] +pub enum ProfileBlock { + /// Stamped with a newer Slidr than this build. Loading is harmless, but + /// saving would drop whatever that version stored in unknown fields. + FromNewer(String), + /// Could not be read at all, so the profile in memory is a blank + /// stand-in. Saving would replace the file with that blank — the worst + /// outcome of the two, and the one that actually destroys a profile. + Unreadable(String), } impl Shared { + /// Persist the active profile — the only path that writes it. + /// + /// Refuses while `profile_newer` is set: the profile came from a newer + /// Slidr, and writing it here would drop whatever that version stored in + /// fields this build does not have. Stamps the profile with the newest + /// version that has written it on the way out. + pub fn save_preset(&mut self) { + match &self.profile_block { + Some(ProfileBlock::FromNewer(v)) => { + log::warn!( + "not saving profile {:?}: written by Slidr {v}, this build is {}", + self.preset.name, + crate::version::CURRENT + ); + return; + } + Some(ProfileBlock::Unreadable(e)) => { + log::warn!( + "not saving profile {:?}: it could not be read ({e}), so this would \ + overwrite it with a blank profile", + self.preset.name + ); + return; + } + None => {} + } + self.preset.app_version = + crate::version::higher(&self.preset.app_version, crate::version::CURRENT); + let _ = crate::storage::save_preset(&self.preset); + } + + /// Compare the freshly loaded profile's stamp against this build and arm + /// the guard if it came from the future. An existing `Unreadable` block is + /// kept: that one is the more serious of the two. + pub fn check_profile_version(&mut self) -> Option { + if matches!(self.profile_block, Some(ProfileBlock::Unreadable(_))) { + return self.profile_block.clone(); + } + self.profile_block = crate::version::is_newer_than_current(&self.preset.app_version) + .then(|| ProfileBlock::FromNewer(self.preset.app_version.clone())); + self.profile_block.clone() + } + + /// Let the user proceed anyway: writes are allowed again from here on. + /// + /// The stamp is lowered to this build, because from the next save onwards + /// that is the truth — whatever the newer version stored is dropped by that + /// save, and warning about it again every launch would be noise. + pub fn acknowledge_profile_version(&mut self) { + if let Some(block) = self.profile_block.take() { + log::info!( + "profile warning acknowledged for {:?} ({block:?}); this build now owns it", + self.preset.name + ); + self.preset.app_version = crate::version::CURRENT.to_string(); + } + } + /// Push the full LED configuration set + brightness + experimental flag to /// the actuator's LED engine. Call after any change to LED config/settings. pub fn push_leds(&self) { @@ -73,6 +147,27 @@ pub fn wire( s.push_leds(); } + // Warn if the active profile was written by a newer Slidr (and block + // writes until acknowledged) — see `Shared::save_preset`. + { + let mut s = shared.lock(); + let newer = s.check_profile_version(); + push_version_warning(ui, &s, newer); + } + { + let shared = shared.clone(); + let weak = ui.as_weak(); + ui.on_version_warning_accept(move || { + let mut s = shared.lock(); + s.acknowledge_profile_version(); + if let Some(ui) = weak.upgrade() { + ui.set_profile_write_blocked(false); + ui.set_version_warning_open(false); + toast(&ui, "Editing enabled — the newer version's extras will be dropped on save"); + } + }); + } + // Empty initial slider/button cells. push_cells(ui, &[0; NUM_SLIDERS], &[0; NUM_BUTTONS], &shared.lock().preset); ui.set_connection_msg("Searching for Slidr device…".into()); @@ -121,7 +216,7 @@ pub fn wire( } else if target_kind == 1 && idx < NUM_BUTTONS { s.preset.assignments.buttons[idx] = chosen; } - let _ = crate::storage::save_preset(&s.preset); + s.save_preset(); push_preset_to_ui(&ui, &s.preset); let live = s.engine.state().clone(); push_cells(&ui, &live.sliders, &live.buttons, &s.preset); @@ -142,7 +237,7 @@ pub fn wire( let id = next_id(&s.preset.button_categories, |c| c.id); s.preset.button_categories.push(ButtonCategory { id, name: name.to_string(), actions: vec![], collapsed: false }); } - let _ = crate::storage::save_preset(&s.preset); + s.save_preset(); let Some(ui) = weak.upgrade() else { return }; push_preset_to_ui(&ui, &s.preset); }); @@ -155,11 +250,14 @@ pub fn wire( let new_preset = Preset { id: 1, name: name.to_string(), + app_version: crate::version::CURRENT.to_string(), ..Default::default() }; if crate::storage::save_preset(&new_preset).is_ok() { let mut s = shared.lock(); s.preset = new_preset; + // Freshly created here, so nothing to guard against. + s.profile_block = None; s.settings.active_preset = name.to_string(); let _ = crate::storage::save_settings(&s.settings); if let Some(ui) = weak.upgrade() { @@ -185,7 +283,7 @@ pub fn wire( } else if let Some(c) = s.preset.button_categories.iter_mut().find(|c| c.id as i32 == id) { c.name = name.to_string(); } - let _ = crate::storage::save_preset(&s.preset); + s.save_preset(); let Some(ui) = weak.upgrade() else { return }; push_preset_to_ui(&ui, &s.preset); refresh_home(&ui, &s); // update Home preview names without a board input @@ -208,7 +306,7 @@ pub fn wire( if *slot == Some(id) { *slot = None } } } - let _ = crate::storage::save_preset(&s.preset); + s.save_preset(); let Some(ui) = weak.upgrade() else { return }; push_preset_to_ui(&ui, &s.preset); refresh_home(&ui, &s); @@ -228,7 +326,7 @@ pub fn wire( } else if let Some(c) = s.preset.button_categories.iter_mut().find(|c| c.id == cat_id) { if idx < c.actions.len() { c.actions.remove(idx); } } - let _ = crate::storage::save_preset(&s.preset); + s.save_preset(); let Some(ui) = weak.upgrade() else { return }; push_preset_to_ui(&ui, &s.preset); }); @@ -247,7 +345,7 @@ pub fn wire( } else if let Some(c) = s.preset.button_categories.iter_mut().find(|c| c.id == id) { c.collapsed = !c.collapsed; } - let _ = crate::storage::save_preset(&s.preset); + s.save_preset(); let Some(ui) = weak.upgrade() else { return }; push_preset_to_ui(&ui, &s.preset); }); @@ -265,7 +363,7 @@ pub fn wire( } else { reorder_by_id(&mut s.preset.button_categories, from_id, to, |c| c.id); } - let _ = crate::storage::save_preset(&s.preset); + s.save_preset(); let Some(ui) = weak.upgrade() else { return }; push_preset_to_ui(&ui, &s.preset); refresh_home(&ui, &s); @@ -286,7 +384,7 @@ pub fn wire( move_line(&mut s.preset.button_categories, from_cat, from_idx, to_cat, to_idx, |c| c.id, |c| &mut c.actions); } - let _ = crate::storage::save_preset(&s.preset); + s.save_preset(); let Some(ui) = weak.upgrade() else { return }; push_preset_to_ui(&ui, &s.preset); }); @@ -454,7 +552,7 @@ pub fn wire( Some(i) if i < c.actions.len() => c.actions[i] = action, _ => c.actions.push(action), } - let _ = crate::storage::save_preset(&s.preset); + s.save_preset(); let Some(ui) = weak.upgrade() else { return }; push_preset_to_ui(&ui, &s.preset); toast(&ui, if editing.is_some() { "Updated." } else { "Added." }); @@ -487,7 +585,7 @@ pub fn wire( _ => c.actions.push(action), } } - let _ = crate::storage::save_preset(&s.preset); + s.save_preset(); let Some(ui) = weak.upgrade() else { return }; push_preset_to_ui(&ui, &s.preset); toast(&ui, if editing.is_some() { "Updated." } else { "Added." }); @@ -530,7 +628,7 @@ pub fn wire( { let mut s = shared.lock(); s.preset.settings.theme = mode; - let _ = crate::storage::save_preset(&s.preset); + s.save_preset(); } if let Some(ui) = weak.upgrade() { apply_color_scheme(&ui, mode); @@ -548,7 +646,7 @@ pub fn wire( if matches!(preset, crate::curve::CurvePreset::Custom) { s.preset.settings.custom_curve = crate::curve::BezierPoints::CUSTOM_DEFAULT; } - let _ = crate::storage::save_preset(&s.preset); + s.save_preset(); if let Some(ui) = weak.upgrade() { push_curve_to_ui(&ui, &s.preset.settings); } @@ -565,7 +663,7 @@ pub fn wire( x2: ui.get_curve_x2(), y2: ui.get_curve_y2(), }; s.preset.settings.curve_preset = crate::curve::CurvePreset::Custom; - let _ = crate::storage::save_preset(&s.preset); + s.save_preset(); }); } { @@ -578,6 +676,18 @@ pub fn wire( if old.is_empty() || new.is_empty() || old == new { return } // Read the old preset, write under new name, delete old. if let Ok(mut p) = crate::storage::load_preset(&old) { + // Renaming writes the whole file back, which would drop fields + // a newer Slidr put there — same hazard as saving, so refuse. + if crate::version::is_newer_than_current(&p.app_version) { + if let Some(ui) = weak.upgrade() { + toast(&ui, &format!( + "Can't rename: {old} was written by Slidr {} (this is {})", + p.app_version, + crate::version::CURRENT + )); + } + return; + } p.name = new.to_string(); if crate::storage::save_preset(&p).is_ok() { let _ = crate::storage::delete_preset(&old); @@ -628,6 +738,20 @@ pub fn wire( if let Some(ui) = weak.upgrade() { toast(&ui, "Import failed: invalid preset file"); } return; }; + // Parsing already dropped any field this build doesn't know, so + // writing the copy would hand back a quietly reduced profile. The + // file itself stays untouched — better to refuse and let the user + // import it with a matching version. + if crate::version::is_newer_than_current(&preset.app_version) { + if let Some(ui) = weak.upgrade() { + toast(&ui, &format!( + "Import refused: profile is from Slidr {} (this is {})", + preset.app_version, + crate::version::CURRENT + )); + } + return; + } // Ensure a unique, non-empty name (avoid clobbering an existing one). if preset.name.trim().is_empty() { preset.name = path.file_stem().map(|s| s.to_string_lossy().to_string()) @@ -661,7 +785,7 @@ pub fn wire( pull_global_from_ui(&ui, &mut s.settings); pull_profile_from_ui(&ui, &mut s.preset.settings); let _ = crate::storage::save_settings(&s.settings); - let _ = crate::storage::save_preset(&s.preset); + s.save_preset(); let _ = crate::autostart::set_enabled(s.settings.start_with_os, s.settings.start_minimized); // Live-apply appearance (accent / theme) immediately. apply_appearance(&ui, &s.preset.settings); @@ -693,12 +817,25 @@ pub fn wire( let shared = shared.clone(); let weak = ui.as_weak(); ui.on_load_preset(move |name| { - let Ok(p) = crate::storage::load_preset(&name) else { return }; + let p = match crate::storage::load_preset(&name) { + Ok(p) => p, + Err(e) => { + // Leave the current profile loaded; switching to a profile + // we cannot read would mean editing a blank stand-in. + log::error!("could not read profile {name:?}: {e:#}"); + if let Some(ui) = weak.upgrade() { + toast(&ui, &format!("Could not read profile {name} — left unchanged")); + } + return; + } + }; let mut s = shared.lock(); s.preset = p; s.settings.active_preset = s.preset.name.clone(); let _ = crate::storage::save_settings(&s.settings); + let newer = s.check_profile_version(); if let Some(ui) = weak.upgrade() { + push_version_warning(&ui, &s, newer); push_preset_to_ui(&ui, &s.preset); push_settings_to_ui(&ui, &s.settings, &s.preset.settings); apply_appearance(&ui, &s.preset.settings); @@ -822,7 +959,7 @@ pub fn wire( ui.get_led_inactive_btarget().as_str(), ); s.preset.leds[led] = Some(cfg); - let _ = crate::storage::save_preset(&s.preset); + s.save_preset(); s.push_leds(); drop(s); ui.set_led_popup_open(false); @@ -883,7 +1020,7 @@ pub fn wire( if let Some(saved) = s.preset.leds.get_mut(led).and_then(|c| c.as_mut()) { if saved.control == LedControl::Manual { saved.manual_active = !saved.manual_active; - let _ = crate::storage::save_preset(&s.preset); + s.save_preset(); } } }); @@ -1021,7 +1158,7 @@ pub fn wire( let _ = s.cmd_tx.send(cmd); } if led_toggled { - let _ = crate::storage::save_preset(&s.preset); + s.save_preset(); } if dirty { changed = true; } } @@ -1165,6 +1302,44 @@ fn move_line( v.insert(to.min(v.len()), item); } +/// Show (or clear) the warning that the active profile must not be written. +fn push_version_warning(ui: &AppWindow, s: &Shared, block: Option) { + let Some(block) = block else { + ui.set_profile_write_blocked(false); + ui.set_version_warning_open(false); + return; + }; + let name = s.preset.name.clone(); + let current = crate::version::CURRENT; + let (title, body, detail, accept) = match &block { + ProfileBlock::FromNewer(v) => ( + "Profile from a newer Slidr".to_string(), + format!("“{name}” was last written by Slidr {v}. This build is {current}."), + "It can hold settings this version does not know about. Loading is safe — saving \ + is not: anything newer would be dropped. Until you decide, Slidr keeps the \ + profile loaded but does not write to it." + .to_string(), + "Edit anyway".to_string(), + ), + ProfileBlock::Unreadable(e) => ( + "Profile could not be read".to_string(), + format!("“{name}” could not be parsed, so Slidr loaded a blank profile under that name.\n\n{e}"), + "This usually means the file came from a newer version whose format this build \ + does not understand. Saving would replace it with the blank one, so Slidr will \ + not write to it. Your file on disk is untouched." + .to_string(), + "Overwrite with a blank profile".to_string(), + ), + }; + log::warn!("profile {name:?}: {block:?} — writes blocked"); + ui.set_version_warning_title(title.into()); + ui.set_version_warning_body(body.into()); + ui.set_version_warning_detail(detail.into()); + ui.set_version_warning_accept_label(accept.into()); + ui.set_profile_write_blocked(true); + ui.set_version_warning_open(true); +} + fn push_preset_to_ui(ui: &AppWindow, preset: &Preset) { let sliders: Vec = preset .slider_categories diff --git a/src/main.rs b/src/main.rs index c12b13d..0a92f9e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -27,6 +27,7 @@ mod model; mod protocol; mod serial; mod storage; +mod version; #[cfg(target_os = "windows")] mod tray; @@ -112,11 +113,24 @@ fn main() -> anyhow::Result<()> { } else { settings.active_preset.clone() }; - let preset = storage::load_preset(&preset_name).unwrap_or_else(|_| model::Preset { - id: 1, - name: preset_name.clone(), - ..Default::default() - }); + // A profile that fails to parse must not be quietly replaced by an empty + // one: the app would then write that blank over the user's file on exit and + // the profile is gone. Load it, or carry the reason and refuse to write. + let (preset, load_error) = match storage::load_preset(&preset_name) { + Ok(p) => (p, None), + Err(e) => { + log::error!( + "could not read profile {preset_name:?}: {e:#} — starting with a blank \ + profile and refusing to write to it" + ); + let blank = model::Preset { + id: 1, + name: preset_name.clone(), + ..Default::default() + }; + (blank, Some(e.to_string())) + } + }; settings.active_preset = preset.name.clone(); // Repair a stale autostart entry (the executable moved since it was set up). @@ -142,6 +156,7 @@ fn main() -> anyhow::Result<()> { editing_idx: None, last_status_refresh: None, editing_led: None, + profile_block: load_error.map(glue::ProfileBlock::Unreadable), })); let ui = AppWindow::new()?; @@ -326,9 +341,10 @@ fn main() -> anyhow::Result<()> { } slint::run_event_loop_until_quit()?; - // Persist on exit. - let s = shared.lock(); + // Persist on exit. The profile goes through the guarded path, so a + // profile from a newer Slidr is not quietly rewritten on the way out. + let mut s = shared.lock(); let _ = storage::save_settings(&s.settings); - let _ = storage::save_preset(&s.preset); + s.save_preset(); Ok(()) } diff --git a/src/model.rs b/src/model.rs index 62e58ac..e075165 100644 --- a/src/model.rs +++ b/src/model.rs @@ -586,6 +586,11 @@ pub struct Preset { /// Per-LED config, indexed by LED (0/1/2 = buttons 5/6/7). None = not configured. #[serde(default)] pub leds: [Option; NUM_LEDS], + /// Newest Slidr version that has written this profile. Raised on save, + /// never lowered; empty for profiles written before stamping existed. + /// See [`crate::version`] for why the comparison only matters downwards. + #[serde(default)] + pub app_version: String, } impl Preset { diff --git a/src/version.rs b/src/version.rs new file mode 100644 index 0000000..ad8ff75 --- /dev/null +++ b/src/version.rs @@ -0,0 +1,102 @@ +//! App version stamping for profiles. +//! +//! A profile records the newest Slidr that has written it. Opening a profile +//! from a newer build with an older app is the dangerous direction: the load +//! itself is harmless (serde ignores fields it doesn't know), but the next save +//! writes the profile back *without* them — silently dropping whatever the newer +//! version stored. So the stamp is compared on load, and the marker is only ever +//! raised, never lowered, or an old build would erase the evidence. +//! +//! Upgrades are a non-event: a profile stamped with an older version (or with no +//! stamp at all, i.e. written before this existed) loads without comment. + +/// Version of this build, from Cargo. +pub const CURRENT: &str = env!("CARGO_PKG_VERSION"); + +/// Split a semver-ish string into comparable numbers. Anything after `-` or `+` +/// (pre-release, build metadata) is ignored, and missing parts count as 0, so +/// "0.4" parses as 0.4.0. Returns `None` if there is no leading number at all. +fn parts(v: &str) -> Option<(u64, u64, u64)> { + let core = v.trim(); + let core = core.split(['-', '+']).next().unwrap_or(""); + let mut it = core.split('.'); + let major: u64 = it.next()?.trim().parse().ok()?; + let minor: u64 = it.next().and_then(|s| s.trim().parse().ok()).unwrap_or(0); + let patch: u64 = it.next().and_then(|s| s.trim().parse().ok()).unwrap_or(0); + Some((major, minor, patch)) +} + +/// Is `stored` a strictly newer version than this build? +/// +/// An empty or unparseable stamp is treated as "not newer": profiles written +/// before stamping existed must keep loading, and a corrupt stamp shouldn't +/// lock someone out of their own configuration. +pub fn is_newer_than_current(stored: &str) -> bool { + match (parts(stored), parts(CURRENT)) { + (Some(a), Some(b)) => a > b, + _ => false, + } +} + +/// The higher of the two versions, as a string. Used when saving, so the stamp +/// tracks the newest build that ever touched the profile — including when an +/// older build saves it after being acknowledged. +pub fn higher(stored: &str, current: &str) -> String { + match (parts(stored), parts(current)) { + (Some(a), Some(b)) if a >= b => stored.to_string(), + (Some(_), None) => stored.to_string(), + _ => current.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_partial_and_decorated_versions() { + assert_eq!(parts("1.2.3"), Some((1, 2, 3))); + assert_eq!(parts("0.4"), Some((0, 4, 0))); + assert_eq!(parts("2"), Some((2, 0, 0))); + assert_eq!(parts("1.2.3-rc.1"), Some((1, 2, 3))); + assert_eq!(parts("1.2.3+build7"), Some((1, 2, 3))); + assert_eq!(parts(""), None); + assert_eq!(parts("nonsense"), None); + } + + #[test] + fn only_a_strictly_newer_stamp_counts() { + // Compared against this build, whatever it happens to be. + let (ma, mi, pa) = parts(CURRENT).expect("own version parses"); + let newer = format!("{ma}.{mi}.{}", pa + 1); + let older = format!("{ma}.{}.{pa}", mi.saturating_sub(1)); + + assert!(is_newer_than_current(&newer)); + assert!(!is_newer_than_current(CURRENT), "same version is fine"); + if mi > 0 { + assert!(!is_newer_than_current(&older), "upgrades never warn"); + } + } + + #[test] + fn unstamped_and_broken_versions_never_warn() { + assert!(!is_newer_than_current("")); + assert!(!is_newer_than_current(" ")); + assert!(!is_newer_than_current("not-a-version")); + } + + #[test] + fn major_and_minor_order_before_patch() { + assert!(is_newer_than_current("99.0.0")); + assert_eq!(higher("1.2.3", "1.10.0"), "1.10.0"); + assert_eq!(higher("2.0.0", "1.99.99"), "2.0.0"); + } + + #[test] + fn higher_keeps_the_newer_stamp_and_fills_in_blanks() { + assert_eq!(higher("", "0.3.0"), "0.3.0"); + assert_eq!(higher("0.9.0", "0.3.0"), "0.9.0", "an old build must not lower the stamp"); + assert_eq!(higher("0.3.0", "0.3.0"), "0.3.0"); + assert_eq!(higher("garbage", "0.3.0"), "0.3.0"); + } +} diff --git a/ui/app.slint b/ui/app.slint index 4b8d443..01678c7 100644 --- a/ui/app.slint +++ b/ui/app.slint @@ -1092,6 +1092,9 @@ component SettingsPage inherits VerticalBox { spacing: 14px; property control-w: 260px; + // True while the active profile is one this build must not write. + in property profile-write-blocked; + callback reopen-version-warning(); HorizontalBox { Text { text: "Settings"; font-size: 26px; font-weight: 700; font-family: "Inter"; @@ -1328,6 +1331,23 @@ component SettingsPage inherits VerticalBox { } } + if root.profile-write-blocked: Rectangle { + border-radius: 10px; border-width: 1px; border-color: #d3831f; + background: #d3831f22; + VerticalLayout { + padding: 12px; spacing: 2px; + Text { text: "Changes are not being saved"; + font-family: "Inter"; font-size: 13px; font-weight: 600; } + Text { text: "This profile is not safe for this version to write. Reopen the warning to decide."; + font-family: "Inter"; font-size: 11px; wrap: word-wrap; + color: Palette.foreground.with-alpha(0.7); } + HorizontalLayout { alignment: start; + IconButton { label: "Show the warning"; + clicked => { root.reopen-version-warning(); } } + } + } + } + if root.build-stamp != "": Text { text: "Build: " + root.build-stamp; horizontal-alignment: center; font-size: 11px; font-family: "Inter"; @@ -2295,6 +2315,14 @@ export component AppWindow inherits Window { // ── Configure-LED popup state ── in-out property led-popup-open: false; + // Active profile was written by a newer Slidr: the dialog explains it, and + // `profile-write-blocked` keeps the warning visible after it is dismissed. + in-out property version-warning-open: false; + in property version-warning-title; + in property version-warning-body; + in property version-warning-detail; + in property version-warning-accept-label; + in-out property profile-write-blocked: false; in-out property led-index: 0; // 0..2 in-out property led-control: 0; in-out property led-combine: 0; @@ -2407,6 +2435,7 @@ export component AppWindow inherits Window { callback led-cond-kind(int, int); callback led-cond-media(int, string); callback led-cond-media-state(int, int); + callback version-warning-accept(); callback led-cond-signal(int, int); callback led-cond-audio(int, int); callback led-cond-target(int, string); @@ -2623,6 +2652,8 @@ export component AppWindow inherits Window { discord-client-secret <=> root.discord-client-secret; discord-status: root.discord-status; discord-redirect: root.discord-redirect; + profile-write-blocked: root.profile-write-blocked; + reopen-version-warning => { root.version-warning-open = true; } unmute-on-change <=> root.unmute-on-change; start-with-os <=> root.start-with-os; start-minimized <=> root.start-minimized; @@ -2648,6 +2679,42 @@ export component AppWindow inherits Window { } } + // Profile written by a newer version. Deliberately not dismissable by + // clicking the backdrop: the consequence of ignoring it is a silently + // reduced profile, so it takes an explicit choice. + if root.version-warning-open: Rectangle { + background: #000000cc; + TouchArea {} + Rectangle { + x: parent.width / 2 - self.width / 2; + y: parent.height / 2 - self.height / 2; + width: 480px; + height: layout.preferred-height; + background: Palette.background; + border-radius: 14px; border-width: 1px; border-color: Palette.border; + TouchArea {} + layout := VerticalBox { + padding: 22px; spacing: 14px; + Text { text: root.version-warning-title; + font-size: 18px; font-weight: 700; font-family: "Inter"; + wrap: word-wrap; } + Text { text: root.version-warning-body; + font-family: "Inter"; font-size: 13px; wrap: word-wrap; } + Text { text: root.version-warning-detail; + font-family: "Inter"; font-size: 12px; wrap: word-wrap; + color: Palette.foreground.with-alpha(0.7); } + HorizontalBox { + padding: 0px; + IconButton { label: "View only (don't save)"; + clicked => { root.version-warning-open = false; } } + Rectangle { horizontal-stretch: 1; } + IconButton { label: root.version-warning-accept-label; primary: true; + clicked => { root.version-warning-accept(); } } + } + } + } + } + if root.assign-popup-open: AssignPopup { target-kind: root.assign-target-kind; options: root.assign-options;