host: palette commands and developer mode watcher
plugins::commands: the palette commands a site tab reports (validated and capped as a whole) and the page script that runs one, now built from the SiteSpec (runtime global, primary origin) instead of a hard-coded x.com. devmode: the debounced polling watcher (plugins, custom.css, one theme file), reload order, DevError and theme-file reading; the app turns the document into its own theme state through a callback.
This commit is contained in:
parent
1efdd8e6c7
commit
e510ab8d59
4 changed files with 434 additions and 1 deletions
307
crates/waybetter-host/src/devmode.rs
Normal file
307
crates/waybetter-host/src/devmode.rs
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
//! Live-reload developer mode (Settings → Developer, off by default): native code watches the
|
||||
//! plugins folder (`*.plugin.js`), `custom.css` and one theme file the owner picked, and hot-reloads
|
||||
//! what changed in every site tab. The app's commands for it are shell-only; site tabs gain no
|
||||
//! command (they only receive the scripts native code already evaluates: plugin start/stop, custom
|
||||
//! CSS, the theme preview).
|
||||
//!
|
||||
//! The watcher polls file signatures (length + modification time) a few times a second, which
|
||||
//! works the same on every platform and for files on synced/network drives. A change is reported
|
||||
//! once the file has been stable for [`DEBOUNCE`] (editors save in several writes), and changes are
|
||||
//! applied in a fixed order: theme, then custom CSS, then plugins by file name, each plugin stopped
|
||||
//! before its new version starts. Pure (signatures and instants in, changes out), tested headless.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant, SystemTime};
|
||||
|
||||
pub const FILE: &str = "developer.json";
|
||||
/// A file must keep the same signature this long before it counts as changed.
|
||||
pub const DEBOUNCE: Duration = Duration::from_millis(250);
|
||||
/// How often the watcher looks.
|
||||
pub const POLL: Duration = Duration::from_millis(200);
|
||||
/// Largest theme file read (a background image is a data: URL inside it).
|
||||
pub const MAX_THEME_FILE: u64 = 1_200_000;
|
||||
/// Shell event for everything developer mode does (`DevEvent`).
|
||||
pub const EVENT: &str = "dev-event";
|
||||
/// `sourceURL` prefix of reloaded plugins (stack traces show `wb-dev/<file>:<line>`).
|
||||
pub const SOURCE_PREFIX: &str = "wb-dev/";
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct DevSettings {
|
||||
pub enabled: bool,
|
||||
/// Absolute path of the theme JSON file to live-apply (picked in the native dialog), if any.
|
||||
pub theme_file: Option<String>,
|
||||
}
|
||||
|
||||
impl DevSettings {
|
||||
pub fn validated(self) -> Result<Self, String> {
|
||||
if let Some(path) = &self.theme_file {
|
||||
let path = Path::new(path);
|
||||
if !path.is_absolute() || path.extension().and_then(|ext| ext.to_str()).map(str::to_ascii_lowercase).as_deref() != Some("json") {
|
||||
return Err("VALIDATION: the theme file must be an absolute path to a .json file".into());
|
||||
}
|
||||
}
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum Target {
|
||||
Theme,
|
||||
Css,
|
||||
Plugin(String),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Signature {
|
||||
pub len: u64,
|
||||
pub modified: Option<SystemTime>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Change {
|
||||
Changed(Target),
|
||||
Removed(Target),
|
||||
}
|
||||
|
||||
impl Change {
|
||||
pub fn target(&self) -> &Target {
|
||||
match self {
|
||||
Change::Changed(target) | Change::Removed(target) => target,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn signature(path: &Path) -> Option<Signature> {
|
||||
let meta = fs::metadata(path).ok()?;
|
||||
meta.is_file().then(|| Signature { len: meta.len(), modified: meta.modified().ok() })
|
||||
}
|
||||
|
||||
/// What the watcher looks at now: every valid `*.plugin.js`, `custom.css` and the theme file.
|
||||
pub fn scan(plugins_dir: &Path, theme_file: Option<&Path>) -> BTreeMap<Target, Signature> {
|
||||
let mut out = BTreeMap::new();
|
||||
if let Ok(entries) = fs::read_dir(plugins_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let Some(name) = entry.file_name().to_str().map(str::to_owned) else { continue };
|
||||
if crate::plugins::valid_file_name(&name) {
|
||||
if let Some(sig) = signature(&entry.path()) {
|
||||
out.insert(Target::Plugin(name), sig);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(sig) = signature(&plugins_dir.join("custom.css")) {
|
||||
out.insert(Target::Css, sig);
|
||||
}
|
||||
if let Some(sig) = theme_file.and_then(signature) {
|
||||
out.insert(Target::Theme, sig);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The debounced change detector. The first scan is the baseline (nothing is "changed" at start).
|
||||
#[derive(Default)]
|
||||
pub struct Watcher {
|
||||
known: Option<BTreeMap<Target, Signature>>,
|
||||
/// Target → (signature seen, since when), for changes not yet stable.
|
||||
pending: BTreeMap<Target, (Option<Signature>, Instant)>,
|
||||
}
|
||||
|
||||
impl Watcher {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Feeds one scan; returns the changes that became stable, in reload order.
|
||||
pub fn observe(&mut self, now: Instant, scan: BTreeMap<Target, Signature>) -> Vec<Change> {
|
||||
let Some(known) = self.known.as_mut() else {
|
||||
self.known = Some(scan);
|
||||
return Vec::new();
|
||||
};
|
||||
let mut targets: Vec<Target> = known.keys().cloned().collect();
|
||||
for target in scan.keys() {
|
||||
if !known.contains_key(target) {
|
||||
targets.push(target.clone());
|
||||
}
|
||||
}
|
||||
for target in targets {
|
||||
let current = scan.get(&target).copied();
|
||||
if current == known.get(&target).copied() {
|
||||
self.pending.remove(&target);
|
||||
continue;
|
||||
}
|
||||
match self.pending.get(&target) {
|
||||
Some((seen, _)) if *seen == current => {}
|
||||
_ => {
|
||||
self.pending.insert(target, (current, now));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut ready = Vec::new();
|
||||
let stable: Vec<Target> = self
|
||||
.pending
|
||||
.iter()
|
||||
.filter(|(_, (_, since))| now.duration_since(*since) >= DEBOUNCE)
|
||||
.map(|(target, _)| target.clone())
|
||||
.collect();
|
||||
for target in stable {
|
||||
let Some((current, _)) = self.pending.remove(&target) else { continue };
|
||||
match current {
|
||||
Some(sig) => {
|
||||
known.insert(target.clone(), sig);
|
||||
ready.push(Change::Changed(target));
|
||||
}
|
||||
None => {
|
||||
known.remove(&target);
|
||||
ready.push(Change::Removed(target));
|
||||
}
|
||||
}
|
||||
}
|
||||
reload_order(ready)
|
||||
}
|
||||
|
||||
/// Forgets the baseline (the next scan becomes it), e.g. after the theme file changed.
|
||||
pub fn reset(&mut self) {
|
||||
self.known = None;
|
||||
self.pending.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Theme first (so plugins read the new `--wb-*` values), then custom CSS, then plugins by name.
|
||||
pub fn reload_order(mut changes: Vec<Change>) -> Vec<Change> {
|
||||
changes.sort_by(|a, b| a.target().cmp(b.target()));
|
||||
changes
|
||||
}
|
||||
|
||||
/// A JSON error position, 1-based, for the error overlay.
|
||||
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DevError {
|
||||
pub file: String,
|
||||
pub line: Option<usize>,
|
||||
pub column: Option<usize>,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Reads and checks the watched theme file: at most [`MAX_THEME_FILE`], UTF-8 (a BOM is fine), one
|
||||
/// JSON object, `$schema` dropped. `build` turns that document into the app's theme preview state
|
||||
/// and validates it; its error (without a `VALIDATION: ` prefix) is shown in the error overlay.
|
||||
pub fn load_theme_file<T>(
|
||||
path: &Path,
|
||||
build: impl FnOnce(serde_json::Map<String, serde_json::Value>) -> Result<T, String>,
|
||||
) -> Result<T, DevError> {
|
||||
let file = path.file_name().and_then(|name| name.to_str()).unwrap_or("theme.json").to_owned();
|
||||
let fail = |line: Option<usize>, column: Option<usize>, message: String| DevError { file: file.clone(), line, column, message };
|
||||
let meta = fs::metadata(path).map_err(|_| fail(None, None, "The theme file is missing.".into()))?;
|
||||
if meta.len() > MAX_THEME_FILE {
|
||||
return Err(fail(None, None, "The theme file is larger than 1.2 MB.".into()));
|
||||
}
|
||||
let raw = fs::read_to_string(path).map_err(|_| fail(None, None, "The theme file is not UTF-8 text.".into()))?;
|
||||
let doc: serde_json::Value = serde_json::from_str(raw.trim_start_matches('\u{feff}')).map_err(|error| {
|
||||
let message = error.to_string();
|
||||
let message = message.split(" at line ").next().unwrap_or(&message).to_owned();
|
||||
fail(Some(error.line()), Some(error.column()), message)
|
||||
})?;
|
||||
let serde_json::Value::Object(mut object) = doc else {
|
||||
return Err(fail(Some(1), Some(1), "A theme file is one JSON object.".into()));
|
||||
};
|
||||
object.remove("$schema");
|
||||
build(object).map_err(|error| fail(None, None, error.trim_start_matches("VALIDATION: ").to_owned()))
|
||||
}
|
||||
|
||||
pub fn plugins_dir_of(app_data: &Path) -> PathBuf {
|
||||
app_data.join("plugins")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sig(len: u64, secs: u64) -> Signature {
|
||||
Signature { len, modified: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(secs)) }
|
||||
}
|
||||
|
||||
fn scan_of(items: &[(Target, Signature)]) -> BTreeMap<Target, Signature> {
|
||||
items.iter().cloned().collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debounce_waits_for_a_stable_file_and_reports_once() {
|
||||
let start = Instant::now();
|
||||
let at = |ms: u64| start + Duration::from_millis(ms);
|
||||
let plugin = Target::Plugin("a.plugin.js".into());
|
||||
let mut watcher = Watcher::new();
|
||||
assert!(watcher.observe(at(0), scan_of(&[(plugin.clone(), sig(10, 1))])).is_empty(), "baseline");
|
||||
// An editor writes in steps: 0 bytes, then partial, then the whole file.
|
||||
assert!(watcher.observe(at(100), scan_of(&[(plugin.clone(), sig(0, 2))])).is_empty());
|
||||
assert!(watcher.observe(at(200), scan_of(&[(plugin.clone(), sig(5, 2))])).is_empty());
|
||||
assert!(watcher.observe(at(300), scan_of(&[(plugin.clone(), sig(12, 3))])).is_empty());
|
||||
assert!(watcher.observe(at(500), scan_of(&[(plugin.clone(), sig(12, 3))])).is_empty(), "stable for 200 ms only");
|
||||
assert_eq!(watcher.observe(at(560), scan_of(&[(plugin.clone(), sig(12, 3))])), vec![Change::Changed(plugin.clone())]);
|
||||
assert!(watcher.observe(at(900), scan_of(&[(plugin.clone(), sig(12, 3))])).is_empty(), "reported once");
|
||||
// Changed and changed back before it settled: nothing to reload.
|
||||
assert!(watcher.observe(at(1000), scan_of(&[(plugin.clone(), sig(13, 4))])).is_empty());
|
||||
assert!(watcher.observe(at(1100), scan_of(&[(plugin.clone(), sig(12, 3))])).is_empty());
|
||||
assert!(watcher.observe(at(2000), scan_of(&[(plugin.clone(), sig(12, 3))])).is_empty());
|
||||
// Deleted.
|
||||
assert!(watcher.observe(at(3000), BTreeMap::new()).is_empty());
|
||||
assert_eq!(watcher.observe(at(3300), BTreeMap::new()), vec![Change::Removed(plugin)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reload_order_is_theme_css_then_plugins_by_name() {
|
||||
let start = Instant::now();
|
||||
let mut watcher = Watcher::new();
|
||||
let (b, a) = (Target::Plugin("b.plugin.js".into()), Target::Plugin("a.plugin.js".into()));
|
||||
watcher.observe(start, scan_of(&[(b.clone(), sig(1, 1)), (a.clone(), sig(1, 1)), (Target::Css, sig(1, 1)), (Target::Theme, sig(1, 1))]));
|
||||
let changed = scan_of(&[(b.clone(), sig(2, 2)), (a.clone(), sig(2, 2)), (Target::Css, sig(2, 2)), (Target::Theme, sig(2, 2))]);
|
||||
watcher.observe(start + Duration::from_millis(10), changed.clone());
|
||||
let ready = watcher.observe(start + Duration::from_millis(300), changed);
|
||||
assert_eq!(ready, vec![Change::Changed(Target::Theme), Change::Changed(Target::Css), Change::Changed(a), Change::Changed(b)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_sees_plugins_css_and_the_theme_file_only() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let plugins = root.path().join("plugins");
|
||||
fs::create_dir_all(&plugins).unwrap();
|
||||
fs::write(plugins.join("a.plugin.js"), "x").unwrap();
|
||||
fs::write(plugins.join("notes.txt"), "x").unwrap();
|
||||
fs::write(plugins.join("enabled.json"), "[]").unwrap();
|
||||
fs::write(plugins.join("custom.css"), "a{}").unwrap();
|
||||
let theme = root.path().join("mine.json");
|
||||
fs::write(&theme, "{}").unwrap();
|
||||
let found: Vec<Target> = scan(&plugins, Some(&theme)).into_keys().collect();
|
||||
assert_eq!(found, vec![Target::Theme, Target::Css, Target::Plugin("a.plugin.js".into())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_file_errors_carry_line_and_column() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let path = root.path().join("mine.json");
|
||||
let id = |object: serde_json::Map<String, serde_json::Value>| -> Result<serde_json::Map<String, serde_json::Value>, String> {
|
||||
match object.get("id").and_then(serde_json::Value::as_str) {
|
||||
Some(id) if !id.contains(' ') => Ok(object),
|
||||
_ => Err("VALIDATION: theme ids are slugs".into()),
|
||||
}
|
||||
};
|
||||
fs::write(&path, "{\n \"id\": \"mine\",\n \"name\": \"Mine\"\n \"colors\": {}\n}").unwrap();
|
||||
let error = load_theme_file(&path, id).unwrap_err();
|
||||
assert_eq!((error.file.as_str(), error.line), ("mine.json", Some(4)));
|
||||
assert!(error.column.is_some() && error.message.contains("expected"), "{error:?}");
|
||||
fs::write(&path, "\u{feff}{ \"$schema\": \"https://unpkg.com/waybetter/theme.schema.json\", \"id\": \"nord\" }").unwrap();
|
||||
let doc = load_theme_file(&path, id).unwrap();
|
||||
assert!(doc.get("$schema").is_none() && doc["id"] == "nord");
|
||||
fs::write(&path, r#"{ "id": "Bad Id", "name": "x" }"#).unwrap();
|
||||
assert_eq!(load_theme_file(&path, id).unwrap_err().message, "theme ids are slugs");
|
||||
fs::write(&path, "[1]").unwrap();
|
||||
assert_eq!(load_theme_file(&path, id).unwrap_err().message, "A theme file is one JSON object.");
|
||||
fs::write(&path, vec![b' '; MAX_THEME_FILE as usize + 1]).unwrap();
|
||||
assert!(load_theme_file(&path, id).unwrap_err().message.contains("larger"));
|
||||
assert!(load_theme_file(&root.path().join("gone.json"), id).unwrap_err().message.contains("missing"));
|
||||
assert!(DevSettings { enabled: true, theme_file: Some("relative.json".into()) }.validated().is_err());
|
||||
}
|
||||
}
|
||||
|
|
@ -9,7 +9,8 @@
|
|||
//! - [`site`]: the site adapter ([`site::SiteSpec`], [`site::AccountRules`]).
|
||||
//! - [`policy`]: webview labels, origin checks and the tab rectangle clamp (security boundary).
|
||||
//! - [`profiles`]: account profiles, one WebView data directory each, with the account seen inside.
|
||||
//! - [`plugins`]: owner-installed `*.plugin.js` files, custom CSS and bundled-plugin upgrades.
|
||||
//! - [`plugins`]: owner-installed `*.plugin.js` files, custom CSS and bundled-plugin upgrades;
|
||||
//! [`plugins::commands`]: the palette commands site tabs report and the script that runs one.
|
||||
//! - [`hotfix`]: the signed, data-only hotfix channel.
|
||||
//! - [`update`]: silent-update scheduling and the crash handshake.
|
||||
//! - [`tabs`]: tab layout/state payloads, link routing and tab-chrome validation.
|
||||
|
|
@ -18,10 +19,12 @@
|
|||
//! - [`backup`]: the settings backup envelope, secret scrubbing and the plugin/account sections.
|
||||
//! - [`sync`]: encrypted settings sync through a folder the owner syncs (Argon2id + XChaCha20-Poly1305).
|
||||
//! - [`workspaces`]: saved tab/pane/deck layouts, bounded and well-formed (never evaluated).
|
||||
//! - [`devmode`]: developer mode's debounced watcher of plugins, custom CSS and a theme file.
|
||||
//!
|
||||
//! With the `tauri` feature, [`host`] adds the Tauri glue that does not depend on app state.
|
||||
|
||||
pub mod backup;
|
||||
pub mod devmode;
|
||||
pub mod hotfix;
|
||||
pub mod i18n;
|
||||
pub mod layout;
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@
|
|||
//! That is a declaration plus a guard rail, not a sandbox: page JavaScript can always reach around
|
||||
//! it. A plugin without the line is "legacy" (full API, labelled so in the app).
|
||||
|
||||
pub mod commands;
|
||||
|
||||
use serde::Serialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
|
|
|||
121
crates/waybetter-host/src/plugins/commands.rs
Normal file
121
crates/waybetter-host/src/plugins/commands.rs
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
//! Plugin commands in the shell's command palette.
|
||||
//!
|
||||
//! A site tab may report the palette commands its plugins registered (`api.commands.register`,
|
||||
//! waybetter runtime `commands.ts`) as `{ id, title, keywords }` strings about itself. The app
|
||||
//! accepts them only from `tab-*` webviews that are on the site right now ([`SiteSpec::is_origin`]),
|
||||
//! validates and caps them here, keeps them per tab (a new page load clears them) and forwards them
|
||||
//! to the shell. Running one is a shell-only app command: native code checks the command is one this
|
||||
//! tab reported and asks that tab's page to `run` it ([`run_script`]); the command's code only ever
|
||||
//! runs in the page.
|
||||
|
||||
use crate::site::SiteSpec;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const MAX_COMMANDS: usize = 48;
|
||||
pub const MAX_TITLE: usize = 80;
|
||||
pub const MAX_KEYWORDS: usize = 160;
|
||||
const MAX_COMMAND_ID: usize = 64;
|
||||
/// Shell event carrying `{ id, commands }` for one tab.
|
||||
pub const EVENT: &str = "tab-commands";
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PaletteCommand {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub keywords: String,
|
||||
}
|
||||
|
||||
/// `<owner>:<command>`: the owner is `_` or a plugin file name (`valid_file_name`), the command id
|
||||
/// letters, digits, `.`, `_`, `-` (1–64, starting with a letter or digit).
|
||||
pub fn valid_command_id(id: &str) -> bool {
|
||||
let Some((owner, command)) = id.split_once(':') else { return false };
|
||||
let owner_ok = owner == "_" || super::valid_file_name(owner);
|
||||
let command_ok = !command.is_empty()
|
||||
&& command.len() <= MAX_COMMAND_ID
|
||||
&& command.as_bytes()[0].is_ascii_alphanumeric()
|
||||
&& command.bytes().all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b));
|
||||
owner_ok && command_ok
|
||||
}
|
||||
|
||||
fn plain(text: &str, max: usize, required: bool) -> bool {
|
||||
let count = text.chars().count();
|
||||
count <= max && (!required || !text.trim().is_empty()) && !text.chars().any(char::is_control)
|
||||
}
|
||||
|
||||
/// The list a tab reported, validated as a whole: any bad entry rejects the report.
|
||||
pub fn validate(list: Vec<PaletteCommand>) -> Result<Vec<PaletteCommand>, String> {
|
||||
if list.len() > MAX_COMMANDS {
|
||||
return Err(format!("VALIDATION: at most {MAX_COMMANDS} palette commands per tab"));
|
||||
}
|
||||
let mut seen: Vec<&str> = Vec::new();
|
||||
for command in &list {
|
||||
if !valid_command_id(&command.id) || seen.contains(&command.id.as_str()) {
|
||||
return Err("VALIDATION: invalid palette command id".into());
|
||||
}
|
||||
if !plain(&command.title, MAX_TITLE, true) || !plain(&command.keywords, MAX_KEYWORDS, false) {
|
||||
return Err("VALIDATION: palette command titles are 1-80 and keywords at most 160 plain characters".into());
|
||||
}
|
||||
seen.push(&command.id);
|
||||
}
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
/// The page script that runs command `id` (already checked against the tab's list) in a site tab:
|
||||
/// it does nothing unless the page is the top frame on the site's primary origin.
|
||||
pub fn run_script(site: &SiteSpec, id: &str) -> Result<String, String> {
|
||||
if !valid_command_id(id) {
|
||||
return Err("VALIDATION: invalid palette command id".into());
|
||||
}
|
||||
let id = serde_json::to_string(id).map_err(|_| "VALIDATION: invalid palette command id")?;
|
||||
let (global, origin) = (site.runtime_global, site.primary_origin());
|
||||
Ok(format!(
|
||||
"(()=>{{const W=window.{global};const C=W&&W.api&&W.api.commands;\
|
||||
if(window.top===window&&location.origin==='{origin}'&&C&&typeof C.run==='function')C.run({id});}})();"
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::site::testing::SITE;
|
||||
|
||||
fn command(id: &str, title: &str) -> PaletteCommand {
|
||||
PaletteCommand { id: id.into(), title: title.into(), keywords: "focus hide".into() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_plugin_owned_ids_titles_and_keywords_only() {
|
||||
let list = vec![command("bt-focus-mode.plugin.js:toggle", "Focus Mode: toggle"), command("_:later", "Later")];
|
||||
assert_eq!(validate(list.clone()).unwrap(), list);
|
||||
assert!(validate(Vec::new()).unwrap().is_empty(), "an empty list clears the tab's commands");
|
||||
// Only the three string fields exist on the wire.
|
||||
let extra = serde_json::from_value::<PaletteCommand>(serde_json::json!({ "id": "_:a", "title": "A", "keywords": "", "run": "alert(1)" }));
|
||||
assert!(extra.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_ids_long_or_control_text_duplicates_and_too_many() {
|
||||
for id in ["toggle", ":toggle", "_:", "../x.plugin.js:a", "x.js:a", "_:-a", "_:has space", "_:a:b", &format!("_:{}", "a".repeat(65))] {
|
||||
assert!(validate(vec![command(id, "T")]).is_err(), "{id}");
|
||||
}
|
||||
assert!(validate(vec![command("_:a", "")]).is_err());
|
||||
assert!(validate(vec![command("_:a", " ")]).is_err());
|
||||
assert!(validate(vec![command("_:a", &"t".repeat(MAX_TITLE + 1))]).is_err());
|
||||
assert!(validate(vec![command("_:a", "line\nbreak")]).is_err());
|
||||
assert!(validate(vec![PaletteCommand { keywords: "k".repeat(MAX_KEYWORDS + 1), ..command("_:a", "A") }]).is_err());
|
||||
assert!(validate(vec![command("_:a", "A"), command("_:a", "B")]).is_err());
|
||||
let many = (0..=MAX_COMMANDS).map(|n| command(&format!("_:c{n}"), "T")).collect();
|
||||
assert!(validate(many).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_script_embeds_the_id_as_a_literal_and_stays_on_the_site() {
|
||||
let script = run_script(&SITE, "bt-focus-mode.plugin.js:toggle").unwrap();
|
||||
assert!(script.contains(r#"C.run("bt-focus-mode.plugin.js:toggle")"#));
|
||||
assert!(script.contains("const W=window.BetterTwttr;"));
|
||||
assert!(script.contains("location.origin==='https://x.com'"));
|
||||
assert!(run_script(&SITE, "_:a\");alert(1);(\"").is_err());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue