waybetter/crates/waybetter-host/src/settings.rs
CRT 365f55f604 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
2026-09-30 18:34:13 +07:00

194 lines
9.2 KiB
Rust

//! What the shell's settings and the page tweaks are, as data the host keeps for every app:
//! [`ShellSettings`] (the home page, where links open, session restore; `settings.json`) and the
//! feature flags (a small map of booleans the page runtime applies; `features.json`). Pure: the
//! commands that read and save them are in [`crate::app`].
use crate::site::SiteSpec;
use crate::tabs;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use url::Url;
/// The home page, where links open and session restore.
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ShellSettings {
/// Page for new tabs and the first launch.
pub home_url: String,
/// Links to other sites opened from a tab: "pane" (split beside it), "tab" or "browser".
pub external_links: String,
/// The site's own pages clicked in the page: "same" (the site's in-place navigation), "pane",
/// "tab" or "browser". The page runtime diverts only those clicks; see [`links_script`].
pub site_links: String,
/// Reopen the previous session's tabs and splits on launch.
pub restore_tabs: bool,
}
/// Settings as they arrive (the shell, `settings.json`, a backup): any field may be missing.
/// `xLinks` is what wayBetter for X called `siteLinks` before the host kept the settings.
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct ShellSettingsInput {
pub home_url: Option<String>,
pub external_links: Option<String>,
#[serde(alias = "xLinks")]
pub site_links: Option<String>,
pub restore_tabs: Option<bool>,
}
impl ShellSettings {
/// The settings before the owner changes anything; `home` is the app's home page.
pub fn defaults(home: &str) -> Self {
Self { home_url: home.to_owned(), external_links: "pane".into(), site_links: "same".into(), restore_tabs: true }
}
/// `input` over the defaults, validated: an http(s) home page and known link modes.
pub fn from_input(input: ShellSettingsInput, home: &str) -> Result<Self, String> {
let defaults = Self::defaults(home);
Self {
home_url: input.home_url.unwrap_or(defaults.home_url),
external_links: input.external_links.unwrap_or(defaults.external_links),
site_links: input.site_links.unwrap_or(defaults.site_links),
restore_tabs: input.restore_tabs.unwrap_or(defaults.restore_tabs),
}
.validated()
}
/// Settings from JSON (a file, a backup section), over the defaults and validated.
pub fn from_value(value: Value, home: &str) -> Result<Self, String> {
let input: ShellSettingsInput = serde_json::from_value(value).map_err(|error| format!("VALIDATION: {error}"))?;
Self::from_input(input, home)
}
pub fn validated(mut self) -> Result<Self, String> {
let home = Url::parse(self.home_url.trim()).map_err(|_| "VALIDATION: invalid home page URL")?;
if !matches!(home.scheme(), "http" | "https") {
return Err("VALIDATION: home page must be an http(s) URL".into());
}
self.home_url = home.to_string();
if !tabs::valid_external_mode(&self.external_links) {
return Err("VALIDATION: external links must be \"pane\", \"tab\" or \"browser\"".into());
}
if !tabs::valid_site_mode(&self.site_links) {
return Err("VALIDATION: site links must be \"same\", \"pane\", \"tab\" or \"browser\"".into());
}
Ok(self)
}
/// Where a new-window request (a link with target=_blank, window.open) opens; `on_site`: the
/// request is for one of the site's own pages.
pub fn new_window_mode(&self, on_site: bool) -> &str {
// "same" only concerns ordinary clicks; an explicit new window on the site still gets a tab.
tabs::new_window_mode(on_site, &self.site_links, &self.external_links)
}
/// Links to other sites go to the system browser.
pub fn external_in_browser(&self) -> bool {
self.external_links == "browser"
}
}
/// Tells a site tab where clicked site pages open (`site_links`); the page runtime diverts only
/// "pane", "tab" and "browser" clicks, as a plain new-window request that
/// [`ShellSettings::new_window_mode`] routes. Queued in `__btLinks` until the runtime exists.
pub fn links_script(site: &SiteSpec, settings: &ShellSettings) -> String {
let links = serde_json::json!({ "site": settings.site_links });
tabs::deliver_script(site.runtime_global, "links", "configure", "__btLinks", &links.to_string())
}
/// Flags a store may hold. It was 64 while every flag was a page tweak; area plugins carry a flag
/// per feature. The map is stored sparsely (only values that differ from the app's defaults), so a
/// backup written by this version normally still fits an older app's limit of 64.
pub const MAX_FEATURES: usize = 256;
/// Characters in a flag key.
pub const MAX_FEATURE_KEY: usize = 64;
/// Feature flags are booleans only, in a small map; the page normalizes them again before use.
pub fn validate_features(features: &Map<String, Value>) -> Result<(), String> {
if features.len() > MAX_FEATURES || features.iter().any(|(key, value)| key.is_empty() || key.len() > MAX_FEATURE_KEY || !value.is_boolean()) {
return Err("VALIDATION: features must be a small map of booleans".into());
}
Ok(())
}
/// Hands a site tab the feature flags (`<global>.features.apply`), queued in `__btFeatures` until
/// the runtime exists.
pub fn features_script(site: &SiteSpec, features: &Value) -> String {
tabs::deliver_script(site.runtime_global, "features", "apply", "__btFeatures", &features.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::site::testing::SITE;
use serde_json::json;
const HOME: &str = "https://x.com/home";
#[test]
fn missing_fields_take_the_defaults_and_the_old_name_is_read() {
let defaults = ShellSettings::defaults(HOME);
assert_eq!(ShellSettings::from_value(json!({}), HOME).unwrap(), defaults);
let old = ShellSettings::from_value(json!({ "homeUrl": "https://x.com/explore", "xLinks": "tab", "restoreTabs": false }), HOME).unwrap();
assert_eq!(old.site_links, "tab");
assert_eq!(old.home_url, "https://x.com/explore");
assert!(!old.restore_tabs);
assert_eq!(old.external_links, "pane");
let new = ShellSettings::from_value(json!({ "siteLinks": "browser" }), HOME).unwrap();
assert_eq!(new.site_links, "browser");
// What the shell reads back.
assert_eq!(
serde_json::to_value(&new).unwrap(),
json!({ "homeUrl": HOME, "externalLinks": "pane", "siteLinks": "browser", "restoreTabs": true })
);
}
#[test]
fn bad_values_are_refused() {
for bad in [
json!({ "homeUrl": "javascript:alert(1)" }),
json!({ "homeUrl": "not a url" }),
json!({ "externalLinks": "same" }),
json!({ "siteLinks": "window" }),
json!({ "restoreTabs": "yes" }),
] {
assert!(ShellSettings::from_value(bad.clone(), HOME).is_err(), "{bad}");
}
}
#[test]
fn new_windows_follow_the_link_settings() {
let mut settings = ShellSettings::defaults(HOME);
assert_eq!(settings.new_window_mode(true), "tab", "\"same\" is about plain clicks; a new window on the site is a tab");
assert_eq!(settings.new_window_mode(false), "pane");
settings.site_links = "browser".into();
settings.external_links = "browser".into();
assert_eq!(settings.new_window_mode(true), "browser");
assert!(settings.external_in_browser());
}
#[test]
fn scripts_deliver_data_to_the_runtime() {
let links = links_script(&SITE, &ShellSettings::defaults(HOME));
assert!(links.contains(r#"{"site":"same"}"#) && links.contains("R.links.configure(v)") && links.contains("window.__btLinks=v"), "{links}");
let features = features_script(&SITE, &json!({ "hideTrends": true }));
assert!(features.contains(r#"{"hideTrends":true}"#) && features.contains("R.features.apply(v)") && features.contains("window.__btFeatures=v"), "{features}");
}
#[test]
fn features_are_a_small_map_of_booleans() {
let map = |value: Value| value.as_object().cloned().unwrap();
assert!(validate_features(&map(json!({ "a": true, "b": false }))).is_ok());
assert!(validate_features(&map(json!({ "a": "yes" }))).is_err());
let long: Map<String, Value> = [("x".repeat(65), Value::Bool(true))].into_iter().collect();
assert!(validate_features(&long).is_err());
// What an older app wrote (at most 64 flags, the whole map) still imports; the cap is 256 now.
let old: Map<String, Value> = (0..64).map(|n| (format!("f{n}"), Value::Bool(n % 2 == 0))).collect();
assert!(validate_features(&old).is_ok());
let full: Map<String, Value> = (0..MAX_FEATURES).map(|n| (format!("area{n}.feature"), Value::Bool(true))).collect();
assert!(validate_features(&full).is_ok());
let many: Map<String, Value> = (0..=MAX_FEATURES).map(|n| (format!("f{n}"), Value::Bool(true))).collect();
assert!(validate_features(&many).is_err());
assert!(validate_features(&map(json!({ "": true }))).is_err());
}
}