host: plugins, custom CSS, feature flags, settings and open_panel are host commands
run_app now keeps and serves what every app's panel and page need, with the same caller checks
as the rest (commands::SHELL: shell_only first; testkit::assert_host_guards covers them):
- app.rs: PageState (the plugin store in app data `plugins`, the feature flags, ShellSettings)
managed by the host; commands plugins_list, plugin_set_enabled, plugins_open_folder,
custom_css_get/set, features_get/set, settings_get/set (moved from wayBetter for X). The app
reads the state through app::page(), gets a loaded document's scripts from app::page_scripts()
(flags, links, custom CSS, plugins) and writes through set_features/set_settings/set_custom_css
(a backup import). AppConfig takes the app's PluginHost (AppConfig::new(site, title, plugins))
and an optional home_url. SiteHooks::navigation_started and new_window_mode default to the
owner's link settings.
- settings.rs (pure, tested): ShellSettings { homeUrl, externalLinks, siteLinks, restoreTabs }
with its input form (missing fields take the defaults; `xLinks`, the name wayBetter for X used,
is read as siteLinks), links_script ({ site: mode }), feature validation and features_script.
- commands::SITE + open_panel: a tab on the site's origin asks the shell to open the app's panel
(site_tab_only first). testkit: assert_site_only(command, site) checks that every capability
granting a site command is a tab capability limited to the site's URLs; assert_host_commands and
assert_host_guards include the site commands.
- shell frame backupModel: a settings section that still says xLinks compares as siteLinks.
This commit is contained in:
parent
cecaf93639
commit
205ee82296
7 changed files with 529 additions and 36 deletions
181
crates/waybetter-host/src/settings.rs
Normal file
181
crates/waybetter-host/src/settings.rs
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
//! 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())
|
||||
}
|
||||
|
||||
/// 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() > 64 || features.iter().any(|(key, value)| key.len() > 64 || !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());
|
||||
let many: Map<String, Value> = (0..65).map(|n| (format!("f{n}"), Value::Bool(true))).collect();
|
||||
assert!(validate_features(&many).is_err());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue