diff --git a/crates/waybetter-host/src/app.rs b/crates/waybetter-host/src/app.rs index ab565be..69b4329 100644 --- a/crates/waybetter-host/src/app.rs +++ b/crates/waybetter-host/src/app.rs @@ -907,7 +907,9 @@ fn tab_builder(app: &AppHandle, hooks: &Arc, tab: &NewTab, url: U } } // Safe mode: the safe set (the tab bridge) instead of the app's page layers. - for script in crate::safe::layer(crate::safe::active(), || hooks.init_scripts(app, tab), || hooks.safe_init_scripts(app, tab)) { + // The string packs' delivery goes first, before the page runtime reads it (not in safe mode). + let layers = || crate::store_commands::strings_init_script(app).into_iter().chain(hooks.init_scripts(app, tab)).collect::>(); + for script in crate::safe::layer(crate::safe::active(), layers, || hooks.safe_init_scripts(app, tab)) { builder = builder.initialization_script(script); } builder diff --git a/crates/waybetter-host/src/commands.rs b/crates/waybetter-host/src/commands.rs index 2aed7a7..593f547 100644 --- a/crates/waybetter-host/src/commands.rs +++ b/crates/waybetter-host/src/commands.rs @@ -59,6 +59,11 @@ pub const STORE: &[&str] = &[ "install_forget", "store_catalog", "store_stage", + "strings_list", + "strings_read", + "strings_set", + "strings_remove", + "strings_apply", ]; /// Whether `command` is one of the host's (dispatched to the host, never to the app's handler). diff --git a/crates/waybetter-host/src/safe.rs b/crates/waybetter-host/src/safe.rs index 6e09637..942f3f1 100644 --- a/crates/waybetter-host/src/safe.rs +++ b/crates/waybetter-host/src/safe.rs @@ -120,7 +120,9 @@ mod tests { let app = include_str!("app.rs"); let host = include_str!("host.rs"); // Document-start scripts: the safe set instead of the app's. - assert!(app.contains("crate::safe::layer(crate::safe::active(), || hooks.init_scripts(app, tab), || hooks.safe_init_scripts(app, tab))")); + // (the normal layers are the string packs' delivery and then the app's own scripts). + assert!(app.contains("let layers = || crate::store_commands::strings_init_script(app).into_iter().chain(hooks.init_scripts(app, tab)).collect::>();")); + assert!(app.contains("crate::safe::layer(crate::safe::active(), layers, || hooks.safe_init_scripts(app, tab))")); // The default safe set is the tab bridge alone. let default = &app[app.find("fn safe_init_scripts").expect("the hook")..]; let default = &default[..default.find("\n }").unwrap()]; diff --git a/crates/waybetter-host/src/store/catalog.rs b/crates/waybetter-host/src/store/catalog.rs index 70ae2d3..2b9afd6 100644 --- a/crates/waybetter-host/src/store/catalog.rs +++ b/crates/waybetter-host/src/store/catalog.rs @@ -59,6 +59,8 @@ pub struct StoreConfig { pub enum Kind { Theme, Plugin, + /// A string pack: data that replaces the site's own UI strings (`store::strings`). + Strings, } impl Kind { @@ -66,7 +68,8 @@ impl Kind { match value { "theme" => Ok(Kind::Theme), "plugin" => Ok(Kind::Plugin), - _ => Err("VALIDATION: kind must be \"theme\" or \"plugin\"".into()), + "strings" => Ok(Kind::Strings), + _ => Err("VALIDATION: kind must be \"theme\", \"plugin\" or \"strings\"".into()), } } @@ -74,6 +77,7 @@ impl Kind { match self { Kind::Theme => "theme", Kind::Plugin => "plugin", + Kind::Strings => "strings", } } @@ -81,6 +85,7 @@ impl Kind { match self { Kind::Theme => MAX_THEME_BYTES, Kind::Plugin => MAX_PLUGIN_BYTES as usize, + Kind::Strings => super::strings::MAX_STRINGS_BYTES, } } } @@ -288,6 +293,15 @@ pub struct CatalogItem { /// Themes: a few colors for swatches. #[serde(default, skip_serializing_if = "Option::is_none")] pub colors: Option, + /// String packs: the language tag of the pack. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub language: Option, + /// String packs: the licence of the text (an SPDX id such as `CC-BY-4.0`, or a short name). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub license: Option, + /// String packs: who translated and reviewed it, plain text. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub credits: Option, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -360,6 +374,13 @@ impl CatalogItem { if self.size == 0 || self.size > self.kind.max_bytes() as u64 { return bad("size"); } + // Any item may name its licence and credit its authors; only a string pack has a language. + if !self.license.as_deref().is_none_or(|v| !v.trim().is_empty() && valid_text(v, 60)) || !self.credits.as_deref().is_none_or(|v| valid_text(v, 600)) { + return bad("license or credits"); + } + if self.kind != Kind::Strings && self.language.is_some() { + return bad("language"); + } match self.kind { Kind::Plugin => { if !self.file.as_deref().is_some_and(valid_file_name) || self.colors.is_some() { @@ -372,6 +393,14 @@ impl CatalogItem { } } } + Kind::Strings => { + if self.file.is_some() || self.permissions.is_some() || self.colors.is_some() { + return bad("string pack fields"); + } + if !self.language.as_deref().is_some_and(super::strings::valid_language) { + return bad("language"); + } + } Kind::Theme => { if self.file.is_some() || self.permissions.is_some() { return bad("theme fields"); diff --git a/crates/waybetter-host/src/store/install.rs b/crates/waybetter-host/src/store/install.rs index 459b793..d580144 100644 --- a/crates/waybetter-host/src/store/install.rs +++ b/crates/waybetter-host/src/store/install.rs @@ -6,6 +6,7 @@ use super::catalog::{ self as dist, Catalog, CatalogItem, InstallLink, InstallRecord, InstallRecords, Kind, SourceKind, StoreConfig, ThemeInfo, }; +use super::strings::{self as packs, StringsInfo, StringsList, StringsStore}; use crate::plugins::{self, PluginHost, PluginStore, ScriptPlugin, PLUGIN_SUFFIX}; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, HashMap, VecDeque}; @@ -72,6 +73,8 @@ pub struct InstallPreview { pub version: String, pub plugin: Option, pub theme: Option, + /// String packs: what the pack says about itself (the shell adds coverage from the inventory). + pub strings: Option, pub findings: Vec<&'static str>, pub existing: Option, pub store: Option, @@ -85,6 +88,8 @@ pub struct InstallOutcome { pub plugins: Option>, /// Themes: the exact JSON the owner confirmed; the shell adds it (sanitized) to its themes. pub theme_text: Option, + /// String packs: the installed packs after installing. + pub strings: Option, } #[derive(Clone, Debug, Serialize)] @@ -191,6 +196,11 @@ impl Store { PluginStore::new(self.data_dir.join("plugins"), self.plugins) } + /// The installed string packs (`/strings`). + pub fn strings(&self) -> StringsStore { + StringsStore::new(&self.data_dir) + } + fn plugins_dir(&self) -> PathBuf { self.data_dir.join("plugins") } @@ -234,7 +244,7 @@ impl Store { let link = dist::parse_install_link(self.config, &dist::install_link(self.config, kind, url))?; let file = match kind { Kind::Plugin => Some(dist::plugin_file_from_url(&Url::parse(&link.url).map_err(|_| "VALIDATION: invalid URL")?)?), - Kind::Theme => None, + Kind::Theme | Kind::Strings => None, }; Ok((link, file)) } @@ -254,6 +264,7 @@ impl Store { }; } let text = String::from_utf8(bytes.clone()).map_err(|_| "VALIDATION: not UTF-8 text")?; + let mut strings = None; let (plugin, theme, findings, version, existing) = match kind { Kind::Plugin => { let file = file.as_deref().ok_or("VALIDATION: missing plugin file name")?; @@ -267,6 +278,22 @@ impl Store { let version = validated.meta.version.clone(); (Some(validated.meta), None, validated.findings, version, existing) } + Kind::Strings => { + let info = packs::validate_strings(&bytes)?; + if info.site != self.config.app_id { + return Err(format!("VALIDATION: this string pack is for another app ({})", info.site)); + } + let id = packs::pack_id(&info, store.as_ref().map(|item| item.id.as_str())); + let local = self.strings(); + let existing = local.read(&id).map(|old| Existing { + version: packs::validate_strings(&old).map(|old| old.version).unwrap_or_default(), + enabled: local.state().enabled.contains(&id), + same: dist::sha256_hex(&old) == dist::sha256_hex(&bytes), + }); + let version = if info.version.is_empty() { store.as_ref().map(|item| item.version.clone()).unwrap_or_default() } else { info.version.clone() }; + strings = Some(info); + (None, None, Vec::new(), version, existing) + } Kind::Theme => { let info = dist::validate_theme(&bytes)?; let version = store.as_ref().map(|item| item.version.clone()).unwrap_or_default(); @@ -291,6 +318,7 @@ impl Store { version: version.clone(), plugin, theme, + strings, findings, existing, store: store.clone(), @@ -316,10 +344,12 @@ impl Store { let lower = name.to_ascii_lowercase(); let (kind, file) = if lower.ends_with(PLUGIN_SUFFIX) { (Kind::Plugin, Some(name.to_owned())) + } else if lower.ends_with(".strings.json") { + (Kind::Strings, None) } else if lower.ends_with(".json") { (Kind::Theme, None) } else { - return Err("VALIDATION: drop a theme (.json) or a plugin (.plugin.js)".into()); + return Err("VALIDATION: drop a theme (.json), a plugin (.plugin.js) or a string pack (.strings.json)".into()); }; if text.len() > kind.max_bytes() { return Err("VALIDATION: the file is larger than allowed".into()); @@ -393,7 +423,18 @@ impl Store { store.set_enabled(&file, true)?; run(&script); } - Ok(InstallOutcome { kind: Kind::Plugin, plugins: Some(store.list()?), theme_text: None }) + Ok(InstallOutcome { kind: Kind::Plugin, plugins: Some(store.list()?), theme_text: None, strings: None }) + } + Kind::Strings => { + let info = packs::validate_strings(&staged.bytes)?; + let id = packs::pack_id(&info, staged.store.as_ref().map(|item| item.id.as_str())); + let local = self.strings(); + local.install(&id, &staged.bytes, enable)?; + inner.records.insert(InstallRecords::key(Kind::Strings, &id), record(staged.version.clone())); + let records = inner.records.clone(); + drop(inner); + self.save_records(&records); + Ok(InstallOutcome { kind: Kind::Strings, plugins: None, theme_text: None, strings: Some(local.list()) }) } Kind::Theme => { let id = theme_id.filter(|id| valid_theme_id(id)).ok_or("VALIDATION: invalid theme id")?; @@ -402,7 +443,7 @@ impl Store { drop(inner); self.save_records(&records); let text = String::from_utf8(staged.bytes).map_err(|_| "VALIDATION: not UTF-8")?; - Ok(InstallOutcome { kind: Kind::Theme, plugins: None, theme_text: Some(text) }) + Ok(InstallOutcome { kind: Kind::Theme, plugins: None, theme_text: Some(text), strings: None }) } } } @@ -421,6 +462,19 @@ impl Store { store.list() } + /// Removes an installed string pack and forgets where it came from. + pub fn remove_strings(&self, id: &str) -> Result { + let local = self.strings(); + local.remove(id)?; + let mut inner = self.lock(); + if inner.records.items.remove(&InstallRecords::key(Kind::Strings, id)).is_some() { + let records = inner.records.clone(); + drop(inner); + self.save_records(&records); + } + Ok(local.list()) + } + /// Where installed plugins and themes came from (for badges and update checks). pub fn records(&self) -> InstallRecords { self.lock().records.clone() diff --git a/crates/waybetter-host/src/store/mod.rs b/crates/waybetter-host/src/store/mod.rs index 4147fdd..29e18f7 100644 --- a/crates/waybetter-host/src/store/mod.rs +++ b/crates/waybetter-host/src/store/mod.rs @@ -27,8 +27,10 @@ pub mod catalog; pub mod install; +pub mod strings; #[cfg(feature = "net")] pub mod net; pub use catalog::*; pub use install::*; +pub use strings::*; diff --git a/crates/waybetter-host/src/store/strings.rs b/crates/waybetter-host/src/store/strings.rs new file mode 100644 index 0000000..77f656e --- /dev/null +++ b/crates/waybetter-host/src/store/strings.rs @@ -0,0 +1,417 @@ +//! String packs (waybetter/docs/STRING-PACKS.md) on disk: data-only packs that replace the wrapped +//! site's own UI strings. The shell checks a pack against the site's inventory (coverage, unknown +//! keys); this side only accepts well-formed data and keeps it: `strings/.json`, and +//! `strings/state.json` (which packs are on, in which order). A pack carries text, never code. + +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +pub const MAX_STRINGS_BYTES: usize = 2 * 1024 * 1024; +pub const MAX_PACK_STRINGS: usize = 40_000; +pub const MAX_PACK_VALUE_CHARS: usize = 2000; +/// At most this many packs are installed (and at most 8 are on at once). +pub const MAX_PACKS: usize = 64; +pub const MAX_ENABLED: usize = 8; +const STATE_FILE: &str = "state.json"; +const DELIVERY_FILE: &str = "delivery.json"; +/// A delivery (packs + the inventory slice their keys need) the shell computed; handed to site tabs. +pub const MAX_DELIVERY_BYTES: usize = 8 * 1024 * 1024; + +/// What the install screen and the settings list show about a pack. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StringsInfo { + pub site: String, + pub catalog: String, + pub language: String, + pub name: String, + pub authors: Vec, + pub version: String, + pub description: String, + /// Number of strings in the pack. + pub strings: usize, +} + +fn plain(value: &str, max: usize) -> bool { + value.chars().count() <= max && !value.chars().any(|ch| ch.is_control() || ch == '<' || ch == '>' || ('\u{202a}'..='\u{202e}').contains(&ch) || ('\u{2066}'..='\u{2069}').contains(&ch)) +} + +fn ident(value: &str) -> bool { + !value.is_empty() && value.len() <= 32 && value.bytes().next().is_some_and(|b| b.is_ascii_lowercase()) && value.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') +} + +/// A BCP 47-shaped language tag (`eo`, `pt-BR`, `en-x-pirate`). +pub fn valid_language(tag: &str) -> bool { + let mut parts = tag.split('-'); + let first = parts.next().unwrap_or_default(); + (2..=8).contains(&first.len()) && first.bytes().all(|b| b.is_ascii_alphabetic()) && parts.all(|part| (1..=8).contains(&part.len()) && part.bytes().all(|b| b.is_ascii_alphanumeric())) +} + +/// A string pack file: at most [`MAX_STRINGS_BYTES`], UTF-8 JSON with the pack envelope, key ids +/// mapped to plain text (no markup, no control or bidi-override characters). Whether the ids and +/// parameters fit the site is the shell's check against the inventory. +pub fn validate_strings(bytes: &[u8]) -> Result { + if bytes.is_empty() { + return Err("VALIDATION: the string pack is empty".into()); + } + if bytes.len() > MAX_STRINGS_BYTES { + return Err("VALIDATION: string packs are limited to 2 MiB".into()); + } + let text = std::str::from_utf8(bytes).map_err(|_| "VALIDATION: a string pack must be UTF-8 JSON")?; + let value: serde_json::Value = serde_json::from_str(text.trim_start_matches('\u{feff}')).map_err(|_| "VALIDATION: this file is not valid JSON")?; + let object = value.as_object().ok_or("VALIDATION: a string pack is a JSON object")?; + let text_field = |key: &str| object.get(key).and_then(serde_json::Value::as_str); + if object.get("format").and_then(serde_json::Value::as_u64) != Some(1) { + return Err("VALIDATION: a string pack needs \"format\": 1".into()); + } + let site = text_field("site").filter(|v| ident(v)).ok_or("VALIDATION: invalid \"site\"")?; + let catalog = text_field("catalog").filter(|v| ident(v)).ok_or("VALIDATION: invalid \"catalog\"")?; + let language = text_field("language").filter(|v| valid_language(v)).ok_or("VALIDATION: invalid \"language\"")?; + if let Some(base) = object.get("base") { + if !base.as_str().is_some_and(valid_language) { + return Err("VALIDATION: invalid \"base\"".into()); + } + } + let name = text_field("name").filter(|v| !v.trim().is_empty() && plain(v, 80)).ok_or("VALIDATION: a string pack needs a plain-text \"name\"")?; + let authors: Vec = match object.get("authors").and_then(serde_json::Value::as_array) { + Some(list) if list.len() <= 16 => list + .iter() + .map(|author| author.as_str().filter(|a| !a.trim().is_empty() && plain(a, 80)).map(str::to_owned)) + .collect::>>() + .ok_or("VALIDATION: invalid \"authors\"")?, + _ => return Err("VALIDATION: \"authors\" must be a list of at most 16 names".into()), + }; + let version = match object.get("version") { + None => String::new(), + Some(v) => v.as_str().filter(|v| plain(v, 32)).ok_or("VALIDATION: invalid \"version\"")?.to_owned(), + }; + let description = match object.get("description") { + None => String::new(), + Some(v) => v.as_str().filter(|v| plain(v, 400)).ok_or("VALIDATION: invalid \"description\"")?.to_owned(), + }; + let strings = object.get("strings").and_then(serde_json::Value::as_object).ok_or("VALIDATION: \"strings\" must be an object of key id -> text")?; + if strings.len() > MAX_PACK_STRINGS { + return Err("VALIDATION: too many strings".into()); + } + for (id, value) in strings { + let ok_id = !id.is_empty() && id.len() <= 64 && id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.' || b == b'$'); + let ok_value = value.as_str().is_some_and(|v| !v.is_empty() && plain(v, MAX_PACK_VALUE_CHARS)); + if !ok_id || !ok_value { + return Err(format!("VALIDATION: string {} is not plain text", id.chars().take(64).collect::())); + } + } + const KNOWN: &[&str] = &["$schema", "format", "site", "catalog", "language", "base", "name", "authors", "version", "description", "strings"]; + if let Some(key) = object.keys().find(|key| !KNOWN.contains(&key.as_str())) { + return Err(format!("VALIDATION: unknown field \"{}\"", key.chars().take(40).collect::())); + } + Ok(StringsInfo { + site: site.to_owned(), + catalog: catalog.to_owned(), + language: language.to_owned(), + name: name.to_owned(), + authors, + version, + description, + strings: strings.len(), + }) +} + +/// The id a pack is installed under: the store item's id, else derived from its language and name. +pub fn pack_id(info: &StringsInfo, store_id: Option<&str>) -> String { + if let Some(id) = store_id { + return id.to_owned(); + } + let mut id = String::new(); + for ch in format!("{}-{}", info.language, info.name).chars() { + let ch = ch.to_ascii_lowercase(); + if ch.is_ascii_alphanumeric() { + id.push(ch); + } else if !id.ends_with('-') { + id.push('-'); + } + } + let id: String = id.trim_matches('-').chars().take(40).collect(); + let id = id.trim_end_matches('-').to_owned(); + if id.is_empty() { "strings".to_owned() } else { id } +} + +fn valid_pack_id(id: &str) -> bool { + !id.is_empty() && id.len() <= 40 && id.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') +} + +/// Which packs are on, in application order (a later one wins per key). +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StringsState { + pub order: Vec, + pub enabled: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct InstalledStrings { + pub id: String, + #[serde(flatten)] + pub info: StringsInfo, + pub sha256: String, + pub enabled: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StringsList { + /// In application order. + pub packs: Vec, +} + +/// The installed packs of one app: `/strings/`. +pub struct StringsStore { + dir: PathBuf, +} + +impl StringsStore { + pub fn new(data_dir: &Path) -> Self { + Self { dir: data_dir.join("strings") } + } + + fn file(&self, id: &str) -> PathBuf { + self.dir.join(format!("{id}.json")) + } + + pub fn state(&self) -> StringsState { + std::fs::read_to_string(self.dir.join(STATE_FILE)).ok().and_then(|text| serde_json::from_str(&text).ok()).unwrap_or_default() + } + + fn save_state(&self, state: &StringsState) -> Result<(), String> { + std::fs::create_dir_all(&self.dir).map_err(|e| format!("STRINGS: {e}"))?; + let text = serde_json::to_string_pretty(state).map_err(|e| e.to_string())?; + write_atomic(&self.dir.join(STATE_FILE), text.as_bytes()) + } + + /// The installed pack's bytes, if any. + pub fn read(&self, id: &str) -> Option> { + if !valid_pack_id(id) { + return None; + } + std::fs::read(self.file(id)).ok() + } + + /// Writes a pack (validated again) under `id`; a new pack goes last in the order, on when `enable`. + pub fn install(&self, id: &str, bytes: &[u8], enable: bool) -> Result { + if !valid_pack_id(id) { + return Err("VALIDATION: invalid string pack id".into()); + } + let info = validate_strings(bytes)?; + let mut state = self.state(); + let known: HashSet<&String> = state.order.iter().collect(); + if !known.contains(&id.to_owned()) && state.order.len() >= MAX_PACKS { + return Err("INSTALL: too many string packs installed".into()); + } + std::fs::create_dir_all(&self.dir).map_err(|e| format!("STRINGS: {e}"))?; + write_atomic(&self.file(id), bytes)?; + if !state.order.iter().any(|item| item == id) { + state.order.push(id.to_owned()); + } + if enable && !state.enabled.iter().any(|item| item == id) && state.enabled.len() < MAX_ENABLED { + state.enabled.push(id.to_owned()); + } + self.save_state(&state)?; + Ok(info) + } + + pub fn remove(&self, id: &str) -> Result<(), String> { + if !valid_pack_id(id) { + return Err("VALIDATION: invalid string pack id".into()); + } + let _ = std::fs::remove_file(self.file(id)); + let mut state = self.state(); + state.order.retain(|item| item != id); + state.enabled.retain(|item| item != id); + self.save_state(&state) + } + + /// Sets the order and which packs are on. Unknown ids are dropped; installed packs missing + /// from `order` keep their place at the end. + pub fn set(&self, order: &[String], enabled: &[String]) -> Result { + let installed = self.installed_ids(); + let mut next = StringsState::default(); + for id in order.iter().chain(installed.iter()) { + if installed.contains(id) && !next.order.contains(id) { + next.order.push(id.clone()); + } + } + for id in &next.order { + if enabled.contains(id) && next.enabled.len() < MAX_ENABLED { + next.enabled.push(id.clone()); + } + } + self.save_state(&next)?; + Ok(self.list()) + } + + fn installed_ids(&self) -> Vec { + let mut ids: Vec = std::fs::read_dir(&self.dir) + .map(|entries| { + entries + .filter_map(Result::ok) + .filter_map(|entry| entry.file_name().to_str().and_then(|name| name.strip_suffix(".json")).map(str::to_owned)) + .filter(|id| valid_pack_id(id) && id != "state" && id != "delivery") + .collect() + }) + .unwrap_or_default(); + ids.sort(); + ids + } + + /// Installed packs in application order (packs the state does not name yet come last). + pub fn list(&self) -> StringsList { + let state = self.state(); + let installed = self.installed_ids(); + let mut order: Vec = state.order.iter().filter(|id| installed.contains(id)).cloned().collect(); + for id in installed { + if !order.contains(&id) { + order.push(id); + } + } + let packs = order + .into_iter() + .filter_map(|id| { + let bytes = self.read(&id)?; + let info = validate_strings(&bytes).ok()?; + Some(InstalledStrings { enabled: state.enabled.contains(&id), sha256: super::catalog::sha256_hex(&bytes), id, info }) + }) + .collect(); + StringsList { packs } + } + + /// The enabled packs' JSON texts, in application order. + pub fn enabled_texts(&self) -> Vec { + self.list().packs.into_iter().filter(|pack| pack.enabled).filter_map(|pack| self.read(&pack.id)).filter_map(|bytes| String::from_utf8(bytes).ok()).collect() + } + + /// Keeps the delivery the shell computed (JSON: `{ inventory, packs }`; `None`: no pack on) + /// under a new revision, and returns the stored text (`{ rev, inventory?, packs? }`). A site + /// tab keeps the newest revision it was given, so a tab created before a change never goes + /// back to the older delivery it was created with. + pub fn set_delivery(&self, delivery: Option<&str>, rev: u64) -> Result { + let mut stored = serde_json::Map::new(); + if let Some(text) = delivery { + if text.len() > MAX_DELIVERY_BYTES { + return Err("VALIDATION: the delivery is too large".into()); + } + let value: serde_json::Value = serde_json::from_str(text).map_err(|_| "VALIDATION: the delivery is not JSON")?; + let inventory = value.get("inventory").filter(|v| v.is_object()).cloned(); + let packs = value.get("packs").filter(|v| v.as_array().is_some_and(|packs| !packs.is_empty() && packs.len() <= MAX_ENABLED)).cloned(); + let (Some(inventory), Some(packs)) = (inventory, packs) else { + return Err("VALIDATION: a delivery is { inventory, packs } with 1 to 8 packs".into()); + }; + stored.insert("inventory".into(), inventory); + stored.insert("packs".into(), packs); + } + stored.insert("rev".into(), rev.into()); + let text = serde_json::Value::Object(stored).to_string(); + std::fs::create_dir_all(&self.dir).map_err(|e| format!("STRINGS: {e}"))?; + write_atomic(&self.dir.join(DELIVERY_FILE), text.as_bytes())?; + Ok(text) + } + + /// The stored delivery (`{ rev, inventory?, packs? }`), if the shell ever set one. + pub fn delivery(&self) -> Option { + std::fs::read_to_string(self.dir.join(DELIVERY_FILE)).ok() + } +} + +/// The session-storage key a page runtime reads its string packs from at document start. +pub const DELIVERY_KEY: &str = "wayBetter.stringPacks"; + +/// A script for a site tab: on one of `hosts` (https) only, puts `stored` (a stored delivery with +/// its `rev`) in the tab's session storage unless the tab already holds that revision or a newer +/// one; with `reload`, reloads the page after a change so the runtime applies it from the start. +/// Data only: the delivery is a JSON string literal, never code. +pub fn delivery_script(hosts: &[&str], stored: &str, reload: bool) -> String { + let hosts = serde_json::to_string(hosts).unwrap_or_else(|_| "[]".into()); + let key = serde_json::to_string(DELIVERY_KEY).unwrap_or_default(); + let rev = serde_json::from_str::(stored).ok().and_then(|v| v.get("rev").and_then(serde_json::Value::as_u64)).unwrap_or(0); + let text = serde_json::to_string(stored).unwrap_or_else(|_| "\"{}\"".into()); + format!( + "(() => {{ try {{ if (location.protocol !== 'https:' || !{hosts}.includes(location.host)) return; \ + let held = 0; try {{ held = Number(JSON.parse(sessionStorage.getItem({key}) || '{{}}').rev) || 0; }} catch (e) {{}} \ + if (held >= {rev}) return; sessionStorage.setItem({key}, {text});{} }} catch (e) {{}} }})();", + if reload { " location.reload();" } else { "" } + ) +} + +fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> { + let tmp = path.with_extension("tmp"); + std::fs::write(&tmp, bytes).map_err(|e| format!("STRINGS: {e}"))?; + std::fs::rename(&tmp, path).map_err(|e| format!("STRINGS: {e}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn pack(strings: &str) -> Vec { + format!(r#"{{"format":1,"site":"x","catalog":"web","language":"eo","name":"Esperanto","authors":["A"],"version":"1.0.0","strings":{strings}}}"#).into_bytes() + } + + #[test] + fn packs_are_plain_data_with_the_envelope() { + let info = validate_strings(&pack(r#"{"a0000001":"Hejmo","b0000002":"{count, plural, one {# afiŝo} other {# afiŝoj}}"}"#)).unwrap(); + assert_eq!((info.language.as_str(), info.strings, info.name.as_str()), ("eo", 2, "Esperanto")); + for bad in [ + pack(r#"{"a":"x"}"#), + pack(r#"{"a":"x > y"}"#), + pack(&format!(r#"{{"a":"{}x"}}"#, char::from_u32(0x202e).unwrap())), + pack(r#"{"a":""}"#), + pack(r#"{"a":1}"#), + pack(r#"{"a b":"x"}"#), + pack(r#"[]"#), + br#"{"format":2,"site":"x","catalog":"web","language":"eo","name":"E","authors":[],"strings":{}}"#.to_vec(), + br#"{"format":1,"site":"X","catalog":"web","language":"eo","name":"E","authors":[],"strings":{}}"#.to_vec(), + br#"{"format":1,"site":"x","catalog":"web","language":"not a tag","name":"E","authors":[],"strings":{}}"#.to_vec(), + br#"{"format":1,"site":"x","catalog":"web","language":"eo","name":"E","authors":[],"strings":{},"code":"x"}"#.to_vec(), + b"not json".to_vec(), + Vec::new(), + ] { + assert!(validate_strings(&bad).is_err(), "{}", String::from_utf8_lossy(&bad)); + } + assert!(valid_language("en-x-pirate") && valid_language("pt-BR") && !valid_language("e") && !valid_language("en_US")); + } + + #[test] + fn install_order_enable_remove_and_delivery() { + let dir = std::env::temp_dir().join(format!("wb-strings-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let store = StringsStore::new(&dir); + let info = validate_strings(&pack(r#"{"a":"x"}"#)).unwrap(); + assert_eq!(pack_id(&info, None), "eo-esperanto"); + assert_eq!(pack_id(&info, Some("esperanto")), "esperanto"); + store.install("eo-esperanto", &pack(r#"{"a":"x"}"#), true).unwrap(); + store.install("second", &pack(r#"{"a":"y"}"#), false).unwrap(); + let list = store.list(); + assert_eq!(list.packs.iter().map(|p| (p.id.as_str(), p.enabled)).collect::>(), [("eo-esperanto", true), ("second", false)]); + let list = store.set(&["second".into(), "nope".into()], &["second".into(), "eo-esperanto".into()]).unwrap(); + assert_eq!(list.packs.iter().map(|p| (p.id.as_str(), p.enabled)).collect::>(), [("second", true), ("eo-esperanto", true)]); + assert_eq!(store.enabled_texts().len(), 2); + assert!(store.install("../evil", &pack("{}"), true).is_err()); + assert!(store.install("bad", b"{}", true).is_err()); + let stored = store.set_delivery(Some(r#"{"inventory":{"site":"x"},"packs":[{"a":1}],"extra":"dropped"}"#), 7).unwrap(); + assert_eq!(stored, r#"{"inventory":{"site":"x"},"packs":[{"a":1}],"rev":7}"#); + assert_eq!(store.delivery().as_deref(), Some(stored.as_str())); + assert!(store.set_delivery(Some("[]"), 8).is_err()); + assert!(store.set_delivery(Some(r#"{"inventory":{},"packs":[]}"#), 8).is_err(), "no packs is None, not an empty list"); + assert_eq!(store.list().packs.len(), 2, "the delivery file is not a pack"); + assert_eq!(store.set_delivery(None, 9).unwrap(), r#"{"rev":9}"#); + store.remove("second").unwrap(); + assert_eq!(store.list().packs.len(), 1); + assert_eq!(store.state().order, ["eo-esperanto"]); + let script = delivery_script(&["x.com"], r#"{"rev":12,"packs":[""]}"#, true); + assert!(script.contains(r#"!["x.com"].includes(location.host)"#), "{script}"); + assert!(script.contains("if (held >= 12) return;") && script.contains("sessionStorage.setItem(\"wayBetter.stringPacks\", \"{\\\"rev\\\":12")); + assert!(script.contains("location.reload();")); + assert!(!delivery_script(&["x.com"], r#"{"rev":1}"#, false).contains("reload")); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/waybetter-host/src/store_commands.rs b/crates/waybetter-host/src/store_commands.rs index 8128751..e48f320 100644 --- a/crates/waybetter-host/src/store_commands.rs +++ b/crates/waybetter-host/src/store_commands.rs @@ -12,7 +12,7 @@ use crate::app::AppConfig; use crate::host::{emit_shell, eval_in_site_tabs, shell_only}; use crate::plugins::ScriptPlugin; use crate::policy; -use crate::store::{InstallOutcome, InstallPreview, InstallRecords, LinkEvent, Store, StoreItemRef, StoreView, EVENT_LINK}; +use crate::store::{delivery_script, InstallOutcome, InstallPreview, InstallRecords, LinkEvent, Store, StoreItemRef, StoreView, StringsList, EVENT_LINK}; use tauri::{AppHandle, Manager, State, Webview}; use tauri_plugin_deep_link::DeepLinkExt; @@ -141,6 +141,55 @@ pub(crate) async fn store_stage(webview: Webview, app: AppHandle, item: StoreIte store(&app)?.stage_item(&item).await } +/// Installed string packs, in application order, with which are on. +#[tauri::command] +pub(crate) async fn strings_list(webview: Webview, app: AppHandle) -> Result { + shell_only(&webview)?; + Ok(store(&app)?.strings().list()) +} + +/// One installed string pack's JSON (the shell checks it against the site's inventory). +#[tauri::command] +pub(crate) async fn strings_read(webview: Webview, app: AppHandle, id: String) -> Result { + shell_only(&webview)?; + let bytes = store(&app)?.strings().read(&id).ok_or("STRINGS: no such string pack")?; + String::from_utf8(bytes).map_err(|_| "STRINGS: not UTF-8".to_owned()) +} + +/// Sets the order of the installed string packs and which are on (at most 8). +#[tauri::command] +pub(crate) async fn strings_set(webview: Webview, app: AppHandle, order: Vec, enabled: Vec) -> Result { + shell_only(&webview)?; + store(&app)?.strings().set(&order, &enabled) +} + +/// Removes an installed string pack. +#[tauri::command] +pub(crate) async fn strings_remove(webview: Webview, app: AppHandle, id: String) -> Result { + shell_only(&webview)?; + store(&app)?.remove_strings(&id) +} + +/// The shell computed what site tabs get (the packs that are on, in order, with the slice of the +/// site's inventory their keys need; `None`: no pack on). It is kept for new tabs, and every site +/// tab gets it in its session storage and reloads, so X registers its strings through it from the +/// start. Data only; a site tab gains no command. +#[tauri::command] +pub(crate) async fn strings_apply(webview: Webview, app: AppHandle, delivery: Option) -> Result<(), String> { + shell_only(&webview)?; + let rev = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0); + let stored = store(&app)?.strings().set_delivery(delivery.as_deref(), rev)?; + eval_in_site(&app, &delivery_script(crate::app::config(&app).site.hosts, &stored, true)); + Ok(()) +} + +/// The document-start script that gives a new site tab the current string packs (none when the +/// shell never applied any). Part of the normal page layers, so safe mode leaves it out. +pub(crate) fn strings_init_script(app: &AppHandle) -> Option { + let stored = app.try_state::()?.strings().delivery()?; + Some(delivery_script(crate::app::config(app).site.hosts, &stored, false)) +} + /// The store's commands ([`crate::commands::STORE`]). pub(crate) fn handler() -> impl Fn(tauri::ipc::Invoke) -> bool + Send + Sync + 'static { tauri::generate_handler![ @@ -153,6 +202,11 @@ pub(crate) fn handler() -> impl Fn(tauri::ipc::Invoke) -> bool + Sen install_records, install_forget, store_catalog, - store_stage + store_stage, + strings_list, + strings_read, + strings_set, + strings_remove, + strings_apply ] } diff --git a/packages/waybetter/cli/store.mjs b/packages/waybetter/cli/store.mjs index 31cc680..717ef62 100644 --- a/packages/waybetter/cli/store.mjs +++ b/packages/waybetter/cli/store.mjs @@ -75,7 +75,8 @@ export function allowedUrl(value, hosts = DEFAULT_HOSTS) { return true; } -const ITEM_FIELDS = new Set(['apps', 'kind', 'id', 'name', 'author', 'version', 'description', 'category', 'tags', 'url', 'file', 'sha256', 'size', 'preview', 'permissions', 'colors']); +const ITEM_FIELDS = new Set(['apps', 'kind', 'id', 'name', 'author', 'version', 'description', 'category', 'tags', 'url', 'file', 'sha256', 'size', 'preview', 'permissions', 'colors', 'language', 'license', 'credits']); +const LANGUAGE = /^[A-Za-z]{2,8}(-[A-Za-z0-9]{1,8})*$/; /** Problems with one item; `hosts` checks the download hosts (the reading app's own items). */ export function itemProblems(item, hosts) { @@ -83,7 +84,10 @@ export function itemProblems(item, hosts) { if (!item || typeof item !== 'object' || Array.isArray(item)) return ['not an object']; for (const key of Object.keys(item)) if (!ITEM_FIELDS.has(key)) bad.push(`unknown field ${key}`); if (!Array.isArray(item.apps) || !item.apps.length || item.apps.length > 16 || !item.apps.every(validAppId) || new Set(item.apps).size !== item.apps.length) bad.push('apps'); - if (item.kind !== 'theme' && item.kind !== 'plugin') bad.push('kind'); + if (item.kind !== 'theme' && item.kind !== 'plugin' && item.kind !== 'strings') bad.push('kind'); + // Any item may name its licence and credit its authors; only a string pack has a language. + if ((item.license !== undefined && (!text(item.license, 60) || !String(item.license).trim())) || (item.credits !== undefined && !text(item.credits, 600))) bad.push('license or credits'); + if (item.kind !== 'strings' && item.language !== undefined) bad.push('language'); if (!validId(item.id)) bad.push('id'); if (!text(item.name, 60) || !String(item.name).trim() || !text(item.author ?? '', 60) || !text(item.description ?? '', 600)) bad.push('name, author or description'); if (!text(item.version, 32) || !item.version || !text(item.category ?? '', 32)) bad.push('version or category'); @@ -97,6 +101,10 @@ export function itemProblems(item, hosts) { const p = item.permissions; if (p != null && (!Array.isArray(p) || !p.every(x => PERMISSIONS.includes(x)) || new Set(p).size !== p.length || (p.includes('network') && p.includes('network:none')))) bad.push('permissions'); } + if (item.kind === 'strings') { + if (item.file !== undefined || item.permissions !== undefined || item.colors !== undefined) bad.push('string pack fields'); + if (typeof item.language !== 'string' || !LANGUAGE.test(item.language)) bad.push('language'); + } if (item.kind === 'theme') { if (item.file !== undefined || item.permissions !== undefined) bad.push('theme fields'); const c = item.colors; @@ -141,7 +149,8 @@ export function catalogProblems(catalog, { app, hosts = DEFAULT_HOSTS } = {}) { * items used, validates the whole catalog and writes `catalog.json` (sign it afterwards). * * config: { app, name?, base?, out?, hosts?, items(context) → [{ kind, id, name, author, version, - * description, category, tags, file (plugins), permissions (plugins), colors (themes), + * description, category, tags, file (plugins), permissions (plugins), colors (themes), language + * (string packs), license, credits, * path ('plugins/x.plugin.js'), bytes (Buffer|string), preview?: { path, bytes } }] } */ export async function buildStore(config, options = {}) { @@ -184,6 +193,9 @@ export async function buildStore(config, options = {}) { } if (entry.kind === 'plugin') item.permissions = entry.permissions ?? null; if (entry.kind === 'theme' && entry.colors) item.colors = entry.colors; + if (entry.kind === 'strings') item.language = entry.language; + if (entry.license !== undefined) item.license = entry.license; + if (entry.credits !== undefined) item.credits = entry.credits; items.push(item); } const catalog = { diff --git a/packages/waybetter/store-catalog.schema.json b/packages/waybetter/store-catalog.schema.json index 7346a51..b683413 100644 --- a/packages/waybetter/store-catalog.schema.json +++ b/packages/waybetter/store-catalog.schema.json @@ -26,7 +26,7 @@ "description": "The wayBetter apps this item is for (app ids such as \"x\"). An app never shows, stages or installs another app's items.", "items": { "type": "string", "pattern": "^[a-z0-9-]{1,32}$" } }, - "kind": { "enum": ["theme", "plugin"] }, + "kind": { "enum": ["theme", "plugin", "strings"], "description": "strings: a string pack (data that replaces the site's own UI strings; waybetter docs/STRING-PACKS.md)." }, "id": { "type": "string", "pattern": "^[a-z0-9-]{1,40}$", "description": "Unique per app and kind." }, "name": { "allOf": [{ "$ref": "#/$defs/text" }], "type": "string", "minLength": 1, "maxLength": 60 }, "author": { "allOf": [{ "$ref": "#/$defs/text" }], "type": "string", "maxLength": 60 }, @@ -46,6 +46,9 @@ { "type": "array", "uniqueItems": true, "items": { "enum": ["storage", "ui", "clipboard", "account", "users", "commands", "network", "network:none"] } } ] }, + "language": { "type": "string", "pattern": "^[A-Za-z]{2,8}(-[A-Za-z0-9]{1,8})*$", "description": "String packs only (required there): the language tag of the pack." }, + "license": { "allOf": [{ "$ref": "#/$defs/text" }], "type": "string", "minLength": 1, "maxLength": 60, "description": "The licence of the item (an SPDX id such as CC-BY-4.0, or a short name)." }, + "credits": { "allOf": [{ "$ref": "#/$defs/text" }], "type": "string", "maxLength": 600, "description": "Who made, translated or reviewed it, plain text." }, "colors": { "description": "Themes only: swatches for the card.", "anyOf": [