collective

Browse / github.com/storytold/photocraft / patch

Plugins: Browse Plugins and Manage Plugins over a GitHub plug-in catalog

Plugins: Browse Plugins and Manage Plugins over a GitHub plug-in catalog ## What Photoshop's top-level **Plugins** menu (between View and Window, as in Photoshop 2026) with **Browse Plugins…** and **Manage Plugins…**. Both open a window over a catalog of community WebAssembly filter plug-ins published on GitHub: - grouped by GitHub project, each with a "Source on GitHub" link - search, and Browse / Installed tabs - Install / Update / Uninstall / Retry per row, and tick several for **Install Selected** - downloads run in the background; a failure shows its reason on the row Installed plug-ins show up under Filter › Plug-ins right away and load again at every star

Status
open
Score
0.00
Adopters
0 other operators
Author
speedyrulz (imported from pull request #9718, open)
Base
52f338e2df44ac73c0bf231fa8d3f8cd496641ec
Size
+1858 −11 in 32 files
Review
pending
Shared
2026-10-28
Id
1178bd6889d3b403eebd1203

How it went

No other operator has reported using it yet.

Diff

raw · Cargo.lock apps/photocraft/src/main.rs apps/photocraft/src/plugin_downloads.rs apps/photocraft/src/services.rs crates/automation/src/workspace.rs crates/automation/src/workspace/tests.rs crates/engine/Cargo.toml crates/engine/src/cmd.rs crates/engine/src/cmd/plugin_store_cmds.rs crates/engine/src/cmd/plugin_store_cmds/catalog.json crates/engine/src/cmd/plugin_store_cmds/tests.rs crates/engine/src/lib.rs crates/engine/src/shell_commands.rs crates/ui-egui/src/app.rs crates/ui-egui/src/app/frame.rs crates/ui-egui/src/lib.rs crates/ui-egui/src/menu_catalog.rs crates/ui-egui/src/menus.rs crates/ui-egui/src/menus/invoke.rs crates/ui-egui/src/menus/menu_keys.rs crates/ui-egui/src/menus/menu_keys/harness_tests.rs crates/ui-egui/src/menus/native_menu.rs crates/ui-egui/src/menus/native_menu/tests.rs crates/ui-egui/src/menus/plugin_store_ui.rs crates/ui-egui/src/menus/plugin_store_ui_tests.rs crates/ui-egui/src/menus/state.rs crates/ui-egui/src/services.rs crates/ui-egui/src/state.rs docs/control-protocol.md docs/gaps.md docs/parity-checklist.md docs/plugins.md

diff --git a/Cargo.lock b/Cargo.lock--- a/Cargo.lock+++ b/Cargo.lock@@ -5177,6 +5177,7 @@ dependencies = [  "semver",  "serde",  "serde_json",+ "sha2",  "tempfile",  "thiserror 2.0.21",  "wat",diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs--- a/apps/photocraft/src/main.rs+++ b/apps/photocraft/src/main.rs@@ -54,6 +54,7 @@ mod linux_libs; mod logging; mod monitor_profile; mod photoshop_settings;+mod plugin_downloads; // Save As through the desktop portal, which reports the file type picked (#6121). #[cfg(target_os = "linux")] mod portal_save;diff --git a/apps/photocraft/src/plugin_downloads.rs b/apps/photocraft/src/plugin_downloads.rsnew file mode 100644--- /dev/null+++ b/apps/photocraft/src/plugin_downloads.rs@@ -0,0 +1,27 @@+//! Plugins › Browse Plugins… downloads through `craft-update`'s HTTPS client, one background+//! thread per module. The engine checks what arrives against the catalog's size and SHA-256.++use photocraft_engine::plugin_store_cmds::{FetchFn, FetchResult};++pub fn service() -> FetchFn {+    Box::new(|url: &str, max: usize| {+        let url = url.to_string();+        let (tx, rx) = std::sync::mpsc::channel();+        std::thread::Builder::new()+            .name("plug-in download".into())+            .spawn(move || {+                let result = fetch(&url, max);+                if let Err(e) = &result {+                    log::warn!("Plug-in download {url}: {e}");+                }+                let _ = tx.send(result);+            })+            .map_err(|e| format!("Could not start the download: {e}"))?;+        Ok(rx)+    })+}++fn fetch(url: &str, max: usize) -> FetchResult {+    let http = craft_update::Http::new(&format!("PhotoCraft/{}", photocraft_engine::build_info::VERSION))?;+    http.get_bytes(url, max)?.ok_or_else(|| format!("{url} was not found"))+}diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs--- a/apps/photocraft/src/services.rs+++ b/apps/photocraft/src/services.rs@@ -705,6 +705,8 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->         open_url: Some(Box::new(|url: &str| open::that(url).map_err(|e| e.to_string()))),         beep: Some(platform_beep),         check_updates: update_transport(std::env::var_os("FLATPAK_ID").as_deref()),+        fetch_plugin: Some(crate::plugin_downloads::service()),+        plugin_dir: config_dir().map(|dir| dir.join("Plug-ins")),         clipboard_set_image: Some({             let clip = clip.clone();             guarded_clipboard_set(move |w: u32, h: u32, px: &[u8]| {diff --git a/crates/automation/src/workspace.rs b/crates/automation/src/workspace.rs--- a/crates/automation/src/workspace.rs+++ b/crates/automation/src/workspace.rs@@ -445,7 +445,8 @@ fn command_uses_ambient_path(id: &str, params: &Value) -> bool {         "edit.keyboardShortcuts" => {             params.get("path").is_some() || ["importKys", "exportKys", "summarize"].iter().any(|k| params.get(*k).and_then(|v| v.get("path")).is_some())         }-        "plugin.reload" => true,+        // The store downloads into, and deletes from, the settings directory's Plug-ins folder.+        "plugin.reload" | "plugin.store.install" | "plugin.store.uninstall" => true,         _ => false,     } }diff --git a/crates/automation/src/workspace/tests.rs b/crates/automation/src/workspace/tests.rs--- a/crates/automation/src/workspace/tests.rs+++ b/crates/automation/src/workspace/tests.rs@@ -425,6 +425,8 @@ fn automation_rejects_ambient_path_commands_and_preferences() {         ("plugin.reload", serde_json::json!({"path": "/outside/plugins"})),         ("plugin.reload", serde_json::json!({})),         ("plugin.install", serde_json::json!({"path": " "})),+        ("plugin.store.install", serde_json::json!({"id": "io.github.mitacore.posterize"})),+        ("plugin.store.uninstall", serde_json::json!({"id": "io.github.mitacore.posterize"})),         ("swatches.import", serde_json::json!({"path": "/outside/set.aco"})),         ("swatches.export", serde_json::json!({"path": "/outside/set.ase"})),         ("actions.load", serde_json::json!({"path": "/outside/set.atn"})),@@ -442,6 +444,7 @@ fn automation_rejects_ambient_path_commands_and_preferences() {         ("brush.presets.importImage", serde_json::json!({"data": "QUJD"})),         ("gradient.presets.importGrd", serde_json::json!({"data": "QUJD"})),         ("plugin.install", serde_json::json!({"data": "QUJD"})),+        ("plugin.store.list", serde_json::json!({})),         ("swatches.import", serde_json::json!({"data": "QUJD"})),         ("swatches.export", serde_json::json!({"format": "ase"})),         ("actions.load", serde_json::json!({"data": "QUJD"})),diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml--- a/crates/engine/Cargo.toml+++ b/crates/engine/Cargo.toml@@ -41,6 +41,9 @@ craft-print = { workspace = true } roxmltree = "0.21" # Release tags use the same semantic versions as Cargo. semver = "1"+# Plug-in catalog downloads are checked against their published SHA-256 (already in the tree+# through craft-update).+sha2 = "0.10"  [target.'cfg(not(target_arch = "wasm32"))'.dependencies] tempfile = "3"diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs--- a/crates/engine/src/cmd.rs+++ b/crates/engine/src/cmd.rs@@ -121,6 +121,7 @@ pub mod perspective_crop_cmds; pub mod photo_cmds; pub mod pick_cmds; pub mod plugin_cmds;+pub mod plugin_store_cmds; pub mod preset_import_cmds; pub mod print_cmds; pub mod rasterize_style_cmds;@@ -1321,6 +1322,7 @@ fn build() -> Vec<CommandSpec> {     v.extend(crate::rasterize_style_cmds::specs());     v.extend(crate::variables_cmds::specs());     v.extend(crate::plugin_cmds::specs());+    v.extend(crate::plugin_store_cmds::specs());     v.extend(crate::group_view_cmds::specs());     v.extend(crate::fx_view_cmds::specs());     v.extend(crate::fx_visibility_cmds::specs());diff --git a/crates/engine/src/cmd/plugin_store_cmds.rs b/crates/engine/src/cmd/plugin_store_cmds.rsnew file mode 100644--- /dev/null+++ b/crates/engine/src/cmd/plugin_store_cmds.rs@@ -0,0 +1,442 @@+//! `plugin.store.*` commands: Plugins › Browse Plugins…, a curated catalog of WebAssembly filter+//! plug-ins published on GitHub.+//!+//! The catalog (`plugin_store_cmds/catalog.json`) is bundled and pins every module by URL, size and+//! SHA-256, so a changed or substituted download is refused rather than installed. Downloads go+//! through a transport the frontend injects ([`FetchFn`]; the engine does no network I/O). A+//! verified module is installed in the process-wide registry and, when the frontend gave a folder+//! ([`PluginStore::set_dir`]), saved there as `<id>.wasm` so it loads again at the next start.+//! Installed modules still run in the plug-in sandbox (`photocraft-plugins`): no imports, capped+//! memory, fuel and time.++use std::collections::BTreeMap;+use std::sync::OnceLock;+use std::sync::mpsc::{Receiver, TryRecvError};++use photocraft_plugins::{Limits, Plugin, registry};+use serde::{Deserialize, Serialize};+use serde_json::{Value, json};+use sha2::{Digest, Sha256};++use crate::commands::{CommandSpec, always};+use crate::{EngineError, Result, Session};++pub type FetchResult = std::result::Result<Vec<u8>, String>;+/// Starts downloading `url` (refusing more than `max` bytes) and returns at once.+pub type FetchFn = Box<dyn FnMut(&str, usize) -> std::result::Result<Receiver<FetchResult>, String> + Send>;++/// Hosts a catalog URL may point at.+const HOSTS: &[&str] = &["github.com", "raw.githubusercontent.com", "akkk09.github.io"];+/// Downloads running at once; more wait their turn.+const MAX_RUNNING: usize = 4;++/// One catalog listing.+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]+pub struct Entry {+    pub id: String,+    pub name: String,+    pub version: String,+    pub author: String,+    pub description: String,+    /// The GitHub project it comes from (one collection per repository).+    pub collection: String,+    pub repo: String,+    pub license: String,+    pub url: String,+    pub sha256: String,+    pub size: usize,+}++#[derive(Deserialize)]+struct Catalog {+    plugins: Vec<Entry>,+}++/// Why an entry can't be offered, if it can't: a bad URL, checksum or size.+fn check_entry(e: &Entry) -> std::result::Result<(), String> {+    let rest = e.url.strip_prefix("https://").ok_or("not an https URL")?;+    let host = rest.split('/').next().unwrap_or_default();+    if !HOSTS.contains(&host) {+        return Err(format!("{host} is not an allowed host"));+    }+    if e.sha256.len() != 64 || !e.sha256.bytes().all(|b| b.is_ascii_hexdigit()) {+        return Err("bad sha256".into());+    }+    if e.size == 0 || e.size > Limits::default().max_module_bytes {+        return Err("bad size".into());+    }+    Ok(())+}++/// The bundled catalog, listings that fail [`check_entry`] left out.+pub fn bundled() -> &'static [Entry] {+    static CATALOG: OnceLock<Vec<Entry>> = OnceLock::new();+    CATALOG.get_or_init(|| {+        serde_json::from_str::<Catalog>(include_str!("plugin_store_cmds/catalog.json"))+            .map(|c| c.plugins.into_iter().filter(|e| check_entry(e).is_ok()).collect())+            .unwrap_or_default()+    })+}++/// Where a store download stands.+#[derive(Clone, Debug, PartialEq, Eq, Serialize)]+#[serde(tag = "state", rename_all = "camelCase")]+pub enum Job {+    Queued,+    Downloading,+    Failed { message: String },+}++/// Store state on the session: the catalog, the transport and the downloads in flight.+pub struct PluginStore {+    pub fetch: Option<FetchFn>,+    pub(crate) catalog: Vec<Entry>,+    #[cfg(not(target_arch = "wasm32"))]+    dir: Option<std::path::PathBuf>,+    jobs: BTreeMap<String, Job>,+    running: Vec<Running>,+}++/// A download in flight. One uninstalled while it runs is `cancelled`: it keeps its slot until+/// the transport answers (the transport can't be stopped; it has its own timeout), so uninstall+/// and install again can't run more than [`MAX_RUNNING`] at once, and its result is dropped.+struct Running {+    id: String,+    rx: Receiver<FetchResult>,+    cancelled: bool,+}++impl Default for PluginStore {+    fn default() -> Self {+        Self {+            fetch: None,+            catalog: bundled().to_vec(),+            #[cfg(not(target_arch = "wasm32"))]+            dir: None,+            jobs: BTreeMap::new(),+            running: Vec::new(),+        }+    }+}++impl PluginStore {+    /// Saves installs to `dir` from now on and installs the `*.wasm` already there. Native only;+    /// the web keeps installs for the session.+    #[cfg(not(target_arch = "wasm32"))]+    pub fn set_dir(&mut self, dir: std::path::PathBuf) -> Value {+        let report = if dir.is_dir() {+            match registry::load_folder(&dir) {+                Ok(r) => json!({"loaded": r.loaded, "failed": r.failed.iter().map(|(f, e)| json!({"file": f, "error": e})).collect::<Vec<_>>()}),+                Err(e) => json!({"error": e.to_string()}),+            }+        } else {+            json!({"loaded": []})+        };+        self.dir = Some(dir);+        report+    }++    pub fn catalog(&self) -> &[Entry] {+        &self.catalog+    }++    /// Replaces the catalog (tests, mirrors); listings that fail validation are left out.+    pub fn set_catalog(&mut self, entries: Vec<Entry>) {+        self.catalog = entries.into_iter().filter(|e| check_entry(e).is_ok()).collect();+    }++    /// Whether this frontend can download plug-ins.+    pub fn available(&self) -> bool {+        self.fetch.is_some()+    }++    pub fn job(&self, id: &str) -> Option<&Job> {+        self.jobs.get(id)+    }++    /// Whether a download is queued or running.+    pub fn busy(&self) -> bool {+        self.jobs.values().any(|j| matches!(j, Job::Queued | Job::Downloading))+    }++    fn entry(&self, id: &str) -> Option<&Entry> {+        self.catalog.iter().find(|e| e.id == id)+    }++    /// Finishes completed downloads and starts queued ones. True when something changed.+    pub fn poll(&mut self) -> bool {+        let mut changed = false;+        let mut k = 0;+        while let Some(Running { id, rx, cancelled }) = self.running.get(k) {+            let result = match rx.try_recv() {+                Ok(r) => r,+                Err(TryRecvError::Empty) => {+                    k += 1;+                    continue;+                }+                Err(TryRecvError::Disconnected) => Err("the download stopped before it finished".into()),+            };+            let (id, cancelled) = (id.clone(), *cancelled);+            self.running.remove(k);+            if cancelled {+                continue;+            }+            let done = match self.entry(&id).cloned() {+                Some(entry) => result.and_then(|bytes| self.finish(&entry, &bytes)),+                None => Err("no longer in the catalog".into()),+            };+            match done {+                Ok(()) => self.jobs.remove(&id),+                Err(message) => self.jobs.insert(id, Job::Failed { message }),+            };+            changed = true;+        }+        changed | self.start_queued()+    }++    fn start_queued(&mut self) -> bool {+        let mut changed = false;+        let queued: Vec<String> = self.jobs.iter().filter(|(_, j)| **j == Job::Queued).map(|(id, _)| id.clone()).collect();+        for id in queued {+            if self.running.len() >= MAX_RUNNING {+                break;+            }+            let Some(entry) = self.entry(&id).cloned() else {+                self.jobs.insert(id, Job::Failed { message: "no longer in the catalog".into() });+                continue;+            };+            let started = match self.fetch.as_mut() {+                Some(fetch) => fetch(&entry.url, entry.size),+                None => Err(UNAVAILABLE.into()),+            };+            match started {+                Ok(rx) => {+                    self.running.push(Running { id: id.clone(), rx, cancelled: false });+                    self.jobs.insert(id, Job::Downloading);+                }+                Err(message) => {+                    self.jobs.insert(id, Job::Failed { message });+                }+            }+            changed = true;+        }+        changed+    }++    /// Verifies downloaded bytes against the catalog, installs the module and saves it.+    fn finish(&self, entry: &Entry, bytes: &[u8]) -> std::result::Result<(), String> {+        let plugin = verify(entry, bytes)?;+        let plugin = match self.save(entry, bytes)? {+            Some(path) => plugin.with_source(path),+            None => plugin,+        };+        registry::install(plugin);+        Ok(())+    }++    #[cfg(not(target_arch = "wasm32"))]+    fn save(&self, entry: &Entry, bytes: &[u8]) -> std::result::Result<Option<String>, String> {+        let Some(dir) = &self.dir else { return Ok(None) };+        std::fs::create_dir_all(dir).map_err(|e| format!("couldn't create {} ({e})", dir.display()))?;+        let path = file_for(dir, &entry.id);+        let mut tmp = tempfile::NamedTempFile::new_in(dir).map_err(|e| format!("couldn't write in {} ({e})", dir.display()))?;+        std::io::Write::write_all(&mut tmp, bytes).map_err(|e| format!("couldn't write {} ({e})", path.display()))?;+        tmp.persist(&path).map_err(|e| format!("couldn't save {} ({e})", path.display()))?;+        Ok(Some(path.display().to_string()))+    }++    #[cfg(target_arch = "wasm32")]+    fn save(&self, _: &Entry, _: &[u8]) -> std::result::Result<Option<String>, String> {+        Ok(None)+    }++    /// Deletes the saved copy of `id`, if this store saved one.+    #[cfg(not(target_arch = "wasm32"))]+    fn delete(&self, id: &str) -> std::result::Result<bool, String> {+        let Some(dir) = &self.dir else { return Ok(false) };+        let path = file_for(dir, id);+        match std::fs::remove_file(&path) {+            Ok(()) => Ok(true),+            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),+            Err(e) => Err(format!("couldn't delete {} ({e})", path.display())),+        }+    }++    #[cfg(target_arch = "wasm32")]+    fn delete(&self, _: &str) -> std::result::Result<bool, String> {+        Ok(false)+    }+}++/// `<dir>/<id>.wasm`. Manifest ids are `[A-Za-z0-9._-]`, so the name stays inside `dir`.+#[cfg(not(target_arch = "wasm32"))]+fn file_for(dir: &std::path::Path, id: &str) -> std::path::PathBuf {+    dir.join(format!("{id}.wasm"))+}++const UNAVAILABLE: &str = "Downloading plug-ins is not available in this frontend";++fn hex(bytes: &[u8]) -> String {+    bytes.iter().map(|b| format!("{b:02x}")).collect()+}++/// The module in `bytes`, if it is exactly the one the catalog lists.+pub fn verify(entry: &Entry, bytes: &[u8]) -> std::result::Result<Plugin, String> {+    if bytes.len() != entry.size {+        return Err(format!("the download is {} bytes, the catalog says {}", bytes.len(), entry.size));+    }+    let sum = hex(&Sha256::digest(bytes));+    if !sum.eq_ignore_ascii_case(&entry.sha256) {+        return Err("the download doesn't match the catalog's checksum (it changed since the catalog was made)".into());+    }+    let plugin = Plugin::load(bytes, Limits::default()).map_err(|e| e.to_string())?;+    if plugin.id() != entry.id {+        return Err(format!("the module calls itself {:?}, not {:?}", plugin.id(), entry.id));+    }+    // Otherwise it would install and then be offered as an update to itself, again and again.+    if plugin.manifest().version != entry.version {+        return Err(format!("the module is version {:?}, the catalog says {:?}", plugin.manifest().version, entry.version));+    }+    Ok(plugin)+}++fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {+    EngineError::BadParams { cmd: cmd.into(), msg: msg.into() }+}++/// A listing with its install state.+fn describe(store: &PluginStore, e: &Entry) -> Value {+    let installed = registry::get(&e.id).map(|p| p.manifest().version.clone());+    let mut v = json!({+        "id": e.id, "name": e.name, "version": e.version, "author": e.author, "description": e.description,+        "collection": e.collection, "repo": e.repo, "license": e.license, "size": e.size,+        "installed": installed.is_some(), "installedVersion": installed,+        "updateAvailable": installed.as_ref().is_some_and(|v| *v != e.version),+    });+    if let (Some(job), Some(o)) = (store.jobs.get(&e.id), v.as_object_mut()) {+        o.insert("job".into(), json!(job));+    }+    v+}++fn matches(e: &Entry, query: &str) -> bool {+    let q = query.to_lowercase();+    [&e.name, &e.description, &e.author, &e.collection, &e.id].iter().any(|f| f.to_lowercase().contains(&q))+}++fn list(s: &mut Session, p: &Value) -> Result<Value> {+    let query = match p.get("query") {+        None | Some(Value::Null) => "",+        Some(v) => v.as_str().ok_or_else(|| bad("plugin.store.list", "`query` must be text"))?,+    };+    let installed_only = p.get("installed").and_then(Value::as_bool).unwrap_or(false);+    s.plugin_store.poll();+    let store = &s.plugin_store;+    let plugins: Vec<Value> = store+        .catalog+        .iter()+        .filter(|e| matches(e, query.trim()))+        .filter(|e| !installed_only || registry::get(&e.id).is_some())+        .map(|e| describe(store, e))+        .collect();+    Ok(json!({"available": store.fetch.is_some(), "busy": store.busy(), "plugins": plugins}))+}++/// The ids in `id` or `ids`.+fn ids(cmd: &str, p: &Value) -> Result<Vec<String>> {+    let ids: Vec<String> = match (p.get("id"), p.get("ids")) {+        (Some(id), None) => vec![id.as_str().ok_or_else(|| bad(cmd, "`id` must be text"))?.to_string()],+        (None, Some(Value::Array(a))) => a.iter().map(|v| v.as_str().map(str::to_string).ok_or_else(|| bad(cmd, "`ids` must be a list of text"))).collect::<Result<_>>()?,+        (None, Some(_)) => return Err(bad(cmd, "`ids` must be a list of text")),+        _ => return Err(bad(cmd, "give `id` or `ids`")),+    };+    if ids.is_empty() {+        return Err(bad(cmd, "`ids` is empty"));+    }+    Ok(ids)+}++fn install(s: &mut Session, p: &Value) -> Result<Value> {+    const CMD: &str = "plugin.store.install";+    let ids = ids(CMD, p)?;+    let reinstall = p.get("reinstall").and_then(Value::as_bool).unwrap_or(false);+    let store = &mut s.plugin_store;+    if let Some(unknown) = ids.iter().find(|id| store.entry(id).is_none()) {+        return Err(bad(CMD, format!("{unknown:?} is not in the plug-in catalog (see plugin.store.list)")));+    }+    if store.fetch.is_none() {+        return Err(EngineError::Other(UNAVAILABLE.into()));+    }+    for id in &ids {+        let current = store.entry(id).map(|e| e.version.clone());+        let up_to_date = registry::get(id).is_some_and(|p| Some(&p.manifest().version) == current.as_ref());+        let busy = matches!(store.jobs.get(id), Some(Job::Queued | Job::Downloading));+        if !busy && (reinstall || !up_to_date) {+            store.jobs.insert(id.clone(), Job::Queued);+        }+    }+    store.poll();+    let store = &s.plugin_store;+    Ok(json!({"plugins": ids.iter().filter_map(|id| store.entry(id)).map(|e| describe(store, e)).collect::<Vec<_>>()}))+}++fn uninstall(s: &mut Session, p: &Value) -> Result<Value> {+    const CMD: &str = "plugin.store.uninstall";+    let ids = ids(CMD, p)?;+    let store = &mut s.plugin_store;+    if let Some(unknown) = ids.iter().find(|id| store.entry(id).is_none()) {+        return Err(bad(CMD, format!("{unknown:?} is not in the plug-in catalog")));+    }+    let mut removed = Vec::new();+    for id in &ids {+        let deleted = store.delete(id).map_err(EngineError::Other)?;+        // A queued or running download would otherwise install it again when it finishes.+        store.jobs.remove(id);+        for r in store.running.iter_mut().filter(|r| r.id == *id) {+            r.cancelled = true;+        }+        if registry::remove(id) || deleted {+            removed.push(id.clone());+        }+    }+    Ok(json!({"removed": removed}))+}++pub fn specs() -> Vec<CommandSpec> {+    vec![+        CommandSpec {+            id: "plugin.store.list",+            label: "Plug-in Catalog",+            menu: &[],+            shortcut: None,+            params: r##"{"query":text,"installed":bool=false} (listings from the curated GitHub catalog, with install state)"##,+            enabled: always,+            run: list,+            journal: false,+        },+        CommandSpec {+            id: "plugin.store.install",+            label: "Install Plug-in from Catalog",+            menu: &[],+            shortcut: None,+            params: r##"{"id":text,"ids":json,"reinstall":bool=false} (downloads in the background; poll plugin.store.list)"##,+            enabled: always,+            run: install,+            journal: false,+        },+        CommandSpec {+            id: "plugin.store.uninstall",+            label: "Uninstall Catalog Plug-in",+            menu: &[],+            shortcut: None,+            params: r##"{"id":text,"ids":json}"##,+            enabled: always,+            run: uninstall,+            journal: false,+        },+    ]+}++#[cfg(test)]+#[path = "plugin_store_cmds/tests.rs"]+mod tests;diff --git a/crates/engine/src/cmd/plugin_store_cmds/catalog.json b/crates/engine/src/cmd/plugin_store_cmds/catalog.jsonnew file mode 100644--- /dev/null+++ b/crates/engine/src/cmd/plugin_store_cmds/catalog.json@@ -0,0 +1,604 @@+{+ "version": 1,+ "updated": "2026-10-26",+ "plugins": [+  {+   "id": "org.photocraft.community.color-toolkit",+   "name": "Color Toolkit",+   "version": "0.1.0",+   "author": "ArtCraft Store community",+   "description": "Adjusts exposure, contrast, and saturation while preserving alpha.",+   "collection": "ArtCraft Store",+   "repo": "https://github.com/akkk09/artcraft-store",+   "license": "MIT",+   "url": "https://akkk09.github.io/artcraft-store/downloads/photocraft_plugin_color_toolkit.wasm",+   "sha256": "fdac9ce5d5ca02cd5b7eec8073eceb6e6f0514d075f2e486e291733557308355",+   "size": 3000+  },+  {+   "id": "org.photocraft.community.detail-recovery",+   "name": "Detail Recovery",+   "version": "0.1.0",+   "author": "ArtCraft Store community",+   "description": "Reduces small noise and restores local detail with lightweight spatial filtering.",+   "collection": "ArtCraft Store",+   "repo": "https://github.com/akkk09/artcraft-store",+   "license": "MIT",+   "url": "https://akkk09.github.io/artcraft-store/downloads/photocraft_plugin_detail_recovery.wasm",+   "sha256": "f4ed43fd7f36f728dd36e8f242eae7c51d9478c89af8cb4ef7789e3463bef16d",+   "size": 3262+  },+  {+   "id": "org.photocraft.community.film-emulation",+   "name": "Film Emulation Toolkit",+   "version": "0.1.0",+   "author": "ArtCraft Store community",+   "description": "Applies compact film-inspired color looks with deterministic grain.",+   "collection": "ArtCraft Store",+   "repo": "https://github.com/akkk09/artcraft-store",+   "license": "MIT",+   "url": "https://akkk09.github.io/artcraft-store/downloads/photocraft_plugin_film_emulation.wasm",+   "sha256": "6ce4158b1b07951ea4da2b8021f365f32b3f279430a80930fe29e31e1c1c55b1",+   "size": 4278+  },+  {+   "id": "org.photocraft.community.geometry-fixer",+   "name": "Geometry Fixer",+   "version": "0.1.0",+   "author": "ArtCraft Store community",+   "description": "Straightens rotation and corrects horizontal or vertical perspective while keeping the original canvas size.",+   "collection": "ArtCraft Store",+   "repo": "https://github.com/akkk09/artcraft-store",+   "license": "MIT",+   "url": "https://akkk09.github.io/artcraft-store/downloads/photocraft_plugin_geometry_fixer.wasm",+   "sha256": "10995731f2c500b31fba24bbefb17dce67bda2b9883d63100ab8bf399a2b9bf4",+   "size": 4114+  },+  {+   "id": "org.photocraft.community.gradient-map",+   "name": "Gradient Map & Duotone",+   "version": "0.1.0",+   "author": "ArtCraft Store community",+   "description": "Maps image luminance to a selectable two-colour gradient while preserving alpha.",+   "collection": "ArtCraft Store",+   "repo": "https://github.com/akkk09/artcraft-store",+   "license": "MIT",+   "url": "https://akkk09.github.io/artcraft-store/downloads/photocraft_plugin_gradient_map.wasm",+   "sha256": "065da12df5ef8a13d08dde3d0275a457e71b64ccf2d45e885e64d7d205758be8",+   "size": 3604+  },+  {+   "id": "org.photocraft.community.lut-studio",+   "name": "LUT Studio",+   "version": "0.1.0",+   "author": "ArtCraft Store community",+   "description": "Applies bundled 3D color lookup tables with adjustable intensity.",+   "collection": "ArtCraft Store",+   "repo": "https://github.com/akkk09/artcraft-store",+   "license": "MIT",+   "url": "https://akkk09.github.io/artcraft-store/downloads/photocraft_plugin_lut_studio.wasm",+   "sha256": "6d93d1272524d5e8cc9978e6ce04d6dd48d6de6793a7fbd1a0a8ad334c190fe9",+   "size": 299044+  },+  {+   "id": "org.photocraft.community.mixer-blend",+   "name": "Mixer Blend",+   "version": "0.1.0",+   "author": "PhotoCraft Plugin Store",+   "description": "A wet-paint-inspired colour mixing filter with pickup and wetness controls. This is not an interactive brush.",+   "collection": "ArtCraft Store",+   "repo": "https://github.com/akkk09/artcraft-store",+   "license": "MIT",+   "url": "https://akkk09.github.io/artcraft-store/downloads/photocraft_plugin_mixer_blend.wasm",+   "sha256": "32f0e9d71f75356282e05c253cb092270cb151d3af44128a41f5ddaa11c6374f",+   "size": 3068+  },+  {+   "id": "org.photocraft.community.mosaic-pixelate",+   "name": "Mosaic Pixelate",+   "version": "0.1.0",+   "author": "ArtCraft Store community",+   "description": "Creates block-based pixelation with adjustable block size and intensity, preserving alpha.",+   "collection": "ArtCraft Store",+   "repo": "https://github.com/akkk09/artcraft-store",+   "license": "MIT",+   "url": "https://akkk09.github.io/artcraft-store/downloads/photocraft_plugin_mosaic_pixelate.wasm",+   "sha256": "dadb5945ca8a1050775bb1fb6b07dbdca3f36e95342d64c73517f2d4ddf59c5f",+   "size": 3525+  },+  {+   "id": "org.photocraft.community.seamless-pattern-generator",+   "name": "Seamless Pattern Generator",+   "version": "0.1.0",+   "author": "ArtCraft Store community",+   "description": "Reduces visible tiling seams by smoothly blending opposing edges.",+   "collection": "ArtCraft Store",+   "repo": "https://github.com/akkk09/artcraft-store",+   "license": "MIT",+   "url": "https://akkk09.github.io/artcraft-store/downloads/photocraft_plugin_seamless_pattern_generator.wasm",+   "sha256": "bbafeec9027554680e877ccecbabd98868e8bc89946451d2fcf9847b0dfb9663",+   "size": 3951+  },+  {+   "id": "org.photocraft.community.smudge-blend",+   "name": "Smudge Blend",+   "version": "0.1.0",+   "author": "PhotoCraft Plugin Store",+   "description": "A directional smudge-inspired filter for the active layer. This is a whole-layer filter, not a cursor-driven brush.",+   "collection": "ArtCraft Store",+   "repo": "https://github.com/akkk09/artcraft-store",+   "license": "MIT",+   "url": "https://akkk09.github.io/artcraft-store/downloads/photocraft_plugin_smudge_blend.wasm",+   "sha256": "9e1fa1871353536520a3ed41a135ba1f28c4710fcec62564cdd5aeded08986ad",+   "size": 3331+  },+  {+   "id": "org.photocraft.community.vignette",+   "name": "Vignette",+   "version": "0.1.0",+   "author": "ArtCraft Store community",+   "description": "Adds a soft, adjustable edge darkening with a smooth radial falloff while preserving alpha.",+   "collection": "ArtCraft Store",+   "repo": "https://github.com/akkk09/artcraft-store",+   "license": "MIT",+   "url": "https://akkk09.github.io/artcraft-store/downloads/photocraft_plugin_vignette.wasm",+   "sha256": "d2477d3a2ef9028cc36cafb6cf681459a9e3b11b586ca35d912145423aa554ff",+   "size": 3019+  },+  {+   "id": "org.photocraft.community.white-balance",+   "name": "White Balance",+   "version": "0.1.0",+   "author": "ArtCraft Store community",+   "description": "Corrects warm/cool color casts with temperature and tint controls.",+   "collection": "ArtCraft Store",+   "repo": "https://github.com/akkk09/artcraft-store",+   "license": "MIT",+   "url": "https://akkk09.github.io/artcraft-store/downloads/photocraft_plugin_white_balance.wasm",+   "sha256": "7544f3848374aa1ef7a02f98cede3c44885b570894b2264933029458a4745c96",+   "size": 2589+  },+  {+   "id": "org.photocraft.filter.comic_book_cel_shading",+   "name": "Comic Book & Cel Shading",+   "version": "1.0.0",+   "author": "CHJ85",+   "description": "Transforms images into graphic novel illustrations with bold ink outlines and cel shading.",+   "collection": "CHJ85 PhotoCraft-Plugins",+   "repo": "https://github.com/CHJ85/PhotoCraft-Plugins",+   "license": "MIT",+   "url": "https://raw.githubusercontent.com/CHJ85/PhotoCraft-Plugins/95cc0420ff39ad108e71b0c0b090f2fad0cd77f0/photocraft_plugin_comic_book_cel_shading.wasm",+   "sha256": "28498c5095eeac7d1a0e92aa8aee528d9c6876108b81d7e3f2225fc3c1b73f64",+   "size": 3015+  },+  {+   "id": "org.photocraft.filter.comic_book",+   "name": "Comic Book & Graphic Novel FX",+   "version": "1.0.0",+   "author": "CHJ85",+   "description": "Converts photos into graphic novel illustration style with ink outlines and color posterization.",+   "collection": "CHJ85 PhotoCraft-Plugins",+   "repo": "https://github.com/CHJ85/PhotoCraft-Plugins",+   "license": "MIT",+   "url": "https://raw.githubusercontent.com/CHJ85/PhotoCraft-Plugins/95cc0420ff39ad108e71b0c0b090f2fad0cd77f0/photocraft_plugin_comic_book_fx.wasm",+   "sha256": "267eb3634dcf775d96945875cd60bd0f1a3a999cff27695c1c4e1cf34cbeb2fe",+   "size": 2946+  },+  {+   "id": "org.photocraft.filter.crt_bloom",+   "name": "CRT Glow & Scanlines",+   "version": "1.0.0",+   "author": "CHJ85",+   "description": "Combines retro CRT scanlines, phosphor triads, and luminance bloom glow.",+   "collection": "CHJ85 PhotoCraft-Plugins",+   "repo": "https://github.com/CHJ85/PhotoCraft-Plugins",+   "license": "MIT",+   "url": "https://raw.githubusercontent.com/CHJ85/PhotoCraft-Plugins/95cc0420ff39ad108e71b0c0b090f2fad0cd77f0/photocraft_plugin_crt_bloom.wasm",+   "sha256": "5ada6984e83cd5374f7bee001623730b6713ad020e14d55275b1fef45e5218f2",+   "size": 3617+  },+  {+   "id": "org.photocraft.filter.crt_scanlines",+   "name": "CRT Scanlines & Retro Monitor",+   "version": "1.0.0",+   "author": "CHJ85",+   "description": "Simulates retro CRT monitor scanlines, phosphor glow, and screen curvature.",+   "collection": "CHJ85 PhotoCraft-Plugins",+   "repo": "https://github.com/CHJ85/PhotoCraft-Plugins",+   "license": "MIT",+   "url": "https://raw.githubusercontent.com/CHJ85/PhotoCraft-Plugins/95cc0420ff39ad108e71b0c0b090f2fad0cd77f0/photocraft_plugin_crt_scanlines.wasm",+   "sha256": "5b42a1881fa6f2b0de1e35a5d861278eec598468a561a7e294f4312bdd09cd8d",+   "size": 2324+  },+  {+   "id": "org.photocraft.filter.crt",+   "name": "CRT Scanlines & Sub-Pixels",+   "version": "1.0.0",+   "author": "CHJ85",+   "description": "Emulates vintage CRT scanlines and RGB phosphor aperture masks.",+   "collection": "CHJ85 PhotoCraft-Plugins",+   "repo": "https://github.com/CHJ85/PhotoCraft-Plugins",+   "license": "MIT",+   "url": "https://raw.githubusercontent.com/CHJ85/PhotoCraft-Plugins/95cc0420ff39ad108e71b0c0b090f2fad0cd77f0/photocraft_plugin_crt.wasm",+   "sha256": "c18876b50350c936b4446cb49a7ced32527b97ff8f50a61e0c97e7c7137889a8",+   "size": 2135+  },+  {+   "id": "org.photocraft.filter.dehaze",+   "name": "DCP Dehaze & Clarity",+   "version": "1.0.0",+   "author": "CHJ85",+   "description": "Removes atmospheric haze and fog while boosting local contrast and clarity.",+   "collection": "CHJ85 PhotoCraft-Plugins",+   "repo": "https://github.com/CHJ85/PhotoCraft-Plugins",+   "license": "MIT",+   "url": "https://raw.githubusercontent.com/CHJ85/PhotoCraft-Plugins/95cc0420ff39ad108e71b0c0b090f2fad0cd77f0/photocraft_plugin_dehaze.wasm",+   "sha256": "9901b52ec28bbcaec69f8953f0a749591cc563f2b170d8adc0bfd41b9c11b39f",+   "size": 2640+  },+  {+   "id": "org.photocraft.filter.neural_hallucination",+   "name": "Domain Warped Neural Hallucination",+   "version": "1.0.0",+   "author": "CHJ85",+   "description": "Recursive domain warping that transforms images into surreal, AI-resembling neural dreamscapes.",+   "collection": "CHJ85 PhotoCraft-Plugins",+   "repo": "https://github.com/CHJ85/PhotoCraft-Plugins",+   "license": "MIT",+   "url": "https://raw.githubusercontent.com/CHJ85/PhotoCraft-Plugins/95cc0420ff39ad108e71b0c0b090f2fad0cd77f0/photocraft_plugin_neural_hallucination.wasm",+   "sha256": "b5a32c489c7542d92ab6e51acdf253fc0b8c232e05a547e9df6ba92bbfca335e",+   "size": 3908+  },+  {+   "id": "org.photocraft.filter.multiscale_smooth",+   "name": "Edge-Preserving Smoothing",+   "version": "1.0.0",+   "author": "CHJ85",+   "description": "Smooths flat regions and noise while preserving sharp structural edges.",+   "collection": "CHJ85 PhotoCraft-Plugins",+   "repo": "https://github.com/CHJ85/PhotoCraft-Plugins",+   "license": "MIT",+   "url": "https://raw.githubusercontent.com/CHJ85/PhotoCraft-Plugins/95cc0420ff39ad108e71b0c0b090f2fad0cd77f0/photocraft_plugin_multiscale_smooth.wasm",+   "sha256": "bcebbc14ab783e7873db59420b45ce22c688a61e35a5a1d8cc242c1929cf9d69",+   "size": 2642+  },+  {+   "id": "org.photocraft.filter.freaky_details",+   "name": "Freaky Details (Micro-Contrast)",+   "version": "1.0.0",+   "author": "CHJ85",+   "description": "Enhances micro-textures and ultra-fine local contrast across the image.",+   "collection": "CHJ85 PhotoCraft-Plugins",+   "repo": "https://github.com/CHJ85/PhotoCraft-Plugins",+   "license": "MIT",+   "url": "https://raw.githubusercontent.com/CHJ85/PhotoCraft-Plugins/95cc0420ff39ad108e71b0c0b090f2fad0cd77f0/photocraft_plugin_freaky_details.wasm",+   "sha256": "087cf08fdf0e0599cc7294051820ed963c35f6e80b0d26dcb1b17c5dccbb8e5d",+   "size": 2611+  },+  {+   "id": "org.photocraft.filter.quadtree_mosaic",+   "name": "Geometric Mosaic & Quadtree FX",+   "version": "1.0.0",+   "author": "CHJ85",+   "description": "Deconstructs images into procedural geometric mosaic blocks and data patterns.",+   "collection": "CHJ85 PhotoCraft-Plugins",+   "repo": "https://github.com/CHJ85/PhotoCraft-Plugins",+   "license": "MIT",+   "url": "https://raw.githubusercontent.com/CHJ85/PhotoCraft-Plugins/95cc0420ff39ad108e71b0c0b090f2fad0cd77f0/photocraft_plugin_quadtree_mosaic.wasm",+   "sha256": "850b9568a9731ff6a66f577f25ee453410efad7d3d37b3e5fc2aed3fe7325fc0",+   "size": 2987+  },+  {+   "id": "org.photocraft.filter.halftone",+   "name": "Halftone Dot Screen FX",+   "version": "1.0.0",+   "author": "CHJ85",+   "description": "Simulates vintage retro print newsprint and comic book halftone dot screens.",+   "collection": "CHJ85 PhotoCraft-Plugins",+   "repo": "https://github.com/CHJ85/PhotoCraft-Plugins",+   "license": "MIT",+   "url": "https://raw.githubusercontent.com/CHJ85/PhotoCraft-Plugins/95cc0420ff39ad108e71b0c0b090f2fad0cd77f0/photocraft_plugin_halftone.wasm",+   "sha256": "339ea068109aff9ec5196e023766205c551a7b79a72236b0c73ce8afaacf0bc4",+   "size": 3258+  },+  {+   "id": "org.photocraft.filter.kuwahara",+   "name": "Kuwahara Painterly Filter",+   "version": "1.0.0",+   "author": "CHJ85",+   "description": "Transforms photos into painterly oil-painting styles while preserving sharp edges.",+   "collection": "CHJ85 PhotoCraft-Plugins",+   "repo": "https://github.com/CHJ85/PhotoCraft-Plugins",+   "license": "MIT",+   "url": "https://raw.githubusercontent.com/CHJ85/PhotoCraft-Plugins/95cc0420ff39ad108e71b0c0b090f2fad0cd77f0/photocraft_plugin_kuwahara.wasm",+   "sha256": "1055c29123226b2fb0c85ddd1993d1ee4ac277210ad4e138debba4cea9e85183",+   "size": 3222+  },+  {+   "id": "org.photocraft.filter.linify",+   "name": "Linify & Line Engraving FX",+   "version": "1.0.0",+   "author": "CHJ85",+   "description": "Transforms images into procedural linear engraving and thread-art hatch patterns.",+   "collection": "CHJ85 PhotoCraft-Plugins",+   "repo": "https://github.com/CHJ85/PhotoCraft-Plugins",+   "license": "MIT",+   "url": "https://raw.githubusercontent.com/CHJ85/PhotoCraft-Plugins/95cc0420ff39ad108e71b0c0b090f2fad0cd77f0/photocraft_plugin_linify.wasm",+   "sha256": "592d6859fc7ba19fea7b527add10e475295ac1d03a176cd27de638796e9feadf",+   "size": 3180+  },+  {+   "id": "org.photocraft.filter.local_variance",+   "name": "Local Variance Normalization",+   "version": "1.0.0",+   "author": "CHJ85",+   "description": "Normalizes local contrast to reveal hidden details in extreme shadows and highlights.",+   "collection": "CHJ85 PhotoCraft-Plugins",+   "repo": "https://github.com/CHJ85/PhotoCraft-Plugins",+   "license": "MIT",+   "url": "https://raw.githubusercontent.com/CHJ85/PhotoCraft-Plugins/95cc0420ff39ad108e71b0c0b090f2fad0cd77f0/photocraft_plugin_local_variance.wasm",+   "sha256": "8cb1578afc18862c6a766a2e24642073477996b019c10799f16144100bfe606b",+   "size": 3149+  },+  {+   "id": "org.photocraft.filter.polygonize",+   "name": "Low-Poly Geometric Art",+   "version": "1.0.0",+   "author": "CHJ85",+   "description": "Converts images into low-poly geometric triangular facet artwork.",+   "collection": "CHJ85 PhotoCraft-Plugins",+   "repo": "https://github.com/CHJ85/PhotoCraft-Plugins",+   "license": "MIT",+   "url": "https://raw.githubusercontent.com/CHJ85/PhotoCraft-Plugins/95cc0420ff39ad108e71b0c0b090f2fad0cd77f0/photocraft_plugin_polygonize.wasm",+   "sha256": "a4748d728d4571e47bb2c099db43ffb30bcffe28b7b8085587daff3b3955e25e",+   "size": 2937+  },+  {+   "id": "org.photocraft.filter.array_mirror",+   "name": "Mirrored Array & Tiling",+   "version": "1.0.0",+   "author": "CHJ85",+   "description": "Creates symmetrical mirrored tiling and array patterns from the image.",+   "collection": "CHJ85 PhotoCraft-Plugins",+   "repo": "https://github.com/CHJ85/PhotoCraft-Plugins",+   "license": "MIT",+   "url": "https://raw.githubusercontent.com/CHJ85/PhotoCraft-Plugins/95cc0420ff39ad108e71b0c0b090f2fad0cd77f0/photocraft_plugin_array_mirror.wasm",+   "sha256": "f19cc5cfca5223fbe63e50f137867621e625d155c7211e8b635d090b40cbabca",+   "size": 2460+  },+  {+   "id": "org.photocraft.filter.motion_pictures",+   "name": "Motion Pictures",+   "version": "1.0.0",+   "author": "CHJ85",+   "description": "Turn your photo into a masterpiece, with this comprehensive collection of cinematic grade style presets.",+   "collection": "CHJ85 PhotoCraft-Plugins",+   "repo": "https://github.com/CHJ85/PhotoCraft-Plugins",+   "license": "MIT",+   "url": "https://raw.githubusercontent.com/CHJ85/PhotoCraft-Plugins/95cc0420ff39ad108e71b0c0b090f2fad0cd77f0/photocraft_plugin_motion_pictures.wasm",+   "sha256": "9bd3fb435007ec6140a201b1319929e36d12605f284617f538cff9c87600d523",+   "size": 54519+  },+  {+   "id": "org.photocraft.filter.motion_pictures_xl",+   "name": "Motion Pictures XL",+   "version": "1.0.0",+   "author": "CHJ85",+   "description": "Turn your photo into a masterpiece, with this comprehensive collection of 111 cinematic and film grade style presets.",+   "collection": "CHJ85 PhotoCraft-Plugins",+   "repo": "https://github.com/CHJ85/PhotoCraft-Plugins",+   "license": "MIT",+   "url": "https://raw.githubusercontent.com/CHJ85/PhotoCraft-Plugins/95cc0420ff39ad108e71b0c0b090f2fad0cd77f0/photocraft_plugin_motion_pictures_xl.wasm",+   "sha256": "fbc3ef56fe93a0ba9adbc82f0f8404df683d1bf95009a70da7d695c469279e3e",+   "size": 57423+  },+  {+   "id": "org.photocraft.filter.neon_glow_multicolor",+   "name": "Multi-Color Neon Glow FX",+   "version": "1.0.0",+   "author": "CHJ85",+   "description": "Transforms image edges into multi-color glowing neon light vectors with adjustable color palettes.",+   "collection": "CHJ85 PhotoCraft-Plugins",+   "repo": "https://github.com/CHJ85/PhotoCraft-Plugins",+   "license": "MIT",+   "url": "https://raw.githubusercontent.com/CHJ85/PhotoCraft-Plugins/95cc0420ff39ad108e71b0c0b090f2fad0cd77f0/photocraft_plugin_neon_glow_multicolor.wasm",+   "sha256": "2166c931f935ab530970f883c4cdd98afbca121df27698b2e29d3472bed592fc",+   "size": 3095+  },+  {+   "id": "org.photocraft.filter.neon_glow",+   "name": "Neon Glow & Cyberpunk FX",+   "version": "1.0.0",+   "author": "CHJ85",+   "description": "Transforms image edges into vibrant glowing neon light vectors.",+   "collection": "CHJ85 PhotoCraft-Plugins",+   "repo": "https://github.com/CHJ85/PhotoCraft-Plugins",+   "license": "MIT",+   "url": "https://raw.githubusercontent.com/CHJ85/PhotoCraft-Plugins/95cc0420ff39ad108e71b0c0b090f2fad0cd77f0/photocraft_plugin_neon_glow.wasm",+   "sha256": "81c9f1831f152629dbb750953e807a019c58a1bce9e1945a231654162e0e0833",+   "size": 2704+  },+  {+   "id": "org.photocraft.filter.restoration",+   "name": "Non-Local Means Image Restoration",+   "version": "1.0.0",+   "author": "CHJ85",+   "description": "Miraculous non-AI restoration using patch-based self-similarity to remove noise and preserve sharp detail.",+   "collection": "CHJ85 PhotoCraft-Plugins",+   "repo": "https://github.com/CHJ85/PhotoCraft-Plugins",+   "license": "MIT",+   "url": "https://raw.githubusercontent.com/CHJ85/PhotoCraft-Plugins/95cc0420ff39ad108e71b0c0b090f2fad0cd77f0/photocraft_plugin_restoration.wasm",+   "sha256": "35b016b5131fd340499459ff69fb92c24196deaae059e9669dca9cf0e5dfc47f",+   "size": 2746+  },+  {+   "id": "org.photocraft.filter.style_mixer",+   "name": "Procedural Style Mixer",+   "version": "1.0.0",+   "author": "CHJ85",+   "description": "Procedurally transforms image styles, now with categorized dropdown selections.",+   "collection": "CHJ85 PhotoCraft-Plugins",+   "repo": "https://github.com/CHJ85/PhotoCraft-Plugins",+   "license": "MIT",+   "url": "https://raw.githubusercontent.com/CHJ85/PhotoCraft-Plugins/95cc0420ff39ad108e71b0c0b090f2fad0cd77f0/photocraft_plugin_style_mixer.wasm",+   "sha256": "62dc91a89a20f914be1b7dab2938f924e9427f46334a22ae984d7386734ab81b",+   "size": 55695+  },+  {+   "id": "org.photocraft.filter.style_mixer_xl",+   "name": "Procedural Style Mixer XL",+   "version": "1.0.0",+   "author": "CHJ85",+   "description": "Mix and match between multiple styles.",+   "collection": "CHJ85 PhotoCraft-Plugins",+   "repo": "https://github.com/CHJ85/PhotoCraft-Plugins",+   "license": "MIT",+   "url": "https://raw.githubusercontent.com/CHJ85/PhotoCraft-Plugins/95cc0420ff39ad108e71b0c0b090f2fad0cd77f0/photocraft_plugin_style_mixer_xl.wasm",+   "sha256": "379ee8e017db73fc68006dfed4011bd3ba1644189599412eef29a58dfb1fbc1a",+   "size": 59882+  },+  {+   "id": "org.photocraft.filter.style_transformer",+   "name": "Procedural Style Transformer",+   "version": "3.0.0",+   "author": "CHJ85",+   "description": "Procedurally transforms images across 50 distinct artistic, film, and mood styles (0 & 51 are default).",+   "collection": "CHJ85 PhotoCraft-Plugins",+   "repo": "https://github.com/CHJ85/PhotoCraft-Plugins",+   "license": "MIT",+   "url": "https://raw.githubusercontent.com/CHJ85/PhotoCraft-Plugins/95cc0420ff39ad108e71b0c0b090f2fad0cd77f0/photocraft_plugin_style_transformer.wasm",+   "sha256": "d787eafa3a56fedf5b905b63d6d6aeb0ab1d0ca17c10f6805b8e8a125419a47d",+   "size": 5999+  },+  {+   "id": "org.photocraft.filter.reaction_diffusion",+   "name": "Reaction-Diffusion Morphogenesis",+   "version": "1.0.0",+   "author": "CHJ85",+   "description": "Simulates chemical reaction-diffusion differential equations to grow biological patterns from image data.",+   "collection": "CHJ85 PhotoCraft-Plugins",+   "repo": "https://github.com/CHJ85/PhotoCraft-Plugins",+   "license": "MIT",+   "url": "https://raw.githubusercontent.com/CHJ85/PhotoCraft-Plugins/95cc0420ff39ad108e71b0c0b090f2fad0cd77f0/photocraft_plugin_reaction_diffusion.wasm",+   "sha256": "d75b3adb96653d89a7e9e68e71a237f5fa672f0a6b9cba32441de6f6976d5013",+   "size": 3362+  },+  {+   "id": "org.photocraft.filter.richardson_lucy",+   "name": "Richardson-Lucy Deconvolution",+   "version": "1.0.0",+   "author": "CHJ85",+   "description": "Bayesian optical deconvolution to mathematically reverse blur and restore high-frequency detail.",+   "collection": "CHJ85 PhotoCraft-Plugins",+   "repo": "https://github.com/CHJ85/PhotoCraft-Plugins",+   "license": "MIT",+   "url": "https://raw.githubusercontent.com/CHJ85/PhotoCraft-Plugins/95cc0420ff39ad108e71b0c0b090f2fad0cd77f0/photocraft_plugin_richardson_lucy.wasm",+   "sha256": "ac7ffc694ef346dc85ff38518b173bc4f86c3daf840385a69705adee02fd470e",+   "size": 2983+  },+  {+   "id": "org.photocraft.filter.rodilius",+   "name": "Rodilius Organic Flow",+   "version": "1.0.0",+   "author": "CHJ85",+   "description": "Generates organic ribbon-like tensor flow patterns from image features.",+   "collection": "CHJ85 PhotoCraft-Plugins",+   "repo": "https://github.com/CHJ85/PhotoCraft-Plugins",+   "license": "MIT",+   "url": "https://raw.githubusercontent.com/CHJ85/PhotoCraft-Plugins/95cc0420ff39ad108e71b0c0b090f2fad0cd77f0/photocraft_plugin_rodilius.wasm",+   "sha256": "c9f3bbd1794ed1bb6497de33a5986df64b60b5a125d9a543d3b53a02d1186502",+   "size": 3191+  },+  {+   "id": "org.photocraft.filter.rorschach",+   "name": "Rorschach Inkblot FX",+   "version": "1.0.0",+   "author": "CHJ85",+   "description": "Generates symmetrical Rorschach psychological inkblot patterns from images.",+   "collection": "CHJ85 PhotoCraft-Plugins",+   "repo": "https://github.com/CHJ85/PhotoCraft-Plugins",+   "license": "MIT",+   "url": "https://raw.githubusercontent.com/CHJ85/PhotoCraft-Plugins/95cc0420ff39ad108e71b0c0b090f2fad0cd77f0/photocraft_plugin_rorschach.wasm",+   "sha256": "a80d102857daf4a141b06df2930430e3a824e0b328313e2aa7da978cf0ad6de7",+   "size": 2290+  },+  {+   "id": "org.photocraft.filter.rorschach_inkblot",+   "name": "Rorschach Inkblot Generator",+   "version": "1.0.0",+   "author": "CHJ85",+   "description": "Transforms images into symmetrical Rorschach psychological inkblot patterns.",+   "collection": "CHJ85 PhotoCraft-Plugins",+   "repo": "https://github.com/CHJ85/PhotoCraft-Plugins",+   "license": "MIT",+   "url": "https://raw.githubusercontent.com/CHJ85/PhotoCraft-Plugins/95cc0420ff39ad108e71b0c0b090f2fad0cd77f0/photocraft_plugin_rorschach_inkblot.wasm",+   "sha256": "ab4b9c92cb62b93ac2da45423a55a4c5cd3d4c4a3cfc40c4e2f169f93009fc31",+   "size": 2517+  },+  {+   "id": "org.photocraft.filter.seamless_synthesis",+   "name": "Seamless Patch Synthesis",+   "version": "1.0.0",+   "author": "CHJ85",+   "description": "Procedurally synthesizes and tiles seamless macro-textures using patch-based neighborhood blending.",+   "collection": "CHJ85 PhotoCraft-Plugins",+   "repo": "https://github.com/CHJ85/PhotoCraft-Plugins",+   "license": "MIT",+   "url": "https://raw.githubusercontent.com/CHJ85/PhotoCraft-Plugins/95cc0420ff39ad108e71b0c0b090f2fad0cd77f0/photocraft_plugin_seamless_synthesis.wasm",+   "sha256": "72f1de3fa202df8a8c8a3b9ba5976c224fd648e73c4cc042b34f441ed82337de",+   "size": 2509+  },+  {+   "id": "org.photocraft.filter.seamless_tiling",+   "name": "Seamless Pattern Tiling",+   "version": "1.0.0",+   "author": "CHJ85",+   "description": "Wraps and repeats image bounds to create seamless repeating texture tiles.",+   "collection": "CHJ85 PhotoCraft-Plugins",+   "repo": "https://github.com/CHJ85/PhotoCraft-Plugins",+   "license": "MIT",+   "url": "https://raw.githubusercontent.com/CHJ85/PhotoCraft-Plugins/95cc0420ff39ad108e71b0c0b090f2fad0cd77f0/photocraft_plugin_seamless_tiling.wasm",+   "sha256": "85b5967447221d5120075267fc3169e8cee32b1bbe879d464f8c1c60bec95cac",+   "size": 2155+  },+  {+   "id": "org.photocraft.filter.skeletik",+   "name": "Skeletonic Wireframe FX",+   "version": "1.0.0",+   "author": "CHJ85",+   "description": "Extracts topological shape skeletons and converts them into abstract wireframe line art.",+   "collection": "CHJ85 PhotoCraft-Plugins",+   "repo": "https://github.com/CHJ85/PhotoCraft-Plugins",+   "license": "MIT",+   "url": "https://raw.githubusercontent.com/CHJ85/PhotoCraft-Plugins/95cc0420ff39ad108e71b0c0b090f2fad0cd77f0/photocraft_plugin_skeletik.wasm",+   "sha256": "eb36b8832290d83765c9a1322d4dc21d3d8dc297b7242f242f702b4b8dd1348c",+   "size": 3028+  },+  {+   "id": "org.photocraft.filter.anisotropic_smoothing",+   "name": "Tensor Anisotropic Smoothing",+   "version": "1.0.0",+   "author": "CHJ85",+   "description": "PDE-based anisotropic diffusion that smooths noise along edges while preserving sharp boundaries.",+   "collection": "CHJ85 PhotoCraft-Plugins",+   "repo": "https://github.com/CHJ85/PhotoCraft-Plugins",+   "license": "MIT",+   "url": "https://raw.githubusercontent.com/CHJ85/PhotoCraft-Plugins/95cc0420ff39ad108e71b0c0b090f2fad0cd77f0/photocraft_plugin_anisotropic_smoothing.wasm",+   "sha256": "7db8eb1979ec6c72861e02736e3271f4086285174d160c2681e662ae2ae2cafd",+   "size": 2792+  },+  {+   "id": "io.github.mitacore.posterize",+   "name": "Posterize (Plug-in)",+   "version": "1.0.0",+   "author": "Mitacore",+   "description": "Reduces each colour channel to a fixed number of levels.",+   "collection": "Mitacore Posterize",+   "repo": "https://github.com/Mitacore/photocraft-plugin-posterize",+   "license": "MIT",+   "url": "https://github.com/Mitacore/photocraft-plugin-posterize/releases/download/v1.0.0/posterize.wasm",+   "sha256": "f98a61bb77bffbfeffa4d7cff2bdb20717965badcd2c540454a4a98f06552d8d",+   "size": 1040+  }+ ]+}\ No newline at end of filediff --git a/crates/engine/src/cmd/plugin_store_cmds/tests.rs b/crates/engine/src/cmd/plugin_store_cmds/tests.rsnew file mode 100644--- /dev/null+++ b/crates/engine/src/cmd/plugin_store_cmds/tests.rs@@ -0,0 +1,299 @@+use super::*;+use std::sync::mpsc;+use std::sync::{Arc, Mutex};++/// A minimal plug-in with its own `id` and `version` (leaves pixels alone).+fn module(id: &str, version: &str) -> Vec<u8> {+    let manifest = format!(r#"{{"id":"{id}","name":"Store {id}","version":"{version}","kind":"filter"}}"#);+    let src = format!(+        r#"(module+  (memory (export "memory") 1)+  (data (i32.const 16) "{data}")+  (func (export "pc_abi_version") (result i32) (i32.const 1))+  (func (export "pc_manifest") (result i64) (i64.or (i64.shl (i64.const {len}) (i64.const 32)) (i64.const 16)))+  (func (export "pc_alloc") (param $n i32) (result i32) (i32.const 0))+  (func (export "pc_filter") (param i32 i32 i32 i32 i32 i32 i32 i32) (result i32) (i32.const 0)))"#,+        data = manifest.replace('"', "\\\""),+        len = manifest.len()+    );+    wat::parse_str(src).unwrap()+}++fn entry(id: &str, version: &str, bytes: &[u8]) -> Entry {+    Entry {+        id: id.into(),+        name: format!("Store {id}"),+        version: version.into(),+        author: "Test".into(),+        description: "A test listing".into(),+        collection: "Tests".into(),+        repo: "https://github.com/example/tests".into(),+        license: "MIT".into(),+        url: format!("https://raw.githubusercontent.com/example/tests/0/{id}.wasm"),+        sha256: hex(&Sha256::digest(bytes)),+        size: bytes.len(),+    }+}++type Served = Arc<Mutex<Vec<(String, mpsc::Sender<FetchResult>)>>>;++/// A session whose catalog is `entries` and whose transport records requests for the test to answer.+fn session(entries: Vec<Entry>) -> (Session, Served) {+    let mut s = Session::new();+    s.plugin_store.catalog = entries;+    let served: Served = Arc::default();+    let log = served.clone();+    s.plugin_store.fetch = Some(Box::new(move |url: &str, _max: usize| {+        let (tx, rx) = mpsc::channel();+        log.lock().unwrap().push((url.to_string(), tx));+        Ok(rx)+    }));+    (s, served)+}++fn answer(served: &Served, result: FetchResult) {+    let (_, tx) = served.lock().unwrap().remove(0);+    tx.send(result).unwrap();+}++fn listing(s: &mut Session, id: &str) -> Value {+    let v = s.execute("plugin.store.list", json!({})).unwrap();+    v["plugins"].as_array().unwrap().iter().find(|p| p["id"] == id).cloned().unwrap()+}++#[test]+fn the_bundled_catalog_parses_and_every_listing_is_valid() {+    let raw: Catalog = serde_json::from_str(include_str!("catalog.json")).unwrap();+    assert!(raw.plugins.len() >= 40, "{} listings", raw.plugins.len());+    for e in &raw.plugins {+        check_entry(e).unwrap_or_else(|why| panic!("{}: {why}", e.id));+        assert!(photocraft_plugins::Manifest::parse(format!(r#"{{"id":"{}","name":"x","kind":"filter"}}"#, e.id).as_bytes()).is_ok(), "{}", e.id);+    }+    assert_eq!(bundled().len(), raw.plugins.len());+    let mut ids: Vec<&str> = raw.plugins.iter().map(|e| e.id.as_str()).collect();+    ids.sort_unstable();+    ids.dedup();+    assert_eq!(ids.len(), raw.plugins.len(), "ids are unique");+}++#[test]+fn listings_with_bad_urls_or_checksums_are_refused() {+    let good = entry("org.test.store.check", "1", b"abc");+    assert!(check_entry(&good).is_ok());+    for (url, sha) in [+        ("http://raw.githubusercontent.com/x.wasm", good.sha256.as_str()),+        ("https://evil.example/x.wasm", good.sha256.as_str()),+        ("https://github.com.evil.example/x.wasm", good.sha256.as_str()),+        (good.url.as_str(), "zz"),+    ] {+        let e = Entry { url: url.into(), sha256: sha.into(), ..good.clone() };+        assert!(check_entry(&e).is_err(), "{url} {sha}");+    }+    assert!(check_entry(&Entry { size: 0, ..good.clone() }).is_err());+    assert!(check_entry(&Entry { size: usize::MAX, ..good }).is_err());+}++#[test]+fn install_downloads_verifies_and_registers_then_uninstall_removes() {+    let id = "org.test.store.flow";+    let bytes = module(id, "1.0.0");+    let (mut s, served) = session(vec![entry(id, "1.0.0", &bytes)]);+    assert_eq!(listing(&mut s, id)["installed"], false);++    let r = s.execute("plugin.store.install", json!({"id": id})).unwrap();+    assert_eq!(r["plugins"][0]["job"]["state"], "downloading");+    assert_eq!(served.lock().unwrap()[0].0, format!("https://raw.githubusercontent.com/example/tests/0/{id}.wasm"));+    assert!(s.plugin_store.busy());+    // A second request while it downloads doesn't start another.+    s.execute("plugin.store.install", json!({"id": id})).unwrap();+    assert_eq!(served.lock().unwrap().len(), 1);++    answer(&served, Ok(bytes));+    let l = listing(&mut s, id);+    assert_eq!(l["installed"], true, "{l}");+    assert_eq!(l["installedVersion"], "1.0.0");+    assert!(l.get("job").is_none());+    assert!(registry::get(id).is_some());+    // Up to date: installing again does nothing.+    s.execute("plugin.store.install", json!({"id": id})).unwrap();+    assert!(served.lock().unwrap().is_empty());++    let r = s.execute("plugin.store.uninstall", json!({"id": id})).unwrap();+    assert_eq!(r["removed"], json!([id]));+    assert!(registry::get(id).is_none());+}++#[test]+fn a_tampered_download_is_refused_and_not_installed() {+    let id = "org.test.store.tampered";+    let bytes = module(id, "1");+    let (mut s, served) = session(vec![entry(id, "1", &bytes)]);+    s.execute("plugin.store.install", json!({"id": id})).unwrap();+    let mut evil = bytes.clone();+    if let Some(b) = evil.last_mut() {+        *b ^= 1;+    }+    answer(&served, Ok(evil));+    let l = listing(&mut s, id);+    assert_eq!(l["installed"], false);+    assert_eq!(l["job"]["state"], "failed");+    assert!(l["job"]["message"].as_str().unwrap().contains("checksum"), "{l}");+    assert!(registry::get(id).is_none());+}++#[test]+fn a_module_with_another_id_or_a_transport_error_fails_cleanly() {+    let id = "org.test.store.wrongid";+    let other = module("org.test.store.impostor", "1");+    let (mut s, served) = session(vec![entry(id, "1", &other)]);+    s.execute("plugin.store.install", json!({"id": id})).unwrap();+    answer(&served, Ok(other));+    assert!(listing(&mut s, id)["job"]["message"].as_str().unwrap().contains("impostor"));+    assert!(registry::get("org.test.store.impostor").is_none());++    s.execute("plugin.store.install", json!({"id": id})).unwrap();+    answer(&served, Err("couldn't reach raw.githubusercontent.com".into()));+    assert_eq!(listing(&mut s, id)["job"]["message"], "couldn't reach raw.githubusercontent.com");++    // A worker that goes away without answering.+    s.execute("plugin.store.install", json!({"id": id})).unwrap();+    served.lock().unwrap().clear();+    assert!(listing(&mut s, id)["job"]["message"].as_str().unwrap().contains("stopped"));+}++#[test]+fn uninstall_cancels_a_download_in_flight() {+    let id = "org.test.store.cancel";+    let bytes = module(id, "1");+    let (mut s, served) = session(vec![entry(id, "1", &bytes)]);+    s.execute("plugin.store.install", json!({"id": id})).unwrap();+    assert!(s.plugin_store.busy());+    s.execute("plugin.store.uninstall", json!({"id": id})).unwrap();+    assert!(!s.plugin_store.busy());+    // The download still arrives, but nobody is waiting for it.+    let (_, tx) = served.lock().unwrap().remove(0);+    let _ = tx.send(Ok(bytes));+    let l = listing(&mut s, id);+    assert_eq!(l["installed"], false, "{l}");+    assert!(l.get("job").is_none());+    assert!(registry::get(id).is_none());+}++#[test]+fn a_cancelled_download_keeps_its_slot_until_it_answers() {+    let entries: Vec<Entry> = (0..MAX_RUNNING + 1).map(|k| entry(&format!("org.test.store.slot{k}"), "1", &module(&format!("org.test.store.slot{k}"), "1"))).collect();+    let ids: Vec<String> = entries.iter().map(|e| e.id.clone()).collect();+    let (mut s, served) = session(entries);+    s.execute("plugin.store.install", json!({"ids": ids[..MAX_RUNNING]})).unwrap();+    assert_eq!(served.lock().unwrap().len(), MAX_RUNNING);+    // Uninstalling one doesn't free its slot while its download is still out.+    s.execute("plugin.store.uninstall", json!({"id": ids[0]})).unwrap();+    s.execute("plugin.store.install", json!({"id": ids[MAX_RUNNING]})).unwrap();+    assert_eq!(served.lock().unwrap().len(), MAX_RUNNING, "no extra download starts");+    // Once it answers, the queued one starts.+    let (_, tx) = served.lock().unwrap().remove(0);+    tx.send(Err("late".into())).unwrap();+    s.plugin_store.poll();+    assert_eq!(served.lock().unwrap().len(), MAX_RUNNING);+    assert!(served.lock().unwrap().last().unwrap().0.contains(&ids[MAX_RUNNING]));+    assert!(listing(&mut s, &ids[0]).get("job").is_none(), "the cancelled one reports nothing");+}++#[test]+fn a_module_whose_version_isnt_the_listed_one_is_refused() {+    let id = "org.test.store.version";+    let bytes = module(id, "2.0.0");+    let (mut s, served) = session(vec![entry(id, "1.0.0", &bytes)]);+    s.execute("plugin.store.install", json!({"id": id})).unwrap();+    answer(&served, Ok(bytes));+    let l = listing(&mut s, id);+    assert_eq!(l["installed"], false, "{l}");+    assert!(l["job"]["message"].as_str().unwrap().contains("version"), "{l}");+}++#[test]+fn a_new_catalog_version_is_offered_as_an_update() {+    let id = "org.test.store.update";+    let old = module(id, "1.0.0");+    let new = module(id, "2.0.0");+    let (mut s, served) = session(vec![entry(id, "1.0.0", &old)]);+    s.execute("plugin.store.install", json!({"id": id})).unwrap();+    answer(&served, Ok(old));+    assert_eq!(listing(&mut s, id)["updateAvailable"], false);+    s.plugin_store.catalog = vec![entry(id, "2.0.0", &new)];+    assert_eq!(listing(&mut s, id)["updateAvailable"], true);+    s.execute("plugin.store.install", json!({"id": id})).unwrap();+    answer(&served, Ok(new));+    assert_eq!(listing(&mut s, id)["installedVersion"], "2.0.0");+    registry::remove(id);+}++#[test]+fn downloads_beyond_the_limit_wait_their_turn() {+    let entries: Vec<Entry> = (0..MAX_RUNNING + 2).map(|k| entry(&format!("org.test.store.queue{k}"), "1", &module(&format!("org.test.store.queue{k}"), "1"))).collect();+    let ids: Vec<String> = entries.iter().map(|e| e.id.clone()).collect();+    let (mut s, served) = session(entries);+    s.execute("plugin.store.install", json!({"ids": ids})).unwrap();+    assert_eq!(served.lock().unwrap().len(), MAX_RUNNING);+    answer(&served, Err("x".into()));+    s.plugin_store.poll();+    assert_eq!(served.lock().unwrap().len(), MAX_RUNNING, "a finished download makes room for a queued one");+}++#[cfg(not(target_arch = "wasm32"))]+#[test]+fn installs_are_saved_to_the_folder_and_load_from_it_again() {+    let id = "org.test.store.saved";+    let bytes = module(id, "1");+    let dir = tempfile::tempdir().unwrap();+    let (mut s, served) = session(vec![entry(id, "1", &bytes)]);+    s.plugin_store.set_dir(dir.path().to_path_buf());+    s.execute("plugin.store.install", json!({"id": id})).unwrap();+    answer(&served, Ok(bytes));+    s.plugin_store.poll();+    let file = dir.path().join(format!("{id}.wasm"));+    assert!(file.is_file());+    assert_eq!(registry::get(id).unwrap().source(), Some(file.display().to_string().as_str()));++    registry::remove(id);+    let report = Session::new().plugin_store.set_dir(dir.path().to_path_buf());+    assert_eq!(report["loaded"], json!([id]));++    s.execute("plugin.store.uninstall", json!({"id": id})).unwrap();+    assert!(!file.exists());+    assert!(registry::get(id).is_none());+}++#[test]+fn bad_params_and_a_missing_transport_are_errors() {+    let id = "org.test.store.params";+    let (mut s, _) = session(vec![entry(id, "1", &module(id, "1"))]);+    for (cmd, p) in [+        ("plugin.store.install", json!({})),+        ("plugin.store.install", json!({"id": 3})),+        ("plugin.store.install", json!({"ids": "x"})),+        ("plugin.store.install", json!({"ids": []})),+        ("plugin.store.install", json!({"ids": [1]})),+        ("plugin.store.install", json!({"id": id, "ids": [id]})),+        ("plugin.store.install", json!({"id": "org.unknown"})),+        ("plugin.store.uninstall", json!({"id": "org.unknown"})),+        ("plugin.store.uninstall", json!(null)),+        ("plugin.store.list", json!({"query": 5})),+    ] {+        assert!(s.execute(cmd, p.clone()).is_err(), "{cmd} {p}");+    }+    let mut bare = Session::new();+    bare.plugin_store.catalog = s.plugin_store.catalog.clone();+    assert_eq!(bare.execute("plugin.store.list", json!({})).unwrap()["available"], false);+    let e = bare.execute("plugin.store.install", json!({"id": id})).unwrap_err().to_string();+    assert!(e.contains("not available"), "{e}");+}++#[test]+fn list_filters_by_query_and_installed() {+    let (mut s, _) = session(vec![entry("org.test.store.alpha", "1", b"a"), Entry { description: "Makes halftone dots".into(), ..entry("org.test.store.beta", "1", b"b") }]);+    let names = |v: Value| v["plugins"].as_array().unwrap().iter().map(|p| p["id"].as_str().unwrap().to_string()).collect::<Vec<_>>();+    assert_eq!(names(s.execute("plugin.store.list", json!({"query": "HALFTONE"})).unwrap()), ["org.test.store.beta"]);+    assert_eq!(names(s.execute("plugin.store.list", json!({"query": "alpha"})).unwrap()), ["org.test.store.alpha"]);+    assert!(names(s.execute("plugin.store.list", json!({"installed": true})).unwrap()).is_empty());+}diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs--- a/crates/engine/src/lib.rs+++ b/crates/engine/src/lib.rs@@ -126,6 +126,7 @@ pub use cmd::perspective_crop_cmds; pub use cmd::photo_cmds; pub use cmd::pick_cmds; pub use cmd::plugin_cmds;+pub use cmd::plugin_store_cmds; pub use cmd::preset_import_cmds; pub use cmd::print_cmds; pub use cmd::rasterize_style_cmds;@@ -221,6 +222,8 @@ pub struct Session {     pub prefs: prefs::PrefsStore,     /// Release checks use an injected platform transport; no network I/O in the engine.     pub updates: updates_cmds::Updates,+    /// Plugins › Browse Plugins…: the plug-in catalog and its downloads (injected transport).+    pub plugin_store: plugin_store_cmds::PluginStore,     /// Edit menu state: Fade source, custom shape library (see `edit_menu_cmds`).     pub edit_state: edit_menu_cmds::EditState,     /// The pattern library (Window › Patterns; see `pattern.*`).diff --git a/crates/engine/src/shell_commands.rs b/crates/engine/src/shell_commands.rs--- a/crates/engine/src/shell_commands.rs+++ b/crates/engine/src/shell_commands.rs@@ -89,6 +89,8 @@ pub const SHELL_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[     ("window.theme.softLight", "Soft", &["Window", "Theme", "Light Theme"], None),     ("window.theme.system", "Sync with system", &["Window", "Theme"], None),     ("edit.search", "Search…", &["Edit"], Some("Cmd+F")),+    ("plugins.browse", "Browse Plugins…", &["Plugins"], None),+    ("plugins.manage", "Manage Plugins…", &["Plugins"], None),     ("help.discord", "Join the ArtCraft Discord…", &["Help"], None),     ("help.website", "PhotoCraft Website", &["Help"], None),     ("help.artcraftWebsite", "ArtCraft Website", &["Help"], None),diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs--- a/crates/ui-egui/src/app.rs+++ b/crates/ui-egui/src/app.rs@@ -26,6 +26,12 @@ pub use thumbs::surface_fingerprint; impl PhotocraftApp {     pub fn new(mut session: Session, mut services: Services) -> Self {         session.updates.check = services.check_updates.take();+        session.plugin_store.fetch = services.fetch_plugin.take();+        #[cfg(not(target_arch = "wasm32"))]+        if let Some(dir) = services.plugin_dir.take() {+            let report = session.plugin_store.set_dir(dir);+            log::info!("catalog plug-ins: {report}");+        }         let mut app = Self {             session,             ui: UiState::default(),diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs--- a/crates/ui-egui/src/app/frame.rs+++ b/crates/ui-egui/src/app/frame.rs@@ -61,6 +61,7 @@ impl PhotocraftApp {         shape_stroke_ui::sync_editor(self, ctx);         prefs_ui::tick(self, ctx);         updates_ui::tick(self, ctx);+        crate::plugin_store_ui::tick(self, ctx);         monitor_status::poll_frame(self, ctx, frame);         // Control requests and persisted preferences can change the language in this frame.         i18n::sync_context(ctx, &self.session.prefs().interface.language);@@ -258,6 +259,7 @@ impl PhotocraftApp {         canvas::extra_windows(self, &ctx);         notices::show(self, &ctx);         updates_ui::show(self, &ctx);+        crate::plugin_store_ui::show(self, &ctx);         gpu_status::show_fallback(self, &ctx);         kys_import::show_offer(self, &ctx);         if self.titlebar_modal_drag(chrome) {diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs--- a/crates/ui-egui/src/lib.rs+++ b/crates/ui-egui/src/lib.rs@@ -182,6 +182,7 @@ pub use menus::menu_keys; pub use menus::menu_nav; pub use menus::native_menu; pub use menus::parity;+pub use menus::plugin_store_ui; pub use menus::plugin_ui; pub use menus::view_cmds; pub use menus::workspace_ui;diff --git a/crates/ui-egui/src/menu_catalog.rs b/crates/ui-egui/src/menu_catalog.rs--- a/crates/ui-egui/src/menu_catalog.rs+++ b/crates/ui-egui/src/menu_catalog.rs@@ -704,6 +704,8 @@ pub static CATALOG: &[(&[&str], &str, Option<&str>, &str)] = &[     (&["View"], "---", None, "---"),     (&["View"], "Pattern Preview", None, "view.patternPreview"),     (&["View"], "Pixel Art Preview", None, "view.pixelArtPreview"),+    (&["Plugins"], "Browse Plugins…", None, "plugins.browse"),+    (&["Plugins"], "Manage Plugins…", None, "plugins.manage"),     (&["Window", "Arrange"], "Tile All Vertically", None, "window.arrange.tileAllVertically"),     (&["Window", "Arrange"], "Tile All Horizontally", None, "window.arrange.tileAllHorizontally"),     (&["Window", "Arrange"], "2-up Vertical", None, "window.arrange.twoUpVertical"),diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs--- a/crates/ui-egui/src/menus.rs+++ b/crates/ui-egui/src/menus.rs@@ -13,6 +13,7 @@ pub mod menu_keys; pub mod menu_nav; pub mod native_menu; pub mod parity;+pub mod plugin_store_ui; pub mod plugin_ui; mod render; mod search;@@ -51,7 +52,7 @@ pub(crate) fn modal_allows(app: &PhotocraftApp, id: &str) -> bool { }  /// Top-level menus in Photoshop order.-pub const TOP_MENUS: [&str; 10] = ["File", "Edit", "Image", "Layer", "Type", "Select", "Filter", "View", "Window", "Help"];+pub const TOP_MENUS: [&str; 11] = ["File", "Edit", "Image", "Layer", "Type", "Select", "Filter", "View", "Plugins", "Window", "Help"];  /// UI-level commands (handled by the shell rather than the engine): id, label, menu, shortcut. /// The table is the engine's ([`photocraft_engine::shell_commands::SHELL_COMMANDS`]), so thediff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs--- a/crates/ui-egui/src/menus/invoke.rs+++ b/crates/ui-egui/src/menus/invoke.rs@@ -323,6 +323,8 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id:             Ok(Value::Null)         }         "help.about" => Ok(json!({"dialog": app.ui.open_dialog(DialogKind::About, Default::default())})),+        "plugins.browse" => Ok(crate::plugin_store_ui::open(app, false)),+        "plugins.manage" => Ok(crate::plugin_store_ui::open(app, true)),         "help.systemInfo" => {             let mut fields = serde_json::Map::new();             fields.insert("systemInfo".into(), json!(true));diff --git a/crates/ui-egui/src/menus/menu_keys.rs b/crates/ui-egui/src/menus/menu_keys.rs--- a/crates/ui-egui/src/menus/menu_keys.rs+++ b/crates/ui-egui/src/menus/menu_keys.rs@@ -19,7 +19,7 @@ use egui::{Color32, Event, Id, Key, Modifiers, Stroke, Ui}; use crate::menu_nav::Nav;  /// Photoshop's mnemonics for the top menus (Windows), used where the shown label has the letter.-pub const TOP_PREFERRED: [(&str, char); 10] = [+pub const TOP_PREFERRED: [(&str, char); 11] = [     ("File", 'F'),     ("Edit", 'E'),     ("Image", 'I'),@@ -28,6 +28,7 @@ pub const TOP_PREFERRED: [(&str, char); 10] = [     ("Select", 'S'),     ("Filter", 'T'),     ("View", 'V'),+    ("Plugins", 'P'),     ("Window", 'W'),     ("Help", 'H'), ];@@ -436,9 +437,9 @@ mod tests {      #[test]     fn letters_are_unique_and_prefer_photoshop_s() {-        let labels = ["File", "Edit", "Image", "Layer", "Type", "Select", "Filter", "View", "Window", "Help"];+        let labels = ["File", "Edit", "Image", "Layer", "Type", "Select", "Filter", "View", "Plugins", "Window", "Help"];         let preferred: Vec<Option<char>> = TOP_PREFERRED.iter().map(|(_, c)| Some(*c)).collect();-        assert_eq!(assign(&labels, &preferred), "FEILYSTVWH".chars().map(Some).collect::<Vec<_>>());+        assert_eq!(assign(&labels, &preferred), "FEILYSTVPWH".chars().map(Some).collect::<Vec<_>>());         // Items: first letters first, then the next free letter of the word starts, then any.         assert_eq!(             assign(&["New…", "Open…", "Open Recent", "Close", "Close All", "Save", "Save As…"], &[]),diff --git a/crates/ui-egui/src/menus/menu_keys/harness_tests.rs b/crates/ui-egui/src/menus/menu_keys/harness_tests.rs--- a/crates/ui-egui/src/menus/menu_keys/harness_tests.rs+++ b/crates/ui-egui/src/menus/menu_keys/harness_tests.rs@@ -60,7 +60,7 @@ fn alt_focuses_the_bar_arrows_move_and_down_opens_the_lit_menu() {     assert_eq!(Keys::current(&h.ctx).focus, Some(1), "→ moves to Edit");     key(&mut h, egui::Key::ArrowLeft);     key(&mut h, egui::Key::ArrowLeft);-    assert_eq!(Keys::current(&h.ctx).focus, Some(9), "← wraps round to Help");+    assert_eq!(Keys::current(&h.ctx).focus, Some(10), "← wraps round to Help");     key(&mut h, egui::Key::ArrowRight);     key(&mut h, egui::Key::ArrowRight);     assert_eq!(Keys::current(&h.ctx).focus, Some(1));diff --git a/crates/ui-egui/src/menus/native_menu.rs b/crates/ui-egui/src/menus/native_menu.rs--- a/crates/ui-egui/src/menus/native_menu.rs+++ b/crates/ui-egui/src/menus/native_menu.rs@@ -763,6 +763,8 @@ pub(crate) fn state_hash(app: &PhotocraftApp, input: Option<u64>) -> u64 {     (app.discard.is_some(), app.camera_raw.is_some(), crate::distort_ui::modal_workspace(app)).hash(&mut h);     serde_json::to_string(&(&ui.panels, &ui.extras, &ui.view, ui.theme, ui.tool)).unwrap_or_default().hash(&mut h);     s.prefs().interface.language.hash(&mut h);+    // Filter › Plug-ins lists the installed plug-ins, which a background download can change.+    photocraft_plugins::registry::revision().hash(&mut h);     h.finish() } diff --git a/crates/ui-egui/src/menus/native_menu/tests.rs b/crates/ui-egui/src/menus/native_menu/tests.rs--- a/crates/ui-egui/src/menus/native_menu/tests.rs+++ b/crates/ui-egui/src/menus/native_menu/tests.rs@@ -40,6 +40,16 @@ fn photocraft_gets_a_mac_app_menu() {     assert_eq!(l.bar.find("help.about").unwrap().label, "About PhotoCraft"); } +/// A plug-in installed in the background (Browse Plugins) rebuilds the menus' Filter › Plug-ins.+#[test]+fn installing_a_plug_in_changes_the_menu_state() {+    let app = app(false);+    let before = state_hash(&app, None);+    let bytes = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../plugins/tests/fixtures/invert.wasm")).unwrap();+    photocraft_plugins::registry::install_bytes(&bytes).unwrap();+    assert_ne!(state_hash(&app, None), before);+}+ #[test] fn moved_items_leave_their_old_menus() {     let l = layout(true);@@ -48,7 +58,7 @@ fn moved_items_leave_their_old_menus() {     assert!(!ids(&by("Help").children).contains(&"help.about".to_string()));     assert!(!ids(&by("Edit").children).contains(&"[Preferences]".to_string()));     let titles: Vec<&str> = l.bar.menus.iter().map(|m| m.title.as_str()).collect();-    assert_eq!(titles, ["PhotoCraft", "File", "Edit", "Image", "Layer", "Type", "Select", "Filter", "View", "Window", "Help"]);+    assert_eq!(titles, ["PhotoCraft", "File", "Edit", "Image", "Layer", "Type", "Select", "Filter", "View", "Plugins", "Window", "Help"]); }  /// PhotoCraft keeps Photoshop's ⌘H, ⌘M, ⌘W and ⌘, (Hide Layers): the system items give way.diff --git a/crates/ui-egui/src/menus/plugin_store_ui.rs b/crates/ui-egui/src/menus/plugin_store_ui.rsnew file mode 100644--- /dev/null+++ b/crates/ui-egui/src/menus/plugin_store_ui.rs@@ -0,0 +1,245 @@+//! Plugins › Browse Plugins… and Manage Plugins…: a non-modal window over the plug-in catalog+//! (`plugin.store.*` engine commands). Browse lists every catalog plug-in by its GitHub project,+//! with Install / Update per row and Install Selected for several at once; Manage lists the+//! installed ones with Uninstall. Downloads run in the background and the rows follow them.++use std::collections::BTreeSet;++use photocraft_engine::plugin_store_cmds::{Entry, Job};+use serde::{Deserialize, Serialize};+use serde_json::{Value, json};++use crate::PhotocraftApp;++/// The window's state (in `UiState`, so `ui.inspect`/`ui.set` can read and drive it).+#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]+#[serde(default, rename_all = "camelCase")]+pub struct StoreView {+    pub open: bool,+    /// Manage Plugins… (installed only) rather than Browse.+    pub installed_only: bool,+    pub query: String,+    /// Ids ticked for Install Selected.+    pub selected: BTreeSet<String>,+}++pub fn open(app: &mut PhotocraftApp, installed_only: bool) -> Value {+    let v = &mut app.ui.plugin_store;+    v.open = true;+    v.installed_only = installed_only;+    json!({"open": true, "installedOnly": installed_only})+}++/// Finishes downloads and keeps the window repainting while they run.+pub fn tick(app: &mut PhotocraftApp, ctx: &egui::Context) {+    app.session.plugin_store.poll();+    if app.session.plugin_store.busy() {+        ctx.request_repaint_after(std::time::Duration::from_millis(100));+    }+}++/// What a row can do.+#[derive(Clone, Copy, Debug, PartialEq, Eq)]+enum State {+    NotInstalled,+    Installed,+    Update,+    Busy,+}++fn installed_version(id: &str) -> Option<String> {+    photocraft_plugins::registry::get(id).map(|p| p.manifest().version.clone())+}++fn state_of(app: &PhotocraftApp, e: &Entry) -> State {+    if matches!(app.session.plugin_store.job(&e.id), Some(Job::Queued | Job::Downloading)) {+        return State::Busy;+    }+    match installed_version(&e.id) {+        None => State::NotInstalled,+        Some(v) if v != e.version => State::Update,+        Some(_) => State::Installed,+    }+}++fn matches(e: &Entry, query: &str) -> bool {+    let q = query.trim().to_lowercase();+    q.is_empty() || [&e.name, &e.description, &e.author, &e.collection].iter().any(|f| f.to_lowercase().contains(&q))+}++/// The listings the window shows, in catalog order (grouped by collection).+fn visible(app: &PhotocraftApp) -> Vec<Entry> {+    let v = &app.ui.plugin_store;+    app.session.plugin_store.catalog().iter().filter(|e| matches(e, &v.query) && (!v.installed_only || installed_version(&e.id).is_some())).cloned().collect()+}++/// What a click asked for, run after drawing (commands need `&mut app`).+enum Action {+    Install(Vec<String>),+    Uninstall(String),+    Link(String),+}++fn size_text(bytes: usize) -> String {+    if bytes < 1024 { format!("{bytes} B") } else { format!("{:.0} KB", bytes as f64 / 1024.0) }+}++pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {+    if !app.ui.plugin_store.open {+        return;+    }+    let mut open = true;+    let mut action: Option<Action> = None;+    let entries = visible(app);+    let available = app.session.plugin_store.available();+    let states: Vec<State> = entries.iter().map(|e| state_of(app, e)).collect();+    let jobs: Vec<Option<Job>> = entries.iter().map(|e| app.session.plugin_store.job(&e.id).cloned()).collect();+    let title = if app.ui.plugin_store.installed_only { tl!("Manage Plugins") } else { tl!("Browse Plugins") };+    egui::Window::new(title)+        .id(egui::Id::unique("photocraft_plugin_store"))+        .open(&mut open)+        .collapsible(false)+        .resizable(true)+        .default_size([560.0, 520.0])+        .pivot(egui::Align2::CENTER_CENTER)+        .default_pos(ctx.content_rect().center())+        .min_width(420.0)+        .show(ctx, |ui| {+            let t = crate::theme::Tokens::get(ctx);+            let view = &mut app.ui.plugin_store;+            ui.horizontal(|ui| {+                if crate::widgets::pill_tab(ui, "Browse", !view.installed_only).clicked() {+                    view.installed_only = false;+                }+                if crate::widgets::pill_tab(ui, "Installed", view.installed_only).clicked() {+                    view.installed_only = true;+                }+                ui.add_space(8.0);+                let w = (ui.available_width() - 4.0).max(120.0);+                crate::widgets::text_field(ui, egui::Id::unique("plugin_store_query"), &mut view.query, tl!("Search plug-ins"), w, false);+            });+            ui.add_space(6.0);+            if !available {+                ui.colored_label(t.warning, tl!("Downloading plug-ins is not available in this build."));+                ui.add_space(4.0);+            }+            let footer = crate::widgets::button_height(ctx) + 44.0;+            egui::ScrollArea::vertical().auto_shrink([false, false]).max_height((ctx.content_rect().height() - 220.0 - footer).clamp(120.0, 440.0)).show(ui, |ui| {+                if entries.is_empty() {+                    ui.add_space(12.0);+                    ui.label(egui::RichText::new(if view.installed_only { tl!("No catalog plug-ins are installed.") } else { tl!("No plug-ins match the search.") }).color(t.text_dim));+                }+                let mut collection = "";+                for ((e, state), job) in entries.iter().zip(&states).zip(&jobs) {+                    if e.collection != collection {+                        collection = &e.collection;+                        ui.add_space(8.0);+                        ui.horizontal(|ui| {+                            crate::widgets::section_label(ui, &e.collection);+                            if ui.link(tl!("Source on GitHub")).on_hover_text(&e.repo).clicked() {+                                action = Some(Action::Link(e.repo.clone()));+                            }+                        });+                        crate::widgets::hairline(ui);+                    }+                    ui.add_space(4.0);+                    ui.horizontal(|ui| {+                        let mut ticked = view.selected.contains(&e.id);+                        let can_tick = available && matches!(state, State::NotInstalled | State::Update);+                        ui.add_enabled_ui(can_tick, |ui| {+                            let r = crate::widgets::checkbox(ui, &mut ticked, "");+                            // The name is drawn beside the box, not as its label: give the box it too.+                            r.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, can_tick, ticked, &e.name));+                            if r.changed() {+                                if ticked {+                                    view.selected.insert(e.id.clone());+                                } else {+                                    view.selected.remove(&e.id);+                                }+                            }+                        });+                        ui.vertical(|ui| {+                            // Room for the row's buttons: an outdated plug-in has Update and Uninstall.+                            let buttons = if *state == State::Update { 196.0 } else { 110.0 };+                            ui.set_width((ui.available_width() - buttons).max(160.0));+                            ui.horizontal(|ui| {+                                ui.label(egui::RichText::new(&e.name).font(crate::theme::medium(12.5)).color(t.text));+                                ui.label(egui::RichText::new(format!("{} · {} · {}", e.version, e.author, size_text(e.size))).color(t.text_faint));+                            });+                            ui.add(egui::Label::new(egui::RichText::new(&e.description).color(t.text_dim)).wrap());+                            if let Some(Job::Failed { message }) = job {+                                ui.add(egui::Label::new(egui::RichText::new(message).color(t.warning)).wrap());+                            }+                        });+                        ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| match state {+                            State::Busy => {+                                ui.label(egui::RichText::new(tl!("Downloading…")).color(t.text_dim));+                                ui.spinner();+                            }+                            State::Installed => {+                                if crate::widgets::secondary_button(ui, tl!("Uninstall"), 84.0).clicked() {+                                    action = Some(Action::Uninstall(e.id.clone()));+                                }+                            }+                            State::NotInstalled | State::Update => {+                                let label = if *state == State::Update { tl!("Update") } else if matches!(job, Some(Job::Failed { .. })) { tl!("Retry") } else { tl!("Install") };+                                let clicked = ui.add_enabled_ui(available, |ui| crate::widgets::secondary_button(ui, label, 84.0).clicked()).inner;+                                if clicked {+                                    action = Some(Action::Install(vec![e.id.clone()]));+                                }+                                // An outdated plug-in can still be removed when updating it fails or can't run.+                                if *state == State::Update && crate::widgets::secondary_button(ui, tl!("Uninstall"), 84.0).clicked() {+                                    action = Some(Action::Uninstall(e.id.clone()));+                                }+                            }+                        });+                    });+                }+            });+            ui.add_space(6.0);+            crate::widgets::hairline(ui);+            ui.add_space(4.0);+            ui.add(+                egui::Label::new(+                    egui::RichText::new(tl!(+                        "Community plug-ins from GitHub. Each download is checked against the catalog's checksum, and plug-ins run sandboxed, without access to files, network or the system."+                    ))+                    .color(t.text_faint),+                )+                .wrap(),+            );+            ui.add_space(4.0);+            // Ticks on rows that are no longer installable (installed meanwhile) don't count.+            view.selected.retain(|id| entries.iter().zip(&states).any(|(e, s)| e.id == *id && matches!(s, State::NotInstalled | State::Update)) || !entries.iter().any(|e| e.id == *id));+            let ticked: Vec<String> = view.selected.iter().cloned().collect();+            ui.horizontal(|ui| {+                let label = crate::i18n::fmt(tl!("Install Selected ({count})"), &[("count", &ticked.len().to_string())]);+                let enabled = available && !ticked.is_empty();+                if ui.add_enabled_ui(enabled, |ui| crate::widgets::primary_button(ui, &label, 160.0).clicked()).inner {+                    action = Some(Action::Install(ticked.clone()));+                }+            });+        });+    app.ui.plugin_store.open = open;+    match action {+        Some(Action::Install(ids)) => {+            match app.run("plugin.store.install", json!({"ids": ids})) {+                Ok(_) => app.ui.plugin_store.selected.retain(|id| !ids.contains(id)),+                Err(e) => app.ui.status = e,+            }+        }+        Some(Action::Uninstall(id)) => {+            if let Err(e) = app.run("plugin.store.uninstall", json!({"id": id})) {+                app.ui.status = e;+            }+        }+        Some(Action::Link(url)) => {+            crate::links::open(app, ctx, &url);+        }+        None => {}+    }+}++#[cfg(test)]+#[path = "plugin_store_ui_tests.rs"]+mod tests;diff --git a/crates/ui-egui/src/menus/plugin_store_ui_tests.rs b/crates/ui-egui/src/menus/plugin_store_ui_tests.rsnew file mode 100644--- /dev/null+++ b/crates/ui-egui/src/menus/plugin_store_ui_tests.rs@@ -0,0 +1,143 @@+use super::*;+use egui::vec2;+use egui_kittest::Harness;+use egui_kittest::kittest::Queryable;+use photocraft_engine::Session;+use photocraft_engine::plugin_store_cmds::FetchResult;+use std::sync::{Arc, Mutex, mpsc};++use crate::Services;++type Requests = Arc<Mutex<Vec<(String, mpsc::Sender<FetchResult>)>>>;++fn entry(id: &str, name: &str) -> Entry {+    Entry {+        id: id.into(),+        name: name.into(),+        version: "1.0.0".into(),+        author: "Tester".into(),+        description: format!("{name} does things"),+        collection: "Test Collection".into(),+        repo: "https://github.com/example/test-plugins".into(),+        license: "MIT".into(),+        url: format!("https://raw.githubusercontent.com/example/test-plugins/0/{id}.wasm"),+        sha256: "0".repeat(64),+        size: 10,+    }+}++fn app() -> (PhotocraftApp, Requests) {+    let requests: Requests = Arc::default();+    let log = requests.clone();+    let fetch: photocraft_engine::plugin_store_cmds::FetchFn = Box::new(move |url: &str, _| {+        let (tx, rx) = mpsc::channel();+        log.lock().unwrap().push((url.to_string(), tx));+        Ok(rx)+    });+    let mut app = PhotocraftApp::new(Session::new(), Services { fetch_plugin: Some(fetch), ..Default::default() });+    app.session.plugin_store.set_catalog(vec![entry("org.test.ui.halftone", "Halftone Test"), entry("org.test.ui.vignette", "Vignette Test")]);+    (app, requests)+}++fn harness(app: PhotocraftApp) -> Harness<'static, PhotocraftApp> {+    let mut h = Harness::builder().with_size(vec2(900.0, 700.0)).build_ui_state(+        |ui, app: &mut PhotocraftApp| {+            let ctx = ui.ctx().clone();+            if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {+                return;+            }+            show(app, &ctx);+        },+        app,+    );+    PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]);+    h.run_steps(4);+    h+}++#[test]+fn the_plugins_menu_has_browse_and_manage_and_they_open_the_window() {+    let (mut app, _) = app();+    let items = crate::menus::menu_items(&app);+    for id in ["plugins.browse", "plugins.manage"] {+        let item = items.iter().find(|i| i.id == id).unwrap_or_else(|| panic!("{id} is in the menus"));+        assert_eq!(item.path, ["Plugins"]);+        assert!(item.enabled, "{id} works without a document");+    }+    let ctx = egui::Context::default();+    crate::menus::invoke(&mut app, &ctx, "plugins.browse", json!({})).unwrap();+    assert!(app.ui.plugin_store.open && !app.ui.plugin_store.installed_only);+    crate::menus::invoke(&mut app, &ctx, "plugins.manage", json!({})).unwrap();+    assert!(app.ui.plugin_store.installed_only);+}++#[test]+fn browse_lists_the_catalog_and_install_starts_a_download() {+    let (mut app, requests) = app();+    open(&mut app, false);+    let mut h = harness(app);+    // Each row's checkbox is named after its plug-in.+    h.get_by_role_and_label(egui::accesskit::Role::CheckBox, "Halftone Test");+    h.get_by_role_and_label(egui::accesskit::Role::CheckBox, "Vignette Test");+    h.get_by_label("Test Collection");+    let install = h.get_all_by_label("Install").next().unwrap();+    install.click();+    h.run_steps(3);+    assert_eq!(requests.lock().unwrap().len(), 1);+    assert!(requests.lock().unwrap()[0].0.ends_with("org.test.ui.halftone.wasm"));+    h.get_by_label("Downloading…");++    // A failed download says why and offers Retry.+    let (_, tx) = requests.lock().unwrap().remove(0);+    tx.send(Err("couldn't reach raw.githubusercontent.com".into())).unwrap();+    h.run_steps(3);+    h.state_mut().session.plugin_store.poll();+    h.run_steps(3);+    h.get_by_label("couldn't reach raw.githubusercontent.com");+    h.get_by_label("Retry");+}++#[test]+fn search_filters_and_install_selected_installs_the_ticked_rows() {+    let (mut app, requests) = app();+    open(&mut app, false);+    app.ui.plugin_store.query = "vignette".into();+    let mut h = harness(app);+    assert!(h.query_by_label("Halftone Test").is_none());+    h.get_by_role_and_label(egui::accesskit::Role::CheckBox, "Vignette Test");++    h.state_mut().ui.plugin_store.query.clear();+    h.state_mut().ui.plugin_store.selected = ["org.test.ui.halftone".to_string(), "org.test.ui.vignette".to_string()].into();+    h.run_steps(3);+    h.get_by_label("Install Selected (2)").click();+    h.run_steps(3);+    assert_eq!(requests.lock().unwrap().len(), 2);+    assert!(h.state().ui.plugin_store.selected.is_empty());+}++#[test]+fn manage_shows_only_installed_and_without_a_transport_install_is_off() {+    let mut app = PhotocraftApp::new(Session::new(), Services::default());+    app.session.plugin_store.set_catalog(vec![entry("org.test.ui.none", "Nothing Installed")]);+    open(&mut app, true);+    let mut h = harness(app);+    h.get_by_label("No catalog plug-ins are installed.");+    h.state_mut().ui.plugin_store.installed_only = false;+    h.run_steps(3);+    h.get_by_label("Downloading plug-ins is not available in this build.");+    h.get_by_role_and_label(egui::accesskit::Role::CheckBox, "Nothing Installed");+}++#[test]+fn an_outdated_plug_in_offers_update_and_uninstall() {+    let bytes = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../plugins/tests/fixtures/invert.wasm")).unwrap();+    let plugin = photocraft_plugins::registry::install_bytes(&bytes).unwrap();+    let mut listing = entry(plugin.id(), "Invert Test");+    listing.version = format!("{}-newer", plugin.manifest().version);+    let (mut app, _) = app();+    app.session.plugin_store.set_catalog(vec![listing]);+    open(&mut app, true);+    let h = harness(app);+    h.get_by_label("Update");+    h.get_by_label("Uninstall");+}diff --git a/crates/ui-egui/src/menus/state.rs b/crates/ui-egui/src/menus/state.rs--- a/crates/ui-egui/src/menus/state.rs+++ b/crates/ui-egui/src/menus/state.rs@@ -58,7 +58,7 @@ pub fn is_enabled(app: &PhotocraftApp, id: &str) -> bool {         | "window.panel.closeOthers"         | "window.panel.minimize"         | "window.panel.iconic" => !app.session.prefs().workspace_locked,-        "file.open" | "file.exit" | "file.clearRecent" | "file.removeRecent" | "help.about" | "help.systemInfo" | "edit.search" => true,+        "file.open" | "file.exit" | "file.clearRecent" | "file.removeRecent" | "help.about" | "help.systemInfo" | "edit.search" | "plugins.browse" | "plugins.manage" => true,         i if i.starts_with("file.openRecent.") => true,         i if i.starts_with(DOCUMENT_ITEM) => true,         i if crate::links::url_for(i).is_some() => true,diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs--- a/crates/ui-egui/src/services.rs+++ b/crates/ui-egui/src/services.rs@@ -367,6 +367,10 @@ pub struct Services {     pub beep: Option<fn()>,     /// Starts a background check for published releases (desktop, outside Flatpak).     pub check_updates: Option<photocraft_engine::updates_cmds::CheckFn>,+    /// Downloads plug-ins for Plugins › Browse Plugins… in the background (desktop).+    pub fetch_plugin: Option<photocraft_engine::plugin_store_cmds::FetchFn>,+    /// Where plug-ins installed from the catalog are kept between runs (desktop).+    pub plugin_dir: Option<std::path::PathBuf>,     /// Files delivered asynchronously (web drag-and-drop): drained every frame and opened as     /// documents.     pub inbox: Option<Inbox>,diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs--- a/crates/ui-egui/src/state.rs+++ b/crates/ui-egui/src/state.rs@@ -467,6 +467,9 @@ pub struct UiState {     /// Non-modal release-check window; readable and controllable through `ui.inspect`/`ui.set`.     #[serde(default)]     pub updates_open: bool,+    /// Plugins › Browse Plugins… / Manage Plugins… (`plugin_store_ui`).+    #[serde(default)]+    pub plugin_store: crate::plugin_store_ui::StoreView,     /// Pending GPU fallback warning, visible to automation.     #[serde(default)]     pub gpu_fallback_notice: Option<String>,@@ -580,6 +583,7 @@ impl Default for UiState {             status_error: false,             notices: Vec::new(),             updates_open: false,+            plugin_store: Default::default(),             gpu_fallback_notice: None,             kys_offer: None,             slow_refresh_noticed: Vec::new(),diff --git a/docs/control-protocol.md b/docs/control-protocol.md--- a/docs/control-protocol.md+++ b/docs/control-protocol.md@@ -179,7 +179,7 @@ automation permissions.  UI-level commands (`view.zoomIn`, `window.theme.pro`, `edit.search`, …) are also accepted by `engine.execute` and `ui.menu.invoke`. -Commands that read or write files by path, instead of through the automation roots, are refused with "automation command `…` uses ambient filesystem paths and is disabled; use capability-scoped document methods". That covers every `file.*` command except `file.new`, the `file.close*` commands and a few path-free ones such as `file.fileInfo`, so `file.open`, `file.save`, `file.saveAs` and `file.saveACopy` always fail here: open and save with `app.open` / `app.save`. Path parameters of other commands (`layer.exportAs {path}`, `filter.distort.displace {mapPath}`, preset imports, and plug-in install/reload) are refused the same way. `prefs.set` rejects whole-preference updates and file-backed sections (`colorSettings`, `scriptEvents`, `historyLog`, `plugIns` and `scratchDisks`) so automation cannot configure ambient file access indirectly. The desktop app also refuses `image.mode.*`, which can load the colour profiles set in its preferences. The rules are in `crates/automation/src/workspace.rs`.+Commands that read or write files by path, instead of through the automation roots, are refused with "automation command `…` uses ambient filesystem paths and is disabled; use capability-scoped document methods". That covers every `file.*` command except `file.new`, the `file.close*` commands and a few path-free ones such as `file.fileInfo`, so `file.open`, `file.save`, `file.saveAs` and `file.saveACopy` always fail here: open and save with `app.open` / `app.save`. Path parameters of other commands (`layer.exportAs {path}`, `filter.distort.displace {mapPath}`, preset imports, plug-in install/reload, and installing or uninstalling from the plug-in catalog, `plugin.store.install`/`plugin.store.uninstall`, which write the settings directory's Plug-ins folder; `plugin.store.list` is allowed) are refused the same way. `prefs.set` rejects whole-preference updates and file-backed sections (`colorSettings`, `scriptEvents`, `historyLog`, `plugIns` and `scratchDisks`) so automation cannot configure ambient file access indirectly. The desktop app also refuses `image.mode.*`, which can load the colour profiles set in its preferences. The rules are in `crates/automation/src/workspace.rs`.  Embedded Smart Objects can be opened with `layer.smartObjects.editContents {layer?}` or unpacked with `layer.smartObjects.convertToLayers {layer?}`. This includes Photoshop sourcesdiff --git a/docs/gaps.md b/docs/gaps.md--- a/docs/gaps.md+++ b/docs/gaps.md@@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (G1: Halftone Pattern Filter Gallery settings survive PSD/PSB saves (#8940); cropped selection morphology and command mask conversion performance evidence added; G9: 12 of 181 preferences do nothing, as the scorecard prefs audit counts them; G6: Art History Brush landed as partial, TOOL-213-20, #1610, so no toolbar tool is missing; G4: five hard-round built-in presets use 5% spacing to remove stacked-tip outlines and reduce pressure-opacity bands, #8842; G9: Crop tool canvas menu, #6561; G2: Content-Aware Scale seam carving 1.65–3.6× faster with unchanged output, P28 row 3.3 → 1.6 s on a 4-core VM, #5236; G9: UI scale presets done, 105–120% steps, #3222; G14: lossy DNG, raw sidecars, Camera Raw Defaults live, Calibration and Auto, CR3 assessed as not clean-room feasible; G8: PSD type on a path opens on its path and saves back into the file's `Txt2`, #7962; G11: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; adjustment clipping stays visible in compact Properties, #5231; G9: Color panel modes, ramp and copy commands, the same in every theme, UI-217-17, #6000; G17: Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; G10: `.asl` style libraries landed, #1051; G8: area type (TYPE-218-10); G1: smart objects keep Photoshop's Cylinder warp, #4112; G8: paragraph direction buttons in the Paragraph panel and options bar, TYPE-218-2 partial; G1: 32-bit Hue/Saturation and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; G11: compound layer properties export as separate Photoshop Set events, rename last, #8304; G11: Batch and droplets take the result at the action's Save step, so actions ending in Save As and Close work; G11: `.atn` steps that switch to another open document import, play and save back, #7103; G14: the Nikon D4 white level follows the sample depth, so 12-bit D4 NEFs no longer open 2.6 stops dark, #5978; G14: a Nikon NEF's Adobe RGB preview is read as Adobe RGB (maker-note ColorSpace 0x001E), #5987; Apply Layer Comp action mapping, #7101; G10: JPEG Options dialog landed, FILE-215-19 partial (no Matte, progressive Scans or size preview); G10: Cineon write; G10: DPX read and write, #1049, Cineon and Amiga IFF read; Classical Object Finder improvement; learned segmentation remains open; G4: named, stateful section locks and visible keyboard focus in Brush Settings; G4: independent Brush Pose tilt-axis overrides, #3825 partial; G4: Transfer Mixer-only controls, #3825 partial; G9: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget in local runs, baseline not re-run, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed; G10: Amiga IFF write; G10: PSD spot channels in Photoshop's polarity, earlier PhotoCraft PSDs keep their ink, #7415 split; G10: TIFF alpha and spot channels written and read as extra samples, FILE-215-7 done, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (G16: Plugins › Browse Plugins… installs community WebAssembly plug-ins from a hash-pinned GitHub catalog; G1: Halftone Pattern Filter Gallery settings survive PSD/PSB saves (#8940); cropped selection morphology and command mask conversion performance evidence added; G9: 12 of 181 preferences do nothing, as the scorecard prefs audit counts them; G6: Art History Brush landed as partial, TOOL-213-20, #1610, so no toolbar tool is missing; G4: five hard-round built-in presets use 5% spacing to remove stacked-tip outlines and reduce pressure-opacity bands, #8842; G9: Crop tool canvas menu, #6561; G2: Content-Aware Scale seam carving 1.65–3.6× faster with unchanged output, P28 row 3.3 → 1.6 s on a 4-core VM, #5236; G9: UI scale presets done, 105–120% steps, #3222; G14: lossy DNG, raw sidecars, Camera Raw Defaults live, Calibration and Auto, CR3 assessed as not clean-room feasible; G8: PSD type on a path opens on its path and saves back into the file's `Txt2`, #7962; G11: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; adjustment clipping stays visible in compact Properties, #5231; G9: Color panel modes, ramp and copy commands, the same in every theme, UI-217-17, #6000; G17: Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; G10: `.asl` style libraries landed, #1051; G8: area type (TYPE-218-10); G1: smart objects keep Photoshop's Cylinder warp, #4112; G8: paragraph direction buttons in the Paragraph panel and options bar, TYPE-218-2 partial; G1: 32-bit Hue/Saturation and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; G11: compound layer properties export as separate Photoshop Set events, rename last, #8304; G11: Batch and droplets take the result at the action's Save step, so actions ending in Save As and Close work; G11: `.atn` steps that switch to another open document import, play and save back, #7103; G14: the Nikon D4 white level follows the sample depth, so 12-bit D4 NEFs no longer open 2.6 stops dark, #5978; G14: a Nikon NEF's Adobe RGB preview is read as Adobe RGB (maker-note ColorSpace 0x001E), #5987; Apply Layer Comp action mapping, #7101; G10: JPEG Options dialog landed, FILE-215-19 partial (no Matte, progressive Scans or size preview); G10: Cineon write; G10: DPX read and write, #1049, Cineon and Amiga IFF read; Classical Object Finder improvement; learned segmentation remains open; G4: named, stateful section locks and visible keyboard focus in Brush Settings; G4: independent Brush Pose tilt-axis overrides, #3825 partial; G4: Transfer Mixer-only controls, #3825 partial; G9: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget in local runs, baseline not re-run, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed; G10: Amiga IFF write; G10: PSD spot channels in Photoshop's polarity, earlier PhotoCraft PSDs keep their ink, #7415 split; G10: TIFF alpha and spot channels written and read as extra samples, FILE-215-7 done, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026  Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that@@ -205,6 +205,10 @@ what an agent can't finish alone. - **Missing:** hosting Photoshop .8BF filters (#2647), UXP panels, CEP. PhotoCraft has its own   sandboxed WebAssembly plug-in ABI v1 (`docs/plugins.md`), but ABI v2 (panels, progress, other   layers) is missing (AUTO-219-6).+- **Progress (2026-10-26):** Photoshop's Plugins menu with Browse Plugins… and Manage Plugins…: a+  bundled catalog of 46 community ABI v1 filters from three GitHub projects, pinned by URL, size and+  SHA-256, downloaded in the background, installed and kept between runs (`plugin.store.*`). The+  web build has no download transport yet; the catalog is updated by hand, not fetched. - **Estimate:** 250–450 h. **Human:** .8BF needs native code under the isolated-`unsafe` rule.  ## UI / UX gaps@@ -397,6 +401,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| | 2026-10-27 | minor | G9: 12 of 181 preferences do nothing, the scorecard's count with integration batch 9 (#9775) on main; no estimate changed |+| 2026-10-26 | minor | G16: Plugins › Browse Plugins… / Manage Plugins… over a hash-pinned catalog of 46 GitHub-hosted ABI v1 plug-ins; no estimate changed | | 2026-10-26 | minor | Expand/Contract/Border distance work, mask reads and replacement cropped to nonzero bounds; bounded distances for radii ≤5; 36 MP exact-output comparisons improve 1.54–37.82× across eight cases; percentages, estimates and scorecard budgets unchanged | | 2026-10-25 | minor | G9: the count of preferences that do nothing is the scorecard's again, 12 of 178 (it still said 24 of 171, from 2026-10-15); no estimate changed | | 2026-10-25 | minor | G6: Art History Brush landed as partial (TOOL-213-20, #1610), so no toolbar tool is missing; no percentage or estimate changed |diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md--- a/docs/parity-checklist.md+++ b/docs/parity-checklist.md@@ -2,7 +2,7 @@  Generated by `cargo xtask parity` from `crates/ui-egui/src/menu_catalog.rs` (Photoshop's menu tree) and the live command registry. Do not edit by hand. -**631 of 631 menu items live (100.0%).**+**633 of 633 menu items live (100.0%).**  | Menu | Live | Total | % | |---|---:|---:|---:|@@ -14,6 +14,7 @@ Generated by `cargo xtask parity` from `crates/ui-egui/src/menu_catalog.rs` (Pho | Select | 25 | 25 | 100% | | Filter | 75 | 75 | 100% | | View | 74 | 74 | 100% |+| Plugins | 2 | 2 | 100% | | Window | 61 | 61 | 100% |  ## Not yet implementeddiff --git a/docs/plugins.md b/docs/plugins.md--- a/docs/plugins.md+++ b/docs/plugins.md@@ -25,6 +25,34 @@ preview when they have parameters), next to **Install Plug-in…**. installed at startup and whenever the preference changes (native builds only; the web build installs from bytes). +**Browse Plugins (the catalog).** **Plugins › Browse Plugins…** lists community plug-ins published on+GitHub; **Plugins › Manage Plugins…** shows the ones installed from it. Tick several and press+*Install Selected*, or use a row's *Install* / *Update* / *Uninstall*. The catalog is bundled+(`crates/engine/src/cmd/plugin_store_cmds/catalog.json`) and pins each module by HTTPS URL (GitHub hosts+only), size and SHA-256: a download that differs from what the catalog lists is refused, as is a+module whose manifest id isn't the listed one. Verified modules install at once and are saved as+`<id>.wasm` in the `Plug-ins` folder of the settings directory, which loads at every start. The+desktop app downloads through `craft-update`'s HTTPS client; the web build has no transport yet.++| Command | Params | What it does |+|---|---|---|+| `plugin.store.list` | `{"query": "…", "installed": bool = false}` | `{"available", "busy", "plugins": [{id, name, version, author, description, collection, repo, license, size, installed, installedVersion, updateAvailable, job?}]}`; `job` is `{"state": "queued" \| "downloading" \| "failed", "message"?}`. |+| `plugin.store.install` | `{"id": "…"}` or `{"ids": […]}`, `"reinstall": bool = false` | Queues downloads (four at a time) and returns at once; poll `plugin.store.list`. Up-to-date plug-ins are skipped unless `reinstall`. |+| `plugin.store.uninstall` | `{"id": "…"}` or `{"ids": […]}` | Uninstalls, cancels a queued or running download, and deletes the saved copy. |++The control channel and MCP refuse `plugin.store.install` and `plugin.store.uninstall`, as they do+`plugin.reload`: they write the settings directory, outside the automation roots. `plugin.store.list`+works, and `plugin.install {"data"}` still installs a module for the session.++The catalog was assembled on 2026-10-26 from the public repositories that ship ABI v1 modules:+[akkk09/artcraft-store](https://github.com/akkk09/artcraft-store) (12 filters),+[CHJ85/PhotoCraft-Plugins](https://github.com/CHJ85/PhotoCraft-Plugins) (33, pinned to a commit) and+[Mitacore/photocraft-plugin-posterize](https://github.com/Mitacore/photocraft-plugin-posterize) (1),+all MIT-licensed. PhotoCraft distributes only the links and checksums, not the modules. To add a+plug-in, add a listing with its URL, size and `sha256sum`; a native-code companion+(`kobaltgit/photocraft-companion`'s `.pcpl` packages) is a separate program, not an ABI v1 module, and+isn't listed.+ Plug-ins are installed **per process**, not per document. A smart filter recorded from a plug-in (`{"command": "plugin.run", "params": {"id", "params"}}`) re-runs on re-render while that plug-in is installed; if it is missing, the filter is skipped (the cached pixels stay as they were), like other