M0.13: web build (trunk, WebGPU/WebGL2)

apps/designcraft-web runs DesignApp through eframe's web runner (wgpu:
WebGPU with WebGL2 fallback, ?webgl forces GL, ?sample opens the sample).
Services gain open_async, download and an inbox: browser-picked and
dropped files are fed back as file.openBytes (.designcraft) or file.place
(images, base64); Save/Export download via Blob + anchor. The web app is
excluded from default-members.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-01 11:17:17 -04:00
parent 91ae2b3cc9
commit 157a3e23c0
12 changed files with 347 additions and 19 deletions

17
Cargo.lock generated
View File

@ -881,6 +881,23 @@ dependencies = [
"serde_json",
]
[[package]]
name = "designcraft-web"
version = "0.1.0"
dependencies = [
"designcraft-engine",
"designcraft-ui-egui",
"eframe",
"egui",
"js-sys",
"log",
"rfd",
"serde_json",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "dispatch"
version = "0.2.0"

View File

@ -1,7 +1,7 @@
[workspace]
resolver = "3"
members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/designcraft", "apps/designcraft-cli", "xtask"]
[workspace.package]
version = "0.1.0"

View File

@ -43,6 +43,15 @@ cargo xtask ci # fmt, clippy, tests,
Drive a running app: send JSON lines to `127.0.0.1:7979` — see [`docs/control-protocol.md`](docs/control-protocol.md).
### Web
```sh
cd apps/designcraft-web && trunk build --release # → dist/web (serve it with any static server)
cd apps/designcraft-web && trunk serve --release # http://127.0.0.1:8767
```
Needs [trunk](https://trunkrs.dev) and the `wasm32-unknown-unknown` target. The same app runs through eframe's web runner on WebGPU, falling back to WebGL2 (`?webgl` forces it; `?sample` opens the sample magazine). Open and Place use the browser's file picker (dropping files works too); Save and Export download the file. The web build has no control channel.
## Architecture
An engine-first Cargo workspace with enforced layering (`cargo xtask layers`); the egui frontend is a separate crate, so the UI can be swapped without touching the engine.
@ -56,7 +65,7 @@ An engine-first Cargo workspace with enforced layering (`cargo xtask layers`); t
| L4 | `tools` (pointer events → commands + overlays) |
| L5 | `engine` (session, history, command registry) |
| L6 | `ui-egui` (InDesign-style UI, control channel) |
| L7 | `apps/designcraft`, `apps/designcraft-cli` |
| L7 | `apps/designcraft`, `apps/designcraft-cli`, `apps/designcraft-web` |
Status and milestones: **[ROADMAP.md](ROADMAP.md)**. Contributor and agent rules (clean-room, asset policy, quality gates): [`AGENTS.md`](AGENTS.md). Every bundled asset is listed with its licence in [`ASSETS.md`](ASSETS.md).

View File

@ -11,8 +11,9 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- UI (egui): application bar + menus, Control panel (object and text modes), Tools panel with flyouts, document tabs, rulers, pasteboard, guides (margins, columns, bleed, baseline grid), frame edges, selection handles, text ports and threads, Properties / Pages / Layers dock, Swatches, Styles, Character, Paragraph, Stroke, Text Wrap panels, New Document dialog, ⌘K palette, 4 brightness themes.
- Tools: Selection (click, marquee, move, Alt-duplicate, resize handles), Direct Selection (basic), Type (draw frame, click caret, select, type), Rectangle/Ellipse/Polygon (+ frame variants), Line, Hand, Zoom.
- ~100 commands, all reachable through the JSON control channel; headless CLI rendering to PNG.
- Web build (`apps/designcraft-web`, trunk): the same UI on WebGPU with a WebGL2 fallback; open/place via the browser file picker or drag-and-drop, save/export as downloads.
**Next (in order):** MCP server · PDF export (krilla, PDF/X-4) · IDML import/export · native format as zip · web build · Pen tool & Direct Selection anchors · Story Editor · Find/Change (+GREP) · tables · Liang hyphenation · smart guides · Links panel + relink · Preflight · TOC/index/footnotes · EPUB.
**Next (in order):** MCP server · PDF export (krilla, PDF/X-4) · IDML import/export · native format as zip · Pen tool & Direct Selection anchors · Story Editor · Find/Change (+GREP) · tables · Liang hyphenation · smart guides · Links panel + relink · Preflight · TOC/index/footnotes · EPUB.
## Milestones

View File

@ -0,0 +1,30 @@
[package]
name = "designcraft-web"
description = "DesignCraft in the browser (eframe web runner, WebGPU with WebGL2 fallback)"
version.workspace = true
edition.workspace = true
license.workspace = true
publish = false
[lints]
workspace = true
[[bin]]
name = "designcraft-web"
path = "src/main.rs"
[dependencies]
designcraft-engine = { workspace = true }
designcraft-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.
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
rfd = { workspace = true }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration"] }

View File

@ -0,0 +1,10 @@
# Build with: trunk build --release (from apps/designcraft-web)
[build]
target = "index.html"
dist = "../../dist/web"
public_url = "./"
filehash = true
[serve]
addresses = ["127.0.0.1"]
port = 8767

View File

@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<title>DesignCraft</title>
<link data-trunk rel="rust" data-bin="designcraft-web" data-wasm-opt="z" data-weak-refs />
<style>
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #262626; }
#designcraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
#designcraft_loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center;
color: #b8b8b8; font: 14px system-ui, sans-serif; pointer-events: none; }
</style>
</head>
<body>
<canvas id="designcraft_canvas"></canvas>
<div id="designcraft_loading">Loading DesignCraft&hellip;</div>
</body>
</html>

View File

@ -0,0 +1,28 @@
//! DesignCraft in the browser.
//!
//! Runs the same [`designcraft_ui_egui::DesignApp`] as the desktop app through eframe's web
//! runner (wgpu: WebGPU where available, WebGL2 otherwise). Build with `trunk build --release`
//! from this directory (output in `dist/web`).
//!
//! Differences from the desktop app:
//! - no TCP control channel (browsers can't listen on sockets);
//! - File → Open / Place use the browser file picker; bytes arrive asynchronously through
//! `Services::inbox` (`.designcraft` → `file.openBytes`, images → `file.place`);
//! - Save and Export trigger a browser download;
//! - dropped files are read asynchronously by `web::WebShell` and delivered through the inbox.
//!
//! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?sample` opens the
//! sample magazine on start.
#[cfg(target_arch = "wasm32")]
mod web;
#[cfg(target_arch = "wasm32")]
fn main() {
web::start();
}
#[cfg(not(target_arch = "wasm32"))]
fn main() {
eprintln!("designcraft-web only runs in the browser: build it with `trunk build --release` in apps/designcraft-web");
}

View File

@ -0,0 +1,163 @@
//! The browser shell: web `Services`, drag-and-drop, and the eframe web runner.
use designcraft_engine::Session;
use designcraft_ui_egui::{DesignApp, Inbox, Services};
use wasm_bindgen::JsCast as _;
const DOC_EXTS: &[&str] = &["designcraft"];
const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff"];
const CANVAS_ID: &str = "designcraft_canvas";
const LOADING_ID: &str = "designcraft_loading";
pub fn start() {
eframe::WebLogger::init(log::LevelFilter::Info).ok();
wasm_bindgen_futures::spawn_local(async {
let Some(document) = web_sys::window().and_then(|w| w.document()) else {
log::error!("no document");
return;
};
let Some(canvas) = document.get_element_by_id(CANVAS_ID).and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok()) else {
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
return;
};
let mut options = eframe::WebOptions::default();
if query().contains("webgl")
&& let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup
{
create.instance_descriptor.backends = eframe::wgpu::Backends::GL;
}
let result = eframe::WebRunner::new()
.start(
canvas,
options,
Box::new(move |cc| {
if let Some(rs) = &cc.wgpu_render_state {
log::info!("designcraft-web: wgpu backend {:?}", rs.adapter.get_info().backend);
}
let inbox: Inbox = Inbox::default();
let mut app = DesignApp::new(Session::new(), services(inbox.clone(), cc.egui_ctx.clone()));
if query().contains("sample") {
let _ = app.run("file.newSample", serde_json::json!({}));
}
Ok(Box::new(WebShell { app, inbox }))
}),
)
.await;
if let Some(el) = document.get_element_by_id(LOADING_ID) {
match result {
Ok(()) => el.remove(),
Err(e) => el.set_inner_html(&format!("<p>DesignCraft failed to start: {e:?}</p><p>A browser with WebGPU or WebGL2 is required.</p>")),
}
}
});
}
fn query() -> String {
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
}
/// Wraps the app to read dropped files asynchronously (browsers can't read them synchronously)
/// and feed them through the inbox.
struct WebShell {
app: DesignApp,
inbox: Inbox,
}
impl eframe::App for WebShell {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
for f in dropped {
let inbox = self.inbox.clone();
let ctx = ctx.clone();
wasm_bindgen_futures::spawn_local(async move {
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
match f.bytes_async().await {
Ok(bytes) => {
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
ctx.request_repaint();
}
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
}
});
}
self.app.logic(ctx);
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
self.app.raw_input_hook(raw);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.app.ui(ui);
}
}
fn services(inbox: Inbox, ctx: egui::Context) -> Services {
let open_inbox = inbox.clone();
Services {
open_async: Some(Box::new(move |purpose: &str| {
let inbox = open_inbox.clone();
let ctx = ctx.clone();
let dialog = if purpose == "place" {
rfd::AsyncFileDialog::new().add_filter("Graphics", IMAGE_EXTS)
} else {
rfd::AsyncFileDialog::new().add_filter("DesignCraft", DOC_EXTS)
};
wasm_bindgen_futures::spawn_local(async move {
let Some(file) = dialog.pick_file().await else {
return;
};
let bytes = file.read().await;
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((file.file_name(), bytes));
ctx.request_repaint();
});
})),
// Exports ask for a name; the browser decides where the download goes.
pick_save: Some(Box::new(|name: &str| Some(name.to_string()))),
write: Some(Box::new(|name: &str, bytes: &[u8]| download(name, bytes))),
download: Some(Box::new(|name: &str, bytes: &[u8]| {
if let Err(e) = download(name, bytes) {
log::error!("download of {name} failed: {e}");
}
})),
inbox: Some(inbox),
..Default::default()
}
}
/// Trigger a browser download of `bytes` named after the last component of `path`.
fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "designcraft".into());
let window = web_sys::window().ok_or("no window")?;
let document = window.document().ok_or("no document")?;
let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes));
let opts = web_sys::BlobPropertyBag::new();
opts.set_type(mime_for(&name));
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(js)?;
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(js)?;
let a: web_sys::HtmlAnchorElement = document.create_element("a").map_err(js)?.dyn_into().map_err(|_| "not an anchor")?;
a.set_href(&url);
a.set_download(&name);
a.style().set_property("display", "none").map_err(js)?;
let body = document.body().ok_or("no body")?;
body.append_child(&a).map_err(js)?;
a.click();
a.remove();
// Revoke after the click has been dispatched; the download keeps its own reference.
let revoke = wasm_bindgen::closure::Closure::once_into_js(move || {
web_sys::Url::revoke_object_url(&url).ok();
});
window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 10_000).map_err(js)?;
Ok(())
}
fn mime_for(name: &str) -> &'static str {
match name.rsplit('.').next().map(str::to_ascii_lowercase).as_deref() {
Some("png") => "image/png",
Some("jpg" | "jpeg") => "image/jpeg",
Some("pdf") => "application/pdf",
Some("designcraft") => "application/json",
_ => "application/octet-stream",
}
}

