Store: Open With (files the OS hands over) and the icons and builtin catalog kinds

Open With: store_commands keeps each existing file the OS hands over (launch
argument, a second launch forwarded by single-instance with its working
directory, a macOS file:// open-URL) under a random id (Store::receive_file:
name says what it is, regular file, size cap, 8 kept, 15 minutes) and queues
LinkEvent.opened {id, name, kind}. The shell-only install_stage_opened {id}
reads that one file once and stages it exactly like a dropped file; it takes
an id only (testkit asserts that), so the shell cannot name a path. The shell
shows the same install screen; nothing installs without it. The NSIS template
lists the app under "Open with" for the declared extensions (OpenWithProgids)
and never makes it the default program, unlike Tauri's own macro.

Catalog kinds: icons (an icon pack, *.icons.json, at most 120 KB; validate_icons,
staged and committed as text for the shell, the install screen draws every icon,
.icons.json is no longer read as a theme) and builtin (a feature the app
already has, such as an area plugin: no url, sha256, size or file; never staged;
the Store view lists it as Built in with the app's own switch via the areas
prop). Rust: Kind, CatalogItem.check, verify_item, install links refuse builtin.
JS: storeModel, installController (requestOpened, icons adapter), InstallDialog,
StoreView, StoreSection (remove an icon pack, area switches), words. CLI and
JSON Schema: kinds, optional url/sha256/size on builtin, icons size cap; the
test schema validator learns not and if/then/else. docs/STORE.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
CRTkafa 2026-10-01 14:23:23 +07:00
parent d40bf273bd
commit 5d19396d89
21 changed files with 1015 additions and 90 deletions

View file

