//! wayBetter for X's part of distribution (owner-approved widening, 2026-09-28): installing themes //! and plugins from a `waybetter://install?kind=theme|plugin&url=` link, a dropped file, //! or the signed wayBetter Store. The rules, the catalog format and the install state are the //! framework's (waybetter-host `store`, docs in waybetter/docs/STORE.md); this app supplies only its //! settings below, and the framework serves the shell-only Tauri commands (waybetter-host //! `store_commands`, enabled by `AppConfig::with_store`). //! //! Trust model (see waybetter-host `store` for the details): //! - Nothing installs without the shell's confirmation screen (name, author, version, source, //! sha256, permissions, code/CSS preview). Only the local shell webview can call the install //! commands; X tabs and web pages cannot. A web page can at most open a `waybetter://` link, //! which only *proposes* an install. //! - Links may only point at https URLs on [`STORE`]'s hosts (waybetter.fyi, cdn1.crt.fyi, and //! git.crt.fyi only for raw files), default port, no credentials; native code fetches them //! without redirects or cookies and with size caps. //! - The store catalog carries a detached Ed25519 signature made offline with the store key //! (`waybetter store sign`, via `scripts/sign-store.mjs`); the app verifies it with the public key //! compiled in (`keys/store.pub`), keeps only items for app id "x", and then checks every //! downloaded item against the catalog's sha256 and size. pub use waybetter_host::store::*; /// Store public keys (base64 of the raw 32-byte Ed25519 key). More than one allows rotation. pub const STORE_PUBKEYS: &[&str] = &[include_str!("../keys/store.pub")]; /// wayBetter for X in the shared store: its id in the catalog and where it may download from. pub static STORE: StoreConfig = StoreConfig { app_id: "x", product: "wayBetter for X", user_agent: concat!("wayBetter-for-X/", env!("CARGO_PKG_VERSION")), scheme: "waybetter", store_base: DEFAULT_STORE_BASE, public_keys: STORE_PUBKEYS, allowed_hosts: DEFAULT_HOSTS, }; #[cfg(test)] mod tests { use super::*; #[test] fn production_store_key_parses() { assert!(decode_key(STORE_PUBKEYS[0]).is_ok()); } #[test] fn x_links_keep_the_hosts_and_scheme_they_had() { let hosts: Vec<(&str, bool)> = STORE.allowed_hosts.iter().map(|h| (h.host, h.raw_files_only)).collect(); assert_eq!(hosts, [("waybetter.fyi", false), ("cdn1.crt.fyi", false), ("git.crt.fyi", true)]); assert_eq!((STORE.app_id, STORE.scheme, STORE.store_base), ("x", "waybetter", "https://waybetter.fyi/store/")); assert!(parse_install_link(&STORE, "waybetter://install?kind=theme&url=https://waybetter.fyi/store/themes/nord.json").is_ok()); assert!(parse_install_link(&STORE, "waybetter://install?kind=theme&url=https://evil.example/a.json").is_err()); } /// `waybetter store sign` (Node crypto) and this verifier agree: the committed store catalog /// verifies with the production key, and every X item file matches its catalog entry. #[test] fn committed_store_catalog_verifies_with_the_production_key() { let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../web/public/store"); let Ok(body) = std::fs::read(dir.join("catalog.json")) else { panic!("apps/web/public/store/catalog.json missing: run node scripts/build-store.mjs && node scripts/sign-store.mjs"); }; let signature = std::fs::read_to_string(dir.join("catalog.json.sig")).expect("catalog.json.sig"); let catalog = verify_catalog(&STORE, &body, &signature).expect("store catalog must verify: rebuild and re-sign it"); assert!(catalog.items.iter().any(|item| item.kind == Kind::Plugin) && catalog.items.iter().any(|item| item.kind == Kind::Theme)); for item in &catalog.items { assert!(item.is_for("x")); let relative = item.url.strip_prefix(STORE.store_base).expect("store items live under the store base"); let bytes = std::fs::read(dir.join(relative)).unwrap_or_else(|_| panic!("{relative} missing")); verify_item(item, &bytes).unwrap_or_else(|e| panic!("{relative}: {e}")); match item.kind { Kind::Plugin => { let validated = validate_plugin(item.file.as_deref().unwrap(), &bytes).unwrap_or_else(|e| panic!("{relative}: {e}")); assert_eq!(validated.meta.version, item.version, "{relative}: catalog version = header version"); assert_eq!(validated.meta.permissions, item.permissions, "{relative}: catalog permissions = header"); } Kind::Theme => { validate_theme(&bytes).unwrap_or_else(|e| panic!("{relative}: {e}")); } Kind::Strings => { validate_strings(&bytes).unwrap_or_else(|e| panic!("{relative}: {e}")); } } } } }