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:
parent
43fa215f3b
commit
e38314f65c
9 changed files with 477 additions and 6 deletions
85
crates/waybetter-host/src/modes.rs
Normal file
85
crates/waybetter-host/src/modes.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue