- 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
194 lines
9.2 KiB
Rust
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());
|
|
}
|
|
}
|