diff --git a/apps/photocraft-web/Cargo.toml b/apps/photocraft-web/Cargo.toml index a4ded64..f712053 100644 --- a/apps/photocraft-web/Cargo.toml +++ b/apps/photocraft-web/Cargo.toml @@ -26,6 +26,7 @@ photocraft-doc = { workspace = true } photocraft-ui-egui = { workspace = true } egui = { workspace = true } log = { workspace = true } +serde_json = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] # `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback. diff --git a/apps/photocraft-web/src/api.rs b/apps/photocraft-web/src/api.rs new file mode 100644 index 0000000..16dead9 --- /dev/null +++ b/apps/photocraft-web/src/api.rs @@ -0,0 +1,122 @@ +//! Community web command bridge for photocraft.run (2026-10-17). +//! Uses the existing UI control queue; no network listener or file-upload service. +use photocraft_ui_egui::ControlRequest; +use serde_json::{Value, json}; +use std::cell::RefCell; +use std::sync::mpsc::{Receiver, Sender, TryRecvError}; +use wasm_bindgen::prelude::*; + +type Waiting = (Receiver, js_sys::Function, js_sys::Function); +thread_local! { + static TX: RefCell>> = const { RefCell::new(None) }; + static WAITING: RefCell> = const { RefCell::new(Vec::new()) }; + static CTX: RefCell> = const { RefCell::new(None) }; +} + +fn from_js(v: &JsValue) -> Result { + if v.is_null() || v.is_undefined() { + return Ok(json!({})); + } + let text = js_sys::JSON::stringify(v)?.as_string().ok_or_else(|| js_sys::Error::new("parameters must be JSON"))?; + if text.len() > 1_048_576 { + return Err(js_sys::Error::new("request exceeds 1 MiB").into()); + } + let value: Value = serde_json::from_str(&text).map_err(|e| js_sys::Error::new(&e.to_string()))?; + if !value.is_object() { + return Err(js_sys::Error::new("parameters must be an object").into()); + } + Ok(value) +} + +fn request(method: String, params: Value) -> js_sys::Promise { + if !matches!(method.as_str(), "engine.execute" | "engine.commands" | "ui.inspect" | "ui.screenshot") { + return js_sys::Promise::reject(&js_sys::Error::new("unsupported web method")); + } + if WAITING.with(|w| w.borrow().len() >= 32) { + return js_sys::Promise::reject(&js_sys::Error::new("too many pending requests")); + } + let promise = js_sys::Promise::new(&mut |resolve, reject| { + let (req, rx) = ControlRequest::new(method.clone(), params.clone()); + if TX.with(|t| t.borrow().as_ref().is_some_and(|tx| tx.send(req).is_ok())) { + WAITING.with(|w| w.borrow_mut().push((rx, resolve, reject))); + } else { + let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new("PhotoCraft is not ready")); + } + }); + CTX.with(|c| { + if let Some(ctx) = c.borrow().as_ref() { + ctx.request_repaint(); + } + }); + promise +} + +pub fn poll_replies() { + WAITING.with(|w| { + w.borrow_mut().retain(|(rx, resolve, reject)| match rx.try_recv() { + Ok(v) => { + if v.get("ok").and_then(Value::as_bool) == Some(true) { + match js_sys::JSON::parse(&v.get("result").unwrap_or(&Value::Null).to_string()) { + Ok(value) => { + let _ = resolve.call1(&JsValue::NULL, &value); + } + Err(error) => { + let _ = reject.call1(&JsValue::NULL, &error); + } + } + } else { + let error = js_sys::Error::new(v.get("error").and_then(Value::as_str).unwrap_or("command failed")); + let _ = reject.call1(&JsValue::NULL, &error); + } + false + } + Err(TryRecvError::Empty) => true, + Err(TryRecvError::Disconnected) => { + let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new("editor closed")); + false + } + }) + }); +} + +pub fn install(ctx: egui::Context) -> Result, String> { + let (tx, rx) = std::sync::mpsc::channel(); + TX.with(|t| *t.borrow_mut() = Some(tx)); + CTX.with(|c| *c.borrow_mut() = Some(ctx)); + let obj = js_sys::Object::new(); + let set = |name: &str, value: JsValue| js_sys::Reflect::set(&obj, &name.into(), &value).map_err(|_| format!("could not install {name}")); + set("apiVersion", "craft-online-1".into())?; + set( + "request", + Closure:: js_sys::Promise>::new(|method: String, params: JsValue| match from_js(¶ms) { + Ok(p) => request(method, p), + Err(e) => js_sys::Promise::reject(&e), + }) + .into_js_value(), + )?; + set( + "execute", + Closure:: js_sys::Promise>::new(|command: String, params: JsValue, expected: JsValue| { + let parsed = from_js(¶ms).and_then(|p| { + let mut value = json!({"command": command, "params": p}); + if !expected.is_undefined() && !expected.is_null() { + value["expected"] = from_js(&expected)?; + } + Ok(value) + }); + match parsed { + Ok(p) => request("engine.execute".into(), p), + Err(e) => js_sys::Promise::reject(&e), + } + }) + .into_js_value(), + )?; + set("commands", Closure:: js_sys::Promise>::new(|| request("engine.commands".into(), json!({}))).into_js_value())?; + set( + "inspect", + Closure:: js_sys::Promise>::new(|| request("engine.execute".into(), json!({"command":"document.inspect","params":{}}))).into_js_value(), + )?; + let win = web_sys::window().ok_or("no window")?; + js_sys::Reflect::set(&win, &"photocraft".into(), &obj).map_err(|_| "could not install window.photocraft")?; + Ok(rx) +} diff --git a/apps/photocraft-web/src/main.rs b/apps/photocraft-web/src/main.rs index 5ba75f5..12484eb 100644 --- a/apps/photocraft-web/src/main.rs +++ b/apps/photocraft-web/src/main.rs @@ -22,6 +22,9 @@ mod preset_bridge; #[cfg(target_arch = "wasm32")] mod indexed_presets; +#[cfg(target_arch = "wasm32")] +mod api; + #[cfg(target_arch = "wasm32")] mod web; diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs index a5c9d8a..860b5e0 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -83,7 +83,9 @@ pub fn start() { load_font_manifest(served.clone(), cc.egui_ctx.clone()); let (session, warnings) = crate::preset_bridge::session(presets.clone()); preset_warnings.extend(warnings); - let mut app = PhotocraftApp::new(session, services(inbox.clone())); + // Community patch: expose the existing control channel to the hosting page. + let control = crate::api::install(cc.egui_ctx.clone())?; + let mut app = PhotocraftApp::new(session, services(inbox.clone())).with_control(control); if !preset_warnings.is_empty() { photocraft_ui_egui::notices::post(&mut app, i18n::t("Some brush presets could not be loaded"), preset_warnings, true, None); } @@ -301,6 +303,7 @@ impl eframe::App for WebShell { } self.serve_fonts(ctx); self.app.logic(ctx, frame); + crate::api::poll_replies(); self.save_presets(ctx); } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 209691b..04ad42d 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -405,6 +405,18 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) ok(json!({"command": id, "dialog": app.ui.dialogs.last().map(|d| d.id)})) } "engine.execute" | "ui.menu.invoke" => { + // Community web bridge: reject stale agent edits before dispatch, atomically. + if let Some(expected) = p.get("expected") { + let current = app.session.active(); + let matches = expected.get("document").and_then(Value::as_u64).and_then(|v| usize::try_from(v).ok()) == app.session.active_index() + && current.is_some_and(|d| { + expected.get("revision").and_then(Value::as_u64) == Some(d.revision) + && expected.get("name").and_then(Value::as_str) == Some(d.doc.name.as_str()) + }); + if !matches { + return err("document changed since inspection; read the current document before editing"); + } + } let Some(id) = s("command").or(s("id")) else { return err("missing `command`") }; let params = p.get("params").cloned().unwrap_or(json!({})); // A control-channel menu click must respect the same modal gate as the native menu. @@ -1658,6 +1670,23 @@ mod tests { assert_eq!(fields["__preset"], "Clipboard"); } + #[test] + fn community_web_edit_rejects_stale_revision_and_is_undoable() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 8, "height": 8})).unwrap(); + let ctx = egui::Context::default(); + let doc = app.session.active().unwrap(); + let before = doc.doc.layers.len(); + let expected = json!({"document": 0, "revision": doc.revision, "name": doc.doc.name}); + let params = json!({"command":"layer.new.layer", "params":{"name":"Agent"}, "expected":expected}); + assert_eq!(call(&mut app, &ctx, "engine.execute", params.clone())["ok"], true); + assert_eq!(app.session.active().unwrap().doc.layers.len(), before + 1); + assert_eq!(call(&mut app, &ctx, "engine.execute", params)["ok"], false); + assert_eq!(app.session.active().unwrap().doc.layers.len(), before + 1); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.active().unwrap().doc.layers.len(), before); + } + #[test] fn expired_queued_edit_does_not_run_and_a_live_retry_runs_once() { use std::time::{Duration, Instant};