- settings: MAX_FEATURES 64 -> 256 (an older app's whole-map backup still validates)
- modes: ModeState {mode, onboarded, existing} in modes.json; existing is decided natively on the first start
- plugins::migrate: PluginHost.superseded (file -> flag); enabled and as shipped -> flag on and the file goes; edited -> kept, flag left off; marker written last, every step repeatable; map_backup for imported enabled lists
- a superseded file is never written again by install_bundled
- create-waybetter: the host command list and PluginHost.superseded
663 lines
31 KiB
Rust
663 lines
31 KiB
Rust
//! 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 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 } }`) or `{ error }`.
|
|
pub const EVENT_LINK: &str = "install-link";
|
|
/// 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<InstallLink>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub error: Option<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SourceView {
|
|
pub kind: SourceKind,
|
|
pub url: Option<String>,
|
|
/// "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<String>,
|
|
pub text: String,
|
|
pub version: String,
|
|
pub plugin: Option<ScriptPlugin>,
|
|
pub theme: Option<ThemeInfo>,
|
|
pub findings: Vec<&'static str>,
|
|
pub existing: Option<Existing>,
|
|
pub store: Option<CatalogItem>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct InstallOutcome {
|
|
pub kind: Kind,
|
|
/// Plugins: the list after installing.
|
|
pub plugins: Option<Vec<ScriptPlugin>>,
|
|
/// Themes: the exact JSON the owner confirmed; the shell adds it (sanitized) to its themes.
|
|
pub theme_text: Option<String>,
|
|
}
|
|
|
|
#[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<Catalog>,
|
|
pub fetched_at: Option<String>,
|
|
/// Why the live catalog could not be loaded (a cached copy may still be shown).
|
|
pub error: Option<String>,
|
|
/// Installed plugin files the catalog lists, by file name.
|
|
pub plugins: BTreeMap<String, InstalledPlugin>,
|
|
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<String>,
|
|
bytes: Vec<u8>,
|
|
sha256: String,
|
|
source: SourceKind,
|
|
url: Option<String>,
|
|
store: Option<CatalogItem>,
|
|
version: String,
|
|
created: Instant,
|
|
}
|
|
|
|
struct CatalogCache {
|
|
catalog: Catalog,
|
|
fetched: Instant,
|
|
fetched_at: String,
|
|
}
|
|
|
|
struct Inner {
|
|
pending: VecDeque<LinkEvent>,
|
|
staged: HashMap<String, Staged>,
|
|
catalog: Option<CatalogCache>,
|
|
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<Inner>,
|
|
}
|
|
|
|
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::<InstallRecords>(&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)
|
|
}
|
|
|
|
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 dist::parse_install_link(self.config, raw) {
|
|
Ok(link) => LinkEvent { link: Some(link), error: None },
|
|
Err(error) => LinkEvent { link: None, error: Some(error) },
|
|
};
|
|
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<LinkEvent> {
|
|
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>), 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 => None,
|
|
};
|
|
Ok((link, file))
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------------------
|
|
// Staging
|
|
|
|
/// Validates bytes for `kind` and stages them under a fresh token.
|
|
fn stage(&self, kind: Kind, file: Option<String>, bytes: Vec<u8>, source: SourceKind, url: Option<String>, store: Option<CatalogItem>) -> Result<InstallPreview, String> {
|
|
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 (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::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,
|
|
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<String>, bytes: Vec<u8>) -> Result<InstallPreview, String> {
|
|
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<InstallPreview, String> {
|
|
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(".json") {
|
|
(Kind::Theme, None)
|
|
} else {
|
|
return Err("VALIDATION: drop a theme (.json) or a plugin (.plugin.js)".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<CatalogItem, String> {
|
|
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<u8>) -> Result<InstallPreview, String> {
|
|
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<String>, run: impl Fn(&str)) -> Result<InstallOutcome, String> {
|
|
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 })
|
|
}
|
|
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) })
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Deletes a plugin; `run` stops it in every site tab first.
|
|
pub fn remove_plugin(&self, file: &str, run: impl Fn(&str)) -> Result<Vec<ScriptPlugin>, 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()
|
|
}
|
|
|
|
/// 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<String>) -> 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::<chrono::Utc>::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<u8>, 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:"));
|
|
for _ in 0..20 {
|
|
store.receive_link("waybetter://install?kind=theme&url=https://waybetter.fyi/a.json");
|
|
}
|
|
assert_eq!(store.take_pending().len(), MAX_PENDING, "bounded");
|
|
assert!(store.take_pending().is_empty(), "drained");
|
|
// The shell's kind + url go through the same parser.
|
|
assert_eq!(store.check_link("plugin", "https://waybetter.fyi/store/plugins/tool.plugin.js").unwrap().1.as_deref(), Some("tool.plugin.js"));
|
|
assert!(store.check_link("plugin", "https://waybetter.fyi/store/plugins/tool.js").is_err());
|
|
assert!(store.check_link("theme", "https://evil.example/a.json").is_err());
|
|
assert!(store.check_link("script", "https://waybetter.fyi/a.json").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn the_catalog_is_verified_filtered_to_the_app_and_kept_for_offline_use() {
|
|
let (dir, store, body, signature) = store_with_catalog();
|
|
assert!(store.view(None).catalog.is_none());
|
|
assert!(store.accept_catalog(&body, "AAAA").is_err());
|
|
assert!(store.view(None).catalog.is_none(), "a bad signature changes nothing");
|
|
store.accept_catalog(&body, &signature).unwrap();
|
|
assert!(store.catalog_is_fresh());
|
|
let view = store.view(None);
|
|
let ids: Vec<_> = view.catalog.unwrap().items.iter().map(|i| i.id.clone()).collect();
|
|
assert_eq!(ids, ["tool", "nord"], "the other app's item is not shown");
|
|
assert!(store.catalog_item(&StoreItemRef { kind: "plugin".into(), id: "kick-tool".into() }).is_err(), "nor staged");
|
|
// A fresh start offline: the saved copy verifies and is shown, marked stale.
|
|
let again = Store::new(store.config(), &HOST, dir.path().to_path_buf());
|
|
again.use_cached_catalog();
|
|
assert_eq!(again.view(Some("NETWORK: offline".into())).catalog.unwrap().items.len(), 2);
|
|
assert!(!again.catalog_is_fresh());
|
|
// A tampered copy on disk is ignored.
|
|
std::fs::write(dir.path().join(CATALOG_CACHE), [&body[..], b" "].concat()).unwrap();
|
|
let tampered = Store::new(store.config(), &HOST, dir.path().to_path_buf());
|
|
tampered.use_cached_catalog();
|
|
assert!(tampered.view(None).catalog.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn store_items_must_match_the_signed_catalog_and_install_the_staged_bytes() {
|
|
let (dir, store, body, signature) = store_with_catalog();
|
|
store.accept_catalog(&body, &signature).unwrap();
|
|
let entry = store.catalog_item(&StoreItemRef { kind: "plugin".into(), id: "tool".into() }).unwrap();
|
|
assert!(store.stage_store_download(entry.clone(), b"tampered".to_vec()).is_err());
|
|
let preview = store.stage_store_download(entry, PLUGIN.to_vec()).unwrap();
|
|
assert!(preview.source.signed && preview.existing.is_none());
|
|
assert_eq!(preview.plugin.as_ref().unwrap().permissions.as_deref(), Some(&["ui".to_owned()][..]));
|
|
// Too fast: the screen was not read.
|
|
assert!(store.commit(&preview.token, true, None, |_| {}).unwrap_err().contains("reviewing"));
|
|
reviewed();
|
|
let ran = RefCell::new(Vec::new());
|
|
let outcome = store.commit(&preview.token, true, None, |script| ran.borrow_mut().push(script.to_owned())).unwrap();
|
|
assert_eq!(outcome.plugins.unwrap().iter().filter(|p| p.file == "tool.plugin.js" && p.enabled).count(), 1);
|
|
assert_eq!(ran.borrow().len(), 1, "started once");
|
|
assert_eq!(std::fs::read(dir.path().join("plugins/tool.plugin.js")).unwrap(), PLUGIN);
|
|
let record = &store.records().items["plugin:tool.plugin.js"];
|
|
assert_eq!((record.source.clone(), record.store_id.as_deref(), record.version.as_str()), (SourceKind::Store, Some("tool"), "1.0.0"));
|
|
// The token is single use; records survive a restart.
|
|
assert!(store.commit(&preview.token, true, None, |_| {}).unwrap_err().contains("expired"));
|
|
assert_eq!(Store::new(store.config(), &HOST, dir.path().to_path_buf()).records(), store.records());
|
|
// Installed and identical: the view says so.
|
|
assert_eq!(store.view(None).plugins["tool.plugin.js"].sha256, dist::sha256_hex(PLUGIN));
|
|
// Staging it again shows what is installed; committing an update of a running plugin stops then starts it.
|
|
let entry = store.catalog_item(&StoreItemRef { kind: "plugin".into(), id: "tool".into() }).unwrap();
|
|
let again = store.stage_store_download(entry, PLUGIN.to_vec()).unwrap();
|
|
let existing = again.existing.unwrap();
|
|
assert!(existing.same && existing.enabled);
|
|
reviewed();
|
|
let ran = RefCell::new(Vec::new());
|
|
store.commit(&again.token, false, None, |script| ran.borrow_mut().push(script.to_owned())).unwrap();
|
|
assert_eq!(ran.borrow().len(), 2, "stop + start");
|
|
// Remove: stopped, deleted, forgotten.
|
|
let ran = RefCell::new(0);
|
|
let list = store.remove_plugin("tool.plugin.js", |_| *ran.borrow_mut() += 1).unwrap();
|
|
assert!(list.iter().all(|p| p.file != "tool.plugin.js") && *ran.borrow() == 1);
|
|
assert!(!store.records().items.contains_key("plugin:tool.plugin.js"));
|
|
}
|
|
|
|
#[test]
|
|
fn dropped_files_and_links_are_validated_and_themes_come_back_as_text() {
|
|
let (_dir, store, body, signature) = store_with_catalog();
|
|
assert!(store.stage_file("photo.png", "x".into()).is_err());
|
|
assert!(store.stage_file("bad.plugin.js", "module.exports = 1".into()).unwrap_err().contains("header"));
|
|
assert!(store.stage_file("big.json", "a".repeat(dist::MAX_THEME_BYTES + 1)).unwrap_err().contains("larger"));
|
|
let dropped = store.stage_file("nord.json", String::from_utf8(THEME.to_vec()).unwrap()).unwrap();
|
|
assert!(!dropped.source.signed && dropped.theme.as_ref().unwrap().name == "Nord");
|
|
reviewed();
|
|
assert!(store.commit(&dropped.token, true, Some("../x".into()), |_| {}).unwrap_err().contains("theme id"));
|
|
let dropped = store.stage_file("nord.json", String::from_utf8(THEME.to_vec()).unwrap()).unwrap();
|
|
reviewed();
|
|
let outcome = store.commit(&dropped.token, true, Some("nord-2".into()), |_| panic!("themes run nothing")).unwrap();
|
|
assert_eq!(outcome.theme_text.as_deref().map(str::as_bytes), Some(THEME));
|
|
assert_eq!(store.records().items["theme:nord-2"].source, SourceKind::File);
|
|
store.forget("theme", "nord-2").unwrap();
|
|
assert!(store.records().items.is_empty());
|
|
// A link to a file the signed store lists carries the store entry, but is still a link.
|
|
store.accept_catalog(&body, &signature).unwrap();
|
|
let (link, file) = store.check_link("theme", "https://waybetter.fyi/store/themes/nord.json").unwrap();
|
|
let preview = store.stage_link_download(&link, file, THEME.to_vec()).unwrap();
|
|
assert!(!preview.source.signed && preview.store.is_some() && preview.source.label == "Link: waybetter.fyi");
|
|
let other = store.stage_link_download(&link, None, br#"{"name":"Other"}"#.to_vec()).unwrap();
|
|
assert!(other.store.is_none(), "different bytes: no store entry");
|
|
store.cancel(&other.token);
|
|
assert!(store.commit(&other.token, true, Some("other".into()), |_| {}).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn staging_is_bounded() {
|
|
let (_dir, store, _, _) = store_with_catalog();
|
|
let tokens: Vec<String> = (0..MAX_STAGED + 4)
|
|
.map(|_| {
|
|
std::thread::sleep(Duration::from_millis(2));
|
|
store.stage_file("a.json", r#"{"name":"A"}"#.into()).unwrap().token
|
|
})
|
|
.collect();
|
|
assert_eq!(store.lock().staged.len(), MAX_STAGED);
|
|
assert!(!store.lock().staged.contains_key(&tokens[0]), "the oldest goes first");
|
|
}
|
|
}
|