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.rs
new 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.rs
new 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.json
new 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 file
diff --git a/crates/engine/src/cmd/plugin_store_cmds/tests.rs b/crates/engine/src/cmd/plugin_store_cmds/tests.rs
new 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 the
diff --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.rs
new 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.rs
new 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 sources
diff --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 implemented
diff --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
