Modes: a mode that no longer matches the features reads "Custom (based on <mode>)" and offers "Re-apply <mode>"
A mode is a starting point, never a lock and never applied again on its own. modeStatus() (runtime) compares the flags with the mode's preset: custom, what re-applying turns on and off, and which of those the preset gained in an update since it was applied. mode_set keeps the preset of that moment (modes.json "preset", flag names only, bounded; forgotten when the mode changes). Settings, the first-run screen and the app menu's Mode row show Custom; Re-apply lists what it turns on and off before it writes anything; a preset that grew is mentioned once on the mode row (Review), never applied. Tests: exact match, changed by the owner, preset grew, choice made before the preset was kept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
b75246bef8
commit
26658e14ae
9 changed files with 288 additions and 27 deletions
|
|
@ -794,12 +794,14 @@ async fn mode_get(webview: Webview, app: AppHandle) -> Result<ModeState, String>
|
|||
Ok(page(&app)?.modes.clone())
|
||||
}
|
||||
|
||||
/// Remembers the chosen mode (or none) and that the first-run screen was answered. The preset's
|
||||
/// flags are the shell's to apply (`features_set`); this only records the choice.
|
||||
/// Remembers the chosen mode (or none), that the first-run screen was answered, and what the
|
||||
/// mode's preset was when it was applied (`preset`, flag names to on/off: data for the shell's
|
||||
/// "custom" and "this mode has new features" notes). The preset's flags are the shell's to apply
|
||||
/// (`features_set`); this only records the choice.
|
||||
#[tauri::command]
|
||||
async fn mode_set(webview: Webview, app: AppHandle, mode: Option<String>, onboarded: bool) -> Result<ModeState, String> {
|
||||
async fn mode_set(webview: Webview, app: AppHandle, mode: Option<String>, onboarded: bool, preset: Option<std::collections::BTreeMap<String, bool>>) -> Result<ModeState, String> {
|
||||
shell_only(&webview)?;
|
||||
let next = page(&app)?.modes.chosen(mode, onboarded)?;
|
||||
let next = page(&app)?.modes.chosen(mode, onboarded, preset)?;
|
||||
write_json(&app, MODES_FILE, &next)?;
|
||||
page(&app)?.modes = next.clone();
|
||||
Ok(next)
|
||||
|
|
|
|||
|
|
@ -2,15 +2,22 @@
|
|||
//! owner started from, and whether the first-run screen was answered. A mode is a starting point:
|
||||
//! the presets themselves are the app's (its feature table) and are applied by the shell through
|
||||
//! `features_set`; native code only remembers the choice (`modes.json`), so the screen shows once.
|
||||
//! With the choice it keeps what the preset was at that moment (`preset`: flag names and on/off,
|
||||
//! data the shell hands over), so the shell can tell "the owner changed features" from "an update
|
||||
//! made the preset larger" later. Native code never reads it and never applies anything.
|
||||
//!
|
||||
//! `existing` is decided natively, once, on the first start of a version that has modes: an app
|
||||
//! data folder that already held settings belongs to someone who set the app up before. They keep
|
||||
//! exactly what they have, and the screen they see can be dismissed without choosing.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const MODES_FILE: &str = "modes.json";
|
||||
const MAX_MODE_ID: usize = 32;
|
||||
const MAX_PRESET_FLAGS: usize = 512;
|
||||
const MAX_FLAG_NAME: usize = 64;
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
|
|
@ -21,6 +28,15 @@ pub struct ModeState {
|
|||
pub onboarded: bool,
|
||||
/// The app was set up before modes existed.
|
||||
pub existing: bool,
|
||||
/// The chosen mode's preset as it was when it was applied (flag name to on/off). `None`: not
|
||||
/// known (no mode, or a choice made by a version that did not keep it).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub preset: Option<BTreeMap<String, bool>>,
|
||||
}
|
||||
|
||||
/// A flag name: letters, digits, `.`, `-` and `_`, at most 64 characters.
|
||||
fn valid_flag(name: &str) -> bool {
|
||||
!name.is_empty() && name.len() <= MAX_FLAG_NAME && name.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'_'))
|
||||
}
|
||||
|
||||
/// A mode id: lowercase letters, digits and `-`, starting with a letter, at most 32 characters.
|
||||
|
|
@ -34,20 +50,31 @@ pub fn valid_mode(id: &str) -> bool {
|
|||
impl ModeState {
|
||||
/// The state on the first start: nothing chosen, the screen still to be shown.
|
||||
pub fn first_run(existing: bool) -> Self {
|
||||
Self { mode: None, onboarded: false, existing }
|
||||
Self { mode: None, onboarded: false, existing, preset: None }
|
||||
}
|
||||
|
||||
pub fn validated(self) -> Result<Self, String> {
|
||||
if self.mode.as_deref().is_some_and(|mode| !valid_mode(mode)) {
|
||||
return Err("VALIDATION: invalid mode".into());
|
||||
}
|
||||
if let Some(preset) = &self.preset {
|
||||
if self.mode.is_none() || preset.len() > MAX_PRESET_FLAGS || !preset.keys().all(|name| valid_flag(name)) {
|
||||
return Err("VALIDATION: invalid mode preset".into());
|
||||
}
|
||||
}
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
/// What the shell may change: the chosen mode and that the screen was answered. `existing`
|
||||
/// stays what native code decided.
|
||||
pub fn chosen(&self, mode: Option<String>, onboarded: bool) -> Result<Self, String> {
|
||||
Self { mode, onboarded, existing: self.existing }.validated()
|
||||
/// What the shell may change: the chosen mode, that the screen was answered, and the preset
|
||||
/// the mode had when it was applied. Without `preset` the stored one stays while the mode
|
||||
/// stays, and is forgotten when the mode changes. `existing` stays what native code decided.
|
||||
pub fn chosen(&self, mode: Option<String>, onboarded: bool, preset: Option<BTreeMap<String, bool>>) -> Result<Self, String> {
|
||||
let preset = match preset {
|
||||
Some(preset) => Some(preset),
|
||||
None if mode == self.mode => self.preset.clone(),
|
||||
None => None,
|
||||
};
|
||||
Self { mode, onboarded, existing: self.existing, preset }.validated()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -68,18 +95,38 @@ mod tests {
|
|||
#[test]
|
||||
fn the_shell_chooses_a_mode_but_never_what_kind_of_install_this_is() {
|
||||
let fresh = ModeState::first_run(false);
|
||||
assert_eq!(fresh, ModeState { mode: None, onboarded: false, existing: false });
|
||||
assert_eq!(fresh, ModeState { mode: None, onboarded: false, existing: false, preset: None });
|
||||
let existing = ModeState::first_run(true);
|
||||
let dismissed = existing.chosen(None, true).unwrap();
|
||||
assert_eq!(dismissed, ModeState { mode: None, onboarded: true, existing: true }, "dismissed without choosing: nothing else changes");
|
||||
let chosen = dismissed.chosen(Some("power".into()), true).unwrap();
|
||||
let dismissed = existing.chosen(None, true, None).unwrap();
|
||||
assert_eq!(dismissed, ModeState { mode: None, onboarded: true, existing: true, preset: None }, "dismissed without choosing: nothing else changes");
|
||||
let chosen = dismissed.chosen(Some("power".into()), true, None).unwrap();
|
||||
assert_eq!(chosen.mode.as_deref(), Some("power"));
|
||||
assert!(chosen.existing);
|
||||
assert!(existing.chosen(Some("<script>".into()), true).is_err());
|
||||
assert!(existing.chosen(Some("<script>".into()), true, None).is_err());
|
||||
// The file reads back; missing fields take the defaults; unknown input never panics.
|
||||
let json = serde_json::to_string(&chosen).unwrap();
|
||||
assert_eq!(json, r#"{"mode":"power","onboarded":true,"existing":true}"#);
|
||||
assert_eq!(serde_json::from_str::<ModeState>("{}").unwrap(), ModeState::default());
|
||||
assert!(serde_json::from_str::<ModeState>(r#"{"mode":"BAD MODE"}"#).unwrap().validated().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_preset_of_the_moment_is_kept_with_the_choice() {
|
||||
let preset = |pairs: &[(&str, bool)]| pairs.iter().map(|(name, on)| (name.to_string(), *on)).collect::<BTreeMap<_, _>>();
|
||||
let start = ModeState::first_run(false);
|
||||
let chosen = start.chosen(Some("advanced".into()), true, Some(preset(&[("reader.readMarker", true), ("area.chat", false)]))).unwrap();
|
||||
assert_eq!(serde_json::to_string(&chosen).unwrap(), r#"{"mode":"advanced","onboarded":true,"existing":false,"preset":{"area.chat":false,"reader.readMarker":true}}"#);
|
||||
// The same mode again without a preset: what is stored stays. Another mode without one: forgotten.
|
||||
assert_eq!(chosen.chosen(Some("advanced".into()), true, None).unwrap().preset, chosen.preset);
|
||||
assert_eq!(chosen.chosen(Some("basic".into()), true, None).unwrap().preset, None);
|
||||
assert_eq!(chosen.chosen(Some("advanced".into()), true, Some(preset(&[]))).unwrap().preset, Some(preset(&[])));
|
||||
// Flag names only, a bounded number of them, and never without a mode.
|
||||
assert!(start.chosen(Some("advanced".into()), true, Some(preset(&[("<b>", true)]))).is_err());
|
||||
assert!(start.chosen(Some("advanced".into()), true, Some(preset(&[(&"a".repeat(65), true)]))).is_err());
|
||||
assert!(start.chosen(None, true, Some(preset(&[("hideAds", true)]))).is_err());
|
||||
let many = (0..=MAX_PRESET_FLAGS).map(|index| (format!("flag{index}"), true)).collect::<BTreeMap<_, _>>();
|
||||
assert!(start.chosen(Some("advanced".into()), true, Some(many)).is_err());
|
||||
// A file written before the preset was kept reads as "not known".
|
||||
assert_eq!(serde_json::from_str::<ModeState>(r#"{"mode":"advanced","onboarded":true,"existing":true}"#).unwrap().preset, None);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue