Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fa0511bc94 | |||
| 3f37de39f8 | |||
| edc0b818ff | |||
| ea6f1b3ac4 | |||
| 5f667a5ae9 | |||
| 0b78bb1d85 | |||
| 110c99d47f | |||
| b43fa4fe43 | |||
| 057ba30908 | |||
| 6e7b7e756a | |||
| 9e478207bb | |||
| 7c56a8d209 | |||
| 01a01129be | |||
| a1ba859aac |
@@ -0,0 +1,7 @@
|
||||
target/
|
||||
analysis/
|
||||
.git/
|
||||
.worktrees/
|
||||
web/pkg/
|
||||
dist/
|
||||
packaging/
|
||||
@@ -3,3 +3,5 @@ target/
|
||||
dist/
|
||||
*.asar
|
||||
app.asar.unpacked/
|
||||
web/pkg/
|
||||
.worktrees/
|
||||
|
||||
Generated
+404
-16
@@ -222,7 +222,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
|
||||
dependencies = [
|
||||
"axum-core",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"form_urlencoded",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
@@ -236,8 +238,13 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"serde_core",
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"serde_urlencoded",
|
||||
"sha1",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
@@ -261,6 +268,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 +307,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 +453,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 +529,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"
|
||||
@@ -517,6 +559,16 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
@@ -530,9 +582,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"core-foundation",
|
||||
"core-foundation 0.9.4",
|
||||
"core-graphics-types",
|
||||
"foreign-types",
|
||||
"foreign-types 0.5.0",
|
||||
"libc",
|
||||
]
|
||||
|
||||
@@ -543,7 +595,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"core-foundation",
|
||||
"core-foundation 0.9.4",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
@@ -594,12 +655,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 +714,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"
|
||||
@@ -985,6 +1072,15 @@ dependencies = [
|
||||
"tinyvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
|
||||
dependencies = [
|
||||
"foreign-types-shared 0.1.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.5.0"
|
||||
@@ -992,7 +1088,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
|
||||
dependencies = [
|
||||
"foreign-types-macros",
|
||||
"foreign-types-shared",
|
||||
"foreign-types-shared 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1006,6 +1102,12 @@ dependencies = [
|
||||
"syn 3.0.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types-shared"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types-shared"
|
||||
version = "0.3.1"
|
||||
@@ -1109,6 +1211,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 +1252,7 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
"rand_core",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1339,13 +1451,29 @@ dependencies = [
|
||||
"want",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-tls"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"native-tls",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
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",
|
||||
@@ -2017,6 +2145,23 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "native-tls"
|
||||
version = "0.2.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"openssl",
|
||||
"openssl-probe",
|
||||
"openssl-sys",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk"
|
||||
version = "0.9.0"
|
||||
@@ -2485,6 +2630,49 @@ version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.81"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"cfg-if",
|
||||
"foreign-types 0.3.2",
|
||||
"libc",
|
||||
"openssl-macros",
|
||||
"openssl-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-macros"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
@@ -2521,12 +2709,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "papercraft"
|
||||
version = "0.1.1"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"axum",
|
||||
"base64",
|
||||
"base64 0.23.1",
|
||||
"bytemuck",
|
||||
"console_error_panic_hook",
|
||||
"console_log",
|
||||
"craft-ui",
|
||||
"dirs",
|
||||
"egui",
|
||||
@@ -2536,6 +2726,7 @@ dependencies = [
|
||||
"fastrand",
|
||||
"i_overlay",
|
||||
"image",
|
||||
"js-sys",
|
||||
"log",
|
||||
"lyon",
|
||||
"miniz_oxide 0.9.1",
|
||||
@@ -2552,9 +2743,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 +2914,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 +3009,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 +3027,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 +3188,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",
|
||||
@@ -2962,15 +3196,19 @@ dependencies = [
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-tls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"native-tls",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
@@ -3043,7 +3281,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 +3292,7 @@ dependencies = [
|
||||
"pastey",
|
||||
"pin-project-lite",
|
||||
"process-wrap",
|
||||
"rand",
|
||||
"rand 0.10.3",
|
||||
"reqwest",
|
||||
"schemars",
|
||||
"serde",
|
||||
@@ -3131,12 +3369,27 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||
dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
@@ -3146,6 +3399,15 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars"
|
||||
version = "1.2.2"
|
||||
@@ -3197,6 +3459,29 @@ dependencies = [
|
||||
"tiny-skia 0.11.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework-sys"
|
||||
version = "2.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "self_cell"
|
||||
version = "1.3.0"
|
||||
@@ -3263,6 +3548,17 @@ dependencies = [
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_path_to_error"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "1.1.2"
|
||||
@@ -3272,6 +3568,29 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_urlencoded"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[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"
|
||||
@@ -3533,6 +3852,19 @@ dependencies = [
|
||||
"syn 3.0.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"rustix 1.1.5",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "termcolor"
|
||||
version = "1.4.1"
|
||||
@@ -3690,6 +4022,16 @@ dependencies = [
|
||||
"syn 3.0.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-native-tls"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
|
||||
dependencies = [
|
||||
"native-tls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-stream"
|
||||
version = "0.1.19"
|
||||
@@ -3701,6 +4043,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 +4209,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 +4234,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 +4300,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",
|
||||
@@ -3969,6 +4345,12 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "vello_common"
|
||||
version = "0.1.0"
|
||||
@@ -4764,7 +5146,7 @@ dependencies = [
|
||||
"calloop 0.13.0",
|
||||
"cfg_aliases",
|
||||
"concurrent-queue",
|
||||
"core-foundation",
|
||||
"core-foundation 0.9.4",
|
||||
"core-graphics",
|
||||
"cursor-icon",
|
||||
"dpi",
|
||||
@@ -4965,6 +5347,12 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879"
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.5"
|
||||
|
||||
+39
-8
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "papercraft"
|
||||
version = "0.1.1"
|
||||
version = "0.3.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,25 @@ 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", "json", "query"] }
|
||||
tokio-util = "0.7"
|
||||
# Shared-project sync; the system TLS stack so https servers work without bundling one.
|
||||
reqwest = { version = "0.13", default-features = false, features = ["native-tls"] }
|
||||
|
||||
# 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", "Request", "RequestInit", "Response"] }
|
||||
|
||||
+14
-14
@@ -13,31 +13,31 @@ Agents can already build a design system through the tools; people cannot yet ma
|
||||
|
||||
| # | Item | Status | Notes |
|
||||
|---|---|---|---|
|
||||
| 1 | Inspector UI for variables | todo | Variables exist only through `get_variables` / `set_variables`. No panel to list, rename, edit or bind them. |
|
||||
| 2 | Inspector UI for components | todo | Create component, detach and go-to-main are shortcuts (⌘⌥K / ⌘⌥B); there is no Components panel or asset browser. |
|
||||
| 1 | Inspector UI for variables | **done** | Design tab lists/renames/adds/deletes variables & modes; Inspector binds/unlinks solid fills to variables. |
|
||||
| 2 | Inspector UI for components | **done** | Design tab searches components, inserts instances, swaps/resets/detaches instances; Inspector header swaps/resets instances. |
|
||||
| 3 | Design lint | **done** | `lint_design`: WCAG contrast, 4px grid, type scale, tiny text, 44px targets, default names, hard-coded colors. |
|
||||
| 4 | Lint auto-fix | todo | `lint_design` suggests a fix per issue; nothing applies it. Safe candidates: snap spacing, rename layers, relink colors to variables. |
|
||||
| 5 | Lint in the UI | todo | Show issues on the canvas or in a panel, click to select. |
|
||||
| 6 | Variable modes | partial | Done through tools: `set_variables {mode}` stores values per mode and `set_variable_mode` switches the whole project. Not yet in the UI, and not per frame or page. |
|
||||
| 7 | Number variables (spacing, radius, font size) | todo | Variables are colors only, and only for fills and text color. |
|
||||
| 4 | Lint auto-fix | **done** | `lint_design {fix: true}` and UI `Fix` / `Fix all`: snaps spacing to 4px grid, snaps type scale, enforces 44px targets, links matching colors to variables. |
|
||||
| 5 | Lint in the UI | **done** | Live Lint section in Design tab; click issue to select & zoom, or click `Fix` / `Fix all`. |
|
||||
| 6 | Variable modes | **done** | Mode selector + create/delete modes in Design tab and via `set_variables {mode}` / `set_variable_mode`. |
|
||||
| 7 | Number variables (spacing, radius, font size) | partial | Non-color tokens (`--spacing-md`) editable in Design tab and substituted in CSS; typed per-field bindings todo. |
|
||||
| 8 | Stroke and gradient variables | todo | Strokes and gradients keep fixed colors. |
|
||||
| 9 | Text styles | todo | Named type styles (Heading / L, Body / M) that update every text layer using them. |
|
||||
| 10 | Component variants and properties | todo | Variants are separate components named `Button / Primary`. No variant sets, boolean or text properties. |
|
||||
| 11 | Swap instance, detach by tool | partial | Detach is a shortcut in the UI. No `swap_instance` or `detach_instance` tool. |
|
||||
| 11 | Swap instance, detach by tool | **done** | `swap_instance` and `detach_instance` MCP tools + UI controls in Design tab and Inspector. |
|
||||
| 12 | Cross-page and shared libraries | todo | Components and instances must live on one page. |
|
||||
| 13 | Export design tokens | partial | `get_variables` with `format: "css"` or `"json"` returns tokens. No Tailwind or Style Dictionary output, no file export from the UI. |
|
||||
| 14 | Apply a design system to existing work | todo | Map hard-coded colors and sizes onto variables and components in one pass. |
|
||||
| 13 | Export design tokens | **done** | `get_variables` with `format: "css"`, `"json"` (W3C DTCG + modes), or `"tailwind"` (`@theme`), plus Copy CSS / Copy JSON in Design tab. |
|
||||
| 14 | Apply a design system to existing work | partial | `lint_design {fix: true}` links matching literal colors to variables and snaps spacing/type scale. |
|
||||
|
||||
## 2. Agent workflow
|
||||
|
||||
| # | Item | Status | Notes |
|
||||
|---|---|---|---|
|
||||
| 15 | MCP and CLI tool surface | **done** | 40 tools, one undo step per call, `papercraft-cli list / schema / call`. |
|
||||
| 15 | MCP and CLI tool surface | **done** | 43 tools, one undo step per call, `papercraft-cli list / schema / call`. |
|
||||
| 16 | Agent skills | **done** | `skills/papercraft` and `skills/papercraft-design`. |
|
||||
| 17 | Accessibility annotations | todo | Reading order, roles and alt text on layers, checked by the linter. |
|
||||
| 18 | Code export from frames | todo | `get_jsx` returns inline-style JSX only. No React / HTML+CSS with variables, no Tailwind (the `tailwind` format is accepted and ignored). |
|
||||
| 18 | Code export from frames | **done** | `get_jsx` supports `format: "inline"`, `"tailwind"` (Tailwind utility classes), and `"html"` (HTML + CSS rules with `:root` variables). |
|
||||
| 19 | Plugin and skill marketplace | todo | Skills are copied by hand today. |
|
||||
| 20 | Screenshot diffing | todo | Compare two renders of a frame to confirm a change did what was asked. |
|
||||
| 20 | Screenshot diffing | **done** | `diff_frames` tool (`src/mcp/diff.rs`) for structural/property frame diffing + `diff_rgba` pixel diffing with highlight PNG. |
|
||||
| 21 | Moving nodes between pages | todo | `move_nodes` stays on one page. |
|
||||
| 22 | Remote images in `write_html` | todo | Only local paths and `data:` URIs. |
|
||||
|
||||
@@ -48,7 +48,7 @@ Agents can already build a design system through the tools; people cannot yet ma
|
||||
| 23 | Prototyping links and flows | todo | No click-through between frames. |
|
||||
| 24 | Responsive constraints and breakpoint preview | partial | Constraints (left, right, stretch, center, scale) exist in the editor; there is no breakpoint preview or per-size variants. |
|
||||
| 25 | Shadows, blur and effects | todo | CSS `box-shadow` and filters are reported as ignored. |
|
||||
| 26 | Margin and grid layout | todo | Flexbox only. |
|
||||
| 26 | Margin and grid layout | partial | Wrapped-line cross alignment, per-line fill/align, and `layout_grid` track layout (`src/layout.rs`) implemented; CSS margin still ignored. |
|
||||
| 27 | Font choice | todo | Inter only. |
|
||||
| 28 | Rotation | todo | Nodes are axis-aligned. |
|
||||
| 29 | Overlay, dodge, burn, light blend modes on the canvas | todo | Exact in SVG and PDF export, approximated as Normal on the canvas. |
|
||||
@@ -60,7 +60,7 @@ Agents can already build a design system through the tools; people cannot yet ma
|
||||
| # | Item | Status | Notes |
|
||||
|---|---|---|---|
|
||||
| 32 | Version history | todo | Undo per page and crash-safe autosave only; no named versions or restore. |
|
||||
| 33 | Figma import | todo | SVG and Excalidraw import exist; `.fig` does not. |
|
||||
| 33 | Figma import | **done** | `.fig` binary (Kiwi wire decoder + zip container) and Figma REST API JSON import in `src/io/fig.rs`. |
|
||||
| 34 | Multiplayer and cloud sync | todo | Local-first by design. If added, it must stay optional. |
|
||||
| 35 | Release builds for Windows and Linux | todo | The code targets them (wgpu, winit); packaging today is the macOS DMG and the `papercraft-cli` zip. |
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# Browser build + relay in one image: `papercraft-relay --static` serves web/ and the agent relay.
|
||||
# craft-libs is a private Gitea dependency, so vendor on the host (authenticated) and pass it in:
|
||||
# cargo vendor --locked /path/vendor/crates >/dev/null
|
||||
# docker build -f deploy/Dockerfile --build-context vendor=/path/vendor -t localhost:32000/papercraft-web:<tag> .
|
||||
FROM rust:slim AS build
|
||||
# Must match the wasm-bindgen crate in Cargo.lock.
|
||||
ARG WASM_BINDGEN=0.2.129
|
||||
RUN rustup target add wasm32-unknown-unknown \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends binaryen pkg-config libssl-dev \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& cargo install wasm-bindgen-cli --version ${WASM_BINDGEN} --locked
|
||||
COPY --from=vendor crates /vendor
|
||||
RUN printf '%s\n' \
|
||||
'[source.crates-io]' 'replace-with = "vendored-sources"' \
|
||||
'[source."git+https://git.marcospaulo.dev.br/masi/craft-libs?rev=22544b6d619d4f16779b8bdfa3f0b642a86fc065"]' \
|
||||
'git = "https://git.marcospaulo.dev.br/masi/craft-libs"' \
|
||||
'rev = "22544b6d619d4f16779b8bdfa3f0b642a86fc065"' 'replace-with = "vendored-sources"' \
|
||||
'[source.vendored-sources]' 'directory = "/vendor"' > "$CARGO_HOME/config.toml"
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
RUN sh web/build.sh && cargo build --release --locked --bin papercraft-relay
|
||||
|
||||
FROM debian:stable-slim
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends libssl3 ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& useradd --system --uid 10001 papercraft
|
||||
COPY --from=build /src/target/release/papercraft-relay /usr/local/bin/papercraft-relay
|
||||
COPY --from=build /src/web/index.html /srv/web/index.html
|
||||
COPY --from=build /src/web/pkg /srv/web/pkg
|
||||
USER 10001
|
||||
EXPOSE 8787
|
||||
ENTRYPOINT ["papercraft-relay", "--static", "/srv/web", "--data", "/data"]
|
||||
@@ -0,0 +1,86 @@
|
||||
# Shared projects live in /data (`--data`) on the HDD: PV /srv/hdd/papercraft, class hdd-storage, Retain (same pattern as actual, kavita, ...).
|
||||
# --library: every project in /data is listed at /library and synced to every device that opens the site, with no link
|
||||
# needed. There are no accounts, so the Tailnet-only ingress is the whole access boundary; never expose this publicly.
|
||||
# Tailnet access (DNS, TLS, ingress) is added with `expose papercraft/papercraft papercraft`; see deploy/README.md.
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: papercraft
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolume
|
||||
metadata:
|
||||
name: pv-hdd-papercraft
|
||||
spec:
|
||||
capacity: {storage: 5Gi}
|
||||
accessModes: [ReadWriteOnce]
|
||||
persistentVolumeReclaimPolicy: Retain
|
||||
storageClassName: hdd-storage
|
||||
volumeMode: Filesystem
|
||||
local: {path: /srv/hdd/papercraft}
|
||||
nodeAffinity:
|
||||
required:
|
||||
nodeSelectorTerms:
|
||||
- matchExpressions:
|
||||
- {key: kubernetes.io/hostname, operator: In, values: [kubernets]}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: papercraft-data
|
||||
namespace: papercraft
|
||||
spec:
|
||||
accessModes: [ReadWriteOnce]
|
||||
storageClassName: hdd-storage
|
||||
volumeName: pv-hdd-papercraft
|
||||
resources:
|
||||
requests: {storage: 5Gi}
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: papercraft
|
||||
namespace: papercraft
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels: {app: papercraft}
|
||||
template:
|
||||
metadata:
|
||||
labels: {app: papercraft}
|
||||
spec:
|
||||
securityContext: {fsGroup: 10001}
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim: {claimName: papercraft-data}
|
||||
containers:
|
||||
- name: web
|
||||
image: localhost:32000/papercraft-web:0.2.0-ea6f1b3
|
||||
args: ["--library", "--allow-origin", "https://papercraft.marcospaulo.dev.br"]
|
||||
volumeMounts:
|
||||
- {name: data, mountPath: /data}
|
||||
ports:
|
||||
- containerPort: 8787
|
||||
readinessProbe:
|
||||
httpGet: {path: /index.html, port: 8787}
|
||||
livenessProbe:
|
||||
httpGet: {path: /index.html, port: 8787}
|
||||
initialDelaySeconds: 10
|
||||
resources:
|
||||
requests: {cpu: 20m, memory: 32Mi}
|
||||
limits: {cpu: 500m, memory: 256Mi}
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities: {drop: [ALL]}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: papercraft
|
||||
namespace: papercraft
|
||||
spec:
|
||||
selector: {app: papercraft}
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 8787
|
||||
+21
-10
@@ -66,7 +66,10 @@ Full flows: [recipes.md](references/recipes.md).
|
||||
|---|---|
|
||||
| Know the project, pages, artboards | `get_basic_info`, `get_tree_summary`, `get_children` |
|
||||
| Know what is selected | `get_selection` |
|
||||
| Inspect one node | `get_node_info`, `get_computed_styles`, `get_jsx` |
|
||||
| Inspect one node or export code | `get_node_info`, `get_computed_styles`, `get_jsx` (`inline` \| `tailwind` \| `react` \| `angular` \| `html` \| `egui`) |
|
||||
| Extract AI design-to-code spec | `get_design_spec` (`nodeId`, `target`: `react` \| `tailwind` \| `angular` \| `html` \| `egui`) |
|
||||
| Verify generated code against design | `verify_code` (`nodeId`, `target`, `code`) |
|
||||
| Compare two frames or subtrees | `diff_frames` (`beforeId`, `afterId`) |
|
||||
| Find nodes by text or style | `find_nodes` |
|
||||
| See it | `get_screenshot` (`nodeId`, `scale` 1 or 2) |
|
||||
| New screen | `create_artboard` (`width` and `height` required) |
|
||||
@@ -74,23 +77,31 @@ Full flows: [recipes.md](references/recipes.md).
|
||||
| Change styles or text | `update_styles`, `set_text_content`, `rename_nodes` |
|
||||
| Whiteboard shapes, sticky notes, arrows | `create_shape` |
|
||||
| Copy, move, delete | `duplicate_nodes`, `move_nodes`, `delete_nodes` |
|
||||
| Tokens | `get_variables` (`format` css or json), `set_variables` (`mode`), `set_variable_mode` |
|
||||
| Reuse | `create_component`, `create_instance`, `get_components`, `<x-paper-instance>` |
|
||||
| Tokens | `get_variables` (`format`: `css` \| `json` \| `tailwind`), `set_variables` (`mode`), `set_tokens`, `set_variable_mode` |
|
||||
| Reuse & components | `create_component`, `create_instance`, `swap_instance`, `detach_instance`, `import_components`, `get_components`, `<x-paper-instance>` |
|
||||
| Icons | `search_icons`, `<x-paper-icon name="set:name">`; CLI: `papercraft-cli icons add\|list\|search` |
|
||||
| Check quality | `lint_design` |
|
||||
| Check & auto-fix quality | `lint_design` (`fix: true` to auto-fix grid/scale/variables/targets) |
|
||||
| Export | `export` (PNG, SVG, PDF to files), `export_combined_pdf` |
|
||||
| Point at things for the user | `select_nodes` (`zoom: true`) |
|
||||
| Take back | `undo`, `redo` |
|
||||
| Save | `finish_working_on_nodes` |
|
||||
|
||||
## Design-to-Code Handoff & Enforcement (React, Angular, HTML/CSS, Rust egui)
|
||||
|
||||
When implementing a PaperCraft design in production code (React/TSX, Tailwind JSX, Angular standalone components, HTML + CSS, or Rust `egui`), follow this mandatory three-step protocol:
|
||||
|
||||
1. **Never implement from a screenshot alone.** Always call `get_design_spec` (`nodeId`, `target: "react" | "tailwind" | "angular" | "html" | "egui"`) to extract the structured layout tree, exact auto-layout flow/sizing (`fixed`, `hug`, `fill`), design tokens (`--var`), component instances, and reference code.
|
||||
2. **Write the target code** following the spec's layout flow (Flexbox/Grid for web; `egui::Frame` + `ui.horizontal`/`ui.vertical` + `tokens` module for Rust `egui`), binding all color variables instead of hardcoding hex values, and never using `position: absolute` on flow children.
|
||||
3. **Enforce with `verify_code`**: Pass the written code to `verify_code({nodeId, target, code})`. Fix any reported `missing-text`, `hardcoded-color`, `forbidden-absolute`, `missing-layout`, `missing-component`, or `spacing-drift` violations until `passed: true` (`score: 100`).
|
||||
|
||||
## 6. Known limits (design around them)
|
||||
|
||||
* **Font is Inter only**, regular and bold plus italic. No other families, no weights between. Build hierarchy from size, weight, color and space.
|
||||
* **Variables are colors only**, for fills and text color. Strokes and gradients keep fixed colors. Spacing, radius and type sizes are plain numbers: keep them on the scale by discipline and `lint_design`.
|
||||
* **No margin, grid or tables; no shadows, filters or transforms** (reported under `ignoredStyles`). Fake elevation with a border or a lighter surface.
|
||||
* **Color variables (`set_variables`) apply to fills and text color**; strokes and gradients keep fixed colors. Non-color tokens (`set_tokens`) provide shared spacing, radius, and shadow values expanded on write; `lint_design` (`fix: true`) enforces scale and 4px grid discipline.
|
||||
* **No CSS margin, grid or tables in `write_html`; no CSS filters or transforms** (reported under `ignoredStyles`). Replace margin with `gap` and `padding`.
|
||||
* **No rotation.** Nodes are axis-aligned.
|
||||
* **Components and their instances live on one page.** Put shared components on the page where they are used, or duplicate the component per page.
|
||||
* **No variants or properties**: one component per variant (`Button / Primary`, `Button / Secondary`). No swap or detach tool.
|
||||
* **Components and their instances live on one page** (use `import_components` to copy main components from another page). Variants are modeled as separate components (`Button / Primary`, `Button / Secondary`) and can be swapped in place with `swap_instance` (preserving overrides) or unlinked with `detach_instance`.
|
||||
* **Supported imports, diffs, and code handoff**: `.fig` file import is supported, `diff_frames` compares two frames/subtrees at the property level, `get_jsx` exports `inline`, `tailwind`, `react`, `angular`, `html`, or `egui`, and `get_design_spec` + `verify_code` provide full AI design-to-code handoff and enforcement.
|
||||
* **Remote images are not fetched.** Use a local path or `data:` URI.
|
||||
* **One project open at a time**; moving layers between pages is not supported.
|
||||
* **Text boxes with fixed widths can wrap earlier than in a browser.** Check headlines in the screenshot and widen.
|
||||
@@ -102,10 +113,10 @@ Full flows: [recipes.md](references/recipes.md).
|
||||
|---|---|
|
||||
| `papercraft-cli` exits 1, "isn't running" | The app is closed, no project is open, or the port differs. Start it and open a project; check `PAPERCRAFT_MCP_PORT`. |
|
||||
| Call fails with an unknown node id | The id is from another page or was deleted. Re-read with `get_children`; pass `pageId`. |
|
||||
| `write_html` reports `ignoredStyles` | You used a CSS property that has no equivalent (`margin`, `box-shadow`, `transform`...). Replace it, see [html-subset.md](references/html-subset.md). |
|
||||
| `write_html` reports `ignoredStyles` | You used a CSS property that has no equivalent (`margin`, `transform`...). Replace it, see [html-subset.md](references/html-subset.md). |
|
||||
| Content is cut off | The artboard height is fixed. `update_styles` with `height: "fit-content"` or a larger number. |
|
||||
| A color did not change with the theme | It was written as hex, or it is a stroke or gradient. Use `var(--name)` on fills and text; strokes need `update_styles` after a mode switch. |
|
||||
| `create_instance` fails | The component is on another page. Instances must be on the component's page. |
|
||||
| `create_instance` fails | The component is on another page. Instances must be on the component's page (or run `import_components` first). |
|
||||
| Icon does not appear | The set is not installed: `papercraft-cli icons add phosphor`, then `papercraft-cli icons search <words>` for the exact `set:name`. |
|
||||
| Layer panel is a pile of `Frame` | You skipped `layer-name`. Fix with `rename_nodes` (batch) before finishing. |
|
||||
| Text looks tiny in the screenshot | Screenshots default to scale 1. Use `scale: 2`. |
|
||||
|
||||
@@ -99,15 +99,49 @@ Compact indented text tree of a subtree: type, name, id and size per node. Much
|
||||
|
||||
### `get_jsx` (read-only)
|
||||
|
||||
JSX for a node and its descendants with inline styles (frames become flex divs, text becomes spans, vectors become inline SVG). Use it to export a design to code.
|
||||
Export a node and its descendants to code: `"inline"` (default, React JSX with inline styles), `"tailwind"` (React JSX with Tailwind CSS utility classes), `"react"` (typed React TSX component with Tailwind classes), `"angular"` (Angular standalone `@Component` with template and CSS), `"html"` (self-contained `<style>` + HTML markup), or `"egui"` (Rust `egui` layout code with a `tokens` module).
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `format` | inline-styles/tailwind | | Only inline styles are produced; "tailwind" is accepted for compatibility. |
|
||||
| `format` | `inline` \| `inline-styles` \| `tailwind` \| `react` \| `angular` \| `html` \| `egui` | | Output format: `"inline"` (default), `"tailwind"`, `"react"`, `"angular"`, `"html"`, or `"egui"`. |
|
||||
| `nodeId` | string | yes | The node to convert. |
|
||||
|
||||
|
||||
### `get_design_spec` (read-only)
|
||||
|
||||
Extract a comprehensive AI design-to-code specification (`ai_spec_markdown` and structured JSON `spec`) for a node and its descendants. Includes the hierarchy, exact auto-layout flow/sizing (`fixed`, `hug`, `fill`), bound design tokens (`--var`), component instances, text content, implementation rules, and reference code for the chosen `target`. Always call this before implementing a design in code.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `nodeId` | string | yes | The root frame or node to extract the spec for. |
|
||||
| `target` | `react` \| `tailwind` \| `angular` \| `html` \| `egui` | | Target framework/format (defaults to `"react"`). |
|
||||
|
||||
|
||||
### `verify_code` (read-only)
|
||||
|
||||
Enforce design-to-code fidelity by checking written `code` against the source PaperCraft node (`nodeId`) for the given `target`. Reports a `score` (0–100), `passed` boolean, and structured violations (`missing-text`, `hardcoded-color`, `forbidden-absolute`, `missing-layout`, `missing-component`, `spacing-drift`) with actionable fixes.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `code` | string | yes | The generated or hand-written implementation code to verify. |
|
||||
| `nodeId` | string | yes | The PaperCraft node id the code implements. |
|
||||
| `target` | `react` \| `tailwind` \| `angular` \| `html` \| `egui` | yes | Target language/framework of `code`. |
|
||||
|
||||
|
||||
### `diff_frames` (read-only)
|
||||
|
||||
Compare two frames or subtrees (`beforeId` and `afterId`) and report property-level differences (size, position, fills, stroke, corner radius, opacity, text, layout, added/removed children).
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `beforeId` | string | yes | First frame or node id (baseline). |
|
||||
| `afterId` | string | yes | Second frame or node id (comparison). |
|
||||
|
||||
|
||||
### `get_computed_styles` (read-only)
|
||||
|
||||
CSS styles (camelCase) of one or more nodes, as {nodeId: styles}.
|
||||
@@ -320,12 +354,12 @@ Create a whiteboard element: rectangle, ellipse, diamond, triangle, polygon, sta
|
||||
|
||||
### `get_variables` (read-only)
|
||||
|
||||
Color variables of the page's project (name -> hex). Fills that follow a variable were written with `var(--name)`; set_variables moves all of them at once. `format` css returns a `:root { --name: #hex }` block, json a design-tokens object.
|
||||
Color variables and design tokens of the page's project (name -> hex/value). Fills that follow a variable were written with `var(--name)`; set_variables moves all of them at once. `format` `css` returns a `:root { --name: #hex }` block (plus `[data-theme]` rules for modes), `json` returns a W3C DTCG design-tokens object, and `tailwind` returns a Tailwind v4 `@theme` block.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `format` | css/json | | |
|
||||
| `format` | `css` \| `json` \| `tailwind` | | Output format: `css`, `json` (W3C DTCG), or `tailwind` (`@theme`). |
|
||||
|
||||
|
||||
### `set_variables`
|
||||
@@ -380,6 +414,27 @@ Place an instance of a component. Edit text and styles on the instance's descend
|
||||
| `y` | number | | |
|
||||
|
||||
|
||||
### `swap_instance`
|
||||
|
||||
Swap an instance root to point at a different main component on the same page, preserving overrides on descendants matched by name and layer type.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `componentId` | string | yes | The new main component id. |
|
||||
| `nodeId` | string | yes | The instance root node to swap. |
|
||||
|
||||
|
||||
### `detach_instance`
|
||||
|
||||
Detach one or more component instances into plain frames, breaking their link to the main component while keeping their current appearance and children.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `nodeIds` | array of string | yes | Instance root nodes to detach. |
|
||||
|
||||
|
||||
### `search_icons` (read-only)
|
||||
|
||||
Search installed open icon sets (Lucide, Tabler, Phosphor, Heroicons, Material). Returns `set:name` references for <x-paper-icon name="lucide:check" size="20" color="var(--text)"/> in write_html. Without a query it lists installed and installable sets; install with `papercraft-cli icons add lucide`.
|
||||
@@ -394,11 +449,12 @@ Search installed open icon sets (Lucide, Tabler, Phosphor, Heroicons, Material).
|
||||
|
||||
### `lint_design` (read-only)
|
||||
|
||||
Check a page or subtree for common design mistakes and get a fix for each: text contrast (WCAG 4.5:1, 3:1 for large text), text under 12px, font sizes off the type scale, gap/padding off the 4px grid, buttons and tabs under 44px tall, default layer names (Frame, Rectangle), and hard-coded colors that equal a variable. Run it before finish_working_on_nodes and fix errors and warnings; `info` items are suggestions.
|
||||
Check a page or subtree for common design mistakes and get a fix for each: text contrast (WCAG 4.5:1, 3:1 for large text), text under 12px, font sizes off the type scale, gap/padding off the 4px grid, buttons and tabs under 44px tall, default layer names (Frame, Rectangle), and hard-coded colors that equal a variable. Run it before finish_working_on_nodes and fix errors and warnings; `info` items are suggestions. Pass `fix: true` to automatically snap spacing/padding to 4px, relink matching color variables, snap text sizes, and enlarge small touch targets.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `fix` | boolean | | Apply safe automatic fixes before reporting remaining issues. |
|
||||
| `nodeId` | string | | Subtree to check; omit for the whole page. |
|
||||
| `scale` | array of number | | Allowed font sizes. Default 12, 14, 16, 20, 24, 32, 40, 48, 64. |
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# Shared projects on the relay
|
||||
|
||||
## Why
|
||||
|
||||
On the web build a project lives in one browser's IndexedDB: it cannot be opened from another
|
||||
device, survives only while the browser keeps its storage, and cannot be edited by two people.
|
||||
The relay already hosts the app, so it can also host the project files.
|
||||
|
||||
## Scope (v1)
|
||||
|
||||
1. **Server store.** `papercraft-relay --data <dir>` keeps shared projects on disk. Without the
|
||||
flag the feature is off and every `/p/` route answers 404.
|
||||
2. **Share a project.** On the home screen a project card's menu has "Share…";
|
||||
the app uploads the project's files and gets a **share link** `https://host/?share=<token>`.
|
||||
3. **Open a shared project.** Opening a share link downloads the project into the tab's vfs and
|
||||
remembers the token, so the project appears on the home screen like any other.
|
||||
4. **Sync, file granularity.** Each shared project polls the relay every 2 s, from the browser
|
||||
and from the native app alike (one engine, `src/sync.rs`, over `crate::vfs`). It compares the
|
||||
server's file list, the folder, and the last synced state (kept in the project's `.share`):
|
||||
folder changes go up, server changes come down and the open project reloads unless it has
|
||||
unsaved edits. Conflicts: whole file, the folder wins a simultaneous change (last write wins).
|
||||
An offline client catches up on its next poll.
|
||||
5. **Native too.** The desktop app shares a project to any relay (`Share…` asks for the server)
|
||||
and joins by link, exactly like the browser; both can edit the same project.
|
||||
|
||||
Out of scope: per-object merging (CRDT), cursors/presence, accounts, per-user permissions,
|
||||
history. The share token is the credential: anyone with the link can read and edit.
|
||||
|
||||
## Protocol
|
||||
|
||||
Token: 32–64 lowercase hex characters, generated by the tab (same rules as the agent token).
|
||||
A different token from the agent link, so sharing a project never exposes the MCP endpoint.
|
||||
|
||||
| Route | Meaning |
|
||||
|-------|---------|
|
||||
| `GET /p/{token}/files` | JSON `[{"path": "project.json", "size": 425, "mtime": <ns>}]` (all files, relative paths) |
|
||||
| `GET /p/{token}/f/{path}` | file bytes, 404 if absent |
|
||||
| `PUT /p/{token}/f/{path}` | write bytes; creates the project on first write; answers the file's `mtime`; optional header `x-client: <id>` |
|
||||
| `DELETE /p/{token}/f/{path}` | remove a file |
|
||||
| `GET /p/{token}/ws` | WebSocket; server pushes `{"op":"put"\|"del","path":"…","rev":N,"client":"<id>"}` |
|
||||
|
||||
The websocket is served but the clients poll `/files` (simpler, same code on both targets);
|
||||
it is there for a later push-based client.
|
||||
|
||||
Rules: paths are relative, no `..`, no absolute, no empty segment; a bad path or token is a plain
|
||||
404 (same text as unknown links). `rev` is a per-project counter the relay bumps on every
|
||||
change. Body cap 64 MiB. Origin is not checked (the web app is served from the relay or another
|
||||
origin). The writer's `client` id is echoed so a tab ignores its own events.
|
||||
|
||||
Storage layout: `<data>/<token>/<path>`; `rev` is kept in memory and restarts at the file count
|
||||
after a relay restart (tabs resync from `/files` on reconnect, so a lower rev is harmless).
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Relay tests: put/get/list/delete round trip; path traversal refused; unknown token and bad
|
||||
token are 404; a second websocket sees the first client's put and delete, the writer does not
|
||||
see its own echo; routes 404 when `--data` is absent.
|
||||
- Web: sharing uploads every file under the project dir; opening `?share=` recreates the project
|
||||
locally; editing a page in tab A shows up in tab B without a manual reload. Wasm check builds
|
||||
clean and native tests stay green.
|
||||
- Local (non-shared) projects behave exactly as before.
|
||||
@@ -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).
|
||||
+57
-6
@@ -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};
|
||||
@@ -218,9 +219,28 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn default_server(&self) -> String {
|
||||
self.session.server_hint()
|
||||
}
|
||||
|
||||
/// Look at the shared projects' sync engines (about once a second).
|
||||
fn tick_sync(&mut self) {
|
||||
if self.session.sync_poll(&mut self.ed, Instant::now()) {
|
||||
if self.st.home.is_some() {
|
||||
self.show_home();
|
||||
}
|
||||
if let Some(g) = &self.gfx {
|
||||
g.window.request_redraw();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn show_home(&mut self) {
|
||||
let mut hv = self.st.home.take().unwrap_or_default();
|
||||
hv.refresh(&self.session.store, crate::project::now_ms());
|
||||
hv.shared = self.session.shared_info();
|
||||
hv.default_server = self.default_server();
|
||||
hv.library = self.session.sync.library_active();
|
||||
self.st.home = Some(hv);
|
||||
self.st.project_name.clear();
|
||||
self.st.save_state.clear();
|
||||
@@ -329,7 +349,18 @@ impl App {
|
||||
HomeAction::Duplicate(dir) => {
|
||||
store.duplicate(&dir).map(|_| ()).map_err(|e| e.to_string())
|
||||
}
|
||||
HomeAction::Trash(dir) => store.trash(&dir).map(|_| ()).map_err(|e| e.to_string()),
|
||||
HomeAction::Trash(dir) => {
|
||||
// A trashed project leaves the share (and a library); restoring it brings
|
||||
// back a local copy.
|
||||
self.session.sync.drop_project(&dir);
|
||||
store.trash(&dir).map(|_| ()).map_err(|e| e.to_string())
|
||||
}
|
||||
HomeAction::Share(dir, server) => self.session.share_project(&dir, &server),
|
||||
HomeAction::Join(link) => self.session.join_shared(&link),
|
||||
HomeAction::Unshare(dir) => {
|
||||
self.session.sync.unshare(&dir);
|
||||
Ok(())
|
||||
}
|
||||
HomeAction::Restore(dir) => {
|
||||
store.restore(&dir).map(|_| ()).map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -337,6 +368,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);
|
||||
@@ -1261,10 +1307,15 @@ impl ApplicationHandler<UserEvent> for App {
|
||||
g.window.request_redraw();
|
||||
}
|
||||
}
|
||||
let wake = match (self.next_repaint, self.session.next_deadline()) {
|
||||
(Some(a), Some(b)) => Some(a.min(b)),
|
||||
(a, b) => a.or(b),
|
||||
};
|
||||
self.tick_sync();
|
||||
let wake = [
|
||||
self.next_repaint,
|
||||
self.session.next_deadline(),
|
||||
self.session.sync_deadline(),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.min();
|
||||
match wake {
|
||||
Some(t) if Instant::now() >= t => {
|
||||
if self.next_repaint.is_some_and(|r| r <= t) {
|
||||
|
||||
+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,127 @@
|
||||
//! `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 papercraft::mcp::store::Data;
|
||||
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>] [--data <dir>] [--library]
|
||||
|
||||
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)
|
||||
--data <dir> keep shared projects in <dir> (off without it): /p/{token}/...
|
||||
--library with --data: a personal library. Every project in <dir> is listed at
|
||||
/library and synced to every device that opens this server, with no
|
||||
link needed, so only run it where the network is the boundary
|
||||
--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>,
|
||||
data_dir: Option<PathBuf>,
|
||||
library: bool,
|
||||
}
|
||||
|
||||
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 data_dir = None;
|
||||
let mut library = false;
|
||||
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()?),
|
||||
"--data" => data_dir = Some(PathBuf::from(value()?)),
|
||||
"--library" => library = true,
|
||||
other => return Err(format!("unknown argument {other}\n\n{USAGE}")),
|
||||
}
|
||||
}
|
||||
Ok(Args {
|
||||
listen,
|
||||
static_dir,
|
||||
allow_origin,
|
||||
data_dir,
|
||||
library,
|
||||
})
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
if args.library && args.data_dir.is_none() {
|
||||
eprintln!("papercraft-relay: --library needs --data <dir>");
|
||||
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_with_data(
|
||||
listen,
|
||||
args.static_dir.clone(),
|
||||
args.allow_origin.clone(),
|
||||
args.data_dir.clone().map(|dir| Data {
|
||||
dir,
|
||||
library: args.library,
|
||||
}),
|
||||
) {
|
||||
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);
|
||||
|
||||
@@ -548,6 +548,20 @@ impl Editor {
|
||||
}
|
||||
}
|
||||
|
||||
/// Swap an instance root to point at `new_main` component, preserving overrides on children
|
||||
/// matched by name and kind.
|
||||
pub fn swap_instance(&mut self, inst: NodeId, new_main: NodeId) -> bool {
|
||||
if !self.is_instance_root(inst)
|
||||
|| !self.doc.get(new_main).is_some_and(|m| m.component)
|
||||
|| inst == new_main
|
||||
|| self.doc.is_ancestor(inst, new_main)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.edit("Swap instance", |tx| swap_instance_tx(tx, inst, new_main));
|
||||
true
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------- auto layout
|
||||
|
||||
/// Add auto layout (Shift+A): converts a selected frame, or wraps the selection in a new
|
||||
@@ -767,3 +781,52 @@ pub fn infer_layout(rects: &[Rect], fw: f64, fh: f64) -> AutoLayout {
|
||||
];
|
||||
l
|
||||
}
|
||||
|
||||
/// Swap `inst` to `new_main` inside an open transaction, matching child overrides by name/kind.
|
||||
pub fn swap_instance_tx(tx: &mut Tx, inst: NodeId, new_main: NodeId) -> bool {
|
||||
let Some(new_comp) = tx.doc().get(new_main).cloned() else {
|
||||
return false;
|
||||
};
|
||||
if !new_comp.component {
|
||||
return false;
|
||||
}
|
||||
let mut target_by_name: Vec<(String, &'static str, NodeId)> = Vec::new();
|
||||
for cid in tx.doc().subtree(new_main).into_iter().skip(1) {
|
||||
if let Some(cn) = tx.doc().get(cid) {
|
||||
target_by_name.push((cn.name.clone(), cn.kind.type_name(), cid));
|
||||
}
|
||||
}
|
||||
let mut used: HashSet<NodeId> = HashSet::new();
|
||||
let inst_children: Vec<NodeId> = tx.doc().subtree(inst).into_iter().skip(1).collect();
|
||||
for k in inst_children {
|
||||
let Some(kn) = tx.doc().get(k).cloned() else {
|
||||
continue;
|
||||
};
|
||||
let matched = target_by_name
|
||||
.iter()
|
||||
.find(|(name, kind, tid)| {
|
||||
!used.contains(tid) && *name == kn.name && *kind == kn.kind.type_name()
|
||||
})
|
||||
.map(|(_, _, tid)| *tid);
|
||||
if let Some(tid) = matched {
|
||||
used.insert(tid);
|
||||
if let Some(link) = tx.node_mut(k).and_then(|n| n.instance.as_mut()) {
|
||||
link.main = tid;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(root) = tx.node_mut(inst) {
|
||||
if let Some(link) = &mut root.instance {
|
||||
link.main = new_main;
|
||||
}
|
||||
if root
|
||||
.instance
|
||||
.as_ref()
|
||||
.is_none_or(|l| !l.overrides.iter().any(|o| o == "name"))
|
||||
{
|
||||
root.name = new_comp.name;
|
||||
}
|
||||
}
|
||||
sync_node(tx, inst, true);
|
||||
true
|
||||
}
|
||||
|
||||
+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);
|
||||
|
||||
@@ -160,12 +160,72 @@ impl<'a> Tx<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Rename a variable across all modes and update every fill linked to it.
|
||||
pub fn rename_variable(&mut self, old: &str, new: &str) -> usize {
|
||||
if old == new || new.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
self.snapshot_variables();
|
||||
if let Some(c) = self.doc.variables.remove(old) {
|
||||
self.doc.variables.insert(new.to_string(), c);
|
||||
}
|
||||
for m in self.doc.modes.values_mut() {
|
||||
if let Some(c) = m.remove(old) {
|
||||
m.insert(new.to_string(), c);
|
||||
}
|
||||
}
|
||||
let users: Vec<NodeId> = self
|
||||
.doc
|
||||
.nodes
|
||||
.values()
|
||||
.filter(|n| n.style.fills.iter().any(|p| p.var() == Some(old)))
|
||||
.map(|n| n.id)
|
||||
.collect();
|
||||
let mut count = 0;
|
||||
for id in users {
|
||||
if let Some(n) = self.node_mut(id) {
|
||||
for p in &mut n.style.fills {
|
||||
if let crate::model::Paint::Solid { var: Some(v), .. } = p
|
||||
&& v == old
|
||||
{
|
||||
*v = new.to_string();
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
/// Define or change a non-color design token.
|
||||
pub fn set_token(&mut self, name: &str, value: &str) {
|
||||
self.snapshot_variables();
|
||||
self.doc.tokens.insert(name.to_string(), value.to_string());
|
||||
}
|
||||
|
||||
/// Delete a non-color design token.
|
||||
pub fn remove_token(&mut self, name: &str) {
|
||||
self.snapshot_variables();
|
||||
self.doc.tokens.remove(name);
|
||||
}
|
||||
|
||||
/// Remove a variable mode. If it was active, switch to the first remaining mode (or None).
|
||||
pub fn remove_mode(&mut self, mode: &str) {
|
||||
self.snapshot_variables();
|
||||
self.doc.modes.remove(mode);
|
||||
if self.doc.mode.as_deref() == Some(mode) {
|
||||
let next = self.doc.modes.keys().next().cloned();
|
||||
match next {
|
||||
Some(m) => {
|
||||
self.set_mode(&m);
|
||||
}
|
||||
None => {
|
||||
self.doc.mode = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot_variables(&mut self) {
|
||||
if self.rec.variables_before.is_none() {
|
||||
self.rec.variables_before = Some(self.doc.var_state());
|
||||
|
||||
+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)
|
||||
}
|
||||
|
||||
+2528
File diff suppressed because it is too large
Load Diff
+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();
|
||||
}
|
||||
}
|
||||
|
||||
+139
-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,155 @@ 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 bytes = crate::vfs::read(path).map_err(|e| e.to_string())?;
|
||||
let ext = path
|
||||
.extension()
|
||||
.map(|e| e.to_string_lossy().to_lowercase())
|
||||
.unwrap_or_default();
|
||||
if ext == "fig" || crate::io::fig::is_fig(&bytes) {
|
||||
return crate::io::fig::import_fig_bytes(&stem, &bytes);
|
||||
}
|
||||
let text = String::from_utf8(bytes).map_err(|e| e.to_string())?;
|
||||
if crate::io::fig::is_figma_rest_json(&text) {
|
||||
return crate::io::fig::import_rest_json(&stem, &text);
|
||||
}
|
||||
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 == "fig" || crate::io::fig::is_fig(bytes) {
|
||||
return crate::io::fig::import_fig_bytes(&stem, bytes).map(|(n, p)| Imported::Pages(n, p));
|
||||
}
|
||||
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 t = text()?;
|
||||
if crate::io::fig::is_figma_rest_json(t) {
|
||||
return crate::io::fig::import_rest_json(&stem, t)
|
||||
.map(|(n, p)| Imported::Pages(n, p));
|
||||
}
|
||||
let doc = crate::io::excalidraw::import_scene(t)?;
|
||||
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,10 +1,13 @@
|
||||
pub mod atomic;
|
||||
pub mod backup;
|
||||
pub mod clipboard;
|
||||
pub mod excalidraw;
|
||||
pub mod export;
|
||||
pub mod fig;
|
||||
pub mod file;
|
||||
pub mod import;
|
||||
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");
|
||||
}
|
||||
}
|
||||
+388
-21
@@ -77,7 +77,14 @@ fn hug_size(doc: &Document, id: NodeId, axis: usize) -> f64 {
|
||||
.iter()
|
||||
.map(|line| {
|
||||
line.iter()
|
||||
.map(|k| get(&doc.get(*k).unwrap().rect, axis).1)
|
||||
.map(|k| {
|
||||
let kn = doc.get(*k).unwrap();
|
||||
match kn.layout_child.sizing[axis] {
|
||||
Sizing::Hug => hug_size(doc, *k, axis),
|
||||
Sizing::Fill => get(&kn.rect, axis).1,
|
||||
Sizing::Fixed => get(&kn.rect, axis).1,
|
||||
}
|
||||
})
|
||||
.fold(0.0, f64::max)
|
||||
})
|
||||
.collect();
|
||||
@@ -104,7 +111,11 @@ fn wrap_lines(
|
||||
let mut lines: Vec<Vec<NodeId>> = vec![Vec::new()];
|
||||
let mut x = 0.0;
|
||||
for k in kids {
|
||||
let s = get(&doc.get(*k).unwrap().rect, ma).1;
|
||||
let kn = doc.get(*k).unwrap();
|
||||
let s = match kn.layout_child.sizing[ma] {
|
||||
Sizing::Hug => hug_size(doc, *k, ma),
|
||||
_ => get(&kn.rect, ma).1,
|
||||
};
|
||||
let line = lines.last_mut().unwrap();
|
||||
if !line.is_empty() && x + l.gap + s > inner + 1e-9 {
|
||||
lines.push(vec![*k]);
|
||||
@@ -190,8 +201,37 @@ fn arrange(tx: &mut Tx, id: NodeId, l: &AutoLayout, rect: Rect, ma: usize, ca: u
|
||||
} else {
|
||||
vec![kids.clone()]
|
||||
};
|
||||
let mut cross_cursor = p0c;
|
||||
for line in lines {
|
||||
let line_cross_sizes: Vec<f64> = if l.wrap {
|
||||
lines
|
||||
.iter()
|
||||
.map(|line| {
|
||||
line.iter()
|
||||
.map(|k| {
|
||||
let kn = tx.doc().get(*k).unwrap();
|
||||
match kn.layout_child.sizing[ca] {
|
||||
Sizing::Hug => hug_size(tx.doc(), *k, ca),
|
||||
Sizing::Fill | Sizing::Fixed => get(&kn.rect, ca).1,
|
||||
}
|
||||
})
|
||||
.fold(0.0, f64::max)
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
vec![inner_c; lines.len()]
|
||||
};
|
||||
let mut cross_cursor = if l.wrap {
|
||||
let total_cross: f64 = line_cross_sizes.iter().sum::<f64>()
|
||||
+ l.cross_gap * lines.len().saturating_sub(1) as f64;
|
||||
let free_cross = (inner_c - total_cross).max(0.0);
|
||||
p0c + match l.align_cross {
|
||||
Align::Start => 0.0,
|
||||
Align::Center => free_cross * 0.5,
|
||||
Align::End => free_cross,
|
||||
}
|
||||
} else {
|
||||
p0c
|
||||
};
|
||||
for (li, line) in lines.into_iter().enumerate() {
|
||||
// Main sizes: fixed / hug as they are; fill shares the rest equally.
|
||||
let mut sizes: Vec<f64> = line
|
||||
.iter()
|
||||
@@ -199,7 +239,8 @@ fn arrange(tx: &mut Tx, id: NodeId, l: &AutoLayout, rect: Rect, ma: usize, ca: u
|
||||
let kn = tx.doc().get(*k).unwrap();
|
||||
match kn.layout_child.sizing[ma] {
|
||||
Sizing::Fill => 0.0,
|
||||
_ => get(&kn.rect, ma).1,
|
||||
Sizing::Hug => hug_size(tx.doc(), *k, ma),
|
||||
Sizing::Fixed => get(&kn.rect, ma).1,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -229,24 +270,25 @@ fn arrange(tx: &mut Tx, id: NodeId, l: &AutoLayout, rect: Rect, ma: usize, ca: u
|
||||
};
|
||||
(start, gap)
|
||||
};
|
||||
let line_cross = if l.wrap {
|
||||
line.iter()
|
||||
.map(|k| get(&tx.doc().get(*k).unwrap().rect, ca).1)
|
||||
.fold(0.0, f64::max)
|
||||
} else {
|
||||
inner_c
|
||||
};
|
||||
let lh = line_cross_sizes[li];
|
||||
for (i, k) in line.iter().enumerate() {
|
||||
let kn = tx.doc().get(*k).unwrap();
|
||||
let mut r = kn.rect;
|
||||
let cs = match kn.layout_child.sizing[ca] {
|
||||
Sizing::Fill => line_cross.max(0.0),
|
||||
_ => get(&r, ca).1,
|
||||
};
|
||||
let cpos = match l.align_cross {
|
||||
Align::Start => cross_cursor,
|
||||
Align::Center => cross_cursor + (line_cross - cs) * 0.5,
|
||||
Align::End => cross_cursor + line_cross - cs,
|
||||
let (cpos, cs) = match kn.layout_child.sizing[ca] {
|
||||
Sizing::Fill => (cross_cursor, lh.max(0.0)),
|
||||
Sizing::Fixed | Sizing::Hug => {
|
||||
let ch = if kn.layout_child.sizing[ca] == Sizing::Hug {
|
||||
hug_size(tx.doc(), *k, ca)
|
||||
} else {
|
||||
get(&r, ca).1
|
||||
};
|
||||
let cy = match l.align_cross {
|
||||
Align::Start => cross_cursor,
|
||||
Align::Center => cross_cursor + (lh - ch) * 0.5,
|
||||
Align::End => cross_cursor + lh - ch,
|
||||
};
|
||||
(cy, ch)
|
||||
}
|
||||
};
|
||||
set(&mut r, ma, cursor, sizes[i]);
|
||||
set(&mut r, ca, cpos, cs);
|
||||
@@ -266,7 +308,7 @@ fn arrange(tx: &mut Tx, id: NodeId, l: &AutoLayout, rect: Rect, ma: usize, ca: u
|
||||
}
|
||||
}
|
||||
}
|
||||
cross_cursor += line_cross + l.cross_gap;
|
||||
cross_cursor += lh + l.cross_gap;
|
||||
}
|
||||
// Fill children that are auto-layout frames lay out their own content again.
|
||||
for k in kids {
|
||||
@@ -280,6 +322,149 @@ fn arrange(tx: &mut Tx, id: NodeId, l: &AutoLayout, rect: Rect, ma: usize, ca: u
|
||||
// Absolute children keep their constraints-based position.
|
||||
}
|
||||
|
||||
/// Lay out `flow_children(tx.doc(), id)` into a `cols`-column CSS-style grid inside frame `id`.
|
||||
/// Returns true if anything moved or resized.
|
||||
pub fn layout_grid(tx: &mut Tx, id: NodeId, cols: usize, col_gap: f64, row_gap: f64) -> bool {
|
||||
let Some(n0) = tx.doc().get(id).cloned() else {
|
||||
return false;
|
||||
};
|
||||
let cols = cols.max(1);
|
||||
let before: Vec<(NodeId, Rect)> = tx
|
||||
.doc()
|
||||
.subtree(id)
|
||||
.iter()
|
||||
.filter_map(|i| tx.doc().get(*i).map(|n| (*i, n.rect)))
|
||||
.collect();
|
||||
|
||||
let kids = flow_children(tx.doc(), id);
|
||||
for k in &kids {
|
||||
if tx.doc().get(*k).is_some_and(|c| c.layout.is_some()) {
|
||||
layout_frame(tx, *k);
|
||||
}
|
||||
}
|
||||
|
||||
let n = tx.doc().get(id).unwrap().clone();
|
||||
let pad = n.layout.unwrap_or_default().padding;
|
||||
let (pt, pr, pb, pl) = (pad[0], pad[1], pad[2], pad[3]);
|
||||
let rows = kids.len().div_ceil(cols);
|
||||
|
||||
let mut col_w: Vec<f64> = vec![0.0; cols];
|
||||
let mut col_fill = vec![false; cols];
|
||||
for (idx, k) in kids.iter().enumerate() {
|
||||
let c = idx % cols;
|
||||
let kn = tx.doc().get(*k).unwrap();
|
||||
if kn.layout_child.sizing[0] == Sizing::Fill {
|
||||
col_fill[c] = true;
|
||||
} else {
|
||||
let w = if kn.layout_child.sizing[0] == Sizing::Hug {
|
||||
hug_size(tx.doc(), *k, 0)
|
||||
} else {
|
||||
kn.rect.w
|
||||
};
|
||||
col_w[c] = col_w[c].max(w);
|
||||
}
|
||||
}
|
||||
|
||||
let total_col_gaps = col_gap * cols.saturating_sub(1) as f64;
|
||||
let fill_cols: Vec<usize> = (0..cols).filter(|&c| col_fill[c]).collect();
|
||||
if !fill_cols.is_empty() {
|
||||
let fixed_w: f64 = (0..cols).filter(|&c| !col_fill[c]).map(|c| col_w[c]).sum();
|
||||
let inner_w = (n.rect.w - pl - pr).max(0.0);
|
||||
let share = ((inner_w - total_col_gaps - fixed_w) / fill_cols.len() as f64).max(0.0);
|
||||
for c in fill_cols {
|
||||
col_w[c] = share;
|
||||
}
|
||||
}
|
||||
|
||||
let mut row_h: Vec<f64> = vec![0.0; rows];
|
||||
for (idx, k) in kids.iter().enumerate() {
|
||||
let r = idx / cols;
|
||||
let kn = tx.doc().get(*k).unwrap();
|
||||
let h = if kn.layout_child.sizing[1] == Sizing::Hug {
|
||||
hug_size(tx.doc(), *k, 1)
|
||||
} else {
|
||||
kn.rect.h
|
||||
};
|
||||
row_h[r] = row_h[r].max(h);
|
||||
}
|
||||
|
||||
let total_row_gaps = row_gap * rows.saturating_sub(1) as f64;
|
||||
let mut rect = n.rect;
|
||||
if n.layout_child.sizing[0] == Sizing::Hug {
|
||||
let tracks_w = if kids.is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
col_w.iter().sum::<f64>() + total_col_gaps
|
||||
};
|
||||
rect.w = (pl + pr + tracks_w).max(0.0);
|
||||
}
|
||||
if n.layout_child.sizing[1] == Sizing::Hug {
|
||||
let tracks_h = if rows == 0 {
|
||||
0.0
|
||||
} else {
|
||||
row_h.iter().sum::<f64>() + total_row_gaps
|
||||
};
|
||||
rect.h = (pt + pb + tracks_h).max(0.0);
|
||||
}
|
||||
if rect != n0.rect {
|
||||
tx.node_mut(id).unwrap().rect = rect;
|
||||
}
|
||||
|
||||
let mut col_x = vec![0.0; cols];
|
||||
let mut cx = pl;
|
||||
for c in 0..cols {
|
||||
col_x[c] = cx;
|
||||
cx += col_w[c] + col_gap;
|
||||
}
|
||||
let mut row_y = vec![0.0; rows];
|
||||
let mut ry = pt;
|
||||
for r in 0..rows {
|
||||
row_y[r] = ry;
|
||||
ry += row_h[r] + row_gap;
|
||||
}
|
||||
|
||||
for (idx, k) in kids.iter().enumerate() {
|
||||
let c = idx % cols;
|
||||
let r = idx / cols;
|
||||
let kn = tx.doc().get(*k).unwrap();
|
||||
let mut cr = kn.rect;
|
||||
cr.x = col_x[c];
|
||||
cr.y = row_y[r];
|
||||
if kn.layout_child.sizing[0] == Sizing::Fill {
|
||||
cr.w = col_w[c];
|
||||
} else if kn.layout_child.sizing[0] == Sizing::Hug {
|
||||
cr.w = hug_size(tx.doc(), *k, 0);
|
||||
}
|
||||
if kn.layout_child.sizing[1] == Sizing::Fill {
|
||||
cr.h = row_h[r];
|
||||
} else if kn.layout_child.sizing[1] == Sizing::Hug {
|
||||
cr.h = hug_size(tx.doc(), *k, 1);
|
||||
}
|
||||
if cr != kn.rect {
|
||||
let resized = (cr.w - kn.rect.w).abs() > 1e-9 || (cr.h - kn.rect.h).abs() > 1e-9;
|
||||
let old = kn.rect;
|
||||
let cn = tx.node_mut(*k).unwrap();
|
||||
if resized {
|
||||
cn.set_rect_scaling(cr);
|
||||
} else {
|
||||
cn.rect = cr;
|
||||
}
|
||||
if resized {
|
||||
resize_children(tx, *k, old, cr);
|
||||
fit_text_height(tx, *k);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let after: Vec<(NodeId, Rect)> = tx
|
||||
.doc()
|
||||
.subtree(id)
|
||||
.iter()
|
||||
.filter_map(|i| tx.doc().get(*i).map(|n| (*i, n.rect)))
|
||||
.collect();
|
||||
before != after
|
||||
}
|
||||
|
||||
/// Re-measure a text node's height after its width changed.
|
||||
pub fn fit_text_height(tx: &mut Tx, id: NodeId) {
|
||||
let Some(n) = tx.doc().get(id) else { return };
|
||||
@@ -640,4 +825,186 @@ mod tests {
|
||||
assert_eq!(r(3), Rect::new(120.0, 60.0, 40.0, 20.0));
|
||||
assert_eq!(r(4), Rect::new(40.0, 20.0, 80.0, 40.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_lines_cross_align_center_and_end() {
|
||||
let base = AutoLayout {
|
||||
wrap: true,
|
||||
gap: 10.0,
|
||||
cross_gap: 10.0,
|
||||
padding: [0.0; 4],
|
||||
align_cross: Align::Center,
|
||||
..Default::default()
|
||||
};
|
||||
// Frame 300x200: line 0 has 20 & 30 (lh=30), line 1 has 20 (lh=20).
|
||||
// total_cross = 30 + 10 + 20 = 60, free_cross = 140.
|
||||
let (doc_c, _, kc) = doc_with(
|
||||
base,
|
||||
FX,
|
||||
&[(120.0, 20.0, FX), (120.0, 30.0, FX), (120.0, 20.0, FX)],
|
||||
);
|
||||
assert_eq!(
|
||||
doc_c.get(kc[0]).unwrap().rect,
|
||||
Rect::new(0.0, 75.0, 120.0, 20.0)
|
||||
);
|
||||
assert_eq!(
|
||||
doc_c.get(kc[1]).unwrap().rect,
|
||||
Rect::new(130.0, 70.0, 120.0, 30.0)
|
||||
);
|
||||
assert_eq!(
|
||||
doc_c.get(kc[2]).unwrap().rect,
|
||||
Rect::new(0.0, 110.0, 120.0, 20.0)
|
||||
);
|
||||
|
||||
let end_l = AutoLayout {
|
||||
align_cross: Align::End,
|
||||
..base
|
||||
};
|
||||
let (doc_e, _, ke) = doc_with(
|
||||
end_l,
|
||||
FX,
|
||||
&[(120.0, 20.0, FX), (120.0, 30.0, FX), (120.0, 20.0, FX)],
|
||||
);
|
||||
assert_eq!(
|
||||
doc_e.get(ke[0]).unwrap().rect,
|
||||
Rect::new(0.0, 150.0, 120.0, 20.0)
|
||||
);
|
||||
assert_eq!(
|
||||
doc_e.get(ke[1]).unwrap().rect,
|
||||
Rect::new(130.0, 140.0, 120.0, 30.0)
|
||||
);
|
||||
assert_eq!(
|
||||
doc_e.get(ke[2]).unwrap().rect,
|
||||
Rect::new(0.0, 180.0, 120.0, 20.0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_per_line_item_align_and_fill_stretch() {
|
||||
let l = AutoLayout {
|
||||
wrap: true,
|
||||
gap: 10.0,
|
||||
cross_gap: 10.0,
|
||||
padding: [10.0; 4],
|
||||
align_cross: Align::Center,
|
||||
..Default::default()
|
||||
};
|
||||
// Inner width = 300 - 20 = 280.
|
||||
// Line 0: three 80w items (80*3 + 20 = 260 <= 280), heights: Fill, 20, 40 -> lh = 40.
|
||||
// Line 1: two 120w items (120*2 + 10 = 250 <= 280), heights: 60, Fill -> lh = 60.
|
||||
let (doc, f, k) = doc_with(
|
||||
l,
|
||||
[Sizing::Fixed, Sizing::Hug],
|
||||
&[
|
||||
(80.0, 5.0, [Sizing::Fixed, Sizing::Fill]),
|
||||
(80.0, 20.0, FX),
|
||||
(80.0, 40.0, FX),
|
||||
(120.0, 60.0, FX),
|
||||
(120.0, 5.0, [Sizing::Fixed, Sizing::Fill]),
|
||||
],
|
||||
);
|
||||
assert_eq!(doc.get(f).unwrap().rect.h, 10.0 + 40.0 + 10.0 + 60.0 + 10.0);
|
||||
assert_eq!(
|
||||
doc.get(k[0]).unwrap().rect,
|
||||
Rect::new(10.0, 10.0, 80.0, 40.0)
|
||||
);
|
||||
assert_eq!(
|
||||
doc.get(k[1]).unwrap().rect,
|
||||
Rect::new(100.0, 20.0, 80.0, 20.0)
|
||||
);
|
||||
assert_eq!(
|
||||
doc.get(k[2]).unwrap().rect,
|
||||
Rect::new(190.0, 10.0, 80.0, 40.0)
|
||||
);
|
||||
assert_eq!(
|
||||
doc.get(k[3]).unwrap().rect,
|
||||
Rect::new(10.0, 60.0, 120.0, 60.0)
|
||||
);
|
||||
assert_eq!(
|
||||
doc.get(k[4]).unwrap().rect,
|
||||
Rect::new(140.0, 60.0, 120.0, 60.0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_grid_columns_fill_and_hug() {
|
||||
// 2-column grid with Hug height and one Fill column.
|
||||
let mut doc = Document::default();
|
||||
let mut rec = Recording::new("t", &doc);
|
||||
let mut tx = Tx::new(&mut doc, &mut rec);
|
||||
let fid = tx.alloc_id();
|
||||
let mut f = Node::new(
|
||||
fid,
|
||||
NodeKind::Frame { clip: true },
|
||||
Rect::new(0.0, 0.0, 300.0, 50.0),
|
||||
);
|
||||
f.layout = Some(AutoLayout {
|
||||
padding: [10.0, 10.0, 10.0, 10.0],
|
||||
..Default::default()
|
||||
});
|
||||
f.layout_child.sizing = [Sizing::Fixed, Sizing::Hug];
|
||||
tx.insert(f, None, None);
|
||||
|
||||
let specs = [
|
||||
(80.0, 30.0, FX),
|
||||
(20.0, 40.0, [Sizing::Fill, Sizing::Fixed]),
|
||||
(100.0, 50.0, FX),
|
||||
(20.0, 10.0, [Sizing::Fill, Sizing::Fill]),
|
||||
];
|
||||
let mut ids = Vec::new();
|
||||
for (w, h, s) in specs {
|
||||
let id = tx.alloc_id();
|
||||
let mut n = Node::new(id, NodeKind::Rect, Rect::new(0.0, 0.0, w, h));
|
||||
n.layout_child.sizing = s;
|
||||
tx.insert(n, Some(fid), None);
|
||||
ids.push(id);
|
||||
}
|
||||
assert!(layout_grid(&mut tx, fid, 2, 20.0, 15.0));
|
||||
// Col 0 max fixed width = 100. Inner width = 280, col_gap = 20 -> Col 1 Fill width = 160.
|
||||
// Row 0 height = max(30, 40) = 40; Row 1 height = max(50, 10) = 50.
|
||||
// Hug height = 10 + 40 + 15 + 50 + 10 = 125.
|
||||
assert_eq!(tx.doc().get(fid).unwrap().rect.h, 125.0);
|
||||
assert_eq!(
|
||||
tx.doc().get(ids[0]).unwrap().rect,
|
||||
Rect::new(10.0, 10.0, 80.0, 30.0)
|
||||
);
|
||||
assert_eq!(
|
||||
tx.doc().get(ids[1]).unwrap().rect,
|
||||
Rect::new(130.0, 10.0, 160.0, 40.0)
|
||||
);
|
||||
assert_eq!(
|
||||
tx.doc().get(ids[2]).unwrap().rect,
|
||||
Rect::new(10.0, 65.0, 100.0, 50.0)
|
||||
);
|
||||
assert_eq!(
|
||||
tx.doc().get(ids[3]).unwrap().rect,
|
||||
Rect::new(130.0, 65.0, 160.0, 50.0)
|
||||
);
|
||||
|
||||
// Re-layout as 3-column grid (col 0 has Fixed 80 & Fill -> Fill; col 1 has Fill -> Fill; col 2 has Fixed 100).
|
||||
// Inner width = 280, gaps = 2 * 10 = 20, fixed col 2 = 100 -> remaining 160 shared by col 0 and col 1 (80 each).
|
||||
assert!(layout_grid(&mut tx, fid, 3, 10.0, 10.0));
|
||||
// Row 0: ids[0](30), ids[1](40), ids[2](50) -> row_h[0] = 50.
|
||||
// Row 1: ids[3](50 from previous stretch) -> row_h[1] = 50.
|
||||
assert_eq!(
|
||||
tx.doc().get(fid).unwrap().rect.h,
|
||||
10.0 + 50.0 + 10.0 + 50.0 + 10.0
|
||||
);
|
||||
assert_eq!(
|
||||
tx.doc().get(ids[0]).unwrap().rect,
|
||||
Rect::new(10.0, 10.0, 80.0, 30.0)
|
||||
);
|
||||
assert_eq!(
|
||||
tx.doc().get(ids[1]).unwrap().rect,
|
||||
Rect::new(100.0, 10.0, 80.0, 40.0)
|
||||
);
|
||||
assert_eq!(
|
||||
tx.doc().get(ids[2]).unwrap().rect,
|
||||
Rect::new(190.0, 10.0, 100.0, 50.0)
|
||||
);
|
||||
assert_eq!(
|
||||
tx.doc().get(ids[3]).unwrap().rect,
|
||||
Rect::new(10.0, 70.0, 80.0, 50.0)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -19,7 +20,11 @@ pub mod model;
|
||||
pub mod project;
|
||||
pub mod render;
|
||||
pub mod session;
|
||||
pub mod sync;
|
||||
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]
|
||||
|
||||
+1501
File diff suppressed because it is too large
Load Diff
+678
@@ -0,0 +1,678 @@
|
||||
//! Structural and pixel diffing between frames (`diff_subtrees`, `diff_rgba`).
|
||||
|
||||
use crate::geom::V2;
|
||||
use crate::model::{
|
||||
BlendMode, Direction, Document, Node, NodeId, NodeKind, Paint, Stop, TextAlign,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
/// One property difference on a paired node.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct PropChange {
|
||||
pub field: &'static str,
|
||||
pub before: String,
|
||||
pub after: String,
|
||||
}
|
||||
|
||||
/// Structural or property diff entry for a single node.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct NodeDiff {
|
||||
pub id_a: Option<NodeId>,
|
||||
pub id_b: Option<NodeId>,
|
||||
pub path: String,
|
||||
pub name: String,
|
||||
pub kind: &'static str,
|
||||
pub status: &'static str,
|
||||
pub changes: Vec<PropChange>,
|
||||
}
|
||||
|
||||
/// Summary and per-node list of differences between two subtrees.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct FrameDiff {
|
||||
pub added: usize,
|
||||
pub removed: usize,
|
||||
pub modified: usize,
|
||||
pub unchanged: usize,
|
||||
pub nodes: Vec<NodeDiff>,
|
||||
}
|
||||
|
||||
/// Result of a pixel-by-pixel RGBA comparison.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PixelDiff {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub total_pixels: usize,
|
||||
pub mismatched_pixels: usize,
|
||||
pub ratio: f64,
|
||||
pub bounds: Option<[u32; 4]>,
|
||||
pub diff_png: Vec<u8>,
|
||||
}
|
||||
|
||||
fn fmt_f64(v: f64) -> String {
|
||||
let r = (v * 100.0).round() / 100.0;
|
||||
if r == 0.0 {
|
||||
return "0".into();
|
||||
}
|
||||
if (r - r.round()).abs() < 1e-9 {
|
||||
format!("{}", r as i64)
|
||||
} else {
|
||||
format!("{r}")
|
||||
}
|
||||
}
|
||||
|
||||
fn fmt_v2(v: V2) -> String {
|
||||
format!("({}, {})", fmt_f64(v.x), fmt_f64(v.y))
|
||||
}
|
||||
|
||||
fn fmt_stops(stops: &[Stop]) -> String {
|
||||
stops
|
||||
.iter()
|
||||
.map(|s| format!("{}@{}", s.color.to_hex(), fmt_f64(s.pos)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
|
||||
fn fmt_fills(n: &Node) -> String {
|
||||
if n.style.fills.is_empty() {
|
||||
return "none".into();
|
||||
}
|
||||
n.style
|
||||
.fills
|
||||
.iter()
|
||||
.map(|p| match p {
|
||||
Paint::Solid {
|
||||
color,
|
||||
var: Some(v),
|
||||
} => format!("var(--{v}) ({})", color.to_hex()),
|
||||
Paint::Solid { color, var: None } => color.to_hex(),
|
||||
Paint::Linear { stops, from, to } => {
|
||||
format!(
|
||||
"linear({}, {} -> {})",
|
||||
fmt_stops(stops),
|
||||
fmt_v2(*from),
|
||||
fmt_v2(*to)
|
||||
)
|
||||
}
|
||||
Paint::Radial {
|
||||
stops,
|
||||
center,
|
||||
radius,
|
||||
} => {
|
||||
format!(
|
||||
"radial({}, center={}, radius={})",
|
||||
fmt_stops(stops),
|
||||
fmt_v2(*center),
|
||||
fmt_v2(*radius)
|
||||
)
|
||||
}
|
||||
Paint::Image {
|
||||
asset,
|
||||
mode,
|
||||
opacity,
|
||||
} => {
|
||||
format!("image({asset}, {mode:?}, {})", fmt_f64(*opacity))
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
|
||||
fn fmt_stroke(n: &Node) -> String {
|
||||
match n.style.stroke {
|
||||
Some(c) if n.style.stroke_width > 0.0 => {
|
||||
format!("{} {}px", c.to_hex(), fmt_f64(n.style.stroke_width))
|
||||
}
|
||||
_ => "none".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn fmt_corner_radius(n: &Node) -> String {
|
||||
match n.style.radii {
|
||||
Some([tl, tr, br, bl]) => {
|
||||
if (tl - tr).abs() < 1e-9 && (tr - br).abs() < 1e-9 && (br - bl).abs() < 1e-9 {
|
||||
fmt_f64(tl)
|
||||
} else {
|
||||
format!(
|
||||
"{} {} {} {}",
|
||||
fmt_f64(tl),
|
||||
fmt_f64(tr),
|
||||
fmt_f64(br),
|
||||
fmt_f64(bl)
|
||||
)
|
||||
}
|
||||
}
|
||||
None => fmt_f64(n.style.corner_radius),
|
||||
}
|
||||
}
|
||||
|
||||
fn fmt_opacity(n: &Node) -> String {
|
||||
if n.style.blend == BlendMode::Normal {
|
||||
fmt_f64(n.style.opacity)
|
||||
} else {
|
||||
format!("{} {}", fmt_f64(n.style.opacity), n.style.blend.css())
|
||||
}
|
||||
}
|
||||
|
||||
fn fmt_font_props(n: &Node) -> Option<String> {
|
||||
match &n.kind {
|
||||
NodeKind::Text {
|
||||
font_size, align, ..
|
||||
} => {
|
||||
let mut parts = vec![format!("{}px", fmt_f64(*font_size))];
|
||||
if n.text_style.bold {
|
||||
parts.push("bold".into());
|
||||
}
|
||||
if n.text_style.italic {
|
||||
parts.push("italic".into());
|
||||
}
|
||||
let al = match align {
|
||||
TextAlign::Left => "left",
|
||||
TextAlign::Center => "center",
|
||||
TextAlign::Right => "right",
|
||||
};
|
||||
parts.push(al.into());
|
||||
Some(parts.join(" "))
|
||||
}
|
||||
NodeKind::Sticky { font_size, .. } => Some(format!("{}px", fmt_f64(*font_size))),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn fmt_layout(n: &Node) -> String {
|
||||
let Some(l) = n.layout else {
|
||||
return "none".into();
|
||||
};
|
||||
let dir = match l.direction {
|
||||
Direction::Horizontal => "row",
|
||||
Direction::Vertical => "column",
|
||||
};
|
||||
let gap = if l.gap_auto {
|
||||
"auto".into()
|
||||
} else {
|
||||
fmt_f64(l.gap)
|
||||
};
|
||||
let [pt, pr, pb, pl] = l.padding;
|
||||
let pad = format!(
|
||||
"{},{},{},{}",
|
||||
fmt_f64(pt),
|
||||
fmt_f64(pr),
|
||||
fmt_f64(pb),
|
||||
fmt_f64(pl)
|
||||
);
|
||||
let wrap = if l.wrap { " wrap" } else { "" };
|
||||
format!(
|
||||
"{dir}{wrap} gap={gap} pad={pad} align={:?}/{:?}",
|
||||
l.align_main, l.align_cross
|
||||
)
|
||||
.to_lowercase()
|
||||
}
|
||||
|
||||
fn compare_node_props(na: &Node, nb: &Node, is_root: bool) -> Vec<PropChange> {
|
||||
let mut out = Vec::new();
|
||||
let mut check = |field: &'static str, before: String, after: String| {
|
||||
if before != after {
|
||||
out.push(PropChange {
|
||||
field,
|
||||
before,
|
||||
after,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
check(
|
||||
"size",
|
||||
format!("{} × {}", fmt_f64(na.rect.w), fmt_f64(na.rect.h)),
|
||||
format!("{} × {}", fmt_f64(nb.rect.w), fmt_f64(nb.rect.h)),
|
||||
);
|
||||
|
||||
if !is_root || (na.parent.is_some() && nb.parent.is_some()) {
|
||||
check(
|
||||
"position",
|
||||
format!("{}, {}", fmt_f64(na.rect.x), fmt_f64(na.rect.y)),
|
||||
format!("{}, {}", fmt_f64(nb.rect.x), fmt_f64(nb.rect.y)),
|
||||
);
|
||||
}
|
||||
|
||||
check("fills", fmt_fills(na), fmt_fills(nb));
|
||||
check("stroke", fmt_stroke(na), fmt_stroke(nb));
|
||||
check(
|
||||
"corner_radius",
|
||||
fmt_corner_radius(na),
|
||||
fmt_corner_radius(nb),
|
||||
);
|
||||
check("opacity", fmt_opacity(na), fmt_opacity(nb));
|
||||
|
||||
if let (Some(ta), Some(tb)) = (na.kind.text(), nb.kind.text()) {
|
||||
check("text", ta.clone(), tb.clone());
|
||||
}
|
||||
if let (Some(fa), Some(fb)) = (fmt_font_props(na), fmt_font_props(nb)) {
|
||||
check("font_size", fa, fb);
|
||||
}
|
||||
|
||||
check("layout", fmt_layout(na), fmt_layout(nb));
|
||||
check("visible", na.visible.to_string(), nb.visible.to_string());
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
fn record_unmatched(doc: &Document, id: NodeId, path: String, added: bool, diff: &mut FrameDiff) {
|
||||
let Some(n) = doc.get(id) else { return };
|
||||
if added {
|
||||
diff.added += 1;
|
||||
} else {
|
||||
diff.removed += 1;
|
||||
}
|
||||
diff.nodes.push(NodeDiff {
|
||||
id_a: (!added).then_some(id),
|
||||
id_b: added.then_some(id),
|
||||
path: path.clone(),
|
||||
name: n.name.clone(),
|
||||
kind: n.kind.type_name(),
|
||||
status: if added { "added" } else { "removed" },
|
||||
changes: Vec::new(),
|
||||
});
|
||||
for &child_id in &n.children {
|
||||
if let Some(child) = doc.get(child_id) {
|
||||
let child_path = format!("{path} > {}", child.name);
|
||||
record_unmatched(doc, child_id, child_path, added, diff);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn walk_pair(
|
||||
doc_a: &Document,
|
||||
id_a: NodeId,
|
||||
doc_b: &Document,
|
||||
id_b: NodeId,
|
||||
path: String,
|
||||
is_root: bool,
|
||||
diff: &mut FrameDiff,
|
||||
) {
|
||||
let (Some(na), Some(nb)) = (doc_a.get(id_a), doc_b.get(id_b)) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let changes = compare_node_props(na, nb, is_root);
|
||||
if changes.is_empty() {
|
||||
diff.unchanged += 1;
|
||||
} else {
|
||||
diff.modified += 1;
|
||||
diff.nodes.push(NodeDiff {
|
||||
id_a: Some(id_a),
|
||||
id_b: Some(id_b),
|
||||
path: path.clone(),
|
||||
name: nb.name.clone(),
|
||||
kind: nb.kind.type_name(),
|
||||
status: "modified",
|
||||
changes,
|
||||
});
|
||||
}
|
||||
|
||||
let mut b_counts: HashMap<(&str, &'static str), usize> = HashMap::new();
|
||||
let mut b_map: HashMap<(&str, &'static str, usize), NodeId> = HashMap::new();
|
||||
for &cid_b in &nb.children {
|
||||
if let Some(cb) = doc_b.get(cid_b) {
|
||||
let key = (cb.name.as_str(), cb.kind.type_name());
|
||||
let occ = *b_counts.entry(key).and_modify(|c| *c += 1).or_insert(0);
|
||||
b_map.insert((key.0, key.1, occ), cid_b);
|
||||
}
|
||||
}
|
||||
|
||||
let mut a_counts: HashMap<(&str, &'static str), usize> = HashMap::new();
|
||||
let mut matched_b: HashSet<NodeId> = HashSet::new();
|
||||
|
||||
for &cid_a in &na.children {
|
||||
let Some(ca) = doc_a.get(cid_a) else { continue };
|
||||
let key = (ca.name.as_str(), ca.kind.type_name());
|
||||
let occ = *a_counts.entry(key).and_modify(|c| *c += 1).or_insert(0);
|
||||
let child_path = format!("{path} > {}", ca.name);
|
||||
if let Some(&cid_b) = b_map.get(&(key.0, key.1, occ)) {
|
||||
matched_b.insert(cid_b);
|
||||
walk_pair(doc_a, cid_a, doc_b, cid_b, child_path, false, diff);
|
||||
} else {
|
||||
record_unmatched(doc_a, cid_a, child_path, false, diff);
|
||||
}
|
||||
}
|
||||
|
||||
for &cid_b in &nb.children {
|
||||
if matched_b.contains(&cid_b) {
|
||||
continue;
|
||||
}
|
||||
if let Some(cb) = doc_b.get(cid_b) {
|
||||
let child_path = format!("{path} > {}", cb.name);
|
||||
record_unmatched(doc_b, cid_b, child_path, true, diff);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compare two frames or subtrees recursively, matching siblings by `(name, kind, occurrence)`.
|
||||
pub fn diff_subtrees(
|
||||
doc_a: &Document,
|
||||
root_a: NodeId,
|
||||
doc_b: &Document,
|
||||
root_b: NodeId,
|
||||
) -> Result<FrameDiff, String> {
|
||||
if doc_a.get(root_a).is_none() {
|
||||
return Err(format!("node {root_a} not found in first document"));
|
||||
}
|
||||
let nb = doc_b
|
||||
.get(root_b)
|
||||
.ok_or_else(|| format!("node {root_b} not found in second document"))?;
|
||||
|
||||
let mut diff = FrameDiff {
|
||||
added: 0,
|
||||
removed: 0,
|
||||
modified: 0,
|
||||
unchanged: 0,
|
||||
nodes: Vec::new(),
|
||||
};
|
||||
walk_pair(
|
||||
doc_a,
|
||||
root_a,
|
||||
doc_b,
|
||||
root_b,
|
||||
nb.name.clone(),
|
||||
true,
|
||||
&mut diff,
|
||||
);
|
||||
Ok(diff)
|
||||
}
|
||||
|
||||
/// Serialize a [`FrameDiff`] into a clean JSON value for MCP output.
|
||||
pub fn to_json(d: &FrameDiff) -> Value {
|
||||
let changes: Vec<Value> = d
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|n| {
|
||||
let mut obj = json!({
|
||||
"path": n.path,
|
||||
"name": n.name,
|
||||
"type": n.kind,
|
||||
"status": n.status,
|
||||
});
|
||||
if let Some(id_a) = n.id_a {
|
||||
obj["idA"] = json!(id_a.0);
|
||||
}
|
||||
if let Some(id_b) = n.id_b {
|
||||
obj["idB"] = json!(id_b.0);
|
||||
}
|
||||
if !n.changes.is_empty() {
|
||||
let props: Vec<Value> = n
|
||||
.changes
|
||||
.iter()
|
||||
.map(|c| {
|
||||
json!({
|
||||
"field": c.field,
|
||||
"before": c.before,
|
||||
"after": c.after,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
obj["changes"] = Value::Array(props);
|
||||
}
|
||||
obj
|
||||
})
|
||||
.collect();
|
||||
json!({
|
||||
"added": d.added,
|
||||
"removed": d.removed,
|
||||
"modified": d.modified,
|
||||
"unchanged": d.unchanged,
|
||||
"changes": changes,
|
||||
})
|
||||
}
|
||||
|
||||
/// Compare two RGBA8 buffers over their union dimensions and return a visual diff PNG.
|
||||
pub fn diff_rgba(
|
||||
a: &[u8],
|
||||
w_a: u32,
|
||||
h_a: u32,
|
||||
b: &[u8],
|
||||
w_b: u32,
|
||||
h_b: u32,
|
||||
threshold: u8,
|
||||
) -> Result<PixelDiff, String> {
|
||||
let expected_a = (w_a as usize)
|
||||
.checked_mul(h_a as usize)
|
||||
.and_then(|px| px.checked_mul(4))
|
||||
.ok_or_else(|| "overflow in image A dimensions".to_string())?;
|
||||
let expected_b = (w_b as usize)
|
||||
.checked_mul(h_b as usize)
|
||||
.and_then(|px| px.checked_mul(4))
|
||||
.ok_or_else(|| "overflow in image B dimensions".to_string())?;
|
||||
if a.len() < expected_a {
|
||||
return Err(format!(
|
||||
"image A buffer too short: got {}, expected {expected_a}",
|
||||
a.len()
|
||||
));
|
||||
}
|
||||
if b.len() < expected_b {
|
||||
return Err(format!(
|
||||
"image B buffer too short: got {}, expected {expected_b}",
|
||||
b.len()
|
||||
));
|
||||
}
|
||||
|
||||
let width = w_a.max(w_b);
|
||||
let height = h_a.max(h_b);
|
||||
if width == 0 || height == 0 {
|
||||
return Err("cannot diff zero-sized images".into());
|
||||
}
|
||||
|
||||
let total_pixels = (width as usize) * (height as usize);
|
||||
let mut diff_buf = vec![0u8; total_pixels * 4];
|
||||
let mut mismatched_pixels = 0usize;
|
||||
let mut min_x = u32::MAX;
|
||||
let mut min_y = u32::MAX;
|
||||
let mut max_x = 0u32;
|
||||
let mut max_y = 0u32;
|
||||
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let pa = if x < w_a && y < h_a {
|
||||
let idx = ((y * w_a + x) as usize) * 4;
|
||||
[a[idx], a[idx + 1], a[idx + 2], a[idx + 3]]
|
||||
} else {
|
||||
[0, 0, 0, 0]
|
||||
};
|
||||
let pb = if x < w_b && y < h_b {
|
||||
let idx = ((y * w_b + x) as usize) * 4;
|
||||
[b[idx], b[idx + 1], b[idx + 2], b[idx + 3]]
|
||||
} else {
|
||||
[0, 0, 0, 0]
|
||||
};
|
||||
|
||||
let differs = (0..4).any(|c| pa[c].abs_diff(pb[c]) > threshold);
|
||||
let out_idx = ((y * width + x) as usize) * 4;
|
||||
if differs {
|
||||
mismatched_pixels += 1;
|
||||
min_x = min_x.min(x);
|
||||
min_y = min_y.min(y);
|
||||
max_x = max_x.max(x);
|
||||
max_y = max_y.max(y);
|
||||
diff_buf[out_idx..out_idx + 4].copy_from_slice(&[239, 68, 68, 255]);
|
||||
} else {
|
||||
let alpha = pa[3] as f64 / 255.0;
|
||||
let r = pa[0] as f64 * alpha + 255.0 * (1.0 - alpha);
|
||||
let g = pa[1] as f64 * alpha + 255.0 * (1.0 - alpha);
|
||||
let bl = pa[2] as f64 * alpha + 255.0 * (1.0 - alpha);
|
||||
let lum = 0.299 * r + 0.587 * g + 0.114 * bl;
|
||||
let dimmed = (255.0 * 0.65 + lum * 0.35).round().clamp(0.0, 255.0) as u8;
|
||||
diff_buf[out_idx..out_idx + 4].copy_from_slice(&[dimmed, dimmed, dimmed, 255]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let bounds = (mismatched_pixels > 0).then_some([min_x, min_y, max_x, max_y]);
|
||||
let ratio = mismatched_pixels as f64 / total_pixels as f64;
|
||||
let img = image::RgbaImage::from_raw(width, height, diff_buf)
|
||||
.ok_or_else(|| "failed to build diff image".to_string())?;
|
||||
let diff_png = crate::io::export::encode_png(&img)?;
|
||||
|
||||
Ok(PixelDiff {
|
||||
width,
|
||||
height,
|
||||
total_pixels,
|
||||
mismatched_pixels,
|
||||
ratio,
|
||||
bounds,
|
||||
diff_png,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::geom::Rect;
|
||||
use crate::model::Color;
|
||||
|
||||
fn add_node(
|
||||
doc: &mut Document,
|
||||
parent: Option<NodeId>,
|
||||
name: &str,
|
||||
kind: NodeKind,
|
||||
rect: Rect,
|
||||
) -> NodeId {
|
||||
let id = doc.alloc_id();
|
||||
let mut n = Node::new(id, kind, rect);
|
||||
n.name = name.to_string();
|
||||
n.parent = parent;
|
||||
doc.nodes.insert(id, n);
|
||||
doc.children_mut(parent).unwrap().push(id);
|
||||
id
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_subtrees_detects_modified_added_removed() {
|
||||
let mut doc_a = Document::default();
|
||||
let root_a = add_node(
|
||||
&mut doc_a,
|
||||
None,
|
||||
"Card",
|
||||
NodeKind::Frame { clip: true },
|
||||
Rect::new(0.0, 0.0, 320.0, 200.0),
|
||||
);
|
||||
let title_a = add_node(
|
||||
&mut doc_a,
|
||||
Some(root_a),
|
||||
"Title",
|
||||
NodeKind::Text {
|
||||
text: "Hello".into(),
|
||||
font_size: 16.0,
|
||||
auto_width: true,
|
||||
align: TextAlign::Left,
|
||||
},
|
||||
Rect::new(16.0, 16.0, 100.0, 24.0),
|
||||
);
|
||||
doc_a
|
||||
.nodes
|
||||
.get_mut(&title_a)
|
||||
.unwrap()
|
||||
.style
|
||||
.set_fill(Some(Color::rgb(0x11, 0x22, 0x33)));
|
||||
let _old_badge = add_node(
|
||||
&mut doc_a,
|
||||
Some(root_a),
|
||||
"OldBadge",
|
||||
NodeKind::Rect,
|
||||
Rect::new(16.0, 50.0, 40.0, 20.0),
|
||||
);
|
||||
|
||||
let mut doc_b = doc_a.clone();
|
||||
// Modify root size, title text + fill, remove OldBadge, and add NewButton.
|
||||
doc_b.nodes.get_mut(&root_a).unwrap().rect.h = 240.0;
|
||||
if let NodeKind::Text { text, .. } = &mut doc_b.nodes.get_mut(&title_a).unwrap().kind {
|
||||
*text = "Welcome".into();
|
||||
}
|
||||
doc_b.nodes.get_mut(&title_a).unwrap().style.fills =
|
||||
vec![Paint::solid_var(Color::rgb(0xff, 0x00, 0x00), "accent")];
|
||||
|
||||
// Remove OldBadge from doc_b
|
||||
let old_id = doc_b.nodes[&root_a].children[1];
|
||||
doc_b.nodes.get_mut(&root_a).unwrap().children.remove(1);
|
||||
doc_b.nodes.remove(&old_id);
|
||||
|
||||
// Add NewButton to doc_b
|
||||
add_node(
|
||||
&mut doc_b,
|
||||
Some(root_a),
|
||||
"NewButton",
|
||||
NodeKind::Rect,
|
||||
Rect::new(16.0, 180.0, 120.0, 40.0),
|
||||
);
|
||||
|
||||
let d = diff_subtrees(&doc_a, root_a, &doc_b, root_a).unwrap();
|
||||
assert_eq!(d.modified, 2);
|
||||
assert_eq!(d.added, 1);
|
||||
assert_eq!(d.removed, 1);
|
||||
assert_eq!(d.unchanged, 0);
|
||||
|
||||
let card_diff = d.nodes.iter().find(|n| n.path == "Card").unwrap();
|
||||
assert_eq!(card_diff.status, "modified");
|
||||
assert!(
|
||||
card_diff
|
||||
.changes
|
||||
.iter()
|
||||
.any(|c| c.field == "size" && c.before == "320 × 200" && c.after == "320 × 240")
|
||||
);
|
||||
|
||||
let title_diff = d.nodes.iter().find(|n| n.path == "Card > Title").unwrap();
|
||||
assert_eq!(title_diff.status, "modified");
|
||||
assert!(
|
||||
title_diff
|
||||
.changes
|
||||
.iter()
|
||||
.any(|c| c.field == "text" && c.before == "Hello" && c.after == "Welcome")
|
||||
);
|
||||
assert!(
|
||||
title_diff
|
||||
.changes
|
||||
.iter()
|
||||
.any(|c| c.field == "fills" && c.after.contains("var(--accent)"))
|
||||
);
|
||||
|
||||
assert!(
|
||||
d.nodes
|
||||
.iter()
|
||||
.any(|n| n.path == "Card > OldBadge" && n.status == "removed")
|
||||
);
|
||||
assert!(
|
||||
d.nodes
|
||||
.iter()
|
||||
.any(|n| n.path == "Card > NewButton" && n.status == "added")
|
||||
);
|
||||
|
||||
let j = to_json(&d);
|
||||
assert_eq!(j["modified"], 2);
|
||||
assert_eq!(j["added"], 1);
|
||||
assert_eq!(j["removed"], 1);
|
||||
assert_eq!(j["changes"].as_array().unwrap().len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_rgba_identical_and_changed() {
|
||||
let w = 4u32;
|
||||
let h = 4u32;
|
||||
let a = vec![200u8; (w * h * 4) as usize];
|
||||
let same = diff_rgba(&a, w, h, &a, w, h, 2).unwrap();
|
||||
assert_eq!(same.mismatched_pixels, 0);
|
||||
assert_eq!(same.bounds, None);
|
||||
assert_eq!(same.ratio, 0.0);
|
||||
assert!(same.diff_png.starts_with(b"\x89PNG"));
|
||||
|
||||
let mut b = a.clone();
|
||||
// Change pixels at (1, 2) and (2, 3)
|
||||
let idx1 = ((2 * w + 1) * 4) as usize;
|
||||
b[idx1] = 10;
|
||||
let idx2 = ((3 * w + 2) * 4) as usize;
|
||||
b[idx2 + 1] = 20;
|
||||
|
||||
let changed = diff_rgba(&a, w, h, &b, w, h, 2).unwrap();
|
||||
assert_eq!(changed.mismatched_pixels, 2);
|
||||
assert_eq!(changed.bounds, Some([1, 2, 2, 3]));
|
||||
assert!((changed.ratio - 2.0 / 16.0).abs() < 1e-9);
|
||||
assert!(changed.diff_png.starts_with(b"\x89PNG"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,754 @@
|
||||
//! AI design-to-code enforcer: verifies generated Web (React, Angular, HTML+CSS) and Rust (`egui`)
|
||||
//! code against a PaperCraft frame's layout, tokens, components, and text content.
|
||||
|
||||
use crate::model::{Direction, Document, NodeId, NodeKind, Paint};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
|
||||
pub struct Violation {
|
||||
pub rule: &'static str,
|
||||
pub severity: &'static str, // "error" or "warning"
|
||||
pub node_id: u64,
|
||||
pub node_name: String,
|
||||
pub expected: String,
|
||||
pub found: String,
|
||||
pub fix: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
|
||||
pub struct EnforcementReport {
|
||||
pub passed: bool,
|
||||
pub score: u32, // 0..=100
|
||||
pub target: String,
|
||||
pub errors: usize,
|
||||
pub warnings: usize,
|
||||
pub checked_nodes: usize,
|
||||
pub violations: Vec<Violation>,
|
||||
}
|
||||
|
||||
fn normalize_target(target: &str) -> Result<&'static str, String> {
|
||||
match target.trim().to_ascii_lowercase().as_str() {
|
||||
"react" | "tsx" => Ok("react"),
|
||||
"tailwind" | "jsx" => Ok("tailwind"),
|
||||
"angular" | "ng" => Ok("angular"),
|
||||
"html" | "css" => Ok("html"),
|
||||
"egui" | "rust" => Ok("egui"),
|
||||
other => Err(format!(
|
||||
"unknown enforcement target {other:?}; expected react, tailwind, angular, html, or egui"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_visible_in_subtree(doc: &Document, root: NodeId, id: NodeId) -> bool {
|
||||
let mut cur = Some(id);
|
||||
while let Some(c) = cur {
|
||||
let Some(n) = doc.get(c) else { return false };
|
||||
if !n.visible {
|
||||
return false;
|
||||
}
|
||||
if c == root {
|
||||
return true;
|
||||
}
|
||||
cur = n.parent;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn visible_subtree(doc: &Document, root: NodeId) -> Vec<NodeId> {
|
||||
doc.subtree(root)
|
||||
.into_iter()
|
||||
.filter(|&id| is_visible_in_subtree(doc, root, id))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn escape_html(t: &str) -> String {
|
||||
t.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
fn text_snippet_present(code: &str, snippet: &str) -> bool {
|
||||
if code.contains(snippet) || code.contains(&escape_html(snippet)) {
|
||||
return true;
|
||||
}
|
||||
if let Ok(json_escaped) = serde_json::to_string(snippet) {
|
||||
let inner = json_escaped.trim_matches('"');
|
||||
if code.contains(inner) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Strip CSS variable declaration blocks (`:root { ... }`, `@theme { ... }`, `:host { ... }`,
|
||||
/// `[data-mode...] { ... }`, `[data-theme...] { ... }`) so hex literals inside token definitions
|
||||
/// are not flagged as hardcoded style values.
|
||||
fn strip_token_declaration_blocks(code: &str) -> String {
|
||||
let mut out = String::with_capacity(code.len());
|
||||
let mut i = 0;
|
||||
while i < code.len() {
|
||||
let rest = &code[i..];
|
||||
let starts_decl = ["root", "theme", "host", "data-mode", "data-theme"]
|
||||
.iter()
|
||||
.any(|kw| {
|
||||
let pfx_len = match *kw {
|
||||
"root" | "host" => 1 + kw.len(), // :root, :host
|
||||
"theme" => 1 + kw.len(), // @theme
|
||||
_ => 1 + kw.len(), // [data-mode, [data-theme
|
||||
};
|
||||
if rest.len() < pfx_len {
|
||||
return false;
|
||||
}
|
||||
let head = &rest[..pfx_len];
|
||||
head.eq_ignore_ascii_case(":root")
|
||||
|| head.eq_ignore_ascii_case("@theme")
|
||||
|| head.eq_ignore_ascii_case(":host")
|
||||
|| head.eq_ignore_ascii_case("[data-mode")
|
||||
|| head.eq_ignore_ascii_case("[data-theme")
|
||||
});
|
||||
if starts_decl && let Some(open_rel) = rest.find('{') {
|
||||
let mut depth = 0usize;
|
||||
let mut end_idx = None;
|
||||
for (rel, ch) in rest[open_rel..].char_indices() {
|
||||
if ch == '{' {
|
||||
depth += 1;
|
||||
} else if ch == '}' {
|
||||
depth = depth.saturating_sub(1);
|
||||
if depth == 0 {
|
||||
end_idx = Some(i + open_rel + rel + ch.len_utf8());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(next_i) = end_idx {
|
||||
i = next_i;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if let Some(ch) = rest.chars().next() {
|
||||
out.push(ch);
|
||||
i += ch.len_utf8();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn web_var_referenced(code: &str, var_name: &str) -> bool {
|
||||
let outside = strip_token_declaration_blocks(code);
|
||||
let css_var = format!("--{var_name}");
|
||||
let tw_color_var = format!("--color-{var_name}");
|
||||
outside.contains(&css_var) || outside.contains(&tw_color_var)
|
||||
}
|
||||
|
||||
fn count_web_absolute(code: &str) -> usize {
|
||||
let lower = code.to_ascii_lowercase();
|
||||
let mut count = 0usize;
|
||||
|
||||
// Count CSS `position: absolute` / `position:absolute`
|
||||
let mut rest = lower.as_str();
|
||||
while let Some(pos) = rest.find("position") {
|
||||
let after = &rest[pos + "position".len()..];
|
||||
let after_trim = after.trim_start();
|
||||
if let Some(after_colon) = after_trim.strip_prefix(':') {
|
||||
let val = after_colon.trim_start();
|
||||
if val.starts_with("absolute") {
|
||||
let next_ch = val.as_bytes().get("absolute".len()).copied();
|
||||
if !next_ch.is_some_and(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_') {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
rest = after;
|
||||
}
|
||||
|
||||
// Count standalone Tailwind `absolute` class tokens inside class/className strings
|
||||
for attr in ["classname=", "class="] {
|
||||
let mut scan = lower.as_str();
|
||||
while let Some(idx) = scan.find(attr) {
|
||||
let after_attr = &scan[idx + attr.len()..].trim_start();
|
||||
let quote = after_attr.as_bytes().first().copied();
|
||||
if let Some(q) = quote
|
||||
&& (q == b'"' || q == b'\'' || q == b'`')
|
||||
{
|
||||
let inner = &after_attr[1..];
|
||||
if let Some(end) = inner.find(q as char) {
|
||||
let class_str = &inner[..end];
|
||||
for token in class_str.split_whitespace() {
|
||||
let base = token.rsplit(':').next().unwrap_or(token);
|
||||
if base == "absolute" {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
scan = &inner[end + 1..];
|
||||
continue;
|
||||
}
|
||||
}
|
||||
scan = after_attr;
|
||||
}
|
||||
}
|
||||
|
||||
count
|
||||
}
|
||||
|
||||
fn px_num(v: f64) -> String {
|
||||
let r = (v * 100.0).round() / 100.0;
|
||||
if (r - r.trunc()).abs() < 1e-9 {
|
||||
format!("{}", r as i64)
|
||||
} else {
|
||||
format!("{r}")
|
||||
}
|
||||
}
|
||||
|
||||
fn tw_gap_scale(px: f64) -> Option<&'static str> {
|
||||
let r = (px * 100.0).round() / 100.0;
|
||||
match (r * 10.0).round() as i64 {
|
||||
0 => Some("0"),
|
||||
20 => Some("0.5"),
|
||||
40 => Some("1"),
|
||||
60 => Some("1.5"),
|
||||
80 => Some("2"),
|
||||
100 => Some("2.5"),
|
||||
120 => Some("3"),
|
||||
140 => Some("3.5"),
|
||||
160 => Some("4"),
|
||||
200 => Some("5"),
|
||||
240 => Some("6"),
|
||||
280 => Some("7"),
|
||||
320 => Some("8"),
|
||||
360 => Some("9"),
|
||||
400 => Some("10"),
|
||||
440 => Some("11"),
|
||||
480 => Some("12"),
|
||||
560 => Some("14"),
|
||||
640 => Some("16"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn web_gap_represented(doc: &Document, code: &str, gap: f64) -> bool {
|
||||
let num = px_num(gap);
|
||||
let px_str = format!("{num}px");
|
||||
if code.contains(&px_str) {
|
||||
return true;
|
||||
}
|
||||
if let Some(scale) = tw_gap_scale(gap) {
|
||||
for prefix in ["gap-", "gap-x-", "gap-y-", "space-x-", "space-y-"] {
|
||||
let pat = format!("{prefix}{scale}");
|
||||
if code.contains(&pat) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
let rem = gap / 16.0;
|
||||
let rem_str = format!("{}rem", px_num(rem));
|
||||
if code.contains(&rem_str) {
|
||||
return true;
|
||||
}
|
||||
// Check document spacing tokens matching `{num}px`
|
||||
for (k, v) in &doc.tokens {
|
||||
if v.trim() == px_str && code.contains(&format!("--{k}")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if code.contains("var(--gap") || code.contains("var(--space") || code.contains("var(--spacing")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn egui_gap_represented(code: &str, gap: f64) -> bool {
|
||||
let num = px_num(gap);
|
||||
code.contains("item_spacing")
|
||||
|| code.contains(&format!("add_space({num})"))
|
||||
|| code.contains(&format!("add_space({num}.0)"))
|
||||
}
|
||||
|
||||
fn to_pascal_case(s: &str) -> String {
|
||||
s.split(|c: char| !c.is_ascii_alphanumeric())
|
||||
.filter(|w| !w.is_empty())
|
||||
.map(|w| {
|
||||
let mut chars = w.chars();
|
||||
match chars.next() {
|
||||
Some(first) => {
|
||||
let mut out = String::new();
|
||||
out.push(first.to_ascii_uppercase());
|
||||
out.push_str(&chars.as_str().to_ascii_lowercase());
|
||||
out
|
||||
}
|
||||
None => String::new(),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn to_kebab_case(s: &str) -> String {
|
||||
let mut words: Vec<String> = Vec::new();
|
||||
let mut cur = String::new();
|
||||
for c in s.chars() {
|
||||
if !c.is_ascii_alphanumeric() {
|
||||
if !cur.is_empty() {
|
||||
words.push(std::mem::take(&mut cur));
|
||||
}
|
||||
} else if c.is_ascii_uppercase() && !cur.is_empty() {
|
||||
words.push(std::mem::take(&mut cur));
|
||||
cur.push(c.to_ascii_lowercase());
|
||||
} else {
|
||||
cur.push(c.to_ascii_lowercase());
|
||||
}
|
||||
}
|
||||
if !cur.is_empty() {
|
||||
words.push(cur);
|
||||
}
|
||||
words.join("-")
|
||||
}
|
||||
|
||||
fn component_referenced(code: &str, comp_name: &str, is_egui: bool) -> bool {
|
||||
let trimmed = comp_name.trim();
|
||||
if trimmed.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let pascal = to_pascal_case(trimmed);
|
||||
let kebab = to_kebab_case(trimmed);
|
||||
let snake = kebab.replace('-', "_");
|
||||
|
||||
if code.contains(trimmed)
|
||||
|| (!pascal.is_empty() && code.contains(&pascal))
|
||||
|| (!kebab.is_empty() && code.contains(&kebab))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if is_egui && !snake.is_empty() && code.to_ascii_lowercase().contains(&snake) {
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn verify_code(
|
||||
doc: &Document,
|
||||
root: NodeId,
|
||||
target: &str,
|
||||
code: &str,
|
||||
) -> Result<EnforcementReport, String> {
|
||||
let Some(root_node) = doc.get(root) else {
|
||||
return Err(format!("missing node {root}"));
|
||||
};
|
||||
let norm_target = normalize_target(target)?;
|
||||
let is_egui = norm_target == "egui";
|
||||
|
||||
let visible_ids = visible_subtree(doc, root);
|
||||
|
||||
let mut violations: Vec<Violation> = Vec::new();
|
||||
let outside_decls = strip_token_declaration_blocks(code);
|
||||
let outside_lower = outside_decls.to_ascii_lowercase();
|
||||
|
||||
// 1 & 2 & 5 & 6: Walk visible nodes
|
||||
let mut out_of_flow_nodes = 0usize;
|
||||
let mut has_any_auto_layout = false;
|
||||
let mut needs_horizontal = false;
|
||||
let mut needs_vertical = false;
|
||||
|
||||
for &id in &visible_ids {
|
||||
let Some(n) = doc.get(id) else { continue };
|
||||
|
||||
if id != root {
|
||||
let parent_has_layout = n
|
||||
.parent
|
||||
.and_then(|p| doc.get(p))
|
||||
.is_some_and(|p| p.layout.is_some());
|
||||
if n.layout_child.absolute || !parent_has_layout {
|
||||
out_of_flow_nodes += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(l) = &n.layout {
|
||||
has_any_auto_layout = true;
|
||||
if !n.children.is_empty() {
|
||||
match l.direction {
|
||||
Direction::Horizontal => needs_horizontal = true,
|
||||
Direction::Vertical => needs_vertical = true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rule 1: missing-text (error)
|
||||
if let NodeKind::Text { text, .. } = &n.kind {
|
||||
let trimmed = text.trim();
|
||||
if !trimmed.is_empty() {
|
||||
for line in trimmed.lines() {
|
||||
let line = line.trim();
|
||||
if !line.is_empty() && !text_snippet_present(code, line) {
|
||||
violations.push(Violation {
|
||||
rule: "missing-text",
|
||||
severity: "error",
|
||||
node_id: id.0,
|
||||
node_name: n.name.clone(),
|
||||
expected: line.to_string(),
|
||||
found: "missing in generated code".into(),
|
||||
fix: format!("Include exact text {:?} from layer {:?}", line, n.name),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rule 2: hardcoded-color (error)
|
||||
for fill in &n.style.fills {
|
||||
if let Paint::Solid {
|
||||
color,
|
||||
var: Some(var_name),
|
||||
} = fill
|
||||
{
|
||||
let hex = color.to_hex().to_ascii_lowercase();
|
||||
let upper_var = var_name.to_ascii_uppercase().replace('-', "_");
|
||||
if is_egui {
|
||||
let has_token = code.contains(&upper_var) || code.contains(var_name.as_str());
|
||||
if !has_token {
|
||||
let rgb_tuple = format!("{}, {}, {}", color.r, color.g, color.b);
|
||||
let found = if code.contains(&rgb_tuple) || outside_lower.contains(&hex) {
|
||||
format!("raw color ({rgb_tuple}) / {hex}")
|
||||
} else {
|
||||
format!("missing token {upper_var}")
|
||||
};
|
||||
violations.push(Violation {
|
||||
rule: "hardcoded-color",
|
||||
severity: "error",
|
||||
node_id: id.0,
|
||||
node_name: n.name.clone(),
|
||||
expected: format!("tokens::{upper_var}"),
|
||||
found,
|
||||
fix: format!(
|
||||
"Use design token constant `tokens::{upper_var}` instead of raw color {hex}"
|
||||
),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
let has_var = web_var_referenced(code, var_name);
|
||||
let has_raw_hex = outside_lower.contains(&hex);
|
||||
if !has_var {
|
||||
violations.push(Violation {
|
||||
rule: "hardcoded-color",
|
||||
severity: "error",
|
||||
node_id: id.0,
|
||||
node_name: n.name.clone(),
|
||||
expected: format!("var(--{var_name})"),
|
||||
found: if has_raw_hex {
|
||||
hex.clone()
|
||||
} else {
|
||||
format!("missing --{var_name}")
|
||||
},
|
||||
fix: format!(
|
||||
"Use `var(--{var_name})` (or `bg-[var(--{var_name})]` / `text-[var(--{var_name})]`) instead of hardcoded {hex}"
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rule 5: missing-component (warning)
|
||||
if let Some(inst) = n.instance.as_ref()
|
||||
&& let Some(main_comp) = doc.get(inst.main)
|
||||
&& main_comp.component
|
||||
&& !component_referenced(code, &main_comp.name, is_egui)
|
||||
{
|
||||
let pascal = to_pascal_case(&main_comp.name);
|
||||
let kebab = to_kebab_case(&main_comp.name);
|
||||
violations.push(Violation {
|
||||
rule: "missing-component",
|
||||
severity: "warning",
|
||||
node_id: id.0,
|
||||
node_name: n.name.clone(),
|
||||
expected: main_comp.name.clone(),
|
||||
found: "inlined / unnamed markup".into(),
|
||||
fix: if is_egui {
|
||||
format!(
|
||||
"Reference component `{}` (e.g. `{}` widget/helper)",
|
||||
main_comp.name,
|
||||
kebab.replace('-', "_")
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"Instantiate component `<{pascal} />`, `<app-{kebab}>`, or `data-component=\"{}\"`",
|
||||
main_comp.name
|
||||
)
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Rule 6: spacing-drift (warning)
|
||||
if let Some(l) = &n.layout
|
||||
&& !n.children.is_empty()
|
||||
&& l.gap > 0.0
|
||||
&& !l.gap_auto
|
||||
{
|
||||
let ok = if is_egui {
|
||||
egui_gap_represented(code, l.gap)
|
||||
} else {
|
||||
web_gap_represented(doc, code, l.gap)
|
||||
};
|
||||
if !ok {
|
||||
let gap_str = px_num(l.gap);
|
||||
violations.push(Violation {
|
||||
rule: "spacing-drift",
|
||||
severity: "warning",
|
||||
node_id: id.0,
|
||||
node_name: n.name.clone(),
|
||||
expected: format!("{gap_str}px gap"),
|
||||
found: "gap not found in code".into(),
|
||||
fix: if is_egui {
|
||||
format!(
|
||||
"Set `item_spacing` or call `ui.add_space({gap_str}.0)` on container {:?}",
|
||||
n.name
|
||||
)
|
||||
} else {
|
||||
let tw_hint = tw_gap_scale(l.gap)
|
||||
.map(|s| format!("`gap-{s}` or "))
|
||||
.unwrap_or_default();
|
||||
format!(
|
||||
"Apply {tw_hint}`gap: {gap_str}px` on container {:?}",
|
||||
n.name
|
||||
)
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rule 3: forbidden-absolute (error)
|
||||
if !is_egui {
|
||||
let code_abs = count_web_absolute(code);
|
||||
if code_abs > out_of_flow_nodes {
|
||||
violations.push(Violation {
|
||||
rule: "forbidden-absolute",
|
||||
severity: "error",
|
||||
node_id: root.0,
|
||||
node_name: root_node.name.clone(),
|
||||
expected: format!("at most {out_of_flow_nodes} absolute node(s)"),
|
||||
found: format!("{code_abs} absolute positioning occurrence(s)"),
|
||||
fix: "Flow children in auto-layout frames must use Flexbox/Grid, never position: absolute".into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Rule 4: missing-layout (error)
|
||||
if has_any_auto_layout {
|
||||
if is_egui {
|
||||
let has_grid = code.contains("egui::Grid") || code.contains("Grid::new");
|
||||
let has_horiz = code.contains("ui.horizontal") || code.contains(".horizontal(");
|
||||
let has_vert = code.contains("ui.vertical") || code.contains(".vertical(");
|
||||
let any_layout = has_grid || has_horiz || has_vert;
|
||||
let dir_ok = has_grid
|
||||
|| ((!needs_horizontal || has_horiz)
|
||||
&& (!needs_vertical || has_vert)
|
||||
&& any_layout);
|
||||
if !dir_ok {
|
||||
violations.push(Violation {
|
||||
rule: "missing-layout",
|
||||
severity: "error",
|
||||
node_id: root.0,
|
||||
node_name: root_node.name.clone(),
|
||||
expected: if needs_horizontal && needs_vertical {
|
||||
"ui.horizontal + ui.vertical (or egui::Grid)".into()
|
||||
} else if needs_horizontal {
|
||||
"ui.horizontal (or egui::Grid)".into()
|
||||
} else {
|
||||
"ui.vertical (or egui::Grid)".into()
|
||||
},
|
||||
found: "no matching egui layout container".into(),
|
||||
fix: "Use `ui.horizontal(|ui| ...)` / `ui.vertical(|ui| ...)` or `egui::Grid` matching the frame's auto-layout direction".into(),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
let lower = code.to_ascii_lowercase();
|
||||
let has_flex_or_grid = lower.contains("flex")
|
||||
|| lower.contains("display: flex")
|
||||
|| lower.contains("display:flex")
|
||||
|| lower.contains("grid");
|
||||
if !has_flex_or_grid {
|
||||
violations.push(Violation {
|
||||
rule: "missing-layout",
|
||||
severity: "error",
|
||||
node_id: root.0,
|
||||
node_name: root_node.name.clone(),
|
||||
expected: "flex or grid layout".into(),
|
||||
found: "no flex/grid layout found".into(),
|
||||
fix: "Use Flexbox (`flex`, `display: flex`) or CSS Grid (`grid`, `display: grid`) for auto-layout frames".into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let errors = violations.iter().filter(|v| v.severity == "error").count();
|
||||
let warnings = violations
|
||||
.iter()
|
||||
.filter(|v| v.severity == "warning")
|
||||
.count();
|
||||
let score = 100u32.saturating_sub((errors * 15 + warnings * 5) as u32);
|
||||
let passed = errors == 0;
|
||||
|
||||
Ok(EnforcementReport {
|
||||
passed,
|
||||
score,
|
||||
target: norm_target.to_string(),
|
||||
errors,
|
||||
warnings,
|
||||
checked_nodes: visible_ids.len(),
|
||||
violations,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn to_json(r: &EnforcementReport) -> serde_json::Value {
|
||||
serde_json::to_value(r).unwrap_or(serde_json::Value::Null)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::geom::Rect;
|
||||
use crate::model::{
|
||||
Align, AutoLayout, Color, Direction, InstanceLink, Node, NodeKind, Paint, TextAlign,
|
||||
};
|
||||
|
||||
fn sample_doc() -> (Document, NodeId) {
|
||||
let mut doc = Document::default();
|
||||
let surface = Color::rgb(0x1e, 0x1e, 0x2e);
|
||||
doc.variables.insert("surface".into(), surface);
|
||||
|
||||
let root = doc.alloc_id();
|
||||
let mut frame = Node::new(
|
||||
root,
|
||||
NodeKind::Frame { clip: true },
|
||||
Rect::new(0.0, 0.0, 320.0, 120.0),
|
||||
);
|
||||
frame.name = "Action Card".into();
|
||||
frame.layout = Some(AutoLayout {
|
||||
direction: Direction::Vertical,
|
||||
wrap: false,
|
||||
gap: 16.0,
|
||||
gap_auto: false,
|
||||
cross_gap: 0.0,
|
||||
padding: [16.0; 4],
|
||||
align_main: Align::Center,
|
||||
align_cross: Align::Center,
|
||||
});
|
||||
frame.style.fills = vec![Paint::solid_var(surface, "surface")];
|
||||
|
||||
let txt_id = doc.alloc_id();
|
||||
let mut txt = Node::new(
|
||||
txt_id,
|
||||
NodeKind::Text {
|
||||
text: "Continue".into(),
|
||||
font_size: 16.0,
|
||||
auto_width: true,
|
||||
align: TextAlign::Center,
|
||||
},
|
||||
Rect::new(0.0, 0.0, 80.0, 20.0),
|
||||
);
|
||||
txt.name = "CTA Label".into();
|
||||
txt.parent = Some(root);
|
||||
txt.style.fills = vec![Paint::solid(Color::WHITE)];
|
||||
frame.children.push(txt_id);
|
||||
|
||||
doc.nodes.insert(root, frame);
|
||||
doc.nodes.insert(txt_id, txt);
|
||||
doc.root.push(root);
|
||||
(doc, root)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_tailwind_angular_and_egui_pass() {
|
||||
let (doc, root) = sample_doc();
|
||||
|
||||
let tw_code = r#"
|
||||
<div className="flex flex-col gap-4 p-4 bg-[var(--surface)]">
|
||||
<span className="text-base text-white">Continue</span>
|
||||
</div>
|
||||
"#;
|
||||
let tw_report = verify_code(&doc, root, "tailwind", tw_code).unwrap();
|
||||
assert!(tw_report.passed, "{tw_report:?}");
|
||||
assert_eq!(tw_report.errors, 0);
|
||||
assert!(tw_report.score >= 95);
|
||||
|
||||
let ng_code = r#"
|
||||
@Component({
|
||||
selector: 'app-action-card',
|
||||
template: `<div class="card"><span>Continue</span></div>`,
|
||||
styles: [`
|
||||
:host { --surface: #1e1e2e; }
|
||||
.card { display: flex; flex-direction: column; gap: 16px; background: var(--surface); }
|
||||
`]
|
||||
})
|
||||
export class ActionCardComponent {}
|
||||
"#;
|
||||
let ng_report = verify_code(&doc, root, "angular", ng_code).unwrap();
|
||||
assert!(ng_report.passed, "{ng_report:?}");
|
||||
assert_eq!(ng_report.errors, 0);
|
||||
assert!(ng_report.score >= 95);
|
||||
|
||||
let egui_code = r#"
|
||||
egui::Frame::none().fill(tokens::SURFACE).show(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing = egui::vec2(16.0, 16.0);
|
||||
ui.vertical(|ui| {
|
||||
ui.label("Continue");
|
||||
});
|
||||
});
|
||||
"#;
|
||||
let egui_report = verify_code(&doc, root, "egui", egui_code).unwrap();
|
||||
assert!(egui_report.passed, "{egui_report:?}");
|
||||
assert_eq!(egui_report.errors, 0);
|
||||
assert!(egui_report.score >= 95);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broken_code_reports_missing_text_hardcoded_color_and_forbidden_absolute() {
|
||||
let (doc, root) = sample_doc();
|
||||
|
||||
let bad_code = r#"
|
||||
<div className="flex flex-col gap-4 bg-[#1e1e2e]">
|
||||
<span className="absolute left-0 top-0">Wrong Label</span>
|
||||
</div>
|
||||
"#;
|
||||
let report = verify_code(&doc, root, "react", bad_code).unwrap();
|
||||
assert!(!report.passed);
|
||||
assert!(report.errors >= 3);
|
||||
let rules: Vec<&str> = report.violations.iter().map(|v| v.rule).collect();
|
||||
assert!(rules.contains(&"missing-text"), "{rules:?}");
|
||||
assert!(rules.contains(&"hardcoded-color"), "{rules:?}");
|
||||
assert!(rules.contains(&"forbidden-absolute"), "{rules:?}");
|
||||
|
||||
let json_val = to_json(&report);
|
||||
assert_eq!(json_val["passed"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_missing_layout_component_and_spacing_drift() {
|
||||
let (mut doc, root) = sample_doc();
|
||||
let comp_id = doc.alloc_id();
|
||||
let mut comp = Node::new(
|
||||
comp_id,
|
||||
NodeKind::Frame { clip: true },
|
||||
Rect::new(0.0, 0.0, 100.0, 40.0),
|
||||
);
|
||||
comp.name = "PrimaryButton".into();
|
||||
comp.component = true;
|
||||
doc.nodes.insert(comp_id, comp);
|
||||
doc.nodes.get_mut(&root).unwrap().instance = Some(InstanceLink {
|
||||
main: comp_id,
|
||||
overrides: Vec::new(),
|
||||
});
|
||||
|
||||
let drift_code = r#"
|
||||
<div className="bg-[var(--surface)]">
|
||||
<span>Continue</span>
|
||||
</div>
|
||||
"#;
|
||||
let report = verify_code(&doc, root, "tailwind", drift_code).unwrap();
|
||||
assert!(!report.passed);
|
||||
let rules: Vec<&str> = report.violations.iter().map(|v| v.rule).collect();
|
||||
assert!(rules.contains(&"missing-layout"), "{rules:?}");
|
||||
assert!(rules.contains(&"missing-component"), "{rules:?}");
|
||||
assert!(rules.contains(&"spacing-drift"), "{rules:?}");
|
||||
}
|
||||
}
|
||||
+251
-35
@@ -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
|
||||
@@ -128,6 +134,9 @@ impl<'a> Args<'a> {
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
fn bool(&self, k: &str) -> Option<bool> {
|
||||
self.get(k).and_then(Value::as_bool)
|
||||
}
|
||||
fn arr(&self, k: &str) -> Result<&'a Vec<Value>, String> {
|
||||
self.get(k)
|
||||
.and_then(Value::as_array)
|
||||
@@ -269,6 +278,8 @@ pub fn call(host: &mut dyn Host, name: &str, args: &Value) -> ToolResult {
|
||||
"get_tree_summary" => tree_summary(ed, &a),
|
||||
"get_screenshot" => screenshot(host, &a),
|
||||
"get_jsx" => jsx(host, &a),
|
||||
"get_design_spec" => get_design_spec(ed, &a),
|
||||
"verify_code" => verify_code(ed, &a),
|
||||
"get_computed_styles" => computed_styles(ed, &a),
|
||||
"get_fill_image" => fill_image(ed, &a),
|
||||
"find_nodes" => find_nodes(ed, &a),
|
||||
@@ -291,6 +302,9 @@ pub fn call(host: &mut dyn Host, name: &str, args: &Value) -> ToolResult {
|
||||
"import_components" => import_components(ed, &a),
|
||||
"create_component" => create_component(ed, &a),
|
||||
"create_instance" => create_instance(ed, &a),
|
||||
"swap_instance" => swap_instance(ed, &a),
|
||||
"detach_instance" => detach_instance(ed, &a),
|
||||
"diff_frames" => diff_frames(ed, &a),
|
||||
"search_icons" => search_icons(&a),
|
||||
"rename_nodes" => rename_nodes(ed, &a),
|
||||
"delete_nodes" => delete_nodes(ed, &a),
|
||||
@@ -317,7 +331,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 +814,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 +876,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(),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1045,9 +1108,50 @@ fn jsx(host: &mut dyn Host, a: &Args) -> ToolResult {
|
||||
let ed = host.editor();
|
||||
let (page, id) = resolve(ed, a.req_str("nodeId")?, page_arg(ed, a)?)?;
|
||||
let doc = ed.page_doc(page);
|
||||
let mut out = String::new();
|
||||
jsx_node(doc, id, 0, true, assets_dir.as_deref(), &mut out);
|
||||
Ok(vec![Out::Text(out)])
|
||||
match a.str("format") {
|
||||
None | Some("") | Some("inline") | Some("inline-styles") => {
|
||||
let mut out = String::new();
|
||||
jsx_node(doc, id, 0, true, assets_dir.as_deref(), &mut out);
|
||||
Ok(vec![Out::Text(out)])
|
||||
}
|
||||
Some(
|
||||
f @ ("tailwind" | "jsx" | "react" | "tsx" | "angular" | "ng" | "html" | "css" | "egui"
|
||||
| "rust"),
|
||||
) => Ok(vec![Out::Text(super::codegen::code_for_target(
|
||||
doc, id, f,
|
||||
)?)]),
|
||||
Some(f) => Err(format!(
|
||||
"unknown format `{f}` (inline, tailwind, react, angular, html, or egui)"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_design_spec(ed: &mut Editor, a: &Args) -> ToolResult {
|
||||
let (page, id) = resolve(ed, a.req_str("nodeId")?, page_arg(ed, a)?)?;
|
||||
let doc = ed.page_doc(page);
|
||||
let target = a.str("target").filter(|s| !s.is_empty()).unwrap_or("react");
|
||||
match a.str("format").unwrap_or("markdown") {
|
||||
"" | "markdown" | "md" => {
|
||||
let md = super::codegen::to_ai_spec_markdown(doc, id, target)?;
|
||||
Ok(vec![Out::Text(md)])
|
||||
}
|
||||
"json" => {
|
||||
let spec = super::codegen::to_ai_spec(doc, id, target)?;
|
||||
text(spec)
|
||||
}
|
||||
other => Err(format!(
|
||||
"unknown format `{other}` (expected markdown or json)"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn verify_code(ed: &mut Editor, a: &Args) -> ToolResult {
|
||||
let (page, id) = resolve(ed, a.req_str("nodeId")?, page_arg(ed, a)?)?;
|
||||
let doc = ed.page_doc(page);
|
||||
let target = a.req_str("target")?;
|
||||
let code = a.req_str("code")?;
|
||||
let report = super::enforcer::verify_code(doc, id, target, code)?;
|
||||
text(super::enforcer::to_json(&report))
|
||||
}
|
||||
|
||||
fn style_obj(m: &Map<String, Value>) -> String {
|
||||
@@ -1168,6 +1272,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 +1290,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 +1299,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 +1311,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 +1388,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 +1579,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());
|
||||
@@ -2940,17 +3064,25 @@ fn get_variables(ed: &mut Editor, a: &Args) -> ToolResult {
|
||||
.map(|(k, c)| format!(" --{k}: {};\n", c.to_hex()))
|
||||
.chain(doc.tokens.iter().map(|(k, v)| format!(" --{k}: {v};\n")))
|
||||
.collect();
|
||||
return Ok(vec![Out::Text(format!(":root {{\n{body}}}\n"))]);
|
||||
let mut out = format!(":root {{\n{body}}}\n");
|
||||
for (mode, vars) in &doc.modes {
|
||||
out.push_str(&format!("[data-theme=\"{mode}\"] {{\n"));
|
||||
for (k, c) in vars {
|
||||
out.push_str(&format!(" --{k}: {};\n", c.to_hex()));
|
||||
}
|
||||
out.push_str("}\n");
|
||||
}
|
||||
return Ok(vec![Out::Text(out)]);
|
||||
}
|
||||
Some("json") => {
|
||||
let tokens: Map<String, Value> = doc
|
||||
.variables
|
||||
.iter()
|
||||
.map(|(k, c)| (k.clone(), json!({"$type": "color", "$value": c.to_hex()})))
|
||||
.collect();
|
||||
return text(Value::Object(tokens));
|
||||
return text(super::codegen::export_variables_dtcg(doc));
|
||||
}
|
||||
Some(f) => return Err(format!("unknown format `{f}` (css or json)")),
|
||||
Some("tailwind") => {
|
||||
return Ok(vec![Out::Text(super::codegen::export_variables_tailwind(
|
||||
doc,
|
||||
))]);
|
||||
}
|
||||
Some(f) => return Err(format!("unknown format `{f}` (css, json, or tailwind)")),
|
||||
None => {}
|
||||
}
|
||||
let vars: Map<String, Value> = doc
|
||||
@@ -3028,6 +3160,15 @@ fn lint_design(ed: &mut Editor, a: &Args) -> ToolResult {
|
||||
Some(v) => v.iter().filter_map(Value::as_f64).collect(),
|
||||
None => super::lint::DEFAULT_SCALE.to_vec(),
|
||||
};
|
||||
let fixed = if a.bool("fix").unwrap_or(false) {
|
||||
ed.with_page(page, |ed| {
|
||||
ed.edit("Auto-fix design lint", |tx| {
|
||||
super::lint::autofix(tx, &roots, &scale)
|
||||
})
|
||||
})
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let doc = ed.page_doc(page);
|
||||
let issues = super::lint::lint(doc, &roots, &scale);
|
||||
let count = |sev: &str| issues.iter().filter(|i| i.severity == sev).count();
|
||||
@@ -3046,6 +3187,7 @@ fn lint_design(ed: &mut Editor, a: &Args) -> ToolResult {
|
||||
.collect();
|
||||
text(json!({
|
||||
"errors": count("error"), "warnings": count("warning"), "info": count("info"),
|
||||
"fixedNodes": fixed,
|
||||
"byRule": by_rule, "issues": list,
|
||||
"truncated": issues.len() > 200,
|
||||
}))
|
||||
@@ -3383,6 +3525,80 @@ fn create_instance(ed: &mut Editor, a: &Args) -> ToolResult {
|
||||
text(json!({"id": node_key(ed, page, id), "textLayers": texts, "layers": layers}))
|
||||
}
|
||||
|
||||
fn swap_instance(ed: &mut Editor, a: &Args) -> ToolResult {
|
||||
let def = page_arg(ed, a)?;
|
||||
let (page, inst) = resolve(ed, a.req_str("nodeId")?, def)?;
|
||||
let (comp_page, new_main) = resolve(ed, a.req_str("componentId")?, page)?;
|
||||
if page != comp_page {
|
||||
return Err("instance and component must be on the same page".into());
|
||||
}
|
||||
ed.with_page(page, |ed| {
|
||||
if !ed.is_instance_root(inst) {
|
||||
return Err(format!(
|
||||
"`{}` is not a component instance root",
|
||||
a.req_str("nodeId").unwrap_or_default()
|
||||
));
|
||||
}
|
||||
if !ed.doc.get(new_main).is_some_and(|m| m.component) {
|
||||
return Err(format!(
|
||||
"`{}` is not a main component",
|
||||
a.req_str("componentId").unwrap_or_default()
|
||||
));
|
||||
}
|
||||
let ok = ed.edit("Swap instance", |tx| {
|
||||
crate::editor::structure::swap_instance_tx(tx, inst, new_main)
|
||||
});
|
||||
if !ok {
|
||||
return Err("could not swap instance to that component".into());
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
text(json!({
|
||||
"id": node_key(ed, page, inst),
|
||||
"componentId": node_key(ed, page, new_main),
|
||||
"swapped": true,
|
||||
}))
|
||||
}
|
||||
|
||||
fn detach_instance(ed: &mut Editor, a: &Args) -> ToolResult {
|
||||
let def = page_arg(ed, a)?;
|
||||
let keys = match a.strs("nodeIds") {
|
||||
Ok(k) => k,
|
||||
Err(_) => vec![a.req_str("nodeId")?.to_string()],
|
||||
};
|
||||
let (page, ids) = resolve_all(ed, &keys, def)?;
|
||||
let detached = ed.with_page(page, |ed| {
|
||||
ed.edit("Detach instance", |tx| {
|
||||
let mut count = 0;
|
||||
for r in &ids {
|
||||
if tx.doc().get(*r).and_then(|n| n.instance.as_ref()).is_some() {
|
||||
for id in tx.doc().subtree(*r) {
|
||||
if let Some(n) = tx.node_mut(id) {
|
||||
n.instance = None;
|
||||
}
|
||||
}
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
count
|
||||
})
|
||||
});
|
||||
text(json!({
|
||||
"detached": detached,
|
||||
"nodeIds": ids.iter().map(|i| node_key(ed, page, *i)).collect::<Vec<_>>(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn diff_frames(ed: &mut Editor, a: &Args) -> ToolResult {
|
||||
let def = page_arg(ed, a)?;
|
||||
let (page_a, id_a) = resolve(ed, a.req_str("beforeId")?, def)?;
|
||||
let (page_b, id_b) = resolve(ed, a.req_str("afterId")?, def)?;
|
||||
let doc_a = ed.page_doc(page_a);
|
||||
let doc_b = ed.page_doc(page_b);
|
||||
let diff = super::diff::diff_subtrees(doc_a, id_a, doc_b, id_b)?;
|
||||
text(super::diff::to_json(&diff))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------ icons
|
||||
|
||||
fn search_icons(a: &Args) -> ToolResult {
|
||||
|
||||
+147
@@ -248,6 +248,120 @@ pub fn lint(doc: &Document, roots: &[NodeId], scale: &[f64]) -> Vec<Issue> {
|
||||
out
|
||||
}
|
||||
|
||||
fn snap4(v: f64) -> f64 {
|
||||
if v <= 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
((v / 4.0).round() * 4.0).max(4.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply safe automatic fixes (`spacing-grid`, `hard-coded-color`, `text-size`, `type-scale`,
|
||||
/// `touch-target`) to `id` (or all non-instance visible descendants under `roots`).
|
||||
/// Returns the number of nodes modified.
|
||||
pub fn autofix(tx: &mut crate::history::Tx, roots: &[NodeId], scale: &[f64]) -> usize {
|
||||
let mut ids: Vec<NodeId> = Vec::new();
|
||||
for r in roots {
|
||||
ids.extend(tx.doc().subtree(*r));
|
||||
}
|
||||
let mut fixed = 0;
|
||||
for id in ids {
|
||||
if autofix_node(tx, id, scale) {
|
||||
fixed += 1;
|
||||
}
|
||||
}
|
||||
fixed
|
||||
}
|
||||
|
||||
/// Apply safe automatic fixes to a single node `id`. Returns true if anything changed.
|
||||
pub fn autofix_node(tx: &mut crate::history::Tx, id: NodeId, scale: &[f64]) -> bool {
|
||||
let Some(n) = tx.doc().get(id).cloned() else {
|
||||
return false;
|
||||
};
|
||||
if !n.visible || n.instance.is_some() {
|
||||
return false;
|
||||
}
|
||||
let vars = tx.doc().variables.clone();
|
||||
let mut changed = false;
|
||||
|
||||
// 1. Snap off-grid auto-layout spacing/padding to 4px multiples.
|
||||
let mut new_layout = n.layout;
|
||||
if let Some(l) = &mut new_layout {
|
||||
if off_grid(l.gap) {
|
||||
l.gap = snap4(l.gap);
|
||||
changed = true;
|
||||
}
|
||||
if off_grid(l.cross_gap) {
|
||||
l.cross_gap = snap4(l.cross_gap);
|
||||
changed = true;
|
||||
}
|
||||
for p in &mut l.padding {
|
||||
if off_grid(*p) {
|
||||
*p = snap4(*p);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Relink hard-coded solid fills to matching document color variables.
|
||||
let mut new_fills = n.style.fills.clone();
|
||||
for fill in &mut new_fills {
|
||||
if let Paint::Solid { color, var } = fill
|
||||
&& var.is_none()
|
||||
&& color.a == 255
|
||||
&& let Some((name, _)) = vars.iter().find(|(_, v)| **v == *color)
|
||||
{
|
||||
*var = Some(name.clone());
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Snap text size to >= 12px and nearest type-scale value.
|
||||
let mut new_kind = n.kind.clone();
|
||||
if let crate::model::NodeKind::Text { font_size, .. } = &mut new_kind {
|
||||
let target = if *font_size < 12.0 {
|
||||
scale.iter().copied().find(|s| *s >= 12.0).unwrap_or(12.0)
|
||||
} else if !scale.is_empty() && !scale.iter().any(|s| (s - *font_size).abs() < 0.01) {
|
||||
scale
|
||||
.iter()
|
||||
.min_by(|a, b| (*a - *font_size).abs().total_cmp(&(*b - *font_size).abs()))
|
||||
.copied()
|
||||
.unwrap_or(*font_size)
|
||||
} else {
|
||||
*font_size
|
||||
};
|
||||
if (target - *font_size).abs() > 0.01 {
|
||||
*font_size = target;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Touch target height >= 44px for buttons/tabs.
|
||||
let lower = n.name.to_lowercase();
|
||||
let tap =
|
||||
lower.starts_with("button") || lower.starts_with("tab /") || lower.starts_with("tab bar /");
|
||||
let mut new_h = n.rect.h;
|
||||
if tap && n.is_frame() && n.rect.h < 44.0 && n.rect.h > 0.0 && !lower.contains("icon") {
|
||||
let extra = ((44.0 - n.rect.h) / 2.0).ceil();
|
||||
if let Some(l) = &mut new_layout
|
||||
&& n.layout_child.sizing[1] == crate::model::Sizing::Hug
|
||||
{
|
||||
l.padding[0] = snap4(l.padding[0] + extra);
|
||||
l.padding[2] = snap4(l.padding[2] + extra);
|
||||
}
|
||||
new_h = 44.0;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if changed && let Some(m) = tx.node_mut(id) {
|
||||
m.layout = new_layout;
|
||||
m.style.fills = new_fills;
|
||||
m.kind = new_kind;
|
||||
m.rect.h = new_h;
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
fn join(v: &[f64]) -> String {
|
||||
v.iter()
|
||||
.map(|s| format!("{s}"))
|
||||
@@ -268,4 +382,37 @@ mod tests {
|
||||
assert!(off_grid(13.0) && !off_grid(12.0) && !off_grid(0.0));
|
||||
assert!(is_default_name("Frame 12") && !is_default_name("Task Row"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autofix_snaps_grid_and_links_variables() {
|
||||
let mut doc = Document::default();
|
||||
let accent = Color::rgb(0x3b, 0x82, 0xf6);
|
||||
doc.variables.insert("accent".into(), accent);
|
||||
let id = doc.alloc_id();
|
||||
let mut n = Node::new(
|
||||
id,
|
||||
crate::model::NodeKind::Frame { clip: false },
|
||||
crate::geom::Rect::new(0.0, 0.0, 120.0, 36.0),
|
||||
);
|
||||
n.name = "Button / Primary".into();
|
||||
n.style.fills = vec![Paint::solid(accent)];
|
||||
n.layout = Some(crate::model::AutoLayout {
|
||||
gap: 10.0,
|
||||
padding: [6.0, 14.0, 6.0, 14.0],
|
||||
..Default::default()
|
||||
});
|
||||
doc.nodes.insert(id, n);
|
||||
doc.root.push(id);
|
||||
|
||||
let mut rec = crate::history::Recording::new("autofix", &doc);
|
||||
let mut tx = crate::history::Tx::new(&mut doc, &mut rec);
|
||||
let count = autofix(&mut tx, &[id], &DEFAULT_SCALE);
|
||||
assert_eq!(count, 1);
|
||||
let fixed = doc.get(id).unwrap();
|
||||
assert_eq!(fixed.style.fills[0].var(), Some("accent"));
|
||||
let l = fixed.layout.unwrap();
|
||||
assert_eq!(l.gap, 12.0);
|
||||
assert_eq!(l.padding, [8.0, 16.0, 8.0, 16.0]);
|
||||
assert_eq!(fixed.rect.h, 44.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,12 +9,23 @@
|
||||
//! * [`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 codegen;
|
||||
pub mod css;
|
||||
pub mod diff;
|
||||
pub mod enforcer;
|
||||
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;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod store;
|
||||
pub mod tools;
|
||||
|
||||
/// Paper uses 29979; papercraft listens next door so both apps can run side by side.
|
||||
|
||||
@@ -0,0 +1,560 @@
|
||||
//! 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());
|
||||
// Served under a public hostname; the URL token is the credential, not the Host header.
|
||||
let config = StreamableHttpServerConfig::default()
|
||||
.disable_allowed_hosts()
|
||||
.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>, store: Option<Arc<super::store::Store>>) -> axum::Router {
|
||||
let app = 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);
|
||||
match store {
|
||||
Some(store) => app.merge(super::store::router(store)),
|
||||
None => app,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
spawn_with_data(listen, static_dir, allow_origin, None)
|
||||
}
|
||||
|
||||
/// [`spawn`], plus `data`: where shared projects are kept (the `/p/` routes are off without it).
|
||||
pub fn spawn_with_data(
|
||||
listen: SocketAddr,
|
||||
static_dir: Option<PathBuf>,
|
||||
allow_origin: Option<String>,
|
||||
data: Option<super::store::Data>,
|
||||
) -> 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, data.map(super::store::Store::new)))
|
||||
.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 {
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
//! Shared projects: the relay keeps a project's files on disk so several tabs (and devices) can
|
||||
//! open and edit the same project.
|
||||
//!
|
||||
//! ```text
|
||||
//! GET /p/{token}/files [{"path","size","mtime"}]
|
||||
//! GET /p/{token}/f/{path} file bytes
|
||||
//! PUT /p/{token}/f/{path} write (creates the project); answers the file's mtime
|
||||
//! DELETE /p/{token}/f/{path} remove
|
||||
//! GET /p/{token}/ws?client=<id> pushes {"op","path","rev","client"} for other clients' changes
|
||||
//! ```
|
||||
//!
|
||||
//! The token is the only credential, like the agent link: anyone holding it can read and edit.
|
||||
//! A bad token or path is a plain 404.
|
||||
//!
|
||||
//! With `Data::library` the relay is a personal library instead: every project in the data
|
||||
//! directory is listed, so a device needs no link to find them (the network is the boundary):
|
||||
//!
|
||||
//! ```text
|
||||
//! GET /library JSON array of every project's token
|
||||
//! DELETE /p/{token} move the project to `.trash/` (kept on disk); later `files` answer 410
|
||||
//! ```
|
||||
|
||||
use super::relay_proto;
|
||||
use axum::body::Bytes;
|
||||
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
|
||||
use axum::extract::{DefaultBodyLimit, Path, Query, State};
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::get;
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Component, Path as FsPath, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
/// Biggest file a tab may upload (screenshots and assets are big).
|
||||
const MAX_FILE_BYTES: usize = 64 << 20;
|
||||
/// Changes buffered per project for a slow socket; a lagging tab resyncs from `/files`.
|
||||
const EVENT_BACKLOG: usize = 256;
|
||||
/// Header and query name a tab uses to identify itself, so it can skip its own echo.
|
||||
const CLIENT_HEADER: &str = "x-client";
|
||||
|
||||
/// One change, as pushed to the other tabs.
|
||||
#[derive(Serialize, Clone)]
|
||||
struct Event {
|
||||
op: &'static str,
|
||||
path: String,
|
||||
rev: u64,
|
||||
client: String,
|
||||
}
|
||||
|
||||
struct Project {
|
||||
rev: AtomicU64,
|
||||
tx: broadcast::Sender<Arc<Event>>,
|
||||
}
|
||||
|
||||
/// Where deleted library projects go, inside the data directory.
|
||||
const TRASH: &str = ".trash";
|
||||
|
||||
/// The `--data` directory, and whether it is a library (`--library`).
|
||||
#[derive(Clone)]
|
||||
pub struct Data {
|
||||
pub dir: PathBuf,
|
||||
pub library: bool,
|
||||
}
|
||||
|
||||
/// The `--data` directory and the live state of the projects in it.
|
||||
pub struct Store {
|
||||
root: PathBuf,
|
||||
library: bool,
|
||||
projects: Mutex<HashMap<String, Arc<Project>>>,
|
||||
}
|
||||
|
||||
impl Store {
|
||||
pub fn new(data: Data) -> Arc<Store> {
|
||||
Arc::new(Store {
|
||||
root: data.dir,
|
||||
library: data.library,
|
||||
projects: Mutex::new(HashMap::new()),
|
||||
})
|
||||
}
|
||||
|
||||
fn project(&self, token: &str) -> Arc<Project> {
|
||||
let mut map = self.projects.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
map.entry(token.to_string())
|
||||
.or_insert_with(|| {
|
||||
Arc::new(Project {
|
||||
rev: AtomicU64::new(0),
|
||||
tx: broadcast::channel(EVENT_BACKLOG).0,
|
||||
})
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// True when `token` was deleted: its project sits in the trash and is gone from the root.
|
||||
fn was_deleted(&self, token: &str) -> bool {
|
||||
let prefix = format!("{token}-");
|
||||
std::fs::read_dir(self.root.join(TRASH)).is_ok_and(|mut d| {
|
||||
d.any(|e| e.is_ok_and(|e| e.file_name().to_string_lossy().starts_with(&prefix)))
|
||||
})
|
||||
}
|
||||
|
||||
fn publish(&self, token: &str, op: &'static str, path: String, client: String) {
|
||||
let project = self.project(token);
|
||||
let rev = project.rev.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
// No receiver is fine: nobody else has the project open.
|
||||
let _ = project.tx.send(Arc::new(Event {
|
||||
op,
|
||||
path,
|
||||
rev,
|
||||
client,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/// `files/` relative path, or `None` for anything that could leave the project directory.
|
||||
fn clean(path: &str) -> Option<PathBuf> {
|
||||
let p = FsPath::new(path);
|
||||
if path.is_empty() || p.components().any(|c| !matches!(c, Component::Normal(_))) {
|
||||
return None;
|
||||
}
|
||||
Some(p.to_path_buf())
|
||||
}
|
||||
|
||||
fn not_found() -> Response {
|
||||
(StatusCode::NOT_FOUND, "not found").into_response()
|
||||
}
|
||||
|
||||
/// The project directory and cleaned file path for a request, or the 404 to answer.
|
||||
fn target(store: &Store, token: &str, path: &str) -> Result<(PathBuf, PathBuf), Response> {
|
||||
if !relay_proto::valid_token(token) {
|
||||
return Err(not_found());
|
||||
}
|
||||
let rel = clean(path).ok_or_else(not_found)?;
|
||||
let dir = store.root.join(token);
|
||||
Ok((dir.join(&rel), rel))
|
||||
}
|
||||
|
||||
fn client_of(headers: &HeaderMap) -> String {
|
||||
headers
|
||||
.get(CLIENT_HEADER)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Last-modified time in ns; clients compare it for equality only, to spot remote changes.
|
||||
fn mtime_ns(meta: &std::fs::Metadata) -> u64 {
|
||||
meta.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map_or(0, |d| d.as_nanos() as u64)
|
||||
}
|
||||
|
||||
fn walk(dir: &FsPath, base: &FsPath, out: &mut Vec<serde_json::Value>) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
walk(&p, base, out);
|
||||
} else if let (Ok(rel), Ok(meta)) = (p.strip_prefix(base), e.metadata()) {
|
||||
// Temp files of an upload in flight are not part of the project.
|
||||
let rel = rel.to_string_lossy().replace('\\', "/");
|
||||
if !rel.ends_with(".part") {
|
||||
out.push(serde_json::json!({ "path": rel, "size": meta.len(), "mtime": mtime_ns(&meta) }));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn list(State(store): State<Arc<Store>>, Path(token): Path<String>) -> Response {
|
||||
if !relay_proto::valid_token(&token) {
|
||||
return not_found();
|
||||
}
|
||||
let dir = store.root.join(&token);
|
||||
if !dir.is_dir() {
|
||||
if store.was_deleted(&token) {
|
||||
return StatusCode::GONE.into_response();
|
||||
}
|
||||
return not_found();
|
||||
}
|
||||
let files = tokio::task::spawn_blocking(move || {
|
||||
let mut out = Vec::new();
|
||||
walk(&dir, &dir, &mut out);
|
||||
out
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
axum::Json(files).into_response()
|
||||
}
|
||||
|
||||
/// Every project's token (library mode).
|
||||
async fn library(State(store): State<Arc<Store>>) -> Response {
|
||||
let mut tokens: Vec<String> = std::fs::read_dir(&store.root)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.filter(|e| e.path().is_dir())
|
||||
.filter_map(|e| e.file_name().into_string().ok())
|
||||
.filter(|name| relay_proto::valid_token(name))
|
||||
.collect();
|
||||
tokens.sort();
|
||||
axum::Json(tokens).into_response()
|
||||
}
|
||||
|
||||
/// Delete a project: it moves to `.trash/<token>-<ms>` so nothing is lost on the server.
|
||||
async fn delete_project(State(store): State<Arc<Store>>, Path(token): Path<String>) -> Response {
|
||||
if !relay_proto::valid_token(&token) {
|
||||
return not_found();
|
||||
}
|
||||
let dir = store.root.join(&token);
|
||||
if !dir.is_dir() {
|
||||
return not_found();
|
||||
}
|
||||
let ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_millis());
|
||||
let trash = store.root.join(TRASH);
|
||||
let moved = tokio::fs::create_dir_all(&trash).await.is_ok()
|
||||
&& tokio::fs::rename(&dir, trash.join(format!("{token}-{ms}"))).await.is_ok();
|
||||
if !moved {
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
}
|
||||
store.projects.lock().unwrap_or_else(PoisonError::into_inner).remove(&token);
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
}
|
||||
|
||||
async fn read(
|
||||
State(store): State<Arc<Store>>,
|
||||
Path((token, path)): Path<(String, String)>,
|
||||
) -> Response {
|
||||
let (file, _) = match target(&store, &token, &path) {
|
||||
Ok(t) => t,
|
||||
Err(r) => return r,
|
||||
};
|
||||
match tokio::fs::read(&file).await {
|
||||
Ok(bytes) => bytes.into_response(),
|
||||
Err(_) => not_found(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn write(
|
||||
State(store): State<Arc<Store>>,
|
||||
Path((token, path)): Path<(String, String)>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> Response {
|
||||
let (file, rel) = match target(&store, &token, &path) {
|
||||
Ok(t) => t,
|
||||
Err(r) => return r,
|
||||
};
|
||||
if let Some(parent) = file.parent()
|
||||
&& tokio::fs::create_dir_all(parent).await.is_err()
|
||||
{
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
}
|
||||
// Write beside, then rename, so a reader never sees a half-written file.
|
||||
let tmp = file.with_extension("part");
|
||||
if tokio::fs::write(&tmp, &body).await.is_err() || tokio::fs::rename(&tmp, &file).await.is_err()
|
||||
{
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
}
|
||||
store.publish(
|
||||
&token,
|
||||
"put",
|
||||
rel.to_string_lossy().replace('\\', "/"),
|
||||
client_of(&headers),
|
||||
);
|
||||
// The new stamp, so the writer does not mistake its own upload for someone else's change.
|
||||
let stamp = tokio::fs::metadata(&file).await.map_or(0, |m| mtime_ns(&m));
|
||||
stamp.to_string().into_response()
|
||||
}
|
||||
|
||||
async fn remove(
|
||||
State(store): State<Arc<Store>>,
|
||||
Path((token, path)): Path<(String, String)>,
|
||||
headers: HeaderMap,
|
||||
) -> Response {
|
||||
let (file, rel) = match target(&store, &token, &path) {
|
||||
Ok(t) => t,
|
||||
Err(r) => return r,
|
||||
};
|
||||
if tokio::fs::remove_file(&file).await.is_err() {
|
||||
return not_found();
|
||||
}
|
||||
store.publish(
|
||||
&token,
|
||||
"del",
|
||||
rel.to_string_lossy().replace('\\', "/"),
|
||||
client_of(&headers),
|
||||
);
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
}
|
||||
|
||||
async fn socket(
|
||||
State(store): State<Arc<Store>>,
|
||||
Path(token): Path<String>,
|
||||
Query(q): Query<HashMap<String, String>>,
|
||||
upgrade: WebSocketUpgrade,
|
||||
) -> Response {
|
||||
if !relay_proto::valid_token(&token) {
|
||||
return not_found();
|
||||
}
|
||||
let me = q.get("client").cloned().unwrap_or_default();
|
||||
let rx = store.project(&token).tx.subscribe();
|
||||
upgrade
|
||||
.on_upgrade(move |s| serve(s, rx, me))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// Push other clients' changes until the socket closes.
|
||||
async fn serve(mut socket: WebSocket, mut rx: broadcast::Receiver<Arc<Event>>, me: String) {
|
||||
loop {
|
||||
tokio::select! {
|
||||
ev = rx.recv() => match ev {
|
||||
Ok(ev) if ev.client != me || me.is_empty() => {
|
||||
let Ok(json) = serde_json::to_string(&*ev) else { continue };
|
||||
if socket.send(Message::Text(json.into())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(_) => {}
|
||||
// Missed events: tell the tab to resync everything.
|
||||
Err(broadcast::error::RecvError::Lagged(_)) => {
|
||||
let resync = r#"{"op":"resync"}"#;
|
||||
if socket.send(Message::Text(resync.into())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
},
|
||||
m = socket.recv() => match m {
|
||||
Some(Ok(Message::Close(_))) | Some(Err(_)) | None => break,
|
||||
Some(Ok(_)) => {}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn router(store: Arc<Store>) -> axum::Router {
|
||||
let mut app = axum::Router::new()
|
||||
.route("/p/{token}/files", get(list))
|
||||
.route("/p/{token}/f/{*path}", get(read).put(write).delete(remove))
|
||||
.route("/p/{token}/ws", get(socket));
|
||||
if store.library {
|
||||
app = app
|
||||
.route("/library", get(library))
|
||||
.route("/p/{token}", axum::routing::delete(delete_project));
|
||||
}
|
||||
app.layer(DefaultBodyLimit::max(MAX_FILE_BYTES)).with_state(store)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn paths_stay_inside_the_project() {
|
||||
assert_eq!(clean("pages/a.json"), Some(PathBuf::from("pages/a.json")));
|
||||
for bad in ["", "../x", "a/../../x", "/etc/passwd", "./x"] {
|
||||
assert_eq!(clean(bad), None, "{bad:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
+78
-6
@@ -194,15 +194,51 @@ pub fn definitions() -> Vec<Value> {
|
||||
),
|
||||
tool(
|
||||
"get_jsx",
|
||||
"JSX for a node and its descendants with inline styles (frames become flex divs, text \
|
||||
becomes spans, vectors become inline SVG). Use it to export a design to code.",
|
||||
"Export a node and its descendants to code: \"inline\" (default, React JSX with inline \
|
||||
styles), \"tailwind\" (React JSX with Tailwind CSS utility classes), \"react\" (React \
|
||||
TSX component), \"angular\" (Angular v17+ standalone component), \"html\" \
|
||||
(self-contained <style> + HTML markup), or \"egui\" (Rust egui 0.36 code with tokens).",
|
||||
json!({
|
||||
"nodeId": node_id("The node to convert."),
|
||||
"format": {"type": "string", "enum": ["inline-styles", "tailwind"], "description": "Only inline styles are produced; \"tailwind\" is accepted for compatibility."},
|
||||
"format": {"type": "string", "enum": ["inline", "inline-styles", "tailwind", "react", "angular", "html", "egui"], "description": "Output format: \"inline\" (default), \"tailwind\", \"react\", \"angular\", \"html\", or \"egui\"."},
|
||||
"pageId": page_id(),
|
||||
}),
|
||||
&["nodeId"],
|
||||
true,
|
||||
),
|
||||
tool(
|
||||
"get_design_spec",
|
||||
"Export a deterministic AI Design-to-Code specification for a frame or subtree. \
|
||||
Includes all design tokens/variables, component instances, exact auto-layout tree \
|
||||
(sizing, flex/grid/egui flow, gaps, paddings, bound `--var` colors), mandatory \
|
||||
enforcement rules, and reference target code. Always call this before implementing \
|
||||
a design in React, Tailwind, Angular, HTML/CSS, or Rust egui instead of guessing \
|
||||
from a screenshot.",
|
||||
json!({
|
||||
"nodeId": node_id("Root frame or node id to export."),
|
||||
"target": {"type": "string", "enum": ["react", "tailwind", "angular", "html", "egui"], "description": "Target UI framework (default \"react\")."},
|
||||
"format": {"type": "string", "enum": ["markdown", "json"], "description": "Output format: \"markdown\" (default) or \"json\"."},
|
||||
"pageId": page_id(),
|
||||
}),
|
||||
&["nodeId"],
|
||||
true,
|
||||
),
|
||||
tool(
|
||||
"verify_code",
|
||||
"Verify that AI-generated UI code (`react`, `tailwind`, `angular`, `html`, or `egui`) \
|
||||
accurately implements a design subtree. Checks for missing text strings, hardcoded \
|
||||
hex colors where `--var` or `tokens::*` must be used, forbidden `position: absolute` \
|
||||
on auto-layout flow children, missing flex/grid/egui layout containers, unreferenced \
|
||||
component instances, and spacing drift. Returns `{passed, score, errors, warnings, violations}`.",
|
||||
json!({
|
||||
"nodeId": node_id("Root frame or node id that the code implements."),
|
||||
"target": {"type": "string", "enum": ["react", "tailwind", "angular", "html", "egui"], "description": "Target UI framework."},
|
||||
"code": {"type": "string", "description": "The generated UI source code to verify against the design."},
|
||||
"pageId": page_id(),
|
||||
}),
|
||||
&["nodeId", "target", "code"],
|
||||
true,
|
||||
),
|
||||
tool(
|
||||
"get_computed_styles",
|
||||
"CSS styles (camelCase) of one or more nodes, as {nodeId: styles}.",
|
||||
@@ -440,10 +476,13 @@ pub fn definitions() -> Vec<Value> {
|
||||
contrast (WCAG 4.5:1, 3:1 for large text), text under 12px, font sizes off the type \
|
||||
scale, gap/padding off the 4px grid, buttons and tabs under 44px tall, default layer \
|
||||
names (Frame, Rectangle), and hard-coded colors that equal a variable. Run it before \
|
||||
finish_working_on_nodes and fix errors and warnings; `info` items are suggestions.",
|
||||
finish_working_on_nodes and fix errors and warnings; `info` items are suggestions. \
|
||||
Pass `fix: true` to automatically snap spacing/padding to 4px, relink matching color \
|
||||
variables, snap text sizes, and enlarge small touch targets.",
|
||||
json!({"nodeId": {"type": "string", "description": "Subtree to check; omit for the whole page."},
|
||||
"scale": {"type": "array", "items": {"type": "number"},
|
||||
"description": "Allowed font sizes. Default 12, 14, 16, 20, 24, 32, 40, 48, 64."},
|
||||
"fix": {"type": "boolean", "description": "Apply safe automatic fixes before reporting remaining issues."},
|
||||
"pageId": page_id()}),
|
||||
&[],
|
||||
true,
|
||||
@@ -452,9 +491,10 @@ pub fn definitions() -> Vec<Value> {
|
||||
"get_variables",
|
||||
"Color variables of the page's project (name -> hex). Fills that follow a variable \
|
||||
were written with `var(--name)`; set_variables moves all of them at once. \
|
||||
`format` css returns a `:root { --name: #hex }` block, json a design-tokens object.",
|
||||
`format` css returns a `:root { --name: #hex }` block (plus `[data-theme]` rules for modes), \
|
||||
json a W3C DTCG design-tokens object, and tailwind a Tailwind v4 `@theme` block.",
|
||||
json!({"pageId": page_id(),
|
||||
"format": {"type": "string", "enum": ["css", "json"]}}),
|
||||
"format": {"type": "string", "enum": ["css", "json", "tailwind"]}}),
|
||||
&[],
|
||||
true,
|
||||
),
|
||||
@@ -539,6 +579,25 @@ pub fn definitions() -> Vec<Value> {
|
||||
&["componentId"],
|
||||
false,
|
||||
),
|
||||
tool(
|
||||
"swap_instance",
|
||||
"Swap an instance root to point at a different main component on the same page, \
|
||||
preserving overrides on descendants matched by name and layer type.",
|
||||
json!({"nodeId": node_id("The instance root node to swap."),
|
||||
"componentId": node_id("The new main component id."),
|
||||
"pageId": page_id()}),
|
||||
&["nodeId", "componentId"],
|
||||
false,
|
||||
),
|
||||
tool(
|
||||
"detach_instance",
|
||||
"Detach one or more component instances into plain frames, breaking their link to \
|
||||
the main component while keeping their current appearance and children.",
|
||||
json!({"nodeIds": ids("Instance root nodes to detach."),
|
||||
"pageId": page_id()}),
|
||||
&["nodeIds"],
|
||||
false,
|
||||
),
|
||||
tool(
|
||||
"search_icons",
|
||||
"Search installed open icon sets (Lucide, Tabler, Phosphor, Heroicons, Material). \
|
||||
@@ -610,6 +669,19 @@ pub fn definitions() -> Vec<Value> {
|
||||
&[],
|
||||
false,
|
||||
),
|
||||
tool(
|
||||
"diff_frames",
|
||||
"Compare two frames or subtrees (`beforeId` and `afterId`) and report property-level \
|
||||
differences (size, position, fills, stroke, corner radius, opacity, text, layout, \
|
||||
added/removed children).",
|
||||
json!({
|
||||
"beforeId": node_id("First frame or node id (baseline)."),
|
||||
"afterId": node_id("Second frame or node id (comparison)."),
|
||||
"pageId": page_id(),
|
||||
}),
|
||||
&["beforeId", "afterId"],
|
||||
true,
|
||||
),
|
||||
tool(
|
||||
"finish_working_on_nodes",
|
||||
"Call when done creating or editing. Saves the project.",
|
||||
|
||||
@@ -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)
|
||||
|
||||
+27
-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)
|
||||
}
|
||||
|
||||
@@ -229,6 +229,8 @@ impl Store {
|
||||
let name = self.unique_name(&format!("{} copy", src.manifest.name));
|
||||
let target = self.unique_dir(&self.root, &name);
|
||||
copy_dir(dir, &target)?;
|
||||
// A copy is its own project, not another editor of the shared one.
|
||||
crate::vfs::remove_file(target.join(crate::sync::SHARE_FILE)).ok();
|
||||
let mut p = Project::open(&target)?;
|
||||
p.manifest.id = new_id();
|
||||
p.manifest.name = name;
|
||||
@@ -244,26 +246,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 +292,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 +303,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 +315,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 +348,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 +381,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 +390,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 +449,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 +476,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]
|
||||
|
||||
+108
-13
@@ -58,6 +58,9 @@ fn ramp(t: f32, row: u32) -> vec4<f32> {
|
||||
fn fs(i: VsOut) -> @location(0) vec4<f32> {
|
||||
let k = i.kind & 255u;
|
||||
let row = i.kind >> 8u;
|
||||
// Derivatives must be taken in uniform control flow (WebGPU rejects textureSample under `k`).
|
||||
let duv_dx = dpdx(i.uv);
|
||||
let duv_dy = dpdy(i.uv);
|
||||
var c = i.color;
|
||||
if (k == 1u) {
|
||||
let r = ramp(i.uv.x, row);
|
||||
@@ -69,7 +72,7 @@ fn fs(i: VsOut) -> @location(0) vec4<f32> {
|
||||
if (k == 3u && (i.uv.x < 0.0 || i.uv.y < 0.0 || i.uv.x > 1.0 || i.uv.y > 1.0)) {
|
||||
discard;
|
||||
}
|
||||
let t = textureSample(img_tex, img_samp, i.uv);
|
||||
let t = textureSampleGrad(img_tex, img_samp, i.uv, duv_dx, duv_dy);
|
||||
c = vec4<f32>(t.rgb, t.a * i.color.a);
|
||||
}
|
||||
// Premultiplied output; every pipeline blends premultiplied.
|
||||
@@ -772,8 +775,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 +808,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 +825,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 +872,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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+154
-4
@@ -6,17 +6,23 @@ 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);
|
||||
/// Re-render the home-screen thumbnail at most this often while editing.
|
||||
pub const THUMBNAIL_INTERVAL: Duration = Duration::from_secs(20);
|
||||
/// How often the shell looks at what the sync engines downloaded.
|
||||
const SYNC_CHECK: Duration = Duration::from_secs(1);
|
||||
pub const THUMB_SIZE: (f64, f64) = (464.0, 296.0);
|
||||
|
||||
pub struct Session {
|
||||
pub store: Store,
|
||||
pub project: Option<Project>,
|
||||
/// Shared projects kept in step with a relay.
|
||||
pub sync: crate::sync::Sync,
|
||||
sync_checked: Option<Instant>,
|
||||
dirty_since: Option<Instant>,
|
||||
thumb_stale: bool,
|
||||
last_thumb: Option<Instant>,
|
||||
@@ -24,9 +30,13 @@ pub struct Session {
|
||||
|
||||
impl Session {
|
||||
pub fn new(store: Store) -> Session {
|
||||
let mut sync = crate::sync::Sync::default();
|
||||
sync.start_all(&store.root);
|
||||
Session {
|
||||
store,
|
||||
project: None,
|
||||
sync,
|
||||
sync_checked: None,
|
||||
dirty_since: None,
|
||||
thumb_stale: false,
|
||||
last_thumb: None,
|
||||
@@ -73,6 +83,99 @@ impl Session {
|
||||
now.duration_since(since) >= AUTOSAVE_DELAY && !ed.is_busy()
|
||||
}
|
||||
|
||||
/// When the sync engines should next be looked at (None while nothing is shared).
|
||||
pub fn sync_deadline(&self) -> Option<Instant> {
|
||||
self.sync.is_active().then(|| {
|
||||
self.sync_checked
|
||||
.map_or_else(Instant::now, |t| t + SYNC_CHECK)
|
||||
})
|
||||
}
|
||||
|
||||
/// Look at the sync engines: the open project is reloaded when they downloaded changes,
|
||||
/// unless it holds unsaved edits (those win and go up on the next save). True when a check
|
||||
/// ran, so the shell can refresh the home list and the sync status shown there.
|
||||
pub fn sync_poll(&mut self, ed: &mut Editor, now: Instant) -> bool {
|
||||
if self.sync_deadline().is_none_or(|d| now < d) {
|
||||
return false;
|
||||
}
|
||||
self.sync_checked = Some(now);
|
||||
if self.sync.library_active() {
|
||||
// The library task writes `.share` files for projects it joined or shared.
|
||||
self.sync.start_all(&self.store.root);
|
||||
}
|
||||
let open = self.project.as_ref().map(|p| p.dir.clone());
|
||||
for dir in self.sync.dirs() {
|
||||
let is_open = open.as_ref() == Some(&dir);
|
||||
// A project the server dropped waits until it is closed.
|
||||
if !is_open && let Some(gone) = self.sync.status(&dir).and_then(|s| s.gone()) {
|
||||
self.sync.unshare(&dir);
|
||||
if gone == crate::sync::Gone::Deleted {
|
||||
self.store.trash(&dir).ok();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if is_open && ed.project_dirty() {
|
||||
continue;
|
||||
}
|
||||
if self.sync.status(&dir).is_some_and(|s| s.take_changed()) {
|
||||
if is_open && let Err(e) = self.open(ed, &dir) {
|
||||
log::warn!("reload shared project: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Put the project in `dir` on the relay at `server`.
|
||||
pub fn share_project(&mut self, dir: &Path, server: &str) -> Result<(), String> {
|
||||
self.sync.share(dir, server).map(|_| ())
|
||||
}
|
||||
|
||||
/// Open the shared project behind `link` as a project of its own; its files arrive with the
|
||||
/// first sync.
|
||||
pub fn join_shared(&mut self, link: &str) -> Result<(), String> {
|
||||
let token = crate::sync::Share::from_link(link)?.token;
|
||||
let dir = self.store.root.join(format!("shared-{}", &token[..8]));
|
||||
if crate::sync::Share::load(&dir).is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
self.sync.join(&dir, link)
|
||||
}
|
||||
|
||||
/// The shared projects for the home screen: their status and links.
|
||||
pub fn shared_info(
|
||||
&self,
|
||||
) -> std::collections::HashMap<std::path::PathBuf, crate::ui::home::ShareInfo> {
|
||||
self.sync
|
||||
.dirs()
|
||||
.into_iter()
|
||||
.filter_map(|dir| {
|
||||
let s = self.sync.status(&dir)?;
|
||||
let info = crate::ui::home::ShareInfo {
|
||||
status: s.label(),
|
||||
link: s.link.clone(),
|
||||
};
|
||||
Some((dir, info))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Keep a library with the relay at `server`, if it runs one (see `Sync::enable_library`).
|
||||
pub fn follow_library(&mut self, server: &str) {
|
||||
let root = self.store.root.clone();
|
||||
self.sync.enable_library(&root, server);
|
||||
}
|
||||
|
||||
/// The server of any shared project here: the likely answer for the next "Share…".
|
||||
pub fn server_hint(&self) -> String {
|
||||
self.sync
|
||||
.dirs()
|
||||
.iter()
|
||||
.find_map(|d| crate::sync::Share::load(d))
|
||||
.map(|s| s.server)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Retry later (after a failed save) instead of spinning on an overdue deadline.
|
||||
pub fn defer(&mut self) {
|
||||
self.dirty_since = Some(Instant::now());
|
||||
@@ -159,12 +262,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 +289,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 +381,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 +412,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();
|
||||
}
|
||||
}
|
||||
|
||||
+635
@@ -0,0 +1,635 @@
|
||||
//! Keep a project folder in step with a shared copy on a relay (`mcp::store`), from the browser
|
||||
//! and from the native app alike (everything goes through [`crate::vfs`]).
|
||||
//!
|
||||
//! A shared project has a `.share` file in its folder: the server, the share token, and the
|
||||
//! state of the last sync. Every [`POLL`] the engine compares three views of each file, the
|
||||
//! server's, the folder's and the last synced one:
|
||||
//!
|
||||
//! * changed in the folder: upload (or delete) it. The folder wins a simultaneous change, so
|
||||
//! whoever is typing keeps their work and the other side sees it on its next poll;
|
||||
//! * changed on the server only: download (or delete) it, and flag [`Status::take_changed`] so
|
||||
//! the shell reloads the open project.
|
||||
//!
|
||||
//! Granularity is a whole file (one page), last write wins.
|
||||
//!
|
||||
//! A relay started with `--library` is a personal library: [`Sync::enable_library`] lists it
|
||||
//! every [`LIBRARY_POLL`], joins the projects this device does not have and shares the local
|
||||
//! ones, so every device that opens the server ends up with the same projects. The sync loops
|
||||
//! above do the actual file work; the library only writes `.share` files.
|
||||
|
||||
use crate::mcp::relay_proto;
|
||||
use crate::vfs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
use std::time::Duration;
|
||||
|
||||
/// File in a project folder that marks it as shared.
|
||||
pub const SHARE_FILE: &str = ".share";
|
||||
/// How often a shared project asks the server what changed.
|
||||
const POLL: Duration = Duration::from_secs(2);
|
||||
/// How often a library device looks for projects added or deleted elsewhere.
|
||||
const LIBRARY_POLL: Duration = Duration::from_secs(5);
|
||||
/// Files that stay on this machine: the sync state and the home-screen preview.
|
||||
const LOCAL_ONLY: [&str; 2] = [SHARE_FILE, "thumbnail.png"];
|
||||
|
||||
/// What the last sync knew about one file.
|
||||
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
|
||||
struct Stamp {
|
||||
/// The server's modified time (compared for equality only).
|
||||
remote: u64,
|
||||
/// Hash of the file's bytes here.
|
||||
local: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Default)]
|
||||
pub struct Share {
|
||||
/// `https://host[:port]`, no trailing slash.
|
||||
pub server: String,
|
||||
pub token: String,
|
||||
#[serde(default)]
|
||||
synced: BTreeMap<String, Stamp>,
|
||||
}
|
||||
|
||||
impl Share {
|
||||
pub fn new(server: &str) -> Share {
|
||||
Share {
|
||||
server: server.trim_end_matches('/').to_string(),
|
||||
token: new_token(),
|
||||
synced: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load(dir: &Path) -> Option<Share> {
|
||||
let text = vfs::read_to_string(dir.join(SHARE_FILE)).ok()?;
|
||||
serde_json::from_str(&text).ok()
|
||||
}
|
||||
|
||||
pub fn save(&self, dir: &Path) -> Result<(), String> {
|
||||
let json = serde_json::to_string(self).map_err(|e| e.to_string())?;
|
||||
vfs::write(dir.join(SHARE_FILE), json).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// What to send to a collaborator: opening it in the web app joins the project.
|
||||
pub fn link(&self) -> String {
|
||||
format!("{}/?share={}", self.server, self.token)
|
||||
}
|
||||
|
||||
/// The share a link such as `https://host/?share=<token>` stands for.
|
||||
pub fn from_link(link: &str) -> Result<Share, String> {
|
||||
let link = link.trim();
|
||||
let (server, query) = link
|
||||
.split_once("?share=")
|
||||
.ok_or("not a share link (it should end in ?share=<token>)")?;
|
||||
let token: String = query
|
||||
.chars()
|
||||
.take_while(char::is_ascii_alphanumeric)
|
||||
.collect();
|
||||
if !relay_proto::valid_token(&token) {
|
||||
return Err("the share token in this link is not valid".into());
|
||||
}
|
||||
let server = server.trim_end_matches('/');
|
||||
if !server.starts_with("http://") && !server.starts_with("https://") {
|
||||
return Err("the link must start with http:// or https://".into());
|
||||
}
|
||||
Ok(Share {
|
||||
server: server.to_string(),
|
||||
token,
|
||||
synced: BTreeMap::new(),
|
||||
})
|
||||
}
|
||||
|
||||
fn base(&self) -> String {
|
||||
format!("{}/p/{}", self.server, self.token)
|
||||
}
|
||||
}
|
||||
|
||||
fn new_token() -> String {
|
||||
(0..relay_proto::MIN_TOKEN_LEN)
|
||||
.map(|_| char::from_digit(fastrand::u32(0..16), 16).unwrap_or('0'))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// FNV-1a: a stable fingerprint of a file's bytes (the std hasher may change between releases,
|
||||
/// and the value is stored in `.share`).
|
||||
fn fingerprint(bytes: &[u8]) -> u64 {
|
||||
bytes.iter().fold(0xcbf2_9ce4_8422_2325, |h, b| {
|
||||
(h ^ u64::from(*b)).wrapping_mul(0x0100_0000_01b3)
|
||||
})
|
||||
}
|
||||
|
||||
/// A file path from the server, only if it stays inside the project folder.
|
||||
fn safe_path(rel: &str) -> Option<PathBuf> {
|
||||
let p = Path::new(rel);
|
||||
let ok = !rel.is_empty()
|
||||
&& p.components().all(|c| matches!(c, Component::Normal(_)))
|
||||
&& !LOCAL_ONLY.contains(&rel);
|
||||
ok.then(|| p.to_path_buf())
|
||||
}
|
||||
|
||||
/// Every file under `dir` that syncs, as relative `/` paths with their fingerprints.
|
||||
fn scan(dir: &Path) -> BTreeMap<String, u64> {
|
||||
fn walk(base: &Path, dir: &Path, out: &mut BTreeMap<String, u64>) {
|
||||
let Ok(entries) = vfs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
if e.file_type().is_ok_and(|t| t.is_dir()) {
|
||||
walk(base, &p, out);
|
||||
} else if let (Ok(rel), Ok(bytes)) = (p.strip_prefix(base), vfs::read(&p)) {
|
||||
let rel = rel.to_string_lossy().replace('\\', "/");
|
||||
if safe_path(&rel).is_some() {
|
||||
out.insert(rel, fingerprint(&bytes));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = BTreeMap::new();
|
||||
walk(dir, dir, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RemoteFile {
|
||||
path: String,
|
||||
mtime: u64,
|
||||
}
|
||||
|
||||
/// Why a shared project stopped syncing for good.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Gone {
|
||||
/// Someone deleted it on the server (library mode): it belongs in this device's trash.
|
||||
Deleted,
|
||||
/// It had synced before but the server no longer knows it, as after losing its data:
|
||||
/// the folder is the only copy left, so it stays and can be shared again.
|
||||
Lost,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum SyncError {
|
||||
Gone(Gone),
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl From<String> for SyncError {
|
||||
fn from(e: String) -> SyncError {
|
||||
SyncError::Other(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SyncError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
SyncError::Gone(Gone::Deleted) => f.write_str("deleted on the server"),
|
||||
SyncError::Gone(Gone::Lost) => f.write_str("the server no longer has this project"),
|
||||
SyncError::Other(e) => f.write_str(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The server's files for a project. `synced_before`: this folder has already synced once, so
|
||||
/// an unknown project is a loss, not a first upload.
|
||||
async fn list_remote(base: &str, synced_before: bool) -> Result<BTreeMap<String, u64>, SyncError> {
|
||||
let (code, body) = net::request("GET", &format!("{base}/files"), None).await?;
|
||||
match code {
|
||||
200 => {}
|
||||
410 => return Err(SyncError::Gone(Gone::Deleted)),
|
||||
// Nothing uploaded yet: the first sync of a freshly shared project.
|
||||
404 if !synced_before => return Ok(BTreeMap::new()),
|
||||
404 => return Err(SyncError::Gone(Gone::Lost)),
|
||||
c => return Err(format!("the server answered {c}").into()),
|
||||
}
|
||||
let files: Vec<RemoteFile> = serde_json::from_slice(&body).map_err(|e| e.to_string())?;
|
||||
Ok(files
|
||||
.into_iter()
|
||||
.filter(|f| safe_path(&f.path).is_some())
|
||||
.map(|f| (f.path, f.mtime))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// One round: reconcile the folder with the server. True when the server's version of some
|
||||
/// file was applied here, so the open project may need reloading.
|
||||
pub async fn sync_once(dir: &Path) -> Result<bool, SyncError> {
|
||||
let mut share = Share::load(dir).ok_or_else(|| "this project is not shared".to_string())?;
|
||||
let base = share.base();
|
||||
let remote = list_remote(&base, !share.synced.is_empty()).await?;
|
||||
let local = scan(dir);
|
||||
let paths: BTreeSet<&String> = remote
|
||||
.keys()
|
||||
.chain(local.keys())
|
||||
.chain(share.synced.keys())
|
||||
.collect();
|
||||
let (mut synced, mut applied) = (share.synced.clone(), false);
|
||||
for path in paths {
|
||||
let (l, r) = (local.get(path).copied(), remote.get(path).copied());
|
||||
let was = synced.get(path).copied();
|
||||
let url = format!("{base}/f/{path}");
|
||||
let file = dir.join(path);
|
||||
if l != was.map(|s| s.local) {
|
||||
let Some(hash) = l else {
|
||||
let (code, _) = net::request("DELETE", &url, None).await?;
|
||||
if code != 204 && code != 404 {
|
||||
return Err(format!("could not delete {path}: server answered {code}").into());
|
||||
}
|
||||
synced.remove(path);
|
||||
continue;
|
||||
};
|
||||
let bytes = vfs::read(&file).map_err(|e| format!("{path}: {e}"))?;
|
||||
let (code, body) = net::request("PUT", &url, Some(bytes)).await?;
|
||||
if code != 200 {
|
||||
return Err(format!("could not upload {path}: server answered {code}").into());
|
||||
}
|
||||
let remote = String::from_utf8_lossy(&body).trim().parse().unwrap_or(0);
|
||||
synced.insert(
|
||||
path.clone(),
|
||||
Stamp {
|
||||
remote,
|
||||
local: hash,
|
||||
},
|
||||
);
|
||||
} else if r != was.map(|s| s.remote) {
|
||||
applied = true;
|
||||
let Some(stamp) = r else {
|
||||
vfs::remove_file(&file).ok();
|
||||
synced.remove(path);
|
||||
continue;
|
||||
};
|
||||
let (code, bytes) = net::request("GET", &url, None).await?;
|
||||
if code != 200 {
|
||||
return Err(format!("could not download {path}: server answered {code}").into());
|
||||
}
|
||||
if let Some(parent) = file.parent() {
|
||||
vfs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
}
|
||||
vfs::write(&file, &bytes).map_err(|e| format!("{path}: {e}"))?;
|
||||
synced.insert(
|
||||
path.clone(),
|
||||
Stamp {
|
||||
remote: stamp,
|
||||
local: fingerprint(&bytes),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
if synced != share.synced {
|
||||
share.synced = synced;
|
||||
share.save(dir)?;
|
||||
}
|
||||
Ok(applied)
|
||||
}
|
||||
|
||||
/// What the shell shows and acts on for one shared project.
|
||||
#[derive(Default)]
|
||||
pub struct Status {
|
||||
/// What to send a collaborator.
|
||||
pub link: String,
|
||||
stop: AtomicBool,
|
||||
inner: Mutex<Inner>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Inner {
|
||||
error: Option<String>,
|
||||
gone: Option<Gone>,
|
||||
synced_once: bool,
|
||||
changed: bool,
|
||||
}
|
||||
|
||||
impl Status {
|
||||
fn inner(&self) -> std::sync::MutexGuard<'_, Inner> {
|
||||
self.inner.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// The server's copy changed files in the folder since the last call.
|
||||
pub fn take_changed(&self) -> bool {
|
||||
std::mem::take(&mut self.inner().changed)
|
||||
}
|
||||
|
||||
/// Why this project stopped syncing for good, if it did.
|
||||
pub fn gone(&self) -> Option<Gone> {
|
||||
self.inner().gone
|
||||
}
|
||||
|
||||
pub fn label(&self) -> String {
|
||||
let i = self.inner();
|
||||
match (&i.error, i.synced_once) {
|
||||
(Some(e), _) => format!("Sync error: {e}"),
|
||||
(None, true) => "Synced".into(),
|
||||
(None, false) => "Connecting".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(dir: PathBuf, status: Arc<Status>) {
|
||||
while !status.stop.load(Ordering::Relaxed) {
|
||||
let round = sync_once(&dir).await;
|
||||
{
|
||||
let mut i = status.inner();
|
||||
match round {
|
||||
Ok(changed) => {
|
||||
i.error = None;
|
||||
i.synced_once = true;
|
||||
i.changed |= changed;
|
||||
}
|
||||
Err(SyncError::Gone(g)) => {
|
||||
i.gone = Some(g);
|
||||
status.stop.store(true, Ordering::Relaxed);
|
||||
}
|
||||
Err(e) => i.error = Some(e.to_string()),
|
||||
}
|
||||
}
|
||||
net::sleep(POLL).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// One look at the library: share local projects, join the ones this device lacks. False when
|
||||
/// the relay has no library.
|
||||
pub async fn library_round(root: &Path, server: &str) -> Result<bool, String> {
|
||||
let (code, body) = net::request("GET", &format!("{server}/library"), None).await?;
|
||||
match code {
|
||||
200 => {}
|
||||
404 => return Ok(false),
|
||||
c => return Err(format!("the server answered {c}")),
|
||||
}
|
||||
let tokens: Vec<String> = serde_json::from_slice(&body).map_err(|e| e.to_string())?;
|
||||
let mut known = BTreeSet::new();
|
||||
for e in vfs::read_dir(root).into_iter().flatten().flatten() {
|
||||
let hidden = e.file_name().to_string_lossy().starts_with('.');
|
||||
if !hidden && let Some(s) = Share::load(&e.path()) {
|
||||
known.insert(s.token);
|
||||
}
|
||||
}
|
||||
let store = crate::project::Store::open(root).map_err(|e| e.to_string())?;
|
||||
for entry in store.list() {
|
||||
if Share::load(&entry.dir).is_none() {
|
||||
Share::new(server).save(&entry.dir)?;
|
||||
}
|
||||
}
|
||||
for token in tokens.iter().filter(|t| !known.contains(*t)) {
|
||||
let share = Share::from_link(&format!("{server}/?share={token}"))?;
|
||||
let dir = root.join(format!("shared-{}", &token[..8]));
|
||||
vfs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
share.save(&dir)?;
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn run_library(root: PathBuf, server: String, alive: Arc<AtomicBool>) {
|
||||
while alive.load(Ordering::Relaxed) {
|
||||
match library_round(&root, &server).await {
|
||||
Ok(true) => {}
|
||||
Ok(false) => alive.store(false, Ordering::Relaxed),
|
||||
Err(e) => log::warn!("library: {e}"),
|
||||
}
|
||||
net::sleep(LIBRARY_POLL).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// The shared projects this process is keeping in sync.
|
||||
#[derive(Default)]
|
||||
pub struct Sync {
|
||||
running: HashMap<PathBuf, Arc<Status>>,
|
||||
library: Option<Library>,
|
||||
}
|
||||
|
||||
/// The relay this device keeps a library with; `alive` goes false when it turns out not to
|
||||
/// be one.
|
||||
struct Library {
|
||||
server: String,
|
||||
alive: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl Sync {
|
||||
/// Start syncing the project in `dir` (it must have a `.share` file). A second call for the
|
||||
/// same folder does nothing.
|
||||
pub fn start(&mut self, dir: &Path) {
|
||||
if self.running.contains_key(dir) || Share::load(dir).is_none() {
|
||||
return;
|
||||
}
|
||||
let status = Arc::new(Status {
|
||||
link: Share::load(dir).map(|s| s.link()).unwrap_or_default(),
|
||||
..Status::default()
|
||||
});
|
||||
net::spawn(run(dir.to_path_buf(), status.clone()));
|
||||
self.running.insert(dir.to_path_buf(), status);
|
||||
}
|
||||
|
||||
/// Resume every shared project under the store root (at startup).
|
||||
pub fn start_all(&mut self, root: &Path) {
|
||||
let Ok(entries) = vfs::read_dir(root) else {
|
||||
return;
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
if e.file_type().is_ok_and(|t| t.is_dir()) {
|
||||
self.start(&e.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Make `dir` a shared project on `server`; returns the link to hand out.
|
||||
pub fn share(&mut self, dir: &Path, server: &str) -> Result<String, String> {
|
||||
if let Some(existing) = Share::load(dir) {
|
||||
return Ok(existing.link());
|
||||
}
|
||||
let share = Share::new(server);
|
||||
share.save(dir)?;
|
||||
self.start(dir);
|
||||
Ok(share.link())
|
||||
}
|
||||
|
||||
/// Download the project behind `link` into a new folder `dir` and keep it in sync.
|
||||
pub fn join(&mut self, dir: &Path, link: &str) -> Result<(), String> {
|
||||
let share = Share::from_link(link)?;
|
||||
vfs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||
share.save(dir)?;
|
||||
self.start(dir);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn status(&self, dir: &Path) -> Option<Arc<Status>> {
|
||||
self.running.get(dir).cloned()
|
||||
}
|
||||
|
||||
pub fn dirs(&self) -> Vec<PathBuf> {
|
||||
self.running.keys().cloned().collect()
|
||||
}
|
||||
|
||||
pub fn is_active(&self) -> bool {
|
||||
!self.running.is_empty() || self.library_active()
|
||||
}
|
||||
|
||||
/// Keep a library with the relay at `server` (the page's own origin on the web): its
|
||||
/// projects appear here and this device's projects appear there. Does nothing, quietly, when
|
||||
/// the relay was not started with `--library`.
|
||||
pub fn enable_library(&mut self, root: &Path, server: &str) {
|
||||
if self.library.is_some() {
|
||||
return;
|
||||
}
|
||||
let server = server.trim_end_matches('/').to_string();
|
||||
let alive = Arc::new(AtomicBool::new(true));
|
||||
net::spawn(run_library(root.to_path_buf(), server.clone(), alive.clone()));
|
||||
self.library = Some(Library { server, alive });
|
||||
}
|
||||
|
||||
/// True while a library relay is being followed: projects there are the user's own, so
|
||||
/// the share/join controls do not apply.
|
||||
pub fn library_active(&self) -> bool {
|
||||
self.library
|
||||
.as_ref()
|
||||
.is_some_and(|l| l.alive.load(Ordering::Relaxed))
|
||||
}
|
||||
|
||||
/// A project leaves this device for good (trashed). In a library it is deleted on the relay
|
||||
/// too, so the other devices drop it; otherwise only this folder stops syncing.
|
||||
pub fn drop_project(&mut self, dir: &Path) {
|
||||
if let (Some(lib), Some(share)) = (&self.library, Share::load(dir))
|
||||
&& lib.alive.load(Ordering::Relaxed)
|
||||
&& share.server == lib.server
|
||||
{
|
||||
let url = share.base();
|
||||
net::spawn(async move {
|
||||
match net::request("DELETE", &url, None).await {
|
||||
Ok((204 | 404 | 410, _)) => {}
|
||||
Ok((code, _)) => log::warn!("library: delete answered {code}"),
|
||||
Err(e) => log::warn!("library: could not delete: {e}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
self.unshare(dir);
|
||||
}
|
||||
|
||||
/// Stop syncing `dir` and forget its share (the files stay).
|
||||
pub fn unshare(&mut self, dir: &Path) {
|
||||
if let Some(s) = self.running.remove(dir) {
|
||||
s.stop.store(true, Ordering::Relaxed);
|
||||
}
|
||||
vfs::remove_file(dir.join(SHARE_FILE)).ok();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod net {
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Duration;
|
||||
|
||||
pub async fn request(
|
||||
method: &str,
|
||||
url: &str,
|
||||
body: Option<Vec<u8>>,
|
||||
) -> Result<(u16, Vec<u8>), String> {
|
||||
static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
|
||||
let client = CLIENT.get_or_init(reqwest::Client::new);
|
||||
let method = reqwest::Method::from_bytes(method.as_bytes()).map_err(|e| e.to_string())?;
|
||||
let mut req = client.request(method, url).timeout(Duration::from_secs(60));
|
||||
if let Some(b) = body {
|
||||
req = req.body(b);
|
||||
}
|
||||
let res = req.send().await.map_err(|e| e.to_string())?;
|
||||
let code = res.status().as_u16();
|
||||
let bytes = res.bytes().await.map_err(|e| e.to_string())?;
|
||||
Ok((code, bytes.to_vec()))
|
||||
}
|
||||
|
||||
pub async fn sleep(d: Duration) {
|
||||
tokio::time::sleep(d).await;
|
||||
}
|
||||
|
||||
/// Own thread and runtime: the editor's event loop has none.
|
||||
pub fn spawn(fut: impl Future<Output = ()> + Send + 'static) {
|
||||
std::thread::Builder::new()
|
||||
.name("papercraft-sync".into())
|
||||
.spawn(move || {
|
||||
if let Ok(rt) = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
rt.block_on(fut);
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod net {
|
||||
use std::time::Duration;
|
||||
use wasm_bindgen::{JsCast, JsValue};
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
|
||||
fn js(e: JsValue) -> String {
|
||||
e.as_string().unwrap_or_else(|| format!("{e:?}"))
|
||||
}
|
||||
|
||||
pub async fn request(
|
||||
method: &str,
|
||||
url: &str,
|
||||
body: Option<Vec<u8>>,
|
||||
) -> Result<(u16, Vec<u8>), String> {
|
||||
let init = web_sys::RequestInit::new();
|
||||
init.set_method(method);
|
||||
if let Some(b) = body {
|
||||
init.set_body(&js_sys::Uint8Array::from(b.as_slice()));
|
||||
}
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
let res = JsFuture::from(window.fetch_with_str_and_init(url, &init))
|
||||
.await
|
||||
.map_err(js)?;
|
||||
let res: web_sys::Response = res.dyn_into().map_err(js)?;
|
||||
let buf = JsFuture::from(res.array_buffer().map_err(js)?)
|
||||
.await
|
||||
.map_err(js)?;
|
||||
Ok((res.status(), js_sys::Uint8Array::new(&buf).to_vec()))
|
||||
}
|
||||
|
||||
pub async fn sleep(d: Duration) {
|
||||
let promise = js_sys::Promise::new(&mut |resolve, _| {
|
||||
if let Some(w) = web_sys::window() {
|
||||
let _ = w.set_timeout_with_callback_and_timeout_and_arguments_0(
|
||||
&resolve,
|
||||
d.as_millis() as i32,
|
||||
);
|
||||
}
|
||||
});
|
||||
let _ = JsFuture::from(promise).await;
|
||||
}
|
||||
|
||||
pub fn spawn(fut: impl Future<Output = ()> + 'static) {
|
||||
wasm_bindgen_futures::spawn_local(fut);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn server_paths_cannot_leave_the_project() {
|
||||
assert!(safe_path("pages/a.json").is_some());
|
||||
for bad in [
|
||||
"",
|
||||
"../x",
|
||||
"a/../../x",
|
||||
"/etc/passwd",
|
||||
".share",
|
||||
"thumbnail.png",
|
||||
] {
|
||||
assert!(safe_path(bad).is_none(), "{bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn links_round_trip() {
|
||||
let s = Share::new("https://papercraft.example.com/");
|
||||
let back = Share::from_link(&s.link()).unwrap();
|
||||
assert_eq!(
|
||||
(back.server.as_str(), back.token.as_str()),
|
||||
(s.server.as_str(), s.token.as_str())
|
||||
);
|
||||
assert!(Share::from_link("https://x/?share=zz").is_err());
|
||||
assert!(Share::from_link("https://x/").is_err());
|
||||
assert!(Share::from_link("ftp://x/?share=00000000000000000000000000000000").is_err());
|
||||
}
|
||||
}
|
||||
+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");
|
||||
}
|
||||
}
|
||||
+345
-32
@@ -18,6 +18,20 @@ fn on_every_page(ed: &mut Editor, label: &str, f: impl Fn(&mut crate::history::T
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_name(s: &str) -> String {
|
||||
s.trim()
|
||||
.trim_start_matches("--")
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || c == '_' {
|
||||
c
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
|
||||
let p = theme::pal(ui);
|
||||
let heading = |ui: &mut Ui, t: &str| {
|
||||
@@ -33,6 +47,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
|
||||
@@ -82,10 +97,10 @@ pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
|
||||
}
|
||||
}
|
||||
// ---- modes
|
||||
if !ed.doc.modes.is_empty() {
|
||||
heading(ui, "Mode");
|
||||
let modes: Vec<String> = ed.doc.modes.keys().cloned().collect();
|
||||
let active = ed.doc.mode.clone();
|
||||
heading(ui, "Modes");
|
||||
let modes: Vec<String> = ed.doc.modes.keys().cloned().collect();
|
||||
let active = ed.doc.mode.clone();
|
||||
if !modes.is_empty() {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for m in &modes {
|
||||
let on = active.as_deref() == Some(m);
|
||||
@@ -95,9 +110,47 @@ pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
|
||||
tx.set_mode(&m);
|
||||
});
|
||||
}
|
||||
if crate::theme::small_button(ui, "✕")
|
||||
.on_hover_text("Delete mode")
|
||||
.clicked()
|
||||
{
|
||||
let m = m.clone();
|
||||
on_every_page(ed, "Remove variable mode", |tx| {
|
||||
tx.remove_mode(&m);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
let w = (ui.available_width() - 44.0).max(60.0);
|
||||
let r = ui.add(
|
||||
egui::TextEdit::singleline(&mut st.mode_draft)
|
||||
.hint_text("New mode (e.g. Dark)")
|
||||
.desired_width(w)
|
||||
.margin(crate::theme::INPUT_MARGIN),
|
||||
);
|
||||
let enter = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
let mode_name = st.mode_draft.trim().to_string();
|
||||
if (crate::theme::small_button(ui, "Add").clicked() || enter)
|
||||
&& !mode_name.is_empty()
|
||||
{
|
||||
st.mode_draft.clear();
|
||||
let snapshot: Vec<(String, Color)> = ed
|
||||
.doc
|
||||
.variables
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), *v))
|
||||
.collect();
|
||||
on_every_page(ed, "Add variable mode", |tx| {
|
||||
tx.doc.modes.entry(mode_name.clone()).or_default();
|
||||
for (k, v) in &snapshot {
|
||||
tx.set_variable_in_mode(&mode_name, k, *v);
|
||||
}
|
||||
tx.set_mode(&mode_name);
|
||||
});
|
||||
}
|
||||
});
|
||||
// ---- variables
|
||||
heading(ui, "Variables");
|
||||
if ed.doc.variables.is_empty() {
|
||||
@@ -128,10 +181,46 @@ pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
|
||||
});
|
||||
}
|
||||
}
|
||||
ui.label(RichText::new(format!("--{name}")).monospace().size(theme::SECONDARY + 1.0));
|
||||
let is_renaming = st
|
||||
.var_renaming
|
||||
.as_ref()
|
||||
.is_some_and(|(old, _)| old == &name);
|
||||
if is_renaming {
|
||||
let (_, mut draft) = st.var_renaming.clone().unwrap();
|
||||
let r = ui.add(
|
||||
egui::TextEdit::singleline(&mut draft)
|
||||
.desired_width(90.0)
|
||||
.margin(crate::theme::INPUT_MARGIN),
|
||||
);
|
||||
if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
st.var_renaming = None;
|
||||
} else if r.lost_focus() || ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||
let new = sanitize_name(&draft);
|
||||
let old = name.clone();
|
||||
st.var_renaming = None;
|
||||
if !new.is_empty() && new != old {
|
||||
on_every_page(ed, "Rename variable", |tx| {
|
||||
tx.rename_variable(&old, &new);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
st.var_renaming = Some((name.clone(), draft));
|
||||
}
|
||||
} else {
|
||||
let lbl = ui.add(
|
||||
egui::Label::new(
|
||||
RichText::new(format!("--{name}"))
|
||||
.monospace()
|
||||
.size(theme::SECONDARY + 1.0),
|
||||
)
|
||||
.sense(egui::Sense::click()),
|
||||
);
|
||||
if lbl.on_hover_text("Click to rename").clicked() {
|
||||
st.var_renaming = Some((name.clone(), name.clone()));
|
||||
}
|
||||
}
|
||||
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()
|
||||
{
|
||||
@@ -151,14 +240,8 @@ pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
|
||||
.margin(crate::theme::INPUT_MARGIN),
|
||||
);
|
||||
let enter = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
let name: String = st
|
||||
.variable_draft
|
||||
.trim()
|
||||
.trim_start_matches("--")
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() || c == '_' { c } else { '-' })
|
||||
.collect();
|
||||
if (ui.small_button("Add").clicked() || enter) && !name.is_empty() {
|
||||
let name = sanitize_name(&st.variable_draft);
|
||||
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| {
|
||||
@@ -166,21 +249,108 @@ pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
|
||||
});
|
||||
}
|
||||
});
|
||||
// ---- tokens
|
||||
if !ed.doc.tokens.is_empty() {
|
||||
heading(ui, "Tokens");
|
||||
for (k, v) in &ed.doc.tokens {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new(format!("--{k}")).monospace().size(theme::SECONDARY));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.label(RichText::new(v).monospace().weak().size(theme::SECONDARY));
|
||||
});
|
||||
});
|
||||
}
|
||||
if !ed.doc.variables.is_empty() || !ed.doc.tokens.is_empty() {
|
||||
ui.horizontal(|ui| {
|
||||
if crate::theme::small_button(ui, "Copy CSS")
|
||||
.on_hover_text("Copy variables and tokens as CSS custom properties")
|
||||
.clicked()
|
||||
{
|
||||
let mut lines = vec![":root {".to_string()];
|
||||
for (k, v) in &ed.doc.variables {
|
||||
lines.push(format!(" --{k}: {};", v.to_hex()));
|
||||
}
|
||||
for (k, v) in &ed.doc.tokens {
|
||||
lines.push(format!(" --{k}: {v};"));
|
||||
}
|
||||
lines.push("}".to_string());
|
||||
for (mode, map) in &ed.doc.modes {
|
||||
lines.push(format!("[data-theme=\"{mode}\"] {{"));
|
||||
for (k, v) in map {
|
||||
lines.push(format!(" --{k}: {};", v.to_hex()));
|
||||
}
|
||||
lines.push("}".to_string());
|
||||
}
|
||||
ui.ctx().copy_text(lines.join("\n"));
|
||||
ed.status = "Copied tokens to clipboard".into();
|
||||
}
|
||||
if crate::theme::small_button(ui, "Copy JSON")
|
||||
.on_hover_text("Copy variables and tokens in W3C DTCG JSON format")
|
||||
.clicked()
|
||||
{
|
||||
let mut root = serde_json::Map::new();
|
||||
for (k, v) in &ed.doc.variables {
|
||||
root.insert(
|
||||
k.clone(),
|
||||
serde_json::json!({ "$type": "color", "$value": v.to_hex() }),
|
||||
);
|
||||
}
|
||||
for (k, v) in &ed.doc.tokens {
|
||||
root.insert(
|
||||
k.clone(),
|
||||
serde_json::json!({ "$type": "dimension", "$value": v }),
|
||||
);
|
||||
}
|
||||
if let Ok(json) =
|
||||
serde_json::to_string_pretty(&serde_json::Value::Object(root))
|
||||
{
|
||||
ui.ctx().copy_text(json);
|
||||
ed.status = "Copied tokens to clipboard".into();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
// ---- tokens
|
||||
heading(ui, "Tokens");
|
||||
let tokens: Vec<(String, String)> = ed
|
||||
.doc
|
||||
.tokens
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect();
|
||||
for (k, v) in tokens {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new(format!("--{k}")).monospace().size(theme::SECONDARY));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if crate::theme::small_button(ui, "✕")
|
||||
.on_hover_text("Delete token")
|
||||
.clicked()
|
||||
{
|
||||
on_every_page(ed, "Delete token", |tx| tx.remove_token(&k));
|
||||
}
|
||||
ui.label(RichText::new(&v).monospace().weak().size(theme::SECONDARY));
|
||||
});
|
||||
});
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
let avail = (ui.available_width() - 48.0).max(80.0);
|
||||
let r1 = ui.add(
|
||||
egui::TextEdit::singleline(&mut st.token_name_draft)
|
||||
.hint_text("spacing-md")
|
||||
.desired_width(avail * 0.55)
|
||||
.margin(crate::theme::INPUT_MARGIN),
|
||||
);
|
||||
let r2 = ui.add(
|
||||
egui::TextEdit::singleline(&mut st.token_val_draft)
|
||||
.hint_text("16px")
|
||||
.desired_width(avail * 0.45)
|
||||
.margin(crate::theme::INPUT_MARGIN),
|
||||
);
|
||||
let enter = (r1.lost_focus() || r2.lost_focus())
|
||||
&& ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
let name = sanitize_name(&st.token_name_draft);
|
||||
let val = st.token_val_draft.trim().to_string();
|
||||
if (crate::theme::small_button(ui, "Add").clicked() || enter)
|
||||
&& !name.is_empty()
|
||||
&& !val.is_empty()
|
||||
{
|
||||
st.token_name_draft.clear();
|
||||
st.token_val_draft.clear();
|
||||
on_every_page(ed, "Set token", |tx| tx.set_token(&name, &val));
|
||||
}
|
||||
});
|
||||
// ---- components
|
||||
heading(ui, "Components");
|
||||
let mut comps: Vec<(NodeId, String, usize)> = ed
|
||||
let mut comps: Vec<(NodeId, String, f64, f64, usize)> = ed
|
||||
.doc
|
||||
.nodes
|
||||
.values()
|
||||
@@ -192,7 +362,7 @@ pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
|
||||
.values()
|
||||
.filter(|i| i.id != n.id && i.instance.as_ref().is_some_and(|l| l.main == n.id))
|
||||
.count();
|
||||
(n.id, n.name.clone(), uses)
|
||||
(n.id, n.name.clone(), n.rect.w, n.rect.h, uses)
|
||||
})
|
||||
.collect();
|
||||
comps.sort_by_key(|c| c.0);
|
||||
@@ -203,21 +373,164 @@ pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
|
||||
.color(p.secondary),
|
||||
);
|
||||
}
|
||||
for (id, name, uses) in comps {
|
||||
if comps.len() > 3 || !st.component_filter.is_empty() {
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut st.component_filter)
|
||||
.hint_text("Filter components…")
|
||||
.desired_width(f32::INFINITY)
|
||||
.margin(crate::theme::INPUT_MARGIN),
|
||||
);
|
||||
ui.add_space(2.0);
|
||||
}
|
||||
if let Some(inst_id) = ed
|
||||
.selection
|
||||
.first()
|
||||
.copied()
|
||||
.filter(|id| ed.is_instance_root(*id))
|
||||
{
|
||||
let cur_main = ed.instance_main_root(inst_id);
|
||||
let cur_name = cur_main
|
||||
.and_then(|m| ed.doc.get(m))
|
||||
.map(|n| n.name.clone())
|
||||
.unwrap_or_else(|| "Instance".into());
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.label(
|
||||
RichText::new("Instance")
|
||||
.size(theme::SECONDARY)
|
||||
.color(p.secondary),
|
||||
);
|
||||
egui::ComboBox::from_id_salt("design_swap_instance")
|
||||
.selected_text(cur_name)
|
||||
.width(100.0)
|
||||
.show_ui(ui, |ui| {
|
||||
for (cid, cname, _, _, _) in &comps {
|
||||
if ui.selectable_label(cur_main == Some(*cid), cname).clicked()
|
||||
&& cur_main != Some(*cid)
|
||||
{
|
||||
ed.swap_instance(inst_id, *cid);
|
||||
}
|
||||
}
|
||||
});
|
||||
if crate::theme::small_button(ui, "Reset").clicked() {
|
||||
ed.reset_overrides();
|
||||
}
|
||||
if crate::theme::small_button(ui, "Detach").clicked() {
|
||||
ed.detach_instance();
|
||||
}
|
||||
});
|
||||
}
|
||||
let needle = st.component_filter.trim().to_lowercase();
|
||||
for (id, name, w, h, uses) in comps {
|
||||
if !needle.is_empty() && !name.to_lowercase().contains(&needle) {
|
||||
continue;
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new(&name).font(theme::medium(theme::BODY)));
|
||||
ui.label(RichText::new(format!("{uses}")).weak().size(theme::SECONDARY));
|
||||
ui.label(
|
||||
RichText::new(format!("{w:.0}×{h:.0} · {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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
// ---- design lint
|
||||
let issues = crate::mcp::lint::lint(
|
||||
&ed.doc,
|
||||
&ed.doc.root,
|
||||
&crate::mcp::lint::DEFAULT_SCALE,
|
||||
);
|
||||
ui.horizontal(|ui| {
|
||||
let title = if issues.is_empty() {
|
||||
"Lint · Clean".to_string()
|
||||
} else {
|
||||
format!("Lint · {} issues", issues.len())
|
||||
};
|
||||
heading(ui, &title);
|
||||
if !issues.is_empty() {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if crate::theme::small_button(ui, "Fix all")
|
||||
.on_hover_text("Auto-fix grid, color variables, text scale, and touch targets")
|
||||
.clicked()
|
||||
{
|
||||
ed.edit("Auto-fix lint issues", |tx| {
|
||||
let roots = tx.doc().root.clone();
|
||||
crate::mcp::lint::autofix(
|
||||
tx,
|
||||
&roots,
|
||||
&crate::mcp::lint::DEFAULT_SCALE,
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
for issue in issues.iter().take(30) {
|
||||
let node_name = ed
|
||||
.doc
|
||||
.get(issue.node)
|
||||
.map(|n| n.name.clone())
|
||||
.unwrap_or_else(|| "Layer".into());
|
||||
let dot_color = match issue.severity {
|
||||
"error" => egui::Color32::from_rgb(239, 68, 68),
|
||||
"warning" => egui::Color32::from_rgb(245, 158, 11),
|
||||
_ => p.secondary,
|
||||
};
|
||||
let fixable = matches!(
|
||||
issue.rule,
|
||||
"spacing-grid"
|
||||
| "hard-coded-color"
|
||||
| "text-size"
|
||||
| "type-scale"
|
||||
| "touch-target"
|
||||
);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new("●").color(dot_color).size(theme::SECONDARY));
|
||||
let r = ui.add(
|
||||
egui::Label::new(
|
||||
RichText::new(format!("{node_name}: {}", issue.message))
|
||||
.size(theme::SECONDARY),
|
||||
)
|
||||
.truncate()
|
||||
.sense(egui::Sense::click()),
|
||||
);
|
||||
if r.on_hover_text(&issue.fix).clicked() {
|
||||
ed.selection = vec![issue.node];
|
||||
ed.run(crate::editor::shortcuts::Action::ZoomSelection);
|
||||
}
|
||||
if fixable {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if crate::theme::small_button(ui, "Fix")
|
||||
.on_hover_text(&issue.fix)
|
||||
.clicked()
|
||||
{
|
||||
let target = issue.node;
|
||||
ed.edit("Fix lint issue", |tx| {
|
||||
crate::mcp::lint::autofix_node(
|
||||
tx,
|
||||
target,
|
||||
&crate::mcp::lint::DEFAULT_SCALE,
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+138
-9
@@ -18,6 +18,30 @@ pub enum HomeAction {
|
||||
Restore(PathBuf),
|
||||
DeleteForever(PathBuf),
|
||||
EmptyTrash,
|
||||
/// Show the projects folder in the system file manager.
|
||||
RevealRoot,
|
||||
/// Put a project on this server (a relay) so others can open and edit it.
|
||||
Share(PathBuf, String),
|
||||
/// Open the shared project behind a share link.
|
||||
Join(String),
|
||||
/// Stop syncing a project; it stays here as a local copy.
|
||||
Unshare(PathBuf),
|
||||
}
|
||||
|
||||
/// The small window opened by "Share…" and "Join shared project…".
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ShareDialog {
|
||||
/// The project to share, or None to join.
|
||||
pub dir: Option<PathBuf>,
|
||||
/// The server to share on, or the link to join.
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// What the sync engine reports for one shared project.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ShareInfo {
|
||||
pub status: String,
|
||||
pub link: String,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -31,11 +55,25 @@ pub struct HomeView {
|
||||
/// "Now" for relative timestamps (unix ms); fixed in screenshots.
|
||||
pub now_ms: u64,
|
||||
pub root: PathBuf,
|
||||
pub dialog: Option<ShareDialog>,
|
||||
/// Shared projects by folder, filled by the shell every frame.
|
||||
pub shared: HashMap<PathBuf, ShareInfo>,
|
||||
/// Where a new share goes unless the user types another server.
|
||||
pub default_server: String,
|
||||
/// The server keeps a library: every project is shared, so no share controls.
|
||||
pub library: bool,
|
||||
thumbs: HashMap<PathBuf, (Option<SystemTime>, egui::TextureHandle)>,
|
||||
}
|
||||
|
||||
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 +91,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 +164,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")
|
||||
@@ -168,6 +209,12 @@ pub fn draw(ui: &mut Ui, hv: &mut HomeView, native_chrome: bool, translucent: bo
|
||||
if ui.button("Import…").clicked() {
|
||||
hv.actions.push(HomeAction::Import);
|
||||
}
|
||||
if !hv.library && ui.button("Join shared…").clicked() {
|
||||
hv.dialog = Some(ShareDialog {
|
||||
dir: None,
|
||||
text: String::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -191,6 +238,55 @@ pub fn draw(ui: &mut Ui, hv: &mut HomeView, native_chrome: bool, translucent: bo
|
||||
}
|
||||
});
|
||||
});
|
||||
share_dialog(ui, hv);
|
||||
}
|
||||
|
||||
fn share_dialog(ui: &mut Ui, hv: &mut HomeView) {
|
||||
let Some(mut d) = hv.dialog.take() else {
|
||||
return;
|
||||
};
|
||||
let (mut keep, mut submit) = (true, false);
|
||||
let (title, hint, button) = match d.dir {
|
||||
Some(_) => (
|
||||
"Share project",
|
||||
"Server, e.g. https://papercraft.example.com",
|
||||
"Share",
|
||||
),
|
||||
None => ("Join shared project", "Paste the share link", "Join"),
|
||||
};
|
||||
egui::Window::new(title)
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
|
||||
.open(&mut keep)
|
||||
.show(ui.ctx(), |ui| {
|
||||
ui.set_width(380.0);
|
||||
let r = ui.add(
|
||||
egui::TextEdit::singleline(&mut d.text)
|
||||
.hint_text(hint)
|
||||
.desired_width(f32::INFINITY),
|
||||
);
|
||||
if d.dir.is_some() {
|
||||
ui.label(
|
||||
RichText::new("Anyone with the link can open and edit the project.")
|
||||
.size(theme::SECONDARY)
|
||||
.color(theme::pal(ui).secondary),
|
||||
);
|
||||
}
|
||||
let enter = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
if ui.button(button).clicked() || enter {
|
||||
submit = true;
|
||||
}
|
||||
});
|
||||
if submit && !d.text.trim().is_empty() {
|
||||
let text = d.text.trim().to_string();
|
||||
hv.actions.push(match d.dir {
|
||||
Some(dir) => HomeAction::Share(dir, text),
|
||||
None => HomeAction::Join(text),
|
||||
});
|
||||
} else if keep {
|
||||
hv.dialog = Some(d);
|
||||
}
|
||||
}
|
||||
|
||||
fn sidebar_item(
|
||||
@@ -391,10 +487,43 @@ fn project_card(ui: &mut Ui, hv: &mut HomeView, e: &Entry) {
|
||||
.size(theme::SECONDARY)
|
||||
.color(p.secondary),
|
||||
);
|
||||
if let Some(s) = hv.shared.get(&e.dir) {
|
||||
let prefix = if hv.library { "" } else { "Shared · " };
|
||||
ui.label(
|
||||
RichText::new(format!("{prefix}{}", s.status))
|
||||
.size(theme::SECONDARY)
|
||||
.color(p.secondary),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn card_menu(ui: &mut Ui, hv: &mut HomeView, e: &Entry) {
|
||||
match hv.shared.get(&e.dir).map(|s| s.link.clone()) {
|
||||
_ if hv.library => {}
|
||||
Some(link) => {
|
||||
if ui.button("Copy share link").clicked() {
|
||||
ui.ctx().copy_text(link);
|
||||
ui.close();
|
||||
}
|
||||
if ui.button("Stop sharing").clicked() {
|
||||
hv.actions.push(HomeAction::Unshare(e.dir.clone()));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if ui.button("Share…").clicked() {
|
||||
hv.dialog = Some(ShareDialog {
|
||||
dir: Some(e.dir.clone()),
|
||||
text: hv.default_server.clone(),
|
||||
});
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
if !hv.library {
|
||||
ui.separator();
|
||||
}
|
||||
if ui.button("Open").clicked() {
|
||||
hv.actions.push(HomeAction::Open(e.dir.clone()));
|
||||
ui.close();
|
||||
@@ -436,7 +565,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() {
|
||||
|
||||
+577
-169
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
+82
-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;
|
||||
@@ -70,8 +71,25 @@ pub struct UiState {
|
||||
pub layer_filter: String,
|
||||
/// Name typed into the Design tab's new-variable box.
|
||||
pub variable_draft: String,
|
||||
/// Name typed into the Design tab's new-mode box.
|
||||
pub mode_draft: String,
|
||||
/// Name typed into the Design tab's new-token name box.
|
||||
pub token_name_draft: String,
|
||||
/// Value typed into the Design tab's new-token value box.
|
||||
pub token_val_draft: String,
|
||||
/// Filter text for the Design tab's components list.
|
||||
pub component_filter: String,
|
||||
/// Variable currently being renamed inline `(old_name, draft_new_name)`.
|
||||
pub var_renaming: Option<(String, 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)>,
|
||||
/// Selected codegen target in the inspector's Code & AI Handoff section.
|
||||
pub code_target: String,
|
||||
}
|
||||
|
||||
/// Where a dragged layer lands relative to a row.
|
||||
@@ -118,6 +136,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 +248,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 +292,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 +338,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 +400,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 +409,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 +451,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 +949,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::*;
|
||||
+1083
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));
|
||||
|
||||
+419
@@ -0,0 +1,419 @@
|
||||
//! 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::store::Data;
|
||||
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);
|
||||
|
||||
// A public hostname in Host is fine: the token is the credential.
|
||||
let r = http
|
||||
.post(format!("{base}/s/{good}/mcp"))
|
||||
.header("content-type", "application/json")
|
||||
.header("accept", "application/json, text/event-stream")
|
||||
.header("host", "papercraft.example.com")
|
||||
.body(init)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_ne!(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();
|
||||
}
|
||||
|
||||
/// Shared projects: files round-trip, bad paths are refused, and a second tab hears about the
|
||||
/// first tab's changes (but a tab does not hear its own).
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn shared_projects_store_files_and_tell_the_other_tabs() {
|
||||
let dir = std::env::temp_dir().join(format!("papercraft-data-{:016x}", fastrand::u64(..)));
|
||||
let listen: SocketAddr = "127.0.0.1:0".parse().expect("a valid address");
|
||||
let running =
|
||||
relay::spawn_with_data(listen, None, None, Some(Data { dir: dir.clone(), library: false })).expect("the relay binds");
|
||||
let port = running.port();
|
||||
let base = format!("http://127.0.0.1:{port}");
|
||||
let http = reqwest::Client::new();
|
||||
let t = token();
|
||||
|
||||
// Unknown project: nothing to list.
|
||||
let r = http
|
||||
.get(format!("{base}/p/{t}/files"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(r.status(), 404);
|
||||
|
||||
// Tab B listens before A writes.
|
||||
let url = format!("ws://127.0.0.1:{port}/p/{t}/ws?client=b");
|
||||
let (tx, rx) = std::sync::mpsc::channel::<String>();
|
||||
std::thread::spawn(move || {
|
||||
let (mut ws, _) = ws_connect(url).expect("connect");
|
||||
while let Ok(Ws::Text(m)) = ws.read() {
|
||||
tx.send(m.to_string()).ok();
|
||||
}
|
||||
});
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
|
||||
let put = |path: &str, client: &str| {
|
||||
http.put(format!("{base}/p/{t}/f/{path}"))
|
||||
.header("x-client", client)
|
||||
.body("hello")
|
||||
.send()
|
||||
};
|
||||
assert_eq!(put("pages/p1.json", "a").await.unwrap().status(), 200);
|
||||
let ev: Value = serde_json::from_str(&rx.recv_timeout(CONNECT_WAIT).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
(ev["op"].as_str(), ev["path"].as_str()),
|
||||
(Some("put"), Some("pages/p1.json"))
|
||||
);
|
||||
|
||||
// B's own writes are not echoed back to B.
|
||||
assert_eq!(put("project.json", "b").await.unwrap().status(), 200);
|
||||
assert!(rx.recv_timeout(Duration::from_millis(300)).is_err());
|
||||
|
||||
let r = http
|
||||
.get(format!("{base}/p/{t}/f/pages/p1.json"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(r.text().await.unwrap(), "hello");
|
||||
let files: Value = http
|
||||
.get(format!("{base}/p/{t}/files"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(files.as_array().unwrap().len(), 2);
|
||||
|
||||
let r = http
|
||||
.delete(format!("{base}/p/{t}/f/pages/p1.json"))
|
||||
.header("x-client", "a")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(r.status(), 204);
|
||||
let ev: Value = serde_json::from_str(&rx.recv_timeout(CONNECT_WAIT).unwrap()).unwrap();
|
||||
assert_eq!(ev["op"], "del");
|
||||
let r = http
|
||||
.get(format!("{base}/p/{t}/f/pages/p1.json"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(r.status(), 404);
|
||||
|
||||
// Traversal and bad tokens are plain 404s.
|
||||
for url in [
|
||||
format!("{base}/p/{t}/f/%2e%2e/escape"),
|
||||
format!("{base}/p/{t}/f/a/%2e%2e/%2e%2e/escape"),
|
||||
format!("{base}/p/ZZ/f/project.json"),
|
||||
] {
|
||||
let r = http.put(&url).body("x").send().await.unwrap();
|
||||
assert_eq!(r.status(), 404, "{url}");
|
||||
}
|
||||
assert!(!dir.join("escape").exists());
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
running.stop();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn shared_project_routes_are_off_without_a_data_dir() {
|
||||
let running = spawn_relay();
|
||||
let base = format!("http://127.0.0.1:{}", running.port());
|
||||
let r = reqwest::Client::new()
|
||||
.put(format!("{base}/p/{}/f/project.json", token()))
|
||||
.body("x")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(r.status(), 404);
|
||||
running.stop();
|
||||
}
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
//! Shared projects end to end: two project folders kept in step through a real relay.
|
||||
|
||||
use papercraft::mcp::relay;
|
||||
use papercraft::mcp::store::Data;
|
||||
use papercraft::sync::{self, SHARE_FILE, Share};
|
||||
use std::net::SocketAddr;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn temp(label: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!(
|
||||
"papercraft-sync-{label}-{:016x}",
|
||||
fastrand::u64(..)
|
||||
));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn read(dir: &Path, rel: &str) -> Option<String> {
|
||||
std::fs::read_to_string(dir.join(rel)).ok()
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn two_folders_follow_each_other_through_the_relay() {
|
||||
let data = temp("data");
|
||||
let listen: SocketAddr = "127.0.0.1:0".parse().unwrap();
|
||||
let running = relay::spawn_with_data(listen, None, None, Some(Data { dir: data.clone(), library: false })).unwrap();
|
||||
let server = format!("http://127.0.0.1:{}", running.port());
|
||||
|
||||
// A shares a project with two pages.
|
||||
let a = temp("a");
|
||||
std::fs::create_dir_all(a.join("pages")).unwrap();
|
||||
std::fs::write(a.join("project.json"), "manifest v1").unwrap();
|
||||
std::fs::write(a.join("pages/p1.json"), "page one").unwrap();
|
||||
let share = Share::new(&server);
|
||||
share.save(&a).unwrap();
|
||||
assert!(
|
||||
!sync::sync_once(&a).await.unwrap(),
|
||||
"uploading is not an applied change"
|
||||
);
|
||||
|
||||
// B joins from the link and gets everything, but not A's local-only state.
|
||||
let b = temp("b");
|
||||
let joined = Share::from_link(&share.link()).unwrap();
|
||||
joined.save(&b).unwrap();
|
||||
assert!(sync::sync_once(&b).await.unwrap());
|
||||
assert_eq!(read(&b, "project.json").as_deref(), Some("manifest v1"));
|
||||
assert_eq!(read(&b, "pages/p1.json").as_deref(), Some("page one"));
|
||||
// Nothing changed: both sides settle.
|
||||
assert!(!sync::sync_once(&b).await.unwrap());
|
||||
assert!(!sync::sync_once(&a).await.unwrap());
|
||||
|
||||
// B edits one page and adds another; A receives both.
|
||||
std::fs::write(b.join("pages/p1.json"), "page one, edited in B").unwrap();
|
||||
std::fs::write(b.join("pages/p2.json"), "page two").unwrap();
|
||||
sync::sync_once(&b).await.unwrap();
|
||||
assert!(sync::sync_once(&a).await.unwrap());
|
||||
assert_eq!(
|
||||
read(&a, "pages/p1.json").as_deref(),
|
||||
Some("page one, edited in B")
|
||||
);
|
||||
assert_eq!(read(&a, "pages/p2.json").as_deref(), Some("page two"));
|
||||
|
||||
// A deletes a page; B loses it too.
|
||||
std::fs::remove_file(a.join("pages/p2.json")).unwrap();
|
||||
sync::sync_once(&a).await.unwrap();
|
||||
assert!(sync::sync_once(&b).await.unwrap());
|
||||
assert!(read(&b, "pages/p2.json").is_none());
|
||||
|
||||
// Both edit the same file before syncing: the one that syncs last (the folder) wins.
|
||||
std::fs::write(a.join("pages/p1.json"), "from A").unwrap();
|
||||
std::fs::write(b.join("pages/p1.json"), "from B").unwrap();
|
||||
sync::sync_once(&a).await.unwrap();
|
||||
sync::sync_once(&b).await.unwrap();
|
||||
sync::sync_once(&a).await.unwrap();
|
||||
assert_eq!(read(&a, "pages/p1.json"), read(&b, "pages/p1.json"));
|
||||
|
||||
// The sync state never travels: the server holds no .share.
|
||||
assert!(!data.join(&share.token).join(SHARE_FILE).exists());
|
||||
|
||||
for d in [&a, &b, &data] {
|
||||
std::fs::remove_dir_all(d).ok();
|
||||
}
|
||||
running.stop();
|
||||
}
|
||||
|
||||
fn project(root: &Path, name: &str) -> PathBuf {
|
||||
let store = papercraft::project::Store::open(root).unwrap();
|
||||
store.create(name).unwrap().0.dir
|
||||
}
|
||||
|
||||
/// Every project directory under `root` that a device would list.
|
||||
fn names(root: &Path) -> Vec<String> {
|
||||
let store = papercraft::project::Store::open(root).unwrap();
|
||||
store.list().into_iter().map(|e| e.manifest.name).collect()
|
||||
}
|
||||
|
||||
fn library_relay(data: &Path, library: bool) -> (relay::Running, String) {
|
||||
let listen: SocketAddr = "127.0.0.1:0".parse().unwrap();
|
||||
let data = Data { dir: data.to_path_buf(), library };
|
||||
let running = relay::spawn_with_data(listen, None, None, Some(data)).unwrap();
|
||||
let server = format!("http://127.0.0.1:{}", running.port());
|
||||
(running, server)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn a_library_shows_every_project_on_every_device() {
|
||||
let data = temp("lib-data");
|
||||
let (_relay, server) = library_relay(&data, true);
|
||||
|
||||
// Device A has a project; one library round shares it and a sync uploads it.
|
||||
let (a, b) = (temp("lib-a"), temp("lib-b"));
|
||||
let alpha = project(&a, "Alpha");
|
||||
assert!(sync::library_round(&a, &server).await.unwrap());
|
||||
let token = Share::load(&alpha).expect("shared by the library").token;
|
||||
sync::sync_once(&alpha).await.unwrap();
|
||||
|
||||
// Device B starts empty, joins what the library lists and downloads it.
|
||||
assert!(sync::library_round(&b, &server).await.unwrap());
|
||||
let joined = b.join(format!("shared-{}", &token[..8]));
|
||||
sync::sync_once(&joined).await.unwrap();
|
||||
assert_eq!(names(&b), ["Alpha"]);
|
||||
|
||||
// Another round changes nothing (no duplicate join, no re-share).
|
||||
sync::library_round(&b, &server).await.unwrap();
|
||||
sync::library_round(&a, &server).await.unwrap();
|
||||
assert_eq!(names(&a), ["Alpha"]);
|
||||
assert_eq!(names(&b), ["Alpha"]);
|
||||
|
||||
// B deletes it on the server: the data is kept in .trash, A is told it is deleted.
|
||||
let http = reqwest::Client::new();
|
||||
let res = http.delete(format!("{server}/p/{token}")).send().await.unwrap();
|
||||
assert_eq!(res.status(), 204);
|
||||
assert!(data.join(".trash").read_dir().unwrap().next().is_some());
|
||||
let listed = http.get(format!("{server}/library")).send().await.unwrap().text().await.unwrap();
|
||||
assert!(!listed.contains(&token), "{listed}");
|
||||
match sync::sync_once(&alpha).await {
|
||||
Err(sync::SyncError::Gone(sync::Gone::Deleted)) => {}
|
||||
other => panic!("expected Gone(Deleted), got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn a_server_that_lost_its_data_does_not_delete_projects() {
|
||||
let data = temp("lost-data");
|
||||
let (_relay, server) = library_relay(&data, true);
|
||||
let a = temp("lost-a");
|
||||
let proj = project(&a, "Keep me");
|
||||
sync::library_round(&a, &server).await.unwrap();
|
||||
sync::sync_once(&proj).await.unwrap();
|
||||
|
||||
// The server's disk is wiped.
|
||||
let token = Share::load(&proj).unwrap().token;
|
||||
std::fs::remove_dir_all(data.join(&token)).unwrap();
|
||||
match sync::sync_once(&proj).await {
|
||||
Err(sync::SyncError::Gone(sync::Gone::Lost)) => {}
|
||||
other => panic!("expected Gone(Lost), got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn a_relay_without_library_says_so() {
|
||||
let (_relay, server) = library_relay(&temp("plain-data"), false);
|
||||
let a = temp("plain-a");
|
||||
project(&a, "Private");
|
||||
assert!(!sync::library_round(&a, &server).await.unwrap());
|
||||
// Nothing was shared behind the user's back.
|
||||
assert!(names(&a).len() == 1);
|
||||
let projects = papercraft::project::Store::open(&a).unwrap().list();
|
||||
assert!(Share::load(&projects[0].dir).is_none());
|
||||
}
|
||||
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