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:
CRTkafa 2026-10-01 03:33:41 +07:00
parent b75246bef8
commit 26658e14ae
9 changed files with 288 additions and 27 deletions

View file

@ -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)

View file

@ -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);
}
}