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

@ -1360,12 +1360,15 @@ impl App {
let builder = tauri::Builder::<Wry>::default()
// Must be first: a second launch focuses the running window instead of fighting over
// the app's data and WebView profile (and hands over its link).
.plugin(tauri_plugin_single_instance::init(|app, _, _| {
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
if let Some(window) = app.get_window(policy::MAIN_LABEL) {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
}
// A double-click or "Open with" on a file while the app runs: its path arrives here.
#[cfg(feature = "net")]
crate::store_commands::opened_args(app, &_args, &_cwd);
}));
// Install links (after single-instance, which forwards a second launch's link).
let builder = if config.deep_link { builder.plugin(tauri_plugin_deep_link::init()) } else { builder };

View file

@ -55,6 +55,7 @@ pub const STORE: &[&str] = &[
"install_pending",
"install_stage_link",
"install_stage_file",
"install_stage_opened",
"install_cancel",
"install_commit",
"plugin_remove",

View file

@ -20,6 +20,8 @@ pub const DEFAULT_HOSTS: &[AllowedHost] = &[
/// The catalog format this code reads. Format 2: every item names the apps it is for (`apps`).
pub const CATALOG_FORMAT: u32 = 2;
pub const MAX_THEME_BYTES: usize = 2 * 1024 * 1024;
/// An icon pack file (the runtime's `ICON_LIMITS.packBytes`).
pub const MAX_ICONS_BYTES: usize = 120_000;
pub const MAX_CATALOG_BYTES: usize = 1024 * 1024;
pub const MAX_SIGNATURE_BYTES: usize = 512;
pub const MAX_LINK_CHARS: usize = 2048;
@ -61,6 +63,11 @@ pub enum Kind {
Plugin,
/// A string pack: data that replaces the site's own UI strings (`store::strings`).
Strings,
/// An icon pack (`*.icons.json`): path data and small drawings, never code.
Icons,
/// Catalog only: a feature that ships inside the app (an area plugin). The Store lists it as
/// built in with its switch; there is nothing to download, stage or install.
Builtin,
}
impl Kind {
@ -69,7 +76,9 @@ impl Kind {
"theme" => Ok(Kind::Theme),
"plugin" => Ok(Kind::Plugin),
"strings" => Ok(Kind::Strings),
_ => Err("VALIDATION: kind must be \"theme\", \"plugin\" or \"strings\"".into()),
"icons" => Ok(Kind::Icons),
"builtin" => Ok(Kind::Builtin),
_ => Err("VALIDATION: kind must be \"theme\", \"plugin\", \"strings\", \"icons\" or \"builtin\"".into()),
}
}
@ -78,6 +87,8 @@ impl Kind {
Kind::Theme => "theme",
Kind::Plugin => "plugin",
Kind::Strings => "strings",
Kind::Icons => "icons",
Kind::Builtin => "builtin",
}
}
@ -86,6 +97,8 @@ impl Kind {
Kind::Theme => MAX_THEME_BYTES,
Kind::Plugin => MAX_PLUGIN_BYTES as usize,
Kind::Strings => super::strings::MAX_STRINGS_BYTES,
Kind::Icons => MAX_ICONS_BYTES,
Kind::Builtin => 0,
}
}
}
@ -163,7 +176,10 @@ pub fn parse_install_link(config: &StoreConfig, link: &str) -> Result<InstallLin
return Err("LINK: repeated parameter".into());
}
}
let kind = Kind::parse(kind.as_deref().ok_or("LINK: missing kind")?).map_err(|_| "LINK: kind must be theme or plugin")?;
let kind = Kind::parse(kind.as_deref().ok_or("LINK: missing kind")?).map_err(|_| "LINK: kind must be theme, plugin, strings or icons")?;
if kind == Kind::Builtin {
return Err("LINK: built-in items cannot be installed".into());
}
let target = Url::parse(url.as_deref().ok_or("LINK: missing url")?).map_err(|_| "LINK: url is not a URL")?;
if !allowed_download_url(config.allowed_hosts, &target) {
return Err(format!("LINK: only https links on {} can be installed", hosts_text(config.allowed_hosts)));
@ -244,6 +260,62 @@ pub fn validate_theme(bytes: &[u8]) -> Result<ThemeInfo, String> {
})
}
/// What the confirmation screen shows about an icon pack (the shell parses the rest and draws it).
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct IconsInfo {
pub id: String,
pub name: String,
pub author: String,
pub license: String,
pub glyphs: usize,
}
/// An icon pack file: at most [`MAX_ICONS_BYTES`], UTF-8 JSON, an object with an `id`
/// (`[a-z0-9-]{1,40}`), a non-empty `name` and a `glyphs` object. It is data: the shell's own
/// sanitizer (`parseIconPackJson`) keeps only valid path data and small drawings.
pub fn validate_icons(bytes: &[u8]) -> Result<IconsInfo, String> {
if bytes.is_empty() {
return Err("VALIDATION: the icon pack file is empty".into());
}
if bytes.len() > MAX_ICONS_BYTES {
return Err("VALIDATION: icon packs are limited to 120 KB".into());
}
let text = std::str::from_utf8(bytes).map_err(|_| "VALIDATION: an icon 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: an icon pack is a JSON object")?;
let id = object.get("id").and_then(serde_json::Value::as_str).unwrap_or_default();
if !valid_id(id) {
return Err("VALIDATION: an icon pack needs an \"id\" of lowercase letters, digits and dashes".into());
}
let name = short(object.get("name"), 60);
if name.trim().is_empty() {
return Err("VALIDATION: an icon pack needs a \"name\"".into());
}
let glyphs = object.get("glyphs").and_then(serde_json::Value::as_object).map(|glyphs| glyphs.len()).unwrap_or(0);
if glyphs == 0 {
return Err("VALIDATION: an icon pack needs a \"glyphs\" object with at least one icon".into());
}
Ok(IconsInfo { id: id.to_owned(), name, author: short(object.get("author"), 60), license: short(object.get("license"), 60), glyphs })
}
/// What a file name says it is: `.plugin.js` a plugin, `.strings.json` a string pack,
/// `.icons.json` an icon pack, any other `.json` a theme; anything else nothing.
pub fn kind_for_file_name(name: &str) -> Option<Kind> {
let lower = name.to_ascii_lowercase();
if lower.ends_with(plugins::PLUGIN_SUFFIX) {
Some(Kind::Plugin)
} else if lower.ends_with(".strings.json") {
Some(Kind::Strings)
} else if lower.ends_with(".icons.json") {
Some(Kind::Icons)
} else if lower.ends_with(".json") {
Some(Kind::Theme)
} else {
None
}
}
/// Validates a plugin for installation (see `plugins::validate_plugin`).
pub fn validate_plugin(file: &str, bytes: &[u8]) -> Result<ValidatedPlugin, String> {
plugins::validate_plugin(file, bytes)
@ -279,11 +351,15 @@ pub struct CatalogItem {
pub category: String,
#[serde(default)]
pub tags: Vec<String>,
/// Where the file is (every kind but `builtin`, which has nothing to download).
#[serde(default, skip_serializing_if = "String::is_empty")]
pub url: String,
/// Plugins: the file name it is installed under.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub file: Option<String>,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub sha256: String,
#[serde(default, skip_serializing_if = "is_zero")]
pub size: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preview: Option<String>,
@ -321,6 +397,10 @@ impl Catalog {
}
}
fn is_zero(value: &u64) -> bool {
*value == 0
}
fn valid_id(id: &str) -> bool {
!id.is_empty() && id.len() <= 40 && id.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
}
@ -358,9 +438,22 @@ impl CatalogItem {
if self.tags.len() > 12 || self.tags.iter().any(|tag| !valid_text(tag, 32)) {
return bad("tags");
}
let url = Url::parse(&self.url).map_err(|_| format!("STORE: item {}: invalid url", self.id))?;
if url.scheme() != "https" || url.as_str().len() > MAX_LINK_CHARS || hosts.is_some_and(|hosts| !allowed_download_url(hosts, &url)) {
return bad("url (host not allowed)");
if self.kind == Kind::Builtin {
// Part of the app: nothing to download, so nothing to hash or point at.
if !self.url.is_empty() || !self.sha256.is_empty() || self.size != 0 || self.file.is_some() || self.permissions.is_some() || self.colors.is_some() || self.language.is_some() {
return bad("built-in item fields");
}
} else {
let url = Url::parse(&self.url).map_err(|_| format!("STORE: item {}: invalid url", self.id))?;
if url.scheme() != "https" || url.as_str().len() > MAX_LINK_CHARS || hosts.is_some_and(|hosts| !allowed_download_url(hosts, &url)) {
return bad("url (host not allowed)");
}
if self.sha256.len() != 64 || !self.sha256.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) {
return bad("sha256");
}
if self.size == 0 || self.size > self.kind.max_bytes() as u64 {
return bad("size");
}
}
if let Some(preview) = &self.preview {
let ok = Url::parse(preview).is_ok_and(|url| url.scheme() == "https" && hosts.is_none_or(|hosts| allowed_download_url(hosts, &url)));
@ -368,12 +461,6 @@ impl CatalogItem {
return bad("preview");
}
}
if self.sha256.len() != 64 || !self.sha256.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) {
return bad("sha256");
}
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");
@ -393,6 +480,12 @@ impl CatalogItem {
}
}
}
Kind::Icons => {
if self.file.is_some() || self.permissions.is_some() || self.colors.is_some() {
return bad("icon pack fields");
}
}
Kind::Builtin => {}
Kind::Strings => {
if self.file.is_some() || self.permissions.is_some() || self.colors.is_some() {
return bad("string pack fields");
@ -473,6 +566,9 @@ pub fn verify_catalog(config: &StoreConfig, body: &[u8], signature: &str) -> Res
/// A downloaded item must be exactly what the signed catalog describes.
pub fn verify_item(item: &CatalogItem, bytes: &[u8]) -> Result<(), String> {
if item.kind == Kind::Builtin {
return Err("STORE: built-in items are part of the app; there is nothing to download".into());
}
if bytes.len() as u64 != item.size {
return Err("STORE: the download's size does not match the signed catalog".into());
}
@ -795,6 +891,104 @@ pub(crate) mod tests {
assert!(verify_catalog(c, &body, &sign(&key, &body)).unwrap_err().contains("update the app"));
}
const ICONS: &[u8] = br#"{"id":"line-2","name":"Line 2","author":"me","license":"CC0-1.0","glyphs":{"home":{"d":"M3 3h18v18H3z"}}}"#;
fn icons_item(body: &[u8]) -> serde_json::Value {
serde_json::json!({
"apps": ["x"], "kind": "icons", "id": "line-2", "name": "Line 2", "version": "1.0.0", "category": "icons",
"url": "https://waybetter.fyi/store/icons/line-2.icons.json", "sha256": sha256_hex(body), "size": body.len(), "license": "CC0-1.0"
})
}
fn builtin_item() -> serde_json::Value {
serde_json::json!({ "apps": ["x"], "kind": "builtin", "id": "compose", "name": "Compose+", "version": "1.0.0", "description": "Writing tools.", "category": "writing" })
}
#[test]
fn icon_packs_and_built_in_items_are_catalog_kinds() {
let (key, public) = test_key(7);
let c = config("x", &[public]);
let body = catalog_json(serde_json::json!([icons_item(ICONS), builtin_item()]));
let catalog = verify_catalog(c, &body, &sign(&key, &body)).unwrap();
assert_eq!(catalog.items.iter().map(|item| item.kind).collect::<Vec<_>>(), [Kind::Icons, Kind::Builtin]);
verify_item(&catalog.items[0], ICONS).unwrap();
assert!(verify_item(&catalog.items[0], b"{}").is_err());
assert!(verify_item(&catalog.items[1], b"").unwrap_err().contains("built-in"), "nothing to download for a built-in item");
// A built-in item serializes without file fields (the signed bytes stay what the builder wrote).
let json = serde_json::to_value(&catalog.items[1]).unwrap();
for field in ["url", "sha256", "size", "file"] {
assert!(json.get(field).is_none(), "{field}");
}
// Built-in items carry no file, hash, size, url, permissions, colors or language; icon packs no plugin or theme fields.
let mut cases: Vec<(&str, serde_json::Value)> = Vec::new();
for (what, patch) in [
("url", serde_json::json!({ "url": "https://waybetter.fyi/a" })),
("sha256", serde_json::json!({ "sha256": sha256_hex(b"x") })),
("size", serde_json::json!({ "size": 5 })),
("file", serde_json::json!({ "file": "a.plugin.js" })),
("permissions", serde_json::json!({ "permissions": ["ui"] })),
("language", serde_json::json!({ "language": "eo" })),
("colors", serde_json::json!({ "colors": { "bg": "#000000", "text": "#ffffff", "accent": "#ff0000" } })),
] {
let mut item = builtin_item();
for (k, v) in patch.as_object().unwrap() {
item[k] = v.clone();
}
cases.push((what, item));
}
for (what, patch) in [("file", serde_json::json!({ "file": "a.plugin.js" })), ("permissions", serde_json::json!({ "permissions": ["ui"] })), ("language", serde_json::json!({ "language": "eo" }))] {
let mut item = icons_item(ICONS);
for (k, v) in patch.as_object().unwrap() {
item[k] = v.clone();
}
cases.push((what, item));
}
let mut no_url = icons_item(ICONS);
no_url.as_object_mut().unwrap().remove("url");
cases.push(("icon pack without url", no_url));
let mut big = icons_item(ICONS);
big["size"] = serde_json::json!(MAX_ICONS_BYTES + 1);
cases.push(("icon pack over the cap", big));
for (what, item) in cases {
let body = catalog_json(serde_json::json!([item]));
assert!(verify_catalog(c, &body, &sign(&key, &body)).is_err(), "accepted bad {what}");
}
// Another app's built-in items are not this app's.
let mut other = builtin_item();
other["apps"] = serde_json::json!(["kick"]);
let body = catalog_json(serde_json::json!([other]));
assert!(verify_catalog(c, &body, &sign(&key, &body)).unwrap().items.is_empty());
// Links and the shell's kind names: icons yes, built-in never.
assert_eq!(parse_install_link(c, "waybetter://install?kind=icons&url=https://waybetter.fyi/store/icons/line-2.icons.json").unwrap().kind, Kind::Icons);
assert!(parse_install_link(c, "waybetter://install?kind=builtin&url=https://waybetter.fyi/a.json").unwrap_err().contains("built-in"));
}
#[test]
fn icon_packs_are_small_json_objects_with_an_id_a_name_and_glyphs() {
let info = validate_icons(ICONS).unwrap();
assert_eq!((info.id.as_str(), info.name.as_str(), info.license.as_str(), info.glyphs), ("line-2", "Line 2", "CC0-1.0", 1));
for bad in [
&b""[..], b"[]", b"{not json", br#"{"name":"A","glyphs":{"home":{}}}"#, br#"{"id":"Bad Id","name":"A","glyphs":{"home":{}}}"#,
br#"{"id":"a","glyphs":{"home":{}}}"#, br#"{"id":"a","name":"A"}"#, br#"{"id":"a","name":"A","glyphs":{}}"#,
] {
assert!(validate_icons(bad).is_err(), "accepted {}", String::from_utf8_lossy(bad));
}
let big = format!(r#"{{"id":"a","name":"A","glyphs":{{"home":{{"d":"{}"}}}}}}"#, "M".repeat(MAX_ICONS_BYTES));
assert!(validate_icons(big.as_bytes()).unwrap_err().contains("120 KB"));
}
#[test]
fn file_names_say_what_a_file_is() {
assert_eq!(kind_for_file_name("tool.plugin.js"), Some(Kind::Plugin));
assert_eq!(kind_for_file_name("TOOL.PLUGIN.JS"), Some(Kind::Plugin));
assert_eq!(kind_for_file_name("eo.strings.json"), Some(Kind::Strings));
assert_eq!(kind_for_file_name("line.icons.json"), Some(Kind::Icons), "not a theme");
assert_eq!(kind_for_file_name("nord.json"), Some(Kind::Theme));
for other in ["script.js", "photo.png", "notes.txt", "json", ""] {
assert_eq!(kind_for_file_name(other), None, "{other}");
}
}
#[test]
fn versions_compare_numerically() {
assert!(newer_version("1.10.0", "1.9.3"));

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();

View file

@ -13,6 +13,7 @@ use crate::host::{emit_shell, eval_in_site_tabs, shell_only};
use crate::plugins::ScriptPlugin;
use crate::policy;
use crate::store::{delivery_script, InstallOutcome, InstallPreview, InstallRecords, LinkEvent, Store, StoreItemRef, StoreView, StringsList, EVENT_LINK};
use std::path::{Path, PathBuf};
use tauri::{AppHandle, Manager, State, Webview};
use tauri_plugin_deep_link::DeepLinkExt;
@ -40,18 +41,63 @@ pub(crate) fn init(app: &AppHandle, config: &AppConfig) {
received(app, url.as_str());
}
}
// Launched by a double-click or "Open with" on a file (Windows/Linux pass its path as an argument).
let args: Vec<String> = std::env::args().collect();
opened_args(app, &args, "");
}
/// Most files taken from one launch's arguments.
const MAX_OPENED_ARGS: usize = 8;
/// The files named in a launch's arguments (the first launch's own, or a second launch forwarded
/// by single-instance, with its working directory): flags and links are not files and are skipped;
/// each file that exists is kept under a random id and offered to the shell
/// ([`Store::receive_file`]), and the window comes forward. Nothing is read or installed here.
pub(crate) fn opened_args(app: &AppHandle, args: &[String], cwd: &str) {
let paths = file_args(args, cwd);
if paths.is_empty() {
return;
}
let Some(store) = app.try_state::<Store>() else { return };
for path in paths {
emit_shell(app, EVENT_LINK, store.receive_file(&path));
}
bring_forward(app);
}
/// The existing files among a launch's arguments (the first argument is the program itself).
fn file_args(args: &[String], cwd: &str) -> Vec<PathBuf> {
args.iter()
.skip(1)
.filter(|arg| !arg.starts_with('-') && !arg.contains("://"))
.map(|arg| if Path::new(arg).is_absolute() || cwd.is_empty() { PathBuf::from(arg) } else { Path::new(cwd).join(arg) })
.filter(|path| path.is_file())
.take(MAX_OPENED_ARGS)
.collect()
}
fn bring_forward(app: &AppHandle) {
if let Some(window) = app.get_window(policy::MAIN_LABEL) {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
}
}
/// A `waybetter://` link reached the app: validate it, queue it for the shell and bring the window
/// forward. Nothing is downloaded or installed until the shell's screen asks for it.
fn received(app: &AppHandle, raw: &str) {
let Some(store) = app.try_state::<Store>() else { return };
emit_shell(app, EVENT_LINK, store.receive_link(raw));
if let Some(window) = app.get_window(policy::MAIN_LABEL) {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
}
// macOS hands a double-clicked file over as an open-URL event with a `file://` URL.
let event = match url::Url::parse(raw).ok().filter(|url| url.scheme() == "file") {
Some(url) => match url.to_file_path() {
Ok(path) => store.receive_file(&path),
Err(()) => store.receive_file(Path::new("")),
},
None => store.receive_link(raw),
};
emit_shell(app, EVENT_LINK, event);
bring_forward(app);
}
fn store(app: &AppHandle) -> Result<State<'_, Store>, String> {
@ -83,6 +129,15 @@ pub(crate) async fn install_stage_file(webview: Webview, app: AppHandle, name: S
store(&app)?.stage_file(&name, text)
}
/// A file the OS handed to the app (a double-click or "Open with"), asked for by the random id the
/// `install-link` event carried: native code reads it and stages it exactly like a dropped file,
/// for the same install screen. The shell never supplies a path; an unknown id is refused.
#[tauri::command]
pub(crate) async fn install_stage_opened(webview: Webview, app: AppHandle, id: String) -> Result<InstallPreview, String> {
shell_only(&webview)?;
store(&app)?.stage_opened(&id)
}
#[tauri::command]
pub(crate) async fn install_cancel(webview: Webview, app: AppHandle, token: String) -> Result<(), String> {
shell_only(&webview)?;
@ -189,6 +244,7 @@ pub(crate) fn handler() -> impl Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Sen
install_pending,
install_stage_link,
install_stage_file,
install_stage_opened,
install_cancel,
install_commit,
plugin_remove,
@ -203,3 +259,30 @@ pub(crate) fn handler() -> impl Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Sen
strings_apply
]
}
#[cfg(test)]
mod tests {
use super::*;
fn args(list: &[&str]) -> Vec<String> {
list.iter().map(|arg| (*arg).to_owned()).collect()
}
#[test]
fn launch_arguments_name_files_never_flags_or_links() {
let dir = std::env::temp_dir().join(format!("wb-opened-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let theme = dir.join("nord.json");
std::fs::write(&theme, "{}").unwrap();
let absolute = theme.to_string_lossy().into_owned();
let found = file_args(&args(&["wayBetter.exe", "--diag", "waybetter://install?kind=theme&url=https://waybetter.fyi/a.json", "file:///x.json", "missing.json", &absolute]), "");
assert_eq!(found, [theme.clone()], "the program, flags, links and missing files are skipped");
// A second launch's relative name is resolved against its own working directory.
let found = file_args(&args(&["wayBetter.exe", "nord.json"]), &dir.to_string_lossy());
assert_eq!(found, [dir.join("nord.json")]);
assert!(file_args(&args(&["wayBetter.exe", &dir.to_string_lossy()]), "").is_empty(), "a folder is not a file");
let many: Vec<String> = std::iter::once("wayBetter.exe".to_owned()).chain((0..20).map(|_| absolute.clone())).collect();
assert_eq!(file_args(&many, "").len(), MAX_OPENED_ARGS);
std::fs::remove_dir_all(&dir).unwrap();
}
}

View file

@ -217,6 +217,11 @@ pub fn assert_store_guards() {
for command in commands::STORE {
assert_guarded(source, command, "shell_only(&webview)?");
}
// A file the OS handed over is asked for by its random id: the command takes no path, so the
// shell cannot make native code read a file of its own choosing.
let at = source.find("fn install_stage_opened(").expect("install_stage_opened exists");
let signature = &source[at..at + source[at..].find(')').expect("signature")];
assert!(signature.contains("id: String") && !signature.contains("path") && !signature.contains("PathBuf"), "install_stage_opened takes an id only: {signature}");
}
/// The media commands' wrappers check their caller first. Runs against the host's source.