@ -4,12 +4,13 @@
//! 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,
self as dist, Catalog, CatalogItem, IconsInfo, 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 crate::plugins::{self, PluginHost, PluginStore, ScriptPlugin};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap, VecDeque};
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, MutexGuard};
use std::time::{Duration, Instant};
@ -20,7 +21,8 @@ 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
/// Shell event: a validated install link (`{ link: { kind, url } }`), a file the OS handed over
/// ("Open with", double-click: `{ opened: { id, name, kind } }`), 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
@ -31,6 +33,8 @@ 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;
/// Files the OS handed over that the shell has not asked for yet.
const MAX_OPENED: 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);
@ -47,6 +51,19 @@ pub struct LinkEvent {
/// A "Share my setup" link, as text (see [`SETUP_LINK`]).
#[serde(skip_serializing_if = "Option::is_none")]
pub setup: Option<String>,
/// A file the OS handed to the app; the shell asks for it by `id` (`install_stage_opened`).
#[serde(skip_serializing_if = "Option::is_none")]
pub opened: Option<OpenedFile>,
}
/// A file the OS handed to the app. Only its random id, its name and what it would be reach the
/// shell; the path stays in native code and the shell cannot name a path of its own.
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct OpenedFile {
pub id: String,
pub name: String,
pub kind: Kind,
}
/// `Some` when `raw` is a setup link: its text when it has the plain shape (the path, a query of
@ -97,6 +114,8 @@ pub struct InstallPreview {
pub theme: Option<ThemeInfo>,
/// String packs: what the pack says about itself (the shell adds coverage from the inventory).
pub strings: Option<StringsInfo>,
/// Icon packs: what the pack says about itself (the shell parses and draws the rest).
pub icons: Option<IconsInfo>,
pub findings: Vec<&'static str>,
pub existing: Option<Existing>,
pub store: Option<CatalogItem>,
@ -112,6 +131,8 @@ pub struct InstallOutcome {
pub theme_text: Option<String>,
/// String packs: the installed packs after installing.
pub strings: Option<StringsList>,
/// Icon packs: the exact JSON the owner confirmed; the shell adds it (sanitized) to its packs.
pub icons_text: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
@ -161,8 +182,16 @@ struct CatalogCache {
fetched_at: String,
}
struct Opened {
path: PathBuf,
name: String,
kind: Kind,
created: Instant,
}
struct Inner {
pending: VecDeque<LinkEvent>,
opened: HashMap<String, Opened>,
staged: HashMap<String, Staged>,
catalog: Option<CatalogCache>,
records: InstallRecords,
@ -202,7 +231,7 @@ impl Store {
config,
plugins,
data_dir,
inner: Mutex::new(Inner { pending: VecDeque::new(), staged: HashMap::new(), catalog: None, records }),
inner: Mutex::new(Inner { pending: VecDeque::new(), opened: HashMap::new(), staged: HashMap::new(), catalog: None, records }),
}
}
@ -242,14 +271,19 @@ impl Store {
/// 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 },
let mut event = LinkEvent { link: None, error: None, setup: None, opened: None };
match setup_link(raw) {
Some(Ok(text)) => event.setup = Some(text),
Some(Err(error)) => event.error = Some(error),
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 },
Ok(link) => event.link = Some(link),
Err(error) => event.error = Some(error),
},
};
}
self.queue(event)
}
fn queue(&self, event: LinkEvent) -> LinkEvent {
let mut inner = self.lock();
if inner.pending.len() >= MAX_PENDING {
inner.pending.pop_front();
@ -258,6 +292,62 @@ impl Store {
event
}
/// A file the OS handed over (a double-click or "Open with", at launch or to the running app):
/// check its name and size, keep its path under a random id and queue the event for the shell.
/// The file is read only when the shell asks ([`Self::stage_opened`]), and what it shows is the
/// install screen: nothing is written without the owner's Install there.
pub fn receive_file(&self, path: &Path) -> LinkEvent {
let mut event = LinkEvent { link: None, error: None, setup: None, opened: None };
match self.keep_opened(path) {
Ok(opened) => event.opened = Some(opened),
Err(error) => event.error = Some(error),
}
self.queue(event)
}
fn keep_opened(&self, path: &Path) -> Result<OpenedFile, String> {
let name = path.file_name().and_then(|name| name.to_str()).ok_or("FILE: this file's name cannot be read")?.to_owned();
let kind = dist::kind_for_file_name(&name)
.ok_or("FILE: only a theme (.json), a plugin (.plugin.js), a string pack (.strings.json) or an icon pack (.icons.json) can be opened here")?;
let meta = std::fs::metadata(path).map_err(|_| "FILE: this file cannot be read")?;
if !meta.is_file() {
return Err("FILE: this is not a file".into());
}
if meta.len() > kind.max_bytes() as u64 {
return Err("FILE: the file is larger than allowed".into());
}
let id = new_token();
let mut inner = self.lock();
inner.opened.retain(|_, opened| opened.created.elapsed() < STAGED_TTL);
while inner.opened.len() >= MAX_OPENED {
let oldest = inner.opened.iter().min_by_key(|(_, opened)| opened.created).map(|(id, _)| id.clone());
match oldest {
Some(id) => inner.opened.remove(&id),
None => break,
};
}
inner.opened.insert(id.clone(), Opened { path: path.to_path_buf(), name: name.clone(), kind, created: Instant::now() });
Ok(OpenedFile { id, name, kind })
}
/// Reads the file kept under `id` (single use) and stages it like a dropped file.
pub fn stage_opened(&self, id: &str) -> Result<InstallPreview, String> {
let opened = self.lock().opened.remove(id).filter(|opened| opened.created.elapsed() < STAGED_TTL);
let Some(opened) = opened else {
return Err("INSTALL: this file is no longer waiting; open it again".into());
};
let max = opened.kind.max_bytes();
let mut bytes = Vec::new();
std::fs::File::open(&opened.path)
.and_then(|file| file.take(max as u64 + 1).read_to_end(&mut bytes))
.map_err(|_| "FILE: this file cannot be read")?;
if bytes.len() > max {
return Err("VALIDATION: the file is larger than allowed".into());
}
let file = (opened.kind == Kind::Plugin).then(|| opened.name.clone());
self.stage(opened.kind, file, bytes, SourceKind::File, None, None)
}
/// Links that arrived before the shell listened (drained).
pub fn take_pending(&self) -> Vec<LinkEvent> {
self.lock().pending.drain(..).collect()
@ -270,7 +360,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 | Kind::Strings => None,
Kind::Theme | Kind::Strings | Kind::Icons | Kind::Builtin => None,
};
Ok((link, file))
}
@ -291,6 +381,7 @@ impl Store {
}
let text = String::from_utf8(bytes.clone()).map_err(|_| "VALIDATION: not UTF-8 text")?;
let mut strings = None;
let mut icons = None;
let (plugin, theme, findings, version, existing) = match kind {
Kind::Plugin => {
let file = file.as_deref().ok_or("VALIDATION: missing plugin file name")?;
@ -325,6 +416,18 @@ impl Store {
let version = store.as_ref().map(|item| item.version.clone()).unwrap_or_default();
(None, Some(info), Vec::new(), version, None)
}
Kind::Icons => {
let info = dist::validate_icons(&bytes)?;
let version = store.as_ref().map(|item| item.version.clone()).unwrap_or_default();
let existing = inner.records.items.get(&InstallRecords::key(Kind::Icons, &info.id)).map(|record| Existing {
version: record.version.clone(),
enabled: false,
same: record.sha256 == dist::sha256_hex(&bytes),
});
icons = Some(info);
(None, None, Vec::new(), version, existing)
}
Kind::Builtin => return Err("VALIDATION: built-in items are part of the app; there is nothing to install".into()),
};
let label = match source {
SourceKind::Store => "wayBetter Store (signed catalog, sha256 verified)".to_owned(),
@ -345,6 +448,7 @@ impl Store {
plugin,
theme,
strings,
icons,
findings,
existing,
store: store.clone(),
@ -367,16 +471,9 @@ impl 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(".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());
};
let kind = dist::kind_for_file_name(name)
.ok_or("VALIDATION: drop a theme (.json), a plugin (.plugin.js), a string pack (.strings.json) or an icon pack (.icons.json)")?;
let file = (kind == Kind::Plugin).then(|| name.to_owned());
if text.len() > kind.max_bytes() {
return Err("VALIDATION: the file is larger than allowed".into());
}
@ -449,7 +546,7 @@ impl Store {
store.set_enabled(&file, true)?;
run(&script);
}
Ok(InstallOutcome { kind: Kind::Plugin, plugins: Some(store.list()?), theme_text: None, strings: None })
Ok(InstallOutcome { kind: Kind::Plugin, plugins: Some(store.list()?), theme_text: None, strings: None, icons_text: None })
}
Kind::Strings => {
let info = packs::validate_strings(&staged.bytes)?;
@ -460,7 +557,7 @@ impl Store {
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()) })
Ok(InstallOutcome { kind: Kind::Strings, plugins: None, theme_text: None, strings: Some(local.list()), icons_text: None })
}
Kind::Theme => {
let id = theme_id.filter(|id| valid_theme_id(id)).ok_or("VALIDATION: invalid theme id")?;
@ -469,8 +566,18 @@ 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), strings: None })
Ok(InstallOutcome { kind: Kind::Theme, plugins: None, theme_text: Some(text), strings: None, icons_text: None })
}
Kind::Icons => {
let info = dist::validate_icons(&staged.bytes)?;
inner.records.insert(InstallRecords::key(Kind::Icons, &info.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::Icons, plugins: None, theme_text: None, strings: None, icons_text: Some(text) })
}
Kind::Builtin => Err("INSTALL: built-in items are part of the app".into()),
}
}
@ -741,6 +848,105 @@ mod tests {
assert!(store.commit(&other.token, true, Some("other".into()), |_| {}).is_err());
}
const ICONS: &str = r#"{"id":"line-2","name":"Line 2","author":"me","license":"CC0-1.0","glyphs":{"home":{"d":"M3 3h18v18H3z"}}}"#;
#[test]
fn icon_packs_install_as_text_for_the_shell_and_are_never_themes() {
let (_dir, store, _, _) = store_with_catalog();
assert!(store.stage_file("bad.icons.json", r#"{"name":"A"}"#.into()).unwrap_err().contains("id"), "an icon pack is not read as a theme");
let dropped = store.stage_file("line-2.icons.json", ICONS.into()).unwrap();
assert_eq!(dropped.kind, Kind::Icons);
assert_eq!(dropped.icons.as_ref().map(|info| (info.id.as_str(), info.glyphs)), Some(("line-2", 1)));
assert!(dropped.plugin.is_none() && dropped.theme.is_none() && !dropped.source.signed);
reviewed();
let outcome = store.commit(&dropped.token, false, None, |_| panic!("icon packs run nothing")).unwrap();
assert_eq!((outcome.kind, outcome.icons_text.as_deref()), (Kind::Icons, Some(ICONS)));
assert!(outcome.theme_text.is_none() && outcome.plugins.is_none());
assert_eq!(store.records().items["icons:line-2"].source, SourceKind::File);
// Staging the same pack again says it is installed; forgetting it clears the record.
let again = store.stage_file("line-2.icons.json", ICONS.into()).unwrap();
assert!(again.existing.as_ref().is_some_and(|existing| existing.same));
store.forget("icons", "line-2").unwrap();
assert!(store.records().items.is_empty());
assert!(store.stage_file("big.icons.json", format!(r#"{{"id":"a","name":"A","glyphs":{{"home":{{"d":"{}"}}}}}}"#, "M".repeat(dist::MAX_ICONS_BYTES))).unwrap_err().contains("larger"));
}
#[test]
fn built_in_items_have_nothing_to_stage() {
let (_dir, store, _, _) = store_with_catalog();
let entry: CatalogItem = serde_json::from_value(serde_json::json!({ "apps": ["x"], "kind": "builtin", "id": "compose", "name": "Compose+", "version": "1.0.0" })).unwrap();
assert!(store.stage_store_download(entry, Vec::new()).unwrap_err().contains("built-in"));
assert!(store.stage(Kind::Builtin, None, b"{}".to_vec(), SourceKind::File, None, None).unwrap_err().contains("built-in"));
}
#[test]
fn files_the_os_hands_over_are_kept_under_an_id_and_staged_like_a_drop() {
let (dir, store, _, _) = store_with_catalog();
let theme = dir.path().join("nord.json");
std::fs::write(&theme, THEME).unwrap();
let event = store.receive_file(&theme);
let opened = event.opened.clone().expect("a theme file is offered");
assert_eq!((opened.name.as_str(), opened.kind), ("nord.json", Kind::Theme));
assert!(event.link.is_none() && event.error.is_none() && event.setup.is_none());
// The shell sees an id and a name, never the path.
let wire = serde_json::to_string(&event).unwrap();
assert!(!wire.contains("path") && !wire.contains(dir.path().file_name().unwrap().to_str().unwrap()), "{wire}");
assert_eq!(store.take_pending().len(), 1, "queued for a shell that was not listening yet");
let preview = store.stage_opened(&opened.id).unwrap();
assert_eq!((preview.kind, preview.source.kind.clone()), (Kind::Theme, SourceKind::File));
assert_eq!(preview.theme.as_ref().unwrap().name, "Nord");
reviewed();
let outcome = store.commit(&preview.token, true, Some("nord-3".into()), |_| {}).unwrap();
assert_eq!(outcome.theme_text.as_deref().map(str::as_bytes), Some(THEME));
// Single use, and an id the OS did not hand over is refused (the shell cannot name a path).
assert!(store.stage_opened(&opened.id).unwrap_err().contains("no longer waiting"));
assert!(store.stage_opened("../nord.json").unwrap_err().contains("no longer waiting"));
assert!(store.stage_opened(&theme.to_string_lossy()).is_err());
// A plugin keeps its file name; an icon pack and a string pack are told apart from a theme.
let plugin = dir.path().join("tool.plugin.js");
std::fs::write(&plugin, PLUGIN).unwrap();
let opened = store.receive_file(&plugin).opened.unwrap();
let preview = store.stage_opened(&opened.id).unwrap();
assert_eq!((preview.kind, preview.file.as_deref()), (Kind::Plugin, Some("tool.plugin.js")));
let pack = dir.path().join("line-2.icons.json");
std::fs::write(&pack, ICONS).unwrap();
assert_eq!(store.receive_file(&pack).opened.unwrap().kind, Kind::Icons);
let strings = dir.path().join("eo.strings.json");
std::fs::write(&strings, "{}").unwrap();
assert_eq!(store.receive_file(&strings).opened.unwrap().kind, Kind::Strings);
// Refused up front, with an error for the shell: other files, folders, missing and oversize files.
let other = dir.path().join("photo.png");
std::fs::write(&other, "x").unwrap();
let big = dir.path().join("big.json");
std::fs::write(&big, vec![b'a'; dist::MAX_THEME_BYTES + 1]).unwrap();
for bad in [other, big, dir.path().to_path_buf(), dir.path().join("missing.json")] {
let event = store.receive_file(&bad);
assert!(event.opened.is_none() && event.error.as_deref().is_some_and(|error| error.starts_with("FILE:")), "{}: {:?}", bad.display(), event.error);
}
// A file that grew after it was offered is refused when read; one that vanished says so.
let late = dir.path().join("late.json");
std::fs::write(&late, THEME).unwrap();
let opened = store.receive_file(&late).opened.unwrap();
std::fs::write(&late, vec![b'a'; dist::MAX_THEME_BYTES + 1]).unwrap();
assert!(store.stage_opened(&opened.id).unwrap_err().contains("larger"));
let gone = dir.path().join("gone.json");
std::fs::write(&gone, THEME).unwrap();
let opened = store.receive_file(&gone).opened.unwrap();
std::fs::remove_file(&gone).unwrap();
assert!(store.stage_opened(&opened.id).unwrap_err().starts_with("FILE:"));
// Bounded: only the newest ones are kept.
let ids: Vec<String> = (0..MAX_OPENED + 3)
.map(|_| {
std::thread::sleep(Duration::from_millis(2));
store.receive_file(&theme).opened.unwrap().id
})
.collect();
assert_eq!(store.lock().opened.len(), MAX_OPENED);
assert!(store.stage_opened(&ids[0]).is_err() && store.stage_opened(ids.last().unwrap()).is_ok());
}
#[test]
fn staging_is_bounded() {
let (_dir, store, _, _) = store_with_catalog();