Host: 256 feature flags, the onboarding mode (mode_get/mode_set, shell-only), and the one-time move of replaced plugin files to feature flags

- settings: MAX_FEATURES 64 -> 256 (an older app's whole-map backup still validates)
- modes: ModeState {mode, onboarded, existing} in modes.json; existing is decided natively on the first start
- plugins::migrate: PluginHost.superseded (file -> flag); enabled and as shipped -> flag on and the file goes; edited -> kept, flag left off; marker written last, every step repeatable; map_backup for imported enabled lists
- a superseded file is never written again by install_bundled
- create-waybetter: the host command list and PluginHost.superseded
This commit is contained in:
CRTkafa 2026-09-30 18:34:13 +07:00
parent 43fa215f3b
commit e38314f65c
9 changed files with 477 additions and 6 deletions

View file

@ -0,0 +1,85 @@
//! The onboarding mode: which preset (Basic / Advanced / Power User, whatever the app defines) the
//! 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.
//!
//! `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 serde::{Deserialize, Serialize};
pub const MODES_FILE: &str = "modes.json";
const MAX_MODE_ID: usize = 32;
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct ModeState {
/// The mode chosen last; `None` = never chosen (the app's defaults, or an earlier setup).
pub mode: Option<String>,
/// The first-run screen was answered or dismissed.
pub onboarded: bool,
/// The app was set up before modes existed.
pub existing: bool,
}
/// A mode id: lowercase letters, digits and `-`, starting with a letter, at most 32 characters.
pub fn valid_mode(id: &str) -> bool {
!id.is_empty()
&& id.len() <= MAX_MODE_ID
&& id.as_bytes()[0].is_ascii_lowercase()
&& id.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
}
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 }
}
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());
}
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()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mode_ids_are_short_plain_words() {
for good in ["basic", "advanced", "power-user", "m2"] {
assert!(valid_mode(good), "{good}");
}
for bad in ["", "Basic", "2fast", "a b", "a/b", "../x", &"a".repeat(33)] {
assert!(!valid_mode(bad), "{bad}");
}
}
#[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 });
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();
assert_eq!(chosen.mode.as_deref(), Some("power"));
assert!(chosen.existing);
assert!(existing.chosen(Some("<script>".into()), true).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());
}
}