//! 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/:`). 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, } impl DevSettings { pub fn validated(self) -> Result { 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, } /// A stable change of one watched key (a [`Target`] for developer mode; the debug page-script /// reload keys its files by path). #[derive(Clone, Debug, PartialEq, Eq)] pub enum Change { Changed(K), Removed(K), } impl Change { pub fn target(&self) -> &K { match self { Change::Changed(target) | Change::Removed(target) => target, } } } pub fn signature(path: &Path) -> Option { 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 { 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). pub struct Watcher { known: Option>, /// Target → (signature seen, since when), for changes not yet stable. pending: BTreeMap, Instant)>, } impl Default for Watcher { fn default() -> Self { Self { known: None, pending: BTreeMap::new() } } } 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) -> Vec> { let Some(known) = self.known.as_mut() else { self.known = Some(scan); return Vec::new(); }; let mut targets: Vec = 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 = 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>) -> Vec> { 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, pub column: Option, 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( path: &Path, build: impl FnOnce(serde_json::Map) -> Result, ) -> Result { let file = path.file_name().and_then(|name| name.to_str()).unwrap_or("theme.json").to_owned(); let fail = |line: Option, column: Option, 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 { 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 = 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| -> Result, 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()); } }