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

@ -132,12 +132,31 @@ impl Capabilities {
assert!(granting.is_empty(), "{permission} is granted by {granting:?}");
}
/// The host's commands ([`commands::SHELL`], [`commands::TAB`]) keep their callers.
/// `command` is granted, and only to tab webviews on `site`'s own pages: every capability
/// granting it is a tab capability whose remote URLs are exactly `https://<host>/*` of the
/// site's hosts.
pub fn assert_site_only(&self, command: &str, site: &crate::site::SiteSpec) {
self.assert_tab_only(command);
let permission = commands::permission(command);
let allowed: Vec<String> = site.hosts.iter().map(|host| format!("https://{host}/*")).collect();
for cap in self.granting(&permission) {
let urls: Vec<&str> = cap.json["remote"]["urls"].as_array().map(|list| list.iter().filter_map(Value::as_str).collect()).unwrap_or_default();
assert!(
!urls.is_empty() && urls.iter().all(|url| allowed.iter().any(|ok| ok == url)),
"{permission} is for {allowed:?} only, but capability {:?} ({}) grants it on {urls:?}",
cap.identifier(),
cap.file
);
}
}
/// The host's commands ([`commands::SHELL`], [`commands::TAB`], [`commands::SITE`]) keep their
/// callers. Check the site commands' URLs with [`Self::assert_site_only`] too.
pub fn assert_host_commands(&self) {
for command in commands::SHELL {
self.assert_shell_only(command);
}
for command in commands::TAB {
for command in commands::TAB.iter().chain(commands::SITE) {
self.assert_tab_only(command);
}
}
@ -179,15 +198,23 @@ pub fn assert_host_guards() {
for command in commands::TAB {
assert_guarded(source, command, "tab_only(&webview)?");
}
for command in commands::SITE {
assert_guarded(source, command, "site_tab_only(config(&app).site, &webview)?");
}
}
#[cfg(test)]
mod tests {
use super::*;
fn caps(shell_extra: &str, tab_extra: &str) -> Capabilities {
fn caps_with(shell_extra: &str, tab_extra: &str, site_extra: &str) -> Capabilities {
let host_shell: Vec<String> = commands::SHELL.iter().map(|c| format!("\"{}\"", commands::permission(c))).collect();
let host_tab: Vec<String> = commands::TAB.iter().map(|c| format!("\"{}\"", commands::permission(c))).collect();
let host_site: Vec<String> = commands::SITE.iter().map(|c| format!("\"{}\"", commands::permission(c))).collect();
let site = format!(
r#"{{"identifier":"site-tab","webviews":["tab-*"],"local":false,"remote":{{"urls":["https://x.com/*"]}},"permissions":[{}{site_extra}]}}"#,
host_site.join(",")
);
let shell = format!(
r#"{{"identifier":"shell","webviews":["shell"],"local":true,"permissions":["core:event:default",{}{shell_extra}]}}"#,
host_shell.join(",")
@ -196,7 +223,11 @@ mod tests {
r#"{{"identifier":"tab-chrome","webviews":["tab-*"],"local":false,"remote":{{"urls":["https://*/*"]}},"permissions":[{}{tab_extra}]}}"#,
host_tab.join(",")
);
Capabilities::from_sources(&[("shell.json", &shell), ("tab-chrome.json", &tab)])
Capabilities::from_sources(&[("shell.json", &shell), ("tab-chrome.json", &tab), ("site-tab.json", &site)])
}
fn caps(shell_extra: &str, tab_extra: &str) -> Capabilities {
caps_with(shell_extra, tab_extra, "")
}
#[test]
@ -219,6 +250,15 @@ mod tests {
caps(r#","allow-tab-shortcut""#, "").assert_host_commands();
}
#[test]
fn site_commands_are_for_the_site_only() {
use crate::site::testing::SITE;
caps("", "").assert_site_only("open_panel", &SITE);
// Granted to every tab (any site), a site command fails.
let wide = std::panic::catch_unwind(|| caps("", r#","allow-open-panel""#).assert_site_only("open_panel", &SITE));
assert!(wide.is_err());
}
#[test]
#[should_panic(expected = "plugin API")]
fn plugin_apis_are_refused() {