//! 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, pub external_links: Option, #[serde(alias = "xLinks")] pub site_links: Option, pub restore_tabs: Option, } 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 { 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 { 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 { 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) -> 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 (`.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 = [("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 = (0..64).map(|n| (format!("f{n}"), Value::Bool(n % 2 == 0))).collect(); assert!(validate_features(&old).is_ok()); let full: Map = (0..MAX_FEATURES).map(|n| (format!("area{n}.feature"), Value::Bool(true))).collect(); assert!(validate_features(&full).is_ok()); let many: Map = (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()); } }