Files
Marcos Paulo 567ee63c7a feat: export sites, translate pages, and harden agent tools and sync
New agent tools (98 to 104):
- export_site writes a website from frames in one call: plain HTML,
  Astro, Vue (Nuxt or Vite), React (Next or Vite) or Angular; css, scss
  or tailwind, and for React vanilla-extract, styled-components or
  emotion; static pages or a single-page app; a folder per language.
  Fonts are subset to WOFF2 with metric-matched fallbacks, images are
  resized, headings follow the outline, pages reflow below the designed
  width, and _headers, vercel.json, _redirects and .nojekyll cache
  hashed assets for a year.
- get_strings and set_strings treat languages as variable modes: read a
  page's text for translation, fill a language or a pseudo-locale.
- get_context says where the agent is, what is cheap to read next and
  what lint found, in one call; big reads take maxTokens and report
  estimatedTokens.
- import_html opens a page from a prompt-to-design tool (.html or .zip)
  as layers.
- install_font adds fonts on top of the bundled Inter; text can name one.

App:
- Present mode plays a page's frames as a prototype: links jump to
  frames, open overlays, go back or open an address.
- Panics are kept in crash.log; release builds keep line tables so its
  traces name files and lines.
- Settings > Canvas is kept between sessions; a keyboard shortcuts
  sheet; icons in the menus.
- Chat: an opencode turn waiting out a rate limit moves on to the
  fallback model.
- The updater checks each download against the SHA-256 GitHub publishes.

Fixes:
- A NaN or infinite tool argument no longer makes a project unopenable.
- Agent paths cannot reach other machines, devices or NTFS streams; a
  tool writes only its own file type, and fills only a new, empty or
  earlier export folder.
- delegate and imagegen pass each value in its own environment
  variable, never as shell syntax.
- An instance placed inside its own component is refused.
- A panic in a tool fails that call instead of closing the app.
- An agent call during a drag waits for it, so the undo step stays whole.
- Sync holds downloads while the open project has unsaved edits, writes
  pages atomically, and leaves a page saved while its download was in
  flight for the next round to merge. Merges repair the tree instead
  of giving a node two parents or orphaning it.
- Share tokens come from the OS random generator.
- Imports have a total memory cap; Excalidraw import is linear.
- Unused images are freed when a project opens.
- Pages that use no added font are still written as format 3, so 0.9.1
  can open them.
- Code export escapes text, attributes and CSS in one place.

CI: a clippy job (warnings do not fail it yet).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J9PiVCQxc2QYmArFq9VBUX
2026-11-06 10:29:43 -03:00

400 lines
15 KiB
Rust

