Browse / github.com/storytold/figcraft / patch
feat(web): a host bridge for embedding pages, ?host=parent and data-host="page" (#1356)
Web: a host bridge for embedding pages (?host=parent and same-document data-host="page") The FigCraft web build has no host bridge, so a page that embeds it can't hand it a file or get its saves back: every save becomes a browser download, and the page can't tell whether there are unsaved changes. GridCraft, PdfCraft, WordCraft and CADCraft already have this as `?host=parent` (an iframe bridge), and storytold/photocraft#6450 asks for an embedding API across the suite. **Suggestion.** The same bridge as those apps, with both transports from the start: - `?host=parent`: a same-origin page that frames FigCraft talks over `postMessage`, exactly like the other apps' bridges (no bridg
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- helenkwok (imported from pull request #1358, open)
- Base
740d117313d7476e29b84f8697af45c40404a270- Size
- +570 −12 in 8 files
- Review
- pending
- Shared
- 2026-10-28
- Id
196d84d23495c266bbf94fc2
How it went
No other operator has reported using it yet.
Diff
raw · ROADMAP.md apps/figcraft-web/Cargo.toml apps/figcraft-web/src/host.rs apps/figcraft-web/src/hostmode.rs apps/figcraft-web/src/main.rs apps/figcraft-web/src/web.rs contributors/people.toml packaging/web/README.md
@@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (web host bridge: `?host=parent` in an iframe or `data-host="page"` on the canvas hands a page the app's files instead of downloads and reports unsaved changes, documented in the hosting guide; colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through@@ -133,6 +133,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log - **2026-10-26** · Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` runs the name through `deunicode` before its ASCII filter, so 로그인 화면 → `rogeuin-hwamyeon`, кнопка → `knopka`, über → `uber` in the HTML/CSS/React/Leptos class names, component roots and `data-*` variant attributes (SwiftUI/Compose type names unchanged); a name left with nothing (symbols only) takes the node's kind (`.text`, `.frame`) instead of `layer-`, and the 40-character cut lands on a word boundary. New dependency: deunicode (pure Rust tables, zero deps, BSD-3-Clause, the `slug` crate's base). `html_codegen_tests`. Numbers unchanged.+- **2026-10-26** · Web host bridge: `?host=parent` (a same-origin page embedding FigCraft in an iframe) or `data-host="page"` on the canvas (the page's own document, over non-bubbling `CustomEvent`s) opens the host's file like File ▸ Open (over the browser open limit of 512 MB refused) and takes every file the app would otherwise download (`figcraft:save` with the `saveAs` rule), plus `figcraft:dirty`, `figcraft:ready` and `figcraft:failed`; `?author=` names the user for comments and board notes; host mode starts with no document. Unit tests cover the opt-in rule (only the exact `page` value), the `saveAs` rule and the open-name fallback. Numbers unchanged. - **2026-10-25** · egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. - **2026-10-24** · Open, Save and the folder dialogs start in the folder of the last file or folder picked in the run (#1290): the Linux portal does not remember it between dialogs as the macOS and Windows panels do. A folder deleted since falls back to the system's choice. No numbers change. - **2026-10-24** · Boards (M12): board pages get a dot grid under their layers (drawn by the canvas render after the page colour, thinned out when zoomed out); a selected connector shows its ends as handles, and dragging one onto another object re-attaches it there, onto empty canvas leaves it free (`board.reattach`, one undo step, also over MCP); connectors can be curved, and elbows go around the objects they join when the short way would cross them. Checked in the app, through MCP and in the web build ([boards-parity](docs/boards-parity.md)).@@ -349,6 +350,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| | 2026-10-26 | minor | Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` transliterates through the new `deunicode` dependency (pure Rust tables, zero deps, BSD-3-Clause), so Korean, Cyrillic, Latin-diacritic and CJK names become readable ASCII classes (`rogeuin-hwamyeon`, `knopka`, `uber`, `an-niu`) in HTML/CSS/React/Leptos output, component roots and `data-*` variant attributes; symbols-only names take the node's kind (`.text`) instead of `layer-`, and the 40-character cut lands on a word boundary. `html_codegen_tests` updated and extended. Numbers unchanged |+| 2026-10-26 | minor | Web host bridge: `?host=parent` (a same-origin page embedding FigCraft in an iframe) or `data-host="page"` on the canvas (the page's own document, over non-bubbling `CustomEvent`s) opens the host's file like File ▸ Open and takes every file the app would otherwise download, with `figcraft:ready`, `figcraft:open`, `figcraft:save` (a `saveAs` rule), `figcraft:dirty` and `figcraft:failed`; `?author=` names the user for comments and board notes; documented in the hosting guide. Numbers unchanged | | 2026-10-25 | minor | egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. | | 2026-10-24 | minor | Server memory (#1134): when the last person leaves a file nobody edited, figcraft-server keeps the client ids it issued in the log instead of rewriting the snapshot (compaction still follows `compact_after`/`compact_mb` and edits), and a snapshot is encoded straight into its file buffer, without a second copy of the body; the file format is unchanged. Numbers unchanged | | 2026-10-24 | minor | Boards (M12): a dot grid on board pages under the layers, connector ends dragged onto other objects or empty canvas (`board.reattach`), curved connectors, elbows that go around what they join. Boards row ~35% → ~37%, ~20% → ~21%, 30–60 h → 28–56 h; headline numbers unchanged (boards are outside the weighted sum) |@@ -28,4 +28,4 @@ base64 = { workspace = true } serde_json = { workspace = true } js-sys = "0.3" wasm-bindgen-futures = "0.4"-web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "File", "FileList", "HtmlInputElement", "EventTarget", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType"] }+web-sys = { version = "0.3", features = ["Window", "Document", "Element", "Event", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "File", "FileList", "HtmlInputElement", "EventTarget", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType", "MessageEvent", "UrlSearchParams", "CustomEvent", "CustomEventInit"] }new file mode 100644@@ -0,0 +1,278 @@+//! Host modes: a page that runs FigCraft (as an `<iframe>`, or in its own document) opens+//! documents in it and stores what it saves instead of the browser downloading. The same+//! messages as the other Crafting Apps' bridges.+//!+//! ## `?host=parent` — an `<iframe>` (such as a Nextcloud app)+//!+//! The framing page and FigCraft talk over `postMessage` between same-origin windows. Messages+//! are plain objects with a `type`:+//!+//! - `figcraft:ready` (to the parent): `{ version }`, sent once the app is running and+//! listening; send a file now.+//! - `figcraft:open` (from the parent): `{ name, bytes }`, bytes an `ArrayBuffer` or+//! `Uint8Array`. It opens exactly as a file picked with File › Open or dropped on the window+//! would (the app's own open queue, with its "Opening as a new file" card). A payload over the+//! browser's own open limit ([`figcraft_ui_egui::browser_can_read`]'s 512 MB) is refused before+//! its bytes are copied, and anything malformed is ignored.+//! - `figcraft:save` (to the parent): `{ name, saveAs, bytes }` (a transferred `ArrayBuffer`) for+//! every file the app hands its download service — in host mode nothing reaches the browser's+//! downloads. The parent stores it and reports failures itself. `saveAs` says whether the file+//! becomes the document's file ([`crate::hostmode::save_as`]):+//! - A Save of the file the host opened, under its own name: `false`.+//! - A document file (`.figcraft` or `.json`, the formats `file.save` and `file.saveAs` write)+//! under a name the host didn't open — Save As, or a Save of a document the host never sent+//! (an untitled one): `true`. It becomes the document's file; the next Save sends its name+//! with `false`. Names compare without case.+//! - Everything else is a copy or export that never becomes the document: `false` — rendered+//! images, SVG and PDF, `.fig` copies, and the variables exports (a `.variables.json`+//! sidecar's `.json` extension doesn't make it a document).+//! - `figcraft:dirty` (to the parent): `{ dirty }` whenever unsaved changes appear or go away.+//! - `figcraft:failed` (to the parent): `{ error }` when the app couldn't start.+//!+//! Only the parent window on the page's own origin is heard or answered.+//!+//! ## `data-host="page"` — the page's own document+//!+//! A page that loads the module into its own document (its own `<canvas id="figcraft_canvas"+//! data-host="page">`, no iframe) gets the same behaviour over DOM `CustomEvent`s on the canvas+//! instead of `postMessage`: the same four events outward (`ready`, `save`, `dirty`, `failed`,+//! with the message fields as the event's `detail`), and the page dispatches `figcraft:open` on+//! the canvas with `detail: { name, bytes }` to open a document. Events don't bubble, so two+//! editors on one page can't hear each other, and the canvas is the only target FigCraft+//! listens on.+//!+//! In host mode the app starts with no document (the home screen) and the host sends the file to+//! edit; `?author=` names the user for comments and board notes.++use std::cell::{Cell, RefCell};+use std::rc::Rc;++use figcraft_ui_egui::FigApp;+use wasm_bindgen::JsCast as _;+use wasm_bindgen::JsValue;+use wasm_bindgen::closure::Closure;++/// The longest user name taken from `?author=`.+const MAX_AUTHOR_CHARS: usize = 200;++/// Files the host sent, on their way to the app's own open queue ([`figcraft_ui_egui::pending_open`]):+/// `(name, bytes)`.+pub type OpenQueue = Rc<RefCell<Vec<(String, Vec<u8>)>>>;++/// How a host page is reached. The modes behave the same; they differ only in how an event goes+/// out ([`Transport::send`]) and how an open request comes in ([`Bridge::listen`]).+#[derive(Clone)]+enum Transport {+ /// `?host=parent`: `postMessage` between same-origin windows.+ Parent { window: web_sys::Window, parent: web_sys::Window, origin: String },+ /// `data-host="page"`: DOM `CustomEvent`s on the editor's own canvas.+ Page { canvas: web_sys::HtmlCanvasElement },+}++impl Transport {+ /// Hand an event to the host. In parent mode this is a `postMessage` whose object carries a+ /// `type` field; in page mode a `CustomEvent` of that name on the canvas, whose `detail`+ /// holds the fields and which never bubbles (two editors on one page must not hear each+ /// other). `transfer` hands an `ArrayBuffer` to the parent instead of copying it (parent+ /// mode only; the page's document already shares the buffer's bytes).+ fn send(&self, event: &str, fields: &[(&str, JsValue)], transfer: Option<&js_sys::ArrayBuffer>) -> Result<(), String> {+ match self {+ Transport::Parent { parent, origin, .. } => {+ let mut message_fields = Vec::with_capacity(fields.len() + 1);+ message_fields.push(("type", JsValue::from_str(event)));+ message_fields.extend_from_slice(fields);+ let message = message(&message_fields).ok_or_else(|| "couldn't build the message".to_string())?;+ let sent = match transfer {+ Some(buffer) => parent.post_message_with_transfer(&message, origin, &js_sys::Array::of1(buffer)),+ None => parent.post_message(&message, origin),+ };+ sent.map_err(|e| js_error(&e, "couldn't post the message"))+ }+ Transport::Page { canvas } => {+ let detail = message(fields).ok_or_else(|| "couldn't build the event detail".to_string())?;+ let init = web_sys::CustomEventInit::new();+ // The builder methods (`bubbles(..)`, `detail(..)`) are deprecated in web-sys+ // 0.3.106 and fail clippy -D warnings; use the setters.+ init.set_bubbles(false);+ init.set_detail(&detail);+ let custom = web_sys::CustomEvent::new_with_event_init_dict(event, &init).map_err(|e| js_error(&e, "couldn't create the event"))?;+ // A listener calling `preventDefault` would be the bool; not a send failure.+ canvas.dispatch_event(&custom).map_err(|e| js_error(&e, "couldn't dispatch the event"))?;+ Ok(())+ }+ }+ }+}++#[derive(Clone)]+pub struct Bridge {+ transport: Transport,+ /// The file the host opened, and after a `saveAs` hand-over the document's file: the name a+ /// Save under (`saveAs: false`) is decided against.+ host_file: Rc<RefCell<Option<String>>>,+ /// The unsaved-changes state last reported to the host.+ reported: Rc<Cell<Option<bool>>>,+}++impl Bridge {+ /// The parent bridge for `?host=parent`. `None` when the page isn't framed (there is no+ /// parent to talk to), so opening that URL directly stays the normal app with downloads, or+ /// when the parent is on another origin: messages only go to this origin, so the browser+ /// would drop every save without an error while the document showed as saved.+ pub fn new() -> Option<Self> {+ let window = web_sys::window()?;+ let parent = window.parent().ok().flatten()?;+ if js_sys::Object::is(&parent, &window) {+ return None;+ }+ let origin = window.location().origin().ok()?;+ // An opaque origin (a `file://` page in some browsers) can't be a `postMessage` target.+ if origin == "null" {+ return None;+ }+ // Reading another origin's location throws, which is `None` too.+ if parent.location().origin().ok()? != origin {+ return None;+ }+ Some(Self { transport: Transport::Parent { window, parent, origin }, host_file: Rc::default(), reported: Rc::default() })+ }++ /// The page bridge for `data-host="page"`: the host page runs FigCraft in its own document+ /// and hears the editor's DOM `CustomEvent`s on this canvas.+ pub fn for_page(canvas: &web_sys::HtmlCanvasElement) -> Self {+ Self { transport: Transport::Page { canvas: canvas.clone() }, host_file: Rc::default(), reported: Rc::default() }+ }++ /// Hear the files the host sends, and queue each one on `opens` for the app's own open path+ /// (the shell drains it into [`figcraft_ui_egui::pending_open`] every frame, so a host file+ /// opens like a dropped one).+ pub fn listen(&self, opens: OpenQueue, ctx: egui::Context) {+ let host_file = self.host_file.clone();+ match &self.transport {+ Transport::Parent { window, parent, origin } => {+ let parent = parent.clone();+ let origin = origin.clone();+ let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| {+ if event.origin() != origin || !event.source().is_some_and(|s| js_sys::Object::is(&s, &parent)) {+ return;+ }+ let data = event.data();+ if string(&data, "type").as_deref() != Some("figcraft:open") {+ return;+ }+ deliver_open(&opens, &ctx, &host_file, &data);+ });+ if window.add_event_listener_with_callback("message", on_message.as_ref().unchecked_ref()).is_ok() {+ // The listener lives as long as the page.+ on_message.forget();+ }+ }+ Transport::Page { canvas } => {+ let on_open = Closure::<dyn FnMut(web_sys::Event)>::new(move |event: web_sys::Event| {+ // A plain `Event` of the same name carries no detail; only a `CustomEvent`.+ let Some(open) = event.dyn_ref::<web_sys::CustomEvent>() else { return };+ deliver_open(&opens, &ctx, &host_file, &open.detail());+ });+ if canvas.add_event_listener_with_callback("figcraft:open", on_open.as_ref().unchecked_ref()).is_ok() {+ // The listener lives as long as the page.+ on_open.forget();+ }+ }+ }+ }++ /// `?author=`: the host's name for the user signs comments and board notes.+ pub fn set_author(app: &mut FigApp, author: &str) {+ let author: String = author.trim().chars().take(MAX_AUTHOR_CHARS).collect();+ if !author.is_empty() {+ app.session.prefs.user_name = author;+ }+ }++ pub fn post_ready(&self) {+ let _ = self.transport.send("figcraft:ready", &[("version", env!("CARGO_PKG_VERSION").into())], None);+ }++ /// The app couldn't start (no WebGPU or WebGL2, for example): let the host say so.+ pub fn post_failed(&self, error: &str) {+ let _ = self.transport.send("figcraft:failed", &[("error", error.into())], None);+ }++ /// Hand a file the app would otherwise download to the host; `false` when nothing was+ /// delivered. The `saveAs` rule is this module's documentation's.+ pub fn post_save(&self, path: &str, bytes: &[u8]) -> bool {+ let name = file_name(path);+ let host_file = self.host_file.borrow().clone();+ let save_as = crate::hostmode::save_as(&name, host_file.as_deref());+ // `figcraft:save` carries the bytes as an `ArrayBuffer`; the parent bridge transfers it.+ let buffer = js_sys::Uint8Array::from(bytes).buffer();+ let fields: [(&str, JsValue); 3] = [("name", name.as_str().into()), ("saveAs", save_as.into()), ("bytes", buffer.clone().into())];+ let sent = self.transport.send("figcraft:save", &fields, Some(&buffer)).is_ok();+ // After a Save As hand-over the host has the new file: the next Save of it sends its name+ // with `saveAs: false`. A failed send changes nothing, so a retry is still a Save As.+ if sent && save_as {+ *self.host_file.borrow_mut() = Some(name);+ }+ sent+ }++ /// Report unsaved changes when they appear or go away, not every frame.+ pub fn set_dirty(&self, dirty: bool) {+ if self.reported.get() != Some(dirty) && self.transport.send("figcraft:dirty", &[("dirty", dirty.into())], None).is_ok() {+ self.reported.set(Some(dirty));+ }+ }+}++/// The file an open message or event carries, queued for the app's own open path, or nothing when+/// the payload isn't one (no bytes, or over the open limit).+fn deliver_open(opens: &OpenQueue, ctx: &egui::Context, host_file: &RefCell<Option<String>>, data: &JsValue) {+ let name = crate::hostmode::open_name(string(data, "name").as_deref());+ let Some(bytes) = payload_bytes(data, &name) else { return };+ // The document's file is now the one the host sent: a Save of it under this name is+ // `saveAs: false`.+ *host_file.borrow_mut() = Some(name.clone());+ opens.borrow_mut().push((name, bytes));+ ctx.request_repaint();+}++/// The `bytes` of an open message or event as owned bytes, refused over the browser's own open+/// limit ([`figcraft_ui_egui::browser_can_read`]) before they are copied. `bytes` is an+/// `ArrayBuffer` or `Uint8Array`.+fn payload_bytes(data: &JsValue, name: &str) -> Option<Vec<u8>> {+ let bytes = js_sys::Reflect::get(data, &JsValue::from_str("bytes")).ok()?;+ let len = if let Some(array) = bytes.dyn_ref::<js_sys::Uint8Array>() {+ array.length() as f64+ } else {+ bytes.dyn_ref::<js_sys::ArrayBuffer>()?.byte_length() as f64+ };+ if figcraft_ui_egui::browser_can_read(name, len).is_err() {+ log::warn!("host file {name} is too large to open in the browser; not opened");+ return None;+ }+ Some(if let Some(array) = bytes.dyn_ref::<js_sys::Uint8Array>() { array.to_vec() } else { js_sys::Uint8Array::new(&bytes).to_vec() })+}++/// The last component of `path`, or the whole of it when there is none.+fn file_name(path: &str) -> String {+ std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string())+}++/// A message object or `CustomEvent` detail with the given fields, or `None` if one couldn't be+/// built (a field that isn't a valid property name, say).+fn message(fields: &[(&str, JsValue)]) -> Option<js_sys::Object> {+ let m = js_sys::Object::new();+ for (key, value) in fields {+ js_sys::Reflect::set(&m, &JsValue::from_str(key), value).ok()?;+ }+ Some(m)+}++fn string(data: &JsValue, key: &str) -> Option<String> {+ js_sys::Reflect::get(data, &JsValue::from_str(key)).ok()?.as_string()+}++/// A `JsValue` error as a string, after `what` went wrong.+fn js_error(e: &JsValue, what: &str) -> String {+ format!("{what}: {}", e.as_string().unwrap_or_else(|| format!("{e:?}")))+}new file mode 100644@@ -0,0 +1,109 @@+//! Pure host-mode rules, shared by the bridge ([`crate::host`]) and testable without a browser+//! (like [`crate::query`]): what opts a page into hosting FigCraft in its own document, the name+//! the bridge fills in when an open request has none, and whether a handed-over file becomes the+//! document's file.++/// The one `data-host` value that opts a page in: the exact string `"page"`.+pub const PAGE_HOST: &str = "page";++/// The file name used when an open request carries no usable one.+pub const DEFAULT_OPEN_NAME: &str = "Untitled.figcraft";++/// The extensions of files that can be a document's own file: the formats `file.save` and+/// `file.saveAs` write (`file.open` opens both as native documents). A `.fig` file is an export+/// copy instead, and so is everything else the app hands over (rendered images, SVG, PDF,+/// variables).+const DOC_EXTS: &[&str] = &["figcraft", "json"];++/// The suffix `variable.export`'s downloads carry: a `.json` that is a variables sidecar, not a+/// document, so its extension alone can't tell the `saveAs` decision.+const VARIABLES_SIDECAR: &str = ".variables.json";++/// Does this `data-host` value ask for page host mode? Only the exact value [`PAGE_HOST`] counts;+/// anything else is ignored, so the page host mode stays opt-in. `?host=parent`, the parent+/// bridge's URL opt-in, is answered before this is asked.+pub fn page_host_requested(data_host: Option<&str>) -> bool {+ data_host == Some(PAGE_HOST)+}++/// A usable file name for an open request: the host's name when it isn't blank, else the+/// [`DEFAULT_OPEN_NAME`].+pub fn open_name(name: Option<&str>) -> String {+ name.filter(|n| !n.trim().is_empty()).unwrap_or(DEFAULT_OPEN_NAME).to_string()+}++/// Whether the file `name` becomes the document's own file when handed over (`saveAs: true`),+/// given the file the host opened (`None`: the host never sent one). The rule:+///+/// - A document file (`.figcraft` or `.json`, [`DOC_EXTS`]'s formats) under a name the host didn't+/// open — Save As, or a Save of a document the host never sent — is the document's new file:+/// `true`. Names compare without case.+/// - A Save of the host's own file under its name is not: `false`.+/// - Everything else is a copy or export that never becomes the document — rendered images, SVG,+/// PDF, `.fig` copies, variables — and the variables sidecar counts as one whatever its `.json`+/// extension says: `false`.+pub fn save_as(name: &str, host_file: Option<&str>) -> bool {+ if name.to_ascii_lowercase().ends_with(VARIABLES_SIDECAR) {+ return false;+ }+ let ext = std::path::Path::new(name).extension().and_then(std::ffi::OsStr::to_str);+ if !ext.is_some_and(|e| DOC_EXTS.iter().any(|x| x.eq_ignore_ascii_case(e))) {+ return false;+ }+ match host_file {+ Some(host) => !host.eq_ignore_ascii_case(name),+ None => true,+ }+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn only_the_exact_page_value_opts_in() {+ assert!(page_host_requested(Some("page")));+ for off in [None, Some(""), Some("Page"), Some("PAGE"), Some(" page"), Some("page "), Some("parent")] {+ assert!(!page_host_requested(off), "{off:?} must keep the page host mode off");+ }+ }++ #[test]+ fn open_names_fall_back_to_the_default_when_blank() {+ assert_eq!(open_name(Some("Poster.figcraft")), "Poster.figcraft");+ assert_eq!(open_name(Some(" Poster.figcraft ")), " Poster.figcraft ", "a name with its spacing is kept");+ assert_eq!(open_name(Some("")), DEFAULT_OPEN_NAME);+ assert_eq!(open_name(Some(" ")), DEFAULT_OPEN_NAME);+ assert_eq!(open_name(None), DEFAULT_OPEN_NAME);+ }++ #[test]+ fn a_save_of_the_host_s_file_is_not_save_as() {+ assert!(!save_as("Poster.figcraft", Some("Poster.figcraft")));+ assert!(!save_as("poster.FIGCRAFT", Some("Poster.figcraft")), "names compare without case");+ assert!(!save_as("design.json", Some("design.json")));+ }++ #[test]+ fn a_new_document_file_is_save_as() {+ assert!(save_as("Poster 2.figcraft", Some("Poster.figcraft")));+ assert!(save_as("Poster.json", Some("Poster.figcraft")), "the other format a document's file takes");+ assert!(save_as("Untitled.figcraft", None), "the host never sent a file");+ assert!(save_as("Poster.figcraft", Some("design.json")), "the opened .json saved as .figcraft is a new file");+ }++ #[test]+ fn copies_and_exports_never_become_the_document_s_file() {+ for name in ["Poster.png", "Poster@2x.jpg", "Poster.svg", "Poster.pdf", "Poster.fig", "Poster.csv", "Poster.variables.css"] {+ assert!(!save_as(name, Some("Poster.figcraft")), "{name} is a copy");+ assert!(!save_as(name, None), "{name} is a copy even without a host file");+ }+ }++ #[test]+ fn the_variables_sidecar_is_a_copy_whatever_its_extension() {+ assert!(!save_as("Poster.variables.json", Some("Poster.figcraft")));+ assert!(!save_as("Poster.variables.json", None));+ assert!(!save_as("design.variables.json", Some("design.variables.json")));+ }+}@@ -8,13 +8,19 @@ //! - no TCP control server (browsers can't listen on sockets); //! - no native file dialogs yet: the session starts with one new document; //! - links open in a new browser tab;+//! - host mode: `?host=parent` (a same-origin page embedding FigCraft in an `<iframe>`) or+//! `data-host="page"` on the canvas (the page's own document) hands the page the app's files+//! instead of downloading them and reports unsaved changes (`host`); //! - live sessions: it joins sessions a desktop app hosts (File ▸ Join live session…) but can't //! host one. A page served over `https://` can only reach sessions on the same computer //! (browsers block `ws://` from secure pages); serve it over `http://` on a trusted network to //! join from other machines. //! //! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?control` installs-//! `window.figcraft`, the page's control channel (`api.rs`), which is off without it.+//! `window.figcraft`, the page's control channel (`api.rs`), which is off without it; `?host=parent`+//! (inside an `<iframe>`) talks to a same-origin parent page instead: it sends the document to open+//! and stores what FigCraft saves. `&author=` names the user for comments and board notes in either+//! host mode. //! //! URL fragment: `#join=<invite link or join code>` joins that live session once the page has //! loaded, so a bookmark opens a shared file. `join` takes the rest of the fragment: put it last.@@ -23,6 +29,10 @@ #[cfg(target_arch = "wasm32")] mod api;+#[cfg(target_arch = "wasm32")]+mod host;+#[cfg(any(target_arch = "wasm32", test))]+mod hostmode; #[cfg(any(target_arch = "wasm32", test))] mod query; #[cfg(test)]@@ -18,6 +18,21 @@ pub fn start() { log::error!("missing <canvas id=\"{CANVAS_ID}\">"); return; };+ // Host modes: `?host=parent`, a same-origin page framing FigCraft, opens documents in it+ // and stores what it saves (`host`); unframed there is no parent, so that URL on its own+ // stays the normal app with downloads. `data-host="page"` on the canvas opts a page in+ // instead: it runs FigCraft in its own document and talks over DOM `CustomEvent`s on the+ // canvas (`host`).+ let bridge = if query_param("host").as_deref() == Some("parent") {+ crate::host::Bridge::new()+ } else if crate::hostmode::page_host_requested(canvas.get_attribute("data-host").as_deref()) {+ Some(crate::host::Bridge::for_page(&canvas))+ } else {+ None+ };+ let app_bridge = bridge.clone();+ // Files the host sends, on their way into the app's own open queue each frame.+ let opens: crate::host::OpenQueue = std::rc::Rc::default(); let mut options = eframe::WebOptions::default(); // `?webgl`: force the WebGL2 backend (troubleshooting, and headless browsers whose WebGPU // adapter request returns nothing).@@ -30,17 +45,27 @@ pub fn start() { .start( canvas, options,- Box::new(|cc| {+ Box::new(move |cc| { if let Some(rs) = &cc.wgpu_render_state { log::info!("figcraft-web: wgpu backend {:?}", rs.adapter.get_info().backend); } let reduce_motion = web_sys::window().and_then(|w| w.match_media("(prefers-reduced-motion: reduce)").ok().flatten());- let mut session = Session::new();+ // Host mode: the host sends the file to edit, so start with no document (the+ // home screen), as the desktop app does without a file; otherwise the session+ // starts with one new document.+ let mut session = if app_bridge.is_some() { Session::empty() } else { Session::new() }; // Live sessions: the browser joins sessions a desktop app hosts; whatever arrives // on the socket wakes the page for a frame. let ctx = cc.egui_ctx.clone(); session.live.install(Box::new(figcraft_collab_net::NetLink::new(std::sync::Arc::new(move || ctx.request_repaint()))));- let mut fig = FigApp::new(session, Box::new(WebServices { reduce_motion, ctx: cc.egui_ctx.clone() }));+ let mut fig = FigApp::new(session, Box::new(WebServices { reduce_motion, ctx: cc.egui_ctx.clone(), host: app_bridge.clone() }));+ // `?author=`: the host's name for the user signs this visit's comments and board+ // notes (nothing is stored in the browser, so nothing needs keeping).+ if app_bridge.is_some()+ && let Some(author) = query_param("author")+ {+ crate::host::Bridge::set_author(&mut fig, &author);+ } // `#join=<link>`: join that session, as opening an invite link does on the desktop. let hash = web_sys::window().and_then(|w| w.location().hash().ok()).unwrap_or_default(); if let Some(link) = crate::query::join_link(&hash) {@@ -54,10 +79,21 @@ pub fn start() { log::error!("couldn't install window.figcraft: {e:?}"); } }- Ok(Box::new(WebShell { fig }))+ // The host's files arrive on the app's own open path (a dropped file's).+ if let Some(bridge) = &app_bridge {+ bridge.listen(opens.clone(), cc.egui_ctx.clone());+ }+ Ok(Box::new(WebShell { fig, host: app_bridge, opens })) }), ) .await;+ // Ready only once the runner is up: starting can still fail after the app is made.+ if let Some(bridge) = &bridge {+ match &result {+ Ok(()) => bridge.post_ready(),+ Err(e) => bridge.post_failed(&format!("{e:?}")),+ }+ } if let Some(el) = document.get_element_by_id(LOADING_ID) { match result { Ok(()) => el.remove(),@@ -71,18 +107,43 @@ fn query() -> String { web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default() } +fn query_param(key: &str) -> Option<String> {+ web_sys::UrlSearchParams::new_with_str(&query()).ok()?.get(key)+}+ struct WebShell { fig: FigApp,+ /// The host bridge, when the page hosts the app (`?host=parent` or `data-host="page"`).+ host: Option<crate::host::Bridge>,+ /// Files the host sent, waiting to join the app's own open queue.+ opens: crate::host::OpenQueue, } impl eframe::App for WebShell { // eframe runs `logic` for visible tabs and, with only `run_logic`, for hidden ones: keep // autosave/live/control deadlines moving without a paint. `FigApp::ui` does not repeat it.- /// Run the browser shell's eframe logic callback: guard `background_tick` so hidden tabs- /// still advance autosave, live and control deadlines without painting. `FigApp::ui` does+ /// Run the browser shell's eframe logic callback: hand the host's files to the app's open+ /// queue, guard `background_tick` so hidden tabs still advance autosave, live and control+ /// deadlines without painting, and report unsaved changes to the host. `FigApp::ui` does /// not repeat it. fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {+ // Files the host sent join the app's own open queue (a dropped file's): the same+ // "Opening as a new file" card, and `file.open` with the contents once read.+ for (name, bytes) in std::mem::take(&mut *self.opens.borrow_mut()) {+ let read: figcraft_ui_egui::ReadFuture = Box::pin(std::future::ready(Ok(bytes)));+ self.fig.ui.pending_opens.push(figcraft_ui_egui::pending_open::PendingOpen {+ label: name.clone(),+ params: serde_json::json!({ "name": name }),+ command: "file.open",+ target: None,+ read: Some(read),+ });+ } figcraft_ui_egui::windows::guard(&mut self.fig, ctx, |fig| fig.background_tick(ctx));+ // The host hears when unsaved changes appear or go away (crate::host).+ if let Some(bridge) = &self.host {+ bridge.set_dirty(self.fig.session.docs.iter().any(|d| d.is_dirty()));+ } } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {@@ -94,12 +155,15 @@ impl eframe::App for WebShell { /// Browser services. Files are opened and imported through the browser's file picker, which hands /// over their contents (pickers that need a path return nothing, the trait's defaults); files are-/// saved as downloads. Copies go to the system clipboard through egui's output; pastes arrive as-/// paste events with their text (a page can't read the clipboard when it likes).+/// saved as downloads, or handed to the host page in host mode. Copies go to the system clipboard+/// through egui's output; pastes arrive as paste events with their text (a page can't read the+/// clipboard when it likes). struct WebServices { /// `prefers-reduced-motion: reduce` (it follows the setting while the page is open). reduce_motion: Option<web_sys::MediaQueryList>, ctx: egui::Context,+ /// The host bridge in host mode: files are handed over instead of downloaded ([`crate::host`]).+ host: Option<crate::host::Bridge>, } impl Services for WebServices {@@ -116,11 +180,15 @@ impl Services for WebServices { fn downloads(&self) -> bool { true }- /// A Blob behind a temporary `<a download>` link, clicked and removed.+ /// A Blob behind a temporary `<a download>` link, clicked and removed; in host mode the file+ /// goes to the host page instead ([`crate::host`]). fn download(&mut self, name: &str, bytes: &[u8]) -> bool { if crate::api::capture(name, bytes) { return true; }+ if let Some(bridge) = &self.host {+ return bridge.post_save(name, bytes);+ } match download(name, bytes) { Ok(()) => true, Err(e) => {@@ -89,3 +89,6 @@ sync_github_name = true [people.mateusvillain] sync_github_name = true++[people.helenkwok]+real_name = "Helen Kwok"@@ -95,6 +95,94 @@ Local test: `python3 -m http.server 8767` inside the folder, then open http://lo (`allow-popups` lets Help links open in a new tab). - Don't send `X-Frame-Options: DENY` or a `frame-ancestors` CSP that excludes the embedding page. +## Host mode (`?host=parent`, or the page's own document)++A page can embed FigCraft so that it hands files over instead of downloading them and reports+unsaved changes. Two opt-in transports carry the same messages:++- **`?host=parent`** on the iframe's URL (`https://example.com/figcraft/?host=parent`): a+ **same-origin** framing page talks to the app over `postMessage`. Opened outside an iframe, the+ URL gives the normal app with downloads; with a parent on another origin there is no host mode+ either (messages to another origin are dropped silently, so saves would be lost while the+ document looked saved).+- **`data-host="page"`** (the exact value `page`) on the canvas: a page that loads the module into+ its own document gets the same behaviour over DOM `CustomEvent`s on the canvas, with no iframe+ (see below).++| Message | Direction | Fields |+|---|---|---|+| `figcraft:ready` | app → page | `version`: the app is running and listening; send a file now |+| `figcraft:open` | page → app | `name`, `bytes` (an `ArrayBuffer` or `Uint8Array`): open like File › Open (a payload over the browser open limit of 512 MB is refused; anything malformed is ignored) |+| `figcraft:save` | app → page | `name`, `saveAs`, `bytes` (a transferred `ArrayBuffer`): every file the app would otherwise download. The page stores it and reports failures itself. `saveAs` says whether the file becomes the document's file: `false` after Save of the file the page sent, under its own name; `true` for a document file (`.figcraft`, `.json`) under a name the page didn't send — Save As, or a Save of a document the page never sent — after which the next Save carries that name with `false`; `false` for copies and exports that don't become the document (rendered images, SVG, PDF, `.fig` copies, the variables exports) |+| `figcraft:dirty` | app → page | `dirty`: unsaved changes appeared or went away |+| `figcraft:failed` | app → page | `error`: the app couldn't start (no WebGPU or WebGL2, for example) |++Files reach the page through the app's download service, the path this web build saves by: Save As+(and a Save of a document with no file yet), the `.fig` copy, the export panel and its dialog,+Export frames as PDF, plugin output and the variables export (Variables panel ▸ Export…). In host+mode each arrives as `figcraft:save` instead of a download.++In host mode the app starts with no document (the home screen), so the page sends the file to edit+and guards leaving the page while changes are unsaved. `&author=` on the URL names the user for+comments and board notes.++### Same-document host mode++The host application loads the wasm-bindgen module itself and gives FigCraft one of its own+canvases, so there is no iframe and no `?host=parent`. It opts in with `data-host="page"` on that+canvas — the exact value `page` — and the canvas keeps the id the app looks up+(`id="figcraft_canvas"`):++```html+<canvas id="figcraft_canvas" data-host="page" style="width: 100%; height: 720px; display: block;"></canvas>+```++The page and the app then talk with DOM `CustomEvent`s on the canvas instead of `postMessage`. The+app dispatches the same messages it would post to a parent — `figcraft:ready`, `figcraft:save`,+`figcraft:dirty`, `figcraft:failed` — with the message fields (without the type) as the event's+`detail`, and it opens a document when the page dispatches a `figcraft:open` event with+`detail: { name, bytes }`. Events do not bubble, so listen on the canvas itself (two editors on+one page can't hear each other).++Add the listeners before `init`: the app announces itself with `figcraft:ready` while `init` is+still running, and a file sent in reply would otherwise be missed.++```js+import init from "./figcraft-web-<hash>.js"; // the wasm-bindgen glue shipped with the app++const canvas = document.getElementById("figcraft_canvas");++canvas.addEventListener("figcraft:ready", async () => {+ // The app is listening; hand it a document now, if you have one.+ const bytes = await documentBytes(); // an ArrayBuffer or Uint8Array+ canvas.dispatchEvent(new CustomEvent("figcraft:open", {+ detail: { name: "Poster.figcraft", bytes },+ }));+});++// Every file FigCraft would otherwise download arrives here.+canvas.addEventListener("figcraft:save", (event) => {+ const { name, saveAs, bytes } = event.detail; // bytes is an ArrayBuffer+ // Store the file under `name`; `saveAs` says whether it becomes the document's file.+ // Report your own errors.+ storeFile(name, bytes, saveAs);+});+canvas.addEventListener("figcraft:dirty", (event) => {+ unsaved = event.detail.dirty; // warn before leaving, as you like+});+canvas.addEventListener("figcraft:failed", (event) => {+ showError(event.detail.error); // the app couldn't start+});++await init({ module_or_path: "./figcraft-web-<hash>_bg.wasm" });+```++- Saves are not downloads in this mode: the `bytes` in `figcraft:save` hold the file, and the host+ decides where it goes.+- `?author=` on the page's URL still names the user for comments and board notes.+- An unframed page that uses `?host=parent` keeps the normal app with downloads; only the exact+ `data-host` value `page` turns this mode on.+ ## Renderer selection and fallback flags FigCraft renders with wgpu. It uses **WebGPU** when the browser has it and falls back to