Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 057ba30908 | |||
| 6e7b7e756a | |||
| 9e478207bb | |||
| 7c56a8d209 | |||
| 01a01129be | |||
| a1ba859aac |
@@ -3,3 +3,5 @@ target/
|
||||
dist/
|
||||
*.asar
|
||||
app.asar.unpacked/
|
||||
web/pkg/
|
||||
.worktrees/
|
||||
|
||||
Generated
+185
-11
@@ -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
@@ -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"] }
|
||||
|
||||
@@ -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
@@ -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
@@ -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)?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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(¢ral);
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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(),
|
||||
))
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+173
@@ -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}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user