//! 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"), r#"{"manifest":1}"#).unwrap();
std::fs::write(a.join("pages/p1.json"), r#"{"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(r#"{"manifest":1}"#)
);
assert_eq!(
read(&b, "pages/p1.json").as_deref(),
Some(r#"{"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"), r#"{"page":"one, edited in B"}"#).unwrap();
std::fs::write(b.join("pages/p2.json"), r#"{"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(r#"{"page":"one, edited in B"}"#)
);
assert_eq!(
read(&a, "pages/p2.json").as_deref(),
Some(r#"{"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"), r#"{"from":"A"}"#).unwrap();
std::fs::write(b.join("pages/p1.json"), r#"{"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());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn stop_sharing_detaches_this_folder_but_leaves_the_relay_copy() {
let data = temp("unshare-data");
let running = relay::spawn_with_data(
"127.0.0.1:0".parse().unwrap(),
None,
None,
Some(Data {
dir: data,
library: false,
}),
)
.unwrap();
let server = format!("http://127.0.0.1:{}", running.port());
let a = temp("unshare-a");
std::fs::create_dir_all(a.join("pages")).unwrap();
std::fs::write(a.join("project.json"), r#"{"manifest":1}"#).unwrap();
std::fs::write(a.join("pages/p1.json"), r#"{"page":"one"}"#).unwrap();
let share = Share::new(&server);
share.save(&a).unwrap();
sync::sync_once(&a).await.unwrap();
let mut engine = sync::Sync::default();
engine.unshare(&a);
assert!(Share::load(&a).is_none(), "the share marker is gone");
// Later local edits never reach the relay...
std::fs::write(
a.join("pages/p1.json"),
r#"{"page":"edited after unsharing"}"#,
)
.unwrap();
let b = temp("unshare-b");
Share::from_link(&share.link()).unwrap().save(&b).unwrap();
sync::sync_once(&b).await.unwrap();
// ...and the copy already on the relay is still there for whoever holds the link.
assert_eq!(
read(&b, "pages/p1.json").as_deref(),
Some(r#"{"page":"one"}"#)
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn two_people_editing_one_page_keep_both_changes() {
let running = relay::spawn_with_data(
"127.0.0.1:0".parse().unwrap(),
None,
None,
Some(Data {
dir: temp("merge-data"),
library: false,
}),
)
.unwrap();
let server = format!("http://127.0.0.1:{}", running.port());
let page = |a: i64, b: i64, extra: &str| {
format!(
r#"{{"next_id":3,"root":[1,2],"nodes":[{{"id":1,"x":{a}}},{{"id":2,"x":{b}}}{extra}]}}"#
)
};
let a = temp("merge-a");
std::fs::create_dir_all(a.join("pages")).unwrap();
std::fs::write(a.join("project.json"), r#"{"manifest":1}"#).unwrap();
std::fs::write(a.join("pages/p1.json"), page(0, 0, "")).unwrap();
let share = Share::new(&server);
share.save(&a).unwrap();
sync::sync_once(&a).await.unwrap();
let b = temp("merge-b");
Share::from_link(&share.link()).unwrap().save(&b).unwrap();
sync::sync_once(&b).await.unwrap();
// Both edit the same page at the same time: A moves node 1, B moves node 2.
std::fs::write(a.join("pages/p1.json"), page(10, 0, "")).unwrap();
std::fs::write(b.join("pages/p1.json"), page(0, 20, "")).unwrap();
sync::sync_once(&b).await.unwrap();
assert!(
sync::sync_once(&a).await.unwrap(),
"A applied B's change by merging"
);
sync::sync_once(&b).await.unwrap();
for dir in [&a, &b] {
let v: serde_json::Value =
serde_json::from_str(&read(dir, "pages/p1.json").unwrap()).unwrap();
assert_eq!(v["nodes"][0]["x"], 10, "A's edit survived in {dir:?}");
assert_eq!(v["nodes"][1]["x"], 20, "B's edit survived in {dir:?}");
}
}
async fn merge_pair(label: &str, page: &str) -> (relay::Running, PathBuf, PathBuf) {
let running = relay::spawn_with_data(
"127.0.0.1:0".parse().unwrap(),
None,
None,
Some(Data {
dir: temp(&format!("{label}-data")),
library: false,
}),
)
.unwrap();
let server = format!("http://127.0.0.1:{}", running.port());
let a = temp(&format!("{label}-a"));
std::fs::create_dir_all(a.join("pages")).unwrap();
std::fs::write(a.join("project.json"), r#"{"manifest":1}"#).unwrap();
std::fs::write(a.join("pages/p1.json"), page).unwrap();
let share = Share::new(&server);
share.save(&a).unwrap();
sync::sync_once(&a).await.unwrap();
let b = temp(&format!("{label}-b"));
Share::from_link(&share.link()).unwrap().save(&b).unwrap();
sync::sync_once(&b).await.unwrap();
(running, a, b)
}
fn fake_page(a: i64, b: i64) -> String {
format!(r#"{{"next_id":3,"root":[1,2],"nodes":[{{"id":1,"x":{a}}},{{"id":2,"x":{b}}}]}}"#)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_held_download_waits_for_the_save_and_then_merges() {
use std::sync::atomic::AtomicBool;
let (_relay, a, b) = merge_pair("hold", &fake_page(0, 0)).await;
std::fs::write(b.join("pages/p1.json"), fake_page(0, 20)).unwrap();
sync::sync_once(&b).await.unwrap();
// A has unsaved edits: B's change must not land in the folder yet.
let hold = AtomicBool::new(true);
assert!(!sync::sync_once_holding(&a, &hold).await.unwrap());
assert_eq!(read(&a, "pages/p1.json").unwrap(), fake_page(0, 0));
// A saves its edit, the hold lifts, and both edits meet in the merge.
std::fs::write(a.join("pages/p1.json"), fake_page(10, 0)).unwrap();
hold.store(false, std::sync::atomic::Ordering::Relaxed);
assert!(sync::sync_once_holding(&a, &hold).await.unwrap());
let v: serde_json::Value = serde_json::from_str(&read(&a, "pages/p1.json").unwrap()).unwrap();
assert_eq!((v["nodes"][0]["x"].as_i64(), v["nodes"][1]["x"].as_i64()), (Some(10), Some(20)));
}
fn real_page(nodes: &str, root: &str) -> String {
format!(
r#"{{"format":"paper-rs-page","version":4,"id":"p1","name":"P","next_id":10,"root":{root},"nodes":[{nodes}]}}"#
)
}
fn frame(id: u64, children: &str) -> String {
format!(
r#"{{"id":{id},"name":"F{id}","type":"frame","rect":{{"x":0,"y":0,"w":50,"h":50}},"children":{children}}}"#
)
}
fn rect(id: u64, parent: Option<u64>) -> String {
let parent = parent.map(|p| format!(r#","parent":{p}"#)).unwrap_or_default();
format!(r#"{{"id":{id},"name":"R{id}","type":"rect","rect":{{"x":0,"y":0,"w":5,"h":5}}{parent}}}"#)
}
async fn assert_opens(dir: &Path) -> papercraft::project::format::PageData {
let text = read(dir, "pages/p1.json").unwrap();
papercraft::project::format::page_from_json(&text).expect("the merged page opens")
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_layer_moved_into_two_frames_still_opens() {
let base = real_page(&format!("{},{},{}", frame(1, "[]"), frame(2, "[]"), rect(3, None)), "[1,2,3]");
let (_relay, a, b) = merge_pair("two-parents", &base).await;
let into = |frame_id: u64| {
let (f1, f2) = if frame_id == 1 { ("[3]", "[]") } else { ("[]", "[3]") };
real_page(
&format!("{},{},{}", frame(1, f1), frame(2, f2), rect(3, Some(frame_id))),
"[1,2]",
)
};
std::fs::write(a.join("pages/p1.json"), into(1)).unwrap();
std::fs::write(b.join("pages/p1.json"), into(2)).unwrap();
sync::sync_once(&b).await.unwrap();
sync::sync_once(&a).await.unwrap();
sync::sync_once(&b).await.unwrap();
for dir in [&a, &b] {
assert_opens(dir).await.doc.validate().unwrap();
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_layer_added_to_a_deleted_frame_is_kept_and_the_page_opens() {
let base = real_page(&frame(1, "[]"), "[1]");
let (_relay, a, b) = merge_pair("orphan", &base).await;
std::fs::write(a.join("pages/p1.json"), real_page("", "[]")).unwrap();
let added = real_page(&format!("{},{}", frame(1, "[2]"), rect(2, Some(1))), "[1]");
std::fs::write(b.join("pages/p1.json"), added).unwrap();
sync::sync_once(&b).await.unwrap();
sync::sync_once(&a).await.unwrap();
sync::sync_once(&b).await.unwrap();
for dir in [&a, &b] {
let page = assert_opens(dir).await;
assert!(page.doc.nodes.contains_key(&papercraft::model::NodeId(2)), "the new layer survived");
}
}