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;