//! The install screen's state and file work, shared by every wayBetter app: links waiting for the //! shell, installs staged for the confirmation screen, the verified catalog (and its offline copy), //! and where installed things came from. The app wraps these methods in shell-only Tauri commands //! and passes a function that runs a plugin start/stop script in its site tabs. 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}; use std::path::{Path, PathBuf}; use std::sync::{Mutex, MutexGuard}; use std::time::{Duration, Instant}; use url::Url; /// Install records in the app-data folder. pub const RECORDS_FILE: &str = "store-installed.json"; /// The last verified catalog and its signature (verified again when read back). pub const CATALOG_CACHE: &str = "store-catalog.json"; pub const CATALOG_SIG_CACHE: &str = "store-catalog.json.sig"; /// Shell event: a validated install link (`{ link: { kind, url } }`), a "Share my setup" link /// (`{ setup }`: its text, for the shell's preview) or `{ error }`. pub const EVENT_LINK: &str = "install-link"; /// "Share my setup" links: `waybetter://setup?app=…&d=…`. Native code only checks the shape and /// hands the text to the shell; it never decodes or applies a setup (the shell shows what would /// change and asks first). pub const SETUP_LINK: &str = "waybetter://setup"; const MAX_SETUP_LINK: usize = 24 * 1024; /// Links received before the shell asked for them (startup, or while the shell reloads). const MAX_PENDING: usize = 8; const MAX_STAGED: usize = 8; const STAGED_TTL: Duration = Duration::from_secs(15 * 60); /// The confirmation screen enables Install after a moment; a commit faster than this was not read. pub const MIN_REVIEW: Duration = Duration::from_millis(600); /// How long a fetched catalog is used before the next store open fetches it again. pub const CATALOG_TTL: Duration = Duration::from_secs(10 * 60); #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct LinkEvent { #[serde(skip_serializing_if = "Option::is_none")] pub link: Option, #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, /// A "Share my setup" link, as text (see [`SETUP_LINK`]). #[serde(skip_serializing_if = "Option::is_none")] pub setup: Option, } /// `Some` when `raw` is a setup link: its text when it has the plain shape (the path, a query of /// URL-safe characters, bounded), else an error. `None`: not a setup link. pub fn setup_link(raw: &str) -> Option> { let rest = raw.strip_prefix(SETUP_LINK)?; let query = rest.strip_prefix("/?").or_else(|| rest.strip_prefix('?')); let ok = query.is_some_and(|query| { !query.is_empty() && raw.len() <= MAX_SETUP_LINK && query.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'=' | b'&' | b'%' | b'.')) }); Some(if ok { Ok(raw.to_owned()) } else { Err("LINK: this setup link is not valid".to_owned()) }) } #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct SourceView { pub kind: SourceKind, pub url: Option, /// "wayBetter Store (signed catalog, sha256 verified)", "Link: waybetter.fyi", "A file you dropped" pub label: String, pub signed: bool, } #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct Existing { pub version: String, pub enabled: bool, /// The installed file is byte-identical to this one. pub same: bool, } /// Everything the confirmation screen shows. `text` is the exact content that will be written. #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct InstallPreview { pub token: String, pub kind: Kind, pub source: SourceView, pub sha256: String, pub size: usize, pub file: Option, pub text: String, 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, } #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct InstallOutcome { pub kind: Kind, /// Plugins: the list after installing. 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)] #[serde(rename_all = "camelCase")] pub struct InstalledPlugin { pub version: String, pub sha256: String, pub enabled: bool, } /// What the Store view shows: this app's items from the verified catalog, with what is installed. #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct StoreView { pub catalog: Option, pub fetched_at: Option, /// Why the live catalog could not be loaded (a cached copy may still be shown). pub error: Option, /// Installed plugin files the catalog lists, by file name. pub plugins: BTreeMap, pub records: InstallRecords, } /// A store item the shell asks for (`store_stage`). #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct StoreItemRef { pub kind: String, pub id: String, } struct Staged { kind: Kind, file: Option, bytes: Vec, sha256: String, source: SourceKind, url: Option, store: Option, version: String, created: Instant, } struct CatalogCache { catalog: Catalog, fetched: Instant, fetched_at: String, } struct Inner { pending: VecDeque, staged: HashMap, catalog: Option, records: InstallRecords, } /// One app's store and install state. Create it once (`Store::new`) and keep it in app state. pub struct Store { config: &'static StoreConfig, plugins: &'static PluginHost, data_dir: PathBuf, inner: Mutex, } fn new_token() -> String { let mut bytes = [0u8; 16]; let _ = getrandom::getrandom(&mut bytes); bytes.iter().map(|b| format!("{b:02x}")).collect() } pub fn now_rfc3339() -> String { chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true) } fn valid_theme_id(id: &str) -> bool { !id.is_empty() && id.len() <= 40 && id.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') } impl Store { /// `data_dir` is the app-data folder: install records and the catalog copy live in it, and /// plugins in its `plugins` folder (the same folder the app's `PluginStore` uses). pub fn new(config: &'static StoreConfig, plugins: &'static PluginHost, data_dir: PathBuf) -> Self { let records = std::fs::read_to_string(data_dir.join(RECORDS_FILE)) .ok() .and_then(|raw| serde_json::from_str::(&raw).ok()) .unwrap_or_default(); Self { config, plugins, data_dir, inner: Mutex::new(Inner { pending: VecDeque::new(), staged: HashMap::new(), catalog: None, records }), } } pub fn config(&self) -> &'static StoreConfig { self.config } fn lock(&self) -> MutexGuard<'_, Inner> { self.inner.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) } fn plugin_store(&self) -> PluginStore { 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") } fn save_records(&self, records: &InstallRecords) { let result = serde_json::to_string_pretty(records) .map_err(|_| "STORAGE: serialization failed".to_owned()) .and_then(|json| std::fs::write(self.data_dir.join(RECORDS_FILE), json).map_err(|error| format!("STORAGE: {error}"))); if let Err(error) = result { eprintln!("{}: {error}", self.config.product); } } // ----------------------------------------------------------------------------------------- // Links /// A link reached the app (OS deep link, second launch): validate it and queue it for the /// shell. Returns the event to emit ([`EVENT_LINK`]). Nothing is downloaded or installed here. pub fn receive_link(&self, raw: &str) -> LinkEvent { let event = match setup_link(raw) { Some(Ok(text)) => LinkEvent { link: None, error: None, setup: Some(text) }, Some(Err(error)) => LinkEvent { link: None, error: Some(error), setup: None }, None => match dist::parse_install_link(self.config, raw) { Ok(link) => LinkEvent { link: Some(link), error: None, setup: None }, Err(error) => LinkEvent { link: None, error: Some(error), setup: None }, }, }; let mut inner = self.lock(); if inner.pending.len() >= MAX_PENDING { inner.pending.pop_front(); } inner.pending.push_back(event.clone()); event } /// Links that arrived before the shell listened (drained). pub fn take_pending(&self) -> Vec { self.lock().pending.drain(..).collect() } /// The same rules as a link the OS handed over, for a `kind` + `url` the shell asks for: the /// shell cannot widen them. Returns the link and, for plugins, the file name. pub fn check_link(&self, kind: &str, url: &str) -> Result<(InstallLink, Option), String> { let kind = Kind::parse(kind)?; 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 | Kind::Strings => None, }; Ok((link, file)) } // ----------------------------------------------------------------------------------------- // Staging /// Validates bytes for `kind` and stages them under a fresh token. fn stage(&self, kind: Kind, file: Option, bytes: Vec, source: SourceKind, url: Option, store: Option) -> Result { let mut inner = self.lock(); inner.staged.retain(|_, staged| staged.created.elapsed() < STAGED_TTL); while inner.staged.len() >= MAX_STAGED { let oldest = inner.staged.iter().min_by_key(|(_, s)| s.created).map(|(k, _)| k.clone()); match oldest { Some(key) => inner.staged.remove(&key), None => break, }; } 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")?; let validated = dist::validate_plugin(file, &bytes)?; let local = self.plugin_store(); let existing = local.file_hash(file).map(|hash| Existing { version: plugins::parse_meta(file, &std::fs::read_to_string(self.plugins_dir().join(file)).unwrap_or_default()).version, enabled: local.is_enabled(file), same: hash == validated.sha256, }); 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(); (None, Some(info), Vec::new(), version, None) } }; let label = match source { SourceKind::Store => "wayBetter Store (signed catalog, sha256 verified)".to_owned(), SourceKind::Link => format!("Link: {}", url.as_deref().and_then(|u| Url::parse(u).ok()).and_then(|u| u.host_str().map(str::to_owned)).unwrap_or_default()), SourceKind::File => "A file you dropped".to_owned(), }; let sha256 = dist::sha256_hex(&bytes); let token = new_token(); let preview = InstallPreview { token: token.clone(), kind, source: SourceView { kind: source.clone(), url: url.clone(), label, signed: source == SourceKind::Store }, sha256: sha256.clone(), size: bytes.len(), file: file.clone(), text, version: version.clone(), plugin, theme, strings, findings, existing, store: store.clone(), }; inner.staged.insert(token, Staged { kind, file, bytes, sha256, source, url, store, version, created: Instant::now() }); Ok(preview) } /// Stages what a link (checked with [`Self::check_link`]) downloaded. A link to a file the /// signed store lists is still a link (no store badge), but the preview carries the store entry /// when the hash matches so the screen can say so. pub fn stage_link_download(&self, link: &InstallLink, file: Option, bytes: Vec) -> Result { if bytes.len() > link.kind.max_bytes() { return Err("VALIDATION: the file is larger than allowed".into()); } let hash = dist::sha256_hex(&bytes); let store = self.lock().catalog.as_ref().and_then(|cache| cache.catalog.items.iter().find(|item| item.url == link.url && item.sha256 == hash).cloned()); self.stage(link.kind, file, bytes, SourceKind::Link, Some(link.url.clone()), store) } /// A file dropped on the window (the shell read it with the File API; validated here). pub fn stage_file(&self, name: &str, text: String) -> Result { 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), 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()); } self.stage(kind, file, text.into_bytes(), SourceKind::File, None, None) } /// The catalog entry the shell asks for (this app's items only). pub fn catalog_item(&self, item: &StoreItemRef) -> Result { let kind = Kind::parse(&item.kind)?; self.lock() .catalog .as_ref() .and_then(|cache| cache.catalog.items.iter().find(|i| i.kind == kind && i.id == item.id).cloned()) .ok_or_else(|| "STORE: not in the catalog; refresh the store".into()) } /// Stages a downloaded store item after checking it against the signed catalog (size + sha256). pub fn stage_store_download(&self, entry: CatalogItem, bytes: Vec) -> Result { dist::verify_item(&entry, &bytes)?; if !entry.is_for(self.config.app_id) { return Err("STORE: this item is for another app".into()); } self.stage(entry.kind, entry.file.clone(), bytes, SourceKind::Store, Some(entry.url.clone()), Some(entry)) } pub fn cancel(&self, token: &str) { self.lock().staged.remove(token); } // ----------------------------------------------------------------------------------------- // Commit and remove /// The owner confirmed the screen for `token`. Plugins are written (validated again) and, when /// `enable` (or they were enabled before an update), started: `run` evaluates each start/stop /// script in the app's site tabs. Themes are handed back for the shell to add under `theme_id`. pub fn commit(&self, token: &str, enable: bool, theme_id: Option, run: impl Fn(&str)) -> Result { let mut inner = self.lock(); let Some(staged) = inner.staged.get(token) else { return Err("INSTALL: this install screen expired; open it again".into()); }; if staged.created.elapsed() < MIN_REVIEW { return Err("INSTALL: confirm again after reviewing".into()); } let staged = inner.staged.remove(token).expect("present"); let record = |version: String| InstallRecord { kind: staged.kind, source: staged.source.clone(), url: staged.url.clone(), store_id: staged.store.as_ref().map(|item| item.id.clone()), version, sha256: staged.sha256.clone(), installed_at: now_rfc3339(), }; match staged.kind { Kind::Plugin => { let file = staged.file.clone().ok_or("INSTALL: missing file")?; let store = self.plugin_store(); let was_enabled = store.is_enabled(&file); let validated = store.install(&file, &staged.bytes)?; inner.records.insert(InstallRecords::key(Kind::Plugin, &file), record(validated.meta.version.clone())); let records = inner.records.clone(); drop(inner); self.save_records(&records); if was_enabled { // An update of a running plugin: stop the old code, start the new. run(&plugins::stop_script(self.plugins, &file)?); run(&store.start_script(&file)?); } else if enable { let script = store.start_script(&file)?; store.set_enabled(&file, true)?; run(&script); } 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")?; inner.records.insert(InstallRecords::key(Kind::Theme, &id), record(staged.version.clone())); let records = inner.records.clone(); 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), strings: None }) } } } /// Deletes a plugin; `run` stops it in every site tab first. pub fn remove_plugin(&self, file: &str, run: impl Fn(&str)) -> Result, String> { let mut inner = self.lock(); let store = self.plugin_store(); let script = plugins::stop_script(self.plugins, file)?; store.remove(file)?; inner.records.items.remove(&InstallRecords::key(Kind::Plugin, file)); let records = inner.records.clone(); drop(inner); self.save_records(&records); run(&script); 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() } /// A theme (or plugin) was removed elsewhere: forget where it came from. pub fn forget(&self, kind: &str, name: &str) -> Result<(), String> { let kind = Kind::parse(kind)?; let mut inner = self.lock(); if inner.records.items.remove(&InstallRecords::key(kind, name)).is_some() { let records = inner.records.clone(); drop(inner); self.save_records(&records); } Ok(()) } // ----------------------------------------------------------------------------------------- // Catalog /// Whether the catalog in memory is recent enough to show without fetching. pub fn catalog_is_fresh(&self) -> bool { self.lock().catalog.as_ref().is_some_and(|cache| cache.fetched.elapsed() < CATALOG_TTL) } /// Verifies fetched catalog bytes, keeps this app's part and saves the verified bytes for /// offline use (they are verified again when read back). pub fn accept_catalog(&self, body: &[u8], signature: &str) -> Result<(), String> { let catalog = dist::verify_catalog(self.config, body, signature)?; let _ = std::fs::write(self.data_dir.join(CATALOG_CACHE), body); let _ = std::fs::write(self.data_dir.join(CATALOG_SIG_CACHE), signature.as_bytes()); self.lock().catalog = Some(CatalogCache { catalog, fetched: Instant::now(), fetched_at: now_rfc3339() }); Ok(()) } /// The live catalog could not be loaded: fall back to the saved copy (if it still verifies) /// when nothing is in memory. It is shown, but fetched again on the next open. pub fn use_cached_catalog(&self) { if self.lock().catalog.is_some() { return; } let Some((catalog, fetched_at)) = cached_catalog(self.config, &self.data_dir) else { return }; let fetched = Instant::now().checked_sub(CATALOG_TTL).unwrap_or_else(Instant::now); let mut inner = self.lock(); if inner.catalog.is_none() { inner.catalog = Some(CatalogCache { catalog, fetched, fetched_at }); } } /// What the Store view shows, with `error` when the live catalog could not be loaded. pub fn view(&self, error: Option) -> StoreView { let inner = self.lock(); let local = self.plugin_store(); let mut installed = BTreeMap::new(); if let Some(cache) = &inner.catalog { for item in cache.catalog.items.iter().filter(|item| item.kind == Kind::Plugin) { let Some(file) = item.file.as_deref() else { continue }; let Some(sha256) = local.file_hash(file) else { continue }; let source = std::fs::read_to_string(self.plugins_dir().join(file)).unwrap_or_default(); installed.insert(file.to_owned(), InstalledPlugin { version: plugins::parse_meta(file, &source).version, sha256, enabled: local.is_enabled(file) }); } } StoreView { catalog: inner.catalog.as_ref().map(|cache| cache.catalog.clone()), fetched_at: inner.catalog.as_ref().map(|cache| cache.fetched_at.clone()), error, plugins: installed, records: inner.records.clone(), } } } fn cached_catalog(config: &StoreConfig, dir: &Path) -> Option<(Catalog, String)> { let body = std::fs::read(dir.join(CATALOG_CACHE)).ok()?; let signature = std::fs::read_to_string(dir.join(CATALOG_SIG_CACHE)).ok()?; let modified = std::fs::metadata(dir.join(CATALOG_CACHE)).and_then(|m| m.modified()).ok()?; let catalog = dist::verify_catalog(config, &body, &signature).ok()?; let at = chrono::DateTime::::from(modified).to_rfc3339_opts(chrono::SecondsFormat::Secs, true); Some((catalog, at)) } #[cfg(test)] mod tests { use super::super::catalog::tests::{catalog_json, config, plugin_item, sign, test_key, theme_item, PLUGIN}; use super::*; use std::cell::RefCell; static HOST: PluginHost = PluginHost { runtime_global: "WayBetterTest", origin: "https://example.com", product: "wayBetter test", css_element_id: "wb-test-css", example: None, bundled: &[], superseded: &[], }; const THEME: &[u8] = br#"{"name":"Nord","author":"me","css":"a{}"}"#; fn store_with_catalog() -> (tempfile::TempDir, Store, Vec, String) { let dir = tempfile::tempdir().unwrap(); let (key, public) = test_key(7); let mut kick = plugin_item(PLUGIN); kick["apps"] = serde_json::json!(["kick"]); kick["id"] = serde_json::json!("kick-tool"); kick["file"] = serde_json::json!("kick-tool.plugin.js"); let body = catalog_json(serde_json::json!([plugin_item(PLUGIN), theme_item(THEME), kick])); let signature = sign(&key, &body); let store = Store::new(config("x", &[public]), &HOST, dir.path().to_path_buf()); (dir, store, body, signature) } fn reviewed() { std::thread::sleep(MIN_REVIEW + Duration::from_millis(50)); } #[test] fn links_are_validated_and_queued_for_the_shell() { let (_dir, store, _, _) = store_with_catalog(); let ok = store.receive_link("waybetter://install?kind=theme&url=https://waybetter.fyi/store/themes/nord.json"); assert!(ok.link.is_some() && ok.error.is_none()); let bad = store.receive_link("waybetter://install?kind=theme&url=https://evil.example/a.json"); assert!(bad.link.is_none() && bad.error.unwrap().starts_with("LINK:")); // A "Share my setup" link is only handed over as text; its data is never read here. let setup = store.receive_link("waybetter://setup?app=x&d=eyJmb3JtYXQiOiJ3YXliZXR0ZXItc2V0dXAifQ"); assert_eq!(setup.setup.as_deref(), Some("waybetter://setup?app=x&d=eyJmb3JtYXQiOiJ3YXliZXR0ZXItc2V0dXAifQ")); assert!(setup.link.is_none() && setup.error.is_none()); for bad in ["waybetter://setup", "waybetter://setup?", "waybetter://setup?d=