From 419f60b5b1094fedb39871c4792664108e966266 Mon Sep 17 00:00:00 2001 From: CRT Date: Thu, 1 Oct 2026 03:56:54 +0700 Subject: [PATCH] Store kind "strings": string packs install like themes and plugins (dropped .strings.json, links, the signed Store), validated as plain data on this side (envelope, plain text, size caps, the app's own site), kept in app data strings/ with their order and which are on (at most 8); licence and credits are optional fields of every catalog item; five shell-only commands (strings_list, strings_read, strings_set, strings_remove, strings_apply); the shell's delivery (packs + the inventory slice) goes into site tabs' session storage by revision, as the first document-start layer of a site tab and never in safe mode Co-Authored-By: Claude Opus 5.5 --- crates/waybetter-host/src/app.rs | 4 +- crates/waybetter-host/src/commands.rs | 5 + crates/waybetter-host/src/safe.rs | 4 +- crates/waybetter-host/src/store/catalog.rs | 31 +- crates/waybetter-host/src/store/install.rs | 62 ++- crates/waybetter-host/src/store/mod.rs | 2 + crates/waybetter-host/src/store/strings.rs | 417 +++++++++++++++++++ crates/waybetter-host/src/store_commands.rs | 58 ++- packages/waybetter/cli/store.mjs | 18 +- packages/waybetter/store-catalog.schema.json | 5 +- 10 files changed, 593 insertions(+), 13 deletions(-) create mode 100644 crates/waybetter-host/src/store/strings.rs 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": [