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:
CRTkafa 2026-09-30 14:28:52 +07:00
parent cecaf93639
commit 205ee82296
7 changed files with 529 additions and 36 deletions

View 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());
}
}