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:
CRTkafa 2026-09-29 11:02:07 +07:00
parent 1efdd8e6c7
commit e510ab8d59
4 changed files with 434 additions and 1 deletions

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

View file

@ -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;

View file

@ -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};

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