View File

@ -80,6 +80,7 @@ fn services() -> Services {
pick_save: Some(Box::new(|name: &str| rfd::FileDialog::new().set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string()))),
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))),
..Default::default()
}
}

View File

@ -30,6 +30,10 @@ pub use control::{ControlRequest, ControlResponse};
pub type ReadFn = Box<dyn Fn(&str) -> Result<Vec<u8>, String>>;
pub type WriteFn = Box<dyn FnMut(&str, &[u8]) -> Result<(), String>>;
pub type PickFn = Box<dyn FnMut(&str) -> Option<String>>;
pub type OpenAsyncFn = Box<dyn FnMut(&str)>;
pub type DownloadFn = Box<dyn FnMut(&str, &[u8])>;
/// Files `(name, bytes)` delivered asynchronously by the host (web file picker, dropped files).
pub type Inbox = std::sync::Arc<std::sync::Mutex<Vec<(String, Vec<u8>)>>>;
/// Platform services injected by the host (desktop or web).
#[derive(Default)]
@ -40,6 +44,15 @@ pub struct Services {
pub pick_save: Option<PickFn>,
pub read: Option<ReadFn>,
pub write: Option<WriteFn>,
/// Asynchronous open dialog for a purpose (`open`, `place`); the chosen file arrives later
/// through [`Services::inbox`]. Used when `pick_open` is unset (web).
pub open_async: Option<OpenAsyncFn>,
/// Hand bytes to the user as a named file (browser download). When set, Save uses it
/// instead of writing to a path.
pub download: Option<DownloadFn>,
/// Files delivered asynchronously, drained every frame: `.designcraft` → `file.openBytes`,
/// anything else → `file.place`.
pub inbox: Option<Inbox>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
@ -227,10 +240,7 @@ impl DesignApp {
UiRequest::View { params } => canvas::apply_view_request(self, &params),
UiRequest::Pick { purpose, params } => {
let _ = params;
if let Some(path) = self.services.pick_open.as_mut().and_then(|f| f(&purpose)) {
let cmd = if purpose == "place" { "file.place" } else { "file.open" };
let _ = self.run(cmd, json!({"path": path}));
}
let _ = self.pick_and_open(&purpose);
}
}
}
@ -241,6 +251,40 @@ impl DesignApp {
}
}
/// Ask the host for a file to open (`open`) or place (`place`). Synchronous pickers run the
/// command right away; asynchronous ones (web) deliver the file through the inbox.
pub fn pick_and_open(&mut self, purpose: &str) -> Result<Value, String> {
let cmd = if purpose == "place" { "file.place" } else { "file.open" };
if let Some(pick) = self.services.pick_open.as_mut() {
return match pick(purpose) {
Some(path) => self.run(cmd, json!({"path": path})),
None => Ok(Value::Null),
};
}
if let Some(open) = self.services.open_async.as_mut() {
open(purpose);
}
Ok(Value::Null)
}
/// Open or place files delivered through the inbox.
fn drain_inbox(&mut self) {
let Some(inbox) = self.services.inbox.clone() else { return };
let files = std::mem::take(&mut *inbox.lock().unwrap_or_else(|e| e.into_inner()));
for (name, bytes) in files {
let b64 = designcraft_engine::cmd::base64_encode(&bytes);
let r = if name.to_ascii_lowercase().ends_with(".designcraft") {
let title = name.rsplit_once('.').map_or(name.as_str(), |(stem, _)| stem);
self.run("file.openBytes", json!({"name": title, "base64": b64}))
} else {
self.run("file.place", json!({"name": name, "base64": b64}))
};
if let Err(e) = r {
self.status(format!("{name}: {e}"));
}
}
}
pub fn select_tool(&mut self, id: &str) {
let _ = self.run("tool.select", json!({"tool": id}));
self.ui.flyout = None;
@ -266,6 +310,9 @@ impl DesignApp {
}
self.last_time = now;
self.drain_control(ctx);
if self.fonts_ready {
self.drain_inbox();
}
if !self.synthetic.is_empty() {
ctx.request_repaint();
}

View File

@ -171,18 +171,9 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value,
app.ui.dialog = Some(crate::dialogs::Dialog::new("newDocument", json!({})));
Ok(Value::Null)
}
"app.openDialog" => {
if let Some(path) = app.services.pick_open.as_mut().and_then(|f| f("open")) {
return Some(app.run("file.open", json!({"path": path})));
}
Ok(Value::Null)
}
"app.placeDialog" => {
if let Some(path) = app.services.pick_open.as_mut().and_then(|f| f("place")) {
return Some(app.run("file.place", json!({"path": path})));
}
Ok(Value::Null)
}
"app.openDialog" => app.pick_and_open("open"),
"app.placeDialog" => app.pick_and_open("place"),
"app.save" | "app.saveDialog" if app.services.download.is_some() => download_document(app),
"app.save" => {
if app.session.active().is_some_and(|d| d.path.is_some()) {
return Some(app.run("file.save", json!({})));
@ -281,6 +272,18 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value,
})
}
/// Save by handing the serialized document to the host as a download (web).
fn download_document(app: &mut DesignApp) -> Result<Value, String> {
let name = app.session.active().map(|d| format!("{}.designcraft", d.doc.title)).ok_or("no document")?;
let ser = app.run("file.serialize", json!({}))?;
let bytes = ser.get("json").and_then(Value::as_str).unwrap_or_default().as_bytes().to_vec();
if let Some(download) = app.services.download.as_mut() {
download(&name, &bytes);
}
// Marks the document saved; on the web the engine doesn't touch a file system.
app.run("file.saveAs", json!({"path": name}))
}
fn export_png(app: &mut DesignApp, p: &Value) -> Result<Value, String> {
let st = app.session.active().ok_or("no document")?;
let page = p.get("page").and_then(Value::as_u64).map(|v| v as usize).or_else(|| crate::canvas::current_page(app)).unwrap_or(0);