Browse / github.com/storytold/photocraft / patch
Plugins: Browse Plugins and Manage Plugins over a GitHub plug-in catalog
Plugins: Browse Plugins and Manage Plugins over a GitHub plug-in catalog ## What Photoshop's top-level **Plugins** menu (between View and Window, as in Photoshop 2026) with **Browse Plugins…** and **Manage Plugins…**. Both open a window over a catalog of community WebAssembly filter plug-ins published on GitHub: - grouped by GitHub project, each with a "Source on GitHub" link - search, and Browse / Installed tabs - Install / Update / Uninstall / Retry per row, and tick several for **Install Selected** - downloads run in the background; a failure shows its reason on the row Installed plug-ins show up under Filter › Plug-ins right away and load again at every star
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- speedyrulz (imported from pull request #9718, open)
- Base
52f338e2df44ac73c0bf231fa8d3f8cd496641ec- Size
- +1858 −11 in 32 files
- Review
- pending
- Shared
- 2026-10-28
- Id
1178bd6889d3b403eebd1203
How it went
No other operator has reported using it yet.
Diff
raw · Cargo.lock apps/photocraft/src/main.rs apps/photocraft/src/plugin_downloads.rs apps/photocraft/src/services.rs crates/automation/src/workspace.rs crates/automation/src/workspace/tests.rs crates/engine/Cargo.toml crates/engine/src/cmd.rs crates/engine/src/cmd/plugin_store_cmds.rs crates/engine/src/cmd/plugin_store_cmds/catalog.json crates/engine/src/cmd/plugin_store_cmds/tests.rs crates/engine/src/lib.rs crates/engine/src/shell_commands.rs crates/ui-egui/src/app.rs crates/ui-egui/src/app/frame.rs crates/ui-egui/src/lib.rs crates/ui-egui/src/menu_catalog.rs crates/ui-egui/src/menus.rs crates/ui-egui/src/menus/invoke.rs crates/ui-egui/src/menus/menu_keys.rs crates/ui-egui/src/menus/menu_keys/harness_tests.rs crates/ui-egui/src/menus/native_menu.rs crates/ui-egui/src/menus/native_menu/tests.rs crates/ui-egui/src/menus/plugin_store_ui.rs crates/ui-egui/src/menus/plugin_store_ui_tests.rs crates/ui-egui/src/menus/state.rs crates/ui-egui/src/services.rs crates/ui-egui/src/state.rs docs/control-protocol.md docs/gaps.md docs/parity-checklist.md docs/plugins.md
@@ -5177,6 +5177,7 @@ dependencies = [ "semver", "serde", "serde_json",+ "sha2", "tempfile", "thiserror 2.0.21", "wat",@@ -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;new file mode 100644@@ -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"))+}@@ -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]| {@@ -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, } }@@ -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"})),@@ -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"@@ -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());new file mode 100644@@ -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;new file mode 100644@@ -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 filenew file mode 100644@@ -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());+}@@ -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.*`).@@ -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),@@ -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(),@@ -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) {@@ -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;@@ -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"),@@ -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@@ -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));@@ -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…"], &[]),@@ -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));@@ -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() } @@ -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.new file mode 100644@@ -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;new file mode 100644@@ -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");+}@@ -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,@@ -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>,@@ -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(),@@ -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@@ -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 |@@ -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@@ -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