6 Commits

Author SHA1 Message Date
Marcos Paulo 057ba30908 chore: release 0.2.0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 20:48:21 -03:00
Marcos Paulo 6e7b7e756a feat(web): remote-agent relay and browser gap closure (import, export, clipboard, thumbnails, persistence, backup)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 20:34:14 -03:00
Marcos Paulo 9e478207bb docs(specs): web relay for remote agents and browser gap closure
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:44:36 -03:00
Marcos Paulo 7c56a8d209 feat(web): projects in the browser (vfs over IndexedDB, home screen, autosave)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:41:01 -03:00
Marcos Paulo 01a01129be feat(ui): Figma-style inspector, flat menus, open projects folder, gentler ctrl+wheel zoom
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:37:54 -03:00
Marcos Paulo a1ba859aac feat(web): browser build (canvas + WebGPU shell), native-only deps gated for wasm32
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:37:54 -03:00
47 changed files with 5623 additions and 325 deletions
+2
View File
@@ -3,3 +3,5 @@ target/
dist/
*.asar
app.asar.unpacked/
web/pkg/
.worktrees/
Generated
+185 -11
View File
@@ -222,6 +222,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
dependencies = [
"axum-core",
"base64 0.22.1",
"bytes",
"futures-util",
"http",
@@ -236,8 +237,10 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"serde_core",
"sha1",
"sync_wrapper",
"tokio",
"tokio-tungstenite",
"tower",
"tower-layer",
"tower-service",
@@ -261,6 +264,12 @@ dependencies = [
"tower-service",
]
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64"
version = "0.23.1"
@@ -294,6 +303,15 @@ version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "block2"
version = "0.5.1"
@@ -431,8 +449,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
"cfg-if",
"cpufeatures",
"rand_core",
"cpufeatures 0.3.1",
"rand_core 0.10.1",
]
[[package]]
@@ -507,6 +525,26 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "console_error_panic_hook"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc"
dependencies = [
"cfg-if",
"wasm-bindgen",
]
[[package]]
name = "console_log"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86919cef3e37b9356ccf54d4421208c17ecfda01beae61393e7ffd72916c0ef1"
dependencies = [
"log",
"web-sys",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
@@ -547,6 +585,15 @@ dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.3.1"
@@ -594,12 +641,28 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "cursor-icon"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
[[package]]
name = "data-encoding"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
[[package]]
name = "data-url"
version = "0.3.2"
@@ -637,6 +700,16 @@ dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "dirs"
version = "6.0.0"
@@ -1109,6 +1182,16 @@ dependencies = [
"slab",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "gethostname"
version = "1.1.0"
@@ -1140,7 +1223,7 @@ dependencies = [
"cfg-if",
"libc",
"r-efi 6.0.0",
"rand_core",
"rand_core 0.10.1",
]
[[package]]
@@ -1345,7 +1428,7 @@ version = "0.1.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff"
dependencies = [
"base64",
"base64 0.23.1",
"bytes",
"futures-channel",
"futures-util",
@@ -2521,12 +2604,14 @@ dependencies = [
[[package]]
name = "papercraft"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"arboard",
"axum",
"base64",
"base64 0.23.1",
"bytemuck",
"console_error_panic_hook",
"console_log",
"craft-ui",
"dirs",
"egui",
@@ -2536,6 +2621,7 @@ dependencies = [
"fastrand",
"i_overlay",
"image",
"js-sys",
"log",
"lyon",
"miniz_oxide 0.9.1",
@@ -2552,9 +2638,14 @@ dependencies = [
"serde",
"serde_json",
"tokio",
"tokio-tungstenite",
"tokio-util",
"ttf-parser",
"usvg",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"web-time",
"wgpu",
"winit",
"winresource",
@@ -2718,6 +2809,15 @@ dependencies = [
"zerovec",
]
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "presser"
version = "0.3.1"
@@ -2804,6 +2904,16 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
dependencies = [
"rand_chacha",
"rand_core 0.9.5",
]
[[package]]
name = "rand"
version = "0.10.3"
@@ -2812,7 +2922,26 @@ checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
dependencies = [
"chacha20",
"getrandom 0.4.3",
"rand_core",
"rand_core 0.10.1",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]]
@@ -2954,7 +3083,7 @@ version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029"
dependencies = [
"base64",
"base64 0.23.1",
"bytes",
"futures-core",
"futures-util",
@@ -3043,7 +3172,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab5023804cbe0304cbe06197ac16e645a0a0d0ffa2953bf735c15c2fa0213350"
dependencies = [
"async-trait",
"base64",
"base64 0.23.1",
"bytes",
"chrono",
"futures",
@@ -3054,7 +3183,7 @@ dependencies = [
"pastey",
"pin-project-lite",
"process-wrap",
"rand",
"rand 0.10.3",
"reqwest",
"schemars",
"serde",
@@ -3272,6 +3401,17 @@ dependencies = [
"serde_core",
]
[[package]]
name = "sha1"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"digest",
]
[[package]]
name = "shlex"
version = "2.0.1"
@@ -3701,6 +3841,18 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-tungstenite"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
dependencies = [
"futures-util",
"log",
"tokio",
"tungstenite",
]
[[package]]
name = "tokio-util"
version = "0.7.20"
@@ -3855,6 +4007,22 @@ version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
[[package]]
name = "tungstenite"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8"
dependencies = [
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"rand 0.9.5",
"sha1",
"thiserror 2.0.21",
]
[[package]]
name = "type-map"
version = "0.5.1"
@@ -3864,6 +4032,12 @@ dependencies = [
"rustc-hash 2.1.3",
]
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-bidi"
version = "0.3.18"
@@ -3924,7 +4098,7 @@ version = "0.48.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "977d0a4abdef933f424a99fe09f95576e089b90aebc6f016a3bc813762493e91"
dependencies = [
"base64",
"base64 0.23.1",
"data-url",
"flate2",
"fontdb",
+37 -8
View File
@@ -1,6 +1,6 @@
[package]
name = "papercraft"
version = "0.1.1"
version = "0.2.0"
edition = "2024"
rust-version = "1.95"
description = "A native infinite-canvas app for planning, working and sketching"
@@ -22,18 +22,21 @@ path = "src/bin/papercraft-mcp.rs"
name = "papercraft-cli"
path = "src/bin/papercraft-cli.rs"
[[bin]]
name = "papercraft-relay"
path = "src/bin/papercraft-relay.rs"
[dependencies]
egui = "0.36"
egui-wgpu = "0.36"
egui-winit = "0.36"
wgpu = "30"
winit = "0.30"
lyon = "1"
ttf-parser = "0.25"
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["float_roundtrip"] }
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp"] }
pollster = "0.4"
web-time = "1"
bytemuck = { version = "1", features = ["derive"] }
log = "0.4"
env_logger = "0.11"
@@ -44,11 +47,7 @@ craft-ui = { version = "0.1.0", git = "https://git.marcospaulo.dev.br/masi/craft
i_overlay = "9.0.1"
miniz_oxide = "0.9.1"
usvg = "0.48"
arboard = { version = "3.6.1", features = ["image-data"] }
rmcp = { version = "3.5.1", default-features = false, features = ["server", "base64", "transport-streamable-http-server", "transport-io", "client", "transport-streamable-http-client-reqwest"] }
tokio = { version = "1", features = ["rt-multi-thread", "net", "sync", "macros", "time", "io-std"] }
axum = { version = "0.8", default-features = false, features = ["http1", "tokio"] }
tokio-util = "0.7"
tokio = { version = "1", features = ["sync"] }
base64 = "0.23"
[build-dependencies]
@@ -57,6 +56,14 @@ winresource = "0.1"
[profile.release]
debug = false
# Browser bundle only (`web/build.sh`): size over compile time. Native release is untouched.
[profile.web]
inherits = "release"
lto = true
codegen-units = 1
panic = "abort"
opt-level = "z"
[profile.dev.package."*"]
opt-level = 2
@@ -64,6 +71,8 @@ opt-level = 2
resvg = "0.48"
rmcp = { version = "3.5.1", default-features = false, features = ["transport-child-process"] }
reqwest = { version = "0.13", default-features = false }
# The relay test drives a fake tab over a real WebSocket; same version axum's `ws` uses.
tokio-tungstenite = "0.29"
[target.'cfg(target_os = "macos")'.dependencies]
muda = { version = "0.21", default-features = false }
@@ -71,3 +80,23 @@ objc2 = "0.6"
objc2-app-kit = "0.3"
objc2-foundation = "0.3"
raw-window-handle = "0.6"
# Native only: the OS clipboard and the MCP HTTP server do not exist in the browser.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
egui-winit = "0.36"
winit = "0.30"
arboard = { version = "3.6.1", features = ["image-data"] }
rmcp = { version = "3.5.1", default-features = false, features = ["server", "base64", "transport-streamable-http-server", "transport-io", "client", "transport-streamable-http-client-reqwest"] }
tokio = { version = "1", features = ["rt-multi-thread", "net", "sync", "macros", "time", "io-std", "fs"] }
axum = { version = "0.8", default-features = false, features = ["http1", "tokio", "ws"] }
tokio-util = "0.7"
# Browser build (`src/web.rs`): canvas + WebGPU/WebGL surface, DOM events instead of winit.
[target.'cfg(target_arch = "wasm32")'.dependencies]
wgpu = { version = "30", features = ["webgl"] }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
console_error_panic_hook = "0.1"
console_log = "1"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "KeyboardEvent", "PointerEvent", "WheelEvent", "ClipboardEvent", "DataTransfer", "MediaQueryList", "Location", "Performance", "Navigator", "Clipboard", "CssStyleDeclaration", "console", "WebSocket", "MessageEvent", "CloseEvent", "ErrorEvent", "Storage", "Crypto", "DragEvent", "FileList", "File", "Blob"] }
+209
View File
@@ -0,0 +1,209 @@
# Web build: remote agent relay + closing the browser gaps
Audience: a coding agent (pi, MiniMax) with no prior context. Everything needed is here.
Work in the git worktree you were started in. Do not commit; leave changes in the working tree.
## 1. What PaperCraft is
PaperCraft is a native infinite-canvas design tool in Rust (edition 2024, rustc >= 1.95).
Crate `papercraft` = library (`src/lib.rs`) + bins (`papercraft`, `papercraft-cli`, `papercraft-mcp`).
UI is egui 0.36 + egui-wgpu; the canvas is drawn by our own wgpu renderer (`src/render/`).
AI agents drive the app over MCP (`src/mcp/`): tool catalogue in `mcp/tools.rs`, executor in
`mcp/exec.rs` (trait `Host`), rmcp HTTP server in `mcp/server.rs` (native only).
## 2. What exists on this branch (already working, do not redo)
A browser build exists (`wasm32-unknown-unknown`):
- `src/web.rs`: browser shell. Canvas + wgpu surface (WebGPU, WebGL fallback), DOM events
translated to egui `RawInput` and to editor `InputEvent`s, requestAnimationFrame loop, home
screen (projects), autosave, `pagehide` save. `Web` struct owns `Editor`, `UiState`, `Session`.
`#demo` in the URL opens the sample board.
- `src/vfs/`: file-system shim. Native = `std::fs` re-export. Wasm (`vfs/mem.rs`) = in-memory tree
mirrored to IndexedDB (`idb_put`/`idb_delete` fire-and-forget; `vfs::load().await` at startup).
Projects live under `/papercraft` (`WEB_ROOT` in web.rs). All code paths that touch files must go
through `crate::vfs`, never `std::fs` / `image::open` / `File::open` (those do not work in wasm).
- Native-only things are gated with `#[cfg(not(target_arch = "wasm32"))]`: `app.rs` (winit shell),
`mcp::server`, arboard clipboard, `rmcp`/`axum`/`tokio` net features (see `Cargo.toml` target tables).
- `web/index.html`, `web/build.sh` (builds `web/pkg`). `web_time::Instant` is used instead of std.
Build/verify commands (ALWAYS put rustup first: Homebrew's cargo is too old):
export PATH="$HOME/.cargo/bin:$PATH"
cargo +stable test --lib # must stay 112+ passing
cargo +stable check --all-targets # native
cargo +stable check --lib --target wasm32-unknown-unknown # browser
cargo +stable check --target x86_64-pc-windows-msvc # windows
./web/build.sh # -> web/pkg (needs wasm-bindgen-cli 0.2.129, installed)
cargo +stable fmt -- <only the files you changed> # do NOT run plain `cargo fmt`; then `git checkout build.rs`
No browser is available in your environment, so you cannot run the web build. Verify with the
checks above plus native tests. Write the logic so it is unit-testable natively where possible
(pure functions, protocol structs, zip writer, byte-based importers).
## 3. Goal
A. Let a remote AI agent drive a PaperCraft tab running in someone's browser (hosted deployment).
B. Close the browser gaps listed in section 5.
## 4. Part A: relay for remote agents
### Architecture
agent --(MCP streamable HTTP)--> relay <--(WebSocket)-- browser tab
POST /s/{token}/mcp GET /tab/{token}
The tab already contains the full executor (`mcp::exec::call(host, name, args)`), so the relay
only forwards tool calls; it holds no document state.
### A1. `src/bin/papercraft-relay.rs` (native binary)
- Add it to `Cargo.toml` as a `[[bin]]`. Needs axum `ws` feature (extend the existing axum entry
in the NON-wasm target table; keep default-features = false). Prefer reusing `mcp::server`
(`PaperServer`, `StreamableHttpService`, `Dispatch`, `Call`) so the tool list and instructions
stay identical to the desktop server. Study `mcp/server.rs::spawn` first; if the service cannot be
mounted under a dynamic path `/s/{token}/mcp`, create one service per token lazily and route by
token, or expose a small refactor in `mcp/server.rs` (keep desktop behaviour unchanged).
- CLI: `papercraft-relay [--listen 0.0.0.0:8787] [--static <dir>]` (env `PAPERCRAFT_RELAY_LISTEN`).
`--static` serves the built web app (`web/index.html`, `web/pkg/*`) from the same origin with
correct MIME types (`.wasm` = `application/wasm`, `.js`, `.html`, `.css`, `.png`, `.svg`), so one
process hosts everything. Implement with `tokio::fs` + a path-traversal check; no new crate unless
unavoidable.
- `GET /tab/{token}`: WebSocket for the browser. One tab per token; a new connection replaces the
old one (close the old with a reason).
- `POST|GET|DELETE /s/{token}/mcp`: streamable-HTTP MCP endpoint for agents.
- Token = 32..64 lowercase hex chars chosen by the tab. Reject anything else with 404. It is a
bearer secret: never log it, never put it in error messages. The relay stores nothing on disk.
- Origin policy: `/tab/*` accepts browser origins (optionally restricted by `--allow-origin`);
`/s/*` rejects requests that carry an `Origin` header (agents are not browsers).
- A tool call is forwarded to the tab, awaited with a 120 s timeout (same as `CALL_TIMEOUT`).
No tab connected => the tool result is an error text:
"No PaperCraft tab is connected for this link. Open PaperCraft and use Agent > Connect."
- Limits: max WebSocket message 16 MiB; ping every 20 s; drop dead sockets; max 8 concurrent
in-flight calls per token (others wait).
### A2. Wire protocol (JSON text frames, UTF-8)
relay -> tab: `{"id": 7, "name": "get_screenshot", "args": { ... }}`
tab -> relay (success): `{"id": 7, "ok": true, "out": [{"type":"text","text":"..."},{"type":"image","mime":"image/png","data":"<base64>"}]}`
tab -> relay (failure): `{"id": 7, "ok": false, "error": "message"}`
Keep-alive: WebSocket ping/pong. Define the frame structs once in a module compiled for both
native and wasm (e.g. `src/mcp/relay_proto.rs`, serde only) and unit-test the (de)serialisation.
### A3. Tab side (`src/web.rs`; split into `src/web/mod.rs` + `src/web/relay.rs` if it helps)
- `web-sys` WebSocket (add features `WebSocket`, `MessageEvent`, `CloseEvent`, `ErrorEvent`,
`Storage`, plus whatever else you need) with auto-reconnect (backoff 1 s -> 15 s).
- Token: generated once with `crypto.getRandomValues` (16 bytes -> 32 hex), stored in
`localStorage["papercraft.relay.token"]`; "Regenerate" action replaces it and reconnects.
- Relay base URL: query param `?relay=wss://host` > `window.PAPERCRAFT_RELAY` > same origin
(`wss://` when the page is https, else `ws://`).
- Incoming call => run `mcp::exec::call(&mut web_host, name, &args)` on the main thread inside the
frame loop (queue them; process at the start of `frame()` so edits and repaint stay in one place;
set `dirty = true`). Implement `mcp::exec::Host` for `Web` mirroring `impl Host for App` in
`src/app.rs` (open_project, save, project, canvas_open, store, editor).
- `Host::gpu()` returns `Option<Headless>` and the native renderer blocks on GPU readback, which is
impossible in wasm. Tools that need pixels (`get_screenshot`, PNG export) must work on the web:
design an async path (e.g. `Host::gpu()` returns None on web and the executor returns a
"needs render" marker; the shell then renders the requested frame/region offscreen, reads it back
with `wgpu::Buffer::map_async` + a JS Promise or via `canvas.toBlob`, and answers the call
later). Keep the desktop code path and its tests unchanged. Document the design in a comment.
- UI: new top-level menu "Agent" (web only) with: "Connect…" opening a small egui window showing the
full agent URL `https://<host>/s/<token>/mcp` with a Copy button, connection state
(Connected / Connecting / Offline), "Regenerate link", and ready-to-paste snippets for
Claude Code (`claude mcp add --transport http papercraft <url>`) and a generic MCP JSON config.
A small status dot in the toolbar when connected is enough. Menus are built in
`src/ui/mod.rs::menu_bar` / `editor/shortcuts.rs::menus()`; the tab needs a way to add an item
without touching the native menus (e.g. a `UiState` flag set only by `web.rs`).
### A4. Acceptance for Part A
- `cargo +stable test` has an integration test (`tests/relay.rs`) that starts the relay on port 0,
connects a fake tab (tokio-tungstenite as dev-dependency is fine), calls a tool through the MCP
HTTP endpoint (use rmcp client as `tests/` already does for the desktop server if present, else raw
JSON-RPC), and asserts the round trip, the no-tab error, token validation, and Origin rejection.
- Wasm and native and windows checks pass; no new warnings.
- `docs/` or `web/README.md` is NOT required (do not create README files).
## 5. Part B: close the browser gaps
Currently these show "Not available in the browser yet" or are broken in the browser:
B1. **Import** (`HomeAction::Import`, `AppRequest::PlaceImage`, `AppRequest::ImageFill`, drag-drop):
use a hidden `<input type=file>` and `dragover/drop` on the canvas. `io::import::import_as_pages`
takes a `&Path`; add a bytes-based entry (`import_bytes(name, bytes)`) used by both the native
wrapper and the web, supporting `.papercraft`, `.excalidraw`, `.svg`, and images (png/jpg/gif/webp).
Native behaviour must not change. Also add importing a project backup zip (see B6).
B2. **Export** (`AppRequest::Export`, `ExportFormat`, inspector Export section): PNG, SVG, PDF built
by the existing `io::export` code, delivered as a browser download (Blob + temporary
`<a download>`). PNG needs the async GPU readback from A3: share that implementation.
B3. **Clipboard**: `Editor.clipboard_io: Option<Box<dyn io::clipboard::Provider>>` is a sync trait.
Implement `WebClipboard` as an in-memory mirror: on copy/cut write text to the mirror AND
`navigator.clipboard.writeText` (fire and forget); on the DOM `paste` event (don't
preventDefault on Ctrl/Cmd+V) fill the mirror from `clipboardData` (text/plain, and image files
decoded with the `image` crate), then trigger the editor's paste action. `paste` text already
reaches egui text fields via `egui::Event::Paste`; keep that working. Also wire `copy`/`cut` DOM
events. `AppRequest::CopyAsPng` = render + `navigator.clipboard.write(ClipboardItem)`.
B4. **Thumbnails**: `Session::write_thumbnail(&Headless, &Editor)` blocks on GPU readback. Produce
the home-screen thumbnail on the web with the async path, write `thumbnail.png` into the project
folder via `vfs`. Also fix `ui/home.rs::thumb` (line ~64) which uses `image::open(path)`: read
bytes with `vfs::read` and decode with `image::load_from_memory`. Grep the whole tree for other
`image::open`, `File::open`, `std::fs`, `std::path::Path::{exists,is_file,is_dir}` uses on
paths that the web build reaches, and route them through `vfs`.
B5. **Settings persistence**: `theme::Choice::path()` returns None on wasm (`dirs::config_dir`). On
wasm use the fixed vfs path `/config/theme` so the theme choice survives reloads. Likewise the
shape library: native loads `Library::load(Library::path_in(&store.root))` in `App::new`; the web
shell currently never loads or saves it (`st.library.library` stays default). Do the same in web.rs
(load at start; save when `st.library.dirty`, see `app.rs`).
B6. **"Open projects folder"** has no meaning in a browser. On wasm, `HomeAction::RevealRoot` becomes
"Download backup (.zip)": a zip of the whole `/papercraft` tree built in memory. Write a small
store-only (no compression) zip writer (pure function over `Vec<(String, Vec<u8>)>`, CRC-32
implemented locally or via an installed crate; `miniz_oxide` is already a dependency) and a
matching reader used by B1's backup import. Label the sidebar item per platform
(`ui/home.rs`: "Open projects folder" native, "Download backup" web).
B7. **Errors**: the web shell only logs and sets `ed.status`. Show errors to the user (the status
bar already renders `ed.status`; make sure it is visible and cleared sensibly), and surface
"Not saved" persistence failures (IndexedDB write errors currently ignored in `vfs/mem.rs`:
keep a shared "last persistence error" and show it).
B8. **Bundle size**: release wasm is ~17 MB. In `web/build.sh` run `wasm-opt -Oz` when available
(skip silently otherwise) and enable `lto = true`, `codegen-units = 1`, `panic = "abort"`
only for a `[profile.web]` that inherits release (do not change the native release profile).
### Acceptance for Part B
- Everything above works through code paths that compile for wasm; native behaviour and all
existing tests unchanged (`cargo +stable test --lib` passes, same or higher count).
- New native unit tests for the pure parts: bytes importer, zip writer/reader round trip, relay
protocol frames, any path/format helpers.
- No remaining user-visible "Not available in the browser yet" for B1-B6 features. Features that
still cannot work (`HomeAction::RevealRoot` native-only semantics) must be explained in code
comments only where non-obvious.
## 6. Constraints (project rules)
- Do exactly this spec. No unrelated refactors, renames, formatting sweeps, or new README/docs.
- Smallest correct diff; reuse existing code (`mcp::exec`, `io::export`, `io::import`, `Session`,
`HomeView`) before writing new code. New dependencies only if unavoidable; list each in your report
with the reason. Never add dependencies to the native-only table that the wasm build needs, and
vice versa.
- Rust style of the surrounding code: named items, early returns, no `unwrap()` on user/IO input,
no magic numbers (named consts), comments only for non-obvious "why".
- `std::time::Instant/SystemTime` panic on wasm: use `web_time` / `crate::project::now_ms`.
- Blocking calls (`pollster::block_on`, `device.poll(Wait)`, `std::thread`, sockets) are forbidden in
code that compiles for wasm.
- No secrets in code or logs. The relay token is a secret.
- Write files in chunks of at most ~300 lines per tool call (your output limit per turn is small).
- Do not touch `build.rs` formatting; do not edit `Cargo.lock` by hand (cargo updates it).
- Do not commit.
## 7. Work order (stop and report on failure)
1. A2 protocol module + tests. 2. A1 relay + `tests/relay.rs`. 3. A3 tab side (without async
screenshot). 4. Async GPU readback (shared by A3, B2, B4). 5. B1, B3, B5, B6, B7. 6. B2, B4.
7. B8. After each step run the three `cargo check` commands and `cargo +stable test --lib`.
## 8. Final report (print at the end)
List: files changed/added, new dependencies and why, each acceptance item with pass/fail evidence
(command output summaries), anything not done and why, and what must be tested manually in a real
browser (exact steps).
+17 -1
View File
@@ -12,7 +12,8 @@ use crate::ui::home::HomeAction;
use crate::ui::{self, UiState};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
use std::time::Duration;
use web_time::Instant;
use winit::application::ApplicationHandler;
use winit::event::{ElementState, MouseButton, MouseScrollDelta, TouchPhase, WindowEvent};
use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
@@ -337,6 +338,21 @@ impl App {
store.delete_forever(&dir).map_err(|e| e.to_string())
}
HomeAction::EmptyTrash => store.empty_trash().map_err(|e| e.to_string()),
HomeAction::RevealRoot => {
let opener = if cfg!(windows) {
"explorer"
} else if cfg!(target_os = "macos") {
"open"
} else {
"xdg-open"
};
// explorer exits non-zero even on success, so only spawn failure counts.
std::process::Command::new(opener)
.arg(&store.root)
.spawn()
.map(|_| ())
.map_err(|e| format!("cannot open {}: {e}", store.root.display()))
}
};
if let Err(e) = r {
self.error(e);
+4 -4
View File
@@ -90,11 +90,11 @@ pub fn get(id: &str) -> Option<Arc<ImageAsset>> {
/// Register every image in a project's `assets/` folder.
pub fn load_dir(dir: &Path) {
let Ok(rd) = std::fs::read_dir(dir) else {
let Ok(rd) = crate::vfs::read_dir(dir) else {
return;
};
for e in rd.flatten() {
if let Ok(bytes) = std::fs::read(e.path())
if let Ok(bytes) = crate::vfs::read(e.path())
&& let Err(err) = register(&bytes)
{
log::warn!("{}: {err}", e.path().display());
@@ -107,8 +107,8 @@ pub fn save_to_dir(dir: &Path, ids: impl IntoIterator<Item = String>) -> std::io
for id in ids {
let Some(a) = get(&id) else { continue };
let path = dir.join(format!("{id}.{}", a.ext));
if !path.exists() {
std::fs::create_dir_all(dir)?;
if !crate::vfs::exists(&path) {
crate::vfs::create_dir_all(dir)?;
crate::io::atomic::write(&path, &a.bytes)?;
}
}
+102
View File
@@ -0,0 +1,102 @@
//! `papercraft-relay`: serves MCP for a PaperCraft tab running in a browser.
//!
//! The tab connects to `GET /tab/<token>` and an agent to `POST /s/<token>/mcp`; the relay
//! forwards tool calls between them and holds no document state. `--static` also serves the
//! built web app (`web/index.html` + `web/pkg`) from the same origin, so one process hosts it.
use papercraft::mcp::relay;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::process::ExitCode;
const USAGE: &str = "\
papercraft-relay — MCP relay for a PaperCraft tab in a browser
USAGE:
papercraft-relay [--listen 0.0.0.0:8787] [--static <dir>] [--allow-origin <origin>]
OPTIONS:
--listen <addr> address to serve on (default: $PAPERCRAFT_RELAY_LISTEN or 0.0.0.0:8787)
--static <dir> serve the built web app from <dir> (e.g. the repo's web/ directory)
--allow-origin <o> only let this browser origin connect a tab (default: any)
--help print this text
The tab picks a 32..64 hex character token and shows the agent URL
http://<host>:<port>/s/<token>/mcp
";
/// Address used when neither `--listen` nor `$PAPERCRAFT_RELAY_LISTEN` says otherwise.
const DEFAULT_LISTEN: &str = "0.0.0.0:8787";
const ENV_LISTEN: &str = "PAPERCRAFT_RELAY_LISTEN";
struct Args {
listen: String,
static_dir: Option<PathBuf>,
allow_origin: Option<String>,
}
fn parse_args() -> Result<Args, String> {
let mut listen = std::env::var(ENV_LISTEN).unwrap_or_else(|_| DEFAULT_LISTEN.to_string());
let mut static_dir = None;
let mut allow_origin = None;
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
let mut value = || args.next().ok_or_else(|| format!("{arg} needs a value"));
match arg.as_str() {
"--listen" => listen = value()?,
"--static" => static_dir = Some(PathBuf::from(value()?)),
"--allow-origin" => allow_origin = Some(value()?),
other => return Err(format!("unknown argument {other}\n\n{USAGE}")),
}
}
Ok(Args {
listen,
static_dir,
allow_origin,
})
}
fn main() -> ExitCode {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
if std::env::args().any(|a| a == "--help" || a == "-h") {
print!("{USAGE}");
return ExitCode::SUCCESS;
}
let args = match parse_args() {
Ok(a) => a,
Err(e) => {
eprintln!("papercraft-relay: {e}");
return ExitCode::FAILURE;
}
};
let listen: SocketAddr = match args.listen.parse() {
Ok(a) => a,
Err(_) => {
eprintln!(
"papercraft-relay: '{}' is not an address like 0.0.0.0:8787 (set {ENV_LISTEN} to change it)",
args.listen
);
return ExitCode::FAILURE;
}
};
let running = match relay::spawn(listen, args.static_dir.clone(), args.allow_origin.clone()) {
Ok(r) => r,
Err(e) => {
eprintln!("papercraft-relay: {e}");
return ExitCode::FAILURE;
}
};
// The port is the real one, which matters when the request asked for 0.
println!("papercraft-relay: listening on port {}", running.port());
if let Some(dir) = &args.static_dir {
println!("papercraft-relay: serving {}", dir.display());
}
println!(
"papercraft-relay: agent endpoints are http://<this host>:{}/s/<token>/mcp",
running.port()
);
// Serve until the process is signalled; `running` then stops the server on the way out.
loop {
std::thread::park();
}
}
+4 -1
View File
@@ -104,6 +104,9 @@ impl Tool {
}
}
/// Ctrl+wheel zoom rate: one 40px wheel notch is about 13%.
const ZOOM_PER_SCROLL_PX: f64 = 0.003;
/// Requests the editor can't fulfil itself (dialogs, file system, clipboard).
#[derive(Debug, Clone, PartialEq)]
pub enum AppRequest {
@@ -633,7 +636,7 @@ impl Editor {
}
InputEvent::Scroll { delta, pos } => {
if self.mods.ctrl || self.mods.logo {
let factor = (-delta.y * 0.01).exp();
let factor = (-delta.y * ZOOM_PER_SCROLL_PX).exp();
self.camera.zoom_about(pos, self.camera.zoom * factor);
} else {
self.camera.pan_by_screen(-delta);
+1 -1
View File
@@ -82,7 +82,7 @@ fn wheel_pans_and_ctrl_wheel_zooms_about_cursor() {
..Modifiers::NONE
}));
e.handle(InputEvent::Scroll {
delta: v2(0.0, -100.0),
delta: v2(0.0, -400.0),
pos: anchor,
});
assert!(e.camera.zoom > 2.5);
+14 -12
View File
@@ -66,22 +66,24 @@ pub fn dir() -> PathBuf {
let data = dirs::data_dir()?;
let new = data.join("papercraft").join("icons");
let legacy = data.join("paper-rs").join("icons");
Some(if legacy.is_dir() && !new.exists() {
legacy
} else {
new
})
Some(
if crate::vfs::is_dir(&legacy) && !crate::vfs::exists(&new) {
legacy
} else {
new
},
)
})
.unwrap_or_else(|| PathBuf::from("icons"))
}
/// Installed sets with their icon counts.
pub fn installed(root: &Path) -> Vec<(String, usize)> {
let mut out: Vec<(String, usize)> = std::fs::read_dir(root)
let mut out: Vec<(String, usize)> = crate::vfs::read_dir(root)
.into_iter()
.flatten()
.flatten()
.filter(|e| e.path().is_dir())
.filter(|e| crate::vfs::is_dir(&e.path()))
.map(|e| {
let n = names(root, &e.file_name().to_string_lossy()).len();
(e.file_name().to_string_lossy().into_owned(), n)
@@ -93,7 +95,7 @@ pub fn installed(root: &Path) -> Vec<(String, usize)> {
/// Icon names (without `.svg`) in a set, sorted.
pub fn names(root: &Path, set: &str) -> Vec<String> {
let mut v: Vec<String> = std::fs::read_dir(root.join(set))
let mut v: Vec<String> = crate::vfs::read_dir(root.join(set))
.into_iter()
.flatten()
.flatten()
@@ -155,7 +157,7 @@ pub fn load(root: &Path, reference: &str) -> Result<String, String> {
None => installed(root).into_iter().map(|(s, _)| s).collect(),
};
for s in &sets {
if let Ok(svg) = std::fs::read_to_string(root.join(s).join(format!("{name}.svg"))) {
if let Ok(svg) = crate::vfs::read_to_string(root.join(s).join(format!("{name}.svg"))) {
return Ok(svg);
}
}
@@ -185,9 +187,9 @@ mod tests {
let root =
std::env::temp_dir().join(format!("papercraft-icons-{:016x}", fastrand::u64(..)));
let set = root.join("t");
std::fs::create_dir_all(&set).unwrap();
crate::vfs::create_dir_all(&set).unwrap();
for n in ["check", "check-circle", "arrow-check", "x"] {
std::fs::write(set.join(format!("{n}.svg")), "<svg/>").unwrap();
crate::vfs::write(set.join(format!("{n}.svg")), "<svg/>").unwrap();
}
assert!(load(&root, "../etc/passwd").is_err());
assert!(load(&root, "t:../x").is_err());
@@ -203,6 +205,6 @@ mod tests {
.unwrap_err()
.contains("no icon sets")
);
std::fs::remove_dir_all(root).ok();
crate::vfs::remove_dir_all(root).ok();
}
}
+16 -5
View File
@@ -5,9 +5,13 @@
//! platform we target, so readers see either the old file or the new one, never a torn write.
//! On Unix the directory is fsynced too, so the rename itself survives a power loss.
#[cfg(not(target_arch = "wasm32"))]
use std::io::Write;
use std::path::{Path, PathBuf};
use std::path::Path;
#[cfg(not(target_arch = "wasm32"))]
use std::path::PathBuf;
#[cfg(not(target_arch = "wasm32"))]
fn temp_path(path: &Path) -> PathBuf {
let name = path
.file_name()
@@ -20,7 +24,14 @@ fn temp_path(path: &Path) -> PathBuf {
))
}
/// The in-memory tree has no torn writes.
#[cfg(target_arch = "wasm32")]
pub fn write(path: &Path, data: &[u8]) -> std::io::Result<()> {
crate::vfs::write(path, data)
}
/// Atomically replace `path` with `data`.
#[cfg(not(target_arch = "wasm32"))]
pub fn write(path: &Path, data: &[u8]) -> std::io::Result<()> {
let tmp = temp_path(path);
let result = (|| {
@@ -38,25 +49,25 @@ pub fn write(path: &Path, data: &[u8]) -> std::io::Result<()> {
result
}
#[cfg(unix)]
#[cfg(all(unix, not(target_arch = "wasm32")))]
fn sync_dir(dir: &Path) {
if let Ok(d) = std::fs::File::open(dir) {
let _ = d.sync_all();
}
}
#[cfg(not(unix))]
#[cfg(all(not(unix), not(target_arch = "wasm32")))]
fn sync_dir(_dir: &Path) {}
/// Remove temp files left behind by a crash mid-write (best effort).
pub fn clean_stale_temps(dir: &Path) {
let Ok(rd) = std::fs::read_dir(dir) else {
let Ok(rd) = crate::vfs::read_dir(dir) else {
return;
};
for e in rd.flatten() {
let name = e.file_name().to_string_lossy().to_string();
if name.starts_with('.') && name.ends_with(".tmp") {
let _ = std::fs::remove_file(e.path());
let _ = crate::vfs::remove_file(e.path());
}
}
}
+139
View File
@@ -0,0 +1,139 @@
//! Whole-library backups as a zip of the store folder, so a backup made in the browser (where
//! there is no folder to open) can be restored there or anywhere else. Restoring brings back
//! the projects only; the trash and the shape library are left as they are.
use super::zip::{self, Entry};
use crate::project::TRASH_DIR;
use std::collections::HashSet;
use std::path::{Component, Path, PathBuf};
fn collect(dir: &Path, prefix: &str, out: &mut Vec<Entry>) -> std::io::Result<()> {
let mut entries: Vec<_> = crate::vfs::read_dir(dir)?.collect::<Result<_, _>>()?;
entries.sort_by_key(|e| e.file_name());
for e in entries {
let name = format!("{prefix}{}", e.file_name().to_string_lossy());
if e.file_type()?.is_dir() {
collect(&e.path(), &format!("{name}/"), out)?;
} else {
out.push((name, crate::vfs::read(e.path())?));
}
}
Ok(())
}
/// A zip of the whole store folder: every project, the trash and the shape library.
pub fn pack(root: &Path) -> Result<Vec<u8>, String> {
let mut files = Vec::new();
collect(root, "", &mut files).map_err(|e| e.to_string())?;
if files.is_empty() {
return Err("there are no projects to back up".into());
}
Ok(zip::write(&files))
}
/// Whether `bytes` look like a zip (the import path then treats the file as a backup).
pub fn is_zip(bytes: &[u8]) -> bool {
bytes.starts_with(b"PK\x03\x04") || bytes.starts_with(b"PK\x05\x06")
}
/// A relative path made only of normal components, so an archive cannot write outside `root`.
fn safe_relative(name: &str) -> Option<PathBuf> {
let p = Path::new(name);
p.components()
.all(|c| matches!(c, Component::Normal(_)))
.then(|| p.to_path_buf())
}
/// Restore the projects in a backup next to the existing ones (never overwriting: a folder
/// that already exists gets a numbered sibling). Returns the folders created.
pub fn unpack(bytes: &[u8], root: &Path) -> Result<Vec<PathBuf>, String> {
let entries = zip::read(bytes)?;
let mut tops: Vec<String> = Vec::new();
for (name, _) in &entries {
let Some(rel) = safe_relative(name) else {
return Err(format!("unsafe path in the backup: {name}"));
};
let mut parts = rel.components();
let (Some(top), Some(_)) = (parts.next(), parts.next()) else {
continue; // loose file at the top level
};
let top = top.as_os_str().to_string_lossy().into_owned();
if top != TRASH_DIR && !tops.contains(&top) {
tops.push(top);
}
}
let mut taken: HashSet<String> = HashSet::new();
let mut created = Vec::new();
for top in tops {
let has_project = entries
.iter()
.any(|(n, _)| n == &format!("{top}/{}", crate::project::MANIFEST));
if !has_project {
continue;
}
let mut target = top.clone();
let mut i = 2;
while taken.contains(&target) || crate::vfs::exists(&root.join(&target)) {
target = format!("{top} {i}");
i += 1;
}
taken.insert(target.clone());
for (name, data) in &entries {
let Some(rest) = name.strip_prefix(&format!("{top}/")) else {
continue;
};
let path = root.join(&target).join(rest);
if let Some(dir) = path.parent() {
crate::vfs::create_dir_all(dir).map_err(|e| e.to_string())?;
}
crate::vfs::write(&path, data).map_err(|e| e.to_string())?;
}
created.push(root.join(target));
}
if created.is_empty() {
return Err("the zip holds no PaperCraft projects".into());
}
Ok(created)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::project::Store;
fn store() -> Store {
Store::open(
std::env::temp_dir().join(format!("papercraft-backup-{:016x}", fastrand::u64(..))),
)
.unwrap()
}
#[test]
fn backup_round_trips_into_another_store() {
let a = store();
a.create("One").unwrap();
a.create("Two").unwrap();
let zip = pack(&a.root).unwrap();
assert!(is_zip(&zip));
let b = store();
let made = unpack(&zip, &b.root).unwrap();
assert_eq!(made.len(), 2);
let mut names: Vec<_> = b.list().into_iter().map(|e| e.manifest.name).collect();
names.sort();
assert_eq!(names, ["One", "Two"]);
// Restoring again never overwrites.
unpack(&zip, &b.root).unwrap();
assert_eq!(b.list().len(), 4);
}
#[test]
fn refuses_path_traversal() {
let zip = zip::write(&[("../evil/project.json".into(), b"{}".to_vec())]);
assert!(unpack(&zip, &store().root).is_err());
}
#[test]
fn empty_store_has_nothing_to_back_up() {
assert!(pack(&store().root).is_err());
}
}
+3
View File
@@ -46,14 +46,17 @@ impl Provider for Memory {
}
/// The OS clipboard via arboard (None when no clipboard is available, e.g. headless).
#[cfg(not(target_arch = "wasm32"))]
pub struct System(arboard::Clipboard);
#[cfg(not(target_arch = "wasm32"))]
impl System {
pub fn new() -> Option<System> {
arboard::Clipboard::new().ok().map(System)
}
}
#[cfg(not(target_arch = "wasm32"))]
impl Provider for System {
fn read_text(&mut self) -> Option<String> {
self.0.get_text().ok()
+60 -1
View File
@@ -192,7 +192,7 @@ pub fn write(
i += 1;
}
used.push(name.clone());
std::fs::create_dir_all(out).map_err(|e| e.to_string())?;
crate::vfs::create_dir_all(out).map_err(|e| e.to_string())?;
out.join(format!("{name}.{}", spec.format.ext()))
};
match spec.format {
@@ -222,3 +222,62 @@ pub fn write(
}
Ok(written)
}
/// The PNG bytes of an image.
pub fn encode_png(img: &image::RgbaImage) -> Result<Vec<u8>, String> {
let mut buf = std::io::Cursor::new(Vec::new());
img.write_to(&mut buf, image::ImageFormat::Png)
.map_err(|e| e.to_string())?;
Ok(buf.into_inner())
}
/// [`write`] into memory for the browser, which downloads instead of writing paths. Returns
/// `(file name, bytes)` per output file; `base` names a single-file export. PNG goes through
/// the non-blocking GPU readback, so this is the only export entry point that works in wasm.
pub async fn render_files(
spec: &Spec,
items: &[Item<'_>],
base: &str,
gpu: &Headless,
) -> Result<Vec<(String, Vec<u8>)>, String> {
if items.is_empty() {
return Err("nothing to export".into());
}
let bg = (!spec.transparent).then_some(Color::WHITE);
let ext = spec.format.ext();
if spec.format == Format::Pdf {
let frames = spec.scope == Scope::Frames;
let pages: Vec<_> = items
.iter()
.flat_map(|it| crate::io::pdf::pages_for(it.doc, Some(&it.roots), frames, bg))
.collect();
if pages.is_empty() {
return Err("nothing to export".into());
}
return Ok(vec![(
format!("{}.{ext}", safe(base)),
crate::io::pdf::export(&pages),
)]);
}
let single = spec.single_file(items.len());
let mut used = std::collections::HashSet::new();
let mut files = Vec::new();
for it in items {
let name = if single {
format!("{}.{ext}", safe(base))
} else {
crate::io::zip::unique_name(&format!("{}.{ext}", safe(&it.name)), &mut used)
};
let bytes = match spec.format {
Format::Svg => crate::io::svg::export(it.doc, Some(&it.roots), bg)
.ok_or("nothing to export")?
.into_bytes(),
Format::Png => encode_png(
&crate::io::png::render_async(gpu, it.doc, Some(&it.roots), spec.scale, bg).await?,
)?,
Format::Pdf => unreachable!(),
};
files.push((name, bytes));
}
Ok(files)
}
+5 -5
View File
@@ -85,12 +85,12 @@ pub fn from_str(s: &str) -> Result<(Document, Option<Camera>), FileError> {
/// Write atomically (temp file + rename) so a crash never leaves a truncated document.
pub fn save(path: &Path, doc: &Document, camera: Option<&Camera>) -> Result<(), FileError> {
let tmp = path.with_extension("papercraft.tmp");
std::fs::write(&tmp, to_string(doc, camera)).map_err(FileError::Io)?;
std::fs::rename(&tmp, path).map_err(FileError::Io)
crate::vfs::write(&tmp, to_string(doc, camera)).map_err(FileError::Io)?;
crate::vfs::rename(&tmp, path).map_err(FileError::Io)
}
pub fn load(path: &Path) -> Result<(Document, Option<Camera>), FileError> {
let s = std::fs::read_to_string(path).map_err(FileError::Io)?;
let s = crate::vfs::read_to_string(path).map_err(FileError::Io)?;
from_str(&s)
}
@@ -136,13 +136,13 @@ mod tests {
#[test]
fn save_and_load_file() {
let dir = std::env::temp_dir().join(format!("papercraft-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
crate::vfs::create_dir_all(&dir).unwrap();
let path = dir.join("doc.paper-rs");
let ed = demo::showcase();
save(&path, &ed.doc, None).unwrap();
let (doc, cam) = load(&path).unwrap();
assert_eq!(doc, ed.doc);
assert!(cam.is_none());
std::fs::remove_dir_all(dir).ok();
crate::vfs::remove_dir_all(dir).ok();
}
}
+120 -4
View File
@@ -1,5 +1,7 @@
//! Importing files into projects.
use crate::library::LibItem;
use crate::model::Node;
use crate::project::{PageData, Project, format};
use std::path::Path;
@@ -9,22 +11,136 @@ pub fn import_as_pages(path: &Path) -> Result<(String, Vec<PageData>), String> {
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "Imported".into());
if path.is_dir() {
if crate::vfs::is_dir(path) {
let p = Project::open(path).map_err(|e| e.to_string())?;
let pages = p.load_pages().map_err(|e| e.to_string())?;
return Ok((p.manifest.name, pages));
}
let text = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
let page = format::page_from_json(&text).map_err(|e| e.to_string())?;
let text = crate::vfs::read_to_string(path).map_err(|e| e.to_string())?;
page_from_text(&stem, &text)
}
fn page_from_text(stem: &str, text: &str) -> Result<(String, Vec<PageData>), String> {
let page = format::page_from_json(text).map_err(|e| e.to_string())?;
let name = if page.name.is_empty() || page.name == "Untitled" {
stem
stem.to_string()
} else {
page.name.clone()
};
Ok((name, vec![page]))
}
/// What a dropped or picked file turned out to be.
#[derive(Debug)]
pub enum Imported {
/// A PaperCraft document: pages for a new project, with a suggested name.
Pages(String, Vec<PageData>),
/// Loose layers from an SVG (`group` names the group they stay together in) or an
/// Excalidraw scene.
Scene {
nodes: Vec<Node>,
group: Option<String>,
},
/// Encoded raster image bytes, to be registered as an asset.
Image(Vec<u8>),
/// Shapes for the library (`.excalidrawlib`).
Library(Vec<LibItem>),
/// A project backup zip (see [`crate::io::backup`]); the caller unpacks it.
Backup,
}
/// Lowercase extension of a file name, without the dot.
pub fn extension(name: &str) -> String {
Path::new(name)
.extension()
.map(|e| e.to_string_lossy().to_lowercase())
.unwrap_or_default()
}
/// Classify and parse a file given only its name and bytes (the browser has no paths).
pub fn import_bytes(name: &str, bytes: &[u8]) -> Result<Imported, String> {
let stem = Path::new(name)
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "Imported".into());
let ext = extension(name);
if ext == "zip" || super::backup::is_zip(bytes) {
return Ok(Imported::Backup);
}
let text = || std::str::from_utf8(bytes).map_err(|_| format!("{name} is not a text file"));
match ext.as_str() {
"png" | "jpg" | "jpeg" | "gif" | "webp" => Ok(Imported::Image(bytes.to_vec())),
"excalidrawlib" => crate::io::excalidraw::import_library(text()?).map(Imported::Library),
"svg" => Ok(Imported::Scene {
nodes: crate::io::svg_import::parse(text()?)?,
group: Some(stem),
}),
"excalidraw" | "json" => {
let doc = crate::io::excalidraw::import_scene(text()?)?;
let nodes = doc
.paint_order()
.into_iter()
.filter_map(|id| doc.get(id).cloned())
.collect();
Ok(Imported::Scene { nodes, group: None })
}
_ => page_from_text(&stem, text()?).map(|(n, p)| Imported::Pages(n, p)),
}
}
/// Load the pages of a project folder or of a single document file (CLI export).
pub fn load_any(path: &Path) -> Result<Vec<PageData>, String> {
import_as_pages(path).map(|(_, pages)| pages)
}
#[cfg(test)]
mod tests {
use super::*;
const SVG: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><rect width="10" height="10"/></svg>"#;
#[test]
fn svg_becomes_a_named_scene() {
let Ok(Imported::Scene { nodes, group }) = import_bytes("Logo.SVG", SVG.as_bytes()) else {
panic!("svg should import as a scene");
};
assert!(!nodes.is_empty());
assert_eq!(group.as_deref(), Some("Logo"));
}
#[test]
fn images_pass_through_untouched() {
let Ok(Imported::Image(b)) = import_bytes("a.png", b"bytes") else {
panic!("png should import as an image");
};
assert_eq!(b, b"bytes");
}
#[test]
fn zips_are_backups_whatever_their_name() {
let zip = crate::io::zip::write(&[("a/project.json".into(), vec![])]);
assert!(matches!(import_bytes("x.bin", &zip), Ok(Imported::Backup)));
}
#[test]
fn papercraft_document_round_trips() {
let page = PageData {
id: "p".into(),
name: "Untitled".into(),
doc: Default::default(),
camera: None,
};
let json = format::page_to_json(&page);
let Ok(Imported::Pages(name, pages)) = import_bytes("My doc.papercraft", json.as_bytes())
else {
panic!("document should import as pages");
};
assert_eq!((name.as_str(), pages.len()), ("My doc", 1));
}
#[test]
fn garbage_is_an_error_not_a_panic() {
assert!(import_bytes("x.papercraft", b"\xff\xfe").is_err());
assert!(import_bytes("x.svg", b"not svg").is_err());
}
}
+2
View File
@@ -1,4 +1,5 @@
pub mod atomic;
pub mod backup;
pub mod clipboard;
pub mod excalidraw;
pub mod export;
@@ -8,3 +9,4 @@ pub mod pdf;
pub mod png;
pub mod svg;
pub mod svg_import;
pub mod zip;
+47 -6
View File
@@ -9,14 +9,20 @@ use crate::render::scene::{self, View};
/// Largest exported edge, in pixels.
pub const MAX_EDGE: f64 = 16384.0;
/// Render `roots` (or all top-level nodes) at `scale` px per unit.
pub fn render(
gpu: &Headless,
/// What the GPU has to draw for a PNG: the frame, its pixel size and the clear colour.
struct Plan {
frame: scene::CanvasFrame,
size: [u32; 2],
clear: [f64; 4],
transparent: bool,
}
fn plan(
doc: &Document,
roots: Option<&[NodeId]>,
scale: f64,
background: Option<Color>,
) -> Result<image::RgbaImage, String> {
) -> Result<Plan, String> {
let roots: Vec<NodeId> = roots
.map(|r| r.to_vec())
.unwrap_or_else(|| doc.root.clone());
@@ -44,8 +50,43 @@ pub fn render(
}
None => [0.0; 4],
};
let mut img = gpu.render_frame(&frame, size, 1.0, clear);
if background.is_none() {
Ok(Plan {
frame,
size,
clear,
transparent: background.is_none(),
})
}
/// Render `roots` (or all top-level nodes) at `scale` px per unit.
pub fn render(
gpu: &Headless,
doc: &Document,
roots: Option<&[NodeId]>,
scale: f64,
background: Option<Color>,
) -> Result<image::RgbaImage, String> {
let p = plan(doc, roots, scale, background)?;
let mut img = gpu.render_frame(&p.frame, p.size, 1.0, p.clear);
if p.transparent {
unpremultiply_edges(&mut img);
}
Ok(img)
}
/// [`render`] for the browser, where the GPU readback cannot block.
pub async fn render_async(
gpu: &Headless,
doc: &Document,
roots: Option<&[NodeId]>,
scale: f64,
background: Option<Color>,
) -> Result<image::RgbaImage, String> {
let p = plan(doc, roots, scale, background)?;
let mut img = gpu
.render_frame_async(&p.frame, p.size, 1.0, p.clear)
.await?;
if p.transparent {
unpremultiply_edges(&mut img);
}
Ok(img)
+232
View File
@@ -0,0 +1,232 @@
//! Minimal zip files: a store-only writer (project backups, multi-file exports in the browser)
//! and a reader that understands what the writer and common tools produce (stored or deflated
//! entries, no encryption, no zip64).
use std::collections::HashSet;
const LOCAL_SIG: u32 = 0x0403_4b50;
const CENTRAL_SIG: u32 = 0x0201_4b50;
const END_SIG: u32 = 0x0605_4b50;
const LOCAL_LEN: usize = 30;
const CENTRAL_LEN: usize = 46;
const END_LEN: usize = 22;
/// Zip version needed to extract (2.0: deflate, folders).
const VERSION: u16 = 20;
/// General purpose flag bit 11: names are UTF-8.
const UTF8_FLAG: u16 = 1 << 11;
const METHOD_STORED: u16 = 0;
const METHOD_DEFLATE: u16 = 8;
/// 1980-01-01, the earliest DOS date; entries carry no real timestamp.
const DOS_DATE: u16 = 0x0021;
/// Largest entry the reader inflates, against zip bombs.
const MAX_ENTRY: usize = 512 << 20;
pub type Entry = (String, Vec<u8>);
pub fn crc32(data: &[u8]) -> u32 {
let mut crc = !0u32;
for &b in data {
crc ^= b as u32;
for _ in 0..8 {
crc = (crc >> 1) ^ (0xEDB8_8320 & (crc & 1).wrapping_neg());
}
}
!crc
}
/// A store-only zip of `entries` (`name` uses `/` separators).
pub fn write(entries: &[Entry]) -> Vec<u8> {
let mut out = Vec::new();
let mut central = Vec::new();
for (name, data) in entries {
let offset = out.len() as u32;
let crc = crc32(data);
let size = data.len() as u32;
let name = name.as_bytes();
out.extend(LOCAL_SIG.to_le_bytes());
out.extend(VERSION.to_le_bytes());
out.extend(UTF8_FLAG.to_le_bytes());
out.extend(METHOD_STORED.to_le_bytes());
out.extend(0u16.to_le_bytes()); // time
out.extend(DOS_DATE.to_le_bytes());
out.extend(crc.to_le_bytes());
out.extend(size.to_le_bytes()); // compressed
out.extend(size.to_le_bytes()); // uncompressed
out.extend((name.len() as u16).to_le_bytes());
out.extend(0u16.to_le_bytes()); // extra
out.extend(name);
out.extend(data);
central.extend(CENTRAL_SIG.to_le_bytes());
central.extend(VERSION.to_le_bytes()); // made by
central.extend(VERSION.to_le_bytes()); // needed
central.extend(UTF8_FLAG.to_le_bytes());
central.extend(METHOD_STORED.to_le_bytes());
central.extend(0u16.to_le_bytes());
central.extend(DOS_DATE.to_le_bytes());
central.extend(crc.to_le_bytes());
central.extend(size.to_le_bytes());
central.extend(size.to_le_bytes());
central.extend((name.len() as u16).to_le_bytes());
central.extend([0u8; 12]); // extra len, comment len, disk, internal attrs, external attrs
central.extend(offset.to_le_bytes());
central.extend(name);
}
let central_offset = out.len() as u32;
let central_len = central.len() as u32;
out.extend(central);
out.extend(END_SIG.to_le_bytes());
out.extend([0u8; 4]); // disk numbers
out.extend((entries.len() as u16).to_le_bytes());
out.extend((entries.len() as u16).to_le_bytes());
out.extend(central_len.to_le_bytes());
out.extend(central_offset.to_le_bytes());
out.extend(0u16.to_le_bytes()); // comment
out
}
fn u16_at(b: &[u8], i: usize) -> Result<u16, String> {
b.get(i..i + 2)
.map(|s| u16::from_le_bytes([s[0], s[1]]))
.ok_or_else(|| "truncated zip".to_string())
}
fn u32_at(b: &[u8], i: usize) -> Result<u32, String> {
b.get(i..i + 4)
.map(|s| u32::from_le_bytes([s[0], s[1], s[2], s[3]]))
.ok_or_else(|| "truncated zip".to_string())
}
/// Every file in the archive (folders are skipped), verifying CRCs.
pub fn read(bytes: &[u8]) -> Result<Vec<Entry>, String> {
let end = (0..=bytes.len().saturating_sub(END_LEN))
.rev()
.find(|&i| u32_at(bytes, i).is_ok_and(|s| s == END_SIG))
.ok_or("not a zip file")?;
let count = u16_at(bytes, end + 10)? as usize;
let mut at = u32_at(bytes, end + 16)? as usize;
let mut out = Vec::with_capacity(count);
for _ in 0..count {
if u32_at(bytes, at)? != CENTRAL_SIG {
return Err("corrupt zip directory".into());
}
let method = u16_at(bytes, at + 10)?;
let crc = u32_at(bytes, at + 16)?;
let csize = u32_at(bytes, at + 20)? as usize;
let usize_ = u32_at(bytes, at + 24)? as usize;
let name_len = u16_at(bytes, at + 28)? as usize;
let extra_len = u16_at(bytes, at + 30)? as usize;
let comment_len = u16_at(bytes, at + 32)? as usize;
let local = u32_at(bytes, at + 42)? as usize;
let name = bytes
.get(at + CENTRAL_LEN..at + CENTRAL_LEN + name_len)
.ok_or("truncated zip")?;
let name = String::from_utf8_lossy(name).into_owned();
at += CENTRAL_LEN + name_len + extra_len + comment_len;
if name.ends_with('/') {
continue;
}
if u32_at(bytes, local)? != LOCAL_SIG {
return Err(format!("corrupt zip entry {name}"));
}
let start = local
+ LOCAL_LEN
+ u16_at(bytes, local + 26)? as usize
+ u16_at(bytes, local + 28)? as usize;
let raw = bytes.get(start..start + csize).ok_or("truncated zip")?;
let data = match method {
METHOD_STORED => raw.to_vec(),
METHOD_DEFLATE => miniz_oxide::inflate::decompress_to_vec_with_limit(raw, MAX_ENTRY)
.map_err(|e| format!("cannot inflate {name}: {e:?}"))?,
m => return Err(format!("{name}: unsupported zip method {m}")),
};
if data.len() != usize_ || crc32(&data) != crc {
return Err(format!("{name}: checksum mismatch"));
}
out.push((name, data));
}
Ok(out)
}
/// `name` made unique among `used` by appending " 2", " 3"… before the extension.
pub fn unique_name(name: &str, used: &mut HashSet<String>) -> String {
let (stem, ext) = name.rsplit_once('.').unwrap_or((name, ""));
let dot = if ext.is_empty() { "" } else { "." };
let mut candidate = name.to_string();
let mut i = 2;
while !used.insert(candidate.clone()) {
candidate = format!("{stem} {i}{dot}{ext}");
i += 1;
}
candidate
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn crc_matches_the_standard_check_value() {
assert_eq!(crc32(b"123456789"), 0xCBF4_3926);
assert_eq!(crc32(b""), 0);
}
#[test]
fn round_trip() {
let entries = vec![
("a/manifest.json".to_string(), b"{\"x\":1}".to_vec()),
("a/pages/p1.json".to_string(), vec![0u8; 1000]),
("empty".to_string(), vec![]),
("ünï.txt".to_string(), vec![1, 2, 3]),
];
assert_eq!(read(&write(&entries)).unwrap(), entries);
}
#[test]
fn empty_archive() {
assert!(read(&write(&[])).unwrap().is_empty());
}
#[test]
fn reads_deflated_entries() {
let data = b"hello hello hello hello".to_vec();
let packed = miniz_oxide::deflate::compress_to_vec(&data, 6);
let mut zip = write(&[("x".into(), data.clone())]);
// Rewrite the single stored entry as deflated by patching both headers.
let name_len = 1;
let local_data = LOCAL_LEN + name_len;
let mut patched = zip[..local_data].to_vec();
patched[8..10].copy_from_slice(&METHOD_DEFLATE.to_le_bytes());
patched[18..22].copy_from_slice(&(packed.len() as u32).to_le_bytes());
patched.extend(&packed);
let central_at = local_data + data.len();
let mut central = zip.split_off(central_at);
central[10..12].copy_from_slice(&METHOD_DEFLATE.to_le_bytes());
central[20..24].copy_from_slice(&(packed.len() as u32).to_le_bytes());
let end = central.len() - END_LEN;
let dir_offset = patched.len() as u32;
let dir_len = (end) as u32;
patched.extend(&central);
let n = patched.len();
patched[n - 6..n - 2].copy_from_slice(&dir_offset.to_le_bytes());
patched[n - 10..n - 6].copy_from_slice(&dir_len.to_le_bytes());
assert_eq!(read(&patched).unwrap(), vec![("x".to_string(), data)]);
}
#[test]
fn rejects_garbage_and_corruption() {
assert!(read(b"nope").is_err());
let mut z = write(&[("f".into(), b"abc".to_vec())]);
z[LOCAL_LEN + 1] ^= 0xff; // flip a data byte
assert!(read(&z).is_err());
}
#[test]
fn unique_names() {
let mut used = HashSet::new();
assert_eq!(unique_name("a.png", &mut used), "a.png");
assert_eq!(unique_name("a.png", &mut used), "a 2.png");
assert_eq!(unique_name("b", &mut used), "b");
assert_eq!(unique_name("b", &mut used), "b 2");
}
}
+4
View File
@@ -1,5 +1,6 @@
//! papercraft: a native infinite canvas for planning, working and sketching.
#[cfg(not(target_arch = "wasm32"))]
pub mod app;
pub mod assets;
pub mod connector;
@@ -23,3 +24,6 @@ pub mod text;
pub mod theme;
pub mod ui;
pub mod vector;
pub mod vfs;
#[cfg(target_arch = "wasm32")]
pub mod web;
+3 -3
View File
@@ -92,7 +92,7 @@ impl Library {
/// Load the library, seeding it with starter shapes on first use.
pub fn load(path: &Path) -> Library {
let mut lib = std::fs::read_to_string(path)
let mut lib = crate::vfs::read_to_string(path)
.ok()
.and_then(|s| serde_json::from_str::<Library>(&s).ok())
.filter(|l| l.format == FORMAT && l.version <= VERSION)
@@ -104,7 +104,7 @@ impl Library {
pub fn save(&self) -> std::io::Result<()> {
let Some(p) = &self.path else { return Ok(()) };
if let Some(dir) = p.parent() {
std::fs::create_dir_all(dir)?;
crate::vfs::create_dir_all(dir)?;
}
crate::io::atomic::write(
p,
@@ -265,7 +265,7 @@ mod tests {
lib.save().unwrap();
let back = Library::load(&path);
assert_eq!(back.items, lib.items);
std::fs::remove_dir_all(dir).ok();
crate::vfs::remove_dir_all(dir).ok();
}
#[test]
+99 -24
View File
@@ -38,6 +38,12 @@ pub trait Host {
fn save(&mut self) -> Result<(), String>;
/// An offscreen renderer for screenshots and PNG export.
fn gpu(&mut self) -> Option<Headless>;
/// The host cannot render synchronously but will render a [`ShotRequest`] itself and answer
/// the call later (the browser: GPU readback is asynchronous there). When true and
/// [`Host::gpu`] is None, `get_screenshot` returns a [`NEEDS_RENDER`] marker error.
fn defer_pixels(&self) -> bool {
false
}
}
/// A host without a window: an editor plus a project session. Used by tests and anything
@@ -317,7 +323,7 @@ pub fn call(host: &mut dyn Host, name: &str, args: &Value) -> ToolResult {
fn find_project(store: &Store, key: &str) -> Result<PathBuf, String> {
let key = key.trim().trim_start_matches("/file/");
let p = Path::new(key);
if p.is_absolute() && p.join(crate::project::MANIFEST).is_file() {
if p.is_absolute() && crate::vfs::is_file(&p.join(crate::project::MANIFEST)) {
return Ok(p.to_path_buf());
}
let list = store.list();
@@ -800,10 +806,55 @@ fn tree_summary(ed: &mut Editor, a: &Args) -> ToolResult {
/// Largest screenshot edge, in pixels.
const SHOT_MAX_EDGE: f64 = 2048.0;
/// Prefix of the error `get_screenshot` returns on a host that renders asynchronously: the rest
/// is a JSON [`ShotRequest`]. The shell renders it, then replies with [`screenshot_async`].
pub const NEEDS_RENDER: &str = "papercraft:needs-render:";
/// What a screenshot has to draw, resolved against the editor at call time.
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct ShotRequest {
pub page: usize,
pub roots: Vec<NodeId>,
pub scale: f64,
}
impl ShotRequest {
/// The request inside a [`NEEDS_RENDER`] marker error, if `err` is one.
pub fn from_marker(err: &str) -> Option<ShotRequest> {
serde_json::from_str(err.strip_prefix(NEEDS_RENDER)?).ok()
}
}
/// PNG-encode a screenshot. None (with `scale` reduced) when the payload is too big to send.
fn shot_out(img: &image::RgbaImage, scale: &mut f64) -> Result<Option<Vec<Out>>, String> {
let mut buf = std::io::Cursor::new(Vec::new());
img.write_to(&mut buf, image::ImageFormat::Png)
.map_err(|e| e.to_string())?;
let png = buf.into_inner();
// Keep the base64 payload comfortably under API image limits (~5 MB).
if png.len() > 3_500_000 && *scale > 0.2 {
*scale *= 0.7;
return Ok(None);
}
let (w, h) = img.dimensions();
Ok(Some(vec![
Out::Image {
data: png,
mime: "image/png".into(),
},
Out::Text(
json!({"width": w, "height": h, "scale": (*scale * 1000.0).round() / 1000.0})
.to_string(),
),
]))
}
fn screenshot(host: &mut dyn Host, a: &Args) -> ToolResult {
let gpu = host
.gpu()
.ok_or("screenshots need a GPU and none is available")?;
let gpu = host.gpu();
let defer = host.defer_pixels();
if gpu.is_none() && !defer {
return Err("screenshots need a GPU and none is available".into());
}
let ed = host.editor();
let def = page_arg(ed, a)?;
let (page, roots) = match a.str("nodeId").filter(|k| !k.is_empty() && *k != ROOT) {
@@ -817,28 +868,32 @@ fn screenshot(host: &mut dyn Host, a: &Args) -> ToolResult {
let b = crate::io::svg::export_bounds(doc, &roots).ok_or("nothing to capture (empty)")?;
let want = a.num("scale").unwrap_or(1.0).clamp(0.1, 4.0);
let mut scale = want.min(SHOT_MAX_EDGE / b.w.max(b.h).max(1.0));
let Some(gpu) = gpu else {
let req = ShotRequest { page, roots, scale };
return Err(format!(
"{NEEDS_RENDER}{}",
serde_json::to_string(&req).map_err(|e| e.to_string())?
));
};
loop {
let img = crate::io::png::render(&gpu, doc, Some(&roots), scale, Some(Color::WHITE))?;
let mut buf = std::io::Cursor::new(Vec::new());
img.write_to(&mut buf, image::ImageFormat::Png)
.map_err(|e| e.to_string())?;
let png = buf.into_inner();
// Keep the base64 payload comfortably under API image limits (~5 MB).
if png.len() > 3_500_000 && scale > 0.2 {
scale *= 0.7;
continue;
if let Some(out) = shot_out(&img, &mut scale)? {
return Ok(out);
}
}
}
/// Finish a deferred screenshot: the same rendering as [`screenshot`], awaiting the readback.
/// `doc` is the page the [`ShotRequest`] named, as it is now.
pub async fn screenshot_async(gpu: &Headless, doc: &Document, req: &ShotRequest) -> ToolResult {
let mut scale = req.scale;
loop {
let img =
crate::io::png::render_async(gpu, doc, Some(&req.roots), scale, Some(Color::WHITE))
.await?;
if let Some(out) = shot_out(&img, &mut scale)? {
return Ok(out);
}
let (w, h) = img.dimensions();
return Ok(vec![
Out::Image {
data: png,
mime: "image/png".into(),
},
Out::Text(
json!({"width": w, "height": h, "scale": (scale * 1000.0).round() / 1000.0})
.to_string(),
),
]);
}
}
@@ -1168,6 +1223,7 @@ fn jsx_node(
// ------------------------------------------------------------------------------ export
#[cfg(not(target_arch = "wasm32"))]
fn parse_scale(s: Option<&str>, b: Rect) -> Result<f64, String> {
let Some(s) = s.map(str::trim).filter(|s| !s.is_empty()) else {
return Ok(2.0);
@@ -1185,6 +1241,7 @@ fn parse_scale(s: Option<&str>, b: Rect) -> Result<f64, String> {
})
}
#[cfg(not(target_arch = "wasm32"))]
fn out_dir(a: &Args) -> PathBuf {
a.str("directory")
.map(PathBuf::from)
@@ -1193,6 +1250,7 @@ fn out_dir(a: &Args) -> PathBuf {
.unwrap_or_else(|| PathBuf::from("."))
}
#[cfg(not(target_arch = "wasm32"))]
fn unique_path(dir: &Path, stem: &str, ext: &str) -> PathBuf {
let stem = crate::project::folder_name(stem);
let mut p = dir.join(format!("{stem}.{ext}"));
@@ -1204,6 +1262,22 @@ fn unique_path(dir: &Path, stem: &str, ext: &str) -> PathBuf {
p
}
/// The browser has no folder to write to: exports there are downloads from the UI.
#[cfg(target_arch = "wasm32")]
const NO_DISK: &str = "this tool writes files to disk, which a browser tab cannot do; \
use get_screenshot, or the Export menu in the PaperCraft tab";
#[cfg(target_arch = "wasm32")]
fn export(_: &mut dyn Host, _: &Args) -> ToolResult {
Err(NO_DISK.into())
}
#[cfg(target_arch = "wasm32")]
fn export_pdf(_: &mut dyn Host, _: &Args) -> ToolResult {
Err(NO_DISK.into())
}
#[cfg(not(target_arch = "wasm32"))]
fn export(host: &mut dyn Host, a: &Args) -> ToolResult {
use crate::io::export::{Format, Item, Scope, Spec};
let gpu = host.gpu();
@@ -1265,6 +1339,7 @@ fn export(host: &mut dyn Host, a: &Args) -> ToolResult {
text(json!({"files": written}))
}
#[cfg(not(target_arch = "wasm32"))]
fn export_pdf(host: &mut dyn Host, a: &Args) -> ToolResult {
use crate::io::export::{Format, Item, Scope, Spec};
let project_name = host.project().map(|p| p.manifest.name.clone());
@@ -1455,7 +1530,7 @@ fn image_asset(src: &str) -> Result<String, String> {
return Ok(path.to_string());
}
let path = percent_decode(path);
std::fs::read(&path).map_err(|e| format!("{path}: {e}"))?
crate::vfs::read(&path).map_err(|e| format!("{path}: {e}"))?
};
if bytes.starts_with(b"<svg") || bytes.starts_with(b"<?xml") {
return Err("SVG images: put the <svg> markup inline instead".into());
+6
View File
@@ -9,11 +9,17 @@
//! * [`exec`]: runs one tool call against a [`exec::Host`] (the app, or a headless session).
//! * [`tools`]: tool names, descriptions and schemas; [`css`] and [`html`]: the style and
//! markup mapping used by `write_html` / `update_styles`.
//! * [`relay_proto`]: JSON frames between the relay and a browser tab; [`relay`]: the
//! relay server itself (native only).
pub mod css;
pub mod exec;
pub mod html;
pub mod lint;
#[cfg(not(target_arch = "wasm32"))]
pub mod relay;
pub mod relay_proto;
#[cfg(not(target_arch = "wasm32"))]
pub mod server;
pub mod tools;
+544
View File
@@ -0,0 +1,544 @@
//! The relay: lets a remote agent drive a PaperCraft tab running in someone's browser.
//!
//! ```text
//! agent --(MCP streamable HTTP)--> relay <--(WebSocket)-- browser tab
//! POST /s/{token}/mcp GET /tab/{token}
//! ```
//!
//! The relay holds no document state: it serves the same tool list and instructions as the
//! desktop server and forwards each call to the tab as a [`relay_proto::Call`] frame. The token
//! in the path is the only credential, so it is never logged and never appears in an error.
use super::relay_proto::{self, Call as CallFrame, Reply};
use super::server::{CALL_TIMEOUT, Dispatch, PaperServer};
use super::{Call, ToolResult};
use axum::extract::ws::{CloseFrame, Message, WebSocket, WebSocketUpgrade};
use axum::extract::{FromRequestParts, Path, Request, State};
use axum::http::{HeaderMap, StatusCode, Uri, header};
use axum::response::{IntoResponse, Response};
use axum::routing::any;
use rmcp::transport::streamable_http_server::{
StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager,
};
use serde_json::Value;
use std::collections::HashMap;
use std::net::SocketAddr;
use std::path::{Component, Path as FsPath, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, PoisonError};
use std::time::Duration;
use tokio::sync::{mpsc, oneshot};
use tokio::time::Instant;
use tokio_util::sync::CancellationToken;
/// Biggest frame we accept from (or send to) a tab.
pub const MAX_WS_MESSAGE_BYTES: usize = 16 << 20;
/// How often we ping a tab; a socket that stops answering is dropped.
pub const PING_INTERVAL: Duration = Duration::from_secs(20);
/// A socket that has not ponged within this many ping intervals is considered dead.
const PONG_GRACE_INTERVALS: u32 = 3;
/// Tool calls allowed to be in flight for one link; the rest wait their turn.
pub const MAX_INFLIGHT_CALLS: usize = 8;
/// Cap on an MCP request body (screenshots and assets are big).
const MAX_MCP_BODY_BYTES: usize = 64 << 20;
/// What an agent sees when nothing is connected to the link.
pub const NO_TAB_ERROR: &str =
"No PaperCraft tab is connected for this link. Open PaperCraft and use Agent > Connect.";
/// Close reason sent to the previous socket when a newer tab claims the link.
const TAB_REPLACED: &str = "replaced by a newer tab";
/// Shown for any request the token rules out; deliberately identical for bad tokens and
/// unknown links so a wrong token cannot be told apart from a wrong path.
const NOT_FOUND: &str = "not found";
/// Answered for a route the relay does not serve.
const UNKNOWN_ROUTE: &str = "not found: this relay serves /tab/{token} and /s/{token}/mcp";
type Service = StreamableHttpService<PaperServer, LocalSessionManager>;
/// Frames the relay pushes to a tab.
enum ToTab {
/// A tool call to run.
Call(String),
/// Close the socket with this reason.
Close(&'static str),
}
/// The live connection of one tab, plus the calls waiting for its answers.
struct Tab {
out: mpsc::UnboundedSender<ToTab>,
pending: Mutex<HashMap<u64, oneshot::Sender<Reply>>>,
}
impl Tab {
/// Shorthand for a poisoned lock: a panic cannot leave the map half-written, and a stale
/// entry only costs one abandoned wait.
fn pending(&self) -> std::sync::MutexGuard<'_, HashMap<u64, oneshot::Sender<Reply>>> {
self.pending.lock().unwrap_or_else(PoisonError::into_inner)
}
/// Answer every call still waiting with `error`, so agents stop waiting on a dead socket.
fn fail_all(&self, error: &str) {
for (id, reply) in self.pending().drain() {
let _ = reply.send(Reply::err(id, error));
}
}
}
/// Per-link state shared by the MCP service and the socket: the connected tab and the
/// in-flight limit.
struct Shared {
tab: tokio::sync::Mutex<Option<Arc<Tab>>>,
calls: Arc<tokio::sync::Semaphore>,
next_id: AtomicU64,
}
impl Shared {
fn new() -> Shared {
Shared {
tab: tokio::sync::Mutex::new(None),
calls: Arc::new(tokio::sync::Semaphore::new(MAX_INFLIGHT_CALLS)),
next_id: AtomicU64::new(1),
}
}
/// Hand a tool call to the tab and wait for its answer.
async fn forward(&self, name: String, args: Value) -> ToolResult {
let Some(tab) = self.tab.lock().await.clone() else {
return Err(NO_TAB_ERROR.to_string());
};
// Acquired before the frame goes out, so a link never asks the tab to answer more
// calls at once than it is allowed to hold.
let Ok(_permit) = self.calls.clone().acquire_owned().await else {
return Err("the relay is shutting down".into());
};
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
let frame = CallFrame { id, name, args };
let json = match serde_json::to_string(&frame) {
Ok(j) => j,
Err(e) => return Err(format!("cannot encode the call: {e}")),
};
let (reply_tx, reply_rx) = oneshot::channel();
// Registered before the frame goes out: a tab may answer before we get there.
tab.pending().insert(id, reply_tx);
if tab.out.send(ToTab::Call(json)).is_err() {
tab.pending().remove(&id);
return Err(NO_TAB_ERROR.to_string());
}
let answered = tokio::time::timeout(CALL_TIMEOUT, reply_rx).await;
tab.pending().remove(&id);
match answered {
Ok(Ok(reply)) => reply.into_result(),
Ok(Err(_)) => Err(NO_TAB_ERROR.to_string()),
Err(_) => Err("the PaperCraft tab did not answer in time".into()),
}
}
}
/// Everything one link owns: the cached MCP service plus its [`Shared`].
struct Link {
service: Service,
shared: Arc<Shared>,
}
impl Link {
fn new(shared: Arc<Shared>, ct: CancellationToken) -> Link {
let dispatch = dispatch(shared.clone());
let config = StreamableHttpServerConfig::default()
.enforce_origin_validation()
.with_max_request_body_bytes(MAX_MCP_BODY_BYTES)
.with_cancellation_token(ct);
let service: Service = StreamableHttpService::new(
move || Ok(PaperServer::new(dispatch.clone())),
Default::default(),
config,
);
Link { service, shared }
}
}
/// The relay holds no editor thread, so a call runs on its own task and the caller's oneshot
/// gets the answer that arrives over the WebSocket.
fn dispatch(shared: Arc<Shared>) -> Dispatch {
Arc::new(move |call: Call| {
let shared = shared.clone();
tokio::spawn(async move {
let r = shared.forward(call.name, call.args).await;
let _ = call.reply.send(r);
});
true
})
}
struct Relay {
links: tokio::sync::Mutex<HashMap<String, Arc<Link>>>,
allow_origin: Option<String>,
static_dir: Option<PathBuf>,
ct: CancellationToken,
}
impl Relay {
/// The link for `token`, created on first use. An invalid token never gets one.
async fn link(&self, token: &str) -> Option<Arc<Link>> {
if !relay_proto::valid_token(token) {
return None;
}
let mut links = self.links.lock().await;
if let Some(link) = links.get(token) {
return Some(link.clone());
}
let link = Arc::new(Link::new(Arc::new(Shared::new()), self.ct.child_token()));
links.insert(token.to_string(), link.clone());
Some(link)
}
/// `/tab/*` is a browser route: with `--allow-origin` only that exact origin may connect.
fn origin_ok(&self, headers: &HeaderMap) -> bool {
let Some(want) = &self.allow_origin else {
return true;
};
match headers.get(header::ORIGIN) {
Some(v) => v.to_str().map(|o| o == want).unwrap_or(false),
None => true,
}
}
}
fn not_found(body: &'static str) -> Response {
(StatusCode::NOT_FOUND, body).into_response()
}
fn forbidden(body: &'static str) -> Response {
(StatusCode::FORBIDDEN, body).into_response()
}
/// `POST|GET|DELETE /s/{token}/mcp`: the agent's streamable-HTTP endpoint for one link.
async fn mcp_endpoint(
State(relay): State<Arc<Relay>>,
Path(token): Path<String>,
req: Request,
) -> Response {
// rmcp's `enforce_origin_validation` already turns browsers away; checking here keeps the
// policy ours, and it runs before the link lookup so a browser cannot create one.
if req.headers().contains_key(header::ORIGIN) {
return forbidden("this endpoint does not accept browser requests");
}
let Some(link) = relay.link(&token).await else {
return not_found(NOT_FOUND);
};
// axum can only nest a service under a path known when the router is built, and tokens
// arrive at runtime; the service only needs the request, so it is called directly.
link.service.handle(req).await.into_response()
}
/// `GET /tab/{token}`: the browser's socket for one link.
async fn tab_socket(
State(relay): State<Arc<Relay>>,
Path(token): Path<String>,
req: Request,
) -> Response {
let Some(link) = relay.link(&token).await else {
return not_found(NOT_FOUND);
};
if !relay.origin_ok(req.headers()) {
return forbidden("this origin may not connect a tab");
}
// The token is checked before the upgrade: a `WebSocketUpgrade` extractor would answer a
// plain request with 400 before we could refuse a bad token.
let (mut parts, _) = req.into_parts();
let upgrade = match WebSocketUpgrade::from_request_parts(&mut parts, &()).await {
Ok(u) => u,
Err(rejection) => return rejection.into_response(),
};
upgrade
.max_message_size(MAX_WS_MESSAGE_BYTES)
.on_upgrade(move |socket| serve_tab(socket, link))
.into_response()
}
/// What the socket loop is waiting for. Only one branch touches the socket, so the borrow
/// from `recv()` is gone by the time we write to it.
enum Event {
Frame(Result<Message, axum::Error>),
Outgoing(Option<ToTab>),
Ping,
Closed,
}
async fn next_event(
socket: &mut WebSocket,
out: &mut mpsc::UnboundedReceiver<ToTab>,
ping: &mut tokio::time::Interval,
) -> Event {
tokio::select! {
m = socket.recv() => match m {
Some(m) => Event::Frame(m),
None => Event::Closed,
},
o = out.recv() => Event::Outgoing(o),
_ = ping.tick() => Event::Ping,
}
}
/// Answer the call waiting on this frame's id; frames for no known call are ignored.
fn handle_reply(tab: &Tab, text: &str) {
let reply = match serde_json::from_str::<Reply>(text) {
Ok(r) => r,
Err(_) => {
log::debug!("relay: ignoring a frame that is not a reply");
return;
}
};
match tab.pending().remove(&reply.id) {
Some(waiter) => {
let _ = waiter.send(reply);
}
None => log::debug!("relay: ignoring a reply with no pending call"),
}
}
/// One tab's socket: forwards tool calls out, routes replies back to their calls, pings to
/// keep the connection (and any proxy in front of it) alive.
async fn serve_tab(mut socket: WebSocket, link: Arc<Link>) {
let (out, mut out_rx) = mpsc::unbounded_channel::<ToTab>();
let tab = Arc::new(Tab {
out,
pending: Mutex::new(HashMap::new()),
});
// One tab per link: a newer connection takes over and the older socket is closed.
let previous = link.shared.tab.lock().await.replace(tab.clone());
if let Some(old) = previous {
let _ = old.out.send(ToTab::Close(TAB_REPLACED));
}
// The tab is live as of here: say so with a ping, so a client that waits for one never
// races this registration.
let _ = socket.send(Message::Ping(axum::body::Bytes::new())).await;
let mut ping = tokio::time::interval_at(Instant::now() + PING_INTERVAL, PING_INTERVAL);
let mut last_pong = Instant::now();
loop {
match next_event(&mut socket, &mut out_rx, &mut ping).await {
Event::Closed | Event::Frame(Err(_)) => break,
Event::Frame(Ok(Message::Close(_))) => break,
Event::Frame(Ok(Message::Text(t))) => handle_reply(&tab, &t),
Event::Frame(Ok(Message::Pong(_))) => last_pong = Instant::now(),
// Ping replies are sent by the protocol layer; other frame types are not ours.
Event::Frame(Ok(_)) => {}
Event::Outgoing(None) => break,
Event::Outgoing(Some(ToTab::Call(json))) => {
if socket.send(Message::Text(json.into())).await.is_err() {
break;
}
}
Event::Outgoing(Some(ToTab::Close(reason))) => {
let frame = CloseFrame {
code: axum::extract::ws::close_code::AWAY,
reason: reason.into(),
};
let _ = socket.send(Message::Close(Some(frame))).await;
break;
}
Event::Ping => {
// No pong for several intervals means the socket is gone even if TCP has not
// noticed yet.
if last_pong.elapsed() > PING_INTERVAL * PONG_GRACE_INTERVALS {
break;
}
if socket
.send(Message::Ping(axum::body::Bytes::new()))
.await
.is_err()
{
break;
}
}
}
}
let mut current = link.shared.tab.lock().await;
if let Some(t) = current.as_ref()
&& Arc::ptr_eq(t, &tab)
{
*current = None;
}
drop(current);
tab.fail_all(NO_TAB_ERROR);
}
/// The MIME types the built web app needs; anything else is an opaque download.
const MIME_TYPES: &[(&str, &str)] = &[
("wasm", "application/wasm"),
("js", "text/javascript"),
("html", "text/html"),
("css", "text/css"),
("png", "image/png"),
("svg", "image/svg+xml"),
];
/// Fallback for anything not in [`MIME_TYPES`].
const DEFAULT_MIME: &str = "application/octet-stream";
fn mime_of(path: &FsPath) -> &'static str {
let Some(ext) = path.extension().and_then(|e| e.to_str()) else {
return DEFAULT_MIME;
};
MIME_TYPES
.iter()
.find(|(e, _)| e.eq_ignore_ascii_case(ext))
.map_or(DEFAULT_MIME, |(_, m)| *m)
}
/// `<dir>/index.html` for `/`, else `<dir>/<path>`; `None` for anything that would read
/// outside `root` (`..`, absolute paths, `.`).
fn static_path(root: &FsPath, request_path: &str) -> Option<PathBuf> {
let rel = request_path.trim_start_matches('/');
let rel = FsPath::new(if rel.is_empty() { "index.html" } else { rel });
if rel.components().any(|c| !matches!(c, Component::Normal(_))) {
return None;
}
Some(root.join(rel))
}
/// The `--static` tree, and the answer for every route the relay does not serve.
async fn serve_static(State(relay): State<Arc<Relay>>, uri: Uri) -> Response {
let Some(dir) = relay.static_dir.as_deref() else {
return not_found(UNKNOWN_ROUTE);
};
let Some(path) = static_path(dir, uri.path()) else {
return not_found(NOT_FOUND);
};
// Read whole: the files are a wasm bundle, and a streaming body would need another
// dependency for no benefit here.
match tokio::fs::read(&path).await {
Ok(bytes) => ([(header::CONTENT_TYPE, mime_of(&path))], bytes).into_response(),
Err(_) => not_found(NOT_FOUND),
}
}
fn router(relay: Arc<Relay>) -> axum::Router {
axum::Router::new()
.route("/tab/{token}", any(tab_socket))
.route("/s/{token}/mcp", any(mcp_endpoint))
.fallback(serve_static)
.layer(axum::middleware::from_fn(super::server::fix_accept))
.with_state(relay)
}
/// A running relay; stops when dropped.
pub struct Running {
port: u16,
ct: CancellationToken,
thread: Option<std::thread::JoinHandle<()>>,
}
impl Running {
/// The port actually bound (0 in the request means "any free port").
pub fn port(&self) -> u16 {
self.port
}
pub fn stop(mut self) {
self.shutdown();
}
fn shutdown(&mut self) {
self.ct.cancel();
if let Some(t) = self.thread.take() {
let _ = t.join();
}
}
}
impl Drop for Running {
fn drop(&mut self) {
self.shutdown();
}
}
/// Serve the relay from a background thread. `static_dir` serves the built web app from the
/// same origin; `allow_origin` restricts which browser may connect a tab.
pub fn spawn(
listen: SocketAddr,
static_dir: Option<PathBuf>,
allow_origin: Option<String>,
) -> Result<Running, String> {
let listener = std::net::TcpListener::bind(listen)
.map_err(|e| format!("cannot listen on {listen}: {e}"))?;
listener.set_nonblocking(true).map_err(|e| e.to_string())?;
let port = listener.local_addr().map_err(|e| e.to_string())?.port();
let ct = CancellationToken::new();
let thread_ct = ct.clone();
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.thread_name("papercraft-relay")
.enable_all()
.build()
.map_err(|e| e.to_string())?;
let thread = std::thread::Builder::new()
.name("papercraft-relay-http".into())
.spawn(move || {
rt.block_on(async move {
let listener = match tokio::net::TcpListener::from_std(listener) {
Ok(l) => l,
Err(e) => {
log::error!("relay: {e}");
return;
}
};
let relay = Arc::new(Relay {
links: tokio::sync::Mutex::new(HashMap::new()),
allow_origin,
static_dir,
ct: thread_ct.child_token(),
});
if let Err(e) = axum::serve(listener, router(relay))
.with_graceful_shutdown(thread_ct.cancelled_owned())
.await
{
log::error!("relay: server stopped: {e}");
}
});
})
.map_err(|e| e.to_string())?;
Ok(Running {
port,
ct,
thread: Some(thread),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn static_paths_stay_inside_the_root() {
let root = FsPath::new("/srv/web");
assert_eq!(
static_path(root, "/").as_deref(),
Some(FsPath::new("/srv/web/index.html"))
);
assert_eq!(
static_path(root, "/pkg/papercraft_bg.wasm").as_deref(),
Some(FsPath::new("/srv/web/pkg/papercraft_bg.wasm"))
);
for bad in [
"/../Cargo.toml",
"/pkg/../../etc/passwd",
"/./index.html",
"..",
] {
assert_eq!(static_path(root, bad), None, "{bad}");
}
// Extra leading slashes are stripped, not resolved: still inside the root.
assert_eq!(
static_path(root, "//etc/passwd").as_deref(),
Some(FsPath::new("/srv/web/etc/passwd"))
);
}
#[test]
fn mime_types_cover_the_built_web_app() {
assert_eq!(mime_of(FsPath::new("a/b.wasm")), "application/wasm");
assert_eq!(mime_of(FsPath::new("a/b.js")), "text/javascript");
assert_eq!(mime_of(FsPath::new("index.html")), "text/html");
assert_eq!(mime_of(FsPath::new("style.css")), "text/css");
assert_eq!(mime_of(FsPath::new("icon.png")), "image/png");
assert_eq!(mime_of(FsPath::new("logo.svg")), "image/svg+xml");
assert_eq!(mime_of(FsPath::new("LICENSE")), DEFAULT_MIME);
}
}
+291
View File
@@ -0,0 +1,291 @@
//! Wire protocol between the relay and a PaperCraft tab running in a browser.
//!
//! JSON text frames, one tool call per exchange: the relay sends a [`Call`], the tab answers
//! with a [`Reply`] carrying the same `id`. Compiled for native and wasm alike: the tab side
//! speaks it too.
use crate::mcp::{Out, ToolResult};
use serde::{Deserialize, Serialize};
/// Shortest link token a tab may generate.
pub const MIN_TOKEN_LEN: usize = 32;
/// Longest link token a tab may generate.
pub const MAX_TOKEN_LEN: usize = 64;
/// relay -> tab: run `name` with `args`, answer frame `id`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Call {
pub id: u64,
pub name: String,
pub args: serde_json::Value,
}
/// One output block, tagged by `type` so the tab can switch on it.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum OutFrame {
Text {
text: String,
},
/// `data` is base64 (standard alphabet, padded).
Image {
mime: String,
data: String,
},
}
/// tab -> relay: the answer to [`Call::id`]. `out` and `error` are mutually exclusive.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Reply {
pub id: u64,
pub ok: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub out: Option<Vec<OutFrame>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
impl Reply {
pub fn ok(id: u64, out: Vec<Out>) -> Reply {
Reply {
id,
ok: true,
out: Some(out.into_iter().map(OutFrame::from).collect()),
error: None,
}
}
pub fn err(id: u64, error: impl Into<String>) -> Reply {
Reply {
id,
ok: false,
out: None,
error: Some(error.into()),
}
}
/// The tool result this reply carries; a malformed image block is an error, not a panic.
pub fn into_result(self) -> ToolResult {
if !self.ok {
return Err(self
.error
.unwrap_or_else(|| "the PaperCraft tab reported an error".into()));
}
let blocks = match self.out {
Some(b) => b,
None => return Err("the PaperCraft tab sent an empty reply".into()),
};
let mut out = Vec::with_capacity(blocks.len());
for b in blocks {
match b.into() {
Some(o) => out.push(o),
None => return Err("the PaperCraft tab sent an image that is not base64".into()),
}
}
Ok(out)
}
}
impl From<Out> for OutFrame {
fn from(o: Out) -> OutFrame {
match o {
Out::Text(text) => OutFrame::Text { text },
Out::Image { data, mime } => OutFrame::Image {
mime,
data: crate::assets::base64(&data),
},
}
}
}
impl From<OutFrame> for Option<Out> {
fn from(f: OutFrame) -> Option<Out> {
match f {
OutFrame::Text { text } => Some(Out::Text(text)),
OutFrame::Image { mime, data } => {
crate::assets::base64_decode(&data).map(|d| Out::Image { data: d, mime })
}
}
}
}
/// A link token is a bearer secret the tab generates: 32..=64 lowercase hex chars, nothing else.
pub fn valid_token(token: &str) -> bool {
(MIN_TOKEN_LEN..=MAX_TOKEN_LEN).contains(&token.len())
&& token
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
}
/// Bytes in a freshly generated link token (hex-encoded: [`MIN_TOKEN_LEN`] characters).
pub const TOKEN_BYTES: usize = MIN_TOKEN_LEN / 2;
/// Lowercase hex of `bytes`, the token alphabet.
pub fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
/// Where the tab's WebSocket connects: `base` is a `ws(s)://host` origin.
pub fn tab_url(base: &str, token: &str) -> String {
format!("{}/tab/{token}", base.trim_end_matches('/'))
}
/// The URL an agent is given: the relay's HTTP origin plus the token's MCP endpoint.
pub fn agent_url(origin: &str, token: &str) -> String {
format!("{}/s/{token}/mcp", origin.trim_end_matches('/'))
}
/// The relay's WebSocket base: `?relay=` in the page URL, else `window.PAPERCRAFT_RELAY`, else the
/// page's own origin. `search` is `location.search`; `page_origin` is `location.origin`.
pub fn relay_base(search: &str, global: Option<&str>, page_origin: &str) -> String {
let from_query = search
.trim_start_matches('?')
.split('&')
.find_map(|kv| kv.strip_prefix("relay="))
.filter(|v| !v.is_empty());
match from_query.or(global.filter(|g| !g.is_empty())) {
Some(url) => url.to_string(),
None => page_origin
.replacen("https://", "wss://", 1)
.replacen("http://", "ws://", 1),
}
}
/// The HTTP origin matching a relay WebSocket base (what the agent connects to).
pub fn http_origin(ws_base: &str) -> String {
ws_base
.replacen("wss://", "https://", 1)
.replacen("ws://", "http://", 1)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn png() -> Vec<u8> {
// 1x1 transparent PNG, encoded in the test so the payload stays tiny.
crate::assets::base64_decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
)
.expect("valid base64 fixture")
}
#[test]
fn success_reply_round_trips_through_json() {
let reply = Reply::ok(
7,
vec![
Out::Text("{\"nodes\":1}".into()),
Out::Image {
data: png(),
mime: "image/png".into(),
},
],
);
let json = serde_json::to_string(&reply).unwrap();
assert_eq!(
json,
format!(
r#"{{"id":7,"ok":true,"out":[{{"type":"text","text":"{{\"nodes\":1}}"}},{{"type":"image","mime":"image/png","data":"{}"}}]}}"#,
crate::assets::base64(&png())
)
);
let back: Reply = serde_json::from_str(&json).unwrap();
assert_eq!(back, reply);
let outs = back.into_result().unwrap();
assert_eq!(outs[0], Out::Text("{\"nodes\":1}".into()));
assert_eq!(
outs[1],
Out::Image {
data: png(),
mime: "image/png".into()
}
);
}
#[test]
fn failure_reply_is_exactly_id_ok_error() {
let reply = Reply::err(42, "no artboard");
let json = serde_json::to_string(&reply).unwrap();
assert_eq!(json, r#"{"id":42,"ok":false,"error":"no artboard"}"#);
assert_eq!(serde_json::from_str::<Reply>(&json).unwrap(), reply);
assert_eq!(reply.into_result().unwrap_err(), "no artboard");
}
#[test]
fn a_reply_that_is_not_a_valid_image_is_an_error() {
let bad: Reply = serde_json::from_value(json!({
"id": 1, "ok": true,
"out": [{"type": "image", "mime": "image/png", "data": "not base64!"}]
}))
.unwrap();
assert!(bad.into_result().is_err());
}
#[test]
fn call_round_trips_through_json() {
let call = Call {
id: 7,
name: "get_screenshot".into(),
args: json!({"nodeId": "n1:3"}),
};
let json = serde_json::to_string(&call).unwrap();
assert_eq!(
json,
r#"{"id":7,"name":"get_screenshot","args":{"nodeId":"n1:3"}}"#
);
assert_eq!(serde_json::from_str::<Call>(&json).unwrap(), call);
}
#[test]
fn tokens_are_lowercase_hex_of_the_documented_length() {
let n32 = "a".repeat(MIN_TOKEN_LEN);
let n64 = "0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c4b5a69788796a5b4c3d2e1f0";
assert_eq!(n64.len(), MAX_TOKEN_LEN);
assert!(valid_token(&n32));
assert!(valid_token(&n64));
assert!(valid_token(&"0123456789abcdef".repeat(2)));
assert!(!valid_token(""));
assert!(!valid_token(&"a".repeat(MIN_TOKEN_LEN - 1)));
assert!(!valid_token(&"a".repeat(MAX_TOKEN_LEN + 1)));
assert!(!valid_token(&"A".repeat(MIN_TOKEN_LEN)), "uppercase");
assert!(!valid_token(&"g".repeat(MIN_TOKEN_LEN)), "non-hex");
assert!(!valid_token(&format!(
"{}-{}",
"a".repeat(MIN_TOKEN_LEN - 1),
"b"
)));
}
#[test]
fn urls_and_hex() {
assert_eq!(hex(&[0, 15, 255]), "000fff");
assert!(valid_token(&hex(&[0xab; TOKEN_BYTES])));
assert_eq!(tab_url("wss://h.example/", "t"), "wss://h.example/tab/t");
assert_eq!(
agent_url("https://h.example", "t"),
"https://h.example/s/t/mcp"
);
}
#[test]
fn relay_base_prefers_query_then_global_then_origin() {
let o = "https://app.example";
assert_eq!(
relay_base("?relay=wss://r.example&x=1", Some("wss://g"), o),
"wss://r.example"
);
assert_eq!(
relay_base("?x=1", Some("wss://g.example"), o),
"wss://g.example"
);
assert_eq!(relay_base("", None, o), "wss://app.example");
assert_eq!(
relay_base("", Some(""), "http://localhost:8787"),
"ws://localhost:8787"
);
assert_eq!(http_origin("wss://r.example"), "https://r.example");
assert_eq!(http_origin("ws://localhost:1"), "http://localhost:1");
}
}
+3 -2
View File
@@ -23,7 +23,7 @@ use tokio_util::sync::CancellationToken;
pub type Dispatch = Arc<dyn Fn(Call) -> bool + Send + Sync>;
/// Longest a tool call may take on the editor thread.
const CALL_TIMEOUT: Duration = Duration::from_secs(120);
pub const CALL_TIMEOUT: Duration = Duration::from_secs(120);
/// The tool catalogue as rmcp tools.
pub fn tool_list() -> Vec<Tool> {
@@ -107,7 +107,8 @@ impl ServerHandler for PaperServer {
}
/// Add the `Accept` header the streamable HTTP transport requires when a client omits it.
async fn fix_accept(
/// Shared with the relay, which serves the same transport to remote agents.
pub(crate) async fn fix_accept(
mut req: axum::extract::Request,
next: axum::middleware::Next,
) -> axum::response::Response {
+6
View File
@@ -252,6 +252,12 @@ fn migrate(
// ---------------------------------------------------------------------------------- helpers
#[cfg(target_arch = "wasm32")]
pub fn now_ms() -> u64 {
js_sys::Date::now() as u64
}
#[cfg(not(target_arch = "wasm32"))]
pub fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
+25 -25
View File
@@ -93,7 +93,7 @@ impl Store {
pub fn open(root: impl Into<PathBuf>) -> std::io::Result<Store> {
let root = root.into();
std::fs::create_dir_all(&root)?;
crate::vfs::create_dir_all(&root)?;
Ok(Store { root })
}
@@ -103,7 +103,7 @@ impl Store {
/// Projects, most recently modified first. Unreadable folders are skipped.
pub fn list(&self) -> Vec<Entry> {
let mut out: Vec<Entry> = std::fs::read_dir(&self.root)
let mut out: Vec<Entry> = crate::vfs::read_dir(&self.root)
.into_iter()
.flatten()
.flatten()
@@ -113,7 +113,7 @@ impl Store {
let manifest = read_manifest(&dir).ok()?;
let thumb = dir.join(THUMBNAIL);
Some(Entry {
thumbnail: thumb.is_file().then_some(thumb),
thumbnail: crate::vfs::is_file(&thumb).then_some(thumb),
dir,
manifest,
})
@@ -133,7 +133,7 @@ impl Store {
let base = folder_name(name);
let mut p = parent.join(&base);
let mut i = 2;
while p.exists() {
while crate::vfs::exists(&p) {
p = parent.join(format!("{base} {i}"));
i += 1;
}
@@ -145,7 +145,7 @@ impl Store {
let taken: Vec<String> = self.list().into_iter().map(|e| e.manifest.name).collect();
let mut n = name.to_string();
let mut i = 2;
while taken.contains(&n) || self.root.join(folder_name(&n)).exists() {
while taken.contains(&n) || crate::vfs::exists(&self.root.join(folder_name(&n))) {
n = format!("{name} {i}");
i += 1;
}
@@ -156,7 +156,7 @@ impl Store {
pub fn create(&self, name: &str) -> Result<(Project, PageData), FormatError> {
let name = self.unique_name(name);
let dir = self.unique_dir(&self.root, &name);
std::fs::create_dir_all(dir.join(PAGES_DIR))?;
crate::vfs::create_dir_all(dir.join(PAGES_DIR))?;
let manifest = Manifest::new(&name);
let page = PageData {
id: manifest.pages[0].id.clone(),
@@ -181,7 +181,7 @@ impl Store {
) -> Result<Project, FormatError> {
let name = self.unique_name(name);
let dir = self.unique_dir(&self.root, &name);
std::fs::create_dir_all(dir.join(PAGES_DIR))?;
crate::vfs::create_dir_all(dir.join(PAGES_DIR))?;
let mut manifest = Manifest::new(&name);
manifest.pages = pages
.iter()
@@ -214,12 +214,12 @@ impl Store {
if wanted == dir {
return Ok(dir.to_path_buf());
}
let target = if wanted.exists() {
let target = if crate::vfs::exists(&wanted) {
self.unique_dir(&self.root, new_name)
} else {
wanted
};
std::fs::rename(dir, &target)?;
crate::vfs::rename(dir, &target)?;
Ok(target)
}
@@ -244,26 +244,26 @@ impl Store {
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
std::fs::create_dir_all(self.trash_root())?;
crate::vfs::create_dir_all(self.trash_root())?;
let deleted = now_ms();
let target = self
.trash_root()
.join(format!("{}~{deleted}", folder_name(&original)));
std::fs::rename(dir, &target)?;
crate::vfs::rename(dir, &target)?;
let info = serde_json::json!({ "deleted": deleted, "original": original });
atomic::write(&target.join(TRASH_INFO), info.to_string().as_bytes())?;
Ok(target)
}
pub fn list_trash(&self) -> Vec<TrashEntry> {
let mut out: Vec<TrashEntry> = std::fs::read_dir(self.trash_root())
let mut out: Vec<TrashEntry> = crate::vfs::read_dir(self.trash_root())
.into_iter()
.flatten()
.flatten()
.filter_map(|e| {
let dir = e.path();
let manifest = read_manifest(&dir).ok()?;
let info: serde_json::Value = std::fs::read_to_string(dir.join(TRASH_INFO))
let info: serde_json::Value = crate::vfs::read_to_string(dir.join(TRASH_INFO))
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
@@ -290,8 +290,8 @@ impl Store {
.find(|e| e.dir == trash_dir)
.ok_or_else(|| FormatError::Invalid("not in trash".into()))?;
let target = self.unique_dir(&self.root, &entry.original);
std::fs::rename(trash_dir, &target)?;
let _ = std::fs::remove_file(target.join(TRASH_INFO));
crate::vfs::rename(trash_dir, &target)?;
let _ = crate::vfs::remove_file(target.join(TRASH_INFO));
Ok(target)
}
@@ -301,7 +301,7 @@ impl Store {
"refusing to delete outside the trash",
));
}
std::fs::remove_dir_all(trash_dir)
crate::vfs::remove_dir_all(trash_dir)
}
pub fn empty_trash(&self) -> std::io::Result<()> {
@@ -313,18 +313,18 @@ impl Store {
}
fn read_manifest(dir: &Path) -> Result<Manifest, FormatError> {
Manifest::from_json(&std::fs::read_to_string(dir.join(MANIFEST))?)
Manifest::from_json(&crate::vfs::read_to_string(dir.join(MANIFEST))?)
}
fn copy_dir(from: &Path, to: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(to)?;
for e in std::fs::read_dir(from)? {
crate::vfs::create_dir_all(to)?;
for e in crate::vfs::read_dir(from)? {
let e = e?;
let target = to.join(e.file_name());
if e.file_type()?.is_dir() {
copy_dir(&e.path(), &target)?;
} else {
std::fs::copy(e.path(), target)?;
crate::vfs::copy(e.path(), target)?;
}
}
Ok(())
@@ -346,7 +346,7 @@ impl Project {
}
pub fn load_page(&self, id: &str) -> Result<PageData, FormatError> {
let mut p = format::page_from_json(&std::fs::read_to_string(self.page_path(id))?)?;
let mut p = format::page_from_json(&crate::vfs::read_to_string(self.page_path(id))?)?;
if let Some(r) = self.manifest.pages.iter().find(|r| r.id == id) {
// The manifest owns page names (renames touch only the manifest).
p.name = r.name.clone();
@@ -379,7 +379,7 @@ impl Project {
}
pub fn save_page(&self, page: &PageData) -> Result<(), FormatError> {
std::fs::create_dir_all(self.dir.join(PAGES_DIR))?;
crate::vfs::create_dir_all(self.dir.join(PAGES_DIR))?;
atomic::write(
&self.page_path(&page.id),
format::page_to_json(page).as_bytes(),
@@ -388,7 +388,7 @@ impl Project {
}
pub fn delete_page_file(&self, id: &str) {
let _ = std::fs::remove_file(self.page_path(id));
let _ = crate::vfs::remove_file(self.page_path(id));
}
pub fn write_manifest(&self) -> Result<(), FormatError> {
@@ -447,7 +447,7 @@ mod tests {
s.empty_trash().unwrap();
assert!(s.list_trash().is_empty());
assert!(s.delete_forever(&s.root.join("Alpha")).is_err());
std::fs::remove_dir_all(&s.root).ok();
crate::vfs::remove_dir_all(&s.root).ok();
}
#[test]
@@ -474,7 +474,7 @@ mod tests {
assert_eq!(pages[0], page);
assert!(pages[1].doc.nodes.is_empty());
assert_eq!(pages[1].name, "Ghost");
std::fs::remove_dir_all(&s.root).ok();
crate::vfs::remove_dir_all(&s.root).ok();
}
#[test]
+104 -12
View File
@@ -772,8 +772,8 @@ impl Headless {
})
}
/// Copy a texture back to the CPU as RGBA8.
pub fn read(&self, tex: &wgpu::Texture) -> image::RgbaImage {
/// Copy `tex` into a mappable buffer and submit; returns the buffer and its row stride.
fn start_readback(&self, tex: &wgpu::Texture) -> (wgpu::Buffer, u32) {
let (w, h) = (tex.width(), tex.height());
let row = (w * 4).div_ceil(256) * 256;
let buf = self.device.create_buffer(&wgpu::BufferDescriptor {
@@ -805,12 +805,15 @@ impl Headless {
},
);
self.queue.submit([enc.finish()]);
let slice = buf.slice(..);
slice.map_async(wgpu::MapMode::Read, |_| {});
self.device
.poll(wgpu::PollType::wait_indefinitely())
.expect("poll");
let data = slice.get_mapped_range().expect("mapped readback buffer");
(buf, row)
}
/// Strip the row padding off a mapped readback buffer.
fn unpad(buf: &wgpu::Buffer, row: u32, w: u32, h: u32) -> image::RgbaImage {
let data = buf
.slice(..)
.get_mapped_range()
.expect("mapped readback buffer");
let mut img = image::RgbaImage::new(w, h);
for y in 0..h {
let src = &data[(y * row) as usize..(y * row + w * 4) as usize];
@@ -819,14 +822,38 @@ impl Headless {
img
}
/// Render a canvas frame into a new image of `size` physical px.
pub fn render_frame(
/// Copy a texture back to the CPU as RGBA8.
pub fn read(&self, tex: &wgpu::Texture) -> image::RgbaImage {
let (buf, row) = self.start_readback(tex);
buf.slice(..).map_async(wgpu::MapMode::Read, |_| {});
self.device
.poll(wgpu::PollType::wait_indefinitely())
.expect("poll");
Self::unpad(&buf, row, tex.width(), tex.height())
}
/// [`Headless::read`] without blocking: the browser cannot wait on the GPU, so the
/// buffer is mapped with a callback and the caller awaits it (the JS event loop drives it).
pub async fn read_async(&self, tex: &wgpu::Texture) -> Result<image::RgbaImage, String> {
let (buf, row) = self.start_readback(tex);
let (tx, rx) = oneshot();
buf.slice(..)
.map_async(wgpu::MapMode::Read, move |r| tx.send(r));
// WebGPU resolves the mapping from the JS event loop; the WebGL backend only delivers
// callbacks when polled, so nudge it (never blocks).
let _ = self.device.poll(wgpu::PollType::Poll);
rx.await.map_err(|e| format!("GPU readback failed: {e}"))?;
Ok(Self::unpad(&buf, row, tex.width(), tex.height()))
}
/// Draw a canvas frame into a new offscreen texture (not yet read back).
fn draw_frame(
&self,
frame: &CanvasFrame,
size: [u32; 2],
scale: f32,
clear: [f64; 4],
) -> image::RgbaImage {
) -> wgpu::Texture {
let tex = self.target(size);
let view = tex.create_view(&Default::default());
let mut r = CanvasRenderer::new(&self.device, OFFSCREEN_FORMAT);
@@ -842,6 +869,71 @@ impl Headless {
clear,
);
self.queue.submit([enc.finish()]);
self.read(&tex)
tex
}
/// Render a canvas frame into a new image of `size` physical px.
pub fn render_frame(
&self,
frame: &CanvasFrame,
size: [u32; 2],
scale: f32,
clear: [f64; 4],
) -> image::RgbaImage {
self.read(&self.draw_frame(frame, size, scale, clear))
}
/// [`Headless::render_frame`] for the browser (see [`Headless::read_async`]).
pub async fn render_frame_async(
&self,
frame: &CanvasFrame,
size: [u32; 2],
scale: f32,
clear: [f64; 4],
) -> Result<image::RgbaImage, String> {
self.read_async(&self.draw_frame(frame, size, scale, clear))
.await
}
}
/// Minimal one-shot channel: wgpu's map callback on one end, an awaiting task on the other.
/// (No async runtime exists in the browser build, and `tokio::sync::oneshot` is native-only.)
struct Slot<T> {
value: Option<T>,
waker: Option<std::task::Waker>,
}
struct Sender<T>(std::sync::Arc<std::sync::Mutex<Slot<T>>>);
struct Receiver<T>(std::sync::Arc<std::sync::Mutex<Slot<T>>>);
fn oneshot<T>() -> (Sender<T>, Receiver<T>) {
let slot = std::sync::Arc::new(std::sync::Mutex::new(Slot {
value: None,
waker: None,
}));
(Sender(slot.clone()), Receiver(slot))
}
impl<T> Sender<T> {
fn send(self, v: T) {
let mut s = self.0.lock().expect("oneshot lock");
s.value = Some(v);
if let Some(w) = s.waker.take() {
w.wake();
}
}
}
impl<T> std::future::Future for Receiver<T> {
type Output = T;
fn poll(self: std::pin::Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> std::task::Poll<T> {
let mut s = self.0.lock().expect("oneshot lock");
match s.value.take() {
Some(v) => std::task::Poll::Ready(v),
None => {
s.waker = Some(cx.waker().clone());
std::task::Poll::Pending
}
}
}
}
+52 -4
View File
@@ -6,7 +6,8 @@ use crate::editor::Editor;
use crate::project::{FormatError, PageRef, Project, Store, now_ms};
use crate::render::gpu::Headless;
use std::path::Path;
use std::time::{Duration, Instant};
use std::time::Duration;
use web_time::Instant;
/// Save this long after the first unsaved change (bounded latency, even while editing).
pub const AUTOSAVE_DELAY: Duration = Duration::from_millis(1000);
@@ -159,12 +160,24 @@ impl Session {
match render_thumbnail(gpu, doc)? {
Some(png) => crate::io::atomic::write(&path, &png).map_err(|e| e.to_string()),
None => {
let _ = std::fs::remove_file(&path);
let _ = crate::vfs::remove_file(&path);
Ok(())
}
}
}
/// Mark the thumbnail as taken and hand back what has to be rendered (the browser renders it
/// asynchronously and then calls [`finish_thumbnail`]).
pub fn start_thumbnail(&mut self, ed: &Editor) -> Option<ThumbJob> {
let p = self.project.as_ref()?;
self.thumb_stale = false;
self.last_thumb = Some(Instant::now());
Some(ThumbJob {
path: p.thumbnail_path(),
doc: ed.page_doc(0).clone(),
})
}
/// Close the project: final save; the caller then shows the home screen.
pub fn close(&mut self, ed: &mut Editor) -> Result<(), FormatError> {
let r = self.save_all(ed);
@@ -174,6 +187,41 @@ impl Session {
}
}
/// A thumbnail waiting to be rendered: the first page as it was when the job started.
pub struct ThumbJob {
pub path: std::path::PathBuf,
pub doc: crate::model::Document,
}
/// Scale that fits the document's bounds into the thumbnail box, None when it's empty.
fn thumbnail_scale(doc: &crate::model::Document) -> Option<f64> {
let b = crate::io::svg::export_bounds(doc, &doc.root)?;
Some((THUMB_SIZE.0 / b.w).min(THUMB_SIZE.1 / b.h).min(2.0))
}
/// [`render_thumbnail`] without blocking on the GPU (browser).
pub async fn render_thumbnail_async(
gpu: &Headless,
doc: &crate::model::Document,
) -> Result<Option<Vec<u8>>, String> {
let Some(scale) = thumbnail_scale(doc) else {
return Ok(None);
};
let img = crate::io::png::render_async(gpu, doc, None, scale, None).await?;
crate::io::export::encode_png(&img).map(Some)
}
/// Store a rendered thumbnail (None: the page is empty, so remove any old one).
pub fn finish_thumbnail(path: &std::path::Path, png: Option<Vec<u8>>) -> Result<(), String> {
match png {
Some(png) => crate::io::atomic::write(path, &png).map_err(|e| e.to_string()),
None => {
let _ = crate::vfs::remove_file(path);
Ok(())
}
}
}
/// PNG bytes of a document fitted into the thumbnail box, or None when it's empty.
pub fn render_thumbnail(
gpu: &Headless,
@@ -231,7 +279,7 @@ mod tests {
Session::new(s.clone()).open(&mut ed2, &dir).unwrap();
assert_eq!(ed2.doc.nodes, ed.doc.nodes);
assert!(!ed2.project_dirty());
std::fs::remove_dir_all(&s.root).ok();
crate::vfs::remove_dir_all(&s.root).ok();
}
#[test]
@@ -262,6 +310,6 @@ mod tests {
p.manifest.active_page.as_deref(),
Some(pages[0].id.as_str())
);
std::fs::remove_dir_all(&s.root).ok();
crate::vfs::remove_dir_all(&s.root).ok();
}
}
+58 -6
View File
@@ -20,7 +20,7 @@ pub const MAC_BLUE: Color32 = Color32::from_rgb(0x00, 0x7a, 0xff);
pub const BODY: f32 = 13.0;
pub const SECONDARY: f32 = 11.0;
/// Inner margin of text inputs (egui's default is a cramped 4x2).
pub const INPUT_MARGIN: egui::Margin = egui::Margin::symmetric(8, 5);
pub const INPUT_MARGIN: egui::Margin = egui::Margin::symmetric(10, 7);
/// Height of the unified toolbar.
pub const TOOLBAR_H: f32 = 40.0;
/// Traffic lights: left inset and the height of the bar they are centred in (points).
@@ -192,6 +192,47 @@ pub fn install_fonts(ctx: &egui::Context) {
pub fn medium(size: f32) -> FontId {
FontId::new(size, FontFamily::Name("medium".into()))
}
/// Figma-style inspector controls inside `ui`: flat tinted fields, no borders, 28px rows.
pub fn figma_fields(ui: &mut egui::Ui) {
let dark = ui.visuals().dark_mode;
let tint = |a: u8| {
if dark {
Color32::from_white_alpha(a)
} else {
Color32::from_black_alpha(a)
}
};
let (rest, hover, press) = (tint(14), tint(24), tint(34));
let sp = ui.spacing_mut();
sp.interact_size.y = FIELD_H;
sp.button_padding = vec2(8.0, 4.0);
sp.item_spacing = vec2(8.0, 6.0);
let v = ui.visuals_mut();
v.text_edit_bg_color = Some(rest);
v.extreme_bg_color = rest;
let r5 = CornerRadius::same(5);
let w = &mut v.widgets;
for (s, fill) in [
(&mut w.inactive, rest),
(&mut w.hovered, hover),
(&mut w.active, press),
(&mut w.open, press),
] {
s.bg_fill = fill;
s.weak_bg_fill = fill;
s.bg_stroke = Stroke::NONE;
s.corner_radius = r5;
}
}
/// Height of an inspector field row.
pub const FIELD_H: f32 = 28.0;
/// egui's `small_button` zeroes the padding; this keeps the theme's but stays compact.
pub fn small_button(ui: &mut egui::Ui, text: impl Into<egui::WidgetText>) -> egui::Response {
ui.add(egui::Button::new(text).min_size(vec2(0.0, 24.0)))
}
pub fn semibold(size: f32) -> FontId {
FontId::new(size, FontFamily::Name("semibold".into()))
}
@@ -213,8 +254,8 @@ fn style_for(s: &mut egui::Style, dark: bool, p: Palette) {
]
.into();
s.spacing.item_spacing = vec2(6.0, 4.0);
s.spacing.button_padding = vec2(9.0, 4.0);
s.spacing.interact_size = vec2(24.0, 26.0);
s.spacing.button_padding = vec2(12.0, 6.0);
s.spacing.interact_size = vec2(26.0, 30.0);
s.spacing.menu_margin = egui::Margin::same(5);
s.spacing.window_margin = egui::Margin::same(12);
s.spacing.scroll = egui::style::ScrollStyle {
@@ -351,6 +392,7 @@ impl Choice {
}
/// Persisted in the config dir; a missing or unreadable file means `System`.
#[cfg(not(target_arch = "wasm32"))]
fn path() -> Option<std::path::PathBuf> {
let dir = dirs::config_dir()?;
let new = dir.join("papercraft").join("theme");
@@ -362,9 +404,15 @@ impl Choice {
})
}
/// No config dir in a browser: the virtual file system holds it at a fixed path.
#[cfg(target_arch = "wasm32")]
fn path() -> Option<std::path::PathBuf> {
Some(std::path::PathBuf::from(WEB_PATH))
}
pub fn load() -> Choice {
Choice::path()
.and_then(|p| std::fs::read_to_string(p).ok())
.and_then(|p| crate::vfs::read_to_string(p).ok())
.and_then(|s| Choice::parse(&s))
.unwrap_or_default()
}
@@ -372,13 +420,17 @@ impl Choice {
pub fn save(self) {
if let Some(p) = Choice::path() {
if let Some(dir) = p.parent() {
let _ = std::fs::create_dir_all(dir);
let _ = crate::vfs::create_dir_all(dir);
}
let _ = std::fs::write(p, self.id());
let _ = crate::vfs::write(p, self.id());
}
}
}
/// Where the theme choice lives in the browser's virtual file system.
#[cfg(target_arch = "wasm32")]
const WEB_PATH: &str = "/config/theme";
static CHOICE: AtomicU8 = AtomicU8::new(0);
pub fn choice() -> Choice {
+161
View File
@@ -0,0 +1,161 @@
//! The browser build's "Agent" menu: a link a remote AI agent can use to drive this tab through
//! the relay (see `mcp::relay`). The shell (`web/relay.rs`) owns the connection and mirrors its
//! state here; this module only draws and records what the user clicked.
use super::theme;
use egui::{Color32, RichText};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum LinkState {
#[default]
Offline,
Connecting,
Connected,
}
impl LinkState {
pub fn label(self) -> &'static str {
match self {
LinkState::Offline => "Offline",
LinkState::Connecting => "Connecting",
LinkState::Connected => "Connected",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AgentAction {
/// Replace the token (the old link stops working) and reconnect.
Regenerate,
}
#[derive(Debug, Default)]
pub struct AgentUi {
/// The full agent URL, `https://<host>/s/<token>/mcp`.
pub url: String,
pub state: LinkState,
pub open: bool,
pub actions: Vec<AgentAction>,
}
const CONNECTED_DOT: Color32 = Color32::from_rgb(0x34, 0xc7, 0x59);
const CONNECTING_DOT: Color32 = Color32::from_rgb(0xff, 0xb3, 0x40);
const DOT_RADIUS: f32 = 4.0;
/// Shell command that registers the link with Claude Code.
pub fn claude_command(url: &str) -> String {
format!("claude mcp add --transport http papercraft {url}")
}
/// Generic MCP client configuration for the link.
pub fn json_config(url: &str) -> String {
serde_json::to_string_pretty(&serde_json::json!({
"mcpServers": { "papercraft": { "type": "http", "url": url } }
}))
.expect("config serializes")
}
impl AgentUi {
/// The top-level "Agent" menu.
pub fn menu(&mut self, ui: &mut egui::Ui) {
ui.menu_button("Agent", |ui| {
if ui.button("Connect…").clicked() {
self.open = true;
ui.close();
}
});
}
/// A status dot for the toolbar; nothing while offline.
pub fn dot(&self, ui: &mut egui::Ui) {
let color = match self.state {
LinkState::Offline => return,
LinkState::Connecting => CONNECTING_DOT,
LinkState::Connected => CONNECTED_DOT,
};
let (rect, resp) = ui.allocate_exact_size(
egui::Vec2::splat(DOT_RADIUS * 2.0 + 4.0),
egui::Sense::hover(),
);
ui.painter().circle_filled(rect.center(), DOT_RADIUS, color);
resp.on_hover_text(format!("Agent link: {}", self.state.label()));
}
/// The "Connect…" window.
pub fn window(&mut self, ctx: &egui::Context) {
let mut open = self.open;
egui::Window::new("Connect an AI agent")
.open(&mut open)
.collapsible(false)
.default_width(460.0)
.show(ctx, |ui| self.contents(ui));
self.open = open && self.open;
}
fn contents(&mut self, ui: &mut egui::Ui) {
let p = theme::pal(ui);
ui.label(
RichText::new(
"Give this link to an MCP-capable agent. Anyone who has it can edit this tab, \
so treat it like a password.",
)
.size(theme::SECONDARY)
.color(p.secondary),
);
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.label("Status:");
let color = match self.state {
LinkState::Connected => CONNECTED_DOT,
LinkState::Connecting => CONNECTING_DOT,
LinkState::Offline => p.secondary,
};
ui.label(RichText::new(self.state.label()).color(color));
});
ui.add_space(6.0);
self.copy_row(ui, "Agent URL", &self.url.clone(), false);
self.copy_row(ui, "Claude Code", &claude_command(&self.url), false);
self.copy_row(ui, "MCP config (JSON)", &json_config(&self.url), true);
ui.add_space(6.0);
if ui.button("Regenerate link").clicked() {
self.actions.push(AgentAction::Regenerate);
}
}
fn copy_row(&self, ui: &mut egui::Ui, label: &str, text: &str, multiline: bool) {
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.label(label);
if ui.button("Copy").clicked() {
ui.ctx().copy_text(text.to_string());
}
});
let mut shown = text;
let edit = if multiline {
egui::TextEdit::multiline(&mut shown).desired_rows(5)
} else {
egui::TextEdit::singleline(&mut shown)
};
ui.add(
edit.desired_width(f32::INFINITY)
.font(egui::TextStyle::Monospace),
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn snippets_carry_the_url() {
let url = "https://h.example/s/abc/mcp";
assert_eq!(
claude_command(url),
"claude mcp add --transport http papercraft https://h.example/s/abc/mcp"
);
let v: serde_json::Value = serde_json::from_str(&json_config(url)).unwrap();
assert_eq!(v["mcpServers"]["papercraft"]["url"], url);
assert_eq!(v["mcpServers"]["papercraft"]["type"], "http");
}
}
+5 -5
View File
@@ -33,6 +33,7 @@ pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
.id_salt("design")
.auto_shrink(false)
.show(ui, |ui| {
crate::theme::figma_fields(ui);
// ---- open feedback on this page (and a pointer to other pages)
let mut open: Vec<(NodeId, String, String)> = ed
.doc
@@ -130,8 +131,7 @@ pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
}
ui.label(RichText::new(format!("--{name}")).monospace().size(theme::SECONDARY + 1.0));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui
.small_button("✕")
if crate::theme::small_button(ui, "✕")
.on_hover_text("Delete (fills keep their color)")
.clicked()
{
@@ -158,7 +158,7 @@ pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
.chars()
.map(|c| if c.is_ascii_alphanumeric() || c == '_' { c } else { '-' })
.collect();
if (ui.small_button("Add").clicked() || enter) && !name.is_empty() {
if (crate::theme::small_button(ui, "Add").clicked() || enter) && !name.is_empty() {
st.variable_draft.clear();
let base = Color::rgb(0x80, 0x80, 0x80);
on_every_page(ed, "Add variable", |tx| {
@@ -208,11 +208,11 @@ pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
ui.label(RichText::new(&name).font(theme::medium(theme::BODY)));
ui.label(RichText::new(format!("{uses}")).weak().size(theme::SECONDARY));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.small_button("Insert").on_hover_text("Place an instance in view").clicked() {
if crate::theme::small_button(ui, "Insert").on_hover_text("Place an instance in view").clicked() {
let at = ed.camera.screen_to_world(ed.viewport * 0.5);
ed.create_instance(id, at);
}
if ui.small_button("Go to").on_hover_text("Select the main component").clicked() {
if crate::theme::small_button(ui, "Go to").on_hover_text("Select the main component").clicked() {
ed.selection = vec![id];
ed.run(crate::editor::shortcuts::Action::ZoomSelection);
}
+21 -9
View File
@@ -18,6 +18,8 @@ pub enum HomeAction {
Restore(PathBuf),
DeleteForever(PathBuf),
EmptyTrash,
/// Show the projects folder in the system file manager.
RevealRoot,
}
#[derive(Default)]
@@ -36,6 +38,13 @@ pub struct HomeView {
const CARD_W: f32 = 232.0;
const THUMB_H: f32 = 148.0;
/// `HomeAction::RevealRoot` opens the folder natively; a browser has none, so the same action
/// downloads a zip of every project there.
const REVEAL_LABEL: &str = if cfg!(target_arch = "wasm32") {
"Download backup"
} else {
"Open projects folder"
};
impl HomeView {
/// Re-read the project list and trash from the store (keeps cached thumbnails).
@@ -53,13 +62,15 @@ impl HomeView {
}
fn thumb(&mut self, ctx: &egui::Context, path: &Path) -> Option<egui::TextureHandle> {
let mtime = std::fs::metadata(path).and_then(|m| m.modified()).ok();
let mtime = crate::vfs::metadata(path).and_then(|m| m.modified()).ok();
if let Some((t, h)) = self.thumbs.get(path)
&& *t == mtime
{
return Some(h.clone());
}
let img = image::open(path).ok()?.to_rgba8();
let img = image::load_from_memory(&crate::vfs::read(path).ok()?)
.ok()?
.to_rgba8();
let size = [img.width() as usize, img.height() as usize];
let ci = egui::ColorImage::from_rgba_unmultiplied(size, img.as_raw());
let h = ctx.load_texture(path.to_string_lossy(), ci, egui::TextureOptions::LINEAR);
@@ -124,12 +135,13 @@ pub fn draw(ui: &mut Ui, hv: &mut HomeView, native_chrome: bool, translucent: bo
}
ui.with_layout(egui::Layout::bottom_up(egui::Align::Min), |ui| {
ui.add_space(4.0);
ui.label(
RichText::new(hv.root.display().to_string())
.size(theme::SECONDARY - 1.0)
.color(p.secondary),
)
.on_hover_text("Projects folder");
let path = RichText::new(hv.root.display().to_string())
.size(theme::SECONDARY - 1.0)
.color(p.secondary);
ui.label(path).on_hover_text("Projects folder");
if sidebar_item(ui, Icon::Page, REVEAL_LABEL, None, false) {
hv.actions.push(HomeAction::RevealRoot);
}
});
});
egui::Panel::top("home-toolbar")
@@ -436,7 +448,7 @@ fn trash_grid(ui: &mut Ui, hv: &mut HomeView) {
ui.vertical(|ui| {
ui.set_width(CARD_W);
let thumb = e.dir.join(crate::project::THUMBNAIL);
let thumb = thumb.is_file().then_some(thumb);
let thumb = crate::vfs::is_file(&thumb).then_some(thumb);
let resp = card_frame(ui, hv, thumb.as_deref(), egui::Id::new(&e.dir));
resp.context_menu(|ui| {
if ui.button("Restore").clicked() {
+457 -167
View File
@@ -56,7 +56,144 @@ fn row<R>(ui: &mut Ui, label: &str, add: impl FnOnce(&mut Ui) -> R) -> R {
.inner
}
/// A compact number field with a leading letter (like Figma's "X 120").
/// Width of one of two equal columns that fill the row.
fn half(ui: &Ui) -> f32 {
(ui.available_width() - ui.spacing().item_spacing.x) / 2.0
}
/// Width of one of three equal columns that fill the row.
fn third(ui: &Ui) -> f32 {
(ui.available_width() - 2.0 * ui.spacing().item_spacing.x) / 3.0
}
enum Cell<'a> {
Icon(theme::Icon),
Text(&'a str),
}
/// A Figma-style segmented control: one tinted box split into equal cells. Returns the cell
/// that was clicked.
fn segmented(ui: &mut Ui, cells: &[(Cell, bool, &str)], width: f32) -> Option<usize> {
let h = theme::FIELD_H;
let (rect, _) = ui.allocate_exact_size(egui::vec2(width, h), egui::Sense::hover());
let p = theme::pal(ui);
let rest = ui.visuals().widgets.inactive.bg_fill;
let (hover, on_fill) = (
ui.visuals().widgets.hovered.bg_fill,
ui.visuals().widgets.active.bg_fill,
);
ui.painter().rect_filled(rect, 5.0, rest);
let cw = width / cells.len() as f32;
let mut clicked = None;
for (i, (cell, on, tip)) in cells.iter().enumerate() {
let r =
egui::Rect::from_min_size(rect.min + egui::vec2(cw * i as f32, 0.0), egui::vec2(cw, h));
let resp = ui
.allocate_rect(r, egui::Sense::click())
.on_hover_text(*tip);
if *on || resp.hovered() {
ui.painter()
.rect_filled(r.shrink(2.0), 4.0, if *on { on_fill } else { hover });
}
let ink = if *on { p.text } else { p.secondary };
match cell {
Cell::Icon(ic) => theme::paint_icon(
ui.painter(),
egui::Rect::from_center_size(r.center(), egui::Vec2::splat(14.0)),
*ic,
ink,
),
Cell::Text(t) => {
ui.painter().text(
r.center(),
egui::Align2::CENTER_CENTER,
*t,
theme::semibold(12.0),
ink,
);
}
}
if resp.clicked() {
clicked = Some(i);
}
}
clicked
}
/// Colour swatch plus hex, in one tinted field (Figma's fill/stroke row).
fn color_field(
ui: &mut Ui,
ed: &mut Editor,
c: Color,
bound: Option<&str>,
width: f32,
) -> Option<Color> {
let (rect, _) = ui.allocate_exact_size(egui::vec2(width, theme::FIELD_H), egui::Sense::hover());
ui.painter()
.rect_filled(rect, 5.0, ui.visuals().widgets.inactive.bg_fill);
let sw = egui::Rect::from_min_size(
rect.left_top() + egui::vec2(5.0, (theme::FIELD_H - 18.0) / 2.0),
egui::Vec2::splat(18.0),
);
let mut out = None;
let mut c2 = c32(c);
let r = ui
.scope_builder(egui::UiBuilder::new().max_rect(sw), |ui| {
ui.spacing_mut().interact_size = egui::Vec2::splat(18.0);
ui.color_edit_button_srgba(&mut c2)
})
.inner;
seal_on_release(ed, &r);
if r.changed() && from32(c2) != c {
out = Some(from32(c2));
}
let text_rect = egui::Rect::from_min_max(
egui::pos2(sw.right() + 8.0, rect.top()),
egui::pos2(rect.right() - 8.0, rect.bottom()),
);
if let Some(v) = bound {
ui.painter().text(
egui::pos2(text_rect.left(), rect.center().y),
egui::Align2::LEFT_CENTER,
format!("--{v}"),
egui::FontId::proportional(theme::SECONDARY + 1.0),
theme::pal(ui).secondary,
);
return out;
}
let id = ui.id().with("hex");
let shown = c.to_hex().trim_start_matches('#').to_uppercase();
let mut text = if ui.memory(|m| m.has_focus(id)) {
ui.data(|d| d.get_temp::<String>(id)).unwrap_or(shown)
} else {
shown
};
let r = ui.put(
text_rect,
egui::TextEdit::singleline(&mut text)
.id(id)
.frame(egui::Frame::NONE)
.margin(egui::Margin::ZERO)
.vertical_align(egui::Align::Center)
.font(egui::FontId::proportional(theme::SECONDARY + 1.0)),
);
if r.has_focus() {
ui.data_mut(|d| d.insert_temp(id, text.clone()));
}
if r.lost_focus() {
if let Some(nc) = Color::from_hex(&format!("#{}", text.trim().trim_start_matches('#'))) {
if nc != c {
out = Some(nc);
}
ed.history.seal();
}
ui.data_mut(|d| d.remove::<String>(id));
}
out
}
/// A Figma-style number field: tinted box, secondary label then the value, both on the left.
/// Drag sideways to scrub, click to type.
fn num_field(
ui: &mut Ui,
ed: &mut Editor,
@@ -66,17 +203,128 @@ fn num_field(
range: std::ops::RangeInclusive<f64>,
width: f32,
) -> Option<f64> {
let mut v = value;
let r = ui.add_sized(
egui::vec2(width, 20.0),
egui::DragValue::new(&mut v)
.speed(speed)
.range(range)
.max_decimals(2)
.prefix(prefix),
field(ui, ed, prefix, "", value, speed, range, width)
}
#[allow(clippy::too_many_arguments)]
fn field(
ui: &mut Ui,
ed: &mut Editor,
prefix: &str,
suffix: &str,
value: f64,
speed: f64,
range: std::ops::RangeInclusive<f64>,
width: f32,
) -> Option<f64> {
let enabled = ui.is_enabled();
let (rect, resp) = ui.allocate_exact_size(
egui::vec2(width, theme::FIELD_H),
egui::Sense::click_and_drag(),
);
seal_on_release(ed, &r);
(r.changed() && v != value).then_some(v)
let id = resp.id;
let p = theme::pal(ui);
let font = egui::FontId::proportional(theme::SECONDARY + 1.0);
let label = prefix.trim();
let inset = 8.0;
let mut out = None;
let mut buf = ui.data(|d| d.get_temp::<String>(id));
let text_x = if label.is_empty() {
rect.left() + inset
} else {
let w = ui
.painter()
.layout_no_wrap(label.into(), font.clone(), p.secondary)
.size()
.x;
rect.left() + inset + w + 8.0
};
if enabled && resp.hovered() || resp.dragged() {
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
}
if enabled && resp.dragged() && buf.is_none() {
let v = (value + resp.drag_delta().x as f64 * speed).clamp(*range.start(), *range.end());
if v != value {
out = Some(v);
}
}
if enabled && resp.drag_stopped() {
ed.history.seal();
}
if enabled && resp.clicked() && buf.is_none() {
buf = Some(fmt_num(value));
ui.memory_mut(|m| m.request_focus(id.with("edit")));
}
let fill = if buf.is_some() || !enabled {
ui.visuals().widgets.inactive.bg_fill
} else {
ui.style().interact(&resp).bg_fill
};
let edit_stroke = if buf.is_some() {
egui::Stroke::new(1.0, theme::accent())
} else {
egui::Stroke::NONE
};
ui.painter().rect(
rect,
egui::CornerRadius::same(5),
fill,
edit_stroke,
egui::StrokeKind::Inside,
);
let ink = if enabled { p.text } else { p.tertiary };
if !label.is_empty() {
ui.painter().text(
egui::pos2(rect.left() + inset, rect.center().y),
egui::Align2::LEFT_CENTER,
label,
font.clone(),
p.secondary,
);
}
if let Some(mut text) = buf {
let te_id = id.with("edit");
let edit_rect = egui::Rect::from_min_max(
egui::pos2(text_x, rect.top()),
egui::pos2(rect.right() - inset, rect.bottom()),
);
let r = ui.put(
edit_rect,
egui::TextEdit::singleline(&mut text)
.id(te_id)
.frame(egui::Frame::NONE)
.margin(egui::Margin::ZERO)
.vertical_align(egui::Align::Center)
.font(font),
);
let esc = ui.input(|i| i.key_pressed(egui::Key::Escape));
if r.lost_focus() {
if !esc && let Ok(v) = text.trim().trim_end_matches(suffix).trim().parse::<f64>() {
let v = v.clamp(*range.start(), *range.end());
if v != value {
out = Some(v);
}
ed.history.seal();
}
ui.data_mut(|d| d.remove::<String>(id));
} else {
ui.data_mut(|d| d.insert_temp(id, text));
}
} else {
ui.painter().text(
egui::pos2(text_x, rect.center().y),
egui::Align2::LEFT_CENTER,
format!("{}{suffix}", fmt_num(value)),
font,
ink,
);
}
out
}
fn fmt_num(v: f64) -> String {
let s = format!("{v:.2}");
s.trim_end_matches('0').trim_end_matches('.').to_string()
}
fn color_button(ui: &mut Ui, ed: &mut Editor, c: Color) -> Option<Color> {
@@ -126,20 +374,11 @@ fn icon_btn(ui: &mut Ui, icon: theme::Icon, on: bool, tip: &str) -> bool {
.clicked()
}
fn icon_toggle(ui: &mut Ui, label: &str, on: bool, tip: &str) -> bool {
ui.add(
egui::Button::selectable(on, RichText::new(label).font(theme::semibold(12.0)))
.min_size(egui::vec2(24.0, 20.0)),
)
.on_hover_text(tip)
.clicked()
}
pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
egui::ScrollArea::vertical()
.auto_shrink(false)
.show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 5.0;
theme::figma_fields(ui);
section(ui, "Design");
ui.add_space(2.0);
let ids = ed.top_selection();
@@ -231,14 +470,21 @@ fn feedback_section(ui: &mut Ui, ed: &mut Editor, st: &mut UiState, ids: &[NodeI
.wrap(),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.small_button("✕").on_hover_text("Delete").clicked() {
if crate::theme::small_button(ui, "✕")
.on_hover_text("Delete")
.clicked()
{
ed.edit("Delete feedback", |tx| {
if let Some(n) = tx.node_mut(id) {
n.comments.retain(|x| x.id != c.id);
}
});
}
if !c.resolved && ui.small_button("✓").on_hover_text("Resolve").clicked() {
if !c.resolved
&& crate::theme::small_button(ui, "✓")
.on_hover_text("Resolve")
.clicked()
{
ed.edit("Resolve feedback", |tx| {
if let Some(x) = tx
.node_mut(id)
@@ -259,29 +505,31 @@ fn feedback_section(ui: &mut Ui, ed: &mut Editor, st: &mut UiState, ids: &[NodeI
}
}
ui.horizontal(|ui| {
let w = (ui.available_width() - 44.0).max(60.0);
let r = ui.add(
egui::TextEdit::singleline(&mut st.feedback_draft)
.hint_text("Note for the agent…")
.desired_width(w)
.margin(crate::theme::INPUT_MARGIN),
);
let enter = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
if (ui.small_button("Add").clicked() || enter) && !st.feedback_draft.trim().is_empty() {
let text = std::mem::take(&mut st.feedback_draft).trim().to_string();
ed.edit("Add feedback", |tx| {
if let Some(n) = tx.node_mut(id) {
let next = n.comments.iter().map(|c| c.id).max().unwrap_or(0) + 1;
n.comments.push(crate::model::Comment {
id: next,
text,
author: "user".into(),
resolved: false,
reply: None,
});
}
});
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let add = crate::theme::small_button(ui, "Add").clicked();
let r = ui.add(
egui::TextEdit::singleline(&mut st.feedback_draft)
.hint_text("Note for the agent…")
.desired_width(ui.available_width())
.margin(crate::theme::INPUT_MARGIN),
);
let enter = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
if (add || enter) && !st.feedback_draft.trim().is_empty() {
let text = std::mem::take(&mut st.feedback_draft).trim().to_string();
ed.edit("Add feedback", |tx| {
if let Some(n) = tx.node_mut(id) {
let next = n.comments.iter().map(|c| c.id).max().unwrap_or(0) + 1;
n.comments.push(crate::model::Comment {
id: next,
text,
author: "user".into(),
resolved: false,
reply: None,
});
}
});
}
});
});
separator(ui);
}
@@ -446,8 +694,7 @@ fn sketch_section(ui: &mut Ui, ed: &mut Editor, ids: &[NodeId], first: &Node) {
});
}
});
if ui
.small_button("New wobble")
if crate::theme::small_button(ui, "New wobble")
.on_hover_text("Re-roll the random seed")
.clicked()
{
@@ -468,6 +715,7 @@ fn export_section(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
ui.label(title_text("Export"));
let spec = &mut st.export;
ui.horizontal(|ui| {
let w = half(ui);
let fmts: Vec<(Format, &str)> = Format::ALL
.iter()
.map(|f| {
@@ -481,19 +729,19 @@ fn export_section(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
)
})
.collect();
if let Some(v) = combo(ui, "exp-format", spec.format, &fmts, 64.0) {
if let Some(v) = combo(ui, "exp-format", spec.format, &fmts, w) {
spec.format = v;
}
if spec.format == Format::Png {
let scales = [(1u8, "1x"), (2, "2x"), (3, "3x"), (4, "4x")];
if let Some(v) = combo(ui, "exp-scale", spec.scale as u8, &scales, 48.0) {
if let Some(v) = combo(ui, "exp-scale", spec.scale as u8, &scales, w) {
spec.scale = v as f64;
}
}
});
let scopes: Vec<(Scope, &str)> = Scope::ALL.iter().map(|s| (*s, s.label())).collect();
row(ui, "Export", |ui| {
if let Some(v) = combo(ui, "exp-scope", spec.scope, &scopes, 110.0) {
if let Some(v) = combo(ui, "exp-scope", spec.scope, &scopes, ui.available_width()) {
spec.scope = v;
}
});
@@ -521,10 +769,17 @@ fn export_section(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
ed.requests.push(AppRequest::Export(*spec));
}
ui.horizontal(|ui| {
if ui.small_button("Copy as PNG").clicked() {
let w = half(ui);
if ui
.add(egui::Button::new("Copy as PNG").min_size(egui::vec2(w, 24.0)))
.clicked()
{
ed.run(crate::editor::shortcuts::Action::CopyAsPng);
}
if ui.small_button("Copy as SVG").clicked() {
if ui
.add(egui::Button::new("Copy as SVG").min_size(egui::vec2(w, 24.0)))
.clicked()
{
ed.run(crate::editor::shortcuts::Action::CopyAsSvg);
}
});
@@ -568,7 +823,14 @@ fn nothing_selected(ui: &mut Ui, ed: &mut Editor) {
separator(ui);
ui.label(title_text("Page"));
let mut name = ed.page_name().to_string();
if ui.text_edit_singleline(&mut name).changed() {
if ui
.add(
egui::TextEdit::singleline(&mut name)
.margin(crate::theme::INPUT_MARGIN)
.desired_width(f32::INFINITY),
)
.changed()
{
let i = ed.active_page;
ed.rename_page(i, &name);
}
@@ -589,19 +851,19 @@ fn header(ui: &mut Ui, ed: &mut Editor, ids: &[NodeId], first: &Node) {
BoolOp::Intersect,
BoolOp::Exclude,
] {
if ui.small_button(op.label()).clicked() {
if crate::theme::small_button(ui, op.label()).clicked() {
ed.boolean(op);
}
}
});
ui.horizontal(|ui| {
if ui.small_button("Group").clicked() {
if crate::theme::small_button(ui, "Group").clicked() {
ed.group_selection();
}
if ui.small_button("Frame").clicked() {
if crate::theme::small_button(ui, "Frame").clicked() {
ed.run(crate::editor::shortcuts::Action::WrapInFrame);
}
if ui.small_button("Component").clicked() {
if crate::theme::small_button(ui, "Component").clicked() {
ed.create_component();
}
});
@@ -617,18 +879,18 @@ fn header(ui: &mut Ui, ed: &mut Editor, ids: &[NodeId], first: &Node) {
ui.label(title_text(format!("{kind} · {}", first.name)));
ui.horizontal_wrapped(|ui| {
if first.component {
if ui.small_button("Create instance").clicked() {
if crate::theme::small_button(ui, "Create instance").clicked() {
ed.instance_of_selection();
}
} else if ed.is_instance_root(first.id) {
if ui.small_button("Go to main").clicked() {
if crate::theme::small_button(ui, "Go to main").clicked() {
ed.go_to_main();
}
if ui.small_button("Detach").clicked() {
if crate::theme::small_button(ui, "Detach").clicked() {
ed.detach_instance();
}
} else if (first.is_frame() || first.is_group())
&& ui.small_button("Create component").clicked()
&& crate::theme::small_button(ui, "Create component").clicked()
{
ed.create_component();
}
@@ -637,23 +899,25 @@ fn header(ui: &mut Ui, ed: &mut Editor, ids: &[NodeId], first: &Node) {
.instance
.as_ref()
.is_some_and(|l| !l.overrides.is_empty())
&& ui.small_button("Reset overrides").clicked()
&& crate::theme::small_button(ui, "Reset overrides").clicked()
{
ed.reset_overrides();
}
if first.is_container()
&& !first.children.is_empty()
&& ui.small_button("Ungroup").clicked()
&& crate::theme::small_button(ui, "Ungroup").clicked()
{
ed.ungroup_selection();
}
if !first.is_container()
&& !matches!(first.kind, NodeKind::Path { .. })
&& ui.small_button("Flatten").clicked()
&& crate::theme::small_button(ui, "Flatten").clicked()
{
ed.flatten_selection();
}
if matches!(first.kind, NodeKind::Path { .. }) && ui.small_button("Edit points").clicked() {
if matches!(first.kind, NodeKind::Path { .. })
&& crate::theme::small_button(ui, "Edit points").clicked()
{
ed.start_path_edit(first.id);
}
});
@@ -670,36 +934,37 @@ fn header(ui: &mut Ui, ed: &mut Editor, ids: &[NodeId], first: &Node) {
fn align_row(ui: &mut Ui, ed: &mut Editor, _ids: &[NodeId]) {
use theme::Icon as I;
let aligns = [
(I::AlignLeft, 0, 0.0, "Align left (⌥A)"),
(I::AlignHCenter, 0, 0.5, "Align horizontal centers (⌥H)"),
(I::AlignRight, 0, 1.0, "Align right (⌥D)"),
(I::AlignTop, 1, 0.0, "Align top (⌥W)"),
(I::AlignVCenter, 1, 0.5, "Align vertical centers (⌥V)"),
(I::AlignBottom, 1, 1.0, "Align bottom (⌥S)"),
];
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 1.0;
for (icon, axis, to, tip) in [
(I::AlignLeft, 0, 0.0, "Align left (⌥A)"),
(I::AlignHCenter, 0, 0.5, "Align horizontal centers (⌥H)"),
(I::AlignRight, 0, 1.0, "Align right (⌥D)"),
(I::AlignTop, 1, 0.0, "Align top (⌥W)"),
(I::AlignVCenter, 1, 0.5, "Align vertical centers (⌥V)"),
(I::AlignBottom, 1, 1.0, "Align bottom (⌥S)"),
] {
if icon_btn(ui, icon, false, tip) {
ed.align(axis, to);
}
let unit = (ui.available_width() - ui.spacing().item_spacing.x) / 8.0;
let cells: Vec<_> = aligns
.iter()
.map(|a| (Cell::Icon(a.0), false, a.3))
.collect();
if let Some(i) = segmented(ui, &cells, unit * 6.0) {
ed.align(aligns[i].1, aligns[i].2);
}
ui.add_space(8.0);
if icon_btn(
ui,
I::DistributeH,
false,
"Distribute horizontal spacing (⌥⇧H)",
) {
ed.distribute(0);
}
if icon_btn(
ui,
I::DistributeV,
false,
"Distribute vertical spacing (⌥⇧V)",
) {
ed.distribute(1);
let dist = [
(
Cell::Icon(I::DistributeH),
false,
"Distribute horizontal spacing (⌥⇧H)",
),
(
Cell::Icon(I::DistributeV),
false,
"Distribute vertical spacing (⌥⇧V)",
),
];
if let Some(i) = segmented(ui, &dist, unit * 2.0) {
ed.distribute(i);
}
});
}
@@ -708,23 +973,26 @@ fn geometry(ui: &mut Ui, ed: &mut Editor, ids: &[NodeId], first: &Node) {
if ids.len() == 1 {
let r = first.rect;
ui.horizontal(|ui| {
if let Some(v) = num_field(ui, ed, "X ", r.x, 1.0, f64::MIN..=f64::MAX, 92.0) {
let w = half(ui);
if let Some(v) = num_field(ui, ed, "X ", r.x, 1.0, f64::MIN..=f64::MAX, w) {
apply(ed, "x", ids, |n| n.rect.x = v);
}
if let Some(v) = num_field(ui, ed, "Y ", r.y, 1.0, f64::MIN..=f64::MAX, 92.0) {
if let Some(v) = num_field(ui, ed, "Y ", r.y, 1.0, f64::MIN..=f64::MAX, w) {
apply(ed, "y", ids, |n| n.rect.y = v);
}
});
ui.horizontal(|ui| {
if let Some(v) = num_field(ui, ed, "W ", r.w, 1.0, 0.0..=1e6, 92.0) {
let w = half(ui);
if let Some(v) = num_field(ui, ed, "W ", r.w, 1.0, 0.0..=1e6, w) {
resize(ed, ids[0], v2(v, r.h));
}
let text = matches!(first.kind, NodeKind::Text { .. });
ui.add_enabled_ui(!text, |ui| {
if let Some(v) = num_field(ui, ed, "H ", r.h, 1.0, 0.0..=1e6, 92.0) {
resize(ed, ids[0], v2(r.w, v));
}
});
// Disabling the Ui itself (not `add_enabled_ui`, whose scope shifts the cell).
if matches!(first.kind, NodeKind::Text { .. }) {
ui.disable();
}
if let Some(v) = num_field(ui, ed, "H ", r.h, 1.0, 0.0..=1e6, w) {
resize(ed, ids[0], v2(r.w, v));
}
});
}
// Shape parameters.
@@ -918,15 +1186,13 @@ fn auto_layout(ui: &mut Ui, ed: &mut Editor, ids: &[NodeId], first: &Node) {
ui.label(title_text("Auto layout"));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if first.layout.is_some() {
if ui
.small_button("−")
if crate::theme::icon_button(ui, crate::theme::Icon::Minus, 24.0, false)
.on_hover_text("Remove auto layout (⌥⇧A)")
.clicked()
{
ed.remove_auto_layout();
}
} else if ui
.small_button("+")
} else if crate::theme::icon_button(ui, crate::theme::Icon::Plus, 24.0, false)
.on_hover_text("Add auto layout (⇧A)")
.clicked()
{
@@ -1015,44 +1281,65 @@ fn text_section(ui: &mut Ui, ed: &mut Editor, ids: &[NodeId], first: &Node) {
let cs = ed.current_char_style();
ui.horizontal(|ui| {
let size = cs.map_or(*font_size, |c| c.size);
if let Some(v) = num_field(ui, ed, "Size ", size, 0.5, 1.0..=1000.0, 80.0) {
let w = (ui.available_width() - 3.0 * ui.spacing().item_spacing.x) / 4.0;
if let Some(v) = num_field(ui, ed, "Size ", size, 0.5, 1.0..=1000.0, w * 1.5) {
ed.style_text("font size", |s| s.size = Some(v));
}
let c = cs.map(|c| c.color).unwrap_or(Color::INK);
if let Some(nc) = color_button(ui, ed, c) {
ed.style_text("color", |s| s.color = Some(nc));
}
ui.add_space(4.0);
if icon_toggle(ui, "B", cs.is_some_and(|c| c.bold), "Bold (⌘B)") {
ed.toggle_bold();
}
if icon_toggle(ui, "I", cs.is_some_and(|c| c.italic), "Italic (⌘I)") {
ed.toggle_italic();
}
if icon_toggle(ui, "U", cs.is_some_and(|c| c.underline), "Underline (⌘U)") {
ed.toggle_underline();
}
if icon_toggle(ui, "S", cs.is_some_and(|c| c.strike), "Strikethrough (⌘⇧X)") {
ed.toggle_strike();
let styles = [
(Cell::Text("B"), cs.is_some_and(|c| c.bold), "Bold (⌘B)"),
(Cell::Text("I"), cs.is_some_and(|c| c.italic), "Italic (⌘I)"),
(
Cell::Text("U"),
cs.is_some_and(|c| c.underline),
"Underline (⌘U)",
),
(
Cell::Text("S"),
cs.is_some_and(|c| c.strike),
"Strikethrough (⌘⇧X)",
),
];
match segmented(ui, &styles, ui.available_width()) {
Some(0) => ed.toggle_bold(),
Some(1) => ed.toggle_italic(),
Some(2) => ed.toggle_underline(),
Some(3) => ed.toggle_strike(),
_ => {}
}
});
ui.horizontal(|ui| {
for (a, ic) in [
(TextAlign::Left, theme::Icon::AlignLeft),
(TextAlign::Center, theme::Icon::AlignHCenter),
(TextAlign::Right, theme::Icon::AlignRight),
] {
if icon_btn(ui, ic, *align == a, "Text alignment") {
apply(ed, "align", ids, |n| {
if let NodeKind::Text { align, .. } = &mut n.kind {
*align = a;
}
});
}
let w = (ui.available_width() - 2.0 * ui.spacing().item_spacing.x) / 5.0;
let aligns = [TextAlign::Left, TextAlign::Center, TextAlign::Right];
let cells = [
(
Cell::Icon(theme::Icon::AlignLeft),
*align == aligns[0],
"Align text left",
),
(
Cell::Icon(theme::Icon::AlignHCenter),
*align == aligns[1],
"Align text center",
),
(
Cell::Icon(theme::Icon::AlignRight),
*align == aligns[2],
"Align text right",
),
];
if let Some(i) = segmented(ui, &cells, w * 2.0) {
apply(ed, "align", ids, |n| {
if let NodeKind::Text { align, .. } = &mut n.kind {
*align = aligns[i];
}
});
}
ui.add_space(6.0);
let lh = first.text_style.line_height.unwrap_or(0.0) * 100.0;
if let Some(v) = num_field(ui, ed, "LH ", lh, 1.0, 0.0..=1000.0, 64.0) {
if let Some(v) = num_field(ui, ed, "LH ", lh, 1.0, 0.0..=1000.0, w) {
apply(ed, "line height", ids, |n| {
n.text_style.line_height = (v > 0.0).then_some(v / 100.0)
});
@@ -1064,7 +1351,7 @@ fn text_section(ui: &mut Ui, ed: &mut Editor, ids: &[NodeId], first: &Node) {
first.text_style.letter_spacing,
0.1,
-50.0..=200.0,
60.0,
w,
) {
apply(ed, "letter spacing", ids, |n| {
n.text_style.letter_spacing = v
@@ -1099,7 +1386,10 @@ fn fills(ui: &mut Ui, ed: &mut Editor, ids: &[NodeId], first: &Node) {
ui.horizontal(|ui| {
ui.label(title_text("Fill"));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.small_button("+").on_hover_text("Add fill").clicked() {
if crate::theme::icon_button(ui, crate::theme::Icon::Plus, 24.0, false)
.on_hover_text("Add fill")
.clicked()
{
let c = fills
.last()
.map_or(Color::rgb(0xd9, 0xd9, 0xd9), |p| p.preview_color());
@@ -1147,13 +1437,10 @@ fn fill_row(ui: &mut Ui, ed: &mut Editor, ids: &[NodeId], i: usize, p: &Paint) {
}
match p {
Paint::Solid { color, var } => {
if let Some(c) = color_button(ui, ed, *color) {
let w = ui.available_width() - 32.0;
if let Some(c) = color_field(ui, ed, *color, var.as_deref(), w) {
set(ed, Paint::solid(c));
}
let label = var
.as_ref()
.map_or_else(|| color.to_hex(), |v| format!("--{v}"));
ui.label(RichText::new(label).monospace().weak());
}
Paint::Image {
mode,
@@ -1176,14 +1463,17 @@ fn fill_row(ui: &mut Ui, ed: &mut Editor, ids: &[NodeId], i: usize, p: &Paint) {
},
);
}
if ui.small_button("Replace…").clicked() {
if crate::theme::small_button(ui, "Replace…").clicked() {
ed.requests.push(AppRequest::ImageFill(ids.to_vec(), i));
}
}
_ => {}
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.small_button("−").on_hover_text("Remove fill").clicked() {
if crate::theme::icon_button(ui, crate::theme::Icon::Minus, 24.0, false)
.on_hover_text("Remove fill")
.clicked()
{
apply(ed, "fills", ids, |n| {
if i < n.style.fills.len() {
n.style.fills.remove(i);
@@ -1230,7 +1520,10 @@ fn fill_row(ui: &mut Ui, ed: &mut Editor, ids: &[NodeId], i: usize, p: &Paint) {
set(ed, rebuild(ns, *from, *to));
}
ui.label(RichText::new("%").weak());
if stops.len() > 2 && ui.small_button("−").clicked() {
if stops.len() > 2
&& crate::theme::icon_button(ui, crate::theme::Icon::Minus, 24.0, false)
.clicked()
{
let mut ns = stops.clone();
ns.remove(si);
set(ed, rebuild(ns, *from, *to));
@@ -1239,7 +1532,7 @@ fn fill_row(ui: &mut Ui, ed: &mut Editor, ids: &[NodeId], i: usize, p: &Paint) {
}
ui.horizontal(|ui| {
ui.add_space(LABEL_W * 0.5);
if ui.small_button("+ stop").clicked() {
if crate::theme::small_button(ui, "+ stop").clicked() {
let mut ns = stops.clone();
let c = ns.last().map_or(Color::BLACK, |s| s.color);
ns.push(Stop { pos: 0.5, color: c });
@@ -1268,20 +1561,23 @@ fn stroke(ui: &mut Ui, ed: &mut Editor, ids: &[NodeId], first: &Node) {
ui.label(title_text("Stroke"));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if s.stroke.is_some() {
if ui.small_button("−").clicked() {
if crate::theme::icon_button(ui, crate::theme::Icon::Minus, 24.0, false).clicked() {
apply(ed, "stroke", ids, |n| n.style.stroke = None);
}
} else if ui.small_button("+").clicked() {
} else if crate::theme::icon_button(ui, crate::theme::Icon::Plus, 24.0, false).clicked()
{
apply(ed, "stroke", ids, |n| n.style.stroke = Some(Color::INK));
}
});
});
let Some(c) = s.stroke else { return };
let full = ui.available_width();
if let Some(nc) = color_field(ui, ed, c, None, full) {
apply(ed, "stroke", ids, |n| n.style.stroke = Some(nc));
}
ui.horizontal(|ui| {
if let Some(nc) = color_button(ui, ed, c) {
apply(ed, "stroke", ids, |n| n.style.stroke = Some(nc));
}
if let Some(v) = num_field(ui, ed, "W ", s.stroke_width, 0.1, 0.0..=500.0, 64.0) {
let w = half(ui);
if let Some(v) = num_field(ui, ed, "W ", s.stroke_width, 0.1, 0.0..=500.0, w) {
apply(ed, "stroke width", ids, |n| n.style.stroke_width = v);
}
let al = [
@@ -1289,18 +1585,19 @@ fn stroke(ui: &mut Ui, ed: &mut Editor, ids: &[NodeId], first: &Node) {
(StrokeAlign::Center, "Center"),
(StrokeAlign::Outside, "Outside"),
];
if let Some(v) = combo(ui, "salign", s.stroke_align, &al, 64.0) {
if let Some(v) = combo(ui, "salign", s.stroke_align, &al, w) {
apply(ed, "stroke align", ids, |n| n.style.stroke_align = v);
}
});
ui.horizontal(|ui| {
let w = third(ui);
let dash_kind = match s.dash.as_slice() {
[] => 0u8,
[a, _] if *a <= 2.0 => 2,
_ => 1,
};
let dashes = [(0u8, "Solid"), (1, "Dashed"), (2, "Dotted")];
if let Some(k) = combo(ui, "dash", dash_kind, &dashes, 64.0) {
if let Some(k) = combo(ui, "dash", dash_kind, &dashes, w) {
let w = s.stroke_width.max(1.0);
apply(ed, "dash", ids, |n| {
n.style.dash = match k {
@@ -1318,7 +1615,7 @@ fn stroke(ui: &mut Ui, ed: &mut Editor, ids: &[NodeId], first: &Node) {
(Cap::Round, "Round"),
(Cap::Square, "Square"),
];
if let Some(v) = combo(ui, "cap", s.cap, &caps, 60.0) {
if let Some(v) = combo(ui, "cap", s.cap, &caps, w) {
apply(ed, "cap", ids, |n| n.style.cap = v);
}
let joins = [
@@ -1326,7 +1623,7 @@ fn stroke(ui: &mut Ui, ed: &mut Editor, ids: &[NodeId], first: &Node) {
(Join::Round, "Round"),
(Join::Bevel, "Bevel"),
];
if let Some(v) = combo(ui, "join", s.join, &joins, 60.0) {
if let Some(v) = combo(ui, "join", s.join, &joins, w) {
apply(ed, "join", ids, |n| n.style.join = v);
}
});
@@ -1335,21 +1632,14 @@ fn stroke(ui: &mut Ui, ed: &mut Editor, ids: &[NodeId], first: &Node) {
fn layer(ui: &mut Ui, ed: &mut Editor, ids: &[NodeId], first: &Node) {
ui.label(title_text("Layer"));
ui.horizontal(|ui| {
let w = half(ui);
let opts: Vec<(BlendMode, &str)> = BlendMode::ALL.iter().map(|b| (*b, b.label())).collect();
if let Some(v) = combo(ui, "blend", first.style.blend, &opts, 96.0) {
if let Some(v) = combo(ui, "blend", first.style.blend, &opts, w) {
apply(ed, "blend", ids, |n| n.style.blend = v);
}
let mut op = first.style.opacity * 100.0;
let r = ui.add_sized(
egui::vec2(70.0, 20.0),
egui::DragValue::new(&mut op)
.range(0.0..=100.0)
.suffix("%")
.speed(0.5),
);
if r.changed() {
apply(ed, "opacity", ids, |n| n.style.opacity = op / 100.0);
let op = first.style.opacity * 100.0;
if let Some(v) = field(ui, ed, "", "%", op, 0.5, 0.0..=100.0, w) {
apply(ed, "opacity", ids, |n| n.style.opacity = v / 100.0);
}
seal_on_release(ed, &r);
});
}
+1
View File
@@ -105,6 +105,7 @@ pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
.id_salt("library")
.auto_shrink(false)
.show(ui, |ui| {
crate::theme::figma_fields(ui);
for row in items.chunks(cols) {
ui.horizontal_top(|ui| {
ui.spacing_mut().item_spacing.x = 6.0;
+70 -8
View File
@@ -1,6 +1,7 @@
//! egui chrome: menu bar, layers panel, design (properties) panel, floating toolbar, and the
//! projects home screen.
pub mod agent;
pub mod design;
pub mod home;
pub mod inspector;
@@ -72,6 +73,11 @@ pub struct UiState {
pub variable_draft: String,
/// The last tool an agent ran and when, for the toolbar's activity hint.
pub agent: Option<(String, std::time::Instant)>,
/// The browser's remote-agent link (Agent menu); None everywhere but the web shell.
pub agent_link: Option<agent::AgentUi>,
/// A message shown over the home screen and the canvas (text, is_error); the shell that
/// sets it also clears it. Only the web shell uses it: native shows errors in dialogs.
pub toast: Option<(String, bool)>,
}
/// Where a dragged layer lands relative to a row.
@@ -118,6 +124,12 @@ pub fn draw(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) -> egui::Rect {
.frame(egui::Frame::NONE.fill(p.window))
.show(ui, |ui| title_bar(ui, ed, st));
}
if let Some(a) = &mut st.agent_link {
a.window(ui.ctx());
}
if let Some((text, error)) = &st.toast {
toast(ui.ctx(), text, *error);
}
if let Some(hv) = &mut st.home {
home::draw(ui, hv, st.native_chrome, st.translucent_sidebar);
return egui::Rect::NOTHING;
@@ -224,8 +236,10 @@ fn top_bar(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
let p = theme::pal(ui);
ui.horizontal_centered(|ui| {
if !st.native_chrome && !st.custom_titlebar {
menu_bar(ui, ed);
ui.add_space(8.0);
ui.scope(|ui| menu_bar(ui, ed, &mut st.agent_link));
ui.add_space(6.0);
ui.separator();
ui.add_space(6.0);
}
if theme::icon_button(ui, theme::Icon::Grid, 26.0, false)
.on_hover_text("All projects")
@@ -266,6 +280,9 @@ fn top_bar(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.spacing_mut().item_spacing.x = 2.0;
if let Some(a) = &st.agent_link {
a.dot(ui);
}
if theme::icon_button(ui, theme::Icon::Question, 26.0, st.show_help)
.on_hover_text("Keyboard shortcuts")
.clicked()
@@ -309,16 +326,36 @@ fn top_bar(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
/// egui menus for platforms without a native menu bar (inline, so the toolbar row keeps
/// the title and zoom controls).
fn menu_bar(ui: &mut Ui, ed: &mut Editor) {
fn menu_bar(ui: &mut Ui, ed: &mut Editor, agent: &mut Option<agent::AgentUi>) {
flat_menus(ui);
for (title, entries) in shortcuts::menus() {
ui.menu_button(title, |ui| {
menu_entries(ui, ed, &entries);
});
}
if let Some(a) = agent {
a.menu(ui);
}
theme_menu(ui);
}
/// Menu titles read as plain text: no fill or border at rest, a tint on hover/press.
fn flat_menus(ui: &mut Ui) {
let p = theme::pal(ui);
ui.spacing_mut().button_padding = egui::vec2(8.0, 4.0);
ui.spacing_mut().item_spacing.x = 0.0;
let w = &mut ui.visuals_mut().widgets;
w.inactive.weak_bg_fill = Color32::TRANSPARENT;
w.hovered.weak_bg_fill = p.hover;
w.active.weak_bg_fill = p.pressed;
w.open.weak_bg_fill = p.pressed;
for s in [&mut w.inactive, &mut w.hovered, &mut w.active, &mut w.open] {
s.bg_stroke = egui::Stroke::NONE;
}
}
fn theme_menu(ui: &mut Ui) {
flat_menus(ui);
ui.menu_button("Theme", |ui| {
for c in theme::Choice::ALL {
if ui
@@ -351,7 +388,7 @@ fn title_bar(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
if st.home.is_some() {
theme_menu(ui);
} else {
menu_bar(ui, ed);
menu_bar(ui, ed, &mut st.agent_link);
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.spacing_mut().item_spacing.x = 0.0;
@@ -360,10 +397,8 @@ fn title_bar(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
(WinCmd::ToggleMaximize, p.hover),
(WinCmd::Minimize, p.hover),
] {
let (r, resp) = ui.allocate_exact_size(
egui::vec2(WIN_BTN_W, TITLEBAR_H),
egui::Sense::click(),
);
let (r, resp) =
ui.allocate_exact_size(egui::vec2(WIN_BTN_W, TITLEBAR_H), egui::Sense::click());
let hot = resp.hovered();
if hot {
ui.painter().rect_filled(r, 0.0, hover);
@@ -404,6 +439,7 @@ fn title_bar(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
}
fn layers(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
theme::figma_fields(ui);
pages::panel(ui, ed, st);
ui.add_space(4.0);
let r = ui.available_rect_before_wrap();
@@ -901,6 +937,32 @@ fn toolbar(ctx: &egui::Context, ed: &mut Editor, canvas: egui::Rect) {
});
}
/// Colour of an error toast's background.
const TOAST_ERROR: Color32 = Color32::from_rgb(0xb4, 0x2b, 0x2b);
fn toast(ctx: &egui::Context, text: &str, error: bool) {
egui::Area::new("toast".into())
.order(egui::Order::Tooltip)
.anchor(Align2::CENTER_TOP, egui::vec2(0.0, 44.0))
.interactable(false)
.show(ctx, |ui| {
let p = theme::pal(ui);
let (fill, ink) = if error {
(TOAST_ERROR, Color32::WHITE)
} else {
(p.popover, p.secondary)
};
egui::Frame::popup(ui.style())
.fill(fill)
.corner_radius(8.0)
.inner_margin(egui::Margin::symmetric(14, 8))
.show(ui, |ui| {
ui.set_max_width(520.0);
ui.label(RichText::new(text).color(ink));
});
});
}
fn status(ctx: &egui::Context, ed: &Editor, canvas: egui::Rect) {
if ed.status.is_empty() {
return;
+372
View File
@@ -0,0 +1,372 @@
//! In-memory tree persisted to IndexedDB (browser).
use std::cell::RefCell;
use std::collections::{BTreeMap, BTreeSet};
use std::ffi::OsString;
use std::io::{Error, ErrorKind, Result};
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use wasm_bindgen::prelude::*;
#[wasm_bindgen(inline_js = r#"
let dbp;
function db() {
return (dbp ??= new Promise((res, rej) => {
const r = indexedDB.open('papercraft', 1);
r.onupgradeneeded = () => r.result.createObjectStore('files');
r.onsuccess = () => res(r.result);
r.onerror = () => rej(r.error);
}));
}
function store(d, mode) { return d.transaction('files', mode).objectStore('files'); }
export async function idb_load_all() {
const d = await db();
return new Promise((res, rej) => {
const out = [];
const rq = store(d, 'readonly').openCursor();
rq.onsuccess = () => {
const c = rq.result;
if (c) { out.push([c.key, c.value]); c.continue(); } else res(out);
};
rq.onerror = () => rej(rq.error);
});
}
export async function idb_put(key, bytes) {
const d = await db();
return new Promise((res, rej) => {
const rq = store(d, 'readwrite').put(bytes, key);
rq.onsuccess = () => res();
rq.onerror = () => rej(rq.error);
});
}
export async function idb_delete(key) {
const d = await db();
return new Promise((res, rej) => {
const rq = store(d, 'readwrite').delete(key);
rq.onsuccess = () => res();
rq.onerror = () => rej(rq.error);
});
}
"#)]
extern "C" {
#[wasm_bindgen(catch)]
async fn idb_load_all() -> std::result::Result<JsValue, JsValue>;
fn idb_put(key: &str, bytes: &[u8]) -> js_sys::Promise;
fn idb_delete(key: &str) -> js_sys::Promise;
}
#[derive(Default)]
struct Tree {
files: BTreeMap<PathBuf, Vec<u8>>,
dirs: BTreeSet<PathBuf>,
}
thread_local! {
static TREE: RefCell<Tree> = RefCell::new(Tree::default());
}
fn key(p: &Path) -> String {
p.to_string_lossy().into_owned()
}
thread_local! {
/// The latest IndexedDB write that failed (quota, private mode, blocked database). The files
/// stay in memory, so the shell shows this and the user can still download a backup.
static PERSIST_ERROR: RefCell<Option<String>> = const { RefCell::new(None) };
}
/// Take the latest failed IndexedDB write, if any.
pub fn take_persist_error() -> Option<String> {
PERSIST_ERROR.with(|e| e.borrow_mut().take())
}
/// A failed write is waiting to be shown.
pub fn has_persist_error() -> bool {
PERSIST_ERROR.with(|e| e.borrow().is_some())
}
fn persist(p: &Path, what: &'static str, promise: js_sys::Promise) {
let name = p.display().to_string();
wasm_bindgen_futures::spawn_local(async move {
if let Err(e) = wasm_bindgen_futures::JsFuture::from(promise).await {
let why = e
.as_string()
.or_else(|| {
js_sys::Reflect::get(&e, &"message".into())
.ok()?
.as_string()
})
.unwrap_or_else(|| "storage error".into());
PERSIST_ERROR.with(|slot| {
*slot.borrow_mut() =
Some(format!("Could not {what} {name} in browser storage: {why}"));
});
}
});
}
fn persist_put(p: &Path, data: &[u8]) {
persist(p, "save", idb_put(&key(p), data));
}
fn persist_delete(p: &Path) {
persist(p, "delete", idb_delete(&key(p)));
}
fn not_found(p: &Path) -> Error {
Error::new(ErrorKind::NotFound, format!("{}: not found", p.display()))
}
/// Read everything back from IndexedDB. Call once before the app starts.
pub async fn load() -> std::result::Result<(), String> {
let rows = idb_load_all().await.map_err(|e| format!("{e:?}"))?;
TREE.with(|t| {
let mut t = t.borrow_mut();
for row in js_sys::Array::from(&rows).iter() {
let row = js_sys::Array::from(&row);
let (Some(k), v) = (row.get(0).as_string(), row.get(1)) else {
continue;
};
let p = PathBuf::from(k);
for a in p.ancestors().skip(1) {
t.dirs.insert(a.to_path_buf());
}
t.files.insert(p, js_sys::Uint8Array::new(&v).to_vec());
}
});
Ok(())
}
pub fn is_file(p: &Path) -> bool {
TREE.with(|t| t.borrow().files.contains_key(p))
}
pub fn is_dir(p: &Path) -> bool {
TREE.with(|t| {
let t = t.borrow();
t.dirs.contains(p) || t.files.keys().any(|f| f.starts_with(p) && f != p)
})
}
pub fn exists(p: &Path) -> bool {
is_file(p) || is_dir(p)
}
pub fn create_dir_all(p: impl AsRef<Path>) -> Result<()> {
TREE.with(|t| {
let mut t = t.borrow_mut();
for a in p.as_ref().ancestors() {
if !a.as_os_str().is_empty() {
t.dirs.insert(a.to_path_buf());
}
}
});
Ok(())
}
pub fn read(p: impl AsRef<Path>) -> Result<Vec<u8>> {
let p = p.as_ref();
TREE.with(|t| t.borrow().files.get(p).cloned())
.ok_or_else(|| not_found(p))
}
pub fn read_to_string(p: impl AsRef<Path>) -> Result<String> {
String::from_utf8(read(p)?).map_err(|e| Error::new(ErrorKind::InvalidData, e))
}
pub fn write(p: impl AsRef<Path>, data: impl AsRef<[u8]>) -> Result<()> {
let (p, data) = (p.as_ref(), data.as_ref());
if let Some(dir) = p.parent() {
create_dir_all(dir)?;
}
TREE.with(|t| t.borrow_mut().files.insert(p.to_path_buf(), data.to_vec()));
persist_put(p, data);
Ok(())
}
pub fn copy(from: impl AsRef<Path>, to: impl AsRef<Path>) -> Result<u64> {
let data = read(from)?;
write(to, &data)?;
Ok(data.len() as u64)
}
pub fn remove_file(p: impl AsRef<Path>) -> Result<()> {
let p = p.as_ref();
TREE.with(|t| t.borrow_mut().files.remove(p))
.ok_or_else(|| not_found(p))?;
persist_delete(p);
Ok(())
}
pub fn remove_dir_all(p: impl AsRef<Path>) -> Result<()> {
let p = p.as_ref();
if !exists(p) {
return Err(not_found(p));
}
let gone: Vec<PathBuf> = TREE.with(|t| {
let mut t = t.borrow_mut();
t.dirs.retain(|d| !d.starts_with(p));
let gone: Vec<PathBuf> = t
.files
.keys()
.filter(|f| f.starts_with(p))
.cloned()
.collect();
for f in &gone {
t.files.remove(f);
}
gone
});
for f in gone {
persist_delete(&f);
}
Ok(())
}
/// Moves a file, or a directory with everything under it.
pub fn rename(from: impl AsRef<Path>, to: impl AsRef<Path>) -> Result<()> {
let (from, to) = (from.as_ref(), to.as_ref());
if !exists(from) {
return Err(not_found(from));
}
if let Some(dir) = to.parent() {
create_dir_all(dir)?;
}
let moved: Vec<(PathBuf, PathBuf, Vec<u8>)> = TREE.with(|t| {
let mut t = t.borrow_mut();
let dirs: Vec<PathBuf> = t
.dirs
.iter()
.filter(|d| d.starts_with(from))
.cloned()
.collect();
for d in dirs {
t.dirs.remove(&d);
if let Ok(rest) = d.strip_prefix(from) {
t.dirs.insert(to.join(rest));
}
}
let files: Vec<PathBuf> = t
.files
.keys()
.filter(|f| f.starts_with(from))
.cloned()
.collect();
files
.into_iter()
.filter_map(|f| {
let data = t.files.remove(&f)?;
let dest = to.join(f.strip_prefix(from).ok()?);
t.files.insert(dest.clone(), data.clone());
Some((f, dest, data))
})
.collect()
});
for (old, new, data) in moved {
persist_put(&new, &data);
persist_delete(&old);
}
Ok(())
}
pub struct Metadata {
len: u64,
}
impl Metadata {
pub fn len(&self) -> u64 {
self.len
}
pub fn modified(&self) -> Result<SystemTime> {
Err(Error::new(
ErrorKind::Unsupported,
"no mtime in the browser",
))
}
}
pub fn metadata(p: impl AsRef<Path>) -> Result<Metadata> {
let p = p.as_ref();
match TREE.with(|t| t.borrow().files.get(p).map(|f| f.len() as u64)) {
Some(len) => Ok(Metadata { len }),
None if is_dir(p) => Ok(Metadata { len: 0 }),
None => Err(not_found(p)),
}
}
#[derive(Clone, Copy)]
pub struct FileType {
dir: bool,
}
impl FileType {
pub fn is_dir(&self) -> bool {
self.dir
}
pub fn is_file(&self) -> bool {
!self.dir
}
}
pub struct DirEntry {
path: PathBuf,
dir: bool,
}
impl DirEntry {
pub fn path(&self) -> PathBuf {
self.path.clone()
}
pub fn file_name(&self) -> OsString {
self.path.file_name().unwrap_or_default().to_owned()
}
pub fn file_type(&self) -> Result<FileType> {
Ok(FileType { dir: self.dir })
}
pub fn metadata(&self) -> Result<Metadata> {
metadata(&self.path)
}
}
pub struct ReadDir(std::vec::IntoIter<DirEntry>);
impl Iterator for ReadDir {
type Item = Result<DirEntry>;
fn next(&mut self) -> Option<Self::Item> {
self.0.next().map(Ok)
}
}
pub fn read_dir(p: impl AsRef<Path>) -> Result<ReadDir> {
let p = p.as_ref();
if !is_dir(p) {
return Err(not_found(p));
}
let mut seen: BTreeMap<PathBuf, bool> = BTreeMap::new();
TREE.with(|t| {
let t = t.borrow();
for d in &t.dirs {
if d.parent() == Some(p) {
seen.insert(d.clone(), true);
}
}
for f in t.files.keys() {
let Ok(rest) = f.strip_prefix(p) else {
continue;
};
let mut parts = rest.components();
let Some(first) = parts.next() else { continue };
let child = p.join(first);
let is_dir = parts.next().is_some();
seen.entry(child)
.and_modify(|d| *d |= is_dir)
.or_insert(is_dir);
}
});
Ok(ReadDir(
seen.into_iter()
.map(|(path, dir)| DirEntry { path, dir })
.collect::<Vec<_>>()
.into_iter(),
))
}
+26
View File
@@ -0,0 +1,26 @@
//! The file system the project store runs on.
//!
//! Native builds use `std::fs` directly. The browser has none, so there a whole tree lives in
//! memory and every change is mirrored into IndexedDB (loaded back by [`load`] at startup).
//! Only the calls the app makes are provided.
#[cfg(not(target_arch = "wasm32"))]
pub use std::fs::*;
#[cfg(not(target_arch = "wasm32"))]
pub fn is_file(p: &std::path::Path) -> bool {
p.is_file()
}
#[cfg(not(target_arch = "wasm32"))]
pub fn is_dir(p: &std::path::Path) -> bool {
p.is_dir()
}
#[cfg(not(target_arch = "wasm32"))]
pub fn exists(p: &std::path::Path) -> bool {
p.exists()
}
#[cfg(target_arch = "wasm32")]
mod mem;
#[cfg(target_arch = "wasm32")]
pub use mem::*;
+1037
View File
File diff suppressed because it is too large Load Diff
+173
View File
@@ -0,0 +1,173 @@
//! Small browser helpers: file download and pick, local storage, and the clipboard mirror.
//! Raw JS (like `vfs/mem.rs` does for IndexedDB) where web-sys would need many feature flags
//! or unstable APIs (`ClipboardItem`).
use crate::io::clipboard::Provider;
use std::cell::RefCell;
use std::rc::Rc;
use wasm_bindgen::prelude::*;
#[wasm_bindgen(inline_js = r#"
export function download(name, bytes, mime) {
const url = URL.createObjectURL(new Blob([bytes], { type: mime }));
const a = document.createElement('a');
a.href = url;
a.download = name;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 10000);
}
export async function read_files(files) {
const out = [];
for (const f of files) out.push([f.name, new Uint8Array(await f.arrayBuffer())]);
return out;
}
export function pick_files(accept, multiple) {
return new Promise((res) => {
const i = document.createElement('input');
i.type = 'file';
i.accept = accept;
i.multiple = multiple;
i.style.display = 'none';
i.onchange = () => { res(read_files(i.files)); i.remove(); };
i.oncancel = () => { res([]); i.remove(); };
document.body.appendChild(i);
i.click();
});
}
export async function copy_png(bytes) {
await navigator.clipboard.write([
new ClipboardItem({ 'image/png': new Blob([bytes], { type: 'image/png' }) }),
]);
}
export function relay_global() {
return typeof window.PAPERCRAFT_RELAY === 'string' ? window.PAPERCRAFT_RELAY : '';
}
"#)]
extern "C" {
fn download(name: &str, bytes: &[u8], mime: &str);
fn read_files(files: &JsValue) -> js_sys::Promise;
fn pick_files(accept: &str, multiple: bool) -> js_sys::Promise;
#[wasm_bindgen(catch)]
async fn copy_png(bytes: &[u8]) -> Result<JsValue, JsValue>;
fn relay_global() -> String;
}
/// `window.PAPERCRAFT_RELAY`, set by the page that hosts the app.
pub fn relay_override() -> Option<String> {
Some(relay_global()).filter(|s| !s.is_empty())
}
/// A file the user chose or dropped.
pub struct Picked {
pub name: String,
pub bytes: Vec<u8>,
}
fn picked(rows: JsValue) -> Vec<Picked> {
js_sys::Array::from(&rows)
.iter()
.filter_map(|row| {
let row = js_sys::Array::from(&row);
Some(Picked {
name: row.get(0).as_string()?,
bytes: js_sys::Uint8Array::new(&row.get(1)).to_vec(),
})
})
.collect()
}
/// Ask for files with a hidden `<input type=file>`. Must start from a user gesture; resolves
/// to nothing when the dialog is cancelled.
pub async fn pick(accept: &str, multiple: bool) -> Result<Vec<Picked>, String> {
wasm_bindgen_futures::JsFuture::from(pick_files(accept, multiple))
.await
.map(picked)
.map_err(|e| format!("could not read the chosen file: {e:?}"))
}
/// Read the contents of a DOM `FileList` (dropped or pasted files).
pub async fn read_list(files: &web_sys::FileList) -> Result<Vec<Picked>, String> {
wasm_bindgen_futures::JsFuture::from(read_files(files))
.await
.map(picked)
.map_err(|e| format!("could not read the dropped file: {e:?}"))
}
/// Offer `bytes` as a browser download.
pub fn save_as(name: &str, bytes: &[u8], mime: &str) {
download(name, bytes, mime);
}
pub fn mime_for(name: &str) -> &'static str {
match crate::io::import::extension(name).as_str() {
"png" => "image/png",
"svg" => "image/svg+xml",
"pdf" => "application/pdf",
"zip" => "application/zip",
_ => "application/octet-stream",
}
}
/// Put a PNG on the system clipboard (needs a user gesture and permission; reports failure).
pub async fn copy_png_to_clipboard(png: Vec<u8>) -> Result<(), String> {
copy_png(&png)
.await
.map(|_| ())
.map_err(|e| format!("the browser refused the clipboard write: {e:?}"))
}
/// A page-lifetime `localStorage` value.
pub fn local_get(key: &str) -> Option<String> {
super::window().local_storage().ok()??.get_item(key).ok()?
}
pub fn local_set(key: &str, value: &str) {
if let Some(Ok(Some(s))) = Some(super::window().local_storage()) {
let _ = s.set_item(key, value);
}
}
/// What the last copy or paste carried. The editor's clipboard provider is synchronous but the
/// browser's is not, so the editor reads and writes this mirror; the shell fills it from DOM
/// `paste` events and pushes copies out to `navigator.clipboard`.
#[derive(Default)]
pub struct Mirror {
pub text: Option<String>,
pub image: Option<image::RgbaImage>,
}
pub type SharedMirror = Rc<RefCell<Mirror>>;
pub struct WebClipboard(pub SharedMirror);
impl Provider for WebClipboard {
fn read_text(&mut self) -> Option<String> {
self.0.borrow().text.clone()
}
fn write_text(&mut self, s: &str) {
let mut m = self.0.borrow_mut();
m.text = Some(s.into());
m.image = None;
// Fire and forget: failure only means other apps do not see the copy.
let _ = super::window().navigator().clipboard().write_text(s);
}
fn read_image(&mut self) -> Option<image::RgbaImage> {
self.0.borrow().image.clone()
}
fn write_image(&mut self, img: &image::RgbaImage) {
self.0.borrow_mut().image = Some(img.clone());
let Ok(png) = crate::io::export::encode_png(img) else {
return;
};
wasm_bindgen_futures::spawn_local(async move {
if let Err(e) = copy_png_to_clipboard(png).await {
log::warn!("{e}");
}
});
}
}
+349
View File
@@ -0,0 +1,349 @@
//! Files in the browser: importing (pick / drop / paste), exporting as downloads, project
//! backups and home-screen thumbnails. Everything that touches the GPU is asynchronous here
//! (see `Headless::read_async`); the finished work re-enters the shell through `Web::me`.
use super::Web;
use super::dom::{self, Picked};
use crate::geom::V2;
use crate::io::export::{self, Format, Spec};
use crate::io::file;
use crate::io::import::{self, Imported};
use crate::library::LibItem;
use crate::model::{ImageMode, Node, NodeId, Paint};
use crate::project::format::PageData;
const BACKUP_NAME: &str = "PaperCraft backup.zip";
/// Pixel scale for "Copy as PNG" (matches the desktop app).
const COPY_PNG_SCALE: f64 = 2.0;
const IMAGE_ACCEPT: &str = ".png,.jpg,.jpeg,.gif,.webp";
fn import_accept() -> String {
format!(
".{},.{},.excalidraw,.excalidrawlib,.json,.svg,.zip,{IMAGE_ACCEPT}",
file::EXTENSION,
file::LEGACY_EXTENSION
)
}
impl Web {
/// Run `f` on the shell once the future finishes (the shell is not borrowed across awaits).
pub(super) fn finish<T: 'static>(
&self,
fut: impl std::future::Future<Output = T> + 'static,
f: impl FnOnce(&mut Web, T) + 'static,
) {
let me = self.me.clone();
wasm_bindgen_futures::spawn_local(async move {
let out = fut.await;
if let Some(w) = me.upgrade() {
let mut w = w.borrow_mut();
f(&mut w, out);
w.dirty = true;
}
});
}
/// Open the file chooser and hand what was picked to `then`.
fn pick_then(
&self,
accept: String,
multiple: bool,
then: impl FnOnce(&mut Web, Vec<Picked>) + 'static,
) {
self.finish(
async move { dom::pick(&accept, multiple).await },
|w, r| match r {
Ok(files) if files.is_empty() => {}
Ok(files) => then(w, files),
Err(e) => w.error(e),
},
);
}
// ---------------------------------------------------------------- import
/// Home screen "Import…" and File > Open.
pub(super) fn request_import(&self) {
self.pick_then(import_accept(), true, |w, files| {
for f in files {
w.import_file(&f.name, &f.bytes, None);
}
});
}
/// Insert > Image.
pub(super) fn request_place_image(&self) {
self.pick_then(IMAGE_ACCEPT.into(), false, |w, files| {
for f in files {
w.import_file(&f.name, &f.bytes, None);
}
});
}
/// Image fill for fill `i` of `ids`.
pub(super) fn request_image_fill(&self, ids: Vec<NodeId>, i: usize) {
self.pick_then(IMAGE_ACCEPT.into(), false, move |w, files| {
let Some(f) = files.into_iter().next() else {
return;
};
match crate::assets::register(&f.bytes) {
Ok(asset) => {
crate::ui::inspector::apply(&mut w.ed, "fills", &ids, |n| {
let paint = Paint::Image {
asset: asset.clone(),
mode: ImageMode::Fill,
opacity: 1.0,
};
match n.style.fills.get_mut(i) {
Some(slot) => *slot = paint,
None => n.style.fills.push(paint),
}
});
}
Err(e) => w.error(format!("Could not load {}: {e}", f.name)),
}
});
}
/// Files dropped on the canvas at `screen` (canvas px), or pasted.
pub(super) fn import_files(&mut self, files: Vec<Picked>, screen: Option<egui::Pos2>) {
let at = screen.map(|p| self.ed.camera.screen_to_world(self.to_canvas(p)));
for f in files {
self.import_file(&f.name, &f.bytes, at);
}
}
fn in_project(&self) -> bool {
self.session.project.is_some() && self.st.home.is_none()
}
fn view_center(&self) -> V2 {
self.ed.camera.screen_to_world(self.ed.viewport * 0.5)
}
/// Bring one file in: SVG / Excalidraw / images go on the open page (a new project is made
/// when none is open), documents become pages or a new project, zips restore a backup.
pub(super) fn import_file(&mut self, name: &str, bytes: &[u8], at: Option<V2>) {
let imported = match import::import_bytes(name, bytes) {
Ok(i) => i,
Err(e) => return self.error(format!("Could not import {name}: {e}")),
};
match imported {
Imported::Backup => self.restore_backup(bytes),
Imported::Library(items) => self.add_to_library(items),
Imported::Pages(_pname, pages) if self.in_project() => self.add_pages(pages),
Imported::Pages(pname, pages) => self.new_project_from(&pname, pages),
Imported::Image(_) | Imported::Scene { .. } => {
if !self.in_project() {
let stem = name.rsplit_once('.').map_or(name, |(s, _)| s);
let r = self.session.create(&mut self.ed, stem);
self.enter_project(r);
if !self.in_project() {
return;
}
}
let at = at.unwrap_or_else(|| self.view_center());
match imported {
Imported::Image(b) => self.place_image(name, &b, at),
Imported::Scene { nodes, group } => self.place_scene(name, nodes, group, at),
_ => unreachable!(),
}
}
}
}
fn place_image(&mut self, name: &str, bytes: &[u8], at: V2) {
match crate::assets::register(bytes) {
Ok(id) => {
self.ed.insert_image(&id, at);
self.ed.status = format!("Placed {name}");
}
Err(e) => self.error(format!("Could not place {name}: {e}")),
}
}
fn place_scene(&mut self, name: &str, nodes: Vec<Node>, group: Option<String>, at: V2) {
use crate::io::clipboard::{bounds, center_offset, roots_of};
let (roots, world) = roots_of(&nodes);
let off = center_offset(&world, at);
let label = format!("Import {name}");
let ids = match group {
// Keep an SVG together as one group.
Some(group) => {
let b = bounds(&world).unwrap_or_default();
vec![self.ed.edit(&label, |tx| {
crate::io::svg_import::insert_group(tx, nodes, &group, b.min() + off)
})]
}
None => self
.ed
.insert_subtrees(&label, &nodes, &roots, &world, off, false),
};
self.ed.selection = ids;
self.ed.status = format!("Imported {name}");
}
fn add_pages(&mut self, pages: Vec<PageData>) {
use crate::io::clipboard::roots_of;
let n = pages.len();
for pg in pages {
let i = self.ed.add_page();
self.ed.rename_page(i, &pg.name);
let nodes: Vec<Node> = pg
.doc
.paint_order()
.into_iter()
.filter_map(|id| pg.doc.get(id).cloned())
.collect();
let (roots, world) = roots_of(&nodes);
self.ed
.insert_subtrees("Import page", &nodes, &roots, &world, V2::ZERO, false);
}
self.ed.status = format!("Imported {n} page(s)");
}
fn new_project_from(&mut self, name: &str, pages: Vec<PageData>) {
match self.session.store.create_with_pages(name, pages) {
Ok(proj) => {
let dir = proj.dir.clone();
let r = self.session.open(&mut self.ed, &dir);
self.enter_project(r);
self.ed.status = format!("Imported {name}");
}
Err(e) => self.error(format!("Could not create a project: {e}")),
}
}
fn add_to_library(&mut self, items: Vec<LibItem>) {
let n = items.len();
self.st.library.library.items.extend(items);
self.st.library.dirty = true;
self.st.show_library = true;
self.ed.status = format!("Added {n} item(s) to the library");
}
// ---------------------------------------------------------------- backup
/// "Download backup": a zip of the whole project folder.
pub(super) fn download_backup(&mut self) {
if self.session.project.is_some() {
self.save_project(true);
}
match crate::io::backup::pack(&self.session.store.root) {
Ok(zip) => {
dom::save_as(BACKUP_NAME, &zip, dom::mime_for(BACKUP_NAME));
self.ed.status = "Backup downloaded".into();
}
Err(e) => self.error(format!("Could not back up: {e}")),
}
}
fn restore_backup(&mut self, bytes: &[u8]) {
match crate::io::backup::unpack(bytes, &self.session.store.root) {
Ok(dirs) => {
self.ed.status = format!("Restored {} project(s) from the backup", dirs.len());
if self.st.home.is_some() {
self.show_home();
}
}
Err(e) => self.error(format!("Could not restore the backup: {e}")),
}
}
// ---------------------------------------------------------------- export
/// Export as a download: one file, or a zip when the spec yields several.
pub(super) fn export(&mut self, spec: Spec) {
let names: Vec<String> = self.ed.pages.iter().map(|p| p.name.clone()).collect();
let docs: Vec<_> = (0..names.len())
.map(|i| self.ed.page_doc(i).clone())
.collect();
let (active, sel) = (self.ed.active_page, self.ed.top_selection());
let project = if self.st.project_name.is_empty() {
"PaperCraft".to_string()
} else {
self.st.project_name.clone()
};
let gpu = self.gpu_handle();
self.finish(
async move {
let pages: Vec<_> = names.iter().map(String::as_str).zip(docs.iter()).collect();
let items = export::items(&spec, &pages, active, &sel);
if items.is_empty() {
return Err("Nothing to export".to_string());
}
let base = if items.len() == 1 {
items[0].name.clone()
} else {
project
};
let files = export::render_files(&spec, &items, &base, &gpu).await?;
if let [(name, bytes)] = files.as_slice() {
return Ok((name.clone(), bytes.clone()));
}
let zip = crate::io::zip::write(&files);
Ok((format!("{}.zip", crate::project::folder_name(&base)), zip))
},
|w, r| match r {
Ok((name, bytes)) => {
dom::save_as(&name, &bytes, dom::mime_for(&name));
w.ed.status = format!("Exported {name}");
}
Err(e) => w.error(format!("Export failed: {e}")),
},
);
}
/// Render the selection (or the page) and put the PNG on the clipboard.
pub(super) fn copy_as_png(&mut self) {
let roots = Some(self.ed.top_selection()).filter(|s| !s.is_empty());
let doc = self.ed.doc.clone();
let gpu = self.gpu_handle();
self.finish(
async move {
crate::io::png::render_async(&gpu, &doc, roots.as_deref(), COPY_PNG_SCALE, None)
.await
},
|w, r| match r {
Ok(img) => {
if let Some(io) = &mut w.ed.clipboard_io {
io.write_image(&img);
w.ed.status = format!("Copied {}×{} PNG", img.width(), img.height());
}
}
Err(e) => w.error(format!("Could not copy as PNG: {e}")),
},
);
}
// ---------------------------------------------------------------- thumbnails
/// Render the open project's thumbnail off the main flow; the home screen refreshes when
/// it lands.
pub(super) fn start_thumbnail(&mut self) {
let Some(job) = self.session.start_thumbnail(&self.ed) else {
return;
};
let gpu = self.gpu_handle();
self.finish(
async move {
let png = crate::session::render_thumbnail_async(&gpu, &job.doc).await?;
crate::session::finish_thumbnail(&job.path, png)
},
|w, r| match r {
Ok(()) if w.st.home.is_some() => w.show_home(),
Ok(()) => {}
Err(e) => log::warn!("thumbnail: {e}"),
},
);
}
/// Format picked by the inspector (kept next to `export` so both stay in sync).
pub(super) fn export_format(&mut self, f: Format) {
let spec = Spec {
format: f,
..self.st.export
};
self.export(spec);
}
}
+228
View File
@@ -0,0 +1,228 @@
//! Tab side of the relay: one WebSocket to `papercraft-relay` that carries agent tool calls in
//! and results out (frames in `mcp::relay_proto`). The tab holds the document and the executor;
//! the relay holds nothing, so reconnecting just resumes.
//!
//! The socket callbacks only queue work into an [`Inbox`]; the shell drains it at the start of
//! every frame (`Web::pump_calls`) so edits and repaint stay on one code path.
use super::dom;
use crate::mcp::relay_proto::{self, Call, Reply, TOKEN_BYTES};
use crate::ui::agent::LinkState;
use std::cell::RefCell;
use std::collections::VecDeque;
use std::rc::Rc;
use wasm_bindgen::prelude::*;
use web_sys::{MessageEvent, WebSocket};
const TOKEN_KEY: &str = "papercraft.relay.token";
const BACKOFF_MIN_MS: f64 = 1_000.0;
const BACKOFF_MAX_MS: f64 = 15_000.0;
/// WebSocket close codes: normal closure, and "going away" for a replaced socket.
const CLOSE_NORMAL: u16 = 1000;
#[derive(Default)]
struct Inbox {
calls: VecDeque<Call>,
state: LinkState,
/// Set by the callbacks for the shell's `tick` to act on.
opened: bool,
closed: bool,
/// Which `connect` the callbacks belong to; stale sockets are ignored.
generation: u32,
/// Called when a call arrives (the shell uses it to keep working in a hidden tab, where
/// `requestAnimationFrame` is paused).
wake: Option<Rc<dyn Fn()>>,
}
type Callbacks = (
Closure<dyn FnMut()>,
Closure<dyn FnMut(MessageEvent)>,
Closure<dyn FnMut()>,
);
pub struct Relay {
token: String,
base: String,
inbox: Rc<RefCell<Inbox>>,
socket: Option<WebSocket>,
_callbacks: Option<Callbacks>,
backoff_ms: f64,
retry_at: Option<f64>,
}
fn new_token() -> String {
let mut bytes = [0u8; TOKEN_BYTES];
let filled = super::window()
.crypto()
.is_ok_and(|c| c.get_random_values_with_u8_array(&mut bytes).is_ok());
if !filled {
// No Web Crypto (very old browser or insecure context): fall back to the seeded PRNG.
bytes.iter_mut().for_each(|b| *b = fastrand::u8(..));
}
relay_proto::hex(&bytes)
}
impl Relay {
pub fn new() -> Relay {
let loc = super::window().location();
let base = relay_proto::relay_base(
&loc.search().unwrap_or_default(),
dom::relay_override().as_deref(),
&loc.origin().unwrap_or_default(),
);
let token = dom::local_get(TOKEN_KEY)
.filter(|t| relay_proto::valid_token(t))
.unwrap_or_else(|| {
let t = new_token();
dom::local_set(TOKEN_KEY, &t);
t
});
Relay {
token,
base,
inbox: Rc::default(),
socket: None,
_callbacks: None,
backoff_ms: BACKOFF_MIN_MS,
retry_at: Some(0.0),
}
}
pub fn set_wake(&self, wake: Rc<dyn Fn()>) {
self.inbox.borrow_mut().wake = Some(wake);
}
pub fn state(&self) -> LinkState {
self.inbox.borrow().state
}
pub fn next_call(&mut self) -> Option<Call> {
self.inbox.borrow_mut().calls.pop_front()
}
/// The URL to hand to an agent. Contains the secret token.
pub fn agent_url(&self) -> String {
relay_proto::agent_url(&relay_proto::http_origin(&self.base), &self.token)
}
/// New token (the old link stops working) and a fresh connection.
pub fn regenerate(&mut self, now: f64) {
self.token = new_token();
dom::local_set(TOKEN_KEY, &self.token);
self.backoff_ms = BACKOFF_MIN_MS;
self.connect(now);
}
/// Answer a call. Dropped silently when the socket is gone: the relay already told the agent.
pub fn send(&self, reply: &Reply) {
let Some(ws) = &self.socket else { return };
match serde_json::to_string(reply) {
Ok(json) => {
if ws.send_with_str(&json).is_err() {
log::warn!("papercraft relay: could not send a reply");
}
}
Err(e) => log::warn!("papercraft relay: {e}"),
}
}
/// Drive reconnection; call every frame. True when something changed that needs a repaint.
pub fn tick(&mut self, now: f64) -> bool {
let (opened, closed) = {
let mut i = self.inbox.borrow_mut();
(std::mem::take(&mut i.opened), std::mem::take(&mut i.closed))
};
if opened {
self.backoff_ms = BACKOFF_MIN_MS;
}
if closed {
self.retry_at = Some(now + self.backoff_ms);
self.backoff_ms = (self.backoff_ms * 2.0).min(BACKOFF_MAX_MS);
}
if self.retry_at.is_some_and(|t| now >= t) {
self.connect(now);
}
opened || closed
}
fn connect(&mut self, now: f64) {
self.retry_at = None;
self.detach();
let generation = {
let mut i = self.inbox.borrow_mut();
i.generation += 1;
i.state = LinkState::Connecting;
i.generation
};
let url = relay_proto::tab_url(&self.base, &self.token);
let ws = match WebSocket::new(&url) {
Ok(ws) => ws,
Err(_) => {
// Never log `url`: it carries the token.
log::warn!(
"papercraft relay: cannot open a connection to {}",
self.base
);
self.inbox.borrow_mut().state = LinkState::Offline;
self.retry_at = Some(now + self.backoff_ms);
self.backoff_ms = (self.backoff_ms * 2.0).min(BACKOFF_MAX_MS);
return;
}
};
let current = move |inbox: &Rc<RefCell<Inbox>>| inbox.borrow().generation == generation;
let inbox = self.inbox.clone();
let onopen = Closure::<dyn FnMut()>::new(move || {
if current(&inbox) {
let mut i = inbox.borrow_mut();
i.state = LinkState::Connected;
i.opened = true;
}
});
let inbox = self.inbox.clone();
let onmessage = Closure::<dyn FnMut(MessageEvent)>::new(move |e: MessageEvent| {
if !current(&inbox) {
return;
}
let Some(text) = e.data().as_string() else {
return;
};
let Ok(call) = serde_json::from_str::<Call>(&text) else {
log::warn!("papercraft relay: ignoring a frame that is not a call");
return;
};
let wake = {
let mut i = inbox.borrow_mut();
i.calls.push_back(call);
i.wake.clone()
};
if let Some(wake) = wake {
wake();
}
});
let inbox = self.inbox.clone();
let onclose = Closure::<dyn FnMut()>::new(move || {
if current(&inbox) {
let mut i = inbox.borrow_mut();
i.state = LinkState::Offline;
i.closed = true;
}
});
ws.set_onopen(Some(onopen.as_ref().unchecked_ref()));
ws.set_onmessage(Some(onmessage.as_ref().unchecked_ref()));
// An error is always followed by a close event, which handles the retry.
ws.set_onclose(Some(onclose.as_ref().unchecked_ref()));
self.socket = Some(ws);
self._callbacks = Some((onopen, onmessage, onclose));
}
fn detach(&mut self) {
if let Some(ws) = self.socket.take() {
ws.set_onopen(None);
ws.set_onmessage(None);
ws.set_onclose(None);
let _ = ws.close_with_code_and_reason(CLOSE_NORMAL, "reconnecting");
}
self._callbacks = None;
}
}
+1 -1
View File
@@ -52,7 +52,7 @@ fn m1_infinite_canvas_pan_and_zoom() {
..Modifiers::NONE
}));
e.handle(InputEvent::Scroll {
delta: v2(0.0, -(3.0f64.ln() * 100.0)),
delta: v2(0.0, -(3.0f64.ln() / 0.003)),
pos: anchor,
});
e.handle(InputEvent::Modifiers(Modifiers::NONE));
+296
View File
@@ -0,0 +1,296 @@
//! The relay end to end: an agent over streamable HTTP, a fake tab over a real WebSocket.
//!
//! The tab is the blocking tungstenite client on a thread: the relay keeps no state, so all
//! the test proves is that a call reaches a socket and its answer comes back as MCP content.
use papercraft::mcp::Out;
use papercraft::mcp::relay::{self, NO_TAB_ERROR};
use papercraft::mcp::relay_proto::{Call as CallFrame, Reply};
use rmcp::ServiceExt;
use rmcp::model::{CallToolRequestParams, CallToolResult, ContentBlock};
use serde_json::{Value, json};
use std::net::SocketAddr;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use tokio_tungstenite::tungstenite::{Message as Ws, connect as ws_connect};
/// What the fake tab answers with.
const TAB_TEXT: &str = "answered in the tab";
/// How long to wait for the tab's socket before giving up.
const CONNECT_WAIT: Duration = Duration::from_secs(10);
const CONNECT_POLL: Duration = Duration::from_millis(20);
/// A link token as a tab generates one: 32 lowercase hex characters.
fn token() -> String {
format!("{:032x}", fastrand::u64(..))
}
/// A 1x1 transparent PNG, so the image path is exercised with real bytes.
fn png() -> Vec<u8> {
papercraft::assets::base64_decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
)
.expect("the PNG fixture is valid base64")
}
fn spawn_relay() -> relay::Running {
let listen: SocketAddr = "127.0.0.1:0".parse().expect("a valid address");
relay::spawn(listen, None, None).expect("the relay binds an ephemeral port")
}
fn tool(name: &str, v: Value) -> CallToolRequestParams {
let mut p = CallToolRequestParams::new("");
if let Value::Object(m) = v {
p.arguments = Some(m);
}
p.name = name.to_string().into();
p
}
fn text_of(r: &CallToolResult) -> String {
r.content
.iter()
.find_map(|c| match c {
ContentBlock::Text(t) => Some(t.text.clone()),
_ => None,
})
.expect("a text block")
}
/// A tab that answers `answers` calls with a text block and a PNG, then hangs up so the relay
/// can shut down. Returns the calls it saw.
fn fake_tab(
url: String,
answers: usize,
connected: Arc<AtomicBool>,
) -> std::thread::JoinHandle<Vec<CallFrame>> {
std::thread::spawn(move || {
let (mut ws, _) = ws_connect(&url).expect("the tab connects to the relay");
let mut seen = Vec::new();
while seen.len() < answers {
match ws.read() {
// The relay pings as soon as the link is live; that is the "connected" signal.
Ok(Ws::Ping(_)) => connected.store(true, Ordering::SeqCst),
Ok(Ws::Text(t)) => {
let Ok(call) = serde_json::from_str::<CallFrame>(&t) else {
continue;
};
seen.push(call.clone());
let reply = Reply::ok(
call.id,
vec![
Out::Text(TAB_TEXT.to_string()),
Out::Image {
data: png(),
mime: "image/png".to_string(),
},
],
);
let json = serde_json::to_string(&reply).expect("a reply encodes");
ws.send(Ws::Text(json.into()))
.expect("the reply reaches the relay");
}
Ok(Ws::Close(_)) | Err(_) => break,
Ok(_) => continue,
}
}
seen
})
}
async fn wait_for_tab(connected: &AtomicBool) {
let deadline = tokio::time::Instant::now() + CONNECT_WAIT;
while !connected.load(Ordering::SeqCst) {
assert!(
tokio::time::Instant::now() < deadline,
"the fake tab never connected"
);
tokio::time::sleep(CONNECT_POLL).await;
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_call_reaches_the_tab_and_its_answer_reaches_the_agent() {
let running = spawn_relay();
let token = token();
let connected = Arc::new(AtomicBool::new(false));
let tab = fake_tab(
format!("ws://127.0.0.1:{}/tab/{token}", running.port()),
1,
connected.clone(),
);
wait_for_tab(&connected).await;
let transport = rmcp::transport::StreamableHttpClientTransport::from_uri(format!(
"http://127.0.0.1:{}/s/{token}/mcp",
running.port()
));
let client = ().serve(transport).await.expect("the agent connects");
// The relay serves the same catalogue as the desktop app.
let tools = client.list_all_tools().await.unwrap();
assert!(tools.iter().any(|t| t.name == "get_basic_info"));
let r = client
.call_tool(tool("get_basic_info", json!({})))
.await
.expect("the call completes");
assert_eq!(text_of(&r), TAB_TEXT, "the text came back from the tab");
let image = r
.content
.iter()
.find_map(|c| match c {
ContentBlock::Image(i) => Some(i),
_ => None,
})
.expect("the tab's image block survives the relay");
assert_eq!(image.mime_type, "image/png");
assert_eq!(image.data, papercraft::assets::base64(&png()));
let seen = tab.join().expect("the tab thread finished");
assert_eq!(seen.len(), 1);
assert_eq!(seen[0].name, "get_basic_info");
assert_eq!(seen[0].args, json!({}));
client.cancel().await.unwrap();
running.stop();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_call_without_a_tab_says_so() {
let running = spawn_relay();
let token = token();
let transport = rmcp::transport::StreamableHttpClientTransport::from_uri(format!(
"http://127.0.0.1:{}/s/{token}/mcp",
running.port()
));
let client = ().serve(transport).await.expect("the agent connects");
let r = client
.call_tool(tool("get_basic_info", json!({})))
.await
.expect("the call completes");
assert_eq!(r.is_error, Some(true), "{r:?}");
assert_eq!(text_of(&r), NO_TAB_ERROR);
client.cancel().await.unwrap();
running.stop();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn bad_tokens_browsers_and_unknown_routes_are_refused() {
let running = spawn_relay();
let base = format!("http://127.0.0.1:{}", running.port());
let init = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}"#;
let http = reqwest::Client::new();
let good = token();
// Agents are not browsers: anything carrying an Origin is turned away.
let r = http
.post(format!("{base}/s/{good}/mcp"))
.header("content-type", "application/json")
.header("origin", "https://evil.example")
.body(init)
.send()
.await
.unwrap();
assert_eq!(r.status(), 403);
// Tokens are 32..=64 lowercase hex characters; anything else is a plain 404.
for (label, bad) in [
("uppercase", good.to_uppercase()),
("too short", "abc".to_string()),
("non-hex", "z".repeat(32)),
("empty", String::new()),
] {
let r = http
.post(format!("{base}/s/{bad}/mcp"))
.header("content-type", "application/json")
.body(init)
.send()
.await
.unwrap();
assert_eq!(r.status(), 404, "{label} token");
let r = http.get(format!("{base}/tab/{bad}")).send().await.unwrap();
assert_eq!(r.status(), 404, "{label} token on the tab route");
}
let r = http.get(format!("{base}/nope")).send().await.unwrap();
assert_eq!(r.status(), 404);
running.stop();
}
/// One tab per link: a newer connection takes over and the older socket is closed with a reason.
#[test]
fn a_newer_tab_takes_over_the_link_and_closes_the_old_socket() {
let running = spawn_relay();
let token = token();
let url = format!("ws://127.0.0.1:{}/tab/{token}", running.port());
let (ready_tx, ready_rx) = std::sync::mpsc::channel();
let (close_tx, close_rx) = std::sync::mpsc::channel();
let first = std::thread::spawn({
let url = url.clone();
move || {
let (mut ws, _) = ws_connect(&url).expect("the first tab connects");
while !matches!(ws.read().expect("the link pings"), Ws::Ping(_)) {}
ready_tx.send(()).expect("the test is waiting");
while let Ok(msg) = ws.read() {
if let Ws::Close(Some(frame)) = msg {
close_tx
.send(frame.reason.to_string())
.expect("the test waits");
return;
}
}
}
});
ready_rx
.recv_timeout(CONNECT_WAIT)
.expect("the first tab is live");
let (mut second, _) = ws_connect(&url).expect("the second tab connects");
while !matches!(second.read().expect("the link pings"), Ws::Ping(_)) {}
assert_eq!(
close_rx
.recv_timeout(CONNECT_WAIT)
.expect("the old socket is closed"),
"replaced by a newer tab"
);
first.join().expect("the first tab thread finished");
drop(second);
running.stop();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn static_files_are_served_with_their_type() {
let dir = std::env::temp_dir().join(format!("papercraft-relay-{:016x}", fastrand::u64(..)));
std::fs::create_dir_all(dir.join("pkg")).unwrap();
std::fs::write(dir.join("index.html"), "<html>paper</html>").unwrap();
std::fs::write(dir.join("pkg/papercraft.js"), "export {};").unwrap();
std::fs::write(dir.join("pkg/papercraft_bg.wasm"), b"\0asm").unwrap();
let listen: SocketAddr = "127.0.0.1:0".parse().expect("a valid address");
let running = relay::spawn(listen, Some(dir.clone()), None).expect("the relay binds");
let base = format!("http://127.0.0.1:{}", running.port());
let http = reqwest::Client::new();
let r = http.get(&base).send().await.unwrap();
assert_eq!(r.status(), 200);
assert_eq!(r.headers().get("content-type").unwrap(), "text/html");
assert_eq!(r.text().await.unwrap(), "<html>paper</html>");
let r = http
.get(format!("{base}/pkg/papercraft_bg.wasm"))
.send()
.await
.unwrap();
assert_eq!(r.status(), 200);
assert_eq!(r.headers().get("content-type").unwrap(), "application/wasm");
let r = http
.get(format!("{base}/pkg/missing.js"))
.send()
.await
.unwrap();
assert_eq!(r.status(), 404);
std::fs::remove_dir_all(&dir).ok();
running.stop();
}
Executable
+13
View File
@@ -0,0 +1,13 @@
#!/bin/sh
# Build the browser app into web/pkg. Needs: rustup target add wasm32-unknown-unknown,
# cargo install wasm-bindgen-cli (same version as the wasm-bindgen crate in Cargo.lock).
set -e
cd "$(dirname "$0")/.."
# Homebrew's cargo can be older than rust-version; prefer the rustup toolchain.
export PATH="$HOME/.cargo/bin:$PATH"
cargo rustc --lib --profile web --target wasm32-unknown-unknown --crate-type cdylib
TARGET_DIR="${CARGO_TARGET_DIR:-target}"
wasm-bindgen --target web --no-typescript --out-dir web/pkg "$TARGET_DIR/wasm32-unknown-unknown/web/papercraft.wasm"
if command -v wasm-opt >/dev/null 2>&1; then
wasm-opt -Oz -o web/pkg/papercraft_bg.wasm web/pkg/papercraft_bg.wasm
fi
+19
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, maximum-scale=1, user-scalable=no">
<title>PaperCraft</title>
<style>
html, body { margin: 0; height: 100%; overflow: hidden; background: #1e1e20; }
canvas { display: block; width: 100vw; height: 100vh; touch-action: none; }
</style>
</head>
<body>
<canvas id="papercraft"></canvas>
<script type="module">
import init from "./pkg/papercraft.js";
init();
</script>
</body>
</html>